@agent-native/core 0.80.7 → 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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +70 -27
- package/corpus/core/src/client/analytics.ts +3 -1
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
- package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
- package/corpus/templates/design/AGENTS.md +4 -2
- package/corpus/templates/design/actions/edit-design.ts +6 -3
- package/corpus/templates/design/actions/generate-screens.ts +5 -3
- package/corpus/templates/design/actions/update-design.ts +47 -10
- package/corpus/templates/design/actions/update-file.ts +33 -8
- package/corpus/templates/design/actions/view-screen.ts +14 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
- package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
- package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +10 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
- package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
- package/corpus/templates/design/e2e/README.md +1 -0
- package/corpus/templates/design/e2e/global-setup.ts +13 -1
- package/corpus/templates/design/playwright.config.ts +10 -1
- package/corpus/templates/design/shared/color-utils.ts +159 -0
- package/corpus/templates/design/shared/generation-session.ts +1 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +54 -24
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +2 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +8 -8
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
|
@@ -7,16 +7,14 @@ import {
|
|
|
7
7
|
IconCursorText,
|
|
8
8
|
IconX,
|
|
9
9
|
} from "@tabler/icons-react";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
175
|
+
setRedoStack([]);
|
|
136
176
|
setCurrentStroke(null);
|
|
137
177
|
setTextInput(null);
|
|
138
178
|
setInstruction("");
|
|
139
179
|
}
|
|
140
180
|
}, [visible]);
|
|
141
181
|
|
|
142
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
340
|
+
setRedoStack((stack) => {
|
|
238
341
|
if (stack.length === 0) return stack;
|
|
239
342
|
const top = stack[stack.length - 1];
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
362
|
+
setRedoStack([]);
|
|
256
363
|
toast(t("visualEditor.clearedAllAnnotations"), {
|
|
257
364
|
action: {
|
|
258
365
|
label: t("visualEditor.undo"),
|
|
259
366
|
onClick: () => {
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
310
|
-
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-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
{
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
<
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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[],
|