Skip to content

Commit 20c4d20

Browse files
authored
Merge pull request recogito#123 from oleksandr-danylchenko/spatial-tree-positioning
Made `TextAnnotatorPopup` resilient to the underlying DOM mutations
2 parents feef3e8 + 2ee2f96 commit 20c4d20

File tree

7 files changed

+83
-33
lines changed

7 files changed

+83
-33
lines changed

packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
22
import { useAnnotator, useSelection } from '@annotorious/react';
3-
import { isRevived, NOT_ANNOTATABLE_CLASS, TextAnnotation, TextAnnotator } from '@recogito/text-annotator';
3+
import {
4+
NOT_ANNOTATABLE_CLASS,
5+
denormalizeRectWithOffset,
6+
toDomRectList,
7+
type TextAnnotation,
8+
type TextAnnotator,
9+
} from '@recogito/text-annotator';
10+
411
import { isMobile } from './isMobile';
512
import {
613
arrow,
@@ -64,8 +71,8 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => {
6471
shift({ crossAxis: true, padding: 10 })
6572
];
6673

67-
return props.arrow
68-
? [...m, arrow({ element: arrowRef }) ]
74+
return props.arrow
75+
? [...m, arrow({ element: arrowRef }) ]
6976
: m;
7077
}, [props.arrow]);
7178

@@ -89,26 +96,37 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => {
8996
const { getFloatingProps } = useInteractions([dismiss, role]);
9097

9198
useEffect(() => {
92-
const annotationSelector = annotation?.target.selector;
93-
setOpen(annotationSelector?.length > 0 ? isRevived(annotationSelector) : false);
94-
}, [annotation]);
99+
if (annotation?.id) {
100+
const bounds = r?.state.store.getAnnotationBounds(annotation.id);
101+
setOpen(Boolean(bounds));
102+
} else {
103+
setOpen(false);
104+
}
105+
}, [annotation?.id, r?.state.store]);
95106

96107
useEffect(() => {
97-
if (isOpen && annotation) {
98-
const {
99-
target: {
100-
selector: [{ range }]
101-
}
102-
} = annotation;
108+
if (!r) return;
103109

110+
if (isOpen && annotation?.id) {
104111
refs.setPositionReference({
105-
getBoundingClientRect: () => range.getBoundingClientRect(),
106-
getClientRects: () => range.getClientRects()
112+
getBoundingClientRect: () => {
113+
const bounds = r.state.store.getAnnotationBounds(annotation.id);
114+
return bounds
115+
? denormalizeRectWithOffset(bounds, r.element.getBoundingClientRect())
116+
: new DOMRect();
117+
},
118+
getClientRects: () => {
119+
const rects = r.state.store.getAnnotationRects(annotation.id);
120+
const denormalizedRects = rects.map((rect) =>
121+
denormalizeRectWithOffset(rect, r.element.getBoundingClientRect())
122+
);
123+
return toDomRectList(denormalizedRects);
124+
}
107125
});
108126
} else {
109127
refs.setPositionReference(null);
110128
}
111-
}, [isOpen, annotation, refs]);
129+
}, [isOpen, annotation?.id, annotation?.target, r]);
112130

113131
useEffect(() => {
114132
const config: MutationObserverInit = { attributes: true, childList: true, subtree: true };
@@ -151,9 +169,9 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => {
151169
})}
152170

153171
{props.arrow && (
154-
<FloatingArrow
172+
<FloatingArrow
155173
ref={arrowRef}
156-
context={context}
174+
context={context}
157175
{...(props.arrowProps || {})} />
158176
)}
159177

packages/text-annotator/src/state/TextAnnotationStore.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export interface TextAnnotationStore<T extends TextAnnotation = TextAnnotation>
1111

1212
bulkUpsertAnnotations(annotations: T[], origin?: Origin): T[];
1313

14-
getAnnotationBounds(id: string, hintX?: number, hintY?: number, buffer?: number): DOMRect;
14+
getAnnotationRects(id: string): DOMRect[];
15+
16+
getAnnotationBounds(id: string, hintX?: number, hintY?: number, buffer?: number): DOMRect | undefined;
1517

1618
getAnnotationRects(id: string): DOMRect[];
1719

packages/text-annotator/src/state/TextAnnotatorState.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
} from '@annotorious/core';
1414
import { createSpatialTree } from './spatialTree';
1515
import type { TextAnnotation, TextAnnotationTarget } from '../model';
16-
import type { TextAnnotationStore } from './TextAnnotationStore';
16+
import type { AnnotationRects, TextAnnotationStore } from './TextAnnotationStore';
1717
import { isRevived, reviveAnnotation, reviveTarget } from '../utils';
1818

1919
export interface TextAnnotatorState<I extends TextAnnotation = TextAnnotation, E extends unknown = TextAnnotation> extends AnnotatorState<I, E> {
@@ -114,7 +114,7 @@ export const createTextAnnotatorState = <I extends TextAnnotation = TextAnnotati
114114
return all ? filtered : filtered[0];
115115
}
116116

