| 1 |
import { ReactNode } from "react"; |
| 2 |
import { |
| 3 |
InspectorControls, |
| 4 |
store as blockEditorStore, |
| 5 |
} from "@wordpress/block-editor"; |
| 6 |
import { useDispatch, useRegistry, useSelect } from "@wordpress/data"; |
| 7 |
import { __ } from "@wordpress/i18n"; |
| 8 |
import { BorderControl } from "@tableberg/components"; |
| 9 |
|
| 10 |
import LockedControl from "../../components/LockedControl"; |
| 11 |
import { Border } from "../../attributes"; |
| 12 |
import { buildOccupancy, cellColumn, GridRow } from "../table/grid-model"; |
| 13 |
|
| 14 |
const EMPTY_BORDER: Border = { top: "", right: "", bottom: "", left: "" }; |
| 15 |
|
| 16 |
const hasBorder = (border: Border) => |
| 17 |
!!border.top || !!border.right || !!border.bottom || !!border.left; |
| 18 |
|
| 19 |
export interface ColumnBorderContext { |
| 20 |
columnBorderControlProps: { |
| 21 |
label: string; |
| 22 |
value: Border; |
| 23 |
hasValue: () => boolean; |
| 24 |
onChange: (newBorder: Border) => void; |
| 25 |
onDeselect: () => void; |
| 26 |
}; |
| 27 |
} |
| 28 |
|
| 29 |
/** |
| 30 |
* Column border — bulk-apply the picked border to every cell in the |
| 31 |
* selected cell's column, mirroring `CellColumnBackgroundControls` (and, |
| 32 |
* further back, `CellDimensionsControls`'s column width): a column has no |
| 33 |
* block of its own, so this is set from whichever cell you have selected. |
| 34 |
* Writes each target cell's own `border` attribute — the same one the |
| 35 |
* individual per-cell border control uses, with the same legacy |
| 36 |
* `styles.border` fallback (tables where it lived before it got its own |
| 37 |
* attribute). Deliberately not `styles.border` directly: that key is read |
| 38 |
* unconditionally by free's own rendering (the table-wide default's |
| 39 |
* fallback target), so anything written there keeps showing even with pro |
| 40 |
* switched off — moving it to a dedicated pro-only attribute is what makes |
| 41 |
* it disappear correctly, the same fix already applied to backgroundColor. |
| 42 |
* |
| 43 |
* The border wraps the column as one block, not each cell individually: |
| 44 |
* every cell gets the left/right sides, but only the topmost cell gets the |
| 45 |
* top and only the bottommost gets the bottom — otherwise cells in the |
| 46 |
* middle of the column would each draw a border against their neighbours. |
| 47 |
*/ |
| 48 |
export function CellColumnBorderControls({ |
| 49 |
clientId, |
| 50 |
cellDefaultsBorder, |
| 51 |
// Injected by the pro plugin; undefined when pro is not installed. A |
| 52 |
// render function rather than a plain node: resolving the column's |
| 53 |
// sibling cells and bulk-writing their styles needs the block registry, |
| 54 |
// which pro's editor.BlockEdit HOC can't reach from outside the tree. |
| 55 |
ProColumnBorderContent, |
| 56 |
}: { |
| 57 |
clientId: string; |
| 58 |
cellDefaultsBorder: Border; |
| 59 |
ProColumnBorderContent?: (ctx: ColumnBorderContext) => ReactNode; |
| 60 |
}) { |
| 61 |
const { updateBlockAttributes } = useDispatch(blockEditorStore) as any; |
| 62 |
const registry = useRegistry() as any; |
| 63 |
|
| 64 |
const info = useSelect( |
| 65 |
select => { |
| 66 |
const be = select(blockEditorStore) as any; |
| 67 |
const rowClientId = be.getBlockRootClientId(clientId); |
| 68 |
const tableClientId = rowClientId |
| 69 |
? be.getBlockRootClientId(rowClientId) |
| 70 |
: null; |
| 71 |
|
| 72 |
if (!rowClientId || !tableClientId) { |
| 73 |
return null; |
| 74 |
} |
| 75 |
|
| 76 |
const rows: GridRow[] = ( |
| 77 |
be.getBlock(tableClientId)?.innerBlocks ?? [] |
| 78 |
) |
| 79 |
.filter((b: any) => b.name === "tableberg/row") |
| 80 |
.map((rowBlock: any) => |
| 81 |
rowBlock.innerBlocks |
| 82 |
.filter((b: any) => b.name === "tableberg/cell") |
| 83 |
.map((cellBlock: any) => ({ |
| 84 |
id: cellBlock.clientId, |
| 85 |
rowSpan: cellBlock.attributes?.span?.rowSpan ?? 1, |
| 86 |
colSpan: cellBlock.attributes?.span?.colSpan ?? 1, |
| 87 |
})) |
| 88 |
); |
| 89 |
|
| 90 |
const column = cellColumn(rows, clientId); |
| 91 |
let columnCellIds: string[] = []; |
| 92 |
if (column !== null) { |
| 93 |
const occupancy = buildOccupancy(rows); |
| 94 |
const seen = new Set<string>(); |
| 95 |
for (let r = 0; r < rows.length; r++) { |
| 96 |
const id = occupancy.matrix[r]?.[column]; |
| 97 |
if (id && !seen.has(id)) { |
| 98 |
seen.add(id); |
| 99 |
columnCellIds.push(id); |
| 100 |
} |
| 101 |
} |
| 102 |
} |
| 103 |
|
| 104 |
const getResolvedBorder = (id: string): Border => { |
| 105 |
const cellAttrs = be.getBlockAttributes(id); |
| 106 |
// New attribute first, legacy `styles.border` fallback for |
| 107 |
// cells edited before it existed — same resolution the |
| 108 |
// individual cell border control uses. |
| 109 |
const cellBorder = (cellAttrs?.border ?? |
| 110 |
cellAttrs?.styles?.border) as Border | undefined; |
| 111 |
return cellBorder && hasBorder(cellBorder) |
| 112 |
? cellBorder |
| 113 |
: cellDefaultsBorder; |
| 114 |
}; |
| 115 |
|
| 116 |
// A column is one rectangular block: only its outermost edges |
| 117 |
// carry the border. Top comes from the first cell, bottom from |
| 118 |
// the last — the cells in between share no border with each |
| 119 |
// other, so their own top/bottom don't factor in here. |
| 120 |
let columnBorder: Border | undefined; |
| 121 |
if (columnCellIds.length > 0) { |
| 122 |
const resolved = columnCellIds.map(getResolvedBorder); |
| 123 |
const first = resolved[0]; |
| 124 |
const last = resolved[resolved.length - 1]; |
| 125 |
const sidesConsistent = resolved.every( |
| 126 |
border => |
| 127 |
border.left === first.left && |
| 128 |
border.right === first.right |
| 129 |
); |
| 130 |
columnBorder = sidesConsistent |
| 131 |
? { |
| 132 |
top: first.top, |
| 133 |
right: first.right, |
| 134 |
bottom: last.bottom, |
| 135 |
left: first.left, |
| 136 |
} |
| 137 |
: undefined; |
| 138 |
} |
| 139 |
|
| 140 |
return { columnCellIds, columnBorder }; |
| 141 |
}, |
| 142 |
[ |
| 143 |
clientId, |
| 144 |
cellDefaultsBorder.top, |
| 145 |
cellDefaultsBorder.right, |
| 146 |
cellDefaultsBorder.bottom, |
| 147 |
cellDefaultsBorder.left, |
| 148 |
] |
| 149 |
); |
| 150 |
|
| 151 |
if (!info) { |
| 152 |
return null; |
| 153 |
} |
| 154 |
|
| 155 |
const applyToCells = (newBorder: Border) => { |
| 156 |
const be = registry.select(blockEditorStore); |
| 157 |
const total = info.columnCellIds.length; |
| 158 |
registry.batch(() => { |
| 159 |
info.columnCellIds.forEach((id, index) => { |
| 160 |
const currentStyles = be.getBlockAttributes(id)?.styles ?? {}; |
| 161 |
const { border: _legacy, ...styles } = currentStyles; |
| 162 |
const isFirst = index === 0; |
| 163 |
const isLast = index === total - 1; |
| 164 |
updateBlockAttributes(id, { |
| 165 |
// Left/right run the full column, but top/bottom only |
| 166 |
// belong to the outermost cells — an interior cell |
| 167 |
// shares its neighbours' edges, not its own. |
| 168 |
border: { |
| 169 |
top: isFirst ? newBorder.top : "", |
| 170 |
right: newBorder.right, |
| 171 |
bottom: isLast ? newBorder.bottom : "", |
| 172 |
left: newBorder.left, |
| 173 |
}, |
| 174 |
styles, |
| 175 |
}); |
| 176 |
}); |
| 177 |
}); |
| 178 |
}; |
| 179 |
|
| 180 |
const columnBorder = info.columnBorder ?? EMPTY_BORDER; |
| 181 |
const context: ColumnBorderContext = { |
| 182 |
columnBorderControlProps: { |
| 183 |
label: __("Column Border", "tableberg"), |
| 184 |
value: columnBorder, |
| 185 |
hasValue: () => hasBorder(columnBorder), |
| 186 |
onChange: (newBorder: Border) => applyToCells(newBorder), |
| 187 |
onDeselect: () => applyToCells(EMPTY_BORDER), |
| 188 |
}, |
| 189 |
}; |
| 190 |
|
| 191 |
return ( |
| 192 |
<InspectorControls group="border"> |
| 193 |
{ProColumnBorderContent ? ( |
| 194 |
ProColumnBorderContent(context) |
| 195 |
) : ( |
| 196 |
<LockedControl isEnhanced selected="col-border"> |
| 197 |
<BorderControl {...context.columnBorderControlProps} /> |
| 198 |
</LockedControl> |
| 199 |
)} |
| 200 |
</InspectorControls> |
| 201 |
); |
| 202 |
} |
| 203 |
|