@aicayzer/inkkit 0.0.16 → 0.0.18

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.d.ts CHANGED
@@ -100,6 +100,12 @@ export declare class InkKitEditor {
100
100
  private plainSearch;
101
101
  private literalMatch?;
102
102
  private beforePlainInput?;
103
+ private pendingSource?;
104
+ private typedSteps;
105
+ private undoneSteps;
106
+ private formattedUndone;
107
+ private lastSourceInput;
108
+ private syncingSource;
103
109
  private mode;
104
110
  private sourcePreservations;
105
111
  private sourceTexts;
@@ -154,11 +160,33 @@ export declare class InkKitEditor {
154
160
  private updateSurface;
155
161
  replaceSource(text: string, expectedGeneration?: number): boolean;
156
162
  private applySource;
163
+ /**
164
+ * Takes typed source without parsing it. Parsing, preservation and replacing
165
+ * the formatted document cost as much as a document load, so they wait for
166
+ * the next thing that needs them.
167
+ */
168
+ private deferSource;
169
+ private rememberTypedStep;
170
+ private showTypedSource;
171
+ /**
172
+ * Undoes or redoes typed source that the formatted document has not taken
173
+ * in, on the text alone. Returns whether there was a step to take.
174
+ */
175
+ private stepTypedSource;
176
+ /** Brings the formatted document up to the source that was typed. */
177
+ private settleSource;
178
+ private commitSource;
157
179
  undo(expectedGeneration?: number): boolean;
158
180
  redo(expectedGeneration?: number): boolean;
159
181
  private replaceLiteralSelection;
160
182
  snapshot(expectedGeneration?: number): DocumentSnapshot;
161
183
  printableSnapshot(expectedGeneration?: number): Promise<PrintableDocument>;
184
+ /**
185
+ * The document to export. Source typed in the source editor has not reached
186
+ * the formatted document, which would merge its undo steps, so a document
187
+ * read from the text stands in without touching history.
188
+ */
189
+ private exportDocument;
162
190
  clipboardSnapshot(all?: boolean): Promise<ClipboardOutput>;
163
191
  paste(input: ClipboardInput): Promise<void>;
164
192
  setCommentsVisible(visible: boolean): void;
@@ -180,7 +208,9 @@ export declare class InkKitEditor {
180
208
  reveal?: boolean;
181
209
  }): void;
182
210
  /** Throws when a document reached by an edit, after every appended normalisation, cannot be
183
- * saved as preserved Markdown. Dispatch and the replacement commands share this check. */
211
+ * saved as preserved Markdown. Dispatch and the replacement commands share this check.
212
+ * `written` is false for history steps and source replacement, which restore or supply a
213
+ * document that may hold comment text the formatted editor would refuse to accept typed. */
184
214
  private assertPreserved;
185
215
  replaceTextRange(range: TextRange, text: string): boolean;
186
216
  textRangeRects(range: TextRange): readonly TextRect[];
@@ -214,6 +244,7 @@ export declare class InkKitEditor {
214
244
  alt: string;
215
245
  title?: string;
216
246
  }[], x?: number, y?: number): void;
247
+ private insertInline;
217
248
  private insertBlocks;
218
249
  private withMarkRunSelected;
219
250
  private toggleInlineCode;
package/dist/editor.js CHANGED
@@ -3,7 +3,8 @@ import { visibleClipboard, portableClipboard, clipboardText, clipboardContent, }
3
3
  import { clipboard } from '@milkdown/kit/plugin/clipboard';
4
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
- import { cursor } from '@milkdown/kit/plugin/cursor';
6
+ import { cursor } from './cursor.js';
7
+ import { blockDropRange } from './drop-placement.js';
7
8
  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
9
  import { strikethroughKeymap, tableKeymap, toggleStrikethroughCommand, } from '@milkdown/kit/preset/gfm';
9
10
  import { NodeRange, Fragment, Slice, } from '@milkdown/kit/prose/model';
@@ -18,10 +19,10 @@ import { labelsCtx, defaultLabels } from './labels.js';
18
19
  import { createDialect, stringifyOptions } from './dialect.js';
19
20
  import { Preservation } from './preserve.js';
20
21
  import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection, rawOffset, normalizedOffset, } from './source.js';
21
- import { normaliseLabel, referenceDefinitions, footnoteDefinitions, } from './references.js';
22
+ import { normaliseLabel, referenceDefinitions, footnoteDefinitions, singleLine, } from './references.js';
22
23
  import { selectionMarkdown, selectionContent, referenceMetadata, withReferenceMetadata, } from './reference-clipboard.js';
