diff --git a/packages/lexical-react/flow/LexicalContentEditable.js.flow b/packages/lexical-react/flow/LexicalContentEditable.js.flow index 8f2752fe6d0..99004d8b137 100644 --- a/packages/lexical-react/flow/LexicalContentEditable.js.flow +++ b/packages/lexical-react/flow/LexicalContentEditable.js.flow @@ -7,16 +7,53 @@ * @flow strict */ +import type { LexicalEditor } from 'lexical'; +// $FlowFixMe - Not able to type this with a flow extension +import type {TRefFor} from 'CoreTypes.flow'; + import * as React from 'react'; +import type { AbstractComponent } from "react"; + +type InlineStyle = { + [key: string]: mixed; +} + +// Due to Flow limitations, we prefer fixed types over the built-in inexact HTMLElement +type HTMLDivElementDOMProps = $ReadOnly<{ + 'aria-label'?: void | string, + 'aria-labeledby'?: void | string, + 'title'?: void | string, + onClick?: void | (e: SyntheticEvent<HTMLDivElement>) => mixed, + autoCapitalize?: void | boolean, + autoComplete?: void | boolean, + autoCorrect?: void | boolean, + id?: void | string, + className?: void | string, + 'data-testid'?: void | string, + role?: void | string, + spellCheck?: void | boolean, + suppressContentEditableWarning?: void | boolean, + tabIndex?: void | number, + style?: void | InlineStyle | CSSStyleDeclaration, + 'data-testid'?: void | string, +}>; + +export type PlaceholderProps = + | $ReadOnly<{ + 'aria-placeholder'?: void, + placeholder?: null, + }> + | $ReadOnly<{ + 'aria-placeholder': string, + placeholder: + | ((isEditable: boolean) => null | React$Node) + | null + | React$Node, + }>; -export type Props = ({...Partial<HTMLDivElement>,...} | $ReadOnly<{ - 'aria-placeholder': string; - placeholder: - | ((isEditable: boolean) => null | React$Node) - | null - | React$Node; -}>) & $ReadOnly<{ - ...Partial<HTMLDivElement>, +export type Props = $ReadOnly<{ + ...HTMLDivElementDOMProps, + editor__DEPRECATED?: LexicalEditor; ariaActiveDescendant?: string, ariaAutoComplete?: string, ariaControls?: string, @@ -26,10 +63,13 @@ export type Props = ({...Partial<HTMLDivElement>,...} | $ReadOnly<{ ariaLabelledBy?: string, ariaMultiline?: boolean, ariaOwns?: string, - ariaRequired?: boolean, + ariaRequired?: string, autoCapitalize?: boolean, - 'data-testid'?: string | null, - ... -}>; + ref?: TRefFor<HTMLDivElement>, + ...PlaceholderProps +}> -declare export function ContentEditable(props: Props): React$Node; +declare export var ContentEditable: AbstractComponent< + Props, + HTMLDivElement, +>; diff --git a/packages/lexical-react/flow/LexicalPlainTextPlugin.js.flow b/packages/lexical-react/flow/LexicalPlainTextPlugin.js.flow index be50e13f492..7a9e8b403e2 100644 --- a/packages/lexical-react/flow/LexicalPlainTextPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalPlainTextPlugin.js.flow @@ -21,6 +21,9 @@ type InitialEditorStateType = declare export function PlainTextPlugin({ contentEditable: React$Node, - placeholder: ((isEditable: boolean) => React$Node) | React$Node, + placeholder?: + | ((isEditable: boolean) => null | React$Node) + | null + | React$Node; ErrorBoundary: LexicalErrorBoundary, }): React$Node; diff --git a/packages/lexical-react/flow/LexicalRichTextPlugin.js.flow b/packages/lexical-react/flow/LexicalRichTextPlugin.js.flow index a99380c5fe4..a07bf9e92f4 100644 --- a/packages/lexical-react/flow/LexicalRichTextPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalRichTextPlugin.js.flow @@ -21,6 +21,9 @@ type InitialEditorStateType = declare export function RichTextPlugin({ contentEditable: React$Node, - placeholder: ((isEditable: boolean) => React$Node) | React$Node, + placeholder?: + | ((isEditable: boolean) => null | React$Node) + | null + | React$Node; ErrorBoundary: LexicalErrorBoundary, }): React$Node; diff --git a/packages/lexical-react/src/LexicalContentEditable.tsx b/packages/lexical-react/src/LexicalContentEditable.tsx index 2ca5839087e..30829f6fb40 100644 --- a/packages/lexical-react/src/LexicalContentEditable.tsx +++ b/packages/lexical-react/src/LexicalContentEditable.tsx @@ -7,50 +7,69 @@ */ import type {Props as ElementProps} from './shared/LexicalContentEditableElement'; +import type {LexicalEditor} from 'lexical'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; +import {forwardRef, Ref, useLayoutEffect, useState} from 'react'; import {ContentEditableElement} from './shared/LexicalContentEditableElement'; import {useCanShowPlaceholder} from './shared/useCanShowPlaceholder'; /* eslint-disable @typescript-eslint/ban-types */ -export type Props = ( - | {} - | { - 'aria-placeholder': string; - placeholder: - | ((isEditable: boolean) => null | JSX.Element) - | null - | JSX.Element; - } -) & - ElementProps; +export type Props = Omit<ElementProps, 'editor'> & { + editor__DEPRECATED?: LexicalEditor; +} & ( + | { + 'aria-placeholder'?: void; + placeholder?: null; + } + | { + 'aria-placeholder': string; + placeholder: + | ((isEditable: boolean) => null | JSX.Element) + | JSX.Element; + } + ); + /* eslint-enable @typescript-eslint/ban-types */ -export function ContentEditable(props: Props): JSX.Element { - let placeholder = null; - let rest = props; - if ('placeholder' in props) { - ({placeholder, ...rest} = props); - } +export const ContentEditable = forwardRef(ContentEditableImpl); + +function ContentEditableImpl( + props: Props, + ref: Ref<HTMLDivElement>, +): JSX.Element { + const {placeholder, editor__DEPRECATED, ...rest} = props; + // editor__DEPRECATED will always be defined for non MLC surfaces + // eslint-disable-next-line react-hooks/rules-of-hooks + const editor = editor__DEPRECATED || useLexicalComposerContext()[0]; return ( <> - <ContentEditableElement {...rest} /> - <Placeholder content={placeholder} /> + <ContentEditableElement editor={editor} {...rest} ref={ref} /> + {placeholder != null && ( + <Placeholder editor={editor} content={placeholder} /> + )} </> ); } function Placeholder({ content, + editor, }: { - content: ((isEditable: boolean) => null | JSX.Element) | null | JSX.Element; + editor: LexicalEditor; + content: ((isEditable: boolean) => null | JSX.Element) | JSX.Element; }): null | JSX.Element { - const [editor] = useLexicalComposerContext(); const showPlaceholder = useCanShowPlaceholder(editor); - const editable = useLexicalEditable(); + + const [isEditable, setEditable] = useState(editor.isEditable()); + useLayoutEffect(() => { + setEditable(editor.isEditable()); + return editor.registerEditableListener((currentIsEditable) => { + setEditable(currentIsEditable); + }); + }, [editor]); if (!showPlaceholder) { return null; @@ -58,7 +77,7 @@ function Placeholder({ let placeholder = null; if (typeof content === 'function') { - placeholder = content(editable); + placeholder = content(isEditable); } else if (content !== null) { placeholder = content; } diff --git a/packages/lexical-react/src/shared/LexicalContentEditableElement.tsx b/packages/lexical-react/src/shared/LexicalContentEditableElement.tsx index 328da5d4028..2e1208e0d64 100644 --- a/packages/lexical-react/src/shared/LexicalContentEditableElement.tsx +++ b/packages/lexical-react/src/shared/LexicalContentEditableElement.tsx @@ -6,12 +6,16 @@ * */ -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; + import * as React from 'react'; -import {useCallback, useState} from 'react'; +import {forwardRef, Ref, useCallback, useMemo, useState} from 'react'; import useLayoutEffect from 'shared/useLayoutEffect'; +import {mergeRefs} from './mergeRefs'; + export type Props = { + editor: LexicalEditor; ariaActiveDescendant?: React.AriaAttributes['aria-activedescendant']; ariaAutoComplete?: React.AriaAttributes['aria-autocomplete']; ariaControls?: React.AriaAttributes['aria-controls']; @@ -26,31 +30,34 @@ export type Props = { 'data-testid'?: string | null | undefined; } & Omit<React.AllHTMLAttributes<HTMLDivElement>, 'placeholder'>; -export function ContentEditableElement({ - ariaActiveDescendant, - ariaAutoComplete, - ariaControls, - ariaDescribedBy, - ariaExpanded, - ariaLabel, - ariaLabelledBy, - ariaMultiline, - ariaOwns, - ariaRequired, - autoCapitalize, - className, - id, - role = 'textbox', - spellCheck = true, - style, - tabIndex, - 'data-testid': testid, - ...rest -}: Props): JSX.Element { - const [editor] = useLexicalComposerContext(); - const [isEditable, setEditable] = useState(false); +function ContentEditableElementImpl( + { + editor, + ariaActiveDescendant, + ariaAutoComplete, + ariaControls, + ariaDescribedBy, + ariaExpanded, + ariaLabel, + ariaLabelledBy, + ariaMultiline, + ariaOwns, + ariaRequired, + autoCapitalize, + className, + id, + role = 'textbox', + spellCheck = true, + style, + tabIndex, + 'data-testid': testid, + ...rest + }: Props, + ref: Ref<HTMLDivElement>, +): JSX.Element { + const [isEditable, setEditable] = useState(editor.isEditable()); - const ref = useCallback( + const handleRef = useCallback( (rootElement: null | HTMLElement) => { // defaultView is required for a root element. // In multi-window setups, the defaultView may not exist at certain points. @@ -66,6 +73,7 @@ export function ContentEditableElement({ }, [editor], ); + const mergedRefs = useMemo(() => mergeRefs(ref, handleRef), [handleRef, ref]); useLayoutEffect(() => { setEditable(editor.isEditable()); @@ -77,33 +85,31 @@ export function ContentEditableElement({ return ( <div {...rest} - aria-activedescendant={!isEditable ? undefined : ariaActiveDescendant} - aria-autocomplete={!isEditable ? 'none' : ariaAutoComplete} - aria-controls={!isEditable ? undefined : ariaControls} + aria-activedescendant={isEditable ? ariaActiveDescendant : undefined} + aria-autocomplete={isEditable ? ariaAutoComplete : 'none'} + aria-controls={isEditable ? ariaControls : undefined} aria-describedby={ariaDescribedBy} aria-expanded={ - !isEditable - ? undefined - : role === 'combobox' - ? !!ariaExpanded - : undefined + isEditable && role === 'combobox' ? !!ariaExpanded : undefined } aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} aria-multiline={ariaMultiline} - aria-owns={!isEditable ? undefined : ariaOwns} - aria-readonly={!isEditable ? true : undefined} + aria-owns={isEditable ? ariaOwns : undefined} + aria-readonly={isEditable ? undefined : true} aria-required={ariaRequired} autoCapitalize={autoCapitalize} className={className} contentEditable={isEditable} data-testid={testid} id={id} - ref={ref} - role={role} + ref={mergedRefs} + role={isEditable ? role : undefined} spellCheck={spellCheck} style={style} tabIndex={tabIndex} /> ); } + +export const ContentEditableElement = forwardRef(ContentEditableElementImpl); diff --git a/packages/lexical-react/src/shared/mergeRefs.ts b/packages/lexical-react/src/shared/mergeRefs.ts new file mode 100644 index 00000000000..23ddadd0620 --- /dev/null +++ b/packages/lexical-react/src/shared/mergeRefs.ts @@ -0,0 +1,24 @@ +/** + * 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. + * + */ +// Source: https://github.com/gregberge/react-merge-refs/blob/main/src/index.tsx + +export function mergeRefs<T>( + ...refs: Array< + React.MutableRefObject<T> | React.LegacyRef<T> | undefined | null + > +): React.RefCallback<T> { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value); + } else if (ref != null) { + (ref as React.MutableRefObject<T | null>).current = value; + } + }); + }; +}