From 6ab546be1fd7210e5aa0ae5e01693087be06d49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 21:45:16 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E8=87=AA=E9=80=82=E5=BA=94=E8=BE=93=E5=85=A5=E6=A1=86=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 Lexical 管理受控纯文本输入 接入 OverlayScrollbars 统一内部滚动条 补充编辑器状态与滚动结构行为测试 --- package-lock.json | 29 + package.json | 2 + .../common/AutoGrowTextArea.test-utils.ts | 47 ++ .../common/AutoGrowTextArea.test.tsx | 403 +++++--------- src/components/common/AutoGrowTextArea.tsx | 504 ++++++++++++++---- src/index.css | 171 ++++-- src/main.tsx | 1 + 7 files changed, 764 insertions(+), 393 deletions(-) create mode 100644 src/components/common/AutoGrowTextArea.test-utils.ts diff --git a/package-lock.json b/package-lock.json index 0b00be7e4..ea348739a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,8 @@ "lexical": "^0.47.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "overlayscrollbars": "2.16.0", + "overlayscrollbars-react": "0.5.6", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -15895,6 +15897,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/overlayscrollbars": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", + "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==", + "license": "MIT" + }, + "node_modules/overlayscrollbars-react": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz", + "integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==", + "license": "MIT", + "peerDependencies": { + "overlayscrollbars": "^2.0.0", + "react": ">=16.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -31502,6 +31520,17 @@ "wcwidth": "^1.0.1" } }, + "overlayscrollbars": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", + "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==" + }, + "overlayscrollbars-react": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz", + "integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==", + "requires": {} + }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index b26e54986..71533ac35 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,8 @@ "lexical": "^0.47.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "overlayscrollbars": "2.16.0", + "overlayscrollbars-react": "0.5.6", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/components/common/AutoGrowTextArea.test-utils.ts b/src/components/common/AutoGrowTextArea.test-utils.ts new file mode 100644 index 000000000..0351fd607 --- /dev/null +++ b/src/components/common/AutoGrowTextArea.test-utils.ts @@ -0,0 +1,47 @@ +import { act } from '@testing-library/react'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + getNearestEditorFromDOMNode, +} from 'lexical'; + +export function setPlainTextEditorValue( + contentEditable: HTMLElement, + value: string, +) { + const editor = getNearestEditorFromDOMNode(contentEditable); + if (!editor) { + throw new Error('Expected a Lexical editor root'); + } + + act(() => { + editor.update( + () => { + const paragraph = $createParagraphNode(); + value.split('\n').forEach((line, index, lines) => { + if (line) { + paragraph.append($createTextNode(line)); + } + if (index < lines.length - 1) { + paragraph.append($createLineBreakNode()); + } + }); + $getRoot().clear().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + }); +} + +export function getPlainTextEditorHost(contentEditable: HTMLElement) { + const host = contentEditable.closest( + '.auto-grow-text-area', + ); + if (!host) { + throw new Error('Expected an auto-grow plain-text editor host'); + } + return host; +} diff --git a/src/components/common/AutoGrowTextArea.test.tsx b/src/components/common/AutoGrowTextArea.test.tsx index 04caaa941..30fe8dc85 100644 --- a/src/components/common/AutoGrowTextArea.test.tsx +++ b/src/components/common/AutoGrowTextArea.test.tsx @@ -1,288 +1,175 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AutoGrowTextArea } from './AutoGrowTextArea'; -afterEach(() => { - vi.unstubAllGlobals(); +beforeEach(() => { + Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect()); + Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList); + vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal( + 'ClipboardEvent', + class ClipboardEvent extends Event { + clipboardData: DataTransfer; + + constructor( + type: string, + init: EventInit & { clipboardData: DataTransfer }, + ) { + super(type, init); + this.clipboardData = init.clipboardData; + } + }, + ); }); +function pastePlainText(editor: HTMLElement, value: string) { + const clipboardData = { + files: [], + getData: (type: string) => (type === 'text/plain' ? value : ''), + items: [], + types: ['text/plain'], + } as unknown as DataTransfer; + fireEvent( + editor, + new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); +} + +function ControlledEditor({ + initialValue = '', + maxLength, +}: { + initialValue?: string; + maxLength?: number; +}) { + const [value, setValue] = useState(initialValue); + return ( + <> + + {value} + + + ); +} + describe('AutoGrowTextArea', () => { - it('uses native field sizing when the browser supports it', () => { - const supports = vi.fn().mockReturnValue(true); - const resizeObserver = vi.fn(); - const onChange = vi.fn(); - vi.stubGlobal('CSS', { supports }); - vi.stubGlobal('ResizeObserver', resizeObserver); + it('keeps React state authoritative and preserves exact line breaks', async () => { + const user = userEvent.setup(); + render(); - render( - , + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + expect(screen.getByLabelText('React 文本状态').textContent).toBe( + '第一行\n第二行', ); - const input = screen.getByLabelText( - '自动增长输入框', - ) as HTMLTextAreaElement; - expect(supports).toHaveBeenCalledWith('field-sizing', 'content'); - expect(resizeObserver).not.toHaveBeenCalled(); - expect(input.getAttribute('rows')).toBe('1'); - expect(input.className).toContain('auto-grow-text-area'); - expect(input.className).toContain('[field-sizing:content]'); - expect(input.className).toContain('min-h-10'); - expect(input.className).toContain('max-h-32'); - expect(input.className).toContain('overflow-y-auto'); - expect(input.className).toContain('disabled:cursor-not-allowed'); - expect(input.className).toContain('disabled:opacity-60'); - expect(input.style.height).toBe(''); - - fireEvent.change(input, { target: { value: '新内容' } }); - expect(onChange).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: '外部更新' })); + expect(screen.getByLabelText('React 文本状态').textContent).toBe( + '外部\n更新', + ); }); - it('falls back to measured sizing, preserves css limits and remeasures width changes', () => { - let resizeObserverCallback!: ResizeObserverCallback; - const observe = vi.fn(); - const disconnect = vi.fn(); - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation((callback: ResizeObserverCallback) => { - resizeObserverCallback = callback; - return { - observe, - unobserve: vi.fn(), - disconnect, - }; - }), + it('publishes edits through onValueChange and enforces maxLength', async () => { + const user = userEvent.setup(); + render(); + + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + await user.click(editor); + pastePlainText(editor, '12345'); + + await waitFor(() => + expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'), + ); + expect(editor.textContent).toBe('1234'); + }); + + it('supports undo and redo while keeping React state synchronized', async () => { + const user = userEvent.setup(); + render(); + + const editor = screen.getByRole('textbox', { name: '受控编辑器' }); + await user.click(editor); + pastePlainText(editor, '草稿'); + await waitFor(() => + expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'), ); - const { rerender, unmount } = render( + await user.keyboard('{Control>}z{/Control}'); + expect(screen.getByLabelText('React 文本状态').textContent).toBe(''); + + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'); + }); + + it('exposes read-only and disabled accessibility without form controls', () => { + const { container } = render( , ); - const input = screen.getByLabelText( - '自动增长输入框', - ) as HTMLTextAreaElement; - expect(observe).toHaveBeenCalledWith(input); - let contentHeight = 92; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => { - const styleHeight = Number.parseFloat(input.style.height); - return Number.isNaN(styleHeight) ? 82 : styleHeight; - }, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => input.offsetHeight - 2, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - rerender( + const editor = screen.getByRole('textbox', { name: '只读编辑器' }); + expect(editor.getAttribute('aria-disabled')).toBe('true'); + expect(editor.getAttribute('aria-readonly')).toBe('true'); + expect(editor.getAttribute('tabindex')).toBe('-1'); + expect(container.querySelector('textarea')).toBeNull(); + expect(container.querySelector('form')).toBeNull(); + }); + + it('uses a distinct internal viewport for overflow content', async () => { + const { container } = render( `第 ${index + 1} 行`).join( + '\n', + )} + onValueChange={vi.fn()} />, ); - expect(input.style.height).toBe('94px'); - expect(input.style.overflowY).toBe('hidden'); - contentHeight = 180; - act(() => { - resizeObserverCallback( - [ - { - target: input, - contentRect: { width: 180 }, - } as unknown as ResizeObserverEntry, - ], - {} as ResizeObserver, + const viewport = await waitFor(() => { + const element = container.querySelector( + '[data-overlayscrollbars-viewport]', ); + expect(element).not.toBeNull(); + return element!; }); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - - contentHeight = 30; - rerender( - , + const content = container.querySelector( + '[data-overlayscrollbars-content]', ); - expect(input.style.height).toBe('80px'); - expect(input.style.overflowY).toBe('hidden'); - unmount(); - expect(disconnect).toHaveBeenCalledTimes(1); + expect(viewport).not.toBe(content); + expect( + viewport.contains( + screen.getByRole('textbox', { name: '滚动编辑器' }), + ), + ).toBe(true); }); - it('remeasures uncontrolled input changes and forwards the consumer input handler', () => { - const onInput = vi.fn(); - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - })), - ); - - render( - , - ); - const input = screen.getByLabelText( - '非受控自动增长输入框', - ) as HTMLTextAreaElement; - let contentHeight = 90; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => { - const styleHeight = Number.parseFloat(input.style.height); - return Number.isNaN(styleHeight) ? 42 : styleHeight; - }, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => input.offsetHeight - 2, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - - fireEvent.input(input, { - target: { value: '第一行\n第二行\n第三行\n第四行' }, - }); - expect(input.style.height).toBe('92px'); - expect(input.style.overflowY).toBe('hidden'); - - contentHeight = 180; - fireEvent.input(input, { - target: { value: '很多行内容'.repeat(30) }, - }); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - - contentHeight = 20; - fireEvent.input(input, { target: { value: '短内容' } }); - expect(input.style.height).toBe('40px'); - expect(input.style.overflowY).toBe('hidden'); - expect(onInput).toHaveBeenCalledTimes(3); - }); - - it('measures content-box height without double-counting padding or borders', () => { - vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); - vi.stubGlobal( - 'ResizeObserver', - vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - })), - ); - - const { rerender } = render( - , - ); - const input = screen.getByLabelText( - '内容盒输入框', - ) as HTMLTextAreaElement; - let contentHeight = 120; - Object.defineProperty(input, 'offsetHeight', { - configurable: true, - get: () => 122, - }); - Object.defineProperty(input, 'clientHeight', { - configurable: true, - get: () => 120, - }); - Object.defineProperty(input, 'scrollHeight', { - configurable: true, - get: () => contentHeight, - }); - - rerender( - , - ); - expect(input.style.height).toBe('104px'); - expect(input.style.overflowY).toBe('hidden'); - - contentHeight = 160; - rerender( - , - ); - expect(input.style.height).toBe('128px'); - expect(input.style.overflowY).toBe('auto'); - }); }); diff --git a/src/components/common/AutoGrowTextArea.tsx b/src/components/common/AutoGrowTextArea.tsx index 282c7c549..945f8d8af 100644 --- a/src/components/common/AutoGrowTextArea.tsx +++ b/src/components/common/AutoGrowTextArea.tsx @@ -1,130 +1,442 @@ +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; +import { mergeRegister } from '@lexical/utils'; import { - type InputEventHandler, - type TextareaHTMLAttributes, + $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, + PASTE_COMMAND, + type LexicalEditor, +} from 'lexical'; +import type { PartialOptions } from 'overlayscrollbars'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import 'overlayscrollbars/styles/overlayscrollbars.css'; +import { + type CSSProperties, + useCallback, + useEffect, useLayoutEffect, + useMemo, useRef, } from 'react'; -const DEFAULT_MIN_HEIGHT_PX = 40; -const DEFAULT_MAX_HEIGHT_PX = 128; +const CONTROLLED_VALUE_TAG = 'auto-grow-controlled-value'; +const MAX_LENGTH_TAG = 'auto-grow-max-length'; -function supportsNativeFieldSizing() { - return ( - typeof CSS !== 'undefined' && - typeof CSS.supports === 'function' && - CSS.supports('field-sizing', 'content') - ); +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 parseComputedPixelValue(value: string, fallback: number) { - const parsedValue = Number.parseFloat(value); - return Number.isFinite(parsedValue) ? parsedValue : fallback; +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 resizeFallbackTextArea(textarea: HTMLTextAreaElement) { - textarea.style.height = 'auto'; - - const computedStyle = window.getComputedStyle(textarea); - const minHeight = parseComputedPixelValue( - computedStyle.minHeight, - DEFAULT_MIN_HEIGHT_PX, - ); - const maxHeight = Math.max( - minHeight, - parseComputedPixelValue(computedStyle.maxHeight, DEFAULT_MAX_HEIGHT_PX), - ); - const contentHeight = textarea.scrollHeight; - const borderHeight = Math.max( - 0, - textarea.offsetHeight - textarea.clientHeight, - ); - const isBorderBox = computedStyle.boxSizing === 'border-box'; - const paddingHeight = - parseComputedPixelValue(computedStyle.paddingTop, 0) + - parseComputedPixelValue(computedStyle.paddingBottom, 0); - const measuredHeight = isBorderBox - ? contentHeight + borderHeight - : Math.max(0, contentHeight - paddingHeight); - const nextHeight = Math.min( - Math.max(measuredHeight, minHeight), - maxHeight, - ); - - textarea.style.height = `${nextHeight}px`; - textarea.style.overflowY = measuredHeight > maxHeight ? 'auto' : 'hidden'; +function readEditorText(editor: LexicalEditor) { + return editor.getEditorState().read(() => $getRoot().getTextContent()); } -export function AutoGrowTextArea({ - className, - onInput, - rows = 1, +function readPixelValue(value: string, fallback: number) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function ControlledPlainTextPlugin({ value, - ...textareaProps -}: TextareaHTMLAttributes) { - const textareaRef = useRef(null); + 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); - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (!textarea || supportsNativeFieldSizing()) { + controlledValueRef.current = value; + onValueChangeRef.current = onValueChange; + + useEffect(() => { + editor.setEditable(!disabled && !readOnly); + }, [disabled, editor, readOnly]); + + useEffect(() => { + const currentText = readEditorText(editor); + if (currentText === value) { return; } - resizeFallbackTextArea(textarea); - }, [value]); - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (!textarea || supportsNativeFieldSizing()) { + 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; } - let previousWidth = textarea.getBoundingClientRect().width; - const resizeWhenWidthChanges = (nextWidth: number) => { - if (Math.abs(nextWidth - previousWidth) < 0.5) { - return; - } - previousWidth = nextWidth; - resizeFallbackTextArea(textarea); - }; - - if (typeof ResizeObserver !== 'undefined') { - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - resizeWhenWidthChanges(entry.contentRect.width); + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.isComposing || event.keyCode === 229) { + return false; } - }); - observer.observe(textarea); - return () => observer.disconnect(); - } + event.preventDefault(); + return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false); + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, onSubmitRequest]); - const handleWindowResize = () => { - resizeWhenWidthChanges(textarea.getBoundingClientRect().width); - }; - window.addEventListener('resize', handleWindowResize); - return () => window.removeEventListener('resize', handleWindowResize); - }, []); + 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. + [], + ); const resolvedClassName = [ - 'auto-grow-text-area max-h-32 min-h-10 resize-none overflow-x-hidden overflow-y-auto overscroll-contain [field-sizing:content] disabled:cursor-not-allowed disabled:opacity-60', + 'auto-grow-text-area', className, ] .filter(Boolean) .join(' '); - const handleInput: InputEventHandler = (event) => { - if (!supportsNativeFieldSizing()) { - resizeFallbackTextArea(event.currentTarget); + + const resizeHost = useCallback(() => { + const host = hostRef.current; + const contentEditable = contentEditableRef.current; + if (!host || !contentEditable) { + return; } - onInput?.(event); - }; + + 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(() => { + window.requestAnimationFrame(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 ( -