@openpresentation/opf-editor 0.26.10 → 0.28.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 (52) hide show
  1. package/README.md +60 -6
  2. package/dist/canvas-variables.d.ts +15 -0
  3. package/dist/canvas-variables.js +52 -0
  4. package/dist/canvas.js +33 -5
  5. package/dist/design-controls.js +3 -2
  6. package/dist/design-options.js +1 -1
  7. package/dist/index.d.ts +37 -1
  8. package/dist/index.js +50 -3
  9. package/dist/json-editor.d.ts +33 -0
  10. package/dist/json-editor.js +83 -2
  11. package/dist/layout-adapter.js +96 -0
  12. package/dist/layout-canvas.js +122 -232
  13. package/dist/layout-editor-ui.js +46 -185
  14. package/dist/layout-inspector.js +36 -99
  15. package/dist/layout-layers.js +75 -92
  16. package/dist/layout-model.js +62 -103
  17. package/dist/react.d.ts +38 -0
  18. package/dist/react.js +127 -0
  19. package/dist/slide-retention.js +13 -0
  20. package/dist/source-color-scheme.js +60 -0
  21. package/dist/source-pane.d.ts +85 -0
  22. package/dist/source-pane.js +151 -9
  23. package/dist/studio-kit/commands.d.ts +38 -0
  24. package/dist/studio-kit/commands.js +148 -0
  25. package/dist/studio-kit/dom.d.ts +36 -0
  26. package/dist/studio-kit/dom.js +162 -0
  27. package/dist/studio-kit/drag.d.ts +26 -0
  28. package/dist/studio-kit/drag.js +127 -0
  29. package/dist/studio-kit/gesture.d.ts +29 -0
  30. package/dist/studio-kit/gesture.js +87 -0
  31. package/dist/studio-kit/index.d.ts +29 -0
  32. package/dist/studio-kit/index.js +12 -0
  33. package/dist/studio-kit/inspector.d.ts +22 -0
  34. package/dist/studio-kit/inspector.js +119 -0
  35. package/dist/studio-kit/overlay.d.ts +29 -0
  36. package/dist/studio-kit/overlay.js +182 -0
  37. package/dist/studio-kit/selection.d.ts +30 -0
  38. package/dist/studio-kit/selection.js +106 -0
  39. package/dist/studio-kit/tree.d.ts +9 -0
  40. package/dist/studio-kit/tree.js +82 -0
  41. package/dist/studio-kit/viewport.d.ts +29 -0
  42. package/dist/studio-kit/viewport.js +153 -0
  43. package/dist/styles.css +325 -7
  44. package/dist/workspace-layouts.d.ts +31 -0
  45. package/dist/workspace-layouts.js +84 -0
  46. package/dist/workspace-measure.js +35 -0
  47. package/dist/workspace-shell.js +96 -0
  48. package/dist/workspace-source.js +83 -0
  49. package/dist/workspace-theme.js +142 -0
  50. package/dist/workspace.d.ts +413 -0
  51. package/dist/workspace.js +1274 -0
  52. package/package.json +29 -11
@@ -1,122 +1,61 @@
1
- // Shared pieces of the layout editor's parts (RR-99): DOM helpers, the inline icons (Lucide, ISC license), the menu used by the context
2
- // menu and the heading tool, and the keyboard shortcuts every part honors. Plain DOM; no network.
1
+ // Shared pieces of the layout editor's parts (RR-99): the studio kit's DOM helpers with the "opf-lx" class prefix, the layout commands and
2
+ // the keyboard shortcuts every part honors (one command registry, CE-18), the context menu items, and the region wireframe. Plain DOM; no
3
+ // network. The icons, `openMenu` and the typing-target guard live in the studio kit (src/studio-kit/dom.js) and are re-exported here.
4
+ import { createDom as createKitDom, isMac, isTypingTarget, nextId, openMenu, pct } from "./studio-kit/dom.js";
5
+ import { createCommandRegistry } from "./studio-kit/commands.js";
3
6
 
