@pixerate/editor-svelte 0.176.1 → 0.178.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.
@@ -40,6 +40,7 @@
40
40
  let water = $state<HTMLCanvasElement | null>(null);
41
41
  let size = $state({ width: 800, height: 600 });
42
42
  let predicted = $state<Array<{ x: number; y: number }>>([]);
43
+ let strokeDrag = $state<{ id: string; dx: number; dy: number } | null>(null);
43
44
  const presenter = $derived(new PaintCanvasPresenter(controller));
44
45
  const gesture = $derived(
45
46
  new PaintGesture(controller, () => {
@@ -101,6 +102,13 @@
101
102
  });
102
103
  });
103
104
 
105
+ // Live effect paint plays while the canvas is mounted (idle when nothing is live or it is paused).
106
+ $effect(() => {
107
+ const c = controller;
108
+ const player = untrack(() => c.playEffects());
109
+ return () => player.stop();
110
+ });
111
+
104
112
  // Wheel needs a non-passive listener to prevent page scroll (see GOTCHAS).
105
113
  $effect(() => {
106
114
  const el = viewport;
@@ -117,7 +125,7 @@
117
125
  const transform = $derived(viewCssTransform(snap.view, doc, size));
118
126
  const lines = $derived.by(() => {
119
127
  void snap;
120
- return showGuides ? guideOverlay(controller) : [];
128
+ return showGuides ? guideOverlay(controller, strokeDrag) : [];
121
129
  });
122
130
  </script>
123
131
 
@@ -130,7 +138,7 @@
130
138
  aria-label="Paint canvas"
131
139
  data-testid="paint-canvas"
132
140
  class={`relative overflow-hidden bg-slate-950 outline-none touch-none select-none ${className}`}
133
- style={`cursor: ${tool === 'pan' ? 'grab' : tool === 'eyedropper' ? 'copy' : 'crosshair'}`}
141
+ style={`cursor: ${tool === 'pan' ? 'grab' : tool === 'eyedropper' ? 'copy' : tool === 'stroke' ? 'default' : 'crosshair'}`}
134
142
  onpointerdown={(e) => {
135
143
  viewport?.focus();
136
144
  if (gesture.down(e)) (e.target as Element).setPointerCapture?.(e.pointerId);
@@ -139,14 +147,18 @@
139
147
  onpointermove={(e) => {
140
148
  gesture.move(e);
141
149
  if (gesture.predicted.length || predicted.length) predicted = gesture.predicted.map((p) => ({ x: p.x, y: p.y }));
150
+ if (gesture.strokeDrag || strokeDrag) strokeDrag = gesture.strokeDrag;
142
151
  }}
143
152
  onpointerup={(e) => {
144
153
  gesture.up(e);
145
154
  predicted = [];
155
+ strokeDrag = null;
156
+ onerror?.(gesture.error);
146
157
  }}
147
158
  onpointercancel={() => {
148
159
  gesture.cancel();
149
160
  predicted = [];
161
+ strokeDrag = null;
150
162
  }}
151
163
  onkeydown={(e) => {
152
164
  if (handlePaintShortcut(controller, gesture, e)) e.preventDefault();
@@ -176,8 +188,8 @@
176
188
  y1={l.from.y}
177
189
  x2={l.to.x}
178
190
  y2={l.to.y}
179
- stroke={l.kind === 'symmetry' ? '#22d3ee' : l.kind === 'stencil' ? '#fbbf24' : l.kind === 'sticker' ? '#818cf8' : '#f472b6'}
180
- stroke-opacity={l.kind === 'symmetry' || l.kind === 'stencil' || l.kind === 'sticker' ? 0.8 : 0.35}
191
+ stroke={l.kind === 'symmetry' ? '#22d3ee' : l.kind === 'stencil' ? '#fbbf24' : l.kind === 'stroke' || l.kind === 'sticker' ? '#818cf8' : '#f472b6'}
192
+ stroke-opacity={l.kind === 'symmetry' || l.kind === 'stencil' || l.kind === 'stroke' || l.kind === 'sticker' ? 0.8 : 0.35}
181
193
  stroke-width={1 / snap.view.zoom}
182
194
  stroke-dasharray={l.kind === 'symmetry' ? `${6 / snap.view.zoom} ${4 / snap.view.zoom}` : undefined}
183
195
  />
@@ -11,6 +11,7 @@
11
11
  import PaperPanel from './PaperPanel.svelte';
12
12
  import WetPanel from './WetPanel.svelte';
13
13
  import StickerPanel from './StickerPanel.svelte';
14
+ import StrokePanel from './StrokePanel.svelte';
14
15
  import PaintEffectsPanel from './PaintEffectsPanel.svelte';
15
16
  import { chip, paintEffectsProvider } from './paintEditorRune.svelte.js';
16
17
 
@@ -128,7 +129,7 @@
128
129
  {#if tab === 'color'}<ColorPanel {controller} />{/if}
129
130
  {#if tab === 'wet'}<WetPanel {controller} />{/if}
130
131
  {#if tab === 'paper'}<PaperPanel {controller} />{/if}
131
- {#if tab === 'layers'}<PaintLayersPanel {controller} /><StickerPanel {controller} />{/if}
132
+ {#if tab === 'layers'}<PaintLayersPanel {controller} /><StrokePanel {controller} /><StickerPanel {controller} />{/if}
132
133
  {#if tab === 'effects' && effects.current}<PaintEffectsPanel {controller} />{/if}
133
134
  </div>
134
135
  </div>
@@ -25,8 +25,16 @@
25
25
  effectPaint: editor.current.effectPaint,
26
26
  rendering: editor.current.effectPaintRendering,
27
27
  color: editor.current.color,
28
+ animation: controller.getAnimation(),
29
+ playback: editor.current.effectPlayback,
28
30
  };
29
31
  });
32
+ /** Paints with the effect paint again with some of it changed (the other settings kept). */
33
+ const repaint = (patch: { values?: Record<string, unknown>; map?: unknown; live?: boolean }) => {
34
+ const fx = view.effectPaint;
35
+ if (!fx) return;
36
+ run(() => controller.setEffectPaint({ effect: fx.effect, values: fx.values as never, time: fx.time, map: fx.map, live: fx.live, ...(patch as object) }));
37
+ };
30
38
  const thumbnail = $derived.by(() => {
31
39
  const { effectPaint, rendering } = view;
32
40
  return effectPaint && !rendering ? untrack(() => controller.effectPaintThumbnail(48)) : null;
@@ -129,9 +137,44 @@
129
137
  {provider}
130
138
  effect={fx.effect}
131
139
  values={fx.values}
132
- onchange={(k, v) => run(() => controller.setEffectPaint({ effect: fx.effect, values: { ...fx.values, [k]: v as never }, time: fx.time }))}
133
- onpreset={(values) => run(() => controller.setEffectPaint({ effect: fx.effect, values: { ...fx.values, ...values } as never, time: fx.time }))}
140
+ onchange={(k, v) => repaint({ values: { ...fx.values, [k]: v } })}
141
+ onpreset={(values) => repaint({ values: { ...fx.values, ...values } })}
134
142
  />
143
+ <div class="flex flex-wrap items-center gap-2" data-testid="effect-paint-placement">
144
+ <label class="flex items-center gap-1" title="Lay the effect along each stroke instead of fixing it to the canvas">
145
+ <input type="checkbox" checked={Boolean(fx.map)} onchange={(e) => repaint({ map: e.currentTarget.checked ? { by: 'stroke', length: 300, repeat: 'repeat' } : { by: 'canvas' } })} />
146
+ Along the stroke
147
+ </label>
148
+ <label class="flex items-center gap-1" title="Strokes on a stroke layer keep playing the effect">
149
+ <input type="checkbox" checked={Boolean(fx.live)} onchange={(e) => repaint({ live: e.currentTarget.checked })} />
150
+ Live
151
+ </label>
152
+ </div>
153
+ {#if fx.map}
154
+ {@const map = fx.map}
155
+ <div class="flex flex-wrap items-center gap-2">
156
+ <label class="flex items-center gap-1">
157
+ <span class="text-slate-400">Length</span>
158
+ <input type="number" aria-label="Effect length along the stroke" min="16" max="4096" value={map.length} onchange={(e) => { const length = Number(e.currentTarget.value); if (length !== map.length) repaint({ map: { ...map, length } }); }} class="w-16 rounded bg-slate-800 px-1 py-0.5" />
159
+ </label>
160
+ <select aria-label="Effect repeat" value={map.repeat} onchange={(e) => repaint({ map: { ...map, repeat: e.currentTarget.value } })} class="rounded bg-slate-800 px-1 py-0.5">
161
+ <option value="repeat">Repeat</option>
162
+ <option value="mirror">Mirror</option>
163
+ <option value="clamp">Clamp</option>
164
+ </select>
165
+ </div>
166
+ {/if}
167
+ </div>
168
+ {/if}
169
+ {#if view.animation}
170
+ {@const animation = view.animation}
171
+ <div class="flex items-center gap-2 rounded border border-slate-700 p-1" data-effect-part="playback">
172
+ <span class="flex-1 truncate">Live paint: {animation.strokes} {animation.strokes === 1 ? 'stroke' : 'strokes'}</span>
173
+ <button type="button" class={button} aria-pressed={view.playback.playing} onclick={() => attempt(() => controller.setEffectPlayback({ playing: !view.playback.playing }), (m) => (error = m))}>{view.playback.playing ? 'Pause' : 'Play'}</button>
174
+ <label class="flex items-center gap-1">
175
+ <span class="text-slate-400">fps</span>
176
+ <input type="number" aria-label="Live paint frame rate" min="1" max="60" value={view.playback.fps} onchange={(e) => attempt(() => controller.setEffectPlayback({ fps: Math.round(Number(e.currentTarget.value)) }), (m) => (error = m))} class="w-12 rounded bg-slate-800 px-1 py-0.5" />
177
+ </label>
135
178
  </div>
136
179
  {/if}
137
180
  {#if view.layerEffect && view.activeId}
@@ -31,6 +31,7 @@
31
31
  <div class="flex flex-wrap gap-1">
32
32
  <button type="button" class={button} onclick={() => run(() => controller.addLayer())}>New layer</button>
33
33
  <button type="button" class={button} onclick={() => run(() => controller.addLayer({ type: 'group' }))}>New group</button>
34
+ <button type="button" class={button} title="A layer whose strokes stay editable" onclick={() => run(() => controller.addLayer({ type: 'strokes' }))}>New stroke layer</button>
34
35
  <button type="button" class={button} disabled={!active} onclick={() => active && run(() => controller.duplicateLayer(active.id))}>Duplicate</button>
35
36
  <button type="button" class={button} disabled={!active} aria-label="Move layer up" onclick={() => move(1)}>↑</button>
36
37
  <button type="button" class={button} disabled={!active} aria-label="Move layer down" onclick={() => move(-1)}>↓</button>
@@ -75,7 +76,7 @@
75
76
  }}
76
77
  />
77
78
  {:else}
78
- <span class="flex-1 truncate" role="presentation" ondblclick={() => (renaming = l.id)}>{l.type === 'group' ? '▸ ' : ''}{l.name}</span>
79
+ <span class="flex-1 truncate" role="presentation" ondblclick={() => (renaming = l.id)}>{l.type === 'group' ? '▸ ' : ''}{#if l.liveStrokes !== undefined}<span aria-label="Stroke layer" title={`Stroke layer: ${l.liveStrokes} editable strokes`}>✎ </span>{/if}{l.name}</span>
79
80
  {/if}
80
81
  {#if l.locks.pixels || l.locks.all}<span title="Locked">🔒</span>{/if}
81
82
  </li>
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import { STROKE_SLIDERS, liveStrokeRefusal, readPath, type PaintEditorController } from '@pixerate/editor/paint-editor';
3
+ import PaintSlider from './PaintSlider.svelte';
4
+ import { attempt, button, paintEditorState, panel } from './paintEditorRune.svelte.js';
5
+
6
+ interface Props {
7
+ controller: PaintEditorController;
8
+ class?: string;
9
+ }
10
+ let { controller, class: className = '' }: Props = $props();
11
+ const editor = paintEditorState(() => controller);
12
+ const layerId = $derived(editor.current.document.activeLayerId);
13
+ const strokes = $derived(layerId ? editor.current.document.liveStrokes?.[layerId] : undefined);
14
+ const index = $derived(strokes ? strokes.findIndex((s) => s.id === editor.current.selectedStroke) : -1);
15
+ const sel = $derived(strokes && index >= 0 ? strokes[index] : null);
16
+ const brushes = $derived(editor.current.brushes.filter((b) => !liveStrokeRefusal(b)));
17
+ let error = $state<string | null>(null);
18
+ const run = (fn: () => unknown) => attempt(fn, (m) => (error = m));
19
+ </script>
20
+
21
+ <!-- The active stroke layer's strokes: pick one (here, or with the Select stroke tool on the canvas) to change its brush, size, opacity, flow, colour or seed, reorder or delete it. The layer redraws exactly; each change is one undo step. -->
22
+ {#if layerId && strokes}
23
+ <div class={`${panel} ${className}`} data-testid="stroke-panel">
24
+ <div class="flex items-center justify-between">
25
+ <span class="font-medium text-slate-300">Strokes ({strokes.length})</span>
26
+ <button type="button" class={button} onclick={() => run(() => controller.rasterizeLiveStrokes(layerId))}>Rasterize</button>
27
+ </div>
28
+ <div class="flex max-h-24 flex-wrap gap-1 overflow-y-auto" role="listbox" aria-label="Strokes">
29
+ {#each strokes as s, i (s.id)}
30
+ <button type="button" role="option" aria-selected={s.id === editor.current.selectedStroke} title={`${i + 1}. ${s.brush.name} (${s.id})`} class={`h-5 w-5 rounded ring-1 ${s.id === editor.current.selectedStroke ? 'ring-indigo-400' : 'ring-slate-700'}`} style={`background: ${s.color}`} onclick={() => controller.selectStroke(s.id)}></button>
31
+ {/each}
32
+ </div>
33
+ {#if sel}
34
+ <div class="flex flex-col gap-1.5" data-testid="stroke-edit">
35
+ <label class="flex items-center justify-between gap-2">
36
+ <span class="text-slate-400">Brush</span>
37
+ <select aria-label="Stroke brush" value={brushes.some((b) => b.id === sel.brush.id) ? sel.brush.id : ''} onchange={(e) => { const brushId = e.currentTarget.value; if (brushId) run(() => controller.updateStroke(sel.id, { brushId })); }} class="max-w-[10rem] rounded bg-slate-800 px-1 py-0.5">
38
+ {#if !brushes.some((b) => b.id === sel.brush.id)}<option value="">{sel.brush.name}</option>{/if}
39
+ {#each brushes as b (b.id)}<option value={b.id}>{b.name}</option>{/each}
40
+ </select>
41
+ </label>
42
+ {#each STROKE_SLIDERS as c (c.path)}
43
+ <PaintSlider control={c} value={readPath(sel.brush, c.path)} oncommit={(v) => run(() => controller.updateStroke(sel.id, { brush: { tip: { [c.path.split('.')[1]]: v } } }))} />
44
+ {/each}
45
+ <label class="flex items-center justify-between">
46
+ <span class="text-slate-400">Colour</span>
47
+ <input type="color" aria-label="Stroke colour" value={sel.color} onchange={(e) => { const color = e.currentTarget.value; run(() => controller.updateStroke(sel.id, { color })); }} class="h-6 w-10 rounded bg-transparent" />
48
+ </label>
49
+ <div class="flex flex-wrap gap-1">
50
+ <button type="button" class={button} onclick={() => run(() => controller.updateStroke(sel.id, { seed: (sel.seed + 1) % 2147483647 }))}>New seed</button>
51
+ <button type="button" class={button} disabled={index === strokes.length - 1} aria-label="Move stroke up" onclick={() => run(() => controller.reorderStroke(sel.id, index + 1))}>↑</button>
52
+ <button type="button" class={button} disabled={index === 0} aria-label="Move stroke down" onclick={() => run(() => controller.reorderStroke(sel.id, index - 1))}>↓</button>
53
+ <button type="button" class={button} onclick={() => run(() => { controller.removeStrokes([sel.id]); controller.selectStroke(null); })}>Delete</button>
54
+ </div>
55
+ </div>
56
+ {/if}
57
+ {#if error}<p role="alert" class="text-[11px] text-rose-400">{error}</p>{/if}
58
+ </div>
59
+ {/if}
@@ -0,0 +1,8 @@
1
+ import { type PaintEditorController } from '@pixerate/editor/paint-editor';
2
+ interface Props {
3
+ controller: PaintEditorController;
4
+ class?: string;
5
+ }
6
+ declare const StrokePanel: import("svelte").Component<Props, {}, "">;
7
+ type StrokePanel = ReturnType<typeof StrokePanel>;
8
+ export default StrokePanel;
@@ -8,6 +8,7 @@ import type ColorPanelComponent from './ColorPanel.svelte';
8
8
  import type PaintLayersPanelComponent from './PaintLayersPanel.svelte';
9
9
  import type WetPanelComponent from './WetPanel.svelte';
10
10
  import type StickerPanelComponent from './StickerPanel.svelte';
11
+ import type StrokePanelComponent from './StrokePanel.svelte';
11
12
  import type PaintEffectsPanelComponent from './PaintEffectsPanel.svelte';
12
13
  import type PaintGradientEditorComponent from './PaintGradientEditor.svelte';
13
14
  export { default as PaintEditor } from './PaintEditor.svelte';
@@ -19,6 +20,7 @@ export { default as ColorPanel } from './ColorPanel.svelte';
19
20
  export { default as PaintLayersPanel } from './PaintLayersPanel.svelte';
20
21
  export { default as WetPanel } from './WetPanel.svelte';
21
22
  export { default as StickerPanel } from './StickerPanel.svelte';
23
+ export { default as StrokePanel } from './StrokePanel.svelte';
22
24
  export { default as PaintEffectsPanel } from './PaintEffectsPanel.svelte';
23
25
  export { default as PaintGradientEditor } from './PaintGradientEditor.svelte';
24
26
  export { default as PaintSlider } from './PaintSlider.svelte';
@@ -33,5 +35,6 @@ export type ColorPanelProps = ComponentProps<typeof ColorPanelComponent>;
33
35
  export type PaintLayersPanelProps = ComponentProps<typeof PaintLayersPanelComponent>;
34
36
  export type WetPanelProps = ComponentProps<typeof WetPanelComponent>;
35
37
  export type StickerPanelProps = ComponentProps<typeof StickerPanelComponent>;
38
+ export type StrokePanelProps = ComponentProps<typeof StrokePanelComponent>;
36
39
  export type PaintEffectsPanelProps = ComponentProps<typeof PaintEffectsPanelComponent>;
37
40
  export type PaintGradientEditorProps = ComponentProps<typeof PaintGradientEditorComponent>;
@@ -7,6 +7,7 @@ export { default as ColorPanel } from './ColorPanel.svelte';
7
7
  export { default as PaintLayersPanel } from './PaintLayersPanel.svelte';
8
8
  export { default as WetPanel } from './WetPanel.svelte';
9
9
  export { default as StickerPanel } from './StickerPanel.svelte';
10
+ export { default as StrokePanel } from './StrokePanel.svelte';
10
11
  export { default as PaintEffectsPanel } from './PaintEffectsPanel.svelte';
11
12
  export { default as PaintGradientEditor } from './PaintGradientEditor.svelte';
12
13
  export { default as PaintSlider } from './PaintSlider.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.176.1",
3
+ "version": "0.178.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.239.0"
168
+ "@pixerate/editor": "0.241.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",