返回 presentation-ai
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
294 lines Plain Text