@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/dom/view.js CHANGED
@@ -1,15 +1,13 @@
1
1
  import { createEngine } from "../core.js";
2
- import { caret, clampPos, deleteBackward, deleteForward, deleteSelection, deleteToEdge, deleteWord, extractSelection, insertBlocks, insertLineBreak, insertText, isCollapsed, moveSelection, selectedText, splitBlock, transposeChars, } from "../edit.js";
2
+ import { caret, clampPos, deleteBackward, deleteForward, deleteSelection, deleteToEdge, deleteWord, extractSelection, insertBlocks, insertLineBreak, endAtBlockEnd, 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
- import { cellChain, getContent, setContent } from "../path.js";
8
+ import { cellChain, containerPaths, getContent, pathKey, 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"],
@@ -25,6 +23,10 @@ export class Editor {
25
23
  engine;
26
24
  composing = false;
27
25
  reading = false;
26
+ userReadOnly = false;
27
+ isDisabled = false;
28
+ ownTabindex = false;
29
+ savedTabindex = null;
28
30
  loadRemoteImages;
29
31
  unhandled;
30
32
  dragSource = null;
@@ -43,30 +45,44 @@ export class Editor {
43
45
  }
44
46
  });
45
47
  cleanups = [];
46
- cellMarks;
47
- imageSelection;
48
- tableGrips;
48
+ changeListeners = new Set();
49
+ replacing = false;
50
+ resyncs = new Set();
51
+ viewHost;
49
52
  constructor(options) {
50
53
  this.element = options.element;
51
54
  this.loadRemoteImages = options.loadRemoteImages ?? false;
52
55
  this.unhandled = options.onUnhandledInput;
53
56
  this.engine = createEngine({
54
- readOnly: options.readOnly ?? false,
57
+ readOnly: (options.readOnly ?? false) || (options.disabled ?? false),
55
58
  beforeAccess: () => this.readSelection(),
56
59
  focus: () => this.element.focus(),
60
+ ...(options.content ? { content: options.content } : {}),
61
+ ...(options.onWarning ? { onWarning: options.onWarning } : {}),
57
62
  ...(options.maxLength !== undefined ? { maxLength: options.maxLength } : {}),
58
63
  ...(options.onMaxLength ? { onMaxLength: options.onMaxLength } : {}),
59
64
  });
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());
65
+ this.viewHost = {
66
+ element: this.element, engine: this.engine, ops,
67
+ listen: (target, type, handler) => this.listen(target, type, handler),
68
+ quiet: () => { this.observer.takeRecords(); },
69
+ onChange: (listener) => { this.changeListeners.add(listener); return () => this.changeListeners.delete(listener); },
70
+ posFromDom: (node, offset) => posFromDom(this.element, node, offset),
71
+ domFromPos: (pos) => domFromPos(this.element, pos),
72
+ };
65
73
  this.engine.pasteHandlers.push(...(options.pasteHandlers ?? []));
66
- for (const plugin of options.plugins ?? [])
67
- this.engine.use(plugin);
74
+ for (const plugin of options.plugins ?? []) {
75
+ try {
76
+ this.use(plugin);
77
+ }
78
+ catch (error) {
79
+ this.engine.report({ code: "plugin-rejected", plugin: plugin.id, message: `${plugin.id} was not added: ${error instanceof Error ? error.message : String(error)}`, error });
80
+ }
81
+ }
68
82
  this.engine.setDocument(importHtml(options.html ?? "", this.engine.importOptions));
69
- this.element.setAttribute("contenteditable", this.engine.readOnly ? "false" : "true");
83
+ this.userReadOnly = options.readOnly ?? false;
84
+ this.isDisabled = options.disabled ?? false;
85
+ this.applyAccess();
70
86
  this.element.setAttribute("role", "textbox");
71
87
  this.element.setAttribute("aria-multiline", "true");
72
88
  this.element.style.whiteSpace = "pre-wrap";
@@ -82,14 +98,10 @@ export class Editor {
82
98
  this.writeSelection();
83
99
  const focusMoved = snapshot.focused !== wasFocused;
84
100
  wasFocused = snapshot.focused;
85
- if (change.docChanged || change.selectionChanged) {
86
- this.cellMarks.update();
87
- this.imageSelection.update();
88
- }
89
101
  if (change.docChanged || change.selectionChanged || focusMoved)
90
- this.tableGrips.update();
102
+ for (const listener of [...this.changeListeners])
103
+ listener({ ...change, focusMoved });
91
104
  }));
92
- installColumnResize(this.element, this.engine, (target, type, handler) => this.listen(target, type, handler), () => this.observer.takeRecords());
93
105
  this.listen(this.element, "focusin", () => this.engine.setFocused(true));
