Skip to content
Merged
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
47 changes: 47 additions & 0 deletions packages/lexical-playground/__tests__/e2e/DraggableBlock.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@
*
*/

import {expect} from '@playwright/test';

import {
assertHTML,
dragDraggableMenuTo,
dragMouse,
focusEditor,
initialize,
insertYouTubeEmbed,
mouseMoveToSelector,
selectorBoundingBox,
test,
YOUTUBE_SAMPLE_URL,
} from '../utils/index.mjs';

test.describe('DraggableBlock', () => {
Expand Down Expand Up @@ -187,4 +193,45 @@ test.describe('DraggableBlock', () => {
`,
);
});

test('Restores focus after dragging a selected decorator block', async ({
page,
isPlainText,
browserName,
isCollab,
}) => {
test.skip(isCollab);
test.skip(isPlainText);

await focusEditor(page);
await page.keyboard.type('Before');
await insertYouTubeEmbed(page, YOUTUBE_SAMPLE_URL);
await page.keyboard.type('After');

const decorator = page.locator('.PlaygroundEditorTheme__embedBlock');
const decoratorElement = page.locator('div[data-lexical-decorator="true"]');
const decoratorBox = await decoratorElement.boundingBox();
if (decoratorBox === null) {
throw new Error('Decorator block is not visible');
}
const pointerX = decoratorBox.x + 10;
const pointerY = decoratorBox.y + decoratorBox.height / 2;
await decorator.evaluate(element => element.click());
await expect(decorator).toHaveClass(
/PlaygroundEditorTheme__embedBlockFocus/,
);
await decoratorElement.dispatchEvent('mousemove', {
clientX: pointerX,
clientY: pointerY,
});
await page.locator('.draggable-block-menu').waitFor();
await dragMouse(
page,
await selectorBoundingBox(page, '.draggable-block-menu'),
await selectorBoundingBox(page, 'p:has-text("After")'),
{positionEnd: 'end', positionStart: 'middle', slow: true},
);

await expect(page.locator('.ContentEditable__root')).toBeFocused();
});
});
47 changes: 19 additions & 28 deletions packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
return editor.read('latest', () => $getRoot().getChildrenKeys());
}

function restoreEditorFocus(
editor: LexicalEditor,
rootElement: HTMLElement,
): void {
rootElement.focus({preventScroll: true});
editor.update(() => {
const selection = $getSelection();
if (selection !== null && !selection.dirty) {
selection.dirty = true;
}
});
}

function getCollapsedMargins(elem: HTMLElement): {
marginTop: number;
marginBottom: number;
Expand Down Expand Up @@ -477,14 +490,7 @@ function useDraggableBlockMenu(
// Blur is caused by clicking on drag handle - restore focus immediately
// to prevent cursor from disappearing. This must be synchronous to work.
if (rootElement) {
rootElement.focus({preventScroll: true});
// Force selection update to ensure cursor is visible
editor.update(() => {
const selection = $getSelection();
if (selection !== null && !selection.dirty) {
selection.dirty = true;
}
});
restoreEditorFocus(editor, rootElement);
}
// Prevent the event from propagating to LexicalEvents handler
event.stopImmediatePropagation();
Expand Down Expand Up @@ -512,13 +518,7 @@ function useDraggableBlockMenu(
isOnMenu(activeElement)
) {
// Focus is on menu - restore to root and prevent blur command
rootElement.focus({preventScroll: true});
editor.update(() => {
const selection = $getSelection();
if (selection !== null && !selection.dirty) {
selection.dirty = true;
}
});
restoreEditorFocus(editor, rootElement);
return true; // Prevent command from propagating
}
return false;
Expand Down Expand Up @@ -558,14 +558,7 @@ function useDraggableBlockMenu(
) {
// Restore focus synchronously - don't use requestAnimationFrame as blur already happened
// and we need immediate focus restoration to maintain cursor visibility
rootElement.focus({preventScroll: true});
// Force selection update to ensure cursor is visible
editor.update(() => {
const selection = $getSelection();
if (selection !== null && !selection.dirty) {
selection.dirty = true;
}
});
restoreEditorFocus(editor, rootElement);
}
}
}
Expand All @@ -574,11 +567,9 @@ function useDraggableBlockMenu(
isDraggingBlockRef.current = false;
hideTargetLine(targetLineRef.current);

// Firefox-specific fix: Use editor.focus() to properly restore both focus and
// selection after drag ends. This ensures cursor visibility immediately.
if (IS_FIREFOX) {
// editor.focus() handles both focus restoration and selection update properly
editor.focus();
const rootElement = editor.getRootElement();
if (rootElement !== null && getActiveElement(rootElement) !== rootElement) {
restoreEditorFocus(editor, rootElement);
}
}
return createPortal(
Expand Down
13 changes: 13 additions & 0 deletions packages/lexical-rich-text/flow/LexicalRichText.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* @flow strict
*/
import type {NamedSignalsOutput} from '@lexical/extension';
import type {
DOMConversionMap,
EditorConfig,
Expand Down Expand Up @@ -101,3 +102,15 @@ export type SerializedHeadingNode = {


declare export var RichTextExtension: LexicalExtension<RichTextConfig, "@lexical/rich-text", void, void>;

export type HeadingAnnounceExtensionConfig = {
created: string,
destroyed: string,
disabled: boolean,
};
declare export var HeadingAnnounceExtension: LexicalExtension<
HeadingAnnounceExtensionConfig,
'@lexical/rich-text/HeadingAnnounce',
NamedSignalsOutput<HeadingAnnounceExtensionConfig>,
void,
>;
1 change: 1 addition & 0 deletions packages/lexical-rich-text/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
}
},
"dependencies": {
"@lexical/a11y": "workspace:*",
"@lexical/clipboard": "workspace:*",
"@lexical/dragon": "workspace:*",
"@lexical/extension": "workspace:*",
Expand Down
104 changes: 104 additions & 0 deletions packages/lexical-rich-text/src/HeadingAnnounceExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {AriaLiveRegionExtension} from '@lexical/a11y';
import {effect, namedSignals} from '@lexical/extension';
import {$getNodeByKey, defineExtension, type NodeKey, safeCast} from 'lexical';

import {$isHeadingNode, HeadingNode, type HeadingTagType} from './index';

export interface HeadingAnnounceExtensionConfig {
/**
* Announced when a block becomes a heading. `%s` is replaced with the
* level (1-6).
*/
created: string;
/**
* Announced when a heading stops being a heading. `%s` is replaced with the
* level it was.
*/
destroyed: string;
/**
* When `true`, headings are not announced. Toggle at runtime via the output
* signal. Default `false`.
*/
disabled: boolean;
}

function $readHeadingTag(key: NodeKey): HeadingTagType | null {
const node = $getNodeByKey(key);
return $isHeadingNode(node) ? node.getTag() : null;
}

/**
* Announces headings through the {@link AriaLiveRegionExtension} sink: a block
* becoming a heading, and a heading ceasing to be one.
*
* The markdown shortcut consumes both keystrokes (`#` then space) and swaps the
* block type, which is silent to a screen reader — so without this the user has
* no way to know the transformation happened, or to confirm the level without
* navigating out of the block and back in.
*
* Only those two transitions announce. Typing inside a heading, moving the
* caret through it, and deleting text while the heading survives are all
* silent; announcing on every keystroke would make a heading impossible to type
* into.
*
* A destroyed node is gone from the current editor state, so its level is read
* from the previous state `registerMutationListener` provides.
*/
export const HeadingAnnounceExtension = /* @__PURE__ */ defineExtension({
build: (_editor, config) => namedSignals(config),
config: /* @__PURE__ */ safeCast<HeadingAnnounceExtensionConfig>({
created: 'Heading level %s',
destroyed: 'Heading level %s removed',
disabled: false,
}),
dependencies: [AriaLiveRegionExtension],
name: '@lexical/rich-text/HeadingAnnounce',
register(editor, _config, state) {
const {created, destroyed, disabled} = state.getOutput();
const {announce} = state.getDependency(AriaLiveRegionExtension).output;

// Gate on `disabled` from an effect so a disabled announcer registers no
// listener at all. Peek the message signals at announce time so editing
// them does not re-register.
return effect(() =>
disabled.value
? undefined
: editor.registerMutationListener(
HeadingNode,
(nodes, {prevEditorState}) => {
// A level change fires both a removal and a creation. In the
// first block the removal reaches the live region, announcing the
// old level instead of the new one. Prefer the creation; only
// announce removal when nothing replaced it.
let createdTag: HeadingTagType | null = null;
let destroyedTag: HeadingTagType | null = null;
for (const [key, mutation] of nodes) {
if (mutation === 'created' && createdTag === null) {
createdTag = editor.read('latest', () =>
$readHeadingTag(key),
);
} else if (mutation === 'destroyed' && destroyedTag === null) {
destroyedTag = prevEditorState.read(() =>
$readHeadingTag(key),
);
}
}
if (createdTag !== null) {
announce(created.peek().replace('%s', createdTag.slice(1)));
} else if (destroyedTag !== null) {
announce(destroyed.peek().replace('%s', destroyedTag.slice(1)));
}
},
{skipInitialization: true},
),
);
},
});
2 changes: 2 additions & 0 deletions packages/lexical-rich-text/src/LexicalRichTextExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type TextFormatType,
} from 'lexical';

import {HeadingAnnounceExtension} from './HeadingAnnounceExtension';
import {
defaultShouldHandlePasteAsFiles,
type EscapeFormatTriggerConfig,
Expand Down Expand Up @@ -112,6 +113,7 @@ export const RichTextExtension = /* @__PURE__ */ defineExtension({
config: /* @__PURE__ */ safeCast<RichTextConfig>(DEFAULT_RICH_TEXT_CONFIG),
conflictsWith: ['@lexical/plain-text'],
dependencies: [
HeadingAnnounceExtension,
DragonExtension,
NormalizeInlineElementsExtension,
NormalizeTripleClickSelectionExtension,
Expand Down
Loading