import { Group, Rect, Text } from "react-konva"; import { renderMarkdownTextRuns } from "@/components/slide-editor/text/markdown-text"; import type { TextRun } from "@/components/slide-editor/types"; import { layoutRichText } from "@/components/slide-editor/text/template-v2-text"; import { effectiveLineHeight } from "@/components/slide-editor/text/text-line-height"; import { readableTableTextColor } from "@/components/slide-editor/tables/table-colors"; import { colorWithOpacity } from "@/components/slide-editor/model/render-style"; type UnknownRecord = Record; type RawElement = UnknownRecord; type RenderTextFont = { family: string; size: number; color: string; bold: boolean; italic: boolean; underline: boolean; lineHeight: number; letterSpacing: number; opacity: number; }; export function TemplateV2TableElement({ element, width, height, interactive, selectedCell, onCellSelect, onCellEdit, }: { element: RawElement; width: number; height: number; interactive: boolean; selectedCell?: { rowIndex: number; colIndex: number } | null; onCellSelect?: (rowIndex: number, colIndex: number) => void; onCellEdit?: (rowIndex: number, colIndex: number) => void; }) { const rows = rawTableRows(element); const rowCount = Math.max(1, rows.length); const colCount = Math.max(1, ...rows.map((row) => row.length)); const cellW = width / colCount; const cellH = height / rowCount; const font = rawFont(element); return ( {rows.map((row, rowIndex) => Array.from({ length: colCount }, (_, colIndex) => { const cell = asRecord(row[colIndex]) ?? {}; const firstRun = asRecord(readArray(cell.runs)[0]) ?? {}; const cellFont = fontFromRecord( asRecord(cell.font) ?? asRecord(firstRun.font), font, ); const fill = fillColor(cell.fill ?? cell.color); const runs = readableTableCellRuns( rawTableCellRuns(cell, cellFont), fill, rowIndex === 0, ); const renderRuns = rowIndex === 0 ? runs.map((run) => ({ ...run, font: { ...run.font, bold: true }, })) : runs; const text = tableCellTextContent(runs); const fontSize = cellFont.size; const textWidth = Math.max(1, cellW - 12); const cellLineHeight = effectiveLineHeight({ text, width: textWidth, fontSize, lineHeight: cellFont.lineHeight, fallback: 1.15, wrap: "word", }); return ( { if (!interactive) return; event.cancelBubble = true; onCellSelect?.(rowIndex, colIndex); }} onTap={(event) => { if (!interactive) return; event.cancelBubble = true; onCellSelect?.(rowIndex, colIndex); }} onDblClick={(event) => { if (!interactive) return; event.cancelBubble = true; onCellSelect?.(rowIndex, colIndex); onCellEdit?.(rowIndex, colIndex); }} onDblTap={(event) => { if (!interactive) return; event.cancelBubble = true; onCellSelect?.(rowIndex, colIndex); onCellEdit?.(rowIndex, colIndex); }} > ); }), )} ); } function TableCellText({ x, y, width, height, runs, font, align, verticalAlign, lineHeight, }: { x: number; y: number; width: number; height: number; runs: Array<{ text: string; font: RenderTextFont }>; font: RenderTextFont; align: string; verticalAlign: string; lineHeight: number; }) { const baseFont = { ...font, lineHeight }; const renderRuns = runs.map((run) => ({ ...run, font: { ...run.font, lineHeight: run.font.lineHeight || lineHeight, }, })); const { tokens } = layoutRichText( renderRuns, width, baseFont, align, verticalAlign, height, "word", ); return ( {tokens.map((token, index) => ( ))} ); } function SelectedTableCellOutline({ cellH, cellW, colCount, rowCount, selectedCell, }: { cellH?: number; cellW?: number; colCount: number; rowCount?: number; selectedCell?: { rowIndex: number; colIndex: number } | null; }) { if (!selectedCell) return null; if (selectedCell.colIndex < 0 || selectedCell.colIndex >= colCount) return null; if (cellW == null || cellH == null || rowCount == null) return null; if (selectedCell.rowIndex < 0 || selectedCell.rowIndex >= rowCount) return null; return ( ); } function rawTableRows(element: RawElement) { const columns = readArray(element.columns); const rows = readArray(element.rows); return [columns, ...rows].filter((row) => Array.isArray(row)) as unknown[][]; } function rawTableCellRuns(cell: unknown, fallbackFont: RenderTextFont) { const sourceRuns = rawTableCellSourceRuns(cell, fallbackFont); return renderMarkdownTextRuns(sourceRuns).map((run) => ({ text: run.text, font: fontFromRecord(asRecord(run.font), fallbackFont), })); } function rawTableCellSourceRuns( cell: unknown, fallbackFont: RenderTextFont, ): TextRun[] { if (typeof cell === "string" || typeof cell === "number") { return [{ text: String(cell), font: fontToTextRunFont(fallbackFont) }]; } const record = asRecord(cell); if (!record) return [{ text: "", font: fontToTextRunFont(fallbackFont) }]; const cellFont = fontFromRecord(asRecord(record.font), fallbackFont); const runs = readArray(record.runs); if (runs.length > 0) { return runs .map((run) => { const runRecord = asRecord(run) ?? {}; return { text: readString(runRecord.text) ?? "", font: fontToTextRunFont( fontFromRecord(asRecord(runRecord.font), cellFont), ), }; }) .filter((run) => run.text.length > 0); } const textRecord = asRecord(record.text); return [ { text: readString(textRecord?.text) ?? readString(record.text) ?? "", font: fontToTextRunFont(cellFont), }, ]; } function fontToTextRunFont(font: RenderTextFont): TextRun["font"] { return { family: font.family, size: font.size, color: font.color, bold: font.bold, italic: font.italic, underline: font.underline, line_height: font.lineHeight, letter_spacing: font.letterSpacing, opacity: font.opacity, }; } function tableCellTextContent(runs: Array<{ text: string }>) { return runs.map((run) => run.text).join(""); } function readableTableCellRuns( runs: Array<{ text: string; font: RenderTextFont }>, fill: string | undefined, isHeader: boolean, ) { if (isHeader) return runs; return runs.map((run) => ({ ...run, font: { ...run.font, color: readableTableTextColor(run.font.color, fill), }, })); } function rawFont(element: RawElement) { const font = asRecord(element.font) ?? {}; return fontFromRecord(font, { family: "Arial", size: 18, color: "#111827", bold: false, italic: false, underline: false, lineHeight: 1.15, letterSpacing: 0, opacity: 1, }); } function fontFromRecord( font: UnknownRecord | null, fallback: RenderTextFont, ): RenderTextFont { return { family: readString(font?.family) ?? fallback.family, size: readNumber(font?.size) ?? fallback.size, color: readString(font?.color) ?? fallback.color, bold: readBoolean(font?.bold) ?? fallback.bold, italic: readBoolean(font?.italic) ?? fallback.italic, underline: readBoolean(font?.underline) ?? (readString(font?.text_decoration) === "underline" || readString(font?.textDecoration) === "underline" ? true : fallback.underline), lineHeight: readNumber(font?.line_height) ?? readNumber(font?.lineHeight) ?? fallback.lineHeight, letterSpacing: readNumber(font?.letter_spacing) ?? readNumber(font?.letterSpacing) ?? fallback.letterSpacing, opacity: readNumber(font?.opacity) ?? fallback.opacity, }; } function fillColor(fill: unknown) { const value = asRecord(fill); return withHash(readString(value?.color)); } function strokeColor(stroke: unknown) { const value = asRecord(stroke); return withHash(readString(value?.color)); } function strokeWidth(stroke: unknown) { const value = asRecord(stroke); return readNumber(value?.width) ?? 0; } function readArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function asRecord(value: unknown): UnknownRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as UnknownRecord) : null; } function readString(value: unknown): string | null { return typeof value === "string" ? value : null; } function readNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function readBoolean(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } function withHash(value: string | null | undefined) { if (!value) return undefined; return value.startsWith("#") || value.startsWith("rgb") ? value : `#${value}`; }