@openpresentation/opf-editor 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +23 -9
  2. package/dist/canvas.d.ts +13 -1
  3. package/dist/canvas.js +39 -9
  4. package/dist/chart-options-panel.js +16 -16
  5. package/dist/codemirror-guard.js +69 -0
  6. package/dist/content-controls.js +127 -74
  7. package/dist/data-grid.js +15 -7
  8. package/dist/design-controls.d.ts +13 -0
  9. package/dist/design-controls.js +136 -125
  10. package/dist/design-options.js +1 -1
  11. package/dist/exact-source.d.ts +30 -0
  12. package/dist/font-gate.js +20 -1
  13. package/dist/json-editor.d.ts +33 -0
  14. package/dist/json-editor.js +91 -4
  15. package/dist/json-options.d.ts +2 -1
  16. package/dist/json-options.js +7 -6
  17. package/dist/layout-adapter.js +96 -0
  18. package/dist/layout-canvas.js +122 -232
  19. package/dist/layout-editor-ui.js +46 -185
  20. package/dist/layout-inspector.js +36 -99
  21. package/dist/layout-layers.js +75 -92
  22. package/dist/layout-model.js +62 -103
  23. package/dist/layout-templates.d.ts +8 -3
  24. package/dist/layout-templates.js +30 -16
  25. package/dist/numbering-panel.js +2 -3
  26. package/dist/react.d.ts +16 -4
  27. package/dist/react.js +47 -6
  28. package/dist/slide-adapter.d.ts +49 -0
  29. package/dist/slide-adapter.js +289 -0
  30. package/dist/slide-edits.d.ts +46 -0
  31. package/dist/slide-edits.js +629 -0
  32. package/dist/slide-manager.js +2 -1
  33. package/dist/slide-scene.d.ts +96 -0
  34. package/dist/slide-scene.js +413 -0
  35. package/dist/source-checker.js +45 -6
  36. package/dist/source-color-scheme.js +60 -0
  37. package/dist/source-completions.js +6 -4
  38. package/dist/source-pane.d.ts +103 -6
  39. package/dist/source-pane.js +196 -9
  40. package/dist/source-worker.js +8 -4
  41. package/dist/studio-canvas.d.ts +34 -0
  42. package/dist/studio-canvas.js +586 -0
  43. package/dist/studio-commands.d.ts +15 -0
  44. package/dist/studio-commands.js +155 -0
  45. package/dist/studio-design.d.ts +16 -0
  46. package/dist/studio-design.js +354 -0
  47. package/dist/studio-kit/commands.d.ts +38 -0
  48. package/dist/studio-kit/commands.js +148 -0
  49. package/dist/studio-kit/dom.d.ts +52 -0
  50. package/dist/studio-kit/dom.js +198 -0
  51. package/dist/studio-kit/drag.d.ts +26 -0
  52. package/dist/studio-kit/drag.js +127 -0
  53. package/dist/studio-kit/gesture.d.ts +29 -0
  54. package/dist/studio-kit/gesture.js +87 -0
  55. package/dist/studio-kit/index.d.ts +29 -0
  56. package/dist/studio-kit/index.js +12 -0
  57. package/dist/studio-kit/inspector.d.ts +22 -0
  58. package/dist/studio-kit/inspector.js +119 -0
  59. package/dist/studio-kit/overlay.d.ts +29 -0
  60. package/dist/studio-kit/overlay.js +182 -0
  61. package/dist/studio-kit/selection.d.ts +30 -0
  62. package/dist/studio-kit/selection.js +106 -0
  63. package/dist/studio-kit/tree.d.ts +9 -0
  64. package/dist/studio-kit/tree.js +82 -0
  65. package/dist/studio-kit/viewport.d.ts +29 -0
  66. package/dist/studio-kit/viewport.js +153 -0
  67. package/dist/studio-layers.d.ts +12 -0
  68. package/dist/studio-layers.js +218 -0
  69. package/dist/studio.d.ts +31 -0
  70. package/dist/studio.js +1608 -0
  71. package/dist/styles.css +1573 -123
  72. package/dist/thumbnail-cache.d.ts +2 -0
  73. package/dist/thumbnail-cache.js +10 -1
  74. package/dist/workspace-measure.js +6 -4
  75. package/dist/workspace-parts.js +20 -0
  76. package/dist/workspace-shell.js +25 -15
  77. package/dist/workspace-theme.js +3 -0
  78. package/dist/workspace.d.ts +114 -10
  79. package/dist/workspace.js +223 -49
  80. package/package.json +30 -19
