@pixerate/editor-svelte 0.108.0 → 0.109.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/canvas/createCanvasPlaybackSurface.svelte.d.ts +8 -2
- package/dist/canvas/createCanvasPlaybackSurface.svelte.js +13 -8
- package/dist/image-editor/AdjustmentLayersPanel.svelte +27 -26
- package/dist/image-editor/AdjustmentLayersPanel.svelte.d.ts +2 -0
- package/dist/image-editor/BrushOptionsBar.svelte +35 -34
- package/dist/image-editor/ColorProfilePanel.svelte +12 -12
- package/dist/image-editor/ControlSlider.svelte +6 -4
- package/dist/image-editor/ControlSlider.svelte.d.ts +2 -0
- package/dist/image-editor/CurvesEditor.svelte +6 -4
- package/dist/image-editor/CurvesEditor.svelte.d.ts +2 -0
- package/dist/image-editor/FreeTransformOptionsBar.svelte +22 -21
- package/dist/image-editor/Histogram.svelte +1 -1
- package/dist/image-editor/ImageEditor.svelte +127 -142
- package/dist/image-editor/ImageEffectControls.svelte +15 -11
- package/dist/image-editor/ImageEffectControls.svelte.d.ts +2 -0
- package/dist/image-editor/ImageProviderEffectsPanel.svelte +15 -15
- package/dist/image-editor/LayerEffectsPanel.svelte +22 -23
- package/dist/image-editor/LayersPanel.svelte +39 -39
- package/dist/image-editor/RefineEdgePanel.svelte +14 -13
- package/dist/image-editor/SelectionOptionsBar.svelte +12 -11
- package/dist/image-editor/ShapeOptionsBar.svelte +14 -13
- package/dist/image-editor/TypeOptionsBar.svelte +12 -11
- package/dist/image-editor/styles.d.ts +51 -0
- package/dist/image-editor/styles.js +51 -0
- package/dist/playback/playback.svelte.d.ts +7 -4
- package/dist/playback/playback.svelte.js +17 -9
- package/package.json +2 -2
|
@@ -10,20 +10,24 @@
|
|
|
10
10
|
values: Readonly<Record<string, unknown>>;
|
|
11
11
|
onchange: (control: string, value: unknown, gesture: string | null) => void;
|
|
12
12
|
onpreset: (values: Readonly<Record<string, unknown>>) => void;
|
|
13
|
+
/** Extra classes on the root element. */
|
|
14
|
+
class?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
let { provider, effect, name, values, onchange, onpreset }: Props = $props();
|
|
17
|
+
let { provider, effect, name, values, onchange, onpreset, class: className = '' }: Props = $props();
|
|
16
18
|
const loaded = effectDescriptionOf(() => provider, () => effect);
|
|
17
19
|
</script>
|
|
18
20
|
|
|
19
21
|
<!-- One effect's controls, loaded from the provider; a note when it does not have the effect. -->
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
{
|
|
22
|
+
<div class={className || undefined} data-testid="image-effect-controls">
|
|
23
|
+
{#if !provider}
|
|
24
|
+
<p class="text-[11px] text-muted-foreground">No effects provider: "{name ?? effect}" is kept but not drawn.</p>
|
|
25
|
+
{:else if loaded.error}
|
|
26
|
+
<p class="text-[11px] text-amber-600">{loaded.error}</p>
|
|
27
|
+
{:else if !loaded.description}
|
|
28
|
+
<p class="text-[11px] text-muted-foreground">Loading…</p>
|
|
29
|
+
{:else}
|
|
30
|
+
{@const description = loaded.description}
|
|
31
|
+
<EffectParameters {description} {values} {onchange} onreset={(k) => onchange(k, description.defaults[k], null)} {onpreset} />
|
|
32
|
+
{/if}
|
|
33
|
+
</div>
|
|
@@ -6,6 +6,8 @@ interface Props {
|
|
|
6
6
|
values: Readonly<Record<string, unknown>>;
|
|
7
7
|
onchange: (control: string, value: unknown, gesture: string | null) => void;
|
|
8
8
|
onpreset: (values: Readonly<Record<string, unknown>>) => void;
|
|
9
|
+
/** Extra classes on the root element. */
|
|
10
|
+
class?: string;
|
|
9
11
|
}
|
|
10
12
|
declare const ImageEffectControls: import("svelte").Component<Props, {}, "">;
|
|
11
13
|
type ImageEffectControls = ReturnType<typeof ImageEffectControls>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { imageOutlineButtonClass } from './styles.js';
|
|
2
3
|
import { findLayer, type ImageEditorController, type ImageLayer, type SmartObjectLayer } from '@pixerate/editor/image-editor';
|
|
3
4
|
import { untrack } from 'svelte';
|
|
4
5
|
import EffectsBrowser from '../effects/EffectsBrowser.svelte';
|
|
@@ -29,7 +30,6 @@
|
|
|
29
30
|
const all = $derived(layer?.type === 'smart' ? ((layer as SmartObjectLayer).smartFilters?.filters ?? []) : []);
|
|
30
31
|
const generator = $derived(layer?.type === 'pixel' ? layer.generator : undefined);
|
|
31
32
|
const canFilter = $derived(!!layer && layer.type !== 'adjustment' && layer.type !== 'annotation');
|
|
32
|
-
const btn = 'rounded border border-slate-700 px-1.5 py-0.5 text-[11px] text-slate-300 hover:text-white disabled:opacity-40';
|
|
33
33
|
const drags = new Set<string>();
|
|
34
34
|
/** Changes during a drag skip the undo stack after the first, so the drag is one step. */
|
|
35
35
|
function commit(gesture: string | null) {
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
-->
|
|
55
55
|
<section class="flex flex-col gap-2 text-xs {className}" aria-label="Provider effects" data-image-provider-effects="">
|
|
56
56
|
<div class="flex items-center gap-1">
|
|
57
|
-
<span class="flex-1 text-[11px] font-semibold uppercase tracking-wider text-
|
|
58
|
-
<button type="button" class={
|
|
59
|
-
<button type="button" class={
|
|
57
|
+
<span class="flex-1 text-[11px] font-semibold uppercase tracking-wider text-foreground">Effects</span>
|
|
58
|
+
<button type="button" class={imageOutlineButtonClass} disabled={!provider || !canFilter} aria-pressed={browse === 'filter'} onclick={() => (browse = browse === 'filter' ? null : 'filter')}>Apply effect…</button>
|
|
59
|
+
<button type="button" class={imageOutlineButtonClass} disabled={!provider} aria-pressed={browse === 'layer'} onclick={() => (browse = browse === 'layer' ? null : 'layer')}>Effect layer…</button>
|
|
60
60
|
</div>
|
|
61
|
-
{#if !provider}<p class="text-[11px] text-
|
|
61
|
+
{#if !provider}<p class="text-[11px] text-muted-foreground">No effects provider is registered. Effects in the document are kept.</p>{/if}
|
|
62
62
|
{#if browse === 'filter' && layer}
|
|
63
63
|
{@const id = layer.id}
|
|
64
64
|
<EffectsBrowser {provider} uses={['filter']} title="Filters" onpick={(e) => ((browse = null), run(() => controller.applyEffectFilter(id, e.id)))} />
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
{@const id = layer.id}
|
|
71
71
|
<div class="space-y-1" data-effect-part="generator">
|
|
72
72
|
<div class="flex items-center gap-1">
|
|
73
|
-
<span class="flex-1 font-medium text-
|
|
74
|
-
<button type="button" class={
|
|
73
|
+
<span class="flex-1 font-medium text-foreground">{generator.name ?? generator.effect}</span>
|
|
74
|
+
<button type="button" class={imageOutlineButtonClass} title="Keep the pixels as a plain layer" onclick={() => run(() => controller.rasterizeEffectLayer(id))}>Rasterize</button>
|
|
75
75
|
</div>
|
|
76
76
|
<ImageEffectControls
|
|
77
77
|
{provider}
|
|
@@ -89,21 +89,21 @@
|
|
|
89
89
|
{#each all as f, index (f.id)}
|
|
90
90
|
{#if f.kind === 'effect'}
|
|
91
91
|
{@const label = f.name ?? f.effect}
|
|
92
|
-
<li class="rounded border border-
|
|
92
|
+
<li class="rounded border border-border p-1" data-smart-effect={f.id}>
|
|
93
93
|
<div class="flex items-center gap-1">
|
|
94
94
|
<input type="checkbox" checked={f.enabled} aria-label="{label} on" onchange={(e) => {
|
|
95
95
|
const enabled = e.currentTarget.checked;
|
|
96
96
|
run(() => controller.updateEffectFilter(id, f.id, { enabled }));
|
|
97
97
|
}} />
|
|
98
|
-
<button type="button" class="min-w-0 flex-1 truncate text-left text-
|
|
99
|
-
<button type="button" class={
|
|
100
|
-
<button type="button" class={
|
|
101
|
-
<button type="button" class={
|
|
98
|
+
<button type="button" class="min-w-0 flex-1 truncate text-left text-foreground" aria-expanded={open === f.id} onclick={() => (open = open === f.id ? null : f.id)}>{label}</button>
|
|
99
|
+
<button type="button" class={imageOutlineButtonClass} disabled={index === all.length - 1} aria-label="Move up" onclick={() => run(() => controller.updateEffectFilter(id, f.id, { index: index + 1 }))}>↑</button>
|
|
100
|
+
<button type="button" class={imageOutlineButtonClass} disabled={index === 0} aria-label="Move down" onclick={() => run(() => controller.updateEffectFilter(id, f.id, { index: index - 1 }))}>↓</button>
|
|
101
|
+
<button type="button" class={imageOutlineButtonClass} aria-label="Remove {label}" onclick={() => run(() => controller.removeSmartFilter(id, f.id))}>✕</button>
|
|
102
102
|
</div>
|
|
103
103
|
{#if open === f.id}
|
|
104
104
|
<div class="mt-1 space-y-2">
|
|
105
105
|
<label class="flex items-center gap-1">
|
|
106
|
-
<span class="w-14 text-[11px] text-
|
|
106
|
+
<span class="w-14 text-[11px] text-foreground">Opacity</span>
|
|
107
107
|
<input type="range" min="0" max="1" step="0.01" class="flex-1" value={f.opacity} aria-label="{label} opacity" oninput={(e) => {
|
|
108
108
|
const opacity = Number(e.currentTarget.value);
|
|
109
109
|
run(() => controller.updateEffectFilter(id, f.id, { opacity }));
|
|
@@ -124,6 +124,6 @@
|
|
|
124
124
|
{/each}
|
|
125
125
|
</ol>
|
|
126
126
|
{/if}
|
|
127
|
-
{#if !layer}<p class="text-[11px] text-
|
|
128
|
-
{#if message}<p role="alert" class="text-[11px] text-
|
|
127
|
+
{#if !layer}<p class="text-[11px] text-muted-foreground">Select a layer to apply effects to it.</p>{/if}
|
|
128
|
+
{#if message}<p role="alert" class="text-[11px] text-destructive">{message}</p>{/if}
|
|
129
129
|
</section>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { imageIconButtonClass, imageSelectClass } from './styles.js';
|
|
2
3
|
import type { EffectControl, ImageEditorController, LayerEffectKind } from '@pixerate/editor/image-editor';
|
|
3
4
|
import { LAYER_EFFECT_MENU, effectControlChange, layerEffectControls, layerEffectsPanel } from '@pixerate/editor/image-editor';
|
|
4
5
|
import { untrack } from 'svelte';
|
|
@@ -37,8 +38,6 @@
|
|
|
37
38
|
/** Whether the next slider change records an undo step (the first of a drag does, the rest replace it). */
|
|
38
39
|
let commitNext = true;
|
|
39
40
|
|
|
40
|
-
const button = 'px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 hover:text-white disabled:opacity-40';
|
|
41
|
-
|
|
42
41
|
function attempt(fn: () => unknown) {
|
|
43
42
|
try {
|
|
44
43
|
error = null;
|
|
@@ -78,24 +77,24 @@
|
|
|
78
77
|
</script>
|
|
79
78
|
|
|
80
79
|
{#if !view.model.available || !view.id}
|
|
81
|
-
<div class="text-xs text-
|
|
80
|
+
<div class="text-xs text-muted-foreground {className}" data-testid="layer-effects">{view.model.reason}</div>
|
|
82
81
|
{:else}
|
|
83
82
|
{@const id = view.id}
|
|
84
|
-
<div class="flex flex-col gap-1.5 text-xs text-
|
|
83
|
+
<div class="flex flex-col gap-1.5 text-xs text-foreground {className}" data-testid="layer-effects" aria-label="Layer effects" role="group">
|
|
85
84
|
<div class="flex flex-wrap items-center gap-2">
|
|
86
|
-
<span class="font-semibold text-
|
|
87
|
-
<label class="flex items-center gap-1 text-
|
|
85
|
+
<span class="font-semibold text-foreground">fx Effects</span>
|
|
86
|
+
<label class="flex items-center gap-1 text-foreground">
|
|
88
87
|
<input
|
|
89
88
|
type="checkbox"
|
|
90
89
|
aria-label="Effects on"
|
|
91
90
|
checked={view.model.enabled}
|
|
92
91
|
disabled={view.model.readOnly}
|
|
93
92
|
onchange={(e) => attempt(() => controller.setLayerEffectsEnabled(id, (e.currentTarget as HTMLInputElement).checked))}
|
|
94
|
-
class="accent-
|
|
93
|
+
class="accent-primary"
|
|
95
94
|
/>
|
|
96
95
|
On
|
|
97
96
|
</label>
|
|
98
|
-
<label class="flex items-center gap-1 text-
|
|
97
|
+
<label class="flex items-center gap-1 text-muted-foreground">
|
|
99
98
|
Scale
|
|
100
99
|
<input
|
|
101
100
|
type="number"
|
|
@@ -106,23 +105,23 @@
|
|
|
106
105
|
value={Math.round(view.model.scale * 100)}
|
|
107
106
|
disabled={view.model.readOnly}
|
|
108
107
|
oninput={setScale}
|
|
109
|
-
class="w-14 bg-
|
|
108
|
+
class="w-14 bg-muted border border-border rounded px-1"
|
|
110
109
|
/>
|
|
111
110
|
%
|
|
112
111
|
</label>
|
|
113
|
-
<select aria-label="Add effect" value="" disabled={view.model.readOnly} onchange={add} class=
|
|
112
|
+
<select aria-label="Add effect" value="" disabled={view.model.readOnly} onchange={add} class={imageSelectClass}>
|
|
114
113
|
<option value="">Add effect…</option>
|
|
115
114
|
{#each LAYER_EFFECT_MENU as m (m.kind)}
|
|
116
115
|
<option value={m.kind}>{m.label}</option>
|
|
117
116
|
{/each}
|
|
118
117
|
</select>
|
|
119
118
|
</div>
|
|
120
|
-
{#if view.model.readOnly && view.model.reason}<p class="text-
|
|
119
|
+
{#if view.model.readOnly && view.model.reason}<p class="text-muted-foreground">{view.model.reason}</p>{/if}
|
|
121
120
|
<ul class="flex flex-col gap-1" aria-label="Effects">
|
|
122
121
|
{#each view.model.rows as row (row.id)}
|
|
123
122
|
{@const effect = view.items.find((e) => e.id === row.id)}
|
|
124
123
|
{@const expanded = open === row.id}
|
|
125
|
-
<li data-effect-id={row.id} class="rounded bg-
|
|
124
|
+
<li data-effect-id={row.id} class="rounded bg-background/60 ring-1 ring-border">
|
|
126
125
|
<div class="flex items-center gap-1.5 px-1.5 py-1">
|
|
127
126
|
<input
|
|
128
127
|
type="checkbox"
|
|
@@ -130,19 +129,19 @@
|
|
|
130
129
|
checked={row.enabled}
|
|
131
130
|
disabled={view.model.readOnly}
|
|
132
131
|
onchange={(e) => attempt(() => controller.updateLayerEffect(id, row.id, { enabled: (e.currentTarget as HTMLInputElement).checked }))}
|
|
133
|
-
class="accent-
|
|
132
|
+
class="accent-primary"
|
|
134
133
|
/>
|
|
135
|
-
<button type="button" aria-expanded={expanded} class="flex-1 text-left {row.enabled && view.model.enabled ? '' : 'text-
|
|
134
|
+
<button type="button" aria-expanded={expanded} class="flex-1 text-left {row.enabled && view.model.enabled ? '' : 'text-muted-foreground'}" onclick={() => (open = expanded ? null : row.id)}>
|
|
136
135
|
{row.label}
|
|
137
136
|
</button>
|
|
138
|
-
<button type="button" class={
|
|
137
|
+
<button type="button" class={imageIconButtonClass} aria-label="Remove {row.label}" disabled={view.model.readOnly} onclick={() => attempt(() => controller.removeLayerEffect(id, row.id))}>✕</button>
|
|
139
138
|
</div>
|
|
140
139
|
{#if expanded && effect}
|
|
141
140
|
<div class="grid grid-cols-2 gap-x-2 gap-y-1 px-1.5 pb-1.5" data-testid="effect-settings">
|
|
142
141
|
{#each layerEffectControls(effect, view.patterns) as c (c.key)}
|
|
143
142
|
{#if c.type === 'range'}
|
|
144
143
|
<label class="flex flex-col gap-0.5">
|
|
145
|
-
<span class="text-
|
|
144
|
+
<span class="text-muted-foreground">{c.label} {shown(c)}{c.unit ?? ''}</span>
|
|
146
145
|
<input
|
|
147
146
|
type="range"
|
|
148
147
|
aria-label={c.label}
|
|
@@ -155,23 +154,23 @@
|
|
|
155
154
|
onpointerup={() => (commitNext = true)}
|
|
156
155
|
onkeydown={() => (commitNext = true)}
|
|
157
156
|
oninput={(e) => change(row.id, c, Number((e.currentTarget as HTMLInputElement).value) / k(c), true)}
|
|
158
|
-
class="accent-
|
|
157
|
+
class="accent-primary"
|
|
159
158
|
/>
|
|
160
159
|
</label>
|
|
161
160
|
{:else if c.type === 'color'}
|
|
162
|
-
<label class="flex items-center gap-1.5 text-
|
|
161
|
+
<label class="flex items-center gap-1.5 text-foreground">
|
|
163
162
|
<input type="color" aria-label={c.label} value={c.value} disabled={view.model.readOnly} oninput={(e) => change(row.id, c, (e.currentTarget as HTMLInputElement).value)} />
|
|
164
163
|
{c.label}
|
|
165
164
|
</label>
|
|
166
165
|
{:else if c.type === 'checkbox'}
|
|
167
|
-
<label class="flex items-center gap-1.5 text-
|
|
168
|
-
<input type="checkbox" aria-label={c.label} checked={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLInputElement).checked)} class="accent-
|
|
166
|
+
<label class="flex items-center gap-1.5 text-foreground">
|
|
167
|
+
<input type="checkbox" aria-label={c.label} checked={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLInputElement).checked)} class="accent-primary" />
|
|
169
168
|
{c.label}
|
|
170
169
|
</label>
|
|
171
170
|
{:else}
|
|
172
171
|
<label class="flex flex-col gap-0.5">
|
|
173
|
-
<span class="text-
|
|
174
|
-
<select aria-label={c.label} value={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLSelectElement).value)} class=
|
|
172
|
+
<span class="text-muted-foreground">{c.label}</span>
|
|
173
|
+
<select aria-label={c.label} value={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLSelectElement).value)} class={imageSelectClass}>
|
|
175
174
|
{#each c.options as o (o.value)}
|
|
176
175
|
<option value={o.value}>{o.label}</option>
|
|
177
176
|
{/each}
|
|
@@ -184,6 +183,6 @@
|
|
|
184
183
|
</li>
|
|
185
184
|
{/each}
|
|
186
185
|
</ul>
|
|
187
|
-
{#if error}<p role="alert" class="text-
|
|
186
|
+
{#if error}<p role="alert" class="text-destructive">{error}</p>{/if}
|
|
188
187
|
</div>
|
|
189
188
|
{/if}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { imageIconButtonClass, imageSelectClass } from './styles.js';
|
|
2
3
|
import type { ImageEditorController, ImageLayer, LayerMenuAction, LayerPanelRow, PixelBuffer } from '@pixerate/editor/image-editor';
|
|
3
4
|
import {
|
|
4
5
|
BIT_DEPTH_OPTIONS,
|
|
@@ -82,9 +83,8 @@
|
|
|
82
83
|
let commitNext = true;
|
|
83
84
|
|
|
84
85
|
const LOCKS = ['pixels', 'transparency', 'position', 'all'] as const;
|
|
85
|
-
const iconButton = 'px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 hover:text-white disabled:opacity-40';
|
|
86
86
|
const rowClass = (active: boolean) =>
|
|
87
|
-
`flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none ${active ? 'bg-
|
|
87
|
+
`flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none ${active ? 'bg-primary/30 ring-1 ring-primary' : 'hover:bg-muted'}`;
|
|
88
88
|
|
|
89
89
|
function attempt(fn: () => unknown) {
|
|
90
90
|
try {
|
|
@@ -147,21 +147,21 @@
|
|
|
147
147
|
return layer.mask && !layer.mask.source ? controller.getLayerThumbnail(layer.id, { maxSide: 64, mask: true }) : null;
|
|
148
148
|
}
|
|
149
149
|
const thumbClass = (selected: boolean) =>
|
|
150
|
-
`shrink-0 rounded bg-
|
|
150
|
+
`shrink-0 rounded bg-muted ${selected ? 'ring-2 ring-foreground' : 'ring-1 ring-border'}`;
|
|
151
151
|
</script>
|
|
152
152
|
|
|
153
153
|
<svelte:window onclick={() => menu && (menu = null)} />
|
|
154
154
|
|
|
155
|
-
<div class="flex flex-col gap-2 text-xs text-
|
|
155
|
+
<div class="flex flex-col gap-2 text-xs text-foreground {className}" data-testid="layers-panel">
|
|
156
156
|
<div class="flex flex-wrap items-center gap-1" role="toolbar" aria-label="Layer actions">
|
|
157
|
-
<button type="button" class={
|
|
158
|
-
<button type="button" class={
|
|
159
|
-
<button type="button" class={
|
|
160
|
-
<button type="button" class={
|
|
161
|
-
<button type="button" class={
|
|
157
|
+
<button type="button" class={imageIconButtonClass} onclick={() => run('new-layer')}>New Layer</button>
|
|
158
|
+
<button type="button" class={imageIconButtonClass} onclick={() => run('new-group')}>New Group</button>
|
|
159
|
+
<button type="button" class={imageIconButtonClass} disabled={!snapshot.active} onclick={() => run('duplicate')}>Duplicate</button>
|
|
160
|
+
<button type="button" class={imageIconButtonClass} disabled={!snapshot.active} onclick={() => run('add-mask')}>Add Mask</button>
|
|
161
|
+
<button type="button" class={imageIconButtonClass} disabled={!snapshot.active} onclick={() => run('delete')}>Delete</button>
|
|
162
162
|
<button
|
|
163
163
|
type="button"
|
|
164
|
-
class={
|
|
164
|
+
class={imageIconButtonClass}
|
|
165
165
|
aria-label="Layer menu"
|
|
166
166
|
aria-haspopup="menu"
|
|
167
167
|
onclick={(e) => {
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
<select
|
|
173
173
|
aria-label="Bits per channel"
|
|
174
174
|
title="Image › Mode: bits per channel"
|
|
175
|
-
class="rounded bg-
|
|
175
|
+
class="rounded bg-muted px-1 py-0.5 text-[11px] text-foreground"
|
|
176
176
|
value={snapshot.bitDepth}
|
|
177
177
|
onchange={(e) => attempt(() => controller.setBitDepth(Number((e.target as HTMLSelectElement).value) as 8 | 16 | 32))}>
|
|
178
178
|
{#each BIT_DEPTH_OPTIONS as o (o.depth)}
|
|
@@ -180,8 +180,8 @@
|
|
|
180
180
|
{/each}
|
|
181
181
|
</select>
|
|
182
182
|
{#if psd}
|
|
183
|
-
<button type="button" class={
|
|
184
|
-
<button type="button" class={
|
|
183
|
+
<button type="button" class={imageIconButtonClass} onclick={() => fileInput?.click()}>Open PSD…</button>
|
|
184
|
+
<button type="button" class={imageIconButtonClass} onclick={() => psdTask(async () => (await downloadPsd(controller, psdFileName)).warnings)}>Export PSD</button>
|
|
185
185
|
<input
|
|
186
186
|
bind:this={fileInput}
|
|
187
187
|
type="file"
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
{/if}
|
|
194
194
|
</div>
|
|
195
195
|
{#if psdNotes.length > 0}
|
|
196
|
-
<ul class="rounded bg-amber-
|
|
196
|
+
<ul class="rounded bg-amber-500/10 text-amber-600 px-2 py-1 space-y-0.5" data-testid="psd-warnings" aria-label="PSD warnings">
|
|
197
197
|
{#each psdNotes as n (n)}
|
|
198
198
|
<li>{n}</li>
|
|
199
199
|
{/each}
|
|
@@ -205,7 +205,7 @@
|
|
|
205
205
|
{@const active = snapshot.active}
|
|
206
206
|
<div class="grid grid-cols-2 gap-x-2 gap-y-1 items-center" data-testid="layer-properties">
|
|
207
207
|
<label class="flex flex-col gap-0.5">
|
|
208
|
-
<span class="text-
|
|
208
|
+
<span class="text-muted-foreground">Opacity {Math.round(active.opacity * 100)}%</span>
|
|
209
209
|
<input
|
|
210
210
|
type="range"
|
|
211
211
|
min="0"
|
|
@@ -216,11 +216,11 @@
|
|
|
216
216
|
onpointerup={() => (commitNext = true)}
|
|
217
217
|
onkeydown={() => (commitNext = true)}
|
|
218
218
|
oninput={(e) => prop('opacity', Number((e.target as HTMLInputElement).value) / 100)}
|
|
219
|
-
class="accent-
|
|
219
|
+
class="accent-primary"
|
|
220
220
|
/>
|
|
221
221
|
</label>
|
|
222
222
|
<label class="flex flex-col gap-0.5">
|
|
223
|
-
<span class="text-
|
|
223
|
+
<span class="text-muted-foreground">Fill {Math.round(fillOf(active) * 100)}%</span>
|
|
224
224
|
<input
|
|
225
225
|
type="range"
|
|
226
226
|
min="0"
|
|
@@ -232,12 +232,12 @@
|
|
|
232
232
|
onpointerup={() => (commitNext = true)}
|
|
233
233
|
onkeydown={() => (commitNext = true)}
|
|
234
234
|
oninput={(e) => prop('fillOpacity', Number((e.target as HTMLInputElement).value) / 100)}
|
|
235
|
-
class="accent-
|
|
235
|
+
class="accent-primary disabled:opacity-40"
|
|
236
236
|
/>
|
|
237
237
|
</label>
|
|
238
238
|
<select
|
|
239
239
|
aria-label="Layer blend mode"
|
|
240
|
-
class=
|
|
240
|
+
class={imageSelectClass}
|
|
241
241
|
value={active.blend}
|
|
242
242
|
onchange={(e) => attempt(() => controller.setLayerProperties(active.id, { blend: (e.target as HTMLSelectElement).value as never }))}
|
|
243
243
|
>
|
|
@@ -246,17 +246,17 @@
|
|
|
246
246
|
<option value={m.id}>{m.label}</option>
|
|
247
247
|
{/each}
|
|
248
248
|
</select>
|
|
249
|
-
<label class="flex items-center gap-1.5 text-
|
|
249
|
+
<label class="flex items-center gap-1.5 text-foreground">
|
|
250
250
|
<input
|
|
251
251
|
type="checkbox"
|
|
252
252
|
aria-label="Clip to layer below"
|
|
253
253
|
checked={Boolean(active.clipped)}
|
|
254
254
|
onchange={(e) => attempt(() => controller.setLayerProperties(active.id, { clipped: (e.target as HTMLInputElement).checked }))}
|
|
255
|
-
class="accent-
|
|
255
|
+
class="accent-primary"
|
|
256
256
|
/>
|
|
257
257
|
Clip to below
|
|
258
258
|
</label>
|
|
259
|
-
<div class="col-span-2 flex flex-wrap gap-2 text-
|
|
259
|
+
<div class="col-span-2 flex flex-wrap gap-2 text-muted-foreground" role="group" aria-label="Layer locks">
|
|
260
260
|
{#each LOCKS as k (k)}
|
|
261
261
|
<label class="flex items-center gap-1">
|
|
262
262
|
<input
|
|
@@ -264,7 +264,7 @@
|
|
|
264
264
|
aria-label={`Lock ${k}`}
|
|
265
265
|
checked={Boolean(active.locks?.[k])}
|
|
266
266
|
onchange={(e) => attempt(() => controller.setLayerProperties(active.id, { locks: { ...active.locks, [k]: (e.target as HTMLInputElement).checked } }))}
|
|
267
|
-
class="accent-
|
|
267
|
+
class="accent-primary"
|
|
268
268
|
/>
|
|
269
269
|
{k}
|
|
270
270
|
</label>
|
|
@@ -274,7 +274,7 @@
|
|
|
274
274
|
{/if}
|
|
275
275
|
|
|
276
276
|
{#if effects && snapshot.active && snapshot.active.type !== 'adjustment'}
|
|
277
|
-
<LayerEffectsPanel {controller} class="border-t border-
|
|
277
|
+
<LayerEffectsPanel {controller} class="border-t border-border pt-1.5" />
|
|
278
278
|
{/if}
|
|
279
279
|
|
|
280
280
|
<ul class="flex flex-col gap-0.5" role="tree" aria-label="Layers">
|
|
@@ -312,7 +312,7 @@
|
|
|
312
312
|
type="button"
|
|
313
313
|
aria-label={layer.visible ? `Hide ${row.label}` : `Show ${row.label}`}
|
|
314
314
|
aria-pressed={layer.visible}
|
|
315
|
-
class="w-4 text-center text-
|
|
315
|
+
class="w-4 text-center text-foreground"
|
|
316
316
|
onclick={(e) => {
|
|
317
317
|
e.stopPropagation();
|
|
318
318
|
attempt(() => controller.setLayerProperties(layer.id, { visible: !layer.visible }));
|
|
@@ -321,13 +321,13 @@
|
|
|
321
321
|
<button
|
|
322
322
|
type="button"
|
|
323
323
|
aria-label={layer.collapsed ? `Expand ${row.label}` : `Collapse ${row.label}`}
|
|
324
|
-
class="w-3 text-
|
|
324
|
+
class="w-3 text-muted-foreground"
|
|
325
325
|
onclick={(e) => {
|
|
326
326
|
e.stopPropagation();
|
|
327
327
|
attempt(() => controller.setLayerProperties(layer.id, { collapsed: !(layer.type === 'group' && layer.collapsed) }));
|
|
328
328
|
}}>{layer.collapsed ? '▸' : '▾'}</button>
|
|
329
329
|
{/if}
|
|
330
|
-
{#if row.clipped}<span class="text-
|
|
330
|
+
{#if row.clipped}<span class="text-muted-foreground" aria-label="Clipped">↳</span>{/if}
|
|
331
331
|
{#if layerHasThumbnail(layer)}
|
|
332
332
|
<span class="relative shrink-0">
|
|
333
333
|
<canvas
|
|
@@ -339,12 +339,12 @@
|
|
|
339
339
|
{#if layer.type !== 'pixel'}
|
|
340
340
|
<span
|
|
341
341
|
data-testid="layer-kind-badge"
|
|
342
|
-
class="absolute -bottom-1 -right-1 rounded bg-
|
|
342
|
+
class="absolute -bottom-1 -right-1 rounded bg-accent px-0.5 text-[8px] font-bold leading-tight text-foreground ring-1 ring-background">{layerBadge(layer)}</span>
|
|
343
343
|
{/if}
|
|
344
344
|
</span>
|
|
345
345
|
{:else}
|
|
346
346
|
<span
|
|
347
|
-
class="shrink-0 grid place-items-center rounded bg-
|
|
347
|
+
class="shrink-0 grid place-items-center rounded bg-muted text-[9px] text-muted-foreground ring-1 ring-border"
|
|
348
348
|
style="width: {thumbnailSize}px; height: {thumbnailSize}px">{layerBadge(layer)}</span>
|
|
349
349
|
{/if}
|
|
350
350
|
{#if layer.mask && !layer.mask.source && layer.type === 'pixel'}
|
|
@@ -354,7 +354,7 @@
|
|
|
354
354
|
aria-pressed={layer.mask.linked !== false}
|
|
355
355
|
title={layer.mask.linked === false ? 'Mask unlinked: it stays put when the layer moves or transforms. Click to link.' : 'Mask linked: it moves with the layer. Click to unlink.'}
|
|
356
356
|
data-testid="layer-mask-link"
|
|
357
|
-
class="shrink-0 text-[10px] leading-none {layer.mask.linked === false ? 'text-
|
|
357
|
+
class="shrink-0 text-[10px] leading-none {layer.mask.linked === false ? 'text-muted-foreground/70' : 'text-foreground'}"
|
|
358
358
|
onclick={(e) => {
|
|
359
359
|
e.stopPropagation();
|
|
360
360
|
attempt(() => controller.setLayerMaskProps(layer.id, { linked: layer.mask!.linked === false }));
|
|
@@ -382,7 +382,7 @@
|
|
|
382
382
|
autofocus
|
|
383
383
|
aria-label="Layer name"
|
|
384
384
|
value={layer.name || row.label}
|
|
385
|
-
class="flex-1 min-w-0 bg-
|
|
385
|
+
class="flex-1 min-w-0 bg-background border border-border rounded px-1"
|
|
386
386
|
onclick={(e) => e.stopPropagation()}
|
|
387
387
|
onblur={(e) => {
|
|
388
388
|
const v = (e.target as HTMLInputElement).value;
|
|
@@ -397,14 +397,14 @@
|
|
|
397
397
|
{:else}
|
|
398
398
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
399
399
|
<span
|
|
400
|
-
class="flex-1 min-w-0 truncate {layer.visible ? '' : 'text-
|
|
400
|
+
class="flex-1 min-w-0 truncate {layer.visible ? '' : 'text-muted-foreground'}"
|
|
401
401
|
ondblclick={(e) => {
|
|
402
402
|
e.stopPropagation();
|
|
403
403
|
renaming = layer.id;
|
|
404
404
|
}}>{row.label}</span>
|
|
405
405
|
{/if}
|
|
406
406
|
{#if layerWarning(layer)}
|
|
407
|
-
<span role="img" data-testid="layer-warning" aria-label={layerWarning(layer)} title={layerWarning(layer)} class="text-[10px] text-amber-
|
|
407
|
+
<span role="img" data-testid="layer-warning" aria-label={layerWarning(layer)} title={layerWarning(layer)} class="text-[10px] text-amber-600">⚠</span>
|
|
408
408
|
{/if}
|
|
409
409
|
{#if layerEffectsBadge(layer)}
|
|
410
410
|
{@const fxOn = layerEffectsBadge(layer) === 'on'}
|
|
@@ -413,21 +413,21 @@
|
|
|
413
413
|
data-testid="layer-fx-badge"
|
|
414
414
|
aria-label={fxOn ? `Turn effects off for ${row.label}` : `Turn effects on for ${row.label}`}
|
|
415
415
|
aria-pressed={fxOn}
|
|
416
|
-
class="text-[9px] italic font-semibold {fxOn ? 'text-
|
|
416
|
+
class="text-[9px] italic font-semibold {fxOn ? 'text-primary' : 'text-muted-foreground/70 line-through'}"
|
|
417
417
|
onclick={(e) => {
|
|
418
418
|
e.stopPropagation();
|
|
419
419
|
attempt(() => controller.setLayerEffectsEnabled(layer.id, !fxOn));
|
|
420
420
|
}}>fx</button
|
|
421
421
|
>
|
|
422
422
|
{/if}
|
|
423
|
-
{#if locked(layer)}<span aria-label="Locked" class="text-
|
|
424
|
-
{#if layer.blend !== 'normal' && layer.blend !== 'pass-through'}<span class="text-[9px] text-
|
|
423
|
+
{#if locked(layer)}<span aria-label="Locked" class="text-muted-foreground">🔒</span>{/if}
|
|
424
|
+
{#if layer.blend !== 'normal' && layer.blend !== 'pass-through'}<span class="text-[9px] text-muted-foreground">{layer.blend}</span>{/if}
|
|
425
425
|
</li>
|
|
426
426
|
{/each}
|
|
427
427
|
</ul>
|
|
428
428
|
|
|
429
429
|
{#if error}
|
|
430
|
-
<p role="alert" class="text-
|
|
430
|
+
<p role="alert" class="text-destructive">{error}</p>
|
|
431
431
|
{/if}
|
|
432
432
|
|
|
433
433
|
{#if menu}
|
|
@@ -435,7 +435,7 @@
|
|
|
435
435
|
role="menu"
|
|
436
436
|
tabindex="-1"
|
|
437
437
|
aria-label="Layer menu"
|
|
438
|
-
class="fixed z-50 min-w-44 rounded border border-
|
|
438
|
+
class="fixed z-50 min-w-44 rounded border border-border bg-background py-1 shadow-xl"
|
|
439
439
|
style="left: {menu.x}px; top: {menu.y}px"
|
|
440
440
|
onclick={(e) => e.stopPropagation()}
|
|
441
441
|
onkeydown={(e) => e.key === 'Escape' && (menu = null)}
|
|
@@ -445,7 +445,7 @@
|
|
|
445
445
|
type="button"
|
|
446
446
|
role="menuitem"
|
|
447
447
|
disabled={!item.enabled}
|
|
448
|
-
class="block w-full px-3 py-1 text-left hover:bg-
|
|
448
|
+
class="block w-full px-3 py-1 text-left hover:bg-muted disabled:opacity-40"
|
|
449
449
|
onclick={() => run(item.action)}>{item.label}</button>
|
|
450
450
|
{/each}
|
|
451
451
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { imageRangeClass, imageSectionTitleClass } from './styles.js';
|
|
2
3
|
import type {
|
|
3
4
|
AdjustmentKind,
|
|
4
5
|
AdjustmentLayer,
|
|
@@ -65,14 +66,14 @@
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const button =
|
|
68
|
-
'px-2 py-1 rounded-md border border-
|
|
69
|
+
'px-2 py-1 rounded-md border border-border bg-background text-[11px] text-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40';
|
|
69
70
|
</script>
|
|
70
71
|
|
|
71
72
|
<div data-testid="refine-edge-panel" class="flex flex-col gap-3 text-xs {className}">
|
|
72
|
-
<span class=
|
|
73
|
+
<span class={imageSectionTitleClass}>Refine Edge</span>
|
|
73
74
|
{#each REFINE_EDGE_CONTROLS as c (c.id)}
|
|
74
75
|
<label class="flex flex-col gap-1">
|
|
75
|
-
<span class="flex justify-between text-
|
|
76
|
+
<span class="flex justify-between text-muted-foreground">
|
|
76
77
|
<span>{c.label}</span>
|
|
77
78
|
<span class="font-mono">{params[c.id]}{c.unit}</span>
|
|
78
79
|
</span>
|
|
@@ -84,11 +85,11 @@
|
|
|
84
85
|
aria-label={c.label}
|
|
85
86
|
value={params[c.id]}
|
|
86
87
|
oninput={(e) => (params = { ...params, [c.id]: Number((e.currentTarget as HTMLInputElement).value) })}
|
|
87
|
-
class=
|
|
88
|
+
class={imageRangeClass}
|
|
88
89
|
/>
|
|
89
90
|
</label>
|
|
90
91
|
{/each}
|
|
91
|
-
<label class="flex items-center gap-2 text-
|
|
92
|
+
<label class="flex items-center gap-2 text-foreground">
|
|
92
93
|
<input
|
|
93
94
|
type="checkbox"
|
|
94
95
|
checked={params.smartRadius}
|
|
@@ -101,16 +102,16 @@
|
|
|
101
102
|
<button type="button" class={button} onclick={() => (params = { ...DEFAULT_PARAMS })}>Reset</button>
|
|
102
103
|
</div>
|
|
103
104
|
{#if error}
|
|
104
|
-
<p role="alert" class="text-
|
|
105
|
+
<p role="alert" class="text-destructive">{error}</p>
|
|
105
106
|
{/if}
|
|
106
107
|
|
|
107
|
-
<span class="font-semibold text-
|
|
108
|
+
<span class="font-semibold text-foreground uppercase tracking-wider text-[11px] mt-2">Layer Mask</span>
|
|
108
109
|
<div class="flex gap-1 items-center">
|
|
109
110
|
<select
|
|
110
111
|
aria-label="New masked layer kind"
|
|
111
112
|
value={newKind}
|
|
112
113
|
onchange={(e) => (newKind = (e.currentTarget as HTMLSelectElement).value as AdjustmentKind)}
|
|
113
|
-
class="flex-1 bg-
|
|
114
|
+
class="flex-1 bg-background border border-border rounded px-1 py-1 text-foreground"
|
|
114
115
|
>
|
|
115
116
|
{#each ADJUSTMENT_KINDS as k (k.kind)}
|
|
116
117
|
<option value={k.kind}>{k.label}</option>
|
|
@@ -124,7 +125,7 @@
|
|
|
124
125
|
aria-label="Mask layer"
|
|
125
126
|
value={target.id}
|
|
126
127
|
onchange={(e) => (layerId = (e.currentTarget as HTMLSelectElement).value)}
|
|
127
|
-
class="bg-
|
|
128
|
+
class="bg-background border border-border rounded px-1 py-1 text-foreground"
|
|
128
129
|
>
|
|
129
130
|
{#each list as l (l.id)}
|
|
130
131
|
<option value={l.id}>{l.name || adjustmentKindLabel(l.kind)}{l.mask ? ' (masked)' : ''}</option>
|
|
@@ -143,7 +144,7 @@
|
|
|
143
144
|
</div>
|
|
144
145
|
{#if target.mask}
|
|
145
146
|
<label class="flex flex-col gap-1">
|
|
146
|
-
<span class="flex justify-between text-
|
|
147
|
+
<span class="flex justify-between text-muted-foreground">
|
|
147
148
|
<span>Density</span>
|
|
148
149
|
<span class="font-mono">{Math.round((target.mask.density ?? 1) * 100)}%</span>
|
|
149
150
|
</span>
|
|
@@ -155,11 +156,11 @@
|
|
|
155
156
|
aria-label="Mask density"
|
|
156
157
|
value={Math.round((target.mask.density ?? 1) * 100)}
|
|
157
158
|
oninput={(e) => controller.setAdjustmentLayerMaskProps(target.id, { density: Number((e.currentTarget as HTMLInputElement).value) / 100 })}
|
|
158
|
-
class=
|
|
159
|
+
class={imageRangeClass}
|
|
159
160
|
/>
|
|
160
161
|
</label>
|
|
161
162
|
<label class="flex flex-col gap-1">
|
|
162
|
-
<span class="flex justify-between text-
|
|
163
|
+
<span class="flex justify-between text-muted-foreground">
|
|
163
164
|
<span>Mask Feather</span>
|
|
164
165
|
<span class="font-mono">{target.mask.feather ?? 0}px</span>
|
|
165
166
|
</span>
|
|
@@ -171,7 +172,7 @@
|
|
|
171
172
|
aria-label="Mask feather"
|
|
172
173
|
value={target.mask.feather ?? 0}
|
|
173
174
|
oninput={(e) => controller.setAdjustmentLayerMaskProps(target.id, { feather: Number((e.currentTarget as HTMLInputElement).value) })}
|
|
174
|
-
class=
|
|
175
|
+
class={imageRangeClass}
|
|
175
176
|
/>
|
|
176
177
|
</label>
|
|
177
178
|
{/if}
|