@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.
Files changed (40) hide show
  1. package/dist/{chunk-KEVZ5XQV.js → chunk-3MGBZOAU.js} +50 -9
  2. package/dist/{chunk-DSBYVA7V.js → chunk-DZMHY3SU.js} +73 -9
  3. package/dist/chunk-KVLSOQTI.js +66 -0
  4. package/dist/chunk-S2FPN5ID.js +115 -0
  5. package/dist/{chunk-3BQVOOAM.js → chunk-Z3VDE7OA.js} +145 -1
  6. package/dist/{context-menu-D3FtTn7v.d.ts → clipboard-menu-B_ouitfS.d.ts} +91 -1
  7. package/dist/color-D_rZ83Oc.d.ts +152 -0
  8. package/dist/color-T63FLJNH.js +515 -0
  9. package/dist/{index-DQNnohoo.d.ts → index-qXOkYQCU.d.ts} +56 -4
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +2 -1
  12. package/dist/next/index.d.ts +3 -3
  13. package/dist/next/index.js +6 -4
  14. package/dist/react/context-menu.d.ts +23 -6
  15. package/dist/react/context-menu.js +2 -2
  16. package/dist/react/index.d.ts +5 -5
  17. package/dist/react/index.js +6 -4
  18. package/dist/react/input.d.ts +2 -2
  19. package/dist/react/input.js +2 -1
  20. package/dist/react-router/index.d.ts +3 -3
  21. package/dist/react-router/index.js +6 -4
  22. package/package.json +3 -1
  23. package/recipes/color/styles.css +163 -0
  24. package/recipes/context-menu/styles.css +8 -0
  25. package/registry.json +86 -7
  26. package/src/core/clipboard-menu.ts +270 -0
  27. package/src/core/color.ts +248 -0
  28. package/src/index.ts +32 -0
  29. package/src/react/context-menu/context.ts +9 -2
  30. package/src/react/context-menu/index.tsx +14 -0
  31. package/src/react/context-menu/root.tsx +132 -8
  32. package/src/react/context-menu/styles.ts +8 -0
  33. package/src/react/index.ts +32 -0
  34. package/src/react/input/color-styles.ts +176 -0
  35. package/src/react/input/color-swatch.tsx +96 -0
  36. package/src/react/input/color.tsx +527 -0
  37. package/src/react/input/index.tsx +77 -6
  38. package/src/react/input/types.ts +63 -3
  39. package/dist/password-C8lG4Zm9.d.ts +0 -71
  40. /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
- /** Open at a point. False when there was nothing to show, so the press is left alone. */
23
- open: (point: ContextMenuPoint) => boolean;
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";