@pixerate/editor-svelte 0.107.0 → 0.109.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.
Files changed (36) hide show
  1. package/README.md +2 -2
  2. package/dist/canvas/createCanvasPlaybackSurface.svelte.d.ts +8 -2
  3. package/dist/canvas/createCanvasPlaybackSurface.svelte.js +13 -8
  4. package/dist/image-editor/AdjustmentLayersPanel.svelte +27 -26
  5. package/dist/image-editor/AdjustmentLayersPanel.svelte.d.ts +2 -0
  6. package/dist/image-editor/BrushOptionsBar.svelte +35 -34
  7. package/dist/image-editor/ColorProfilePanel.svelte +12 -12
  8. package/dist/image-editor/ControlSlider.svelte +6 -4
  9. package/dist/image-editor/ControlSlider.svelte.d.ts +2 -0
  10. package/dist/image-editor/CurvesEditor.svelte +6 -4
  11. package/dist/image-editor/CurvesEditor.svelte.d.ts +2 -0
  12. package/dist/image-editor/FreeTransformOptionsBar.svelte +22 -21
  13. package/dist/image-editor/Histogram.svelte +1 -1
  14. package/dist/image-editor/ImageEditor.svelte +171 -883
  15. package/dist/image-editor/ImageEditorCanvas.svelte +825 -0
  16. package/dist/image-editor/ImageEditorCanvas.svelte.d.ts +64 -0
  17. package/dist/image-editor/ImageEffectControls.svelte +15 -11
  18. package/dist/image-editor/ImageEffectControls.svelte.d.ts +2 -0
  19. package/dist/image-editor/ImageProviderEffectsPanel.svelte +15 -15
  20. package/dist/image-editor/LayerEffectsPanel.svelte +22 -23
  21. package/dist/image-editor/LayersPanel.svelte +39 -39
  22. package/dist/image-editor/RefineEdgePanel.svelte +14 -13
  23. package/dist/image-editor/SelectionOptionsBar.svelte +12 -11
  24. package/dist/image-editor/ShapeOptionsBar.svelte +14 -13
  25. package/dist/image-editor/TypeOptionsBar.svelte +12 -11
  26. package/dist/image-editor/canvasTypes.d.ts +4 -0
  27. package/dist/image-editor/canvasTypes.js +1 -0
  28. package/dist/image-editor/index.d.ts +7 -0
  29. package/dist/image-editor/index.js +2 -0
  30. package/dist/image-editor/styles.d.ts +51 -0
  31. package/dist/image-editor/styles.js +51 -0
  32. package/dist/playback/PresenceLayer.svelte +4 -2
  33. package/dist/playback/PresenceLayer.svelte.d.ts +2 -0
  34. package/dist/playback/playback.svelte.d.ts +7 -4
  35. package/dist/playback/playback.svelte.js +17 -9
  36. package/package.json +2 -2
@@ -1,40 +1,21 @@
1
1
  <script lang="ts">
2
+ import { imageDividerClass, imageEditorFrameClass, imageEditorHeaderClass, imageErrorBannerClass, imageHeaderButtonClass, imagePrimaryButtonClass, imageRangeClass, imageSectionClass, imageSectionTitleClass, imageSelectClass, imageSidebarClass, imageSmallButtonClass, imageSpinnerClass, imageTabClass, imageTabListClass } from './styles.js';
2
3
  import type {
3
- Annotation,
4
- CropBox,
5
- CropHandle,
6
4
  ExportOptions,
7
5
  ImageEditorTool,
8
- ImagePoint,
9
6
  InpaintHook,
10
7
  RemoveBackgroundHook,
11
8
  ImageEditorAIHooks,
12
9
  ImageEditorFit,
13
10
  } from '@pixerate/editor/image-editor';
14
- import type { AgentPlayback, PlaybackPoint, PlaybackRect, PlaybackRef } from '@pixerate/editor/tools';
15
- import { createPlaybackSurface, getAgentPlayback } from '../playback/playback.svelte.js';
16
- import PresenceLayer from '../playback/PresenceLayer.svelte';
11
+ import type { AgentPlayback, PlaybackRef } from '@pixerate/editor/tools';
17
12
  import {
18
- canvasToImagePoint,
19
- imageToCanvasPoint,
20
- createImagePlaybackLocator,
21
- hitTestCrop,
22
- getCropCursor,
23
- calculateCropDrag,
24
- hitTestAnnotation,
25
13
  normalizeProcessVersion,
26
14
  LATEST_PROCESS_VERSION,
27
15
  IMAGE_ADJUSTMENT_CONTROLS,
28
16
  readAdjustmentControls,
29
17
  DEFAULT_SELECTION_TOOL_OPTIONS,
30
- SelectionGesture,
31
- PaintGesture,
32
- handlePaintShortcut,
33
- pointerPressure,
34
18
  FreeTransformTool,
35
- getViewportProjection,
36
- ShapeDrawGesture,
37
- TypeToolGesture,
38
19
  DEFAULT_SHAPE_TOOL_OPTIONS,
39
20
  DEFAULT_TYPE_TOOL_OPTIONS,
40
21
  type SelectionToolOptions,
@@ -42,6 +23,8 @@
42
23
  type TextEditSession,
43
24
  type TypeToolOptions,
44
25
  } from '@pixerate/editor/image-editor';
26
+ import ImageEditorCanvas from './ImageEditorCanvas.svelte';
27
+ import type { ImageEditorCanvasErrorSource, ImageEditorCanvasMode } from './canvasTypes.js';
45
28
  import ControlSlider from './ControlSlider.svelte';
46
29
  import Histogram from './Histogram.svelte';
47
30
  import AdjustmentLayersPanel from './AdjustmentLayersPanel.svelte';
@@ -115,204 +98,59 @@
115
98
  normalizeProcessVersion(editor.state.adjustments.process) < LATEST_PROCESS_VERSION
116
99
  );
117
100
 
118
- let canvasEl = $state<HTMLCanvasElement | null>(null);
119
- let containerEl = $state<HTMLDivElement | null>(null);
120
101
  let activeTab = $state<'crop' | 'adjust' | 'layers' | 'paint' | 'select' | 'annotate' | 'depth' | 'ai'>('crop');
121
102
  let rootEl = $state<HTMLDivElement | null>(null);
103
+ /** The tabs with canvas gestures drive the canvas mode; the others only view. */
104
+ const canvasMode = $derived<ImageEditorCanvasMode>(
105
+ activeTab === 'crop' || activeTab === 'select' || activeTab === 'paint' || activeTab === 'annotate' ? activeTab : 'none'
106
+ );
122
107
 
123
- // Agent playback: the agent's cursor in document pixels, over the whole editor (canvas and panels).
124
- const ambientPlayback = getAgentPlayback();
125
- const playing = $derived((playback === undefined ? ambientPlayback : playback) ?? null);
126
- /** Document pixels → pixels in the editor's root element, through the canvas projection. */
127
- const docToRoot = (p: PlaybackPoint): PlaybackPoint => {
128
- if (!canvasEl || !rootEl || !canvasEl.width) return p;
129
- const r = canvasEl.getBoundingClientRect();
130
- const box = rootEl.getBoundingClientRect();
131
- const c = imageToCanvasPoint(p, editor.controller.getState(), canvasEl.width, canvasEl.height, activeTab === 'crop');
132
- return { x: r.left - box.left + (c.x * r.width) / canvasEl.width, y: r.top - box.top + (c.y * r.height) / canvasEl.height };
133
- };
134
- /** Viewport (client) pixels → document pixels. */
135
- const clientToDoc = (p: PlaybackPoint): PlaybackPoint => {
136
- if (!canvasEl || !canvasEl.width) return p;
137
- const r = canvasEl.getBoundingClientRect();
138
- const sx = r.width > 0 ? canvasEl.width / r.width : 1;
139
- const sy = r.height > 0 ? canvasEl.height / r.height : 1;
140
- return canvasToImagePoint({ x: (p.x - r.left) * sx, y: (p.y - r.top) * sy }, editor.controller.getState(), canvasEl.width, canvasEl.height, activeTab === 'crop');
141
- };
142
- /** An element of the editor (a tab, slider or layer row) as a rectangle in document pixels. */
143
- const elementRect = (selector: string): PlaybackRect | null => {
144
- const r = rootEl?.querySelector(selector)?.getBoundingClientRect();
145
- if (!r || r.width <= 0) return null;
146
- const a = clientToDoc({ x: r.left, y: r.top });
147
- const b = clientToDoc({ x: r.right, y: r.bottom });
148
- return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) };
149
- };
150
- const agentPresence = createPlaybackSurface(() => playing, () => playbackId, () => ({
151
- defaultKind: 'layer',
152
- locate: (ref: PlaybackRef) => {
153
- if (ref.kind === 'panel') return elementRect(`[data-image-tab="${CSS.escape(ref.id)}"]`);
154
- if (ref.kind === 'control') return elementRect(`[data-control-id="${CSS.escape(ref.id)}"]`);
155
- return createImagePlaybackLocator(editor.controller)(ref) ?? (ref.kind === 'layer' || ref.kind === 'item' ? elementRect(`[data-layer-id="${CSS.escape(ref.id)}"]`) : null);
156
- },
157
- open: async (ref: PlaybackRef) => {
158
- if (ref.kind !== 'panel' || activeTab === ref.id) return;
159
- rootEl?.querySelector<HTMLElement>(`[data-image-tab="${CSS.escape(ref.id)}"]`)?.click();
160
- // Let the panel render before the cursor looks for its controls.
161
- await new Promise((resolve) => setTimeout(resolve, 60));
162
- },
163
- bounds: () => {
164
- const r = canvasEl?.getBoundingClientRect();
165
- if (!r || r.width <= 0) return null;
166
- const a = clientToDoc({ x: r.left, y: r.top });
167
- const b = clientToDoc({ x: r.right, y: r.bottom });
168
- return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) };
169
- },
170
- visible: () => {
171
- const r = rootEl?.getBoundingClientRect();
172
- return !!r && r.width > 0 && r.height > 0 && !(typeof document !== 'undefined' && document.visibilityState === 'hidden');
173
- },
174
- toViewport: (p: PlaybackPoint) => {
175
- const box = rootEl?.getBoundingClientRect();
176
- const q = docToRoot(p);
177
- return { x: (box?.left ?? 0) + q.x, y: (box?.top ?? 0) + q.y };
178
- },
179
- fromViewport: clientToDoc,
180
- reveal: () => onplaybackreveal?.()
181
- }));
108
+ /** Agent playback opens a panel by clicking its tab, unless it is already open. */
109
+ async function openPanel(ref: PlaybackRef) {
110
+ if (ref.kind !== 'panel' || activeTab === ref.id) return;
111
+ rootEl?.querySelector<HTMLElement>(`[data-image-tab="${CSS.escape(ref.id)}"]`)?.click();
112
+ // Let the panel render before the cursor looks for its controls.
113
+ await new Promise((resolve) => setTimeout(resolve, 60));
114
+ }
182
115
 
