@pixerate/editor-svelte 0.150.0 → 0.151.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,279 @@
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
+
61
+ // Draw the preview whenever it changes.
62
+ $effect(() => {
63
+ void tick;
64
+ const ctx = canvas?.getContext?.('2d');
65
+ if (!canvas || !ctx || !size.width || typeof ImageData === 'undefined') return;
66
+ canvas.width = size.width;
67
+ canvas.height = size.height;
68
+ ctx.putImageData(new ImageData(session.render() as Uint8ClampedArray<ArrayBuffer>, size.width, size.height), 0, 0);
69
+ });
70
+
71
+ const set = (changes: Partial<SelectAndMaskSettings>) => session.update(changes);
72
+
73
+ async function ok() {
74
+ busy = true;
75
+ error = null;
76
+ try {
77
+ onclose(await session.commit());
78
+ } catch (err) {
79
+ error = err instanceof Error ? err.message : String(err);
80
+ busy = false;
81
+ }
82
+ }
83
+
84
+ function onkeydown(e: KeyboardEvent) {
85
+ const t = e.target as HTMLElement | null;
86
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return;
87
+ if (e.key === 'Escape') onclose(null);
88
+ else if (e.key === 'Enter') void ok();
89
+ else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') session.undoEdit();
90
+ else if (e.ctrlKey || e.metaKey || e.altKey || !session.handleKey(e.key)) return;
91
+ e.preventDefault();
92
+ }
93
+
94
+ /** The pointer's position in natural image pixels. */
95
+ function toImage(e: PointerEvent) {
96
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
97
+ const naturalW = size.width / size.scale;
98
+ const naturalH = size.height / size.scale;
99
+ return { x: ((e.clientX - rect.left) / Math.max(1, rect.width)) * naturalW, y: ((e.clientY - rect.top) / Math.max(1, rect.height)) * naturalH };
100
+ }
101
+
102
+ function onpointerdown(e: PointerEvent) {
103
+ if (settings.tool === 'hand') {
104
+ panFrom = { x: e.clientX, y: e.clientY, pan };
105
+ return;
106
+ }
107
+ if (settings.tool === 'zoom') {
108
+ zoom = Math.min(16, Math.max(0.25, e.altKey ? zoom / 1.25 : zoom * 1.25));
109
+ return;
110
+ }
111
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
112
+ session.begin(toImage(e), { altKey: e.altKey });
113
+ }
114
+ function onpointermove(e: PointerEvent) {
115
+ if (panFrom) {
116
+ pan = { x: panFrom.pan.x + e.clientX - panFrom.x, y: panFrom.pan.y + e.clientY - panFrom.y };
117
+ return;
118
+ }
119
+ if (e.buttons) session.move(toImage(e));
120
+ }
121
+ function onpointerup() {
122
+ panFrom = null;
123
+ session.end();
124
+ }
125
+
126
+ const toLayer = $derived(settings.output === 'new-layer' || settings.output === 'new-layer-with-mask');
127
+ const brushTool = $derived(settings.tool === 'quick-select' || settings.tool === 'brush' || settings.tool === 'refine-brush');
128
+
129
+ const button =
130
+ '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';
131
+ const toggle = (on: boolean) => `${button} ${on ? 'bg-primary text-primary-foreground border-primary' : ''}`;
132
+ </script>
133
+
134
+ <svelte:window {onkeydown} />
135
+
136
+ <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}">
137
+ <div role="group" aria-label="Select and Mask tool" class="flex flex-col gap-1 p-2 border-r border-border">
138
+ {#each SELECT_AND_MASK_TOOLS as t (t.id)}
139
+ <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 })}>
140
+ {t.label}
141
+ </button>
142
+ {/each}
143
+ </div>
144
+ <div class="flex-1 flex flex-col min-w-0">
145
+ <div class="flex flex-wrap items-center gap-2 p-2 border-b border-border">
146
+ {#if brushTool}
147
+ <label class="flex items-center gap-1 text-muted-foreground">
148
+ Size
149
+ <input
150
+ type="number"
151
+ min="1"
152
+ aria-label="Brush size"
153
+ value={settings.brushSize}
154
+ oninput={(e) => set({ brushSize: Math.max(1, Number((e.currentTarget as HTMLInputElement).value) || 1) })}
155
+ class="w-16 bg-background border border-border rounded px-1 py-0.5 text-foreground"
156
+ />
157
+ px
158
+ </label>
159
+ {/if}
160
+ {#if settings.tool !== 'refine-brush' && settings.tool !== 'hand' && settings.tool !== 'zoom'}
161
+ <div role="group" aria-label="Stroke mode" class="flex gap-1">
162
+ <button type="button" aria-pressed={settings.brushMode === 'add'} class={toggle(settings.brushMode === 'add')} onclick={() => set({ brushMode: 'add' })}>Add</button>
163
+ <button type="button" aria-pressed={settings.brushMode === 'subtract'} class={toggle(settings.brushMode === 'subtract')} onclick={() => set({ brushMode: 'subtract' })}>
164
+ Subtract
165
+ </button>
166
+ </div>
167
+ {/if}
168
+ <span class="text-muted-foreground">{edits} strokes</span>
169
+ <button type="button" class={button} disabled={!edits} onclick={() => session.undoEdit()}>Undo Stroke</button>
170
+ <span class="font-mono text-muted-foreground">{Math.round(zoom * 100)}%</span>
171
+ </div>
172
+ <div class="flex-1 overflow-hidden flex items-center justify-center bg-muted/30">
173
+ <canvas
174
+ bind:this={canvas}
175
+ data-testid="select-and-mask-preview"
176
+ {onpointerdown}
177
+ {onpointermove}
178
+ {onpointerup}
179
+ style:max-width="100%"
180
+ style:max-height="100%"
181
+ style:transform="translate({pan.x}px, {pan.y}px) scale({zoom})"
182
+ style:image-rendering={zoom > 2 ? 'pixelated' : undefined}
183
+ style:cursor={settings.tool === 'hand' ? 'grab' : settings.tool === 'zoom' ? 'zoom-in' : 'crosshair'}
184
+ ></canvas>
185
+ </div>
186
+ </div>
187
+ <div class="w-64 flex flex-col gap-3 p-3 border-l border-border overflow-y-auto">
188
+ <span class={imageSectionTitleClass}>View Mode</span>
189
+ <label class="flex items-center gap-2">
190
+ View
191
+ <ImageSelect
192
+ label="View"
193
+ value={settings.view}
194
+ options={SELECT_AND_MASK_VIEWS.map((v) => ({ value: v.id, label: `${v.label} (${v.key.toUpperCase()})` }))}
195
+ onchange={(v) => set({ view: v as SelectAndMaskView })}
196
+ />
197
+ </label>
198
+ <label class="flex flex-col gap-1">
199
+ <span class="flex justify-between text-muted-foreground">
200
+ <span>Opacity</span>
201
+ <span class="font-mono">{Math.round(settings.opacity * 100)}%</span>
202
+ </span>
203
+ <ImageRange label="View opacity" min={0} max={100} step={1} value={Math.round(settings.opacity * 100)} oninput={(v) => set({ opacity: v / 100 })} />
204
+ </label>
205
+ <label class="flex items-center gap-2">
206
+ <ImageCheckbox label="Show Edge" checked={settings.showEdge} onchange={(on) => set({ showEdge: on })} />
207
+ Show Edge (J)
208
+ </label>
209
+ <label class="flex items-center gap-2">
210
+ <ImageCheckbox label="Show Original" checked={settings.showOriginal} onchange={(on) => set({ showOriginal: on })} />
211
+ Show Original (P)
212
+ </label>
213
+ <label class="flex items-center gap-2">
214
+ <ImageCheckbox label="Real-Time Refinement" checked={settings.realTime} onchange={(on) => set({ realTime: on })} />
215
+ Real-Time Refinement
216
+ </label>
217
+ <label class="flex items-center gap-2">
218
+ <ImageCheckbox label="High Quality Preview" checked={settings.highQuality} onchange={(on) => set({ highQuality: on })} />
219
+ High Quality Preview
220
+ </label>
221
+
222
+ <span class={imageSectionTitleClass}>Edge Detection and Refinement</span>
223
+ {#if controller.segmentationProvider}
224
+ <label class="flex items-center gap-2">
225
+ Mode
226
+ <ImageSelect
227
+ label="Refine mode"
228
+ value={settings.refineMode}
229
+ options={[
230
+ { value: 'color', label: 'Color Aware' },
231
+ { value: 'object', label: 'Object Aware' },
232
+ ]}
233
+ onchange={(v) => set({ refineMode: v as 'color' | 'object' })}
234
+ />
235
+ </label>
236
+ {/if}
237
+ {#each REFINE_EDGE_CONTROLS as c (c.id)}
238
+ <label class="flex flex-col gap-1">
239
+ <span class="flex justify-between text-muted-foreground">
240
+ <span>{c.label}</span>
241
+ <span class="font-mono">{settings.params[c.id]}{c.unit}</span>
242
+ </span>
243
+ <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 } })} />
244
+ </label>
245
+ {/each}
246
+ <label class="flex items-center gap-2">
247
+ <ImageCheckbox label="Workspace Smart Radius" checked={settings.params.smartRadius} onchange={(on) => set({ params: { ...settings.params, smartRadius: on } })} />
248
+ Smart Radius
249
+ </label>
250
+
251
+ <span class={imageSectionTitleClass}>Output Settings</span>
252
+ <label class="flex flex-col gap-1">
253
+ <span class="flex justify-between text-muted-foreground">
254
+ <span>Decontaminate Colors{toLayer ? '' : ' (new layer outputs)'}</span>
255
+ <span class="font-mono">{settings.decontaminate}%</span>
256
+ </span>
257
+ <ImageRange label="Workspace decontaminate amount" min={0} max={100} step={1} value={settings.decontaminate} disabled={!toLayer} oninput={(v) => set({ decontaminate: v })} />
258
+ </label>
259
+ <label class="flex items-center gap-2">
260
+ Output To
261
+ <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 })} />
262
+ </label>
263
+ <label class="flex items-center gap-2">
264
+ <ImageCheckbox label="Remember Settings" checked={settings.remember} onchange={(on) => set({ remember: on })} />
265
+ Remember Settings
266
+ </label>
267
+ {#if error ?? sessionError}
268
+ <p role="alert" class="text-destructive">{error ?? sessionError}</p>
269
+ {/if}
270
+ {#if note}
271
+ <p class="text-muted-foreground">{note}</p>
272
+ {/if}
273
+ <div class="flex gap-1 mt-auto">
274
+ <button type="button" class={button} onclick={() => session.reset()}>Reset</button>
275
+ <button type="button" class={button} onclick={() => onclose(null)}>Cancel</button>
276
+ <button type="button" class={toggle(true)} disabled={busy} aria-busy={busy} onclick={() => void ok()}>OK</button>
277
+ </div>
278
+ </div>
279
+ </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,
@@ -109,6 +110,7 @@
109
110
  let subject = $state('');
110
111
  let segmenting = $state(false);
111
112
  let subjectStatus = $state<string | null>(null);
113
+ let workspace = $state(false);
112
114
  let skying = $state(false);
113
115
  let skyStatus = $state<string | null>(null);
114
116
  async function selectSky() {
@@ -426,6 +428,10 @@
426
428
  <p role="status" class="text-muted-foreground">{subjectStatus}</p>
427
429
  {/if}
428
430
  </form>
431
+ <button type="button" class={btn(false)} onclick={() => (workspace = true)}>Select and Mask…</button>
432
+ {#if workspace}
433
+ <SelectAndMaskWorkspace {controller} onclose={() => (workspace = false)} />
434
+ {/if}
429
435
  <div data-testid="select-sky" class="flex flex-col gap-1">
430
436
  <button type="button" class={btn(false)} disabled={skying} aria-busy={skying} onclick={() => void selectSky()}>
431
437
  {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.151.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.207.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",