Files
Genarrative/src/components/common/AutoGrowTextArea.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

432 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,也不接入原生 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>
);
}