| 1 | import { NodeApi, PathApi } from "platejs"; |
| 2 | import { SlateElement, type SlateElementProps } from "platejs/static"; |
| 3 | import { useLayoutEffect, useRef, useState } from "react"; |
| 4 | |
| 5 | import { cn } from "@/lib/utils"; |
| 6 | import { |
| 7 | type TStairGroupElement, |
| 8 | type TStairItemElement, |
| 9 | } from "../../plugins/staircase-plugin"; |
| 10 | import { getAlignmentClasses } from "../../utils"; |
| 11 | import { PresentationIcon } from "../presentation-icon"; |
| 12 | |
| 13 | const STAIR_MIN_BLOCK_HEIGHT = 48; |
| 14 | |
| 15 | export function StairItemStatic(props: SlateElementProps<TStairItemElement>) { |
| 16 | const path = props.editor.api.findPath(props.element) ?? [-1]; |
| 17 | const parentPath = PathApi.parent(path); |
| 18 | const parentElement = NodeApi.get( |
| 19 | props.editor, |
| 20 | parentPath, |
| 21 | ) as TStairGroupElement; |
| 22 | |
| 23 | const totalItems = parentElement?.children?.length || 1; |
| 24 | const index = (path?.at(-1) as number) ?? 0; |
| 25 | const alignment = |
| 26 | props.element.alignment ?? parentElement?.alignment ?? "left"; |
| 27 | const { icon } = props.element; |
| 28 | const markerColor = |
| 29 | (parentElement?.color as string) || "var(--presentation-smart-layout)"; |
| 30 | |
| 31 | // Refs and state for dynamic height |
| 32 | const contentRef = useRef<HTMLDivElement>(null); |
| 33 | const [blockHeight, setBlockHeight] = useState(STAIR_MIN_BLOCK_HEIGHT); |
| 34 | |
| 35 | // ResizeObserver to dynamically adjust height based on content height |
| 36 | useLayoutEffect(() => { |
| 37 | if (!contentRef.current) return; |
| 38 | |
| 39 | const updateHeight = () => { |
| 40 | const contentHeight = |
| 41 | contentRef.current?.getBoundingClientRect().height ?? |
| 42 | STAIR_MIN_BLOCK_HEIGHT; |
| 43 | const nextHeight = Math.max( |
| 44 | Math.ceil(contentHeight), |
| 45 | STAIR_MIN_BLOCK_HEIGHT, |
| 46 | ); |
| 47 | |
| 48 | setBlockHeight((currentHeight) => |
| 49 | Math.abs(currentHeight - nextHeight) > 0.5 ? nextHeight : currentHeight, |
| 50 | ); |
| 51 | }; |
| 52 | |
| 53 | updateHeight(); |
| 54 | const resizeObserver = new ResizeObserver(updateHeight); |
| 55 | resizeObserver.observe(contentRef.current); |
| 56 | |
| 57 | return () => resizeObserver.disconnect(); |
| 58 | }, []); |
| 59 | |
| 60 | // Calculate a width ramp similar to non-static design, but driven by totalItems |
| 61 | const baseWidth = 70; |
| 62 | const maxWidth = 220; |
| 63 | const increment = (maxWidth - baseWidth) / (totalItems - 1 || 1); |
| 64 | const widthPx = baseWidth + index * increment; |
| 65 | |
| 66 | const variant = parentElement?.variant; |
| 67 | const isInside = variant === "inside"; |
| 68 | |
| 69 | // For inside variant, use percentage-based widths |
| 70 | const baseWidthPercent = 30; |
| 71 | const maxWidthPercent = 70; |
| 72 | const incrementPercent = |
| 73 | (maxWidthPercent - baseWidthPercent) / (totalItems - 1 || 1); |
| 74 | const widthPercent = baseWidthPercent + index * incrementPercent; |
| 75 | |
| 76 | if (isInside) { |
| 77 | return ( |
| 78 | <div className={cn("group/stair-item relative w-full")}> |
| 79 | <div |
| 80 | className={cn( |
| 81 | "flex w-full border-b border-gray-700", |
| 82 | alignment === "right" && "justify-end", |
| 83 | alignment !== "right" && "justify-start", |
| 84 | )} |
| 85 | > |
| 86 | <div |
| 87 | data-shape="rect" |
| 88 | data-shape-text={String(index + 1)} |
| 89 | data-fill-color={ |
| 90 | (parentElement?.color as string) || |
| 91 | "var(--presentation-smart-layout)" |
| 92 | } |
| 93 | data-text-color="var(--presentation-background)" |
| 94 | style={ |
| 95 | { |
| 96 | width: `${widthPercent}%`, |
| 97 | backgroundColor: markerColor, |
| 98 | color: "var(--presentation-background)", |
| 99 | "--presentation-heading": "var(--presentation-card-background)", |
| 100 | "--presentation-text": "var(--presentation-card-background)", |
| 101 | } as React.CSSProperties |
| 102 | } |
| 103 | className="flex min-h-15 shrink-0 flex-col justify-center rounded-md px-4 py-3" |
| 104 | > |
| 105 | <SlateElement |
| 106 | ref={contentRef} |
| 107 | className="w-full font-normal" |
| 108 | {...props} |
| 109 | > |
| 110 | {props.children} |
| 111 | </SlateElement> |
| 112 | </div> |
| 113 | </div> |
| 114 | </div> |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | return ( |
| 119 | <div className={cn("group/stair-item relative w-full")}> |
| 120 | <div |
| 121 | className={cn( |
| 122 | "flex items-center gap-4 border-b border-gray-700", |
| 123 | alignment === "right" && "flex-row-reverse", |
| 124 | )} |
| 125 | > |
| 126 | {/* Width-growing block with number */} |
| 127 | <div |
| 128 | data-shape="rect" |
| 129 | data-shape-text={String(index + 1)} |
| 130 | data-fill-color={ |
| 131 | (parentElement?.color as string) || |
| 132 | "var(--presentation-smart-layout)" |
| 133 | } |
| 134 | data-text-color="var(--presentation-background)" |
| 135 | style={{ |
| 136 | width: `${widthPx}px`, |
| 137 | height: `${blockHeight}px`, |
| 138 | backgroundColor: markerColor, |
| 139 | color: "var(--presentation-background)", |
| 140 | }} |
| 141 | className="flex shrink-0 items-center justify-center rounded-md text-2xl font-bold" |
| 142 | > |
| 143 | {icon ? <PresentationIcon icon={icon} size={24} /> : index + 1} |
| 144 | </div> |
| 145 | |
| 146 | <SlateElement |
| 147 | ref={contentRef} |
| 148 | className={cn( |
| 149 | "min-w-0 flex flex-1 flex-col justify-center self-center", |
| 150 | getAlignmentClasses(alignment), |
| 151 | )} |
| 152 | {...props} |
| 153 | > |
| 154 | {props.children} |
| 155 | </SlateElement> |
| 156 | </div> |
| 157 | </div> |
| 158 | ); |
| 159 | } |
| 160 |