Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/lexical-extension/src/HorizontalRuleExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ import {
type DOMConversionOutput,
type DOMExportOutput,
type EditorConfig,
getDOMSelectionFromTarget,
getDOMSelectionPoints,
isDOMNode,
isHTMLElement,
type LexicalCommand,
type LexicalEditor,
type LexicalNode,
mergeRegister,
type NodeKey,
Expand Down Expand Up @@ -117,6 +121,59 @@ export function $isHorizontalRuleNode(
return node instanceof HorizontalRuleNode;
}

/**
* A horizontal rule is a block decorator, so the empty space between it and an
* adjacent block still belongs to the parent element. A click there is
* resolved by the browser to a collapsed caret immediately before or after the
* rule, which Lexical reconciles to a block cursor: the caret looks like it is
* on the rule but typing goes into the neighbouring block. Arrow keys select
* the rule from those positions, so find the rule the click landed beside and
* let the click handler do the same.
*
* The DOM selection is read rather than the Lexical selection because the
* `selectionchange` event that would update the editor state has not
* necessarily been processed by the time `click` fires — the browser has
* already moved the DOM caret on `mousedown`, so the DOM is the only source
* that reflects this click.
*/
function $getHorizontalRuleBesideClick(
editor: LexicalEditor,
event: MouseEvent,
): null | HorizontalRuleNode {
const domSelection = getDOMSelectionFromTarget(event.target);
if (domSelection === null || !domSelection.isCollapsed) {
return null;
}
const rootElement = editor.getRootElement();
const {anchorNode, anchorOffset} = getDOMSelectionPoints(
domSelection,
rootElement,
);
if (!isHTMLElement(anchorNode)) {
return null;
}
// The block cursor is transient DOM that Lexical inserts beside decorators;
// it is not part of the Lexical tree, so drop it before indexing children.
const blockCursorElement = editor._blockCursorElement;
const childNodes = Array.prototype.filter.call(
anchorNode.childNodes,
(child: Node) => child !== blockCursorElement,
) as Node[];
let offset = anchorOffset;
if (
blockCursorElement !== null &&
blockCursorElement.parentNode === anchorNode &&
Array.prototype.indexOf.call(anchorNode.childNodes, blockCursorElement) <
offset
) {
offset -= 1;
}
const childDOM =
offset === childNodes.length ? childNodes[offset - 1] : childNodes[offset];
const node = childDOM === undefined ? null : $getNodeFromDOMNode(childDOM);
return $isHorizontalRuleNode(node) ? node : null;
}

function $toggleNodeSelection(
node: LexicalNode,
shiftKey: boolean = false,
Expand Down Expand Up @@ -190,6 +247,13 @@ export const HorizontalRuleExtension = /* @__PURE__ */ defineExtension({
return true;
}
}
const besideNode = $getHorizontalRuleBesideClick(editor, event);
if (besideNode !== null) {
const nodeSelection = $createNodeSelection();
nodeSelection.add(besideNode.getKey());
$setSelection(nodeSelection);
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,56 @@ test.describe('HorizontalRule', () => {
expect(isNodeSel).toBe(true);
});

test('Clicking the empty space beside a horizontal rule selects it (#7758)', async ({
page,
isPlainText,
isCollab,
}) => {
test.skip(isPlainText || isCollab);
await focusEditor(page);

await page.keyboard.type('Above');
await selectFromInsertDropdown(page, '.horizontal-rule');
await waitForSelector(page, 'hr');
await page.keyboard.type('Below');

// Click the empty space between the paragraph above and the rule. The
// browser resolves that point to a caret immediately before the rule,
// which reconciles to a block cursor instead of selecting the rule, so
// the caret looks like it is on the rule but types into the paragraph.
const clickPos = await getPageOrFrame(page).evaluate(() => {
const editor = document.querySelector('[contenteditable="true"]');
const hr = editor.querySelector('hr');
const hrRect = hr.getBoundingClientRect();
return {
x: Math.round(hrRect.left) + 8,
y: Math.round(hrRect.top) - 3,
};
});
await page.mouse.click(clickPos.x, clickPos.y);

const selState = await getPageOrFrame(page).evaluate(() => {
const state = window.lexicalEditor.getEditorState();
const sel = state._selection;
if (sel === null) {
return 'null';
}
if (!('_nodes' in sel)) {
return `range(${sel.anchor.key}:${sel.anchor.offset})`;
}
return state.read(() =>
Array.from(sel._nodes)
.map(key => state._nodeMap.get(key).getType())
.join(','),
);
});
expect(selState).toBe('horizontalrule');

await expect(getPageOrFrame(page).locator('hr')).toHaveClass(
/PlaygroundEditorTheme__hrSelected/,
);
});

test('Clicking between consecutive block decorators creates selection (#6775)', async ({
page,
isPlainText,
Expand Down Expand Up @@ -850,9 +900,13 @@ test.describe('HorizontalRule', () => {
await page.mouse.click(clickPos.x, clickPos.y);

// Without the fix, Firefox leaves selection as null (rangeCount === 0).
// The fix computes the correct child offset from click coordinates.
// The fix computes the correct child offset from click coordinates; the
// horizontal rule extension then turns that offset into a selection of
// the rule the click landed beside (#7758), so the observable result of
// the coordinate fix is a node selection rather than a null selection.
const selState = await getPageOrFrame(page).evaluate(() => {
const sel = window.lexicalEditor.getEditorState()._selection;
const state = window.lexicalEditor.getEditorState();
const sel = state._selection;
if (sel === null) {
return null;
}
Expand All @@ -863,12 +917,16 @@ test.describe('HorizontalRule', () => {
type: 'range',
};
}
return {type: 'node'};
return {
type: 'node',
types: state.read(() =>
Array.from(sel._nodes).map(key => state._nodeMap.get(key).getType()),
),
};
});
expect(selState).not.toBeNull();
expect(selState.type).toBe('range');
expect(selState.anchorKey).toBe('root');
expect(selState.anchorType).toBe('element');
expect(selState.type).toBe('node');
expect(selState.types).toEqual(['horizontalrule']);
});

test('ArrowDown from block cursor between shadow root and decorator selects the decorator', async ({
Expand Down
Loading