@aicayzer/inkkit 0.0.6 → 0.0.8

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 CHANGED
@@ -1,30 +1,36 @@
1
- import { Editor, defaultValueCtx, editorViewCtx, editorViewOptionsCtx, remarkCtx, remarkStringifyOptionsCtx, rootCtx, } from '@milkdown/kit/core';
1
+ import { Editor, commandsCtx, defaultValueCtx, editorViewCtx, parserCtx, editorViewOptionsCtx, remarkCtx, remarkStringifyOptionsCtx, rootCtx, } from '@milkdown/kit/core';
2
2
  import { visibleClipboard, portableClipboard, clipboardText, clipboardContent, } from './clipboard.js';
3
3
  import { clipboard } from '@milkdown/kit/plugin/clipboard';
4
- import { history } from '@milkdown/kit/plugin/history';
4
+ import { history, historyKeymap } from '@milkdown/kit/plugin/history';
5
+ import { closeHistory, undo as undoHistory, redo as redoHistory, } from '@milkdown/kit/prose/history';
5
6
  import { cursor } from '@milkdown/kit/plugin/cursor';
6
7
  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 { strikethroughKeymap, tableKeymap, toggleStrikethroughCommand, } from '@milkdown/kit/preset/gfm';
8
9
  import { NodeRange, Fragment, Slice, } from '@milkdown/kit/prose/model';
9
10
  import { keydownHandler, keymap } from '@milkdown/kit/prose/keymap';
11
+ import { goToNextCell } from '@milkdown/kit/prose/tables';
10
12
  import { liftListItem } from '@milkdown/kit/prose/schema-list';
11
13
  import { findWrapping, liftTarget } from '@milkdown/kit/prose/transform';
12
14
  import { AllSelection, NodeSelection, Plugin, PluginKey, Selection, TextSelection, } from '@milkdown/kit/prose/state';
13
15
  import { $prose, callCommand, replaceAll, } from '@milkdown/kit/utils';
14
16
  import { codeCopyPlugin, placeholderPlugin } from './decorations.js';
15
- import { createDialect, serialize, stringifyOptions } from './dialect.js';
17
+ import { labelsCtx, defaultLabels } from './labels.js';
18
+ import { createDialect, stringifyOptions } from './dialect.js';
16
19
  import { Preservation } from './preserve.js';
20
+ import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection, revealSourceCaret, rawOffset, normalizedOffset, } from './source.js';
17
21
  import { normaliseLabel, referenceDefinitions, footnoteDefinitions, } from './references.js';
18
22
  import { selectionMarkdown, selectionContent, referenceMetadata, withReferenceMetadata, } from './reference-clipboard.js';
19
23
  import { imageView } from './images.js';
20
- import { tablePlugins, tableCommand, configureTableMovement, } from './tables.js';
24
+ import { tablePlugins, tableCommand, tableCommands, tableAvailability, tableContext, configureTableMovement, } from './tables.js';
21
25
  import { InkKitError, } from './types.js';
22
26
  import { highlightPlugin } from './highlight.js';
23
27
  import { highlightKeymap, toggleHighlightCommand } from './inline-highlight.js';
24
28
  import { commentSelectionContent, setCommentVisibility } from './comments.js';
25
29
  import { PasteController } from './paste.js';
26
30
  import { selectionPlugin } from './selection.js';
27
- import { search, SearchQuery, getSearchState, setSearchState, } from 'prosemirror-search';
31
+ import { search, SearchQuery, setSearchState } from 'prosemirror-search';
32
+ import { findFormatted, findLiteral, replaceFormatted, replaceLiteral, } from './search.js';
33
+ import { collectFormattedHeadings, collectSourceHeadings, navigateFormattedHeading, sourceHeadingPosition, } from './outline.js';
28
34
  import { taskListPlugin, toggleTaskList } from './tasks.js';
29
35
  import { isMermaid, mermaidPreview } from './mermaid.js';
30
36
  import { hasAuthoredImagesInLiterals, printableMarkdown, printableText, } from './print.js';
