@pixerate/editor-svelte 0.106.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.
@@ -1,40 +1,20 @@
1
1
  <script lang="ts">
2
2
  import type {
3
- Annotation,
4
- CropBox,
5
- CropHandle,
6
3
  ExportOptions,
7
4
  ImageEditorTool,
8
- ImagePoint,
9
5
  InpaintHook,
10
6
  RemoveBackgroundHook,
11
7
  ImageEditorAIHooks,
12
8
  ImageEditorFit,
13
9
  } from '@pixerate/editor/image-editor';
14
- import type { AgentPlayback, PlaybackPoint, PlaybackRect, PlaybackRef } from '@pixerate/editor/tools';
15
- import { createPlaybackSurface, getAgentPlayback } from '../playback/playback.svelte.js';
16
- import PresenceLayer from '../playback/PresenceLayer.svelte';
10
+ import type { AgentPlayback, PlaybackRef } from '@pixerate/editor/tools';
17
11
  import {
18
- canvasToImagePoint,
19
- imageToCanvasPoint,
20
- createImagePlaybackLocator,
21
- hitTestCrop,
22
- getCropCursor,
23
- calculateCropDrag,
24
- hitTestAnnotation,
25
12
  normalizeProcessVersion,
26
13
  LATEST_PROCESS_VERSION,
27
14
  IMAGE_ADJUSTMENT_CONTROLS,
28
15
  readAdjustmentControls,
29
16
  DEFAULT_SELECTION_TOOL_OPTIONS,
30
- SelectionGesture,
31
- PaintGesture,
32
- handlePaintShortcut,
33
- pointerPressure,
34
17
  FreeTransformTool,
35
- getViewportProjection,
36
- ShapeDrawGesture,
37
- TypeToolGesture,
38
18
  DEFAULT_SHAPE_TOOL_OPTIONS,
39
19
  DEFAULT_TYPE_TOOL_OPTIONS,
40
20
  type SelectionToolOptions,
@@ -42,6 +22,8 @@
42
22
  type TextEditSession,
43
23
  type TypeToolOptions,
44
24
  } from '@pixerate/editor/image-editor';
25
+ import ImageEditorCanvas from './ImageEditorCanvas.svelte';
26
+ import type { ImageEditorCanvasErrorSource, ImageEditorCanvasMode } from './canvasTypes.js';
45
27
  import ControlSlider from './ControlSlider.svelte';
46
28
  import Histogram from './Histogram.svelte';
47
29
  import AdjustmentLayersPanel from './AdjustmentLayersPanel.svelte';
@@ -115,204 +97,59 @@
115
97
  normalizeProcessVersion(editor.state.adjustments.process) < LATEST_PROCESS_VERSION
116
98
  );
117
99
 
118
- let canvasEl = $state<HTMLCanvasElement | null>(null);
119
- let containerEl = $state<HTMLDivElement | null>(null);
120
100
  let activeTab = $state<'crop' | 'adjust' | 'layers' | 'paint' | 'select' | 'annotate' | 'depth' | 'ai'>('crop');
121
101
  let rootEl = $state<HTMLDivElement | null>(null);
102
+ /** The tabs with canvas gestures drive the canvas mode; the others only view. */
103
+ const canvasMode = $derived<ImageEditorCanvasMode>(
104
+ activeTab === 'crop' || activeTab === 'select' || activeTab === 'paint' || activeTab === 'annotate' ? activeTab : 'none'
105
+ );
122
106
 
