@pixerate/editor-svelte 0.128.5 → 0.130.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,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import ImageColor from './controls/ImageColor.svelte';
2
3
  import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
4
  import ImageSelect from './controls/ImageSelect.svelte';
4
5
  import { imageSectionTitleClass } from './styles.js';
@@ -242,13 +243,7 @@
242
243
  {:else if layer.kind === 'photoFilter'}
243
244
  <label class="flex items-center gap-2 text-foreground">
244
245
  Filter colour
245
- <input
246
- type="color"
247
- aria-label="Filter colour"
248
- value={layer.params.color}
249
- oninput={(e) => setParam(layer, 'color', (e.target as HTMLInputElement).value)}
250
- class="h-5 w-8 bg-transparent"
251
- />
246
+ <ImageColor label="Filter colour" value={layer.params.color} oninput={(v) => setParam(layer, 'color', v)} />
252
247
  </label>
253
248
  {:else if layer.kind === 'invert'}
254
249
  <p class="text-muted-foreground text-[11px]">Invert has no settings.</p>
@@ -275,13 +270,7 @@
275
270
  <ImageCheckbox label="Tint" checked={layer.params.tint !== null} onchange={(on) => setParam(layer, 'tint', on ? '#e1d3b3' : null)} />
276
271
  Tint
