Skip to content
Source
Sign inSign up

composer-markdown-source.ts

585 lines22.0 KB

1import { type Command, Extension, Mark as TiptapMark } from "@tiptap/core";
2import {
3 closeHistory,
4 isHistoryTransaction,
5 redo,
6 redoDepth,
7 undo,
8 undoDepth,
9} from "@tiptap/pm/history";
10import type { MarkType, Node as ProseMirrorNode } from "@tiptap/pm/model";
11import {
12 AllSelection,
13 type EditorState,
14 NodeSelection,
15 Plugin,
16 PluginKey,
17 type Selection,
18 TextSelection,
19 type Transaction,
20} from "@tiptap/pm/state";
21import { type Mapping, StepMap } from "@tiptap/pm/transform";
22import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
23​
24import {
25 inlineMarkdownUnitMarks,
26 MARKDOWN_SOURCE_MARK,
27 type RichTextMark,
28} from "~/composer-rich-text";
29import { caretTakesMarksBefore, serializeEditorDoc } from "~/composer-rich-text-doc";
30​
31// Styled text the selection touches becomes source text with the source mark,
32// parsed back once the selection leaves. History steps only replay on the shape
33// they were recorded in, so the history records both rewrites. Recording would
34// clear redo after undo or redo, so until the next edit see `normalizeDetached`.
35​
36const STYLE_MARKS: RichTextMark[] = ["bold", "italic", "strike", "code"];
37const PREVIEW_TAGS: Record<Exclude<RichTextMark, "code">, string> = {
38 bold: "strong",
39 italic: "em",
40 strike: "s",
41};
42const ATOM_TEXT = "";
43const DELIMITER = /[*_~`]/;
44​
45type TextRange = { from: number; to: number };
46​
47type StyledRange = TextRange & {
48 escapedOpener: boolean;
49 markers: { at: number; closing: boolean; text: string }[];
50};
51​
52function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {
53 const ranges: StyledRange[] = [];
54 const map = serializeEditorDoc(doc);
55 let range: StyledRange | null = null;
56 let openLength = 0;
57 for (const run of map.runs) {
58 if (run.nodeName === "codeBlock") continue;
59 if (run.openLen > 0) {
60 let backslashes = 0;
61 while (map.value[run.mdStart - backslashes - 1] === "\\") backslashes += 1;
62 range ??= {
63 from: run.pmPos,
64 to: run.pmPos,
65 escapedOpener: backslashes % 2 !== 0,
66 markers: [],
67 };
68 range.markers.push({
69 at: run.pmPos,
70 closing: false,
71 text: map.value.slice(run.mdStart, run.mdStart + run.openLen),
72 });
73 }
74 if (range === null) continue;
75 range.to = run.pmPos + run.docLen;
76 if (run.closeLen > 0) {
77 const end = run.mdStart + run.mdLen;
78 range.markers.push({
79 at: range.to,
80 closing: true,
81 text: map.value.slice(end - run.closeLen, end),
82 });
83 }
84 openLength += run.openLen - run.closeLen;
85 if (openLength === 0) {
86 ranges.push(range);
87 range = null;
88 }
89 }
90 return ranges;
92​
93/** Whether the Markdown that reveal shows for `range` parses back to the same styles. */
94function revealsFaithfully(doc: ProseMirrorNode, range: StyledRange): boolean {
95 if (range.escapedOpener) return false;
96 const adjacent = withAdjacentDelimiters(doc, range);
97 if (adjacent.from !== range.from || adjacent.to !== range.to) return false;
98 // Parsing keeps escapes as text, so styled `path\` or `2*3` stays styled, unshown.
99 const markers = range.markers.toSorted(
100 (left, right) => left.at - right.at || Number(right.closing) - Number(left.closing),
101 );
102 const expected: Array<RichTextMark[] | null> = [];
103 let text = "";
104 let next = 0;
105 const addMarkers = (pos: number) => {
106 for (; next < markers.length && markers[next]!.at <= pos; next++) {
107 text += markers[next]!.text;
108 expected.push(...markers[next]!.text.split("").map(() => null));
109 }
110 };
111 doc.nodesBetween(range.from, range.to, (node, pos) => {
112 if (!node.isInline) return true;
113 const marks = STYLE_MARKS.filter((name) => node.marks.some((mark) => mark.type.name === name));
114 const from = Math.max(pos, range.from);
115 const content = node.isText ? node.text!.slice(from - pos, range.to - pos) : ATOM_TEXT;
116 for (let index = 0; index < content.length; index++) {
117 addMarkers(from + index);
118 text += content[index];
119 expected.push(marks);
120 }
121 return false;
122 });
123 addMarkers(range.to);
124 return inlineMarkdownUnitMarks(text).every((marks, index) => sameMarks(marks, expected[index]!));
126​
127function touches(selection: Selection, { from, to }: TextRange): boolean {
128 return selection.empty
129 ? selection.from >= from && selection.from <= to
130 : selection.from < to && selection.to > from;
132​
133function sourceRegions(doc: ProseMirrorNode, type: MarkType): TextRange[] {
134 const regions: TextRange[] = [];
135 doc.descendants((node, pos) => {
136 if (!node.isTextblock) return true;
137 let current: TextRange | null = null;
138 node.forEach((child, offset) => {
139 const start = pos + 1 + offset;
140 if (!type.isInSet(child.marks)) {
141 current = null;
142 } else if (current) {
143 current.to = start + child.nodeSize;
144 } else {
145 current = { from: start, to: start + child.nodeSize };
146 regions.push(current);
147 }
148 });
149 return false;
150 });
151 return regions;
153​
154/** Takes in delimiters typed beside the source, like a closing `**` retyped after its text. */
155function withAdjacentDelimiters(doc: ProseMirrorNode, region: TextRange): TextRange {
156 const before = doc.resolve(region.from).nodeBefore;
157 const after = doc.resolve(region.to).nodeAfter;
158 let from = region.from;
159 let to = region.to;
160 if (before?.isText && before.marks.length === 0) {
161 const text = before.text!;
162 let count = 0;
163 while (count < text.length && DELIMITER.test(text[text.length - 1 - count]!)) count += 1;
164 // Only an odd backslash run escapes the first adjacent delimiter.
165 let backslashes = 0;
166 while (text[text.length - 1 - count - backslashes] === "\\") backslashes += 1;
167 if (count > 0 && backslashes % 2 === 1) count -= 1;
168 from -= count;
169 }
170 if (after?.isText && after.marks.length === 0) {
171 const text = after.text!;
172 let count = 0;
173 while (count < text.length && DELIMITER.test(text[count]!)) count += 1;
174 to += count;
175 }
176 return { from, to };
178​
179function sameMarks(left: RichTextMark[] | null, right: RichTextMark[] | null): boolean {
180 if (left === null || right === null) return left === right;
181 return left.length === right.length && left.every((mark) => right.includes(mark));
183​
184/** Calls `visit` for each run of units that parse the same way. */
185function forEachUnitRun(
186 units: ReadonlyArray<RichTextMark[] | null>,
187 visit: (start: number, end: number, marks: RichTextMark[] | null) => void,
188): void {
189 let start = 0;
190 while (start < units.length) {
191 let end = start + 1;
192 while (end < units.length && sameMarks(units[end]!, units[start]!)) end += 1;
193 visit(start, end, units[start]!);
194 start = end;
195 }
197​
198function regionUnits(doc: ProseMirrorNode, { from, to }: TextRange) {
199 const text = doc.textBetween(from, to, undefined, ATOM_TEXT);
200 return { text, units: inlineMarkdownUnitMarks(text) };
202​
203function collapseRegion(tr: Transaction, region: TextRange, type: MarkType): void {
204 const { schema } = tr.doc.type;
205 const { units } = regionUnits(tr.doc, region);
206 tr.removeMark(region.from, region.to, type);
207 forEachUnitRun(units, (start, end, marks) => {
208 for (const name of marks ?? []) {
209 const markType = schema.marks[name];
210 if (markType) tr.addMark(region.from + start, region.from + end, markType.create());
211 }
212 });
213 const deletions: TextRange[] = [];
214 forEachUnitRun(units, (start, end, marks) => {
215 if (marks === null) deletions.push({ from: region.from + start, to: region.from + end });
216 });
217 for (const { from, to } of deletions.toReversed()) tr.delete(from, to);
219​
220/** Parses source the selection does not touch back into marks, in place. */
221function collapseMarkdownSource(
222 tr: Transaction,
223 keep: (region: TextRange) => boolean = () => false,
224): void {
225 const type = tr.doc.type.schema.marks[MARKDOWN_SOURCE_MARK];
226 if (!type) return;
227 const regions: TextRange[] = [];
228 for (const region of sourceRegions(tr.doc, type)) {
229 const extended = withAdjacentDelimiters(tr.doc, region);
230 const previous = regions.at(-1);
231 if (previous && extended.from <= previous.to) previous.to = Math.max(previous.to, extended.to);
232 else regions.push(extended);
233 }
234 for (const region of regions.toReversed()) {
235 if (keep(region)) tr.addMark(region.from, region.to, type.create());
236 else collapseRegion(tr, region, type);
237 }
239​
240function mapSelection(tr: Transaction, selection: Selection, map: (pos: number) => number) {
241 if (selection instanceof AllSelection) return new AllSelection(tr.doc);
242 if (selection instanceof NodeSelection) {
243 const pos = map(selection.from);
244 if (tr.doc.nodeAt(pos)) return NodeSelection.create(tr.doc, pos);
245 }
246 return TextSelection.create(tr.doc, map(selection.anchor), map(selection.head));
248​
249/** Parses all shown source first, so a command that splits it keeps its styles. */
250export const collapseShownMarkdownSource: Command = ({ tr }) => {
251 collapseMarkdownSource(tr);
252 return true;
253};
254​
255function mapRange(mapping: Mapping, { from, to }: TextRange): TextRange {
256 return { from: mapping.map(from, 1), to: mapping.map(to, -1) };
258​
259/** Shows styled text the selection touches as Markdown, returning the source it added. */
260function revealStyledText(tr: Transaction, type: MarkType, typed: boolean, takesBefore: boolean) {
261 const { schema } = tr.doc.type;
262 const selection = tr.selection;
263 const ranges = collectStyledRanges(tr.doc).filter(
264 (range) => touches(selection, range) && revealsFaithfully(tr.doc, range),
265 );
266 const revealed: { range: TextRange; step: number }[] = [];
267 const insertions: { at: number; length: number; closing: boolean }[] = [];
268 for (const range of ranges.toReversed()) {
269 for (const name of STYLE_MARKS) {
270 const markType = schema.marks[name];
271 if (markType) tr.removeMark(range.from, range.to, markType);
272 }
273 let length = 0;
274 // Inserting from the end keeps earlier positions valid; at a shared
275 // position the opening marker goes in first so the closing one ends up before it.
276 const markers = range.markers.toSorted(
277 (left, right) => right.at - left.at || Number(left.closing) - Number(right.closing),
278 );
279 for (const { at, closing, text } of markers) {
280 tr.insert(at, schema.text(text, [type.create()]));
281 insertions.push({ at, length: text.length, closing });
282 length += text.length;
283 }
284 tr.addMark(range.from, range.to + length, type.create());
285 revealed.push({ range: { from: range.from, to: range.to + length }, step: tr.steps.length });
286 }
287 if (ranges.length === 0) return [];
288 // A caret that arrives at a styled edge stays outside its markers. One that
289 // just typed styled text stays inside them, so the next character continues it.
290 const map = (pos: number) => {
291 let next = pos;
292 for (const { at, length, closing } of insertions) {
293 const inside = typed && selection.empty && (closing ? takesBefore : !takesBefore);
294 if (at < pos || (at === pos && (selection instanceof NodeSelection || closing !== inside))) {
295 next += length;
296 }
297 }
298 return next;
299 };
300 tr.setSelection(mapSelection(tr, selection, map));
301 return revealed.map(({ range, step }) => mapRange(tr.mapping.slice(step), range));
303​
304/** Shows styled text the selection touches as Markdown, and parses source it left. */
305function normalizeMarkdownSource(state: EditorState, typed: boolean): Transaction | null {
306 const type = state.schema.marks[MARKDOWN_SOURCE_MARK];
307 if (!type) return null;
308 const tr = state.tr;
309 collapseMarkdownSource(tr, (region) => touches(state.selection, region));
310 revealStyledText(tr, type, typed, caretTakesMarksBefore(state));
311 return tr.docChanged ? tr : null;
313​
314/**
315 * After undo or redo, history items may point inside shown source, and a
316 * rewrite in the history would clear redo. So until the next recorded edit,
317 * rewrites stay out of it and only remove text they added: source the history
318 * knows is hidden instead of parsed, and source revealed here is parsed back.
319 */
320function normalizeDetached(state: EditorState, revealed: TextRange[]): Transaction | null {
321 const type = state.schema.marks[MARKDOWN_SOURCE_MARK];
322 if (!type) return null;
323 const tr = state.tr;
324 const kept: TextRange[] = [];
325 for (const region of revealed.toSorted((left, right) => right.from - left.from)) {
326 if (touches(state.selection, region)) kept.push(region);
327 else collapseRegion(tr, region, type);
328 }
329 const added = revealStyledText(tr, type, false, caretTakesMarksBefore(state));
330 if (!tr.docChanged) return null;
331 const shown = [...kept.map((region) => mapRange(tr.mapping, region)), ...added];
332 return tr.setMeta(markdownSourceKey, shown).setMeta("addToHistory", false);
334​
335/** Parses the source `normalizeDetached` revealed, leaving the document the history knows. */
336function collapseRevealed(state: EditorState, revealed: TextRange[]): Transaction {
337 const tr = state.tr;
338 const type = state.schema.marks[MARKDOWN_SOURCE_MARK];
339 if (type) {
340 for (const region of revealed.toSorted((left, right) => right.from - left.from)) {
341 collapseRegion(tr, region, type);
342 }
343 }
344 return tr.setMeta(markdownSourceKey, []).setMeta("addToHistory", false);
346​
347/** Replays `edit` after undoing `collapse`, so the history records the reveal with the edit. */
348function rebaseEdit(tr: Transaction, collapse: Transaction, edit: Transaction): Transaction {
349 for (let index = collapse.steps.length - 1; index >= 0; index--) {
350 tr.step(collapse.steps[index]!.invert(collapse.docs[index]!));
351 }
352 for (const step of edit.steps) tr.step(step);
353 tr.setSelection(edit.selection.map(tr.doc, StepMap.empty)).setTime(edit.time);
354 if (edit.storedMarksSet) tr.setStoredMarks(edit.storedMarks);
355 if (edit.scrolledIntoView) tr.scrollIntoView();
356 // Metadata has no public accessor, and grouping, paste rules and composition read it.
357 type WithMeta = { meta: Record<string, unknown> };
358 Object.assign((tr as unknown as WithMeta).meta, (edit as unknown as WithMeta).meta);
359 return tr;
361​
362const replayKey = new PluginKey("composer-markdown-source-replay");
363​
364/**
365 * Replays the edit a request carries after the source plugin appended its collapse,
366 * in the request's one view update so an IME keeps its preedit. History ignores what
367 * is appended to an unrecorded transaction, so the request itself records nothing.
368 */
369const replayPlugin = new Plugin({
370 key: replayKey,
371 appendTransaction([request, collapse], _, state) {
372 const edit = request?.getMeta(replayKey) as Transaction | undefined;
373 // Appended transactions otherwise join the previous undo event.
374 return edit && collapse ? closeHistory(rebaseEdit(state.tr, collapse, edit)) : null;
375 },
376});
377​
378function previewDecorations(
379 doc: ProseMirrorNode,
380 type: MarkType,
381 hidden: Selection | null,
382): DecorationSet {
383 const decorations: Decoration[] = [];
384 for (const region of sourceRegions(doc, type)) {
385 const hide = hidden !== null && !touches(hidden, region);
386 const { text, units } = regionUnits(doc, region);
387 const codeTicks = new Set<number>();
388 forEachUnitRun(units, (start, end, marks) => {
389 for (const name of marks ?? []) {
390 if (name !== "code") {
391 decorations.push(
392 Decoration.inline(region.from + start, region.from + end, {
393 nodeName: PREVIEW_TAGS[name],
394 }),
395 );
396 continue;
397 }
398 // One element holds the ticks and the code, so a right-to-left line
399 // keeps both ticks on their own sides of it.
400 let from = start;
401 let to = end;
402 if (units[from - 1] === null && text[from - 1] === "`") codeTicks.add((from -= 1));
403 if (units[to] === null && text[to] === "`") codeTicks.add(to++);
404 decorations.push(
405 Decoration.inline(region.from + from, region.from + to, { nodeName: "code", dir: "ltr" }),
406 );
407 }
408 });
409 forEachUnitRun(
410 units.map((marks, index) => (marks === null && (hide || !codeTicks.has(index)) ? null : [])),
411 (start, end, marks) => {
412 if (marks !== null) return;
413 decorations.push(
414 Decoration.inline(region.from + start, region.from + end, {
415 class: hide ? "composer-rich-marker-hidden" : "composer-rich-marker",
416 }),
417 );
418 },
419 );
420 }
421 return DecorationSet.create(doc, decorations);
423​
424type MarkdownSourceState = {
425 decorations: DecorationSet;
426 /** From an undo or redo until the next recorded edit; see `normalizeDetached`. */
427 detached: boolean;
428 /** Source `normalizeDetached` revealed, which the history knows as styled text. */
429 revealed: TextRange[];
430};
431​
432const markdownSourceKey = new PluginKey<MarkdownSourceState>("composer-markdown-source");
433​
434function markdownSourcePlugin(type: MarkType): Plugin<MarkdownSourceState> {
435 let view: EditorView | null = null;
436 return new Plugin<MarkdownSourceState>({
437 key: markdownSourceKey,
438 view(editorView) {
439 view = editorView;
440 return {
441 destroy() {
442 view = null;
443 },
444 };
445 },
446 state: {
447 init: (_, state) => ({
448 decorations: previewDecorations(state.doc, type, null),
449 detached: false,
450 revealed: [],
451 }),
452 apply: (tr, old, _, state) => {
453 const history = isHistoryTransaction(tr);
454 const detached =
455 history || (old.detached && !(tr.docChanged && tr.getMeta("addToHistory") !== false));
456 const own = tr.getMeta(markdownSourceKey) as TextRange[] | undefined;
457 const revealed =
458 !detached || history
459 ? []
460 : (own ??
461 (tr.docChanged
462 ? old.revealed.map((range) => mapRange(tr.mapping, range))
463 : old.revealed));
464 const redraw = tr.docChanged || detached !== old.detached || (detached && tr.selectionSet);
465 return {
466 decorations: redraw
467 ? previewDecorations(state.doc, type, detached ? state.selection : null)
468 : old.decorations,
469 detached,
470 revealed,
471 };
472 },
473 },
474 props: {
475 decorations: (state) => markdownSourceKey.getState(state)?.decorations,
476 },
477 appendTransaction(transactions, _, state) {
478 if (transactions[0]?.getMeta(replayKey)) {
479 return collapseRevealed(state, markdownSourceKey.getState(state)?.revealed ?? []);
480 }
481 // Rewriting text mid-composition would break the IME's preedit.
482 if (view?.composing) return null;
483 // Rewrites appended to an undo or redo join its history step, which leaves
484 // the other direction unmapped. The dispatch hook normalizes after it instead.
485 if (transactions.some((tr) => isHistoryTransaction(tr) || tr.getMeta(markdownSourceKey))) {
486 return null;
487 }
488 if (!transactions.some((tr) => tr.docChanged || tr.selectionSet)) return null;
489 const source = markdownSourceKey.getState(state);
490 if (source?.detached) return normalizeDetached(state, source.revealed);
491 const tr = normalizeMarkdownSource(
492 state,
493 transactions.some((item) => item.docChanged),
494 );
495 // With nothing to undo, a recorded rewrite would only add a no-op undo.
496 if (tr && undoDepth(state) === 0) tr.setMeta("addToHistory", false);
497 return tr;
498 },
499 });
501​
502/** Keeps source `normalizeDetached` revealed out of what undo, redo and edits see. */
503const MarkdownSourceHistoryExtension = Extension.create({
504 name: "composer-markdown-source-history",
505 // Runs after undo grouping, so a rebased edit keeps the grouping chosen for it.
506 priority: 1,
507 dispatchTransaction({ transaction, next }) {
508 const { editor } = this;
509 const revealed = markdownSourceKey.getState(editor.state)?.revealed ?? [];
510 if (isHistoryTransaction(transaction)) {
511 if (revealed.length === 0) {
512 next(transaction);
513 } else {
514 const undoing = redoDepth(editor.state.apply(transaction)) > redoDepth(editor.state);
515 next(collapseRevealed(editor.state, revealed));
516 (undoing ? undo : redo)(editor.state, next);
517 }
518 const source = markdownSourceKey.getState(editor.state);
519 const tr = editor.view.composing
520 ? null
521 : normalizeDetached(editor.state, source?.revealed ?? []);
522 if (tr) next(tr);
523 return;
524 }
525 const recordedEdit =
526 transaction.docChanged &&
527 transaction.getMeta("addToHistory") !== false &&
528 transaction.getMeta(markdownSourceKey) === undefined;
529 if (revealed.length === 0 || !recordedEdit) {
530 next(transaction);
531 return;
532 }
533 next(editor.state.tr.setMeta(replayKey, transaction));
534 },
535});
536​
537export const ComposerMarkdownSourceExtension = TiptapMark.create({
538 name: MARKDOWN_SOURCE_MARK,
539 // Its shortcuts run ahead of the style marks' own.
540 priority: 1001,
541 inclusive: false,
542 parseHTML() {
543 return [{ tag: "span.composer-markdown-source" }];
544 },
545 renderHTML() {
546 return ["span", { class: "composer-markdown-source" }, 0];
547 },
548 addKeyboardShortcuts() {
549 // Shown markers are plain text, so toggling a style there would wrap them
550 // in another style. Parse the source first and toggle the result.
551 const toggle = (name: RichTextMark) => () => {
552 const { state } = this.editor;
553 const { selection } = state;
554 const touched = sourceRegions(state.doc, this.type).some((region) => {
555 if (!touches(selection, region)) return false;
556 if (!selection.empty) return true;
557 // Turning a style off just inside its own markers would type into them.
558 // Everywhere else Tiptap's own toggle styles the typed text correctly.
559 const { units } = regionUnits(state.doc, region);
560 const offset = selection.from - region.from;
561 const [before, after] = [units[offset - 1], units[offset]];
562 return (
563 (before === null && after?.includes(name) === true) ||
564 (after === null && before?.includes(name) === true)
565 );
566 });
567 if (!touched) return false;
568 return this.editor.chain().command(collapseShownMarkdownSource).toggleMark(name).run();
569 };
570 return {
571 "Mod-b": toggle("bold"),
572 "Mod-B": toggle("bold"),
573 "Mod-i": toggle("italic"),
574 "Mod-I": toggle("italic"),
575 "Mod-Shift-s": toggle("strike"),
576 "Mod-e": toggle("code"),
577 };
578 },
579 addExtensions() {
580 return [MarkdownSourceHistoryExtension];
581 },
582 addProseMirrorPlugins() {
583 return [markdownSourcePlugin(this.type), replayPlugin];
584 },
585});