Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* 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 {RovingTabIndexExtension} from '@lexical/a11y';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$selectAll,
defineExtension,
type LexicalEditor,
} from 'lexical';
import * as React from 'react';
import {act} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';

import FloatingTextFormatToolbarPlugin from '../../src/plugins/FloatingTextFormatToolbarPlugin';

const ToolbarTestExtension = /* @__PURE__ */ defineExtension({
$initialEditorState: () => {
$getRoot()
.clear()
.append($createParagraphNode().append($createTextNode('hello')));
},
dependencies: [RichTextExtension, RovingTabIndexExtension],
name: '[test-floating-toolbar]',
});

describe('FloatingTextFormatToolbarPlugin', () => {
let container: HTMLDivElement;
let anchorElem: HTMLDivElement;
let reactRoot: Root;
let editor: LexicalEditor;

function Capture() {
const [contextEditor] = useLexicalComposerContext();
editor = contextEditor;
return null;
}

beforeEach(async () => {
container = document.createElement('div');
anchorElem = document.createElement('div');
document.body.append(container, anchorElem);
reactRoot = createRoot(container);

await act(async () => {
reactRoot.render(
<LexicalExtensionComposer extension={ToolbarTestExtension}>
<Capture />
<FloatingTextFormatToolbarPlugin
anchorElem={anchorElem}
setIsLinkEditMode={vi.fn()}
/>
</LexicalExtensionComposer>,
);
});

// The popup only opens for a non-collapsed selection whose DOM anchor is
// inside the root element, so set both.
await act(async () => {
editor.update(() => void $selectAll(), {discrete: true});
const textDOM = editor.getRootElement()!.querySelector('p')!.firstChild!
.firstChild!;
document
.getSelection()!
.setBaseAndExtent(textDOM, 0, textDOM, 'hello'.length);
document.dispatchEvent(new Event('selectionchange'));
});
});

afterEach(async () => {
await act(async () => {
reactRoot.unmount();
});
container.remove();
anchorElem.remove();
});

function formatButtonCount(): number {
return anchorElem.querySelectorAll(
'.floating-text-format-popup button[aria-label^="Format text"]',
).length;
}

it('hides its format buttons when the editor becomes read-only', async () => {
expect(anchorElem.querySelector('.floating-text-format-popup')).not.toBe(
null,
);
expect(formatButtonCount()).toBeGreaterThan(0);

await act(async () => {
editor.setEditable(false);
});
expect(formatButtonCount()).toBe(0);

await act(async () => {
editor.setEditable(true);
});
expect(formatButtonCount()).toBeGreaterThan(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {useMergeRefs} from '@floating-ui/react';
import {$isCodeNode} from '@lexical/code';
import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import {useLexicalRovingTabIndexRef} from '@lexical/react/useLexicalRovingTabIndexRef';
import {
$getSelection,
Expand Down Expand Up @@ -82,6 +83,9 @@ function TextFormatFloatingToolbar({
}): JSX.Element {
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
const rovingRef = useLexicalRovingTabIndexRef();
// Subscribed rather than read off the editor, so that setEditable() actually
// re-renders the toolbar.
const isEditable = useLexicalEditable();
const mergedRef = useMergeRefs([popupCharStylesEditorRef, rovingRef, ref]);

const insertLink = useCallback(() => {
Expand Down Expand Up @@ -224,7 +228,7 @@ function TextFormatFloatingToolbar({
className="floating-text-format-popup"
role="toolbar"
aria-label="Floating text format toolbar">
{editor.isEditable() && (
{isEditable && (
<>
<button
type="button"
Expand Down
20 changes: 17 additions & 3 deletions packages/lexical-react/src/LexicalCollaborationPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,15 @@ export function CollaborationPlugin({
rootName,
}: CollaborationPluginProps): JSX.Element {
const isBindingInitialized = useRef(false);
const isProviderInitialized = useRef(false);
// The inputs that produced the current Provider. A ref rather than the effect
// deps alone because the effect must be idempotent: React StrictMode (and
// React 18+ remounts in general) re-runs the effect with unchanged inputs and
// must not create a second Provider.
const providerInputs = useRef<null | {
id: string;
providerFactory: ProviderFactory;
yjsDocMap: Map<string, Doc>;
}>(null);

const collabContext = useCollaborationContext(username, cursorColor);
const {yjsDocMap, name, color} = collabContext;
Expand All @@ -93,11 +101,17 @@ export function CollaborationPlugin({
const [doc, setDoc] = useState<Doc>();

useEffect(() => {
if (isProviderInitialized.current) {
const prevInputs = providerInputs.current;
if (
prevInputs !== null &&
prevInputs.id === id &&
prevInputs.providerFactory === providerFactory &&
prevInputs.yjsDocMap === yjsDocMap
) {
return;
}

isProviderInitialized.current = true;
providerInputs.current = {id, providerFactory, yjsDocMap};

const newProvider = providerFactory(id, yjsDocMap);
setProvider(newProvider);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import {eventFiles} from '@lexical/rich-text';
import {calculateZoomLevel} from '@lexical/utils';
import {
Expand Down Expand Up @@ -621,12 +622,16 @@ export function DraggableBlockPlugin_EXPERIMENTAL({
onElementChanged?: (element: HTMLElement | null) => void;
}): JSX.Element {
const [editor] = useLexicalComposerContext();
// Subscribed rather than read off the editor, so that setEditable() actually
// re-renders the handle. useLexicalEditable also handles StrictMode and
// concurrent rendering correctly.
const isEditable = useLexicalEditable();
return useDraggableBlockMenu(
editor,
anchorElem,
menuRef,
targetLineRef,
editor._editable,
isEditable,
menuComponent,
targetLineComponent,
isOnMenu,
Expand Down
33 changes: 20 additions & 13 deletions packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,26 @@ const NodeContextMenuPlugin = forwardRef<

useEffect(() => {
function onContextMenu(e: MouseEvent) {
let visibleItems: ContextMenuType[] = [];
if (items) {
editor.read(() => {
const node =
$getNearestNodeFromDOMNode(e.target as Element) ?? $getRoot();
if (node) {
visibleItems = items!.filter(option =>
option.$showOn ? option.$showOn(node) : true,
);
}
});
}

// Every item is hidden for this node, so there is no menu to show. Let
// the browser's own context menu open rather than suppressing it and
// mounting a scroll-locking overlay around nothing.
if (visibleItems.length === 0) {
return;
}

e.preventDefault();

refs.setPositionReference({
Expand All @@ -238,19 +258,6 @@ const NodeContextMenuPlugin = forwardRef<
},
});

let visibleItems: ContextMenuType[] = [];
if (items) {
editor.read(() => {
const node =
$getNearestNodeFromDOMNode(e.target as Element) ?? $getRoot();
if (node) {
visibleItems = items!.filter(option =>
option.$showOn ? option.$showOn(node) : true,
);
}
});
}

const renderableItems = visibleItems.map((option, index) => {
if (option.type === 'separator') {
return {
Expand Down
11 changes: 10 additions & 1 deletion packages/lexical-react/src/LexicalTreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {type EditorState, type LexicalEditor, mergeRegister} from 'lexical';
import * as React from 'react';
import {type JSX, useEffect, useState} from 'react';

import useLayoutEffect from './shared/useLayoutEffect';

/**
* TreeView is a React component that provides a visual representation of
* the Lexical editor's state and enables debugging features like time travel
Expand Down Expand Up @@ -65,7 +67,14 @@ export function TreeView({

const commandsLog = useLexicalCommandsLog(editor);

useEffect(() => {
// useLayoutEffect, like useCanShowPlaceholder and ContentEditableElement, so
// the state can be re-derived before subscribing without the cascading
// render that setState inside a useEffect body causes.
useLayoutEffect(() => {
// The state was seeded on the first render only, so re-read it here: when
// the editor prop changes, neither listener has fired yet for the new
// editor and the view would keep rendering the previous editor's state.
setEditorCurrentState(editor.getEditorState());
// Registers listeners to update the tree view when the editor state changes
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
Expand Down
Loading
Loading