@pixerate/editor-svelte 0.124.1 → 0.126.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.
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
- import { imageSectionTitleClass, imageSelectClass } from './styles.js';
2
+ import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
+ import ImageSelect from './controls/ImageSelect.svelte';
4
+ import { imageSectionTitleClass } from './styles.js';
3
5
  import type {
4
6
  AdjustmentKind,
5
7
  AdjustmentLayer,
@@ -109,11 +111,13 @@
109
111
  <span class={imageSectionTitleClass}>Adjustment Layers</span>
110
112
  </div>
111
113
  <div class="flex gap-1.5">
112
- <select aria-label="New adjustment layer kind" class="{imageSelectClass} flex-1" bind:value={newKind}>
113
- {#each ADJUSTMENT_KINDS as k (k.kind)}
114
- <option value={k.kind}>{k.label}</option>
115
- {/each}
116
- </select>
114
+ <ImageSelect
115
+ label="New adjustment layer kind"
116
+ class="flex-1"
117
+ value={newKind}
118
+ options={ADJUSTMENT_KINDS.map((k) => ({ value: k.kind, label: k.label }))}
119
+ onchange={(v) => (newKind = v as typeof newKind)}
120
+ />
117
121
  <button type="button" class="px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90" onclick={addLayer}>
118
122
  Add Layer
119
123
  </button>
@@ -129,13 +133,7 @@
129
133
  ? 'bg-primary/20 ring-1 ring-primary/50'
130
134
  : 'bg-muted/60'}"
131
135
  >
132
- <input
133
- type="checkbox"
134
- aria-label="Show {label}"
135
- checked={layer.enabled}
136
- onchange={() => controller.toggleAdjustmentLayer(layer.id)}
137
- class="accent-primary"
138
- />
136
+ <ImageCheckbox label="Show {label}" checked={layer.enabled} onchange={() => controller.toggleAdjustmentLayer(layer.id)} />
139
137
  <button
140
138
  type="button"
141
139
  class="flex-1 text-left truncate {layer.enabled ? 'text-foreground' : 'text-muted-foreground line-through'}"
@@ -183,16 +181,13 @@
183
181
  <div class="flex flex-col gap-2 rounded border border-border p-2" data-testid="adjustment-layer-editor">
184
182
  <div class="flex items-center gap-2">
185
183
  <span class="text-muted-foreground">Blend</span>
186
- <select
187
- aria-label="Blend mode"
188
- class="{imageSelectClass} flex-1"
184
+ <ImageSelect
185
+ label="Blend mode"
186
+ class="flex-1"
189
187
  value={layer.blend}
190
- onchange={(e) => controller.updateAdjustmentLayer(layer.id, { blend: (e.target as HTMLSelectElement).value as BlendMode })}
191
- >
192
- {#each BLEND_MODES as m (m.id)}
193
- <option value={m.id}>{m.label}</option>
194
- {/each}
195
- </select>
188
+ options={BLEND_MODES.map((m) => ({ value: m.id, label: m.label }))}
189
+ onchange={(v) => controller.updateAdjustmentLayer(layer.id, { blend: v as BlendMode })}
190
+ />
196
191
  </div>
197
192
  <ControlSlider
198
193
  spec={ADJUSTMENT_LAYER_OPACITY_CONTROL}
@@ -265,34 +260,19 @@
265
260
 
266
261
  {#if layer.kind === 'hueSaturation' && range === -1}
267
262
  <label class="flex items-center gap-2 text-foreground">
268
- <input
269
- type="checkbox"
270
- checked={layer.params.colorize}
271
- onchange={(e) => setParam(layer, 'colorize', (e.target as HTMLInputElement).checked)}
272
- class="accent-primary"
273
- />
263
+ <ImageCheckbox label="Colorize" checked={layer.params.colorize} onchange={(on) => setParam(layer, 'colorize', on)} />
274
264
  Colorize
275
265
  </label>
276
266
  {/if}
277
267
  {#if layer.kind === 'colorBalance' || layer.kind === 'photoFilter'}
278
268
  <label class="flex items-center gap-2 text-foreground">
279
- <input
280
- type="checkbox"
281
- checked={layer.params.preserveLuminosity}
282
- onchange={(e) => setParam(layer, 'preserveLuminosity', (e.target as HTMLInputElement).checked)}
283
- class="accent-primary"
284
- />
269
+ <ImageCheckbox label="Preserve luminosity" checked={layer.params.preserveLuminosity} onchange={(on) => setParam(layer, 'preserveLuminosity', on)} />
285
270
  Preserve luminosity
286
271
  </label>
287
272
  {/if}
288
273
  {#if layer.kind === 'blackWhite'}
289
274
  <label class="flex items-center gap-2 text-foreground">
290
- <input
291
- type="checkbox"
292
- checked={layer.params.tint !== null}
293
- onchange={(e) => setParam(layer, 'tint', (e.target as HTMLInputElement).checked ? '#e1d3b3' : null)}
294
- class="accent-primary"
295
- />
275
+ <ImageCheckbox label="Tint" checked={layer.params.tint !== null} onchange={(on) => setParam(layer, 'tint', on ? '#e1d3b3' : null)} />
296
276
  Tint
297
277
  {#if layer.params.tint}
298
278
  <input
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import ImageCheckbox from './controls/ImageCheckbox.svelte';
2
3
  import { imageChipClass, imageColorInputClass } from './styles.js';
3
4
  import type { ImageEditorController } from '@pixerate/editor/image-editor';
4
5
  import {
@@ -136,23 +137,11 @@
136
137
  {/each}
137
138
  <div class="flex flex-wrap gap-3 text-foreground">
138
139
  <label class="flex items-center gap-1.5">
139
- <input
140
- type="checkbox"
141
- aria-label="Load the brush after each stroke"
142
- checked={view.mixer.loadEachStroke}
143
- onchange={(e) => controller.setPaintOptions({ mixer: { loadEachStroke: (e.target as HTMLInputElement).checked } })}
144
- class="accent-primary"
145
- />
140
+ <ImageCheckbox label="Load the brush after each stroke" checked={view.mixer.loadEachStroke} onchange={(on) => controller.setPaintOptions({ mixer: { loadEachStroke: on } })} />
146
141
  Load each stroke
147
142
  </label>
148
143
  <label class="flex items-center gap-1.5">
149
- <input
150
- type="checkbox"
151
- aria-label="Clean the brush after each stroke"
152
- checked={view.mixer.cleanEachStroke}
153
- onchange={(e) => controller.setPaintOptions({ mixer: { cleanEachStroke: (e.target as HTMLInputElement).checked } })}
154
- class="accent-primary"
155
- />
144
+ <ImageCheckbox label="Clean the brush after each stroke" checked={view.mixer.cleanEachStroke} onchange={(on) => controller.setPaintOptions({ mixer: { cleanEachStroke: on } })} />
156
145
  Clean each stroke
157
146
  </label>
158
147
  </div>
@@ -200,13 +189,7 @@
200
189
  <div class="flex flex-wrap gap-3 text-foreground">
201
190
  {#each RETOUCH_TOGGLES.filter((t) => t.tools.includes(view.tool as never)) as t (t.id)}
202
191
  <label class="flex items-center gap-1.5">
203
- <input
204
- type="checkbox"
205
- aria-label={t.label}
206
- checked={view.retouch[t.id]}
207
- onchange={(e) => controller.setPaintOptions({ retouch: { [t.id]: (e.target as HTMLInputElement).checked } })}
208
- class="accent-primary"
209
- />
192
+ <ImageCheckbox label={t.label} checked={view.retouch[t.id]} onchange={(on) => controller.setPaintOptions({ retouch: { [t.id]: on } })} />
210
193
  {t.label}
211
194
  </label>
212
195
  {/each}
@@ -216,23 +199,11 @@
216
199
 
217
200
  <div class="flex flex-wrap gap-3 text-foreground" role="group" aria-label="Pressure">
218
201
  <label class="flex items-center gap-1.5">
219
- <input
220
- type="checkbox"
221
- aria-label="Pressure controls size"
222
- checked={view.brush.pressureSize}
223
- onchange={(e) => controller.setPaintOptions({ brush: { pressureSize: (e.target as HTMLInputElement).checked } })}
224
- class="accent-primary"
225
- />
202
+ <ImageCheckbox label="Pressure controls size" checked={view.brush.pressureSize} onchange={(on) => controller.setPaintOptions({ brush: { pressureSize: on } })} />
226
203
  Pressure size
227
204
  </label>
228
205
  <label class="flex items-center gap-1.5">
229
- <input
230
- type="checkbox"
231
- aria-label="Pressure controls opacity"
232
- checked={view.brush.pressureOpacity}
233
- onchange={(e) => controller.setPaintOptions({ brush: { pressureOpacity: (e.target as HTMLInputElement).checked } })}
234
- class="accent-primary"
235
- />
206
+ <ImageCheckbox label="Pressure controls opacity" checked={view.brush.pressureOpacity} onchange={(on) => controller.setPaintOptions({ brush: { pressureOpacity: on } })} />
236
207
  Pressure opacity
237
208
  </label>
238
209
  </div>
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
+ import ImageSelect from './controls/ImageSelect.svelte';
3
+ import ImageCheckbox from './controls/ImageCheckbox.svelte';
2
4
  import type { ImageEditorController, TransformAnchor } from '@pixerate/editor/image-editor';
3
5
  import { CANVAS_SIZE_PRESETS, isTransformAnchor } from '@pixerate/editor/image-editor';
4
6
  import { untrack } from 'svelte';
5
- import { imagePrimaryButtonClass, imageSelectClass } from './styles.js';
7
+ import { imagePrimaryButtonClass } from './styles.js';
6
8
 
7
9
  interface Props {
8
10
  controller: ImageEditorController;
@@ -56,10 +58,7 @@
56
58
  height = val;
57
59
  }
58
60
 
59
- function onPresetSelect(e: Event) {
60
- const target = e.currentTarget as HTMLSelectElement;
61
- const presetId = target.value;
62
- target.value = '';
61
+ function onPresetSelect(presetId: string) {
63
62
  const preset = CANVAS_SIZE_PRESETS.find((p) => p.id === presetId);
64
63
  if (!preset) return;
65
64
  width = preset.width;
@@ -85,18 +84,15 @@
85
84
  {/if}
86
85
 
87
86
  <div class="flex flex-col gap-1.5">
88
- <label for="canvas-size-preset" class="text-muted-foreground text-[11px]">Preset</label>
89
- <select
90
- id="canvas-size-preset"
91
- class="{imageSelectClass} w-full"
87
+ <span class="text-muted-foreground text-[11px]">Preset</span>
88
+ <ImageSelect
89
+ class="w-full"
90
+ label="Preset canvas sizes"
91
+ value=""
92
+ placeholder="Choose preset…"
93
+ options={CANVAS_SIZE_PRESETS.map((preset) => ({ value: preset.id, label: `${preset.label} (${preset.width} × ${preset.height})` }))}
92
94
  onchange={onPresetSelect}
93
- aria-label="Preset canvas sizes"
94
- >
95
- <option value="">Choose preset…</option>
96
- {#each CANVAS_SIZE_PRESETS as preset (preset.id)}
97
- <option value={preset.id}>{preset.label} ({preset.width} × {preset.height})</option>
98
- {/each}
99
- </select>
95
+ />
100
96
  </div>
101
97
 
102
98
  <div class="grid grid-cols-2 gap-2 items-center">
@@ -129,12 +125,7 @@
129
125
 
130
126
  <div class="flex items-center gap-2">
131
127
  <label class="flex items-center gap-1.5 cursor-pointer text-muted-foreground select-none">
132
- <input
133
- type="checkbox"
134
- class="rounded border-border text-primary focus:ring-primary"
135
- checked={lockAspect}
136
- onchange={(e) => (lockAspect = (e.currentTarget as HTMLInputElement).checked)}
137
- />
128
+ <ImageCheckbox label="Lock aspect ratio" checked={lockAspect} onchange={(on) => (lockAspect = on)} />
138
129
  <span>Lock aspect ratio</span>
139
130
  </label>
140
131
  </div>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import ImageRange from './controls/ImageRange.svelte';
2
3
  import type { ControlSpec } from '@pixerate/editor/image-editor';
3
4
  import { controlTrackBackground, formatControlValue } from '@pixerate/editor/image-editor';
4
5
 
@@ -24,17 +25,7 @@
24
25
  <span>{name}</span>
25
26
  <span class="font-mono">{formatControlValue(spec, value)}</span>
26
27
  </div>
27
- <input
28
- type="range"
29
- min={spec.min}
30
- max={spec.max}
31
- step={spec.step}
32
- aria-label={name}
33
- {disabled}
34
- {value}
35
- oninput={(e) => onchange(Number((e.target as HTMLInputElement).value))}
36
- class="w-full accent-primary disabled:opacity-40"
37
- />
28
+ <ImageRange min={spec.min} max={spec.max} step={spec.step} label={name} {disabled} {value} oninput={onchange} />
38
29
  {#if track}
39
30
  <div class="h-0.5 rounded-full opacity-70" style="background: {track}"></div>
40
31
  {/if}
@@ -93,6 +93,11 @@
93
93
  open?: (ref: PlaybackRef) => void | Promise<void>;
94
94
  /** Whether wheel zoom and middle/space drag panning are enabled (default true). */
95
95
  viewportGestures?: boolean;
96
+ /**
97
+ * Pan mode (a host's hand tool): a primary drag pans the view in every mode, as holding Space
98
+ * does, and the canvas's own gestures pause. Needs `viewportGestures`. Default false.
99
+ */
100
+ panMode?: boolean;
96
101
  class?: string;
97
102
  style?: string;
98
103
  }
@@ -118,6 +123,7 @@
118
123
  locateElement,
119
124
  open,
120
125
  viewportGestures = true,
126
+ panMode = false,
121
127
  class: className = '',
122
128
  style = '',
123
129
  }: Props = $props();
@@ -200,6 +206,8 @@
200
206
 
201
207
  let isSpacePressed = $state(false);
202
208
  let isPanning = $state(false);
209
+ // A primary drag pans: Space held, or the host's pan mode.
210
+ const panning = $derived(viewportGestures && (isSpacePressed || panMode));
203
211
  let panStartPoint: { clientX: number; clientY: number } | null = null;
204
212
  let panStartOffset: { x: number; y: number } = { x: 0, y: 0 };
205
213
 
@@ -373,7 +381,7 @@
373
381
  function handleFramePointerDown(e: PointerEvent): boolean {
374
382
  if (mode !== 'transform' || transformTool.active || !canvasEl) return false;
375
383
  // Middle-button and Space drags pan (see handleMouseDown).
376
- if (e.button !== 0 || (viewportGestures && isSpacePressed)) return false;
384
+ if (e.button !== 0 || panning) return false;
377
385
  const c = editor.controller;
378
386
  const { pt, tolerance } = transformPoint(e);
379
387
  const frame = layerFrame();
@@ -498,6 +506,7 @@
498
506
  viewportWidth: viewport.width,
499
507
  viewportHeight: viewport.height,
500
508
  pixelRatio: viewport.pixelRatio,
509
+ hostScale: viewport.hostScale,
501
510
  fit,
502
511
  showCropOverlay: mode === 'crop',
503
512
  showSelectionOverlay: true,
@@ -528,6 +537,8 @@
528
537
  }));
529
538
  }
530
539
  function handlePaintPointerDown(e: PointerEvent) {
540
+ // Panning: the drag moves the view (handleMouseDown), not the document.
541
+ if (panning && e.button === 0) return;
531
542
  if (handleTransformPointerDown(e)) return;
532
543
  if (handleFramePointerDown(e)) return;
533
544
  if (mode !== 'paint' || e.button !== 0 || !canvasEl) return;
@@ -540,6 +551,7 @@
540
551
  report('paint', paintGesture.down({ ...paintSamples(e)[0], altKey: e.altKey }));
541
552
  }
542
553
  function handlePaintPointerMove(e: PointerEvent) {
554
+ if (panning && !transformTool.active) return;
543
555
  if (handleTransformPointerMove(e)) return;
544
556
  if (mode === 'transform' && canvasEl) {
545
557
  const { pt, tolerance } = transformPoint(e);
@@ -614,7 +626,7 @@
614
626
  function handleMouseDown(e: MouseEvent) {
615
627
  if (!canvasEl) return;
616
628
 
617
- if (viewportGestures && (e.button === 1 || (e.button === 0 && isSpacePressed))) {
629
+ if (viewportGestures && (e.button === 1 || (e.button === 0 && panning))) {
618
630
  e.preventDefault();
619
631
  isPanning = true;
620
632
  panStartPoint = { clientX: e.clientX, clientY: e.clientY };
@@ -729,8 +741,9 @@
729
741
  return;
730
742
  }
731
743
 
732
- if (viewportGestures && isSpacePressed) {
744
+ if (panning) {
733
745
  canvasEl.style.cursor = 'grab';
746
+ return;
734
747
  }
735
748
 
736
749
  if (transformTool.active) return;
@@ -1013,7 +1026,7 @@
1013
1026
  endFrameDrag(true);
1014
1027
  }}
1015
1028
  data-testid="image-editor-canvas"
1016
- style="width: 100%; height: 100%;{transformActive || mode === 'transform' ? ` cursor: ${transformCursor};` : ''}"
1029
+ style="width: 100%; height: 100%;{panning ? ` cursor: ${isPanning ? 'grabbing' : 'grab'};` : transformActive || mode === 'transform' ? ` cursor: ${transformCursor};` : ''}"
1017
1030
  class="absolute inset-0 w-full h-full block {transformActive || mode === 'transform' ? 'touch-none' : mode === 'paint' ? 'touch-none cursor-crosshair' : mode === 'annotate' && editor.state.activeTool === 'type' ? 'cursor-text' : mode === 'annotate' && editor.state.activeTool === 'shape' ? 'cursor-crosshair' : ''}"
1018
1031
  ></canvas>
1019
1032
  {#if typeSession}
@@ -58,6 +58,11 @@ interface Props {
58
58
  open?: (ref: PlaybackRef) => void | Promise<void>;
59
59
  /** Whether wheel zoom and middle/space drag panning are enabled (default true). */
60
60
  viewportGestures?: boolean;
61
+ /**
62
+ * Pan mode (a host's hand tool): a primary drag pans the view in every mode, as holding Space
63
+ * does, and the canvas's own gestures pause. Needs `viewportGestures`. Default false.
64
+ */
65
+ panMode?: boolean;
61
66
  class?: string;
62
67
  style?: string;
63
68
  }
@@ -1,5 +1,8 @@
1
1
  <script lang="ts">
2
- import { imageIconButtonClass, imageSelectClass } from './styles.js';
2
+ import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
+ import ImageRange from './controls/ImageRange.svelte';
4
+ import ImageSelect from './controls/ImageSelect.svelte';
5
+ import { imageIconButtonClass } from './styles.js';
3
6
  import type { EffectControl, ImageEditorController, LayerEffectKind } from '@pixerate/editor/image-editor';
4
7
  import { LAYER_EFFECT_MENU, effectControlChange, layerEffectControls, layerEffectsPanel } from '@pixerate/editor/image-editor';
5
8
  import { untrack } from 'svelte';
@@ -57,10 +60,8 @@
57
60
  });
58
61
  }
59
62
 
60
- function add(e: Event) {
61
- const select = e.currentTarget as HTMLSelectElement;
62
- const kind = select.value as LayerEffectKind;
63
- select.value = '';
63
+ function add(value: string) {
64
+ const kind = value as LayerEffectKind;
64
65
  const id = view.id;
65
66
  if (!kind || !id) return;
66
67
  attempt(() => (open = controller.addLayerEffect(id, kind).effect.id));
@@ -84,13 +85,11 @@
84
85
  <div class="flex flex-wrap items-center gap-2">
85
86
  <span class="font-semibold text-foreground">fx Effects</span>
86
87
  <label class="flex items-center gap-1 text-foreground">
87
- <input
88
- type="checkbox"
89
- aria-label="Effects on"
88
+ <ImageCheckbox
89
+ label="Effects on"
90
90
  checked={view.model.enabled}
91
91
  disabled={view.model.readOnly}
92
- onchange={(e) => attempt(() => controller.setLayerEffectsEnabled(id, (e.currentTarget as HTMLInputElement).checked))}
93
- class="accent-primary"
92
+ onchange={(on) => attempt(() => controller.setLayerEffectsEnabled(id, on))}
94
93
  />
95
94
  On
96
95
  </label>
@@ -109,12 +108,14 @@
109
108
  />
110
109
  %
111
110
  </label>
112
- <select aria-label="Add effect" value="" disabled={view.model.readOnly} onchange={add} class={imageSelectClass}>
113
- <option value="">Add effect…</option>
114
- {#each LAYER_EFFECT_MENU as m (m.kind)}
115
- <option value={m.kind}>{m.label}</option>
116
- {/each}
117
- </select>
111
+ <ImageSelect
112
+ label="Add effect"
113
+ value=""
114
+ placeholder="Add effect…"
115
+ disabled={view.model.readOnly}
116
+ options={LAYER_EFFECT_MENU.map((m) => ({ value: m.kind, label: m.label }))}
117
+ onchange={add}
118
+ />
118
119
  </div>
119
120
  {#if view.model.readOnly && view.model.reason}<p class="text-muted-foreground">{view.model.reason}</p>{/if}
120
121
  <ul class="flex flex-col gap-1" aria-label="Effects">
@@ -123,13 +124,11 @@
123
124
  {@const expanded = open === row.id}
124
125
  <li data-effect-id={row.id} class="rounded bg-background/60 ring-1 ring-border">
125
126
  <div class="flex items-center gap-1.5 px-1.5 py-1">
126
- <input
127
- type="checkbox"
128
- aria-label="{row.label} on"
127
+ <ImageCheckbox
128
+ label="{row.label} on"
129
129
  checked={row.enabled}
130
130
  disabled={view.model.readOnly}
131
- onchange={(e) => attempt(() => controller.updateLayerEffect(id, row.id, { enabled: (e.currentTarget as HTMLInputElement).checked }))}
132
- class="accent-primary"
131
+ onchange={(on) => attempt(() => controller.updateLayerEffect(id, row.id, { enabled: on }))}
133
132
  />
134
133
  <button type="button" aria-expanded={expanded} class="flex-1 text-left {row.enabled && view.model.enabled ? '' : 'text-muted-foreground'}" onclick={() => (open = expanded ? null : row.id)}>
135
134
  {row.label}
@@ -142,19 +141,16 @@
142
141
  {#if c.type === 'range'}
143
142
  <label class="flex flex-col gap-0.5">
144
143
  <span class="text-muted-foreground">{c.label} {shown(c)}{c.unit ?? ''}</span>
145
- <input
146
- type="range"
147
- aria-label={c.label}
144
+ <ImageRange
145
+ label={c.label}
148
146
  min={c.min * k(c)}
149
147
  max={c.max * k(c)}
150
148
  step={c.step * k(c)}
151
149
  value={shown(c)}
152
150
  disabled={view.model.readOnly}
153
- onpointerdown={() => (commitNext = true)}
154
- onpointerup={() => (commitNext = true)}
155
- onkeydown={() => (commitNext = true)}
156
- oninput={(e) => change(row.id, c, Number((e.currentTarget as HTMLInputElement).value) / k(c), true)}
157
- class="accent-primary"
151
+ onstart={() => (commitNext = true)}
152
+ onend={() => (commitNext = true)}
153
+ oninput={(v) => change(row.id, c, v / k(c), true)}
158
154
  />
159
155
  </label>
160
156
  {:else if c.type === 'color'}
@@ -164,17 +160,13 @@
164
160
  </label>
165
161
  {:else if c.type === 'checkbox'}
166
162
  <label class="flex items-center gap-1.5 text-foreground">
167
- <input type="checkbox" aria-label={c.label} checked={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLInputElement).checked)} class="accent-primary" />
163
+ <ImageCheckbox label={c.label} checked={c.value} disabled={view.model.readOnly} onchange={(on) => change(row.id, c, on)} />
168
164
  {c.label}
169
165
  </label>
170
166
  {:else}
171
167
  <label class="flex flex-col gap-0.5">
172
168
  <span class="text-muted-foreground">{c.label}</span>
173
- <select aria-label={c.label} value={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLSelectElement).value)} class={imageSelectClass}>
174
- {#each c.options as o (o.value)}
175
- <option value={o.value}>{o.label}</option>
176
- {/each}
177
- </select>
169
+ <ImageSelect label={c.label} value={String(c.value)} disabled={view.model.readOnly} options={c.options.map((o) => ({ value: String(o.value), label: o.label }))} onchange={(v) => change(row.id, c, v)} />
178
170
  </label>
179
171
  {/if}
180
172
  {/each}
@@ -6,6 +6,7 @@
6
6
  DEFAULT_TYPE_TOOL_OPTIONS,
7
7
  DEFAULT_SELECTION_TOOL_OPTIONS,
8
8
  } from '@pixerate/editor/image-editor';
9
+ import { untrack } from 'svelte';
9
10
  import CanvasSizePanel from './CanvasSizePanel.svelte';
10
11
  import ControlSlider from './ControlSlider.svelte';
11
12
  import LayerEffectsPanel from './LayerEffectsPanel.svelte';
@@ -23,7 +24,17 @@
23
24
 
24
25
  let { controller, panelId, class: className = '' }: Props = $props();
25
26
 
26
- const activeLayer = $derived(controller.getActiveLayer());
27
+ let tick = $state(0);
28
+ $effect(() => {
29
+ const c = controller;
30
+ // `subscribe` calls the listener at once: untracked, or the effect would depend on `tick`.
31
+ return untrack(() => c.subscribe(() => tick++));
32
+ });
33
+ // The controller isn't reactive: re-read the active layer after every change.
34
+ const activeLayer = $derived.by(() => {
35
+ void tick;
36
+ return controller.getActiveLayer();
37
+ });
27
38
  let shapeOptions = $state({ ...DEFAULT_SHAPE_TOOL_OPTIONS });
28
39
  let typeOptions = $state({ ...DEFAULT_TYPE_TOOL_OPTIONS });
29
40
  let selectionOptions = $state({ ...DEFAULT_SELECTION_TOOL_OPTIONS });
@@ -52,8 +63,8 @@
52
63
  <ControlSlider
53
64
  spec={ADJUSTMENT_LAYER_OPACITY_CONTROL}
54
65
  label="Opacity"
55
- value={Math.round(activeLayer.opacity * 100)}
56
- onchange={(val) => controller.setLayerProperties(activeLayer.id, { opacity: val / 100 })}
66
+ value={activeLayer.opacity}
67
+ onchange={(val) => controller.setLayerProperties(activeLayer.id, { opacity: val })}
57
68
  />
58
69
  </div>
59
70
  {:else}
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
- import { imageRangeClass } from './styles.js';
2
+ import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
+ import ImageRange from './controls/ImageRange.svelte';
4
+ import ImageSelect from './controls/ImageSelect.svelte';
3
5
  import type {
4
6
  ImageEditorController,
5
7
  SelectionMask,
@@ -83,41 +85,32 @@
83
85
  <span>Tolerance</span>
84
86
  <span class="font-mono">{options.tolerance}</span>
85
87
  </span>
86
- <input
87
- type="range"
88
- min="0"
89
- max="255"
90
- step="1"
91
- aria-label="Tolerance"
92
- value={options.tolerance}
93
- oninput={(e) => set({ tolerance: Number((e.currentTarget as HTMLInputElement).value) })}
94
- class={imageRangeClass}
95
- />
88
+ <ImageRange label="Tolerance" min={0} max={255} step={1} value={options.tolerance} oninput={(v) => set({ tolerance: v })} />
96
89
  </label>
97
90
  <label class="flex items-center gap-2 text-foreground">
98
- <input type="checkbox" checked={options.contiguous} onchange={(e) => set({ contiguous: (e.currentTarget as HTMLInputElement).checked })} />
91
+ <ImageCheckbox label="Contiguous" checked={options.contiguous} onchange={(on) => set({ contiguous: on })} />
99
92
  Contiguous
100
93
  </label>
101
94
  <label class="flex items-center gap-2 text-foreground">
102
- <input type="checkbox" checked={options.sampleMerged} onchange={(e) => set({ sampleMerged: (e.currentTarget as HTMLInputElement).checked })} />
95
+ <ImageCheckbox label="Sample adjusted image" checked={options.sampleMerged} onchange={(on) => set({ sampleMerged: on })} />
103
96
  Sample adjusted image
104
97
  </label>
105
98
  <label class="flex items-center gap-2 text-foreground">
106
99
  Sample
107
- <select
108
- aria-label="Sample from"
100
+ <ImageSelect
101
+ label="Sample from"
109
102
  value={options.sample ?? 'composite'}
110
- onchange={(e) => set({ sample: (e.currentTarget as HTMLSelectElement).value as 'composite' | 'active-layer' })}
111
- class="bg-muted border border-border rounded px-1 py-0.5"
112
- >
113
- <option value="composite">All layers</option>
114
- <option value="active-layer">Active layer</option>
115
- </select>
103
+ options={[
104
+ { value: 'composite', label: 'All layers' },
105
+ { value: 'active-layer', label: 'Active layer' },
106
+ ]}
107
+ onchange={(v) => set({ sample: v as 'composite' | 'active-layer' })}
108
+ />
116
109
  </label>
117
110
  </div>
118
111
  {/if}
119
112
  <label class="flex items-center gap-2 text-foreground">
120
- <input type="checkbox" checked={options.antiAlias} onchange={(e) => set({ antiAlias: (e.currentTarget as HTMLInputElement).checked })} />
113
+ <ImageCheckbox label="Anti-alias" checked={options.antiAlias} onchange={(on) => set({ antiAlias: on })} />
121
114
  Anti-alias
122
115
  </label>
123
116
  <div class="flex flex-wrap gap-1">
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
+ import ImageRange from './controls/ImageRange.svelte';
2
4
  import { imageChipClass, imageColorInputClass, imageFieldClass } from './styles.js';
3
5
  import type { ImageEditorController, ShapeToolOptions } from '@pixerate/editor/image-editor';
4
6
  import { LIVE_SHAPE_KINDS, SHAPE_KIND_LABELS, applyShapeOptions } from '@pixerate/editor/image-editor';
@@ -44,7 +46,6 @@
44
46
  }
45
47
  }
46
48
  const value = (e: Event) => (e.target as HTMLInputElement).value;
47
- const checked = (e: Event) => (e.target as HTMLInputElement).checked;
48
49
  </script>
49
50
 
50
51
  <div class="flex flex-col gap-2 text-xs text-foreground {className}" data-testid="shape-options">
@@ -55,7 +56,7 @@
55
56
  </div>
56
57
  <div class="flex flex-wrap items-center gap-3">
57
58
  <label class="flex items-center gap-1">
58
- <input type="checkbox" aria-label="Fill" checked={options.fill !== null} onchange={(e) => set({ fill: checked(e) ? '#4f46e5' : null })} />
59
+ <ImageCheckbox label="Fill" checked={options.fill !== null} onchange={(on) => set({ fill: on ? '#4f46e5' : null })} />
59
60
  Fill
60
61
  <input
61
62
  type="color"
@@ -67,7 +68,7 @@
67
68
  />
68
69
  </label>
69
70
  <label class="flex items-center gap-1">
70
- <input type="checkbox" aria-label="Stroke" checked={options.stroke !== null} onchange={(e) => set({ stroke: checked(e) ? '#000000' : null })} />
71
+ <ImageCheckbox label="Stroke" checked={options.stroke !== null} onchange={(on) => set({ stroke: on ? '#000000' : null })} />
71
72
  Stroke
72
73
  <input
73
74
  type="color"
@@ -101,7 +102,7 @@
101
102
  </label>
102
103
  <label class="flex items-center gap-1">
103
104
  Inner
104
- <input type="range" aria-label="Inner radius" min="0.05" max="1" step="0.05" value={options.innerRatio} oninput={(e) => set({ innerRatio: Number(value(e)) })} class="accent-primary" />
105
+ <ImageRange label="Inner radius" min={0.05} max={1} step={0.05} value={options.innerRatio} oninput={(v) => set({ innerRatio: v })} />
105
106
  </label>
106
107
  {:else if options.kind === 'line'}
107
108
  <label class="flex items-center gap-1">
@@ -109,7 +110,7 @@
109
110
  <input type="number" aria-label="Line weight" min="1" max="200" value={options.weight} oninput={(e) => set({ weight: num(value(e), 1, 200) })} class="w-14 {imageFieldClass}" />
110
111
  </label>
111
112
  <label class="flex items-center gap-1">
112
- <input type="checkbox" aria-label="Arrowhead" checked={options.arrow} onchange={(e) => set({ arrow: checked(e) })} />
113
+ <ImageCheckbox label="Arrowhead" checked={options.arrow} onchange={(on) => set({ arrow: on })} />
113
114
  Arrowhead
114
115
  </label>
115
116
  {/if}
@@ -0,0 +1,19 @@
1
+ <script lang="ts">
2
+ import { getImageEditorControls, type ImageCheckboxProps } from './controls.js';
3
+
4
+ let { checked, label, disabled = false, onchange, class: className = '' }: ImageCheckboxProps = $props();
5
+ const Host = getImageEditorControls().Checkbox;
6
+ </script>
7
+
8
+ {#if Host}
9
+ <Host {checked} {label} {disabled} {onchange} class={className} />
10
+ {:else}
11
+ <input
12
+ type="checkbox"
13
+ aria-label={label}
14
+ {checked}
15
+ {disabled}
16
+ onchange={(e) => onchange((e.currentTarget as HTMLInputElement).checked)}
17
+ class="accent-primary {className}"
18
+ />
19
+ {/if}
@@ -0,0 +1,4 @@
1
+ import { type ImageCheckboxProps } from './controls.js';
2
+ declare const ImageCheckbox: import("svelte").Component<ImageCheckboxProps, {}, "">;
3
+ type ImageCheckbox = ReturnType<typeof ImageCheckbox>;
4
+ export default ImageCheckbox;
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { getImageEditorControls, type ImageRangeProps } from './controls.js';
3
+
4
+ let { value, min, max, step, label, disabled = false, oninput, onstart, onend, class: className = '' }: ImageRangeProps = $props();
5
+ const Host = getImageEditorControls().Range;
6
+ </script>
7
+
8
+ {#if Host}
9
+ <Host {value} {min} {max} {step} {label} {disabled} {oninput} {onstart} {onend} class={className} />
10
+ {:else}
11
+ <input
12
+ type="range"
13
+ aria-label={label}
14
+ {min}
15
+ {max}
16
+ {step}
17
+ {value}
18
+ {disabled}
19
+ onpointerdown={() => onstart?.()}
20
+ onpointerup={() => onend?.()}
21
+ onkeydown={() => onstart?.()}
22
+ onkeyup={() => onend?.()}
23
+ oninput={(e) => oninput(Number((e.currentTarget as HTMLInputElement).value))}
24
+ class="w-full accent-primary disabled:opacity-40 {className}"
25
+ />
26
+ {/if}
@@ -0,0 +1,4 @@
1
+ import { type ImageRangeProps } from './controls.js';
2
+ declare const ImageRange: import("svelte").Component<ImageRangeProps, {}, "">;
3
+ type ImageRange = ReturnType<typeof ImageRange>;
4
+ export default ImageRange;
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import { imageSelectClass } from '../styles.js';
3
+ import { getImageEditorControls, type ImageSelectProps } from './controls.js';
4
+
5
+ let { value, options, label, placeholder, disabled = false, onchange, class: className = '' }: ImageSelectProps = $props();
6
+ const Host = getImageEditorControls().Select;
7
+ </script>
8
+
9
+ {#if Host}
10
+ <Host {value} {options} {label} {placeholder} {disabled} {onchange} class={className} />
11
+ {:else}
12
+ <select
13
+ aria-label={label}
14
+ {value}
15
+ {disabled}
16
+ onchange={(e) => {
17
+ const select = e.currentTarget as HTMLSelectElement;
18
+ onchange(select.value);
19
+ // A placeholder select (an action menu) shows its placeholder again.
20
+ if (placeholder !== undefined) select.value = value;
21
+ }}
22
+ class="{imageSelectClass} {className}"
23
+ >
24
+ {#if placeholder !== undefined}<option value="">{placeholder}</option>{/if}
25
+ {#each options as o (o.value)}
26
+ <option value={o.value}>{o.label}</option>
27
+ {/each}
28
+ </select>
29
+ {/if}
@@ -0,0 +1,4 @@
1
+ import { type ImageSelectProps } from './controls.js';
2
+ declare const ImageSelect: import("svelte").Component<ImageSelectProps, {}, "">;
3
+ type ImageSelect = ReturnType<typeof ImageSelect>;
4
+ export default ImageSelect;
@@ -0,0 +1,60 @@
1
+ import { type Component } from 'svelte';
2
+ /** A checkbox in the image editor's panels. */
3
+ export interface ImageCheckboxProps {
4
+ checked: boolean;
5
+ /** The accessible name. */
6
+ label: string;
7
+ disabled?: boolean;
8
+ onchange: (checked: boolean) => void;
9
+ class?: string;
10
+ }
11
+ /** A range slider in the image editor's panels. */
12
+ export interface ImageRangeProps {
13
+ value: number;
14
+ min: number;
15
+ max: number;
16
+ step: number;
17
+ /** The accessible name. */
18
+ label: string;
19
+ disabled?: boolean;
20
+ /** Every change while the value moves. */
21
+ oninput: (value: number) => void;
22
+ /** A drag (or key press) starts. */
23
+ onstart?: () => void;
24
+ /** A drag (or key press) ends. */
25
+ onend?: () => void;
26
+ class?: string;
27
+ }
28
+ /** One choice of an {@link ImageSelectProps}. */
29
+ export interface ImageSelectOption {
30
+ value: string;
31
+ label: string;
32
+ }
33
+ /** A dropdown in the image editor's panels. */
34
+ export interface ImageSelectProps {
35
+ value: string;
36
+ options: readonly ImageSelectOption[];
37
+ /** The accessible name. */
38
+ label: string;
39
+ /** Shown when `value` matches no option (e.g. "Add effect…"). */
40
+ placeholder?: string;
41
+ disabled?: boolean;
42
+ onchange: (value: string) => void;
43
+ class?: string;
44
+ }
45
+ /**
46
+ * The host's own form controls for the image editor's panels. Any left out fall back to the
47
+ * editor's plain inputs.
48
+ */
49
+ export interface ImageEditorControls {
50
+ Checkbox?: Component<ImageCheckboxProps>;
51
+ Range?: Component<ImageRangeProps>;
52
+ Select?: Component<ImageSelectProps>;
53
+ }
54
+ /**
55
+ * Makes the image editor's panels below this component use the host's controls (call it while
56
+ * a component initialises, like `setContext`).
57
+ */
58
+ export declare function setImageEditorControls(controls: ImageEditorControls): ImageEditorControls;
59
+ /** The controls set by an ancestor with {@link setImageEditorControls}, or none. */
60
+ export declare function getImageEditorControls(): ImageEditorControls;
@@ -0,0 +1,13 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const KEY = Symbol('pixerate.image-editor.controls');
3
+ /**
4
+ * Makes the image editor's panels below this component use the host's controls (call it while
5
+ * a component initialises, like `setContext`).
6
+ */
7
+ export function setImageEditorControls(controls) {
8
+ return setContext(KEY, controls);
9
+ }
10
+ /** The controls set by an ancestor with {@link setImageEditorControls}, or none. */
11
+ export function getImageEditorControls() {
12
+ return getContext(KEY) ?? {};
13
+ }
@@ -40,6 +40,10 @@ export { default as ShapeOptionsBar } from './ShapeOptionsBar.svelte';
40
40
  export { default as TypeOptionsBar } from './TypeOptionsBar.svelte';
41
41
  export { default as ImageProviderEffectsPanel } from './ImageProviderEffectsPanel.svelte';
42
42
  export * from './imageEditorState.svelte.js';
43
+ export { default as ImageCheckbox } from './controls/ImageCheckbox.svelte';
44
+ export { default as ImageRange } from './controls/ImageRange.svelte';
45
+ export { default as ImageSelect } from './controls/ImageSelect.svelte';
46
+ export { setImageEditorControls, getImageEditorControls, type ImageEditorControls, type ImageCheckboxProps, type ImageRangeProps, type ImageSelectProps, type ImageSelectOption, } from './controls/controls.js';
43
47
  export type ImageEditorProps = ComponentProps<typeof ImageEditorComponent>;
44
48
  export type ImageEditorCanvasProps = ComponentProps<typeof ImageEditorCanvasComponent>;
45
49
  export type CanvasSizePanelProps = ComponentProps<typeof CanvasSizePanelComponent>;
@@ -19,4 +19,8 @@ export { default as ShapeOptionsBar } from './ShapeOptionsBar.svelte';
19
19
  export { default as TypeOptionsBar } from './TypeOptionsBar.svelte';
20
20
  export { default as ImageProviderEffectsPanel } from './ImageProviderEffectsPanel.svelte';
21
21
  export * from './imageEditorState.svelte.js';
22
+ export { default as ImageCheckbox } from './controls/ImageCheckbox.svelte';
23
+ export { default as ImageRange } from './controls/ImageRange.svelte';
24
+ export { default as ImageSelect } from './controls/ImageSelect.svelte';
25
+ export { setImageEditorControls, getImageEditorControls, } from './controls/controls.js';
22
26
  export { applyRegionMosaic, applyRegionBlur, resolveMosaicRegionBounds } from '@pixerate/editor/image-editor';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.124.1",
3
+ "version": "0.126.0",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -168,7 +168,7 @@
168
168
  "@tiptap/extension-bubble-menu": "^2.27.3",
169
169
  "@tiptap/extension-placeholder": "^2.27.3",
170
170
  "@tiptap/starter-kit": "^2.27.3",
171
- "@pixerate/editor": "0.168.0"
171
+ "@pixerate/editor": "0.170.0"
172
172
  },
173
173
  "peerDependencies": {
174
174
  "@dagrejs/dagre": "^1.1.4",