@pixerate/editor-svelte 0.105.0 → 0.106.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/README.md +2 -0
- package/dist/color/ColorPicker.svelte +546 -0
- package/dist/color/ColorPicker.svelte.d.ts +36 -0
- package/dist/color/DualColorPicker.svelte +296 -0
- package/dist/color/DualColorPicker.svelte.d.ts +34 -0
- package/dist/color/colorScheme.svelte.d.ts +19 -0
- package/dist/color/colorScheme.svelte.js +76 -0
- package/dist/color/index.d.ts +9 -0
- package/dist/color/index.js +5 -0
- package/dist/gradient/FillPicker.svelte +236 -0
- package/dist/gradient/FillPicker.svelte.d.ts +32 -0
- package/dist/gradient/GradientEditor.svelte +472 -0
- package/dist/gradient/GradientEditor.svelte.d.ts +23 -0
- package/dist/gradient/gradientController.svelte.d.ts +13 -0
- package/dist/gradient/gradientController.svelte.js +21 -0
- package/dist/gradient/index.d.ts +10 -0
- package/dist/gradient/index.js +5 -0
- package/dist/gradient/types.d.ts +2 -0
- package/dist/gradient/types.js +1 -0
- package/dist/parameters/ParameterColor.svelte +63 -64
- package/package.json +18 -2
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FillMode } from './types.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Active CSS colour (hex/rgb/hsl etc.) or CSS gradient string. */
|
|
4
|
+
value: string;
|
|
5
|
+
/** Called when the fill value changes. */
|
|
6
|
+
onchange?: (value: string) => void;
|
|
7
|
+
onChange?: (value: string) => void;
|
|
8
|
+
/** Called when a continuous pointer drag starts. */
|
|
9
|
+
ongesturestart?: () => void;
|
|
10
|
+
onGestureStart?: () => void;
|
|
11
|
+
/** Called when a continuous pointer drag ends. */
|
|
12
|
+
ongestureend?: () => void;
|
|
13
|
+
onGestureEnd?: () => void;
|
|
14
|
+
/** Allowed fill modes. Default: ['solid', 'gradient']. */
|
|
15
|
+
modes?: readonly FillMode[];
|
|
16
|
+
/** Whether the alpha channel slider is shown on ColorPicker. Default: false. */
|
|
17
|
+
alpha?: boolean;
|
|
18
|
+
/** Preset swatches for ColorPicker. */
|
|
19
|
+
swatches?: readonly string[];
|
|
20
|
+
/** Whether the gradient editor shows preset buttons. Default: true. */
|
|
21
|
+
showPresets?: boolean;
|
|
22
|
+
/** Whether the gradient editor shows the CSS output with a copy button. Default: false. */
|
|
23
|
+
showCss?: boolean;
|
|
24
|
+
/** Additional CSS class names. */
|
|
25
|
+
class?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
/** Whether the picker is disabled. */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
declare const FillPicker: import("svelte").Component<Props, {}, "">;
|
|
31
|
+
type FillPicker = ReturnType<typeof FillPicker>;
|
|
32
|
+
export default FillPicker;
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
import {
|
|
4
|
+
GRADIENT_PRESETS,
|
|
5
|
+
type GradientController,
|
|
6
|
+
type GradientInterpolation,
|
|
7
|
+
type GradientState,
|
|
8
|
+
type GradientType,
|
|
9
|
+
angleFromPointer,
|
|
10
|
+
gradientKeyStep,
|
|
11
|
+
gradientToCss,
|
|
12
|
+
gradientToCssFallback,
|
|
13
|
+
gradientTrackBackground,
|
|
14
|
+
isDualGradient
|
|
15
|
+
} from '@pixerate/editor/gradient';
|
|
16
|
+
import { formatDualColor, type ColorScheme } from '@pixerate/editor/color';
|
|
17
|
+
import ColorPicker from '../color/ColorPicker.svelte';
|
|
18
|
+
import DualColorPicker from '../color/DualColorPicker.svelte';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** Required controller that manages gradient data and history. */
|
|
22
|
+
controller: GradientController;
|
|
23
|
+
/** Allowed gradient types to switch between (default: ['linear', 'radial', 'conic']). */
|
|
24
|
+
types?: readonly GradientType[];
|
|
25
|
+
/** Whether to display preset gradient buttons (default: true). */
|
|
26
|
+
showPresets?: boolean;
|
|
27
|
+
/** Whether to display CSS code with copy button (default: false). */
|
|
28
|
+
showCss?: boolean;
|
|
29
|
+
/** Whether stops support dual light/dark colour definitions (default: false or auto-detected). */
|
|
30
|
+
dual?: boolean;
|
|
31
|
+
/** Called when a continuous edit (stop drag, angle drag, colour drag) starts. */
|
|
32
|
+
ongesturestart?: () => void;
|
|
33
|
+
/** Called when that edit ends. */
|
|
34
|
+
ongestureend?: () => void;
|
|
35
|
+
/** Additional CSS class name. */
|
|
36
|
+
class?: string;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
controller,
|
|
42
|
+
types = ['linear', 'radial', 'conic'],
|
|
43
|
+
showPresets = true,
|
|
44
|
+
showCss = false,
|
|
45
|
+
dual: controlledDual = undefined,
|
|
46
|
+
class: klass = '',
|
|
47
|
+
ongesturestart,
|
|
48
|
+
ongestureend,
|
|
49
|
+
className = ''
|
|
50
|
+
}: Props = $props();
|
|
51
|
+
|
|
52
|
+
// A gesture is one undo step in the controller and is reported to the host.
|
|
53
|
+
function beginGesture(label: string) {
|
|
54
|
+
controller.beginGesture(label);
|
|
55
|
+
ongesturestart?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function endGesture() {
|
|
59
|
+
controller.endGesture();
|
|
60
|
+
ongestureend?.();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let gradientState = $state.raw<GradientState | null>(null);
|
|
64
|
+
$effect(() => {
|
|
65
|
+
gradientState = controller.state;
|
|
66
|
+
return controller.subscribe((next) => {
|
|
67
|
+
gradientState = next;
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
let safeState = $derived(gradientState ?? controller.state);
|
|
72
|
+
|
|
73
|
+
let activeScheme = $state<ColorScheme>('light');
|
|
74
|
+
let copied = $state(false);
|
|
75
|
+
|
|
76
|
+
let trackEl: HTMLDivElement | null = $state(null);
|
|
77
|
+
let dialEl: HTMLDivElement | null = $state(null);
|
|
78
|
+
|
|
79
|
+
let gradient = $derived(safeState.gradient);
|
|
80
|
+
let selectedStopId = $derived(safeState.selectedStopId);
|
|
81
|
+
let isDual = $derived(controlledDual ?? isDualGradient(gradient));
|
|
82
|
+
|
|
83
|
+
let selectedStop = $derived(
|
|
84
|
+
gradient.stops.find((s) => s.id === selectedStopId) ?? gradient.stops[0]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
let previewCss = $derived(
|
|
88
|
+
isDual ? gradientToCssFallback(gradient, 16, activeScheme) : gradientToCss(gradient)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
let trackCss = $derived(gradientTrackBackground(gradient));
|
|
92
|
+
|
|
93
|
+
const buttonClass =
|
|
94
|
+
'inline-flex h-7 items-center justify-center rounded border border-border bg-background px-2 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40 aria-pressed:bg-primary aria-pressed:text-primary-foreground';
|
|
95
|
+
const selectClass =
|
|
96
|
+
'h-7 rounded border border-border bg-background px-2 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40';
|
|
97
|
+
const inputClass =
|
|
98
|
+
'h-7 w-16 rounded border border-border bg-background px-1.5 text-center text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40';
|
|
99
|
+
|
|
100
|
+
function handleTrackClick(e: MouseEvent) {
|
|
101
|
+
if (controller.isReadOnly || !trackEl) return;
|
|
102
|
+
const rect = trackEl.getBoundingClientRect();
|
|
103
|
+
if (rect.width <= 0) return;
|
|
104
|
+
const offset = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
105
|
+
controller.addStop(offset);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleStopPointerDown(e: PointerEvent, stopId: string) {
|
|
109
|
+
if (controller.isReadOnly) return;
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
controller.selectStop(stopId);
|
|
112
|
+
beginGesture('Move Stop');
|
|
113
|
+
|
|
114
|
+
const startY = e.clientY;
|
|
115
|
+
const rect = trackEl?.getBoundingClientRect();
|
|
116
|
+
|
|
117
|
+
const onPointerMove = (moveEvent: PointerEvent) => {
|
|
118
|
+
if (!rect || rect.width <= 0) return;
|
|
119
|
+
if (moveEvent.clientY - startY > 32 && controller.getGradient().stops.length > 2) {
|
|
120
|
+
cleanup();
|
|
121
|
+
controller.removeStop(stopId);
|
|
122
|
+
endGesture();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const nextOffset = Math.max(0, Math.min(1, (moveEvent.clientX - rect.left) / rect.width));
|
|
126
|
+
controller.updateStop(stopId, { offset: Math.round(nextOffset * 1000) / 1000 });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const cleanup = () => {
|
|
130
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
131
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
132
|
+
window.removeEventListener('pointercancel', onPointerUp);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onPointerUp = () => {
|
|
136
|
+
cleanup();
|
|
137
|
+
endGesture();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
141
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
142
|
+
window.addEventListener('pointercancel', onPointerUp);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleStopKeyDown(e: KeyboardEvent, stopId: string) {
|
|
146
|
+
if (controller.isReadOnly) return;
|
|
147
|
+
const res = gradientKeyStep(gradient, stopId, e.key, e.shiftKey);
|
|
148
|
+
if (!res) return;
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
if (res.remove) {
|
|
151
|
+
if (gradient.stops.length > 2) {
|
|
152
|
+
controller.removeStop(stopId);
|
|
153
|
+
}
|
|
154
|
+
} else if (res.offset !== undefined) {
|
|
155
|
+
controller.updateStop(stopId, { offset: res.offset });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function handleAngleDialPointerDown(e: PointerEvent) {
|
|
160
|
+
if (controller.isReadOnly || !dialEl) return;
|
|
161
|
+
beginGesture('Change Angle');
|
|
162
|
+
const rect = dialEl.getBoundingClientRect();
|
|
163
|
+
const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
164
|
+
|
|
165
|
+
const update = (pe: PointerEvent) => {
|
|
166
|
+
const deg = angleFromPointer(center, { x: pe.clientX, y: pe.clientY }, pe.shiftKey ? 15 : 1);
|
|
167
|
+
controller.setAngle(deg);
|
|
168
|
+
};
|
|
169
|
+
update(e);
|
|
170
|
+
|
|
171
|
+
const cleanup = () => {
|
|
172
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
173
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
174
|
+
window.removeEventListener('pointercancel', onPointerUp);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const onPointerMove = (pe: PointerEvent) => update(pe);
|
|
178
|
+
const onPointerUp = () => {
|
|
179
|
+
cleanup();
|
|
180
|
+
endGesture();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
184
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
185
|
+
window.addEventListener('pointercancel', onPointerUp);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function copyCss() {
|
|
189
|
+
const text = gradientToCss(gradient);
|
|
190
|
+
navigator.clipboard?.writeText(text).then(() => {
|
|
191
|
+
copied = true;
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
copied = false;
|
|
194
|
+
}, 1500);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
</script>
|
|
198
|
+
|
|
199
|
+
<div
|
|
200
|
+
class="flex flex-col gap-3 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-sm {klass} {className}"
|
|
201
|
+
data-testid="gradient-editor"
|
|
202
|
+
>
|
|
203
|
+
<!-- Preview box -->
|
|
204
|
+
<div class="relative h-20 w-full overflow-hidden rounded-md border border-border">
|
|
205
|
+
<div
|
|
206
|
+
class="absolute inset-0"
|
|
207
|
+
style="background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%); background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0px;"
|
|
208
|
+
></div>
|
|
209
|
+
<div
|
|
210
|
+
class="absolute inset-0 transition-all"
|
|
211
|
+
style="background: {previewCss}"
|
|
212
|
+
data-testid="gradient-preview"
|
|
213
|
+
></div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<!-- Stop Track -->
|
|
217
|
+
<div class="flex flex-col gap-1">
|
|
218
|
+
<div
|
|
219
|
+
bind:this={trackEl}
|
|
220
|
+
role="button"
|
|
221
|
+
tabindex={0}
|
|
222
|
+
aria-label="Add gradient stop"
|
|
223
|
+
class="relative h-6 w-full cursor-pointer rounded border border-border"
|
|
224
|
+
style="background: {trackCss}"
|
|
225
|
+
onclick={handleTrackClick}
|
|
226
|
+
onkeydown={(e) => e.key === 'Enter' && handleTrackClick(e as any)}
|
|
227
|
+
data-testid="gradient-track"
|
|
228
|
+
>
|
|
229
|
+
{#each gradient.stops as stop, idx (stop.id)}
|
|
230
|
+
{@const isSelected = stop.id === selectedStopId}
|
|
231
|
+
{@const pct = Math.round(stop.offset * 100)}
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
role="slider"
|
|
235
|
+
aria-label="Stop {idx + 1}, {stop.color} at {pct}%"
|
|
236
|
+
aria-valuemin={0}
|
|
237
|
+
aria-valuemax={100}
|
|
238
|
+
aria-valuenow={pct}
|
|
239
|
+
aria-current={isSelected ? 'true' : undefined}
|
|
240
|
+
disabled={controller.isReadOnly}
|
|
241
|
+
class="absolute top-1/2 h-6 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded border border-white shadow-sm transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary {isSelected
|
|
242
|
+
? 'scale-110 ring-2 ring-primary z-10'
|
|
243
|
+
: 'hover:scale-105'}"
|
|
244
|
+
style="left: {stop.offset * 100}%; background-color: {stop.color.startsWith(
|
|
245
|
+
'light-dark('
|
|
246
|
+
)
|
|
247
|
+
? '#888'
|
|
248
|
+
: stop.color}"
|
|
249
|
+
onclick={(e) => {
|
|
250
|
+
e.stopPropagation();
|
|
251
|
+
controller.selectStop(stop.id);
|
|
252
|
+
}}
|
|
253
|
+
onpointerdown={(e) => handleStopPointerDown(e, stop.id)}
|
|
254
|
+
onkeydown={(e) => handleStopKeyDown(e, stop.id)}
|
|
255
|
+
data-testid="gradient-stop-{stop.id}"
|
|
256
|
+
></button>
|
|
257
|
+
{/each}
|
|
258
|
+
</div>
|
|
259
|
+
<div class="flex justify-between px-0.5 text-[10px] text-muted-foreground">
|
|
260
|
+
<span>0%</span>
|
|
261
|
+
<span>50%</span>
|
|
262
|
+
<span>100%</span>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<!-- Geometry and Types toolbar -->
|
|
267
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
268
|
+
{#if types.length > 1}
|
|
269
|
+
<div class="flex rounded-md border border-border p-0.5">
|
|
270
|
+
{#each types as t}
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
class={buttonClass}
|
|
274
|
+
aria-pressed={gradient.type === t}
|
|
275
|
+
disabled={controller.isReadOnly}
|
|
276
|
+
onclick={() => controller.setType(t)}
|
|
277
|
+
>
|
|
278
|
+
{t.charAt(0).toUpperCase() + t.slice(1)}
|
|
279
|
+
</button>
|
|
280
|
+
{/each}
|
|
281
|
+
</div>
|
|
282
|
+
{/if}
|
|
283
|
+
|
|
284
|
+
{#if gradient.type === 'linear' || gradient.type === 'conic'}
|
|
285
|
+
<div class="flex items-center gap-1.5">
|
|
286
|
+
<div
|
|
287
|
+
bind:this={dialEl}
|
|
288
|
+
role="slider"
|
|
289
|
+
tabindex={0}
|
|
290
|
+
aria-label="Gradient angle"
|
|
291
|
+
aria-valuemin={0}
|
|
292
|
+
aria-valuemax={360}
|
|
293
|
+
aria-valuenow={gradient.angle ?? 180}
|
|
294
|
+
class="relative h-7 w-7 cursor-crosshair rounded-full border border-border bg-muted/40 shadow-inner flex items-center justify-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
|
295
|
+
onpointerdown={handleAngleDialPointerDown}
|
|
296
|
+
>
|
|
297
|
+
<div
|
|
298
|
+
class="h-2.5 w-0.5 rounded-full bg-foreground"
|
|
299
|
+
style="transform: rotate({gradient.angle ??
|
|
300
|
+
180}deg) translateY(-50%); transform-origin: 50% 100%;"
|
|
301
|
+
></div>
|
|
302
|
+
</div>
|
|
303
|
+
<input
|
|
304
|
+
type="number"
|
|
305
|
+
aria-label="Angle in degrees"
|
|
306
|
+
class={inputClass}
|
|
307
|
+
min={0}
|
|
308
|
+
max={360}
|
|
309
|
+
value={Math.round(gradient.angle ?? 180)}
|
|
310
|
+
disabled={controller.isReadOnly}
|
|
311
|
+
oninput={(e) => controller.setAngle(Number(e.currentTarget.value) % 360)}
|
|
312
|
+
/>
|
|
313
|
+
<span class="text-xs text-muted-foreground">°</span>
|
|
314
|
+
</div>
|
|
315
|
+
{/if}
|
|
316
|
+
|
|
317
|
+
{#if gradient.type === 'radial'}
|
|
318
|
+
<div class="flex items-center gap-1">
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
class={buttonClass}
|
|
322
|
+
aria-pressed={(gradient.shape ?? 'ellipse') === 'circle'}
|
|
323
|
+
disabled={controller.isReadOnly}
|
|
324
|
+
onclick={() =>
|
|
325
|
+
controller.setShape(
|
|
326
|
+
(gradient.shape ?? 'ellipse') === 'circle' ? 'ellipse' : 'circle'
|
|
327
|
+
)}
|
|
328
|
+
>
|
|
329
|
+
{(gradient.shape ?? 'ellipse') === 'circle' ? 'Circle' : 'Ellipse'}
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
332
|
+
{/if}
|
|
333
|
+
|
|
334
|
+
<select
|
|
335
|
+
aria-label="Interpolation space"
|
|
336
|
+
class={selectClass}
|
|
337
|
+
value={gradient.interpolation ?? 'oklab'}
|
|
338
|
+
disabled={controller.isReadOnly}
|
|
339
|
+
onchange={(e) =>
|
|
340
|
+
controller.setInterpolation(e.currentTarget.value as GradientInterpolation)}
|
|
341
|
+
>
|
|
342
|
+
<option value="oklab">oklab</option>
|
|
343
|
+
<option value="oklch">oklch</option>
|
|
344
|
+
<option value="srgb">srgb</option>
|
|
345
|
+
<option value="srgb-linear">srgb-linear</option>
|
|
346
|
+
</select>
|
|
347
|
+
|
|
348
|
+
<div class="flex items-center gap-1 ml-auto">
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
class={buttonClass}
|
|
352
|
+
title="Reverse stops"
|
|
353
|
+
aria-label="Reverse stops"
|
|
354
|
+
disabled={controller.isReadOnly}
|
|
355
|
+
onclick={() => controller.reverse()}
|
|
356
|
+
>
|
|
357
|
+
⇄
|
|
358
|
+
</button>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
class={buttonClass}
|
|
362
|
+
title="Distribute stops evenly"
|
|
363
|
+
aria-label="Distribute stops evenly"
|
|
364
|
+
disabled={controller.isReadOnly}
|
|
365
|
+
onclick={() => controller.distribute()}
|
|
366
|
+
>
|
|
367
|
+
⫶⫶
|
|
368
|
+
</button>
|
|
369
|
+
{#if gradient.stops.length > 2 && selectedStop}
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
class={buttonClass}
|
|
373
|
+
title="Delete selected stop"
|
|
374
|
+
aria-label="Delete selected stop"
|
|
375
|
+
disabled={controller.isReadOnly}
|
|
376
|
+
onclick={() => controller.removeStop(selectedStop.id)}
|
|
377
|
+
>
|
|
378
|
+
✕
|
|
379
|
+
</button>
|
|
380
|
+
{/if}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<!-- Selected stop colour editor -->
|
|
385
|
+
{#if selectedStop}
|
|
386
|
+
<div class="rounded-md border border-border p-2">
|
|
387
|
+
<div class="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
|
388
|
+
<span>Selected Stop ({Math.round(selectedStop.offset * 100)}%)</span>
|
|
389
|
+
<div class="flex items-center gap-1">
|
|
390
|
+
<span>Offset:</span>
|
|
391
|
+
<input
|
|
392
|
+
type="number"
|
|
393
|
+
aria-label="Stop offset percentage"
|
|
394
|
+
class={inputClass}
|
|
395
|
+
min={0}
|
|
396
|
+
max={100}
|
|
397
|
+
value={Math.round(selectedStop.offset * 100)}
|
|
398
|
+
disabled={controller.isReadOnly}
|
|
399
|
+
oninput={(e) =>
|
|
400
|
+
controller.updateStop(selectedStop.id, {
|
|
401
|
+
offset: Math.max(0, Math.min(100, Number(e.currentTarget.value))) / 100
|
|
402
|
+
})}
|
|
403
|
+
/>
|
|
404
|
+
<span>%</span>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
{#if isDual}
|
|
409
|
+
<DualColorPicker
|
|
410
|
+
value={selectedStop.color}
|
|
411
|
+
scheme={activeScheme}
|
|
412
|
+
onschemechange={(s) => (activeScheme = s)}
|
|
413
|
+
onchange={(dualColor) => {
|
|
414
|
+
controller.updateStop(selectedStop.id, {
|
|
415
|
+
color: formatDualColor(dualColor)
|
|
416
|
+
});
|
|
417
|
+
}}
|
|
418
|
+
ongesturestart={() => beginGesture('Change Stop Color')}
|
|
419
|
+
ongestureend={() => endGesture()}
|
|
420
|
+
disabled={controller.isReadOnly}
|
|
421
|
+
/>
|
|
422
|
+
{:else}
|
|
423
|
+
<ColorPicker
|
|
424
|
+
value={selectedStop.color}
|
|
425
|
+
alpha={true}
|
|
426
|
+
onchange={(color) => {
|
|
427
|
+
controller.updateStop(selectedStop.id, { color });
|
|
428
|
+
}}
|
|
429
|
+
ongesturestart={() => beginGesture('Change Stop Color')}
|
|
430
|
+
ongestureend={() => endGesture()}
|
|
431
|
+
disabled={controller.isReadOnly}
|
|
432
|
+
/>
|
|
433
|
+
{/if}
|
|
434
|
+
</div>
|
|
435
|
+
{/if}
|
|
436
|
+
|
|
437
|
+
<!-- Presets -->
|
|
438
|
+
{#if showPresets}
|
|
439
|
+
<div class="flex flex-col gap-1">
|
|
440
|
+
<span class="text-[11px] font-medium text-muted-foreground">Presets</span>
|
|
441
|
+
<div class="flex flex-wrap gap-1.5" data-testid="gradient-presets">
|
|
442
|
+
{#each Object.entries(GRADIENT_PRESETS) as [name, preset] (name)}
|
|
443
|
+
<button
|
|
444
|
+
type="button"
|
|
445
|
+
class="h-6 w-10 rounded border border-border shadow-sm transition hover:scale-105 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
|
446
|
+
style="background: {gradientToCss(preset)}"
|
|
447
|
+
title={name}
|
|
448
|
+
aria-label="Preset {name}"
|
|
449
|
+
disabled={controller.isReadOnly}
|
|
450
|
+
onclick={() => controller.setGradient(preset, `Apply Preset ${name}`)}
|
|
451
|
+
></button>
|
|
452
|
+
{/each}
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
{/if}
|
|
456
|
+
|
|
457
|
+
<!-- CSS Code Output -->
|
|
458
|
+
{#if showCss}
|
|
459
|
+
<div class="flex flex-col gap-1 border-t border-border pt-2">
|
|
460
|
+
<div class="flex items-center justify-between">
|
|
461
|
+
<span class="text-[11px] font-medium text-muted-foreground">CSS Code</span>
|
|
462
|
+
<button type="button" class={buttonClass} onclick={copyCss}>
|
|
463
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
464
|
+
</button>
|
|
465
|
+
</div>
|
|
466
|
+
<pre
|
|
467
|
+
class="overflow-x-auto rounded bg-muted p-2 font-mono text-[11px] text-foreground select-all">{gradientToCss(
|
|
468
|
+
gradient
|
|
469
|
+
)}</pre>
|
|
470
|
+
</div>
|
|
471
|
+
{/if}
|
|
472
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type GradientController, type GradientType } from '@pixerate/editor/gradient';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Required controller that manages gradient data and history. */
|
|
4
|
+
controller: GradientController;
|
|
5
|
+
/** Allowed gradient types to switch between (default: ['linear', 'radial', 'conic']). */
|
|
6
|
+
types?: readonly GradientType[];
|
|
7
|
+
/** Whether to display preset gradient buttons (default: true). */
|
|
8
|
+
showPresets?: boolean;
|
|
9
|
+
/** Whether to display CSS code with copy button (default: false). */
|
|
10
|
+
showCss?: boolean;
|
|
11
|
+
/** Whether stops support dual light/dark colour definitions (default: false or auto-detected). */
|
|
12
|
+
dual?: boolean;
|
|
13
|
+
/** Called when a continuous edit (stop drag, angle drag, colour drag) starts. */
|
|
14
|
+
ongesturestart?: () => void;
|
|
15
|
+
/** Called when that edit ends. */
|
|
16
|
+
ongestureend?: () => void;
|
|
17
|
+
/** Additional CSS class name. */
|
|
18
|
+
class?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
declare const GradientEditor: import("svelte").Component<Props, {}, "">;
|
|
22
|
+
type GradientEditor = ReturnType<typeof GradientEditor>;
|
|
23
|
+
export default GradientEditor;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { GradientController, type GradientControllerOptions, type GradientState } from '@pixerate/editor/gradient';
|
|
2
|
+
export interface SvelteGradient {
|
|
3
|
+
readonly controller: GradientController;
|
|
4
|
+
/** The controller's state (reactive). */
|
|
5
|
+
readonly state: GradientState;
|
|
6
|
+
/** Stops listening to the controller. */
|
|
7
|
+
destroy(): void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Svelte 5 rune: subscribes to a `GradientController` (or creates one from options)
|
|
11
|
+
* and exposes its state reactively.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createGradientController(controllerOrOptions?: GradientController | GradientControllerOptions): SvelteGradient;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { GradientController } from '@pixerate/editor/gradient';
|
|
2
|
+
/**
|
|
3
|
+
* Svelte 5 rune: subscribes to a `GradientController` (or creates one from options)
|
|
4
|
+
* and exposes its state reactively.
|
|
5
|
+
*/
|
|
6
|
+
export function createGradientController(controllerOrOptions) {
|
|
7
|
+
const controller = controllerOrOptions instanceof GradientController
|
|
8
|
+
? controllerOrOptions
|
|
9
|
+
: new GradientController(controllerOrOptions);
|
|
10
|
+
let state = $state.raw(controller.state);
|
|
11
|
+
const unsubscribe = controller.subscribe((next) => {
|
|
12
|
+
state = next;
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
controller,
|
|
16
|
+
get state() {
|
|
17
|
+
return state;
|
|
18
|
+
},
|
|
19
|
+
destroy: unsubscribe
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ComponentProps } from 'svelte';
|
|
2
|
+
import type GradientEditorComponent from './GradientEditor.svelte';
|
|
3
|
+
import type FillPickerComponent from './FillPicker.svelte';
|
|
4
|
+
export { default as GradientEditor } from './GradientEditor.svelte';
|
|
5
|
+
export { default as FillPicker } from './FillPicker.svelte';
|
|
6
|
+
export type { FillMode } from './types.js';
|
|
7
|
+
export type GradientEditorProps = ComponentProps<typeof GradientEditorComponent>;
|
|
8
|
+
export type FillPickerProps = ComponentProps<typeof FillPickerComponent>;
|
|
9
|
+
export { createGradientController, type SvelteGradient } from './gradientController.svelte.js';
|
|
10
|
+
export * from '@pixerate/editor/gradient';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as GradientEditor } from './GradientEditor.svelte';
|
|
2
|
+
export { default as FillPicker } from './FillPicker.svelte';
|
|
3
|
+
export { createGradientController } from './gradientController.svelte.js';
|
|
4
|
+
// Re-export core gradient module for consumer convenience
|
|
5
|
+
export * from '@pixerate/editor/gradient';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|