@rathkit/rte-core 0.1.0-alpha.4 → 0.2.0-beta.0
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 +4 -4
- package/dist/dom/view.d.ts +56 -3
- package/dist/dom/view.js +174 -25
- package/dist/edit.d.ts +6 -0
- package/dist/edit.js +96 -9
- package/dist/engine.d.ts +19 -3
- package/dist/engine.js +96 -22
- package/dist/export.js +1 -1
- package/dist/html/parse.d.ts +8 -2
- package/dist/html/parse.js +6 -2
- package/dist/images/plugin.js +2 -1
- package/dist/images/view.d.ts +1 -1
- package/dist/images/view.js +4 -4
- package/dist/import.d.ts +18 -2
- package/dist/import.js +35 -27
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/lists/lists.js +14 -4
- package/dist/markdown/export.js +1 -1
- package/dist/markdown/inline.d.ts +7 -0
- package/dist/markdown/inline.js +29 -20
- package/dist/markdown/parse.js +1 -1
- package/dist/markdown/plugin.js +5 -1
- package/dist/model.d.ts +5 -0
- package/dist/paste.js +9 -2
- package/dist/path.d.ts +7 -0
- package/dist/path.js +20 -7
- package/dist/plugin.d.ts +1 -1
- package/dist/policy.d.ts +41 -0
- package/dist/policy.js +40 -0
- package/dist/standard/index.d.ts +0 -1
- package/dist/standard/index.js +0 -1
- package/dist/stats.js +1 -1
- package/dist/svg/svg.js +9 -1
- package/dist/tables/cells.d.ts +1 -1
- package/dist/tables/cells.js +3 -3
- package/dist/tables/grips.js +2 -2
- package/dist/warning.d.ts +16 -0
- package/dist/warning.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @rathkit/rte-core
|
|
2
2
|
|
|
3
|
-
> **
|
|
3
|
+
> **Beta.** Not for production yet: the APIs may still change between releases. Published to the `next` tag only.
|
|
4
4
|
|
|
5
5
|
The editor itself: the document model, HTML import and export, editing operations, commands and keys, the plugin host, the browser view, and the free plugins (lists, tables, find and replace, word-processor lists, SVG, images). The model does not touch the DOM, so it also runs in Node.
|
|
6
6
|
|
|
@@ -73,7 +73,19 @@ export function spanShortcut(doc, sel, typed) {
|
|
|
73
73
|
}
|
|
74
74
|
return undefined;
|
|
75
75
|
}
|
|
76
|
-
const
|
|
76
|
+
const ADDRESS_START = /(?:https?:\/\/|www\.)/gi;
|
|
77
|
+
const ADDRESS_BREAK = /[\s<>"]/;
|
|
78
|
+
function addressAtEnd(text) {
|
|
79
|
+
let from = text.length;
|
|
80
|
+
while (from > 0 && !ADDRESS_BREAK.test(text[from - 1]))
|
|
81
|
+
from--;
|
|
82
|
+
ADDRESS_START.lastIndex = from;
|
|
83
|
+
for (let m = ADDRESS_START.exec(text); m; m = ADDRESS_START.exec(text)) {
|
|
84
|
+
if (m.index + m[0].length < text.length)
|
|
85
|
+
return { index: m.index, text: text.slice(m.index) };
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
77
89
|
export function addressShortcut(doc, sel, typed) {
|
|
78
90
|
if (typed !== " " || !isCollapsed(sel))
|
|
79
91
|
return undefined;
|
|
@@ -83,10 +95,13 @@ export function addressShortcut(doc, sel, typed) {
|
|
|
83
95
|
const content = getContent(doc, path);
|
|
84
96
|
const offset = sel.anchor.offset;
|
|
85
97
|
const before = inlineText(sliceInline(content, 0, offset));
|
|
86
|
-
const m =
|
|
98
|
+
const m = addressAtEnd(before);
|
|
87
99
|
if (!m)
|
|
88
100
|
return undefined;
|
|
89
|
-
|
|
101
|
+
let stop = m.text.length;
|
|
102
|
+
while (stop > 0 && ".,;:!?)]'\"".includes(m.text[stop - 1]))
|
|
103
|
+
stop--;
|
|
104
|
+
const word = m.text.slice(0, stop);
|
|
90
105
|
if (word.replace(/^(?:https?:\/\/|www\.)/i, "").length < 3 || !/[a-z0-9]/i.test(word.replace(/^(?:https?:\/\/|www\.)/i, "")))
|
|
91
106
|
return undefined;
|
|
92
107
|
const start = m.index;
|
package/dist/base64.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
2
2
|
export function decodeBase64(input) {
|
|
3
|
-
const
|
|
3
|
+
const spaced = input.replace(/\s+/g, "");
|
|
4
|
+
let end = spaced.length;
|
|
5
|
+
while (end > 0 && spaced[end - 1] === "=")
|
|
6
|
+
end--;
|
|
7
|
+
const text = spaced.slice(0, end);
|
|
4
8
|
if (/[^A-Za-z0-9+/]/.test(text))
|
|
5
9
|
return undefined;
|
|
6
10
|
const out = new Uint8Array(Math.floor((text.length * 3) / 4));
|
package/dist/dom/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { Editor, type EditorOptions } from "./view.ts";
|
|
1
|
+
export { Editor, type EditorEvents, type EditorOptions } from "./view.ts";
|
|
2
2
|
export { renderDoc, posFromDom, domFromPos } from "./render.ts";
|
package/dist/dom/render.js
CHANGED
|
@@ -112,7 +112,7 @@ function renderList(list, path, options) {
|
|
|
112
112
|
if (list.style !== undefined)
|
|
113
113
|
el.style.listStyleType = list.style;
|
|
114
114
|
if (list.notes) {
|
|
115
|
-
el.setAttribute("data-
|
|
115
|
+
el.setAttribute("data-rte-notes", "");
|
|
116
116
|
el.setAttribute("role", "doc-endnotes");
|
|
117
117
|
el.setAttribute("aria-label", "Notes");
|
|
118
118
|
}
|
|
@@ -120,12 +120,12 @@ function renderList(list, path, options) {
|
|
|
120
120
|
const itemPath = [...path, i];
|
|
121
121
|
const li = tag("li", itemPath);
|
|
122
122
|
if (list.notes && item.id)
|
|
123
|
-
li.setAttribute("data-
|
|
124
|
-
if (item.content.length === 0 && (item.children[0]?.type === "ul" || item.children[0]?.type === "ol"))
|
|
123
|
+
li.setAttribute("data-rte-note-id", item.id);
|
|
124
|
+
if (item.bare === true && item.content.length === 0 && (item.children[0]?.type === "ul" || item.children[0]?.type === "ol"))
|
|
125
125
|
li.style.listStyleType = "none";
|
|
126
126
|
if (item.checked !== undefined) {
|
|
127
127
|
li.style.listStyleType = "none";
|
|
128
|
-
li.setAttribute("data-
|
|
128
|
+
li.setAttribute("data-rte-task", item.checked ? "done" : "open");
|
|
129
129
|
const box = document.createElement("span");
|
|
130
130
|
box.setAttribute("data-rte-check", "");
|
|
131
131
|
box.setAttribute("role", "checkbox");
|
package/dist/dom/view.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Engine } from "../engine.ts";
|
|
2
2
|
import type { Plugin } from "../plugin.ts";
|
|
3
|
+
import type { ContentOptions } from "../policy.ts";
|
|
4
|
+
import type { Warning } from "../warning.ts";
|
|
3
5
|
import { type Doc, type MarkType } from "../model.ts";
|
|
4
6
|
import { type ClipboardData, type PasteHandler } from "../paste.ts";
|
|
5
7
|
import { type Pos, type Selection } from "../path.ts";
|
|
@@ -7,7 +9,10 @@ export interface EditorOptions {
|
|
|
7
9
|
readonly element: HTMLElement;
|
|
8
10
|
readonly html?: string;
|
|
9
11
|
readonly pasteHandlers?: readonly PasteHandler[];
|
|
12
|
+
/** No edits; the page can still be focused, selected and copied from. Default false. */
|
|
10
13
|
readonly readOnly?: boolean;
|
|
14
|
+
/** Read-only, and also out of the tab order and not focusable: for a field that does not apply now. Default false. */
|
|
15
|
+
readonly disabled?: boolean;
|
|
11
16
|
/** Draw remote (`http`, `https`) images. Off by default, so pasting never makes the browser fetch anything. */
|
|
12
17
|
readonly loadRemoteImages?: boolean;
|
|
13
18
|
/** Called with the `inputType` of input the editor does not handle. That input is blocked, not applied. */
|
|
@@ -15,12 +20,16 @@ export interface EditorOptions {
|
|
|
15
20
|
/** Plugins to add: lists, for one (`@rathkit/rte-core/lists`). With none, the editor has only what is built in. */
|
|
16
21
|
readonly plugins?: readonly Plugin[];
|
|
17
22
|
/**
|
|
18
|
-
* A hint shown while the document is empty. The editor marks itself `data-
|
|
19
|
-
* technology (`aria-placeholder`) and puts it in the custom property `--
|
|
23
|
+
* A hint shown while the document is empty. The editor marks itself `data-rte-empty` when empty, says the hint to assistive
|
|
24
|
+
* technology (`aria-placeholder`) and puts it in the custom property `--rte-placeholder`; the styled interface draws it.
|
|
20
25
|
*/
|
|
21
26
|
readonly placeholder?: string;
|
|
22
27
|
/** The most text the document may hold, in characters. Typing and pasting past it are refused (see `onMaxLength`). */
|
|
23
28
|
readonly maxLength?: number;
|
|
29
|
+
/** Told about problems the editor carried on from (a plugin that threw, content that was refused): see docs/ERRORS.md. Default: the console. */
|
|
30
|
+
readonly onWarning?: (warning: Warning) => void;
|
|
31
|
+
/** What is kept and dropped when HTML is read: a profile and single choices on top of it. See docs/CONTENT-POLICY.md. */
|
|
32
|
+
readonly content?: ContentOptions;
|
|
24
33
|
/** Called when an edit is refused for being past `maxLength`, with the limit and what the length would have been. */
|
|
25
34
|
readonly onMaxLength?: (info: {
|
|
26
35
|
readonly limit: number;
|
|
@@ -32,12 +41,29 @@ export interface EditorOptions {
|
|
|
32
41
|
readonly autofocus?: boolean;
|
|
33
42
|
}
|
|
34
43
|
type Listener = (editor: Editor) => void;
|
|
44
|
+
/**
|
|
45
|
+
* What an editor reports, by name (`editor.on(name, listener)`). Every package that wraps the editor reports the same five things
|
|
46
|
+
* with the same timing; see docs/EVENTS.md.
|
|
47
|
+
* - `change`: the document's HTML, after an edit that altered it. Not for `setHtml`, and not for a selection that moved.
|
|
48
|
+
* - `focus` and `blur`: the editor's own focus moved in or out.
|
|
49
|
+
* - `selection`: the selection moved or changed.
|
|
50
|
+
*/
|
|
51
|
+
export interface EditorEvents {
|
|
52
|
+
change: (html: string) => void;
|
|
53
|
+
focus: (editor: Editor) => void;
|
|
54
|
+
blur: (editor: Editor) => void;
|
|
55
|
+
selection: (selection: Selection) => void;
|
|
56
|
+
}
|
|
35
57
|
export declare class Editor {
|
|
36
58
|
readonly element: HTMLElement;
|
|
37
59
|
/** The editor's state, commands, keys and plugins. It has no DOM, so everything it does can be driven without a page. */
|
|
38
60
|
readonly engine: Engine;
|
|
39
61
|
private composing;
|
|
40
62
|
private reading;
|
|
63
|
+
private userReadOnly;
|
|
64
|
+
private isDisabled;
|
|
65
|
+
private ownTabindex;
|
|
66
|
+
private savedTabindex;
|
|
41
67
|
private readonly loadRemoteImages;
|
|
42
68
|
private readonly unhandled;
|
|
43
69
|
private dragSource;
|
|
@@ -46,6 +72,10 @@ export declare class Editor {
|
|
|
46
72
|
private readonly observer;
|
|
47
73
|
private readonly cleanups;
|
|
48
74
|
private readonly changeListeners;
|
|
75
|
+
/** Set while `setHtml` replaces the document, so replacing it is not reported as an edit. */
|
|
76
|
+
private replacing;
|
|
77
|
+
/** Lets each `change` listener forget what it last reported, after the document was replaced. */
|
|
78
|
+
private readonly resyncs;
|
|
49
79
|
private readonly viewHost;
|
|
50
80
|
constructor(options: EditorOptions);
|
|
51
81
|
get doc(): Doc;
|
|
@@ -56,7 +86,10 @@ export declare class Editor {
|
|
|
56
86
|
rangeFor(from: Pos, to: Pos): Range | null;
|
|
57
87
|
get canUndo(): boolean;
|
|
58
88
|
get canRedo(): boolean;
|
|
89
|
+
/** Whether edits are refused: read-only, or disabled. */
|
|
59
90
|
get readOnly(): boolean;
|
|
91
|
+
/** Whether the editor is disabled: read-only and out of the tab order. */
|
|
92
|
+
get disabled(): boolean;
|
|
60
93
|
/** Named commands and their state: `editor.commands.run("format.bold")`, `editor.commands.state("format.bold")`. */
|
|
61
94
|
get commands(): Engine["commands"];
|
|
62
95
|
/** Interface items (toolbar buttons and the like) that the editor and its plugins offer. */
|
|
@@ -70,15 +103,27 @@ export declare class Editor {
|
|
|
70
103
|
/** Adds a plugin. Returns a function that removes it. */
|
|
71
104
|
/** Adds a plugin, with its page-facing part if it has one. Returns what takes both away again. */
|
|
72
105
|
use: Engine["use"];
|
|
106
|
+
/** What takes each added plugin away again, so `destroy` can take away every one that is still in (a plugin's page part holds listeners on the window). */
|
|
107
|
+
private readonly installed;
|
|
73
108
|
/** The document as HTML. `separator` goes between blocks (a newline makes it readable). */
|
|
74
109
|
getHtml(separator?: string): string;
|
|
75
110
|
/** Addresses of remote images in the document, so the host can decide whether to show them. */
|
|
76
111
|
remoteImages(): string[];
|
|
77
112
|
/** Sets the hint shown while the document is empty ("" for none). */
|
|
78
113
|
setPlaceholder(text: string): void;
|
|
79
|
-
/** Replaces the whole document and empties the history. */
|
|
114
|
+
/** Replaces the whole document and empties the history. Reported to nobody as a `change`, like setting the value of a text field. */
|
|
80
115
|
setHtml(html: string): void;
|
|
116
|
+
/** Listens for `change`, `focus`, `blur` or `selection` (see `EditorEvents`). Returns a function that stops listening. */
|
|
117
|
+
on<K extends keyof EditorEvents>(event: K, listener: EditorEvents[K]): () => void;
|
|
118
|
+
/** Read-only on or off. While the editor is disabled it stays read-only whatever this says. */
|
|
81
119
|
setReadOnly(value: boolean): void;
|
|
120
|
+
/** Disabled on or off. Disabled is read-only, not focusable and out of the tab order. */
|
|
121
|
+
setDisabled(value: boolean): void;
|
|
122
|
+
/**
|
|
123
|
+
* The page attributes that say what the editor allows: editable, read-only (focusable, `aria-readonly`) or disabled (not
|
|
124
|
+
* focusable, `aria-disabled`). Only the attributes this sets are touched; a `tabindex` the page gave is its own.
|
|
125
|
+
*/
|
|
126
|
+
private applyAccess;
|
|
82
127
|
/** Calls back after the document or selection changed, once per turn. Same as `subscribe`, kept for older callers. */
|
|
83
128
|
onChange(listener: Listener): () => void;
|
|
84
129
|
destroy(): void;
|
|
@@ -104,6 +149,8 @@ export declare class Editor {
|
|
|
104
149
|
* Brings the editor's selection up to date from the page. Only while the editor has the focus: when focus is somewhere else (a dialog,
|
|
105
150
|
* a toolbar button) the page's selection is not the editor's business, and the editor keeps the selection it had.
|
|
106
151
|
*/
|
|
152
|
+
/** Set while a selection is read when some point had to be moved to a text position. */
|
|
153
|
+
private fellBack;
|
|
107
154
|
private readSelection;
|
|
108
155
|
/**
|
|
109
156
|
* A point on the page as a position in the document. Some browsers report a selection that spans whole blocks (select all, in Firefox)
|
|
@@ -137,5 +184,11 @@ export declare class Editor {
|
|
|
137
184
|
private pasteFiles;
|
|
138
185
|
/** Keys go through the key map: a chord bound to a command runs it, and the event is stopped if the command changed something. */
|
|
139
186
|
private onKeyDown;
|
|
187
|
+
/**
|
|
188
|
+
* Backspace at the very start of the document, and Delete at the very end, change something here (a code block or heading becomes a
|
|
189
|
+
* paragraph, a rule goes) but there is nothing for the browser to delete, so WebKit sends no input event for them. They are handled
|
|
190
|
+
* from the key instead.
|
|
191
|
+
*/
|
|
192
|
+
private keyAtDocumentEdge;
|
|
140
193
|
}
|
|
141
194
|
export {};
|
package/dist/dom/view.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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
4
|
import { ops } from "../ops.js";
|
|
5
5
|
import { importBlocks, importHtml } from "../import.js";
|
|
6
6
|
import { inlineLength } from "../model.js";
|
|
7
7
|
import { pasteBlocks } from "../paste.js";
|
|
8
|
-
import { cellChain, getContent, setContent } from "../path.js";
|
|
8
|
+
import { cellChain, containerPaths, getContent, pathKey, setContent } from "../path.js";
|
|
9
9
|
import { tableFromText } from "../tablefill.js";
|
|
10
10
|
import { imageSelection } from "../imageselect.js";
|
|
11
11
|
import { containerAt, createRenderCache, domFromPos, posFromDom, renderDoc } from "./render.js";
|
|
@@ -23,6 +23,10 @@ export class Editor {
|
|
|
23
23
|
engine;
|
|
24
24
|
composing = false;
|
|
25
25
|
reading = false;
|
|
26
|
+
userReadOnly = false;
|
|
27
|
+
isDisabled = false;
|
|
28
|
+
ownTabindex = false;
|
|
29
|
+
savedTabindex = null;
|
|
26
30
|
loadRemoteImages;
|
|
27
31
|
unhandled;
|
|
28
32
|
dragSource = null;
|
|
@@ -42,15 +46,19 @@ export class Editor {
|
|
|
42
46
|
});
|
|
43
47
|
cleanups = [];
|
|
44
48
|
changeListeners = new Set();
|
|
49
|
+
replacing = false;
|
|
50
|
+
resyncs = new Set();
|
|
45
51
|
viewHost;
|
|
46
52
|
constructor(options) {
|
|
47
53
|
this.element = options.element;
|
|
48
54
|
this.loadRemoteImages = options.loadRemoteImages ?? false;
|
|
49
55
|
this.unhandled = options.onUnhandledInput;
|
|
50
56
|
this.engine = createEngine({
|
|
51
|
-
readOnly: options.readOnly ?? false,
|
|
57
|
+
readOnly: (options.readOnly ?? false) || (options.disabled ?? false),
|
|
52
58
|
beforeAccess: () => this.readSelection(),
|
|
53
59
|
focus: () => this.element.focus(),
|
|
60
|
+
...(options.content ? { content: options.content } : {}),
|
|
61
|
+
...(options.onWarning ? { onWarning: options.onWarning } : {}),
|
|
54
62
|
...(options.maxLength !== undefined ? { maxLength: options.maxLength } : {}),
|
|
55
63
|
...(options.onMaxLength ? { onMaxLength: options.onMaxLength } : {}),
|
|
56
64
|
});
|
|
@@ -63,10 +71,18 @@ export class Editor {
|
|
|
63
71
|
domFromPos: (pos) => domFromPos(this.element, pos),
|
|
64
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";
|
|
@@ -110,6 +126,15 @@ export class Editor {
|
|
|
110
126
|
this.listen(this.element, "compositionstart", () => { this.composing = true; });
|
|
111
127
|
this.listen(this.element, "compositionend", () => { this.composing = false; this.reconcileFromDom(); });
|
|
112
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
|
+
});
|
|
113
138
|
this.observer.observe(this.element, { subtree: true, childList: true, characterData: true, attributes: true });
|
|
114
139
|
if (options.autofocus)
|
|
115
140
|
this.focus();
|
|
@@ -130,6 +155,7 @@ export class Editor {
|
|
|
130
155
|
get canUndo() { return this.engine.canUndo; }
|
|
131
156
|
get canRedo() { return this.engine.canRedo; }
|
|
132
157
|
get readOnly() { return this.engine.readOnly; }
|
|
158
|
+
get disabled() { return this.isDisabled; }
|
|
133
159
|
get commands() { return this.engine.commands; }
|
|
134
160
|
get ui() { return this.engine.ui; }
|
|
135
161
|
get keymap() { return this.engine.keymap; }
|
|
@@ -138,10 +164,18 @@ export class Editor {
|
|
|
138
164
|
select = (selector, onChange, equals) => this.engine.select(selector, onChange, equals);
|
|
139
165
|
use = (plugin) => {
|
|
140
166
|
const remove = this.engine.use(plugin);
|
|
141
|
-
const stop = plugin.view?.(this.viewHost);
|
|
142
|
-
|
|
143
|
-
|
|
167
|
+
const stop = this.engine.attempt(plugin.id, "its page part", () => plugin.view?.(this.viewHost), () => undefined);
|
|
168
|
+
const undo = () => {
|
|
169
|
+
if (!this.installed.delete(undo))
|
|
170
|
+
return;
|
|
171
|
+
if (stop)
|
|
172
|
+
this.engine.attempt(plugin.id, "taking its page part away", stop, undefined);
|
|
173
|
+
remove();
|
|
174
|
+
};
|
|
175
|
+
this.installed.add(undo);
|
|
176
|
+
return undo;
|
|
144
177
|
};
|
|
178
|
+
installed = new Set();
|
|
145
179
|
getHtml(separator = "") { return exportHtml(this.engine.document, separator); }
|
|
146
180
|
remoteImages() {
|
|
147
181
|
const found = [];
|
|
@@ -161,24 +195,105 @@ export class Editor {
|
|
|
161
195
|
setPlaceholder(text) {
|
|
162
196
|
if (text === "") {
|
|
163
197
|
this.element.removeAttribute("aria-placeholder");
|
|
164
|
-
this.element.style.removeProperty("--
|
|
198
|
+
this.element.style.removeProperty("--rte-placeholder");
|
|
165
199
|
}
|
|
166
200
|
else {
|
|
167
201
|
this.element.setAttribute("aria-placeholder", text);
|
|
168
|
-
this.element.style.setProperty("--
|
|
202
|
+
this.element.style.setProperty("--rte-placeholder", `"${text.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\A ")}"`);
|
|
169
203
|
}
|
|
170
204
|
}
|
|
171
205
|
setHtml(html) {
|
|
172
|
-
this.
|
|
206
|
+
this.replacing = true;
|
|
207
|
+
try {
|
|
208
|
+
this.engine.setDocument(importHtml(html, this.engine.importOptions));
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
this.replacing = false;
|
|
212
|
+
}
|
|
213
|
+
for (const resync of this.resyncs)
|
|
214
|
+
resync();
|
|
215
|
+
}
|
|
216
|
+
on(event, listener) {
|
|
217
|
+
const given = listener;
|
|
218
|
+
const call = (...args) => this.engine.attempt(undefined, `your ${event} listener`, () => given(...args), undefined, "listener-failed");
|
|
219
|
+
let last = event === "change" ? this.getHtml() : "";
|
|
220
|
+
const resync = () => { if (event === "change")
|
|
221
|
+
last = this.getHtml(); };
|
|
222
|
+
const handler = (change) => {
|
|
223
|
+
if (this.replacing)
|
|
224
|
+
return;
|
|
225
|
+
if (event === "change") {
|
|
226
|
+
if (!change.docChanged)
|
|
227
|
+
return;
|
|
228
|
+
const html = this.getHtml();
|
|
229
|
+
if (html === last)
|
|
230
|
+
return;
|
|
231
|
+
last = html;
|
|
232
|
+
call(html);
|
|
233
|
+
}
|
|
234
|
+
else if (event === "selection") {
|
|
235
|
+
if (change.selectionChanged)
|
|
236
|
+
call(this.selection);
|
|
237
|
+
}
|
|
238
|
+
else if (change.focusMoved && this.engine.snapshot.focused === (event === "focus"))
|
|
239
|
+
call(this);
|
|
240
|
+
};
|
|
241
|
+
this.resyncs.add(resync);
|
|
242
|
+
this.changeListeners.add(handler);
|
|
243
|
+
return () => { this.resyncs.delete(resync); this.changeListeners.delete(handler); };
|
|
173
244
|
}
|
|
174
245
|
setReadOnly(value) {
|
|
175
|
-
this.
|
|
176
|
-
this.
|
|
246
|
+
this.userReadOnly = value;
|
|
247
|
+
this.applyAccess();
|
|
248
|
+
}
|
|
249
|
+
setDisabled(value) {
|
|
250
|
+
this.isDisabled = value;
|
|
251
|
+
this.applyAccess();
|
|
252
|
+
}
|
|
253
|
+
applyAccess() {
|
|
254
|
+
const el = this.element;
|
|
255
|
+
const off = this.userReadOnly || this.isDisabled;
|
|
256
|
+
this.engine.setReadOnly(off);
|
|
257
|
+
el.setAttribute("contenteditable", off ? "false" : "true");
|
|
258
|
+
if (this.userReadOnly && !this.isDisabled)
|
|
259
|
+
el.setAttribute("aria-readonly", "true");
|
|
260
|
+
else
|
|
261
|
+
el.removeAttribute("aria-readonly");
|
|
262
|
+
if (this.isDisabled)
|
|
263
|
+
el.setAttribute("aria-disabled", "true");
|
|
264
|
+
else
|
|
265
|
+
el.removeAttribute("aria-disabled");
|
|
266
|
+
if (off && !this.isDisabled) {
|
|
267
|
+
if (!el.hasAttribute("tabindex")) {
|
|
268
|
+
el.setAttribute("tabindex", "0");
|
|
269
|
+
this.ownTabindex = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (this.ownTabindex) {
|
|
273
|
+
el.removeAttribute("tabindex");
|
|
274
|
+
this.ownTabindex = false;
|
|
275
|
+
}
|
|
276
|
+
if (this.isDisabled) {
|
|
277
|
+
if (el.hasAttribute("tabindex")) {
|
|
278
|
+
this.savedTabindex = el.getAttribute("tabindex");
|
|
279
|
+
el.removeAttribute("tabindex");
|
|
280
|
+
}
|
|
281
|
+
if (el.contains(el.ownerDocument.activeElement))
|
|
282
|
+
el.ownerDocument.activeElement?.blur();
|
|
283
|
+
}
|
|
284
|
+
else if (this.savedTabindex !== null) {
|
|
285
|
+
el.setAttribute("tabindex", this.savedTabindex);
|
|
286
|
+
this.savedTabindex = null;
|
|
287
|
+
}
|
|
288
|
+
for (const listener of [...this.changeListeners])
|
|
289
|
+
listener({ docChanged: false, selectionChanged: false, focusMoved: false });
|
|
177
290
|
}
|
|
178
291
|
onChange(listener) {
|
|
179
292
|
return this.engine.subscribe(() => listener(this));
|
|
180
293
|
}
|
|
181
294
|
destroy() {
|
|
295
|
+
for (const undo of [...this.installed].reverse())
|
|
296
|
+
undo();
|
|
182
297
|
for (const cleanup of this.cleanups)
|
|
183
298
|
cleanup();
|
|
184
299
|
this.cleanups.length = 0;
|
|
@@ -250,13 +365,14 @@ export class Editor {
|
|
|
250
365
|
draw() {
|
|
251
366
|
renderDoc(this.engine.document, this.element, { loadRemoteImages: this.loadRemoteImages }, this.renderCache);
|
|
252
367
|
const blocks = this.engine.document.blocks;
|
|
253
|
-
this.element.toggleAttribute("data-
|
|
368
|
+
this.element.toggleAttribute("data-rte-empty", blocks.length === 1 && blocks[0]?.type === "p" && blocks[0].content.length === 0);
|
|
254
369
|
this.observer.takeRecords();
|
|
255
370
|
this.writeSelection();
|
|
256
371
|
}
|
|
257
372
|
setSelection(sel) {
|
|
258
373
|
this.engine.setSelection({ anchor: clampPos(this.engine.document, sel.anchor), focus: clampPos(this.engine.document, sel.focus) });
|
|
259
374
|
}
|
|
375
|
+
fellBack = false;
|
|
260
376
|
readSelection(force = false) {
|
|
261
377
|
if (!force && !this.element.contains(document.activeElement))
|
|
262
378
|
return;
|
|
@@ -267,6 +383,7 @@ export class Editor {
|
|
|
267
383
|
return;
|
|
268
384
|
const first = domSel.rangeCount > 1 ? domSel.getRangeAt(0) : undefined;
|
|
269
385
|
const last = first ? domSel.getRangeAt(domSel.rangeCount - 1) : undefined;
|
|
386
|
+
this.fellBack = false;
|
|
270
387
|
const anchor = first ? this.posOf(first.startContainer, first.startOffset) : this.posOf(domSel.anchorNode, domSel.anchorOffset);
|
|
271
388
|
const focus = last ? this.posOf(last.endContainer, last.endOffset) : this.posOf(domSel.focusNode, domSel.focusOffset);
|
|
272
389
|
if (!anchor || !focus)
|
|
@@ -279,17 +396,33 @@ export class Editor {
|
|
|
279
396
|
finally {
|
|
280
397
|
this.reading = false;
|
|
281
398
|
}
|
|
399
|
+
if (this.fellBack && isCollapsed(this.engine.selection))
|
|
400
|
+
this.writeSelection();
|
|
282
401
|
}
|
|
283
402
|
posOf(node, offset) {
|
|
284
403
|
const direct = node !== this.element ? posFromDom(this.element, node, offset) : null;
|
|
285
404
|
if (direct || !(node instanceof Element))
|
|
286
405
|
return direct;
|
|
287
406
|
const containers = (el) => (el.hasAttribute("data-rte") ? [el, ...el.querySelectorAll("[data-rte]")] : [...el.querySelectorAll("[data-rte]")]);
|
|
288
|
-
|
|
289
|
-
|
|
407
|
+
this.fellBack = true;
|
|
408
|
+
let first;
|
|
409
|
+
for (let i = offset; i < node.children.length && !first; i++)
|
|
410
|
+
first = containers(node.children[i])[0];
|
|
290
411
|
if (first)
|
|
291
412
|
return posFromDom(this.element, first, 0);
|
|
292
413
|
const all = containers(node);
|
|
414
|
+
if (all.length === 0 && node !== this.element) {
|
|
415
|
+
for (let from = node; from && from !== this.element; from = from.parentElement) {
|
|
416
|
+
for (let next = from.nextElementSibling; next; next = next.nextElementSibling) {
|
|
417
|
+
const there = containers(next)[0];
|
|
418
|
+
if (there)
|
|
419
|
+
return posFromDom(this.element, there, 0);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const every = [...this.element.querySelectorAll("[data-rte]")];
|
|
423
|
+
const end = every[every.length - 1];
|
|
424
|
+
return end ? posFromDom(this.element, end, end.childNodes.length) : null;
|
|
425
|
+
}
|
|
293
426
|
const last = all[all.length - 1];
|
|
294
427
|
return last ? posFromDom(this.element, last, last.childNodes.length) : null;
|
|
295
428
|
}
|
|
@@ -320,7 +453,7 @@ export class Editor {
|
|
|
320
453
|
return;
|
|
321
454
|
const domSel = document.getSelection();
|
|
322
455
|
const raw = domSel?.focusNode && this.element.contains(domSel.focusNode) ? posFromDom(this.element, domSel.focusNode, domSel.focusOffset) : null;
|
|
323
|
-
this.apply("
|
|
456
|
+
this.apply("compose", (doc) => {
|
|
324
457
|
const next = setContent(doc, path, first.content);
|
|
325
458
|
return { doc: next, selection: raw ? caret(clampPos(next, raw)) : caret(clampPos(next, this.engine.selection.focus)) };
|
|
326
459
|
});
|
|
@@ -500,12 +633,7 @@ export class Editor {
|
|
|
500
633
|
const formats = { "text/html": exportHtml({ blocks }), "text/plain": selectedText(this.engine.document, this.engine.selection) };
|
|
501
634
|
for (const formatter of this.engine.copyFormatters) {
|
|
502
635
|
let added;
|
|
503
|
-
|
|
504
|
-
added = formatter(blocks);
|
|
505
|
-
}
|
|
506
|
-
catch (error) {
|
|
507
|
-
this.engine.warn(`A copy formatter failed: ${String(error)}`);
|
|
508
|
-
}
|
|
636
|
+
added = formatter(blocks);
|
|
509
637
|
for (const [type, value] of Object.entries(added ?? {}))
|
|
510
638
|
if (/^[a-z]+\/[a-z0-9.+-]+$/i.test(type) && typeof value === "string")
|
|
511
639
|
formats[type] = value;
|
|
@@ -590,7 +718,28 @@ export class Editor {
|
|
|
590
718
|
onKeyDown(event) {
|
|
591
719
|
if (this.composing || event.isComposing)
|
|
592
720
|
return;
|
|
593
|
-
if (this.engine.handleKey(event))
|
|
721
|
+
if (this.engine.handleKey(event)) {
|
|
594
722
|
event.preventDefault();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
this.keyAtDocumentEdge(event);
|
|
726
|
+
}
|
|
727
|
+
keyAtDocumentEdge(event) {
|
|
728
|
+
if ((event.key !== "Backspace" && event.key !== "Delete") || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || this.engine.readOnly)
|
|
729
|
+
return;
|
|
730
|
+
this.engine.refresh();
|
|
731
|
+
const sel = this.engine.selection;
|
|
732
|
+
if (!isCollapsed(sel))
|
|
733
|
+
return;
|
|
734
|
+
const paths = containerPaths(this.engine.document);
|
|
735
|
+
const edge = paths[event.key === "Backspace" ? 0 : paths.length - 1];
|
|
736
|
+
const here = sel.focus;
|
|
737
|
+
if (!edge || pathKey(here.path) !== pathKey(edge) || here.offset !== (event.key === "Backspace" ? 0 : inlineLength(getContent(this.engine.document, edge))))
|
|
738
|
+
return;
|
|
739
|
+
event.preventDefault();
|
|
740
|
+
if (event.key === "Backspace")
|
|
741
|
+
this.apply("delete", (doc, s) => deleteBackward(doc, s, this.engine.behaviors));
|
|
742
|
+
else
|
|
743
|
+
this.apply("delete", deleteForward);
|
|
595
744
|
}
|
|
596
745
|
}
|
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. */
|