@pixerate/editor-svelte 0.106.0 → 0.108.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.
package/README.md CHANGED
@@ -79,7 +79,7 @@ Tables are on by default in `createRichTextPreset` (`table: false` turns them of
79
79
  | :--- | :--- |
80
80
  | `@pixerate/editor-svelte` | `EditableTextNodeEditor`, `BubbleMenu`, `TemplateRenderer`, `initiateEditor`, `createReactiveEditor`, `createHistory`, `createHistoryDirty`, `createHistoryShortcuts`, `navigationGuard` (action), `createNavigationGuard`, `SpreadsheetEditor`, `FormulaBar`, `FormulaHints`, `createReactiveSpreadsheet`, `DirtyTracker` |
81
81
  | `@pixerate/editor-svelte/spreadsheet` | `SpreadsheetEditor` (virtualised for large sheets, `virtualize` prop), `FormulaBar`, `FormulaHints`, `SpreadsheetContextMenu`, `SpreadsheetGroupRow`, `SheetTabs`, `NameManager`, `createReactiveSpreadsheet`, `createSpreadsheetViewport` (also re-exported from the root entry) |
82
- | `@pixerate/editor-svelte/image-editor` | `ImageEditor`, `createReactiveImageEditor`: crop, rotate/flip, adjustments, annotations, depth masks, Select & Mask (`SelectionOptionsBar`, `RefineEdgePanel`: magic wand, marquees, lasso, Refine Edge, layer masks), and `onInpaint` / `onRemoveBackground` AI hooks |
82
+ | `@pixerate/editor-svelte/image-editor` | `ImageEditor`, `createReactiveImageEditor`, `ImageEditorCanvas` (the canvas alone, for your own chrome; see `docs/image-editor-canvas.md`), `ImageEffectControls`: crop, rotate/flip, adjustments, annotations, depth masks, Select & Mask (`SelectionOptionsBar`, `RefineEdgePanel`: magic wand, marquees, lasso, Refine Edge, layer masks), and `onInpaint` / `onRemoveBackground` AI hooks |
83
83
  | `@pixerate/editor-svelte/paint-editor` | `PaintEditor`, `createReactivePaintEditor`: natural-media painting with `PaintCanvas` (pen pressure, tilt and twist, coalesced and predicted events, palm rejection, view rotation and mirroring), `PaintToolbar` (tools, symmetry, perspective, tracing and reference images, save / open, PNG / JPEG / PSD), `BrushPanel` (library with thumbnails), `PaperPanel`, `ColorPanel` (wheel, harmonies, swatches, mixing pad) and `PaintLayersPanel` |
84
84
  | `@pixerate/editor-svelte/commands` | `CommandPalette`, `createCommandPalette` (`hotkey: "Mod+K"`; call `destroy()` when done), `createCommandShortcuts`, `useRegisterCommands` over a `CommandRegistry` from `@pixerate/editor/commands` (also re-exported from the root entry) |
85
85
  | `@pixerate/editor-svelte/persistence` | `createAutosave` (crash-recovery snapshots bound to a history / dirty flag, flushed on `pagehide`; call `destroy()` when done), `createEditorAutosave` (the same for any editor's `EditorDocument`, e.g. `createMermaidDiagram().editorDocument`; `restore` loads the snapshot into the editor) and `<RecoveryPrompt>` (also re-exported from the root entry) |
@@ -115,7 +115,7 @@ The components are styled with [Tailwind CSS](https://tailwindcss.com) utility c
115
115
 
116
116
  2. **Define the theme colors.** `SpreadsheetEditor`, `FormulaBar`, `TemplateRenderer` and the editor use [shadcn-svelte](https://www.shadcn-svelte.com)-style tokens: `background`, `foreground`, `muted`, `muted-foreground`, `border`, `input`, `primary`, `popover`, `popover-foreground` and `destructive`. Projects already using shadcn-svelte have these. Otherwise, define them as colors in your Tailwind theme.
117
117
 
118
- `ImageEditor` uses a fixed dark (slate) palette and only needs step 1. Component-scoped styles, such as the editor placeholder, are included automatically.
118
+ `ImageEditor`, `ImageEditorCanvas` and the image panels use the same tokens (plus `accent`, `primary-foreground` and `accent-foreground`), so they follow your light or dark theme; their shared classes are in `image-editor/styles.ts`. Component-scoped styles, such as the editor placeholder, are included automatically.
119
119
 
120
120
  ## License
121
121
 
@@ -0,0 +1,447 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { effectSampleInputs, type EffectAuthoringSession, type EffectRenderer } from '@pixerate/editor/effects';
4
+ import { effectAuthoringOf } from './effects.svelte.js';
5
+ import EffectParameters from './EffectParameters.svelte';
6
+
7
+ interface Props {
8
+ /** The effect authoring session to edit. */
9
+ session: EffectAuthoringSession | null;
10
+ /** Whether to show the source code editor pane when supported (default true). */
11
+ showSource?: boolean;
12
+ /** Whether to show the keyframe timeline below parameters (default true). */
13
+ showTimeline?: boolean;
14
+ /** Size of the preview canvas (default 480 × 270). */
15
+ previewSize?: { width: number; height: number };
16
+ class?: string;
17
+ }
18
+
19
+ let {
20
+ session,
21
+ showSource = true,
22
+ showTimeline = true,
23
+ previewSize = { width: 480, height: 270 },
24
+ class: className = ''
25
+ }: Props = $props();
26
+
27
+ const authoring = effectAuthoringOf(() => session);
28
+ const current = $derived(authoring.state);
29
+
30
+ // Preview renderer lifecycle
31
+ let canvas: HTMLCanvasElement | null = $state(null);
32
+ let renderer: EffectRenderer | null = $state.raw(null);
33
+ let rendering = false;
34
+ let pending = false;
35
+
36
+ // Preset saving state
37
+ let savingPreset = $state(false);
38
+ let presetName = $state('');
39
+
40
+ // Source code pane state
41
+ let textarea: HTMLTextAreaElement | null = $state(null);
42
+ let gutter: HTMLDivElement | null = $state(null);
43
+ let sourceCode = $state('');
44
+ let sourceDebounceTimer: ReturnType<typeof setTimeout> | null = null;
45
+
46
+ // Sync preview renderer
47
+ $effect(() => {
48
+ const s = session;
49
+ const w = previewSize.width;
50
+ const h = previewSize.height;
51
+
52
+ if (!s) {
53
+ if (renderer) {
54
+ renderer.dispose();
55
+ renderer = null;
56
+ }
57
+ return;
58
+ }
59
+
60
+ let live = true;
61
+ let createdRenderer: EffectRenderer | null = null;
62
+
63
+ Promise.resolve(s.createPreview({ width: w, height: h })).then((r) => {
64
+ if (!live) {
65
+ r.dispose();
66
+ return;
67
+ }
68
+ createdRenderer = r;
69
+ renderer = r;
70
+ });
71
+
72
+ return () => {
73
+ live = false;
74
+ if (createdRenderer) {
75
+ createdRenderer.dispose();
76
+ }
77
+ if (renderer === createdRenderer) {
78
+ renderer = null;
79
+ }
80
+ };
81
+ });
82
+
83
+ // Draws frames one at a time. A change while a frame renders asks for one more frame, which
84
+ // then shows the latest playhead and values, so the preview never stops on a stale frame.
85
+ let latestTime = 0;
86
+ /** Sample pictures for the effect's image inputs, so filters and transitions show something. */
87
+ const inputSpecs = $derived(current?.description.inputs);
88
+ const sampleInputs = $derived(inputSpecs ? effectSampleInputs({ inputs: inputSpecs }, previewSize) : {});
89
+ async function pump() {
90
+ if (rendering) {
91
+ pending = true;
92
+ return;
93
+ }
94
+ rendering = true;
95
+ try {
96
+ do {
97
+ pending = false;
98
+ const r = renderer;
99
+ if (!r) break;
100
+ try {
101
+ const pixels = await r.render({ time: latestTime, inputs: sampleInputs });
102
+ const ctx = renderer === r ? canvas?.getContext('2d') : null;
103
+ if (ctx && typeof ImageData !== 'undefined') {
104
+ ctx.putImageData(new ImageData(new Uint8ClampedArray(pixels.data), pixels.width, pixels.height), 0, 0);
105
+ }
106
+ } catch {
107
+ // A failed frame (for example a disposed renderer) is skipped; the next change draws again.
108
+ }
109
+ } while (pending);
110
+ } finally {
111
+ rendering = false;
112
+ }
113
+ }
114
+
115
+ $effect(() => {
116
+ const st = current;
117
+ if (!renderer || !st) return;
118
+ latestTime = st.time;
119
+ void pump();
120
+ });
121
+
122
+ // Sync source code when the session's source changes
123
+ $effect(() => {
124
+ if (current?.source !== null && current?.source !== undefined) {
125
+ sourceCode = current.source;
126
+ }
127
+ });
128
+
129
+ onDestroy(() => {
130
+ if (sourceDebounceTimer) clearTimeout(sourceDebounceTimer);
131
+ });
132
+
133
+ function handleSourceInput(e: Event & { currentTarget: HTMLTextAreaElement }) {
134
+ const val = e.currentTarget.value;
135
+ sourceCode = val;
136
+ if (sourceDebounceTimer) clearTimeout(sourceDebounceTimer);
137
+ sourceDebounceTimer = setTimeout(() => {
138
+ session?.setSource?.(val);
139
+ }, 300);
140
+ }
141
+
142
+ function handleSavePreset(e: SubmitEvent) {
143
+ e.preventDefault();
144
+ const trimmed = presetName.trim();
145
+ if (trimmed && session) {
146
+ session.savePreset(trimmed);
147
+ savingPreset = false;
148
+ presetName = '';
149
+ }
150
+ }
151
+
152
+ function jumpToLine(lineNum: number) {
153
+ if (!textarea) return;
154
+ const lines = sourceCode.split('\n');
155
+ let offset = 0;
156
+ for (let i = 0; i < lineNum - 1 && i < lines.length; i++) {
157
+ offset += lines[i].length + 1;
158
+ }
159
+ textarea.focus();
160
+ textarea.setSelectionRange(offset, offset);
161
+ }
162
+
163
+ const hasSource = $derived(
164
+ showSource && current?.sourceLanguage !== null && typeof session?.setSource === 'function'
165
+ );
166
+
167
+ const sourceLines = $derived(sourceCode.split('\n'));
168
+ const issueLines = $derived(
169
+ new Set((current?.issues ?? []).map((i) => i.line).filter((l): l is number => typeof l === 'number'))
170
+ );
171
+ </script>
172
+
173
+ {#if !session || !current}
174
+ <div class="p-4 text-center text-xs text-muted-foreground {className}">
175
+ No effect authoring session is open.
176
+ </div>
177
+ {:else}
178
+ <div class="space-y-4 rounded border border-border bg-background p-4 text-foreground {className}" data-effect-authoring-editor={current.name}>
179
+ <!-- Toolbar -->
180
+ <div class="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3">
181
+ <div class="flex items-center gap-2">
182
+ <button
183
+ type="button"
184
+ class="rounded border border-border px-2.5 py-1 text-xs font-medium hover:bg-muted"
185
+ aria-label={current.playing ? 'Pause playback' : 'Start playback'}
186
+ onclick={() => (current.playing ? session?.pause() : session?.play())}
187
+ >
188
+ {current.playing ? 'Pause' : 'Play'}
189
+ </button>
190
+ <span class="font-mono text-xs text-muted-foreground" data-time-readout>
191
+ {current.time}ms / {current.duration}ms
192
+ </span>
193
+ </div>
194
+
195
+ <div class="flex items-center gap-2">
196
+ <button
197
+ type="button"
198
+ class="rounded border border-border px-2 py-1 text-xs disabled:opacity-40 hover:bg-muted"
199
+ aria-label="Undo"
200
+ disabled={!current.canUndo}
201
+ onclick={() => session?.undo()}
202
+ >
203
+ Undo
204
+ </button>
205
+ <button
206
+ type="button"
207
+ class="rounded border border-border px-2 py-1 text-xs disabled:opacity-40 hover:bg-muted"
208
+ aria-label="Redo"
209
+ disabled={!current.canRedo}
210
+ onclick={() => session?.redo()}
211
+ >
212
+ Redo
213
+ </button>
214
+
215
+ {#if savingPreset}
216
+ <form onsubmit={handleSavePreset} class="flex items-center gap-1">
217
+ <input
218
+ type="text"
219
+ class="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
220
+ placeholder="Preset name"
221
+ bind:value={presetName}
222
+ />
223
+ <button
224
+ type="submit"
225
+ class="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground"
226
+ >
227
+ Save
228
+ </button>
229
+ <button
230
+ type="button"
231
+ class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-muted"
232
+ onclick={() => (savingPreset = false)}
233
+ >
234
+ Cancel
235
+ </button>
236
+ </form>
237
+ {:else}
238
+ <button
239
+ type="button"
240
+ class="rounded border border-border px-2 py-1 text-xs hover:bg-muted"
241
+ onclick={() => (savingPreset = true)}
242
+ >
243
+ Save preset…
244
+ </button>
245
+ {/if}
246
+ </div>
247
+ </div>
248
+
249
+ <!-- Main workspace: Preview + Source -->
250
+ <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
251
+ <!-- Preview Panel -->
252
+ <div class="space-y-2">
253
+ <div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
254
+ Preview
255
+ </div>
256
+ <div class="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded border border-border bg-black">
257
+ <canvas
258
+ bind:this={canvas}
259
+ width={previewSize.width}
260
+ height={previewSize.height}
261
+ class="h-full w-full object-contain"
262
+ aria-label="Effect preview canvas"
263
+ ></canvas>
264
+ </div>
265
+ {#if current.issues.length > 0}
266
+ <div class="space-y-1 rounded border border-destructive/20 bg-destructive/10 p-2 text-xs" data-effect-issues>
267
+ {#each current.issues as issue, idx (idx)}
268
+ <button
269
+ type="button"
270
+ class="block w-full text-left {issue.severity === 'error' ? 'text-destructive' : 'text-amber-500'}"
271
+ onclick={() => issue.line !== undefined && jumpToLine(issue.line)}
272
+ >
273
+ {issue.line !== undefined ? `Line ${issue.line}: ` : ''}
274
+ {issue.message}
275
+ </button>
276
+ {/each}
277
+ </div>
278
+ {/if}
279
+ </div>
280
+
281
+ <!-- Source Code Pane -->
282
+ {#if hasSource}
283
+ <div class="space-y-2" data-effect-source-pane>
284
+ <div class="flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-muted-foreground">
285
+ <span>Source ({current.sourceLanguage})</span>
286
+ </div>
287
+ <div class="flex h-64 rounded border border-border bg-muted/20 font-mono text-xs">
288
+ <!-- Line Gutter -->
289
+ <div bind:this={gutter} class="select-none overflow-hidden border-r border-border bg-muted/40 px-2 py-2 text-right text-muted-foreground" aria-hidden="true">
290
+ {#each sourceLines as _, i (i)}
291
+ {@const lineNum = i + 1}
292
+ {@const hasIssue = issueLines.has(lineNum)}
293
+ <div data-gutter-line={lineNum} class={hasIssue ? 'font-bold text-destructive' : ''}>
294
+ {lineNum}
295
+ </div>
296
+ {/each}
297
+ </div>
298
+ <!-- Textarea -->
299
+ <textarea
300
+ bind:this={textarea}
301
+ value={sourceCode}
302
+ oninput={handleSourceInput}
303
+ onscroll={(e) => {
304
+ if (gutter) gutter.scrollTop = e.currentTarget.scrollTop;
305
+ }}
306
+ wrap="off"
307
+ aria-label="Effect source code"
308
+ spellcheck="false"
309
+ class="flex-1 resize-none bg-transparent p-2 font-mono text-xs text-foreground focus:outline-none"
310
+ ></textarea>
311
+ </div>
312
+ </div>
313
+ {/if}
314
+ </div>
315
+
316
+ <!-- Parameters -->
317
+ <div class="space-y-2 border-t border-border pt-4">
318
+ <div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
319
+ Parameters
320
+ </div>
321
+ <EffectParameters
322
+ description={current.description}
323
+ values={current.values}
324
+ onchange={(control, val, gesture) => session?.setValue(control, val as any, { gesture })}
325
+ onreset={(control) => session?.resetValues([control])}
326
+ onpresetname={(name) => session?.applyPreset(name)}
327
+ >
328
+ {#snippet addon(control: string)}
329
+ {@const track = current.tracks[control]}
330
+ {@const isAnimated = Boolean(track && track.length > 0)}
331
+ {@const hasKeyframeAtPlayhead = Boolean(isAnimated && track?.some((kf) => kf.time === current.time))}
332
+ <button
333
+ type="button"
334
+ data-keyframe-toggle={control}
335
+ aria-label={
336
+ hasKeyframeAtPlayhead
337
+ ? `Remove keyframe for ${control}`
338
+ : `Add keyframe for ${control}`
339
+ }
340
+ onclick={() => {
341
+ if (hasKeyframeAtPlayhead) {
342
+ session?.removeKeyframe(control, current.time);
343
+ } else {
344
+ session?.setKeyframe(control, current.time);
345
+ }
346
+ }}
347
+ class="rounded px-1 text-xs transition-colors {hasKeyframeAtPlayhead ? 'text-primary font-bold' : isAnimated ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}"
348
+ title={hasKeyframeAtPlayhead ? 'Remove keyframe at playhead' : 'Add keyframe at playhead'}
349
+ >
350
+ {hasKeyframeAtPlayhead ? '◆' : '◇'}
351
+ </button>
352
+ {/snippet}
353
+ </EffectParameters>
354
+ </div>
355
+
356
+ <!-- Keyframe Timeline -->
357
+ {#if showTimeline}
358
+ <div class="space-y-3 border-t border-border pt-4" data-effect-timeline>
359
+ <div class="flex flex-wrap items-center justify-between gap-4">
360
+ <div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
361
+ Timeline
362
+ </div>
363
+ <div class="flex items-center gap-3 text-xs">
364
+ <label class="flex items-center gap-1.5">
365
+ <span>Duration:</span>
366
+ <input
367
+ type="number"
368
+ aria-label="Duration"
369
+ min={1}
370
+ value={current.duration}
371
+ onchange={(e) =>
372
+ session?.setTiming({ duration: Math.max(1, Number(e.currentTarget.value)) })
373
+ }
374
+ class="w-16 rounded border border-border bg-background px-1.5 py-0.5 text-xs font-mono"
375
+ />
376
+ <span class="text-muted-foreground">ms</span>
377
+ </label>
378
+
379
+ <label class="flex items-center gap-1.5 cursor-pointer">
380
+ <input
381
+ type="checkbox"
382
+ aria-label="Loop playback"
383
+ checked={current.loop}
384
+ onchange={(e) => session?.setTiming({ loop: e.currentTarget.checked })}
385
+ />
386
+ <span>Loop</span>
387
+ </label>
388
+ </div>
389
+ </div>
390
+
391
+ <!-- Scrubber -->
392
+ <div class="space-y-1">
393
+ <input
394
+ type="range"
395
+ aria-label="Timeline scrubber"
396
+ min={0}
397
+ max={current.duration}
398
+ value={current.time}
399
+ oninput={(e) => session?.seek(Number(e.currentTarget.value))}
400
+ class="w-full cursor-pointer accent-primary"
401
+ />
402
+ </div>
403
+
404
+ <!-- Tracks -->
405
+ {#if Object.entries(current.tracks).length > 0}
406
+ <div class="space-y-2 pt-1">
407
+ {#each Object.entries(current.tracks) as [control, keyframes] (control)}
408
+ {#if keyframes && keyframes.length > 0}
409
+ <div class="space-y-1" data-track={control}>
410
+ <div class="flex items-center justify-between text-xs text-muted-foreground">
411
+ <span class="font-medium">{control}</span>
412
+ <button
413
+ type="button"
414
+ class="text-[11px] hover:text-foreground"
415
+ onclick={() => session?.clearTrack(control)}
416
+ aria-label="Clear animation track for {control}"
417
+ >
418
+ Clear track
419
+ </button>
420
+ </div>
421
+ <div class="relative h-4 w-full rounded bg-muted/40">
422
+ {#each keyframes as kf, i (i)}
423
+ {@const leftPct = Math.max(
424
+ 0,
425
+ Math.min(100, (kf.time / (current.duration || 1)) * 100)
426
+ )}
427
+ <button
428
+ type="button"
429
+ onclick={() => session?.seek(kf.time)}
430
+ aria-label="Seek to {control} keyframe at {kf.time}ms"
431
+ title="{control} @ {kf.time}ms"
432
+ style="left: {leftPct}%;"
433
+ class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 text-[10px] text-primary hover:scale-125"
434
+ >
435
+ ◆
436
+ </button>
437
+ {/each}
438
+ </div>
439
+ </div>
440
+ {/if}
441
+ {/each}
442
+ </div>
443
+ {/if}
444
+ </div>
445
+ {/if}
446
+ </div>
447
+ {/if}
@@ -0,0 +1,18 @@
1
+ import { type EffectAuthoringSession } from '@pixerate/editor/effects';
2
+ interface Props {
3
+ /** The effect authoring session to edit. */
4
+ session: EffectAuthoringSession | null;
5
+ /** Whether to show the source code editor pane when supported (default true). */
6
+ showSource?: boolean;
7
+ /** Whether to show the keyframe timeline below parameters (default true). */
8
+ showTimeline?: boolean;
9
+ /** Size of the preview canvas (default 480 × 270). */
10
+ previewSize?: {
11
+ width: number;
12
+ height: number;
13
+ };
14
+ class?: string;
15
+ }
16
+ declare const EffectAuthoringEditor: import("svelte").Component<Props, {}, "">;
17
+ type EffectAuthoringEditor = ReturnType<typeof EffectAuthoringEditor>;
18
+ export default EffectAuthoringEditor;
@@ -19,18 +19,27 @@
19
19
  keyframed?: ReadonlySet<string>;
20
20
  /** Applies a preset's values. */
21
21
  onpreset?: (values: Readonly<Record<string, unknown>>) => void;
22
+ /** Applies a preset by name. */
23
+ onpresetname?: (name: string) => void;
24
+ /** Optional addon rendered next to the control label (e.g. keyframe toggle button). */
25
+ addon?: import('svelte').Snippet<[string]>;
22
26
  disabled?: boolean;
23
27
  class?: string;
24
28
  }
25
29
 
26
- let { description, values, onchange, onreset, keyframed, onpreset, disabled = false, class: className = '' }: Props = $props();
30
+ let { description, values, onchange, onreset, keyframed, onpreset, onpresetname, addon, disabled = false, class: className = '' }: Props = $props();
27
31
  const fields = $derived(effectParameterFields(description));
28
32
  const idBase = `fx-${Math.random().toString(36).slice(2, 8)}`;
29
33
  let gesture: string | null = null;
30
34
  let count = 0;
31
35
  function preset(name: string) {
32
- const p = description.presets.find((x) => x.name === name);
33
- if (p) onpreset?.({ ...description.defaults, ...p.values });
36
+ if (!name) return;
37
+ if (onpresetname) {
38
+ onpresetname(name);
39
+ } else if (onpreset) {
40
+ const p = description.presets.find((x) => x.name === name);
41
+ if (p) onpreset({ ...description.defaults, ...p.values });
42
+ }
34
43
  }
35
44
  </script>
36
45
 
@@ -40,7 +49,7 @@
40
49
  per-control reset and the effect's presets.
41
50
  -->
42
51
  <div class="space-y-2 {className}" data-effect-parameters={description.id}>
43
- {#if onpreset && description.presets.length > 0}
52
+ {#if (onpreset || onpresetname) && description.presets.length > 0}
44
53
  <select class="w-full rounded border border-border bg-transparent px-1 py-0.5 text-xs" aria-label="{description.name} presets" value="" {disabled} onchange={(e) => preset(e.currentTarget.value)}>
45
54
  <option value="">Presets…</option>
46
55
  {#each description.presets as p (p.name)}<option value={p.name}>{p.name}</option>{/each}
@@ -54,6 +63,7 @@
54
63
  <div class="flex items-center gap-1.5">
55
64
  <label for="{idBase}-{field.id}" class="min-w-0 flex-1 truncate text-xs font-medium" title={field.description}>{label}</label>
56
65
  {#if keyed}<span class="text-[10px] text-muted-foreground">keyframed</span>{/if}
66
+ {#if addon}{@render addon(field.id)}{/if}
57
67
  {#if onreset && !keyed && !disabled && !parameterValuesEqual(value, field.default)}
58
68
  <button type="button" class="rounded px-1 text-[11px] text-muted-foreground hover:text-foreground" aria-label="Reset {label}" onclick={() => onreset(field.id)}>↺</button>
59
69
  {/if}
@@ -16,6 +16,10 @@ interface Props {
16
16
  keyframed?: ReadonlySet<string>;
17
17
  /** Applies a preset's values. */
18
18
  onpreset?: (values: Readonly<Record<string, unknown>>) => void;
19
+ /** Applies a preset by name. */
20
+ onpresetname?: (name: string) => void;
21
+ /** Optional addon rendered next to the control label (e.g. keyframe toggle button). */
22
+ addon?: import('svelte').Snippet<[string]>;
19
23
  disabled?: boolean;
20
24
  class?: string;
21
25
  }
@@ -1,4 +1,4 @@
1
- import type { EffectDescription, EffectProvider } from "@pixerate/editor/effects";
1
+ import type { EffectAuthoringSession, EffectAuthoringState, EffectDescription, EffectProvider } from "@pixerate/editor/effects";
2
2
  /** Anything that takes an effects provider: every editor controller with effects support. */
3
3
  export interface EffectsHost {
4
4
  useEffects(provider: EffectProvider): () => void;
@@ -14,3 +14,10 @@ export declare function effectDescriptionOf(provider: () => EffectProvider | nul
14
14
  readonly description: EffectDescription | null;
15
15
  readonly error: string | null;
16
16
  };
17
+ /**
18
+ * Subscribes a Svelte component to the observable state of an effect authoring session.
19
+ * Updates reactively whenever the session changes. State is `null` when session is null.
20
+ */
21
+ export declare function effectAuthoringOf(session: EffectAuthoringSession | null | undefined | (() => EffectAuthoringSession | null | undefined)): {
22
+ readonly state: EffectAuthoringState | null;
23
+ };
@@ -47,3 +47,29 @@ export function effectDescriptionOf(provider, effect) {
47
47
  },
48
48
  };
49
49
  }
50
+ /**
51
+ * Subscribes a Svelte component to the observable state of an effect authoring session.
52
+ * Updates reactively whenever the session changes. State is `null` when session is null.
53
+ */
54
+ export function effectAuthoringOf(session) {
55
+ let currentState = $state.raw(null);
56
+ $effect(() => {
57
+ const s = typeof session === "function" ? session() : session;
58
+ if (!s) {
59
+ currentState = null;
60
+ return;
61
+ }
62
+ currentState = s.getState();
63
+ const unsubscribe = s.subscribe((next) => {
64
+ currentState = next;
65
+ });
66
+ return () => {
67
+ unsubscribe();
68
+ };
69
+ });
70
+ return {
71
+ get state() {
72
+ return currentState;
73
+ },
74
+ };
75
+ }
@@ -2,10 +2,13 @@ import type { ComponentProps } from "svelte";
2
2
  import type EffectsBrowserComponent from "./EffectsBrowser.svelte";
3
3
  import type EffectParametersComponent from "./EffectParameters.svelte";
4
4
  import type EffectThumbnailComponent from "./EffectThumbnail.svelte";
5
+ import type EffectAuthoringEditorComponent from "./EffectAuthoringEditor.svelte";
5
6
  export { default as EffectsBrowser } from "./EffectsBrowser.svelte";
6
7
  export { default as EffectParameters } from "./EffectParameters.svelte";
7
8
  export { default as EffectThumbnail } from "./EffectThumbnail.svelte";
9
+ export { default as EffectAuthoringEditor } from "./EffectAuthoringEditor.svelte";
8
10
  export * from "./effects.svelte.js";
9
11
  export type EffectsBrowserProps = ComponentProps<typeof EffectsBrowserComponent>;
10
12
  export type EffectParametersProps = ComponentProps<typeof EffectParametersComponent>;
11
13
  export type EffectThumbnailProps = ComponentProps<typeof EffectThumbnailComponent>;
14
+ export type EffectAuthoringEditorProps = ComponentProps<typeof EffectAuthoringEditorComponent>;
@@ -1,4 +1,5 @@
1
1
  export { default as EffectsBrowser } from "./EffectsBrowser.svelte";
2
2
  export { default as EffectParameters } from "./EffectParameters.svelte";
3
3
  export { default as EffectThumbnail } from "./EffectThumbnail.svelte";
4
+ export { default as EffectAuthoringEditor } from "./EffectAuthoringEditor.svelte";
4
5
  export * from "./effects.svelte.js";