183
- // Selection tools (magic wand, marquees, lasso): options bar state and the pointer gesture.
116
+ // Tool options for the canvas, set by the options bars.
184
117
  let selectionOptions = $state<SelectionToolOptions>({ ...DEFAULT_SELECTION_TOOL_OPTIONS });
185
- const selectionGesture = $derived(new SelectionGesture(editor.controller, () => selectionOptions));
186
- // Paint tools (Move, Brush, Eraser): the pointer gesture and the brush cursor.
187
- const paintGesture = $derived(new PaintGesture(editor.controller));
188
- let brushCursor: ImagePoint | null = null;
189
-
190
- // Shape and type tools: their options and gestures; type is edited on the canvas through a hidden textarea.
191
118
  let shapeOptions = $state<ShapeToolOptions>({ ...DEFAULT_SHAPE_TOOL_OPTIONS });
192
119
  let typeOptions = $state<TypeToolOptions>({ ...DEFAULT_TYPE_TOOL_OPTIONS });
193
- const shapeGesture = $derived(new ShapeDrawGesture(editor.controller, () => shapeOptions));
194
- const typeTool = $derived(new TypeToolGesture(editor.controller, () => typeOptions));
195
120
  let typeSession = $state<TextEditSession | null>(null);
196
- let typeInputEl = $state<HTMLTextAreaElement | null>(null);
197
121
  let shapeTypeError = $state<string | null>(null);
198
- $effect(() => {
199
- const s = typeSession;
200
- if (!s) return;
201
- return untrack(() => s.subscribe(() => void renderCanvas()));
202
- });
203
- $effect(() => {
204
- // End the edit when the tool or tab changes.
205
- const keep = activeTab === 'annotate' && editor.state.activeTool === 'type';
206
- untrack(() => {
207
- if (!keep && typeTool.session) finishTyping();
208
- });
209
- });
122
+ let paintError = $state<string | null>(null);
123
+ let selectionError = $state<string | null>(null);
124
+ let activeCropRatio = $state<number | null>(null);
125
+ function handleCanvasError(source: ImageEditorCanvasErrorSource, message: string | null) {
126
+ if (source === 'paint') paintError = message;
127
+ else if (source === 'select') selectionError = message;
128
+ else shapeTypeError = message;
129
+ }
210
130
  function finishTyping() {
211
- typeTool.finish();
131
+ // The canvas finishes the edit when its bound session is cleared.
212
132
  typeSession = null;
213
- void renderCanvas();
214
- }
215
- function handleTypeKeyDown(e: KeyboardEvent) {
216
- const session = typeTool.session;
217
- if (!session || e.isComposing) return;
218
- if (e.key === 'Escape') {
219
- e.preventDefault();
220
- finishTyping();
221
- return;
222
- }
223
- if ((e.metaKey || e.ctrlKey) && ['z', 'y', 'c', 'x', 'v'].includes(e.key.toLowerCase())) return;
224
- if (session.handleKey(e)) {
225
- e.preventDefault();
226
- shapeTypeError = session.error;
227
- }
228
- }
229
- function handleTypeInput(e: Event) {
230
- // Composed (IME) and pasted text arrives here; plain keys were handled on keydown.
231
- const el = e.currentTarget as HTMLTextAreaElement;
232
- const session = typeTool.session;
233
- if (session && el.value && !(e as InputEvent).isComposing) {
234
- session.insert(el.value);
235
- el.value = '';
236
- }
237
- }
238
- function handleTypeComposition(e: CompositionEvent) {
239
- const session = typeTool.session;
240
- if (session && e.data) session.insert(e.data);
241
- (e.currentTarget as HTMLTextAreaElement).value = '';
242
133
  }
243
134
 
244
- // Free Transform (Ctrl/Cmd+T): the shared tool drives a session; its overlay is drawn by the renderer.
135
+ // Free Transform (Ctrl/Cmd+T): shared with the canvas, which drives and draws it.
245
136
  const transformTool = $derived(
246
137
  new FreeTransformTool(editor.controller, {
247
138
  schedule: (run) => (typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame(run) : run()),
248
139
  })
249
140
  );
250
141
  let transformTick = $state(0);
251
- let transformCursor = $state('default');
252
142
  const transformActive = $derived((void transformTick, transformTool.active));
253
143
  const transformError = $derived((void transformTick, transformTool.error));
254
144
  $effect(() => {
255
145
  const tool = transformTool;
256
146
  const off = tool.subscribe(() => {
257
147
  transformTick++;
258
- void renderCanvas();
259
148
  });
260
149
  return () => {
261
150
  off();
262
151
  tool.cancel();
263
152
  };
264
153
  });
265
- $effect(() => {
266
- if (typeof window === 'undefined') return;
267
- const tool = transformTool;
268
- const onKey = (e: KeyboardEvent) => {
269
- if (tool.key(e)) e.preventDefault();
270
- };
271
- window.addEventListener('keydown', onKey);
272
- return () => window.removeEventListener('keydown', onKey);
273
- });
274
-
275
- /** The document point of a pointer event and the hit tolerance (6 screen px). */
276
- function transformPoint(e: PointerEvent) {
277
- const rect = canvasEl!.getBoundingClientRect();
278
- const sx = rect.width > 0 ? canvasEl!.width / rect.width : 1;
279
- const sy = rect.height > 0 ? canvasEl!.height / rect.height : 1;
280
- const s = editor.controller.getState();
281
- const cropping = activeTab === 'crop';
282
- const projection = getViewportProjection(s, canvasEl!.width, canvasEl!.height, cropping);
283
- const pt = canvasToImagePoint({ x: (e.clientX - rect.left) * sx, y: (e.clientY - rect.top) * sy }, s, canvasEl!.width, canvasEl!.height, cropping);
284
- transformTool.setView(projection.scale);
285
- return { pt, tolerance: 6 / (projection.scale || 1) };
286
- }
287
- function handleTransformPointerDown(e: PointerEvent): boolean {
288
- if (!transformTool.active || !canvasEl) return false;
289
- if (e.button !== 0) return true;
290
- e.preventDefault();
291
- try {
292
- canvasEl.setPointerCapture?.(e.pointerId);
293
- } catch {
294
- // Synthetic events have no active pointer to capture.
295
- }
296
- const { pt, tolerance } = transformPoint(e);
297
- transformTool.down(pt, tolerance);
298
- return true;
299
- }
300
- function handleTransformPointerMove(e: PointerEvent): boolean {
301
- if (!transformTool.active || !canvasEl) return false;
302
- const { pt, tolerance } = transformPoint(e);
303
- if (e.buttons & 1) transformTool.move(pt, e);
304
- else transformCursor = transformTool.cursor(transformTool.hit(pt, tolerance));
305
- return true;
306
- }
307
- function handleTransformPointerUp(): boolean {
308
- if (!transformTool.active) return false;
309
- transformTool.up();
310
- return true;
311
- }
312
- let paintError = $state<string | null>(null);
313
- let selectionDraft = $state.raw<ImagePoint[] | null>(null);
314
- let selectionError = $state<string | null>(null);
315
- let activeCropRatio = $state<number | null>(null);
316
154
 
317
155
  // AI state
318
156
  let inpaintPrompt = $state('');
@@ -398,24 +236,6 @@
398
236
  let fontSize = $state(24);
399
237
  let textInput = $state('');
400
238
 
401
- // Live draft and drag references
402
- let draftAnnotation = $state<Annotation | null>(null);
403
- let draftCrop = $state<CropBox | null>(null);
404
- let cropDragInfo: {
405
- handle: CropHandle | 'new';
406
- startCanvasPt: ImagePoint;
407
- startImgPt: ImagePoint;
408
- startCrop: CropBox;
409
- } | null = null;
410
- let annotationDragInfo: {
411
- annId: string;
412
- startImgPt: ImagePoint;
413
- initialAnn: Annotation;
414
- } | null = null;
415
- let isDrawing = false;
416
- let currentPath: ImagePoint[] = [];
417
- let dragStartPoint: ImagePoint | null = null;
418
-
419
239
  // Load the image when `src` changes. Only `src` is tracked: AI edits replace
420
240
  // the editor's source with a data URL and must not be reverted to `src`.
421
241
  let loadedSrc: string | undefined;
@@ -428,512 +248,12 @@
428
248
  });
429
249
  });
430
250
 
