返回 presentation-ai
antv-infographic.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / antv-infographic.tsx
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
238 lines Plain Text