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

553 lines
16 KiB
TypeScript

import {
childCrossSize,
flexBasis,
flowLayoutKind,
isFlowLayoutElement,
placeGridChildren,
type FlowLayoutDeps,
} from "@/components/slide-editor/layout/flowLayout";
type RawRecord = Record<string, any>;
type Direction = "row" | "column";
export type LayoutItemResizeBox = {
x: number;
y: number;
width: number;
height: number;
};
type Size = {
width: number;
height: number;
};
type Point = {
x: number;
y: number;
};
type Padding = {
top: number;
right: number;
bottom: number;
left: number;
};
type ChildArrayInfo = {
key: "children" | "elements" | "child";
items: unknown[];
};
export type LayoutItemResizeDeps = FlowLayoutDeps & {
childrenBounds: (children: unknown[]) => Size;
normalizeLayoutChildren: (
element: RawRecord,
parentBox: LayoutItemResizeBox,
) => RawRecord;
};
const STAGE_WIDTH = 1280;
const STAGE_HEIGHT = 720;
const MIN_EMPTY_LAYOUT_SIZE = 24;
export function updateComponentLayoutElement(
component: RawRecord,
elementPath: number[],
changes: Record<string, unknown>,
fallbackAbsoluteBox: LayoutItemResizeBox,
deps: LayoutItemResizeDeps,
): RawRecord {
const currentElements = readArray(component.elements);
const currentElement = getElementFromArray(currentElements, elementPath);
if (!currentElement) return component;
const componentOrigin = readPoint(component.position);
const currentBox = {
...fallbackAbsoluteBox,
x: fallbackAbsoluteBox.x - componentOrigin.x,
y: fallbackAbsoluteBox.y - componentOrigin.y,
};
const mergedElement = {
...currentElement,
...changes,
};
const itemCountDelta = layoutElementItemCountDelta(
currentElement,
mergedElement,
);
const resizedElement = layoutElementWithAdjustedItemSpace(
currentElement,
mergedElement,
currentBox,
itemCountDelta,
deps,
);
const nextRenderBox = {
...currentBox,
...readSize(resizedElement.size, {
width: currentBox.width,
height: currentBox.height,
}),
};
const nextElement = deps.normalizeLayoutChildren(resizedElement, nextRenderBox);
const elements = updateElementArray(
currentElements,
elementPath,
() => nextElement,
);
if (elements === currentElements) return component;
return resizeComponentForLayoutItemChange(
{ ...component, elements },
itemCountDelta,
deps,
);
}
export function deleteLayoutChildFromArray(elements: unknown[], path: number[]) {
const [index, ...rest] = path;
if (!Number.isInteger(index) || index < 0 || index >= elements.length) {
return elements;
}
const current = asRecord(elements[index]);
const childInfo = current ? childArrayInfo(current) : null;
if (!current || !childInfo) return elements;
if (rest.length >= 1 && isFlowLayoutElement(current)) {
if (childInfo.key === "children") {
const minChildren = Math.max(0, readNumber(current.min_children) ?? 0);
if (childInfo.items.length <= minChildren) return elements;
const updatedChildren = [...childInfo.items];
updatedChildren.splice(rest[0], 1);
const next = [...elements];
next[index] = withUpdatedChildItems(current, childInfo, updatedChildren);
return next;
}
}
const updatedChildren = deleteLayoutChildFromArray(childInfo.items, rest);
if (updatedChildren === childInfo.items) return elements;
const next = [...elements];
next[index] = withUpdatedChildItems(current, childInfo, updatedChildren);
return next;
}
function layoutElementWithAdjustedItemSpace(
previous: RawRecord,
next: RawRecord,
currentBox: LayoutItemResizeBox,
itemCountDelta: number,
deps: LayoutItemResizeDeps,
): RawRecord {
const kind = flowLayoutKind(next);
if (itemCountDelta === 0 || !kind) return next;
const currentSize = readSize(next.size, {
width: currentBox.width,
height: currentBox.height,
});
const adjustedSize =
kind === "flex"
? adjustedFlexSize(next, currentSize, itemCountDelta, deps)
: kind === "grid"
? adjustedGridSize(previous, next, currentSize, itemCountDelta)
: currentSize;
if (
adjustedSize.width === currentSize.width &&
adjustedSize.height === currentSize.height
) {
return next;
}
return {
...next,
size: {
width: Math.max(1, adjustedSize.width),
height: Math.max(1, adjustedSize.height),
},
};
}
function layoutElementItemCountDelta(previous: RawRecord, next: RawRecord) {
const previousChildren = layoutChildItems(previous);
const nextChildren = layoutChildItems(next);
return nextChildren.length - previousChildren.length;
}
function adjustedFlexSize(
next: RawRecord,
currentSize: Size,
itemCountDelta: number,
deps: LayoutItemResizeDeps,
): Size {
const nextChildren = layoutChildItems(next).filter(isRecord);
const padding = readPadding(next.padding);
if (nextChildren.length === 0) {
return emptyLayoutSize(padding);
}
const direction = readString(next.direction) === "row" ? "row" : "column";
const isColumn = direction === "column";
const mainGap =
(isColumn
? readNumber(next.row_gap) ?? readNumber(next.rowGap)
: readNumber(next.column_gap) ?? readNumber(next.columnGap)) ??
readNumber(next.gap) ??
0;
const crossGap =
(isColumn
? readNumber(next.column_gap) ?? readNumber(next.columnGap)
: readNumber(next.row_gap) ?? readNumber(next.rowGap)) ??
readNumber(next.gap) ??
0;
const availableMain = Math.max(
1,
(isColumn ? currentSize.height : currentSize.width) -
(isColumn
? padding.top + padding.bottom
: padding.left + padding.right),
);
const availableCross = Math.max(
1,
(isColumn ? currentSize.width : currentSize.height) -
(isColumn
? padding.left + padding.right
: padding.top + padding.bottom),
);
if (next.wrap === true) {
const required = wrappedFlexRequiredSpace({
align: readString(next.align_items) ?? readString(next.alignItems) ?? "stretch",
availableMain,
availableCross,
children: nextChildren,
crossGap,
direction,
mainGap,
deps,
});
const mainAdjustment =
itemCountDelta > 0
? Math.max(0, required.main - availableMain)
: required.main - availableMain;
const crossAdjustment =
itemCountDelta > 0
? Math.max(0, required.cross - availableCross)
: required.cross - availableCross;
return isColumn
? {
width: currentSize.width + crossAdjustment,
height: currentSize.height + mainAdjustment,
}
: {
width: currentSize.width + mainAdjustment,
height: currentSize.height + crossAdjustment,
};
}
const requiredMain =
nextChildren.reduce(
(sum, child) =>
sum + Math.max(1, flexBasis(child, direction, availableCross, deps)),
0,
) +
mainGap * Math.max(0, nextChildren.length - 1);
const mainAdjustment =
itemCountDelta > 0
? Math.max(0, requiredMain - availableMain)
: requiredMain - availableMain;
return isColumn
? { width: currentSize.width, height: currentSize.height + mainAdjustment }
: { width: currentSize.width + mainAdjustment, height: currentSize.height };
}
function wrappedFlexRequiredSpace({
align,
availableCross,
availableMain,
children,
crossGap,
deps,
direction,
mainGap,
}: {
align: string;
availableCross: number;
availableMain: number;
children: RawRecord[];
crossGap: number;
deps: LayoutItemResizeDeps;
direction: Direction;
mainGap: number;
}) {
const lines: RawRecord[][] = [];
let currentLine: RawRecord[] = [];
let currentMain = 0;
let largestMain = 0;
children.forEach((child) => {
const basis = Math.max(1, flexBasis(child, direction, availableCross, deps));
largestMain = Math.max(largestMain, basis);
const nextMain = currentMain + (currentLine.length > 0 ? mainGap : 0) + basis;
if (currentLine.length > 0 && nextMain > availableMain) {
lines.push(currentLine);
currentLine = [];
currentMain = 0;
}
currentLine.push(child);
currentMain += (currentLine.length > 1 ? mainGap : 0) + basis;
});
if (currentLine.length > 0) lines.push(currentLine);
const requiredCross =
lines.reduce((sum, line) => {
const lineCross = line.reduce(
(max, child) =>
Math.max(
max,
childCrossSize(child, direction, availableCross, align, deps),
),
1,
);
return sum + lineCross;
}, 0) + crossGap * Math.max(0, lines.length - 1);
return {
main: Math.max(availableMain, largestMain),
cross: requiredCross,
};
}
function adjustedGridSize(
previous: RawRecord,
next: RawRecord,
currentSize: Size,
itemCountDelta: number,
): Size {
const previousChildren = layoutChildItems(previous).filter(isRecord);
const nextChildren = layoutChildItems(next).filter(isRecord);
const padding = readPadding(next.padding);
if (nextChildren.length === 0) {
return emptyLayoutSize(padding);
}
const gap = readNumber(next.gap) ?? 0;
const rowGap = readNumber(next.row_gap) ?? readNumber(next.rowGap) ?? gap;
const previousColumns = gridColumnCount(previous);
const nextColumns = gridColumnCount(next);
const previousDeclaredRows = gridDeclaredRows(previous);
const nextDeclaredRows = gridDeclaredRows(next);
const previousRows = gridRowCount(
previousChildren,
previousColumns,
previousDeclaredRows,
);
const nextRows = gridRowCount(nextChildren, nextColumns, nextDeclaredRows);
if (nextRows === previousRows) return currentSize;
if (itemCountDelta > 0 && nextRows < previousRows) return currentSize;
if (itemCountDelta < 0 && nextRows > previousRows) return currentSize;
const availableHeight = Math.max(
1,
currentSize.height - padding.top - padding.bottom,
);
const currentRowHeight = Math.max(
1,
(availableHeight - rowGap * Math.max(0, previousRows - 1)) / previousRows,
);
const rowDelta = nextRows - previousRows;
return {
width: currentSize.width,
height: currentSize.height + rowDelta * (currentRowHeight + rowGap),
};
}
function gridColumnCount(element: RawRecord) {
const explicitColumns = readArray(element.columns);
const columnCount =
readNumber(element.columns) ??
(explicitColumns.length > 0 ? explicitColumns.length : 1);
return Math.max(1, Math.floor(columnCount));
}
function gridDeclaredRows(element: RawRecord) {
const explicitRows = readArray(element.rows);
return readNumber(element.rows) ??
(explicitRows.length > 0 ? explicitRows.length : null);
}
function gridRowCount(
children: RawRecord[],
columns: number,
declaredRows: number | null,
) {
if (children.length === 0) return Math.max(1, declaredRows ?? 1);
const placements = placeGridChildren(children, columns, declaredRows);
return Math.max(
declaredRows ?? 1,
...placements.map((placement) => placement.row + placement.rowSpan),
);
}
function emptyLayoutSize(padding: Padding): Size {
return {
width: Math.max(MIN_EMPTY_LAYOUT_SIZE, padding.left + padding.right),
height: Math.max(MIN_EMPTY_LAYOUT_SIZE, padding.top + padding.bottom),
};
}
function resizeComponentForLayoutItemChange(
component: RawRecord,
itemCountDelta: number,
deps: LayoutItemResizeDeps,
): RawRecord {
if (itemCountDelta === 0) return component;
const componentSize = readSize(component.size, {
width: STAGE_WIDTH,
height: STAGE_HEIGHT,
});
const contentSize = deps.childrenBounds(readArray(component.elements));
const nextWidth =
itemCountDelta > 0
? Math.max(componentSize.width, contentSize.width)
: contentSize.width;
const nextHeight =
itemCountDelta > 0
? Math.max(componentSize.height, contentSize.height)
: contentSize.height;
if (nextWidth === componentSize.width && nextHeight === componentSize.height) {
return component;
}
return {
...component,
size: {
width: nextWidth,
height: nextHeight,
},
};
}
function getElementFromArray(elements: unknown[], path: number[]): RawRecord | null {
const [index, ...rest] = path;
const current = asRecord(elements[index]);
if (!current) return null;
if (rest.length === 0) return current;
const childInfo = childArrayInfo(current);
return childInfo ? getElementFromArray(childInfo.items, rest) : null;
}
function updateElementArray(
elements: unknown[],
path: number[],
updater: (element: RawRecord) => RawRecord,
): unknown[] {
if (path.length === 0) return elements;
const [index, ...rest] = path;
const current = asRecord(elements[index]);
if (!current) return elements;
if (rest.length === 0) {
const updated = updater(current);
if (updated === current) return elements;
const next = [...elements];
next[index] = updated;
return next;
}
const childInfo = childArrayInfo(current);
if (!childInfo) return elements;
const updatedChildren = updateElementArray(childInfo.items, rest, updater);
if (updatedChildren === childInfo.items) return elements;
const next = [...elements];
next[index] = withUpdatedChildItems(current, childInfo, updatedChildren);
return next;
}
function childArrayInfo(element: RawRecord): ChildArrayInfo | null {
if (Array.isArray(element.children)) return { key: "children", items: element.children };
if (Array.isArray(element.elements)) return { key: "elements", items: element.elements };
if (isRecord(element.child)) return { key: "child", items: [element.child] };
return null;
}
function layoutChildItems(element: RawRecord): unknown[] {
if (Array.isArray(element.children)) return element.children;
if (Array.isArray(element.elements)) return element.elements;
return [];
}
function withUpdatedChildItems(
element: RawRecord,
childInfo: ChildArrayInfo,
updatedChildren: unknown[],
) {
if (childInfo.key === "child") {
return { ...element, child: updatedChildren[0] ?? null };
}
return { ...element, [childInfo.key]: updatedChildren };
}
function emptyRecord(value: unknown): RawRecord {
return asRecord(value) ?? {};
}
function readPadding(value: unknown): Padding {
if (typeof value === "number") {
return { top: value, right: value, bottom: value, left: value };
}
const record = emptyRecord(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 readPoint(value: unknown): Point {
const record = emptyRecord(value);
return {
x: readNumber(record.x) ?? 0,
y: readNumber(record.y) ?? 0,
};
}
function readSize(value: unknown, fallback: Size = { width: 1, height: 1 }): Size {
const record = emptyRecord(value);
return {
width: Math.max(1, readNumber(record.width) ?? fallback.width),
height: Math.max(1, readNumber(record.height) ?? fallback.height),
};
}
function readArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function asRecord(value: unknown): RawRecord | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as RawRecord)
: null;
}
function isRecord(value: unknown): value is RawRecord {
return Boolean(asRecord(value));
}
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;
}