@openpresentation/opf-editor 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -9
- package/dist/canvas.d.ts +13 -1
- package/dist/canvas.js +39 -9
- package/dist/chart-options-panel.js +16 -16
- package/dist/codemirror-guard.js +69 -0
- package/dist/content-controls.js +127 -74
- package/dist/data-grid.js +15 -7
- package/dist/design-controls.d.ts +13 -0
- package/dist/design-controls.js +136 -125
- package/dist/design-options.js +1 -1
- package/dist/exact-source.d.ts +30 -0
- package/dist/font-gate.js +20 -1
- package/dist/json-editor.d.ts +33 -0
- package/dist/json-editor.js +91 -4
- package/dist/json-options.d.ts +2 -1
- package/dist/json-options.js +7 -6
- package/dist/layout-adapter.js +96 -0
- package/dist/layout-canvas.js +122 -232
- package/dist/layout-editor-ui.js +46 -185
- package/dist/layout-inspector.js +36 -99
- package/dist/layout-layers.js +75 -92
- package/dist/layout-model.js +62 -103
- package/dist/layout-templates.d.ts +8 -3
- package/dist/layout-templates.js +30 -16
- package/dist/numbering-panel.js +2 -3
- package/dist/react.d.ts +16 -4
- package/dist/react.js +47 -6
- package/dist/slide-adapter.d.ts +49 -0
- package/dist/slide-adapter.js +289 -0
- package/dist/slide-edits.d.ts +46 -0
- package/dist/slide-edits.js +629 -0
- package/dist/slide-manager.js +2 -1
- package/dist/slide-scene.d.ts +96 -0
- package/dist/slide-scene.js +413 -0
- package/dist/source-checker.js +45 -6
- package/dist/source-color-scheme.js +60 -0
- package/dist/source-completions.js +6 -4
- package/dist/source-pane.d.ts +103 -6
- package/dist/source-pane.js +196 -9
- package/dist/source-worker.js +8 -4
- package/dist/studio-canvas.d.ts +34 -0
- package/dist/studio-canvas.js +586 -0
- package/dist/studio-commands.d.ts +15 -0
- package/dist/studio-commands.js +155 -0
- package/dist/studio-design.d.ts +16 -0
- package/dist/studio-design.js +354 -0
- package/dist/studio-kit/commands.d.ts +38 -0
- package/dist/studio-kit/commands.js +148 -0
- package/dist/studio-kit/dom.d.ts +52 -0
- package/dist/studio-kit/dom.js +198 -0
- package/dist/studio-kit/drag.d.ts +26 -0
- package/dist/studio-kit/drag.js +127 -0
- package/dist/studio-kit/gesture.d.ts +29 -0
- package/dist/studio-kit/gesture.js +87 -0
- package/dist/studio-kit/index.d.ts +29 -0
- package/dist/studio-kit/index.js +12 -0
- package/dist/studio-kit/inspector.d.ts +22 -0
- package/dist/studio-kit/inspector.js +119 -0
- package/dist/studio-kit/overlay.d.ts +29 -0
- package/dist/studio-kit/overlay.js +182 -0
- package/dist/studio-kit/selection.d.ts +30 -0
- package/dist/studio-kit/selection.js +106 -0
- package/dist/studio-kit/tree.d.ts +9 -0
- package/dist/studio-kit/tree.js +82 -0
- package/dist/studio-kit/viewport.d.ts +29 -0
- package/dist/studio-kit/viewport.js +153 -0
- package/dist/studio-layers.d.ts +12 -0
- package/dist/studio-layers.js +218 -0
- package/dist/studio.d.ts +31 -0
- package/dist/studio.js +1608 -0
- package/dist/styles.css +1573 -123
- package/dist/thumbnail-cache.d.ts +2 -0
- package/dist/thumbnail-cache.js +10 -1
- package/dist/workspace-measure.js +6 -4
- package/dist/workspace-parts.js +20 -0
- package/dist/workspace-shell.js +25 -15
- package/dist/workspace-theme.js +3 -0
- package/dist/workspace.d.ts +114 -10
- package/dist/workspace.js +223 -49
- package/package.json +30 -19
package/dist/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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|