@openpresentation/opf-editor 0.28.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 +20 -6
- 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 +134 -124
- package/dist/exact-source.d.ts +30 -0
- package/dist/font-gate.js +20 -1
- package/dist/json-editor.js +8 -2
- package/dist/json-options.d.ts +2 -1
- package/dist/json-options.js +7 -6
- 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 +12 -4
- package/dist/react.js +39 -5
- 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-completions.js +6 -4
- package/dist/source-pane.d.ts +18 -6
- package/dist/source-pane.js +45 -0
- 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/dom.d.ts +16 -0
- package/dist/studio-kit/dom.js +42 -6
- 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 +1546 -117
- 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 +88 -9
- package/dist/workspace.js +210 -49
- package/package.json +29 -18
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@openpresentation/opf-editor/exact-source`: the writer the editor uses to put a changed document back into the JSON text it was read from,
|
|
3
|
+
* keeping the author's bytes. Only the tokens of values that changed are rewritten; indentation, key order, escapes (`"\/"`, `"é"`), line
|
|
4
|
+
* endings and every unrelated value stay byte for byte. A memory of the texts it has seen brings an authored spelling back when a value returns
|
|
5
|
+
* (Escape, Undo, Redo) instead of re-serializing it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Texts longer than this many characters are not remembered (edits still work; a returning value is then written normally). */
|
|
9
|
+
export declare const MAX_EXACT_SOURCE_LENGTH: number;
|
|
10
|
+
|
|
11
|
+
/** The memory of exact source texts, keyed by the parsed value. Give each document (each editor, each file) its own. */
|
|
12
|
+
export interface SourceMemory {
|
|
13
|
+
/** True when the last source given was too long to remember (see `MAX_EXACT_SOURCE_LENGTH`): tell the author a returning value may be respelled. */
|
|
14
|
+
limited: boolean;
|
|
15
|
+
readonly limit: number;
|
|
16
|
+
readonly maxBytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A new memory: at most `limit` texts (default 64) and `maxBytes` characters in total (default 8,000,000), the oldest evicted first. */
|
|
20
|
+
export declare function createSourceMemory(limit?: number, maxBytes?: number): SourceMemory;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Write `document` into `source` (JSON text) and return the new text. Only the affected values change; everything else is kept byte for byte.
|
|
24
|
+
* Throws when `source` is not valid JSON. The result always parses to `document`: when an edit in place cannot achieve that (the source
|
|
25
|
+
* repeats a key, say), the whole document is written as JSON indented like `source`. Without `memory` a memory shared by the module is used.
|
|
26
|
+
*/
|
|
27
|
+
export declare function updateJsonSource(source: string, document: unknown, memory?: SourceMemory): string;
|
|
28
|
+
|
|
29
|
+
/** The first object key that JSON text repeats (which `JSON.parse` silently collapses to the last), as `{ key, path }`; null when there is none. */
|
|
30
|
+
export declare function findDuplicateKey(source: string): { key: string; path: (string | number)[] } | null;
|
package/dist/font-gate.js
CHANGED
|
@@ -30,8 +30,27 @@ const GATE = Symbol("opf-editor.fontGate");
|
|
|
30
30
|
export function fontGate(fonts) {
|
|
31
31
|
if (!fonts || typeof fonts !== "object") return undefined;
|
|
32
32
|
if (fonts[GATE]) return fonts;
|
|
33
|
+
// CE-20: "nothing is pending" for one document object and the same options stays true (faces are never unloaded), so it is remembered:
|
|
34
|
+
// the canvas, the workspace and the thumbnails ask about the same frozen snapshot several times per edit, and on a long deck each
|
|
35
|
+
// answer composes every slide.
|
|
36
|
+
const settled = new WeakMap();
|
|
37
|
+
const sameOptions = (a = {}, b = {}) => {
|
|
38
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
const x = a[key], y = b[key];
|
|
41
|
+
if (x === y) continue;
|
|
42
|
+
if (Array.isArray(x) && Array.isArray(y) && x.length === y.length && x.every((entry, at) => entry === y[at])) continue;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
};
|
|
33
47
|
const pending = (presentation, renderOptions) => {
|
|
34
|
-
|
|
48
|
+
const known = presentation && typeof presentation === "object" ? settled.get(presentation) : undefined;
|
|
49
|
+
if (known?.some((options) => sameOptions(options, renderOptions))) return [];
|
|
50
|
+
let found;
|
|
51
|
+
try { found = fonts.pending?.(presentation, renderOptions) ?? []; } catch { return []; /* the renderer reports the document itself when it renders */ }
|
|
52
|
+
if (!found.length && presentation && typeof presentation === "object" && Object.isFrozen(presentation)) settled.set(presentation, [...(known ?? []), renderOptions ?? {}].slice(-4));
|
|
53
|
+
return found;
|
|
35
54
|
};
|
|
36
55
|
const gate = {
|
|
37
56
|
[GATE]: true,
|
package/dist/json-editor.js
CHANGED
|
@@ -13,6 +13,7 @@ import {textInputMap} from './text-input.js';
|
|
|
13
13
|
import {detectIndent,formatJsonSource,whitespaceEdits} from './json-format.js';
|
|
14
14
|
import { redoPair, undoPair } from "./source-undo-groups.js";
|
|
15
15
|
import {a11yExtension,createAnnouncer} from './source-a11y.js';
|
|
16
|
+
import { checkCodeMirrorCopies } from './codemirror-guard.js';
|
|
16
17
|
import { forEachDiagnostic } from "@codemirror/lint";
|
|
17
18
|
import {createDiagnostics,editsTransaction} from './source-diagnostics.js';
|
|
18
19
|
import { createKeymap, domKeymap, strokeText } from './source-keymap.js';
|
|
@@ -530,6 +531,8 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
530
531
|
const shortcutAttribute = (ids) => ids.flatMap((id) => km.shortcuts().find((row) => row.id === id)?.strokes.slice(0, 1) ?? []).map((stroke) => strokeText(stroke, km.platform).replace(/Cmd/g, "Meta").replace(/Ctrl/g, "Control")).join(" ");
|
|
531
532
|
const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
|
|
532
533
|
"aria-label": options.label,
|
|
534
|
+
// CE-20: the scroller around the text is a scrolling region; its text is focusable (axe scrollable-region-focusable).
|
|
535
|
+
tabindex: "0",
|
|
533
536
|
"aria-invalid": String(Boolean(invalid)),
|
|
534
537
|
"aria-describedby": describedBy ?? "",
|
|
535
538
|
"aria-keyshortcuts": shortcutAttribute(["suggest.trigger", "problem.next", "problem.previous", "problem.quickFix", "format.document", "nav.symbol"]),
|
|
@@ -538,7 +541,7 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
538
541
|
autocapitalize: "off",
|
|
539
542
|
autocorrect: "off"
|
|
540
543
|
});
|
|
541
|
-
const
|
|
544
|
+
const mountExtensions = [
|
|
542
545
|
// The problem menu's keys come before the list's: the two are never open together, and Enter must reach whichever is.
|
|
543
546
|
diagnostics.extension,
|
|
544
547
|
language.of(languageExtension(languageName, completionExtension)),
|
|
@@ -618,7 +621,10 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
618
621
|
if (update.docChanged) options.onDocChange?.();
|
|
619
622
|
if (update.docChanged && update.transactions.some((tr) => tr.isUserEvent("input.type"))) autoHint(update.view);
|
|
620
623
|
})
|
|
621
|
-
]
|
|
624
|
+
];
|
|
625
|
+
// Two copies of @codemirror/view break the tooltips, completions, problems and find without an error: say so once (codemirror-guard.js).
|
|
626
|
+
checkCodeMirrorCopies(mountExtensions, options.onError);
|
|
627
|
+
const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: mountExtensions }) });
|
|
622
628
|
const api = {
|
|
623
629
|
getValue: () => view.state.field(jsonSource),
|
|
624
630
|
getSelection: () => {const map=textInputMap(api.getValue());return [map.toSource(view.state.selection.main.from),map.toSource(view.state.selection.main.to)];},
|
package/dist/json-options.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export interface JsonFieldOption {
|
|
|
9
9
|
/** User-facing provenance; source remains stable for existing consumers. */
|
|
10
10
|
sourceLabel?: string;
|
|
11
11
|
sourceDescription?: string;
|
|
12
|
-
|
|
12
|
+
/** `Current layout`, the layout record's catalog group (for the Gallery: Openers and closers, Text, Groups of items, Numbers, Visuals or Mixed), or `Custom layouts`. */
|
|
13
|
+
layoutGroup?: string;
|
|
13
14
|
/** Region names, accepted kinds and flow from the v2 template. */
|
|
14
15
|
regions?: string;
|
|
15
16
|
}
|
package/dist/json-options.js
CHANGED
|
@@ -2,7 +2,7 @@ import { checkFormat, firstErrorMessage } from "./checks.js";
|
|
|
2
2
|
import { listCatalogRecords } from "./catalogs.js";
|
|
3
3
|
import { resolveSchema, schemaAtPath, schemaVariants } from "./schema.js";
|
|
4
4
|
import { findNodeAtOffset, getNodePath, parse as parseJsonc, parseTree } from "jsonc-parser";
|
|
5
|
-
import {
|
|
5
|
+
import { CUSTOM_LAYOUTS_GROUP, layoutGroupName, layoutGroupOrder, layoutRegionSummary } from "./layout-templates.js";
|
|
6
6
|
import { catalogKindAt } from "@openpresentation/opf/schema";
|
|
7
7
|
const sourceDetails = {
|
|
8
8
|
"Loaded catalog": ["Provided by app", "Records from a catalog this app registered; nothing is fetched from a URL. Saving embeds the record in the document."],
|
|
@@ -10,16 +10,17 @@ const sourceDetails = {
|
|
|
10
10
|
"Current value": ["Custom value", "The current value is not in the available catalogs."],
|
|
11
11
|
"Schema": ["OPF option", "Allowed by the OPF schema."],
|
|
12
12
|
};
|
|
13
|
-
const layoutGroups = ["Current layout", ...LAYOUT_INTENT_GROUPS.map(group => group.name), "Custom layouts"];
|
|
14
13
|
function describeOptions(choices, records, current, catalog) {
|
|
14
|
+
// A layout's group is its catalog record's (see layoutGroupName); the groups follow the catalog's own order.
|
|
15
|
+
const layoutGroups = catalog === "layouts" ? ["Current layout", ...layoutGroupOrder([...records.values()]), CUSTOM_LAYOUTS_GROUP] : [];
|
|
15
16
|
for (const option of choices) {
|
|
16
17
|
const [label, description] = sourceDetails[option.source];
|
|
17
18
|
option.sourceLabel = label;
|
|
18
19
|
option.sourceDescription = description;
|
|
19
20
|
if (catalog !== "layouts") continue;
|
|
20
|
-
const
|
|
21
|
-
option.regions = layoutRegionSummary(
|
|
22
|
-
option.layoutGroup = option.value === current ? "Current layout" :
|
|
21
|
+
const entry = records.get(String(option.value));
|
|
22
|
+
option.regions = layoutRegionSummary(entry?.record);
|
|
23
|
+
option.layoutGroup = option.value === current ? "Current layout" : entry ? layoutGroupName(entry) : CUSTOM_LAYOUTS_GROUP;
|
|
23
24
|
}
|
|
24
25
|
const priority = option => option.source === "Document catalog" ? 2 : option.source === "Loaded catalog" ? 1 : 0;
|
|
25
26
|
return choices.sort((a, b) => (catalog === "layouts" ? layoutGroups.indexOf(a.layoutGroup) - layoutGroups.indexOf(b.layoutGroup) : priority(b) - priority(a)) || a.label.localeCompare(b.label, "en", {numeric: true}) || String(a.value).localeCompare(String(b.value), "en"));
|
|
@@ -42,7 +43,7 @@ function fieldOptions(presentation, path, loadedCatalogs, { string, boolean }) {
|
|
|
42
43
|
const attempt = (document) => { try { return listCatalogRecords(document, catalog, { catalogs: hostCatalogs }); } catch { return undefined; } };
|
|
43
44
|
const available = attempt(presentation) ?? attempt({ ...presentation, catalogs: undefined }) ?? [];
|
|
44
45
|
for (const option of available) {
|
|
45
|
-
records.set(option.id, option
|
|
46
|
+
records.set(option.id, option);
|
|
46
47
|
options.set(option.id, { value: option.id, label: option.label, description: option.description,
|
|
47
48
|
source: option.origin === "document" ? "Document catalog" : "Loaded catalog" });
|
|
48
49
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { EditorSession, EditorChange, JsonPatchOperation } from "./index.js";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
export interface
|
|
5
|
-
|
|
3
|
+
/** A layout a picker offers. `group` is the catalog group of a `listCatalogRecords` entry (`custom` is the deck's own layout); `origin` is `document` for a record the deck embeds. */
|
|
4
|
+
export interface LayoutChoice { id: string; label: string; record?: Record<string, unknown>; group?: string; origin?: string }
|
|
5
|
+
/** The group of layouts the catalogs do not group: a deck's or host's own layout, or a record with no tags. */
|
|
6
|
+
export declare const CUSTOM_LAYOUTS_GROUP: "Custom layouts";
|
|
7
|
+
/** The picker group of a layout: the first tag of its catalog record (the Gallery's index group), else `CUSTOM_LAYOUTS_GROUP`. */
|
|
8
|
+
export declare function layoutGroupName(choice: LayoutChoice): string;
|
|
9
|
+
/** The catalog's groups in picker order (the catalog's own layout order), without the custom group. */
|
|
10
|
+
export declare function layoutGroupOrder(choices: readonly LayoutChoice[]): string[];
|
|
6
11
|
export declare const LAYOUT_REGION_KINDS: readonly string[];
|
|
7
12
|
export declare const LAYOUT_REGION_ROLES: readonly string[];
|
|
8
13
|
export declare const LAYOUT_REGION_FLOWS: readonly string[];
|
package/dist/layout-templates.js
CHANGED
|
@@ -5,14 +5,32 @@ import { applyJsonPatch, createValuePatch, opfPathToJsonPointer } from "./index.
|
|
|
5
5
|
import { checkedDocument, fail } from "./edit-helpers.js";
|
|
6
6
|
import { checkFormat } from "./checks.js";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
/** The group of a layout the catalogs do not group: a deck's or host's own layout, or a catalog record with no group. */
|
|
9
|
+
export const CUSTOM_LAYOUTS_GROUP = "Custom layouts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The picker group of one layout choice (`listCatalogRecords` entry, or a host's `{ id, label, record?, group? }`). A catalog
|
|
13
|
+
* record carries its group as its first tag (the Gallery publishes the same group in its layout index, and its tests keep the
|
|
14
|
+
* two equal), so a layout added to the catalog is grouped with no change here. A record with no tag, a choice with no record and a
|
|
15
|
+
* deck's own layout (catalog group `custom`) are `CUSTOM_LAYOUTS_GROUP`.
|
|
16
|
+
*/
|
|
17
|
+
export function layoutGroupName(choice) {
|
|
18
|
+
if (choice?.group === "custom") return CUSTOM_LAYOUTS_GROUP;
|
|
19
|
+
const tag = Array.isArray(choice?.record?.tags) ? choice.record.tags[0] : undefined;
|
|
20
|
+
return typeof tag === "string" && tag.trim() ? tag.trim() : CUSTOM_LAYOUTS_GROUP;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The catalog's groups in picker order, without the custom group: the order the catalog lists its layouts in (the Gallery lists
|
|
25
|
+
* them in its index order), so the groups follow the catalog. Records a deck embeds come after the registered catalogs' own.
|
|
26
|
+
*/
|
|
27
|
+
export function layoutGroupOrder(choices) {
|
|
28
|
+
const names = [];
|
|
29
|
+
const pass = (list) => { for (const choice of list) { const name = layoutGroupName(choice); if (name !== CUSTOM_LAYOUTS_GROUP && !names.includes(name)) names.push(name); } };
|
|
30
|
+
pass(choices.filter((choice) => choice.origin !== "document"));
|
|
31
|
+
pass(choices.filter((choice) => choice.origin === "document"));
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
16
34
|
|
|
17
35
|
export const LAYOUT_REGION_KINDS = Object.freeze(["text", "list", "image", "video", "chart", "table", "code", "metric", "quote", "timeline", "group"]);
|
|
18
36
|
export const LAYOUT_REGION_ROLES = Object.freeze(["primary", "secondary", "media", "supporting"]);
|
|
@@ -20,15 +38,11 @@ export const LAYOUT_REGION_FLOWS = Object.freeze(["none", "grid", "column", "aut
|
|
|
20
38
|
const RESERVED = new Set(["title", "subtitle", "tag", "auto", "left", "center", "right", "top", "middle", "bottom"]);
|
|
21
39
|
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
22
40
|
|
|
23
|
-
/** Group
|
|
41
|
+
/** Group layouts by their catalog group (see `layoutGroupName`), in catalog order, then the custom ones. Choices keep their order within a group. */
|
|
24
42
|
export function groupLayoutChoices(choices) {
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
name, choices: ids.map((id) => byId.get(id)).filter((choice) => { if (!choice || used.has(choice.id)) return false; used.add(choice.id); return true; }),
|
|
29
|
-
})).filter((group) => group.choices.length);
|
|
30
|
-
const custom = choices.filter((choice) => !used.has(choice.id));
|
|
31
|
-
if (custom.length) groups.push({ name: "Custom layouts", choices: custom });
|
|
43
|
+
const groups = layoutGroupOrder(choices).map((name) => ({ name, choices: choices.filter((choice) => layoutGroupName(choice) === name) }));
|
|
44
|
+
const custom = choices.filter((choice) => layoutGroupName(choice) === CUSTOM_LAYOUTS_GROUP);
|
|
45
|
+
if (custom.length) groups.push({ name: CUSTOM_LAYOUTS_GROUP, choices: custom });
|
|
32
46
|
return groups;
|
|
33
47
|
}
|
|
34
48
|
|
package/dist/numbering-panel.js
CHANGED
|
@@ -159,7 +159,7 @@ export function createNumberingPanel(container, options) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
if (enabled && state.entry) {
|
|
162
|
-
const start = h(doc, "input", { id: `${id}-entry-start`, type: "number", min: 1, max: MAX_NUMBERING_START, step: 1, value: state.entry.start ?? "", placeholder: state.entry.marker ?? "" });
|
|
162
|
+
const start = h(doc, "input", { id: `${id}-entry-start`, type: "number", min: 1, max: MAX_NUMBERING_START, step: 1, value: state.entry.start ?? "", placeholder: state.entry.marker ?? "", title: "Empty continues the count" });
|
|
163
163
|
const apply = () => {
|
|
164
164
|
const text = start.value.trim();
|
|
165
165
|
try {
|
|
@@ -172,8 +172,7 @@ export function createNumberingPanel(container, options) {
|
|
|
172
172
|
};
|
|
173
173
|
start.addEventListener("change", apply);
|
|
174
174
|
body.append(h(doc, "div", { class: "opf-numbering-entry" },
|
|
175
|
-
h(doc, "label", { for: start.id, text: `Entry ${state.entry.index + 1} starts at` }), start
|
|
176
|
-
h(doc, "span", { class: "opf-numbering-help", text: "Leave empty to continue the count. Later entries carry on from it." })));
|
|
175
|
+
h(doc, "label", { for: start.id, text: `Entry ${state.entry.index + 1} starts at` }), start));
|
|
177
176
|
}
|
|
178
177
|
}
|
|
179
178
|
|
package/dist/react.d.ts
CHANGED
|
@@ -32,9 +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";
|
|
35
|
+
import type { CSSProperties, ForwardRefExoticComponent, MutableRefObject, ReactNode, RefAttributes } from "react";
|
|
36
36
|
import type { SourceCursor } from "./source-pane.js";
|
|
37
|
-
import type { OpfEditor as OpfEditorHandle, OpfEditorOptions, OpfEditorSlots } from "./workspace.js";
|
|
37
|
+
import type { OpfEditor as OpfEditorHandle, OpfEditorOptions, OpfEditorSlots, OpfCommandInfo, OpfStudioHostPanel, OpfStudioSelectionItem } from "./workspace.js";
|
|
38
38
|
|
|
39
39
|
/** A slot is an element, or a React ref to one (resolved when the editor mounts). */
|
|
40
40
|
export type OpfEditorReactSlots = { [Name in keyof OpfEditorSlots]?: OpfEditorSlots[Name] | { current: OpfEditorSlots[Name] | null } };
|
|
@@ -46,6 +46,11 @@ export interface OpfEditorProps extends Omit<OpfEditorOptions, "slots"> {
|
|
|
46
46
|
id?: string;
|
|
47
47
|
/** Called with the editor right after it is created, and with `null` when it is destroyed. For the hooks below. */
|
|
48
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 })[];
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
@@ -61,8 +66,11 @@ export declare function useOpfEditor(options?: OpfEditorProps): { ref: MutableRe
|
|
|
61
66
|
/** The document of an editor, replaced on every change; `undefined` while `editor` is `null`. Frozen: do not modify it. */
|
|
62
67
|
export declare function useOpfDocument(editor: OpfEditorHandle | null | undefined): unknown;
|
|
63
68
|
|
|
64
|
-
/** The content last selected on the canvas, or `null`. */
|
|
65
|
-
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 };
|
|
66
74
|
|
|
67
75
|
/** The path under the caret of the source pane, the latest `cursor` event (`pointer`, `idPath`, `from`, `to`, ...), or `null` before the first. */
|
|
68
76
|
export declare function useOpfCursor(editor: OpfEditorHandle | null | undefined): SourceCursor | null;
|
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", "onCursor", "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 = {};
|
|
@@ -130,7 +131,7 @@ function splitProps(props) {
|
|
|
130
131
|
* `key` of the component to mount another); `theme`, `readOnly`, `view`, `density` and `exportMode` follow later renders. The callbacks
|
|
131
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,8 +146,11 @@ 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),
|
|
@@ -188,10 +192,22 @@ function useMountedEditor(container, props, onEditor) {
|
|
|
188
192
|
*/
|
|
189
193
|
export const OpfEditor = forwardRef(function OpfEditor(props, ref) {
|
|
190
194
|
const container = useRef(null);
|
|
191
|
-
|
|
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);
|
|
192
207
|
useImperativeHandle(ref, () => editor, [editor]);
|
|
193
208
|
const { rest } = splitProps(props);
|
|
194
|
-
|
|
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);
|
|
195
211
|
});
|
|
196
212
|
|
|
197
213
|
/**
|
|
@@ -218,6 +234,24 @@ export function useOpfSelection(editor) {
|
|
|
218
234
|
return useSyncExternalStore(subscribe, () => (editor ? editor.selection : null), () => null);
|
|
219
235
|
}
|
|
220
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
|
+
|
|
221
255
|
/** The path under the caret of the source pane (the latest `cursor` event: `{ pointer, idPath, from, to, ... }`), or `null` before the first. */
|
|
222
256
|
export function useOpfCursor(editor) {
|
|
223
257
|
const subscribe = useCallback(subscribeTo(editor, "cursor"), [editor]);
|
|
@@ -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;
|