676bd524ee
示例来自微信:  before: - firefox:  - edge:  after: - firefox:   - edge:  - 使用 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>
444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
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,也不接入原生 form:React 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>
|
||
);
|
||
}
|