import 'overlayscrollbars/styles/overlayscrollbars.css'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; import { mergeRegister } from '@lexical/utils'; import { $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, COMPOSITION_END_COMMAND, COMPOSITION_START_COMMAND, INSERT_LINE_BREAK_COMMAND, KEY_ENTER_COMMAND, type LexicalEditor, PASTE_COMMAND, } from 'lexical'; import type { PartialOptions } from 'overlayscrollbars'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, } from 'react'; const CONTROLLED_VALUE_TAG = 'auto-grow-controlled-value'; const MAX_LENGTH_TAG = 'auto-grow-max-length'; const OVERLAY_SCROLLBAR_OPTIONS = { overflow: { x: 'hidden', y: 'scroll', }, scrollbars: { autoHide: 'never', clickScroll: false, dragScroll: true, // 由 OverlayScrollbars 统一绘制,避免 Edge / Firefox 原生滚动条差异。 theme: 'os-theme-auto-grow-text-area', visibility: 'auto', }, } satisfies PartialOptions; export type AutoGrowTextAreaProps = { value: string; onValueChange: (value: string) => void; className?: string; style?: CSSProperties; placeholder?: string; disabled?: boolean; readOnly?: boolean; maxLength?: number; onPaste?: (event: ClipboardEvent) => void; onSubmitRequest?: () => void; 'aria-label': string; 'aria-describedby'?: string; 'aria-invalid'?: boolean | 'false' | 'true'; }; function replaceEditorText(value: string, selectEnd: boolean) { const root = $getRoot(); const paragraph = $createParagraphNode(); const lines = value.split('\n'); lines.forEach((line, index) => { if (line) { paragraph.append($createTextNode(line)); } if (index < lines.length - 1) { paragraph.append($createLineBreakNode()); } }); root.clear().append(paragraph); if (selectEnd) { paragraph.selectEnd(); } } function trimToUtf16Length(value: string, maxLength: number) { if (value.length <= maxLength) { return value; } let trimmed = value.slice(0, maxLength); const finalCodeUnit = trimmed.charCodeAt(trimmed.length - 1); if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) { trimmed = trimmed.slice(0, -1); } return trimmed; } function readEditorText(editor: LexicalEditor) { return editor.getEditorState().read(() => $getRoot().getTextContent()); } function readPixelValue(value: string, fallback: number) { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : fallback; } function ControlledPlainTextPlugin({ value, onValueChange, disabled, readOnly, maxLength, onPaste, onSubmitRequest, onEditorUpdate, }: Pick< AutoGrowTextAreaProps, | 'value' | 'onValueChange' | 'disabled' | 'readOnly' | 'maxLength' | 'onPaste' | 'onSubmitRequest' > & { onEditorUpdate: () => void; }) { const [editor] = useLexicalComposerContext(); const controlledValueRef = useRef(value); const onValueChangeRef = useRef(onValueChange); const isComposingRef = useRef(false); controlledValueRef.current = value; onValueChangeRef.current = onValueChange; useEffect(() => { editor.setEditable(!disabled && !readOnly); }, [disabled, editor, readOnly]); useEffect(() => { const currentText = readEditorText(editor); if (currentText === value) { return; } controlledValueRef.current = value; editor.update(() => replaceEditorText(value, true), { discrete: true, tag: CONTROLLED_VALUE_TAG, }); editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); }, [editor, value]); useEffect(() => { let active = true; const publishCurrentValue = () => { if (!active || isComposingRef.current || editor.isComposing()) { return; } const editorText = readEditorText(editor); const nextValue = typeof maxLength === 'number' ? trimToUtf16Length(editorText, Math.max(0, maxLength)) : editorText; if (nextValue !== editorText) { editor.update(() => replaceEditorText(nextValue, true), { discrete: true, tag: MAX_LENGTH_TAG, }); } if (nextValue !== controlledValueRef.current) { controlledValueRef.current = nextValue; onValueChangeRef.current(nextValue); } }; return mergeRegister( editor.registerUpdateListener(({ tags }) => { onEditorUpdate(); if ( tags.has(CONTROLLED_VALUE_TAG) || tags.has(MAX_LENGTH_TAG) ) { return; } publishCurrentValue(); }), editor.registerCommand( COMPOSITION_START_COMMAND, () => { isComposingRef.current = true; return false; }, COMMAND_PRIORITY_LOW, ), editor.registerCommand( COMPOSITION_END_COMMAND, () => { isComposingRef.current = false; queueMicrotask(publishCurrentValue); return false; }, COMMAND_PRIORITY_LOW, ), () => { active = false; }, ); }, [editor, maxLength, onEditorUpdate]); useEffect( () => mergeRegister( editor.registerCommand( KEY_ENTER_COMMAND, (event) => { if (!event) { return false; } if ( event.isComposing || event.keyCode === 229 || !onSubmitRequest || event.shiftKey ) { return false; } event.preventDefault(); onSubmitRequest(); return true; }, COMMAND_PRIORITY_HIGH, ), editor.registerCommand( PASTE_COMMAND, (event) => { if (!onPaste || !('clipboardData' in event)) { return false; } onPaste(event as ClipboardEvent); return event.defaultPrevented; }, COMMAND_PRIORITY_HIGH, ), ), [editor, onPaste, onSubmitRequest], ); useEffect(() => { if (onSubmitRequest) { return undefined; } return editor.registerCommand( KEY_ENTER_COMMAND, (event) => { if (!event || event.isComposing || event.keyCode === 229) { return false; } event.preventDefault(); return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false); }, COMMAND_PRIORITY_HIGH, ); }, [editor, onSubmitRequest]); return null; } export function AutoGrowTextArea({ value, onValueChange, className, style, placeholder, disabled = false, readOnly = false, maxLength, onPaste, onSubmitRequest, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: AutoGrowTextAreaProps) { const initialValueRef = useRef(value); const hostRef = useRef(null); const viewportRef = useRef(null); const contentRef = useRef(null); const contentEditableRef = useRef(null); const [initializeOverlayScrollbars, overlayScrollbarsInstance] = useOverlayScrollbars({ options: OVERLAY_SCROLLBAR_OPTIONS }); const initialConfig = useMemo( () => ({ editable: !disabled && !readOnly, editorState: () => replaceEditorText(initialValueRef.current, false), namespace: 'AutoGrowTextArea', onError(error: Error) { throw error; }, }), // LexicalComposer intentionally reads its initial configuration once. [disabled, readOnly], ); const resolvedClassName = [ 'auto-grow-text-area', className, ] .filter(Boolean) .join(' '); const resizeHost = useCallback(() => { const host = hostRef.current; const contentEditable = contentEditableRef.current; if (!host || !contentEditable) { return; } const style = window.getComputedStyle(host); const minHeight = readPixelValue(style.minHeight, 40); const maxHeight = Math.max( minHeight, readPixelValue(style.maxHeight, 128), ); const borderHeight = readPixelValue(style.borderTopWidth, 0) + readPixelValue(style.borderBottomWidth, 0); const nextHeight = Math.min( Math.max(contentEditable.scrollHeight + borderHeight, minHeight), maxHeight, ); host.style.height = `${nextHeight}px`; overlayScrollbarsInstance()?.update(true); }, [overlayScrollbarsInstance]); const scheduleResize = useCallback(() => { resizeHost(); }, [resizeHost]); useLayoutEffect(() => { resizeHost(); const contentEditable = contentEditableRef.current; if (!contentEditable) { return undefined; } if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', resizeHost); return () => window.removeEventListener('resize', resizeHost); } const observer = new ResizeObserver(resizeHost); observer.observe(contentEditable); return () => observer.disconnect(); }, [resizeHost]); useLayoutEffect(() => { scheduleResize(); }, [scheduleResize, value]); useEffect(() => { const host = hostRef.current; const viewport = viewportRef.current; const content = contentRef.current; if (!host || !viewport || !content) { return; } initializeOverlayScrollbars({ target: host, elements: { viewport, content }, }); resizeHost(); }, [initializeOverlayScrollbars, resizeHost]); return ( // 有意不渲染隐藏 textarea,也不接入原生 form:React value 是唯一文本真值,提交只能走显式回调。
} placeholder={ placeholder ? (
{placeholder}
) : null } ErrorBoundary={LexicalErrorBoundary} />
); }