@pixerate/editor-svelte 0.12.0 → 0.14.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.
@@ -0,0 +1,1119 @@
1
+ <script lang="ts">
2
+ import type {
3
+ Annotation,
4
+ CropBox,
5
+ CropHandle,
6
+ ExportOptions,
7
+ ImageEditorTool,
8
+ ImagePoint,
9
+ } from '@pixerate/editor';
10
+ import {
11
+ canvasToImagePoint,
12
+ hitTestCrop,
13
+ getCropCursor,
14
+ calculateCropDrag,
15
+ hitTestAnnotation,
16
+ } from '@pixerate/editor';
17
+ import { createReactiveImageEditor, type ReactiveImageEditor } from './imageEditorState.svelte';
18
+
19
+ interface Props {
20
+ editor?: ReactiveImageEditor;
21
+ src?: string;
22
+ class?: string;
23
+ style?: string;
24
+ onSave?: (dataUrl: string) => void;
25
+ exportOptions?: ExportOptions;
26
+ }
27
+
28
+ let {
29
+ editor: propEditor,
30
+ src = '',
31
+ class: className = '',
32
+ style = '',
33
+ onSave,
34
+ exportOptions
35
+ }: Props = $props();
36
+
37
+ const internalEditor = createReactiveImageEditor();
38
+ const editor = $derived(propEditor || internalEditor);
39
+
40
+ let canvasEl = $state<HTMLCanvasElement | null>(null);
41
+ let containerEl = $state<HTMLDivElement | null>(null);
42
+ let activeTab = $state<'crop' | 'adjust' | 'annotate' | 'depth'>('crop');
43
+ let activeCropRatio = $state<number | null>(null);
44
+
45
+ // Annotation drafting state
46
+ let activeColor = $state('#ef4444');
47
+ let strokeWidth = $state(3);
48
+ let fontSize = $state(24);
49
+ let textInput = $state('');
50
+
51
+ // Live draft and drag references
52
+ let draftAnnotation = $state<Annotation | null>(null);
53
+ let draftCrop = $state<CropBox | null>(null);
54
+ let cropDragInfo: {
55
+ handle: CropHandle | 'new';
56
+ startCanvasPt: ImagePoint;
57
+ startImgPt: ImagePoint;
58
+ startCrop: CropBox;
59
+ } | null = null;
60
+ let annotationDragInfo: {
61
+ annId: string;
62
+ startImgPt: ImagePoint;
63
+ initialAnn: Annotation;
64
+ } | null = null;
65
+ let isDrawing = false;
66
+ let currentPath: ImagePoint[] = [];
67
+ let dragStartPoint: ImagePoint | null = null;
68
+
69
+ // Update image when src changes
70
+ $effect(() => {
71
+ if (src && src !== editor.state.sourceUrl) {
72
+ editor.loadImage(src);
73
+ }
74
+ });
75
+
76
+ async function renderCanvas(overrideAnnotation?: Annotation | null, overrideCrop?: CropBox | null) {
77
+ if (!canvasEl || !containerEl) return;
78
+ const vpWidth = containerEl.clientWidth || 800;
79
+ const vpHeight = containerEl.clientHeight || 500;
80
+
81
+ await editor.controller.render(canvasEl, {
82
+ viewportWidth: vpWidth,
83
+ viewportHeight: vpHeight,
84
+ showCropOverlay: activeTab === 'crop',
85
+ showSelectionOverlay: true,
86
+ interactive: true,
87
+ draftAnnotation: overrideAnnotation !== undefined ? overrideAnnotation : draftAnnotation,
88
+ draftCrop: overrideCrop !== undefined ? overrideCrop : draftCrop,
89
+ });
90
+ }
91
+
92
+ $effect(() => {
93
+ // Re-render when editor state or activeTab changes
94
+ void editor.state;
95
+ void activeTab;
96
+
97
+ const raf = typeof requestAnimationFrame !== 'undefined'
98
+ ? requestAnimationFrame
99
+ : (cb: () => void) => setTimeout(cb, 0) as unknown as number;
100
+ const caf = typeof cancelAnimationFrame !== 'undefined'
101
+ ? cancelAnimationFrame
102
+ : (id: number) => clearTimeout(id);
103
+
104
+ const animId = raf(() => {
105
+ renderCanvas();
106
+ });
107
+ return () => caf(animId);
108
+ });
109
+
110
+ $effect(() => {
111
+ if (activeTab === 'crop' && editor.state.activeTool !== 'crop') {
112
+ editor.setTool('crop');
113
+ }
114
+ });
115
+
116
+ $effect(() => {
117
+ if (!containerEl) return;
118
+ if (typeof ResizeObserver !== 'undefined') {
119
+ const observer = new ResizeObserver(() => {
120
+ renderCanvas();
121
+ });
122
+ observer.observe(containerEl);
123
+ return () => observer.disconnect();
124
+ } else {
125
+ const handleResize = () => renderCanvas();
126
+ window.addEventListener('resize', handleResize);
127
+ return () => window.removeEventListener('resize', handleResize);
128
+ }
129
+ });
130
+
131
+ function getCanvasEventPoint(e: MouseEvent): ImagePoint {
132
+ if (!canvasEl) return { x: 0, y: 0 };
133
+ const rect = canvasEl.getBoundingClientRect();
134
+ const scaleX = rect.width > 0 ? canvasEl.width / rect.width : 1;
135
+ const scaleY = rect.height > 0 ? canvasEl.height / rect.height : 1;
136
+ return {
137
+ x: (e.clientX - rect.left) * scaleX,
138
+ y: (e.clientY - rect.top) * scaleY,
139
+ };
140
+ }
141
+
142
+ function handleMouseDown(e: MouseEvent) {
143
+ if (!canvasEl) return;
144
+ const canvasPt = getCanvasEventPoint(e);
145
+
146
+ if (activeTab === 'crop') {
147
+ const activeCrop = draftCrop || editor.state.crop || {
148
+ x: 0,
149
+ y: 0,
150
+ width: editor.state.imageDimensions.width,
151
+ height: editor.state.imageDimensions.height,
152
+ };
153
+ const handle = hitTestCrop(canvasPt, editor.state, canvasEl.width, canvasEl.height, 14, activeCrop);
154
+ const startImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, true);
155
+
156
+ if (handle) {
157
+ cropDragInfo = {
158
+ handle,
159
+ startCanvasPt: canvasPt,
160
+ startImgPt,
161
+ startCrop: activeCrop,
162
+ };
163
+ } else {
164
+ // Clicked outside crop box: start new crop selection
165
+ cropDragInfo = {
166
+ handle: 'new',
167
+ startCanvasPt: canvasPt,
168
+ startImgPt,
169
+ startCrop: { x: startImgPt.x, y: startImgPt.y, width: 0, height: 0 },
170
+ };
171
+ }
172
+ return;
173
+ }
174
+
175
+ if (activeTab === 'annotate') {
176
+ const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
177
+ dragStartPoint = imgPt;
178
+
179
+ if (editor.state.activeTool === 'select') {
180
+ for (let i = editor.state.annotations.length - 1; i >= 0; i--) {
181
+ const ann = editor.state.annotations[i];
182
+ if (hitTestAnnotation(imgPt, ann)) {
183
+ editor.selectAnnotation(ann.id);
184
+ annotationDragInfo = {
185
+ annId: ann.id,
186
+ startImgPt: imgPt,
187
+ initialAnn: { ...ann },
188
+ };
189
+ return;
190
+ }
191
+ }
192
+ editor.selectAnnotation(null);
193
+ return;
194
+ }
195
+
196
+ if (editor.state.activeTool === 'pen') {
197
+ isDrawing = true;
198
+ currentPath = [imgPt];
199
+ const draft: Annotation = {
200
+ id: 'draft',
201
+ type: 'pen',
202
+ x: 0,
203
+ y: 0,
204
+ points: [imgPt],
205
+ strokeColor: activeColor,
206
+ strokeWidth,
207
+ };
208
+ draftAnnotation = draft;
209
+ renderCanvas(draft);
210
+ }
211
+ }
212
+ }
213
+
214
+ function handleMouseMove(e: MouseEvent) {
215
+ if (!canvasEl) return;
216
+ const canvasPt = getCanvasEventPoint(e);
217
+
218
+ if (activeTab === 'crop') {
219
+ if (cropDragInfo) {
220
+ const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, true);
221
+ let newCrop: CropBox;
222
+
223
+ if (cropDragInfo.handle === 'new') {
224
+ const minX = Math.max(0, Math.min(cropDragInfo.startImgPt.x, currentImgPt.x));
225
+ const minY = Math.max(0, Math.min(cropDragInfo.startImgPt.y, currentImgPt.y));
226
+ const maxX = Math.min(editor.state.imageDimensions.width, Math.max(cropDragInfo.startImgPt.x, currentImgPt.x));
227
+ const maxY = Math.min(editor.state.imageDimensions.height, Math.max(cropDragInfo.startImgPt.y, currentImgPt.y));
228
+ newCrop = {
229
+ x: Math.round(minX),
230
+ y: Math.round(minY),
231
+ width: Math.round(Math.max(20, maxX - minX)),
232
+ height: Math.round(Math.max(20, maxY - minY)),
233
+ };
234
+ } else {
235
+ newCrop = calculateCropDrag(
236
+ cropDragInfo.startCrop,
237
+ cropDragInfo.handle,
238
+ cropDragInfo.startImgPt,
239
+ currentImgPt,
240
+ editor.state.imageDimensions.width,
241
+ editor.state.imageDimensions.height,
242
+ activeCropRatio
243
+ );
244
+ }
245
+
246
+ draftCrop = newCrop;
247
+ renderCanvas(null, newCrop);
248
+ } else {
249
+ const activeCrop = draftCrop || editor.state.crop || {
250
+ x: 0,
251
+ y: 0,
252
+ width: editor.state.imageDimensions.width,
253
+ height: editor.state.imageDimensions.height,
254
+ };
255
+ const handle = hitTestCrop(canvasPt, editor.state, canvasEl.width, canvasEl.height, 14, activeCrop);
256
+ canvasEl.style.cursor = getCropCursor(handle);
257
+ }
258
+ return;
259
+ }
260
+
261
+ if (activeTab === 'annotate') {
262
+ if (annotationDragInfo) {
263
+ const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
264
+ const dx = currentImgPt.x - annotationDragInfo.startImgPt.x;
265
+ const dy = currentImgPt.y - annotationDragInfo.startImgPt.y;
266
+ editor.updateAnnotation(annotationDragInfo.annId, {
267
+ x: Math.round(annotationDragInfo.initialAnn.x + dx),
268
+ y: Math.round(annotationDragInfo.initialAnn.y + dy),
269
+ });
270
+ return;
271
+ }
272
+
273
+ if (!dragStartPoint) {
274
+ if (editor.state.activeTool === 'select') {
275
+ const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
276
+ let hovered = false;
277
+ for (let i = editor.state.annotations.length - 1; i >= 0; i--) {
278
+ if (hitTestAnnotation(imgPt, editor.state.annotations[i])) {
279
+ hovered = true;
280
+ break;
281
+ }
282
+ }
283
+ canvasEl.style.cursor = hovered ? 'move' : 'default';
284
+ } else {
285
+ canvasEl.style.cursor = 'crosshair';
286
+ }
287
+ return;
288
+ }
289
+
290
+ const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
291
+
292
+ if (editor.state.activeTool === 'pen' && isDrawing) {
293
+ currentPath.push(currentImgPt);
294
+ const draft: Annotation = {
295
+ id: 'draft',
296
+ type: 'pen',
297
+ x: 0,
298
+ y: 0,
299
+ points: [...currentPath],
300
+ strokeColor: activeColor,
301
+ strokeWidth,
302
+ };
303
+ draftAnnotation = draft;
304
+ renderCanvas(draft);
305
+ } else if (editor.state.activeTool === 'rect') {
306
+ const w = currentImgPt.x - dragStartPoint.x;
307
+ const h = currentImgPt.y - dragStartPoint.y;
308
+ const draft: Annotation = {
309
+ id: 'draft',
310
+ type: 'rect',
311
+ x: Math.min(dragStartPoint.x, currentImgPt.x),
312
+ y: Math.min(dragStartPoint.y, currentImgPt.y),
313
+ width: Math.abs(w),
314
+ height: Math.abs(h),
315
+ strokeColor: activeColor,
316
+ strokeWidth,
317
+ };
318
+ draftAnnotation = draft;
319
+ renderCanvas(draft);
320
+ } else if (editor.state.activeTool === 'circle') {
321
+ const rx = Math.abs(currentImgPt.x - dragStartPoint.x) / 2;
322
+ const ry = Math.abs(currentImgPt.y - dragStartPoint.y) / 2;
323
+ const draft: Annotation = {
324
+ id: 'draft',
325
+ type: 'circle',
326
+ x: Math.min(dragStartPoint.x, currentImgPt.x) + rx,
327
+ y: Math.min(dragStartPoint.y, currentImgPt.y) + ry,
328
+ radiusX: rx,
329
+ radiusY: ry,
330
+ strokeColor: activeColor,
331
+ strokeWidth,
332
+ };
333
+ draftAnnotation = draft;
334
+ renderCanvas(draft);
335
+ } else if (editor.state.activeTool === 'arrow') {
336
+ const draft: Annotation = {
337
+ id: 'draft',
338
+ type: 'arrow',
339
+ x: dragStartPoint.x,
340
+ y: dragStartPoint.y,
341
+ endX: currentImgPt.x,
342
+ endY: currentImgPt.y,
343
+ strokeColor: activeColor,
344
+ strokeWidth,
345
+ };
346
+ draftAnnotation = draft;
347
+ renderCanvas(draft);
348
+ } else if (editor.state.activeTool === 'line') {
349
+ const draft: Annotation = {
350
+ id: 'draft',
351
+ type: 'line',
352
+ x: dragStartPoint.x,
353
+ y: dragStartPoint.y,
354
+ endX: currentImgPt.x,
355
+ endY: currentImgPt.y,
356
+ strokeColor: activeColor,
357
+ strokeWidth,
358
+ };
359
+ draftAnnotation = draft;
360
+ renderCanvas(draft);
361
+ }
362
+ }
363
+ }
364
+
365
+ function handleMouseUp(e: MouseEvent) {
366
+ if (!canvasEl) return;
367
+ const canvasPt = getCanvasEventPoint(e);
368
+
369
+ if (activeTab === 'crop') {
370
+ if (cropDragInfo) {
371
+ if (draftCrop && draftCrop.width >= 20 && draftCrop.height >= 20) {
372
+ editor.setCrop(draftCrop, true);
373
+ }
374
+ cropDragInfo = null;
375
+ draftCrop = null;
376
+ renderCanvas();
377
+ }
378
+ return;
379
+ }
380
+
381
+ if (activeTab === 'annotate') {
382
+ if (annotationDragInfo) {
383
+ annotationDragInfo = null;
384
+ return;
385
+ }
386
+
387
+ if (dragStartPoint) {
388
+ const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
389
+
390
+ if (editor.state.activeTool === 'pen' && isDrawing) {
391
+ if (currentPath.length > 1) {
392
+ editor.addAnnotation({
393
+ type: 'pen',
394
+ x: 0,
395
+ y: 0,
396
+ points: [...currentPath],
397
+ strokeColor: activeColor,
398
+ strokeWidth,
399
+ });
400
+ }
401
+ isDrawing = false;
402
+ currentPath = [];
403
+ } else if (editor.state.activeTool === 'rect') {
404
+ const w = Math.abs(currentImgPt.x - dragStartPoint.x);
405
+ const h = Math.abs(currentImgPt.y - dragStartPoint.y);
406
+ if (w > 4 && h > 4) {
407
+ editor.addAnnotation({
408
+ type: 'rect',
409
+ x: Math.min(dragStartPoint.x, currentImgPt.x),
410
+ y: Math.min(dragStartPoint.y, currentImgPt.y),
411
+ width: w,
412
+ height: h,
413
+ strokeColor: activeColor,
414
+ strokeWidth,
415
+ });
416
+ }
417
+ } else if (editor.state.activeTool === 'circle') {
418
+ const rx = Math.abs(currentImgPt.x - dragStartPoint.x) / 2;
419
+ const ry = Math.abs(currentImgPt.y - dragStartPoint.y) / 2;
420
+ if (rx > 4 && ry > 4) {
421
+ editor.addAnnotation({
422
+ type: 'circle',
423
+ x: Math.min(dragStartPoint.x, currentImgPt.x) + rx,
424
+ y: Math.min(dragStartPoint.y, currentImgPt.y) + ry,
425
+ radiusX: rx,
426
+ radiusY: ry,
427
+ strokeColor: activeColor,
428
+ strokeWidth,
429
+ });
430
+ }
431
+ } else if (editor.state.activeTool === 'arrow') {
432
+ if (Math.hypot(currentImgPt.x - dragStartPoint.x, currentImgPt.y - dragStartPoint.y) > 4) {
433
+ editor.addAnnotation({
434
+ type: 'arrow',
435
+ x: dragStartPoint.x,
436
+ y: dragStartPoint.y,
437
+ endX: currentImgPt.x,
438
+ endY: currentImgPt.y,
439
+ strokeColor: activeColor,
440
+ strokeWidth,
441
+ });
442
+ }
443
+ } else if (editor.state.activeTool === 'line') {
444
+ if (Math.hypot(currentImgPt.x - dragStartPoint.x, currentImgPt.y - dragStartPoint.y) > 4) {
445
+ editor.addAnnotation({
446
+ type: 'line',
447
+ x: dragStartPoint.x,
448
+ y: dragStartPoint.y,
449
+ endX: currentImgPt.x,
450
+ endY: currentImgPt.y,
451
+ strokeColor: activeColor,
452
+ strokeWidth,
453
+ });
454
+ }
455
+ }
456
+
457
+ draftAnnotation = null;
458
+ dragStartPoint = null;
459
+ renderCanvas();
460
+ }
461
+ }
462
+ }
463
+
464
+ function handleAddText() {
465
+ if (!textInput.trim()) return;
466
+ editor.addAnnotation({
467
+ type: 'text',
468
+ x: 50,
469
+ y: 50,
470
+ text: textInput.trim(),
471
+ fontSize,
472
+ color: activeColor,
473
+ });
474
+ textInput = '';
475
+ }
476
+
477
+ async function handleExport() {
478
+ const dataUrl = await editor.toDataURL(exportOptions);
479
+ if (onSave) {
480
+ onSave(dataUrl);
481
+ } else {
482
+ const link = document.createElement('a');
483
+ link.download = `edited-image-${Date.now()}.png`;
484
+ link.href = dataUrl;
485
+ link.click();
486
+ }
487
+ }
488
+ </script>
489
+
490
+ <div
491
+ class="flex flex-col bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-2xl text-slate-100 {className}"
492
+ {style}
493
+ >
494
+ <!-- Top Action Header -->
495
+ <div class="flex flex-wrap items-center justify-between px-4 py-3 bg-slate-950/80 border-b border-slate-800 gap-3">
496
+ <!-- Navigation Tabs -->
497
+ <div class="flex items-center gap-1 bg-slate-900 p-1 rounded-lg border border-slate-800 text-xs">
498
+ <button
499
+ type="button"
500
+ onclick={() => {
501
+ activeTab = 'crop';
502
+ editor.setTool('crop');
503
+ }}
504
+ class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'crop'
505
+ ? 'bg-indigo-600 text-white shadow'
506
+ : 'text-slate-400 hover:text-slate-200'}"
507
+ >
508
+ Transform & Crop
509
+ </button>
510
+ <button
511
+ type="button"
512
+ onclick={() => {
513
+ activeTab = 'adjust';
514
+ editor.setTool('select');
515
+ }}
516
+ class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'adjust'
517
+ ? 'bg-indigo-600 text-white shadow'
518
+ : 'text-slate-400 hover:text-slate-200'}"
519
+ >
520
+ Adjustments
521
+ </button>
522
+ <button
523
+ type="button"
524
+ onclick={() => {
525
+ activeTab = 'annotate';
526
+ editor.setTool('pen');
527
+ }}
528
+ class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'annotate'
529
+ ? 'bg-indigo-600 text-white shadow'
530
+ : 'text-slate-400 hover:text-slate-200'}"
531
+ >
532
+ Annotations
533
+ </button>
534
+ <button
535
+ type="button"
536
+ onclick={() => {
537
+ activeTab = 'depth';
538
+ editor.setTool('select');
539
+ }}
540
+ class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'depth'
541
+ ? 'bg-indigo-600 text-white shadow'
542
+ : 'text-slate-400 hover:text-slate-200'}"
543
+ >
544
+ Depth Mask
545
+ </button>
546
+ </div>
547
+
548
+ <!-- Global Action Tools (Undo/Redo, Zoom, Compare, Save) -->
549
+ <div class="flex items-center gap-2 text-xs">
550
+ <button
551
+ type="button"
552
+ disabled={!editor.canUndo}
553
+ onclick={() => editor.undo()}
554
+ class="p-1.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
555
+ title="Undo"
556
+ >
557
+ ↩
558
+ </button>
559
+ <button
560
+ type="button"
561
+ disabled={!editor.canRedo}
562
+ onclick={() => editor.redo()}
563
+ class="p-1.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
564
+ title="Redo"
565
+ >
566
+ ↪
567
+ </button>
568
+ <button
569
+ type="button"
570
+ onclick={() => editor.reset()}
571
+ class="px-2 py-1.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-400 hover:text-slate-200 hover:bg-slate-800"
572
+ >
573
+ Reset
574
+ </button>
575
+
576
+ <div class="h-4 w-px bg-slate-800 mx-1"></div>
577
+
578
+ <!-- Zoom -->
579
+ <button
580
+ type="button"
581
+ onclick={() => editor.zoomOut()}
582
+ class="p-1.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800"
583
+ title="Zoom Out"
584
+ >
585
+ -
586
+ </button>
587
+ <span class="font-mono text-slate-400 w-10 text-center">{Math.round(editor.state.zoom * 100)}%</span>
588
+ <button
589
+ type="button"
590
+ onclick={() => editor.zoomIn()}
591
+ class="p-1.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800"
592
+ title="Zoom In"
593
+ >
594
+ +
595
+ </button>
596
+
597
+ <div class="h-4 w-px bg-slate-800 mx-1"></div>
598
+
599
+ <!-- Live Compare -->
600
+ <button
601
+ type="button"
602
+ onmousedown={() => editor.setComparing(true)}
603
+ onmouseup={() => editor.setComparing(false)}
604
+ onmouseleave={() => editor.setComparing(false)}
605
+ class="px-2.5 py-1.5 rounded-lg border border-slate-800 bg-slate-900 text-amber-400 hover:bg-slate-800 select-none active:bg-amber-950/40"
606
+ >
607
+ Hold Compare
608
+ </button>
609
+
610
+ <!-- Save / Export -->
611
+ <button
612
+ type="button"
613
+ onclick={handleExport}
614
+ class="px-4 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-medium shadow-md shadow-emerald-900/30 transition-colors"
615
+ >
616
+ Export Image
617
+ </button>
618
+ </div>
619
+ </div>
620
+
621
+ <!-- Main Viewport + Sidebar Layout -->
622
+ <div class="flex flex-col md:flex-row flex-1 min-h-[520px] md:h-[560px]">
623
+ <!-- Canvas Area -->
624
+ <div
625
+ bind:this={containerEl}
626
+ class="relative flex-1 h-[360px] md:h-full min-h-[320px] bg-slate-950 overflow-hidden select-none"
627
+ >
628
+ <canvas
629
+ bind:this={canvasEl}
630
+ onmousedown={handleMouseDown}
631
+ onmousemove={handleMouseMove}
632
+ onmouseup={handleMouseUp}
633
+ onmouseleave={handleMouseUp}
634
+ class="absolute inset-0 w-full h-full block"
635
+ ></canvas>
636
+ </div>
637
+
638
+ <!-- Tab Controls Sidebar -->
639
+ <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]">
640
+ <!-- TAB 1: CROP & TRANSFORM -->
641
+ {#if activeTab === 'crop'}
642
+ <div class="flex flex-col gap-4 text-xs">
643
+ <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
644
+ Aspect Ratio Presets
645
+ </span>
646
+ <div class="grid grid-cols-3 gap-2">
647
+ <button
648
+ type="button"
649
+ onclick={() => {
650
+ activeCropRatio = null;
651
+ editor.applyCropPreset(null);
652
+ }}
653
+ class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === null && editor.state.crop
654
+ ? 'bg-indigo-600 border-indigo-500 text-white'
655
+ : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
656
+ >
657
+ Freeform
658
+ </button>
659
+ <button
660
+ type="button"
661
+ onclick={() => {
662
+ activeCropRatio = 1;
663
+ editor.applyCropPreset(1);
664
+ }}
665
+ class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === 1
666
+ ? 'bg-indigo-600 border-indigo-500 text-white'
667
+ : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
668
+ >
669
+ 1:1 Square
670
+ </button>
671
+ <button
672
+ type="button"
673
+ onclick={() => {
674
+ activeCropRatio = 4 / 3;
675
+ editor.applyCropPreset(4 / 3);
676
+ }}
677
+ class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === 4 / 3
678
+ ? 'bg-indigo-600 border-indigo-500 text-white'
679
+ : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
680
+ >
681
+ 4:3 Standard
682
+ </button>
683
+ <button
684
+ type="button"
685
+ onclick={() => {
686
+ activeCropRatio = 16 / 9;
687
+ editor.applyCropPreset(16 / 9);
688
+ }}
689
+ class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === 16 / 9
690
+ ? 'bg-indigo-600 border-indigo-500 text-white'
691
+ : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
692
+ >
693
+ 16:9 Wide
694
+ </button>
695
+ <button
696
+ type="button"
697
+ onclick={() => {
698
+ activeCropRatio = 3 / 2;
699
+ editor.applyCropPreset(3 / 2);
700
+ }}
701
+ class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === 3 / 2
702
+ ? 'bg-indigo-600 border-indigo-500 text-white'
703
+ : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
704
+ >
705
+ 3:2 Photo
706
+ </button>
707
+ <button
708
+ type="button"
709
+ onclick={() => {
710
+ activeCropRatio = null;
711
+ editor.resetCrop();
712
+ }}
713
+ class="px-2.5 py-2 rounded-lg bg-rose-950/40 hover:bg-rose-900/60 text-rose-300 border border-rose-800/40 font-medium"
714
+ >
715
+ Clear Crop
716
+ </button>
717
+ </div>
718
+
719
+ <div class="h-px bg-slate-800 my-1"></div>
720
+
721
+ <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
722
+ Orientation & Flips
723
+ </span>
724
+ <div class="grid grid-cols-2 gap-2">
725
+ <button
726
+ type="button"
727
+ onclick={() => editor.rotate(-90)}
728
+ class="px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700"
729
+ >
730
+ Rotate -90°
731
+ </button>
732
+ <button
733
+ type="button"
734
+ onclick={() => editor.rotate(90)}
735
+ class="px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700"
736
+ >
737
+ Rotate +90°
738
+ </button>
739
+ <button
740
+ type="button"
741
+ onclick={() => editor.flipHorizontal()}
742
+ class="px-3 py-2 rounded-lg border transition-colors {editor.state.transform.flipH
743
+ ? 'bg-indigo-600 border-indigo-500 text-white'
744
+ : 'bg-slate-800 border-slate-700 text-slate-200 hover:bg-slate-700'}"
745
+ >
746
+ Flip Horizontal
747
+ </button>
748
+ <button
749
+ type="button"
750
+ onclick={() => editor.flipVertical()}
751
+ class="px-3 py-2 rounded-lg border transition-colors {editor.state.transform.flipV
752
+ ? 'bg-indigo-600 border-indigo-500 text-white'
753
+ : 'bg-slate-800 border-slate-700 text-slate-200 hover:bg-slate-700'}"
754
+ >
755
+ Flip Vertical
756
+ </button>
757
+ </div>
758
+ </div>
759
+ {/if}
760
+
761
+ <!-- TAB 2: ADJUSTMENTS -->
762
+ {#if activeTab === 'adjust'}
763
+ <div class="flex flex-col gap-3 text-xs">
764
+ <div class="flex items-center justify-between">
765
+ <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
766
+ Color Adjustments
767
+ </span>
768
+ <button
769
+ type="button"
770
+ onclick={() => editor.resetAdjustments()}
771
+ class="text-[10px] text-rose-400 hover:underline"
772
+ >
773
+ Reset Sliders
774
+ </button>
775
+ </div>
776
+
777
+ <!-- Brightness -->
778
+ <div>
779
+ <div class="flex justify-between text-slate-400 mb-1">
780
+ <span>Brightness</span>
781
+ <span class="font-mono">{editor.state.adjustments.brightness}</span>
782
+ </div>
783
+ <input
784
+ type="range"
785
+ min="-100"
786
+ max="100"
787
+ value={editor.state.adjustments.brightness}
788
+ oninput={(e) => editor.setAdjustments({ brightness: Number((e.target as HTMLInputElement).value) })}
789
+ class="w-full accent-indigo-500"
790
+ />
791
+ </div>
792
+
793
+ <!-- Contrast -->
794
+ <div>
795
+ <div class="flex justify-between text-slate-400 mb-1">
796
+ <span>Contrast</span>
797
+ <span class="font-mono">{editor.state.adjustments.contrast}</span>
798
+ </div>
799
+ <input
800
+ type="range"
801
+ min="-100"
802
+ max="100"
803
+ value={editor.state.adjustments.contrast}
804
+ oninput={(e) => editor.setAdjustments({ contrast: Number((e.target as HTMLInputElement).value) })}
805
+ class="w-full accent-indigo-500"
806
+ />
807
+ </div>
808
+
809
+ <!-- Saturation -->
810
+ <div>
811
+ <div class="flex justify-between text-slate-400 mb-1">
812
+ <span>Saturation</span>
813
+ <span class="font-mono">{editor.state.adjustments.saturation}</span>
814
+ </div>
815
+ <input
816
+ type="range"
817
+ min="-100"
818
+ max="100"
819
+ value={editor.state.adjustments.saturation}
820
+ oninput={(e) => editor.setAdjustments({ saturation: Number((e.target as HTMLInputElement).value) })}
821
+ class="w-full accent-indigo-500"
822
+ />
823
+ </div>
824
+
825
+ <!-- Exposure -->
826
+ <div>
827
+ <div class="flex justify-between text-slate-400 mb-1">
828
+ <span>Exposure</span>
829
+ <span class="font-mono">{editor.state.adjustments.exposure}</span>
830
+ </div>
831
+ <input
832
+ type="range"
833
+ min="-100"
834
+ max="100"
835
+ value={editor.state.adjustments.exposure}
836
+ oninput={(e) => editor.setAdjustments({ exposure: Number((e.target as HTMLInputElement).value) })}
837
+ class="w-full accent-indigo-500"
838
+ />
839
+ </div>
840
+
841
+ <!-- Temperature / Warmth -->
842
+ <div>
843
+ <div class="flex justify-between text-slate-400 mb-1">
844
+ <span>Warmth / Temperature</span>
845
+ <span class="font-mono">{editor.state.adjustments.temperature}</span>
846
+ </div>
847
+ <input
848
+ type="range"
849
+ min="-100"
850
+ max="100"
851
+ value={editor.state.adjustments.temperature}
852
+ oninput={(e) => editor.setAdjustments({ temperature: Number((e.target as HTMLInputElement).value) })}
853
+ class="w-full accent-indigo-500"
854
+ />
855
+ </div>
856
+
857
+ <!-- Blur -->
858
+ <div>
859
+ <div class="flex justify-between text-slate-400 mb-1">
860
+ <span>Blur</span>
861
+ <span class="font-mono">{editor.state.adjustments.blur}px</span>
862
+ </div>
863
+ <input
864
+ type="range"
865
+ min="0"
866
+ max="30"
867
+ value={editor.state.adjustments.blur}
868
+ oninput={(e) => editor.setAdjustments({ blur: Number((e.target as HTMLInputElement).value) })}
869
+ class="w-full accent-indigo-500"
870
+ />
871
+ </div>
872
+
873
+ <!-- Opacity -->
874
+ <div>
875
+ <div class="flex justify-between text-slate-400 mb-1">
876
+ <span>Opacity</span>
877
+ <span class="font-mono">{Math.round(editor.state.adjustments.opacity * 100)}%</span>
878
+ </div>
879
+ <input
880
+ type="range"
881
+ min="0"
882
+ max="1"
883
+ step="0.05"
884
+ value={editor.state.adjustments.opacity}
885
+ oninput={(e) => editor.setAdjustments({ opacity: Number((e.target as HTMLInputElement).value) })}
886
+ class="w-full accent-indigo-500"
887
+ />
888
+ </div>
889
+ </div>
890
+ {/if}
891
+
892
+ <!-- TAB 3: ANNOTATIONS -->
893
+ {#if activeTab === 'annotate'}
894
+ <div class="flex flex-col gap-3 text-xs">
895
+ <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
896
+ Draw & Annotate
897
+ </span>
898
+
899
+ <!-- Tool Picker -->
900
+ <div class="grid grid-cols-3 gap-1.5">
901
+ {#each (['pen', 'rect', 'circle', 'arrow', 'line'] as ImageEditorTool[]) as t}
902
+ <button
903
+ type="button"
904
+ onclick={() => editor.setTool(t)}
905
+ class="py-1.5 px-2 rounded-lg font-medium capitalize border transition-colors {editor.state.activeTool === t
906
+ ? 'bg-indigo-600 border-indigo-500 text-white'
907
+ : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}"
908
+ >
909
+ {t}
910
+ </button>
911
+ {/each}
912
+ </div>
913
+
914
+ <!-- Color & Size -->
915
+ <div class="flex items-center gap-2 mt-2">
916
+ <span class="text-slate-400">Color:</span>
917
+ <input
918
+ type="color"
919
+ bind:value={activeColor}
920
+ class="w-8 h-8 rounded border border-slate-700 cursor-pointer bg-transparent"
921
+ />
922
+ <div class="flex-1 ml-2">
923
+ <div class="flex justify-between text-slate-400 mb-0.5">
924
+ <span>Width:</span>
925
+ <span class="font-mono">{strokeWidth}px</span>
926
+ </div>
927
+ <input
928
+ type="range"
929
+ min="1"
930
+ max="20"
931
+ bind:value={strokeWidth}
932
+ class="w-full accent-indigo-500"
933
+ />
934
+ </div>
935
+ </div>
936
+
937
+ <div class="h-px bg-slate-800 my-1"></div>
938
+
939
+ <!-- Text Tool -->
940
+ <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">Add Text</span>
941
+ <div class="flex gap-2">
942
+ <input
943
+ type="text"
944
+ placeholder="Type watermark or label..."
945
+ bind:value={textInput}
946
+ onkeydown={(e) => e.key === 'Enter' && handleAddText()}
947
+ class="flex-1 bg-slate-950 border border-slate-700 rounded-lg px-2.5 py-1.5 text-xs text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500"
948
+ />
949
+ <button
950
+ type="button"
951
+ onclick={handleAddText}
952
+ class="px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium shadow"
953
+ >
954
+ Add
955
+ </button>
956
+ </div>
957
+ <div class="flex items-center justify-between text-slate-400">
958
+ <span>Font Size:</span>
959
+ <span class="font-mono">{fontSize}px</span>
960
+ </div>
961
+ <input
962
+ type="range"
963
+ min="12"
964
+ max="72"
965
+ bind:value={fontSize}
966
+ class="w-full accent-indigo-500"
967
+ />
968
+
969
+ <!-- Layers & Selected Annotation -->
970
+ <div class="h-px bg-slate-800 my-1"></div>
971
+ <div class="flex items-center justify-between">
972
+ <span class="text-slate-400">Layers ({editor.state.annotations.length})</span>
973
+ {#if editor.state.selectedAnnotationId}
974
+ <button
975
+ type="button"
976
+ onclick={() => editor.removeAnnotation(editor.state.selectedAnnotationId!)}
977
+ class="text-rose-400 hover:underline text-[11px]"
978
+ >
979
+ Delete Selected
980
+ </button>
981
+ {/if}
982
+ {#if editor.state.annotations.length > 0}
983
+ <button
984
+ type="button"
985
+ onclick={() => editor.clearAnnotations()}
986
+ class="text-slate-400 hover:text-slate-200 text-[11px]"
987
+ >
988
+ Clear All
989
+ </button>
990
+ {/if}
991
+ </div>
992
+ </div>
993
+ {/if}
994
+
995
+ <!-- TAB 4: GLEAMFORGE DEPTH MASKING -->
996
+ {#if activeTab === 'depth'}
997
+ <div class="flex flex-col gap-3 text-xs">
998
+ <div class="flex items-center justify-between">
999
+ <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
1000
+ Depth Masking
1001
+ </span>
1002
+ <button
1003
+ type="button"
1004
+ onclick={() => editor.resetDepthMask()}
1005
+ class="text-[10px] text-rose-400 hover:underline"
1006
+ >
1007
+ Reset Mask
1008
+ </button>
1009
+ </div>
1010
+
1011
+ <label class="flex items-center gap-2 cursor-pointer text-slate-300">
1012
+ <input
1013
+ type="checkbox"
1014
+ checked={editor.state.depthMask.enabled}
1015
+ onchange={(e) => editor.setDepthMask({ enabled: (e.target as HTMLInputElement).checked })}
1016
+ class="rounded accent-indigo-500"
1017
+ />
1018
+ <span>Enable Depth / Alpha Mask</span>
1019
+ </label>
1020
+
1021
+ {#if editor.state.depthMask.enabled}
1022
+ <div>
1023
+ <span class="text-slate-400 block mb-1">Mask Image URL:</span>
1024
+ <input
1025
+ type="text"
1026
+ placeholder="https://.../depthmap.png"
1027
+ value={editor.state.depthMask.maskSource || ''}
1028
+ oninput={(e) => editor.setDepthMask({ maskSource: (e.target as HTMLInputElement).value || null })}
1029
+ class="w-full bg-slate-950 border border-slate-700 rounded-lg px-2.5 py-1.5 text-xs text-slate-100 placeholder-slate-500"
1030
+ />
1031
+ </div>
1032
+
1033
+ <div class="flex gap-2">
1034
+ <button
1035
+ type="button"
1036
+ onclick={() => editor.setDepthMask({ mode: 'threshold' })}
1037
+ class="flex-1 py-1.5 rounded-lg border text-xs font-medium {editor.state.depthMask.mode === 'threshold'
1038
+ ? 'bg-indigo-600 border-indigo-500 text-white'
1039
+ : 'bg-slate-800 border-slate-700 text-slate-300'}"
1040
+ >
1041
+ Threshold Mode
1042
+ </button>
1043
+ <button
1044
+ type="button"
1045
+ onclick={() => editor.setDepthMask({ mode: 'brightness' })}
1046
+ class="flex-1 py-1.5 rounded-lg border text-xs font-medium {editor.state.depthMask.mode === 'brightness'
1047
+ ? 'bg-indigo-600 border-indigo-500 text-white'
1048
+ : 'bg-slate-800 border-slate-700 text-slate-300'}"
1049
+ >
1050
+ Brightness Mode
1051
+ </button>
1052
+ </div>
1053
+
1054
+ <div>
1055
+ <div class="flex justify-between text-slate-400 mb-1">
1056
+ <span>Depth Start (Lower):</span>
1057
+ <span class="font-mono">{editor.state.depthMask.depthRange[0]}</span>
1058
+ </div>
1059
+ <input
1060
+ type="range"
1061
+ min="0"
1062
+ max="255"
1063
+ value={editor.state.depthMask.depthRange[0]}
1064
+ oninput={(e) =>
1065
+ editor.setDepthMask({
1066
+ depthRange: [Number((e.target as HTMLInputElement).value), editor.state.depthMask.depthRange[1]],
1067
+ })}
1068
+ class="w-full accent-indigo-500"
1069
+ />
1070
+ </div>
1071
+
1072
+ <div>
1073
+ <div class="flex justify-between text-slate-400 mb-1">
1074
+ <span>Depth Stop (Upper):</span>
1075
+ <span class="font-mono">{editor.state.depthMask.depthRange[1]}</span>
1076
+ </div>
1077
+ <input
1078
+ type="range"
1079
+ min="0"
1080
+ max="255"
1081
+ value={editor.state.depthMask.depthRange[1]}
1082
+ oninput={(e) =>
1083
+ editor.setDepthMask({
1084
+ depthRange: [editor.state.depthMask.depthRange[0], Number((e.target as HTMLInputElement).value)],
1085
+ })}
1086
+ class="w-full accent-indigo-500"
1087
+ />
1088
+ </div>
1089
+
1090
+ <div>
1091
+ <div class="flex justify-between text-slate-400 mb-1">
1092
+ <span>Softness:</span>
1093
+ <span class="font-mono">{editor.state.depthMask.softness}</span>
1094
+ </div>
1095
+ <input
1096
+ type="range"
1097
+ min="1"
1098
+ max="50"
1099
+ value={editor.state.depthMask.softness}
1100
+ oninput={(e) => editor.setDepthMask({ softness: Number((e.target as HTMLInputElement).value) })}
1101
+ class="w-full accent-indigo-500"
1102
+ />
1103
+ </div>
1104
+
1105
+ <label class="flex items-center gap-2 cursor-pointer text-slate-300 mt-1">
1106
+ <input
1107
+ type="checkbox"
1108
+ checked={editor.state.depthMask.invert}
1109
+ onchange={(e) => editor.setDepthMask({ invert: (e.target as HTMLInputElement).checked })}
1110
+ class="rounded accent-indigo-500"
1111
+ />
1112
+ <span>Invert Mask</span>
1113
+ </label>
1114
+ {/if}
1115
+ </div>
1116
+ {/if}
1117
+ </div>
1118
+ </div>
1119
+ </div>