@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.
- package/dist/chunk-HEOI4UNF.js +1191 -0
- package/dist/chunk-HEOI4UNF.js.map +1 -0
- package/dist/image-editor/index.d.ts +16 -0
- package/dist/image-editor/index.js +9 -0
- package/dist/image-editor/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/package.json +12 -3
- package/src/image-editor/ImageEditor.svelte +1345 -0
- package/src/image-editor/imageEditorState.svelte.ts +151 -0
- package/src/image-editor/index.d.ts +16 -0
- package/src/image-editor/index.ts +2 -0
- package/src/index.d.ts +2 -0
- package/src/index.ts +1 -0
|
@@ -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>
|