fix(site): restore iOS backspace in agent chat input (#25531)

This commit is contained in:
Danielle Maywood
2026-05-21 11:42:20 +01:00
committed by GitHub
parent 92d67888b8
commit d9875d8902
3 changed files with 387 additions and 1 deletions
@@ -41,6 +41,7 @@ import {
$createFileReferenceNode,
FileReferenceNode,
} from "./FileReferenceNode";
import { IOSBackspacePlugin } from "./iosBackspace";
import {
createPasteFile,
getPasteDataTransfer,
@@ -376,7 +377,7 @@ const ValueSyncPlugin: FC<{
editor.setEditorState(parsed);
return;
} catch {
// Malformed state fall through to plain-text path.
// Malformed state, fall through to plain-text path.
}
}
@@ -736,6 +737,7 @@ const ChatMessageInput = ({
onEnter={disabled ? undefined : onEnter}
sendShortcut={sendShortcut}
/>
<IOSBackspacePlugin />
<ContentChangePlugin onChange={onChange} />
<ValueSyncPlugin
initialValue={initialValue}
@@ -0,0 +1,299 @@
import {
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getRoot,
$getSelection,
$setSelection,
COMMAND_PRIORITY_HIGH,
createEditor,
DELETE_CHARACTER_COMMAND,
KEY_BACKSPACE_COMMAND,
} from "lexical";
import { describe, expect, it } from "vitest";
import {
$createFileReferenceNode,
FileReferenceNode,
} from "./FileReferenceNode";
import {
$shouldUseNativeBackspace,
registerIOSBackspaceCommand,
} from "./iosBackspace";
function readBackspaceDecision(setup: () => void) {
const editor = createEditor({
namespace: "ios-backspace-test",
nodes: [FileReferenceNode],
onError: (error) => {
throw error;
},
});
let result = false;
editor.update(
() => {
setup();
result = $shouldUseNativeBackspace($getSelection());
},
{ discrete: true },
);
return result;
}
describe("$shouldUseNativeBackspace", () => {
it("allows native deletion from plain text selections", () => {
const result = readBackspaceDecision(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode("hello");
root.append(paragraph);
paragraph.append(text);
text.select(5, 5);
});
expect(result).toBe(true);
});
it("allows native deletion when a file reference precedes a mid-text caret", () => {
const result = readBackspaceDecision(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const fileReference = $createFileReferenceNode(
"main.go",
1,
1,
"package main",
);
const text = $createTextNode("hello");
root.append(paragraph);
paragraph.append(fileReference, text);
text.select(3, 3);
});
expect(result).toBe(true);
});
it("keeps Lexical deletion when backspace would hit a file reference", () => {
const result = readBackspaceDecision(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const fileReference = $createFileReferenceNode(
"main.go",
1,
1,
"package main",
);
const text = $createTextNode("hello");
root.append(paragraph);
paragraph.append(fileReference, text);
text.select(0, 0);
});
expect(result).toBe(false);
});
it("keeps Lexical deletion when backspace would hit a file reference in the previous paragraph", () => {
const result = readBackspaceDecision(() => {
const root = $getRoot();
const previousParagraph = $createParagraphNode();
const currentParagraph = $createParagraphNode();
const fileReference = $createFileReferenceNode(
"main.go",
1,
1,
"package main",
);
const text = $createTextNode("hello");
root.append(previousParagraph, currentParagraph);
previousParagraph.append(fileReference);
currentParagraph.append(text);
text.select(0, 0);
});
expect(result).toBe(false);
});
it("keeps Lexical deletion when the selection contains a file reference", () => {
const result = readBackspaceDecision(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const before = $createTextNode("before");
const fileReference = $createFileReferenceNode(
"main.go",
1,
1,
"package main",
);
const after = $createTextNode("after");
root.append(paragraph);
paragraph.append(before, fileReference, after);
const selection = $createRangeSelection();
selection.anchor.set(before.getKey(), 0, "text");
selection.focus.set(after.getKey(), 5, "text");
$setSelection(selection);
});
expect(result).toBe(false);
});
it("keeps Lexical deletion for element anchor at offset 0 after a file reference paragraph", () => {
const result = readBackspaceDecision(() => {
const root = $getRoot();
const firstParagraph = $createParagraphNode();
const secondParagraph = $createParagraphNode();
const fileReference = $createFileReferenceNode(
"main.go",
1,
1,
"package main",
);
root.append(firstParagraph, secondParagraph);
firstParagraph.append(fileReference);
secondParagraph.selectStart();
});
expect(result).toBe(false);
});
it("keeps Lexical deletion for element anchor at offset > 0 next to a file reference child", () => {
const result = readBackspaceDecision(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const fileReference = $createFileReferenceNode(
"main.go",
1,
1,
"package main",
);
const text = $createTextNode("hello");
root.append(paragraph);
paragraph.append(fileReference, text);
paragraph.select(1, 1);
});
expect(result).toBe(false);
});
});
describe("registerIOSBackspaceCommand", () => {
it("falls through when native backspace is disabled", () => {
const editor = createEditor({
namespace: "ios-backspace-command-disabled-test",
onError: (error) => {
throw error;
},
});
let deleteCharacterDispatched = false;
registerIOSBackspaceCommand(editor, false);
editor.registerCommand(
DELETE_CHARACTER_COMMAND,
() => {
deleteCharacterDispatched = true;
return true;
},
COMMAND_PRIORITY_HIGH,
);
const event = new KeyboardEvent("keydown", {
cancelable: true,
key: "Backspace",
});
expect(editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(deleteCharacterDispatched).toBe(false);
});
it("prevents default and dispatches Lexical deletion near file references", () => {
const editor = createEditor({
namespace: "ios-backspace-command-test",
nodes: [FileReferenceNode],
onError: (error) => {
throw error;
},
});
let deleteCharacterDirection: boolean | undefined;
registerIOSBackspaceCommand(editor, true);
editor.registerCommand(
DELETE_CHARACTER_COMMAND,
(isBackward) => {
deleteCharacterDirection = isBackward;
return true;
},
COMMAND_PRIORITY_HIGH,
);
editor.update(
() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const fileReference = $createFileReferenceNode(
"main.go",
1,
1,
"package main",
);
const text = $createTextNode("hello");
root.append(paragraph);
paragraph.append(fileReference, text);
text.select(0, 0);
},
{ discrete: true },
);
const event = new KeyboardEvent("keydown", {
cancelable: true,
key: "Backspace",
});
expect(editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event)).toBe(true);
expect(event.defaultPrevented).toBe(true);
expect(deleteCharacterDirection).toBe(true);
});
it("does not prevent default for plain text iOS backspace", () => {
const editor = createEditor({
namespace: "ios-backspace-command-test",
nodes: [FileReferenceNode],
onError: (error) => {
throw error;
},
});
let deleteCharacterDirection: boolean | undefined;
registerIOSBackspaceCommand(editor, true);
editor.registerCommand(
DELETE_CHARACTER_COMMAND,
(isBackward) => {
deleteCharacterDirection = isBackward;
return true;
},
COMMAND_PRIORITY_HIGH,
);
editor.update(
() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode("hello");
root.append(paragraph);
paragraph.append(text);
text.select(5, 5);
},
{ discrete: true },
);
const event = new KeyboardEvent("keydown", {
cancelable: true,
key: "Backspace",
});
expect(editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event)).toBe(true);
expect(event.defaultPrevented).toBe(false);
expect(deleteCharacterDirection).toBeUndefined();
});
});
@@ -0,0 +1,85 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { CAN_USE_BEFORE_INPUT, IS_IOS } from "@lexical/utils";
import {
$getSelection,
$isDecoratorNode,
$isElementNode,
$isRangeSelection,
type BaseSelection,
COMMAND_PRIORITY_HIGH,
DELETE_CHARACTER_COMMAND,
KEY_BACKSPACE_COMMAND,
type LexicalEditor,
type LexicalNode,
type RangeSelection,
} from "lexical";
import { type FC, useEffect } from "react";
function $containsDecoratorNode(node: LexicalNode | null): boolean {
if (!node) return false;
if ($isDecoratorNode(node)) return true;
if (!$isElementNode(node)) return false;
return node.getChildren().some($containsDecoratorNode);
}
function $backspaceHitsDecoratorNode(selection: RangeSelection): boolean {
const { anchor } = selection;
if (anchor.type === "element") {
const node = anchor.getNode();
if (anchor.offset === 0) {
const topBlock = node.getTopLevelElement() ?? node;
return $containsDecoratorNode(topBlock.getPreviousSibling());
}
return $containsDecoratorNode(node.getChildAtIndex(anchor.offset - 1));
}
if (anchor.offset !== 0) return false;
const textNode = anchor.getNode();
if ($containsDecoratorNode(textNode.getPreviousSibling())) return true;
const topBlock = textNode.getTopLevelElement();
if (topBlock?.getFirstDescendant() !== textNode) return false;
return $containsDecoratorNode(topBlock.getPreviousSibling());
}
export function $shouldUseNativeBackspace(
selection: BaseSelection | null,
): boolean {
if (!$isRangeSelection(selection)) return false;
if (selection.getNodes().some($containsDecoratorNode)) return false;
return !selection.isCollapsed() || !$backspaceHitsDecoratorNode(selection);
}
export function registerIOSBackspaceCommand(
editor: LexicalEditor,
useNativeBackspace: boolean,
): () => void {
return editor.registerCommand(
KEY_BACKSPACE_COMMAND,
(event) => {
if (!useNativeBackspace) return false;
const selection = $getSelection();
if ($shouldUseNativeBackspace(selection)) return true;
if (!$isRangeSelection(selection)) return false;
// WebKit native deletion can desynchronize contentEditable=false decorator nodes.
event.preventDefault();
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
},
COMMAND_PRIORITY_HIGH,
);
}
const IOSBackspacePlugin: FC = function IOSBackspacePlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
// Lexical's default Backspace handler blocks the beforeinput event WebKit needs for delete acceleration.
return registerIOSBackspaceCommand(editor, IS_IOS && CAN_USE_BEFORE_INPUT);
}, [editor]);
return null;
};
export { IOSBackspacePlugin };