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