返回 presentation-ai
stats-item.tsx
1 "use client";
2
3 import { Star } from "lucide-react";
4 import { NodeApi, PathApi } from "platejs";
5 import {
6 PlateElement,
7 useReadOnly,
8 type PlateElementProps,
9 } from "platejs/react";
10
11 import { cn } from "@/lib/utils";
12 import {
13 type TStatsGroupElement,
14 type TStatsItemElement,
15 } from "../plugins/stats-plugin";
16 import { getAlignmentClasses } from "../utils";
17 import { getPresentationAccentColor } from "./color-utils";
18
19 interface StatValueProps {
20 className: string;
21 color: string;
22 fitToContent?: boolean;
23 isReadOnly: boolean;
24 stat: string;
25 onChange: (newStat: string) => void;
26 onFocus: () => void;
27 }
28
29 function StatValue({
30 className,
31 color,
32 fitToContent = false,
33 isReadOnly,
34 stat,
35 onChange,
36 onFocus,
37 }: StatValueProps) {
38 if (isReadOnly) {
39 return (
40 <span
41 className={cn("inline-block max-w-full wrap-anywhere", className)}
42 style={{ color }}
43 >
44 {stat}
45 </span>
46 );
47 }
48
49 return (
50 <input
51 aria-label="stats item control"
52 type="text"
53 value={stat}
54 onFocus={onFocus}
55 onKeyDown={(e) => {
56 e.stopPropagation();
57 }}
58 onChange={(e) => onChange(e.target.value)}
59 onBlur={(e) => onChange(e.target.value)}
60 className={cn(
61 "max-w-full min-w-0 border-none bg-transparent outline-none",
62 className,
63 )}
64 style={{
65 color,
66 ...(fitToContent ? { width: `${Math.max(stat.length, 1) + 1}ch` } : {}),
67 }}
68 />
69 );
70 }
71
72 interface StatsItemVisualProps {
73 alignment: "left" | "center" | "right";
74 color?: string;
75 isReadOnly: boolean;
76 stat: string;
77 statsType: NonNullable<TStatsGroupElement["statsType"]>;
78 onChange: (newStat: string) => void;
79 onFocus: () => void;
80 }
81
82 function StatsItemVisual({
83 alignment,
84 color,
85 isReadOnly,
86 stat,
87 statsType,
88 onChange,
89 onFocus,
90 }: StatsItemVisualProps) {
91 const statValue = parseFloat(stat) || 0;
92 const percentage = Math.min(Math.max(statValue, 0), 100);
93 const primaryColor = color || "var(--presentation-primary)";
94 const ringTrackColor =
95 "var(--presentation-card-background, var(--presentation-primary))";
96 const ringProgressColor =
97 color || "var(--presentation-smart-layout, var(--presentation-primary))";
98 const smartColor =
99 color || "var(--presentation-smart-layout, var(--presentation-primary))";
100
101 switch (statsType) {
102 case "plain":
103 return (
104 <div
105 className={cn(
106 "w-full min-w-0 text-6xl font-bold text-primary",
107 getAlignmentClasses(alignment),
108 )}
109 >
110 <StatValue
111 className={cn("text-6xl font-bold", getAlignmentClasses(alignment))}
112 color={primaryColor}
113 fitToContent
114 isReadOnly={isReadOnly}
115 stat={stat}
116 onChange={onChange}
117 onFocus={onFocus}
118 />
119 </div>
120 );
121
122 case "circle":
123 return (
124 <div className="relative size-32">
125 <svg className="h-full w-full" viewBox="0 0 100 100">
126 <circle
127 cx="50"
128 cy="50"
129 r="45"
130 fill="none"
131 stroke={ringTrackColor}
132 strokeWidth="8"
133 />
134 <circle
135 cx="50"
136 cy="50"
137 r="45"
138 fill="none"
139 stroke={ringProgressColor}
140 strokeWidth="8"
141 strokeDasharray={`${2 * Math.PI * 45}`}
142 strokeDashoffset={`${2 * Math.PI * 45 * (1 - percentage / 100)}`}
143 transform="rotate(-90 50 50)"
144 />
145 </svg>
146 <div className="absolute inset-0 flex items-center justify-center">
147 <StatValue
148 className="w-full text-center text-2xl font-bold"
149 color={primaryColor}
150 isReadOnly={isReadOnly}
151 stat={stat}
152 onChange={onChange}
153 onFocus={onFocus}
154 />
155 </div>
156 </div>
157 );
158
159 case "circle-bold":
160 return (
161 <div className="relative size-35">
162 <svg className="h-full w-full" viewBox="0 0 100 100">
163 <circle
164 cx="50"
165 cy="50"
166 r="40"
167 fill="none"
168 stroke={ringTrackColor}
169 strokeWidth="12"
170 />
171 <circle
172 cx="50"
173 cy="50"
174 r="40"
175 fill="none"
176 stroke={ringProgressColor}
177 strokeWidth="12"
178 strokeDasharray={`${2 * Math.PI * 40}`}
179 strokeDashoffset={`${2 * Math.PI * 40 * (1 - percentage / 100)}`}
180 transform="rotate(-90 50 50)"
181 />
182 <circle
183 cx="50"
184 cy="50"
185 r="30"
186 fill="none"
187 stroke={ringTrackColor}
188 strokeWidth="4"
189 opacity="0.4"
190 />
191 </svg>
192 <div className="absolute inset-0 flex items-center justify-center">
193 <StatValue
194 className="w-full text-center text-2xl font-bold"
195 color={smartColor}
196 isReadOnly={isReadOnly}
197 stat={stat}
198 onChange={onChange}
199 onFocus={onFocus}
200 />
201 </div>
202 </div>
203 );
204
205 case "star":
206 const filledStars = statValue % 5 || 5;
207 return (
208 <div className="flex items-center gap-1">
209 <div className="flex gap-1">
210 {Array.from({ length: 5 }).map((_, i) => (
211 <Star
212 key={i}
213 className={cn(
214 "h-8 w-8",
215 i < filledStars
216 ? "fill-current text-primary"
217 : "text-gray-300",
218 )}
219 style={{
220 color: smartColor,
221 }}
222 />
223 ))}
224 </div>
225 <StatValue
226 className="w-full text-center text-2xl font-bold"
227 color={smartColor}
228 isReadOnly={isReadOnly}
229 stat={stat}
230 onChange={onChange}
231 onFocus={onFocus}
232 />
233 </div>
234 );
235
236 case "bar":
237 return (
238 <div className="flex w-full items-center gap-1">
239 <div className="h-8 w-full flex-1 border border-(--presentation-smart-layout) bg-(--presentation-background)">
240 <div
241 className="h-8 transition-all duration-300"
242 style={{
243 width: `${percentage}%`,
244 backgroundColor: color || "var(--presentation-secondary)",
245 }}
246 />
247 </div>
248 <StatValue
249 className="min-w-max text-center text-2xl font-bold"
250 color={smartColor}
251 fitToContent
252 isReadOnly={isReadOnly}
253 stat={stat}
254 onChange={onChange}
255 onFocus={onFocus}
256 />
257 </div>
258 );
259
260 case "dot-grid":
261 const filledDots = Math.round((percentage / 100) * 100);
262 return (
263 <div className="flex flex-col gap-2">
264 <div className="grid size-32 grid-cols-10 gap-1">
265 {Array.from({ length: 100 }).map((_, i) => (
266 <div
267 key={i}
268 className="size-2 rounded-full"
269 style={{
270 backgroundColor:
271 i < filledDots
272 ? "var(--presentation-secondary)"
273 : smartColor,
274 }}
275 />
276 ))}
277 </div>
278 <StatValue
279 className="w-full text-2xl font-bold"
280 color={smartColor}
281 isReadOnly={isReadOnly}
282 stat={stat}
283 onChange={onChange}
284 onFocus={onFocus}
285 />
286 </div>
287 );
288
289 case "dot-line":
290 const filledLineDots = Math.round((percentage / 100) * 20);
291 return (
292 <div className="flex items-center gap-2">
293 <div className="flex gap-1">
294 {Array.from({ length: 5 }).map((_, i) => (
295 <div
296 key={i}
297 className="size-4 rounded-full"
298 style={{
299 backgroundColor:
300 i < filledLineDots
301 ? "var(--presentation-secondary)"
302 : smartColor,
303 }}
304 />
305 ))}
306 </div>
307 <StatValue
308 className="w-full text-2xl font-bold"
309 color={smartColor}
310 isReadOnly={isReadOnly}
311 stat={stat}
312 onChange={onChange}
313 onFocus={onFocus}
314 />
315 </div>
316 );
317
318 default:
319 return null;
320 }
321 }
322
323 export const StatsItem = (props: PlateElementProps<TStatsItemElement>) => {
324 const readOnly = useReadOnly();
325 // Get parent element for variant and color information
326 const parentPath = PathApi.parent(props.path);
327 const parentElement = NodeApi.get(
328 props.editor,
329 parentPath,
330 ) as TStatsGroupElement;
331
332 const statsType = parentElement?.statsType ?? "plain";
333 const { stat = "0" } = props.element;
334
335 // Get alignment - use item alignment if set, otherwise inherit from parent
336 const itemAlignment = props.element.alignment;
337 const parentAlignment = parentElement?.alignment;
338 const alignment = itemAlignment ?? parentAlignment ?? "left";
339 const accentColor = getPresentationAccentColor(
340 props.element,
341 parentElement,
342 "var(--presentation-primary)",
343 );
344
345 const handleStatChange = (newStat: string) => {
346 if (readOnly) return;
347 const itemPath = props.editor.api.findPath(props.element);
348 if (!itemPath) return;
349 props.editor.tf.setNodes({ stat: newStat }, { at: itemPath });
350 };
351
352 const blurEditor = () => {
353 props.editor.tf.blur();
354 };
355
356 return (
357 <PlateElement
358 {...props}
359 className={cn("grid min-w-0 grid-flow-row grid-cols-1 gap-4 p-6")}
360 >
361 <div
362 className="flex h-max w-full min-w-0"
363 contentEditable={false}
364 data-decor="true"
365 data-slate-void="true"
366 >
367 <StatsItemVisual
368 alignment={alignment}
369 color={accentColor}
370 isReadOnly={readOnly}
371 stat={stat}
372 statsType={statsType}
373 onChange={handleStatChange}
374 onFocus={blurEditor}
375 />
376 </div>
377
378 <div className={cn(getAlignmentClasses(alignment))}>
379 <div>{props.children}</div>
380 </div>
381 </PlateElement>
382 );
383 };
384
384 lines Plain Text