134 lines
4.2 KiB
TypeScript
134 lines
4.2 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(
|
|
"overflow-y-auto overflow-x-hidden w-full min-h-6",
|
|
wrapperClassName,
|
|
)}
|
|
onClick={() => localRef.current?.focus()}
|
|
>
|
|
<div
|
|
ref={localRef}
|
|
contentEditable={!disabled}
|
|
onInput={handleInput}
|
|
onPaste={handlePaste}
|
|
onBlur={onBlur}
|
|
onFocus={onFocus}
|
|
className={cn(
|
|
"outline-none break-all",
|
|
"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;
|