import { displayText, rawFont, rawTextContent, } from "@/components/slide-editor/text/template-v2-text"; import { layoutWrappedFlexChildren } from "@/components/slide-editor/layout/wrappedFlexLayout"; export type FlowLayoutElement = Record; 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, ): 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(); 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, ): 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, ): 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, ): 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) { 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, 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, 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, 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; }