Skip to content

Commit

Permalink
simulate typing
Browse files Browse the repository at this point in the history
  • Loading branch information
ilyaGurevich committed Sep 12, 2023
1 parent 75ea189 commit 5afd7eb
Show file tree
Hide file tree
Showing 3 changed files with 327 additions and 1 deletion.
58 changes: 58 additions & 0 deletions src/components/__tests__/ProseMirror.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
teardownProseMirrorView,
} from "../../testing/setupProseMirrorView.js";
import { ProseMirror } from "../ProseMirror.js";
import { simulateType } from "../utils/textKeyboardSimulator.js";

describe("ProseMirror", () => {
beforeAll(() => {
Expand Down Expand Up @@ -139,6 +140,63 @@ describe("ProseMirror", () => {
expect(screen.getAllByTestId("paragraph").length).toBeGreaterThanOrEqual(1);
});

it("supports composition events", async () => {
const schema = new Schema({
nodes: {
text: {},
paragraph: { content: "text*" },
doc: { content: "paragraph+" },
},
});
const editorState = EditorState.create({ schema });

function Paragraph({ children }: NodeViewComponentProps) {
return <p data-testid="paragraph">{children}</p>;
}

const reactNodeViews = {
paragraph: () => ({
component: Paragraph,
dom: document.createElement("div"),
contentDOM: document.createElement("span"),
}),
};

function TestEditor() {
const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);
const [mount, setMount] = useState<HTMLDivElement | null>(null);

return (
<ProseMirror
mount={mount}
state={editorState}
dispatchTransaction={function (this: EditorView, tr) {
// We have to wrap the update in `act` to handle all of
// the async portal registering and component rendering that
// happens "out of band" because it's triggered by ProseMirror,
// not React.
act(() => this.updateState(this.state.apply(tr)));
}}
nodeViews={nodeViews}
>
<div data-testid="editor" ref={setMount} />
{renderNodeViews()}
</ProseMirror>
);
}

render(<TestEditor />);

const editor = screen.getByTestId("editor");

await editor.focus();
simulateType("ó");

expect(editor.textContent).toBe("ó");
// Ensure that ProseMirror really rendered our Paragraph
// component, not just any old <p> tag
expect(screen.getAllByTestId("paragraph").length).toBeGreaterThanOrEqual(1);
});
afterAll(() => {
teardownProseMirrorView();
});
Expand Down
268 changes: 268 additions & 0 deletions src/components/utils/textKeyboardSimulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
/*
* This simulates keypresses on an Icelandic layout keyboard. It is only
* intended to test composition sessions which testing-library does not support
* at this time. We should prefer using userEvent.type or userEvent.keyboard to
* this where possible.
*
* See: https://github.com/testing-library/user-event/issues/1097
*/
import { fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

// Keyboard has been set up in the standard testing-library way in case we want
// to use it at some point when/if they support composition sessions:
// https://github.com/testing-library/user-event/blob/main/src/system/keyboard.ts
const LOC_LEFT = 1;
const LOC_RIGHT = 2;

const islKeyboard = [
..."0123456789".split("").map((c) => ({ code: `Digit${c}`, key: c })),
...'=!"#$%&/()'
.split("")
.map((c, i) => ({ code: `Digit${i}`, key: c, shiftKey: true })),
..."abcdefghijklmnopqrstuvwxyz"
.split("")
.map((c) => ({ code: `Key${c.toUpperCase()}`, key: c })),
..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"
.split("")
.map((c) => ({ code: `Key${c}`, key: c, shiftKey: true })),
{ code: "Minus", key: "ö", shiftKey: false },
{ code: "Minus", key: "Ö", shiftKey: true },
{ code: "BracketLeft", key: "ð", shiftKey: false },
{ code: "BracketLeft", key: "Ð", shiftKey: true },
{ code: "Semicolon", key: "æ", shiftKey: false },
{ code: "Semicolon", key: "Æ", shiftKey: true },
{ code: "Slash", key: "þ", shiftKey: false },
{ code: "Slash", key: "Þ", shiftKey: true },
{ code: "Backslash", key: "*", shiftKey: true },
{ code: "Backslash", key: "+", shiftKey: false },
{ code: "BracketRight", key: "'", shiftKey: false },
{ code: "BracketRight", key: "?", shiftKey: true },
{ code: "Comma", key: ",", shiftKey: false },
{ code: "Comma", key: ";", shiftKey: true },
{ code: "Equal", key: "-", shiftKey: false },
{ code: "Equal", key: "_", shiftKey: true },
{ code: "IntlBackslash", key: "<", shiftKey: false },
{ code: "IntlBackslash", key: ">", shiftKey: true },
{ code: "Period", key: ".", shiftKey: false },
{ code: "Period", key: ":", shiftKey: true },
{
code: "Quote",
key: "´",
shiftKey: false,
composing: true,
} /* key: 'Dead' */,
{
code: "Quote",
key: "´",
shiftKey: true,
composing: true,
} /* key: 'Dead' */,
{ code: "Space", key: " " },
{ code: "AltLeft", key: "Alt", location: LOC_LEFT },
{ code: "AltRight", key: "Alt", location: LOC_RIGHT },
{ code: "ShiftLeft", key: "Shift", location: LOC_LEFT },
{ code: "ShiftRight", key: "Shift", location: LOC_RIGHT },
{ code: "ControlLeft", key: "Control", location: LOC_LEFT },
{ code: "ControlRight", key: "Control", location: LOC_RIGHT },
{ code: "MetaLeft", key: "Meta", location: LOC_LEFT },
{ code: "MetaRight", key: "Meta", location: LOC_RIGHT },
{ code: "OSLeft", key: "OS", location: LOC_LEFT },
{ code: "OSRight", key: "OS", location: LOC_RIGHT },
{ code: "Tab", key: "Tab" },
{ code: "CapsLock", key: "CapsLock" },
{ code: "Backspace", key: "Backspace" },
{ code: "Enter", key: "Enter" },
{ code: "Escape", key: "Escape" },
{ code: "ArrowUp", key: "ArrowUp" },
{ code: "ArrowDown", key: "ArrowDown" },
{ code: "ArrowLeft", key: "ArrowLeft" },
{ code: "ArrowRight", key: "ArrowRight" },
{ code: "Home", key: "Home" },
{ code: "End", key: "End" },
{ code: "Delete", key: "Delete" },
{ code: "PageUp", key: "PageUp" },
{ code: "PageDown", key: "PageDown" },
{ code: "Fn", key: "Fn" },
{ code: "Symbol", key: "Symbol" },
{ code: "AltRight", key: "AltGraph" },
];

// a map of characters which will be split into composed keystrokes
const decomp = {
á: "´a",
é: "´e",
í: "´i",
ó: "´o",
ú: "´u",
ý: "´y",
};

function findAllTextNodes(list: Node[], ret: Node[] = []) {
list.forEach((node) => {
if (node.nodeType === 3) {
ret.push(node);
} else if (node.nodeType === 1) {
findAllTextNodes([...node.childNodes], ret);
}
});
return ret;
}

function isText(node: Node | null): node is Text {
return node?.nodeType === Node.TEXT_NODE;
}

function getRangeOfPreviousChar(range0: Range | undefined) {
const range = range0?.cloneRange();
let textNode =
range?.startContainer.nodeType === 3 ? range?.startContainer : null;
if (range?.startContainer.nodeType === 1) {
const textNodes = findAllTextNodes(
[...(range?.startContainer.childNodes || [])].slice(0, range?.startOffset)
);
textNode = textNodes.pop() || null;
}
if (isText(textNode)) {
range?.setStart(textNode, textNode?.data.length - 1);
}
return range;
}

export function pressKey(key: string) {

Check failure on line 132 in src/components/utils/textKeyboardSimulator.ts

View workflow job for this annotation

GitHub Actions / check

Not all code paths return a value.
const focusElm = document.activeElement;
if (!focusElm?.hasAttribute("contenteditable")) {
return userEvent.keyboard(key);
}

const isLetter = /[a-záéíóúýþæðö]/i.test(key);
const isShift = isLetter && key === key.toUpperCase();
const seq = decomp[key.toLowerCase() as keyof typeof decomp];

// use ranges for inserts?
const sequence = [];
if (isShift) {
sequence.push({
type: "keydown",
code: "ShiftLeft",
key: "Shift",
shiftKey: isShift,
});
}
if (seq) {
// accent key
const quote = { code: "Quote", key: "Dead", shiftKey: isShift };
sequence.push({ type: "keydown", ...quote });
sequence.push({ type: "compositionstart" });
sequence.push({ type: "compositionupdate", data: seq[0] });
sequence.push({ type: "input", isComposing: true });
sequence.push({ type: "selectionchange" });
sequence.push({ type: "keyup", ...quote });
sequence.push({ type: "keypress", ...quote });
// character
const keyData = islKeyboard.find((d) => d.key === seq[1]);
sequence.push({ type: "keydown", ...keyData });
sequence.push({ type: "compositionupdate", data: key });
sequence.push({ type: "compositionend" });
sequence.push({ type: "input", isComposing: false });
sequence.push({ type: "selectionchange" });
sequence.push({ type: "keyup", ...keyData });
} else {
const keyData = islKeyboard.find((d) => d.key === key) || {
key,
code: "Unknown",
shiftKey: isShift,
};
sequence.push({ type: "keydown", data: key, ...keyData });
sequence.push({ type: "input", isComposing: false });
sequence.push({ type: "selectionchange" });
sequence.push({ type: "keyup", ...keyData });
}
if (isShift) {
sequence.push({
type: "keyup",
code: "ShiftLeft",
key: "Shift",
shiftKey: isShift,
});
}

let composeSession = false;
let defaultPrevented = false;
sequence.forEach((event) => {
let e;
const { type, data } = event;
if (type.startsWith("key") || type === "input") {
e = new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
...event,
});
} else if (type.startsWith("composition")) {
e = new CompositionEvent(type, {
bubbles: true,
cancelable: true,
...event,
});
} else if (type === "selectionchange") {
e = new Event("selectionchange", {
bubbles: true,
cancelable: true,
...event,
});
} else {
throw new Error("Unsupported event " + type);
}
fireEvent(focusElm, e);
// if default was prevented during keydown, we don't add chars for this cycle
if (e.type === "keydown") {
defaultPrevented = e.defaultPrevented;
}
if (data && !defaultPrevented) {
// ensure we have a selection
const selection = document.getSelection();
if (!selection?.rangeCount) {
selection?.selectAllChildren(focusElm);
selection?.collapseToEnd();
}
let selectionRange = selection?.getRangeAt(0);
// The test env does not seem to update selection with focus
// in this case the selection points at <body>.
// We can sidestep this by setting the caret to active element
// if the selection container contains the active element:
const cAC = selectionRange?.commonAncestorContainer;
if (cAC?.contains(focusElm) && cAC !== focusElm) {
selection?.selectAllChildren(focusElm);
selection?.collapseToEnd();
selectionRange = selection?.getRangeAt(0);
}
if (composeSession) {
getRangeOfPreviousChar(selectionRange)?.deleteContents();
}
if (type === "compositionupdate") {
composeSession = false;
}
const charCode = data.charCodeAt(0);
if (charCode === 8) {
const r = selection?.isCollapsed
? getRangeOfPreviousChar(selectionRange)
: selectionRange;
r?.deleteContents();
selection?.collapseToEnd();
} else if (charCode > 31) {
selectionRange?.deleteContents();
selectionRange?.insertNode(document.createTextNode(data));
selection?.collapseToEnd();
}
}
});
}

export function simulateType(text: string) {
text.split("").forEach(pressKey);
}

export function selectAll(node: Node) {
const selection = document.getSelection();
selection?.selectAllChildren(node);
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["src/**/*", "src/**/*.json"],
"compilerOptions": {
"lib": ["DOM", "ES2022"],
"lib": ["DOM", "ES2022", "dom.iterable"],
"target": "ES2015",
/* Input/output paths */
"outDir": "dist/types",
Expand Down

0 comments on commit 5afd7eb

Please sign in to comment.