@pixerate/editor-svelte 0.133.3 → 0.136.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
  }
@@ -0,0 +1,142 @@
1
+ <script lang="ts">
2
+ import { imageSectionTitleClass } from './styles.js';
3
+ import type { AlphaChannel, ImageEditorController } from '@pixerate/editor/image-editor';
4
+ import { channelThumbnail, selectionModeFromModifiers } from '@pixerate/editor/image-editor';
5
+ import { untrack } from 'svelte';
6
+
7
+ interface Props {
8
+ controller: ImageEditorController;
9
+ /** Thumbnail size in CSS pixels (default 32). */
10
+ thumbnailSize?: number;
11
+ class?: string;
12
+ }
13
+
14
+ let { controller, thumbnailSize = 32, class: className = '' }: Props = $props();
15
+
16
+ let tick = $state(0);
17
+ $effect(() => {
18
+ const c = controller;
19
+ return untrack(() => c.subscribe(() => tick++));
20
+ });
21
+ const snapshot = $derived.by(() => {
22
+ void tick;
23
+ return {
24
+ channels: controller.getChannels(),
25
+ hasSelection: Boolean(controller.getSelection()),
26
+ quickMask: controller.quickMaskEditing,
27
+ };
28
+ });
29
+ let activeId = $state<string | null>(null);
30
+ let renaming = $state<string | null>(null);
31
+ let error = $state<string | null>(null);
32
+ const active = $derived(snapshot.channels.find((c) => c.id === activeId) ?? null);
33
+
34
+ function attempt(fn: () => unknown) {
35
+ error = null;
36
+ try {
37
+ fn();
38
+ } catch (err) {
39
+ error = err instanceof Error ? err.message : String(err);
40
+ }
41
+ }
42
+
43
+ /** Draws a channel's thumbnail (nothing without a 2D context). */
44
+ function thumb(canvas: HTMLCanvasElement, channel: AlphaChannel) {
45
+ const draw = (c: AlphaChannel) => {
46
+ const ctx = canvas.getContext('2d');
47
+ if (!ctx || typeof ctx.createImageData !== 'function') return;
48
+ const px = channelThumbnail(c, 64);
49
+ canvas.width = px.width;
50
+ canvas.height = px.height;
51
+ const img = ctx.createImageData(px.width, px.height);
52
+ img.data.set(px.data);
53
+ ctx.putImageData(img, 0, 0);
54
+ };
55
+ draw(channel);
56
+ return { update: draw };
57
+ }
58
+
59
+ const btn = (on = false) =>
60
+ `px-2 py-1 rounded-md border text-[11px] transition-colors disabled:opacity-40 ${
61
+ on ? 'bg-primary border-primary text-primary-foreground' : 'bg-background border-border text-foreground hover:bg-accent hover:text-accent-foreground'
62
+ }`;
63
+ </script>
64
+
65
+ <!--
66
+ The Channels panel: alpha channels (saved selections) with thumbnails, an eye to show each as a
67
+ tint, click to load it as the selection (Shift adds, Alt subtracts, both intersect), double-click
68
+ to rename; Save Selection, Add to Channel, Duplicate and Delete; and the Quick Mask mode switch.
69
+ -->
70
+ <div data-testid="channels-panel" class="flex flex-col gap-2 text-xs {className}">
71
+ <div class="flex items-center justify-between">
72
+ <span class={imageSectionTitleClass}>Channels</span>
73
+ <button type="button" aria-pressed={snapshot.quickMask} class={btn(snapshot.quickMask)} title="Paint the selection: white selects, black deselects (Q)" onclick={() => controller.setQuickMaskMode(!snapshot.quickMask)}>Quick Mask</button>
74
+ </div>
75
+ {#if snapshot.channels.length === 0}
76
+ <p class="text-muted-foreground">No channels. Save a selection to keep it.</p>
77
+ {/if}
78
+ <ul role="list" class="flex flex-col gap-0.5">
79
+ {#each snapshot.channels as c (c.id)}
80
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
81
+ <li
82
+ data-testid="channel-row"
83
+ aria-current={c.id === activeId ? "true" : undefined}
84
+ class="flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none {c.id === activeId ? 'bg-primary/30 ring-1 ring-primary' : 'hover:bg-muted'}"
85
+ onclick={(e) => {
86
+ activeId = c.id;
87
+ attempt(() => controller.loadSelectionFromChannel(c.id, { mode: selectionModeFromModifiers(e, 'replace') }));
88
+ }}
89
+ ondblclick={() => (renaming = c.id)}
90
+ >
91
+ <button
92
+ type="button"
93
+ aria-label={`${c.visible ? 'Hide' : 'Show'} ${c.name}`}
94
+ aria-pressed={c.visible}
95
+ class="w-4 text-muted-foreground"
96
+ onclick={(e) => {
97
+ e.stopPropagation();
98
+ attempt(() => controller.setChannelProperties(c.id, { visible: !c.visible }));
99
+ }}>{c.visible ? '●' : '○'}</button>
100
+ <canvas use:thumb={c} aria-hidden="true" style="width: {thumbnailSize}px; height: {thumbnailSize}px" class="shrink-0 rounded object-contain ring-1 ring-border bg-black"></canvas>
101
+ {#if renaming === c.id}
102
+ <!-- svelte-ignore a11y_autofocus -->
103
+ <input
104
+ autofocus
105
+ aria-label="Channel name"
106
+ value={c.name}
107
+ class="min-w-0 flex-1 bg-background border border-border rounded px-1 py-0.5 text-foreground"
108
+ onclick={(e) => e.stopPropagation()}
109
+ onblur={(e) => {
110
+ const v = (e.currentTarget as HTMLInputElement).value;
111
+ renaming = null;
112
+ if (v.trim() && v !== c.name) attempt(() => controller.setChannelProperties(c.id, { name: v }));
113
+ }}
114
+ onkeydown={(e) => {
115
+ if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
116
+ if (e.key === 'Escape') renaming = null;
117
+ }}
118
+ />
119
+ {:else}
120
+ <span class="min-w-0 flex-1 truncate">{c.name}</span>
121
+ {/if}
122
+ </li>
123
+ {/each}
124
+ </ul>
125
+ <div class="flex flex-wrap gap-1">
126
+ <button type="button" class={btn()} disabled={!snapshot.hasSelection} onclick={() => attempt(() => (activeId = controller.saveSelection().id))}>Save Selection</button>
127
+ <button type="button" class={btn()} disabled={!active || !snapshot.hasSelection} title="Add the selection to the channel" onclick={() => attempt(() => controller.saveSelection({ channelId: active!.id, mode: 'add' }))}>Add to Channel</button>
128
+ <button type="button" class={btn()} disabled={!active} onclick={() => attempt(() => (activeId = controller.duplicateChannel(active!.id).id))}>Duplicate</button>
129
+ <button
130
+ type="button"
131
+ class={btn()}
132
+ disabled={!active}
133
+ onclick={() =>
134
+ attempt(() => {
135
+ controller.deleteChannel(active!.id);
136
+ activeId = null;
137
+ })}>Delete</button>
138
+ </div>
139
+ {#if error}
140
+ <p role="alert" class="text-destructive">{error}</p>
141
+ {/if}
142
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { ImageEditorController } from '@pixerate/editor/image-editor';
2
+ interface Props {
3
+ controller: ImageEditorController;
4
+ /** Thumbnail size in CSS pixels (default 32). */
5
+ thumbnailSize?: number;
6
+ class?: string;
7
+ }
8
+ declare const ChannelsPanel: import("svelte").Component<Props, {}, "">;
9
+ type ChannelsPanel = ReturnType<typeof ChannelsPanel>;
10
+ export default ChannelsPanel;
@@ -34,6 +34,7 @@
34
34
  import BrushOptionsBar from './BrushOptionsBar.svelte';
35
35
  import SelectionOptionsBar from './SelectionOptionsBar.svelte';
36
36
  import RefineEdgePanel from './RefineEdgePanel.svelte';
37
+ import ChannelsPanel from './ChannelsPanel.svelte';
37
38
  import FreeTransformOptionsBar from './FreeTransformOptionsBar.svelte';
38
39
  import ShapeOptionsBar from './ShapeOptionsBar.svelte';
39
40
  import TypeOptionsBar from './TypeOptionsBar.svelte';
@@ -954,6 +955,7 @@
954
955
  onOptionsChange={(next) => (selectionOptions = next)}
955
956
  selection={editor.state.selection ?? null}
956
957
  quickMask={Boolean(editor.state.quickMask)}
958
+ selectionDisplay={editor.state.selectionDisplay}
957
959
  />
958
960
  {#if selectionError}
959
961
  <p role="alert" class="text-destructive">{selectionError}</p>
@@ -977,6 +979,7 @@
977
979
  selection={editor.state.selection ?? null}
978
980
  layers={editor.state.adjustments.layers}
979
981
  />
982
+ <ChannelsPanel controller={editor.controller} />
980
983
  </div>
981
984
  {/if}
982
985
 
@@ -23,6 +23,8 @@
23
23
  measureCanvasViewport,
24
24
  watchCanvasViewport,
25
25
  startMarchingAnts,
26
+ handleSelectionShortcut,
27
+ handleQuickMaskKey,
26
28
  ShapeDrawGesture,
27
29
  TypeToolGesture,
28
30
  DEFAULT_SHAPE_TOOL_OPTIONS,
@@ -594,6 +596,32 @@
594
596
  if (paintGesture.error) report('paint', paintGesture.error);
595
597
  }
596
598
 
599
+ $effect(() => {
600
+ if (mode !== 'select' || typeof window === 'undefined') return;
601
+ const gesture = selectionGesture;
602
+ // The polygonal lasso: Enter closes it, Escape cancels, Backspace removes the last corner.
603
+ const onKey = (e: KeyboardEvent) => {
604
+ const target = e.target as HTMLElement | null;
605
+ if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
606
+ // Leave Copy to the page while text on it is selected.
607
+ if (window.getSelection?.()?.toString()) return;
608
+ if (handleQuickMaskKey(editor.controller, e)) {
609
+ e.preventDefault();
610
+ return;
611
+ }
612
+ if (handleSelectionShortcut(editor.controller, e, (message) => report('select', message))) {
613
+ e.preventDefault();
614
+ return;
615
+ }
616
+ if (gesture.key(e.key)) {
617
+ e.preventDefault();
618
+ selectionDraft = gesture.draftPath;
619
+ }
620
+ };
621
+ window.addEventListener('keydown', onKey);
622
+ return () => window.removeEventListener('keydown', onKey);
623
+ });
624
+
597
625
  $effect(() => {
598
626
  if (mode !== 'paint' || typeof window === 'undefined') return;
599
627
  const c = editor.controller;
@@ -663,7 +691,10 @@
663
691
  if (mode === 'select') {
664
692
  const gesture = selectionGesture;
665
693
  const imgPt = canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false);
666
- void gesture.begin(imgPt, e).then(() => report('select', gesture.error));
694
+ void gesture.begin(imgPt, e).then(() => {
695
+ report('select', gesture.error);
696
+ selectionDraft = gesture.draftPath;
697
+ });
667
698
  return;
668
699
  }
669
700
 
@@ -771,7 +802,7 @@
771
802
 
772
803
  if (mode === 'select') {
773
804
  const gesture = selectionGesture;
774
- if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false))) {
805
+ if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false), e)) {
775
806
  selectionDraft = gesture.draftPath;
776
807
  renderCanvas();
777
808
  }
@@ -924,7 +955,8 @@
924
955
  const gesture = selectionGesture;
925
956
  if (gesture.active) {
926
957
  gesture.end(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false), e);
927
- selectionDraft = null;
958
+ // A polygonal lasso stays open between clicks.
959
+ selectionDraft = gesture.draftPath;
928
960
  renderCanvas();
929
961
  }
