返回 presentation-ai
InfographicSelectionPlugin.ts
根目录 / src / hooks / presentation / infographic / InfographicSelectionPlugin.ts
1 import {
2 Plugin,
3 type ICommandManager,
4 type IStateManager,
5 type PluginInitOptions,
6 type Selection,
7 } from "@antv/infographic";
8
9 type InfographicSelectionType = "text" | "icon" | "geometry" | "mixed" | "none";
10
11 export interface InfographicSelectionPayload {
12 type: InfographicSelectionType;
13 boundingRect: DOMRect | null;
14 selection: Selection;
15 textElements: SVGForeignObjectElement[];
16 iconElements: SVGElement[];
17 geometryElements: SVGElement[];
18 iconIndexes: number[][];
19 commander: ICommandManager | null;
20 state: IStateManager | null;
21 fontFamily?: string;
22 fontSize?: number;
23 fontWeight?: string;
24 }
25
26 type SelectionBuckets = {
27 geometryElements: SVGElement[];
28 iconElements: SVGElement[];
29 iconIndexes: number[][];
30 textElements: SVGForeignObjectElement[];
31 type: InfographicSelectionType;
32 };
33
34 const TEXT_ROLES = new Set(["title", "desc", "item-label", "item-desc"]);
35 const ICON_ROLES = new Set(["item-icon", "item-icon-group"]);
36 const GEOMETRY_TAGS = new Set([
37 "rect",
38 "circle",
39 "ellipse",
40 "line",
41 "polygon",
42 "polyline",
43 "path",
44 ]);
45
46 const EMPTY_SELECTION_PAYLOAD: InfographicSelectionPayload = {
47 type: "none",
48 boundingRect: null,
49 selection: [],
50 textElements: [],
51 iconElements: [],
52 geometryElements: [],
53 iconIndexes: [],
54 commander: null,
55 state: null,
56 };
57
58 export class InfographicSelectionPlugin extends Plugin {
59 name = "infographic-selection-plugin";
60 private selection: Selection = [];
61 private onSelectionChange: (payload: InfographicSelectionPayload) => void;
62
63 constructor(
64 onSelectionChange: (payload: InfographicSelectionPayload) => void,
65 ) {
66 super();
67 this.onSelectionChange = onSelectionChange;
68 }
69
70 init(options: PluginInitOptions) {
71 super.init(options);
72 const { emitter } = options;
73 emitter.on("selection:change", this.handleSelectionChanged);
74 emitter.on("selection:geometrychange", this.handleGeometryChanged);
75 emitter.on("history:change", this.handleHistoryChanged);
76 }
77
78 destroy(): void {
79 if (this.emitter) {
80 this.emitter.off("selection:change", this.handleSelectionChanged);
81 this.emitter.off("selection:geometrychange", this.handleGeometryChanged);
82 this.emitter.off("history:change", this.handleHistoryChanged);
83 }
84 }
85
86 private handleSelectionChanged = ({ next }: { next: Selection }) => {
87 this.selection = next;
88 this.emitSelectionUpdate();
89 };
90
91 private handleGeometryChanged = ({
92 target,
93 }: {
94 type: "selection:geometrychange";
95 target: Selection[number];
96 }) => {
97 if (!this.selection.includes(target)) return;
98 this.emitSelectionUpdate();
99 };
100
101 private handleHistoryChanged = () => {
102 if (this.selection.length === 0) return;
103 this.emitSelectionUpdate();
104 };
105
106 private emitSelectionUpdate(): void {
107 if (this.selection.length === 0) {
108 this.onSelectionChange(EMPTY_SELECTION_PAYLOAD);
109 return;
110 }
111
112 const buckets = getSelectionBuckets(this.selection);
113 const textStyle = getSelectedTextStyle(buckets.textElements);
114
115 this.onSelectionChange({
116 type: buckets.type,
117 boundingRect: getSelectionBoundingRect(this.selection),
118 selection: this.selection,
119 textElements: buckets.textElements,
120 iconElements: buckets.iconElements,
121 geometryElements: buckets.geometryElements,
122 iconIndexes: buckets.iconIndexes,
123 commander: this.commander,
124 state: this.state,
125 fontFamily: textStyle.fontFamily,
126 fontSize: textStyle.fontSize,
127 fontWeight: textStyle.fontWeight,
128 });
129 }
130 }
131
132 function getSelectionBuckets(selection: Selection): SelectionBuckets {
133 const textElements: SVGForeignObjectElement[] = [];
134 const iconElements: SVGElement[] = [];
135 const geometryElements: SVGElement[] = [];
136 const iconIndexes: number[][] = [];
137
138 for (const element of selection) {
139 if (isTextElement(element)) {
140 textElements.push(element);
141 continue;
142 }
143
144 if (isIconElement(element)) {
145 iconElements.push(element);
146 const indexes = getIconIndexes(element);
147 if (indexes) iconIndexes.push(indexes);
148 continue;
149 }
150
151 if (isGeometryElement(element)) {
152 geometryElements.push(element);
153 }
154 }
155
156 return {
157 textElements,
158 iconElements,
159 geometryElements,
160 iconIndexes,
161 type: getSelectionType({
162 hasText: textElements.length > 0,
163 hasIcon: iconElements.length > 0,
164 hasGeometry: geometryElements.length > 0,
165 }),
166 };
167 }
168
169 function isTextElement(
170 element: SVGElement,
171 ): element is SVGForeignObjectElement {
172 const role = element.getAttribute("data-element-type");
173 return TEXT_ROLES.has(role ?? "");
174 }
175
176 function isIconElement(element: SVGElement): boolean {
177 const role = element.getAttribute("data-element-type");
178 const parentRole = element.parentElement?.getAttribute("data-element-type");
179
180 return (
181 ICON_ROLES.has(role ?? "") ||
182 (element.tagName.toLowerCase() === "use" && parentRole === "item-icon")
183 );
184 }
185
186 function isGeometryElement(element: SVGElement): boolean {
187 return GEOMETRY_TAGS.has(element.tagName.toLowerCase());
188 }
189
190 function getIconIndexes(element: SVGElement): number[] | null {
191 const entity =
192 element.tagName.toLowerCase() === "use"
193 ? element
194 : (element.querySelector<SVGElement>("use") ?? element);
195 const indexes = entity.dataset.indexes;
196
197 if (!indexes) return null;
198
199 return indexes.split(",").map(Number);
200 }
201
202 function getSelectionType({
203 hasText,
204 hasIcon,
205 hasGeometry,
206 }: {
207 hasGeometry: boolean;
208 hasIcon: boolean;
209 hasText: boolean;
210 }): InfographicSelectionType {
211 if (hasText && !hasIcon && !hasGeometry) return "text";
212 if (!hasText && hasIcon && !hasGeometry) return "icon";
213 if (!hasText && !hasIcon && hasGeometry) return "geometry";
214
215 return "mixed";
216 }
217
218 function getSelectionBoundingRect(selection: Selection): DOMRect | null {
219 try {
220 const rects = selection.map((element) => element.getBoundingClientRect());
221 if (rects.length === 0) return null;
222
223 let minX = Infinity;
224 let minY = Infinity;
225 let maxX = -Infinity;
226 let maxY = -Infinity;
227
228 for (const rect of rects) {
229 minX = Math.min(minX, rect.left);
230 minY = Math.min(minY, rect.top);
231 maxX = Math.max(maxX, rect.right);
232 maxY = Math.max(maxY, rect.bottom);
233 }
234
235 return new DOMRect(minX, minY, maxX - minX, maxY - minY);
236 } catch {
237 return null;
238 }
239 }
240
241 function getSelectedTextStyle(textElements: SVGForeignObjectElement[]): {
242 fontFamily: string;
243 fontSize: number;
244 fontWeight: string;
245 } {
246 const [firstText] = textElements;
247 if (!firstText) {
248 return {
249 fontFamily: "",
250 fontSize: 14,
251 fontWeight: "normal",
252 };
253 }
254
255 const css = getComputedStyle(firstText);
256
257 return {
258 fontFamily:
259 css.fontFamily || firstText.getAttribute("font-family") || "Open Sans",
260 fontSize: Number.parseFloat(
261 css.fontSize || firstText.getAttribute("font-size") || "14",
262 ),
263 fontWeight:
264 css.fontWeight || firstText.getAttribute("font-weight") || "normal",
265 };
266 }
267
267 lines TYPESCRIPT