@agent-native/toolkit 0.11.2 → 0.12.0
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/README.md +19 -0
- package/agent-native.eject.json +38 -0
- package/dist/canvas-annotations/CanvasCommentPins.d.ts +68 -0
- package/dist/canvas-annotations/CanvasCommentPins.d.ts.map +1 -0
- package/dist/canvas-annotations/CanvasCommentPins.js +513 -0
- package/dist/canvas-annotations/CanvasCommentPins.js.map +1 -0
- package/dist/canvas-annotations/DrawOverlay.d.ts +108 -0
- package/dist/canvas-annotations/DrawOverlay.d.ts.map +1 -0
- package/dist/canvas-annotations/DrawOverlay.js +746 -0
- package/dist/canvas-annotations/DrawOverlay.js.map +1 -0
- package/dist/canvas-annotations/DrawOverlay.spec.d.ts +2 -0
- package/dist/canvas-annotations/DrawOverlay.spec.d.ts.map +1 -0
- package/dist/canvas-annotations/DrawOverlay.spec.js +111 -0
- package/dist/canvas-annotations/DrawOverlay.spec.js.map +1 -0
- package/dist/canvas-annotations/index.d.ts +4 -0
- package/dist/canvas-annotations/index.d.ts.map +1 -0
- package/dist/canvas-annotations/index.js +3 -0
- package/dist/canvas-annotations/index.js.map +1 -0
- package/dist/canvas-annotations/types.d.ts +8 -0
- package/dist/canvas-annotations/types.d.ts.map +1 -0
- package/dist/canvas-annotations/types.js +2 -0
- package/dist/canvas-annotations/types.js.map +1 -0
- package/dist/canvas-interactions/canvas-interactions.d.ts +334 -0
- package/dist/canvas-interactions/canvas-interactions.d.ts.map +1 -0
- package/dist/canvas-interactions/canvas-interactions.js +451 -0
- package/dist/canvas-interactions/canvas-interactions.js.map +1 -0
- package/dist/canvas-interactions/canvas-interactions.spec.d.ts +2 -0
- package/dist/canvas-interactions/canvas-interactions.spec.d.ts.map +1 -0
- package/dist/canvas-interactions/canvas-interactions.spec.js +538 -0
- package/dist/canvas-interactions/canvas-interactions.spec.js.map +1 -0
- package/dist/canvas-interactions/index.d.ts +2 -0
- package/dist/canvas-interactions/index.d.ts.map +1 -0
- package/dist/canvas-interactions/index.js +2 -0
- package/dist/canvas-interactions/index.js.map +1 -0
- package/dist/design-tweaks/scrub-input-utils.d.ts +61 -0
- package/dist/design-tweaks/scrub-input-utils.d.ts.map +1 -0
- package/dist/design-tweaks/scrub-input-utils.js +235 -0
- package/dist/design-tweaks/scrub-input-utils.js.map +1 -0
- package/dist/design-tweaks/scrub-input.d.ts +66 -0
- package/dist/design-tweaks/scrub-input.d.ts.map +1 -0
- package/dist/design-tweaks/scrub-input.js +313 -0
- package/dist/design-tweaks/scrub-input.js.map +1 -0
- package/dist/design-tweaks/visual-style-controls.d.ts +2 -12
- package/dist/design-tweaks/visual-style-controls.d.ts.map +1 -1
- package/dist/design-tweaks/visual-style-controls.js +3 -107
- package/dist/design-tweaks/visual-style-controls.js.map +1 -1
- package/dist/design-tweaks/visual-style-controls.spec.js +26 -0
- package/dist/design-tweaks/visual-style-controls.spec.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +29 -1
- package/src/canvas-annotations/CanvasCommentPins.tsx +861 -0
- package/src/canvas-annotations/DrawOverlay.spec.tsx +145 -0
- package/src/canvas-annotations/DrawOverlay.tsx +1233 -0
- package/src/canvas-annotations/index.ts +15 -0
- package/src/canvas-annotations/types.ts +14 -0
- package/src/canvas-interactions/canvas-interactions.spec.ts +688 -0
- package/src/canvas-interactions/canvas-interactions.ts +933 -0
- package/src/canvas-interactions/index.ts +67 -0
- package/src/design-tweaks/scrub-input-utils.ts +311 -0
- package/src/design-tweaks/scrub-input.tsx +491 -0
- package/src/design-tweaks/visual-style-controls.spec.tsx +60 -0
- package/src/design-tweaks/visual-style-controls.tsx +23 -168
- 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
|