@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContextMenu, isAction } from './chunk-
|
|
1
|
+
import { createContextMenu, clipboardAction, performClipboardAction, inspectClipboardTarget, CLIPBOARD_PREFIX, clipboardEntries, isAction, clipboardHasText } from './chunk-Z3VDE7OA.js';
|
|
2
2
|
import { shortcutTokens } from './chunk-4VUHQFAT.js';
|
|
3
3
|
import { Slot } from './chunk-HS3X3XCW.js';
|
|
4
4
|
import { shortenQuery } from './chunk-4ZPMP47J.js';
|
|
@@ -60,6 +60,14 @@ var CONTEXT_MENU_STYLES = `
|
|
|
60
60
|
visibility: hidden;
|
|
61
61
|
}
|
|
62
62
|
[data-enigma-menu-panel][data-placed] { visibility: visible; }
|
|
63
|
+
/* No focus ring around the panel. It takes focus so the keyboard has somewhere to be, not
|
|
64
|
+
because it is a control: what MOVES is the highlighted row, drawn by [data-active] and
|
|
65
|
+
announced through aria-activedescendant. A ring around the whole menu says the menu is the
|
|
66
|
+
thing focused, which is not what is happening - and it appears the moment you press an
|
|
67
|
+
arrow, over a surface that was already showing you where you are. Nothing is lost by
|
|
68
|
+
removing it, because nothing inside is reachable by Tab: the menu owns that key. */
|
|
69
|
+
[data-enigma-menu-panel]:focus,
|
|
70
|
+
[data-enigma-menu-panel]:focus-visible { outline: none; }
|
|
63
71
|
@keyframes enigma-menu-in {
|
|
64
72
|
from { opacity: 0; transform: scale(0.98); }
|
|
65
73
|
to { opacity: 1; transform: none; }
|
|
@@ -184,6 +192,32 @@ function useContextMenuContext(part) {
|
|
|
184
192
|
if (!value) throw new Error(`<${part}> must be rendered inside <ContextMenu.Root>.`);
|
|
185
193
|
return value;
|
|
186
194
|
}
|
|
195
|
+
var CLIPBOARD_ICON = {
|
|
196
|
+
viewBox: "0 0 24 24",
|
|
197
|
+
width: "1em",
|
|
198
|
+
height: "1em",
|
|
199
|
+
fill: "none",
|
|
200
|
+
stroke: "currentColor",
|
|
201
|
+
strokeWidth: 2,
|
|
202
|
+
strokeLinecap: "round",
|
|
203
|
+
strokeLinejoin: "round",
|
|
204
|
+
"aria-hidden": true
|
|
205
|
+
};
|
|
206
|
+
var CLIPBOARD_ICONS = {
|
|
207
|
+
copy: /* @__PURE__ */ jsxs("svg", { ...CLIPBOARD_ICON, children: [
|
|
208
|
+
/* @__PURE__ */ jsx("rect", { x: "9", y: "9", width: "12", height: "12", rx: "2" }),
|
|
209
|
+
/* @__PURE__ */ jsx("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })
|
|
210
|
+
] }),
|
|
211
|
+
cut: /* @__PURE__ */ jsxs("svg", { ...CLIPBOARD_ICON, children: [
|
|
212
|
+
/* @__PURE__ */ jsx("circle", { cx: "6", cy: "6", r: "3" }),
|
|
213
|
+
/* @__PURE__ */ jsx("circle", { cx: "6", cy: "18", r: "3" }),
|
|
214
|
+
/* @__PURE__ */ jsx("path", { d: "M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12" })
|
|
215
|
+
] }),
|
|
216
|
+
paste: /* @__PURE__ */ jsxs("svg", { ...CLIPBOARD_ICON, children: [
|
|
217
|
+
/* @__PURE__ */ jsx("path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }),
|
|
218
|
+
/* @__PURE__ */ jsx("rect", { x: "8", y: "2", width: "8", height: "4", rx: "1" })
|
|
219
|
+
] })
|
|
220
|
+
};
|
|
187
221
|
var injected = false;
|
|
188
222
|
function injectStyles() {
|
|
189
223
|
if (injected || typeof document === "undefined") return;
|
|
@@ -223,6 +257,7 @@ function ContextMenuRoot(props) {
|
|
|
223
257
|
loadingLabel = "Loading...",
|
|
224
258
|
emptyLabel = "Nothing here.",
|
|
225
259
|
styles = true,
|
|
260
|
+
clipboard = true,
|
|
226
261
|
children
|
|
227
262
|
} = props;
|
|
228
263
|
useLayoutEffect(() => {
|
|
@@ -242,7 +277,11 @@ function ContextMenuRoot(props) {
|
|
|
242
277
|
matcher,
|
|
243
278
|
searchKeys,
|
|
244
279
|
cacheMs,
|
|
245
|
-
onSelect: (item, path) =>
|
|
280
|
+
onSelect: (item, path) => {
|
|
281
|
+
const action = clipboardAction(item.id);
|
|
282
|
+
if (action) void performClipboardAction(action, clipboardTarget.current);
|
|
283
|
+
latest.current.onSelect?.(item, path);
|
|
284
|
+
},
|
|
246
285
|
onOpenChange: (open2) => latest.current.onOpenChange?.(open2)
|
|
247
286
|
// Built once: rebuilding it would drop the open branch and the highlight on every
|
|
248
287
|
// render. Every option below is pushed in through update().
|
|
@@ -276,9 +315,21 @@ function ContextMenuRoot(props) {
|
|
|
276
315
|
}, [items]);
|
|
277
316
|
const currentItems = useRef(items);
|
|
278
317
|
currentItems.current = items;
|
|
318
|
+
const clipboardTarget = useRef(inspectClipboardTarget(null));
|
|
319
|
+
const clipboardRows = useRef([]);
|
|
320
|
+
const clipboardOptions = useMemo(() => {
|
|
321
|
+
if (clipboard === false) return null;
|
|
322
|
+
return { icons: CLIPBOARD_ICONS, ...clipboard === true ? {} : clipboard };
|
|
323
|
+
}, [clipboard]);
|
|
324
|
+
const withClipboard = useCallback((rows) => {
|
|
325
|
+
const clip = clipboardRows.current;
|
|
326
|
+
if (clip.length === 0) return rows;
|
|
327
|
+
if (rows.length === 0) return clip;
|
|
328
|
+
return [...clip, { type: "separator", id: `${CLIPBOARD_PREFIX}rule` }, ...rows];
|
|
329
|
+
}, []);
|
|
279
330
|
useEffect(() => {
|
|
280
|
-
instance.update({ items: currentItems.current, title });
|
|
281
|
-
}, [instance, signature, title]);
|
|
331
|
+
instance.update({ items: withClipboard(currentItems.current), title });
|
|
332
|
+
}, [instance, signature, title, withClipboard]);
|
|
282
333
|
const cancelClose = useCallback(() => {
|
|
283
334
|
window.clearTimeout(closing.current);
|
|
284
335
|
}, []);
|
|
@@ -287,11 +338,23 @@ function ContextMenuRoot(props) {
|
|
|
287
338
|
closing.current = window.setTimeout(() => instance.closeBelow(level), closeDelay);
|
|
288
339
|
}, [instance, closeDelay]);
|
|
289
340
|
useEffect(() => cancelClose, [cancelClose]);
|
|
290
|
-
const open = useCallback((point) => {
|
|
341
|
+
const open = useCallback((point, target) => {
|
|
291
342
|
if (disabled) return false;
|
|
292
343
|
cancelClose();
|
|
344
|
+
if (clipboardOptions) {
|
|
345
|
+
clipboardTarget.current = inspectClipboardTarget(target ?? null);
|
|
346
|
+
clipboardRows.current = clipboardEntries(clipboardTarget.current, clipboardOptions);
|
|
347
|
+
instance.update({ items: withClipboard(currentItems.current) });
|
|
348
|
+
if (clipboardRows.current.some((entry) => isAction(entry) && clipboardAction(entry.id) === "paste")) {
|
|
349
|
+
void clipboardHasText().then((has) => {
|
|
350
|
+
if (has !== false || !instance.state.open) return;
|
|
351
|
+
clipboardRows.current = clipboardRows.current.map((entry) => isAction(entry) && clipboardAction(entry.id) === "paste" ? { ...entry, disabled: true } : entry);
|
|
352
|
+
instance.update({ items: withClipboard(currentItems.current) });
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
293
356
|
return instance.open(point);
|
|
294
|
-
}, [instance, disabled, cancelClose]);
|
|
357
|
+
}, [instance, disabled, cancelClose, clipboardOptions, withClipboard]);
|
|
295
358
|
const close = useCallback(() => {
|
|
296
359
|
cancelClose();
|
|
297
360
|
instance.close();
|
|
@@ -414,16 +477,17 @@ function ContextMenuTrigger({ asChild = false, longPress = true, children, ...pr
|
|
|
414
477
|
onContextMenu: (event) => {
|
|
415
478
|
props.onContextMenu?.(event);
|
|
416
479
|
if (event.defaultPrevented) return;
|
|
417
|
-
if (menu.open({ x: event.clientX, y: event.clientY })) event.preventDefault();
|
|
480
|
+
if (menu.open({ x: event.clientX, y: event.clientY }, event.target)) event.preventDefault();
|
|
418
481
|
},
|
|
419
482
|
onPointerDown: (event) => {
|
|
420
483
|
props.onPointerDown?.(event);
|
|
421
484
|
if (event.defaultPrevented || !longPress || event.pointerType !== "touch") return;
|
|
422
485
|
const { clientX: x, clientY: y } = event;
|
|
423
486
|
cancelPress();
|
|
487
|
+
const target = event.target;
|
|
424
488
|
press.current = { x, y, timer: window.setTimeout(() => {
|
|
425
489
|
press.current = null;
|
|
426
|
-
menu.open({ x, y });
|
|
490
|
+
menu.open({ x, y }, target);
|
|
427
491
|
}, LONG_PRESS_MS) };
|
|
428
492
|
},
|
|
429
493
|
onPointerMove: (event) => {
|
|
@@ -445,7 +509,7 @@ function ContextMenuTrigger({ asChild = false, longPress = true, children, ...pr
|
|
|
445
509
|
const wanted = event.key === "ContextMenu" || event.key === "F10" && event.shiftKey;
|
|
446
510
|
if (!wanted) return;
|
|
447
511
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
448
|
-
if (menu.open({ x: rect.left + 8, y: rect.top + 8 })) event.preventDefault();
|
|
512
|
+
if (menu.open({ x: rect.left + 8, y: rect.top + 8 }, document.activeElement)) event.preventDefault();
|
|
449
513
|
},
|
|
450
514
|
children
|
|
451
515
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { writeValue } from './chunk-S7EE57YB.js';
|
|
1
2
|
import { INPUT_ICON_PATHS } from './chunk-F25CGNQC.js';
|
|
2
3
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
4
|
import { lazy, forwardRef, useRef, useState, useId, useCallback, useEffect, Suspense } from 'react';
|
|
@@ -16,16 +17,15 @@ var ICON_PROPS = {
|
|
|
16
17
|
function Icon({ paths }) {
|
|
17
18
|
return /* @__PURE__ */ jsx("svg", { ...ICON_PROPS, children: paths.map((path) => /* @__PURE__ */ jsx("path", { d: path }, path)) });
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
26
|
-
}
|
|
20
|
+
var COLOR_FIELD_PROPS = {
|
|
21
|
+
autoComplete: "off",
|
|
22
|
+
autoCorrect: "off",
|
|
23
|
+
autoCapitalize: "off",
|
|
24
|
+
spellCheck: false
|
|
25
|
+
};
|
|
27
26
|
var PasswordExtras = lazy(() => import('./password-3DRQYAYQ.js').then((module) => ({ default: module.PasswordExtras })));
|
|
28
27
|
var SearchExtras = lazy(() => import('./search-PBZORZ7P.js').then((module) => ({ default: module.SearchExtras })));
|
|
28
|
+
var ColorExtras = lazy(() => import('./color-OPV3BJV6.js').then((module) => ({ default: module.ColorExtras })));
|
|
29
29
|
var Input = forwardRef(function Input2(props, forwardedRef) {
|
|
30
30
|
const {
|
|
31
31
|
reveal,
|
|
@@ -53,6 +53,13 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
|
|
|
53
53
|
renderResults,
|
|
54
54
|
clearable,
|
|
55
55
|
clearLabel = "Clear",
|
|
56
|
+
format,
|
|
57
|
+
alpha,
|
|
58
|
+
swatches,
|
|
59
|
+
eyedropper,
|
|
60
|
+
placement,
|
|
61
|
+
styles,
|
|
62
|
+
colorLabels,
|
|
56
63
|
wrapperProps,
|
|
57
64
|
fieldProps,
|
|
58
65
|
classNames,
|
|
@@ -67,6 +74,7 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
|
|
|
67
74
|
const describedBy = useId();
|
|
68
75
|
const isPassword = type === "password";
|
|
69
76
|
const isSearch = type === "search";
|
|
77
|
+
const isColor = type === "color";
|
|
70
78
|
const showReveal = reveal ?? isPassword;
|
|
71
79
|
const showGenerate = generate !== false && isPassword;
|
|
72
80
|
const showClear = (clearable ?? isSearch) && isSearch;
|
|
@@ -192,10 +200,26 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
|
|
|
192
200
|
"data-score": score ?? void 0,
|
|
193
201
|
children: [
|
|
194
202
|
/* @__PURE__ */ jsxs("div", { ...fieldProps, "data-enigma-input-field": "", children: [
|
|
203
|
+
isColor && /* @__PURE__ */ jsx(Suspense, { fallback: null, children: /* @__PURE__ */ jsx(
|
|
204
|
+
ColorExtras,
|
|
205
|
+
{
|
|
206
|
+
input: element,
|
|
207
|
+
value,
|
|
208
|
+
format,
|
|
209
|
+
alpha,
|
|
210
|
+
swatches,
|
|
211
|
+
eyedropper,
|
|
212
|
+
placement,
|
|
213
|
+
styles,
|
|
214
|
+
locked,
|
|
215
|
+
labels: colorLabels
|
|
216
|
+
}
|
|
217
|
+
) }),
|
|
195
218
|
position === "start" && buttons,
|
|
196
219
|
/* @__PURE__ */ jsx(
|
|
197
220
|
"input",
|
|
198
221
|
{
|
|
222
|
+
...isColor ? COLOR_FIELD_PROPS : null,
|
|
199
223
|
...rest,
|
|
200
224
|
ref: (node) => {
|
|
201
225
|
innerRef.current = node;
|
|
@@ -203,7 +227,7 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
|
|
|
203
227
|
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
204
228
|
else if (forwardedRef) forwardedRef.current = node;
|
|
205
229
|
},
|
|
206
|
-
type: revealed && isPassword ? "text" : type,
|
|
230
|
+
type: isColor ? "text" : revealed && isPassword ? "text" : type,
|
|
207
231
|
onChange: handleChange,
|
|
208
232
|
"data-enigma-input": "",
|
|
209
233
|
"aria-describedby": score !== null ? describedBy : rest["aria-describedby"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/core/color.ts
|
|
2
|
+
function clamp(value, min, max) {
|
|
3
|
+
return value < min ? min : value > max ? max : value;
|
|
4
|
+
}
|
|
5
|
+
function clampAlpha(value) {
|
|
6
|
+
return Number.isFinite(value) ? clamp(value, 0, 1) : 1;
|
|
7
|
+
}
|
|
8
|
+
function channel(value) {
|
|
9
|
+
return Math.round(clamp(value, 0, 255));
|
|
10
|
+
}
|
|
11
|
+
function ratio(token, scale) {
|
|
12
|
+
const text = token.trim();
|
|
13
|
+
const value = Number.parseFloat(text);
|
|
14
|
+
if (!Number.isFinite(value)) return Number.NaN;
|
|
15
|
+
return text.endsWith("%") ? value / 100 * scale : value;
|
|
16
|
+
}
|
|
17
|
+
function components(body) {
|
|
18
|
+
return body.split(/[\s,/]+/).map((part) => part.trim()).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
function parseColor(input) {
|
|
21
|
+
const text = input.trim().toLowerCase();
|
|
22
|
+
if (!text) return null;
|
|
23
|
+
if (text === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
|
|
24
|
+
const hex = /^#?([0-9a-f]+)$/.exec(text);
|
|
25
|
+
if (hex) return fromHex(hex[1]);
|
|
26
|
+
const functional = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text);
|
|
27
|
+
if (!functional) return null;
|
|
28
|
+
const parts = components(functional[2]);
|
|
29
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
30
|
+
const alpha = parts.length === 4 ? clampAlpha(ratio(parts[3], 1)) : 1;
|
|
31
|
+
if (functional[1].startsWith("rgb")) {
|
|
32
|
+
const values = parts.slice(0, 3).map((part) => ratio(part, 255));
|
|
33
|
+
if (values.some((value) => !Number.isFinite(value))) return null;
|
|
34
|
+
return { r: channel(values[0]), g: channel(values[1]), b: channel(values[2]), a: alpha };
|
|
35
|
+
}
|
|
36
|
+
const hue = Number.parseFloat(parts[0]);
|
|
37
|
+
const saturation = ratio(parts[1], 1);
|
|
38
|
+
const lightness = ratio(parts[2], 1);
|
|
39
|
+
if (!Number.isFinite(hue) || !Number.isFinite(saturation) || !Number.isFinite(lightness)) return null;
|
|
40
|
+
return hslToRgb({ h: hue, s: clamp(saturation, 0, 1), l: clamp(lightness, 0, 1), a: alpha });
|
|
41
|
+
}
|
|
42
|
+
function fromHex(digits) {
|
|
43
|
+
const size = digits.length;
|
|
44
|
+
if (size !== 3 && size !== 4 && size !== 6 && size !== 8) return null;
|
|
45
|
+
const short = size <= 4;
|
|
46
|
+
const at = (index) => {
|
|
47
|
+
const slice = short ? digits[index].repeat(2) : digits.slice(index * 2, index * 2 + 2);
|
|
48
|
+
return Number.parseInt(slice, 16);
|
|
49
|
+
};
|
|
50
|
+
const alpha = size === 4 || size === 8 ? at(3) / 255 : 1;
|
|
51
|
+
return { r: at(0), g: at(1), b: at(2), a: clampAlpha(alpha) };
|
|
52
|
+
}
|
|
53
|
+
function toHex(color, options = {}) {
|
|
54
|
+
const pair = (value) => channel(value).toString(16).padStart(2, "0");
|
|
55
|
+
const alpha = clampAlpha(color.a);
|
|
56
|
+
const suffix = options.alpha && alpha < 1 ? pair(Math.round(alpha * 255)) : "";
|
|
57
|
+
return `#${pair(color.r)}${pair(color.g)}${pair(color.b)}${suffix}`;
|
|
58
|
+
}
|
|
59
|
+
function formatColor(color, format = "hex", options = {}) {
|
|
60
|
+
const alpha = clampAlpha(color.a);
|
|
61
|
+
const opaque = !options.alpha || alpha >= 1;
|
|
62
|
+
const printed = Number.parseFloat(alpha.toFixed(3));
|
|
63
|
+
if (format === "hex") return toHex(color, options);
|
|
64
|
+
if (format === "rgb") {
|
|
65
|
+
const body2 = `${channel(color.r)}, ${channel(color.g)}, ${channel(color.b)}`;
|
|
66
|
+
return opaque ? `rgb(${body2})` : `rgba(${body2}, ${printed})`;
|
|
67
|
+
}
|
|
68
|
+
const hsl = rgbToHsl(color);
|
|
69
|
+
const body = `${Math.round(hsl.h)}, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%`;
|
|
70
|
+
return opaque ? `hsl(${body})` : `hsla(${body}, ${printed})`;
|
|
71
|
+
}
|
|
72
|
+
function rgbToHsv(color, fallbackHue = 0) {
|
|
73
|
+
const r = clamp(color.r, 0, 255) / 255;
|
|
74
|
+
const g = clamp(color.g, 0, 255) / 255;
|
|
75
|
+
const b = clamp(color.b, 0, 255) / 255;
|
|
76
|
+
const max = Math.max(r, g, b);
|
|
77
|
+
const span = max - Math.min(r, g, b);
|
|
78
|
+
let h = fallbackHue;
|
|
79
|
+
if (span > 0) {
|
|
80
|
+
if (max === r) h = (g - b) / span % 6;
|
|
81
|
+
else if (max === g) h = (b - r) / span + 2;
|
|
82
|
+
else h = (r - g) / span + 4;
|
|
83
|
+
h *= 60;
|
|
84
|
+
if (h < 0) h += 360;
|
|
85
|
+
}
|
|
86
|
+
return { h, s: max === 0 ? 0 : span / max, v: max, a: clampAlpha(color.a) };
|
|
87
|
+
}
|
|
88
|
+
function hsvToRgb(color) {
|
|
89
|
+
const h = (color.h % 360 + 360) % 360;
|
|
90
|
+
const s = clamp(color.s, 0, 1);
|
|
91
|
+
const v = clamp(color.v, 0, 1);
|
|
92
|
+
const c = v * s;
|
|
93
|
+
const x = c * (1 - Math.abs(h / 60 % 2 - 1));
|
|
94
|
+
const m = v - c;
|
|
95
|
+
const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
|
96
|
+
return { r: channel((r + m) * 255), g: channel((g + m) * 255), b: channel((b + m) * 255), a: clampAlpha(color.a) };
|
|
97
|
+
}
|
|
98
|
+
function rgbToHsl(color, fallbackHue = 0) {
|
|
99
|
+
const hsv = rgbToHsv(color, fallbackHue);
|
|
100
|
+
const l = hsv.v * (1 - hsv.s / 2);
|
|
101
|
+
const s = l === 0 || l === 1 ? 0 : (hsv.v - l) / Math.min(l, 1 - l);
|
|
102
|
+
return { h: hsv.h, s: clamp(s, 0, 1), l, a: hsv.a };
|
|
103
|
+
}
|
|
104
|
+
function hslToRgb(color) {
|
|
105
|
+
const s = clamp(color.s, 0, 1);
|
|
106
|
+
const l = clamp(color.l, 0, 1);
|
|
107
|
+
const v = l + s * Math.min(l, 1 - l);
|
|
108
|
+
return hsvToRgb({ h: color.h, s: v === 0 ? 0 : 2 * (1 - l / v), v, a: color.a });
|
|
109
|
+
}
|
|
110
|
+
function colorEquals(a, b) {
|
|
111
|
+
if (!a || !b) return a === b;
|
|
112
|
+
return channel(a.r) === channel(b.r) && channel(a.g) === channel(b.g) && channel(a.b) === channel(b.b) && Math.round(clampAlpha(a.a) * 255) === Math.round(clampAlpha(b.a) * 255);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export { colorEquals, formatColor, hslToRgb, hsvToRgb, parseColor, rgbToHsl, rgbToHsv, toHex };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// src/react/input/write-value.ts
|
|
2
|
+
function writeValue(input, next) {
|
|
3
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
4
|
+
if (setter) setter.call(input, next);
|
|
5
|
+
else input.value = next;
|
|
6
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export { writeValue };
|
|
@@ -385,4 +385,148 @@ function createContextMenu(options = {}) {
|
|
|
385
385
|
};
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
|
|
388
|
+
// src/core/clipboard-menu.ts
|
|
389
|
+
var CLIPBOARD_PREFIX = "enigma:clipboard:";
|
|
390
|
+
var SELECTABLE_TYPES = /* @__PURE__ */ new Set(["text", "search", "url", "tel", "password"]);
|
|
391
|
+
var EMPTY = { editable: null, writable: false, selection: "", copyable: true, range: null };
|
|
392
|
+
function isEditableElement(node) {
|
|
393
|
+
return Boolean(node && node.isContentEditable);
|
|
394
|
+
}
|
|
395
|
+
function documentSelection(element) {
|
|
396
|
+
const selection = typeof window === "undefined" ? null : window.getSelection();
|
|
397
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return "";
|
|
398
|
+
const range = selection.getRangeAt(0);
|
|
399
|
+
if (element && !range.intersectsNode(element)) return "";
|
|
400
|
+
return selection.toString();
|
|
401
|
+
}
|
|
402
|
+
function inspectClipboardTarget(node) {
|
|
403
|
+
if (typeof document === "undefined") return EMPTY;
|
|
404
|
+
const element = node instanceof Element ? node : null;
|
|
405
|
+
const field = element?.closest?.("input, textarea");
|
|
406
|
+
if (field) {
|
|
407
|
+
const password = field instanceof HTMLInputElement && field.type === "password";
|
|
408
|
+
const selectable = field instanceof HTMLTextAreaElement || SELECTABLE_TYPES.has(field.type);
|
|
409
|
+
const start = selectable ? field.selectionStart ?? 0 : 0;
|
|
410
|
+
const end = selectable ? field.selectionEnd ?? 0 : 0;
|
|
411
|
+
return {
|
|
412
|
+
editable: field,
|
|
413
|
+
writable: !field.disabled && !field.readOnly,
|
|
414
|
+
selection: selectable ? field.value.slice(start, end) : "",
|
|
415
|
+
copyable: !password,
|
|
416
|
+
range: selectable ? { start, end } : null
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const editable = isEditableElement(element) ? element.closest("[contenteditable]") ?? element : null;
|
|
420
|
+
return {
|
|
421
|
+
editable,
|
|
422
|
+
writable: Boolean(editable),
|
|
423
|
+
selection: documentSelection(element),
|
|
424
|
+
copyable: true,
|
|
425
|
+
// A contenteditable's selection is a live DOM Range the browser keeps for us; there is
|
|
426
|
+
// no pair of offsets to restore, and re-focusing the element puts the caret back.
|
|
427
|
+
range: null
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
async function clipboardHasText() {
|
|
431
|
+
if (typeof navigator === "undefined" || !navigator.clipboard?.readText) return null;
|
|
432
|
+
try {
|
|
433
|
+
const status = await navigator.permissions?.query({ name: "clipboard-read" });
|
|
434
|
+
if (status?.state !== "granted") return null;
|
|
435
|
+
return (await navigator.clipboard.readText()).length > 0;
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function clipboardEntries(target, options = {}) {
|
|
441
|
+
const { copy = true, cut = true, paste = true, labels = {}, icons = {}, clipboardEmpty = false } = options;
|
|
442
|
+
const entries = [];
|
|
443
|
+
const selected = target.selection.length > 0 && target.copyable;
|
|
444
|
+
const canPaste = typeof navigator !== "undefined" && Boolean(navigator.clipboard?.readText);
|
|
445
|
+
if (copy && selected) {
|
|
446
|
+
entries.push({ id: `${CLIPBOARD_PREFIX}copy`, label: labels.copy ?? "Copy", shortcut: "Mod+C", icon: icons.copy });
|
|
447
|
+
}
|
|
448
|
+
if (cut && selected && target.writable) {
|
|
449
|
+
entries.push({ id: `${CLIPBOARD_PREFIX}cut`, label: labels.cut ?? "Cut", shortcut: "Mod+X", icon: icons.cut });
|
|
450
|
+
}
|
|
451
|
+
if (paste && target.writable && canPaste) {
|
|
452
|
+
entries.push({
|
|
453
|
+
id: `${CLIPBOARD_PREFIX}paste`,
|
|
454
|
+
label: labels.paste ?? "Paste",
|
|
455
|
+
shortcut: "Mod+V",
|
|
456
|
+
icon: icons.paste,
|
|
457
|
+
// Listed and greyed rather than dropped: a row that disappears between two opens
|
|
458
|
+
// is read as the menu being unreliable, and every desktop menu greys this one.
|
|
459
|
+
disabled: clipboardEmpty
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return entries;
|
|
463
|
+
}
|
|
464
|
+
function clipboardAction(id) {
|
|
465
|
+
if (!id.startsWith(CLIPBOARD_PREFIX)) return null;
|
|
466
|
+
const action = id.slice(CLIPBOARD_PREFIX.length);
|
|
467
|
+
return action === "copy" || action === "cut" || action === "paste" ? action : null;
|
|
468
|
+
}
|
|
469
|
+
function restore(target) {
|
|
470
|
+
const element = target.editable;
|
|
471
|
+
if (!element) return;
|
|
472
|
+
element.focus({ preventScroll: true });
|
|
473
|
+
if (!target.range) return;
|
|
474
|
+
const field = element;
|
|
475
|
+
try {
|
|
476
|
+
field.setSelectionRange(target.range.start, target.range.end);
|
|
477
|
+
} catch {
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function writeFieldValue(field, next) {
|
|
481
|
+
const prototype = field instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
482
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
483
|
+
if (setter) setter.call(field, next);
|
|
484
|
+
else field.value = next;
|
|
485
|
+
field.dispatchEvent(new Event("input", { bubbles: true }));
|
|
486
|
+
}
|
|
487
|
+
function replaceSelection(target, text) {
|
|
488
|
+
const element = target.editable;
|
|
489
|
+
if (!element) return;
|
|
490
|
+
try {
|
|
491
|
+
if (document.execCommand("insertText", false, text)) return;
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
const field = element;
|
|
495
|
+
if (typeof field.setSelectionRange !== "function" || target.range === null) return;
|
|
496
|
+
const start = field.selectionStart ?? target.range.start;
|
|
497
|
+
const end = field.selectionEnd ?? target.range.end;
|
|
498
|
+
writeFieldValue(field, field.value.slice(0, start) + text + field.value.slice(end));
|
|
499
|
+
const caret = start + text.length;
|
|
500
|
+
try {
|
|
501
|
+
field.setSelectionRange(caret, caret);
|
|
502
|
+
} catch {
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async function performClipboardAction(action, target) {
|
|
506
|
+
restore(target);
|
|
507
|
+
if (action === "copy" || action === "cut") {
|
|
508
|
+
if (!target.selection || !target.copyable) return false;
|
|
509
|
+
try {
|
|
510
|
+
await navigator.clipboard.writeText(target.selection);
|
|
511
|
+
} catch {
|
|
512
|
+
try {
|
|
513
|
+
if (!document.execCommand("copy")) return false;
|
|
514
|
+
} catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (action === "cut" && target.writable) replaceSelection(target, "");
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
if (!target.writable) return false;
|
|
522
|
+
try {
|
|
523
|
+
const text = await navigator.clipboard.readText();
|
|
524
|
+
if (!text) return false;
|
|
525
|
+
replaceSelection(target, text);
|
|
526
|
+
return true;
|
|
527
|
+
} catch {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export { CLIPBOARD_PREFIX, CONTEXT_MENU_SEARCH_KEYS, clipboardAction, clipboardEntries, clipboardHasText, createContextMenu, inspectClipboardTarget, isAction, performClipboardAction };
|
|
@@ -171,4 +171,94 @@ interface ContextMenuInstance {
|
|
|
171
171
|
type ContextMenuMoveKey = "ArrowDown" | "ArrowUp" | "Home" | "End" | "PageDown" | "PageUp";
|
|
172
172
|
declare function createContextMenu(options?: ContextMenuOptions): ContextMenuInstance;
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
/**
|
|
175
|
+
* The rows every context menu on a desktop already has: Copy, Cut and Paste.
|
|
176
|
+
*
|
|
177
|
+
* A menu opened over a selection or over a field is expected to offer them - the browser's own
|
|
178
|
+
* does, and replacing that menu with a custom one takes them away without saying so. Which is
|
|
179
|
+
* why they are ON by default here and turned off with a prop, rather than the other way round:
|
|
180
|
+
* the defect is silent, so the default has to be the safe one.
|
|
181
|
+
*
|
|
182
|
+
* WHAT DECIDES THEY APPEAR is what was right-clicked, and it is read at the moment the menu
|
|
183
|
+
* opens, not from React state - by then the selection is settled (a `contextmenu` event fires
|
|
184
|
+
* after the press has adjusted it) and the caret is still where the visitor left it.
|
|
185
|
+
*
|
|
186
|
+
* - **Copy**, when there is selected text under the pointer.
|
|
187
|
+
* - **Cut**, when that selection is also in something writable.
|
|
188
|
+
* - **Paste**, in anything writable - and disabled when the clipboard is known to be empty.
|
|
189
|
+
*
|
|
190
|
+
* Not a React module: what a selection is, whether a field takes writes, and how text is put
|
|
191
|
+
* back into one are DOM questions, so a menu drawn by anything else gets the same rows.
|
|
192
|
+
*/
|
|
193
|
+
|
|
194
|
+
/** The ids these rows carry. Namespaced, so they can never collide with a caller's own. */
|
|
195
|
+
declare const CLIPBOARD_PREFIX = "enigma:clipboard:";
|
|
196
|
+
type ClipboardAction = "copy" | "cut" | "paste";
|
|
197
|
+
/** What the menu was opened over, as far as the clipboard is concerned. */
|
|
198
|
+
interface ClipboardTarget {
|
|
199
|
+
/** The field or contenteditable under the pointer, or null when it is neither. */
|
|
200
|
+
editable: HTMLElement | null;
|
|
201
|
+
/** Whether that element takes writes: not disabled, not read-only. */
|
|
202
|
+
writable: boolean;
|
|
203
|
+
/** The selected text, from the field's own selection or the document's. */
|
|
204
|
+
selection: string;
|
|
205
|
+
/**
|
|
206
|
+
* Whether that text may be put on the clipboard.
|
|
207
|
+
*
|
|
208
|
+
* False for a password field. The clipboard is shared with every other application on the
|
|
209
|
+
* machine and is not cleared, so a menu that copies a password out of a masked field
|
|
210
|
+
* leaks it somewhere the visitor cannot see - and the browser's own menu refuses too.
|
|
211
|
+
*/
|
|
212
|
+
copyable: boolean;
|
|
213
|
+
/** Where the selection was, so it can be put back after the menu has taken focus. */
|
|
214
|
+
range: {
|
|
215
|
+
start: number;
|
|
216
|
+
end: number;
|
|
217
|
+
} | null;
|
|
218
|
+
}
|
|
219
|
+
interface ClipboardMenuLabels {
|
|
220
|
+
copy?: string;
|
|
221
|
+
cut?: string;
|
|
222
|
+
paste?: string;
|
|
223
|
+
}
|
|
224
|
+
interface ClipboardMenuOptions {
|
|
225
|
+
copy?: boolean;
|
|
226
|
+
cut?: boolean;
|
|
227
|
+
paste?: boolean;
|
|
228
|
+
labels?: ClipboardMenuLabels;
|
|
229
|
+
/** Whatever the renderer draws icons with. `unknown`, because a core cannot know. */
|
|
230
|
+
icons?: {
|
|
231
|
+
copy?: unknown;
|
|
232
|
+
cut?: unknown;
|
|
233
|
+
paste?: unknown;
|
|
234
|
+
};
|
|
235
|
+
/** The clipboard is known to be empty, so Paste is listed and greyed rather than missing. */
|
|
236
|
+
clipboardEmpty?: boolean;
|
|
237
|
+
}
|
|
238
|
+
/** What was right-clicked, read the moment the menu opens. */
|
|
239
|
+
declare function inspectClipboardTarget(node: EventTarget | null): ClipboardTarget;
|
|
240
|
+
/**
|
|
241
|
+
* Whether the clipboard has text in it, or null when that cannot be known.
|
|
242
|
+
*
|
|
243
|
+
* Null is the common answer and not a failure: reading the clipboard needs permission, and
|
|
244
|
+
* asking for it puts a browser prompt on screen just to decide whether to grey out a row -
|
|
245
|
+
* which is a worse trade than showing an enabled Paste that turns out to do nothing. So the
|
|
246
|
+
* permission is only READ, never requested, and the clipboard is only opened where it has
|
|
247
|
+
* already been granted.
|
|
248
|
+
*/
|
|
249
|
+
declare function clipboardHasText(): Promise<boolean | null>;
|
|
250
|
+
/** The rows for this target, in the order every desktop menu puts them. */
|
|
251
|
+
declare function clipboardEntries(target: ClipboardTarget, options?: ClipboardMenuOptions): ContextMenuEntry[];
|
|
252
|
+
/** Which clipboard row an id belongs to, or null for anything that is not one of ours. */
|
|
253
|
+
declare function clipboardAction(id: string): ClipboardAction | null;
|
|
254
|
+
/**
|
|
255
|
+
* Do what the row says.
|
|
256
|
+
*
|
|
257
|
+
* Called straight from the press that chose it, so the browser still counts it as a user
|
|
258
|
+
* gesture - which is what the clipboard API requires and what makes a paste possible at all.
|
|
259
|
+
* Returns whether the action happened: a refused permission and an empty clipboard are both
|
|
260
|
+
* "no", and neither is worth an exception the caller has to catch.
|
|
261
|
+
*/
|
|
262
|
+
declare function performClipboardAction(action: ClipboardAction, target: ClipboardTarget): Promise<boolean>;
|
|
263
|
+
|
|
264
|
+
export { CLIPBOARD_PREFIX as C, CONTEXT_MENU_SEARCH_KEYS as a, type ClipboardAction as b, type ClipboardMenuLabels as c, type ClipboardMenuOptions as d, type ClipboardTarget as e, type ContextMenuAction as f, type ContextMenuEntry as g, type ContextMenuInstance as h, type ContextMenuLabel as i, type ContextMenuLevel as j, type ContextMenuMoveKey as k, type ContextMenuOptions as l, type ContextMenuPoint as m, type ContextMenuSeparator as n, type ContextMenuState as o, clipboardAction as p, clipboardEntries as q, clipboardHasText as r, createContextMenu as s, inspectClipboardTarget as t, isAction as u, performClipboardAction as v };
|