404 lines
11 KiB
TypeScript
404 lines
11 KiB
TypeScript
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<string, any>;
|
|
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 (
|
|
<Group listening={interactive}>
|
|
{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 (
|
|
<Group
|
|
key={`${rowIndex}-${colIndex}`}
|
|
x={colIndex * cellW}
|
|
y={rowIndex * cellH}
|
|
onClick={(event) => {
|
|
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);
|
|
}}
|
|
>
|
|
<Rect
|
|
width={cellW}
|
|
height={cellH}
|
|
fill={fill ?? "rgba(0,0,0,0)"}
|
|
stroke={strokeColor(cell.stroke) ?? "#D0D5DD"}
|
|
strokeWidth={strokeWidth(cell.stroke) || 1}
|
|
/>
|
|
<TableCellText
|
|
x={6}
|
|
y={4}
|
|
width={textWidth}
|
|
height={Math.max(1, cellH - 8)}
|
|
runs={renderRuns}
|
|
font={rowIndex === 0 ? { ...cellFont, bold: true } : cellFont}
|
|
align={readString(cell.alignment) ?? "left"}
|
|
verticalAlign="middle"
|
|
lineHeight={cellLineHeight}
|
|
/>
|
|
</Group>
|
|
);
|
|
}),
|
|
)}
|
|
<SelectedTableCellOutline
|
|
cellH={cellH}
|
|
cellW={cellW}
|
|
colCount={colCount}
|
|
rowCount={rowCount}
|
|
selectedCell={selectedCell}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Group x={x} y={y} listening={false}>
|
|
{tokens.map((token, index) => (
|
|
<Text
|
|
key={index}
|
|
x={token.x}
|
|
y={token.y}
|
|
width={token.width}
|
|
height={token.height}
|
|
text={token.text}
|
|
fill={colorWithOpacity(withHash(token.font.color), token.font.opacity)}
|
|
fontFamily={`${token.font.family}, Inter`}
|
|
fontSize={token.font.size}
|
|
fontStyle={`${token.font.bold ? "bold" : "normal"} ${token.font.italic ? "italic" : ""
|
|
}`}
|
|
textDecoration={token.font.underline ? "underline" : ""}
|
|
lineHeight={token.font.lineHeight}
|
|
letterSpacing={token.font.letterSpacing}
|
|
wrap="none"
|
|
listening={false}
|
|
/>
|
|
))}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Rect
|
|
x={selectedCell.colIndex * cellW}
|
|
y={selectedCell.rowIndex * cellH}
|
|
width={cellW}
|
|
height={cellH}
|
|
fill="rgba(0,0,0,0)"
|
|
stroke="#7C51F8"
|
|
strokeWidth={2}
|
|
listening={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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}`;
|
|
}
|