返回 presentation-ai
antv-infographic-static.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / static / antv-infographic-static.tsx
1 "use client";
2
3 import { Infographic } from "@antv/infographic";
4 import { SlateElement, type SlateElementProps } from "platejs/static";
5 import { useEffect, useRef, useState } from "react";
6
7 import { SparklesCore } from "@/components/ui/sparkles";
8 import { useAntvInfographicTheme } from "@/hooks/presentation/infographic/useAntvInfographicTheme";
9 import { cn } from "@/lib/utils";
10 import { type TAntvInfographicElement } from "../../plugins/antv-infographic-plugin";
11 import {
12 enforceInfographicCardBackground,
13 useInfographicCardBackground,
14 } from "../../utils/infographic-card-background";
15 import {
16 applyInitialInfographicTitleLayout,
17 hasInfographicTitleLayoutAttributes,
18 } from "../../utils/infographic-title-layout";
19 import { applyThemeToData } from "../../utils/infographic-utils";
20
21 const alignmentClasses = {
22 center: "mx-auto",
23 left: "mr-auto",
24 right: "ml-auto",
25 } as const;
26
27 /**
28 *
29 * Static (read-only) AntV Infographic component for preview/display
30 * This component re-renders when element.data changes (unlike the editable version)
31 */
32 export default function AntvInfographicStatic(
33 props: SlateElementProps<TAntvInfographicElement>,
34 ) {
35 const { element, children } = props;
36 const align = element.align ?? "center";
37
38 const containerStyles: React.CSSProperties = {
39 width:
40 typeof element.width === "string" || typeof element.width === "number"
41 ? element.width
42 : "100%",
43 };
44
45 // Refs
46 const containerRef = useRef<HTMLDivElement>(null);
47 const infographicRef = useRef<Infographic | null>(null);
48
49 // State
50 const [hasError, setHasError] = useState(false);
51
52 // Theme
53 const { isDark, themedSyntax, themeColors } = useAntvInfographicTheme(
54 element.syntax,
55 );
56 const cardBackgroundRefreshKey = `${isDark}:${themeColors?.cardBackground ?? ""}`;
57
58 // ============================================================================
59 // INFOGRAPHIC INSTANCE LIFECYCLE
60 // ============================================================================
61
62 // Create infographic instance once (container is always mounted)
63 useEffect(() => {
64 const container = containerRef.current;
65 if (!container) return;
66
67 const instance = new Infographic({
68 container,
69 width: "100%",
70 height: "100%",
71 editable: false,
72 });
73
74 infographicRef.current = instance;
75
76 return () => {
77 try {
78 instance.destroy();
79 } catch {
80 // Ignore destruction errors
81 }
82 infographicRef.current = null;
83 };
84 }, []);
85
86 // Render content when data/syntax/theme changes
87 useEffect(() => {
88 const instance = infographicRef.current;
89 if (!instance || element.isLoading) {
90 return;
91 }
92 let frameId: number | null = null;
93
94 // Prefer data over syntax
95 const savedData = element.data;
96 const hasSavedData = Boolean(
97 savedData && Object.keys(savedData).length > 0,
98 );
99 const payload =
100 hasSavedData && savedData
101 ? applyThemeToData(savedData, isDark, themeColors)
102 : themedSyntax;
103
104 if (!payload || (typeof payload === "string" && !payload.trim())) return;
105
106 try {
107 instance.render(payload);
108 frameId = window.requestAnimationFrame(() => {
109 const container = containerRef.current;
110 if (container) {
111 if (!hasInfographicTitleLayoutAttributes(savedData)) {
112 applyInitialInfographicTitleLayout(container, instance);
113 }
114 enforceInfographicCardBackground(container);
115 }
116 });
117 setHasError(false);
118 } catch (err) {
119 console.error("Failed to render infographic:", err);
120 setHasError(true);
121 }
122
123 return () => {
124 if (frameId !== null) {
125 window.cancelAnimationFrame(frameId);
126 }
127 };
128 }, [themedSyntax, element.isLoading, element.data, isDark, themeColors]);
129 useInfographicCardBackground(containerRef, cardBackgroundRefreshKey);
130
131 // ============================================================================
132 // RENDER
133 // ============================================================================
134
135 return (
136 <SlateElement {...props}>
137 <div
138 className={cn(
139 "relative my-4 min-h-75 overflow-hidden rounded-lg",
140 alignmentClasses[align],
141 )}
142 style={{
143 ...containerStyles,
144 backgroundColor: "var(--presentation-background)",
145 }}
146 data-slate-void="true"
147 data-ppt-ignore="true"
148 contentEditable={false}
149 >
150 {/* Container is always mounted so instance can be created */}
151 <div
152 ref={containerRef}
153 className="min-h-75 w-full overflow-hidden"
154 style={{
155 minHeight: "300px",
156 display: element.isLoading || hasError ? "none" : "block",
157 }}
158 />
159
160 {/* Loading state */}
161 {element.isLoading && (
162 <div className="relative flex h-75 w-full items-center justify-center">
163 <SparklesCore
164 background="transparent"
165 minSize={1}
166 maxSize={2}
167 particleDensity={150}
168 particleColor={isDark ? "#22d3ee" : "#0ea5e9"}
169 className="absolute inset-0"
170 />
171 </div>
172 )}
173
174 {/* Error state */}
175 {hasError && !element.isLoading && (
176 <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">
177 <p className="font-medium">Failed to render diagram</p>
178 <p className="mt-1 text-sm text-red-400">
179 Please try again with different content
180 </p>
181 </div>
182 )}
183 </div>
184 {children}
185 </SlateElement>
186 );
187 }
188
188 lines Plain Text