4
- // Lucide icons (ISC license), as path data.
5
- const ICONS = {
6
- 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"/>',
7
- Columns2: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/>',
8
- Rows2: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/>',
9
- Plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
10
- Heading: '<path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/>',
11
- 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"/>',
12
- 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"/>',
13
- 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"/>',
14
- 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"/>',
15
- ChevronRight: '<path d="m9 18 6-6-6-6"/>',
16
- ChevronDown: '<path d="m6 9 6 6 6-6"/>',
17
- X: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
18
- Check: '<path d="M20 6 9 17l-5-5"/>',
19
- CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
20
- Info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
21
- 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"/>',
22
- 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"/>',
23
- 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"/>',
24
- 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"/>',
25
- 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"/>',
26
- 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"/>',
27
- Square: '<rect width="18" height="18" x="3" y="3" rx="2"/>',
28
- 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"/>',
29
- 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"/>',
30
- Unfold: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
31
- 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"/>',
32
- AlignLeft: '<path d="M15 12H3"/><path d="M17 18H3"/><path d="M21 6H3"/>',
33
- 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"/>',
34
- 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"/>',
35
- Play: '<polygon points="6 3 20 12 6 21 6 3"/>',
36
- ChartColumn: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
37
- Table: '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
38
- Code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
39
- 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"/>',
40
- 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"/>',
41
- 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"/>',
42
- 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"/>',
43
- };
44
- /** The Lucide icon for a content kind, shown in the wireframe and on the chips. */
45
- export const KIND_ICONS = { text: "AlignLeft", list: "List", image: "Image", video: "Play", chart: "ChartColumn", table: "Table", code: "Code", metric: "Hash", quote: "Quote", timeline: "GitCommitHorizontal", group: "Group" };
7
+ export { isMac, isTypingTarget, nextId, openMenu, pct };
8
+ export { isHandled, markHandled } from "./studio-kit/commands.js";
46
9
 
47
- const SVG = "http://www.w3.org/2000/svg";
48
- let serial = 0;
49
- export const nextId = (prefix) => `${prefix}-${++serial}`;
10
+ /** The class prefix of every layout editor part. */
11
+ export const LAYOUT_PREFIX = "opf-lx";
12
+ /** The studio kit's DOM helpers bound to one document, with the layout editor's class prefix. */
13
+ export const createDom = (doc) => createKitDom(doc, { prefix: LAYOUT_PREFIX });
50
14
 
51
- /** DOM helpers bound to one document. */
52
- export function createDom(doc) {
53
- const el = (tag, className, attributes = {}) => {
54
- const node = doc.createElement(tag);
55
- if (className) node.className = className;
56
- for (const [key, value] of Object.entries(attributes)) {
57
- if (value === undefined || value === null || value === false) continue;
58
- if (key === "text") node.textContent = value; else node.setAttribute(key, value === true ? "" : String(value));
59
- }
60
- return node;
61
- };
62
- const icon = (name, size = 16) => {
63
- const svg = doc.createElementNS(SVG, "svg");
64
- svg.setAttribute("viewBox", "0 0 24 24");
65
- svg.setAttribute("width", String(size));
66
- svg.setAttribute("height", String(size));
67
- svg.setAttribute("fill", "none");
68
- svg.setAttribute("stroke", "currentColor");
69
- svg.setAttribute("stroke-width", "2");
70
- svg.setAttribute("stroke-linecap", "round");
71
- svg.setAttribute("stroke-linejoin", "round");
72
- svg.setAttribute("aria-hidden", "true");
73
- svg.setAttribute("focusable", "false");
74
- svg.setAttribute("class", `opf-lx-icon opf-lx-icon-${name.toLowerCase()}`);
75
- svg.innerHTML = ICONS[name] ?? "";
76
- return svg;
77
- };
78
- /** A real button; with `label` and an icon only, the label is its accessible name. */
79
- const button = (className, { label, text, iconName, size = 16, pressed, title, attributes = {} } = {}) => {
80
- const node = el("button", className, { type: "button", "aria-label": label, title: title ?? (text ? undefined : label), ...attributes });
81
- if (pressed !== undefined) node.setAttribute("aria-pressed", String(pressed));
82
- if (iconName) node.append(icon(iconName, size));
83
- if (text) node.append(el("span", "opf-lx-button-text", { text }));
84
- return node;
85
- };
86
- return { doc, el, icon, button };
87
- }
15
+ /** The Lucide icon for a content kind, shown in the wireframe and on the chips. */
16
+ export const KIND_ICONS = { text: "AlignLeft", list: "List", image: "Image", video: "Play", chart: "ChartColumn", table: "Table", code: "Code", metric: "Hash", quote: "Quote", timeline: "GitCommitHorizontal", group: "Group" };
88
17
 
