Skip to content
Source
Sign inSign up

composer-rich-text-doc.test.ts

1,442 lines52.0 KB

1import { Editor, getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/core";
2import StarterKit from "@tiptap/starter-kit";
3import { TaskList } from "@tiptap/extension-task-list";
4import { Node as ProseMirrorNode } from "@tiptap/pm/model";
5import { EditorState, TextSelection } from "@tiptap/pm/state";
6import { splitBlockKeepMarks } from "@tiptap/pm/commands";
7import { describe, expect, it, onTestFinished } from "vite-plus/test";
8import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
9​
10import {
11 collapseExpandedComposerCursor,
12 expandCollapsedComposerCursor,
13 pastedPathQueryLength,
14} from "./composer-logic";
15​
16import {
17 buildDocJson,
18 buildTiptapContent,
19 caretTakesMarksBefore,
20 ComposerBlockExtensions,
21 ComposerCodeBlockExtension,
22 ComposerListExtensions,
23 ComposerBoldExtension,
24 ComposerCodeExtension,
25 ComposerItalicExtension,
26 ComposerStrikeExtension,
27 ComposerTaskItemExtension,
28 flatToMarkdown,
29 flatToPm,
30 markdownToFlat,
31 pmToFlat,
32 serializeEditorDoc,
33 serializeSelection,
34 skillChipReplacements,
35 stepCaretAcrossStyledEdge,
36 surroundSelectionWithMark,
37} from "./composer-rich-text-doc";
38​
39function stubAtom(name: string, attrs: Record<string, { default: unknown }>) {
40 return Node.create({
41 name,
42 group: "inline",
43 inline: true,
44 atom: true,
45 addAttributes: () => attrs,
46 });
48​
49const schema = getSchemaByResolvedExtensions(
50 resolveExtensions([
51 StarterKit.configure({
52 blockquote: false,
53 bulletList: false,
54 codeBlock: false,
55 heading: false,
56 horizontalRule: false,
57 listItem: false,
58 orderedList: false,
59 dropcursor: false,
60 gapcursor: false,
61 trailingNode: false,
62 code: false,
63 }),
64 ComposerCodeExtension,
65 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),
66 stubAtom("composer-skill", {
67 skillName: { default: "" },
68 skillLabel: { default: "" },
69 skillDescription: { default: null },
70 }),
71 stubAtom("composer-model", {
72 instanceId: { default: "" },
73 model: { default: "" },
74 source: { default: "" },
75 }),
76 stubAtom("composer-citation", {
77 citation: { default: null },
78 source: { default: "" },
79 citeKey: { default: "" },
80 }),
81 stubAtom("composer-context-reference", {
82 kind: { default: "" },
83 contextId: { default: "" },
84 label: { default: "" },
85 source: { default: "" },
86 }),
87 TaskList,
88 ComposerTaskItemExtension,
89 ComposerCodeBlockExtension,
90 ...ComposerListExtensions,
91 ...ComposerBlockExtensions,
92 ]),
93);
94​
95function roundTrip(value: string) {
96 const json = buildDocJson(value, (name) => ({ label: name, description: null }));
97 const doc = ProseMirrorNode.fromJSON(schema, json);
98 // `insertContent` validates every node against the schema; `fromJSON` does not.
99 doc.check();
100 return serializeEditorDoc(doc);
102​
103describe("editable math source", () => {
104 it.each(["app/routes/posts.$postId.tsx", "pages/posts/[id].tsx"])(
105 "keeps a mentioned file whose name looks like TeX as a chip: %s",
106 (path) => {
107 const source = `fix ${serializeComposerFileLink(path)} please`;
108 const map = roundTrip(source);
109 expect(map.value).toBe(source);
110 expect(
111 map.runs.filter((run) => run.kind === "token" && run.nodeName === "composer-mention"),
112 ).toHaveLength(1);
113 },
114 );
115​
116 it.each([
117 "before $x + @README.md$ after",
118 "before \\(x + @README.md\\) after",
119 "\\[x + @README.md\\] after",
120 ])("keeps a mention that ends a formula inside the formula: %s", (source) => {
121 const map = roundTrip(source);
122 expect(map.value).toBe(source);
123 expect(map.runs.some((run) => run.kind === "token")).toBe(false);
124 });
125​
126 it.each([
127 "Area $x_i^2$ after",
128 "before \\(\\frac{1}{2}\\) after",
129 "before $$x^2$$ after",
130 "\\[\nx = 1\ny = 2\n\\]",
131 "\\[\\text{use $browser then @README.md and **literal**}\\]",
132 "```math\nx_i + y_i\n```",
133 "- **use** $x_i$ then @README.md",
134 "**$x_i$** and **A $x_i$ B**",
135 "| Name | Formula |\n| --- | --- |\n| Norm | $|x|$ |",
136 "| Name | Formula |\n| --- | --- |\n| Norm | \\(\\|x\\|\\) |",
137 ])("keeps TeX editable and round trips its source: %s", (source) => {
138 const map = roundTrip(source);
139 expect(map.value).toBe(source);
140 expect(map.runs.some((run) => run.nodeName === "composer-math")).toBe(false);
141 const doc = schema.nodeFromJSON(
142 buildDocJson(source, (name) => ({ label: name, description: null })),
143 );
144 expect(serializeSelection(doc, 1, doc.content.size - 1)).toBe(source);
145 });
146​
147 it("edits a command character directly without replacing a formula node", () => {
148 const source = "before \\(\\frac{a}{b}\\) after";
149 const doc = schema.nodeFromJSON(
150 buildDocJson(source, (name) => ({ label: name, description: null })),
151 );
152 const map = serializeEditorDoc(doc);
153 const offset = source.indexOf("{a}") + 1;
154 const position = flatToPm(map, offset);
155 const state = EditorState.create({ doc });
156 const next = state.apply(state.tr.insertText("c", position, position + 1));
157 expect(serializeEditorDoc(next.doc).value).toBe(source.replace("{a}", "{c}"));
158 });
159 it("preserves formula source as text when styling is disabled", () => {
160 const source =
161 "\\[\n\\text{use $browser, @README.md and [file](t3-context://v1/file/ctx_1)}\n\\]";
162 const doc = plainSchema.nodeFromJSON(
163 buildDocJson(source, (name) => ({ label: name, description: null }), { styling: false }),
164 );
165 const map = serializeEditorDoc(doc);
166 expect(map.value).toBe(source);
167 expect(map.runs.some((run) => run.kind === "token")).toBe(false);
168 });
169});
170​
171// Plain mode: the same engine with the mark extensions off. Markers stay
172// literal characters and task lines stay paragraphs.
173const plainSchema = getSchemaByResolvedExtensions(
174 resolveExtensions([
175 StarterKit.configure({
176 blockquote: false,
177 bulletList: false,
178 codeBlock: false,
179 heading: false,
180 horizontalRule: false,
181 listItem: false,
182 orderedList: false,
183 dropcursor: false,
184 gapcursor: false,
185 trailingNode: false,
186 bold: false,
187 italic: false,
188 strike: false,
189 code: false,
190 }),
191 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),
192 stubAtom("composer-skill", {
193 skillName: { default: "" },
194 skillLabel: { default: "" },
195 skillDescription: { default: null },
196 }),
197 stubAtom("composer-model", {
198 instanceId: { default: "" },
199 model: { default: "" },
200 source: { default: "" },
201 }),
202 stubAtom("composer-citation", {
203 citation: { default: null },
204 source: { default: "" },
205 citeKey: { default: "" },
206 }),
207 stubAtom("composer-context-reference", {
208 kind: { default: "" },
209 contextId: { default: "" },
210 label: { default: "" },
211 source: { default: "" },
212 }),
213 TaskList,
214 ComposerTaskItemExtension,
215 ]),
216);
217​
218function roundTripPlain(value: string) {
219 const json = buildDocJson(value, (name) => ({ label: name, description: null }), {
220 styling: false,
221 });
222 const doc = ProseMirrorNode.fromJSON(plainSchema, json);
223 return serializeEditorDoc(doc);
225​
226describe("literal editor answers", () => {
227 const makeDoc = (value: string) =>
228 ProseMirrorNode.fromJSON(
229 plainSchema,
230 buildDocJson(value, (name) => ({ label: name, description: null }), {
231 literalText: true,
232 }),
233 );
234​
235 it.each([
236 "",
237 "\n\n",
238 " - [X] done\n\n**keep** €my-skill @README.md \n",
239 "line one\r\nline two\r\n",
240 `- [X] done\n${" detail €my-skill @README.md\n".repeat(100)}`,
241 ])("preserves the complete answer through selection and serialization", (value) => {
242 const doc = makeDoc(value);
243 doc.check();
244 const serialized = serializeEditorDoc(doc);
245 expect(serialized.value).toBe(value);
246 expect(serialized.contextIds).toEqual([]);
247 expect(serializeSelection(doc, 1, doc.content.size - 1)).toBe(value);
248 for (let offset = 0; offset <= value.length; offset += 1) {
249 expect(collapseExpandedComposerCursor(value, flatToMarkdown(serialized, offset), true)).toBe(
250 offset,
251 );
252 }
253 });
254​
255 it("edits literal Markdown, inserts a newline, and preserves an explicitly cleared answer", () => {
256 const value = " - [X] done €my-skill @README.md ";
257 const doc = makeDoc(value);
258 let state = EditorState.create({
259 doc,
260 selection: TextSelection.create(doc, doc.content.size - 1),
261 });
262 state = state.apply(state.tr.insertText(" edited"));
263 expect(
264 splitBlockKeepMarks(state, (transaction) => {
265 state = state.apply(transaction);
266 }),
267 ).toBe(true);
268 state = state.apply(state.tr.insertText("second line "));
269 expect(serializeEditorDoc(state.doc).value).toBe(`${value} edited\nsecond line `);
270 state = state.apply(state.tr.delete(1, state.doc.content.size - 1));
271 expect(serializeEditorDoc(state.doc).value).toBe("");
272 });
273});
274​
275describe("composer rich text document model", () => {
276 it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
277 "canonicalizes %s skill aliases while preserving amounts",
278 (prefix) => {
279 const value = `Use ${prefix}my-skill for ${prefix}20 please`;
280 const expected = `Use $my-skill for ${prefix}20 please`;
281 expect(roundTrip(value).value).toBe(expected);
282 expect(roundTripPlain(value).value).toBe(expected);
283 },
284 );
285​
286 it.each([
287 "",
288 "\n\n",
289 "plain text",
290 "hello **bold** world",
291 "a *italic* word and `code` here",
292 "struck ~~out~~ now",
293 "**`x`**",
294 "*`x`*",
295 "~~`x`~~",
296 "**a `code` c**",
297 "***bold italic*** keeps nesting",
298 "line one\nline two",
299 "trailing newline\n",
300 "1. foo\n2. asdf\n",
301 "- [ ] buy milk",
302 "- [ ] buy milk",
303 "\t-\t[x]\t\titem",
304 "- [ ] ",
305 "- [ ]\n - [ ] child",
306 " - [ ] first\n - [ ] second\n - [ ] child",
307 "**before @README.md after**",
308 "*a **b** c*",
309 "*a**b***",
310 "**a*b***",
311 "**a *b* c**",
312 "literal \uFFFC **before @README.md after**",
313 "- [x] done\n- [ ] next",
314 "- [ ] parent\n - [ ] child\n - [ ] sibling\n- [ ] uncle",
315 "- [ ] empty task follows\n- [ ]",
316 "- [ ] **bold** task with @README.md",
317 "para\n- [ ] task\npara",
318 "- [ ]No space stays literal",
319 "-[ ] also literal",
320 "@README.md explain this",
321 '@"docs/My File.md" and $my-skill please',
322 "snake_case stays literal",
323 "unmatched ** stays literal",
324 "**bold** then @README.md then *italic*",
325 ])("round-trips %s through a real ProseMirror document", (value) => {
326 expect(roundTrip(value).value).toBe(value);
327 });
328​
329 it.each([
330 "",
331 "\n",
332 "text\n\n",
333 "- [ ]\n- [ ] next\n",
334 "- [ ] parent\n - [ ] child\n- [ ]",
335 "para\n- [ ] task\npara",
336 "**before @README.md after**",
337 ])("maps editable positions in %s", (value) => {
338 const doc = ProseMirrorNode.fromJSON(
339 schema,
340 buildDocJson(value, (name) => ({ label: name, description: null })),
341 );
342 const map = serializeEditorDoc(doc);
343 for (let flat = 0; flat <= map.docLength; flat += 1) {
344 const position = flatToPm(map, flat);
345 expect(doc.resolve(position).parent.isTextblock).toBe(true);
346 expect(pmToFlat(map, position)).toBe(flat);
347 expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat);
348 }
349 });
350​
351 it("keeps the caret after a trailing hard break inside the same paragraph", () => {
352 const doc = schema.node("doc", null, [
353 schema.node("paragraph", null, [schema.text("a"), schema.node("hardBreak")]),
354 ]);
355 const map = serializeEditorDoc(doc);
356 expect(flatToPm(map, map.docLength)).toBe(3);
357 expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true);
358 });
359​
360 it("renders a leading task dedent as siblings and nests under the new indent", () => {
361 const doc = ProseMirrorNode.fromJSON(
362 schema,
363 buildDocJson(" - [ ] first\n - [ ] second\n - [ ] child", (name) => ({
364 label: name,
365 description: null,
366 })),
367 );
368 const list = doc.firstChild!;
369 expect(list.childCount).toBe(2);
370 expect(list.child(0).childCount).toBe(1);
371 expect(list.child(1).child(1).firstChild!.textContent).toBe("child");
372 });
373​
374 it("applies a shared mark to text on both sides of a chip", () => {
375 const doc = ProseMirrorNode.fromJSON(
376 schema,
377 buildDocJson("**before @README.md after**", (name) => ({ label: name, description: null })),
378 );
379 expect(doc.firstChild!.childCount).toBe(3);
380 doc.firstChild!.forEach((child) =>
381 expect(child.marks.map((mark) => mark.type.name)).toContain("bold"),
382 );
383 expect(serializeEditorDoc(doc).value).toBe("**before @README.md after**");
384 });
385​
386 it.each([
387 [["bold"], ["bold", "italic"], ["italic"]],
388 [["italic"], ["bold", "italic"], ["bold"]],
389 [["bold"], ["bold", "strike"], ["strike"]],
390 [["strike"], ["bold", "strike"], ["bold"]],
391 ])("preserves crossing mark ranges %j through controlled rebuilds", (...marks) => {
392 const doc = schema.node("doc", null, [
393 schema.node(
394 "paragraph",
395 null,
396 marks.map((names, index) =>
397 schema.text(
398 String.fromCharCode(97 + index),
399 names.map((name) => schema.mark(name)),
400 ),
401 ),
402 ),
403 ]);
404 const serialized = serializeEditorDoc(doc).value;
405 const rebuilt = ProseMirrorNode.fromJSON(
406 schema,
407 buildDocJson(serialized, (name) => ({ label: name, description: null })),
408 );
409 expect(rebuilt.eq(doc)).toBe(true);
410 expect(serializeEditorDoc(rebuilt).value).toBe(serialized);
411 });
412​
413 it.each([
414 { parts: [{ text: "hello ", marks: ["bold"] }], expected: "**hello** " },
415 { parts: [{ text: " ", marks: ["bold"] }], expected: " " },
416 { parts: [{ text: " left ", marks: ["bold", "italic"] }], expected: " ***left*** " },
417 {
418 parts: [
419 { text: "one ", marks: ["bold"] },
420 { text: " two ", marks: ["bold", "italic"] },
421 { text: " three", marks: ["bold"] },
422 ],
423 expected: "**one *two* three**",
424 },
425 {
426 parts: [
427 { text: "hello ", marks: ["bold"] },
428 { text: "world ", marks: ["bold", "italic"] },
429 ],
430 expected: "**hello *world*** ",
431 },
432 { parts: [{ text: " hello ", marks: ["code"] }], expected: "` hello `" },
433 { parts: [{ text: " hello ", marks: ["bold", "code"] }], expected: "**` hello `**" },
434 { parts: [{ text: " ", marks: ["bold", "code"] }], expected: "**` `**" },
435 ])("keeps boundary whitespace outside emphasis in $expected", ({ parts, expected }) => {
436 const doc = schema.node("doc", null, [
437 schema.node(
438 "paragraph",
439 null,
440 parts.map(({ text, marks }) =>
441 schema.text(
442 text,
443 marks.map((name) => schema.mark(name)),
444 ),
445 ),
446 ),
447 ]);
448 const map = serializeEditorDoc(doc);
449 expect(map.value).toBe(expected);
450 const rebuilt = ProseMirrorNode.fromJSON(
451 schema,
452 buildDocJson(map.value, (name) => ({ label: name, description: null })),
453 );
454 expect(rebuilt.textContent).toBe(doc.textContent);
455 expect(serializeEditorDoc(rebuilt).value).toBe(map.value);
456 for (let flat = 0; flat <= map.docLength; flat += 1) {
457 expect(pmToFlat(map, flatToPm(map, flat))).toBe(flat);
458 expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat);
459 if (flat < map.docLength && !/\s/.test(doc.textContent[flat]!)) {
460 expect(rebuilt.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name)).toEqual(
461 doc.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name),
462 );
463 }
464 }
465 });
466​
467 it.each([
468 "plain text",
469 "hello **bold** stays literal",
470 "a *italic* stays literal",
471 "some `code` stays literal",
472 "struck ~~out~~ stays literal",
473 "- [ ] stays a paragraph",
474 "- [x] stays a paragraph",
475 "line one\nline two",
476 "@README.md explain this",
477 "**bold** then @README.md then *italic*",
478 ])("round-trips %s byte-identically in plain mode", (value) => {
479 expect(roundTripPlain(value).value).toBe(value);
480 });
481​
482 it.each([
483 "- one\n- two",
484 "* star\n* star two",
485 "+ plus",
486 "* star\n+ plus",
487 "1. first\n2. second",
488 "1) paren\n2) paren",
489 "3. starts at three\n4. four",
490 "01. zero padded\n02. two",
491 "- parent\n - child\n - sibling\n- uncle",
492 "1. ordered\n - bullet child\n2. next",
493 "- outer\n 1. inner ordered\n 2. more\n- outer again",
494 "- wide space item",
495 "-",
496 "1.",
497 "- \n- second",
498 "- **bold** item with @README.md",
499 "- [ ] task\n- bullet after",
500 "- bullet\n- [x] task after",
501 "para\n- item\npara",
502 "\t- tab indented\n\t- again",
503 " - leading indent only",
504 "- item\n\n- after a blank",
505 "- item one\n```ts\ncode\n```\n- item two",
506 "-no space stays literal",
507 "1.no space stays literal",
508 "10. ten\n11. eleven",
509 ])("round-trips the list %s through a real ProseMirror document", (value) => {
510 expect(roundTrip(value).value).toBe(value);
511 });
512​
513 it("clamps offsets inside a list marker to the start of the item text", () => {
514 const value = "- item";
515 const map = roundTrip(value);
516 for (let markdown = 0; markdown <= "- ".length; markdown += 1) {
517 expect(markdownToFlat(map, markdown)).toBe(0);
518 }
519 expect(markdownToFlat(map, "- it".length)).toBe(2);
520 expect(flatToMarkdown(map, 0)).toBe("- ".length);
521 });
522​
523 it.each([
524 "> quoted",
525 "> line one\n> line two",
526 ">no space",
527 "> two spaces",
528 "> a\n>b",
529 ">",
530 "> **bold** and @README.md inside",
531 "> - looks like a list but stays quote text",
532 "> > nested stays literal inside the quote",
533 "before\n> quoted",
534 "> quote\n\n> another",
535 "- item\n> quote after list",
536 "> quoted\n\ncomment",
537 "> line one\n> line two\n\ncomment",
538 ])("round-trips the quote %s through a real ProseMirror document", (value) => {
539 expect(roundTrip(value).value).toBe(value);
540 });
541​
542 describe("a paragraph after a quote", () => {
543 const paragraph = (text?: string) => ({
544 type: "paragraph",
545 ...(text ? { content: [{ type: "text", text }] } : {}),
546 });
547 const quote = (...lines: string[]) => ({
548 type: "blockquote",
549 attrs: { prefix: "> " },
550 content: lines.map((line) => paragraph(line)),
551 });
552 const serialize = (...content: object[]) => {
553 const doc = ProseMirrorNode.fromJSON(schema, { type: "doc", content });
554 doc.check();
555 return { doc, map: serializeEditorDoc(doc) };
556 };
557​
558 it("is written after a blank line, or Markdown folds it into the quote", () => {
559 expect(serialize(quote("quoted"), paragraph("Comment to it")).map.value).toBe(
560 "> quoted\n\nComment to it",
561 );
562 expect(serialize(quote("a", "b"), paragraph("comment")).map.value).toBe(
563 "> a\n> b\n\ncomment",
564 );
565 });
566​
567 it("does not double up on the empty line the user left themselves", () => {
568 expect(serialize(quote("quoted"), paragraph(), paragraph("Comment to it")).map.value).toBe(
569 "> quoted\n\nComment to it",
570 );
571 });
572​
573 it("settles on the blank line when a draft glued it to the quote", () => {
574 const settled = "before\n> quoted\n\nafter";
575 expect(roundTrip("before\n> quoted\nafter").value).toBe(settled);
576 expect(roundTrip(settled).value).toBe(settled);
577 });
578​
579 it.each(["# Heading", "- item", "3. item", "---", "```\ncode\n```"])(
580 "leaves %s on the next line, since it opens its own block",
581 (next) => {
582 const value = `> quoted\n${next}`;
583 expect(roundTrip(value).value).toBe(value);
584 },
585 );
586​
587 it("keeps every offset mapped through the blank line", () => {
588 const { doc, map } = serialize(quote("quoted"), paragraph("Comment"));
589 for (let flat = 0; flat <= map.docLength; flat += 1) {
590 expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat);
591 }
592 // The caret on the end of the quote stays on it, and the blank line is
593 // not a place to type: it clamps to the start of the comment.
594 expect(flatToMarkdown(map, "quoted".length)).toBe("> quoted".length);
595 expect(markdownToFlat(map, "> quoted\n".length)).toBe("quoted".length + 1);
596 expect(flatToMarkdown(map, "quoted".length + 1)).toBe("> quoted\n\n".length);
597 expect(flatToPm(map, "quoted".length + 1)).toBe(doc.child(0).nodeSize + 1);
598 });
599 });
600​
601 it.each([
602 "---",
603 "***",
604 "___",
605 "- - -",
606 "* * *",
607 "-----",
608 "--- ",
609 "text\n---\nmore",
610 "- item\n---\n- item two",
611 "```\n---\n```",
612 "--",
613 "-- -",
614 "---text",
615 ])("round-trips the rule %s through a real ProseMirror document", (value) => {
616 expect(roundTrip(value).value).toBe(value);
617 });
618​
619 it("parses rules ahead of lists and emphasis", () => {
620 const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({
621 label: n,
622 description: null,
623 }));
624 expect(json.content.map((block) => block.type)).toEqual([
625 "horizontalRule",
626 "horizontalRule",
627 "horizontalRule",
628 "paragraph",
629 "horizontalRule",
630 "bulletList",
631 ]);
632 });
633​
634 it.each([
635 "# Heading",
636 "## Two",
637 "###### Six",
638 "####### seven hashes stays a paragraph",
639 "# two spaces",
640 "#\tTab",
641 "# Trailing hashes stay literal #",
642 "#1234",
643 "#1234 is a pull request, not a heading",
644 "# Heading with **bold** and @README.md",
645 "#",
646 "# ",
647 "text\n# Heading\ntext",
648 "# Heading\n- item\n> quote\n---",
649 ])("round-trips the heading %s through a real ProseMirror document", (value) => {
650 expect(roundTrip(value).value).toBe(value);
651 });
652​
653 it.each([
654 ["---", "---"],
655 ["a\n---", "a\n---"],
656 ["---\n", "---"],
657 ])("gives the caret a line after the final rule of %j, written %j", (value, expected) => {
658 const map = roundTrip(value);
659 expect(map.value).toBe(expected);
660 const json = buildDocJson(value, () => ({ label: "", description: null }));
661 const doc = ProseMirrorNode.fromJSON(schema, json);
662 expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true);
663 });
664​
665 it.each([
666 "```\ncode\n```",
667 "```ts\nconst a = 1;\n```",
668 "```ts\nconst a = 1;\n```\n",
669 "before\n```ts\nconst a = 1;\n```\nafter",
670 "```\n```",
671 "```ts\nline one\nline two\nline three\n```",
672 "```ts\n indented\n deeper\n```",
673 "```js title=example\ncode\n```",
674 "~~~py\ncode\n~~~",
675 "````\n```\n````",
676 "```ts\ncode without a closing fence",
677 "```",
678 "```ts\n**not bold** and @README.md stay literal\n```",
679 "```ts\ncode\n````",
680 "- [ ] task\n```ts\ncode\n```\n- [ ] after",
681 " ```ts\nindented fence stays a paragraph\n ```",
682 ])("round-trips the fenced block %s", (value) => {
683 expect(roundTrip(value).value).toBe(value);
684 });
685​
686 it("reads blocks written with CRLF line endings", () => {
687 const value = "# Title\r\n> quote\r\n---\r\n```ts\r\nconst a = 1;\r\n```\r\nafter";
688 const doc = buildDocJson(value, () => ({ label: "", description: null }));
689 expect(doc.content.map((block) => block.type)).toEqual([
690 "heading",
691 "blockquote",
692 "horizontalRule",
693 "codeBlock",
694 "paragraph",
695 ]);
696 expect(roundTrip(value).value).toBe(value);
697 });
698​
699 it("keeps the caret inside an empty fence", () => {
700 const value = "before\n```\n```";
701 const map = roundTrip(value);
702 const inside = "before\n".length;
703 expect(flatToMarkdown(map, inside)).toBe("before\n```".length);
704 expect(markdownToFlat(map, flatToMarkdown(map, inside))).toBe(inside);
705 });
706​
707 it("clamps offsets inside a fence to the edge of the code", () => {
708 const value = "```ts\nab\n```";
709 const map = roundTrip(value);
710 expect(map.value).toBe(value);
711 for (let markdown = 0; markdown <= "```ts\n".length; markdown += 1) {
712 expect(markdownToFlat(map, markdown)).toBe(0);
713 }
714 expect(markdownToFlat(map, "```ts\na".length)).toBe(1);
715 for (let markdown = "```ts\nab".length; markdown <= value.length; markdown += 1) {
716 expect(markdownToFlat(map, markdown)).toBe(2);
717 }
718 });
719​
720 it("keeps a chip in a fence info string as source and keeps later chips aligned", () => {
721 const value = "```@README.md\ncode\n```\n$my-skill after";
722 expect(roundTrip(value).value).toBe(value);
723 const json = buildDocJson(value, (n) => ({ label: n, description: null }));
724 const after = json.content[1] as {
725 content: { type: string; attrs?: { skillName?: string } }[];
726 };
727 expect(after.content.map((n) => n.type)).toEqual(["composer-skill", "text"]);
728 expect(after.content[0]?.attrs?.skillName).toBe("my-skill");
729 });
730​
731 it("writes code holding a closing fence inside a longer fence", () => {
732 const doc = ProseMirrorNode.fromJSON(schema, {
733 type: "doc",
734 content: [
735 {
736 type: "codeBlock",
737 attrs: { fence: "```", close: "\n```" },
738 content: [{ type: "text", text: "initial\n```\nsecret" }],
739 },
740 ],
741 });
742 const stored = serializeEditorDoc(doc).value;
743 expect(stored).toBe("````\ninitial\n```\nsecret\n````");
744 expect(buildDocJson(stored, () => ({ label: "", description: null })).content).toHaveLength(1);
745 expect(roundTrip(stored).value).toBe(stored);
746 });
747​
748 it("closes an unclosed fence that has a block after it", () => {
749 const doc = ProseMirrorNode.fromJSON(schema, {
750 type: "doc",
751 content: [
752 { type: "codeBlock", attrs: { close: "" }, content: [{ type: "text", text: "code" }] },
753 { type: "paragraph", content: [{ type: "text", text: "after" }] },
754 ],
755 });
756 expect(serializeEditorDoc(doc).value).toBe("```\ncode\n```\nafter");
757 expect(roundTrip("```\ncode").value).toBe("```\ncode");
758 });
759​
760 it("keeps a chip's source in a fence as literal text", () => {
761 const value = "```\nsee @b.md and $babysit now model:codex/gpt-6.1 ok\n```\nafter";
762 const known = () => ({ label: "Babysit", description: null });
763 const anyModel = () => true;
764 const doc = ProseMirrorNode.fromJSON(
765 schema,
766 buildDocJson(value, known, { isKnownModel: anyModel }),
767 );
768 const map = serializeEditorDoc(doc);
769 expect(map.value).toBe(value);
770 expect(map.runs.filter((run) => run.kind === "token")).toEqual([]);
771 for (let flat = 0; flat <= map.docLength; flat += 1) {
772 expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat);
773 }
774 // Reconciling against the provider's skills and models leaves the code alone too.
775 expect(skillChipReplacements(doc, known, anyModel)).toEqual([]);
776 });
777​
778 it.each([
779 "plain prose with no styling at all",
780 "a chip @README.md counts one character in both modes",
781 "$my-skill leads the line",
782 "trailing newline\n",
783 ])("resolves a collapsed cursor identically in both modes for %s", (value) => {
784 const rich = roundTrip(value);
785 const plain = roundTripPlain(value);
786 expect(rich.value).toBe(value);
787 expect(plain.value).toBe(value);
788 for (let collapsed = 0; collapsed <= value.length; collapsed += 1) {
789 const markdown = expandCollapsedComposerCursor(value, collapsed);
790 expect(flatToMarkdown(rich, markdownToFlat(rich, markdown))).toBe(
791 flatToMarkdown(plain, markdownToFlat(plain, markdown)),
792 );
793 }
794 });
795​
796 it("clamps a cursor that was sitting inside a marker onto the styled text", () => {
797 const value = "a **bold** c";
798 const plain = roundTripPlain(value);
799 const rich = roundTrip(value);
800 expect(flatToMarkdown(plain, markdownToFlat(plain, 3))).toBe(3);
801 expect(flatToMarkdown(rich, markdownToFlat(rich, 3))).toBe(4);
802 });
803});
804​
805describe("serializeSelection", () => {
806 function copy(value: string, first: string, last: string) {
807 const doc = ProseMirrorNode.fromJSON(
808 schema,
809 buildDocJson(value, (name) => ({ label: name, description: null })),
810 );
811 let from = -1;
812 let to = -1;
813 doc.descendants((node, pos) => {
814 if (!node.isText) return true;
815 const text = node.text ?? "";
816 if (from < 0 && text.includes(first)) from = pos + text.indexOf(first);
817 if (text.includes(last)) to = pos + text.lastIndexOf(last) + last.length;
818 return false;
819 });
820 return serializeSelection(doc, from, to);
821 }
822​
823 it("keeps the markers of the list items it spans", () => {
824 expect(copy("- one\n- two\n- three", "one", "two")).toBe("- one\n- two");
825 expect(copy("3) one\n4) two", "one", "two")).toBe("3) one\n4) two");
826 expect(copy("- [ ] one\n- [x] two", "one", "two")).toBe("- [ ] one\n- [x] two");
827 });
828​
829 it("keeps the nesting of an item and its children", () => {
830 expect(copy("- p\n - a\n - b", "p", "a")).toBe("- p\n - a");
831 });
832});
833​
834describe("pasting into a list item or quote", () => {
835 it("keeps inline marks but builds no blocks", () => {
836 const content = buildTiptapContent(
837 "**bold** text\n- item\n> quote",
838 () => ({ label: "", description: null }),
839 { styling: true, blocks: false },
840 );
841 expect(content.map((block) => block.type)).toEqual(["paragraph", "paragraph", "paragraph"]);
842 const first = content[0] as { content: { text: string; marks?: { type: string }[] }[] };
843 expect(first.content[0]).toMatchObject({ text: "bold", marks: [{ type: "bold" }] });
844 expect(JSON.stringify(content[1])).toContain("- item");
845 expect(JSON.stringify(content[2])).toContain("> quote");
846 });
847});
848​
849describe("model mention chips", () => {
850 const skillLabelFor = (name: string) => ({ label: name, description: null });
851 const isCodexModel = (instanceId: string, model: string) =>
852 instanceId === "codex" && model === "gpt-6.1";
853 const modelAttrs = (doc: ProseMirrorNode) => {
854 const attrs: unknown[] = [];
855 doc.descendants((node) => {
856 if (node.type.name === "composer-model") attrs.push(node.attrs);
857 });
858 return attrs;
859 };
860 const replace = (doc: ProseMirrorNode, isKnownModel?: typeof isCodexModel) => {
861 const transaction = EditorState.create({ doc }).tr;
862 for (const { from, to, node } of skillChipReplacements(doc, skillLabelFor, isKnownModel)) {
863 transaction.replaceWith(from, to, node);
864 }
865 return transaction.doc;
866 };
867 const chip = { instanceId: "codex", model: "gpt-6.1", source: "model:codex/gpt-6.1" };
868​
869 it.each([
870 ["Ask model:codex/gpt-6.1 now", [chip]],
871 ["Ask model:openai/gpt-4o now", []],
872 // Still being typed: `gpt-6` must not chip on the way to `gpt-6.1`.
873 ["Ask model:codex/gpt-6.1", []],
874 ])("chips only a known reference followed by whitespace: %s", (value, expected) => {
875 const doc = ProseMirrorNode.fromJSON(
876 schema,
877 buildDocJson(value, skillLabelFor, { isKnownModel: isCodexModel }),
878 );
879 doc.check();
880 expect(modelAttrs(doc)).toEqual(expected);
881 expect(serializeEditorDoc(doc).value).toBe(value);
882 });
883​
884 it("reconciles references when the environment's models load or change", () => {
885 const value = "Ask model:codex/gpt-6.1 now";
886 const text = ProseMirrorNode.fromJSON(schema, buildDocJson(value, skillLabelFor));
887 expect(modelAttrs(text)).toEqual([]);
888 const loaded = replace(text, isCodexModel);
889 expect(modelAttrs(loaded)).toEqual([chip]);
890 expect(serializeEditorDoc(loaded).value).toBe(value);
891 const removed = replace(loaded);
892 expect(modelAttrs(removed)).toEqual([]);
893 expect(serializeEditorDoc(removed).value).toBe(value);
894 });
895});
896​
897describe("pasting into a path query", () => {
898 const skillLabelFor = (name: string) => ({ label: name, description: null });
899​
900 function paste(prompt: string | ProseMirrorNode, pasted: string, pathQueryActive = true) {
901 const promptDoc =
902 typeof prompt === "string"
903 ? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor))
904 : prompt;
905 const line = promptDoc.lastChild!;
906 const lineBefore = line.textBetween(0, line.content.size, undefined, "\uFFFC");
907 const literalLength = pastedPathQueryLength(lineBefore, pasted, pathQueryActive);
908 const blocks = buildTiptapContent(pasted, skillLabelFor, { literalLength });
909 const [first, ...rest] = blocks.map((block) => ProseMirrorNode.fromJSON(schema, block));
910 const head = promptDoc.lastChild!.content.append(first!.content);
911 const doc = schema.node("doc", null, [first!.copy(head), ...rest]);
912 doc.check();
913 return doc;
914 }
915​
916 function boldText(doc: ProseMirrorNode) {
917 const bold: string[] = [];
918 doc.descendants((node) => {
919 if (node.isText && node.marks.some((mark) => mark.type.name === "bold")) {
920 bold.push(node.text!);
921 }
922 });
923 return bold;
924 }
925​
926 it.each(["src/__test__.tsx", "src/__tests__/a.ts", "pkg/__init__.py", "a/**b**/c"])(
927 "keeps %s literal after @ so file search can match it",
928 (path) => {
929 const doc = paste("Open @", path);
930 expect(boldText(doc)).toEqual([]);
931 expect(serializeEditorDoc(doc).value).toBe(`Open @${path}`);
932 },
933 );
934​
935 it("still styles the text after the path", () => {
936 const doc = paste("Open @", "src/__test__.tsx and **this**\n- [ ] then __that__");
937 expect(boldText(doc)).toEqual(["this", "that"]);
938 expect(doc.lastChild!.type.name).toBe("taskList");
939 expect(serializeEditorDoc(doc).value).toBe(
940 "Open @src/__test__.tsx and **this**\n- [ ] then **that**",
941 );
942 });
943​
944 it("parses markdown after Escape dismisses the path query", () => {
945 const doc = paste("Open @", "__hello__ world", false);
946 expect(boldText(doc)).toEqual(["hello"]);
947 expect(serializeEditorDoc(doc).value).toBe("Open @**hello** world");
948 });
949​
950 it("keeps the rest of the path's line on that line", () => {
951 const doc = paste("Open @", "src/a.ts - not a list item");
952 expect(doc.childCount).toBe(1);
953 expect(serializeEditorDoc(doc).value).toBe("Open @src/a.ts - not a list item");
954 });
955​
956 it("parses markdown when the paste does not continue a path query", () => {
957 expect(boldText(paste("Open ", "src/__test__.tsx"))).toEqual(["test"]);
958 expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]);
959 expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]);
960 });
961​
962 it("keeps a path literal after a partly styled query", () => {
963 const doc = paste("Open @**src/**", "__test__.tsx");
964 expect(boldText(doc)).toEqual(["src/"]);
965 expect(serializeEditorDoc(doc).value).toBe("Open @**src/**__test__.tsx");
966 });
967​
968 // regression: #16619 paths pasted after @ lost their underscores to markdown parsing
969 it("parses markdown pasted right after a mention chip", () => {
970 const withSpace = ProseMirrorNode.fromJSON(
971 schema,
972 buildDocJson("Open @README.md ", skillLabelFor),
973 );
974 const paragraph = withSpace.firstChild!;
975 expect(paragraph.lastChild!.text).toBe(" ");
976 const chipLast = schema.node("doc", null, [
977 paragraph.copy(paragraph.content.cut(0, paragraph.content.size - 1)),
978 ]);
979 expect(chipLast.firstChild!.lastChild!.type.name).toBe("composer-mention");
980 expect(boldText(paste(chipLast, "__b__"))).toEqual(["b"]);
981 });
982});
983​
984describe("caret stops at styled edges", () => {
985 function stateAt(value: string, pmPos: number) {
986 const doc = ProseMirrorNode.fromJSON(
987 schema,
988 buildDocJson(value, (name) => ({ label: name, description: null })),
989 );
990 return EditorState.create({ doc, selection: TextSelection.create(doc, pmPos) });
991 }
992​
993 function typed(state: EditorState, text: string) {
994 return serializeEditorDoc(state.apply(state.tr.insertText(text)).doc).value;
995 }
996​
997 it("lets the caret step out in front of bold that starts the line", () => {
998 const inside = stateAt("**bold** tail", 1);
999 expect(caretTakesMarksBefore(inside)).toBe(false);
1000 expect(typed(inside, "x")).toBe("**xbold** tail");
1001​
1002 const step = stepCaretAcrossStyledEdge(inside, -1);
1003 expect(step).not.toBeNull();
1004 const outside = inside.apply(step!);
1005 expect(caretTakesMarksBefore(outside)).toBe(true);
1006 expect(typed(outside, "x")).toBe("x**bold** tail");
1007 expect(stepCaretAcrossStyledEdge(outside, -1)).toBeNull();
1008​
1009 const back = outside.apply(stepCaretAcrossStyledEdge(outside, 1)!);
1010 expect(typed(back, "x")).toBe("**xbold** tail");
1011 });
1012​
1013 it("lets the caret step out after bold that ends the line", () => {
1014 const inside = stateAt("head **bold**", 10);
1015 expect(caretTakesMarksBefore(inside)).toBe(true);
1016 expect(typed(inside, "x")).toBe("head **boldx**");
1017 expect(stepCaretAcrossStyledEdge(inside, -1)).toBeNull();
1018​
1019 const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!);
1020 expect(caretTakesMarksBefore(outside)).toBe(false);
1021 expect(typed(outside, "x")).toBe("head **bold**x");
1022 });
1023​
1024 // regression: #13217 could not type in front of bold that starts a composer line
1025 it("offers both stops where styled text meets plain text mid-line", () => {
1026 const plain = stateAt("a **b** c", 3);
1027 expect(caretTakesMarksBefore(plain)).toBe(true);
1028 expect(typed(plain, "x")).toBe("a x**b** c");
1029 const bold = plain.apply(stepCaretAcrossStyledEdge(plain, 1)!);
1030 expect(typed(bold, "x")).toBe("a **xb** c");
1031 });
1032​
1033 it("offers the plain stop between bold text and a chip right after it", () => {
1034 const doc = schema.node("doc", null, [
1035 schema.node("paragraph", null, [
1036 schema.text("bold", [schema.marks.bold!.create()]),
1037 schema.nodes["composer-mention"]!.create({ path: "README.md", source: "@README.md" }),
1038 ]),
1039 ]);
1040 const inside = EditorState.create({ doc, selection: TextSelection.create(doc, 5) });
1041 expect(caretTakesMarksBefore(inside)).toBe(true);
1042 const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!);
1043 expect(caretTakesMarksBefore(outside)).toBe(false);
1044 const withText = outside.apply(outside.tr.insertText("x")).doc;
1045 expect(withText.child(0).child(1).text).toBe("x");
1046 expect(withText.child(0).child(1).marks).toEqual([]);
1047 });
1048​
1049 it("steps out of inline code at the end of a line without inserting a space", () => {
1050 expect(ComposerCodeExtension.config.exitable).toBe(false);
1051 const inside = stateAt("`code`", 5);
1052 const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!);
1053 expect(typed(outside, "x")).toBe("`code`x");
1054 expect(stepCaretAcrossStyledEdge(outside, 1)).toBeNull();
1055 });
1056​
1057 it("keeps marks the user toggled at an edge and lets the arrow move", () => {
1058 const inside = stateAt("a **b** c", 3);
1059 const toggled = inside.apply(
1060 inside.tr.setStoredMarks([schema.marks.bold!.create(), schema.marks.italic!.create()]),
1061 );
1062 expect(stepCaretAcrossStyledEdge(toggled, 1)).toBeNull();
1063 expect(stepCaretAcrossStyledEdge(toggled, -1)).toBeNull();
1064 });
1065});
1066​
1067describe("inline code entry", () => {
1068 function editorFor(block = "paragraph", mark?: string) {
1069 const editor = new Editor({
1070 element: null,
1071 extensions: [
1072 StarterKit.configure({
1073 bold: false,
1074 code: false,
1075 codeBlock: false,
1076 italic: false,
1077 strike: false,
1078 trailingNode: false,
1079 }),
1080 ComposerBoldExtension,
1081 ComposerItalicExtension,
1082 ComposerStrikeExtension,
1083 ComposerCodeExtension,
1084 ComposerCodeBlockExtension,
1085 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),
1086 ],
1087 content: { type: "doc", content: [{ type: block }] },
1088 });
1089 // Unmounted editors need the same plugins that Tiptap installs on mount.
1090 editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins }));
1091 if (mark) editor.commands.setMark(mark);
1092 onTestFinished(() => editor.destroy());
1093 return editor;
1095​
1096 function type(editor: Editor, text: string) {
1097 for (const ch of text) {
1098 const { from, to } = editor.state.selection;
1099 const insert = () => editor.state.tr.insertText(ch, from, to);
1100 const handled = editor.state.plugins.some((plugin) =>
1101 plugin.props.handleTextInput?.call(plugin, editor.view, from, to, ch, insert),
1102 );
1103 if (!handled) editor.view.dispatch(insert());
1106​
1107 it.each(["", "this is a "])(
1108 "formats code after %j when the opening backtick is typed last",
1109 (prefix) => {
1110 const editor = editorFor();
1111 type(editor, `${prefix}test\` after`);
1112 editor.commands.setTextSelection(prefix.length + 1);
1113 type(editor, "`");
1114​
1115 expect(editor.state.doc.textContent).toBe(`${prefix}test after`);
1116 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`test\` after`);
1117 expect(editor.state.selection.from).toBe(prefix.length + 1);
1118 type(editor, "new ");
1119 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`new test\` after`);
1120 editor.commands.setTextSelection(editor.state.doc.content.size - 1);
1121 type(editor, " plain");
1122 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`new test\` after plain`);
1123 const formatted = editor.state.doc.toJSON();
1124 let undos = 0;
1125 while (editor.commands.undo()) undos += 1;
1126 expect(undos).toBeGreaterThan(0);
1127 expect(editor.state.doc.textContent).toBe("");
1128 for (let i = 0; i < undos; i += 1) expect(editor.commands.redo()).toBe(true);
1129 expect(editor.state.doc.toJSON()).toEqual(formatted);
1130 },
1131 );
1132​
1133 it.each([
1134 { name: "doubled closing ticks", text: "test``" },
1135 { name: "a code block", text: "test`", block: "codeBlock" },
1136 { name: "bold text", text: "test`", mark: "bold" },
1137 { name: "existing inline code", text: "test`", mark: "code" },
1138 ])("keeps an opening tick literal before $name", ({ text, block, mark }) => {
1139 const editor = editorFor(block, mark);
1140 type(editor, text);
1141 editor.commands.setTextSelection(1);
1142 type(editor, "`");
1143 expect(editor.state.doc.textContent).toBe(`\`${text}`);
1144 });
1145​
1146 it.each(["hardBreak", "composer-mention"])("does not pair ticks across a %s", (nodeType) => {
1147 const editor = editorFor();
1148 type(editor, "before");
1149 editor.commands.insertContent({ type: nodeType });
1150 type(editor, "after`");
1151 editor.commands.setTextSelection(1);
1152 type(editor, "`");
1153 expect(editor.state.doc.firstChild?.firstChild?.marks).toEqual([]);
1154 expect(editor.state.doc.textContent).toBe("`beforeafter`");
1155​
1156 const local = editorFor();
1157 type(local, "a ` ");
1158 local.commands.insertContent({ type: nodeType });
1159 type(local, "`local`");
1160 expect(local.state.doc.firstChild?.lastChild?.toJSON()).toEqual({
1161 type: "text",
1162 marks: [{ type: "code" }],
1163 text: "local",
1164 });
1165 });
1166​
1167 it.each(["", "prefix ", "@README.md", "a ``` "])(
1168 "formats a filled pair after %j, leaves following text plain, and keeps the normal input rule",
1169 (prefix) => {
1170 const editor = editorFor();
1171 if (prefix === "@README.md") {
1172 editor.commands.insertContent({
1173 type: "composer-mention",
1174 attrs: { path: "README.md", source: prefix },
1175 });
1176 } else {
1177 type(editor, prefix);
1179 const offset = editor.state.selection.from - 1;
1180 type(editor, "``");
1181 editor.commands.setTextSelection(offset + 2);
1182 type(editor, "hello world");
1183 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`hello world\``);
1184 expect(editor.state.doc.firstChild?.lastChild?.marks).toEqual([]);
1185​
1186 editor.commands.setTextSelection(offset + 14);
1187 expect(editor.state.doc.textContent).toBe(
1188 `${prefix === "@README.md" ? "" : prefix}hello world`,
1189 );
1190 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`hello world\``);
1191 type(editor, " next `typed`");
1192 expect(serializeEditorDoc(editor.state.doc).value).toBe(
1193 `${prefix}\`hello world\` next \`typed\``,
1194 );
1195 const formatted = editor.state.doc.toJSON();
1196 let undos = 0;
1197 while (editor.commands.undo()) undos += 1;
1198 expect(undos).toBeGreaterThan(0);
1199 expect(serializeEditorDoc(editor.state.doc).value).toBe("");
1200 for (let i = 0; i < undos; i += 1) expect(editor.commands.redo()).toBe(true);
1201 expect(editor.state.doc.toJSON()).toEqual(formatted);
1202 },
1203 );
1204​
1205 it.each([
1207 "formats a pair",
1208 "do ",
1210 { type: "text", text: "do " },
1211 { type: "text", marks: [{ type: "code" }], text: "xyz" },
1212 ],
1213 ],
1215 "keeps a pair literal after an unmatched backtick",
1216 "a ` ",
1217 [{ type: "text", text: "a ` `xyz`" }],
1218 ],
1219 ])("%s whose backticks came from a dead key", (_name, prefix, content) => {
1220 const editor = editorFor();
1221 type(editor, prefix);
1222 editor.view.dispatch(editor.state.tr.insertText("`").setMeta("composition", 1));
1223 editor.view.dispatch(editor.state.tr.insertText("`").setMeta("composition", 2));
1224 editor.commands.setTextSelection(prefix.length + 2);
1225 type(editor, "xyz");
1226 expect(editor.state.doc.textContent).toBe(`${prefix}\`xyz\``);
1227​
1228 editor.commands.setTextSelection(prefix.length + 6);
1229 expect(editor.state.doc.firstChild?.toJSON().content).toEqual(content);
1230 });
1231​
1232 it("does not reuse the closing backtick of a literal pair to open code", () => {
1233 const editor = editorFor();
1234 editor.view.dispatch(editor.state.tr.insertText("is `enabled`", 1));
1235 type(editor, " and `");
1236 expect(editor.state.doc.firstChild?.toJSON().content).toEqual([
1237 { type: "text", text: "is `enabled` and `" },
1238 ]);
1239​
1240 type(editor, "persist`");
1241 expect(editor.state.doc.firstChild?.toJSON().content).toEqual([
1242 { type: "text", text: "is `enabled` and " },
1243 { type: "text", marks: [{ type: "code" }], text: "persist" },
1244 ]);
1245 });
1246​
1247 it("keeps composition edits literal until the caret leaves the typed pair", () => {
1248 const editor = editorFor();
1249 type(editor, "``");
1250 editor.commands.setTextSelection(2);
1251 editor.view.dispatch(editor.state.tr.insertText("世").setMeta("composition", 1));
1252 editor.view.dispatch(editor.state.tr.insertText("世界", 2, 3).setMeta("composition", 1));
1253 expect(editor.state.doc.textContent).toBe("`世界`");
1254 expect(editor.state.doc.firstChild?.firstChild?.marks).toEqual([]);
1255 editor.commands.setTextSelection(5);
1256 expect(editor.state.doc.textContent).toBe("世界");
1257 expect(serializeEditorDoc(editor.state.doc).value).toBe("`世界`");
1258 });
1259​
1260 it.each(["paste", "setContent"])("keeps a %s pair literal after moving through it", (source) => {
1261 const editor = editorFor();
1262 const literal = "`literal`";
1263 if (source === "paste") {
1264 editor.view.dispatch(editor.state.tr.insertText(literal).setMeta("uiEvent", "paste"));
1265 } else {
1266 type(editor, "``");
1267 editor.commands.setContent({
1268 type: "doc",
1269 content: [{ type: "paragraph", content: [{ type: "text", text: literal }] }],
1270 });
1272 const before = editor.state.doc.toJSON();
1273 editor.commands.setTextSelection(2);
1274 editor.commands.setTextSelection(literal.length + 1);
1275 expect(editor.state.doc.toJSON()).toEqual(before);
1276 });
1277​
1278 it.each([
1279 { name: "doubled delimiters", delimiters: "````", caret: 3 },
1280 { name: "doubled closing delimiters", delimiters: "```", caret: 2 },
1281 { name: "bold text", delimiters: "``", caret: 2, mark: "bold" },
1282 { name: "existing inline code", delimiters: "``", caret: 2, mark: "code" },
1283 { name: "a code block", delimiters: "``", caret: 2, block: "codeBlock" },
1284 ])("leaves $name unchanged on caret movement", ({ delimiters, caret, mark, block }) => {
1285 const editor = editorFor(block, mark);
1286 type(editor, delimiters);
1287 editor.commands.setTextSelection(caret);
1288 type(editor, "literal");
1289 const before = editor.state.doc.toJSON();
1290 // Visit both the gap between closing ticks and the end of the pair.
1291 editor.commands.setTextSelection(caret + "literal".length + 1);
1292 expect(editor.state.doc.toJSON()).toEqual(before);
1293 editor.commands.setTextSelection(editor.state.doc.content.size - 1);
1294 expect(editor.state.doc.toJSON()).toEqual(before);
1295 });
1296​
1297 function marked(editor: Editor, name: string) {
1298 const text: string[] = [];
1299 editor.state.doc.descendants((node) => {
1300 if (node.isText && node.marks.some((mark) => mark.type.name === name)) {
1301 text.push(node.text ?? "");
1303 });
1304 return text;
1306​
1307 /** Types `text` into a backtick pair and steps the caret out so it formats. */
1308 function typeCode(editor: Editor, text: string) {
1309 const offset = editor.state.selection.from;
1310 type(editor, "``");
1311 editor.commands.setTextSelection(offset + 1);
1312 type(editor, text);
1313 editor.commands.setTextSelection(offset + text.length + 2);
1314 expect(marked(editor, "code")).toContain(text);
1316​
1317 it.each([
1318 { mark: "italic", open: "*" },
1319 { mark: "bold", open: "**" },
1320 { mark: "strike", open: "~~" },
1321 ])("keeps a $mark delimiter inside an earlier code span literal", ({ mark, open }) => {
1322 const editor = editorFor();
1323 typeCode(editor, `${open}foo`);
1324 type(editor, ` bar \`${open}`);
1325 expect(serializeEditorDoc(editor.state.doc).value).toBe(`\`${open}foo\` bar \`${open}`);
1326 expect(marked(editor, mark)).toEqual([]);
1327 expect(marked(editor, "code")).toEqual([`${open}foo`]);
1328 });
1329​
1330 it.each([
1331 { mark: "italic", open: "*" },
1332 { mark: "bold", open: "**" },
1333 { mark: "strike", open: "~~" },
1334 ])("still wraps a code span in $mark when both delimiters are outside it", ({ mark, open }) => {
1335 const editor = editorFor();
1336 type(editor, open);
1337 typeCode(editor, "foo");
1338 type(editor, ` bar${open}`);
1339 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${open}\`foo\` bar${open}`);
1340 expect(marked(editor, mark)).toEqual(["foo", " bar"]);
1341​
1342 editor.commands.setContent({
1343 type: "doc",
1344 content: [
1346 type: "paragraph",
1347 content: [
1348 { type: "text", text: `${open}foo tail ` },
1349 { type: "text", text: "x", marks: [{ type: "code" }] },
1350 ],
1351 },
1352 ],
1353 });
1354 editor.commands.setTextSelection({
1355 from: open.length + 4,
1356 to: editor.state.doc.content.size - 1,
1357 });
1358 type(editor, open);
1359 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${open}foo${open}`);
1360 expect(marked(editor, mark)).toEqual(["foo"]);
1361 });
1362});
1363​
1364describe("surroundSelectionWithMark", () => {
1365 it("keeps markers inside math literal across draft restoration", () => {
1366 const doc = schema.node("doc", null, [schema.node("paragraph", null, schema.text("$x$"))]);
1367 const state = EditorState.create({ doc, selection: TextSelection.create(doc, 2, 3) });
1368 expect(surroundSelectionWithMark(state, 2, 3, "*")).toBeNull();
1369 });
1370​
1371 it.each([
1372 ["`", "hello", "say `hello` now"],
1373 ["*", "hello", "say *hello* now"],
1374 ["_", "hello", "say *hello* now"],
1375 ["`", " ", "say ` ` now"],
1376 ])("formats a selection with %s like typed markers (%j)", (marker, selected, expected) => {
1377 const doc = schema.node("doc", null, [
1378 schema.node("paragraph", null, schema.text(`say ${selected} now`)),
1379 ]);
1380 const to = 5 + selected.length;
1381 const state = EditorState.create({ doc, selection: TextSelection.create(doc, 5, to) });
1382 const tr = surroundSelectionWithMark(state, 5, to, marker);
1383 expect(tr).not.toBeNull();
1384 const formatted = state.apply(tr!);
1385 const typed = ProseMirrorNode.fromJSON(
1386 schema,
1387 buildDocJson(`say ${marker}${selected}${marker} now`, (name) => ({
1388 label: name,
1389 description: null,
1390 })),
1391 );
1392 expect(formatted.doc.eq(typed)).toBe(true);
1393 expect(serializeEditorDoc(formatted.doc).value).toBe(expected);
1394 expect(formatted.doc.textBetween(formatted.selection.from, formatted.selection.to)).toBe(
1395 selected,
1396 );
1397 });
1398​
1399 it.each([
1400 ["*", " "],
1401 ["_", " "],
1402 ["*", "a*b"],
1403 ["`", "foo`bar"],
1404 ])("leaves %s around %j to literal wrapping when the mark cannot round-trip", (marker, text) => {
1405 const doc = schema.node("doc", null, [schema.node("paragraph", null, schema.text(text))]);
1406 const to = 1 + text.length;
1407 const state = EditorState.create({ doc, selection: TextSelection.create(doc, 1, to) });
1408 expect(surroundSelectionWithMark(state, 1, to, marker)).toBeNull();
1409 });
1410​
1411 it.each([
1413 "plain mode",
1414 plainSchema.node("doc", null, [plainSchema.node("paragraph", null, plainSchema.text("one"))]),
1415 ],
1417 "paragraphs",
1418 schema.node("doc", null, [
1419 schema.node("paragraph", null, schema.text("one")),
1420 schema.node("paragraph", null, schema.text("two")),
1421 ]),
1422 ],
1424 "a hard break",
1425 schema.node("doc", null, [
1426 schema.node("paragraph", null, [
1427 schema.text("one"),
1428 schema.node("hardBreak"),
1429 schema.text("two"),
1430 ]),
1431 ]),
1432 ],
1434 "a code block",
1435 schema.node("doc", null, [schema.node("codeBlock", null, schema.text("one"))]),
1436 ],
1437 ])("leaves selections in %s to literal wrapping", (_, doc) => {
1438 doc.check();
1439 const state = EditorState.create({ doc });
1440 expect(surroundSelectionWithMark(state, 1, doc.content.size - 1, "`")).toBeNull();
1441 });
1442});