# Files changed · PR \#21360 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/21360/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): surround selected text with dead-key quotes](https://git.cafe/pingdotgg/t3code/pull/21360.md)

Version: 2

Head commit: 18e33fb223ed2994ec0a5a5001e240155f46a958

Base commit: ca4d58305400ee2628d03c6f967834140031607f

Comparison base: ca4d58305400ee2628d03c6f967834140031607f

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx

Status: modified; +192; −1

```diff
@@ -17,6 +17,7 @@
 });
 
 afterEach(async () => {
+  vi.useRealTimers();
   await act(async () => root.unmount());
   container.remove();
   vi.unstubAllGlobals();
@@ -64,7 +65,10 @@
 }
 
 function typeText(editor: Editor, from: number, to: number, text: string) {
+  const insert = () => {
+    const tr = editor.state.tr.insertText(text, from, to);
+    return editor.view.composing ? tr.setMeta("composition", 1) : tr;
-  const insert = () => editor.state.tr.insertText(text, from, to);
+  };
   if (
     !editor.view.someProp("handleTextInput", (handler) =>
       handler(editor.view, from, to, text, insert),
@@ -74,6 +78,19 @@
   }
 }
 
+function dispatchComposition(
+  editor: Editor,
+  type: "compositionstart" | "compositionend",
+  data = "",
+) {
+  editor.view.dom.dispatchEvent(new CompositionEvent(type, { bubbles: true, data }));
+}
+
+async function finishComposition(editor: Editor, text: string) {
+  dispatchComposition(editor, "compositionend", text);
+  await vi.advanceTimersByTimeAsync(0);
+}
+
 it.each([false, true])(
   "composes an umlaut without wrapping the preedit text (rich text: %s)",
   async (richTextEnabled) => {
@@ -135,3 +152,177 @@
     expect(serializeEditorDoc(editor.state.doc).value).toBe('"word"');
   },
 );
+
+it.each([
+  { quote: "'", richTextEnabled: true, key: "Dead" },
+  { quote: '"', richTextEnabled: true, key: "Dead" },
+  { quote: "'", richTextEnabled: false, key: "Dead" },
+  { quote: "'", richTextEnabled: true, key: "Process" },
+  { quote: '"', richTextEnabled: true, key: "Process" },
+])(
+  "surrounds the original selection after dead-key composition: %j",
+  async ({ quote, richTextEnabled, key }) => {
+    const editor = await mountEditor("before word after", richTextEnabled);
+    vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+    await act(async () => {
+      editor.commands.setTextSelection({ from: 8, to: 12 });
+      editor.view.dom.dispatchEvent(
+        new KeyboardEvent("keydown", {
+          key,
+          code: "Quote",
+          keyCode: key === "Process" ? 229 : 222,
+          bubbles: true,
+        }),
+      );
+      expect(editor.state.selection.empty).toBe(true);
+      dispatchComposition(editor, "compositionstart", "word");
+      editor.view.dispatch(
+        editor.state.tr.insertText(quote === "'" ? "´" : "¨").setMeta("composition", 1),
+      );
+      expect(editor.state.doc.textContent).toBe(`before ${quote === "'" ? "´" : "¨"}word after`);
+      typeText(editor, 8, 9, quote);
+      expect(editor.state.doc.textContent).toBe(`before ${quote}word after`);
+      await finishComposition(editor, quote);
+    });
+    expect(serializeEditorDoc(editor.state.doc).value).toBe(`before ${quote}word${quote} after`);
+    expect(
+      editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to),
+    ).toBe("word");
+    await act(async () => {
+      editor.commands.undo();
+    });
+    expect(serializeEditorDoc(editor.state.doc).value).toBe("before word after");
+    await act(async () => {
+      editor.commands.redo();
+    });
+    expect(serializeEditorDoc(editor.state.doc).value).toBe(`before ${quote}word${quote} after`);
+  },
+);
+
+it("lets an accented letter replace the selection", async () => {
+  const editor = await mountEditor("word", true);
+  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+  await act(async () => {
+    editor.commands.setTextSelection({ from: 1, to: 5 });
+    editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true }));
+    dispatchComposition(editor, "compositionstart");
+    editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1));
+    typeText(editor, 1, 2, "é");
+    await finishComposition(editor, "é");
+    typeText(editor, 2, 2, "'");
+  });
+  expect(serializeEditorDoc(editor.state.doc).value).toBe("é'");
+  expect(editor.state.selection.empty).toBe(true);
+});
+
+it.each([
+  { text: "'", expected: "'word'", selected: "word" },
+  { text: '"', expected: '"word"', selected: "word" },
+  { text: "é", expected: "é", selected: "" },
+])("resolves dead keys without composition events: %j", async ({ text, expected, selected }) => {
+  const editor = await mountEditor("word", true);
+  await act(async () => {
+    editor.commands.setTextSelection({ from: 1, to: 5 });
+    editor.view.dom.dispatchEvent(
+      new KeyboardEvent("keydown", { key: "Dead", code: "Quote", bubbles: true }),
+    );
+    typeText(editor, 1, 1, text);
+  });
+  expect(editor.state.doc.textContent).toBe(expected);
+  expect(editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)).toBe(
+    selected,
+  );
+  await act(async () => {
+    editor.commands.undo();
+  });
+  expect(editor.state.doc.textContent).toBe("word");
+});
+
+it("does not restore a composition selection after the draft is replaced", async () => {
+  const editor = await mountEditor("word", true);
+  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+  await act(async () => {
+    editor.commands.setTextSelection({ from: 1, to: 5 });
+    editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true }));
+    dispatchComposition(editor, "compositionstart");
+    editor.view.dispatch(editor.state.tr.insertText("'").setMeta("composition", 1));
+    dispatchComposition(editor, "compositionend", "'");
+    editor.commands.setContent("new draft");
+    await vi.advanceTimersByTimeAsync(0);
+  });
+  expect(serializeEditorDoc(editor.state.doc).value).toBe("new draft");
+});
+
+it("keeps the original selection when dead-key composition is cancelled", async () => {
+  const editor = await mountEditor("word", true);
+  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+  await act(async () => {
+    editor.commands.setTextSelection({ from: 1, to: 5 });
+    editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true }));
+    dispatchComposition(editor, "compositionstart");
+    editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1));
+    typeText(editor, 1, 2, "");
+    await finishComposition(editor, "");
+  });
+  expect(serializeEditorDoc(editor.state.doc).value).toBe("word");
+  expect(editor.state.selection.from).toBe(1);
+  expect(editor.state.selection.to).toBe(5);
+});
+
+it("leaves other input methods free to replace selected text during composition", async () => {
+  const editor = await mountEditor("word", true);
+  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+  await act(async () => {
+    editor.commands.setTextSelection({ from: 1, to: 5 });
+    dispatchComposition(editor, "compositionstart");
+    editor.view.dispatch(editor.state.tr.insertText("字").setMeta("composition", 1));
+    expect(editor.state.doc.textContent).toBe("字");
+    await finishComposition(editor, "字");
+  });
+  expect(serializeEditorDoc(editor.state.doc).value).toBe("字");
+});
+
+it("keeps select-all text in place while a dead key resolves", async () => {
+  const editor = await mountEditor("word", true);
+  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+  await act(async () => {
+    editor.commands.selectAll();
+    editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true }));
+    dispatchComposition(editor, "compositionstart");
+    expect(editor.state.selection.from).toBe(1);
+    editor.view.dispatch(editor.state.tr.insertText("´").setMeta("composition", 1));
+    expect(editor.state.doc.textContent).toBe("´word");
+    typeText(editor, 1, 2, "'");
+    await finishComposition(editor, "'");
+  });
+  expect(serializeEditorDoc(editor.state.doc).value).toBe("'word'");
+});
+
+it("does not reuse a dead-key selection after the caret moves away and back", async () => {
+  const editor = await mountEditor("word");
+  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+  await act(async () => {
+    editor.commands.setTextSelection({ from: 1, to: 5 });
+    editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true }));
+    editor.commands.setTextSelection(5);
+    editor.commands.setTextSelection(1);
+    dispatchComposition(editor, "compositionstart");
+    typeText(editor, 1, 1, "'");
+    await finishComposition(editor, "'");
+  });
+  expect(editor.state.doc.textContent).toBe("'word");
+  expect(editor.state.selection.empty).toBe(true);
+});
+
+it("lets a dead key replace a selected node", async () => {
+  const editor = await mountEditor("word");
+  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+  await act(async () => {
+    editor.commands.setNodeSelection(0);
+    editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true }));
+    dispatchComposition(editor, "compositionstart");
+    typeText(editor, editor.state.selection.from, editor.state.selection.to, "'");
+    await finishComposition(editor, "'");
+  });
+  expect(editor.state.doc.textContent).toBe("'");
+});
```