89
- /** Whether keyboard events from this element belong to a field the person is typing in. */
90
- export function isTypingTarget(target) {
91
- if (!target || target.nodeType !== 1) return false;
92
- const tag = target.tagName;
93
- if (tag === "TEXTAREA" || tag === "SELECT") return true;
94
- if (tag === "INPUT") return !["checkbox", "radio", "button", "submit", "reset"].includes(target.type);
95
- return target.isContentEditable === true;
96
- }
97
- export const isMac = (win) => /Mac|iPhone|iPad/.test(win?.navigator?.platform ?? "");
98
- const modifier = (event) => event.metaKey || event.ctrlKey;
99
18
  /** `[` and `]` by character or by physical key, so a layout where they need AltGr still has the shortcut elsewhere. */
100
19
  export const bracket = (event) => (event.key === "[" || event.code === "BracketLeft" ? -1 : event.key === "]" || event.code === "BracketRight" ? 1 : 0);
101
20
 
102
21
  export const TOOL_KEYS = Object.freeze({ v: "select", r: "split-row", c: "split-column", i: "insert", t: "heading", h: "hand" });
22
+ const TOOL_LABELS = { select: "Select", "split-row": "Split into a row", "split-column": "Split into a column", insert: "Insert region", heading: "Heading regions", hand: "Hand" };
23
+
24
+ // The commands of the layout editor. The "global" scope is honored by every part (undo and redo, the tool letters, Preview); the "selection"
25
+ // scope by the canvas and the layers when something is selected. A command's ctx is `{ model, surface, paths }`.
26
+ const one = ({ paths }) => paths.length === 1;
27
+ const some = ({ paths }) => paths.length > 0;
28
+ const last = (paths) => paths[paths.length - 1];
29
+ const singleNode = ({ model, paths }) => (paths.length === 1 ? model.nodeAt(paths[0]) : null);
30
+ const isSplitNode = (node) => Boolean(node) && node.name === undefined;
31
+
32
+ /** The layout editor's command registry (the same commands for the keyboard, the context menu and `layoutCommands.list()`). */
33
+ export const layoutCommands = createCommandRegistry({
34
+ commands: [
35
+ { id: "layout.undo", label: "Undo", group: "Edit", keys: ["Mod-z"], run: ({ model }) => model.undo() },
36
+ { id: "layout.redo", label: "Redo", group: "Edit", keys: ["Shift-Mod-z", "Ctrl-y", "Shift-Ctrl-y"], run: ({ model }) => model.redo() },
37
+ ...Object.entries(TOOL_KEYS).map(([key, tool]) => ({ id: `layout.tool.${tool}`, label: TOOL_LABELS[tool], group: "Tools", keys: [key], repeat: false, run: ({ model }) => model.setTool(tool) })),
38
+ { id: "layout.preview", label: "Preview", group: "View", keys: ["p"], repeat: false, run: ({ model }) => model.setPreview(!model.preview) },
39
+ { id: "layout.move.earlier", label: "Move earlier", group: "Structure", scope: "selection", keys: ["["], when: some, run: ({ model, paths }) => model.reorder(last(paths), -1) },
40
+ { id: "layout.move.later", label: "Move later", group: "Structure", scope: "selection", keys: ["]"], when: some, run: ({ model, paths }) => model.reorder(last(paths), 1) },
41
+ { id: "layout.move.intoPrevious", label: "Move into the previous split", group: "Structure", scope: "selection", keys: ["Shift-["], when: some, run: ({ model, paths }) => model.moveInto(last(paths), -1) },
42
+ { id: "layout.move.intoNext", label: "Move into the next split", group: "Structure", scope: "selection", keys: ["Shift-]"], when: some, run: ({ model, paths }) => model.moveInto(last(paths), 1) },
43
+ { id: "layout.wrap.row", label: "Wrap in row", group: "Structure", scope: "selection", when: some, run: ({ model, paths }) => model.wrap(paths, "row") },
44
+ { id: "layout.wrap.column", label: "Wrap in column", group: "Structure", scope: "selection", when: some, run: ({ model, paths }) => model.wrap(paths, "column") },
45
+ { id: "layout.unwrap", label: "Unwrap", group: "Structure", scope: "selection", when: (ctx) => isSplitNode(singleNode(ctx)), run: ({ model, paths }) => model.unwrap(paths[0]) },
46
+ { id: "layout.duplicate", label: "Duplicate", group: "Edit", scope: "selection", keys: ["Mod-d"], when: some, run: ({ model, paths }) => model.duplicate(paths) },
47
+ { id: "layout.delete", label: "Delete", group: "Edit", scope: "selection", keys: ["Delete", "Backspace", "Shift-Delete", "Shift-Backspace"], hint: "⌫", when: some, run: ({ model, paths }) => model.remove(paths) },
48
+ { id: "layout.rename", label: "Rename", group: "Edit", scope: "selection", keys: ["F2"], when: (ctx) => one(ctx) && Boolean(singleNode(ctx)) && !isSplitNode(singleNode(ctx)), run: ({ model, paths, surface }) => model.requestRename(paths[0], surface) },
49
+ ],
50
+ });
51
+ const NODE_MENU = ["layout.wrap.row", "layout.wrap.column", "layout.unwrap", "layout.duplicate", "layout.delete", "layout.rename"];
103
52
 
