@pixerate/editor-svelte 0.133.3 → 0.135.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.
@@ -101,7 +101,7 @@
101
101
  }
102
102
 
103
103
  function addLayer() {
104
- selectedId = controller.addAdjustmentLayer(newKind);
104
+ selectedId = controller.addAdjustmentLayer({ kind: newKind, useSelection: true });
105
105
  channel = 'master';
106
106
  range = -1;
107
107
  }
@@ -954,6 +954,7 @@
954
954
  onOptionsChange={(next) => (selectionOptions = next)}
955
955
  selection={editor.state.selection ?? null}
956
956
  quickMask={Boolean(editor.state.quickMask)}
957
+ selectionDisplay={editor.state.selectionDisplay}
957
958
  />
958
959
  {#if selectionError}
959
960
  <p role="alert" class="text-destructive">{selectionError}</p>
@@ -23,6 +23,7 @@
23
23
  measureCanvasViewport,
24
24
  watchCanvasViewport,
25
25
  startMarchingAnts,
26
+ handleSelectionShortcut,
26
27
  ShapeDrawGesture,
27
28
  TypeToolGesture,
28
29
  DEFAULT_SHAPE_TOOL_OPTIONS,
@@ -594,6 +595,28 @@
594
595
  if (paintGesture.error) report('paint', paintGesture.error);
595
596
  }
596
597
 
598
+ $effect(() => {
599
+ if (mode !== 'select' || typeof window === 'undefined') return;
600
+ const gesture = selectionGesture;
601
+ // The polygonal lasso: Enter closes it, Escape cancels, Backspace removes the last corner.
602
+ const onKey = (e: KeyboardEvent) => {
603
+ const target = e.target as HTMLElement | null;
604
+ if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
605
+ // Leave Copy to the page while text on it is selected.
606
+ if (window.getSelection?.()?.toString()) return;
607
+ if (handleSelectionShortcut(editor.controller, e, (message) => report('select', message))) {
608
+ e.preventDefault();
609
+ return;
610
+ }
611
+ if (gesture.key(e.key)) {
612
+ e.preventDefault();
613
+ selectionDraft = gesture.draftPath;
614
+ }
615
+ };
616
+ window.addEventListener('keydown', onKey);
617
+ return () => window.removeEventListener('keydown', onKey);
618
+ });
619
+
597
620
  $effect(() => {
598
621
  if (mode !== 'paint' || typeof window === 'undefined') return;
599
622
  const c = editor.controller;
@@ -663,7 +686,10 @@
663
686
  if (mode === 'select') {
664
687
  const gesture = selectionGesture;
665
688
  const imgPt = canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false);
666
- void gesture.begin(imgPt, e).then(() => report('select', gesture.error));
689
+ void gesture.begin(imgPt, e).then(() => {
690
+ report('select', gesture.error);
691
+ selectionDraft = gesture.draftPath;
692
+ });
667
693
  return;
668
694
  }
669
695
 
@@ -771,7 +797,7 @@
771
797
 
772
798
  if (mode === 'select') {
773
799
  const gesture = selectionGesture;
774
- if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false))) {
800
+ if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false), e)) {
775
801
  selectionDraft = gesture.draftPath;
776
802
  renderCanvas();
777
803
  }
@@ -924,7 +950,8 @@
924
950
  const gesture = selectionGesture;
925
951
  if (gesture.active) {
926
952
  gesture.end(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false), e);
927
- selectionDraft = null;
953
+ // A polygonal lasso stays open between clicks.
954
+ selectionDraft = gesture.draftPath;
928
955
  renderCanvas();
929
956
  }
930
957
  return;
@@ -162,7 +162,7 @@
162
162
  </div>
