@openpresentation/opf-editor 0.25.0 → 0.25.2

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 CHANGED
@@ -40,7 +40,8 @@ The release after 0.14.2 moves the editor onto core 0.15 (a breaking, clean spec
40
40
  - **Save and export embed.** `prepareSave`/`saveDocument`, `serializeOpfTransfer` (presentation and slide scope) and `convert` (`/export`) call core's `embed`; the playground's OPF download and PPTX export do too.
41
41
  - **Paste** (`prepareOpfImport`, insert mode) uses core's `copySlides`: groups match by `source`, identical records are reused, and the result's `renamed` (`custom-conflict`, `catalog-revision`, `group-name`) and `addedGroups` tell the user what changed.
42
42
  - **Update from catalog** is an explicit action: `checkCatalogUpdates(presentation, { catalogs })` lists the embedded records whose catalog record differs, and `applyCatalogUpdate(editor, { refs })` applies only the approved ones as one undoable step. The design panel's `catalog` section runs both and lists the `opf/unresolved-reference` and `opf/undeclared-catalog` findings (`referenceFindings(editor.validation)`).
43
- - **Editing a catalog's record forks it.** An edit of a layout, theme, colour scheme or font scheme embedded under `catalogs.default` or a named group (from a panel, Properties, Source or any `applyPatch`) moves it into `catalogs.custom` as `<id>-custom` (or the id in `meta.forkIds["<group>:<kind>:<id>"]`), rewrites every reference to it and drops the original, in the same undo step. The change and the session event carry `forked` and a `notice` ("… is now this presentation's own; it no longer receives catalog updates"); approved catalog updates and `moveToCustom` change records in place (`meta.catalogRecordEdit: "in-place"`). `moveToCustom(editor, { group, kind, id }, { id })` is the explicit form, offered for `opf/catalog-record-not-in-source`.
43
+ - **Editing a catalog's record forks it.** An edit of a layout, theme, colour scheme or font scheme embedded under `catalogs.default` or a named group (from a panel, Properties, Source or any `applyPatch`) moves it into `catalogs.custom` as `<id>-custom` (or the id in `meta.forkIds["<group>:<kind>:<id>"]`), rewrites every reference to it and drops the original, in the same undo step. The change and the session event carry `forked` and a `notice` ("… is now this presentation's own; it no longer receives catalog updates"); approved catalog updates and `moveToCustom` change records in place (`meta.catalogRecordEdit: "in-place"`). `moveToCustom(editor, { group, kind, id }, { id })` is the explicit form, offered for `opf/catalog-record-not-in-source`. Opening a document is not an edit: use `replaceDocument` (next bullet).
44
+ - **Opening a document: `replaceDocument`.** `editor.replaceDocument(input, meta?)` puts exactly `input` (a parsed document, JSON text or bytes) in the session as ONE undoable change: Undo restores the previous document, Redo the new one, and nothing under `catalogs.default` or a named group is forked into `catalogs.custom`, even when the two documents embed the same record ids with different content (two examples published at different times). Use it for a host's example menu, File > Open, "start over" and restoring a stored copy; keep `applyPatch` for edits, because an `applyPatch` that replaces the root IS read as an edit of the records the documents share and forks them. It validates like any edit (`meta.rejectInvalid` or the session option refuses an invalid document and changes nothing), accepts the usual `meta` (`source` defaults to `"replace-document"`; the `patch` event carries `meta.replaceDocument: true`) and returns the change; it always records one undo step, also for an equal document. The editor's own File > Open (the playground's Import dialog, "Open as a presentation"), the gallery handoff and `createPersistence`'s `restore()` use it.
44
45
  - **Backgrounds** take the flat image form `{ type: "image", src, alt, fit, focus, opacity, recolor, overlay }` (fits `cover`, `contain`, `stretch`, `tile`) and the image-source shorthand, on the deck and on a slide. **Image blocks** carry their own fit, focus, treatments and placement (`/image-options`, the panel's `image` section): a full-bleed photo is a background, a picture beside the content is a placed image block. `design.imageFit` is the default fit of image blocks.
45
46
 
46
47
  Editor 0.15.0 uses core `^0.15.1` and the optional renderer peer `^0.15.0`. Install renderer and PPTX 0.15 together for the coordinated canvas and export workflow. The shared APIs are core's `validate`, `Finding`, `resolveSlideContext` and the catalog helpers above, and the renderer's `loadFonts()` handle passed as `fonts` to the canvas, export, composition and pagination. Historical version changes are recorded in `CHANGELOG.md`.
@@ -109,8 +110,9 @@ const pane = createSourcePane(container, {
109
110
  pane.setFormat('yaml'); pane.focusPath('/slides/1/title'); pane.getText(); pane.destroy();
110
111
  ```
111
112
 
112
- Methods: `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (moves the cursor to the line of a JSON Pointer in any format; exact for JSON, best effort for YAML and Markdown), `getText()`, `refresh()`, `setName`, `setDensity`, `setReadOnly` and `destroy()`. A finding is `{ path, message, severity, line? }` (core `Finding` objects fit). Rows show the path in mono and the line at the right; a click moves the cursor and calls `onFindingSelect`.
113
+ Methods: `replaceDocument(document, { source?, name?, meta? })` (below), `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (moves the cursor to the line of a JSON Pointer in any format; exact for JSON, best effort for YAML and Markdown), `getText()`, `refresh()`, `setName`, `setDensity`, `setReadOnly` and `destroy()`. A finding is `{ path, message, severity, line? }` (core `Finding` objects fit). Rows show the path in mono and the line at the right; a click moves the cursor and calls `onFindingSelect`.
113
114
 
115
+ - **Opening a document: `pane.replaceDocument(document, { source, name })`.** Loading an example or a file into the pane (never by writing its text into the code editor, which the pane applies as a typed edit and forks the catalog records the two documents share) calls `editor.replaceDocument`: one undoable change, no catalog record forked. `document` is a parsed document or JSON text; `source` is the JSON text it was read from (shown byte for byte; `document` itself when that is text) and `name` the file name. In YAML or Markdown the pane shows the document written in that format. Undo and Redo bring back each document's text exactly in the format shown (the opened JSON as written, the replaced document's text as it was, including YAML comments), so one Undo returns to the document and the text the visitor had. The pane keeps the last 8 such texts. A refused document throws (`invalid-opf-edit`, with `issues`) and changes nothing, and works in a read-only pane. A diff typed or pasted into the pane is still an edit, and editing a catalog record in it forks the record.
114
116
  - **JSON is canonical** and keeps the author's bytes: a YAML or Markdown edit rewrites only the changed JSON tokens (`exact-source`), and Undo restores the exact spelling.
115
117
  - **YAML and Markdown** are core's browser-safe converters, `@openpresentation/opf/yaml` and `/markdown`. Switching format writes the session's document in the new format. An edit parses back to the document and reaches the session as one `replace` patch, after a short delay (`applyDelay`, default 250 ms) so one burst of typing is one undo step. A syntax error, or an OPF rule the session refuses, is listed with its line and never reaches the document; Undo, Redo and outside edits replace a draft that did not parse.
116
118
  - **Nothing is lost silently.** Markdown writes what its dialect has no syntax for (a `design`, region keys, nested groups) as YAML in `opf-slide` and `opf-block` fences; each such part is a warning finding with its JSON Pointer and reason. Comments in YAML cannot survive a rewrite from the document: Format and outside edits drop them and say how many. A document that is not valid OPF cannot be written as YAML or Markdown; the pane stays in JSON and says so.
@@ -951,7 +953,7 @@ input.onchange = async () => {
951
953
  - **Findings.** The conversion's findings for YAML and Markdown, in core's `Finding` shape: `path` (a JSON Pointer such as `/slides/1/bogus`), `message`, `severity` and `location` (line and column in the file). A deck that reads but has OPF errors is returned with them, so a host can show them; JSON gives none (the editor session validates it).
952
954
  - **Errors.** Text that cannot be read at all throws `DeckFileError` with a `code` and a clear message: `unreadable`, `file-too-large` (over 20 MB), `empty-file`, `unsupported-file` (a .pptx or other binary file), `invalid-json`, `invalid-yaml`, `invalid-markdown` (with the syntax `findings` and the line) and `not-a-deck` (JSON without a `slides` list, such as one slide or a block, which a host can send to its paste path as the playground does).
953
955
 
954
- The source pane has no file-opening path of its own, so it keeps no second copy of this. The playground's Import dialog uses `readDeckFile` and `DECK_FILE_ACCEPT` for its file tab and for dropped files (`examples/transfer-controls.js`). Icons in the presenter button are inlined [Lucide](https://lucide.dev) paths (ISC license).
956
+ Put the opened document in the session with `editor.replaceDocument(document)` (or `pane.replaceDocument(document, { source })` when a source pane is mounted), not `applyPatch`: opening is one undoable change that forks no catalog record. The source pane has no file-opening path of its own, so it keeps no second copy of this. The playground's Import dialog uses `readDeckFile` and `DECK_FILE_ACCEPT` for its file tab and for dropped files (`examples/transfer-controls.js`). Icons in the presenter button are inlined [Lucide](https://lucide.dev) paths (ISC license).
955
957
 
956
958
  ## Optional React Bindings
957
959
 
package/dist/index.d.ts CHANGED
@@ -91,6 +91,14 @@ export interface EditorSession {
91
91
  setGroupComposition(path: string, composition: Composition, meta?: Record<string, unknown>): EditorChange;
92
92
  setCatalog(path: string | string[], catalogKind: string, id: string, meta?: Record<string, unknown>): EditorChange;
93
93
  applyPatch(operations: JsonPatchOperation[], meta?: Record<string, unknown>): EditorChange;
94
+ /**
95
+ * Open a different document in the session: the whole document is replaced as one undoable change, and the replacement is never
96
+ * read as an edit of the catalog records the two documents share, so nothing under `catalogs.default` or a named group is forked
97
+ * into `catalogs.custom`. `input` is a parsed document, JSON text or bytes. It is validated like any edit (`meta.rejectInvalid` or
98
+ * the session option refuses an invalid document and changes nothing). It always records its one change, also for an equal document.
99
+ * `meta.source` defaults to `"replace-document"`; the `patch` event carries `meta.replaceDocument: true`.
100
+ */
101
+ replaceDocument(input: unknown, meta?: Record<string, unknown>): EditorChange;
94
102
  /** The undo and redo stacks as plain data (oldest entry first), for hosts that persist work. */
95
103
  exportHistory(): EditorHistory;
96
104
  /** Replace the document and, optionally, the history in one step (one `restore` event). A history that does not replay against the document throws `invalid-history` before anything changes. */
package/dist/index.js CHANGED
@@ -314,6 +314,23 @@ export function createEditorSession(input, options = {}) {
314
314
  return setCatalogId(editor, path, catalogKind, id, meta);
315
315
  },
316
316
  applyPatch: commitPatch,
317
+ /**
318
+ * Open a different document in the session (a host's example menu, File > Open, "start over"): the whole document is replaced as ONE
319
+ * undoable change (Undo restores the previous document, Redo the new one). Unlike an `applyPatch` that replaces the root, this is
320
+ * never read as the user editing the catalog records the two documents share: records under `catalogs.default` or a named group
321
+ * keep the ids and content the new document carries, and nothing is forked into `catalogs.custom`. `input` is a parsed document,
322
+ * JSON text or bytes. The document is validated like any edit (`meta.rejectInvalid` or the session option refuses an invalid one
323
+ * and changes nothing). It always records its one change, also when the new document equals the open one (Undo then returns to the
324
+ * same document, as for any other replace). `meta.source` defaults to `"replace-document"`; the event is a `patch` whose `meta.replaceDocument` is true.
325
+ */
326
+ replaceDocument(input, meta = {}) {
327
+ return commitPatch([{ op: "replace", path: "", value: parseInput(input) }], {
328
+ ...meta,
329
+ source: meta.source ?? "replace-document",
330
+ replaceDocument: true,
331
+ catalogRecordEdit: "in-place"
332
+ });
333
+ },
317
334
  /**
318
335
  * The undo and redo stacks as plain data (oldest entry first): `{ undo: [{ patches, inversePatches, meta }], redo: [...] }`.
319
336
  * Together with `presentation` this is everything `restoreState` needs to bring a session back, for hosts that persist work.
@@ -268,7 +268,7 @@ export function createPersistence(editor, options = {}) {
268
268
  }
269
269
  } else {
270
270
  // The user already worked in this session: bring the copy back as one undoable change instead of replacing their history.
271
- editor.applyPatch([{ op: "replace", path: "", value: structuredClone(record.presentation) }], { source: "restore" });
271
+ editor.replaceDocument(structuredClone(record.presentation), { source: "restore" });
272
272
  }
273
273
  touched = true;
274
274
  baseline = record.dirty === false ? jsonOf(editor.presentation) : null;
@@ -315,7 +315,7 @@ export function createPersistence(editor, options = {}) {
315
315
  let record;
316
316
  try { record = await adapter.get(earlierKey); } catch { return false; }
317
317
  if (!validRecord(record)) return false;
318
- try { editor.applyPatch([{ op: "replace", path: "", value: structuredClone(record.presentation) }], { source: "restore" }); }
318
+ try { editor.replaceDocument(structuredClone(record.presentation), { source: "restore" }); }
319
319
  catch { return false; }
320
320
  pending = undefined;
321
321
  touched = true;
package/dist/presenter.js CHANGED
@@ -69,7 +69,7 @@ export function createPresenter(container, options = {}) {
69
69
  // Drawn slides by deck index, for the deck object they were drawn from: a new snapshot (an edit) or a new start() draws again.
70
70
  let drawn = new Map(), drawnFor;
71
71
 
72
- let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, leaving = false;
72
+ let active = false, destroyed = false, current = -1, opener = null, enteredFullscreen = false, leaving = false, returnTo = null;
73
73
  let counterTimer, preloadTimer, renderToken = 0, swipe = null, clickGuardUntil = 0;
74
74
 
75
75
  // ---- the DOM ----------------------------------------------------------------------------------------------------------------------------
@@ -197,7 +197,10 @@ export function createPresenter(container, options = {}) {
197
197
  try { attempt = Promise.resolve(request.call(root, { navigationUI: "hide" })); } catch (error) { attempt = Promise.reject(error); }
198
198
  return attempt.then(
199
199
  () => {
200
- if (!active || doc.fullscreenElement !== root) return "overlay";
200
+ if (doc.fullscreenElement !== root) return "overlay";
201
+ // stop() ran while the request was pending (a quick Esc or close), so it saw no full screen to leave: give the screen back now, or the
202
+ // page stays full screen behind a hidden presenter.
203
+ if (!active) { leaveFullscreen(); return "overlay"; }
201
204
  enteredFullscreen = true;
202
205
  root.dataset.mode = "fullscreen";
203
206
  return "fullscreen";
@@ -205,6 +208,17 @@ export function createPresenter(container, options = {}) {
205
208
  () => "overlay",
206
209
  );
207
210
  }
211
+ // Ask the browser to leave full screen; onFullscreenChange then sees `leaving` and does not read the exit as the user's. Focus goes back to the
212
+ // element that opened the presentation once the exit is done, because the page behind a full screen element takes no focus until then.
213
+ function leaveFullscreen() {
214
+ leaving = true;
215
+ Promise.resolve(doc.exitFullscreen?.()).then(restoreFocus, () => { leaving = false; });
216
+ }
217
+ // Only when nobody took focus since: the host may move it in `onExit`.
218
+ function restoreFocus() {
219
+ const now = doc.activeElement;
220
+ if (returnTo?.isConnected && typeof returnTo.focus === "function" && (!now || now === doc.body || now === root)) returnTo.focus({ preventScroll: true });
221
+ }
208
222
  // The browser takes full screen away with Esc (that key never reaches the page) or the host exits it: the presentation ends with it.
209
223
  function onFullscreenChange() {
210
224
  if (doc.fullscreenElement === root) return;
@@ -314,19 +328,10 @@ export function createPresenter(container, options = {}) {
314
328
  root.hidden = true;
315
329
  slide.replaceChildren();
316
330
  swipe = null;
317
- const back = opener;
331
+ returnTo = opener;
318
332
  opener = null;
319
- const restoreFocus = () => {
320
- // Only when nobody took focus since: the host may move it in `onExit`.
321
- const now = doc.activeElement;
322
- if (back?.isConnected && typeof back.focus === "function" && (!now || now === doc.body || now === root)) back.focus({ preventScroll: true });
323
- };
324
333
  restoreFocus();
325
- if (fullscreen) {
326
- leaving = true;
327
- // The page behind a full screen element takes no focus until the exit is done.
328
- Promise.resolve(doc.exitFullscreen?.()).then(restoreFocus, () => { leaving = false; });
329
- }
334
+ if (fullscreen) leaveFullscreen();
330
335
  // The deck index the presentation ended on (-1 when no slide was shown: there was nothing to present).
331
336
  options.onExit?.(index);
332
337
  }
@@ -1,4 +1,4 @@
1
- import type { EditorSession } from "./index.js";
1
+ import type { EditorChange, EditorSession } from "./index.js";
2
2
  import type { JsonCatalogContext } from "./json-options.js";
3
3
  import type { JsonCodeEditor } from "./json-editor.js";
4
4
 
@@ -64,8 +64,26 @@ export interface SourcePaneOptions {
64
64
  onError?(error: unknown): void;
65
65
  }
66
66
 
67
+ export interface ReplaceDocumentOptions {
68
+ /** The JSON text the document was read from. Used when it parses to the document, so its spelling is kept. */
69
+ source?: string;
70
+ /** The file name without extension, as `setName` (the document's own name is not used). */
71
+ name?: string;
72
+ /** Extra `meta` for the session change (`source` defaults to `"replace-document"`). */
73
+ meta?: Record<string, unknown>;
74
+ }
75
+
67
76
  export interface SourcePane {
68
77
  element: HTMLElement;
78
+ /**
79
+ * Open a different document in the session and the pane (a host's example menu, File > Open): `editor.replaceDocument`, so it is one
80
+ * undoable change and no catalog record is forked into `catalogs.custom` (a diff typed or pasted into the pane is an edit; this is not).
81
+ * `document` is a parsed document or JSON text. The pane shows `options.source` (the JSON text the document was read from, kept byte for
82
+ * byte; `document` itself when it is text) in JSON and the document written in YAML or Markdown. Undo and Redo bring back each
83
+ * document's text exactly, in the format that is shown. A document the session refuses throws (`invalid-opf-edit` with `issues`) and
84
+ * changes nothing. Works in a read-only pane.
85
+ */
86
+ replaceDocument(document: unknown, options?: ReplaceDocumentOptions): EditorChange;
69
87
  /** Re-read the document from the session. */
70
88
  refresh(): void;
71
89
  /** Replace the host findings; `null` returns to the session's own validation. */
@@ -2,7 +2,7 @@ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
2
2
  import { fromYaml, scanYamlComments, toYaml } from "@openpresentation/opf/yaml";
3
3
  import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
4
4
  import { mountJsonCodeEditor } from "./json-editor.js";
5
- import { createSourceMemory, findDuplicateKey, updateJsonSource } from "./exact-source.js";
5
+ import { MAX_EXACT_SOURCE_LENGTH, createSourceMemory, findDuplicateKey, updateJsonSource } from "./exact-source.js";
6
6
  import { lineForPath, lineOfOffset, offsetOfLine } from "./source-lines.js";
7
7
  import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
8
8
 
@@ -127,6 +127,17 @@ export function createSourcePane(container, options = {}) {
127
127
  // Whether the findings include an error, and what the editor's `aria-invalid` currently says (null: not yet set).
128
128
  let invalid = false, shownInvalid = null;
129
129
  const lineCache = { key: "", values: new Map() };
130
+ // The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
131
+ // (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
132
+ const exactTexts = new Map();
133
+ const rememberText = (target, value, text) => {
134
+ if (typeof text !== "string" || text.length > MAX_EXACT_SOURCE_LENGTH) return;
135
+ const key = `${target}|${JSON.stringify(value)}`;
136
+ exactTexts.delete(key);
137
+ exactTexts.set(key, text);
138
+ while (exactTexts.size > 8) exactTexts.delete(exactTexts.keys().next().value);
139
+ };
140
+ const exactText = (target, value) => exactTexts.get(`${target}|${JSON.stringify(value)}`);
130
141
 
131
142
  // ---- DOM ----------------------------------------------------------------------------------------------------------
132
143
  const root = el("div", "opf-source opf-ui", { "data-density": density });
@@ -315,6 +326,12 @@ export function createSourcePane(container, options = {}) {
315
326
  const before = control.api.getValue(), comments = scanYamlComments(before).count;
316
327
  if (comments > 0 && written.text !== before) transientNotes = [normalizeFinding({ message: `${plural(comments, "comment")} in the YAML ${comments === 1 ? "was" : "were"} dropped when the document changed outside the source.`, severity: "warning" }, "format")];
317
328
  }
329
+ // A document `replaceDocument` opened or replaced comes back with the exact text it had.
330
+ const exact = exactText(format, deck());
331
+ if (exact !== undefined && exact !== written.text) {
332
+ try { if (same(readText(format, exact, catalogs()).deck, deck())) { written = { ...written, text: exact }; if (format === "json") jsonText = exact; } }
333
+ catch { /* The remembered text no longer reads as this document: use the generated one. */ }
334
+ }
318
335
  noteFindings = written.notes;
319
336
  if (format !== "json") syncJsonSafe();
320
337
  if (written.text !== control.api.getValue()) show(written.text);
@@ -332,6 +349,60 @@ export function createSourcePane(container, options = {}) {
332
349
  fromSession();
333
350
  });
334
351
 
352
+ // ---- Replacing the whole document ----------------------------------------------------------------------------------
353
+ /**
354
+ * Open `input` (a document, or JSON text) as the whole document with `editor.replaceDocument`: one undoable change, no catalog record
355
+ * forked. The text shown is `options.source` (or `input` when it is text) in JSON, the document written in YAML or Markdown, and Undo
356
+ * and Redo bring each document's text back exactly.
357
+ */
358
+ function replaceDocument(input, replaceOptions = {}) {
359
+ if (destroyed) throw new Error("The source pane was destroyed.");
360
+ flush();
361
+ const before = deck();
362
+ const beforeText = dirty ? undefined : control.api.getValue();
363
+ const authored = typeof input === "string" ? input : replaceOptions.source;
364
+ transientNotes = [];
365
+ let change;
366
+ applying = true;
367
+ try {
368
+ // Remember the spelling of the document being replaced, then open the new one.
369
+ syncJson(before);
370
+ change = editor.replaceDocument(input, { format, ...replaceOptions.meta });
371
+ } catch (error) {
372
+ rejectionFindings = error?.issues?.length ? findingsOfError(error) : [normalizeFinding({ message: errorText(error), kind: "rejected" })];
373
+ renderFindings();
374
+ throw error;
375
+ } finally { applying = false; }
376
+ clearTimeout(timer);
377
+ timer = null;
378
+ for (const key of Object.keys(drafts)) delete drafts[key];
379
+ syntaxFindings = [];
380
+ rejectionFindings = [];
381
+ dirty = false;
382
+ version++;
383
+ const opened = deck();
384
+ let written;
385
+ let authoredJson = null;
386
+ if (typeof authored === "string") { try { if (same(JSON.parse(authored), opened) && !findDuplicateKey(authored)) authoredJson = authored; } catch { /* Not the document's JSON: write it. */ } }
387
+ if (replaceOptions.name !== undefined) { name = sanitizeName(replaceOptions.name) || "deck"; updateBar(); }
388
+ try { written = format === "json" && authoredJson !== null ? { text: authoredJson, notes: [] } : write(format, opened); }
389
+ catch (error) {
390
+ syncJsonSafe();
391
+ rejectionFindings = [normalizeFinding({ message: `The document cannot be written as ${formatOf(format).label}: ${errorText(error)}. The text below is the last version that could be.`, kind: "rejected" })];
392
+ renderFindings();
393
+ return change;
394
+ }
395
+ if (authoredJson !== null) jsonText = authoredJson; else syncJsonSafe();
396
+ rememberText(format, opened, written.text);
397
+ rememberText(format, before, beforeText);
398
+ noteFindings = written.notes;
399
+ if (written.text !== control.api.getValue()) show(written.text); else baseline = written.text;
400
+ renderedKey = "";
401
+ renderFindings();
402
+ syncAttributes();
403
+ return change;
404
+ }
405
+
335
406
  // ---- Format -------------------------------------------------------------------------------------------------------
336
407
  function setFormatInternal(next, notify) {
337
408
  const target = formatOf(next);
@@ -627,6 +698,11 @@ export function createSourcePane(container, options = {}) {
627
698
 
628
699
  return {
629
700
  element: root,
701
+ /**
702
+ * Open a different document: `editor.replaceDocument` as one undoable change that forks no catalog record, with the text shown in
703
+ * the current format. `options.source` is the JSON text the document was read from (kept byte for byte); `options.name` renames the file.
704
+ */
705
+ replaceDocument,
630
706
  /** Re-read the document from the session (for example after the host changed it without events). */
631
707
  refresh() { version++; fromSession(false); },
632
708
  /** Replace the host findings; `setFindings(null)` returns to the session's own validation. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",