@aicayzer/inkkit 0.0.6 → 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,8 +13,9 @@ 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';
@@ -24,7 +26,9 @@ 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)
@@ -592,7 +798,7 @@ export class InkKitEditor {
592
798
  configureTableMovement(instance.editor.ctx, () => {
593
799
  try {
594
800
  instance.assertCurrent();
595
- return instance.formatType === 'md';
801
+ return !instance.literalSurface;
596
802
  }
597
803
  catch {
598
804
  return false;
@@ -603,9 +809,52 @@ export class InkKitEditor {
603
809
  instance.plain.setAttribute('aria-label', 'Plain text editor');
604
810
  instance.plain.hidden = true;
605
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
+ });
606
819
  instance.plain.addEventListener('input', () => {
607
- instance.revision += 1;
608
- 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));
609
858
  });
610
859
  instance.plain.addEventListener('compositionstart', () => {
611
860
  instance.plainComposing = true;
@@ -657,6 +906,10 @@ export class InkKitEditor {
657
906
  instance.copyHandler = (event) => {
658
907
  if (instance.formatType === 'txt')
659
908
  return;
909
+ if (instance.mode === 'source') {
910
+ instance.copySourceSelection(event);
911
+ return;
912
+ }
660
913
  const view = instance.editor.ctx.get(editorViewCtx);
661
914
  const fragment = commentSelectionContent(view.state.doc, view.state.selection.from, view.state.selection.to, view.state.selection.content().content);
662
915
  const expanded = selectionContent(view.state.doc, fragment);
@@ -731,36 +984,51 @@ export class InkKitEditor {
731
984
  this.assertAlive();
732
985
  this.pasteController.cancelPending();
733
986
  this.documentEpoch += 1;
987
+ this.outlineEpoch += 1;
988
+ this.sourcePreservations = new WeakMap();
989
+ this.mode = 'formatted';
734
990
  this.plainComposing = false;
991
+ this.beforePlainInput = undefined;
735
992
  this.plainSearch = '';
993
+ this.literalMatch = undefined;
736
994
  this.documentId = input.documentId;
737
995
  this.formatType = input.format;
738
996
  this.revision = 0;
739
- this.plain.hidden = input.format !== 'txt';
740
- this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
741
- input.format === 'txt';
742
997
  this.plain.value = input.text;
743
998
  this.load(input.format === 'txt' ? '' : input.text, input.generation);
744
999
  this.originalSource = input.text;
1000
+ this.lastMarkdown = input.text;
745
1001
  this.ready = true;
1002
+ this.updateSurface();
746
1003
  }
747
1004
  reloadDocument(input) {
748
1005
  this.assertAlive();
749
1006
  const view = this.editor.ctx.get(editorViewCtx);
750
- const at = this.formatType === 'txt'
1007
+ const wasLiteral = this.literalSurface;
1008
+ const previousMode = this.editingMode;
1009
+ const previousFormat = this.formatType;
1010
+ const at = wasLiteral
751
1011
  ? this.plain.selectionStart
752
1012
  : view.state.selection.from;
1013
+ const end = wasLiteral ? this.plain.selectionEnd : at;
753
1014
  const top = this.root.scrollTop;
1015
+ const plainTop = this.plain.scrollTop, plainLeft = this.plain.scrollLeft;
754
1016
  this.loadDocument(input);
755
- if (input.format === 'txt')
756
- 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));
757
1023
  else
758
1024
  view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(Math.min(at, view.state.doc.content.size)))));
759
1025
  this.root.scrollTop = top;
1026
+ if (wasLiteral && this.literalSurface) {
1027
+ this.plain.scrollTop = plainTop;
1028
+ this.plain.scrollLeft = plainLeft;
1029
+ }
760
1030
  }
