@openpresentation/opf-editor 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +20 -6
  2. package/dist/canvas.d.ts +13 -1
  3. package/dist/canvas.js +39 -9
  4. package/dist/chart-options-panel.js +16 -16
  5. package/dist/codemirror-guard.js +69 -0
  6. package/dist/content-controls.js +127 -74
  7. package/dist/data-grid.js +15 -7
  8. package/dist/design-controls.d.ts +13 -0
  9. package/dist/design-controls.js +134 -124
  10. package/dist/exact-source.d.ts +30 -0
  11. package/dist/font-gate.js +20 -1
  12. package/dist/json-editor.js +8 -2
  13. package/dist/json-options.d.ts +2 -1
  14. package/dist/json-options.js +7 -6
  15. package/dist/layout-templates.d.ts +8 -3
  16. package/dist/layout-templates.js +30 -16
  17. package/dist/numbering-panel.js +2 -3
  18. package/dist/react.d.ts +12 -4
  19. package/dist/react.js +39 -5
  20. package/dist/slide-adapter.d.ts +49 -0
  21. package/dist/slide-adapter.js +289 -0
  22. package/dist/slide-edits.d.ts +46 -0
  23. package/dist/slide-edits.js +629 -0
  24. package/dist/slide-manager.js +2 -1
  25. package/dist/slide-scene.d.ts +96 -0
  26. package/dist/slide-scene.js +413 -0
  27. package/dist/source-checker.js +45 -6
  28. package/dist/source-completions.js +6 -4
  29. package/dist/source-pane.d.ts +18 -6
  30. package/dist/source-pane.js +45 -0
  31. package/dist/source-worker.js +8 -4
  32. package/dist/studio-canvas.d.ts +34 -0
  33. package/dist/studio-canvas.js +586 -0
  34. package/dist/studio-commands.d.ts +15 -0
  35. package/dist/studio-commands.js +155 -0
  36. package/dist/studio-design.d.ts +16 -0
  37. package/dist/studio-design.js +354 -0
  38. package/dist/studio-kit/dom.d.ts +16 -0
  39. package/dist/studio-kit/dom.js +42 -6
  40. package/dist/studio-layers.d.ts +12 -0
  41. package/dist/studio-layers.js +218 -0
  42. package/dist/studio.d.ts +31 -0
  43. package/dist/studio.js +1608 -0
  44. package/dist/styles.css +1546 -117
  45. package/dist/thumbnail-cache.d.ts +2 -0
  46. package/dist/thumbnail-cache.js +10 -1
  47. package/dist/workspace-measure.js +6 -4
  48. package/dist/workspace-parts.js +20 -0
  49. package/dist/workspace-shell.js +25 -15
  50. package/dist/workspace-theme.js +3 -0
  51. package/dist/workspace.d.ts +97 -9
  52. package/dist/workspace.js +325 -59
  53. package/package.json +29 -18
@@ -0,0 +1,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
- try { return fonts.pending?.(presentation, renderOptions) ?? []; } catch { return []; /* the renderer reports the document itself when it renders */ }
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,
@@ -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 view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
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)];},
@@ -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
- layoutGroup?: 'Current layout' | 'Openers and closers' | 'Text' | 'Groups of items' | 'Numbers' | 'Visuals' | 'Mixed' | 'Custom layouts';
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
  }
@@ -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 { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
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 id = String(option.value).replace(/^default:/, "");
21
- option.regions = layoutRegionSummary(records.get(String(option.value)));
22
- option.layoutGroup = option.value === current ? "Current layout" : LAYOUT_INTENT_GROUPS.find(group => group.ids.includes(id))?.name ?? "Custom layouts";
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.record);
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
- export interface LayoutChoice { id: string; label: string; record?: Record<string, unknown> }
4
- export interface LayoutIntentGroup { name: string; ids: readonly string[] }
5
- export declare const LAYOUT_INTENT_GROUPS: readonly LayoutIntentGroup[];
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[];
@@ -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
- export const LAYOUT_INTENT_GROUPS = Object.freeze([
9
- { name: "Openers and closers", ids: ["cover", "section", "agenda", "statement", "closing"] },
10
- { name: "Text", ids: ["text", "list", "quote", "two-column"] },
11
- { name: "Groups of items", ids: ["pillars", "comparison", "process", "timeline", "faq"] },
12
- { name: "Numbers", ids: ["metrics", "scorecard"] },
13
- { name: "Visuals", ids: ["image", "image-beside", "gallery", "chart", "chart-beside", "table", "table-beside", "code"] },
14
- { name: "Mixed", ids: ["hero", "dashboard", "team", "logos"] },
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 registered layouts by the 28 built-in intents, then any deck or host layouts. */
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 byId = new Map(choices.map((choice) => [choice.id, choice]));
26
- const used = new Set();
27
- const groups = LAYOUT_INTENT_GROUPS.map(({ name, ids }) => ({
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
 
@@ -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
- 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);
192
207
  useImperativeHandle(ref, () => editor, [editor]);
193
208
  const { rest } = splitProps(props);
194
- 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);
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;