94
106
  this.listen(this.element, "focusout", () => this.engine.setFocused(false));
95
107
  this.listen(this.element, "beforeinput", (e) => this.onBeforeInput(e));
@@ -114,6 +126,15 @@ export class Editor {
114
126
  this.listen(this.element, "compositionstart", () => { this.composing = true; });
115
127
  this.listen(this.element, "compositionend", () => { this.composing = false; this.reconcileFromDom(); });
116
128
  this.listen(document, "selectionchange", () => this.readSelection());
129
+ this.listen(this.element, "pointerup", () => this.readSelection());
130
+ this.listen(this.element, "mouseup", (e) => {
131
+ if (e.detail >= 3) {
132
+ this.readSelection();
133
+ const trimmed = endAtBlockEnd(this.engine.document, this.engine.selection);
134
+ if (trimmed !== this.engine.selection)
135
+ this.engine.setSelection(trimmed);
136
+ }
137
+ });
117
138
  this.observer.observe(this.element, { subtree: true, childList: true, characterData: true, attributes: true });
118
139
  if (options.autofocus)
119
140
  this.focus();
@@ -134,13 +155,19 @@ export class Editor {
134
155
  get canUndo() { return this.engine.canUndo; }
135
156
  get canRedo() { return this.engine.canRedo; }
136
157
  get readOnly() { return this.engine.readOnly; }
158
+ get disabled() { return this.isDisabled; }
137
159
  get commands() { return this.engine.commands; }
138
160
  get ui() { return this.engine.ui; }
139
161
  get keymap() { return this.engine.keymap; }
140
162
  get snapshot() { return this.engine.snapshot; }
141
163
  subscribe = (listener) => this.engine.subscribe(listener);
142
164
  select = (selector, onChange, equals) => this.engine.select(selector, onChange, equals);
143
- use = (plugin) => this.engine.use(plugin);
165
+ use = (plugin) => {
166
+ const remove = this.engine.use(plugin);
167
+ const stop = this.engine.attempt(plugin.id, "its page part", () => plugin.view?.(this.viewHost), () => undefined);
168
+ return () => { if (stop)
169
+ this.engine.attempt(plugin.id, "taking its page part away", stop, undefined); remove(); };
170
+ };
144
171
  getHtml(separator = "") { return exportHtml(this.engine.document, separator); }
145
172
  remoteImages() {
146
173
  const found = [];
@@ -160,19 +187,98 @@ export class Editor {
160
187
  setPlaceholder(text) {
161
188
  if (text === "") {
162
189
  this.element.removeAttribute("aria-placeholder");
163
- this.element.style.removeProperty("--rk-placeholder");
190
+ this.element.style.removeProperty("--rte-placeholder");
164
191
  }
165
192
  else {
166
193
  this.element.setAttribute("aria-placeholder", text);
167
- this.element.style.setProperty("--rk-placeholder", `"${text.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\A ")}"`);
194
+ this.element.style.setProperty("--rte-placeholder", `"${text.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\A ")}"`);
168
195
  }
169
196
  }
170
197
  setHtml(html) {
171
- this.engine.setDocument(importHtml(html, this.engine.importOptions));
198
+ this.replacing = true;
199
+ try {
200
+ this.engine.setDocument(importHtml(html, this.engine.importOptions));
201
+ }
202
+ finally {
203
+ this.replacing = false;
204
+ }
205
+ for (const resync of this.resyncs)
206
+ resync();
207
+ }
208
+ on(event, listener) {
209
+ const given = listener;
210
+ const call = (...args) => this.engine.attempt(undefined, `your ${event} listener`, () => given(...args), undefined, "listener-failed");
211
+ let last = event === "change" ? this.getHtml() : "";
212
+ const resync = () => { if (event === "change")
213
+ last = this.getHtml(); };
214
+ const handler = (change) => {
215
+ if (this.replacing)
216
+ return;
217
+ if (event === "change") {
218
+ if (!change.docChanged)
219
+ return;
220
+ const html = this.getHtml();
221
+ if (html === last)
222
+ return;
223
+ last = html;
224
+ call(html);
225
+ }
226
+ else if (event === "selection") {
227
+ if (change.selectionChanged)
228
+ call(this.selection);
229
+ }
230
+ else if (change.focusMoved && this.engine.snapshot.focused === (event === "focus"))
231
+ call(this);
232
+ };
233
+ this.resyncs.add(resync);
234
+ this.changeListeners.add(handler);
235
+ return () => { this.resyncs.delete(resync); this.changeListeners.delete(handler); };
172
236
  }
173
237
  setReadOnly(value) {
174
- this.engine.setReadOnly(value);
175
- this.element.setAttribute("contenteditable", value ? "false" : "true");
238
+ this.userReadOnly = value;
239
+ this.applyAccess();
240
+ }
241
+ setDisabled(value) {
242
+ this.isDisabled = value;
243
+ this.applyAccess();
244
+ }
245
+ applyAccess() {
246
+ const el = this.element;
247
+ const off = this.userReadOnly || this.isDisabled;
248
+ this.engine.setReadOnly(off);
249
+ el.setAttribute("contenteditable", off ? "false" : "true");
250
+ if (this.userReadOnly && !this.isDisabled)
251
+ el.setAttribute("aria-readonly", "true");
252
+ else
253
+ el.removeAttribute("aria-readonly");
254
+ if (this.isDisabled)
255
+ el.setAttribute("aria-disabled", "true");
256
+ else
257
+ el.removeAttribute("aria-disabled");
258
+ if (off && !this.isDisabled) {
259
+ if (!el.hasAttribute("tabindex")) {
260
+ el.setAttribute("tabindex", "0");
261
+ this.ownTabindex = true;
262
+ }
263
+ }
264
+ else if (this.ownTabindex) {
265
+ el.removeAttribute("tabindex");
266
+ this.ownTabindex = false;
267
+ }
268
+ if (this.isDisabled) {
269
+ if (el.hasAttribute("tabindex")) {
270
+ this.savedTabindex = el.getAttribute("tabindex");
271
+ el.removeAttribute("tabindex");
272
+ }
273
+ if (el.contains(el.ownerDocument.activeElement))
274
+ el.ownerDocument.activeElement?.blur();
275
+ }
276
+ else if (this.savedTabindex !== null) {
277
+ el.setAttribute("tabindex", this.savedTabindex);
278
+ this.savedTabindex = null;
279
+ }
280
+ for (const listener of [...this.changeListeners])
281
+ listener({ docChanged: false, selectionChanged: false, focusMoved: false });
176
282
  }
177
283
  onChange(listener) {
178
284
  return this.engine.subscribe(() => listener(this));
@@ -249,13 +355,14 @@ export class Editor {
249
355
  draw() {
250
356
  renderDoc(this.engine.document, this.element, { loadRemoteImages: this.loadRemoteImages }, this.renderCache);
251
357
  const blocks = this.engine.document.blocks;
252
- this.element.toggleAttribute("data-rk-empty", blocks.length === 1 && blocks[0]?.type === "p" && blocks[0].content.length === 0);
358
+ this.element.toggleAttribute("data-rte-empty", blocks.length === 1 && blocks[0]?.type === "p" && blocks[0].content.length === 0);
253
359
  this.observer.takeRecords();
254
360
  this.writeSelection();
255
361
  }
256
362
  setSelection(sel) {
257
363
  this.engine.setSelection({ anchor: clampPos(this.engine.document, sel.anchor), focus: clampPos(this.engine.document, sel.focus) });
258
364
  }
365
+ fellBack = false;
259
366
  readSelection(force = false) {
260
367
  if (!force && !this.element.contains(document.activeElement))
261
368
  return;
@@ -266,6 +373,7 @@ export class Editor {
266
373
  return;
267
374
  const first = domSel.rangeCount > 1 ? domSel.getRangeAt(0) : undefined;
268
375
  const last = first ? domSel.getRangeAt(domSel.rangeCount - 1) : undefined;
376
+ this.fellBack = false;
269
377
  const anchor = first ? this.posOf(first.startContainer, first.startOffset) : this.posOf(domSel.anchorNode, domSel.anchorOffset);
270
378
  const focus = last ? this.posOf(last.endContainer, last.endOffset) : this.posOf(domSel.focusNode, domSel.focusOffset);
271
379
  if (!anchor || !focus)
@@ -278,17 +386,33 @@ export class Editor {
278
386
  finally {
279
387
  this.reading = false;
280
388
  }
389
+ if (this.fellBack && isCollapsed(this.engine.selection))
390
+ this.writeSelection();
281
391
  }
282
392
  posOf(node, offset) {
283
393
  const direct = node !== this.element ? posFromDom(this.element, node, offset) : null;
284
394
  if (direct || !(node instanceof Element))
285
395
  return direct;
286
396
  const containers = (el) => (el.hasAttribute("data-rte") ? [el, ...el.querySelectorAll("[data-rte]")] : [...el.querySelectorAll("[data-rte]")]);
287
- const at = node.children[offset];
288
- const first = at ? containers(at)[0] : undefined;
397
+ this.fellBack = true;
398
+ let first;
399
+ for (let i = offset; i < node.children.length && !first; i++)
400
+ first = containers(node.children[i])[0];
289
401
  if (first)
290
402
  return posFromDom(this.element, first, 0);
291
403
  const all = containers(node);
404
+ if (all.length === 0 && node !== this.element) {
405
+ for (let from = node; from && from !== this.element; from = from.parentElement) {
406
+ for (let next = from.nextElementSibling; next; next = next.nextElementSibling) {
407
+ const there = containers(next)[0];
408
+ if (there)
409
+ return posFromDom(this.element, there, 0);
410
+ }
411
+ }
412
+ const every = [...this.element.querySelectorAll("[data-rte]")];
413
+ const end = every[every.length - 1];
414
+ return end ? posFromDom(this.element, end, end.childNodes.length) : null;
415
+ }
292
416
  const last = all[all.length - 1];
293
417
  return last ? posFromDom(this.element, last, last.childNodes.length) : null;
294
418
  }
@@ -319,7 +443,7 @@ export class Editor {
319
443
  return;
320
444
  const domSel = document.getSelection();
321
445
  const raw = domSel?.focusNode && this.element.contains(domSel.focusNode) ? posFromDom(this.element, domSel.focusNode, domSel.focusOffset) : null;
322
- this.apply("type", (doc) => {
446
+ this.apply("compose", (doc) => {
323
447
  const next = setContent(doc, path, first.content);
324
448
  return { doc: next, selection: raw ? caret(clampPos(next, raw)) : caret(clampPos(next, this.engine.selection.focus)) };
325
449
  });
@@ -494,13 +618,25 @@ export class Editor {
494
618
  types: [...clipboard.types],
495
619
  });
496
620
  }
621
+ copyFormats() {
622
+ const blocks = extractSelection(this.engine.document, this.engine.selection);
623
+ const formats = { "text/html": exportHtml({ blocks }), "text/plain": selectedText(this.engine.document, this.engine.selection) };
624
+ for (const formatter of this.engine.copyFormatters) {
625
+ let added;
626
+ added = formatter(blocks);
627
+ for (const [type, value] of Object.entries(added ?? {}))
628
+ if (/^[a-z]+\/[a-z0-9.+-]+$/i.test(type) && typeof value === "string")
629
+ formats[type] = value;
630
+ }
631
+ return formats;
632
+ }
497
633
  onCopy(event, cut) {
498
634
  this.readSelection();
499
635
  if (isCollapsed(this.engine.selection) || !event.clipboardData)
500
636
  return;
501
637
  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));
638
+ for (const [type, value] of Object.entries(this.copyFormats()))
639
+ event.clipboardData.setData(type, value);
504
640
  if (cut && !this.engine.readOnly)
505
641
  this.apply("cut", deleteSelection);
506
642
  }
@@ -515,8 +651,8 @@ export class Editor {
515
651
  this.readSelection();
516
652
  if (isCollapsed(this.engine.selection) || !event.dataTransfer)
517
653
  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));
654
+ for (const [type, value] of Object.entries(this.copyFormats()))
655
+ event.dataTransfer.setData(type, value);
520
656
  event.dataTransfer.effectAllowed = this.engine.readOnly ? "copy" : "copyMove";
521
657
  this.dragSource = this.engine.selection;
522
658
  }
@@ -572,7 +708,28 @@ export class Editor {
572
708
  onKeyDown(event) {
573
709
  if (this.composing || event.isComposing)
574
710
  return;
575
- if (this.engine.handleKey(event))
711
+ if (this.engine.handleKey(event)) {
576
712
  event.preventDefault();
713
+ return;
714
+ }
715
+ this.keyAtDocumentEdge(event);
716
+ }
717
+ keyAtDocumentEdge(event) {
718
+ if ((event.key !== "Backspace" && event.key !== "Delete") || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || this.engine.readOnly)
719
+ return;
720
+ this.engine.refresh();
721
+ const sel = this.engine.selection;
722
+ if (!isCollapsed(sel))
723
+ return;
724
+ const paths = containerPaths(this.engine.document);
725
+ const edge = paths[event.key === "Backspace" ? 0 : paths.length - 1];
726
+ const here = sel.focus;
727
+ if (!edge || pathKey(here.path) !== pathKey(edge) || here.offset !== (event.key === "Backspace" ? 0 : inlineLength(getContent(this.engine.document, edge))))
728
+ return;
729
+ event.preventDefault();
730
+ if (event.key === "Backspace")
731
+ this.apply("delete", (doc, s) => deleteBackward(doc, s, this.engine.behaviors));
732
+ else
733
+ this.apply("delete", deleteForward);
577
734
  }
578
735
  }
package/dist/edit.d.ts CHANGED
@@ -20,6 +20,12 @@ export declare function insertLineBreak(doc: Doc, sel: Selection): EditResult;
20
20
  export declare function splitBlock(doc: Doc, sel: Selection, behaviors?: Behaviors): EditResult;
21
21
  export declare function deleteBackward(doc: Doc, sel: Selection, behaviors?: Behaviors): EditResult;
22
22
  export declare function deleteForward(doc: Doc, sel: Selection): EditResult;
23
+ /**
24
+ * A selection of whole blocks that a browser made by running its end on to the start of the next block (a triple click in Chrome and
25
+ * Safari does this) ends at the end of the last selected block instead, so what is typed or deleted never reaches into the next one.
26
+ * Anything else is returned as it was.
27
+ */
28
+ export declare function endAtBlockEnd(doc: Doc, sel: Selection): Selection;
23
29
  /** Deletes back to the start of the previous word (or forward to the end of the next). At a block edge it joins blocks like Backspace. */
24
30
  export declare function deleteWord(doc: Doc, sel: Selection, direction: "backward" | "forward", behaviors?: Behaviors): EditResult;
25
31
  /** Deletes from the caret to the start or end of the block. */
package/dist/edit.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { inlineLength, inlineText, normalizeInline, } from "./model.js";
2
+ import { setBlockType } from "./blocks.js";
2
3
  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";
4
+ import { asChildren, blockContainers, caret, cellChain, clampPos, comparePath, containerPaths, fixup, getBlock, getContent, hasContainer, inBlockList, isCollapsed, kindAt, mapBlockList, mapListOf, pathKey, removeEntry, rulePaths, sameCells, setContent, } from "./path.js";
4
5
  export { caret, clampPos, isCollapsed };
5
6
  export function startOf(doc) {
6
7
  return { path: containerPaths(doc)[0] ?? [0], offset: 0 };
@@ -169,6 +170,32 @@ export function insertLineBreak(doc, sel) {
169
170
  return { doc: setContent(cleared.doc, pos.path, next), selection: caret({ path: pos.path, offset: pos.offset + 1 }) };
170
171
  }
171
172
  const behaviorOf = (behaviors, kind) => (kind === "li" || kind === "dt" || kind === "dd" ? behaviors?.[kind] : undefined);
173
+ function quoteParent(doc, path) {
174
+ if (path.length < 2 || (kindAt(doc, path) !== "p" && kindAt(doc, path) !== "h" && kindAt(doc, path) !== "pre"))
175
+ return undefined;
176
+ const quote = path.slice(0, -1);
177
+ const block = getBlock(doc, quote);
178
+ return block?.type === "quote" ? { quote, index: path[path.length - 1], count: block.blocks.length } : undefined;
179
+ }
180
+ function liftFromQuote(doc, path, where, sel) {
181
+ const parent = quoteParent(doc, path);
182
+ const ordinal = ordinalOf(doc, path);
183
+ const next = fixup(mapBlockList(doc, parent.quote, (blocks, at) => {
184
+ const quote = blocks[at];
185
+ if (quote?.type !== "quote")
186
+ return blocks;
187
+ const taken = quote.blocks[parent.index];
188
+ const left = quote.blocks.filter((_, i) => i !== parent.index);
189
+ const rest = left.length > 0 ? [{ ...quote, blocks: left }] : [];
190
+ return [...blocks.slice(0, at), ...(where === "before" ? [taken, ...rest] : [...rest, taken]), ...blocks.slice(at + 1)];
191
+ }));
192
+ return { doc: next, selection: caret({ path: containerPaths(next)[ordinal] ?? path, offset: sel.anchor.offset }) };
193
+ }
194
+ const emptyBlock = (doc, path) => (kindAt(doc, path) === "p" || kindAt(doc, path) === "h") && inlineLength(getContent(doc, path)) === 0;
195
+ const isNoteItem = (doc, path) => {
196
+ const top = doc.blocks[path[0]];
197
+ return (top?.type === "ol" || top?.type === "ul") && top.notes === true && path.length === 2;
198
+ };
172
199
  export function splitBlock(doc, sel, behaviors) {
173
200
  const cleared = deleteSelection(doc, sel);
174
201
  const pos = cleared.selection.anchor;
@@ -178,8 +205,19 @@ export function splitBlock(doc, sel, behaviors) {
178
205
  const before = sliceInline(content, 0, pos.offset);
179
206
  const after = sliceInline(content, pos.offset);
180
207
  const ordinal = ordinalOf(d, pos.path);
181
- if (kind === "pre")
208
+ if (kind === "pre") {
209
+ if (pos.offset === inlineLength(content) && pos.offset > 0 && inlineText(content).endsWith("\n")) {
210
+ const trimmed = setContent(d, pos.path, sliceInline(content, 0, pos.offset - 1));
211
+ const out = mapBlockList(trimmed, pos.path, (blocks, index) => [...blocks.slice(0, index + 1), { type: "p", content: [] }, ...blocks.slice(index + 1)]);
212
+ return { doc: out, selection: caret({ path: ordinalToPath(out, ordinal + 1), offset: 0 }) };
213
+ }
182
214
  return insertText(d, cleared.selection, "\n");
215
+ }
216
+ if (kind === "p" && content.length === 0) {
217
+ const parent = quoteParent(d, pos.path);
218
+ if (parent && parent.index === parent.count - 1)
219
+ return liftFromQuote(d, pos.path, "after", cleared.selection);
220
+ }
183
221
  if (kind === "li" || kind === "dt" || kind === "dd")
184
222
  return behaviorOf(behaviors, kind)?.split?.(d, pos) ?? insertLineBreak(d, cleared.selection);
185
223
  const continueAsParagraph = kind === "h" && after.length === 0;
@@ -201,17 +239,33 @@ export function deleteBackward(doc, sel, behaviors) {
201
239
  const pos = sel.anchor;
202
240
  if (pos.offset > 0)
203
241
  return deleteSelection(doc, { anchor: { path: pos.path, offset: pos.offset - 1 }, focus: pos });
242
+ if (isNoteItem(doc, pos.path))
243
+ return { doc, selection: sel };
204
244
  const leaving = behaviorOf(behaviors, kindAt(doc, pos.path))?.backspaceAtStart?.(doc, pos);
205
245
  if (leaving)
206
246
  return leaving;
207
247
  const ordinal = ordinalOf(doc, pos.path);
248
+ const rule = lastRule(doc, ordinal);
249
+ if (rule)
250
+ return removeRule(doc, rule, ordinal, sel);
251
+ const kind = kindAt(doc, pos.path);
252
+ if ((kind === "pre" && (ordinal <= 0 || inlineLength(getContent(doc, pos.path)) > 0)) || (kind === "h" && ordinal <= 0))
253
+ return setBlockType(doc, sel, { type: "p" });
254
+ if (quoteParent(doc, pos.path)?.index === 0)
255
+ return liftFromQuote(doc, pos.path, "before", sel);
208
256
  if (ordinal <= 0)
209
257
  return { doc, selection: sel };
210
258
  const previous = ordinalToPath(doc, ordinal - 1);
211
259
  const depths = cellDepths(doc, previous, pos.path);
212
- if (depths)
213
- return depths[0] > depths[1] ? { doc, selection: caret({ path: previous, offset: inlineLength(getContent(doc, previous)) }) } : { doc, selection: sel };
214
- return joinWithPrevious(doc, previous, pos);
260
+ if (depths) {
261
+ if (depths[0] <= depths[1])
262
+ return { doc, selection: sel };
263
+ if (emptyBlock(doc, pos.path))
264
+ return joinWithPrevious(doc, previous, pos);
265
+ return { doc, selection: caret({ path: previous, offset: inlineLength(getContent(doc, previous)) }) };
266
+ }
267
+ const joined = joinWithPrevious(doc, previous, pos);
268
+ return joined.doc === doc ? { doc, selection: caret({ path: previous, offset: inlineLength(getContent(doc, previous)) }) } : joined;
215
269
  }
216
270
  export function deleteForward(doc, sel) {
217
271
  if (!isCollapsed(sel))
@@ -221,18 +275,54 @@ export function deleteForward(doc, sel) {
221
275
  if (pos.offset < length)
222
276
  return deleteSelection(doc, { anchor: pos, focus: { path: pos.path, offset: pos.offset + 1 } });
223
277
  const paths = containerPaths(doc);
224
- const next = paths[ordinalOf(doc, pos.path) + 1];
225
- if (!next)
278
+ const ordinal = ordinalOf(doc, pos.path);
279
+ const rule = firstRule(doc, ordinal + 1);
280
+ if (rule)
281
+ return removeRule(doc, rule, ordinal, sel);
282
+ const next = paths[ordinal + 1];
283
+ if (!next || isNoteItem(doc, next))
226
284
  return { doc, selection: sel };
227
285
  const depths = cellDepths(doc, pos.path, next);
228
- if (depths)
229
- return depths[1] > depths[0] ? { doc, selection: caret({ path: next, offset: 0 }) } : { doc, selection: sel };
286
+ if (depths) {
287
+ if (depths[1] <= depths[0])
288
+ return { doc, selection: sel };
289
+ if (emptyBlock(doc, pos.path)) {
290
+ const gone = removeContainer(doc, pos.path);
291
+ return { doc: gone, selection: caret({ path: containerPaths(gone)[ordinal] ?? next, offset: 0 }) };
292
+ }
293
+ return { doc, selection: caret({ path: next, offset: 0 }) };
294
+ }
230
295
  return joinWithPrevious(doc, pos.path, { path: next, offset: 0 });
231
296
  }
297
+ export function endAtBlockEnd(doc, sel) {
298
+ if (isCollapsed(sel))
299
+ return sel;
300
+ const forward = comparePath(sel.anchor.path, sel.focus.path) < 0;
301
+ const [start, end] = forward ? [sel.anchor, sel.focus] : [sel.focus, sel.anchor];
302
+ if (end.offset !== 0 || start.offset !== 0 || pathKey(start.path) === pathKey(end.path))
303
+ return sel;
304
+ const ordinal = ordinalOf(doc, end.path);
305
+ const previous = ordinal > 0 ? ordinalToPath(doc, ordinal - 1) : undefined;
306
+ if (!previous || comparePath(previous, start.path) < 0)
307
+ return sel;
308
+ const trimmed = { path: previous, offset: inlineLength(getContent(doc, previous)) };
309
+ return forward ? { anchor: sel.anchor, focus: trimmed } : { anchor: trimmed, focus: sel.focus };
310
+ }
311
+ const lastRule = (doc, ordinal) => rulePaths(doc).filter((r) => r.before === ordinal).pop();
312
+ const firstRule = (doc, ordinal) => rulePaths(doc).find((r) => r.before === ordinal);
313
+ function removeRule(doc, rule, ordinal, sel) {
314
+ const next = fixup(mapBlockList(doc, rule.path, (blocks, index) => blocks.filter((_, i) => i !== index)));
315
+ const path = containerPaths(next)[ordinal];
316
+ const at = sel.anchor;
317
+ return { doc: next, selection: path ? caret({ path, offset: at.offset }) : sel };
318
+ }
232
319
  function joinWithPrevious(doc, into, from) {
233
320
  const intoKind = kindAt(doc, into);
234
321
  const fromKind = kindAt(doc, from.path);
235
322
  const joinable = (k) => k === "p" || k === "h" || k === "li" || k === "dt" || k === "dd";
323
+ if ((!joinable(intoKind) || !joinable(fromKind)) && inlineLength(getContent(doc, from.path)) === 0 && (fromKind === "p" || fromKind === "h" || fromKind === "pre") && intoKind !== undefined) {
324
+ return { doc: removeContainer(doc, from.path), selection: caret({ path: into, offset: inlineLength(getContent(doc, into)) }) };
325
+ }
236
326
  if (!joinable(intoKind) || !joinable(fromKind))
237
327
  return { doc, selection: caret(from) };
238
328
  const head = getContent(doc, into);
@@ -463,8 +553,8 @@ export function extractSelection(doc, sel) {
463
553
  const items = list.items.flatMap((item) => {
464
554
  const ordinal = n++;
465
555
  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 }] : [];
556
+ const children = asChildren(cropBlocks(item.children));
557
+ return inRange(ordinal) || children.length > 0 ? [{ content, children, ...(item.checked !== undefined ? { checked: item.checked } : {}), ...(item.id !== undefined ? { id: item.id } : {}) }] : [];
468
558
  });
469
559
  return items.length > 0 ? { ...list, items } : undefined;
470
560
  };
package/dist/engine.d.ts CHANGED
@@ -5,9 +5,11 @@ import { type Doc, type Mark } from "./model.ts";
5
5
  import type { PasteHandler } from "./paste.ts";
6
6
  import { type EditResult, type Selection } from "./path.ts";
7
7
  import type { ImportOptions } from "./import.ts";
8
+ import { type ContentOptions } from "./policy.ts";
8
9
  import { type Counts } from "./stats.ts";
9
10
  import { UiRegistry } from "./ui.ts";
10
- import { type FileHandler, type PasteTransform, type Plugin } from "./plugin.ts";
11
+ import { type Warning } from "./warning.ts";
12
+ import { type CopyFormatter, type FileHandler, type Normalizer, type PasteTransform, type Plugin } from "./plugin.ts";
11
13
  export interface EditorSnapshot {
12
14
  /** Goes up on every change to the document, selection, read-only or focus state. */
13
15
  readonly version: number;
@@ -28,7 +30,7 @@ export interface EngineOptions {
28
30
  /** Decides what `Mod` means in key chords. Detected from the environment when not given. */
29
31
  readonly platform?: Platform;
30
32
  /** Called with warnings meant for developers, such as two plugins claiming one key. Default: the console. */
31
- readonly onWarning?: (message: string) => void;
33
+ readonly onWarning?: (warning: Warning) => void;
32
34
  /** The host calls this before the engine reads state it also tracks elsewhere (a DOM selection, say). */
33
35
  readonly beforeAccess?: () => void;
34
36
  /** Asked to put the keyboard focus in the editor. */
@@ -43,6 +45,11 @@ export interface EngineOptions {
43
45
  readonly limit: number;
44
46
  readonly wouldBe: number;
45
47
  }) => void;
48
+ /**
49
+ * What is kept and what is dropped when HTML is read (loading, `setHtml`, pasting): a profile (`strict`, `standard` or `loose`)
50
+ * and single choices on top of it. See docs/CONTENT-POLICY.md. Default: the `standard` profile.
51
+ */
52
+ readonly content?: ContentOptions;
46
53
  }
47
54
  type Listener = (snapshot: EditorSnapshot, previous: EditorSnapshot) => void;
48
55
  type Change = {
@@ -61,11 +68,16 @@ export declare class Engine {
61
68
  private readonly textTriggers;
62
69
  /** Plugins that change pasted blocks before they go in. */
63
70
  readonly pasteTransforms: PasteTransform[];
71
+ /** Plugins that add formats to what copy, cut and drag write. */
72
+ readonly copyFormatters: CopyFormatter[];
73
+ /** Plugins that keep the document consistent after every edit (footnotes keep their notes with their references). */
74
+ readonly normalizers: Normalizer[];
64
75
  /** Plugins that turn pasted and dropped files into blocks. */
65
76
  readonly fileHandlers: FileHandler[];
66
77
  /** How HTML is imported here: what plugins have added (an SVG cleaner, recognizers). Pass it to the importer. */
67
78
  readonly importOptions: {
68
- sanitizeSvg?: ImportOptions["sanitizeSvg"];
79
+ -readonly [K in keyof ImportOptions]: ImportOptions[K];
80
+ } & {
69
81
  recognizers: NonNullable<ImportOptions["recognizers"]>;
70
82
  };
71
83
  private doc;
@@ -89,6 +101,9 @@ export declare class Engine {
89
101
  private readonly focusRequest;
90
102
  private readonly maxLength;
91
103
  private readonly onMaxLength;
104
+ /** Reports a problem on the one channel (`onWarning`; the console when there is none). Never throws. */
105
+ readonly report: (warning: Warning) => void;
106
+ /** What a plugin says to the developer: reported with the code `plugin-message`. */
92
107
  readonly warn: (message: string) => void;
93
108
  constructor(options?: EngineOptions);
94
109
  /** The current state. A new object after every change, the same object until then. */
@@ -117,6 +132,8 @@ export declare class Engine {
117
132
  * `kind` ("type", "delete") share one step. False when nothing changed, or when the editor is read-only.
118
133
  */
119
134
  apply(kind: string, op: (doc: Doc, sel: Selection) => EditResult): boolean;
135
+ /** Lets the normalizers put right what an edit left, so the repair is part of the same history step. */
136
+ private settle;
120
137
  /** Sets the selection without making a history step. Nothing happens if it is the selection already. */
121
138
  setSelection(selection: Selection): void;
122
139
  /** 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. */
@@ -143,7 +160,12 @@ export declare class Engine {
143
160
  * first one that takes over, or undefined when none does and the text should be inserted as usual.
144
161
  */
145
162
  triggerText(typed: string): TriggerResult | undefined;
146
- /** Adds a plugin. Returns a function that takes everything it added away again. */
163
+ /**
164
+ * Runs `fn` for a plugin or a listener. A throw is reported (`plugin-failed`, or `code`) and `fallback` is used instead; it is never
165
+ * passed on, so one plugin's failure cannot stop the editor or the plugins after it.
166
+ */
167
+ attempt<T>(plugin: string | undefined, where: string, fn: () => T, fallback: (() => T) | undefined, code?: "plugin-failed" | "listener-failed"): T;
168
+ /** Adds a plugin. Returns a function that takes everything it added away again. A plugin that throws while starting is left out and reported. */
147
169
  use(plugin: Plugin): Unsubscribe;
148
170
  }
149
171
  export {};