mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix(site): restore iOS backspace in agent chat input (#25531)
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user