@pixerate/editor-svelte 0.76.2 → 0.77.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.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/dist/paint-editor/BrushPanel.svelte +96 -0
  3. package/dist/paint-editor/BrushPanel.svelte.d.ts +11 -0
  4. package/dist/paint-editor/BrushPanel.svelte.d.ts.map +1 -0
  5. package/dist/paint-editor/ColorPanel.svelte +175 -0
  6. package/dist/paint-editor/ColorPanel.svelte.d.ts +13 -0
  7. package/dist/paint-editor/ColorPanel.svelte.d.ts.map +1 -0
  8. package/dist/paint-editor/PaintCanvas.svelte +163 -0
  9. package/dist/paint-editor/PaintCanvas.svelte.d.ts +17 -0
  10. package/dist/paint-editor/PaintCanvas.svelte.d.ts.map +1 -0
  11. package/dist/paint-editor/PaintEditor.svelte +45 -0
  12. package/dist/paint-editor/PaintEditor.svelte.d.ts +13 -0
  13. package/dist/paint-editor/PaintEditor.svelte.d.ts.map +1 -0
  14. package/dist/paint-editor/PaintLayersPanel.svelte +108 -0
  15. package/dist/paint-editor/PaintLayersPanel.svelte.d.ts +9 -0
  16. package/dist/paint-editor/PaintLayersPanel.svelte.d.ts.map +1 -0
  17. package/dist/paint-editor/PaintSlider.svelte +41 -0
  18. package/dist/paint-editor/PaintSlider.svelte.d.ts +13 -0
  19. package/dist/paint-editor/PaintSlider.svelte.d.ts.map +1 -0
  20. package/dist/paint-editor/PaintToolbar.svelte +132 -0
  21. package/dist/paint-editor/PaintToolbar.svelte.d.ts +13 -0
  22. package/dist/paint-editor/PaintToolbar.svelte.d.ts.map +1 -0
  23. package/dist/paint-editor/PaperPanel.svelte +54 -0
  24. package/dist/paint-editor/PaperPanel.svelte.d.ts +9 -0
  25. package/dist/paint-editor/PaperPanel.svelte.d.ts.map +1 -0
  26. package/dist/paint-editor/PixelCanvas.svelte +22 -0
  27. package/dist/paint-editor/PixelCanvas.svelte.d.ts +14 -0
  28. package/dist/paint-editor/PixelCanvas.svelte.d.ts.map +1 -0
  29. package/dist/paint-editor/ReferencePanel.svelte +51 -0
  30. package/dist/paint-editor/ReferencePanel.svelte.d.ts +9 -0
  31. package/dist/paint-editor/ReferencePanel.svelte.d.ts.map +1 -0
  32. package/dist/paint-editor/index.d.ts +25 -0
  33. package/dist/paint-editor/index.d.ts.map +1 -0
  34. package/dist/paint-editor/index.js +9 -0
  35. package/dist/paint-editor/paintEditorRune.svelte.d.ts +20 -0
  36. package/dist/paint-editor/paintEditorRune.svelte.d.ts.map +1 -0
  37. package/dist/paint-editor/paintEditorRune.svelte.js +61 -0
  38. package/package.json +10 -2
