@opengeni/react 3.8.0-canary.0 → 3.8.0-canary.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 (72) hide show
  1. package/README.md +6 -0
  2. package/dist/chat.js +1 -1
  3. package/dist/chunk-5M6VTFJ5.js +171 -0
  4. package/dist/chunk-5M6VTFJ5.js.map +1 -0
  5. package/dist/{chunk-IOLVN74D.js → chunk-6J56T3MG.js} +15 -1
  6. package/dist/chunk-6J56T3MG.js.map +1 -0
  7. package/dist/{chunk-BQGIXRKV.js → chunk-7BWFU6H4.js} +22 -4
  8. package/dist/chunk-7BWFU6H4.js.map +1 -0
  9. package/dist/{chunk-3HIF5IFC.js → chunk-7W6PVVTK.js} +63 -18
  10. package/dist/chunk-7W6PVVTK.js.map +1 -0
  11. package/dist/{chunk-ZFOFY5ST.js → chunk-NLTS7JHI.js} +16 -31
  12. package/dist/chunk-NLTS7JHI.js.map +1 -0
  13. package/dist/chunk-NOSYBS2D.js +208 -0
  14. package/dist/chunk-NOSYBS2D.js.map +1 -0
  15. package/dist/{chunk-F2CFHN3Y.js → chunk-YAIPFO5D.js} +2 -1
  16. package/dist/chunk-YAIPFO5D.js.map +1 -0
  17. package/dist/chunk-YBM6CQRU.js +292 -0
  18. package/dist/chunk-YBM6CQRU.js.map +1 -0
  19. package/dist/chunk-ZYRGEKWS.js +216 -0
  20. package/dist/chunk-ZYRGEKWS.js.map +1 -0
  21. package/dist/components/composer.d.ts +4 -2
  22. package/dist/components/message-timeline.d.ts +9 -2
  23. package/dist/components/timeline-annotation-chrome.d.ts +25 -0
  24. package/dist/components/timeline-annotation-layout.d.ts +42 -0
  25. package/dist/components/timeline-annotation-markers.d.ts +8 -0
  26. package/dist/components/timeline-annotation-reveal-context.d.ts +4 -0
  27. package/dist/components/timeline-annotation-shared.d.ts +31 -0
  28. package/dist/components/timeline-annotations-dialog.d.ts +1 -1
  29. package/dist/components/timeline-annotations.d.ts +8 -2
  30. package/dist/composer.js +2 -2
  31. package/dist/hooks/use-composer.d.ts +2 -0
  32. package/dist/index.js +9 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/interaction.js +6 -6
  35. package/dist/machines.js +1 -1
  36. package/dist/{queue-surface-implementation-XL2A6PBM.js → queue-surface-implementation-LK3S4BIW.js} +5 -2
  37. package/dist/{queue-surface-implementation-XL2A6PBM.js.map → queue-surface-implementation-LK3S4BIW.js.map} +1 -1
  38. package/dist/session-ui.js +8 -5
  39. package/dist/session.js +1 -1
  40. package/dist/timeline-annotation-markers-DRSTLEM6.js +165 -0
  41. package/dist/timeline-annotation-markers-DRSTLEM6.js.map +1 -0
  42. package/dist/timeline-annotation-selection-IHLUGVND.js +236 -0
  43. package/dist/timeline-annotation-selection-IHLUGVND.js.map +1 -0
  44. package/dist/timeline-annotations-dialog-6Q53N5FG.js +262 -0
  45. package/dist/timeline-annotations-dialog-6Q53N5FG.js.map +1 -0
  46. package/package.json +2 -2
  47. package/src/components/chat-composer.tsx +5 -2
  48. package/src/components/composer.tsx +40 -5
  49. package/src/components/copy-button.tsx +1 -0
  50. package/src/components/machines-dashboard.tsx +1 -16
  51. package/src/components/message-timeline.tsx +357 -297
  52. package/src/components/timeline-annotation-chrome.tsx +183 -0
  53. package/src/components/timeline-annotation-layout.ts +268 -0
  54. package/src/components/timeline-annotation-markers.tsx +185 -0
  55. package/src/components/timeline-annotation-reveal-context.ts +9 -0
  56. package/src/components/timeline-annotation-selection.tsx +99 -54
  57. package/src/components/timeline-annotation-shared.ts +245 -0
  58. package/src/components/timeline-annotations-dialog.tsx +172 -128
  59. package/src/components/timeline-annotations.tsx +178 -45
  60. package/src/hooks/use-composer.ts +17 -0
  61. package/styles/compiled.css +86 -22
  62. package/dist/chunk-3HIF5IFC.js.map +0 -1
  63. package/dist/chunk-BQGIXRKV.js.map +0 -1
  64. package/dist/chunk-F2CFHN3Y.js.map +0 -1
  65. package/dist/chunk-IOLVN74D.js.map +0 -1
  66. package/dist/chunk-W3OUAH6M.js +0 -170
  67. package/dist/chunk-W3OUAH6M.js.map +0 -1
  68. package/dist/chunk-ZFOFY5ST.js.map +0 -1
  69. package/dist/timeline-annotation-selection-3L7LHGG2.js +0 -187
  70. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +0 -1
  71. package/dist/timeline-annotations-dialog-U7EVLR75.js +0 -196
  72. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +0 -1