123
- // Agent playback: the agent's cursor in document pixels, over the whole editor (canvas and panels).
124
- const ambientPlayback = getAgentPlayback();
125
- const playing = $derived((playback === undefined ? ambientPlayback : playback) ?? null);
126
- /** Document pixels → pixels in the editor's root element, through the canvas projection. */
127
- const docToRoot = (p: PlaybackPoint): PlaybackPoint => {
128
- if (!canvasEl || !rootEl || !canvasEl.width) return p;
129
- const r = canvasEl.getBoundingClientRect();
130
- const box = rootEl.getBoundingClientRect();
131
- const c = imageToCanvasPoint(p, editor.controller.getState(), canvasEl.width, canvasEl.height, activeTab === 'crop');
132
- return { x: r.left - box.left + (c.x * r.width) / canvasEl.width, y: r.top - box.top + (c.y * r.height) / canvasEl.height };
133
- };
134
- /** Viewport (client) pixels → document pixels. */
135
- const clientToDoc = (p: PlaybackPoint): PlaybackPoint => {
136
- if (!canvasEl || !canvasEl.width) return p;
137
- const r = canvasEl.getBoundingClientRect();
138
- const sx = r.width > 0 ? canvasEl.width / r.width : 1;
139
- const sy = r.height > 0 ? canvasEl.height / r.height : 1;
140
- return canvasToImagePoint({ x: (p.x - r.left) * sx, y: (p.y - r.top) * sy }, editor.controller.getState(), canvasEl.width, canvasEl.height, activeTab === 'crop');
141
- };
142
- /** An element of the editor (a tab, slider or layer row) as a rectangle in document pixels. */
143
- const elementRect = (selector: string): PlaybackRect | null => {
144
- const r = rootEl?.querySelector(selector)?.getBoundingClientRect();
145
- if (!r || r.width <= 0) return null;
146
- const a = clientToDoc({ x: r.left, y: r.top });
147
- const b = clientToDoc({ x: r.right, y: r.bottom });
148
- 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) };
149
- };
150
- const agentPresence = createPlaybackSurface(() => playing, () => playbackId, () => ({
151
- defaultKind: 'layer',
152
- locate: (ref: PlaybackRef) => {
153
- if (ref.kind === 'panel') return elementRect(`[data-image-tab="${CSS.escape(ref.id)}"]`);
154
- if (ref.kind === 'control') return elementRect(`[data-control-id="${CSS.escape(ref.id)}"]`);
155
- return createImagePlaybackLocator(editor.controller)(ref) ?? (ref.kind === 'layer' || ref.kind === 'item' ? elementRect(`[data-layer-id="${CSS.escape(ref.id)}"]`) : null);
156
- },
157
- open: async (ref: PlaybackRef) => {
158
- if (ref.kind !== 'panel' || activeTab === ref.id) return;
159
- rootEl?.querySelector<HTMLElement>(`[data-image-tab="${CSS.escape(ref.id)}"]`)?.click();
160
- // Let the panel render before the cursor looks for its controls.
161
- await new Promise((resolve) => setTimeout(resolve, 60));
162
- },
163
- bounds: () => {
164
- const r = canvasEl?.getBoundingClientRect();
165
- if (!r || r.width <= 0) return null;
166
- const a = clientToDoc({ x: r.left, y: r.top });
167
- const b = clientToDoc({ x: r.right, y: r.bottom });
168
- 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) };
169
- },
170
- visible: () => {
171
- const r = rootEl?.getBoundingClientRect();
172
- return !!r && r.width > 0 && r.height > 0 && !(typeof document !== 'undefined' && document.visibilityState === 'hidden');
173
- },
174
- toViewport: (p: PlaybackPoint) => {
175
- const box = rootEl?.getBoundingClientRect();
176
- const q = docToRoot(p);
177
- return { x: (box?.left ?? 0) + q.x, y: (box?.top ?? 0) + q.y };
178
- },
179
- fromViewport: clientToDoc,
180
- reveal: () => onplaybackreveal?.()
181
- }));
107
+ /** Agent playback opens a panel by clicking its tab, unless it is already open. */
108
+ async function openPanel(ref: PlaybackRef) {
109
+ if (ref.kind !== 'panel' || activeTab === ref.id) return;
110
+ rootEl?.querySelector<HTMLElement>(`[data-image-tab="${CSS.escape(ref.id)}"]`)?.click();
111
+ // Let the panel render before the cursor looks for its controls.
112
+ await new Promise((resolve) => setTimeout(resolve, 60));
113
+ }
182
114
 
183
- // Selection tools (magic wand, marquees, lasso): options bar state and the pointer gesture.
115
+ // Tool options for the canvas, set by the options bars.
184
116
  let selectionOptions = $state<SelectionToolOptions>({ ...DEFAULT_SELECTION_TOOL_OPTIONS });
185
- const selectionGesture = $derived(new SelectionGesture(editor.controller, () => selectionOptions));
186
- // Paint tools (Move, Brush, Eraser): the pointer gesture and the brush cursor.
187
- const paintGesture = $derived(new PaintGesture(editor.controller));
188
- let brushCursor: ImagePoint | null = null;
189
-
190
- // Shape and type tools: their options and gestures; type is edited on the canvas through a hidden textarea.
191
117
  let shapeOptions = $state<ShapeToolOptions>({ ...DEFAULT_SHAPE_TOOL_OPTIONS });
192
118
  let typeOptions = $state<TypeToolOptions>({ ...DEFAULT_TYPE_TOOL_OPTIONS });
193
- const shapeGesture = $derived(new ShapeDrawGesture(editor.controller, () => shapeOptions));
194
- const typeTool = $derived(new TypeToolGesture(editor.controller, () => typeOptions));
195
119
  let typeSession = $state<TextEditSession | null>(null);
196
- let typeInputEl = $state<HTMLTextAreaElement | null>(null);
197
120
  let shapeTypeError = $state<string | null>(null);
