返回 presentation-ai
pyramid-item.tsx
1 // custom-elements/pyramid-item.tsx
2 "use client";
3
4 import { NodeApi, PathApi } from "platejs";
5 import { PlateElement, type PlateElementProps } from "platejs/react";
6 import { useEffect, useRef, useState } from "react";
7
8 import { IconPicker } from "@/components/ui/icon-picker";
9 import { cn } from "@/lib/utils";
10 import {
11 type TPyramidGroupElement,
12 type TPyramidItemElement,
13 } from "../plugins/pyramid-plugin";
14 import { getPresentationAccentColor } from "./color-utils";
15 import {
16 getPyramidBorderExtension,
17 getPyramidSegmentClipPath,
18 getPyramidTextOffset,
19 } from "./pyramid-geometry";
20 import { usePyramidHeight } from "./pyramid-height-context";
21 import { getSiblingIndexContext } from "./sibling-index";
22
23 // PyramidItem component for individual items in the pyramid
24 export const PyramidItem = (props: PlateElementProps<TPyramidItemElement>) => {
25 // Get the parent pyramid element to access totalChildren
26 const { index, parentElement } = getSiblingIndexContext<TPyramidGroupElement>(
27 props.editor,
28 props.element,
29 props.path,
30 );
31 const fallbackParentPath = PathApi.parent(props.path);
32 const fallbackParentElement = NodeApi.get(
33 props.editor,
34 fallbackParentPath,
35 ) as TPyramidGroupElement | undefined;
36 const resolvedParentElement = parentElement ?? fallbackParentElement;
37
38 // Get total items from parent element, fallback to calculating from parent's children
39 const totalItems = resolvedParentElement?.children?.length || 1;
40 const isFunnel = resolvedParentElement?.isFunnel;
41
42 const alignment = resolvedParentElement?.alignment;
43 // Refs and state for dynamic height
44 const contentRef = useRef<HTMLDivElement>(null);
45 const [contentHeight, setContentHeight] = useState(80);
46 const { maxShapeHeight, registerItemHeight, unregisterItemHeight } =
47 usePyramidHeight();
48 const itemKey = props.path.join(".");
49
50 // ResizeObserver to dynamically adjust height based on content height
51 useEffect(() => {
52 if (!contentRef.current) return;
53
54 const updateHeight = () => {
55 const currentContentHeight = contentRef.current?.offsetHeight ?? 80;
56 const nextHeight = Math.max(currentContentHeight, 80);
57 setContentHeight(nextHeight);
58 registerItemHeight(itemKey, nextHeight);
59 };
60
61 updateHeight();
62 const resizeObserver = new ResizeObserver(updateHeight);
63 resizeObserver.observe(contentRef.current);
64
65 return () => {
66 resizeObserver.disconnect();
67 unregisterItemHeight(itemKey);
68 };
69 }, [itemKey, registerItemHeight, unregisterItemHeight]);
70
71 const shapeHeight = maxShapeHeight ?? contentHeight;
72
73 const geometryOptions = { index, totalItems, isFunnel };
74 const clipPath = getPyramidSegmentClipPath(geometryOptions);
75 const leftOffset = getPyramidTextOffset(geometryOptions);
76 const borderExtension = getPyramidBorderExtension(geometryOptions);
77
78 const contentGap = "2.5rem";
79 const { icon } = props.element;
80 const markerColor = getPresentationAccentColor(
81 props.element,
82 resolvedParentElement,
83 "var(--presentation-smart-layout)",
84 );
85
86 const handleIconSelect = (iconName: string) => {
87 const itemPath = props.editor.api.findPath(props.element);
88 if (!itemPath) return;
89 props.editor.tf.setNodes({ icon: iconName }, { at: itemPath });
90 };
91
92 const variant = resolvedParentElement?.variant;
93 const isInside = variant === "inside";
94
95 // For inside variant, offset the geometry so segments start wider (no pointy tip).
96 // Offset of 3 means the first visible segment acts as index 3 in a larger pyramid.
97 // Dynamic offset ensures the narrowest (top) segment is at least 45% wide.
98 // For funnel, the top is already wide, so we don't offset the index, but we
99 // still use the larger totalItems so the bottom doesn't end in a point.
100 const insideOffset = Math.max(3, Math.ceil((45 * totalItems) / 55));
101 const effectiveIndex = isFunnel ? index : index + insideOffset;
102 const effectiveTotal = totalItems + insideOffset;
103 const insideGeometryOptions = {
104 index: effectiveIndex,
105 totalItems: effectiveTotal,
106 isFunnel,
107 };
108 const insideClipPath = getPyramidSegmentClipPath(insideGeometryOptions);
109
110 // Compute horizontal padding so text stays within the narrowest edge of the trapezoid.
111 // For pyramid the top is narrowest; for funnel the bottom is narrowest.
112 const increment = 50 / effectiveTotal;
113 const textInsetPercent = isFunnel
114 ? 50 - increment * (effectiveTotal - effectiveIndex - 1) // funnel: bottom is narrow
115 : 50 - increment * effectiveIndex; // pyramid: top is narrow
116 // Add 1% breathing room
117 const insidePadding = `${textInsetPercent + 1}%`;
118
119 // For inside variant, the shape spans the full width.
120 // All items share the same height via PyramidHeightProvider (maxShapeHeight).
121 if (isInside) {
122 return (
123 <PlateElement
124 {...props}
125 className={cn("group/pyramid-item relative h-full w-full")}
126 >
127 <div className="relative w-full">
128 <div
129 data-decor="true"
130 className="relative flex flex-col items-center justify-center border-b border-(--presentation-card-background)"
131 style={
132 {
133 height: `${shapeHeight}px`,
134 clipPath: insideClipPath,
135 backgroundColor: markerColor,
136 color: "var(--presentation-background)",
137 "--presentation-heading": "var(--presentation-card-background)",
138 "--presentation-text": "var(--presentation-card-background)",
139 } as React.CSSProperties
140 }
141 >
142 {/* Content inside the shape, padded to fit within the trapezoid */}
143 <div
144 ref={contentRef}
145 className="relative z-10 w-full py-4 text-center"
146 style={{
147 paddingLeft: insidePadding,
148 paddingRight: insidePadding,
149 }}
150 >
151 {props.children}
152 </div>
153 </div>
154 </div>
155 </PlateElement>
156 );
157 }
158
159 return (
160 <PlateElement
161 {...props}
162 className={cn("group/pyramid-item relative h-full w-full")}
163 >
164 {/* The pyramid item layout */}
165 <div
166 className={cn(
167 "grid h-full auto-cols-fr grid-flow-col items-center",
168 alignment === "right" && "col-start-2",
169 )}
170 >
171 {/* Shape with number */}
172 <div className="relative flex-1">
173 <div
174 data-decor="true"
175 className="grid place-items-center border-b border-(--presentation-card-background) text-2xl font-bold"
176 style={{
177 height: `${shapeHeight}px`,
178 clipPath: clipPath,
179 backgroundColor: markerColor,
180 color: "var(--presentation-background)",
181 }}
182 >
183 <IconPicker
184 defaultIcon={icon}
185 placeholder={
186 <span className="text-2xl font-bold">{index + 1}</span>
187 }
188 onIconSelect={(iconName) => handleIconSelect(iconName)}
189 onIconRemove={() => {
190 const itemPath = props.editor.api.findPath(props.element);
191 if (!itemPath) return;
192 props.editor.tf.setNodes({ icon: "" }, { at: itemPath });
193 }}
194 className="h-full w-full border-transparent bg-transparent shadow-none hover:bg-white/15"
195 size="lg"
196 style={{
197 borderColor: "transparent",
198 backgroundColor: "transparent",
199 color: "var(--presentation-background)",
200 }}
201 />
202 </div>
203 </div>
204
205 <div
206 className={cn(
207 "relative flex h-full flex-1 items-center after:absolute after:bottom-0 after:h-px after:bg-(--presentation-card-background) after:content-['']",
208 alignment === "right"
209 ? "after:-right-(--border-extension) after:left-0"
210 : "after:right-0 after:-left-(--border-extension)",
211 alignment === "right" && "col-start-1 justify-end",
212 )}
213 style={
214 {
215 "--border-extension": `${borderExtension}%`,
216 transform:
217 alignment === "right"
218 ? `translateX(${leftOffset}%)`
219 : `translateX(-${leftOffset}%)`,
220 paddingLeft: isFunnel
221 ? alignment === "right"
222 ? `0`
223 : contentGap
224 : alignment === "right"
225 ? `0`
226 : contentGap,
227 paddingRight: isFunnel
228 ? alignment === "right"
229 ? contentGap
230 : `0`
231 : alignment === "right"
232 ? contentGap
233 : `0`,
234 } as React.CSSProperties
235 }
236 >
237 <div ref={contentRef} className="grid w-max items-center px-3">
238 {props.children}
239 </div>
240 </div>
241 </div>
242 </PlateElement>
243 );
244 };
245
245 lines Plain Text