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