@aicayzer/inkkit 0.0.5 → 0.0.7

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,7 +1,8 @@
1
- import { Editor, defaultValueCtx, editorViewCtx, editorViewOptionsCtx, remarkCtx, remarkStringifyOptionsCtx, rootCtx, } from '@milkdown/kit/core';
1
+ import { Editor, 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
4
  import { history } from '@milkdown/kit/plugin/history';
5
+ import { closeHistory, undo as undoHistory, redo as redoHistory, } from '@milkdown/kit/prose/history';
5
6
  import { cursor } from '@milkdown/kit/plugin/cursor';
6
7
  import { blockquoteKeymap, blockquoteSchema, bulletListKeymap, codeBlockKeymap, createCodeBlockCommand, emphasisKeymap, headingKeymap, inlineCodeKeymap, inlineCodeSchema, linkSchema, listItemKeymap, orderedListKeymap, paragraphKeymap, strongKeymap, toggleEmphasisCommand, toggleInlineCodeCommand, toggleLinkCommand, toggleStrongCommand, turnIntoTextCommand, wrapInBulletListCommand, wrapInHeadingCommand, wrapInOrderedListCommand, } from '@milkdown/kit/preset/commonmark';
7
8
  import { strikethroughKeymap, toggleStrikethroughCommand, } from '@milkdown/kit/preset/gfm';
@@ -12,19 +13,22 @@ import { findWrapping, liftTarget } from '@milkdown/kit/prose/transform';
12
13
  import { AllSelection, NodeSelection, Plugin, PluginKey, Selection, TextSelection, } from '@milkdown/kit/prose/state';
13
14
  import { $prose, callCommand, replaceAll, } from '@milkdown/kit/utils';
14
15
  import { codeCopyPlugin, placeholderPlugin } from './decorations.js';
15
- import { createDialect, serialize, stringifyOptions } from './dialect.js';
16
+ import { createDialect, stringifyOptions } from './dialect.js';
16
17
  import { Preservation } from './preserve.js';
18
+ import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection, revealSourceCaret, rawOffset, normalizedOffset, } from './source.js';
17
19
  import { normaliseLabel, referenceDefinitions, footnoteDefinitions, } from './references.js';
18
20
  import { selectionMarkdown, selectionContent, referenceMetadata, withReferenceMetadata, } from './reference-clipboard.js';
19
21
  import { imageView } from './images.js';
20
- import { tablePlugins, tableCommand } from './tables.js';
22
+ import { tablePlugins, tableCommand, configureTableMovement, } from './tables.js';
21
23
  import { InkKitError, } from './types.js';
22
24
  import { highlightPlugin } from './highlight.js';
23
25
  import { highlightKeymap, toggleHighlightCommand } from './inline-highlight.js';
24
26
  import { commentSelectionContent, setCommentVisibility } from './comments.js';
25
27
  import { PasteController } from './paste.js';
26
28
  import { selectionPlugin } from './selection.js';
27
- import { search, SearchQuery, getSearchState, setSearchState, } from 'prosemirror-search';
29
+ import { search, SearchQuery, setSearchState } from 'prosemirror-search';
30
+ import { findFormatted, findLiteral, replaceFormatted, replaceLiteral, } from './search.js';
31
+ import { collectFormattedHeadings, collectSourceHeadings, navigateFormattedHeading, sourceHeadingPosition, } from './outline.js';
28
32
  import { taskListPlugin, toggleTaskList } from './tasks.js';
29
33
  import { isMermaid, mermaidPreview } from './mermaid.js';
30
34
  import { hasAuthoredImagesInLiterals, printableMarkdown, printableText, } from './print.js';
@@ -187,11 +191,13 @@ const dropControlCharacters = $prose(() => new Plugin({
187
191
  }));
188
192
  // The listener plugin reports selection changes from inside state.apply, before
189
193
  // the view holds the new state, so the caret state is read from the view instead.
