@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/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 { asChildren, 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,14 +275,47 @@ 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);
|
package/dist/engine.d.ts
CHANGED
|
@@ -5,8 +5,10 @@ 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";
|
|
11
|
+
import { type Warning } from "./warning.ts";
|
|
10
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. */
|
|
@@ -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 = {
|
|
@@ -69,7 +76,8 @@ export declare class Engine {
|
|
|
69
76
|
readonly fileHandlers: FileHandler[];
|
|
70
77
|
/** How HTML is imported here: what plugins have added (an SVG cleaner, recognizers). Pass it to the importer. */
|
|
71
78
|
readonly importOptions: {
|
|
72
|
-
|
|
79
|
+
-readonly [K in keyof ImportOptions]: ImportOptions[K];
|
|
80
|
+
} & {
|
|
73
81
|
recognizers: NonNullable<ImportOptions["recognizers"]>;
|
|
74
82
|
};
|
|
75
83
|
private doc;
|
|
@@ -93,6 +101,9 @@ export declare class Engine {
|
|
|
93
101
|
private readonly focusRequest;
|
|
94
102
|
private readonly maxLength;
|
|
95
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`. */
|
|
96
107
|
readonly warn: (message: string) => void;
|
|
97
108
|
constructor(options?: EngineOptions);
|
|
98
109
|
/** The current state. A new object after every change, the same object until then. */
|
|
@@ -149,7 +160,12 @@ export declare class Engine {
|
|
|
149
160
|
* first one that takes over, or undefined when none does and the text should be inserted as usual.
|
|
150
161
|
*/
|
|
151
162
|
triggerText(typed: string): TriggerResult | undefined;
|
|
152
|
-
/**
|
|
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. */
|
|
153
169
|
use(plugin: Plugin): Unsubscribe;
|
|
154
170
|
}
|
|
155
171
|
export {};
|
package/dist/engine.js
CHANGED
|
@@ -3,8 +3,10 @@ import { detectPlatform, Keymap } from "./keymap.js";
|
|
|
3
3
|
import { emptyDoc } from "./model.js";
|
|
4
4
|
import { caret, isCollapsed, pathKey } from "./path.js";
|
|
5
5
|
import { startOf } from "./edit.js";
|
|
6
|
+
import { contentPolicy } from "./policy.js";
|
|
6
7
|
import { countDocument, countSelection, textLength } from "./stats.js";
|
|
7
8
|
import { UiRegistry } from "./ui.js";
|
|
9
|
+
import { describeError } from "./warning.js";
|
|
8
10
|
import { PLUGIN_API_VERSION, satisfiesApi } from "./plugin.js";
|
|
9
11
|
import { ops } from "./ops.js";
|
|
10
12
|
const sameSelection = (a, b) => a === b || (a.anchor.offset === b.anchor.offset && a.focus.offset === b.focus.offset && pathKey(a.anchor.path) === pathKey(b.anchor.path) && pathKey(a.focus.path) === pathKey(b.focus.path));
|
|
@@ -42,7 +44,8 @@ export class Engine {
|
|
|
42
44
|
focusRequest;
|
|
43
45
|
maxLength;
|
|
44
46
|
onMaxLength;
|
|
45
|
-
|
|
47
|
+
report;
|
|
48
|
+
warn = (message) => this.report({ code: "plugin-message", message });
|
|
46
49
|
constructor(options = {}) {
|
|
47
50
|
this.doc = options.doc ?? emptyDoc;
|
|
48
51
|
this.sel = caret(startOf(this.doc));
|
|
@@ -51,8 +54,19 @@ export class Engine {
|
|
|
51
54
|
this.focusRequest = options.focus;
|
|
52
55
|
this.maxLength = options.maxLength;
|
|
53
56
|
this.onMaxLength = options.onMaxLength;
|
|
54
|
-
|
|
55
|
-
this.
|
|
57
|
+
const handler = options.onWarning ?? ((w) => console.warn(`[rte] ${w.code}: ${w.message}`));
|
|
58
|
+
this.report = (warning) => { try {
|
|
59
|
+
handler(warning);
|
|
60
|
+
}
|
|
61
|
+
catch { } };
|
|
62
|
+
const policy = contentPolicy(options.content, (message) => this.report({ code: "bad-option", message }));
|
|
63
|
+
Object.assign(this.importOptions, {
|
|
64
|
+
linkSchemes: policy.linkSchemes, styles: policy.styles, maxDataImageLength: policy.maxDataImageLength,
|
|
65
|
+
maxDepth: policy.maxDepth, maxInputLength: policy.maxInputLength, pasteRelativeImages: policy.pasteRelativeImages,
|
|
66
|
+
onLimit: (info) => this.report({ code: "content-too-long", message: `Content of ${info.length} characters was not read: the limit is ${info.max} (content.maxInputLength).` }),
|
|
67
|
+
onError: (error) => this.report({ code: "content-unreadable", message: `Content could not be read and was left out: ${describeError(error)}`, error }),
|
|
68
|
+
});
|
|
69
|
+
this.keymap = new Keymap(options.platform ?? detectPlatform(), (message) => this.report({ code: "key-conflict", message }));
|
|
56
70
|
this.commands = new CommandRegistry(this);
|
|
57
71
|
this.current = this.makeSnapshot();
|
|
58
72
|
this.notified = this.current;
|
|
@@ -104,12 +118,12 @@ export class Engine {
|
|
|
104
118
|
return;
|
|
105
119
|
this.notified = snapshot;
|
|
106
120
|
for (const listener of [...this.listeners])
|
|
107
|
-
listener(snapshot, previous);
|
|
121
|
+
this.attempt(undefined, "a change listener", () => listener(snapshot, previous), undefined, "listener-failed");
|
|
108
122
|
for (const sub of [...this.selectors]) {
|
|
109
123
|
const value = sub.select(snapshot);
|
|
110
124
|
if (!sub.equals(value, sub.last)) {
|
|
111
125
|
sub.last = value;
|
|
112
|
-
sub.onChange(value);
|
|
126
|
+
this.attempt(undefined, "a selector callback", () => sub.onChange(value), undefined, "listener-failed");
|
|
113
127
|
}
|
|
114
128
|
}
|
|
115
129
|
this.commands.flush();
|
|
@@ -145,6 +159,9 @@ export class Engine {
|
|
|
145
159
|
}
|
|
146
160
|
this.lastKind = kind;
|
|
147
161
|
this.redoStack = [];
|
|
162
|
+
const top = this.undoStack[this.undoStack.length - 1];
|
|
163
|
+
if (top)
|
|
164
|
+
top.after = result.selection;
|
|
148
165
|
}
|
|
149
166
|
this.doc = result.doc;
|
|
150
167
|
this.sel = result.selection;
|
|
@@ -158,6 +175,7 @@ export class Engine {
|
|
|
158
175
|
if (sameSelection(selection, this.sel))
|
|
159
176
|
return;
|
|
160
177
|
this.sel = selection;
|
|
178
|
+
this.lastKind = "";
|
|
161
179
|
this.commit({ docChanged: false, selectionChanged: true });
|
|
162
180
|
}
|
|
163
181
|
setPluginState(id, value) {
|
|
@@ -172,6 +190,7 @@ export class Engine {
|
|
|
172
190
|
this.refresh();
|
|
173
191
|
if (!isCollapsed(this.sel))
|
|
174
192
|
return false;
|
|
193
|
+
this.lastKind = "";
|
|
175
194
|
this.stored = { path: this.sel.focus.path, offset: this.sel.focus.offset, marks };
|
|
176
195
|
this.commit({ docChanged: false, selectionChanged: false });
|
|
177
196
|
return true;
|
|
@@ -205,7 +224,7 @@ export class Engine {
|
|
|
205
224
|
this.undoStack.push(level);
|
|
206
225
|
return false;
|
|
207
226
|
}
|
|
208
|
-
this.redoStack.push({ doc: this.doc, selection: this.sel });
|
|
227
|
+
this.redoStack.push({ doc: this.doc, selection: level.after ?? this.sel });
|
|
209
228
|
return this.restore(level);
|
|
210
229
|
}
|
|
211
230
|
redo() {
|
|
@@ -215,7 +234,7 @@ export class Engine {
|
|
|
215
234
|
this.redoStack.push(level);
|
|
216
235
|
return false;
|
|
217
236
|
}
|
|
218
|
-
this.undoStack.push({ doc: this.doc, selection: this.sel });
|
|
237
|
+
this.undoStack.push({ doc: this.doc, selection: this.sel, after: level.selection });
|
|
219
238
|
return this.restore(level);
|
|
220
239
|
}
|
|
221
240
|
restore(level) {
|
|
@@ -255,6 +274,16 @@ export class Engine {
|
|
|
255
274
|
}
|
|
256
275
|
return undefined;
|
|
257
276
|
}
|
|
277
|
+
attempt(plugin, where, fn, fallback, code = "plugin-failed") {
|
|
278
|
+
try {
|
|
279
|
+
return fn();
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
const who = plugin ?? "a listener";
|
|
283
|
+
this.report({ code: plugin ? code : "listener-failed", ...(plugin ? { plugin } : {}), message: `${who} failed in ${where}; it was skipped: ${describeError(error)}`, error });
|
|
284
|
+
return fallback?.();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
258
287
|
use(plugin) {
|
|
259
288
|
if (this.plugins.has(plugin.id))
|
|
260
289
|
throw new Error(`The plugin ${plugin.id} is already registered.`);
|
|
@@ -262,10 +291,18 @@ export class Engine {
|
|
|
262
291
|
throw new Error(`The plugin ${plugin.id} needs plugin API ${plugin.api}; this editor has ${PLUGIN_API_VERSION}.`);
|
|
263
292
|
}
|
|
264
293
|
const undo = [];
|
|
294
|
+
const safe = (where, fn, fallback) => ((...args) => this.attempt(plugin.id, where, () => fn(...args), () => fallback(...args)));
|
|
265
295
|
const host = {
|
|
266
296
|
editor: this,
|
|
267
297
|
ops,
|
|
268
|
-
command: (def) => {
|
|
298
|
+
command: (def) => {
|
|
299
|
+
const guarded = {
|
|
300
|
+
...def,
|
|
301
|
+
run: safe(`the command ${def.name}`, def.run.bind(def), () => false),
|
|
302
|
+
...(def.state ? { state: safe(`the state of ${def.name}`, def.state.bind(def), () => ({ enabled: false })) } : {}),
|
|
303
|
+
};
|
|
304
|
+
undo.push(this.commands.register(guarded));
|
|
305
|
+
},
|
|
269
306
|
keybinding: (binding) => {
|
|
270
307
|
this.keymap.add(binding, plugin.id);
|
|
271
308
|
undo.push(() => this.keymap.remove(binding.command));
|
|
@@ -273,10 +310,15 @@ export class Engine {
|
|
|
273
310
|
behavior: (kind, behavior) => {
|
|
274
311
|
if (this.behaviors[kind])
|
|
275
312
|
throw new Error(`The container ${kind} already has a behavior; ${plugin.id} cannot add another.`);
|
|
276
|
-
this.behaviors[kind] =
|
|
313
|
+
this.behaviors[kind] = {
|
|
314
|
+
...(behavior.split ? { split: safe(`the ${kind} split behavior`, behavior.split.bind(behavior), () => undefined) } : {}),
|
|
315
|
+
...(behavior.backspaceAtStart ? { backspaceAtStart: safe(`the ${kind} backspace behavior`, behavior.backspaceAtStart.bind(behavior), () => undefined) } : {}),
|
|
316
|
+
...(behavior.insertBlocks ? { insertBlocks: safe(`the ${kind} paste behavior`, behavior.insertBlocks.bind(behavior), () => undefined) } : {}),
|
|
317
|
+
};
|
|
277
318
|
undo.push(() => { delete this.behaviors[kind]; });
|
|
278
319
|
},
|
|
279
|
-
textTrigger: (
|
|
320
|
+
textTrigger: (given) => {
|
|
321
|
+
const trigger = safe("a text trigger", given, () => undefined);
|
|
280
322
|
this.textTriggers.push(trigger);
|
|
281
323
|
undo.push(() => { const i = this.textTriggers.indexOf(trigger); if (i !== -1)
|
|
282
324
|
this.textTriggers.splice(i, 1); });
|
|
@@ -285,51 +327,83 @@ export class Engine {
|
|
|
285
327
|
if (options.sanitizeSvg) {
|
|
286
328
|
if (this.importOptions.sanitizeSvg)
|
|
287
329
|
throw new Error(`${plugin.id} cannot set an SVG cleaner: one is already set.`);
|
|
288
|
-
this.importOptions.sanitizeSvg = options.sanitizeSvg;
|
|
330
|
+
this.importOptions.sanitizeSvg = safe("the SVG cleaner", options.sanitizeSvg, () => undefined);
|
|
289
331
|
}
|
|
290
|
-
const added = options.recognizers ?? []
|
|
332
|
+
const added = (options.recognizers ?? []).map((r) => ({
|
|
333
|
+
...(r.element ? { element: safe("a recognizer", r.element.bind(r), () => undefined) } : {}),
|
|
334
|
+
...(r.listItemStyle ? { listItemStyle: safe("a recognizer", r.listItemStyle.bind(r), () => undefined) } : {}),
|
|
335
|
+
}));
|
|
291
336
|
this.importOptions.recognizers = [...this.importOptions.recognizers, ...added];
|
|
292
337
|
undo.push(() => {
|
|
293
|
-
if (options.sanitizeSvg
|
|
338
|
+
if (options.sanitizeSvg)
|
|
294
339
|
delete this.importOptions.sanitizeSvg;
|
|
295
340
|
this.importOptions.recognizers = this.importOptions.recognizers.filter((r) => !added.includes(r));
|
|
296
341
|
});
|
|
297
342
|
},
|
|
298
|
-
normalize: (
|
|
343
|
+
normalize: (given) => {
|
|
344
|
+
const normalizer = safe("a normalizer", given, (doc, selection) => ({ doc, selection }));
|
|
299
345
|
this.normalizers.push(normalizer);
|
|
300
346
|
undo.push(() => { const i = this.normalizers.indexOf(normalizer); if (i !== -1)
|
|
301
347
|
this.normalizers.splice(i, 1); });
|
|
302
348
|
},
|
|
303
|
-
pasteTransform: (
|
|
349
|
+
pasteTransform: (given) => {
|
|
350
|
+
const transform = (blocks) => {
|
|
351
|
+
const failed = (error) => { this.report({ code: "plugin-failed", plugin: plugin.id, message: `${plugin.id} failed in a paste transform and was skipped: ${describeError(error)}`, error }); return blocks; };
|
|
352
|
+
try {
|
|
353
|
+
const out = given(blocks);
|
|
354
|
+
return out instanceof Promise ? out.catch(failed) : out;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
return failed(error);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
304
360
|
this.pasteTransforms.push(transform);
|
|
305
361
|
undo.push(() => { const i = this.pasteTransforms.indexOf(transform); if (i !== -1)
|
|
306
362
|
this.pasteTransforms.splice(i, 1); });
|
|
307
363
|
},
|
|
308
364
|
ui: (slot, item) => { undo.push(this.ui.add(slot, item)); },
|
|
309
|
-
fileHandler: (
|
|
365
|
+
fileHandler: (given) => {
|
|
366
|
+
const handler = async (files) => {
|
|
367
|
+
try {
|
|
368
|
+
return await given(files);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
this.report({ code: "plugin-failed", plugin: plugin.id, message: `${plugin.id} failed in a file handler and was skipped: ${describeError(error)}`, error });
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
310
375
|
this.fileHandlers.push(handler);
|
|
311
376
|
undo.push(() => { const i = this.fileHandlers.indexOf(handler); if (i !== -1)
|
|
312
377
|
this.fileHandlers.splice(i, 1); });
|
|
313
378
|
},
|
|
314
|
-
copyFormat: (
|
|
379
|
+
copyFormat: (given) => {
|
|
380
|
+
const formatter = safe("a copy formatter", given, () => undefined);
|
|
315
381
|
this.copyFormatters.push(formatter);
|
|
316
382
|
undo.push(() => { const i = this.copyFormatters.indexOf(formatter); if (i !== -1)
|
|
317
383
|
this.copyFormatters.splice(i, 1); });
|
|
318
384
|
},
|
|
319
|
-
pasteHandler: (
|
|
385
|
+
pasteHandler: (given) => {
|
|
386
|
+
const handler = safe("a paste handler", given, () => undefined);
|
|
320
387
|
this.pasteHandlers.push(handler);
|
|
321
388
|
undo.push(() => { const i = this.pasteHandlers.indexOf(handler); if (i !== -1)
|
|
322
389
|
this.pasteHandlers.splice(i, 1); });
|
|
323
390
|
},
|
|
324
391
|
};
|
|
325
|
-
const teardown = plugin.setup(host);
|
|
326
|
-
if (teardown)
|
|
327
|
-
undo.push(teardown);
|
|
328
392
|
const remove = () => {
|
|
329
393
|
for (const fn of undo.reverse())
|
|
330
|
-
fn
|
|
394
|
+
this.attempt(plugin.id, "taking the plugin away", fn, undefined);
|
|
331
395
|
this.plugins.delete(plugin.id);
|
|
332
396
|
};
|
|
397
|
+
try {
|
|
398
|
+
const teardown = plugin.setup(host);
|
|
399
|
+
if (teardown)
|
|
400
|
+
undo.push(teardown);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
remove();
|
|
404
|
+
this.report({ code: "plugin-failed", plugin: plugin.id, message: `${plugin.id} failed to start and was left out: ${describeError(error)}`, error });
|
|
405
|
+
return () => undefined;
|
|
406
|
+
}
|
|
333
407
|
this.plugins.set(plugin.id, remove);
|
|
334
408
|
return remove;
|
|
335
409
|
}
|
package/dist/export.js
CHANGED
|
@@ -61,7 +61,7 @@ function exportList(list, depth) {
|
|
|
61
61
|
const style = list.style !== undefined && list.style !== DEFAULT_MARKER(list.type, depth) ? ` style="list-style-type: ${list.style};"` : "";
|
|
62
62
|
const start = list.type === "ol" && list.start !== undefined ? ` start="${list.start}"` : "";
|
|
63
63
|
const items = list.items.map((item) => {
|
|
64
|
-
const noMarker = item.checked !== undefined || (item.content.length === 0 && (item.children[0]?.type === "ul" || item.children[0]?.type === "ol"));
|
|
64
|
+
const noMarker = item.checked !== undefined || (item.bare === true && item.content.length === 0 && (item.children[0]?.type === "ul" || item.children[0]?.type === "ol"));
|
|
65
65
|
const box = item.checked === undefined ? "" : `<input type="checkbox" disabled${item.checked ? " checked" : ""}> `;
|
|
66
66
|
const paragraphs = [[]];
|
|
67
67
|
for (const node of item.content) {
|
package/dist/html/parse.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface HText {
|
|
|
9
9
|
readonly text: string;
|
|
10
10
|
}
|
|
11
11
|
export type HNode = HElement | HText;
|
|
12
|
-
/**
|
|
13
|
-
export declare
|
|
12
|
+
/** Deepest nesting read when the caller does not say. */
|
|
13
|
+
export declare const DEFAULT_MAX_DEPTH = 100;
|
|
14
|
+
/**
|
|
15
|
+
* Parses markup into nodes. Tag and attribute names are lower-cased and entities in text and values are decoded. An element
|
|
16
|
+
* that would nest deeper than `maxDepth` is left out and what is inside it stays in the deepest element allowed, so the tree is
|
|
17
|
+
* never deeper than that however the markup is written.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseHtml(source: string, maxDepth?: number): HNode[];
|
|
14
20
|
export declare function findAll(nodes: readonly HNode[], tag: string, out?: HElement[]): HElement[];
|
package/dist/html/parse.js
CHANGED
|
@@ -7,7 +7,8 @@ const BLOCKS_CLOSING_P = new Set([
|
|
|
7
7
|
]);
|
|
8
8
|
const TAG_NAME = /[A-Za-z][^\s/>]*/y;
|
|
9
9
|
const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/y;
|
|
10
|
-
export
|
|
10
|
+
export const DEFAULT_MAX_DEPTH = 100;
|
|
11
|
+
export function parseHtml(source, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
11
12
|
const root = { type: "el", tag: "#root", attrs: {}, children: [] };
|
|
12
13
|
const stack = [root];
|
|
13
14
|
const top = () => stack[stack.length - 1];
|
|
@@ -123,9 +124,12 @@ export function parseHtml(source) {
|
|
|
123
124
|
closeWithin(new Set(["thead", "tbody", "tfoot"]), new Set(["table"]));
|
|
124
125
|
if (BLOCKS_CLOSING_P.has(tag))
|
|
125
126
|
closeWithin(new Set(["p"]), new Set(["li", "td", "th", "table", "blockquote", "div", "ul", "ol"]));
|
|
127
|
+
const void_ = VOID.has(tag);
|
|
128
|
+
if (!void_ && stack.length > maxDepth)
|
|
129
|
+
continue;
|
|
126
130
|
const el = { type: "el", tag, attrs, children: [] };
|
|
127
131
|
top().children.push(el);
|
|
128
|
-
if (!
|
|
132
|
+
if (!void_)
|
|
129
133
|
stack.push(el);
|
|
130
134
|
}
|
|
131
135
|
return root.children;
|
package/dist/images/plugin.js
CHANGED
|
@@ -21,7 +21,8 @@ export function images(options = {}) {
|
|
|
21
21
|
const checked = url ? checkImageSrc(url) : undefined;
|
|
22
22
|
return checked ?? reject("unsupported", file.type, file.size);
|
|
23
23
|
}
|
|
24
|
-
catch {
|
|
24
|
+
catch (error) {
|
|
25
|
+
host.editor.report({ code: "upload-failed", message: `Uploading an image failed: ${error instanceof Error ? error.message : String(error)}`, error });
|
|
25
26
|
return reject("upload-failed", file.type, file.size);
|
|
26
27
|
}
|
|
27
28
|
};
|
package/dist/images/view.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ViewExtension, ViewHost } from "../plugin.ts";
|
|
2
2
|
/** The attribute on the selected image, for styling. */
|
|
3
|
-
export declare const SELECTED_IMAGE = "data-
|
|
3
|
+
export declare const SELECTED_IMAGE = "data-rte-image-selected";
|
|
4
4
|
export declare class ImageSelection {
|
|
5
5
|
private readonly host;
|
|
6
6
|
private marked;
|
package/dist/images/view.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { imageSelection, selectedImage } from "../imageselect.js";
|
|
2
|
-
export const SELECTED_IMAGE = "data-
|
|
2
|
+
export const SELECTED_IMAGE = "data-rte-image-selected";
|
|
3
3
|
const MIN = 16;
|
|
4
4
|
export class ImageSelection {
|
|
5
5
|
host;
|
|
@@ -40,7 +40,7 @@ export class ImageSelection {
|
|
|
40
40
|
return el instanceof HTMLImageElement ? el : undefined;
|
|
41
41
|
}
|
|
42
42
|
update() {
|
|
43
|
-
const img = this.find();
|
|
43
|
+
const img = this.engine.readOnly ? undefined : this.find();
|
|
44
44
|
if (img === this.marked) {
|
|
45
45
|
this.place();
|
|
46
46
|
return;
|
|
@@ -60,7 +60,7 @@ export class ImageSelection {
|
|
|
60
60
|
}
|
|
61
61
|
if (!this.handle) {
|
|
62
62
|
const handle = document.createElement("div");
|
|
63
|
-
handle.setAttribute("data-
|
|
63
|
+
handle.setAttribute("data-rte-resize-handle", "");
|
|
64
64
|
handle.style.cssText = "position:fixed;z-index:2147483646;width:12px;height:12px;background:#17408b;border:2px solid #fff;border-radius:3px;cursor:nwse-resize;touch-action:none;box-sizing:border-box";
|
|
65
65
|
handle.addEventListener("pointerdown", (e) => this.start(e));
|
|
66
66
|
document.body.appendChild(handle);
|
|
@@ -80,7 +80,7 @@ export class ImageSelection {
|
|
|
80
80
|
event.preventDefault();
|
|
81
81
|
const box = img.getBoundingClientRect();
|
|
82
82
|
const guide = document.createElement("div");
|
|
83
|
-
guide.setAttribute("data-
|
|
83
|
+
guide.setAttribute("data-rte-resize-line", "");
|
|
84
84
|
guide.style.cssText = `position:fixed;z-index:2147483645;pointer-events:none;border:2px dashed #17408b;left:${box.left}px;top:${box.top}px;width:${box.width}px;height:${box.height}px;box-sizing:border-box`;
|
|
85
85
|
document.body.appendChild(guide);
|
|
86
86
|
this.drag = { startX: event.clientX, startWidth: box.width, width: box.width, guide };
|
package/dist/import.d.ts
CHANGED
|
@@ -49,8 +49,24 @@ export interface ImportOptions {
|
|
|
49
49
|
readonly linkSchemes?: readonly string[];
|
|
50
50
|
/** Keep empty paragraphs. Content that is loaded keeps them; pasted content does not. */
|
|
51
51
|
readonly keepEmptyBlocks?: boolean;
|
|
52
|
-
/** Largest `data:` image address, in characters. */
|
|
52
|
+
/** Largest `data:` image address, in characters. 0 refuses every `data:` image. Default 12 million. */
|
|
53
53
|
readonly maxDataImageLength?: number;
|
|
54
|
+
/** Longest markup read, in characters. Longer markup is refused whole: nothing is read, `onLimit` is told. Default 50 million. */
|
|
55
|
+
readonly maxInputLength?: number;
|
|
56
|
+
/** Keep an image whose address is relative (`a.png`, `/a.png`). Default true; pasting turns it off (see `pasteRelativeImages`). */
|
|
57
|
+
readonly relativeImages?: boolean;
|
|
58
|
+
/** Keep relative image addresses when pasting. Default false: a pasted `/path` would be fetched from the page's own origin. */
|
|
59
|
+
readonly pasteRelativeImages?: boolean;
|
|
60
|
+
/** Deepest element nesting read; deeper elements are left out and their content kept. Default 100. */
|
|
61
|
+
readonly maxDepth?: number;
|
|
62
|
+
/** Told when reading failed for a reason in the reader or a recognizer: the content is then left out and nothing is thrown. */
|
|
63
|
+
readonly onError?: (error: unknown) => void;
|
|
64
|
+
/** Told when markup was refused for being longer than `maxInputLength`. */
|
|
65
|
+
readonly onLimit?: (info: {
|
|
66
|
+
readonly limit: "maxInputLength";
|
|
67
|
+
readonly max: number;
|
|
68
|
+
readonly length: number;
|
|
69
|
+
}) => void;
|
|
54
70
|
}
|
|
55
71
|
export declare function parseStyle(style: string | undefined): Map<string, string>;
|
|
56
72
|
/** Returns the address when its scheme is allowed, otherwise undefined. Obfuscation with spaces, tabs or entities does not help. */
|
|
@@ -62,4 +78,4 @@ export declare function importHtml(html: string, options?: ImportOptions): Doc;
|
|
|
62
78
|
export declare function importText(value: string): Block[];
|
|
63
79
|
export { decodeEntities };
|
|
64
80
|
/** Checks an image address the same way the importer does: remote, or a raster image whose bytes match its type. */
|
|
65
|
-
export declare const checkImageSrc: (src: string, maxDataLength?: number, sanitizeSvg?: ImportOptions["sanitizeSvg"]) => string | undefined;
|
|
81
|
+
export declare const checkImageSrc: (src: string, maxDataLength?: number, sanitizeSvg?: ImportOptions["sanitizeSvg"], relative?: boolean) => string | undefined;
|