Files
2026-07-13 13:08:41 +08:00

338 lines
10 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
import type {
TableCellSelection,
TableSlideElement,
TextSlideElement,
} from "@/components/slide-editor/state/state";
import { withHash } from "@/components/slide-editor/utils/color";
import {
elementBox,
elementFont,
tableRowsAsStrings,
} from "@/components/slide-editor/model/element-model";
import type { Font, TableCell, TextRun } from "@/components/slide-editor/types";
import { effectiveLineHeight } from "@/components/slide-editor/text/text-line-height";
import {
textRunsContent,
type TextSelectionRange,
} from "@/components/slide-editor/text/text-runs";
import type { TemplateFontOption } from "@/components/slide-editor/text/google-fonts";
import { inlineStyles } from "@/components/slide-editor/toolbar/inlineStyles";
import { TextToolbar } from "@/components/slide-editor/text/TextToolbar";
import { TiptapInlineTextEditor } from "@/components/slide-editor/text/TiptapInlineTextEditor";
import { readableTableTextColor } from "@/components/slide-editor/tables/table-colors";
const EMPTY_TEMPLATE_FONTS: TemplateFontOption[] = [];
const TEMPLATE_V2_PX_PER_IN = 128;
export function TableInlineEditor({
element,
index,
scale,
selectedCell,
templateFonts = EMPTY_TEMPLATE_FONTS,
onChange,
onClose,
}: {
element: TableSlideElement;
index: number;
scale: number;
selectedCell: TableCellSelection | null;
templateFonts?: TemplateFontOption[];
onChange: (index: number, element: TableSlideElement) => void;
onClose: () => void;
}) {
const editorRef = useRef<HTMLDivElement | null>(null);
const [textSelectionRange, setTextSelectionRange] =
useState<TextSelectionRange | null>(null);
const box = elementBox(element);
const rows = [element.columns, ...element.rows];
const stringRows = tableRowsAsStrings(element);
const rowCount = Math.max(1, rows.length);
const columnCount = Math.max(1, ...stringRows.map((row) => row.length));
const rowIndex = Math.min(
rowCount - 1,
Math.max(0, selectedCell?.rowIndex ?? 0),
);
const colIndex = Math.min(
columnCount - 1,
Math.max(0, selectedCell?.colIndex ?? 0),
);
const tableFont = elementFont(element);
const isHeader = rowIndex === 0;
const cell = rows[rowIndex]?.[colIndex] ?? { runs: [] };
const cellBackground = tableCellBackground(cell);
const font = readableTableCellFont(
tableCellFont(cell, tableFont, isHeader),
cellBackground,
);
const tableWidth = box.w * scale;
const tableHeight = box.h * scale;
const cellWidth = tableWidth / columnCount;
const cellHeight = tableHeight / rowCount;
const cellLeft = box.x * scale + colIndex * cellWidth;
const cellTop = box.y * scale + rowIndex * cellHeight;
const paddingX = Math.max(4, 0.08 * scale);
const paddingY = Math.max(3, 0.04 * scale);
const textElement: TextSlideElement = {
type: "text",
position: { x: cellLeft / scale, y: cellTop / scale },
size: { width: cellWidth / scale, height: cellHeight / scale },
font,
alignment: {
horizontal: cell.alignment ?? "left",
vertical: "middle",
},
runs: normalizedTextRuns(cell, font).map((run) =>
readableTableCellRun(run, font, cellBackground),
),
};
const textFont = elementFont(textElement);
const cellText = textRunsContent(textElement.runs);
const textFontSizePx = textFont.size * (scale / TEMPLATE_V2_PX_PER_IN);
const editorTextWidth = Math.max(1, cellWidth - paddingX * 2);
const editorLineHeight = effectiveLineHeight({
text: cellText,
width: editorTextWidth,
fontSize: textFontSizePx,
lineHeight: textFont.lineHeight,
fallback: 1.12,
wrap: "word",
});
const closeAfterBlur = useCallback(() => {
window.setTimeout(() => {
const active = document.activeElement;
if (active && editorRef.current?.contains(active)) return;
if (active?.closest?.("[data-inline-edit-ignore='true']")) return;
onClose();
}, 0);
}, [onClose]);
const updateCell = (nextCell: TableCell) => {
if (isHeader) {
onChange(index, {
...element,
columns: Array.from({ length: columnCount }, (_, nextColIndex) =>
nextColIndex === colIndex
? nextCell
: element.columns[nextColIndex] ?? { runs: [] },
),
});
return;
}
onChange(index, {
...element,
rows: element.rows.map((row, nextRowIndex) =>
nextRowIndex === rowIndex - 1
? Array.from({ length: columnCount }, (_, nextColIndex) =>
nextColIndex === colIndex
? nextCell
: row[nextColIndex] ?? { runs: [] },
)
: row,
),
});
};
const updateCellTextElement = (nextTextElement: TextSlideElement) => {
updateCell({
...cell,
font: nextTextElement.font ?? cell.font,
alignment: nextTextElement.alignment?.horizontal ?? cell.alignment,
runs: normalizedTextRuns(nextTextElement, nextTextElement.font ?? font),
});
};
const updateCellRuns = (runs: TextRun[]) => {
updateCell({
...cell,
runs: runs.length > 0 ? runs : [{ text: " ", font }],
});
};
return (
<div
ref={editorRef}
data-inline-edit-ignore="true"
onBlur={closeAfterBlur}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
style={{
position: "absolute",
zIndex: 30,
inset: 0,
pointerEvents: "none",
}}
>
<div style={{ pointerEvents: "auto" }}>
<TextToolbar
element={textElement}
index={index}
scale={scale}
selectionRange={textSelectionRange}
templateFonts={templateFonts}
onChange={(_index, nextTextElement) =>
updateCellTextElement(nextTextElement)
}
/>
</div>
<TiptapInlineTextEditor
baseFont={textElement.font ?? font}
runs={textElement.runs}
onBlurOutside={closeAfterBlur}
onCommitShortcut={onClose}
onEscape={onClose}
onRunsChange={updateCellRuns}
onSelectionChange={setTextSelectionRange}
contentClassName="template-v2-table-cell-editor-content"
contentStyle={{ width: "100%" }}
editorStyle={{
...inlineStyles.textEditor,
...cellEditorStyle,
left: cellLeft,
top: cellTop,
width: cellWidth,
height: cellHeight,
pointerEvents: "auto",
display: "flex",
alignItems: "center",
padding: `${paddingY}px ${paddingX}px`,
background: cellBackground,
color: withHash(textFont.color),
caretColor: withHash(textFont.color),
fontFamily: `${textFont.family}, Helvetica, sans-serif`,
fontSize: textFontSizePx,
fontStyle: textFont.italic ? "italic" : "normal",
fontWeight: textFont.bold ? 700 : 400,
textDecoration: textFont.underline ? "underline" : "none",
lineHeight: editorLineHeight,
textAlign: textElement.alignment?.horizontal ?? "left",
letterSpacing:
(textFont.letterSpacing ?? 0) * (scale / TEMPLATE_V2_PX_PER_IN),
}}
/>
</div>
);
}
export function tableDraftFromElement(element: TableSlideElement) {
return tableRowsAsStrings(element)
.map((row) => row.map(formatTableCell).join(", "))
.join("\n");
}
export function tableRowsFromDraft(draft: string) {
return draft
.split(/\r?\n/)
.map(parseTableRow)
.filter((row) => row.some(Boolean))
.map((row) => row.slice(0, 6))
.slice(0, 8);
}
function tableCellFont(
cell: TableCell,
tableFont: ReturnType<typeof elementFont>,
isHeader: boolean,
): Font {
const cellFont = cell.font ?? {};
return {
family: cellFont.family ?? tableFont.family,
size: cellFont.size ?? tableFont.size,
color: cellFont.color ?? tableFont.color,
bold: cellFont.bold ?? tableFont.bold ?? isHeader,
italic: cellFont.italic ?? tableFont.italic,
line_height: cellFont.line_height ?? tableFont.lineHeight ?? 1.12,
letter_spacing: cellFont.letter_spacing ?? tableFont.letterSpacing,
ellipsis: cellFont.ellipsis ?? tableFont.ellipsis,
opacity: cellFont.opacity ?? tableFont.opacity,
};
}
function readableTableCellFont(font: Font, background: string): Font {
return {
...font,
color: readableTableTextColor(font.color, background),
};
}
function readableTableCellRun(
run: TextRun,
fallbackFont: Font,
background: string,
): TextRun {
const runFont = run.font ?? fallbackFont;
return {
...run,
font: {
...runFont,
color: readableTableTextColor(
runFont.color ?? fallbackFont.color,
background,
),
},
};
}
function tableCellBackground(cell: TableCell) {
const fill =
cell.color?.color ??
(cell as TableCell & { fill?: { color?: string | null } | null }).fill
?.color;
return fill ? withHash(fill) : "transparent";
}
function normalizedTextRuns(
source: Pick<TextSlideElement, "runs"> | TableCell,
font: Font | null | undefined,
) {
const runs = source.runs.length > 0 ? source.runs : [{ text: " " }];
return runs.map((run) => ({
...run,
text: run.text || " ",
font: run.font ?? font ?? undefined,
}));
}
function formatTableCell(cell: string) {
if (!/[",\n\r]/.test(cell)) return cell;
return `"${cell.replace(/"/g, '""')}"`;
}
function parseTableRow(line: string) {
const cells: string[] = [];
let current = "";
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (char === '"') {
if (quoted && line[index + 1] === '"') {
current += '"';
index += 1;
} else {
quoted = !quoted;
}
continue;
}
if (char === "," && !quoted) {
cells.push(current.trim());
current = "";
continue;
}
current += char;
}
cells.push(current.trim());
return cells;
}
const cellEditorStyle = {
zIndex: 31,
border: "1px solid #7C51F8",
outline: "none",
resize: "none",
margin: 0,
backgroundClip: "padding-box",
} as const;