返回 presentation-ai
staircase-item.tsx
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
192 lines Plain Text