返回 JoyAI-Echo
CountableTextarea.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / components / thread / CountableTextarea.tsx
1 import {
2 forwardRef,
3 useCallback,
4 useMemo,
5 type CompositionEventHandler,
6 type FormEventHandler,
7 } from "react";
8
9 import { countMixedUnits, truncateToMaxUnits } from "@/lib/countText";
10 import { cn } from "@/lib/utils";
11
12 export interface CountableTextareaProps
13 extends Omit<
14 React.TextareaHTMLAttributes<HTMLTextAreaElement>,
15 "onChange" | "value"
16 > {
17 value: string;
18 onChange: (value: string) => void;
19 /** Mixed unit limit; excess input is silently truncated. */
20 maxUnits?: number;
21 /** Whether to show the count label. Default true. */
22 showCount?: boolean;
23 /** Count label placement. Default "bottom-right". */
24 countPosition?: "bottom-right" | "none";
25 /** Custom count formatter. */
26 formatCount?: (count: number) => string;
27 }
28
29 const MAX_HEIGHT_PX = 260;
30
31 export const CountableTextarea = forwardRef<
32 HTMLTextAreaElement,
33 CountableTextareaProps
34 >(function CountableTextarea(
35 {
36 value,
37 onChange,
38 maxUnits,
39 showCount = true,
40 countPosition = "bottom-right",
41 formatCount,
42 className,
43 onInput,
44 onCompositionEnd,
45 ...props
46 },
47 ref,
48 ) {
49 const count = useMemo(() => countMixedUnits(value), [value]);
50
51 const applyValue = useCallback(
52 (next: string) => {
53 const limited =
54 maxUnits !== undefined ? truncateToMaxUnits(next, maxUnits) : next;
55 onChange(limited);
56 },
57 [maxUnits, onChange],
58 );
59
60 const resize = useCallback((el: HTMLTextAreaElement) => {
61 el.style.height = "auto";
62 el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT_PX)}px`;
63 }, []);
64
65 const handleInput: FormEventHandler<HTMLTextAreaElement> = useCallback(
66 (e) => {
67 resize(e.currentTarget);
68 onInput?.(e);
69 },
70 [onInput, resize],
71 );
72
73 const handleCompositionEnd: CompositionEventHandler<
74 HTMLTextAreaElement
75 > = useCallback(
76 (e) => {
77 if (maxUnits !== undefined) {
78 applyValue(e.currentTarget.value);
79 }
80 onCompositionEnd?.(e);
81 },
82 [applyValue, maxUnits, onCompositionEnd],
83 );
84
85 const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> =
86 useCallback(
87 (e) => {
88 applyValue(e.target.value);
89 },
90 [applyValue],
91 );
92
93 const showCountLabel =
94 showCount && countPosition === "bottom-right"
95 ? formatCount
96 ? formatCount(count)
97 : `${count} characters`
98 : null;
99
100 return (
101 <div className="relative">
102 <textarea
103 ref={ref}
104 value={value}
105 onChange={handleChange}
106 onInput={handleInput}
107 onCompositionEnd={handleCompositionEnd}
108 className={cn(className, showCountLabel && "pr-14")}
109 {...props}
110 />
111 {showCountLabel ? (
112 <span
113 className={cn(
114 "pointer-events-none absolute bottom-2 right-3 select-none",
115 "text-[11px] tabular-nums text-muted-foreground/60",
116 )}
117 aria-live="polite"
118 aria-atomic="true"
119 >
120 {showCountLabel}
121 </span>
122 ) : null}
123 </div>
124 );
125 });
126
127 CountableTextarea.displayName = "CountableTextarea";
128
128 lines Plain Text