返回 presentation-ai
infographic-title-layout.ts
根目录 / src / components / notebook / presentation / editor / utils / infographic-title-layout.ts
1 "use client";
2
3 import { type Infographic, type InfographicOptions } from "@antv/infographic";
4
5 const TITLE_WIDTH_MULTIPLIER = 1.5;
6 const DESCRIPTION_WIDTH_MULTIPLIER = 1.5;
7 const MAX_TEXT_VIEWPORT_RATIO = 0.95;
8 const CENTER_ALIGN = "CENTER";
9
10 type InfographicElementUpdate = {
11 attributes: Record<string, string | number>;
12 };
13
14 type InfographicEditorState = {
15 updateElement: (element: Element, props: InfographicElementUpdate) => void;
16 };
17
18 type InfographicEditorContainer = {
19 editor?: {
20 state?: InfographicEditorState;
21 };
22 };
23
24 export function hasInfographicTitleLayoutAttributes(
25 data?: Partial<InfographicOptions>,
26 ): boolean {
27 const attributes = data?.data?.attributes;
28
29 return Boolean(attributes?.title || attributes?.desc);
30 }
31
32 function parseNumberAttribute(value: string | null): number | null {
33 if (!value) return null;
34
35 const parsed = Number.parseFloat(value);
36 return Number.isFinite(parsed) ? parsed : null;
37 }
38
39 function getSvgViewportWidth(element: SVGGraphicsElement): number | null {
40 const svgElement = element.ownerSVGElement;
41 if (!svgElement) return null;
42
43 const viewBoxWidth = svgElement.viewBox.baseVal.width;
44 if (viewBoxWidth > 0) return viewBoxWidth;
45
46 const attributeWidth = parseNumberAttribute(svgElement.getAttribute("width"));
47 if (attributeWidth !== null && attributeWidth > 0) return attributeWidth;
48
49 const rectWidth = svgElement.getBoundingClientRect().width;
50 return rectWidth > 0 ? rectWidth : null;
51 }
52
53 function clampTextXPosition(
54 x: number,
55 width: number,
56 maxWidth: number,
57 ): number {
58 return Math.min(Math.max(x, 0), Math.max(maxWidth - width, 0));
59 }
60
61 function centerInfographicText(textElement: SVGGraphicsElement): void {
62 textElement.setAttribute("data-horizontal-align", CENTER_ALIGN);
63
64 if (textElement instanceof SVGTextElement) {
65 textElement.setAttribute("text-anchor", "middle");
66 }
67
68 const textEntity = textElement.querySelector("span");
69 if (textEntity instanceof HTMLSpanElement) {
70 textEntity.style.textAlign = "center";
71 textEntity.style.justifyContent = "center";
72 }
73 }
74
75 function getCenterAttributes(
76 textElement: SVGGraphicsElement,
77 ): Record<string, string | number> {
78 const attributes: Record<string, string | number> = {
79 "data-horizontal-align": CENTER_ALIGN,
80 };
81
82 if (textElement instanceof SVGTextElement) {
83 attributes["text-anchor"] = "middle";
84 }
85
86 return attributes;
87 }
88
89 function getInfographicEditorState(
90 instance?: Infographic,
91 ): InfographicEditorState | undefined {
92 return (instance as unknown as InfographicEditorContainer | undefined)?.editor
93 ?.state;
94 }
95
96 function widenInfographicText(
97 textElement: SVGGraphicsElement,
98 widthMultiplier: number,
99 state?: InfographicEditorState,
100 ): boolean {
101 const originalWidth = parseNumberAttribute(textElement.getAttribute("width"));
102 if (originalWidth === null || originalWidth <= 0) {
103 centerInfographicText(textElement);
104 state?.updateElement(textElement, {
105 attributes: getCenterAttributes(textElement),
106 });
107 return true;
108 }
109
110 const originalX = parseNumberAttribute(textElement.getAttribute("x")) ?? 0;
111 const viewportWidth = getSvgViewportWidth(textElement);
112 const nextWidth =
113 viewportWidth === null
114 ? originalWidth * widthMultiplier
115 : Math.min(
116 originalWidth * widthMultiplier,
117 viewportWidth * MAX_TEXT_VIEWPORT_RATIO,
118 );
119 const centeredX = originalX - (nextWidth - originalWidth) / 2;
120 const nextX =
121 viewportWidth === null
122 ? centeredX
123 : clampTextXPosition(centeredX, nextWidth, viewportWidth);
124
125 textElement.setAttribute("width", String(nextWidth));
126 textElement.setAttribute("x", String(nextX));
127 centerInfographicText(textElement);
128
129 const attributes: Record<string, string | number> = {
130 ...getCenterAttributes(textElement),
131 width: nextWidth,
132 x: nextX,
133 };
134
135 state?.updateElement(textElement, { attributes });
136 return true;
137 }
138
139 export function applyInitialInfographicTitleLayout(
140 container: HTMLElement,
141 instance?: Infographic,
142 ): boolean {
143 const state = getInfographicEditorState(instance);
144 const titleElements = container.querySelectorAll<SVGGraphicsElement>(
145 '[data-element-type="title"]',
146 );
147 const descElements = container.querySelectorAll<SVGGraphicsElement>(
148 '[data-element-type="desc"]',
149 );
150 let didUpdate = false;
151
152 for (const titleElement of titleElements) {
153 didUpdate =
154 widenInfographicText(titleElement, TITLE_WIDTH_MULTIPLIER, state) ||
155 didUpdate;
156 }
157
158 for (const descElement of descElements) {
159 didUpdate =
160 widenInfographicText(descElement, DESCRIPTION_WIDTH_MULTIPLIER, state) ||
161 didUpdate;
162 }
163
164 return didUpdate;
165 }
166
166 lines TYPESCRIPT