@pixerate/editor-svelte 0.187.1 → 0.188.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.
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { brushLibraryGroups, type BrushHostProfile, type BrushLibraryEntry } from '@pixerate/editor/brushes';
3
- import PixelCanvas from './PixelCanvas.svelte';
3
+ import Thumbnail from './Thumbnail.svelte';
4
4
 
5
5
  interface Props {
6
6
  /** The library's brushes (built-in presets and saved ones), as facets. */
@@ -10,8 +10,8 @@
10
10
  onselect: (id: string) => void;
11
11
  /** Only brushes this host renders. */
12
12
  profile?: BrushHostProfile;
13
- /** A thumbnail for a brush (a test stroke). */
14
- thumbnail?: (id: string) => { width: number; height: number; data: Uint8ClampedArray } | null;
13
+ /** A thumbnail for a brush (a test stroke): pixels, or an image URL. */
14
+ thumbnail?: (id: string) => { width: number; height: number; data: Uint8ClampedArray } | string | null | undefined;
15
15
  thumbnailWidth?: number;
16
16
  thumbnailHeight?: number;
17
17
  class?: string;
@@ -40,7 +40,7 @@
40
40
  onclick={() => onselect(e.brush.id)}
41
41
  >
42
42
  {#if thumbnail}
43
- <PixelCanvas pixels={thumbnail(e.brush.id)} label={`${e.brush.name} thumbnail`} class="rounded" style={`width: 100%; aspect-ratio: ${thumbnailWidth} / ${thumbnailHeight}`} />
43
+ <Thumbnail image={thumbnail(e.brush.id) ?? null} label={`${e.brush.name} thumbnail`} style={`width: 100%; aspect-ratio: ${thumbnailWidth} / ${thumbnailHeight}`} />
44
44
  {/if}
45
45
  <span class="truncate text-[10px] text-slate-300">{e.brush.name}</span>
46
46
  </button>
@@ -7,12 +7,12 @@ interface Props {
7
7
  onselect: (id: string) => void;
8
8
  /** Only brushes this host renders. */
9
9
  profile?: BrushHostProfile;
10
- /** A thumbnail for a brush (a test stroke). */
10
+ /** A thumbnail for a brush (a test stroke): pixels, or an image URL. */
11
11
  thumbnail?: (id: string) => {
12
12
  width: number;
13
13
  height: number;
14
14
  data: Uint8ClampedArray;
15
- } | null;
15
+ } | string | null | undefined;
16
16
  thumbnailWidth?: number;
17
17
  thumbnailHeight?: number;
18
18
  class?: string;
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /** RGBA pixels drawn on a canvas, or an image URL (e.g. a brushstroke swatch's data URL). */
4
+ image: { width: number; height: number; data: Uint8ClampedArray } | string | null;
5
+ label: string;
6
+ style?: string;
7
+ }
8
+ let { image, label, style = '' }: Props = $props();
9
+ let canvas = $state<HTMLCanvasElement | null>(null);
10
+
11
+ $effect(() => {
12
+ const ctx = canvas?.getContext('2d');
13
+ if (!canvas || !ctx || !image || typeof image === 'string' || typeof ctx.createImageData !== 'function') return;
14
+ canvas.width = image.width;
15
+ canvas.height = image.height;
16
+ const img = ctx.createImageData(image.width, image.height);
17
+ img.data.set(image.data);
18
+ ctx.putImageData(img, 0, 0);
19
+ });
20
+ </script>
21
+
22
+ {#if typeof image === 'string'}
23
+ <img src={image} alt={label} class="rounded object-contain" {style} />
24
+ {:else}
25
+ <canvas bind:this={canvas} role="img" aria-label={label} class="rounded" {style} width={image?.width} height={image?.height}></canvas>
26
+ {/if}
@@ -0,0 +1,13 @@
1
+ interface Props {
2
+ /** RGBA pixels drawn on a canvas, or an image URL (e.g. a brushstroke swatch's data URL). */
3
+ image: {
4
+ width: number;
5
+ height: number;
6
+ data: Uint8ClampedArray;
7
+ } | string | null;
8
+ label: string;
9
+ style?: string;
10
+ }
11
+ declare const Thumbnail: import("svelte").Component<Props, {}, "">;
12
+ type Thumbnail = ReturnType<typeof Thumbnail>;
13
+ export default Thumbnail;
@@ -0,0 +1,9 @@
1
+ import type { BrushFilter, BrushLibrary, BrushLibraryEntry } from '@pixerate/editor/brushes';
2
+ /**
3
+ * The brushes of a brush library as a rune (`filter`: e.g. `{ profile: CANVAS_PROFILE }` for what
4
+ * a host can paint), read again after every change to the library: a brush saved in another
5
+ * editor sharing it shows up. Empty until the first read. Call it during component setup.
6
+ */
7
+ export declare function brushLibraryState(library: () => Pick<BrushLibrary, 'list' | 'subscribe'> | null | undefined, filter?: () => BrushFilter): {
8
+ readonly entries: BrushLibraryEntry[];
9
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The brushes of a brush library as a rune (`filter`: e.g. `{ profile: CANVAS_PROFILE }` for what
3
+ * a host can paint), read again after every change to the library: a brush saved in another
4
+ * editor sharing it shows up. Empty until the first read. Call it during component setup.
5
+ */
6
+ export function brushLibraryState(library, filter = () => ({})) {
7
+ let entries = $state([]);
8
+ $effect(() => {
9
+ const lib = library();
10
+ const f = filter();
11
+ if (!lib)
12
+ return;
13
+ let live = true;
14
+ const read = () => void lib.list(f).then((list) => live && (entries = list), () => undefined);
15
+ read();
16
+ const unsubscribe = lib.subscribe(read);
17
+ return () => {
18
+ live = false;
19
+ unsubscribe();
20
+ };
21
+ });
22
+ return {
23
+ get entries() {
24
+ return entries;
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,5 @@
1
+ import type { ComponentProps } from 'svelte';
2
+ import type BrushLibraryBrowserComponent from './BrushLibraryBrowser.svelte';
3
+ export { default as BrushLibraryBrowser } from './BrushLibraryBrowser.svelte';
4
+ export { brushLibraryState } from './brushLibrary.svelte.js';
5
+ export type BrushLibraryBrowserProps = ComponentProps<typeof BrushLibraryBrowserComponent>;
@@ -0,0 +1,2 @@
1
+ export { default as BrushLibraryBrowser } from './BrushLibraryBrowser.svelte';
2
+ export { brushLibraryState } from './brushLibrary.svelte.js';
@@ -33,7 +33,7 @@ export declare function createCanvasDraw<TNode extends CanvasNode = CanvasNode>(
33
33
  height: number;
34
34
  } | undefined;
35
35
  /** The dry paint presets `style.brush` accepts (loads the paint engine). */
36
- listBrushes: typeof import("@pixerate/editor/canvas").listCanvasBrushes;
36
+ listBrushes: () => Promise<import("@pixerate/editor/canvas").CanvasBrushPreset[]>;
37
37
  };
38
38
  export type CanvasDraw<TNode extends CanvasNode = CanvasNode> = ReturnType<typeof createCanvasDraw<TNode>>;
39
39
  export {};
@@ -1,4 +1,5 @@
1
1
  import { type CanvasBrushPreset, type CanvasBrushSwatchOptions } from '@pixerate/editor/canvas';
2
+ import type { BrushLibrary } from '@pixerate/editor/brushes';
2
3
  /**
3
4
  * Tells brushed `ShapeNode`s the canvas zoom so their paint stays sharp. Call it in the component
4
5
  * that renders `<SvelteFlow>`, with a getter of the live zoom. Nodes re-render only when the zoom
@@ -8,15 +9,19 @@ export declare function setShapeNodeZoom(getZoom: () => number): void;
8
9
  /** The bucketed canvas zoom for brushed shape nodes (1 when no `setShapeNodeZoom` is above). */
9
10
  export declare function getShapeNodeZoom(): () => number;
10
11
  /**
11
- * The dry paint presets a freehand stroke can be brushed with (loads the paint engine); with
12
- * `swatches`, each with a preview image (`renderCanvasBrushSwatch`) for a brush menu.
12
+ * The brushes a freehand stroke can be brushed with (loads the paint engine): the dry presets and,
13
+ * with `library`, its saved brushes the canvas can paint (each with its `spec`; pass it as the
14
+ * stroke style's `brushSpec`), read again when the library changes (`dispose` stops that). With
15
+ * `swatches`, each has a preview image (`renderCanvasBrushSwatch`) for a brush menu.
13
16
  */
14
17
  export declare function createCanvasBrushes(options?: {
15
18
  swatches?: CanvasBrushSwatchOptions;
19
+ library?: Pick<BrushLibrary, 'list' | 'subscribe'> | null;
16
20
  }): {
17
21
  readonly brushes: (CanvasBrushPreset & {
18
22
  swatch?: string;
19
23
  })[];
20
24
  readonly loading: boolean;
21
25
  readonly error: string | null;
26
+ dispose(): void;
22
27
  };
@@ -14,16 +14,19 @@ export function getShapeNodeZoom() {
14
14
  return getContext(ZOOM_KEY) ?? (() => 1);
15
15
  }
16
16
  /**
17
- * The dry paint presets a freehand stroke can be brushed with (loads the paint engine); with
18
- * `swatches`, each with a preview image (`renderCanvasBrushSwatch`) for a brush menu.
17
+ * The brushes a freehand stroke can be brushed with (loads the paint engine): the dry presets and,
18
+ * with `library`, its saved brushes the canvas can paint (each with its `spec`; pass it as the
19
+ * stroke style's `brushSpec`), read again when the library changes (`dispose` stops that). With
20
+ * `swatches`, each has a preview image (`renderCanvasBrushSwatch`) for a brush menu.
19
21
  */
20
22
  export function createCanvasBrushes(options = {}) {
21
23
  let brushes = $state.raw([]);
22
24
  let loading = $state(true);
23
25
  let error = $state(null);
24
26
  const opts = options.swatches;
25
- listCanvasBrushes()
26
- .then(async (list) => (opts ? Promise.all(list.map(async (b) => ({ ...b, swatch: await renderCanvasBrushSwatch(b.id, opts) }))) : list))
27
+ const library = options.library ?? undefined;
28
+ const read = () => listCanvasBrushes(library)
29
+ .then(async (list) => (opts ? Promise.all(list.map(async (b) => ({ ...b, swatch: await renderCanvasBrushSwatch(b.id, opts, b.spec) }))) : list))
27
30
  .then((list) => {
28
31
  brushes = list;
29
32
  loading = false;
@@ -31,6 +34,8 @@ export function createCanvasBrushes(options = {}) {
31
34
  error = e instanceof Error ? e.message : String(e);
32
35
  loading = false;
33
36
  });
37
+ void read();
38
+ const unsubscribe = library?.subscribe(() => void read());
34
39
  return {
35
40
  get brushes() {
36
41
  return brushes;
@@ -40,6 +45,9 @@ export function createCanvasBrushes(options = {}) {
40
45
  },
41
46
  get error() {
42
47
  return error;
48
+ },
49
+ dispose() {
50
+ unsubscribe?.();
43
51
  }
44
52
  };
45
53
  }
@@ -24,6 +24,9 @@
24
24
  import { DEFAULT_PAINT_GRADIENT } from '@pixerate/editor/image-editor';
25
25
  import PaintGradientEditor from './PaintGradientEditor.svelte';
26
26
  import ControlSlider from './ControlSlider.svelte';
27
+ import { IMAGE_PROFILE } from '@pixerate/editor/brushes';
28
+ import BrushLibraryBrowser from '../brushes/BrushLibraryBrowser.svelte';
29
+ import { brushLibraryState } from '../brushes/brushLibrary.svelte.js';
27
30
 
28
31
  interface Props {
29
32
  controller: ImageEditorController;
@@ -46,6 +49,10 @@
46
49
  return { tool: s.activeTool, sourceLabel: cloneSourceLabel(controller), cloneView: cloneTransformView(controller), ...s.paint };
47
50
  });
48
51
  let error = $state<string | null>(null);
52
+ let presetsOpen = $state(false);
53
+ // Read only while open: the list follows the library, so a brush saved in another editor shows up.
54
+ const presets = brushLibraryState(() => (presetsOpen ? controller.brushLibrary : null), () => ({ profile: IMAGE_PROFILE }));
55
+ const presetName = $derived(presets.entries.find((e) => e.brush.id === view.brushPreset)?.brush.name ?? view.brushPreset);
49
56
 
50
57
 
51
58
 
@@ -73,6 +80,23 @@
73
80
  </div>
74
81
  {/if}
75
82
 
83
+ {#if view.tool === 'brush' || view.tool === 'mixer' || view.tool === 'eraser'}
84
+ <div class="flex flex-col gap-1" data-testid="brush-presets">
85
+ <button type="button" aria-expanded={presetsOpen} class={imageChipClass(presetsOpen)} onclick={() => (presetsOpen = !presetsOpen)} title="Paint with a brush from the brush library">Brush presets{presetName ? `: ${presetName}` : ''}</button>
86
+ {#if presetsOpen}
87
+ <BrushLibraryBrowser
88
+ entries={presets.entries}
89
+ activeId={view.brushPreset ?? undefined}
90
+ profile={IMAGE_PROFILE}
91
+ onselect={(id) => {
92
+ error = null;
93
+ controller.setBrushPreset(id).catch((err) => (error = userErrorMessage(err)));
94
+ }}
95
+ />
96
+ {/if}
97
+ </div>
98
+ {/if}
99
+
76
100
  <div class="flex items-center gap-2" role="group" aria-label="Colours">
77
101
  <label class="flex items-center gap-1">
78
102
  <ImageColor label="Foreground colour" value={view.foreground} oninput={(foreground) => controller.setPaintOptions({ foreground })} />
@@ -4,7 +4,7 @@
4
4
  import { PAINT_PROFILE, type Brush, type BrushLibraryEntry } from '@pixerate/editor/brushes';
5
5
  import { untrack } from 'svelte';
6
6
  import BrushEditor from './BrushEditor.svelte';
7
- import BrushLibraryBrowser from './BrushLibraryBrowser.svelte';
7
+ import BrushLibraryBrowser from '../brushes/BrushLibraryBrowser.svelte';
8
8
  import PaintGradientEditor from './PaintGradientEditor.svelte';
9
9
  import { attempt, button, chip, paintEditorState, panel } from './paintEditorRune.svelte.js';
10
10
 
@@ -4,7 +4,7 @@ import type PaintCanvasComponent from './PaintCanvas.svelte';
4
4
  import type PaintToolbarComponent from './PaintToolbar.svelte';
5
5
  import type BrushPanelComponent from './BrushPanel.svelte';
6
6
  import type BrushEditorComponent from './BrushEditor.svelte';
7
- import type BrushLibraryBrowserComponent from './BrushLibraryBrowser.svelte';
7
+ import type BrushLibraryBrowserComponent from '../brushes/BrushLibraryBrowser.svelte';
8
8
  import type PaperPanelComponent from './PaperPanel.svelte';
9
9
  import type ColorPanelComponent from './ColorPanel.svelte';
10
10
  import type PaintLayersPanelComponent from './PaintLayersPanel.svelte';
@@ -19,7 +19,7 @@ export { default as PaintCanvas } from './PaintCanvas.svelte';
19
19
  export { default as PaintToolbar } from './PaintToolbar.svelte';
20
20
  export { default as BrushPanel } from './BrushPanel.svelte';
21
21
  export { default as BrushEditor } from './BrushEditor.svelte';
22
- export { default as BrushLibraryBrowser } from './BrushLibraryBrowser.svelte';
22
+ export { default as BrushLibraryBrowser } from '../brushes/BrushLibraryBrowser.svelte';
23
23
  export { default as PaperPanel } from './PaperPanel.svelte';
24
24
  export { default as ColorPanel } from './ColorPanel.svelte';
25
25
  export { default as PaintLayersPanel } from './PaintLayersPanel.svelte';
@@ -3,7 +3,8 @@ 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
5
  export { default as BrushEditor } from './BrushEditor.svelte';
6
- export { default as BrushLibraryBrowser } from './BrushLibraryBrowser.svelte';
6
+ // The library browser moved to `@pixerate/editor-svelte/brushes`; it is exported here too.
7
+ export { default as BrushLibraryBrowser } from '../brushes/BrushLibraryBrowser.svelte';
7
8
  export { default as PaperPanel } from './PaperPanel.svelte';
8
9
  export { default as ColorPanel } from './ColorPanel.svelte';
9
10
  export { default as PaintLayersPanel } from './PaintLayersPanel.svelte';
@@ -10,8 +10,7 @@
10
10
  type VectorFilterType,
11
11
  type VectorFiltersPanelItem,
12
12
  type VectorStrokeAlign,
13
- randomVectorBrushstrokeSeed,
14
- vectorBrushstrokeBrushes
13
+ randomVectorBrushstrokeSeed
15
14
  } from '@pixerate/editor/vector';
16
15
  import { vectorStateOf } from './subscribe.svelte.js';
17
16
 
@@ -32,6 +31,8 @@
32
31
  const store = vectorStateOf(() => controller);
33
32
  const view = $derived(store.current);
34
33
  const panel = $derived(getVectorAppearancePanel(view));
34
+ // Follows the editor state, which changes when the brush library is read again.
35
+ const brushes = $derived.by(() => (void view, controller.listBrushstrokeBrushes()));
35
36
 
36
37
  const buttonClass =
37
38
  'inline-flex h-7 min-w-7 items-center justify-center rounded-md border border-border bg-background px-1.5 text-xs font-medium text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40';
@@ -72,7 +73,7 @@
72
73
  <input type="checkbox" aria-label="{name} visible" title="Show" checked={l.visible} onchange={(e) => update(l, { visible: e.currentTarget.checked })} />
73
74
  <input type="color" aria-label="{name} colour" class="h-7 w-10 cursor-pointer rounded border border-border bg-background" value={l.color ?? '#000000'} oninput={(e) => controller.setBrushstroke(l.index, { color: e.currentTarget.value }, { ids, merge: true })} onblur={() => controller.endMergedEdit()} />
74
75
  <select aria-label="{name} brush" class={selectClass} value={bs.brushId} onchange={(e) => controller.setBrushstroke(l.index, { brushId: e.currentTarget.value }, { ids })}>
75
- {#each vectorBrushstrokeBrushes() as b (b.id)}<option value={b.id}>{b.name}</option>{/each}
76
+ {#each brushes as b (b.id)}<option value={b.id}>{b.name}</option>{/each}
76
77
  </select>
77
78
  <input type="number" min="0.5" step="0.5" aria-label="{name} brush size" title="Brush size" class={numberClass} value={bs.size} oninput={(e) => {
78
79
  const n = parseFloat(e.currentTarget.value);
@@ -169,7 +170,7 @@
169
170
  onclick={() => controller.addPaintLayer(kind, {}, { ids })}>+</button
170
171
  >
171
172
  {#if kind === 'stroke'}
172
- <button type="button" class={buttonClass} title="Add a brushstroke (a natural-media brush along the path)" aria-label="Add brushstroke" data-vector-add-brushstroke="" disabled={!ids.length} onclick={() => controller.addBrushstroke({ brushId: vectorBrushstrokeBrushes()[0].id, seed: randomVectorBrushstrokeSeed() }, { ids })}>✎+</button>
173
+ <button type="button" class={buttonClass} title="Add a brushstroke (a natural-media brush along the path)" aria-label="Add brushstroke" data-vector-add-brushstroke="" disabled={!ids.length} onclick={() => controller.addBrushstroke({ brushId: brushes[0].id, seed: randomVectorBrushstrokeSeed() }, { ids })}>✎+</button>
173
174
  {/if}
174
175
  </div>
175
176
  <!-- Top layer first, as it draws. -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.187.1",
3
+ "version": "0.188.0",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -48,6 +48,11 @@
48
48
  "svelte": "./dist/paint-editor/index.js",
49
49
  "default": "./dist/paint-editor/index.js"
50
50
  },
51
+ "./brushes": {
52
+ "types": "./dist/brushes/index.d.ts",
53
+ "svelte": "./dist/brushes/index.js",
54
+ "default": "./dist/brushes/index.js"
55
+ },
51
56
  "./commands": {
52
57
  "types": "./dist/commands/index.d.ts",
53
58
  "svelte": "./dist/commands/index.js",
@@ -170,7 +175,7 @@
170
175
  "**/*.css"
171
176
  ],
172
177
  "dependencies": {
173
- "@pixerate/editor": "0.254.0"
178
+ "@pixerate/editor": "0.255.0"
174
179
  },
175
180
  "peerDependencies": {
176
181
  "@dagrejs/dagre": "^1.1.4",