返回 presentation-ai
box-item.tsx
1 "use client";
2
3 import { cva } from "class-variance-authority";
4 import { NodeApi, PathApi } from "platejs";
5 import { PlateElement, type PlateElementProps } from "platejs/react";
6
7 import { IconPicker } from "@/components/ui/icon-picker";
8 import { cn } from "@/lib/utils";
9 import { usePresentationState } from "@/states/presentation-state";
10 import {
11 type TBoxGroupElement,
12 type TBoxItemElement,
13 } from "../plugins/box-plugin";
14 import { getAlignmentClasses, getDefaultColumnSize } from "../utils";
15 import { getPresentationAccentColor } from "./color-utils";
16 import { PresentationIcon } from "./presentation-icon";
17
18 export const boxItemVariants = cva(
19 "w-full border p-4 transition-(--presentation-transition)",
20 {
21 variants: {
22 boxType: {
23 outline: "border-2 bg-transparent",
24 icon: "gap-3 border-0 bg-(--presentation-card-background)",
25 solid: "border-0 bg-(--presentation-card-background)",
26 sideline: "border-y-2 border-r-2 border-l-8 bg-transparent",
27 "side-label": "border-y-0 border-r-0 border-l-8 bg-transparent",
28 "top-label": "border-x-0 border-t-8 border-b-0 bg-transparent",
29 "top-circle": "relative mt-6 border-2 bg-transparent pt-10",
30 joined: "rounded-none!",
31 "joined-icon": "relative rounded-none!",
32 leaf: "rounded-none rounded-tl-lg! rounded-br-lg!",
33 labeled:
34 "overflow-hidden border-2 bg-transparent pt-14 @[600px]/box-item:pt-4 @[600px]/box-item:pl-14",
35 alternating: "border-0 bg-(--presentation-card-background)",
36 },
37 },
38 },
39 );
40
41 const ICON_BOX_TYPES = new Set(["icon", "top-circle", "joined-icon"]);
42 const PRIMARY_ACCENT_BOX_TYPES = new Set([
43 "outline",
44 "sideline",
45 "side-label",
46 "top-label",
47 "top-circle",
48 "joined",
49 "joined-icon",
50 "leaf",
51 "labeled",
52 ]);
53
54 function getItemIndex(path: PlateElementProps<TBoxItemElement>["path"]) {
55 return path[path.length - 1] ?? 0;
56 }
57
58 export const BoxItem = (props: PlateElementProps<TBoxItemElement>) => {
59 const isPresenting = usePresentationState((state) => state.isPresenting);
60
61 // Get parent element for color and variant information
62 const parentPath = PathApi.parent(props.path);
63 const parentElement = NodeApi.get(
64 props.editor,
65 parentPath,
66 ) as TBoxGroupElement;
67
68 const boxType = parentElement?.boxType ?? "solid";
69 const columnSize =
70 parentElement?.columnSize ??
71 getDefaultColumnSize(parentElement?.children.length ?? 0);
72 const { icon } = props.element as unknown as { icon?: string };
73 const itemIndex = getItemIndex(props.path);
74 const isLastItem = parentElement?.children
75 ? itemIndex === parentElement.children.length - 1
76 : false;
77
78 const isEndOfRow =
79 (columnSize === "md" && (itemIndex + 1) % 3 === 0) ||
80 (columnSize === "lg" && (itemIndex + 1) % 2 === 0);
81 const isStartOfRow =
82 itemIndex > 0 &&
83 ((columnSize === "md" && itemIndex % 3 === 0) ||
84 (columnSize === "lg" && itemIndex % 2 === 0));
85 const accentColor = getPresentationAccentColor(
86 props.element,
87 parentElement,
88 "var(--presentation-primary)",
89 );
90 const shouldUseAccent = PRIMARY_ACCENT_BOX_TYPES.has(boxType);
91
92 // Get alignment - use item alignment if set, otherwise inherit from parent
93 const itemAlignment = props.element.alignment;
94 const parentAlignment = parentElement?.alignment;
95 const alignment = itemAlignment ?? parentAlignment ?? "left";
96
97 const handleIconSelect = (iconName: string) => {
98 const itemPath = props.editor.api.findPath(props.element);
99 if (!itemPath) return;
100 props.editor.tf.setNodes({ icon: iconName }, { at: itemPath });
101 };
102
103 return (
104 <PlateElement
105 {...props}
106 className={cn(
107 "group @container/box-item",
108 isPresenting ? "w-full self-stretch" : "size-full",
109 )}
110 >
111 <div
112 className={cn(
113 boxItemVariants({ boxType }),
114 isPresenting ? "grid size-full" : "grid size-full",
115 ICON_BOX_TYPES.has(boxType)
116 ? "grid-flow-row gap-2"
117 : "auto-cols-fr grid-flow-col gap-4",
118 boxType === "joined-icon" && "overflow-visible",
119 boxType === "joined-icon" &&
120 !isLastItem &&
121 !isEndOfRow &&
122 "pr-10! @[600px]/box-item:pr-4!",
123 boxType === "joined-icon" &&
124 !isLastItem &&
125 "@[600px]/box-item:pb-10!",
126 boxType === "joined-icon" &&
127 itemIndex > 0 &&
128 !isStartOfRow &&
129 "pl-10! @[600px]/box-item:pl-4!",
130 boxType === "joined-icon" &&
131 itemIndex > 0 &&
132 "@[600px]/box-item:pt-10!",
133 boxType === "labeled" && "relative",
134 boxType === "alternating" && "text-center",
135 "[&_:is(.presentation-heading)]:[-webkit-background-clip:unset!important;]",
136 "[&_:is(.presentation-heading)]:[-webkit-text-fill-color:unset!important;]",
137 "[&_:is(.presentation-heading)]:[background-clip:unset!important;]",
138 "[&_:is(.presentation-heading)]:[background:none!important;]",
139 "[&_:is(.presentation-heading)]:text-(--presentation-text)!",
140 )}
141 data-bg-export="true"
142 style={{
143 borderColor: accentColor,
144 color: shouldUseAccent ? accentColor : undefined,
145 borderRadius:
146 boxType !== "joined" &&
147 boxType !== "joined-icon" &&
148 boxType !== "leaf" &&
149 boxType !== "side-label" &&
150 boxType !== "top-label"
151 ? "var(--presentation-card-border-radius, 0.5rem)"
152 : undefined,
153 boxShadow:
154 boxType === "solid"
155 ? "var(--presentation-card-shadow, 0 1px 3px rgba(0,0,0,0.12))"
156 : undefined,
157 }}
158 >
159 {boxType === "labeled" ? (
160 <div
161 className="absolute top-0 right-0 left-0 flex h-12 items-center justify-center @[600px]/box-item:right-auto @[600px]/box-item:bottom-0 @[600px]/box-item:h-auto @[600px]/box-item:w-12 @[600px]/box-item:flex-col"
162 style={{ backgroundColor: accentColor }}
163 >
164 {isPresenting ? (
165 <div className="relative z-10 flex size-10 items-center justify-center text-lg font-bold text-white">
166 {icon ? (
167 <PresentationIcon icon={icon} size={20} />
168 ) : (
169 <span>{itemIndex + 1}</span>
170 )}
171 </div>
172 ) : (
173 <IconPicker
174 defaultIcon={icon}
175 hidePlaceholderWhenEmpty={false}
176 placeholder={
177 <span className="text-lg font-bold text-white">
178 {itemIndex + 1}
179 </span>
180 }
181 onIconSelect={(iconName) => handleIconSelect(iconName)}
182 onIconRemove={() => {
183 const itemPath = props.editor.api.findPath(props.element);
184 if (!itemPath) return;
185 props.editor.tf.setNodes({ icon: "" }, { at: itemPath });
186 }}
187 className="relative z-10 size-10 border-transparent bg-transparent shadow-none hover:bg-white/15 focus-visible:ring-0 focus-visible:ring-offset-0"
188 size="md"
189 style={{
190 borderColor: "transparent",
191 backgroundColor: "transparent",
192 color: "white",
193 }}
194 />
195 )}
196 </div>
197 ) : null}
198 {boxType === "joined-icon" && !isLastItem ? (
199 <IconPicker
200 disabled={false}
201 defaultIcon={icon}
202 hidePlaceholderWhenEmpty
203 onIconSelect={(name) => handleIconSelect(name)}
204 onIconRemove={() => {
205 const itemPath = props.editor.api.findPath(props.element);
206 if (!itemPath) return;
207 props.editor.tf.setNodes({ icon: "" }, { at: itemPath });
208 }}
209 className={cn(
210 "absolute top-1/2 right-0 z-10 size-10 translate-x-1/2 -translate-y-1/2 rounded-md border-2 bg-(--presentation-background) shadow-none hover:opacity-80 disabled:opacity-100 @[600px]/box-item:top-auto @[600px]/box-item:right-1/2 @[600px]/box-item:bottom-0 @[600px]/box-item:translate-x-1/2 @[600px]/box-item:translate-y-1/2",
211 isEndOfRow && "hidden @[600px]/box-item:flex",
212 )}
213 size="sm"
214 style={{ borderColor: accentColor }}
215 />
216 ) : null}
217 {ICON_BOX_TYPES.has(boxType) && boxType !== "joined-icon" ? (
218 <div
219 className={cn(
220 boxType === "top-circle" &&
221 "absolute top-0 left-1/2 z-10 -translate-x-1/2 -translate-y-1/2",
222 )}
223 >
224 <IconPicker
225 disabled={false}
226 defaultIcon={icon}
227 hidePlaceholderWhenEmpty={boxType !== "top-circle"}
228 onIconSelect={(name) => handleIconSelect(name)}
229 onIconRemove={() => {
230 const itemPath = props.editor.api.findPath(props.element);
231 if (!itemPath) return;
232 props.editor.tf.setNodes({ icon: "" }, { at: itemPath });
233 }}
234 className={cn(
235 "shadow-none disabled:opacity-100",
236 boxType === "top-circle"
237 ? "flex items-center justify-center rounded-full border-2 transition-all duration-200 hover:scale-110 active:scale-95"
238 : "hover:opacity-80",
239 )}
240 placeholder={
241 boxType === "top-circle" ? (
242 <span className="size-4" />
243 ) : undefined
244 }
245 size={boxType === "top-circle" ? "lg" : "md"}
246 style={
247 boxType === "top-circle"
248 ? {
249 backgroundColor: accentColor,
250 borderColor: accentColor,
251 color: "var(--presentation-background)",
252 width: "2.75rem",
253 height: "2.75rem",
254 }
255 : {
256 backgroundColor: "transparent",
257 borderColor: "transparent",
258 }
259 }
260 />
261 </div>
262 ) : null}
263 <div className={cn("w-full", getAlignmentClasses(alignment))}>
264 {props.children}
265 </div>
266 </div>
267 </PlateElement>
268 );
269 };
270
270 lines Plain Text