277
272
  {#if layer.params.tint}
278
- <input
279
- type="color"
280
- aria-label="Tint colour"
281
- value={layer.params.tint}
282
- oninput={(e) => setParam(layer, 'tint', (e.target as HTMLInputElement).value)}
283
- class="h-5 w-8 bg-transparent"
284
- />
273
+ <ImageColor label="Tint colour" value={layer.params.tint} oninput={(v) => setParam(layer, 'tint', v)} />
285
274
  {/if}
286
275
  </label>
287
276
  {/if}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
+ import ImageColor from './controls/ImageColor.svelte';
2
3
  import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
- import { imageChipClass, imageColorInputClass } from './styles.js';
4
+ import { imageChipClass } from './styles.js';
4
5
  import type { ImageEditorController } from '@pixerate/editor/image-editor';
5
6
  import {
6
7
  userErrorMessage,
@@ -70,23 +71,11 @@
70
71
 
71
72
  <div class="flex items-center gap-2" role="group" aria-label="Colours">
72
73
  <label class="flex items-center gap-1">
73
- <input
74
- type="color"
75
- aria-label="Foreground colour"
76
- value={view.foreground}
77
- oninput={(e) => controller.setPaintOptions({ foreground: (e.target as HTMLInputElement).value })}
78
- class={imageColorInputClass}
79
- />
74
+ <ImageColor label="Foreground colour" value={view.foreground} oninput={(foreground) => controller.setPaintOptions({ foreground })} />
80
75
  <span class="text-muted-foreground">FG</span>
81
76
  </label>
82
77
  <label class="flex items-center gap-1">
83
- <input
84
- type="color"
85
- aria-label="Background colour"
86
- value={view.background}
87
- oninput={(e) => controller.setPaintOptions({ background: (e.target as HTMLInputElement).value })}
88
- class={imageColorInputClass}
89
- />
78
+ <ImageColor label="Background colour" value={view.background} oninput={(background) => controller.setPaintOptions({ background })} />
90
79
  <span class="text-muted-foreground">BG</span>
91
80
  </label>
92
81
  <button type="button" title="Swap colours (X)" class={imageChipClass(false)} onclick={() => controller.swapPaintColors()}>Swap</button>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import ImageColor from './controls/ImageColor.svelte';
2
3
  import { imageDividerClass, imageEditorFrameClass, imageEditorHeaderClass, imageErrorBannerClass, imageHeaderButtonClass, imagePrimaryButtonClass, imageRangeClass, imageSectionClass, imageSectionTitleClass, imageSelectClass, imageSidebarClass, imageSmallButtonClass, imageSpinnerClass, imageTabClass, imageTabListClass } from './styles.js';
3
4
  import type {
4
5
  ExportOptions,
@@ -747,11 +748,7 @@
747
748
  <!-- Color & Size -->
748
749
  <div class="flex items-center gap-2 mt-2">
749
750
  <span class="text-muted-foreground">Color:</span>
750
- <input
751
- type="color"
752
- bind:value={activeColor}
753
- class="w-8 h-8 rounded border border-border cursor-pointer bg-transparent"
754
- />
751
+ <ImageColor label="Annotation colour" value={activeColor} oninput={(c) => (activeColor = c)} />
755
752
  <div class="flex-1 ml-2">
756
753
  <div class="flex justify-between text-muted-foreground mb-0.5">
757
754
  <span>Width:</span>
@@ -1,11 +1,14 @@
1
1
  <script lang="ts">
2
2
  import { imageOutlineButtonClass } from './styles.js';
3
- import { EFFECT_LAYER_USES, findLayer, type ImageEditorController, type ImageLayer, type SmartObjectLayer } from '@pixerate/editor/image-editor';
3
+ import { EFFECT_LAYER_USES, LAYER_EFFECT_MENU, effectLayerTiming, findLayer, layerEffectsPanel, type ImageEditorController, type ImageLayer, type LayerEffectKind, type SmartObjectLayer } from '@pixerate/editor/image-editor';
4
4
  import { untrack } from 'svelte';
5
5
  import EffectsBrowser from '../effects/EffectsBrowser.svelte';
6
+ import { effectDescriptionOf } from '../effects/effects.svelte.js';
6
7
  import ImageEffectControls from './ImageEffectControls.svelte';
8
+ import LayerEffectsPanel from './LayerEffectsPanel.svelte';
7
9
  import ImageCheckbox from './controls/ImageCheckbox.svelte';
8
10
  import ImageRange from './controls/ImageRange.svelte';
11
+ import ImageSelect from './controls/ImageSelect.svelte';
9
12
  import type { ParameterComponents } from '../parameters/index.js';
10
13
 
11
14
  interface Props {
@@ -21,7 +24,8 @@
21
24
  const c = controller;
22
25
  return untrack(() => c.subscribe(() => tick++));
23
26
  });
24
- let browse = $state<null | 'filter' | 'layer'>(null);
27
+ /** The open effect browser: filters to apply, or effects to change an effect layer to. */
28
+ let browse = $state<null | 'filter' | 'change'>(null);
25
29
  let message = $state<string | null>(null);
26
30
  let open = $state<string | null>(null);
27
31
  const snapshot = $derived.by(() => {
@@ -35,6 +39,13 @@
35
39
  const all = $derived(layer?.type === 'smart' ? ((layer as SmartObjectLayer).smartFilters?.filters ?? []) : []);
36
40
  const generator = $derived(layer?.type === 'pixel' ? layer.generator : undefined);
37
41
  const canFilter = $derived(!!layer && layer.type !== 'adjustment' && layer.type !== 'annotation');
42
+ const styles = $derived(layerEffectsPanel(layer));
43
+ const described = effectDescriptionOf(() => provider, () => generator?.effect);
44
+ const timing = $derived(generator ? effectLayerTiming(generator, described.description) : null);
45
+ const addOptions = $derived([
46
+ ...(provider && canFilter ? [{ value: 'filter', label: 'Filter…' }] : []),
47
+ ...(styles.available && !styles.readOnly ? LAYER_EFFECT_MENU.map((m) => ({ value: `style:${m.kind}`, label: m.label })) : []),
48
+ ]);
38
49
  const drags = new Set<string>();
39
50
  /** Changes during a drag skip the undo stack after the first, so the drag is one step. */
40
51
  function commit(gesture: string | null) {
@@ -49,35 +60,102 @@
49
60
  .then(work)
50
61
  .catch((e: unknown) => (message = e instanceof Error ? e.message : String(e)));
51
62
  }
63
+ function add(value: string) {
64
+ const id = layer?.id;
65
+ if (!value || !id) return;
66
+ if (value === 'filter') browse = browse === 'filter' ? null : 'filter';
67
+ else run(() => controller.addLayerEffect(id, value.slice('style:'.length) as LayerEffectKind));
68
+ }
69
+
70
+ // Scrubbing the frame draws the effect again: one render at a time, the latest frame wins.
71
+ let scrub: { layerId: string; time: number; commit: boolean } | null = null;
72
+ let scrubbing = false;
73
+ let gesture: string | null = null;
74
+ let scrubbed = $state<number | null>(null);
75
+ function scrubTo(layerId: string, time: number) {
76
+ scrubbed = time;
77
+ scrub = { layerId, time, commit: commit(gesture) };
78
+ if (scrubbing) return;
79
+ scrubbing = true;
80
+ const next = (): Promise<void> => {
81
+ const s = scrub;
82
+ scrub = null;
83
+ if (!s) return Promise.resolve();
84
+ return controller.updateEffectLayer(s.layerId, { time: s.time }, s.commit).then(next);
85
+ };
86
+ next()
87
+ .catch((e: unknown) => (message = e instanceof Error ? e.message : String(e)))
88
+ .finally(() => {
89
+ scrubbing = false;
90
+ scrubbed = null;
91
+ });
92
+ }
93
+ const fmt = (ms: number) => `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)} s`;
52
94
  </script>
53
95
 
54
96
  <!--
55
- Provider effects for the active layer: its effect smart filters (apply, turn on and off, opacity,
56
- reorder, change controls, remove) and, for an effect layer, its own controls; plus adding effect
57
- layers. Applying a filter effect to a pixel layer converts it to a smart object in the same undo
58
- step. Without a provider it says why nothing can be added.
97
+ Effects for the active layer, in one place. An effect layer first: its effect (change it or
98
+ rasterize it), a still frame or an animation for an effect that moves, and its controls. Then
99
+ what is applied to any layer: provider filters (smart filters; applying one converts a pixel
100
+ layer to a smart object in the same undo step) and layer styles, added from one menu. New
101
+ effect layers come from the layers panel. Without a provider it says why nothing can be added.
59
102
  -->
60
- <section class="flex flex-col gap-2 text-xs {className}" aria-label="Provider effects" data-image-provider-effects="">
61
- <div class="flex items-center gap-1">
62
- <span class="flex-1 text-[11px] font-semibold uppercase tracking-wider text-foreground">Effects</span>
63
- <button type="button" class={imageOutlineButtonClass} disabled={!provider || !canFilter} aria-pressed={browse === 'filter'} onclick={() => (browse = browse === 'filter' ? null : 'filter')}>Apply effect…</button>
64
- <button type="button" class={imageOutlineButtonClass} disabled={!provider} aria-pressed={browse === 'layer'} onclick={() => (browse = browse === 'layer' ? null : 'layer')}>Effect layer…</button>
65
- </div>
103
+ <section class="flex flex-col gap-2 text-xs {className}" aria-label="Effects" data-image-provider-effects="">
66
104
  {#if !provider}<p class="text-[11px] text-muted-foreground">No effects provider is registered. Effects in the document are kept.</p>{/if}
67
- {#if browse === 'filter' && layer}
68
- {@const id = layer.id}
69
- <EffectsBrowser {provider} uses={['filter']} title="Filters" onpick={(e) => ((browse = null), run(() => controller.applyEffectFilter(id, e.id)))} />
70
- {/if}
71
- {#if browse === 'layer'}
72
- <EffectsBrowser {provider} uses={EFFECT_LAYER_USES} title="Generators and overlays" onpick={(e) => ((browse = null), run(() => controller.addEffectLayer(e.id)))} />
73
- {/if}
74
105
  {#if generator && layer}
75
106
  {@const id = layer.id}
76
- <div class="space-y-1" data-effect-part="generator">
107
+ <div class="space-y-2" data-effect-part="generator">
77
108
  <div class="flex items-center gap-1">
78
- <span class="flex-1 font-medium text-foreground">{generator.name ?? generator.effect}</span>
109
+ <span class="min-w-0 flex-1 truncate font-medium text-foreground" title={generator.name ?? generator.effect}>{generator.name ?? generator.effect}</span>
110
+ <button type="button" class={imageOutlineButtonClass} disabled={!provider} aria-pressed={browse === 'change'} onclick={() => (browse = browse === 'change' ? null : 'change')}>Change…</button>
79
111
  <button type="button" class={imageOutlineButtonClass} title="Keep the pixels as a plain layer" onclick={() => run(() => controller.rasterizeEffectLayer(id))}>Rasterize</button>
80
112
  </div>
113
+ {#if browse === 'change'}
114
+ <EffectsBrowser {provider} uses={EFFECT_LAYER_USES} title="Change the effect to" onpick={(e) => ((browse = null), run(() => controller.updateEffectLayer(id, { effect: e.id })))} />
115
+ {/if}
116
+ {#if timing}
117
+ {@const shown = scrubbed ?? timing.time}
118
+ <div class="space-y-1.5 rounded border border-border p-1.5" data-effect-part="timing" role="group" aria-label="Frames">
119
+ <div class="flex gap-1" role="radiogroup" aria-label="Output">
120
+ <button type="button" role="radio" aria-checked={timing.mode === 'still'} class="{imageOutlineButtonClass} flex-1 {timing.mode === 'still' ? 'bg-accent text-accent-foreground' : ''}" onclick={() => run(() => controller.updateEffectLayer(id, { animate: null }))}>Still frame</button>
121
+ <button type="button" role="radio" aria-checked={timing.mode === 'animate'} class="{imageOutlineButtonClass} flex-1 {timing.mode === 'animate' ? 'bg-accent text-accent-foreground' : ''}" onclick={() => run(() => controller.updateEffectLayer(id, { animate: { duration: timing.duration } }))}>Animate</button>
122
+ </div>
123
+ <label class="flex items-center gap-1.5">
124
+ <span class="w-16 shrink-0 text-[11px] text-foreground">{timing.mode === 'still' ? 'Frame' : 'Starts at'}</span>
125
+ <ImageRange
126
+ min={0}
127
+ max={timing.range}
128
+ step={Math.max(1, Math.round(timing.range / 200))}
129
+ class="flex-1"
130
+ value={shown}
131
+ label={timing.mode === 'still' ? 'Frame' : 'Starts at'}
132
+ onstart={() => (gesture = `time:${Date.now()}`)}
133
+ onend={() => (gesture = null)}
134
+ oninput={(t) => scrubTo(id, t)}
135
+ />
136
+ <span class="w-12 shrink-0 text-right tabular-nums text-muted-foreground">{fmt(shown)}</span>
137
+ </label>
138
+ {#if timing.mode === 'animate'}
139
+ <label class="flex items-center gap-1.5">
140
+ <span class="w-16 shrink-0 text-[11px] text-foreground">Length</span>
141
+ <input
142
+ type="number"
143
+ aria-label="Animation length in seconds"
144
+ min="0.1"
145
+ max="60"
146
+ step="0.1"
147
+ value={Math.round(timing.duration / 100) / 10}
148
+ class="w-16 rounded border border-border bg-muted px-1 text-foreground"
149
+ onchange={(e) => {
150
+ const s = Number((e.currentTarget as HTMLInputElement).value);
151
+ if (s > 0) run(() => controller.updateEffectLayer(id, { animate: { duration: Math.min(60, s) * 1000 } }));
152
+ }}
153
+ />
154
+ <span class="text-muted-foreground">s{timing.loops ? ` · loops every ${fmt(timing.range)}` : ''}</span>
155
+ </label>
156
+ {/if}
157
+ </div>
158
+ {/if}
81
159
  <ImageEffectControls
82
160
  {provider}
83
161
  effect={generator.effect}
@@ -89,9 +167,21 @@
89
167
  />
90
168
  </div>
91
169
  {/if}
170
+ {#if layer && canFilter}
171
+ <div class="flex items-center gap-1" data-effect-part="applied">
172
+ <span class="flex-1 text-[11px] font-semibold uppercase tracking-wider text-foreground">{generator ? 'On this layer' : 'Effects'}</span>
173
+ {#if addOptions.length}
174
+ <ImageSelect label="Add effect" value="" placeholder="Add…" options={addOptions} onchange={add} />
175
+ {/if}
176
+ </div>
177
+ {#if browse === 'filter'}
178
+ {@const id = layer.id}
179
+ <EffectsBrowser {provider} uses={['filter']} title="Filters" onpick={(e) => ((browse = null), run(() => controller.applyEffectFilter(id, e.id)))} />
180
+ {/if}
181
+ {/if}
92
182
  {#if layer && all.some((f) => f.kind === 'effect')}
93
183
  {@const id = layer.id}
94
- <ol class="space-y-1" data-effect-part="filters">
184
+ <ol class="space-y-1" data-effect-part="filters" aria-label="Filters">
95
185
  {#each all as f, index (f.id)}
96
186
  {#if f.kind === 'effect'}
97
187
  {@const label = f.name ?? f.effect}
@@ -129,6 +219,12 @@
129
219
  {/each}
130
220
  </ol>
131
221
  {/if}
222
+ {#if layer && styles.available && styles.rows.length}
223
+ <LayerEffectsPanel {controller} layerId={layer.id} heading={false} />
224
+ {/if}
225
+ {#if layer && canFilter && !generator && !all.some((f) => f.kind === 'effect') && !styles.rows.length}
226
+ <p class="text-[11px] text-muted-foreground">No effects yet. Add a filter, or a style such as a drop shadow. New effect layers are added from the layers panel.</p>
227
+ {/if}
132
228
  {#if !layer}<p class="text-[11px] text-muted-foreground">Select a layer to apply effects to it.</p>{/if}
133
229
  {#if message}<p role="alert" class="text-[11px] text-destructive">{message}</p>{/if}
134
230
  </section>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import ImageColor from './controls/ImageColor.svelte';
2
3
  import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
4
  import ImageRange from './controls/ImageRange.svelte';
4
5
  import ImageSelect from './controls/ImageSelect.svelte';
@@ -16,10 +17,15 @@
16
17
  controller: ImageEditorController;
17
18
  /** The layer whose effects to show (default: the active layer). */
18
19
  layerId?: string | null;
20
+ /**
21
+ * Show the "fx Effects" title and the "Add effect" menu (default true). Without them (inside a
22
+ * panel that adds styles from its own menu) the master switch and Scale stay.
23
+ */
24
+ heading?: boolean;
19
25
  class?: string;
20
26
  }
21
27
 
22
- let { controller, layerId = null, class: className = '' }: Props = $props();
28
+ let { controller, layerId = null, heading = true, class: className = '' }: Props = $props();
23
29
 
24
30
  let tick = $state(0);
25
31
  $effect(() => {
@@ -83,7 +89,7 @@
83
89
  {@const id = view.id}
84
90
  <div class="flex flex-col gap-1.5 text-xs text-foreground {className}" data-testid="layer-effects" aria-label="Layer effects" role="group">
85
91
  <div class="flex flex-wrap items-center gap-2">
86
- <span class="font-semibold text-foreground">fx Effects</span>
92
+ {#if heading}<span class="font-semibold text-foreground">fx Effects</span>{:else}<span class="font-semibold text-foreground">Styles</span>{/if}
87
93
  <label class="flex items-center gap-1 text-foreground">
88
94
  <ImageCheckbox
89
95
  label="Effects on"
@@ -108,14 +114,16 @@
108
114
  />
109
115
  %
110
116
  </label>
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
- />
117
+ {#if heading}
118
+ <ImageSelect
119
+ label="Add effect"
120
+ value=""
121
+ placeholder="Add effect…"
122
+ disabled={view.model.readOnly}
123
+ options={LAYER_EFFECT_MENU.map((m) => ({ value: m.kind, label: m.label }))}
124
+ onchange={add}
125
+ />
126
+ {/if}
119
127
  </div>
120
128
  {#if view.model.readOnly && view.model.reason}<p class="text-muted-foreground">{view.model.reason}</p>{/if}
121
129
  <ul class="flex flex-col gap-1" aria-label="Effects">
@@ -155,7 +163,7 @@
155
163
  </label>
156
164
  {:else if c.type === 'color'}
157
165
  <label class="flex items-center gap-1.5 text-foreground">
158
- <input type="color" aria-label={c.label} value={c.value} disabled={view.model.readOnly} oninput={(e) => change(row.id, c, (e.currentTarget as HTMLInputElement).value)} />
166
+ <ImageColor label={c.label} value={c.value} disabled={view.model.readOnly} oninput={(v) => change(row.id, c, v)} />
159
167
  {c.label}
160
168
  </label>
161
169
  {:else if c.type === 'checkbox'}
@@ -8,6 +8,11 @@ interface Props {
8
8
  controller: ImageEditorController;
9
9
  /** The layer whose effects to show (default: the active layer). */
10
10
  layerId?: string | null;
11
+ /**
12
+ * Show the "fx Effects" title and the "Add effect" menu (default true). Without them (inside a
13
+ * panel that adds styles from its own menu) the master switch and Scale stay.
14
+ */
15
+ heading?: boolean;
11
16
  class?: string;
12
17
  }
13
18
  declare const LayerEffectsPanel: import("svelte").Component<Props, {}, "">;
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
+ import ImageColor from './controls/ImageColor.svelte';
2
3
  import ImageCheckbox from './controls/ImageCheckbox.svelte';
3
4
  import ImageRange from './controls/ImageRange.svelte';
4
- import { imageChipClass, imageColorInputClass, imageFieldClass } from './styles.js';
5
+ import { imageChipClass, imageFieldClass } from './styles.js';
5
6
  import type { ImageEditorController, ShapeToolOptions } from '@pixerate/editor/image-editor';
6
7
  import { LIVE_SHAPE_KINDS, SHAPE_KIND_LABELS, applyShapeOptions } from '@pixerate/editor/image-editor';
7
8
  import { untrack } from 'svelte';
@@ -58,26 +59,12 @@
58
59
  <label class="flex items-center gap-1">
59
60
  <ImageCheckbox label="Fill" checked={options.fill !== null} onchange={(on) => set({ fill: on ? '#4f46e5' : null })} />
60
61
  Fill
61
- <input
62
- type="color"
63
- aria-label="Fill colour"
64
- disabled={options.fill === null}
65
- value={options.fill ?? '#4f46e5'}
66
- oninput={(e) => set({ fill: value(e) })}
67
- class={imageColorInputClass}
68
- />
62
+ <ImageColor label="Fill colour" disabled={options.fill === null} value={options.fill ?? '#4f46e5'} oninput={(fill) => set({ fill })} />
69
63
  </label>
70
64
  <label class="flex items-center gap-1">
71
65
  <ImageCheckbox label="Stroke" checked={options.stroke !== null} onchange={(on) => set({ stroke: on ? '#000000' : null })} />
72
66
  Stroke
73
- <input
74
- type="color"
75
- aria-label="Stroke colour"
76
- disabled={options.stroke === null}
77
- value={options.stroke ?? '#000000'}
78
- oninput={(e) => set({ stroke: value(e) })}
79
- class={imageColorInputClass}
80
- />
67
+ <ImageColor label="Stroke colour" disabled={options.stroke === null} value={options.stroke ?? '#000000'} oninput={(stroke) => set({ stroke })} />
81
68
  </label>
82
69
  <label class="flex items-center gap-1">
83
70
  Width
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- import { imageChipClass, imageColorInputClass } from './styles.js';
2
+ import ImageColor from './controls/ImageColor.svelte';
3
+ import { imageChipClass } from './styles.js';
3
4
  import type { ImageEditorController, TextAlign, TextEditSession, TextStyle, TypeToolOptions } from '@pixerate/editor/image-editor';
4
5
  import { TEXT_ALIGNS } from '@pixerate/editor/image-editor';
5
6
  import { untrack } from 'svelte';
@@ -100,7 +101,7 @@
100
101
  </label>
101
102
  <button type="button" aria-pressed={view.shown.fontWeight >= 600} class="{imageChipClass(view.shown.fontWeight >= 600)} font-bold" onclick={() => style({ fontWeight: view.shown.fontWeight >= 600 ? 400 : 700 })}>B</button>
102
103
  <button type="button" aria-pressed={view.shown.italic} class="{imageChipClass(view.shown.italic)} italic" onclick={() => style({ italic: !view.shown.italic })}>I</button>
103
- <input type="color" aria-label="Text colour" value={view.shown.color} oninput={(e) => style({ color: value(e) })} class={imageColorInputClass} />
104
+ <ImageColor label="Text colour" value={view.shown.color} oninput={(color) => style({ color })} />
104
105
  </div>
105
106
  <div class="flex gap-1" role="toolbar" aria-label="Alignment">
106
107
  {#each TEXT_ALIGNS as a (a)}
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import { getImageEditorControls, type ImageColorProps } from './controls.js';
3
+ import { imageColorInputClass } from '../styles.js';
4
+
5
+ let { value, label, alpha = false, disabled = false, oninput, onstart, onend, class: className = '' }: ImageColorProps = $props();
6
+ const Host = getImageEditorControls().Color;
7
+ </script>
8
+
9
+ {#if Host}
10
+ <Host {value} {label} {alpha} {disabled} {oninput} {onstart} {onend} class={className} />
11
+ {:else}
12
+ <!-- The native well has no alpha: keep the stored alpha on what it picks. -->
13
+ <input
14
+ type="color"
15
+ aria-label={label}
16
+ value={value.slice(0, 7)}
17
+ {disabled}
18
+ oninput={(e) => oninput((e.currentTarget as HTMLInputElement).value + (alpha ? value.slice(7, 9) : ''))}
19
+ class="{imageColorInputClass} {className}"
20
+ />
21
+ {/if}
@@ -0,0 +1,4 @@
1
+ import { type ImageColorProps } from './controls.js';
2
+ declare const ImageColor: import("svelte").Component<ImageColorProps, {}, "">;
3
+ type ImageColor = ReturnType<typeof ImageColor>;
4
+ export default ImageColor;
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ import type { ParameterColorProps } from '../../parameters/controls.js';
3
+ import ImageColor from './ImageColor.svelte';
4
+
5
+ let { value, label, alpha = false, disabled = false, class: className = '', onchange, ongesturestart, ongestureend }: ParameterColorProps = $props();
6
+ </script>
7
+
8
+ <!-- A parameter colour drawn with the host's image-editor `Color`. -->
9
+ <ImageColor {value} {label} {alpha} {disabled} class={className} oninput={onchange} onstart={ongesturestart} onend={ongestureend} />
@@ -0,0 +1,4 @@
1
+ import type { ParameterColorProps } from '../../parameters/controls.js';
2
+ declare const ImageParameterColor: import("svelte").Component<ParameterColorProps, {}, "">;
3
+ type ImageParameterColor = ReturnType<typeof ImageParameterColor>;
4
+ export default ImageParameterColor;
@@ -42,6 +42,22 @@ export interface ImageSelectProps {
42
42
  onchange: (value: string) => void;
43
43
  class?: string;
44
44
  }
45
+ /** A colour well in the image editor's panels. */
46
+ export interface ImageColorProps {
47
+ /** `#rrggbb`, or `#rrggbbaa` with `alpha`. */
48
+ value: string;
49
+ /** The accessible name. */
50
+ label: string;
51
+ /** Whether the colour has an alpha channel. */
52
+ alpha?: boolean;
53
+ disabled?: boolean;
54
+ /** Every change while the colour is picked. */
55
+ oninput: (value: string) => void;
56
+ /** A pick (e.g. a drag in the picker) starts: changes until `onend` merge into one undo step. */
57
+ onstart?: () => void;
58
+ onend?: () => void;
59
+ class?: string;
60
+ }
45
61
  /**
46
62
  * The host's own form controls for the image editor's panels. Any left out fall back to the
47
63
  * editor's plain inputs.
@@ -50,6 +66,7 @@ export interface ImageEditorControls {
50
66
  Checkbox?: Component<ImageCheckboxProps>;
51
67
  Range?: Component<ImageRangeProps>;
52
68
  Select?: Component<ImageSelectProps>;
69
+ Color?: Component<ImageColorProps>;
53
70
  }
54
71
  /**
55
72
  * Makes the image editor's panels below this component use the host's controls (call it while
@@ -1,4 +1,4 @@
1
1
  import type { ParameterComponents } from '../../parameters/controls.js';
2
2
  import type { ImageEditorControls } from './controls.js';
3
- /** The host's image-editor `Range` and `Select` as parameter controls (only the ones it set). */
3
+ /** The host's image-editor `Range`, `Select` and `Color` as parameter controls (only the ones it set). */
4
4
  export declare function imageEditorParameterComponents(controls: ImageEditorControls): ParameterComponents;
@@ -1,9 +1,11 @@
1
+ import ImageParameterColor from './ImageParameterColor.svelte';
1
2
  import ImageParameterSelect from './ImageParameterSelect.svelte';
2
3
  import ImageParameterSlider from './ImageParameterSlider.svelte';
3
- /** The host's image-editor `Range` and `Select` as parameter controls (only the ones it set). */
4
+ /** The host's image-editor `Range`, `Select` and `Color` as parameter controls (only the ones it set). */
4
5
  export function imageEditorParameterComponents(controls) {
5
6
  return {
6
7
  slider: controls.Range ? ImageParameterSlider : undefined,
7
8
  select: controls.Select ? ImageParameterSelect : undefined,
9
+ color: controls.Color ? ImageParameterColor : undefined,
8
10
  };
9
11
  }
@@ -43,7 +43,8 @@ export * from './imageEditorState.svelte.js';
43
43
  export { default as ImageCheckbox } from './controls/ImageCheckbox.svelte';
44
44
  export { default as ImageRange } from './controls/ImageRange.svelte';
45
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';
46
+ export { default as ImageColor } from './controls/ImageColor.svelte';
47
+ export { setImageEditorControls, getImageEditorControls, type ImageEditorControls, type ImageCheckboxProps, type ImageRangeProps, type ImageSelectProps, type ImageSelectOption, type ImageColorProps, } from './controls/controls.js';
47
48
  export type ImageEditorProps = ComponentProps<typeof ImageEditorComponent>;
48
49
  export type ImageEditorCanvasProps = ComponentProps<typeof ImageEditorCanvasComponent>;
49
50
  export type CanvasSizePanelProps = ComponentProps<typeof CanvasSizePanelComponent>;
@@ -22,5 +22,6 @@ export * from './imageEditorState.svelte.js';
22
22
  export { default as ImageCheckbox } from './controls/ImageCheckbox.svelte';
23
23
  export { default as ImageRange } from './controls/ImageRange.svelte';
24
24
  export { default as ImageSelect } from './controls/ImageSelect.svelte';
25
+ export { default as ImageColor } from './controls/ImageColor.svelte';
25
26
  export { setImageEditorControls, getImageEditorControls, } from './controls/controls.js';
26
27
  export { applyRegionMosaic, applyRegionBlur, resolveMosaicRegionBounds } from '@pixerate/editor/image-editor';
@@ -3,6 +3,7 @@
3
3
  import ColorPicker from '../color/ColorPicker.svelte';
4
4
  import ParameterCommitInput from './ParameterCommitInput.svelte';
5
5
  import { parameterInputClass } from './styles.js';
6
+ import { getParameterComponents, resolveParameterComponents, type ParameterComponents } from './controls.js';
6
7
 
7
8
  interface Props {
8
9
  field: ColorParameterField;
@@ -13,9 +14,10 @@
13
14
  ongestureend?: () => void;
14
15
  disabled?: boolean;
15
16
  id?: string;
17
+ components?: ParameterComponents;
16
18
  }
17
19
 
18
- let { field, value, onchange, ongesturestart, ongestureend, disabled = false, id }: Props = $props();
20
+ let { field, value, onchange, ongesturestart, ongestureend, disabled = false, id, components }: Props = $props();
19
21
 
20
22
  const alpha = $derived(!!field.alpha);
21
23
  const hex = $derived(normalizeHexColor(value, true) ?? '#000000ff');
@@ -23,6 +25,8 @@
23
25
  const shown = $derived(alpha ? hex : rgb);
24
26
  const off = $derived(disabled || !!field.readOnly);
25
27
  const name = $derived(parameterLabel(field));
28
+ const context = getParameterComponents();
29
+ const CustomColor = $derived(resolveParameterComponents(components, context).color);
26
30
 
27
31
  let open = $state(false);
28
32
  let containerEl = $state<HTMLDivElement | null>(null);
@@ -52,57 +56,61 @@
52
56
  });
53
57
  </script>
54
58
 
55
- <!-- A colour picker with a hex box and a popup ColorPicker. -->
56
- <div bind:this={containerEl} class="relative space-y-1.5">
57
- <div class="flex items-center gap-2">
58
- <button
59
- bind:this={buttonEl}
60
- type="button"
61
- aria-label={name}
62
- aria-expanded={open}
63
- aria-haspopup="dialog"
64
- disabled={off}
65
- onclick={() => (open = !open)}
66
- class="relative h-7 w-9 shrink-0 overflow-hidden rounded-md border border-input bg-[repeating-conic-gradient(#d4d4d8_0_25%,#fff_0_50%)] bg-[length:8px_8px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
67
- >
68
- <span class="absolute inset-0" style:background={shown}></span>
69
- </button>
70
- <ParameterCommitInput
71
- {id}
72
- aria-label="{name} hex"
73
- value={shown}
74
- disabled={off}
75
- spellcheck={false}
76
- class="{parameterInputClass} font-mono"
77
- oncommit={(text) => {
78
- const next = normalizeHexColor(text, alpha);
79
- if (next) onchange(next);
80
- }}
81
- />
82
- </div>
83
- {#if open}
84
- <div
85
- bind:this={popoverEl}
86
- role="dialog"
87
- aria-label={name}
88
- tabindex="-1"
89
- onfocusout={(e) => {
90
- if (popoverEl && !popoverEl.contains(e.relatedTarget as Node)) {
91
- open = false;
92
- }
93
- }}
94
- class="absolute left-0 top-full z-50 mt-1 w-64 rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-xl"
95
- style="background: var(--popover, var(--background, #fff));"
96
- >
97
- <ColorPicker
59
+ <!-- A colour picker with a hex box and a popup ColorPicker, or the host's own colour control. -->
60
+ {#if CustomColor}
61
+ <CustomColor {id} value={shown} {alpha} swatches={field.swatches} disabled={off} label={name} {onchange} {ongesturestart} {ongestureend} />
62
+ {:else}
63
+ <div bind:this={containerEl} class="relative space-y-1.5">
64
+ <div class="flex items-center gap-2">
65
+ <button
66
+ bind:this={buttonEl}
67
+ type="button"
68
+ aria-label={name}
69
+ aria-expanded={open}
70
+ aria-haspopup="dialog"
71
+ disabled={off}
72
+ onclick={() => (open = !open)}
73
+ class="relative h-7 w-9 shrink-0 overflow-hidden rounded-md border border-input bg-[repeating-conic-gradient(#d4d4d8_0_25%,#fff_0_50%)] bg-[length:8px_8px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
74
+ >
75
+ <span class="absolute inset-0" style:background={shown}></span>
76
+ </button>
77
+ <ParameterCommitInput
78
+ {id}
79
+ aria-label="{name} hex"
98
80
  value={shown}
99
- {alpha}
100
- swatches={field.swatches}
101
81
  disabled={off}
102
- {onchange}
103
- {ongesturestart}
104
- {ongestureend}
82
+ spellcheck={false}
83
+ class="{parameterInputClass} font-mono"
84
+ oncommit={(text) => {
85
+ const next = normalizeHexColor(text, alpha);
86
+ if (next) onchange(next);
87
+ }}
105
88
  />
106
89
  </div>
107
- {/if}
108
- </div>
90
+ {#if open}
91
+ <div
92
+ bind:this={popoverEl}
93
+ role="dialog"
94
+ aria-label={name}
95
+ tabindex="-1"
96
+ onfocusout={(e) => {
97
+ if (popoverEl && !popoverEl.contains(e.relatedTarget as Node)) {
98
+ open = false;
99
+ }
100
+ }}
101
+ class="absolute left-0 top-full z-50 mt-1 w-64 rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-xl"
102
+ style="background: var(--popover, var(--background, #fff));"
103
+ >
104
+ <ColorPicker
105
+ value={shown}
106
+ {alpha}
107
+ swatches={field.swatches}
108
+ disabled={off}
109
+ {onchange}
110
+ {ongesturestart}
111
+ {ongestureend}
112
+ />
113
+ </div>
114
+ {/if}
115
+ </div>
116
+ {/if}
@@ -1,4 +1,5 @@
1
1
  import { type ColorParameterField } from '@pixerate/editor/parameters';
2
+ import { type ParameterComponents } from './controls.js';
2
3
  interface Props {
3
4
  field: ColorParameterField;
4
5
  value: string;
@@ -8,6 +9,7 @@ interface Props {
8
9
  ongestureend?: () => void;
9
10
  disabled?: boolean;
10
11
  id?: string;
12
+ components?: ParameterComponents;
11
13
  }
12
14
  declare const ParameterColor: import("svelte").Component<Props, {}, "">;
13
15
  type ParameterColor = ReturnType<typeof ParameterColor>;
@@ -33,7 +33,7 @@
33
33
  {:else if field.type === 'choice'}
34
34
  <ParameterSelect {field} value={value as string | number} onchange={set} {disabled} {id} {components} />
35
35
  {:else if field.type === 'color'}
36
- <ParameterColor {field} value={value as string} onchange={set} {ongesturestart} {ongestureend} {disabled} {id} />
36
+ <ParameterColor {field} value={value as string} onchange={set} {ongesturestart} {ongestureend} {disabled} {id} {components} />
37
37
  {:else if field.type === 'vector'}
38
38
  <ParameterVector {field} value={value as number[]} onchange={set} {disabled} {id} {components} />
39
39
  {:else if field.type === 'image'}
@@ -55,11 +55,31 @@ export interface ParameterSelectProps {
55
55
  style?: string;
56
56
  onchange: (value: string | number) => void;
57
57
  }
58
+ /** Props for a custom colour control in parameter panels. */
59
+ export interface ParameterColorProps {
60
+ id?: string;
61
+ /** `#rrggbb`, or `#rrggbbaa` with `alpha`. */
62
+ value: string;
63
+ /** Whether the field has an alpha channel. */
64
+ alpha?: boolean;
65
+ /** Suggested colours for the field, if it has any. */
66
+ swatches?: readonly string[];
67
+ disabled?: boolean;
68
+ /** The field's label. */
69
+ label: string;
70
+ class?: string;
71
+ style?: string;
72
+ onchange: (value: string) => void;
73
+ /** A pick starts: changes until `ongestureend` merge into one undo step. */
74
+ ongesturestart?: () => void;
75
+ ongestureend?: () => void;
76
+ }
58
77
  /** Host form controls for parameter panels. Any left out fall back to the default controls. */
59
78
  export interface ParameterComponents {
60
79
  input?: Component<ParameterInputProps>;
61
80
  slider?: Component<ParameterSliderProps>;
62
81
  select?: Component<ParameterSelectProps>;
82
+ color?: Component<ParameterColorProps>;
63
83
  }
64
84
  /** Makes the parameter panels below this component use the host's controls (call it while a component initialises). */
65
85
  export declare function setParameterComponents(components: ParameterComponents): ParameterComponents;
@@ -14,5 +14,6 @@ export function resolveParameterComponents(explicit, fallback = getParameterComp
14
14
  input: explicit?.input ?? fallback.input,
15
15
  slider: explicit?.slider ?? fallback.slider,
16
16
  select: explicit?.select ?? fallback.select,
17
+ color: explicit?.color ?? fallback.color,
17
18
  };
18
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.128.5",
3
+ "version": "0.130.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.175.0"
168
+ "@pixerate/editor": "0.176.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",