@rathkit/rte-core 0.1.0-alpha.3 → 0.1.0-alpha.5

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 (71) hide show
  1. package/README.md +1 -1
  2. package/dist/autoformat/autoformat.js +18 -3
  3. package/dist/base64.js +5 -1
  4. package/dist/dom/index.d.ts +1 -1
  5. package/dist/dom/render.js +26 -15
  6. package/dist/dom/view.d.ts +59 -6
  7. package/dist/dom/view.js +195 -38
  8. package/dist/edit.d.ts +6 -0
  9. package/dist/edit.js +101 -11
  10. package/dist/engine.d.ts +26 -4
  11. package/dist/engine.js +113 -23
  12. package/dist/export.d.ts +2 -2
  13. package/dist/export.js +22 -6
  14. package/dist/footnotes/index.d.ts +2 -0
  15. package/dist/footnotes/index.js +2 -0
  16. package/dist/footnotes/plugin.d.ts +6 -0
  17. package/dist/footnotes/plugin.js +130 -0
  18. package/dist/footnotes/reconcile.d.ts +4 -0
  19. package/dist/footnotes/reconcile.js +155 -0
  20. package/dist/html/parse.d.ts +8 -2
  21. package/dist/html/parse.js +6 -2
  22. package/dist/images/images.js +4 -3
  23. package/dist/images/index.d.ts +5 -1
  24. package/dist/images/index.js +3 -1
  25. package/dist/images/plugin.js +2 -1
  26. package/dist/{dom/imageview.d.ts → images/view.d.ts} +9 -6
  27. package/dist/{dom/imageview.js → images/view.js} +20 -14
  28. package/dist/import.d.ts +18 -2
  29. package/dist/import.js +87 -42
  30. package/dist/index.d.ts +4 -1
  31. package/dist/index.js +3 -1
  32. package/dist/lists/lists.js +55 -30
  33. package/dist/markdown/detect.d.ts +1 -0
  34. package/dist/markdown/detect.js +81 -0
  35. package/dist/markdown/export.d.ts +10 -0
  36. package/dist/markdown/export.js +252 -0
  37. package/dist/markdown/index.d.ts +5 -0
  38. package/dist/markdown/index.js +4 -0
  39. package/dist/markdown/inline.d.ts +37 -0
  40. package/dist/markdown/inline.js +604 -0
  41. package/dist/markdown/parse.d.ts +4 -0
  42. package/dist/markdown/parse.js +586 -0
  43. package/dist/markdown/plugin.d.ts +20 -0
  44. package/dist/markdown/plugin.js +56 -0
  45. package/dist/model.d.ts +30 -2
  46. package/dist/model.js +2 -0
  47. package/dist/notes.d.ts +6 -0
  48. package/dist/notes.js +49 -0
  49. package/dist/ops.d.ts +6 -1
  50. package/dist/ops.js +5 -4
  51. package/dist/paste.js +9 -2
  52. package/dist/path.d.ts +10 -1
  53. package/dist/path.js +110 -11
  54. package/dist/plugin.d.ts +53 -2
  55. package/dist/policy.d.ts +41 -0
  56. package/dist/policy.js +40 -0
  57. package/dist/standard/index.d.ts +5 -3
  58. package/dist/standard/index.js +6 -5
  59. package/dist/stats.js +1 -1
  60. package/dist/svg/svg.js +9 -1
  61. package/dist/{dom/tableview.d.ts → tables/cells.d.ts} +7 -6
  62. package/dist/{dom/tableview.js → tables/cells.js} +16 -12
  63. package/dist/{dom/tablegrips.d.ts → tables/grips.d.ts} +5 -4
  64. package/dist/{dom/tablegrips.js → tables/grips.js} +10 -9
  65. package/dist/tables/index.d.ts +5 -1
  66. package/dist/tables/index.js +3 -1
  67. package/dist/tables/view.d.ts +2 -0
  68. package/dist/tables/view.js +15 -0
  69. package/dist/warning.d.ts +16 -0
  70. package/dist/warning.js +5 -0
  71. package/package.json +23 -1
