@pixerate/editor-svelte 0.104.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 +3 -1
- package/dist/canvas/createMermaidDiagram.svelte.d.ts +7 -1
- package/dist/canvas/createMermaidDiagram.svelte.js +7 -2
- 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/dist/persistence/editorAutosave.svelte.d.ts +17 -0
- package/dist/persistence/editorAutosave.svelte.js +40 -0
- package/dist/persistence/index.d.ts +1 -0
- package/dist/persistence/index.js +1 -0
- package/package.json +18 -2
package/README.md
CHANGED
|
@@ -82,11 +82,13 @@ Tables are on by default in `createRichTextPreset` (`table: false` turns them of
|
|
|
82
82
|
| `@pixerate/editor-svelte/image-editor` | `ImageEditor`, `createReactiveImageEditor`: crop, rotate/flip, adjustments, annotations, depth masks, Select & Mask (`SelectionOptionsBar`, `RefineEdgePanel`: magic wand, marquees, lasso, Refine Edge, layer masks), and `onInpaint` / `onRemoveBackground` AI hooks |
|
|
83
83
|
| `@pixerate/editor-svelte/paint-editor` | `PaintEditor`, `createReactivePaintEditor`: natural-media painting with `PaintCanvas` (pen pressure, tilt and twist, coalesced and predicted events, palm rejection, view rotation and mirroring), `PaintToolbar` (tools, symmetry, perspective, tracing and reference images, save / open, PNG / JPEG / PSD), `BrushPanel` (library with thumbnails), `PaperPanel`, `ColorPanel` (wheel, harmonies, swatches, mixing pad) and `PaintLayersPanel` |
|
|
84
84
|
| `@pixerate/editor-svelte/commands` | `CommandPalette`, `createCommandPalette` (`hotkey: "Mod+K"`; call `destroy()` when done), `createCommandShortcuts`, `useRegisterCommands` over a `CommandRegistry` from `@pixerate/editor/commands` (also re-exported from the root entry) |
|
|
85
|
-
| `@pixerate/editor-svelte/persistence` | `createAutosave` (crash-recovery snapshots bound to a history / dirty flag, flushed on `pagehide`; call `destroy()` when done) and `<RecoveryPrompt>` (also re-exported from the root entry) |
|
|
85
|
+
| `@pixerate/editor-svelte/persistence` | `createAutosave` (crash-recovery snapshots bound to a history / dirty flag, flushed on `pagehide`; call `destroy()` when done), `createEditorAutosave` (the same for any editor's `EditorDocument`, e.g. `createMermaidDiagram().editorDocument`; `restore` loads the snapshot into the editor) and `<RecoveryPrompt>` (also re-exported from the root entry) |
|
|
86
86
|
| `@pixerate/editor-svelte/generation` | `GenerationSettings` (controls generated from a model's capabilities, reference slots by role, plan adjustments; `onchange` / `onaddreference`), `createGenerationPlan(() => request, () => caps)`, `JobProgress`, `createJobProgress` (call `destroy()` when done) over `@pixerate/editor/generation` (also re-exported from the root entry) |
|
|
87
87
|
| `@pixerate/editor-svelte/vector` | `VectorEditor` (toolbar, canvas and style panel), `VectorCanvas` (SVG canvas forwarding pointer, wheel, keyboard and clipboard events), `VectorToolbar`, `VectorStylePanel`, `VectorSceneNodes`, `VectorGradientDefs`, `createVectorEditor` (call `destroy()` when done) and `useVectorCommands` over `@pixerate/editor/vector` (see [the vector editor guide](../../docs/vector-editor.md)) |
|
|
88
88
|
| `@pixerate/editor-svelte/chart` | `ChartEditor` (toolbar, chart, inspector and data grid), `ChartCanvas` (SVG chart with selection, drag-to-edit and keyboard editing), `ChartDataGrid`, `ChartInspector`, `createChartEditor` (call `destroy()` when done) and `useChartCommands` over `@pixerate/editor/chart` (see [the chart editor guide](../../docs/chart-editor.md)) |
|
|
89
89
|
| `@pixerate/editor-svelte/parameters` | `ParametersPanel` (a whole schema with groups, presets, undo / reset / randomize and keyframe diamonds), `createParameters` (call `destroy()` when done), `useParameterCommands`, and the field controls `ParameterFieldControl`, `ParameterSlider`, `ParameterToggle`, `ParameterSelect`, `ParameterColor`, `ParameterVector`, `ParameterImage`, `ParameterText`, `KeyframeDiamond` over `@pixerate/editor/parameters` (see [the parameters guide](../../docs/parameters.md)) |
|
|
90
|
+
| `@pixerate/editor-svelte/color` | `ColorPicker` (interactive hex/rgb/hsl/oklch picker with alpha, swatches, eyedropper, recent history), `DualColorPicker` (dual light/dark mode color picker with theme preview and switch), `createColorScheme` / `resolvedColor` (the active scheme from the system, a root attribute or an override) |
|
|
91
|
+
| `@pixerate/editor-svelte/gradient` | `GradientEditor` (interactive stops bar, midpoints, presets, angle dial, type selector, OKLab/OKLCH/sRGB/linear-sRGB interpolation, CSS output), `FillPicker` (controlled solid-or-gradient fill picker), `createGradientController` |
|
|
90
92
|
| `@pixerate/editor-svelte/canvas` | Runes for `@xyflow/svelte`: `createCanvasGraph`, `createCanvasClipboard`, `createCanvasDocking`, `createCanvasShortcuts`, `createCanvasInteractions`, `createCanvasExplosion`, `createCanvasNodeSync`, `createCanvasMultiDrag`, `createStreamingCanvas`, `AgentPresenceLayer`, `FloatingHorizontalScrollbar`, plus layout helpers |
|
|
91
93
|
|
|
92
94
|
Spreadsheet persistence: `createReactiveSpreadsheet` exposes `document` (and passes it to `onDocumentChange`) with the defined names its formulas use in `document.names`, so saving that document and passing it back as `document` restores workbook, sheet-scoped and LAMBDA names. For several sheets, save `controller.getWorkbook()` instead.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { DiagramController, type DiagramActionResult, type DiagramControllerOptions, type DiagramState, type EditOp, type MermaidDocument } from '@pixerate/editor/diagram';
|
|
1
|
+
import { DiagramController, type DiagramActionResult, type DiagramControllerOptions, type DiagramState, type EditOp, type DiagramEditorDocument, type MermaidDocument } from '@pixerate/editor/diagram';
|
|
2
|
+
import type { EditorDocument } from '@pixerate/editor/persistence';
|
|
2
3
|
import { type DiagramLayout, type DiagramLayoutOptions, type DiagramScene, type DiagramSceneTheme, type MermaidFlowEdge, type MermaidFlowNode } from '@pixerate/editor/canvas';
|
|
3
4
|
export interface CreateMermaidDiagramOptions extends DiagramControllerOptions {
|
|
4
5
|
/** Layout spacing and node measuring for new nodes (stored positions always win). */
|
|
@@ -16,6 +17,11 @@ type Placed = {
|
|
|
16
17
|
};
|
|
17
18
|
export interface SvelteMermaidDiagram {
|
|
18
19
|
readonly controller: DiagramController;
|
|
20
|
+
/**
|
|
21
|
+
* The save / load / undo contract for this diagram: pass it to `createEditorAutosave`
|
|
22
|
+
* (`@pixerate/editor-svelte/persistence`) or `createDocumentToolProvider`.
|
|
23
|
+
*/
|
|
24
|
+
readonly editorDocument: EditorDocument<DiagramEditorDocument>;
|
|
19
25
|
/** The controller's state (reactive). */
|
|
20
26
|
readonly state: DiagramState;
|
|
21
27
|
readonly document: MermaidDocument;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DiagramController } from '@pixerate/editor/diagram';
|
|
1
|
+
import { DiagramController, createDiagramEditorDocument } from '@pixerate/editor/diagram';
|
|
2
2
|
import { CSS_VARIABLE_DIAGRAM_SCENE_THEME, DiagramStream, blendDiagramLayout, diagramRenameOp, familySceneFromLayout, easeDiagramTween, mermaidMovesFromCanvas, mermaidToCanvas } from '@pixerate/editor/canvas';
|
|
3
3
|
/**
|
|
4
4
|
* Svelte 5 rune: a Mermaid diagram on a Svelte Flow canvas. Subscribes to a
|
|
@@ -19,8 +19,10 @@ view = {}) {
|
|
|
19
19
|
const unsubscribe = controller.subscribe((next) => {
|
|
20
20
|
state = next;
|
|
21
21
|
});
|
|
22
|
+
const editorDocument = createDiagramEditorDocument(controller);
|
|
22
23
|
return {
|
|
23
24
|
controller,
|
|
25
|
+
editorDocument,
|
|
24
26
|
get state() {
|
|
25
27
|
return state;
|
|
26
28
|
},
|
|
@@ -68,7 +70,10 @@ view = {}) {
|
|
|
68
70
|
select: (ids) => controller.select(ids),
|
|
69
71
|
undo: () => void controller.undo(),
|
|
70
72
|
redo: () => void controller.redo(),
|
|
71
|
-
destroy:
|
|
73
|
+
destroy: () => {
|
|
74
|
+
unsubscribe();
|
|
75
|
+
editorDocument.destroy();
|
|
76
|
+
}
|
|
72
77
|
};
|
|
73
78
|
}
|
|
74
79
|
/**
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, untrack } from 'svelte';
|
|
3
|
+
import {
|
|
4
|
+
type ColorPickerState,
|
|
5
|
+
contrastRatio,
|
|
6
|
+
formatHex,
|
|
7
|
+
inSrgbGamut,
|
|
8
|
+
parseColor,
|
|
9
|
+
pickerAlphaBackground,
|
|
10
|
+
pickerAreaBackground,
|
|
11
|
+
pickerKeyStep,
|
|
12
|
+
pickerStateFromAlpha,
|
|
13
|
+
pickerStateFromArea,
|
|
14
|
+
pickerStateFromColor,
|
|
15
|
+
pickerStateFromHue,
|
|
16
|
+
pickerStateToHex,
|
|
17
|
+
pushRecentColor,
|
|
18
|
+
PICKER_HUE_BACKGROUND
|
|
19
|
+
} from '@pixerate/editor/color';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** The current colour value (any valid CSS colour string or hex). */
|
|
23
|
+
value: string;
|
|
24
|
+
/** Called when the colour value changes. */
|
|
25
|
+
onchange?: (value: string) => void;
|
|
26
|
+
onChange?: (value: string) => void;
|
|
27
|
+
/** Called when a continuous pointer drag or keyboard interaction starts. */
|
|
28
|
+
ongesturestart?: () => void;
|
|
29
|
+
onGestureStart?: () => void;
|
|
30
|
+
/** Called when a continuous pointer drag or keyboard interaction ends. */
|
|
31
|
+
ongestureend?: () => void;
|
|
32
|
+
onGestureEnd?: () => void;
|
|
33
|
+
/** Whether the alpha channel slider is shown and alpha hex values emitted. Default: false. */
|
|
34
|
+
alpha?: boolean;
|
|
35
|
+
/** Palette swatches shown beneath the sliders. */
|
|
36
|
+
swatches?: readonly string[];
|
|
37
|
+
/** Controlled recent colours. If omitted, internal recents persisted in localStorage are used. */
|
|
38
|
+
recent?: readonly string[];
|
|
39
|
+
/** Called when the list of recent colours changes. */
|
|
40
|
+
onrecentchange?: (recent: readonly string[]) => void;
|
|
41
|
+
onRecentChange?: (recent: readonly string[]) => void;
|
|
42
|
+
/** If provided, renders live WCAG contrast ratio badges against this background colour. */
|
|
43
|
+
contrastAgainst?: string;
|
|
44
|
+
/** Whether the picker is disabled. */
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
/** Additional CSS class names for the outer container. */
|
|
47
|
+
class?: string;
|
|
48
|
+
className?: string;
|
|
49
|
+
/** Optional DOM id for the container. */
|
|
50
|
+
id?: string;
|
|
51
|
+
/** Accessible label for the colour picker group. */
|
|
52
|
+
'aria-label'?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let {
|
|
56
|
+
value,
|
|
57
|
+
onchange,
|
|
58
|
+
onChange,
|
|
59
|
+
ongesturestart,
|
|
60
|
+
onGestureStart,
|
|
61
|
+
ongestureend,
|
|
62
|
+
onGestureEnd,
|
|
63
|
+
alpha = false,
|
|
64
|
+
swatches,
|
|
65
|
+
recent,
|
|
66
|
+
onrecentchange,
|
|
67
|
+
onRecentChange,
|
|
68
|
+
contrastAgainst,
|
|
69
|
+
disabled = false,
|
|
70
|
+
class: classProp,
|
|
71
|
+
className,
|
|
72
|
+
id,
|
|
73
|
+
'aria-label': ariaLabel = 'Color picker'
|
|
74
|
+
}: Props = $props();
|
|
75
|
+
|
|
76
|
+
const LOCAL_STORAGE_KEY = 'pixerate:color:recent';
|
|
77
|
+
|
|
78
|
+
function readLocalRecents(): string[] {
|
|
79
|
+
try {
|
|
80
|
+
if (typeof localStorage === 'undefined') return [];
|
|
81
|
+
const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
|
|
82
|
+
if (!raw) return [];
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeLocalRecents(list: readonly string[]): void {
|
|
91
|
+
try {
|
|
92
|
+
if (typeof localStorage !== 'undefined') {
|
|
93
|
+
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(list));
|
|
94
|
+
}
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Transparency checkerboard; themeable through `--pixerate-checker-a` / `--pixerate-checker-b`. */
|
|
99
|
+
const CHECKERBOARD_BG =
|
|
100
|
+
'repeating-conic-gradient(var(--pixerate-checker-a, #d4d4d8) 0 25%, var(--pixerate-checker-b, #ffffff) 0 50%)';
|
|
101
|
+
|
|
102
|
+
const DEFAULT_STATE: ColorPickerState = { h: 0, s: 1, v: 1, alpha: 1 };
|
|
103
|
+
|
|
104
|
+
let pickerState = $state<ColorPickerState>(
|
|
105
|
+
untrack(() => pickerStateFromColor(value)) ?? DEFAULT_STATE
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Synchronise pickerState when external value changes
|
|
109
|
+
$effect(() => {
|
|
110
|
+
const prev = untrack(() => pickerState);
|
|
111
|
+
const next = pickerStateFromColor(value, prev);
|
|
112
|
+
if (next) {
|
|
113
|
+
pickerState = next;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Callbacks dispatcher
|
|
118
|
+
function emitChange(nextVal: string) {
|
|
119
|
+
onchange?.(nextVal);
|
|
120
|
+
onChange?.(nextVal);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function emitGestureStart() {
|
|
124
|
+
ongesturestart?.();
|
|
125
|
+
onGestureStart?.();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function emitGestureEnd() {
|
|
129
|
+
ongestureend?.();
|
|
130
|
+
onGestureEnd?.();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function emitRecentChange(recents: readonly string[]) {
|
|
134
|
+
onrecentchange?.(recents);
|
|
135
|
+
onRecentChange?.(recents);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Recents management
|
|
139
|
+
let internalRecent = $state<string[]>(readLocalRecents());
|
|
140
|
+
const activeRecent = $derived(recent ?? internalRecent);
|
|
141
|
+
|
|
142
|
+
function recordRecent(color: string) {
|
|
143
|
+
const updated = pushRecentColor(activeRecent, color, 12);
|
|
144
|
+
if (!recent) {
|
|
145
|
+
internalRecent = updated;
|
|
146
|
+
writeLocalRecents(updated);
|
|
147
|
+
}
|
|
148
|
+
emitRecentChange(updated);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Derived styling & formatting
|
|
152
|
+
const currentHex = $derived(pickerStateToHex(pickerState, alpha));
|
|
153
|
+
const areaBg = $derived(pickerAreaBackground(pickerState));
|
|
154
|
+
const alphaBg = $derived(pickerAlphaBackground(pickerState));
|
|
155
|
+
|
|
156
|
+
// Contrast derived
|
|
157
|
+
const contrastResult = $derived.by(() => {
|
|
158
|
+
if (!contrastAgainst) return null;
|
|
159
|
+
const fg = parseColor(value);
|
|
160
|
+
const bg = parseColor(contrastAgainst);
|
|
161
|
+
if (!fg || !bg) return null;
|
|
162
|
+
const ratio = contrastRatio(fg, bg);
|
|
163
|
+
return {
|
|
164
|
+
ratio,
|
|
165
|
+
aa: ratio >= 4.5,
|
|
166
|
+
aaa: ratio >= 7
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const liveDescription = $derived(
|
|
171
|
+
`Selected color: ${value}${
|
|
172
|
+
contrastResult
|
|
173
|
+
? `, contrast ${contrastResult.ratio.toFixed(1)}:1 against ${contrastAgainst}, ${
|
|
174
|
+
contrastResult.aa ? 'passes AA' : 'fails AA'
|
|
175
|
+
}`
|
|
176
|
+
: ''
|
|
177
|
+
}`
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Text draft state
|
|
181
|
+
let textDraft = $state(untrack(() => value));
|
|
182
|
+
let isInvalid = $state(false);
|
|
183
|
+
let isClipped = $state(false);
|
|
184
|
+
let isTyping = false;
|
|
185
|
+
|
|
186
|
+
$effect(() => {
|
|
187
|
+
if (!isTyping) {
|
|
188
|
+
textDraft = value;
|
|
189
|
+
isInvalid = false;
|
|
190
|
+
const parsed = parseColor(value);
|
|
191
|
+
isClipped = Boolean(parsed && !inSrgbGamut(parsed));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
function commitTextInput(target?: HTMLInputElement) {
|
|
196
|
+
isTyping = false;
|
|
197
|
+
const parsed = parseColor(textDraft);
|
|
198
|
+
if (parsed) {
|
|
199
|
+
isInvalid = false;
|
|
200
|
+
isClipped = !inSrgbGamut(parsed);
|
|
201
|
+
const formatted = formatHex(parsed);
|
|
202
|
+
emitChange(formatted);
|
|
203
|
+
recordRecent(formatted);
|
|
204
|
+
} else {
|
|
205
|
+
isInvalid = true;
|
|
206
|
+
textDraft = value;
|
|
207
|
+
if (target) {
|
|
208
|
+
target.value = value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// EyeDropper API check
|
|
214
|
+
let hasEyeDropper = $state(false);
|
|
215
|
+
$effect(() => {
|
|
216
|
+
hasEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
async function handleEyeDropper() {
|
|
220
|
+
if (disabled || !hasEyeDropper) return;
|
|
221
|
+
try {
|
|
222
|
+
const EyeDropperClass = (window as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper;
|
|
223
|
+
const eyeDropper = new EyeDropperClass();
|
|
224
|
+
const res = await eyeDropper.open();
|
|
225
|
+
if (res?.sRGBHex) {
|
|
226
|
+
emitChange(res.sRGBHex);
|
|
227
|
+
recordRecent(res.sRGBHex);
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
// User cancelled
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Pointer drags: listeners live on window and are removed on pointerup, or on unmount,
|
|
235
|
+
// which also ends the host's gesture so it never stays open.
|
|
236
|
+
let endDrag: (() => void) | null = null;
|
|
237
|
+
onDestroy(() => endDrag?.());
|
|
238
|
+
|
|
239
|
+
function startDrag(
|
|
240
|
+
e: PointerEvent,
|
|
241
|
+
next: (state: ColorPickerState, x: number, y: number) => ColorPickerState
|
|
242
|
+
) {
|
|
243
|
+
if (disabled) return;
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
endDrag?.();
|
|
246
|
+
emitGestureStart();
|
|
247
|
+
|
|
248
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
249
|
+
const update = (clientX: number, clientY: number) => {
|
|
250
|
+
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
251
|
+
const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
|
252
|
+
pickerState = next(pickerState, x, y);
|
|
253
|
+
emitChange(pickerStateToHex(pickerState, alpha));
|
|
254
|
+
};
|
|
255
|
+
update(e.clientX, e.clientY);
|
|
256
|
+
|
|
257
|
+
const onPointerMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY);
|
|
258
|
+
const end = () => {
|
|
259
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
260
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
261
|
+
window.removeEventListener('pointercancel', onPointerUp);
|
|
262
|
+
endDrag = null;
|
|
263
|
+
emitGestureEnd();
|
|
264
|
+
};
|
|
265
|
+
const onPointerUp = () => {
|
|
266
|
+
end();
|
|
267
|
+
recordRecent(pickerStateToHex(pickerState, alpha));
|
|
268
|
+
};
|
|
269
|
+
endDrag = end;
|
|
270
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
271
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
272
|
+
window.addEventListener('pointercancel', onPointerUp);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const handleAreaPointerDown = (e: PointerEvent) =>
|
|
276
|
+
startDrag(e, (state, x, y) => pickerStateFromArea(state, x, y));
|
|
277
|
+
const handleHuePointerDown = (e: PointerEvent) => startDrag(e, (state, x) => pickerStateFromHue(state, x));
|
|
278
|
+
const handleAlphaPointerDown = (e: PointerEvent) =>
|
|
279
|
+
startDrag(e, (state, x) => pickerStateFromAlpha(state, x));
|
|
280
|
+
|
|
281
|
+
// Keyboard navigation
|
|
282
|
+
function handleAreaKeyDown(e: KeyboardEvent) {
|
|
283
|
+
if (disabled) return;
|
|
284
|
+
const next = pickerKeyStep(pickerState, 'area', e.key, e.shiftKey);
|
|
285
|
+
if (next) {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
pickerState = next;
|
|
288
|
+
emitChange(pickerStateToHex(next, alpha));
|
|
289
|
+
recordRecent(pickerStateToHex(next, alpha));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function handleHueKeyDown(e: KeyboardEvent) {
|
|
294
|
+
if (disabled) return;
|
|
295
|
+
const next = pickerKeyStep(pickerState, 'hue', e.key, e.shiftKey);
|
|
296
|
+
if (next) {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
pickerState = next;
|
|
299
|
+
emitChange(pickerStateToHex(next, alpha));
|
|
300
|
+
recordRecent(pickerStateToHex(next, alpha));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function handleAlphaKeyDown(e: KeyboardEvent) {
|
|
305
|
+
if (disabled) return;
|
|
306
|
+
const next = pickerKeyStep(pickerState, 'alpha', e.key, e.shiftKey);
|
|
307
|
+
if (next) {
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
pickerState = next;
|
|
310
|
+
emitChange(pickerStateToHex(next, alpha));
|
|
311
|
+
recordRecent(pickerStateToHex(next, alpha));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const computedClass = $derived(classProp || className || '');
|
|
316
|
+
</script>
|
|
317
|
+
|
|
318
|
+
<div
|
|
319
|
+
{id}
|
|
320
|
+
role="group"
|
|
321
|
+
aria-label={ariaLabel}
|
|
322
|
+
class={computedClass}
|
|
323
|
+
style="box-sizing: border-box; width: var(--pixerate-color-picker-width, 240px); border-radius: var(--pixerate-color-picker-radius, 8px); background: var(--pixerate-color-picker-bg, #1f2430); color: var(--pixerate-color-picker-fg, #f0f2f5); padding: 12px; display: flex; flex-direction: column; gap: 10px; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; user-select: none; opacity: {disabled ? 0.6 : 1}; pointer-events: {disabled ? 'none' : 'auto'};"
|
|
324
|
+
>
|
|
325
|
+
<!-- Live region for accessibility announcements -->
|
|
326
|
+
<span
|
|
327
|
+
role="status"
|
|
328
|
+
aria-live="polite"
|
|
329
|
+
style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;"
|
|
330
|
+
>
|
|
331
|
+
{liveDescription}
|
|
332
|
+
</span>
|
|
333
|
+
|
|
334
|
+
<!-- 2D Area: Saturation and Brightness -->
|
|
335
|
+
<div
|
|
336
|
+
role="slider"
|
|
337
|
+
tabindex={disabled ? -1 : 0}
|
|
338
|
+
aria-label="Saturation and brightness"
|
|
339
|
+
aria-valuenow={Math.round(pickerState.v * 100)}
|
|
340
|
+
aria-valuemin={0}
|
|
341
|
+
aria-valuemax={100}
|
|
342
|
+
aria-valuetext="Saturation {Math.round(pickerState.s * 100)}%, Brightness {Math.round(pickerState.v * 100)}%"
|
|
343
|
+
aria-disabled={disabled}
|
|
344
|
+
onpointerdown={handleAreaPointerDown}
|
|
345
|
+
onkeydown={handleAreaKeyDown}
|
|
346
|
+
style="position: relative; width: 100%; height: 150px; border-radius: 6px; background: {areaBg}; cursor: {disabled ? 'default' : 'crosshair'}; touch-action: none; outline: none;"
|
|
347
|
+
>
|
|
348
|
+
<!-- Thumb -->
|
|
349
|
+
<div
|
|
350
|
+
style="position: absolute; width: var(--pixerate-color-picker-thumb-size, 14px); height: var(--pixerate-color-picker-thumb-size, 14px); border-radius: 50%; transform: translate(-50%, -50%); left: {pickerState.s * 100}%; top: {(1 - pickerState.v) * 100}%; border: 2px solid #ffffff; box-shadow: 0 0 2px 1px rgba(0,0,0,0.6); pointer-events: none; background-color: {currentHex};"
|
|
351
|
+
></div>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
<!-- Hue Slider -->
|
|
355
|
+
<div
|
|
356
|
+
role="slider"
|
|
357
|
+
tabindex={disabled ? -1 : 0}
|
|
358
|
+
aria-label="Hue"
|
|
359
|
+
aria-valuenow={Math.round(pickerState.h)}
|
|
360
|
+
aria-valuemin={0}
|
|
361
|
+
aria-valuemax={360}
|
|
362
|
+
aria-valuetext="{Math.round(pickerState.h)} degrees"
|
|
363
|
+
aria-disabled={disabled}
|
|
364
|
+
onpointerdown={handleHuePointerDown}
|
|
365
|
+
onkeydown={handleHueKeyDown}
|
|
366
|
+
style="position: relative; width: 100%; height: 12px; border-radius: 6px; background: {PICKER_HUE_BACKGROUND}; cursor: {disabled ? 'default' : 'pointer'}; touch-action: none; outline: none;"
|
|
367
|
+
>
|
|
368
|
+
<div
|
|
369
|
+
style="position: absolute; top: 50%; transform: translate(-50%, -50%); left: {(pickerState.h / 360) * 100}%; width: var(--pixerate-color-picker-thumb-size, 14px); height: var(--pixerate-color-picker-thumb-size, 14px); border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0 0 2px 1px rgba(0,0,0,0.6); pointer-events: none; background-color: hsl({pickerState.h}, 100%, 50%);"
|
|
370
|
+
></div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<!-- Alpha Slider (conditional) -->
|
|
374
|
+
{#if alpha}
|
|
375
|
+
<div
|
|
376
|
+
role="slider"
|
|
377
|
+
tabindex={disabled ? -1 : 0}
|
|
378
|
+
aria-label="Alpha"
|
|
379
|
+
aria-valuenow={Math.round(pickerState.alpha * 100)}
|
|
380
|
+
aria-valuemin={0}
|
|
381
|
+
aria-valuemax={100}
|
|
382
|
+
aria-valuetext="{Math.round(pickerState.alpha * 100)}%"
|
|
383
|
+
aria-disabled={disabled}
|
|
384
|
+
onpointerdown={handleAlphaPointerDown}
|
|
385
|
+
onkeydown={handleAlphaKeyDown}
|
|
386
|
+
style="position: relative; width: 100%; height: 12px; border-radius: 6px; background-image: {CHECKERBOARD_BG}; background-size: 8px 8px; cursor: {disabled ? 'default' : 'pointer'}; touch-action: none; outline: none; overflow: hidden;"
|
|
387
|
+
>
|
|
388
|
+
<div
|
|
389
|
+
style="position: absolute; inset: 0; background: {alphaBg};"
|
|
390
|
+
></div>
|
|
391
|
+
<div
|
|
392
|
+
style="position: absolute; top: 50%; transform: translate(-50%, -50%); left: {pickerState.alpha * 100}%; width: var(--pixerate-color-picker-thumb-size, 14px); height: var(--pixerate-color-picker-thumb-size, 14px); border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0 0 2px 1px rgba(0,0,0,0.6); pointer-events: none; background-color: {currentHex};"
|
|
393
|
+
></div>
|
|
394
|
+
</div>
|
|
395
|
+
{/if}
|
|
396
|
+
|
|
397
|
+
<!-- Controls row: Swatch preview, Text Input, EyeDropper, Gamut Warning -->
|
|
398
|
+
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
|
399
|
+
<!-- Preview swatch with checkerboard -->
|
|
400
|
+
<div
|
|
401
|
+
style="position: relative; width: 28px; height: 28px; border-radius: 4px; background-image: {CHECKERBOARD_BG}; background-size: 8px 8px; overflow: hidden; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.15);"
|
|
402
|
+
>
|
|
403
|
+
<div
|
|
404
|
+
style="width: 100%; height: 100%; background-color: {currentHex};"
|
|
405
|
+
></div>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<!-- Text Field -->
|
|
409
|
+
<input
|
|
410
|
+
type="text"
|
|
411
|
+
value={textDraft}
|
|
412
|
+
{disabled}
|
|
413
|
+
aria-label="Color hex or CSS string"
|
|
414
|
+
aria-invalid={isInvalid ? 'true' : undefined}
|
|
415
|
+
oninput={(e) => {
|
|
416
|
+
isTyping = true;
|
|
417
|
+
textDraft = e.currentTarget.value;
|
|
418
|
+
isInvalid = false;
|
|
419
|
+
}}
|
|
420
|
+
onkeydown={(e) => {
|
|
421
|
+
if (e.key === 'Enter') {
|
|
422
|
+
e.preventDefault();
|
|
423
|
+
commitTextInput(e.currentTarget);
|
|
424
|
+
} else if (e.key === 'Escape') {
|
|
425
|
+
e.preventDefault();
|
|
426
|
+
isTyping = false;
|
|
427
|
+
isInvalid = false;
|
|
428
|
+
textDraft = value;
|
|
429
|
+
e.currentTarget.value = value;
|
|
430
|
+
}
|
|
431
|
+
}}
|
|
432
|
+
onblur={(e) => commitTextInput(e.currentTarget)}
|
|
433
|
+
style="flex: 1; min-width: 0; height: 28px; padding: 0 8px; font-size: 12px; font-family: monospace; border-radius: 4px; border: {isInvalid ? '1px solid #ef4444' : '1px solid rgba(255,255,255,0.2)'}; background: rgba(0,0,0,0.2); color: inherit; outline: none;"
|
|
434
|
+
/>
|
|
435
|
+
|
|
436
|
+
<!-- EyeDropper button -->
|
|
437
|
+
{#if hasEyeDropper}
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
{disabled}
|
|
441
|
+
aria-label="Pick color from screen"
|
|
442
|
+
title="Pick color from screen"
|
|
443
|
+
onclick={handleEyeDropper}
|
|
444
|
+
style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.2); background: transparent; color: inherit; cursor: {disabled ? 'default' : 'pointer'};"
|
|
445
|
+
>
|
|
446
|
+
<svg
|
|
447
|
+
width="14"
|
|
448
|
+
height="14"
|
|
449
|
+
viewBox="0 0 24 24"
|
|
450
|
+
fill="none"
|
|
451
|
+
stroke="currentColor"
|
|
452
|
+
stroke-width="2"
|
|
453
|
+
stroke-linecap="round"
|
|
454
|
+
stroke-linejoin="round"
|
|
455
|
+
>
|
|
456
|
+
<path d="m19 11-8-8-8.5 8.5a2.12 2.12 0 0 0 0 3l2.5 2.5 1.5-1.5" />
|
|
457
|
+
<path d="m5 16-3 3 1 1 3-3" />
|
|
458
|
+
<path d="m14 6 4 4" />
|
|
459
|
+
</svg>
|
|
460
|
+
</button>
|
|
461
|
+
{/if}
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
<!-- Gamut clipping warning -->
|
|
465
|
+
{#if isClipped}
|
|
466
|
+
<div
|
|
467
|
+
role="note"
|
|
468
|
+
title="This colour was outside standard sRGB and clipped."
|
|
469
|
+
style="display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: #f59e0b; background: rgba(245, 158, 11, 0.1); padding: 2px 6px; border-radius: 4px; align-self: flex-start;"
|
|
470
|
+
>
|
|
471
|
+
<span>⚠ Clipped to sRGB</span>
|
|
472
|
+
</div>
|
|
473
|
+
{/if}
|
|
474
|
+
|
|
475
|
+
<!-- Contrast badge -->
|
|
476
|
+
{#if contrastResult}
|
|
477
|
+
<div
|
|
478
|
+
style="display: flex; align-items: center; justify-content: space-between; font-size: 11px; padding: 4px 8px; border-radius: 4px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);"
|
|
479
|
+
>
|
|
480
|
+
<span>
|
|
481
|
+
Contrast: <strong>{contrastResult.ratio.toFixed(2)}:1</strong>
|
|
482
|
+
</span>
|
|
483
|
+
<div style="display: flex; gap: 6px;">
|
|
484
|
+
<span
|
|
485
|
+
style="color: {contrastResult.aa ? '#10b981' : '#ef4444'}; font-weight: 600;"
|
|
486
|
+
>
|
|
487
|
+
AA {contrastResult.aa ? '✓' : '✗'}
|
|
488
|
+
</span>
|
|
489
|
+
<span
|
|
490
|
+
style="color: {contrastResult.aaa ? '#10b981' : '#ef4444'}; font-weight: 600;"
|
|
491
|
+
>
|
|
492
|
+
AAA {contrastResult.aaa ? '✓' : '✗'}
|
|
493
|
+
</span>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
{/if}
|
|
497
|
+
|
|
498
|
+
<!-- Palette Swatches -->
|
|
499
|
+
{#if swatches && swatches.length > 0}
|
|
500
|
+
<div style="display: flex; flex-direction: column; gap: 4px;">
|
|
501
|
+
<span style="font-size: 10px; text-transform: uppercase; opacity: 0.6;">
|
|
502
|
+
Palette
|
|
503
|
+
</span>
|
|
504
|
+
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
|
|
505
|
+
{#each swatches as color, idx (`${color}-${idx}`)}
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
data-testid="color-picker-swatch"
|
|
509
|
+
title={color}
|
|
510
|
+
{disabled}
|
|
511
|
+
aria-label="Color {color}"
|
|
512
|
+
onclick={() => {
|
|
513
|
+
emitChange(color);
|
|
514
|
+
recordRecent(color);
|
|
515
|
+
}}
|
|
516
|
+
style="width: 18px; height: 18px; border-radius: 3px; background-color: {color}; border: {color.toLowerCase() === value.toLowerCase() ? '2px solid #ffffff' : '1px solid rgba(255,255,255,0.2)'}; cursor: {disabled ? 'default' : 'pointer'}; padding: 0; outline: none;"
|
|
517
|
+
></button>
|
|
518
|
+
{/each}
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
{/if}
|
|
522
|
+
|
|
523
|
+
<!-- Recent Colours -->
|
|
524
|
+
{#if activeRecent.length > 0}
|
|
525
|
+
<div style="display: flex; flex-direction: column; gap: 4px;">
|
|
526
|
+
<span style="font-size: 10px; text-transform: uppercase; opacity: 0.6;">
|
|
527
|
+
Recent
|
|
528
|
+
</span>
|
|
529
|
+
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
|
|
530
|
+
{#each activeRecent as color, idx (`recent-${color}-${idx}`)}
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
data-testid="color-picker-recent"
|
|
534
|
+
{disabled}
|
|
535
|
+
aria-label="Color {color}"
|
|
536
|
+
onclick={() => {
|
|
537
|
+
emitChange(color);
|
|
538
|
+
recordRecent(color);
|
|
539
|
+
}}
|
|
540
|
+
style="width: 18px; height: 18px; border-radius: 3px; background-color: {color}; border: {color.toLowerCase() === value.toLowerCase() ? '2px solid #ffffff' : '1px solid rgba(255,255,255,0.2)'}; cursor: {disabled ? 'default' : 'pointer'}; padding: 0; outline: none;"
|
|
541
|
+
></button>
|
|
542
|
+
{/each}
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
{/if}
|
|
546
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
/** The current colour value (any valid CSS colour string or hex). */
|
|
3
|
+
value: string;
|
|
4
|
+
/** Called when the colour value changes. */
|
|
5
|
+
onchange?: (value: string) => void;
|
|
6
|
+
onChange?: (value: string) => void;
|
|
7
|
+
/** Called when a continuous pointer drag or keyboard interaction starts. */
|
|
8
|
+
ongesturestart?: () => void;
|
|
9
|
+
onGestureStart?: () => void;
|
|
10
|
+
/** Called when a continuous pointer drag or keyboard interaction ends. */
|
|
11
|
+
ongestureend?: () => void;
|
|
12
|
+
onGestureEnd?: () => void;
|
|
13
|
+
/** Whether the alpha channel slider is shown and alpha hex values emitted. Default: false. */
|
|
14
|
+
alpha?: boolean;
|
|
15
|
+
/** Palette swatches shown beneath the sliders. */
|
|
16
|
+
swatches?: readonly string[];
|
|
17
|
+
/** Controlled recent colours. If omitted, internal recents persisted in localStorage are used. */
|
|
18
|
+
recent?: readonly string[];
|
|
19
|
+
/** Called when the list of recent colours changes. */
|
|
20
|
+
onrecentchange?: (recent: readonly string[]) => void;
|
|
21
|
+
onRecentChange?: (recent: readonly string[]) => void;
|
|
22
|
+
/** If provided, renders live WCAG contrast ratio badges against this background colour. */
|
|
23
|
+
contrastAgainst?: string;
|
|
24
|
+
/** Whether the picker is disabled. */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Additional CSS class names for the outer container. */
|
|
27
|
+
class?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
/** Optional DOM id for the container. */
|
|
30
|
+
id?: string;
|
|
31
|
+
/** Accessible label for the colour picker group. */
|
|
32
|
+
'aria-label'?: string;
|
|
33
|
+
}
|
|
34
|
+
declare const ColorPicker: import("svelte").Component<Props, {}, "">;
|
|
35
|
+
type ColorPicker = ReturnType<typeof ColorPicker>;
|
|
36
|
+
export default ColorPicker;
|