返回 presentation-ai
useAntvInfographicInstance.ts
根目录 / src / hooks / presentation / infographic / useAntvInfographicInstance.ts
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 IPlugin,
17 } from "@antv/infographic";
18 import { useEffect, useRef, type RefObject } from "react";
19
20 import { registerLucideIconLoader } from "./infographic-icon-loader";
21 import {
22 InfographicSelectionPlugin,
23 type InfographicSelectionPayload,
24 } from "./InfographicSelectionPlugin";
25
26 registerLucideIconLoader();
27
28 type InfographicEditorInstance = {
29 editor?: {
30 interaction?: {
31 clearSelection?: () => void;
32 };
33 };
34 };
35
36 type InfographicInstanceParams = {
37 containerRef: RefObject<HTMLDivElement | null>;
38 editable?: boolean;
39 onSelectionChange?: (payload: InfographicSelectionPayload) => void;
40 };
41
42 const INLINE_TEXT_EDITOR_CLASS = "infographic-inline-text-editor";
43 const INLINE_TEXT_SHORTCUT_KEYS = new Set(["a", "c", "v", "x"]);
44
45 const isNativeInlineTextShortcut = (event: KeyboardEvent) =>
46 (event.ctrlKey || event.metaKey) &&
47 !event.altKey &&
48 !event.shiftKey &&
49 INLINE_TEXT_SHORTCUT_KEYS.has(event.key.toLowerCase());
50
51 const getEventElement = (target: EventTarget | null) => {
52 if (target instanceof Element) return target;
53 if (target instanceof Node) return target.parentElement;
54 return null;
55 };
56
57 const getInfographicInlineTextEditor = (
58 target: EventTarget | null,
59 container: HTMLElement,
60 ) => {
61 const element = getEventElement(target);
62 const inlineTextEditor = element?.closest<HTMLElement>(
63 `.${INLINE_TEXT_EDITOR_CLASS}`,
64 );
65 if (!inlineTextEditor) return null;
66 if (!inlineTextEditor.isContentEditable) return null;
67 if (!container.contains(inlineTextEditor)) return null;
68
69 return inlineTextEditor;
70 };
71
72 export function useAntvInfographicInstance({
73 containerRef,
74 editable = true,
75 onSelectionChange,
76 }: InfographicInstanceParams) {
77 const infographicRef = useRef<Infographic | null>(null);
78
79 useEffect(() => {
80 const container = containerRef.current;
81 if (!container) return;
82
83 const plugins: IPlugin[] = editable
84 ? [new ResizeElement(), new ResetViewBox()]
85 : [];
86
87 if (editable && onSelectionChange) {
88 plugins.unshift(new InfographicSelectionPlugin(onSelectionChange));
89 }
90
91 const instance = new Infographic({
92 container,
93 width: "100%",
94 height: "100%",
95 editable,
96 plugins,
97 interactions: editable
98 ? ([
99 new DragCanvas({ trigger: ["Space"] }),
100 new DblClickEditText(),
101 new BrushSelect(),
102 new ClickSelect(),
103 new DragElement(),
104 new HotkeyHistory(),
105 new ZoomWheel(),
106 new SelectHighlight(),
107 ] satisfies IInteraction[])
108 : [],
109 });
110
111 infographicRef.current = instance;
112
113 const getSvg = () => container.querySelector("svg");
114
115 const handleDocumentClick = (event: MouseEvent) => {
116 const target = event.target as Node | null;
117 if (!target) return;
118
119 const svg = getSvg();
120 if (!svg) return;
121
122 const inInfographicContainer = container.contains(target);
123 const inToolbarOrPanel =
124 target instanceof Element &&
125 (!!target.closest(".antv-infographic-toolbar-floating") ||
126 !!target.closest(".antv-infographic-template-panel") ||
127 !!target.closest(".icon-picker-panel") ||
128 !!target.closest(".presentation-right-panel") ||
129 // Radix UI portals for dropdowns, popovers, color pickers
130 !!target.closest("[data-radix-popper-content-wrapper]") ||
131 !!target.closest("[role='dialog']") ||
132 !!target.closest("[data-slot='tooltip-content']"));
133
134 if (!inInfographicContainer && !inToolbarOrPanel) {
135 (
136 instance as unknown as InfographicEditorInstance
137 ).editor?.interaction?.clearSelection?.();
138 }
139 };
140
141 // Use bubble phase so toolbar's stopPropagation can prevent this
142 document.addEventListener("click", handleDocumentClick);
143
144 const handleInlineTextEditorShortcut = (event: KeyboardEvent) => {
145 if (!isNativeInlineTextShortcut(event)) return;
146
147 const inlineTextEditor = getInfographicInlineTextEditor(
148 event.target,
149 container,
150 );
151 if (!inlineTextEditor) return;
152
153 event.stopPropagation();
154 event.stopImmediatePropagation();
155 };
156
157 const handleInlineTextEditorClipboard = (event: ClipboardEvent) => {
158 const inlineTextEditor = getInfographicInlineTextEditor(
159 event.target,
160 container,
161 );
162 if (!inlineTextEditor) return;
163
164 event.stopPropagation();
165 event.stopImmediatePropagation();
166 };
167
168 container.addEventListener("keydown", handleInlineTextEditorShortcut);
169 container.addEventListener("copy", handleInlineTextEditorClipboard);
170 container.addEventListener("cut", handleInlineTextEditorClipboard);
171 container.addEventListener("paste", handleInlineTextEditorClipboard);
172
173 return () => {
174 document.removeEventListener("click", handleDocumentClick);
175 container.removeEventListener("keydown", handleInlineTextEditorShortcut);
176 container.removeEventListener("copy", handleInlineTextEditorClipboard);
177 container.removeEventListener("cut", handleInlineTextEditorClipboard);
178 container.removeEventListener("paste", handleInlineTextEditorClipboard);
179
180 try {
181 instance.destroy();
182 } catch {
183 // Ignore destruction errors
184 }
185 infographicRef.current = null;
186 };
187 }, [containerRef, onSelectionChange, editable]);
188
189 return infographicRef;
190 }
191
191 lines TYPESCRIPT