431
- async function renderCanvas(overrideAnnotation?: Annotation | null, overrideCrop?: CropBox | null) {
432
- if (!canvasEl || !containerEl) return;
433
- const vpWidth = containerEl.clientWidth || 800;
434
- const vpHeight = containerEl.clientHeight || 500;
435
-
436
- await editor.controller.render(canvasEl, {
437
- viewportWidth: vpWidth,
438
- viewportHeight: vpHeight,
439
- fit,
440
- showCropOverlay: activeTab === 'crop',
441
- showSelectionOverlay: true,
442
- interactive: true,
443
- draftAnnotation: overrideAnnotation !== undefined ? overrideAnnotation : draftAnnotation,
444
- draftCrop: overrideCrop !== undefined ? overrideCrop : draftCrop,
445
- draftSelectionPath: selectionDraft,
446
- brushCursor:
447
- activeTab === 'paint' && brushCursor && editor.controller.getState().activeTool !== 'move' && !transformTool.active
448
- ? { ...brushCursor, diameter: editor.controller.getState().paint.brush.size }
449
- : null,
450
- freeTransform: transformTool.overlay(),
451
- textEdit: typeTool.session?.alive ? typeTool.session.overlay() : null,
452
- });
453
- }
454
-
455
- // Paint tools: pointer events (coalesced samples, pointer capture) into strokes and moves.
456
- function paintSamples(e: PointerEvent) {
457
- const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
458
- const list = events.length ? events : [e];
459
- const rect = canvasEl!.getBoundingClientRect();
460
- const sx = rect.width > 0 ? canvasEl!.width / rect.width : 1;
461
- const sy = rect.height > 0 ? canvasEl!.height / rect.height : 1;
462
- const s = editor.controller.getState();
463
- return list.map((ev) => ({
464
- ...canvasToImagePoint({ x: (ev.clientX - rect.left) * sx, y: (ev.clientY - rect.top) * sy }, s, canvasEl!.width, canvasEl!.height, false),
465
- pressure: pointerPressure(ev),
466
- }));
467
- }
468
- function handlePaintPointerDown(e: PointerEvent) {
469
- if (handleTransformPointerDown(e)) return;
470
- if (activeTab !== 'paint' || e.button !== 0 || !canvasEl) return;
471
- e.preventDefault();
472
- try {
473
- canvasEl.setPointerCapture?.(e.pointerId);
474
- } catch {
475
- // Synthetic events have no active pointer to capture.
476
- }
477
- paintError = paintGesture.down({ ...paintSamples(e)[0], altKey: e.altKey });
478
- }
479
- function handlePaintPointerMove(e: PointerEvent) {
480
- if (handleTransformPointerMove(e)) return;
481
- if (activeTab !== 'paint' || !canvasEl) return;
482
- const samples = paintSamples(e);
483
- brushCursor = samples[samples.length - 1] ?? null;
484
- if (paintGesture.active) paintGesture.moveTo(samples);
485
- else void renderCanvas();
486
- }
487
- function handlePaintPointerUp() {
488
- if (handleTransformPointerUp()) return;
489
- if (activeTab !== 'paint' || !paintGesture.active) return;
490
- paintGesture.up();
491
- if (paintGesture.error) paintError = paintGesture.error;
492
- }
493
-
494
- $effect(() => {
495
- if (activeTab !== 'paint' || typeof window === 'undefined') return;
496
- const c = editor.controller;
497
- const onKey = (e: KeyboardEvent) => {
498
- if (handlePaintShortcut(c, e)) e.preventDefault();
499
- };
500
- window.addEventListener('keydown', onKey);
501
- return () => window.removeEventListener('keydown', onKey);
502
- });
503
-
504
- $effect(() => {
505
- // Re-render when editor state or activeTab changes
506
- void editor.state;
507
- void activeTab;
508
-
509
- const raf = typeof requestAnimationFrame !== 'undefined'
510
- ? requestAnimationFrame
511
- : (cb: () => void) => setTimeout(cb, 0) as unknown as number;
512
- const caf = typeof cancelAnimationFrame !== 'undefined'
513
- ? cancelAnimationFrame
514
- : (id: number) => clearTimeout(id);
515
-
516
- const animId = raf(() => {
517
- renderCanvas();
518
- });
519
- return () => caf(animId);
520
- });
521
-
522
251
  $effect(() => {
523
252
  if (activeTab === 'crop' && editor.state.activeTool !== 'crop') {
524
253
  editor.setTool('crop');
525
254
  }
526
255
  });
527
256
 
