@aicayzer/inkkit 0.0.7 → 0.0.9

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,33 +1,36 @@
1
- import { Editor, defaultValueCtx, editorViewCtx, parserCtx, 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
5
  import { closeHistory, undo as undoHistory, redo as redoHistory, } from '@milkdown/kit/prose/history';
6
6
  import { cursor } from '@milkdown/kit/plugin/cursor';
7
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';
8
- import { strikethroughKeymap, toggleStrikethroughCommand, } from '@milkdown/kit/preset/gfm';
8
+ import { strikethroughKeymap, tableKeymap, toggleStrikethroughCommand, } from '@milkdown/kit/preset/gfm';
9
9
  import { NodeRange, Fragment, Slice, } from '@milkdown/kit/prose/model';
10
10
  import { keydownHandler, keymap } from '@milkdown/kit/prose/keymap';
11
+ import { goToNextCell } from '@milkdown/kit/prose/tables';
11
12
  import { liftListItem } from '@milkdown/kit/prose/schema-list';
12
13
  import { findWrapping, liftTarget } from '@milkdown/kit/prose/transform';
13
14
  import { AllSelection, NodeSelection, Plugin, PluginKey, Selection, TextSelection, } from '@milkdown/kit/prose/state';
14
15
  import { $prose, callCommand, replaceAll, } from '@milkdown/kit/utils';
15
16
  import { codeCopyPlugin, placeholderPlugin } from './decorations.js';
17
+ import { labelsCtx, defaultLabels } from './labels.js';
16
18
  import { createDialect, stringifyOptions } from './dialect.js';
17
19
  import { Preservation } from './preserve.js';
18
- import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection, revealSourceCaret, rawOffset, normalizedOffset, } from './source.js';
20
+ import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection, rawOffset, normalizedOffset, } from './source.js';
19
21
  import { normaliseLabel, referenceDefinitions, footnoteDefinitions, } from './references.js';
20
22
  import { selectionMarkdown, selectionContent, referenceMetadata, withReferenceMetadata, } from './reference-clipboard.js';
21
23
  import { imageView } from './images.js';
22
- import { tablePlugins, tableCommand, configureTableMovement, } from './tables.js';
24
+ import { tablePlugins, tableCommand, tableCommands, tableAvailability, tableContext, configureTableMovement, } from './tables.js';
23
25
  import { InkKitError, } from './types.js';
26
+ import { readableProjection, projectionPosition, projectionOffset, validateOffsets, formattedRects, literalRects, literalVisibleRanges, intersectsViewport, textRect, } from './text-ranges.js';
24
27
  import { highlightPlugin } from './highlight.js';
25
28
  import { highlightKeymap, toggleHighlightCommand } from './inline-highlight.js';
26
29
  import { commentSelectionContent, setCommentVisibility } from './comments.js';
27
30
  import { PasteController } from './paste.js';
28
31
  import { selectionPlugin } from './selection.js';
29
32
  import { search, SearchQuery, setSearchState } from 'prosemirror-search';
30
- import { findFormatted, findLiteral, replaceFormatted, replaceLiteral, } from './search.js';
33
+ import { findFormatted, findLiteral, replaceFormatted, replaceLiteral, replaceFormattedRange, } from './search.js';
31
34
  import { collectFormattedHeadings, collectSourceHeadings, navigateFormattedHeading, sourceHeadingPosition, } from './outline.js';
32
35
  import { taskListPlugin, toggleTaskList } from './tasks.js';
33
36
  import { isMermaid, mermaidPreview } from './mermaid.js';
