@rathkit/rte-core 0.1.0-alpha.3 → 0.1.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/autoformat/autoformat.js +18 -3
- package/dist/base64.js +5 -1
- package/dist/dom/index.d.ts +1 -1
- package/dist/dom/render.js +26 -15
- package/dist/dom/view.d.ts +59 -6
- package/dist/dom/view.js +195 -38
- package/dist/edit.d.ts +6 -0
- package/dist/edit.js +101 -11
- package/dist/engine.d.ts +26 -4
- package/dist/engine.js +113 -23
- package/dist/export.d.ts +2 -2
- package/dist/export.js +22 -6
- package/dist/footnotes/index.d.ts +2 -0
- package/dist/footnotes/index.js +2 -0
- package/dist/footnotes/plugin.d.ts +6 -0
- package/dist/footnotes/plugin.js +130 -0
- package/dist/footnotes/reconcile.d.ts +4 -0
- package/dist/footnotes/reconcile.js +155 -0
- package/dist/html/parse.d.ts +8 -2
- package/dist/html/parse.js +6 -2
- package/dist/images/images.js +4 -3
- package/dist/images/index.d.ts +5 -1
- package/dist/images/index.js +3 -1
- package/dist/images/plugin.js +2 -1
- package/dist/{dom/imageview.d.ts → images/view.d.ts} +9 -6
- package/dist/{dom/imageview.js → images/view.js} +20 -14
- package/dist/import.d.ts +18 -2
- package/dist/import.js +87 -42
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/lists/lists.js +55 -30
- package/dist/markdown/detect.d.ts +1 -0
- package/dist/markdown/detect.js +81 -0
- package/dist/markdown/export.d.ts +10 -0
- package/dist/markdown/export.js +252 -0
- package/dist/markdown/index.d.ts +5 -0
- package/dist/markdown/index.js +4 -0
- package/dist/markdown/inline.d.ts +37 -0
- package/dist/markdown/inline.js +604 -0
- package/dist/markdown/parse.d.ts +4 -0
- package/dist/markdown/parse.js +586 -0
- package/dist/markdown/plugin.d.ts +20 -0
- package/dist/markdown/plugin.js +56 -0
- package/dist/model.d.ts +30 -2
- package/dist/model.js +2 -0
- package/dist/notes.d.ts +6 -0
- package/dist/notes.js +49 -0
- package/dist/ops.d.ts +6 -1
- package/dist/ops.js +5 -4
- package/dist/paste.js +9 -2
- package/dist/path.d.ts +10 -1
- package/dist/path.js +110 -11
- package/dist/plugin.d.ts +53 -2
- package/dist/policy.d.ts +41 -0
- package/dist/policy.js +40 -0
- package/dist/standard/index.d.ts +5 -3
- package/dist/standard/index.js +6 -5
- package/dist/stats.js +1 -1
- package/dist/svg/svg.js +9 -1
- package/dist/{dom/tableview.d.ts → tables/cells.d.ts} +7 -6
- package/dist/{dom/tableview.js → tables/cells.js} +16 -12
- package/dist/{dom/tablegrips.d.ts → tables/grips.d.ts} +5 -4
- package/dist/{dom/tablegrips.js → tables/grips.js} +10 -9
- package/dist/tables/index.d.ts +5 -1
- package/dist/tables/index.js +3 -1
- package/dist/tables/view.d.ts +2 -0
- package/dist/tables/view.js +15 -0
- package/dist/warning.d.ts +16 -0
- package/dist/warning.js +5 -0
- package/package.json +23 -1
package/dist/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));
|
|
@@ -17,6 +19,8 @@ export class Engine {
|
|
|
17
19
|
behaviors = {};
|
|
18
20
|
textTriggers = [];
|
|
19
21
|
pasteTransforms = [];
|
|
22
|
+
copyFormatters = [];
|
|
23
|
+
normalizers = [];
|
|
20
24
|
fileHandlers = [];
|
|
21
25
|
importOptions = { recognizers: [] };
|
|
22
26
|
doc;
|
|
@@ -40,7 +44,8 @@ export class Engine {
|
|
|
40
44
|
focusRequest;
|
|
41
45
|
maxLength;
|
|
42
46
|
onMaxLength;
|
|
43
|
-
|
|
47
|
+
report;
|
|
48
|
+
warn = (message) => this.report({ code: "plugin-message", message });
|
|
44
49
|
constructor(options = {}) {
|
|
45
50
|
this.doc = options.doc ?? emptyDoc;
|
|
46
51
|
this.sel = caret(startOf(this.doc));
|
|
@@ -49,8 +54,19 @@ export class Engine {
|
|
|
49
54
|
this.focusRequest = options.focus;
|
|
50
55
|
this.maxLength = options.maxLength;
|
|
51
56
|
this.onMaxLength = options.onMaxLength;
|
|
52
|
-
|
|
53
|
-
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 }));
|
|
54
70
|
this.commands = new CommandRegistry(this);
|
|
55
71
|
this.current = this.makeSnapshot();
|
|
56
72
|
this.notified = this.current;
|
|
@@ -102,12 +118,12 @@ export class Engine {
|
|
|
102
118
|
return;
|
|
103
119
|
this.notified = snapshot;
|
|
104
120
|
for (const listener of [...this.listeners])
|
|
105
|
-
listener(snapshot, previous);
|
|
121
|
+
this.attempt(undefined, "a change listener", () => listener(snapshot, previous), undefined, "listener-failed");
|
|
106
122
|
for (const sub of [...this.selectors]) {
|
|
107
123
|
const value = sub.select(snapshot);
|
|
108
124
|
if (!sub.equals(value, sub.last)) {
|
|
109
125
|
sub.last = value;
|
|
110
|
-
sub.onChange(value);
|
|
126
|
+
this.attempt(undefined, "a selector callback", () => sub.onChange(value), undefined, "listener-failed");
|
|
111
127
|
}
|
|
112
128
|
}
|
|
113
129
|
this.commands.flush();
|
|
@@ -123,7 +139,7 @@ export class Engine {
|
|
|
123
139
|
return false;
|
|
124
140
|
this.refresh();
|
|
125
141
|
const before = { doc: this.doc, selection: this.sel };
|
|
126
|
-
const result = op(this.doc, this.sel);
|
|
142
|
+
const result = this.settle(op(this.doc, this.sel));
|
|
127
143
|
if (result.doc === this.doc && sameSelection(result.selection, this.sel))
|
|
128
144
|
return false;
|
|
129
145
|
const docChanged = result.doc !== this.doc;
|
|
@@ -143,16 +159,23 @@ export class Engine {
|
|
|
143
159
|
}
|
|
144
160
|
this.lastKind = kind;
|
|
145
161
|
this.redoStack = [];
|
|
162
|
+
const top = this.undoStack[this.undoStack.length - 1];
|
|
163
|
+
if (top)
|
|
164
|
+
top.after = result.selection;
|
|
146
165
|
}
|
|
147
166
|
this.doc = result.doc;
|
|
148
167
|
this.sel = result.selection;
|
|
149
168
|
this.commit({ docChanged, selectionChanged: !sameSelection(before.selection, this.sel) });
|
|
150
169
|
return true;
|
|
151
170
|
}
|
|
171
|
+
settle(result) {
|
|
172
|
+
return result.doc === this.doc ? result : this.normalizers.reduce((r, normalize) => normalize(r.doc, r.selection), result);
|
|
173
|
+
}
|
|
152
174
|
setSelection(selection) {
|
|
153
175
|
if (sameSelection(selection, this.sel))
|
|
154
176
|
return;
|
|
155
177
|
this.sel = selection;
|
|
178
|
+
this.lastKind = "";
|
|
156
179
|
this.commit({ docChanged: false, selectionChanged: true });
|
|
157
180
|
}
|
|
158
181
|
setPluginState(id, value) {
|
|
@@ -167,6 +190,7 @@ export class Engine {
|
|
|
167
190
|
this.refresh();
|
|
168
191
|
if (!isCollapsed(this.sel))
|
|
169
192
|
return false;
|
|
193
|
+
this.lastKind = "";
|
|
170
194
|
this.stored = { path: this.sel.focus.path, offset: this.sel.focus.offset, marks };
|
|
171
195
|
this.commit({ docChanged: false, selectionChanged: false });
|
|
172
196
|
return true;
|
|
@@ -184,8 +208,9 @@ export class Engine {
|
|
|
184
208
|
this.commit({ docChanged: false, selectionChanged: false });
|
|
185
209
|
}
|
|
186
210
|
setDocument(doc, selection) {
|
|
187
|
-
this.doc
|
|
188
|
-
this.
|
|
211
|
+
const settled = this.settle({ doc, selection: selection ?? caret(startOf(doc)) });
|
|
212
|
+
this.doc = settled.doc;
|
|
213
|
+
this.sel = settled.selection;
|
|
189
214
|
this.undoStack = [];
|
|
190
215
|
this.redoStack = [];
|
|
191
216
|
this.lastKind = "";
|
|
@@ -199,7 +224,7 @@ export class Engine {
|
|
|
199
224
|
this.undoStack.push(level);
|
|
200
225
|
return false;
|
|
201
226
|
}
|
|
202
|
-
this.redoStack.push({ doc: this.doc, selection: this.sel });
|
|
227
|
+
this.redoStack.push({ doc: this.doc, selection: level.after ?? this.sel });
|
|
203
228
|
return this.restore(level);
|
|
204
229
|
}
|
|
205
230
|
redo() {
|
|
@@ -209,7 +234,7 @@ export class Engine {
|
|
|
209
234
|
this.redoStack.push(level);
|
|
210
235
|
return false;
|
|
211
236
|
}
|
|
212
|
-
this.undoStack.push({ doc: this.doc, selection: this.sel });
|
|
237
|
+
this.undoStack.push({ doc: this.doc, selection: this.sel, after: level.selection });
|
|
213
238
|
return this.restore(level);
|
|
214
239
|
}
|
|
215
240
|
restore(level) {
|
|
@@ -249,6 +274,16 @@ export class Engine {
|
|
|
249
274
|
}
|
|
250
275
|
return undefined;
|
|
251
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
|
+
}
|
|
252
287
|
use(plugin) {
|
|
253
288
|
if (this.plugins.has(plugin.id))
|
|
254
289
|
throw new Error(`The plugin ${plugin.id} is already registered.`);
|
|
@@ -256,10 +291,18 @@ export class Engine {
|
|
|
256
291
|
throw new Error(`The plugin ${plugin.id} needs plugin API ${plugin.api}; this editor has ${PLUGIN_API_VERSION}.`);
|
|
257
292
|
}
|
|
258
293
|
const undo = [];
|
|
294
|
+
const safe = (where, fn, fallback) => ((...args) => this.attempt(plugin.id, where, () => fn(...args), () => fallback(...args)));
|
|
259
295
|
const host = {
|
|
260
296
|
editor: this,
|
|
261
297
|
ops,
|
|
262
|
-
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
|
+
},
|
|
263
306
|
keybinding: (binding) => {
|
|
264
307
|
this.keymap.add(binding, plugin.id);
|
|
265
308
|
undo.push(() => this.keymap.remove(binding.command));
|
|
@@ -267,10 +310,15 @@ export class Engine {
|
|
|
267
310
|
behavior: (kind, behavior) => {
|
|
268
311
|
if (this.behaviors[kind])
|
|
269
312
|
throw new Error(`The container ${kind} already has a behavior; ${plugin.id} cannot add another.`);
|
|
270
|
-
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
|
+
};
|
|
271
318
|
undo.push(() => { delete this.behaviors[kind]; });
|
|
272
319
|
},
|
|
273
|
-
textTrigger: (
|
|
320
|
+
textTrigger: (given) => {
|
|
321
|
+
const trigger = safe("a text trigger", given, () => undefined);
|
|
274
322
|
this.textTriggers.push(trigger);
|
|
275
323
|
undo.push(() => { const i = this.textTriggers.indexOf(trigger); if (i !== -1)
|
|
276
324
|
this.textTriggers.splice(i, 1); });
|
|
@@ -279,41 +327,83 @@ export class Engine {
|
|
|
279
327
|
if (options.sanitizeSvg) {
|
|
280
328
|
if (this.importOptions.sanitizeSvg)
|
|
281
329
|
throw new Error(`${plugin.id} cannot set an SVG cleaner: one is already set.`);
|
|
282
|
-
this.importOptions.sanitizeSvg = options.sanitizeSvg;
|
|
330
|
+
this.importOptions.sanitizeSvg = safe("the SVG cleaner", options.sanitizeSvg, () => undefined);
|
|
283
331
|
}
|
|
284
|
-
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
|
+
}));
|
|
285
336
|
this.importOptions.recognizers = [...this.importOptions.recognizers, ...added];
|
|
286
337
|
undo.push(() => {
|
|
287
|
-
if (options.sanitizeSvg
|
|
338
|
+
if (options.sanitizeSvg)
|
|
288
339
|
delete this.importOptions.sanitizeSvg;
|
|
289
340
|
this.importOptions.recognizers = this.importOptions.recognizers.filter((r) => !added.includes(r));
|
|
290
341
|
});
|
|
291
342
|
},
|
|
292
|
-
|
|
343
|
+
normalize: (given) => {
|
|
344
|
+
const normalizer = safe("a normalizer", given, (doc, selection) => ({ doc, selection }));
|
|
345
|
+
this.normalizers.push(normalizer);
|
|
346
|
+
undo.push(() => { const i = this.normalizers.indexOf(normalizer); if (i !== -1)
|
|
347
|
+
this.normalizers.splice(i, 1); });
|
|
348
|
+
},
|
|
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
|
+
};
|
|
293
360
|
this.pasteTransforms.push(transform);
|
|
294
361
|
undo.push(() => { const i = this.pasteTransforms.indexOf(transform); if (i !== -1)
|
|
295
362
|
this.pasteTransforms.splice(i, 1); });
|
|
296
363
|
},
|
|
297
364
|
ui: (slot, item) => { undo.push(this.ui.add(slot, item)); },
|
|
298
|
-
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
|
+
};
|
|
299
375
|
this.fileHandlers.push(handler);
|
|
300
376
|
undo.push(() => { const i = this.fileHandlers.indexOf(handler); if (i !== -1)
|
|
301
377
|
this.fileHandlers.splice(i, 1); });
|
|
302
378
|
},
|
|
303
|
-
|
|
379
|
+
copyFormat: (given) => {
|
|
380
|
+
const formatter = safe("a copy formatter", given, () => undefined);
|
|
381
|
+
this.copyFormatters.push(formatter);
|
|
382
|
+
undo.push(() => { const i = this.copyFormatters.indexOf(formatter); if (i !== -1)
|
|
383
|
+
this.copyFormatters.splice(i, 1); });
|
|
384
|
+
},
|
|
385
|
+
pasteHandler: (given) => {
|
|
386
|
+
const handler = safe("a paste handler", given, () => undefined);
|
|
304
387
|
this.pasteHandlers.push(handler);
|
|
305
388
|
undo.push(() => { const i = this.pasteHandlers.indexOf(handler); if (i !== -1)
|
|
306
389
|
this.pasteHandlers.splice(i, 1); });
|
|
307
390
|
},
|
|
308
391
|
};
|
|
309
|
-
const teardown = plugin.setup(host);
|
|
310
|
-
if (teardown)
|
|
311
|
-
undo.push(teardown);
|
|
312
392
|
const remove = () => {
|
|
313
393
|
for (const fn of undo.reverse())
|
|
314
|
-
fn
|
|
394
|
+
this.attempt(plugin.id, "taking the plugin away", fn, undefined);
|
|
315
395
|
this.plugins.delete(plugin.id);
|
|
316
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
|
+
}
|
|
317
407
|
this.plugins.set(plugin.id, remove);
|
|
318
408
|
return remove;
|
|
319
409
|
}
|
package/dist/export.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Block, type Doc, type Inline } from "./model.ts";
|
|
2
2
|
export declare function exportInline(content: readonly Inline[]): string;
|
|
3
3
|
export declare function exportBlock(block: Block): string;
|
|
4
|
-
export declare
|
|
4
|
+
export declare function exportHtml(doc: Doc, separator?: string): string;
|
package/dist/export.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { escapeAttribute, escapeText } from "./html/entities.js";
|
|
2
2
|
import { lineCss, SIDES } from "./cellborder.js";
|
|
3
|
+
import { noteIds } from "./notes.js";
|
|
4
|
+
import { DEFAULT_MARKER } from "./model.js";
|
|
3
5
|
const MARK_ORDER = ["link", "bold", "italic", "underline", "strike", "code", "sup", "sub", "color", "background"];
|
|
4
6
|
const TAGS = { bold: "strong", italic: "em", underline: "u", strike: "s", code: "code", sup: "sup", sub: "sub" };
|
|
5
7
|
function openClose(mark) {
|
|
@@ -28,11 +30,14 @@ function exportImage(node, placed) {
|
|
|
28
30
|
+ `${node.width !== undefined ? ` width="${node.width}"` : ""}${node.height !== undefined ? ` height="${node.height}"` : ""}`
|
|
29
31
|
+ `${placed && node.align ? ` style="${IMAGE_ALIGN_STYLE[node.align]}"` : ""}${placed && node.caption ? ` data-caption="${escapeAttribute(node.caption)}"` : ""}>`;
|
|
30
32
|
}
|
|
33
|
+
let numbers = [];
|
|
31
34
|
export function exportInline(content) {
|
|
32
35
|
let out = "";
|
|
33
36
|
content.forEach((node, i) => {
|
|
34
37
|
if (node.type === "br")
|
|
35
38
|
out += "<br>";
|
|
39
|
+
else if (node.type === "note")
|
|
40
|
+
out += `<sup data-note="${node.id}"><a href="#fn-${node.id}" id="fnref-${node.id}" role="doc-noteref">${numbers.indexOf(node.id) + 1 || ""}</a></sup>`;
|
|
36
41
|
else if (node.type === "image")
|
|
37
42
|
out += exportImage(node, true);
|
|
38
43
|
else {
|
|
@@ -52,12 +57,11 @@ const blockStyle = (block) => {
|
|
|
52
57
|
return parts.length > 0 ? ` style="${parts.join("; ")};"` : "";
|
|
53
58
|
};
|
|
54
59
|
const dirAttr = (dir) => (dir ? ` dir="${dir}"` : "");
|
|
55
|
-
const DEFAULT_MARKER = (type, depth) => (type === "ol" ? "decimal" : ["disc", "circle", "square"][Math.min(depth, 2)]);
|
|
56
60
|
function exportList(list, depth) {
|
|
57
61
|
const style = list.style !== undefined && list.style !== DEFAULT_MARKER(list.type, depth) ? ` style="list-style-type: ${list.style};"` : "";
|
|
58
62
|
const start = list.type === "ol" && list.start !== undefined ? ` start="${list.start}"` : "";
|
|
59
63
|
const items = list.items.map((item) => {
|
|
60
|
-
const noMarker = item.checked !== undefined || (item.content.length === 0 && item.
|
|
64
|
+
const noMarker = item.checked !== undefined || (item.bare === true && item.content.length === 0 && (item.children[0]?.type === "ul" || item.children[0]?.type === "ol"));
|
|
61
65
|
const box = item.checked === undefined ? "" : `<input type="checkbox" disabled${item.checked ? " checked" : ""}> `;
|
|
62
66
|
const paragraphs = [[]];
|
|
63
67
|
for (const node of item.content) {
|
|
@@ -67,9 +71,12 @@ function exportList(list, depth) {
|
|
|
67
71
|
paragraphs[paragraphs.length - 1].push(node);
|
|
68
72
|
}
|
|
69
73
|
const text = paragraphs.length === 1 ? exportInline(item.content) : paragraphs.map((part, i) => `<p>${i === 0 ? box : ""}${exportInline(part)}</p>`).join("");
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
const inner = `${paragraphs.length === 1 ? box : ""}${text}${item.children.map((child) => (child.type === "ul" || child.type === "ol" ? exportList(child, depth + 1) : exportBlock(child))).join("")}`;
|
|
75
|
+
return list.notes ? inner : `<li${noMarker ? ' style="list-style-type: none;"' : ""}>${inner}</li>`;
|
|
76
|
+
});
|
|
77
|
+
if (list.notes)
|
|
78
|
+
return `<section class="footnotes" role="doc-endnotes"><ol>${list.items.map((item, i) => `<li id="fn-${item.id}" data-note="${item.id}">${items[i]} <a href="#fnref-${item.id}" role="doc-backlink">\u21A9\uFE0E</a></li>`).join("")}</ol></section>`;
|
|
79
|
+
return `<${list.type}${start}${style}>${items.join("")}</${list.type}>`;
|
|
73
80
|
}
|
|
74
81
|
function cellStyle(cell) {
|
|
75
82
|
const parts = [];
|
|
@@ -116,4 +123,13 @@ export function exportBlock(block) {
|
|
|
116
123
|
case "hr": return "<hr>";
|
|
117
124
|
}
|
|
118
125
|
}
|
|
119
|
-
export
|
|
126
|
+
export function exportHtml(doc, separator = "") {
|
|
127
|
+
const outer = numbers;
|
|
128
|
+
numbers = noteIds(doc);
|
|
129
|
+
try {
|
|
130
|
+
return doc.blocks.map(exportBlock).join(separator);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
numbers = outer;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Plugin } from "../plugin.ts";
|
|
2
|
+
export interface FootnotesOptions {
|
|
3
|
+
/** Ctrl+Alt+F inserts a note, Ctrl+Alt+Shift+F jumps between a reference and its note. Default on. */
|
|
4
|
+
readonly keys?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function footnotes(options?: FootnotesOptions): Plugin;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createReconciler } from "./reconcile.js";
|
|
2
|
+
function referenceAt(host, doc, id) {
|
|
3
|
+
const { ops } = host;
|
|
4
|
+
for (const path of ops.containerPaths(doc)) {
|
|
5
|
+
if (ops.isNotes(doc.blocks[path[0]]))
|
|
6
|
+
continue;
|
|
7
|
+
let offset = 0;
|
|
8
|
+
for (const node of ops.getContent(doc, path)) {
|
|
9
|
+
offset += node.type === "text" ? node.text.length : 1;
|
|
10
|
+
if (node.type === "note" && node.id === id)
|
|
11
|
+
return { path, offset };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
function nearby(content, offset) {
|
|
17
|
+
let at = 0;
|
|
18
|
+
let found;
|
|
19
|
+
for (const node of content) {
|
|
20
|
+
const next = at + (node.type === "text" ? node.text.length : 1);
|
|
21
|
+
if (node.type === "note" && (next === offset || at === offset))
|
|
22
|
+
found = found ?? node.id;
|
|
23
|
+
at = next;
|
|
24
|
+
}
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
export function footnotes(options = {}) {
|
|
28
|
+
return {
|
|
29
|
+
id: "rathkit.footnotes",
|
|
30
|
+
api: "1",
|
|
31
|
+
setup(host) {
|
|
32
|
+
const { ops } = host;
|
|
33
|
+
const reconcile = createReconciler(ops);
|
|
34
|
+
host.normalize(reconcile);
|
|
35
|
+
const noteOf = (doc, path) => {
|
|
36
|
+
const block = doc.blocks[path[0]];
|
|
37
|
+
return block && ops.isNotes(block) && path.length >= 2 ? block.items[path[1]]?.id : undefined;
|
|
38
|
+
};
|
|
39
|
+
host.command({
|
|
40
|
+
name: "footnote.insert", label: "Footnote",
|
|
41
|
+
run: (ctx) => ctx.apply("footnote", (doc, sel) => {
|
|
42
|
+
const here = ops.isCollapsed(sel) ? sel.focus.path : sel.anchor.path;
|
|
43
|
+
if (ops.kindAt(doc, here) === "pre" || noteOf(doc, here) !== undefined || ops.isNotes(doc.blocks[here[0]]))
|
|
44
|
+
return { doc, selection: sel };
|
|
45
|
+
const cleared = ops.deleteSelection(doc, sel);
|
|
46
|
+
const pos = cleared.selection.anchor;
|
|
47
|
+
if (ops.kindAt(cleared.doc, pos.path) === "pre")
|
|
48
|
+
return { doc, selection: sel };
|
|
49
|
+
const used = new Set(ops.noteIds(cleared.doc));
|
|
50
|
+
let n = used.size + 1;
|
|
51
|
+
while (used.has(`n${n}`))
|
|
52
|
+
n++;
|
|
53
|
+
const id = `n${n}`;
|
|
54
|
+
const content = ops.getContent(cleared.doc, pos.path);
|
|
55
|
+
const next = ops.normalizeInline([...ops.sliceInline(content, 0, pos.offset), { type: "note", id }, ...ops.sliceInline(content, pos.offset)]);
|
|
56
|
+
const placed = reconcile(ops.setContent(cleared.doc, pos.path, next), ops.caret({ path: pos.path, offset: pos.offset + 1 }));
|
|
57
|
+
const block = placed.doc.blocks.findIndex((b) => ops.isNotes(b));
|
|
58
|
+
const list = placed.doc.blocks[block];
|
|
59
|
+
const index = list && ops.isNotes(list) ? list.items.findIndex((item) => item.id === id) : -1;
|
|
60
|
+
return index === -1 ? placed : { doc: placed.doc, selection: ops.caret({ path: [block, index], offset: 0 }) };
|
|
61
|
+
}),
|
|
62
|
+
state: (snap) => {
|
|
63
|
+
const path = snap.selection.focus.path;
|
|
64
|
+
return { enabled: ops.kindAt(snap.doc, path) !== "pre" && noteOf(snap.doc, path) === undefined };
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
host.command({
|
|
68
|
+
name: "footnote.goto", label: "Go to footnote",
|
|
69
|
+
mutates: false,
|
|
70
|
+
run: (ctx) => {
|
|
71
|
+
const snap = ctx.snapshot;
|
|
72
|
+
const { path, offset } = snap.selection.focus;
|
|
73
|
+
const inNote = noteOf(snap.doc, path);
|
|
74
|
+
const id = inNote ?? nearby(ops.getContent(snap.doc, path), offset);
|
|
75
|
+
if (id === undefined)
|
|
76
|
+
return false;
|
|
77
|
+
const target = inNote !== undefined ? referenceAt(host, snap.doc, id) : (() => {
|
|
78
|
+
const b = snap.doc.blocks.findIndex((block) => ops.isNotes(block));
|
|
79
|
+
const list = snap.doc.blocks[b];
|
|
80
|
+
const i = list && ops.isNotes(list) ? list.items.findIndex((item) => item.id === id) : -1;
|
|
81
|
+
return i === -1 ? undefined : { path: [b, i], offset: 0 };
|
|
82
|
+
})();
|
|
83
|
+
if (!target)
|
|
84
|
+
return false;
|
|
85
|
+
const there = ops.caret({ path: target.path, offset: target.offset });
|
|
86
|
+
return ctx.apply("footnote-goto", (doc) => ({ doc, selection: there }));
|
|
87
|
+
},
|
|
88
|
+
state: (snap) => {
|
|
89
|
+
const { path, offset } = snap.selection.focus;
|
|
90
|
+
return { enabled: noteOf(snap.doc, path) !== undefined || nearby(ops.getContent(snap.doc, path), offset) !== undefined };
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
host.copyFormat((blocks) => {
|
|
94
|
+
const wanted = ops.noteIds({ blocks });
|
|
95
|
+
const list = host.editor.snapshot.doc.blocks.find((b) => ops.isNotes(b));
|
|
96
|
+
if (wanted.length === 0 || !list || !ops.isNotes(list) || blocks.some((b) => ops.isNotes(b)))
|
|
97
|
+
return undefined;
|
|
98
|
+
const items = wanted.flatMap((id) => list.items.filter((item) => item.id === id));
|
|
99
|
+
return items.length === 0 ? undefined : { "text/html": ops.exportHtml({ blocks: [...blocks, { ...list, items }] }) };
|
|
100
|
+
});
|
|
101
|
+
host.pasteTransform((blocks) => {
|
|
102
|
+
const pasted = blocks.flatMap((b) => (ops.isNotes(b) ? b.items.map((item) => item.id ?? "") : []));
|
|
103
|
+
const taken = new Set([...ops.noteIds(host.editor.snapshot.doc), ...ops.noteIds({ blocks })]);
|
|
104
|
+
const rename = new Map();
|
|
105
|
+
let n = taken.size;
|
|
106
|
+
for (const id of pasted) {
|
|
107
|
+
if (id === "" || rename.has(id) || !ops.noteIds(host.editor.snapshot.doc).includes(id))
|
|
108
|
+
continue;
|
|
109
|
+
while (taken.has(`n${++n}`)) { }
|
|
110
|
+
taken.add(`n${n}`);
|
|
111
|
+
rename.set(id, `n${n}`);
|
|
112
|
+
}
|
|
113
|
+
if (rename.size === 0)
|
|
114
|
+
return blocks;
|
|
115
|
+
let doc = { blocks: blocks.map((b) => (ops.isNotes(b) ? { ...b, items: b.items.map((item) => (item.id !== undefined && rename.has(item.id) ? { ...item, id: rename.get(item.id) } : item)) } : b)) };
|
|
116
|
+
for (const path of ops.containerPaths(doc)) {
|
|
117
|
+
const content = ops.getContent(doc, path);
|
|
118
|
+
if (content.some((node) => node.type === "note" && rename.has(node.id)))
|
|
119
|
+
doc = ops.setContent(doc, path, content.map((node) => (node.type === "note" && rename.has(node.id) ? { type: "note", id: rename.get(node.id) } : node)));
|
|
120
|
+
}
|
|
121
|
+
return doc.blocks;
|
|
122
|
+
});
|
|
123
|
+
if (options.keys ?? true) {
|
|
124
|
+
host.keybinding({ key: "Mod-Alt-f", command: "footnote.insert" });
|
|
125
|
+
host.keybinding({ key: "Mod-Alt-Shift-f", command: "footnote.goto" });
|
|
126
|
+
}
|
|
127
|
+
host.ui("toolbar.main", { kind: "button", id: "footnote", command: "footnote.insert", icon: "footnote", group: "insert", order: 4 });
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|