返回 presentation-ai
normalizePresentationSlate.ts
根目录 / src / components / notebook / presentation / utils / normalizePresentationSlate.ts
1 import { nanoid } from "nanoid";
2 import {
3 type Descendant,
4 type TElement,
5 type TText,
6 type Value,
7 } from "platejs";
8
9 import { type PlateSlide } from "./parser";
10
11 const COLUMN_GROUP_TYPE = "column_group";
12 const COLUMN_TYPE = "column";
13 const TABLE_TYPE = "table";
14 const TABLE_ROW_TYPE = "tr";
15 const TABLE_CELL_TYPES = new Set(["td", "th"]);
16 const MIN_COLUMN_WIDTH_PERCENT = 10;
17
18 const emptyText = (): TText => ({ text: "" });
19
20 const emptyParagraph = (): Descendant =>
21 ({
22 type: "p",
23 children: [emptyText()],
24 }) as TElement;
25
26 function isRecord(value: unknown): value is Record<string, unknown> {
27 return typeof value === "object" && value !== null && !Array.isArray(value);
28 }
29
30 function normalizeTextNode(node: Record<string, unknown>): TText {
31 const { children: _children, text, ...rest } = node;
32
33 return {
34 ...rest,
35 text: typeof text === "string" ? text : "",
36 } as TText;
37 }
38
39 function distributeColumnWidths(widths: Array<number | null>): string[] {
40 if (widths.length === 0) {
41 return [];
42 }
43
44 const validWidths = widths.filter((width) => width !== null);
45 const shouldResetWidths =
46 validWidths.length !== widths.length ||
47 validWidths.some((width) => width < MIN_COLUMN_WIDTH_PERCENT);
48
49 return createExactPercentageWidths(
50 shouldResetWidths ? widths.map(() => 1) : validWidths,
51 );
52 }
53
54 function createExactPercentageWidths(weights: number[]): string[] {
55 if (weights.length === 0) {
56 return [];
57 }
58
59 const safeWeights = weights.map((weight) =>
60 Number.isFinite(weight) && weight > 0 ? weight : 0,
61 );
62 const total = safeWeights.reduce((sum, weight) => sum + weight, 0);
63 const percentages =
64 total > 0
65 ? safeWeights.map((weight) => (weight / total) * 100)
66 : safeWeights.map(() => 100 / safeWeights.length);
67 let assignedTotal = 0;
68
69 return percentages.map((percentage, index) => {
70 const width =
71 index === percentages.length - 1 ? 100 - assignedTotal : percentage;
72
73 assignedTotal += width;
74 return `${width}%`;
75 });
76 }
77
78 function normalizeColumnGroupChildren(children: Descendant[]): Descendant[] {
79 const columnWidths = children
80 .filter((child) => isRecord(child) && child.type === COLUMN_TYPE)
81 .map((child) => parseColumnWidth(child.width));
82 const normalizedWidths = distributeColumnWidths(columnWidths);
83 let columnIndex = 0;
84
85 return children.map((child) => {
86 if (!isRecord(child) || child.type !== COLUMN_TYPE) {
87 return child;
88 }
89
90 const width = normalizedWidths[columnIndex];
91 columnIndex += 1;
92
93 return width ? ({ ...child, width } as TElement) : (child as TElement);
94 });
95 }
96
97 function getNodeType(node: Record<string, unknown>): string | undefined {
98 return typeof node.type === "string" ? node.type : undefined;
99 }
100
101 function isTableCellType(type: string | undefined): boolean {
102 return type !== undefined && TABLE_CELL_TYPES.has(type);
103 }
104
105 function normalizeTableCellContent(child: unknown): Descendant[] {
106 if (isRecord(child) && "text" in child) {
107 return [normalizeTextNode(child)];
108 }
109
110 return [normalizeDescendant(child, "element")];
111 }
112
113 function normalizeTableCellChild(child: unknown): Descendant {
114 const normalizedChild = normalizeDescendant(child, "element");
115
116 if (isRecord(normalizedChild) && "text" in normalizedChild) {
117 return {
118 type: "p",
119 children: [normalizedChild],
120 } as TElement;
121 }
122
123 return normalizedChild;
124 }
125
126 function normalizeTableCellChildren(children: unknown): Descendant[] {
127 if (!Array.isArray(children) || children.length === 0) {
128 return [emptyParagraph()];
129 }
130
131 return children
132 .flatMap(normalizeTableCellContent)
133 .map(normalizeTableCellChild);
134 }
135
136 function createTableCellFromChild(child: unknown): TElement {
137 const normalizedChild = normalizeDescendant(child, "element");
138 const children =
139 isRecord(normalizedChild) && "text" in normalizedChild
140 ? [
141 {
142 type: "p",
143 children: [normalizedChild],
144 } as TElement,
145 ]
146 : [normalizedChild];
147
148 return {
149 type: "td",
150 id: nanoid(),
151 children,
152 } as TElement;
153 }
154
155 function normalizeTableRowChildren(children: unknown): Descendant[] {
156 if (!Array.isArray(children) || children.length === 0) {
157 return [
158 {
159 type: "td",
160 id: nanoid(),
161 children: [emptyParagraph()],
162 } as TElement,
163 ];
164 }
165
166 return children.map((child) => {
167 if (!isRecord(child) || !isTableCellType(getNodeType(child))) {
168 return createTableCellFromChild(child);
169 }
170
171 return normalizeElementNode(child);
172 });
173 }
174
175 function createTableRowFromChild(child: unknown): TElement {
176 const children =
177 isRecord(child) && isTableCellType(getNodeType(child))
178 ? [normalizeElementNode(child)]
179 : [createTableCellFromChild(child)];
180
181 return {
182 type: TABLE_ROW_TYPE,
183 id: nanoid(),
184 children,
185 } as TElement;
186 }
187
188 function normalizeTableChildren(children: unknown): Descendant[] {
189 if (!Array.isArray(children) || children.length === 0) {
190 return [
191 {
192 type: TABLE_ROW_TYPE,
193 id: nanoid(),
194 children: [
195 {
196 type: "td",
197 id: nanoid(),
198 children: [emptyParagraph()],
199 } as TElement,
200 ],
201 } as TElement,
202 ];
203 }
204
205 return children.map((child) => {
206 if (!isRecord(child) || getNodeType(child) !== TABLE_ROW_TYPE) {
207 return createTableRowFromChild(child);
208 }
209
210 return normalizeElementNode(child);
211 });
212 }
213
214 function parseColumnWidth(width: unknown): number | null {
215 if (width === undefined || width === null) return null;
216
217 const parsed = Number.parseFloat(String(width));
218
219 if (!Number.isFinite(parsed) || parsed <= 0) return null;
220
221 return Math.round(parsed * 100) / 100;
222 }
223
224 function normalizeElementNode(node: Record<string, unknown>): TElement {
225 const type = typeof node.type === "string" ? node.type : "p";
226 const id = typeof node.id === "string" && node.id ? node.id : nanoid();
227 const normalizedChildren = Array.isArray(node.children)
228 ? node.children.map((child) => normalizeDescendant(child, "text"))
229 : [];
230 const children =
231 normalizedChildren.length > 0 ? normalizedChildren : [emptyText()];
232 const tableChildren =
233 type === TABLE_TYPE
234 ? normalizeTableChildren(node.children)
235 : type === TABLE_ROW_TYPE
236 ? normalizeTableRowChildren(node.children)
237 : isTableCellType(type)
238 ? normalizeTableCellChildren(node.children)
239 : undefined;
240
241 return {
242 ...node,
243 id,
244 type,
245 children: tableChildren
246 ? tableChildren
247 : type === COLUMN_GROUP_TYPE
248 ? normalizeColumnGroupChildren(children)
249 : children,
250 } as TElement;
251 }
252
253 function normalizeDescendant(
254 node: unknown,
255 fallback: "element" | "text",
256 ): Descendant {
257 if (!isRecord(node)) {
258 return fallback === "element" ? emptyParagraph() : emptyText();
259 }
260
261 if ("text" in node) {
262 return normalizeTextNode(node);
263 }
264
265 return normalizeElementNode(node);
266 }
267
268 export function normalizePresentationValue(value: unknown): Value {
269 if (!Array.isArray(value) || value.length === 0) {
270 return [emptyParagraph()] as Value;
271 }
272
273 return value.map((node) => normalizeDescendant(node, "element")) as Value;
274 }
275
276 function normalizePresentationSlide(slide: unknown): PlateSlide {
277 if (!isRecord(slide)) {
278 return {
279 id: nanoid(),
280 content: [emptyParagraph()],
281 alignment: "center",
282 } as PlateSlide;
283 }
284
285 const id = typeof slide.id === "string" && slide.id ? slide.id : nanoid();
286
287 return {
288 ...slide,
289 id,
290 content: normalizePresentationValue(slide.content) as PlateSlide["content"],
291 } as PlateSlide;
292 }
293
294 export function normalizePresentationSlides(slides: unknown): PlateSlide[] {
295 if (!Array.isArray(slides)) {
296 return [];
297 }
298
299 return slides.map(normalizePresentationSlide);
300 }
301
301 lines TYPESCRIPT