返回 presentation-ai
timeline-item.tsx
1 "use client";
2
3 import { cva } from "class-variance-authority";
4 import { NodeApi, PathApi } from "platejs";
5 import {
6 PlateElement,
7 useReadOnly,
8 type PlateElementProps,
9 } from "platejs/react";
10
11 import { IconPicker } from "@/components/ui/icon-picker";
12 import { cn } from "@/lib/utils";
13 import { type TTimelineGroupElement } from "../plugins/timeline-plugin";
14 import { getAlignmentClasses } from "../utils";
15 import { getPresentationAccentColor } from "./color-utils";
16 import { PresentationIcon } from "./presentation-icon";
17 import { getSiblingIndexContext } from "./sibling-index";
18
19 const HORIZONTAL_DOUBLE_CONTENT_CLASS =
20 "w-[min(22rem,calc(200%-2.5rem))] max-w-none px-3 text-center [&_h3]:mb-2 [&_h3]:text-base [&_p]:text-xs [&_p]:leading-snug";
21 const VERTICAL_DOUBLE_CONTENT_CLASS =
22 "relative z-10 min-h-40 max-w-full py-5 [&_h3]:mb-2 [&_h3]:text-base [&_p]:text-xs [&_p]:leading-snug";
23
24 export const containerVariants = cva("flex flex-1", {
25 variants: {
26 orientation: {
27 horizontal: "items-center p-4 pt-0",
28 vertical: "items-center p-4 pl-0",
29 },
30 sidedness: {
31 single: "",
32 double: "",
33 },
34 isEven: {
35 true: "",
36 false: "",
37 },
38 showLine: {
39 true: "gap-6",
40 false: "gap-4",
41 },
42 alignment: {
43 left: "",
44 center: "",
45 right: "",
46 },
47 },
48 compoundVariants: [
49 {
50 sidedness: "single",
51 alignment: "left",
52 orientation: "horizontal",
53 class: "p-4 pt-0",
54 },
55 {
56 sidedness: "single",
57 alignment: "right",
58 orientation: "horizontal",
59 class: "p-4 pb-0",
60 },
61 {
62 sidedness: "single",
63 alignment: "left",
64 orientation: "vertical",
65 class: "p-4 pl-0",
66 },
67 {
68 sidedness: "single",
69 alignment: "right",
70 orientation: "vertical",
71 class: "p-4 pr-0",
72 },
73 {
74 orientation: "horizontal",
75 sidedness: "single",
76 class: "flex-col",
77 },
78
79 {
80 orientation: "horizontal",
81 sidedness: "double",
82 isEven: true,
83 class:
84 "grid grid-rows-[minmax(max-content,1fr)_2.5rem_minmax(max-content,1fr)] place-items-center gap-0 p-0",
85 },
86 {
87 orientation: "horizontal",
88 sidedness: "double",
89 isEven: false,
90 class:
91 "grid grid-rows-[minmax(max-content,1fr)_2.5rem_minmax(max-content,1fr)] place-items-center gap-0 p-0",
92 },
93
94 {
95 orientation: "vertical",
96 sidedness: "double",
97 isEven: true,
98 class: "w-[calc(50%+2.25rem)] place-self-end pl-4",
99 },
100 {
101 orientation: "vertical",
102 sidedness: "double",
103 isEven: false,
104 class: "w-[calc(50%+2.25rem)] flex-row-reverse place-self-start pl-4",
105 },
106 ],
107 });
108
109 export const circleVariants = cva(
110 "relative flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-bold ring ring-(--ring-color) ring-offset-2",
111 {
112 variants: {
113 orientation: {
114 horizontal: "",
115 vertical: "",
116 },
117 sidedness: {
118 single: "",
119 double: "",
120 },
121 },
122 },
123 );
124
125 export const connectorLineVariants = cva(
126 "pointer-events-none absolute z-50 rounded-full bg-(--before-bg)",
127 {
128 variants: {
129 orientation: {
130 horizontal: "",
131 vertical: "",
132 },
133 sidedness: {
134 single: "",
135 double: "",
136 },
137 isEven: {
138 true: "",
139 false: "",
140 },
141 },
142 compoundVariants: [
143 {
144 orientation: "horizontal",
145 sidedness: "single",
146 class:
147 "top-1/2 left-1/2 h-1/2 w-0.5 -translate-x-1/2 translate-y-[calc(100%+2px)]",
148 },
149
150 {
151 orientation: "horizontal",
152 sidedness: "double",
153 isEven: true,
154 class: "bottom-[calc(100%+2px)] left-1/2 h-3 w-0.5 -translate-x-1/2",
155 },
156
157 {
158 orientation: "horizontal",
159 sidedness: "double",
160 isEven: false,
161 class: "top-[calc(100%+2px)] left-1/2 h-3 w-0.5 -translate-x-1/2",
162 },
163
164 {
165 orientation: "vertical",
166 class:
167 "top-1/2 left-1/2 h-0.5 w-1/2 translate-x-[calc(100%+2px)] -translate-y-1/2",
168 },
169
170 {
171 orientation: "vertical",
172 sidedness: "double",
173 isEven: false,
174 class:
175 "top-1/2 left-0 h-0.5 w-1/2 -translate-x-[calc(100%+2px)] -translate-y-1/2",
176 },
177 ],
178 },
179 );
180
181 export const contentVariants = cva("flex", {
182 variants: {
183 orientation: {
184 horizontal: "flex-col",
185 vertical: "flex-col",
186 },
187 sidedness: {
188 single: "",
189 double: "",
190 },
191 },
192 });
193
194 export function TimelineItem(props: PlateElementProps) {
195 const { index, parentElement } =
196 getSiblingIndexContext<TTimelineGroupElement>(
197 props.editor,
198 props.element,
199 props.path,
200 );
201 const isPresenting = useReadOnly();
202 const fallbackParentPath = PathApi.parent(props.path);
203 const fallbackParentElement = NodeApi.get(
204 props.editor,
205 fallbackParentPath,
206 ) as TTimelineGroupElement | undefined;
207 const resolvedParentElement = parentElement ?? fallbackParentElement;
208 const orientation = resolvedParentElement?.orientation ?? "vertical";
209 const sidedness = resolvedParentElement?.sidedness ?? "single";
210 const showLine = resolvedParentElement?.showLine ?? true;
211 const numbered = resolvedParentElement?.numbered ?? true;
212 const variant = resolvedParentElement?.variant ?? "default";
213 const totalItems = resolvedParentElement?.children.length ?? 1;
214 const itemNumber = index + 1;
215 const isEven =
216 orientation === "horizontal" && sidedness === "double"
217 ? index % 2 === 0
218 : itemNumber % 2 === 0;
219 const isFirstItem = index === 0;
220 const isLastItem = index === totalItems - 1;
221
222 const alignment = resolvedParentElement?.alignment ?? "left";
223 const horizontalDoubleEdgeAlignmentClass =
224 sidedness === "double" && orientation === "horizontal"
225 ? isFirstItem
226 ? "justify-self-start"
227 : isLastItem
228 ? "justify-self-end"
229 : "justify-self-center"
230 : "";
231 const horizontalDoubleTextAlignmentClass =
232 sidedness === "double" && orientation === "horizontal"
233 ? isFirstItem
234 ? "items-start text-left [&>*]:self-start [&>*]:text-left"
235 : isLastItem
236 ? "items-end text-right [&>*]:self-end [&>*]:text-right"
237 : "items-center text-center [&>*]:self-center [&>*]:text-center"
238 : "";
239 const verticalDoubleOverlapClass =
240 sidedness === "double" && orientation === "vertical"
241 ? isFirstItem
242 ? "-mb-10 pb-10"
243 : isLastItem
244 ? "-mt-10 pt-10"
245 : "-my-10 py-10"
246 : "";
247 const verticalDoubleTextAlignmentClass =
248 sidedness === "double" && orientation === "vertical"
249 ? isEven
250 ? "items-start text-left [&>*]:self-start [&>*]:text-left"
251 : "items-end text-right [&>*]:self-end [&>*]:text-right"
252 : "";
253 const timelineColor = getPresentationAccentColor(
254 props.element,
255 resolvedParentElement,
256 "var(--presentation-smart-layout, var(--presentation-primary))",
257 );
258 const timelineTextColor = "var(--presentation-background)";
259 const { icon } = props.element as { icon?: string };
260
261 const handleIconSelect = (iconName: string) => {
262 const itemPath = props.editor.api.findPath(props.element);
263 if (!itemPath) return;
264 props.editor.tf.setNodes({ icon: iconName }, { at: itemPath });
265 };
266
267 return (
268 //* Container
269 <PlateElement
270 {...props}
271 className={cn(
272 "group min-h-full",
273 containerVariants({
274 orientation,
275 sidedness,
276 isEven,
277 showLine,
278 alignment,
279 }),
280 sidedness === "single" &&
281 alignment === "left" &&
282 orientation === "horizontal" &&
283 "flex-col",
284 sidedness === "single" &&
285 alignment === "right" &&
286 orientation === "horizontal" &&
287 "flex-col-reverse",
288 sidedness === "single" &&
289 alignment === "left" &&
290 orientation === "vertical" &&
291 "flex-row",
292 sidedness === "single" &&
293 alignment === "right" &&
294 orientation === "vertical" &&
295 "flex-row-reverse",
296 )}
297 >
298 {/* Circle */}
299 <div
300 data-shape="ellipse"
301 data-shape-role="timeline-marker"
302 data-fill-color={timelineColor}
303 data-text-color={timelineTextColor}
304 data-shape-text={numbered ? String(itemNumber) : undefined}
305 className={cn(
306 circleVariants({ orientation, sidedness }),
307 sidedness === "single" && alignment === "right" && "rotate-180",
308 sidedness === "single" &&
309 alignment === "right" &&
310 orientation === "horizontal" &&
311 isPresenting &&
312 "translate-y-3",
313 sidedness === "double" &&
314 orientation === "horizontal" &&
315 "row-start-2",
316 )}
317 style={
318 {
319 backgroundColor: timelineColor,
320 color: timelineTextColor,
321 "--ring-color": timelineColor,
322 "--before-bg": timelineColor,
323 } as React.CSSProperties & {
324 "--ring-color": string;
325 "--before-bg": string;
326 }
327 }
328 >
329 {showLine ? (
330 <div
331 aria-hidden="true"
332 data-shape="rect"
333 data-shape-role="timeline-connector"
334 data-fill-color={timelineColor}
335 data-orientation={orientation}
336 className={cn(
337 connectorLineVariants({ orientation, sidedness, isEven }),
338 )}
339 />
340 ) : null}
341 {isPresenting ? (
342 <div
343 className={cn(
344 "relative z-10",
345 sidedness === "single" && alignment === "right" && "rotate-180",
346 )}
347 >
348 {icon ? (
349 <PresentationIcon icon={icon} size={18} />
350 ) : numbered ? (
351 itemNumber
352 ) : (
353 ""
354 )}
355 </div>
356 ) : (
357 <IconPicker
358 defaultIcon={icon}
359 hidePlaceholderWhenEmpty={!numbered}
360 placeholder={
361 numbered ? (
362 <span>{itemNumber}</span>
363 ) : (
364 <span className="text-lg font-semibold leading-none">+</span>
365 )
366 }
367 onIconSelect={(iconName) => handleIconSelect(iconName)}
368 onIconRemove={() => {
369 const itemPath = props.editor.api.findPath(props.element);
370 if (!itemPath) return;
371 props.editor.tf.setNodes({ icon: "" }, { at: itemPath });
372 }}
373 className={cn(
374 "relative z-10 h-8 w-8 border-transparent bg-transparent shadow-none hover:bg-white/15",
375 sidedness === "single" && alignment === "right" && "rotate-180",
376 )}
377 size="sm"
378 style={{
379 borderColor: "transparent",
380 backgroundColor: "transparent",
381 color: timelineTextColor,
382 }}
383 />
384 )}
385 </div>
386
387 {/* Content */}
388 <div
389 className={cn(
390 "h-full max-w-full",
391 contentVariants({ orientation, sidedness }),
392 getAlignmentClasses(alignment),
393 sidedness === "double" &&
394 orientation === "horizontal" &&
395 isEven &&
396 cn(
397 "row-start-1 justify-end pb-8",
398 horizontalDoubleEdgeAlignmentClass,
399 horizontalDoubleTextAlignmentClass,
400 HORIZONTAL_DOUBLE_CONTENT_CLASS,
401 ),
402 sidedness === "double" &&
403 orientation === "horizontal" &&
404 !isEven &&
405 cn(
406 "row-start-3 justify-start pt-8",
407 horizontalDoubleEdgeAlignmentClass,
408 horizontalDoubleTextAlignmentClass,
409 HORIZONTAL_DOUBLE_CONTENT_CLASS,
410 ),
411 sidedness === "double" &&
412 orientation === "vertical" &&
413 cn(
414 VERTICAL_DOUBLE_CONTENT_CLASS,
415 verticalDoubleOverlapClass,
416 verticalDoubleTextAlignmentClass,
417 ),
418 variant === "boxes" &&
419 "flex-1 w-full rounded-xl border bg-card p-4 text-card-foreground shadow-sm",
420 )}
421 >
422 {props.children}
423 </div>
424 </PlateElement>
425 );
426 }
427
427 lines Plain Text