@pixerate/editor-svelte 0.133.3 → 0.136.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/image-editor/AdjustmentLayersPanel.svelte +1 -1
- package/dist/image-editor/ChannelsPanel.svelte +142 -0
- package/dist/image-editor/ChannelsPanel.svelte.d.ts +10 -0
- package/dist/image-editor/ImageEditor.svelte +3 -0
- package/dist/image-editor/ImageEditorCanvas.svelte +35 -3
- package/dist/image-editor/ImageProviderEffectsPanel.svelte +1 -1
- package/dist/image-editor/LayersPanel.svelte +10 -2
- package/dist/image-editor/SelectionOptionsBar.svelte +182 -7
- package/dist/image-editor/SelectionOptionsBar.svelte.d.ts +3 -1
- package/dist/image-editor/index.d.ts +3 -0
- package/dist/image-editor/index.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { imageSectionTitleClass } from './styles.js';
|
|
3
|
+
import type { AlphaChannel, ImageEditorController } from '@pixerate/editor/image-editor';
|
|
4
|
+
import { channelThumbnail, 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
|
+
const active = $derived(snapshot.channels.find((c) => c.id === activeId) ?? null);
|
|
33
|
+
|
|
34
|
+
function attempt(fn: () => unknown) {
|
|
35
|
+
error = null;
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
error = err instanceof Error ? err.message : String(err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Draws a channel's thumbnail (nothing without a 2D context). */
|
|
44
|
+
function thumb(canvas: HTMLCanvasElement, channel: AlphaChannel) {
|
|
45
|
+
const draw = (c: AlphaChannel) => {
|
|
46
|
+
const ctx = canvas.getContext('2d');
|
|
47
|
+
if (!ctx || typeof ctx.createImageData !== 'function') return;
|
|
48
|
+
const px = channelThumbnail(c, 64);
|
|
49
|
+
canvas.width = px.width;
|
|
50
|
+
canvas.height = px.height;
|
|
51
|
+
const img = ctx.createImageData(px.width, px.height);
|
|
52
|
+
img.data.set(px.data);
|
|
53
|
+
ctx.putImageData(img, 0, 0);
|
|
54
|
+
};
|
|
55
|
+
draw(channel);
|
|
56
|
+
return { update: draw };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const btn = (on = false) =>
|
|
60
|
+
`px-2 py-1 rounded-md border text-[11px] transition-colors disabled:opacity-40 ${
|
|
61
|
+
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-background border-border text-foreground hover:bg-accent hover:text-accent-foreground'
|
|
62
|
+
}`;
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<!--
|
|
66
|
+
The Channels panel: alpha channels (saved selections) with thumbnails, an eye to show each as a
|
|
67
|
+
tint, click to load it as the selection (Shift adds, Alt subtracts, both intersect), double-click
|
|
68
|
+
to rename; Save Selection, Add to Channel, Duplicate and Delete; and the Quick Mask mode switch.
|
|
69
|
+
-->
|
|
70
|
+
<div data-testid="channels-panel" class="flex flex-col gap-2 text-xs {className}">
|
|
71
|
+
<div class="flex items-center justify-between">
|
|
72
|
+
<span class={imageSectionTitleClass}>Channels</span>
|
|
73
|
+
<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>
|
|
74
|
+
</div>
|
|
75
|
+
{#if snapshot.channels.length === 0}
|
|
76
|
+
<p class="text-muted-foreground">No channels. Save a selection to keep it.</p>
|
|
77
|
+
{/if}
|
|
78
|
+
<ul role="list" class="flex flex-col gap-0.5">
|
|
79
|
+
{#each snapshot.channels as c (c.id)}
|
|
80
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
|
81
|
+
<li
|
|
82
|
+
data-testid="channel-row"
|
|
83
|
+
aria-current={c.id === activeId ? "true" : undefined}
|
|
84
|
+
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'}"
|
|
85
|
+
onclick={(e) => {
|
|
86
|
+
activeId = c.id;
|
|
87
|
+
attempt(() => controller.loadSelectionFromChannel(c.id, { mode: selectionModeFromModifiers(e, 'replace') }));
|
|
88
|
+
}}
|
|
89
|
+
ondblclick={() => (renaming = c.id)}
|
|
90
|
+
>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
aria-label={`${c.visible ? 'Hide' : 'Show'} ${c.name}`}
|
|
94
|
+
aria-pressed={c.visible}
|
|
95
|
+
class="w-4 text-muted-foreground"
|
|
96
|
+
onclick={(e) => {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
attempt(() => controller.setChannelProperties(c.id, { visible: !c.visible }));
|
|
99
|
+
}}>{c.visible ? '●' : '○'}</button>
|
|
100
|
+
<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>
|
|
101
|
+
{#if renaming === c.id}
|
|
102
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
103
|
+
<input
|
|
104
|
+
autofocus
|
|
105
|
+
aria-label="Channel name"
|
|
106
|
+
value={c.name}
|
|
107
|
+
class="min-w-0 flex-1 bg-background border border-border rounded px-1 py-0.5 text-foreground"
|
|
108
|
+
onclick={(e) => e.stopPropagation()}
|
|
109
|
+
onblur={(e) => {
|
|
110
|
+
const v = (e.currentTarget as HTMLInputElement).value;
|
|
111
|
+
renaming = null;
|
|
112
|
+
if (v.trim() && v !== c.name) attempt(() => controller.setChannelProperties(c.id, { name: v }));
|
|
113
|
+
}}
|
|
114
|
+
onkeydown={(e) => {
|
|
115
|
+
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
|
|
116
|
+
if (e.key === 'Escape') renaming = null;
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
{:else}
|
|
120
|
+
<span class="min-w-0 flex-1 truncate">{c.name}</span>
|
|
121
|
+
{/if}
|
|
122
|
+
</li>
|
|
123
|
+
{/each}
|
|
124
|
+
</ul>
|
|
125
|
+
<div class="flex flex-wrap gap-1">
|
|
126
|
+
<button type="button" class={btn()} disabled={!snapshot.hasSelection} onclick={() => attempt(() => (activeId = controller.saveSelection().id))}>Save Selection</button>
|
|
127
|
+
<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>
|
|
128
|
+
<button type="button" class={btn()} disabled={!active} onclick={() => attempt(() => (activeId = controller.duplicateChannel(active!.id).id))}>Duplicate</button>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
class={btn()}
|
|
132
|
+
disabled={!active}
|
|
133
|
+
onclick={() =>
|
|
134
|
+
attempt(() => {
|
|
135
|
+
controller.deleteChannel(active!.id);
|
|
136
|
+
activeId = null;
|
|
137
|
+
})}>Delete</button>
|
|
138
|
+
</div>
|
|
139
|
+
{#if error}
|
|
140
|
+
<p role="alert" class="text-destructive">{error}</p>
|
|
141
|
+
{/if}
|
|
142
|
+
</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';
|
|
@@ -954,6 +955,7 @@
|
|
|
954
955
|
onOptionsChange={(next) => (selectionOptions = next)}
|
|
955
956
|
selection={editor.state.selection ?? null}
|
|
956
957
|
quickMask={Boolean(editor.state.quickMask)}
|
|
958
|
+
selectionDisplay={editor.state.selectionDisplay}
|
|
957
959
|
/>
|
|
958
960
|
{#if selectionError}
|
|
959
961
|
<p role="alert" class="text-destructive">{selectionError}</p>
|
|
@@ -977,6 +979,7 @@
|
|
|
977
979
|
selection={editor.state.selection ?? null}
|
|
978
980
|
layers={editor.state.adjustments.layers}
|
|
979
981
|
/>
|
|
982
|
+
<ChannelsPanel controller={editor.controller} />
|
|
980
983
|
</div>
|
|
981
984
|
{/if}
|
|
982
985
|
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
measureCanvasViewport,
|
|
24
24
|
watchCanvasViewport,
|
|
25
25
|
startMarchingAnts,
|
|
26
|
+
handleSelectionShortcut,
|
|
27
|
+
handleQuickMaskKey,
|
|
26
28
|
ShapeDrawGesture,
|
|
27
29
|
TypeToolGesture,
|
|
28
30
|
DEFAULT_SHAPE_TOOL_OPTIONS,
|
|
@@ -594,6 +596,32 @@
|
|
|
594
596
|
if (paintGesture.error) report('paint', paintGesture.error);
|
|
595
597
|
}
|
|
596
598
|
|
|
599
|
+
$effect(() => {
|
|
600
|
+
if (mode !== 'select' || typeof window === 'undefined') return;
|
|
601
|
+
const gesture = selectionGesture;
|
|
602
|
+
// The polygonal lasso: Enter closes it, Escape cancels, Backspace removes the last corner.
|
|
603
|
+
const onKey = (e: KeyboardEvent) => {
|
|
604
|
+
const target = e.target as HTMLElement | null;
|
|
605
|
+
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
|
606
|
+
// Leave Copy to the page while text on it is selected.
|
|
607
|
+
if (window.getSelection?.()?.toString()) return;
|
|
608
|
+
if (handleQuickMaskKey(editor.controller, e)) {
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (handleSelectionShortcut(editor.controller, e, (message) => report('select', message))) {
|
|
613
|
+
e.preventDefault();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (gesture.key(e.key)) {
|
|
617
|
+
e.preventDefault();
|
|
618
|
+
selectionDraft = gesture.draftPath;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
window.addEventListener('keydown', onKey);
|
|
622
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
623
|
+
});
|
|
624
|
+
|
|
597
625
|
$effect(() => {
|
|
598
626
|
if (mode !== 'paint' || typeof window === 'undefined') return;
|
|
599
627
|
const c = editor.controller;
|
|
@@ -663,7 +691,10 @@
|
|
|
663
691
|
if (mode === 'select') {
|
|
664
692
|
const gesture = selectionGesture;
|
|
665
693
|
const imgPt = canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false);
|
|
666
|
-
void gesture.begin(imgPt, e).then(() =>
|
|
694
|
+
void gesture.begin(imgPt, e).then(() => {
|
|
695
|
+
report('select', gesture.error);
|
|
696
|
+
selectionDraft = gesture.draftPath;
|
|
697
|
+
});
|
|
667
698
|
return;
|
|
668
699
|
}
|
|
669
700
|
|
|
@@ -771,7 +802,7 @@
|
|
|
771
802
|
|
|
772
803
|
if (mode === 'select') {
|
|
773
804
|
const gesture = selectionGesture;
|
|
774
|
-
if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false))) {
|
|
805
|
+
if (gesture.active && gesture.move(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false), e)) {
|
|
775
806
|
selectionDraft = gesture.draftPath;
|
|
776
807
|
renderCanvas();
|
|
777
808
|
}
|
|
@@ -924,7 +955,8 @@
|
|
|
924
955
|
const gesture = selectionGesture;
|
|
925
956
|
if (gesture.active) {
|
|
926
957
|
gesture.end(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false), e);
|
|
927
|
-
|
|
958
|
+
// A polygonal lasso stays open between clicks.
|
|
959
|
+
selectionDraft = gesture.draftPath;
|
|
928
960
|
renderCanvas();
|
|
929
961
|
}
|
|
930
962
|
return;
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
</div>
|
|
163
163
|
{#if browse === 'filter'}
|
|
164
164
|
{@const id = layer.id}
|
|
165
|
-
<EffectsBrowser {provider} uses={['filter']} title="Filters" onpick={(e) => ((browse = null), run(() => controller.applyEffectFilter(id, e.id)))} />
|
|
165
|
+
<EffectsBrowser {provider} uses={['filter']} title="Filters" onpick={(e) => ((browse = null), run(() => controller.applyEffectFilter(id, e.id, { useSelection: true })))} />
|
|
166
166
|
{/if}
|
|
167
167
|
{/if}
|
|
168
168
|
{#if layer && all.some((f) => f.kind === 'effect')}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
downloadPsd,
|
|
9
9
|
dropPlacement,
|
|
10
10
|
dropZone,
|
|
11
|
+
maskThumbnailClick,
|
|
12
|
+
layerThumbnailClick,
|
|
11
13
|
layerBadge,
|
|
12
14
|
layerEffectsBadge,
|
|
13
15
|
layerHasThumbnail,
|
|
@@ -333,6 +335,13 @@
|
|
|
333
335
|
<canvas
|
|
334
336
|
use:paint={thumb(layer)}
|
|
335
337
|
aria-label={`${row.label} pixels`}
|
|
338
|
+
data-testid="layer-thumbnail"
|
|
339
|
+
onclick={(e) => {
|
|
340
|
+
if (e.ctrlKey || e.metaKey) {
|
|
341
|
+
e.stopPropagation();
|
|
342
|
+
attempt(() => layerThumbnailClick(controller, layer, e));
|
|
343
|
+
}
|
|
344
|
+
}}
|
|
336
345
|
style="width: {thumbnailSize}px; height: {thumbnailSize}px"
|
|
337
346
|
class={thumbClass(row.active && snapshot.activeTarget === 'pixels' && Boolean(layer.mask))}
|
|
338
347
|
></canvas>
|
|
@@ -371,8 +380,7 @@
|
|
|
371
380
|
class={thumbClass(row.active && snapshot.activeTarget === 'mask')}
|
|
372
381
|
onclick={(e) => {
|
|
373
382
|
e.stopPropagation();
|
|
374
|
-
|
|
375
|
-
else attempt(() => controller.setActiveLayer(layer.id, 'mask'));
|
|
383
|
+
attempt(() => maskThumbnailClick(controller, layer, e));
|
|
376
384
|
}}
|
|
377
385
|
></canvas>
|
|
378
386
|
{/if}
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
import ImageRange from './controls/ImageRange.svelte';
|
|
4
4
|
import ImageSelect from './controls/ImageSelect.svelte';
|
|
5
5
|
import type {
|
|
6
|
+
ColorRangePreset,
|
|
6
7
|
ImageEditorController,
|
|
8
|
+
SelectionDisplay,
|
|
7
9
|
SelectionMask,
|
|
8
10
|
SelectionModifyOp,
|
|
9
11
|
SelectionToolOptions,
|
|
10
12
|
} from '@pixerate/editor/image-editor';
|
|
11
13
|
import {
|
|
14
|
+
COLOR_RANGE_PRESETS,
|
|
15
|
+
COLOR_RANGE_PRESET_LABELS,
|
|
12
16
|
SELECTION_MODES,
|
|
13
17
|
SELECTION_MODE_LABELS,
|
|
14
18
|
SELECTION_TOOLS,
|
|
@@ -24,10 +28,12 @@
|
|
|
24
28
|
selection?: SelectionMask | null;
|
|
25
29
|
/** The Quick Mask view flag (`state.quickMask`). Omit to follow the controller. */
|
|
26
30
|
quickMask?: boolean;
|
|
31
|
+
/** How the canvas shows the selection (`state.selectionDisplay`); wins over `quickMask`. */
|
|
32
|
+
selectionDisplay?: SelectionDisplay;
|
|
27
33
|
class?: string;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
let { controller, options, onOptionsChange, selection, quickMask, class: className = '' }: Props = $props();
|
|
36
|
+
let { controller, options, onOptionsChange, selection, quickMask, selectionDisplay, class: className = '' }: Props = $props();
|
|
31
37
|
|
|
32
38
|
let tick = $state(0);
|
|
33
39
|
$effect(() => {
|
|
@@ -38,10 +44,60 @@
|
|
|
38
44
|
void tick;
|
|
39
45
|
return selection === undefined ? controller.getSelection() : selection;
|
|
40
46
|
});
|
|
41
|
-
const
|
|
47
|
+
const display = $derived.by((): SelectionDisplay => {
|
|
42
48
|
void tick;
|
|
43
|
-
return
|
|
49
|
+
return selectionDisplay ?? (quickMask === undefined ? controller.getSelectionDisplay() : quickMask ? 'quickMask' : 'ants');
|
|
44
50
|
});
|
|
51
|
+
const DISPLAYS: { id: SelectionDisplay; label: string }[] = [
|
|
52
|
+
{ id: 'ants', label: 'Ants' },
|
|
53
|
+
{ id: 'quickMask', label: 'Quick Mask' },
|
|
54
|
+
{ id: 'none', label: 'Hidden' },
|
|
55
|
+
];
|
|
56
|
+
const numberInput = 'w-14 bg-background border border-border rounded px-1 py-0.5 text-foreground';
|
|
57
|
+
|
|
58
|
+
// Color Range presets.
|
|
59
|
+
let preset = $state<ColorRangePreset>('reds');
|
|
60
|
+
let rangeStatus = $state<string | null>(null);
|
|
61
|
+
const canReselect = $derived.by(() => {
|
|
62
|
+
void tick;
|
|
63
|
+
void sel;
|
|
64
|
+
return controller.canReselect();
|
|
65
|
+
});
|
|
66
|
+
let colorStatus = $state<string | null>(null);
|
|
67
|
+
let strokeWidth = $state(4);
|
|
68
|
+
let strokeLocation = $state<'inside' | 'center' | 'outside'>('outside');
|
|
69
|
+
function edit(fn: () => unknown) {
|
|
70
|
+
colorStatus = null;
|
|
71
|
+
try {
|
|
72
|
+
fn();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
colorStatus = err instanceof Error ? err.message : String(err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function runColor(op: 'grow' | 'similar') {
|
|
78
|
+
colorStatus = null;
|
|
79
|
+
const opts = { tolerance: options.tolerance, sampleMerged: options.sampleMerged, sample: options.sample };
|
|
80
|
+
try {
|
|
81
|
+
await (op === 'grow' ? controller.growSelection(opts) : controller.selectSimilar(opts));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
colorStatus = err instanceof Error ? err.message : String(err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function applyPreset() {
|
|
87
|
+
rangeStatus = null;
|
|
88
|
+
try {
|
|
89
|
+
await controller.selectColorRangePreset(preset, {
|
|
90
|
+
mode: options.mode,
|
|
91
|
+
feather: options.feather ?? 0,
|
|
92
|
+
invert: options.rangeInvert ?? false,
|
|
93
|
+
falloff: options.fuzziness ?? 20,
|
|
94
|
+
sampleMerged: options.sampleMerged,
|
|
95
|
+
sample: options.sample,
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
rangeStatus = err instanceof Error ? err.message : String(err);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
45
101
|
|
|
46
102
|
let radius = $state(2);
|
|
47
103
|
|
|
@@ -69,8 +125,8 @@
|
|
|
69
125
|
}
|
|
70
126
|
|
|
71
127
|
const MODIFY_OPS: { op: SelectionModifyOp; label: string }[] = [
|
|
72
|
-
{ op: '
|
|
73
|
-
{ op: '
|
|
128
|
+
{ op: 'expand', label: 'Expand' },
|
|
129
|
+
{ op: 'contract', label: 'Contract' },
|
|
74
130
|
{ op: 'feather', label: 'Feather' },
|
|
75
131
|
{ op: 'border', label: 'Border' },
|
|
76
132
|
{ op: 'smooth', label: 'Smooth' },
|
|
@@ -114,6 +170,18 @@
|
|
|
114
170
|
<ImageCheckbox label="Contiguous" checked={options.contiguous} onchange={(on) => set({ contiguous: on })} />
|
|
115
171
|
Contiguous
|
|
116
172
|
</label>
|
|
173
|
+
<label class="flex items-center gap-2 text-foreground">
|
|
174
|
+
Distance
|
|
175
|
+
<ImageSelect
|
|
176
|
+
label="Colour distance"
|
|
177
|
+
value={options.distance}
|
|
178
|
+
options={[
|
|
179
|
+
{ value: 'rgb', label: 'RGB' },
|
|
180
|
+
{ value: 'perceptual', label: 'Perceptual' },
|
|
181
|
+
]}
|
|
182
|
+
onchange={(v) => set({ distance: v as 'rgb' | 'perceptual' })}
|
|
183
|
+
/>
|
|
184
|
+
</label>
|
|
117
185
|
<label class="flex items-center gap-2 text-foreground">
|
|
118
186
|
<ImageCheckbox label="Sample adjusted image" checked={options.sampleMerged} onchange={(on) => set({ sampleMerged: on })} />
|
|
119
187
|
Sample adjusted image
|
|
@@ -132,6 +200,64 @@
|
|
|
132
200
|
</label>
|
|
133
201
|
</div>
|
|
134
202
|
{/if}
|
|
203
|
+
{#if options.tool === 'polygon'}
|
|
204
|
+
<p class="text-muted-foreground">Click to add corners (Shift for 45°), drag for a free-hand stretch; double-click, Enter or a click on the first corner closes it, Backspace removes a corner, Escape cancels.</p>
|
|
205
|
+
{/if}
|
|
206
|
+
{#if options.tool === 'range'}
|
|
207
|
+
<div data-testid="color-range-options" class="flex flex-col gap-2">
|
|
208
|
+
<p class="text-muted-foreground">Click a colour to select it; Shift-click adds a colour, Alt-click removes one.</p>
|
|
209
|
+
<label class="flex flex-col gap-1">
|
|
210
|
+
<span class="flex justify-between text-muted-foreground">
|
|
211
|
+
<span>Fuzziness</span>
|
|
212
|
+
<span class="font-mono">{options.fuzziness ?? 40}</span>
|
|
213
|
+
</span>
|
|
214
|
+
<ImageRange label="Fuzziness" min={0} max={200} step={1} value={options.fuzziness ?? 40} oninput={(v) => set({ fuzziness: v })} />
|
|
215
|
+
</label>
|
|
216
|
+
<label class="flex items-center gap-1 text-muted-foreground">
|
|
217
|
+
Localized radius
|
|
218
|
+
<input
|
|
219
|
+
type="number"
|
|
220
|
+
min="0"
|
|
221
|
+
step="1"
|
|
222
|
+
aria-label="Localized radius"
|
|
223
|
+
value={options.localized ?? 0}
|
|
224
|
+
oninput={(e) => set({ localized: Math.max(0, Number((e.currentTarget as HTMLInputElement).value) || 0) })}
|
|
225
|
+
class={numberInput}
|
|
226
|
+
/>
|
|
227
|
+
px
|
|
228
|
+
</label>
|
|
229
|
+
<label class="flex items-center gap-2 text-foreground">
|
|
230
|
+
<ImageCheckbox label="Invert" checked={options.rangeInvert ?? false} onchange={(on) => set({ rangeInvert: on })} />
|
|
231
|
+
Invert
|
|
232
|
+
</label>
|
|
233
|
+
<div class="flex items-center gap-1">
|
|
234
|
+
<ImageSelect
|
|
235
|
+
label="Color Range preset"
|
|
236
|
+
value={preset}
|
|
237
|
+
options={COLOR_RANGE_PRESETS.map((p) => ({ value: p, label: COLOR_RANGE_PRESET_LABELS[p] }))}
|
|
238
|
+
onchange={(v) => (preset = v as ColorRangePreset)}
|
|
239
|
+
/>
|
|
240
|
+
<button type="button" class={btn(false)} onclick={() => void applyPreset()}>Select</button>
|
|
241
|
+
</div>
|
|
242
|
+
{#if rangeStatus}
|
|
243
|
+
<p role="status" class="text-muted-foreground">{rangeStatus}</p>
|
|
244
|
+
{/if}
|
|
245
|
+
</div>
|
|
246
|
+
{/if}
|
|
247
|
+
<label class="flex items-center gap-1 text-muted-foreground">
|
|
248
|
+
Feather
|
|
249
|
+
<input
|
|
250
|
+
type="number"
|
|
251
|
+
min="0"
|
|
252
|
+
max="1000"
|
|
253
|
+
step="0.5"
|
|
254
|
+
aria-label="Feather"
|
|
255
|
+
value={options.feather ?? 0}
|
|
256
|
+
oninput={(e) => set({ feather: Math.max(0, Number((e.currentTarget as HTMLInputElement).value) || 0) })}
|
|
257
|
+
class={numberInput}
|
|
258
|
+
/>
|
|
259
|
+
px
|
|
260
|
+
</label>
|
|
135
261
|
{#if canSegment}
|
|
136
262
|
<form
|
|
137
263
|
data-testid="select-subject"
|
|
@@ -167,7 +293,15 @@
|
|
|
167
293
|
<button type="button" class={btn(false)} onclick={() => controller.selectAll()}>Select All</button>
|
|
168
294
|
<button type="button" class={btn(false)} disabled={!sel} onclick={() => controller.deselect()}>Deselect</button>
|
|
169
295
|
<button type="button" class={btn(false)} onclick={() => controller.invertSelection()}>Inverse</button>
|
|
170
|
-
<button type="button"
|
|
296
|
+
<button type="button" class={btn(false)} disabled={!canReselect} onclick={() => controller.reselect()}>Reselect</button>
|
|
297
|
+
<button type="button" class={btn(false)} disabled={!sel} title="Add connected pixels of a selected colour (wand tolerance)" onclick={() => void runColor('grow')}>Grow</button>
|
|
298
|
+
<button type="button" class={btn(false)} disabled={!sel} title="Add every pixel of a selected colour (wand tolerance)" onclick={() => void runColor('similar')}>Similar</button>
|
|
299
|
+
</div>
|
|
300
|
+
<div role="group" aria-label="Show selection" class="flex flex-wrap items-center gap-1">
|
|
301
|
+
<span class="text-muted-foreground">Show</span>
|
|
302
|
+
{#each DISPLAYS as d (d.id)}
|
|
303
|
+
<button type="button" aria-pressed={display === d.id} class={btn(display === d.id)} onclick={() => controller.setSelectionDisplay(d.id)}>{d.label}</button>
|
|
304
|
+
{/each}
|
|
171
305
|
</div>
|
|
172
306
|
<div class="flex flex-wrap items-center gap-1">
|
|
173
307
|
<label class="flex items-center gap-1 text-muted-foreground">
|
|
@@ -180,12 +314,53 @@
|
|
|
180
314
|
aria-label="Modify radius"
|
|
181
315
|
value={radius}
|
|
182
316
|
oninput={(e) => (radius = Math.max(0, Number((e.currentTarget as HTMLInputElement).value) || 0))}
|
|
183
|
-
class=
|
|
317
|
+
class={numberInput}
|
|
184
318
|
/>
|
|
185
319
|
</label>
|
|
186
320
|
{#each MODIFY_OPS as m (m.op)}
|
|
187
321
|
<button type="button" class={btn(false)} disabled={!sel} onclick={() => controller.modifySelection(m.op, radius)}>{m.label}</button>
|
|
188
322
|
{/each}
|
|
189
323
|
</div>
|
|
324
|
+
<div role="group" aria-label="Edit selection" class="flex flex-wrap items-center gap-1">
|
|
325
|
+
<label class="flex items-center gap-1 text-muted-foreground">
|
|
326
|
+
Stroke
|
|
327
|
+
<input
|
|
328
|
+
type="number"
|
|
329
|
+
min="1"
|
|
330
|
+
max="250"
|
|
331
|
+
step="1"
|
|
332
|
+
aria-label="Stroke width"
|
|
333
|
+
value={strokeWidth}
|
|
334
|
+
oninput={(e) => (strokeWidth = Math.min(250, Math.max(1, Number((e.currentTarget as HTMLInputElement).value) || 1)))}
|
|
335
|
+
class={numberInput}
|
|
336
|
+
/>
|
|
337
|
+
px
|
|
338
|
+
</label>
|
|
339
|
+
<ImageSelect
|
|
340
|
+
label="Stroke location"
|
|
341
|
+
value={strokeLocation}
|
|
342
|
+
options={[
|
|
343
|
+
{ value: 'outside', label: 'Outside' },
|
|
344
|
+
{ value: 'center', label: 'Center' },
|
|
345
|
+
{ value: 'inside', label: 'Inside' },
|
|
346
|
+
]}
|
|
347
|
+
onchange={(v) => (strokeLocation = v as 'inside' | 'center' | 'outside')}
|
|
348
|
+
/>
|
|
349
|
+
<button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.strokeSelection({ width: strokeWidth, location: strokeLocation }))}>Stroke</button>
|
|
350
|
+
<button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.cropToSelection())}>Crop to Selection</button>
|
|
351
|
+
<button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.contentAwareFill({ output: 'new-layer' }))}>Content-Aware Fill</button>
|
|
352
|
+
</div>
|
|
353
|
+
<div role="group" aria-label="Clipboard" class="flex flex-wrap gap-1">
|
|
354
|
+
<button type="button" class={btn(false)} onclick={() => edit(() => controller.copyPixels())}>Copy</button>
|
|
355
|
+
<button type="button" class={btn(false)} onclick={() => edit(() => controller.copyPixels({ merged: true }))}>Copy Merged</button>
|
|
356
|
+
<button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.cutPixels())}>Cut</button>
|
|
357
|
+
<button type="button" class={btn(false)} onclick={() => edit(() => controller.pastePixels('new-layer'))}>Paste</button>
|
|
358
|
+
<button type="button" class={btn(false)} onclick={() => edit(() => controller.pastePixels('in-place'))}>Paste in Place</button>
|
|
359
|
+
<button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.pastePixels('into'))}>Paste Into</button>
|
|
360
|
+
<button type="button" class={btn(false)} disabled={!sel} onclick={() => edit(() => controller.pastePixels('outside'))}>Paste Outside</button>
|
|
361
|
+
</div>
|
|
362
|
+
{#if colorStatus}
|
|
363
|
+
<p role="status" class="text-muted-foreground">{colorStatus}</p>
|
|
364
|
+
{/if}
|
|
190
365
|
<p data-testid="selection-status" class="text-muted-foreground">{describeSelection(sel)}</p>
|
|
191
366
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ImageEditorController, SelectionMask, SelectionToolOptions } from '@pixerate/editor/image-editor';
|
|
1
|
+
import type { ImageEditorController, SelectionDisplay, SelectionMask, SelectionToolOptions } from '@pixerate/editor/image-editor';
|
|
2
2
|
interface Props {
|
|
3
3
|
controller: ImageEditorController;
|
|
4
4
|
options: SelectionToolOptions;
|
|
@@ -7,6 +7,8 @@ interface Props {
|
|
|
7
7
|
selection?: SelectionMask | null;
|
|
8
8
|
/** The Quick Mask view flag (`state.quickMask`). Omit to follow the controller. */
|
|
9
9
|
quickMask?: boolean;
|
|
10
|
+
/** How the canvas shows the selection (`state.selectionDisplay`); wins over `quickMask`. */
|
|
11
|
+
selectionDisplay?: SelectionDisplay;
|
|
10
12
|
class?: string;
|
|
11
13
|
}
|
|
12
14
|
declare const SelectionOptionsBar: import("svelte").Component<Props, {}, "">;
|
|
@@ -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.
|
|
3
|
+
"version": "0.136.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.186.0"
|
|
169
169
|
},
|
|
170
170
|
"peerDependencies": {
|
|
171
171
|
"@dagrejs/dagre": "^1.1.4",
|