Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ async function replaceDomText(editor: Editor, text: string, isComposing = false)
}

function typeText(editor: Editor, from: number, to: number, text: string) {
const insert = () => editor.state.tr.insertText(text, from, to);
const insert = () => {
const tr = editor.state.tr.insertText(text, from, to);
return editor.view.composing ? tr.setMeta("composition", 1) : tr;
};
if (
!editor.view.someProp("handleTextInput", (handler) =>
handler(editor.view, from, to, text, insert),
Expand All @@ -123,6 +126,19 @@ function typeText(editor: Editor, from: number, to: number, text: string) {
}
}

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);
}

function textWithMark(editor: Editor, name: string) {
const text: string[] = [];
editor.state.doc.descendants((node) => {
Expand Down Expand Up @@ -437,3 +453,202 @@ describe("an Arabic line", () => {
);
});
});

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 }) => {
await using screen = await mountEditor("before word after", richTextEnabled);
const { editor } = screen;
using _timers = fakeTimers();
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 () => {
await using screen = await mountEditor("word", true);
const { editor } = screen;
using _timers = fakeTimers();
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 }) => {
await using screen = await mountEditor("word", true);
const { editor } = screen;
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 () => {
await using screen = await mountEditor("word", true);
const { editor } = screen;
using _timers = fakeTimers();
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 () => {
await using screen = await mountEditor("word", true);
const { editor } = screen;
using _timers = fakeTimers();
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 () => {
await using screen = await mountEditor("word", true);
const { editor } = screen;
using _timers = fakeTimers();
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 () => {
await using screen = await mountEditor("word", true);
const { editor } = screen;
using _timers = fakeTimers();
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 () => {
await using screen = await mountEditor("word");
const { editor } = screen;
using _timers = fakeTimers();
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 () => {
await using screen = await mountEditor("word");
const { editor } = screen;
using _timers = fakeTimers();
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("'");
});

it("surrounds a selection when a dead-key quote commits", async () => {
await using screen = await mountEditor("word");
const { editor } = screen;
using _timers = fakeTimers();
await act(async () => {
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Dead", bubbles: true }));
editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
const { from, to } = editor.state.selection;
editor.view.dispatch(editor.state.tr.insertText("'", from, to).setMeta("composition", 1));
editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: "'" }));
await vi.advanceTimersByTimeAsync(0);
});
expect(serializeEditorDoc(editor.state.doc).value).toBe("'word'");
});
Loading
Loading