| 1 | "use client"; |
| 2 | |
| 3 | import { ResizableProvider, ResizeHandle } from "@platejs/resizable"; |
| 4 | import { BlockSelectionPlugin } from "@platejs/selection/react"; |
| 5 | import { |
| 6 | PlateElement, |
| 7 | useReadOnly, |
| 8 | withHOC, |
| 9 | type PlateElementProps, |
| 10 | } from "platejs/react"; |
| 11 | import { memo, useCallback, useRef, useState, type MouseEvent } from "react"; |
| 12 | |
| 13 | import { InfographicFloatingToolbar } from "@/components/notebook/presentation/editor/custom-elements/infographic/InfographicFloatingToolbar"; |
| 14 | import { |
| 15 | mediaResizeHandleVariants, |
| 16 | Resizable, |
| 17 | } from "@/components/plate/ui/resize-handle"; |
| 18 | import { SparklesCore } from "@/components/ui/sparkles"; |
| 19 | import { type InfographicSelectionPayload } from "@/hooks/presentation/infographic/InfographicSelectionPlugin"; |
| 20 | import { useAntvInfographicActions } from "@/hooks/presentation/infographic/useAntvInfographicActions"; |
| 21 | import { useAntvInfographicGeneration } from "@/hooks/presentation/infographic/useAntvInfographicGeneration"; |
| 22 | import { useAntvInfographicInstance } from "@/hooks/presentation/infographic/useAntvInfographicInstance"; |
| 23 | import { useAntvInfographicMutationSync } from "@/hooks/presentation/infographic/useAntvInfographicMutationSync"; |
| 24 | import { useAntvInfographicRendering } from "@/hooks/presentation/infographic/useAntvInfographicRendering"; |
| 25 | import { useAntvInfographicTheme } from "@/hooks/presentation/infographic/useAntvInfographicTheme"; |
| 26 | import { useSyncedAntvElementRef } from "@/hooks/presentation/infographic/useSyncedAntvElementRef"; |
| 27 | import { cn } from "@/lib/utils"; |
| 28 | import { type TAntvInfographicElement } from "../plugins/antv-infographic-plugin"; |
| 29 | import { useInfographicCardBackground } from "../utils/infographic-card-background"; |
| 30 | import { EventBoundary } from "./event-boundary"; |
| 31 | |
| 32 | /** |
| 33 | * Editable AntV Infographic component for the Plate editor |
| 34 | */ |
| 35 | const AntvInfographicBase = memo(function AntvInfographic( |
| 36 | props: PlateElementProps<TAntvInfographicElement>, |
| 37 | ) { |
| 38 | const { element, editor } = props; |
| 39 | const readOnly = useReadOnly(); |
| 40 | const containerRef = useRef<HTMLDivElement>(null); |
| 41 | const skipNextRenderRef = useRef(false); |
| 42 | const [hasError, setHasError] = useState(false); |
| 43 | const [selectionPayload, setSelectionPayload] = |
| 44 | useState<InfographicSelectionPayload | null>(null); |
| 45 | const elementRef = useSyncedAntvElementRef(element); |
| 46 | const infographicRef = useAntvInfographicInstance({ |
| 47 | containerRef, |
| 48 | editable: !readOnly, |
| 49 | onSelectionChange: setSelectionPayload, |
| 50 | }); |
| 51 | const { isDark, themedSyntax, themeColors } = useAntvInfographicTheme( |
| 52 | element.syntax, |
| 53 | ); |
| 54 | const cardBackgroundRefreshKey = `${isDark}:${themeColors?.cardBackground ?? ""}`; |
| 55 | const align = element.align ?? "center"; |
| 56 | |
| 57 | const { syntax } = useAntvInfographicGeneration({ |
| 58 | editor, |
| 59 | element, |
| 60 | setHasError, |
| 61 | canResumeLoadingGeneration: true, |
| 62 | }); |
| 63 | |
| 64 | useAntvInfographicRendering({ |
| 65 | infographicRef, |
| 66 | containerRef, |
| 67 | element, |
| 68 | elementRef, |
| 69 | themedSyntax, |
| 70 | isDark, |
| 71 | themeColors, |
| 72 | syntax, |
| 73 | skipNextRenderRef, |
| 74 | setHasError, |
| 75 | }); |
| 76 | useInfographicCardBackground(containerRef, cardBackgroundRefreshKey); |
| 77 | |
| 78 | useAntvInfographicMutationSync({ |
| 79 | infographicRef, |
| 80 | editor, |
| 81 | elementRef, |
| 82 | skipNextRenderRef, |
| 83 | syntax: element.syntax, |
| 84 | }); |
| 85 | |
| 86 | const { handleDelete } = useAntvInfographicActions({ editor, elementRef }); |
| 87 | |
| 88 | const selectInfographicBlock = useCallback(() => { |
| 89 | const elementId = element.id; |
| 90 | if (typeof elementId !== "string" || !elementId) return; |
| 91 | |
| 92 | editor.getApi(BlockSelectionPlugin).blockSelection.set([elementId]); |
| 93 | editor.getApi(BlockSelectionPlugin).blockSelection.focus(); |
| 94 | }, [editor, element.id]); |
| 95 | |
| 96 | const shouldSkipBlockSelect = (target: EventTarget | null) => { |
| 97 | const targetElement = |
| 98 | target instanceof Element |
| 99 | ? target |
| 100 | : target instanceof Node |
| 101 | ? target.parentElement |
| 102 | : null; |
| 103 | if (!targetElement) return false; |
| 104 | |
| 105 | return Boolean( |
| 106 | targetElement.closest( |
| 107 | [ |
| 108 | "[data-infographic-canvas]", |
| 109 | "button", |
| 110 | "input", |
| 111 | "textarea", |
| 112 | "select", |
| 113 | "[contenteditable='true']", |
| 114 | ].join(", "), |
| 115 | ), |
| 116 | ); |
| 117 | }; |
| 118 | |
| 119 | const handleMouseDownCapture = (event: MouseEvent<HTMLDivElement>) => { |
| 120 | if (event.button !== 0) return; |
| 121 | if (shouldSkipBlockSelect(event.target)) return; |
| 122 | |
| 123 | selectInfographicBlock(); |
| 124 | }; |
| 125 | |
| 126 | // ============================================================================ |
| 127 | // RENDER |
| 128 | // ============================================================================ |
| 129 | |
| 130 | return ( |
| 131 | <PlateElement |
| 132 | {...props} |
| 133 | className="slate-editable-void slate-selectable relative my-4 w-full" |
| 134 | > |
| 135 | <EventBoundary |
| 136 | style={{ backgroundColor: "var(--presentation-background)" }} |
| 137 | data-slate-void="true" |
| 138 | // data-ppt-ignore="true" |
| 139 | contentEditable={false} |
| 140 | onMouseDownCapture={handleMouseDownCapture} |
| 141 | > |
| 142 | <Resizable |
| 143 | align={align} |
| 144 | options={{ |
| 145 | align, |
| 146 | readOnly, |
| 147 | }} |
| 148 | className={cn("flex", !element.width && "w-full")} |
| 149 | > |
| 150 | <ResizeHandle |
| 151 | className={mediaResizeHandleVariants({ direction: "left" })} |
| 152 | options={{ direction: "left" }} |
| 153 | data-infographic-resize-handle="true" |
| 154 | /> |
| 155 | |
| 156 | <div className="relative min-h-75 w-full overflow-hidden rounded-lg"> |
| 157 | {hasError ? ( |
| 158 | <div className="flex h-50 w-full flex-col items-center justify-center rounded-lg bg-red-50 text-red-500 dark:bg-red-950"> |
| 159 | <p className="font-medium">Failed to generate diagram</p> |
| 160 | <p className="mt-1 text-sm text-red-400"> |
| 161 | Please try again with different text |
| 162 | </p> |
| 163 | <button |
| 164 | type="button" |
| 165 | onClick={handleDelete} |
| 166 | className="mt-4 rounded-lg bg-red-100 px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-200 dark:bg-red-900 dark:text-red-300 dark:hover:bg-red-800" |
| 167 | > |
| 168 | Remove |
| 169 | </button> |
| 170 | </div> |
| 171 | ) : ( |
| 172 | <> |
| 173 | {/* Container is always mounted for stable instance */} |
| 174 | <div |
| 175 | ref={containerRef} |
| 176 | data-infographic-canvas="true" |
| 177 | className="min-h-75 w-full overflow-hidden" |
| 178 | style={{ |
| 179 | minHeight: "300px", |
| 180 | opacity: element.isLoading ? 0.35 : 1, |
| 181 | }} |
| 182 | /> |
| 183 | |
| 184 | {element.isLoading && ( |
| 185 | <SparklesCore |
| 186 | background="transparent" |
| 187 | minSize={1} |
| 188 | maxSize={2} |
| 189 | particleDensity={150} |
| 190 | particleColor={isDark ? "#22d3ee" : "#0ea5e9"} |
| 191 | className="pointer-events-none absolute inset-0 z-10" |
| 192 | /> |
| 193 | )} |
| 194 | {selectionPayload && !readOnly && ( |
| 195 | <InfographicFloatingToolbar payload={selectionPayload} /> |
| 196 | )} |
| 197 | </> |
| 198 | )} |
| 199 | </div> |
| 200 | |
| 201 | <ResizeHandle |
| 202 | className={mediaResizeHandleVariants({ direction: "right" })} |
| 203 | options={{ direction: "right" }} |
| 204 | data-infographic-resize-handle="true" |
| 205 | /> |
| 206 | </Resizable> |
| 207 | </EventBoundary> |
| 208 | |
| 209 | {props.children} |
| 210 | </PlateElement> |
| 211 | ); |
| 212 | }, areEqualInfographicProps); |
| 213 | |
| 214 | export const AntvInfographic = withHOC(ResizableProvider, AntvInfographicBase); |
| 215 | |
| 216 | /** |
| 217 | * Custom equality check - ignores data changes to prevent re-renders while editing |
| 218 | */ |
| 219 | function areEqualInfographicProps( |
| 220 | prev: PlateElementProps<TAntvInfographicElement>, |
| 221 | next: PlateElementProps<TAntvInfographicElement>, |
| 222 | ): boolean { |
| 223 | if (prev.editor !== next.editor) return false; |
| 224 | if (prev.attributes !== next.attributes) return false; |
| 225 | |
| 226 | return ( |
| 227 | prev.element.id === next.element.id && |
| 228 | prev.element.syntax === next.element.syntax && |
| 229 | prev.element.isLoading === next.element.isLoading && |
| 230 | prev.element.sourceText === next.element.sourceText && |
| 231 | prev.element.generationPrompt === next.element.generationPrompt && |
| 232 | prev.element.slideLayoutType === next.element.slideLayoutType && |
| 233 | prev.element.align === next.element.align && |
| 234 | prev.element.width === next.element.width && |
| 235 | prev.element.data === next.element.data |
| 236 | ); |
| 237 | } |
| 238 |