190
- function caretStatePlugin(events) {
194
+ function caretStatePlugin(events, active = () => true) {
191
195
  return $prose(() => new Plugin({
192
196
  key: new PluginKey('caretState'),
193
197
  view: () => ({
194
198
  update(view, previous) {
199
+ if (!active())
200
+ return;
195
201
  const { state } = view;
196
202
  if (state.selection.eq(previous.selection) &&
197
203
  state.doc.eq(previous.doc) &&
@@ -233,7 +239,6 @@ export class InkKitEditor {
233
239
  options;
234
240
  editor;
235
241
  lastMarkdown = '';
236
- baseline = '';
237
242
  documentId = '';
238
243
  formatType = 'md';
239
244
  revision = 0;
@@ -241,6 +246,11 @@ export class InkKitEditor {
241
246
  operationSequence = 0;
242
247
  plainComposing = false;
243
248
  plainSearch = '';
249
+ literalMatch;
250
+ beforePlainInput;
251
+ mode = 'formatted';
252
+ sourcePreservations = new WeakMap();
253
+ outlineEpoch = 0;
244
254
  ready = false;
245
255
  destroyed = false;
246
256
  preservation;
@@ -260,20 +270,30 @@ export class InkKitEditor {
260
270
  if (this.loading || view.state.doc.eq(previous.doc))
261
271
  return;
262
272
  this.revision += 1;
273
+ this.literalMatch = undefined;
263
274
  let markdown;
264
275
  try {
265
- markdown =
266
- this.preservation?.serialize(view.state.doc) ??
267
- serialize(this.editor.ctx, view.state.doc);
276
+ markdown = this.currentText(view.state.doc);
268
277
  }
269
278
  catch (error) {
270
279
  this.events.error?.(error instanceof Error ? error : new Error(String(error)));
271
280
  return;
272
281
  }
282
+ if (this.literalSurface) {
283
+ const anchor = view.state.doc.attrs[sourceAttribute];
284
+ const from = anchor?.from ?? this.plain.selectionStart;
285
+ const to = anchor?.to ?? this.plain.selectionEnd;
286
+ if (!this.plainComposing)
287
+ this.plain.value = markdown;
288
+ if (!this.plainComposing) {
289
+ this.plain.setSelectionRange(Math.min(from, this.plain.value.length), Math.min(to, this.plain.value.length));
290
+ revealSourceCaret(this.plain);
291
+ }
292
+ }
273
293
  if (markdown === this.lastMarkdown)
274
294
  return;
275
295
  this.lastMarkdown = markdown;
276
- this.events.changed(markdown === this.baseline ? this.originalSource : markdown, this.generation);
296
+ this.events.changed(markdown, this.generation);
277
297
  },
278
298
  }),
279
299
  }));
@@ -299,29 +319,147 @@ export class InkKitEditor {
299
319
  throw new InkKitError('not-ready', 'No document has been loaded');
300
320
  if (generation !== this.generation)
301
321
  throw new InkKitError('stale-document', 'Document changed');
302
- if (this.formatType === 'txt'
322
+ if (this.literalSurface
303
323
  ? this.plainComposing
304
324
  : this.editor.ctx.get(editorViewCtx).composing)
305
325
  throw new InkKitError('composition', 'Text composition is in progress');
306
326
  if (this.pasteController.pending)
307
327
  throw new InkKitError('operation-pending', 'An image import is in progress');
308
328
  }
309
- plainSource() {
310
- const normalized = this.originalSource.replace(/\r\n?/g, '\n');
311
- if (this.plain.value === normalized)
312
- return this.originalSource;
313
- const crlf = this.originalSource.includes('\r\n') &&
314
- !this.originalSource.replaceAll('\r\n', '').includes('\n');
315
- return crlf ? this.plain.value.replaceAll('\n', '\r\n') : this.plain.value;
329
+ get literalSurface() {
330
+ return this.formatType === 'txt' || this.mode === 'source';
331
+ }
332
+ get editingMode() {
333
+ this.assertAlive();
334
+ return this.literalSurface ? 'source' : 'formatted';
335
+ }
336
+ currentText(doc = this.editor.ctx.get(editorViewCtx).state.doc) {
337
+ const provenance = doc.attrs[sourceAttribute];
338
+ if (this.formatType === 'txt')
339
+ return provenance?.text ?? this.originalSource;
340
+ let preservation = this.preservation;
341
+ if (provenance) {
342
+ preservation = this.sourcePreservations.get(provenance);
343
+ if (!preservation) {
344
+ preservation = new Preservation(this.editor.ctx, provenance.text);
345
+ this.sourcePreservations.set(provenance, preservation);
346
+ }
347
+ }
348
+ return preservation.serialize(cleanSourceDoc(doc));
349
+ }
350
+ setEditingMode(mode, expectedGeneration) {
351
+ this.assertCurrent(expectedGeneration);
352
+ if (mode !== 'source' && mode !== 'formatted')
353
+ throw new RangeError('Editing mode must be source or formatted');
354
+ if (this.formatType === 'txt' || this.mode === mode)
355
+ return false;
356
+ const text = this.snapshot().text;
357
+ this.mode = mode;
358
+ this.outlineEpoch += 1;
359
+ this.plainSearch = '';
360
+ this.literalMatch = undefined;
361
+ if (mode === 'source') {
362
+ this.plain.value = text;
363
+ this.plain.setSelectionRange(this.plain.value.length, this.plain.value.length);
364
+ }
365
+ this.updateSurface();
366
+ this.events.stateChanged(this.literalSurface
367
+ ? { marks: [], block: { type: 'paragraph' }, quoted: false }
368
+ : caretState(this.editor.ctx.get(editorViewCtx).state));
369
+ return true;
370
+ }
371
+ updateSurface() {
372
+ this.plain.hidden = !this.literalSurface;
373
+ this.plain.setAttribute('aria-label', this.formatType === 'txt' ? 'Plain text editor' : 'Markdown source');
374
+ this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
375
+ this.literalSurface;
376
+ }
377
+ replaceSource(text, expectedGeneration) {
378
+ this.assertCurrent(expectedGeneration);
379
+ return this.applySource(text, true);
380
+ }
381
+ applySource(text, isolated, from, to) {
382
+ if (typeof text !== 'string')
383
+ throw new TypeError('Source must be a string');
384
+ if (text === this.currentText())
385
+ return false;
386
+ const view = this.editor.ctx.get(editorViewCtx);
387
+ const normalizedLength = text.replace(/\r\n?/g, '\n').length;
388
+ const provenance = Object.freeze({
389
+ text,
390
+ format: this.formatType,
391
+ from: from ?? normalizedLength,
392
+ to: to ?? from ?? normalizedLength,
393
+ });
394
+ let parsed;
395
+ try {
396
+ parsed =
397
+ this.formatType === 'txt'
398
+ ? view.state.schema.topNodeType.create(null, view.state.schema.nodes.paragraph.create(null, text ? view.state.schema.text(text) : undefined))
399
+ : this.editor.ctx.get(parserCtx)(text);
400
+ if (this.formatType === 'md')
401
+ this.sourcePreservations.set(provenance, new Preservation(this.editor.ctx, text));
402
+ }
403
+ catch (error) {
404
+ throw new InkKitError('preservation', error instanceof Error ? error.message : 'Cannot read Markdown source');
405
+ }
406
+ if (this.literalSurface) {
407
+ const previous = Object.freeze({
408
+ text: this.currentText(),
409
+ format: this.formatType,
410
+ from: this.beforePlainInput?.from ?? this.plain.selectionStart,
411
+ to: this.beforePlainInput?.to ?? this.plain.selectionEnd,
412
+ });
413
+ // History must retain the source caret even when the formatted document has no corresponding position.
414
+ this.loading = true;
415
+ try {
416
+ view.dispatch(view.state.tr
417
+ .setDocAttribute(sourceAttribute, previous)
418
+ .setMeta('addToHistory', false));
419
+ }
420
+ finally {
421
+ this.loading = false;
422
+ }
423
+ }
424
+ let tr = view.state.tr
425
+ .replaceWith(0, view.state.doc.content.size, parsed.content)
426
+ .setDocAttribute(sourceAttribute, provenance);
427
+ tr.setSelection(Selection.atEnd(tr.doc));
428
+ if (isolated)
429
+ tr = closeHistory(tr);
430
+ view.dispatch(tr);
431
+ if (isolated)
432
+ view.dispatch(closeHistory(view.state.tr).setMeta('addToHistory', false));
433
+ return true;
434
+ }
435
+ undo(expectedGeneration) {
436
+ this.assertCurrent(expectedGeneration);
437
+ const view = this.editor.ctx.get(editorViewCtx);
438
+ return undoHistory(view.state, view.dispatch);
439
+ }
440
+ redo(expectedGeneration) {
441
+ this.assertCurrent(expectedGeneration);
442
+ const view = this.editor.ctx.get(editorViewCtx);
443
+ return redoHistory(view.state, view.dispatch);
444
+ }
445
+ replaceLiteralSelection(text, isolated = true, retainEndings = false) {
446
+ const value = this.plain.value;
447
+ const start = this.plain.selectionStart, end = this.plain.selectionEnd;
448
+ const next = value.slice(0, start) + text.replace(/\r\n?/g, '\n') + value.slice(end);
449
+ const caret = start + text.replace(/\r\n?/g, '\n').length;
450
+ const previous = this.currentText();
451
+ const source = retainEndings
452
+ ? previous.slice(0, rawOffset(previous, start)) +
453
+ text +
454
+ previous.slice(rawOffset(previous, end))
455
+ : editedPlainSource(previous, next);
456
+ this.applySource(source, isolated, caret, caret);
316
457
  }
317
458
  snapshot(expectedGeneration) {
318
459
  this.assertCurrent(expectedGeneration);
319
460
  let text;
320
461
  try {
321
- text =
322
- this.formatType === 'txt'
323
- ? this.plainSource()
324
- : this.preservation.serialize(this.editor.ctx.get(editorViewCtx).state.doc);
462
+ text = this.currentText();
325
463
  }
326
464
  catch (error) {
327
465
  throw new InkKitError('preservation', error instanceof Error ? error.message : 'Cannot preserve Markdown');
@@ -377,7 +515,7 @@ export class InkKitEditor {
377
515
  if (this.formatType === 'txt') {
378
516
  const text = all
379
517
  ? snapshot.text
380
- : this.plain.value.slice(this.plain.selectionStart, this.plain.selectionEnd);
518
+ : snapshot.text.slice(rawOffset(snapshot.text, this.plain.selectionStart), rawOffset(snapshot.text, this.plain.selectionEnd));
381
519
  const pre = document.createElement('pre');
382
520
  pre.textContent = text;
383
521
  return {
@@ -389,40 +527,54 @@ export class InkKitEditor {
389
527
  };
390
528
  }
391
529
  const { doc, schema, selection } = this.editor.ctx.get(editorViewCtx).state;
530
+ const selectedSource = !all && this.mode === 'source'
531
+ ? sourceSelection(snapshot.text, this.plain.selectionStart, this.plain.selectionEnd)
532
+ : undefined;
533
+ const selectedDoc = selectedSource
534
+ ? this.editor.ctx.get(parserCtx)(selectedSource.shareable)
535
+ : undefined;
392
536
  const completeDiagrams = new Set();
393
- doc.descendants((node, pos) => {
537
+ (selectedDoc ?? doc).descendants((node, pos) => {
394
538
  if (isMermaid(node) &&
395
- (all ||
396
- (selection.from <= pos + 1 &&
397
- selection.to >= pos + node.nodeSize - 1))) {
539
+ (selectedDoc
540
+ ? Boolean(node.attrs.authoredFence?.close)
541
+ : all ||
542
+ (selection.from <= pos + 1 &&
543
+ selection.to >= pos + node.nodeSize - 1))) {
398
544
  completeDiagrams.add(node.textContent.replace(/\r\n?/g, '\n'));
399
545
  }
400
546
  });
401
- const content = all
402
- ? doc.content
403
- : commentSelectionContent(doc, selection.from, selection.to, selection.content().content);
547
+ const content = selectedDoc
548
+ ? selectedDoc.content
549
+ : all
550
+ ? doc.content
551
+ : commentSelectionContent(doc, selection.from, selection.to, selection.content().content);
404
552
  const valid = content.firstChild?.isInline
405
553
  ? Fragment.from(schema.nodes.paragraph.create(null, content))
406
554
  : content;
407
555
  let markdown;
408
556
  try {
409
- markdown = all
410
- ? snapshot.text
411
- : completeDiagrams.size
412
- ? new Preservation(this.editor.ctx, snapshot.text).serialize(doc.type.create(null, selectionContent(doc, valid)))
413
- : selectionMarkdown(this.editor.ctx, doc, valid);
557
+ markdown = selectedSource
558
+ ? selectedSource.markdown
559
+ : all
560
+ ? snapshot.text
561
+ : completeDiagrams.size
562
+ ? new Preservation(this.editor.ctx, snapshot.text).serialize(doc.type.create(null, selectionContent(doc, valid)))
563
+ : selectionMarkdown(this.editor.ctx, doc, valid);
414
564
  }
415
565
  catch (error) {
416
566
  throw new InkKitError('preservation', error instanceof Error
417
567
  ? error.message
418
568
  : 'Cannot copy the selected Markdown');
419
569
  }
420
- const result = await portableClipboard(all ? content : selectionContent(doc, content), schema, markdown, this.options.images, this.context(), completeDiagrams);
421
- const metadata = referenceMetadata(this.editor.ctx, doc, content);
570
+ const result = await portableClipboard(all || selectedDoc ? content : selectionContent(doc, content), schema, markdown, this.options.images, this.context(), completeDiagrams);
571
+ const metadata = referenceMetadata(this.editor.ctx, selectedDoc ?? doc, content);
422
572
  if (metadata != null)
423
573
  result.html = withReferenceMetadata(result.html, metadata);
424
574
  this.assertCurrent(snapshot.generation);
425
- if (snapshot.documentId !== this.documentId || epoch !== this.documentEpoch)
575
+ if (snapshot.documentId !== this.documentId ||
576
+ epoch !== this.documentEpoch ||
577
+ snapshot.revision !== this.revision)
426
578
  throw new InkKitError('stale-document', 'Document changed');
427
579
  if (result.images.some((image) => image.error))
428
580
  this.events.error?.(new InkKitError('image-unavailable', 'Some copied images were unavailable; their descriptions were retained'));
@@ -432,8 +584,10 @@ export class InkKitEditor {
432
584
  }
433
585
  async paste(input) {
434
586
  this.assertCurrent();
435
- if (this.formatType === 'txt' || input.plainText) {
436
- this.pasteAsPlainText(input.text);
587
+ if (this.literalSurface || input.plainText) {
588
+ this.pasteAsPlainText(this.mode === 'source' && this.formatType === 'md'
589
+ ? (input.markdown ?? input.text)
590
+ : input.text);
437
591
  return;
438
592
  }
439
593
  await this.pasteController.paste(input);
@@ -444,9 +598,8 @@ export class InkKitEditor {
444
598
  }
445
599
  pasteAsPlainText(text) {
446
600
  this.assertCurrent();
447
- if (this.formatType === 'txt') {
448
- this.plain.setRangeText(text, this.plain.selectionStart, this.plain.selectionEnd, 'end');
449
- this.plain.dispatchEvent(new Event('input'));
601
+ if (this.literalSurface) {
602
+ this.replaceLiteralSelection(text, true, this.formatType === 'md');
450
603
  }
451
604
  else {
452
605
  const view = this.editor.ctx.get(editorViewCtx);
@@ -484,7 +637,7 @@ export class InkKitEditor {
484
637
  }
485
638
  table(command, options) {
486
639
  this.assertCurrent();
487
- if (this.formatType === 'txt')
640
+ if (this.literalSurface)
488
641
  return false;
489
642
  return tableCommand(this.editor.ctx, command, options);
490
643
  }
@@ -517,6 +670,58 @@ export class InkKitEditor {
517
670
  },
518
671
  },
519
672
  }));
673
+ copySourceSelection(event) {
674
+ event.preventDefault();
675
+ event.stopPropagation();
676
+ try {
677
+ const snapshot = this.snapshot();
678
+ const from = this.plain.selectionStart, to = this.plain.selectionEnd;
679
+ const selected = sourceSelection(snapshot.text, from, to);
680
+ const parsed = this.editor.ctx.get(parserCtx)(selected.shareable);
681
+ let portable = false;
682
+ parsed.descendants((node) => {
683
+ if (node.type.name === 'image' || isMermaid(node))
684
+ portable = true;
685
+ });
686
+ const cut = event.type === 'cut';
687
+ const epoch = this.documentEpoch;
688
+ if (portable && this.events.clipboard) {
689
+ void this.clipboardSnapshot(false)
690
+ .then(async (content) => {
691
+ if (cut &&
692
+ (content.images.some((image) => image.error) ||
693
+ content.diagrams?.some((diagram) => diagram.error)))
694
+ throw new InkKitError('image-unavailable', 'The selected content could not be cut safely');
695
+ await this.events.clipboard(content);
696
+ if (cut) {
697
+ this.assertCurrent(snapshot.generation);
698
+ if (epoch !== this.documentEpoch ||
699
+ snapshot.revision !== this.revision)
700
+ throw new InkKitError('stale-document', 'Document changed before cutting');
701
+ this.plain.setSelectionRange(from, to);
702
+ this.replaceLiteralSelection('');
703
+ }
704
+ })
705
+ .catch((error) => this.events.error?.(error));
706
+ }
707
+ else {
708
+ const output = clipboardContent(parsed.content, parsed.type.schema);
709
+ if (!event.clipboardData)
710
+ return;
711
+ event.clipboardData.setData('text/plain', output.text);
712
+ event.clipboardData.setData('text/html', output.html);
713
+ if (portable) {
714
+ this.events.error?.(new InkKitError('image-unavailable', 'The host must provide a clipboard handler for portable images and diagrams'));
715
+ return;
716
+ }
717
+ if (cut)
718
+ this.replaceLiteralSelection('');
719
+ }
720
+ }
721
+ catch (error) {
722
+ this.events.error?.(error instanceof Error ? error : new Error(String(error)));
723
+ }
724
+ }
520
725
  static async mount(root, events, options = {}) {
521
726
  const instance = new InkKitEditor(events, options);
522
727
  instance.root = root;
@@ -525,7 +730,8 @@ export class InkKitEditor {
525
730
  context: () => instance.context(),
526
731
  adapter: options.images,
527
732
  onError: (error) => events.error?.(error instanceof Error ? error : new Error(String(error))),
528
- literalText: () => instance.formatType === 'txt',
733
+ literalText: () => instance.literalSurface,
734
+ editable: () => !instance.literalSurface,
529
735
  });
530
736
  instance.editor = await Editor.make()
531
737
  .config((ctx) => {
@@ -566,7 +772,7 @@ export class InkKitEditor {
566
772
  scrollMargin: { top: 8, right: 0, bottom: 24, left: 0 },
567
773
  }));
568
774
  })
569
- .use(caretStatePlugin(events))
775
+ .use(caretStatePlugin(events, () => !instance.literalSurface))
570
776
  .use(instance.pasteController.plugin)
571
777
  .use(visibleClipboard)
572
778
  .use(instance.keymapPlugin)
@@ -589,14 +795,66 @@ export class InkKitEditor {
589
795
  .use($prose(() => search()))
590
796
  .use(options.images ? imageView(options.images) : [])
591
797
  .create();
798
+ configureTableMovement(instance.editor.ctx, () => {
799
+ try {
800
+ instance.assertCurrent();
801
+ return !instance.literalSurface;
802
+ }
803
+ catch {
804
+ return false;
805
+ }
806
+ });
592
807
  instance.plain = document.createElement('textarea');
593
808
  instance.plain.className = 'inkkit-plain';
594
809
  instance.plain.setAttribute('aria-label', 'Plain text editor');
595
810
  instance.plain.hidden = true;
596
811
  root.append(instance.plain);
812
+ instance.plain.addEventListener('beforeinput', () => {
813
+ if (instance.ready && instance.literalSurface)
814
+ instance.beforePlainInput = {
815
+ from: instance.plain.selectionStart,
816
+ to: instance.plain.selectionEnd,
817
+ };
818
+ });
597
819
  instance.plain.addEventListener('input', () => {
598
- instance.revision += 1;
599
- events.changed(instance.plainSource(), instance.generation);
820
+ if (!instance.ready || !instance.literalSurface)
821
+ return;
822
+ try {
823
+ instance.applySource(editedPlainSource(instance.currentText(), instance.plain.value), false, instance.plain.selectionStart, instance.plain.selectionEnd);
824
+ }
825
+ catch (error) {
826
+ events.error?.(error instanceof Error ? error : new Error(String(error)));
827
+ }
828
+ finally {
829
+ instance.beforePlainInput = undefined;
830
+ }
831
+ });
832
+ 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')
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
+ }
847
+ });
848
+ instance.plain.addEventListener('paste', (event) => {
849
+ if (!event.clipboardData)
850
+ return;
851
+ event.preventDefault();
852
+ void instance
853
+ .paste({
854
+ text: event.clipboardData.getData('text/plain'),
855
+ markdown: event.clipboardData.getData('text/markdown') || undefined,
856
+ })
857
+ .catch((error) => events.error?.(error));
600
858
  });
601
859
  instance.plain.addEventListener('compositionstart', () => {
602
860
  instance.plainComposing = true;
@@ -648,6 +906,10 @@ export class InkKitEditor {
648
906
  instance.copyHandler = (event) => {
649
907
  if (instance.formatType === 'txt')
650
908
  return;
909
+ if (instance.mode === 'source') {
910
+ instance.copySourceSelection(event);
911
+ return;
912
+ }
651
913
  const view = instance.editor.ctx.get(editorViewCtx);
652
914
  const fragment = commentSelectionContent(view.state.doc, view.state.selection.from, view.state.selection.to, view.state.selection.content().content);
653
915
  const expanded = selectionContent(view.state.doc, fragment);
@@ -722,36 +984,51 @@ export class InkKitEditor {
722
984
  this.assertAlive();
723
985
  this.pasteController.cancelPending();
724
986
  this.documentEpoch += 1;
987
+ this.outlineEpoch += 1;
988
+ this.sourcePreservations = new WeakMap();
989
+ this.mode = 'formatted';
725
990
  this.plainComposing = false;
991
+ this.beforePlainInput = undefined;
726
992
  this.plainSearch = '';
993
+ this.literalMatch = undefined;
727
994
  this.documentId = input.documentId;
728
995
  this.formatType = input.format;
729
996
  this.revision = 0;
730
- this.plain.hidden = input.format !== 'txt';
731
- this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
732
- input.format === 'txt';
733
997
  this.plain.value = input.text;
734
998
  this.load(input.format === 'txt' ? '' : input.text, input.generation);
735
999
  this.originalSource = input.text;
1000
+ this.lastMarkdown = input.text;
736
1001
  this.ready = true;
1002
+ this.updateSurface();
737
1003
  }
738
1004
  reloadDocument(input) {
739
1005
  this.assertAlive();
740
1006
  const view = this.editor.ctx.get(editorViewCtx);
741
- const at = this.formatType === 'txt'
1007
+ const wasLiteral = this.literalSurface;
1008
+ const previousMode = this.editingMode;
1009
+ const previousFormat = this.formatType;
1010
+ const at = wasLiteral
742
1011
  ? this.plain.selectionStart
743
1012
  : view.state.selection.from;
1013
+ const end = wasLiteral ? this.plain.selectionEnd : at;
744
1014
  const top = this.root.scrollTop;
1015
+ const plainTop = this.plain.scrollTop, plainLeft = this.plain.scrollLeft;
745
1016
  this.loadDocument(input);
746
- if (input.format === 'txt')
747
- this.plain.setSelectionRange(Math.min(at, input.text.length), Math.min(at, input.text.length));
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));
748
1023
  else
749
1024
  view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(Math.min(at, view.state.doc.content.size)))));
750
1025
  this.root.scrollTop = top;
1026
+ if (wasLiteral && this.literalSurface) {
1027
+ this.plain.scrollTop = plainTop;
1028
+ this.plain.scrollLeft = plainLeft;
1029
+ }
751
1030
  }
752
1031
  load(markdown, generation) {
753
- // A loaded document only counts as changed once it is edited, so its
754
- // canonical form is the baseline, not the text as stored.
755
1032
  this.generation = generation;
756
1033
  this.originalSource = markdown;
757
1034
  this.loading = true;
@@ -762,8 +1039,7 @@ export class InkKitEditor {
762
1039
  this.loading = false;
763
1040
  }
764
1041
  this.preservation = new Preservation(this.editor.ctx, markdown);
765
- this.baseline = serialize(this.editor.ctx);
766
- this.lastMarkdown = this.baseline;
1042
+ this.lastMarkdown = markdown;
767
1043
  const view = this.editor.ctx.get(editorViewCtx);
768
1044
  // A caret at the end, where writing carries on; at the start it would sit in a first-line heading and
769
1045
  // show its marks. The last place text can go, so a document ending in a rule takes a caret above it
@@ -774,54 +1050,118 @@ export class InkKitEditor {
774
1050
  view.dispatch(setSearchState(view.state.tr.setSelection(end).scrollIntoView(), new SearchQuery({ search: '' })));
775
1051
  this.events.stateChanged(caretState(view.state));
776
1052
  }
777
- find(text) {
778
- this.assertCurrent();
779
- if (this.formatType === 'txt') {
780
- const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
781
- const matches = text
782
- ? [...this.plain.value.matchAll(new RegExp(escaped, 'giu'))]
783
- : [];
1053
+ find(text, expectedGeneration) {
1054
+ this.assertCurrent(expectedGeneration);
1055
+ if (this.literalSurface) {
784
1056
  const from = this.plainSearch === text
785
1057
  ? this.plain.selectionEnd
786
1058
  : this.plain.selectionStart;
787
- const match = matches.find((item) => item.index >= from) ?? matches[0];
788
- if (match)
789
- this.plain.setSelectionRange(match.index, match.index + match[0].length);
1059
+ const raw = this.currentText();
1060
+ const match = findLiteral(raw, text, rawOffset(raw, from));
1061
+ this.literalMatch = undefined;
1062
+ if (match) {
1063
+ // A textarea displays CRLF as one character; retain a match of either raw half for replacement.
1064
+ const start = raw[match.from] === '\n' && raw[match.from - 1] === '\r'
1065
+ ? match.from - 1
1066
+ : match.from;
1067
+ const displayedFrom = normalizedOffset(raw, start), displayedTo = normalizedOffset(raw, match.to);
1068
+ this.plain.setSelectionRange(displayedFrom, displayedTo);
1069
+ this.literalMatch = {
1070
+ ...match,
1071
+ query: text,
1072
+ displayedFrom,
1073
+ displayedTo,
1074
+ revision: this.revision,
1075
+ epoch: this.documentEpoch,
1076
+ };
1077
+ }
790
1078
  else if (this.plainSearch)
791
1079
  this.plain.setSelectionRange(this.plain.selectionEnd, this.plain.selectionEnd);
792
1080
  this.plainSearch = text;
1081
+ revealSourceCaret(this.plain);
793
1082
  return;
794
1083
  }
795
- const view = this.editor.ctx.get(editorViewCtx);
796
- const previous = getSearchState(view.state)?.query.search;
797
- const query = new SearchQuery({ search: text, literal: true });
798
- let tr = setSearchState(view.state.tr, query);
799
- if (text) {
800
- const from = previous === text ? view.state.selection.to : view.state.selection.from;
801
- const match = query.findNext(view.state, from) ?? query.findNext(view.state, 0);
802
- if (match)
803
- tr = tr
804
- .setSelection(TextSelection.create(tr.doc, match.from, match.to))
805
- .scrollIntoView();
806
- else
807
- tr = tr.setSelection(Selection.near(view.state.selection.$to));
1084
+ findFormatted(this.editor.ctx.get(editorViewCtx), text);
1085
+ }
1086
+ replace(search, replacement, expectedGeneration) {
1087
+ this.assertCurrent(expectedGeneration);
1088
+ return this.replaceMatches(search, replacement, false) > 0;
1089
+ }
1090
+ replaceAll(search, replacement, expectedGeneration) {
1091
+ this.assertCurrent(expectedGeneration);
1092
+ return this.replaceMatches(search, replacement, true);
1093
+ }
1094
+ replaceMatches(search, replacement, all) {
1095
+ if (this.literalSurface) {
1096
+ const raw = this.currentText();
1097
+ const remembered = this.literalMatch;
1098
+ const selection = remembered &&
1099
+ remembered.query === search &&
1100
+ remembered.revision === this.revision &&
1101
+ remembered.epoch === this.documentEpoch &&
1102
+ remembered.displayedFrom === this.plain.selectionStart &&
1103
+ remembered.displayedTo === this.plain.selectionEnd
1104
+ ? { from: remembered.from, to: remembered.to }
1105
+ : {
1106
+ from: rawOffset(raw, this.plain.selectionStart),
1107
+ to: rawOffset(raw, this.plain.selectionEnd),
1108
+ };
1109
+ if (remembered?.query !== search)
1110
+ this.literalMatch = undefined;
1111
+ const result = replaceLiteral(raw, search, replacement, selection, all);
1112
+ if (result.count)
1113
+ this.applySource(result.text, true, normalizedOffset(result.text, result.from), normalizedOffset(result.text, result.to));
1114
+ this.plainSearch = search;
1115
+ return result.count;
808
1116
  }
809
- else if (previous) {
810
- tr = tr.setSelection(Selection.near(view.state.selection.$to));
1117
+ return replaceFormatted(this.editor.ctx.get(editorViewCtx), search, replacement, (doc) => {
1118
+ this.currentText(doc);
1119
+ }, all);
1120
+ }
1121
+ outlineContext() {
1122
+ return {
1123
+ documentId: this.documentId,
1124
+ generation: this.generation,
1125
+ revision: this.revision,
1126
+ epoch: this.outlineEpoch,
1127
+ mode: this.editingMode,
1128
+ };
1129
+ }
1130
+ headings(expectedGeneration) {
1131
+ this.assertCurrent(expectedGeneration);
1132
+ if (this.formatType === 'txt')
1133
+ return Object.freeze([]);
1134
+ return this.mode === 'source'
1135
+ ? collectSourceHeadings(this.editor.ctx, this.plain.value, this.outlineContext())
1136
+ : collectFormattedHeadings(this.editor.ctx.get(editorViewCtx).state.doc, this.outlineContext());
1137
+ }
1138
+ navigateHeading(heading) {
1139
+ this.assertCurrent();
1140
+ if (!heading || typeof heading !== 'object')
1141
+ throw new InkKitError('stale-document', 'The heading is no longer current');
1142
+ this.assertCurrent(heading.generation);
1143
+ if (this.formatType === 'txt')
1144
+ throw new InkKitError('stale-document', 'The heading is no longer current');
1145
+ if (this.mode === 'source') {
1146
+ const position = sourceHeadingPosition(this.editor.ctx, this.plain.value, heading, this.outlineContext());
1147
+ this.plain.setSelectionRange(position, position);
1148
+ this.plain.focus({ preventScroll: true });
1149
+ revealSourceCaret(this.plain);
1150
+ return true;
811
1151
  }
812
- view.dispatch(tr);
1152
+ return navigateFormattedHeading(this.editor.ctx.get(editorViewCtx), heading, this.outlineContext());
813
1153
  }
814
1154
  /** Buffered native typing follows the same input rules as direct typing. */
815
1155
  insertText(text, generation) {
816
1156
  this.assertAlive();
817
- if ((this.formatType === 'txt'
1157
+ if ((this.literalSurface
818
1158
  ? this.plainComposing
819
1159
  : this.editor.ctx.get(editorViewCtx).composing) &&
820
1160
  generation === this.generation)
821
1161
  return false;
822
1162
  this.assertCurrent(generation);
823
- if (this.formatType === 'txt') {
824
- this.pasteAsPlainText(text);
1163
+ if (this.literalSurface) {
1164
+ this.replaceLiteralSelection(text, false);
825
1165
  return true;
826
1166
  }
827
1167
  const view = this.editor.ctx.get(editorViewCtx);
@@ -837,14 +1177,38 @@ export class InkKitEditor {
837
1177
  /** Let existing keymaps handle buffered editing commands before native fallback. */
838
1178
  keyDown(key, code, metaKey, ctrlKey, altKey, shiftKey, generation) {
839
1179
  this.assertAlive();
840
- if ((this.formatType === 'txt'
1180
+ if ((this.literalSurface
841
1181
  ? this.plainComposing
842
1182
  : this.editor.ctx.get(editorViewCtx).composing) &&
843
1183
  generation === this.generation)
844
1184
  return false;
845
1185
  this.assertCurrent(generation);
846
- if (this.formatType === 'txt')
1186
+ 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);
1191
+ if (metaKey || ctrlKey || altKey)
1192
+ return false;
1193
+ if (key === 'Enter' || key === 'Tab') {
1194
+ this.replaceLiteralSelection(key === 'Enter' ? '\n' : '\t', false);
1195
+ return true;
1196
+ }
1197
+ if (key === 'Backspace' || key === 'Delete') {
1198
+ const start = this.plain.selectionStart, end = this.plain.selectionEnd;
1199
+ if (start === end) {
1200
+ if (key === 'Backspace' && start > 0)
1201
+ this.plain.setSelectionRange(start - [...this.plain.value.slice(0, start)].at(-1).length, end);
1202
+ else if (key === 'Delete' && end < this.plain.value.length)
1203
+ this.plain.setSelectionRange(start, end + [...this.plain.value.slice(end)][0].length);
1204
+ else
1205
+ return false;
1206
+ }
1207
+ this.replaceLiteralSelection('', false);
1208
+ return true;
1209
+ }
847
1210
  return false;
1211
+ }
848
1212
  const view = this.editor.ctx.get(editorViewCtx);
849
1213
  if (view.composing)
850
1214
  return false;
@@ -864,7 +1228,7 @@ export class InkKitEditor {
864
1228
  this.assertAlive();
865
1229
  if (!this.ready)
866
1230
  throw new InkKitError('not-ready', 'No document has been loaded');
867
- if (this.formatType === 'txt')
1231
+ if (this.literalSurface)
868
1232
  this.plain.focus();
869
1233
  else
870
1234
  this.editor.ctx.get(editorViewCtx).focus();
@@ -890,7 +1254,7 @@ export class InkKitEditor {
890
1254
  this.assertCurrent();
891
1255
  if (paths.length === 0)
892
1256
  return;
893
- if (this.formatType === 'txt') {
1257
+ if (this.literalSurface) {
894
1258
  this.pasteAsPlainText(paths.join('\n\n'));
895
1259
  return;
896
1260
  }
@@ -901,7 +1265,7 @@ export class InkKitEditor {
901
1265
  this.assertCurrent();
902
1266
  if (!images.length)
903
1267
  return;
904
- if (!this.options.images || this.formatType === 'txt')
1268
+ if (!this.options.images || this.literalSurface)
905
1269
  throw new InkKitError('image-unavailable', 'This editor has no image adapter');
906
1270
  const view = this.editor.ctx.get(editorViewCtx);
907
1271
  const { schema } = view.state;
@@ -1057,7 +1421,7 @@ export class InkKitEditor {
1057
1421
  }
1058
1422
  format(command, arg) {
1059
1423
  this.assertCurrent();
1060
- if (this.formatType === 'txt')
1424
+ if (this.literalSurface)
1061
1425
  return;
1062
1426
  const run = (cmd, payload) => this.editor.action(callCommand(cmd, payload));
1063
1427
  const state = caretState(this.editor.ctx.get(editorViewCtx).state);
@@ -1131,7 +1495,7 @@ export class InkKitEditor {
1131
1495
  }
1132
1496
  editReferenceDefinition(label, destination, title) {
1133
1497
  this.assertCurrent();
1134
- if (this.formatType === 'txt')
1498
+ if (this.literalSurface)
1135
1499
  return false;
1136
1500
  const view = this.editor.ctx.get(editorViewCtx);
1137
1501
  const definitions = referenceDefinitions(view.state.doc);
@@ -1148,7 +1512,7 @@ export class InkKitEditor {
1148
1512
  }
1149
1513
  insertFootnote(label) {
1150
1514
  this.assertCurrent();
1151
- if (this.formatType === 'txt')
1515
+ if (this.literalSurface)
1152
1516
  return;
1153
1517
  const view = this.editor.ctx.get(editorViewCtx);
1154
1518
  const definitions = footnoteDefinitions(view.state.doc);
@@ -1183,7 +1547,7 @@ export class InkKitEditor {
1183
1547
  }
1184
1548
  navigateFootnote(target) {
1185
1549
  this.assertCurrent();
1186
- if (this.formatType === 'txt')
1550
+ if (this.literalSurface)
1187
1551
  return false;
1188
1552
  const view = this.editor.ctx.get(editorViewCtx);
1189
1553
  const { $from } = view.state.selection;