@@ -36,6 +39,9 @@ const shortcutCommands = {
36
39
  heading1: ['heading', 1],
37
40
  heading2: ['heading', 2],
38
41
  heading3: ['heading', 3],
42
+ heading4: ['heading', 4],
43
+ heading5: ['heading', 5],
44
+ heading6: ['heading', 6],
39
45
  paragraph: ['paragraph'],
40
46
  bold: ['bold'],
41
47
  italic: ['italic'],
@@ -234,6 +240,7 @@ function joinLists(tr, start, index) {
234
240
  if (before && after && isList(before) && after.type === before.type)
235
241
  tr.join($start.posAtIndex(index));
236
242
  }
243
+ let editorSequence = 0;
237
244
  export class InkKitEditor {
238
245
  events;
239
246
  options;
@@ -257,12 +264,29 @@ export class InkKitEditor {
257
264
  pasteController;
258
265
  plain;
259
266
  root;
267
+ ownsRootClass = false;
260
268
  clickHandler;
261
269
  footnoteKeyHandler;
262
270
  copyHandler;
263
271
  originalSource = '';
264
272
  generation = 0;
273
+ reportingSuppressed = false;
265
274
  loading = false;
275
+ textIdentity = ++editorSequence;
276
+ textScope;
277
+ viewportContainer;
278
+ viewportInsets = {
279
+ top: 0,
280
+ right: 0,
281
+ bottom: 0,
282
+ left: 0,
283
+ };
284
+ writable = true;
285
+ inputPreferences = {};
286
+ policyEpoch = 0;
287
+ literalKeys = () => false;
288
+ configuredKeys = {};
289
+ readOnlyKeys = () => false;
266
290
  changePlugin = $prose(() => new Plugin({
267
291
  key: new PluginKey('documentChanges'),
268
292
  view: () => ({
@@ -287,7 +311,7 @@ export class InkKitEditor {
287
311
  this.plain.value = markdown;
288
312
  if (!this.plainComposing) {
289
313
  this.plain.setSelectionRange(Math.min(from, this.plain.value.length), Math.min(to, this.plain.value.length));
290
- revealSourceCaret(this.plain);
314
+ this.revealLiteralSelection();
291
315
  }
292
316
  }
293
317
  if (markdown === this.lastMarkdown)
@@ -301,6 +325,8 @@ export class InkKitEditor {
301
325
  constructor(events, options) {
302
326
  this.events = events;
303
327
  this.options = options;
328
+ this.writable = options.editable ?? true;
329
+ this.inputPreferences = { ...options.textInput };
304
330
  }
305
331
  context() {
306
332
  return {
@@ -326,6 +352,212 @@ export class InkKitEditor {
326
352
  if (this.pasteController.pending)
327
353
  throw new InkKitError('operation-pending', 'An image import is in progress');
328
354
  }
355
+ assertMutation(generation = this.generation) {
356
+ this.assertCurrent(generation);
357
+ if (!this.writable)
358
+ throw new InkKitError('read-only', 'The editor is read-only');
359
+ }
360
+ get composing() {
361
+ return this.literalSurface
362
+ ? this.plainComposing
363
+ : this.editor.ctx.get(editorViewCtx).composing;
364
+ }
365
+ get editable() {
366
+ this.assertAlive();
367
+ return this.writable;
368
+ }
369
+ setEditable(editable) {
370
+ this.assertAlive();
371
+ if (this.composing)
372
+ throw new InkKitError('composition', 'Text composition is in progress');
373
+ if (this.writable === editable)
374
+ return;
375
+ const suppressed = this.reportingSuppressed;
376
+ this.reportingSuppressed = true;
377
+ try {
378
+ this.writable = editable;
379
+ this.policyEpoch += 1;
380
+ if (!editable)
381
+ this.pasteController.cancelPending();
382
+ this.editor.ctx
383
+ .get(editorViewCtx)
384
+ .dom.dispatchEvent(new Event('inkkit-cancel-resize'));
385
+ this.applyInputPolicy();
386
+ }
387
+ finally {
388
+ this.reportingSuppressed = suppressed;
389
+ }
390
+ this.publishCommandState();
391
+ }
392
+ setTextInputPreferences(preferences) {
393
+ this.assertAlive();
394
+ if (this.composing)
395
+ throw new InkKitError('composition', 'Text composition is in progress');
396
+ const suppressed = this.reportingSuppressed;
397
+ this.reportingSuppressed = true;
398
+ try {
399
+ this.inputPreferences = { ...preferences };
400
+ this.applyInputPolicy();
401
+ }
402
+ finally {
403
+ this.reportingSuppressed = suppressed;
404
+ }
405
+ this.publishCommandState();
406
+ }
407
+ applyInputPolicy() {
408
+ const view = this.editor.ctx.get(editorViewCtx);
409
+ const attributes = typeof view.props.attributes === 'function'
410
+ ? view.props.attributes(view.state)
411
+ : view.props.attributes;
412
+ view.setProps({
413
+ editable: () => this.writable && !this.literalSurface,
414
+ attributes: {
415
+ ...attributes,
416
+ role: 'textbox',
417
+ 'aria-multiline': 'true',
418
+ 'aria-label': this.options.labels?.formattedEditor ?? defaultLabels.formattedEditor,
419
+ },
420
+ });
421
+ this.plain.readOnly = !this.writable;
422
+ for (const surface of [view.dom, this.plain]) {
423
+ for (const name of [
424
+ 'spellcheck',
425
+ 'autocorrect',
426
+ 'autocapitalize',
427
+ ]) {
428
+ const value = this.inputPreferences[name];
429
+ if (value == null)
430
+ surface.removeAttribute(name);
431
+ else
432
+ surface.setAttribute(name, name === 'autocorrect' ? (value ? 'on' : 'off') : String(value));
433
+ }
434
+ }
435
+ }
436
+ publishCommandState() {
437
+ if (!this.ready ||
438
+ this.loading ||
439
+ this.reportingSuppressed ||
440
+ this.destroyed)
441
+ return;
442
+ this.events.commandStateChanged?.(this.commandState());
443
+ }
444
+ commandState(expectedGeneration = this.generation) {
445
+ this.assertAlive();
446
+ if (!this.ready)
447
+ throw new InkKitError('not-ready', 'No document has been loaded');
448
+ if (expectedGeneration !== this.generation)
449
+ throw new InkKitError('stale-document', 'Document changed');
450
+ const view = this.editor.ctx.get(editorViewCtx);
451
+ const { state } = view;
452
+ const mutable = this.writable && !this.composing && !this.pasteController.pending;
453
+ const formatted = mutable && !this.literalSurface;
454
+ const manager = this.editor.ctx.get(commandsCtx);
455
+ const check = (key, payload) => formatted && manager.get(key)(payload)(state);
456
+ const format = {
457
+ heading: check(wrapInHeadingCommand.key, 1) || check(turnIntoTextCommand.key),
458
+ paragraph: check(turnIntoTextCommand.key),
459
+ bold: check(toggleStrongCommand.key),
460
+ italic: check(toggleEmphasisCommand.key),
461
+ strikethrough: check(toggleStrikethroughCommand.key),
462
+ highlight: check(toggleHighlightCommand.key),
463
+ code: formatted &&
464
+ (state.selection.empty
465
+ ? state.selection.$from.parent.inlineContent &&
466
+ state.selection.$from.parent.type.allowsMarkType(inlineCodeSchema.type(this.editor.ctx))
467
+ : check(toggleInlineCodeCommand.key)),
468
+ codeBlock: check(createCodeBlockCommand.key, '') || check(turnIntoTextCommand.key),
469
+ quote: formatted && this.canQuote(state),
470
+ bulletList: formatted &&
471
+ (innermostListDepth(textBounds(state).$from) != null ||
472
+ check(wrapInBulletListCommand.key)),
473
+ orderedList: formatted &&
474
+ (innermostListDepth(textBounds(state).$from) != null ||
475
+ check(wrapInOrderedListCommand.key)),
476
+ taskList: formatted &&
477
+ (innermostListDepth(textBounds(state).$from) != null ||
478
+ check(wrapInBulletListCommand.key)),
479
+ link: check(toggleLinkCommand.key, {}),
480
+ };
481
+ const tables = formatted
482
+ ? tableAvailability(this.editor.ctx)
483
+ : Object.fromEntries(tableCommands.map((name) => [name, false]));
484
+ return {
485
+ documentId: this.documentId,
486
+ generation: this.generation,
487
+ revision: this.revision,
488
+ format: this.formatType,
489
+ mode: this.editingMode,
490
+ editable: this.writable,
491
+ composing: this.composing,
492
+ pending: this.pasteController.pending,
493
+ caret: this.literalSurface
494
+ ? { marks: [], block: { type: 'paragraph' }, quoted: false }
495
+ : caretState(state),
496
+ table: this.literalSurface ? undefined : tableContext(state),
497
+ commands: {
498
+ undo: mutable && undoHistory(state),
499
+ redo: mutable && redoHistory(state),
500
+ insertText: mutable,
501
+ paste: mutable,
502
+ replace: mutable,
503
+ replaceSource: mutable,
504
+ insertImages: formatted && !!this.options.images,
505
+ insertPaths: mutable,
506
+ insertFootnote: formatted,
507
+ editReferenceDefinition: formatted && referenceDefinitions(state.doc).size > 0,
508
+ format,
509
+ table: tables,
510
+ },
511
+ };
512
+ }
513
+ canQuote(state) {
514
+ const { $from, $to } = textBounds(state);
515
+ if (quoted($from, $to)) {
516
+ const range = $from.blockRange($to, (node) => node.type.name === 'blockquote');
517
+ return !!range && liftTarget(range) != null;
518
+ }
519
+ const type = blockquoteSchema.type(this.editor.ctx);
520
+ let range = $from.blockRange($to);
521
+ if (range && findWrapping(range, type))
522
+ return true;
523
+ const depth = outermostListDepth($from);
524
+ if (depth == null)
525
+ return false;
526
+ range = new NodeRange(state.doc.resolve($from.before(depth)), state.doc.resolve($from.after(depth)), depth - 1);
527
+ return !!findWrapping(range, type);
528
+ }
529
+ policyPlugin = $prose(() => new Plugin({
530
+ key: new PluginKey('inkkitInputPolicy'),
531
+ filterTransaction: (tr) => this.loading ||
532
+ this.writable ||
533
+ (!tr.docChanged && !tr.storedMarksSet),
534
+ props: {
535
+ editable: () => this.writable && !this.literalSurface,
536
+ handleDOMEvents: {
537
+ compositionstart: () => {
538
+ queueMicrotask(() => this.publishCommandState());
539
+ return false;
540
+ },
541
+ compositionend: () => {
542
+ queueMicrotask(() => this.publishCommandState());
543
+ return false;
544
+ },
545
+ beforeinput: (_view, event) => {
546
+ if (this.writable)
547
+ return false;
548
+ event.preventDefault();
549
+ return true;
550
+ },
551
+ drop: (_view, event) => {
552
+ if (this.writable)
553
+ return false;
554
+ event.preventDefault();
555
+ return true;
556
+ },
557
+ },
558
+ },
559
+ view: () => ({ update: () => this.publishCommandState() }),
560
+ }));
329
561
  get literalSurface() {
330
562
  return this.formatType === 'txt' || this.mode === 'source';
331
563
  }
@@ -353,6 +585,12 @@ export class InkKitEditor {
353
585
  throw new RangeError('Editing mode must be source or formatted');
354
586
  if (this.formatType === 'txt' || this.mode === mode)
355
587
  return false;
588
+ const view = this.editor.ctx.get(editorViewCtx);
589
+ const focused = this.literalSurface
590
+ ? this.plain.ownerDocument.activeElement === this.plain
591
+ : view.hasFocus();
592
+ const container = this.viewportContainer ?? this.root;
593
+ const top = container.scrollTop, left = container.scrollLeft;
356
594
  const text = this.snapshot().text;
357
595
  this.mode = mode;
358
596
  this.outlineEpoch += 1;
@@ -362,23 +600,45 @@ export class InkKitEditor {
362
600
  this.plain.value = text;
363
601
  this.plain.setSelectionRange(this.plain.value.length, this.plain.value.length);
364
602
  }
365
- this.updateSurface();
603
+ const suppressed = this.reportingSuppressed;
604
+ this.reportingSuppressed = true;
605
+ try {
606
+ this.updateSurface();
607
+ }
608
+ finally {
609
+ this.reportingSuppressed = suppressed;
610
+ }
611
+ if (focused) {
612
+ if (this.literalSurface)
613
+ this.plain.focus({ preventScroll: true });
614
+ else
615
+ view.focus();
616
+ }
617
+ container.scrollTop = top;
618
+ container.scrollLeft = left;
366
619
  this.events.stateChanged(this.literalSurface
367
620
  ? { marks: [], block: { type: 'paragraph' }, quoted: false }
368
621
  : caretState(this.editor.ctx.get(editorViewCtx).state));
622
+ this.publishCommandState();
369
623
  return true;
370
624
  }
371
625
  updateSurface() {
372
626
  this.plain.hidden = !this.literalSurface;
373
- this.plain.setAttribute('aria-label', this.formatType === 'txt' ? 'Plain text editor' : 'Markdown source');
627
+ this.applyInputPolicy();
628
+ this.plain.setAttribute('aria-label', this.formatType === 'txt'
629
+ ? (this.options.labels?.plainTextEditor ??
630
+ defaultLabels.plainTextEditor)
631
+ : (this.options.labels?.sourceEditor ?? defaultLabels.sourceEditor));
374
632
  this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
375
633
  this.literalSurface;
376
634
  }
377
635
  replaceSource(text, expectedGeneration) {
378
- this.assertCurrent(expectedGeneration);
636
+ this.assertMutation(expectedGeneration);
379
637
  return this.applySource(text, true);
380
638
  }
381
639
  applySource(text, isolated, from, to) {
640
+ if (!this.writable)
641
+ throw new InkKitError('read-only', 'The editor is read-only');
382
642
  if (typeof text !== 'string')
383
643
  throw new TypeError('Source must be a string');
384
644
  if (text === this.currentText())
@@ -433,12 +693,12 @@ export class InkKitEditor {
433
693
  return true;
434
694
  }
435
695
  undo(expectedGeneration) {
436
- this.assertCurrent(expectedGeneration);
696
+ this.assertMutation(expectedGeneration);
437
697
  const view = this.editor.ctx.get(editorViewCtx);
438
698
  return undoHistory(view.state, view.dispatch);
439
699
  }
440
700
  redo(expectedGeneration) {
441
- this.assertCurrent(expectedGeneration);
701
+ this.assertMutation(expectedGeneration);
442
702
  const view = this.editor.ctx.get(editorViewCtx);
443
703
  return redoHistory(view.state, view.dispatch);
444
704
  }
@@ -583,7 +843,7 @@ export class InkKitEditor {
583
843
  return result;
584
844
  }
585
845
  async paste(input) {
586
- this.assertCurrent();
846
+ this.assertMutation();
587
847
  if (this.literalSurface || input.plainText) {
588
848
  this.pasteAsPlainText(this.mode === 'source' && this.formatType === 'md'
589
849
  ? (input.markdown ?? input.text)
@@ -597,7 +857,7 @@ export class InkKitEditor {
597
857
  setCommentVisibility(this.editor.ctx.get(editorViewCtx), visible);
598
858
  }
599
859
  pasteAsPlainText(text) {
600
- this.assertCurrent();
860
+ this.assertMutation();
601
861
  if (this.literalSurface) {
602
862
  this.replaceLiteralSelection(text, true, this.formatType === 'md');
603
863
  }
@@ -636,7 +896,7 @@ export class InkKitEditor {
636
896
  }
637
897
  }
638
898
  table(command, options) {
639
- this.assertCurrent();
899
+ this.assertMutation();
640
900
  if (this.literalSurface)
641
901
  return false;
642
902
  return tableCommand(this.editor.ctx, command, options);
@@ -657,6 +917,8 @@ export class InkKitEditor {
657
917
  }
658
918
  this.plain.remove();
659
919
  await this.editor.destroy();
920
+ if (this.ownsRootClass)
921
+ this.root.classList.remove('inkkit-root');
660
922
  }
661
923
  // The app's bindings, replaced whole whenever they change; the plugin stays.
662
924
  keymapPlugin = $prose(() => new Plugin({
@@ -683,7 +945,8 @@ export class InkKitEditor {
683
945
  if (node.type.name === 'image' || isMermaid(node))
684
946
  portable = true;
685
947
  });
686
- const cut = event.type === 'cut';
948
+ const cut = event.type === 'cut' && this.writable;
949
+ const policyEpoch = this.policyEpoch;
687
950
  const epoch = this.documentEpoch;
688
951
  if (portable && this.events.clipboard) {
689
952
  void this.clipboardSnapshot(false)
@@ -694,8 +957,9 @@ export class InkKitEditor {
694
957
  throw new InkKitError('image-unavailable', 'The selected content could not be cut safely');
695
958
  await this.events.clipboard(content);
696
959
  if (cut) {
697
- this.assertCurrent(snapshot.generation);
960
+ this.assertMutation(snapshot.generation);
698
961
  if (epoch !== this.documentEpoch ||
962
+ policyEpoch !== this.policyEpoch ||
699
963
  snapshot.revision !== this.revision)
700
964
  throw new InkKitError('stale-document', 'Document changed before cutting');
701
965
  this.plain.setSelectionRange(from, to);
@@ -725,17 +989,24 @@ export class InkKitEditor {
725
989
  static async mount(root, events, options = {}) {
726
990
  const instance = new InkKitEditor(events, options);
727
991
  instance.root = root;
992
+ instance.ownsRootClass = !root.classList.contains('inkkit-root');
993
+ root.classList.add('inkkit-root');
728
994
  instance.pasteController = new PasteController({
729
995
  ctx: () => instance.editor.ctx,
730
996
  context: () => instance.context(),
731
997
  adapter: options.images,
732
998
  onError: (error) => events.error?.(error instanceof Error ? error : new Error(String(error))),
733
999
  literalText: () => instance.literalSurface,
734
- editable: () => !instance.literalSurface,
1000
+ editable: () => instance.writable && !instance.literalSurface,
1001
+ onPendingChanged: () => instance.publishCommandState(),
735
1002
  });
736
1003
  instance.editor = await Editor.make()
737
1004
  .config((ctx) => {
738
1005
  ctx.set(rootCtx, root);
1006
+ ctx.update(labelsCtx.key, () => ({
1007
+ ...defaultLabels,
1008
+ ...options.labels,
1009
+ }));
739
1010
  ctx.set(defaultValueCtx, '');
740
1011
  ctx.set(remarkStringifyOptionsCtx, stringifyOptions);
741
1012
  // The preset also binds Mod-[ and Mod-] here; the app uses those for back and forward.
@@ -754,6 +1025,8 @@ export class InkKitEditor {
754
1025
  }
755
1026
  return cleared;
756
1027
  });
1028
+ unbind(tableKeymap, ['NextCell', 'PrevCell']);
1029
+ unbind(historyKeymap);
757
1030
  unbind(strongKeymap);
758
1031
  unbind(emphasisKeymap);
759
1032
  unbind(inlineCodeKeymap);
@@ -765,14 +1038,17 @@ export class InkKitEditor {
765
1038
  unbind(codeBlockKeymap);
766
1039
  unbind(bulletListKeymap);
767
1040
  unbind(orderedListKeymap);
768
- // The caret is kept above the fade under the formatting bar.
769
1041
  ctx.update(editorViewOptionsCtx, (options) => ({
770
1042
  ...options,
771
- scrollThreshold: { top: 8, right: 0, bottom: 24, left: 0 },
772
- scrollMargin: { top: 8, right: 0, bottom: 24, left: 0 },
1043
+ handleScrollToSelection: () => {
1044
+ if (!instance.loading && instance.ready)
1045
+ instance.revealCurrentSelection();
1046
+ return true;
1047
+ },
773
1048
  }));
774
1049
  })
775
- .use(caretStatePlugin(events, () => !instance.literalSurface))
1050
+ .use(labelsCtx)
1051
+ .use(caretStatePlugin(events, () => !instance.loading && !instance.literalSurface))
776
1052
  .use(instance.pasteController.plugin)
777
1053
  .use(visibleClipboard)
778
1054
  .use(instance.keymapPlugin)
@@ -793,12 +1069,13 @@ export class InkKitEditor {
793
1069
  .use(mermaidPreview((error) => events.error?.(error)))
794
1070
  .use(selectionPlugin)
795
1071
  .use($prose(() => search()))
1072
+ .use(instance.policyPlugin)
796
1073
  .use(options.images ? imageView(options.images) : [])
797
1074
  .create();
798
1075
  configureTableMovement(instance.editor.ctx, () => {
799
1076
  try {
800
1077
  instance.assertCurrent();
801
- return !instance.literalSurface;
1078
+ return instance.writable && !instance.literalSurface;
802
1079
  }
803
1080
  catch {
804
1081
  return false;
@@ -809,7 +1086,11 @@ export class InkKitEditor {
809
1086
  instance.plain.setAttribute('aria-label', 'Plain text editor');
810
1087
  instance.plain.hidden = true;
811
1088
  root.append(instance.plain);
812
- instance.plain.addEventListener('beforeinput', () => {
1089
+ instance.plain.addEventListener('beforeinput', (event) => {
1090
+ if (!instance.writable) {
1091
+ event.preventDefault();
1092
+ return;
1093
+ }
813
1094
  if (instance.ready && instance.literalSurface)
814
1095
  instance.beforePlainInput = {
815
1096
  from: instance.plain.selectionStart,
@@ -819,6 +1100,10 @@ export class InkKitEditor {
819
1100
  instance.plain.addEventListener('input', () => {
820
1101
  if (!instance.ready || !instance.literalSurface)
821
1102
  return;
1103
+ if (!instance.writable) {
1104
+ instance.plain.value = instance.currentText();
1105
+ return;
1106
+ }
822
1107
  try {
823
1108
  instance.applySource(editedPlainSource(instance.currentText(), instance.plain.value), false, instance.plain.selectionStart, instance.plain.selectionEnd);
824
1109
  }
@@ -830,22 +1115,18 @@ export class InkKitEditor {
830
1115
  }
831
1116
  });
832
1117
  instance.plain.addEventListener('keydown', (event) => {
833
- if (instance.plainComposing || !(event.metaKey || event.ctrlKey))
834
- return;
835
- if (event.key.toLowerCase() !== 'z' && event.key.toLowerCase() !== 'y')
1118
+ if (instance.plainComposing)
836
1119
  return;
837
- event.preventDefault();
838
- try {
839
- if (event.key.toLowerCase() === 'y' || event.shiftKey)
840
- instance.redo();
841
- else
842
- instance.undo();
843
- }
844
- catch (error) {
845
- events.error?.(error instanceof Error ? error : new Error(String(error)));
846
- }
1120
+ if (instance.literalKeys(instance.editor.ctx.get(editorViewCtx), event))
1121
+ event.preventDefault();
847
1122
  });
1123
+ for (const event of ['select', 'keyup', 'click'])
1124
+ instance.plain.addEventListener(event, () => instance.publishCommandState());
848
1125
  instance.plain.addEventListener('paste', (event) => {
1126
+ if (!instance.writable) {
1127
+ event.preventDefault();
1128
+ return;
1129
+ }
849
1130
  if (!event.clipboardData)
850
1131
  return;
851
1132
  event.preventDefault();
@@ -858,9 +1139,11 @@ export class InkKitEditor {
858
1139
  });
859
1140
  instance.plain.addEventListener('compositionstart', () => {
860
1141
  instance.plainComposing = true;
1142
+ instance.publishCommandState();
861
1143
  });
862
1144
  instance.plain.addEventListener('compositionend', () => {
863
1145
  instance.plainComposing = false;
1146
+ instance.publishCommandState();
864
1147
  });
865
1148
  const activateFootnote = (event) => {
866
1149
  const element = event.target.closest('[data-inkkit-footnote-reference]');
@@ -936,7 +1219,7 @@ export class InkKitEditor {
936
1219
  event.clipboardData.setData('text/html', metadata != null
937
1220
  ? withReferenceMetadata(output.html, metadata)
938
1221
  : output.html);
939
- if (event.type === 'cut')
1222
+ if (event.type === 'cut' && instance.writable)
940
1223
  view.dispatch(view.state.tr.deleteSelection().scrollIntoView());
941
1224
  }
942
1225
  catch (error) {
@@ -949,7 +1232,8 @@ export class InkKitEditor {
949
1232
  if (events.clipboard) {
950
1233
  const epoch = instance.documentEpoch, revision = instance.revision;
951
1234
  const { from, to } = view.state.selection;
952
- const cut = event.type === 'cut';
1235
+ const cut = event.type === 'cut' && instance.writable;
1236
+ const policyEpoch = instance.policyEpoch;
953
1237
  void instance
954
1238
  .clipboardSnapshot(false)
955
1239
  .then(async (content) => {
@@ -959,8 +1243,9 @@ export class InkKitEditor {
959
1243
  throw new InkKitError('image-unavailable', 'The selected content could not be cut safely');
960
1244
  await events.clipboard(content);
961
1245
  if (cut) {
962
- instance.assertCurrent();
1246
+ instance.assertMutation();
963
1247
  if (epoch !== instance.documentEpoch ||
1248
+ policyEpoch !== instance.policyEpoch ||
964
1249
  revision !== instance.revision)
965
1250
  throw new InkKitError('stale-document', 'Document changed before cutting');
966
1251
  view.dispatch(view.state.tr.deleteRange(from, to).scrollIntoView());
@@ -978,10 +1263,18 @@ export class InkKitEditor {
978
1263
  };
979
1264
  root.addEventListener('copy', instance.copyHandler, true);
980
1265
  root.addEventListener('cut', instance.copyHandler, true);
1266
+ instance.applyInputPolicy();
1267
+ instance.setKeymap(options.keymap ?? {});
981
1268
  return instance;
982
1269
  }
983
1270
  loadDocument(input) {
984
1271
  this.assertAlive();
1272
+ const container = this.viewportContainer ?? this.root;
1273
+ const top = container.scrollTop, left = container.scrollLeft;
1274
+ const focused = this.literalSurface
1275
+ ? this.plain.ownerDocument.activeElement === this.plain
1276
+ : this.editor.ctx.get(editorViewCtx).hasFocus();
1277
+ this.ready = false;
985
1278
  this.pasteController.cancelPending();
986
1279
  this.documentEpoch += 1;
987
1280
  this.outlineEpoch += 1;
@@ -995,11 +1288,24 @@ export class InkKitEditor {
995
1288
  this.formatType = input.format;
996
1289
  this.revision = 0;
997
1290
  this.plain.value = input.text;
1291
+ this.plain.setSelectionRange(this.plain.value.length, this.plain.value.length);
998
1292
  this.load(input.format === 'txt' ? '' : input.text, input.generation);
999
1293
  this.originalSource = input.text;
1000
1294
  this.lastMarkdown = input.text;
1001
- this.ready = true;
1002
1295
  this.updateSurface();
1296
+ this.ready = true;
1297
+ if (focused) {
1298
+ if (this.literalSurface)
1299
+ this.plain.focus({ preventScroll: true });
1300
+ else
1301
+ this.editor.ctx.get(editorViewCtx).focus();
1302
+ }
1303
+ container.scrollTop = top;
1304
+ container.scrollLeft = left;
1305
+ this.events.stateChanged(this.literalSurface
1306
+ ? { marks: [], block: { type: 'paragraph' }, quoted: false }
1307
+ : caretState(this.editor.ctx.get(editorViewCtx).state));
1308
+ this.publishCommandState();
1003
1309
  }
1004
1310
  reloadDocument(input) {
1005
1311
  this.assertAlive();
@@ -1007,26 +1313,350 @@ export class InkKitEditor {
1007
1313
  const wasLiteral = this.literalSurface;
1008
1314
  const previousMode = this.editingMode;
1009
1315
  const previousFormat = this.formatType;
1316
+ const selectedNode = !wasLiteral && view.state.selection instanceof NodeSelection
1317
+ ? view.state.selection.node
1318
+ : undefined;
1010
1319
  const at = wasLiteral
1011
1320
  ? this.plain.selectionStart
1012
- : view.state.selection.from;
1013
- const end = wasLiteral ? this.plain.selectionEnd : at;
1014
- const top = this.root.scrollTop;
1321
+ : view.state.selection.anchor;
1322
+ const end = wasLiteral ? this.plain.selectionEnd : view.state.selection.head;
1323
+ const direction = this.plain.selectionDirection;
1324
+ const container = this.viewportContainer ?? this.root;
1325
+ const top = container.scrollTop, left = container.scrollLeft;
1326
+ const focused = wasLiteral
1327
+ ? document.activeElement === this.plain
1328
+ : view.hasFocus();
1015
1329
  const plainTop = this.plain.scrollTop, plainLeft = this.plain.scrollLeft;
1016
- this.loadDocument(input);
1017
- if (previousFormat === 'md' &&
1018
- input.format === 'md' &&
1019
- previousMode === 'source')
1020
- this.setEditingMode('source', input.generation);
1330
+ this.reportingSuppressed = true;
1331
+ try {
1332
+ this.loadDocument(input);
1333
+ if (previousFormat === 'md' &&
1334
+ input.format === 'md' &&
1335
+ previousMode === 'source')
1336
+ this.setEditingMode('source', input.generation);
1337
+ if (this.literalSurface)
1338
+ this.plain.setSelectionRange(Math.min(at, this.plain.value.length), Math.min(end, this.plain.value.length), direction);
1339
+ else {
1340
+ const anchor = Math.min(at, view.state.doc.content.size);
1341
+ const node = view.state.doc.nodeAt(anchor);
1342
+ view.dispatch(view.state.tr.setSelection(selectedNode &&
1343
+ node?.type === selectedNode.type &&
1344
+ NodeSelection.isSelectable(node)
1345
+ ? NodeSelection.create(view.state.doc, anchor)
1346
+ : TextSelection.between(view.state.doc.resolve(anchor), view.state.doc.resolve(Math.min(end, view.state.doc.content.size)))));
1347
+ }
1348
+ if (focused) {
1349
+ if (this.literalSurface)
1350
+ this.plain.focus({ preventScroll: true });
1351
+ else
1352
+ view.focus();
1353
+ }
1354
+ container.scrollTop = top;
1355
+ container.scrollLeft = left;
1356
+ if (wasLiteral && this.literalSurface) {
1357
+ this.plain.scrollTop = plainTop;
1358
+ this.plain.scrollLeft = plainLeft;
1359
+ }
1360
+ }
1361
+ finally {
1362
+ this.reportingSuppressed = false;
1363
+ this.publishCommandState();
1364
+ }
1365
+ }
1366
+ setViewport(options) {
1367
+ this.assertAlive();
1368
+ const container = options.scrollContainer ?? this.root;
1369
+ if (container !== this.root && !container.contains(this.root))
1370
+ throw new RangeError('The scroll container must be the editor root or an ancestor');
1371
+ const insets = { top: 0, right: 0, bottom: 0, left: 0, ...options.insets };
1372
+ if (Object.values(insets).some((value) => !Number.isFinite(value) || value < 0))
1373
+ throw new RangeError('Viewport insets must be finite non-negative CSS pixel values');
1374
+ this.viewportContainer = container;
1375
+ this.viewportInsets = insets;
1376
+ }
1377
+ viewport() {
1378
+ this.assertAlive();
1379
+ const container = this.viewportContainer ?? this.root;
1380
+ const bounds = container.getBoundingClientRect();
1381
+ const root = this.root.getBoundingClientRect();
1382
+ const insets = this.viewportInsets;
1383
+ const window = this.root.ownerDocument.defaultView;
1384
+ const left = Math.max(bounds.left + container.clientLeft, root.left, 0);
1385
+ const top = Math.max(bounds.top + container.clientTop, root.top, 0);
1386
+ const right = Math.max(left, Math.min(bounds.left + container.clientLeft + container.clientWidth, root.right, window.innerWidth));
1387
+ const bottom = Math.max(top, Math.min(bounds.top + container.clientTop + container.clientHeight, root.bottom, window.innerHeight));
1388
+ let rect = textRect({ left, top, right, bottom });
1389
+ if (this.literalSurface) {
1390
+ const plain = this.plain.getBoundingClientRect();
1391
+ const literalLeft = Math.max(rect.left, plain.left + this.plain.clientLeft);
1392
+ const literalTop = Math.max(rect.top, plain.top + this.plain.clientTop);
1393
+ rect = textRect({
1394
+ left: literalLeft,
1395
+ top: literalTop,
1396
+ right: Math.max(literalLeft, Math.min(rect.right, plain.left + this.plain.clientLeft + this.plain.clientWidth)),
1397
+ bottom: Math.max(literalTop, Math.min(rect.bottom, plain.top + this.plain.clientTop + this.plain.clientHeight)),
1398
+ });
1399
+ }
1400
+ const insetLeft = Math.min(rect.right, rect.left + insets.left);
1401
+ const insetTop = Math.min(rect.bottom, rect.top + insets.top);
1402
+ rect = textRect({
1403
+ left: insetLeft,
1404
+ top: insetTop,
1405
+ right: Math.max(insetLeft, rect.right - insets.right),
1406
+ bottom: Math.max(insetTop, rect.bottom - insets.bottom),
1407
+ });
1408
+ return Object.freeze({
1409
+ rect: Object.freeze(rect),
1410
+ insets: Object.freeze({ ...insets }),
1411
+ scrollTop: container.scrollTop + (this.literalSurface ? this.plain.scrollTop : 0),
1412
+ scrollLeft: container.scrollLeft +
1413
+ (this.literalSurface ? this.plain.scrollLeft : 0),
1414
+ });
1415
+ }
1416
+ readableScope() {
1417
+ const key = `${this.documentEpoch}:${this.revision}:${this.outlineEpoch}:${this.editingMode}`;
1418
+ if (this.textScope?.key !== key)
1419
+ this.textScope = {
1420
+ key,
1421
+ id: `${this.textIdentity}:${key}`,
1422
+ projection: this.literalSurface
1423
+ ? { text: this.plain.value, spans: [] }
1424
+ : readableProjection(this.editor.ctx.get(editorViewCtx).state.doc),
1425
+ };
1426
+ return this.textScope;
1427
+ }
1428
+ textSnapshot(expectedGeneration) {
1429
+ this.assertCurrent(expectedGeneration);
1430
+ const scope = this.readableScope();
1431
+ const selection = this.editor.ctx.get(editorViewCtx).state.selection;
1432
+ return Object.freeze({
1433
+ snapshotId: scope.id,
1434
+ documentId: this.documentId,
1435
+ generation: this.generation,
1436
+ revision: this.revision,
1437
+ format: this.formatType,
1438
+ mode: this.editingMode,
1439
+ text: scope.projection.text,
1440
+ selection: Object.freeze({
1441
+ snapshotId: scope.id,
1442
+ from: this.literalSurface
1443
+ ? this.plain.selectionStart
1444
+ : projectionOffset(scope.projection, selection.from),
1445
+ to: this.literalSurface
1446
+ ? this.plain.selectionEnd
1447
+ : projectionOffset(scope.projection, selection.to),
1448
+ }),
1449
+ });
1450
+ }
1451
+ validateTextRange(range) {
1452
+ this.assertCurrent();
1453
+ const scope = this.readableScope();
1454
+ if (range.snapshotId !== scope.id)
1455
+ throw new InkKitError('stale-document', 'The readable text snapshot changed');
1456
+ validateOffsets(scope.projection.text, range.from, range.to);
1457
+ return scope.projection;
1458
+ }
1459
+ selectTextRange(range, options = {}) {
1460
+ const projection = this.validateTextRange(range);
1021
1461
  if (this.literalSurface)
1022
- this.plain.setSelectionRange(Math.min(at, this.plain.value.length), Math.min(end, this.plain.value.length));
1023
- else
1024
- view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(Math.min(at, view.state.doc.content.size)))));
1025
- this.root.scrollTop = top;
1026
- if (wasLiteral && this.literalSurface) {
1027
- this.plain.scrollTop = plainTop;
1028
- this.plain.scrollLeft = plainLeft;
1462
+ this.plain.setSelectionRange(range.from, range.to);
1463
+ else {
1464
+ for (const span of projection.spans)
1465
+ if (span.kind === 'embed' &&
1466
+ ((range.from > span.from && range.from < span.to) ||
1467
+ (range.to > span.from && range.to < span.to)))
1468
+ throw new InkKitError('invalid-range', 'Embedded labels must be selected as complete ranges');
1469
+ const view = this.editor.ctx.get(editorViewCtx);
1470
+ const from = projectionPosition(projection, range.from, 1);
1471
+ const to = projectionPosition(projection, range.to, range.from === range.to ? 1 : -1);
1472
+ const embed = projection.spans.find((span) => span.kind === 'embed' &&
1473
+ span.from === range.from &&
1474
+ span.to === range.to);
1475
+ view.dispatch(view.state.tr.setSelection(embed && view.state.doc.nodeAt(embed.start)
1476
+ ? NodeSelection.create(view.state.doc, embed.start)
1477
+ : TextSelection.between(view.state.doc.resolve(from), view.state.doc.resolve(to))));
1478
+ }
1479
+ if (options.focus) {
1480
+ if (this.literalSurface)
1481
+ this.plain.focus({ preventScroll: true });
1482
+ else
1483
+ this.editor.ctx.get(editorViewCtx).focus();
1484
+ }
1485
+ if (options.reveal)
1486
+ this.revealTextRange(range);
1487
+ this.publishCommandState();
1488
+ }
1489
+ replaceTextRange(range, text) {
1490
+ const projection = this.validateTextRange(range);
1491
+ this.assertMutation();
1492
+ if (typeof text !== 'string')
1493
+ throw new TypeError('Replacement must be a string');
1494
+ if (this.literalSurface) {
1495
+ const raw = this.currentText();
1496
+ const from = rawOffset(raw, range.from), to = rawOffset(raw, range.to);
1497
+ const ending = /\r\n|\r|\n/.exec(raw)?.[0] ?? '\n';
1498
+ const value = text.replace(/\r\n?/g, '\n');
1499
+ const replaced = raw.slice(0, from) + value.replaceAll('\n', ending) + raw.slice(to);
1500
+ return this.applySource(replaced, true, range.from, range.from + value.length);
1029
1501
  }
1502
+ if (projection.spans.some((span) => (span.kind === 'embed' &&
1503
+ range.from > span.from &&
1504
+ range.from < span.to) ||
1505
+ (span.kind === 'embed' &&
1506
+ range.to > span.from &&
1507
+ range.to < span.to) ||
1508
+ (span.to > range.from &&
1509
+ span.from < range.to &&
1510
+ span.kind !== 'text')))
1511
+ throw new InkKitError('invalid-range', 'Replacement cannot remove embedded content or structural separators');
1512
+ const from = projectionPosition(projection, range.from, 1);
1513
+ const to = projectionPosition(projection, range.to, range.from === range.to ? 1 : -1);
1514
+ return replaceFormattedRange(this.editor.ctx.get(editorViewCtx), from, to, text, (doc) => {
1515
+ this.currentText(doc);
1516
+ });
1517
+ }
1518
+ textRangeRects(range) {
1519
+ const projection = this.validateTextRange(range);
1520
+ const rects = this.literalSurface
1521
+ ? literalRects(this.plain, range.from, range.to)
1522
+ : formattedRects(this.editor.ctx.get(editorViewCtx), projection, range.from, range.to);
1523
+ return Object.freeze(rects.map((rect) => Object.freeze(rect)));
1524
+ }
1525
+ visibleTextRanges(snapshotId) {
1526
+ const snapshot = this.textSnapshot();
1527
+ this.validateTextRange({ snapshotId, from: 0, to: 0 });
1528
+ const viewport = this.viewport().rect;
1529
+ if (this.literalSurface)
1530
+ return Object.freeze(literalVisibleRanges(this.plain, viewport).map((range) => Object.freeze({ snapshotId, ...range })));
1531
+ const visible = (rect) => intersectsViewport(rect, viewport);
1532
+ const ranges = [];
1533
+ const projection = this.readableScope().projection;
1534
+ const view = this.editor.ctx.get(editorViewCtx);
1535
+ for (const span of projection.spans) {
1536
+ const current = { text: projection.text, spans: [span] };
1537
+ if (span.kind === 'separator' ||
1538
+ !formattedRects(view, current, span.from, span.to).some(visible))
1539
+ continue;
1540
+ for (let from = span.from; from < span.to;) {
1541
+ const to = from + (snapshot.text.codePointAt(from) > 0xffff ? 2 : 1);
1542
+ if (formattedRects(view, current, from, to).some(visible)) {
1543
+ const previous = ranges.at(-1);
1544
+ if (previous?.to === from)
1545
+ previous.to = to;
1546
+ else
1547
+ ranges.push({ snapshotId, from, to });
1548
+ }
1549
+ from = to;
1550
+ }
1551
+ }
1552
+ return Object.freeze(ranges.map((range) => Object.freeze(range)));
1553
+ }
1554
+ revealTextRange(range) {
1555
+ const projection = this.validateTextRange(range);
1556
+ if (!this.literalSurface) {
1557
+ const view = this.editor.ctx.get(editorViewCtx);
1558
+ const position = projectionPosition(projection, range.from, 1);
1559
+ const at = view.domAtPos(position).node;
1560
+ let element = at instanceof Element ? at : at.parentElement;
1561
+ while (element && this.root.contains(element)) {
1562
+ if (element.matches('.inkkit-callout[data-inkkit-folded="true"]'))
1563
+ element
1564
+ .querySelector(':scope > .inkkit-callout-header [data-inkkit-callout-toggle]')
1565
+ ?.click();
1566
+ element = element.parentElement;
1567
+ }
1568
+ }
1569
+ this.revealRects(this.textRangeRects(range));
1570
+ }
1571
+ revealRects(rects) {
1572
+ if (!rects.length)
1573
+ return;
1574
+ const viewport = this.viewport().rect;
1575
+ const leading = rects[0];
1576
+ const envelope = {
1577
+ top: leading.top,
1578
+ bottom: leading.bottom,
1579
+ left: leading.left,
1580
+ right: leading.right,
1581
+ };
1582
+ for (const rect of rects) {
1583
+ envelope.top = Math.min(envelope.top, rect.top);
1584
+ envelope.bottom = Math.max(envelope.bottom, rect.bottom);
1585
+ envelope.left = Math.min(envelope.left, rect.left);
1586
+ envelope.right = Math.max(envelope.right, rect.right);
1587
+ }
1588
+ const fitsHeight = envelope.bottom - envelope.top <= viewport.height;
1589
+ const fitsWidth = envelope.right - envelope.left <= viewport.width;
1590
+ const rect = {
1591
+ top: fitsHeight ? envelope.top : leading.top,
1592
+ bottom: fitsHeight
1593
+ ? envelope.bottom
1594
+ : Math.min(leading.bottom, leading.top + viewport.height),
1595
+ left: fitsWidth ? envelope.left : leading.left,
1596
+ right: fitsWidth
1597
+ ? envelope.right
1598
+ : Math.min(leading.right, leading.left + viewport.width),
1599
+ };
1600
+ const vertical = viewport.height <= 0
1601
+ ? 0
1602
+ : rect.top < viewport.top
1603
+ ? rect.top - viewport.top
1604
+ : rect.bottom > viewport.bottom
1605
+ ? rect.bottom - viewport.bottom
1606
+ : 0;
1607
+ const horizontal = viewport.width <= 0
1608
+ ? 0
1609
+ : rect.left < viewport.left
1610
+ ? rect.left - viewport.left
1611
+ : rect.right > viewport.right
1612
+ ? rect.right - viewport.right
1613
+ : 0;
1614
+ let remainingTop = vertical, remainingLeft = horizontal;
1615
+ if (this.literalSurface) {
1616
+ const top = this.plain.scrollTop, left = this.plain.scrollLeft;
1617
+ this.plain.scrollTop += vertical;
1618
+ this.plain.scrollLeft += horizontal;
1619
+ remainingTop -= this.plain.scrollTop - top;
1620
+ remainingLeft -= this.plain.scrollLeft - left;
1621
+ }
1622
+ let container = this.viewportContainer ?? this.root;
1623
+ const document = this.root.ownerDocument;
1624
+ while (container &&
1625
+ container !== document.body &&
1626
+ container !== document.documentElement) {
1627
+ const top = container.scrollTop, left = container.scrollLeft;
1628
+ container.scrollTop += remainingTop;
1629
+ container.scrollLeft += remainingLeft;
1630
+ remainingTop -= container.scrollTop - top;
1631
+ remainingLeft -= container.scrollLeft - left;
1632
+ container = container.parentElement;
1633
+ }
1634
+ if (remainingTop || remainingLeft)
1635
+ document.defaultView.scrollBy(remainingLeft, remainingTop);
1636
+ }
1637
+ revealCurrentSelection() {
1638
+ if (this.loading || !this.ready)
1639
+ return;
1640
+ if (this.literalSurface) {
1641
+ this.revealLiteralSelection();
1642
+ return;
1643
+ }
1644
+ const view = this.editor.ctx.get(editorViewCtx);
1645
+ const projection = this.readableScope().projection;
1646
+ const from = projectionOffset(projection, view.state.selection.from);
1647
+ const to = projectionOffset(projection, view.state.selection.to);
1648
+ if (this.composing || this.pasteController.pending) {
1649
+ this.revealRects(formattedRects(view, projection, from, to));
1650
+ return;
1651
+ }
1652
+ this.revealTextRange({
1653
+ snapshotId: this.readableScope().id,
1654
+ from,
1655
+ to,
1656
+ });
1657
+ }
1658
+ revealLiteralSelection() {
1659
+ this.revealRects(literalRects(this.plain, this.plain.selectionStart, this.plain.selectionEnd));
1030
1660
  }
1031
1661
  load(markdown, generation) {
1032
1662
  this.generation = generation;
@@ -1047,8 +1677,7 @@ export class InkKitEditor {
1047
1677
  const { doc } = view.state;
1048
1678
  const end = Selection.findFrom(doc.resolve(doc.content.size), -1, true) ??
1049
1679
  Selection.atEnd(doc);
1050
- view.dispatch(setSearchState(view.state.tr.setSelection(end).scrollIntoView(), new SearchQuery({ search: '' })));
1051
- this.events.stateChanged(caretState(view.state));
1680
+ view.dispatch(setSearchState(view.state.tr.setSelection(end), new SearchQuery({ search: '' })));
1052
1681
  }
1053
1682
  find(text, expectedGeneration) {
1054
1683
  this.assertCurrent(expectedGeneration);
@@ -1078,17 +1707,18 @@ export class InkKitEditor {
1078
1707
  else if (this.plainSearch)
1079
1708
  this.plain.setSelectionRange(this.plain.selectionEnd, this.plain.selectionEnd);
1080
1709
  this.plainSearch = text;
1081
- revealSourceCaret(this.plain);
1710
+ this.revealLiteralSelection();
1082
1711
  return;
1083
1712
  }
1084
- findFormatted(this.editor.ctx.get(editorViewCtx), text);
1713
+ if (findFormatted(this.editor.ctx.get(editorViewCtx), text))
1714
+ this.revealCurrentSelection();
1085
1715
  }
1086
1716
  replace(search, replacement, expectedGeneration) {
1087
- this.assertCurrent(expectedGeneration);
1717
+ this.assertMutation(expectedGeneration);
1088
1718
  return this.replaceMatches(search, replacement, false) > 0;
1089
1719
  }
1090
1720
  replaceAll(search, replacement, expectedGeneration) {
1091
- this.assertCurrent(expectedGeneration);
1721
+ this.assertMutation(expectedGeneration);
1092
1722
  return this.replaceMatches(search, replacement, true);
1093
1723
  }
1094
1724
  replaceMatches(search, replacement, all) {
@@ -1146,7 +1776,7 @@ export class InkKitEditor {
1146
1776
  const position = sourceHeadingPosition(this.editor.ctx, this.plain.value, heading, this.outlineContext());
1147
1777
  this.plain.setSelectionRange(position, position);
1148
1778
  this.plain.focus({ preventScroll: true });
1149
- revealSourceCaret(this.plain);
1779
+ this.revealLiteralSelection();
1150
1780
  return true;
1151
1781
  }
1152
1782
  return navigateFormattedHeading(this.editor.ctx.get(editorViewCtx), heading, this.outlineContext());
@@ -1159,7 +1789,7 @@ export class InkKitEditor {
1159
1789
  : this.editor.ctx.get(editorViewCtx).composing) &&
1160
1790
  generation === this.generation)
1161
1791
  return false;
1162
- this.assertCurrent(generation);
1792
+ this.assertMutation(generation);
1163
1793
  if (this.literalSurface) {
1164
1794
  this.replaceLiteralSelection(text, false);
1165
1795
  return true;
@@ -1183,11 +1813,38 @@ export class InkKitEditor {
1183
1813
  generation === this.generation)
1184
1814
  return false;
1185
1815
  this.assertCurrent(generation);
1816
+ const configuredEvent = new KeyboardEvent('keydown', {
1817
+ key,
1818
+ code,
1819
+ metaKey,
1820
+ ctrlKey,
1821
+ altKey,
1822
+ shiftKey,
1823
+ bubbles: true,
1824
+ cancelable: true,
1825
+ });
1826
+ if (!this.literalSurface && altKey && key === 'Enter')
1827
+ return this.navigateFootnote(shiftKey ? 'reference' : 'definition');
1828
+ if (!this.writable &&
1829
+ !this.literalSurface &&
1830
+ key === 'Tab' &&
1831
+ !metaKey &&
1832
+ !ctrlKey &&
1833
+ !altKey) {
1834
+ const view = this.editor.ctx.get(editorViewCtx);
1835
+ return goToNextCell(shiftKey ? -1 : 1)(view.state, view.dispatch);
1836
+ }
1837
+ if (!this.writable) {
1838
+ if (['Enter', 'Tab', 'Backspace', 'Delete'].includes(key))
1839
+ this.assertMutation(generation);
1840
+ this.readOnlyKeys(this.editor.ctx.get(editorViewCtx), configuredEvent);
1841
+ }
1186
1842
  if (this.literalSurface) {
1187
- if ((metaKey || ctrlKey) && key.toLowerCase() === 'z')
1188
- return shiftKey ? this.redo(generation) : this.undo(generation);
1189
- if ((metaKey || ctrlKey) && key.toLowerCase() === 'y')
1190
- return this.redo(generation);
1843
+ if (this.literalKeys(this.editor.ctx.get(editorViewCtx), configuredEvent))
1844
+ return true;
1845
+ if (!this.writable &&
1846
+ ['Enter', 'Tab', 'Backspace', 'Delete'].includes(key))
1847
+ throw new InkKitError('read-only', 'The editor is read-only');
1191
1848
  if (metaKey || ctrlKey || altKey)
1192
1849
  return false;
1193
1850
  if (key === 'Enter' || key === 'Tab') {
@@ -1237,21 +1894,58 @@ export class InkKitEditor {
1237
1894
  * order, so a key given to two shortcuts lands the same way every time. */
1238
1895
  setKeymap(keymap) {
1239
1896
  this.assertAlive();
1897
+ this.configuredKeys = {
1898
+ undo: ['Mod-z'],
1899
+ redo: ['Mod-y', 'Shift-Mod-z'],
1900
+ tableExit: ['Mod-Enter', 'Enter'],
1901
+ ...keymap,
1902
+ };
1240
1903
  const bindings = {};
1904
+ // Consuming unbound history keys prevents the browser's separate undo stack
1905
+ // from changing source/TXT outside the shared document history.
1906
+ const historyBindings = Object.fromEntries(['Mod-z', 'Mod-y', 'Shift-Mod-z'].map((key) => [key, () => true]));
1907
+ for (const name of ['undo', 'redo']) {
1908
+ for (const key of this.configuredKeys[name] ?? []) {
1909
+ historyBindings[key] = () => {
1910
+ if (!this.ready || !this.commandState().commands[name])
1911
+ return true;
1912
+ return this[name]();
1913
+ };
1914
+ }
1915
+ }
1916
+ Object.assign(bindings, historyBindings);
1241
1917
  for (const [name, command] of Object.entries(shortcutCommands)) {
1242
1918
  for (const key of keymap[name] ?? []) {
1243
1919
  bindings[key] = () => {
1920
+ if (!this.ready || !this.commandState().commands.format[command[0]])
1921
+ return true;
1244
1922
  this.format(...command);
1245
1923
  return true;
1246
1924
  };
1247
1925
  }
1248
1926
  }
1927
+ for (const command of tableCommands) {
1928
+ const name = 'table' + command[0].toUpperCase() + command.slice(1);
1929
+ for (const key of this.configuredKeys[name] ?? [])
1930
+ bindings[key] = () => {
1931
+ if (!this.ready || !this.commandState().commands.table[command])
1932
+ return false;
1933
+ return this.table(command);
1934
+ };
1935
+ }
1936
+ this.readOnlyKeys = keydownHandler(Object.fromEntries(Object.keys(bindings).map((key) => [
1937
+ key,
1938
+ () => {
1939
+ throw new InkKitError('read-only', 'The editor is read-only');
1940
+ },
1941
+ ])));
1249
1942
  this.keys = keydownHandler(bindings);
1943
+ this.literalKeys = keydownHandler(historyBindings);
1250
1944
  }
1251
1945
  /** Dropped files land as one paragraph per path at the drop point: in place of an empty block, after
1252
1946
  * the top-level block otherwise, so a list or quote is not opened up by them. */
1253
1947
  insertPaths(paths, x, y) {
1254
- this.assertCurrent();
1948
+ this.assertMutation();
1255
1949
  if (paths.length === 0)
1256
1950
  return;
1257
1951
  if (this.literalSurface) {
@@ -1262,7 +1956,7 @@ export class InkKitEditor {
1262
1956
  this.insertBlocks(paths.map((path) => schema.nodes.paragraph.create(null, schema.text(path))), x, y);
1263
1957
  }
1264
1958
  insertImages(images, x, y) {
1265
- this.assertCurrent();
1959
+ this.assertMutation();
1266
1960
  if (!images.length)
1267
1961
  return;
1268
1962
  if (!this.options.images || this.literalSurface)
@@ -1420,7 +2114,7 @@ export class InkKitEditor {
1420
2114
  view.dispatch(view.state.tr.lift(range, target));
1421
2115
  }
1422
2116
  format(command, arg) {
1423
- this.assertCurrent();
2117
+ this.assertMutation();
1424
2118
  if (this.literalSurface)
1425
2119
  return;
1426
2120
  const run = (cmd, payload) => this.editor.action(callCommand(cmd, payload));
@@ -1494,7 +2188,7 @@ export class InkKitEditor {
1494
2188
  this.focus();
1495
2189
  }
1496
2190
  editReferenceDefinition(label, destination, title) {
1497
- this.assertCurrent();
2191
+ this.assertMutation();
1498
2192
  if (this.literalSurface)
1499
2193
  return false;
1500
2194
  const view = this.editor.ctx.get(editorViewCtx);
@@ -1511,7 +2205,7 @@ export class InkKitEditor {
1511
2205
  return true;
1512
2206
  }
1513
2207
  insertFootnote(label) {
1514
- this.assertCurrent();
2208
+ this.assertMutation();
1515
2209
  if (this.literalSurface)
1516
2210
  return;
1517
2211
  const view = this.editor.ctx.get(editorViewCtx);