104
53
  /**
105
54
  * The shortcuts every part honors: undo and redo, the tool letters, and Preview. Returns true when the event was handled. Not while the person
106
55
  * types in a field (the browser's own undo applies there).
107
56
  */
108
57
  export function handleGlobalShortcut(event, model) {
109
- if (event.opfLayoutHandled || event.defaultPrevented || isTypingTarget(event.target) || event.isComposing) return false;
110
- const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
111
- if (modifier(event) && !event.altKey) {
112
- if (key === "z") { event.preventDefault(); (event.shiftKey ? model.redo() : model.undo()); event.opfLayoutHandled = true; return true; }
113
- if (key === "y" && event.ctrlKey) { event.preventDefault(); model.redo(); event.opfLayoutHandled = true; return true; }
114
- return false;
115
- }
116
- if (event.altKey || event.shiftKey) return false;
117
- if (TOOL_KEYS[key] && !event.repeat) { event.preventDefault(); model.setTool(TOOL_KEYS[key]); event.opfLayoutHandled = true; return true; }
118
- if (key === "p" && !event.repeat) { event.preventDefault(); model.setPreview(!model.preview); event.opfLayoutHandled = true; return true; }
119
- return false;
58
+ return layoutCommands.handle(event, { model, surface: null, paths: model.selection }, { scope: "global" });
120
59
  }
121
60
 
122
61
  /**
@@ -124,87 +63,12 @@ export function handleGlobalShortcut(event, model) {
124
63
  * `Shift` with a bracket moves the node into the neighboring split.
125
64
  */
126
65
  export function handleSelectionShortcut(event, model, surface = "canvas") {
127
- if (event.opfLayoutHandled || event.defaultPrevented || isTypingTarget(event.target) || event.isComposing) return false;
128
- const paths = model.selection;
129
- if (!paths.length) return false;
130
- const done = () => { event.preventDefault(); event.opfLayoutHandled = true; return true; };
131
- const delta = bracket(event);
132
- if (delta && !modifier(event) && !event.altKey) {
133
- if (event.shiftKey) model.moveInto(paths[paths.length - 1], delta); else model.reorder(paths[paths.length - 1], delta);
134
- return done();
135
- }
136
- if (modifier(event) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "d") { model.duplicate(paths); return done(); }
137
- if (!modifier(event) && !event.altKey && (event.key === "Delete" || event.key === "Backspace")) { model.remove(paths); return done(); }
138
- if (!modifier(event) && !event.altKey && !event.shiftKey && event.key === "F2" && paths.length === 1) { model.requestRename(paths[0], surface); return done(); }
139
- return false;
140
- }
141
-
142
- /**
143
- * A menu (`role="menu"`) at a point, inside `host` (a positioned element). Items are `{ label, onSelect, disabled, role, checked, hint }`.
144
- * Arrow keys, Home, End, Enter and Space work; Escape closes and returns focus to `returnFocus`. Returns `{ element, close }`.
145
- */
146
- export function openMenu(dom, host, { x, y, items, label, returnFocus, onClose, className = "" }) {
147
- const menu = dom.el("div", `opf-lx-menu ${className}`.trim(), { role: "menu", "aria-label": label });
148
- const buttons = items.map((item) => {
149
- const role = item.role ?? "menuitem";
150
- const node = dom.el("button", "opf-lx-menuitem", { type: "button", role, tabindex: "-1", "aria-disabled": item.disabled ? "true" : undefined, "aria-checked": role === "menuitemcheckbox" ? String(Boolean(item.checked)) : undefined });
151
- node.append(dom.el("span", "opf-lx-menu-label", { text: item.label }));
152
- if (item.hint) node.append(dom.el("span", "opf-lx-menu-hint", { text: item.hint }));
153
- if (role === "menuitemcheckbox") node.prepend(dom.icon(item.checked ? "Check" : "Square", 14));
154
- node.addEventListener("click", () => {
155
- if (item.disabled) return;
156
- close();
157
- item.onSelect?.();
158
- });
159
- return node;
160
- });
161
- menu.append(...buttons);
162
- let closed = false;
163
- const outside = (event) => { if (!menu.contains(event.target)) close({ restore: false }); };
164
- function close({ restore = true } = {}) {
165
- if (closed) return;
166
- closed = true;
167
- dom.doc.removeEventListener("pointerdown", outside, true);
168
- menu.remove();
169
- if (restore) returnFocus?.focus?.({ preventScroll: true });
170
- onClose?.();
171
- }
172
- const enabled = () => buttons.filter((node) => node.getAttribute("aria-disabled") !== "true");
173
- menu.addEventListener("keydown", (event) => {
174
- const list = enabled();
175
- const at = list.indexOf(dom.doc.activeElement);
176
- const go = (index) => { event.preventDefault(); event.stopPropagation(); list[(index + list.length) % list.length]?.focus(); };
177
- if (event.key === "ArrowDown") go(at + 1);
178
- else if (event.key === "ArrowUp") go(at < 0 ? -1 : at - 1);
179
- else if (event.key === "Home") go(0);
180
- else if (event.key === "End") go(-1);
181
- else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
182
- else if (event.key === "Tab") close({ restore: false });
183
- else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); if (dom.doc.activeElement?.click) dom.doc.activeElement.click(); }
184
- });
185
- host.append(menu);
186
- const bounds = host.getBoundingClientRect();
187
- const width = menu.offsetWidth || 200, height = menu.offsetHeight || buttons.length * 32;
188
- menu.style.left = `${Math.max(4, Math.min(x - bounds.left, bounds.width - width - 4))}px`;
189
- menu.style.top = `${Math.max(4, Math.min(y - bounds.top, bounds.height - height - 4))}px`;
190
- dom.doc.addEventListener("pointerdown", outside, true);
191
- enabled()[0]?.focus();
192
- return { element: menu, close };
66
+ return layoutCommands.handle(event, { model, surface, paths: model.selection }, { scope: "selection" });
193
67
  }
