@aicayzer/inkkit 0.0.7 → 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,25 +1,27 @@
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
20
  import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection, revealSourceCaret, 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';
24
26
  import { highlightPlugin } from './highlight.js';
25
27
  import { highlightKeymap, toggleHighlightCommand } from './inline-highlight.js';
@@ -36,6 +38,9 @@ const shortcutCommands = {
36
38
  heading1: ['heading', 1],
37
39
  heading2: ['heading', 2],
38
40
  heading3: ['heading', 3],
41
+ heading4: ['heading', 4],
42
+ heading5: ['heading', 5],
43
+ heading6: ['heading', 6],
39
44
  paragraph: ['paragraph'],
40
45
  bold: ['bold'],
41
46
  italic: ['italic'],
@@ -257,12 +262,20 @@ export class InkKitEditor {
257
262
  pasteController;
258
263
  plain;
259
264
  root;
265
+ ownsRootClass = false;
260
266
  clickHandler;
261
267
  footnoteKeyHandler;
262
268
  copyHandler;
263
269
  originalSource = '';
264
270
  generation = 0;
271
+ reportingSuppressed = false;
265
272
  loading = false;
273
+ writable = true;
274
+ inputPreferences = {};
275
+ policyEpoch = 0;
276
+ literalKeys = () => false;
277
+ configuredKeys = {};
278
+ readOnlyKeys = () => false;
266
279
  changePlugin = $prose(() => new Plugin({
267
280
  key: new PluginKey('documentChanges'),
268
281
  view: () => ({
@@ -301,6 +314,8 @@ export class InkKitEditor {
301
314
  constructor(events, options) {
302
315
  this.events = events;
303
316
  this.options = options;
317
+ this.writable = options.editable ?? true;
318
+ this.inputPreferences = { ...options.textInput };
304
319
  }
305
320
  context() {
306
321
  return {
@@ -326,6 +341,212 @@ export class InkKitEditor {
326
341
  if (this.pasteController.pending)
327
342
  throw new InkKitError('operation-pending', 'An image import is in progress');
328
343
  }
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
+ }));
329
550
  get literalSurface() {
330
551
  return this.formatType === 'txt' || this.mode === 'source';
331
552
  }
@@ -362,23 +583,37 @@ export class InkKitEditor {
362
583
  this.plain.value = text;
363
584
  this.plain.setSelectionRange(this.plain.value.length, this.plain.value.length);
364
585
  }
365
- this.updateSurface();
586
+ const suppressed = this.reportingSuppressed;
587
+ this.reportingSuppressed = true;
588
+ try {
589
+ this.updateSurface();
590
+ }
591
+ finally {
592
+ this.reportingSuppressed = suppressed;
593
+ }
366
594
  this.events.stateChanged(this.literalSurface
367
595
  ? { marks: [], block: { type: 'paragraph' }, quoted: false }
368
596
  : caretState(this.editor.ctx.get(editorViewCtx).state));
597
+ this.publishCommandState();
369
598
  return true;
370
599
  }
371
600
  updateSurface() {
372
601
  this.plain.hidden = !this.literalSurface;
373
- this.plain.setAttribute('aria-label', this.formatType === 'txt' ? 'Plain text editor' : 'Markdown source');
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));
374
607
  this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
375
608
  this.literalSurface;
376
609
  }
377
610
  replaceSource(text, expectedGeneration) {
378
- this.assertCurrent(expectedGeneration);
611
+ this.assertMutation(expectedGeneration);
379
612
  return this.applySource(text, true);
380
613
  }
381
614
  applySource(text, isolated, from, to) {
615
+ if (!this.writable)
616
+ throw new InkKitError('read-only', 'The editor is read-only');
382
617
  if (typeof text !== 'string')
383
618
  throw new TypeError('Source must be a string');
384
619
  if (text === this.currentText())
@@ -433,12 +668,12 @@ export class InkKitEditor {
433
668
  return true;
434
669
  }
435
670
  undo(expectedGeneration) {
436
- this.assertCurrent(expectedGeneration);
671
+ this.assertMutation(expectedGeneration);
437
672
  const view = this.editor.ctx.get(editorViewCtx);
438
673
  return undoHistory(view.state, view.dispatch);
439
674
  }
440
675
  redo(expectedGeneration) {
441
- this.assertCurrent(expectedGeneration);
676
+ this.assertMutation(expectedGeneration);
442
677
  const view = this.editor.ctx.get(editorViewCtx);
443
678
  return redoHistory(view.state, view.dispatch);
444
679
  }
@@ -583,7 +818,7 @@ export class InkKitEditor {
583
818
  return result;
584
819
  }
585
820
  async paste(input) {
586
- this.assertCurrent();
821
+ this.assertMutation();
587
822
  if (this.literalSurface || input.plainText) {
588
823
  this.pasteAsPlainText(this.mode === 'source' && this.formatType === 'md'
589
824
  ? (input.markdown ?? input.text)
@@ -597,7 +832,7 @@ export class InkKitEditor {
597
832
  setCommentVisibility(this.editor.ctx.get(editorViewCtx), visible);
598
833
  }
599
834
  pasteAsPlainText(text) {
600
- this.assertCurrent();
835
+ this.assertMutation();
601
836
  if (this.literalSurface) {
602
837
  this.replaceLiteralSelection(text, true, this.formatType === 'md');
603
838
  }
@@ -636,7 +871,7 @@ export class InkKitEditor {
636
871
  }
637
872
  }
638
873
  table(command, options) {
639
- this.assertCurrent();
874
+ this.assertMutation();
640
875
  if (this.literalSurface)
641
876
  return false;
642
877
  return tableCommand(this.editor.ctx, command, options);
@@ -657,6 +892,8 @@ export class InkKitEditor {
657
892
  }
658
893
  this.plain.remove();
659
894
  await this.editor.destroy();
895
+ if (this.ownsRootClass)
896
+ this.root.classList.remove('inkkit-root');
660
897
  }
661
898
  // The app's bindings, replaced whole whenever they change; the plugin stays.
662
899
  keymapPlugin = $prose(() => new Plugin({
@@ -683,7 +920,8 @@ export class InkKitEditor {
683
920
  if (node.type.name === 'image' || isMermaid(node))
684
921
  portable = true;
685
922
  });
686
- const cut = event.type === 'cut';
923
+ const cut = event.type === 'cut' && this.writable;
924
+ const policyEpoch = this.policyEpoch;
687
925
  const epoch = this.documentEpoch;
688
926
  if (portable && this.events.clipboard) {
689
927
  void this.clipboardSnapshot(false)
@@ -694,8 +932,9 @@ export class InkKitEditor {
694
932
  throw new InkKitError('image-unavailable', 'The selected content could not be cut safely');
695
933
  await this.events.clipboard(content);
696
934
  if (cut) {
697
- this.assertCurrent(snapshot.generation);
935
+ this.assertMutation(snapshot.generation);
698
936
  if (epoch !== this.documentEpoch ||
937
+ policyEpoch !== this.policyEpoch ||
699
938
  snapshot.revision !== this.revision)
700
939
  throw new InkKitError('stale-document', 'Document changed before cutting');
701
940
  this.plain.setSelectionRange(from, to);
@@ -725,17 +964,24 @@ export class InkKitEditor {
725
964
  static async mount(root, events, options = {}) {
726
965
  const instance = new InkKitEditor(events, options);
727
966
  instance.root = root;
967
+ instance.ownsRootClass = !root.classList.contains('inkkit-root');
968
+ root.classList.add('inkkit-root');
728
969
  instance.pasteController = new PasteController({
729
970
  ctx: () => instance.editor.ctx,
730
971
  context: () => instance.context(),
731
972
  adapter: options.images,
732
973
  onError: (error) => events.error?.(error instanceof Error ? error : new Error(String(error))),
733
974
  literalText: () => instance.literalSurface,
734
- editable: () => !instance.literalSurface,
975
+ editable: () => instance.writable && !instance.literalSurface,
976
+ onPendingChanged: () => instance.publishCommandState(),
735
977
  });
736
978
  instance.editor = await Editor.make()
737
979
  .config((ctx) => {
738
980
  ctx.set(rootCtx, root);
981
+ ctx.update(labelsCtx.key, () => ({
982
+ ...defaultLabels,
983
+ ...options.labels,
984
+ }));
739
985
  ctx.set(defaultValueCtx, '');
740
986
  ctx.set(remarkStringifyOptionsCtx, stringifyOptions);
741
987
  // The preset also binds Mod-[ and Mod-] here; the app uses those for back and forward.
@@ -754,6 +1000,8 @@ export class InkKitEditor {
754
1000
  }
755
1001
  return cleared;
756
1002
  });
1003
+ unbind(tableKeymap, ['NextCell', 'PrevCell']);
1004
+ unbind(historyKeymap);
757
1005
  unbind(strongKeymap);
758
1006
  unbind(emphasisKeymap);
759
1007
  unbind(inlineCodeKeymap);
@@ -772,7 +1020,8 @@ export class InkKitEditor {
772
1020
  scrollMargin: { top: 8, right: 0, bottom: 24, left: 0 },
773
1021
  }));
774
1022
  })
775
- .use(caretStatePlugin(events, () => !instance.literalSurface))
1023
+ .use(labelsCtx)
1024
+ .use(caretStatePlugin(events, () => !instance.loading && !instance.literalSurface))
776
1025
  .use(instance.pasteController.plugin)
777
1026
  .use(visibleClipboard)
778
1027
  .use(instance.keymapPlugin)
@@ -793,12 +1042,13 @@ export class InkKitEditor {
793
1042
  .use(mermaidPreview((error) => events.error?.(error)))
794
1043
  .use(selectionPlugin)
795
1044
  .use($prose(() => search()))
1045
+ .use(instance.policyPlugin)
796
1046
  .use(options.images ? imageView(options.images) : [])
797
1047
  .create();
798
1048
  configureTableMovement(instance.editor.ctx, () => {
799
1049
  try {
800
1050
  instance.assertCurrent();
801
- return !instance.literalSurface;
1051
+ return instance.writable && !instance.literalSurface;
802
1052
  }
803
1053
  catch {
804
1054
  return false;
@@ -809,7 +1059,11 @@ export class InkKitEditor {
809
1059
  instance.plain.setAttribute('aria-label', 'Plain text editor');
810
1060
  instance.plain.hidden = true;
811
1061
  root.append(instance.plain);
812
- instance.plain.addEventListener('beforeinput', () => {
1062
+ instance.plain.addEventListener('beforeinput', (event) => {
1063
+ if (!instance.writable) {
1064
+ event.preventDefault();
1065
+ return;
1066
+ }
813
1067
  if (instance.ready && instance.literalSurface)
814
1068
  instance.beforePlainInput = {
815
1069
  from: instance.plain.selectionStart,
@@ -819,6 +1073,10 @@ export class InkKitEditor {
819
1073
  instance.plain.addEventListener('input', () => {
820
1074
  if (!instance.ready || !instance.literalSurface)
821
1075
  return;
1076
+ if (!instance.writable) {
1077
+ instance.plain.value = instance.currentText();
1078
+ return;
1079
+ }
822
1080
  try {
823
1081
  instance.applySource(editedPlainSource(instance.currentText(), instance.plain.value), false, instance.plain.selectionStart, instance.plain.selectionEnd);
824
1082
  }
@@ -830,22 +1088,18 @@ export class InkKitEditor {
830
1088
  }
831
1089
  });
832
1090
  instance.plain.addEventListener('keydown', (event) => {
833
- if (instance.plainComposing || !(event.metaKey || event.ctrlKey))
1091
+ if (instance.plainComposing)
834
1092
  return;
835
- if (event.key.toLowerCase() !== 'z' && event.key.toLowerCase() !== 'y')
836
- 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
- }
1093
+ if (instance.literalKeys(instance.editor.ctx.get(editorViewCtx), event))
1094
+ event.preventDefault();
847
1095
  });
1096
+ for (const event of ['select', 'keyup', 'click'])
1097
+ instance.plain.addEventListener(event, () => instance.publishCommandState());
848
1098
  instance.plain.addEventListener('paste', (event) => {
1099
+ if (!instance.writable) {
1100
+ event.preventDefault();
1101
+ return;
1102
+ }
849
1103
  if (!event.clipboardData)
850
1104
  return;
851
1105
  event.preventDefault();
@@ -858,9 +1112,11 @@ export class InkKitEditor {
858
1112
  });
859
1113
  instance.plain.addEventListener('compositionstart', () => {
860
1114
  instance.plainComposing = true;
1115
+ instance.publishCommandState();
861
1116
  });
862
1117
  instance.plain.addEventListener('compositionend', () => {
863
1118
  instance.plainComposing = false;
1119
+ instance.publishCommandState();
864
1120
  });
865
1121
  const activateFootnote = (event) => {
866
1122
  const element = event.target.closest('[data-inkkit-footnote-reference]');
@@ -936,7 +1192,7 @@ export class InkKitEditor {
936
1192
  event.clipboardData.setData('text/html', metadata != null
937
1193
  ? withReferenceMetadata(output.html, metadata)
938
1194
  : output.html);
939
- if (event.type === 'cut')
1195
+ if (event.type === 'cut' && instance.writable)
940
1196
  view.dispatch(view.state.tr.deleteSelection().scrollIntoView());
941
1197
  }
942
1198
  catch (error) {
@@ -949,7 +1205,8 @@ export class InkKitEditor {
949
1205
  if (events.clipboard) {
950
1206
  const epoch = instance.documentEpoch, revision = instance.revision;
951
1207
  const { from, to } = view.state.selection;
952
- const cut = event.type === 'cut';
1208
+ const cut = event.type === 'cut' && instance.writable;
1209
+ const policyEpoch = instance.policyEpoch;
953
1210
  void instance
954
1211
  .clipboardSnapshot(false)
955
1212
  .then(async (content) => {
@@ -959,8 +1216,9 @@ export class InkKitEditor {
959
1216
  throw new InkKitError('image-unavailable', 'The selected content could not be cut safely');
960
1217
  await events.clipboard(content);
961
1218
  if (cut) {
962
- instance.assertCurrent();
1219
+ instance.assertMutation();
963
1220
  if (epoch !== instance.documentEpoch ||
1221
+ policyEpoch !== instance.policyEpoch ||
964
1222
  revision !== instance.revision)
965
1223
  throw new InkKitError('stale-document', 'Document changed before cutting');
966
1224
  view.dispatch(view.state.tr.deleteRange(from, to).scrollIntoView());
@@ -978,10 +1236,13 @@ export class InkKitEditor {
978
1236
  };
979
1237
  root.addEventListener('copy', instance.copyHandler, true);
980
1238
  root.addEventListener('cut', instance.copyHandler, true);
1239
+ instance.applyInputPolicy();
1240
+ instance.setKeymap(options.keymap ?? {});
981
1241
  return instance;
982
1242
  }
983
1243
  loadDocument(input) {
984
1244
  this.assertAlive();
1245
+ this.ready = false;
985
1246
  this.pasteController.cancelPending();
986
1247
  this.documentEpoch += 1;
987
1248
  this.outlineEpoch += 1;
@@ -998,8 +1259,12 @@ export class InkKitEditor {
998
1259
  this.load(input.format === 'txt' ? '' : input.text, input.generation);
999
1260
  this.originalSource = input.text;
1000
1261
  this.lastMarkdown = input.text;
1001
- this.ready = true;
1002
1262
  this.updateSurface();
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();
1003
1268
  }
1004
1269
  reloadDocument(input) {
1005
1270
  this.assertAlive();
@@ -1013,19 +1278,26 @@ export class InkKitEditor {
1013
1278
  const end = wasLiteral ? this.plain.selectionEnd : at;
1014
1279
  const top = this.root.scrollTop;
1015
1280
  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);
1021
- 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;
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();
1029
1301
  }
1030
1302
  }
1031
1303
  load(markdown, generation) {
@@ -1048,7 +1320,6 @@ export class InkKitEditor {
1048
1320
  const end = Selection.findFrom(doc.resolve(doc.content.size), -1, true) ??
1049
1321
  Selection.atEnd(doc);
1050
1322
  view.dispatch(setSearchState(view.state.tr.setSelection(end).scrollIntoView(), new SearchQuery({ search: '' })));
1051
- this.events.stateChanged(caretState(view.state));
1052
1323
  }
1053
1324
  find(text, expectedGeneration) {
1054
1325
  this.assertCurrent(expectedGeneration);
@@ -1084,11 +1355,11 @@ export class InkKitEditor {
1084
1355
  findFormatted(this.editor.ctx.get(editorViewCtx), text);
1085
1356
  }
1086
1357
  replace(search, replacement, expectedGeneration) {
1087
- this.assertCurrent(expectedGeneration);
1358
+ this.assertMutation(expectedGeneration);
1088
1359
  return this.replaceMatches(search, replacement, false) > 0;
1089
1360
  }
1090
1361
  replaceAll(search, replacement, expectedGeneration) {
1091
- this.assertCurrent(expectedGeneration);
1362
+ this.assertMutation(expectedGeneration);
1092
1363
  return this.replaceMatches(search, replacement, true);
1093
1364
  }
1094
1365
  replaceMatches(search, replacement, all) {
@@ -1159,7 +1430,7 @@ export class InkKitEditor {
1159
1430
  : this.editor.ctx.get(editorViewCtx).composing) &&
1160
1431
  generation === this.generation)
1161
1432
  return false;
1162
- this.assertCurrent(generation);
1433
+ this.assertMutation(generation);
1163
1434
  if (this.literalSurface) {
1164
1435
  this.replaceLiteralSelection(text, false);
1165
1436
  return true;
@@ -1183,11 +1454,38 @@ export class InkKitEditor {
1183
1454
  generation === this.generation)
1184
1455
  return false;
1185
1456
  this.assertCurrent(generation);
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
+ }
1186
1483
  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);
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');
1191
1489
  if (metaKey || ctrlKey || altKey)
1192
1490
  return false;
1193
1491
  if (key === 'Enter' || key === 'Tab') {
@@ -1237,21 +1535,58 @@ export class InkKitEditor {
1237
1535
  * order, so a key given to two shortcuts lands the same way every time. */
1238
1536
  setKeymap(keymap) {
1239
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
+ };
1240
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);
1241
1558
  for (const [name, command] of Object.entries(shortcutCommands)) {
1242
1559
  for (const key of keymap[name] ?? []) {
1243
1560
  bindings[key] = () => {
1561
+ if (!this.ready || !this.commandState().commands.format[command[0]])
1562
+ return true;
1244
1563
  this.format(...command);
1245
1564
  return true;
1246
1565
  };
1247
1566
  }
1248
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
+ ])));
1249
1583
  this.keys = keydownHandler(bindings);
1584
+ this.literalKeys = keydownHandler(historyBindings);
1250
1585
  }
1251
1586
  /** Dropped files land as one paragraph per path at the drop point: in place of an empty block, after
1252
1587
  * the top-level block otherwise, so a list or quote is not opened up by them. */
1253
1588
  insertPaths(paths, x, y) {
1254
- this.assertCurrent();
1589
+ this.assertMutation();
1255
1590
  if (paths.length === 0)
1256
1591
  return;
1257
1592
  if (this.literalSurface) {
@@ -1262,7 +1597,7 @@ export class InkKitEditor {
1262
1597
  this.insertBlocks(paths.map((path) => schema.nodes.paragraph.create(null, schema.text(path))), x, y);
1263
1598
  }
1264
1599
  insertImages(images, x, y) {
1265
- this.assertCurrent();
1600
+ this.assertMutation();
1266
1601
  if (!images.length)
1267
1602
  return;
1268
1603
  if (!this.options.images || this.literalSurface)
@@ -1420,7 +1755,7 @@ export class InkKitEditor {
1420
1755
  view.dispatch(view.state.tr.lift(range, target));
1421
1756
  }
1422
1757
  format(command, arg) {
1423
- this.assertCurrent();
1758
+ this.assertMutation();
1424
1759
  if (this.literalSurface)
1425
1760
  return;
1426
1761
  const run = (cmd, payload) => this.editor.action(callCommand(cmd, payload));
@@ -1494,7 +1829,7 @@ export class InkKitEditor {
1494
1829
  this.focus();
1495
1830
  }
1496
1831
  editReferenceDefinition(label, destination, title) {
1497
- this.assertCurrent();
1832
+ this.assertMutation();
1498
1833
  if (this.literalSurface)
1499
1834
  return false;
1500
1835
  const view = this.editor.ctx.get(editorViewCtx);
@@ -1511,7 +1846,7 @@ export class InkKitEditor {
1511
1846
  return true;
1512
1847
  }
1513
1848
  insertFootnote(label) {
1514
- this.assertCurrent();
1849
+ this.assertMutation();
1515
1850
  if (this.literalSurface)
1516
1851
  return;
1517
1852
  const view = this.editor.ctx.get(editorViewCtx);