返回 presentation-ai
pyramid-item-static.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / static / pyramid-item-static.tsx
1 import { NodeApi, PathApi } from "platejs";
2 import { SlateElement, type SlateElementProps } from "platejs/static";
3 import { useEffect, useRef, useState } from "react";
4
5 import { cn } from "@/lib/utils";
6 import { type TPyramidGroupElement } from "../../plugins/pyramid-plugin";
7 import { PresentationIcon } from "../presentation-icon";
8 import {
9 getPyramidBorderExtension,
10 getPyramidSegmentClipPath,
11 getPyramidTextOffset,
12 } from "../pyramid-geometry";
13 import { usePyramidHeight } from "../pyramid-height-context";
14
15 export function PyramidItemStatic(props: SlateElementProps) {
16 const path = props.path ?? props.editor.api.findPath(props.element) ?? [0];
17 const parentPath = PathApi.parent(path);
18 const parentElement = NodeApi.get(
19 props.editor,
20 parentPath,
21 ) as TPyramidGroupElement;
22
23 const totalItems = parentElement?.children?.length || 1;
24 const index = path.at(-1) ?? 0;
25 const isFunnel = parentElement?.isFunnel;
26 const alignment = parentElement?.alignment;
27
28 const contentRef = useRef<HTMLDivElement>(null);
29 const [contentHeight, setContentHeight] = useState(80);
30 const { maxShapeHeight, registerItemHeight, unregisterItemHeight } =
31 usePyramidHeight();
32 const itemKey = path.join(".");
33
34 useEffect(() => {
35 if (!contentRef.current) return;
36
37 const updateHeight = () => {
38 const currentContentHeight = contentRef.current?.offsetHeight ?? 80;
39 const nextHeight = Math.max(currentContentHeight, 80);
40 setContentHeight(nextHeight);
41 registerItemHeight(itemKey, nextHeight);
42 };
43
44 updateHeight();
45 const resizeObserver = new ResizeObserver(updateHeight);
46 resizeObserver.observe(contentRef.current);
47
48 return () => {
49 resizeObserver.disconnect();
50 unregisterItemHeight(itemKey);
51 };
52 }, [itemKey, registerItemHeight, unregisterItemHeight]);
53
54 const shapeHeight = maxShapeHeight ?? contentHeight;
55 const geometryOptions = { index, totalItems, isFunnel };
56 const clipPath = getPyramidSegmentClipPath(geometryOptions);
57 const leftOffset = getPyramidTextOffset(geometryOptions);
58 const borderExtension = getPyramidBorderExtension(geometryOptions);
59
60 const contentGap = "2.5rem";
61 const icon =
62 "icon" in props.element && typeof props.element.icon === "string"
63 ? props.element.icon
64 : undefined;
65 const markerColor =
66 (parentElement?.color as string) || "var(--presentation-smart-layout)";
67
68 const variant = parentElement?.variant;
69 const isInside = variant === "inside";
70
71 // For inside variant, offset geometry so segments start wider (min 45% top width)
72 // For funnel, top is already full width, so do not offset the index.
73 const insideOffset = Math.max(3, Math.ceil((45 * totalItems) / 55));
74 const effectiveIndex = isFunnel ? index : index + insideOffset;
75 const effectiveTotal = totalItems + insideOffset;
76 const insideClipPath = getPyramidSegmentClipPath({
77 index: effectiveIndex,
78 totalItems: effectiveTotal,
79 isFunnel,
80 });
81
82 // Compute horizontal padding so text stays within the narrowest edge of the trapezoid.
83 const increment = 50 / effectiveTotal;
84 const textInsetPercent = isFunnel
85 ? 50 - increment * (effectiveTotal - effectiveIndex - 1)
86 : 50 - increment * effectiveIndex;
87 const insidePadding = `${textInsetPercent + 1}%`;
88
89 if (isInside) {
90 return (
91 <div className={cn("group/pyramid-item relative h-full w-full")}>
92 <div className="relative w-full">
93 <div
94 data-decor="true"
95 className="relative flex flex-col items-center justify-center border-b border-(--presentation-card-background)"
96 style={
97 {
98 height: `${shapeHeight}px`,
99 clipPath: insideClipPath,
100 backgroundColor: markerColor,
101 color: "var(--presentation-background)",
102 "--presentation-heading": "var(--presentation-card-background)",
103 "--presentation-text": "var(--presentation-card-background)",
104 } as React.CSSProperties
105 }
106 >
107 <SlateElement
108 ref={contentRef}
109 className="relative z-10 w-full py-4 text-center"
110 style={{
111 paddingLeft: insidePadding,
112 paddingRight: insidePadding,
113 }}
114 {...props}
115 >
116 {props.children}
117 </SlateElement>
118 </div>
119 </div>
120 </div>
121 );
122 }
123
124 return (
125 <div className={cn("group/pyramid-item relative h-full w-full")}>
126 <div
127 className={cn(
128 "grid h-full auto-cols-fr grid-flow-col items-center",
129 alignment === "right" && "col-start-2",
130 )}
131 >
132 <div className="relative flex-1">
133 <div
134 data-decor="true"
135 className="grid place-items-center border-b border-(--presentation-card-background) text-2xl font-bold"
136 style={{
137 height: `${shapeHeight}px`,
138 clipPath,
139 backgroundColor: markerColor,
140 color: "var(--presentation-background)",
141 }}
142 >
143 {icon ? <PresentationIcon icon={icon} size={24} /> : index + 1}
144 </div>
145 </div>
146 <div
147 className={cn(
148 "relative flex h-full flex-1 items-center after:absolute after:bottom-0 after:h-px after:bg-(--presentation-card-background) after:content-['']",
149 alignment === "right"
150 ? "after:left-0 after:-right-(--border-extension)"
151 : "after:right-0 after:-left-(--border-extension)",
152 alignment === "right" && "col-start-1 justify-end",
153 )}
154 style={
155 {
156 "--border-extension": `${borderExtension}%`,
157 transform:
158 alignment === "right"
159 ? `translateX(${leftOffset}%)`
160 : `translateX(-${leftOffset}%)`,
161 paddingLeft: isFunnel
162 ? alignment === "right"
163 ? `0`
164 : contentGap
165 : alignment === "right"
166 ? `0`
167 : contentGap,
168 paddingRight: isFunnel
169 ? alignment === "right"
170 ? contentGap
171 : `0`
172 : alignment === "right"
173 ? contentGap
174 : `0`,
175 } as React.CSSProperties
176 }
177 >
178 <SlateElement
179 ref={contentRef}
180 className="grid w-max items-center px-3"
181 {...props}
182 >
183 {props.children}
184 </SlateElement>
185 </div>
186 </div>
187 </div>
188 );
189 }
190
190 lines Plain Text