@openpresentation/opf-editor 0.27.0 → 0.29.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 (80) hide show
  1. package/README.md +23 -9
  2. package/dist/canvas.d.ts +13 -1
  3. package/dist/canvas.js +39 -9
  4. package/dist/chart-options-panel.js +16 -16
  5. package/dist/codemirror-guard.js +69 -0
  6. package/dist/content-controls.js +127 -74
  7. package/dist/data-grid.js +15 -7
  8. package/dist/design-controls.d.ts +13 -0
  9. package/dist/design-controls.js +136 -125
  10. package/dist/design-options.js +1 -1
  11. package/dist/exact-source.d.ts +30 -0
  12. package/dist/font-gate.js +20 -1
  13. package/dist/json-editor.d.ts +33 -0
  14. package/dist/json-editor.js +91 -4
  15. package/dist/json-options.d.ts +2 -1
  16. package/dist/json-options.js +7 -6
  17. package/dist/layout-adapter.js +96 -0
  18. package/dist/layout-canvas.js +122 -232
  19. package/dist/layout-editor-ui.js +46 -185
  20. package/dist/layout-inspector.js +36 -99
  21. package/dist/layout-layers.js +75 -92
  22. package/dist/layout-model.js +62 -103
  23. package/dist/layout-templates.d.ts +8 -3
  24. package/dist/layout-templates.js +30 -16
  25. package/dist/numbering-panel.js +2 -3
  26. package/dist/react.d.ts +16 -4
  27. package/dist/react.js +47 -6
  28. package/dist/slide-adapter.d.ts +49 -0
  29. package/dist/slide-adapter.js +289 -0
  30. package/dist/slide-edits.d.ts +46 -0
  31. package/dist/slide-edits.js +629 -0
  32. package/dist/slide-manager.js +2 -1
  33. package/dist/slide-scene.d.ts +96 -0
  34. package/dist/slide-scene.js +413 -0
  35. package/dist/source-checker.js +45 -6
  36. package/dist/source-color-scheme.js +60 -0
  37. package/dist/source-completions.js +6 -4
  38. package/dist/source-pane.d.ts +103 -6
  39. package/dist/source-pane.js +196 -9
  40. package/dist/source-worker.js +8 -4
  41. package/dist/studio-canvas.d.ts +34 -0
  42. package/dist/studio-canvas.js +586 -0
  43. package/dist/studio-commands.d.ts +15 -0
  44. package/dist/studio-commands.js +155 -0
  45. package/dist/studio-design.d.ts +16 -0
  46. package/dist/studio-design.js +354 -0
  47. package/dist/studio-kit/commands.d.ts +38 -0
  48. package/dist/studio-kit/commands.js +148 -0
  49. package/dist/studio-kit/dom.d.ts +52 -0
  50. package/dist/studio-kit/dom.js +198 -0
  51. package/dist/studio-kit/drag.d.ts +26 -0
  52. package/dist/studio-kit/drag.js +127 -0
  53. package/dist/studio-kit/gesture.d.ts +29 -0
  54. package/dist/studio-kit/gesture.js +87 -0
  55. package/dist/studio-kit/index.d.ts +29 -0
  56. package/dist/studio-kit/index.js +12 -0
  57. package/dist/studio-kit/inspector.d.ts +22 -0
  58. package/dist/studio-kit/inspector.js +119 -0
  59. package/dist/studio-kit/overlay.d.ts +29 -0
  60. package/dist/studio-kit/overlay.js +182 -0
  61. package/dist/studio-kit/selection.d.ts +30 -0
  62. package/dist/studio-kit/selection.js +106 -0
  63. package/dist/studio-kit/tree.d.ts +9 -0
  64. package/dist/studio-kit/tree.js +82 -0
  65. package/dist/studio-kit/viewport.d.ts +29 -0
  66. package/dist/studio-kit/viewport.js +153 -0
  67. package/dist/studio-layers.d.ts +12 -0
  68. package/dist/studio-layers.js +218 -0
  69. package/dist/studio.d.ts +31 -0
  70. package/dist/studio.js +1608 -0
  71. package/dist/styles.css +1573 -123
  72. package/dist/thumbnail-cache.d.ts +2 -0
  73. package/dist/thumbnail-cache.js +10 -1
  74. package/dist/workspace-measure.js +6 -4
  75. package/dist/workspace-parts.js +20 -0
  76. package/dist/workspace-shell.js +25 -15
  77. package/dist/workspace-theme.js +3 -0
  78. package/dist/workspace.d.ts +114 -10
  79. package/dist/workspace.js +223 -49
  80. package/package.json +30 -19
