@blocksdiy/react-common 1.71.0 → 1.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/comments/anchor.d.ts +23 -1
  2. package/dist/comments/anchor.d.ts.map +1 -1
  3. package/dist/comments/anchor.js +74 -7
  4. package/dist/comments/comment-mode.d.ts +22 -4
  5. package/dist/comments/comment-mode.d.ts.map +1 -1
  6. package/dist/comments/comment-mode.js +22 -5
  7. package/dist/comments/index.d.ts +9 -3
  8. package/dist/comments/index.d.ts.map +1 -1
  9. package/dist/comments/index.js +9 -3
  10. package/dist/comments/menu.d.ts +33 -0
  11. package/dist/comments/menu.d.ts.map +1 -0
  12. package/dist/comments/menu.js +47 -0
  13. package/dist/comments/open-comment.d.ts +28 -0
  14. package/dist/comments/open-comment.d.ts.map +1 -0
  15. package/dist/comments/open-comment.js +9 -0
  16. package/dist/comments/page-pick.d.ts.map +1 -1
  17. package/dist/comments/page-pick.js +23 -7
  18. package/dist/comments/pin-view.d.ts +107 -0
  19. package/dist/comments/pin-view.d.ts.map +1 -0
  20. package/dist/comments/pin-view.js +248 -0
  21. package/dist/comments/pins.d.ts +66 -12
  22. package/dist/comments/pins.d.ts.map +1 -1
  23. package/dist/comments/pins.js +132 -39
  24. package/dist/comments/text-pick.d.ts +11 -1
  25. package/dist/comments/text-pick.d.ts.map +1 -1
  26. package/dist/comments/text-pick.js +143 -1
  27. package/dist/comments/types.d.ts +4 -1
  28. package/dist/comments/types.d.ts.map +1 -1
  29. package/dist/comments/use-comment-menu.d.ts +69 -0
  30. package/dist/comments/use-comment-menu.d.ts.map +1 -0
  31. package/dist/comments/use-comment-menu.js +140 -0
  32. package/dist/comments/use-held.d.ts +3 -0
  33. package/dist/comments/use-held.d.ts.map +1 -0
  34. package/dist/comments/use-held.js +16 -0
  35. package/dist/comments/use-thread-scroll.d.ts +23 -0
  36. package/dist/comments/use-thread-scroll.d.ts.map +1 -0
  37. package/dist/comments/use-thread-scroll.js +89 -0
  38. package/dist/iframe-communication/types.d.ts +3 -2
  39. package/dist/iframe-communication/types.d.ts.map +1 -1
  40. package/package.json +1 -1
@@ -6,25 +6,23 @@ import { clipRectsOf, toRect, usePageLayoutTick, visiblePart } from "./layout.js
6
6
  * every time the page moves, so a pin follows its element across screen sizes, re-sorted lists and
7
7
  * scrolling.
8
8
  */
9
+ /** The page's direction, as the comments read it (left to right with no page). */
10
+ const isPageRtl = () => typeof document !== "undefined" && getComputedStyle(document.documentElement).direction === "rtl";
9
11
  /** How far a pin keeps from the viewport's edges. */
10
12
  export const PIN_EDGE_MARGIN = 12;
11
13
  /**
12
- * The pin's point, in viewport coordinates.
13
- * - A quote: the end of its last line.
14
- * - An element: where it was clicked, scaled to the element's box now; else its top-right corner.
14
+ * The pin's point, in viewport coordinates: the top-end corner of what the comment is on (top-right
15
+ * on a left-to-right page, top-left with `isRtl`; without it, as the page reads), so the pin,
16
+ * standing above its point and past the end side (`Pin`), stays outside it.
17
+ * - A quote: its first line's corner (`firstLine`), the pin above the words, never over them;
18
+ * given only `lastLine` (a copy from before `firstLine`), that line's corner.
19
+ * - An element: its box's corner. Where it was clicked (`point`, stored on the anchor) doesn't
20
+ * place the pin; it is taken so a copy that passes it keeps working.
15
21
  */
16
22
  export const pinPoint = (target) => {
17
- if (target.kind === "quote") {
18
- return { x: target.lastLine.right, y: (target.lastLine.top + target.lastLine.bottom) / 2 };
19
- }
20
- const { rect, point } = target;
21
- if (!point) {
22
- return { x: rect.right, y: rect.top };
23
- }
24
- return {
25
- x: rect.left + point.x * (rect.right - rect.left),
26
- y: rect.top + point.y * (rect.bottom - rect.top),
27
- };
23
+ const box = target.kind === "quote" ? (target.firstLine ?? target.lastLine) : target.rect;
24
+ const isRtl = target.isRtl ?? isPageRtl();
25
+ return { x: isRtl ? box.left : box.right, y: box.top };
28
26
  };
29
27
  /** `point` kept `margin` inside the viewport. */