package/dist/engine.js CHANGED
@@ -3,8 +3,10 @@ import { detectPlatform, Keymap } from "./keymap.js";
3
3
  import { emptyDoc } from "./model.js";
4
4
  import { caret, isCollapsed, pathKey } from "./path.js";
5
5
  import { startOf } from "./edit.js";
6
+ import { contentPolicy } from "./policy.js";
6
7
  import { countDocument, countSelection, textLength } from "./stats.js";
7
8
  import { UiRegistry } from "./ui.js";
9
+ import { describeError } from "./warning.js";
8
10
  import { PLUGIN_API_VERSION, satisfiesApi } from "./plugin.js";
9
11
  import { ops } from "./ops.js";
10
12
  const sameSelection = (a, b) => a === b || (a.anchor.offset === b.anchor.offset && a.focus.offset === b.focus.offset && pathKey(a.anchor.path) === pathKey(b.anchor.path) && pathKey(a.focus.path) === pathKey(b.focus.path));
@@ -17,6 +19,8 @@ export class Engine {
17
19
  behaviors = {};
18
20
  textTriggers = [];
19
21
  pasteTransforms = [];
22
+ copyFormatters = [];
23
+ normalizers = [];
20
24
  fileHandlers = [];
21
25
  importOptions = { recognizers: [] };
22
26
  doc;
@@ -40,7 +44,8 @@ export class Engine {
40
44
  focusRequest;
41
45
  maxLength;
42
46
  onMaxLength;
43
- warn;
47
+ report;
48
+ warn = (message) => this.report({ code: "plugin-message", message });
44
49
  constructor(options = {}) {
45
50
  this.doc = options.doc ?? emptyDoc;
46
51
  this.sel = caret(startOf(this.doc));
@@ -49,8 +54,19 @@ export class Engine {
49
54
  this.focusRequest = options.focus;
50
55
  this.maxLength = options.maxLength;
51
56
  this.onMaxLength = options.onMaxLength;
52
- this.warn = options.onWarning ?? ((message) => console.warn(`[rte] ${message}`));
53
- this.keymap = new Keymap(options.platform ?? detectPlatform(), this.warn);
57
+ const handler = options.onWarning ?? ((w) => console.warn(`[rte] ${w.code}: ${w.message}`));
58
+ this.report = (warning) => { try {
59
+ handler(warning);
60
+ }
61
+ catch { } };
62
+ const policy = contentPolicy(options.content, (message) => this.report({ code: "bad-option", message }));
63
+ Object.assign(this.importOptions, {
64
+ linkSchemes: policy.linkSchemes, styles: policy.styles, maxDataImageLength: policy.maxDataImageLength,
65
+ maxDepth: policy.maxDepth, maxInputLength: policy.maxInputLength, pasteRelativeImages: policy.pasteRelativeImages,
66
+ onLimit: (info) => this.report({ code: "content-too-long", message: `Content of ${info.length} characters was not read: the limit is ${info.max} (content.maxInputLength).` }),
67
+ onError: (error) => this.report({ code: "content-unreadable", message: `Content could not be read and was left out: ${describeError(error)}`, error }),
68
+ });
69
+ this.keymap = new Keymap(options.platform ?? detectPlatform(), (message) => this.report({ code: "key-conflict", message }));
54
70
  this.commands = new CommandRegistry(this);
55
71
  this.current = this.makeSnapshot();
56
72
  this.notified = this.current;
@@ -102,12 +118,12 @@ export class Engine {
102
118
  return;
103
119
  this.notified = snapshot;
104
120
  for (const listener of [...this.listeners])
105
- listener(snapshot, previous);
121
+ this.attempt(undefined, "a change listener", () => listener(snapshot, previous), undefined, "listener-failed");
106
122
  for (const sub of [...this.selectors]) {
107
123
  const value = sub.select(snapshot);
108
124
  if (!sub.equals(value, sub.last)) {
109
125
  sub.last = value;
110
- sub.onChange(value);
126
+ this.attempt(undefined, "a selector callback", () => sub.onChange(value), undefined, "listener-failed");
111
127
  }
112
128
  }
113
129
  this.commands.flush();
@@ -123,7 +139,7 @@ export class Engine {
123
139
  return false;
124
140
  this.refresh();
125
141
  const before = { doc: this.doc, selection: this.sel };
126
- const result = op(this.doc, this.sel);
142
+ const result = this.settle(op(this.doc, this.sel));
127
143
  if (result.doc === this.doc && sameSelection(result.selection, this.sel))
128
144
  return false;
129
145
  const docChanged = result.doc !== this.doc;
@@ -143,16 +159,23 @@ export class Engine {
143
159
  }
144
160
  this.lastKind = kind;
145
161
  this.redoStack = [];
162
+ const top = this.undoStack[this.undoStack.length - 1];
163
+ if (top)
164
+ top.after = result.selection;
146
165
  }
147
166
  this.doc = result.doc;
148
167
  this.sel = result.selection;
149
168
  this.commit({ docChanged, selectionChanged: !sameSelection(before.selection, this.sel) });
150
169
  return true;
151
170
  }
171
+ settle(result) {
172
+ return result.doc === this.doc ? result : this.normalizers.reduce((r, normalize) => normalize(r.doc, r.selection), result);
173
+ }
152
174
  setSelection(selection) {
153
175
  if (sameSelection(selection, this.sel))
154
176
  return;
155
177
  this.sel = selection;
178
+ this.lastKind = "";
156
179
  this.commit({ docChanged: false, selectionChanged: true });
157
180
  }
158
181
  setPluginState(id, value) {
@@ -167,6 +190,7 @@ export class Engine {
167
190
  this.refresh();
168
191
  if (!isCollapsed(this.sel))
169
192
  return false;
193
+ this.lastKind = "";
170
194
  this.stored = { path: this.sel.focus.path, offset: this.sel.focus.offset, marks };
171
195
  this.commit({ docChanged: false, selectionChanged: false });
172
196
  return true;
@@ -184,8 +208,9 @@ export class Engine {
184
208
  this.commit({ docChanged: false, selectionChanged: false });
185
209
  }
186
210
  setDocument(doc, selection) {
187
- this.doc = doc;
188
- this.sel = selection ?? caret(startOf(doc));
211
+ const settled = this.settle({ doc, selection: selection ?? caret(startOf(doc)) });
212
+ this.doc = settled.doc;
213
+ this.sel = settled.selection;
189
214
  this.undoStack = [];
190
215
  this.redoStack = [];
191
216
  this.lastKind = "";
@@ -199,7 +224,7 @@ export class Engine {
199
224
  this.undoStack.push(level);
200
225
  return false;
201
226
  }
202
- this.redoStack.push({ doc: this.doc, selection: this.sel });
227
+ this.redoStack.push({ doc: this.doc, selection: level.after ?? this.sel });
203
228
  return this.restore(level);
204
229
  }
205
230
  redo() {
@@ -209,7 +234,7 @@ export class Engine {
209
234
  this.redoStack.push(level);
210
235
  return false;
211
236
  }
212
- this.undoStack.push({ doc: this.doc, selection: this.sel });
237
+ this.undoStack.push({ doc: this.doc, selection: this.sel, after: level.selection });
213
238
  return this.restore(level);
214
239
  }
215
240
  restore(level) {
@@ -249,6 +274,16 @@ export class Engine {
249
274
  }
250
275
  return undefined;
251
276
  }
277
+ attempt(plugin, where, fn, fallback, code = "plugin-failed") {
278
+ try {
279
+ return fn();
280
+ }
281
+ catch (error) {
282
+ const who = plugin ?? "a listener";
283
+ this.report({ code: plugin ? code : "listener-failed", ...(plugin ? { plugin } : {}), message: `${who} failed in ${where}; it was skipped: ${describeError(error)}`, error });
284
+ return fallback?.();
285
+ }
286
+ }
252
287
  use(plugin) {
253
288
  if (this.plugins.has(plugin.id))
254
289
  throw new Error(`The plugin ${plugin.id} is already registered.`);
@@ -256,10 +291,18 @@ export class Engine {
256
291
  throw new Error(`The plugin ${plugin.id} needs plugin API ${plugin.api}; this editor has ${PLUGIN_API_VERSION}.`);
257
292
  }
258
293
  const undo = [];
294
+ const safe = (where, fn, fallback) => ((...args) => this.attempt(plugin.id, where, () => fn(...args), () => fallback(...args)));
259
295
  const host = {
260
296
  editor: this,
261
297
  ops,
262
- command: (def) => { undo.push(this.commands.register(def)); },
298
+ command: (def) => {
299
+ const guarded = {
300
+ ...def,
301
+ run: safe(`the command ${def.name}`, def.run.bind(def), () => false),
302
+ ...(def.state ? { state: safe(`the state of ${def.name}`, def.state.bind(def), () => ({ enabled: false })) } : {}),
303
+ };
304
+ undo.push(this.commands.register(guarded));
305
+ },
263
306
  keybinding: (binding) => {
264
307
  this.keymap.add(binding, plugin.id);
265
308
  undo.push(() => this.keymap.remove(binding.command));
@@ -267,10 +310,15 @@ export class Engine {
267
310
  behavior: (kind, behavior) => {
268
311
  if (this.behaviors[kind])
269
312
  throw new Error(`The container ${kind} already has a behavior; ${plugin.id} cannot add another.`);
270
- this.behaviors[kind] = behavior;
313
+ this.behaviors[kind] = {
314
+ ...(behavior.split ? { split: safe(`the ${kind} split behavior`, behavior.split.bind(behavior), () => undefined) } : {}),
315
+ ...(behavior.backspaceAtStart ? { backspaceAtStart: safe(`the ${kind} backspace behavior`, behavior.backspaceAtStart.bind(behavior), () => undefined) } : {}),
316
+ ...(behavior.insertBlocks ? { insertBlocks: safe(`the ${kind} paste behavior`, behavior.insertBlocks.bind(behavior), () => undefined) } : {}),
317
+ };
271
318
  undo.push(() => { delete this.behaviors[kind]; });
272
319
  },
273
- textTrigger: (trigger) => {
320
+ textTrigger: (given) => {
321
+ const trigger = safe("a text trigger", given, () => undefined);
274
322
  this.textTriggers.push(trigger);
275
323
  undo.push(() => { const i = this.textTriggers.indexOf(trigger); if (i !== -1)
276
324
  this.textTriggers.splice(i, 1); });
@@ -279,41 +327,83 @@ export class Engine {
279
327
  if (options.sanitizeSvg) {
280
328
  if (this.importOptions.sanitizeSvg)
281
329
  throw new Error(`${plugin.id} cannot set an SVG cleaner: one is already set.`);
282
- this.importOptions.sanitizeSvg = options.sanitizeSvg;
330
+ this.importOptions.sanitizeSvg = safe("the SVG cleaner", options.sanitizeSvg, () => undefined);
283
331
  }
284
- const added = options.recognizers ?? [];
332
+ const added = (options.recognizers ?? []).map((r) => ({
333
+ ...(r.element ? { element: safe("a recognizer", r.element.bind(r), () => undefined) } : {}),
334
+ ...(r.listItemStyle ? { listItemStyle: safe("a recognizer", r.listItemStyle.bind(r), () => undefined) } : {}),
335
+ }));
285
336
  this.importOptions.recognizers = [...this.importOptions.recognizers, ...added];
286
337
  undo.push(() => {
287
- if (options.sanitizeSvg && this.importOptions.sanitizeSvg === options.sanitizeSvg)
338
+ if (options.sanitizeSvg)
288
339
  delete this.importOptions.sanitizeSvg;
289
340
  this.importOptions.recognizers = this.importOptions.recognizers.filter((r) => !added.includes(r));
290
341
  });
291
342
  },
292
- pasteTransform: (transform) => {
343
+ normalize: (given) => {
344
+ const normalizer = safe("a normalizer", given, (doc, selection) => ({ doc, selection }));
345
+ this.normalizers.push(normalizer);
346
+ undo.push(() => { const i = this.normalizers.indexOf(normalizer); if (i !== -1)
347
+ this.normalizers.splice(i, 1); });
348
+ },
349
+ pasteTransform: (given) => {
350
+ const transform = (blocks) => {
351
+ const failed = (error) => { this.report({ code: "plugin-failed", plugin: plugin.id, message: `${plugin.id} failed in a paste transform and was skipped: ${describeError(error)}`, error }); return blocks; };
352
+ try {
353
+ const out = given(blocks);
354
+ return out instanceof Promise ? out.catch(failed) : out;
355
+ }
356
+ catch (error) {
357
+ return failed(error);
358
+ }
359
+ };
293
360
  this.pasteTransforms.push(transform);
294
361
  undo.push(() => { const i = this.pasteTransforms.indexOf(transform); if (i !== -1)
295
362
  this.pasteTransforms.splice(i, 1); });
296
363
  },
297
364
  ui: (slot, item) => { undo.push(this.ui.add(slot, item)); },
298
- fileHandler: (handler) => {
365
+ fileHandler: (given) => {
366
+ const handler = async (files) => {
367
+ try {
368
+ return await given(files);
369
+ }
370
+ catch (error) {
371
+ this.report({ code: "plugin-failed", plugin: plugin.id, message: `${plugin.id} failed in a file handler and was skipped: ${describeError(error)}`, error });
372
+ return undefined;
373
+ }
374
+ };
299
375
  this.fileHandlers.push(handler);
300
376
  undo.push(() => { const i = this.fileHandlers.indexOf(handler); if (i !== -1)
301
377
  this.fileHandlers.splice(i, 1); });
302
378
  },
303
- pasteHandler: (handler) => {
379
+ copyFormat: (given) => {
380
+ const formatter = safe("a copy formatter", given, () => undefined);
381
+ this.copyFormatters.push(formatter);
382
+ undo.push(() => { const i = this.copyFormatters.indexOf(formatter); if (i !== -1)
383
+ this.copyFormatters.splice(i, 1); });
384
+ },
385
+ pasteHandler: (given) => {
386
+ const handler = safe("a paste handler", given, () => undefined);
304
387
  this.pasteHandlers.push(handler);
305
388
  undo.push(() => { const i = this.pasteHandlers.indexOf(handler); if (i !== -1)
306
389
  this.pasteHandlers.splice(i, 1); });
307
390
  },
308
391
  };
309
- const teardown = plugin.setup(host);
310
- if (teardown)
311
- undo.push(teardown);
312
392
  const remove = () => {
313
393
  for (const fn of undo.reverse())
314
- fn();
394
+ this.attempt(plugin.id, "taking the plugin away", fn, undefined);
315
395
  this.plugins.delete(plugin.id);
316
396
  };
397
+ try {
398
+ const teardown = plugin.setup(host);
399
+ if (teardown)
400
+ undo.push(teardown);
401
+ }
402
+ catch (error) {
403
+ remove();
404
+ this.report({ code: "plugin-failed", plugin: plugin.id, message: `${plugin.id} failed to start and was left out: ${describeError(error)}`, error });
405
+ return () => undefined;
406
+ }
317
407
  this.plugins.set(plugin.id, remove);
318
408
  return remove;
319
409
  }
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.bare === true && 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;