528
- $effect(() => {
529
- if (!containerEl) return;
530
- if (typeof ResizeObserver !== 'undefined') {
531
- const observer = new ResizeObserver(() => {
532
- renderCanvas();
533
- });
534
- observer.observe(containerEl);
535
- return () => observer.disconnect();
536
- } else {
537
- const handleResize = () => renderCanvas();
538
- window.addEventListener('resize', handleResize);
539
- return () => window.removeEventListener('resize', handleResize);
540
- }
541
- });
542
-
543
- function getCanvasEventPoint(e: MouseEvent): ImagePoint {
544
- if (!canvasEl) return { x: 0, y: 0 };
545
- const rect = canvasEl.getBoundingClientRect();
546
- const scaleX = rect.width > 0 ? canvasEl.width / rect.width : 1;
547
- const scaleY = rect.height > 0 ? canvasEl.height / rect.height : 1;
548
- return {
549
- x: (e.clientX - rect.left) * scaleX,
550
- y: (e.clientY - rect.top) * scaleY,
551
- };
552
- }
553
-
554
- function handleMouseDown(e: MouseEvent) {
555
- if (!canvasEl || transformTool.active) return;
556
- const canvasPt = getCanvasEventPoint(e);
557
-
558
- if (activeTab === 'select') {
559
- const gesture = selectionGesture;
560
- const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
561
- void gesture.begin(imgPt, e).then(() => (selectionError = gesture.error));
562
- return;
563
- }
564
-
565
- if (activeTab === 'crop') {
566
- const activeCrop = draftCrop || editor.state.crop || {
567
- x: 0,
568
- y: 0,
569
- width: editor.state.imageDimensions.width,
570
- height: editor.state.imageDimensions.height,
571
- };
572
- const handle = hitTestCrop(canvasPt, editor.state, canvasEl.width, canvasEl.height, 14, activeCrop);
573
- const startImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, true);
574
-
575
- if (handle) {
576
- cropDragInfo = {
577
- handle,
578
- startCanvasPt: canvasPt,
579
- startImgPt,
580
- startCrop: activeCrop,
581
- };
582
- } else {
583
- // Clicked outside crop box: start new crop selection
584
- cropDragInfo = {
585
- handle: 'new',
586
- startCanvasPt: canvasPt,
587
- startImgPt,
588
- startCrop: { x: startImgPt.x, y: startImgPt.y, width: 0, height: 0 },
589
- };
590
- }
591
- return;
592
- }
593
-
594
- if (activeTab === 'annotate' && (editor.state.activeTool === 'shape' || editor.state.activeTool === 'type')) {
595
- const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
596
- shapeTypeError = null;
597
- if (editor.state.activeTool === 'shape') shapeGesture.down(imgPt);
598
- else {
599
- typeTool.down(imgPt, { shift: e.shiftKey });
600
- typeSession = typeTool.session;
601
- void renderCanvas();
602
- }
603
- return;
604
- }
605
-
606
- if (activeTab === 'annotate') {
607
- const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
608
- dragStartPoint = imgPt;
609
-
610
- if (editor.state.activeTool === 'select') {
611
- for (let i = editor.state.annotations.length - 1; i >= 0; i--) {
612
- const ann = editor.state.annotations[i];
613
- if (hitTestAnnotation(imgPt, ann)) {
614
- editor.selectAnnotation(ann.id);
615
- annotationDragInfo = {
616
- annId: ann.id,
617
- startImgPt: imgPt,
618
- initialAnn: { ...ann },
619
- };
620
- return;
621
- }
622
- }
623
- editor.selectAnnotation(null);
624
- return;
625
- }
626
-
627
- if (editor.state.activeTool === 'pen') {
628
- isDrawing = true;
629
- currentPath = [imgPt];
630
- const draft: Annotation = {
631
- id: 'draft',
632
- type: 'pen',
633
- x: 0,
634
- y: 0,
635
- points: [imgPt],
636
- strokeColor: activeColor,
637
- strokeWidth,
638
- };
639
- draftAnnotation = draft;
640
- renderCanvas(draft);
641
- }
642
- }
643
- }
644
-
645
- function handleMouseMove(e: MouseEvent) {
646
- if (!canvasEl || transformTool.active) return;
647
- const canvasPt = getCanvasEventPoint(e);
648
-
649
- if (activeTab === 'select') {
650
- const gesture = selectionGesture;
651
- if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false))) {
652
- selectionDraft = gesture.draftPath;
653
- renderCanvas();
654
- }
655
- return;
656
- }
657
-
658
- if (activeTab === 'crop') {
659
- if (cropDragInfo) {
660
- const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, true);
661
- let newCrop: CropBox;
662
-
663
- if (cropDragInfo.handle === 'new') {
664
- const minX = Math.max(0, Math.min(cropDragInfo.startImgPt.x, currentImgPt.x));
665
- const minY = Math.max(0, Math.min(cropDragInfo.startImgPt.y, currentImgPt.y));
666
- const maxX = Math.min(editor.state.imageDimensions.width, Math.max(cropDragInfo.startImgPt.x, currentImgPt.x));
667
- const maxY = Math.min(editor.state.imageDimensions.height, Math.max(cropDragInfo.startImgPt.y, currentImgPt.y));
668
- newCrop = {
669
- x: Math.round(minX),
670
- y: Math.round(minY),
671
- width: Math.round(Math.max(20, maxX - minX)),
672
- height: Math.round(Math.max(20, maxY - minY)),
673
- };
674
- } else {
675
- newCrop = calculateCropDrag(
676
- cropDragInfo.startCrop,
677
- cropDragInfo.handle,
678
- cropDragInfo.startImgPt,
679
- currentImgPt,
680
- editor.state.imageDimensions.width,
681
- editor.state.imageDimensions.height,
682
- activeCropRatio,
683
- editor.state.transform
684
- );
685
- }
686
-
687
- draftCrop = newCrop;
688
- renderCanvas(null, newCrop);
689
- } else {
690
- const activeCrop = draftCrop || editor.state.crop || {
691
- x: 0,
692
- y: 0,
693
- width: editor.state.imageDimensions.width,
694
- height: editor.state.imageDimensions.height,
695
- };
696
- const handle = hitTestCrop(canvasPt, editor.state, canvasEl.width, canvasEl.height, 14, activeCrop);
697
- canvasEl.style.cursor = getCropCursor(handle, editor.state.transform);
698
- }
699
- return;
700
- }
701
-
702
- if (activeTab === 'annotate' && editor.state.activeTool === 'shape') {
703
- if (shapeGesture.active) {
704
- shapeGesture.move(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false), { shift: e.shiftKey, alt: e.altKey });
705
- if (shapeGesture.error) shapeTypeError = shapeGesture.error;
706
- }
707
- return;
708
- }
709
-
710
- if (activeTab === 'annotate') {
711
- if (annotationDragInfo) {
712
- const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
713
- const dx = currentImgPt.x - annotationDragInfo.startImgPt.x;
714
- const dy = currentImgPt.y - annotationDragInfo.startImgPt.y;
715
- editor.updateAnnotation(annotationDragInfo.annId, {
716
- x: Math.round(annotationDragInfo.initialAnn.x + dx),
717
- y: Math.round(annotationDragInfo.initialAnn.y + dy),
718
- });
719
- return;
720
- }
721
-
722
- if (!dragStartPoint) {
723
- if (editor.state.activeTool === 'select') {
724
- const imgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
725
- let hovered = false;
726
- for (let i = editor.state.annotations.length - 1; i >= 0; i--) {
727
- if (hitTestAnnotation(imgPt, editor.state.annotations[i])) {
728
- hovered = true;
729
- break;
730
- }
731
- }
732
- canvasEl.style.cursor = hovered ? 'move' : 'default';
733
- } else {
734
- canvasEl.style.cursor = 'crosshair';
735
- }
736
- return;
737
- }
738
-
739
- const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
740
-
741
- if (editor.state.activeTool === 'pen' && isDrawing) {
742
- currentPath.push(currentImgPt);
743
- const draft: Annotation = {
744
- id: 'draft',
745
- type: 'pen',
746
- x: 0,
747
- y: 0,
748
- points: [...currentPath],
749
- strokeColor: activeColor,
750
- strokeWidth,
751
- };
752
- draftAnnotation = draft;
753
- renderCanvas(draft);
754
- } else if (editor.state.activeTool === 'rect') {
755
- const w = currentImgPt.x - dragStartPoint.x;
756
- const h = currentImgPt.y - dragStartPoint.y;
757
- const draft: Annotation = {
758
- id: 'draft',
759
- type: 'rect',
760
- x: Math.min(dragStartPoint.x, currentImgPt.x),
761
- y: Math.min(dragStartPoint.y, currentImgPt.y),
762
- width: Math.abs(w),
763
- height: Math.abs(h),
764
- strokeColor: activeColor,
765
- strokeWidth,
766
- };
767
- draftAnnotation = draft;
768
- renderCanvas(draft);
769
- } else if (editor.state.activeTool === 'circle') {
770
- const rx = Math.abs(currentImgPt.x - dragStartPoint.x) / 2;
771
- const ry = Math.abs(currentImgPt.y - dragStartPoint.y) / 2;
772
- const draft: Annotation = {
773
- id: 'draft',
774
- type: 'circle',
775
- x: Math.min(dragStartPoint.x, currentImgPt.x) + rx,
776
- y: Math.min(dragStartPoint.y, currentImgPt.y) + ry,
777
- radiusX: rx,
778
- radiusY: ry,
779
- strokeColor: activeColor,
780
- strokeWidth,
781
- };
782
- draftAnnotation = draft;
783
- renderCanvas(draft);
784
- } else if (editor.state.activeTool === 'arrow') {
785
- const draft: Annotation = {
786
- id: 'draft',
787
- type: 'arrow',
788
- x: dragStartPoint.x,
789
- y: dragStartPoint.y,
790
- endX: currentImgPt.x,
791
- endY: currentImgPt.y,
792
- strokeColor: activeColor,
793
- strokeWidth,
794
- };
795
- draftAnnotation = draft;
796
- renderCanvas(draft);
797
- } else if (editor.state.activeTool === 'line') {
798
- const draft: Annotation = {
799
- id: 'draft',
800
- type: 'line',
801
- x: dragStartPoint.x,
802
- y: dragStartPoint.y,
803
- endX: currentImgPt.x,
804
- endY: currentImgPt.y,
805
- strokeColor: activeColor,
806
- strokeWidth,
807
- };
808
- draftAnnotation = draft;
809
- renderCanvas(draft);
810
- }
811
- }
812
- }
813
-
814
- function handleMouseUp(e: MouseEvent) {
815
- if (!canvasEl || transformTool.active) return;
816
- const canvasPt = getCanvasEventPoint(e);
817
-
818
- if (activeTab === 'select') {
819
- const gesture = selectionGesture;
820
- if (gesture.active) {
821
- gesture.end(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false), e);
822
- selectionDraft = null;
823
- renderCanvas();
824
- }
825
- return;
826
- }
827
-
828
- if (activeTab === 'crop') {
829
- if (cropDragInfo) {
830
- if (draftCrop && draftCrop.width >= 20 && draftCrop.height >= 20) {
831
- editor.setCrop(draftCrop, true);
832
- }
833
- cropDragInfo = null;
834
- draftCrop = null;
835
- renderCanvas();
836
- }
837
- return;
838
- }
839
-
840
- if (activeTab === 'annotate' && editor.state.activeTool === 'shape') {
841
- shapeGesture.up();
842
- return;
843
- }
844
- if (activeTab === 'annotate' && editor.state.activeTool === 'type') {
845
- typeTool.up(canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false));
846
- typeSession = typeTool.session;
847
- if (typeTool.error) shapeTypeError = typeTool.error;
848
- void renderCanvas();
849
- // Keystrokes go to the hidden textarea while text is being edited.
850
- if (typeTool.session) setTimeout(() => typeInputEl?.focus(), 0);
851
- return;
852
- }
853
-
854
- if (activeTab === 'annotate') {
855
- if (annotationDragInfo) {
856
- annotationDragInfo = null;
857
- return;
858
- }
859
-
860
- if (dragStartPoint) {
861
- const currentImgPt = canvasToImagePoint(canvasPt, editor.state, canvasEl.width, canvasEl.height, false);
862
-
863
- if (editor.state.activeTool === 'pen' && isDrawing) {
864
- if (currentPath.length > 1) {
865
- editor.addAnnotation({
866
- type: 'pen',
867
- x: 0,
868
- y: 0,
869
- points: [...currentPath],
870
- strokeColor: activeColor,
871
- strokeWidth,
872
- });
873
- }
874
- isDrawing = false;
875
- currentPath = [];
876
- } else if (editor.state.activeTool === 'rect') {
877
- const w = Math.abs(currentImgPt.x - dragStartPoint.x);
878
- const h = Math.abs(currentImgPt.y - dragStartPoint.y);
879
- if (w > 4 && h > 4) {
880
- editor.addAnnotation({
881
- type: 'rect',
882
- x: Math.min(dragStartPoint.x, currentImgPt.x),
883
- y: Math.min(dragStartPoint.y, currentImgPt.y),
884
- width: w,
885
- height: h,
886
- strokeColor: activeColor,
887
- strokeWidth,
888
- });
889
- }
890
- } else if (editor.state.activeTool === 'circle') {
891
- const rx = Math.abs(currentImgPt.x - dragStartPoint.x) / 2;
892
- const ry = Math.abs(currentImgPt.y - dragStartPoint.y) / 2;
893
- if (rx > 4 && ry > 4) {
894
- editor.addAnnotation({
895
- type: 'circle',
896
- x: Math.min(dragStartPoint.x, currentImgPt.x) + rx,
897
- y: Math.min(dragStartPoint.y, currentImgPt.y) + ry,
898
- radiusX: rx,
899
- radiusY: ry,
900
- strokeColor: activeColor,
901
- strokeWidth,
902
- });
903
- }
904
- } else if (editor.state.activeTool === 'arrow') {
905
- if (Math.hypot(currentImgPt.x - dragStartPoint.x, currentImgPt.y - dragStartPoint.y) > 4) {
906
- editor.addAnnotation({
907
- type: 'arrow',
908
- x: dragStartPoint.x,
909
- y: dragStartPoint.y,
910
- endX: currentImgPt.x,
911
- endY: currentImgPt.y,
912
- strokeColor: activeColor,
913
- strokeWidth,
914
- });
915
- }
916
- } else if (editor.state.activeTool === 'line') {
917
- if (Math.hypot(currentImgPt.x - dragStartPoint.x, currentImgPt.y - dragStartPoint.y) > 4) {
918
- editor.addAnnotation({
919
- type: 'line',
920
- x: dragStartPoint.x,
921
- y: dragStartPoint.y,
922
- endX: currentImgPt.x,
923
- endY: currentImgPt.y,
924
- strokeColor: activeColor,
925
- strokeWidth,
926
- });
927
- }
928
- }
929
-
930
- draftAnnotation = null;
931
- dragStartPoint = null;
932
- renderCanvas();
933
- }
934
- }
935
- }
936
-
937
257
  function handleAddText() {
938
258
  if (!textInput.trim()) return;
939
259
  editor.addAnnotation({
@@ -962,16 +282,13 @@
962
282
 
963
283
  <div
964
284
  bind:this={rootEl}
965
- class="relative flex flex-col bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-2xl text-slate-100 {className}"
285
+ class="{imageEditorFrameClass} {className}"
966
286
  {style}
967
287
  >
968
- {#if playing}
969
- <PresenceLayer presence={agentPresence} viewport={docToRoot} />
970
- {/if}
971
288
  <!-- Top Action Header -->
972
- <div class="flex flex-wrap items-center justify-between px-4 py-3 bg-slate-950/80 border-b border-slate-800 gap-3">
289
+ <div class={imageEditorHeaderClass}>
973
290
  <!-- Navigation Tabs -->
974
- <div class="flex items-center gap-1 bg-slate-900 p-1 rounded-lg border border-slate-800 text-xs">
291
+ <div role="tablist" class={imageTabListClass}>
975
292
  <button
976
293
  type="button"
977
294
  data-image-tab="crop"
@@ -979,9 +296,7 @@
979
296
  activeTab = 'crop';
980
297
  editor.setTool('crop');
981
298
  }}
982
- class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'crop'
983
- ? 'bg-indigo-600 text-white shadow'
984
- : 'text-slate-400 hover:text-slate-200'}"
299
+ role="tab" aria-selected={activeTab === 'crop'} class="{imageTabClass(activeTab === 'crop')}"
985
300
  >
986
301
  Transform & Crop
987
302
  </button>
@@ -992,9 +307,7 @@
992
307
  activeTab = 'adjust';
993
308
  editor.setTool('select');
994
309
  }}
995
- class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'adjust'
996
- ? 'bg-indigo-600 text-white shadow'
997
- : 'text-slate-400 hover:text-slate-200'}"
310
+ role="tab" aria-selected={activeTab === 'adjust'} class="{imageTabClass(activeTab === 'adjust')}"
998
311
  >
999
312
  Adjustments
1000
313
  </button>
@@ -1005,9 +318,7 @@
1005
318
  activeTab = 'layers';
1006
319
  editor.setTool('select');
1007
320
  }}
1008
- class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'layers'
1009
- ? 'bg-indigo-600 text-white shadow'
1010
- : 'text-slate-400 hover:text-slate-200'}"
321
+ role="tab" aria-selected={activeTab === 'layers'} class="{imageTabClass(activeTab === 'layers')}"
1011
322
  >
1012
323
  Layers
1013
324
  </button>
@@ -1018,9 +329,7 @@
1018
329
  activeTab = 'paint';
1019
330
  editor.setTool('brush');
1020
331
  }}
