@agent-native/core 0.80.6 → 0.80.8

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 (71) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +70 -27
  5. package/corpus/core/src/client/analytics.ts +3 -1
  6. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
  7. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
  8. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
  9. package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
  10. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
  11. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  12. package/corpus/templates/analytics/app/i18n-data.ts +34 -0
  13. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
  14. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
  15. package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
  16. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
  17. package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
  18. package/corpus/templates/design/AGENTS.md +4 -2
  19. package/corpus/templates/design/actions/edit-design.ts +6 -3
  20. package/corpus/templates/design/actions/generate-screens.ts +5 -3
  21. package/corpus/templates/design/actions/update-design.ts +47 -10
  22. package/corpus/templates/design/actions/update-file.ts +33 -8
  23. package/corpus/templates/design/actions/view-screen.ts +14 -0
  24. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
  25. package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
  26. package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
  27. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
  28. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
  29. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
  30. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
  31. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
  32. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
  33. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
  34. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
  35. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
  36. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
  37. package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
  38. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  39. package/corpus/templates/design/app/i18n-data.ts +10 -0
  40. package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
  41. package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
  42. package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
  43. package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
  44. package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
  45. package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
  46. package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
  47. package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
  48. package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
  49. package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
  50. package/corpus/templates/design/e2e/README.md +1 -0
  51. package/corpus/templates/design/e2e/global-setup.ts +13 -1
  52. package/corpus/templates/design/playwright.config.ts +10 -1
  53. package/corpus/templates/design/shared/color-utils.ts +159 -0
  54. package/corpus/templates/design/shared/generation-session.ts +1 -0
  55. package/dist/client/AssistantChat.d.ts.map +1 -1
  56. package/dist/client/AssistantChat.js +54 -24
  57. package/dist/client/AssistantChat.js.map +1 -1
  58. package/dist/client/analytics.d.ts.map +1 -1
  59. package/dist/client/analytics.js +2 -1
  60. package/dist/client/analytics.js.map +1 -1
  61. package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
  62. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  63. package/dist/client/extensions/EmbeddedExtension.js +34 -3
  64. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  65. package/dist/collab/routes.d.ts +2 -2
  66. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  67. package/dist/notifications/routes.d.ts +2 -2
  68. package/dist/observability/routes.d.ts +5 -5
  69. package/dist/resources/handlers.d.ts +2 -2
  70. package/dist/server/transcribe-voice.d.ts +1 -1
  71. package/package.json +1 -1
@@ -7,16 +7,14 @@ import {
7
7
  IconCursorText,
8
8
  IconX,
9
9
  } from "@tabler/icons-react";
10
- /**
11
- * NOTE: This is the new shared visual-editor DrawOverlay, mirrored from the
12
- * slides template so both apps share the same comment/draw/save UX.
13
- *
14
- * The legacy iframe-based design overlay still lives at
15
- * `app/components/design/DrawOverlay.tsx` and is currently wired into
16
- * `DesignCanvas`. Both exist for now; the legacy one can be migrated to use
17
- * this shared version in a follow-up. Don't import both in the same screen.
18
- */
19
- import { useState, useRef, useCallback, useEffect } from "react";
10
+ import {
11
+ useState,
12
+ useRef,
13
+ useCallback,
14
+ useEffect,
15
+ type ReactNode,
16
+ } from "react";
17
+ import { createPortal } from "react-dom";
20
18
  import { toast } from "sonner";
21
19
 
22
20
  import { Button } from "@/components/ui/button";
@@ -41,6 +39,12 @@ export interface DrawAnnotation {
41
39
  lineWidth: number;
42
40
  /** Position relative to the canvas (text annotations only) */
43
41
  position: { x: number; y: number };
42
+ /**
43
+ * Creation timestamp (Date.now()) — used for unified undo ordering across
44
+ * strokes and text annotations. Optional so existing callers of DrawAnnotation
45
+ * don't need to supply it.
46
+ */
47
+ createdAt?: number;
44
48
  }
45
49
 
