@openpresentation/opf-editor 0.24.3 → 0.25.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.
@@ -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;
@@ -83,16 +83,19 @@ export interface PresentButtonOptions {
83
83
  density?: "comfortable" | "touch";
84
84
  /** The accessible name (default "Present"). */
85
85
  label?: string;
86
+ /** `"quiet"` (default): a transparent icon button like the toolbar's undo and redo. `"primary"`: the filled accent look of the Export trigger. */
87
+ variant?: "quiet" | "primary";
86
88
  }
87
89
 
88
90
  export interface PresentButton {
89
91
  readonly element: HTMLButtonElement;
90
92
  setDisabled(reason?: boolean | string): void;
91
93
  setDensity(density: "comfortable" | "touch"): void;
94
+ setVariant(variant: "quiet" | "primary"): void;
92
95
  destroy(): void;
93
96
  }
94
97
 
95
- /** The icon-only Present button: a Lucide Presentation icon, `aria-label="Present"`, title "Present (F5)", styled like the Export trigger. */
98
+ /** The icon-only Present button: a Lucide Presentation icon, `aria-label="Present"`, title "Present (F5)". Quiet by default (a transparent icon button like undo and redo); `variant: "primary"` styles it like the Export trigger. */
96
99
  export declare function createPresentButton(container: HTMLElement, options?: PresentButtonOptions): PresentButton;
97
100
 
98
101
  /**
package/dist/presenter.js CHANGED
@@ -372,9 +372,10 @@ function isInteractiveTarget(target) {
372
372
  * Mount the Present button in `container`: an icon-only button (Lucide Presentation) named "Present", titled "Present (F5)".
373
373
  *
374
374
  * Options: `onPresent(event)`, `disabled` (`true`, or a string that says why and is the title), `density` (`"comfortable"` 32px, or `"touch"` 40px)
375
- * and `label` (the accessible name, default "Present"). A disabled button stays focusable (`aria-disabled`) and does nothing.
375
+ * `label` (the accessible name, default "Present") and `variant` (`"quiet"`, the default: a transparent icon button like the toolbar's undo and redo,
376
+ * or `"primary"`: the filled accent look of the Export trigger). A disabled button stays focusable (`aria-disabled`) and does nothing.
376
377
  *
377
- * Returns `{ element, setDisabled(reason), setDensity(density), destroy() }`.
378
+ * Returns `{ element, setDisabled(reason), setDensity(density), setVariant(variant), destroy() }`.
378
379
  */
379
380
  export function createPresentButton(container, options = {}) {
380
381
  if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
@@ -384,7 +385,7 @@ export function createPresentButton(container, options = {}) {
384
385
  let destroyed = false;
385
386
  const button = el(doc, "button", {
386
387
  type: "button", class: "opf-present-button opf-ui", "aria-label": label, title: "Present (F5)", "aria-keyshortcuts": "F5",
387
- "data-density": options.density === "touch" ? "touch" : "comfortable",
388
+ "data-density": options.density === "touch" ? "touch" : "comfortable", "data-variant": options.variant === "primary" ? "primary" : "quiet",
388
389
  }, icon(doc, "Presentation", 16));
389
390
  const paint = () => {
390
391
  if (disabledReason) { button.setAttribute("aria-disabled", "true"); button.title = disabledReason; }
@@ -404,6 +405,7 @@ export function createPresentButton(container, options = {}) {
404
405
  paint();
405
406
  },
406
407
  setDensity(next) { button.dataset.density = next === "touch" ? "touch" : "comfortable"; },
408
+ setVariant(next) { button.dataset.variant = next === "primary" ? "primary" : "quiet"; },
407
409
  destroy() { destroyed = true; button.remove(); },
408
410
  };
409
411
  }
@@ -9,6 +9,7 @@ import {
9
9
  import { listSwitchOptions } from "./switches.js";
10
10
  import { layoutRegionSummary } from "./layout-templates.js";
11
11
  import { groupLayoutChoices } from "./layout-templates.js";
12
+ import { buildLayoutDiagram } from "./layout-diagram.js";
12
13
  import { switchDimension } from "./switches.js";
13
14
 
14
15
  let instances = 0;
@@ -521,13 +522,8 @@ export function createSlideManager(container, options) {
521
522
  template.replaceChildren(); currentPreview.replaceChildren();
522
523
  if (choice?.record?.preview && typeof choice.record.preview === "string") {
523
524
  template.append(el("img", { src: choice.record.preview, alt: `${choice.label} template preview` }));
524
- } else if (Array.isArray(choice?.record?.areas)) {
525
- const diagram = el("div", { class: "opf-layout-diagram" });
526
- diagram.style.gridTemplateAreas = choice.record.areas.map((row) => `"${row.join(" ")}"`).join(" ");
527
- diagram.style.gridTemplateColumns = `repeat(${choice.record.areas[0]?.length ?? 1}, 1fr)`;
528
- const areas = [...new Set(choice.record.areas.flat())].filter((area) => area !== ".");
529
- for (const area of areas) { const part = el("span", { text: area }); part.style.gridArea = area; diagram.append(part); }
530
- template.append(diagram);
525
+ } else if (Array.isArray(choice?.record?.regions)) {
526
+ template.append(buildLayoutDiagram(document, choice.record));
531
527
  }
532
528
  if (choice && options.renderLayoutPreview) {
533
529
  try { currentPreview.innerHTML = options.renderLayoutPreview(deck(), current(), choice) ?? ""; }
@@ -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. */