1import { describe, expect, it } from "vite-plus/test";
2import { Editor, Node } from "@tiptap/core";
3import { newlineInCode } from "@tiptap/pm/commands";
4import { TextSelection } from "@tiptap/pm/state";
5
6import {
7 indentCodeBlock,
8 convertCodeFenceOnEnter,
9 exitCodeBlockOnClosingFence,
10 exitCodeBlockOnTrailingBlankLines,
11 indentedNewlineInCodeBlock,
12} from "./composer-code-block";
13import StarterKit from "@tiptap/starter-kit";
14
15import {
16 ComposerCodeBlockExtension,
17 ComposerListExtensions,
18 serializeEditorDoc,
19} from "./composer-rich-text-doc";
20
21const extensions = [
22 StarterKit.configure({ codeBlock: false, trailingNode: false }),
23 ComposerCodeBlockExtension,
24 ...ComposerListExtensions,
25 Node.create({
26 name: "composer-mention",
27 group: "inline",
28 inline: true,
29 atom: true,
30 addAttributes: () => ({ path: { default: "" }, source: { default: "" } }),
31 }),
32];
33
34function codeEditor(code: string, at?: { from: number; to?: number }) {
35 const editor = new Editor({
36 extensions,
37 content: {
38 type: "doc",
39 content: [{ type: "codeBlock", content: [{ type: "text", text: code }] }],
40 },
41 });
42 const start = 1;
43 const from = start + (at?.from ?? code.length);
44 const to = start + (at?.to ?? at?.from ?? code.length);
45 editor.view.dispatch(
46 editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)),
47 );
48 return editor;
49}
50
51const textOf = (editor: Editor) => editor.state.doc.textContent;
52
53describe("indentedNewlineInCodeBlock", () => {
54 it("carries the current indentation onto the new line", () => {
55 const editor = codeEditor("function f() {\n body()");
56 const handled = indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr));
57 expect(handled).toBe(true);
58 expect(textOf(editor)).toBe("function f() {\n body()\n ");
59 });
60
61 it("indents from the line the caret is on, not the last line", () => {
62 const editor = codeEditor(" deep()\nflush()", { from: 10 });
63 indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr));
64 expect(textOf(editor)).toBe(" deep()\n \nflush()");
65 });
66});
67
68function pressEnter(editor: Editor) {
69 const dispatch = (tr: Parameters<typeof editor.view.dispatch>[0]) => editor.view.dispatch(tr);
70 return (
71 exitCodeBlockOnTrailingBlankLines(editor.view) ||
72 indentedNewlineInCodeBlock(editor.state, dispatch) ||
73 newlineInCode(editor.state, dispatch)
74 );
75}
76
77describe("exitCodeBlockOnTrailingBlankLines", () => {
78 it("leaves the block once two blank lines end it", () => {
79 const editor = codeEditor("flush()");
80 pressEnter(editor);
81 pressEnter(editor);
82 expect(editor.state.doc.childCount).toBe(1);
83 pressEnter(editor);
84 expect(editor.state.doc.firstChild?.textContent).toBe("flush()");
85 expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
86 });
87
88 it("closes an unclosed fence on the way out", () => {
89 const editor = codeEditor("flush()");
90 editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "close", ""));
91 pressEnter(editor);
92 pressEnter(editor);
93 pressEnter(editor);
94 expect(editor.state.doc.firstChild?.attrs.close).toBe("\n```");
95 });
96});
97
98describe("exitCodeBlockOnClosingFence", () => {
99 const exit = (editor: Editor) =>
100 exitCodeBlockOnClosingFence(editor.state, (tr) => editor.view.dispatch(tr));
101
102 it("ends the block at a typed closing fence and starts a line after it", () => {
103 const editor = codeEditor("const a = 1\n```");
104 editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "language", "ts"));
105 expect(exit(editor)).toBe(true);
106 editor.view.dispatch(editor.state.tr.insertText("explain this"));
107 expect(serializeEditorDoc(editor.state.doc).value).toBe(
108 "```ts\nconst a = 1\n```\nexplain this",
109 );
110 });
111
112 it("moves the lines after the fence out of the block", () => {
113 const editor = codeEditor("a\n```\nb", { from: "a\n```".length });
114 expect(exit(editor)).toBe(true);
115 expect(serializeEditorDoc(editor.state.doc).value).toBe("```\na\n```\n\nb");
116 expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
117 expect(editor.state.selection.$from.parent.textContent).toBe("");
118 });
119
120 it.each([
121 ["a line that cannot close the fence", "a\n``", undefined],
122 ["a caret before the end of the line", "a\n```", { from: "a\n``".length }],
123 ])("ignores %s", (_, code, at) => {
124 expect(exit(codeEditor(code, at))).toBe(false);
125 });
126});
127
128describe("indentCodeBlock", () => {
129 it("indents every line a selection touches", () => {
130 const editor = codeEditor("one\ntwo\nthree", { from: 1, to: 9 });
131 indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr));
132 expect(textOf(editor)).toBe(" one\n two\n three");
133 });
134
135 it("leaves the line a selection ends at the start of", () => {
136 const editor = codeEditor("one\ntwo", { from: 0, to: 4 });
137 indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr));
138 expect(textOf(editor)).toBe(" one\ntwo");
139 });
140
141 it("outdents every line a selection touches", () => {
142 const editor = codeEditor(" one\n two", { from: 2, to: 9 });
143 indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr));
144 expect(textOf(editor)).toBe("one\ntwo");
145 });
146
147 it.each([
148 ["one\ntwo", " one\n two", 5],
149 ["\none\ntwo", "\n one\n two", 1],
150 ])("keeps the selection across the shifted lines in %s", (code, expected, from) => {
151 const editor = codeEditor(code, { from: 0, to: code.length });
152 indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr));
153 indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr));
154 expect(textOf(editor)).toBe(expected);
155 expect(editor.state.selection.from).toBe(from);
156 });
157
158 it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => {
159 const editor = codeEditor("one\ntwo", { from: 0, to: 7 });
160 expect(indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr))).toBe(true);
161 expect(textOf(editor)).toBe("one\ntwo");
162 });
163});
164
165describe("selections across two identical fences", () => {
166 function twoBlockEditor() {
167 const block = { type: "codeBlock", content: [{ type: "text", text: " a" }] };
168 const editor = new Editor({ extensions, content: { type: "doc", content: [block, block] } });
169 editor.view.dispatch(
170 editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 7)),
171 );
172 return editor;
173 }
174
175 it("leaves Tab and Enter alone rather than editing through the boundary", () => {
176 const editor = twoBlockEditor();
177 const before = editor.getJSON();
178 expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false);
179 expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false);
180 expect(editor.getJSON()).toEqual(before);
181 });
182});
183
184describe("convertCodeFenceOnEnter", () => {
185 function paragraphEditor(text: string) {
186 const editor = new Editor({
187 extensions,
188 content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] },
189 });
190 editor.view.dispatch(
191 editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1 + text.length)),
192 );
193 return editor;
194 }
195
196 it.each([
197 ["```", "```", ""],
198 ["```ts", "```", "ts"],
199 ["~~~py", "~~~", "py"],
200 ["````", "````", ""],
201 ["```ts title=example", "```", "ts title=example"],
202 ["``` js", "```", " js"],
203 ])("turns %s into a code block", (text, fence, language) => {
204 const editor = paragraphEditor(text);
205 expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true);
206 const block = editor.state.doc.firstChild!;
207 expect(block.type.name).toBe("codeBlock");
208 expect(block.attrs).toMatchObject({ fence, language, close: `\n${fence}` });
209 expect(block.textContent).toBe("");
210 expect(editor.state.selection.from).toBe(1);
211 });
212
213 it.each(["``", "text ```", "```a`b"])("leaves %s alone", (text) => {
214 const editor = paragraphEditor(text);
215 expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false);
216 });
217
218 it("refuses to open a fence inside a list item", () => {
219 const text = "```ts";
220 const editor = new Editor({
221 extensions,
222 content: {
223 type: "doc",
224 content: [
225 {
226 type: "bulletList",
227 content: [
228 {
229 type: "listItem",
230 content: [{ type: "paragraph", content: [{ type: "text", text }] }],
231 },
232 ],
233 },
234 ],
235 },
236 });
237 editor.view.dispatch(
238 editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3 + text.length)),
239 );
240 const before = editor.getJSON();
241 expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false);
242 expect(editor.getJSON()).toEqual(before);
243 });
244
245 it("keeps a chip's source in the info string", () => {
246 const editor = new Editor({
247 extensions,
248 content: {
249 type: "doc",
250 content: [
251 {
252 type: "paragraph",
253 content: [
254 { type: "text", text: "```ts " },
255 { type: "composer-mention", attrs: { path: "a.md", source: "@a.md" } },
256 ],
257 },
258 ],
259 },
260 });
261 editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc)));
262 expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true);
263 expect(editor.state.doc.firstChild?.attrs.language).toBe("ts @a.md");
264 });
265});