930
962
  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, {}, "">;
@@ -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 ChannelsPanelComponent from "./ChannelsPanel.svelte";
11
12
  import type LayersPanelComponent from "./LayersPanel.svelte";
12
13
  import type ColorProfilePanelComponent from "./ColorProfilePanel.svelte";
13
14
  import type LayerEffectsPanelComponent from "./LayerEffectsPanel.svelte";
@@ -31,6 +32,7 @@ export { default as CurvesEditor } from './CurvesEditor.svelte';
31
32
  export { default as ControlSlider } from './ControlSlider.svelte';
32
33
  export { default as SelectionOptionsBar } from './SelectionOptionsBar.svelte';
33
34
  export { default as RefineEdgePanel } from './RefineEdgePanel.svelte';
35
+ export { default as ChannelsPanel } from './ChannelsPanel.svelte';
34
36
  export { default as LayersPanel } from './LayersPanel.svelte';
35
37
  export { default as ColorProfilePanel } from './ColorProfilePanel.svelte';
36
38
  export { default as LayerEffectsPanel } from './LayerEffectsPanel.svelte';
@@ -56,6 +58,7 @@ export type CurvesEditorProps = ComponentProps<typeof CurvesEditorComponent>;
56
58
  export type ControlSliderProps = ComponentProps<typeof ControlSliderComponent>;
57
59
  export type SelectionOptionsBarProps = ComponentProps<typeof SelectionOptionsBarComponent>;
58
60
  export type RefineEdgePanelProps = ComponentProps<typeof RefineEdgePanelComponent>;
61
+ export type ChannelsPanelProps = ComponentProps<typeof ChannelsPanelComponent>;
59
62
  export type LayersPanelProps = ComponentProps<typeof LayersPanelComponent>;
60
63
  export type ColorProfilePanelProps = ComponentProps<typeof ColorProfilePanelComponent>;
61
64
  export type LayerEffectsPanelProps = ComponentProps<typeof LayerEffectsPanelComponent>;
@@ -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 ChannelsPanel } from './ChannelsPanel.svelte';
13
14
  export { default as LayersPanel } from './LayersPanel.svelte';
14
15
  export { default as ColorProfilePanel } from './ColorProfilePanel.svelte';
15
16
  export { default as LayerEffectsPanel } from './LayerEffectsPanel.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.133.3",
3
+ "version": "0.136.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.186.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",