@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
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// CE-19: the structural edits of the slide scene, as prepared changes. Each `prepareScene*` function reads the document and a scene of
|
|
2
|
+
// it (`slide-scene.js`), and returns `{ presentation, patches, changed, paths, label }` without touching a session: `patches` turn the
|
|
3
|
+
// document into `presentation` (validated with core), `paths` are the index pointers to select afterwards. A refusal throws an
|
|
4
|
+
// `OPFEditorError` whose message says why ("A heading cannot move."), which the drag shows on its drop line. Where the editor already
|
|
5
|
+
// has the operation it is reused (block insert, duplicate and remove; group and ungroup; track resize; slide move, duplicate, remove and
|
|
6
|
+
// import); the multi-block move, the move into a layout region and the implicit-content conversion are here.
|
|
7
|
+
//
|
|
8
|
+
// D2: slides stay auto-layout. Every move resolves to a container and an index; no edit writes a position.
|
|
9
|
+
import { resolveSlideContext } from "@openpresentation/opf";
|
|
10
|
+
import { bindRegions } from "@openpresentation/opf/composition";
|
|
11
|
+
import { formatPointer, jsonEqual, parsePointer, readPointer } from "@openpresentation/opf/patch";
|
|
12
|
+
import { resolveIdPointer } from "@openpresentation/opf/ids";
|
|
13
|
+
import { applyJsonPatch, createValuePatch } from "./index.js";
|
|
14
|
+
import { checkFormat, errorFindings, firstErrorMessage } from "./checks.js";
|
|
15
|
+
import { fail } from "./edit-helpers.js";
|
|
16
|
+
import { prepareBlockDuplicate, prepareBlockInsert, prepareBlockRemove } from "./blocks.js";
|
|
17
|
+
import { prepareGroupBlocks, prepareUngroupBlock } from "./content-actions.js";
|
|
18
|
+
import { prepareTrackResize } from "./layout.js";
|
|
19
|
+
import { prepareDuplicateSlides, prepareMoveSlides, prepareRemoveSlides } from "./slides.js";
|
|
20
|
+
import { collectReservedPresentationIds, remapSlideTreeIds } from "./presentation-ids.js";
|
|
21
|
+
import { prepareOpfImport } from "./transfer.js";
|
|
22
|
+
import { prepareSave } from "./catalogs.js";
|
|
23
|
+
import { CONTENT_FIELDS, contentFieldsOf, isPromotedRegion } from "./slide-scene.js";
|
|
24
|
+
|
|
25
|
+
/** The error a refused studio edit throws (`code` `studio-refused`); its message is the reason to show. */
|
|
26
|
+
export const refuse = (message, details) => fail("studio-refused", message, details);
|
|
27
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
const valueAt = (document, pointer) => { const found = readPointer(document, pointer); return found.found ? found.value : undefined; };
|
|
29
|
+
const parentPointer = (pointer) => formatPointer(parsePointer(pointer).slice(0, -2));
|
|
30
|
+
const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
31
|
+
const KIND_WORDS = { text: "text", list: "list", image: "image", video: "video", chart: "chart", table: "table", code: "code", metric: "metric", quote: "quote", timeline: "timeline", group: "group" };
|
|
32
|
+
const HEADING_REFUSALS = { move: "move", group: "be grouped", delete: "be deleted; clear its text instead", duplicate: "be duplicated", copy: "be copied as a block" };
|
|
33
|
+
const wordList = (list) => (list.length <= 1 ? list.join("") : `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`);
|
|
34
|
+
|
|
35
|
+
/** The validated result of `patches` on `presentation`: a valid deck must stay valid (a deck that was already invalid is not blamed on the edit). */
|
|
36
|
+
function validated(presentation, patches, catalogs) {
|
|
37
|
+
const next = applyJsonPatch(presentation, patches);
|
|
38
|
+
const options = catalogs ? { catalogs } : {};
|
|
39
|
+
const report = checkFormat(next, options);
|
|
40
|
+
if (!report.valid && checkFormat(presentation, options).valid)
|
|
41
|
+
throw fail("invalid-opf-edit", firstErrorMessage(report, "This change produces an invalid document."), { issues: errorFindings(report), patches });
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
const unchanged = (presentation, extra = {}) => ({ presentation, patches: [], changed: false, paths: [], noop: true, ...extra });
|
|
45
|
+
|
|
46
|
+
// --- nodes -------------------------------------------------------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** The scene nodes of `keys`, checked for a structural `action` ("move", "group", ...): siblings only, in document order. */
|
|
49
|
+
function siblings(scene, keys, action) {
|
|
50
|
+
const list = [...new Set(keys ?? [])].map((key) => {
|
|
51
|
+
const node = scene.node(key);
|
|
52
|
+
if (!node) throw refuse("Select something on this slide first.", { key });
|
|
53
|
+
return node;
|
|
54
|
+
});
|
|
55
|
+
if (!list.length) throw refuse("Select something on this slide first.");
|
|
56
|
+
for (const node of list) {
|
|
57
|
+
if (node.kind === "heading") throw refuse(`A heading cannot ${HEADING_REFUSALS[action] ?? action}.`, { key: node.key });
|
|
58
|
+
if (node.kind === "part") throw refuse(`Select the whole block to ${action} it.`, { key: node.key });
|
|
59
|
+
if (node.kind === "region") throw refuse(node.virtual ? "A region belongs to the slide's layout. Edit the layout to change it." : "A promoted region keeps its place on the slide grid.", { key: node.key });
|
|
60
|
+
if (node.kind === "slide") throw refuse(`Use the slide list to ${action} slides.`, { key: node.key });
|
|
61
|
+
}
|
|
62
|
+
if (list.some((node) => node.parent !== list[0].parent)) throw refuse("Multiple selection works within one group. Select siblings.");
|
|
63
|
+
const parent = scene.node(list[0].parent);
|
|
64
|
+
const order = scene.contentChildren(parent.key).map((node) => node.key);
|
|
65
|
+
return list.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Turn an owner's implicit content fields into its `blocks` (core's rule: a single payload keeps the owner's `type`). */
|
|
69
|
+
function explicitOwner(owner) {
|
|
70
|
+
if (Array.isArray(owner.blocks)) return owner.blocks;
|
|
71
|
+
const keys = contentFieldsOf(owner);
|
|
72
|
+
owner.blocks = keys.map((key) => ({ ...(keys.length === 1 && owner.type ? { type: owner.type } : {}), [key]: owner[key] }));
|
|
73
|
+
for (const key of [...CONTENT_FIELDS, "type"]) delete owner[key];
|
|
74
|
+
return owner.blocks;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Walk a slide's block tree: every block, group and promoted region object with its parent (`array` and `owner`, or `key` for a region).
|
|
78
|
+
function parents(slide) {
|
|
79
|
+
const map = new Map();
|
|
80
|
+
const walk = (owner, depth) => {
|
|
81
|
+
if (!Array.isArray(owner.blocks) || depth > 32) return;
|
|
82
|
+
for (const block of owner.blocks) if (isObject(block)) { map.set(block, { owner }); walk(block, depth + 1); }
|
|
83
|
+
};
|
|
84
|
+
walk(slide, 0);
|
|
85
|
+
for (const key of Object.keys(slide)) if (isPromotedRegion(key) && isObject(slide[key])) { map.set(slide[key], { owner: slide, key }); walk(slide[key], 0); }
|
|
86
|
+
return map;
|
|
87
|
+
}
|
|
88
|
+
// The index pointer of `target` (an object of the working slide) by identity, or null.
|
|
89
|
+
function locate(slide, slidePointer, target) {
|
|
90
|
+
const walk = (owner, tokens, depth) => {
|
|
91
|
+
if (!Array.isArray(owner.blocks) || depth > 32) return null;
|
|
92
|
+
for (let at = 0; at < owner.blocks.length; at += 1) {
|
|
93
|
+
const block = owner.blocks[at], here = [...tokens, "blocks", String(at)];
|
|
94
|
+
if (block === target) return here;
|
|
95
|
+
if (isObject(block)) { const found = walk(block, here, depth + 1); if (found) return found; }
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
const base = parsePointer(slidePointer);
|
|
100
|
+
if (target === slide) return formatPointer(base);
|
|
101
|
+
const root = walk(slide, base, 0);
|
|
102
|
+
if (root) return formatPointer(root);
|
|
103
|
+
for (const key of Object.keys(slide)) {
|
|
104
|
+
if (!isPromotedRegion(key) || !isObject(slide[key])) continue;
|
|
105
|
+
if (slide[key] === target) return formatPointer([...base, key]);
|
|
106
|
+
const found = walk(slide[key], [...base, key], 0);
|
|
107
|
+
if (found) return formatPointer(found);
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A working copy of the scene's slide in which the owners in `convert` (pointers) hold explicit blocks, with `objectOf(node)` for the
|
|
114
|
+
* object of a block or implicit node in it. Returns the copy, its slide, the identity map of parents and the converted owners.
|
|
115
|
+
*/
|
|
116
|
+
function workingCopy(presentation, scene, convert) {
|
|
117
|
+
const work = structuredClone(presentation);
|
|
118
|
+
const slide = work.slides[scene.slideIndex];
|
|
119
|
+
const converted = [];
|
|
120
|
+
for (const pointer of new Set(convert)) {
|
|
121
|
+
const owner = valueAt(work, pointer);
|
|
122
|
+
if (isObject(owner) && !Array.isArray(owner.blocks) && contentFieldsOf(owner).length) { explicitOwner(owner); converted.push(pointer); }
|
|
123
|
+
}
|
|
124
|
+
const objectOf = (node) => {
|
|
125
|
+
if (node.kind === "implicit") return valueAt(work, node.owner)?.blocks?.[node.blockIndex];
|
|
126
|
+
return valueAt(work, node.path);
|
|
127
|
+
};
|
|
128
|
+
return { work, slide, objectOf, converted, parentMap: parents(slide) };
|
|
129
|
+
}
|
|
130
|
+
// Remove `object` from its blocks array; then remove every group (and promoted region, and the slide's own `blocks`) the removal empties,
|
|
131
|
+
// except `keep`, the container the objects move into.
|
|
132
|
+
function detach(slide, parentMap, object, pruned, keep) {
|
|
133
|
+
const info = parentMap.get(object);
|
|
134
|
+
if (!info) return;
|
|
135
|
+
const list = info.owner.blocks;
|
|
136
|
+
list.splice(list.indexOf(object), 1);
|
|
137
|
+
if (list.length || info.owner === keep) return;
|
|
138
|
+
const owner = info.owner;
|
|
139
|
+
if (owner === slide) { delete slide.blocks; pruned.push(slide); return; }
|
|
140
|
+
const up = parentMap.get(owner);
|
|
141
|
+
if (up?.key) { delete slide[up.key]; pruned.push(slide); return; }
|
|
142
|
+
pruned.push(up?.owner ?? slide);
|
|
143
|
+
detach(slide, parentMap, owner, pruned, keep);
|
|
144
|
+
}
|
|
145
|
+
// One patch for the change inside the working slide: a replace of the deepest object that holds every changed owner (of its `blocks`
|
|
146
|
+
// when nothing else of it changed), so a reorder inside a group touches that group only.
|
|
147
|
+
function slidePatch(presentation, work, scene, owners) {
|
|
148
|
+
const slidePointer = scene.node(scene.slideKey).path;
|
|
149
|
+
const tokens = owners.map((pointer) => parsePointer(pointer));
|
|
150
|
+
let common = tokens[0] ?? parsePointer(slidePointer);
|
|
151
|
+
for (const list of tokens.slice(1)) { let at = 0; while (at < common.length && at < list.length && common[at] === list[at]) at += 1; common = common.slice(0, at); }
|
|
152
|
+
// An owner pointer is an object (slide, group, region): trim back to one, never into a `blocks` array.
|
|
153
|
+
while (common.length > 2 && common.at(-1) === "blocks") common = common.slice(0, -1);
|
|
154
|
+
if (common.length < 2) common = parsePointer(slidePointer);
|
|
155
|
+
const pointer = formatPointer(common);
|
|
156
|
+
const before = valueAt(presentation, pointer), after = valueAt(work, pointer);
|
|
157
|
+
const { blocks: oldBlocks, ...oldRest } = before ?? {}, { blocks: newBlocks, ...newRest } = after ?? {};
|
|
158
|
+
if (Array.isArray(oldBlocks) && Array.isArray(newBlocks) && jsonEqual(oldRest, newRest)) return [{ op: "replace", path: `${pointer}/blocks`, value: newBlocks }];
|
|
159
|
+
return [{ op: "replace", path: pointer, value: after }];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- move --------------------------------------------------------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Move the sibling nodes `keys` (blocks, groups, implicit content) to `index` of the content children of the container `targetKey` (the
|
|
166
|
+
* slide, a group, a promoted region or a layout region). `index` counts the children before the move, as a drop gap does. The moved
|
|
167
|
+
* nodes keep their order and land together. A group the move empties is removed; implicit content becomes blocks first; a block moved
|
|
168
|
+
* into a group or promoted region loses its layout region pin; a block moved into a layout region is pinned to it (and must be accepted
|
|
169
|
+
* there). A move that changes nothing returns `changed: false` with `noop: true`.
|
|
170
|
+
*/
|
|
171
|
+
export function prepareSceneMove(presentation, scene, keys, targetKey, index, options = {}) {
|
|
172
|
+
const sources = siblings(scene, keys, "move");
|
|
173
|
+
const target = scene.node(targetKey);
|
|
174
|
+
if (!target || !["slide", "group", "region"].includes(target.kind)) throw refuse("Drop into the slide, a group or a region.");
|
|
175
|
+
for (const source of sources) for (let at = target; at; at = scene.node(at.parent)) if (at.key === source.key) throw refuse("A group cannot move into itself.");
|
|
176
|
+
const content = scene.contentChildren(target.key);
|
|
177
|
+
if (!Number.isInteger(index) || index < 0 || index > content.length) throw refuse("The drop place is out of range.", { index });
|
|
178
|
+
if (target.kind === "slide" && target.container?.promoted) throw refuse("Drop into one of the slide's regions.");
|
|
179
|
+
if (target.kind === "region" && target.virtual) return regionMove(presentation, scene, sources, target, index, options);
|
|
180
|
+
if (target.kind === "slide" && scene.layout) throw refuse("Drop into one of the layout's regions.");
|
|
181
|
+
|
|
182
|
+
const sourceOwners = sources.map((node) => (node.kind === "implicit" ? node.owner : parentPointer(node.path)));
|
|
183
|
+
const targetOwner = target.path; // the slide, a group or a promoted region: the object that holds the blocks
|
|
184
|
+
const { work, slide, objectOf, converted, parentMap } = workingCopy(presentation, scene, [...sources.filter((node) => node.kind === "implicit").map((node) => node.owner), targetOwner]);
|
|
185
|
+
const objects = sources.map(objectOf);
|
|
186
|
+
if (objects.some((object) => !isObject(object))) throw refuse("This content cannot move. Convert it to blocks first.");
|
|
187
|
+
const owner = valueAt(work, targetOwner);
|
|
188
|
+
const list = Array.isArray(owner?.blocks) ? owner.blocks : explicitOwner(owner);
|
|
189
|
+
const moved = new Set(objects);
|
|
190
|
+
let at = index;
|
|
191
|
+
while (at < list.length && moved.has(list[at])) at += 1;
|
|
192
|
+
const anchor = list[at];
|
|
193
|
+
const order = [...list];
|
|
194
|
+
const pruned = [];
|
|
195
|
+
for (const object of objects) detach(slide, parentMap, object, pruned, owner);
|
|
196
|
+
const into = owner.blocks;
|
|
197
|
+
into.splice(anchor ? into.indexOf(anchor) : into.length, 0, ...objects);
|
|
198
|
+
// The same order in the same container is no move (and does not convert implicit content for nothing).
|
|
199
|
+
if (!pruned.length && into.length === order.length && into.every((object, place) => object === order[place])) return unchanged(presentation, { paths: sources.map((node) => node.path) });
|
|
200
|
+
if (target.kind !== "slide") for (const object of objects) delete object.region;
|
|
201
|
+
if (jsonEqual(work.slides[scene.slideIndex], presentation.slides[scene.slideIndex])) return unchanged(presentation, { paths: sources.map((node) => node.path) });
|
|
202
|
+
const changedOwners = converted.length || pruned.length ? [scene.node(scene.slideKey).path] : [...sourceOwners, targetOwner];
|
|
203
|
+
const patches = slidePatch(presentation, work, scene, changedOwners);
|
|
204
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
205
|
+
const paths = objects.map((object) => locate(slide, scene.node(scene.slideKey).path, object));
|
|
206
|
+
return { presentation: next, patches, changed: true, paths, label: `Moved ${sources.length === 1 ? sources[0].label.split(":")[0] : plural(sources.length, "block")}` };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A move into a layout region: the blocks become root blocks pinned to the region, placed in the slide's `blocks` beside the region's
|
|
210
|
+
// other blocks so the region shows them at `index`.
|
|
211
|
+
function regionMove(presentation, scene, sources, region, index, options) {
|
|
212
|
+
const accepts = region.accepts ?? [];
|
|
213
|
+
for (const source of sources) {
|
|
214
|
+
const kind = source.kind === "group" ? "group" : source.type;
|
|
215
|
+
if (accepts.length && !accepts.includes(kind)) throw refuse(`Region ${region.region} accepts only ${wordList(accepts.map((entry) => KIND_WORDS[entry] ?? entry))}.`, { region: region.region, kind });
|
|
216
|
+
}
|
|
217
|
+
const slidePointer = scene.node(scene.slideKey).path;
|
|
218
|
+
const { work, slide, objectOf, parentMap } = workingCopy(presentation, scene, [slidePointer, ...sources.filter((node) => node.kind === "implicit").map((node) => node.owner)]);
|
|
219
|
+
const objects = sources.map(objectOf);
|
|
220
|
+
if (objects.some((object) => !isObject(object))) throw refuse("This content cannot move. Convert it to blocks first.");
|
|
221
|
+
const moved = new Set(objects);
|
|
222
|
+
const members = scene.contentChildren(region.key).map(objectOf);
|
|
223
|
+
let at = index;
|
|
224
|
+
while (at < members.length && moved.has(members[at])) at += 1;
|
|
225
|
+
const anchor = members[at];
|
|
226
|
+
const remaining = members.filter((object) => object && !moved.has(object));
|
|
227
|
+
// With no other block in the region, the moved blocks stay where they were in the slide's order (after the root group they left).
|
|
228
|
+
const rootOf = (object) => { let at = object; while (parentMap.get(at) && parentMap.get(at).owner !== slide) at = parentMap.get(at).owner; return at; };
|
|
229
|
+
const roots = slide.blocks ?? [];
|
|
230
|
+
const firstRoot = rootOf(objects[0]);
|
|
231
|
+
const after = roots.slice(roots.indexOf(firstRoot) + 1).find((object) => !moved.has(object));
|
|
232
|
+
const pruned = [];
|
|
233
|
+
for (const object of objects) detach(slide, parentMap, object, pruned, slide);
|
|
234
|
+
slide.blocks ??= [];
|
|
235
|
+
// Dropping a region's own blocks back in their place changes nothing (it does not pin blocks the region already holds).
|
|
236
|
+
const place0 = anchor && slide.blocks.includes(anchor) ? slide.blocks.indexOf(anchor) : remaining.length && slide.blocks.includes(remaining.at(-1)) ? slide.blocks.indexOf(remaining.at(-1)) + 1 : null;
|
|
237
|
+
if (sources.every((node) => node.parent === region.key) && !remaining.length) return unchanged(presentation, { paths: sources.map((node) => node.path) });
|
|
238
|
+
if (sources.every((node) => node.parent === region.key) && place0 !== null) {
|
|
239
|
+
const trial = [...slide.blocks];
|
|
240
|
+
trial.splice(place0, 0, ...objects);
|
|
241
|
+
if (jsonEqual(trial, presentation.slides[scene.slideIndex].blocks)) return unchanged(presentation, { paths: sources.map((node) => node.path) });
|
|
242
|
+
}
|
|
243
|
+
for (const object of objects) object.region = region.region;
|
|
244
|
+
const place = anchor && slide.blocks.includes(anchor) ? slide.blocks.indexOf(anchor)
|
|
245
|
+
: remaining.length && slide.blocks.includes(remaining.at(-1)) ? slide.blocks.indexOf(remaining.at(-1)) + 1
|
|
246
|
+
: moved.has(firstRoot) ? (after && slide.blocks.includes(after) ? slide.blocks.indexOf(after) : slide.blocks.length)
|
|
247
|
+
: slide.blocks.includes(firstRoot) ? slide.blocks.indexOf(firstRoot) + 1 : slide.blocks.length;
|
|
248
|
+
slide.blocks.splice(place, 0, ...objects);
|
|
249
|
+
if (jsonEqual(work.slides[scene.slideIndex], presentation.slides[scene.slideIndex])) return unchanged(presentation, { paths: sources.map((node) => node.path) });
|
|
250
|
+
const patches = slidePatch(presentation, work, scene, [slidePointer]);
|
|
251
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
252
|
+
const paths = objects.map((object) => locate(slide, slidePointer, object));
|
|
253
|
+
// The binding decides: each moved block must land in the region as a pinned block (the region may be full).
|
|
254
|
+
const context = resolveSlideContext(next, scene.slideIndex, { catalogs: options.catalogs });
|
|
255
|
+
const binding = bindRegions(next.slides[scene.slideIndex], context.options.layout, { slideIndex: scene.slideIndex });
|
|
256
|
+
const bound = binding.regions?.find((entry) => entry.name === region.region);
|
|
257
|
+
for (const path of paths) {
|
|
258
|
+
const dotted = parsePointer(path).join(".");
|
|
259
|
+
if (!bound?.blocks?.some((block) => block.path === dotted && block.pinned)) throw refuse(`Region ${region.region} is full.`, { region: region.region });
|
|
260
|
+
}
|
|
261
|
+
return { presentation: next, patches, changed: true, paths, label: `Moved ${sources.length === 1 ? sources[0].label.split(":")[0] : plural(sources.length, "block")} to ${region.region}` };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Move the siblings `keys` one place earlier (`delta` -1) or later (1) in their container. Past either end nothing changes. */
|
|
265
|
+
export function prepareSceneMoveBy(presentation, scene, keys, delta, options = {}) {
|
|
266
|
+
const sources = siblings(scene, keys, "move");
|
|
267
|
+
const parent = scene.node(sources[0].parent);
|
|
268
|
+
const content = scene.contentChildren(parent.key);
|
|
269
|
+
const first = content.findIndex((node) => node.key === sources[0].key), last = content.findIndex((node) => node.key === sources.at(-1).key);
|
|
270
|
+
const index = delta < 0 ? first - 1 : last + 2;
|
|
271
|
+
if (index < 0 || index > content.length) return unchanged(presentation, { paths: sources.map((node) => node.path) });
|
|
272
|
+
return prepareSceneMove(presentation, scene, keys, parent.key, index, options);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Move the siblings `keys` into the group just before them (`delta` -1, at its end) or just after them (1, at its start). */
|
|
276
|
+
export function prepareSceneMoveInto(presentation, scene, keys, delta, options = {}) {
|
|
277
|
+
const sources = siblings(scene, keys, "move");
|
|
278
|
+
const content = scene.contentChildren(sources[0].parent);
|
|
279
|
+
const at = delta < 0 ? content.findIndex((node) => node.key === sources[0].key) - 1 : content.findIndex((node) => node.key === sources.at(-1).key) + 1;
|
|
280
|
+
const group = content[at];
|
|
281
|
+
if (!group || group.kind !== "group") throw refuse(delta < 0 ? "There is no group just before this." : "There is no group just after this.");
|
|
282
|
+
return prepareSceneMove(presentation, scene, keys, group.key, delta < 0 ? scene.contentChildren(group.key).length : 0, options);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- group and ungroup -------------------------------------------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Wrap the siblings `keys` in a new group in their place (core's `wrapBlocks`). In a layout region, the group takes the blocks' region pin.
|
|
289
|
+
* When the slide has an id (the deck uses ids), the group gets a free `group-N` id, so its key is stable.
|
|
290
|
+
*/
|
|
291
|
+
export function prepareSceneGroup(presentation, scene, keys, options = {}) {
|
|
292
|
+
const sources = siblings(scene, keys, "group");
|
|
293
|
+
const parent = scene.node(sources[0].parent);
|
|
294
|
+
const container = parent.kind === "region" && parent.virtual ? scene.node(scene.slideKey).path : parent.path;
|
|
295
|
+
const prepared = prepareGroupBlocks(presentation, container, sources.map((node) => node.blockIndex), { validate: false, ...(options.composition ? { composition: options.composition } : {}) });
|
|
296
|
+
const path = parsePointer(prepared.path.startsWith("/") ? prepared.path : formatPointer(prepared.path.split(".")));
|
|
297
|
+
const slidePointer = scene.node(scene.slideKey).path;
|
|
298
|
+
const slide = structuredClone(prepared.patches.at(-1).value);
|
|
299
|
+
const group = valueAt(slide, formatPointer(path.slice(2)));
|
|
300
|
+
const pins = new Set(sources.map((node) => node.pin));
|
|
301
|
+
if (parent.virtual && pins.size === 1 && [...pins][0]) {
|
|
302
|
+
group.region = [...pins][0];
|
|
303
|
+
for (const block of group.blocks) delete block.region;
|
|
304
|
+
}
|
|
305
|
+
// A deck that uses ids (its slide has one) gives the new group one too, so its key survives later reorders (`group-N`, unused).
|
|
306
|
+
if (typeof presentation.slides[scene.slideIndex].id === "string" && group.id === undefined) {
|
|
307
|
+
const taken = new Set(collectReservedPresentationIds(presentation));
|
|
308
|
+
let number = 1;
|
|
309
|
+
while (taken.has(`group-${number}`)) number += 1;
|
|
310
|
+
group.id = `group-${number}`;
|
|
311
|
+
}
|
|
312
|
+
const patches = [{ op: "replace", path: slidePointer, value: slide }];
|
|
313
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
314
|
+
return { presentation: next, patches, changed: true, paths: [formatPointer(path)], label: `Grouped ${plural(sources.length, "block")}`, lossless: prepared.lossless, loss: prepared.loss, ...(group.id ? { id: group.id } : {}) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Replace the group `key` by its blocks (core's `unwrapGroup`). A pinned root group passes its region pin to its blocks. */
|
|
318
|
+
export function prepareSceneUngroup(presentation, scene, key, options = {}) {
|
|
319
|
+
const node = scene.node(key);
|
|
320
|
+
if (!node || node.kind !== "group") throw refuse("Select a group to ungroup.");
|
|
321
|
+
const prepared = prepareUngroupBlock(presentation, node.path, { validate: false });
|
|
322
|
+
const tokens = parsePointer(node.path);
|
|
323
|
+
const slidePointer = scene.node(scene.slideKey).path;
|
|
324
|
+
const slide = structuredClone(prepared.patches.at(-1).value);
|
|
325
|
+
const group = valueAt(presentation, node.path);
|
|
326
|
+
const count = group.blocks.length, start = Number(tokens.at(-1));
|
|
327
|
+
const list = valueAt(slide, formatPointer(tokens.slice(2, -1)));
|
|
328
|
+
if (typeof group.region === "string") for (const block of list.slice(start, start + count)) block.region ??= group.region;
|
|
329
|
+
const patches = [{ op: "replace", path: slidePointer, value: slide }];
|
|
330
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
331
|
+
const paths = Array.from({ length: count }, (_, at) => formatPointer([...tokens.slice(0, -1), String(start + at)]));
|
|
332
|
+
return { presentation: next, patches, changed: true, paths, label: "Ungrouped", lossless: prepared.lossless, loss: prepared.loss };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- duplicate and delete ----------------------------------------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
// Convert the owner of implicit `nodes` to blocks as one guarded change; returns the converted document, its patches and each node's block pointer.
|
|
338
|
+
function asBlocks(presentation, nodes) {
|
|
339
|
+
const owners = [...new Set(nodes.filter((node) => node.kind === "implicit").map((node) => node.owner))];
|
|
340
|
+
let current = presentation;
|
|
341
|
+
const patches = [];
|
|
342
|
+
for (const pointer of owners) {
|
|
343
|
+
const next = structuredClone(valueAt(current, pointer));
|
|
344
|
+
explicitOwner(next);
|
|
345
|
+
const step = [{ op: "test", path: pointer, value: structuredClone(valueAt(current, pointer)) }, { op: "replace", path: pointer, value: next }];
|
|
346
|
+
current = applyJsonPatch(current, step);
|
|
347
|
+
patches.push(...step);
|
|
348
|
+
}
|
|
349
|
+
return { presentation: current, patches, pointerOf: (node) => (node.kind === "implicit" ? `${node.owner}/blocks/${node.blockIndex}` : node.path) };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Duplicate the siblings `keys`: each copy goes right after its original, with fresh ids. Slide keys duplicate slides. */
|
|
353
|
+
export function prepareSceneDuplicate(presentation, scene, keys, options = {}) {
|
|
354
|
+
const slideIndices = slideIndicesOf(presentation, scene, keys);
|
|
355
|
+
if (slideIndices) {
|
|
356
|
+
const prepared = prepareDuplicateSlides(presentation, slideIndices, options);
|
|
357
|
+
return { ...prepared, paths: prepared.selection.map((index) => `/slides/${index}`), label: slideIndices.length === 1 ? "Duplicated slide" : `Duplicated ${slideIndices.length} slides` };
|
|
358
|
+
}
|
|
359
|
+
const sources = siblings(scene, keys, "duplicate");
|
|
360
|
+
const converted = asBlocks(presentation, sources);
|
|
361
|
+
let current = converted.presentation;
|
|
362
|
+
const patches = [...converted.patches];
|
|
363
|
+
const pointers = sources.map(converted.pointerOf);
|
|
364
|
+
for (const pointer of [...pointers].reverse()) {
|
|
365
|
+
const step = prepareBlockDuplicate(current, pointer);
|
|
366
|
+
current = step.presentation;
|
|
367
|
+
patches.push(...step.patches);
|
|
368
|
+
}
|
|
369
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
370
|
+
const paths = pointers.map((pointer, order) => {
|
|
371
|
+
const tokens = parsePointer(pointer);
|
|
372
|
+
return formatPointer([...tokens.slice(0, -1), String(Number(tokens.at(-1)) + order + 1)]);
|
|
373
|
+
});
|
|
374
|
+
return { presentation: next, patches, changed: true, paths, label: `Duplicated ${sources.length === 1 ? sources[0].label.split(":")[0] : plural(sources.length, "block")}` };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Delete the siblings `keys`. A group they empty goes too; a slide is never left without its frame. Slide keys delete slides. */
|
|
378
|
+
export function prepareSceneDelete(presentation, scene, keys, options = {}) {
|
|
379
|
+
const slideIndices = slideIndicesOf(presentation, scene, keys);
|
|
380
|
+
if (slideIndices) {
|
|
381
|
+
const prepared = prepareRemoveSlides(presentation, slideIndices);
|
|
382
|
+
return { ...prepared, paths: prepared.selection.map((index) => `/slides/${index}`), label: slideIndices.length === 1 ? "Deleted slide" : `Deleted ${slideIndices.length} slides` };
|
|
383
|
+
}
|
|
384
|
+
const sources = siblings(scene, keys, "delete");
|
|
385
|
+
let current = presentation, selection = null;
|
|
386
|
+
const patches = [];
|
|
387
|
+
for (const node of [...sources].reverse()) {
|
|
388
|
+
if (node.kind === "implicit") {
|
|
389
|
+
// The field goes, with the owner's `type` when it named this content, and a promoted region left with no content goes whole.
|
|
390
|
+
const owner = valueAt(current, node.owner);
|
|
391
|
+
const rest = contentFieldsOf(owner).filter((field) => field !== node.field);
|
|
392
|
+
const ownerTokens = parsePointer(node.owner);
|
|
393
|
+
const region = ownerTokens.length === 3 && isPromotedRegion(ownerTokens[2]);
|
|
394
|
+
const typed = typeof owner.type === "string" && (!rest.length || owner.type === node.type || (owner.type === "text" && node.field === "bullets"));
|
|
395
|
+
const step = region && !rest.length ? [{ op: "remove", path: node.owner }] : [{ op: "remove", path: node.path }, ...(typed ? [{ op: "remove", path: `${node.owner}/type` }] : [])];
|
|
396
|
+
current = applyJsonPatch(current, step);
|
|
397
|
+
patches.push(...step);
|
|
398
|
+
selection = region && !rest.length ? formatPointer(ownerTokens.slice(0, 2)) : node.owner;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const step = prepareBlockRemove(current, node.path);
|
|
402
|
+
current = step.presentation;
|
|
403
|
+
patches.push(...step.patches);
|
|
404
|
+
selection = step.path;
|
|
405
|
+
}
|
|
406
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
407
|
+
return { presentation: next, patches, changed: true, paths: selection ? [selection] : [], label: `Deleted ${sources.length === 1 ? sources[0].label.split(":")[0] : plural(sources.length, "block")}` };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- track resize ------------------------------------------------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
const round3 = (value) => Math.round(value * 1000) / 1000;
|
|
413
|
+
function niceRatio(fraction) {
|
|
414
|
+
let best = { p: 1, q: 1, error: Infinity };
|
|
415
|
+
for (let total = 2; total <= 12; total += 1) for (let p = 1; p < total; p += 1) {
|
|
416
|
+
const error = Math.abs(p / total - fraction);
|
|
417
|
+
if (error < best.error - 1e-9) best = { p, q: total - p, error };
|
|
418
|
+
}
|
|
419
|
+
return best;
|
|
420
|
+
}
|
|
421
|
+
/** "2 : 1": the ratio two track weights read as. */
|
|
422
|
+
export const ratioLabel = (a, b) => `${round3(a)} : ${round3(b)}`;
|
|
423
|
+
/**
|
|
424
|
+
* The weights the tracks `boundary` and `boundary + 1` take when the divider between them sits at `fraction` (0 to 1) of the pair, as
|
|
425
|
+
* Layout Studio snaps them: with two tracks the nearest small whole ratio (2 : 1, 3 : 2, up to twelfths); with more, halves of the pair's
|
|
426
|
+
* sum, so the other tracks keep their share. `free` (Shift) turns snapping off.
|
|
427
|
+
*/
|
|
428
|
+
export function snapTrackPair(weights, boundary, fraction, { free = false } = {}) {
|
|
429
|
+
const a = weights[boundary], b = weights[boundary + 1];
|
|
430
|
+
const f = Math.max(0, Math.min(1, fraction));
|
|
431
|
+
const total = a + b, others = weights.length - 2;
|
|
432
|
+
if (free) {
|
|
433
|
+
const first = Math.max(total * 0.05, Math.min(total * 0.95, round3(f * total)));
|
|
434
|
+
return { a: first, b: round3(total - first) };
|
|
435
|
+
}
|
|
436
|
+
if (!others) { const { p, q } = niceRatio(Math.max(0.05, Math.min(0.95, f))); return { a: p, b: q }; }
|
|
437
|
+
if (total < 1) return { a, b };
|
|
438
|
+
const first = Math.max(0.5, Math.min(total - 0.5, Math.round((f * total) / 0.5) * 0.5));
|
|
439
|
+
return { a: round3(first), b: round3(total - first) };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// The effective weights of a container's composed flow, one per track.
|
|
443
|
+
function flowWeights(flow) {
|
|
444
|
+
return drawnTracks(flow).weights;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* The weights a container's tracks are drawn with, and how the drawn pair maps onto them. With explicit weights (or tracks that fill the
|
|
448
|
+
* container) they are the composition's weights. A column stacked at its content's heights (no weights: its rows do not fill it) reads
|
|
449
|
+
* as weights in proportion to the drawn heights, with the last row taking the empty space below, so the first resize starts from what is
|
|
450
|
+
* drawn: every other divider stays where it is (CE-20; without this the first nudge of a content-sized column jumped from about 26% to
|
|
451
|
+
* 48%, because explicit weights fill the container while the drawn stack does not). `scale(boundary)` turns a fraction of the drawn
|
|
452
|
+
* pair into the fraction of the pair the weights will draw.
|
|
453
|
+
*/
|
|
454
|
+
export function drawnTracks(flow) {
|
|
455
|
+
const vertical = flow.composition?.mode === "column";
|
|
456
|
+
const tracks = (vertical ? flow.rows : flow.columns) ?? [];
|
|
457
|
+
const explicit = Array.isArray(flow.composition?.weights) && flow.composition.weights.length ? flow.composition.weights : null;
|
|
458
|
+
const plain = { weights: Array.from({ length: tracks.length }, (_, index) => explicit?.[index] ?? 1), drawn: false, scale: () => 1 };
|
|
459
|
+
if (explicit || tracks.length < 2 || !flow.box) return plain;
|
|
460
|
+
const extent = vertical ? flow.box.height : flow.box.width;
|
|
461
|
+
const room = extent - (flow.gap ?? 0) * (tracks.length - 1);
|
|
462
|
+
const sizes = tracks.map((track) => track.size);
|
|
463
|
+
const slack = room - sizes.reduce((sum, size) => sum + size, 0);
|
|
464
|
+
if (!(room > 0) || slack <= room * 0.005 || sizes.some((size) => !(size > 0))) return plain;
|
|
465
|
+
const raw = [...sizes];
|
|
466
|
+
raw[raw.length - 1] += slack;
|
|
467
|
+
// Whole-number-ish weights (the smallest at least 2, so the halves the snapping uses are fine enough), never over core's 100.
|
|
468
|
+
let unit = Math.max(tracks.length / room, 2 / Math.min(...raw));
|
|
469
|
+
if (Math.max(...raw) * unit > 100) unit = 100 / Math.max(...raw);
|
|
470
|
+
const weights = raw.map((size) => Math.max(0.001, round3(size * unit)));
|
|
471
|
+
return { weights, drawn: true, scale: (boundary) => (sizes[boundary] + sizes[boundary + 1]) / (raw[boundary] + raw[boundary + 1]) };
|
|
472
|
+
}
|
|
473
|
+
// Write `weights` as the container's composition: `prepareTrackResize` checks the flow (2 to 12 tracks, no reserved slots) and turns an
|
|
474
|
+
// automatic arrangement into the explicit grid it drew; the weights are then the exact snapped values.
|
|
475
|
+
function prepareWeights(presentation, scene, key, weights, label, options) {
|
|
476
|
+
const node = scene.node(key);
|
|
477
|
+
const flow = scene.flowOf(key);
|
|
478
|
+
if (!node || !flow) throw refuse("This has no tracks to resize. Select a row, column or grid.");
|
|
479
|
+
let base;
|
|
480
|
+
try {
|
|
481
|
+
base = prepareTrackResize(presentation, flow, 0, weights[0] / (weights[0] + weights[1]));
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error instanceof RangeError) throw refuse(error.message);
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
const composition = { ...base.composition, weights: weights.map(round3) };
|
|
487
|
+
const container = valueAt(presentation, formatPointer(flow.path.split(".")));
|
|
488
|
+
if (jsonEqual(container?.composition, composition)) return unchanged(presentation, { paths: [node.path], weights: composition.weights, label });
|
|
489
|
+
const patches = [base.patches[0], ...createValuePatch(presentation, [...flow.path.split("."), "composition"], composition)];
|
|
490
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
491
|
+
return { presentation: next, patches, changed: true, paths: [node.path], weights: composition.weights, label };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Resize the tracks of the container `key` (a slide, group or promoted region with a row, column, grid or automatic arrangement): the
|
|
496
|
+
* divider between track `boundary` and the next moves to `fraction` of the pair, snapped (`snapTrackPair`). This writes the container's
|
|
497
|
+
* `composition.weights` (columns in a row or grid, rows in a column); an automatic arrangement becomes the grid it drew.
|
|
498
|
+
*/
|
|
499
|
+
export function prepareSceneTrackResize(presentation, scene, key, boundary, fraction, options = {}) {
|
|
500
|
+
const flow = scene.flowOf(key);
|
|
501
|
+
if (!flow) throw refuse("This has no tracks to resize. Select a row, column or grid.");
|
|
502
|
+
const drawn = drawnTracks(flow);
|
|
503
|
+
const weights = drawn.weights;
|
|
504
|
+
if (!Number.isInteger(boundary) || boundary < 0 || boundary >= weights.length - 1) throw refuse("Choose a divider between two tracks.");
|
|
505
|
+
// `fraction` is where the divider sits in the pair as drawn; the weights draw the pair over its share of the whole container.
|
|
506
|
+
const { a, b } = snapTrackPair(weights, boundary, Math.max(0, Math.min(1, fraction * drawn.scale(boundary))), options);
|
|
507
|
+
weights[boundary] = a;
|
|
508
|
+
weights[boundary + 1] = b;
|
|
509
|
+
return prepareWeights(presentation, scene, key, weights, `Resized to ${ratioLabel(a, b)}`, options);
|
|
510
|
+
}
|
|
511
|
+
/** Give the tracks on both sides of divider `boundary` of container `key` the same weight (a double-click or Enter on the divider). */
|
|
512
|
+
export function prepareSceneEqualize(presentation, scene, key, boundary, options = {}) {
|
|
513
|
+
const flow = scene.flowOf(key);
|
|
514
|
+
if (!flow) throw refuse("This has no tracks to resize. Select a row, column or grid.");
|
|
515
|
+
const weights = flowWeights(flow);
|
|
516
|
+
if (!Number.isInteger(boundary) || boundary < 0 || boundary >= weights.length - 1) throw refuse("Choose a divider between two tracks.");
|
|
517
|
+
const half = weights.length === 2 ? 1 : (weights[boundary] + weights[boundary + 1]) / 2;
|
|
518
|
+
weights[boundary] = half;
|
|
519
|
+
weights[boundary + 1] = half;
|
|
520
|
+
return prepareWeights(presentation, scene, key, weights, "Equalized", options);
|
|
521
|
+
}
|
|
522
|
+
/** Set every track weight of container `key` (positive numbers up to 100, one per track). */
|
|
523
|
+
export function prepareSceneWeights(presentation, scene, key, weights, options = {}) {
|
|
524
|
+
const flow = scene.flowOf(key);
|
|
525
|
+
if (!flow) throw refuse("This has no tracks to resize. Select a row, column or grid.");
|
|
526
|
+
const count = flowWeights(flow).length;
|
|
527
|
+
if (!Array.isArray(weights) || weights.length !== count || weights.some((weight) => !Number.isFinite(weight) || weight <= 0 || weight > 100)) throw refuse(`Give ${count} weights between 0 and 100.`);
|
|
528
|
+
return prepareWeights(presentation, scene, key, [...weights], `Resized to ${weights.map(round3).join(" : ")}`, options);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// --- slides ------------------------------------------------------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
// The slide indices of `keys` when every key names a slide (`/slides/@id` or `/slides/2`), else null.
|
|
534
|
+
function slideIndicesOf(presentation, scene, keys) {
|
|
535
|
+
const list = [...new Set(keys ?? [])];
|
|
536
|
+
if (!list.length || !list.every((key) => typeof key === "string" && !key.includes("#") && parsePointer(key).length === 2 && parsePointer(key)[0] === "slides")) return null;
|
|
537
|
+
return list.map((key) => {
|
|
538
|
+
let pointer;
|
|
539
|
+
try { pointer = resolveIdPointer(presentation, key); } catch { throw refuse("That slide no longer exists.", { key }); }
|
|
540
|
+
const index = Number(parsePointer(pointer)[1]);
|
|
541
|
+
if (!presentation.slides?.[index]) throw refuse("That slide no longer exists.", { key });
|
|
542
|
+
return index;
|
|
543
|
+
}).sort((a, b) => a - b);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Move the slides `keys` to the gap `to` of the original order (0 first, the slide count last), keeping their order. */
|
|
547
|
+
export function prepareSceneMoveSlides(presentation, scene, keys, to, options = {}) {
|
|
548
|
+
const indices = slideIndicesOf(presentation, scene, keys);
|
|
549
|
+
if (!indices) throw refuse("Select slides to move them.");
|
|
550
|
+
const prepared = prepareMoveSlides(presentation, indices, to, options);
|
|
551
|
+
return { ...prepared, paths: (prepared.selection ?? indices).map((index) => `/slides/${index}`), label: indices.length === 1 ? "Moved slide" : `Moved ${indices.length} slides` };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// --- clipboard ---------------------------------------------------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* What the clipboard holds for `keys`: slide keys copy those slides as a self-contained deck (its catalog records embedded, so it pastes
|
|
558
|
+
* into another deck too), anything else copies the sibling blocks (implicit content as blocks, without layout region pins).
|
|
559
|
+
*/
|
|
560
|
+
export function copyScene(presentation, scene, keys, options = {}) {
|
|
561
|
+
const slideIndices = slideIndicesOf(presentation, scene, keys);
|
|
562
|
+
if (slideIndices) {
|
|
563
|
+
const document = prepareSave({ ...presentation, slides: slideIndices.map((index) => presentation.slides[index]) }, { catalogs: options.catalogs }).document;
|
|
564
|
+
return { kind: "opf-studio-slides", presentation: document, count: slideIndices.length };
|
|
565
|
+
}
|
|
566
|
+
const sources = siblings(scene, keys, "copy");
|
|
567
|
+
const blocks = sources.map((node) => {
|
|
568
|
+
const value = node.kind === "implicit" ? { [node.field]: structuredClone(valueAt(presentation, node.path)) } : structuredClone(valueAt(presentation, node.path));
|
|
569
|
+
delete value.region;
|
|
570
|
+
return value;
|
|
571
|
+
});
|
|
572
|
+
return { kind: "opf-studio-blocks", blocks, count: blocks.length };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Patches from `before` to `after` where `after` inserted `count` slides at `at`: each changed top-level key, then each slide.
|
|
576
|
+
function insertPatches(before, after, at, count) {
|
|
577
|
+
const patches = [];
|
|
578
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
579
|
+
if (key === "slides" || jsonEqual(before[key], after[key])) continue;
|
|
580
|
+
patches.push(after[key] === undefined ? { op: "remove", path: formatPointer([key]) } : { op: Object.hasOwn(before, key) ? "replace" : "add", path: formatPointer([key]), value: after[key] });
|
|
581
|
+
}
|
|
582
|
+
for (let offset = 0; offset < count; offset += 1) patches.push({ op: "add", path: `/slides/${at + offset}`, value: after.slides[at + offset] });
|
|
583
|
+
return jsonEqual(applyJsonPatch(before, patches), after) ? patches : [{ op: "replace", path: "", value: after }];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Paste a clipboard from `copyScene`. Slides go after slide `options.slideIndex` (default the scene's slide), with catalog records,
|
|
588
|
+
* assets and datasets brought along and renamed where they differ (the transfer import). Blocks go to `index` (default the end) of the
|
|
589
|
+
* content children of the container `options.target` (default the slide; a layout region pins them to it), with fresh ids.
|
|
590
|
+
*/
|
|
591
|
+
export function prepareScenePaste(presentation, scene, clip, options = {}) {
|
|
592
|
+
if (clip?.kind === "opf-studio-slides") {
|
|
593
|
+
const after = options.slideIndex ?? scene.slideIndex;
|
|
594
|
+
const imported = prepareOpfImport(presentation, { presentation: clip.presentation }, { mode: "insert", slideIndex: after, catalogs: options.catalogs });
|
|
595
|
+
const count = imported.presentation.slides.length - presentation.slides.length;
|
|
596
|
+
const patches = insertPatches(presentation, imported.presentation, after + 1, count);
|
|
597
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
598
|
+
return { presentation: next, patches, changed: count > 0, paths: Array.from({ length: count }, (_, offset) => `/slides/${after + 1 + offset}`), label: count === 1 ? "Pasted slide" : `Pasted ${count} slides`, renamed: imported.renamed, addedGroups: imported.addedGroups };
|
|
599
|
+
}
|
|
600
|
+
if (clip?.kind !== "opf-studio-blocks" || !Array.isArray(clip.blocks) || !clip.blocks.length) throw refuse("There is nothing to paste.");
|
|
601
|
+
const target = scene.node(options.target ?? scene.slideKey);
|
|
602
|
+
if (!target || !["slide", "group", "region"].includes(target.kind)) throw refuse("Paste into the slide, a group or a region.");
|
|
603
|
+
if (target.kind === "slide" && (target.container?.promoted || scene.layout)) throw refuse("Paste into one of the slide's regions.");
|
|
604
|
+
const content = scene.contentChildren(target.key);
|
|
605
|
+
const index = options.index ?? content.length;
|
|
606
|
+
if (!Number.isInteger(index) || index < 0 || index > content.length) throw refuse("The paste place is out of range.", { index });
|
|
607
|
+
const copies = structuredClone(clip.blocks);
|
|
608
|
+
remapSlideTreeIds({ blocks: copies }, new Set(collectReservedPresentationIds(presentation)));
|
|
609
|
+
let container = target.path, at = index;
|
|
610
|
+
if (target.kind === "region" && target.virtual) {
|
|
611
|
+
// A layout region: the copies become root blocks pinned to it, beside its other blocks.
|
|
612
|
+
for (const copy of copies) copy.region = target.region;
|
|
613
|
+
const members = content;
|
|
614
|
+
container = scene.node(scene.slideKey).path;
|
|
615
|
+
const blocks = valueAt(presentation, `${container}/blocks`);
|
|
616
|
+
const rootIndex = (node) => (node.kind === "implicit" ? node.blockIndex : Number(parsePointer(node.path).at(-1)));
|
|
617
|
+
at = members[index] ? rootIndex(members[index]) : members.length ? rootIndex(members.at(-1)) + 1 : Array.isArray(blocks) ? blocks.length : contentFieldsOf(valueAt(presentation, container)).length;
|
|
618
|
+
} else for (const copy of copies) delete copy.region;
|
|
619
|
+
let current = presentation;
|
|
620
|
+
const patches = [];
|
|
621
|
+
copies.forEach((copy, offset) => {
|
|
622
|
+
const step = prepareBlockInsert(current, container, copy, at + offset);
|
|
623
|
+
current = step.presentation;
|
|
624
|
+
patches.push(...step.patches);
|
|
625
|
+
});
|
|
626
|
+
const next = validated(presentation, patches, options.catalogs);
|
|
627
|
+
const paths = copies.map((_, offset) => `${container}/blocks/${at + offset}`);
|
|
628
|
+
return { presentation: next, patches, changed: true, paths, label: copies.length === 1 ? "Pasted block" : `Pasted ${copies.length} blocks` };
|
|
629
|
+
}
|
package/dist/slide-manager.js
CHANGED
|
@@ -83,7 +83,8 @@ export function createSlideManager(container, options) {
|
|
|
83
83
|
: "Up and Down arrows move between slides. Shift plus arrow keys extend the selection. Alt plus Up or Down moves the selected slides. Delete removes them. Press the context menu key for more actions." });
|
|
84
84
|
container.after(hint);
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
// The snapshot is one object per version; `editor.presentation` copies the whole deck on every read (CE-20: read once per card).
|
|
87
|
+
const deck = () => editor.snapshot?.().presentation ?? editor.presentation;
|
|
87
88
|
const count = () => deck().slides.length;
|
|
88
89
|
const current = () => Math.max(0, Math.min(count() - 1, options.getSlideIndex()));
|
|
89
90
|
const announce = (message) => {
|