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

251 lines
6.1 KiB
TypeScript

import type {
BorderRadius,
ChartElement,
Fill,
Font,
ImageElement,
Position,
RectangleElement,
Size,
SlideElement,
Stroke,
TableCell,
TableElement,
TextElement,
TextListElement,
TextRun,
} from "@/components/slide-editor/types";
import {
applyTextRunFontToSelection,
replaceTextRunsContent,
textRunsContent,
type TextSelectionRange,
} from "@/components/slide-editor/text/text-runs";
export type ElementType = SlideElement["type"];
export type ElementBox = { x: number; y: number; w: number; h: number };
export type ResolvedFont = {
family: string;
size: number;
color: string;
bold?: boolean | null;
italic?: boolean | null;
underline?: boolean | null;
lineHeight?: number | null;
letterSpacing?: number | null;
ellipsis?: boolean | null;
opacity?: number | null;
};
export const DEFAULT_FONT_FAMILY = "Arial";
export const DEFAULT_TEXT_COLOR = "1A2B45";
export const DEFAULT_TEXT_SIZE = 18;
export function elementBox(element: Pick<SlideElement, "position" | "size">) {
return {
x: element.position?.x ?? 0,
y: element.position?.y ?? 0,
w: element.size?.width ?? 1,
h: element.size?.height ?? 1,
};
}
export function boxToPositionSize(box: ElementBox): {
position: Position;
size: Size;
} {
return {
position: { x: box.x, y: box.y },
size: { width: box.w, height: box.h },
};
}
export function resizeElement<T extends SlideElement>(
element: T,
box: Partial<ElementBox>,
): T {
const current = elementBox(element);
return {
...element,
...boxToPositionSize({
x: box.x ?? current.x,
y: box.y ?? current.y,
w: box.w ?? current.w,
h: box.h ?? current.h,
}),
} as T;
}
export function moveElement<T extends SlideElement>(
element: T,
dx: number,
dy: number,
): T {
const box = elementBox(element);
return resizeElement(element, {
x: box.x + dx,
y: box.y + dy,
});
}
export function textContent(element: TextElement): string {
return textRunsContent(element.runs);
}
export function textRun(text: string, font?: Font | null): TextRun {
return font ? { text, font } : { text };
}
export function setTextContent(element: TextElement, text: string): TextElement {
return {
...element,
runs: replaceTextRunsContent(element.runs, text, element.font),
};
}
export function mergeFontForTextSelection<T extends TextElement>(
element: T,
range: TextSelectionRange | null | undefined,
font: Partial<Font>,
): T {
return applyTextRunFontToSelection(element, range, font) as T;
}
export function elementFont(element: {
font?: Font | null;
}): ResolvedFont {
return {
family: element.font?.family ?? DEFAULT_FONT_FAMILY,
size: element.font?.size ?? DEFAULT_TEXT_SIZE,
color: element.font?.color ?? DEFAULT_TEXT_COLOR,
bold: element.font?.bold ?? null,
italic: element.font?.italic ?? null,
underline: element.font?.underline ?? null,
lineHeight: element.font?.line_height ?? null,
letterSpacing: element.font?.letter_spacing ?? null,
ellipsis: element.font?.ellipsis ?? null,
opacity: element.font?.opacity ?? null,
};
}
export function mergeFont<T extends { font?: Font | null }>(
element: T,
font: Partial<Font>,
): T {
const nextFont = { ...(element.font ?? {}), ...font };
return {
...element,
font: nextFont,
...("runs" in element && Array.isArray(element.runs)
? {
runs: element.runs.map((run) => ({
...run,
font: { ...(run.font ?? element.font ?? {}), ...font },
})),
}
: {}),
};
}
export function textListStrings(element: TextListElement): string[] {
return element.items.map(textListItemText);
}
export function setTextListStrings(
element: TextListElement,
items: string[],
): TextListElement {
return {
...element,
items: items.map((text) => [textRun(text)]),
};
}
export function textListItemText(item: TextListElement["items"][number]): string {
return textRunsContent(item);
}
export function fillColor(fill: Fill | null | undefined, fallback = "FFFFFF") {
return fill?.color ?? fallback;
}
export function strokeColor(
stroke: Stroke | null | undefined,
fallback = "0B1F3A",
) {
return stroke?.color ?? fallback;
}
export function strokeWidth(stroke: Stroke | null | undefined) {
return stroke?.width ?? 0;
}
export function uniformBorderRadius(value: number): BorderRadius {
return { tl: value, tr: value, bl: value, br: value };
}
export function averageBorderRadius(
radius: BorderRadius | null | undefined,
): number {
if (!radius) return 0;
return (radius.tl + radius.tr + radius.bl + radius.br) / 4;
}
export function tableRowsAsStrings(element: TableElement): string[][] {
return [
element.columns.map(tableCellText),
...element.rows.map((row) => row.map(tableCellText)),
];
}
export function tableCellText(cell: TableCell): string {
return textRunsContent(cell.runs);
}
export function setTableCellText(cell: TableCell, text: string): TableCell {
return {
...cell,
runs: text ? replaceTextRunsContent(cell.runs, text, cell.font) : [],
};
}
export function setTableRowsFromStrings(
element: TableElement,
rows: string[][],
): TableElement {
const [header = [], ...body] = rows;
const existingRows = tableRowsAsStrings(element);
const cellFor = (text: string, rowIndex: number, colIndex: number): TableCell => ({
...setTableCellText(
(rowIndex === 0
? element.columns[colIndex]
: element.rows[rowIndex - 1]?.[colIndex]) ?? { runs: [] },
text,
),
});
return {
...element,
columns: header.map((text, colIndex) =>
cellFor(text || existingRows[0]?.[colIndex] || "", 0, colIndex),
),
rows: body.map((row, rowIndex) =>
row.map((text, colIndex) => cellFor(text, rowIndex + 1, colIndex)),
),
};
}
export function chartColor(element: ChartElement, fallback = "D4A24C") {
return element.color ?? fallback;
}
export function hasImageData(element: ImageElement) {
return Boolean(element.data);
}
export function isShapeElement(
element: SlideElement,
): element is RectangleElement | Extract<SlideElement, { type: "ellipse" }> {
return element.type === "rectangle" || element.type === "ellipse";
}