@@ -1,7 +1,17 @@
1
1
  import type { DraftTimelineAnnotation } from "@opengeni/sdk";
2
+ import { QuoteIcon } from "lucide-react";
2
3
  import { useEffect, useRef, useState, type RefObject } from "react";
3
4
  import { createPortal } from "react-dom";
5
+ import { usePortalTokenStyle } from "../lib/use-portal-token-style";
4
6
  import type { TimelineAnnotationSourceDescriptor } from "../timeline";
7
+ import {
8
+ clipRangeToSource,
9
+ matchingQuoteInSource,
10
+ occurrenceOffsets,
11
+ quoteOccurrenceIndex,
12
+ truncateAnnotationQuote,
13
+ visibleClientRect,
14
+ } from "./timeline-annotation-shared";
5
15
 
6
16
  type SelectionCandidate = {
7
17
  annotation: DraftTimelineAnnotation;
@@ -10,10 +20,11 @@ type SelectionCandidate = {
10
20
  keyboard: boolean;
11
21
  };
12
22
 
13
- const INTERACTIVE_SELECTOR =
14
- 'a,button,input,textarea,select,summary,[role="button"],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
23
+ const CONTROL_SELECTOR = "button,input,textarea,select,summary,[contenteditable='true']";
15
24
  const SOURCE_CONTEXT_BYTES = 160;
16
25
  const MAX_QUOTE_BYTES = 16 * 1024;
26
+ const POPOVER_WIDTH = 280;
27
+ const POPOVER_HEIGHT = 64;
17
28
 
18
29
  function utf8Prefix(value: string, maxBytes: number): string {
19
30
  let output = "";
@@ -49,33 +60,21 @@ function containingSource(node: Node | null): HTMLElement | null {
49
60
  return source instanceof HTMLElement ? source : null;
50
61
  }
51
62
 
52
- function isInteractiveSelection(node: Node | null, boundary: HTMLElement): boolean {
53
- const element = selectionElement(node);
54
- const interactive = element?.closest(INTERACTIVE_SELECTOR);
55
- return interactive instanceof Element && boundary.contains(interactive);
63
+ function resolveSourceBoundary(range: Range, root: HTMLElement): HTMLElement | null {
64
+ const start = containingSource(range.startContainer);
65
+ const end = containingSource(range.endContainer);
66
+ if (start && end && start !== end) return null;
67
+ const boundary = start ?? end;
68
+ if (!boundary || !root.contains(boundary)) return null;
69
+ return boundary;
56
70
  }
57
71
 
58
- function occurrenceOffsets(text: string, quote: string): number[] {
59
- const offsets: number[] = [];
60
- let cursor = 0;
61
- while (cursor <= text.length - quote.length) {
62
- const offset = text.indexOf(quote, cursor);
63
- if (offset < 0) break;
64
- offsets.push(offset);
65
- cursor = offset + 1;
66
- }
67
- return offsets;
68
- }
69
-
70
- function selectedOccurrence(boundary: HTMLElement, range: Range, quote: string): number {
71
- try {
72
- const prefix = range.cloneRange();
73
- prefix.selectNodeContents(boundary);
74
- prefix.setEnd(range.startContainer, range.startOffset);
75
- return occurrenceOffsets(prefix.toString(), quote).length;
76
- } catch {
77
- return 0;
78
- }
72
+ function isWhollyInsideControl(range: Range, boundary: HTMLElement): boolean {
73
+ const start = selectionElement(range.startContainer)?.closest(CONTROL_SELECTOR);
74
+ const end = selectionElement(range.endContainer)?.closest(CONTROL_SELECTOR);
75
+ return (
76
+ start instanceof Element && start === end && boundary.contains(start) && start !== boundary
77
+ );
79
78
  }
80
79
 
81
80
  function annotationId(): string {
@@ -85,6 +84,18 @@ function annotationId(): string {
85
84
  return `annotation-${Date.now()}-${Math.random().toString(16).slice(2)}`;
86
85
  }
87
86
 
87
+ function clamp(value: number, min: number, max: number): number {
88
+ return Math.min(Math.max(value, min), max);
89
+ }
90
+
91
+ function popoverPosition(rect: DOMRect): { left: number; top: number } {
92
+ const half = POPOVER_WIDTH / 2;
93
+ const left = clamp(rect.left + rect.width / 2, 12 + half, window.innerWidth - 12 - half);
94
+ const preferBelow = rect.bottom + 8 + POPOVER_HEIGHT <= window.innerHeight - 12;
95
+ const top = preferBelow ? rect.bottom + 8 : Math.max(12, rect.top - POPOVER_HEIGHT - 8);
96
+ return { left, top };
97
+ }
98
+
88
99
  function buildCandidate(
89
100
  root: HTMLElement,
90
101
  sources: ReadonlyMap<string, TimelineAnnotationSourceDescriptor>,
@@ -93,18 +104,15 @@ function buildCandidate(
93
104
  const selection = document.getSelection();
94
105
  if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
95
106
  const range = selection.getRangeAt(0);
96
- const startBoundary = containingSource(range.startContainer);
97
- const endBoundary = containingSource(range.endContainer);
98
- if (!startBoundary || startBoundary !== endBoundary || !root.contains(startBoundary)) return null;
99
- if (
100
- isInteractiveSelection(range.startContainer, startBoundary) ||
101
- isInteractiveSelection(range.endContainer, startBoundary)
102
- ) {
103
- return null;
104
- }
107
+ const startBoundary = resolveSourceBoundary(range, root);
108
+ if (!startBoundary) return null;
109
+ if (isWhollyInsideControl(range, startBoundary)) return null;
110
+ const clipped = clipRangeToSource(range, startBoundary);
111
+ if (!clipped) return null;
105
112
  const sourceKey = startBoundary.dataset.ogAnnotationSourceKey;
106
113
  const source = sourceKey ? sources.get(sourceKey) : undefined;
107
- const quote = selection.toString();
114
+ const rawQuote = clipped.toString();
115
+ const quote = (source ? matchingQuoteInSource(source.text, rawQuote) : null) ?? rawQuote;
108
116
  if (
109
117
  !source ||
110
118
  quote.trim().length === 0 ||
@@ -114,11 +122,12 @@ function buildCandidate(
114
122
  }
115
123
  const sourceOffsets = occurrenceOffsets(source.text, quote);
116
124
  if (sourceOffsets.length === 0) return null;
117
- const occurrence = selectedOccurrence(startBoundary, range, quote);
125
+ const occurrence = quoteOccurrenceIndex(startBoundary, clipped, quote);
118
126
  const startOffset = sourceOffsets[Math.min(occurrence, sourceOffsets.length - 1)]!;
119
127
  const endOffset = startOffset + quote.length;
120
- const rect = range.getBoundingClientRect();
121
- if (rect.width === 0 && rect.height === 0) return null;
128
+ const rect = visibleClientRect(range) ?? visibleClientRect(clipped);
129
+ if (!rect) return null;
130
+ const position = popoverPosition(rect);
122
131
  return {
123
132
  annotation: {
124
133
  id: annotationId(),
@@ -137,8 +146,8 @@ function buildCandidate(
137
146
  ...(source.label ? { label: source.label } : {}),
138
147
  },
139
148
  },
140
- left: Math.min(Math.max(12, rect.left + rect.width / 2), window.innerWidth - 12),
141
- top: rect.bottom + 48 <= window.innerHeight ? rect.bottom + 8 : Math.max(12, rect.top - 40),
149
+ left: position.left,
150
+ top: position.top,
142
151
  keyboard,
143
152
  };
144
153
  }
@@ -154,6 +163,8 @@ export function TimelineAnnotationSelection({
154
163
  }) {
155
164
  const [candidate, setCandidate] = useState<SelectionCandidate | null>(null);
156
165
  const buttonRef = useRef<HTMLButtonElement | null>(null);
166
+ const pointerOriginRef = useRef<"unset" | "inside" | "outside">("unset");
167
+ const portalStyle = usePortalTokenStyle(rootRef.current);
157
168
 
158
169
  useEffect(() => {
159
170
  const root = rootRef.current;
@@ -164,7 +175,20 @@ export function TimelineAnnotationSelection({
164
175
  const update = (keyboard: boolean) => {
165
176
  setCandidate(buildCandidate(root, sources, keyboard));
166
177
  };
167
- const onPointerUp = () => window.setTimeout(() => update(false), 0);
178
+ const onRootPointerUp = (event: PointerEvent) => {
179
+ if (event.target instanceof Node && buttonRef.current?.contains(event.target)) return;
180
+ // Tests may fire only pointerup on the source. A real click that started
181
+ // outside the timeline must not resurrect leftover selection.
182
+ if (pointerOriginRef.current === "outside") return;
183
+ window.setTimeout(() => update(false), 0);
184
+ };
185
+ const onDocumentPointerUp = (event: PointerEvent) => {
186
+ if (event.target instanceof Node && buttonRef.current?.contains(event.target)) return;
187
+ const startedInRoot = pointerOriginRef.current === "inside";
188
+ pointerOriginRef.current = "unset";
189
+ if (!startedInRoot) return;
190
+ window.setTimeout(() => update(false), 0);
191
+ };
168
192
  const onKeyUp = (event: KeyboardEvent) => {
169
193
  if (event.key === "Escape") {
170
194
  setCandidate(null);
@@ -173,46 +197,67 @@ export function TimelineAnnotationSelection({
173
197
  window.setTimeout(() => update(true), 0);
174
198
  };
175
199
  const onPointerDown = (event: PointerEvent) => {
176
- if (
177
- candidate &&
178
- !(event.target instanceof Node && buttonRef.current?.contains(event.target))
179
- ) {
180
- setCandidate(null);
200
+ if (event.target instanceof Node && buttonRef.current?.contains(event.target)) {
201
+ event.preventDefault();
202
+ return;
181
203
  }
204
+ pointerOriginRef.current =
205
+ event.target instanceof Node && root.contains(event.target) ? "inside" : "outside";
206
+ setCandidate(null);
182
207
  };
183
208
  const onDocumentKeyDown = (event: KeyboardEvent) => {
184
209
  if (event.key === "Escape") setCandidate(null);
185
210
  };
186
- root.addEventListener("pointerup", onPointerUp);
211
+ const onSelectionChange = () => {
212
+ if (buttonRef.current && document.activeElement === buttonRef.current) return;
213
+ const selection = document.getSelection();
214
+ if (!selection || selection.isCollapsed) setCandidate(null);
215
+ };
216
+ root.addEventListener("pointerup", onRootPointerUp);
187
217
  root.addEventListener("keyup", onKeyUp);
218
+ document.addEventListener("pointerup", onDocumentPointerUp);
188
219
  document.addEventListener("pointerdown", onPointerDown);
189
220
  document.addEventListener("keydown", onDocumentKeyDown);
221
+ document.addEventListener("selectionchange", onSelectionChange);
190
222
  return () => {
191
- root.removeEventListener("pointerup", onPointerUp);
223
+ root.removeEventListener("pointerup", onRootPointerUp);
192
224
  root.removeEventListener("keyup", onKeyUp);
225
+ document.removeEventListener("pointerup", onDocumentPointerUp);
193
226
  document.removeEventListener("pointerdown", onPointerDown);
194
227
  document.removeEventListener("keydown", onDocumentKeyDown);
228
+ document.removeEventListener("selectionchange", onSelectionChange);
195
229
  };
196
- }, [candidate, onAnnotate, rootRef, sources]);
230
+ }, [onAnnotate, rootRef, sources]);
197
231
 
198
232
  useEffect(() => {
199
233
  if (candidate?.keyboard) buttonRef.current?.focus();
200
234
  }, [candidate]);
201
235
 
202
236
  if (!candidate || !onAnnotate || typeof document === "undefined") return null;
237
+ const preview = truncateAnnotationQuote(candidate.annotation.quote, 72);
203
238
  return createPortal(
204
239
  <button
205
240
  ref={buttonRef}
206
241
  type="button"
207
- style={{ left: candidate.left, top: candidate.top }}
208
- className="og-root fixed z-[80] -translate-x-1/2 rounded-full border border-og-border bg-og-surface-1 px-3 py-1.5 text-og-sm font-medium text-og-fg shadow-lg outline-hidden transition hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-[44px]"
242
+ data-og-annotation-popover=""
243
+ style={{ left: candidate.left, top: candidate.top, ...portalStyle }}
244
+ className="og-root fixed z-[80] flex max-w-[min(18rem,calc(100vw-1.5rem))] -translate-x-1/2 items-start gap-2 rounded-2xl border border-og-border bg-og-surface-1 px-3 py-2 text-og-sm font-medium text-og-fg shadow-xl outline-hidden transition hover:border-og-accent/40 hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-[44px]"
245
+ aria-label={`Add a note about “${preview}”`}
246
+ title={candidate.annotation.quote}
247
+ onPointerDown={(event) => event.preventDefault()}
209
248
  onClick={() => {
210
249
  onAnnotate(candidate.annotation);
211
250
  document.getSelection()?.removeAllRanges();
212
251
  setCandidate(null);
213
252
  }}
214
253
  >
215
- Annotate
254
+ <QuoteIcon className="mt-0.5 size-3.5 shrink-0 text-og-accent" aria-hidden="true" />
255
+ <span className="min-w-0 text-left">
256
+ <span className="block leading-5">Add note</span>
257
+ <span className="mt-0.5 block truncate text-og-xs font-normal text-og-fg-muted">
258
+ {preview}
259
+ </span>
260
+ </span>
216
261
  </button>,
217
262
  document.body,
218
263
  );
@@ -0,0 +1,245 @@
1
+ import type { TimelineAnnotationSource } from "@opengeni/sdk";
2
+
3
+ export const ANNOTATION_CHROME_SELECTOR =
4
+ "[data-og-annotation-chrome], [data-og-annotation-badge], [data-og-annotation-popover]";
5
+
6
+ export function annotationHasNote(note: string): boolean {
7
+ return note.trim().length > 0;
8
+ }
9
+
10
+ export function annotationDisplayOrdinal(
11
+ annotation: { readonly id?: string; readonly ordinal?: number | undefined },
12
+ index: number,
13
+ ): number {
14
+ return typeof annotation.ordinal === "number" ? annotation.ordinal : index + 1;
15
+ }
16
+
17
+ export function sourceKindLabel(kind: TimelineAnnotationSource["kind"]): string {
18
+ switch (kind) {
19
+ case "user_message":
20
+ return "Your message";
21
+ case "assistant_message":
22
+ return "Assistant";
23
+ case "tool_output":
24
+ return "Tool output";
25
+ }
26
+ }
27
+
28
+ export function annotationSourceLabel(source: TimelineAnnotationSource): string {
29
+ return source.label?.trim() || sourceKindLabel(source.kind);
30
+ }
31
+
32
+ export function truncateAnnotationQuote(quote: string, maxChars = 160): string {
33
+ const normalized = quote.replace(/\s+/g, " ").trim();
34
+ if (normalized.length <= maxChars) return normalized;
35
+ return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
36
+ }
37
+
38
+ export function cssEscapeAttribute(value: string): string {
39
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
40
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
41
+ }
42
+
43
+ export function isAnnotationChromeNode(node: Node | null): boolean {
44
+ const element = node instanceof Element ? node : node?.parentElement;
45
+ return Boolean(element?.closest(ANNOTATION_CHROME_SELECTOR));
46
+ }
47
+
48
+ export function occurrenceOffsets(text: string, quote: string): number[] {
49
+ if (!quote) return [];
50
+ const offsets: number[] = [];
51
+ let cursor = 0;
52
+ while (cursor <= text.length - quote.length) {
53
+ const offset = text.indexOf(quote, cursor);
54
+ if (offset < 0) break;
55
+ offsets.push(offset);
56
+ cursor = offset + 1;
57
+ }
58
+ return offsets;
59
+ }
60
+
61
+ function uniqueNonEmpty(values: string[]): string[] {
62
+ const seen = new Set<string>();
63
+ const out: string[] = [];
64
+ for (const value of values) {
65
+ if (!value || seen.has(value)) continue;
66
+ seen.add(value);
67
+ out.push(value);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export function matchingQuoteInSource(sourceText: string, quote: string): string | null {
73
+ for (const candidate of uniqueNonEmpty([quote, quote.trim()])) {
74
+ if (sourceText.includes(candidate)) return candidate;
75
+ }
76
+ const trimmed = quote.trim();
77
+ for (let length = trimmed.length; length >= 8; length--) {
78
+ const slice = trimmed.slice(0, length);
79
+ if (sourceText.includes(slice)) return slice;
80
+ }
81
+ for (let start = 1; start <= trimmed.length - 8; start++) {
82
+ const slice = trimmed.slice(start);
83
+ if (sourceText.includes(slice)) return slice;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ type AnnotatableSpan = {
89
+ node: Text;
90
+ start: number;
91
+ length: number;
92
+ };
93
+
94
+ export function annotatableText(root: HTMLElement): { text: string; spans: AnnotatableSpan[] } {
95
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
96
+ acceptNode(node) {
97
+ if (!(node instanceof Text) || node.data.length === 0) return NodeFilter.FILTER_REJECT;
98
+ if (isAnnotationChromeNode(node)) return NodeFilter.FILTER_REJECT;
99
+ return NodeFilter.FILTER_ACCEPT;
100
+ },
101
+ });
102
+ const spans: AnnotatableSpan[] = [];
103
+ let text = "";
104
+ let current: Node | null;
105
+ while ((current = walker.nextNode())) {
106
+ if (!(current instanceof Text)) continue;
107
+ spans.push({ node: current, start: text.length, length: current.data.length });
108
+ text += current.data;
109
+ }
110
+ return { text, spans };
111
+ }
112
+
113
+ function pointAt(
114
+ spans: AnnotatableSpan[],
115
+ offset: number,
116
+ stickEnd: boolean,
117
+ ): { node: Text; offset: number } | null {
118
+ if (spans.length === 0) return null;
119
+ for (let index = 0; index < spans.length; index++) {
120
+ const span = spans[index]!;
121
+ const local = offset - span.start;
122
+ if (local < 0) continue;
123
+ if (
124
+ local < span.length ||
125
+ (local === span.length && (stickEnd || index === spans.length - 1))
126
+ ) {
127
+ return { node: span.node, offset: local };
128
+ }
129
+ }
130
+ const last = spans[spans.length - 1]!;
131
+ return { node: last.node, offset: last.length };
132
+ }
133
+
134
+ export function rangeFromOffsets(
135
+ spans: AnnotatableSpan[],
136
+ startOffset: number,
137
+ endOffset: number,
138
+ ): Range | null {
139
+ const start = pointAt(spans, startOffset, false);
140
+ const end = pointAt(spans, endOffset, true);
141
+ if (!start || !end) return null;
142
+ const range = document.createRange();
143
+ range.setStart(start.node, start.offset);
144
+ range.setEnd(end.node, end.offset);
145
+ return range.collapsed ? null : range;
146
+ }
147
+
148
+ export function buildQuoteRange(
149
+ sourceEl: HTMLElement,
150
+ quote: string,
151
+ occurrenceIndex = 0,
152
+ ): Range | null {
153
+ if (!quote) return null;
154
+ const { text, spans } = annotatableText(sourceEl);
155
+ const offsets = occurrenceOffsets(text, quote);
156
+ if (offsets.length === 0) return null;
157
+ const start = offsets[Math.min(Math.max(0, occurrenceIndex), offsets.length - 1)]!;
158
+ return rangeFromOffsets(spans, start, start + quote.length);
159
+ }
160
+
161
+ export function quoteOccurrenceIndex(sourceEl: HTMLElement, range: Range, quote: string): number {
162
+ try {
163
+ const prefix = range.cloneRange();
164
+ prefix.selectNodeContents(sourceEl);
165
+ prefix.setEnd(range.startContainer, range.startOffset);
166
+ return occurrenceOffsets(prefix.toString(), quote).length;
167
+ } catch {
168
+ return 0;
169
+ }
170
+ }
171
+
172
+ export function clipRangeToSource(range: Range, sourceEl: HTMLElement): Range | null {
173
+ const startOk =
174
+ sourceEl.contains(range.startContainer) && !isAnnotationChromeNode(range.startContainer);
175
+ const endOk =
176
+ sourceEl.contains(range.endContainer) && !isAnnotationChromeNode(range.endContainer);
177
+ if (!startOk && !endOk) return null;
178
+ const { spans } = annotatableText(sourceEl);
179
+ if (spans.length === 0) return null;
180
+ const next = range.cloneRange();
181
+ const first = spans[0]!;
182
+ const last = spans[spans.length - 1]!;
183
+ if (!startOk) next.setStart(first.node, 0);
184
+ if (!endOk) next.setEnd(last.node, last.node.data.length);
185
+ return next.collapsed ? null : next;
186
+ }
187
+
188
+ export function visibleClientRect(range: Range): DOMRect | null {
189
+ try {
190
+ const bounding = range.getBoundingClientRect();
191
+ if (bounding.width > 0 || bounding.height > 0) return bounding;
192
+ } catch {
193
+ // jsdom ranges may omit layout geometry.
194
+ }
195
+ try {
196
+ const rects = range.getClientRects();
197
+ for (let index = 0; index < rects.length; index++) {
198
+ const rect = rects.item(index);
199
+ if (rect && (rect.width > 0 || rect.height > 0)) return rect;
200
+ }
201
+ } catch {
202
+ // jsdom ranges may omit client rects.
203
+ }
204
+ return null;
205
+ }
206
+
207
+ export function resolveAnnotationRevealRoot(from: Element | null | undefined): HTMLElement | null {
208
+ const conversation = from?.closest("[data-og-conversation]");
209
+ if (conversation instanceof HTMLElement) {
210
+ const nested = conversation.querySelector("[data-og-timeline-scroller]");
211
+ return nested instanceof HTMLElement ? nested : conversation;
212
+ }
213
+ const scroller = from?.closest("[data-og-timeline-scroller]");
214
+ return scroller instanceof HTMLElement ? scroller : null;
215
+ }
216
+
217
+ export function revealLoadedAnnotationSource(
218
+ source: TimelineAnnotationSource,
219
+ root?: ParentNode | null,
220
+ ): boolean {
221
+ if (typeof document === "undefined") return false;
222
+ // An explicit null root means the caller already scoped the search and found
223
+ // no owning timeline. Do not fall back to `document`, or a second mounted
224
+ // timeline with the same event id would steal the reveal.
225
+ if (root === null) return false;
226
+ const element = (root ?? document).querySelector(
227
+ `[data-og-annotation-source-key="${cssEscapeAttribute(source.eventId)}"]`,
228
+ );
229
+ if (!(element instanceof HTMLElement)) return false;
230
+ element.scrollIntoView({ block: "center", behavior: "smooth" });
231
+ element.animate?.(
232
+ [
233
+ {
234
+ boxShadow: "inset 0 0 0 2px color-mix(in oklch, var(--og-accent) 70%, transparent)",
235
+ backgroundColor: "color-mix(in oklch, var(--og-accent) 14%, transparent)",
236
+ },
237
+ {
238
+ boxShadow: "inset 0 0 0 2px transparent",
239
+ backgroundColor: "transparent",
240
+ },
241
+ ],
242
+ { duration: 1400, easing: "ease-out" },
243
+ );
244
+ return true;
245
+ }