@openpresentation/opf-editor 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +20 -6
  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 +134 -124
  10. package/dist/exact-source.d.ts +30 -0
  11. package/dist/font-gate.js +20 -1
  12. package/dist/json-editor.js +8 -2
  13. package/dist/json-options.d.ts +2 -1
  14. package/dist/json-options.js +7 -6
  15. package/dist/layout-templates.d.ts +8 -3
  16. package/dist/layout-templates.js +30 -16
  17. package/dist/numbering-panel.js +2 -3
  18. package/dist/react.d.ts +12 -4
  19. package/dist/react.js +39 -5
  20. package/dist/slide-adapter.d.ts +49 -0
  21. package/dist/slide-adapter.js +289 -0
  22. package/dist/slide-edits.d.ts +46 -0
  23. package/dist/slide-edits.js +629 -0
  24. package/dist/slide-manager.js +2 -1
  25. package/dist/slide-scene.d.ts +96 -0
  26. package/dist/slide-scene.js +413 -0
  27. package/dist/source-checker.js +45 -6
  28. package/dist/source-completions.js +6 -4
  29. package/dist/source-pane.d.ts +18 -6
  30. package/dist/source-pane.js +45 -0
  31. package/dist/source-worker.js +8 -4
  32. package/dist/studio-canvas.d.ts +34 -0
  33. package/dist/studio-canvas.js +586 -0
  34. package/dist/studio-commands.d.ts +15 -0
  35. package/dist/studio-commands.js +155 -0
  36. package/dist/studio-design.d.ts +16 -0
  37. package/dist/studio-design.js +354 -0
  38. package/dist/studio-kit/dom.d.ts +16 -0
  39. package/dist/studio-kit/dom.js +42 -6
  40. package/dist/studio-layers.d.ts +12 -0
  41. package/dist/studio-layers.js +218 -0
  42. package/dist/studio.d.ts +31 -0
  43. package/dist/studio.js +1608 -0
  44. package/dist/styles.css +1546 -117
  45. package/dist/thumbnail-cache.d.ts +2 -0
  46. package/dist/thumbnail-cache.js +10 -1
  47. package/dist/workspace-measure.js +6 -4
  48. package/dist/workspace-parts.js +20 -0
  49. package/dist/workspace-shell.js +25 -15
  50. package/dist/workspace-theme.js +3 -0
  51. package/dist/workspace.d.ts +97 -9
  52. package/dist/workspace.js +325 -59
  53. package/package.json +29 -18
