| 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, BorderRadiusControl } from "@tableberg/components"; |
| 9 |
|
| 10 |
import LockedControl from "../../components/LockedControl"; |
| 11 |
import { Border, Corners } from "../../attributes"; |
| 12 |
import { buildOccupancy, cellColumn, GridRow } from "../table/grid-model"; |
| 13 |
|
| 14 |
const EMPTY_BORDER: Border = { top: "", right: "", bottom: "", left: "" }; |
| 15 |
|
| 16 |
const EMPTY_CORNERS: Corners = { |
| 17 |
topLeft: "", |
| 18 |
topRight: "", |
| 19 |
bottomRight: "", |
| 20 |
bottomLeft: "", |
| 21 |
}; |
| 22 |
|
| 23 |
const hasBorder = (border: Border) => |
| 24 |
!!border.top || !!border.right || !!border.bottom || !!border.left; |
| 25 |
|
| 26 |
const hasCorners = (corners: Corners) => |
| 27 |
!!corners.topLeft || |
| 28 |
!!corners.topRight || |
| 29 |
!!corners.bottomRight || |
| 30 |
!!corners.bottomLeft; |
| 31 |
|
| 32 |
export interface ColumnBorderContext { |
| 33 |
columnBorderControlProps: { |
| 34 |
label: string; |
| 35 |
value: Border; |
| 36 |
hasValue: () => boolean; |
| 37 |
onChange: (newBorder: Border) => void; |
| 38 |
onDeselect: () => void; |
| 39 |
}; |
| 40 |
columnRadiusControlProps: { |
| 41 |
label: string; |
| 42 |
value: Corners; |
| 43 |
hasValue: () => boolean; |
| 44 |
onChange: (newRadius: Corners) => void; |
| 45 |
onDeselect: () => void; |
| 46 |
}; |
| 47 |
} |
| 48 |
|
| 49 |
/** |
| 50 |
* Column border — bulk-apply the picked border to every cell in the |
| 51 |
* selected cell's column, mirroring `CellColumnBackgroundControls` (and, |
| 52 |
* further back, `CellDimensionsControls`'s column width): a column has no |
| 53 |
* block of its own, so this is set from whichever cell you have selected. |
| 54 |
* Writes each target cell's own `border` attribute — the same one the |
| 55 |
* individual per-cell border control uses, with the same legacy |
| 56 |
* `styles.border` fallback (tables where it lived before it got its own |
| 57 |
* attribute). Deliberately not `styles.border` directly: that key is read |
| 58 |
* unconditionally by free's own rendering (the table-wide default's |
| 59 |
* fallback target), so anything written there keeps showing even with pro |
| 60 |
* switched off — moving it to a dedicated pro-only attribute is what makes |
| 61 |
* it disappear correctly, the same fix already applied to backgroundColor. |
| 62 |
* |
| 63 |
* The border wraps the column as one block, not each cell individually: |
| 64 |
* every cell gets the left/right sides, but only the topmost cell gets the |
| 65 |
* top and only the bottommost gets the bottom — otherwise cells in the |
| 66 |
* middle of the column would each draw a border against their neighbours. |
| 67 |
*/ |
| 68 |
export function CellColumnBorderControls({ |
| 69 |
clientId, |
| 70 |
cellDefaultsBorder, |
| 71 |
// Injected by the pro plugin; undefined when pro is not installed. A |
| 72 |
// render function rather than a plain node: resolving the column's |
| 73 |
// sibling cells and bulk-writing their styles needs the block registry, |
| 74 |
// which pro's editor.BlockEdit HOC can't reach from outside the tree. |
| 75 |
ProColumnBorderContent, |
| 76 |
}: { |
| 77 |
clientId: string; |
| 78 |
cellDefaultsBorder: Border; |
| 79 |
ProColumnBorderContent?: (ctx: ColumnBorderContext) => ReactNode; |
| 80 |
}) { |
| 81 |
const { updateBlockAttributes } = useDispatch(blockEditorStore) as any; |
| 82 |
const registry = useRegistry() as any; |
| 83 |
|
| 84 |
const info = useSelect( |
| 85 |
select => { |
| 86 |
const be = select(blockEditorStore) as any; |
| 87 |
const rowClientId = be.getBlockRootClientId(clientId); |
| 88 |
const tableClientId = rowClientId |
| 89 |
? be.getBlockRootClientId(rowClientId) |
| 90 |
: null; |
| 91 |
|
| 92 |
if (!rowClientId || !tableClientId) { |
| 93 |
return null; |
| 94 |
} |
| 95 |
|
| 96 |
const rows: GridRow[] = ( |
| 97 |
be.getBlock(tableClientId)?.innerBlocks ?? [] |
| 98 |
) |
| 99 |
.filter((b: any) => b.name === "tableberg/row") |
| 100 |
.map((rowBlock: any) => |
| 101 |
rowBlock.innerBlocks |
| 102 |
.filter((b: any) => b.name === "tableberg/cell") |
| 103 |
.map((cellBlock: any) => ({ |
| 104 |
id: cellBlock.clientId, |
| 105 |
rowSpan: cellBlock.attributes?.span?.rowSpan ?? 1, |
| 106 |
colSpan: cellBlock.attributes?.span?.colSpan ?? 1, |
| 107 |
})) |
| 108 |
); |
| 109 |
|
| 110 |
const column = cellColumn(rows, clientId); |
| 111 |
let columnCellIds: string[] = []; |
| 112 |
if (column !== null) { |
| 113 |
const occupancy = buildOccupancy(rows); |
| 114 |
const seen = new Set<string>(); |
| 115 |
for (let r = 0; r < rows.length; r++) { |
| 116 |
const id = occupancy.matrix[r]?.[column]; |
| 117 |
if (id && !seen.has(id)) { |
| 118 |
seen.add(id); |
| 119 |
columnCellIds.push(id); |
| 120 |
} |
| 121 |
} |
| 122 |
} |
| 123 |
|
| 124 |
const getResolvedBorder = (id: string): Border => { |
| 125 |
const cellAttrs = be.getBlockAttributes(id); |
| 126 |
// New attribute first, legacy `styles.border` fallback for |
| 127 |
// cells edited before it existed — same resolution the |
| 128 |
// individual cell border control uses. |
| 129 |
const cellBorder = (cellAttrs?.border ?? |
| 130 |
cellAttrs?.styles?.border) as Border | undefined; |
| 131 |
return cellBorder && hasBorder(cellBorder) |
| 132 |
? cellBorder |
| 133 |
: cellDefaultsBorder; |
| 134 |
}; |
| 135 |
|
| 136 |
// A column is one rectangular block: only its outermost edges |
| 137 |
// carry the border. Top comes from the first cell, bottom from |
| 138 |
// the last — the cells in between share no border with each |
| 139 |
// other, so their own top/bottom don't factor in here. |
| 140 |
let columnBorder: Border | undefined; |
| 141 |
if (columnCellIds.length > 0) { |
| 142 |
const resolved = columnCellIds.map(getResolvedBorder); |
| 143 |
const first = resolved[0]; |
| 144 |
const last = resolved[resolved.length - 1]; |
| 145 |
const sidesConsistent = resolved.every( |
| 146 |
border => |
| 147 |
border.left === first.left && |
| 148 |
border.right === first.right |
| 149 |
); |
| 150 |
columnBorder = sidesConsistent |
| 151 |
? { |
| 152 |
top: first.top, |
| 153 |
right: first.right, |
| 154 |
bottom: last.bottom, |
| 155 |
left: first.left, |
| 156 |
} |
| 157 |
: undefined; |
| 158 |
} |
| 159 |
|
| 160 |
// Same idea for the corners: a column is rounded at its own |
| 161 |
// four corners, so the top two come from the first cell and the |
| 162 |
// bottom two from the last. |
| 163 |
let columnRadius: Corners | undefined; |
| 164 |
if (columnCellIds.length > 0) { |
| 165 |
const radii = columnCellIds.map( |
| 166 |
id => |
| 167 |
(be.getBlockAttributes(id)?.styles?.borderRadius ?? |
| 168 |
EMPTY_CORNERS) as Corners |
| 169 |
); |
| 170 |
const first = radii[0]; |
| 171 |
const last = radii[radii.length - 1]; |
| 172 |
columnRadius = { |
| 173 |
topLeft: first.topLeft ?? "", |
| 174 |
topRight: first.topRight ?? "", |
| 175 |
bottomRight: last.bottomRight ?? "", |
| 176 |
bottomLeft: last.bottomLeft ?? "", |
| 177 |
}; |
| 178 |
} |
| 179 |
|
| 180 |
return { columnCellIds, columnBorder, columnRadius }; |
| 181 |
}, |
| 182 |
[ |
| 183 |
clientId, |
| 184 |
cellDefaultsBorder.top, |
| 185 |
cellDefaultsBorder.right, |
| 186 |
cellDefaultsBorder.bottom, |
| 187 |
cellDefaultsBorder.left, |
| 188 |
] |
| 189 |
); |
| 190 |
|
| 191 |
if (!info) { |
| 192 |
return null; |
| 193 |
} |
| 194 |
|
| 195 |
const applyToCells = (newBorder: Border) => { |
| 196 |
const be = registry.select(blockEditorStore); |
| 197 |
const total = info.columnCellIds.length; |
| 198 |
registry.batch(() => { |
| 199 |
info.columnCellIds.forEach((id, index) => { |
| 200 |
const currentStyles = be.getBlockAttributes(id)?.styles ?? {}; |
| 201 |
const { border: _legacy, ...styles } = currentStyles; |
| 202 |
const isFirst = index === 0; |
| 203 |
const isLast = index === total - 1; |
| 204 |
updateBlockAttributes(id, { |
| 205 |
// Left/right run the full column, but top/bottom only |
| 206 |
// belong to the outermost cells — an interior cell |
| 207 |
// shares its neighbours' edges, not its own. |
| 208 |
border: { |
| 209 |
top: isFirst ? newBorder.top : "", |
| 210 |
right: newBorder.right, |
| 211 |
bottom: isLast ? newBorder.bottom : "", |
| 212 |
left: newBorder.left, |
| 213 |
}, |
| 214 |
styles, |
| 215 |
}); |
| 216 |
}); |
| 217 |
}); |
| 218 |
}; |
| 219 |
|
| 220 |
// Only the column's outermost cells are rounded; an interior cell has |
| 221 |
// no corner of the column to round. |
| 222 |
const applyRadiusToCells = (newRadius: Corners) => { |
| 223 |
const be = registry.select(blockEditorStore); |
| 224 |
const total = info.columnCellIds.length; |
| 225 |
|
| 226 |
registry.batch(() => { |
| 227 |
info.columnCellIds.forEach((id, index) => { |
| 228 |
const styles = be.getBlockAttributes(id)?.styles ?? {}; |
| 229 |
const isFirst = index === 0; |
| 230 |
const isLast = index === total - 1; |
| 231 |
|
| 232 |
updateBlockAttributes(id, { |
| 233 |
styles: { |
| 234 |
...styles, |
| 235 |
borderRadius: { |
| 236 |
topLeft: isFirst ? newRadius.topLeft : "", |
| 237 |
topRight: isFirst ? newRadius.topRight : "", |
| 238 |
bottomRight: isLast ? newRadius.bottomRight : "", |
| 239 |
bottomLeft: isLast ? newRadius.bottomLeft : "", |
| 240 |
}, |
| 241 |
}, |
| 242 |
}); |
| 243 |
}); |
| 244 |
}); |
| 245 |
}; |
| 246 |
|
| 247 |
const columnBorder = info.columnBorder ?? EMPTY_BORDER; |
| 248 |
const columnRadius = info.columnRadius ?? EMPTY_CORNERS; |
| 249 |
const context: ColumnBorderContext = { |
| 250 |
columnBorderControlProps: { |
| 251 |
label: __("Column Border", "tableberg"), |
| 252 |
value: columnBorder, |
| 253 |
hasValue: () => hasBorder(columnBorder), |
| 254 |
onChange: (newBorder: Border) => applyToCells(newBorder), |
| 255 |
onDeselect: () => applyToCells(EMPTY_BORDER), |
| 256 |
}, |
| 257 |
columnRadiusControlProps: { |
| 258 |
label: __("Column Border Radius", "tableberg"), |
| 259 |
value: columnRadius, |
| 260 |
hasValue: () => hasCorners(columnRadius), |
| 261 |
onChange: (newRadius: Corners) => applyRadiusToCells(newRadius), |
| 262 |
onDeselect: () => applyRadiusToCells(EMPTY_CORNERS), |
| 263 |
}, |
| 264 |
}; |
| 265 |
|
| 266 |
return ( |
| 267 |
<InspectorControls group="border"> |
| 268 |
{ProColumnBorderContent ? ( |
| 269 |
ProColumnBorderContent(context) |
| 270 |
) : ( |
| 271 |
<> |
| 272 |
<LockedControl isEnhanced selected="col-border"> |
| 273 |
<BorderControl {...context.columnBorderControlProps} /> |
| 274 |
</LockedControl> |
| 275 |
<LockedControl isEnhanced selected="col-border-radius"> |
| 276 |
<BorderRadiusControl |
| 277 |
{...context.columnRadiusControlProps} |
| 278 |
/> |
| 279 |
</LockedControl> |
| 280 |
</> |
| 281 |
)} |
| 282 |
</InspectorControls> |
| 283 |
); |
| 284 |
} |
| 285 |
|