@enigmax/primitives 0.23.0 → 0.25.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/{chunk-KEVZ5XQV.js → chunk-3MGBZOAU.js} +50 -9
- package/dist/{chunk-DSBYVA7V.js → chunk-DZMHY3SU.js} +73 -9
- package/dist/chunk-KVLSOQTI.js +66 -0
- package/dist/chunk-S2FPN5ID.js +115 -0
- package/dist/{chunk-3BQVOOAM.js → chunk-Z3VDE7OA.js} +145 -1
- package/dist/{context-menu-D3FtTn7v.d.ts → clipboard-menu-B_ouitfS.d.ts} +91 -1
- package/dist/color-D_rZ83Oc.d.ts +152 -0
- package/dist/color-T63FLJNH.js +515 -0
- package/dist/{index-DQNnohoo.d.ts → index-qXOkYQCU.d.ts} +56 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/next/index.d.ts +3 -3
- package/dist/next/index.js +6 -4
- package/dist/react/context-menu.d.ts +23 -6
- package/dist/react/context-menu.js +2 -2
- package/dist/react/index.d.ts +5 -5
- package/dist/react/index.js +6 -4
- package/dist/react/input.d.ts +2 -2
- package/dist/react/input.js +2 -1
- package/dist/react-router/index.d.ts +3 -3
- package/dist/react-router/index.js +6 -4
- package/package.json +3 -1
- package/recipes/color/styles.css +163 -0
- package/recipes/context-menu/styles.css +8 -0
- package/registry.json +86 -7
- package/src/core/clipboard-menu.ts +270 -0
- package/src/core/color.ts +248 -0
- package/src/index.ts +32 -0
- package/src/react/context-menu/context.ts +9 -2
- package/src/react/context-menu/index.tsx +14 -0
- package/src/react/context-menu/root.tsx +132 -8
- package/src/react/context-menu/styles.ts +8 -0
- package/src/react/index.ts +32 -0
- package/src/react/input/color-styles.ts +176 -0
- package/src/react/input/color-swatch.tsx +96 -0
- package/src/react/input/color.tsx +527 -0
- package/src/react/input/index.tsx +77 -6
- package/src/react/input/types.ts +63 -3
- package/dist/password-C8lG4Zm9.d.ts +0 -71
- /package/dist/{chunk-3HDEZ2E7.js → chunk-MSOCCQGH.js} +0 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rows every context menu on a desktop already has: Copy, Cut and Paste.
|
|
3
|
+
*
|
|
4
|
+
* A menu opened over a selection or over a field is expected to offer them - the browser's own
|
|
5
|
+
* does, and replacing that menu with a custom one takes them away without saying so. Which is
|
|
6
|
+
* why they are ON by default here and turned off with a prop, rather than the other way round:
|
|
7
|
+
* the defect is silent, so the default has to be the safe one.
|
|
8
|
+
*
|
|
9
|
+
* WHAT DECIDES THEY APPEAR is what was right-clicked, and it is read at the moment the menu
|
|
10
|
+
* opens, not from React state - by then the selection is settled (a `contextmenu` event fires
|
|
11
|
+
* after the press has adjusted it) and the caret is still where the visitor left it.
|
|
12
|
+
*
|
|
13
|
+
* - **Copy**, when there is selected text under the pointer.
|
|
14
|
+
* - **Cut**, when that selection is also in something writable.
|
|
15
|
+
* - **Paste**, in anything writable - and disabled when the clipboard is known to be empty.
|
|
16
|
+
*
|
|
17
|
+
* Not a React module: what a selection is, whether a field takes writes, and how text is put
|
|
18
|
+
* back into one are DOM questions, so a menu drawn by anything else gets the same rows.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ContextMenuEntry } from "@/core/context-menu";
|
|
22
|
+
|
|
23
|
+
/** The ids these rows carry. Namespaced, so they can never collide with a caller's own. */
|
|
24
|
+
export const CLIPBOARD_PREFIX = "enigma:clipboard:";
|
|
25
|
+
|
|
26
|
+
export type ClipboardAction = "copy" | "cut" | "paste";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Input types with a text selection.
|
|
30
|
+
*
|
|
31
|
+
* An allowlist rather than a guess: reading `selectionStart` on `number`, `date`, `color` or
|
|
32
|
+
* `email` throws `InvalidStateError` in every browser, because the spec only defines the
|
|
33
|
+
* selection API for these five. A try/catch would hide it; knowing which is which is better.
|
|
34
|
+
*/
|
|
35
|
+
const SELECTABLE_TYPES = new Set(["text", "search", "url", "tel", "password"]);
|
|
36
|
+
|
|
37
|
+
/** What the menu was opened over, as far as the clipboard is concerned. */
|
|
38
|
+
export interface ClipboardTarget {
|
|
39
|
+
/** The field or contenteditable under the pointer, or null when it is neither. */
|
|
40
|
+
editable: HTMLElement | null;
|
|
41
|
+
/** Whether that element takes writes: not disabled, not read-only. */
|
|
42
|
+
writable: boolean;
|
|
43
|
+
/** The selected text, from the field's own selection or the document's. */
|
|
44
|
+
selection: string;
|
|
45
|
+
/**
|
|
46
|
+
* Whether that text may be put on the clipboard.
|
|
47
|
+
*
|
|
48
|
+
* False for a password field. The clipboard is shared with every other application on the
|
|
49
|
+
* machine and is not cleared, so a menu that copies a password out of a masked field
|
|
50
|
+
* leaks it somewhere the visitor cannot see - and the browser's own menu refuses too.
|
|
51
|
+
*/
|
|
52
|
+
copyable: boolean;
|
|
53
|
+
/** Where the selection was, so it can be put back after the menu has taken focus. */
|
|
54
|
+
range: { start: number; end: number; } | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ClipboardMenuLabels {
|
|
58
|
+
copy?: string;
|
|
59
|
+
cut?: string;
|
|
60
|
+
paste?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ClipboardMenuOptions {
|
|
64
|
+
copy?: boolean;
|
|
65
|
+
cut?: boolean;
|
|
66
|
+
paste?: boolean;
|
|
67
|
+
labels?: ClipboardMenuLabels;
|
|
68
|
+
/** Whatever the renderer draws icons with. `unknown`, because a core cannot know. */
|
|
69
|
+
icons?: { copy?: unknown; cut?: unknown; paste?: unknown; };
|
|
70
|
+
/** The clipboard is known to be empty, so Paste is listed and greyed rather than missing. */
|
|
71
|
+
clipboardEmpty?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const EMPTY: ClipboardTarget = { editable: null, writable: false, selection: "", copyable: true, range: null };
|
|
75
|
+
|
|
76
|
+
function isEditableElement(node: Element | null): node is HTMLElement {
|
|
77
|
+
return Boolean(node && (node as HTMLElement).isContentEditable);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The text selected in the document, but only where it touches the element clicked on. */
|
|
81
|
+
function documentSelection(element: Element | null): string {
|
|
82
|
+
const selection = typeof window === "undefined" ? null : window.getSelection();
|
|
83
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return "";
|
|
84
|
+
const range = selection.getRangeAt(0);
|
|
85
|
+
// Selected text elsewhere on the page is not what this menu is over: a right-click away
|
|
86
|
+
// from a selection offers to copy something the visitor is not pointing at.
|
|
87
|
+
if (element && !range.intersectsNode(element)) return "";
|
|
88
|
+
return selection.toString();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What was right-clicked, read the moment the menu opens. */
|
|
92
|
+
export function inspectClipboardTarget(node: EventTarget | null): ClipboardTarget {
|
|
93
|
+
if (typeof document === "undefined") return EMPTY;
|
|
94
|
+
const element = node instanceof Element ? node : null;
|
|
95
|
+
|
|
96
|
+
const field = element?.closest?.("input, textarea") as HTMLInputElement | HTMLTextAreaElement | null;
|
|
97
|
+
if (field) {
|
|
98
|
+
const password = field instanceof HTMLInputElement && field.type === "password";
|
|
99
|
+
const selectable = field instanceof HTMLTextAreaElement || SELECTABLE_TYPES.has(field.type);
|
|
100
|
+
const start = selectable ? field.selectionStart ?? 0 : 0;
|
|
101
|
+
const end = selectable ? field.selectionEnd ?? 0 : 0;
|
|
102
|
+
return {
|
|
103
|
+
editable: field,
|
|
104
|
+
writable: !field.disabled && !field.readOnly,
|
|
105
|
+
selection: selectable ? field.value.slice(start, end) : "",
|
|
106
|
+
copyable: !password,
|
|
107
|
+
range: selectable ? { start, end } : null
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const editable = isEditableElement(element) ? (element.closest("[contenteditable]") as HTMLElement | null) ?? element : null;
|
|
112
|
+
return {
|
|
113
|
+
editable,
|
|
114
|
+
writable: Boolean(editable),
|
|
115
|
+
selection: documentSelection(element),
|
|
116
|
+
copyable: true,
|
|
117
|
+
// A contenteditable's selection is a live DOM Range the browser keeps for us; there is
|
|
118
|
+
// no pair of offsets to restore, and re-focusing the element puts the caret back.
|
|
119
|
+
range: null
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether the clipboard has text in it, or null when that cannot be known.
|
|
125
|
+
*
|
|
126
|
+
* Null is the common answer and not a failure: reading the clipboard needs permission, and
|
|
127
|
+
* asking for it puts a browser prompt on screen just to decide whether to grey out a row -
|
|
128
|
+
* which is a worse trade than showing an enabled Paste that turns out to do nothing. So the
|
|
129
|
+
* permission is only READ, never requested, and the clipboard is only opened where it has
|
|
130
|
+
* already been granted.
|
|
131
|
+
*/
|
|
132
|
+
export async function clipboardHasText(): Promise<boolean | null> {
|
|
133
|
+
if (typeof navigator === "undefined" || !navigator.clipboard?.readText) return null;
|
|
134
|
+
try {
|
|
135
|
+
const status = await navigator.permissions?.query({ name: "clipboard-read" as PermissionName });
|
|
136
|
+
if (status?.state !== "granted") return null;
|
|
137
|
+
return (await navigator.clipboard.readText()).length > 0;
|
|
138
|
+
} catch {
|
|
139
|
+
// Firefox has no `clipboard-read` in its permission registry, Safari refuses the
|
|
140
|
+
// query outright. Both mean "unknown", which is what the caller already handles.
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The rows for this target, in the order every desktop menu puts them. */
|
|
146
|
+
export function clipboardEntries(target: ClipboardTarget, options: ClipboardMenuOptions = {}): ContextMenuEntry[] {
|
|
147
|
+
const { copy = true, cut = true, paste = true, labels = {}, icons = {}, clipboardEmpty = false } = options;
|
|
148
|
+
const entries: ContextMenuEntry[] = [];
|
|
149
|
+
const selected = target.selection.length > 0 && target.copyable;
|
|
150
|
+
const canPaste = typeof navigator !== "undefined" && Boolean(navigator.clipboard?.readText);
|
|
151
|
+
|
|
152
|
+
if (copy && selected) {
|
|
153
|
+
entries.push({ id: `${CLIPBOARD_PREFIX}copy`, label: labels.copy ?? "Copy", shortcut: "Mod+C", icon: icons.copy });
|
|
154
|
+
}
|
|
155
|
+
if (cut && selected && target.writable) {
|
|
156
|
+
entries.push({ id: `${CLIPBOARD_PREFIX}cut`, label: labels.cut ?? "Cut", shortcut: "Mod+X", icon: icons.cut });
|
|
157
|
+
}
|
|
158
|
+
if (paste && target.writable && canPaste) {
|
|
159
|
+
entries.push({
|
|
160
|
+
id: `${CLIPBOARD_PREFIX}paste`,
|
|
161
|
+
label: labels.paste ?? "Paste",
|
|
162
|
+
shortcut: "Mod+V",
|
|
163
|
+
icon: icons.paste,
|
|
164
|
+
// Listed and greyed rather than dropped: a row that disappears between two opens
|
|
165
|
+
// is read as the menu being unreliable, and every desktop menu greys this one.
|
|
166
|
+
disabled: clipboardEmpty
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return entries;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Which clipboard row an id belongs to, or null for anything that is not one of ours. */
|
|
173
|
+
export function clipboardAction(id: string): ClipboardAction | null {
|
|
174
|
+
if (!id.startsWith(CLIPBOARD_PREFIX)) return null;
|
|
175
|
+
const action = id.slice(CLIPBOARD_PREFIX.length);
|
|
176
|
+
return action === "copy" || action === "cut" || action === "paste" ? action : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Put the caret back where it was before the menu took focus.
|
|
181
|
+
*
|
|
182
|
+
* Choosing a row moves focus into the panel and then destroys it, so by the time the action
|
|
183
|
+
* runs the field is not focused and its selection is gone. Both are restored first, or Cut
|
|
184
|
+
* deletes nothing and Paste inserts at position zero.
|
|
185
|
+
*/
|
|
186
|
+
function restore(target: ClipboardTarget): void {
|
|
187
|
+
const element = target.editable;
|
|
188
|
+
if (!element) return;
|
|
189
|
+
element.focus({ preventScroll: true });
|
|
190
|
+
if (!target.range) return;
|
|
191
|
+
const field = element as HTMLInputElement | HTMLTextAreaElement;
|
|
192
|
+
try { field.setSelectionRange(target.range.start, target.range.end); } catch { /* not selectable */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Write a value the way a keystroke would, for either kind of field.
|
|
197
|
+
*
|
|
198
|
+
* The same trick `<Input>` uses, and for the same reason: assigning `.value` is invisible to
|
|
199
|
+
* React, which compares against the last value it rendered and skips the change event. The
|
|
200
|
+
* setter has to come from the element's OWN prototype - a textarea's is not an input's.
|
|
201
|
+
*/
|
|
202
|
+
function writeFieldValue(field: HTMLInputElement | HTMLTextAreaElement, next: string): void {
|
|
203
|
+
const prototype = field instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
204
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
205
|
+
if (setter) setter.call(field, next);
|
|
206
|
+
else field.value = next;
|
|
207
|
+
field.dispatchEvent(new Event("input", { bubbles: true }));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Replace what is selected with `text` (or delete it, when `text` is empty).
|
|
212
|
+
*
|
|
213
|
+
* `execCommand("insertText")` first, deprecated as it is: it is the only insertion that joins
|
|
214
|
+
* the browser's own UNDO stack, so Ctrl+Z after a paste behaves like a paste and not like a
|
|
215
|
+
* value that appeared from nowhere. The fallback is exact and does everything but the undo.
|
|
216
|
+
*/
|
|
217
|
+
function replaceSelection(target: ClipboardTarget, text: string): void {
|
|
218
|
+
const element = target.editable;
|
|
219
|
+
if (!element) return;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (document.execCommand("insertText", false, text)) return;
|
|
223
|
+
} catch {
|
|
224
|
+
// Denied, or not implemented. The fallback below is the whole behaviour anyway.
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const field = element as HTMLInputElement | HTMLTextAreaElement;
|
|
228
|
+
if (typeof field.setSelectionRange !== "function" || target.range === null) return;
|
|
229
|
+
const start = field.selectionStart ?? target.range.start;
|
|
230
|
+
const end = field.selectionEnd ?? target.range.end;
|
|
231
|
+
writeFieldValue(field, field.value.slice(0, start) + text + field.value.slice(end));
|
|
232
|
+
const caret = start + text.length;
|
|
233
|
+
try { field.setSelectionRange(caret, caret); } catch { /* not selectable */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Do what the row says.
|
|
238
|
+
*
|
|
239
|
+
* Called straight from the press that chose it, so the browser still counts it as a user
|
|
240
|
+
* gesture - which is what the clipboard API requires and what makes a paste possible at all.
|
|
241
|
+
* Returns whether the action happened: a refused permission and an empty clipboard are both
|
|
242
|
+
* "no", and neither is worth an exception the caller has to catch.
|
|
243
|
+
*/
|
|
244
|
+
export async function performClipboardAction(action: ClipboardAction, target: ClipboardTarget): Promise<boolean> {
|
|
245
|
+
restore(target);
|
|
246
|
+
|
|
247
|
+
if (action === "copy" || action === "cut") {
|
|
248
|
+
if (!target.selection || !target.copyable) return false;
|
|
249
|
+
try {
|
|
250
|
+
await navigator.clipboard.writeText(target.selection);
|
|
251
|
+
} catch {
|
|
252
|
+
// An insecure context, or a browser that refuses without permission. The
|
|
253
|
+
// deprecated command still works in both, and it is the only fallback there is.
|
|
254
|
+
try { if (!document.execCommand("copy")) return false; } catch { return false; }
|
|
255
|
+
}
|
|
256
|
+
if (action === "cut" && target.writable) replaceSelection(target, "");
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!target.writable) return false;
|
|
261
|
+
try {
|
|
262
|
+
const text = await navigator.clipboard.readText();
|
|
263
|
+
if (!text) return false;
|
|
264
|
+
replaceSelection(target, text);
|
|
265
|
+
return true;
|
|
266
|
+
} catch {
|
|
267
|
+
// Refused, dismissed, or empty. Nothing changed, so there is nothing to report.
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a colour, writing one, and converting between the two models a picker needs.
|
|
3
|
+
*
|
|
4
|
+
* Arithmetic only, like every other core here: no DOM, no framework. The picker drags a
|
|
5
|
+
* saturation/value square and a hue rail, which are HSV; a form field holds `#3b82f6`,
|
|
6
|
+
* `rgb(59, 130, 246)` or an `hsl()`, which is what a stylesheet and a database take. This
|
|
7
|
+
* module is the translation between them.
|
|
8
|
+
*
|
|
9
|
+
* Two things it does NOT do, both on purpose:
|
|
10
|
+
*
|
|
11
|
+
* - **Named colours.** `red`, `rebeccapurple` and the other 146 are a table nobody needs in
|
|
12
|
+
* a bundle to drag a square, and the DOM already resolves them for free (assign the name
|
|
13
|
+
* to `style.color` and read it back). The picker's canonical value is a hex string.
|
|
14
|
+
* - **Colour spaces past sRGB.** `oklch()` and `color()` describe colours a hex cannot, so
|
|
15
|
+
* accepting one here and handing back `#rrggbb` would silently clip it. Parsing returns
|
|
16
|
+
* null instead, which the field reports as unparseable rather than as a different colour.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** sRGB, 0-255 per channel, with alpha 0-1. The transport shape everything converts through. */
|
|
20
|
+
export interface Rgb {
|
|
21
|
+
r: number;
|
|
22
|
+
g: number;
|
|
23
|
+
b: number;
|
|
24
|
+
a: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Hue 0-360, saturation and value 0-1, alpha 0-1. What the picker's two controls move. */
|
|
28
|
+
export interface Hsv {
|
|
29
|
+
h: number;
|
|
30
|
+
s: number;
|
|
31
|
+
v: number;
|
|
32
|
+
a: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Hue 0-360, saturation and lightness 0-1, alpha 0-1. Only used by `hsl()` in and out. */
|
|
36
|
+
export interface Hsl {
|
|
37
|
+
h: number;
|
|
38
|
+
s: number;
|
|
39
|
+
l: number;
|
|
40
|
+
a: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** How a colour is written back into the field. */
|
|
44
|
+
export type ColorFormat = "hex" | "rgb" | "hsl";
|
|
45
|
+
|
|
46
|
+
export interface FormatColorOptions {
|
|
47
|
+
/** Write the alpha channel. Off drops it, so a half-transparent colour becomes opaque. */
|
|
48
|
+
alpha?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clamp(value: number, min: number, max: number): number {
|
|
52
|
+
return value < min ? min : value > max ? max : value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 0-1, and never NaN: an unparsed alpha must not travel as one and poison every later sum. */
|
|
56
|
+
function clampAlpha(value: number): number {
|
|
57
|
+
return Number.isFinite(value) ? clamp(value, 0, 1) : 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function channel(value: number): number {
|
|
61
|
+
return Math.round(clamp(value, 0, 255));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `"50%"` -> 0.5, `"0.5"` -> 0.5. Both spellings are legal for every CSS component. */
|
|
65
|
+
function ratio(token: string, scale: number): number {
|
|
66
|
+
const text = token.trim();
|
|
67
|
+
const value = Number.parseFloat(text);
|
|
68
|
+
if (!Number.isFinite(value)) return Number.NaN;
|
|
69
|
+
return text.endsWith("%") ? (value / 100) * scale : value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The components inside `rgb(...)` / `hsl(...)`, however they are punctuated.
|
|
74
|
+
*
|
|
75
|
+
* CSS Color 4 allows `rgb(0 0 0 / 50%)` beside the legacy `rgba(0, 0, 0, 0.5)`, and both
|
|
76
|
+
* turn up in real stylesheets - a value pasted out of devtools is usually the space form.
|
|
77
|
+
* Splitting on the separators rather than matching one syntax accepts them both without a
|
|
78
|
+
* second regular expression to keep in step with the first.
|
|
79
|
+
*/
|
|
80
|
+
function components(body: string): string[] {
|
|
81
|
+
return body.split(/[\s,/]+/).map((part) => part.trim()).filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A colour string to sRGB, or null when it is not one.
|
|
86
|
+
*
|
|
87
|
+
* Null is the whole point of the return type: a field is unparseable for as long as someone
|
|
88
|
+
* is halfway through typing `#3b8`, and a picker that guesses at that moment fights the
|
|
89
|
+
* caret. Everything here is tolerant of what a person types - a missing `#`, upper case,
|
|
90
|
+
* stray spaces - and intolerant of what would be a guess.
|
|
91
|
+
*/
|
|
92
|
+
export function parseColor(input: string): Rgb | null {
|
|
93
|
+
const text = input.trim().toLowerCase();
|
|
94
|
+
if (!text) return null;
|
|
95
|
+
|
|
96
|
+
// The one keyword worth a line: it is what an empty colour is called everywhere in CSS,
|
|
97
|
+
// and a picker that cannot read back what it wrote for alpha 0 is broken.
|
|
98
|
+
if (text === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
|
|
99
|
+
|
|
100
|
+
const hex = /^#?([0-9a-f]+)$/.exec(text);
|
|
101
|
+
if (hex) return fromHex(hex[1]);
|
|
102
|
+
|
|
103
|
+
const functional = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text);
|
|
104
|
+
if (!functional) return null;
|
|
105
|
+
|
|
106
|
+
const parts = components(functional[2]);
|
|
107
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
108
|
+
const alpha = parts.length === 4 ? clampAlpha(ratio(parts[3], 1)) : 1;
|
|
109
|
+
|
|
110
|
+
if (functional[1].startsWith("rgb")) {
|
|
111
|
+
const values = parts.slice(0, 3).map((part) => ratio(part, 255));
|
|
112
|
+
if (values.some((value) => !Number.isFinite(value))) return null;
|
|
113
|
+
return { r: channel(values[0]), g: channel(values[1]), b: channel(values[2]), a: alpha };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// `hsl(210deg 40% 96%)`. The angle units past degrees are rare enough in hand-written CSS
|
|
117
|
+
// that supporting them would be speculative; a plain number is degrees, which is the rule
|
|
118
|
+
// CSS itself uses.
|
|
119
|
+
const hue = Number.parseFloat(parts[0]);
|
|
120
|
+
const saturation = ratio(parts[1], 1);
|
|
121
|
+
const lightness = ratio(parts[2], 1);
|
|
122
|
+
if (!Number.isFinite(hue) || !Number.isFinite(saturation) || !Number.isFinite(lightness)) return null;
|
|
123
|
+
return hslToRgb({ h: hue, s: clamp(saturation, 0, 1), l: clamp(lightness, 0, 1), a: alpha });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` - and nothing else.
|
|
128
|
+
*
|
|
129
|
+
* Five and seven digits are REFUSED rather than padded. A truncated paste is the common way
|
|
130
|
+
* to arrive at one, and inventing the missing digit produces a colour nobody chose.
|
|
131
|
+
*/
|
|
132
|
+
function fromHex(digits: string): Rgb | null {
|
|
133
|
+
const size = digits.length;
|
|
134
|
+
if (size !== 3 && size !== 4 && size !== 6 && size !== 8) return null;
|
|
135
|
+
|
|
136
|
+
const short = size <= 4;
|
|
137
|
+
const at = (index: number): number => {
|
|
138
|
+
const slice = short ? digits[index].repeat(2) : digits.slice(index * 2, index * 2 + 2);
|
|
139
|
+
return Number.parseInt(slice, 16);
|
|
140
|
+
};
|
|
141
|
+
const alpha = size === 4 || size === 8 ? at(3) / 255 : 1;
|
|
142
|
+
return { r: at(0), g: at(1), b: at(2), a: clampAlpha(alpha) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `#rrggbb`, or `#rrggbbaa` when alpha is asked for and the colour is not opaque. */
|
|
146
|
+
export function toHex(color: Rgb, options: FormatColorOptions = {}): string {
|
|
147
|
+
const pair = (value: number): string => channel(value).toString(16).padStart(2, "0");
|
|
148
|
+
const alpha = clampAlpha(color.a);
|
|
149
|
+
const suffix = options.alpha && alpha < 1 ? pair(Math.round(alpha * 255)) : "";
|
|
150
|
+
return `#${pair(color.r)}${pair(color.g)}${pair(color.b)}${suffix}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A colour back to a string.
|
|
155
|
+
*
|
|
156
|
+
* The legacy comma syntax for `rgb()` and `hsl()`, deliberately: this string is going into a
|
|
157
|
+
* field someone will paste into a stylesheet, a spreadsheet or an older toolchain, and the
|
|
158
|
+
* space-and-slash form is the one those still refuse.
|
|
159
|
+
*/
|
|
160
|
+
export function formatColor(color: Rgb, format: ColorFormat = "hex", options: FormatColorOptions = {}): string {
|
|
161
|
+
const alpha = clampAlpha(color.a);
|
|
162
|
+
const opaque = !options.alpha || alpha >= 1;
|
|
163
|
+
// Three decimals: enough to survive a round trip through a hex byte (1/255), short enough
|
|
164
|
+
// that the field does not fill up with digits nobody chose.
|
|
165
|
+
const printed = Number.parseFloat(alpha.toFixed(3));
|
|
166
|
+
|
|
167
|
+
if (format === "hex") return toHex(color, options);
|
|
168
|
+
if (format === "rgb") {
|
|
169
|
+
const body = `${channel(color.r)}, ${channel(color.g)}, ${channel(color.b)}`;
|
|
170
|
+
return opaque ? `rgb(${body})` : `rgba(${body}, ${printed})`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const hsl = rgbToHsl(color);
|
|
174
|
+
const body = `${Math.round(hsl.h)}, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%`;
|
|
175
|
+
return opaque ? `hsl(${body})` : `hsla(${body}, ${printed})`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* sRGB to HSV, keeping a hue the arithmetic cannot see.
|
|
180
|
+
*
|
|
181
|
+
* THE colour picker bug, and it is in almost every hand-rolled one: hue is undefined at
|
|
182
|
+
* black, at white and at every grey, because those have no dominant channel. Recomputing it
|
|
183
|
+
* from the RGB therefore returns 0 - red - so dragging the square into its bottom-left
|
|
184
|
+
* corner and back out again resets a hue the visitor picked, and the rail jumps under their
|
|
185
|
+
* finger. `fallbackHue` is the hue they last chose, and it is what the picker keeps its own
|
|
186
|
+
* state for.
|
|
187
|
+
*/
|
|
188
|
+
export function rgbToHsv(color: Rgb, fallbackHue = 0): Hsv {
|
|
189
|
+
const r = clamp(color.r, 0, 255) / 255;
|
|
190
|
+
const g = clamp(color.g, 0, 255) / 255;
|
|
191
|
+
const b = clamp(color.b, 0, 255) / 255;
|
|
192
|
+
const max = Math.max(r, g, b);
|
|
193
|
+
const span = max - Math.min(r, g, b);
|
|
194
|
+
|
|
195
|
+
let h = fallbackHue;
|
|
196
|
+
if (span > 0) {
|
|
197
|
+
if (max === r) h = ((g - b) / span) % 6;
|
|
198
|
+
else if (max === g) h = (b - r) / span + 2;
|
|
199
|
+
else h = (r - g) / span + 4;
|
|
200
|
+
h *= 60;
|
|
201
|
+
if (h < 0) h += 360;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { h, s: max === 0 ? 0 : span / max, v: max, a: clampAlpha(color.a) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function hsvToRgb(color: Hsv): Rgb {
|
|
208
|
+
const h = ((color.h % 360) + 360) % 360;
|
|
209
|
+
const s = clamp(color.s, 0, 1);
|
|
210
|
+
const v = clamp(color.v, 0, 1);
|
|
211
|
+
|
|
212
|
+
const c = v * s;
|
|
213
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
214
|
+
const m = v - c;
|
|
215
|
+
const [r, g, b] = h < 60 ? [c, x, 0]
|
|
216
|
+
: h < 120 ? [x, c, 0]
|
|
217
|
+
: h < 180 ? [0, c, x]
|
|
218
|
+
: h < 240 ? [0, x, c]
|
|
219
|
+
: h < 300 ? [x, 0, c]
|
|
220
|
+
: [c, 0, x];
|
|
221
|
+
|
|
222
|
+
return { r: channel((r + m) * 255), g: channel((g + m) * 255), b: channel((b + m) * 255), a: clampAlpha(color.a) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** sRGB to HSL. Same undefined hue at the greys, same reason to pass the one being kept. */
|
|
226
|
+
export function rgbToHsl(color: Rgb, fallbackHue = 0): Hsl {
|
|
227
|
+
const hsv = rgbToHsv(color, fallbackHue);
|
|
228
|
+
const l = hsv.v * (1 - hsv.s / 2);
|
|
229
|
+
// Saturation is not shared between the two models: at the same hue, HSL's denominator is
|
|
230
|
+
// how far the lightness is from black OR white, which is why a "vivid" HSV colour flattens
|
|
231
|
+
// if its saturation is copied across instead of converted.
|
|
232
|
+
const s = l === 0 || l === 1 ? 0 : (hsv.v - l) / Math.min(l, 1 - l);
|
|
233
|
+
return { h: hsv.h, s: clamp(s, 0, 1), l, a: hsv.a };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function hslToRgb(color: Hsl): Rgb {
|
|
237
|
+
const s = clamp(color.s, 0, 1);
|
|
238
|
+
const l = clamp(color.l, 0, 1);
|
|
239
|
+
const v = l + s * Math.min(l, 1 - l);
|
|
240
|
+
return hsvToRgb({ h: color.h, s: v === 0 ? 0 : 2 * (1 - l / v), v, a: color.a });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Same colour, to the byte. Used to tell a value the picker wrote from one someone typed. */
|
|
244
|
+
export function colorEquals(a: Rgb | null, b: Rgb | null): boolean {
|
|
245
|
+
if (!a || !b) return a === b;
|
|
246
|
+
return channel(a.r) === channel(b.r) && channel(a.g) === channel(b.g) && channel(a.b) === channel(b.b)
|
|
247
|
+
&& Math.round(clampAlpha(a.a) * 255) === Math.round(clampAlpha(b.a) * 255);
|
|
248
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,23 @@ export {
|
|
|
12
12
|
type PasswordStrengthReport,
|
|
13
13
|
type PasswordScore
|
|
14
14
|
} from "@/core/password";
|
|
15
|
+
// The colour maths behind `<Input type="color">`. The picker is React, the conversions are
|
|
16
|
+
// not, so a vanilla page drawing its own square has the same parser and the same hue rule.
|
|
17
|
+
export {
|
|
18
|
+
parseColor,
|
|
19
|
+
formatColor,
|
|
20
|
+
toHex,
|
|
21
|
+
rgbToHsv,
|
|
22
|
+
hsvToRgb,
|
|
23
|
+
rgbToHsl,
|
|
24
|
+
hslToRgb,
|
|
25
|
+
colorEquals,
|
|
26
|
+
type Rgb,
|
|
27
|
+
type Hsv,
|
|
28
|
+
type Hsl,
|
|
29
|
+
type ColorFormat,
|
|
30
|
+
type FormatColorOptions
|
|
31
|
+
} from "@/core/color";
|
|
15
32
|
export { createNotifications, type Notifications, type Notification, type NotificationInput, type NotificationTone, type NotificationAction, type NotificationsOptions, type PromiseMessages } from "@/core/notifications";
|
|
16
33
|
export { createNetworkMonitor, SERVER_NETWORK_STATE, type NetworkState, type NetworkMonitor } from "@/core/network";
|
|
17
34
|
export {
|
|
@@ -89,6 +106,21 @@ export {
|
|
|
89
106
|
type ContextMenuPoint,
|
|
90
107
|
type ContextMenuMoveKey
|
|
91
108
|
} from "@/core/context-menu";
|
|
109
|
+
// Copy, Cut and Paste for a menu, and the DOM questions behind them: what is selected,
|
|
110
|
+
// whether it can be written to, and how text goes back in without breaking undo.
|
|
111
|
+
export {
|
|
112
|
+
CLIPBOARD_PREFIX,
|
|
113
|
+
clipboardEntries,
|
|
114
|
+
clipboardAction,
|
|
115
|
+
clipboardHasText,
|
|
116
|
+
inspectClipboardTarget,
|
|
117
|
+
performClipboardAction,
|
|
118
|
+
type ClipboardAction,
|
|
119
|
+
type ClipboardTarget,
|
|
120
|
+
type ClipboardMenuOptions,
|
|
121
|
+
type ClipboardMenuLabels
|
|
122
|
+
} from "@/core/clipboard-menu";
|
|
123
|
+
|
|
92
124
|
// The selection model, for the same reason: what a Ctrl+click does to a set is not React.
|
|
93
125
|
export {
|
|
94
126
|
createSelection,
|
|
@@ -19,8 +19,15 @@ export type ContextMenuNode = ContextMenuItem | Exclude<ContextMenuEntry, Contex
|
|
|
19
19
|
export interface ContextMenuContextValue {
|
|
20
20
|
instance: ContextMenuInstance;
|
|
21
21
|
state: ContextMenuState;
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Open at a point, over what the press landed on. False when there was nothing to show,
|
|
24
|
+
* so the press is left alone.
|
|
25
|
+
*
|
|
26
|
+
* `target` is what the clipboard rows are built from - the field or the selection under
|
|
27
|
+
* the pointer - and a caller that leaves it out gets a menu with none of them, which is
|
|
28
|
+
* the right answer for a menu opened from somewhere that is not a press.
|
|
29
|
+
*/
|
|
30
|
+
open: (point: ContextMenuPoint, target?: EventTarget | null) => boolean;
|
|
24
31
|
close: () => void;
|
|
25
32
|
/** ids the parts need to point at each other. */
|
|
26
33
|
ids: { trigger: string; };
|
|
@@ -90,5 +90,19 @@ export {
|
|
|
90
90
|
type ContextMenuPoint,
|
|
91
91
|
type ContextMenuMoveKey
|
|
92
92
|
} from "@/core/context-menu";
|
|
93
|
+
// The clipboard rows as their own surface: a menu built from the parts can add them
|
|
94
|
+
// itself, and a caller that performs Copy in its own way has the same reader and writer.
|
|
95
|
+
export {
|
|
96
|
+
CLIPBOARD_PREFIX,
|
|
97
|
+
clipboardEntries,
|
|
98
|
+
clipboardAction,
|
|
99
|
+
clipboardHasText,
|
|
100
|
+
inspectClipboardTarget,
|
|
101
|
+
performClipboardAction,
|
|
102
|
+
type ClipboardAction,
|
|
103
|
+
type ClipboardTarget,
|
|
104
|
+
type ClipboardMenuOptions,
|
|
105
|
+
type ClipboardMenuLabels
|
|
106
|
+
} from "@/core/clipboard-menu";
|
|
93
107
|
export { CONTEXT_MENU_STYLES } from "@/react/context-menu/styles";
|
|
94
108
|
export { shortcutTokens, shortcutText, parseShortcut, matchesShortcut, type Shortcut, type ShortcutSpec } from "@/core/keys";
|