23
24
  import { imageView } from './images.js';
24
- import { isImage, isImageBlock } from './image-blocks.js';
25
+ import { imagePlacement, isImage, isImageBlock } from './image-blocks.js';
25
26
  import { GapCursor } from '@milkdown/kit/prose/gapcursor';
26
27
  import { wikiLinkView } from './wiki-view.js';
27
28
  import { withFileSignal } from './files.js';
@@ -29,14 +30,15 @@ import { tablePlugins, tableCommand, tableCommands, tableAvailability, tableCont
29
30
  import { InkKitError, } from './types.js';
30
31
  import { readableProjection, projectionPosition, projectionOffset, validateOffsets, formattedRects, literalRangesRects, literalVisibleRanges, intersectsViewport, textRect, } from './text-ranges.js';
31
32
  import { highlightKeymap, toggleHighlightCommand } from './inline-highlight.js';
32
- import { commentSelectionContent, setCommentVisibility } from './comments.js';
33
+ import { assertCommentsEndWhereWritten, commentSelectionContent, isWrittenEdit, setCommentVisibility, sourceReplacement, } from './comments.js';
34
+ import { scrollOutward, scrollWholePixels } from './scroll.js';
33
35
  import { PasteController } from './paste.js';
34
36
  import { selectionPlugin } from './selection.js';
35
37
  import { search, SearchQuery, setSearchState } from 'prosemirror-search';
36
38
  import { findFormatted, findLiteral, replaceFormatted, replaceLiteral, replaceFormattedRange, } from './search.js';
37
39
  import { collectFormattedHeadings, collectSourceHeadings, navigateFormattedHeading, sourceHeadingPosition, } from './outline.js';
38
40
  import { taskListPlugin, toggleTaskList } from './tasks.js';
39
- import { isMermaid, mermaidPreview } from './mermaid.js';
41
+ import { diagramsAvailable, isMermaid, mermaidPreview } from './mermaid.js';
40
42
  import { hasAuthoredImagesInLiterals, printableMarkdown, printableText, } from './print.js';
41
43
  const shortcutCommands = {
42
44
  heading1: ['heading', 1],
@@ -244,6 +246,12 @@ function joinLists(tr, start, index) {
244
246
  tr.join($start.posAtIndex(index));
245
247
  }
246
248
  let editorSequence = 0;
249
+ // A pause in source typing longer than this ends an undo step, as in the
250
+ // formatted editor's history.
251
+ const sourceGroupDelay = 500;
252
+ // Earlier undo steps of typed source that the formatted document has not taken
253
+ // in; the oldest merge into the next beyond this many.
254
+ const sourceStepLimit = 100;
247
255
  export class InkKitEditor {
248
256
  events;
249
257
  options;
@@ -259,6 +267,21 @@ export class InkKitEditor {
259
267
  plainSearch = '';
260
268
  literalMatch;
261
269
  beforePlainInput;
270
+ // Source typed in the source editor that the hidden formatted document has
271
+ // not taken in yet. The text is already authoritative: snapshots, change
272
+ // events and the textarea use it, and anything that reads the formatted
273
+ // document first settles it. Undo steps taken inside it need no document,
274
+ // so they run on the text alone: `typedSteps` hold the states at the end of
275
+ // earlier typing, `pendingSource` the latest, and `undoneSteps` what undo
276
+ // set aside.
277
+ pendingSource;
278
+ typedSteps = [];
279
+ undoneSteps = [];
280
+ // Formatted-history undos taken while source steps were undone, which a
281
+ // redo replays before it reaches those steps.
282
+ formattedUndone = 0;
283
+ lastSourceInput = 0;
284
+ syncingSource = false;
262
285
  mode = 'formatted';
263
286
  sourcePreservations = new WeakMap();
264
287
  sourceTexts = new WeakMap();
@@ -298,6 +321,12 @@ export class InkKitEditor {
298
321
  update: (view, previous) => {
299
322
  if (this.loading || view.state.doc.eq(previous.doc))
300
323
  return;
324
+ // Settling typed source brings the document up to a revision and a
325
+ // change event that were already reported.
326
+ if (this.syncingSource) {
327
+ this.mediaPolicy();
328
+ return;
329
+ }
301
330
  this.revision += 1;
302
331
  this.cancelExports();
303
332
  this.mediaPolicy();
@@ -533,8 +562,10 @@ export class InkKitEditor {
533
562
  : caretState(state),
534
563
  table: this.literalSurface ? undefined : tableContext(state),
535
564
  commands: {
536
- undo: mutable && undoHistory(state),
537
- redo: mutable && redoHistory(state),
565
+ undo: mutable && (!!this.pendingSource || undoHistory(state)),
566
+ redo: mutable &&
567
+ (this.undoneSteps.length > 0 ||
568
+ (!this.pendingSource && redoHistory(state))),
538
569
  insertText: mutable,
539
570
  paste: mutable,
540
571
  replace: mutable,
@@ -568,6 +599,8 @@ export class InkKitEditor {
568
599
  key: new PluginKey('inkkitInputPolicy'),
569
600
  filterTransaction: (tr) => this.loading ||
570
601
  this.writable ||
602
+ // Source that was typed while the editor was writable.
603
+ this.syncingSource ||
571
604
  (!tr.docChanged && !tr.storedMarksSet),
572
605
  props: {
573
606
  editable: () => this.writable && !this.literalSurface,
@@ -603,7 +636,10 @@ export class InkKitEditor {
603
636
  this.assertAlive();
604
637
  return this.literalSurface ? 'source' : 'formatted';
605
638
  }
606
- currentText(doc = this.editor.ctx.get(editorViewCtx).state.doc) {
639
+ currentText(doc) {
640
+ if (!doc && this.pendingSource)
641
+ return this.pendingSource.text;
642
+ doc ??= this.editor.ctx.get(editorViewCtx).state.doc;
607
643
  const cached = this.sourceTexts.get(doc);
608
644
  if (cached !== undefined)
609
645
  return cached;
@@ -628,6 +664,10 @@ export class InkKitEditor {
628
664
  throw new RangeError('Editing mode must be source or formatted');
629
665
  if (this.formatType === 'txt' || this.mode === mode)
630
666
  return false;
667
+ this.settleSource();
668
+ // Source steps that were undone cannot be redone from the other mode.
669
+ this.undoneSteps = [];
670
+ this.formattedUndone = 0;
631
671
  const view = this.editor.ctx.get(editorViewCtx);
632
672
  const focused = this.literalSurface
633
673
  ? this.plain.ownerDocument.activeElement === this.plain
@@ -681,11 +721,142 @@ export class InkKitEditor {
681
721
  this.assertMutation(expectedGeneration);
682
722
  return this.applySource(text, true);
683
723
  }
684
- applySource(text, isolated, from, to) {
724
+ applySource(text, isolated, from, to, native = false) {
685
725
  if (!this.writable)
686
726
  throw new InkKitError('read-only', 'The editor is read-only');
687
727
  if (typeof text !== 'string')
688
728
  throw new TypeError('Source must be a string');
729
+ // Source edits take the same steps whether typed or not; an edit that is
730
+ // not typing ends the typing step before it and is closed to the next.
731
+ if (this.literalSurface)
732
+ return this.deferSource(text, from, to, native, isolated);
733
+ return this.commitSource(text, isolated, from, to, this.beforePlainInput);
734
+ }
735
+ /**
736
+ * Takes typed source without parsing it. Parsing, preservation and replacing
737
+ * the formatted document cost as much as a document load, so they wait for
738
+ * the next thing that needs them.
739
+ */
740
+ deferSource(text, from, to, native, closed) {
741
+ if (text === this.currentText())
742
+ return false;
743
+ const length = from === undefined ? text.replace(/\r\n?/g, '\n').length : from;
744
+ const now = Date.now();
745
+ const previous = this.pendingSource;
746
+ const continuing = previous &&
747
+ !previous.closed &&
748
+ !closed &&
749
+ now - this.lastSourceInput <= sourceGroupDelay;
750
+ if (previous && !continuing)
751
+ this.rememberTypedStep(previous);
752
+ this.lastSourceInput = now;
753
+ this.undoneSteps = [];
754
+ this.formattedUndone = 0;
755
+ const pending = {
756
+ text,
757
+ from: length,
758
+ to: to ?? length,
759
+ before: (continuing ? previous.before : undefined) ??
760
+ this.beforePlainInput ?? {
761
+ from: this.plain.selectionStart,
762
+ to: this.plain.selectionEnd,
763
+ },
764
+ closed,
765
+ };
766
+ this.pendingSource = pending;
767
+ this.revision += 1;
768
+ this.cancelExports();
769
+ this.literalMatch = undefined;
770
+ // Text that did not come from the textarea is shown there.
771
+ if (!native)
772
+ this.showTypedSource(pending.text, pending.from, pending.to);
773
+ if (text !== this.lastMarkdown) {
774
+ this.lastMarkdown = text;
775
+ this.events.changed(text, this.generation);
776
+ }
777
+ this.publishCommandState();
778
+ return true;
779
+ }
780
+ rememberTypedStep(step) {
781
+ this.typedSteps.push(step);
782
+ if (this.typedSteps.length > sourceStepLimit)
783
+ this.typedSteps.shift();
784
+ }
785
+ showTypedSource(text, from, to) {
786
+ this.plain.value = text;
787
+ this.plain.setSelectionRange(Math.min(from, this.plain.value.length), Math.min(to, this.plain.value.length));
788
+ this.revealLiteralSelection();
789
+ }
790
+ /**
791
+ * Undoes or redoes typed source that the formatted document has not taken
792
+ * in, on the text alone. Returns whether there was a step to take.
793
+ */
794
+ stepTypedSource(direction) {
795
+ const current = this.pendingSource;
796
+ let text, from, to;
797
+ if (direction === 'undo') {
798
+ if (!current)
799
+ return false;
800
+ this.undoneSteps.push(current);
801
+ this.pendingSource = this.typedSteps.pop();
802
+ text = this.pendingSource?.text ?? this.currentText();
803
+ ({ from, to } = current.before);
804
+ }
805
+ else {
806
+ const next = this.undoneSteps.pop();
807
+ if (!next)
808
+ return false;
809
+ if (current)
810
+ this.rememberTypedStep(current);
811
+ this.pendingSource = next;
812
+ text = next.text;
813
+ ({ from, to } = next);
814
+ }
815
+ this.lastSourceInput = 0;
816
+ this.revision += 1;
817
+ this.cancelExports();
818
+ this.literalMatch = undefined;
819
+ this.showTypedSource(text, from, to);
820
+ if (text !== this.lastMarkdown) {
821
+ this.lastMarkdown = text;
822
+ this.events.changed(text, this.generation);
823
+ }
824
+ this.publishCommandState();
825
+ return true;
826
+ }
827
+ /** Brings the formatted document up to the source that was typed. */
828
+ settleSource() {
829
+ const pending = this.pendingSource;
830
+ if (!pending)
831
+ return;
832
+ const steps = this.typedSteps, undone = this.undoneSteps;
833
+ this.pendingSource = undefined;
834
+ this.typedSteps = [];
835
+ this.undoneSteps = [];
836
+ this.syncingSource = true;
837
+ try {
838
+ if (this.commitSource(pending.text,
839
+ // A step of its own, whatever edit follows within the group delay.
840
+ true, pending.from, pending.to, steps[0]?.before ?? pending.before)) {
841
+ const { doc } = this.editor.ctx.get(editorViewCtx).state;
842
+ const applied = doc.attrs[sourceAttribute];
843
+ // A plugin that refuses the change must not lose what was typed.
844
+ if (applied?.text !== pending.text)
845
+ throw new InkKitError('preservation', 'The typed source could not be applied');
846
+ this.sourceTexts.set(doc, pending.text);
847
+ }
848
+ }
849
+ catch (error) {
850
+ this.pendingSource = pending;
851
+ this.typedSteps = steps;
852
+ this.undoneSteps = undone;
853
+ throw error;
854
+ }
855
+ finally {
856
+ this.syncingSource = false;
857
+ }
858
+ }
859
+ commitSource(text, isolated, from, to, before) {
689
860
  if (text === this.currentText())
690
861
  return false;
691
862
  const view = this.editor.ctx.get(editorViewCtx);
@@ -712,8 +883,8 @@ export class InkKitEditor {
712
883
  const previous = Object.freeze({
713
884
  text: this.currentText(),
714
885
  format: this.formatType,
715
- from: this.beforePlainInput?.from ?? this.plain.selectionStart,
716
- to: this.beforePlainInput?.to ?? this.plain.selectionEnd,
886
+ from: before?.from ?? this.plain.selectionStart,
887
+ to: before?.to ?? this.plain.selectionEnd,
717
888
  });
718
889
  // History must retain the source caret even when the formatted document has no corresponding position.
719
890
  this.loading = true;
@@ -728,7 +899,8 @@ export class InkKitEditor {
728
899
  }
729
900
  let tr = view.state.tr
730
901
  .replaceWith(0, view.state.doc.content.size, parsed.content)
731
- .setDocAttribute(sourceAttribute, provenance);
902
+ .setDocAttribute(sourceAttribute, provenance)
903
+ .setMeta(sourceReplacement, true);
732
904
  tr.setSelection(Selection.atEnd(tr.doc));
733
905
  if (isolated)
734
906
  tr = closeHistory(tr);
@@ -739,12 +911,35 @@ export class InkKitEditor {
739
911
  }
740
912
  undo(expectedGeneration) {
741
913
  this.assertMutation(expectedGeneration);
914
+ if (this.literalSurface && this.stepTypedSource('undo'))
915
+ return true;
742
916
  const view = this.editor.ctx.get(editorViewCtx);
743
- return undoHistory(view.state, view.dispatch);
917
+ const undone = undoHistory(view.state, view.dispatch);
918
+ if (undone) {
919
+ // Source steps that were undone come back after the formatted ones.
920
+ if (this.literalSurface && this.undoneSteps.length)
921
+ this.formattedUndone++;
922
+ else
923
+ this.undoneSteps = [];
924
+ }
925
+ return undone;
744
926
  }
745
927
  redo(expectedGeneration) {
746
928
  this.assertMutation(expectedGeneration);
747
929
  const view = this.editor.ctx.get(editorViewCtx);
930
+ if (this.literalSurface) {
931
+ if (this.formattedUndone > 0) {
932
+ this.formattedUndone--;
933
+ if (redoHistory(view.state, view.dispatch))
934
+ return true;
935
+ this.formattedUndone = 0;
936
+ }
937
+ if (this.stepTypedSource('redo'))
938
+ return true;
939
+ // Typing after an undo ended the formatted history's redo steps.
940
+ if (this.pendingSource)
941
+ return false;
942
+ }
748
943
  return redoHistory(view.state, view.dispatch);
749
944
  }
750
945
  replaceLiteralSelection(text, isolated = true, retainEndings = false) {
@@ -781,15 +976,16 @@ export class InkKitEditor {
781
976
  async printableSnapshot(expectedGeneration) {
782
977
  const snapshot = this.snapshot(expectedGeneration);
783
978
  const state = this.editor.ctx.get(editorViewCtx).state;
979
+ const doc = this.exportDocument(snapshot.text);
784
980
  const output = await this.exportContent(snapshot, async (signal) => {
785
981
  if (snapshot.format === 'md' &&
786
982
  !this.options.images &&
787
983
  !this.options.files &&
788
- hasAuthoredImagesInLiterals(state.doc.content, (source) => this.editor.ctx.get(remarkCtx).parse(source)))
984
+ hasAuthoredImagesInLiterals(doc.content, (source) => this.editor.ctx.get(remarkCtx).parse(source)))
789
985
  throw new InkKitError('image-unavailable', 'No image adapter is configured for the authored images.');
790
986
  return snapshot.format === 'txt'
791
987
  ? printableText(snapshot.text)
792
- : printableMarkdown(state.doc.content, state.schema, this.options.images, this.context(), this.options.files, signal);
988
+ : printableMarkdown(doc.content, state.schema, this.options.images, this.context(), this.options.files, signal);
793
989
  });
794
990
  return Object.freeze({
795
991
  documentId: snapshot.documentId,
@@ -805,6 +1001,17 @@ export class InkKitEditor {
805
1001
  warnings: Object.freeze(output.warnings.map((warning) => Object.freeze({ ...warning }))),
806
1002
  });
807
1003
  }
1004
+ /**
1005
+ * The document to export. Source typed in the source editor has not reached
1006
+ * the formatted document, which would merge its undo steps, so a document
1007
+ * read from the text stands in without touching history.
1008
+ */
1009
+ exportDocument(text) {
1010
+ const { doc } = this.editor.ctx.get(editorViewCtx).state;
1011
+ return this.pendingSource && this.formatType === 'md'
1012
+ ? this.editor.ctx.get(parserCtx)(text)
1013
+ : doc;
1014
+ }
808
1015
  async clipboardSnapshot(all = true) {
809
1016
  const snapshot = this.snapshot();
810
1017
  const epoch = this.documentEpoch;
@@ -822,7 +1029,11 @@ export class InkKitEditor {
822
1029
  diagrams: [],
823
1030
  };
824
1031
  }
825
- const { doc, schema, selection } = this.editor.ctx.get(editorViewCtx).state;
1032
+ const { schema, selection } = this.editor.ctx.get(editorViewCtx).state;
1033
+ // A partial copy in the source editor reads only the selected source.
1034
+ const doc = all || this.mode !== 'source'
1035
+ ? this.exportDocument(snapshot.text)
1036
+ : this.editor.ctx.get(editorViewCtx).state.doc;
826
1037
  const selectedSource = !all && this.mode === 'source'
827
1038
  ? sourceSelection(snapshot.text, this.plain.selectionStart, this.plain.selectionEnd)
828
1039
  : undefined;
@@ -874,7 +1085,9 @@ export class InkKitEditor {
874
1085
  throw new InkKitError('stale-document', 'Document changed');
875
1086
  if (result.images.some((image) => image.error))
876
1087
  this.events.error?.(new InkKitError('image-unavailable', 'Some copied images were unavailable; their descriptions were retained'));
877
- if (result.diagrams?.some((diagram) => diagram.error))
1088
+ // A host built without Mermaid chose not to render diagrams.
1089
+ if (diagramsAvailable() &&
1090
+ result.diagrams?.some((diagram) => diagram.error))
878
1091
  this.events.error?.(new InkKitError('diagram-unavailable', 'Some diagrams could not be exported; their source was retained'));
879
1092
  return result;
880
1093
  }
@@ -955,6 +1168,10 @@ export class InkKitEditor {
955
1168
  return;
956
1169
  this.destroyed = true;
957
1170
  this.ready = false;
1171
+ this.pendingSource = undefined;
1172
+ this.typedSteps = [];
1173
+ this.undoneSteps = [];
1174
+ this.formattedUndone = 0;
958
1175
  this.cancelExports();
959
1176
  this.mediaPolicy();
960
1177
  this.pasteController.destroy();
@@ -1095,7 +1312,7 @@ export class InkKitEditor {
1095
1312
  const next = this.state.apply(tr);
1096
1313
  if (!instance.loading) {
1097
1314
  try {
1098
- instance.assertPreserved(this.state.doc, next.doc);
1315
+ instance.assertPreserved(this.state.doc, next.doc, isWrittenEdit(tr));
1099
1316
  }
1100
1317
  catch (error) {
1101
1318
  this.updateState(this.state);
@@ -1132,8 +1349,8 @@ export class InkKitEditor {
1132
1349
  .use(placeholderPlugin)
1133
1350
  .use(options.rendering?.codeHighlighting === false
1134
1351
  ? []
1135
- : (await import('./highlight')).highlightPlugin)
1136
- .use(options.rendering?.diagramPreview === false
1352
+ : (await import('./highlight.js')).highlightPlugin)
1353
+ .use(options.rendering?.diagramPreview === false || !diagramsAvailable()
1137
1354
  ? []
1138
1355
  : mermaidPreview((error) => events.error?.(error)))
1139
1356
  .use(selectionPlugin)
@@ -1224,7 +1441,7 @@ export class InkKitEditor {
1224
1441
  return;
1225
1442
  }
1226
1443
  try {
1227
- instance.applySource(editedPlainSource(instance.currentText(), instance.plain.value), false, instance.plain.selectionStart, instance.plain.selectionEnd);
1444
+ instance.applySource(editedPlainSource(instance.currentText(), instance.plain.value), false, instance.plain.selectionStart, instance.plain.selectionEnd, true);
1228
1445
  }
1229
1446
  catch (error) {
1230
1447
  events.error?.(error instanceof Error ? error : new Error(String(error)));
@@ -1402,6 +1619,10 @@ export class InkKitEditor {
1402
1619
  this.outlineEpoch += 1;
1403
1620
  this.sourcePreservations = new WeakMap();
1404
1621
  this.sourceTexts = new WeakMap();
1622
+ this.pendingSource = undefined;
1623
+ this.typedSteps = [];
1624
+ this.undoneSteps = [];
1625
+ this.formattedUndone = 0;
1405
1626
  this.mode = 'formatted';
1406
1627
  this.plainComposing = false;
1407
1628
  this.beforePlainInput = undefined;
@@ -1605,9 +1826,15 @@ export class InkKitEditor {
1605
1826
  this.publishCommandState();
1606
1827
  }
1607
1828
  /** Throws when a document reached by an edit, after every appended normalisation, cannot be
1608
- * saved as preserved Markdown. Dispatch and the replacement commands share this check. */
1609
- assertPreserved(before, after) {
1610
- if (this.preservation && !this.literalSurface && !after.eq(before))
1829
+ * saved as preserved Markdown. Dispatch and the replacement commands share this check.
1830
+ * `written` is false for history steps and source replacement, which restore or supply a
1831
+ * document that may hold comment text the formatted editor would refuse to accept typed. */
1832
+ assertPreserved(before, after, written = true) {
1833
+ if (this.literalSurface || after.eq(before))
1834
+ return;
1835
+ if (written)
1836
+ assertCommentsEndWhereWritten(before, after);
1837
+ if (this.preservation)
1611
1838
  this.currentText(after);
1612
1839
  }
1613
1840
  replaceTextRange(range, text) {
@@ -1742,26 +1969,30 @@ export class InkKitEditor {
1742
1969
  : 0;
1743
1970
  let remainingTop = vertical, remainingLeft = horizontal;
1744
1971
  if (this.literalSurface) {
1745
- const top = this.plain.scrollTop, left = this.plain.scrollLeft;
1746
- this.plain.scrollTop += vertical;
1747
- this.plain.scrollLeft += horizontal;
1748
- remainingTop -= this.plain.scrollTop - top;
1749
- remainingLeft -= this.plain.scrollLeft - left;
1972
+ const style = this.root.ownerDocument.defaultView.getComputedStyle(this.plain);
1973
+ // The first or last line sits within the padding plus its own leading,
1974
+ // under half the height of its first rect, so a reveal that ends there
1975
+ // shows the edge. A range of many lines does not count as one line.
1976
+ const halfCaret = (leading.bottom - leading.top) / 2;
1977
+ const remaining = scrollWholePixels(this.plain, vertical, horizontal, {
1978
+ top: (Number.parseFloat(style.paddingTop) || 0) + halfCaret,
1979
+ bottom: (Number.parseFloat(style.paddingBottom) || 0) + halfCaret,
1980
+ });
1981
+ remainingTop = remaining.vertical;
1982
+ remainingLeft = remaining.horizontal;
1750
1983
  }
1751
1984
  let container = this.viewportContainer ?? this.root;
1752
1985
  const document = this.root.ownerDocument;
1753
1986
  while (container &&
1754
1987
  container !== document.body &&
1755
1988
  container !== document.documentElement) {
1756
- const top = container.scrollTop, left = container.scrollLeft;
1757
- container.scrollTop += remainingTop;
1758
- container.scrollLeft += remainingLeft;
1759
- remainingTop -= container.scrollTop - top;
1760
- remainingLeft -= container.scrollLeft - left;
1989
+ ;
1990
+ ({ vertical: remainingTop, horizontal: remainingLeft } =
1991
+ scrollWholePixels(container, remainingTop, remainingLeft));
1761
1992
  container = container.parentElement;
1762
1993
  }
1763
1994
  if (remainingTop || remainingLeft)
1764
- document.defaultView.scrollBy(remainingLeft, remainingTop);
1995
+ document.defaultView.scrollBy(scrollOutward(remainingLeft), scrollOutward(remainingTop));
1765
1996
  }
1766
1997
  revealCurrentSelection() {
1767
1998
  if (this.loading || !this.ready)
@@ -2102,6 +2333,11 @@ export class InkKitEditor {
2102
2333
  throw new InkKitError('image-unavailable', 'This editor has no image adapter');
2103
2334
  const view = this.editor.ctx.get(editorViewCtx);
2104
2335
  const { schema } = view.state;
2336
+ const placement = x != null && y != null ? undefined : imagePlacement(view.state);
2337
+ if (placement?.kind === 'inline') {
2338
+ this.insertInline(images);
2339
+ return;
2340
+ }
2105
2341
  // One paragraph per image: each is read back as a standalone image block where one fits.
2106
2342
  const blocks = images.map((image) => schema.nodes.paragraph.create(null, schema.nodes.image.create({
2107
2343
  src: image.path,
@@ -2112,7 +2348,9 @@ export class InkKitEditor {
2112
2348
  this.insertBlocks(blocks, x, y);
2113
2349
  return;
2114
2350
  }
2115
- const tr = view.state.tr.replaceSelection(new Slice(Fragment.fromArray(blocks), 0, 0));
2351
+ const tr = placement?.kind === 'after'
2352
+ ? view.state.tr.insert(placement.pos, blocks)
2353
+ : view.state.tr.replaceSelection(new Slice(Fragment.fromArray(blocks), 0, 0));
2116
2354
  // After the last image, so typing continues below it rather than in the block that follows.
2117
2355
  const last = blocks.at(-1).firstChild;
2118
2356
  let after;
@@ -2125,32 +2363,26 @@ export class InkKitEditor {
2125
2363
  tr.setSelection(TextSelection.create(tr.doc, after));
2126
2364
  view.dispatch(tr.scrollIntoView());
2127
2365
  }
2128
- insertBlocks(blocks, x, y) {
2366
+ // A table cell holds one line, so the images sit side by side where the selection was, with no
2367
+ // space between them: a space beside an image in a cell is saved as `&#x20;`.
2368
+ insertInline(images) {
2129
2369
  const view = this.editor.ctx.get(editorViewCtx);
2130
2370
  const { state } = view;
2131
- const hit = view.posAtCoords({ left: x, top: y });
2132
- const $pos = state.doc.resolve(hit?.pos ?? state.selection.from);
2133
- const tr = state.tr;
2134
- let at;
2135
- if ($pos.depth === 1 &&
2136
- $pos.parent.isTextblock &&
2137
- $pos.parent.content.size === 0) {
2138
- at = $pos.before(1);
2139
- tr.replaceWith(at, $pos.after(1), blocks);
2140
- }
2141
- else {
2142
- // A point over a top-level leaf, such as an image, resolves beside it rather than inside.
2143
- const leaf = $pos.depth === 0 && hit && hit.inside >= 0
2144
- ? state.doc.resolve(hit.inside)
2145
- : undefined;
2146
- at =
2147
- $pos.depth > 0
2148
- ? $pos.after(1)
2149
- : leaf?.depth === 0
2150
- ? hit.inside + state.doc.nodeAt(hit.inside).nodeSize
2151
- : $pos.pos;
2152
- tr.insert(at, blocks);
2153
- }
2371
+ const oneLine = (text) => text.replace(/\s*[\r\n]+\s*/g, ' ');
2372
+ const nodes = images.map((image) => state.schema.nodes.image.create({
2373
+ src: image.path,
2374
+ alt: oneLine(image.alt),
2375
+ title: oneLine(image.title ?? ''),
2376
+ }));
2377
+ const { from, to } = state.selection;
2378
+ const tr = state.tr.replaceWith(from, to, nodes);
2379
+ tr.setSelection(TextSelection.create(tr.doc, from + nodes.length));
2380
+ view.dispatch(tr.scrollIntoView());
2381
+ }
2382
+ insertBlocks(blocks, x, y) {
2383
+ const view = this.editor.ctx.get(editorViewCtx);
2384
+ const { from: at, to } = blockDropRange(view, x, y);
2385
+ const tr = view.state.tr.replaceWith(at, to, blocks);
2154
2386
  const end = at + blocks.reduce((size, node) => size + node.nodeSize, 0) - 1;
2155
2387
  view.dispatch(tr.setSelection(TextSelection.create(tr.doc, end)).scrollIntoView());
2156
2388
  this.focus();
@@ -2360,7 +2592,7 @@ export class InkKitEditor {
2360
2592
  view.dispatch(view.state.tr.setNodeMarkup(definition.pos, undefined, {
2361
2593
  ...definition.node.attrs,
2362
2594
  url: destination,
2363
- title: title ?? definition.node.attrs.title,
2595
+ title: title == null ? definition.node.attrs.title : singleLine(title),
2364
2596
  }));
2365
2597
  return true;
2366
2598
  }
@@ -0,0 +1,13 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx';
2
+ import { Fragment, Slice, type Node, type Schema } from '@milkdown/kit/prose/model';
3
+ /**
4
+ * The slice's nodes as blocks a document holds, wrapped as insertion would
5
+ * wrap them: inline nodes in a paragraph and list items in a list.
6
+ */
7
+ export declare function topLevel(schema: Schema, content: Fragment): Node[];
8
+ /**
9
+ * Returns the slice with every part whose Markdown would read back as
10
+ * different structure replaced by a form that reads back the same, keeping as
11
+ * much of its text and formatting as possible.
12
+ */
13
+ export declare function importableSlice(ctx: Ctx, slice: Slice): Slice;