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

721 lines
22 KiB
TypeScript

import {
displayText,
rawFont,
rawTextContent,
} from "@/components/slide-editor/text/template-v2-text";
import { layoutWrappedFlexChildren } from "@/components/slide-editor/layout/wrappedFlexLayout";
export type FlowLayoutElement = Record<string, any>;
export type FlowDirection = "row" | "column";
export type FlowLayoutKind = "flex" | "grid";
export type FlowLayoutBox = {
x: number;
y: number;
width: number;
height: number;
};
type Size = {
width: number;
height: number;
};
type Padding = {
top: number;
right: number;
bottom: number;
left: number;
};
export type FlowLaidOutChild = {
child: FlowLayoutElement;
index: number;
box: FlowLayoutBox | null;
layoutManaged: boolean;
};
export type FlowLayoutDeps = {
elementBox: (element: FlowLayoutElement) => FlowLayoutBox;
elementSize: (element: FlowLayoutElement, fallback?: Size) => Size;
isManualPositioned: (element: FlowLayoutElement) => boolean;
};
export type GridPlacement = {
col: number;
row: number;
columnSpan: number;
rowSpan: number;
};
const TEXT_AVERAGE_CHAR_EM = 0.5;
const DECORATIVE_LINE_LENGTH = 80;
const DECORATIVE_LINE_THICKNESS = 4;
export function flowLayoutKind(
element: FlowLayoutElement,
): FlowLayoutKind | null {
const type = readString(element.type);
if (type === "flex" || type === "list-view") return "flex";
if (type === "grid" || type === "grid-view") return "grid";
return null;
}
export function isFlowLayoutElement(element: FlowLayoutElement) {
return flowLayoutKind(element) != null;
}
export function layoutFlowChildren(
parent: FlowLayoutElement,
children: FlowLayoutElement[],
parentBox: FlowLayoutBox,
deps: FlowLayoutDeps,
): FlowLaidOutChild[] {
const kind = flowLayoutKind(parent);
if (kind === "grid") {
return layoutGridChildren(parent, children, parentBox, deps);
}
if (kind === "flex") {
return layoutFlexChildren(parent, children, parentBox, deps);
}
return children.map((child, index) => ({
child,
index,
box: null,
layoutManaged: false,
}));
}
export function intrinsicFlowSize(
parent: FlowLayoutElement,
children: FlowLayoutElement[],
deps: Pick<FlowLayoutDeps, "elementBox" | "elementSize" | "isManualPositioned">,
): Size {
const kind = flowLayoutKind(parent);
if (kind === "flex") {
return intrinsicFlexSize(parent, children, deps);
}
if (kind === "grid") {
return intrinsicGridSize(parent, children, deps);
}
return { width: 1, height: 1 };
}
export function flexBasis(
child: FlowLayoutElement,
direction: FlowDirection,
crossSize: number,
deps: FlowLayoutDeps,
) {
const dimension = direction === "row" ? "width" : "height";
const explicit = layoutNumber(child, "basis") ?? readOptionalSize(child.size)?.[dimension];
if (explicit != null && explicit > 0) {
return clampLayoutSize(explicit, child, dimension);
}
if (isFramelessDecorativeShape(child)) {
return DECORATIVE_LINE_THICKNESS;
}
if (readString(child.type) === "text") {
return clampLayoutSize(
intrinsicTextMainSize(child, direction, crossSize),
child,
dimension,
);
}
const inferred = deps.elementSize(child);
const size = direction === "row" ? inferred.width : inferred.height;
return size > 1 ? clampLayoutSize(size, child, dimension) : 0;
}
export function childCrossSize(
child: FlowLayoutElement,
direction: FlowDirection,
crossSize: number,
alignItems: string,
deps: FlowLayoutDeps,
) {
const dimension = direction === "row" ? "height" : "width";
const alignSelf =
readString(child.layout?.align_self) ?? readString(child.layout?.alignSelf);
if (isFramelessDecorativeShape(child)) {
return clampLayoutSize(
Math.min(crossSize, DECORATIVE_LINE_LENGTH),
child,
dimension,
);
}
if (alignItems === "stretch" && alignSelf == null) {
return crossSize;
}
const explicit = readOptionalSize(child.size)?.[dimension];
const inferred = deps.elementSize(child, {
width: direction === "row" ? 1 : crossSize,
height: direction === "row" ? crossSize : 1,
})[dimension];
return clampLayoutSize(explicit ?? inferred ?? crossSize, child, dimension, crossSize);
}
export function placeGridChildren(
children: FlowLayoutElement[],
columns: number,
declaredRows: number | null,
) {
const occupied = new Set<string>();
const placements: GridPlacement[] = [];
let rowLimit = Math.max(1, declaredRows ?? Math.ceil(children.length / columns));
children.forEach((child) => {
const columnSpan = Math.min(
columns,
Math.max(1, Math.floor(layoutNumber(child, "columnSpan", "column_span") ?? 1)),
);
const rowSpan = Math.max(
1,
Math.floor(layoutNumber(child, "rowSpan", "row_span") ?? 1),
);
let placedRow = 0;
let placedCol = 0;
while (true) {
let placed = false;
for (let row = 0; row < rowLimit && !placed; row += 1) {
for (let col = 0; col <= columns - columnSpan; col += 1) {
if (gridAreaOpen(occupied, row, col, rowSpan, columnSpan)) {
placed = true;
placedRow = row;
placedCol = col;
break;
}
}
}
if (placed) break;
rowLimit += 1;
}
markGridArea(occupied, placedRow, placedCol, rowSpan, columnSpan);
placements.push({
col: placedCol,
row: placedRow,
columnSpan,
rowSpan,
});
});
return placements;
}
function layoutFlexChildren(
parent: FlowLayoutElement,
children: FlowLayoutElement[],
parentBox: FlowLayoutBox,
deps: FlowLayoutDeps,
) {
if (children.length === 0) return [];
const padding = readPadding(parent.padding);
const direction = readString(parent.direction) === "row" ? "row" : "column";
const isColumn = direction === "column";
const mainGap =
(isColumn
? readNumber(parent.row_gap) ?? readNumber(parent.rowGap)
: readNumber(parent.column_gap) ?? readNumber(parent.columnGap)) ??
readNumber(parent.gap) ??
0;
const align =
readString(parent.align_items) ?? readString(parent.alignItems) ?? "stretch";
const justify =
readString(parent.justify_content) ??
readString(parent.justifyContent) ??
"flex-start";
const availableW = Math.max(1, parentBox.width - padding.left - padding.right);
const availableH = Math.max(1, parentBox.height - padding.top - padding.bottom);
const availableMain = isColumn ? availableH : availableW;
const availableCross = isColumn ? availableW : availableH;
if (parent.wrap === true) {
const crossGap =
(isColumn
? readNumber(parent.column_gap) ?? readNumber(parent.columnGap)
: readNumber(parent.row_gap) ?? readNumber(parent.rowGap)) ??
readNumber(parent.gap) ??
0;
return layoutWrappedFlexChildren({
align,
alignSelf: (child) =>
readString(child.layout?.align_self) ??
readString(child.layout?.alignSelf),
alignmentOffset,
availableCross,
availableMain,
childCrossSize: (child, childDirection, crossSize, alignment) =>
childCrossSize(child, childDirection, crossSize, alignment, deps),
children,
clampLayoutSize,
crossGap,
direction,
elementBox: deps.elementBox,
flexBasis: (child, childDirection, crossSize) =>
flexBasis(child, childDirection, crossSize, deps),
isManualPositioned: deps.isManualPositioned,
justify,
layoutNumber,
mainGap,
padding,
});
}
const bases = children.map((child) =>
deps.isManualPositioned(child)
? isColumn
? deps.elementBox(child).height
: deps.elementBox(child).width
: flexBasis(child, direction, availableCross, deps),
);
const gapTotal = mainGap * Math.max(0, children.length - 1);
const freeBeforeFlex =
Math.max(1, availableMain - gapTotal) -
bases.reduce((sum, size) => sum + Math.max(0, size), 0);
let mainSizes = bases.map((basis) => Math.max(0, basis));
const grows = children.map((child, index) =>
deps.isManualPositioned(child)
? 0
: layoutNumber(child, "grow") ?? (bases[index] > 0 ? 0 : 1),
);
const growTotal = grows.reduce((sum, grow) => sum + grow, 0);
if (freeBeforeFlex > 0 && growTotal > 0) {
mainSizes = mainSizes.map(
(size, index) => size + (freeBeforeFlex * grows[index]) / growTotal,
);
} else if (freeBeforeFlex > 0 && justify === "stretch") {
const flexibleCount = Math.max(
1,
children.filter((child) => !deps.isManualPositioned(child)).length,
);
mainSizes = mainSizes.map((size, index) =>
deps.isManualPositioned(children[index])
? size
: size + freeBeforeFlex / flexibleCount,
);
} else if (freeBeforeFlex < 0) {
const shrinks = children.map((child) =>
deps.isManualPositioned(child) ? 0 : layoutNumber(child, "shrink") ?? 1,
);
const scaledShrinks = shrinks.map((shrink, index) => shrink * mainSizes[index]);
const shrinkTotal = scaledShrinks.reduce((sum, shrink) => sum + shrink, 0);
if (shrinkTotal > 0) {
mainSizes = mainSizes.map((size, index) =>
Math.max(1, size + (freeBeforeFlex * scaledShrinks[index]) / shrinkTotal),
);
}
}
const usedMain =
mainSizes.reduce((sum, size) => sum + size, 0) +
mainGap * Math.max(0, children.length - 1);
let cursor = alignmentOffset(justify, availableMain, usedMain);
return children.map((child, index) => {
const raw = deps.elementBox(child);
if (deps.isManualPositioned(child)) {
cursor += (isColumn ? raw.height : raw.width) + mainGap;
return { child, index, box: raw, layoutManaged: false };
}
const main = clampLayoutSize(mainSizes[index], child, isColumn ? "height" : "width");
const cross = childCrossSize(child, direction, availableCross, align, deps);
const alignSelf =
readString(child.layout?.align_self) ?? readString(child.layout?.alignSelf);
const crossOffset = alignmentOffset(alignSelf ?? align, availableCross, cross);
const box = isColumn
? {
x: padding.left + crossOffset,
y: padding.top + cursor,
width: cross,
height: main,
}
: {
x: padding.left + cursor,
y: padding.top + crossOffset,
width: main,
height: cross,
};
cursor += main + mainGap;
return { child, index, box, layoutManaged: true };
});
}
function layoutGridChildren(
parent: FlowLayoutElement,
children: FlowLayoutElement[],
parentBox: FlowLayoutBox,
deps: FlowLayoutDeps,
) {
const padding = readPadding(parent.padding);
const gap = readNumber(parent.gap) ?? 0;
const columnGap =
readNumber(parent.column_gap) ?? readNumber(parent.columnGap) ?? gap;
const rowGap = readNumber(parent.row_gap) ?? readNumber(parent.rowGap) ?? gap;
const explicitColumns = readArray(parent.columns);
const explicitRows = readArray(parent.rows);
const columnCount =
readNumber(parent.columns) ??
(explicitColumns.length > 0
? explicitColumns.length
: 1);
const safeColumns = Math.max(1, Math.floor(columnCount));
const declaredRows =
readNumber(parent.rows) ??
(explicitRows.length > 0 ? explicitRows.length : null);
const placements = placeGridChildren(children, safeColumns, declaredRows);
const rowCount = Math.max(
declaredRows ?? 1,
...placements.map((placement) => placement.row + placement.rowSpan),
);
const availableW = Math.max(1, parentBox.width - padding.left - padding.right);
const availableH = Math.max(1, parentBox.height - padding.top - padding.bottom);
const cellW = Math.max(1, (availableW - columnGap * (safeColumns - 1)) / safeColumns);
const cellH = Math.max(1, (availableH - rowGap * Math.max(0, rowCount - 1)) / rowCount);
return children.map((child, index) => {
const raw = deps.elementBox(child);
if (deps.isManualPositioned(child)) {
return { child, index, box: raw, layoutManaged: false };
}
const placement = placements[index];
const area = {
x: padding.left + placement.col * (cellW + columnGap),
y: padding.top + placement.row * (cellH + rowGap),
width: cellW * placement.columnSpan + columnGap * (placement.columnSpan - 1),
height: cellH * placement.rowSpan + rowGap * (placement.rowSpan - 1),
};
const justify =
readString(child.layout?.align_self) ??
readString(child.layout?.alignSelf) ??
readString(parent.justify_items) ??
readString(parent.justifyItems) ??
"stretch";
const align =
readString(child.layout?.align_self) ??
readString(child.layout?.alignSelf) ??
readString(parent.align_items) ??
readString(parent.alignItems) ??
"stretch";
const width =
justify === "stretch"
? area.width
: clampLayoutSize(raw.width, child, "width", area.width);
const height =
align === "stretch"
? area.height
: clampLayoutSize(raw.height, child, "height", area.height);
return {
child,
index,
box: {
x: area.x + alignmentOffset(justify, area.width, width),
y: area.y + alignmentOffset(align, area.height, height),
width,
height,
},
layoutManaged: true,
};
});
}
function intrinsicFlexSize(
parent: FlowLayoutElement,
children: FlowLayoutElement[],
deps: Pick<FlowLayoutDeps, "elementBox" | "elementSize" | "isManualPositioned">,
): Size {
const padding = readPadding(parent.padding);
if (children.length === 0) {
return {
width: Math.max(1, padding.left + padding.right),
height: Math.max(1, padding.top + padding.bottom),
};
}
const direction = readString(parent.direction) === "row" ? "row" : "column";
const isColumn = direction === "column";
const mainGap =
(isColumn
? readNumber(parent.row_gap) ?? readNumber(parent.rowGap)
: readNumber(parent.column_gap) ?? readNumber(parent.columnGap)) ??
readNumber(parent.gap) ??
0;
let main = 0;
let cross = 0;
children.forEach((child) => {
const size = intrinsicFlowChildSize(child, deps);
main += isColumn ? size.height : size.width;
cross = Math.max(cross, isColumn ? size.width : size.height);
});
main += mainGap * Math.max(0, children.length - 1);
return {
width: Math.max(1, padding.left + padding.right + (isColumn ? cross : main)),
height: Math.max(1, padding.top + padding.bottom + (isColumn ? main : cross)),
};
}
function intrinsicGridSize(
parent: FlowLayoutElement,
children: FlowLayoutElement[],
deps: Pick<FlowLayoutDeps, "elementBox" | "elementSize" | "isManualPositioned">,
): Size {
const padding = readPadding(parent.padding);
if (children.length === 0) {
return {
width: Math.max(1, padding.left + padding.right),
height: Math.max(1, padding.top + padding.bottom),
};
}
const gap = readNumber(parent.gap) ?? 0;
const columnGap =
readNumber(parent.column_gap) ?? readNumber(parent.columnGap) ?? gap;
const rowGap = readNumber(parent.row_gap) ?? readNumber(parent.rowGap) ?? gap;
const explicitColumns = readArray(parent.columns);
const explicitRows = readArray(parent.rows);
const columnCount =
readNumber(parent.columns) ??
(explicitColumns.length > 0 ? explicitColumns.length : 1);
const columns = Math.max(1, Math.floor(columnCount));
const declaredRows =
readNumber(parent.rows) ??
(explicitRows.length > 0 ? explicitRows.length : null);
const placements = placeGridChildren(children, columns, declaredRows);
const rows = Math.max(
declaredRows ?? 1,
...placements.map((placement) => placement.row + placement.rowSpan),
);
const columnWidths = Array.from({ length: columns }, () => 1);
const rowHeights = Array.from({ length: rows }, () => 1);
children.forEach((child, index) => {
const placement = placements[index];
if (!placement) return;
const size = intrinsicFlowChildSize(child, deps);
const widthShare =
(size.width - columnGap * Math.max(0, placement.columnSpan - 1)) /
placement.columnSpan;
const heightShare =
(size.height - rowGap * Math.max(0, placement.rowSpan - 1)) /
placement.rowSpan;
for (
let column = placement.col;
column < placement.col + placement.columnSpan;
column += 1
) {
columnWidths[column] = Math.max(columnWidths[column], widthShare);
}
for (
let row = placement.row;
row < placement.row + placement.rowSpan;
row += 1
) {
rowHeights[row] = Math.max(rowHeights[row], heightShare);
}
});
return {
width: Math.max(
1,
padding.left +
padding.right +
columnWidths.reduce((sum, width) => sum + Math.max(1, width), 0) +
columnGap * Math.max(0, columns - 1),
),
height: Math.max(
1,
padding.top +
padding.bottom +
rowHeights.reduce((sum, height) => sum + Math.max(1, height), 0) +
rowGap * Math.max(0, rows - 1),
),
};
}
function intrinsicFlowChildSize(
child: FlowLayoutElement,
deps: Pick<FlowLayoutDeps, "elementBox" | "elementSize" | "isManualPositioned">,
): Size {
const box = deps.isManualPositioned(child) ? deps.elementBox(child) : null;
if (box) {
return {
width: Math.max(1, box.width),
height: Math.max(1, box.height),
};
}
return deps.elementSize(child);
}
function intrinsicTextMainSize(
child: FlowLayoutElement,
direction: FlowDirection,
crossSize: number,
) {
const font = rawFont(child);
const text = displayText(rawTextContent(child));
if (direction === "row") {
return Math.max(1, estimateTextWidth(text, font));
}
const explicitWidth = readOptionalSize(child.size)?.width;
const width = Math.max(1, explicitWidth ?? crossSize);
return Math.max(1, estimateTextHeight(text, font, width));
}
function estimateTextWidth(text: string, font: ReturnType<typeof rawFont>) {
const longestLine = text
.split(/\r?\n/)
.reduce((longest, line) => Math.max(longest, line.length), 0);
const weight = font.bold ? 0.56 : TEXT_AVERAGE_CHAR_EM;
return Math.max(font.size, longestLine * font.size * weight);
}
function estimateTextHeight(
text: string,
font: ReturnType<typeof rawFont>,
width: number,
) {
const lineHeight = font.size * font.lineHeight;
const averageCharWidth = Math.max(1, font.size * TEXT_AVERAGE_CHAR_EM);
const charsPerLine = Math.max(1, Math.floor(width / averageCharWidth));
const lines = text.split(/\r?\n/).reduce((count, line) => {
return count + Math.max(1, Math.ceil(line.length / charsPerLine));
}, 0);
return Math.max(lineHeight, lines * lineHeight);
}
function gridAreaOpen(
occupied: Set<string>,
row: number,
col: number,
rowSpan: number,
columnSpan: number,
) {
for (let r = row; r < row + rowSpan; r += 1) {
for (let c = col; c < col + columnSpan; c += 1) {
if (occupied.has(`${r}:${c}`)) return false;
}
}
return true;
}
function markGridArea(
occupied: Set<string>,
row: number,
col: number,
rowSpan: number,
columnSpan: number,
) {
for (let r = row; r < row + rowSpan; r += 1) {
for (let c = col; c < col + columnSpan; c += 1) {
occupied.add(`${r}:${c}`);
}
}
}
function isFramelessDecorativeShape(child: FlowLayoutElement) {
if (readOptionalSize(child.size) || asRecord(child.position)) return false;
const type = readString(child.type);
return type === "rectangle" || type === "ellipse" || type === "line";
}
function clampLayoutSize(
size: number,
child: FlowLayoutElement,
dimension: "width" | "height",
fallback = 1,
) {
const value = Number.isFinite(size) && size > 0 ? size : fallback;
const min =
dimension === "width"
? layoutNumber(child, "minWidth", "min_width")
: layoutNumber(child, "minHeight", "min_height");
const max =
dimension === "width"
? layoutNumber(child, "maxWidth", "max_width")
: layoutNumber(child, "maxHeight", "max_height");
return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 1, value));
}
function layoutNumber(child: FlowLayoutElement, ...keys: string[]) {
const layout = asRecord(child.layout);
for (const key of keys) {
const value = readNumber(layout?.[key]);
if (value != null) return value;
}
return null;
}
function alignmentOffset(
alignment: string | null,
available: number,
used: number,
) {
const free = Math.max(0, available - used);
if (alignment === "center") return free / 2;
if (
alignment === "right" ||
alignment === "bottom" ||
alignment === "end" ||
alignment === "flex-end"
) {
return free;
}
return 0;
}
function readOptionalSize(value: unknown): Size | null {
const record = asRecord(value);
const width = readNumber(record?.width);
const height = readNumber(record?.height);
if (width == null || height == null) return null;
return {
width: Math.max(1, width),
height: Math.max(1, height),
};
}
function readPadding(value: unknown): Padding {
if (typeof value === "number") {
return { top: value, right: value, bottom: value, left: value };
}
const record = asRecord(value);
const x = readNumber(record?.x) ?? readNumber(record?.horizontal);
const y = readNumber(record?.y) ?? readNumber(record?.vertical);
return {
top: readNumber(record?.top) ?? y ?? 0,
right: readNumber(record?.right) ?? x ?? 0,
bottom: readNumber(record?.bottom) ?? y ?? 0,
left: readNumber(record?.left) ?? x ?? 0,
};
}
function readArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function asRecord(value: unknown): FlowLayoutElement | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as FlowLayoutElement)
: null;
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}