@rathkit/rte-core 0.1.0-alpha.0 → 0.1.0-alpha.4

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 (55) hide show
  1. package/dist/dom/render.js +25 -14
  2. package/dist/dom/view.d.ts +5 -3
  3. package/dist/dom/view.js +41 -23
  4. package/dist/edit.js +6 -3
  5. package/dist/engine.d.ts +7 -1
  6. package/dist/engine.js +19 -3
  7. package/dist/export.d.ts +2 -2
  8. package/dist/export.js +22 -6
  9. package/dist/footnotes/index.d.ts +2 -0
  10. package/dist/footnotes/index.js +2 -0
  11. package/dist/footnotes/plugin.d.ts +6 -0
  12. package/dist/footnotes/plugin.js +130 -0
  13. package/dist/footnotes/reconcile.d.ts +4 -0
  14. package/dist/footnotes/reconcile.js +155 -0
  15. package/dist/images/images.js +4 -3
  16. package/dist/images/index.d.ts +5 -1
  17. package/dist/images/index.js +3 -1
  18. package/dist/{dom/imageview.d.ts → images/view.d.ts} +8 -5
  19. package/dist/{dom/imageview.js → images/view.js} +16 -10
  20. package/dist/import.js +55 -18
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +1 -0
  23. package/dist/lists/lists.js +45 -30
  24. package/dist/markdown/detect.d.ts +1 -0
  25. package/dist/markdown/detect.js +81 -0
  26. package/dist/markdown/export.d.ts +10 -0
  27. package/dist/markdown/export.js +252 -0
  28. package/dist/markdown/index.d.ts +5 -0
  29. package/dist/markdown/index.js +4 -0
  30. package/dist/markdown/inline.d.ts +35 -0
  31. package/dist/markdown/inline.js +604 -0
  32. package/dist/markdown/parse.d.ts +4 -0
  33. package/dist/markdown/parse.js +586 -0
  34. package/dist/markdown/plugin.d.ts +20 -0
  35. package/dist/markdown/plugin.js +53 -0
  36. package/dist/model.d.ts +25 -2
  37. package/dist/model.js +2 -0
  38. package/dist/notes.d.ts +6 -0
  39. package/dist/notes.js +49 -0
  40. package/dist/ops.d.ts +6 -1
  41. package/dist/ops.js +5 -4
  42. package/dist/path.d.ts +3 -1
  43. package/dist/path.js +91 -5
  44. package/dist/plugin.d.ts +52 -1
  45. package/dist/standard/index.d.ts +5 -2
  46. package/dist/standard/index.js +6 -4
  47. package/dist/{dom/tableview.d.ts → tables/cells.d.ts} +6 -5
  48. package/dist/{dom/tableview.js → tables/cells.js} +13 -9
  49. package/dist/{dom/tablegrips.d.ts → tables/grips.d.ts} +5 -4
  50. package/dist/{dom/tablegrips.js → tables/grips.js} +8 -7
  51. package/dist/tables/index.d.ts +5 -1
  52. package/dist/tables/index.js +3 -1
  53. package/dist/tables/view.d.ts +2 -0
  54. package/dist/tables/view.js +15 -0
  55. package/package.json +23 -1
@@ -44,6 +44,13 @@ function renderInline(parent, content, options) {
44
44
  parent.appendChild(gap);
45
45
  }
46
46
  }
