@pixerate/editor-svelte 0.135.0 → 0.137.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.
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ import { imageSectionTitleClass } from './styles.js';
3
+ import type { AlphaChannel, ImageEditorController } from '@pixerate/editor/image-editor';
4
+ import { MASK_FILE_ACCEPT, channelThumbnail, downloadMaskImage, openMaskFile, selectionModeFromModifiers } from '@pixerate/editor/image-editor';
5
+ import { untrack } from 'svelte';
6
+
7
+ interface Props {
8
+ controller: ImageEditorController;
9
+ /** Thumbnail size in CSS pixels (default 32). */
10
+ thumbnailSize?: number;
11
+ class?: string;
12
+ }
13
+
14
+ let { controller, thumbnailSize = 32, class: className = '' }: Props = $props();
15
+
16
+ let tick = $state(0);
17
+ $effect(() => {
18
+ const c = controller;
19
+ return untrack(() => c.subscribe(() => tick++));
20
+ });
21
+ const snapshot = $derived.by(() => {
22
+ void tick;
23
+ return {
24
+ channels: controller.getChannels(),
25
+ hasSelection: Boolean(controller.getSelection()),
26
+ quickMask: controller.quickMaskEditing,
27
+ };
28
+ });
29
+ let activeId = $state<string | null>(null);
30
+ let renaming = $state<string | null>(null);
31
+ let error = $state<string | null>(null);
32
+ let fileInput = $state<HTMLInputElement | null>(null);
33
+ const active = $derived(snapshot.channels.find((c) => c.id === activeId) ?? null);
34
+
35
+ function attempt(fn: () => unknown) {
36
+ error = null;
37
+ try {
38
+ fn();
39
+ } catch (err) {
40
+ error = err instanceof Error ? err.message : String(err);
41
+ }
42
+ }
43
+
44
+ async function importFile(file: File | undefined) {
45
+ if (!file) return;
46
+ error = null;
47
+ try {
48
+ activeId = (await openMaskFile(controller, file)).id;
49
+ } catch (err) {
50
+ error = err instanceof Error ? err.message : String(err);
51
+ }
52
+ }
53
+
54
+ /** Draws a channel's thumbnail (nothing without a 2D context). */
55
+ function thumb(canvas: HTMLCanvasElement, channel: AlphaChannel) {
56
+ const draw = (c: AlphaChannel) => {
57
+ const ctx = canvas.getContext('2d');
58
+ if (!ctx || typeof ctx.createImageData !== 'function') return;
59
+ const px = channelThumbnail(c, 64);
60
+ canvas.width = px.width;
61
+ canvas.height = px.height;
62
+ const img = ctx.createImageData(px.width, px.height);
63
+ img.data.set(px.data);
64
+ ctx.putImageData(img, 0, 0);
65
+ };
66
+ draw(channel);
67
+ return { update: draw };
68
+ }
69
+
70
+ const btn = (on = false) =>
71
+ `px-2 py-1 rounded-md border text-[11px] transition-colors disabled:opacity-40 ${
72
+ on ? 'bg-primary border-primary text-primary-foreground' : 'bg-background border-border text-foreground hover:bg-accent hover:text-accent-foreground'
73
+ }`;
74
+ </script>
75
+
76
+ <!--
77
+ The Channels panel: alpha channels (saved selections) with thumbnails, an eye to show each as a
78
+ tint, click to load it as the selection (Shift adds, Alt subtracts, both intersect), double-click
79
+ to rename; Save Selection, Add to Channel, Duplicate and Delete; Import (a mask image into a new
80
+ channel) and Export PNG (the active channel, else the selection); and the Quick Mask mode switch.
81
+ -->
82
+ <div data-testid="channels-panel" class="flex flex-col gap-2 text-xs {className}">
83
+ <div class="flex items-center justify-between">
84
+ <span class={imageSectionTitleClass}>Channels</span>
85
+ <button type="button" aria-pressed={snapshot.quickMask} class={btn(snapshot.quickMask)} title="Paint the selection: white selects, black deselects (Q)" onclick={() => controller.setQuickMaskMode(!snapshot.quickMask)}>Quick Mask</button>
86
+ </div>
87
+ {#if snapshot.channels.length === 0}
88
+ <p class="text-muted-foreground">No channels. Save a selection to keep it.</p>
89
+ {/if}
90
+ <ul role="list" class="flex flex-col gap-0.5">
91
+ {#each snapshot.channels as c (c.id)}
92
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
93
+ <li
94
+ data-testid="channel-row"
95
+ aria-current={c.id === activeId ? "true" : undefined}
96
+ class="flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none {c.id === activeId ? 'bg-primary/30 ring-1 ring-primary' : 'hover:bg-muted'}"
97
+ onclick={(e) => {
98
+ activeId = c.id;
99
+ attempt(() => controller.loadSelectionFromChannel(c.id, { mode: selectionModeFromModifiers(e, 'replace') }));
100
+ }}
101
+ ondblclick={() => (renaming = c.id)}
102
+ >
103
+ <button
104
+ type="button"
105
+ aria-label={`${c.visible ? 'Hide' : 'Show'} ${c.name}`}
106
+ aria-pressed={c.visible}
107
+ class="w-4 text-muted-foreground"
108
+ onclick={(e) => {
109
+ e.stopPropagation();
110
+ attempt(() => controller.setChannelProperties(c.id, { visible: !c.visible }));
111
+ }}>{c.visible ? '●' : '○'}</button>
112
+ <canvas use:thumb={c} aria-hidden="true" style="width: {thumbnailSize}px; height: {thumbnailSize}px" class="shrink-0 rounded object-contain ring-1 ring-border bg-black"></canvas>
113
+ {#if renaming === c.id}
114
+ <!-- svelte-ignore a11y_autofocus -->
115
+ <input
116
+ autofocus
117
+ aria-label="Channel name"
118
+ value={c.name}
119
+ class="min-w-0 flex-1 bg-background border border-border rounded px-1 py-0.5 text-foreground"
120
+ onclick={(e) => e.stopPropagation()}
121
+ onblur={(e) => {
122
+ const v = (e.currentTarget as HTMLInputElement).value;
123
+ renaming = null;
124
+ if (v.trim() && v !== c.name) attempt(() => controller.setChannelProperties(c.id, { name: v }));
125
+ }}
126
+ onkeydown={(e) => {
127
+ if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
128
+ if (e.key === 'Escape') renaming = null;
129
+ }}
130
+ />
131
+ {:else}
132
+ <span class="min-w-0 flex-1 truncate">{c.name}</span>
133
+ {/if}
134
+ </li>
135
+ {/each}
136
+ </ul>
137
+ <div class="flex flex-wrap gap-1">
138
+ <button type="button" class={btn()} disabled={!snapshot.hasSelection} onclick={() => attempt(() => (activeId = controller.saveSelection().id))}>Save Selection</button>
139
+ <button type="button" class={btn()} disabled={!active || !snapshot.hasSelection} title="Add the selection to the channel" onclick={() => attempt(() => controller.saveSelection({ channelId: active!.id, mode: 'add' }))}>Add to Channel</button>
140
+ <button type="button" class={btn()} disabled={!active} onclick={() => attempt(() => (activeId = controller.duplicateChannel(active!.id).id))}>Duplicate</button>
141
+ <button
142
+ type="button"
143
+ class={btn()}
144
+ disabled={!active}
145
+ onclick={() =>
146
+ attempt(() => {
147
+ controller.deleteChannel(active!.id);
148
+ activeId = null;
149
+ })}>Delete</button>
150
+ <button type="button" class={btn()} title="Load a mask image (white = selected) into a new channel" onclick={() => fileInput?.click()}>Import…</button>
151
+ <button
152
+ type="button"
153
+ class={btn()}
154
+ disabled={!active && !snapshot.hasSelection}
155
+ title={active ? 'Save the channel as a greyscale PNG' : 'Save the selection as a greyscale PNG'}
156
+ onclick={() => attempt(() => downloadMaskImage(controller, active ? { channelId: active.id } : 'selection', active?.name ?? 'selection'))}>Export PNG</button>
157
+ <input
158
+ bind:this={fileInput}
159
+ type="file"
160
+ accept={MASK_FILE_ACCEPT}
161
+ hidden
162
+ data-testid="channel-import-input"
163
+ onchange={(e) => {
164
+ const input = e.currentTarget as HTMLInputElement;
165
+ void importFile(input.files?.[0]);
166
+ input.value = '';
167
+ }}
168
+ />
169
+ </div>
170
+ {#if error}
171
+ <p role="alert" class="text-destructive">{error}</p>
172
+ {/if}
173
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { ImageEditorController } from '@pixerate/editor/image-editor';
2
+ interface Props {
3
+ controller: ImageEditorController;
4
+ /** Thumbnail size in CSS pixels (default 32). */
5
+ thumbnailSize?: number;
6
+ class?: string;
7
+ }
8
+ declare const ChannelsPanel: import("svelte").Component<Props, {}, "">;
9
+ type ChannelsPanel = ReturnType<typeof ChannelsPanel>;
10
+ export default ChannelsPanel;
@@ -34,6 +34,7 @@
34
34
  import BrushOptionsBar from './BrushOptionsBar.svelte';
35
35
  import SelectionOptionsBar from './SelectionOptionsBar.svelte';
36
36
  import RefineEdgePanel from './RefineEdgePanel.svelte';
37
+ import ChannelsPanel from './ChannelsPanel.svelte';
37
38
  import FreeTransformOptionsBar from './FreeTransformOptionsBar.svelte';
38
39
  import ShapeOptionsBar from './ShapeOptionsBar.svelte';
39
40
  import TypeOptionsBar from './TypeOptionsBar.svelte';
@@ -978,6 +979,7 @@
978
979
  selection={editor.state.selection ?? null}
979
980
  layers={editor.state.adjustments.layers}
980
981
  />
982
+ <ChannelsPanel controller={editor.controller} />
981
983
  </div>
982
984
  {/if}
983
985
 
@@ -24,6 +24,7 @@
24
24
  watchCanvasViewport,
25
25
  startMarchingAnts,
26
26
  handleSelectionShortcut,
27
+ handleQuickMaskKey,
27
28
  ShapeDrawGesture,
28
29
  TypeToolGesture,
29
30
  DEFAULT_SHAPE_TOOL_OPTIONS,
@@ -604,6 +605,10 @@
604
605
  if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
605
606
  // Leave Copy to the page while text on it is selected.
606
607
  if (window.getSelection?.()?.toString()) return;
608
+ if (handleQuickMaskKey(editor.controller, e)) {
609
+ e.preventDefault();
610
+ return;
611
+ }
607
612
  if (handleSelectionShortcut(editor.controller, e, (message) => report('select', message))) {
608
613
  e.preventDefault();
609
614
  return;
@@ -8,6 +8,7 @@ import type CurvesEditorComponent from "./CurvesEditor.svelte";
8
8
  import type ControlSliderComponent from "./ControlSlider.svelte";
9
9
  import type SelectionOptionsBarComponent from "./SelectionOptionsBar.svelte";
10
10
  import type RefineEdgePanelComponent from "./RefineEdgePanel.svelte";
11
+ import type ChannelsPanelComponent from "./ChannelsPanel.svelte";
11
12
  import type LayersPanelComponent from "./LayersPanel.svelte";
12
13
  import type ColorProfilePanelComponent from "./ColorProfilePanel.svelte";
13
14
  import type LayerEffectsPanelComponent from "./LayerEffectsPanel.svelte";
@@ -31,6 +32,7 @@ export { default as CurvesEditor } from './CurvesEditor.svelte';
31
32
  export { default as ControlSlider } from './ControlSlider.svelte';
32
33
  export { default as SelectionOptionsBar } from './SelectionOptionsBar.svelte';
33
34
  export { default as RefineEdgePanel } from './RefineEdgePanel.svelte';
35
+ export { default as ChannelsPanel } from './ChannelsPanel.svelte';
34
36
  export { default as LayersPanel } from './LayersPanel.svelte';
35
37
  export { default as ColorProfilePanel } from './ColorProfilePanel.svelte';
36
38
  export { default as LayerEffectsPanel } from './LayerEffectsPanel.svelte';
@@ -56,6 +58,7 @@ export type CurvesEditorProps = ComponentProps<typeof CurvesEditorComponent>;
56
58
  export type ControlSliderProps = ComponentProps<typeof ControlSliderComponent>;
57
59
  export type SelectionOptionsBarProps = ComponentProps<typeof SelectionOptionsBarComponent>;
58
60
  export type RefineEdgePanelProps = ComponentProps<typeof RefineEdgePanelComponent>;
61
+ export type ChannelsPanelProps = ComponentProps<typeof ChannelsPanelComponent>;
59
62
  export type LayersPanelProps = ComponentProps<typeof LayersPanelComponent>;
60
63
  export type ColorProfilePanelProps = ComponentProps<typeof ColorProfilePanelComponent>;
61
64
  export type LayerEffectsPanelProps = ComponentProps<typeof LayerEffectsPanelComponent>;
@@ -10,6 +10,7 @@ export { default as CurvesEditor } from './CurvesEditor.svelte';
10
10
  export { default as ControlSlider } from './ControlSlider.svelte';
11
11
  export { default as SelectionOptionsBar } from './SelectionOptionsBar.svelte';
12
12
  export { default as RefineEdgePanel } from './RefineEdgePanel.svelte';
13
+ export { default as ChannelsPanel } from './ChannelsPanel.svelte';
13
14
  export { default as LayersPanel } from './LayersPanel.svelte';
14
15
  export { default as ColorProfilePanel } from './ColorProfilePanel.svelte';
15
16
  export { default as LayerEffectsPanel } from './LayerEffectsPanel.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.135.0",
3
+ "version": "0.137.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.185.0"
168
+ "@pixerate/editor": "0.187.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",