## apps/web/src/components/ComposerPromptEditorTiptap.tsx

Status: modified; +125; −28

```diff
@@ -13,6 +13,7 @@
 import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands";
 import {
   type EditorState,
+  AllSelection,
   NodeSelection,
   Plugin,
   PluginKey,
@@ -224,6 +225,24 @@
   _: "_",
 };
 
+function canSurroundComposerRange(doc: ProseMirrorNode, from: number, to: number) {
+  if (from === to) return false;
+  // Wrapping styled text or inline chips would discard their formatting or context.
+  let touchesSpecial = false;
+  doc.nodesBetween(from, to, (node) => {
+    if ((node.isAtom && node.isInline && !node.isText) || (node.isText && node.marks.length > 0)) {
+      touchesSpecial = true;
+      return false;
+    }
+    return true;
+  });
+  if (touchesSpecial) return false;
+  const map = serializeEditorDoc(doc);
+  const startMd = flatToMarkdown(map, pmToFlat(map, from));
+  const endMd = flatToMarkdown(map, pmToFlat(map, to));
+  return !selectionTouchesMentionBoundary(map.value, startMd, endMd);
+}
+
 function resolvedThemeFromDocument(): "light" | "dark" {
   return document.documentElement.classList.contains("dark") ? "dark" : "light";
 }
@@ -928,6 +947,11 @@
   const importFragmentRef = useRef(importContextFragment);
   const latestValueRef = useRef(value);
   const editorHolder = useRef<TiptapEditor | null>(null);
+  const deadKeySelectionRef = useRef<{
+    selection: TextSelection;
+    inputState: EditorState;
+    compositionId: number | null;
+  } | null>(null);
 
   useEffect(() => {
     onChangeRef.current = onChange;
@@ -1105,8 +1129,77 @@
       editable: !disabled,
       editorProps: {
         attributes: editorAttributes,
+        handleDOMEvents: {
+          compositionstart: (view) => {
+            const pending = deadKeySelectionRef.current;
+            if (pending?.inputState !== view.state) {
+              deadKeySelectionRef.current = null;
+            }
+            return false;
+          },
+          compositionend: (view, event) => {
+            const previous = deadKeySelectionRef.current;
+            const text = event.data;
+            if (!previous) return false;
+            const closer = text.length === 1 ? SURROUND_CLOSE[text] : undefined;
+            // Like Tiptap's input rules, wait for ProseMirror to commit the composition.
+            setTimeout(() => {
+              if (deadKeySelectionRef.current !== previous) return;
+              deadKeySelectionRef.current = null;
+              if (view.isDestroyed || view.composing) return;
+              const { from, to } = previous.selection;
+              const committed = previous.inputState.tr.insertText(text, from, from);
+              if (
+                !view.state.doc.eq(committed.doc) ||
+                !view.state.selection.eq(committed.selection)
+              )
+                return;
+              const tr = view.state.tr;
+              const start = from + text.length;
+              const end = to + text.length;
+              const replaceSelection = text.length > 0 && !closer;
+              if (closer) tr.insertText(closer, end);
+              else if (replaceSelection) tr.delete(start, end);
+              tr.setSelection(TextSelection.create(tr.doc, start, replaceSelection ? start : end));
+              if (previous.compositionId !== null)
+                tr.setMeta("composition", previous.compositionId);
+              view.dispatch(tr);
+            });
+            return false;
+          },
+        },
         handleKeyDown: (view, event) => {
           if (
+            (event.key === "Dead" ||
+              (event.key === "Process" &&
+                (event.code === "Quote" || event.code === "Backquote"))) &&
+            !literalText &&
+            !view.composing
+          ) {
+            const { state } = view;
+            const selection =
+              state.selection instanceof AllSelection
+                ? TextSelection.between(state.selection.$from, state.selection.$to)
+                : state.selection;
+            if (
+              selection instanceof TextSelection &&
+              canSurroundComposerRange(state.doc, selection.from, selection.to)
+            ) {
+              // Wayland reports Process on keydown and Dead only on keyup. Collapse
+              // before composition starts so native input leaves the selected text in place.
+              view.dispatch(
+                view.state.tr.setSelection(TextSelection.create(view.state.doc, selection.from)),
+              );
+              deadKeySelectionRef.current = {
+                selection,
+                inputState: view.state,
+                compositionId: null,
+              };
+            } else {
+              deadKeySelectionRef.current = null;
+            }
+          }
+          if (
             isMacPlatform(navigator.platform) &&
             (event.key === "Home" || event.key === "End") &&
             !event.altKey &&
@@ -1296,39 +1389,38 @@
           return handled;
         },
         handleTextInput: (view, from, to, text) => {
-          if (literalText || view.composing || text.length !== 1) return false;
-          const closer = SURROUND_CLOSE[text];
-          const { selection } = view.state;
+          if (literalText || view.composing) return false;
+          const pending = deadKeySelectionRef.current;
+          // Windows dead keys commit text without starting an IME composition.
+          const preserved =
+            pending &&
+            view.state === pending.inputState &&
+            from === to &&
+            from === pending.selection.from
+              ? pending.selection
+              : null;
+          if (preserved) deadKeySelectionRef.current = null;
+          const closer = text.length === 1 ? SURROUND_CLOSE[text] : undefined;
+          if (!closer) {
+            if (!preserved) return false;
+            view.dispatch(view.state.tr.insertText(text, preserved.from, preserved.to));
-          // Compose preedit updates replace a range even without a user selection.
+            return true;
+          }
+          const selection = preserved ?? view.state.selection;
+          // A preedit replacement range is not necessarily the user's selection.
           if (
-            !closer ||
             !(selection instanceof TextSelection) ||
-            selection.empty ||
-            selection.from !== from ||
-            selection.to !== to
+            (!preserved && (selection.from !== from || selection.to !== to)) ||
+            !canSurroundComposerRange(view.state.doc, selection.from, selection.to)
           ) {
             return false;
           }
-          // Never wrap chips or other atoms, and never wrap styled text: the
-          // default replace keeps marks intact, wrapping would drop them.
-          let touchesSpecial = false;
-          view.state.doc.nodesBetween(from, to, (node) => {
-            if (
-              (node.isAtom && node.isInline && !node.isText) ||
-              (node.isText && node.marks.length > 0)
-            ) {
-              touchesSpecial = true;
-              return false;
-            }
-            return true;
-          });
+          const tr = view.state.tr
-          if (touchesSpecial) return false;
-          const map = serializeEditorDoc(view.state.doc);
-          const startMd = flatToMarkdown(map, pmToFlat(map, from));
+            .insertText(closer, selection.to)
+            .insertText(text, selection.from);
+          tr.setSelection(
+            TextSelection.create(tr.doc, selection.from + text.length, selection.to + text.length),
+          );
-          const endMd = flatToMarkdown(map, pmToFlat(map, to));
-          if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false;
-          const tr = view.state.tr.insertText(closer, to).insertText(text, from);
-          tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length));
           view.dispatch(tr);
           return true;
         },
@@ -1417,6 +1509,11 @@
           return true;
         },
       },
+      onTransaction: ({ transaction }) => {
+        const pending = deadKeySelectionRef.current;
+        const compositionId = transaction.getMeta("composition");
+        if (pending && typeof compositionId === "number") pending.compositionId = compositionId;
+      },
       onUpdate: ({ editor: updated }) => {
         handleEditorChange(updated);
       },
```