198
- $effect(() => {
199
- const s = typeSession;
200
- if (!s) return;
201
- return untrack(() => s.subscribe(() => void renderCanvas()));
202
- });
203
- $effect(() => {
204
- // End the edit when the tool or tab changes.
205
- const keep = activeTab === 'annotate' && editor.state.activeTool === 'type';
206
- untrack(() => {
207
- if (!keep && typeTool.session) finishTyping();
208
- });
209
- });
121
+ let paintError = $state<string | null>(null);
122
+ let selectionError = $state<string | null>(null);
123
+ let activeCropRatio = $state<number | null>(null);
124
+ function handleCanvasError(source: ImageEditorCanvasErrorSource, message: string | null) {
125
+ if (source === 'paint') paintError = message;
126
+ else if (source === 'select') selectionError = message;
127
+ else shapeTypeError = message;
128
+ }
210
129
  function finishTyping() {
211
- typeTool.finish();
130
+ // The canvas finishes the edit when its bound session is cleared.
212
131
  typeSession = null;
213
- void renderCanvas();
214
- }
215
- function handleTypeKeyDown(e: KeyboardEvent) {
216
- const session = typeTool.session;
217
- if (!session || e.isComposing) return;
218
- if (e.key === 'Escape') {
219
- e.preventDefault();
220
- finishTyping();
221
- return;
222
- }
223
- if ((e.metaKey || e.ctrlKey) && ['z', 'y', 'c', 'x', 'v'].includes(e.key.toLowerCase())) return;
224
- if (session.handleKey(e)) {
225
- e.preventDefault();
226
- shapeTypeError = session.error;
227
- }
228
- }
229
- function handleTypeInput(e: Event) {
230
- // Composed (IME) and pasted text arrives here; plain keys were handled on keydown.
231
- const el = e.currentTarget as HTMLTextAreaElement;
232
- const session = typeTool.session;
233
- if (session && el.value && !(e as InputEvent).isComposing) {
234
- session.insert(el.value);
235
- el.value = '';
236
- }
237
- }
238
- function handleTypeComposition(e: CompositionEvent) {
239
- const session = typeTool.session;
240
- if (session && e.data) session.insert(e.data);
241
- (e.currentTarget as HTMLTextAreaElement).value = '';
242
132
  }
243
133
 
244
- // Free Transform (Ctrl/Cmd+T): the shared tool drives a session; its overlay is drawn by the renderer.
134
+ // Free Transform (Ctrl/Cmd+T): shared with the canvas, which drives and draws it.
245
135
  const transformTool = $derived(
246
136
  new FreeTransformTool(editor.controller, {
247
137
  schedule: (run) => (typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame(run) : run()),
248
138
  })
249
139
  );
250
140
  let transformTick = $state(0);
251
- let transformCursor = $state('default');
252
141
  const transformActive = $derived((void transformTick, transformTool.active));
253
142
  const transformError = $derived((void transformTick, transformTool.error));
254
143
  $effect(() => {
255
144
  const tool = transformTool;
256
145
  const off = tool.subscribe(() => {
257
146
  transformTick++;
258
- void renderCanvas();
259
147
  });
260
148
  return () => {
261
149
  off();
262
150
  tool.cancel();
263
151
  };
264
152
  });
265
- $effect(() => {
266
- if (typeof window === 'undefined') return;
267
- const tool = transformTool;
268
- const onKey = (e: KeyboardEvent) => {
269
- if (tool.key(e)) e.preventDefault();
270
- };
271
- window.addEventListener('keydown', onKey);
272
- return () => window.removeEventListener('keydown', onKey);
273
- });
274
-
275
- /** The document point of a pointer event and the hit tolerance (6 screen px). */
276
- function transformPoint(e: PointerEvent) {
277
- const rect = canvasEl!.getBoundingClientRect();
278
- const sx = rect.width > 0 ? canvasEl!.width / rect.width : 1;
279
- const sy = rect.height > 0 ? canvasEl!.height / rect.height : 1;
280
- const s = editor.controller.getState();
281
- const cropping = activeTab === 'crop';
282
- const projection = getViewportProjection(s, canvasEl!.width, canvasEl!.height, cropping);
283
- const pt = canvasToImagePoint({ x: (e.clientX - rect.left) * sx, y: (e.clientY - rect.top) * sy }, s, canvasEl!.width, canvasEl!.height, cropping);
284
- transformTool.setView(projection.scale);
285
- return { pt, tolerance: 6 / (projection.scale || 1) };
286
- }
287
- function handleTransformPointerDown(e: PointerEvent): boolean {
288
- if (!transformTool.active || !canvasEl) return false;
289
- if (e.button !== 0) return true;
290
- e.preventDefault();
291
- try {
292
- canvasEl.setPointerCapture?.(e.pointerId);
293
- } catch {
294
- // Synthetic events have no active pointer to capture.
295
- }
296
- const { pt, tolerance } = transformPoint(e);
297
- transformTool.down(pt, tolerance);
298
- return true;
299
- }
300
- function handleTransformPointerMove(e: PointerEvent): boolean {
301
- if (!transformTool.active || !canvasEl) return false;
302
- const { pt, tolerance } = transformPoint(e);
303
- if (e.buttons & 1) transformTool.move(pt, e);
304
- else transformCursor = transformTool.cursor(transformTool.hit(pt, tolerance));
305
- return true;
306
- }
307
- function handleTransformPointerUp(): boolean {
308
- if (!transformTool.active) return false;
309
- transformTool.up();
310
- return true;
311
- }
312
- let paintError = $state<string | null>(null);
313
- let selectionDraft = $state.raw<ImagePoint[] | null>(null);
314
- let selectionError = $state<string | null>(null);
315
- let activeCropRatio = $state<number | null>(null);
316
153
 
