@pixerate/editor-svelte 0.107.0 → 0.108.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 +2 -2
- package/dist/image-editor/ImageEditor.svelte +45 -742
- package/dist/image-editor/ImageEditorCanvas.svelte +825 -0
- package/dist/image-editor/ImageEditorCanvas.svelte.d.ts +64 -0
- package/dist/image-editor/canvasTypes.d.ts +4 -0
- package/dist/image-editor/canvasTypes.js +1 -0
- package/dist/image-editor/index.d.ts +7 -0
- package/dist/image-editor/index.js +2 -0
- package/dist/playback/PresenceLayer.svelte +4 -2
- package/dist/playback/PresenceLayer.svelte.d.ts +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Annotation, CropBox, CropHandle, ImageEditorFit, ImagePoint } from '@pixerate/editor/image-editor';
|
|
3
|
+
import type { AgentPlayback, PlaybackPoint, PlaybackRect, PlaybackRef } from '@pixerate/editor/tools';
|
|
4
|
+
import {
|
|
5
|
+
canvasToImagePoint,
|
|
6
|
+
imageToCanvasPoint,
|
|
7
|
+
createImagePlaybackLocator,
|
|
8
|
+
hitTestCrop,
|
|
9
|
+
getCropCursor,
|
|
10
|
+
calculateCropDrag,
|
|
11
|
+
hitTestAnnotation,
|
|
12
|
+
DEFAULT_SELECTION_TOOL_OPTIONS,
|
|
13
|
+
SelectionGesture,
|
|
14
|
+
PaintGesture,
|
|
15
|
+
handlePaintShortcut,
|
|
16
|
+
pointerPressure,
|
|
17
|
+
FreeTransformTool,
|
|
18
|
+
getViewportProjection,
|
|
19
|
+
ShapeDrawGesture,
|
|
20
|
+
TypeToolGesture,
|
|
21
|
+
DEFAULT_SHAPE_TOOL_OPTIONS,
|
|
22
|
+
DEFAULT_TYPE_TOOL_OPTIONS,
|
|
23
|
+
type SelectionToolOptions,
|
|
24
|
+
type ShapeToolOptions,
|
|
25
|
+
type TextEditSession,
|
|
26
|
+
type TypeToolOptions,
|
|
27
|
+
} from '@pixerate/editor/image-editor';
|
|
28
|
+
import { untrack } from 'svelte';
|
|
29
|
+
import { createPlaybackSurface, getAgentPlayback } from '../playback/playback.svelte.js';
|
|
30
|
+
import PresenceLayer from '../playback/PresenceLayer.svelte';
|
|
31
|
+
import type { ReactiveImageEditor } from './imageEditorState.svelte.js';
|
|
32
|
+
import type { ImageEditorCanvasErrorSource, ImageEditorCanvasMode } from './canvasTypes.js';
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
/** The editor to draw and edit (`createReactiveImageEditor`). */
|
|
36
|
+
editor: ReactiveImageEditor;
|
|
37
|
+
/**
|
|
38
|
+
* What pointer gestures do: `crop` (crop box and its overlay, in the crop margin), `select`
|
|
39
|
+
* (the selection tools), `paint` (brush, eraser, move and their shortcuts), `annotate`
|
|
40
|
+
* (annotations, shape and type layers, by the active tool) or `none` (view only). Free
|
|
41
|
+
* Transform works in every mode. Default `none`.
|
|
42
|
+
*/
|
|
43
|
+
mode?: ImageEditorCanvasMode;
|
|
44
|
+
/** How the image fits the canvas (default: the renderer's). */
|
|
45
|
+
fit?: ImageEditorFit;
|
|
46
|
+
/** Options of the selection tools (magic wand tolerance, feather, …). */
|
|
47
|
+
selectionOptions?: SelectionToolOptions;
|
|
48
|
+
/** Options of the shape tool. */
|
|
49
|
+
shapeOptions?: ShapeToolOptions;
|
|
50
|
+
/** Options of the type tool. */
|
|
51
|
+
typeOptions?: TypeToolOptions;
|
|
52
|
+
/** Colour of new annotations (default `#ef4444`). */
|
|
53
|
+
annotationColor?: string;
|
|
54
|
+
/** Stroke width of new annotations, in image pixels (default 3). */
|
|
55
|
+
annotationWidth?: number;
|
|
56
|
+
/** Aspect ratio a crop handle drag keeps (width / height), or null for free (default). */
|
|
57
|
+
cropRatio?: number | null;
|
|
58
|
+
/** A gesture failed (`message`) or recovered (`null`). */
|
|
59
|
+
onerror?: (source: ImageEditorCanvasErrorSource, message: string | null) => void;
|
|
60
|
+
/** The text being edited on the canvas (bindable). Set it to `null` to finish editing. */
|
|
61
|
+
typeSession?: TextEditSession | null;
|
|
62
|
+
/**
|
|
63
|
+
* The Free Transform tool (Ctrl/Cmd+T) the canvas drives and draws. Pass your own to show its
|
|
64
|
+
* options bar or begin transforms from your UI; by default the canvas makes one.
|
|
65
|
+
*/
|
|
66
|
+
transformTool?: FreeTransformTool;
|
|
67
|
+
/**
|
|
68
|
+
* Agent playback: act out the image tools' calls on this canvas with the agent's cursor
|
|
69
|
+
* (default: `setAgentPlayback` above; `null` turns it off).
|
|
70
|
+
*/
|
|
71
|
+
playback?: AgentPlayback | null;
|
|
72
|
+
/** The playback surface id (default `image`, where the image tool providers play). */
|
|
73
|
+
playbackId?: string;
|
|
74
|
+
/** Bring the canvas on screen (switch to its tab) before the agent acts. */
|
|
75
|
+
onplaybackreveal?: () => void | Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Where the default playback lookups find the editor's chrome: tabs (`[data-image-tab]`),
|
|
78
|
+
* sliders (`[data-control-id]`) and layer rows (`[data-layer-id]`). Default: the canvas itself.
|
|
79
|
+
*/
|
|
80
|
+
chromeRoot?: () => HTMLElement | null | undefined;
|
|
81
|
+
/** Your own lookup for a playback ref, in document pixels (tried first). */
|
|
82
|
+
locate?: (ref: PlaybackRef) => PlaybackRect | null;
|
|
83
|
+
/** Your own element for a playback ref, anywhere on the page; its box is mapped to document pixels. */
|
|
84
|
+
locateElement?: (ref: PlaybackRef) => Element | null;
|
|
85
|
+
/** Show a panel for an `open` step (default: click its `[data-image-tab]` under `chromeRoot`). */
|
|
86
|
+
open?: (ref: PlaybackRef) => void | Promise<void>;
|
|
87
|
+
class?: string;
|
|
88
|
+
style?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let {
|
|
92
|
+
editor,
|
|
93
|
+
mode = 'none',
|
|
94
|
+
fit,
|
|
95
|
+
selectionOptions = DEFAULT_SELECTION_TOOL_OPTIONS,
|
|
96
|
+
shapeOptions = DEFAULT_SHAPE_TOOL_OPTIONS,
|
|
97
|
+
typeOptions = DEFAULT_TYPE_TOOL_OPTIONS,
|
|
98
|
+
annotationColor = '#ef4444',
|
|
99
|
+
annotationWidth = 3,
|
|
100
|
+
cropRatio = null,
|
|
101
|
+
onerror,
|
|
102
|
+
typeSession = $bindable(null),
|
|
103
|
+
transformTool: hostTransformTool,
|
|
104
|
+
playback,
|
|
105
|
+
playbackId = 'image',
|
|
106
|
+
onplaybackreveal,
|
|
107
|
+
chromeRoot,
|
|
108
|
+
locate,
|
|
109
|
+
locateElement,
|
|
110
|
+
open,
|
|
111
|
+
class: className = '',
|
|
112
|
+
style = '',
|
|
113
|
+
}: Props = $props();
|
|
114
|
+
|
|
115
|
+
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
|
116
|
+
let containerEl = $state<HTMLDivElement | null>(null);
|
|
117
|
+
let rootEl = $state<HTMLDivElement | null>(null);
|
|
118
|
+
const report = (source: ImageEditorCanvasErrorSource, message: string | null) => onerror?.(source, message);
|
|
119
|
+
|
|
120
|
+
// Agent playback: the agent's cursor in document pixels, drawn over the canvas without clipping so
|
|
121
|
+
// it can visit the host's chrome (tabs, sliders, layer rows).
|
|
122
|
+
const ambientPlayback = getAgentPlayback();
|
|
123
|
+
const playing = $derived((playback === undefined ? ambientPlayback : playback) ?? null);
|
|
124
|
+
/** Document pixels → pixels in the canvas root, through the canvas projection. */
|
|
125
|
+
const docToRoot = (p: PlaybackPoint): PlaybackPoint => {
|
|
126
|
+
if (!canvasEl || !rootEl || !canvasEl.width) return p;
|
|
127
|
+
const r = canvasEl.getBoundingClientRect();
|
|
128
|
+
const box = rootEl.getBoundingClientRect();
|
|
129
|
+
const c = imageToCanvasPoint(p, editor.controller.getState(), canvasEl.width, canvasEl.height, mode === 'crop');
|
|
130
|
+
return { x: r.left - box.left + (c.x * r.width) / canvasEl.width, y: r.top - box.top + (c.y * r.height) / canvasEl.height };
|
|
131
|
+
};
|
|
132
|
+
/** Viewport (client) pixels → document pixels. */
|
|
133
|
+
const clientToDoc = (p: PlaybackPoint): PlaybackPoint => {
|
|
134
|
+
if (!canvasEl || !canvasEl.width) return p;
|
|
135
|
+
const r = canvasEl.getBoundingClientRect();
|
|
136
|
+
const sx = r.width > 0 ? canvasEl.width / r.width : 1;
|
|
137
|
+
const sy = r.height > 0 ? canvasEl.height / r.height : 1;
|
|
138
|
+
return canvasToImagePoint({ x: (p.x - r.left) * sx, y: (p.y - r.top) * sy }, editor.controller.getState(), canvasEl.width, canvasEl.height, mode === 'crop');
|
|
139
|
+
};
|
|
140
|
+
/** A client box as a rectangle in document pixels. */
|
|
141
|
+
const clientRect = (r: DOMRect | null | undefined): PlaybackRect | null => {
|
|
142
|
+
if (!r || r.width <= 0) return null;
|
|
143
|
+
const a = clientToDoc({ x: r.left, y: r.top });
|
|
144
|
+
const b = clientToDoc({ x: r.right, y: r.bottom });
|
|
145
|
+
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) };
|
|
146
|
+
};
|
|
147
|
+
const chrome = () => chromeRoot?.() ?? rootEl;
|
|
148
|
+
const elementRect = (selector: string): PlaybackRect | null => clientRect(chrome()?.querySelector(selector)?.getBoundingClientRect());
|
|
149
|
+
const agentPresence = createPlaybackSurface(() => playing, () => playbackId, () => ({
|
|
150
|
+
defaultKind: 'layer',
|
|
151
|
+
locate: (ref: PlaybackRef) => {
|
|
152
|
+
const own = locate?.(ref) ?? clientRect(locateElement?.(ref)?.getBoundingClientRect());
|
|
153
|
+
if (own) return own;
|
|
154
|
+
if (ref.kind === 'panel') return elementRect(`[data-image-tab="${CSS.escape(ref.id)}"]`);
|
|
155
|
+
if (ref.kind === 'control') return elementRect(`[data-control-id="${CSS.escape(ref.id)}"]`);
|
|
156
|
+
return createImagePlaybackLocator(editor.controller)(ref) ?? (ref.kind === 'layer' || ref.kind === 'item' ? elementRect(`[data-layer-id="${CSS.escape(ref.id)}"]`) : null);
|
|
157
|
+
},
|
|
158
|
+
open: async (ref: PlaybackRef) => {
|
|
159
|
+
if (open) return open(ref);
|
|
160
|
+
if (ref.kind !== 'panel') return;
|
|
161
|
+
const tab = chrome()?.querySelector<HTMLElement>(`[data-image-tab="${CSS.escape(ref.id)}"]`);
|
|
162
|
+
if (!tab || tab.getAttribute('aria-selected') === 'true') return;
|
|
163
|
+
tab.click();
|
|
164
|
+
// Let the panel render before the cursor looks for its controls.
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
166
|
+
},
|
|
167
|
+
bounds: () => clientRect(canvasEl?.getBoundingClientRect()),
|
|
168
|
+
visible: () => {
|
|
169
|
+
const r = rootEl?.getBoundingClientRect();
|
|
170
|
+
return !!r && r.width > 0 && r.height > 0 && !(typeof document !== 'undefined' && document.visibilityState === 'hidden');
|
|
171
|
+
},
|
|
172
|
+
toViewport: (p: PlaybackPoint) => {
|
|
173
|
+
const box = rootEl?.getBoundingClientRect();
|
|
174
|
+
const q = docToRoot(p);
|
|
175
|
+
return { x: (box?.left ?? 0) + q.x, y: (box?.top ?? 0) + q.y };
|
|
176
|
+
},
|
|
177
|
+
fromViewport: clientToDoc,
|
|
178
|
+
reveal: () => onplaybackreveal?.(),
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
// Selection tools (magic wand, marquees, lasso): the pointer gesture.
|
|
182
|
+
const selectionGesture = $derived(new SelectionGesture(editor.controller, () => selectionOptions));
|
|
183
|
+
let selectionDraft = $state.raw<ImagePoint[] | null>(null);
|
|
184
|
+
// Paint tools (Move, Brush, Eraser): the pointer gesture and the brush cursor.
|
|
185
|
+
const paintGesture = $derived(new PaintGesture(editor.controller));
|
|
186
|
+
let brushCursor: ImagePoint | null = null;
|
|
187
|
+
|
|
188
|
+
// Shape and type tools; type is edited on the canvas through a hidden textarea.
|
|
189
|
+
const shapeGesture = $derived(new ShapeDrawGesture(editor.controller, () => shapeOptions));
|
|
190
|
+
const typeTool = $derived(new TypeToolGesture(editor.controller, () => typeOptions));
|
|
191
|
+
let typeInputEl = $state<HTMLTextAreaElement | null>(null);
|
|
192
|
+
$effect(() => {
|
|
193
|
+
const s = typeSession;
|
|
194
|
+
if (!s) return;
|
|
195
|
+
return untrack(() => s.subscribe(() => void renderCanvas()));
|
|
196
|
+
});
|
|
197
|
+
$effect(() => {
|
|
198
|
+
// End the edit when the mode or tool changes, or when the host sets `typeSession` to null.
|
|
199
|
+
const keep = mode === 'annotate' && editor.state.activeTool === 'type' && typeSession !== null;
|
|
200
|
+
untrack(() => {
|
|
201
|
+
if (!keep && typeTool.session) finishTyping();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
function finishTyping() {
|
|
205
|
+
typeTool.finish();
|
|
206
|
+
typeSession = null;
|
|
207
|
+
void renderCanvas();
|
|
208
|
+
}
|
|
209
|
+
function handleTypeKeyDown(e: KeyboardEvent) {
|
|
210
|
+
const session = typeTool.session;
|
|
211
|
+
if (!session || e.isComposing) return;
|
|
212
|
+
if (e.key === 'Escape') {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
finishTyping();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if ((e.metaKey || e.ctrlKey) && ['z', 'y', 'c', 'x', 'v'].includes(e.key.toLowerCase())) return;
|
|
218
|
+
if (session.handleKey(e)) {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
report('type', session.error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function handleTypeInput(e: Event) {
|
|
224
|
+
// Composed (IME) and pasted text arrives here; plain keys were handled on keydown.
|
|
225
|
+
const el = e.currentTarget as HTMLTextAreaElement;
|
|
226
|
+
const session = typeTool.session;
|
|
227
|
+
if (session && el.value && !(e as InputEvent).isComposing) {
|
|
228
|
+
session.insert(el.value);
|
|
229
|
+
el.value = '';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function handleTypeComposition(e: CompositionEvent) {
|
|
233
|
+
const session = typeTool.session;
|
|
234
|
+
if (session && e.data) session.insert(e.data);
|
|
235
|
+
(e.currentTarget as HTMLTextAreaElement).value = '';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Free Transform (Ctrl/Cmd+T): the shared tool drives a session; its overlay is drawn by the renderer.
|
|
239
|
+
const ownTransformTool = $derived(
|
|
240
|
+
hostTransformTool
|
|
241
|
+
? null
|
|
242
|
+
: new FreeTransformTool(editor.controller, {
|
|
243
|
+
schedule: (run) => (typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame(run) : run()),
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
const transformTool = $derived(hostTransformTool ?? ownTransformTool!);
|
|
247
|
+
let transformTick = $state(0);
|
|
248
|
+
let transformCursor = $state('default');
|
|
249
|
+
const transformActive = $derived((void transformTick, transformTool.active));
|
|
250
|
+
$effect(() => {
|
|
251
|
+
const tool = transformTool;
|
|
252
|
+
const owned = tool === ownTransformTool;
|
|
253
|
+
const off = tool.subscribe(() => {
|
|
254
|
+
transformTick++;
|
|
255
|
+
void renderCanvas();
|
|
256
|
+
});
|
|
257
|
+
return () => {
|
|
258
|
+
off();
|
|
259
|
+
if (owned) tool.cancel();
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
$effect(() => {
|
|
263
|
+
if (typeof window === 'undefined') return;
|
|
264
|
+
const tool = transformTool;
|
|
265
|
+
const onKey = (e: KeyboardEvent) => {
|
|
266
|
+
if (tool.key(e)) e.preventDefault();
|
|
267
|
+
};
|
|
268
|
+
window.addEventListener('keydown', onKey);
|
|
269
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/** The document point of a pointer event and the hit tolerance (6 screen px). */
|
|
273
|
+
function transformPoint(e: PointerEvent) {
|
|
274
|
+
const rect = canvasEl!.getBoundingClientRect();
|
|
275
|
+
const sx = rect.width > 0 ? canvasEl!.width / rect.width : 1;
|
|
276
|
+
const sy = rect.height > 0 ? canvasEl!.height / rect.height : 1;
|
|
277
|
+
const s = editor.controller.getState();
|
|
278
|
+
const cropping = mode === 'crop';
|
|
279
|
+
const projection = getViewportProjection(s, canvasEl!.width, canvasEl!.height, cropping);
|
|
280
|
+
const pt = canvasToImagePoint({ x: (e.clientX - rect.left) * sx, y: (e.clientY - rect.top) * sy }, s, canvasEl!.width, canvasEl!.height, cropping);
|
|
281
|
+
transformTool.setView(projection.scale);
|
|
282
|
+
return { pt, tolerance: 6 / (projection.scale || 1) };
|
|
283
|
+
}
|
|
284
|
+
function handleTransformPointerDown(e: PointerEvent): boolean {
|
|
285
|
+
if (!transformTool.active || !canvasEl) return false;
|
|
286
|
+
if (e.button !== 0) return true;
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
try {
|
|
289
|
+
canvasEl.setPointerCapture?.(e.pointerId);
|
|
290
|
+
} catch {
|
|
291
|
+
// Synthetic events have no active pointer to capture.
|
|
292
|
+
}
|
|
293
|
+
const { pt, tolerance } = transformPoint(e);
|
|
294
|
+
transformTool.down(pt, tolerance);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
function handleTransformPointerMove(e: PointerEvent): boolean {
|
|
298
|
+
if (!transformTool.active || !canvasEl) return false;
|
|
299
|
+
const { pt, tolerance } = transformPoint(e);
|
|
300
|
+
if (e.buttons & 1) transformTool.move(pt, e);
|
|
301
|
+
else transformCursor = transformTool.cursor(transformTool.hit(pt, tolerance));
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
function handleTransformPointerUp(): boolean {
|
|
305
|
+
if (!transformTool.active) return false;
|
|
306
|
+
transformTool.up();
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Live draft and drag references
|
|
311
|
+
let draftAnnotation = $state<Annotation | null>(null);
|
|
312
|
+
let draftCrop = $state<CropBox | null>(null);
|
|
313
|
+
let cropDragInfo: {
|
|
314
|
+
handle: CropHandle | 'new';
|
|
315
|
+
startCanvasPt: ImagePoint;
|
|
316
|
+
startImgPt: ImagePoint;
|
|
317
|
+
startCrop: CropBox;
|
|
318
|
+
} | null = null;
|
|
319
|
+
let annotationDragInfo: {
|
|
320
|
+
annId: string;
|
|
321
|
+
startImgPt: ImagePoint;
|
|
322
|
+
initialAnn: Annotation;
|
|
323
|
+
} | null = null;
|
|
324
|
+
let isDrawing = false;
|
|
325
|
+
let currentPath: ImagePoint[] = [];
|
|
326
|
+
let dragStartPoint: ImagePoint | null = null;
|
|
327
|
+
|
|
328
|
+
async function renderCanvas(overrideAnnotation?: Annotation | null, overrideCrop?: CropBox | null) {
|
|
329
|
+
if (!canvasEl || !containerEl) return;
|
|
330
|
+
const vpWidth = containerEl.clientWidth || 800;
|
|
331
|
+
const vpHeight = containerEl.clientHeight || 500;
|
|
332
|
+
|
|
333
|
+
await editor.controller.render(canvasEl, {
|
|
334
|
+
viewportWidth: vpWidth,
|
|
335
|
+
viewportHeight: vpHeight,
|
|
336
|
+
fit,
|
|
337
|
+
showCropOverlay: mode === 'crop',
|
|
338
|
+
showSelectionOverlay: true,
|
|
339
|
+
interactive: true,
|
|
340
|
+
draftAnnotation: overrideAnnotation !== undefined ? overrideAnnotation : draftAnnotation,
|
|
341
|
+
draftCrop: overrideCrop !== undefined ? overrideCrop : draftCrop,
|
|
342
|
+
draftSelectionPath: selectionDraft,
|
|
343
|
+
brushCursor:
|
|
344
|
+
mode === 'paint' && brushCursor && editor.controller.getState().activeTool !== 'move' && !transformTool.active
|
|
345
|
+
? { ...brushCursor, diameter: editor.controller.getState().paint.brush.size }
|
|
346
|
+
: null,
|
|
347
|
+
freeTransform: transformTool.overlay(),
|
|
348
|
+
textEdit: typeTool.session?.alive ? typeTool.session.overlay() : null,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Paint tools: pointer events (coalesced samples, pointer capture) into strokes and moves.
|
|
353
|
+
function paintSamples(e: PointerEvent) {
|
|
354
|
+
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
|
|
355
|
+
const list = events.length ? events : [e];
|
|
356
|
+
const rect = canvasEl!.getBoundingClientRect();
|
|
357
|
+
const sx = rect.width > 0 ? canvasEl!.width / rect.width : 1;
|
|
358
|
+
const sy = rect.height > 0 ? canvasEl!.height / rect.height : 1;
|
|
359
|
+
const s = editor.controller.getState();
|
|
360
|
+
return list.map((ev) => ({
|
|
361
|
+
...canvasToImagePoint({ x: (ev.clientX - rect.left) * sx, y: (ev.clientY - rect.top) * sy }, s, canvasEl!.width, canvasEl!.height, false),
|
|
362
|
+
pressure: pointerPressure(ev),
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
function handlePaintPointerDown(e: PointerEvent) {
|
|
366
|
+
if (handleTransformPointerDown(e)) return;
|
|
367
|
+
if (mode !== 'paint' || e.button !== 0 || !canvasEl) return;
|
|
368
|
+
e.preventDefault();
|
|
369
|
+
try {
|
|
370
|
+
canvasEl.setPointerCapture?.(e.pointerId);
|
|
371
|
+
} catch {
|
|
372
|
+
// Synthetic events have no active pointer to capture.
|
|
373
|
+
}
|
|
374
|
+
report('paint', paintGesture.down({ ...paintSamples(e)[0], altKey: e.altKey }));
|
|
375
|
+
}
|
|
376
|
+
function handlePaintPointerMove(e: PointerEvent) {
|
|
377
|
+
if (handleTransformPointerMove(e)) return;
|
|
378
|
+
if (mode !== 'paint' || !canvasEl) return;
|
|
379
|
+
const samples = paintSamples(e);
|
|
380
|
+
brushCursor = samples[samples.length - 1] ?? null;
|
|
381
|
+
if (paintGesture.active) paintGesture.moveTo(samples);
|
|
382
|
+
else void renderCanvas();
|
|
383
|
+
}
|
|
384
|
+
function handlePaintPointerUp() {
|
|
385
|
+
if (handleTransformPointerUp()) return;
|
|
386
|
+
if (mode !== 'paint' || !paintGesture.active) return;
|
|
387
|
+
paintGesture.up();
|
|
388
|
+
if (paintGesture.error) report('paint', paintGesture.error);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
$effect(() => {
|
|
392
|
+
if (mode !== 'paint' || typeof window === 'undefined') return;
|
|
393
|
+
const c = editor.controller;
|
|
394
|
+
const onKey = (e: KeyboardEvent) => {
|
|
395
|
+
if (handlePaintShortcut(c, e)) e.preventDefault();
|
|
396
|
+
};
|
|
397
|
+
window.addEventListener('keydown', onKey);
|
|
398
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
$effect(() => {
|
|
402
|
+
// Re-render when editor state, the mode or the fit changes
|
|
403
|
+
void editor.state;
|
|
404
|
+
void mode;
|
|
405
|
+
void fit;
|
|
406
|
+
|
|
407
|
+
const raf = typeof requestAnimationFrame !== 'undefined'
|
|
408
|
+
? requestAnimationFrame
|
|
409
|
+
: (cb: () => void) => setTimeout(cb, 0) as unknown as number;
|
|
410
|
+
const caf = typeof cancelAnimationFrame !== 'undefined'
|
|
411
|
+
? cancelAnimationFrame
|
|
412
|
+
: (id: number) => clearTimeout(id);
|
|
413
|
+
|
|
414
|
+
const animId = raf(() => {
|
|
415
|
+
renderCanvas();
|
|
416
|
+
});
|
|
417
|
+
return () => caf(animId);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
$effect(() => {
|
|
421
|
+
if (!containerEl) return;
|
|
422
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
423
|
+
const observer = new ResizeObserver(() => {
|
|
424
|
+
renderCanvas();
|
|
425
|
+
});
|
|
426
|
+
observer.observe(containerEl);
|
|
427
|
+
return () => observer.disconnect();
|
|
428
|
+
} else {
|
|
429
|
+
const handleResize = () => renderCanvas();
|
|
430
|
+
window.addEventListener('resize', handleResize);
|
|
431
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
function getCanvasEventPoint(e: MouseEvent): ImagePoint {
|
|
436
|
+
if (!canvasEl) return { x: 0, y: 0 };
|
|
437
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
438
|
+
const scaleX = rect.width > 0 ? canvasEl.width / rect.width : 1;
|
|
439
|
+
const scaleY = rect.height > 0 ? canvasEl.height / rect.height : 1;
|
|
440
|
+
return {
|
|
441
|
+
x: (e.clientX - rect.left) * scaleX,
|
|
442
|
+
y: (e.clientY - rect.top) * scaleY,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function handleMouseDown(e: MouseEvent) {
|
|
447
|
+
if (!canvasEl || transformTool.active) return;
|
|
448
|
+
const canvasPt = getCanvasEventPoint(e);
|
|
449
|
+
|
|
450
|
+
if (mode === 'select') {
|
|
451
|
+
const gesture = selectionGesture;
|
|
452
|
+
const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
|
|
453
|
+
void gesture.begin(imgPt, e).then(() => report('select', gesture.error));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (mode === 'crop') {
|
|
458
|
+
const activeCrop = draftCrop || editor.state.crop || {
|
|
459
|
+
x: 0,
|
|
460
|
+
y: 0,
|
|
461
|
+
width: editor.state.imageDimensions.width,
|
|
462
|
+
height: editor.state.imageDimensions.height,
|
|
463
|
+
};
|
|
464
|
+
const handle = hitTestCrop(canvasPt, editor.state, canvasEl.width, canvasEl.height, 14, activeCrop);
|
|
465
|
+
const startImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, true);
|
|
466
|
+
|
|
467
|
+
if (handle) {
|
|
468
|
+
cropDragInfo = {
|
|
469
|
+
handle,
|
|
470
|
+
startCanvasPt: canvasPt,
|
|
471
|
+
startImgPt,
|
|
472
|
+
startCrop: activeCrop,
|
|
473
|
+
};
|
|
474
|
+
} else {
|
|
475
|
+
// Clicked outside crop box: start new crop selection
|
|
476
|
+
cropDragInfo = {
|
|
477
|
+
handle: 'new',
|
|
478
|
+
startCanvasPt: canvasPt,
|
|
479
|
+
startImgPt,
|
|
480
|
+
startCrop: { x: startImgPt.x, y: startImgPt.y, width: 0, height: 0 },
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (mode === 'annotate' && (editor.state.activeTool === 'shape' || editor.state.activeTool === 'type')) {
|
|
487
|
+
const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
|
|
488
|
+
report(editor.state.activeTool, null);
|
|
489
|
+
if (editor.state.activeTool === 'shape') shapeGesture.down(imgPt);
|
|
490
|
+
else {
|
|
491
|
+
typeTool.down(imgPt, { shift: e.shiftKey });
|
|
492
|
+
typeSession = typeTool.session;
|
|
493
|
+
void renderCanvas();
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (mode === 'annotate') {
|
|
499
|
+
const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
|
|
500
|
+
dragStartPoint = imgPt;
|
|
501
|
+
|
|
502
|
+
if (editor.state.activeTool === 'select') {
|
|
503
|
+
for (let i = editor.state.annotations.length - 1; i >= 0; i--) {
|
|
504
|
+
const ann = editor.state.annotations[i];
|
|
505
|
+
if (hitTestAnnotation(imgPt, ann)) {
|
|
506
|
+
editor.selectAnnotation(ann.id);
|
|
507
|
+
annotationDragInfo = {
|
|
508
|
+
annId: ann.id,
|
|
509
|
+
startImgPt: imgPt,
|
|
510
|
+
initialAnn: { ...ann },
|
|
511
|
+
};
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
editor.selectAnnotation(null);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (editor.state.activeTool === 'pen') {
|
|
520
|
+
isDrawing = true;
|
|
521
|
+
currentPath = [imgPt];
|
|
522
|
+
const draft: Annotation = {
|
|
523
|
+
id: 'draft',
|
|
524
|
+
type: 'pen',
|
|
525
|
+
x: 0,
|
|
526
|
+
y: 0,
|
|
527
|
+
points: [imgPt],
|
|
528
|
+
strokeColor: annotationColor,
|
|
529
|
+
strokeWidth: annotationWidth,
|
|
530
|
+
};
|
|
531
|
+
draftAnnotation = draft;
|
|
532
|
+
renderCanvas(draft);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function handleMouseMove(e: MouseEvent) {
|
|
538
|
+
if (!canvasEl || transformTool.active) return;
|
|
539
|
+
const canvasPt = getCanvasEventPoint(e);
|
|
540
|
+
|
|
541
|
+
if (mode === 'select') {
|
|
542
|
+
const gesture = selectionGesture;
|
|
543
|
+
if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false))) {
|
|
544
|
+
selectionDraft = gesture.draftPath;
|
|
545
|
+
renderCanvas();
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (mode === 'crop') {
|
|
551
|
+
if (cropDragInfo) {
|
|
552
|
+
const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, true);
|
|
553
|
+
let newCrop: CropBox;
|
|
554
|
+
|
|
555
|
+
if (cropDragInfo.handle === 'new') {
|
|
556
|
+
const minX = Math.max(0, Math.min(cropDragInfo.startImgPt.x, currentImgPt.x));
|
|
557
|
+
const minY = Math.max(0, Math.min(cropDragInfo.startImgPt.y, currentImgPt.y));
|
|
558
|
+
const maxX = Math.min(editor.state.imageDimensions.width, Math.max(cropDragInfo.startImgPt.x, currentImgPt.x));
|
|
559
|
+
const maxY = Math.min(editor.state.imageDimensions.height, Math.max(cropDragInfo.startImgPt.y, currentImgPt.y));
|
|
560
|
+
newCrop = {
|
|
561
|
+
x: Math.round(minX),
|
|
562
|
+
y: Math.round(minY),
|
|
563
|
+
width: Math.round(Math.max(20, maxX - minX)),
|
|
564
|
+
height: Math.round(Math.max(20, maxY - minY)),
|
|
565
|
+
};
|
|
566
|
+
} else {
|
|
567
|
+
newCrop = calculateCropDrag(
|
|
568
|
+
cropDragInfo.startCrop,
|
|
569
|
+
cropDragInfo.handle,
|
|
570
|
+
cropDragInfo.startImgPt,
|
|
571
|
+
currentImgPt,
|
|
572
|
+
editor.state.imageDimensions.width,
|
|
573
|
+
editor.state.imageDimensions.height,
|
|
574
|
+
cropRatio,
|
|
575
|
+
editor.state.transform
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
draftCrop = newCrop;
|
|
580
|
+
renderCanvas(null, newCrop);
|
|
581
|
+
} else {
|
|
582
|
+
const activeCrop = draftCrop || editor.state.crop || {
|
|
583
|
+
x: 0,
|
|
584
|
+
y: 0,
|
|
585
|
+
width: editor.state.imageDimensions.width,
|
|
586
|
+
height: editor.state.imageDimensions.height,
|
|
587
|
+
};
|
|
588
|
+
const handle = hitTestCrop(canvasPt, editor.state, canvasEl.width, canvasEl.height, 14, activeCrop);
|
|
589
|
+
canvasEl.style.cursor = getCropCursor(handle, editor.state.transform);
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (mode === 'annotate' && editor.state.activeTool === 'shape') {
|
|
595
|
+
if (shapeGesture.active) {
|
|
596
|
+
shapeGesture.move(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false), { shift: e.shiftKey, alt: e.altKey });
|
|
597
|
+
if (shapeGesture.error) report('shape', shapeGesture.error);
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (mode === 'annotate') {
|
|
603
|
+
if (annotationDragInfo) {
|
|
604
|
+
const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
|
|
605
|
+
const dx = currentImgPt.x - annotationDragInfo.startImgPt.x;
|
|
606
|
+
const dy = currentImgPt.y - annotationDragInfo.startImgPt.y;
|
|
607
|
+
editor.updateAnnotation(annotationDragInfo.annId, {
|
|
608
|
+
x: Math.round(annotationDragInfo.initialAnn.x + dx),
|
|
609
|
+
y: Math.round(annotationDragInfo.initialAnn.y + dy),
|
|
610
|
+
});
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (!dragStartPoint) {
|
|
615
|
+
if (editor.state.activeTool === 'select') {
|
|
616
|
+
const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
|
|
617
|
+
let hovered = false;
|
|
618
|
+
for (let i = editor.state.annotations.length - 1; i >= 0; i--) {
|
|
619
|
+
if (hitTestAnnotation(imgPt, editor.state.annotations[i])) {
|
|
620
|
+
hovered = true;
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
canvasEl.style.cursor = hovered ? 'move' : 'default';
|
|
625
|
+
} else {
|
|
626
|
+
canvasEl.style.cursor = 'crosshair';
|
|
627
|
+
}
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
|
|
632
|
+
const stroke = { strokeColor: annotationColor, strokeWidth: annotationWidth };
|
|
633
|
+
|
|
634
|
+
if (editor.state.activeTool === 'pen' && isDrawing) {
|
|
635
|
+
currentPath.push(currentImgPt);
|
|
636
|
+
const draft: Annotation = { id: 'draft', type: 'pen', x: 0, y: 0, points: [...currentPath], ...stroke };
|
|
637
|
+
draftAnnotation = draft;
|
|
638
|
+
renderCanvas(draft);
|
|
639
|
+
} else if (editor.state.activeTool === 'rect') {
|
|
640
|
+
const w = currentImgPt.x - dragStartPoint.x;
|
|
641
|
+
const h = currentImgPt.y - dragStartPoint.y;
|
|
642
|
+
const draft: Annotation = {
|
|
643
|
+
id: 'draft',
|
|
644
|
+
type: 'rect',
|
|
645
|
+
x: Math.min(dragStartPoint.x, currentImgPt.x),
|
|
646
|
+
y: Math.min(dragStartPoint.y, currentImgPt.y),
|
|
647
|
+
width: Math.abs(w),
|
|
648
|
+
height: Math.abs(h),
|
|
649
|
+
...stroke,
|
|
650
|
+
};
|
|
651
|
+
draftAnnotation = draft;
|
|
652
|
+
renderCanvas(draft);
|
|
653
|
+
} else if (editor.state.activeTool === 'circle') {
|
|
654
|
+
const rx = Math.abs(currentImgPt.x - dragStartPoint.x) / 2;
|
|
655
|
+
const ry = Math.abs(currentImgPt.y - dragStartPoint.y) / 2;
|
|
656
|
+
const draft: Annotation = {
|
|
657
|
+
id: 'draft',
|
|
658
|
+
type: 'circle',
|
|
659
|
+
x: Math.min(dragStartPoint.x, currentImgPt.x) + rx,
|
|
660
|
+
y: Math.min(dragStartPoint.y, currentImgPt.y) + ry,
|
|
661
|
+
radiusX: rx,
|
|
662
|
+
radiusY: ry,
|
|
663
|
+
...stroke,
|
|
664
|
+
};
|
|
665
|
+
draftAnnotation = draft;
|
|
666
|
+
renderCanvas(draft);
|
|
667
|
+
} else if (editor.state.activeTool === 'arrow' || editor.state.activeTool === 'line') {
|
|
668
|
+
const draft: Annotation = {
|
|
669
|
+
id: 'draft',
|
|
670
|
+
type: editor.state.activeTool,
|
|
671
|
+
x: dragStartPoint.x,
|
|
672
|
+
y: dragStartPoint.y,
|
|
673
|
+
endX: currentImgPt.x,
|
|
674
|
+
endY: currentImgPt.y,
|
|
675
|
+
...stroke,
|
|
676
|
+
};
|
|
677
|
+
draftAnnotation = draft;
|
|
678
|
+
renderCanvas(draft);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function handleMouseUp(e: MouseEvent) {
|
|
684
|
+
if (!canvasEl || transformTool.active) return;
|
|
685
|
+
const canvasPt = getCanvasEventPoint(e);
|
|
686
|
+
|
|
687
|
+
if (mode === 'select') {
|
|
688
|
+
const gesture = selectionGesture;
|
|
689
|
+
if (gesture.active) {
|
|
690
|
+
gesture.end(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false), e);
|
|
691
|
+
selectionDraft = null;
|
|
692
|
+
renderCanvas();
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (mode === 'crop') {
|
|
698
|
+
if (cropDragInfo) {
|
|
699
|
+
if (draftCrop && draftCrop.width >= 20 && draftCrop.height >= 20) {
|
|
700
|
+
editor.setCrop(draftCrop, true);
|
|
701
|
+
}
|
|
702
|
+
cropDragInfo = null;
|
|
703
|
+
draftCrop = null;
|
|
704
|
+
renderCanvas();
|
|
705
|
+
}
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (mode === 'annotate' && editor.state.activeTool === 'shape') {
|
|
710
|
+
shapeGesture.up();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (mode === 'annotate' && editor.state.activeTool === 'type') {
|
|
714
|
+
typeTool.up(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false));
|
|
715
|
+
typeSession = typeTool.session;
|
|
716
|
+
if (typeTool.error) report('type', typeTool.error);
|
|
717
|
+
void renderCanvas();
|
|
718
|
+
// Keystrokes go to the hidden textarea while text is being edited.
|
|
719
|
+
if (typeTool.session) setTimeout(() => typeInputEl?.focus(), 0);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (mode === 'annotate') {
|
|
724
|
+
if (annotationDragInfo) {
|
|
725
|
+
annotationDragInfo = null;
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (dragStartPoint) {
|
|
730
|
+
const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
|
|
731
|
+
const stroke = { strokeColor: annotationColor, strokeWidth: annotationWidth };
|
|
732
|
+
|
|
733
|
+
if (editor.state.activeTool === 'pen' && isDrawing) {
|
|
734
|
+
if (currentPath.length > 1) {
|
|
735
|
+
editor.addAnnotation({ type: 'pen', x: 0, y: 0, points: [...currentPath], ...stroke });
|
|
736
|
+
}
|
|
737
|
+
isDrawing = false;
|
|
738
|
+
currentPath = [];
|
|
739
|
+
} else if (editor.state.activeTool === 'rect') {
|
|
740
|
+
const w = Math.abs(currentImgPt.x - dragStartPoint.x);
|
|
741
|
+
const h = Math.abs(currentImgPt.y - dragStartPoint.y);
|
|
742
|
+
if (w > 4 && h > 4) {
|
|
743
|
+
editor.addAnnotation({
|
|
744
|
+
type: 'rect',
|
|
745
|
+
x: Math.min(dragStartPoint.x, currentImgPt.x),
|
|
746
|
+
y: Math.min(dragStartPoint.y, currentImgPt.y),
|
|
747
|
+
width: w,
|
|
748
|
+
height: h,
|
|
749
|
+
...stroke,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
} else if (editor.state.activeTool === 'circle') {
|
|
753
|
+
const rx = Math.abs(currentImgPt.x - dragStartPoint.x) / 2;
|
|
754
|
+
const ry = Math.abs(currentImgPt.y - dragStartPoint.y) / 2;
|
|
755
|
+
if (rx > 4 && ry > 4) {
|
|
756
|
+
editor.addAnnotation({
|
|
757
|
+
type: 'circle',
|
|
758
|
+
x: Math.min(dragStartPoint.x, currentImgPt.x) + rx,
|
|
759
|
+
y: Math.min(dragStartPoint.y, currentImgPt.y) + ry,
|
|
760
|
+
radiusX: rx,
|
|
761
|
+
radiusY: ry,
|
|
762
|
+
...stroke,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
} else if (editor.state.activeTool === 'arrow' || editor.state.activeTool === 'line') {
|
|
766
|
+
if (Math.hypot(currentImgPt.x - dragStartPoint.x, currentImgPt.y - dragStartPoint.y) > 4) {
|
|
767
|
+
editor.addAnnotation({
|
|
768
|
+
type: editor.state.activeTool,
|
|
769
|
+
x: dragStartPoint.x,
|
|
770
|
+
y: dragStartPoint.y,
|
|
771
|
+
endX: currentImgPt.x,
|
|
772
|
+
endY: currentImgPt.y,
|
|
773
|
+
...stroke,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
draftAnnotation = null;
|
|
779
|
+
dragStartPoint = null;
|
|
780
|
+
renderCanvas();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
</script>
|
|
785
|
+
|
|
786
|
+
<!--
|
|
787
|
+
The image editor's canvas on its own: the render loop (sized by its container), every pointer
|
|
788
|
+
gesture of the crop, select, paint and annotate modes, Free Transform, the hidden textarea for
|
|
789
|
+
on-canvas type, and the agent playback surface. Give it a size (it fills its box). Parity with
|
|
790
|
+
React's `<ImageEditorCanvas>`.
|
|
791
|
+
-->
|
|
792
|
+
<div bind:this={rootEl} class="relative {className}" {style} data-testid="image-editor-canvas-root">
|
|
793
|
+
<div bind:this={containerEl} class="absolute inset-0 overflow-hidden select-none">
|
|
794
|
+
<canvas
|
|
795
|
+
bind:this={canvasEl}
|
|
796
|
+
onmousedown={handleMouseDown}
|
|
797
|
+
onmousemove={handleMouseMove}
|
|
798
|
+
onmouseup={handleMouseUp}
|
|
799
|
+
onmouseleave={handleMouseUp}
|
|
800
|
+
onpointerdown={handlePaintPointerDown}
|
|
801
|
+
onpointermove={handlePaintPointerMove}
|
|
802
|
+
onpointerup={handlePaintPointerUp}
|
|
803
|
+
onpointercancel={() => paintGesture.cancel()}
|
|
804
|
+
data-testid="image-editor-canvas"
|
|
805
|
+
style={transformActive ? `cursor: ${transformCursor}` : undefined}
|
|
806
|
+
class="absolute inset-0 w-full h-full block {transformActive ? 'touch-none' : mode === 'paint' ? 'touch-none cursor-crosshair' : mode === 'annotate' && editor.state.activeTool === 'type' ? 'cursor-text' : mode === 'annotate' && editor.state.activeTool === 'shape' ? 'cursor-crosshair' : ''}"
|
|
807
|
+
></canvas>
|
|
808
|
+
{#if typeSession}
|
|
809
|
+
<textarea
|
|
810
|
+
bind:this={typeInputEl}
|
|
811
|
+
aria-label="Edit text"
|
|
812
|
+
data-testid="type-input"
|
|
813
|
+
autocapitalize="off"
|
|
814
|
+
spellcheck={false}
|
|
815
|
+
onkeydown={handleTypeKeyDown}
|
|
816
|
+
oninput={handleTypeInput}
|
|
817
|
+
oncompositionend={handleTypeComposition}
|
|
818
|
+
class="absolute left-0 top-0 h-px w-px opacity-0"
|
|
819
|
+
></textarea>
|
|
820
|
+
{/if}
|
|
821
|
+
</div>
|
|
822
|
+
{#if playing}
|
|
823
|
+
<PresenceLayer presence={agentPresence} viewport={docToRoot} clip={false} />
|
|
824
|
+
{/if}
|
|
825
|
+
</div>
|