返回 presentation-ai
arrow-item.tsx
1 "use client";
2
3 import { NodeApi, PathApi } from "platejs";
4 import { PlateElement, type PlateElementProps } from "platejs/react";
5 import { useEffect, useRef, useState, type RefObject } from "react";
6
7 import { IconPicker } from "@/components/ui/icon-picker";
8 import { cn } from "@/lib/utils";
9 import {
10 type TArrowListElement,
11 type TArrowListItemElement,
12 } from "../plugins/arrow-plugin";
13 import { getAlignmentClasses } from "../utils";
14 import { getPresentationAccentColor } from "./color-utils";
15 import { PresentationIcon } from "./presentation-icon";
16
17 // ArrowItem component for individual items in the arrow visualization
18 export const ArrowItem = (props: PlateElementProps<TArrowListItemElement>) => {
19 const path = props.editor.api.findPath(props.element) ?? [-1];
20 const parentPath = PathApi.parent(path);
21 const parentElement = NodeApi.get(props.editor, parentPath);
22 const { orientation, svgType, showIcon } = parentElement as TArrowListElement;
23 const contentRef = useRef<HTMLDivElement | null>(null);
24 const isHorizontal = orientation === "horizontal";
25 const { icon } = props.element as unknown as { icon?: string };
26
27 // Get alignment - use item alignment if set, otherwise inherit from parent
28 const itemAlignment = props.element.alignment;
29 const parentAlignment = (parentElement as TArrowListElement)?.alignment;
30 const alignment = itemAlignment ?? parentAlignment ?? "left";
31 const accentColor = getPresentationAccentColor(
32 props.element,
33 parentElement as TArrowListElement | undefined,
34 "var(--presentation-smart-layout, var(--presentation-primary))",
35 );
36
37 const handleIconSelect = (iconName: string) => {
38 const itemPath = props.editor.api.findPath(props.element);
39 if (!itemPath) return;
40 props.editor.tf.setNodes({ icon: iconName }, { at: itemPath });
41 };
42
43 return (
44 <PlateElement
45 {...props}
46 className={cn(
47 "group group/arrow-item relative mb-2 flex w-full max-w-full min-w-0 gap-6 pl-4",
48 isHorizontal && "flex-col gap-3 pl-0",
49 !isHorizontal && "items-start",
50 alignment === "right" && !isHorizontal && "pr-4 pl-0 flex-row-reverse",
51 alignment === "center" && "justify-center",
52 )}
53 >
54 {/* Chevron icon column */}
55 <div
56 className={cn(
57 "relative grid shrink-0",
58 isHorizontal ? "h-24 w-full" : "h-full w-24",
59 )}
60 >
61 <ArrowChevron
62 className={cn(
63 "relative z-50 block overflow-visible",
64 isHorizontal ? "top-0 left-0" : "top-0",
65 )}
66 isHorizontal={isHorizontal}
67 sizeTargetRef={contentRef}
68 svgType={svgType}
69 color={accentColor}
70 icon={icon}
71 showIcon={!!showIcon}
72 onIconSelect={handleIconSelect}
73 />
74 </div>
75 {/* Content column */}
76 <div
77 ref={contentRef}
78 className={cn("grid min-w-0 flex-1", !isHorizontal && "self-start")}
79 >
80 <div className={cn("min-w-0 w-full", getAlignmentClasses(alignment))}>
81 {props.children}
82 </div>
83 </div>
84 </PlateElement>
85 );
86 };
87
88 // Extracted SVG chevron for reuse and clarity
89 type ArrowChevronProps = {
90 isHorizontal: boolean;
91 sizeTargetRef: RefObject<HTMLDivElement | null>;
92 svgType: "arrow" | "pill" | "parallelogram";
93 color: string;
94 icon?: string;
95 showIcon: boolean;
96 className?: string;
97 onIconSelect?: (iconName: string) => void;
98 disabled?: boolean;
99 };
100
101 export const ArrowChevron = ({
102 isHorizontal,
103 sizeTargetRef,
104 svgType,
105 color,
106 className,
107 icon,
108 showIcon,
109 onIconSelect,
110 disabled,
111 }: ArrowChevronProps) => {
112 const [height, setHeight] = useState(90);
113 const [width, setWidth] = useState(90);
114 const pillHorizontalInset = 8;
115 const pillVerticalInset = 6;
116 const pillHeight = 78;
117 const pillWidth = 72;
118
119 useEffect(() => {
120 if (!sizeTargetRef.current) return;
121
122 const updateDimensions = () => {
123 const h = sizeTargetRef.current?.offsetHeight ?? 90;
124 const w = sizeTargetRef.current?.offsetWidth ?? 90;
125 setHeight(Math.max(h, 80));
126 setWidth(Math.max(w, 80));
127 };
128
129 updateDimensions();
130 const resizeObserver = new ResizeObserver(updateDimensions);
131 resizeObserver.observe(sizeTargetRef.current);
132
133 return () => resizeObserver.disconnect();
134 }, [sizeTargetRef]);
135
136 const pathD = (() => {
137 if (svgType === "pill") return ""; // handled as <rect/>
138 if (svgType === "parallelogram") {
139 const offset = 18;
140 return isHorizontal
141 ? `M${offset},0 L${width},0 L${Math.max(width - offset, 0)},90 L0,90 Z`
142 : `M0,${offset} L90,0 L90,${Math.max(height - offset, 0)} L0,${height} Z`;
143 }
144 // default: arrow
145 return isHorizontal
146 ? `M${Math.max(width - 18, 0)},0L${width},45L${Math.max(width - 18, 0)},90L0,90L18,45L0,0Z`
147 : `M0,${Math.max(height - 18, 0)}L45,${height}L90,${Math.max(height - 18, 0)}L90,0L45,18L0,0Z`;
148 })();
149
150 const svgWidth = isHorizontal ? width : svgType === "pill" ? 80 : 90;
151 const svgHeight = isHorizontal
152 ? 90
153 : svgType === "pill"
154 ? Math.max(height, 100)
155 : height;
156 const hasIcon = Boolean(icon?.trim());
157
158 return (
159 <>
160 <svg
161 className={cn(className, "max-w-full")}
162 style={{ justifySelf: isHorizontal ? undefined : "center" }}
163 width={svgWidth}
164 height={svgHeight}
165 viewBox={`0 0 ${svgWidth} ${svgHeight}`}
166 preserveAspectRatio="none"
167 data-shape={svgType}
168 data-orientation={isHorizontal ? "horizontal" : "vertical"}
169 data-fill-color={color}
170 >
171 {svgType === "pill" ? (
172 isHorizontal ? (
173 <rect
174 x={pillHorizontalInset}
175 y={pillVerticalInset}
176 width={Math.max(width - pillHorizontalInset * 2, 0)}
177 height={pillHeight}
178 rx={pillHeight / 2}
179 ry={pillHeight / 2}
180 style={{ fill: color }}
181 />
182 ) : (
183 <rect
184 x={4}
185 y={pillVerticalInset}
186 width={pillWidth}
187 height={Math.max(height - pillVerticalInset * 2, 88)}
188 rx={pillWidth / 2}
189 ry={pillWidth / 2}
190 style={{ fill: color }}
191 />
192 )
193 ) : (
194 <path d={pathD} style={{ fill: color }}></path>
195 )}
196 </svg>
197
198 {showIcon ? (
199 <div
200 className={cn(
201 "pointer-events-none absolute inset-0 z-50 flex items-center justify-center",
202 )}
203 >
204 {disabled ? (
205 hasIcon ? (
206 <div
207 className="flex size-10 items-center justify-center rounded-full"
208 style={{ color: "var(--presentation-background)" }}
209 >
210 <PresentationIcon icon={icon} size={20} />
211 </div>
212 ) : null
213 ) : (
214 <IconPicker
215 disabled={disabled}
216 defaultIcon={icon}
217 hidePlaceholderWhenEmpty
218 onIconSelect={(name) => onIconSelect?.(name)}
219 onIconRemove={() => onIconSelect?.("")}
220 className="pointer-events-auto size-10 rounded-full shadow-none transition-opacity hover:opacity-80"
221 size="md"
222 style={{
223 backgroundColor: color,
224 borderColor: "transparent",
225 color: "var(--presentation-background)",
226 }}
227 />
228 )}
229 </div>
230 ) : null}
231 </>
232 );
233 };
234
235 // cleanup: removed unused/incomplete stubs
236
236 lines Plain Text