@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
@@ -0,0 +1,183 @@
1
+ import type {
2
+ DraftTimelineAnnotation,
3
+ TimelineAnnotation,
4
+ TimelineAnnotationSource,
5
+ } from "@opengeni/sdk";
6
+ import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
7
+ import { cn } from "../lib/cn";
8
+ import { annotationNoteNeedsDisclosure } from "./timeline-annotation-layout";
9
+ import { useTimelineAnnotationSourceRoot } from "./timeline-annotation-reveal-context";
10
+ import {
11
+ annotationSourceLabel,
12
+ revealLoadedAnnotationSource,
13
+ truncateAnnotationQuote,
14
+ } from "./timeline-annotation-shared";
15
+
16
+ export type TimelineAnnotationLike = DraftTimelineAnnotation | TimelineAnnotation;
17
+
18
+ const NOTE_MAX_HEIGHT_PX = 160;
19
+
20
+ function autosizeNote(textarea: HTMLTextAreaElement | null): void {
21
+ if (!textarea) return;
22
+ textarea.style.height = "auto";
23
+ textarea.style.height = `${Math.min(Math.max(textarea.scrollHeight, 32), NOTE_MAX_HEIGHT_PX)}px`;
24
+ }
25
+
26
+ function quotePreview(quote: string): { text: string; multiline: boolean } {
27
+ const trimmed = quote.trim();
28
+ const multiline = trimmed.includes("\n");
29
+ return {
30
+ text: multiline ? trimmed : trimmed.replace(/\s+/g, " "),
31
+ multiline,
32
+ };
33
+ }
34
+
35
+ export function AnnotationQuoteSourceButton({
36
+ annotation,
37
+ lines = 2,
38
+ onRevealSource,
39
+ onUnavailable,
40
+ }: {
41
+ annotation: TimelineAnnotationLike;
42
+ lines?: 2 | 3;
43
+ onRevealSource?: ((source: TimelineAnnotationSource) => boolean) | undefined;
44
+ onUnavailable: (id: string | null) => void;
45
+ }) {
46
+ const preview = quotePreview(annotation.quote);
47
+ const sourceLabel = annotationSourceLabel(annotation.source);
48
+ const sourceRoot = useTimelineAnnotationSourceRoot();
49
+ return (
50
+ <button
51
+ type="button"
52
+ title={annotation.quote}
53
+ aria-label={`View ${sourceLabel} source: ${truncateAnnotationQuote(annotation.quote, 88)}`}
54
+ className="block w-full min-w-0 rounded-sm border-0 bg-transparent text-left text-og-sm leading-5 text-og-fg-muted outline-hidden transition-colors hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-[44px]"
55
+ onClick={() => {
56
+ const revealed =
57
+ onRevealSource?.(annotation.source) ??
58
+ (sourceRoot
59
+ ? revealLoadedAnnotationSource(annotation.source, sourceRoot.current)
60
+ : revealLoadedAnnotationSource(annotation.source));
61
+ onUnavailable(revealed ? null : annotation.id);
62
+ }}
63
+ >
64
+ <span
65
+ className={cn(
66
+ "block min-w-0 max-w-full break-words",
67
+ preview.multiline ? "whitespace-pre-wrap" : "whitespace-normal",
68
+ lines === 3 ? "line-clamp-3" : "line-clamp-2",
69
+ )}
70
+ >
71
+ {preview.text}
72
+ </span>
73
+ <span className="sr-only"> view source</span>
74
+ </button>
75
+ );
76
+ }
77
+
78
+ export function AnnotationAccentRow({
79
+ children,
80
+ className,
81
+ }: {
82
+ children: ReactNode;
83
+ className?: string | undefined;
84
+ }) {
85
+ return (
86
+ <div className={cn("flex gap-2.5", className)}>
87
+ <div aria-hidden="true" className="w-0.5 shrink-0 rounded-full bg-og-accent/55" />
88
+ <div className="min-w-0 flex-1">{children}</div>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ export function AnnotationNotePreview({
94
+ note,
95
+ annotationId,
96
+ ordinal,
97
+ className,
98
+ }: {
99
+ note: string;
100
+ annotationId: string;
101
+ ordinal: number;
102
+ className?: string | undefined;
103
+ }) {
104
+ const [expanded, setExpanded] = useState(false);
105
+ if (!note) return null;
106
+ const collapsible = annotationNoteNeedsDisclosure(note);
107
+ const contentId = `og-annotation-note-${annotationId}`;
108
+ return (
109
+ <div className={className}>
110
+ <p
111
+ id={contentId}
112
+ data-og-annotation-note=""
113
+ data-og-annotation-note-expanded={expanded ? "true" : "false"}
114
+ className={cn(
115
+ "mt-0.5 break-words whitespace-pre-wrap text-og-sm leading-5 text-og-fg",
116
+ !expanded && "line-clamp-4",
117
+ )}
118
+ >
119
+ {note}
120
+ </p>
121
+ {collapsible ? (
122
+ <button
123
+ type="button"
124
+ className="mt-0.5 inline-flex min-h-7 items-center rounded-og-sm px-1 text-og-xs font-medium text-og-fg-muted outline-hidden hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:min-h-11"
125
+ aria-expanded={expanded}
126
+ aria-controls={contentId}
127
+ aria-label={
128
+ expanded ? `Show less annotation ${ordinal}` : `Show more annotation ${ordinal}`
129
+ }
130
+ onClick={() => setExpanded((current) => !current)}
131
+ >
132
+ {expanded ? "Show less" : "Show more"}
133
+ </button>
134
+ ) : null}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ export function AnnotationNoteField({
140
+ annotation,
141
+ inputRef,
142
+ onUpdate,
143
+ onCommit,
144
+ }: {
145
+ annotation: TimelineAnnotationLike;
146
+ inputRef?: ((node: HTMLTextAreaElement | null) => void) | undefined;
147
+ onUpdate: (id: string, note: string) => void;
148
+ onCommit?: (() => void) | undefined;
149
+ }) {
150
+ const localRef = useRef<HTMLTextAreaElement | null>(null);
151
+ const setRef = (node: HTMLTextAreaElement | null) => {
152
+ localRef.current = node;
153
+ inputRef?.(node);
154
+ autosizeNote(node);
155
+ };
156
+
157
+ useLayoutEffect(() => {
158
+ autosizeNote(localRef.current);
159
+ }, [annotation.note]);
160
+
161
+ return (
162
+ <textarea
163
+ ref={setRef}
164
+ value={annotation.note}
165
+ rows={1}
166
+ maxLength={2048}
167
+ placeholder="Add a note…"
168
+ aria-label="Note"
169
+ className="mt-0.5 max-h-40 min-h-8 w-full resize-none overflow-y-auto break-words border-0 bg-transparent px-0 py-1 text-og-sm leading-5 text-og-fg outline-hidden placeholder:text-og-fg-subtle focus-visible:ring-0"
170
+ onInput={(event) => {
171
+ autosizeNote(event.currentTarget);
172
+ onUpdate(annotation.id, event.currentTarget.value);
173
+ }}
174
+ onKeyDown={(event) => {
175
+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
176
+ event.preventDefault();
177
+ event.stopPropagation();
178
+ if (event.currentTarget.value.trim().length === 0) return;
179
+ onCommit?.();
180
+ }}
181
+ />
182
+ );
183
+ }
@@ -0,0 +1,268 @@
1
+ export type AnnotationBox = {
2
+ left: number;
3
+ right: number;
4
+ top: number;
5
+ bottom: number;
6
+ };
7
+
8
+ export type AnnotationBadgeAnchor = {
9
+ id: string;
10
+ ordinal: number;
11
+ left: number;
12
+ top: number;
13
+ incomplete: boolean;
14
+ };
15
+
16
+ export const ANNOTATION_BADGE_GAP_PX = 32;
17
+ export const ANNOTATION_BADGE_MIN_GAP_PX = 12;
18
+ export const ANNOTATION_REVIEW_DIALOG_WIDTH_PX = 400;
19
+ export const ANNOTATION_REVIEW_DIALOG_MAX_HEIGHT_PX = 32 * 16;
20
+ export const ANNOTATION_REVIEW_DIALOG_MIN_HEIGHT_PX = 160;
21
+ export const ANNOTATION_REVIEW_DIALOG_FLOOR_HEIGHT_PX = 80;
22
+ export const ANNOTATION_REVIEW_MARGIN_PX = 12;
23
+ export const ANNOTATION_NOTE_PREVIEW_CHARS = 280;
24
+ export const ANNOTATION_CARD_STACK_SCROLL_AT = 4;
25
+
26
+ function clampAnnotationAxis(value: number, min: number, max: number): number {
27
+ return Math.min(Math.max(value, min), Math.max(min, max));
28
+ }
29
+
30
+ export function annotationBoxIntersects(a: AnnotationBox, b: AnnotationBox): boolean {
31
+ return a.left <= b.right && a.right >= b.left && a.top <= b.bottom && a.bottom >= b.top;
32
+ }
33
+
34
+ export function annotationViewportBox(
35
+ viewportWidth: number,
36
+ viewportHeight: number,
37
+ scroller?: AnnotationBox | null,
38
+ ): AnnotationBox {
39
+ const viewport: AnnotationBox = {
40
+ left: 0,
41
+ top: 0,
42
+ right: Math.max(1, viewportWidth),
43
+ bottom: Math.max(1, viewportHeight),
44
+ };
45
+ if (!scroller || scroller.right - scroller.left <= 0 || scroller.bottom - scroller.top <= 0) {
46
+ return viewport;
47
+ }
48
+ return {
49
+ left: Math.max(viewport.left, scroller.left),
50
+ top: Math.max(viewport.top, scroller.top),
51
+ right: Math.min(viewport.right, scroller.right),
52
+ bottom: Math.min(viewport.bottom, scroller.bottom),
53
+ };
54
+ }
55
+
56
+ export function annotationNoteNeedsDisclosure(note: string): boolean {
57
+ const trimmed = note.trim();
58
+ if (trimmed.length === 0) return false;
59
+ return trimmed.length > ANNOTATION_NOTE_PREVIEW_CHARS || trimmed.split("\n").length > 4;
60
+ }
61
+
62
+ function annotationAxisStops(min: number, max: number, gap: number): number[] {
63
+ if (max <= min) return [min];
64
+ const stops: number[] = [];
65
+ for (let value = min; value <= max + 1e-6; value += gap) {
66
+ stops.push(value);
67
+ }
68
+ return stops;
69
+ }
70
+
71
+ function annotationPointKey(left: number, top: number): string {
72
+ return `${Math.round(left * 100) / 100},${Math.round(top * 100) / 100}`;
73
+ }
74
+
75
+ function annotationPointFree(
76
+ left: number,
77
+ top: number,
78
+ placed: readonly AnnotationBadgeAnchor[],
79
+ gap: number,
80
+ ): boolean {
81
+ return placed.every((other) => Math.hypot(left - other.left, top - other.top) >= gap);
82
+ }
83
+
84
+ export function annotationBadgePackGap(
85
+ viewport: AnnotationBox,
86
+ count: number,
87
+ preferred = ANNOTATION_BADGE_GAP_PX,
88
+ ): number {
89
+ if (count <= 1) return preferred;
90
+ const width = Math.max(
91
+ 1,
92
+ viewport.right - ANNOTATION_REVIEW_MARGIN_PX - (viewport.left + ANNOTATION_REVIEW_MARGIN_PX),
93
+ );
94
+ const height = Math.max(1, viewport.bottom - ANNOTATION_REVIEW_MARGIN_PX - (viewport.top + 8));
95
+ let gap = preferred;
96
+ const capacity = (step: number) =>
97
+ (Math.floor(width / step) + 1) * (Math.floor(height / step) + 1);
98
+ while (gap > ANNOTATION_BADGE_MIN_GAP_PX && capacity(gap) < count) {
99
+ gap -= 1;
100
+ }
101
+ return gap;
102
+ }
103
+
104
+ const ANNOTATION_BADGE_SHIFT_DIRS: Array<[number, number]> = [
105
+ [1, 0],
106
+ [1, 1],
107
+ [0, 1],
108
+ [-1, 1],
109
+ [-1, 0],
110
+ [-1, -1],
111
+ [0, -1],
112
+ [1, -1],
113
+ ];
114
+
115
+ export function layoutAnnotationBadges(
116
+ anchors: readonly AnnotationBadgeAnchor[],
117
+ viewport: AnnotationBox,
118
+ preferredGap = ANNOTATION_BADGE_GAP_PX,
119
+ ): AnnotationBadgeAnchor[] {
120
+ const minLeft = viewport.left + ANNOTATION_REVIEW_MARGIN_PX;
121
+ const maxLeft = viewport.right - ANNOTATION_REVIEW_MARGIN_PX;
122
+ const minTop = viewport.top + 8;
123
+ const maxTop = viewport.bottom - ANNOTATION_REVIEW_MARGIN_PX;
124
+ const gap = annotationBadgePackGap(viewport, anchors.length, preferredGap);
125
+ const cells = annotationAxisStops(minLeft, maxLeft, gap).flatMap((left) =>
126
+ annotationAxisStops(minTop, maxTop, gap).map((top) => ({ left, top })),
127
+ );
128
+ const placed: AnnotationBadgeAnchor[] = [];
129
+ for (const anchor of [...anchors].sort((left, right) => left.ordinal - right.ordinal)) {
130
+ const originLeft = clampAnnotationAxis(anchor.left, minLeft, maxLeft);
131
+ const originTop = clampAnnotationAxis(anchor.top, minTop, maxTop);
132
+ const seen = new Set<string>();
133
+ const candidates: Array<{ left: number; top: number }> = [];
134
+ const pushCandidate = (left: number, top: number) => {
135
+ if (left < minLeft || left > maxLeft || top < minTop || top > maxTop) return;
136
+ const key = annotationPointKey(left, top);
137
+ if (seen.has(key)) return;
138
+ seen.add(key);
139
+ candidates.push({ left, top });
140
+ };
141
+ pushCandidate(originLeft, originTop);
142
+ for (const [dx, dy] of ANNOTATION_BADGE_SHIFT_DIRS) {
143
+ const colliders = [...placed].sort(
144
+ (left, right) => right.left * dx + right.top * dy - (left.left * dx + left.top * dy),
145
+ );
146
+ for (const collider of colliders) {
147
+ pushCandidate(collider.left + dx * gap, collider.top + dy * gap);
148
+ }
149
+ }
150
+ const maxRadius =
151
+ Math.ceil(Math.max(maxLeft - minLeft, maxTop - minTop) / Math.max(gap, 1)) + 1;
152
+ for (let radius = 1; radius <= maxRadius; radius++) {
153
+ const ring: Array<[number, number]> = [];
154
+ for (let dx = -radius; dx <= radius; dx++) {
155
+ for (let dy = -radius; dy <= radius; dy++) {
156
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue;
157
+ ring.push([dx, dy]);
158
+ }
159
+ }
160
+ ring.sort((left, right) => {
161
+ const leftDir = (left[0] < 0 ? 2 : 0) + (left[1] < 0 ? 1 : 0);
162
+ const rightDir = (right[0] < 0 ? 2 : 0) + (right[1] < 0 ? 1 : 0);
163
+ if (leftDir !== rightDir) return leftDir - rightDir;
164
+ const hypot = Math.hypot(left[0], left[1]) - Math.hypot(right[0], right[1]);
165
+ if (hypot !== 0) return hypot;
166
+ if (left[0] !== right[0]) return right[0] - left[0];
167
+ return left[1] - right[1];
168
+ });
169
+ for (const [dx, dy] of ring) {
170
+ pushCandidate(originLeft + dx * gap, originTop + dy * gap);
171
+ }
172
+ }
173
+ for (const cell of cells) {
174
+ pushCandidate(cell.left, cell.top);
175
+ }
176
+ const free = candidates.find((point) =>
177
+ annotationPointFree(point.left, point.top, placed, gap),
178
+ );
179
+ let chosen = free ?? candidates[0]!;
180
+ if (!free && placed.length > 0) {
181
+ let bestMin = Number.NEGATIVE_INFINITY;
182
+ for (const point of candidates) {
183
+ const minDist = placed.reduce(
184
+ (current, other) =>
185
+ Math.min(current, Math.hypot(point.left - other.left, point.top - other.top)),
186
+ Number.POSITIVE_INFINITY,
187
+ );
188
+ if (minDist > bestMin) {
189
+ bestMin = minDist;
190
+ chosen = point;
191
+ }
192
+ }
193
+ }
194
+ placed.push({ ...anchor, left: chosen.left, top: chosen.top });
195
+ }
196
+ return placed;
197
+ }
198
+
199
+ export function scrollAnnotationRowIntoList(
200
+ list: HTMLElement | null | undefined,
201
+ row: HTMLElement | null | undefined,
202
+ ): void {
203
+ if (!list || !row || !list.contains(row)) return;
204
+ const listRect = list.getBoundingClientRect();
205
+ const rowRect = row.getBoundingClientRect();
206
+ if (rowRect.top < listRect.top) {
207
+ list.scrollTop -= listRect.top - rowRect.top;
208
+ return;
209
+ }
210
+ if (rowRect.bottom > listRect.bottom) {
211
+ list.scrollTop += rowRect.bottom - listRect.bottom;
212
+ }
213
+ }
214
+
215
+ export function clampAnnotationDialogPlacement(input: {
216
+ triggerLeft: number;
217
+ triggerTop: number;
218
+ triggerBottom: number;
219
+ contentHeight: number;
220
+ viewportWidth: number;
221
+ viewportHeight: number;
222
+ panelWidth?: number;
223
+ }): {
224
+ left: number;
225
+ top: number;
226
+ maxHeight: number;
227
+ above: boolean;
228
+ } {
229
+ const margin = ANNOTATION_REVIEW_MARGIN_PX;
230
+ const panelWidth = Math.min(
231
+ input.panelWidth ?? ANNOTATION_REVIEW_DIALOG_WIDTH_PX,
232
+ Math.max(1, input.viewportWidth - margin * 2),
233
+ );
234
+ const left = Math.min(
235
+ Math.max(margin, input.triggerLeft),
236
+ Math.max(margin, input.viewportWidth - panelWidth - margin),
237
+ );
238
+ const spaceBelow = input.viewportHeight - input.triggerBottom - margin;
239
+ const spaceAbove = input.triggerTop - margin;
240
+ const above = spaceBelow < Math.min(input.contentHeight, 200) && spaceAbove > spaceBelow;
241
+ const viewportBudget = Math.max(
242
+ ANNOTATION_REVIEW_DIALOG_FLOOR_HEIGHT_PX,
243
+ input.viewportHeight - margin * 2,
244
+ );
245
+ const available = Math.max(
246
+ Math.min(ANNOTATION_REVIEW_DIALOG_MIN_HEIGHT_PX, viewportBudget),
247
+ above ? spaceAbove : spaceBelow,
248
+ );
249
+ let maxHeight = Math.min(
250
+ Math.max(input.contentHeight, Math.min(ANNOTATION_REVIEW_DIALOG_MIN_HEIGHT_PX, viewportBudget)),
251
+ available,
252
+ Math.floor(input.viewportHeight * 0.7) || viewportBudget,
253
+ ANNOTATION_REVIEW_DIALOG_MAX_HEIGHT_PX,
254
+ viewportBudget,
255
+ );
256
+ let top = above
257
+ ? Math.max(margin, input.triggerTop - 8 - maxHeight)
258
+ : Math.min(
259
+ input.triggerBottom + 8,
260
+ Math.max(margin, input.viewportHeight - margin - maxHeight),
261
+ );
262
+ top = Math.max(margin, top);
263
+ maxHeight = Math.min(
264
+ maxHeight,
265
+ Math.max(ANNOTATION_REVIEW_DIALOG_FLOOR_HEIGHT_PX, input.viewportHeight - margin - top),
266
+ );
267
+ return { left, top, maxHeight, above };
268
+ }
@@ -0,0 +1,185 @@
1
+ import type { DraftTimelineAnnotation } from "@opengeni/sdk";
2
+ import { useLayoutEffect, useState, type RefObject } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { cn } from "../lib/cn";
5
+ import {
6
+ annotationBoxIntersects,
7
+ annotationViewportBox,
8
+ layoutAnnotationBadges,
9
+ type AnnotationBadgeAnchor,
10
+ type AnnotationBox,
11
+ } from "./timeline-annotation-layout";
12
+ import {
13
+ annotationDisplayOrdinal,
14
+ annotationHasNote,
15
+ annotatableText,
16
+ buildQuoteRange,
17
+ cssEscapeAttribute,
18
+ matchingQuoteInSource,
19
+ occurrenceOffsets,
20
+ } from "./timeline-annotation-shared";
21
+
22
+ function lastVisibleRect(range: Range): DOMRect | null {
23
+ try {
24
+ const rects = range.getClientRects();
25
+ for (let index = rects.length - 1; index >= 0; index--) {
26
+ const rect = rects.item(index);
27
+ if (rect && (rect.width > 0 || rect.height > 0)) return rect;
28
+ }
29
+ } catch {
30
+ // jsdom ranges may omit client rects.
31
+ }
32
+ try {
33
+ const bounding = range.getBoundingClientRect();
34
+ if (bounding.width > 0 || bounding.height > 0) return bounding;
35
+ } catch {
36
+ // jsdom ranges may omit layout geometry.
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function occurrenceForAnnotation(
42
+ sourceEl: HTMLElement,
43
+ annotation: DraftTimelineAnnotation,
44
+ ): number {
45
+ const { text } = annotatableText(sourceEl);
46
+ const quote = matchingQuoteInSource(text, annotation.quote) ?? annotation.quote;
47
+ const offsets = occurrenceOffsets(text, quote);
48
+ if (offsets.length <= 1) return 0;
49
+ let best = 0;
50
+ let bestDistance = Number.POSITIVE_INFINITY;
51
+ offsets.forEach((offset, index) => {
52
+ const distance = Math.abs(offset - annotation.source.startOffset);
53
+ if (distance < bestDistance) {
54
+ best = index;
55
+ bestDistance = distance;
56
+ }
57
+ });
58
+ return best;
59
+ }
60
+
61
+ function timelineScroller(root: HTMLElement): HTMLElement {
62
+ if (root.matches("[data-og-timeline-scroller]")) return root;
63
+ const nested = root.querySelector("[data-og-timeline-scroller]");
64
+ return nested instanceof HTMLElement ? nested : root;
65
+ }
66
+
67
+ function currentAnnotationViewport(root: HTMLElement): AnnotationBox {
68
+ const scroller = timelineScroller(root);
69
+ const rect = scroller.getBoundingClientRect();
70
+ const scrollerBox = {
71
+ left: rect.left,
72
+ right: rect.right,
73
+ top: rect.top,
74
+ bottom: rect.bottom,
75
+ } satisfies AnnotationBox;
76
+ return annotationViewportBox(window.innerWidth || 1024, window.innerHeight || 768, scrollerBox);
77
+ }
78
+
79
+ function sourceInTimeline(root: HTMLElement, eventId: string): HTMLElement | null {
80
+ const source = root.querySelector(
81
+ `[data-og-annotation-source-key="${cssEscapeAttribute(eventId)}"]`,
82
+ );
83
+ return source instanceof HTMLElement ? source : null;
84
+ }
85
+
86
+ function collectMarkers(
87
+ root: HTMLElement | null,
88
+ annotations: readonly DraftTimelineAnnotation[],
89
+ ): AnnotationBadgeAnchor[] {
90
+ if (!root) return [];
91
+ const viewport = currentAnnotationViewport(root);
92
+ const next: AnnotationBadgeAnchor[] = [];
93
+ for (const [index, annotation] of annotations.entries()) {
94
+ const source = sourceInTimeline(root, annotation.source.eventId);
95
+ if (!source) continue;
96
+ const quote =
97
+ matchingQuoteInSource(annotatableText(source).text, annotation.quote) ?? annotation.quote;
98
+ const range = buildQuoteRange(source, quote, occurrenceForAnnotation(source, annotation));
99
+ if (!range) continue;
100
+ const rect = lastVisibleRect(range);
101
+ if (!rect) continue;
102
+ if (
103
+ !annotationBoxIntersects(
104
+ { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom },
105
+ viewport,
106
+ )
107
+ ) {
108
+ continue;
109
+ }
110
+ next.push({
111
+ id: annotation.id,
112
+ ordinal: annotationDisplayOrdinal(annotation, index),
113
+ left: rect.right,
114
+ top: rect.top,
115
+ incomplete: !annotationHasNote(annotation.note),
116
+ });
117
+ }
118
+ return layoutAnnotationBadges(next, viewport);
119
+ }
120
+
121
+ export function TimelineAnnotationMarkers({
122
+ annotations,
123
+ onSelect,
124
+ rootRef,
125
+ }: {
126
+ annotations: readonly DraftTimelineAnnotation[];
127
+ onSelect?: ((id: string) => void) | undefined;
128
+ rootRef: RefObject<HTMLElement | null>;
129
+ }) {
130
+ const [markers, setMarkers] = useState<AnnotationBadgeAnchor[]>([]);
131
+
132
+ useLayoutEffect(() => {
133
+ const root = rootRef.current;
134
+ if (!root || annotations.length === 0) {
135
+ setMarkers([]);
136
+ return;
137
+ }
138
+ const update = () => setMarkers(collectMarkers(root, annotations));
139
+ update();
140
+ window.addEventListener("resize", update);
141
+ window.addEventListener("scroll", update, true);
142
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(update) : null;
143
+ observer?.observe(root);
144
+ for (const annotation of annotations) {
145
+ const source = sourceInTimeline(root, annotation.source.eventId);
146
+ if (source) observer?.observe(source);
147
+ }
148
+ return () => {
149
+ window.removeEventListener("resize", update);
150
+ window.removeEventListener("scroll", update, true);
151
+ observer?.disconnect();
152
+ };
153
+ }, [annotations, rootRef]);
154
+
155
+ if (markers.length === 0 || typeof document === "undefined") return null;
156
+ return createPortal(
157
+ <div className="og-root pointer-events-none fixed inset-0 z-[35]">
158
+ {markers.map((marker) => (
159
+ <button
160
+ key={marker.id}
161
+ type="button"
162
+ data-og-annotation-badge=""
163
+ data-og-annotation-badge-ordinal={marker.ordinal}
164
+ style={{ left: marker.left, top: marker.top, zIndex: marker.ordinal }}
165
+ className={cn(
166
+ "pointer-events-auto absolute flex h-4 min-w-4 -translate-x-1/2 -translate-y-[110%] items-center justify-center rounded-full bg-og-accent px-0.5 text-[10px] font-semibold tabular-nums text-white shadow-sm outline-hidden select-none after:absolute after:-inset-2 after:content-[''] focus-visible:ring-2 focus-visible:ring-og-accent pointer-coarse:after:-inset-2.5",
167
+ marker.incomplete && "ring-1 ring-og-accent-fg/35",
168
+ )}
169
+ aria-label={`Annotation ${marker.ordinal}`}
170
+ onMouseDown={(event) => event.preventDefault()}
171
+ onClick={(event) => {
172
+ event.preventDefault();
173
+ event.stopPropagation();
174
+ onSelect?.(marker.id);
175
+ }}
176
+ >
177
+ {marker.ordinal}
178
+ </button>
179
+ ))}
180
+ </div>,
181
+ document.body,
182
+ );
183
+ }
184
+
185
+ export default TimelineAnnotationMarkers;
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext, type RefObject } from "react";
2
+
3
+ /** Owning timeline scroller for quote-source navigation. */
4
+ export const TimelineAnnotationSourceRootContext =
5
+ createContext<RefObject<HTMLElement | null> | null>(null);
6
+
7
+ export function useTimelineAnnotationSourceRoot(): RefObject<HTMLElement | null> | null {
8
+ return useContext(TimelineAnnotationSourceRootContext);
9
+ }