194
68
 
195
69
  /** The items of the context menu for the selection `paths` (wrap in row or column, unwrap, duplicate, delete, rename). */
196
70
  export function nodeMenuItems(model, paths, surface, win) {
197
- const node = paths.length === 1 ? model.nodeAt(paths[0]) : null;
198
- const split = Boolean(node) && node.name === undefined;
199
- const mod = isMac(win) ? "⌘" : "Ctrl+";
200
- return [
201
- { label: "Wrap in row", onSelect: () => model.wrap(paths, "row") },
202
- { label: "Wrap in column", onSelect: () => model.wrap(paths, "column") },
203
- { label: "Unwrap", disabled: !split, onSelect: () => model.unwrap(paths[0]) },
204
- { label: "Duplicate", hint: `${mod}D`, onSelect: () => model.duplicate(paths) },
205
- { label: "Delete", hint: "⌫", onSelect: () => model.remove(paths) },
206
- { label: "Rename", hint: "F2", disabled: !node || split, onSelect: () => model.requestRename(paths[0], surface) },
207
- ];
71
+ return layoutCommands.menu(NODE_MENU, { model, surface, paths }, { win });
208
72
  }
209
73
 
210
74
  /** A small wireframe of the kinds a region accepts: up to three blocks, one glyph and a word each (or every kind: text, list, image). */
@@ -221,9 +85,6 @@ export function buildWireframe(dom, accepts, { count = 3, flow = "auto" } = {})
221
85
  return wire;
222
86
  }
223
87
 
