@rathkit/rte-core 0.1.0-alpha.4 → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- return depths[0] > depths[1] ? { doc, selection: caret({ path: previous, offset: inlineLength(getContent(doc, previous)) }) } : { doc, selection: sel };
214
- return joinWithPrevious(doc, previous, pos);
260
+ if (depths) {
261
+ if (depths[0] <= depths[1])
262
+ return { doc, selection: sel };
263
+ if (emptyBlock(doc, pos.path))
264
+ return joinWithPrevious(doc, previous, pos);
265
+ return { doc, selection: caret({ path: previous, offset: inlineLength(getContent(doc, previous)) }) };
266
+ }
267
+ const joined = joinWithPrevious(doc, previous, pos);
268
+ return joined.doc === doc ? { doc, selection: caret({ path: previous, offset: inlineLength(getContent(doc, previous)) }) } : joined;
215
269
  }
216
270
  export function deleteForward(doc, sel) {
217
271
  if (!isCollapsed(sel))
@@ -221,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 next = paths[ordinalOf(doc, pos.path) + 1];
225
- if (!next)
278
+ const ordinal = ordinalOf(doc, pos.path);
279
+ const rule = firstRule(doc, ordinal + 1);
280
+ if (rule)
281
+ return removeRule(doc, rule, ordinal, sel);
282
+ const next = paths[ordinal + 1];
283
+ if (!next || isNoteItem(doc, next))
226
284
  return { doc, selection: sel };
227
285
  const depths = cellDepths(doc, pos.path, next);
228
- if (depths)
229
- return depths[1] > depths[0] ? { doc, selection: caret({ path: next, offset: 0 }) } : { doc, selection: sel };
286
+ if (depths) {
287
+ if (depths[1] <= depths[0])
288
+ return { doc, selection: sel };
289
+ if (emptyBlock(doc, pos.path)) {
290
+ const gone = removeContainer(doc, pos.path);
291
+ return { doc: gone, selection: caret({ path: containerPaths(gone)[ordinal] ?? next, offset: 0 }) };
292
+ }
293
+ return { doc, selection: caret({ path: next, offset: 0 }) };
294
+ }
230
295
  return joinWithPrevious(doc, pos.path, { path: next, offset: 0 });
231
296
  }
297
+ export function endAtBlockEnd(doc, sel) {
298
+ if (isCollapsed(sel))
299
+ return sel;
300
+ const forward = comparePath(sel.anchor.path, sel.focus.path) < 0;
301
+ const [start, end] = forward ? [sel.anchor, sel.focus] : [sel.focus, sel.anchor];
302
+ if (end.offset !== 0 || start.offset !== 0 || pathKey(start.path) === pathKey(end.path))
303
+ return sel;
304
+ const ordinal = ordinalOf(doc, end.path);
305
+ const previous = ordinal > 0 ? ordinalToPath(doc, ordinal - 1) : undefined;
306
+ if (!previous || comparePath(previous, start.path) < 0)
307
+ return sel;
308
+ const trimmed = { path: previous, offset: inlineLength(getContent(doc, previous)) };
309
+ return forward ? { anchor: sel.anchor, focus: trimmed } : { anchor: trimmed, focus: sel.focus };
310
+ }
311
+ const lastRule = (doc, ordinal) => rulePaths(doc).filter((r) => r.before === ordinal).pop();
312
+ const firstRule = (doc, ordinal) => rulePaths(doc).find((r) => r.before === ordinal);
313
+ function removeRule(doc, rule, ordinal, sel) {
314
+ const next = fixup(mapBlockList(doc, rule.path, (blocks, index) => blocks.filter((_, i) => i !== index)));
315
+ const path = containerPaths(next)[ordinal];
316
+ const at = sel.anchor;
317
+ return { doc: next, selection: path ? caret({ path, offset: at.offset }) : sel };
318
+ }
232
319
  function joinWithPrevious(doc, into, from) {
233
320
  const intoKind = kindAt(doc, into);
234
321
  const fromKind = kindAt(doc, from.path);
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?: (message: string) => void;
33
+ readonly onWarning?: (warning: Warning) => void;
32
34
  /** The host calls this before the engine reads state it also tracks elsewhere (a DOM selection, say). */
33
35
  readonly beforeAccess?: () => void;
34
36
  /** Asked to put the keyboard focus in the editor. */
@@ -43,6 +45,11 @@ export interface EngineOptions {
43
45
  readonly limit: number;
44
46
  readonly wouldBe: number;
45
47
  }) => void;
48
+ /**
49
+ * What is kept and what is dropped when HTML is read (loading, `setHtml`, pasting): a profile (`strict`, `standard` or `loose`)
50
+ * and single choices on top of it. See docs/CONTENT-POLICY.md. Default: the `standard` profile.
51
+ */
52
+ readonly content?: ContentOptions;
46
53
  }
47
54
  type Listener = (snapshot: EditorSnapshot, previous: EditorSnapshot) => void;
48
55
  type Change = {
@@ -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
- sanitizeSvg?: ImportOptions["sanitizeSvg"];
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
- /** Adds a plugin. Returns a function that takes everything it added away again. */
163
+ /**
164
+ * Runs `fn` for a plugin or a listener. A throw is reported (`plugin-failed`, or `code`) and `fallback` is used instead; it is never
165
+ * passed on, so one plugin's failure cannot stop the editor or the plugins after it.
166
+ */
167
+ attempt<T>(plugin: string | undefined, where: string, fn: () => T, fallback: (() => T) | undefined, code?: "plugin-failed" | "listener-failed"): T;
168
+ /** Adds a plugin. Returns a function that takes everything it added away again. A plugin that throws while starting is left out and reported. */
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
- warn;
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
- this.warn = options.onWarning ?? ((message) => console.warn(`[rte] ${message}`));
55
- this.keymap = new Keymap(options.platform ?? detectPlatform(), this.warn);
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) => { undo.push(this.commands.register(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] = behavior;
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: (trigger) => {
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 && this.importOptions.sanitizeSvg === 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: (normalizer) => {
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: (transform) => {
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: (handler) => {
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: (formatter) => {
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: (handler) => {
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) {
@@ -9,6 +9,12 @@ export interface HText {
9
9
  readonly text: string;
10
10
  }
11
11
  export type HNode = HElement | HText;
12
- /** Parses markup into nodes. Tag and attribute names are lower-cased and entities in text and values are decoded. */
13
- export declare function parseHtml(source: string): HNode[];
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[];
@@ -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 function parseHtml(source) {
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 (!VOID.has(tag))
132
+ if (!void_)
129
133
  stack.push(el);
130
134
  }
131
135
  return root.children;
@@ -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
  };
@@ -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-rk-image-selected";
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;
@@ -1,5 +1,5 @@
1
1
  import { imageSelection, selectedImage } from "../imageselect.js";
2
- export const SELECTED_IMAGE = "data-rk-image-selected";
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-rk-resize-handle", "");
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-rk-resize-line", "");
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;