Files
kandev/apps/web/components/task/chat/rich-text-input.tsx
T
2026-02-19 22:18:01 +00:00

202 lines
5.6 KiB
TypeScript

"use client";
import {
forwardRef,
useRef,
useImperativeHandle,
type KeyboardEvent,
type ChangeEvent,
useCallback,
} from "react";
import { cn } from "@/lib/utils";
function isSubmitKeyPress(
event: KeyboardEvent<HTMLTextAreaElement>,
submitKey: "enter" | "cmd_enter",
): boolean {
if (submitKey === "enter") {
return event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey;
}
return event.key === "Enter" && (event.metaKey || event.ctrlKey);
}
const MIRROR_STYLES = [
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"letterSpacing",
"textTransform",
"wordSpacing",
"textIndent",
"whiteSpace",
"wordWrap",
"wordBreak",
"overflowWrap",
"lineHeight",
"padding",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderWidth",
"boxSizing",
] as const;
function measureCaretRect(textarea: HTMLTextAreaElement, value: string): DOMRect {
const selectionStart = textarea.selectionStart;
const computed = window.getComputedStyle(textarea);
const mirror = document.createElement("div");
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.whiteSpace = "pre-wrap";
mirror.style.wordWrap = "break-word";
mirror.style.width = `${textarea.clientWidth}px`;
MIRROR_STYLES.forEach((prop) => {
mirror.style[prop as unknown as number] = computed[prop];
});
document.body.appendChild(mirror);
mirror.textContent = value.substring(0, selectionStart);
const marker = document.createElement("span");
marker.textContent = "\u200B";
mirror.appendChild(marker);
const textareaRect = textarea.getBoundingClientRect();
const markerRect = marker.getBoundingClientRect();
const mirrorRect = mirror.getBoundingClientRect();
const scrollTop = textarea.scrollTop;
document.body.removeChild(mirror);
return new DOMRect(
textareaRect.left + (markerRect.left - mirrorRect.left),
textareaRect.top + (markerRect.top - mirrorRect.top) - scrollTop,
0,
parseInt(computed.lineHeight, 10) || parseInt(computed.fontSize, 10) * 1.2,
);
}
export type RichTextInputHandle = {
focus: () => void;
blur: () => void;
getSelectionStart: () => number;
getSelectionEnd: () => number;
setSelectionRange: (start: number, end: number) => void;
getCaretRect: () => DOMRect | null;
getValue: () => string;
setValue: (value: string) => void;
insertText: (text: string, from: number, to: number) => void;
getTextareaElement: () => HTMLTextAreaElement | null;
};
type RichTextInputProps = {
value: string;
onChange: (value: string) => void;
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
onSubmit?: () => void;
placeholder?: string;
disabled?: boolean;
className?: string;
planModeEnabled?: boolean;
submitKey?: "enter" | "cmd_enter";
onFocus?: () => void;
onBlur?: () => void;
};
export const RichTextInput = forwardRef<RichTextInputHandle, RichTextInputProps>(
function RichTextInput(
{
value,
onChange,
onKeyDown,
onSubmit,
placeholder,
disabled = false,
className,
planModeEnabled = false,
submitKey = "cmd_enter",
onFocus,
onBlur,
},
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const getCaretRect = useCallback((): DOMRect | null => {
const textarea = textareaRef.current;
if (!textarea) return null;
return measureCaretRect(textarea, value);
}, [value]);
useImperativeHandle(
ref,
() => ({
focus: () => textareaRef.current?.focus(),
blur: () => textareaRef.current?.blur(),
getSelectionStart: () => textareaRef.current?.selectionStart ?? 0,
getSelectionEnd: () => textareaRef.current?.selectionEnd ?? 0,
setSelectionRange: (start: number, end: number) => {
textareaRef.current?.setSelectionRange(start, end);
},
getCaretRect,
getValue: () => textareaRef.current?.value ?? "",
setValue: (newValue: string) => onChange(newValue),
insertText: (text: string, from: number, to: number) => {
const newValue = value.substring(0, from) + text + value.substring(to);
onChange(newValue);
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(from + text.length, from + text.length);
});
},
getTextareaElement: () => textareaRef.current,
}),
[getCaretRect, onChange, value],
);
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
onChange(event.target.value);
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (onKeyDown) {
onKeyDown(event);
if (event.defaultPrevented) return;
}
const isSubmitKey = isSubmitKeyPress(event, submitKey);
if (disabled) {
if (isSubmitKey) event.preventDefault();
return;
}
if (isSubmitKey) {
event.preventDefault();
onSubmit?.();
}
};
return (
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
disabled={disabled}
className={cn(
"w-full h-full resize-none bg-transparent px-2 py-2 overflow-y-auto",
"text-sm leading-relaxed",
"placeholder:text-muted-foreground",
"focus:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
planModeEnabled && "border-primary/40",
className,
)}
/>
);
},
);