117-
const getAnnotationBounds = (id: string, x?: number, y?: number, buffer = 5): DOMRect => {
117+
const getAnnotationBounds = (id: string, x?: number, y?: number, buffer = 5): DOMRect | undefined => {
118118
const rects = tree.getAnnotationRects(id);
119119
if (rects.length === 0) return;
120120

@@ -129,6 +129,13 @@ export const createTextAnnotatorState = <I extends TextAnnotation = TextAnnotati
129129
return tree.getAnnotationBounds(id);
130130
}
131131

132+
const getIntersecting = (
133+
minX: number,
134+
minY: number,
135+
maxX: number,
136+
maxY: number,
137+
): AnnotationRects<I>[] => tree.getIntersecting(minX, minY, maxX, maxY);
138+
132139
const getAnnotationRects = (id: string): DOMRect[] => tree.getAnnotationRects(id);
133140

134141
const recalculatePositions = () => tree.recalculate();
@@ -157,8 +164,8 @@ export const createTextAnnotatorState = <I extends TextAnnotation = TextAnnotati
157164
bulkUpsertAnnotations,
158165
getAnnotationBounds,
159166
getAnnotationRects,
167+
getIntersecting,
160168
getAt,
161-
getIntersecting: tree.getIntersecting,
162169
recalculatePositions,
163170
updateTarget
164171
},

packages/text-annotator/src/state/spatialTree.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import RBush from 'rbush';
22
import type { Store } from '@annotorious/core';
33
import type { TextAnnotation, TextAnnotationTarget } from '../model';
4-
import { isRevived, mergeClientRects } from '../utils';
5-
import { reviveSelector } from '../utils';
4+
import {
5+
isRevived,
6+
reviveSelector,
7+
mergeClientRects,
8+
normalizeRectWithOffset
9+
} from '../utils';
610
import type { AnnotationRects } from './TextAnnotationStore';
711

812
interface IndexedHighlightRect {
@@ -38,11 +42,11 @@ export const createSpatialTree = <T extends TextAnnotation>(store: Store<T>, con
3842
return Array.from(revivedRange.getClientRects());
3943
});
4044

41-
const merged = mergeClientRects(rects)
42-
// Offset the merged client rects so that coords
43-
// are relative to the parent container
44-
.map(({ left, top, right, bottom }) =>
45-
new DOMRect(left - offset.left, top - offset.top, right - left, bottom - top));
45+
/**
46+
* Offset the merged client rects so that coords
47+
* are relative to the parent container
48+
*/
49+
const merged = mergeClientRects(rects).map(rect => normalizeRectWithOffset(rect, offset));
4650

4751
return merged.map(rect => {
4852
const { x, y, width, height } = rect;
@@ -145,7 +149,7 @@ export const createSpatialTree = <T extends TextAnnotation>(store: Store<T>, con
145149

146150
const getAnnotationRects = (id: string): DOMRect[] => {
147151
const indexed = index.get(id);
148-
if (indexed) {
152+
if (indexed[0]) {
149153
// Reminder: *each* IndexedHighlightRect stores *all*
150154
// DOMRects for the annotation for convenience
151155
return indexed[0].annotation.rects;

packages/text-annotator/src/utils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export * from './reviveSelector';
1414
export * from './reviveTarget';
1515
export * from './splitAnnotatableRanges';
1616
export * from './trimRangeToContainer';
17-
17+
export * from './cloneEvents';
18+
export * from './normalizeRects';
1819

packages/text-annotator/src/utils/mergeClientRects.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const union = (a: DOMRect, b: DOMRect): DOMRect => {
6565
return new DOMRect(left, top, right - left, bottom - top);
6666
}
6767

68-
export const mergeClientRects = (rects: DOMRect[]) => rects.reduce((merged, rectA) => {
68+
export const mergeClientRects = (rects: DOMRect[]) => rects.reduce<DOMRect[]>((merged, rectA) => {
6969
// Some browser report empty rects - discard
7070
if (rectA.width === 0 || rectA.height === 0)
7171
return merged;
@@ -102,7 +102,16 @@ export const mergeClientRects = (rects: DOMRect[]) => rects.reduce((merged, rect
102102
}
103103

104104
return wasMerged ? next : [ ...next, rectA ];
105-
}, [] as DOMRect[]);
105+
}, []);
106+
107+
export const toDomRectList = (rects: DOMRect[]): DOMRectList => ({
108+
length: rects.length,
109+
item: (index) => rects[index],
110+
[Symbol.iterator]: function* (): ArrayIterator<DOMRect> {
111+
for (let i = 0; i < this.length; i++)
112+
yield this.item(i)!;
113+
}
114+
})
106115

107116
/* Pixels that rects can be apart vertically while still
108117
// being considered to be on the same line.
@@ -142,7 +151,7 @@ export const mergeClientRects = (rects: DOMRect[]) => {
142151
}).filter(r => r.height > 0 && r.width > 0);
143152
144153
// Checks if the given rect contains any other rects
145-
const containsOthers = (rect: DOMRect) => mergedRects.some(other =>
154+
const containsOthers = (rect: DOMRect) => mergedRects.some(other =>
146155
other !== rect &&
147156
other.left >= rect.left &&
148157
other.right <= rect.right &&
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const normalizeRectWithOffset = (rect: DOMRect, offset: DOMRect): DOMRect => {
2+
const { left, top, right, bottom } = rect;
3+
return new DOMRect(left - offset.left, top - offset.top, right - left, bottom - top);
4+
};
5+
6+
export const denormalizeRectWithOffset = (rect: DOMRect, offset: DOMRect): DOMRect => {
7+
const { left, top, right, bottom } = rect;
8+
return new DOMRect(left + offset.left, top + offset.top, right - left, bottom - top);
9+
}

0 commit comments

Comments
 (0)