@@ -0,0 +1,289 @@
1
+ // CE-19: the studio kit's document adapter for the slides of a deck, over an editor session. Layout Studio has `layout-adapter.js` over its
2
+ // record model; this is the same contract over `createEditorSession`: keys are id paths (`/slides/@intro/blocks/@kpi`, see
3
+ // `slide-scene.js`), boxes and containers come from the composed layout geometry of the current slide, a trial move runs the edit
4
+ // without applying it, and every edit is ONE validated `applyPatch` guarded by `expectHash` (the document must still be the one the edit
5
+ // was computed from). A gesture (a drag, a divider) previews drafts without writing to the document and lands as one undo step. No DOM:
6
+ // the Studio (CE-20) draws, this answers.
7
+ import { resolveSlideContext } from "@openpresentation/opf";
8
+ import { composeSlide } from "@openpresentation/opf/composition";
9
+ import { documentHash, resolveIdPointer, toIdPointer } from "@openpresentation/opf/ids";
10
+ import { parsePointer } from "@openpresentation/opf/patch";
11
+ import { createGesture } from "./studio-kit/gesture.js";
12
+ import { dropTarget, slideScene } from "./slide-scene.js";
13
+ import {
14
+ copyScene,
15
+ prepareSceneDelete,
16
+ prepareSceneDuplicate,
17
+ prepareSceneEqualize,
18
+ prepareSceneGroup,
19
+ prepareSceneMove,
20
+ prepareSceneMoveBy,
21
+ prepareSceneMoveInto,
22
+ prepareSceneMoveSlides,
23
+ prepareScenePaste,
24
+ prepareSceneTrackResize,
25
+ prepareSceneUngroup,
26
+ prepareSceneWeights,
27
+ } from "./slide-edits.js";
28
+
29
+ const COMMANDS = {
30
+ move: prepareSceneMove,
31
+ moveBy: prepareSceneMoveBy,
32
+ moveInto: prepareSceneMoveInto,
33
+ group: prepareSceneGroup,
34
+ ungroup: prepareSceneUngroup,
35
+ duplicate: prepareSceneDuplicate,
36
+ delete: prepareSceneDelete,
37
+ resizeTracks: prepareSceneTrackResize,
38
+ equalize: prepareSceneEqualize,
39
+ setWeights: prepareSceneWeights,
40
+ paste: prepareScenePaste,
41
+ moveSlides: prepareSceneMoveSlides,
42
+ };
43
+ /** The command names `prepare`, `apply` and `run` take. */
44
+ export const SLIDE_COMMANDS = Object.freeze(Object.keys(COMMANDS));
45
+
46
+ /** Core composition of slide `slideIndex` (portable text estimate unless `fonts` measure), as the session's `composeSlide` does. */
47
+ export function composeSlideGeometry(presentation, slideIndex, { catalogs, fonts } = {}) {
48
+ const { slide, options } = resolveSlideContext(presentation, slideIndex, { catalogs, fonts });
49
+ return composeSlide(slide, options);
50
+ }
51
+
52
+ /**
53
+ * The studio document adapter of the slides of `session`. Options:
54
+ * - `slideIndex` (default 0): the slide the scene shows; `setSlide` changes it;
55
+ * - `geometry(presentation, slideIndex)`: the `SlideComposition` of a slide as it is drawn (the canvas's `onRender` geometry, or the
56
+ * renderer's `resolvePresentation(...).slides[i].geometry` with the canvas fonts); default core composition with the session's catalogs;
57
+ * - `selection()`: the keys selected now, recorded with each change (`meta.selection`) so undo and redo can restore them;
58
+ * - `onPreview({ presentation, message })`: draw a gesture's draft (`presentation` is null when the gesture ends or is cancelled);
59
+ * - `onSelection(keys, { source })`: the keys to select after an edit, an undo or a redo;
60
+ * - `onRefuse({ reason, command })`: a command was refused (the Studio's status line and live region).
61
+ */
62
+ export function createSlideAdapter(session, options = {}) {
63
+ if (!session || typeof session.applyPatch !== "function") throw new TypeError("createSlideAdapter needs an editor session.");
64
+ let slideIndex = options.slideIndex ?? 0;
65
+ let draft = null; // the prepared change a gesture shows
66
+ let lastResult = null;
67
+ const geometryOf = options.geometry ?? ((presentation, index) => composeSlideGeometry(presentation, index, { catalogs: session.catalogs }));
68
+ const document = () => session.snapshot().presentation;
69
+ const catalogs = () => session.catalogs;
70
+
71
+ // One scene per (document, slide); the draft's while a gesture previews one.
72
+ let cache = { doc: null, index: -1, scene: null };
73
+ function sceneFor(doc, index) {
74
+ if (cache.doc === doc && cache.index === index) return cache.scene;
75
+ const findings = doc === document() ? session.validation?.findings ?? [] : [];
76
+ const scene = slideScene(doc, index, { geometry: geometryOf(doc, index), findings });
77
+ cache = { doc, index, scene };
78
+ return scene;
79
+ }
80
+ const clampSlide = () => { const count = document().slides?.length ?? 0; slideIndex = Math.max(0, Math.min(slideIndex, count - 1)); return slideIndex; };
81
+ const committedScene = () => sceneFor(document(), clampSlide());
82
+ const scene = () => (draft ? sceneFor(draft.presentation, Math.min(slideIndex, draft.presentation.slides.length - 1)) : committedScene());
83
+
84
+ // Trial moves are asked on every pointer move of a drag: one answer per (document, keys, target, index).
85
+ let trials = { doc: null, map: new Map() };
86
+ function trialMove(keys, target, index) {
87
+ const doc = document();
88
+ if (trials.doc !== doc) trials = { doc, map: new Map() };
89
+ const id = JSON.stringify([keys, target, index]);
90
+ if (!trials.map.has(id)) {
91
+ let answer;
92
+ try {
93
+ const prepared = prepareSceneMove(doc, committedScene(), keys, target, index, { catalogs: catalogs() });
94
+ answer = { ok: true, reason: "", noop: !prepared.changed };
95
+ } catch (error) {
96
+ answer = { ok: false, reason: error?.message ?? String(error), noop: false };
97
+ }
98
+ trials.map.set(id, answer);
99
+ }
100
+ return trials.map.get(id);
101
+ }
102
+
103
+ /** Prepare `command` against the session's document without applying it (a refusal throws, with the reason as its message). */
104
+ function prepare(command, ...args) {
105
+ const run = COMMANDS[command];
106
+ if (!run) throw new TypeError(`Unknown slide command ${command}.`);
107
+ const doc = document();
108
+ const last = args.length && args.at(-1) && typeof args.at(-1) === "object" && !Array.isArray(args.at(-1)) && !args.at(-1).kind ? args.pop() : {};
109
+ const prepared = run(doc, committedScene(), ...args, { ...last, catalogs: catalogs() });
110
+ // CE-20: a caller may name the step itself (`{ label }` as the last argument: "Inserted chart" for a paste of a new block).
111
+ return { ...prepared, ...(typeof last.label === "string" ? { label: last.label } : {}), base: doc, command };
112
+ }
113
+
114
+ // Selections recorded per undo depth, so undo and redo restore the selection of the edit they cross.
115
+ const selections = new Map();
116
+ let applying = false;
117
+ const unsubscribe = session.subscribe((event) => {
118
+ if (event.type === "patch" && !applying) selections.delete(event.snapshot.undoDepth);
119
+ if (event.type === "restore") selections.clear();
120
+ });
121
+ const keysFor = (doc, paths) => (paths ?? []).map((path) => { try { return toIdPointer(doc, path); } catch { return path; } });
122
+
123
+ /** Apply a prepared change as one undo step, guarded by the hash of the document it was prepared from. */
124
+ function commitPrepared(prepared, message) {
125
+ if (!prepared?.changed) return result({ ok: true, changed: false, keys: keysFor(document(), prepared?.paths), label: prepared?.label ?? message });
126
+ const before = options.selection?.() ?? [];
127
+ const label = message ?? prepared.label;
128
+ applying = true;
129
+ try {
130
+ session.applyPatch(prepared.patches, { source: "studio", label, command: prepared.command, selection: before, rejectInvalid: true }, { expectHash: documentHash(prepared.base) });
131
+ } catch (error) {
132
+ const reason = error?.code === "patch-hash-mismatch" ? "The document changed while this edit was prepared. Nothing was changed; try again." : error?.message ?? String(error);
133
+ return refused(reason, prepared.command);
134
+ } finally {
135
+ applying = false;
136
+ }
137
+ const doc = document();
138
+ const keys = keysFor(doc, prepared.paths);
139
+ selections.set(session.snapshot().undoDepth, { before, after: keys });
140
+ if (prepared.paths?.[0]) {
141
+ const slide = parsePointer(prepared.paths[0]);
142
+ if (slide[0] === "slides" && /^\d+$/.test(slide[1] ?? "")) slideIndex = Number(slide[1]);
143
+ }
144
+ options.onSelection?.(keys, { source: prepared.command ?? "studio" });
145
+ return result({ ok: true, changed: true, keys, label });
146
+ }
147
+ function result(value) { lastResult = value; return value; }
148
+ function refused(reason, command) {
149
+ options.onRefuse?.({ reason, command });
150
+ return result({ ok: false, changed: false, keys: [], reason });
151
+ }
152
+
153
+ /** Prepare and apply `command` as one undo step. Returns `{ ok, changed, keys, label, reason }`; a refusal changes nothing. */
154
+ function apply(command, ...args) {
155
+ let prepared;
156
+ try {
157
+ prepared = prepare(command, ...args);
158
+ } catch (error) {
159
+ if (error instanceof TypeError && /^Unknown slide command/.test(error.message)) throw error;
160
+ return refused(error?.message ?? String(error), command);
161
+ }
162
+ return commitPrepared(prepared);
163
+ }
164
+
165
+ // A gesture over the kit's gesture history: drafts are prepared changes, shown through `onPreview`; the end applies the last one.
166
+ const gesture = createGesture({
167
+ snapshot: () => document(),
168
+ preview(prepared, message) {
169
+ draft = prepared?.changed ? prepared : null;
170
+ options.onPreview?.({ presentation: draft?.presentation ?? null, message });
171
+ },
172
+ commit(start, { message }) {
173
+ const last = draft;
174
+ draft = null;
175
+ options.onPreview?.({ presentation: null, message });
176
+ if (!last) return false;
177
+ if (last.base !== start) { refused("The document changed during the gesture. Nothing was changed.", last.command); return false; }
178
+ return commitPrepared(last, message).changed;
179
+ },
180
+ restore() {
181
+ draft = null;
182
+ options.onPreview?.({ presentation: null });
183
+ },
184
+ });
185
+
186
+ const historyStep = (direction) => {
187
+ const depth = session.snapshot().undoDepth;
188
+ const change = direction < 0 ? session.undo({ source: "studio" }) : session.redo({ source: "studio" });
189
+ if (!change) return false;
190
+ const entry = selections.get(direction < 0 ? depth : session.snapshot().undoDepth);
191
+ if (entry) options.onSelection?.(direction < 0 ? entry.before : entry.after, { source: direction < 0 ? "undo" : "redo" });
192
+ return true;
193
+ };
194
+
195
+ const slideKeyAt = (doc, index) => toIdPointer(doc, `/slides/${index}`);
196
+ const isSlideKey = (key) => typeof key === "string" && !key.includes("#") && parsePointer(key).length === 2 && parsePointer(key)[0] === "slides";
197
+ const slideOf = (key) => { try { return Number(parsePointer(resolveIdPointer(document(), key))[1]); } catch { return -1; } };
198
+
199
+ /** The kit selection's adapter: slide keys at the top (nothing selected is the deck), then the current slide's tree. */
200
+ function selectionAdapter() {
201
+ return {
202
+ exists: (key) => (isSlideKey(key) ? slideOf(key) >= 0 : Boolean(scene().node(key))),
203
+ parent: (key) => (isSlideKey(key) ? null : scene().node(key)?.parent ?? null),
204
+ firstChild(key) {
205
+ const s = scene();
206
+ if (isSlideKey(key) && slideOf(key) !== slideIndex) return null;
207
+ const first = s.children(isSlideKey(key) ? s.slideKey : key).find((node) => node.kind !== "part");
208
+ return first?.key ?? null;
209
+ },
210
+ sibling(key, delta) {
211
+ if (isSlideKey(key)) { const doc = document(); const at = slideOf(key) + delta; return at >= 0 && at < doc.slides.length ? slideKeyAt(doc, at) : null; }
212
+ const s = scene(), node = s.node(key);
213
+ if (!node?.parent) return null;
214
+ const list = s.children(node.parent).filter((child) => child.kind !== "part");
215
+ return list[list.findIndex((child) => child.key === key) + delta]?.key ?? null;
216
+ },
217
+ first: () => scene().slideKey,
218
+ siblings(key) {
219
+ if (isSlideKey(key)) { const doc = document(); return doc.slides.map((_, at) => slideKeyAt(doc, at)); }
220
+ const s = scene(), node = s.node(key);
221
+ return node?.parent ? s.children(node.parent).filter((child) => child.kind !== "part").map((child) => child.key) : [key];
222
+ },
223
+ };
224
+ }
225
+
226
+ return {
227
+ session,
228
+ get slideIndex() { return clampSlide(); },
229
+ /** Show slide `indexOrKey` (an index or a slide key). Returns false when there is no such slide. */
230
+ setSlide(indexOrKey) {
231
+ const index = typeof indexOrKey === "number" ? indexOrKey : slideOf(indexOrKey);
232
+ if (!Number.isInteger(index) || index < 0 || index >= (document().slides?.length ?? 0)) return false;
233
+ slideIndex = index;
234
+ return true;
235
+ },
236
+ /** The scene of the current slide (of the gesture's draft while one previews). */
237
+ scene,
238
+ /** The slides of the deck, for the filmstrip: `{ key, path, index, label, hidden }`. */
239
+ slides() {
240
+ const doc = document();
241
+ return (doc.slides ?? []).map((slide, index) => ({ key: slideKeyAt(doc, index), path: `/slides/${index}`, index, label: `Slide ${index + 1}${typeof slide.layout === "string" && slide.layout !== "auto" ? ` · ${slide.layout}` : ""}`, hidden: slide.hidden === true }));
242
+ },
243
+ /** The current slide's nodes as the kit's layers and overlay read them. */
244
+ nodes: () => scene().nodes.map(({ key, path, kind, parent, depth, label, findings }) => ({ key, path, kind, parent, depth, label, findings })),
245
+ boxOf: (key) => scene().boxOf(key),
246
+ containerOf: (key) => scene().containerOf(key),
247
+ /** The boxes under a unit point, outermost first (the kit's signature; `skip(box)` leaves boxes out, `box.node` is the scene node). */
248
+ hitChain: (point, skip, hitOptions = {}) => scene().hitChain(point, { ...hitOptions, skip }),
249
+ /** The node a click on `chain` selects (see `SlideScene.target`). */
250
+ target: (chain, targetOptions = {}) => scene().target(chain, { primary: options.selection?.().at(-1) ?? null, ...targetOptions }),
251
+ trialMove,
252
+ /** Where the dragged `keys` would land at `point`, with the trial's answer: `{ target, index, line, into, ok, reason, noop }`, or null. */
253
+ dropAt(point, keys) {
254
+ const found = dropTarget(committedScene(), point, keys);
255
+ if (!found) return null;
256
+ return { ...found, ...trialMove(keys, found.target, found.index) };
257
+ },
258
+ selectionAdapter,
259
+ /** The key of an index pointer in the current document (its id path), or the pointer when it does not resolve. */
260
+ keyOf: (path) => keysFor(document(), [path])[0],
261
+ /** The index pointer a key names now, or null when it is gone (a virtual region key gives its slide's pointer). */
262
+ pathOf(key) {
263
+ try { return resolveIdPointer(document(), key.split("#")[0]); } catch { return null; }
264
+ },
265
+ /** The nearest existing node at or above `key` on the current slide (selection after an edit by someone else). */
266
+ survivor: (key) => scene().survivor(key),
267
+ /** Give every slide and block an id (one undoable change named "Add ids"), so keys survive reorders. Returns whether ids were added. */
268
+ ensureIds() {
269
+ const change = session.assignIds({}, { source: "assign-ids", label: "Add ids" });
270
+ return Boolean(change);
271
+ },
272
+ prepare,
273
+ apply,
274
+ /** `apply(command, ...args).changed`, for the kit's `run`. */
275
+ run: (command, ...args) => apply(command, ...args).changed,
276
+ get lastResult() { return lastResult; },
277
+ /** What the clipboard holds for `keys` (`copyScene`); paste it with `apply("paste", clip, { target, index })`. */
278
+ copy: (keys) => copyScene(document(), committedScene(), keys, { catalogs: catalogs() }),
279
+ begin: (label) => gesture.begin(label),
280
+ /** Show a draft: a change from `prepare` (or null to show the document). Nothing is written until `commit`. */
281
+ preview: (prepared, message) => { gesture.update(prepared, message); },
282
+ commit: (message) => gesture.end(message),
283
+ cancel: () => { gesture.cancel(); },
284
+ get gestureActive() { return gesture.active; },
285
+ undo: () => historyStep(-1),
286
+ redo: () => historyStep(1),
287
+ destroy() { gesture.reset(); draft = null; unsubscribe(); },
288
+ };
289
+ }
@@ -0,0 +1,46 @@
1
+ /** CE-19 (internal): the structural edits of the slide scene, as prepared changes (nothing is applied). */
2
+ import type { Catalog } from "@openpresentation/opf";
3
+ import type { JsonPatchOperation, OPFEditorError } from "./index.js";
4
+ import type { SlideScene } from "./slide-scene.js";
5
+ import type { SlideComposition } from "@openpresentation/opf/composition";
6
+
7
+ export interface PreparedSceneChange {
8
+ presentation: unknown;
9
+ patches: JsonPatchOperation[];
10
+ changed: boolean;
11
+ /** Index pointers to select afterwards. */
12
+ paths: string[];
13
+ label?: string;
14
+ /** A move that changes nothing. */
15
+ noop?: boolean;
16
+ lossless?: boolean;
17
+ loss?: unknown[];
18
+ weights?: number[];
19
+ [key: string]: unknown;
20
+ }
21
+ export interface SceneEditOptions { catalogs?: readonly Catalog[] }
22
+ export type StudioClipboard =
23
+ | { kind: "opf-studio-blocks"; blocks: unknown[]; count: number }
24
+ | { kind: "opf-studio-slides"; presentation: unknown; count: number };
25
+
26
+ export declare function refuse(message: string, details?: Record<string, unknown>): OPFEditorError;
27
+ export declare function prepareSceneMove(presentation: unknown, scene: SlideScene, keys: string[], targetKey: string, index: number, options?: SceneEditOptions): PreparedSceneChange;
28
+ export declare function prepareSceneMoveBy(presentation: unknown, scene: SlideScene, keys: string[], delta: -1 | 1, options?: SceneEditOptions): PreparedSceneChange;
29
+ export declare function prepareSceneMoveInto(presentation: unknown, scene: SlideScene, keys: string[], delta: -1 | 1, options?: SceneEditOptions): PreparedSceneChange;
30
+ export declare function prepareSceneGroup(presentation: unknown, scene: SlideScene, keys: string[], options?: SceneEditOptions & { composition?: Record<string, unknown> }): PreparedSceneChange;
31
+ export declare function prepareSceneUngroup(presentation: unknown, scene: SlideScene, key: string, options?: SceneEditOptions): PreparedSceneChange;
32
+ export declare function prepareSceneDuplicate(presentation: unknown, scene: SlideScene, keys: string[], options?: SceneEditOptions): PreparedSceneChange;
33
+ export declare function prepareSceneDelete(presentation: unknown, scene: SlideScene, keys: string[], options?: SceneEditOptions): PreparedSceneChange;
34
+ export declare function ratioLabel(a: number, b: number): string;
35
+ /**
36
+ * The weights a composed flow's tracks are drawn with (`drawn` when a content-sized column is read as weights from its drawn heights, the
37
+ * last row taking the empty space), and `scale(boundary)`, which turns a fraction of the drawn pair into the fraction of the weighted pair.
38
+ */
39
+ export declare function drawnTracks(flow: SlideComposition["flows"][number]): { weights: number[]; drawn: boolean; scale(boundary: number): number };
40
+ export declare function snapTrackPair(weights: number[], boundary: number, fraction: number, options?: { free?: boolean }): { a: number; b: number };
41
+ export declare function prepareSceneTrackResize(presentation: unknown, scene: SlideScene, key: string, boundary: number, fraction: number, options?: SceneEditOptions & { free?: boolean }): PreparedSceneChange;
42
+ export declare function prepareSceneEqualize(presentation: unknown, scene: SlideScene, key: string, boundary: number, options?: SceneEditOptions): PreparedSceneChange;
43
+ export declare function prepareSceneWeights(presentation: unknown, scene: SlideScene, key: string, weights: number[], options?: SceneEditOptions): PreparedSceneChange;
44
+ export declare function prepareSceneMoveSlides(presentation: unknown, scene: SlideScene, keys: string[], to: number, options?: SceneEditOptions & { section?: string | null }): PreparedSceneChange;
45
+ export declare function copyScene(presentation: unknown, scene: SlideScene, keys: string[], options?: SceneEditOptions): StudioClipboard;
46
+ export declare function prepareScenePaste(presentation: unknown, scene: SlideScene, clip: StudioClipboard, options?: SceneEditOptions & { target?: string; index?: number; slideIndex?: number }): PreparedSceneChange;