@openpresentation/opf-editor 0.27.0 → 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.
- package/README.md +4 -4
- package/dist/design-controls.js +3 -2
- package/dist/design-options.js +1 -1
- package/dist/json-editor.d.ts +33 -0
- package/dist/json-editor.js +83 -2
- package/dist/layout-adapter.js +96 -0
- package/dist/layout-canvas.js +122 -232
- package/dist/layout-editor-ui.js +46 -185
- package/dist/layout-inspector.js +36 -99
- package/dist/layout-layers.js +75 -92
- package/dist/layout-model.js +62 -103
- package/dist/react.d.ts +4 -0
- package/dist/react.js +9 -2
- package/dist/source-color-scheme.js +60 -0
- package/dist/source-pane.d.ts +85 -0
- package/dist/source-pane.js +151 -9
- package/dist/studio-kit/commands.d.ts +38 -0
- package/dist/studio-kit/commands.js +148 -0
- package/dist/studio-kit/dom.d.ts +36 -0
- package/dist/studio-kit/dom.js +162 -0
- package/dist/studio-kit/drag.d.ts +26 -0
- package/dist/studio-kit/drag.js +127 -0
- package/dist/studio-kit/gesture.d.ts +29 -0
- package/dist/studio-kit/gesture.js +87 -0
- package/dist/studio-kit/index.d.ts +29 -0
- package/dist/studio-kit/index.js +12 -0
- package/dist/studio-kit/inspector.d.ts +22 -0
- package/dist/studio-kit/inspector.js +119 -0
- package/dist/studio-kit/overlay.d.ts +29 -0
- package/dist/studio-kit/overlay.js +182 -0
- package/dist/studio-kit/selection.d.ts +30 -0
- package/dist/studio-kit/selection.js +106 -0
- package/dist/studio-kit/tree.d.ts +9 -0
- package/dist/studio-kit/tree.js +82 -0
- package/dist/studio-kit/viewport.d.ts +29 -0
- package/dist/studio-kit/viewport.js +153 -0
- package/dist/styles.css +28 -7
- package/dist/workspace.d.ts +26 -1
- package/dist/workspace.js +13 -0
- package/package.json +10 -10
package/dist/layout-editor-ui.js
CHANGED
|
@@ -1,122 +1,61 @@
|
|
|
1
|
-
// Shared pieces of the layout editor's parts (RR-99): DOM helpers
|
|
2
|
-
//
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
52
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/dist/layout-inspector.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
295
|
-
|
|
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();
|