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 / grid-model.ts

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

395 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Pure, WP-free structural model for the native table's span-aware
3 * operations. The block tree is reduced to anchors-only rows; every plan
4 * function returns instructions for the dispatcher in table-ops.ts.
5 *
6 * Positions: row = row-block index, col = visual grid column. A cell with
7 * rowSpan/colSpan occupies a rectangle anchored at its position; covered
8 * positions hold no cell.
9 */
10
11 export interface GridCell {
12 id: string;
13 rowSpan: number;
14 colSpan: number;
15 }
16
17 export type GridRow = GridCell[];
18
19 export interface CellPosition {
20 row: number;
21 col: number;
22 }
23
24 export interface Occupancy {
25 /** occupancy[row][col] = id of the cell covering that position. */
26 matrix: string[][];
27 /** Anchor position for each cell id. */
28 anchors: Map<string, CellPosition>;
29 cols: number;
30 }
31
32 export function buildOccupancy(rows: GridRow[]): Occupancy {
33 const matrix: string[][] = rows.map(() => []);
34 const anchors = new Map<string, CellPosition>();
35 let cols = 0;
36
37 rows.forEach((row, r) => {
38 let c = 0;
39 for (const cell of row) {
40 while (matrix[r][c] !== undefined) {
41 c++;
42 }
43
44 anchors.set(cell.id, { row: r, col: c });
45
46 for (let dr = 0; dr < cell.rowSpan; dr++) {
47 for (let dc = 0; dc < cell.colSpan; dc++) {
48 if (matrix[r + dr]) {
49 matrix[r + dr][c + dc] = cell.id;
50 }
51 }
52 }
53
54 c += cell.colSpan;
55 if (c > cols) {
56 cols = c;
57 }
58 }
59
60 // Trailing covered positions widen the grid too.
61 while (matrix[r][c] !== undefined) {
62 c++;
63 if (c > cols) {
64 cols = c;
65 }
66 }
67 });
68
69 return { matrix, anchors, cols };
70 }
71
72 export interface InsertRowPlan {
73 /** Cells whose rowSpan grows by one (they cross the insertion line). */
74 growSpans: string[];
75 /** Number of fresh cells the new row block needs. */
76 newCellCount: number;
77 }
78
79 /**
80 * Plan inserting a row so the new row sits at `index` (0..rows.length).
81 * A span crosses the line when its anchor row < index and it extends to
82 * index or beyond.
83 */
84 export function planInsertRow(rows: GridRow[], index: number): InsertRowPlan {
85 const { anchors, cols } = buildOccupancy(rows);
86
87 const growSpans: string[] = [];
88 let coveredCols = 0;
89
90 for (const row of rows) {
91 for (const cell of row) {
92 const pos = anchors.get(cell.id)!;
93 if (pos.row < index && pos.row + cell.rowSpan > index) {
94 growSpans.push(cell.id);
95 coveredCols += cell.colSpan;
96 }
97 }
98 }
99
100 return { growSpans, newCellCount: Math.max(0, cols - coveredCols) };
101 }
102
103 export interface DeleteRowPlan {
104 /** Cells whose rowSpan shrinks by one (they cross the deleted row). */
105 shrinkSpans: string[];
106 /**
107 * Cells anchored IN the deleted row that span further down: they must be
108 * re-anchored into the next row. `insertIndex` is the cell-array index in
109 * the next row block where the replacement belongs.
110 */
111 reanchor: Array<{
112 id: string;
113 insertIndex: number;
114 rowSpan: number;
115 colSpan: number;
116 }>;
117 }
118
119 export function planDeleteRow(rows: GridRow[], index: number): DeleteRowPlan {
120 const { matrix, anchors } = buildOccupancy(rows);
121
122 const shrinkSpans: string[] = [];
123 const reanchor: DeleteRowPlan["reanchor"] = [];
124
125 for (const row of rows) {
126 for (const cell of row) {
127 const pos = anchors.get(cell.id)!;
128 const crosses =
129 pos.row < index && pos.row + cell.rowSpan > index;
130 if (crosses) {
131 shrinkSpans.push(cell.id);
132 }
133 }
134 }
135
136 const nextRow = rows[index + 1];
137 if (nextRow) {
138 for (const cell of rows[index]) {
139 const pos = anchors.get(cell.id)!;
140 if (cell.rowSpan <= 1) {
141 continue;
142 }
143
144 // Where in the next row's cell array does this column fall?
145 // Count next-row anchors whose column is left of ours.
146 let insertIndex = 0;
147 for (const nextCell of nextRow) {
148 const nextPos = anchors.get(nextCell.id)!;
149 if (nextPos.col < pos.col) {
150 insertIndex++;
151 }
152 }
153
154 reanchor.push({
155 id: cell.id,
156 insertIndex,
157 rowSpan: cell.rowSpan - 1,
158 colSpan: cell.colSpan,
159 });
160 }
161 }
162
163 return { shrinkSpans, reanchor };
164 }
165
166 export type ColumnRowAction =
167 | { type: "insert"; rowIndex: number; insertIndex: number }
168 | { type: "grow"; rowIndex: number; id: string }
169 | { type: "skip"; rowIndex: number };
170
171 /**
172 * Plan inserting a column so the new column sits at grid column `index`
173 * (0..cols). Per row: insert a fresh cell before the anchor at/after the
174 * position, or grow a span that crosses the line, or skip rows covered by a
175 * multi-row span already grown at its anchor row.
176 */
177 export function planInsertColumn(
178 rows: GridRow[],
179 index: number
180 ): ColumnRowAction[] {
181 const { matrix, anchors, cols } = buildOccupancy(rows);
182 const actions: ColumnRowAction[] = [];
183 const grown = new Set<string>();
184
185 rows.forEach((row, r) => {
186 // Inserting at the far edges never splits a span.
187 if (index < cols) {
188 const coveringId = matrix[r][index];
189 if (coveringId !== undefined) {
190 const pos = anchors.get(coveringId)!;
191 // The line between index-1 and index runs through this cell
192 // when its anchor starts left of index.
193 if (pos.col < index) {
194 if (pos.row === r) {
195 actions.push({ type: "grow", rowIndex: r, id: coveringId });
196 grown.add(coveringId);
197 } else {
198 // Covered by a span anchored in an earlier row.
199 actions.push({ type: "skip", rowIndex: r });
200 }
201 return;
202 }
203 }
204 }
205
206 // Fresh cell: its array position = number of anchors in this row
207 // whose column is left of the target column.
208 let insertIndex = 0;
209 for (const cell of row) {
210 const pos = anchors.get(cell.id)!;
211 if (pos.col < index) {
212 insertIndex++;
213 }
214 }
215
216 actions.push({ type: "insert", rowIndex: r, insertIndex });
217 });
218
219 return actions;
220 }
221
222 export type DeleteColumnRowAction =
223 | { type: "remove"; rowIndex: number; id: string }
224 | { type: "shrink"; rowIndex: number; id: string }
225 | { type: "skip"; rowIndex: number };
226
227 /** Plan deleting grid column `index`. */
228 export function planDeleteColumn(
229 rows: GridRow[],
230 index: number
231 ): DeleteColumnRowAction[] {
232 const { matrix, anchors } = buildOccupancy(rows);
233 const actions: DeleteColumnRowAction[] = [];
234 const handled = new Set<string>();
235
236 rows.forEach((row, r) => {
237 const id = matrix[r][index];
238 if (id === undefined) {
239 actions.push({ type: "skip", rowIndex: r });
240 return;
241 }
242
243 const pos = anchors.get(id)!;
244 if (pos.row !== r) {
245 // Covered by a span anchored above; handled at its anchor row.
246 actions.push({ type: "skip", rowIndex: r });
247 return;
248 }
249
250 if (handled.has(id)) {
251 actions.push({ type: "skip", rowIndex: r });
252 return;
253 }
254 handled.add(id);
255
256 const cell = findCell(rows, id)!;
257 if (cell.colSpan > 1) {
258 actions.push({ type: "shrink", rowIndex: r, id });
259 } else {
260 actions.push({ type: "remove", rowIndex: r, id });
261 }
262 });
263
264 return actions;
265 }
266
267 export interface MergePlan {
268 anchorId: string;
269 rowSpan: number;
270 colSpan: number;
271 absorbedIds: string[];
272 }
273
274 /**
275 * Plan merging the given cells. Valid only when the cells' combined
276 * rectangles exactly tile a rectangle. Returns null when invalid.
277 */
278 export function planMerge(rows: GridRow[], ids: string[]): MergePlan | null {
279 if (ids.length < 2) {
280 return null;
281 }
282
283 const { anchors } = buildOccupancy(rows);
284
285 let minRow = Infinity;
286 let minCol = Infinity;
287 let maxRow = -1;
288 let maxCol = -1;
289 let area = 0;
290
291 for (const id of ids) {
292 const pos = anchors.get(id);
293 const cell = findCell(rows, id);
294 if (!pos || !cell) {
295 return null;
296 }
297
298 minRow = Math.min(minRow, pos.row);
299 minCol = Math.min(minCol, pos.col);
300 maxRow = Math.max(maxRow, pos.row + cell.rowSpan - 1);
301 maxCol = Math.max(maxCol, pos.col + cell.colSpan - 1);
302 area += cell.rowSpan * cell.colSpan;
303 }
304
305 const rectArea = (maxRow - minRow + 1) * (maxCol - minCol + 1);
306 if (area !== rectArea) {
307 return null;
308 }
309
310 // The anchor is the top-left cell; it must sit exactly at the rect corner.
311 let anchorId: string | null = null;
312 for (const id of ids) {
313 const pos = anchors.get(id)!;
314 if (pos.row === minRow && pos.col === minCol) {
315 anchorId = id;
316 break;
317 }
318 }
319 if (!anchorId) {
320 return null;
321 }
322
323 return {
324 anchorId,
325 rowSpan: maxRow - minRow + 1,
326 colSpan: maxCol - minCol + 1,
327 absorbedIds: ids.filter(id => id !== anchorId),
328 };
329 }
330
331 export interface SplitPlan {
332 /** Fresh 1x1 cells to insert: per row, the cell-array insertion index. */
333 inserts: Array<{ rowIndex: number; insertIndex: number; count: number }>;
334 }
335
336 /** Plan splitting a merged cell back into 1x1 cells. */
337 export function planSplit(rows: GridRow[], id: string): SplitPlan | null {
338 const { anchors } = buildOccupancy(rows);
339 const pos = anchors.get(id);
340 const cell = findCell(rows, id);
341 if (!pos || !cell || (cell.rowSpan === 1 && cell.colSpan === 1)) {
342 return null;
343 }
344
345 const inserts: SplitPlan["inserts"] = [];
346
347 for (let dr = 0; dr < cell.rowSpan; dr++) {
348 const r = pos.row + dr;
349 const row = rows[r];
350 if (!row) {
351 continue;
352 }
353
354 // In the anchor's own row the anchor keeps the first position, so
355 // colSpan-1 fresh cells go right after it. In covered rows all
356 // colSpan positions need fresh cells.
357 const count = dr === 0 ? cell.colSpan - 1 : cell.colSpan;
358 if (count === 0) {
359 continue;
360 }
361
362 let insertIndex = 0;
363 for (const rowCell of row) {
364 const rowPos = anchors.get(rowCell.id)!;
365 if (
366 rowPos.col < pos.col ||
367 (dr === 0 && rowCell.id === id)
368 ) {
369 insertIndex++;
370 }
371 }
372
373 inserts.push({ rowIndex: r, insertIndex, count });
374 }
375
376 return { inserts };
377 }
378
379 export function findCell(rows: GridRow[], id: string): GridCell | null {
380 for (const row of rows) {
381 for (const cell of row) {
382 if (cell.id === id) {
383 return cell;
384 }
385 }
386 }
387 return null;
388 }
389
390 /** Grid column of a cell (for toolbar labels / column ops entry points). */
391 export function cellColumn(rows: GridRow[], id: string): number | null {
392 const { anchors } = buildOccupancy(rows);
393 return anchors.get(id)?.col ?? null;
394 }
395