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