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