Files
Genarrative/src/components/common/AutoGrowTextArea.tsx
T
k88936 676bd524ee
Project CI / Repository checks (push) Successful in 1m23s
Project CI / Frontend tests (push) Successful in 3m1s
Project CI / Backend tests (push) Successful in 3m51s
Project CI / Native shell tests (push) Successful in 13m32s
滑动条样式调整 (#149)
示例来自微信:
![shotmd-1786091231.jpg](/attachments/b59bae77-00ca-4669-8a54-a9786ae53c93)

before:
- firefox:
![shotmd-1786091298.jpg](/attachments/80bbd46e-551d-4b44-abe4-d3d0ecaa34dd)

- edge:
![shotmd-1786091062.jpg](/attachments/7918c523-1b5b-4439-926d-591e0029c5b9)

after:
- firefox:
![shotmd-1786110119.jpg](/attachments/31b22537-236b-4782-bc47-28ac5ecb0f9f)
![shotmd-1786110060.jpg](/attachments/b48d5343-fd07-451d-943a-55000600e248)

- edge:

![shotmd-1786110050.jpg](/attachments/cfd0d8e4-7d52-45f2-bab8-1b573b3a43b0)

- 使用 Lexical + OverlayScrollbars 实现统一可控的滑动条样式, 取代原来的textarea

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/149
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-08 15:10:16 +08:00

444 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const contentEditableRef = useRef<HTMLDivElement>(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,也不接入原生 formReact value 是唯一文本真值,提交只能走显式回调。
<div
ref={hostRef}
className={resolvedClassName}
style={style}
data-disabled={disabled || undefined}
data-readonly={readOnly || undefined}
>
<div
ref={viewportRef}
className="auto-grow-text-area__viewport"
>
<div ref={contentRef} className="auto-grow-text-area__content-root">
<LexicalComposer initialConfig={initialConfig}>
<PlainTextPlugin
contentEditable={
<ContentEditable
className="auto-grow-text-area__content"
role="textbox"
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-disabled={disabled || undefined}
aria-invalid={ariaInvalid}
aria-multiline="true"
aria-readonly={readOnly || undefined}
spellCheck
tabIndex={disabled ? -1 : 0}
ref={contentEditableRef}
/>
}
placeholder={
placeholder ? (
<div className="auto-grow-text-area__placeholder">
{placeholder}
</div>
) : null
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ControlledPlainTextPlugin
value={value}
onValueChange={onValueChange}
disabled={disabled}
readOnly={readOnly}
maxLength={maxLength}
onPaste={onPaste}
onSubmitRequest={onSubmitRequest}
onEditorUpdate={scheduleResize}
/>
</LexicalComposer>
</div>
</div>
</div>
);
}