| 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 |