@@ -0,0 +1,148 @@
1
+ // CE-18, the studio kit: the command registry and keymap. A command is `{ id, label, group, scope, keys, hint, repeat, when, run }`; the
2
+ // keyboard, the context menu, the bar's disabled states, the shortcut sheet and a host palette all read one registry, so they cannot drift.
3
+ // Keys are CodeMirror-style strings ("Mod-z", "Shift-Mod-z", "Ctrl-y", "[", "Shift-[", "Delete", "F2"). No DOM beyond the event it is given.
4
+ import { isMac, isTypingTarget } from "./dom.js";
5
+
6
+ const HANDLED = "opfStudioHandled";
7
+ /** Whether an earlier handler already acted on this key event. */
8
+ export const isHandled = (event) => Boolean(event?.[HANDLED]);
9
+ /** Mark a key event as acted on, so the handlers it bubbles to leave it alone. */
10
+ export const markHandled = (event) => { event[HANDLED] = true; };
11
+ /** Whether a shortcut may act on this event: not handled already, not prevented, not typed into a field, not an IME composition. */
12
+ export const keyGuard = (event) => !(isHandled(event) || event.defaultPrevented || isTypingTarget(event.target) || event.isComposing);
13
+
14
+ // A key can also be matched by its physical position, so a keyboard layout where "[" needs AltGr (or Shift turns it into "{") still has it.
15
+ const CODES = { "[": "BracketLeft", "]": "BracketRight" };
16
+ const MODIFIERS = new Set(["Shift", "Alt", "Mod", "Ctrl", "Meta", "Cmd"]);
17
+
18
+ /** Parse "Shift-Mod-z" into `{ key, shift, alt, mod, ctrl, meta }`. A trailing "-" is the minus key ("Mod--"). */
19
+ export function parseKey(spec) {
20
+ const text = String(spec);
21
+ if (text === "-") return { key: "-", shift: false, alt: false, mod: false, ctrl: false, meta: false };
22
+ const parts =text.endsWith("-") && text.length > 1 ? [...text.slice(0, -2).split("-").filter(Boolean), "-"] : text.split("-");
23
+ const key = parts.pop();
24
+ const out = { key, shift: false, alt: false, mod: false, ctrl: false, meta: false };
25
+ for (const part of parts) {
26
+ if (!MODIFIERS.has(part)) throw new TypeError(`Unknown modifier ${part} in ${spec}`);
27
+ if (part === "Cmd") out.meta = true; else out[part.toLowerCase()] = true;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * Whether a keyboard event matches one key spec. Shift and Alt must be exactly as listed. `Mod` is satisfied by Cmd or Ctrl on every platform
34
+ * (so a Windows keyboard on a Mac works), `Ctrl` by Ctrl and `Meta`/`Cmd` by Cmd; with none of the three listed, neither Cmd nor Ctrl may be
35
+ * down. A one-character key compares without case; "[" and "]" also match their physical key.
36
+ */
37
+ export function matchKey(event, spec) {
38
+ const want = typeof spec === "string" ? parseKey(spec) : spec;
39
+ if (Boolean(event.shiftKey) !== want.shift || Boolean(event.altKey) !== want.alt) return false;
40
+ if (want.mod || want.ctrl || want.meta) {
41
+ if (want.mod && !(event.metaKey || event.ctrlKey)) return false;
42
+ if (want.ctrl && !event.ctrlKey) return false;
43
+ if (want.meta && !event.metaKey) return false;
44
+ } else if (event.metaKey || event.ctrlKey) return false;
45
+ const key = event.key ?? "";
46
+ if (want.key.length === 1) return key.toLowerCase() === want.key.toLowerCase() || (CODES[want.key] !== undefined && event.code === CODES[want.key]);
47
+ return key === want.key;
48
+ }
49
+
50
+ const MAC_NAMES = { shift: "⇧", alt: "⌥", ctrl: "⌃", mod: "⌘", meta: "⌘" };
51
+ const PC_NAMES = { shift: "Shift+", alt: "Alt+", ctrl: "Ctrl+", mod: "Ctrl+", meta: "Win+" };
52
+ const KEY_NAMES = { " ": "Space", ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", Escape: "Esc" };
53
+ /** A key spec as people read it: "⌘D" on a Mac, "Ctrl+D" elsewhere. */
54
+ export function formatKey(spec, win) {
55
+ const want = typeof spec === "string" ? parseKey(spec) : spec;
56
+ const mac = isMac(win);
57
+ const names = mac ? MAC_NAMES : PC_NAMES;
58
+ const order = mac ? ["ctrl", "alt", "shift", "mod", "meta"] : ["ctrl", "mod", "meta", "alt", "shift"];
59
+ const mods = order.filter((name) => want[name] && !(name === "ctrl" && want.mod)).map((name) => names[name]).join("");
60
+ const key = KEY_NAMES[want.key] ?? (want.key.length === 1 ? want.key.toUpperCase() : want.key);
61
+ return mods + key;
62
+ }
63
+
64
+ /**
65
+ * A command registry. `register(command)` returns the function that removes it. A command has an `id`, a `label`, optional `group`,
66
+ * `scope` (a name or a list of names; `handle` runs only the commands of the scope it is given; default "global"), `keys` (key specs),
67
+ * `hint` (what a menu shows instead of the formatted first key), `repeat` (false: a held key does not repeat it), `when(ctx)` (true, false,
68
+ * or a reason string that disables it) and `run(ctx, event)`.
69
+ */
70
+ export function createCommandRegistry({ commands = [] } = {}) {
71
+ const byId = new Map();
72
+ const overrides = new Map();
73
+ const keysOf = (command) => (overrides.has(command.id) ? overrides.get(command.id) : command.keys) ?? [];
74
+ const scopesOf = (command) => [].concat(command.scope ?? "global");
75
+ const state = (command, ctx) => {
76
+ const verdict = command.when ? command.when(ctx) : true;
77
+ return verdict === true ? { enabled: true, reason: "" } : { enabled: false, reason: typeof verdict === "string" ? verdict : "" };
78
+ };
79
+
80
+ const registry = {
81
+ register(command) {
82
+ if (!command?.id || typeof command.run !== "function") throw new TypeError("A command needs an id and a run function.");
83
+ if (byId.has(command.id)) throw new Error(`A command ${command.id} is already registered.`);
84
+ const parsed = { ...command };
85
+ byId.set(command.id, parsed);
86
+ return () => { if (byId.get(command.id) === parsed) byId.delete(command.id); };
87
+ },
88
+ get: (id) => byId.get(id),
89
+ has: (id) => byId.has(id),
90
+ /** Rebind commands: `{ id: keys }` replaces a command's keys, `{ id: null }` unbinds it. */
91
+ setKeymap(map = {}) {
92
+ for (const [id, keys] of Object.entries(map)) overrides.set(id, keys === null ? [] : [].concat(keys));
93
+ },
94
+ /** Every command with its state for `ctx`: `{ id, label, group, keys, shortcut, enabled, reason }`, in registration order. */
95
+ list(ctx, { win } = {}) {
96
+ return [...byId.values()].map((command) => {
97
+ const keys = keysOf(command);
98
+ return { id: command.id, label: command.label, group: command.group, keys: [...keys], shortcut: command.hint ?? (keys[0] ? formatKey(keys[0], win) : ""), ...state(command, ctx) };
99
+ });
100
+ },
101
+ /** Whether `id` may run for `ctx` (`when` returned true). */
102
+ enabled: (id, ctx) => (byId.has(id) ? state(byId.get(id), ctx).enabled : false),
103
+ /** Run `id` for `ctx` when it is enabled. Returns false when it is unknown or disabled, else what `run` returned (true when it returned nothing). */
104
+ run(id, ctx, event) {
105
+ const command = byId.get(id);
106
+ if (!command || !state(command, ctx).enabled) return false;
107
+ const result = command.run(ctx, event);
108
+ return result === undefined ? true : Boolean(result);
109
+ },
110
+ /** The command of `scope` a key event would run: the first whose keys match and whose `when` is true. */
111
+ match(event, ctx, { scope = "global" } = {}) {
112
+ for (const command of byId.values()) {
113
+ if (!scopesOf(command).includes(scope)) continue;
114
+ if (command.repeat === false && event.repeat) continue;
115
+ if (!keysOf(command).some((spec) => matchKey(event, spec))) continue;
116
+ if (state(command, ctx).enabled) return command;
117
+ }
118
+ return null;
119
+ },
120
+ /**
121
+ * Act on a key event: the guard (`keyGuard`), then the matching command of `scope`. The event is prevented and marked handled, and true
122
+ * is returned, whether or not the command changed anything.
123
+ */
124
+ handle(event, ctx, { scope = "global" } = {}) {
125
+ if (!keyGuard(event)) return false;
126
+ const command = registry.match(event, ctx, { scope });
127
+ if (!command) return false;
128
+ event.preventDefault();
129
+ command.run(ctx, event);
130
+ markHandled(event);
131
+ return true;
132
+ },
133
+ /**
134
+ * Menu items for `openMenu` from command ids, in order: `{ id, label, hint, disabled, title, onSelect }`. The state is read when the menu
135
+ * is built; a disabled item cannot be chosen, and choosing an enabled one runs the command for the `ctx` the menu was built with.
136
+ */
137
+ menu(ids, ctx, { win } = {}) {
138
+ return ids.filter((id) => byId.has(id)).map((id) => {
139
+ const command = byId.get(id);
140
+ const keys = keysOf(command);
141
+ const { enabled, reason } = state(command, ctx);
142
+ return { id, label: command.label, hint: command.hint ?? (keys[0] ? formatKey(keys[0], win) : undefined), disabled: !enabled, title: reason || undefined, onSelect: () => command.run(ctx) };
143
+ });
144
+ },
145
+ };
146
+ for (const command of commands) registry.register(command);
147
+ return registry;
148
+ }
@@ -0,0 +1,52 @@
1
+ /** CE-18 studio kit (internal): DOM helpers, icons, the menu and the typing-target guard. */
2
+ export declare const ICONS: Readonly<Record<string, string>>;
3
+ /** The default class prefix of kit parts ("opf-kit"); Layout Studio passes "opf-lx". */
4
+ export declare const KIT_PREFIX: string;
5
+ export declare function nextId(prefix: string): string;
6
+ export interface KitButtonOptions {
7
+ label?: string;
8
+ text?: string;
9
+ iconName?: string;
10
+ size?: number;
11
+ pressed?: boolean;
12
+ title?: string;
13
+ attributes?: Record<string, string | number | boolean | undefined | null>;
14
+ }
15
+ export interface KitDom {
16
+ doc: Document;
17
+ prefix: string;
18
+ el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, attributes?: Record<string, string | number | boolean | undefined | null>): HTMLElementTagNameMap[K];
19
+ icon(name: string, size?: number): SVGSVGElement;
20
+ button(className: string, options?: KitButtonOptions): HTMLButtonElement;
21
+ }
22
+ export declare function createDom(doc: Document, options?: { prefix?: string }): KitDom;
23
+ export declare function isTypingTarget(target: EventTarget | null): boolean;
24
+ export declare function isMac(win?: Window | null): boolean;
25
+ /** A unit fraction as a CSS percentage. */
26
+ export declare function pct(fraction: number): string;
27
+ export interface KitMenuItem {
28
+ label: string;
29
+ onSelect?(): void;
30
+ disabled?: boolean;
31
+ role?: "menuitem" | "menuitemcheckbox";
32
+ checked?: boolean;
33
+ hint?: string;
34
+ /** CE-20: a Lucide icon name drawn before the label. */
35
+ icon?: string;
36
+ /** CE-20: an element drawn before the label instead of an icon (a layout diagram). */
37
+ media?: HTMLElement;
38
+ /** CE-20: a second, quieter line under the label. */
39
+ description?: string;
40
+ /** CE-20: the item's title (why it is disabled). */
41
+ title?: string;
42
+ /** CE-20: `aria-keyshortcuts`. */
43
+ keyshortcuts?: string;
44
+ /** CE-20: consecutive items with the same group sit in a `role="group"` under a visible heading. */
45
+ group?: string;
46
+ /** CE-20: a separator instead of an item. */
47
+ separator?: boolean;
48
+ /** CE-20: a press on the item (an item that can be dragged out of the menu); `close()` closes the menu without restoring focus. */
49
+ pointerdown?(event: PointerEvent, item: KitMenuItem, menu: { close(): void }): void;
50
+ }
51
+ export interface KitMenu { element: HTMLElement; close(options?: { restore?: boolean }): void }
52
+ export declare function openMenu(dom: KitDom, host: HTMLElement, options: { x: number; y: number; items: KitMenuItem[]; label: string; returnFocus?: HTMLElement | null; onClose?(): void; className?: string }): KitMenu;
@@ -0,0 +1,198 @@
1
+ // CE-18, the studio kit: DOM helpers every studio part shares. `createDom` binds element, icon and button factories to one document and
2
+ // one class prefix (Layout Studio passes "opf-lx", so its classes are unchanged), the inline Lucide icons, `openMenu` (the menu behind every
3
+ // context menu), the typing-target guard of the shortcuts, and small formatting helpers. Plain DOM; no network; nothing about a document
4
+ // format.
5
+
6
+ // Lucide icons (ISC license), as path data.
7
+ export const ICONS = {
8
+ MousePointer2: '<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/>',
9
+ Columns2: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/>',
10
+ Rows2: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/>',
11
+ Plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
12
+ Heading: '<path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/>',
13
+ Hand: '<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>',
14
+ Eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
15
+ Undo2: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/>',
16
+ Redo2: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13"/>',
17
+ ChevronRight: '<path d="m9 18 6-6-6-6"/>',
18
+ ChevronDown: '<path d="m6 9 6 6 6-6"/>',
19
+ X: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
20
+ Check: '<path d="M20 6 9 17l-5-5"/>',
21
+ CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
22
+ Info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
23
+ Trash2: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
24
+ Copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
25
+ GripVertical: '<circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/>',
26
+ ZoomIn: '<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/>',
27
+ ZoomOut: '<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="8" x2="14" y1="11" y2="11"/>',
28
+ Maximize: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
29
+ Square: '<rect width="18" height="18" x="3" y="3" rx="2"/>',
30
+ Layers: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
31
+ Pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
32
+ Unfold: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
33
+ BetweenHorizontalStart: '<rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/>',
34
+ AlignLeft: '<path d="M15 12H3"/><path d="M17 18H3"/><path d="M21 6H3"/>',
35
+ List: '<path d="M3 12h.01"/><path d="M3 18h.01"/><path d="M3 6h.01"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M8 6h13"/>',
36
+ Image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
37
+ Play: '<polygon points="6 3 20 12 6 21 6 3"/>',
38
+ ChartColumn: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
39
+ Table: '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
40
+ Code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
41
+ Hash: '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>',
42
+ Quote: '<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
43
+ GitCommitHorizontal: '<circle cx="12" cy="12" r="3"/><line x1="3" x2="9" y1="12" y2="12"/><line x1="15" x2="21" y1="12" y2="12"/>',
44
+ Video: '<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/>',
45
+ Gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
46
+ CalendarRange: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4"/><path d="M3 10h18"/><path d="M8 2v4"/><path d="M17 14h-6"/><path d="M13 18H7"/><path d="M7 14h.01"/><path d="M17 18h.01"/>',
47
+ Type: '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/>',
48
+ Menu: '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>',
49
+ FileCode: '<path d="M10 12.5 8 15l2 2.5"/><path d="m14 12.5 2 2.5-2 2.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/>',
50
+ PanelLeft: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/>',
51
+ Presentation: '<path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/>',
52
+ Keyboard: '<path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/>',
53
+ EyeOff: '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
54
+ Ungroup: '<rect width="8" height="6" x="5" y="4" rx="1"/><rect width="8" height="6" x="11" y="14" rx="1"/>',
55
+ ArrowUp: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
56
+ IndentIncrease: '<path d="M21 12H11"/><path d="M21 18H11"/><path d="M21 6H11"/><path d="m3 8 4 4-4 4"/>',
57
+ IndentDecrease: '<path d="M21 12H11"/><path d="M21 18H11"/><path d="M21 6H11"/><path d="m7 8-4 4 4 4"/>',
58
+ ArrowDown: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
59
+ Group: '<path d="M3 7V5c0-1.1.9-2 2-2h2"/><path d="M17 3h2c1.1 0 2 .9 2 2v2"/><path d="M21 17v2c0 1.1-.9 2-2 2h-2"/><path d="M7 21H5c-1.1 0-2-.9-2-2v-2"/><rect width="7" height="5" x="7" y="7" rx="1"/><rect width="7" height="5" x="10" y="12" rx="1"/>',
60
+ };
61
+
62
+ const SVG = "http://www.w3.org/2000/svg";
63
+ let serial = 0;
64
+ /** A unique id for this page: `prefix-1`, `prefix-2`... (one counter for every part, so ids never collide across parts). */
65
+ export const nextId = (prefix) => `${prefix}-${++serial}`;
66
+
67
+ /** The default class prefix of kit parts; Layout Studio passes "opf-lx". */
68
+ export const KIT_PREFIX = "opf-kit";
69
+
70
+ /**
71
+ * DOM helpers bound to one document and one class prefix: `el(tag, className, attributes)` (`text` sets the text; `undefined`, `null` and
72
+ * `false` attributes are skipped), `icon(name, size)` (an inline Lucide icon, `aria-hidden`), and `button(className, options)` (a real
73
+ * button; with a `label` and only an icon, the label is its accessible name).
74
+ */
75
+ export function createDom(doc, { prefix = KIT_PREFIX } = {}) {
76
+ const el = (tag, className, attributes = {}) => {
77
+ const node = doc.createElement(tag);
78
+ if (className) node.className = className;
79
+ for (const [key, value] of Object.entries(attributes)) {
80
+ if (value === undefined || value === null || value === false) continue;
81
+ if (key === "text") node.textContent = value; else node.setAttribute(key, value === true ? "" : String(value));
82
+ }
83
+ return node;
84
+ };
85
+ const icon = (name, size = 16) => {
86
+ const svg = doc.createElementNS(SVG, "svg");
87
+ svg.setAttribute("viewBox", "0 0 24 24");
88
+ svg.setAttribute("width", String(size));
89
+ svg.setAttribute("height", String(size));
90
+ svg.setAttribute("fill", "none");
91
+ svg.setAttribute("stroke", "currentColor");
92
+ svg.setAttribute("stroke-width", "2");
93
+ svg.setAttribute("stroke-linecap", "round");
94
+ svg.setAttribute("stroke-linejoin", "round");
95
+ svg.setAttribute("aria-hidden", "true");
96
+ svg.setAttribute("focusable", "false");
97
+ svg.setAttribute("class", `${prefix}-icon ${prefix}-icon-${name.toLowerCase()}`);
98
+ svg.innerHTML = ICONS[name] ?? "";
99
+ return svg;
100
+ };
101
+ const button = (className, { label, text, iconName, size = 16, pressed, title, attributes = {} } = {}) => {
102
+ const node = el("button", className, { type: "button", "aria-label": label, title: title ?? (text ? undefined : label), ...attributes });
103
+ if (pressed !== undefined) node.setAttribute("aria-pressed", String(pressed));
104
+ if (iconName) node.append(icon(iconName, size));
105
+ if (text) node.append(el("span", `${prefix}-button-text`, { text }));
106
+ return node;
107
+ };
108
+ return { doc, prefix, el, icon, button };
109
+ }
110
+
111
+ /** Whether keyboard events from this element belong to a field the person is typing in (so a shortcut must not fire). */
112
+ export function isTypingTarget(target) {
113
+ if (!target || target.nodeType !== 1) return false;
114
+ const tag = target.tagName;
115
+ if (tag === "TEXTAREA" || tag === "SELECT") return true;
116
+ if (tag === "INPUT") return !["checkbox", "radio", "button", "submit", "reset"].includes(target.type);
117
+ return target.isContentEditable === true;
118
+ }
119
+ export const isMac = (win) => /Mac|iPhone|iPad/.test(win?.navigator?.platform ?? "");
120
+
121
+ /** A unit fraction as a CSS percentage, rounded to 0.01%. */
122
+ export const pct = (fraction) => `${Math.round(fraction * 10000) / 100}%`;
123
+
124
+ /**
125
+ * A menu (`role="menu"`) at a client point, inside `host` (a positioned element). Items are `{ label, onSelect, disabled, role, checked, hint }`.
126
+ * Arrow keys, Home, End, Enter and Space work; Escape closes and returns focus to `returnFocus`; a press outside closes it. Returns
127
+ * `{ element, close }`.
128
+ */
129
+ export function openMenu(dom, host, { x, y, items, label, returnFocus, onClose, className = "" }) {
130
+ const p = dom.prefix;
131
+ const menu = dom.el("div", `${p}-menu ${className}`.trim(), { role: "menu", "aria-label": label });
132
+ // CE-20: items may carry an `icon` (a Lucide name), a `media` element (a layout diagram), a `description` (a second line), a `title` (why
133
+ // it is disabled), `pointerdown(event, item)` (an item that can also be dragged out of the menu) and a `group` name: consecutive items of
134
+ // one group sit in a `role="group"` under a visible heading. `{ separator: true }` draws a separator.
135
+ let parent = menu, groupName;
136
+ const buttons = [];
137
+ for (const item of items) {
138
+ if (item.separator) { menu.append(dom.el("div", `${p}-menu-separator`, { role: "separator" })); parent = menu; groupName = undefined; continue; }
139
+ if (item.group !== groupName) {
140
+ groupName = item.group;
141
+ if (groupName) {
142
+ parent = dom.el("div", `${p}-menu-group`, { role: "group", "aria-label": groupName });
143
+ parent.append(dom.el("div", `${p}-menu-heading`, { "aria-hidden": "true", text: groupName }));
144
+ menu.append(parent);
145
+ } else parent = menu;
146
+ }
147
+ const role = item.role ?? "menuitem";
148
+ const node = dom.el("button", `${p}-menuitem`, { type: "button", role, tabindex: "-1", title: item.title, "aria-disabled": item.disabled ? "true" : undefined, "aria-checked": role === "menuitemcheckbox" ? String(Boolean(item.checked)) : undefined, "aria-keyshortcuts": item.keyshortcuts });
149
+ if (item.media) node.append(item.media);
150
+ else if (item.icon) node.append(dom.icon(item.icon, 16));
151
+ if (item.description) {
152
+ const text = dom.el("span", `${p}-menu-text`);
153
+ text.append(dom.el("span", `${p}-menu-label`, { text: item.label }), dom.el("span", `${p}-menu-description`, { text: item.description }));
154
+ node.append(text);
155
+ } else node.append(dom.el("span", `${p}-menu-label`, { text: item.label }));
156
+ if (item.hint) node.append(dom.el("span", `${p}-menu-hint`, { text: item.hint }));
157
+ if (role === "menuitemcheckbox") node.prepend(dom.icon(item.checked ? "Check" : "Square", 14));
158
+ if (item.pointerdown) node.addEventListener("pointerdown", (event) => item.pointerdown(event, item, { close: () => close({ restore: false }) }));
159
+ node.addEventListener("click", () => {
160
+ if (item.disabled) return;
161
+ close();
162
+ item.onSelect?.();
163
+ });
164
+ parent.append(node);
165
+ buttons.push(node);
166
+ }
167
+ let closed = false;
168
+ const outside = (event) => { if (!menu.contains(event.target)) close({ restore: false }); };
169
+ function close({ restore = true } = {}) {
170
+ if (closed) return;
171
+ closed = true;
172
+ dom.doc.removeEventListener("pointerdown", outside, true);
173
+ menu.remove();
174
+ if (restore) returnFocus?.focus?.({ preventScroll: true });
175
+ onClose?.();
176
+ }
177
+ const enabled = () => buttons.filter((node) => node.getAttribute("aria-disabled") !== "true");
178
+ menu.addEventListener("keydown", (event) => {
179
+ const list = enabled();
180
+ const at = list.indexOf(dom.doc.activeElement);
181
+ const go = (index) => { event.preventDefault(); event.stopPropagation(); list[(index + list.length) % list.length]?.focus(); };
182
+ if (event.key === "ArrowDown") go(at + 1);
183
+ else if (event.key === "ArrowUp") go(at < 0 ? -1 : at - 1);
184
+ else if (event.key === "Home") go(0);
185
+ else if (event.key === "End") go(-1);
186
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
187
+ else if (event.key === "Tab") close({ restore: false });
188
+ else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); if (dom.doc.activeElement?.click) dom.doc.activeElement.click(); }
189
+ });
190
+ host.append(menu);
191
+ const bounds = host.getBoundingClientRect();
192
+ const width = menu.offsetWidth || 200, height = menu.offsetHeight || buttons.length * 32;
193
+ menu.style.left = `${Math.max(4, Math.min(x - bounds.left, bounds.width - width - 4))}px`;
194
+ menu.style.top = `${Math.max(4, Math.min(y - bounds.top, bounds.height - height - 4))}px`;
195
+ dom.doc.addEventListener("pointerdown", outside, true);
196
+ enabled()[0]?.focus();
197
+ return { element: menu, close };
198
+ }
@@ -0,0 +1,26 @@
1
+ /** CE-18 studio kit (internal): the drag controller (threshold, touch hold, trial drop, Escape), pointer tracking and the drag label. */
2
+ import type { KitDom } from "./dom.js";
3
+ export interface KitPress<Payload> { x: number; y: number; id: number; type: string; time: number; payload: Payload }
4
+ export interface KitDragOptions<Payload, Session> {
5
+ doc?: Document;
6
+ threshold?: number;
7
+ holdMs?: number;
8
+ now?(): number;
9
+ onStart?(press: KitPress<Payload>): Session | null | undefined;
10
+ onStarted?(session: Session): void;
11
+ onMove?(session: Session, event: PointerEvent): void;
12
+ onEnd?(session: Session, info: { cancelled: boolean }): void;
13
+ onDrop?(session: Session, event: PointerEvent): void;
14
+ onEscape?(session: Session): void;
15
+ }
16
+ export interface KitDragController<Payload, Session> {
17
+ readonly active: Session | null;
18
+ readonly pending: KitPress<Payload> | null;
19
+ press(event: PointerEvent, payload: Payload): void;
20
+ move(event: PointerEvent): boolean;
21
+ release(event: PointerEvent): boolean;
22
+ cancel(): boolean;
23
+ }
24
+ export declare function createDragController<Payload, Session>(options?: KitDragOptions<Payload, Session>): KitDragController<Payload, Session>;
25
+ export declare function trackPointer(node: HTMLElement, options: { doc?: Document; onMove(event: PointerEvent): void; onEnd(commit: boolean): void }): { cancel(): void };
26
+ export declare function dragGhost(dom: KitDom, host: HTMLElement, text: string): { element: HTMLElement; moveTo(event: { clientX: number; clientY: number }): void; remove(): void };
@@ -0,0 +1,127 @@
1
+ // CE-18, the studio kit: dragging. `createDragController` turns a press into a drag once the pointer moves past a threshold (on touch, after a
2
+ // hold, so a scroll is never taken for a move), runs the host's trial drop on each move, lands it once on release, and cancels on Escape.
3
+ // `trackPointer` follows one captured pointer (a divider) until it is released or cancelled. `dragGhost` is the label that follows the
4
+ // pointer. No document knowledge: the host decides what a drag carries and what a drop does.
5
+
6
+ /**
7
+ * A drag controller. Options:
8
+ * - `doc`: the document (for the Escape key while dragging);
9
+ * - `threshold`: the distance in pixels before a press becomes a drag (default 4);
10
+ * - `holdMs`: on touch, how long the press must be held before movement drags (default 0); moving earlier abandons the press;
11
+ * - `onStart(pending)`: make the drag session from the press (`{ x, y, id, type, time, payload }`); null or undefined refuses it;
12
+ * - `onStarted(session)`: after the controller holds the session (paint the dragging state here);
13
+ * - `onMove(session, event)`: each move while dragging (the trial drop);
14
+ * - `onEnd(session, { cancelled })`: clean up, before `onDrop` or after a cancel;
15
+ * - `onDrop(session, event)`: the release (land the drop);
16
+ * - `onEscape(session)`: after Escape cancelled the drag.
17
+ * The host forwards its pointer events: `press(event, payload)`, `move(event)` (true while dragging), `release(event)` (true when it ended a
18
+ * drag) and `cancel()`.
19
+ */
20
+ export function createDragController(options = {}) {
21
+ const threshold = options.threshold ?? 4;
22
+ const holdMs = options.holdMs ?? 0;
23
+ const now = options.now ?? (() => Date.now());
24
+ let pending = null;
25
+ let active = null;
26
+
27
+ const onKey = (event) => {
28
+ if (event.key !== "Escape" || !active) return;
29
+ event.preventDefault();
30
+ event.stopPropagation();
31
+ const session = active;
32
+ controller.cancel();
33
+ options.onEscape?.(session);
34
+ };
35
+ const listen = (on) => { if (options.doc) (on ? options.doc.addEventListener : options.doc.removeEventListener).call(options.doc, "keydown", onKey, true); };
36
+
37
+ function start() {
38
+ const session = options.onStart ? options.onStart(pending) : pending;
39
+ if (session === null || session === undefined) { pending = null; return false; }
40
+ active = session;
41
+ listen(true);
42
+ options.onStarted?.(session);
43
+ return true;
44
+ }
45
+ function end(cancelled) {
46
+ const session = active;
47
+ listen(false);
48
+ active = null;
49
+ pending = null;
50
+ options.onEnd?.(session, { cancelled });
51
+ return session;
52
+ }
53
+
54
+ const controller = {
55
+ get active() { return active; },
56
+ get pending() { return pending; },
57
+ /** A press that may become a drag; `payload` is what the drag would carry. */
58
+ press(event, payload) {
59
+ pending = { x: event.clientX, y: event.clientY, id: event.pointerId, type: event.pointerType, time: now(), payload };
60
+ },
61
+ /** A move: starts the drag past the threshold, then reports each move. Returns true while a drag runs. */
62
+ move(event) {
63
+ if (pending && !active && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) > threshold) {
64
+ if (holdMs > 0 && pending.type === "touch" && now() - pending.time < holdMs) pending = null;
65
+ else start();
66
+ }
67
+ if (!active) return false;
68
+ options.onMove?.(active, event);
69
+ return true;
70
+ },
71
+ /** The release: lands the drag (`onEnd`, then `onDrop`) and returns true, or forgets the press and returns false. */
72
+ release(event) {
73
+ if (!active) { pending = null; return false; }
74
+ const session = end(false);
75
+ options.onDrop?.(session, event);
76
+ return true;
77
+ },
78
+ /** Drop the press, and the drag if one runs (`onEnd` with `cancelled`). */
79
+ cancel() {
80
+ if (!active) { pending = null; return false; }
81
+ end(true);
82
+ return true;
83
+ },
84
+ };
85
+ return controller;
86
+ }
87
+
88
+ /**
89
+ * Follow one captured pointer on `node` from a press: `onMove(event)` on each move, then `onEnd(commit)`, true on release and false on
90
+ * cancel or Escape. Returns `{ cancel }`.
91
+ */
92
+ export function trackPointer(node, { doc, onMove, onEnd }) {
93
+ let done = false;
94
+ const finish = (commit) => {
95
+ if (done) return;
96
+ done = true;
97
+ node.removeEventListener("pointermove", onMove);
98
+ node.removeEventListener("pointerup", onUp);
99
+ node.removeEventListener("pointercancel", onCancel);
100
+ doc?.removeEventListener("keydown", onKey, true);
101
+ onEnd(commit);
102
+ };
103
+ const onUp = () => finish(true);
104
+ const onCancel = () => finish(false);
105
+ const onKey = (event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); finish(false); } };
106
+ node.addEventListener("pointermove", onMove);
107
+ node.addEventListener("pointerup", onUp);
108
+ node.addEventListener("pointercancel", onCancel);
109
+ doc?.addEventListener("keydown", onKey, true);
110
+ return { cancel: () => finish(false) };
111
+ }
112
+
113
+ /** The label that follows the pointer during a drag ("Text", "3 blocks"), inside `host` (a positioned element). */
114
+ export function dragGhost(dom, host, text) {
115
+ const ghost = dom.el("div", `${dom.prefix}-ghost`, { "aria-hidden": "true", text });
116
+ host.append(ghost);
117
+ return {
118
+ element: ghost,
119
+ /** Put the label just below and right of the pointer. */
120
+ moveTo(event) {
121
+ const box = host.getBoundingClientRect();
122
+ ghost.style.left = `${event.clientX - box.left + 12}px`;
123
+ ghost.style.top = `${event.clientY - box.top + 12}px`;
124
+ },
125
+ remove() { ghost.remove(); },
126
+ };
127
+ }
@@ -0,0 +1,29 @@
1
+ /** CE-18 studio kit (internal): gesture history (drafts while a gesture runs, one step at its end) and a snapshot history. */
2
+ export interface KitGestureAdapter<Snapshot, Draft> {
3
+ snapshot(): Snapshot;
4
+ preview(draft: Draft, message?: string): void;
5
+ commit(start: Snapshot, info: { label: string; message: string }): boolean;
6
+ restore(start: Snapshot): void;
7
+ }
8
+ export interface KitGesture<Draft> {
9
+ readonly active: boolean;
10
+ readonly label: string | null;
11
+ begin(label: string): boolean;
12
+ update(draft: Draft, message?: string): boolean;
13
+ end(message?: string): boolean;
14
+ cancel(): boolean;
15
+ reset(): void;
16
+ }
17
+ export declare function createGesture<Snapshot, Draft>(adapter: KitGestureAdapter<Snapshot, Draft>): KitGesture<Draft>;
18
+ export interface KitHistory<Snapshot extends object> {
19
+ readonly index: number;
20
+ readonly size: number;
21
+ readonly head: (Snapshot & { coalesce?: string | null; time?: number }) | undefined;
22
+ reset(snapshot: Snapshot): void;
23
+ push(snapshot: Snapshot, coalesce?: string | null): void;
24
+ append(snapshot: Snapshot): void;
25
+ canUndo(): boolean;
26
+ canRedo(): boolean;
27
+ go(step: number): (Snapshot & { coalesce?: string | null; time?: number }) | null;
28
+ }
29
+ export declare function createHistory<Snapshot extends object>(options?: { limit?: number; coalesceMs?: number; now?(): number }): KitHistory<Snapshot>;