@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.
- package/dist/paint-editor/PaintCanvas.svelte +16 -4
- package/dist/paint-editor/PaintEditor.svelte +2 -1
- package/dist/paint-editor/PaintEffectsPanel.svelte +45 -2
- package/dist/paint-editor/PaintLayersPanel.svelte +2 -1
- package/dist/paint-editor/StrokePanel.svelte +59 -0
- package/dist/paint-editor/StrokePanel.svelte.d.ts +8 -0
- package/dist/paint-editor/index.d.ts +3 -0
- package/dist/paint-editor/index.js +1 -0
- package/package.json +2 -2
|
@@ -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) =>
|
|
133
|
-
onpreset={(values) =>
|
|
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.
|
|
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.
|
|
168
|
+
"@pixerate/editor": "0.241.0"
|
|
169
169
|
},
|
|
170
170
|
"peerDependencies": {
|
|
171
171
|
"@dagrejs/dagre": "^1.1.4",
|