Skip to content
Source
Sign inSign up

ComposerPromptEditorTiptap.input.browser.test.tsx

959 lines35.4 KB

1import type { ComposerFileAttachment } from "../composerDraftStore";
2import { formatInlineContextReference } from "../lib/composerContextReferences";
3import { fileContextReference } from "../lib/composerContextRecords";
4import {
5 reconcileAttachmentContextReferences,
6 type RetainedContextPayloads,
7 useRetainedContextPayloads,
8} from "./chat/composerContextUndo";
9​
10import type { ServerProviderSkill } from "@t3tools/contracts";
11import type { Editor } from "@tiptap/core";
12import { redoDepth, undoDepth } from "@tiptap/pm/history";
13import { NodeSelection } from "@tiptap/pm/state";
14import { createElement, createRef, useEffect, useState } from "react";
15import { describe, expect, it, vi } from "vite-plus/test";
16import { userEvent } from "vite-plus/test/browser";
17​
18import { act, fakeTimers, render } from "../../test/render";
19​
20import { flatToMarkdown, pmToFlat, serializeEditorDoc } from "~/composer-rich-text-doc";
21import { isMacPlatform } from "~/lib/utils";
22import { registerCommands } from "~/shortcuts/dispatcher";
23import {
24 ComposerPromptEditorTiptap as ComposerPromptEditor,
25 type ComposerPromptEditorHandle,
26 type ComposerPromptEditorProps,
27} from "./ComposerPromptEditorTiptap";
28​
29vi.mock(import("../state/server"), async (original) => {
30 const { Atom } = await import("effect/reactivity");
31 const { compileResolvedKeybindingsConfig, DEFAULT_KEYBINDINGS } =
32 await import("@t3tools/shared/keybindings");
33 return {
34 ...(await original()),
35 primaryServerKeybindingsAtom: Atom.make(
36 compileResolvedKeybindingsConfig([
37 ...DEFAULT_KEYBINDINGS,
38 { key: "mod+arrowup", command: "thread.previous" },
39 { key: "mod+arrowdown", command: "thread.next" },
40 ]),
41 ),
42 };
43});
44​
45const babysit: ServerProviderSkill = {
46 name: "babysit",
47 path: "/skills/babysit/SKILL.md",
48 enabled: true,
49};
50​
51function Composer({
52 value,
53 richTextEnabled,
54 skills,
55 onChange,
56 onCommandKeyDown = () => false,
57}: {
58 value: string;
59 richTextEnabled: boolean;
60 skills: ReadonlyArray<ServerProviderSkill>;
61 onChange?: ComposerPromptEditorProps["onChange"] | undefined;
62 onCommandKeyDown?: ComposerPromptEditorProps["onCommandKeyDown"] | undefined;
63}) {
64 const [prompt, setPrompt] = useState({ value, cursor: value.length });
65 return (
66 <ComposerPromptEditor
67 editorRef={createRef<ComposerPromptEditorHandle>()}
68 value={prompt.value}
69 cursor={prompt.cursor}
70 richTextEnabled={richTextEnabled}
71 contextRecords={new Map()}
72 skills={skills}
73 disabled={false}
74 placeholder=""
75 onChange={(...args) => {
76 setPrompt({ value: args[0], cursor: args[1] });
77 onChange?.(...args);
78 }}
79 onPaste={() => {}}
80 onCommandKeyDown={onCommandKeyDown}
81 />
82 );
84​
85function composer(
86 value: string,
87 richTextEnabled = true,
88 skills: ReadonlyArray<ServerProviderSkill> = [],
89 onChange?: ComposerPromptEditorProps["onChange"],
90 onCommandKeyDown?: ComposerPromptEditorProps["onCommandKeyDown"],
91) {
92 return (
93 <Composer
94 value={value}
95 richTextEnabled={richTextEnabled}
96 skills={skills}
97 onChange={onChange}
98 onCommandKeyDown={onCommandKeyDown}
99 />
100 );
102​
103function editorIn(container: HTMLElement) {
104 const element = container.querySelector<HTMLElement & { editor: Editor }>(".tiptap");
105 if (!element) throw new Error("Composer did not mount");
106 return element.editor;
108​
109async function mountEditor(...props: Parameters<typeof composer>) {
110 await using stack = new AsyncDisposableStack();
111 const screen = stack.use(await render(composer(...props)));
112 const editor = editorIn(screen.container);
113 const environment = stack.move();
114 return Object.assign(screen, {
115 [Symbol.asyncDispose]: () => environment.disposeAsync(),
116 editor,
117 /** Rerenders the same composer with new props, as a parent would. */
118 update: async (...next: Parameters<typeof composer>) => {
119 await screen.rerender(composer(...next));
120 return editorIn(screen.container);
121 },
122 });
124​
125async function replaceDomText(editor: Editor, text: string, isComposing = false) {
126 await act(async () => {
127 const node = editor.view.dom.querySelector("p")?.firstChild;
128 if (!(node instanceof Text)) throw new Error("Composition text is missing");
129 node.data = text;
130 window.getSelection()?.collapse(node, text.length);
131 if (isComposing) {
132 editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: text }));
133 }
134 editor.view.dom.dispatchEvent(
135 new InputEvent("input", {
136 inputType: isComposing ? "insertCompositionText" : "insertText",
137 data: text,
138 isComposing,
139 }),
140 );
141 });
143​
144function typeText(editor: Editor, from: number, to: number, text: string) {
145 const insert = () => editor.state.tr.insertText(text, from, to);
146 if (
147 !editor.view.someProp("handleTextInput", (handler) =>
148 handler(editor.view, from, to, text, insert),
149 )
150 ) {
151 editor.view.dispatch(insert());
152 }
154​
155function textWithMark(editor: Editor, name: string) {
156 const text: string[] = [];
157 editor.state.doc.descendants((node) => {
158 if (node.isText && node.marks.some((mark) => mark.type.name === name)) {
159 text.push(node.textContent);
160 }
161 });
162 return text;
164​
165it.each([
166 { value: "clean _build and node", code: [] },
167 { value: "clean `_build` and `node", code: ["_build"] },
168])("keeps a typed underscore literal after $value", async ({ value, code }) => {
169 await using screen = await mountEditor(value);
170 const { editor } = screen;
171 await act(async () => {
172 editor.commands.focus("end");
173 const { from, to } = editor.state.selection;
174 typeText(editor, from, to, "_");
175 });
176 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${value}_`);
177 expect(textWithMark(editor, "italic")).toEqual([]);
178 expect(textWithMark(editor, "code")).toEqual(code);
179});
180​
181it.each([
182 { mark: "bold", key: "b", marker: "**" },
183 { mark: "italic", key: "i", marker: "*" },
184])("toggles $mark with Mod+$key at a collapsed cursor", async ({ mark, key, marker }) => {
185 await using screen = await mountEditor("ship it");
186 const { editor } = screen;
187 const mod = isMacPlatform(navigator.platform) ? "Meta" : "Control";
188 const chord = `{${mod}>}${key}{/${mod}}`;
189 await act(async () => {
190 editor.commands.focus("end");
191 });
192 await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
193 await userEvent.keyboard(" ");
194 await userEvent.keyboard(chord);
195 await userEvent.keyboard("now");
196 await userEvent.keyboard(chord);
197 await userEvent.keyboard(" done");
198 await vi.waitFor(() =>
199 expect(serializeEditorDoc(editor.state.doc).value).toBe(`ship it ${marker}now${marker} done`),
200 );
201 expect(textWithMark(editor, mark)).toEqual(["now"]);
202});
203​
204it.each([
205 { key: "i", caret: 10, expected: "ship it **n*X*ow** done" },
206 { key: "i", caret: 9, expected: "ship it *X***now** done" },
207 { key: "e", caret: "{ArrowLeft>5/}", expected: "ship it **now**`X` done" },
208])(
209 "Mod+$key at $caret in a bold word styles the next typed text",
210 async ({ key, caret, expected }) => {
211 await using screen = await mountEditor("ship it **now** done");
212 const { editor } = screen;
213 const mod = isMacPlatform(navigator.platform) ? "Meta" : "Control";
214 // ProseMirror rewrites the DOM caret 20ms after focus. Run that now, or it
215 // can undo the first arrow key before ProseMirror reads the native move.
216 {
217 using _timers = fakeTimers();
218 await act(async () => {
219 if (typeof caret === "number") editor.commands.focus(caret);
220 else editor.commands.focus("end");
221 });
222 await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
223 vi.runOnlyPendingTimers();
224 }
225 if (typeof caret === "string") await userEvent.keyboard(caret);
226 await userEvent.keyboard(`{${mod}>}${key}{/${mod}}`);
227 await userEvent.keyboard("X");
228 await vi.waitFor(() => expect(serializeEditorDoc(editor.state.doc).value).toBe(expected));
229 },
230);
231​
232it.each([
233 { word: "ship", prefix: "", source: ["**ship**"] },
234 { word: "path\\", prefix: "", source: [] },
235 { word: "2*3", prefix: "", source: [] },
236 { word: "b", prefix: "*", source: [] },
237 { word: "word", prefix: "\\\\*", source: [] },
238 { word: "word", prefix: "\\\\**", source: [] },
239 { word: "word", prefix: "\\", source: [] },
240 { word: "word", prefix: "\\\\", source: ["**word**"] },
241 { word: "word", prefix: "\\\\\\", source: [] },
242 {
243 word: "word",
244 prefix: "`path\\`",
245 prefixText: "path\\",
246 source: ["**word**"],
247 code: ["path\\"],
248 },
249])(
250 "bolds $word after '$prefix' with Mod+B over a selection",
251 async ({ word, prefix, prefixText = prefix, source, code = [] }) => {
252 const value = `${prefix}${word} it`;
253 await using screen = await mountEditor(value);
254 const { editor } = screen;
255 const modB = isMacPlatform(navigator.platform) ? "{Meta>}b{/Meta}" : "{Control>}b{/Control}";
256 await act(async () => {
257 editor.commands.focus("end");
258 });
259 await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
260 await act(async () => {
261 editor.commands.setTextSelection({
262 from: prefixText.length + 1,
263 to: prefixText.length + word.length + 1,
264 });
265 });
266 await userEvent.keyboard(modB);
267 await vi.waitFor(() =>
268 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}**${word}** it`),
269 );
270 expect(textWithMark(editor, "markdownSource")).toEqual(source);
271 const depth = undoDepth(editor.state);
272 await userEvent.keyboard("{End}");
273 expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}**${word}** it`);
274 expect(textWithMark(editor, "bold")).toEqual([word]);
275 expect(textWithMark(editor, "code")).toEqual(code);
276 expect(undoDepth(editor.state)).toBe(depth);
277 await act(async () => {
278 editor.commands.setTextSelection({
279 from: prefixText.length + 1,
280 to: prefixText.length + word.length + 1,
281 });
282 });
283​
284 // Whether the selection spans shown markers or not, Mod+B removes the bold.
285 await userEvent.keyboard(modB);
286 await vi.waitFor(() => expect(serializeEditorDoc(editor.state.doc).value).toBe(value));
287 },
288);
289​
290// The mocked server keybindings bind Command-Up and Command-Down to thread navigation.
291it("leaves Command-arrow thread navigation bindings to the app on macOS", async () => {
292 using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
293 await using screen = await mountEditor("first\nsecond");
294 const { editor } = screen;
295 await act(async () => {
296 editor.commands.focus("end");
297 });
298 await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
299 const reachedApp: string[] = [];
300 const onWindowKeyDown = (event: KeyboardEvent) => {
301 if (!event.defaultPrevented && event.key.startsWith("Arrow")) reachedApp.push(event.key);
302 };
303 window.addEventListener("keydown", onWindowKeyDown);
304 using _listener = {
305 [Symbol.dispose]: () => window.removeEventListener("keydown", onWindowKeyDown),
306 };
307​
308 await userEvent.keyboard("{Meta>}{ArrowUp}{ArrowDown}{/Meta}");
309​
310 expect(reachedApp).toEqual(["ArrowUp", "ArrowDown"]);
311});
312​
313it.each([
314 ["e", false, false],
315 ["i", false, false],
316 ["e", true, false],
317 ["e", false, true],
318])(
319 "leaves Mod+%s to an app command bound to it instead of formatting (composer command: %s, declined capture command: %s)",
320 async (key, composerClaimsKey, declinedCapture) => {
321 const openUsage = vi.fn((event: KeyboardEvent) => {
322 if (declinedCapture) return false;
323 event.preventDefault();
324 });
325 using _commands = {
326 [Symbol.dispose]: registerCommands(
327 { "usage.open": openUsage },
328 {
329 keybindings: () => [
330 {
331 command: "usage.open",
332 shortcut: {
333 key,
334 metaKey: false,
335 ctrlKey: false,
336 shiftKey: false,
337 altKey: false,
338 modKey: true,
339 },
340 },
341 ],
342 capture: declinedCapture,
343 },
344 ),
345 };
346 const composerCommand = vi.fn(() => composerClaimsKey);
347 await using screen = await mountEditor("word", true, [], undefined, composerCommand);
348 const { editor } = screen;
349 await act(async () => {
350 editor.commands.focus();
351 editor.commands.setTextSelection({ from: 1, to: 5 });
352 });
353 await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
354 const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control";
355 await userEvent.keyboard(`{${modifier}>}${key}{/${modifier}}`);
356 expect(composerCommand).toHaveBeenCalled();
357 expect(openUsage).toHaveBeenCalledTimes(composerClaimsKey ? 0 : 1);
358 expect(serializeEditorDoc(editor.state.doc).value).toBe(declinedCapture ? "`word`" : "word");
359 },
360);
361​
362it.each([false, true])(
363 "composes an umlaut without wrapping the preedit text (rich text: %s)",
364 async (richTextEnabled) => {
365 // The middle dot stands in for the provisional text shown by the Compose key.
366 await using screen = await mountEditor("·", richTextEnabled);
367 const { editor } = screen;
368 await act(async () => {
369 editor.view.focus();
370 editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
371 });
372 await replaceDomText(editor, '"', true);
373 expect(serializeEditorDoc(editor.state.doc).value).toBe('"');
374 await replaceDomText(editor, "ö", true);
375 await act(async () => {
376 editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: "ö" }));
377 });
378 expect(serializeEditorDoc(editor.state.doc).value).toBe("ö");
379 },
380);
381​
382it("lets composition replace selected text without surrounding it", async () => {
383 await using screen = await mountEditor("word");
384 const { editor } = screen;
385 await act(async () => {
386 editor.commands.setTextSelection({ from: 1, to: 5 });
387 editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
388 typeText(editor, 1, 5, '"');
389 });
390 expect(serializeEditorDoc(editor.state.doc).value).toBe('"');
391});
392​
393it.each([
394 { at: "inside bold", selection: 5, composed: "**bo漢ld** after", bold: ["bo漢ld"] },
395 {
396 at: "over the opening markers",
397 selection: { from: 1, to: 3 },
398 composed: "漢bold** after",
399 bold: [],
400 },
401 { at: "after the closing markers", selection: 9, composed: "**bold**漢 after", bold: ["bold"] },
402])("undoes composition $at in revealed Markdown through the earlier paste", async (row) => {
403 await using screen = await mountEditor("");
404 const { editor } = screen;
405 const value = () => serializeEditorDoc(editor.state.doc).value;
406 await act(async () => {
407 editor.view.focus();
408 const clipboardData = new DataTransfer();
409 clipboardData.setData("text/plain", "**bold** after");
410 editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true }));
411 editor.commands.insertContent("!");
412 editor.commands.undo();
413 editor.commands.setTextSelection(3);
414 });
415 expect(value()).toBe("**bold** after");
416 expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]);
417 await act(async () => {
418 editor.commands.setTextSelection(row.selection);
419 // A composition canceled before it edits anything keeps the redo and adds no undo.
420 editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
421 editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: "" }));
422 });
423 expect([undoDepth(editor.state), redoDepth(editor.state)]).toEqual([1, 1]);
424 await act(async () => {
425 editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
426 // ProseMirror numbers each composition; the canceled one was 1.
427 editor.view.dispatch(editor.state.tr.insertText("漢").setMeta("composition", 2));
428 editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: "漢" }));
429 editor.commands.setTextSelection(editor.state.doc.content.size - 1);
430 });
431 expect(value()).toBe(row.composed);
432 await act(async () => {
433 editor.commands.undo();
434 });
435 expect(value()).toBe("**bold** after");
436 await act(async () => {
437 editor.commands.undo();
438 });
439 expect(value()).toBe("");
440 await act(async () => {
441 editor.commands.redo();
442 editor.commands.redo();
443 editor.commands.setTextSelection(editor.state.doc.content.size - 1);
444 });
445 expect(value()).toBe(row.composed);
446 expect(textWithMark(editor, "bold")).toEqual(row.bold);
447});
448​
449it("keeps unknown dollar names editable when restoring and pasting a draft", async () => {
450 const value = "echo $HOME $PATH $anything $notaskill $20 $20k $babysit ";
451 await using screen = await mountEditor(value, true, [babysit]);
452 const { editor } = screen;
453 expect(editor.state.doc.textContent).toBe(value.replace("$babysit", ""));
454 expect(serializeEditorDoc(editor.state.doc).value).toBe(value);
455 await act(async () => {
456 editor.commands.setTextSelection("echo $HO".length + 1);
457 editor.commands.insertContent("X");
458 });
459 expect(serializeEditorDoc(editor.state.doc).value).toBe(value.replace("$HOME", "$HOXME"));
460 await act(async () => {
461 editor.commands.selectAll();
462 const clipboardData = new DataTransfer();
463 clipboardData.setData("text/plain", value);
464 editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true }));
465 });
466 expect(editor.state.doc.textContent).toBe(value.replace("$babysit", ""));
467 expect(serializeEditorDoc(editor.state.doc).value).toBe(value);
468});
469​
470it.each(["x\r\n\r\ny", "x\r\ry"])(
471 "pastes %j with the same blank line as x\\n\\ny",
472 async (text) => {
473 await using screen = await mountEditor("");
474 const { editor } = screen;
475 await act(async () => {
476 editor.commands.focus("end");
477 const clipboardData = new DataTransfer();
478 clipboardData.setData("text/plain", text);
479 editor.view.dom.dispatchEvent(
480 new ClipboardEvent("paste", { clipboardData, cancelable: true }),
481 );
482 });
483 const paragraphs: string[] = [];
484 editor.state.doc.forEach((node) => paragraphs.push(node.textContent));
485 expect(paragraphs).toEqual(["x", "", "y"]);
486 expect(serializeEditorDoc(editor.state.doc).value).toBe("x\n\ny");
487 },
488);
489​
490it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => {
491 const value = "echo $HOME now";
492 await using screen = await mountEditor(value);
493 let { editor } = screen;
494 await act(async () => {
495 editor.commands.setTextSelection("echo $HO".length + 1);
496 });
497 editor = await screen.update(value, false);
498 await act(async () => {
499 editor.commands.insertContent("X");
500 });
501 expect(serializeEditorDoc(editor.state.doc).value).toBe("echo $HOXME now");
502 editor = await screen.update(value, true);
503 await act(async () => {
504 editor.commands.insertContent("Y");
505 });
506 expect(serializeEditorDoc(editor.state.doc).value).toBe("echo $HOXYME now");
507});
508​
509async function focusAt(editor: Editor, position: number | "end") {
510 // ProseMirror rewrites the DOM caret 20ms after focus. Run that now, or it
511 // can undo the first key before ProseMirror reads the native move.
512 using _timers = fakeTimers();
513 await act(async () => {
514 editor.commands.focus(position);
515 });
516 await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
517 vi.runOnlyPendingTimers();
519​
520describe("Markdown markers around styled text", () => {
521 it.each([
522 { keys: "{Backspace}", expected: "**** after" },
523 { keys: "X", expected: "**X** after" },
524 ])("keeps a styled chip selected for $keys when showing source", async ({ keys, expected }) => {
525 await using screen = await mountEditor("$babysit after", true, [babysit]);
526 const { editor } = screen;
527 await focusAt(editor, "end");
528 await act(async () => {
529 editor.view.dispatch(editor.state.tr.addMark(1, 2, editor.schema.marks.bold!.create()));
530 });
531 expect(editor.state.doc.nodeAt(1)?.type.name).toBe("composer-skill");
532 await act(async () => {
533 editor.commands.setNodeSelection(1);
534 });
535 expect(editor.state.selection).toBeInstanceOf(NodeSelection);
536 expect((editor.state.selection as NodeSelection).node.type.name).toBe("composer-skill");
537 await userEvent.keyboard(keys);
538 await userEvent.keyboard("{End}");
539 await vi.waitFor(() => expect(serializeEditorDoc(editor.state.doc).value).toBe(expected));
540 });
541​
542 it.each([
543 {
544 name: "deleting the opening markers removes bold",
545 value: "**bold** after",
546 caret: 1,
547 keys: "{Delete}{Delete}",
548 expected: "bold** after",
549 marks: {},
550 },
551 {
552 name: "turning ** into * makes it italic",
553 value: "**bold** after",
554 caret: 1,
555 keys: "{Delete}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{Delete}",
556 expected: "*bold* after",
557 marks: { italic: ["bold"] },
558 },
559 {
560 name: "backspacing from the closing edge keeps italic",
561 value: "**bold** after",
562 caret: 1,
563 keys: async (editor: Editor) => {
564 await userEvent.keyboard("{Delete}");
565 await vi.waitFor(() => expect(editor.state.doc.textContent).toBe("*bold** after"));
566 for (let position = 2; position <= 8; position += 1) {
567 await userEvent.keyboard("{ArrowRight}");
568 await vi.waitFor(() => expect(editor.state.selection.from).toBe(position));
569 }
570 await userEvent.keyboard("{Backspace}");
571 await vi.waitFor(() =>
572 expect(serializeEditorDoc(editor.state.doc).value).toBe("*bold* after"),
573 );
574 },
575 expected: "*bold* after",
576 marks: { italic: ["bold"] },
577 },
578 {
579 name: "retyping deleted closing markers restores bold",
580 value: "**bold** after",
581 caret: 3,
582 keys: "{ArrowRight}{ArrowRight}{Delete}{Delete}**",
583 expected: "**bold** after",
584 marks: { bold: ["bold"] },
585 },
586 {
587 name: "deleting a backtick removes inline code",
588 value: "`code` after",
589 caret: 1,
590 keys: "{Delete}",
591 expected: "code` after",
592 marks: {},
593 },
594 {
595 name: "a line break inside bold keeps both halves bold",
596 value: "**bold** after",
597 caret: 3,
598 keys: "{Shift>}{Enter}{/Shift}",
599 expected: "**bo**\n**ld** after",
600 marks: { bold: ["bo", "ld"] },
601 },
602 {
603 name: "splitting a list item inside bold keeps both halves bold",
604 value: "- **bold** after",
605 caret: 5,
606 keys: "{Enter}",
607 expected: "- **bo**\n- **ld** after",
608 marks: { bold: ["bo", "ld"] },
609 },
610 ])("edits the markers as text: $name", async ({ value, caret, keys, expected, marks }) => {
611 await using screen = await mountEditor(value);
612 const { editor } = screen;
613 // Position 1 is the start of the styled word; 3 is two characters into it (5 in a list item).
614 await focusAt(editor, caret);
615 if (typeof keys === "string") await userEvent.keyboard(keys);
616 else await keys(editor);
617 await userEvent.keyboard("{End}");
618 await vi.waitFor(() => expect(serializeEditorDoc(editor.state.doc).value).toBe(expected));
619 for (const name of ["bold", "italic", "code"] as const) {
620 expect(textWithMark(editor, name)).toEqual(marks[name as keyof typeof marks] ?? []);
621 }
622 });
623​
624 it("shows nested formatting as Markdown and restores it when the caret leaves", async () => {
625 const value = "**a *b* c** d";
626 await using screen = await mountEditor(value);
627 const { editor } = screen;
628 await focusAt(editor, "a b".length + 1);
629 expect(editor.state.doc.textContent).toBe(value);
630 expect(textWithMark(editor, "bold")).toEqual([]);
631 await userEvent.keyboard("{End}");
632 await vi.waitFor(() => expect(editor.state.doc.textContent).toBe("a b c d"));
633 expect(textWithMark(editor, "bold")).toEqual(["a ", "b", " c"]);
634 expect(textWithMark(editor, "italic")).toEqual(["b"]);
635 expect(serializeEditorDoc(editor.state.doc).value).toBe(value);
636 });
637​
638 it("undoes a marker edit in one step without an extra step for showing markers", async () => {
639 await using screen = await mountEditor("**bold** after");
640 const { editor } = screen;
641 await focusAt(editor, "end");
642 await userEvent.keyboard("!");
643 await act(async () => {
644 editor.commands.setTextSelection(1);
645 });
646 await userEvent.keyboard("{Delete}{Delete}{End}");
647 const value = () => serializeEditorDoc(editor.state.doc).value;
648 await vi.waitFor(() => expect(value()).toBe("bold** after!"));
649 await act(async () => {
650 editor.commands.undo();
651 });
652 expect(value()).toBe("**bold** after!");
653 expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]);
654 const end = () => editor.state.doc.content.size - 1;
655 // Undo restored the markers as text; leaving hides them without touching the text undo needs.
656 await act(async () => {
657 editor.commands.setTextSelection(end());
658 });
659 expect(value()).toBe("**bold** after!");
660 const hidden = editor.view.dom.querySelectorAll(".composer-rich-marker-hidden");
661 expect(Array.from(hidden, (marker) => marker.textContent)).toEqual(["**", "**"]);
662 await act(async () => {
663 editor.commands.undo();
664 });
665 expect(value()).toBe("**bold** after");
666 // Moving the caret into styled text keeps redo, and adds no undo step after it.
667 await act(async () => {
668 editor.commands.setTextSelection(3);
669 });
670 expect(editor.state.doc.textContent).toBe("**bold** after");
671 expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]);
672 await act(async () => {
673 editor.commands.redo();
674 });
675 expect(value()).toBe("**bold** after!");
676 await act(async () => {
677 editor.commands.setTextSelection(1);
678 });
679 await act(async () => {
680 editor.commands.redo();
681 });
682 expect(value()).toBe("bold** after!");
683 expect(textWithMark(editor, "bold")).toEqual([]);
684 await act(async () => {
685 editor.commands.undo();
686 editor.commands.undo();
687 });
688 await act(async () => {
689 editor.commands.redo();
690 });
691 await act(async () => {
692 editor.commands.setTextSelection(editor.state.doc.content.size - 1);
693 });
694 await act(async () => {
695 editor.commands.undo();
696 });
697 // Undo puts the caret back inside the styled text.
698 expect(editor.state.doc.textContent).toBe("**bold** after");
699 await act(async () => {
700 editor.commands.setTextSelection(end());
701 });
702 expect(editor.state.doc.textContent).toBe("bold after");
703 expect(textWithMark(editor, "bold")).toEqual(["bold"]);
704 // After a completed redo, markers still follow the caret, and Delete edits the opening one.
705 await userEvent.keyboard("?");
706 await act(async () => {
707 editor.commands.undo();
708 });
709 await act(async () => {
710 editor.commands.redo();
711 });
712 expect(editor.state.doc.textContent).toBe("bold after?");
713 await act(async () => {
714 editor.commands.setTextSelection(1);
715 });
716 expect(editor.state.doc.textContent).toBe("**bold** after?");
717 expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]);
718 await act(async () => {
719 editor.commands.setTextSelection(end());
720 });
721 expect(editor.state.doc.textContent).toBe("bold after?");
722 expect(textWithMark(editor, "bold")).toEqual(["bold"]);
723 await act(async () => {
724 editor.commands.setTextSelection(1);
725 });
726 await userEvent.keyboard("{Delete}");
727 expect(value()).toBe("*bold** after?");
728 await act(async () => {
729 editor.commands.undo();
730 });
731 expect(value()).toBe("**bold** after?");
732 expect(textWithMark(editor, "markdownSource")).toEqual(["**bold**"]);
733 await act(async () => {
734 editor.commands.setTextSelection(end());
735 });
736 expect(editor.state.doc.textContent).toBe("bold after?");
737 expect(textWithMark(editor, "bold")).toEqual(["bold"]);
738 });
739​
740 it("keeps bold around a skill pasted inside it", async () => {
741 await using screen = await mountEditor("**helloworld** after", true, [babysit]);
742 const { editor } = screen;
743 await focusAt(editor, "hello".length + 1);
744 await act(async () => {
745 const clipboardData = new DataTransfer();
746 clipboardData.setData("text/plain", "$babysit");
747 editor.view.dom.dispatchEvent(
748 new ClipboardEvent("paste", { clipboardData, cancelable: true }),
749 );
750 });
751 await userEvent.keyboard("{End}");
752 await vi.waitFor(() =>
753 expect(serializeEditorDoc(editor.state.doc).value).toBe("**hello** $babysit **world** after"),
754 );
755 expect(textWithMark(editor, "bold")).toEqual(["hello", "world"]);
756 });
757});
758​
759const file = {
760 type: "file",
761 id: "file-1",
762 name: "notes.txt",
763 mimeType: "text/plain",
764 sizeBytes: 5,
765 file: new File(["notes"], "notes.txt", { type: "text/plain" }),
766} satisfies ComposerFileAttachment;
767​
768describe("composer context undo lifetime", () => {
769 async function mountComposer(destinationValue = "") {
770 const initialValue = `Draft A ${formatInlineContextReference(fileContextReference(file))}`;
771 const editorRef = createRef<ComposerPromptEditorHandle>();
772 let current: { value: string; files: ComposerFileAttachment[] };
773 let retained: RetainedContextPayloads;
774​
775 function Composer({ targetKey }: { targetKey: string }) {
776 const [drafts, setDrafts] = useState<
777 Record<string, { value: string; files: ComposerFileAttachment[] }>
778 >({
779 a: { value: initialValue, files: [file] },
780 b: { value: destinationValue, files: [] },
781 });
782 const payloads = useRetainedContextPayloads(targetKey);
783 const draft = drafts[targetKey]!;
784 useEffect(() => {
785 current = draft;
786 retained = payloads;
787 });
788 const editorProps = {
789 draftKey: targetKey,
790 editorRef,
791 value: draft.value,
792 cursor: 0,
793 contextRecords: new Map(
794 draft.files.map((entry) => [
795 fileContextReference(entry).contextId,
796 { kind: "file" as const, record: entry },
797 ]),
798 ),
799 skills: [],
800 disabled: false,
801 placeholder: "",
802 onChange: (value, _cursor, _expandedCursor, _adjacent, contextIds) => {
803 const changes = reconcileAttachmentContextReferences({
804 referencedContextIds: new Set(contextIds),
805 files: draft.files,
806 images: [],
807 previewAnnotations: [],
808 retained: payloads,
809 });
810 setDrafts({
811 ...drafts,
812 [targetKey]: {
813 value,
814 files: [
815 ...draft.files.filter((entry) => !changes.filesToRemove.includes(entry.id)),
816 ...changes.filesToRestore,
817 ],
818 },
819 });
820 },
821 onPaste: () => {},
822 } satisfies ComposerPromptEditorProps;
823 return createElement(ComposerPromptEditor, editorProps);
824 }
825​
826 await using stack = new AsyncDisposableStack();
827 const screen = stack.use(await render(createElement(Composer, { targetKey: "a" })));
828 const editor = () => editorIn(screen.container);
829 const environment = stack.move();
830 return Object.assign(screen, {
831 [Symbol.asyncDispose]: () => environment.disposeAsync(),
832 initialValue,
833 show: (targetKey: string) => screen.rerender(createElement(Composer, { targetKey })),
834 editor,
835 draft: () => current,
836 retained: () => retained,
837 deleteDraft: async () => {
838 await act(async () => {
839 const currentEditor = editor();
840 currentEditor.view.dispatch(
841 currentEditor.state.tr.delete(1, currentEditor.state.doc.content.size - 1),
842 );
843 });
844 },
845 undo: async () => {
846 await act(async () => {
847 editor().commands.undo();
848 });
849 },
850 });
851 }
852​
853 it("restores a deleted chip and its file bytes on same-draft undo, then releases them on unmount", async () => {
854 await using composer = await mountComposer();
855 await composer.deleteDraft();
856 expect(composer.draft()).toEqual({ value: "", files: [] });
857 const retained = composer.retained();
858 expect(retained.files.get(fileContextReference(file).contextId)?.file).toBe(file.file);
859​
860 await composer.show("a");
861 await composer.undo();
862 expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(composer.initialValue);
863 expect(composer.draft()).toEqual({ value: composer.initialValue, files: [file] });
864 expect(composer.draft().files[0]?.file).toBe(file.file);
865​
866 await composer.deleteDraft();
867 await composer.unmount();
868 expect(retained.files.size).toBe(0);
869 });
870​
871 it.each(["", "Draft B"])(
872 "releases deleted payloads and prevents undo into the previous draft when switching to %j",
873 async (destinationValue) => {
874 await using composer = await mountComposer(destinationValue);
875 await composer.deleteDraft();
876 expect(composer.draft()).toEqual({ value: "", files: [] });
877 const retained = composer.retained();
878 expect(retained.files.get(fileContextReference(file).contextId)?.file).toBe(file.file);
879​
880 await composer.show("b");
881 expect(retained.files.size).toBe(0);
882 await composer.undo();
883 expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(destinationValue);
884 expect(composer.draft()).toEqual({ value: destinationValue, files: [] });
885​
886 await composer.show("a");
887 await composer.undo();
888 expect(serializeEditorDoc(composer.editor().state.doc).value).toBe("");
889 expect(composer.draft()).toEqual({ value: "", files: [] });
890 },
891 );
892});
893​
894describe("an Arabic line", () => {
895 async function focusEnd(editor: Editor) {
896 // ProseMirror rewrites the DOM caret 20ms after focus. Run that now, or it
897 // can undo the first arrow key before ProseMirror reads the native move.
898 using _timers = fakeTimers();
899 await act(async () => {
900 editor.commands.focus("end");
901 });
902 await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
903 vi.runOnlyPendingTimers();
904 }
905​
906 it("walks back through inline code and its backticks with ArrowRight", async () => {
907 const value = "اكتب `sumBy` الان";
908 await using screen = await mountEditor(value);
909 const { editor } = screen;
910 await focusEnd(editor);
911 const offset = () => {
912 const map = serializeEditorDoc(editor.state.doc);
913 return flatToMarkdown(map, pmToFlat(map, editor.state.selection.head));
914 };
915 const visited = [offset()];
916 let sawSource = false;
917 while (visited.at(-1)! > "اكتب".length) {
918 const before = visited.at(-1)!;
919 await userEvent.keyboard("{ArrowRight}");
920 await vi.waitFor(() => expect(offset()).toBe(before - 1));
921 sawSource ||= editor.state.doc.textContent === value;
922 visited.push(offset());
923 }
924 expect(sawSource).toBe(true);
925 expect(textWithMark(editor, "code")).toEqual(["sumBy"]);
926 });
927​
928 it("shows the backticks on either side of inline code it is editing", async () => {
929 await using screen = await mountEditor("اكتب `sumBy` الان");
930 const { editor } = screen;
931 await act(async () => {
932 editor.commands.setTextSelection("اكتب su".length + 1);
933 });
934 const code = editor.view.dom.querySelector("code")!;
935 const text = [...code.childNodes].find((node) => node instanceof Text)!;
936 expect(text.data).toBe("`sumBy`");
937 const rect = (from: number, to: number) => {
938 const range = document.createRange();
939 range.setStart(text, from);
940 range.setEnd(text, to);
941 return range.getBoundingClientRect();
942 };
943 const [open, word, close] = [rect(0, 1), rect(1, 6), rect(6, 7)];
944 expect(open.right).toBeLessThanOrEqual(word.left + 1);
945 expect(close.left).toBeGreaterThanOrEqual(word.right - 1);
946 });
947​
948 it("starts the next line on the right", async () => {
949 await using screen = await mountEditor("اكتب الدالة");
950 const { editor } = screen;
951 await focusEnd(editor);
952 await userEvent.keyboard("{Shift>}{Enter}{/Shift}");
953 await vi.waitFor(() => expect(editor.state.doc.childCount).toBe(2));
954 const box = editor.view.dom.getBoundingClientRect();
955 expect(editor.view.coordsAtPos(editor.state.selection.head).left).toBeGreaterThan(
956 box.left + box.width / 2,
957 );
958 });
959});