@openpresentation/opf-editor 0.24.3 → 0.25.1
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 +69 -6
- package/dist/galleries.js +2 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.js +17 -0
- package/dist/layout-canvas.js +748 -0
- package/dist/layout-diagram.js +25 -0
- package/dist/layout-editor-ui.js +232 -0
- package/dist/layout-editor.d.ts +212 -0
- package/dist/layout-editor.js +197 -0
- package/dist/layout-inspector.js +339 -0
- package/dist/layout-layers.js +331 -0
- package/dist/layout-model.js +299 -0
- package/dist/layout-palette.js +77 -0
- package/dist/layout-samples.js +84 -0
- package/dist/layout-templates.d.ts +32 -8
- package/dist/layout-templates.js +89 -37
- package/dist/layout-tree.d.ts +87 -0
- package/dist/layout-tree.js +704 -0
- package/dist/persistence.js +2 -2
- package/dist/presenter.d.ts +4 -1
- package/dist/presenter.js +5 -3
- package/dist/slide-manager.js +3 -7
- package/dist/source-pane.d.ts +19 -1
- package/dist/source-pane.js +77 -1
- package/dist/styles.css +852 -31
- package/package.json +15 -10
- package/dist/custom-layout-editor.d.ts +0 -7
- package/dist/custom-layout-editor.js +0 -105
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// The layout editor (RR-99): a Figma-style GUI for building one OPF layout record, a `direction` plus an ordered `regions` list of regions
|
|
2
|
+
// and splits. `createLayoutEditor` composes four parts that are exported separately, so a host can arrange them differently: the tool
|
|
3
|
+
// palette, the canvas, the layers tree and the inspector. They share one model (the record, the selection, the tool, core's validation
|
|
4
|
+
// and the editor's own undo history). Plain DOM, styled only by `--opf-ui-*` tokens (src/styles/layout-editor.css); no network.
|
|
5
|
+
import { mergeCatalogs } from "./catalogs.js";
|
|
6
|
+
import { fail } from "./edit-helpers.js";
|
|
7
|
+
import { prepareDimensionSwitch } from "./switches.js";
|
|
8
|
+
import { prepareCustomLayout, saveCustomLayout } from "./layout-templates.js";
|
|
9
|
+
import { createLayoutCanvas } from "./layout-canvas.js";
|
|
10
|
+
import { createLayoutInspector } from "./layout-inspector.js";
|
|
11
|
+
import { createLayoutTree } from "./layout-layers.js";
|
|
12
|
+
import { createLayoutModel, layoutForCatalog } from "./layout-model.js";
|
|
13
|
+
import { createLayoutToolPalette } from "./layout-palette.js";
|
|
14
|
+
import { createDom, handleGlobalShortcut, nextId } from "./layout-editor-ui.js";
|
|
15
|
+
|
|
16
|
+
export { createLayoutCanvas } from "./layout-canvas.js";
|
|
17
|
+
export { createLayoutInspector } from "./layout-inspector.js";
|
|
18
|
+
export { createLayoutTree } from "./layout-layers.js";
|
|
19
|
+
export { LAYOUT_SCHEMA, LAYOUT_TOOLS, blankLayoutRecord, createLayoutModel, findingLocation, layoutForCatalog, validateLayoutRecord } from "./layout-model.js";
|
|
20
|
+
export { LAYOUT_PREVIEW_DEF, LAYOUT_TOOL_DEFS, createLayoutToolPalette } from "./layout-palette.js";
|
|
21
|
+
export { sampleDeck } from "./layout-samples.js";
|
|
22
|
+
|
|
23
|
+
const COMPACT_WIDTH = 760;
|
|
24
|
+
|
|
25
|
+
function assertEditor(editor) {
|
|
26
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
27
|
+
}
|
|
28
|
+
function checkedLayout(model) {
|
|
29
|
+
const errors = model.findings.filter((finding) => finding.severity === "error");
|
|
30
|
+
if (errors.length) throw fail("layout-invalid", `Fix the findings before saving: ${errors[0].message}`, { findings: errors });
|
|
31
|
+
return layoutForCatalog(model.record);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write the model's record into the deck's `catalogs.custom.layouts` through `saveCustomLayout`, as one undoable edit. The functions below are
|
|
36
|
+
* what the composed editor's `saveToDeck` and `applyToSlide` call; a host that arranges the parts itself calls them with the shared model.
|
|
37
|
+
* A layout with an error finding is refused, and so is an id the deck already has unless `replace` is true.
|
|
38
|
+
*/
|
|
39
|
+
export function saveLayoutToDeck(editor, model, options = {}) {
|
|
40
|
+
assertEditor(editor);
|
|
41
|
+
const layout = checkedLayout(model);
|
|
42
|
+
const change = saveCustomLayout(editor, layout, { replace: options.replace === true });
|
|
43
|
+
return { ...change, layoutId: layout.id };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Save the layout (replacing the deck's copy of the same id) and make it the layout of slide `slideIndex`, in one undoable edit. */
|
|
47
|
+
export function applyLayoutToSlide(editor, model, slideIndex, options = {}) {
|
|
48
|
+
assertEditor(editor);
|
|
49
|
+
if (!Number.isInteger(slideIndex) || !editor.presentation.slides?.[slideIndex]) throw fail("layout-slide", "Choose a slide of the deck.");
|
|
50
|
+
const layout = checkedLayout(model);
|
|
51
|
+
const saved = prepareCustomLayout(editor.presentation, layout, { replace: options.replace !== false });
|
|
52
|
+
const switched = prepareDimensionSwitch(saved.presentation, "layouts", layout.id, { slideIndex, catalogs: mergeCatalogs(editor.catalogs, options.catalogs) });
|
|
53
|
+
const change = editor.applyPatch([...saved.patches, ...switched.patches], { source: "layout-apply", rejectInvalid: true });
|
|
54
|
+
return { ...change, layoutId: layout.id, slideIndex };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mount the layout editor in `container`: palette on the left, canvas in the middle, layers and inspector on the right (in tabs under the
|
|
59
|
+
* canvas when the container is narrow).
|
|
60
|
+
*
|
|
61
|
+
* Options: `record` (the layout record to edit, default a title and one content region), `onChange(record)` (after each edit, undo and
|
|
62
|
+
* redo), `density` ("comfortable" or "touch"), `readOnly`, `toSvg` and `fonts` (the host's renderer and its fonts handle, for Preview;
|
|
63
|
+
* without them Preview draws a wireframe) and `renderOptions`. Returns the parts, the model and the API described in layout-editor.d.ts.
|
|
64
|
+
*/
|
|
65
|
+
export function createLayoutEditor(container, options = {}) {
|
|
66
|
+
if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
|
|
67
|
+
const doc = container.ownerDocument, win = doc.defaultView;
|
|
68
|
+
const dom = createDom(doc);
|
|
69
|
+
const { el } = dom;
|
|
70
|
+
const model = options.model ?? createLayoutModel(options);
|
|
71
|
+
const id = nextId("opf-layout-editor");
|
|
72
|
+
|
|
73
|
+
const root = el("div", "opf-lx opf-layout-editor opf-ui", { "data-density": model.density, "data-panel": "inspector", role: "group", "aria-label": options.label ?? "Layout editor" });
|
|
74
|
+
const topbar = el("div", "opf-lx-topbar");
|
|
75
|
+
const history = el("div", "opf-lx-history", { role: "group", "aria-label": "History" });
|
|
76
|
+
const undo = dom.button("opf-lx-top-button", { label: "Undo", iconName: "Undo2", title: "Undo (Ctrl+Z)", attributes: { "aria-keyshortcuts": "Control+Z Meta+Z" } });
|
|
77
|
+
const redo = dom.button("opf-lx-top-button", { label: "Redo", iconName: "Redo2", title: "Redo (Shift+Ctrl+Z)", attributes: { "aria-keyshortcuts": "Control+Shift+Z Meta+Shift+Z" } });
|
|
78
|
+
history.append(undo, redo);
|
|
79
|
+
const name = el("span", "opf-lx-doc-name");
|
|
80
|
+
const status = dom.button("opf-lx-validity", {});
|
|
81
|
+
topbar.append(history, name, status);
|
|
82
|
+
const main = el("div", "opf-lx-main");
|
|
83
|
+
const paletteHost = el("div", "opf-lx-palette-host");
|
|
84
|
+
const canvasHost = el("div", "opf-lx-canvas-host");
|
|
85
|
+
const side = el("div", "opf-lx-side");
|
|
86
|
+
const tabs = el("div", "opf-lx-tabs", { role: "tablist", "aria-label": "Panels" });
|
|
87
|
+
const tabLayers = el("button", "opf-lx-tab", { type: "button", role: "tab", id: `${id}-tab-layers`, "aria-controls": `${id}-layers`, text: "Layers" });
|
|
88
|
+
const tabInspector = el("button", "opf-lx-tab", { type: "button", role: "tab", id: `${id}-tab-inspector`, "aria-controls": `${id}-inspector`, text: "Inspector" });
|
|
89
|
+
tabs.append(tabLayers, tabInspector);
|
|
90
|
+
const layersHost = el("div", "opf-lx-panel", { id: `${id}-layers`, "data-panel": "layers" });
|
|
91
|
+
const inspectorHost = el("div", "opf-lx-panel", { id: `${id}-inspector`, "data-panel": "inspector" });
|
|
92
|
+
side.append(tabs, layersHost, inspectorHost);
|
|
93
|
+
main.append(paletteHost, canvasHost, side);
|
|
94
|
+
root.append(topbar, main);
|
|
95
|
+
container.append(root);
|
|
96
|
+
|
|
97
|
+
const partOptions = { model, toSvg: options.toSvg, fonts: options.fonts, renderOptions: options.renderOptions };
|
|
98
|
+
const palette = createLayoutToolPalette(paletteHost, partOptions);
|
|
99
|
+
const canvas = createLayoutCanvas(canvasHost, partOptions);
|
|
100
|
+
const layers = createLayoutTree(layersHost, { model, onFindingSelect: options.onFindingSelect });
|
|
101
|
+
const inspector = createLayoutInspector(inspectorHost, { model });
|
|
102
|
+
|
|
103
|
+
function showPanel(panel, focus = false) {
|
|
104
|
+
root.dataset.panel = panel;
|
|
105
|
+
tabLayers.setAttribute("aria-selected", String(panel === "layers"));
|
|
106
|
+
tabInspector.setAttribute("aria-selected", String(panel === "inspector"));
|
|
107
|
+
tabLayers.tabIndex = panel === "layers" ? 0 : -1;
|
|
108
|
+
tabInspector.tabIndex = panel === "inspector" ? 0 : -1;
|
|
109
|
+
if (focus) (panel === "layers" ? tabLayers : tabInspector).focus();
|
|
110
|
+
}
|
|
111
|
+
tabLayers.addEventListener("click", () => showPanel("layers"));
|
|
112
|
+
tabInspector.addEventListener("click", () => showPanel("inspector"));
|
|
113
|
+
tabs.addEventListener("keydown", (event) => {
|
|
114
|
+
if (event.key === "ArrowRight" || event.key === "ArrowLeft" || event.key === "Home" || event.key === "End") {
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
showPanel(root.dataset.panel === "layers" ? "inspector" : "layers", true);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
showPanel("inspector");
|
|
120
|
+
|
|
121
|
+
undo.addEventListener("click", () => model.undo());
|
|
122
|
+
redo.addEventListener("click", () => model.redo());
|
|
123
|
+
status.addEventListener("click", () => {
|
|
124
|
+
const first = model.findings[0];
|
|
125
|
+
if (first) { model.select(first.node.length ? [first.node] : []); showPanel("layers"); layers.focus(); }
|
|
126
|
+
});
|
|
127
|
+
function paint() {
|
|
128
|
+
const record = model.record;
|
|
129
|
+
name.replaceChildren(el("strong", "opf-lx-doc-title", { text: record.name || "Untitled layout" }), el("span", "opf-lx-doc-id", { text: record.id || "no id" }));
|
|
130
|
+
undo.disabled = !model.canUndo();
|
|
131
|
+
redo.disabled = !model.canRedo();
|
|
132
|
+
const count = model.findings.length;
|
|
133
|
+
status.replaceChildren(dom.icon(count ? "CircleX" : "Check", 14), el("span", "", { text: count ? `${count} finding${count === 1 ? "" : "s"}` : "Valid" }));
|
|
134
|
+
status.dataset.state = count ? "invalid" : "valid";
|
|
135
|
+
status.setAttribute("aria-label", count ? `${count} finding${count === 1 ? "" : "s"}. Show them in Layers.` : "The layout is valid");
|
|
136
|
+
status.disabled = !count;
|
|
137
|
+
root.dataset.density = model.density;
|
|
138
|
+
}
|
|
139
|
+
const unsubscribe = model.subscribe((event) => { if (event.type !== "notice" && event.type !== "tool" && event.type !== "rename") paint(); });
|
|
140
|
+
paint();
|
|
141
|
+
|
|
142
|
+
// The container's width decides the layout: wide shows the palette, canvas and side panels; narrow stacks them with tabs.
|
|
143
|
+
const measure = () => { root.dataset.compact = String((container.clientWidth || root.clientWidth || COMPACT_WIDTH) < COMPACT_WIDTH); };
|
|
144
|
+
let observer = null;
|
|
145
|
+
if (typeof win.ResizeObserver === "function") { observer = new win.ResizeObserver(measure); observer.observe(container); }
|
|
146
|
+
measure();
|
|
147
|
+
|
|
148
|
+
root.addEventListener("keydown", (event) => { handleGlobalShortcut(event, model); });
|
|
149
|
+
|
|
150
|
+
const api = {
|
|
151
|
+
element: root,
|
|
152
|
+
model,
|
|
153
|
+
palette,
|
|
154
|
+
canvas,
|
|
155
|
+
layers,
|
|
156
|
+
inspector,
|
|
157
|
+
refresh() { palette.refresh(); canvas.refresh(); layers.refresh(); inspector.refresh(); paint(); },
|
|
158
|
+
/** The record as it is now (a copy). */
|
|
159
|
+
getRecord: () => model.getRecord(),
|
|
160
|
+
/** Replace the record, e.g. with a layout the host loaded. Clears the selection and, unless `keepHistory`, the undo history. Does not call `onChange`. */
|
|
161
|
+
setRecord: (record, opts) => model.setRecord(record, opts),
|
|
162
|
+
/** Core's findings for the record: `{ ruleId, severity, message, path, node, field }`. */
|
|
163
|
+
getFindings: () => model.findings.map((finding) => ({ ...finding, node: [...finding.node] })),
|
|
164
|
+
getSelection: () => model.selection.map((path) => [...path]),
|
|
165
|
+
select: (path) => model.select(path ? [path] : []),
|
|
166
|
+
getTool: () => model.tool,
|
|
167
|
+
setTool: (tool) => model.setTool(tool),
|
|
168
|
+
getPreview: () => model.preview,
|
|
169
|
+
setPreview: (on) => model.setPreview(on),
|
|
170
|
+
setDensity: (density) => model.setDensity(density),
|
|
171
|
+
setReadOnly: (on) => model.setReadOnly(on),
|
|
172
|
+
undo: () => model.undo(),
|
|
173
|
+
redo: () => model.redo(),
|
|
174
|
+
canUndo: () => model.canUndo(),
|
|
175
|
+
canRedo: () => model.canRedo(),
|
|
176
|
+
/** Listen to the model (`record`, `selection`, `tool`, `view`, `load`, `notice`). Returns the unsubscribe function. */
|
|
177
|
+
subscribe: (listener) => model.subscribe(listener),
|
|
178
|
+
/**
|
|
179
|
+
* Write the record into the deck's `catalogs.custom.layouts` through `saveCustomLayout`, as one undoable edit. A layout with an error
|
|
180
|
+
* finding is refused. An id the deck already has is refused too, unless `replace` is true (editing a layout the deck already holds).
|
|
181
|
+
*/
|
|
182
|
+
saveToDeck: (editor, saveOptions) => saveLayoutToDeck(editor, model, saveOptions),
|
|
183
|
+
/** Save the layout (replacing the deck's copy of the same id) and set it as the layout of slide `slideIndex`, in one undoable edit. */
|
|
184
|
+
applyToSlide: (editor, slideIndex, applyOptions) => applyLayoutToSlide(editor, model, slideIndex, applyOptions),
|
|
185
|
+
destroy() {
|
|
186
|
+
unsubscribe();
|
|
187
|
+
observer?.disconnect();
|
|
188
|
+
palette.destroy();
|
|
189
|
+
canvas.destroy();
|
|
190
|
+
layers.destroy();
|
|
191
|
+
inspector.destroy();
|
|
192
|
+
root.remove();
|
|
193
|
+
if (!options.model) model.destroy();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
return api;
|
|
197
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
// (id, name, summary, direction, default anchor, overflow region, design hints); a split has a direction, size and anchor; a region has
|
|
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.
|
|
5
|
+
import { createLayoutModel } from "./layout-model.js";
|
|
6
|
+
import { LAYOUT_ANCHORS, LAYOUT_REGION_FLOWS, LAYOUT_REGION_KINDS, LAYOUT_REGION_ROLES } from "./layout-templates.js";
|
|
7
|
+
import { effectiveSize, inheritedAnchor, isHeading, isSplit, listAt, parseSize } from "./layout-tree.js";
|
|
8
|
+
import { KIND_ICONS, createDom, handleGlobalShortcut, nextId } from "./layout-editor-ui.js";
|
|
9
|
+
|
|
10
|
+
const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
11
|
+
const SIZE_ERROR = "A size is a number above 0 and up to 100, or auto.";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mount the inspector in `container`. Options: `model` (shared state; one is created from the other options when omitted), `label`.
|
|
15
|
+
* Returns `{ element, model, refresh, focus, destroy }`.
|
|
16
|
+
*/
|
|
17
|
+
export function createLayoutInspector(container, options = {}) {
|
|
18
|
+
if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
|
|
19
|
+
const doc = container.ownerDocument;
|
|
20
|
+
const dom = createDom(doc);
|
|
21
|
+
const { el } = dom;
|
|
22
|
+
const ownModel = !options.model;
|
|
23
|
+
const model = options.model ?? createLayoutModel(options);
|
|
24
|
+
const id = nextId("opf-lx-inspector");
|
|
25
|
+
|
|
26
|
+
const root = el("section", "opf-lx-inspector opf-ui", { "aria-labelledby": `${id}-title`, "data-density": model.density });
|
|
27
|
+
const head = el("div", "opf-lx-panel-head");
|
|
28
|
+
const title = el("h2", "opf-lx-panel-title", { id: `${id}-title` });
|
|
29
|
+
head.append(dom.icon("Pencil", 14), title);
|
|
30
|
+
const body = el("div", "opf-lx-inspector-body");
|
|
31
|
+
root.append(head, body);
|
|
32
|
+
container.append(root);
|
|
33
|
+
|
|
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; };
|
|
100
|
+
|
|
101
|
+
// --- the size field: a number, and Auto where the parent is a column -----------------------------------------------------------------
|
|
102
|
+
function sizeField(path, entry) {
|
|
103
|
+
const direction = model.parentDirection(path);
|
|
104
|
+
const list = listAt(model.record, path.slice(0, -1)) ?? [];
|
|
105
|
+
const current = effectiveSize(entry, direction);
|
|
106
|
+
const auto = entry.size === "auto" || (entry.size === undefined && current === "auto");
|
|
107
|
+
const input = el("input", "opf-lx-input opf-lx-size-input", { type: "number", min: "0.01", max: "100", step: "0.5", "data-field": "size", inputmode: "decimal", id: nextId(`${id}-size`) });
|
|
108
|
+
input.value = auto ? "" : String(current);
|
|
109
|
+
input.disabled = auto || model.readOnly;
|
|
110
|
+
input.placeholder = auto ? "auto" : "1";
|
|
111
|
+
const wrap = el("div", "opf-lx-size-row");
|
|
112
|
+
wrap.append(input);
|
|
113
|
+
let toggle = null;
|
|
114
|
+
// Auto is the height the content needs, so it is offered only where a column stacks its children (in a row an existing auto can only be cleared).
|
|
115
|
+
if (direction === "column" || entry.size === "auto") {
|
|
116
|
+
toggle = dom.button("opf-lx-segment opf-lx-auto", { text: "Auto", pressed: auto, attributes: { "data-field": "size-auto" } });
|
|
117
|
+
toggle.addEventListener("click", () => model.setProperty(path, "size", auto ? 1 : "auto", { coalesce: null }));
|
|
118
|
+
wrap.append(toggle);
|
|
119
|
+
}
|
|
120
|
+
const note = (() => {
|
|
121
|
+
if (auto) return "The height the content needs, at most half the slide.";
|
|
122
|
+
const total = list.reduce((sum, item) => { const size = effectiveSize(item, direction); return sum + (typeof size === "number" ? size : 0); }, 0);
|
|
123
|
+
return total > 0 && typeof current === "number" ? `${Math.round(current / total * 100)}% of the ${direction === "row" ? "width" : "height"} shared with its siblings.` : "";
|
|
124
|
+
})();
|
|
125
|
+
const error = el("p", "opf-lx-field-error opf-lx-size-error", { role: "status" });
|
|
126
|
+
error.hidden = true;
|
|
127
|
+
input.addEventListener("input", () => {
|
|
128
|
+
const text = input.value.trim();
|
|
129
|
+
if (text === "") { error.hidden = true; edit(() => model.setProperty(path, "size", undefined)); return; }
|
|
130
|
+
if (parseSize(text) === undefined) { error.hidden = false; error.replaceChildren(dom.icon("CircleX", 14), el("span", "", { text: SIZE_ERROR })); input.setAttribute("aria-invalid", "true"); return; }
|
|
131
|
+
error.hidden = true;
|
|
132
|
+
input.removeAttribute("aria-invalid");
|
|
133
|
+
edit(() => model.setProperty(path, "size", Number(text)));
|
|
134
|
+
});
|
|
135
|
+
// On leaving the field it shows the size the layout really has (an empty or rejected entry is the default again).
|
|
136
|
+
input.addEventListener("change", () => {
|
|
137
|
+
const now = model.nodeAt(path);
|
|
138
|
+
const size = now ? effectiveSize(now, direction) : current;
|
|
139
|
+
if (typeof size === "number") input.value = String(size);
|
|
140
|
+
error.hidden = true;
|
|
141
|
+
input.removeAttribute("aria-invalid");
|
|
142
|
+
});
|
|
143
|
+
const holder = field("Size", wrap, { help: note, path, field: "size", labelFor: input.id, target: input });
|
|
144
|
+
holder.append(error);
|
|
145
|
+
return holder;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function anchorField(path, inherited, current, onChange) {
|
|
149
|
+
const names = { top: "Top", middle: "Middle", bottom: "Bottom" };
|
|
150
|
+
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
|
+
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 });
|
|
153
|
+
wrap.dataset.inherited = String(current === undefined);
|
|
154
|
+
return wrap;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- the three views ---------------------------------------------------------------------------------------------------------------
|
|
158
|
+
function layoutView() {
|
|
159
|
+
const record = model.record;
|
|
160
|
+
title.textContent = "Layout";
|
|
161
|
+
const props = section();
|
|
162
|
+
props.append(
|
|
163
|
+
field("Id", textInput(record.id, { name: "id", placeholder: "my-layout", pattern: "[a-z][a-z0-9-]*", onInput: (value) => model.setLayoutProperty("id", value) }), { path: [], field: "id", help: "Lowercase letters, digits and hyphens. Slides use this id." }),
|
|
164
|
+
field("Name", textInput(record.name, { name: "name", placeholder: "My layout", onInput: (value) => model.setLayoutProperty("name", value) }), { path: [], field: "name" }),
|
|
165
|
+
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
|
+
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)),
|
|
168
|
+
);
|
|
169
|
+
const bodyNames = model.record.regions ? flatBody(record) : [];
|
|
170
|
+
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." }));
|
|
171
|
+
body.append(props);
|
|
172
|
+
const hints = record.design && typeof record.design === "object" ? Object.entries(record.design) : [];
|
|
173
|
+
if (hints.length) {
|
|
174
|
+
const block = section("Design hints");
|
|
175
|
+
const dl = el("dl", "opf-lx-hints");
|
|
176
|
+
for (const [key, value] of hints) {
|
|
177
|
+
const row = el("div", "opf-lx-hint-row");
|
|
178
|
+
const remove = dom.button("opf-lx-hint-remove", { label: `Remove the ${key} design hint`, iconName: "X", size: 14 });
|
|
179
|
+
remove.disabled = model.readOnly;
|
|
180
|
+
remove.addEventListener("click", () => model.removeDesignHint(key));
|
|
181
|
+
row.append(el("dt", "opf-lx-hint-key", { text: key }), el("dd", "opf-lx-hint-value", { text: typeof value === "string" ? value : JSON.stringify(value) }), remove);
|
|
182
|
+
dl.append(row);
|
|
183
|
+
}
|
|
184
|
+
block.append(dl);
|
|
185
|
+
body.append(block);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const flatBody = (record) => {
|
|
189
|
+
const out = [];
|
|
190
|
+
const walk = (list) => { for (const entry of Array.isArray(list) ? list : []) { if (isSplit(entry)) walk(entry.regions); else if (entry && !isHeading(entry)) out.push(entry.name); } };
|
|
191
|
+
walk(record.regions);
|
|
192
|
+
return out;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function actions(paths, { split, heading }) {
|
|
196
|
+
const block = section("Actions");
|
|
197
|
+
const group = el("div", "opf-lx-actions", { role: "group", "aria-label": "Actions" });
|
|
198
|
+
const one = paths.length === 1 ? paths[0] : null;
|
|
199
|
+
if (one && !split) {
|
|
200
|
+
group.append(
|
|
201
|
+
actionButton("Split into row", "Columns2", () => model.splitRegion(one, "row"), { label: "Split into a row" }),
|
|
202
|
+
actionButton("Split into column", "Rows2", () => model.splitRegion(one, "column"), { label: "Split into a column" }),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
group.append(
|
|
206
|
+
actionButton("Wrap in row", "Columns2", () => model.wrap(paths, "row")),
|
|
207
|
+
actionButton("Wrap in column", "Rows2", () => model.wrap(paths, "column")),
|
|
208
|
+
);
|
|
209
|
+
if (split) group.append(actionButton("Unwrap", "Unfold", () => model.unwrap(one)), actionButton("Insert region", "Plus", () => model.insertRegion(one, (model.nodeAt(one).regions ?? []).length), { label: "Insert a region in this split" }));
|
|
210
|
+
if (!heading) group.append(actionButton("Duplicate", "Copy", () => model.duplicate(paths), { shortcut: "Control+D Meta+D" }));
|
|
211
|
+
if (one) group.append(
|
|
212
|
+
actionButton("Earlier", undefined, () => model.reorder(one, -1), { label: "Move earlier", shortcut: "[" }),
|
|
213
|
+
actionButton("Later", undefined, () => model.reorder(one, 1), { label: "Move later", shortcut: "]" }),
|
|
214
|
+
);
|
|
215
|
+
group.append(actionButton("Delete", "Trash2", () => model.remove(paths), { label: heading ? `Remove the ${model.nodeAt(one)?.name} region` : "Delete", shortcut: "Delete" }));
|
|
216
|
+
block.append(group);
|
|
217
|
+
return block;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function splitView(path, entry) {
|
|
221
|
+
title.textContent = "Split";
|
|
222
|
+
const props = section();
|
|
223
|
+
props.append(
|
|
224
|
+
field("Direction", segmented([["row", "Row", "Columns2"], ["column", "Column", "Rows2"]], entry.direction === "row" ? "row" : "column", { label: "Direction", name: "direction", onPick: (value) => model.setProperty(path, "direction", value, { coalesce: null }) }), { group: true, path, field: "direction", help: "How the regions inside this split are placed." }),
|
|
225
|
+
sizeField(path, entry),
|
|
226
|
+
anchorField(path, inheritedAnchor(model.record, path), entry.anchor, (value) => model.setProperty(path, "anchor", value, { coalesce: null })),
|
|
227
|
+
);
|
|
228
|
+
const note = el("p", "opf-lx-help", { text: `${(entry.regions ?? []).length} ${(entry.regions ?? []).length === 1 ? "entry" : "entries"} inside. Regions under this split inherit its anchor.` });
|
|
229
|
+
body.append(props, note, actions([path], { split: true }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function headingView(path, entry) {
|
|
233
|
+
title.textContent = `${cap(entry.name)} region`;
|
|
234
|
+
const props = section();
|
|
235
|
+
props.append(
|
|
236
|
+
field("Name", Object.assign(textInput(entry.name, { name: "name", onInput: () => {} }), { readOnly: true }), { path, field: "name", help: entry.name === "title" ? "Holds the slide's title. Without a title region the engine adds one on top." : "Holds the slide's subtitle. A subtitle needs a title." }),
|
|
237
|
+
sizeField(path, entry),
|
|
238
|
+
);
|
|
239
|
+
body.append(props, el("p", "opf-lx-help", { text: "A heading region takes only a size." }), actions([path], { heading: true }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function regionView(path, entry) {
|
|
243
|
+
title.textContent = "Region";
|
|
244
|
+
const props = section();
|
|
245
|
+
const inherited = inheritedAnchor(model.record, path);
|
|
246
|
+
const set = (key, value, opts) => model.setProperty(path, key, value, opts);
|
|
247
|
+
props.append(
|
|
248
|
+
field("Name", textInput(entry.name, { name: "name", pattern: "[a-z][a-z0-9-]*", onInput: (value) => set("name", value) }), { path, field: "name", help: "Lowercase letters, digits and hyphens, unique in the layout." }),
|
|
249
|
+
sizeField(path, entry),
|
|
250
|
+
);
|
|
251
|
+
// Accepted kinds: none selected means every kind.
|
|
252
|
+
const chips = el("div", "opf-lx-chips", { role: "group" });
|
|
253
|
+
const accepted = new Set(entry.accepts ?? []);
|
|
254
|
+
for (const kind of LAYOUT_REGION_KINDS) {
|
|
255
|
+
const chip = dom.button("opf-lx-chip", { text: kind, iconName: KIND_ICONS[kind], size: 14, pressed: accepted.has(kind), attributes: { "data-kind": kind, "data-field": `accepts-${kind}` } });
|
|
256
|
+
chip.disabled = model.readOnly;
|
|
257
|
+
chip.addEventListener("click", () => {
|
|
258
|
+
const next = new Set(accepted);
|
|
259
|
+
if (next.has(kind)) next.delete(kind); else next.add(kind);
|
|
260
|
+
set("accepts", [...next], { coalesce: null });
|
|
261
|
+
});
|
|
262
|
+
chips.append(chip);
|
|
263
|
+
}
|
|
264
|
+
props.append(field("Accepts", chips, { group: true, path, field: "accepts", help: accepted.size ? `${accepted.size} of ${LAYOUT_REGION_KINDS.length} kinds. Deselect them all to accept every kind.` : "Every kind. Choose kinds to limit this region." }));
|
|
265
|
+
const defaults = { role: "primary", flow: "auto", empty: "collapse" };
|
|
266
|
+
const withDefault = (key, values) => [["", `Default · ${defaults[key]}`], ...values.map((value) => [value, cap(value)])];
|
|
267
|
+
props.append(
|
|
268
|
+
field("Role", select(withDefault("role", LAYOUT_REGION_ROLES), entry.role ?? "", { name: "role", onChange: (value) => set("role", value || undefined, { coalesce: null }) }), { path, field: "role", help: "Which content this region wants first." }),
|
|
269
|
+
field("Flow", select(withDefault("flow", LAYOUT_REGION_FLOWS), entry.flow ?? "", { name: "flow", onChange: (value) => set("flow", value || undefined, { coalesce: null }) }), { path, field: "flow", help: "How several blocks share the region." }),
|
|
270
|
+
);
|
|
271
|
+
const max = el("input", "opf-lx-input", { type: "number", min: "1", max: "12", step: "1", "data-field": "max", placeholder: "Default" });
|
|
272
|
+
max.value = entry.max === undefined ? "" : String(entry.max);
|
|
273
|
+
max.addEventListener("input", () => edit(() => set("max", max.value === "" ? undefined : Number(max.value))));
|
|
274
|
+
props.append(field("Max blocks", max, { path, field: "max", help: "A whole number from 1 to 12. Default: 1 for flow none, else 6." }));
|
|
275
|
+
props.append(anchorField(path, inherited, entry.anchor, (value) => set("anchor", value, { coalesce: null })));
|
|
276
|
+
const bleed = el("input", "opf-lx-check", { type: "checkbox", "data-field": "bleed" });
|
|
277
|
+
bleed.checked = entry.bleed === true;
|
|
278
|
+
bleed.addEventListener("change", () => set("bleed", bleed.checked || undefined, { coalesce: null }));
|
|
279
|
+
const bleedField = field("Bleed to the slide edge", bleed, { path, field: "bleed", help: "Outer edges on the content box reach the slide edge." });
|
|
280
|
+
bleedField.classList.add("opf-lx-field-check");
|
|
281
|
+
props.append(
|
|
282
|
+
bleedField,
|
|
283
|
+
field("List columns", select([["", "Default · 1"], ["auto", "Automatic"], ["1", "1"]], entry.listColumns === undefined ? "" : String(entry.listColumns), { name: "listColumns", onChange: (value) => set("listColumns", value === "" ? undefined : value === "auto" ? "auto" : 1, { coalesce: null }) }), { path, field: "listColumns", help: "Automatic lets a long list flow into columns." }),
|
|
284
|
+
field("When empty", select(withDefault("empty", ["collapse", "keep"]), entry.empty ?? "", { name: "empty", onChange: (value) => set("empty", value || undefined, { coalesce: null }) }), { path, field: "empty", help: "Collapse gives the space to a neighbor; keep leaves it." }),
|
|
285
|
+
);
|
|
286
|
+
body.append(props, actions([path], {}));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function multiView(paths) {
|
|
290
|
+
title.textContent = `${paths.length} selected`;
|
|
291
|
+
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
|
+
}
|
|
293
|
+
|
|
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(); }
|
|
322
|
+
|
|
323
|
+
root.addEventListener("keydown", (event) => { handleGlobalShortcut(event, model); });
|
|
324
|
+
const unsubscribe = model.subscribe((event) => {
|
|
325
|
+
if (event.type === "notice" || event.type === "tool" || event.type === "rename") return;
|
|
326
|
+
if (event.type === "view") { root.dataset.density = model.density; render(); return; }
|
|
327
|
+
if (editing) { update(); return; }
|
|
328
|
+
render();
|
|
329
|
+
});
|
|
330
|
+
render();
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
element: root,
|
|
334
|
+
model,
|
|
335
|
+
refresh: render,
|
|
336
|
+
focus() { (body.querySelector("input, select, textarea, button") ?? root).focus?.(); },
|
|
337
|
+
destroy() { unsubscribe(); root.remove(); if (ownModel) model.destroy(); },
|
|
338
|
+
};
|
|
339
|
+
}
|