@openpresentation/opf-editor 0.25.2 → 0.25.3

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
@@ -110,9 +110,10 @@ const pane = createSourcePane(container, {
110
110
  pane.setFormat('yaml'); pane.focusPath('/slides/1/title'); pane.getText(); pane.destroy();
111
111
  ```
112
112
 
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
+ Methods: `replaceDocument(document, { source?, format?, 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`.
114
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.
115
+ - **Opening a document: `pane.replaceDocument(document, { source, format, 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; `name` is the file name. `source` is the text the document was read from, in any of the three formats: the pane shows it as written (the JSON's formatting, the YAML's comments, the Markdown's layout) when the pane is in that format, and core's rewrite of the document otherwise. `format` (`"json"`, `"yaml"` or `"md"`) says which format `source` is; left out, it is inferred as the first of JSON, the pane's format, YAML and Markdown that reads as exactly this document (a `source` that reads as no format, or as another document, is ignored; an unknown `format` throws a `TypeError` before anything changes). The original text of a format other than the one shown is kept too: switching the pane to it shows those bytes, not a rewrite. Undo and Redo bring back each document's text exactly in the format shown (the opened text 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, which are also what a later load of the same document shows. 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.
116
+ - **Keyboard Undo and Redo in the pane (one model).** Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z (Ctrl+Y) work in two layers, in this order. First CodeMirror's history, which holds only the text typed in the pane since its last whole-document change (a `replaceDocument`, or a session Undo, Redo or restore, each of which clears it because the typed edits no longer fit the text). When that has nothing left, the key steps the session over a whole-document replace, exactly as its Undo and Redo do, so the text comes back byte for byte: Undo steps back over the nearest `replaceDocument` together with the pane's own edits typed on it (the typing was already undone, step by step, by CodeMirror's history); Redo steps forward over the replace. The key never reaches over an edit made elsewhere (the canvas, a panel): Undo stops at it, and the host's Undo button (or `editor.undo()`) undoes that edit, after which the key reaches the load again. A read-only pane takes neither key. `mountJsonCodeEditor` exposes the same seam as `historyFallback: { undo(), redo() }` (answer `true` when you did something) plus `resetHistory()` and `undoDepth()` on the control.
116
117
  - **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.
117
118
  - **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.
118
119
  - **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.
@@ -28,6 +28,12 @@ export interface JsonCodeEditorOptions {
28
28
  catalogs?: JsonCatalogContext;
29
29
  /** Overrides the built-in menu, allowing an existing host UI to keep its appearance. */
30
30
  onOptions?(left?: number, top?: number): void;
31
+ /**
32
+ * Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z (Ctrl+Y) run CodeMirror's history first. When it has nothing left to undo or redo, the fallback runs
33
+ * and answers true when it did something (false lets the key pass on). The source pane uses it to step the session back over a
34
+ * whole-document replace.
35
+ */
36
+ historyFallback?: { undo?(): boolean; redo?(): boolean };
31
37
  onFocus?(): void;
32
38
  onHoverField?(path: string | null): void;
33
39
  onError?(error: unknown): void;
@@ -39,6 +45,10 @@ export interface JsonCodeEditor {
39
45
  setCatalogs(catalogs: JsonCatalogContext): void;
40
46
  setLanguage(language: "json" | "yaml" | "markdown"): void;
41
47
  setReadOnly(readOnly: boolean): void;
48
+ /** Forget the typed edits CodeMirror can undo, for when the text they were typed against was replaced. The text is unchanged. */
49
+ resetHistory(): void;
50
+ /** How many typed edit groups CodeMirror can still undo. */
51
+ undoDepth(): number;
42
52
  /** The CodeMirror `EditorView`. */
43
53
  readonly view: import("@codemirror/view").EditorView;
44
54
  openOptions(left?: number, top?: number): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { Annotation, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction } from "@codemirror/state";
2
2
  import { Decoration, EditorView, drawSelection, keymap, lineNumbers } from "@codemirror/view";
3
- import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory, redo } from "@codemirror/commands";
3
+ import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory, redo, undo, undoDepth } from "@codemirror/commands";
4
4
  import { HighlightStyle, StreamLanguage, bracketMatching, codeFolding, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
5
5
  import { json, jsonParseLinter } from "@codemirror/lang-json";
6
6
  import { yaml } from "@codemirror/lang-yaml";
@@ -167,7 +167,7 @@ function skipDelimiter(view, delimiter) {
167
167
  return true;
168
168
  }
169
169
  function mountJsonCodeEditor(parent, options) {
170
- const attributes = new Compartment(), indent = new Compartment(), language = new Compartment(), editable = new Compartment();
170
+ const attributes = new Compartment(), indent = new Compartment(), language = new Compartment(), editable = new Compartment(), historyField = new Compartment();
171
171
  const editableExtension = (readOnly) => [EditorState.readOnly.of(Boolean(readOnly)), EditorView.editable.of(!readOnly)];
172
172
  let languageName = options.language ?? "json";
173
173
  let disposed=false, menu=null, catalogs=options.catalogs??[], generation=0;
@@ -196,6 +196,11 @@ function mountJsonCodeEditor(parent, options) {
196
196
  label: option.label, detail: option.detail, info: option.info, boost: option.boost, type: found.kind === "key" ? "property" : "enum", apply: completionApply(found, option),
197
197
  })) };
198
198
  }
199
+ // Undo and Redo run CodeMirror's history first. When it has nothing left, the host's fallback may step further (the source pane
200
+ // routes a whole-document replace to the session); a fallback answers true when it did something.
201
+ const undoCommand = (target) => undo(target) || Boolean(options.historyFallback?.undo?.());
202
+ const redoCommand = (target) => redo(target) || Boolean(options.historyFallback?.redo?.());
203
+ const historyBindings = historyKeymap.map((binding) => binding.run === undo ? { ...binding, run: undoCommand } : binding.run === redo ? { ...binding, run: redoCommand } : binding);
199
204
  const completionExtension = options.completions ? [autocompletion({ override: [completionSource], defaultKeymap: false, icons: false, maxRenderedOptions: 60 }), completionKeys] : null;
200
205
  let currentIndent = indentation(options.code);
201
206
  const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
@@ -213,7 +218,7 @@ function mountJsonCodeEditor(parent, options) {
213
218
  jsonSource.init(()=>options.code),jsonSourceHistory,
214
219
  indent.of(indentUnit.of(currentIndent)),
215
220
  indentOnInput(),
216
- history(),
221
+ historyField.of(history()),
217
222
  drawSelection(),
218
223
  bracketMatching(),
219
224
  closeBrackets(),
@@ -233,7 +238,7 @@ function mountJsonCodeEditor(parent, options) {
233
238
  // undo steps still available (otherwise it silently performs undo).
234
239
  if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "z") {
235
240
  event.preventDefault();
236
- redo(view);
241
+ redoCommand(view);
237
242
  return true;
238
243
  }
239
244
  if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "f") {
@@ -262,7 +267,7 @@ function mountJsonCodeEditor(parent, options) {
262
267
  indentWithTab,
263
268
  ...closeBracketsKeymap,
264
269
  ...defaultKeymap,
265
- ...historyKeymap,
270
+ ...historyBindings,
266
271
  ...searchKeymap,
267
272
  ...foldKeymap
268
273
  ]),
@@ -331,6 +336,13 @@ function mountJsonCodeEditor(parent, options) {
331
336
  languageName=name;closeOptions();
332
337
  view.dispatch({ effects: language.reconfigure(languageExtension(name, completionExtension)) });
333
338
  },
339
+ /** Forget the typed edits CodeMirror can undo (the text they were typed against is gone). Returns nothing; the document is unchanged. */
340
+ resetHistory(){
341
+ view.dispatch({ effects: historyField.reconfigure([]) });
342
+ view.dispatch({ effects: historyField.reconfigure(history()) });
343
+ },
344
+ /** How many typed edit groups CodeMirror can still undo. */
345
+ undoDepth: () => undoDepth(view.state),
334
346
  setReadOnly(value){view.dispatch({ effects: editable.reconfigure(editableExtension(value)) });},
335
347
  /** The CodeMirror view, for hosts that scroll or measure it. */
336
348
  view,
@@ -65,8 +65,14 @@ export interface SourcePaneOptions {
65
65
  }
66
66
 
67
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. */
68
+ /**
69
+ * The text the document was read from: JSON, YAML or Markdown. When it reads as exactly the document, the pane keeps its spelling (JSON
70
+ * formatting, YAML comments, Markdown prose layout), shows it while the pane is in that format and brings it back on Undo, Redo and
71
+ * when the format is switched to it. Text that does not read as the document is ignored.
72
+ */
69
73
  source?: string;
74
+ /** The format of `source`. Default: inferred, the first of JSON, the pane's format, YAML and Markdown that reads as the document. */
75
+ format?: SourceFormat;
70
76
  /** The file name without extension, as `setName` (the document's own name is not used). */
71
77
  name?: string;
72
78
  /** Extra `meta` for the session change (`source` defaults to `"replace-document"`). */
@@ -78,10 +84,11 @@ export interface SourcePane {
78
84
  /**
79
85
  * Open a different document in the session and the pane (a host's example menu, File > Open): `editor.replaceDocument`, so it is one
80
86
  * 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.
87
+ * `document` is a parsed document or JSON text. The pane shows `options.source` (the text the document was read from in JSON, YAML or
88
+ * Markdown, kept byte for byte, comments included; `document` itself when it is JSON text) in the format it is in, and the document
89
+ * written by core's converter when no original text fits. Undo and Redo bring back each document's text exactly, in the format that is
90
+ * shown, and so do Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z once the text typed since the load is undone. A document the session refuses throws
91
+ * (`invalid-opf-edit` with `issues`) and changes nothing. Works in a read-only pane.
85
92
  */
86
93
  replaceDocument(document: unknown, options?: ReplaceDocumentOptions): EditorChange;
87
94
  /** Re-read the document from the session. */
@@ -236,6 +236,7 @@ export function createSourcePane(container, options = {}) {
236
236
  catalogs: options.catalogs ?? editor.catalogs,
237
237
  describedBy: `${id}-findings`,
238
238
  onChange: () => schedule(),
239
+ historyFallback: { undo: () => stepReplace("undo"), redo: () => stepReplace("redo") },
239
240
  onError: (error) => options.onError?.(error),
240
241
  });
241
242
 
@@ -327,11 +328,7 @@ export function createSourcePane(container, options = {}) {
327
328
  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")];
328
329
  }
329
330
  // 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
- }
331
+ written = preferExact(format, deck(), written);
335
332
  noteFindings = written.notes;
336
333
  if (format !== "json") syncJsonSafe();
337
334
  if (written.text !== control.api.getValue()) show(written.text);
@@ -339,6 +336,16 @@ export function createSourcePane(container, options = {}) {
339
336
  renderFindings();
340
337
  syncAttributes();
341
338
  }
339
+ /** The remembered text of `value` in `target` (what `replaceDocument` was given or replaced) when it still reads as `value`; else `written`. */
340
+ function preferExact(target, value, written) {
341
+ const exact = exactText(target, value);
342
+ if (exact === undefined || exact === written.text) return written;
343
+ try {
344
+ if (!same(readText(target, exact, catalogs()).deck, value)) return written;
345
+ } catch { return written; }
346
+ if (target === "json") jsonText = exact;
347
+ return { ...written, text: exact };
348
+ }
342
349
  function syncJsonSafe() { try { syncJson(deck()); } catch { /* Keep the last JSON. */ } }
343
350
 
344
351
  const unsubscribe = editor.subscribe((event) => {
@@ -346,21 +353,39 @@ export function createSourcePane(container, options = {}) {
346
353
  if (event.type === "catalogs") { control.setCatalogs(options.catalogs ?? editor.catalogs); renderFindings(); return; }
347
354
  if (applying) return;
348
355
  version++;
356
+ // Undo, Redo, a restore and a whole-document replace by the host move the text under the typed edits CodeMirror remembers; they
357
+ // would no longer fit it.
358
+ if (event.type === "undo" || event.type === "redo" || event.type === "restore" || event.meta?.replaceDocument) control.resetHistory();
349
359
  fromSession();
350
360
  });
351
361
 
352
362
  // ---- Replacing the whole document ----------------------------------------------------------------------------------
363
+ /**
364
+ * Which format the text is, among those that read as exactly `opened`: the declared `format` (which must read so), else JSON first
365
+ * (the form `replaceDocument` always took), then the pane's own format, then the others. Null when none reads as the document.
366
+ */
367
+ function sourceFormatOf(text, opened, declared) {
368
+ const order = declared ? [declared] : [...new Set(["json", format, "yaml", "md"])];
369
+ for (const candidate of order) {
370
+ try {
371
+ const read = readText(candidate, text, catalogs());
372
+ if (read.deck && same(read.deck, opened)) return candidate;
373
+ } catch { /* Not this format. */ }
374
+ }
375
+ return null;
376
+ }
353
377
  /**
354
378
  * 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.
379
+ * forked. The text shown is the original text of the document when `source` (or `input`, when it is JSON text) is that text in the
380
+ * pane's format, and the document written by core's converter otherwise. The original text of any format is kept for the pane's other
381
+ * formats and for Undo and Redo, which bring each document's text back exactly.
357
382
  */
358
383
  function replaceDocument(input, replaceOptions = {}) {
359
384
  if (destroyed) throw new Error("The source pane was destroyed.");
385
+ if (replaceOptions.format !== undefined && !FORMATS.some((entry) => entry.id === replaceOptions.format)) throw new TypeError(`replaceDocument: format must be "json", "yaml" or "md", not ${JSON.stringify(replaceOptions.format)}.`);
360
386
  flush();
361
387
  const before = deck();
362
388
  const beforeText = dirty ? undefined : control.api.getValue();
363
- const authored = typeof input === "string" ? input : replaceOptions.source;
364
389
  transientNotes = [];
365
390
  let change;
366
391
  applying = true;
@@ -380,11 +405,21 @@ export function createSourcePane(container, options = {}) {
380
405
  rejectionFindings = [];
381
406
  dirty = false;
382
407
  version++;
408
+ // The typed edits CodeMirror remembers belong to the document that was replaced.
409
+ control.resetHistory();
383
410
  const opened = deck();
384
- let written;
411
+ // The original text: JSON text given as `input` is that document's JSON; `source` is its text in `format` (inferred when not given).
385
412
  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. */ } }
413
+ const originals = [];
414
+ if (typeof input === "string") originals.push({ text: input, format: "json" });
415
+ if (typeof replaceOptions.source === "string" && (typeof input !== "string" || replaceOptions.format)) originals.push({ text: replaceOptions.source, format: replaceOptions.format });
416
+ for (const original of originals) {
417
+ const kind = sourceFormatOf(original.text, opened, original.format);
418
+ if (kind === "json") authoredJson ??= original.text;
419
+ else if (kind) rememberText(kind, opened, original.text);
420
+ }
387
421
  if (replaceOptions.name !== undefined) { name = sanitizeName(replaceOptions.name) || "deck"; updateBar(); }
422
+ let written;
388
423
  try { written = format === "json" && authoredJson !== null ? { text: authoredJson, notes: [] } : write(format, opened); }
389
424
  catch (error) {
390
425
  syncJsonSafe();
@@ -393,6 +428,8 @@ export function createSourcePane(container, options = {}) {
393
428
  return change;
394
429
  }
395
430
  if (authoredJson !== null) jsonText = authoredJson; else syncJsonSafe();
431
+ // The text shown now is what Undo and Redo bring back for this document, in the format shown.
432
+ if (!(format === "json" && authoredJson !== null)) written = preferExact(format, opened, written);
396
433
  rememberText(format, opened, written.text);
397
434
  rememberText(format, before, beforeText);
398
435
  noteFindings = written.notes;
@@ -403,6 +440,29 @@ export function createSourcePane(container, options = {}) {
403
440
  return change;
404
441
  }
405
442
 
443
+ /**
444
+ * Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z (Ctrl+Y) after CodeMirror's own history has nothing left: step the session over a whole-document
445
+ * replace, as its Undo and Redo buttons do, so the text comes back exactly. Undo steps back over the nearest `replaceDocument` together
446
+ * with the pane's own edits typed on it (CodeMirror already undid the typing); an edit made elsewhere (the canvas, a panel) stops it.
447
+ * Redo steps forward over a replace only. Returns whether it did something.
448
+ */
449
+ function stepReplace(direction) {
450
+ if (destroyed || readOnly || typeof editor.exportHistory !== "function") return false;
451
+ flush();
452
+ if (dirty || !(direction === "undo" ? editor.canUndo : editor.canRedo)) return false;
453
+ const history = editor.exportHistory();
454
+ if (direction === "redo") {
455
+ if (!history.redo.at(-1)?.meta?.replaceDocument) return false;
456
+ editor.redo({ source: "source-pane" });
457
+ return true;
458
+ }
459
+ let index = history.undo.length - 1;
460
+ while (index >= 0 && !history.undo[index].meta?.replaceDocument && history.undo[index].meta?.source === "source-pane") index -= 1;
461
+ if (index < 0 || !history.undo[index].meta?.replaceDocument) return false;
462
+ for (let steps = history.undo.length - index; steps > 0; steps -= 1) editor.undo({ source: "source-pane" });
463
+ return true;
464
+ }
465
+
406
466
  // ---- Format -------------------------------------------------------------------------------------------------------
407
467
  function setFormatInternal(next, notify) {
408
468
  const target = formatOf(next);
@@ -413,7 +473,7 @@ export function createSourcePane(container, options = {}) {
413
473
  const draft = drafts[target.id];
414
474
  if (draft && draft.version === version) written = { text: draft.text, notes: [], draft: true };
415
475
  else {
416
- try { written = write(target.id, deck()); }
476
+ try { written = preferExact(target.id, deck(), write(target.id, deck())); }
417
477
  catch (error) {
418
478
  rejectionFindings = [normalizeFinding({ message: `The document cannot be written as ${target.label}: ${errorText(error)} Fix the findings first.`, kind: "rejected" })];
419
479
  renderFindings();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",