@aicayzer/inkkit 0.0.0-stage → 0.0.2

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/editor.js ADDED
@@ -0,0 +1,1149 @@
1
+ import { Editor, defaultValueCtx, editorViewCtx, editorViewOptionsCtx, remarkStringifyOptionsCtx, rootCtx, } from '@milkdown/kit/core';
2
+ import { visibleClipboard, portableClipboard, clipboardText, clipboardContent, } from './clipboard.js';
3
+ import { clipboard } from '@milkdown/kit/plugin/clipboard';
4
+ import { history } from '@milkdown/kit/plugin/history';
5
+ import { cursor } from '@milkdown/kit/plugin/cursor';
6
+ import { blockquoteKeymap, blockquoteSchema, bulletListKeymap, codeBlockKeymap, createCodeBlockCommand, emphasisKeymap, headingKeymap, inlineCodeKeymap, inlineCodeSchema, linkSchema, listItemKeymap, orderedListKeymap, paragraphKeymap, strongKeymap, toggleEmphasisCommand, toggleInlineCodeCommand, toggleLinkCommand, toggleStrongCommand, turnIntoTextCommand, wrapInBulletListCommand, wrapInHeadingCommand, wrapInOrderedListCommand, } from '@milkdown/kit/preset/commonmark';
7
+ import { strikethroughKeymap, toggleStrikethroughCommand, } from '@milkdown/kit/preset/gfm';
8
+ import { NodeRange, Fragment, Slice, } from '@milkdown/kit/prose/model';
9
+ import { keydownHandler, keymap } from '@milkdown/kit/prose/keymap';
10
+ import { liftListItem } from '@milkdown/kit/prose/schema-list';
11
+ import { findWrapping, liftTarget } from '@milkdown/kit/prose/transform';
12
+ import { AllSelection, NodeSelection, Plugin, PluginKey, Selection, TextSelection, } from '@milkdown/kit/prose/state';
13
+ import { $prose, callCommand, replaceAll, } from '@milkdown/kit/utils';
14
+ import { codeCopyPlugin, placeholderPlugin } from './decorations.js';
15
+ import { createDialect, serialize, stringifyOptions } from './dialect.js';
16
+ import { Preservation } from './preserve.js';
17
+ import { normaliseLabel, referenceDefinitions, footnoteDefinitions, } from './references.js';
18
+ import { selectionMarkdown, selectionContent, referenceMetadata, withReferenceMetadata, } from './reference-clipboard.js';
19
+ import { imageView } from './images.js';
20
+ import { tablePlugins, tableCommand } from './tables.js';
21
+ import { InkKitError, } from './types.js';
22
+ import { highlightPlugin } from './highlight.js';
23
+ import { PasteController } from './paste.js';
24
+ import { selectionPlugin } from './selection.js';
25
+ import { search, SearchQuery, getSearchState, setSearchState, } from 'prosemirror-search';
26
+ import { taskListPlugin, toggleTaskList } from './tasks.js';
27
+ const shortcutCommands = {
28
+ heading1: ['heading', 1],
29
+ heading2: ['heading', 2],
30
+ heading3: ['heading', 3],
31
+ paragraph: ['paragraph'],
32
+ bold: ['bold'],
33
+ italic: ['italic'],
34
+ strikethrough: ['strikethrough'],
35
+ code: ['code'],
36
+ codeBlock: ['codeBlock'],
37
+ quote: ['quote'],
38
+ bulletList: ['bulletList'],
39
+ orderedList: ['orderedList'],
40
+ taskList: ['taskList'],
41
+ };
42
+ const markNames = {
43
+ strong: 'bold',
44
+ emphasis: 'italic',
45
+ strike_through: 'strikethrough',
46
+ inlineCode: 'code',
47
+ link: 'link',
48
+ };
49
+ // Select All resolves at the document level, where no block can be read.
50
+ function textBounds(state) {
51
+ const { selection, doc } = state;
52
+ if (selection instanceof AllSelection)
53
+ return TextSelection.between(doc.resolve(0), doc.resolve(doc.content.size));
54
+ return selection;
55
+ }
56
+ function caretState(state) {
57
+ const { empty } = state.selection;
58
+ const { $from, $to } = textBounds(state);
59
+ const active = new Set();
60
+ const marks = empty ? (state.storedMarks ?? $from.marks()) : [];
61
+ for (const mark of marks) {
62
+ const name = markNames[mark.type.name];
63
+ if (name)
64
+ active.add(name);
65
+ }
66
+ if (!empty) {
67
+ for (const [name, mark] of Object.entries(markNames)) {
68
+ const type = state.schema.marks[name];
69
+ if (type && state.doc.rangeHasMark($from.pos, $to.pos, type))
70
+ active.add(mark);
71
+ }
72
+ }
73
+ return {
74
+ marks: [...active],
75
+ block: blockAt($from.parent, $from),
76
+ quoted: quoted($from, $to),
77
+ };
78
+ }
79
+ // True only when one quote holds the whole selection, so the state matches what the command can lift.
80
+ function quoted($from, $to) {
81
+ return ($from.blockRange($to, (node) => node.type.name === 'blockquote') != null);
82
+ }
83
+ function blockAt(parent, $from) {
84
+ for (let depth = $from.depth; depth > 0; depth--) {
85
+ const node = $from.node(depth);
86
+ switch (node.type.name) {
87
+ case 'heading':
88
+ return { type: 'heading', level: node.attrs.level };
89
+ case 'code_block':
90
+ return { type: 'codeBlock' };
91
+ case 'list_item':
92
+ return node.attrs.checked == null
93
+ ? {
94
+ type: $from.node(depth - 1).type.name === 'ordered_list'
95
+ ? 'orderedList'
96
+ : 'bulletList',
97
+ }
98
+ : { type: 'taskList' };
99
+ }
100
+ }
101
+ if (parent.type.name === 'heading')
102
+ return { type: 'heading', level: parent.attrs.level };
103
+ return { type: 'paragraph' };
104
+ }
105
+ // Backspace at the start of a quote's first block is left alone by the preset;
106
+ // leaving the quote is what a Backspace there means.
107
+ const quoteBackspace = $prose(() => keymap({
108
+ Backspace: (state, dispatch) => {
109
+ const { $from, empty } = state.selection;
110
+ if (!empty || $from.parentOffset > 0 || $from.depth < 2)
111
+ return false;
112
+ if ($from.node($from.depth - 1).type.name !== 'blockquote' ||
113
+ $from.index($from.depth - 1) > 0)
114
+ return false;
115
+ const range = $from.blockRange($from, (node) => node.type.name === 'blockquote');
116
+ const target = range && liftTarget(range);
117
+ if (!range || target == null)
118
+ return false;
119
+ dispatch?.(state.tr.lift(range, target).scrollIntoView());
120
+ return true;
121
+ },
122
+ }));
123
+ // The preset lifts only an item's first line, which cannot leave on its own while the item holds
124
+ // more, such as a nested list: at the top of a document Backspace did nothing, and below another block
125
+ // it merged the line upward and left an empty bullet. The whole item is lifted instead, and a nested
126
+ // list it held joins the items that followed, since both now sit at one level.
127
+ const listItemBackspace = $prose(() => keymap({
128
+ Backspace: (state, dispatch) => {
129
+ const { $from, empty } = state.selection;
130
+ if (!empty || $from.parentOffset > 0 || $from.depth < 3)
131
+ return false;
132
+ const item = $from.node(-1);
133
+ if (item.type.name !== 'list_item' ||
134
+ item.childCount < 2 ||
135
+ $from.index(-1) > 0 ||
136
+ $from.index(-2) > 0)
137
+ return false;
138
+ const { doc } = state;
139
+ const range = new NodeRange(doc.resolve($from.start(-1)), doc.resolve($from.end(-1)), $from.depth - 1);
140
+ const target = liftTarget(range);
141
+ if (target == null)
142
+ return false;
143
+ const tr = state.tr.lift(range, target);
144
+ const $line = tr.doc.resolve(tr.mapping.map($from.pos));
145
+ joinLists(tr, $line.start(-1), $line.index(-1) + item.childCount);
146
+ dispatch?.(tr.scrollIntoView());
147
+ return true;
148
+ },
149
+ }));
150
+ // Lifting an item out of a list that no item holds, the preset leaves a nested list the item held
151
+ // beside the items that followed. Out of a nested list it joins the two itself.
152
+ const liftItem = (state, dispatch) => {
153
+ const itemType = state.schema.nodes.list_item;
154
+ const { $from, $to } = state.selection;
155
+ const range = $from.blockRange($to, (node) => node.firstChild?.type === itemType);
156
+ const lift = liftListItem(itemType);
157
+ if (!range || !dispatch || $from.node(range.depth - 1).type === itemType)
158
+ return lift(state, dispatch);
159
+ let lifted;
160
+ if (!lift(state, (tr) => (lifted = tr)) || !lifted)
161
+ return false;
162
+ let count = 0;
163
+ for (let index = range.startIndex; index < range.endIndex; index++)
164
+ count += range.parent.child(index).childCount;
165
+ const start = range.$from.start(range.depth - 1);
166
+ const first = range.$from.index(range.depth - 1) + (range.startIndex > 0 ? 1 : 0);
167
+ joinLists(lifted, start, first + count);
168
+ joinLists(lifted, start, first);
169
+ dispatch(lifted);
170
+ return true;
171
+ };
172
+ const listItemShiftTab = $prose(() => keymap({ 'Shift-Tab': liftItem }));
173
+ // A control chord that nothing handles reaches the page as its ASCII control character
174
+ // (Control-N as U+000E), which the web view would insert as text.
175
+ const controlCharacters = /^[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]+$/;
176
+ const dropControlCharacters = $prose(() => new Plugin({
177
+ key: new PluginKey('dropControlCharacters'),
178
+ props: {
179
+ handleTextInput: (_view, _from, _to, text) => controlCharacters.test(text),
180
+ },
181
+ }));
182
+ // The listener plugin reports selection changes from inside state.apply, before
183
+ // the view holds the new state, so the caret state is read from the view instead.
184
+ function caretStatePlugin(events) {
185
+ return $prose(() => new Plugin({
186
+ key: new PluginKey('caretState'),
187
+ view: () => ({
188
+ update(view, previous) {
189
+ const { state } = view;
190
+ if (state.selection.eq(previous.selection) &&
191
+ state.doc.eq(previous.doc) &&
192
+ state.storedMarks === previous.storedMarks)
193
+ return;
194
+ events.stateChanged(caretState(state));
195
+ },
196
+ }),
197
+ }));
198
+ }
199
+ function outermostListDepth($pos) {
200
+ for (let depth = 1; depth <= $pos.depth; depth++) {
201
+ if (isList($pos.node(depth)))
202
+ return depth;
203
+ }
204
+ return null;
205
+ }
206
+ function innermostListDepth($pos) {
207
+ for (let depth = $pos.depth; depth >= 1; depth--) {
208
+ if (isList($pos.node(depth)))
209
+ return depth;
210
+ }
211
+ return null;
212
+ }
213
+ function isList(node) {
214
+ return node.type.name === 'bullet_list' || node.type.name === 'ordered_list';
215
+ }
216
+ // Two lists of one kind side by side are written with a changed marker to keep them apart, and the
217
+ // document shows a gap between them, so an edit that leaves them so joins them.
218
+ function joinLists(tr, start, index) {
219
+ const $start = tr.doc.resolve(start);
220
+ const before = $start.parent.maybeChild(index - 1);
221
+ const after = $start.parent.maybeChild(index);
222
+ if (before && after && isList(before) && after.type === before.type)
223
+ tr.join($start.posAtIndex(index));
224
+ }
225
+ export class InkKitEditor {
226
+ events;
227
+ options;
228
+ editor;
229
+ lastMarkdown = '';
230
+ baseline = '';
231
+ documentId = '';
232
+ formatType = 'md';
233
+ revision = 0;
234
+ documentEpoch = 0;
235
+ operationSequence = 0;
236
+ plainComposing = false;
237
+ plainSearch = '';
238
+ ready = false;
239
+ destroyed = false;
240
+ preservation;
241
+ pasteController;
242
+ plain;
243
+ root;
244
+ clickHandler;
245
+ footnoteKeyHandler;
246
+ copyHandler;
247
+ originalSource = '';
248
+ generation = 0;
249
+ loading = false;
250
+ changePlugin = $prose(() => new Plugin({
251
+ key: new PluginKey('documentChanges'),
252
+ view: () => ({
253
+ update: (view, previous) => {
254
+ if (this.loading || view.state.doc.eq(previous.doc))
255
+ return;
256
+ this.revision += 1;
257
+ let markdown;
258
+ try {
259
+ markdown =
260
+ this.preservation?.serialize(view.state.doc) ??
261
+ serialize(this.editor.ctx, view.state.doc);
262
+ }
263
+ catch (error) {
264
+ this.events.error?.(error instanceof Error ? error : new Error(String(error)));
265
+ return;
266
+ }
267
+ if (markdown === this.lastMarkdown)
268
+ return;
269
+ this.lastMarkdown = markdown;
270
+ this.events.changed(markdown === this.baseline ? this.originalSource : markdown, this.generation);
271
+ },
272
+ }),
273
+ }));
274
+ keys = () => false;
275
+ constructor(events, options) {
276
+ this.events = events;
277
+ this.options = options;
278
+ }
279
+ context() {
280
+ return {
281
+ documentId: this.documentId,
282
+ generation: this.generation,
283
+ operationId: `${this.documentEpoch}:${this.generation}:${++this.operationSequence}`,
284
+ };
285
+ }
286
+ assertAlive() {
287
+ if (this.destroyed)
288
+ throw new InkKitError('destroyed', 'The editor has been destroyed');
289
+ }
290
+ assertCurrent(generation = this.generation) {
291
+ this.assertAlive();
292
+ if (!this.ready)
293
+ throw new InkKitError('not-ready', 'No document has been loaded');
294
+ if (generation !== this.generation)
295
+ throw new InkKitError('stale-document', 'Document changed');
296
+ if (this.formatType === 'txt'
297
+ ? this.plainComposing
298
+ : this.editor.ctx.get(editorViewCtx).composing)
299
+ throw new InkKitError('composition', 'Text composition is in progress');
300
+ if (this.pasteController.pending)
301
+ throw new InkKitError('operation-pending', 'An image import is in progress');
302
+ }
303
+ plainSource() {
304
+ const normalized = this.originalSource.replace(/\r\n?/g, '\n');
305
+ if (this.plain.value === normalized)
306
+ return this.originalSource;
307
+ const crlf = this.originalSource.includes('\r\n') &&
308
+ !this.originalSource.replaceAll('\r\n', '').includes('\n');
309
+ return crlf ? this.plain.value.replaceAll('\n', '\r\n') : this.plain.value;
310
+ }
311
+ snapshot(expectedGeneration) {
312
+ this.assertCurrent(expectedGeneration);
313
+ let text;
314
+ try {
315
+ text =
316
+ this.formatType === 'txt'
317
+ ? this.plainSource()
318
+ : this.preservation.serialize(this.editor.ctx.get(editorViewCtx).state.doc);
319
+ }
320
+ catch (error) {
321
+ throw new InkKitError('preservation', error instanceof Error ? error.message : 'Cannot preserve Markdown');
322
+ }
323
+ return {
324
+ documentId: this.documentId,
325
+ generation: this.generation,
326
+ revision: this.revision,
327
+ format: this.formatType,
328
+ text,
329
+ dirty: text !== this.originalSource,
330
+ };
331
+ }
332
+ async clipboardSnapshot(all = true) {
333
+ const snapshot = this.snapshot();
334
+ const epoch = this.documentEpoch;
335
+ if (this.formatType === 'txt') {
336
+ const text = all
337
+ ? snapshot.text
338
+ : this.plain.value.slice(this.plain.selectionStart, this.plain.selectionEnd);
339
+ const pre = document.createElement('pre');
340
+ pre.textContent = text;
341
+ return { text, html: pre.outerHTML, markdown: text, images: [] };
342
+ }
343
+ const { doc, schema, selection } = this.editor.ctx.get(editorViewCtx).state;
344
+ const content = all ? doc.content : selection.content().content;
345
+ const valid = content.firstChild?.isInline
346
+ ? Fragment.from(schema.nodes.paragraph.create(null, content))
347
+ : content;
348
+ let markdown;
349
+ try {
350
+ markdown = all
351
+ ? snapshot.text
352
+ : selectionMarkdown(this.editor.ctx, doc, valid);
353
+ }
354
+ catch (error) {
355
+ throw new InkKitError('preservation', error instanceof Error
356
+ ? error.message
357
+ : 'Cannot copy the selected Markdown');
358
+ }
359
+ const result = await portableClipboard(all ? content : selectionContent(doc, content), schema, markdown, this.options.images, this.context());
360
+ const metadata = referenceMetadata(this.editor.ctx, doc, content);
361
+ if (metadata != null)
362
+ result.html = withReferenceMetadata(result.html, metadata);
363
+ this.assertCurrent(snapshot.generation);
364
+ if (snapshot.documentId !== this.documentId || epoch !== this.documentEpoch)
365
+ throw new InkKitError('stale-document', 'Document changed');
366
+ if (result.images.some((image) => image.error))
367
+ this.events.error?.(new InkKitError('image-unavailable', 'Some copied images were unavailable; their descriptions were retained'));
368
+ return result;
369
+ }
370
+ async paste(input) {
371
+ this.assertCurrent();
372
+ if (this.formatType === 'txt' || input.plainText) {
373
+ this.pasteAsPlainText(input.text);
374
+ return;
375
+ }
376
+ await this.pasteController.paste(input);
377
+ }
378
+ pasteAsPlainText(text) {
379
+ this.assertCurrent();
380
+ if (this.formatType === 'txt') {
381
+ this.plain.setRangeText(text, this.plain.selectionStart, this.plain.selectionEnd, 'end');
382
+ this.plain.dispatchEvent(new Event('input'));
383
+ }
384
+ else {
385
+ const view = this.editor.ctx.get(editorViewCtx);
386
+ if (view.state.selection.$from.parent.type.spec.code) {
387
+ view.dispatch(view.state.tr
388
+ .insertText(text.replace(/\r\n?/g, '\n'))
389
+ .scrollIntoView());
390
+ }
391
+ else {
392
+ const { schema } = view.state;
393
+ const children = [];
394
+ text
395
+ .replace(/\r\n?/g, '\n')
396
+ .split('\n')
397
+ .forEach((line, index) => {
398
+ if (index)
399
+ children.push(schema.nodes.hardbreak.create({
400
+ isHTML: index ===
401
+ text.replace(/\r\n?/g, '\n').split('\n').length - 1 &&
402
+ line === '',
403
+ }));
404
+ if (line)
405
+ children.push(schema.text(line));
406
+ });
407
+ if (!children.length) {
408
+ view.dispatch(view.state.tr.deleteSelection());
409
+ return;
410
+ }
411
+ const paragraph = schema.nodes.paragraph.create(null, children);
412
+ view.dispatch(view.state.tr
413
+ .replaceSelection(new Slice(Fragment.from(paragraph), 1, 1))
414
+ .scrollIntoView());
415
+ }
416
+ }
417
+ }
418
+ table(command, options) {
419
+ this.assertCurrent();
420
+ if (this.formatType === 'txt')
421
+ return false;
422
+ return tableCommand(this.editor.ctx, command, options);
423
+ }
424
+ async destroy() {
425
+ if (this.destroyed)
426
+ return;
427
+ this.destroyed = true;
428
+ this.ready = false;
429
+ this.pasteController.destroy();
430
+ if (this.clickHandler)
431
+ this.root.removeEventListener('click', this.clickHandler);
432
+ if (this.footnoteKeyHandler)
433
+ this.root.removeEventListener('keydown', this.footnoteKeyHandler, true);
434
+ if (this.copyHandler) {
435
+ this.root.removeEventListener('copy', this.copyHandler, true);
436
+ this.root.removeEventListener('cut', this.copyHandler, true);
437
+ }
438
+ this.plain.remove();
439
+ await this.editor.destroy();
440
+ }
441
+ // The app's bindings, replaced whole whenever they change; the plugin stays.
442
+ keymapPlugin = $prose(() => new Plugin({
443
+ key: new PluginKey('appKeymap'),
444
+ props: {
445
+ handleKeyDown: (view, event) => {
446
+ if (event.altKey && event.key === 'Enter') {
447
+ return this.navigateFootnote(event.shiftKey ? 'reference' : 'definition');
448
+ }
449
+ return this.keys(view, event);
450
+ },
451
+ },
452
+ }));
453
+ static async mount(root, events, options = {}) {
454
+ const instance = new InkKitEditor(events, options);
455
+ instance.root = root;
456
+ instance.pasteController = new PasteController({
457
+ ctx: () => instance.editor.ctx,
458
+ context: () => instance.context(),
459
+ adapter: options.images,
460
+ onError: (error) => events.error?.(error instanceof Error ? error : new Error(String(error))),
461
+ literalText: () => instance.formatType === 'txt',
462
+ });
463
+ instance.editor = await Editor.make()
464
+ .config((ctx) => {
465
+ ctx.set(rootCtx, root);
466
+ ctx.set(defaultValueCtx, '');
467
+ ctx.set(remarkStringifyOptionsCtx, stringifyOptions);
468
+ // The preset also binds Mod-[ and Mod-] here; the app uses those for back and forward.
469
+ // Shift-Tab is bound to liftItem instead.
470
+ ctx.update(listItemKeymap.key, (keys) => ({
471
+ ...keys,
472
+ SinkListItem: { shortcuts: 'Tab' },
473
+ LiftListItem: { shortcuts: [] },
474
+ }));
475
+ // Formatting keys are the app's to set, through setKeymap; the presets' own go.
476
+ const unbind = (keymap, keep = []) => ctx.update(keymap.key, (keys) => {
477
+ const cleared = { ...keys };
478
+ for (const name of Object.keys(cleared)) {
479
+ if (!keep.includes(name))
480
+ cleared[name] = { shortcuts: [] };
481
+ }
482
+ return cleared;
483
+ });
484
+ unbind(strongKeymap);
485
+ unbind(emphasisKeymap);
486
+ unbind(inlineCodeKeymap);
487
+ unbind(strikethroughKeymap);
488
+ unbind(headingKeymap, ['DowngradeHeading']);
489
+ unbind(paragraphKeymap);
490
+ unbind(blockquoteKeymap);
491
+ unbind(codeBlockKeymap);
492
+ unbind(bulletListKeymap);
493
+ unbind(orderedListKeymap);
494
+ // The caret is kept above the fade under the formatting bar.
495
+ ctx.update(editorViewOptionsCtx, (options) => ({
496
+ ...options,
497
+ scrollThreshold: { top: 8, right: 0, bottom: 24, left: 0 },
498
+ scrollMargin: { top: 8, right: 0, bottom: 24, left: 0 },
499
+ }));
500
+ })
501
+ .use(caretStatePlugin(events))
502
+ .use(instance.pasteController.plugin)
503
+ .use(visibleClipboard)
504
+ .use(instance.keymapPlugin)
505
+ .use(createDialect(Boolean(options.images)))
506
+ .use(tablePlugins)
507
+ .use(instance.changePlugin)
508
+ .use(history)
509
+ .use(clipboard)
510
+ .use(cursor)
511
+ .use(taskListPlugin)
512
+ .use(quoteBackspace)
513
+ .use(listItemBackspace)
514
+ .use(listItemShiftTab)
515
+ .use(dropControlCharacters)
516
+ .use(codeCopyPlugin((text) => events.copy(text)))
517
+ .use(placeholderPlugin)
518
+ .use(highlightPlugin)
519
+ .use(selectionPlugin)
520
+ .use($prose(() => search()))
521
+ .use(options.images ? imageView(options.images) : [])
522
+ .create();
523
+ instance.plain = document.createElement('textarea');
524
+ instance.plain.className = 'inkkit-plain';
525
+ instance.plain.setAttribute('aria-label', 'Plain text editor');
526
+ instance.plain.hidden = true;
527
+ root.append(instance.plain);
528
+ instance.plain.addEventListener('input', () => {
529
+ instance.revision += 1;
530
+ events.changed(instance.plainSource(), instance.generation);
531
+ });
532
+ instance.plain.addEventListener('compositionstart', () => {
533
+ instance.plainComposing = true;
534
+ });
535
+ instance.plain.addEventListener('compositionend', () => {
536
+ instance.plainComposing = false;
537
+ });
538
+ const activateFootnote = (event) => {
539
+ const element = event.target.closest('[data-inkkit-footnote-reference]');
540
+ if (!element)
541
+ return false;
542
+ const identifier = element.getAttribute('data-inkkit-footnote-reference');
543
+ const view = instance.editor.ctx.get(editorViewCtx);
544
+ let position;
545
+ view.state.doc.descendants((node, pos) => {
546
+ if (position == null &&
547
+ node.type.name === 'footnote_reference' &&
548
+ node.attrs.identifier === identifier)
549
+ position = pos;
550
+ });
551
+ if (position == null)
552
+ return false;
553
+ event.preventDefault();
554
+ try {
555
+ view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, position)));
556
+ instance.navigateFootnote('definition');
557
+ }
558
+ catch (error) {
559
+ events.error?.(error instanceof Error ? error : new Error(String(error)));
560
+ }
561
+ return true;
562
+ };
563
+ instance.footnoteKeyHandler = (event) => {
564
+ if ((event.key === 'Enter' || event.key === ' ') &&
565
+ activateFootnote(event))
566
+ event.stopPropagation();
567
+ };
568
+ root.addEventListener('keydown', instance.footnoteKeyHandler, true);
569
+ instance.clickHandler = (event) => {
570
+ if (activateFootnote(event))
571
+ return;
572
+ const anchor = event.target.closest('a[href]');
573
+ if (anchor && event.metaKey) {
574
+ event.preventDefault();
575
+ events.openLink(anchor.getAttribute('href') ?? '');
576
+ }
577
+ };
578
+ root.addEventListener('click', instance.clickHandler);
579
+ instance.copyHandler = (event) => {
580
+ if (instance.formatType === 'txt')
581
+ return;
582
+ const view = instance.editor.ctx.get(editorViewCtx);
583
+ const fragment = view.state.selection.content().content;
584
+ const expanded = selectionContent(view.state.doc, fragment);
585
+ let hasImages = false;
586
+ expanded.descendants((node) => {
587
+ if (node.type.name === 'image')
588
+ hasImages = true;
589
+ });
590
+ if (!hasImages) {
591
+ if (!event.clipboardData || fragment.size === 0)
592
+ return;
593
+ event.preventDefault();
594
+ event.stopPropagation();
595
+ try {
596
+ instance.snapshot();
597
+ const content = expanded;
598
+ const valid = fragment.firstChild?.isInline
599
+ ? Fragment.from(view.state.schema.nodes.paragraph.create(null, fragment))
600
+ : fragment;
601
+ const markdown = selectionMarkdown(instance.editor.ctx, view.state.doc, valid);
602
+ const output = clipboardContent(content, view.state.schema);
603
+ event.clipboardData.setData('text/plain', output.text);
604
+ const metadata = referenceMetadata(instance.editor.ctx, view.state.doc, valid);
605
+ event.clipboardData.setData('text/html', metadata != null
606
+ ? withReferenceMetadata(output.html, metadata)
607
+ : output.html);
608
+ if (event.type === 'cut')
609
+ view.dispatch(view.state.tr.deleteSelection().scrollIntoView());
610
+ }
611
+ catch (error) {
612
+ events.error?.(error instanceof Error ? error : new Error(String(error)));
613
+ }
614
+ return;
615
+ }
616
+ event.preventDefault();
617
+ event.stopPropagation();
618
+ if (events.clipboard) {
619
+ const epoch = instance.documentEpoch, revision = instance.revision;
620
+ const { from, to } = view.state.selection;
621
+ const cut = event.type === 'cut';
622
+ void instance
623
+ .clipboardSnapshot(false)
624
+ .then(async (content) => {
625
+ if (cut && content.images.some((image) => image.error))
626
+ throw new InkKitError('image-unavailable', 'The selected images could not be cut safely');
627
+ await events.clipboard(content);
628
+ if (cut) {
629
+ instance.assertCurrent();
630
+ if (epoch !== instance.documentEpoch ||
631
+ revision !== instance.revision)
632
+ throw new InkKitError('stale-document', 'Document changed before cutting');
633
+ view.dispatch(view.state.tr.deleteRange(from, to).scrollIntoView());
634
+ }
635
+ })
636
+ .catch((error) => events.error?.(error instanceof Error ? error : new Error(String(error))));
637
+ }
638
+ else {
639
+ const container = document.createElement('div');
640
+ container.textContent = clipboardText(expanded);
641
+ event.clipboardData?.setData('text/plain', container.textContent ?? '');
642
+ event.clipboardData?.setData('text/html', container.outerHTML);
643
+ events.error?.(new InkKitError('image-unavailable', 'The host must provide a clipboard handler to copy images'));
644
+ }
645
+ };
646
+ root.addEventListener('copy', instance.copyHandler, true);
647
+ root.addEventListener('cut', instance.copyHandler, true);
648
+ return instance;
649
+ }
650
+ loadDocument(input) {
651
+ this.assertAlive();
652
+ this.pasteController.cancelPending();
653
+ this.documentEpoch += 1;
654
+ this.plainComposing = false;
655
+ this.plainSearch = '';
656
+ this.documentId = input.documentId;
657
+ this.formatType = input.format;
658
+ this.revision = 0;
659
+ this.plain.hidden = input.format !== 'txt';
660
+ this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
661
+ input.format === 'txt';
662
+ this.plain.value = input.text;
663
+ this.load(input.format === 'txt' ? '' : input.text, input.generation);
664
+ this.originalSource = input.text;
665
+ this.ready = true;
666
+ }
667
+ reloadDocument(input) {
668
+ this.assertAlive();
669
+ const view = this.editor.ctx.get(editorViewCtx);
670
+ const at = this.formatType === 'txt'
671
+ ? this.plain.selectionStart
672
+ : view.state.selection.from;
673
+ const top = this.root.scrollTop;
674
+ this.loadDocument(input);
675
+ if (input.format === 'txt')
676
+ this.plain.setSelectionRange(Math.min(at, input.text.length), Math.min(at, input.text.length));
677
+ else
678
+ view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(Math.min(at, view.state.doc.content.size)))));
679
+ this.root.scrollTop = top;
680
+ }
681
+ load(markdown, generation) {
682
+ // A loaded document only counts as changed once it is edited, so its
683
+ // canonical form is the baseline, not the text as stored.
684
+ this.generation = generation;
685
+ this.originalSource = markdown;
686
+ this.loading = true;
687
+ try {
688
+ this.editor.action(replaceAll(markdown, true));
689
+ }
690
+ finally {
691
+ this.loading = false;
692
+ }
693
+ this.preservation = new Preservation(this.editor.ctx, markdown);
694
+ this.baseline = serialize(this.editor.ctx);
695
+ this.lastMarkdown = this.baseline;
696
+ const view = this.editor.ctx.get(editorViewCtx);
697
+ // A caret at the end, where writing carries on; at the start it would sit in a first-line heading and
698
+ // show its marks. The last place text can go, so a document ending in a rule takes a caret above it
699
+ // rather than selecting the rule itself.
700
+ const { doc } = view.state;
701
+ const end = Selection.findFrom(doc.resolve(doc.content.size), -1, true) ??
702
+ Selection.atEnd(doc);
703
+ view.dispatch(setSearchState(view.state.tr.setSelection(end).scrollIntoView(), new SearchQuery({ search: '' })));
704
+ this.events.stateChanged(caretState(view.state));
705
+ }
706
+ find(text) {
707
+ this.assertCurrent();
708
+ if (this.formatType === 'txt') {
709
+ const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
710
+ const matches = text
711
+ ? [...this.plain.value.matchAll(new RegExp(escaped, 'giu'))]
712
+ : [];
713
+ const from = this.plainSearch === text
714
+ ? this.plain.selectionEnd
715
+ : this.plain.selectionStart;
716
+ const match = matches.find((item) => item.index >= from) ?? matches[0];
717
+ if (match)
718
+ this.plain.setSelectionRange(match.index, match.index + match[0].length);
719
+ else if (this.plainSearch)
720
+ this.plain.setSelectionRange(this.plain.selectionEnd, this.plain.selectionEnd);
721
+ this.plainSearch = text;
722
+ return;
723
+ }
724
+ const view = this.editor.ctx.get(editorViewCtx);
725
+ const previous = getSearchState(view.state)?.query.search;
726
+ const query = new SearchQuery({ search: text, literal: true });
727
+ let tr = setSearchState(view.state.tr, query);
728
+ if (text) {
729
+ const from = previous === text ? view.state.selection.to : view.state.selection.from;
730
+ const match = query.findNext(view.state, from) ?? query.findNext(view.state, 0);
731
+ if (match)
732
+ tr = tr
733
+ .setSelection(TextSelection.create(tr.doc, match.from, match.to))
734
+ .scrollIntoView();
735
+ else
736
+ tr = tr.setSelection(Selection.near(view.state.selection.$to));
737
+ }
738
+ else if (previous) {
739
+ tr = tr.setSelection(Selection.near(view.state.selection.$to));
740
+ }
741
+ view.dispatch(tr);
742
+ }
743
+ /** Buffered native typing follows the same input rules as direct typing. */
744
+ insertText(text, generation) {
745
+ this.assertAlive();
746
+ if ((this.formatType === 'txt'
747
+ ? this.plainComposing
748
+ : this.editor.ctx.get(editorViewCtx).composing) &&
749
+ generation === this.generation)
750
+ return false;
751
+ this.assertCurrent(generation);
752
+ if (this.formatType === 'txt') {
753
+ this.pasteAsPlainText(text);
754
+ return true;
755
+ }
756
+ const view = this.editor.ctx.get(editorViewCtx);
757
+ if (view.composing)
758
+ return false;
759
+ const { from, to } = view.state.selection;
760
+ const transaction = () => view.state.tr.insertText(text, from, to);
761
+ if (!view.someProp('handleTextInput', (handler) => handler(view, from, to, text, transaction))) {
762
+ view.dispatch(transaction().scrollIntoView());
763
+ }
764
+ return true;
765
+ }
766
+ /** Let existing keymaps handle buffered editing commands before native fallback. */
767
+ keyDown(key, code, metaKey, ctrlKey, altKey, shiftKey, generation) {
768
+ this.assertAlive();
769
+ if ((this.formatType === 'txt'
770
+ ? this.plainComposing
771
+ : this.editor.ctx.get(editorViewCtx).composing) &&
772
+ generation === this.generation)
773
+ return false;
774
+ this.assertCurrent(generation);
775
+ if (this.formatType === 'txt')
776
+ return false;
777
+ const view = this.editor.ctx.get(editorViewCtx);
778
+ if (view.composing)
779
+ return false;
780
+ const event = new KeyboardEvent('keydown', {
781
+ key,
782
+ code,
783
+ metaKey,
784
+ ctrlKey,
785
+ altKey,
786
+ shiftKey,
787
+ bubbles: true,
788
+ cancelable: true,
789
+ });
790
+ return Boolean(view.someProp('handleKeyDown', (handler) => handler(view, event)));
791
+ }
792
+ focus() {
793
+ this.assertAlive();
794
+ if (!this.ready)
795
+ throw new InkKitError('not-ready', 'No document has been loaded');
796
+ if (this.formatType === 'txt')
797
+ this.plain.focus();
798
+ else
799
+ this.editor.ctx.get(editorViewCtx).focus();
800
+ }
801
+ /** Binds keys, in ProseMirror's names, to the formatting each shortcut runs. Walked in the table's
802
+ * order, so a key given to two shortcuts lands the same way every time. */
803
+ setKeymap(keymap) {
804
+ this.assertAlive();
805
+ const bindings = {};
806
+ for (const [name, command] of Object.entries(shortcutCommands)) {
807
+ for (const key of keymap[name] ?? []) {
808
+ bindings[key] = () => {
809
+ this.format(...command);
810
+ return true;
811
+ };
812
+ }
813
+ }
814
+ this.keys = keydownHandler(bindings);
815
+ }
816
+ /** Dropped files land as one paragraph per path at the drop point: in place of an empty block, after
817
+ * the top-level block otherwise, so a list or quote is not opened up by them. */
818
+ insertPaths(paths, x, y) {
819
+ this.assertCurrent();
820
+ if (paths.length === 0)
821
+ return;
822
+ if (this.formatType === 'txt') {
823
+ this.pasteAsPlainText(paths.join('\n\n'));
824
+ return;
825
+ }
826
+ const { schema } = this.editor.ctx.get(editorViewCtx).state;
827
+ this.insertBlocks(paths.map((path) => schema.nodes.paragraph.create(null, schema.text(path))), x, y);
828
+ }
829
+ insertImages(images, x, y) {
830
+ this.assertCurrent();
831
+ if (!images.length)
832
+ return;
833
+ if (!this.options.images || this.formatType === 'txt')
834
+ throw new InkKitError('image-unavailable', 'This editor has no image adapter');
835
+ const view = this.editor.ctx.get(editorViewCtx);
836
+ const { schema } = view.state;
837
+ const nodes = images.map((image) => schema.nodes.image.create({
838
+ src: image.path,
839
+ alt: image.alt,
840
+ title: image.title ?? '',
841
+ }));
842
+ if (x != null && y != null)
843
+ this.insertBlocks(nodes.map((node) => schema.nodes.paragraph.create(null, node)), x, y);
844
+ else
845
+ view.dispatch(view.state.tr
846
+ .replaceSelectionWith(schema.nodes.paragraph.create(null, nodes))
847
+ .scrollIntoView());
848
+ }
849
+ insertBlocks(blocks, x, y) {
850
+ const view = this.editor.ctx.get(editorViewCtx);
851
+ const { state } = view;
852
+ const $pos = state.doc.resolve(view.posAtCoords({ left: x, top: y })?.pos ?? state.selection.from);
853
+ const tr = state.tr;
854
+ let at;
855
+ if ($pos.depth === 1 &&
856
+ $pos.parent.isTextblock &&
857
+ $pos.parent.content.size === 0) {
858
+ at = $pos.before(1);
859
+ tr.replaceWith(at, $pos.after(1), blocks);
860
+ }
861
+ else {
862
+ at = $pos.depth > 0 ? $pos.after(1) : $pos.pos;
863
+ tr.insert(at, blocks);
864
+ }
865
+ const end = at + blocks.reduce((size, node) => size + node.nodeSize, 0) - 1;
866
+ view.dispatch(tr.setSelection(TextSelection.create(tr.doc, end)).scrollIntoView());
867
+ this.focus();
868
+ }
869
+ // Removing a mark at a caret only clears the stored mark, so the whole marked
870
+ // run is selected for the command and the caret put back after it.
871
+ withMarkRunSelected(mark, command) {
872
+ const view = this.editor.ctx.get(editorViewCtx);
873
+ const { selection, doc } = view.state;
874
+ if (!selection.empty)
875
+ return false;
876
+ const $pos = selection.$from;
877
+ const parent = $pos.parent;
878
+ const start = $pos.start();
879
+ let from = $pos.pos;
880
+ let to = $pos.pos;
881
+ parent.forEach((child, offset) => {
882
+ const childFrom = start + offset;
883
+ const childTo = childFrom + child.nodeSize;
884
+ if (!mark.isInSet(child.marks))
885
+ return;
886
+ if (childTo >= $pos.pos && childFrom <= to) {
887
+ from = Math.min(from, childFrom);
888
+ to = Math.max(to, childTo);
889
+ }
890
+ });
891
+ if (from === to)
892
+ return false;
893
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, from, to)));
894
+ command();
895
+ const caret = Math.min($pos.pos, view.state.doc.content.size);
896
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, caret)));
897
+ return true;
898
+ }
899
+ // Milkdown's inline code command ignores a caret; a stored mark makes the
900
+ // next typed text code, the way bold and italic behave.
901
+ toggleInlineCode() {
902
+ const view = this.editor.ctx.get(editorViewCtx);
903
+ const type = inlineCodeSchema.type(this.editor.ctx);
904
+ const run = () => {
905
+ this.editor.action(callCommand(toggleInlineCodeCommand.key));
906
+ };
907
+ const { selection, storedMarks } = view.state;
908
+ if (!selection.empty)
909
+ return run();
910
+ // A pending stored mark is only that; the span next to the caret is left alone.
911
+ if (storedMarks) {
912
+ const tr = type.isInSet(storedMarks)
913
+ ? view.state.tr.removeStoredMark(type)
914
+ : view.state.tr.addStoredMark(type.create());
915
+ return view.dispatch(tr);
916
+ }
917
+ if (!type.isInSet(selection.$from.marks()))
918
+ view.dispatch(view.state.tr.addStoredMark(type.create()));
919
+ else
920
+ this.withMarkRunSelected(type, run);
921
+ }
922
+ list(kind) {
923
+ const view = this.editor.ctx.get(editorViewCtx);
924
+ const { state } = view;
925
+ const { $from } = textBounds(state);
926
+ const wanted = kind === 'bulletList' ? 'bullet_list' : 'ordered_list';
927
+ const depth = innermostListDepth($from);
928
+ if (depth == null) {
929
+ const command = kind === 'bulletList'
930
+ ? wrapInBulletListCommand
931
+ : wrapInOrderedListCommand;
932
+ this.editor.action(callCommand(command.key));
933
+ return;
934
+ }
935
+ const list = $from.node(depth);
936
+ if (list.type.name === wanted) {
937
+ liftItem(state, view.dispatch);
938
+ return;
939
+ }
940
+ // The items carry the list kind too, and a bullet list whose items say
941
+ // ordered is turned back into one by the preset.
942
+ const pos = $from.before(depth);
943
+ const ordered = wanted === 'ordered_list';
944
+ const attrs = ordered
945
+ ? { order: 1, spread: list.attrs.spread }
946
+ : { spread: list.attrs.spread };
947
+ let tr = state.tr.setNodeMarkup(pos, state.schema.nodes[wanted], attrs);
948
+ list.forEach((item, offset) => {
949
+ tr = tr.setNodeMarkup(pos + 1 + offset, undefined, {
950
+ ...item.attrs,
951
+ listType: ordered ? 'ordered' : 'bullet',
952
+ label: ordered ? '1.' : '•',
953
+ });
954
+ });
955
+ view.dispatch(tr);
956
+ }
957
+ quote() {
958
+ const view = this.editor.ctx.get(editorViewCtx);
959
+ const type = blockquoteSchema.type(this.editor.ctx);
960
+ const { $from, $to } = textBounds(view.state);
961
+ let range = $from.blockRange($to);
962
+ let wrapping = range && findWrapping(range, type);
963
+ if (!wrapping) {
964
+ // A list item cannot hold a quote, so the whole list is quoted instead.
965
+ const depth = outermostListDepth($from);
966
+ if (depth == null)
967
+ return;
968
+ const doc = view.state.doc;
969
+ range = new NodeRange(doc.resolve($from.before(depth)), doc.resolve($from.after(depth)), depth - 1);
970
+ wrapping = findWrapping(range, type);
971
+ }
972
+ if (!range || !wrapping)
973
+ return;
974
+ view.dispatch(view.state.tr.wrap(range, wrapping));
975
+ }
976
+ unquote() {
977
+ const view = this.editor.ctx.get(editorViewCtx);
978
+ const { $from, $to } = textBounds(view.state);
979
+ const range = $from.blockRange($to, (node) => node.type.name === 'blockquote');
980
+ if (!range)
981
+ return;
982
+ const target = liftTarget(range);
983
+ if (target == null)
984
+ return;
985
+ view.dispatch(view.state.tr.lift(range, target));
986
+ }
987
+ format(command, arg) {
988
+ this.assertCurrent();
989
+ if (this.formatType === 'txt')
990
+ return;
991
+ const run = (cmd, payload) => this.editor.action(callCommand(cmd, payload));
992
+ const state = caretState(this.editor.ctx.get(editorViewCtx).state);
993
+ switch (command) {
994
+ case 'heading': {
995
+ const level = Number(arg ?? 1);
996
+ if (state.block.type === 'heading' && state.block.level === level)
997
+ run(turnIntoTextCommand.key);
998
+ else
999
+ run(wrapInHeadingCommand.key, level);
1000
+ break;
1001
+ }
1002
+ case 'paragraph':
1003
+ run(turnIntoTextCommand.key);
1004
+ break;
1005
+ case 'bold':
1006
+ run(toggleStrongCommand.key);
1007
+ break;
1008
+ case 'italic':
1009
+ run(toggleEmphasisCommand.key);
1010
+ break;
1011
+ case 'strikethrough':
1012
+ run(toggleStrikethroughCommand.key);
1013
+ break;
1014
+ case 'code':
1015
+ this.toggleInlineCode();
1016
+ break;
1017
+ case 'codeBlock':
1018
+ if (state.block.type === 'codeBlock')
1019
+ run(turnIntoTextCommand.key);
1020
+ else
1021
+ run(createCodeBlockCommand.key, typeof arg === 'string' ? arg : '');
1022
+ break;
1023
+ case 'quote':
1024
+ if (state.quoted)
1025
+ this.unquote();
1026
+ else
1027
+ this.quote();
1028
+ break;
1029
+ case 'bulletList':
1030
+ case 'orderedList':
1031
+ this.list(command);
1032
+ break;
1033
+ case 'taskList':
1034
+ toggleTaskList(this.editor.ctx);
1035
+ break;
1036
+ case 'link': {
1037
+ const view = this.editor.ctx.get(editorViewCtx);
1038
+ const reference = view.state.selection.$from
1039
+ .marks()
1040
+ .find((mark) => mark.type.name === 'link' && mark.attrs.identifier) ??
1041
+ view.state.doc
1042
+ .nodeAt(view.state.selection.from)
1043
+ ?.marks.find((mark) => mark.type.name === 'link' && mark.attrs.identifier);
1044
+ if (reference && typeof arg === 'string') {
1045
+ this.editReferenceDefinition(String(reference.attrs.identifier), arg);
1046
+ break;
1047
+ }
1048
+ const payload = typeof arg === 'string' ? { href: arg } : {};
1049
+ const toggle = () => run(toggleLinkCommand.key, payload);
1050
+ if (!state.marks.includes('link') ||
1051
+ !this.withMarkRunSelected(linkSchema.type(this.editor.ctx), toggle))
1052
+ toggle();
1053
+ break;
1054
+ }
1055
+ }
1056
+ this.focus();
1057
+ }
1058
+ editReferenceDefinition(label, destination, title) {
1059
+ this.assertCurrent();
1060
+ if (this.formatType === 'txt')
1061
+ return false;
1062
+ const view = this.editor.ctx.get(editorViewCtx);
1063
+ const definitions = referenceDefinitions(view.state.doc);
1064
+ const definition = definitions.get(normaliseLabel(label)) ??
1065
+ [...definitions.values()].find(({ node }) => normaliseLabel(String(node.attrs.label)) === normaliseLabel(label));
1066
+ if (!definition)
1067
+ return false;
1068
+ view.dispatch(view.state.tr.setNodeMarkup(definition.pos, undefined, {
1069
+ ...definition.node.attrs,
1070
+ url: destination,
1071
+ title: title ?? definition.node.attrs.title,
1072
+ }));
1073
+ return true;
1074
+ }
1075
+ insertFootnote(label) {
1076
+ this.assertCurrent();
1077
+ if (this.formatType === 'txt')
1078
+ return;
1079
+ const view = this.editor.ctx.get(editorViewCtx);
1080
+ const definitions = footnoteDefinitions(view.state.doc);
1081
+ if (label == null) {
1082
+ let number = 1;
1083
+ const labels = new Set(definitions.keys());
1084
+ for (const match of this.snapshot().text.matchAll(/\[\^([^\]\r\n]+)\]/g))
1085
+ labels.add(normaliseLabel(match[1]));
1086
+ view.state.doc.descendants((node) => {
1087
+ if (node.type.name === 'footnote_reference')
1088
+ labels.add(String(node.attrs.identifier));
1089
+ });
1090
+ while (labels.has(normaliseLabel(String(number))))
1091
+ number++;
1092
+ label = String(number);
1093
+ }
1094
+ if (!label || /[\s\[\]\\]/.test(label) || label.length > 999)
1095
+ throw new Error('A footnote label must be 1–999 characters without whitespace, brackets or backslashes');
1096
+ const identifier = normaliseLabel(label);
1097
+ const reference = view.state.schema.nodes.footnote_reference.create({
1098
+ label,
1099
+ identifier,
1100
+ });
1101
+ const tr = view.state.tr.insert(view.state.selection.to, reference);
1102
+ if (!definitions.has(identifier)) {
1103
+ const definition = view.state.schema.nodes.footnote_definition.create({ label, identifier }, view.state.schema.nodes.paragraph.create());
1104
+ const position = tr.doc.content.size;
1105
+ tr.insert(position, definition).setSelection(TextSelection.create(tr.doc, position + 2));
1106
+ }
1107
+ view.dispatch(tr.scrollIntoView());
1108
+ this.focus();
1109
+ }
1110
+ navigateFootnote(target) {
1111
+ this.assertCurrent();
1112
+ if (this.formatType === 'txt')
1113
+ return false;
1114
+ const view = this.editor.ctx.get(editorViewCtx);
1115
+ const { $from } = view.state.selection;
1116
+ let identifier;
1117
+ for (let depth = $from.depth; depth > 0; depth--) {
1118
+ const node = $from.node(depth);
1119
+ if (node.type.name === 'footnote_definition')
1120
+ identifier = String(node.attrs.identifier);
1121
+ }
1122
+ const at = view.state.doc.nodeAt(view.state.selection.from);
1123
+ const adjacent = at?.type.name === 'footnote_reference' ? at : $from.nodeBefore;
1124
+ if (adjacent?.type.name === 'footnote_reference')
1125
+ identifier = String(adjacent.attrs.identifier);
1126
+ if (!identifier)
1127
+ return false;
1128
+ let position;
1129
+ if (target === 'definition') {
1130
+ const found = footnoteDefinitions(view.state.doc).get(identifier);
1131
+ if (found)
1132
+ position = found.pos + 2;
1133
+ }
1134
+ else
1135
+ view.state.doc.descendants((node, pos) => {
1136
+ if (position == null &&
1137
+ node.type.name === 'footnote_reference' &&
1138
+ node.attrs.identifier === identifier)
1139
+ position = pos;
1140
+ });
1141
+ if (position == null)
1142
+ return false;
1143
+ view.dispatch(view.state.tr
1144
+ .setSelection(TextSelection.near(view.state.doc.resolve(position)))
1145
+ .scrollIntoView());
1146
+ this.focus();
1147
+ return true;
1148
+ }
1149
+ }