@@ -32,6 +38,9 @@ const shortcutCommands = {
32
38
  heading1: ['heading', 1],
33
39
  heading2: ['heading', 2],
34
40
  heading3: ['heading', 3],
41
+ heading4: ['heading', 4],
42
+ heading5: ['heading', 5],
43
+ heading6: ['heading', 6],
35
44
  paragraph: ['paragraph'],
36
45
  bold: ['bold'],
37
46
  italic: ['italic'],
@@ -187,11 +196,13 @@ const dropControlCharacters = $prose(() => new Plugin({
187
196
  }));
188
197
  // The listener plugin reports selection changes from inside state.apply, before
189
198
  // the view holds the new state, so the caret state is read from the view instead.
190
- function caretStatePlugin(events) {
199
+ function caretStatePlugin(events, active = () => true) {
191
200
  return $prose(() => new Plugin({
192
201
  key: new PluginKey('caretState'),
193
202
  view: () => ({
194
203
  update(view, previous) {
204
+ if (!active())
205
+ return;
195
206
  const { state } = view;
196
207
  if (state.selection.eq(previous.selection) &&
197
208
  state.doc.eq(previous.doc) &&
@@ -233,7 +244,6 @@ export class InkKitEditor {
233
244
  options;
234
245
  editor;
235
246
  lastMarkdown = '';
236
- baseline = '';
237
247
  documentId = '';
238
248
  formatType = 'md';
239
249
  revision = 0;
@@ -241,18 +251,31 @@ export class InkKitEditor {
241
251
  operationSequence = 0;
242
252
  plainComposing = false;
243
253
  plainSearch = '';
254
+ literalMatch;
255
+ beforePlainInput;
256
+ mode = 'formatted';
257
+ sourcePreservations = new WeakMap();
258
+ outlineEpoch = 0;
244
259
  ready = false;
245
260
  destroyed = false;
246
261
  preservation;
247
262
  pasteController;
248
263
  plain;
249
264
  root;
265
+ ownsRootClass = false;
250
266
  clickHandler;
251
267
  footnoteKeyHandler;
252
268
  copyHandler;
253
269
  originalSource = '';
254
270
  generation = 0;
271
+ reportingSuppressed = false;
255
272
  loading = false;
273
+ writable = true;
274
+ inputPreferences = {};
275
+ policyEpoch = 0;
276
+ literalKeys = () => false;
277
+ configuredKeys = {};
278
+ readOnlyKeys = () => false;
256
279
  changePlugin = $prose(() => new Plugin({
257
280
  key: new PluginKey('documentChanges'),
258
281
  view: () => ({
@@ -260,20 +283,30 @@ export class InkKitEditor {
260
283
  if (this.loading || view.state.doc.eq(previous.doc))
261
284
  return;
262
285
  this.revision += 1;
286
+ this.literalMatch = undefined;
263
287
  let markdown;
264
288
  try {
265
- markdown =
266
- this.preservation?.serialize(view.state.doc) ??
267
- serialize(this.editor.ctx, view.state.doc);
289
+ markdown = this.currentText(view.state.doc);
268
290
  }
269
291
  catch (error) {
270
292
  this.events.error?.(error instanceof Error ? error : new Error(String(error)));
271
293
  return;
272
294
  }
295
+ if (this.literalSurface) {
296
+ const anchor = view.state.doc.attrs[sourceAttribute];
297
+ const from = anchor?.from ?? this.plain.selectionStart;
298
+ const to = anchor?.to ?? this.plain.selectionEnd;
299
+ if (!this.plainComposing)
300
+ this.plain.value = markdown;
301
+ if (!this.plainComposing) {
302
+ this.plain.setSelectionRange(Math.min(from, this.plain.value.length), Math.min(to, this.plain.value.length));
303
+ revealSourceCaret(this.plain);
304
+ }
305
+ }
273
306
  if (markdown === this.lastMarkdown)
274
307
  return;
275
308
  this.lastMarkdown = markdown;
276
- this.events.changed(markdown === this.baseline ? this.originalSource : markdown, this.generation);
309
+ this.events.changed(markdown, this.generation);
277
310
  },
278
311
  }),
279
312
  }));
@@ -281,6 +314,8 @@ export class InkKitEditor {
281
314
  constructor(events, options) {
282
315
  this.events = events;
283
316
  this.options = options;
317
+ this.writable = options.editable ?? true;
318
+ this.inputPreferences = { ...options.textInput };
284
319
  }
285
320
  context() {
286
321
  return {
@@ -299,29 +334,367 @@ export class InkKitEditor {
299
334
  throw new InkKitError('not-ready', 'No document has been loaded');
300
335
  if (generation !== this.generation)
301
336
  throw new InkKitError('stale-document', 'Document changed');
302
- if (this.formatType === 'txt'
337
+ if (this.literalSurface
303
338
  ? this.plainComposing
304
339
  : this.editor.ctx.get(editorViewCtx).composing)
305
340
  throw new InkKitError('composition', 'Text composition is in progress');
306
341
  if (this.pasteController.pending)
307
342
  throw new InkKitError('operation-pending', 'An image import is in progress');
308
343
  }
309
- plainSource() {
310
- const normalized = this.originalSource.replace(/\r\n?/g, '\n');
311
- if (this.plain.value === normalized)
312
- return this.originalSource;
313
- const crlf = this.originalSource.includes('\r\n') &&
314
- !this.originalSource.replaceAll('\r\n', '').includes('\n');
315
- return crlf ? this.plain.value.replaceAll('\n', '\r\n') : this.plain.value;
344
+ assertMutation(generation = this.generation) {
345
+ this.assertCurrent(generation);
346
+ if (!this.writable)
347
+ throw new InkKitError('read-only', 'The editor is read-only');
348
+ }
349
+ get composing() {
350
+ return this.literalSurface
351
+ ? this.plainComposing
352
+ : this.editor.ctx.get(editorViewCtx).composing;
353
+ }
354
+ get editable() {
355
+ this.assertAlive();
356
+ return this.writable;
357
+ }
358
+ setEditable(editable) {
359
+ this.assertAlive();
360
+ if (this.composing)
361
+ throw new InkKitError('composition', 'Text composition is in progress');
362
+ if (this.writable === editable)
363
+ return;
364
+ const suppressed = this.reportingSuppressed;
365
+ this.reportingSuppressed = true;
366
+ try {
367
+ this.writable = editable;
368
+ this.policyEpoch += 1;
369
+ if (!editable)
370
+ this.pasteController.cancelPending();
371
+ this.editor.ctx
372
+ .get(editorViewCtx)
373
+ .dom.dispatchEvent(new Event('inkkit-cancel-resize'));
374
+ this.applyInputPolicy();
375
+ }
376
+ finally {
377
+ this.reportingSuppressed = suppressed;
378
+ }
379
+ this.publishCommandState();
380
+ }
381
+ setTextInputPreferences(preferences) {
382
+ this.assertAlive();
383
+ if (this.composing)
384
+ throw new InkKitError('composition', 'Text composition is in progress');
385
+ const suppressed = this.reportingSuppressed;
386
+ this.reportingSuppressed = true;
387
+ try {
388
+ this.inputPreferences = { ...preferences };
389
+ this.applyInputPolicy();
390
+ }
391
+ finally {
392
+ this.reportingSuppressed = suppressed;
393
+ }
394
+ this.publishCommandState();
395
+ }
396
+ applyInputPolicy() {
397
+ const view = this.editor.ctx.get(editorViewCtx);
398
+ const attributes = typeof view.props.attributes === 'function'
399
+ ? view.props.attributes(view.state)
400
+ : view.props.attributes;
401
+ view.setProps({
402
+ editable: () => this.writable && !this.literalSurface,
403
+ attributes: {
404
+ ...attributes,
405
+ role: 'textbox',
406
+ 'aria-multiline': 'true',
407
+ 'aria-label': this.options.labels?.formattedEditor ?? defaultLabels.formattedEditor,
408
+ },
409
+ });
410
+ this.plain.readOnly = !this.writable;
411
+ for (const surface of [view.dom, this.plain]) {
412
+ for (const name of [
413
+ 'spellcheck',
414
+ 'autocorrect',
415
+ 'autocapitalize',
416
+ ]) {
417
+ const value = this.inputPreferences[name];
418
+ if (value == null)
419
+ surface.removeAttribute(name);
420
+ else
421
+ surface.setAttribute(name, name === 'autocorrect' ? (value ? 'on' : 'off') : String(value));
422
+ }
423
+ }
424
+ }
425
+ publishCommandState() {
426
+ if (!this.ready ||
427
+ this.loading ||
428
+ this.reportingSuppressed ||
429
+ this.destroyed)
430
+ return;
431
+ this.events.commandStateChanged?.(this.commandState());
432
+ }
433
+ commandState(expectedGeneration = this.generation) {
434
+ this.assertAlive();
435
+ if (!this.ready)
436
+ throw new InkKitError('not-ready', 'No document has been loaded');
437
+ if (expectedGeneration !== this.generation)
438
+ throw new InkKitError('stale-document', 'Document changed');
439
+ const view = this.editor.ctx.get(editorViewCtx);
440
+ const { state } = view;
441
+ const mutable = this.writable && !this.composing && !this.pasteController.pending;
442
+ const formatted = mutable && !this.literalSurface;
443
+ const manager = this.editor.ctx.get(commandsCtx);
444
+ const check = (key, payload) => formatted && manager.get(key)(payload)(state);
445
+ const format = {
446
+ heading: check(wrapInHeadingCommand.key, 1) || check(turnIntoTextCommand.key),
447
+ paragraph: check(turnIntoTextCommand.key),
448
+ bold: check(toggleStrongCommand.key),
449
+ italic: check(toggleEmphasisCommand.key),
450
+ strikethrough: check(toggleStrikethroughCommand.key),
451
+ highlight: check(toggleHighlightCommand.key),
452
+ code: formatted &&
453
+ (state.selection.empty
454
+ ? state.selection.$from.parent.inlineContent &&
455
+ state.selection.$from.parent.type.allowsMarkType(inlineCodeSchema.type(this.editor.ctx))
456
+ : check(toggleInlineCodeCommand.key)),
457
+ codeBlock: check(createCodeBlockCommand.key, '') || check(turnIntoTextCommand.key),
458
+ quote: formatted && this.canQuote(state),
459
+ bulletList: formatted &&
460
+ (innermostListDepth(textBounds(state).$from) != null ||
461
+ check(wrapInBulletListCommand.key)),
462
+ orderedList: formatted &&
463
+ (innermostListDepth(textBounds(state).$from) != null ||
464
+ check(wrapInOrderedListCommand.key)),
465
+ taskList: formatted &&
466
+ (innermostListDepth(textBounds(state).$from) != null ||
467
+ check(wrapInBulletListCommand.key)),
468
+ link: check(toggleLinkCommand.key, {}),
469
+ };
470
+ const tables = formatted
471
+ ? tableAvailability(this.editor.ctx)
472
+ : Object.fromEntries(tableCommands.map((name) => [name, false]));
473
+ return {
474
+ documentId: this.documentId,
475
+ generation: this.generation,
476
+ revision: this.revision,
477
+ format: this.formatType,
478
+ mode: this.editingMode,
479
+ editable: this.writable,
480
+ composing: this.composing,
481
+ pending: this.pasteController.pending,
482
+ caret: this.literalSurface
483
+ ? { marks: [], block: { type: 'paragraph' }, quoted: false }
484
+ : caretState(state),
485
+ table: this.literalSurface ? undefined : tableContext(state),
486
+ commands: {
487
+ undo: mutable && undoHistory(state),
488
+ redo: mutable && redoHistory(state),
489
+ insertText: mutable,
490
+ paste: mutable,
491
+ replace: mutable,
492
+ replaceSource: mutable,
493
+ insertImages: formatted && !!this.options.images,
494
+ insertPaths: mutable,
495
+ insertFootnote: formatted,
496
+ editReferenceDefinition: formatted && referenceDefinitions(state.doc).size > 0,
497
+ format,
498
+ table: tables,
499
+ },
500
+ };
501
+ }
502
+ canQuote(state) {
503
+ const { $from, $to } = textBounds(state);
504
+ if (quoted($from, $to)) {
505
+ const range = $from.blockRange($to, (node) => node.type.name === 'blockquote');
506
+ return !!range && liftTarget(range) != null;
507
+ }
508
+ const type = blockquoteSchema.type(this.editor.ctx);
509
+ let range = $from.blockRange($to);
510
+ if (range && findWrapping(range, type))
511
+ return true;
512
+ const depth = outermostListDepth($from);
513
+ if (depth == null)
514
+ return false;
515
+ range = new NodeRange(state.doc.resolve($from.before(depth)), state.doc.resolve($from.after(depth)), depth - 1);
516
+ return !!findWrapping(range, type);
517
+ }
518
+ policyPlugin = $prose(() => new Plugin({
519
+ key: new PluginKey('inkkitInputPolicy'),
520
+ filterTransaction: (tr) => this.loading ||
521
+ this.writable ||
522
+ (!tr.docChanged && !tr.storedMarksSet),
523
+ props: {
524
+ editable: () => this.writable && !this.literalSurface,
525
+ handleDOMEvents: {
526
+ compositionstart: () => {
527
+ queueMicrotask(() => this.publishCommandState());
528
+ return false;
529
+ },
530
+ compositionend: () => {
531
+ queueMicrotask(() => this.publishCommandState());
532
+ return false;
533
+ },
534
+ beforeinput: (_view, event) => {
535
+ if (this.writable)
536
+ return false;
537
+ event.preventDefault();
538
+ return true;
539
+ },
540
+ drop: (_view, event) => {
541
+ if (this.writable)
542
+ return false;
543
+ event.preventDefault();
544
+ return true;
545
+ },
546
+ },
547
+ },
548
+ view: () => ({ update: () => this.publishCommandState() }),
549
+ }));
550
+ get literalSurface() {
551
+ return this.formatType === 'txt' || this.mode === 'source';
552
+ }
553
+ get editingMode() {
554
+ this.assertAlive();
555
+ return this.literalSurface ? 'source' : 'formatted';
556
+ }
557
+ currentText(doc = this.editor.ctx.get(editorViewCtx).state.doc) {
558
+ const provenance = doc.attrs[sourceAttribute];
559
+ if (this.formatType === 'txt')
560
+ return provenance?.text ?? this.originalSource;
561
+ let preservation = this.preservation;
562
+ if (provenance) {
563
+ preservation = this.sourcePreservations.get(provenance);
564
+ if (!preservation) {
565
+ preservation = new Preservation(this.editor.ctx, provenance.text);
566
+ this.sourcePreservations.set(provenance, preservation);
567
+ }
568
+ }
569
+ return preservation.serialize(cleanSourceDoc(doc));
570
+ }
571
+ setEditingMode(mode, expectedGeneration) {
572
+ this.assertCurrent(expectedGeneration);
573
+ if (mode !== 'source' && mode !== 'formatted')
574
+ throw new RangeError('Editing mode must be source or formatted');
575
+ if (this.formatType === 'txt' || this.mode === mode)
576
+ return false;
577
+ const text = this.snapshot().text;
578
+ this.mode = mode;
579
+ this.outlineEpoch += 1;
580
+ this.plainSearch = '';
581
+ this.literalMatch = undefined;
582
+ if (mode === 'source') {
583
+ this.plain.value = text;
584
+ this.plain.setSelectionRange(this.plain.value.length, this.plain.value.length);
585
+ }
586
+ const suppressed = this.reportingSuppressed;
587
+ this.reportingSuppressed = true;
588
+ try {
589
+ this.updateSurface();
590
+ }
591
+ finally {
592
+ this.reportingSuppressed = suppressed;
593
+ }
594
+ this.events.stateChanged(this.literalSurface
595
+ ? { marks: [], block: { type: 'paragraph' }, quoted: false }
596
+ : caretState(this.editor.ctx.get(editorViewCtx).state));
597
+ this.publishCommandState();
598
+ return true;
599
+ }
600
+ updateSurface() {
601
+ this.plain.hidden = !this.literalSurface;
602
+ this.applyInputPolicy();
603
+ this.plain.setAttribute('aria-label', this.formatType === 'txt'
604
+ ? (this.options.labels?.plainTextEditor ??
605
+ defaultLabels.plainTextEditor)
606
+ : (this.options.labels?.sourceEditor ?? defaultLabels.sourceEditor));
607
+ this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
608
+ this.literalSurface;
609
+ }
610
+ replaceSource(text, expectedGeneration) {
611
+ this.assertMutation(expectedGeneration);
612
+ return this.applySource(text, true);
613
+ }
614
+ applySource(text, isolated, from, to) {
615
+ if (!this.writable)
616
+ throw new InkKitError('read-only', 'The editor is read-only');
617
+ if (typeof text !== 'string')
618
+ throw new TypeError('Source must be a string');
619
+ if (text === this.currentText())
620
+ return false;
621
+ const view = this.editor.ctx.get(editorViewCtx);
622
+ const normalizedLength = text.replace(/\r\n?/g, '\n').length;
623
+ const provenance = Object.freeze({
624
+ text,
625
+ format: this.formatType,
626
+ from: from ?? normalizedLength,
627
+ to: to ?? from ?? normalizedLength,
628
+ });
629
+ let parsed;
630
+ try {
631
+ parsed =
632
+ this.formatType === 'txt'
633
+ ? view.state.schema.topNodeType.create(null, view.state.schema.nodes.paragraph.create(null, text ? view.state.schema.text(text) : undefined))
634
+ : this.editor.ctx.get(parserCtx)(text);
635
+ if (this.formatType === 'md')
636
+ this.sourcePreservations.set(provenance, new Preservation(this.editor.ctx, text));
637
+ }
638
+ catch (error) {
639
+ throw new InkKitError('preservation', error instanceof Error ? error.message : 'Cannot read Markdown source');
640
+ }
641
+ if (this.literalSurface) {
642
+ const previous = Object.freeze({
643
+ text: this.currentText(),
644
+ format: this.formatType,
645
+ from: this.beforePlainInput?.from ?? this.plain.selectionStart,
646
+ to: this.beforePlainInput?.to ?? this.plain.selectionEnd,
647
+ });
648
+ // History must retain the source caret even when the formatted document has no corresponding position.
649
+ this.loading = true;
650
+ try {
651
+ view.dispatch(view.state.tr
652
+ .setDocAttribute(sourceAttribute, previous)
653
+ .setMeta('addToHistory', false));
654
+ }
655
+ finally {
656
+ this.loading = false;
657
+ }
658
+ }
659
+ let tr = view.state.tr
660
+ .replaceWith(0, view.state.doc.content.size, parsed.content)
661
+ .setDocAttribute(sourceAttribute, provenance);
662
+ tr.setSelection(Selection.atEnd(tr.doc));
663
+ if (isolated)
664
+ tr = closeHistory(tr);
665
+ view.dispatch(tr);
666
+ if (isolated)
667
+ view.dispatch(closeHistory(view.state.tr).setMeta('addToHistory', false));
668
+ return true;
669
+ }
670
+ undo(expectedGeneration) {
671
+ this.assertMutation(expectedGeneration);
672
+ const view = this.editor.ctx.get(editorViewCtx);
673
+ return undoHistory(view.state, view.dispatch);
674
+ }
675
+ redo(expectedGeneration) {
676
+ this.assertMutation(expectedGeneration);
677
+ const view = this.editor.ctx.get(editorViewCtx);
678
+ return redoHistory(view.state, view.dispatch);
679
+ }
680
+ replaceLiteralSelection(text, isolated = true, retainEndings = false) {
681
+ const value = this.plain.value;
682
+ const start = this.plain.selectionStart, end = this.plain.selectionEnd;
683
+ const next = value.slice(0, start) + text.replace(/\r\n?/g, '\n') + value.slice(end);
684
+ const caret = start + text.replace(/\r\n?/g, '\n').length;
685
+ const previous = this.currentText();
686
+ const source = retainEndings
687
+ ? previous.slice(0, rawOffset(previous, start)) +
688
+ text +
689
+ previous.slice(rawOffset(previous, end))
690
+ : editedPlainSource(previous, next);
691
+ this.applySource(source, isolated, caret, caret);
316
692
  }
317
693
  snapshot(expectedGeneration) {
318
694
  this.assertCurrent(expectedGeneration);
319
695
  let text;
320
696
  try {
321
- text =
322
- this.formatType === 'txt'
323
- ? this.plainSource()
324
- : this.preservation.serialize(this.editor.ctx.get(editorViewCtx).state.doc);
697
+ text = this.currentText();
325
698
  }
326
699
  catch (error) {
327
700
  throw new InkKitError('preservation', error instanceof Error ? error.message : 'Cannot preserve Markdown');
@@ -377,7 +750,7 @@ export class InkKitEditor {
377
750
  if (this.formatType === 'txt') {
378
751
  const text = all
379
752
  ? snapshot.text
380
- : this.plain.value.slice(this.plain.selectionStart, this.plain.selectionEnd);
753
+ : snapshot.text.slice(rawOffset(snapshot.text, this.plain.selectionStart), rawOffset(snapshot.text, this.plain.selectionEnd));
381
754
  const pre = document.createElement('pre');
382
755
  pre.textContent = text;
383
756
  return {
@@ -389,40 +762,54 @@ export class InkKitEditor {
389
762
  };
390
763
  }
391
764
  const { doc, schema, selection } = this.editor.ctx.get(editorViewCtx).state;
765
+ const selectedSource = !all && this.mode === 'source'
766
+ ? sourceSelection(snapshot.text, this.plain.selectionStart, this.plain.selectionEnd)
767
+ : undefined;
768
+ const selectedDoc = selectedSource
769
+ ? this.editor.ctx.get(parserCtx)(selectedSource.shareable)
770
+ : undefined;
392
771
  const completeDiagrams = new Set();
393
- doc.descendants((node, pos) => {
772
+ (selectedDoc ?? doc).descendants((node, pos) => {
394
773
  if (isMermaid(node) &&
395
- (all ||
396
- (selection.from <= pos + 1 &&
397
- selection.to >= pos + node.nodeSize - 1))) {
774
+ (selectedDoc
775
+ ? Boolean(node.attrs.authoredFence?.close)
776
+ : all ||
777
+ (selection.from <= pos + 1 &&
778
+ selection.to >= pos + node.nodeSize - 1))) {
398
779
  completeDiagrams.add(node.textContent.replace(/\r\n?/g, '\n'));
399
780
  }
400
781
  });
401
- const content = all
402
- ? doc.content
403
- : commentSelectionContent(doc, selection.from, selection.to, selection.content().content);
782
+ const content = selectedDoc
783
+ ? selectedDoc.content
784
+ : all
785
+ ? doc.content
786
+ : commentSelectionContent(doc, selection.from, selection.to, selection.content().content);
404
787
  const valid = content.firstChild?.isInline
405
788
  ? Fragment.from(schema.nodes.paragraph.create(null, content))
406
789
  : content;
407
790
  let markdown;
408
791
  try {
409
- markdown = all
410
- ? snapshot.text
411
- : completeDiagrams.size
412
- ? new Preservation(this.editor.ctx, snapshot.text).serialize(doc.type.create(null, selectionContent(doc, valid)))
413
- : selectionMarkdown(this.editor.ctx, doc, valid);
792
+ markdown = selectedSource
793
+ ? selectedSource.markdown
794
+ : all
795
+ ? snapshot.text
796
+ : completeDiagrams.size
797
+ ? new Preservation(this.editor.ctx, snapshot.text).serialize(doc.type.create(null, selectionContent(doc, valid)))
798
+ : selectionMarkdown(this.editor.ctx, doc, valid);
414
799
  }
415
800
  catch (error) {
416
801
  throw new InkKitError('preservation', error instanceof Error
417
802
  ? error.message
418
803
  : 'Cannot copy the selected Markdown');
419
804
  }
420
- const result = await portableClipboard(all ? content : selectionContent(doc, content), schema, markdown, this.options.images, this.context(), completeDiagrams);
421
- const metadata = referenceMetadata(this.editor.ctx, doc, content);
805
+ const result = await portableClipboard(all || selectedDoc ? content : selectionContent(doc, content), schema, markdown, this.options.images, this.context(), completeDiagrams);
806
+ const metadata = referenceMetadata(this.editor.ctx, selectedDoc ?? doc, content);
422
807
  if (metadata != null)
423
808
  result.html = withReferenceMetadata(result.html, metadata);
424
809
  this.assertCurrent(snapshot.generation);
425
- if (snapshot.documentId !== this.documentId || epoch !== this.documentEpoch)
810
+ if (snapshot.documentId !== this.documentId ||
811
+ epoch !== this.documentEpoch ||
812
+ snapshot.revision !== this.revision)
426
813
  throw new InkKitError('stale-document', 'Document changed');
427
814
  if (result.images.some((image) => image.error))
428
815
  this.events.error?.(new InkKitError('image-unavailable', 'Some copied images were unavailable; their descriptions were retained'));
@@ -431,9 +818,11 @@ export class InkKitEditor {
431
818
  return result;
432
819
  }
433
820
  async paste(input) {
434
- this.assertCurrent();
435
- if (this.formatType === 'txt' || input.plainText) {
436
- this.pasteAsPlainText(input.text);
821
+ this.assertMutation();
822
+ if (this.literalSurface || input.plainText) {
823
+ this.pasteAsPlainText(this.mode === 'source' && this.formatType === 'md'
824
+ ? (input.markdown ?? input.text)
825
+ : input.text);
437
826
  return;
438
827
  }
439
828
  await this.pasteController.paste(input);
@@ -443,10 +832,9 @@ export class InkKitEditor {
443
832
  setCommentVisibility(this.editor.ctx.get(editorViewCtx), visible);
444
833
  }
445
834
  pasteAsPlainText(text) {
446
- this.assertCurrent();
447
- if (this.formatType === 'txt') {
448
- this.plain.setRangeText(text, this.plain.selectionStart, this.plain.selectionEnd, 'end');
449
- this.plain.dispatchEvent(new Event('input'));
835
+ this.assertMutation();
836
+ if (this.literalSurface) {
837
+ this.replaceLiteralSelection(text, true, this.formatType === 'md');
450
838
  }
451
839
  else {
452
840
  const view = this.editor.ctx.get(editorViewCtx);
@@ -483,8 +871,8 @@ export class InkKitEditor {
483
871
  }
484
872
  }
485
873
  table(command, options) {
486
- this.assertCurrent();
487
- if (this.formatType === 'txt')
874
+ this.assertMutation();
875
+ if (this.literalSurface)
488
876
  return false;
489
877
  return tableCommand(this.editor.ctx, command, options);
490
878
  }
@@ -504,6 +892,8 @@ export class InkKitEditor {
504
892
  }
505
893
  this.plain.remove();
506
894
  await this.editor.destroy();
895
+ if (this.ownsRootClass)
896
+ this.root.classList.remove('inkkit-root');
507
897
  }
508
898
  // The app's bindings, replaced whole whenever they change; the plugin stays.
509
899
  keymapPlugin = $prose(() => new Plugin({
@@ -517,19 +907,81 @@ export class InkKitEditor {
517
907
  },
518
908
  },
519
909
  }));
910
+ copySourceSelection(event) {
911
+ event.preventDefault();
912
+ event.stopPropagation();
913
+ try {
914
+ const snapshot = this.snapshot();
915
+ const from = this.plain.selectionStart, to = this.plain.selectionEnd;
916
+ const selected = sourceSelection(snapshot.text, from, to);
917
+ const parsed = this.editor.ctx.get(parserCtx)(selected.shareable);
918
+ let portable = false;
919
+ parsed.descendants((node) => {
920
+ if (node.type.name === 'image' || isMermaid(node))
921
+ portable = true;
922
+ });
923
+ const cut = event.type === 'cut' && this.writable;
924
+ const policyEpoch = this.policyEpoch;
925
+ const epoch = this.documentEpoch;
926
+ if (portable && this.events.clipboard) {
927
+ void this.clipboardSnapshot(false)
928
+ .then(async (content) => {
929
+ if (cut &&
930
+ (content.images.some((image) => image.error) ||
931
+ content.diagrams?.some((diagram) => diagram.error)))
932
+ throw new InkKitError('image-unavailable', 'The selected content could not be cut safely');
933
+ await this.events.clipboard(content);
934
+ if (cut) {
935
+ this.assertMutation(snapshot.generation);
936
+ if (epoch !== this.documentEpoch ||
937
+ policyEpoch !== this.policyEpoch ||
938
+ snapshot.revision !== this.revision)
939
+ throw new InkKitError('stale-document', 'Document changed before cutting');
940
+ this.plain.setSelectionRange(from, to);
941
+ this.replaceLiteralSelection('');
942
+ }
943
+ })
944
+ .catch((error) => this.events.error?.(error));
945
+ }
946
+ else {
947
+ const output = clipboardContent(parsed.content, parsed.type.schema);
948
+ if (!event.clipboardData)
949
+ return;
950
+ event.clipboardData.setData('text/plain', output.text);
951
+ event.clipboardData.setData('text/html', output.html);
952
+ if (portable) {
953
+ this.events.error?.(new InkKitError('image-unavailable', 'The host must provide a clipboard handler for portable images and diagrams'));
954
+ return;
955
+ }
956
+ if (cut)
957
+ this.replaceLiteralSelection('');
958
+ }
959
+ }
960
+ catch (error) {
961
+ this.events.error?.(error instanceof Error ? error : new Error(String(error)));
962
+ }
963
+ }
520
964
  static async mount(root, events, options = {}) {
521
965
  const instance = new InkKitEditor(events, options);
522
966
  instance.root = root;
967
+ instance.ownsRootClass = !root.classList.contains('inkkit-root');
968
+ root.classList.add('inkkit-root');
523
969
  instance.pasteController = new PasteController({
524
970
  ctx: () => instance.editor.ctx,
525
971
  context: () => instance.context(),
526
972
  adapter: options.images,
527
973
  onError: (error) => events.error?.(error instanceof Error ? error : new Error(String(error))),
528
- literalText: () => instance.formatType === 'txt',
974
+ literalText: () => instance.literalSurface,
975
+ editable: () => instance.writable && !instance.literalSurface,
976
+ onPendingChanged: () => instance.publishCommandState(),
529
977
  });
530
978
  instance.editor = await Editor.make()
531
979
  .config((ctx) => {
532
980
  ctx.set(rootCtx, root);
981
+ ctx.update(labelsCtx.key, () => ({
982
+ ...defaultLabels,
983
+ ...options.labels,
984
+ }));
533
985
  ctx.set(defaultValueCtx, '');
534
986
  ctx.set(remarkStringifyOptionsCtx, stringifyOptions);
535
987
  // The preset also binds Mod-[ and Mod-] here; the app uses those for back and forward.
@@ -548,6 +1000,8 @@ export class InkKitEditor {
548
1000
  }
549
1001
  return cleared;
550
1002
  });
1003
+ unbind(tableKeymap, ['NextCell', 'PrevCell']);
1004
+ unbind(historyKeymap);
551
1005
  unbind(strongKeymap);
552
1006
  unbind(emphasisKeymap);
553
1007
  unbind(inlineCodeKeymap);
@@ -566,7 +1020,8 @@ export class InkKitEditor {
566
1020
  scrollMargin: { top: 8, right: 0, bottom: 24, left: 0 },
567
1021
  }));
568
1022
  })
569
- .use(caretStatePlugin(events))
1023
+ .use(labelsCtx)
1024
+ .use(caretStatePlugin(events, () => !instance.loading && !instance.literalSurface))
570
1025
  .use(instance.pasteController.plugin)
571
1026
  .use(visibleClipboard)
572
1027
  .use(instance.keymapPlugin)
@@ -587,12 +1042,13 @@ export class InkKitEditor {
587
1042
  .use(mermaidPreview((error) => events.error?.(error)))
588
1043
  .use(selectionPlugin)
589
1044
  .use($prose(() => search()))
1045
+ .use(instance.policyPlugin)
590
1046
  .use(options.images ? imageView(options.images) : [])
591
1047
  .create();
592
1048
  configureTableMovement(instance.editor.ctx, () => {
593
1049
  try {
594
1050
  instance.assertCurrent();
595
- return instance.formatType === 'md';
1051
+ return instance.writable && !instance.literalSurface;
596
1052
  }
597
1053
  catch {
598
1054
  return false;
@@ -603,15 +1059,64 @@ export class InkKitEditor {
603
1059
  instance.plain.setAttribute('aria-label', 'Plain text editor');
604
1060
  instance.plain.hidden = true;
605
1061
  root.append(instance.plain);
1062
+ instance.plain.addEventListener('beforeinput', (event) => {
1063
+ if (!instance.writable) {
1064
+ event.preventDefault();
1065
+ return;
1066
+ }
1067
+ if (instance.ready && instance.literalSurface)
1068
+ instance.beforePlainInput = {
1069
+ from: instance.plain.selectionStart,
1070
+ to: instance.plain.selectionEnd,
1071
+ };
1072
+ });
606
1073
  instance.plain.addEventListener('input', () => {
607
- instance.revision += 1;
608
- events.changed(instance.plainSource(), instance.generation);
1074
+ if (!instance.ready || !instance.literalSurface)
1075
+ return;
1076
+ if (!instance.writable) {
1077
+ instance.plain.value = instance.currentText();
1078
+ return;
1079
+ }
1080
+ try {
1081
+ instance.applySource(editedPlainSource(instance.currentText(), instance.plain.value), false, instance.plain.selectionStart, instance.plain.selectionEnd);
1082
+ }
1083
+ catch (error) {
1084
+ events.error?.(error instanceof Error ? error : new Error(String(error)));
1085
+ }
1086
+ finally {
1087
+ instance.beforePlainInput = undefined;
1088
+ }
1089
+ });
1090
+ instance.plain.addEventListener('keydown', (event) => {
1091
+ if (instance.plainComposing)
1092
+ return;
1093
+ if (instance.literalKeys(instance.editor.ctx.get(editorViewCtx), event))
1094
+ event.preventDefault();
1095
+ });
1096
+ for (const event of ['select', 'keyup', 'click'])
1097
+ instance.plain.addEventListener(event, () => instance.publishCommandState());
1098
+ instance.plain.addEventListener('paste', (event) => {
1099
+ if (!instance.writable) {
1100
+ event.preventDefault();
1101
+ return;
1102
+ }
1103
+ if (!event.clipboardData)
1104
+ return;
1105
+ event.preventDefault();
1106
+ void instance
1107
+ .paste({
1108
+ text: event.clipboardData.getData('text/plain'),
1109
+ markdown: event.clipboardData.getData('text/markdown') || undefined,
1110
+ })
1111
+ .catch((error) => events.error?.(error));
609
1112
  });
610
1113
  instance.plain.addEventListener('compositionstart', () => {
611
1114
  instance.plainComposing = true;
1115
+ instance.publishCommandState();
612
1116
  });
613
1117
  instance.plain.addEventListener('compositionend', () => {
614
1118
  instance.plainComposing = false;
1119
+ instance.publishCommandState();
615
1120
  });
616
1121
  const activateFootnote = (event) => {
617
1122
  const element = event.target.closest('[data-inkkit-footnote-reference]');
@@ -657,6 +1162,10 @@ export class InkKitEditor {
657
1162
  instance.copyHandler = (event) => {
658
1163
  if (instance.formatType === 'txt')
659
1164
  return;
1165
+ if (instance.mode === 'source') {
1166
+ instance.copySourceSelection(event);
1167
+ return;
1168
+ }
660
1169
  const view = instance.editor.ctx.get(editorViewCtx);
661
1170
  const fragment = commentSelectionContent(view.state.doc, view.state.selection.from, view.state.selection.to, view.state.selection.content().content);
662
1171
  const expanded = selectionContent(view.state.doc, fragment);
@@ -683,7 +1192,7 @@ export class InkKitEditor {
683
1192
  event.clipboardData.setData('text/html', metadata != null
684
1193
  ? withReferenceMetadata(output.html, metadata)
685
1194
  : output.html);
686
- if (event.type === 'cut')
1195
+ if (event.type === 'cut' && instance.writable)
687
1196
  view.dispatch(view.state.tr.deleteSelection().scrollIntoView());
688
1197
  }
689
1198
  catch (error) {
@@ -696,7 +1205,8 @@ export class InkKitEditor {
696
1205
  if (events.clipboard) {
697
1206
  const epoch = instance.documentEpoch, revision = instance.revision;
698
1207
  const { from, to } = view.state.selection;
699
- const cut = event.type === 'cut';
1208
+ const cut = event.type === 'cut' && instance.writable;
1209
+ const policyEpoch = instance.policyEpoch;
700
1210
  void instance
701
1211
  .clipboardSnapshot(false)
702
1212
  .then(async (content) => {
@@ -706,8 +1216,9 @@ export class InkKitEditor {
706
1216
  throw new InkKitError('image-unavailable', 'The selected content could not be cut safely');
707
1217
  await events.clipboard(content);
708
1218
  if (cut) {
709
- instance.assertCurrent();
1219
+ instance.assertMutation();
710
1220
  if (epoch !== instance.documentEpoch ||
1221
+ policyEpoch !== instance.policyEpoch ||
711
1222
  revision !== instance.revision)
712
1223
  throw new InkKitError('stale-document', 'Document changed before cutting');
713
1224
  view.dispatch(view.state.tr.deleteRange(from, to).scrollIntoView());
@@ -725,42 +1236,71 @@ export class InkKitEditor {
725
1236
  };
726
1237
  root.addEventListener('copy', instance.copyHandler, true);
727
1238
  root.addEventListener('cut', instance.copyHandler, true);
1239
+ instance.applyInputPolicy();
1240
+ instance.setKeymap(options.keymap ?? {});
728
1241
  return instance;
729
1242
  }
730
1243
  loadDocument(input) {
731
1244
  this.assertAlive();
1245
+ this.ready = false;
732
1246
  this.pasteController.cancelPending();
733
1247
  this.documentEpoch += 1;
1248
+ this.outlineEpoch += 1;
1249
+ this.sourcePreservations = new WeakMap();
1250
+ this.mode = 'formatted';
734
1251
  this.plainComposing = false;
1252
+ this.beforePlainInput = undefined;
735
1253
  this.plainSearch = '';
1254
+ this.literalMatch = undefined;
736
1255
  this.documentId = input.documentId;
737
1256
  this.formatType = input.format;
738
1257
  this.revision = 0;
739
- this.plain.hidden = input.format !== 'txt';
740
- this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
741
- input.format === 'txt';
742
1258
  this.plain.value = input.text;
743
1259
  this.load(input.format === 'txt' ? '' : input.text, input.generation);
744
1260
  this.originalSource = input.text;
1261
+ this.lastMarkdown = input.text;
1262
+ this.updateSurface();
745
1263
  this.ready = true;
1264
+ this.events.stateChanged(this.literalSurface
1265
+ ? { marks: [], block: { type: 'paragraph' }, quoted: false }
1266
+ : caretState(this.editor.ctx.get(editorViewCtx).state));
1267
+ this.publishCommandState();
746
1268
  }
747
1269
  reloadDocument(input) {
748
1270
  this.assertAlive();
749
1271
  const view = this.editor.ctx.get(editorViewCtx);
750
- const at = this.formatType === 'txt'
1272
+ const wasLiteral = this.literalSurface;
1273
+ const previousMode = this.editingMode;
1274
+ const previousFormat = this.formatType;
1275
+ const at = wasLiteral
751
1276
  ? this.plain.selectionStart
752
1277
  : view.state.selection.from;
1278
+ const end = wasLiteral ? this.plain.selectionEnd : at;
753
1279
  const top = this.root.scrollTop;
754
- this.loadDocument(input);
755
- if (input.format === 'txt')
756
- this.plain.setSelectionRange(Math.min(at, input.text.length), Math.min(at, input.text.length));
757
- else
758
- view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(Math.min(at, view.state.doc.content.size)))));
759
- this.root.scrollTop = top;
1280
+ const plainTop = this.plain.scrollTop, plainLeft = this.plain.scrollLeft;
1281
+ this.reportingSuppressed = true;
1282
+ try {
1283
+ this.loadDocument(input);
1284
+ if (previousFormat === 'md' &&
1285
+ input.format === 'md' &&
1286
+ previousMode === 'source')
1287
+ this.setEditingMode('source', input.generation);
1288
+ if (this.literalSurface)
1289
+ this.plain.setSelectionRange(Math.min(at, this.plain.value.length), Math.min(end, this.plain.value.length));
1290
+ else
1291
+ view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(Math.min(at, view.state.doc.content.size)))));
1292
+ this.root.scrollTop = top;
1293
+ if (wasLiteral && this.literalSurface) {
1294
+ this.plain.scrollTop = plainTop;
1295
+ this.plain.scrollLeft = plainLeft;
1296
+ }
1297
+ }
1298
+ finally {
1299
+ this.reportingSuppressed = false;
1300
+ this.publishCommandState();
1301
+ }
760
1302
  }
761
1303
  load(markdown, generation) {
762
- // A loaded document only counts as changed once it is edited, so its
763
- // canonical form is the baseline, not the text as stored.
764
1304
  this.generation = generation;
765
1305
  this.originalSource = markdown;
766
1306
  this.loading = true;
@@ -771,8 +1311,7 @@ export class InkKitEditor {
771
1311
  this.loading = false;
772
1312
  }
773
1313
  this.preservation = new Preservation(this.editor.ctx, markdown);
774
- this.baseline = serialize(this.editor.ctx);
775
- this.lastMarkdown = this.baseline;
1314
+ this.lastMarkdown = markdown;
776
1315
  const view = this.editor.ctx.get(editorViewCtx);
777
1316
  // A caret at the end, where writing carries on; at the start it would sit in a first-line heading and
778
1317
  // show its marks. The last place text can go, so a document ending in a rule takes a caret above it
@@ -781,56 +1320,119 @@ export class InkKitEditor {
781
1320
  const end = Selection.findFrom(doc.resolve(doc.content.size), -1, true) ??
782
1321
  Selection.atEnd(doc);
783
1322
  view.dispatch(setSearchState(view.state.tr.setSelection(end).scrollIntoView(), new SearchQuery({ search: '' })));
784
- this.events.stateChanged(caretState(view.state));
785
1323
  }
786
- find(text) {
787
- this.assertCurrent();
788
- if (this.formatType === 'txt') {
789
- const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
790
- const matches = text
791
- ? [...this.plain.value.matchAll(new RegExp(escaped, 'giu'))]
792
- : [];
1324
+ find(text, expectedGeneration) {
1325
+ this.assertCurrent(expectedGeneration);
1326
+ if (this.literalSurface) {
793
1327
  const from = this.plainSearch === text
794
1328
  ? this.plain.selectionEnd
795
1329
  : this.plain.selectionStart;
796
- const match = matches.find((item) => item.index >= from) ?? matches[0];
797
- if (match)
798
- this.plain.setSelectionRange(match.index, match.index + match[0].length);
1330
+ const raw = this.currentText();
1331
+ const match = findLiteral(raw, text, rawOffset(raw, from));
1332
+ this.literalMatch = undefined;
1333
+ if (match) {
1334
+ // A textarea displays CRLF as one character; retain a match of either raw half for replacement.
1335
+ const start = raw[match.from] === '\n' && raw[match.from - 1] === '\r'
1336
+ ? match.from - 1
1337
+ : match.from;
1338
+ const displayedFrom = normalizedOffset(raw, start), displayedTo = normalizedOffset(raw, match.to);
1339
+ this.plain.setSelectionRange(displayedFrom, displayedTo);
1340
+ this.literalMatch = {
1341
+ ...match,
1342
+ query: text,
1343
+ displayedFrom,
1344
+ displayedTo,
1345
+ revision: this.revision,
1346
+ epoch: this.documentEpoch,
1347
+ };
1348
+ }
799
1349
  else if (this.plainSearch)
800
1350
  this.plain.setSelectionRange(this.plain.selectionEnd, this.plain.selectionEnd);
801
1351
  this.plainSearch = text;
1352
+ revealSourceCaret(this.plain);
802
1353
  return;
803
1354
  }
804
- const view = this.editor.ctx.get(editorViewCtx);
805
- const previous = getSearchState(view.state)?.query.search;
806
- const query = new SearchQuery({ search: text, literal: true });
807
- let tr = setSearchState(view.state.tr, query);
808
- if (text) {
809
- const from = previous === text ? view.state.selection.to : view.state.selection.from;
810
- const match = query.findNext(view.state, from) ?? query.findNext(view.state, 0);
811
- if (match)
812
- tr = tr
813
- .setSelection(TextSelection.create(tr.doc, match.from, match.to))
814
- .scrollIntoView();
815
- else
816
- tr = tr.setSelection(Selection.near(view.state.selection.$to));
1355
+ findFormatted(this.editor.ctx.get(editorViewCtx), text);
1356
+ }
1357
+ replace(search, replacement, expectedGeneration) {
1358
+ this.assertMutation(expectedGeneration);
1359
+ return this.replaceMatches(search, replacement, false) > 0;
1360
+ }
1361
+ replaceAll(search, replacement, expectedGeneration) {
1362
+ this.assertMutation(expectedGeneration);
1363
+ return this.replaceMatches(search, replacement, true);
1364
+ }
1365
+ replaceMatches(search, replacement, all) {
1366
+ if (this.literalSurface) {
1367
+ const raw = this.currentText();
1368
+ const remembered = this.literalMatch;
1369
+ const selection = remembered &&
1370
+ remembered.query === search &&
1371
+ remembered.revision === this.revision &&
1372
+ remembered.epoch === this.documentEpoch &&
1373
+ remembered.displayedFrom === this.plain.selectionStart &&
1374
+ remembered.displayedTo === this.plain.selectionEnd
1375
+ ? { from: remembered.from, to: remembered.to }
1376
+ : {
1377
+ from: rawOffset(raw, this.plain.selectionStart),
1378
+ to: rawOffset(raw, this.plain.selectionEnd),
1379
+ };
1380
+ if (remembered?.query !== search)
1381
+ this.literalMatch = undefined;
1382
+ const result = replaceLiteral(raw, search, replacement, selection, all);
1383
+ if (result.count)
1384
+ this.applySource(result.text, true, normalizedOffset(result.text, result.from), normalizedOffset(result.text, result.to));
1385
+ this.plainSearch = search;
1386
+ return result.count;
817
1387
  }
818
- else if (previous) {
819
- tr = tr.setSelection(Selection.near(view.state.selection.$to));
1388
+ return replaceFormatted(this.editor.ctx.get(editorViewCtx), search, replacement, (doc) => {
1389
+ this.currentText(doc);
1390
+ }, all);
1391
+ }
1392
+ outlineContext() {
1393
+ return {
1394
+ documentId: this.documentId,
1395
+ generation: this.generation,
1396
+ revision: this.revision,
1397
+ epoch: this.outlineEpoch,
1398
+ mode: this.editingMode,
1399
+ };
1400
+ }
1401
+ headings(expectedGeneration) {
1402
+ this.assertCurrent(expectedGeneration);
1403
+ if (this.formatType === 'txt')
1404
+ return Object.freeze([]);
1405
+ return this.mode === 'source'
1406
+ ? collectSourceHeadings(this.editor.ctx, this.plain.value, this.outlineContext())
1407
+ : collectFormattedHeadings(this.editor.ctx.get(editorViewCtx).state.doc, this.outlineContext());
1408
+ }
1409
+ navigateHeading(heading) {
1410
+ this.assertCurrent();
1411
+ if (!heading || typeof heading !== 'object')
1412
+ throw new InkKitError('stale-document', 'The heading is no longer current');
1413
+ this.assertCurrent(heading.generation);
1414
+ if (this.formatType === 'txt')
1415
+ throw new InkKitError('stale-document', 'The heading is no longer current');
1416
+ if (this.mode === 'source') {
1417
+ const position = sourceHeadingPosition(this.editor.ctx, this.plain.value, heading, this.outlineContext());
1418
+ this.plain.setSelectionRange(position, position);
1419
+ this.plain.focus({ preventScroll: true });
1420
+ revealSourceCaret(this.plain);
1421
+ return true;
820
1422
  }
821
- view.dispatch(tr);
1423
+ return navigateFormattedHeading(this.editor.ctx.get(editorViewCtx), heading, this.outlineContext());
822
1424
  }
823
1425
  /** Buffered native typing follows the same input rules as direct typing. */
824
1426
  insertText(text, generation) {
825
1427
  this.assertAlive();
826
- if ((this.formatType === 'txt'
1428
+ if ((this.literalSurface
827
1429
  ? this.plainComposing
828
1430
  : this.editor.ctx.get(editorViewCtx).composing) &&
829
1431
  generation === this.generation)
830
1432
  return false;
831
- this.assertCurrent(generation);
832
- if (this.formatType === 'txt') {
833
- this.pasteAsPlainText(text);
1433
+ this.assertMutation(generation);
1434
+ if (this.literalSurface) {
1435
+ this.replaceLiteralSelection(text, false);
834
1436
  return true;
835
1437
  }
836
1438
  const view = this.editor.ctx.get(editorViewCtx);
@@ -846,14 +1448,65 @@ export class InkKitEditor {
846
1448
  /** Let existing keymaps handle buffered editing commands before native fallback. */
847
1449
  keyDown(key, code, metaKey, ctrlKey, altKey, shiftKey, generation) {
848
1450
  this.assertAlive();
849
- if ((this.formatType === 'txt'
1451
+ if ((this.literalSurface
850
1452
  ? this.plainComposing
851
1453
  : this.editor.ctx.get(editorViewCtx).composing) &&
852
1454
  generation === this.generation)
853
1455
  return false;
854
1456
  this.assertCurrent(generation);
855
- if (this.formatType === 'txt')
1457
+ const configuredEvent = new KeyboardEvent('keydown', {
1458
+ key,
1459
+ code,
1460
+ metaKey,
1461
+ ctrlKey,
1462
+ altKey,
1463
+ shiftKey,
1464
+ bubbles: true,
1465
+ cancelable: true,
1466
+ });
1467
+ if (!this.literalSurface && altKey && key === 'Enter')
1468
+ return this.navigateFootnote(shiftKey ? 'reference' : 'definition');
1469
+ if (!this.writable &&
1470
+ !this.literalSurface &&
1471
+ key === 'Tab' &&
1472
+ !metaKey &&
1473
+ !ctrlKey &&
1474
+ !altKey) {
1475
+ const view = this.editor.ctx.get(editorViewCtx);
1476
+ return goToNextCell(shiftKey ? -1 : 1)(view.state, view.dispatch);
1477
+ }
1478
+ if (!this.writable) {
1479
+ if (['Enter', 'Tab', 'Backspace', 'Delete'].includes(key))
1480
+ this.assertMutation(generation);
1481
+ this.readOnlyKeys(this.editor.ctx.get(editorViewCtx), configuredEvent);
1482
+ }
1483
+ if (this.literalSurface) {
1484
+ if (this.literalKeys(this.editor.ctx.get(editorViewCtx), configuredEvent))
1485
+ return true;
1486
+ if (!this.writable &&
1487
+ ['Enter', 'Tab', 'Backspace', 'Delete'].includes(key))
1488
+ throw new InkKitError('read-only', 'The editor is read-only');
1489
+ if (metaKey || ctrlKey || altKey)
1490
+ return false;
1491
+ if (key === 'Enter' || key === 'Tab') {
1492
+ this.replaceLiteralSelection(key === 'Enter' ? '\n' : '\t', false);
1493
+ return true;
1494
+ }
1495
+ if (key === 'Backspace' || key === 'Delete') {
1496
+ const start = this.plain.selectionStart, end = this.plain.selectionEnd;
1497
+ if (start === end) {
1498
+ if (key === 'Backspace' && start > 0)
1499
+ this.plain.setSelectionRange(start - [...this.plain.value.slice(0, start)].at(-1).length, end);
1500
+ else if (key === 'Delete' && end < this.plain.value.length)
1501
+ this.plain.setSelectionRange(start, end + [...this.plain.value.slice(end)][0].length);
1502
+ else
1503
+ return false;
1504
+ }
1505
+ this.replaceLiteralSelection('', false);
1506
+ return true;
1507
+ }
856
1508
  return false;
1509
+ }
857
1510
  const view = this.editor.ctx.get(editorViewCtx);
858
1511
  if (view.composing)
859
1512
  return false;
@@ -873,7 +1526,7 @@ export class InkKitEditor {
873
1526
  this.assertAlive();
874
1527
  if (!this.ready)
875
1528
  throw new InkKitError('not-ready', 'No document has been loaded');
876
- if (this.formatType === 'txt')
1529
+ if (this.literalSurface)
877
1530
  this.plain.focus();
878
1531
  else
879
1532
  this.editor.ctx.get(editorViewCtx).focus();
@@ -882,24 +1535,61 @@ export class InkKitEditor {
882
1535
  * order, so a key given to two shortcuts lands the same way every time. */
883
1536
  setKeymap(keymap) {
884
1537
  this.assertAlive();
1538
+ this.configuredKeys = {
1539
+ undo: ['Mod-z'],
1540
+ redo: ['Mod-y', 'Shift-Mod-z'],
1541
+ tableExit: ['Mod-Enter', 'Enter'],
1542
+ ...keymap,
1543
+ };
885
1544
  const bindings = {};
1545
+ // Consuming unbound history keys prevents the browser's separate undo stack
1546
+ // from changing source/TXT outside the shared document history.
1547
+ const historyBindings = Object.fromEntries(['Mod-z', 'Mod-y', 'Shift-Mod-z'].map((key) => [key, () => true]));
1548
+ for (const name of ['undo', 'redo']) {
1549
+ for (const key of this.configuredKeys[name] ?? []) {
1550
+ historyBindings[key] = () => {
1551
+ if (!this.ready || !this.commandState().commands[name])
1552
+ return true;
1553
+ return this[name]();
1554
+ };
1555
+ }
1556
+ }
1557
+ Object.assign(bindings, historyBindings);
886
1558
  for (const [name, command] of Object.entries(shortcutCommands)) {
887
1559
  for (const key of keymap[name] ?? []) {
888
1560
  bindings[key] = () => {
1561
+ if (!this.ready || !this.commandState().commands.format[command[0]])
1562
+ return true;
889
1563
  this.format(...command);
890
1564
  return true;
891
1565
  };
892
1566
  }
893
1567
  }
1568
+ for (const command of tableCommands) {
1569
+ const name = 'table' + command[0].toUpperCase() + command.slice(1);
1570
+ for (const key of this.configuredKeys[name] ?? [])
1571
+ bindings[key] = () => {
1572
+ if (!this.ready || !this.commandState().commands.table[command])
1573
+ return false;
1574
+ return this.table(command);
1575
+ };
1576
+ }
1577
+ this.readOnlyKeys = keydownHandler(Object.fromEntries(Object.keys(bindings).map((key) => [
1578
+ key,
1579
+ () => {
1580
+ throw new InkKitError('read-only', 'The editor is read-only');
1581
+ },
1582
+ ])));
894
1583
  this.keys = keydownHandler(bindings);
1584
+ this.literalKeys = keydownHandler(historyBindings);
895
1585
  }
896
1586
  /** Dropped files land as one paragraph per path at the drop point: in place of an empty block, after
897
1587
  * the top-level block otherwise, so a list or quote is not opened up by them. */
898
1588
  insertPaths(paths, x, y) {
899
- this.assertCurrent();
1589
+ this.assertMutation();
900
1590
  if (paths.length === 0)
901
1591
  return;
902
- if (this.formatType === 'txt') {
1592
+ if (this.literalSurface) {
903
1593
  this.pasteAsPlainText(paths.join('\n\n'));
904
1594
  return;
905
1595
  }
@@ -907,10 +1597,10 @@ export class InkKitEditor {
907
1597
  this.insertBlocks(paths.map((path) => schema.nodes.paragraph.create(null, schema.text(path))), x, y);
908
1598
  }
909
1599
  insertImages(images, x, y) {
910
- this.assertCurrent();
1600
+ this.assertMutation();
911
1601
  if (!images.length)
912
1602
  return;
913
- if (!this.options.images || this.formatType === 'txt')
1603
+ if (!this.options.images || this.literalSurface)
914
1604
  throw new InkKitError('image-unavailable', 'This editor has no image adapter');
915
1605
  const view = this.editor.ctx.get(editorViewCtx);
916
1606
  const { schema } = view.state;
@@ -1065,8 +1755,8 @@ export class InkKitEditor {
1065
1755
  view.dispatch(view.state.tr.lift(range, target));
1066
1756
  }
1067
1757
  format(command, arg) {
1068
- this.assertCurrent();
1069
- if (this.formatType === 'txt')
1758
+ this.assertMutation();
1759
+ if (this.literalSurface)
1070
1760
  return;
1071
1761
  const run = (cmd, payload) => this.editor.action(callCommand(cmd, payload));
1072
1762
  const state = caretState(this.editor.ctx.get(editorViewCtx).state);
@@ -1139,8 +1829,8 @@ export class InkKitEditor {
1139
1829
  this.focus();
1140
1830
  }
1141
1831
  editReferenceDefinition(label, destination, title) {
1142
- this.assertCurrent();
1143
- if (this.formatType === 'txt')
1832
+ this.assertMutation();
1833
+ if (this.literalSurface)
1144
1834
  return false;
1145
1835
  const view = this.editor.ctx.get(editorViewCtx);
1146
1836
  const definitions = referenceDefinitions(view.state.doc);
@@ -1156,8 +1846,8 @@ export class InkKitEditor {
1156
1846
  return true;
1157
1847
  }
1158
1848
  insertFootnote(label) {
1159
- this.assertCurrent();
1160
- if (this.formatType === 'txt')
1849
+ this.assertMutation();
1850
+ if (this.literalSurface)
1161
1851
  return;
1162
1852
  const view = this.editor.ctx.get(editorViewCtx);
1163
1853
  const definitions = footnoteDefinitions(view.state.doc);
@@ -1192,7 +1882,7 @@ export class InkKitEditor {
1192
1882
  }
1193
1883
  navigateFootnote(target) {
1194
1884
  this.assertCurrent();
1195
- if (this.formatType === 'txt')
1885
+ if (this.literalSurface)
1196
1886
  return false;
1197
1887
  const view = this.editor.ctx.get(editorViewCtx);
1198
1888
  const { $from } = view.state.selection;