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