@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.
- package/README.md +1 -1
- package/dist/autoformat/autoformat.js +18 -3
- package/dist/base64.js +5 -1
- package/dist/dom/index.d.ts +1 -1
- package/dist/dom/render.js +26 -15
- package/dist/dom/view.d.ts +59 -6
- package/dist/dom/view.js +195 -38
- package/dist/edit.d.ts +6 -0
- package/dist/edit.js +101 -11
- package/dist/engine.d.ts +26 -4
- package/dist/engine.js +113 -23
- package/dist/export.d.ts +2 -2
- package/dist/export.js +22 -6
- package/dist/footnotes/index.d.ts +2 -0
- package/dist/footnotes/index.js +2 -0
- package/dist/footnotes/plugin.d.ts +6 -0
- package/dist/footnotes/plugin.js +130 -0
- package/dist/footnotes/reconcile.d.ts +4 -0
- package/dist/footnotes/reconcile.js +155 -0
- package/dist/html/parse.d.ts +8 -2
- package/dist/html/parse.js +6 -2
- package/dist/images/images.js +4 -3
- package/dist/images/index.d.ts +5 -1
- package/dist/images/index.js +3 -1
- package/dist/images/plugin.js +2 -1
- package/dist/{dom/imageview.d.ts → images/view.d.ts} +9 -6
- package/dist/{dom/imageview.js → images/view.js} +20 -14
- package/dist/import.d.ts +18 -2
- package/dist/import.js +87 -42
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/lists/lists.js +55 -30
- package/dist/markdown/detect.d.ts +1 -0
- package/dist/markdown/detect.js +81 -0
- package/dist/markdown/export.d.ts +10 -0
- package/dist/markdown/export.js +252 -0
- package/dist/markdown/index.d.ts +5 -0
- package/dist/markdown/index.js +4 -0
- package/dist/markdown/inline.d.ts +37 -0
- package/dist/markdown/inline.js +604 -0
- package/dist/markdown/parse.d.ts +4 -0
- package/dist/markdown/parse.js +586 -0
- package/dist/markdown/plugin.d.ts +20 -0
- package/dist/markdown/plugin.js +56 -0
- package/dist/model.d.ts +30 -2
- package/dist/model.js +2 -0
- package/dist/notes.d.ts +6 -0
- package/dist/notes.js +49 -0
- package/dist/ops.d.ts +6 -1
- package/dist/ops.js +5 -4
- package/dist/paste.js +9 -2
- package/dist/path.d.ts +10 -1
- package/dist/path.js +110 -11
- package/dist/plugin.d.ts +53 -2
- package/dist/policy.d.ts +41 -0
- package/dist/policy.js +40 -0
- package/dist/standard/index.d.ts +5 -3
- package/dist/standard/index.js +6 -5
- package/dist/stats.js +1 -1
- package/dist/svg/svg.js +9 -1
- package/dist/{dom/tableview.d.ts → tables/cells.d.ts} +7 -6
- package/dist/{dom/tableview.js → tables/cells.js} +16 -12
- package/dist/{dom/tablegrips.d.ts → tables/grips.d.ts} +5 -4
- package/dist/{dom/tablegrips.js → tables/grips.js} +10 -9
- package/dist/tables/index.d.ts +5 -1
- package/dist/tables/index.js +3 -1
- package/dist/tables/view.d.ts +2 -0
- package/dist/tables/view.js +15 -0
- package/dist/warning.d.ts +16 -0
- package/dist/warning.js +5 -0
- 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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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) =>
|
|
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("--
|
|
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("--
|
|
194
|
+
this.element.style.setProperty("--rte-placeholder", `"${text.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\A ")}"`);
|
|
168
195
|
}
|
|
169
196
|
}
|
|
170
197
|
setHtml(html) {
|
|
171
|
-
this.
|
|
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.
|
|
175
|
-
this.
|
|
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-
|
|
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
|
-
|
|
288
|
-
|
|
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("
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
225
|
-
|
|
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
|
-
|
|
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
|
|
467
|
-
return inRange(ordinal) ||
|
|
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
|
|
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?: (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {};
|