163
163
  {#if browse === 'filter'}
164
164
  {@const id = layer.id}
165
- <EffectsBrowser {provider} uses={['filter']} title="Filters" onpick={(e) => ((browse = null), run(() => controller.applyEffectFilter(id, e.id)))} />
165
+ <EffectsBrowser {provider} uses={['filter']} title="Filters" onpick={(e) => ((browse = null), run(() => controller.applyEffectFilter(id, e.id, { useSelection: true })))} />
166
166
  {/if}
167
167
  {/if}
168
168
  {#if layer && all.some((f) => f.kind === 'effect')}
@@ -8,6 +8,8 @@
8
8
  downloadPsd,
9
9
  dropPlacement,
10
10
  dropZone,
11
+ maskThumbnailClick,
12
+ layerThumbnailClick,
11
13
  layerBadge,
12
14
  layerEffectsBadge,
13
15
  layerHasThumbnail,
@@ -333,6 +335,13 @@
333
335
  <canvas
334
336
  use:paint={thumb(layer)}
335
337
  aria-label={`${row.label} pixels`}
338
+ data-testid="layer-thumbnail"
339
+ onclick={(e) => {
340
+ if (e.ctrlKey || e.metaKey) {
341
+ e.stopPropagation();
342
+ attempt(() => layerThumbnailClick(controller, layer, e));
343
+ }
344
+ }}
336
345
  style="width: {thumbnailSize}px; height: {thumbnailSize}px"
337
346
  class={thumbClass(row.active && snapshot.activeTarget === 'pixels' && Boolean(layer.mask))}
338
347
  ></canvas>
@@ -371,8 +380,7 @@
371
380
  class={thumbClass(row.active && snapshot.activeTarget === 'mask')}
372
381
  onclick={(e) => {
373
382
  e.stopPropagation();
374
- if (e.shiftKey) attempt(() => controller.setLayerMaskProps(layer.id, { enabled: layer.mask!.enabled === false }));
375
- else attempt(() => controller.setActiveLayer(layer.id, 'mask'));
383
+ attempt(() => maskThumbnailClick(controller, layer, e));
376
384
  }}
377
385
  ></canvas>
378
386
  {/if}
@@ -3,12 +3,16 @@
3
3
  import ImageRange from './controls/ImageRange.svelte';
4
4
  import ImageSelect from './controls/ImageSelect.svelte';
5
5
  import type {
6
+ ColorRangePreset,
6
7
  ImageEditorController,
8
+ SelectionDisplay,
7
9
  SelectionMask,
8
10
  SelectionModifyOp,
9
11
  SelectionToolOptions,
10
12
  } from '@pixerate/editor/image-editor';
11
13
  import {
14
+ COLOR_RANGE_PRESETS,
15
+ COLOR_RANGE_PRESET_LABELS,
12
16
  SELECTION_MODES,
13
17
  SELECTION_MODE_LABELS,
14
18
  SELECTION_TOOLS,
@@ -24,10 +28,12 @@
24
28
  selection?: SelectionMask | null;
25
29
  /** The Quick Mask view flag (`state.quickMask`). Omit to follow the controller. */
26
30
  quickMask?: boolean;
31
+ /** How the canvas shows the selection (`state.selectionDisplay`); wins over `quickMask`. */
32
+ selectionDisplay?: SelectionDisplay;
27
33
  class?: string;
28
34
  }
29
35
 
30
- let { controller, options, onOptionsChange, selection, quickMask, class: className = '' }: Props = $props();
36
+ let { controller, options, onOptionsChange, selection, quickMask, selectionDisplay, class: className = '' }: Props = $props();
31
37
 
32
38
  let tick = $state(0);
33
39
  $effect(() => {
@@ -38,10 +44,60 @@
38
44
  void tick;
39
45
  return selection === undefined ? controller.getSelection() : selection;
40
46
  });
41
- const qm = $derived.by(() => {
47
+ const display = $derived.by((): SelectionDisplay => {
42
48
  void tick;
43
- return quickMask ?? Boolean(controller.getState().quickMask);
49
+ return selectionDisplay ?? (quickMask === undefined ? controller.getSelectionDisplay() : quickMask ? 'quickMask' : 'ants');
44
50
  });
51
+ const DISPLAYS: { id: SelectionDisplay; label: string }[] = [
52
+ { id: 'ants', label: 'Ants' },
53
+ { id: 'quickMask', label: 'Quick Mask' },
54
+ { id: 'none', label: 'Hidden' },
55
+ ];
56
+ const numberInput = 'w-14 bg-background border border-border rounded px-1 py-0.5 text-foreground';
57
+
58
+ // Color Range presets.
59
+ let preset = $state<ColorRangePreset>('reds');
60
+ let rangeStatus = $state<string | null>(null);
61
+ const canReselect = $derived.by(() => {
62
+ void tick;
63
+ void sel;
64
+ return controller.canReselect();
65
+ });
66
+ let colorStatus = $state<string | null>(null);
67
+ let strokeWidth = $state(4);
68
+ let strokeLocation = $state<'inside' | 'center' | 'outside'>('outside');
69
+ function edit(fn: () => unknown) {
70
+ colorStatus = null;
71
+ try {
72
+ fn();
73
+ } catch (err) {
74
+ colorStatus = err instanceof Error ? err.message : String(err);
75
+ }
76
+ }
77
+ async function runColor(op: 'grow' | 'similar') {
78
+ colorStatus = null;
79
+ const opts = { tolerance: options.tolerance, sampleMerged: options.sampleMerged, sample: options.sample };
80
+ try {
81
+ await (op === 'grow' ? controller.growSelection(opts) : controller.selectSimilar(opts));
82
+ } catch (err) {
83
+ colorStatus = err instanceof Error ? err.message : String(err);
84
+ }
85
+ }
86
+ async function applyPreset() {
87
+ rangeStatus = null;
88
+ try {
89
+ await controller.selectColorRangePreset(preset, {
90
+ mode: options.mode,
91
+ feather: options.feather ?? 0,
92
+ invert: options.rangeInvert ?? false,
93
+ falloff: options.fuzziness ?? 20,
94
+ sampleMerged: options.sampleMerged,
95
+ sample: options.sample,
96
+ });
97
+ } catch (err) {
98
+ rangeStatus = err instanceof Error ? err.message : String(err);
99
+ }
100
+ }
45
101
 
46
102
  let radius = $state(2);
47
103
 
@@ -69,8 +125,8 @@
69
125
  }
70
126
 
71
127
  const MODIFY_OPS: { op: SelectionModifyOp; label: string }[] = [
72
- { op: 'grow', label: 'Grow' },
73
- { op: 'shrink', label: 'Shrink' },
128
+ { op: 'expand', label: 'Expand' },
129
+ { op: 'contract', label: 'Contract' },
74
130
  { op: 'feather', label: 'Feather' },
75
131
  { op: 'border', label: 'Border' },
76
132
  { op: 'smooth', label: 'Smooth' },
@@ -114,6 +170,18 @@
114
170
  <ImageCheckbox label="Contiguous" checked={options.contiguous} onchange={(on) => set({ contiguous: on })} />
115
171
  Contiguous
116
172
  </label>
173
+ <label class="flex items-center gap-2 text-foreground">
174
+ Distance
175
+ <ImageSelect
176
+ label="Colour distance"
177
+ value={options.distance}
178
+ options={[
179
+ { value: 'rgb', label: 'RGB' },
180
+ { value: 'perceptual', label: 'Perceptual' },
181
+ ]}
182
+ onchange={(v) => set({ distance: v as 'rgb' | 'perceptual' })}
183
+ />
184
+ </label>
117
185
  <label class="flex items-center gap-2 text-foreground">
118
186
  <ImageCheckbox label="Sample adjusted image" checked={options.sampleMerged} onchange={(on) => set({ sampleMerged: on })} />
119
187
  Sample adjusted image
@@ -132,6 +200,64 @@
132
200
  </label>
133
201
  </div>
134
202
  {/if}
203
+ {#if options.tool === 'polygon'}
204
+ <p class="text-muted-foreground">Click to add corners (Shift for 45°), drag for a free-hand stretch; double-click, Enter or a click on the first corner closes it, Backspace removes a corner, Escape cancels.</p>
205
+ {/if}
206
+ {#if options.tool === 'range'}
207
+ <div data-testid="color-range-options" class="flex flex-col gap-2">
208
+ <p class="text-muted-foreground">Click a colour to select it; Shift-click adds a colour, Alt-click removes one.</p>
209
+ <label class="flex flex-col gap-1">
210
+ <span class="flex justify-between text-muted-foreground">
211
+ <span>Fuzziness</span>
212
+ <span class="font-mono">{options.fuzziness ?? 40}</span>
213
+ </span>
214
+ <ImageRange label="Fuzziness" min={0} max={200} step={1} value={options.fuzziness ?? 40} oninput={(v) => set({ fuzziness: v })} />
215
+ </label>
216
+ <label class="flex items-center gap-1 text-muted-foreground">
217
+ Localized radius
218
+ <input
219
+ type="number"
220
+ min="0"
221
+ step="1"
222
+ aria-label="Localized radius"
223
+ value={options.localized ?? 0}
224
+ oninput={(e) => set({ localized: Math.max(0, Number((e.currentTarget as HTMLInputElement).value) || 0) })}
225
+ class={numberInput}
226
+ />
227
+ px
228
+ </label>
229
+ <label class="flex items-center gap-2 text-foreground">
230
+ <ImageCheckbox label="Invert" checked={options.rangeInvert ?? false} onchange={(on) => set({ rangeInvert: on })} />
231
+ Invert
232
+ </label>
233
+ <div class="flex items-center gap-1">
234
+ <ImageSelect
235
+ label="Color Range preset"
236
+ value={preset}
237
+ options={COLOR_RANGE_PRESETS.map((p) => ({ value: p, label: COLOR_RANGE_PRESET_LABELS[p] }))}
238
+ onchange={(v) => (preset = v as ColorRangePreset)}
239
+ />
240
+ <button type="button" class={btn(false)} onclick={() => void applyPreset()}>Select</button>
241
+ </div>
242
+ {#if rangeStatus}
243
+ <p role="status" class="text-muted-foreground">{rangeStatus}</p>
244
+ {/if}
245
+ </div>
246
+ {/if}
247
+ <label class="flex items-center gap-1 text-muted-foreground">
248
+ Feather
249
+ <input
250
+ type="number"
251
+ min="0"
252
+ max="1000"
253
+ step="0.5"
254
+ aria-label="Feather"
255
+ value={options.feather ?? 0}
256
+ oninput={(e) => set({ feather: Math.max(0, Number((e.currentTarget as HTMLInputElement).value) || 0) })}
257
+ class={numberInput}
258
+ />
259
+ px
260
+ </label>
135
261
  {#if canSegment}
136
262
  <form
137
263
  data-testid="select-subject"
@@ -167,7 +293,15 @@
167
293
  <button type="button" class={btn(false)} onclick={() => controller.selectAll()}>Select All</button>
168
294
  <button type="button" class={btn(false)} disabled={!sel} onclick={() => controller.deselect()}>Deselect</button>
169
295
  <button type="button" class={btn(false)} onclick={() => controller.invertSelection()}>Inverse</button>
170
- <button type="button" aria-pressed={qm} class={btn(qm)} onclick={() => controller.setQuickMask(!qm)}>Quick Mask</button>
296
+ <button type="button" class={btn(false)} disabled={!canReselect} onclick={() => controller.reselect()}>Reselect</button>
297
+ <button type="button" class={btn(false)} disabled={!sel} title="Add connected pixels of a selected colour (wand tolerance)" onclick={() => void runColor('grow')}>Grow</button>
298
+ <button type="button" class={btn(false)} disabled={!sel} title="Add every pixel of a selected colour (wand tolerance)" onclick={() => void runColor('similar')}>Similar</button>
299
+ </div>
300
+ <div role="group" aria-label="Show selection" class="flex flex-wrap items-center gap-1">
301
+ <span class="text-muted-foreground">Show</span>
302
+ {#each DISPLAYS as d (d.id)}
303
+ <button type="button" aria-pressed={display === d.id} class={btn(display === d.id)} onclick={() => controller.setSelectionDisplay(d.id)}>{d.label}</button>
304
+ {/each}
171
305
  </div>
172
306
  <div class="flex flex-wrap items-center gap-1">
173
307
  <label class="flex items-center gap-1 text-muted-foreground">
@@ -180,12 +314,53 @@
180
314
  aria-label="Modify radius"
181
315
  value={radius}
182
316
  oninput={(e) => (radius = Math.max(0, Number((e.currentTarget as HTMLInputElement).value) || 0))}
183
- class="w-14 bg-background border border-border rounded px-1 py-0.5 text-foreground"
317
+ class={numberInput}
184
318
  />
185
319
  </label>
186
320
  {#each MODIFY_OPS as m (m.op)}
187
321
  <button type="button" class={btn(false)} disabled={!sel} onclick={() => controller.modifySelection(m.op, radius)}>{m.label}</button>
188
322
  {/each}
189
323
  </div>
324
+ <div role="group" aria-label="Edit selection" class="flex flex-wrap items-center gap-1">
325
+ <label class="flex items-center gap-1 text-muted-foreground">
326
+ Stroke
327
+ <input
328
+ type="number"
329
+ min="1"
330
+ max="250"
331
+ step="1"
332
+ aria-label="Stroke width"
333
+ value={strokeWidth}
334
+ oninput={(e) => (strokeWidth = Math.min(250, Math.max(1, Number((e.currentTarget as HTMLInputElement).value) || 1)))}
335
+ class={numberInput}
336
+ />
337
+ px
338
+ </label>
339
+ <ImageSelect
340
+ label="Stroke location"
341
+ value={strokeLocation}
342
+ options={[
343
+ { value: 'outside', label: 'Outside' },
344
+ { value: 'center', label: 'Center' },
345
+ { value: 'inside', label: 'Inside' },
346
+ ]}
347
+ onchange={(v) => (strokeLocation = v as 'inside' | 'center' | 'outside')}
348
+ />
349
+ <button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.strokeSelection({ width: strokeWidth, location: strokeLocation }))}>Stroke</button>
350
+ <button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.cropToSelection())}>Crop to Selection</button>
351
+ <button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.contentAwareFill({ output: 'new-layer' }))}>Content-Aware Fill</button>
352
+ </div>
353
+ <div role="group" aria-label="Clipboard" class="flex flex-wrap gap-1">
354
+ <button type="button" class={btn(false)} onclick={() => edit(() => controller.copyPixels())}>Copy</button>
355
+ <button type="button" class={btn(false)} onclick={() => edit(() => controller.copyPixels({ merged: true }))}>Copy Merged</button>
356
+ <button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.cutPixels())}>Cut</button>
357
+ <button type="button" class={btn(false)} onclick={() => edit(() => controller.pastePixels('new-layer'))}>Paste</button>
358
+ <button type="button" class={btn(false)} onclick={() => edit(() => controller.pastePixels('in-place'))}>Paste in Place</button>
359
+ <button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.pastePixels('into'))}>Paste Into</button>
360
+ <button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.pastePixels('outside'))}>Paste Outside</button>
361
+ </div>
362
+ {#if colorStatus}
363
+ <p role="status" class="text-muted-foreground">{colorStatus}</p>
364
+ {/if}
190
365
  <p data-testid="selection-status" class="text-muted-foreground">{describeSelection(sel)}</p>
191
366
  </div>
@@ -1,4 +1,4 @@
1
- import type { ImageEditorController, SelectionMask, SelectionToolOptions } from '@pixerate/editor/image-editor';
1
+ import type { ImageEditorController, SelectionDisplay, SelectionMask, SelectionToolOptions } from '@pixerate/editor/image-editor';
2
2
  interface Props {
3
3
  controller: ImageEditorController;
4
4
  options: SelectionToolOptions;
@@ -7,6 +7,8 @@ interface Props {
7
7
  selection?: SelectionMask | null;
8
8
  /** The Quick Mask view flag (`state.quickMask`). Omit to follow the controller. */
9
9
  quickMask?: boolean;
10
+ /** How the canvas shows the selection (`state.selectionDisplay`); wins over `quickMask`. */
11
+ selectionDisplay?: SelectionDisplay;
10
12
  class?: string;
11
13
  }
12
14
  declare const SelectionOptionsBar: import("svelte").Component<Props, {}, "">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.133.3",
3
+ "version": "0.135.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.183.0"
168
+ "@pixerate/editor": "0.185.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",