1import { exitCode } from "@tiptap/pm/commands";
2import type { EditorState, Transaction } from "@tiptap/pm/state";
3import { TextSelection } from "@tiptap/pm/state";
4import type { EditorView } from "@tiptap/pm/view";
5
6import { isClosingFence, parseOpeningFence, serializeSelection } from "~/composer-rich-text-doc";
7
8const CODE_BLOCK_INDENT = " ";
9
10function leadingWhitespace(line: string): string {
11 return /^[ \t]*/.exec(line)?.[0] ?? "";
12}
13
14function outdentLine(line: string): string {
15 if (line.startsWith(CODE_BLOCK_INDENT)) return line.slice(CODE_BLOCK_INDENT.length);
16 if (line.startsWith("\t")) return line.slice(1);
17 if (line.startsWith(" ")) return line.slice(1);
18 return line;
19}
20
21function indentLines(lines: ReadonlyArray<string>, direction: "in" | "out"): string[] {
22 return lines.map((line) => {
23 if (direction === "in") return line.length === 0 ? line : `${CODE_BLOCK_INDENT}${line}`;
24 return outdentLine(line);
25 });
26}
27
28export function selectionInOneCodeBlock(state: EditorState): boolean {
29 const { $from, $to } = state.selection;
30 return $from.parent.type.spec.code === true && $from.sameParent($to);
31}
32
33function codeBlockRange(
34 state: EditorState,
35): { readonly from: number; readonly to: number; readonly text: string } | null {
36 const { $from, $to } = state.selection;
37 const parent = $from.parent;
38 if (parent.type.spec.code !== true) return null;
39 // Structural equality would let selections span identical fences.
40 if (!$from.sameParent($to)) return null;
41 const from = $from.start();
42 return { from, to: from + parent.content.size, text: parent.textContent };
43}
44
45export function indentedNewlineInCodeBlock(
46 state: EditorState,
47 dispatch?: (transaction: Transaction) => void,
48): boolean {
49 const block = codeBlockRange(state);
50 if (!block) return false;
51
52 const { from, to } = state.selection;
53 const beforeCursor = block.text.slice(0, from - block.from);
54 const currentLine = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1);
55 const indent = leadingWhitespace(currentLine);
56
57 if (indent.length === 0) return false;
58
59 if (dispatch) {
60 const inserted = `\n${indent}`;
61 const transaction = state.tr.insertText(inserted, from, to);
62 const caret = from + inserted.length;
63 transaction.setSelection(TextSelection.create(transaction.doc, caret));
64 dispatch(transaction.scrollIntoView());
65 }
66 return true;
67}
68
69export function exitCodeBlockOnTrailingBlankLines(view: EditorView): boolean {
70 const { $from, empty } = view.state.selection;
71 if (!empty || $from.parent.type.spec.code !== true) return false;
72 if ($from.parentOffset !== $from.parent.content.size) return false;
73 const trailing = /\n[ \t]*\n[ \t]*$/.exec($from.parent.textContent);
74 if (!trailing) return false;
75 const transaction = view.state.tr.delete($from.pos - trailing[0].length, $from.pos);
76 // Close the fence on exit so following prose is not read as code.
77 if (!$from.parent.attrs.close) {
78 transaction.setNodeAttribute($from.before(), "close", `\n${$from.parent.attrs.fence}`);
79 }
80 view.dispatch(transaction);
81 return exitCode(view.state, (tr) => view.dispatch(tr.scrollIntoView()));
82}
83
84export function exitCodeBlockOnClosingFence(
85 state: EditorState,
86 dispatch?: (transaction: Transaction) => void,
87): boolean {
88 const block = codeBlockRange(state);
89 if (!block || !state.selection.empty) return false;
90 const caret = state.selection.from - block.from;
91 const lineStart = block.text.lastIndexOf("\n", caret - 1) + 1;
92 const newlineAfter = block.text.indexOf("\n", caret);
93 const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter;
94 const line = block.text.slice(lineStart, lineEnd);
95 const node = state.selection.$from.parent;
96 if (caret !== lineEnd || !isClosingFence(line, String(node.attrs.fence))) return false;
97
98 if (dispatch) {
99 const { schema } = state;
100 const paragraph = (text: string) =>
101 schema.nodes.paragraph!.create(null, text ? schema.text(text) : null);
102 const code = block.text.slice(0, Math.max(0, lineStart - 1));
103 const rest = newlineAfter === -1 ? [] : block.text.slice(newlineAfter + 1).split("\n");
104 const closed = node.type.create(
105 { ...node.attrs, close: `\n${line}` },
106 code ? schema.text(code) : null,
107 );
108 const start = state.selection.$from.before();
109 const transaction = state.tr.replaceWith(start, start + node.nodeSize, [
110 closed,
111 paragraph(""),
112 ...rest.map(paragraph),
113 ]);
114 transaction.setSelection(TextSelection.create(transaction.doc, start + closed.nodeSize + 1));
115 dispatch(transaction.scrollIntoView());
116 }
117 return true;
118}
119
120export function indentCodeBlock(
121 state: EditorState,
122 direction: "in" | "out",
123 dispatch?: (transaction: Transaction) => void,
124): boolean {
125 const block = codeBlockRange(state);
126 if (!block) return false;
127
128 const { from, to } = state.selection;
129 const startOffset = from - block.from;
130 const endOffset = to - block.from;
131
132 if (from === to && direction === "in") {
133 if (dispatch) {
134 const transaction = state.tr.insertText(CODE_BLOCK_INDENT, from, to);
135 dispatch(transaction.scrollIntoView());
136 }
137 return true;
138 }
139
140 const lineStart = startOffset === 0 ? 0 : block.text.lastIndexOf("\n", startOffset - 1) + 1;
141 const newlineAfter = block.text.indexOf("\n", Math.max(startOffset, endOffset - 1));
142 const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter;
143
144 const originalLines = block.text.slice(lineStart, lineEnd).split("\n");
145 const nextLines = indentLines(originalLines, direction);
146 if (nextLines.join("\n") === originalLines.join("\n")) return true;
147
148 if (dispatch) {
149 const transaction = state.tr.insertText(
150 nextLines.join("\n"),
151 block.from + lineStart,
152 block.from + lineEnd,
153 );
154 const firstDelta = (nextLines[0]?.length ?? 0) - (originalLines[0]?.length ?? 0);
155 const totalDelta = nextLines.join("\n").length - originalLines.join("\n").length;
156 const nextFrom = Math.max(block.from + lineStart, from + firstDelta);
157 const nextTo = Math.max(nextFrom, to + totalDelta);
158 transaction.setSelection(TextSelection.create(transaction.doc, nextFrom, nextTo));
159 dispatch(transaction.scrollIntoView());
160 }
161 return true;
162}
163
164/** Tiptap's input rules only fire on a space, leaving a fence followed by Enter literal. */
165export function convertCodeFenceOnEnter(
166 state: EditorState,
167 dispatch?: (transaction: Transaction) => void,
168): boolean {
169 const { $from, empty } = state.selection;
170 // List and quote serializers cannot write fences, so allow only top-level paragraphs.
171 if (!empty || $from.parent.type.name !== "paragraph" || $from.depth !== 1) return false;
172 if ($from.parentOffset !== $from.parent.content.size) return false;
173 const opening = parseOpeningFence(serializeSelection(state.doc, $from.start(), $from.end()));
174 const codeBlock = state.schema.nodes.codeBlock;
175 if (!opening || !codeBlock) return false;
176
177 if (dispatch) {
178 const blockStart = $from.before();
179 const { fence, language } = opening;
180 const transaction = state.tr.replaceWith(
181 blockStart,
182 blockStart + $from.parent.nodeSize,
183 codeBlock.create({ language, fence, close: `\n${fence}` }),
184 );
185 transaction.setSelection(TextSelection.create(transaction.doc, blockStart + 1));
186 dispatch(transaction.scrollIntoView());
187 }
188 return true;
189}