| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | BrushSelect, |
| 5 | ClickSelect, |
| 6 | DblClickEditText, |
| 7 | DragCanvas, |
| 8 | DragElement, |
| 9 | HotkeyHistory, |
| 10 | Infographic, |
| 11 | ResetViewBox, |
| 12 | ResizeElement, |
| 13 | SelectHighlight, |
| 14 | ZoomWheel, |
| 15 | type IInteraction, |
| 16 | type InfographicOptions, |
| 17 | type IPlugin, |
| 18 | type ParsedInfographicOptions, |
| 19 | } from "@antv/infographic"; |
| 20 | import { Loader2 } from "lucide-react"; |
| 21 | import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 22 | |
| 23 | import { InfographicFloatingToolbar } from "@/components/notebook/presentation/editor/custom-elements/infographic/InfographicFloatingToolbar"; |
| 24 | import { |
| 25 | enforceInfographicCardBackground, |
| 26 | useInfographicCardBackground, |
| 27 | } from "@/components/notebook/presentation/editor/utils/infographic-card-background"; |
| 28 | import { |
| 29 | applyThemeToData, |
| 30 | applyThemeToSyntax, |
| 31 | parseInfographicTemplate, |
| 32 | syncInfographicSyntaxWithData, |
| 33 | updateInfographicSyntaxWithParsedData, |
| 34 | type InfographicPaletteThemeColors, |
| 35 | } from "@/components/notebook/presentation/editor/utils/infographic-utils"; |
| 36 | import { registerLucideIconLoader } from "@/hooks/presentation/infographic/infographic-icon-loader"; |
| 37 | import { |
| 38 | pickSerializableOptions, |
| 39 | toSerializableOptionsFromParsed, |
| 40 | } from "@/hooks/presentation/infographic/infographic-options"; |
| 41 | import { |
| 42 | InfographicSelectionPlugin, |
| 43 | type InfographicSelectionPayload, |
| 44 | } from "@/hooks/presentation/infographic/InfographicSelectionPlugin"; |
| 45 | import { cn } from "@/lib/utils"; |
| 46 | import { type EditableInfographicData } from "./types"; |
| 47 | import { |
| 48 | createEditableInfographicData, |
| 49 | toInfographicOptionsData, |
| 50 | toParsedInfographicData, |
| 51 | } from "./utils"; |
| 52 | |
| 53 | registerLucideIconLoader(); |
| 54 | |
| 55 | interface InfographicDataPreviewProps { |
| 56 | data: EditableInfographicData; |
| 57 | isDark: boolean; |
| 58 | onDataChange: (data: EditableInfographicData) => void; |
| 59 | options?: Partial<InfographicOptions>; |
| 60 | toolbarPortalContainerRef?: RefObject<HTMLElement | null>; |
| 61 | syntax: string; |
| 62 | themeColors: InfographicPaletteThemeColors | null; |
| 63 | } |
| 64 | |
| 65 | type PreviewState = "error" | "loading" | "ready"; |
| 66 | const PREVIEW_DATA_DEBOUNCE_MS = 450; |
| 67 | |
| 68 | type InfographicEditorInstance = { |
| 69 | editor?: { |
| 70 | state?: { getOptions?: () => Partial<ParsedInfographicOptions> }; |
| 71 | }; |
| 72 | }; |
| 73 | |
| 74 | function buildPreviewPayload({ |
| 75 | data, |
| 76 | isDark, |
| 77 | options, |
| 78 | syntax, |
| 79 | themeColors, |
| 80 | }: InfographicDataPreviewProps): Partial<InfographicOptions> | string { |
| 81 | const template = parseInfographicTemplate(syntax) ?? options?.template; |
| 82 | const nextData = toInfographicOptionsData(data); |
| 83 | |
| 84 | if (options && Object.keys(options).length > 0) { |
| 85 | return applyThemeToData( |
| 86 | { |
| 87 | ...options, |
| 88 | template, |
| 89 | data: nextData, |
| 90 | }, |
| 91 | isDark, |
| 92 | themeColors, |
| 93 | ); |
| 94 | } |
| 95 | |
| 96 | const parsed = toParsedInfographicData(data); |
| 97 | const nextSyntax = updateInfographicSyntaxWithParsedData(syntax, parsed); |
| 98 | return applyThemeToSyntax( |
| 99 | syncInfographicSyntaxWithData(nextSyntax, { data: nextData, template }), |
| 100 | isDark, |
| 101 | themeColors, |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | export function InfographicDataPreview(props: InfographicDataPreviewProps) { |
| 106 | const containerRef = useRef<HTMLDivElement>(null); |
| 107 | const infographicRef = useRef<Infographic | null>(null); |
| 108 | const frameRef = useRef<number | null>(null); |
| 109 | const isRenderingRef = useRef(false); |
| 110 | const skipNextRenderRef = useRef(false); |
| 111 | const latestSyncPropsRef = useRef({ |
| 112 | data: props.data, |
| 113 | onDataChange: props.onDataChange, |
| 114 | syntax: props.syntax, |
| 115 | }); |
| 116 | const [selectionPayload, setSelectionPayload] = |
| 117 | useState<InfographicSelectionPayload | null>(null); |
| 118 | const [previewState, setPreviewState] = useState<PreviewState>("loading"); |
| 119 | const [renderRevision, setRenderRevision] = useState(0); |
| 120 | const [debouncedData, setDebouncedData] = useState(props.data); |
| 121 | const cardBackgroundRefreshKey = `${props.isDark}:${props.themeColors?.cardBackground ?? ""}`; |
| 122 | |
| 123 | useInfographicCardBackground(containerRef, cardBackgroundRefreshKey); |
| 124 | |
| 125 | const payload = useMemo( |
| 126 | () => |
| 127 | buildPreviewPayload({ |
| 128 | data: debouncedData, |
| 129 | isDark: props.isDark, |
| 130 | onDataChange: props.onDataChange, |
| 131 | options: props.options, |
| 132 | syntax: props.syntax, |
| 133 | themeColors: props.themeColors, |
| 134 | }), |
| 135 | [ |
| 136 | debouncedData, |
| 137 | props.isDark, |
| 138 | props.onDataChange, |
| 139 | props.options, |
| 140 | props.syntax, |
| 141 | props.themeColors, |
| 142 | ], |
| 143 | ); |
| 144 | |
| 145 | const forcePreviewRender = () => { |
| 146 | skipNextRenderRef.current = false; |
| 147 | setRenderRevision((current) => current + 1); |
| 148 | }; |
| 149 | |
| 150 | useEffect(() => { |
| 151 | latestSyncPropsRef.current = { |
| 152 | data: props.data, |
| 153 | onDataChange: props.onDataChange, |
| 154 | syntax: props.syntax, |
| 155 | }; |
| 156 | }, [props.data, props.onDataChange, props.syntax]); |
| 157 | |
| 158 | useEffect(() => { |
| 159 | const timeoutId = window.setTimeout(() => { |
| 160 | setDebouncedData(props.data); |
| 161 | }, PREVIEW_DATA_DEBOUNCE_MS); |
| 162 | |
| 163 | return () => { |
| 164 | window.clearTimeout(timeoutId); |
| 165 | }; |
| 166 | }, [props.data]); |
| 167 | |
| 168 | useEffect(() => { |
| 169 | if (!containerRef.current) return; |
| 170 | setPreviewState("loading"); |
| 171 | |
| 172 | if (!infographicRef.current) { |
| 173 | const plugins: IPlugin[] = [ |
| 174 | new InfographicSelectionPlugin(setSelectionPayload), |
| 175 | new ResizeElement(), |
| 176 | new ResetViewBox(), |
| 177 | ]; |
| 178 | |
| 179 | const instance = new Infographic({ |
| 180 | container: containerRef.current, |
| 181 | width: "100%", |
| 182 | height: "100%", |
| 183 | editable: true, |
| 184 | plugins, |
| 185 | interactions: [ |
| 186 | new DragCanvas({ trigger: ["Space"] }), |
| 187 | new DblClickEditText(), |
| 188 | new BrushSelect(), |
| 189 | new ClickSelect(), |
| 190 | new DragElement(), |
| 191 | new HotkeyHistory(), |
| 192 | new ZoomWheel(), |
| 193 | new SelectHighlight(), |
| 194 | ] satisfies IInteraction[], |
| 195 | }); |
| 196 | |
| 197 | const handleOptionsChange = () => { |
| 198 | if (isRenderingRef.current) return; |
| 199 | |
| 200 | const parsedOptions = ( |
| 201 | instance as unknown as InfographicEditorInstance |
| 202 | ).editor?.state?.getOptions?.(); |
| 203 | const nextOptions = parsedOptions |
| 204 | ? toSerializableOptionsFromParsed(parsedOptions) |
| 205 | : pickSerializableOptions(instance.getOptions()); |
| 206 | |
| 207 | const syncProps = latestSyncPropsRef.current; |
| 208 | skipNextRenderRef.current = true; |
| 209 | syncProps.onDataChange( |
| 210 | createEditableInfographicData({ |
| 211 | options: nextOptions, |
| 212 | previousData: syncProps.data, |
| 213 | syntax: syncProps.syntax, |
| 214 | }), |
| 215 | ); |
| 216 | }; |
| 217 | |
| 218 | instance.on("options:change", handleOptionsChange); |
| 219 | infographicRef.current = instance; |
| 220 | } |
| 221 | |
| 222 | if (skipNextRenderRef.current) { |
| 223 | skipNextRenderRef.current = false; |
| 224 | setPreviewState("ready"); |
| 225 | return; |
| 226 | } |
| 227 | |
| 228 | try { |
| 229 | isRenderingRef.current = true; |
| 230 | infographicRef.current.render(payload); |
| 231 | frameRef.current = window.requestAnimationFrame(() => { |
| 232 | frameRef.current = null; |
| 233 | if (containerRef.current) { |
| 234 | enforceInfographicCardBackground(containerRef.current); |
| 235 | } |
| 236 | isRenderingRef.current = false; |
| 237 | setPreviewState("ready"); |
| 238 | }); |
| 239 | } catch (error) { |
| 240 | isRenderingRef.current = false; |
| 241 | console.error("Failed to render infographic preview:", error); |
| 242 | setPreviewState("error"); |
| 243 | } |
| 244 | |
| 245 | return () => { |
| 246 | if (frameRef.current !== null) { |
| 247 | window.cancelAnimationFrame(frameRef.current); |
| 248 | frameRef.current = null; |
| 249 | } |
| 250 | }; |
| 251 | }, [payload, renderRevision]); |
| 252 | |
| 253 | useEffect(() => { |
| 254 | return () => { |
| 255 | if (infographicRef.current) { |
| 256 | infographicRef.current.destroy(); |
| 257 | infographicRef.current = null; |
| 258 | } |
| 259 | }; |
| 260 | }, []); |
| 261 | |
| 262 | return ( |
| 263 | <div |
| 264 | className="relative h-full min-h-0 overflow-hidden rounded-lg border bg-background" |
| 265 | data-infographic-preview-interactive="true" |
| 266 | > |
| 267 | {previewState === "loading" && ( |
| 268 | <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-background/70"> |
| 269 | <Loader2 className="size-5 animate-spin text-muted-foreground" /> |
| 270 | </div> |
| 271 | )} |
| 272 | {selectionPayload && ( |
| 273 | <InfographicFloatingToolbar |
| 274 | onDataMutation={forcePreviewRender} |
| 275 | payload={selectionPayload} |
| 276 | portalContainer={props.toolbarPortalContainerRef?.current} |
| 277 | /> |
| 278 | )} |
| 279 | {previewState === "error" && ( |
| 280 | <div className="absolute inset-0 z-10 flex items-center justify-center p-6 text-center text-sm text-muted-foreground"> |
| 281 | Preview unavailable for this data shape. |
| 282 | </div> |
| 283 | )} |
| 284 | <div |
| 285 | ref={containerRef} |
| 286 | className={cn( |
| 287 | "h-full min-h-0 w-full p-4 transition-opacity", |
| 288 | previewState === "ready" ? "opacity-100" : "opacity-30", |
| 289 | )} |
| 290 | /> |
| 291 | </div> |
| 292 | ); |
| 293 | } |
| 294 |