From 88df0d5f9256730aed27c38a9d6d683cd354322a Mon Sep 17 00:00:00 2001 From: lyubomir-bozhinov Date: Fri, 17 Jul 2026 17:31:48 +0300 Subject: [PATCH] Place the caret before an inline decorator when Gecko drops the selection Clicking just before a mention that starts a paragraph left the editor with no selection in Firefox, so anything typed afterwards was silently dropped. Gecko resolves such a click to a text node inside the decorator's contenteditable="false" subtree. That node is not part of the editor state, so no selection point maps to it and Lexical ends up with none. Blink resolves the same click to the paragraph itself at offset 0. Place the caret before the decorator ourselves when a click leaves us without a selection, which matches what Blink does natively. --- src/editor/selection.js | 30 +++++++++- .../prompts/typing_before_mention.test.js | 55 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 test/browser/tests/prompts/typing_before_mention.test.js diff --git a/src/editor/selection.js b/src/editor/selection.js index 217e6d9f3..7a200dc9c 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -9,6 +9,7 @@ import { $getListDepth, ListItemNode, ListNode } from "@lexical/list" import { $getTableCellNodeFromLexicalNode, TableCellNode } from "@lexical/table" import { CodeNode } from "@lexical/code" import { isSelectionHighlighted } from "../helpers/format_helper" +import { caretFromPoint } from "../helpers/caret_helpers" import { getNonce } from "../helpers/csp_helper" import { $createNodeSelectionWith, $isListItemStructurallyEmpty, getListType } from "../helpers/lexical_helper" import { LinkNode } from "@lexical/link" @@ -375,11 +376,16 @@ export default class Selection { } #listenForNodeSelections() { - this.#listeners.track(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => { + this.#listeners.track(this.editor.registerCommand(CLICK_COMMAND, (event) => { + const { target } = event if (!isDOMNode(target)) return false const targetNode = $getNearestNodeFromDOMNode(target) - return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode) + if ($isDecoratorNode(targetNode)) { + return this.#selectInLexical(targetNode) + } else { + return this.#placeCaretBeforeInlineDecorator(event) + } }, COMMAND_PRIORITY_LOW)) this.#listeners.track( @@ -391,6 +397,26 @@ export default class Selection { ) } + // Gecko resolves a click just before an inline decorator to a text node inside + // its contenteditable="false" subtree, which maps to no selection at all, so + // typing is silently dropped. Place the caret before the decorator ourselves. + #placeCaretBeforeInlineDecorator(event) { + if ($getSelection() !== null) return false + + const position = caretFromPoint(event.clientX, event.clientY) + if (!position) return false + + const node = $getNearestNodeFromDOMNode(position.node) + if (!$isDecoratorNode(node) || !node.isInline()) return false + + const parent = node.getParent() + if (!$isElementNode(parent)) return false + + const index = node.getIndexWithinParent() + parent.select(index, index) + return true + } + #containEditorFocus() { // Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements // above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes. diff --git a/test/browser/tests/prompts/typing_before_mention.test.js b/test/browser/tests/prompts/typing_before_mention.test.js new file mode 100644 index 000000000..18ef9370f --- /dev/null +++ b/test/browser/tests/prompts/typing_before_mention.test.js @@ -0,0 +1,55 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +async function insertMention(page, editor) { + await editor.send("@") + await expect(page.locator(".lexxy-prompt-menu--visible")).toBeVisible({ timeout: 5_000 }) + await editor.send("Enter") + await editor.flush() + await expect(editor.content.locator("action-text-attachment")).toBeVisible({ timeout: 5_000 }) +} + +async function clickAt(page, editor, clientX, clientY) { + await page.mouse.click(clientX, clientY) + await editor.flush() +} + +test.describe("Typing beside a mention", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/mentions.html") + await page.waitForSelector("lexxy-editor[connected]") + }) + + test("clicking before a leading mention places the caret before it", async ({ page, editor }) => { + await insertMention(page, editor) + + const mention = await editor.content.locator("action-text-attachment").boundingBox() + const content = await editor.content.boundingBox() + await clickAt(page, editor, content.x + 1, mention.y + mention.height / 2) + + await editor.send("Hi") + await editor.flush() + + await expect(editor.content.locator("action-text-attachment")).toHaveCount(1) + const value = await editor.value() + expect(value).toContain("Hi") + expect(value.indexOf("Hi")).toBeLessThan(value.indexOf(" { + await insertMention(page, editor) + + // Well past the trailing space Lexxy inserts after a mention, so the click + // lands clearly after the mention rather than on that space. + const mention = await editor.content.locator("action-text-attachment").boundingBox() + await clickAt(page, editor, mention.x + mention.width + 40, mention.y + mention.height / 2) + + await editor.send("Hi") + await editor.flush() + + await expect(editor.content.locator("action-text-attachment")).toHaveCount(1) + const value = await editor.value() + expect(value).toContain("Hi") + expect(value.indexOf("Hi")).toBeGreaterThan(value.indexOf("