317
154
  // AI state
318
155
  let inpaintPrompt = $state('');
@@ -398,24 +235,6 @@
398
235
  let fontSize = $state(24);
399
236
  let textInput = $state('');
400
237
 
401
- // Live draft and drag references
402
- let draftAnnotation = $state<Annotation | null>(null);
403
- let draftCrop = $state<CropBox | null>(null);
404
- let cropDragInfo: {
405
- handle: CropHandle | 'new';
406
- startCanvasPt: ImagePoint;
407
- startImgPt: ImagePoint;
408
- startCrop: CropBox;
409
- } | null = null;
410
- let annotationDragInfo: {
411
- annId: string;
412
- startImgPt: ImagePoint;
413
- initialAnn: Annotation;
414
- } | null = null;
415
- let isDrawing = false;
416
- let currentPath: ImagePoint[] = [];
417
- let dragStartPoint: ImagePoint | null = null;
418
-
419
238
  // Load the image when `src` changes. Only `src` is tracked: AI edits replace
420
239
  // the editor's source with a data URL and must not be reverted to `src`.
421
240
  let loadedSrc: string | undefined;
@@ -428,512 +247,12 @@
428
247
  });
429
248
  });
430
249
 
431
- async function renderCanvas(overrideAnnotation?: Annotation | null, overrideCrop?: CropBox | null) {
432
- if (!canvasEl || !containerEl) return;
433
- const vpWidth = containerEl.clientWidth || 800;
434
- const vpHeight = containerEl.clientHeight || 500;
435
-
436
- await editor.controller.render(canvasEl, {
437
- viewportWidth: vpWidth,
438
- viewportHeight: vpHeight,
439
- fit,
440
- showCropOverlay: activeTab === 'crop',
441
- showSelectionOverlay: true,
442
- interactive: true,
443
- draftAnnotation: overrideAnnotation !== undefined ? overrideAnnotation : draftAnnotation,
444
- draftCrop: overrideCrop !== undefined ? overrideCrop : draftCrop,
445
- draftSelectionPath: selectionDraft,
446
- brushCursor:
447
- activeTab === 'paint' && brushCursor && editor.controller.getState().activeTool !== 'move' && !transformTool.active
448
- ? { ...brushCursor, diameter: editor.controller.getState().paint.brush.size }
449
- : null,
450
- freeTransform: transformTool.overlay(),
451
- textEdit: typeTool.session?.alive ? typeTool.session.overlay() : null,
452
- });
453
- }
454
-
455
- // Paint tools: pointer events (coalesced samples, pointer capture) into strokes and moves.
456
- function paintSamples(e: PointerEvent) {
457
- const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
458
- const list = events.length ? events : [e];
459
- const rect = canvasEl!.getBoundingClientRect();
460
- const sx = rect.width > 0 ? canvasEl!.width / rect.width : 1;
461
- const sy = rect.height > 0 ? canvasEl!.height / rect.height : 1;
462
- const s = editor.controller.getState();
463
- return list.map((ev) => ({
464
- ...canvasToImagePoint({ x: (ev.clientX - rect.left) * sx, y: (ev.clientY - rect.top) * sy }, s, canvasEl!.width, canvasEl!.height, false),
465
- pressure: pointerPressure(ev),
466
- }));
467
- }
468
- function handlePaintPointerDown(e: PointerEvent) {
469
- if (handleTransformPointerDown(e)) return;
470
- if (activeTab !== 'paint' || e.button !== 0 || !canvasEl) return;
471
- e.preventDefault();
472
- try {
473
- canvasEl.setPointerCapture?.(e.pointerId);
474
- } catch {
475
- // Synthetic events have no active pointer to capture.
476
- }
477
- paintError = paintGesture.down({ ...paintSamples(e)[0], altKey: e.altKey });
478
- }
479
- function handlePaintPointerMove(e: PointerEvent) {
480
- if (handleTransformPointerMove(e)) return;
481
- if (activeTab !== 'paint' || !canvasEl) return;
482
- const samples = paintSamples(e);
483
- brushCursor = samples[samples.length - 1] ?? null;
484
- if (paintGesture.active) paintGesture.moveTo(samples);
485
- else void renderCanvas();
486
- }
487
- function handlePaintPointerUp() {
488
- if (handleTransformPointerUp()) return;
489
- if (activeTab !== 'paint' || !paintGesture.active) return;
490
- paintGesture.up();
491
- if (paintGesture.error) paintError = paintGesture.error;
492
- }
493
-
494
- $effect(() => {
495
- if (activeTab !== 'paint' || typeof window === 'undefined') return;
496
- const c = editor.controller;
497
- const onKey = (e: KeyboardEvent) => {
498
- if (handlePaintShortcut(c, e)) e.preventDefault();
499
- };
500
- window.addEventListener('keydown', onKey);
501
- return () => window.removeEventListener('keydown', onKey);
502
- });
503
-
504
- $effect(() => {
505
- // Re-render when editor state or activeTab changes
506
- void editor.state;
507
- void activeTab;
508
-
509
- const raf = typeof requestAnimationFrame !== 'undefined'
510
- ? requestAnimationFrame
511
- : (cb: () => void) => setTimeout(cb, 0) as unknown as number;
512
- const caf = typeof cancelAnimationFrame !== 'undefined'
513
- ? cancelAnimationFrame
514
- : (id: number) => clearTimeout(id);
515
-
516
- const animId = raf(() => {
517
- renderCanvas();
518
- });
519
- return () => caf(animId);
520
- });
521
-
522
250
  $effect(() => {
523
251
  if (activeTab === 'crop' && editor.state.activeTool !== 'crop') {
524
252
  editor.setTool('crop');
525
253
  }
526
254
  });
