@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.
- package/dist/comments/anchor.d.ts +23 -1
- package/dist/comments/anchor.d.ts.map +1 -1
- package/dist/comments/anchor.js +74 -7
- package/dist/comments/comment-mode.d.ts +22 -4
- package/dist/comments/comment-mode.d.ts.map +1 -1
- package/dist/comments/comment-mode.js +22 -5
- package/dist/comments/index.d.ts +9 -3
- package/dist/comments/index.d.ts.map +1 -1
- package/dist/comments/index.js +9 -3
- package/dist/comments/menu.d.ts +33 -0
- package/dist/comments/menu.d.ts.map +1 -0
- package/dist/comments/menu.js +47 -0
- package/dist/comments/open-comment.d.ts +28 -0
- package/dist/comments/open-comment.d.ts.map +1 -0
- package/dist/comments/open-comment.js +9 -0
- package/dist/comments/page-pick.d.ts.map +1 -1
- package/dist/comments/page-pick.js +23 -7
- package/dist/comments/pin-view.d.ts +107 -0
- package/dist/comments/pin-view.d.ts.map +1 -0
- package/dist/comments/pin-view.js +248 -0
- package/dist/comments/pins.d.ts +66 -12
- package/dist/comments/pins.d.ts.map +1 -1
- package/dist/comments/pins.js +132 -39
- package/dist/comments/text-pick.d.ts +11 -1
- package/dist/comments/text-pick.d.ts.map +1 -1
- package/dist/comments/text-pick.js +143 -1
- package/dist/comments/types.d.ts +4 -1
- package/dist/comments/types.d.ts.map +1 -1
- package/dist/comments/use-comment-menu.d.ts +69 -0
- package/dist/comments/use-comment-menu.d.ts.map +1 -0
- package/dist/comments/use-comment-menu.js +140 -0
- package/dist/comments/use-held.d.ts +3 -0
- package/dist/comments/use-held.d.ts.map +1 -0
- package/dist/comments/use-held.js +16 -0
- package/dist/comments/use-thread-scroll.d.ts +23 -0
- package/dist/comments/use-thread-scroll.d.ts.map +1 -0
- package/dist/comments/use-thread-scroll.js +89 -0
- package/dist/iframe-communication/types.d.ts +3 -2
- package/dist/iframe-communication/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/comments/pins.js
CHANGED
|
@@ -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
|
-
* -
|
|
14
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
/**
|
|
70
|
-
|
|
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 =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
/**
|
|
96
|
-
|
|
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
|
|
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
|
-
/**
|
|
109
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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;
|
package/dist/comments/types.d.ts
CHANGED
|
@@ -46,7 +46,10 @@ export interface CommentAnchor {
|
|
|
46
46
|
item?: CommentAnchorItem;
|
|
47
47
|
element?: CommentAnchorElement;
|
|
48
48
|
text?: CommentAnchorText;
|
|
49
|
-
/**
|
|
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":"AAAA;;;;;;GAMG;AAEH,iGAAiG;AACjG,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACnC,kFAAkF;IAClF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wFAAwF;IACxF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mFAAmF;IACnF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,sGAAsG;AACtG,MAAM,WAAW,iBAAiB;IAChC,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,mGAAmG;AACnG,MAAM,WAAW,aAAa;IAC5B,CAAC,EAAE,CAAC,CAAC;IACL,mGAAmG;IACnG,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,iBAAiB,CAAC;IACzB,OAAO,CAAC,EAAE,oBAAoB,CAAC;IAC/B,IAAI,CAAC,EAAE,iBAAiB,CAAC;IACzB
|
|
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
|
+
};
|