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