1021
- class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'paint'
1022
- ? 'bg-indigo-600 text-white shadow'
1023
- : 'text-slate-400 hover:text-slate-200'}"
332
+ role="tab" aria-selected={activeTab === 'paint'} class="{imageTabClass(activeTab === 'paint')}"
1024
333
  >
1025
334
  Paint
1026
335
  </button>
@@ -1031,9 +340,7 @@
1031
340
  activeTab = 'select';
1032
341
  editor.setTool('select');
1033
342
  }}
1034
- class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'select'
1035
- ? 'bg-indigo-600 text-white shadow'
1036
- : 'text-slate-400 hover:text-slate-200'}"
343
+ role="tab" aria-selected={activeTab === 'select'} class="{imageTabClass(activeTab === 'select')}"
1037
344
  >
1038
345
  Select & Mask
1039
346
  </button>
@@ -1044,9 +351,7 @@
1044
351
  activeTab = 'annotate';
1045
352
  editor.setTool('pen');
1046
353
  }}
1047
- class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'annotate'
1048
- ? 'bg-indigo-600 text-white shadow'
1049
- : 'text-slate-400 hover:text-slate-200'}"
354
+ role="tab" aria-selected={activeTab === 'annotate'} class="{imageTabClass(activeTab === 'annotate')}"
1050
355
  >
1051
356
  Annotations
1052
357
  </button>
@@ -1057,9 +362,7 @@
1057
362
  activeTab = 'depth';
1058
363
  editor.setTool('select');
1059
364
  }}
1060
- class="px-3 py-1.5 rounded-md font-medium transition-colors {activeTab === 'depth'
1061
- ? 'bg-indigo-600 text-white shadow'
1062
- : 'text-slate-400 hover:text-slate-200'}"
365
+ role="tab" aria-selected={activeTab === 'depth'} class="{imageTabClass(activeTab === 'depth')}"
1063
366
  >
1064
367
  Depth Mask
1065
368
  </button>
@@ -1070,9 +373,7 @@
1070
373
  activeTab = 'ai';
1071
374
  editor.setTool('select');
1072
375
  }}
1073
- class="px-3 py-1.5 rounded-md font-medium transition-colors flex items-center gap-1.5 {activeTab === 'ai'
1074
- ? 'bg-indigo-600 text-white shadow'
1075
- : 'text-slate-400 hover:text-slate-200'}"
376
+ role="tab" aria-selected={activeTab === 'ai'} class="{imageTabClass(activeTab === 'ai')} flex items-center gap-1.5"
1076
377
  >
1077
378
  <svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1078
379
  <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"/>
@@ -1087,7 +388,7 @@
1087
388
  type="button"
1088
389
  disabled={!editor.canUndo}
1089
390
  onclick={() => editor.undo()}
1090
- 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"
391
+ class={imageHeaderButtonClass}
1091
392
  title="Undo"
1092
393
  >
1093
394
  ↩
@@ -1096,7 +397,7 @@
1096
397
  type="button"
1097
398
  disabled={!editor.canRedo}
1098
399
  onclick={() => editor.redo()}
1099
- 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"
400
+ class={imageHeaderButtonClass}
1100
401
  title="Redo"
1101
402
  >
1102
403
  ↪
@@ -1104,33 +405,33 @@
1104
405
  <button
1105
406
  type="button"
1106
407
  onclick={() => editor.reset()}
1107
- 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"
408
+ class="{imageHeaderButtonClass} px-2 text-muted-foreground"
1108
409
  >
1109
410
  Reset
1110
411
  </button>
1111
412
 
1112
- <div class="h-4 w-px bg-slate-800 mx-1"></div>
413
+ <div class="h-4 w-px bg-muted mx-1"></div>
1113
414
 
1114
415
  <!-- Zoom -->
1115
416
  <button
1116
417
  type="button"
1117
418
  onclick={() => editor.zoomOut()}
1118
- class="p-1.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800"
419
+ class={imageHeaderButtonClass}
1119
420
  title="Zoom Out"
1120
421
  >
1121
422
  -
1122
423
  </button>
1123
- <span class="font-mono text-slate-400 w-10 text-center">{Math.round(editor.state.zoom * 100)}%</span>
424
+ <span class="font-mono text-muted-foreground w-10 text-center">{Math.round(editor.state.zoom * 100)}%</span>
1124
425
  <button
1125
426
  type="button"
1126
427
  onclick={() => editor.zoomIn()}
1127
- class="p-1.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800"
428
+ class={imageHeaderButtonClass}
1128
429
  title="Zoom In"
1129
430
  >
1130
431
  +
1131
432
  </button>
1132
433
 
1133
- <div class="h-4 w-px bg-slate-800 mx-1"></div>
434
+ <div class="h-4 w-px bg-muted mx-1"></div>
1134
435
 
1135
436
  <!-- Live Compare -->
1136
437
  <button
@@ -1138,7 +439,7 @@
1138
439
  onmousedown={() => editor.setComparing(true)}
1139
440
  onmouseup={() => editor.setComparing(false)}
1140
441
  onmouseleave={() => editor.setComparing(false)}
1141
- 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"
442
+ class="px-2.5 py-1.5 rounded-lg border border-border bg-background text-amber-600 hover:bg-muted select-none active:bg-amber-500/10"
1142
443
  >
1143
444
  Hold Compare
1144
445
  </button>
@@ -1147,7 +448,7 @@
1147
448
  <button
1148
449
  type="button"
1149
450
  onclick={handleExport}
1150
- 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"
451
+ class="{imagePrimaryButtonClass} px-4 py-1.5"
1151
452
  >
1152
453
  Export Image
1153
454
  </button>
@@ -1157,53 +458,40 @@
1157
458
  <!-- Main Viewport + Sidebar Layout -->
1158
459
  <!-- Free Transform options bar (Ctrl/Cmd+T), above the viewport while a transform is open -->
1159
460
  {#if transformActive}
1160
- <FreeTransformOptionsBar tool={transformTool} class="border-b border-slate-800 bg-slate-900/95 px-4 py-2" />
461
+ <FreeTransformOptionsBar tool={transformTool} class="border-b border-border bg-background/95 px-4 py-2" />
1161
462
  {:else if transformError}
1162
- <div role="alert" class="border-b border-rose-900 bg-rose-950/80 px-4 py-1.5 text-xs text-rose-200">{transformError}</div>
463
+ <div role="alert" class={imageErrorBannerClass}>{transformError}</div>
1163
464
  {/if}
1164
465
 
1165
466
  <div class="flex flex-col md:flex-row flex-1 min-h-[520px] md:h-[560px]">
1166
467
  <!-- Canvas Area -->
1167
- <div
1168
- bind:this={containerEl}
1169
- class="relative flex-1 h-[360px] md:h-full min-h-[320px] bg-slate-950 overflow-hidden select-none"
1170
- >
1171
- <canvas
1172
- bind:this={canvasEl}
1173
- onmousedown={handleMouseDown}
1174
- onmousemove={handleMouseMove}
1175
- onmouseup={handleMouseUp}
1176
- onmouseleave={handleMouseUp}
1177
- onpointerdown={handlePaintPointerDown}
1178
- onpointermove={handlePaintPointerMove}
1179
- onpointerup={handlePaintPointerUp}
1180
- onpointercancel={() => paintGesture.cancel()}
1181
- data-testid="image-editor-canvas"
1182
- style={transformActive ? `cursor: ${transformCursor}` : undefined}
1183
- class="absolute inset-0 w-full h-full block {transformActive ? 'touch-none' : activeTab === 'paint' ? 'touch-none cursor-crosshair' : activeTab === 'annotate' && editor.state.activeTool === 'type' ? 'cursor-text' : activeTab === 'annotate' && editor.state.activeTool === 'shape' ? 'cursor-crosshair' : ''}"
1184
- ></canvas>
1185
- {#if typeSession}
1186
- <textarea
1187
- bind:this={typeInputEl}
1188
- aria-label="Edit text"
1189
- data-testid="type-input"
1190
- autocapitalize="off"
1191
- spellcheck={false}
1192
- onkeydown={handleTypeKeyDown}
1193
- oninput={handleTypeInput}
1194
- oncompositionend={handleTypeComposition}
1195
- class="absolute left-0 top-0 h-px w-px opacity-0"
1196
- ></textarea>
1197
- {/if}
1198
-
1199
- </div>
468
+ <ImageEditorCanvas
469
+ {editor}
470
+ mode={canvasMode}
471
+ {fit}
472
+ {selectionOptions}
473
+ {shapeOptions}
474
+ {typeOptions}
475
+ annotationColor={activeColor}
476
+ annotationWidth={strokeWidth}
477
+ cropRatio={activeCropRatio}
478
+ onerror={handleCanvasError}
479
+ bind:typeSession
480
+ {transformTool}
481
+ {playback}
482
+ {playbackId}
483
+ {onplaybackreveal}
484
+ chromeRoot={() => rootEl}
485
+ open={openPanel}
486
+ class="flex-1 h-[360px] md:h-full min-h-[320px] bg-muted"
487
+ />
1200
488
 
1201
489
  <!-- Tab Controls Sidebar -->
1202
- <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]">
490
+ <div class={imageSidebarClass}>
1203
491
  <!-- TAB 1: CROP & TRANSFORM -->
1204
492
  {#if activeTab === 'crop'}
1205
493
  <div class="flex flex-col gap-4 text-xs">
1206
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
494
+ <span class={imageSectionTitleClass}>
1207
495
  Aspect Ratio Presets
1208
496
  </span>
1209
497
  <div class="grid grid-cols-3 gap-2">
@@ -1214,8 +502,8 @@
1214
502
  editor.applyCropPreset(null);
1215
503
  }}
1216
504
  class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === null && editor.state.crop
1217
- ? 'bg-indigo-600 border-indigo-500 text-white'
1218
- : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
505
+ ? 'bg-primary border-primary text-primary-foreground'
506
+ : 'bg-muted hover:bg-accent text-foreground border-border'}"
1219
507
  >
1220
508
  Freeform
1221
509
  </button>
@@ -1226,8 +514,8 @@
1226
514
  editor.applyCropPreset(1);
1227
515
  }}
1228
516
  class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === 1
1229
- ? 'bg-indigo-600 border-indigo-500 text-white'
1230
- : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
517
+ ? 'bg-primary border-primary text-primary-foreground'
518
+ : 'bg-muted hover:bg-accent text-foreground border-border'}"
1231
519
  >
1232
520
  1:1 Square
