backstory/frontend/src/components/BackstoryTextField.tsx
2025-06-12 12:27:39 -07:00

148 lines
4.4 KiB
TypeScript

import React, { useRef, useEffect, CSSProperties, KeyboardEvent, useState, useImperativeHandle } from 'react';
import { useTheme } from '@mui/material/styles';
import './BackstoryTextField.css';
// Define ref interface for exposed methods
interface BackstoryTextFieldRef {
getValue: () => string;
setValue: (value: string) => void;
getAndResetValue: () => string;
}
interface BackstoryTextFieldProps {
value?: string;
disabled?: boolean;
placeholder: string;
onEnter?: (value: string) => void;
onChange?: (value: string) => void;
style?: CSSProperties;
}
const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryTextFieldProps>((props, ref) => {
const {
value = '',
disabled = false,
placeholder,
onEnter,
onChange,
style,
} = props;
const theme = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const shadowRef = useRef<HTMLTextAreaElement>(null);
const [editValue, setEditValue] = useState<string>(value);
// Sync editValue with prop value if it changes externally
useEffect(() => {
setEditValue(value || "");
}, [value]);
// Adjust textarea height based on content
useEffect(() => {
if (!textareaRef.current || !shadowRef.current) {
return;
}
const textarea = textareaRef.current;
const shadow = shadowRef.current;
// Set shadow value to editValue or placeholder if editValue is empty
shadow.value = editValue || placeholder;
// Ensure shadow textarea has same content-relevant styles
const computed = getComputedStyle(textarea);
shadow.style.width = computed.width; // Match width for accurate wrapping
shadow.style.fontSize = computed.fontSize;
shadow.style.lineHeight = computed.lineHeight;
shadow.style.fontFamily = computed.fontFamily;
shadow.style.letterSpacing = computed.letterSpacing;
shadow.style.wordSpacing = computed.wordSpacing;
// Use requestAnimationFrame to ensure DOM is settled
const raf = requestAnimationFrame(() => {
const paddingTop = parseFloat(computed.paddingTop || '0');
const paddingBottom = parseFloat(computed.paddingBottom || '0');
const totalPadding = paddingTop + paddingBottom;
// Reset height to auto to allow shrinking
textarea.style.height = 'auto';
const newHeight = shadow.scrollHeight + totalPadding;
textarea.style.height = `${newHeight}px`;
});
// Cleanup RAF to prevent memory leaks
return () => cancelAnimationFrame(raf);
}, [editValue, placeholder]);
// Expose getValue method via ref
useImperativeHandle(ref, () => ({
getValue: () => editValue,
setValue: (value: string) => setEditValue(value),
getAndResetValue: () => { const _ev = editValue; setEditValue(''); return _ev; }
}));
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); // Prevent newline
onEnter && onEnter(editValue);
setEditValue(''); // Clear textarea
}
};
const fullStyle: CSSProperties = {
display: 'flex',
flexGrow: 1,
width: '100%',
padding: '16.5px 14px',
resize: 'none',
overflow: 'hidden',
boxSizing: 'border-box',
// minHeight: 'calc(1.5rem + 28px)', // lineHeight + padding
lineHeight: '1.5',
borderRadius: '4px',
fontSize: '16px',
backgroundColor: 'rgba(0, 0, 0, 0)',
fontFamily: theme.typography.fontFamily,
...style,
};
return (
<>
<textarea
className="BackstoryTextField"
ref={textareaRef}
value={editValue}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => { setEditValue(e.target.value); onChange && onChange(e.target.value); }}
onKeyDown={handleKeyDown}
style={fullStyle}
/>
<textarea
className="BackgroundTextField"
ref={shadowRef}
aria-hidden="true"
style={{
...fullStyle,
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
padding: '0px', // No padding to match content height
margin: '0px',
border: '0px', // Remove border to avoid extra height
height: 'auto', // Allow natural height
minHeight: '0px',
}}
readOnly
tabIndex={-1}
/>
</>
);
});
export type {
BackstoryTextFieldRef
};
export { BackstoryTextField };