返回 presentation-ai
contentWalker.ts
根目录 / src / components / presentation / export / contentWalker.ts
1 /**
2 * PlateJS Content Walker
3 * Traverses PlateJS slide content to extract structured elements
4 */
5 import { toPng } from "html-to-image";
6 import { KEYS } from "platejs";
7
8 import { type PlateNode } from "@/components/notebook/presentation/utils/parser";
9 import { extractTextStyles } from "./cssVariableResolver";
10 import { getEChartSvgDataUrl } from "./echartSvgExport";
11 import {
12 type BackgroundRectExportElement,
13 type DecorExportElement,
14 type ElementPosition,
15 type ExportElement,
16 type ImageExportElement,
17 type NativeShapeType,
18 type ShapeExportElement,
19 type TableCell,
20 type TableExportElement,
21 type TableRow,
22 } from "./types";
23 import { getOptimalPixelRatio } from "./utils";
24
25 /**
26 *
27 * Scan a slide's content and extract all exportable elements
28 */
29 export async function walkSlideContent(
30 content: PlateNode[],
31 slideElement: Element,
32 ): Promise<ExportElement[]> {
33 const elements: ExportElement[] = [];
34
35 // 1. First, scan for background rectangle elements (elements with data-bg-export)
36 // These are backgrounds of components like box-item, cycle-item, etc.
37 const backgroundRects = await scanBackgroundRects(slideElement);
38 elements.push(...backgroundRects);
39
40 // Scan for shape elements (native PPT shapes for arrows, etc.)
41 const shapeElements = await scanShapeElements(slideElement);
42 elements.push(...shapeElements);
43
44 // 2. Scan for ALL decorative elements at the slide level
45 // This catches decor elements that are siblings of PlateElements (like bullet markers)
46 const decorElements = await scanDecorElements(slideElement);
47 elements.push(...decorElements);
48
49 // 2. Then process content nodes (text, tables, images, etc.)
50 for (const node of content) {
51 const processed = await processNode(node, slideElement);
52 if (processed) {
53 if (Array.isArray(processed)) {
54 elements.push(...processed);
55 } else {
56 elements.push(processed);
57 }
58 }
59 }
60 return elements;
61 }
62
63 /**
64 * Scan for all decorative elements (data-decor, standalone SVGs) in the slide
65 * Uses parallel processing for better performance
66 */
67 async function scanDecorElements(
68 slideElement: Element,
69 ): Promise<ExportElement[]> {
70 // Find all elements with data-decor="true" and standalone SVGs
71 const allDecorElements = Array.from(
72 slideElement.querySelectorAll(
73 '[data-decor="true"]:not([data-shape]), svg:not([data-ppt-ignore="true"]):not([data-shape])',
74 ),
75 );
76
77 // Filter to only top-level decor elements (not nested inside other decor elements)
78 // Also filter out SVGs that are inside chart/antv elements (already captured as screenshots)
79 const distinctDecorElements = allDecorElements.filter((el) => {
80 // Skip if nested inside another decor element
81 if (
82 allDecorElements.some((parent) => parent !== el && parent.contains(el))
83 ) {
84 return false;
85 }
86
87 // For SVG elements, check if they're inside an element with data-ppt-ignore
88 // These are already captured as screenshots (e.g., charts, infographics)
89 if (el.tagName.toLowerCase() === "svg") {
90 const ignoredContainer = el.closest('[data-ppt-ignore="true"]');
91 if (ignoredContainer) {
92 return false; // Skip SVGs inside data-ppt-ignore containers
93 }
94 }
95
96 return true;
97 });
98
99 // Process all decor elements in parallel for better performance
100 const results = await Promise.all(
101 distinctDecorElements.map(async (decorEl) => {
102 // preserve aspect ratio for decor elements (width-based) to prevent distortion in PPT
103 const position = getElementPositionFromDOM(
104 decorEl,
105 slideElement,
106 true,
107 "width",
108 );
109 const decorType = decorEl.getAttribute("data-decor") || "svg-content";
110 return createDecorImage(decorEl, position, decorType);
111 }),
112 );
113
114 return results.filter((el): el is DecorExportElement => el !== null);
115 }
116
117 /**
118 * Scan for background rectangle elements (elements with data-bg-export attribute)
119 * These are backgrounds of components like box-item, cycle-item, pros/cons items, etc.
120 * We clone the element, remove children, and capture just the empty background.
121 */
122 async function scanBackgroundRects(
123 slideElement: Element,
124 ): Promise<ExportElement[]> {
125 const elements: ExportElement[] = [];
126
127 // Find all elements with data-bg-export="true"
128 const bgExportElements = Array.from(
129 slideElement.querySelectorAll('[data-bg-export="true"]'),
130 );
131
132 for (const bgEl of bgExportElements) {
133 const backgroundRect = createBackgroundRect(
134 bgEl as HTMLElement,
135 slideElement,
136 );
137 if (backgroundRect) {
138 elements.push(backgroundRect);
139 }
140 }
141
142 return elements;
143 }
144
145 /**
146 * Create a background rectangle element by extracting computed styles.
147 * No cloning or image capture needed - just get the styles for PPT shape rendering.
148 */
149 function createBackgroundRect(
150 element: HTMLElement,
151 slideElement: Element,
152 ): BackgroundRectExportElement | null {
153 try {
154 // Get position from DOM
155 const position = getElementPositionFromDOM(element, slideElement);
156
157 // Get computed styles
158 const computedStyle = window.getComputedStyle(element);
159
160 // Get background color (resolved from CSS variables)
161 const bgColor = computedStyle.backgroundColor;
162
163 // Get background (for gradients like "linear-gradient(...)")
164 const background = computedStyle.background;
165
166 // Extract corner radius as number (in pixels)
167 const borderRadiusStr = computedStyle.borderRadius;
168 const cornerRadius = parseFloat(borderRadiusStr) || 0;
169
170 // Extract border info
171 const borderWidth = parseFloat(computedStyle.borderWidth) || 0;
172 const borderColor = computedStyle.borderColor;
173
174 // Skip if no visible background
175 if (
176 (!bgColor ||
177 bgColor === "rgba(0, 0, 0, 0)" ||
178 bgColor === "transparent") &&
179 (!background || background === "none")
180 ) {
181 return null;
182 }
183
184 return {
185 type: "backgroundRect",
186 position,
187 fillColor: colorToHex(bgColor),
188 cornerRadius,
189 borderColor: borderWidth > 0 ? colorToHex(borderColor) : undefined,
190 borderWidth: borderWidth > 0 ? borderWidth : undefined,
191 // Store the full background string for gradient support
192 background: background !== "none" ? background : undefined,
193 };
194 } catch (error) {
195 console.error("Failed to create background rect:", error);
196 return null;
197 }
198 }
199
200 /**
201 * Convert color to hex (simple helper)
202 */
203 function colorToHex(color: string): string {
204 if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)") {
205 return "FFFFFF";
206 }
207
208 // Already hex
209 if (color.startsWith("#")) {
210 return color.slice(1).toUpperCase();
211 }
212
213 // Use canvas to convert any color format
214 const canvas = document.createElement("canvas");
215 canvas.width = 1;
216 canvas.height = 1;
217 const ctx = canvas.getContext("2d");
218 if (!ctx) return "FFFFFF";
219
220 ctx.fillStyle = color;
221 ctx.fillRect(0, 0, 1, 1);
222 const data = ctx.getImageData(0, 0, 1, 1).data;
223
224 const toHex = (n: number) => n.toString(16).padStart(2, "0");
225 return `${toHex(data[0]!)}${toHex(data[1]!)}${toHex(data[2]!)}`.toUpperCase();
226 }
227
228 /**
229 * Process a single PlateJS node
230 */
231 async function processNode(
232 node: PlateNode,
233 slideElement: Element,
234 ): Promise<ExportElement | ExportElement[] | null> {
235 const type = node.type || "p";
236
237 // 1. Process Table
238 if (type === "table") {
239 return processTable(node, slideElement);
240 }
241
242 // 2. Process Image
243 // Checks for standard PlateJS image types
244 if (type === KEYS.img || type === "image") {
245 return processImage(node, slideElement);
246 }
247
248 // 3. Process MediaEmbed with image provider
249 // MediaEmbed elements with provider === 'image' should be exported as images
250 if (type === KEYS.mediaEmbed && node.provider === "image") {
251 return processMediaEmbedImage(node, slideElement);
252 }
253
254 // 4. Process Chart/AntV elements
255 // Elements with type starting with 'chart' or 'antv' should be converted to images
256 if (type.startsWith("chart") || type.startsWith("antv")) {
257 return processChartElement(node, slideElement);
258 }
259
260 // 5. Process generic Element (Text, Container, etc.)
261 // This handles everything else: paragraphs, headings, blockquotes, lists, etc.
262 return processElement(node, slideElement);
263 }
264
265 // ============================================================================
266 // 1. PROCESS TABLE
267 // ============================================================================
268
269 function processTable(
270 node: PlateNode,
271 slideElement: Element,
272 ): TableExportElement | null {
273 const children = node.children || [];
274 const rows: TableRow[] = [];
275
276 // Find the DOM element for the table to measure columns
277 const tableDOM = findDOMElement(node, slideElement) as HTMLTableElement;
278 // Get table width for ratio calculation
279 const tableWidth = tableDOM ? tableDOM.getBoundingClientRect().width : 0;
280
281 for (let r = 0; r < children.length; r++) {
282 const rowNode = children[r];
283 if (!rowNode || rowNode.type !== KEYS.tr) continue;
284
285 const cells: TableCell[] = [];
286 const rowChildren = (rowNode.children as PlateNode[]) || [];
287
288 // Get DOM row if possible
289 // const rowDOM = tableDOM
290 // ? (tableDOM.querySelectorAll("tr")[r] as HTMLTableRowElement)
291 // : null;
292
293 for (let c = 0; c < rowChildren.length; c++) {
294 const cellNode = rowChildren[c];
295 if (!cellNode) continue;
296
297 const isHeader = cellNode.type === KEYS.th;
298 // Extract text from cell children
299
300 const text = extractTextFromNodes(cellNode.children as PlateNode[]);
301
302 const cell: TableCell = {
303 text: text.trim(),
304 isHeader,
305 colSpan: (cellNode.colSpan as number) || 1,
306 rowSpan: (cellNode.rowSpan as number) || 1,
307 backgroundColor: isHeader ? "#f3f4f6" : undefined,
308 };
309
310 // Precise Measurement Strategy:
311 // Find the specific DOM element for this cell using data-block-id
312 const cellDOM = findDOMElement(cellNode, slideElement);
313
314 if (cellDOM && tableWidth > 0) {
315 const tableRect = tableDOM.getBoundingClientRect();
316 const cellRect = cellDOM.getBoundingClientRect();
317 const textStyleElement =
318 cellDOM.querySelector("h1,h2,h3,h4,h5,h6,p,span") ?? cellDOM;
319
320 // Calculate relative position and size in pixels (source dimensions)
321 // We will scale these in the converter
322 cell.box = {
323 x: cellRect.left - tableRect.left,
324 y: cellRect.top - tableRect.top,
325 width: cellRect.width,
326 height: cellRect.height,
327 };
328 cell.textStyles = extractTextStyles(textStyleElement);
329 }
330
331 cells.push(cell);
332 }
333
334 if (cells.length > 0) {
335 rows.push({ cells });
336 }
337 }
338
339 if (rows.length === 0) return null;
340
341 // IMPORTANT: Get position using data-block-id
342 const position = findElementPosition(node, slideElement);
343
344 return {
345 type: "table",
346 rows,
347 position,
348 headerRowCount: 1,
349 };
350 }
351
352 // ============================================================================
353 // 2. PROCESS IMAGE
354 // ============================================================================
355
356 async function processImage(
357 node: PlateNode,
358 slideElement: Element,
359 ): Promise<ImageExportElement | null> {
360 const url = node.url as string;
361 if (!url) return null;
362
363 // IMPORTANT: Get position using data-block-id
364 const position = findElementPosition(node, slideElement);
365
366 return {
367 type: "image",
368 url: url,
369 alt: (node.caption as string) || (node.alt as string) || "",
370 imageSource: node.imageSource as ImageExportElement["imageSource"],
371 position,
372 sizing: "contain", // Default to contain for clarity
373 stockImageProvider: node.stockImageProvider as string | undefined,
374 };
375 }
376
377 // ============================================================================
378 // 3. PROCESS MEDIA EMBED IMAGE
379 // ============================================================================
380
381 /**
382 * Process MediaEmbed elements with provider === 'image'
383 * These are image embeds created via the MediaEmbed component
384 */
385 async function processMediaEmbedImage(
386 node: PlateNode,
387 slideElement: Element,
388 ): Promise<ImageExportElement | null> {
389 // MediaEmbed stores the URL directly in the url property
390 const url = node.url as string;
391 if (!url) return null;
392
393 // IMPORTANT: Get position using data-block-id
394 const position = findElementPosition(node, slideElement);
395
396 return {
397 type: "image",
398 url: url,
399 alt: "Embedded image",
400 imageSource: node.imageSource as ImageExportElement["imageSource"],
401 position,
402 sizing: "contain",
403 stockImageProvider: node.stockImageProvider as string | undefined,
404 };
405 }
406
407 // ============================================================================
408 // 4. PROCESS CHART/ANTV ELEMENTS
409 // ============================================================================
410
411 /**
412 * Process Chart/AntV elements for export.
413 * ECharts render in SVG mode, so prefer their SVG instead of a screenshot.
414 */
415 async function processChartElement(
416 node: PlateNode,
417 slideElement: Element,
418 ): Promise<ImageExportElement | null> {
419 // Find the DOM element for this chart
420 const domElement = findDOMElement(node, slideElement);
421 if (!domElement) {
422 console.warn(`Chart element not found in DOM: ${node.id}`);
423 return null;
424 }
425
426 // Get the position with dimensions intact, preserving aspect ratio by height for charts
427 const position = getElementPositionFromDOM(
428 domElement,
429 slideElement,
430 true,
431 "height",
432 );
433
434 try {
435 const svgDataUrl = getEChartSvgDataUrl(domElement);
436 if (svgDataUrl) {
437 return {
438 type: "image",
439 url: svgDataUrl,
440 alt: `Chart: ${node.type}`,
441 position,
442 sizing: "contain",
443 };
444 }
445
446 // Convert the chart element to a PNG data URL
447 const dataUrl = await toPng(domElement as HTMLElement, {
448 backgroundColor: "transparent",
449 // Skip font embedding to avoid CORS errors with Google Fonts
450 skipFonts: true,
451 quality: 1,
452 // Bust cache for reliable rendering
453 cacheBust: true,
454 // Adaptive quality based on device capability
455 pixelRatio: getOptimalPixelRatio(),
456 });
457
458 return {
459 type: "image",
460 url: dataUrl,
461 alt: `Chart: ${node.type}`,
462 position,
463 sizing: "contain",
464 };
465 } catch (error) {
466 console.error(`Failed to convert chart element to image:`, error);
467 // Return null on failure - the chart won't be included
468 return null;
469 }
470 }
471
472 async function processElement(
473 node: PlateNode,
474 slideElement: Element,
475 ): Promise<ExportElement | ExportElement[] | null> {
476 const elements: ExportElement[] = [];
477
478 // 1. Find the DOM element for this node
479 const domElement = findDOMElement(node, slideElement);
480 if (!domElement) {
481 // If no DOM element found for this node, still try to process children recursively
482 if (node.children && Array.isArray(node.children)) {
483 for (const child of node.children as PlateNode[]) {
484 if (child.type) {
485 const childResult = await processNode(child, slideElement);
486 if (childResult) {
487 if (Array.isArray(childResult)) {
488 elements.push(...childResult);
489 } else {
490 elements.push(childResult);
491 }
492 }
493 }
494 }
495 }
496 return elements.length > 0 ? elements : null;
497 }
498
499 // 2. Process text content from this node's leaf nodes
500 // NOTE: Decor elements (SVGs, data-decor) are now scanned at the slide level
501 // in scanDecorElements() to ensure all are captured regardless of DOM structure
502 const textContent = extractTextFromLeafNodes(node);
503
504 // Only add text element if there is actual text at this level
505 if (textContent.trim()) {
506 const textStyles = extractTextStyles(domElement);
507 const textPosition = getElementPositionFromDOM(domElement, slideElement);
508
509 // Increase width by 5% for heading elements to prevent text overflow in PPT
510 const type = node.type || "";
511 const isHeading = /^h[1-6]$/.test(type);
512 if (isHeading) {
513 textPosition.width = textPosition.width * 1.1;
514 }
515
516 elements.push({
517 type: "text",
518 textContent: textContent,
519 textStyles,
520 position: textPosition,
521 nodeType: type || "p",
522 });
523 }
524
525 // 4. Recursively process child nodes that have their own type (block elements)
526 if (node.children && Array.isArray(node.children)) {
527 for (const child of node.children as PlateNode[]) {
528 // Only process children that are block-level elements (have a type)
529 // Skip leaf text nodes as they're already handled above
530 if (child.type && !child.text) {
531 const childResult = await processNode(child, slideElement);
532 if (childResult) {
533 if (Array.isArray(childResult)) {
534 elements.push(...childResult);
535 } else {
536 elements.push(childResult);
537 }
538 }
539 }
540 }
541 }
542
543 return elements.length > 0 ? elements : null;
544 }
545
546 /**
547 * Helper to capture element as image (for decor/svg)
548 */
549 async function createDecorImage(
550 element: Element,
551 position: ElementPosition,
552 decorType: string,
553 ): Promise<DecorExportElement | null> {
554 try {
555 const htmlElement = element as HTMLElement;
556 const computedStyle = window.getComputedStyle(htmlElement);
557 const width = htmlElement.offsetWidth;
558 const height = htmlElement.offsetHeight;
559 const dataUrl = await toPng(htmlElement, {
560 backgroundColor: "transparent",
561 // Skip font embedding to avoid CORS errors with Google Fonts
562 skipFonts: true,
563 // Bust cache for reliable rendering
564 cacheBust: true,
565 // Force dimensions to match rendered size
566 width: width,
567 height: height,
568 // Apply computed styles explicitly to ensure CSS variables are resolved
569 style: {
570 backgroundColor: computedStyle.backgroundColor,
571 color: computedStyle.color,
572 borderColor: computedStyle.borderColor,
573 borderWidth: computedStyle.borderWidth,
574 borderStyle: computedStyle.borderStyle,
575 borderRadius: computedStyle.borderRadius,
576 // Ensure flex layouts inside (like centered numbers) are preserved
577 display: computedStyle.display,
578 alignItems: computedStyle.alignItems,
579 justifyContent: computedStyle.justifyContent,
580 },
581 // Adaptive quality based on device capability
582 pixelRatio: getOptimalPixelRatio(),
583 quality: 1,
584 });
585
586 return {
587 type: "decor",
588 decorType: decorType,
589 base64Data: dataUrl,
590 position,
591 sizing: "contain",
592 };
593 } catch (e) {
594 console.error("Failed to convert element to image", e);
595 // Return null on failure - the element won't be included
596 return null;
597 }
598 }
599
600 // ============================================================================
601 // HELPERS
602 // ============================================================================
603
604 /**
605 * Extract plain text from nested nodes (recursive)
606 */
607 function extractTextFromNodes(nodes: PlateNode[]): string {
608 let text = "";
609 for (const node of nodes) {
610 if (node.text) {
611 text += node.text;
612 }
613 if (node.children) {
614 text += extractTextFromNodes(node.children as PlateNode[]);
615 }
616 }
617 return text;
618 }
619
620 /**
621 * Extract text only from direct leaf children of a node (not recursive into block children)
622 * This extracts text nodes at the current block level only
623 */
624 function extractTextFromLeafNodes(node: PlateNode): string {
625 let text = "";
626
627 // If the node itself has text, return it
628 if (node.text) {
629 return node.text as string;
630 }
631
632 // Only process immediate children that are text leaves
633 if (node.children && Array.isArray(node.children)) {
634 for (const child of node.children as PlateNode[]) {
635 // Only process if it's a text node (has .text property) or inline element
636 // Skip block-level children (they have their own type and will be processed recursively)
637 if (child.text) {
638 text += child.text;
639 } else if (!child.type || isInlineType(child.type as string)) {
640 // Inline elements - extract text from them
641 text += extractTextFromLeafNodes(child);
642 }
643 // Block-level children with their own type are skipped here
644 // They will be processed recursively by processElement
645 }
646 }
647
648 return text;
649 }
650
651 /**
652 * Check if a PlateJS type is inline (not a block element)
653 */
654 function isInlineType(type: string): boolean {
655 const inlineTypes = ["a", "link", "mention", "inline-code", "code_line"];
656 return inlineTypes.includes(type);
657 }
658
659 /**
660 * Find DOM element for a PlateNode
661 */
662 function findDOMElement(
663 node: PlateNode,
664 slideElement: Element,
665 ): Element | null {
666 if (!node.id) return null;
667 return slideElement.querySelector(`[data-block-id="${node.id}"]`);
668 }
669
670 /**
671 * Find element position and normalize it (0-100%)
672 */
673 function findElementPosition(
674 node: PlateNode,
675 slideElement: Element,
676 ): ElementPosition {
677 const domElement = findDOMElement(node, slideElement);
678 if (domElement) {
679 return getElementPositionFromDOM(domElement, slideElement);
680 }
681 return getDefaultPosition();
682 }
683
684 /**
685 * Get position of a DOM element relative to slide (Percentage)
686 * @param preserveAspectRatio - If true, includes aspect ratio for PPT shape preservation
687 * @param aspectRatioBase - Which dimension to preserve: 'width' recalculates height, 'height' recalculates width
688 */
689 function getElementPositionFromDOM(
690 element: Element,
691 slideElement: Element,
692 preserveAspectRatio = false,
693 aspectRatioBase: "width" | "height" = "width",
694 ): ElementPosition {
695 const slideRect = slideElement.getBoundingClientRect();
696 const elementRect = element.getBoundingClientRect();
697
698 return getRelativePercentRect({
699 child: elementRect,
700 parent: slideRect,
701 preserveAspectRatio,
702 aspectRatioBase,
703 });
704 }
705
706 /**
707 * Calculates normalized percentage positions
708 * @param preserveAspectRatio - If true, includes the original pixel aspect ratio for shape preservation
709 * @param aspectRatioBase - Which dimension to preserve: 'width' recalculates height, 'height' recalculates width
710 */
711 function getRelativePercentRect({
712 child,
713 parent,
714 preserveAspectRatio = false,
715 aspectRatioBase = "width",
716 }: {
717 child: DOMRect;
718 parent: DOMRect;
719 preserveAspectRatio?: boolean;
720 aspectRatioBase?: "width" | "height";
721 }): ElementPosition {
722 return {
723 x: ((child.left - parent.left) / parent.width) * 100,
724 y: ((child.top - parent.top) / parent.height) * 100,
725 width: (child.width / parent.width) * 100,
726 height: (child.height / parent.height) * 100,
727 // Original pixel aspect ratio for elements that need it preserved in PPT
728 aspectRatio:
729 preserveAspectRatio && child.height > 0
730 ? child.width / child.height
731 : undefined,
732 aspectRatioBase: preserveAspectRatio ? aspectRatioBase : undefined,
733 };
734 }
735
736 function getDefaultPosition(): ElementPosition {
737 return { x: 5, y: 5, width: 90, height: 90 };
738 }
739
740 /**
741 * Scan for native shape elements (data-shape attribute)
742 * These are elements like arrows that should be exported as native PPT shapes
743 * instead of images to prevent quality/overflow issues
744 */
745 async function scanShapeElements(
746 slideElement: Element,
747 ): Promise<ExportElement[]> {
748 const elements: ExportElement[] = [];
749
750 // Find all elements with data-shape
751 const shapeElements = Array.from(
752 slideElement.querySelectorAll("[data-shape]"),
753 ).sort((a, b) => {
754 const aOrder = getTimelineShapeOrder(a);
755 const bOrder = getTimelineShapeOrder(b);
756
757 if (aOrder === null || bOrder === null) {
758 return 0;
759 }
760
761 return aOrder - bOrder;
762 });
763
764 for (const shapeEl of shapeElements) {
765 const shapeElement = createShapeElement(
766 shapeEl as HTMLElement,
767 slideElement,
768 );
769 if (shapeElement) {
770 elements.push(shapeElement);
771 }
772 }
773
774 return elements;
775 }
776
777 function createShapeElement(
778 element: HTMLElement,
779 slideElement: Element,
780 ): ShapeExportElement | null {
781 try {
782 const shapeType = getNativeShapeType(element.getAttribute("data-shape"));
783 if (!shapeType) {
784 return null;
785 }
786
787 const orientation =
788 (element.getAttribute("data-orientation") as "horizontal" | "vertical") ||
789 "horizontal";
790
791 let fillColor = element.getAttribute("data-fill-color") ?? "#ffffff";
792 let textColor = element.getAttribute("data-text-color") ?? "";
793
794 // Resolve CSS variables if present
795 if (fillColor.includes("var(")) {
796 const prevColor = element.style.color;
797 element.style.color = fillColor;
798 fillColor = window.getComputedStyle(element).color;
799 element.style.color = prevColor;
800 }
801
802 if (textColor.includes("var(")) {
803 const prevColor = element.style.color;
804 element.style.color = textColor;
805 textColor = window.getComputedStyle(element).color;
806 element.style.color = prevColor;
807 }
808
809 const position = getElementPositionFromDOM(
810 element,
811 slideElement,
812 shapeType === "ellipse",
813 "height",
814 );
815 position.centerAspectRatio = shapeType === "ellipse" ? true : undefined;
816
817 const parentBlock = element.closest(".slate-blockWrapper");
818 if (
819 parentBlock &&
820 (shapeType === "arrow" ||
821 shapeType === "pill" ||
822 shapeType === "parallelogram")
823 ) {
824 const parentRect = parentBlock.getBoundingClientRect();
825 const slideRect = slideElement.getBoundingClientRect();
826
827 // Take 95% of the parent height to leave some breathing room
828 const targetSize = parentRect.height * 0.95;
829
830 if (orientation === "vertical") {
831 position.height = (targetSize / slideRect.height) * 100;
832 } else {
833 position.width = (targetSize / slideRect.width) * 100;
834 }
835 }
836
837 return {
838 type: "shape",
839 shapeType,
840 orientation,
841 fillColor: colorToHex(fillColor),
842 textContent: element.getAttribute("data-shape-text") ?? undefined,
843 textColor: textColor ? colorToHex(textColor) : undefined,
844 position,
845 };
846 } catch (error) {
847 console.error("Failed to create shape element:", error);
848 return null;
849 }
850 }
851
852 function getNativeShapeType(shapeType: string | null): NativeShapeType | null {
853 if (
854 shapeType === "arrow" ||
855 shapeType === "pill" ||
856 shapeType === "parallelogram" ||
857 shapeType === "rect" ||
858 shapeType === "ellipse"
859 ) {
860 return shapeType;
861 }
862
863 return null;
864 }
865
866 function getTimelineShapeOrder(element: Element): number | null {
867 const role = element.getAttribute("data-shape-role");
868
869 if (role === "timeline-rail") {
870 return 0;
871 }
872
873 if (role === "timeline-connector") {
874 return 1;
875 }
876
877 if (role === "timeline-marker") {
878 return 2;
879 }
880
881 return null;
882 }
883
883 lines TYPESCRIPT