@aicayzer/inkkit 0.0.0-stage → 0.0.1

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