@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
@@ -1,3 +1,4 @@
1
+ import { writeValue, ColorSwatch } from './chunk-KVLSOQTI.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
- // src/react/input/write-value.ts
21
- function writeValue(input, next) {
22
- const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
23
- if (setter) setter.call(input, next);
24
- else input.value = next;
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-T63FLJNH.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,
@@ -64,9 +71,11 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
64
71
  const innerRef = useRef(null);
65
72
  const [element, setElement] = useState(null);
66
73
  const [revealed, setRevealed] = useState(false);
74
+ const [colorWanted, setColorWanted] = useState(false);
67
75
  const describedBy = useId();
68
76
  const isPassword = type === "password";
69
77
  const isSearch = type === "search";
78
+ const isColor = type === "color";
70
79
  const showReveal = reveal ?? isPassword;
71
80
  const showGenerate = generate !== false && isPassword;
72
81
  const showClear = (clearable ?? isSearch) && isSearch;
@@ -192,10 +201,42 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
192
201
  "data-score": score ?? void 0,
193
202
  children: [
194
203
  /* @__PURE__ */ jsxs("div", { ...fieldProps, "data-enigma-input-field": "", children: [
204
+ isColor && /* @__PURE__ */ jsx(
205
+ Suspense,
206
+ {
207
+ fallback: /* @__PURE__ */ jsx(
208
+ ColorSwatch,
209
+ {
210
+ value,
211
+ locked,
212
+ baseline: styles !== false,
213
+ label: colorLabels?.open ?? "Pick a colour",
214
+ onPress: () => setColorWanted(true)
215
+ }
216
+ ),
217
+ children: /* @__PURE__ */ jsx(
218
+ ColorExtras,
219
+ {
220
+ openOnMount: colorWanted,
221
+ input: element,
222
+ value,
223
+ format,
224
+ alpha,
225
+ swatches,
226
+ eyedropper,
227
+ placement,
228
+ styles,
229
+ locked,
230
+ labels: colorLabels
231
+ }
232
+ )
233
+ }
234
+ ),
195
235
  position === "start" && buttons,
196
236
  /* @__PURE__ */ jsx(
197
237
  "input",
198
238
  {
239
+ ...isColor ? COLOR_FIELD_PROPS : null,
199
240
  ...rest,
200
241
  ref: (node) => {
201
242
  innerRef.current = node;
@@ -203,7 +244,7 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
203
244
  if (typeof forwardedRef === "function") forwardedRef(node);
204
245
  else if (forwardedRef) forwardedRef.current = node;
205
246
  },
206
- type: revealed && isPassword ? "text" : type,
247
+ type: isColor ? "text" : revealed && isPassword ? "text" : type,
207
248
  onChange: handleChange,
208
249
  "data-enigma-input": "",
209
250
  "aria-describedby": score !== null ? describedBy : rest["aria-describedby"]
@@ -1,4 +1,4 @@
1
- import { createContextMenu, isAction } from './chunk-3BQVOOAM.js';
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) => latest.current.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
  }
@@ -0,0 +1,66 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+
3
+ // src/react/input/color-swatch.tsx
4
+ var BASELINE = {
5
+ display: "block",
6
+ boxSizing: "border-box",
7
+ width: "1.25rem",
8
+ height: "1.25rem",
9
+ padding: 0,
10
+ background: "none",
11
+ border: "1px solid var(--enigma-color-border, #404040)",
12
+ borderRadius: "0.3125rem",
13
+ overflow: "hidden",
14
+ cursor: "pointer"
15
+ };
16
+ function ColorSwatch({ value, open = false, invalid = false, locked = false, label, anchorRef, buttonRef, onPress, baseline = false, children }) {
17
+ return /* @__PURE__ */ jsxs(
18
+ "span",
19
+ {
20
+ ref: anchorRef,
21
+ "data-enigma-color": "",
22
+ "data-open": open ? "" : void 0,
23
+ style: baseline ? { position: "relative", display: "inline-flex", flex: "none" } : void 0,
24
+ children: [
25
+ /* @__PURE__ */ jsx(
26
+ "button",
27
+ {
28
+ ref: buttonRef,
29
+ type: "button",
30
+ "data-enigma-color-swatch": "",
31
+ "data-enigma-color-checkers": "",
32
+ "data-invalid": invalid ? "" : void 0,
33
+ "aria-haspopup": "dialog",
34
+ "aria-expanded": open,
35
+ "aria-label": label,
36
+ title: label,
37
+ disabled: locked,
38
+ style: baseline ? BASELINE : void 0,
39
+ onClick: onPress,
40
+ children: /* @__PURE__ */ jsx(
41
+ "span",
42
+ {
43
+ "data-enigma-color-fill": "",
44
+ style: {
45
+ background: invalid ? void 0 : value,
46
+ ...baseline ? { display: "block", width: "100%", height: "100%" } : null
47
+ }
48
+ }
49
+ )
50
+ }
51
+ ),
52
+ children
53
+ ]
54
+ }
55
+ );
56
+ }
57
+
58
+ // src/react/input/write-value.ts
59
+ function writeValue(input, next) {
60
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
61
+ if (setter) setter.call(input, next);
62
+ else input.value = next;
63
+ input.dispatchEvent(new Event("input", { bubbles: true }));
64
+ }
65
+
66
+ export { ColorSwatch, writeValue };
@@ -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 };
@@ -385,4 +385,148 @@ function createContextMenu(options = {}) {
385
385
  };
386
386
  }
387
387
 
388
- export { CONTEXT_MENU_SEARCH_KEYS, createContextMenu, isAction };
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 };