PluginProbe
Tableberg – Simple Gutenberg Table Block / 1.1.5
Tableberg – Simple Gutenberg Table Block v1.1.5
1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 trunk 0.0.2 0.2.1 0.3.2 0.3.3 0.4.1 0.5.0 0.5.1 0.5.2 0.5.3 0.5.4 0.5.5 0.5.6 0.5.7 All 42 releases
tableberg / src / blocks / table / table-ops.ts

table-ops.ts in Tableberg – Simple Gutenberg Table Block 1.1.5, at src/blocks/table/table-ops.ts

363 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { store as blockEditorStore } from "@wordpress/block-editor";
2 import { BlockInstance, cloneBlock, createBlock } from "@wordpress/blocks";
3
4 import {
5 GridRow,
6 buildOccupancy,
7 cellColumn,
8 planDeleteColumn,
9 planDeleteRow,
10 planInsertColumn,
11 planInsertRow,
12 planMerge,
13 planSplit,
14 } from "./grid-model";
15
16 /**
17 * Structural table operations over the native block tree. Every op runs in
18 * registry.batch() so it lands as a single undo step. The span-aware
19 * decisions live in grid-model.ts (pure + unit-tested); this module only
20 * reads the tree and dispatches the plan.
21 */
22
23 // The data registry instance, obtained via useRegistry() in components.
24 type Registry = {
25 batch: (fn: () => void) => void;
26 select: (store: unknown) => any;
27 dispatch: (store: unknown) => any;
28 };
29
30 function freshCell(): BlockInstance {
31 return createBlock("tableberg/cell", {}, [createBlock("tableberg/text")]);
32 }
33
34 export function readGrid(registry: Registry, tableClientId: string) {
35 const be = registry.select(blockEditorStore);
36 const rowBlocks: BlockInstance[] = (
37 be.getBlock(tableClientId)?.innerBlocks ?? []
38 ).filter((b: BlockInstance) => b.name === "tableberg/row");
39
40 const rows: GridRow[] = rowBlocks.map(rowBlock =>
41 rowBlock.innerBlocks
42 .filter(b => b.name === "tableberg/cell")
43 .map(cellBlock => ({
44 id: cellBlock.clientId,
45 rowSpan: cellBlock.attributes?.span?.rowSpan ?? 1,
46 colSpan: cellBlock.attributes?.span?.colSpan ?? 1,
47 }))
48 );
49
50 return { rows, rowBlocks };
51 }
52
53 function spanAttr(rowSpan: number, colSpan: number) {
54 // Always write an explicit span: updateBlockAttributes ignores keys set
55 // to undefined, so a merged cell could never reset back to 1x1.
56 return { span: { rowSpan, colSpan } };
57 }
58
59 export function updateSpan(
60 registry: Registry,
61 rows: GridRow[],
62 clientId: string,
63 dRow: number,
64 dCol: number
65 ) {
66 const cell = rows.flat().find(c => c.id === clientId);
67 if (!cell) {
68 return;
69 }
70 registry
71 .dispatch(blockEditorStore)
72 .updateBlockAttributes(
73 clientId,
74 spanAttr(
75 Math.max(1, cell.rowSpan + dRow),
76 Math.max(1, cell.colSpan + dCol)
77 )
78 );
79 }
80
81 /** Locates a cell block's row/table/grid context. */
82 export function getCellContext(registry: Registry, cellClientId: string) {
83 const be = registry.select(blockEditorStore);
84 const rowClientId = be.getBlockRootClientId(cellClientId);
85 if (!rowClientId) {
86 return null;
87 }
88 const tableClientId = be.getBlockRootClientId(rowClientId);
89 if (!tableClientId) {
90 return null;
91 }
92 const rowIndex = be.getBlockIndex(rowClientId);
93 const { rows } = readGrid(registry, tableClientId);
94 const column = cellColumn(rows, cellClientId);
95
96 return { rowClientId, tableClientId, rowIndex, column, rows };
97 }
98
99 export function insertRow(
100 registry: Registry,
101 tableClientId: string,
102 index: number
103 ) {
104 const { rows } = readGrid(registry, tableClientId);
105 const plan = planInsertRow(rows, index);
106 const dispatch = registry.dispatch(blockEditorStore);
107
108 registry.batch(() => {
109 for (const id of plan.growSpans) {
110 updateSpan(registry, rows, id, 1, 0);
111 }
112
113 const cells = Array.from(
114 { length: Math.max(1, plan.newCellCount) },
115 freshCell
116 );
117 dispatch.insertBlocks(
118 createBlock("tableberg/row", {}, cells),
119 index,
120 tableClientId,
121 false
122 );
123 });
124 }
125
126 export function deleteRow(
127 registry: Registry,
128 tableClientId: string,
129 index: number
130 ) {
131 const { rows, rowBlocks } = readGrid(registry, tableClientId);
132 if (rowBlocks.length <= 1) {
133 return;
134 }
135
136 const plan = planDeleteRow(rows, index);
137 const dispatch = registry.dispatch(blockEditorStore);
138 const rowClientId = rowBlocks[index].clientId;
139 const nextRowClientId = rowBlocks[index + 1]?.clientId;
140
141 registry.batch(() => {
142 for (const id of plan.shrinkSpans) {
143 updateSpan(registry, rows, id, -1, 0);
144 }
145
146 // Move down-spanning cells (with their content) into the next row
147 // before the row block disappears.
148 for (const item of plan.reanchor) {
149 if (!nextRowClientId) {
150 break;
151 }
152 dispatch.moveBlocksToPosition(
153 [item.id],
154 rowClientId,
155 nextRowClientId,
156 item.insertIndex
157 );
158 dispatch.updateBlockAttributes(
159 item.id,
160 spanAttr(item.rowSpan, item.colSpan)
161 );
162 }
163
164 dispatch.removeBlocks([rowClientId], false);
165 });
166 }
167
168 export function insertColumn(
169 registry: Registry,
170 tableClientId: string,
171 index: number
172 ) {
173 const { rows, rowBlocks } = readGrid(registry, tableClientId);
174 const actions = planInsertColumn(rows, index);
175 const dispatch = registry.dispatch(blockEditorStore);
176
177 registry.batch(() => {
178 for (const action of actions) {
179 if (action.type === "grow") {
180 updateSpan(registry, rows, action.id, 0, 1);
181 } else if (action.type === "insert") {
182 dispatch.insertBlocks(
183 freshCell(),
184 action.insertIndex,
185 rowBlocks[action.rowIndex].clientId,
186 false
187 );
188 }
189 }
190 });
191 }
192
193 export function deleteColumn(
194 registry: Registry,
195 tableClientId: string,
196 index: number
197 ) {
198 const { rows } = readGrid(registry, tableClientId);
199 const actions = planDeleteColumn(rows, index);
200 const dispatch = registry.dispatch(blockEditorStore);
201
202 const removals = actions
203 .filter(a => a.type === "remove")
204 .map(a => (a as { id: string }).id);
205 const shrinks = actions
206 .filter(a => a.type === "shrink")
207 .map(a => (a as { id: string }).id);
208
209 // If every row would lose its only cell, keep the table intact.
210 const totalCols = Math.max(0, ...rows.map(row =>
211 row.reduce((sum, c) => sum + c.colSpan, 0)
212 ));
213 if (totalCols <= 1) {
214 return;
215 }
216
217 registry.batch(() => {
218 for (const id of shrinks) {
219 updateSpan(registry, rows, id, 0, -1);
220 }
221 if (removals.length > 0) {
222 dispatch.removeBlocks(removals, false);
223 }
224 });
225 }
226
227
228 /**
229 * Duplicates a row directly below itself. Cells whose rowSpan crosses the
230 * insertion line (including this row's own multi-row anchors) grow by one
231 * instead of being cloned, keeping the grid consistent.
232 */
233 export function mergeCells(
234 registry: Registry,
235 tableClientId: string,
236 cellClientIds: string[]
237 ): boolean {
238 const { rows } = readGrid(registry, tableClientId);
239 const plan = planMerge(rows, cellClientIds);
240 if (!plan) {
241 return false;
242 }
243
244 const be = registry.select(blockEditorStore);
245 const dispatch = registry.dispatch(blockEditorStore);
246
247 const { anchors } = buildOccupancy(rows);
248 const anchorPos = anchors.get(plan.anchorId);
249
250 registry.batch(() => {
251 for (const absorbedId of plan.absorbedIds) {
252 const children: string[] = be.getBlockOrder(absorbedId);
253 if (children.length > 0) {
254 // Remember where each element came from so a later split can
255 // put it back into its original cell.
256 const absorbedPos = anchors.get(absorbedId);
257 if (anchorPos && absorbedPos) {
258 const origin = {
259 dr: absorbedPos.row - anchorPos.row,
260 dc: absorbedPos.col - anchorPos.col,
261 };
262 for (const childId of children) {
263 dispatch.updateBlockAttributes(childId, {
264 mergeOrigin: origin,
265 });
266 }
267 }
268
269 const anchorCount = be.getBlockOrder(plan.anchorId).length;
270 dispatch.moveBlocksToPosition(
271 children,
272 absorbedId,
273 plan.anchorId,
274 anchorCount
275 );
276 }
277 }
278
279 dispatch.updateBlockAttributes(
280 plan.anchorId,
281 spanAttr(plan.rowSpan, plan.colSpan)
282 );
283 dispatch.removeBlocks(plan.absorbedIds, false);
284 });
285
286 return true;
287 }
288
289 export function splitCell(
290 registry: Registry,
291 tableClientId: string,
292 cellClientId: string
293 ) {
294 const { rows, rowBlocks } = readGrid(registry, tableClientId);
295 const plan = planSplit(rows, cellClientId);
296 if (!plan) {
297 return;
298 }
299
300 const be = registry.select(blockEditorStore);
301 const dispatch = registry.dispatch(blockEditorStore);
302
303 const { anchors } = buildOccupancy(rows);
304 const anchorPos = anchors.get(cellClientId);
305 const anchorCell = rows.flat().find(c => c.id === cellClientId);
306
307 // Offset (dr,dc) -> the fresh cell created for that grid position.
308 const freshByOffset = new Map<string, ReturnType<typeof freshCell>>();
309
310 registry.batch(() => {
311 dispatch.updateBlockAttributes(cellClientId, {
312 span: { rowSpan: 1, colSpan: 1 },
313 });
314
315 for (const item of plan.inserts) {
316 const dr = anchorPos ? item.rowIndex - anchorPos.row : 0;
317 const cells = Array.from({ length: item.count }, (_, i) => {
318 const cell = freshCell();
319 const dc = dr === 0 ? i + 1 : i;
320 freshByOffset.set(`${dr},${dc}`, cell);
321 return cell;
322 });
323 dispatch.insertBlocks(
324 cells,
325 item.insertIndex,
326 rowBlocks[item.rowIndex].clientId,
327 false
328 );
329 }
330
331 // Send elements that were absorbed during a merge back to the cell
332 // at their original offset; everything else stays in the anchor.
333 const children: string[] = be.getBlockOrder(cellClientId);
334 for (const childId of children) {
335 const origin = be.getBlockAttributes(childId)?.mergeOrigin as
336 | { dr: number; dc: number }
337 | undefined;
338 if (!origin) {
339 continue;
340 }
341
342 const target = freshByOffset.get(`${origin.dr},${origin.dc}`);
343 // null, not undefined — updateBlockAttributes ignores undefined.
344 dispatch.updateBlockAttributes(childId, {
345 mergeOrigin: null,
346 });
347 if (!target) {
348 continue;
349 }
350
351 const targetCount = be.getBlockOrder(target.clientId).length;
352 dispatch.moveBlocksToPosition(
353 [childId],
354 cellClientId,
355 target.clientId,
356 targetCount
357 );
358 }
359 });
360
361 void anchorCell;
362 }
363