@openpresentation/opf-editor 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -9
- package/dist/canvas.d.ts +13 -1
- package/dist/canvas.js +39 -9
- package/dist/chart-options-panel.js +16 -16
- package/dist/codemirror-guard.js +69 -0
- package/dist/content-controls.js +127 -74
- package/dist/data-grid.js +15 -7
- package/dist/design-controls.d.ts +13 -0
- package/dist/design-controls.js +136 -125
- package/dist/design-options.js +1 -1
- package/dist/exact-source.d.ts +30 -0
- package/dist/font-gate.js +20 -1
- package/dist/json-editor.d.ts +33 -0
- package/dist/json-editor.js +91 -4
- package/dist/json-options.d.ts +2 -1
- package/dist/json-options.js +7 -6
- 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/layout-templates.d.ts +8 -3
- package/dist/layout-templates.js +30 -16
- package/dist/numbering-panel.js +2 -3
- package/dist/react.d.ts +16 -4
- package/dist/react.js +47 -6
- package/dist/slide-adapter.d.ts +49 -0
- package/dist/slide-adapter.js +289 -0
- package/dist/slide-edits.d.ts +46 -0
- package/dist/slide-edits.js +629 -0
- package/dist/slide-manager.js +2 -1
- package/dist/slide-scene.d.ts +96 -0
- package/dist/slide-scene.js +413 -0
- package/dist/source-checker.js +45 -6
- package/dist/source-color-scheme.js +60 -0
- package/dist/source-completions.js +6 -4
- package/dist/source-pane.d.ts +103 -6
- package/dist/source-pane.js +196 -9
- package/dist/source-worker.js +8 -4
- package/dist/studio-canvas.d.ts +34 -0
- package/dist/studio-canvas.js +586 -0
- package/dist/studio-commands.d.ts +15 -0
- package/dist/studio-commands.js +155 -0
- package/dist/studio-design.d.ts +16 -0
- package/dist/studio-design.js +354 -0
- package/dist/studio-kit/commands.d.ts +38 -0
- package/dist/studio-kit/commands.js +148 -0
- package/dist/studio-kit/dom.d.ts +52 -0
- package/dist/studio-kit/dom.js +198 -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/studio-layers.d.ts +12 -0
- package/dist/studio-layers.js +218 -0
- package/dist/studio.d.ts +31 -0
- package/dist/studio.js +1608 -0
- package/dist/styles.css +1573 -123
- package/dist/thumbnail-cache.d.ts +2 -0
- package/dist/thumbnail-cache.js +10 -1
- package/dist/workspace-measure.js +6 -4
- package/dist/workspace-parts.js +20 -0
- package/dist/workspace-shell.js +25 -15
- package/dist/workspace-theme.js +3 -0
- package/dist/workspace.d.ts +114 -10
- package/dist/workspace.js +223 -49
- package/package.json +30 -19
package/dist/layout-layers.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// The layers panel of the layout editor (RR-99): the tree outline of splits and regions in reading order (role="tree"). Select, rename,
|
|
2
2
|
// drag to reorder or to move between splits, and see which nodes have findings; the findings are listed under the tree.
|
|
3
|
+
// CE-18: the tree items, the tree keyboard, the drag controller and the drop place are the studio kit's (src/studio-kit/).
|
|
3
4
|
import { createLayoutModel } from "./layout-model.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { trialMove } from "./layout-adapter.js";
|
|
6
|
+
import { isHeading, isPrefix, isSplit, nodeAt, pathKey, samePath } from "./layout-tree.js";
|
|
7
|
+
import { SIZE_LABEL, createDom, handleGlobalShortcut, handleSelectionShortcut, isHandled, isTypingTarget, nextId, nodeMenuItems, openMenu } from "./layout-editor-ui.js";
|
|
8
|
+
import { hitRow, rovingTabStop, rowDropMode, showTreeDrop, treeItem, treeKeyAction } from "./studio-kit/tree.js";
|
|
9
|
+
import { createDragController } from "./studio-kit/drag.js";
|
|
8
10
|
const parsePath = (text) => (text === "" || text === undefined ? [] : text.split(".").map(Number));
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -36,8 +38,6 @@ export function createLayoutTree(container, options = {}) {
|
|
|
36
38
|
container.append(root);
|
|
37
39
|
|
|
38
40
|
const collapsed = new Set();
|
|
39
|
-
let drag = null;
|
|
40
|
-
let pending = null;
|
|
41
41
|
let menu = null;
|
|
42
42
|
let renameInput = null;
|
|
43
43
|
|
|
@@ -46,10 +46,11 @@ export function createLayoutTree(container, options = {}) {
|
|
|
46
46
|
|
|
47
47
|
function build(path, entry, level, direction, position) {
|
|
48
48
|
const rootItem = path.length === 0, split = !rootItem && isSplit(entry);
|
|
49
|
-
const li =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
const li = treeItem(dom, {
|
|
50
|
+
level, selected: isSelected(path), expanded: rootItem || split ? !collapsed.has(pathKey(path)) : undefined,
|
|
51
|
+
setsize: rootItem ? undefined : position.size, posinset: rootItem ? undefined : position.index + 1,
|
|
52
|
+
attributes: { "data-path": pathKey(path), "data-kind": rootItem ? "layout" : split ? "split" : "region" },
|
|
53
|
+
});
|
|
53
54
|
const issues = model.findingsFor(path);
|
|
54
55
|
const within = model.findingsWithin(path);
|
|
55
56
|
const row = el("div", "opf-lx-row", { "data-selected": String(isSelected(path)), "data-finding": issues.length ? "error" : within.length ? "within" : "" });
|
|
@@ -96,7 +97,7 @@ export function createLayoutTree(container, options = {}) {
|
|
|
96
97
|
list.replaceChildren(build([], model.record, 1, "column", { index: 0, size: 1 }));
|
|
97
98
|
const primary = model.primary ?? [];
|
|
98
99
|
const stop = itemOf(primary) ?? itemOf([]);
|
|
99
|
-
|
|
100
|
+
rovingTabStop(list.querySelectorAll(".opf-lx-item"), stop);
|
|
100
101
|
if (focusedKey !== undefined) { const next = itemOf(primary) ?? itemOf(parsePath(focusedKey)) ?? stop; next?.focus({ preventScroll: true }); }
|
|
101
102
|
paintFindings();
|
|
102
103
|
syncRename();
|
|
@@ -174,7 +175,7 @@ export function createLayoutTree(container, options = {}) {
|
|
|
174
175
|
});
|
|
175
176
|
list.addEventListener("focusin", (event) => {
|
|
176
177
|
const item = event.target.closest?.(".opf-lx-item");
|
|
177
|
-
if (!item || event.target !== item ||
|
|
178
|
+
if (!item || event.target !== item || dragger.active || dragger.pending) return;
|
|
178
179
|
const path = parsePath(item.dataset.path);
|
|
179
180
|
if (!path.length) { if (model.selection.length) model.select([]); } else if (!isSelected(path)) model.select([path]);
|
|
180
181
|
});
|
|
@@ -197,76 +198,64 @@ export function createLayoutTree(container, options = {}) {
|
|
|
197
198
|
const path = rowPath(event);
|
|
198
199
|
if (!path?.length) return;
|
|
199
200
|
const paths = model.selection.some((entry) => samePath(entry, path)) ? model.selection.map((entry) => [...entry]) : [path];
|
|
200
|
-
|
|
201
|
+
dragger.press(event, { paths });
|
|
201
202
|
});
|
|
202
|
-
list.addEventListener("pointermove", (event) => {
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
list.addEventListener("pointermove", (event) => { dragger.move(event); });
|
|
204
|
+
list.addEventListener("pointerup", (event) => { dragger.release(event); });
|
|
205
|
+
list.addEventListener("pointercancel", () => { dragger.cancel(); });
|
|
206
|
+
list.addEventListener("touchmove", (event) => { if (dragger.active && event.cancelable) event.preventDefault(); }, { passive: false });
|
|
207
|
+
// The kit's drag controller: a press past 4px drags the rows; each move finds the drop place; the release moves once; Escape cancels.
|
|
208
|
+
const dragger = createDragController({
|
|
209
|
+
doc,
|
|
210
|
+
onStart(press) {
|
|
211
|
+
list.setPointerCapture?.(press.id);
|
|
212
|
+
return { paths: press.payload.paths, drop: null };
|
|
213
|
+
},
|
|
214
|
+
onStarted(drag) {
|
|
215
|
+
root.dataset.dragging = "true";
|
|
216
|
+
for (const path of drag.paths) itemOf(path)?.classList.add("is-dragging");
|
|
217
|
+
},
|
|
218
|
+
onMove(drag, event) {
|
|
219
|
+
const found = dropAt(drag, event);
|
|
220
|
+
drag.drop = found;
|
|
221
|
+
for (const node of list.querySelectorAll("[data-into]")) node.removeAttribute("data-into");
|
|
222
|
+
showTreeDrop(dropMark, scroller, found && { mode: found.mode, state: found.reason ? "refused" : found.noop ? "same" : "ok", reason: found.reason, box: found.box });
|
|
223
|
+
if (found?.mode === "into") found.item.setAttribute("data-into", "true");
|
|
224
|
+
},
|
|
225
|
+
onEnd() {
|
|
226
|
+
dropMark.hidden = true;
|
|
227
|
+
delete root.dataset.dragging;
|
|
228
|
+
for (const node of list.querySelectorAll(".is-dragging, [data-into]")) { node.classList.remove("is-dragging"); node.removeAttribute("data-into"); }
|
|
229
|
+
},
|
|
230
|
+
onDrop(drag) {
|
|
231
|
+
const outcome = drag.drop, paths = drag.paths;
|
|
232
|
+
if (!outcome || !paths) return;
|
|
233
|
+
if (outcome.reason) { model.notify(outcome.reason, "error"); return; }
|
|
234
|
+
if (outcome.noop) return;
|
|
235
|
+
model.move(paths, outcome.listPath, outcome.index);
|
|
236
|
+
},
|
|
237
|
+
onEscape: () => model.notify("Move cancelled."),
|
|
205
238
|
});
|
|
206
|
-
|
|
207
|
-
list.addEventListener("pointercancel", () => { endDrag(); pending = null; });
|
|
208
|
-
list.addEventListener("touchmove", (event) => { if (drag && event.cancelable) event.preventDefault(); }, { passive: false });
|
|
209
|
-
function startDrag() {
|
|
210
|
-
drag = { paths: pending.paths, drop: null };
|
|
211
|
-
list.setPointerCapture?.(pending.id);
|
|
212
|
-
root.dataset.dragging = "true";
|
|
213
|
-
doc.addEventListener("keydown", dragKey, true);
|
|
214
|
-
for (const path of drag.paths) itemOf(path)?.classList.add("is-dragging");
|
|
215
|
-
}
|
|
216
|
-
function dropAt(event) {
|
|
239
|
+
function dropAt(drag, event) {
|
|
217
240
|
const rows = [...list.querySelectorAll(".opf-lx-item")].map((item) => ({ item, path: parsePath(item.dataset.path), row: item.firstElementChild }));
|
|
218
|
-
const hit = rows
|
|
241
|
+
const hit = hitRow(rows, event.clientY);
|
|
219
242
|
if (!hit || drag.paths.some((path) => isPrefix(path, hit.path))) return null;
|
|
220
243
|
const box = hit.row.getBoundingClientRect();
|
|
221
244
|
const fraction = (event.clientY - box.top) / (box.height || 1);
|
|
222
245
|
const entry = hit.path.length ? nodeAt(model.record, hit.path) : model.record;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
else { listPath = hit.path
|
|
227
|
-
|
|
228
|
-
|
|
246
|
+
const mode = rowDropMode(fraction, { root: !hit.path.length, container: hit.path.length > 0 && isSplit(entry) });
|
|
247
|
+
let listPath, index;
|
|
248
|
+
if (!hit.path.length) { listPath = []; index = model.record.regions.length; }
|
|
249
|
+
else if (mode === "into") { listPath = hit.path; index = (entry.regions ?? []).length; }
|
|
250
|
+
else { listPath = hit.path.slice(0, -1); index = hit.path[hit.path.length - 1] + (mode === "before" ? 0 : 1); }
|
|
251
|
+
const { reason, noop } = trialMove(model.record, drag.paths, listPath, index);
|
|
229
252
|
return { listPath, index, mode, reason, noop, box, item: hit.item };
|
|
230
253
|
}
|
|
231
|
-
function moveDrag(event) {
|
|
232
|
-
const found = dropAt(event);
|
|
233
|
-
drag.drop = found;
|
|
234
|
-
for (const node of list.querySelectorAll("[data-into]")) node.removeAttribute("data-into");
|
|
235
|
-
if (!found) { dropMark.hidden = true; return; }
|
|
236
|
-
const parent = scroller.getBoundingClientRect();
|
|
237
|
-
dropMark.hidden = false;
|
|
238
|
-
dropMark.dataset.mode = found.mode;
|
|
239
|
-
dropMark.dataset.state = found.reason ? "refused" : found.noop ? "same" : "ok";
|
|
240
|
-
dropMark.title = found.reason;
|
|
241
|
-
const top = found.mode === "after" ? found.box.bottom : found.mode === "before" ? found.box.top : found.box.top;
|
|
242
|
-
dropMark.style.top = `${top - parent.top + scroller.scrollTop}px`;
|
|
243
|
-
dropMark.style.left = `${found.box.left - parent.left}px`;
|
|
244
|
-
dropMark.style.width = `${found.box.width}px`;
|
|
245
|
-
dropMark.style.height = found.mode === "into" ? `${found.box.height}px` : "0px";
|
|
246
|
-
if (found.mode === "into") found.item.setAttribute("data-into", "true");
|
|
247
|
-
}
|
|
248
|
-
function finishDrag() {
|
|
249
|
-
const outcome = drag?.drop, paths = drag?.paths;
|
|
250
|
-
endDrag();
|
|
251
|
-
if (!outcome || !paths) return;
|
|
252
|
-
if (outcome.reason) { model.notify(outcome.reason, "error"); return; }
|
|
253
|
-
if (outcome.noop) return;
|
|
254
|
-
model.move(paths, outcome.listPath, outcome.index);
|
|
255
|
-
}
|
|
256
|
-
function endDrag() {
|
|
257
|
-
doc.removeEventListener("keydown", dragKey, true);
|
|
258
|
-
dropMark.hidden = true;
|
|
259
|
-
delete root.dataset.dragging;
|
|
260
|
-
for (const node of list.querySelectorAll(".is-dragging, [data-into]")) { node.classList.remove("is-dragging"); node.removeAttribute("data-into"); }
|
|
261
|
-
drag = null;
|
|
262
|
-
pending = null;
|
|
263
|
-
}
|
|
264
|
-
function dragKey(event) { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); endDrag(); model.notify("Move cancelled."); } }
|
|
265
254
|
|
|
266
255
|
// --- keyboard (the WAI-ARIA tree pattern) -------------------------------------------------------------------------------------------------
|
|
267
256
|
const visibleItems = () => [...list.querySelectorAll(".opf-lx-item")];
|
|
268
257
|
list.addEventListener("keydown", (event) => {
|
|
269
|
-
if (event
|
|
258
|
+
if (isHandled(event) || event.target.closest?.(".opf-lx-rename")) return;
|
|
270
259
|
if (handleGlobalShortcut(event, model)) return;
|
|
271
260
|
if (isTypingTarget(event.target)) return;
|
|
272
261
|
const item = event.target.closest?.(".opf-lx-item");
|
|
@@ -275,31 +264,25 @@ export function createLayoutTree(container, options = {}) {
|
|
|
275
264
|
if (event.key === "Escape") {
|
|
276
265
|
event.preventDefault();
|
|
277
266
|
if (menu) { menu.close(); return; }
|
|
278
|
-
if (
|
|
267
|
+
if (dragger.active) { dragger.cancel(); return; }
|
|
279
268
|
if (path.length) { const parent = path.slice(0, -1); model.select(parent.length ? [parent] : []); itemOf(parent)?.focus({ preventScroll: true }); }
|
|
280
269
|
return;
|
|
281
270
|
}
|
|
282
271
|
if (path.length && handleSelectionShortcut(event, model, "layers")) return;
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
} else if (event.key === "Enter" || event.key === " ") {
|
|
298
|
-
event.preventDefault();
|
|
299
|
-
if (path.length) model.select([path]); else model.select([]);
|
|
300
|
-
} else if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
|
|
301
|
-
if (!path.length) return;
|
|
302
|
-
event.preventDefault();
|
|
272
|
+
const action = treeKeyAction(event, item, visibleItems(), {
|
|
273
|
+
expandable: item.hasAttribute("aria-expanded"),
|
|
274
|
+
expanded: item.getAttribute("aria-expanded") === "true",
|
|
275
|
+
collapsible: path.length > 0,
|
|
276
|
+
parent: path.length ? itemOf(path.slice(0, -1)) : null,
|
|
277
|
+
firstChild: item.querySelector(":scope > ul > .opf-lx-item"),
|
|
278
|
+
menu: path.length > 0,
|
|
279
|
+
});
|
|
280
|
+
if (!action) return;
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
if (action.type === "focus") action.target?.focus();
|
|
283
|
+
else if (action.type === "expand" || action.type === "collapse") { toggle(path); itemOf(path)?.focus(); }
|
|
284
|
+
else if (action.type === "activate") { if (path.length) model.select([path]); else model.select([]); }
|
|
285
|
+
else if (action.type === "menu") {
|
|
303
286
|
const box = item.firstElementChild.getBoundingClientRect();
|
|
304
287
|
openRowMenu({ x: box.left + 24, y: box.bottom }, item);
|
|
305
288
|
}
|
|
@@ -308,7 +291,7 @@ export function createLayoutTree(container, options = {}) {
|
|
|
308
291
|
const unsubscribe = model.subscribe((event) => {
|
|
309
292
|
if (event.type === "notice" || event.type === "tool") return;
|
|
310
293
|
if (event.type === "view") { root.dataset.density = model.density; return; }
|
|
311
|
-
if (
|
|
294
|
+
if (dragger.active && event.type === "record") return;
|
|
312
295
|
render();
|
|
313
296
|
});
|
|
314
297
|
render();
|
|
@@ -322,7 +305,7 @@ export function createLayoutTree(container, options = {}) {
|
|
|
322
305
|
beginRename(path) { model.requestRename(path, "layers"); },
|
|
323
306
|
destroy() {
|
|
324
307
|
unsubscribe();
|
|
325
|
-
|
|
308
|
+
dragger.cancel();
|
|
326
309
|
menu?.close({ restore: false });
|
|
327
310
|
root.remove();
|
|
328
311
|
if (ownModel) model.destroy();
|
package/dist/layout-model.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// The state shared by the parts of the layout editor (RR-99): the record being edited, the selection, the active tool, live validation
|
|
2
2
|
// by core, and the editor's own undo history. The palette, canvas, layers and inspector are views of one model, so a host that lays
|
|
3
3
|
// them out differently only passes the same model to each. Every change goes through a command here, which runs a pure operation of
|
|
4
|
-
// `layout-tree.js`, validates the result and records one history step.
|
|
4
|
+
// `layout-tree.js`, validates the result and records one history step. The selection, the gesture and the snapshot history are the
|
|
5
|
+
// studio kit's (CE-18), over the layout's index paths (src/layout-adapter.js). No DOM, no network.
|
|
5
6
|
import { validateCatalogRecord } from "@openpresentation/opf";
|
|
6
7
|
import { layoutTemplateIssues } from "@openpresentation/opf/composition";
|
|
7
8
|
import {
|
|
8
|
-
LayoutOperationError, clone, deleteNodes, duplicateNodes, equalizePair, insertRegion, isSplit,
|
|
9
|
+
LayoutOperationError, clone, deleteNodes, duplicateNodes, equalizePair, insertRegion, isSplit, moveIntoSibling, moveNodes, nodeAt, nodes, nudgePair,
|
|
9
10
|
parentDirection, removeDesignHint, reorderNode, samePath, setLayoutProperty, setProperty, splitRegion, toggleHeading, unwrapNode, wrapNodes,
|
|
10
11
|
} from "./layout-tree.js";
|
|
12
|
+
import { createSelection } from "./studio-kit/selection.js";
|
|
13
|
+
import { layoutSelectionAdapter } from "./layout-adapter.js";
|
|
14
|
+
import { createGesture, createHistory } from "./studio-kit/gesture.js";
|
|
11
15
|
|
|
12
16
|
/** The `$schema` of a layout record; core's catalog validation expects it. */
|
|
13
17
|
export const LAYOUT_SCHEMA = "https://openpresentation.org/schema/opf-layout/v2";
|
|
14
18
|
export const LAYOUT_TOOLS = Object.freeze(["select", "split-row", "split-column", "insert", "heading", "hand"]);
|
|
15
|
-
const COALESCE_MS = 1500;
|
|
16
19
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
20
|
|
|
18
21
|
/** A blank layout: a title and one content region. */
|
|
@@ -69,7 +72,6 @@ export function createLayoutModel(options = {}) {
|
|
|
69
72
|
const listeners = new Set();
|
|
70
73
|
const state = {
|
|
71
74
|
record: normalize(options.record ?? blankLayoutRecord()),
|
|
72
|
-
selection: [],
|
|
73
75
|
tool: "select",
|
|
74
76
|
preview: false,
|
|
75
77
|
density: options.density === "touch" ? "touch" : "comfortable",
|
|
@@ -78,37 +80,49 @@ export function createLayoutModel(options = {}) {
|
|
|
78
80
|
notice: null,
|
|
79
81
|
renaming: null,
|
|
80
82
|
};
|
|
81
|
-
const history = {
|
|
82
|
-
const limit = options.historyLimit ?? 200;
|
|
83
|
-
let gesture = null;
|
|
83
|
+
const history = createHistory({ limit: options.historyLimit ?? 200 });
|
|
84
84
|
let destroyed = false;
|
|
85
85
|
let onChange = options.onChange;
|
|
86
86
|
state.findings = validateLayoutRecord(state.record);
|
|
87
|
-
history.
|
|
87
|
+
history.reset({ record: clone(state.record), selection: [] });
|
|
88
88
|
|
|
89
89
|
const emit = (event) => { if (!destroyed) for (const listener of [...listeners]) listener({ ...event, model }); };
|
|
90
90
|
const notice = (message, kind = "info") => { state.notice = message ? { message, kind, at: Date.now() } : null; emit({ type: "notice" }); };
|
|
91
91
|
const validate = () => { state.findings = validateLayoutRecord(state.record); };
|
|
92
|
-
const
|
|
93
|
-
const snapshot = () => ({ record: clone(state.record), selection:
|
|
92
|
+
const selection = createSelection(layoutSelectionAdapter(() => state.record), { onChange: ({ source }) => emit({ type: "selection", source }) });
|
|
93
|
+
const snapshot = () => ({ record: clone(state.record), selection: selection.snapshot() });
|
|
94
94
|
const fire = () => { try { onChange?.(clone(state.record)); } catch (error) { console.error(error); } };
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
95
|
+
const push = (snap, coalesce) => history.push(snap, coalesce);
|
|
96
|
+
// A drag is one step: drafts show the record (no history, no onChange); the end pushes one snapshot; a cancel puts the start back.
|
|
97
|
+
const gesture = createGesture({
|
|
98
|
+
snapshot: () => ({ record: state.record, selection: selection.items }),
|
|
99
|
+
preview(record, message) {
|
|
100
|
+
state.record = record;
|
|
101
|
+
validate();
|
|
102
|
+
state.notice = message ? { message, kind: "info", at: Date.now(), live: true } : state.notice;
|
|
103
|
+
emit({ type: "record", source: "gesture" });
|
|
104
|
+
},
|
|
105
|
+
commit(start, { message }) {
|
|
106
|
+
if (JSON.stringify(start.record) === JSON.stringify(state.record)) { emit({ type: "record", source: "gesture-end" }); return false; }
|
|
107
|
+
selection.prune();
|
|
108
|
+
push(snapshot());
|
|
109
|
+
state.notice = { message, kind: "info", at: Date.now() };
|
|
110
|
+
emit({ type: "record", source: "gesture-end", message: state.notice.message });
|
|
111
|
+
fire();
|
|
112
|
+
return true;
|
|
113
|
+
},
|
|
114
|
+
restore(start) {
|
|
115
|
+
state.record = start.record;
|
|
116
|
+
selection.replace(start.selection);
|
|
117
|
+
validate();
|
|
118
|
+
emit({ type: "record", source: "gesture-cancel" });
|
|
119
|
+
},
|
|
120
|
+
});
|
|
107
121
|
|
|
108
122
|
/** Run `operation(record)` -> `{ record, selection }`; a refusal is a notice, and nothing changes. */
|
|
109
123
|
function run(message, operation, { coalesce } = {}) {
|
|
110
124
|
if (state.readOnly) { notice("This layout is read only.", "error"); return false; }
|
|
111
|
-
if (gesture) return false;
|
|
125
|
+
if (gesture.active) return false;
|
|
112
126
|
let outcome;
|
|
113
127
|
try { outcome = operation(state.record); } catch (error) {
|
|
114
128
|
if (error instanceof LayoutOperationError) { notice(error.message, "error"); return false; }
|
|
@@ -116,8 +130,8 @@ export function createLayoutModel(options = {}) {
|
|
|
116
130
|
}
|
|
117
131
|
if (JSON.stringify(outcome.record) === JSON.stringify(state.record)) { if (outcome.selection) select(outcome.selection); return false; }
|
|
118
132
|
state.record = outcome.record;
|
|
119
|
-
if (outcome.selection)
|
|
120
|
-
else
|
|
133
|
+
if (outcome.selection) selection.replace(outcome.selection.filter((path) => nodeAt(state.record, path)));
|
|
134
|
+
else selection.prune();
|
|
121
135
|
validate();
|
|
122
136
|
push(snapshot(), coalesce);
|
|
123
137
|
state.notice = message ? { message: typeof message === "function" ? message(outcome) : message, kind: "info", at: Date.now() } : null;
|
|
@@ -126,18 +140,12 @@ export function createLayoutModel(options = {}) {
|
|
|
126
140
|
return true;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
|
|
130
|
-
const next = existing(paths).map((path) => [...path]);
|
|
131
|
-
if (next.length === state.selection.length && next.every((path, index) => samePath(path, state.selection[index]))) return false;
|
|
132
|
-
state.selection = next;
|
|
133
|
-
emit({ type: "selection", source });
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
143
|
+
const select = (paths, options) => selection.set(paths, options);
|
|
136
144
|
|
|
137
145
|
const model = {
|
|
138
146
|
get record() { return state.record; },
|
|
139
|
-
get selection() { return
|
|
140
|
-
get primary() { return
|
|
147
|
+
get selection() { return selection.items; },
|
|
148
|
+
get primary() { return selection.primary; },
|
|
141
149
|
get tool() { return state.tool; },
|
|
142
150
|
get preview() { return state.preview; },
|
|
143
151
|
get density() { return state.density; },
|
|
@@ -145,7 +153,7 @@ export function createLayoutModel(options = {}) {
|
|
|
145
153
|
get findings() { return state.findings; },
|
|
146
154
|
get notice() { return state.notice; },
|
|
147
155
|
get renaming() { return state.renaming; },
|
|
148
|
-
get gesturing() { return gesture
|
|
156
|
+
get gesturing() { return gesture.active; },
|
|
149
157
|
|
|
150
158
|
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
151
159
|
setOnChange(callback) { onChange = callback; },
|
|
@@ -153,45 +161,22 @@ export function createLayoutModel(options = {}) {
|
|
|
153
161
|
/** Replace the record (a layout the host loaded). Resets the selection and, by default, the history; does not call `onChange`. */
|
|
154
162
|
setRecord(record, { keepHistory = false } = {}) {
|
|
155
163
|
state.record = normalize(record);
|
|
156
|
-
|
|
157
|
-
gesture
|
|
164
|
+
selection.replace([]);
|
|
165
|
+
gesture.reset();
|
|
158
166
|
state.renaming = null;
|
|
159
167
|
validate();
|
|
160
|
-
if (keepHistory)
|
|
161
|
-
else
|
|
168
|
+
if (keepHistory) history.append(snapshot());
|
|
169
|
+
else history.reset(snapshot());
|
|
162
170
|
emit({ type: "load" });
|
|
163
171
|
},
|
|
164
172
|
|
|
165
173
|
select,
|
|
166
174
|
/** Shift-click: add a sibling of the selection, else replace it. */
|
|
167
|
-
selectAdd(path)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const parent = path.slice(0, -1);
|
|
171
|
-
if (!samePath(current[0].slice(0, -1), parent)) return select([path]);
|
|
172
|
-
const has = current.some((entry) => samePath(entry, path));
|
|
173
|
-
return select(has ? current.filter((entry) => !samePath(entry, path)) : [...current, path]);
|
|
174
|
-
},
|
|
175
|
-
selectParent() {
|
|
176
|
-
const path = model.primary;
|
|
177
|
-
if (!path) return false;
|
|
178
|
-
return select(path.length > 1 ? [path.slice(0, -1)] : []);
|
|
179
|
-
},
|
|
180
|
-
selectChild() {
|
|
181
|
-
const path = model.primary;
|
|
182
|
-
const node = path && nodeAt(state.record, path);
|
|
183
|
-
if (!node || !isSplit(node) || !node.regions?.length) return false;
|
|
184
|
-
return select([[...path, 0]]);
|
|
185
|
-
},
|
|
175
|
+
selectAdd: (path) => selection.add(path),
|
|
176
|
+
selectParent: () => selection.selectParent(),
|
|
177
|
+
selectChild: () => selection.selectChild(),
|
|
186
178
|
/** Move the selection to a sibling (`delta` -1 or 1), or to the first entry when nothing is selected. */
|
|
187
|
-
selectSibling(delta)
|
|
188
|
-
const path = model.primary;
|
|
189
|
-
if (!path) return select(state.record.regions.length ? [[0]] : []);
|
|
190
|
-
const list = listAt(state.record, path.slice(0, -1));
|
|
191
|
-
const at = path[path.length - 1] + delta;
|
|
192
|
-
if (!list || at < 0 || at >= list.length) return false;
|
|
193
|
-
return select([[...path.slice(0, -1), at]]);
|
|
194
|
-
},
|
|
179
|
+
selectSibling: (delta) => selection.selectSibling(delta),
|
|
195
180
|
setTool(tool) {
|
|
196
181
|
if (!LAYOUT_TOOLS.includes(tool) || state.tool === tool) return false;
|
|
197
182
|
state.tool = tool;
|
|
@@ -224,42 +209,17 @@ export function createLayoutModel(options = {}) {
|
|
|
224
209
|
|
|
225
210
|
// --- gestures: a drag is one step ---
|
|
226
211
|
beginGesture(label) {
|
|
227
|
-
if (state.readOnly || gesture) return false;
|
|
228
|
-
gesture
|
|
229
|
-
return true;
|
|
212
|
+
if (state.readOnly || gesture.active) return false;
|
|
213
|
+
return gesture.begin(label);
|
|
230
214
|
},
|
|
231
215
|
/** Show a record while the gesture runs (no history, no `onChange`). */
|
|
232
|
-
updateGesture(record, message) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
validate();
|
|
236
|
-
state.notice = message ? { message, kind: "info", at: Date.now(), live: true } : state.notice;
|
|
237
|
-
emit({ type: "record", source: "gesture" });
|
|
238
|
-
},
|
|
239
|
-
endGesture(message) {
|
|
240
|
-
if (!gesture) return false;
|
|
241
|
-
const { record, label } = gesture;
|
|
242
|
-
gesture = null;
|
|
243
|
-
if (JSON.stringify(record) === JSON.stringify(state.record)) { emit({ type: "record", source: "gesture-end" }); return false; }
|
|
244
|
-
state.selection = existing(state.selection);
|
|
245
|
-
push(snapshot());
|
|
246
|
-
state.notice = { message: message ?? label, kind: "info", at: Date.now() };
|
|
247
|
-
emit({ type: "record", source: "gesture-end", message: state.notice.message });
|
|
248
|
-
fire();
|
|
249
|
-
return true;
|
|
250
|
-
},
|
|
251
|
-
cancelGesture() {
|
|
252
|
-
if (!gesture) return;
|
|
253
|
-
state.record = gesture.record;
|
|
254
|
-
state.selection = gesture.selection;
|
|
255
|
-
gesture = null;
|
|
256
|
-
validate();
|
|
257
|
-
emit({ type: "record", source: "gesture-cancel" });
|
|
258
|
-
},
|
|
216
|
+
updateGesture(record, message) { gesture.update(record, message); },
|
|
217
|
+
endGesture: (message) => gesture.end(message),
|
|
218
|
+
cancelGesture() { gesture.cancel(); },
|
|
259
219
|
|
|
260
220
|
// --- history ---
|
|
261
|
-
canUndo: () => history.
|
|
262
|
-
canRedo: () => history.
|
|
221
|
+
canUndo: () => history.canUndo() && !gesture.active,
|
|
222
|
+
canRedo: () => history.canRedo() && !gesture.active,
|
|
263
223
|
undo() { return jump(-1); },
|
|
264
224
|
redo() { return jump(1); },
|
|
265
225
|
|
|
@@ -277,12 +237,11 @@ export function createLayoutModel(options = {}) {
|
|
|
277
237
|
};
|
|
278
238
|
|
|
279
239
|
function jump(step) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const entry = history.entries[target];
|
|
240
|
+
if (gesture.active) return false;
|
|
241
|
+
const entry = history.go(step);
|
|
242
|
+
if (!entry) return false;
|
|
284
243
|
state.record = clone(entry.record);
|
|
285
|
-
|
|
244
|
+
selection.replace(selection.existing(entry.selection));
|
|
286
245
|
validate();
|
|
287
246
|
state.notice = { message: step < 0 ? "Undid the last change" : "Redid the change", kind: "info", at: Date.now() };
|
|
288
247
|
emit({ type: "record", source: "history", message: state.notice.message });
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { EditorSession, EditorChange, JsonPatchOperation } from "./index.js";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
export interface
|
|
5
|
-
|
|
3
|
+
/** A layout a picker offers. `group` is the catalog group of a `listCatalogRecords` entry (`custom` is the deck's own layout); `origin` is `document` for a record the deck embeds. */
|
|
4
|
+
export interface LayoutChoice { id: string; label: string; record?: Record<string, unknown>; group?: string; origin?: string }
|
|
5
|
+
/** The group of layouts the catalogs do not group: a deck's or host's own layout, or a record with no tags. */
|
|
6
|
+
export declare const CUSTOM_LAYOUTS_GROUP: "Custom layouts";
|
|
7
|
+
/** The picker group of a layout: the first tag of its catalog record (the Gallery's index group), else `CUSTOM_LAYOUTS_GROUP`. */
|
|
8
|
+
export declare function layoutGroupName(choice: LayoutChoice): string;
|
|
9
|
+
/** The catalog's groups in picker order (the catalog's own layout order), without the custom group. */
|
|
10
|
+
export declare function layoutGroupOrder(choices: readonly LayoutChoice[]): string[];
|
|
6
11
|
export declare const LAYOUT_REGION_KINDS: readonly string[];
|
|
7
12
|
export declare const LAYOUT_REGION_ROLES: readonly string[];
|
|
8
13
|
export declare const LAYOUT_REGION_FLOWS: readonly string[];
|
package/dist/layout-templates.js
CHANGED
|
@@ -5,14 +5,32 @@ import { applyJsonPatch, createValuePatch, opfPathToJsonPointer } from "./index.
|
|
|
5
5
|
import { checkedDocument, fail } from "./edit-helpers.js";
|
|
6
6
|
import { checkFormat } from "./checks.js";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
/** The group of a layout the catalogs do not group: a deck's or host's own layout, or a catalog record with no group. */
|
|
9
|
+
export const CUSTOM_LAYOUTS_GROUP = "Custom layouts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The picker group of one layout choice (`listCatalogRecords` entry, or a host's `{ id, label, record?, group? }`). A catalog
|
|
13
|
+
* record carries its group as its first tag (the Gallery publishes the same group in its layout index, and its tests keep the
|
|
14
|
+
* two equal), so a layout added to the catalog is grouped with no change here. A record with no tag, a choice with no record and a
|
|
15
|
+
* deck's own layout (catalog group `custom`) are `CUSTOM_LAYOUTS_GROUP`.
|
|
16
|
+
*/
|
|
17
|
+
export function layoutGroupName(choice) {
|
|
18
|
+
if (choice?.group === "custom") return CUSTOM_LAYOUTS_GROUP;
|
|
19
|
+
const tag = Array.isArray(choice?.record?.tags) ? choice.record.tags[0] : undefined;
|
|
20
|
+
return typeof tag === "string" && tag.trim() ? tag.trim() : CUSTOM_LAYOUTS_GROUP;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The catalog's groups in picker order, without the custom group: the order the catalog lists its layouts in (the Gallery lists
|
|
25
|
+
* them in its index order), so the groups follow the catalog. Records a deck embeds come after the registered catalogs' own.
|
|
26
|
+
*/
|
|
27
|
+
export function layoutGroupOrder(choices) {
|
|
28
|
+
const names = [];
|
|
29
|
+
const pass = (list) => { for (const choice of list) { const name = layoutGroupName(choice); if (name !== CUSTOM_LAYOUTS_GROUP && !names.includes(name)) names.push(name); } };
|
|
30
|
+
pass(choices.filter((choice) => choice.origin !== "document"));
|
|
31
|
+
pass(choices.filter((choice) => choice.origin === "document"));
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
16
34
|
|
|
17
35
|
export const LAYOUT_REGION_KINDS = Object.freeze(["text", "list", "image", "video", "chart", "table", "code", "metric", "quote", "timeline", "group"]);
|
|
18
36
|
export const LAYOUT_REGION_ROLES = Object.freeze(["primary", "secondary", "media", "supporting"]);
|
|
@@ -20,15 +38,11 @@ export const LAYOUT_REGION_FLOWS = Object.freeze(["none", "grid", "column", "aut
|
|
|
20
38
|
const RESERVED = new Set(["title", "subtitle", "tag", "auto", "left", "center", "right", "top", "middle", "bottom"]);
|
|
21
39
|
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
22
40
|
|
|
23
|
-
/** Group
|
|
41
|
+
/** Group layouts by their catalog group (see `layoutGroupName`), in catalog order, then the custom ones. Choices keep their order within a group. */
|
|
24
42
|
export function groupLayoutChoices(choices) {
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
name, choices: ids.map((id) => byId.get(id)).filter((choice) => { if (!choice || used.has(choice.id)) return false; used.add(choice.id); return true; }),
|
|
29
|
-
})).filter((group) => group.choices.length);
|
|
30
|
-
const custom = choices.filter((choice) => !used.has(choice.id));
|
|
31
|
-
if (custom.length) groups.push({ name: "Custom layouts", choices: custom });
|
|
43
|
+
const groups = layoutGroupOrder(choices).map((name) => ({ name, choices: choices.filter((choice) => layoutGroupName(choice) === name) }));
|
|
44
|
+
const custom = choices.filter((choice) => layoutGroupName(choice) === CUSTOM_LAYOUTS_GROUP);
|
|
45
|
+
if (custom.length) groups.push({ name: CUSTOM_LAYOUTS_GROUP, choices: custom });
|
|
32
46
|
return groups;
|
|
33
47
|
}
|
|
34
48
|
|
package/dist/numbering-panel.js
CHANGED
|
@@ -159,7 +159,7 @@ export function createNumberingPanel(container, options) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
if (enabled && state.entry) {
|
|
162
|
-
const start = h(doc, "input", { id: `${id}-entry-start`, type: "number", min: 1, max: MAX_NUMBERING_START, step: 1, value: state.entry.start ?? "", placeholder: state.entry.marker ?? "" });
|
|
162
|
+
const start = h(doc, "input", { id: `${id}-entry-start`, type: "number", min: 1, max: MAX_NUMBERING_START, step: 1, value: state.entry.start ?? "", placeholder: state.entry.marker ?? "", title: "Empty continues the count" });
|
|
163
163
|
const apply = () => {
|
|
164
164
|
const text = start.value.trim();
|
|
165
165
|
try {
|
|
@@ -172,8 +172,7 @@ export function createNumberingPanel(container, options) {
|
|
|
172
172
|
};
|
|
173
173
|
start.addEventListener("change", apply);
|
|
174
174
|
body.append(h(doc, "div", { class: "opf-numbering-entry" },
|
|
175
|
-
h(doc, "label", { for: start.id, text: `Entry ${state.entry.index + 1} starts at` }), start
|
|
176
|
-
h(doc, "span", { class: "opf-numbering-help", text: "Leave empty to continue the count. Later entries carry on from it." })));
|
|
175
|
+
h(doc, "label", { for: start.id, text: `Entry ${state.entry.index + 1} starts at` }), start));
|
|
177
176
|
}
|
|
178
177
|
}
|
|
179
178
|
|