527
255
 
528
- $effect(() => {
529
- if (!containerEl) return;
530
- if (typeof ResizeObserver !== 'undefined') {
531
- const observer = new ResizeObserver(() => {
532
- renderCanvas();
533
- });
534
- observer.observe(containerEl);
535
- return () => observer.disconnect();
536
- } else {
537
- const handleResize = () => renderCanvas();
538
- window.addEventListener('resize', handleResize);
539
- return () => window.removeEventListener('resize', handleResize);
540
- }
541
- });
542
-
543
- function getCanvasEventPoint(e: MouseEvent): ImagePoint {
544
- if (!canvasEl) return { x: 0, y: 0 };
545
- const rect = canvasEl.getBoundingClientRect();
546
- const scaleX = rect.width > 0 ? canvasEl.width / rect.width : 1;
547
- const scaleY = rect.height > 0 ? canvasEl.height / rect.height : 1;
548
- return {
549
- x: (e.clientX - rect.left) * scaleX,
550
- y: (e.clientY - rect.top) * scaleY,
551
- };
552
- }
553
-
554
- function handleMouseDown(e: MouseEvent) {
555
- if (!canvasEl || transformTool.active) return;
556
- const canvasPt = getCanvasEventPoint(e);
557
-
558
- if (activeTab === 'select') {
559
- const gesture = selectionGesture;
560
- const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
561
- void gesture.begin(imgPt, e).then(() => (selectionError = gesture.error));
562
- return;
563
- }
564
-
565
- if (activeTab === 'crop') {
566
- const activeCrop = draftCrop || editor.state.crop || {
567
- x: 0,
568
- y: 0,
569
- width: editor.state.imageDimensions.width,
570
- height: editor.state.imageDimensions.height,
571
- };
572
- const handle = hitTestCrop(canvasPt, editor.state, canvasEl.width, canvasEl.height, 14, activeCrop);
573
- const startImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, true);
574
-
575
- if (handle) {
576
- cropDragInfo = {
577
- handle,
578
- startCanvasPt: canvasPt,
579
- startImgPt,
580
- startCrop: activeCrop,
581
- };
582
- } else {
583
- // Clicked outside crop box: start new crop selection
584
- cropDragInfo = {
585
- handle: 'new',
586
- startCanvasPt: canvasPt,
587
- startImgPt,
588
- startCrop: { x: startImgPt.x, y: startImgPt.y, width: 0, height: 0 },
589
- };
590
- }
591
- return;
592
- }
593
-
594
- if (activeTab === 'annotate' && (editor.state.activeTool === 'shape' || editor.state.activeTool === 'type')) {
595
- const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
596
- shapeTypeError = null;
597
- if (editor.state.activeTool === 'shape') shapeGesture.down(imgPt);
598
- else {
599
- typeTool.down(imgPt, { shift: e.shiftKey });
600
- typeSession = typeTool.session;
601
- void renderCanvas();
602
- }
603
- return;
604
- }
605
-
606
- if (activeTab === 'annotate') {
607
- const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
608
- dragStartPoint = imgPt;
609
-
610
- if (editor.state.activeTool === 'select') {
611
- for (let i = editor.state.annotations.length - 1; i >= 0; i--) {
612
- const ann = editor.state.annotations[i];
613
- if (hitTestAnnotation(imgPt, ann)) {
614
- editor.selectAnnotation(ann.id);
615
- annotationDragInfo = {
616
- annId: ann.id,
617
- startImgPt: imgPt,
618
- initialAnn: { ...ann },
619
- };
620
- return;
621
- }
622
- }
623
- editor.selectAnnotation(null);
624
- return;
625
- }
626
-
627
- if (editor.state.activeTool === 'pen') {
628
- isDrawing = true;
629
- currentPath = [imgPt];
630
- const draft: Annotation = {
631
- id: 'draft',
632
- type: 'pen',
633
- x: 0,
634
- y: 0,
635
- points: [imgPt],
636
- strokeColor: activeColor,
637
- strokeWidth,
638
- };
639
- draftAnnotation = draft;
640
- renderCanvas(draft);
641
- }
642
- }
643
- }
644
-
645
- function handleMouseMove(e: MouseEvent) {
646
- if (!canvasEl || transformTool.active) return;
647
- const canvasPt = getCanvasEventPoint(e);
648
-
649
- if (activeTab === 'select') {
650
- const gesture = selectionGesture;
651
- if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false))) {
652
- selectionDraft = gesture.draftPath;
653
- renderCanvas();
654
- }
655
- return;
656
- }
657
-
658
- if (activeTab === 'crop') {
659
- if (cropDragInfo) {
660
- const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, true);
661
- let newCrop: CropBox;
662
-
663
- if (cropDragInfo.handle === 'new') {
664
- const minX = Math.max(0, Math.min(cropDragInfo.startImgPt.x, currentImgPt.x));
665
- const minY = Math.max(0, Math.min(cropDragInfo.startImgPt.y, currentImgPt.y));
666
- const maxX = Math.min(editor.state.imageDimensions.width, Math.max(cropDragInfo.startImgPt.x, currentImgPt.x));
667
- const maxY = Math.min(editor.state.imageDimensions.height, Math.max(cropDragInfo.startImgPt.y, currentImgPt.y));
668
- newCrop = {
669
- x: Math.round(minX),
670
- y: Math.round(minY),
671
- width: Math.round(Math.max(20, maxX - minX)),
672
- height: Math.round(Math.max(20, maxY - minY)),
673
- };
674
- } else {
675
- newCrop = calculateCropDrag(
676
- cropDragInfo.startCrop,
677
- cropDragInfo.handle,
678
- cropDragInfo.startImgPt,
679
- currentImgPt,
680
- editor.state.imageDimensions.width,
681
- editor.state.imageDimensions.height,
682
- activeCropRatio,
683
- editor.state.transform
684
- );
685
- }
686
-
687
- draftCrop = newCrop;
688
- renderCanvas(null, newCrop);
689
- } else {
690
- const activeCrop = draftCrop || editor.state.crop || {
691
- x: 0,
692
- y: 0,
693
- width: editor.state.imageDimensions.width,
694
- height: editor.state.imageDimensions.height,
695
- };
696
- const handle = hitTestCrop(canvasPt, editor.state, canvasEl.width, canvasEl.height, 14, activeCrop);
697
- canvasEl.style.cursor = getCropCursor(handle, editor.state.transform);
698
- }
699
- return;
700
- }
701
-
702
- if (activeTab === 'annotate' && editor.state.activeTool === 'shape') {
703
- if (shapeGesture.active) {
704
- shapeGesture.move(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false), { shift: e.shiftKey, alt: e.altKey });
705
- if (shapeGesture.error) shapeTypeError = shapeGesture.error;
706
- }
707
- return;
708
- }
709
-
710
- if (activeTab === 'annotate') {
711
- if (annotationDragInfo) {
712
- const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
713
- const dx = currentImgPt.x - annotationDragInfo.startImgPt.x;
714
- const dy = currentImgPt.y - annotationDragInfo.startImgPt.y;
715
- editor.updateAnnotation(annotationDragInfo.annId, {
716
- x: Math.round(annotationDragInfo.initialAnn.x + dx),
717
- y: Math.round(annotationDragInfo.initialAnn.y + dy),
718
- });
719
- return;
720
- }
721
-
722
- if (!dragStartPoint) {
723
- if (editor.state.activeTool === 'select') {
724
- const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
725
- let hovered = false;
726
- for (let i = editor.state.annotations.length - 1; i >= 0; i--) {
727
- if (hitTestAnnotation(imgPt, editor.state.annotations[i])) {
728
- hovered = true;
729
- break;
730
- }
731
- }
732
- canvasEl.style.cursor = hovered ? 'move' : 'default';
733
- } else {
734
- canvasEl.style.cursor = 'crosshair';
735
- }
736
- return;
737
- }
738
-
739
- const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
740
-
741
- if (editor.state.activeTool === 'pen' && isDrawing) {
742
- currentPath.push(currentImgPt);
743
- const draft: Annotation = {
744
- id: 'draft',
745
- type: 'pen',
746
- x: 0,
747
- y: 0,
748
- points: [...currentPath],
749
- strokeColor: activeColor,
750
- strokeWidth,
751
- };
752
- draftAnnotation = draft;
753
- renderCanvas(draft);
754
- } else if (editor.state.activeTool === 'rect') {
755
- const w = currentImgPt.x - dragStartPoint.x;
756
- const h = currentImgPt.y - dragStartPoint.y;
757
- const draft: Annotation = {
758
- id: 'draft',
759
- type: 'rect',
760
- x: Math.min(dragStartPoint.x, currentImgPt.x),
761
- y: Math.min(dragStartPoint.y, currentImgPt.y),
762
- width: Math.abs(w),
763
- height: Math.abs(h),
764
- strokeColor: activeColor,
765
- strokeWidth,
766
- };
767
- draftAnnotation = draft;
768
- renderCanvas(draft);
769
- } else if (editor.state.activeTool === 'circle') {
770
- const rx = Math.abs(currentImgPt.x - dragStartPoint.x) / 2;
771
- const ry = Math.abs(currentImgPt.y - dragStartPoint.y) / 2;
772
- const draft: Annotation = {
773
- id: 'draft',
774
- type: 'circle',
775
- x: Math.min(dragStartPoint.x, currentImgPt.x) + rx,
776
- y: Math.min(dragStartPoint.y, currentImgPt.y) + ry,
777
- radiusX: rx,
778
- radiusY: ry,
779
- strokeColor: activeColor,
780
- strokeWidth,
781
- };
782
- draftAnnotation = draft;
783
- renderCanvas(draft);
784
- } else if (editor.state.activeTool === 'arrow') {
785
- const draft: Annotation = {
786
- id: 'draft',
787
- type: 'arrow',
788
- x: dragStartPoint.x,
789
- y: dragStartPoint.y,
790
- endX: currentImgPt.x,
791
- endY: currentImgPt.y,
792
- strokeColor: activeColor,
793
- strokeWidth,
794
- };
795
- draftAnnotation = draft;
796
- renderCanvas(draft);
797
- } else if (editor.state.activeTool === 'line') {
798
- const draft: Annotation = {
799
- id: 'draft',
800
- type: 'line',
801
- x: dragStartPoint.x,
802
- y: dragStartPoint.y,
803
- endX: currentImgPt.x,
804
- endY: currentImgPt.y,
805
- strokeColor: activeColor,
806
- strokeWidth,
807
- };
808
- draftAnnotation = draft;
809
- renderCanvas(draft);
810
- }
811
- }
812
- }
813
-
814
- function handleMouseUp(e: MouseEvent) {
815
- if (!canvasEl || transformTool.active) return;
816
- const canvasPt = getCanvasEventPoint(e);
817
-
818
- if (activeTab === 'select') {
819
- const gesture = selectionGesture;
820
- if (gesture.active) {
821
- gesture.end(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false), e);
822
- selectionDraft = null;
823
- renderCanvas();
824
- }
825
- return;
826
- }
827
-
828
- if (activeTab === 'crop') {
829
- if (cropDragInfo) {
830
- if (draftCrop && draftCrop.width >= 20 && draftCrop.height >= 20) {
831
- editor.setCrop(draftCrop, true);
832
- }
833
- cropDragInfo = null;
834
- draftCrop = null;
835
- renderCanvas();
836
- }
837
- return;
838
- }
839
-
840
- if (activeTab === 'annotate' && editor.state.activeTool === 'shape') {
841
- shapeGesture.up();
842
- return;
843
- }
844
- if (activeTab === 'annotate' && editor.state.activeTool === 'type') {
845
- typeTool.up(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false));
846
- typeSession = typeTool.session;
847
- if (typeTool.error) shapeTypeError = typeTool.error;
848
- void renderCanvas();
849
- // Keystrokes go to the hidden textarea while text is being edited.
850
- if (typeTool.session) setTimeout(() => typeInputEl?.focus(), 0);
851
- return;
852
- }
853
-
854
- if (activeTab === 'annotate') {
855
- if (annotationDragInfo) {
856
- annotationDragInfo = null;
857
- return;
858
- }
859
-
860
- if (dragStartPoint) {
861
- const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
862
-
863
- if (editor.state.activeTool === 'pen' && isDrawing) {
864
- if (currentPath.length > 1) {
865
- editor.addAnnotation({
866
- type: 'pen',
867
- x: 0,
868
- y: 0,
869
- points: [...currentPath],
870
- strokeColor: activeColor,
871
- strokeWidth,
872
- });
873
- }
874
- isDrawing = false;
875
- currentPath = [];
876
- } else if (editor.state.activeTool === 'rect') {
877
- const w = Math.abs(currentImgPt.x - dragStartPoint.x);
878
- const h = Math.abs(currentImgPt.y - dragStartPoint.y);
879
- if (w > 4 && h > 4) {
880
- editor.addAnnotation({
881
- type: 'rect',
882
- x: Math.min(dragStartPoint.x, currentImgPt.x),
883
- y: Math.min(dragStartPoint.y, currentImgPt.y),
884
- width: w,
885
- height: h,
886
- strokeColor: activeColor,
887
- strokeWidth,
888
- });
889
- }
890
- } else if (editor.state.activeTool === 'circle') {
891
- const rx = Math.abs(currentImgPt.x - dragStartPoint.x) / 2;
892
- const ry = Math.abs(currentImgPt.y - dragStartPoint.y) / 2;
893
- if (rx > 4 && ry > 4) {
894
- editor.addAnnotation({
895
- type: 'circle',
896
- x: Math.min(dragStartPoint.x, currentImgPt.x) + rx,
897
- y: Math.min(dragStartPoint.y, currentImgPt.y) + ry,
898
- radiusX: rx,
899
- radiusY: ry,
900
- strokeColor: activeColor,
901
- strokeWidth,
902
- });
903
- }
904
- } else if (editor.state.activeTool === 'arrow') {
905
- if (Math.hypot(currentImgPt.x - dragStartPoint.x, currentImgPt.y - dragStartPoint.y) > 4) {
906
- editor.addAnnotation({
907
- type: 'arrow',
908
- x: dragStartPoint.x,
909
- y: dragStartPoint.y,
910
- endX: currentImgPt.x,
911
- endY: currentImgPt.y,
912
- strokeColor: activeColor,
913
- strokeWidth,
914
- });
915
- }
916
- } else if (editor.state.activeTool === 'line') {
917
- if (Math.hypot(currentImgPt.x - dragStartPoint.x, currentImgPt.y - dragStartPoint.y) > 4) {
918
- editor.addAnnotation({
919
- type: 'line',
920
- x: dragStartPoint.x,
921
- y: dragStartPoint.y,
922
- endX: currentImgPt.x,
923
- endY: currentImgPt.y,
924
- strokeColor: activeColor,
925
- strokeWidth,
926
- });
927
- }
928
- }
929
-
930
- draftAnnotation = null;
931
- dragStartPoint = null;
932
- renderCanvas();
933
- }
934
- }
935
- }
936
-
937
256
  function handleAddText() {
938
257
  if (!textInput.trim()) return;
939
258
  editor.addAnnotation({
@@ -965,9 +284,6 @@
965
284
  class="relative flex flex-col bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-2xl text-slate-100 {className}"
966
285
  {style}
967
286
  >
968
- {#if playing}
969
- <PresenceLayer presence={agentPresence} viewport={docToRoot} />
970
- {/if}
971
287
  <!-- Top Action Header -->
972
288
  <div class="flex flex-wrap items-center justify-between px-4 py-3 bg-slate-950/80 border-b border-slate-800 gap-3">
973
289
  <!-- Navigation Tabs -->
@@ -1164,39 +480,26 @@
1164
480
 
1165
481
  <div class="flex flex-col md:flex-row flex-1 min-h-[520px] md:h-[560px]">
1166
482
  <!-- Canvas Area -->
1167
- <div
1168
- bind:this={containerEl}
1169
- class="relative flex-1 h-[360px] md:h-full min-h-[320px] bg-slate-950 overflow-hidden select-none"
1170
- >
1171
- <canvas
1172
- bind:this={canvasEl}
1173
- onmousedown={handleMouseDown}
1174
- onmousemove={handleMouseMove}
1175
- onmouseup={handleMouseUp}
1176
- onmouseleave={handleMouseUp}
1177
- onpointerdown={handlePaintPointerDown}
1178
- onpointermove={handlePaintPointerMove}
1179
- onpointerup={handlePaintPointerUp}
1180
- onpointercancel={() => paintGesture.cancel()}
1181
- data-testid="image-editor-canvas"
1182
- style={transformActive ? `cursor: ${transformCursor}` : undefined}
1183
- class="absolute inset-0 w-full h-full block {transformActive ? 'touch-none' : activeTab === 'paint' ? 'touch-none cursor-crosshair' : activeTab === 'annotate' && editor.state.activeTool === 'type' ? 'cursor-text' : activeTab === 'annotate' && editor.state.activeTool === 'shape' ? 'cursor-crosshair' : ''}"
1184
- ></canvas>
1185
- {#if typeSession}
1186
- <textarea
1187
- bind:this={typeInputEl}
1188
- aria-label="Edit text"
1189
- data-testid="type-input"
1190
- autocapitalize="off"
1191
- spellcheck={false}
1192
- onkeydown={handleTypeKeyDown}
1193
- oninput={handleTypeInput}
1194
- oncompositionend={handleTypeComposition}
1195
- class="absolute left-0 top-0 h-px w-px opacity-0"
1196
- ></textarea>
1197
- {/if}
1198
-
1199
- </div>
483
+ <ImageEditorCanvas
484
+ {editor}
485
+ mode={canvasMode}
486
+ {fit}
487
+ {selectionOptions}
488
+ {shapeOptions}
489
+ {typeOptions}
490
+ annotationColor={activeColor}
491
+ annotationWidth={strokeWidth}
492
+ cropRatio={activeCropRatio}
493
+ onerror={handleCanvasError}
494
+ bind:typeSession
495
+ {transformTool}
496
+ {playback}
497
+ {playbackId}
498
+ {onplaybackreveal}
499
+ chromeRoot={() => rootEl}
500
+ open={openPanel}
501
+ class="flex-1 h-[360px] md:h-full min-h-[320px] bg-slate-950"
502
+ />
1200
503
 
1201
504
  <!-- Tab Controls Sidebar -->
1202
505
  <div class="w-full md:w-80 bg-slate-900/90 border-t md:border-t-0 md:border-l border-slate-800 p-4 overflow-y-auto h-auto md:h-full md:max-h-[560px]">