30
28
  export const keepInViewport = (point, viewport, margin = PIN_EDGE_MARGIN) => ({
@@ -66,36 +64,125 @@ const keyOf = (element) => {
66
64
  }
67
65
  return key;
68
66
  };
69
- /** Lays out one anchor whose element is known. */
70
- export const layoutAnchor = (anchor, resolved) => {
67
+ /**
68
+ * How far `box` lies past the window's edges: negative past the start (left, top), positive past
69
+ * the end, zero along an axis where it overlaps the window.
70
+ */
71
+ export const pastWindow = (box, viewport) => ({
72
+ x: Math.min(0, box.right) + Math.max(0, box.left - viewport.width),
73
+ y: Math.min(0, box.bottom) + Math.max(0, box.top - viewport.height),
74
+ });
75
+ const NO_SHIFT = { x: 0, y: 0 };
76
+ /** A scroll container's box, each edge at or past the window's taken on without end. */
77
+ const reachingWindowEdges = (clip, viewport) => ({
78
+ top: clip.top <= 0 ? -Infinity : clip.top,
79
+ left: clip.left <= 0 ? -Infinity : clip.left,
80
+ right: clip.right >= viewport.width ? Infinity : clip.right,
81
+ bottom: clip.bottom >= viewport.height ? Infinity : clip.bottom,
82
+ });
83
+ /** `rect` squeezed into `bounds`: each edge kept inside it (a line or a point at its edge when outside). */
84
+ const clampRect = (rect, bounds) => {
85
+ const clampX = (x) => Math.min(Math.max(x, bounds.left), bounds.right);
86
+ const clampY = (y) => Math.min(Math.max(y, bounds.top), bounds.bottom);
87
+ return { top: clampY(rect.top), left: clampX(rect.left), right: clampX(rect.right), bottom: clampY(rect.bottom) };
88
+ };
89
+ /**
90
+ * A quote's line boxes (several to a line where it crosses inline elements): its first line (the
91
+ * boxes level with the first one, as one) and the box around them all.
92
+ */
93
+ const quoteLines = (lines) => {
94
+ const first = lines[0];
95
+ const onFirstLine = lines.filter((line) => {
96
+ const middle = (line.top + line.bottom) / 2;
97
+ return middle >= first.top && middle <= first.bottom;
98
+ });
99
+ const around = (rects) => ({
100
+ top: Math.min(...rects.map((rect) => rect.top)),
101
+ left: Math.min(...rects.map((rect) => rect.left)),
102
+ right: Math.max(...rects.map((rect) => rect.right)),
103
+ bottom: Math.max(...rects.map((rect) => rect.bottom)),
104
+ });
105
+ return { firstLine: around(onFirstLine), bounds: around(lines) };
106
+ };
107
+ /**
108
+ * Where a comment's pin goes and what its panel keeps clear of, measured now: the top-end corner of
109
+ * its quote's first line, else of its element's box (`pinPoint`), kept on the part of the element in
110
+ * view and inside the window. A thread's pin (`layoutAnchor`) and a new comment's (`pickPlacement`)
111
+ * both come from here.
112
+ */
113
+ const measure = (element, range, isRtl) => {
114
+ const box = toRect(element.getBoundingClientRect());
115
+ const clips = clipRectsOf(element);
116
+ const visible = visiblePart(box, clips);
117
+ const boxes = range
118
+ ? Array.from(range.getClientRects())
119
+ .filter((rect) => rect.width > 0 || rect.height > 0)
120
+ .map(toRect)
121
+ : [];
122
+ // A drag from just past a line's last letter leaves an empty box at that line's end: not where
123
+ // the words start, unless nothing else is there.
124
+ const wide = boxes.filter((rect) => rect.right > rect.left);
125
+ const lines = wide.length > 0 ? wide : boxes;
126
+ const quote = lines.length > 0 ? quoteLines(lines) : null;
127
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
128
+ const corner = quote
129
+ ? pinPoint({ kind: "quote", firstLine: quote.firstLine, lastLine: lines.at(-1), isRtl })
130
+ : pinPoint({ kind: "element", rect: box, isRtl });
131
+ const shown = visible ?? clips[0];
132
+ const point = keepInViewport({
133
+ x: Math.min(Math.max(corner.x, shown.left), shown.right),
134
+ y: Math.min(Math.max(corner.y, shown.top), shown.bottom),
135
+ }, viewport);
136
+ return { box, clips, visible, viewport, point, target: clampRect(quote?.bounds ?? box, shown) };
137
+ };
138
+ /** Lays out one anchor whose element is known, on a page that reads right to left with `isRtl`. */
139
+ export const layoutAnchor = (resolved, isRtl) => {
71
140
  const { element, state, range, currentText } = resolved;
72
141
  if (!element) {
73
142
  return { state, element: null, currentText, ...HIDDEN };
74
143
  }
75
- const box = toRect(element.getBoundingClientRect());
76
- const visible = visiblePart(box, clipRectsOf(element));
77
- if (!visible || !isUncovered(element, visible)) {
78
- return { state, element, currentText, ...HIDDEN };
144
+ const { box, clips, visible, viewport, point, target } = measure(element, range, isRtl);
145
+ if (!visible) {
146
+ // Off screen: only the window hides it. A scroll container's edge counts as the window's only
147
+ // where it reaches the window's edge (a page that scrolls in a full-window container); outside
148
+ // a container through an edge inside the window (below a fixed header, a list mid-page), it is
149
+ // clipped, and stays so once its box leaves the window too: its pin never pops back at the
150
+ // window's edge. So is an element with no box at all.
151
+ const [windowClip, ...containers] = clips;
152
+ const isBox = box.right > box.left && box.bottom > box.top;
153
+ const isOffscreen = isBox &&
154
+ !visiblePart(box, [windowClip]) &&
155
+ !!visiblePart(box, containers.map((clip) => reachingWindowEdges(clip, viewport)));
156
+ const hidden = isOffscreen
157
+ ? { by: "offscreen", point, shift: pastWindow(box, viewport) }
158
+ : { by: "clipped", point, shift: NO_SHIFT };
159
+ return { state, element, currentText, target, ...HIDDEN, hidden };
79
160
  }
80
- const lines = range ? Array.from(range.getClientRects()).filter((rect) => rect.width > 0 || rect.height > 0) : [];
81
- const lastLine = lines.at(-1);
82
- const point = lastLine
83
- ? pinPoint({ kind: "quote", lastLine: toRect(lastLine) })
84
- : pinPoint({ kind: "element", rect: box, point: anchor.point });
85
- return {
86
- state,
87
- element,
88
- currentText,
89
- point: keepInViewport(point, { width: window.innerWidth, height: window.innerHeight }),
90
- groupKey: keyOf(element),
91
- };
161
+ if (!isUncovered(element, visible)) {
162
+ return { state, element, currentText, target, ...HIDDEN, hidden: { by: "covered", point, shift: NO_SHIFT } };
163
+ }
164
+ return { state, element, currentText, target, point, groupKey: keyOf(element) };
165
+ };
166
+ /**
167
+ * Where a new comment's pin goes and what its panel keeps clear of, as it is picked (`usePagePick`'s
168
+ * `onPick`): the same corner and target as a thread on it gets (`layoutAnchor`), its quote found in
169
+ * `element`. Reads the page; call it from the pick, not while rendering.
170
+ */
171
+ export const pickPlacement = (anchor, element, isRtl = isPageRtl()) => {
172
+ const { point, target } = measure(element, checkText(anchor, element).range, isRtl);
173
+ return { point, target };
92
174
  };
93
175
  /** The element each anchor was last found at: an anchor's object stays the same while its thread is unchanged. */
94
176
  const foundByAnchor = new WeakMap();
95
- /** Finds an anchor again, re-checking only its text while its element is still in the page and still in the same row. */
96
- const resolveCached = (anchor) => {
177
+ /**
178
+ * Finds an anchor again, re-checking only its text while its element is still in the page and
179
+ * still inside the same row. One found on the row itself is found anew each time: what the row
180
+ * must say depends on how the thread came to be on it (`resolveAnchor`).
181
+ */
182
+ export const resolveAnchorCached = (anchor) => {
97
183
  const known = foundByAnchor.get(anchor);
98
- const stillThere = known?.isConnected && (!anchor.item || findAnchorItemElement(anchor)?.contains(known) === true);
184
+ const row = anchor.item ? findAnchorItemElement(anchor) : null;
185
+ const stillThere = known?.isConnected && (!anchor.item || (row !== known && row?.contains(known) === true));
99
186
  const resolved = stillThere ? checkText(anchor, known) : resolveAnchor(anchor);
100
187
  if (resolved.element) {
101
188
  foundByAnchor.set(anchor, resolved.element);
@@ -105,12 +192,18 @@ const resolveCached = (anchor) => {
105
192
  }
106
193
  return resolved;
107
194
  };
108
- /** Where each thread is on the page now, re-measured at most once per frame while `isActive`. */
109
- export const useCommentPins = (anchors, isActive) => {
195
+ /**
196
+ * Where each thread is on the page now, re-measured at most once per frame while `isActive`, on a
197
+ * page that reads right to left with `isRtl` (the caller's window as it read it; read here on each
198
+ * render when absent).
199
+ */
200
+ export const useCommentPins = (anchors, isActive, isRtl) => {
110
201
  // Not for the React Compiler (app-render compiles this module): the memo's input is the page's
111
202
  // layout, read through `tick`, which the compiler would drop from the deps as unused.
112
203
  "use no memo";
113
204
  const tick = usePageLayoutTick(isActive);
205
+ // A change of direction moves no tick: it is a dependency of its own.
206
+ const rtl = isRtl ?? (isActive && isPageRtl());
114
207
  return useMemo(() => {
115
208
  void tick;
116
209
  const layouts = new Map();
@@ -118,8 +211,8 @@ export const useCommentPins = (anchors, isActive) => {
118
211
  return layouts;
119
212
  }
120
213
  for (const { id, anchor } of anchors) {
121
- layouts.set(id, layoutAnchor(anchor, resolveCached(anchor)));
214
+ layouts.set(id, layoutAnchor(resolveAnchorCached(anchor), rtl));
122
215
  }
123
216
  return layouts;
124
- }, [anchors, isActive, tick]);
217
+ }, [anchors, isActive, tick, rtl]);
125
218
  };
@@ -39,7 +39,17 @@ export declare const rangeBetween: (doc: Document, a: Boundary, b: Boundary) =>
39
39
  export declare const dragRootOf: (node: Node) => Element | null;
40
40
  /** `point` kept inside `root`: a position past its end is its end, one before its start is its start. */
41
41
  export declare const clampToRoot: (root: Element, point: Boundary) => Boundary;
42
- /** The caret position the browser puts under `(x, y)`, when it is in a text node. */
42
+ /** The most characters a lookup measures to correct the browser's answer before keeping it. */
43
+ export declare const CARET_SCAN_MAX = 256;
44
+ /**
45
+ * The caret position the browser puts under `(x, y)`, when it is in a text node. Where a run reads
46
+ * the other way than its line (Latin words on a right-to-left page), the browser can answer at a
47
+ * run's edge with a position beside other characters, far from the pointer (Chrome: the line's
48
+ * end for the left half of the run's first letter, the run's start for the right half of its
49
+ * last): when neither character beside its answer is under the pointer, the one that is decides,
50
+ * found near the answer (`CARET_SCAN_MAX`), else the answer stands. Characters are what a reader
51
+ * sees as one (an emoji, a letter with its marks): a corrected position is never inside one.
52
+ */
43
53
  export declare const caretFromPoint: (doc: Document, x: number, y: number) => TextPoint | null;
44
54
  /**
45
55
  * The text position under the pointer, only when the pointer is ON the text: the browser answers
@@ -1 +1 @@
1
- {"version":3,"file":"text-pick.d.ts","sourceRoot":"","sources":["../../src/comments/text-pick.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,IAAI,EAAU,MAAM,UAAU,CAAC;AAE7C;;;;;GAKG;AAEH,mEAAmE;AACnE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,8EAA8E;AAC9E,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,uFAAuF;AACvF,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAgBnC;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,EAAE,QAAQ,MAAM,EAAE,SAAS,MAAM,KAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAoC7G,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,cAAc,GAAI,GAAG,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,SAAS,IAAI,EAAE,EAAE,aAAQ,KAAG,OAGrF,CAAC;AAEJ,mDAAmD;AACnD,eAAO,MAAM,OAAO,GAAI,GAAG,QAAQ,EAAE,GAAG,QAAQ,KAAG,OAMlD,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,YAAY,GAAI,KAAK,QAAQ,EAAE,GAAG,QAAQ,EAAE,GAAG,QAAQ,KAAG,KAMtE,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,GAAI,MAAM,IAAI,KAAG,OAAO,GAAG,IAGjD,CAAC;AAEF,yGAAyG;AACzG,eAAO,MAAM,WAAW,GAAI,MAAM,OAAO,EAAE,OAAO,QAAQ,KAAG,QAM5D,CAAC;AAOF,qFAAqF;AACrF,eAAO,MAAM,cAAc,GAAI,KAAK,QAAQ,EAAE,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,SAAS,GAAG,IAchF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,QAAQ,EAAE,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,SAAS,GAAG,IAgB7E,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,WAAW,GAAI,KAAK,QAAQ,EAAE,OAAO,SAAS,KAAG,KAAK,GAAG,IASrE,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,WAAW,GAAI,KAAK,QAAQ,EAAE,OAAO,SAAS,KAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAmB1G,CAAC"}
1
+ {"version":3,"file":"text-pick.d.ts","sourceRoot":"","sources":["../../src/comments/text-pick.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,IAAI,EAAU,MAAM,UAAU,CAAC;AAE7C;;;;;GAKG;AAEH,mEAAmE;AACnE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,8EAA8E;AAC9E,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,uFAAuF;AACvF,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAgBnC;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,EAAE,QAAQ,MAAM,EAAE,SAAS,MAAM,KAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAoC7G,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,cAAc,GAAI,GAAG,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,SAAS,IAAI,EAAE,EAAE,aAAQ,KAAG,OAGrF,CAAC;AAEJ,mDAAmD;AACnD,eAAO,MAAM,OAAO,GAAI,GAAG,QAAQ,EAAE,GAAG,QAAQ,KAAG,OAMlD,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,YAAY,GAAI,KAAK,QAAQ,EAAE,GAAG,QAAQ,EAAE,GAAG,QAAQ,KAAG,KAMtE,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,GAAI,MAAM,IAAI,KAAG,OAAO,GAAG,IAGjD,CAAC;AAEF,yGAAyG;AACzG,eAAO,MAAM,WAAW,GAAI,MAAM,OAAO,EAAE,OAAO,QAAQ,KAAG,QAM5D,CAAC;AAOF,+FAA+F;AAC/F,eAAO,MAAM,cAAc,MAAM,CAAC;AA6IlC;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,QAAQ,EAAE,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,SAAS,GAAG,IAYhF,CAAC;AAkBF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,QAAQ,EAAE,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,SAAS,GAAG,IAgB7E,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,WAAW,GAAI,KAAK,QAAQ,EAAE,OAAO,SAAS,KAAG,KAAK,GAAG,IASrE,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,WAAW,GAAI,KAAK,QAAQ,EAAE,OAAO,SAAS,KAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAmB1G,CAAC"}
@@ -91,8 +91,150 @@ export const clampToRoot = (root, point) => {
91
91
  const end = { node: root, offset: root.childNodes.length };
92
92
  return isAfter(point, end) ? end : { node: root, offset: 0 };
93
93
  };
94
- /** The caret position the browser puts under `(x, y)`, when it is in a text node. */
94
+ /** The most characters a lookup measures to correct the browser's answer before keeping it. */
95
+ export const CARET_SCAN_MAX = 256;
96
+ let graphemes;
97
+ const graphemeSegmenter = () => {
98
+ if (graphemes === undefined) {
99
+ graphemes =
100
+ typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
101
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
102
+ : null;
103
+ }
104
+ return graphemes;
105
+ };
106
+ const clustersByNode = new WeakMap();
107
+ /**
108
+ * Where each character of `node`'s text starts as a reader sees it (a grapheme cluster: an emoji,
109
+ * a letter with its marks), then the text's end. Without `Intl.Segmenter`, code points. Kept per
110
+ * node while its text is the same.
111
+ */
112
+ const clusterBounds = (node) => {
113
+ const data = node.data;
114
+ const known = clustersByNode.get(node);
115
+ if (known?.data === data) {
116
+ return known.bounds;
117
+ }
118
+ const bounds = [];
119
+ const segmenter = graphemeSegmenter();
120
+ if (segmenter) {
121
+ for (const segment of segmenter.segment(data)) {
122
+ bounds.push(segment.index);
123
+ }
124
+ }
125
+ else {
126
+ for (let index = 0; index < data.length; index += data.codePointAt(index) > 0xffff ? 2 : 1) {
127
+ bounds.push(index);
128
+ }
129
+ }
130
+ bounds.push(data.length);
131
+ clustersByNode.set(node, { data, bounds });
132
+ return bounds;
133
+ };
134
+ /** The character `offset` is in or starts (its index in `bounds`); the count of them at the end. */
135
+ const clusterAt = (bounds, offset) => {
136
+ let low = 0;
137
+ let high = bounds.length - 1;
138
+ while (low < high) {
139
+ const middle = (low + high + 1) >> 1;
140
+ if (bounds[middle] <= offset) {
141
+ low = middle;
142
+ }
143
+ else {
144
+ high = middle - 1;
145
+ }
146
+ }
147
+ return low;
148
+ };
149
+ /** The box of character `index` (as `clusterBounds` counts them), if it has one with width. */
150
+ const clusterRect = (doc, node, bounds, index) => {
151
+ if (index < 0 || index >= bounds.length - 1) {
152
+ return null;
153
+ }
154
+ const range = doc.createRange();
155
+ range.setStart(node, bounds[index]);
156
+ range.setEnd(node, bounds[index + 1]);
157
+ const rects = Array.from(range.getClientRects());
158
+ return rects.find((rect) => rect.right > rect.left) ?? null;
159
+ };
160
+ const holds = (rect, x, y) => !!rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
161
+ /**
162
+ * Whether character `index` (its box `rect`) is in a run read left to right: from a neighbour
163
+ * touching it on its line, else from its element's direction.
164
+ */
165
+ const readsLeftToRight = (doc, node, bounds, index, rect) => {
166
+ for (const neighbour of [index + 1, index - 1]) {
167
+ const box = clusterRect(doc, node, bounds, neighbour);
168
+ if (!box || Math.abs(box.top - rect.top) > 1) {
169
+ continue;
170
+ }
171
+ if (Math.abs(box.left - rect.right) < 1) {
172
+ return neighbour > index;
173
+ }
174
+ if (Math.abs(box.right - rect.left) < 1) {
175
+ return neighbour < index;
176
+ }
177
+ }
178
+ const element = node.parentElement;
179
+ return !element || doc.defaultView?.getComputedStyle(element).direction !== "rtl";
180
+ };
181
+ /**
182
+ * The caret position at `(x, y)` from the boxes of `node`'s characters, looked for outward from
183
+ * the browser's answer (`near`, its character index): before the character under it when on its
184
+ * leading half, else after it. Null when the pointer isn't on `node`'s text, or no character
185
+ * within `CARET_SCAN_MAX` of the answer is under it.
186
+ */
187
+ const caretOnCharacters = (doc, node, bounds, near, x, y) => {
188
+ const whole = doc.createRange();
189
+ whole.selectNodeContents(node);
190
+ if (!isPointInRects(x, y, Array.from(whole.getClientRects(), toRect), 0)) {
191
+ return null;
192
+ }
193
+ const count = bounds.length - 1;
194
+ let measured = 0;
195
+ for (let distance = 0; measured < CARET_SCAN_MAX; distance += 1) {
196
+ const after = near + distance;
197
+ const before = near - distance - 1;
198
+ if (after >= count && before < 0) {
199
+ return null;
200
+ }
201
+ for (const index of [after, before]) {
202
+ if (index < 0 || index >= count || measured >= CARET_SCAN_MAX) {
203
+ continue;
204
+ }
205
+ measured += 1;
206
+ const rect = clusterRect(doc, node, bounds, index);
207
+ if (rect && holds(rect, x, y)) {
208
+ const isLeading = x < (rect.left + rect.right) / 2 === readsLeftToRight(doc, node, bounds, index, rect);
209
+ return { node, offset: bounds[isLeading ? index : index + 1] };
210
+ }
211
+ }
212
+ }
213
+ return null;
214
+ };
215
+ /**
216
+ * The caret position the browser puts under `(x, y)`, when it is in a text node. Where a run reads
217
+ * the other way than its line (Latin words on a right-to-left page), the browser can answer at a
218
+ * run's edge with a position beside other characters, far from the pointer (Chrome: the line's
219
+ * end for the left half of the run's first letter, the run's start for the right half of its
220
+ * last): when neither character beside its answer is under the pointer, the one that is decides,
221
+ * found near the answer (`CARET_SCAN_MAX`), else the answer stands. Characters are what a reader
222
+ * sees as one (an emoji, a letter with its marks): a corrected position is never inside one.
223
+ */
95
224
  export const caretFromPoint = (doc, x, y) => {
225
+ const point = browserCaretAt(doc, x, y);
226
+ if (!point) {
227
+ return null;
228
+ }
229
+ const bounds = clusterBounds(point.node);
230
+ const index = clusterAt(bounds, point.offset);
231
+ const beside = bounds[index] === point.offset ? [index - 1, index] : [index];
232
+ if (beside.some((at) => holds(clusterRect(doc, point.node, bounds, at), x, y))) {
233
+ return point;
234
+ }
235
+ return caretOnCharacters(doc, point.node, bounds, index, x, y) ?? point;
236
+ };
237
+ const browserCaretAt = (doc, x, y) => {
96
238
  const caretDoc = doc;
97
239
  let node = null;
98
240
  let offset = 0;
@@ -46,7 +46,10 @@ export interface CommentAnchor {
46
46
  item?: CommentAnchorItem;
47
47
  element?: CommentAnchorElement;
48
48
  text?: CommentAnchorText;
49
- /** Where in the element the comment was dropped, as fractions of its box (0..1). */
49
+ /**
50
+ * Where in the element the comment was dropped, as fractions of its box (0..1). Stored only: the
51
+ * pin goes at the top-end corner of the element or its quote (`pinPoint`), not here.
52
+ */
50
53
  point?: {
51
54
  x: number;
52
55
  y: number;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/comments/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/comments/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ import { type FocusEvent, type KeyboardEvent, type MouseEvent } from "react";
2
+ /**
3
+ * A comment menu's open content carries this attribute: the layer asks at a press on the page
4
+ * whether one is open, so that click only closes it.
5
+ */
6
+ export declare const COMMENT_MENU_ATTRIBUTE = "data-blocks-comment-menu";
7
+ export interface UseCommentMenuOptions {
8
+ /** Which edge of the trigger the menu lines up with: its start (a picker) or its end (a ⋯). */
9
+ align: "start" | "end";
10
+ /** The height the menu asks for, to open below its trigger or, without room there, above. */
11
+ height: number;
12
+ }
13
+ /**
14
+ * A menu button of the comments, headless (the registry draws it): the menu is fixed in the window
15
+ * beside its trigger (`menuPosition`), portalled to the comments' layer (`container`) so no
16
+ * panel's overflow clips it and no app menu stack hides it. Opening it (a click, or ↓ on the
17
+ * trigger) moves focus to its checked item, else its first, and ↑ on the trigger to its last
18
+ * (WAI-ARIA's menu button); ↓ ↑ Home End move between items (they
19
+ * take focus by keys only: give them `tabIndex={-1}`); Esc, Tab or Shift+Tab close it with focus
20
+ * back on the trigger, and Esc goes no further (not the thread, not comment mode); focus leaving
21
+ * it, the page scrolling or the window resizing closes it (it stands where its trigger was). A
22
+ * press in it keeps focus where it is, so a browser that doesn't focus buttons on click doesn't
23
+ * blur it shut before the click lands.
24
+ *
25
+ * Spread `rootProps` on an element around the trigger (events from the portalled menu reach it as
26
+ * React bubbles them), `triggerProps` on the button and `menuProps` on the menu's root.
27
+ */
28
+ export declare const useCommentMenu: ({ align, height }: UseCommentMenuOptions) => {
29
+ isOpen: boolean;
30
+ /** Closes it with focus back on its trigger, as a pick does (WAI-ARIA's menu button). */
31
+ close: () => void;
32
+ /** Where to portal the menu while it is open: the comments' layer the trigger is in, else the body. */
33
+ container: Element | null;
34
+ rootProps: {
35
+ ref: import("react").RefObject<HTMLDivElement | null>;
36
+ onBlur: (event: FocusEvent<HTMLElement>) => void;
37
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
38
+ };
39
+ triggerProps: {
40
+ ref: import("react").RefObject<HTMLButtonElement | null>;
41
+ id: string;
42
+ "aria-haspopup": "menu";
43
+ "aria-expanded": boolean;
44
+ "aria-controls": string | undefined;
45
+ "data-state": "open" | "closed";
46
+ onClick: () => void;
47
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
48
+ };
49
+ menuProps: {
50
+ ref: import("react").RefObject<HTMLDivElement | null>;
51
+ id: string;
52
+ role: "menu";
53
+ "aria-labelledby": string;
54
+ "data-blocks-comment-menu": string;
55
+ "data-state": "open";
56
+ "data-side": "top" | "bottom" | undefined;
57
+ style: {
58
+ top?: number | undefined;
59
+ bottom?: number | undefined;
60
+ left?: number | undefined;
61
+ right?: number | undefined;
62
+ maxHeight?: number | undefined;
63
+ };
64
+ onMouseDown: (event: MouseEvent<HTMLElement>) => void;
65
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
66
+ };
67
+ };
68
+ export type CommentMenuState = ReturnType<typeof useCommentMenu>;
69
+ //# sourceMappingURL=use-comment-menu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-comment-menu.d.ts","sourceRoot":"","sources":["../../src/comments/use-comment-menu.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,UAAU,EAMhB,MAAM,OAAO,CAAC;AAMf;;;GAGG;AACH,eAAO,MAAM,sBAAsB,6BAA6B,CAAC;AAMjE,MAAM,WAAW,qBAAqB;IACpC,+FAA+F;IAC/F,KAAK,EAAE,OAAO,GAAG,KAAK,CAAC;IACvB,6FAA6F;IAC7F,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,GAAI,mBAAmB,qBAAqB;;IAsEnE,yFAAyF;;IAEzF,uGAAuG;;;;wBAIrF,UAAU,CAAC,WAAW,CAAC;2BAKpB,aAAa,CAAC,WAAW,CAAC;;;;;;;;;;2BAgB1B,aAAa,CAAC,WAAW,CAAC;;;;;;;;;;;;;;;;;6BAgBxB,UAAU,CAAC,WAAW,CAAC;2BACzB,aAAa,CAAC,WAAW,CAAC;;CAUlD,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC"}
@@ -0,0 +1,140 @@
1
+ import { useCallback, useEffect, useId, useRef, useState, } from "react";
2
+ import { toRect } from "./layout.js";
3
+ import { menuKeyTarget, menuPosition } from "./menu.js";
4
+ import { COMMENTS_LAYER_ATTRIBUTE } from "./pins.js";
5
+ /**
6
+ * A comment menu's open content carries this attribute: the layer asks at a press on the page
7
+ * whether one is open, so that click only closes it.
8
+ */
9
+ export const COMMENT_MENU_ATTRIBUTE = "data-blocks-comment-menu";
10
+ const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]';
11
+ const itemsIn = (menu) => Array.from(menu?.querySelectorAll(ITEM_SELECTOR) ?? []);
12
+ /**
13
+ * A menu button of the comments, headless (the registry draws it): the menu is fixed in the window
14
+ * beside its trigger (`menuPosition`), portalled to the comments' layer (`container`) so no
15
+ * panel's overflow clips it and no app menu stack hides it. Opening it (a click, or ↓ on the
16
+ * trigger) moves focus to its checked item, else its first, and ↑ on the trigger to its last
17
+ * (WAI-ARIA's menu button); ↓ ↑ Home End move between items (they
18
+ * take focus by keys only: give them `tabIndex={-1}`); Esc, Tab or Shift+Tab close it with focus
19
+ * back on the trigger, and Esc goes no further (not the thread, not comment mode); focus leaving
20
+ * it, the page scrolling or the window resizing closes it (it stands where its trigger was). A
21
+ * press in it keeps focus where it is, so a browser that doesn't focus buttons on click doesn't
22
+ * blur it shut before the click lands.
23
+ *
24
+ * Spread `rootProps` on an element around the trigger (events from the portalled menu reach it as
25
+ * React bubbles them), `triggerProps` on the button and `menuProps` on the menu's root.
26
+ */
27
+ export const useCommentMenu = ({ align, height }) => {
28
+ // Where it stands and where it is portalled, both read from the trigger as it opens.
29
+ const [opened, setOpened] = useState(null);
30
+ const position = opened?.position ?? null;
31
+ const isOpen = opened !== null;
32
+ const rootRef = useRef(null);
33
+ const triggerRef = useRef(null);
34
+ const menuRef = useRef(null);
35
+ const triggerId = useId();
36
+ const menuId = useId();
37
+ const open = (focus = "checked") => {
38
+ const trigger = triggerRef.current;
39
+ if (!trigger) {
40
+ return;
41
+ }
42
+ setOpened({
43
+ position: menuPosition(toRect(trigger.getBoundingClientRect()), {
44
+ height,
45
+ align,
46
+ isRtl: getComputedStyle(trigger).direction === "rtl",
47
+ viewportWidth: document.documentElement.clientWidth,
48
+ viewportHeight: window.innerHeight,
49
+ }),
50
+ // The comments' layer the trigger is in, else the body.
51
+ container: trigger.closest(`[${COMMENTS_LAYER_ATTRIBUTE}]`) ?? document.body,
52
+ focus,
53
+ });
54
+ };
55
+ const close = useCallback(() => setOpened(null), []);
56
+ const closeToTrigger = () => {
57
+ close();
58
+ triggerRef.current?.focus();
59
+ };
60
+ const focusFrom = opened?.focus;
61
+ useEffect(() => {
62
+ if (!focusFrom) {
63
+ return;
64
+ }
65
+ const all = itemsIn(menuRef.current);
66
+ (focusFrom === "last"
67
+ ? all.at(-1)
68
+ : (all.find((item) => item.getAttribute("aria-checked") === "true") ?? all[0]))?.focus();
69
+ // A scroll inside its own list doesn't move it.
70
+ const onScroll = (event) => {
71
+ if (!menuRef.current?.contains(event.target)) {
72
+ close();
73
+ }
74
+ };
75
+ window.addEventListener("scroll", onScroll, true);
76
+ window.addEventListener("resize", close);
77
+ return () => {
78
+ window.removeEventListener("scroll", onScroll, true);
79
+ window.removeEventListener("resize", close);
80
+ };
81
+ }, [focusFrom, close]);
82
+ const isInside = (node) => !!node && (!!rootRef.current?.contains(node) || !!menuRef.current?.contains(node));
83
+ const { side, ...style } = position ?? {};
84
+ return {
85
+ isOpen,
86
+ /** Closes it with focus back on its trigger, as a pick does (WAI-ARIA's menu button). */
87
+ close: closeToTrigger,
88
+ /** Where to portal the menu while it is open: the comments' layer the trigger is in, else the body. */
89
+ container: opened?.container ?? null,
90
+ rootProps: {
91
+ ref: rootRef,
92
+ onBlur: (event) => {
93
+ if (!isInside(event.relatedTarget)) {
94
+ close();
95
+ }
96
+ },
97
+ onKeyDown: (event) => {
98
+ if (isOpen && (event.key === "Escape" || event.key === "Tab")) {
99
+ event.preventDefault();
100
+ event.stopPropagation();
101
+ closeToTrigger();
102
+ }
103
+ },
104
+ },
105
+ triggerProps: {
106
+ ref: triggerRef,
107
+ id: triggerId,
108
+ "aria-haspopup": "menu",
109
+ "aria-expanded": isOpen,
110
+ "aria-controls": isOpen ? menuId : undefined,
111
+ "data-state": isOpen ? "open" : "closed",
112
+ onClick: () => (isOpen ? close() : open()),
113
+ onKeyDown: (event) => {
114
+ if (!isOpen && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
115
+ event.preventDefault();
116
+ open(event.key === "ArrowUp" ? "last" : "checked");
117
+ }
118
+ },
119
+ },
120
+ menuProps: {
121
+ ref: menuRef,
122
+ id: menuId,
123
+ role: "menu",
124
+ "aria-labelledby": triggerId,
125
+ [COMMENT_MENU_ATTRIBUTE]: "",
126
+ "data-state": "open",
127
+ "data-side": side,
128
+ style,
129
+ onMouseDown: (event) => event.preventDefault(),
130
+ onKeyDown: (event) => {
131
+ const all = itemsIn(menuRef.current);
132
+ const next = menuKeyTarget(event.key, all.indexOf(document.activeElement), all.length);
133
+ if (next !== null) {
134
+ event.preventDefault();
135
+ all[next]?.focus();
136
+ }
137
+ },
138
+ },
139
+ };
140
+ };
@@ -0,0 +1,3 @@
1
+ /** True once `isOn` has held for `ms` under the same `key`; false again the moment it doesn't. */
2
+ export declare const useHeld: (isOn: boolean, ms: number, key: unknown) => boolean;
3
+ //# sourceMappingURL=use-held.d.ts.map