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("