@agent-native/toolkit 0.11.2 → 0.12.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/README.md +19 -0
  2. package/agent-native.eject.json +38 -0
  3. package/dist/canvas-annotations/CanvasCommentPins.d.ts +68 -0
  4. package/dist/canvas-annotations/CanvasCommentPins.d.ts.map +1 -0
  5. package/dist/canvas-annotations/CanvasCommentPins.js +513 -0
  6. package/dist/canvas-annotations/CanvasCommentPins.js.map +1 -0
  7. package/dist/canvas-annotations/DrawOverlay.d.ts +108 -0
  8. package/dist/canvas-annotations/DrawOverlay.d.ts.map +1 -0
  9. package/dist/canvas-annotations/DrawOverlay.js +746 -0
  10. package/dist/canvas-annotations/DrawOverlay.js.map +1 -0
  11. package/dist/canvas-annotations/DrawOverlay.spec.d.ts +2 -0
  12. package/dist/canvas-annotations/DrawOverlay.spec.d.ts.map +1 -0
  13. package/dist/canvas-annotations/DrawOverlay.spec.js +111 -0
  14. package/dist/canvas-annotations/DrawOverlay.spec.js.map +1 -0
  15. package/dist/canvas-annotations/index.d.ts +4 -0
  16. package/dist/canvas-annotations/index.d.ts.map +1 -0
  17. package/dist/canvas-annotations/index.js +3 -0
  18. package/dist/canvas-annotations/index.js.map +1 -0
  19. package/dist/canvas-annotations/types.d.ts +8 -0
  20. package/dist/canvas-annotations/types.d.ts.map +1 -0
  21. package/dist/canvas-annotations/types.js +2 -0
  22. package/dist/canvas-annotations/types.js.map +1 -0
  23. package/dist/canvas-interactions/canvas-interactions.d.ts +334 -0
  24. package/dist/canvas-interactions/canvas-interactions.d.ts.map +1 -0
  25. package/dist/canvas-interactions/canvas-interactions.js +451 -0
  26. package/dist/canvas-interactions/canvas-interactions.js.map +1 -0
  27. package/dist/canvas-interactions/canvas-interactions.spec.d.ts +2 -0
  28. package/dist/canvas-interactions/canvas-interactions.spec.d.ts.map +1 -0
  29. package/dist/canvas-interactions/canvas-interactions.spec.js +538 -0
  30. package/dist/canvas-interactions/canvas-interactions.spec.js.map +1 -0
  31. package/dist/canvas-interactions/index.d.ts +2 -0
  32. package/dist/canvas-interactions/index.d.ts.map +1 -0
  33. package/dist/canvas-interactions/index.js +2 -0
  34. package/dist/canvas-interactions/index.js.map +1 -0
  35. package/dist/composer/runtime-adapters.d.ts.map +1 -1
  36. package/dist/composer/runtime-adapters.js +13 -10
  37. package/dist/composer/runtime-adapters.js.map +1 -1
  38. package/dist/composer/runtime-adapters.spec.d.ts +2 -0
  39. package/dist/composer/runtime-adapters.spec.d.ts.map +1 -0
  40. package/dist/composer/runtime-adapters.spec.js +54 -0
  41. package/dist/composer/runtime-adapters.spec.js.map +1 -0
  42. package/dist/design-tweaks/scrub-input-utils.d.ts +61 -0
  43. package/dist/design-tweaks/scrub-input-utils.d.ts.map +1 -0
  44. package/dist/design-tweaks/scrub-input-utils.js +235 -0
  45. package/dist/design-tweaks/scrub-input-utils.js.map +1 -0
  46. package/dist/design-tweaks/scrub-input.d.ts +66 -0
  47. package/dist/design-tweaks/scrub-input.d.ts.map +1 -0
  48. package/dist/design-tweaks/scrub-input.js +313 -0
  49. package/dist/design-tweaks/scrub-input.js.map +1 -0
  50. package/dist/design-tweaks/visual-style-controls.d.ts +2 -12
  51. package/dist/design-tweaks/visual-style-controls.d.ts.map +1 -1
  52. package/dist/design-tweaks/visual-style-controls.js +3 -107
  53. package/dist/design-tweaks/visual-style-controls.js.map +1 -1
  54. package/dist/design-tweaks/visual-style-controls.spec.js +26 -0
  55. package/dist/design-tweaks/visual-style-controls.spec.js.map +1 -1
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -0
  59. package/dist/index.js.map +1 -1
  60. package/package.json +29 -1
  61. package/src/canvas-annotations/CanvasCommentPins.tsx +861 -0
  62. package/src/canvas-annotations/DrawOverlay.spec.tsx +145 -0
  63. package/src/canvas-annotations/DrawOverlay.tsx +1233 -0
  64. package/src/canvas-annotations/index.ts +15 -0
  65. package/src/canvas-annotations/types.ts +14 -0
  66. package/src/canvas-interactions/canvas-interactions.spec.ts +688 -0
  67. package/src/canvas-interactions/canvas-interactions.ts +933 -0
  68. package/src/canvas-interactions/index.ts +67 -0
  69. package/src/composer/runtime-adapters.spec.tsx +83 -0
  70. package/src/composer/runtime-adapters.tsx +16 -11
  71. package/src/design-tweaks/scrub-input-utils.ts +311 -0
  72. package/src/design-tweaks/scrub-input.tsx +491 -0
  73. package/src/design-tweaks/visual-style-controls.spec.tsx +60 -0
  74. package/src/design-tweaks/visual-style-controls.tsx +23 -168
  75. package/src/index.ts +1 -0
