import { RefObject, useLayoutEffect, useMemo, useRef } from "react"; import { store as blockEditorStore } from "@wordpress/block-editor"; import { useDispatch, useSelect } from "@wordpress/data"; import { buildStackRows } from "../../responsive-stack"; import { buildOccupancy, GridRow } from "./grid-model"; /** * The tablet/mobile "stack" mode, previewed on the real block tree. * * The frontend stacks a table by moving its cells into new rows (see * frontend/responsive.ts). The editor cannot do that: React and the block * editor own those elements. So the table becomes a CSS grid instead, its * rows step aside (`display: contents`) and each cell is placed where the * frontend would put it. The cells stay the real blocks, so they can still * be selected and edited, the way the core Columns block stacks. */ export interface StackSettings { stackCount: number; transpose: boolean; repeatFirstCol: boolean; } export interface StackLayoutItem { id: string; /** 1-based grid row. */ row: number; /** 1-based grid column. */ col: number; /** Index among the repeated copies, for those only. */ repeatIndex?: number; } export interface StackLayout { columns: number; rows: number; items: StackLayoutItem[]; } export function useStackLayout( clientId: string, settings: StackSettings | null ): StackLayout | null { const enabled = !!settings; // The cells' grid positions as a string, so the selector's result stays // the same until the table's structure changes. const matrixKey = useSelect( select => { if (!enabled) { return ""; } const be = select(blockEditorStore) as any; const rows: GridRow[] = (be.getBlock(clientId)?.innerBlocks ?? []) .filter((block: any) => block.name === "tableberg/row") .map((rowBlock: any) => rowBlock.innerBlocks .filter((block: any) => block.name === "tableberg/cell") .map((cellBlock: any) => ({ id: cellBlock.clientId, rowSpan: cellBlock.attributes?.span?.rowSpan ?? 1, colSpan: cellBlock.attributes?.span?.colSpan ?? 1, })) ); const occupancy = buildOccupancy(rows); // Each cell at the column it starts at, like the frontend's // `data-cell-col`, with nothing where a span covers. const matrix: Array> = rows.map(() => Array.from({ length: occupancy.cols }, () => null) ); occupancy.anchors.forEach(({ row, col }, id) => { matrix[row][col] = id; }); return JSON.stringify(matrix); }, [clientId, enabled] ); const stackCount = settings?.stackCount ?? 1; const transpose = settings?.transpose ?? false; const repeatFirstCol = settings?.repeatFirstCol ?? false; return useMemo(() => { if (!matrixKey) { return null; } const stackRows = buildStackRows( JSON.parse(matrixKey), stackCount, transpose, repeatFirstCol ); let repeatIndex = 0; const items: StackLayoutItem[] = []; stackRows.forEach((stackRow, rowIndex) => { stackRow.forEach(({ cell, repeat }, colIndex) => { items.push({ id: cell, row: rowIndex + 1, col: colIndex + 1, ...(repeat ? { repeatIndex: repeatIndex++ } : {}), }); }); }); return { columns: stackRows.reduce( (max, stackRow) => Math.max(max, stackRow.length), 0 ), rows: stackRows.length, items, }; }, [matrixKey, stackCount, transpose, repeatFirstCol]); } const repeatSelector = (index: number) => `[data-tableberg-stack-repeat="${index}"]`; /** * The grid itself: what does not depend on how the cells look. * * @param spacing The table's cell spacing, when it has any. A grid has no * `border-spacing`, so its gaps and padding stand in for it. */ export function getStackLayoutCss( tableClientId: string, layout: StackLayout, spacing: { horizontal: string; vertical: string } | null ): string { const table = `#block-${tableClientId} .tableberg-table`; const groups = `${table} > tbody, ${table} > tfoot`; const rows = `${table} > tbody > tr, ${table} > tfoot > tr`; const cells = `${table} > tbody > tr > *, ${table} > tfoot > tr > *`; // Stacked cells sit in their own rows, so the column gap stops showing. // Borrow it for the vertical gap, as the frontend does. const verticalSpacing = spacing && parseFloat(spacing.horizontal) > parseFloat(spacing.vertical || "0") ? spacing.horizontal : spacing?.vertical || "0"; const css = [ `${table} { display: grid; grid-template-columns: repeat(${Math.max( 1, layout.columns )}, auto);${ spacing ? ` column-gap: ${spacing.horizontal || "0"}; row-gap: ${verticalSpacing}; padding: ${verticalSpacing} ${spacing.horizontal || "0"};` : "" } }`, `${table} > colgroup { display: none; }`, `${groups}, ${rows} { display: contents; }`, // A grid item is no table cell, so `vertical-align` does nothing: // a column flexbox aligns the content instead (see below). `${cells} { display: flex; flex-direction: column; }`, // The row no longer paints a box, so its cells show its colour. `${table} > tbody > tr > * { background-color: inherit; }`, ]; layout.items.forEach(item => { const selector = item.repeatIndex === undefined ? `#block-${item.id}` : `${table} ${repeatSelector(item.repeatIndex)}`; css.push( `${selector} { grid-row: ${item.row}; grid-column: ${item.col}; }` ); }); return css.join("\n"); } type Sides = { top: number; right: number; bottom: number; left: number }; const JUSTIFY_BY_VERTICAL_ALIGN: Record = { middle: "center", bottom: "flex-end", }; /** * What can only be read off the rendered cells: their vertical alignment, * and the borders a collapsed table would have merged. A grid draws both * borders of two neighbours, so of each pair only the wider one is kept (the * top/left one on a tie), the way `border-collapse` resolves them. A cell on * the table's edge gives way to the table's own border there. */ function resolveStackCellCss( table: HTMLTableElement, tableClientId: string, layout: StackLayout, collapse: boolean ): string { const view = table.ownerDocument.defaultView; if (!view) { return ""; } const widthsOf = (style: CSSStyleDeclaration): Sides => ({ top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, bottom: parseFloat(style.borderBottomWidth) || 0, left: parseFloat(style.borderLeftWidth) || 0, }); const entries: Array<{ item: StackLayoutItem; selector: string; widths: Sides; verticalAlign: string; }> = []; layout.items.forEach(item => { const element = item.repeatIndex === undefined ? table.ownerDocument.getElementById(`block-${item.id}`) : table.querySelector(repeatSelector(item.repeatIndex)); if (!element) { return; } const style = view.getComputedStyle(element); entries.push({ item, selector: item.repeatIndex === undefined ? `#block-${item.id}` : `#block-${tableClientId} .tableberg-table ${repeatSelector( item.repeatIndex )}`, widths: widthsOf(style), verticalAlign: style.verticalAlign, }); }); const rules = new Map(); const add = (selector: string, declaration: string) => { rules.set(selector, [...(rules.get(selector) ?? []), declaration]); }; entries.forEach(({ selector, verticalAlign }) => { const justify = JUSTIFY_BY_VERTICAL_ALIGN[verticalAlign]; if (justify) { add(selector, `justify-content: ${justify}`); } }); if (collapse) { const at = new Map( entries.map(entry => [`${entry.item.row},${entry.item.col}`, entry]) ); const hide = (selector: string, side: keyof Sides) => add(selector, `border-${side}: none !important`); const tableWidths = widthsOf(view.getComputedStyle(table)); entries.forEach(entry => { const { row, col } = entry.item; const right = at.get(`${row},${col + 1}`); if (right && entry.widths.right > 0 && right.widths.left > 0) { if (entry.widths.right >= right.widths.left) { hide(right.selector, "left"); } else { hide(entry.selector, "right"); } } const below = at.get(`${row + 1},${col}`); if (below && entry.widths.bottom > 0 && below.widths.top > 0) { if (entry.widths.bottom >= below.widths.top) { hide(below.selector, "top"); } else { hide(entry.selector, "bottom"); } } if (row === 1 && tableWidths.top > 0) { hide(entry.selector, "top"); } if (row === layout.rows && tableWidths.bottom > 0) { hide(entry.selector, "bottom"); } if (col === 1 && tableWidths.left > 0) { hide(entry.selector, "left"); } if (col === layout.columns && tableWidths.right > 0) { hide(entry.selector, "right"); } }); } return Array.from(rules, ([selector, declarations]) => { return `${selector} { ${declarations.join("; ")}; }`; }).join("\n"); } /** * Keeps `resolveStackCellCss` in the given `