@enigmax/primitives 0.23.0 → 0.24.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-DSBYVA7V.js → chunk-DZMHY3SU.js} +73 -9
- package/dist/{chunk-KEVZ5XQV.js → chunk-QJJ34E4G.js} +33 -9
- package/dist/chunk-S2FPN5ID.js +115 -0
- package/dist/chunk-S7EE57YB.js +9 -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-OPV3BJV6.js +452 -0
- package/dist/{index-DQNnohoo.d.ts → index-ZPvlf9vs.d.ts} +52 -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 +143 -0
- package/recipes/context-menu/styles.css +8 -0
- package/registry.json +77 -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 +156 -0
- package/src/react/input/color.tsx +466 -0
- package/src/react/input/index.tsx +54 -6
- package/src/react/input/types.ts +59 -3
- package/dist/password-C8lG4Zm9.d.ts +0 -71
- /package/dist/{chunk-3HDEZ2E7.js → chunk-MSOCCQGH.js} +0 -0
package/registry.json
CHANGED
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
{
|
|
157
157
|
"name": "input",
|
|
158
158
|
"title": "Input, one field for every type",
|
|
159
|
-
"description": "A field you pass props to, typed by its `type`: password gets the reveal toggle, a generator, a strength meter and a breach check; search gets debouncing, ranking and a clear button; everything else is a plain field with native props. What each type needs is loaded when that type is used, so a form of text fields ships none of it.",
|
|
159
|
+
"description": "A field you pass props to, typed by its `type`: password gets the reveal toggle, a generator, a strength meter and a breach check; search gets debouncing, ranking and a clear button; color gets a swatch and a picker panel - saturation square, hue and alpha rails, presets and the screen eyedropper - instead of the operating system popup; everything else is a plain field with native props. What each type needs is loaded when that type is used, so a form of text fields ships none of it.",
|
|
160
160
|
"targets": [
|
|
161
161
|
"vanilla",
|
|
162
162
|
"astro",
|
|
@@ -224,7 +224,8 @@
|
|
|
224
224
|
],
|
|
225
225
|
"rewrite": {
|
|
226
226
|
"@/core/search": "./search-core",
|
|
227
|
-
"@/core/password": "./password-core"
|
|
227
|
+
"@/core/password": "./password-core",
|
|
228
|
+
"@/core/color": "./color-core"
|
|
228
229
|
}
|
|
229
230
|
},
|
|
230
231
|
{
|
|
@@ -262,6 +263,43 @@
|
|
|
262
263
|
"@/core/search": "./search-core"
|
|
263
264
|
}
|
|
264
265
|
},
|
|
266
|
+
{
|
|
267
|
+
"path": "src/core/color.ts",
|
|
268
|
+
"dest": "input/color-core.ts",
|
|
269
|
+
"targets": [
|
|
270
|
+
"vanilla",
|
|
271
|
+
"astro",
|
|
272
|
+
"react"
|
|
273
|
+
]
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"path": "src/react/input/color-styles.ts",
|
|
277
|
+
"dest": "input/color-styles.ts",
|
|
278
|
+
"targets": [
|
|
279
|
+
"react"
|
|
280
|
+
]
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"path": "src/react/input/color.tsx",
|
|
284
|
+
"dest": "input/color.tsx",
|
|
285
|
+
"targets": [
|
|
286
|
+
"react"
|
|
287
|
+
],
|
|
288
|
+
"rewrite": {
|
|
289
|
+
"@/core/color": "./color-core",
|
|
290
|
+
"@/react/input/write-value": "./write-value",
|
|
291
|
+
"@/react/input/color-styles": "./color-styles",
|
|
292
|
+
"@/react/input/types": "./types"
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"path": "recipes/color/styles.css",
|
|
297
|
+
"dest": "input/color.css",
|
|
298
|
+
"targets": [
|
|
299
|
+
"react"
|
|
300
|
+
],
|
|
301
|
+
"style": "css"
|
|
302
|
+
},
|
|
265
303
|
{
|
|
266
304
|
"path": "recipes/input/styles.css",
|
|
267
305
|
"dest": "input/styles.css",
|
|
@@ -286,7 +324,8 @@
|
|
|
286
324
|
"@/react/input/types": "./types",
|
|
287
325
|
"@/react/input/password": "./password",
|
|
288
326
|
"@/react/input/search": "./search",
|
|
289
|
-
"@/core/password": "./password-core"
|
|
327
|
+
"@/core/password": "./password-core",
|
|
328
|
+
"@/react/input/color": "./color"
|
|
290
329
|
}
|
|
291
330
|
},
|
|
292
331
|
{
|
|
@@ -322,7 +361,26 @@
|
|
|
322
361
|
"[data-enigma-password-strength-segment][data-filled]",
|
|
323
362
|
"[data-enigma-password-strength-label]",
|
|
324
363
|
"[data-enigma-password-strength-warning]",
|
|
325
|
-
"[data-enigma-input-action=clear]"
|
|
364
|
+
"[data-enigma-input-action=clear]",
|
|
365
|
+
"[data-enigma-color]",
|
|
366
|
+
"[data-enigma-color][data-open]",
|
|
367
|
+
"[data-enigma-color-swatch]",
|
|
368
|
+
"[data-enigma-color-swatch][data-invalid]",
|
|
369
|
+
"[data-enigma-color-checkers]",
|
|
370
|
+
"[data-enigma-color-fill]",
|
|
371
|
+
"[data-enigma-color-panel]",
|
|
372
|
+
"[data-enigma-color-panel][data-side=top]",
|
|
373
|
+
"[data-enigma-color-area]",
|
|
374
|
+
"[data-enigma-color-controls]",
|
|
375
|
+
"[data-enigma-color-rails]",
|
|
376
|
+
"[data-enigma-color-rail=hue]",
|
|
377
|
+
"[data-enigma-color-rail=alpha]",
|
|
378
|
+
"[data-enigma-color-gradient]",
|
|
379
|
+
"[data-enigma-color-thumb]",
|
|
380
|
+
"[data-enigma-color-preview]",
|
|
381
|
+
"[data-enigma-color-eyedropper]",
|
|
382
|
+
"[data-enigma-color-swatches]",
|
|
383
|
+
"[data-enigma-color-preset]"
|
|
326
384
|
],
|
|
327
385
|
"docs": "docs/notes/primitives.md#input",
|
|
328
386
|
"recipes": [
|
|
@@ -739,7 +797,7 @@
|
|
|
739
797
|
{
|
|
740
798
|
"name": "context-menu",
|
|
741
799
|
"title": "Context menu",
|
|
742
|
-
"description": "A right-click menu with the desktop's behaviour: submenus that open on a beat of hovering and survive a diagonal pointer, icons, a second line, shortcuts printed on the right, checkable and radio rows, separators and captions, destructive rows in red, an optional heading naming what it acts on, a filter once a level is long, a long level rendered a window at a time, and a submenu fetched on demand and cached. Opens at the pointer, on a long press, and on Shift+F10. A menu with no rows does not open at all. It brings its own theme - an unstyled popup is transparent text over the page rather than a plain-looking control.",
|
|
800
|
+
"description": "A right-click menu with the desktop's behaviour: submenus that open on a beat of hovering and survive a diagonal pointer, icons, a second line, shortcuts printed on the right, checkable and radio rows, separators and captions, destructive rows in red, an optional heading naming what it acts on, a filter once a level is long, a long level rendered a window at a time, and a submenu fetched on demand and cached. Opens at the pointer, on a long press, and on Shift+F10. Copy, Cut and Paste are built from whatever was right-clicked and are on by default, because the browser menu this one replaces has them. A menu with no rows does not open at all. It brings its own theme - an unstyled popup is transparent text over the page rather than a plain-looking control.",
|
|
743
801
|
"targets": [
|
|
744
802
|
"react"
|
|
745
803
|
],
|
|
@@ -779,6 +837,16 @@
|
|
|
779
837
|
"@/core/keys": "./keys"
|
|
780
838
|
}
|
|
781
839
|
},
|
|
840
|
+
{
|
|
841
|
+
"path": "src/core/clipboard-menu.ts",
|
|
842
|
+
"dest": "context-menu/clipboard-menu.ts",
|
|
843
|
+
"targets": [
|
|
844
|
+
"react"
|
|
845
|
+
],
|
|
846
|
+
"rewrite": {
|
|
847
|
+
"@/core/context-menu": "./context-menu"
|
|
848
|
+
}
|
|
849
|
+
},
|
|
782
850
|
{
|
|
783
851
|
"path": "src/react/slot.tsx",
|
|
784
852
|
"dest": "context-menu/slot.tsx",
|
|
@@ -823,7 +891,8 @@
|
|
|
823
891
|
"@/core/keys": "./keys",
|
|
824
892
|
"@/react/slot": "./slot",
|
|
825
893
|
"@/react/context-menu/styles": "./styles",
|
|
826
|
-
"@/react/context-menu/context": "./context"
|
|
894
|
+
"@/react/context-menu/context": "./context",
|
|
895
|
+
"@/core/clipboard-menu": "./clipboard-menu"
|
|
827
896
|
}
|
|
828
897
|
},
|
|
829
898
|
{
|
|
@@ -838,7 +907,8 @@
|
|
|
838
907
|
"@/react/context-menu/context": "./context",
|
|
839
908
|
"@/react/context-menu/styles": "./styles",
|
|
840
909
|
"@/core/context-menu": "./context-menu",
|
|
841
|
-
"@/core/keys": "./keys"
|
|
910
|
+
"@/core/keys": "./keys",
|
|
911
|
+
"@/core/clipboard-menu": "./clipboard-menu"
|
|
842
912
|
}
|
|
843
913
|
}
|
|
844
914
|
],
|
|
@@ -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
|
+
}
|