1233
521
  </button>
@@ -1238,8 +526,8 @@
1238
526
  editor.applyCropPreset(4 / 3);
1239
527
  }}
1240
528
  class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === 4 / 3
1241
- ? 'bg-indigo-600 border-indigo-500 text-white'
1242
- : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
529
+ ? 'bg-primary border-primary text-primary-foreground'
530
+ : 'bg-muted hover:bg-accent text-foreground border-border'}"
1243
531
  >
1244
532
  4:3 Standard
1245
533
  </button>
@@ -1250,8 +538,8 @@
1250
538
  editor.applyCropPreset(16 / 9);
1251
539
  }}
1252
540
  class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === 16 / 9
1253
- ? 'bg-indigo-600 border-indigo-500 text-white'
1254
- : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
541
+ ? 'bg-primary border-primary text-primary-foreground'
542
+ : 'bg-muted hover:bg-accent text-foreground border-border'}"
1255
543
  >
1256
544
  16:9 Wide
1257
545
  </button>
@@ -1262,8 +550,8 @@
1262
550
  editor.applyCropPreset(3 / 2);
1263
551
  }}
1264
552
  class="px-2.5 py-2 rounded-lg border font-medium {activeCropRatio === 3 / 2
1265
- ? 'bg-indigo-600 border-indigo-500 text-white'
1266
- : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}"
553
+ ? 'bg-primary border-primary text-primary-foreground'
554
+ : 'bg-muted hover:bg-accent text-foreground border-border'}"
1267
555
  >
1268
556
  3:2 Photo
1269
557
  </button>
@@ -1273,29 +561,29 @@
1273
561
  activeCropRatio = null;
1274
562
  editor.resetCrop();
1275
563
  }}
1276
- 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"
564
+ class="px-2.5 py-2 rounded-lg bg-destructive/10 hover:bg-destructive/20 text-destructive border border-destructive/40 font-medium"
1277
565
  >
1278
566
  Clear Crop
1279
567
  </button>
1280
568
  </div>
1281
569
 
1282
- <div class="h-px bg-slate-800 my-1"></div>
570
+ <div class={imageDividerClass}></div>
1283
571
 
1284
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
572
+ <span class={imageSectionTitleClass}>
1285
573
  Orientation & Flips
1286
574
  </span>
1287
575
  <div class="grid grid-cols-2 gap-2">
1288
576
  <button
1289
577
  type="button"
1290
578
  onclick={() => editor.rotate(-90)}
1291
- class="px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700"
579
+ class="px-3 py-2 rounded-lg bg-muted hover:bg-accent text-foreground border border-border"
1292
580
  >
1293
581
  Rotate -90°
1294
582
  </button>
1295
583
  <button
1296
584
  type="button"
1297
585
  onclick={() => editor.rotate(90)}
1298
- class="px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700"
586
+ class="px-3 py-2 rounded-lg bg-muted hover:bg-accent text-foreground border border-border"
1299
587
  >
1300
588
  Rotate +90°
1301
589
  </button>
@@ -1303,8 +591,8 @@
1303
591
  type="button"
1304
592
  onclick={() => editor.flipHorizontal()}
1305
593
  class="px-3 py-2 rounded-lg border transition-colors {editor.state.transform.flipH
1306
- ? 'bg-indigo-600 border-indigo-500 text-white'
1307
- : 'bg-slate-800 border-slate-700 text-slate-200 hover:bg-slate-700'}"
594
+ ? 'bg-primary border-primary text-primary-foreground'
595
+ : 'bg-muted border-border text-foreground hover:bg-accent'}"
1308
596
  >
1309
597
  Flip Horizontal
1310
598
  </button>
@@ -1312,8 +600,8 @@
1312
600
  type="button"
1313
601
  onclick={() => editor.flipVertical()}
1314
602
  class="px-3 py-2 rounded-lg border transition-colors {editor.state.transform.flipV
1315
- ? 'bg-indigo-600 border-indigo-500 text-white'
1316
- : 'bg-slate-800 border-slate-700 text-slate-200 hover:bg-slate-700'}"
603
+ ? 'bg-primary border-primary text-primary-foreground'
604
+ : 'bg-muted border-border text-foreground hover:bg-accent'}"
1317
605
  >
1318
606
  Flip Vertical
1319
607
  </button>