46
50
  interface DrawOverlayProps {
@@ -50,6 +54,13 @@ interface DrawOverlayProps {
50
54
  canvasInteractive?: boolean;
51
55
  /** Extra queued annotations owned by sibling tools, such as comment pins. */
52
56
  queuedAnnotationCount?: number;
57
+ /**
58
+ * Current zoom level (percentage, e.g. 100 = 100%). Used to convert
59
+ * getBoundingClientRect() visual-space coordinates to layout-space so that
60
+ * strokes and text labels stay anchored to their painted position across
61
+ * zoom changes.
62
+ */
63
+ zoom?: number;
53
64
  /** Called when the user submits the queued strokes/text to the agent */
54
65
  onSend: (
55
66
  annotations: DrawAnnotation[],
@@ -73,6 +84,12 @@ const LINE_WIDTHS = [
73
84
  { value: 8, label: "Thick" },
74
85
  ];
75
86
 
87
+ /**
88
+ * A point stored as fractions (0..1) of the canvas visual rect.
89
+ * This makes coordinates zoom- and resize-stable: multiply by the current
90
+ * rect width/height to get visual pixels for canvas drawing, or multiply by
91
+ * rect/scale to get layout-space for CSS positioning and pathData output.
92
+ */
76
93
  interface Point {
77
94
  x: number;
78
95
  y: number;
@@ -80,9 +97,12 @@ interface Point {
80
97
 
81
98
  interface Stroke {
82
99
  id: string;
100
+ /** Points stored as fractions (0..1) of the canvas visual rect. */
83
101
  points: Point[];
84
102
  color: string;
85
103
  lineWidth: number;
104
+ /** Creation timestamp for unified undo ordering. */
105
+ createdAt: number;
86
106
  }
87
107
 
88
108
  /**
@@ -97,11 +117,22 @@ interface Stroke {
97
117
  * This component is canvas-agnostic — it overlays absolutely-positioned over
98
118
  * its parent, so the parent (a slide editor or design canvas) only needs to
99
119
  * be `position: relative`.
120
+ *
121
+ * Coordinate model
122
+ * ----------------
123
+ * All stroke points and text positions are stored as fractions (0..1) of the
124
+ * canvas's visual rect (getBoundingClientRect). This makes them stable across
125
+ * zoom and resize. When rendering:
126
+ * - Canvas strokes: multiply by rect.width / rect.height (visual pixels).
127
+ * - CSS text labels: multiply by rect.width/scale / rect.height/scale
128
+ * (layout pixels inside the scaled wrapper).
129
+ * - pathData in send(): layout pixels = fraction * rect.width / scale.
100
130
  */
101
131
  export function DrawOverlay({
102
132
  visible,
103
133
  canvasInteractive = true,
104
134
  queuedAnnotationCount = 0,
135
+ zoom = 100,
105
136
  onSend,
106
137
  onClose,
107
138
  }: DrawOverlayProps) {
@@ -112,34 +143,61 @@ export function DrawOverlay({
112
143
  const [lineWidth, setLineWidth] = useState(LINE_WIDTHS[1].value);
113
144
  const [strokes, setStrokes] = useState<Stroke[]>([]);
114
145
  const [textAnnotations, setTextAnnotations] = useState<DrawAnnotation[]>([]);
115
- // Redo stack. Pushing here happens via undo(); a fresh stroke clears it
116
- // (standard editor convention once you draw something new, the redo
117
- // history is no longer reachable).
118
- const [redoStrokes, setRedoStrokes] = useState<Stroke[]>([]);
146
+ // Unified redo stack. Each entry is either a Stroke or a DrawAnnotation so
147
+ // undo/redo work in creation order across both types.
148
+ const [redoStack, setRedoStack] = useState<Array<Stroke | DrawAnnotation>>(
149
+ [],
150
+ );
119
151
  const [currentStroke, setCurrentStroke] = useState<Point[] | null>(null);
120
152
  const [textMode, setTextMode] = useState(false);
121
153
  const [textInput, setTextInput] = useState<{
122
- x: number;
123
- y: number;
154
+ /** Fractional x (0..1) of the visual rect. */
155
+ xFrac: number;
156
+ /** Fractional y (0..1) of the visual rect. */
157
+ yFrac: number;
124
158
  value: string;
125
159
  } | null>(null);
160
+ const textInputRef = useRef<HTMLInputElement>(null);
126
161
  const [instruction, setInstruction] = useState("");
127
162
  const drawing = useRef(false);
128
163
  const canvasSizeRef = useRef({ w: 0, h: 0 });
164
+ // Resize tick: bumped by ResizeObserver so the redraw effect re-runs when
165
+ // the canvas element's CSS size changes (e.g. device frame switch).
166
+ const [resizeTick, setResizeTick] = useState(0);
167
+
168
+ const scale = zoom / 100;
129
169
 
130
170
  // Clear all state on hide so the next open starts fresh.
131
171
  useEffect(() => {
132
172
  if (!visible) {
133
173
  setStrokes([]);
134
174
  setTextAnnotations([]);
135
- setRedoStrokes([]);
175
+ setRedoStack([]);
136
176
  setCurrentStroke(null);
137
177
  setTextInput(null);
138
178
  setInstruction("");
139
179
  }
140
180
  }, [visible]);
141
181
 
142
- // Redraw canvas whenever strokes change.
182
+ useEffect(() => {
183
+ if (!textInput) return;
184
+ const id = window.requestAnimationFrame(() => {
185
+ textInputRef.current?.focus();
186
+ });
187
+ return () => window.cancelAnimationFrame(id);
188
+ }, [textInput?.xFrac, textInput?.yFrac]);
189
+
190
+ // ResizeObserver: bump resizeTick whenever the canvas changes CSS size so
191
+ // the redraw effect re-runs and the backing store is resized correctly.
192
+ useEffect(() => {
193
+ const canvas = canvasRef.current;
194
+ if (!canvas) return;
195
+ const ro = new ResizeObserver(() => setResizeTick((t) => t + 1));
196
+ ro.observe(canvas);
197
+ return () => ro.disconnect();
198
+ }, []);
199
+
200
+ // Redraw canvas whenever strokes change or the canvas is resized.
143
201
  useEffect(() => {
144
202
  const canvas = canvasRef.current;
145
203
  if (!canvas) return;
@@ -159,31 +217,54 @@ export function DrawOverlay({
159
217
 
160
218
  ctx.clearRect(0, 0, rect.width, rect.height);
161
219
 
220
+ // Points are stored as fractions; multiply by current rect to get visual px.
162
221
  for (const stroke of strokes) {
163
- drawStroke(ctx, stroke.points, stroke.color, stroke.lineWidth);
222
+ drawStroke(
223
+ ctx,
224
+ stroke.points.map((p) => ({
225
+ x: p.x * rect.width,
226
+ y: p.y * rect.height,
227
+ })),
228
+ stroke.color,
229
+ stroke.lineWidth,
230
+ );
164
231
  }
165
232
 
166
233
  if (currentStroke && currentStroke.length > 0) {
167
- drawStroke(ctx, currentStroke, color, lineWidth);
234
+ drawStroke(
235
+ ctx,
236
+ currentStroke.map((p) => ({
237
+ x: p.x * rect.width,
238
+ y: p.y * rect.height,
239
+ })),
240
+ color,
241
+ lineWidth,
242
+ );
168
243
  }
169
- }, [strokes, currentStroke, color, lineWidth]);
244
+ // `zoom` is a dependency because it scales the canvas via a CSS transform,
245
+ // which changes getBoundingClientRect() without firing ResizeObserver — the
246
+ // effect must re-run on zoom change to redraw the fraction-based strokes at
247
+ // the new visual size.
248
+ }, [strokes, currentStroke, color, lineWidth, resizeTick, zoom]);
170
249
 
171
250
  const handlePointerDown = useCallback(
172
251
  (e: React.PointerEvent) => {
252
+ const rect = canvasRef.current?.getBoundingClientRect();
253
+ if (!rect) return;
173
254
  if (textMode) {
174
- const rect = canvasRef.current?.getBoundingClientRect();
175
- if (!rect) return;
176
255
  setTextInput({
177
- x: e.clientX - rect.left,
178
- y: e.clientY - rect.top,
256
+ xFrac: (e.clientX - rect.left) / rect.width,
257
+ yFrac: (e.clientY - rect.top) / rect.height,
179
258
  value: "",
180
259
  });
181
260
  return;
182
261
  }
183
262
  drawing.current = true;
184
- const rect = canvasRef.current?.getBoundingClientRect();
185
- if (!rect) return;
186
- const point = { x: e.clientX - rect.left, y: e.clientY - rect.top };
263
+ // Store as fractions so the point survives zoom/resize changes.
264
+ const point = {
265
+ x: (e.clientX - rect.left) / rect.width,
266
+ y: (e.clientY - rect.top) / rect.height,
267
+ };
187
268
  setCurrentStroke([point]);
188
269
  (e.target as HTMLElement).setPointerCapture(e.pointerId);
189
270
  },
@@ -195,7 +276,10 @@ export function DrawOverlay({
195
276
  if (!drawing.current || textMode) return;
196
277
  const rect = canvasRef.current?.getBoundingClientRect();
197
278
  if (!rect) return;
198
- const point = { x: e.clientX - rect.left, y: e.clientY - rect.top };
279
+ const point = {
280
+ x: (e.clientX - rect.left) / rect.width,
281
+ y: (e.clientY - rect.top) / rect.height,
282
+ };
199
283
  setCurrentStroke((prev) => (prev ? [...prev, point] : [point]));
200
284
  },
201
285
  [textMode],
@@ -212,9 +296,11 @@ export function DrawOverlay({
212
296
  points: currentStroke,
213
297
  color,
214
298
  lineWidth,
299
+ createdAt: Date.now(),
215
300
  },
216
301
  ]);
217
- setRedoStrokes([]);
302
+ // A new stroke clears the redo stack (standard editor convention).
303
+ setRedoStack([]);
218
304
  }
219
305
  setCurrentStroke(null);
220
306
  }, [currentStroke, color, lineWidth]);
@@ -224,41 +310,65 @@ export function DrawOverlay({
224
310
  setCurrentStroke(null);
225
311
  }, []);
226
312
 
313
+ /**
314
+ * Undo removes the most recently created annotation (stroke or text) in
315
+ * creation order and pushes it onto the unified redo stack.
316
+ */
227
317
  const undo = () => {
228
- setStrokes((prev) => {
229
- if (prev.length === 0) return prev;
230
- const last = prev[prev.length - 1];
231
- setRedoStrokes((stack) => [...stack, last]);
232
- return prev.slice(0, -1);
233
- });
318
+ const lastStroke = strokes.length > 0 ? strokes[strokes.length - 1] : null;
319
+ const lastText =
320
+ textAnnotations.length > 0
321
+ ? textAnnotations[textAnnotations.length - 1]
322
+ : null;
323
+
324
+ if (!lastStroke && !lastText) return;
325
+
326
+ // Remove whichever was created most recently.
327
+ const strokeTime = lastStroke?.createdAt ?? -Infinity;
328
+ const textTime = lastText?.createdAt ?? 0;
329
+
330
+ if (lastStroke && strokeTime >= textTime) {
331
+ setStrokes((prev) => prev.slice(0, -1));
332
+ setRedoStack((stack) => [...stack, lastStroke]);
333
+ } else if (lastText) {
334
+ setTextAnnotations((prev) => prev.slice(0, -1));
335
+ setRedoStack((stack) => [...stack, lastText]);
336
+ }
234
337
  };
235
338
 
236
339
  const redo = () => {
237
- setRedoStrokes((stack) => {
340
+ setRedoStack((stack) => {
238
341
  if (stack.length === 0) return stack;
239
342
  const top = stack[stack.length - 1];
240
- setStrokes((prev) => [...prev, top]);
241
- return stack.slice(0, -1);
343
+ const remaining = stack.slice(0, -1);
344
+ if ("points" in top) {
345
+ // It's a Stroke
346
+ setStrokes((prev) => [...prev, top as Stroke]);
347
+ } else {
348
+ // It's a DrawAnnotation (text)
349
+ setTextAnnotations((prev) => [...prev, top as DrawAnnotation]);
350
+ }
351
+ return remaining;
242
352
  });
243
353
  };
244
354
 
245
355
  const clear = () => {
246
356
  if (strokes.length === 0 && textAnnotations.length === 0) return;
247
- // Snapshot for the toast's Undo. Using a toast (instead of an AlertDialog
248
- // confirm) keeps Clear a single click for users who know what they want,
249
- // while still letting an accidental click be recovered for the toast's
250
- // lifetime.
251
357
  const prevStrokes = strokes;
252
358
  const prevTexts = textAnnotations;
359
+ const prevRedo = redoStack;
253
360
  setStrokes([]);
254
361
  setTextAnnotations([]);
255
- setRedoStrokes([]);
362
+ setRedoStack([]);
256
363
  toast(t("visualEditor.clearedAllAnnotations"), {
257
364
  action: {
258
365
  label: t("visualEditor.undo"),
259
366
  onClick: () => {
260
- setStrokes(prevStrokes);
261
- setTextAnnotations(prevTexts);
367
+ // Merge snapshot with any strokes/texts drawn during the toast window
368
+ // so new work is not discarded; also restore the pre-clear redo stack.
369
+ setStrokes((cur) => [...prevStrokes, ...cur]);
370
+ setTextAnnotations((cur) => [...prevTexts, ...cur]);
371
+ setRedoStack(prevRedo);
262
372
  },
263
373
  },
264
374
  duration: 6000,
@@ -270,17 +380,19 @@ export function DrawOverlay({
270
380
  setTextInput(null);
271
381
  return;
272
382
  }
273
- setTextAnnotations((prev) => [
274
- ...prev,
275
- {
276
- id: crypto.randomUUID(),
277
- type: "text",
278
- text: textInput.value.trim(),
279
- position: { x: textInput.x, y: textInput.y },
280
- color,
281
- lineWidth,
282
- },
283
- ]);
383
+ const ann: DrawAnnotation = {
384
+ id: crypto.randomUUID(),
385
+ type: "text",
386
+ text: textInput.value.trim(),
387
+ // Store fractional position so the label stays anchored across zoom changes.
388
+ position: { x: textInput.xFrac, y: textInput.yFrac },
389
+ color,
390
+ lineWidth,
391
+ createdAt: Date.now(),
392
+ };
393
+ setTextAnnotations((prev) => [...prev, ann]);
394
+ // A new text annotation also clears the redo stack.
395
+ setRedoStack([]);
284
396
  setTextInput(null);
285
397
  };
286
398
 
@@ -288,13 +400,18 @@ export function DrawOverlay({
288
400
  const canvas = canvasRef.current;
289
401
  if (!canvas) return;
290
402
  const rect = canvas.getBoundingClientRect();
403
+ // Layout-space dimensions = visual rect / scale factor.
404
+ const layoutW = rect.width / scale;
405
+ const layoutH = rect.height / scale;
291
406
 
292
407
  const pathAnnotations: DrawAnnotation[] = strokes.map((s) => ({
293
408
  id: s.id,
294
409
  type: "path",
410
+ // Convert fractional points to layout-space absolute pixels for the agent.
295
411
  pathData: s.points
296
412
  .map(
297
- (p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`,
413
+ (p, i) =>
414
+ `${i === 0 ? "M" : "L"}${(p.x * layoutW).toFixed(1)},${(p.y * layoutH).toFixed(1)}`,
298
415
  )
299
416
  .join(" "),
300
417
  color: s.color,
@@ -302,12 +419,23 @@ export function DrawOverlay({
302
419
  position: { x: 0, y: 0 },
303
420
  }));
304
421
 
305
- const all = [...pathAnnotations, ...textAnnotations];
422
+ // Convert fractional text positions to layout-space absolute pixels.
423
+ const layoutTextAnnotations: DrawAnnotation[] = textAnnotations.map(
424
+ (a) => ({
425
+ ...a,
426
+ position: {
427
+ x: a.position.x * layoutW,
428
+ y: a.position.y * layoutH,
429
+ },
430
+ }),
431
+ );
432
+
433
+ const all = [...pathAnnotations, ...layoutTextAnnotations];
306
434
  if (all.length === 0 && !instruction.trim()) return;
307
435
 
308
436
  onSend(all, instruction.trim(), {
309
- width: rect.width,
310
- height: rect.height,
437
+ width: layoutW,
438
+ height: layoutH,
311
439
  });
312
440
  };
313
441
 
@@ -319,12 +447,194 @@ export function DrawOverlay({
319
447
  instruction.trim() ||
320
448
  queuedAnnotationCount > 0;
321
449
 
450
+ const canUndo = strokes.length > 0 || textAnnotations.length > 0;
451
+ const canRedo = redoStack.length > 0;
452
+
453
+ const toolbar = (
454
+ <div
455
+ data-draw-toolbar
456
+ className="pointer-events-auto fixed bottom-4 left-1/2 z-[110] flex -translate-x-1/2 items-center gap-2 rounded-xl border border-border bg-popover px-3 py-2 shadow-2xl"
457
+ >
458
+ {/* Color picker */}
459
+ <div className="flex gap-1">
460
+ {PRESET_COLORS.map((preset) => (
461
+ <Tooltip key={preset.color}>
462
+ <TooltipTrigger asChild>
463
+ <button
464
+ type="button"
465
+ aria-label={preset.label}
466
+ data-testid={`draw-color-${preset.label.toLowerCase()}`}
467
+ onClick={() => setColor(preset.color)}
468
+ className={cn(
469
+ "h-5 w-5 cursor-pointer rounded-full",
470
+ color === preset.color
471
+ ? "ring-2 ring-foreground ring-offset-1 ring-offset-popover"
472
+ : "ring-1 ring-border",
473
+ )}
474
+ style={{ backgroundColor: preset.color }}
475
+ />
476
+ </TooltipTrigger>
477
+ <TooltipContent>{preset.label}</TooltipContent>
478
+ </Tooltip>
479
+ ))}
480
+ </div>
481
+
482
+ <div className="mx-1 h-4 w-px bg-border" />
483
+
484
+ {/* Line widths */}
485
+ <div className="flex gap-1">
486
+ {LINE_WIDTHS.map((lw) => (
487
+ <Tooltip key={lw.value}>
488
+ <TooltipTrigger asChild>
489
+ <button
490
+ type="button"
491
+ aria-label={lw.label}
492
+ data-testid={`draw-line-width-${lw.label.toLowerCase()}`}
493
+ onClick={() => setLineWidth(lw.value)}
494
+ className={cn(
495
+ "flex h-6 w-6 cursor-pointer items-center justify-center rounded",
496
+ lineWidth === lw.value
497
+ ? "bg-accent text-foreground"
498
+ : "text-muted-foreground hover:text-foreground",
499
+ )}
500
+ >
501
+ <div
502
+ className="rounded-full bg-current"
503
+ style={{ width: lw.value + 2, height: lw.value + 2 }}
504
+ />
505
+ </button>
506
+ </TooltipTrigger>
507
+ <TooltipContent>{lw.label}</TooltipContent>
508
+ </Tooltip>
509
+ ))}
510
+ </div>
511
+
512
+ <div className="mx-1 h-4 w-px bg-border" />
513
+
514
+ {/* Text mode */}
515
+ <Tooltip>
516
+ <TooltipTrigger asChild>
517
+ <button
518
+ type="button"
519
+ aria-label={t("visualEditor.typeAnywhereOnCanvas")}
520
+ data-testid="draw-text-mode"
521
+ onClick={() => setTextMode(!textMode)}
522
+ className={cn(
523
+ "flex h-6 w-6 cursor-pointer items-center justify-center rounded",
524
+ textMode
525
+ ? "bg-accent text-foreground"
526
+ : "text-muted-foreground hover:text-foreground",
527
+ )}
528
+ >
529
+ <IconCursorText className="h-3.5 w-3.5" />
530
+ </button>
531
+ </TooltipTrigger>
532
+ <TooltipContent>
533
+ {t("visualEditor.typeAnywhereOnCanvas")}
534
+ </TooltipContent>
535
+ </Tooltip>
536
+
537
+ {/* Undo last annotation (stroke or text) */}
538
+ <Tooltip>
539
+ <TooltipTrigger asChild>
540
+ <button
541
+ type="button"
542
+ aria-label={t("visualEditor.undoStroke")}
543
+ data-testid="draw-undo"
544
+ onClick={undo}
545
+ disabled={!canUndo}
546
+ 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"
547
+ >
548
+ <IconArrowBackUp className="h-3.5 w-3.5" />
549
+ </button>
550
+ </TooltipTrigger>
551
+ <TooltipContent>{t("visualEditor.undoStroke")}</TooltipContent>
552
+ </Tooltip>
553
+
554
+ {/* Redo */}
555
+ <Tooltip>
556
+ <TooltipTrigger asChild>
557
+ <button
558
+ type="button"
559
+ aria-label={t("visualEditor.redoStroke")}
560
+ data-testid="draw-redo"
561
+ onClick={redo}
562
+ disabled={!canRedo}
563
+ 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"
564
+ >
565
+ <IconArrowForwardUp className="h-3.5 w-3.5" />
566
+ </button>
567
+ </TooltipTrigger>
568
+ <TooltipContent>{t("visualEditor.redoStroke")}</TooltipContent>
569
+ </Tooltip>
570
+
571
+ {/* Clear all */}
572
+ <Tooltip>
573
+ <TooltipTrigger asChild>
574
+ <button
575
+ type="button"
576
+ aria-label={t("visualEditor.clearAll")}
577
+ data-testid="draw-clear-all"
578
+ onClick={clear}
579
+ disabled={strokes.length === 0 && textAnnotations.length === 0}
580
+ 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"
581
+ >
582
+ <IconEraser className="h-3.5 w-3.5" />
583
+ </button>
584
+ </TooltipTrigger>
585
+ <TooltipContent>{t("visualEditor.clearAll")}</TooltipContent>
586
+ </Tooltip>
587
+
588
+ <div className="mx-1 h-4 w-px bg-border" />
589
+
590
+ {/* Instruction input */}
591
+ <Input
592
+ value={instruction}
593
+ onChange={(e) => setInstruction(e.target.value)}
594
+ onKeyDown={(e) => {
595
+ if (e.key === "Enter" && hasContent) send();
596
+ if (e.key === "Escape") onClose();
597
+ }}
598
+ placeholder={t("visualEditor.tellAgentWhatToDo")}
599
+ className="h-7 w-56 border-border bg-background text-xs"
600
+ />
601
+
602
+ {/* Send */}
603
+ <Button
604
+ size="sm"
605
+ data-testid="draw-send"
606
+ className="h-7 gap-1 px-3 text-[11px] cursor-pointer"
607
+ onClick={send}
608
+ disabled={!hasContent}
609
+ >
610
+ <IconSend className="h-3 w-3" />
611
+ {t("visualEditor.send")}
612
+ </Button>
613
+
614
+ {/* Close */}
615
+ <Tooltip>
616
+ <TooltipTrigger asChild>
617
+ <button
618
+ type="button"
619
+ aria-label={t("visualEditor.exitDrawMode")}
620
+ data-testid="draw-exit"
621
+ onClick={onClose}
622
+ className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground"
623
+ >
624
+ <IconX className="h-3.5 w-3.5" />
625
+ </button>
626
+ </TooltipTrigger>
627
+ <TooltipContent>{t("visualEditor.exitDrawMode")}</TooltipContent>
628
+ </Tooltip>
629
+ </div>
630
+ );
631
+
322
632
  return (
323
633
  <div
324
634
  ref={containerRef}
325
635
  data-draw-overlay
326
636
  className={cn(
327
- "absolute inset-0 z-30",
637
+ "absolute inset-0 z-[100]",
328
638
  canvasInteractive ? "pointer-events-auto" : "pointer-events-none",
329
639
  )}
330
640
  >
@@ -343,209 +653,88 @@ export function DrawOverlay({
343
653
  onPointerCancel={handlePointerCancel}
344
654
  />
345
655
 
346
- {/* Rendered text annotations */}
347
- {textAnnotations.map((ann) => (
348
- <div
349
- key={ann.id}
350
- className="absolute pointer-events-none select-none whitespace-nowrap font-semibold"
351
- style={{
352
- left: ann.position.x,
353
- top: ann.position.y,
354
- color: ann.color,
355
- fontSize: 14 + ann.lineWidth,
356
- }}
357
- >
358
- {ann.text}
359
- </div>
360
- ))}
361
-
362
- {/* Pending text input */}
363
- {textInput && (
364
- <div
365
- className="pointer-events-auto absolute z-40"
366
- style={{ left: textInput.x, top: textInput.y }}
367
- >
368
- <Input
369
- value={textInput.value}
370
- onChange={(e) =>
371
- setTextInput((prev) =>
372
- prev ? { ...prev, value: e.target.value } : null,
373
- )
374
- }
375
- onBlur={commitTextAnnotation}
376
- onKeyDown={(e) => {
377
- if (e.key === "Enter") {
378
- e.preventDefault();
379
- commitTextAnnotation();
380
- }
381
- if (e.key === "Escape") setTextInput(null);
656
+ {/* Rendered text annotations.
657
+ Positions are stored as fractions (0..1) of the visual rect. Convert
658
+ to layout-space pixels (fraction * visualDim / scale) so the label
659
+ sits at the right layout position inside the scaled wrapper, where
660
+ CSS left/top are interpreted in pre-scale coordinates. */}
661
+ {textAnnotations.map((ann) => {
662
+ const canvas = canvasRef.current;
663
+ const rect = canvas?.getBoundingClientRect();
664
+ const layoutX = rect
665
+ ? (ann.position.x * rect.width) / scale
666
+ : ann.position.x;
667
+ const layoutY = rect
668
+ ? (ann.position.y * rect.height) / scale
669
+ : ann.position.y;
670
+ return (
671
+ <div
672
+ key={ann.id}
673
+ className="absolute pointer-events-none select-none whitespace-nowrap font-semibold"
674
+ style={{
675
+ left: layoutX,
676
+ top: layoutY,
677
+ color: ann.color,
678
+ fontSize: 14 + ann.lineWidth,
382
679
  }}
383
- className="h-7 w-48 border-primary bg-background text-sm"
384
- autoFocus
385
- placeholder={t("visualEditor.typeAnnotationFancy")}
386
- />
387
- </div>
388
- )}
389
-
390
- {/* Bottom toolbar */}
391
- <div
392
- data-draw-toolbar
393
- className="pointer-events-auto absolute bottom-4 left-1/2 z-40 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-border bg-popover px-3 py-2 shadow-2xl"
394
- >
395
- {/* Color picker */}
396
- <div className="flex gap-1">
397
- {PRESET_COLORS.map((preset) => (
398
- <Tooltip key={preset.color}>
399
- <TooltipTrigger asChild>
400
- <button
401
- onClick={() => setColor(preset.color)}
402
- className={cn(
403
- "h-5 w-5 cursor-pointer rounded-full",
404
- color === preset.color
405
- ? "ring-2 ring-foreground ring-offset-1 ring-offset-popover"
406
- : "ring-1 ring-border",
407
- )}
408
- style={{ backgroundColor: preset.color }}
409
- />
410
- </TooltipTrigger>
411
- <TooltipContent>{preset.label}</TooltipContent>
412
- </Tooltip>
413
- ))}
414
- </div>
415
-
416
- <div className="mx-1 h-4 w-px bg-border" />
417
-
418
- {/* Line widths */}
419
- <div className="flex gap-1">
420
- {LINE_WIDTHS.map((lw) => (
421
- <Tooltip key={lw.value}>
422
- <TooltipTrigger asChild>
423
- <button
424
- onClick={() => setLineWidth(lw.value)}
425
- className={cn(
426
- "flex h-6 w-6 cursor-pointer items-center justify-center rounded",
427
- lineWidth === lw.value
428
- ? "bg-accent text-foreground"
429
- : "text-muted-foreground hover:text-foreground",
430
- )}
431
- >
432
- <div
433
- className="rounded-full bg-current"
434
- style={{ width: lw.value + 2, height: lw.value + 2 }}
435
- />
436
- </button>
437
- </TooltipTrigger>
438
- <TooltipContent>{lw.label}</TooltipContent>
439
- </Tooltip>
440
- ))}
441
- </div>
442
-
443
- <div className="mx-1 h-4 w-px bg-border" />
444
-
445
- {/* Text mode */}
446
- <Tooltip>
447
- <TooltipTrigger asChild>
448
- <button
449
- onClick={() => setTextMode(!textMode)}
450
- className={cn(
451
- "flex h-6 w-6 cursor-pointer items-center justify-center rounded",
452
- textMode
453
- ? "bg-accent text-foreground"
454
- : "text-muted-foreground hover:text-foreground",
455
- )}
680
+ >
681
+ {ann.text}
682
+ </div>
683
+ );
684
+ })}
685
+
686
+ {/* Pending text input — positioned at the click point in layout space. */}
687
+ {textInput &&
688
+ (() => {
689
+ const canvas = canvasRef.current;
690
+ const rect = canvas?.getBoundingClientRect();
691
+ const layoutX = rect
692
+ ? (textInput.xFrac * rect.width) / scale
693
+ : textInput.xFrac;
694
+ const layoutY = rect
695
+ ? (textInput.yFrac * rect.height) / scale
696
+ : textInput.yFrac;
697
+ return (
698
+ <div
699
+ className="pointer-events-auto absolute z-40"
700
+ style={{ left: layoutX, top: layoutY }}
456
701
  >
457
- <IconCursorText className="h-3.5 w-3.5" />
458
- </button>
459
- </TooltipTrigger>
460
- <TooltipContent>
461
- {t("visualEditor.typeAnywhereOnCanvas")}
462
- </TooltipContent>
463
- </Tooltip>
464
-
465
- {/* Undo last stroke */}
466
- <Tooltip>
467
- <TooltipTrigger asChild>
468
- <button
469
- onClick={undo}
470
- disabled={strokes.length === 0}
471
- 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"
472
- >
473
- <IconArrowBackUp className="h-3.5 w-3.5" />
474
- </button>
475
- </TooltipTrigger>
476
- <TooltipContent>{t("visualEditor.undoStroke")}</TooltipContent>
477
- </Tooltip>
478
-
479
- {/* Redo */}
480
- <Tooltip>
481
- <TooltipTrigger asChild>
482
- <button
483
- onClick={redo}
484
- disabled={redoStrokes.length === 0}
485
- 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"
486
- >
487
- <IconArrowForwardUp className="h-3.5 w-3.5" />
488
- </button>
489
- </TooltipTrigger>
490
- <TooltipContent>{t("visualEditor.redoStroke")}</TooltipContent>
491
- </Tooltip>
492
-
493
- {/* Clear all */}
494
- <Tooltip>
495
- <TooltipTrigger asChild>
496
- <button
497
- onClick={clear}
498
- disabled={strokes.length === 0 && textAnnotations.length === 0}
499
- 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"
500
- >
501
- <IconEraser className="h-3.5 w-3.5" />
502
- </button>
503
- </TooltipTrigger>
504
- <TooltipContent>{t("visualEditor.clearAll")}</TooltipContent>
505
- </Tooltip>
506
-
507
- <div className="mx-1 h-4 w-px bg-border" />
508
-
509
- {/* Instruction input */}
510
- <Input
511
- value={instruction}
512
- onChange={(e) => setInstruction(e.target.value)}
513
- onKeyDown={(e) => {
514
- if (e.key === "Enter" && hasContent) send();
515
- if (e.key === "Escape") onClose();
516
- }}
517
- placeholder={t("visualEditor.tellAgentWhatToDo")}
518
- className="h-7 w-56 border-border bg-background text-xs"
519
- />
520
-
521
- {/* Send */}
522
- <Button
523
- size="sm"
524
- className="h-7 gap-1 px-3 text-[11px] cursor-pointer"
525
- onClick={send}
526
- disabled={!hasContent}
527
- >
528
- <IconSend className="h-3 w-3" />
529
- {t("visualEditor.send")}
530
- </Button>
531
-
532
- {/* Close */}
533
- <Tooltip>
534
- <TooltipTrigger asChild>
535
- <button
536
- onClick={onClose}
537
- className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground"
538
- >
539
- <IconX className="h-3.5 w-3.5" />
540
- </button>
541
- </TooltipTrigger>
542
- <TooltipContent>{t("visualEditor.exitDrawMode")}</TooltipContent>
543
- </Tooltip>
544
- </div>
702
+ <Input
703
+ ref={textInputRef}
704
+ value={textInput.value}
705
+ onChange={(e) =>
706
+ setTextInput((prev) =>
707
+ prev ? { ...prev, value: e.target.value } : null,
708
+ )
709
+ }
710
+ onBlur={() => {
711
+ if (textInput.value.trim()) commitTextAnnotation();
712
+ }}
713
+ onKeyDown={(e) => {
714
+ if (e.key === "Enter") {
715
+ e.preventDefault();
716
+ commitTextAnnotation();
717
+ }
718
+ if (e.key === "Escape") setTextInput(null);
719
+ }}
720
+ className="h-7 w-48 border-primary bg-background text-sm"
721
+ autoFocus
722
+ placeholder={t("visualEditor.typeAnnotationFancy")}
723
+ />
724
+ </div>
725
+ );
726
+ })()}
727
+
728
+ <DrawToolbarPortal>{toolbar}</DrawToolbarPortal>
545
729
  </div>
546
730
  );
547
731
  }
548
732
 
733
+ function DrawToolbarPortal({ children }: { children: ReactNode }) {
734
+ if (typeof document === "undefined") return <>{children}</>;
735
+ return createPortal(children, document.body);
736
+ }
737
+
549
738
  function drawStroke(
550
739
  ctx: CanvasRenderingContext2D,
551
740
  points: Point[],