@@ -0,0 +1,746 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { IconEraser, IconArrowBackUp, IconArrowForwardUp, IconLoader2, IconSend, IconCursorText, IconX, } from "@tabler/icons-react";
3
+ import { useState, useRef, useCallback, useEffect, useLayoutEffect, } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { toast } from "sonner";
6
+ import { Button } from "../ui/button.js";
7
+ import { Input } from "../ui/input.js";
8
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../ui/tooltip.js";
9
+ import { cn } from "../utils.js";
10
+ const PRESET_COLORS = [
11
+ { color: "#ef4444", label: "Red" },
12
+ { color: "#3b82f6", label: "Blue" },
13
+ { color: "#22c55e", label: "Green" },
14
+ { color: "#eab308", label: "Yellow" },
15
+ ];
16
+ const LINE_WIDTHS = [
17
+ { value: 2, label: "Thin" },
18
+ { value: 4, label: "Medium" },
19
+ { value: 8, label: "Thick" },
20
+ ];
21
+ /**
22
+ * Keep long pen/stylus gestures bounded before they become agent prompt data.
23
+ * Once this limit is reached, older samples are progressively decimated while
24
+ * preserving the first, latest, and future points. At normal pointer rates the
25
+ * sub-pixel filter below is the only sampling users will notice.
26
+ */
27
+ const MAX_STROKE_POINTS = 2048;
28
+ const MIN_POINT_DISTANCE_PX = 0.35;
29
+ function pointFromClient(clientX, clientY, rect) {
30
+ if (rect.width <= 0 || rect.height <= 0)
31
+ return null;
32
+ return {
33
+ x: Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)),
34
+ y: Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)),
35
+ };
36
+ }
37
+ function appendStrokePoint(points, point, rect, force = false) {
38
+ const last = points[points.length - 1];
39
+ if (last) {
40
+ const dx = (point.x - last.x) * rect.width;
41
+ const dy = (point.y - last.y) * rect.height;
42
+ const distanceSquared = dx * dx + dy * dy;
43
+ if (distanceSquared === 0 ||
44
+ (!force && distanceSquared < MIN_POINT_DISTANCE_PX ** 2)) {
45
+ return points;
46
+ }
47
+ }
48
+ let next = points;
49
+ if (next.length >= MAX_STROKE_POINTS) {
50
+ // Preserve the full trajectory instead of dropping the tail of a long
51
+ // gesture. Repeated compaction gradually lowers only the oldest sampling
52
+ // density, which is visually preferable to a path that suddenly stops.
53
+ const compacted = [next[0]];
54
+ for (let index = 2; index < next.length - 1; index += 2) {
55
+ compacted.push(next[index]);
56
+ }
57
+ compacted.push(next[next.length - 1]);
58
+ next = compacted;
59
+ }
60
+ next.push(point);
61
+ return next;
62
+ }
63
+ /**
64
+ * Draw-to-prompt overlay for the slide canvas.
65
+ *
66
+ * Mirrors claude.ai/design's "Draw mode": the user sketches on the canvas,
67
+ * adds a one-line text instruction, hits Send, and the strokes + instruction
68
+ * are forwarded to the agent. The agent receives the path geometry along with
69
+ * the canvas size so it can interpret position semantically (e.g. "move the
70
+ * title here").
71
+ *
72
+ * This component is canvas-agnostic — it overlays absolutely-positioned over
73
+ * its parent, so the parent (a slide editor or design canvas) only needs to
74
+ * be `position: relative`.
75
+ *
76
+ * Coordinate model
77
+ * ----------------
78
+ * All stroke points and text positions are stored as fractions (0..1) of the
79
+ * canvas's visual rect (getBoundingClientRect). This makes them stable across
80
+ * zoom and resize. When rendering:
81
+ * - Canvas strokes: multiply by rect.width / rect.height (visual pixels).
82
+ * - CSS text labels: multiply by rect.width/scale / rect.height/scale
83
+ * (layout pixels inside the scaled wrapper).
84
+ * - pathData in send(): layout pixels = fraction * rect.width / scale.
85
+ */
86
+ export function DrawOverlay({ translate, visible, canvasInteractive = true, queuedAnnotationCount = 0, zoom = 100, onSend, onClose, sending = false, clearSignal, scopeKey, retainSurfaceWhenHidden = false, }) {
87
+ const t = translate;
88
+ const containerRef = useRef(null);
89
+ const canvasRef = useRef(null);
90
+ const [color, setColor] = useState(PRESET_COLORS[0].color);
91
+ const [lineWidth, setLineWidth] = useState(LINE_WIDTHS[1].value);
92
+ const [strokes, setStrokes] = useState([]);
93
+ const strokesRef = useRef([]);
94
+ const [textAnnotations, setTextAnnotations] = useState([]);
95
+ const textAnnotationsRef = useRef([]);
96
+ // Unified redo stack. Each entry is either a Stroke or a DrawAnnotation so
97
+ // undo/redo work in creation order across both types.
98
+ const [redoStack, setRedoStack] = useState([]);
99
+ const clearUndoGenerationRef = useRef(0);
100
+ const [currentStroke, setCurrentStroke] = useState(null);
101
+ // Pointer events can arrive down -> move -> up before React renders once.
102
+ // This ref is the authoritative in-progress gesture; state is only a
103
+ // frame-throttled snapshot used to redraw the canvas.
104
+ const currentStrokeRef = useRef(null);
105
+ const currentStrokeFrameRef = useRef(null);
106
+ const activePointerIdRef = useRef(null);
107
+ const activeStrokeStyleRef = useRef({ color, lineWidth });
108
+ const lastCreatedAtRef = useRef(0);
109
+ const [textMode, setTextMode] = useState(false);
110
+ const [textInput, setTextInput] = useState(null);
111
+ const textInputStateRef = useRef(null);
112
+ const textInputGenerationRef = useRef(0);
113
+ const textInputRef = useRef(null);
114
+ // Escape cancels the pending text annotation, but unmounting the input also
115
+ // fires its blur handler, which would commit the very annotation the user
116
+ // just cancelled. This flag lets the blur handler skip that commit.
117
+ const cancelingTextRef = useRef(false);
118
+ const [instruction, setInstruction] = useState("");
119
+ const instructionRef = useRef("");
120
+ const drawing = useRef(false);
121
+ const canvasSizeRef = useRef({ w: 0, h: 0 });
122
+ // Resize tick: bumped by ResizeObserver so the redraw effect re-runs when
123
+ // the canvas element's CSS size changes (e.g. device frame switch).
124
+ const [resizeTick, setResizeTick] = useState(0);
125
+ const scale = Math.max(zoom / 100, 0.01);
126
+ // A host can still unmount the active editor entirely (route/design close,
127
+ // or focused canvas teardown) rather than merely hiding this component.
128
+ // Losing unsent work in that teardown must never be silent. Normal
129
+ // overview/focused visibility changes retain their active surface and do
130
+ // not hit this cleanup; only a real component unmount does.
131
+ useEffect(() => {
132
+ return () => {
133
+ clearUndoGenerationRef.current += 1;
134
+ // A true unmount never runs the `!visible` effect above (the prop
135
+ // never transitions — the component is simply gone), so a still-open
136
+ // pending text box never gets its usual commit-on-hide chance. Count
137
+ // it here too, or a label the user was mid-typing would vanish
138
+ // without even counting toward the warning.
139
+ const pendingText = textInputStateRef.current?.value.trim();
140
+ const discardedCount = strokesRef.current.length +
141
+ textAnnotationsRef.current.length +
142
+ (pendingText ? 1 : 0) +
143
+ (instructionRef.current.trim() ? 1 : 0);
144
+ if (discardedCount > 0) {
145
+ toast(t("visualEditor.annotationsDiscardedOnViewChange", {
146
+ count: discardedCount,
147
+ }));
148
+ }
149
+ };
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- must only run
151
+ // its cleanup on actual unmount, not on every `t`/locale change.
152
+ }, []);
153
+ const cancelScheduledStrokeFrame = useCallback(() => {
154
+ if (currentStrokeFrameRef.current === null)
155
+ return;
156
+ window.cancelAnimationFrame(currentStrokeFrameRef.current);
157
+ currentStrokeFrameRef.current = null;
158
+ }, []);
159
+ const scheduleCurrentStrokeRedraw = useCallback(() => {
160
+ if (currentStrokeFrameRef.current !== null)
161
+ return;
162
+ currentStrokeFrameRef.current = window.requestAnimationFrame(() => {
163
+ currentStrokeFrameRef.current = null;
164
+ const points = currentStrokeRef.current;
165
+ setCurrentStroke(points ? [...points] : null);
166
+ });
167
+ }, []);
168
+ const resetActiveStroke = useCallback(() => {
169
+ drawing.current = false;
170
+ activePointerIdRef.current = null;
171
+ currentStrokeRef.current = null;
172
+ cancelScheduledStrokeFrame();
173
+ setCurrentStroke(null);
174
+ }, [cancelScheduledStrokeFrame]);
175
+ const nextCreatedAt = useCallback(() => {
176
+ const next = Math.max(Date.now(), lastCreatedAtRef.current + 1);
177
+ lastCreatedAtRef.current = next;
178
+ return next;
179
+ }, []);
180
+ const setPendingTextInput = useCallback((update) => {
181
+ const next = typeof update === "function"
182
+ ? update(textInputStateRef.current)
183
+ : update;
184
+ textInputStateRef.current = next;
185
+ setTextInput(next);
186
+ }, []);
187
+ const clearAnnotationState = useCallback(() => {
188
+ clearUndoGenerationRef.current += 1;
189
+ resetActiveStroke();
190
+ strokesRef.current = [];
191
+ textAnnotationsRef.current = [];
192
+ setStrokes([]);
193
+ setTextAnnotations([]);
194
+ setRedoStack([]);
195
+ setPendingTextInput(null);
196
+ setTextMode(false);
197
+ instructionRef.current = "";
198
+ setInstruction("");
199
+ cancelingTextRef.current = false;
200
+ lastCreatedAtRef.current = 0;
201
+ textInputGenerationRef.current = 0;
202
+ }, [resetActiveStroke, setPendingTextInput]);
203
+ // Hiding ends whatever the user was mid-gesture on, but must NOT discard
204
+ // already-committed strokes/text: `visible` can turn false for reasons
205
+ // that have nothing to do with the user wanting to abandon their
206
+ // annotations (switching tools, views, or side panels). Only reset the
207
+ // transient in-flight state here — the committed `strokes`/
208
+ // `textAnnotations` arrays are cleared exclusively by `clearSignal` below.
209
+ useEffect(() => {
210
+ if (!visible) {
211
+ resetActiveStroke();
212
+ setTextMode(false);
213
+ cancelingTextRef.current = false;
214
+ }
215
+ }, [resetActiveStroke, visible]);
216
+ // Comment-pin mode can temporarily leave the toolbar visible while making
217
+ // the drawing surface inert. Never retain a half-finished gesture across
218
+ // that tool switch.
219
+ useEffect(() => {
220
+ if (!canvasInteractive)
221
+ resetActiveStroke();
222
+ }, [canvasInteractive, resetActiveStroke]);
223
+ useEffect(() => () => {
224
+ cancelScheduledStrokeFrame();
225
+ }, [cancelScheduledStrokeFrame]);
226
+ const shouldFocusTextInput = textInput !== null;
227
+ useEffect(() => {
228
+ if (!visible || !shouldFocusTextInput)
229
+ return;
230
+ const id = window.requestAnimationFrame(() => {
231
+ textInputRef.current?.focus();
232
+ });
233
+ return () => window.cancelAnimationFrame(id);
234
+ }, [shouldFocusTextInput, visible]);
235
+ // Retained editor surfaces keep their canvas mounted while hidden, so a
236
+ // hidden-first host installs its observer and the bitmap does not flash
237
+ // blank when the user re-enters annotate mode. Non-retained preview-grid
238
+ // overlays attach only while visible to avoid idle canvas/observer cost.
239
+ useEffect(() => {
240
+ if (!visible && !retainSurfaceWhenHidden)
241
+ return;
242
+ const canvas = canvasRef.current;
243
+ if (!canvas)
244
+ return;
245
+ if (typeof ResizeObserver === "undefined")
246
+ return;
247
+ const ro = new ResizeObserver(() => setResizeTick((t) => t + 1));
248
+ ro.observe(canvas);
249
+ return () => ro.disconnect();
250
+ }, [retainSurfaceWhenHidden, visible]);
251
+ // Redraw before paint whenever strokes change, the canvas is resized, or a
252
+ // preserved batch becomes visible again. The visible dependency is
253
+ // intentional even though the DOM stays mounted: browsers may discard a
254
+ // hidden canvas backing store under memory pressure, and re-entry must
255
+ // never expose a blank frame before the next pointer event.
256
+ useLayoutEffect(() => {
257
+ if (!visible)
258
+ return;
259
+ const canvas = canvasRef.current;
260
+ if (!canvas)
261
+ return;
262
+ const ctx = canvas.getContext("2d");
263
+ if (!ctx)
264
+ return;
265
+ const rect = canvas.getBoundingClientRect();
266
+ const dpr = window.devicePixelRatio;
267
+ const newW = rect.width * dpr;
268
+ const newH = rect.height * dpr;
269
+ if (newW !== canvasSizeRef.current.w || newH !== canvasSizeRef.current.h) {
270
+ canvas.width = newW;
271
+ canvas.height = newH;
272
+ canvasSizeRef.current = { w: newW, h: newH };
273
+ ctx.scale(dpr, dpr);
274
+ }
275
+ ctx.clearRect(0, 0, rect.width, rect.height);
276
+ // Points are stored as fractions; multiply by current rect to get visual px.
277
+ for (const stroke of strokes) {
278
+ drawStroke(ctx, stroke.points.map((p) => ({
279
+ x: p.x * rect.width,
280
+ y: p.y * rect.height,
281
+ })), stroke.color, stroke.lineWidth);
282
+ }
283
+ if (currentStroke && currentStroke.length > 0) {
284
+ const activeStyle = activeStrokeStyleRef.current;
285
+ drawStroke(ctx, currentStroke.map((p) => ({
286
+ x: p.x * rect.width,
287
+ y: p.y * rect.height,
288
+ })), activeStyle.color, activeStyle.lineWidth);
289
+ }
290
+ // `zoom` is a dependency because it scales the canvas via a CSS transform,
291
+ // which changes getBoundingClientRect() without firing ResizeObserver — the
292
+ // effect must re-run on zoom change to redraw the fraction-based strokes at
293
+ // the new visual size.
294
+ }, [strokes, currentStroke, color, lineWidth, resizeTick, visible, zoom]);
295
+ /**
296
+ * Commits the pending text annotation (if any) and clears the pending
297
+ * input. `expectedGeneration`, when passed, guards against a stale
298
+ * blur/Enter/Escape belonging to a pending text box that has since been
299
+ * replaced or already cleared — see `PendingTextInput.generation`. Called
300
+ * with no argument to force-commit whatever is currently pending (e.g.
301
+ * before opening a new text box, or right before Send).
302
+ */
303
+ const commitTextAnnotation = useCallback((expectedGeneration) => {
304
+ const pendingText = textInputStateRef.current;
305
+ if (!pendingText ||
306
+ (expectedGeneration !== undefined &&
307
+ pendingText.generation !== expectedGeneration)) {
308
+ return;
309
+ }
310
+ // Clear the authoritative pending value first. Enter, blur, and Send can
311
+ // all occur in the same browser turn; any later handler becomes a no-op
312
+ // instead of duplicating the label.
313
+ setPendingTextInput(null);
314
+ if (!pendingText.value.trim())
315
+ return;
316
+ const ann = {
317
+ id: crypto.randomUUID(),
318
+ type: "text",
319
+ text: pendingText.value.trim(),
320
+ // Store fractional position so the label stays anchored across zoom changes.
321
+ position: { x: pendingText.xFrac, y: pendingText.yFrac },
322
+ color,
323
+ lineWidth,
324
+ createdAt: nextCreatedAt(),
325
+ };
326
+ const nextTexts = [...textAnnotationsRef.current, ann];
327
+ textAnnotationsRef.current = nextTexts;
328
+ setTextAnnotations(nextTexts);
329
+ // A new text annotation also clears the redo stack.
330
+ setRedoStack([]);
331
+ }, [color, lineWidth, nextCreatedAt, setPendingTextInput]);
332
+ // Hiding removes the pending-text input from interaction. Commit whatever
333
+ // was typed explicitly so a still-open label survives an unrelated exit
334
+ // instead of relying on incidental native blur ordering.
335
+ useEffect(() => {
336
+ if (!visible)
337
+ commitTextAnnotation();
338
+ }, [visible, commitTextAnnotation]);
339
+ // The explicit-discard path: the caller bumps `clearSignal` exactly when
340
+ // the user deliberately closes the overlay (X/Escape) or a Send is
341
+ // confirmed delivered. A scope change below is the only other reset path,
342
+ // guarding against submitting one screen's geometry against another.
343
+ const lastClearSignalRef = useRef(clearSignal);
344
+ useLayoutEffect(() => {
345
+ if (clearSignal === lastClearSignalRef.current)
346
+ return;
347
+ lastClearSignalRef.current = clearSignal;
348
+ clearAnnotationState();
349
+ }, [clearAnnotationState, clearSignal]);
350
+ const lastScopeKeyRef = useRef(scopeKey);
351
+ useLayoutEffect(() => {
352
+ if (scopeKey === lastScopeKeyRef.current)
353
+ return;
354
+ lastScopeKeyRef.current = scopeKey;
355
+ const pendingText = textInputStateRef.current?.value.trim();
356
+ const discardedCount = strokesRef.current.length +
357
+ textAnnotationsRef.current.length +
358
+ (pendingText ? 1 : 0) +
359
+ (instructionRef.current.trim() ? 1 : 0);
360
+ if (discardedCount > 0) {
361
+ toast(t("visualEditor.annotationsDiscardedOnViewChange", {
362
+ count: discardedCount,
363
+ }));
364
+ }
365
+ clearAnnotationState();
366
+ }, [clearAnnotationState, scopeKey, t]);
367
+ const handlePointerDown = useCallback((e) => {
368
+ const rect = canvasRef.current?.getBoundingClientRect();
369
+ if (!rect || rect.width <= 0 || rect.height <= 0)
370
+ return;
371
+ if (!canvasInteractive || sending || drawing.current)
372
+ return;
373
+ if (e.pointerType === "mouse" && e.button !== 0)
374
+ return;
375
+ if (textMode) {
376
+ e.preventDefault();
377
+ cancelingTextRef.current = false;
378
+ // Commit whatever text box was still open (using the current ref,
379
+ // which still holds the outgoing box at this point) before replacing
380
+ // it, so clicking a new spot never silently drops the previous label.
381
+ commitTextAnnotation();
382
+ textInputGenerationRef.current += 1;
383
+ setPendingTextInput({
384
+ xFrac: (e.clientX - rect.left) / rect.width,
385
+ yFrac: (e.clientY - rect.top) / rect.height,
386
+ value: "",
387
+ generation: textInputGenerationRef.current,
388
+ });
389
+ return;
390
+ }
391
+ const point = pointFromClient(e.clientX, e.clientY, rect);
392
+ if (!point)
393
+ return;
394
+ e.preventDefault();
395
+ drawing.current = true;
396
+ activePointerIdRef.current = e.pointerId;
397
+ activeStrokeStyleRef.current = { color, lineWidth };
398
+ currentStrokeRef.current = [point];
399
+ setCurrentStroke([point]);
400
+ try {
401
+ e.currentTarget.setPointerCapture(e.pointerId);
402
+ }
403
+ catch {
404
+ // Pointer capture may fail if the browser already cancelled the
405
+ // gesture. The pointer-id guard still prevents cross-pointer mixing.
406
+ }
407
+ }, [
408
+ canvasInteractive,
409
+ color,
410
+ commitTextAnnotation,
411
+ lineWidth,
412
+ sending,
413
+ setPendingTextInput,
414
+ textMode,
415
+ ]);
416
+ const handlePointerMove = useCallback((e) => {
417
+ if (!drawing.current || textMode)
418
+ return;
419
+ if (activePointerIdRef.current !== e.pointerId)
420
+ return;
421
+ const rect = canvasRef.current?.getBoundingClientRect();
422
+ if (!rect || !currentStrokeRef.current)
423
+ return;
424
+ const nativeEvent = e.nativeEvent;
425
+ const coalesced = nativeEvent.getCoalescedEvents?.() ?? [];
426
+ const samples = [...coalesced, nativeEvent];
427
+ let points = currentStrokeRef.current;
428
+ let changed = false;
429
+ for (const sample of samples) {
430
+ const point = pointFromClient(sample.clientX, sample.clientY, rect);
431
+ if (!point)
432
+ continue;
433
+ const previousLength = points.length;
434
+ const next = appendStrokePoint(points, point, rect);
435
+ if (next !== points || next.length !== previousLength)
436
+ changed = true;
437
+ points = next;
438
+ }
439
+ currentStrokeRef.current = points;
440
+ if (changed)
441
+ scheduleCurrentStrokeRedraw();
442
+ }, [scheduleCurrentStrokeRedraw, textMode]);
443
+ const handlePointerUp = useCallback((e) => {
444
+ if (!drawing.current || activePointerIdRef.current !== e.pointerId)
445
+ return;
446
+ e.preventDefault();
447
+ const rect = canvasRef.current?.getBoundingClientRect();
448
+ let points = currentStrokeRef.current;
449
+ if (rect && points) {
450
+ const coalesced = e.nativeEvent.getCoalescedEvents?.() ?? [];
451
+ for (const sample of [...coalesced, e.nativeEvent]) {
452
+ const point = pointFromClient(sample.clientX, sample.clientY, rect);
453
+ if (point)
454
+ points = appendStrokePoint(points, point, rect, true);
455
+ }
456
+ currentStrokeRef.current = points;
457
+ }
458
+ drawing.current = false;
459
+ activePointerIdRef.current = null;
460
+ cancelScheduledStrokeFrame();
461
+ if (points && points.length > 1) {
462
+ const activeStyle = activeStrokeStyleRef.current;
463
+ const stroke = {
464
+ id: crypto.randomUUID(),
465
+ points: [...points],
466
+ color: activeStyle.color,
467
+ lineWidth: activeStyle.lineWidth,
468
+ createdAt: nextCreatedAt(),
469
+ };
470
+ const nextStrokes = [...strokesRef.current, stroke];
471
+ strokesRef.current = nextStrokes;
472
+ setStrokes(nextStrokes);
473
+ // A new stroke clears the redo stack (standard editor convention).
474
+ setRedoStack([]);
475
+ }
476
+ currentStrokeRef.current = null;
477
+ setCurrentStroke(null);
478
+ try {
479
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
480
+ e.currentTarget.releasePointerCapture(e.pointerId);
481
+ }
482
+ }
483
+ catch {
484
+ // The browser can release capture before React handles pointerup.
485
+ }
486
+ }, [cancelScheduledStrokeFrame, nextCreatedAt]);
487
+ const handlePointerCancel = useCallback((e) => {
488
+ if (activePointerIdRef.current !== e.pointerId)
489
+ return;
490
+ resetActiveStroke();
491
+ try {
492
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
493
+ e.currentTarget.releasePointerCapture(e.pointerId);
494
+ }
495
+ }
496
+ catch {
497
+ // Pointer cancellation often releases capture before this event.
498
+ }
499
+ }, [resetActiveStroke]);
500
+ const handleLostPointerCapture = useCallback((e) => {
501
+ if (drawing.current && activePointerIdRef.current === e.pointerId) {
502
+ resetActiveStroke();
503
+ }
504
+ }, [resetActiveStroke]);
505
+ /**
506
+ * Undo removes the most recently created annotation (stroke or text) in
507
+ * creation order and pushes it onto the unified redo stack.
508
+ */
509
+ const undo = () => {
510
+ if (sending)
511
+ return;
512
+ const currentStrokes = strokesRef.current;
513
+ const currentTexts = textAnnotationsRef.current;
514
+ const lastStroke = currentStrokes.length > 0
515
+ ? currentStrokes[currentStrokes.length - 1]
516
+ : null;
517
+ const lastText = currentTexts.length > 0 ? currentTexts[currentTexts.length - 1] : null;
518
+ if (!lastStroke && !lastText)
519
+ return;
520
+ // Remove whichever was created most recently.
521
+ const strokeTime = lastStroke?.createdAt ?? -Infinity;
522
+ const textTime = lastText?.createdAt ?? 0;
523
+ if (lastStroke && strokeTime >= textTime) {
524
+ const nextStrokes = currentStrokes.slice(0, -1);
525
+ strokesRef.current = nextStrokes;
526
+ setStrokes(nextStrokes);
527
+ setRedoStack((stack) => [...stack, lastStroke]);
528
+ }
529
+ else if (lastText) {
530
+ const nextTexts = currentTexts.slice(0, -1);
531
+ textAnnotationsRef.current = nextTexts;
532
+ setTextAnnotations(nextTexts);
533
+ setRedoStack((stack) => [...stack, lastText]);
534
+ }
535
+ };
536
+ const redo = () => {
537
+ if (sending)
538
+ return;
539
+ setRedoStack((stack) => {
540
+ if (stack.length === 0)
541
+ return stack;
542
+ const top = stack[stack.length - 1];
543
+ const remaining = stack.slice(0, -1);
544
+ if ("points" in top) {
545
+ // It's a Stroke
546
+ const nextStrokes = [...strokesRef.current, top];
547
+ strokesRef.current = nextStrokes;
548
+ setStrokes(nextStrokes);
549
+ }
550
+ else {
551
+ // It's a DrawAnnotation (text)
552
+ const nextTexts = [
553
+ ...textAnnotationsRef.current,
554
+ top,
555
+ ];
556
+ textAnnotationsRef.current = nextTexts;
557
+ setTextAnnotations(nextTexts);
558
+ }
559
+ return remaining;
560
+ });
561
+ };
562
+ const clear = () => {
563
+ if (sending)
564
+ return;
565
+ if (strokes.length === 0 && textAnnotations.length === 0)
566
+ return;
567
+ const undoGeneration = ++clearUndoGenerationRef.current;
568
+ const prevStrokes = strokesRef.current;
569
+ const prevTexts = textAnnotationsRef.current;
570
+ const prevRedo = redoStack;
571
+ strokesRef.current = [];
572
+ textAnnotationsRef.current = [];
573
+ setStrokes([]);
574
+ setTextAnnotations([]);
575
+ setRedoStack([]);
576
+ toast(t("visualEditor.clearedAllAnnotations"), {
577
+ action: {
578
+ label: t("visualEditor.undo"),
579
+ onClick: () => {
580
+ if (undoGeneration !== clearUndoGenerationRef.current)
581
+ return;
582
+ // Merge snapshot with any strokes/texts drawn during the toast window
583
+ // so new work is not discarded; also restore the pre-clear redo stack.
584
+ const restoredStrokes = [...prevStrokes, ...strokesRef.current];
585
+ const restoredTexts = [...prevTexts, ...textAnnotationsRef.current];
586
+ strokesRef.current = restoredStrokes;
587
+ textAnnotationsRef.current = restoredTexts;
588
+ setStrokes(restoredStrokes);
589
+ setTextAnnotations(restoredTexts);
590
+ setRedoStack(prevRedo);
591
+ },
592
+ },
593
+ duration: 6000,
594
+ });
595
+ };
596
+ const send = () => {
597
+ if (sending)
598
+ return;
599
+ const canvas = canvasRef.current;
600
+ if (!canvas)
601
+ return;
602
+ // A focused text label normally commits on blur before the Send click, but
603
+ // keyboard/programmatic activation does not guarantee that ordering.
604
+ commitTextAnnotation();
605
+ const rect = canvas.getBoundingClientRect();
606
+ // Layout-space dimensions = visual rect / scale factor.
607
+ const layoutW = rect.width / scale;
608
+ const layoutH = rect.height / scale;
609
+ const pathAnnotations = strokesRef.current.map((s) => ({
610
+ id: s.id,
611
+ type: "path",
612
+ // Convert fractional points to layout-space absolute pixels for the agent.
613
+ pathData: s.points
614
+ .map((p, i) => `${i === 0 ? "M" : "L"}${(p.x * layoutW).toFixed(1)},${(p.y * layoutH).toFixed(1)}`)
615
+ .join(" "),
616
+ color: s.color,
617
+ lineWidth: s.lineWidth,
618
+ position: { x: 0, y: 0 },
619
+ }));
620
+ // Convert fractional text positions to layout-space absolute pixels.
621
+ const layoutTextAnnotations = textAnnotationsRef.current.map((a) => ({
622
+ ...a,
623
+ position: {
624
+ x: a.position.x * layoutW,
625
+ y: a.position.y * layoutH,
626
+ },
627
+ }));
628
+ const all = [...pathAnnotations, ...layoutTextAnnotations];
629
+ if (all.length === 0 && !instruction.trim() && queuedAnnotationCount === 0)
630
+ return;
631
+ onSend(all, instruction.trim(), {
632
+ width: layoutW,
633
+ height: layoutH,
634
+ });
635
+ };
636
+ if (!visible && !retainSurfaceWhenHidden)
637
+ return null;
638
+ const hasContent = strokes.length > 0 ||
639
+ textAnnotations.length > 0 ||
640
+ !!textInput?.value.trim() ||
641
+ instruction.trim() ||
642
+ queuedAnnotationCount > 0;
643
+ const canUndo = strokes.length > 0 || textAnnotations.length > 0;
644
+ const canRedo = redoStack.length > 0;
645
+ const toolbar = (_jsx(TooltipProvider, { children: _jsxs("div", { "data-draw-toolbar": true, className: "pointer-events-auto fixed bottom-20 left-1/2 z-[110] flex max-w-[calc(100vw-1rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-2 rounded-xl border border-border bg-popover px-3 py-2 shadow-2xl", children: [_jsx("div", { className: "flex gap-1", children: PRESET_COLORS.map((preset) => (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": preset.label, "data-testid": `draw-color-${preset.label.toLowerCase()}`, onClick: () => {
646
+ if (!sending)
647
+ setColor(preset.color);
648
+ }, disabled: sending, className: cn("h-5 w-5 cursor-pointer rounded-full", color === preset.color
649
+ ? "ring-2 ring-foreground ring-offset-1 ring-offset-popover"
650
+ : "ring-1 ring-border"), style: { backgroundColor: preset.color } }) }), _jsx(TooltipContent, { children: preset.label })] }, preset.color))) }), _jsx("div", { className: "mx-1 h-4 w-px bg-border" }), _jsx("div", { className: "flex gap-1", children: LINE_WIDTHS.map((lw) => (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": lw.label, "data-testid": `draw-line-width-${lw.label.toLowerCase()}`, onClick: () => {
651
+ if (!sending)
652
+ setLineWidth(lw.value);
653
+ }, disabled: sending, className: cn("flex h-6 w-6 cursor-pointer items-center justify-center rounded", lineWidth === lw.value
654
+ ? "bg-accent text-foreground"
655
+ : "text-muted-foreground hover:text-foreground"), children: _jsx("div", { className: "rounded-full bg-current", style: { width: lw.value + 2, height: lw.value + 2 } }) }) }), _jsx(TooltipContent, { children: lw.label })] }, lw.value))) }), _jsx("div", { className: "mx-1 h-4 w-px bg-border" }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("visualEditor.typeAnywhereOnCanvas"), "data-testid": "draw-text-mode", onClick: () => {
656
+ if (!sending)
657
+ setTextMode(!textMode);
658
+ }, disabled: sending, className: cn("flex h-6 w-6 cursor-pointer items-center justify-center rounded", textMode
659
+ ? "bg-accent text-foreground"
660
+ : "text-muted-foreground hover:text-foreground"), children: _jsx(IconCursorText, { className: "h-3.5 w-3.5" }) }) }), _jsx(TooltipContent, { children: t("visualEditor.typeAnywhereOnCanvas") })] }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("visualEditor.undoStroke"), "data-testid": "draw-undo", onClick: undo, disabled: !canUndo || sending, className: "flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-30", children: _jsx(IconArrowBackUp, { className: "h-3.5 w-3.5" }) }) }), _jsx(TooltipContent, { children: t("visualEditor.undoStroke") })] }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("visualEditor.redoStroke"), "data-testid": "draw-redo", onClick: redo, disabled: !canRedo || sending, className: "flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-30", children: _jsx(IconArrowForwardUp, { className: "h-3.5 w-3.5" }) }) }), _jsx(TooltipContent, { children: t("visualEditor.redoStroke") })] }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("visualEditor.clearAll"), "data-testid": "draw-clear-all", onClick: clear, disabled: sending ||
661
+ (strokes.length === 0 && textAnnotations.length === 0), className: "flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-30", children: _jsx(IconEraser, { className: "h-3.5 w-3.5" }) }) }), _jsx(TooltipContent, { children: t("visualEditor.clearAll") })] }), _jsx("div", { className: "mx-1 h-4 w-px bg-border" }), _jsx(Input, { value: instruction, onChange: (e) => {
662
+ if (sending)
663
+ return;
664
+ instructionRef.current = e.target.value;
665
+ setInstruction(e.target.value);
666
+ }, onKeyDown: (e) => {
667
+ if (e.key === "Enter" && hasContent && !sending)
668
+ send();
669
+ if (e.key === "Escape")
670
+ onClose();
671
+ }, placeholder: t("visualEditor.tellAgentWhatToDo"), disabled: sending, className: "h-7 w-56 border-border bg-background text-xs" }), _jsxs(Button, { size: "sm", "data-testid": "draw-send", className: "h-7 gap-1 px-3 !text-[11px] cursor-pointer", onClick: send, disabled: !hasContent || sending, children: [sending ? (_jsx(IconLoader2, { className: "h-3 w-3 animate-spin" })) : (_jsx(IconSend, { className: "h-3 w-3" })), sending ? t("visualEditor.sendingDrawing") : t("visualEditor.send")] }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("visualEditor.exitDrawMode"), "data-testid": "draw-exit", onClick: onClose, disabled: sending, className: "flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground", children: _jsx(IconX, { className: "h-3.5 w-3.5" }) }) }), _jsx(TooltipContent, { children: t("visualEditor.exitDrawMode") })] })] }) }));
672
+ return (_jsxs("div", { ref: containerRef, "data-draw-overlay": true, "aria-hidden": !visible, className: cn("absolute inset-0 z-[100]", visible ? "visible" : "invisible pointer-events-none", canvasInteractive && !sending
673
+ ? "pointer-events-auto"
674
+ : "pointer-events-none"), children: [_jsx("canvas", { ref: canvasRef, "data-draw-canvas": true, className: cn("absolute inset-0 h-full w-full touch-none", textMode ? "cursor-text" : "cursor-crosshair", (!canvasInteractive || sending) && "pointer-events-none"), onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onLostPointerCapture: handleLostPointerCapture }), textAnnotations.map((ann) => {
675
+ const canvas = canvasRef.current;
676
+ const rect = canvas?.getBoundingClientRect();
677
+ const layoutX = rect
678
+ ? (ann.position.x * rect.width) / scale
679
+ : ann.position.x;
680
+ const layoutY = rect
681
+ ? (ann.position.y * rect.height) / scale
682
+ : ann.position.y;
683
+ return (_jsx("div", { className: "absolute pointer-events-none select-none whitespace-nowrap font-semibold", style: {
684
+ left: layoutX,
685
+ top: layoutY,
686
+ color: ann.color,
687
+ fontSize: 14 + ann.lineWidth,
688
+ }, children: ann.text }, ann.id));
689
+ }), textInput &&
690
+ (() => {
691
+ const canvas = canvasRef.current;
692
+ const rect = canvas?.getBoundingClientRect();
693
+ const layoutX = rect
694
+ ? (textInput.xFrac * rect.width) / scale
695
+ : textInput.xFrac;
696
+ const layoutY = rect
697
+ ? (textInput.yFrac * rect.height) / scale
698
+ : textInput.yFrac;
699
+ return (_jsx("div", { className: "pointer-events-auto absolute z-40", style: { left: layoutX, top: layoutY }, children: _jsx(Input, { ref: textInputRef, value: textInput.value, onChange: (e) => !sending &&
700
+ setPendingTextInput((prev) => prev ? { ...prev, value: e.target.value } : null), onBlur: () => {
701
+ // Escape unmounts the input, which fires this blur — skip
702
+ // the commit for a cancelled annotation.
703
+ if (cancelingTextRef.current) {
704
+ cancelingTextRef.current = false;
705
+ return;
706
+ }
707
+ // Pass this render's generation so a trailing blur that
708
+ // arrives after pointerdown has already opened a *newer*
709
+ // text box (see PendingTextInput.generation) is a no-op
710
+ // instead of wiping the box the user just started typing.
711
+ commitTextAnnotation(textInput.generation);
712
+ }, onKeyDown: (e) => {
713
+ if (e.key === "Enter") {
714
+ e.preventDefault();
715
+ // Committing unmounts the focused input, which can emit a
716
+ // blur event in the same turn. Skip that second commit.
717
+ cancelingTextRef.current = true;
718
+ commitTextAnnotation(textInput.generation);
719
+ }
720
+ if (e.key === "Escape") {
721
+ cancelingTextRef.current = true;
722
+ setPendingTextInput(null);
723
+ }
724
+ }, className: "h-7 w-48 border-primary bg-background text-sm", disabled: sending, autoFocus: true, placeholder: t("visualEditor.typeAnnotationFancy") }) }));
725
+ })(), visible ? _jsx(DrawToolbarPortal, { children: toolbar }) : null] }));
726
+ }
727
+ function DrawToolbarPortal({ children }) {
728
+ if (typeof document === "undefined")
729
+ return _jsx(_Fragment, { children: children });
730
+ return createPortal(children, document.body);
731
+ }
732
+ function drawStroke(ctx, points, color, lineWidth) {
733
+ if (points.length < 2)
734
+ return;
735
+ ctx.beginPath();
736
+ ctx.strokeStyle = color;
737
+ ctx.lineWidth = lineWidth;
738
+ ctx.lineCap = "round";
739
+ ctx.lineJoin = "round";
740
+ ctx.moveTo(points[0].x, points[0].y);
741
+ for (let i = 1; i < points.length; i++) {
742
+ ctx.lineTo(points[i].x, points[i].y);
743
+ }
744
+ ctx.stroke();
745
+ }
746
+ //# sourceMappingURL=DrawOverlay.js.map