761
1031
  load(markdown, generation) {
762
- // A loaded document only counts as changed once it is edited, so its
763
- // canonical form is the baseline, not the text as stored.
764
1032
  this.generation = generation;
765
1033
  this.originalSource = markdown;
766
1034
  this.loading = true;
@@ -771,8 +1039,7 @@ export class InkKitEditor {
771
1039
  this.loading = false;
772
1040
  }
773
1041
  this.preservation = new Preservation(this.editor.ctx, markdown);
774
- this.baseline = serialize(this.editor.ctx);
775
- this.lastMarkdown = this.baseline;
1042
+ this.lastMarkdown = markdown;
776
1043
  const view = this.editor.ctx.get(editorViewCtx);
777
1044
  // A caret at the end, where writing carries on; at the start it would sit in a first-line heading and
778
1045
  // show its marks. The last place text can go, so a document ending in a rule takes a caret above it
@@ -783,54 +1050,118 @@ export class InkKitEditor {
783
1050
  view.dispatch(setSearchState(view.state.tr.setSelection(end).scrollIntoView(), new SearchQuery({ search: '' })));
784
1051
  this.events.stateChanged(caretState(view.state));
785
1052
  }
786
- find(text) {
787
- this.assertCurrent();
788
- if (this.formatType === 'txt') {
789
- const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
790
- const matches = text
791
- ? [...this.plain.value.matchAll(new RegExp(escaped, 'giu'))]
792
- : [];
1053
+ find(text, expectedGeneration) {
1054
+ this.assertCurrent(expectedGeneration);
1055
+ if (this.literalSurface) {
793
1056
  const from = this.plainSearch === text
794
1057
  ? this.plain.selectionEnd
795
1058
  : this.plain.selectionStart;
796
- const match = matches.find((item) => item.index >= from) ?? matches[0];
797
- if (match)
798
- this.plain.setSelectionRange(match.index, match.index + match[0].length);
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
+ }
799
1078
  else if (this.plainSearch)
800
1079
  this.plain.setSelectionRange(this.plain.selectionEnd, this.plain.selectionEnd);
801
1080
  this.plainSearch = text;
1081
+ revealSourceCaret(this.plain);
802
1082
  return;
803
1083
  }
804
- const view = this.editor.ctx.get(editorViewCtx);
805
- const previous = getSearchState(view.state)?.query.search;
806
- const query = new SearchQuery({ search: text, literal: true });
807
- let tr = setSearchState(view.state.tr, query);
808
- if (text) {
809
- const from = previous === text ? view.state.selection.to : view.state.selection.from;
810
- const match = query.findNext(view.state, from) ?? query.findNext(view.state, 0);
811
- if (match)
812
- tr = tr
813
- .setSelection(TextSelection.create(tr.doc, match.from, match.to))
814
- .scrollIntoView();
815
- else
816
- tr = tr.setSelection(Selection.near(view.state.selection.$to));
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;
817
1116
  }
818
- else if (previous) {
819
- 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;
820
1151
  }
821
- view.dispatch(tr);
1152
+ return navigateFormattedHeading(this.editor.ctx.get(editorViewCtx), heading, this.outlineContext());
822
1153
  }
823
1154
  /** Buffered native typing follows the same input rules as direct typing. */
824
1155
  insertText(text, generation) {
825
1156
  this.assertAlive();
826
- if ((this.formatType === 'txt'
1157
+ if ((this.literalSurface
827
1158
  ? this.plainComposing
828
1159
  : this.editor.ctx.get(editorViewCtx).composing) &&
829
1160
  generation === this.generation)
830
1161
  return false;
831
1162
  this.assertCurrent(generation);
832
- if (this.formatType === 'txt') {
833
- this.pasteAsPlainText(text);
1163
+ if (this.literalSurface) {
1164
+ this.replaceLiteralSelection(text, false);
834
1165
  return true;
835
1166
  }
836
1167
  const view = this.editor.ctx.get(editorViewCtx);
@@ -846,14 +1177,38 @@ export class InkKitEditor {
846
1177
  /** Let existing keymaps handle buffered editing commands before native fallback. */
847
1178
  keyDown(key, code, metaKey, ctrlKey, altKey, shiftKey, generation) {
848
1179
  this.assertAlive();
849
- if ((this.formatType === 'txt'
1180
+ if ((this.literalSurface
850
1181
  ? this.plainComposing
851
1182
  : this.editor.ctx.get(editorViewCtx).composing) &&
852
1183
  generation === this.generation)
853
1184
  return false;
854
1185
  this.assertCurrent(generation);
855
- 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
+ }
856
1210
  return false;
1211
+ }
857
1212
  const view = this.editor.ctx.get(editorViewCtx);
858
1213
  if (view.composing)
859
1214
  return false;
@@ -873,7 +1228,7 @@ export class InkKitEditor {
873
1228
  this.assertAlive();
874
1229
  if (!this.ready)
875
1230
  throw new InkKitError('not-ready', 'No document has been loaded');
876
- if (this.formatType === 'txt')
1231
+ if (this.literalSurface)
877
1232
  this.plain.focus();
878
1233
  else
879
1234
  this.editor.ctx.get(editorViewCtx).focus();
@@ -899,7 +1254,7 @@ export class InkKitEditor {
899
1254
  this.assertCurrent();
900
1255
  if (paths.length === 0)
901
1256
  return;
902
- if (this.formatType === 'txt') {
1257
+ if (this.literalSurface) {
903
1258
  this.pasteAsPlainText(paths.join('\n\n'));
904
1259
  return;
905
1260
  }
@@ -910,7 +1265,7 @@ export class InkKitEditor {
910
1265
  this.assertCurrent();
911
1266
  if (!images.length)
912
1267
  return;
913
- if (!this.options.images || this.formatType === 'txt')
1268
+ if (!this.options.images || this.literalSurface)
914
1269
  throw new InkKitError('image-unavailable', 'This editor has no image adapter');
915
1270
  const view = this.editor.ctx.get(editorViewCtx);
916
1271
  const { schema } = view.state;
@@ -1066,7 +1421,7 @@ export class InkKitEditor {
1066
1421
  }
1067
1422
  format(command, arg) {
1068
1423
  this.assertCurrent();
1069
- if (this.formatType === 'txt')
1424
+ if (this.literalSurface)
1070
1425
  return;
1071
1426
  const run = (cmd, payload) => this.editor.action(callCommand(cmd, payload));
1072
1427
  const state = caretState(this.editor.ctx.get(editorViewCtx).state);
@@ -1140,7 +1495,7 @@ export class InkKitEditor {
1140
1495
  }
1141
1496
  editReferenceDefinition(label, destination, title) {
1142
1497
  this.assertCurrent();
1143
- if (this.formatType === 'txt')
1498
+ if (this.literalSurface)
1144
1499
  return false;
1145
1500
  const view = this.editor.ctx.get(editorViewCtx);
1146
1501
  const definitions = referenceDefinitions(view.state.doc);
@@ -1157,7 +1512,7 @@ export class InkKitEditor {
1157
1512
  }
1158
1513
  insertFootnote(label) {
1159
1514
  this.assertCurrent();
1160
- if (this.formatType === 'txt')
1515
+ if (this.literalSurface)
1161
1516
  return;
1162
1517
  const view = this.editor.ctx.get(editorViewCtx);
1163
1518
  const definitions = footnoteDefinitions(view.state.doc);
@@ -1192,7 +1547,7 @@ export class InkKitEditor {
1192
1547
  }
1193
1548
  navigateFootnote(target) {
1194
1549
  this.assertCurrent();
1195
- if (this.formatType === 'txt')
1550
+ if (this.literalSurface)
1196
1551
  return false;
1197
1552
  const view = this.editor.ctx.get(editorViewCtx);
1198
1553
  const { $from } = view.state.selection;