125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
|
import { cn } from '~/lib/utils';
|
|
|
|
export interface TextBoxProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'value'> {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
wrapperClassName?: string;
|
|
inputClassName?: string;
|
|
disabled?: boolean;
|
|
autoFocus?: boolean;
|
|
spellCheck?: boolean;
|
|
}
|
|
|
|
export const TextBox = forwardRef<HTMLDivElement, TextBoxProps>(
|
|
(
|
|
{
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
wrapperClassName,
|
|
inputClassName,
|
|
disabled = false,
|
|
autoFocus = false,
|
|
spellCheck = true,
|
|
onInput,
|
|
onBlur,
|
|
onFocus,
|
|
...rest
|
|
},
|
|
ref
|
|
) => {
|
|
const localRef = useRef<HTMLDivElement>(null);
|
|
useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
|
|
|
// Function to handle DOM updates
|
|
const updateDOM = (newValue: string) => {
|
|
if (localRef.current) {
|
|
// Only update if different to avoid selection issues
|
|
if (localRef.current.textContent !== newValue) {
|
|
localRef.current.textContent = newValue;
|
|
|
|
// Clear any <br> elements if the content is empty
|
|
if (!newValue && localRef.current.innerHTML.includes('<br>')) {
|
|
localRef.current.innerHTML = '';
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update DOM when value prop changes
|
|
useEffect(() => {
|
|
updateDOM(value);
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
if (autoFocus && localRef.current) {
|
|
localRef.current.focus();
|
|
}
|
|
}, [autoFocus]);
|
|
|
|
const handleInput = (event: React.FormEvent<HTMLDivElement>) => {
|
|
const newValue = event.currentTarget.textContent || '';
|
|
|
|
// Handle the case where the content is empty but contains a <br>
|
|
if (!newValue && event.currentTarget.innerHTML.includes('<br>')) {
|
|
event.currentTarget.innerHTML = '';
|
|
}
|
|
|
|
onChange(newValue);
|
|
onInput?.(event);
|
|
};
|
|
|
|
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
const text = event.clipboardData.getData('text/plain');
|
|
|
|
// Use document.execCommand to maintain undo stack
|
|
document.execCommand('insertText', false, text);
|
|
|
|
// Manually trigger input event
|
|
const inputEvent = new Event('input', { bubbles: true });
|
|
event.currentTarget.dispatchEvent(inputEvent);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"max-h-96 w-full overflow-y-auto min-h-6 outline-none p-4 rounded-xl no-scrollbar bg-background border border-input",
|
|
"focus-within:ring-2 focus-within:ring-ring focus-within:border-ring",
|
|
wrapperClassName
|
|
)}
|
|
onClick={() => localRef.current?.focus()}
|
|
>
|
|
<div
|
|
ref={localRef}
|
|
contentEditable={!disabled}
|
|
onInput={handleInput}
|
|
onPaste={handlePaste}
|
|
onBlur={onBlur}
|
|
onFocus={onFocus}
|
|
className={cn(
|
|
"break-words whitespace-pre-wrap outline-none w-full",
|
|
"empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground empty:before:cursor-text",
|
|
disabled && "cursor-not-allowed opacity-50",
|
|
inputClassName
|
|
)}
|
|
data-placeholder={placeholder}
|
|
role="textbox"
|
|
aria-multiline="true"
|
|
aria-disabled={disabled}
|
|
aria-placeholder={placeholder}
|
|
spellCheck={spellCheck}
|
|
suppressContentEditableWarning
|
|
tabIndex={0}
|
|
{...rest}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
TextBox.displayName = 'TextBox';
|
|
|
|
export default TextBox; |