@pixerate/editor-svelte 0.150.0 → 0.152.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ <script lang="ts">
2
+ import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
+ import ImageRange from './controls/ImageRange.svelte';
4
+ import ImageSelect from './controls/ImageSelect.svelte';
5
+ import { imageSectionTitleClass } from './styles.js';
6
+ import type { ImageEditorController, RefineOutput, RefineOutputResult, SelectAndMaskSettings, SelectAndMaskView } from '@pixerate/editor/image-editor';
7
+ import { REFINE_EDGE_CONTROLS, REFINE_OUTPUTS, SELECT_AND_MASK_TOOLS, SELECT_AND_MASK_VIEWS, SelectAndMaskSession } from '@pixerate/editor/image-editor';
8
+ import { untrack } from 'svelte';
9
+
10
+ /**
11
+ * Select and Mask: a full-window workspace over the editor with its own tools, view modes, edge
12
+ * and output settings, and a live preview. OK applies everything as one undo step, the same
13
+ * `refineSelectionTo` call an agent makes; Cancel leaves the document as it was. Keys as in
14
+ * Photoshop, plus Enter (OK), Escape (Cancel) and Ctrl/⌘+Z (undo the last stroke).
15
+ */
16
+ interface Props {
17
+ controller: ImageEditorController;
18
+ /** Called with the result after OK, or `null` after Cancel. */
19
+ onclose: (result: RefineOutputResult | null) => void;
20
+ class?: string;
21
+ }
22
+
23
+ let { controller, onclose, class: className = '' }: Props = $props();
24
+
25
+ const session = untrack(() => new SelectAndMaskSession(controller));
26
+ let tick = $state(0);
27
+ let zoom = $state(1);
28
+ let pan = $state({ x: 0, y: 0 });
29
+ let busy = $state(false);
30
+ let error = $state<string | null>(null);
31
+ let canvas: HTMLCanvasElement | undefined = $state();
32
+ let panFrom: { x: number; y: number; pan: { x: number; y: number } } | null = null;
33
+
34
+ $effect(() => {
35
+ const off = session.subscribe(() => tick++);
36
+ void session.open();
37
+ return off;
38
+ });
39
+
40
+ const settings = $derived.by(() => {
41
+ void tick;
42
+ return session.settings;
43
+ });
44
+ const size = $derived.by(() => {
45
+ void tick;
46
+ return session.previewSize;
47
+ });
48
+ const edits = $derived.by(() => {
49
+ void tick;
50
+ return session.edits.length;
51
+ });
52
+ const sessionError = $derived.by(() => {
53
+ void tick;
54
+ return session.error;
55
+ });
56
+ const note = $derived.by(() => {
57
+ void tick;
58
+ return session.note;
59
+ });
60
+ const computing = $derived.by(() => {
61
+ void tick;
62
+ return session.computing;
63
+ });
64
+
65
+ // Draw the preview whenever it changes.
66
+ $effect(() => {
67
+ void tick;
68
+ const ctx = canvas?.getContext?.('2d');
69
+ if (!canvas || !ctx || !size.width || typeof ImageData === 'undefined') return;
70
+ canvas.width = size.width;
71
+ canvas.height = size.height;
72
+ ctx.putImageData(new ImageData(session.render() as Uint8ClampedArray<ArrayBuffer>, size.width, size.height), 0, 0);
73
+ });
74
+
75
+ const set = (changes: Partial<SelectAndMaskSettings>) => session.update(changes);
76
+
77
+ async function ok() {
78
+ busy = true;
79
+ error = null;
80
+ try {
81
+ onclose(await session.commit());
82
+ } catch (err) {
83
+ error = err instanceof Error ? err.message : String(err);
84
+ busy = false;
85
+ }
86
+ }
87
+
88
+ function onkeydown(e: KeyboardEvent) {
89
+ const t = e.target as HTMLElement | null;
90
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return;
91
+ if (e.key === 'Escape') onclose(null);
92
+ else if (e.key === 'Enter') void ok();
93
+ else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') session.undoEdit();
94
+ else if (e.ctrlKey || e.metaKey || e.altKey || !session.handleKey(e.key)) return;
95
+ e.preventDefault();
96
+ }
97
+
98
+ /** The pointer's position in natural image pixels. */
99
+ function toImage(e: PointerEvent) {
100
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
101
+ const naturalW = size.width / size.scale;
102
+ const naturalH = size.height / size.scale;
103
+ return { x: ((e.clientX - rect.left) / Math.max(1, rect.width)) * naturalW, y: ((e.clientY - rect.top) / Math.max(1, rect.height)) * naturalH };
104
+ }
105
+
106
+ function onpointerdown(e: PointerEvent) {
107
+ if (settings.tool === 'hand') {
108
+ panFrom = { x: e.clientX, y: e.clientY, pan };
109
+ return;
110
+ }
111
+ if (settings.tool === 'zoom') {
112
+ zoom = Math.min(16, Math.max(0.25, e.altKey ? zoom / 1.25 : zoom * 1.25));
113
+ return;
114
+ }
115
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
116
+ session.begin(toImage(e), { altKey: e.altKey });
117
+ }
118
+ function onpointermove(e: PointerEvent) {
119
+ if (panFrom) {
120
+ pan = { x: panFrom.pan.x + e.clientX - panFrom.x, y: panFrom.pan.y + e.clientY - panFrom.y };
121
+ return;
122
+ }
123
+ if (e.buttons) session.move(toImage(e));
124
+ }
125
+ function onpointerup() {
126
+ panFrom = null;
127
+ session.end();
128
+ }
129
+
130
+ const toLayer = $derived(settings.output === 'new-layer' || settings.output === 'new-layer-with-mask');
131
+ const brushTool = $derived(settings.tool === 'quick-select' || settings.tool === 'brush' || settings.tool === 'refine-brush');
132
+
133
+ const button =
134
+ 'px-2 py-1 rounded-md border border-border bg-background text-[11px] text-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40';
135
+ const toggle = (on: boolean) => `${button} ${on ? 'bg-primary text-primary-foreground border-primary' : ''}`;
136
+ </script>
137
+
138
+ <svelte:window {onkeydown} />
139
+
140
+ <div data-testid="select-and-mask-workspace" role="dialog" aria-label="Select and Mask" class="fixed inset-0 z-50 flex bg-background text-xs text-foreground {className}">
141
+ <div role="group" aria-label="Select and Mask tool" class="flex flex-col gap-1 p-2 border-r border-border">
142
+ {#each SELECT_AND_MASK_TOOLS as t (t.id)}
143
+ <button type="button" title="{t.label} ({t.key.toUpperCase()})" aria-pressed={settings.tool === t.id} class={toggle(settings.tool === t.id)} onclick={() => set({ tool: t.id })}>
144
+ {t.label}
145
+ </button>
146
+ {/each}
147
+ </div>
148
+ <div class="flex-1 flex flex-col min-w-0">
149
+ <div class="flex flex-wrap items-center gap-2 p-2 border-b border-border">
150
+ {#if brushTool}
151
+ <label class="flex items-center gap-1 text-muted-foreground">
152
+ Size
153
+ <input
154
+ type="number"
155
+ min="1"
156
+ aria-label="Brush size"
157
+ value={settings.brushSize}
158
+ oninput={(e) => set({ brushSize: Math.max(1, Number((e.currentTarget as HTMLInputElement).value) || 1) })}
159
+ class="w-16 bg-background border border-border rounded px-1 py-0.5 text-foreground"
160
+ />
161
+ px
162
+ </label>
163
+ {/if}
164
+ {#if settings.tool !== 'refine-brush' && settings.tool !== 'hand' && settings.tool !== 'zoom'}
165
+ <div role="group" aria-label="Stroke mode" class="flex gap-1">
166
+ <button type="button" aria-pressed={settings.brushMode === 'add'} class={toggle(settings.brushMode === 'add')} onclick={() => set({ brushMode: 'add' })}>Add</button>
167
+ <button type="button" aria-pressed={settings.brushMode === 'subtract'} class={toggle(settings.brushMode === 'subtract')} onclick={() => set({ brushMode: 'subtract' })}>
168
+ Subtract
169
+ </button>
170
+ </div>
171
+ {/if}
172
+ <span class="text-muted-foreground">{edits} strokes</span>
173
+ <button type="button" class={button} disabled={!edits} onclick={() => session.undoEdit()}>Undo Stroke</button>
174
+ <span class="font-mono text-muted-foreground">{Math.round(zoom * 100)}%</span>
175
+ {#if computing}
176
+ <span role="status" class="text-muted-foreground">Updating preview…</span>
177
+ {/if}
178
+ </div>
179
+ <div class="flex-1 overflow-hidden flex items-center justify-center bg-muted/30">
180
+ <canvas
181
+ bind:this={canvas}
182
+ data-testid="select-and-mask-preview"
183
+ {onpointerdown}
184
+ {onpointermove}
185
+ {onpointerup}
186
+ style:max-width="100%"
187
+ style:max-height="100%"
188
+ style:transform="translate({pan.x}px, {pan.y}px) scale({zoom})"
189
+ style:image-rendering={zoom > 2 ? 'pixelated' : undefined}
190
+ style:cursor={settings.tool === 'hand' ? 'grab' : settings.tool === 'zoom' ? 'zoom-in' : 'crosshair'}
191
+ ></canvas>
192
+ </div>
193
+ </div>
194
+ <div class="w-64 flex flex-col gap-3 p-3 border-l border-border overflow-y-auto">
195
+ <span class={imageSectionTitleClass}>View Mode</span>
196
+ <label class="flex items-center gap-2">
197
+ View
198
+ <ImageSelect
199
+ label="View"
200
+ value={settings.view}
201
+ options={SELECT_AND_MASK_VIEWS.map((v) => ({ value: v.id, label: `${v.label} (${v.key.toUpperCase()})` }))}
202
+ onchange={(v) => set({ view: v as SelectAndMaskView })}
203
+ />
204
+ </label>
205
+ <label class="flex flex-col gap-1">
206
+ <span class="flex justify-between text-muted-foreground">
207
+ <span>Opacity</span>
208
+ <span class="font-mono">{Math.round(settings.opacity * 100)}%</span>
209
+ </span>
210
+ <ImageRange label="View opacity" min={0} max={100} step={1} value={Math.round(settings.opacity * 100)} oninput={(v) => set({ opacity: v / 100 })} />
211
+ </label>
212
+ <label class="flex items-center gap-2">
213
+ <ImageCheckbox label="Show Edge" checked={settings.showEdge} onchange={(on) => set({ showEdge: on })} />
214
+ Show Edge (J)
215
+ </label>
216
+ <label class="flex items-center gap-2">
217
+ <ImageCheckbox label="Show Original" checked={settings.showOriginal} onchange={(on) => set({ showOriginal: on })} />
218
+ Show Original (P)
219
+ </label>
220
+ <label class="flex items-center gap-2">
221
+ <ImageCheckbox label="Real-Time Refinement" checked={settings.realTime} onchange={(on) => set({ realTime: on })} />
222
+ Real-Time Refinement
223
+ </label>
224
+ <label class="flex items-center gap-2">
225
+ <ImageCheckbox label="High Quality Preview" checked={settings.highQuality} onchange={(on) => set({ highQuality: on })} />
226
+ High Quality Preview
227
+ </label>
228
+
229
+ <span class={imageSectionTitleClass}>Edge Detection and Refinement</span>
230
+ {#if controller.segmentationProvider}
231
+ <label class="flex items-center gap-2">
232
+ Mode
233
+ <ImageSelect
234
+ label="Refine mode"
235
+ value={settings.refineMode}
236
+ options={[
237
+ { value: 'color', label: 'Color Aware' },
238
+ { value: 'object', label: 'Object Aware' },
239
+ ]}
240
+ onchange={(v) => set({ refineMode: v as 'color' | 'object' })}
241
+ />
242
+ </label>
243
+ {/if}
244
+ {#each REFINE_EDGE_CONTROLS as c (c.id)}
245
+ <label class="flex flex-col gap-1">
246
+ <span class="flex justify-between text-muted-foreground">
247
+ <span>{c.label}</span>
248
+ <span class="font-mono">{settings.params[c.id]}{c.unit}</span>
249
+ </span>
250
+ <ImageRange label="Workspace {c.label}" min={c.min} max={c.max} step={c.step} value={settings.params[c.id]} oninput={(v) => set({ params: { ...settings.params, [c.id]: v } })} />
251
+ </label>
252
+ {/each}
253
+ <label class="flex items-center gap-2">
254
+ <ImageCheckbox label="Workspace Smart Radius" checked={settings.params.smartRadius} onchange={(on) => set({ params: { ...settings.params, smartRadius: on } })} />
255
+ Smart Radius
256
+ </label>
257
+
258
+ <span class={imageSectionTitleClass}>Output Settings</span>
259
+ <label class="flex flex-col gap-1">
260
+ <span class="flex justify-between text-muted-foreground">
261
+ <span>Decontaminate Colors{toLayer ? '' : ' (new layer outputs)'}</span>
262
+ <span class="font-mono">{settings.decontaminate}%</span>
263
+ </span>
264
+ <ImageRange label="Workspace decontaminate amount" min={0} max={100} step={1} value={settings.decontaminate} disabled={!toLayer} oninput={(v) => set({ decontaminate: v })} />
265
+ </label>
266
+ <label class="flex items-center gap-2">
267
+ Output To
268
+ <ImageSelect label="Output to" value={settings.output} options={REFINE_OUTPUTS.map((o) => ({ value: o.id, label: o.label }))} onchange={(v) => set({ output: v as RefineOutput })} />
269
+ </label>
270
+ <label class="flex items-center gap-2">
271
+ <ImageCheckbox label="Remember Settings" checked={settings.remember} onchange={(on) => set({ remember: on })} />
272
+ Remember Settings
273
+ </label>
274
+ {#if error ?? sessionError}
275
+ <p role="alert" class="text-destructive">{error ?? sessionError}</p>
276
+ {/if}
277
+ {#if note}
278
+ <p class="text-muted-foreground">{note}</p>
279
+ {/if}
280
+ <div class="flex gap-1 mt-auto">
281
+ <button type="button" class={button} onclick={() => session.reset()}>Reset</button>
282
+ <button type="button" class={button} onclick={() => onclose(null)}>Cancel</button>
283
+ <button type="button" class={toggle(true)} disabled={busy} aria-busy={busy} onclick={() => void ok()}>OK</button>
284
+ </div>
285
+ </div>
286
+ </div>
@@ -0,0 +1,16 @@
1
+ import type { ImageEditorController, RefineOutputResult } from '@pixerate/editor/image-editor';
2
+ /**
3
+ * Select and Mask: a full-window workspace over the editor with its own tools, view modes, edge
4
+ * and output settings, and a live preview. OK applies everything as one undo step, the same
5
+ * `refineSelectionTo` call an agent makes; Cancel leaves the document as it was. Keys as in
6
+ * Photoshop, plus Enter (OK), Escape (Cancel) and Ctrl/⌘+Z (undo the last stroke).
7
+ */
8
+ interface Props {
9
+ controller: ImageEditorController;
10
+ /** Called with the result after OK, or `null` after Cancel. */
11
+ onclose: (result: RefineOutputResult | null) => void;
12
+ class?: string;
13
+ }
14
+ declare const SelectAndMaskWorkspace: import("svelte").Component<Props, {}, "">;
15
+ type SelectAndMaskWorkspace = ReturnType<typeof SelectAndMaskWorkspace>;
16
+ export default SelectAndMaskWorkspace;
@@ -2,6 +2,7 @@
2
2
  import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
3
  import ImageRange from './controls/ImageRange.svelte';
4
4
  import ImageSelect from './controls/ImageSelect.svelte';
5
+ import SelectAndMaskWorkspace from './SelectAndMaskWorkspace.svelte';
5
6
  import type {
6
7
  ColorRangePreset,
7
8
  ImageEditorController,
@@ -106,9 +107,24 @@
106
107
  void tick;
107
108
  return Boolean(controller.segmentationProvider);
108
109
  });
110
+ // The long selection running now (Select Subject, Sky, Focus Area): its progress and Cancel.
111
+ let task = $state<{ stage: string; fraction: number } | null>(null);
112
+ let taskAbort: AbortController | null = null;
113
+ function tracked() {
114
+ const abort = new AbortController();
115
+ taskAbort = abort;
116
+ task = { stage: 'Starting', fraction: 0 };
117
+ return { signal: abort.signal, onProgress: (fraction: number, stage: string) => (task = { stage, fraction }) };
118
+ }
119
+ function untracked() {
120
+ taskAbort = null;
121
+ task = null;
122
+ }
123
+ const failure = (err: unknown) => ((err as { name?: string })?.name === 'AbortError' ? 'Cancelled.' : err instanceof Error ? err.message : String(err));
109
124
  let subject = $state('');
110
125
  let segmenting = $state(false);
111
126
  let subjectStatus = $state<string | null>(null);
127
+ let workspace = $state(false);
112
128
  let skying = $state(false);
113
129
  let skyStatus = $state<string | null>(null);
114
130
  async function selectSky() {
@@ -116,11 +132,12 @@
116
132
  skying = true;
117
133
  skyStatus = null;
118
134
  try {
119
- const { note } = await controller.selectSky({ mode: options.mode });
135
+ const { note } = await controller.selectSky({ mode: options.mode, ...tracked() });
120
136
  skyStatus = note ?? null;
121
137
  } catch (err) {
122
- skyStatus = err instanceof Error ? err.message : String(err);
138
+ skyStatus = failure(err);
123
139
  } finally {
140
+ untracked();
124
141
  skying = false;
125
142
  }
126
143
  }
@@ -136,11 +153,13 @@
136
153
  noise: options.focusNoise ?? 0,
137
154
  softness: options.focusSoftness ?? 4,
138
155
  mode: options.mode,
156
+ ...tracked(),
139
157
  });
140
158
  focusStatus = selection ? null : 'No part of the image stands out as in focus.';
141
159
  } catch (err) {
142
- focusStatus = err instanceof Error ? err.message : String(err);
160
+ focusStatus = failure(err);
143
161
  } finally {
162
+ untracked();
144
163
  focusing = false;
145
164
  }
146
165
  }
@@ -150,12 +169,13 @@
150
169
  segmenting = true;
151
170
  subjectStatus = null;
152
171
  try {
153
- const { segments, method, note } = await controller.selectSubject(canSegment ? target : '', { mode: options.mode });
172
+ const { segments, method, note } = await controller.selectSubject(canSegment ? target : '', { mode: options.mode, ...tracked() });
154
173
  // Without a model, say only when nothing stood out.
155
174
  subjectStatus = segments.length ? null : method === 'saliency' ? (note ?? 'Nothing stands out from the background.') : `Nothing matching “${target}” was found.`;
156
175
  } catch (err) {
157
- subjectStatus = err instanceof Error ? err.message : String(err);
176
+ subjectStatus = failure(err);
158
177
  } finally {
178
+ untracked();
159
179
  segmenting = false;
160
180
  }
161
181
  }
@@ -426,6 +446,26 @@
426
446
  <p role="status" class="text-muted-foreground">{subjectStatus}</p>
427
447
  {/if}
428
448
  </form>
449
+ {#if task}
450
+ <div data-testid="selection-progress" class="flex items-center gap-2">
451
+ <div
452
+ role="progressbar"
453
+ aria-label={task.stage}
454
+ aria-valuemin={0}
455
+ aria-valuemax={100}
456
+ aria-valuenow={Math.round(task.fraction * 100)}
457
+ class="flex-1 h-1.5 rounded bg-muted overflow-hidden"
458
+ >
459
+ <div class="h-full bg-primary" style:width="{Math.round(task.fraction * 100)}%"></div>
460
+ </div>
461
+ <span class="text-muted-foreground">{task.stage}</span>
462
+ <button type="button" class={btn(false)} onclick={() => taskAbort?.abort()}>Cancel</button>
463
+ </div>
464
+ {/if}
465
+ <button type="button" class={btn(false)} onclick={() => (workspace = true)}>Select and Mask…</button>
466
+ {#if workspace}
467
+ <SelectAndMaskWorkspace {controller} onclose={() => (workspace = false)} />
468
+ {/if}
429
469
  <div data-testid="select-sky" class="flex flex-col gap-1">
430
470
  <button type="button" class={btn(false)} disabled={skying} aria-busy={skying} onclick={() => void selectSky()}>
431
471
  {skying ? 'Selecting…' : 'Select Sky'}
@@ -8,6 +8,7 @@ import type CurvesEditorComponent from "./CurvesEditor.svelte";
8
8
  import type ControlSliderComponent from "./ControlSlider.svelte";
9
9
  import type SelectionOptionsBarComponent from "./SelectionOptionsBar.svelte";
10
10
  import type RefineEdgePanelComponent from "./RefineEdgePanel.svelte";
11
+ import type SelectAndMaskWorkspaceComponent from "./SelectAndMaskWorkspace.svelte";
11
12
  import type ChannelsPanelComponent from "./ChannelsPanel.svelte";
12
13
  import type LayersPanelComponent from "./LayersPanel.svelte";
13
14
  import type ColorProfilePanelComponent from "./ColorProfilePanel.svelte";
@@ -32,6 +33,7 @@ export { default as CurvesEditor } from './CurvesEditor.svelte';
32
33
  export { default as ControlSlider } from './ControlSlider.svelte';
33
34
  export { default as SelectionOptionsBar } from './SelectionOptionsBar.svelte';
34
35
  export { default as RefineEdgePanel } from './RefineEdgePanel.svelte';
36
+ export { default as SelectAndMaskWorkspace } from './SelectAndMaskWorkspace.svelte';
35
37
  export { default as ChannelsPanel } from './ChannelsPanel.svelte';
36
38
  export { default as LayersPanel } from './LayersPanel.svelte';
37
39
  export { default as ColorProfilePanel } from './ColorProfilePanel.svelte';
@@ -58,6 +60,7 @@ export type CurvesEditorProps = ComponentProps<typeof CurvesEditorComponent>;
58
60
  export type ControlSliderProps = ComponentProps<typeof ControlSliderComponent>;
59
61
  export type SelectionOptionsBarProps = ComponentProps<typeof SelectionOptionsBarComponent>;
60
62
  export type RefineEdgePanelProps = ComponentProps<typeof RefineEdgePanelComponent>;
63
+ export type SelectAndMaskWorkspaceProps = ComponentProps<typeof SelectAndMaskWorkspaceComponent>;
61
64
  export type ChannelsPanelProps = ComponentProps<typeof ChannelsPanelComponent>;
62
65
  export type LayersPanelProps = ComponentProps<typeof LayersPanelComponent>;
63
66
  export type ColorProfilePanelProps = ComponentProps<typeof ColorProfilePanelComponent>;
@@ -10,6 +10,7 @@ export { default as CurvesEditor } from './CurvesEditor.svelte';
10
10
  export { default as ControlSlider } from './ControlSlider.svelte';
11
11
  export { default as SelectionOptionsBar } from './SelectionOptionsBar.svelte';
12
12
  export { default as RefineEdgePanel } from './RefineEdgePanel.svelte';
13
+ export { default as SelectAndMaskWorkspace } from './SelectAndMaskWorkspace.svelte';
13
14
  export { default as ChannelsPanel } from './ChannelsPanel.svelte';
14
15
  export { default as LayersPanel } from './LayersPanel.svelte';
15
16
  export { default as ColorProfilePanel } from './ColorProfilePanel.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.150.0",
3
+ "version": "0.152.0",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -165,7 +165,7 @@
165
165
  "**/*.css"
166
166
  ],
167
167
  "dependencies": {
168
- "@pixerate/editor": "0.206.0"
168
+ "@pixerate/editor": "0.208.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",