@blocksdiy/react-common 1.70.2 → 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 (75) hide show
  1. package/dist/comments/anchor.d.ts +96 -0
  2. package/dist/comments/anchor.d.ts.map +1 -0
  3. package/dist/comments/anchor.js +346 -0
  4. package/dist/comments/api.d.ts +38 -0
  5. package/dist/comments/api.d.ts.map +1 -0
  6. package/dist/comments/api.js +62 -0
  7. package/dist/comments/comment-agents.d.ts +28 -0
  8. package/dist/comments/comment-agents.d.ts.map +1 -0
  9. package/dist/comments/comment-agents.js +32 -0
  10. package/dist/comments/comment-mode.d.ts +115 -0
  11. package/dist/comments/comment-mode.d.ts.map +1 -0
  12. package/dist/comments/comment-mode.js +176 -0
  13. package/dist/comments/composer.d.ts +133 -0
  14. package/dist/comments/composer.d.ts.map +1 -0
  15. package/dist/comments/composer.js +273 -0
  16. package/dist/comments/index.d.ts +19 -0
  17. package/dist/comments/index.d.ts.map +1 -0
  18. package/dist/comments/index.js +23 -0
  19. package/dist/comments/layout.d.ts +25 -0
  20. package/dist/comments/layout.d.ts.map +1 -0
  21. package/dist/comments/layout.js +83 -0
  22. package/dist/comments/mentions.d.ts +32 -0
  23. package/dist/comments/mentions.d.ts.map +1 -0
  24. package/dist/comments/mentions.js +30 -0
  25. package/dist/comments/menu.d.ts +33 -0
  26. package/dist/comments/menu.d.ts.map +1 -0
  27. package/dist/comments/menu.js +47 -0
  28. package/dist/comments/open-comment.d.ts +28 -0
  29. package/dist/comments/open-comment.d.ts.map +1 -0
  30. package/dist/comments/open-comment.js +9 -0
  31. package/dist/comments/page-pick.d.ts +76 -0
  32. package/dist/comments/page-pick.d.ts.map +1 -0
  33. package/dist/comments/page-pick.js +283 -0
  34. package/dist/comments/pick-message.d.ts +11 -0
  35. package/dist/comments/pick-message.d.ts.map +1 -0
  36. package/dist/comments/pick-message.js +33 -0
  37. package/dist/comments/pin-view.d.ts +107 -0
  38. package/dist/comments/pin-view.d.ts.map +1 -0
  39. package/dist/comments/pin-view.js +248 -0
  40. package/dist/comments/pins.d.ts +126 -0
  41. package/dist/comments/pins.d.ts.map +1 -0
  42. package/dist/comments/pins.js +218 -0
  43. package/dist/comments/text-pick.d.ts +69 -0
  44. package/dist/comments/text-pick.d.ts.map +1 -0
  45. package/dist/comments/text-pick.js +307 -0
  46. package/dist/comments/threads-store.d.ts +119 -0
  47. package/dist/comments/threads-store.d.ts.map +1 -0
  48. package/dist/comments/threads-store.js +286 -0
  49. package/dist/comments/types.d.ts +235 -0
  50. package/dist/comments/types.d.ts.map +1 -0
  51. package/dist/comments/types.js +109 -0
  52. package/dist/comments/use-comment-menu.d.ts +69 -0
  53. package/dist/comments/use-comment-menu.d.ts.map +1 -0
  54. package/dist/comments/use-comment-menu.js +140 -0
  55. package/dist/comments/use-held.d.ts +3 -0
  56. package/dist/comments/use-held.d.ts.map +1 -0
  57. package/dist/comments/use-held.js +16 -0
  58. package/dist/comments/use-thread-scroll.d.ts +23 -0
  59. package/dist/comments/use-thread-scroll.d.ts.map +1 -0
  60. package/dist/comments/use-thread-scroll.js +89 -0
  61. package/dist/hooks/agent-conversation-context.d.ts +12 -1
  62. package/dist/hooks/agent-conversation-context.d.ts.map +1 -1
  63. package/dist/hooks/agent-conversation-context.js +12 -5
  64. package/dist/hooks/agent-thread-live-update.d.ts +5 -2
  65. package/dist/hooks/agent-thread-live-update.d.ts.map +1 -1
  66. package/dist/hooks/agent-thread-live-update.js +14 -0
  67. package/dist/hooks/use-agent-conversation.d.ts +8 -0
  68. package/dist/hooks/use-agent-conversation.d.ts.map +1 -1
  69. package/dist/hooks/use-agent-conversation.js +10 -0
  70. package/dist/hooks/use-agent-threads.d.ts +1 -0
  71. package/dist/hooks/use-agent-threads.d.ts.map +1 -1
  72. package/dist/hooks/use-agent-threads.js +4 -1
  73. package/dist/iframe-communication/types.d.ts +25 -0
  74. package/dist/iframe-communication/types.d.ts.map +1 -1
  75. package/package.json +5 -2