47
+ else if (node.type === "note") {
48
+ const mark = document.createElement("sup");
49
+ mark.setAttribute("data-rte-note", node.id);
50
+ mark.setAttribute("contenteditable", "false");
51
+ mark.setAttribute("role", "doc-noteref");
52
+ parent.appendChild(mark);
53
+ }
47
54
  else if (node.type === "image") {
48
55
  const img = document.createElement("img");
49
56
  if (isRemote(node.src) && !options.loadRemoteImages)
@@ -104,10 +111,17 @@ function renderList(list, path, options) {
104
111
  el.setAttribute("start", String(list.start));
105
112
  if (list.style !== undefined)
106
113
  el.style.listStyleType = list.style;
114
+ if (list.notes) {
115
+ el.setAttribute("data-rk-notes", "");
116
+ el.setAttribute("role", "doc-endnotes");
117
+ el.setAttribute("aria-label", "Notes");
118
+ }
107
119
  list.items.forEach((item, i) => {
108
120
  const itemPath = [...path, i];
109
121
  const li = tag("li", itemPath);
110
- if (item.content.length === 0 && item.sublists.length > 0)
122
+ if (list.notes && item.id)
123
+ li.setAttribute("data-rk-note-id", item.id);
124
+ if (item.content.length === 0 && (item.children[0]?.type === "ul" || item.children[0]?.type === "ol"))
111
125
  li.style.listStyleType = "none";
112
126
  if (item.checked !== undefined) {
113
127
  li.style.listStyleType = "none";
@@ -121,7 +135,7 @@ function renderList(list, path, options) {
121
135
  li.appendChild(box);
122
136
  }
123
137
  renderInline(li, item.content, options);
124
- item.sublists.forEach((sub, k) => li.appendChild(renderList(sub, [...itemPath, k], options)));
138
+ item.children.forEach((child, k) => li.appendChild(renderBlock(child, [...itemPath, k], options)));
125
139
  el.appendChild(li);
126
140
  });
127
141
  return el;
@@ -281,17 +295,14 @@ export function renderDoc(doc, root, options = { loadRemoteImages: false }, cach
281
295
  });
282
296
  cache.dirty.clear();
283
297
  cache.claimed.clear();
284
- const keep = new Set(wanted);
285
- for (const node of [...root.childNodes])
286
- if (!keep.has(node))
287
- root.removeChild(node);
288
- let cursor = root.firstChild;
289
- for (const el of wanted) {
290
- if (cursor === el)
291
- cursor = cursor.nextSibling;
292
- else
293
- root.insertBefore(el, cursor);
294
- }
298
+ reconcile(root, wanted);
299
+ root.querySelectorAll("sup[data-rte-note]").forEach((mark, i) => {
300
+ const n = String(i + 1);
301
+ if (mark.textContent !== n) {
302
+ mark.textContent = n;
303
+ mark.setAttribute("aria-label", `Footnote ${n}`);
304
+ }
305
+ });
295
306
  }
296
307
  function* inlineItems(container) {
297
308
  const stack = [[...container.childNodes].reverse()];
@@ -307,7 +318,7 @@ function* inlineItems(container) {
307
318
  else if (node instanceof Element) {
308
319
  if (node.hasAttribute("data-rte-ph") || node.hasAttribute("data-rte-check") || node.hasAttribute("data-rte-caption") || BLOCK_TAGS.has(node.tagName))
309
320
  continue;
310
- if (node.tagName === "BR" || node.tagName === "IMG")
321
+ if (node.tagName === "BR" || node.tagName === "IMG" || node.hasAttribute("data-rte-note"))
311
322
  yield node;
312
323
  else
313
324
  stack.push([...node.childNodes].reverse());
@@ -45,9 +45,8 @@ export declare class Editor {
45
45
  /** Notices changes to the page that the editor did not make, so the block they touched is drawn again from the model. */
46
46
  private readonly observer;
47
47
  private readonly cleanups;
48
- private readonly cellMarks;
49
- private readonly imageSelection;
50
- private readonly tableGrips;
48
+ private readonly changeListeners;
49
+ private readonly viewHost;
51
50
  constructor(options: EditorOptions);
52
51
  get doc(): Doc;
53
52
  get selection(): Selection;
@@ -69,6 +68,7 @@ export declare class Editor {
69
68
  subscribe: Engine["subscribe"];
70
69
  select: Engine["select"];
71
70
  /** Adds a plugin. Returns a function that removes it. */
71
+ /** Adds a plugin, with its page-facing part if it has one. Returns what takes both away again. */
72
72
  use: Engine["use"];
73
73
  /** The document as HTML. `separator` goes between blocks (a newline makes it readable). */
74
74
  getHtml(separator?: string): string;
@@ -124,6 +124,8 @@ export declare class Editor {
124
124
  */
125
125
  private deleteVisualLine;
126
126
  private onPaste;
127
+ /** What copy, cut and drag write: the model's own HTML and text, then whatever formats plugins add. */
128
+ private copyFormats;
127
129
  /** Copy and cut write the model's own HTML and text, not the page's markup. */
128
130
  private onCopy;
129
131
  private onDragStart;
package/dist/dom/view.js CHANGED
@@ -1,15 +1,13 @@
1
1
  import { createEngine } from "../core.js";
2
2
  import { caret, clampPos, deleteBackward, deleteForward, deleteSelection, deleteToEdge, deleteWord, extractSelection, insertBlocks, insertLineBreak, insertText, isCollapsed, moveSelection, selectedText, splitBlock, transposeChars, } from "../edit.js";
3
3
  import { exportHtml } from "../export.js";
4
+ import { ops } from "../ops.js";
4
5
  import { importBlocks, importHtml } from "../import.js";
5
6
  import { inlineLength } from "../model.js";
6
7
  import { pasteBlocks } from "../paste.js";
7
8
  import { cellChain, getContent, setContent } from "../path.js";
8
9
  import { tableFromText } from "../tablefill.js";
9
10
  import { imageSelection } from "../imageselect.js";
10
- import { ImageSelection } from "./imageview.js";
11
- import { TableGrips } from "./tablegrips.js";
12
- import { CellMarks, installColumnResize } from "./tableview.js";
13
11
  import { containerAt, createRenderCache, domFromPos, posFromDom, renderDoc } from "./render.js";
14
12
  const INPUT_COMMANDS = {
15
13
  formatSubscript: ["format.subscript"], formatSuperscript: ["format.superscript"],
@@ -43,9 +41,8 @@ export class Editor {
43
41
  }
44
42
  });
45
43
  cleanups = [];
46
- cellMarks;
47
- imageSelection;
48
- tableGrips;
44
+ changeListeners = new Set();
45
+ viewHost;
49
46
  constructor(options) {
50
47
  this.element = options.element;
51
48
  this.loadRemoteImages = options.loadRemoteImages ?? false;
@@ -57,14 +54,17 @@ export class Editor {
57
54
  ...(options.maxLength !== undefined ? { maxLength: options.maxLength } : {}),
58
55
  ...(options.onMaxLength ? { onMaxLength: options.onMaxLength } : {}),
59
56
  });
60
- this.cellMarks = new CellMarks(this.element, this.engine, () => this.observer.takeRecords());
61
- this.imageSelection = new ImageSelection(this.element, this.engine, () => this.observer.takeRecords());
62
- this.cleanups.push(() => this.imageSelection.destroy());
63
- this.tableGrips = new TableGrips(this.element, this.engine);
64
- this.cleanups.push(() => this.tableGrips.destroy());
57
+ this.viewHost = {
58
+ element: this.element, engine: this.engine, ops,
59
+ listen: (target, type, handler) => this.listen(target, type, handler),
60
+ quiet: () => { this.observer.takeRecords(); },
61
+ onChange: (listener) => { this.changeListeners.add(listener); return () => this.changeListeners.delete(listener); },
62
+ posFromDom: (node, offset) => posFromDom(this.element, node, offset),
63
+ domFromPos: (pos) => domFromPos(this.element, pos),
64
+ };
65
65
  this.engine.pasteHandlers.push(...(options.pasteHandlers ?? []));
66
66
  for (const plugin of options.plugins ?? [])
67
- this.engine.use(plugin);
67
+ this.use(plugin);
68
68
  this.engine.setDocument(importHtml(options.html ?? "", this.engine.importOptions));
69
69
  this.element.setAttribute("contenteditable", this.engine.readOnly ? "false" : "true");
70
70
  this.element.setAttribute("role", "textbox");
@@ -82,14 +82,10 @@ export class Editor {
82
82
  this.writeSelection();
83
83
  const focusMoved = snapshot.focused !== wasFocused;
84
84
  wasFocused = snapshot.focused;
85
- if (change.docChanged || change.selectionChanged) {
86
- this.cellMarks.update();
87
- this.imageSelection.update();
88
- }
89
85
  if (change.docChanged || change.selectionChanged || focusMoved)
90
- this.tableGrips.update();
86
+ for (const listener of [...this.changeListeners])
87
+ listener({ ...change, focusMoved });
91
88
  }));
92
- installColumnResize(this.element, this.engine, (target, type, handler) => this.listen(target, type, handler), () => this.observer.takeRecords());
93
89
  this.listen(this.element, "focusin", () => this.engine.setFocused(true));
94
90
  this.listen(this.element, "focusout", () => this.engine.setFocused(false));
95
91
  this.listen(this.element, "beforeinput", (e) => this.onBeforeInput(e));
@@ -140,7 +136,12 @@ export class Editor {
140
136
  get snapshot() { return this.engine.snapshot; }
141
137
  subscribe = (listener) => this.engine.subscribe(listener);
142
138
  select = (selector, onChange, equals) => this.engine.select(selector, onChange, equals);
143
- use = (plugin) => this.engine.use(plugin);
139
+ use = (plugin) => {
140
+ const remove = this.engine.use(plugin);
141
+ const stop = plugin.view?.(this.viewHost);
142
+ return () => { if (stop)
143
+ stop(); remove(); };
144
+ };
144
145
  getHtml(separator = "") { return exportHtml(this.engine.document, separator); }
145
146
  remoteImages() {
146
147
  const found = [];
@@ -494,13 +495,30 @@ export class Editor {
494
495
  types: [...clipboard.types],
495
496
  });
496
497
  }
498
+ copyFormats() {
499
+ const blocks = extractSelection(this.engine.document, this.engine.selection);
500
+ const formats = { "text/html": exportHtml({ blocks }), "text/plain": selectedText(this.engine.document, this.engine.selection) };
501
+ for (const formatter of this.engine.copyFormatters) {
502
+ let added;
503
+ try {
504
+ added = formatter(blocks);
505
+ }
506
+ catch (error) {
507
+ this.engine.warn(`A copy formatter failed: ${String(error)}`);
508
+ }
509
+ for (const [type, value] of Object.entries(added ?? {}))
510
+ if (/^[a-z]+\/[a-z0-9.+-]+$/i.test(type) && typeof value === "string")
511
+ formats[type] = value;
512
+ }
513
+ return formats;
514
+ }
497
515
  onCopy(event, cut) {
498
516
  this.readSelection();
499
517
  if (isCollapsed(this.engine.selection) || !event.clipboardData)
500
518
  return;
501
519
  event.preventDefault();
502
- event.clipboardData.setData("text/html", exportHtml({ blocks: extractSelection(this.engine.document, this.engine.selection) }));
503
- event.clipboardData.setData("text/plain", selectedText(this.engine.document, this.engine.selection));
520
+ for (const [type, value] of Object.entries(this.copyFormats()))
521
+ event.clipboardData.setData(type, value);
504
522
  if (cut && !this.engine.readOnly)
505
523
  this.apply("cut", deleteSelection);
506
524
  }
@@ -515,8 +533,8 @@ export class Editor {
515
533
  this.readSelection();
516
534
  if (isCollapsed(this.engine.selection) || !event.dataTransfer)
517
535
  return;
518
- event.dataTransfer.setData("text/html", exportHtml({ blocks: extractSelection(this.engine.document, this.engine.selection) }));
519
- event.dataTransfer.setData("text/plain", selectedText(this.engine.document, this.engine.selection));
536
+ for (const [type, value] of Object.entries(this.copyFormats()))
537
+ event.dataTransfer.setData(type, value);
520
538
  event.dataTransfer.effectAllowed = this.engine.readOnly ? "copy" : "copyMove";
521
539
  this.dragSource = this.engine.selection;
522
540
  }
package/dist/edit.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { inlineLength, inlineText, normalizeInline, } from "./model.js";
2
2
  import { fillTable } from "./tablefill.js";
3
- import { blockContainers, caret, cellChain, clampPos, comparePath, containerPaths, fixup, getContent, hasContainer, inBlockList, isCollapsed, kindAt, mapBlockList, mapListOf, pathKey, removeEntry, sameCells, setContent, } from "./path.js";
3
+ import { asChildren, blockContainers, caret, cellChain, clampPos, comparePath, containerPaths, fixup, getContent, hasContainer, inBlockList, isCollapsed, kindAt, mapBlockList, mapListOf, pathKey, removeEntry, sameCells, setContent, } from "./path.js";
4
4
  export { caret, clampPos, isCollapsed };
5
5
  export function startOf(doc) {
6
6
  return { path: containerPaths(doc)[0] ?? [0], offset: 0 };
@@ -233,6 +233,9 @@ function joinWithPrevious(doc, into, from) {
233
233
  const intoKind = kindAt(doc, into);
234
234
  const fromKind = kindAt(doc, from.path);
235
235
  const joinable = (k) => k === "p" || k === "h" || k === "li" || k === "dt" || k === "dd";
236
+ if ((!joinable(intoKind) || !joinable(fromKind)) && inlineLength(getContent(doc, from.path)) === 0 && (fromKind === "p" || fromKind === "h" || fromKind === "pre") && intoKind !== undefined) {
237
+ return { doc: removeContainer(doc, from.path), selection: caret({ path: into, offset: inlineLength(getContent(doc, into)) }) };
238
+ }
236
239
  if (!joinable(intoKind) || !joinable(fromKind))
237
240
  return { doc, selection: caret(from) };
238
241
  const head = getContent(doc, into);
@@ -463,8 +466,8 @@ export function extractSelection(doc, sel) {
463
466
  const items = list.items.flatMap((item) => {
464
467
  const ordinal = n++;
465
468
  const content = inRange(ordinal) ? cut(item.content, ordinal) : [];
466
- const sublists = item.sublists.map(cropList).filter((l) => l !== undefined);
467
- return inRange(ordinal) || sublists.length > 0 ? [{ content, sublists }] : [];
469
+ const children = asChildren(cropBlocks(item.children));
470
+ return inRange(ordinal) || children.length > 0 ? [{ content, children, ...(item.checked !== undefined ? { checked: item.checked } : {}), ...(item.id !== undefined ? { id: item.id } : {}) }] : [];
468
471
  });
469
472
  return items.length > 0 ? { ...list, items } : undefined;
470
473
  };
package/dist/engine.d.ts CHANGED
@@ -7,7 +7,7 @@ import { type EditResult, type Selection } from "./path.ts";
7
7
  import type { ImportOptions } from "./import.ts";
8
8
  import { type Counts } from "./stats.ts";
9
9
  import { UiRegistry } from "./ui.ts";
10
- import { type FileHandler, type PasteTransform, type Plugin } from "./plugin.ts";
10
+ import { type CopyFormatter, type FileHandler, type Normalizer, type PasteTransform, type Plugin } from "./plugin.ts";
11
11
  export interface EditorSnapshot {
12
12
  /** Goes up on every change to the document, selection, read-only or focus state. */
13
13
  readonly version: number;
@@ -61,6 +61,10 @@ export declare class Engine {
61
61
  private readonly textTriggers;
62
62
  /** Plugins that change pasted blocks before they go in. */
63
63
  readonly pasteTransforms: PasteTransform[];
64
+ /** Plugins that add formats to what copy, cut and drag write. */
65
+ readonly copyFormatters: CopyFormatter[];
66
+ /** Plugins that keep the document consistent after every edit (footnotes keep their notes with their references). */
67
+ readonly normalizers: Normalizer[];
64
68
  /** Plugins that turn pasted and dropped files into blocks. */
65
69
  readonly fileHandlers: FileHandler[];
66
70
  /** How HTML is imported here: what plugins have added (an SVG cleaner, recognizers). Pass it to the importer. */
@@ -117,6 +121,8 @@ export declare class Engine {
117
121
  * `kind` ("type", "delete") share one step. False when nothing changed, or when the editor is read-only.
118
122
  */
119
123
  apply(kind: string, op: (doc: Doc, sel: Selection) => EditResult): boolean;
124
+ /** Lets the normalizers put right what an edit left, so the repair is part of the same history step. */
125
+ private settle;
120
126
  /** Sets the selection without making a history step. Nothing happens if it is the selection already. */
121
127
  setSelection(selection: Selection): void;
122
128
  /** Keeps a plugin's own state with the editor, so commands and interfaces read it from the snapshot. Changes the snapshot; not an undo step. */
package/dist/engine.js CHANGED
@@ -17,6 +17,8 @@ export class Engine {
17
17
  behaviors = {};
18
18
  textTriggers = [];
19
19
  pasteTransforms = [];
20
+ copyFormatters = [];
21
+ normalizers = [];
20
22
  fileHandlers = [];
21
23
  importOptions = { recognizers: [] };
22
24
  doc;
@@ -123,7 +125,7 @@ export class Engine {
123
125
  return false;
124
126
  this.refresh();
125
127
  const before = { doc: this.doc, selection: this.sel };
126
- const result = op(this.doc, this.sel);
128
+ const result = this.settle(op(this.doc, this.sel));
127
129
  if (result.doc === this.doc && sameSelection(result.selection, this.sel))
128
130
  return false;
129
131
  const docChanged = result.doc !== this.doc;
@@ -149,6 +151,9 @@ export class Engine {
149
151
  this.commit({ docChanged, selectionChanged: !sameSelection(before.selection, this.sel) });
150
152
  return true;
151
153
  }
154
+ settle(result) {
155
+ return result.doc === this.doc ? result : this.normalizers.reduce((r, normalize) => normalize(r.doc, r.selection), result);
156
+ }
152
157
  setSelection(selection) {
153
158
  if (sameSelection(selection, this.sel))
154
159
  return;
@@ -184,8 +189,9 @@ export class Engine {
184
189
  this.commit({ docChanged: false, selectionChanged: false });
185
190
  }
186
191
  setDocument(doc, selection) {
187
- this.doc = doc;
188
- this.sel = selection ?? caret(startOf(doc));
192
+ const settled = this.settle({ doc, selection: selection ?? caret(startOf(doc)) });
193
+ this.doc = settled.doc;
194
+ this.sel = settled.selection;
189
195
  this.undoStack = [];
190
196
  this.redoStack = [];
191
197
  this.lastKind = "";
@@ -289,6 +295,11 @@ export class Engine {
289
295
  this.importOptions.recognizers = this.importOptions.recognizers.filter((r) => !added.includes(r));
290
296
  });
291
297
  },
298
+ normalize: (normalizer) => {
299
+ this.normalizers.push(normalizer);
300
+ undo.push(() => { const i = this.normalizers.indexOf(normalizer); if (i !== -1)
301
+ this.normalizers.splice(i, 1); });
302
+ },
292
303
  pasteTransform: (transform) => {
293
304
  this.pasteTransforms.push(transform);
294
305
  undo.push(() => { const i = this.pasteTransforms.indexOf(transform); if (i !== -1)
@@ -300,6 +311,11 @@ export class Engine {
300
311
  undo.push(() => { const i = this.fileHandlers.indexOf(handler); if (i !== -1)
301
312
  this.fileHandlers.splice(i, 1); });
302
313
  },
314
+ copyFormat: (formatter) => {
315
+ this.copyFormatters.push(formatter);
316
+ undo.push(() => { const i = this.copyFormatters.indexOf(formatter); if (i !== -1)
317
+ this.copyFormatters.splice(i, 1); });
318
+ },
303
319
  pasteHandler: (handler) => {
304
320
  this.pasteHandlers.push(handler);
305
321
  undo.push(() => { const i = this.pasteHandlers.indexOf(handler); if (i !== -1)
package/dist/export.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Block, Doc, Inline } from "./model.ts";
1
+ import { type Block, type Doc, type Inline } from "./model.ts";
2
2
  export declare function exportInline(content: readonly Inline[]): string;
3
3
  export declare function exportBlock(block: Block): string;
4
- export declare const exportHtml: (doc: Doc, separator?: string) => string;
4
+ export declare function exportHtml(doc: Doc, separator?: string): string;
package/dist/export.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { escapeAttribute, escapeText } from "./html/entities.js";
2
2
  import { lineCss, SIDES } from "./cellborder.js";
3
+ import { noteIds } from "./notes.js";
4
+ import { DEFAULT_MARKER } from "./model.js";
3
5
  const MARK_ORDER = ["link", "bold", "italic", "underline", "strike", "code", "sup", "sub", "color", "background"];
4
6
  const TAGS = { bold: "strong", italic: "em", underline: "u", strike: "s", code: "code", sup: "sup", sub: "sub" };
5
7
  function openClose(mark) {
@@ -28,11 +30,14 @@ function exportImage(node, placed) {
28
30
  + `${node.width !== undefined ? ` width="${node.width}"` : ""}${node.height !== undefined ? ` height="${node.height}"` : ""}`
29
31
  + `${placed && node.align ? ` style="${IMAGE_ALIGN_STYLE[node.align]}"` : ""}${placed && node.caption ? ` data-caption="${escapeAttribute(node.caption)}"` : ""}>`;
30
32
  }
33
+ let numbers = [];
31
34
  export function exportInline(content) {
32
35
  let out = "";
33
36
  content.forEach((node, i) => {
34
37
  if (node.type === "br")
35
38
  out += "<br>";
39
+ else if (node.type === "note")
40
+ out += `<sup data-note="${node.id}"><a href="#fn-${node.id}" id="fnref-${node.id}" role="doc-noteref">${numbers.indexOf(node.id) + 1 || ""}</a></sup>`;
36
41
  else if (node.type === "image")
37
42
  out += exportImage(node, true);
38
43
  else {
@@ -52,12 +57,11 @@ const blockStyle = (block) => {
52
57
  return parts.length > 0 ? ` style="${parts.join("; ")};"` : "";
53
58
  };
54
59
  const dirAttr = (dir) => (dir ? ` dir="${dir}"` : "");
55
- const DEFAULT_MARKER = (type, depth) => (type === "ol" ? "decimal" : ["disc", "circle", "square"][Math.min(depth, 2)]);
56
60
  function exportList(list, depth) {
57
61
  const style = list.style !== undefined && list.style !== DEFAULT_MARKER(list.type, depth) ? ` style="list-style-type: ${list.style};"` : "";
58
62
  const start = list.type === "ol" && list.start !== undefined ? ` start="${list.start}"` : "";
59
63
  const items = list.items.map((item) => {
60
- const noMarker = item.checked !== undefined || (item.content.length === 0 && item.sublists.length > 0);
64
+ const noMarker = item.checked !== undefined || (item.content.length === 0 && (item.children[0]?.type === "ul" || item.children[0]?.type === "ol"));
61
65
  const box = item.checked === undefined ? "" : `<input type="checkbox" disabled${item.checked ? " checked" : ""}> `;
62
66
  const paragraphs = [[]];
63
67
  for (const node of item.content) {
@@ -67,9 +71,12 @@ function exportList(list, depth) {
67
71
  paragraphs[paragraphs.length - 1].push(node);
68
72
  }
69
73
  const text = paragraphs.length === 1 ? exportInline(item.content) : paragraphs.map((part, i) => `<p>${i === 0 ? box : ""}${exportInline(part)}</p>`).join("");
70
- return `<li${noMarker ? ' style="list-style-type: none;"' : ""}>${paragraphs.length === 1 ? box : ""}${text}${item.sublists.map((sub) => exportList(sub, depth + 1)).join("")}</li>`;
71
- }).join("");
72
- return `<${list.type}${start}${style}>${items}</${list.type}>`;
74
+ const inner = `${paragraphs.length === 1 ? box : ""}${text}${item.children.map((child) => (child.type === "ul" || child.type === "ol" ? exportList(child, depth + 1) : exportBlock(child))).join("")}`;
75
+ return list.notes ? inner : `<li${noMarker ? ' style="list-style-type: none;"' : ""}>${inner}</li>`;
76
+ });
77
+ if (list.notes)
78
+ return `<section class="footnotes" role="doc-endnotes"><ol>${list.items.map((item, i) => `<li id="fn-${item.id}" data-note="${item.id}">${items[i]} <a href="#fnref-${item.id}" role="doc-backlink">\u21A9\uFE0E</a></li>`).join("")}</ol></section>`;
79
+ return `<${list.type}${start}${style}>${items.join("")}</${list.type}>`;
73
80
  }
74
81
  function cellStyle(cell) {
75
82
  const parts = [];
@@ -116,4 +123,13 @@ export function exportBlock(block) {
116
123
  case "hr": return "<hr>";
117
124
  }
118
125
  }
119
- export const exportHtml = (doc, separator = "") => doc.blocks.map(exportBlock).join(separator);
126
+ export function exportHtml(doc, separator = "") {
127
+ const outer = numbers;
128
+ numbers = noteIds(doc);
129
+ try {
130
+ return doc.blocks.map(exportBlock).join(separator);
131
+ }
132
+ finally {
133
+ numbers = outer;
134
+ }
135
+ }
@@ -0,0 +1,2 @@
1
+ export { footnotes, type FootnotesOptions } from "./plugin.ts";
2
+ export { createReconciler } from "./reconcile.ts";
@@ -0,0 +1,2 @@
1
+ export { footnotes } from "./plugin.js";
2
+ export { createReconciler } from "./reconcile.js";
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from "../plugin.ts";
2
+ export interface FootnotesOptions {
3
+ /** Ctrl+Alt+F inserts a note, Ctrl+Alt+Shift+F jumps between a reference and its note. Default on. */
4
+ readonly keys?: boolean;
5
+ }
6
+ export declare function footnotes(options?: FootnotesOptions): Plugin;
@@ -0,0 +1,130 @@
1
+ import { createReconciler } from "./reconcile.js";
2
+ function referenceAt(host, doc, id) {
3
+ const { ops } = host;
4
+ for (const path of ops.containerPaths(doc)) {
5
+ if (ops.isNotes(doc.blocks[path[0]]))
6
+ continue;
7
+ let offset = 0;
8
+ for (const node of ops.getContent(doc, path)) {
9
+ offset += node.type === "text" ? node.text.length : 1;
10
+ if (node.type === "note" && node.id === id)
11
+ return { path, offset };
12
+ }
13
+ }
14
+ return undefined;
15
+ }
16
+ function nearby(content, offset) {
17
+ let at = 0;
18
+ let found;
19
+ for (const node of content) {
20
+ const next = at + (node.type === "text" ? node.text.length : 1);
21
+ if (node.type === "note" && (next === offset || at === offset))
22
+ found = found ?? node.id;
23
+ at = next;
24
+ }
25
+ return found;
26
+ }
27
+ export function footnotes(options = {}) {
28
+ return {
29
+ id: "rathkit.footnotes",
30
+ api: "1",
31
+ setup(host) {
32
+ const { ops } = host;
33
+ const reconcile = createReconciler(ops);
34
+ host.normalize(reconcile);
35
+ const noteOf = (doc, path) => {
36
+ const block = doc.blocks[path[0]];
37
+ return block && ops.isNotes(block) && path.length >= 2 ? block.items[path[1]]?.id : undefined;
38
+ };
39
+ host.command({
40
+ name: "footnote.insert", label: "Footnote",
41
+ run: (ctx) => ctx.apply("footnote", (doc, sel) => {
42
+ const here = ops.isCollapsed(sel) ? sel.focus.path : sel.anchor.path;
43
+ if (ops.kindAt(doc, here) === "pre" || noteOf(doc, here) !== undefined || ops.isNotes(doc.blocks[here[0]]))
44
+ return { doc, selection: sel };
45
+ const cleared = ops.deleteSelection(doc, sel);
46
+ const pos = cleared.selection.anchor;
47
+ if (ops.kindAt(cleared.doc, pos.path) === "pre")
48
+ return { doc, selection: sel };
49
+ const used = new Set(ops.noteIds(cleared.doc));
50
+ let n = used.size + 1;
51
+ while (used.has(`n${n}`))
52
+ n++;
53
+ const id = `n${n}`;
54
+ const content = ops.getContent(cleared.doc, pos.path);
55
+ const next = ops.normalizeInline([...ops.sliceInline(content, 0, pos.offset), { type: "note", id }, ...ops.sliceInline(content, pos.offset)]);
56
+ const placed = reconcile(ops.setContent(cleared.doc, pos.path, next), ops.caret({ path: pos.path, offset: pos.offset + 1 }));
57
+ const block = placed.doc.blocks.findIndex((b) => ops.isNotes(b));
58
+ const list = placed.doc.blocks[block];
59
+ const index = list && ops.isNotes(list) ? list.items.findIndex((item) => item.id === id) : -1;
60
+ return index === -1 ? placed : { doc: placed.doc, selection: ops.caret({ path: [block, index], offset: 0 }) };
61
+ }),
62
+ state: (snap) => {
63
+ const path = snap.selection.focus.path;
64
+ return { enabled: ops.kindAt(snap.doc, path) !== "pre" && noteOf(snap.doc, path) === undefined };
65
+ },
66
+ });
67
+ host.command({
68
+ name: "footnote.goto", label: "Go to footnote",
69
+ mutates: false,
70
+ run: (ctx) => {
71
+ const snap = ctx.snapshot;
72
+ const { path, offset } = snap.selection.focus;
73
+ const inNote = noteOf(snap.doc, path);
74
+ const id = inNote ?? nearby(ops.getContent(snap.doc, path), offset);
75
+ if (id === undefined)
76
+ return false;
77
+ const target = inNote !== undefined ? referenceAt(host, snap.doc, id) : (() => {
78
+ const b = snap.doc.blocks.findIndex((block) => ops.isNotes(block));
79
+ const list = snap.doc.blocks[b];
80
+ const i = list && ops.isNotes(list) ? list.items.findIndex((item) => item.id === id) : -1;
81
+ return i === -1 ? undefined : { path: [b, i], offset: 0 };
82
+ })();
83
+ if (!target)
84
+ return false;
85
+ const there = ops.caret({ path: target.path, offset: target.offset });
86
+ return ctx.apply("footnote-goto", (doc) => ({ doc, selection: there }));
87
+ },
88
+ state: (snap) => {
89
+ const { path, offset } = snap.selection.focus;
90
+ return { enabled: noteOf(snap.doc, path) !== undefined || nearby(ops.getContent(snap.doc, path), offset) !== undefined };
91
+ },
92
+ });
93
+ host.copyFormat((blocks) => {
94
+ const wanted = ops.noteIds({ blocks });
95
+ const list = host.editor.snapshot.doc.blocks.find((b) => ops.isNotes(b));
96
+ if (wanted.length === 0 || !list || !ops.isNotes(list) || blocks.some((b) => ops.isNotes(b)))
97
+ return undefined;
98
+ const items = wanted.flatMap((id) => list.items.filter((item) => item.id === id));
99
+ return items.length === 0 ? undefined : { "text/html": ops.exportHtml({ blocks: [...blocks, { ...list, items }] }) };
100
+ });
101
+ host.pasteTransform((blocks) => {
102
+ const pasted = blocks.flatMap((b) => (ops.isNotes(b) ? b.items.map((item) => item.id ?? "") : []));
103
+ const taken = new Set([...ops.noteIds(host.editor.snapshot.doc), ...ops.noteIds({ blocks })]);
104
+ const rename = new Map();
105
+ let n = taken.size;
106
+ for (const id of pasted) {
107
+ if (id === "" || rename.has(id) || !ops.noteIds(host.editor.snapshot.doc).includes(id))
108
+ continue;
109
+ while (taken.has(`n${++n}`)) { }
110
+ taken.add(`n${n}`);
111
+ rename.set(id, `n${n}`);
112
+ }
113
+ if (rename.size === 0)
114
+ return blocks;
115
+ let doc = { blocks: blocks.map((b) => (ops.isNotes(b) ? { ...b, items: b.items.map((item) => (item.id !== undefined && rename.has(item.id) ? { ...item, id: rename.get(item.id) } : item)) } : b)) };
116
+ for (const path of ops.containerPaths(doc)) {
117
+ const content = ops.getContent(doc, path);
118
+ if (content.some((node) => node.type === "note" && rename.has(node.id)))
119
+ doc = ops.setContent(doc, path, content.map((node) => (node.type === "note" && rename.has(node.id) ? { type: "note", id: rename.get(node.id) } : node)));
120
+ }
121
+ return doc.blocks;
122
+ });
123
+ if (options.keys ?? true) {
124
+ host.keybinding({ key: "Mod-Alt-f", command: "footnote.insert" });
125
+ host.keybinding({ key: "Mod-Alt-Shift-f", command: "footnote.goto" });
126
+ }
127
+ host.ui("toolbar.main", { kind: "button", id: "footnote", command: "footnote.insert", icon: "footnote", group: "insert", order: 4 });
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,4 @@
1
+ import type { Doc } from "../model.ts";
2
+ import type { Ops } from "../ops.ts";
3
+ import type { EditResult, Selection } from "../path.ts";
4
+ export declare function createReconciler(ops: Ops): (doc: Doc, sel: Selection) => EditResult;