1// Markdown editing commands as StateCommands: they read EditorState and dispatch transactions, so
2// they run headless (property tests drive them with a capturing dispatch) and bind to any keymap.
3import { syntaxTree } from "@codemirror/language";
4import { EditorSelection, type EditorState, type StateCommand } from "@codemirror/state";
5import type { SyntaxNode } from "@lezer/common";
6
7/**
8 * Wrap each selected range in `mark`, or unwrap when the same delimiter already hugs it, so the
9 * shortcut toggles emphasis on a second press; an empty selection inserts the pair and lands the
10 * cursor between.
11 */
12export function toggleInlineMark(mark: string): StateCommand {
13 return ({ state, dispatch }) => {
14 const len = mark.length;
15 const transaction = state.changeByRange((range) => {
16 const before = state.sliceDoc(Math.max(0, range.from - len), range.from);
17 const after = state.sliceDoc(range.to, Math.min(state.doc.length, range.to + len));
18 if (before === mark && after === mark) {
19 return {
20 changes: [
21 { from: range.from - len, to: range.from },
22 { from: range.to, to: range.to + len },
23 ],
24 range: EditorSelection.range(range.from - len, range.to - len),
25 };
26 }
27 return {
28 changes: [
29 { from: range.from, insert: mark },
30 { from: range.to, insert: mark },
31 ],
32 range: EditorSelection.range(range.from + len, range.to + len),
33 };
34 });
35 dispatch(state.update(transaction, { scrollIntoView: true, userEvent: "input" }));
36 return true;
37 };
38}
39
40/** `toggleInlineMark` gated on a non-empty selection, so bare typing of the mark falls through. */
41export function surroundSelection(mark: string): StateCommand {
42 const toggle = toggleInlineMark(mark);
43 return (target) => (target.state.selection.main.empty ? false : toggle(target));
44}
45
46/** Bare `*` over a selection cycles plain → italic → bold → plain. */
47export const cycleEmphasis: StateCommand = ({ state, dispatch }) => {
48 if (state.selection.ranges.some((range) => range.empty)) return false;
49 const transaction = state.changeByRange((range) => {
50 const beforeTwo = state.sliceDoc(Math.max(0, range.from - 2), range.from);
51 const afterTwo = state.sliceDoc(range.to, Math.min(state.doc.length, range.to + 2));
52 if (beforeTwo === "**" && afterTwo === "**") {
53 return {
54 changes: [
55 { from: range.from - 2, to: range.from },
56 { from: range.to, to: range.to + 2 },
57 ],
58 range: EditorSelection.range(range.from - 2, range.to - 2),
59 };
60 }
61 const beforeOne = state.sliceDoc(Math.max(0, range.from - 1), range.from);
62 const afterOne = state.sliceDoc(range.to, Math.min(state.doc.length, range.to + 1));
63 if (beforeOne === "*" && afterOne === "*") {
64 return {
65 changes: [
66 { from: range.from - 1, insert: "*" },
67 { from: range.to + 1, insert: "*" },
68 ],
69 range: EditorSelection.range(range.from + 1, range.to + 1),
70 };
71 }
72 return {
73 changes: [
74 { from: range.from, insert: "*" },
75 { from: range.to, insert: "*" },
76 ],
77 range: EditorSelection.range(range.from + 1, range.to + 1),
78 };
79 });
80 dispatch(state.update(transaction, { scrollIntoView: true, userEvent: "input" }));
81 return true;
82};
83
84/** The fenced/indented code block enclosing `pos`, or null when outside code. */
85export function enclosingCodeBlock(state: EditorState, pos: number): SyntaxNode | null {
86 for (
87 let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1);
88 node;
89 node = node.parent
90 ) {
91 if (node.name === "FencedCode" || node.name === "CodeBlock") return node;
92 }
93 return null;
94}
95
96/**
97 * On the opening fence line of an unclosed fence with an empty body, scaffold the closing fence
98 * and land the cursor on a blank line between the fences.
99 */
100export const autoCloseFence: StateCommand = ({ state, dispatch }) => {
101 const cursor = state.selection.main;
102 if (!cursor.empty) return false;
103 const node = enclosingCodeBlock(state, cursor.head);
104 const line = state.doc.lineAt(cursor.head);
105 const closing = node?.getChildren("CodeMark")[1];
106 if (closing && state.doc.lineAt(closing.from).number === line.number) return false;
107 const parsedOpener =
108 node?.name === "FencedCode" &&
109 line.number === state.doc.lineAt(node.from).number &&
110 node.getChildren("CodeMark").length === 1 &&
111 state.sliceDoc(cursor.head, node.to).trim() === "";
112 // The incremental parser may not have incorporated a just-typed opener by the time Enter runs.
113 // Recognize only a complete opener ending at the caret; this cannot claim prose containing ```.
114 const rawOpener =
115 state.sliceDoc(cursor.head).trim() === ""
116 ? /^(\s*)(`{3,}|~{3,})([^`~]*)$/.exec(state.sliceDoc(line.from, cursor.head))
117 : null;
118 if (!parsedOpener && !rawOpener) return false;
119 const close = rawOpener ? `${rawOpener[1]}${rawOpener[2]}` : "```";
120 dispatch(
121 state.update({
122 changes: { from: cursor.head, insert: `\n\n${close}` },
123 selection: { anchor: cursor.head + 1 },
124 scrollIntoView: true,
125 userEvent: "input",
126 }),
127 );
128 return true;
129};
130
131// A list line: optional indent, then a bullet (`-`/`*`/`+`), an ordered marker (`1.`/`1)`), or a
132// continuation line indented under one. Four spaces make nesting visibly distinct and remain
133// unambiguous across CommonMark list-marker widths.
134const LIST_LINE = /^(\s*)(?:[-*+]|\d{1,9}[.)])\s/;
135const INDENT = " ";
136
137function listLines(state: EditorState): number[] {
138 const lines = new Set<number>();
139 for (const range of state.selection.ranges) {
140 const first = state.doc.lineAt(range.from).number;
141 const last = state.doc.lineAt(range.to).number;
142 for (let n = first; n <= last; n++) {
143 if (LIST_LINE.test(state.doc.line(n).text)) lines.add(n);
144 }
145 }
146 return [...lines].sort((a, b) => a - b);
147}
148
149/** Tab in a list: indent every selected list line by one unit. Falls through outside lists. */
150export const indentListItem: StateCommand = ({ state, dispatch }) => {
151 const lines = listLines(state);
152 if (lines.length === 0) return false;
153 dispatch(
154 state.update({
155 changes: lines.map((n) => ({ from: state.doc.line(n).from, insert: INDENT })),
156 scrollIntoView: true,
157 userEvent: "input.indent",
158 }),
159 );
160 return true;
161};
162
163/** Shift-Tab in a list: dedent every selected list line that has leading indent. */
164export const dedentListItem: StateCommand = ({ state, dispatch }) => {
165 const lines = listLines(state);
166 const changes = lines.flatMap((n) => {
167 const line = state.doc.line(n);
168 const indent = /^\s+/.exec(line.text)?.[0] ?? "";
169 if (indent === "") return [];
170 return [{ from: line.from, to: line.from + Math.min(INDENT.length, indent.length) }];
171 });
172 if (changes.length === 0) return false;
173 dispatch(state.update({ changes, scrollIntoView: true, userEvent: "delete.dedent" }));
174 return true;
175};
176
177const LIST_MARKER = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\[[ xX]\]\s)?/;
178const QUOTE_PREFIX = /^\s*(?:>\s?)+/;
179
180/**
181 * Enter inside a list item or blockquote continues it: the next line gets the same marker (with
182 * ordered numbers incremented and task boxes reset to unchecked), and text after the caret moves
183 * to the new item. Enter on an *empty* item terminates the construct by clearing its marker.
184 * Falls through outside lists/quotes, or with the caret inside the marker itself.
185 */
186export const continueList: StateCommand = ({ state, dispatch }) => {
187 const range = state.selection.main;
188 if (!range.empty) return false;
189 const line = state.doc.lineAt(range.head);
190
191 const list = LIST_MARKER.exec(line.text);
192 if (list) {
193 const markerEnd = line.from + list[0].length;
194 if (range.head < markerEnd) return false;
195 if (line.text.slice(list[0].length).trim() === "") {
196 dispatch(
197 state.update({
198 changes: { from: line.from, to: line.to, insert: "" },
199 selection: { anchor: line.from },
200 scrollIntoView: true,
201 userEvent: "delete",
202 }),
203 );
204 return true;
205 }
206 const marker = list[2]!;
207 const next = /^\d/.test(marker)
208 ? `${Number.parseInt(marker, 10) + 1}${marker.slice(-1)}`
209 : marker;
210 const insert = `\n${list[1]}${next}${list[3]}${list[4] ? "[ ] " : ""}`;
211 dispatch(
212 state.update(state.replaceSelection(insert), {
213 scrollIntoView: true,
214 userEvent: "input",
215 }),
216 );
217 return true;
218 }
219
220 const quote = QUOTE_PREFIX.exec(line.text);
221 if (quote) {
222 if (line.text.slice(quote[0].length).trim() === "") {
223 dispatch(
224 state.update({
225 changes: { from: line.from, to: line.to, insert: "" },
226 selection: { anchor: line.from },
227 scrollIntoView: true,
228 userEvent: "delete",
229 }),
230 );
231 return true;
232 }
233 if (range.head < line.from + quote[0].length) return false;
234 dispatch(
235 state.update(state.replaceSelection(`\n${quote[0]}`), {
236 scrollIntoView: true,
237 userEvent: "input",
238 }),
239 );
240 return true;
241 }
242 return false;
243};
244
245export const insertNewline: StateCommand = ({ state, dispatch }) => {
246 dispatch(
247 state.update(state.replaceSelection("\n"), { scrollIntoView: true, userEvent: "input" }),
248 );
249 return true;
250};
251
252/** Enter continues indentation; two trailing blank lines leave a fence for prose. */
253export const codeBlockEnter: StateCommand = (target) => {
254 if (autoCloseFence(target)) return true;
255
256 const { state, dispatch } = target;
257 const range = state.selection.main;
258 const block = enclosingCodeBlock(state, range.from);
259 if (!block) return false;
260
261 const marks = block.getChildren("CodeMark");
262 const closing = marks[1];
263 if (closing && range.from >= closing.from) return insertNewline(target);
264
265 if (block.name === "FencedCode" && range.empty) {
266 const openingLine = state.doc.lineAt(block.from);
267 const bodyFrom = openingLine.to + 1;
268 const bodyTo = closing ? state.doc.lineAt(closing.from).from - 1 : block.to;
269 const trailing = /\n[ \t]*\n[ \t]*$/.exec(state.sliceDoc(bodyFrom, bodyTo));
270 if (range.head === bodyTo && trailing) {
271 const from = bodyTo - trailing[0].length;
272 const fence = closing
273 ? state.doc.lineAt(closing.from).text
274 : state.sliceDoc(marks[0]!.from, marks[0]!.to);
275 const insert = `\n${fence}\n\n`;
276 dispatch(
277 state.update({
278 changes: { from, to: block.to, insert },
279 selection: { anchor: from + insert.length },
280 scrollIntoView: true,
281 userEvent: "input",
282 }),
283 );
284 return true;
285 }
286 }
287
288 const line = state.doc.lineAt(range.from);
289 const indent = /^[ \t]*/.exec(state.sliceDoc(line.from, range.from))![0];
290 dispatch(
291 state.update(state.replaceSelection(`\n${indent}`), {
292 scrollIntoView: true,
293 userEvent: "input",
294 }),
295 );
296 return true;
297};