@@ -0,0 +1,248 @@
1
+ import { intersectRects } from "./layout.js";
2
+ const PIN_SIZE = 28;
3
+ /** What each face after the first adds to a pin's width: a face, less the overlap. */
4
+ const PIN_FACE_STEP = 14;
5
+ /** How far a pin keeps inside the window when its point is near an edge. */
6
+ const PIN_EDGE_MARGIN = 4;
7
+ /**
8
+ * The gap between a pin (or a new comment's point) and its panel: room for the open pin's glow (7px)
9
+ * and its growth (110%, from its pointed corner), with air past them.
10
+ */
11
+ const PANEL_GAP = 20;
12
+ /**
13
+ * The height a panel is given room for while its own height isn't known (`panelHeight`), the window
14
+ * allowing: its header, its comments' minimum and a composer at its tallest. In a short window the
15
+ * panel slides along the pin's side for it.
16
+ */
17
+ const PANEL_MIN_ROOM = 384;
18
+ /** Faces a pin shows before "+N". */
19
+ const PIN_FACES_MAX = 3;
20
+ /** A pin's width: one face fills it, and each more face (or "+N") adds what shows past the overlap. */
21
+ export const pinWidth = (faceCount) => PIN_SIZE + Math.max(0, faceCount - 1) * PIN_FACE_STEP;
22
+ /** The gap between what a comment is on (or its pin) and a panel above or below it. */
23
+ const TARGET_GAP = 8;
24
+ /**
25
+ * Where the panel goes by a pin `width` wide whose pointed corner is on `point` (a new comment's:
26
+ * the one-face pin it becomes), keeping clear of `target` where it can. The pin's box is worked out
27
+ * as `Pin` places it: above the point and toward the line's end, slid back inside the window at its
28
+ * edges. Where it stands is `side` (else as `panelSide` opens one): past the pin's end side or
29
+ * before its start, level with the pin's top (`down`) or with its bottom (`up`), or further along
30
+ * when that leaves it less than `PANEL_MIN_ROOM`; or below or above the target, its end edge level
31
+ * with the pin's. It is never taller than the room it has, so its replies scroll and its composer
32
+ * stays on screen. With a `shift` it then moves by it, keeping its height: it goes out of the
33
+ * window with its pin.
34
+ */
35
+ export const panelPosition = (point, width, panelWidth, { viewport: frame, shift, side, target, panelHeight }) => {
36
+ const geometry = besidePin(point, width, panelWidth, frame, target, panelHeight);
37
+ const taken = side ?? panelSide(point, width, panelWidth, undefined, frame, target, panelHeight);
38
+ const box = panelBox(taken, geometry);
39
+ const dx = shift?.x ?? 0;
40
+ const dy = shift?.y ?? 0;
41
+ const hangsUp = taken.place === "above" || (taken.place !== "below" && taken.block === "up");
42
+ if (hangsUp) {
43
+ return {
44
+ left: box.left + dx,
45
+ bottom: frame.height - box.bottom - dy,
46
+ width: panelWidth,
47
+ maxHeight: box.bottom - geometry.margin,
48
+ };
49
+ }
50
+ return {
51
+ left: box.left + dx,
52
+ top: box.top + dy,
53
+ width: panelWidth,
54
+ maxHeight: frame.height - box.top - geometry.margin,
55
+ };
56
+ };
57
+ /**
58
+ * How far a panel's place may run short of the room it wants (its height down the window, its
59
+ * width across), or cover what the comment is on, before it moves: the two switch points are this
60
+ * far apart at least, so a page scrolled back and forth by a few pixels never makes it flap.
61
+ */
62
+ const PANEL_SIDE_SLACK = 48;
63
+ /**
64
+ * Where a panel stands by its pin (`panelPosition`'s `side`), sticky. As it opens: level with the
65
+ * pin's top, hanging down, when it fits between there and the window's bottom margin; hanging up
66
+ * from the pin's bottom only when it doesn't and does fit above (`block`); and the first place
67
+ * (`PanelSide`) that fits inside the window's margins (a panel below or above the target with the
68
+ * room it wants) without covering `target` (`ThreadLayout.target`), else before the pin's start.
69
+ * The room it wants is `panelHeight` when known, else `PANEL_MIN_ROOM`. Then, given where it
70
+ * stood (`previous`): hanging down and standing past the pin's end side are where it rests, kept
71
+ * while they fit within `PANEL_SIDE_SLACK` (a panel kept within the slack slides along for room,
72
+ * still beside the pin); any other side or place is held only until the resting one fits again
73
+ * with no slack, then kept while it fits within the slack. The two switch points are the slack
74
+ * apart, so a page scrolled back and forth by a few pixels never makes it flap. A `previous`
75
+ * chosen before the height was known is not kept once it is (`isMeasured`). Without a target only
76
+ * the places beside the pin are taken.
77
+ */
78
+ export const panelSide = (point, width, panelWidth, previous, viewport, target, panelHeight) => {
79
+ const geometry = besidePin(point, width, panelWidth, viewport, target, panelHeight);
80
+ const { margin, pin, room } = geometry;
81
+ const isMeasured = panelHeight !== undefined;
82
+ const kept = previous && (previous.isMeasured || !isMeasured) ? previous : undefined;
83
+ const roomDown = viewport.height - margin - pin.top;
84
+ const roomUp = pin.bottom - margin;
85
+ // Down is where it rests: it hangs up only while down is short by more than the slack, and comes
86
+ // back down as soon as down fits.
87
+ const block = kept?.block === "down"
88
+ ? roomDown < room - PANEL_SIDE_SLACK && roomUp >= room
89
+ ? "up"
90
+ : "down"
91
+ : kept?.block === "up"
92
+ ? roomDown >= room
93
+ ? "down"
94
+ : "up"
95
+ : roomDown < room && roomUp >= room
96
+ ? "up"
97
+ : "down";
98
+ const otherBlock = block === "down" ? "up" : "down";
99
+ const order = [
100
+ { block, place: "end" },
101
+ { block: otherBlock, place: "end" },
102
+ { block, place: "below" },
103
+ { block, place: "above" },
104
+ { block, place: "start" },
105
+ ];
106
+ if (kept) {
107
+ // Beside the pin is where it rests too: a panel below, above or over the target goes back
108
+ // there as soon as it fits; one beside it stays while it fits within the slack.
109
+ const backBeside = kept.place === "end" ? undefined : order.slice(0, 2).find((candidate) => fits(candidate, geometry, 0));
110
+ if (backBeside) {
111
+ return { ...backBeside, isMeasured };
112
+ }
113
+ if (fits({ block, place: kept.place }, geometry, PANEL_SIDE_SLACK)) {
114
+ return { block, place: kept.place, isMeasured };
115
+ }
116
+ }
117
+ const side = order.find((candidate) => fits(candidate, geometry, 0)) ?? { block, place: kept?.place ?? "start" };
118
+ return { ...side, isMeasured };
119
+ };
120
+ /**
121
+ * The pin's box as `Pin` places it, where a panel would go on each side of it, and what the panel
122
+ * keeps clear of: the target and the pin together.
123
+ */
124
+ const besidePin = (point, width, panelWidth, viewport, target, panelHeight) => {
125
+ const margin = 8;
126
+ const pinLeft = viewport.isRtl
127
+ ? Math.max(PIN_EDGE_MARGIN, point.x - width)
128
+ : Math.min(point.x, viewport.width - PIN_EDGE_MARGIN - width);
129
+ const pinRight = pinLeft + width;
130
+ const pinTop = Math.max(PIN_EDGE_MARGIN, point.y - PIN_SIZE);
131
+ const pin = { left: pinLeft, right: pinRight, top: pinTop, bottom: pinTop + PIN_SIZE };
132
+ return {
133
+ margin,
134
+ panelWidth,
135
+ viewport,
136
+ pin,
137
+ target,
138
+ /** The target and the pin together: what a panel below or above them stands clear of. */
139
+ avoid: target
140
+ ? {
141
+ top: Math.min(target.top, pin.top),
142
+ left: Math.min(target.left, pin.left),
143
+ right: Math.max(target.right, pin.right),
144
+ bottom: Math.max(target.bottom, pin.bottom),
145
+ }
146
+ : null,
147
+ pastEnd: viewport.isRtl ? pinLeft - PANEL_GAP - panelWidth : pinRight + PANEL_GAP,
148
+ beforeStart: viewport.isRtl ? pinRight + PANEL_GAP : pinLeft - PANEL_GAP - panelWidth,
149
+ /** A panel's left edge with its end edge level with the pin's. */
150
+ endAligned: viewport.isRtl ? pinLeft : pinRight - panelWidth,
151
+ room: Math.min(panelHeight ?? PANEL_MIN_ROOM, viewport.height - 2 * margin),
152
+ };
153
+ };
154
+ /** The panel's box at `side`, as tall as the room it wants, kept inside the window's margins. */
155
+ const panelBox = (side, geometry) => {
156
+ const { margin, panelWidth, viewport, pin, room } = geometry;
157
+ const avoid = geometry.avoid ?? pin;
158
+ // The lowest top that still leaves the panel its room.
159
+ const farthest = viewport.height - margin - room;
160
+ const inMargins = (left) => Math.min(Math.max(margin, left), viewport.width - margin - panelWidth);
161
+ const box = (left, top) => ({
162
+ left,
163
+ right: left + panelWidth,
164
+ top,
165
+ bottom: top + room,
166
+ });
167
+ switch (side.place) {
168
+ case "below":
169
+ return box(inMargins(geometry.endAligned), Math.max(margin, Math.min(avoid.bottom + TARGET_GAP, farthest)));
170
+ case "above":
171
+ return box(inMargins(geometry.endAligned), Math.min(viewport.height - margin, Math.max(avoid.top - TARGET_GAP, margin + room)) - room);
172
+ default: {
173
+ const left = inMargins(side.place === "end" ? geometry.pastEnd : geometry.beforeStart);
174
+ if (side.block === "up") {
175
+ const bottom = Math.max(margin, Math.min(viewport.height - pin.bottom, farthest));
176
+ return box(left, viewport.height - bottom - room);
177
+ }
178
+ return box(left, Math.max(margin, Math.min(pin.top, farthest)));
179
+ }
180
+ }
181
+ };
182
+ /** How far two boxes overlap: the lesser of their overlaps across and down, zero when apart. */
183
+ const overlapDepth = (a, b) => {
184
+ const overlap = intersectRects(a, b);
185
+ return overlap ? Math.min(overlap.right - overlap.left, overlap.bottom - overlap.top) : 0;
186
+ };
187
+ /**
188
+ * Whether a panel at `side` fits within `slack`: beside the pin, across the window's margins;
189
+ * below or above a target, with the room it wants (a panel short of room slides over the target);
190
+ * and, but for the last resort (`start`), covering the target and its pin (each on its own, not
191
+ * the empty corner between them) by no more than `slack`.
192
+ */
193
+ const fits = (side, geometry, slack) => {
194
+ const { target, pin } = geometry;
195
+ if (side.place === "start") {
196
+ return fitsAt(geometry.beforeStart, geometry, slack);
197
+ }
198
+ if (side.place === "end" ? !fitsAt(geometry.pastEnd, geometry, slack) : !target) {
199
+ return false;
200
+ }
201
+ if (!target) {
202
+ return true;
203
+ }
204
+ const box = panelBox(side, geometry);
205
+ return overlapDepth(box, target) <= slack && overlapDepth(box, pin) <= slack;
206
+ };
207
+ /** Whether a panel whose left edge is at `left` fits across the window, within `slack` past its margins. */
208
+ const fitsAt = (left, geometry, slack) => left >= geometry.margin - slack && left + geometry.panelWidth <= geometry.viewport.width - geometry.margin + slack;
209
+ export const participantKey = (participant) => participant.type === "user" ? `u${participant.userId}` : `a${participant.agentId}`;
210
+ /**
211
+ * Everyone in a pin's threads, by first comment, each once: up to three as faces, the working
212
+ * agent's last (on top, so its spinner badge shows), and how many more there are, as "+N".
213
+ */
214
+ export const pinParticipants = (threads, workingAgentId) => {
215
+ const seen = new Set();
216
+ const others = threads
217
+ // Participants count only comments still there: a thread whose comments were all deleted
218
+ // shows its author.
219
+ .flatMap((thread) => thread.participants.length > 0 ? thread.participants : [{ type: "user", userId: thread.createdByUserId }])
220
+ .filter((participant) => {
221
+ const key = participantKey(participant);
222
+ const isNew = !seen.has(key) && !(participant.type === "agent" && participant.agentId === workingAgentId);
223
+ seen.add(key);
224
+ return isNew;
225
+ });
226
+ const shown = workingAgentId
227
+ ? [...others.slice(0, PIN_FACES_MAX - 1), { type: "agent", agentId: workingAgentId }]
228
+ : others.slice(0, PIN_FACES_MAX);
229
+ return {
230
+ shown,
231
+ moreCount: others.length + (workingAgentId ? 1 : 0) - shown.length,
232
+ };
233
+ };
234
+ /** How far a panel with nothing to stand beside keeps from the window's bottom and end edges. */
235
+ const CORNER_INSET = 16;
236
+ /**
237
+ * Where a pin `width` wide goes for a panel with nothing on the page to stand beside (a thread
238
+ * opened from its link whose element isn't there): so that `panelPosition` from it puts the panel
239
+ * in the window's bottom end corner, 16px in, with the pin just past the panel's start side, its
240
+ * bottom level with the panel's.
241
+ */
242
+ export const cornerPoint = (viewport, width, panelWidth) => {
243
+ const fromEnd = CORNER_INSET + panelWidth + PANEL_GAP + width;
244
+ return {
245
+ x: viewport.isRtl ? fromEnd : viewport.width - fromEnd,
246
+ y: viewport.height - CORNER_INSET,
247
+ };
248
+ };
@@ -0,0 +1,126 @@
1
+ import { type AnchorState, resolveAnchor } from "./anchor.js";
2
+ import { type Rect } from "./layout.js";
3
+ import type { CommentAnchor } from "./types.js";
4
+ /** How far a pin keeps from the viewport's edges. */
5
+ export declare const PIN_EDGE_MARGIN = 12;
6
+ export interface PinPoint {
7
+ x: number;
8
+ y: number;
9
+ }
10
+ /**
11
+ * The pin's point, in viewport coordinates: the top-end corner of what the comment is on (top-right
12
+ * on a left-to-right page, top-left with `isRtl`; without it, as the page reads), so the pin,
13
+ * standing above its point and past the end side (`Pin`), stays outside it.
14
+ * - A quote: its first line's corner (`firstLine`), the pin above the words, never over them;
15
+ * given only `lastLine` (a copy from before `firstLine`), that line's corner.
16
+ * - An element: its box's corner. Where it was clicked (`point`, stored on the anchor) doesn't
17
+ * place the pin; it is taken so a copy that passes it keeps working.
18
+ */
19
+ export declare const pinPoint: (target: {
20
+ kind: "quote";
21
+ lastLine: Rect;
22
+ firstLine?: Rect;
23
+ isRtl?: boolean;
24
+ } | {
25
+ kind: "element";
26
+ rect: Rect;
27
+ point?: {
28
+ x: number;
29
+ y: number;
30
+ };
31
+ isRtl?: boolean;
32
+ }) => PinPoint;
33
+ /** `point` kept `margin` inside the viewport. */
34
+ export declare const keepInViewport: (point: PinPoint, viewport: {
35
+ width: number;
36
+ height: number;
37
+ }, margin?: number) => {
38
+ x: number;
39
+ y: number;
40
+ };
41
+ export interface PinGroup<T> {
42
+ key: string;
43
+ point: PinPoint;
44
+ items: T[];
45
+ }
46
+ /** Pins on the same element become one group, at the first one's point (items in input order). */
47
+ export declare const groupPins: <T>(pins: readonly {
48
+ key: string;
49
+ point: PinPoint;
50
+ item: T;
51
+ }[]) => PinGroup<T>[];
52
+ export interface ThreadLayout {
53
+ state: AnchorState;
54
+ element: HTMLElement | null;
55
+ /** Where the pin goes; null when there is nothing on screen to pin to (scrolled away, covered, missing). */
56
+ point: PinPoint | null;
57
+ /** Threads with the same key point at the same element. */
58
+ groupKey: string | null;
59
+ /** What the anchored text says now, when it changed. */
60
+ currentText?: string;
61
+ /**
62
+ * What the comment is on, in window coordinates, for its panel to keep clear of: the quote's
63
+ * lines' bounding box, else the element's box, cut to the part of it in view (to the window's
64
+ * edge, a line with no height or width, while it is out of view). Absent when the element is
65
+ * missing, and on a platform from before it.
66
+ */
67
+ target?: Rect;
68
+ /**
69
+ * Why `point` is null though the element is on the page, and where its pin would go: what an
70
+ * open thread needs to keep its pin and panel with the element. Absent while the pin shows, when
71
+ * the element is missing, and on a platform from before it.
72
+ * - `offscreen`: the element is outside the window. `point` is where the pin stood as the element
73
+ * left (kept inside the window, as a shown pin is), and `shift` is how far the element has gone
74
+ * past the window's edge since: the pin and its panel move by it, so they slide out with the
75
+ * page instead of jumping or staying at the edge.
76
+ * - `clipped`: the element's box is in the window but a scroll container hides it (or it has no
77
+ * box): nothing of it can be seen, and nothing should float over what is there instead.
78
+ * - `covered`: it is in view under other UI (a dialog, a menu). `shift` is zero then and for
79
+ * `clipped`.
80
+ */
81
+ hidden?: {
82
+ by: "offscreen" | "clipped" | "covered";
83
+ point: PinPoint;
84
+ shift: PinPoint;
85
+ };
86
+ }
87
+ /**
88
+ * The comments' own layer (pins, threads) marks its root with this attribute: what it draws over an
89
+ * element doesn't hide the element. Other ignored UI (the agent chat's panel) does.
90
+ */
91
+ export declare const COMMENTS_LAYER_ATTRIBUTE = "data-blocks-comments-layer";
92
+ /**
93
+ * How far `box` lies past the window's edges: negative past the start (left, top), positive past
94
+ * the end, zero along an axis where it overlaps the window.
95
+ */
96
+ export declare const pastWindow: (box: Rect, viewport: {
97
+ width: number;
98
+ height: number;
99
+ }) => PinPoint;
100
+ /** Lays out one anchor whose element is known, on a page that reads right to left with `isRtl`. */
101
+ export declare const layoutAnchor: (resolved: ReturnType<typeof resolveAnchor>, isRtl: boolean) => ThreadLayout;
102
+ /**
103
+ * Where a new comment's pin goes and what its panel keeps clear of, as it is picked (`usePagePick`'s
104
+ * `onPick`): the same corner and target as a thread on it gets (`layoutAnchor`), its quote found in
105
+ * `element`. Reads the page; call it from the pick, not while rendering.
106
+ */
107
+ export declare const pickPlacement: (anchor: CommentAnchor, element: HTMLElement, isRtl?: boolean) => {
108
+ point: PinPoint;
109
+ target: Rect;
110
+ };
111
+ /**
112
+ * Finds an anchor again, re-checking only its text while its element is still in the page and
113
+ * still inside the same row. One found on the row itself is found anew each time: what the row
114
+ * must say depends on how the thread came to be on it (`resolveAnchor`).
115
+ */
116
+ export declare const resolveAnchorCached: (anchor: CommentAnchor) => import("./anchor.js").ResolvedAnchor;
117
+ /**
118
+ * Where each thread is on the page now, re-measured at most once per frame while `isActive`, on a
119
+ * page that reads right to left with `isRtl` (the caller's window as it read it; read here on each
120
+ * render when absent).
121
+ */
122
+ export declare const useCommentPins: (anchors: readonly {
123
+ id: string | number;
124
+ anchor: CommentAnchor;
125
+ }[], isActive: boolean, isRtl?: boolean) => ReadonlyMap<string | number, ThreadLayout>;
126
+ //# sourceMappingURL=pins.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pins.d.ts","sourceRoot":"","sources":["../../src/comments/pins.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,218 @@
1
+ import { useMemo } from "react";
2
+ import { checkText, findAnchorItemElement, resolveAnchor } from "./anchor.js";
3
+ import { clipRectsOf, toRect, usePageLayoutTick, visiblePart } from "./layout.js";
4
+ /**
5
+ * Where each thread's pin goes on the page as it is now. Never stored: worked out from the anchor
6
+ * every time the page moves, so a pin follows its element across screen sizes, re-sorted lists and
7
+ * scrolling.
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";
11
+ /** How far a pin keeps from the viewport's edges. */
12
+ export const PIN_EDGE_MARGIN = 12;
13
+ /**
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.
21
+ */
22
+ export const pinPoint = (target) => {
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 };
26
+ };
27
+ /** `point` kept `margin` inside the viewport. */
28
+ export const keepInViewport = (point, viewport, margin = PIN_EDGE_MARGIN) => ({
29
+ x: Math.min(Math.max(point.x, margin), Math.max(margin, viewport.width - margin)),
30
+ y: Math.min(Math.max(point.y, margin), Math.max(margin, viewport.height - margin)),
31
+ });
32
+ /** Pins on the same element become one group, at the first one's point (items in input order). */
33
+ export const groupPins = (pins) => {
34
+ const groups = new Map();
35
+ for (const pin of pins) {
36
+ const group = groups.get(pin.key);
37
+ if (group) {
38
+ group.items.push(pin.item);
39
+ }
40
+ else {
41
+ groups.set(pin.key, { key: pin.key, point: pin.point, items: [pin.item] });
42
+ }
43
+ }
44
+ return [...groups.values()];
45
+ };
46
+ const HIDDEN = { point: null, groupKey: null };
47
+ /**
48
+ * The comments' own layer (pins, threads) marks its root with this attribute: what it draws over an
49
+ * element doesn't hide the element. Other ignored UI (the agent chat's panel) does.
50
+ */
51
+ export const COMMENTS_LAYER_ATTRIBUTE = "data-blocks-comments-layer";
52
+ /** Whether the element can be seen at `visible`'s centre, or only the comments' own layer is over it. */
53
+ const isUncovered = (element, visible) => {
54
+ const hit = document.elementFromPoint((visible.left + visible.right) / 2, (visible.top + visible.bottom) / 2);
55
+ return !hit || element.contains(hit) || hit.closest(`[${COMMENTS_LAYER_ATTRIBUTE}]`) !== null;
56
+ };
57
+ const elementKeys = new WeakMap();
58
+ let nextElementKey = 0;
59
+ const keyOf = (element) => {
60
+ let key = elementKeys.get(element);
61
+ if (!key) {
62
+ key = `el-${++nextElementKey}`;
63
+ elementKeys.set(element, key);
64
+ }
65
+ return key;
66
+ };
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) => {
140
+ const { element, state, range, currentText } = resolved;
141
+ if (!element) {
142
+ return { state, element: null, currentText, ...HIDDEN };
143
+ }
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 };
160
+ }
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 };
174
+ };
175
+ /** The element each anchor was last found at: an anchor's object stays the same while its thread is unchanged. */
176
+ const foundByAnchor = new WeakMap();
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) => {
183
+ const known = foundByAnchor.get(anchor);
184
+ const row = anchor.item ? findAnchorItemElement(anchor) : null;
185
+ const stillThere = known?.isConnected && (!anchor.item || (row !== known && row?.contains(known) === true));
186
+ const resolved = stillThere ? checkText(anchor, known) : resolveAnchor(anchor);
187
+ if (resolved.element) {
188
+ foundByAnchor.set(anchor, resolved.element);
189
+ }
190
+ else {
191
+ foundByAnchor.delete(anchor);
192
+ }
193
+ return resolved;
194
+ };
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) => {
201
+ // Not for the React Compiler (app-render compiles this module): the memo's input is the page's
202
+ // layout, read through `tick`, which the compiler would drop from the deps as unused.
203
+ "use no memo";
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());
207
+ return useMemo(() => {
208
+ void tick;
209
+ const layouts = new Map();
210
+ if (!isActive || typeof document === "undefined") {
211
+ return layouts;
212
+ }
213
+ for (const { id, anchor } of anchors) {
214
+ layouts.set(id, layoutAnchor(resolveAnchorCached(anchor), rtl));
215
+ }
216
+ return layouts;
217
+ }, [anchors, isActive, tick, rtl]);
218
+ };
@@ -0,0 +1,69 @@
1
+ import { type Rect } from "./layout.js";
2
+ /**
3
+ * Pointing at part of a text, as the picker does over text: the caret position under the pointer,
4
+ * the word around it, and the range a drag covers — clamped to the row it started in, so a quote
5
+ * never spans two records. The geometry is the browser's; what can be computed without it is pure
6
+ * (word bounds, hit tests, ordering) and tested on its own.
7
+ */
8
+ /** A position in a text node: before the character at `offset`. */
9
+ export interface TextPoint {
10
+ node: Text;
11
+ offset: number;
12
+ }
13
+ /** A position between nodes or characters, as a Range's boundary takes it. */
14
+ export interface Boundary {
15
+ node: Node;
16
+ offset: number;
17
+ }
18
+ /** How far the pointer moves from a press on text before it is a drag, not a click. */
19
+ export declare const DRAG_THRESHOLD_PX = 4;
20
+ /**
21
+ * The word around `offset` in `text` — the one it is in, else the one ending right before it —
22
+ * as `[start, end)`; null over spaces and punctuation. Words in any script (`Intl.Segmenter`), a
23
+ * whitespace split where it is missing.
24
+ */
25
+ export declare const wordBoundsAt: (text: string, offset: number, locale?: string) => {
26
+ start: number;
27
+ end: number;
28
+ } | null;
29
+ /** Whether `(x, y)` is on one of `rects`, with `slop` px around each. */
30
+ export declare const isPointInRects: (x: number, y: number, rects: readonly Rect[], slop?: number) => boolean;
31
+ /** Whether `a` comes after `b` in the document. */
32
+ export declare const isAfter: (a: Boundary, b: Boundary) => boolean;
33
+ /** The range between two positions, in document order. */
34
+ export declare const rangeBetween: (doc: Document, a: Boundary, b: Boundary) => Range;
35
+ /**
36
+ * What a drag that starts at `node` may cover: the record it is in (the nearest `itemAnchorProps`
37
+ * element), else the element a click there would pick. A quote never leaves it.
38
+ */
39
+ export declare const dragRootOf: (node: Node) => Element | null;
40
+ /** `point` kept inside `root`: a position past its end is its end, one before its start is its start. */
41
+ export declare const clampToRoot: (root: Element, point: Boundary) => Boundary;
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
+ */
53
+ export declare const caretFromPoint: (doc: Document, x: number, y: number) => TextPoint | null;
54
+ /**
55
+ * The text position under the pointer, only when the pointer is ON the text: the browser answers
56
+ * with the nearest text from anywhere in an element, so the characters beside the caret decide
57
+ * (a space between words counts, so the cursor doesn't flicker along a line). Null over empty
58
+ * space, in ignored UI or an editable field.
59
+ */
60
+ export declare const textPointAt: (doc: Document, x: number, y: number) => TextPoint | null;
61
+ /** The word under a text position, as a range: what the hover tints. */
62
+ export declare const wordRangeAt: (doc: Document, point: TextPoint) => Range | null;
63
+ /** Where the caret bar goes for a text position: the edge of the character beside it. */
64
+ export declare const caretRectAt: (doc: Document, point: TextPoint) => {
65
+ x: number;
66
+ top: number;
67
+ bottom: number;
68
+ } | null;
69
+ //# sourceMappingURL=text-pick.d.ts.map