@pixerate/editor-svelte 0.186.2 → 0.187.1

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.
@@ -11,7 +11,7 @@
11
11
  class?: string;
12
12
  }
13
13
 
14
- const LABELS: Partial<Record<EffectSummary['kind'], string>> = { motion: 'Motion', camera: 'Camera' };
14
+ const LABELS: Partial<Record<EffectSummary['kind'], string>> = { motion: 'Motion', camera: 'Camera', timing: 'Timing' };
15
15
 
16
16
  let { provider, effect: item, width = 96, height = 54, class: className = '' }: Props = $props();
17
17
  let canvas = $state<HTMLCanvasElement | null>(null);
@@ -40,7 +40,7 @@
40
40
  });
41
41
  </script>
42
42
 
43
- <!-- A preview of an effect drawn by the provider's own renderer; motion effects, camera moves and failures show a label. -->
43
+ <!-- A preview of an effect drawn by the provider's own renderer; motion effects, camera moves, timing effects and failures show a label. -->
44
44
  <div
45
45
  class="relative overflow-hidden rounded border border-border bg-[repeating-conic-gradient(#8884_0_25%,transparent_0_50%)] bg-[length:12px_12px] {className}"
46
46
  style="aspect-ratio: {width} / {height}"
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { brushEditorSections, PAINT_PROFILE, setBrushEditorValue, type Brush, type BrushHostProfile } from '@pixerate/editor/brushes';
4
+ import PaintSlider from './PaintSlider.svelte';
5
+ import PixelCanvas from './PixelCanvas.svelte';
6
+ import { button, chip } from './paintEditorRune.svelte.js';
7
+
8
+ interface Props {
9
+ /** The brush being edited, as facets. */
10
+ brush: Brush;
11
+ /** What the host renders (default the paint editor): other controls are left out. */
12
+ profile?: BrushHostProfile;
13
+ /** Called with the whole brush after each change (normalised). */
14
+ onchange: (brush: Brush) => void;
15
+ /** A live preview stroke of `brush` (e.g. `controller.brushThumbnail`). */
16
+ preview?: { width: number; height: number; data: Uint8ClampedArray } | null;
17
+ /** With it, a name field and "Save brush" save the brush under that name. */
18
+ onsaveas?: (name: string) => unknown;
19
+ /** Extra controls a host adds to a section (e.g. the sticker picker for stamps). */
20
+ extra?: Snippet<[string]>;
21
+ class?: string;
22
+ }
23
+ let { brush, profile = PAINT_PROFILE, onchange, preview, onsaveas, extra, class: className = '' }: Props = $props();
24
+ const sections = $derived(brushEditorSections(brush, profile));
25
+ let problems = $state<string | null>(null);
26
+ let name = $state('');
27
+ const set = (path: string, value: unknown) => {
28
+ const r = setBrushEditorValue(brush, path, value);
29
+ problems = r.problems.length ? r.problems.join(' ') : null;
30
+ if (r.brush !== brush) onchange(r.brush);
31
+ };
32
+ const save = async () => {
33
+ try {
34
+ await onsaveas?.(name.trim());
35
+ name = '';
36
+ problems = null;
37
+ } catch (e) {
38
+ problems = (e as Error).message;
39
+ }
40
+ };
41
+ </script>
42
+
43
+ <!-- Edits a brush by its facets (brushEditorSections), with a live preview stroke and "Save as". Only what the host profile renders is shown. -->
44
+ <div class={`flex flex-col gap-2 ${className}`} data-testid="brush-editor">
45
+ {#if preview !== undefined}
46
+ <PixelCanvas pixels={preview} label="Brush preview" class="rounded" style={`width: 100%; aspect-ratio: ${preview ? `${preview.width} / ${preview.height}` : '4 / 1'}`} />
47
+ {/if}
48
+ {#each sections as s (s.id)}
49
+ <section class="flex flex-col gap-1.5" data-section={s.id} data-testid={s.id === 'behaviour' ? 'brush-engine' : undefined}>
50
+ <span class="font-medium text-slate-300">{s.title}</span>
51
+ {#each s.controls as c (c.path)}
52
+ {#if c.kind === 'slider'}
53
+ <div title={c.note}>
54
+ <PaintSlider control={c} value={c.value} onchange={(v) => set(c.path, v)} />
55
+ {#if c.note}<span class="text-[10px] text-amber-400">{c.note}</span>{/if}
56
+ </div>
57
+ {:else if c.kind === 'choice'}
58
+ <label class="flex items-center justify-between gap-2" title={c.note}>
59
+ <span class="text-slate-400">{c.label}</span>
60
+ <select aria-label={c.ariaLabel} value={c.value} onchange={(e) => set(c.path, e.currentTarget.value)} class="max-w-[60%] rounded bg-slate-800 px-1 py-0.5">
61
+ {#each c.options as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
62
+ </select>
63
+ </label>
64
+ {:else if c.kind === 'toggle'}
65
+ <label class="flex items-center gap-2" title={c.note}>
66
+ <input type="checkbox" aria-label={c.ariaLabel} checked={c.value} onchange={(e) => set(c.path, e.currentTarget.checked)} /> {c.label}
67
+ </label>
68
+ {:else if c.kind === 'text'}
69
+ <label class="flex items-center justify-between gap-2">
70
+ <span class="text-slate-400">{c.label}</span>
71
+ <input aria-label={c.ariaLabel} value={c.value} placeholder={c.placeholder} onblur={(e) => e.currentTarget.value !== c.value && set(c.path, e.currentTarget.value.trim().toLowerCase())} class="w-28 rounded bg-slate-800 px-1.5 py-0.5" />
72
+ </label>
73
+ {:else if c.kind === 'colour'}
74
+ <div class="flex items-center justify-between gap-2">
75
+ <span class="text-slate-400">{c.label}</span>
76
+ <span class="flex items-center gap-1">
77
+ <input type="color" aria-label={c.ariaLabel} value={c.value ?? '#000000'} onchange={(e) => set(c.path, e.currentTarget.value)} class="h-5 w-8" />
78
+ {#if c.value}<button type="button" class={button} onclick={() => set(c.path, undefined)}>None</button>{/if}
79
+ </span>
80
+ </div>
81
+ {:else if c.kind === 'palette'}
82
+ <div class="flex flex-wrap items-center gap-1" role="group" aria-label={c.ariaLabel}>
83
+ <span class="text-slate-400">{c.label}</span>
84
+ {#each c.value as col, i (`${col}-${i}`)}
85
+ <button type="button" title={`Remove ${col}`} aria-label={`Remove ${col}`} class="h-4 w-4 rounded ring-1 ring-slate-700" style={`background: ${col}`} onclick={() => set(c.path, c.value.filter((_, j) => j !== i))}></button>
86
+ {/each}
87
+ {#if c.value.length < c.max}
88
+ <input type="color" aria-label="Add a palette colour" onchange={(e) => set(c.path, [...c.value, e.currentTarget.value])} class="h-4 w-6" />
89
+ {/if}
90
+ </div>
91
+ {:else if c.kind === 'stamps'}
92
+ <div class="flex flex-wrap gap-1" role="group" aria-label={c.ariaLabel} title={`Up to ${c.max}`}>
93
+ {#each c.options as o (o.value)}
94
+ {@const on = c.value.includes(o.value)}
95
+ <button type="button" aria-pressed={on} class={chip(on)} onclick={() => set(c.path, on ? c.value.filter((v) => v !== o.value) : [...c.value, o.value].slice(-c.max))}>{o.label}</button>
96
+ {/each}
97
+ </div>
98
+ {/if}
99
+ {/each}
100
+ {#if extra}{@render extra(s.id)}{/if}
101
+ </section>
102
+ {/each}
103
+ {#if onsaveas}
104
+ <div class="flex flex-wrap items-center gap-1">
105
+ <input bind:value={name} placeholder="Brush name" aria-label="Brush name" class="w-28 rounded bg-slate-800 px-1.5 py-0.5" />
106
+ <button type="button" class={button} disabled={!name.trim()} onclick={save}>Save brush</button>
107
+ </div>
108
+ {/if}
109
+ {#if problems}<p role="alert" class="text-[11px] text-rose-400">{problems}</p>{/if}
110
+ </div>
@@ -0,0 +1,24 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type Brush, type BrushHostProfile } from '@pixerate/editor/brushes';
3
+ interface Props {
4
+ /** The brush being edited, as facets. */
5
+ brush: Brush;
6
+ /** What the host renders (default the paint editor): other controls are left out. */
7
+ profile?: BrushHostProfile;
8
+ /** Called with the whole brush after each change (normalised). */
9
+ onchange: (brush: Brush) => void;
10
+ /** A live preview stroke of `brush` (e.g. `controller.brushThumbnail`). */
11
+ preview?: {
12
+ width: number;
13
+ height: number;
14
+ data: Uint8ClampedArray;
15
+ } | null;
16
+ /** With it, a name field and "Save brush" save the brush under that name. */
17
+ onsaveas?: (name: string) => unknown;
18
+ /** Extra controls a host adds to a section (e.g. the sticker picker for stamps). */
19
+ extra?: Snippet<[string]>;
20
+ class?: string;
21
+ }
22
+ declare const BrushEditor: import("svelte").Component<Props, {}, "">;
23
+ type BrushEditor = ReturnType<typeof BrushEditor>;
24
+ export default BrushEditor;
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import { brushLibraryGroups, type BrushHostProfile, type BrushLibraryEntry } from '@pixerate/editor/brushes';
3
+ import PixelCanvas from './PixelCanvas.svelte';
4
+
5
+ interface Props {
6
+ /** The library's brushes (built-in presets and saved ones), as facets. */
7
+ entries: readonly BrushLibraryEntry[];
8
+ /** The active brush id. */
9
+ activeId?: string;
10
+ onselect: (id: string) => void;
11
+ /** Only brushes this host renders. */
12
+ profile?: BrushHostProfile;
13
+ /** A thumbnail for a brush (a test stroke). */
14
+ thumbnail?: (id: string) => { width: number; height: number; data: Uint8ClampedArray } | null;
15
+ thumbnailWidth?: number;
16
+ thumbnailHeight?: number;
17
+ class?: string;
18
+ }
19
+ let { entries, activeId, onselect, profile, thumbnail, thumbnailWidth = 112, thumbnailHeight = 32, class: className = '' }: Props = $props();
20
+ let text = $state('');
21
+ const groups = $derived(brushLibraryGroups(entries, { text, profile }));
22
+ </script>
23
+
24
+ <!-- The brush library grouped by medium, then variety, with tools as their own group, and a filter (brushLibraryGroups). -->
25
+ <div class={`flex flex-col gap-1 ${className}`} data-testid="brush-library">
26
+ <input type="search" bind:value={text} placeholder="Find a brush" aria-label="Find a brush" class="rounded bg-slate-800 px-1.5 py-0.5" />
27
+ <div class="flex flex-col gap-1.5" role="listbox" aria-label="Brushes">
28
+ {#each groups as g (g.id)}
29
+ <div role="group" aria-label={g.title} class="flex flex-col gap-0.5">
30
+ <span class="text-[10px] uppercase tracking-wide text-slate-500">{g.title}</span>
31
+ <div class="grid grid-cols-2 gap-1">
32
+ {#each g.varieties as v (v.variety)}
33
+ {#each v.entries as e (e.brush.id)}
34
+ <button
35
+ type="button"
36
+ role="option"
37
+ aria-selected={activeId === e.brush.id}
38
+ title={[e.brush.name, v.title.toLowerCase() === e.brush.name.toLowerCase() ? '' : v.title, e.builtin ? '' : 'saved'].filter(Boolean).join(' · ')}
39
+ class={`flex flex-col items-start gap-0.5 rounded p-1 text-left ${activeId === e.brush.id ? 'bg-indigo-600/30 ring-1 ring-indigo-500' : 'bg-slate-900 hover:bg-slate-800'}`}
40
+ onclick={() => onselect(e.brush.id)}
41
+ >
42
+ {#if thumbnail}
43
+ <PixelCanvas pixels={thumbnail(e.brush.id)} label={`${e.brush.name} thumbnail`} class="rounded" style={`width: 100%; aspect-ratio: ${thumbnailWidth} / ${thumbnailHeight}`} />
44
+ {/if}
45
+ <span class="truncate text-[10px] text-slate-300">{e.brush.name}</span>
46
+ </button>
47
+ {/each}
48
+ {/each}
49
+ </div>
50
+ </div>
51
+ {/each}
52
+ {#if !groups.length}<span class="text-slate-500">No brush matches.</span>{/if}
53
+ </div>
54
+ </div>
@@ -0,0 +1,22 @@
1
+ import { type BrushHostProfile, type BrushLibraryEntry } from '@pixerate/editor/brushes';
2
+ interface Props {
3
+ /** The library's brushes (built-in presets and saved ones), as facets. */
4
+ entries: readonly BrushLibraryEntry[];
5
+ /** The active brush id. */
6
+ activeId?: string;
7
+ onselect: (id: string) => void;
8
+ /** Only brushes this host renders. */
9
+ profile?: BrushHostProfile;
10
+ /** A thumbnail for a brush (a test stroke). */
11
+ thumbnail?: (id: string) => {
12
+ width: number;
13
+ height: number;
14
+ data: Uint8ClampedArray;
15
+ } | null;
16
+ thumbnailWidth?: number;
17
+ thumbnailHeight?: number;
18
+ class?: string;
19
+ }
20
+ declare const BrushLibraryBrowser: import("svelte").Component<Props, {}, "">;
21
+ type BrushLibraryBrowser = ReturnType<typeof BrushLibraryBrowser>;
22
+ export default BrushLibraryBrowser;
@@ -1,52 +1,10 @@
1
1
  <script lang="ts">
2
2
  import StickerPicker from './StickerPicker.svelte';
3
- import {
4
- BLEND_MENU_GROUPS,
5
- BRUSH_ROTATIONS,
6
- BRUSH_SLIDERS,
7
- brushEngineControls,
8
- brushLoadColor,
9
- downloadBytes,
10
- brushTakesPaint,
11
- patchBrushValue,
12
- patchPath,
13
- readPath,
14
- WET_TOOLS,
15
- WET_TOOL_LABELS,
16
- WET_MEDIA,
17
- WET_MEDIUM_LABELS,
18
- IMPASTO_TOOLS,
19
- IMPASTO_TOOL_LABELS,
20
- PARTICLE_KINDS,
21
- PARTICLE_KIND_LABELS,
22
- PARTICLE_RENDERS,
23
- PARTICLE_RENDER_LABELS,
24
- RIBBON_PAIRS,
25
- RIBBON_PAIR_LABELS,
26
- DOT_TEXTURE_ORDERS,
27
- DOT_TEXTURE_ORDER_LABELS,
28
- DOT_ROTATIONS,
29
- DOT_ROTATION_LABELS,
30
- toggleDotTexture,
31
- STAMP_KINDS,
32
- STAMP_LABELS,
33
- toggleStickerRef,
34
- type ParticleKind,
35
- type ParticleRender,
36
- type RibbonPairs,
37
- type DotTextureOrder,
38
- type DotRotation,
39
- type ImpastoTool,
40
- type WetMediumKind,
41
- type BlendMode,
42
- type BrushRotation,
43
- type WetTool,
44
- type PaintEditorController,
45
- } from '@pixerate/editor/paint-editor';
3
+ import { brushLoadColor, brushTakesPaint, downloadBytes, toggleStickerRef, DEFAULT_PAINT_GRADIENT, type PaintEditorController } from '@pixerate/editor/paint-editor';
4
+ import { PAINT_PROFILE, type Brush, type BrushLibraryEntry } from '@pixerate/editor/brushes';
46
5
  import { untrack } from 'svelte';
47
- import PaintSlider from './PaintSlider.svelte';
48
- import PixelCanvas from './PixelCanvas.svelte';
49
- import { DEFAULT_PAINT_GRADIENT } from '@pixerate/editor/paint-editor';
6
+ import BrushEditor from './BrushEditor.svelte';
7
+ import BrushLibraryBrowser from './BrushLibraryBrowser.svelte';
50
8
  import PaintGradientEditor from './PaintGradientEditor.svelte';
51
9
  import { attempt, button, chip, paintEditorState, panel } from './paintEditorRune.svelte.js';
52
10
 
@@ -61,7 +19,6 @@
61
19
  const snap = $derived(editor.current);
62
20
  const brush = $derived(snap.brush);
63
21
  let error = $state<string | null>(null);
64
- let saveName = $state('');
65
22
  let fileInput = $state<HTMLInputElement | null>(null);
66
23
  const run = (fn: () => unknown) => attempt(fn, (m) => (error = m));
67
24
  // Thumbnails follow the library and the paper.
@@ -73,200 +30,60 @@
73
30
  void paper;
74
31
  return untrack(() => new Map(list.map((b) => [b.id, controller.brushThumbnail(b.id, { width: thumbnailWidth, height: thumbnailHeight, color: '#26324a' })])));
75
32
  });
76
- const engine = $derived(brushEngineControls(brush));
33
+ const entries = $derived.by<BrushLibraryEntry[]>(() => {
34
+ const list = library;
35
+ return untrack(() => list.map((b) => ({ brush: controller.getBrushSpec(b.id)!, builtin: Boolean(b.builtin) })));
36
+ });
37
+ // The active brush as facets and its preview follow every change to it.
38
+ const spec = $derived.by<Brush>(() => {
39
+ void brush;
40
+ return untrack(() => controller.getBrushSpec()!);
41
+ });
42
+ // Previewed in the brush's own default colour when it has one, else the paint colour.
43
+ const own = $derived(typeof spec.colour?.default === 'string' && /^#[0-9a-f]{6}/i.test(spec.colour.default) ? spec.colour.default.slice(0, 7) : undefined);
44
+ const preview = $derived.by(() => {
45
+ void brush;
46
+ void paper;
47
+ void snap.color;
48
+ const color = own;
49
+ return untrack(() => controller.brushThumbnail(brush.id, { width: 240, height: 64, color }));
50
+ });
77
51
  const carried = $derived(brushLoadColor(snap.mixerLoad));
78
- const mixingValue = $derived(engine?.mixingPath === 'blender.mixing' ? brush.blender.mixing : engine?.mixingPath === 'smudge.mixing' ? brush.smudge.mixing : brush.behavior.mixing);
52
+ const loads = $derived(['dab', 'mixer', 'impasto'].includes(brush.engine) || (brush.engine === 'wet' && brush.wet.tool === 'paint'));
79
53
  const slug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'brush';
80
54
  </script>
81
55
 
82
- <!-- The brush library with thumbnails, the active brush's settings and blend, its engine's controls (blender, smudge, pick-up / mix, mixing, dirty, brush gradient, load), save, import and export. -->
83
- <div class={`${panel} ${className}`} data-testid="brush-panel">
84
- <div class="grid grid-cols-2 gap-1" role="listbox" aria-label="Brushes">
85
- {#each snap.brushes as b (b.id)}
86
- <button
87
- type="button"
88
- role="option"
89
- aria-selected={brush.id === b.id}
90
- title={`${b.name} (${b.category})`}
91
- class={`flex flex-col items-start gap-0.5 rounded p-1 text-left ${brush.id === b.id ? 'bg-indigo-600/30 ring-1 ring-indigo-500' : 'bg-slate-900 hover:bg-slate-800'}`}
92
- onclick={() => controller.setBrush({ id: b.id })}
93
- >
94
- <PixelCanvas pixels={thumbs.get(b.id) ?? null} label={`${b.name} thumbnail`} class="rounded" style={`width: 100%; aspect-ratio: ${thumbnailWidth} / ${thumbnailHeight}`} />
95
- <span class="truncate text-[10px] text-slate-300">{b.name}</span>
96
- </button>
97
- {/each}
98
- </div>
99
- <div class="flex flex-col gap-1.5">
100
- {#each BRUSH_SLIDERS as c (c.path)}
101
- <PaintSlider control={c} value={readPath(brush, c.path)} onchange={(v) => controller.setBrush(patchPath(brush as never, c.path, v) as never)} />
102
- {/each}
103
- <label class="flex items-center justify-between gap-2">
104
- <span class="text-slate-400">Rotation</span>
105
- <select aria-label="Brush rotation" value={brush.rotation} onchange={(e) => controller.setBrush({ rotation: e.currentTarget.value as BrushRotation })} class="rounded bg-slate-800 px-1 py-0.5">
106
- {#each BRUSH_ROTATIONS as r (r)}
107
- <option value={r}>{r === 'stroke' ? 'Follow stroke' : r === 'barrel' ? 'Pen barrel' : 'Fixed'}</option>
108
- {/each}
109
- </select>
110
- </label>
111
- <label class="flex items-center gap-2">
112
- <input type="checkbox" checked={brush.tip.pressureSize} onchange={(e) => controller.setBrush({ tip: { pressureSize: e.currentTarget.checked } })} /> Pressure → size
113
- </label>
114
- <label class="flex items-center gap-2">
115
- <input type="checkbox" checked={brush.tip.pressureOpacity} onchange={(e) => controller.setBrush({ tip: { pressureOpacity: e.currentTarget.checked } })} /> Pressure → opacity
116
- </label>
117
- {#if brush.engine === 'dab'}
118
- <label class="flex items-center justify-between gap-2">
119
- <span class="text-slate-400">Blend</span>
120
- <select aria-label="Brush blend" value={brush.tip.blend} onchange={(e) => controller.setBrush({ tip: { blend: e.currentTarget.value as BlendMode } })} class="rounded bg-slate-800 px-1 py-0.5">
121
- {#each BLEND_MENU_GROUPS as g (g.label)}
122
- <optgroup label={g.label}>
123
- {#each g.modes as m (m.id)}
124
- <option value={m.id} title={m.description}>{m.label}</option>
125
- {/each}
126
- </optgroup>
127
- {/each}
128
- </select>
129
- </label>
130
- {/if}
131
- </div>
132
- {#if engine}
133
- <div class="flex flex-col gap-1.5" data-testid="brush-engine">
134
- <span class="font-medium text-slate-300">{engine.title}</span>
135
- {#if engine.wetTool}
136
- <label class="flex items-center justify-between gap-2">
137
- <span class="text-slate-400">Wet tool</span>
138
- <select aria-label="Wet tool" value={brush.wet.tool} onchange={(e) => controller.setBrush({ wet: { tool: e.currentTarget.value as WetTool } })} class="rounded bg-slate-800 px-1 py-0.5">
139
- {#each WET_TOOLS as t (t)}<option value={t}>{WET_TOOL_LABELS[t]}</option>{/each}
140
- </select>
141
- </label>
142
- {/if}
143
- {#if engine.wetTool}
144
- <label class="flex items-center justify-between gap-2">
145
- <span class="text-slate-400">Medium</span>
146
- <select aria-label="Wet medium" value={brush.wet.medium} onchange={(e) => controller.setBrush({ wet: { medium: e.currentTarget.value as WetMediumKind } })} class="rounded bg-slate-800 px-1 py-0.5">
147
- {#each WET_MEDIA as m (m)}<option value={m}>{WET_MEDIUM_LABELS[m]}</option>{/each}
148
- </select>
149
- </label>
150
- {/if}
151
- {#if engine.particleMenus}
152
- <label class="flex items-center justify-between gap-2">
153
- <span class="text-slate-400">Particles</span>
154
- <select aria-label="Particle kind" value={brush.particle.kind} onchange={(e) => controller.setBrush({ particle: { kind: e.currentTarget.value as ParticleKind } })} class="rounded bg-slate-800 px-1 py-0.5">
155
- {#each PARTICLE_KINDS as k (k)}<option value={k}>{PARTICLE_KIND_LABELS[k]}</option>{/each}
156
- </select>
157
- </label>
158
- <label class="flex items-center justify-between gap-2">
159
- <span class="text-slate-400">Drawn as</span>
160
- <select aria-label="Particle render" value={brush.particle.render} onchange={(e) => controller.setBrush({ particle: { render: e.currentTarget.value as ParticleRender } })} class="rounded bg-slate-800 px-1 py-0.5">
161
- {#each PARTICLE_RENDERS as k (k)}<option value={k}>{PARTICLE_RENDER_LABELS[k]}</option>{/each}
162
- </select>
163
- </label>
164
- {#if brush.particle.render === 'ribbon'}
165
- <label class="flex items-center justify-between gap-2">
166
- <span class="text-slate-400">Ribbon joins</span>
167
- <select aria-label="Ribbon pairs" value={brush.particle.ribbonPairs} onchange={(e) => controller.setBrush({ particle: { ribbonPairs: e.currentTarget.value as RibbonPairs } })} class="rounded bg-slate-800 px-1 py-0.5">
168
- {#each RIBBON_PAIRS as k (k)}<option value={k}>{RIBBON_PAIR_LABELS[k]}</option>{/each}
169
- </select>
170
- </label>
171
- {/if}
172
- {#if brush.particle.render === 'dot' || brush.particle.render === 'sequence'}
173
- <div class="flex flex-wrap gap-1" role="group" aria-label="Dot textures" title="Stamps drawn instead of round dots (up to 4; none: round)">
174
- {#each STAMP_KINDS as k (k)}
175
- <button
176
- type="button"
177
- aria-pressed={brush.particle.dotTextures.includes(k)}
178
- class={`px-1.5 py-0.5 rounded text-[10px] ${brush.particle.dotTextures.includes(k) ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-300'}`}
179
- onclick={() => controller.setBrush({ particle: { dotTextures: toggleDotTexture(brush.particle.dotTextures, k) } })}
180
- >
181
- {STAMP_LABELS[k]}
182
- </button>
183
- {/each}
184
- </div>
185
- {#if brush.particle.dotTextures.length > 0}
186
- <label class="flex items-center justify-between gap-2">
187
- <span class="text-slate-400">Texture order</span>
188
- <select aria-label="Dot texture order" value={brush.particle.dotTextureOrder} onchange={(e) => controller.setBrush({ particle: { dotTextureOrder: e.currentTarget.value as DotTextureOrder } })} class="rounded bg-slate-800 px-1 py-0.5">
189
- {#each DOT_TEXTURE_ORDERS as k (k)}<option value={k}>{DOT_TEXTURE_ORDER_LABELS[k]}</option>{/each}
190
- </select>
191
- </label>
192
- <label class="flex items-center justify-between gap-2">
193
- <span class="text-slate-400">Texture turn</span>
194
- <select aria-label="Dot rotation" value={brush.particle.dotRotation} onchange={(e) => controller.setBrush({ particle: { dotRotation: e.currentTarget.value as DotRotation } })} class="rounded bg-slate-800 px-1 py-0.5">
195
- {#each DOT_ROTATIONS as k (k)}<option value={k}>{DOT_ROTATION_LABELS[k]}</option>{/each}
196
- </select>
197
- </label>
198
- {/if}
199
- {/if}
56
+ {#snippet extra(section: string)}
57
+ {#if section === 'shape' && brush.engine === 'sticker'}
58
+ <StickerPicker {controller} label="Sticker stamps" selected={brush.sticker.stamps} onpick={(ref) => controller.setBrush({ sticker: { stamps: toggleStickerRef(brush.sticker.stamps, ref) } })} />
59
+ {:else if section === 'behaviour' && loads}
60
+ <div class="flex items-center gap-1">
61
+ <span class="text-slate-400">Carries</span>
62
+ <span aria-label="Brush load" title={carried ?? 'Clean'} class="h-4 w-4 rounded ring-1 ring-slate-700" style={`background: ${carried ?? 'transparent'}`}></span>
63
+ {#if brush.engine === 'mixer'}
64
+ <button type="button" class={button} onclick={() => controller.loadMixerBrush()}>Load</button>
200
65
  {/if}
201
- {#if engine.stickerStamps}
202
- <StickerPicker {controller} label="Sticker stamps" selected={brush.sticker.stamps} onpick={(ref) => controller.setBrush({ sticker: { stamps: toggleStickerRef(brush.sticker.stamps, ref) } })} />
203
- {/if}
204
- {#if engine.impastoTool}
205
- <label class="flex items-center justify-between gap-2">
206
- <span class="text-slate-400">Tool</span>
207
- <select aria-label="Impasto tool" value={brush.impasto.tool} onchange={(e) => controller.setBrush({ impasto: { tool: e.currentTarget.value as ImpastoTool } })} class="rounded bg-slate-800 px-1 py-0.5">
208
- {#each IMPASTO_TOOLS as t (t)}<option value={t}>{IMPASTO_TOOL_LABELS[t]}</option>{/each}
209
- </select>
210
- </label>
211
- {/if}
212
- {#each engine.sliders as c (c.path)}
213
- <PaintSlider control={c} value={readPath(brush, c.path)} onchange={(v) => controller.setBrush(patchPath(brush as never, c.path, v) as never)} />
214
- {/each}
215
- {#if engine.mixingPath}
216
- <label class="flex items-center justify-between gap-2">
217
- <span class="text-slate-400">Mixing</span>
218
- <select aria-label="Brush mixing" value={mixingValue} onchange={(e) => controller.setBrush(patchBrushValue(brush, engine.mixingPath!, e.currentTarget.value))} class="rounded bg-slate-800 px-1 py-0.5">
219
- <option value="pigment">Pigment</option>
220
- <option value="rgb">RGB</option>
221
- </select>
222
- </label>
223
- {/if}
224
- {#if engine.light && brush.light.amount > 0}
225
- <label class="flex items-center gap-2" title="Pressure scales the light the brush lays">
226
- <input type="checkbox" aria-label="Light follows pressure" checked={brush.light.pressure} onchange={(e) => controller.setBrush({ light: { pressure: e.currentTarget.checked } })} /> Light follows pressure
227
- </label>
228
- {/if}
229
- {#if engine.dirty}
230
- <label class="flex items-center gap-2" title="Keep the colour the brush picked up for the next stroke (needs Pick-up above 0)">
231
- <input type="checkbox" aria-label="Dirty brush" checked={brush.behavior.dirty} onchange={(e) => controller.setBrush({ behavior: { dirty: e.currentTarget.checked } })} /> Dirty brush
232
- </label>
233
- {/if}
234
- {#if brushTakesPaint(brush)}
235
- <div class="flex flex-col gap-1.5" data-testid="brush-gradient">
236
- <div class="flex items-center justify-between gap-2">
237
- <span class="text-slate-400" title="A gradient of the brush's own, used instead of the paint (the multi-colour brush)">Brush gradient</span>
238
- <button type="button" aria-pressed={brush.paint !== null} class={chip(brush.paint !== null)} onclick={() => run(() => controller.setBrush({ paint: brush.paint ? null : DEFAULT_PAINT_GRADIENT }))}>{brush.paint ? 'On' : 'Off'}</button>
239
- </div>
240
- {#if brush.paint}
241
- <PaintGradientEditor value={brush.paint} colors={{ paint: snap.color, secondary: snap.secondaryColor }} onchange={(g) => run(() => controller.setBrush({ paint: g }))} />
242
- {/if}
243
- </div>
244
- {/if}
245
- {#if engine.load}
246
- <div class="flex items-center gap-1">
247
- <span class="text-slate-400">Carries</span>
248
- <span aria-label="Brush load" title={carried ?? 'Clean'} class="h-4 w-4 rounded ring-1 ring-slate-700" style={`background: ${carried ?? 'transparent'}`}></span>
249
- {#if brush.engine === 'mixer'}
250
- <button type="button" class={button} onclick={() => controller.loadMixerBrush()}>Load</button>
251
- {/if}
252
- <button type="button" class={button} onclick={() => controller.cleanMixerBrush(brush.engine === 'mixer')}>Clean</button>
253
- </div>
66
+ <button type="button" class={button} onclick={() => controller.cleanMixerBrush(brush.engine === 'mixer')}>Clean</button>
67
+ </div>
68
+ {:else if section === 'colour' && brushTakesPaint(brush)}
69
+ <div class="flex flex-col gap-1.5" data-testid="brush-gradient">
70
+ <div class="flex items-center justify-between gap-2">
71
+ <span class="text-slate-400" title="A gradient of the brush's own, used instead of the paint (the multi-colour brush)">Brush gradient</span>
72
+ <button type="button" aria-pressed={brush.paint !== null} class={chip(brush.paint !== null)} onclick={() => run(() => controller.setBrush({ paint: brush.paint ? null : DEFAULT_PAINT_GRADIENT }))}>{brush.paint ? 'On' : 'Off'}</button>
73
+ </div>
74
+ {#if brush.paint}
75
+ <PaintGradientEditor value={brush.paint} colors={{ paint: snap.color, secondary: snap.secondaryColor }} onchange={(g) => run(() => controller.setBrush({ paint: g }))} />
254
76
  {/if}
255
77
  </div>
256
78
  {/if}
79
+ {/snippet}
80
+
81
+ <!-- The brush library (BrushLibraryBrowser) and the active brush in the BrushEditor, plus the brush gradient, sticker stamps, what the brush carries, import and export. -->
82
+ <div class={`${panel} ${className}`} data-testid="brush-panel">
83
+ <BrushLibraryBrowser {entries} activeId={brush.id} onselect={(id) => controller.setBrush({ id })} thumbnail={(id) => thumbs.get(id) ?? null} {thumbnailWidth} {thumbnailHeight} />
84
+ <BrushEditor brush={spec} profile={PAINT_PROFILE} {preview} onchange={(next) => run(() => controller.setBrushSpec(next))} onsaveas={(name) => controller.saveBrush({ id: slug(name), name })} {extra} />
257
85
  <div class="flex flex-wrap items-center gap-1">
258
- <input bind:value={saveName} placeholder="Brush name" aria-label="Brush name" class="w-28 rounded bg-slate-800 px-1.5 py-0.5" />
259
- <button
260
- type="button"
261
- class={button}
262
- disabled={!saveName.trim()}
263
- onclick={() =>
264
- run(() => {
265
- controller.saveBrush({ id: slug(saveName), name: saveName.trim() });
266
- saveName = '';
267
- })}>Save brush</button
268
- >
269
- <button type="button" class={button} onclick={() => run(() => downloadBytes(JSON.stringify(controller.exportBrushes(), null, 2), 'brushes.json', 'application/json'))}>Export</button>
86
+ <button type="button" class={button} onclick={() => run(async () => downloadBytes(JSON.stringify(await controller.exportBrushes(), null, 2), 'brushes.json', 'application/json'))}>Export</button>
270
87
  <button type="button" class={button} onclick={() => fileInput?.click()}>Import…</button>
271
88
  <input
272
89
  bind:this={fileInput}
@@ -3,6 +3,8 @@ import type PaintEditorComponent from './PaintEditor.svelte';
3
3
  import type PaintCanvasComponent from './PaintCanvas.svelte';
4
4
  import type PaintToolbarComponent from './PaintToolbar.svelte';
5
5
  import type BrushPanelComponent from './BrushPanel.svelte';
6
+ import type BrushEditorComponent from './BrushEditor.svelte';
7
+ import type BrushLibraryBrowserComponent from './BrushLibraryBrowser.svelte';
6
8
  import type PaperPanelComponent from './PaperPanel.svelte';
7
9
  import type ColorPanelComponent from './ColorPanel.svelte';
8
10
  import type PaintLayersPanelComponent from './PaintLayersPanel.svelte';
@@ -16,6 +18,8 @@ export { default as PaintEditor } from './PaintEditor.svelte';
16
18
  export { default as PaintCanvas } from './PaintCanvas.svelte';
17
19
  export { default as PaintToolbar } from './PaintToolbar.svelte';
18
20
  export { default as BrushPanel } from './BrushPanel.svelte';
21
+ export { default as BrushEditor } from './BrushEditor.svelte';
22
+ export { default as BrushLibraryBrowser } from './BrushLibraryBrowser.svelte';
19
23
  export { default as PaperPanel } from './PaperPanel.svelte';
20
24
  export { default as ColorPanel } from './ColorPanel.svelte';
21
25
  export { default as PaintLayersPanel } from './PaintLayersPanel.svelte';
@@ -32,6 +36,8 @@ export type PaintEditorProps = ComponentProps<typeof PaintEditorComponent>;
32
36
  export type PaintCanvasProps = ComponentProps<typeof PaintCanvasComponent>;
33
37
  export type PaintToolbarProps = ComponentProps<typeof PaintToolbarComponent>;
34
38
  export type BrushPanelProps = ComponentProps<typeof BrushPanelComponent>;
39
+ export type BrushEditorProps = ComponentProps<typeof BrushEditorComponent>;
40
+ export type BrushLibraryBrowserProps = ComponentProps<typeof BrushLibraryBrowserComponent>;
35
41
  export type PaperPanelProps = ComponentProps<typeof PaperPanelComponent>;
36
42
  export type ColorPanelProps = ComponentProps<typeof ColorPanelComponent>;
37
43
  export type PaintLayersPanelProps = ComponentProps<typeof PaintLayersPanelComponent>;
@@ -2,6 +2,8 @@ export { default as PaintEditor } from './PaintEditor.svelte';
2
2
  export { default as PaintCanvas } from './PaintCanvas.svelte';
3
3
  export { default as PaintToolbar } from './PaintToolbar.svelte';
4
4
  export { default as BrushPanel } from './BrushPanel.svelte';
5
+ export { default as BrushEditor } from './BrushEditor.svelte';
6
+ export { default as BrushLibraryBrowser } from './BrushLibraryBrowser.svelte';
5
7
  export { default as PaperPanel } from './PaperPanel.svelte';
6
8
  export { default as ColorPanel } from './ColorPanel.svelte';
7
9
  export { default as PaintLayersPanel } from './PaintLayersPanel.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.186.2",
3
+ "version": "0.187.1",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -170,7 +170,7 @@
170
170
  "**/*.css"
171
171
  ],
172
172
  "dependencies": {
173
- "@pixerate/editor": "0.252.0"
173
+ "@pixerate/editor": "0.254.0"
174
174
  },
175
175
  "peerDependencies": {
176
176
  "@dagrejs/dagre": "^1.1.4",