@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
package/dist/react.d.ts CHANGED
@@ -32,8 +32,9 @@ export declare function createOPFReactComponents(React: {
32
32
  };
33
33
 
34
34
  // ---- CE-06: the embeddable editor ---------------------------------------------------------------------------------------------------------
35
- import type { CSSProperties, ForwardRefExoticComponent, MutableRefObject, RefAttributes } from "react";
36
- import type { OpfEditor as OpfEditorHandle, OpfEditorOptions, OpfEditorSlots } from "./workspace.js";
35
+ import type { CSSProperties, ForwardRefExoticComponent, MutableRefObject, ReactNode, RefAttributes } from "react";
36
+ import type { SourceCursor } from "./source-pane.js";
37
+ import type { OpfEditor as OpfEditorHandle, OpfEditorOptions, OpfEditorSlots, OpfCommandInfo, OpfStudioHostPanel, OpfStudioSelectionItem } from "./workspace.js";
37
38
 
38
39
  /** A slot is an element, or a React ref to one (resolved when the editor mounts). */
39
40
  export type OpfEditorReactSlots = { [Name in keyof OpfEditorSlots]?: OpfEditorSlots[Name] | { current: OpfEditorSlots[Name] | null } };
@@ -45,6 +46,11 @@ export interface OpfEditorProps extends Omit<OpfEditorOptions, "slots"> {
45
46
  id?: string;
46
47
  /** Called with the editor right after it is created, and with `null` when it is destroyed. For the hooks below. */
47
48
  onEditor?: (editor: OpfEditorHandle | null) => void;
49
+ /**
50
+ * CE-20, the Studio: the host's panels (`layout: "studio"`). Each `render()` is mounted by React through a portal into the panel the Studio
51
+ * makes for it (a tab of the right panel, or a section of the left panel with `side: "left"`), so it shares the host's context and state.
52
+ */
53
+ hostPanels?: (Omit<OpfStudioHostPanel, "mount"> & { render(): ReactNode })[];
48
54
  }
49
55
 
50
56
  /**
@@ -60,7 +66,13 @@ export declare function useOpfEditor(options?: OpfEditorProps): { ref: MutableRe
60
66
  /** The document of an editor, replaced on every change; `undefined` while `editor` is `null`. Frozen: do not modify it. */
61
67
  export declare function useOpfDocument(editor: OpfEditorHandle | null | undefined): unknown;
62
68
 
63
- /** The content last selected on the canvas, or `null`. */
64
- export declare function useOpfSelection(editor: OpfEditorHandle | null | undefined): { path: string; value: unknown } | null;
69
+ /** The content last selected on the canvas, or `null`; in the Studio, the selection list `[{ key, path, idPath, kind }]`. */
70
+ export declare function useOpfSelection(editor: OpfEditorHandle | null | undefined): { path: string; value: unknown } | OpfStudioSelectionItem[] | null;
71
+
72
+ /** CE-20: the Studio's commands for a host palette (pptx.dev's Cmd+K): the list with each command's state, and `run(id)`. */
73
+ export declare function useOpfCommands(editor: OpfEditorHandle | null | undefined): { commands: OpfCommandInfo[]; run(id: string): boolean };
74
+
75
+ /** The path under the caret of the source pane, the latest `cursor` event (`pointer`, `idPath`, `from`, `to`, ...), or `null` before the first. */
76
+ export declare function useOpfCursor(editor: OpfEditorHandle | null | undefined): SourceCursor | null;
65
77
 
66
78
  export type { OpfEditorHandle };
package/dist/react.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, useSyncExternalStore } from "react";
1
+ import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { commitCatalogControl } from "./catalog-control.js";
3
4
  import {
4
5
  getCatalogOptions,
@@ -116,7 +117,7 @@ function resolveSlots(slots) {
116
117
  }
117
118
 
118
119
  // The props that are not editor options.
119
- const COMPONENT_PROPS = new Set(["className", "style", "id", "onEditor", "children", "slots", "onChange", "onSelection", "onError", "onReady"]);
120
+ const COMPONENT_PROPS = new Set(["className", "style", "id", "onEditor", "children", "slots", "onChange", "onSelection", "onCursor", "onError", "onReady", "hostPanels"]);
120
121
 
121
122
  function splitProps(props) {
122
123
  const options = {};
@@ -128,9 +129,9 @@ function splitProps(props) {
128
129
  /**
129
130
  * Create and destroy one editor in the element `container.current` points at. The options are read once, when the editor mounts (change the
130
131
  * `key` of the component to mount another); `theme`, `readOnly`, `view`, `density` and `exportMode` follow later renders. The callbacks
131
- * (`onChange`, `onSelection`, `onError`, `onReady`) always call the latest function passed. Returns the editor once it is mounted, else `null`.
132
+ * (`onChange`, `onSelection`, `onCursor`, `onError`, `onReady`) always call the latest function passed. Returns the editor once it is mounted, else `null`.
132
133
  */
133
- function useMountedEditor(container, props, onEditor) {
134
+ function useMountedEditor(container, props, onEditor, registerPanel) {
134
135
  const [editor, setEditor] = useState(null);
135
136
  const latest = useRef(props);
136
137
  latest.current = props;
@@ -145,11 +146,15 @@ function useMountedEditor(container, props, onEditor) {
145
146
  if (cancelled) return;
146
147
  const { options } = splitProps(latest.current);
147
148
  const current = () => latest.current;
149
+ // CE-20: host panels with `render` are mounted by React through portals into the Studio's panel elements.
150
+ const hostPanels = (latest.current.hostPanels ?? []).map(({ render: _render, ...panel }) => ({ ...panel, mount: (panelElement) => registerPanel?.(panel.id, panelElement) }));
148
151
  created = createOpfEditor(element, {
149
152
  ...options,
153
+ ...(hostPanels.length ? { studio: { ...options.studio, hostPanels: [...(options.studio?.hostPanels ?? []), ...hostPanels] } } : {}),
150
154
  slots: resolveSlots(latest.current.slots),
151
155
  onChange: (detail) => current().onChange?.(detail),
152
156
  onSelection: (detail) => current().onSelection?.(detail),
157
+ onCursor: (detail) => current().onCursor?.(detail),
153
158
  onError: (error, detail) => current().onError?.(error, detail),
154
159
  onReady: (detail) => current().onReady?.(detail),
155
160
  });
@@ -187,10 +192,22 @@ function useMountedEditor(container, props, onEditor) {
187
192
  */
188
193
  export const OpfEditor = forwardRef(function OpfEditor(props, ref) {
189
194
  const container = useRef(null);
190
- const editor = useMountedEditor(container, props, props.onEditor);
195
+ // CE-20: the elements of the Studio's host panels, by id, once the editor mounted them; each `render` goes there through a portal.
196
+ const [panelHosts, setPanelHosts] = useState({});
197
+ const registerPanel = useCallback((id, panelElement) => {
198
+ setPanelHosts((previous) => ({ ...previous, [id]: panelElement }));
199
+ return () => setPanelHosts((previous) => {
200
+ if (previous[id] !== panelElement) return previous;
201
+ const next = { ...previous };
202
+ delete next[id];
203
+ return next;
204
+ });
205
+ }, []);
206
+ const editor = useMountedEditor(container, props, props.onEditor, registerPanel);
191
207
  useImperativeHandle(ref, () => editor, [editor]);
192
208
  const { rest } = splitProps(props);
193
- return createElement("div", { ref: container, id: rest.id, className: rest.className, style: rest.style, "data-opf-react": "" });
209
+ const portals = (props.hostPanels ?? []).map((panel) => (panelHosts[panel.id] && typeof panel.render === "function" ? createPortal(panel.render(), panelHosts[panel.id], panel.id) : null));
210
+ return createElement("div", { ref: container, id: rest.id, className: rest.className, style: rest.style, "data-opf-react": "" }, ...portals);
194
211
  });
195
212
 
196
213
  /**
@@ -216,3 +233,27 @@ export function useOpfSelection(editor) {
216
233
  const subscribe = useCallback(subscribeTo(editor, "selection"), [editor]);
217
234
  return useSyncExternalStore(subscribe, () => (editor ? editor.selection : null), () => null);
218
235
  }
236
+
237
+ /**
238
+ * CE-20: the Studio's commands for a host palette: `{ commands, run }`, where `commands` is `editor.commands.list()` (id, label, group, keys,
239
+ * shortcut, enabled, reason), read again after every selection, document, layout and command change, and `run(id)` runs one.
240
+ */
241
+ export function useOpfCommands(editor) {
242
+ const [version, setVersion] = useState(0);
243
+ useEffect(() => {
244
+ if (!editor) return undefined;
245
+ const bump = () => setVersion((value) => value + 1);
246
+ const stops = ["selection", "change", "layout", "command", "source", "slide"].map((name) => editor.on(name, bump));
247
+ return () => { for (const stop of stops) stop(); };
248
+ }, [editor]);
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ const commands = useMemo(() => (editor ? editor.commands.list() : []), [editor, version]);
251
+ const run = useCallback((id) => (editor ? editor.commands.run(id) : false), [editor]);
252
+ return { commands, run };
253
+ }
254
+
255
+ /** The path under the caret of the source pane (the latest `cursor` event: `{ pointer, idPath, from, to, ... }`), or `null` before the first. */
256
+ export function useOpfCursor(editor) {
257
+ const subscribe = useCallback(subscribeTo(editor, "cursor"), [editor]);
258
+ return useSyncExternalStore(subscribe, () => (editor ? editor.sourceCursor : null), () => null);
259
+ }
@@ -0,0 +1,49 @@
1
+ /** CE-19 (internal; no package subpath exports it yet): the studio kit's document adapter for the slides of a deck, over an editor session. */
2
+ import type { Catalog } from "@openpresentation/opf";
3
+ import type { SlideComposition } from "@openpresentation/opf/composition";
4
+ import type { EditorSession } from "./index.js";
5
+ import type { StudioDocumentAdapter, StudioNode } from "./studio-kit/index.js";
6
+ import type { KitSelectionAdapter } from "./studio-kit/selection.js";
7
+ import type { PreparedSceneChange, StudioClipboard } from "./slide-edits.js";
8
+ import type { SceneBox, SceneContainer, SceneDrop, SceneHit, SlideScene } from "./slide-scene.js";
9
+
10
+ export type SlideCommand = "move" | "moveBy" | "moveInto" | "group" | "ungroup" | "duplicate" | "delete" | "resizeTracks" | "equalize" | "setWeights" | "paste" | "moveSlides";
11
+ export declare const SLIDE_COMMANDS: readonly SlideCommand[];
12
+ export interface SlideCommandResult { ok: boolean; changed: boolean; keys: string[]; label?: string; reason?: string }
13
+ export interface SlideAdapterOptions {
14
+ slideIndex?: number;
15
+ /** The `SlideComposition` of a slide as it is drawn; default core composition with the session's catalogs. */
16
+ geometry?(presentation: unknown, slideIndex: number): SlideComposition;
17
+ /** The keys selected now, recorded with each change so undo and redo restore them. */
18
+ selection?(): string[];
19
+ onPreview?(event: { presentation: unknown | null; message?: string }): void;
20
+ onSelection?(keys: string[], info: { source: string }): void;
21
+ onRefuse?(event: { reason: string; command?: string }): void;
22
+ }
23
+ export interface SlideAdapter extends Omit<StudioDocumentAdapter<string, PreparedSceneChange>, "nodes" | "boxOf" | "containerOf" | "hitChain"> {
24
+ readonly session: EditorSession;
25
+ readonly slideIndex: number;
26
+ setSlide(indexOrKey: number | string): boolean;
27
+ scene(): SlideScene;
28
+ slides(): { key: string; path: string; index: number; label: string; hidden: boolean }[];
29
+ nodes(): StudioNode<string>[];
30
+ boxOf(key: string): (SceneBox & { depth: number }) | undefined;
31
+ containerOf(key: string): SceneContainer | undefined;
32
+ hitChain(point: { x: number; y: number }, skip?: (hit: SceneHit) => boolean, options?: { parts?: boolean }): SceneHit[];
33
+ target(chain: SceneHit[], options?: { primary?: string | null; shift?: boolean; deep?: boolean }): SceneHit | null;
34
+ dropAt(point: { x: number; y: number }, keys: string[]): (SceneDrop & { ok: boolean; reason: string; noop: boolean }) | null;
35
+ selectionAdapter(): KitSelectionAdapter<string>;
36
+ keyOf(path: string): string;
37
+ pathOf(key: string): string | null;
38
+ survivor(key: string): string | null;
39
+ ensureIds(): boolean;
40
+ prepare(command: SlideCommand, ...args: unknown[]): PreparedSceneChange & { base: unknown; command: SlideCommand };
41
+ apply(command: SlideCommand, ...args: unknown[]): SlideCommandResult;
42
+ run(command: SlideCommand, ...args: unknown[]): boolean;
43
+ readonly lastResult: SlideCommandResult | null;
44
+ copy(keys: string[]): StudioClipboard;
45
+ readonly gestureActive: boolean;
46
+ destroy(): void;
47
+ }
48
+ export declare function composeSlideGeometry(presentation: unknown, slideIndex: number, options?: { catalogs?: readonly Catalog[]; fonts?: unknown }): SlideComposition;
49
+ export declare function createSlideAdapter(session: EditorSession, options?: SlideAdapterOptions): SlideAdapter;
@@ -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;