@@ -1325,13 +613,13 @@
1325
613
  {#if activeTab === 'adjust'}
1326
614
  <div class="flex flex-col gap-3 text-xs">
1327
615
  <div class="flex items-center justify-between">
1328
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
616
+ <span class={imageSectionTitleClass}>
1329
617
  Color Adjustments
1330
618
  </span>
1331
619
  <button
1332
620
  type="button"
1333
621
  onclick={() => editor.resetAdjustments()}
1334
- class="text-[10px] text-rose-400 hover:underline"
622
+ class="text-[10px] text-destructive hover:underline"
1335
623
  >
1336
624
  Reset Sliders
1337
625
  </button>
@@ -1348,7 +636,7 @@
1348
636
  />
1349
637
  {#if spec.id === 'tint' && legacyProcess}
1350
638
  <div
1351
- class="flex items-center justify-between gap-2 rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-[10px] text-amber-200"
639
+ class="flex items-center justify-between gap-2 rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-[10px] text-amber-600"
1352
640
  data-testid="image-editor-process-notice"
1353
641
  >
1354
642
  <span>Legacy rendering (process 1). Tint needs the current process.</span>
@@ -1363,7 +651,7 @@
1363
651
  {/if}
1364
652
  {/each}
1365
653
 
1366
- <div class="border-t border-slate-700/60 pt-3">
654
+ <div class={imageSectionClass}>
1367
655
  <AdjustmentLayersPanel
1368
656
  controller={editor.controller}
1369
657
  layers={editor.state.adjustments.layers}
@@ -1376,34 +664,34 @@
1376
664
  <!-- TAB 3: ANNOTATIONS -->
1377
665
  {#if activeTab === 'layers'}
1378
666
  <div class="flex flex-col gap-3 text-xs">
1379
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">Layers</span>
667
+ <span class={imageSectionTitleClass}>Layers</span>
1380
668
  <button
1381
669
  type="button"
1382
670
  title="Edit › Free Transform (Ctrl/Cmd+T): the selected pixels, or the whole active layer"
1383
- class="px-2 py-1 rounded text-[11px] bg-slate-800 text-slate-300 hover:text-white self-start disabled:opacity-40"
671
+ class={imageSmallButtonClass}
1384
672
  disabled={transformActive}
1385
673
  onclick={() => transformTool.begin()}>Free Transform</button>
1386
674
  <LayersPanel controller={editor.controller} />
1387
- {#if effectsShown}<ImageProviderEffectsPanel controller={editor.controller} class="border-t border-slate-700/60 pt-3" />{/if}
1388
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">Colour</span>
675
+ {#if effectsShown}<ImageProviderEffectsPanel controller={editor.controller} class={imageSectionClass} />{/if}
676
+ <span class={imageSectionTitleClass}>Colour</span>
1389
677
  <ColorProfilePanel controller={editor.controller} />
1390
678
  </div>
1391
679
  {/if}
1392
680
 
1393
681
  {#if activeTab === 'paint'}
1394
682
  <div class="flex flex-col gap-3 text-xs">
1395
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">Paint</span>
683
+ <span class={imageSectionTitleClass}>Paint</span>
1396
684
  <BrushOptionsBar controller={editor.controller} />
1397
685
  <button
1398
686
  type="button"
1399
687
  title="Edit › Free Transform (Ctrl/Cmd+T): the selected pixels, or the whole active layer"
1400
- class="px-2 py-1 rounded text-[11px] bg-slate-800 text-slate-300 hover:text-white self-start disabled:opacity-40"
688
+ class={imageSmallButtonClass}
1401
689
  disabled={transformActive}
1402
690
  onclick={() => transformTool.begin()}>Free Transform</button>
1403
691
  {#if paintError}
1404
- <p role="alert" class="text-rose-400" data-testid="paint-error">{paintError}</p>
692
+ <p role="alert" class="text-destructive" data-testid="paint-error">{paintError}</p>
1405
693
  {/if}
1406
- <div class="border-t border-slate-700/60 pt-3">
694
+ <div class={imageSectionClass}>
1407
695
  <LayersPanel controller={editor.controller} />
1408
696
  </div>
1409
697
  </div>
@@ -1411,7 +699,7 @@
1411
699
 
1412
700
  {#if activeTab === 'annotate'}
1413
701
  <div class="flex flex-col gap-3 text-xs">
1414
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">Shape & Type Layers</span>
702
+ <span class={imageSectionTitleClass}>Shape & Type Layers</span>
1415
703
  <div class="grid grid-cols-2 gap-1.5">
1416
704
  {#each ['shape', 'type'] as const as t (t)}
1417
705
  <button
@@ -1419,8 +707,8 @@
1419
707
  aria-pressed={editor.state.activeTool === t}
1420
708
  onclick={() => editor.setTool(t)}
1421
709
  class="py-1.5 px-2 rounded-lg font-medium border transition-colors {editor.state.activeTool === t
1422
- ? 'bg-indigo-600 border-indigo-500 text-white'
1423
- : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}">{t === 'shape' ? 'Shape' : 'Type'}</button>
710
+ ? 'bg-primary border-primary text-primary-foreground'
711
+ : 'bg-muted border-border text-foreground hover:bg-accent'}">{t === 'shape' ? 'Shape' : 'Type'}</button>
1424
712
  {/each}
1425
713
  </div>
1426
714
  {#if editor.state.activeTool === 'shape'}
@@ -1429,15 +717,15 @@
1429
717
  {#if editor.state.activeTool === 'type'}
1430
718
  <TypeOptionsBar controller={editor.controller} options={typeOptions} onchange={(o) => (typeOptions = o)} session={typeSession} />
1431
719
  {#if typeSession}
1432
- <button type="button" onclick={finishTyping} class="self-start rounded bg-slate-800 px-2 py-1 text-[11px] text-slate-200 hover:bg-slate-700">Done editing text</button>
720
+ <button type="button" onclick={finishTyping} class="self-start rounded bg-muted px-2 py-1 text-[11px] text-foreground hover:bg-accent">Done editing text</button>
1433
721
  {/if}
1434
722
  {/if}
1435
723
  {#if shapeTypeError}
1436
- <p role="alert" class="text-rose-400" data-testid="shape-type-error">{shapeTypeError}</p>
724
+ <p role="alert" class="text-destructive" data-testid="shape-type-error">{shapeTypeError}</p>
1437
725
  {/if}
1438
- <div class="h-px bg-slate-800 my-1"></div>
726
+ <div class={imageDividerClass}></div>
1439
727
 
1440
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
728
+ <span class={imageSectionTitleClass}>
1441
729
  Draw & Annotate
1442
730
  </span>
1443
731
 
@@ -1448,8 +736,8 @@
1448
736
  type="button"
1449
737
  onclick={() => editor.setTool(t)}
1450
738
  class="py-1.5 px-2 rounded-lg font-medium capitalize border transition-colors {editor.state.activeTool === t
1451
- ? 'bg-indigo-600 border-indigo-500 text-white'
1452
- : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}"
739
+ ? 'bg-primary border-primary text-primary-foreground'
740
+ : 'bg-muted border-border text-foreground hover:bg-accent'}"
1453
741
  >
1454
742
  {t}
1455
743
  </button>
@@ -1458,14 +746,14 @@
1458
746
 
1459
747
  <!-- Color & Size -->
1460
748
  <div class="flex items-center gap-2 mt-2">
1461
- <span class="text-slate-400">Color:</span>
749
+ <span class="text-muted-foreground">Color:</span>
1462
750
  <input
1463
751
  type="color"
1464
752
  bind:value={activeColor}
1465
- class="w-8 h-8 rounded border border-slate-700 cursor-pointer bg-transparent"
753
+ class="w-8 h-8 rounded border border-border cursor-pointer bg-transparent"
1466
754
  />
1467
755
  <div class="flex-1 ml-2">
1468
- <div class="flex justify-between text-slate-400 mb-0.5">
756
+ <div class="flex justify-between text-muted-foreground mb-0.5">
1469
757
  <span>Width:</span>
1470
758
  <span class="font-mono">{strokeWidth}px</span>
1471
759
  </div>
@@ -1474,32 +762,32 @@
1474
762
  min="1"
1475
763
  max="20"
1476
764
  bind:value={strokeWidth}
1477
- class="w-full accent-indigo-500"
765
+ class={imageRangeClass}
1478
766
  />
1479
767
  </div>
1480
768
  </div>
1481
769
 
1482
- <div class="h-px bg-slate-800 my-1"></div>
770
+ <div class={imageDividerClass}></div>
1483
771
 
1484
772
  <!-- Text Tool -->
1485
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">Add Text</span>
773
+ <span class={imageSectionTitleClass}>Add Text</span>
1486
774
  <div class="flex gap-2">
1487
775
  <input
1488
776
  type="text"
1489
777
  placeholder="Type watermark or label..."
1490
778
  bind:value={textInput}
1491
779
  onkeydown={(e) => e.key === 'Enter' && handleAddText()}
1492
- 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"
780
+ class="flex-1 bg-background border border-border rounded-lg px-2.5 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary"
1493
781
  />
1494
782
  <button
1495
783
  type="button"
1496
784
  onclick={handleAddText}
1497
- class="px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium shadow"
785
+ class="px-3 py-1.5 rounded-lg bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow"
1498
786
  >
1499
787
  Add
1500
788
  </button>
1501
789
  </div>
1502
- <div class="flex items-center justify-between text-slate-400">
790
+ <div class="flex items-center justify-between text-muted-foreground">
1503
791
  <span>Font Size:</span>
1504
792
  <span class="font-mono">{fontSize}px</span>
1505
793
  </div>
@@ -1508,18 +796,18 @@
1508
796
  min="12"
1509
797
  max="72"
1510
798
  bind:value={fontSize}
1511
- class="w-full accent-indigo-500"
799
+ class={imageRangeClass}
1512
800
  />
1513
801
 
1514
802
  <!-- Layers & Selected Annotation -->
1515
- <div class="h-px bg-slate-800 my-1"></div>
803
+ <div class={imageDividerClass}></div>
1516
804
  <div class="flex items-center justify-between">
1517
- <span class="text-slate-400">Layers ({editor.state.annotations.length})</span>
805
+ <span class="text-muted-foreground">Layers ({editor.state.annotations.length})</span>
1518
806
  {#if editor.state.selectedAnnotationId}
1519
807
  <button
1520
808
  type="button"
1521
809
  onclick={() => editor.removeAnnotation(editor.state.selectedAnnotationId!)}
1522
- class="text-rose-400 hover:underline text-[11px]"
810
+ class="text-destructive hover:underline text-[11px]"
1523
811
  >
1524
812
  Delete Selected
1525
813
  </button>
@@ -1528,7 +816,7 @@
1528
816
  <button
1529
817
  type="button"
1530
818
  onclick={() => editor.clearAnnotations()}
1531
- class="text-slate-400 hover:text-slate-200 text-[11px]"
819
+ class="text-muted-foreground hover:text-foreground text-[11px]"
1532
820
  >
1533
821
  Clear All
1534
822
  </button>
@@ -1541,37 +829,37 @@
1541
829
  {#if activeTab === 'depth'}
1542
830
  <div class="flex flex-col gap-3 text-xs">
1543
831
  <div class="flex items-center justify-between">
1544
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">
832
+ <span class={imageSectionTitleClass}>
1545
833
  Depth Masking
1546
834
  </span>
1547
835
  <button
1548
836
  type="button"
1549
837
  onclick={() => editor.resetDepthMask()}
1550
- class="text-[10px] text-rose-400 hover:underline"
838
+ class="text-[10px] text-destructive hover:underline"
1551
839
  >
1552
840
  Reset Mask
1553
841
  </button>
1554
842
  </div>
1555
843
 
1556
- <label class="flex items-center gap-2 cursor-pointer text-slate-300">
844
+ <label class="flex items-center gap-2 cursor-pointer text-foreground">
1557
845
  <input
1558
846
  type="checkbox"
1559
847
  checked={editor.state.depthMask.enabled}
1560
848
  onchange={(e) => editor.setDepthMask({ enabled: (e.target as HTMLInputElement).checked })}
1561
- class="rounded accent-indigo-500"
849
+ class="rounded accent-primary"
1562
850
  />
1563
851
  <span>Enable Depth / Alpha Mask</span>
1564
852
  </label>
1565
853
 
1566
854
  {#if editor.state.depthMask.enabled}
1567
855
  <div>
1568
- <span class="text-slate-400 block mb-1">Mask Image URL:</span>
856
+ <span class="text-muted-foreground block mb-1">Mask Image URL:</span>
1569
857
  <input
1570
858
  type="text"
1571
859
  placeholder="https://.../depthmap.png"
1572
860
  value={editor.state.depthMask.maskSource || ''}
1573
861
  oninput={(e) => editor.setDepthMask({ maskSource: (e.target as HTMLInputElement).value || null })}
1574
- 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"
862
+ class="w-full bg-background border border-border rounded-lg px-2.5 py-1.5 text-xs text-foreground placeholder:text-muted-foreground"
1575
863
  />
1576
864
  </div>
1577
865
 
@@ -1580,8 +868,8 @@
1580
868
  type="button"
1581
869
  onclick={() => editor.setDepthMask({ mode: 'threshold' })}
1582
870
  class="flex-1 py-1.5 rounded-lg border text-xs font-medium {editor.state.depthMask.mode === 'threshold'
1583
- ? 'bg-indigo-600 border-indigo-500 text-white'
1584
- : 'bg-slate-800 border-slate-700 text-slate-300'}"
871
+ ? 'bg-primary border-primary text-primary-foreground'
872
+ : 'bg-muted border-border text-foreground'}"
1585
873
  >
1586
874
  Threshold Mode
1587
875
  </button>
@@ -1589,15 +877,15 @@
1589
877
  type="button"
1590
878
  onclick={() => editor.setDepthMask({ mode: 'brightness' })}
1591
879
  class="flex-1 py-1.5 rounded-lg border text-xs font-medium {editor.state.depthMask.mode === 'brightness'
1592
- ? 'bg-indigo-600 border-indigo-500 text-white'
1593
- : 'bg-slate-800 border-slate-700 text-slate-300'}"
880
+ ? 'bg-primary border-primary text-primary-foreground'
881
+ : 'bg-muted border-border text-foreground'}"
1594
882
  >
1595
883
  Brightness Mode
1596
884
  </button>
1597
885
  </div>
1598
886
 
1599
887
  <div>
1600
- <div class="flex justify-between text-slate-400 mb-1">
888
+ <div class="flex justify-between text-muted-foreground mb-1">
1601
889
  <span>Depth Start (Lower):</span>
1602
890
  <span class="font-mono">{editor.state.depthMask.depthRange[0]}</span>
1603
891
  </div>
@@ -1610,12 +898,12 @@
1610
898
  editor.setDepthMask({
1611
899
  depthRange: [Number((e.target as HTMLInputElement).value), editor.state.depthMask.depthRange[1]],
1612
900
  })}
1613
- class="w-full accent-indigo-500"
901
+ class={imageRangeClass}
1614
902
  />
1615
903
  </div>
1616
904
 
1617
905
  <div>
1618
- <div class="flex justify-between text-slate-400 mb-1">
906
+ <div class="flex justify-between text-muted-foreground mb-1">
1619
907
  <span>Depth Stop (Upper):</span>
1620
908
  <span class="font-mono">{editor.state.depthMask.depthRange[1]}</span>
1621
909
  </div>
@@ -1628,12 +916,12 @@
1628
916
  editor.setDepthMask({
1629
917
  depthRange: [editor.state.depthMask.depthRange[0], Number((e.target as HTMLInputElement).value)],
1630
918
  })}
1631
- class="w-full accent-indigo-500"
919
+ class={imageRangeClass}
1632
920
  />
1633
921
  </div>
1634
922
 
1635
923
  <div>
1636
- <div class="flex justify-between text-slate-400 mb-1">
924
+ <div class="flex justify-between text-muted-foreground mb-1">
1637
925
  <span>Softness:</span>
1638
926
  <span class="font-mono">{editor.state.depthMask.softness}</span>
1639
927
  </div>
@@ -1643,16 +931,16 @@
1643
931
  max="50"
1644
932
  value={editor.state.depthMask.softness}
1645
933
  oninput={(e) => editor.setDepthMask({ softness: Number((e.target as HTMLInputElement).value) })}
1646
- class="w-full accent-indigo-500"
934
+ class={imageRangeClass}
1647
935
  />
1648
936
  </div>
1649
937
 
1650
- <label class="flex items-center gap-2 cursor-pointer text-slate-300 mt-1">
938
+ <label class="flex items-center gap-2 cursor-pointer text-foreground mt-1">
1651
939
  <input
1652
940
  type="checkbox"
1653
941
  checked={editor.state.depthMask.invert}
1654
942
  onchange={(e) => editor.setDepthMask({ invert: (e.target as HTMLInputElement).checked })}
1655
- class="rounded accent-indigo-500"
943
+ class="rounded accent-primary"
1656
944
  />
1657
945
  <span>Invert Mask</span>
1658
946
  </label>
@@ -1671,19 +959,19 @@
1671
959
  quickMask={Boolean(editor.state.quickMask)}
1672
960
  />
1673
961
  {#if selectionError}
1674
- <p role="alert" class="text-rose-400">{selectionError}</p>
962
+ <p role="alert" class="text-destructive">{selectionError}</p>
1675
963
  {/if}
1676
964
  <div class="flex gap-1">
1677
965
  <button
1678
966
  type="button"
1679
967
  title="Select › Transform Selection: the outline only"
1680
- class="px-2 py-1 rounded text-[11px] bg-slate-800 text-slate-300 hover:text-white self-start disabled:opacity-40"
968
+ class={imageSmallButtonClass}
1681
969
  disabled={transformActive}
1682
970
  onclick={() => transformTool.begin({ content: 'selection-outline' })}>Transform Selection</button>
1683
971
  <button
1684
972
  type="button"
1685
973
  title="Edit › Free Transform: the selected pixels of the active layer"
1686
- class="px-2 py-1 rounded text-[11px] bg-slate-800 text-slate-300 hover:text-white self-start disabled:opacity-40"
974
+ class={imageSmallButtonClass}
1687
975
  disabled={transformActive}
1688
976
  onclick={() => transformTool.begin({ content: 'selection' })}>Free Transform Pixels</button>
1689
977
  </div>
@@ -1698,12 +986,12 @@
1698
986
  <!-- TAB 5: AI TOOLS / INPAINTING & BACKGROUND REMOVAL -->
1699
987
  {#if activeTab === 'ai'}
1700
988
  <div class="flex flex-col gap-4 text-xs">
1701
- <label class="flex items-center justify-between gap-2 text-slate-300">
989
+ <label class="flex items-center justify-between gap-2 text-foreground">
1702
990
  <span>Results go to</span>
1703
991
  <select
1704
992
  aria-label="AI result target"
1705
993
  bind:value={aiTarget}
1706
- class="bg-slate-800 border border-slate-700 rounded px-1.5 py-1"
994
+ class={imageSelectClass}
1707
995
  >
1708
996
  <option value="auto">{editor.controller.isMultiLayer ? 'Auto (new layer)' : 'Auto (replace image)'}</option>
1709
997
  <option value="new-layer">New layer / mask</option>
@@ -1711,22 +999,22 @@
1711
999
  </select>
1712
1000
  </label>
1713
1001
  <!-- Background Removal Section -->
1714
- <div class="p-3 rounded-lg bg-slate-950/60 border border-slate-800 flex flex-col gap-2.5">
1002
+ <div class="p-3 rounded-lg bg-muted/40 border border-border flex flex-col gap-2.5">
1715
1003
  <div class="flex items-center justify-between">
1716
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px] flex items-center gap-1.5">
1717
- <svg class="w-3.5 h-3.5 text-indigo-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1004
+ <span class="font-semibold text-foreground uppercase tracking-wider text-[11px] flex items-center gap-1.5">
1005
+ <svg class="w-3.5 h-3.5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1718
1006
  <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"/>
1719
1007
  </svg>
1720
1008
  Background Removal
1721
1009
  </span>
1722
1010
  {#if isRemovingBg}
1723
- <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">
1011
+ <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/15 text-primary animate-pulse">
1724
1012
  Removing...
1725
1013
  </span>
1726
1014
  {/if}
1727
1015
  </div>
1728
1016
 
1729
- <p class="text-slate-400 text-[11px] leading-relaxed">
1017
+ <p class="text-muted-foreground text-[11px] leading-relaxed">
1730
1018
  Isolate foreground subject with AI alpha transparency segmentation.
1731
1019
  </p>
1732
1020
 
@@ -1734,10 +1022,10 @@
1734
1022
  type="button"
1735
1023
  onclick={handleRemoveBackground}
1736
1024
  disabled={isRemovingBg || isInpainting}
1737
- 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"
1025
+ class="mt-1 px-3 py-2 rounded-lg bg-primary hover:bg-primary/90 disabled:opacity-50 text-primary-foreground font-medium shadow transition-colors flex items-center justify-center gap-2"
1738
1026
  >
1739
1027
  {#if isRemovingBg}
1740
- <div class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
1028
+ <div class={imageSpinnerClass}></div>
1741
1029
  Removing Background...
1742
1030
  {:else}
1743
1031
  <svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1749,46 +1037,46 @@
1749
1037
  </div>
1750
1038
 
1751
1039
  <!-- AI Inpainting / Generative Fill Section -->
1752
- <div class="p-3 rounded-lg bg-slate-950/60 border border-slate-800 flex flex-col gap-2.5">
1040
+ <div class="p-3 rounded-lg bg-muted/40 border border-border flex flex-col gap-2.5">
1753
1041
  <div class="flex items-center justify-between">
1754
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px] flex items-center gap-1.5">
1755
- <svg class="w-3.5 h-3.5 text-purple-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1042
+ <span class="font-semibold text-foreground uppercase tracking-wider text-[11px] flex items-center gap-1.5">
1043
+ <svg class="w-3.5 h-3.5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1756
1044
  <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"/>
1757
1045
  <path d="m14 7 3 3"/>
1758
1046
  </svg>
1759
1047
  Generative Inpainting
1760
1048
  </span>
1761
1049
  {#if isInpainting}
1762
- <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">
1050
+ <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/15 text-primary animate-pulse">
1763
1051
  Inpainting...
1764
1052
  </span>
1765
1053
  {/if}
1766
1054
  </div>
1767
1055
 
1768
- <p class="text-slate-400 text-[11px] leading-relaxed">
1056
+ <p class="text-muted-foreground text-[11px] leading-relaxed">
1769
1057
  Replace, modify, or fill selected regions using prompt-guided generative inpainting.
1770
1058
  </p>
1771
1059
 
1772
1060
  <div class="flex flex-col gap-1">
1773
- <label for="ai-inpaint-prompt" class="text-slate-300 text-[11px] font-medium">Prompt</label>
1061
+ <label for="ai-inpaint-prompt" class="text-foreground text-[11px] font-medium">Prompt</label>
1774
1062
  <textarea
1775
1063
  id="ai-inpaint-prompt"
1776
1064
  bind:value={inpaintPrompt}
1777
1065
  placeholder="e.g. replace sunglasses with steampunk goggles, remove person in background..."
1778
1066
  rows="3"
1779
- 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"
1067
+ class="w-full bg-background border border-border rounded-lg p-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary resize-none"
1780
1068
  ></textarea>
1781
1069
  </div>
1782
1070
 
1783
1071
  <div class="flex flex-col gap-1">
1784
- <span class="text-slate-300 text-[11px] font-medium">Target Mask Region</span>
1072
+ <span class="text-foreground text-[11px] font-medium">Target Mask Region</span>
1785
1073
  <div class="grid grid-cols-2 gap-1.5">
1786
1074
  <button
1787
1075
  type="button"
1788
1076
  onclick={() => inpaintMaskMode = 'annotations'}
1789
1077
  class="px-2 py-1.5 rounded border text-[11px] font-medium text-left transition-colors {inpaintMaskMode === 'annotations'
1790
- ? 'bg-purple-600/30 border-purple-500 text-purple-200'
1791
- : 'bg-slate-900 border-slate-700 text-slate-400 hover:text-slate-200'}"
1078
+ ? 'bg-primary/30 border-primary text-primary'
1079
+ : 'bg-background border-border text-muted-foreground hover:text-foreground'}"
1792
1080
  >
1793
1081
  Drawn Shapes ({editor.state.annotations.length})
1794
1082
  </button>
@@ -1796,8 +1084,8 @@
1796
1084
  type="button"
1797
1085
  onclick={() => inpaintMaskMode = 'crop'}
1798
1086
  class="px-2 py-1.5 rounded border text-[11px] font-medium text-left transition-colors {inpaintMaskMode === 'crop'
1799
- ? 'bg-purple-600/30 border-purple-500 text-purple-200'
1800
- : 'bg-slate-900 border-slate-700 text-slate-400 hover:text-slate-200'}"
1087
+ ? 'bg-primary/30 border-primary text-primary'
1088
+ : 'bg-background border-border text-muted-foreground hover:text-foreground'}"
1801
1089
  >
1802
1090
  Crop Box ({editor.state.crop ? 'Selected' : 'Full'})
1803
1091
  </button>
@@ -1808,10 +1096,10 @@
1808
1096
  type="button"
1809
1097
  onclick={handleInpaint}
1810
1098
  disabled={isInpainting || isRemovingBg || !inpaintPrompt.trim()}
1811
- 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"
1099
+ class="mt-1 px-3 py-2 rounded-lg bg-primary hover:bg-primary/90 disabled:opacity-50 text-primary-foreground font-medium shadow transition-colors flex items-center justify-center gap-2"
1812
1100
  >
1813
1101
  {#if isInpainting}
1814
- <div class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
1102
+ <div class={imageSpinnerClass}></div>
1815
1103
  Generating Inpaint...
1816
1104
  {:else}
1817
1105
  <svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1823,13 +1111,13 @@
1823
1111
  </div>
1824
1112
 
1825
1113
  {#if aiError}
1826
- <div class="p-2.5 rounded-lg bg-rose-950/60 border border-rose-800 text-rose-300 text-[11px]">
1114
+ <div class="p-2.5 rounded-lg bg-destructive/10 border border-destructive/40 text-destructive text-[11px]">
1827
1115
  {aiError}
1828
1116
  </div>
1829
1117
  {/if}
1830
1118
 
1831
1119
  {#if aiSuccess}
1832
- <div class="p-2.5 rounded-lg bg-emerald-950/60 border border-emerald-800 text-emerald-300 text-[11px]">
1120
+ <div class="p-2.5 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-600 text-[11px]">
1833
1121
  {aiSuccess}
1834
1122
  </div>
1835
1123
  {/if}