package/README.md CHANGED
@@ -80,6 +80,7 @@ Tables are on by default in `createRichTextPreset` (`table: false` turns them of
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
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 |
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` |
83
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) |
84
85
  | `@pixerate/editor-svelte/persistence` | `createAutosave` (crash-recovery snapshots bound to a history / dirty flag, flushed on `pagehide`; call `destroy()` when done) and `<RecoveryPrompt>` (also re-exported from the root entry) |
85
86
  | `@pixerate/editor-svelte/generation` | `GenerationSettings` (controls generated from a model's capabilities, reference slots by role, plan adjustments; `onchange` / `onaddreference`), `createGenerationPlan(() => request, () => caps)`, `JobProgress`, `createJobProgress` (call `destroy()` when done) over `@pixerate/editor/generation` (also re-exported from the root entry) |
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import { BRUSH_ROTATIONS, BRUSH_SLIDERS, downloadBytes, patchPath, readPath, type BrushRotation, type PaintEditorController } from '@pixerate/editor/paint-editor';
3
+ import { untrack } from 'svelte';
4
+ import PaintSlider from './PaintSlider.svelte';
5
+ import PixelCanvas from './PixelCanvas.svelte';
6
+ import { attempt, button, paintEditorState, panel } from './paintEditorRune.svelte.js';
7
+
8
+ interface Props {
9
+ controller: PaintEditorController;
10
+ thumbnailWidth?: number;
11
+ thumbnailHeight?: number;
12
+ class?: string;
13
+ }
14
+ let { controller, thumbnailWidth = 112, thumbnailHeight = 32, class: className = '' }: Props = $props();
15
+ const editor = paintEditorState(() => controller);
16
+ const snap = $derived(editor.current);
17
+ const brush = $derived(snap.brush);
18
+ let error = $state<string | null>(null);
19
+ let saveName = $state('');
20
+ let fileInput = $state<HTMLInputElement | null>(null);
21
+ const run = (fn: () => unknown) => attempt(fn, (m) => (error = m));
22
+ // Thumbnails follow the library and the paper.
23
+ const thumbs = $derived.by(() => {
24
+ const list = snap.brushes;
25
+ void snap.document.paper;
26
+ return untrack(() => new Map(list.map((b) => [b.id, controller.brushThumbnail(b.id, { width: thumbnailWidth, height: thumbnailHeight, color: '#26324a' })])));
27
+ });
28
+ const slug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'brush';
29
+ </script>
30
+
31
+ <!-- The brush library with thumbnails, the active brush's settings, save, import and export. -->
32
+ <div class={`${panel} ${className}`} data-testid="brush-panel">
33
+ <div class="grid grid-cols-2 gap-1" role="listbox" aria-label="Brushes">
34
+ {#each snap.brushes as b (b.id)}
35
+ <button
36
+ type="button"
37
+ role="option"
38
+ aria-selected={brush.id === b.id}
39
+ title={`${b.name} (${b.category})`}
40
+ 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'}`}
41
+ onclick={() => controller.setBrush({ id: b.id })}
42
+ >
43
+ <PixelCanvas pixels={thumbs.get(b.id) ?? null} label={`${b.name} thumbnail`} class="rounded" style={`width: 100%; aspect-ratio: ${thumbnailWidth} / ${thumbnailHeight}`} />
44
+ <span class="truncate text-[10px] text-slate-300">{b.name}</span>
45
+ </button>
46
+ {/each}
47
+ </div>
48
+ <div class="flex flex-col gap-1.5">
49
+ {#each BRUSH_SLIDERS as c (c.path)}
50
+ <PaintSlider control={c} value={readPath(brush, c.path)} onchange={(v) => controller.setBrush(patchPath(brush as never, c.path, v) as never)} />
51
+ {/each}
52
+ <label class="flex items-center justify-between gap-2">
53
+ <span class="text-slate-400">Rotation</span>
54
+ <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">
55
+ {#each BRUSH_ROTATIONS as r (r)}
56
+ <option value={r}>{r === 'stroke' ? 'Follow stroke' : r === 'barrel' ? 'Pen barrel' : 'Fixed'}</option>
57
+ {/each}
58
+ </select>
59
+ </label>
60
+ <label class="flex items-center gap-2">
61
+ <input type="checkbox" checked={brush.tip.pressureSize} onchange={(e) => controller.setBrush({ tip: { pressureSize: e.currentTarget.checked } })} /> Pressure → size
62
+ </label>
63
+ <label class="flex items-center gap-2">
64
+ <input type="checkbox" checked={brush.tip.pressureOpacity} onchange={(e) => controller.setBrush({ tip: { pressureOpacity: e.currentTarget.checked } })} /> Pressure → opacity
65
+ </label>
66
+ </div>
67
+ <div class="flex flex-wrap items-center gap-1">
68
+ <input bind:value={saveName} placeholder="Brush name" aria-label="Brush name" class="w-28 rounded bg-slate-800 px-1.5 py-0.5" />
69
+ <button
70
+ type="button"
71
+ class={button}
72
+ disabled={!saveName.trim()}
73
+ onclick={() =>
74
+ run(() => {
75
+ controller.saveBrush({ id: slug(saveName), name: saveName.trim() });
76
+ saveName = '';
77
+ })}>Save brush</button
78
+ >
79
+ <button type="button" class={button} onclick={() => run(() => downloadBytes(JSON.stringify(controller.exportBrushes(), null, 2), 'brushes.json', 'application/json'))}>Export</button>
80
+ <button type="button" class={button} onclick={() => fileInput?.click()}>Import…</button>
81
+ <input
82
+ bind:this={fileInput}
83
+ type="file"
84
+ accept=".json,application/json"
85
+ aria-label="Import brushes"
86
+ class="hidden"
87
+ onchange={(e) => {
88
+ const input = e.currentTarget;
89
+ const f = input.files?.[0];
90
+ input.value = '';
91
+ if (f) run(async () => controller.importBrushes(JSON.parse(await f.text())));
92
+ }}
93
+ />
94
+ </div>
95
+ {#if error}<p role="alert" class="text-[11px] text-rose-400">{error}</p>{/if}
96
+ </div>
@@ -0,0 +1,11 @@
1
+ import { type PaintEditorController } from '@pixerate/editor/paint-editor';
2
+ interface Props {
3
+ controller: PaintEditorController;
4
+ thumbnailWidth?: number;
5
+ thumbnailHeight?: number;
6
+ class?: string;
7
+ }
8
+ declare const BrushPanel: import("svelte").Component<Props, {}, "">;
9
+ type BrushPanel = ReturnType<typeof BrushPanel>;
10
+ export default BrushPanel;
11
+ //# sourceMappingURL=BrushPanel.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrushPanel.svelte.d.ts","sourceRoot":"","sources":["../../src/paint-editor/BrushPanel.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAA0F,KAAK,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AAOjK,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA2EH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ import {
3
+ HARMONY_KINDS,
4
+ MIXING_PAD_HEIGHT,
5
+ MIXING_PAD_WIDTH,
6
+ hsvToRgb,
7
+ hueToWheel,
8
+ parseHexColor,
9
+ renderColorWheel,
10
+ rgbToHexColor,
11
+ rgbToHsv,
12
+ wheelPick,
13
+ wheelPoint,
14
+ type ColorWheel,
15
+ type HarmonyKind,
16
+ type PaintEditorController,
17
+ } from '@pixerate/editor/paint-editor';
18
+ import { untrack } from 'svelte';
19
+ import PixelCanvas from './PixelCanvas.svelte';
20
+ import { attempt, button, chip, paintEditorState, panel } from './paintEditorRune.svelte.js';
21
+
22
+ interface Props {
23
+ controller: PaintEditorController;
24
+ /** Wheel diameter in px (default 168). */
25
+ wheelSize?: number;
26
+ /** Show the mixing pad (default true). */
27
+ mixingPad?: boolean;
28
+ class?: string;
29
+ }
30
+ let { controller, wheelSize = 168, mixingPad = true, class: className = '' }: Props = $props();
31
+ const editor = paintEditorState(() => controller);
32
+ const snap = $derived(editor.current);
33
+ let error = $state<string | null>(null);
34
+ let wheel = $state<ColorWheel>('ryb');
35
+ let kind = $state<HarmonyKind>('complementary');
36
+ let padTool = $state<'paint' | 'knife' | 'pick'>('paint');
37
+ let padPoints: Array<{ x: number; y: number; pressure?: number }> | null = null;
38
+ const run = (fn: () => unknown) => attempt(fn, (m) => (error = m));
39
+ const ring = $derived(renderColorWheel(wheelSize, wheel));
40
+ const color = $derived(snap.color);
41
+ const harmony = $derived(controller.harmony(kind, { color, wheel }));
42
+ const hsv = $derived(rgbToHsv(...parseHexColor(color)));
43
+ const pad = $derived.by(() => {
44
+ void snap.mixingPadVersion;
45
+ return untrack(() => ({ width: MIXING_PAD_WIDTH, height: MIXING_PAD_HEIGHT, data: controller.mixingPad.pixels() }));
46
+ });
47
+ const R = $derived(wheelSize / 2);
48
+ const setHsv = (s: number, v: number) => controller.setColor(rgbToHexColor(...hsvToRgb(hsv.h, s, v)));
49
+ const padPoint = (e: PointerEvent) => {
50
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
51
+ const x = Math.min(MIXING_PAD_WIDTH, Math.max(0, (e.clientX - rect.left) * (MIXING_PAD_WIDTH / (rect.width || MIXING_PAD_WIDTH))));
52
+ const y = Math.min(MIXING_PAD_HEIGHT, Math.max(0, (e.clientY - rect.top) * (MIXING_PAD_HEIGHT / (rect.height || MIXING_PAD_HEIGHT))));
53
+ return e.pointerType === 'pen' && e.pressure > 0 ? { x, y, pressure: e.pressure } : { x, y };
54
+ };
55
+ </script>
56
+
57
+ <!-- Hue wheel with harmonies, saturation and value, colours, harmony chips, swatches, mixing pad. -->
58
+ <div class={`${panel} ${className}`} data-testid="color-panel">
59
+ <div class="relative self-center" style={`width: ${wheelSize}px; height: ${wheelSize}px`}>
60
+ <div
61
+ role="slider"
62
+ aria-label="Hue wheel"
63
+ aria-valuemin={0}
64
+ aria-valuemax={360}
65
+ aria-valuenow={Math.round(hueToWheel(hsv.h, wheel))}
66
+ tabindex="0"
67
+ class="absolute inset-0 cursor-crosshair"
68
+ onpointerdown={(e) => {
69
+ const rect = e.currentTarget.getBoundingClientRect();
70
+ controller.setColor(wheelPick(e.clientX - rect.left - R, e.clientY - rect.top - R, wheel, color));
71
+ }}
72
+ >
73
+ <PixelCanvas pixels={ring} label="Colour wheel" style={`width: ${wheelSize}px; height: ${wheelSize}px`} />
74
+ </div>
75
+ <svg class="pointer-events-none absolute inset-0" width={wheelSize} height={wheelSize} aria-hidden="true">
76
+ {#each harmony as h, i (i)}
77
+ {@const p = wheelPoint(h.angle, R * 0.82)}
78
+ <circle cx={R + p.x} cy={R + p.y} r={i === 0 ? 7 : 5} fill={h.color} stroke="#fff" stroke-width={i === 0 ? 2 : 1} />
79
+ {/each}
80
+ </svg>
81
+ <div class="absolute rounded-full border border-slate-700" style={`left: ${R - R * 0.45}px; top: ${R - R * 0.45}px; width: ${R * 0.9}px; height: ${R * 0.9}px; background: ${color}`}></div>
82
+ </div>
83
+ <div class="flex items-center gap-2">
84
+ <input type="color" aria-label="Paint colour" value={color} oninput={(e) => controller.setColor(e.currentTarget.value)} class="h-7 w-9 rounded bg-transparent" />
85
+ <input type="color" aria-label="Second colour" value={snap.secondaryColor} oninput={(e) => controller.setSecondaryColor(e.currentTarget.value)} class="h-5 w-7 rounded bg-transparent" />
86
+ <button type="button" class={button} title="Swap colours (X)" onclick={() => controller.swapColors()}>Swap</button>
87
+ {#key color}
88
+ <input
89
+ aria-label="Hex colour"
90
+ value={color}
91
+ onblur={(e) => {
92
+ const v = e.currentTarget.value;
93
+ run(() => controller.setColor(v.startsWith('#') ? v : `#${v}`));
94
+ }}
95
+ class="w-20 rounded bg-slate-800 px-1.5 py-0.5 font-mono"
96
+ />
97
+ {/key}
98
+ </div>
99
+ <label class="flex items-center gap-2">
100
+ <span class="w-16 text-slate-400">Saturation</span>
101
+ <input type="range" aria-label="Saturation" min="0" max="1" step="0.01" value={hsv.s} oninput={(e) => setHsv(Number(e.currentTarget.value), hsv.v)} class="flex-1 accent-indigo-500" />
102
+ </label>
103
+ <label class="flex items-center gap-2">
104
+ <span class="w-16 text-slate-400">Value</span>
105
+ <input type="range" aria-label="Value" min="0" max="1" step="0.01" value={hsv.v} oninput={(e) => setHsv(hsv.s, Number(e.currentTarget.value))} class="flex-1 accent-indigo-500" />
106
+ </label>
107
+ <div class="flex items-center gap-1">
108
+ <select aria-label="Harmony" value={kind} onchange={(e) => (kind = e.currentTarget.value as HarmonyKind)} class="rounded bg-slate-800 px-1 py-0.5">
109
+ {#each HARMONY_KINDS as k (k)}
110
+ <option value={k}>{k}</option>
111
+ {/each}
112
+ </select>
113
+ <button type="button" class={chip(wheel === 'ryb')} aria-pressed={wheel === 'ryb'} onclick={() => (wheel = 'ryb')}>RYB</button>
114
+ <button type="button" class={chip(wheel === 'rgb')} aria-pressed={wheel === 'rgb'} onclick={() => (wheel = 'rgb')}>RGB</button>
115
+ </div>
116
+ <div class="flex flex-wrap gap-1" role="group" aria-label="Harmony colours">
117
+ {#each harmony as h, i (i)}
118
+ <button type="button" aria-label={`Use ${h.color}`} title={h.color} class="h-6 w-6 rounded ring-1 ring-slate-700" style={`background: ${h.color}`} onclick={() => controller.setColor(h.color)}></button>
119
+ {/each}
120
+ <button type="button" class={button} onclick={() => harmony.forEach((h) => controller.addSwatch(h.color))}>+ swatches</button>
121
+ </div>
122
+ <div class="flex flex-wrap gap-1" role="listbox" aria-label="Swatches">
123
+ {#each snap.swatches as s, i (i)}
124
+ <button
125
+ type="button"
126
+ role="option"
127
+ aria-selected={s.color === color}
128
+ aria-label={s.name ?? s.color}
129
+ title={`${s.name ?? s.color} (right-click to remove)`}
130
+ class="h-5 w-5 rounded ring-1 ring-slate-700"
131
+ style={`background: ${s.color}`}
132
+ onclick={() => controller.setColor(s.color)}
133
+ oncontextmenu={(e) => {
134
+ e.preventDefault();
135
+ controller.removeSwatch(i);
136
+ }}
137
+ ></button>
138
+ {/each}
139
+ <button type="button" class={button} aria-label="Add swatch" onclick={() => controller.addSwatch(color)}>+</button>
140
+ </div>
141
+ {#if mixingPad}
142
+ <div class="flex flex-col gap-1" data-testid="mixing-pad">
143
+ <div class="flex items-center gap-1">
144
+ <span class="text-slate-400">Mixing pad</span>
145
+ {#each ['paint', 'knife', 'pick'] as const as t (t)}
146
+ <button type="button" aria-pressed={padTool === t} class={chip(padTool === t)} onclick={() => (padTool = t)}>{t === 'paint' ? 'Paint' : t === 'knife' ? 'Knife' : 'Pick'}</button>
147
+ {/each}
148
+ <button type="button" class={button} onclick={() => controller.clearMixingPad()}>Clear</button>
149
+ </div>
150
+ <div
151
+ role="application"
152
+ aria-label="Mixing pad"
153
+ class="touch-none cursor-crosshair"
154
+ onpointerdown={(e) => {
155
+ const p = padPoint(e);
156
+ if (padTool === 'pick') {
157
+ run(() => controller.mixingPadPick(p.x, p.y, { setColor: true }));
158
+ return;
159
+ }
160
+ padPoints = [p];
161
+ (e.target as Element).setPointerCapture?.(e.pointerId);
162
+ }}
163
+ onpointermove={(e) => padPoints?.push(padPoint(e))}
164
+ onpointerup={() => {
165
+ const pts = padPoints;
166
+ padPoints = null;
167
+ if (pts?.length) run(() => controller.mixingPadStroke(pts, { tool: padTool === 'knife' ? 'knife' : 'paint' }));
168
+ }}
169
+ >
170
+ <PixelCanvas pixels={pad} label="Mixing pad pixels" class="w-full rounded" style={`aspect-ratio: ${MIXING_PAD_WIDTH} / ${MIXING_PAD_HEIGHT}`} />
171
+ </div>
172
+ </div>
173
+ {/if}
174
+ {#if error}<p role="alert" class="text-[11px] text-rose-400">{error}</p>{/if}
175
+ </div>
@@ -0,0 +1,13 @@
1
+ import { type PaintEditorController } from '@pixerate/editor/paint-editor';
2
+ interface Props {
3
+ controller: PaintEditorController;
4
+ /** Wheel diameter in px (default 168). */
5
+ wheelSize?: number;
6
+ /** Show the mixing pad (default true). */
7
+ mixingPad?: boolean;
8
+ class?: string;
9
+ }
10
+ declare const ColorPanel: import("svelte").Component<Props, {}, "">;
11
+ type ColorPanel = ReturnType<typeof ColorPanel>;
12
+ export default ColorPanel;
13
+ //# sourceMappingURL=ColorPanel.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ColorPanel.svelte.d.ts","sourceRoot":"","sources":["../../src/paint-editor/ColorPanel.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAcH,KAAK,qBAAqB,EAC3B,MAAM,+BAA+B,CAAC;AAMvC,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA6HH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,163 @@
1
+ <script lang="ts">
2
+ import {
3
+ PaintCanvasPresenter,
4
+ PaintGesture,
5
+ fitZoom,
6
+ guideOverlay,
7
+ handlePaintShortcut,
8
+ viewCssTransform,
9
+ type PaintEditorController,
10
+ type PaintTool,
11
+ } from '@pixerate/editor/paint-editor';
12
+ import { untrack } from 'svelte';
13
+ import { paintEditorState } from './paintEditorRune.svelte.js';
14
+ import ReferencePanel from './ReferencePanel.svelte';
15
+
16
+ interface Props {
17
+ controller: PaintEditorController;
18
+ /** The active tool (default brush). */
19
+ tool?: PaintTool;
20
+ /** Draw the symmetry axes and perspective lines (default true). */
21
+ showGuides?: boolean;
22
+ /** Fit the canvas in the viewport on mount (default true). */
23
+ fitOnMount?: boolean;
24
+ /** Called with a refusal (hidden or locked layer). */
25
+ onerror?: (message: string | null) => void;
26
+ class?: string;
27
+ }
28
+ let { controller, tool = 'brush', showGuides = true, fitOnMount = true, onerror, class: className = '' }: Props = $props();
29
+
30
+ const editor = paintEditorState(() => controller);
31
+ const snap = $derived(editor.current);
32
+ let viewport = $state<HTMLDivElement | null>(null);
33
+ let canvas = $state<HTMLCanvasElement | null>(null);
34
+ let size = $state({ width: 800, height: 600 });
35
+ let predicted = $state<Array<{ x: number; y: number }>>([]);
36
+ const presenter = $derived(new PaintCanvasPresenter(controller));
37
+ const gesture = $derived(
38
+ new PaintGesture(controller, () => {
39
+ const r = viewport?.getBoundingClientRect();
40
+ return { left: r?.left ?? 0, top: r?.top ?? 0, width: r?.width || size.width, height: r?.height || size.height };
41
+ })
42
+ );
43
+ $effect(() => {
44
+ gesture.tool = tool;
45
+ });
46
+ const doc = $derived(snap.document.canvas);
47
+
48
+ $effect(() => {
49
+ const el = viewport;
50
+ if (!el || typeof ResizeObserver === 'undefined') return;
51
+ const ro = new ResizeObserver(() => {
52
+ if (!el.clientWidth || !el.clientHeight) return;
53
+ measured = true;
54
+ size = { width: el.clientWidth, height: el.clientHeight };
55
+ });
56
+ ro.observe(el);
57
+ return () => ro.disconnect();
58
+ });
59
+
60
+ // Fit once per canvas size, after the viewport has been measured (or at once where it cannot be).
61
+ let fitted: string | null = null;
62
+ let measured = $state(false);
63
+ $effect(() => {
64
+ const key = `${doc.width}x${doc.height}`;
65
+ const s = size;
66
+ if (!fitOnMount || fitted === key) return;
67
+ if (typeof ResizeObserver !== 'undefined' && !measured) return;
68
+ fitted = key;
69
+ untrack(() => controller.setView({ zoom: fitZoom(doc, s), pan: { x: 0, y: 0 } }));
70
+ });
71
+
72
+ // Upload changed tiles whenever the state changes.
73
+ $effect(() => {
74
+ void snap;
75
+ const ctx = canvas?.getContext('2d');
76
+ untrack(() => {
77
+ if (ctx && typeof ctx.createImageData === 'function') presenter.render(ctx as never);
78
+ else controller.takeDirty();
79
+ });
80
+ });
81
+
82
+ // Wheel needs a non-passive listener to prevent page scroll (see GOTCHAS).
83
+ $effect(() => {
84
+ const el = viewport;
85
+ if (!el) return;
86
+ const g = gesture;
87
+ const onWheel = (e: WheelEvent) => {
88
+ e.preventDefault();
89
+ g.wheel(e);
90
+ };
91
+ el.addEventListener('wheel', onWheel, { passive: false });
92
+ return () => el.removeEventListener('wheel', onWheel);
93
+ });
94
+
95
+ const transform = $derived(viewCssTransform(snap.view, doc, size));
96
+ const lines = $derived.by(() => {
97
+ void snap;
98
+ return showGuides ? guideOverlay(controller) : [];
99
+ });
100
+ </script>
101
+
102
+ <!-- The canvas is a painting surface (role application): it takes pointer and key input. -->
103
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
104
+ <div
105
+ bind:this={viewport}
106
+ tabindex="0"
107
+ role="application"
108
+ aria-label="Paint canvas"
109
+ data-testid="paint-canvas"
110
+ class={`relative overflow-hidden bg-slate-950 outline-none touch-none select-none ${className}`}
111
+ style={`cursor: ${tool === 'pan' ? 'grab' : tool === 'eyedropper' ? 'copy' : 'crosshair'}`}
112
+ onpointerdown={(e) => {
113
+ viewport?.focus();
114
+ if (gesture.down(e)) (e.target as Element).setPointerCapture?.(e.pointerId);
115
+ onerror?.(gesture.error);
116
+ }}
117
+ onpointermove={(e) => {
118
+ gesture.move(e);
119
+ if (gesture.predicted.length || predicted.length) predicted = gesture.predicted.map((p) => ({ x: p.x, y: p.y }));
120
+ }}
121
+ onpointerup={(e) => {
122
+ gesture.up(e);
123
+ predicted = [];
124
+ }}
125
+ onpointercancel={() => {
126
+ gesture.cancel();
127
+ predicted = [];
128
+ }}
129
+ onkeydown={(e) => {
130
+ if (handlePaintShortcut(controller, gesture, e)) e.preventDefault();
131
+ }}
132
+ >
133
+ <canvas
134
+ bind:this={canvas}
135
+ width={doc.width}
136
+ height={doc.height}
137
+ class="absolute left-0 top-0 shadow-2xl"
138
+ style={`transform: ${transform}; transform-origin: 0 0; image-rendering: ${snap.view.zoom > 2 ? 'pixelated' : 'auto'}`}
139
+ ></canvas>
140
+ <svg class="absolute left-0 top-0 pointer-events-none overflow-visible" width={doc.width} height={doc.height} style={`transform: ${transform}; transform-origin: 0 0`} aria-hidden="true">
141
+ {#each lines as l, i (i)}
142
+ <line
143
+ x1={l.from.x}
144
+ y1={l.from.y}
145
+ x2={l.to.x}
146
+ y2={l.to.y}
147
+ stroke={l.kind === 'symmetry' ? '#22d3ee' : '#f472b6'}
148
+ stroke-opacity={l.kind === 'symmetry' ? 0.8 : 0.35}
149
+ stroke-width={1 / snap.view.zoom}
150
+ stroke-dasharray={l.kind === 'symmetry' ? `${6 / snap.view.zoom} ${4 / snap.view.zoom}` : undefined}
151
+ />
152
+ {/each}
153
+ {#if predicted.length > 0}
154
+ <polyline points={predicted.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke={snap.color} stroke-opacity="0.5" stroke-width={Math.max(1, snap.brush.tip.size / 2)} stroke-linecap="round" />
155
+ {/if}
156
+ </svg>
157
+ {#each snap.guides.references.filter((r) => r.visible) as r (r.id)}
158
+ <ReferencePanel {controller} reference={r} />
159
+ {/each}
160
+ <div class="absolute bottom-1 right-2 text-[10px] text-slate-500 pointer-events-none" data-testid="paint-view-status">
161
+ {Math.round(snap.view.zoom * 100)}% · {Math.round(snap.view.rotation)}°{snap.view.mirror ? ' · mirrored' : ''}
162
+ </div>
163
+ </div>
@@ -0,0 +1,17 @@
1
+ import { type PaintEditorController, type PaintTool } from '@pixerate/editor/paint-editor';
2
+ interface Props {
3
+ controller: PaintEditorController;
4
+ /** The active tool (default brush). */
5
+ tool?: PaintTool;
6
+ /** Draw the symmetry axes and perspective lines (default true). */
7
+ showGuides?: boolean;
8
+ /** Fit the canvas in the viewport on mount (default true). */
9
+ fitOnMount?: boolean;
10
+ /** Called with a refusal (hidden or locked layer). */
11
+ onerror?: (message: string | null) => void;
12
+ class?: string;
13
+ }
14
+ declare const PaintCanvas: import("svelte").Component<Props, {}, "">;
15
+ type PaintCanvas = ReturnType<typeof PaintCanvas>;
16
+ export default PaintCanvas;
17
+ //# sourceMappingURL=PaintCanvas.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PaintCanvas.svelte.d.ts","sourceRoot":"","sources":["../../src/paint-editor/PaintCanvas.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAOH,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EACf,MAAM,+BAA+B,CAAC;AAMvC,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,uCAAuC;IACvC,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,mEAAmE;IACnE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,8DAA8D;IAC9D,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,sDAAsD;IACtD,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAsHH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,45 @@
1
+ <script lang="ts">
2
+ import type { PaintEditorController, PaintTool } from '@pixerate/editor/paint-editor';
3
+ import BrushPanel from './BrushPanel.svelte';
4
+ import ColorPanel from './ColorPanel.svelte';
5
+ import PaintCanvas from './PaintCanvas.svelte';
6
+ import PaintLayersPanel from './PaintLayersPanel.svelte';
7
+ import PaintToolbar from './PaintToolbar.svelte';
8
+ import PaperPanel from './PaperPanel.svelte';
9
+ import { chip } from './paintEditorRune.svelte.js';
10
+
11
+ interface Props {
12
+ controller: PaintEditorController;
13
+ /** Base name of saved and exported files (default `painting`). */
14
+ fileName?: string;
15
+ /** Height of the editor (default 720 px). */
16
+ height?: number | string;
17
+ class?: string;
18
+ }
19
+ let { controller, fileName, height = 720, class: className = '' }: Props = $props();
20
+ let tool = $state<PaintTool>('brush');
21
+ let tab = $state<'color' | 'paper' | 'layers'>('color');
22
+ let error = $state<string | null>(null);
23
+ </script>
24
+
25
+ <!-- Toolbar, brush library, the canvas, and colour / paper / layers. -->
26
+ <div class={`flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900 p-2 ${className}`} style={`height: ${typeof height === 'number' ? `${height}px` : height}`} data-testid="paint-editor">
27
+ <PaintToolbar {controller} {tool} ontoolchange={(t) => (tool = t)} {fileName} />
28
+ <div class="flex min-h-0 flex-1 gap-2">
29
+ <BrushPanel {controller} class="w-60 shrink-0 overflow-y-auto pr-1" />
30
+ <div class="flex min-w-0 flex-1 flex-col">
31
+ <PaintCanvas {controller} {tool} onerror={(e) => (error = e)} class="flex-1 rounded-lg" />
32
+ {#if error}<p role="alert" class="mt-1 text-[11px] text-rose-400">{error}</p>{/if}
33
+ </div>
34
+ <div class="flex w-64 shrink-0 flex-col gap-2 overflow-y-auto pr-1">
35
+ <div class="flex gap-1" role="tablist" aria-label="Paint panels">
36
+ {#each ['color', 'paper', 'layers'] as const as t (t)}
37
+ <button type="button" role="tab" aria-selected={tab === t} class={chip(tab === t)} onclick={() => (tab = t)}>{t === 'color' ? 'Colour' : t === 'paper' ? 'Paper' : 'Layers'}</button>
38
+ {/each}
39
+ </div>
40
+ {#if tab === 'color'}<ColorPanel {controller} />{/if}
41
+ {#if tab === 'paper'}<PaperPanel {controller} />{/if}
42
+ {#if tab === 'layers'}<PaintLayersPanel {controller} />{/if}
43
+ </div>
44
+ </div>
45
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { PaintEditorController } from '@pixerate/editor/paint-editor';
2
+ interface Props {
3
+ controller: PaintEditorController;
4
+ /** Base name of saved and exported files (default `painting`). */
5
+ fileName?: string;
6
+ /** Height of the editor (default 720 px). */
7
+ height?: number | string;
8
+ class?: string;
9
+ }
10
+ declare const PaintEditor: import("svelte").Component<Props, {}, "">;
11
+ type PaintEditor = ReturnType<typeof PaintEditor>;
12
+ export default PaintEditor;
13
+ //# sourceMappingURL=PaintEditor.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PaintEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/paint-editor/PaintEditor.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAa,MAAM,+BAA+B,CAAC;AAUpF,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyCH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}