@@ -0,0 +1,96 @@
1
+ /** CE-19 (internal; no package subpath exports it yet): the slide scene model, one slide of a deck as the studio kit sees it. */
2
+ import type { SlideComposition } from "@openpresentation/opf/composition";
3
+
4
+ /** A box in slide units: 0 to 1 across the slide's width and height. */
5
+ export interface SceneBox { x: number; y: number; w: number; h: number }
6
+ export interface SceneTrack { start: number; length: number; weight: number }
7
+ /** A container's arrangement: its tracks along `direction`, for dividers and drop lines. */
8
+ export interface SceneContainer {
9
+ mode: "auto" | "grid" | "row" | "column" | "region" | "layout";
10
+ direction: "row" | "column";
11
+ columns: number;
12
+ rows: number;
13
+ tracks: SceneTrack[];
14
+ gap: number;
15
+ padding: number;
16
+ /** Whether a divider drag can resize the tracks (`composition.weights`). */
17
+ resizable: boolean;
18
+ /** The composed flow's path, when the container has one. */
19
+ flowPath?: string;
20
+ /** The slide's content is in promoted regions (`left`, `center+right`, ...): drops go into a region. */
21
+ promoted?: boolean;
22
+ /** The slide uses a layout template: its regions are its children. */
23
+ layout?: boolean;
24
+ }
25
+ export interface SceneCan { move: boolean; group: boolean; delete: boolean; duplicate: boolean; resizeTracks: boolean }
26
+ export interface SceneFinding { message: string; code?: string; severity?: string; path: string }
27
+ export type SceneNodeKind = "slide" | "heading" | "region" | "group" | "block" | "part" | "implicit";
28
+ export interface SceneNode {
29
+ /** The id path (`/slides/@intro/blocks/@kpi`; an element without an id keeps its index); a layout region is `<slide key>#<name>`. */
30
+ key: string;
31
+ /** The index JSON Pointer now (a layout region's is its slide's). */
32
+ path: string;
33
+ kind: SceneNodeKind;
34
+ /** The content kind (`text`, `chart`, ...), `group`, `region`, `slide`, or the heading field (`title`, `subtitle`, `tag`). */
35
+ type: string;
36
+ parent: string | null;
37
+ /** The index in the parent's children. */
38
+ index: number;
39
+ depth: number;
40
+ /** "Text: Start with a clear…", "Row · 3", "Region media", "Title", "Slide 3 · two-column". */
41
+ label: string;
42
+ box?: SceneBox;
43
+ container?: SceneContainer;
44
+ findings: SceneFinding[];
45
+ can: SceneCan;
46
+ children: string[];
47
+ /** A block's index in its `blocks` array; an implicit field's index among its owner's content fields (its block index once converted). */
48
+ blockIndex?: number;
49
+ /** A root block's layout region pin. */
50
+ pin?: string;
51
+ /** A layout region: it has no place in the document. */
52
+ virtual?: boolean;
53
+ /** A promoted region of the slide grid. */
54
+ promoted?: boolean;
55
+ /** The region's name. */
56
+ region?: string;
57
+ accepts?: string[];
58
+ max?: number;
59
+ collapsed?: boolean;
60
+ /** Implicit content: its field and the pointer of the object that holds it. */
61
+ field?: string;
62
+ owner?: string;
63
+ hidden?: boolean;
64
+ }
65
+ export interface SceneHit extends SceneBox { depth: number; key: string; node: SceneNode }
66
+ export interface SlideScene {
67
+ slideIndex: number;
68
+ slideKey: string;
69
+ width: number;
70
+ height: number;
71
+ /** Uses a layout template (its regions are the slide's children). */
72
+ layout: boolean;
73
+ nodes: SceneNode[];
74
+ node(key: string): SceneNode | undefined;
75
+ nodeAtPath(path: string): SceneNode | undefined;
76
+ children(key: string): SceneNode[];
77
+ contentChildren(key: string): SceneNode[];
78
+ boxOf(key: string): (SceneBox & { depth: number }) | undefined;
79
+ containerOf(key: string): SceneContainer | undefined;
80
+ flowOf(key: string): SlideComposition["flows"][number] | undefined;
81
+ hitChain(point: { x: number; y: number }, options?: { parts?: boolean; skip?(hit: SceneHit): boolean }): SceneHit[];
82
+ target(chain: SceneHit[], options?: { primary?: string | null; shift?: boolean; deep?: boolean }): SceneHit | null;
83
+ survivor(key: string): string | null;
84
+ }
85
+ export interface SceneDrop {
86
+ target: string;
87
+ index: number;
88
+ into: string | null;
89
+ line: { x: number; y: number; w: number; h: number; orientation: "vertical" | "horizontal" };
90
+ }
91
+ export declare const CONTENT_FIELDS: readonly string[];
92
+ export declare function isPromotedRegion(key: string): boolean;
93
+ export declare function contentFieldsOf(owner: unknown): string[];
94
+ export declare function describeBlock(block: unknown): string;
95
+ export declare function slideScene(presentation: unknown, slideIndex: number, options: { geometry: SlideComposition; findings?: { path: string; message: string; code?: string; severity?: string }[]; parts?: boolean }): SlideScene;
96
+ export declare function dropTarget(scene: SlideScene, point: { x: number; y: number }, keys?: string[], options?: { edge?: number }): SceneDrop | null;
@@ -0,0 +1,413 @@
1
+ // CE-19: the slide scene model. One slide of a deck as the studio kit sees it: a tree of nodes (the slide, its headings, the regions of
2
+ // its layout or its promoted regions, groups, blocks, the parts of a block, and implicit content written on the slide itself), each with
3
+ // a stable key, its JSON Pointer, a box in slide units (0 to 1) read from the composed layout geometry, and, for a container, the tracks
4
+ // its children sit on. It is pure: the caller hands it the document and the `SlideComposition` of the slide (the canvas's render, or core
5
+ // composition), and nothing here edits or renders. Slides stay auto-layout (decision D2): no node has a free position, and a drop
6
+ // resolves to a container and an index, never to coordinates. The edits are in `slide-edits.js`; `slide-adapter.js` puts both behind
7
+ // the kit's document adapter over an editor session.
8
+ //
9
+ // Keys. A node's key is its id path: the JSON Pointer with every array index whose element has a unique `id` written as `@id`
10
+ // (`/slides/@intro/blocks/@kpi`), so it names the same node after edits by others; an element without an id keeps its index. A layout
11
+ // region has no place in the document (it is virtual: the blocks bound to it live in the slide's `blocks`) and is keyed `<slide key>#<name>`.
12
+ import { CONTENT_KIND_LABELS, readContent } from "@openpresentation/opf/convert";
13
+ import { formatPointer, parsePointer } from "@openpresentation/opf/patch";
14
+ import { boxesAt } from "./studio-kit/overlay.js";
15
+ import { clickTarget } from "./studio-kit/selection.js";
16
+
17
+ /** The content fields a slide, a promoted region or a block can hold, in the order implicit content becomes blocks. */
18
+ export const CONTENT_FIELDS = Object.freeze(["text", "items", "bullets", "image", "video", "chart", "table", "code", "metric", "quote", "timeline"]);
19
+ const HEADINGS = ["tag", "title", "subtitle"];
20
+ const HEADING_LABELS = { tag: "Tag", title: "Title", subtitle: "Subtitle" };
21
+ const MODE_LABELS = { auto: "Group", grid: "Grid", row: "Row", column: "Column" };
22
+ const REGION_KEY = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
23
+ const NONE = Object.freeze({ move: false, group: false, delete: false, duplicate: false, resizeTracks: false });
24
+ const CONTAINERS = new Set(["slide", "region", "group"]);
25
+
26
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
27
+ /** Whether `key` names a promoted region of a slide (`left`, `center+right`, `top:left`, ...). */
28
+ export const isPromotedRegion = (key) => REGION_KEY.test(key);
29
+ /** The content fields `owner` holds itself, in conversion order. */
30
+ export const contentFieldsOf = (owner) => (isObject(owner) ? CONTENT_FIELDS.filter((key) => Object.hasOwn(owner, key)) : []);
31
+
32
+ /** The pointer token of `array[at]`: `@id` when that element has a unique, non-empty string id, else the index. */
33
+ function tokenOf(array, at) {
34
+ const id = array[at]?.id;
35
+ if (typeof id !== "string" || id === "") return String(at);
36
+ return array.filter((item) => isObject(item) && item.id === id).length === 1 ? `@${id}` : String(at);
37
+ }
38
+
39
+ const unitBox = (box, width, height) => (box ? { x: box.x / width, y: box.y / height, w: box.width / width, h: box.height / height } : undefined);
40
+ function union(boxes) {
41
+ const list = boxes.filter((box) => box && box.w >= 0 && box.h >= 0);
42
+ if (!list.length) return undefined;
43
+ const x = Math.min(...list.map((box) => box.x)), y = Math.min(...list.map((box) => box.y));
44
+ return { x, y, w: Math.max(...list.map((box) => box.x + box.w)) - x, h: Math.max(...list.map((box) => box.y + box.h)) - y };
45
+ }
46
+ const prefixOf = (prefix, tokens) => prefix.length <= tokens.length && prefix.every((token, at) => token === tokens[at]);
47
+
48
+ // --- labels ----------------------------------------------------------------------------------------------------------------------
49
+
50
+ function plain(value, depth = 0) {
51
+ if (value === null || value === undefined || depth > 6) return "";
52
+ if (typeof value === "string" || typeof value === "number") return String(value);
53
+ if (Array.isArray(value)) return value.map((entry) => plain(entry, depth + 1)).join("");
54
+ if (isObject(value)) return plain(value.text ?? value.value ?? value.label ?? "", depth + 1);
55
+ return "";
56
+ }
57
+ const snippet = (text) => {
58
+ const clean = String(text ?? "").replace(/\s+/g, " ").trim();
59
+ return clean.length > 32 ? `${clean.slice(0, 32).trimEnd()}…` : clean;
60
+ };
61
+ function contentSnippet(kind, content) {
62
+ switch (kind) {
63
+ case "text": return plain(content);
64
+ case "list": return Array.isArray(content) && content.length ? plain(isObject(content[0]) ? content[0].text : content[0]) : "";
65
+ case "chart": return isObject(content) ? plain(content.title) || (Array.isArray(content.data?.columns) ? content.data.columns.slice(1).map((column) => plain(column)).join(", ") : "") : "";
66
+ case "table": return isObject(content) && Array.isArray(content.columns) ? content.columns.map((column) => plain(isObject(column) ? column.label ?? column.text ?? column.name : column)).join(", ") : "";
67
+ case "metric": return isObject(content) ? plain(content.label) || plain(content.value) : plain(content);
68
+ case "quote": return isObject(content) ? plain(content.text) : plain(content);
69
+ case "code": return isObject(content) ? plain(content.filename) || plain(content.language) || plain(String(content.source ?? "").split("\n")[0]) : "";
70
+ case "timeline": return Array.isArray(content) && content.length ? plain(content[0]?.what ?? content[0]?.when) : "";
71
+ case "image":
72
+ case "video": return isObject(content) ? plain(content.alt ?? content.caption) : "";
73
+ default: return "";
74
+ }
75
+ }
76
+ /** What the layers and the screen reader say for a block: "Text: Start with a clear…", "Row · 3". */
77
+ export function describeBlock(block) {
78
+ if (isObject(block) && Array.isArray(block.blocks)) return `${MODE_LABELS[block.composition?.mode ?? "auto"] ?? "Group"} · ${block.blocks.length}`;
79
+ const info = readContent(block);
80
+ if (!info) return "Block";
81
+ const label = CONTENT_KIND_LABELS[info.kind] ?? info.kind;
82
+ const text = snippet(contentSnippet(info.kind, info.content));
83
+ return text ? `${label}: ${text}` : label;
84
+ }
85
+ const kindOfField = (field) => (field === "items" || field === "bullets" ? "list" : field);
86
+
87
+ // --- containers --------------------------------------------------------------------------------------------------------------------
88
+
89
+ /** The container of a composed flow in slide units: its direction, tracks with their weights, gap and padding, and whether a divider drag can resize it. */
90
+ function flowContainer(flow, width, height) {
91
+ const composition = flow.composition ?? {};
92
+ const mode = composition.mode ?? "auto";
93
+ const direction = mode === "column" ? "column" : "row";
94
+ const list = direction === "column" ? flow.rows : flow.columns;
95
+ const origin = direction === "column" ? flow.box.y : flow.box.x, extent = direction === "column" ? height : width;
96
+ const weights = Array.isArray(composition.weights) ? composition.weights : [];
97
+ const tracks = (list ?? []).map((track, index) => ({ start: (origin + track.offset) / extent, length: track.size / extent, weight: weights[index] ?? 1 }));
98
+ return {
99
+ mode, direction, columns: flow.columns?.length ?? 1, rows: flow.rows?.length ?? 1, tracks,
100
+ gap: (flow.gap ?? 0) / extent, padding: composition.padding ?? 0,
101
+ resizable: tracks.length >= 2 && tracks.length <= 12 && flow.itemCount === flow.slotCount,
102
+ flowPath: flow.path,
103
+ };
104
+ }
105
+ /** A container without a composed flow (a layout region, a slide of implicit content): its tracks are its children's boxes. */
106
+ function childContainer(mode, direction, children) {
107
+ const boxes = children.map((child) => child.box).filter(Boolean);
108
+ const along = direction === "column" ? (box) => ({ start: box.y, length: box.h }) : (box) => ({ start: box.x, length: box.w });
109
+ return { mode, direction, columns: direction === "row" ? boxes.length : 1, rows: direction === "column" ? boxes.length : 1, tracks: boxes.map((box) => ({ ...along(box), weight: 1 })), gap: 0, padding: 0, resizable: false };
110
+ }
111
+
112
+ // --- the scene ---------------------------------------------------------------------------------------------------------------------
113
+
114
+ /**
115
+ * The scene of slide `slideIndex` of `presentation`. `geometry` is the slide's `SlideComposition` (required: the boxes come from it);
116
+ * `findings` are `{ path, message, severity?, code? }` entries (the session's validation findings) shown on the deepest node whose path
117
+ * holds theirs, together with the composition's own diagnostics; `parts: false` leaves the parts of blocks out.
118
+ */
119
+ export function slideScene(presentation, slideIndex, { geometry, findings = [], parts = true } = {}) {
120
+ const slides = presentation?.slides;
121
+ const slide = Array.isArray(slides) ? slides[slideIndex] : undefined;
122
+ if (!isObject(slide)) throw new RangeError(`Slide ${slideIndex} does not exist.`);
123
+ if (!geometry || !Number.isFinite(geometry.width) || !Number.isFinite(geometry.height)) throw new TypeError("slideScene needs the slide's composed geometry.");
124
+ const width = geometry.width, height = geometry.height;
125
+ const unit = (box) => unitBox(box, width, height);
126
+ const items = new Map(), groups = new Map(), flows = new Map();
127
+ for (const item of geometry.items ?? []) { const list = items.get(item.path) ?? []; list.push(item); items.set(item.path, list); }
128
+ for (const group of geometry.groups ?? []) groups.set(group.path, group);
129
+ for (const flow of geometry.flows ?? []) flows.set(flow.path, flow);
130
+ const itemBox = (dotted) => union((items.get(dotted) ?? []).flatMap((item) => [unit(item.frameBox ?? item.box), item.caption?.box ? unit(item.caption.box) : undefined]));
131
+
132
+ const nodes = [], byKey = new Map(), byPath = new Map();
133
+ const slideTokens = ["slides", tokenOf(slides, slideIndex)], slidePath = ["slides", String(slideIndex)];
134
+ const add = (node) => {
135
+ nodes.push(node);
136
+ byKey.set(node.key, node);
137
+ if (!node.virtual) byPath.set(node.path, node);
138
+ const parent = node.parent === null ? null : byKey.get(node.parent);
139
+ if (parent) { node.index = parent.children.length; parent.children.push(node.key); }
140
+ return node;
141
+ };
142
+ const base = (fields) => ({ children: [], findings: [], can: NONE, ...fields });
143
+
144
+ const slideNode = add(base({
145
+ key: formatPointer(slideTokens), path: formatPointer(slidePath), kind: "slide", type: "slide", parent: null, index: slideIndex, depth: 0,
146
+ label: `Slide ${slideIndex + 1}${typeof slide.layout === "string" && slide.layout !== "auto" ? ` · ${slide.layout}` : ""}`,
147
+ box: { x: 0, y: 0, w: 1, h: 1 }, hidden: slide.hidden === true,
148
+ }));
149
+
150
+ for (const field of HEADINGS) {
151
+ if (slide[field] === undefined) continue;
152
+ const box = itemBox(`slides.${slideIndex}.${field}`) ?? unit(geometry.headingAreas?.find((area) => area.name === field)?.box);
153
+ add(base({ key: formatPointer([...slideTokens, field]), path: formatPointer([...slidePath, field]), kind: "heading", type: field, parent: slideNode.key, depth: 1, label: HEADING_LABELS[field], box }));
154
+ }
155
+
156
+ // Parts: the pieces of a block that composition places on their own (list items, metric value and label, quote body and footer,
157
+ // timeline events, code body). Only a Cmd-click (deep select) reaches them; nothing structural applies to them.
158
+ function addParts(owner, dotted) {
159
+ if (!parts) return;
160
+ const ownerTokens = parsePointer(owner.path), ownerKeyTokens = parsePointer(owner.key), at = ownerTokens.length;
161
+ const found = new Map();
162
+ const note = (path, box, label) => {
163
+ if (typeof path !== "string" || !path.startsWith(`${dotted}.`) || !box || !(box.width > 0) || !(box.height > 0)) return;
164
+ const entry = found.get(path) ?? { boxes: [], label };
165
+ entry.boxes.push(unit(box));
166
+ found.set(path, entry);
167
+ };
168
+ for (const item of items.get(dotted) ?? []) {
169
+ for (const entry of item.text?.listEntries ?? []) {
170
+ const label = snippet(plain(entry.value));
171
+ note(`${item.path}.${entry.index}`, entry.textBox, `Item ${entry.index + 1}${label ? `: ${label}` : ""}`);
172
+ if (entry.descriptionBox) note(`${item.path}.${entry.index}`, entry.descriptionBox);
173
+ }
174
+ for (const layout of [item.metricLayout, item.quoteLayout, item.timelineLayout, item.codeLayout]) {
175
+ for (const part of layout?.parts ?? []) if (part.visible !== false && !part.generated) note(part.path, part.box, `${part.role.charAt(0).toUpperCase()}${part.role.slice(1).replace(/-/g, " ")}${part.text ? `: ${snippet(part.text)}` : ""}`);
176
+ }
177
+ }
178
+ for (const [path, entry] of found) {
179
+ const rest = path.split(".").slice(at);
180
+ add(base({
181
+ key: formatPointer([...ownerKeyTokens, ...rest]), path: formatPointer([...ownerTokens, ...rest]), kind: "part", type: owner.type, parent: owner.key,
182
+ depth: owner.depth + 1, label: entry.label ?? "Part", box: union(entry.boxes),
183
+ }));
184
+ }
185
+ }
186
+
187
+ // A block of a `blocks` array: a group (with its own flow and children) or a leaf (with parts).
188
+ function addBlock(block, pathTokens, keyTokens, parentKey, depth, blockIndex) {
189
+ const dotted = pathTokens.join(".");
190
+ const group = Array.isArray(block.blocks);
191
+ const node = add(base({
192
+ key: formatPointer(keyTokens), path: formatPointer(pathTokens), kind: group ? "group" : "block",
193
+ type: group ? "group" : readContent(block)?.kind ?? kindOfField(contentFieldsOf(block)[0] ?? "block"),
194
+ parent: parentKey, depth, blockIndex, label: describeBlock(block),
195
+ box: group ? unit(groups.get(dotted)?.box) : itemBox(`${dotted}.${contentFieldsOf(block)[0]}`),
196
+ can: { move: true, group: true, delete: true, duplicate: true, resizeTracks: false },
197
+ ...(typeof block.region === "string" ? { pin: block.region } : {}),
198
+ }));
199
+ if (group) {
200
+ block.blocks.forEach((child, at) => { if (isObject(child)) addBlock(child, [...pathTokens, "blocks", String(at)], [...keyTokens, "blocks", tokenOf(block.blocks, at)], node.key, depth + 1, at); });
201
+ const flow = flows.get(dotted);
202
+ node.box ??= union(node.children.map((key) => byKey.get(key).box));
203
+ node.container = flow ? flowContainer(flow, width, height) : childContainer(block.composition?.mode ?? "auto", block.composition?.mode === "column" ? "column" : "row", node.children.map((key) => byKey.get(key)));
204
+ node.can = { ...node.can, resizeTracks: node.container.resizable };
205
+ } else addParts(node, `${dotted}.${contentFieldsOf(block)[0]}`);
206
+ return node;
207
+ }
208
+ // Implicit content: a content field written on the slide or a promoted region instead of a block. A structural command converts it to
209
+ // blocks first, in the same change, so it moves, groups, duplicates and deletes like a block.
210
+ function addImplicit(owner, ownerTokens, ownerKeyTokens, field, parentKey, depth, fieldIndex) {
211
+ const pathTokens = [...ownerTokens, field];
212
+ const kind = kindOfField(field);
213
+ const text = snippet(contentSnippet(kind, owner[field]));
214
+ const node = add(base({
215
+ key: formatPointer([...ownerKeyTokens, field]), path: formatPointer(pathTokens), kind: "implicit", type: kind, parent: parentKey, depth,
216
+ field, blockIndex: fieldIndex, owner: formatPointer(ownerTokens),
217
+ label: `${CONTENT_KIND_LABELS[kind] ?? kind} (slide)${text ? `: ${text}` : ""}`,
218
+ box: itemBox(pathTokens.join(".")),
219
+ can: { move: true, group: true, delete: true, duplicate: true, resizeTracks: false },
220
+ }));
221
+ addParts(node, pathTokens.join("."));
222
+ return node;
223
+ }
224
+ // The content of an owner (the slide, a promoted region): its blocks, else its implicit fields.
225
+ function addContent(owner, ownerTokens, ownerKeyTokens, parentKey, depth, only) {
226
+ if (Array.isArray(owner.blocks)) {
227
+ owner.blocks.forEach((block, at) => {
228
+ if (!isObject(block) || (only && !only.has(`${ownerTokens.join(".")}.blocks.${at}`))) return;
229
+ addBlock(block, [...ownerTokens, "blocks", String(at)], [...ownerKeyTokens, "blocks", tokenOf(owner.blocks, at)], parentKey, depth, at);
230
+ });
231
+ }
232
+ contentFieldsOf(owner).forEach((field, at) => {
233
+ if (only && !only.has(`${ownerTokens.join(".")}.${field}`)) return;
234
+ addImplicit(owner, ownerTokens, ownerKeyTokens, field, parentKey, depth, at);
235
+ });
236
+ }
237
+
238
+ const regions = Array.isArray(geometry.regions) ? geometry.regions : null;
239
+ if (regions) {
240
+ // A layout template: the regions are the slide's children, and each holds the root blocks or root content fields bound to it.
241
+ const placed = new Set();
242
+ for (const region of regions) {
243
+ const node = add(base({
244
+ key: `${slideNode.key}#${region.name}`, path: slideNode.path, kind: "region", type: "region", virtual: true, region: region.name, parent: slideNode.key, depth: 1,
245
+ label: `Region ${region.name}`, box: unit(region.box), accepts: [...(region.accepts ?? [])], max: region.max, flow: region.flow, collapsed: region.collapsed === true,
246
+ }));
247
+ for (const dotted of [...(region.content ?? []), ...(region.overflow ?? [])]) {
248
+ if (placed.has(dotted)) continue;
249
+ placed.add(dotted);
250
+ const tokens = dotted.split(".");
251
+ if (tokens.length === 4 && tokens[2] === "blocks" && isObject(slide.blocks?.[Number(tokens[3])])) {
252
+ const at = Number(tokens[3]);
253
+ addBlock(slide.blocks[at], [...slidePath, "blocks", String(at)], [...slideTokens, "blocks", tokenOf(slide.blocks, at)], node.key, 2, at);
254
+ } else if (tokens.length === 3 && CONTENT_FIELDS.includes(tokens[2]) && slide[tokens[2]] !== undefined) {
255
+ addImplicit(slide, slidePath, slideTokens, tokens[2], node.key, 2, contentFieldsOf(slide).indexOf(tokens[2]));
256
+ }
257
+ }
258
+ node.container = childContainer("region", region.arrangement === "row" || region.arrangement === "grid" ? "row" : "column", node.children.map((key) => byKey.get(key)));
259
+ }
260
+ // Content no region took (unbound, not even as overflow) stays a child of the slide.
261
+ const rest = new Set([...(slide.blocks ?? []).map((_, at) => `slides.${slideIndex}.blocks.${at}`), ...contentFieldsOf(slide).map((field) => `slides.${slideIndex}.${field}`)].filter((dotted) => !placed.has(dotted)));
262
+ if (rest.size) addContent(slide, slidePath, slideTokens, slideNode.key, 1, rest);
263
+ slideNode.container = { mode: "layout", direction: "column", columns: 1, rows: regions.length, tracks: [], gap: 0, padding: 0, resizable: false, layout: true };
264
+ } else {
265
+ for (const key of Object.keys(slide)) {
266
+ if (!isPromotedRegion(key) || !isObject(slide[key])) continue;
267
+ const value = slide[key];
268
+ const tokens = [...slidePath, key], keyTokens = [...slideTokens, key];
269
+ const node = add(base({ key: formatPointer(keyTokens), path: formatPointer(tokens), kind: "region", type: "region", promoted: true, region: key, parent: slideNode.key, depth: 1, label: `Region ${key}` }));
270
+ addContent(value, tokens, keyTokens, node.key, 2);
271
+ const flow = flows.get(tokens.join("."));
272
+ node.box = flow ? unit(flow.box) : union(node.children.map((child) => byKey.get(child).box));
273
+ node.container = flow ? flowContainer(flow, width, height) : childContainer(value.composition?.mode ?? "auto", value.composition?.mode === "column" ? "column" : "row", node.children.map((child) => byKey.get(child)));
274
+ node.can = { ...NONE, resizeTracks: node.container.resizable };
275
+ }
276
+ addContent(slide, slidePath, slideTokens, slideNode.key, 1);
277
+ const flow = flows.get(slidePath.join("."));
278
+ const body = slideNode.children.map((key) => byKey.get(key)).filter((child) => child.kind !== "heading");
279
+ slideNode.container = flow ? flowContainer(flow, width, height) : childContainer(slide.composition?.mode ?? "auto", slide.composition?.mode === "column" ? "column" : "row", body);
280
+ if (body.some((child) => child.promoted)) slideNode.container = { ...slideNode.container, promoted: true, resizable: false };
281
+ }
282
+ slideNode.can = { move: true, group: false, delete: true, duplicate: true, resizeTracks: slideNode.container.resizable };
283
+
284
+ // Findings: each on the deepest real node whose path holds the finding's path.
285
+ const toTokens = (path) => {
286
+ if (typeof path !== "string") return null;
287
+ try { return path.startsWith("/") ? parsePointer(path) : path.split(".").filter(Boolean); } catch { return null; }
288
+ };
289
+ for (const finding of [...findings, ...(geometry.diagnostics ?? [])]) {
290
+ const tokens = toTokens(finding?.path);
291
+ if (!tokens || !prefixOf(slidePath, tokens)) continue;
292
+ let owner = slideNode;
293
+ for (let length = tokens.length; length > 2; length -= 1) {
294
+ const node = byPath.get(formatPointer(tokens.slice(0, length)));
295
+ if (node) { owner = node; break; }
296
+ }
297
+ owner.findings.push({ message: finding.message, ...(finding.code ? { code: finding.code } : {}), ...(finding.severity ? { severity: finding.severity } : {}), path: finding.path });
298
+ }
299
+
300
+ const node = (key) => byKey.get(key);
301
+ const content = (key) => (node(key)?.children ?? []).map(node).filter((child) => child.kind !== "heading" && child.kind !== "part");
302
+ const boxed = nodes.filter((entry) => entry.box && entry.box.w > 0 && entry.box.h > 0).map((entry) => ({ ...entry.box, depth: entry.depth, key: entry.key, node: entry }));
303
+ return {
304
+ slideIndex, slideKey: slideNode.key, width, height, nodes, layout: Boolean(regions),
305
+ /** The node with this key, or undefined. */
306
+ node,
307
+ /** The node at this index pointer (not a virtual region), or undefined. */
308
+ nodeAtPath: (path) => byPath.get(path),
309
+ /** The children of a node, in order (headings first on a slide). */
310
+ children: (key) => (node(key)?.children ?? []).map(node),
311
+ /** The children that content can be dropped between: no headings, no parts. */
312
+ contentChildren: content,
313
+ boxOf: (key) => { const found = node(key); return found?.box ? { ...found.box, depth: found.depth } : undefined; },
314
+ containerOf: (key) => node(key)?.container,
315
+ /** The composed flow of a container node, for track resize. */
316
+ flowOf: (key) => { const path = node(key)?.container?.flowPath; return path ? flows.get(path) : undefined; },
317
+ /** The nodes under a unit point, outermost first (`parts: true` adds the parts of blocks; `skip(hit)` leaves hits out). */
318
+ hitChain(point, { parts: withParts = false, skip } = {}) {
319
+ return boxesAt(boxed, point, { skip: (hit) => (!withParts && hit.node.kind === "part") || Boolean(skip?.(hit)) });
320
+ },
321
+ /**
322
+ * The node a click on `chain` selects: the deepest node, or one level into the selected container (`primary`); `deep` (Cmd or Ctrl)
323
+ * selects the deepest including parts (pass a chain made with `parts: true`).
324
+ */
325
+ target(chain, { primary = null, shift = false, deep = false } = {}) {
326
+ if (!chain.length) return null;
327
+ if (deep) return chain[chain.length - 1];
328
+ const plainChain = chain.filter((hit) => hit.node.kind !== "part");
329
+ return clickTarget(plainChain, primary, { shift, keyOf: (hit) => hit.key, isContainer: (hit) => CONTAINERS.has(hit.node.kind) });
330
+ },
331
+ /** The nearest node at or above `key` that still exists, for a selection after an edit (`null` for none). */
332
+ survivor(key) {
333
+ for (let at = key; at; ) {
334
+ if (byKey.has(at)) return at;
335
+ const hash = at.lastIndexOf("#");
336
+ if (hash > 0) { at = at.slice(0, hash); continue; }
337
+ const tokens = parsePointer(at);
338
+ if (tokens.length <= 2) return null;
339
+ tokens.pop();
340
+ if (tokens.at(-1) === "blocks") tokens.pop();
341
+ at = formatPointer(tokens);
342
+ }
343
+ return null;
344
+ },
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Where content dragged at `point` (slide units) would land: the container key and the index in its content children, the drop line in
350
+ * slide units, and `into` (the group or region being dropped into, for its highlight). `keys` are the dragged nodes, which never take
351
+ * part in the hit test. Headings and parts are never targets. `edge` is the band, as a share of a group's size along its parent's
352
+ * direction, that drops before or after the group instead of into it. Returns null outside the slide.
353
+ */
354
+ export function dropTarget(scene, point, keys = [], { edge = 0.2 } = {}) {
355
+ const dragged = new Set(keys);
356
+ const insideDragged = (key) => { for (let at = scene.node(key); at; at = scene.node(at.parent)) if (dragged.has(at.key)) return true; return false; };
357
+ const chain = scene.hitChain(point, { skip: (hit) => hit.node.kind === "heading" || insideDragged(hit.key) });
358
+ let hit = chain[chain.length - 1]?.node;
359
+ if (!hit) return null;
360
+ let target, index;
361
+ if (hit.kind === "group" && hit.parent) {
362
+ // Near a group's leading or trailing edge, the drop goes beside it; over its body, into it.
363
+ const parent = scene.node(hit.parent);
364
+ const row = parent.container?.direction !== "column";
365
+ const along = row ? (point.x - hit.box.x) / hit.box.w : (point.y - hit.box.y) / hit.box.h;
366
+ if (along < edge || along > 1 - edge) { target = parent; index = indexAmong(scene, parent, hit, along > 0.5); }
367
+ }
368
+ if (!target) {
369
+ if (CONTAINERS.has(hit.kind)) { target = hit; index = insertionIndex(scene, hit, point, dragged); }
370
+ else {
371
+ target = scene.node(hit.parent);
372
+ const row = target.container?.direction !== "column";
373
+ const after = row ? point.x > hit.box.x + hit.box.w / 2 : point.y > hit.box.y + hit.box.h / 2;
374
+ index = indexAmong(scene, target, hit, after);
375
+ }
376
+ }
377
+ return { target: target.key, index, into: target.kind === "group" || target.kind === "region" ? target.key : null, line: dropLine(scene, target, index, dragged) };
378
+ }
379
+ // The index in the content children of `container` (dragged nodes included, as a move counts them before removal) of the gap beside `hit`.
380
+ function indexAmong(scene, container, hit, after) {
381
+ const list = scene.contentChildren(container.key);
382
+ const at = list.findIndex((child) => child.key === hit.key);
383
+ return at < 0 ? list.length : at + (after ? 1 : 0);
384
+ }
385
+ // The gap a point over a container's body falls in: before the first child that comes after the point in reading order.
386
+ function insertionIndex(scene, container, point, dragged) {
387
+ const list = scene.contentChildren(container.key);
388
+ const column = container.container?.direction === "column";
389
+ for (let at = 0; at < list.length; at += 1) {
390
+ const box = list[at].box;
391
+ if (!box || dragged.has(list[at].key)) continue;
392
+ if (column ? point.y < box.y + box.h / 2 : point.y < box.y || (point.y <= box.y + box.h && point.x < box.x + box.w / 2)) return at;
393
+ }
394
+ return list.length;
395
+ }
396
+ // The drop line: at the leading edge of the child that will follow the drop, else after the last child, else along the container's start.
397
+ function dropLine(scene, container, index, dragged) {
398
+ const list = scene.contentChildren(container.key).filter((child) => child.box);
399
+ const column = container.container?.direction === "column";
400
+ const next = list.slice(index).find((child) => !dragged.has(child.key)) ?? (index < list.length ? list[index] : undefined);
401
+ const previous = list.slice(0, index).reverse().find((child) => !dragged.has(child.key));
402
+ const half = (container.container?.gap ?? 0) / 2;
403
+ if (column) {
404
+ const ref = next ?? previous ?? null;
405
+ const box = ref?.box ?? container.box;
406
+ const y = next ? next.box.y - half : previous ? previous.box.y + previous.box.h + half : container.box.y;
407
+ return { x: box.x, y, w: box.w, h: 0, orientation: "horizontal" };
408
+ }
409
+ const ref = next ?? previous ?? null;
410
+ const box = ref?.box ?? container.box;
411
+ const x = next ? next.box.x - half : previous ? previous.box.x + previous.box.w + half : container.box.x;
412
+ return { x, y: box.y, w: 0, h: box.h, orientation: "vertical" };
413
+ }
@@ -2,8 +2,12 @@
2
2
  // pane's, and falls back to the pane's own inline path for good, once, when anything about the worker goes wrong (it cannot start, a script URL is
3
3
  // blocked or missing, a message cannot be cloned, the worker throws or stops answering). It never loses an edit: a failed request rejects with
4
4
  // `error.workerFailed === true`, and the pane then does the same work inline.
5
+ //
6
+ // The worker says `{ ready: true }` as soon as its module has run. A worker that loads but never says so (a script a bundler emptied, a wrong
7
+ // file) is given up after `startTimeout` (5 s), not after the 15 s a single check may take.
5
8
 
6
9
  const DEFAULT_TIMEOUT = 15000;
10
+ const DEFAULT_START_TIMEOUT = 5000;
7
11
 
8
12
  /** A failure of the worker as an error the caller can tell from an exception of the checked text. */
9
13
  function failure(cause) {
@@ -13,23 +17,50 @@ function failure(cause) {
13
17
  return error;
14
18
  }
15
19
 
16
- /** The worker the `worker` option stands for: a function is called, an object with `postMessage` is used as given, `true` starts the package's own. */
20
+ /** Where the worker comes from, for the messages: the URL a host gave, or the package's own file. */
21
+ function describe(option) {
22
+ if (option instanceof URL || typeof option === "string") return `The source worker (${String(option)})`;
23
+ if (option === true) return "The source worker (worker: true, the package's source-worker.js, which the app's bundler must emit)";
24
+ return "The source worker";
25
+ }
26
+
27
+ /** Vite's dev server moves a dependency into node_modules/.vite/deps, where `new URL("./source-worker.js", import.meta.url)` names no file. */
28
+ function hint(option) {
29
+ if (option === true && /\/\.vite\/deps\//.test(import.meta.url)) {
30
+ return ' Vite\'s dependency optimizer moved the editor into .vite/deps, so the worker file is not next to it: pass `worker: () => new Worker(new URL("@openpresentation/opf-editor/source-worker", import.meta.url), { type: "module" })` and list "@openpresentation/opf" and its "/yaml", "/markdown" and "/source" entries in optimizeDeps.include (docs/embedding.md, "The source worker").';
31
+ }
32
+ return "";
33
+ }
34
+
35
+ /**
36
+ * The worker the `worker` option stands for: a function is called, an object with `postMessage` is used as given, a URL (or a string) is started
37
+ * as a module worker, `true` starts the package's own.
38
+ */
17
39
  function makeWorker(option) {
18
40
  if (typeof option === "function") return option();
19
41
  if (option && typeof option === "object" && typeof option.postMessage === "function") return option;
42
+ if (option instanceof URL || (typeof option === "string" && option)) {
43
+ if (typeof Worker !== "function") throw new Error("Web Workers are not available here.");
44
+ return new Worker(option, { type: "module" });
45
+ }
20
46
  if (option === true) {
21
47
  if (typeof Worker !== "function") throw new Error("Web Workers are not available here.");
48
+ // Written as one expression so that webpack, Turbopack, Vite and Parcel see it and bundle the worker with its imports.
22
49
  return new Worker(new URL("./source-worker.js", import.meta.url), { type: "module" });
23
50
  }
24
- throw new TypeError("The worker option is true, a Worker, or a function that returns one.");
51
+ throw new TypeError("The worker option is true, a Worker, a function that returns one, or the URL of a worker script.");
25
52
  }
26
53
 
27
54
  /**
28
- * @param {{ worker: boolean | object | (() => object), onError?: (error: unknown) => void, timeout?: number }} options
55
+ * @param {{ worker: boolean | string | URL | object | (() => object), onError?: (error: unknown) => void, timeout?: number, startTimeout?: number }} options
29
56
  */
30
57
  export function createSourceChecker(options = {}) {
31
58
  const timeout = options.timeout ?? DEFAULT_TIMEOUT;
59
+ const startTimeout = options.startTimeout ?? DEFAULT_START_TIMEOUT;
60
+ const source = describe(options.worker);
32
61
  let worker = null;
62
+ let started = false;
63
+ let startTimer = null;
33
64
  let failed = false;
34
65
  let disposed = false;
35
66
  let nextId = 0;
@@ -42,6 +73,7 @@ export function createSourceChecker(options = {}) {
42
73
  function fail(cause) {
43
74
  if (failed || disposed) return;
44
75
  failed = true;
76
+ clearTimeout(startTimer);
45
77
  const error = failure(cause);
46
78
  for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
47
79
  pending.clear();
@@ -52,6 +84,9 @@ export function createSourceChecker(options = {}) {
52
84
 
53
85
  function reply(event) {
54
86
  const data = event?.data;
87
+ // Any message says the worker runs: its ready signal, or an answer (a host's own worker object may not send the signal).
88
+ if (!started) { started = true; clearTimeout(startTimer); }
89
+ if (data?.ready === true && data.id === undefined) return;
55
90
  const entry = pending.get(data?.id);
56
91
  if (!entry) return;
57
92
  pending.delete(data.id);
@@ -67,8 +102,11 @@ export function createSourceChecker(options = {}) {
67
102
  worker = makeWorker(options.worker);
68
103
  if (!worker || typeof worker.postMessage !== "function") throw new TypeError("The worker option did not give a Worker.");
69
104
  worker.onmessage = reply;
70
- worker.onerror = (event) => { event?.preventDefault?.(); fail(new Error(event?.message || "The source worker failed to start.")); };
71
- worker.onmessageerror = () => fail(new Error("The source worker sent a message that could not be read."));
105
+ // A module worker whose script is missing or cannot load fires an error without a message: say which worker it was.
106
+ worker.onerror = (event) => { event?.preventDefault?.(); fail(new Error(event?.message ? `${source} failed: ${event.message}` : `${source} failed to start.${hint(options.worker)}`)); };
107
+ worker.onmessageerror = () => fail(new Error(`${source} sent a message that could not be read.`));
108
+ startTimer = setTimeout(() => { if (!started) fail(new Error(`${source} loaded but did not start within ${Math.round(startTimeout / 1000)} s; the checks run on the main thread.${hint(options.worker)}`)); }, startTimeout);
109
+ startTimer?.unref?.();
72
110
  return worker;
73
111
  }
74
112
 
@@ -79,7 +117,7 @@ export function createSourceChecker(options = {}) {
79
117
  const id = ++nextId;
80
118
  if (kind) latest[kind] = id;
81
119
  return new Promise((resolve, reject) => {
82
- const timer = setTimeout(() => fail(new Error("The source worker did not answer in time.")), timeout);
120
+ const timer = setTimeout(() => fail(new Error(`${source} did not answer in time.`)), timeout);
83
121
  pending.set(id, { resolve, reject, timer, kind });
84
122
  try { target.postMessage({ id, ...message }); } catch (error) { fail(error); }
85
123
  });
@@ -121,6 +159,7 @@ export function createSourceChecker(options = {}) {
121
159
  dispose() {
122
160
  if (disposed) return;
123
161
  disposed = true;
162
+ clearTimeout(startTimer);
124
163
  for (const entry of pending.values()) { clearTimeout(entry.timer); entry.resolve(null); }
125
164
  pending.clear();
126
165
  try { worker?.terminate?.(); } catch { /* Already gone. */ }