224
- /** `+/- x`: round a fraction to a CSS percentage. */
225
- export const pct = (fraction) => `${Math.round(fraction * 10000) / 100}%`;
226
-
227
88
  export const SIZE_LABEL = (entry, direction) => {
228
89
  const size = entry.size;
229
90
  if (size === "auto") return "auto";
@@ -1,11 +1,13 @@
1
1
  // The inspector of the layout editor (RR-99): the properties of the selection, as form fields. With nothing selected it edits the layout
2
2
  // (id, name, summary, direction, default anchor, overflow region, design hints); a split has a direction, size and anchor; a region has
3
3
  // its name, size, accepted kinds, role, flow, max, anchor, bleed, list columns and empty rule. Every field is a command on the shared
4
- // model, so it is validated by core and undoable like a drag on the canvas.
4
+ // model, so it is validated by core and undoable like a drag on the canvas. CE-18: the panel is the studio kit's inspector frame (one
5
+ // section per selection kind) with the kit's field builders.
5
6
  import { createLayoutModel } from "./layout-model.js";
6
7
  import { LAYOUT_ANCHORS, LAYOUT_REGION_FLOWS, LAYOUT_REGION_KINDS, LAYOUT_REGION_ROLES } from "./layout-templates.js";
7
8
  import { effectiveSize, inheritedAnchor, isHeading, isSplit, listAt, parseSize } from "./layout-tree.js";
8
9
  import { KIND_ICONS, createDom, handleGlobalShortcut, nextId } from "./layout-editor-ui.js";
10
+ import { createFields, createInspectorFrame } from "./studio-kit/inspector.js";
9
11
 
10
12
  const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
11
13
  const SIZE_ERROR = "A size is a number above 0 and up to 100, or auto.";
@@ -31,72 +33,32 @@ export function createLayoutInspector(container, options = {}) {
31
33
  root.append(head, body);
32
34
  container.append(root);
33
35
 
34
- let editing = false;
35
- let checks = [];
36
- /** Text and number fields keep their focus and caret while the person types, so an edit from one does not redraw the panel; the other controls do. */
37
- const edit = (callback) => { editing = true; try { callback(); } finally { editing = false; } };
38
-
39
- // --- field builders --------------------------------------------------------------------------------------------------------------
40
- /** A labeled field. `path`/`field` tie it to core's findings: they show under the field and mark it invalid. */
41
- function field(label, control, { help, path, field: key, labelFor, group, target } = {}) {
42
- const wrap = el("div", "opf-lx-field", { "data-field-name": key ?? "" });
43
- const fieldId = `${id}-${nextId("f")}`;
44
- const focusTarget = target ?? control;
45
- if (group) {
46
- const caption = el("span", "opf-lx-label-text", { id: `${fieldId}-label`, text: label });
47
- control.setAttribute("aria-labelledby", `${fieldId}-label`);
48
- if (!control.getAttribute("role")) control.setAttribute("role", "group");
49
- wrap.append(caption);
50
- } else {
51
- if (!labelFor && !control.id) control.id = fieldId;
52
- wrap.append(el("label", "opf-lx-label-text", { for: labelFor ?? control.id, text: label }));
53
- }
54
- wrap.append(control);
55
- if (help) { const note = el("p", "opf-lx-help", { id: `${fieldId}-help`, text: help }); wrap.append(note); if (!group) focusTarget.setAttribute("aria-describedby", note.id); }
56
- const error = el("p", "opf-lx-field-error", { id: `${fieldId}-error`, role: "status", "aria-live": "polite" });
57
- error.hidden = true;
58
- wrap.append(error);
59
- if (path && key) {
60
- checks.push(() => {
61
- const issues = model.findingsFor(path, key);
62
- error.hidden = !issues.length;
63
- error.replaceChildren();
64
- if (issues.length) error.append(dom.icon("CircleX", 14), el("span", "", { text: issues.map((issue) => issue.message).join(" ") }));
65
- wrap.dataset.invalid = String(Boolean(issues.length));
66
- if (!group) { if (issues.length) { focusTarget.setAttribute("aria-invalid", "true"); focusTarget.setAttribute("aria-errormessage", error.id); } else { focusTarget.removeAttribute("aria-invalid"); focusTarget.removeAttribute("aria-errormessage"); } }
67
- });
68
- }
69
- return Object.assign(wrap, { error });
70
- }
71
- function textInput(value, { onInput, name, pattern, placeholder, multiline } = {}) {
72
- const input = el(multiline ? "textarea" : "input", "opf-lx-input", { type: multiline ? undefined : "text", "data-field": name, pattern, placeholder, spellcheck: "false", autocomplete: "off", rows: multiline ? 2 : undefined });
73
- input.value = value ?? "";
74
- input.addEventListener("input", () => edit(() => onInput(input.value)));
75
- return input;
76
- }
77
- function select(entries, current, { onChange, name }) {
78
- const input = el("select", "opf-lx-select", { "data-field": name });
79
- for (const [value, label] of entries) { const option = el("option", "", { value, text: label }); input.append(option); }
80
- input.value = current;
81
- input.addEventListener("change", () => onChange(input.value));
82
- return input;
83
- }
84
- function segmented(entries, current, { onPick, label, name }) {
85
- const wrap = el("div", "opf-lx-segmented", { role: "group", "aria-label": label });
86
- for (const [value, text, iconName] of entries) {
87
- const button = dom.button("opf-lx-segment", { text, iconName, size: 14, pressed: value === current, attributes: { "data-field": `${name}-${value}`, "data-value": value } });
88
- button.addEventListener("click", () => { if (value !== current) onPick(value); });
89
- wrap.append(button);
90
- }
91
- return wrap;
92
- }
93
- const actionButton = (text, iconName, run, { label, disabled, shortcut } = {}) => {
94
- const button = dom.button("opf-lx-action", { text, iconName, size: 14, attributes: { "aria-label": label, "aria-keyshortcuts": shortcut } });
95
- button.disabled = Boolean(disabled) || model.readOnly;
96
- button.addEventListener("click", () => run());
97
- return button;
98
- };
99
- const section = (label) => { const node = el("div", "opf-lx-section"); if (label) node.append(el("h3", "opf-lx-section-title", { text: label })); return node; };
36
+ // --- the kit's inspector frame and field builders ----------------------------------------------------------------------------------
37
+ // Text and number fields keep their focus and caret while the person types, so an edit from one does not redraw the panel; the other controls do.
38
+ const single = () => (model.selection.length === 1 ? model.nodeAt(model.selection[0]) : null);
39
+ const frame = createInspectorFrame(body, {
40
+ context: () => ({ paths: model.selection, entry: single() }),
41
+ sections: [
42
+ { id: "layout", when: ({ paths, entry }) => paths.length === 0 || (paths.length === 1 && !entry), render: () => layoutView() },
43
+ { id: "multi", when: ({ paths }) => paths.length > 1, render: (_, { paths }) => multiView(paths) },
44
+ { id: "split", when: ({ entry }) => Boolean(entry) && isSplit(entry), render: (_, { paths, entry }) => splitView(paths[0], entry) },
45
+ { id: "heading", when: ({ entry }) => Boolean(entry) && !isSplit(entry) && isHeading(entry), render: (_, { paths, entry }) => headingView(paths[0], entry) },
46
+ { id: "region", when: ({ entry }) => Boolean(entry) && !isSplit(entry) && !isHeading(entry), render: (_, { paths, entry }) => regionView(paths[0], entry) },
47
+ ],
48
+ after: ({ paths }) => {
49
+ // Findings that point at the node but at no field shown here.
50
+ const shown = new Set([...body.querySelectorAll("[data-field-name]")].map((node) => node.dataset.fieldName));
51
+ const loose = model.findingsFor(paths.length === 1 ? paths[0] : []).filter((finding) => !finding.field || !shown.has(finding.field));
52
+ if (loose.length && paths.length <= 1) {
53
+ const list = el("ul", "opf-lx-loose", { "aria-label": "Other findings" });
54
+ for (const finding of loose) { const item = el("li", "opf-lx-field-error"); item.append(dom.icon("CircleX", 14), el("span", "", { text: finding.message })); list.append(item); }
55
+ body.append(list);
56
+ }
57
+ root.dataset.density = model.density;
58
+ },
59
+ });
60
+ const edit = (callback) => frame.edit(callback);
61
+ const { field, textInput, select, segmented, actionButton, section } = createFields(dom, frame, { idPrefix: id, issues: (path, key) => model.findingsFor(path, key), readOnly: () => model.readOnly });
100
62
 
101
63
  // --- the size field: a number, and Auto where the parent is a column -----------------------------------------------------------------
102
64
  function sizeField(path, entry) {
@@ -145,11 +107,12 @@ export function createLayoutInspector(container, options = {}) {
145
107
  return holder;
146
108
  }
147
109
 
148
- function anchorField(path, inherited, current, onChange) {
110
+ function anchorField(path, inherited, current, onChange, stack = false) {
149
111
  const names = { top: "Top", middle: "Middle", bottom: "Bottom" };
150
112
  const shown = inherited ? `Inherited · ${names[inherited.value ?? "top"].toLowerCase()}${inherited.from === "default" ? " (default)" : inherited.from === "split" ? " (from the split)" : " (from the layout)"}` : "None · top";
151
113
  const input = select([["", shown], ...LAYOUT_ANCHORS.map((value) => [value, names[value]])], current ?? "", { onChange: (value) => onChange(value || undefined), name: "anchor" });
152
- const wrap = field("Anchor", input, { help: current === undefined ? (inherited ? "Not set here, so this follows the nearest split or the layout." : "Regions without their own anchor sit at the top.") : "Set here. Choose the first option to inherit again.", path, field: "anchor", labelFor: undefined });
114
+ const note = stack ? "Every entry is auto-sized, so this places the whole stack, title included. Top starts it at the top." : undefined;
115
+ const wrap = field("Anchor", input, { help: note ?? (current === undefined ? (inherited ? "Not set here, so this follows the nearest split or the layout." : "Regions without their own anchor sit at the top.") : "Set here. Choose the first option to inherit again."), path, field: "anchor", labelFor: undefined });
153
116
  wrap.dataset.inherited = String(current === undefined);
154
117
  return wrap;
155
118
  }
@@ -164,7 +127,7 @@ export function createLayoutInspector(container, options = {}) {
164
127
  field("Name", textInput(record.name, { name: "name", placeholder: "My layout", onInput: (value) => model.setLayoutProperty("name", value) }), { path: [], field: "name" }),
165
128
  field("Summary", textInput(record.summary, { name: "summary", multiline: true, placeholder: "When to use this layout", onInput: (value) => model.setLayoutProperty("summary", value) }), { path: [], field: "summary" }),
166
129
  field("Direction", segmented([["row", "Row", "Columns2"], ["column", "Column", "Rows2"]], record.direction === "row" ? "row" : "column", { label: "Direction", name: "direction", onPick: (value) => model.setLayoutProperty("direction", value) }), { group: true, path: [], field: "direction", help: "Row places the regions side by side; column stacks them." }),
167
- anchorField([], null, record.anchor, (value) => model.setLayoutProperty("anchor", value)),
130
+ anchorField([], null, record.anchor, (value) => model.setLayoutProperty("anchor", value), record.direction !== "row" && (record.regions ?? []).length > 0 && record.regions.every((entry) => effectiveSize(entry, "column") === "auto")),
168
131
  );
169
132
  const bodyNames = model.record.regions ? flatBody(record) : [];
170
133
  props.append(field("Overflow region", select([["", "Default · the first primary region"], ...bodyNames.map((name) => [name, name])], record.overflowRegion ?? "", { name: "overflowRegion", onChange: (value) => model.setLayoutProperty("overflowRegion", value || undefined) }), { path: [], field: "overflowRegion", help: "Where content that no region accepts goes." }));
@@ -291,40 +254,14 @@ export function createLayoutInspector(container, options = {}) {
291
254
  body.append(el("p", "opf-lx-help", { text: `${paths.length} siblings are selected. Wrap them in a row or column, duplicate them or delete them.` }), actions(paths, {}));
292
255
  }
293
256
 
294
- function render() {
295
- const active = doc.activeElement;
296
- const focusKey = body.contains(active) ? active.dataset?.field : undefined;
297
- checks = [];
298
- body.replaceChildren();
299
- const paths = model.selection;
300
- if (!paths.length) layoutView();
301
- else if (paths.length > 1) multiView(paths);
302
- else {
303
- const entry = model.nodeAt(paths[0]);
304
- if (!entry) layoutView();
305
- else if (isSplit(entry)) splitView(paths[0], entry);
306
- else if (isHeading(entry)) headingView(paths[0], entry);
307
- else regionView(paths[0], entry);
308
- }
309
- // Findings that point at the node but at no field shown here.
310
- const shown = new Set([...body.querySelectorAll("[data-field-name]")].map((node) => node.dataset.fieldName));
311
- const loose = model.findingsFor(paths.length === 1 ? paths[0] : []).filter((finding) => !finding.field || !shown.has(finding.field));
312
- if (loose.length && paths.length <= 1) {
313
- const list = el("ul", "opf-lx-loose", { "aria-label": "Other findings" });
314
- for (const finding of loose) { const item = el("li", "opf-lx-field-error"); item.append(dom.icon("CircleX", 14), el("span", "", { text: finding.message })); list.append(item); }
315
- body.append(list);
316
- }
317
- root.dataset.density = model.density;
318
- update();
319
- if (focusKey) body.querySelector(`[data-field="${focusKey}"]`)?.focus({ preventScroll: true });
320
- }
321
- function update() { for (const check of checks) check(); }
257
+ const render = () => frame.render();
258
+ const update = () => frame.update();
322
259
 
323
260
  root.addEventListener("keydown", (event) => { handleGlobalShortcut(event, model); });
324
261
  const unsubscribe = model.subscribe((event) => {
325
262
  if (event.type === "notice" || event.type === "tool" || event.type === "rename") return;
326
263
  if (event.type === "view") { root.dataset.density = model.density; render(); return; }
327
- if (editing) { update(); return; }
264
+ if (frame.editing) { update(); return; }
328
265
  render();
329
266
  });
330
267
  render();