| 1 | "use client"; |
| 2 | |
| 3 | import { NodeApi, PathApi } from "platejs"; |
| 4 | import { |
| 5 | PlateElement, |
| 6 | useReadOnly, |
| 7 | type PlateElementProps, |
| 8 | } from "platejs/react"; |
| 9 | |
| 10 | import { cn } from "@/lib/utils"; |
| 11 | import { CIRCULAR_GRID_MAX_ITEMS } from "../lib"; |
| 12 | import { |
| 13 | type TCircularGridGroupElement, |
| 14 | type TCircularGridItemElement, |
| 15 | } from "../plugins/diagram-components-plugin"; |
| 16 | import { |
| 17 | getCircularGridItemIndex, |
| 18 | getCircularGridItemPosition, |
| 19 | getCircularGridItemSelfAlignment, |
| 20 | getCircularGridItemTransform, |
| 21 | getCircularGridPointerDirection, |
| 22 | isVisibleCircularGridItem, |
| 23 | useCircularGridLayoutContext, |
| 24 | type CircularGridPointerDirection, |
| 25 | } from "./circular-grid"; |
| 26 | import { getPresentationAccentColor } from "./color-utils"; |
| 27 | import { getSiblingIndexContext } from "./sibling-index"; |
| 28 | import { getSmartLayoutStepColor } from "./smart-layout-gradient"; |
| 29 | |
| 30 | /** Size of the rotated square that creates the speech-bubble triangle. */ |
| 31 | const POINTER_SIZE = 16; |
| 32 | const POINTER_HALF = POINTER_SIZE / 2; |
| 33 | /** How far inward from the corner the pointer sits. */ |
| 34 | const POINTER_EDGE_INSET = 20; |
| 35 | |
| 36 | /** |
| 37 | * Returns absolute positioning CSS for the speech-bubble pointer |
| 38 | * based on which direction it should point. |
| 39 | */ |
| 40 | function getPointerPositionStyle( |
| 41 | direction: CircularGridPointerDirection, |
| 42 | ): React.CSSProperties { |
| 43 | switch (direction) { |
| 44 | case "bottom": |
| 45 | return { bottom: -POINTER_HALF, left: "50%", marginLeft: -POINTER_HALF }; |
| 46 | case "bottom-right": |
| 47 | return { bottom: -POINTER_HALF, right: POINTER_EDGE_INSET }; |
| 48 | case "bottom-left": |
| 49 | return { bottom: -POINTER_HALF, left: POINTER_EDGE_INSET }; |
| 50 | case "right": |
| 51 | return { right: -POINTER_HALF, top: "50%", marginTop: -POINTER_HALF }; |
| 52 | case "left": |
| 53 | return { left: -POINTER_HALF, top: "50%", marginTop: -POINTER_HALF }; |
| 54 | case "top-right": |
| 55 | return { top: -POINTER_HALF, right: POINTER_EDGE_INSET }; |
| 56 | case "top-left": |
| 57 | return { top: -POINTER_HALF, left: POINTER_EDGE_INSET }; |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | export function CircularGridItem( |
| 62 | props: PlateElementProps<TCircularGridItemElement>, |
| 63 | ) { |
| 64 | const layoutContext = useCircularGridLayoutContext(); |
| 65 | const { index: fallbackIndex, parentElement } = |
| 66 | getSiblingIndexContext<TCircularGridGroupElement>( |
| 67 | props.editor, |
| 68 | props.element, |
| 69 | props.path, |
| 70 | ); |
| 71 | const fallbackParentPath = PathApi.parent(props.path); |
| 72 | const fallbackParentElement = NodeApi.get( |
| 73 | props.editor, |
| 74 | fallbackParentPath, |
| 75 | ) as TCircularGridGroupElement | undefined; |
| 76 | const resolvedParentElement = |
| 77 | layoutContext?.parentElement ?? parentElement ?? fallbackParentElement; |
| 78 | const index = getCircularGridItemIndex( |
| 79 | resolvedParentElement, |
| 80 | props.element, |
| 81 | fallbackIndex, |
| 82 | ); |
| 83 | const total = Math.min( |
| 84 | layoutContext?.total ?? resolvedParentElement?.children?.length ?? 1, |
| 85 | CIRCULAR_GRID_MAX_ITEMS, |
| 86 | ); |
| 87 | const alignment = |
| 88 | props.element.alignment ?? |
| 89 | layoutContext?.alignment ?? |
| 90 | resolvedParentElement?.alignment ?? |
| 91 | "center"; |
| 92 | |
| 93 | const readOnly = useReadOnly(); |
| 94 | const position = getCircularGridItemPosition(index, total); |
| 95 | const selfAlign = getCircularGridItemSelfAlignment(index, total); |
| 96 | // In edit mode, BlockDraggable wrapper already applies the transform via |
| 97 | // getGridStyleForElement. Only apply it ourselves in readonly/present mode. |
| 98 | const transform = readOnly |
| 99 | ? getCircularGridItemTransform(index, total) |
| 100 | : undefined; |
| 101 | const pointerDir = getCircularGridPointerDirection(index, total); |
| 102 | const bgColor = getPresentationAccentColor( |
| 103 | props.element, |
| 104 | resolvedParentElement, |
| 105 | getSmartLayoutStepColor(index, total), |
| 106 | ); |
| 107 | const isVisible = isVisibleCircularGridItem(index); |
| 108 | |
| 109 | return ( |
| 110 | <PlateElement |
| 111 | {...props} |
| 112 | className={cn( |
| 113 | "relative z-10 min-h-22 min-w-40 overflow-visible", |
| 114 | !isVisible && "hidden", |
| 115 | )} |
| 116 | style={{ |
| 117 | gridRow: position.gridRow, |
| 118 | gridColumn: position.gridColumn, |
| 119 | justifySelf: selfAlign.justifySelf, |
| 120 | alignSelf: selfAlign.alignSelf, |
| 121 | transform, |
| 122 | }} |
| 123 | > |
| 124 | {/* Speech-bubble pointer (sits behind content) */} |
| 125 | <div |
| 126 | className="absolute z-0 size-4 rotate-45" |
| 127 | style={{ |
| 128 | background: bgColor, |
| 129 | ...getPointerPositionStyle(pointerDir), |
| 130 | }} |
| 131 | aria-hidden="true" |
| 132 | data-decor="true" |
| 133 | data-bg-export="true" |
| 134 | /> |
| 135 | {/* Content box (above pointer so the inner half is hidden) */} |
| 136 | <div |
| 137 | className={cn( |
| 138 | "relative z-1 min-h-22 min-w-0 rounded-xl px-4 py-3 text-(--presentation-foreground)", |
| 139 | alignment === "left" && "text-left", |
| 140 | alignment === "center" && "text-center", |
| 141 | alignment === "right" && "text-right", |
| 142 | "[&_h1]:text-xl [&_h2]:text-xl [&_h3]:text-xl [&_h4]:text-lg [&_p]:mt-1 [&_p]:text-sm", |
| 143 | )} |
| 144 | style={ |
| 145 | { |
| 146 | background: bgColor, |
| 147 | "--presentation-heading": "var(--presentation-card-background)", |
| 148 | "--presentation-text": "var(--presentation-card-background)", |
| 149 | } as React.CSSProperties |
| 150 | } |
| 151 | data-bg-export="true" |
| 152 | > |
| 153 | {props.children} |
| 154 | </div> |
| 155 | </PlateElement> |
| 156 | ); |
| 157 | } |
| 158 |