Skip to content

Commit e8f1c10

Browse files
authored
fix: issue with vertical cells merging (#2387)
* fix: issue with vertical cells merging * fix: don't consume merged cols twice * chore: add test case for _vMergeConsumed: true
1 parent de197c7 commit e8f1c10

4 files changed

Lines changed: 237 additions & 10 deletions

File tree

packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,28 +105,27 @@ export function handleTableCellNode({
105105
const rows = table.elements.filter((el) => el.name === 'w:tr');
106106
const currentRowIndex = rows.findIndex((r) => r === row);
107107
const remainingRows = rows.slice(currentRowIndex + 1);
108-
109-
const cellsInRow = row.elements.filter((el) => el.name === 'w:tc');
110-
let cellIndex = cellsInRow.findIndex((el) => el === node);
111108
let rowspan = 1;
109+
const startColumn = Number.isFinite(columnIndex) ? columnIndex : 0;
112110

113-
// Iterate through all remaining rows after the current cell, and find all cells that need to be merged
111+
// Continue the merge by matching cells on the logical table grid, not by raw tc index.
112+
// This keeps vertical merges aligned when rows have gridBefore or different preceding spans.
114113
for (let remainingRow of remainingRows) {
115-
const firstCell = remainingRow.elements.findIndex((el) => el.name === 'w:tc');
116-
const cellAtIndex = remainingRow.elements[firstCell + cellIndex];
114+
const cellAtColumn = findTableCellAtColumn(remainingRow, startColumn);
117115

118-
if (!cellAtIndex) break;
116+
if (!cellAtColumn) break;
119117

120-
const vMerge = getTableCellVMerge(cellAtIndex);
118+
const vMerge = getTableCellVMerge(cellAtColumn);
121119

122120
if (!vMerge || vMerge === 'restart') {
123121
// We have reached the end of the vertically merged cells
124122
break;
125123
}
126124

127-
// This cell is part of a merged cell, merge it (remove it from its row)
125+
// This cell is part of a merged cell. Mark it consumed so the row encoder skips it
126+
// but still advances the column index (grid geometry is preserved).
128127
rowspan++;
129-
remainingRow.elements.splice(firstCell + cellIndex, 1);
128+
markTableCellAsVMergeConsumed(cellAtColumn);
130129
}
131130
attributes['rowspan'] = rowspan;
132131
}
@@ -256,6 +255,45 @@ const getTableCellVMerge = (node) => {
256255
return vMerge.attributes?.['w:val'] || 'continue';
257256
};
258257

258+
const getGridBefore = (row) => {
259+
const trPr = row.elements?.find((el) => el.name === 'w:trPr');
260+
const gridBefore = trPr?.elements?.find((el) => el.name === 'w:gridBefore');
261+
const raw = gridBefore?.attributes?.['w:val'];
262+
const value = typeof raw === 'string' ? parseInt(raw, 10) : raw;
263+
return Number.isFinite(value) && value > 0 ? value : 0;
264+
};
265+
266+
const getTableCellGridSpan = (node) => {
267+
if (!node || node.name !== 'w:tc') return 1;
268+
const tcPr = node.elements?.find((el) => el.name === 'w:tcPr');
269+
const gridSpan = tcPr?.elements?.find((el) => el.name === 'w:gridSpan');
270+
const raw = gridSpan?.attributes?.['w:val'];
271+
const value = typeof raw === 'string' ? parseInt(raw, 10) : raw;
272+
return Number.isFinite(value) && value > 0 ? value : 1;
273+
};
274+
275+
const findTableCellAtColumn = (row, targetColumn) => {
276+
const cells = row.elements?.filter((el) => el.name === 'w:tc') ?? [];
277+
let currentColumn = getGridBefore(row);
278+
279+
for (const cell of cells) {
280+
const colSpan = getTableCellGridSpan(cell);
281+
if (targetColumn >= currentColumn && targetColumn < currentColumn + colSpan) {
282+
return cell._vMergeConsumed ? null : cell;
283+
}
284+
currentColumn += colSpan;
285+
}
286+
287+
return null;
288+
};
289+
290+
/** Mark a w:tc as consumed by a vertical merge so the row encoder skips it. */
291+
const markTableCellAsVMergeConsumed = (node) => {
292+
if (node?.name === 'w:tc') {
293+
node._vMergeConsumed = true;
294+
}
295+
};
296+
259297
/**
260298
* Process the margins for a table cell
261299
* @param {Object} inlineMargins

packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,144 @@ describe('legacy-handle-table-cell-node', () => {
136136
expect(out.attrs.rowspan).toBe(3);
137137
});
138138

139+
it('resolves vertical merge continuations by logical grid column when rows use gridBefore', () => {
140+
const cellNode = {
141+
name: 'w:tc',
142+
elements: [
143+
{
144+
name: 'w:tcPr',
145+
elements: [
146+
{ name: 'w:vMerge', attributes: { 'w:val': 'restart' } },
147+
{ name: 'w:shd', attributes: { 'w:fill': '006A72' } },
148+
],
149+
},
150+
{ name: 'w:p' },
151+
],
152+
};
153+
154+
const row1 = {
155+
name: 'w:tr',
156+
elements: [
157+
{
158+
name: 'w:trPr',
159+
elements: [{ name: 'w:gridBefore', attributes: { 'w:val': '1' } }],
160+
},
161+
cellNode,
162+
{ name: 'w:tc', elements: [{ name: 'w:p' }] },
163+
],
164+
};
165+
166+
const row2 = {
167+
name: 'w:tr',
168+
elements: [
169+
{
170+
name: 'w:trPr',
171+
elements: [{ name: 'w:gridBefore', attributes: { 'w:val': '1' } }],
172+
},
173+
{
174+
name: 'w:tc',
175+
elements: [{ name: 'w:tcPr', elements: [{ name: 'w:vMerge' }] }, { name: 'w:p' }],
176+
},
177+
{ name: 'w:tc', elements: [{ name: 'w:p' }] },
178+
],
179+
};
180+
181+
const table = { name: 'w:tbl', elements: [row1, row2] };
182+
const params = {
183+
docx: {},
184+
nodeListHandler: { handler: vi.fn(() => 'CONTENT') },
185+
path: [],
186+
editor: createEditorStub(),
187+
};
188+
189+
const out = handleTableCellNode({
190+
params,
191+
node: cellNode,
192+
table,
193+
row: row1,
194+
columnIndex: 1,
195+
columnWidth: null,
196+
allColumnWidths: [90, 100, 110],
197+
_referencedStyles: null,
198+
});
199+
200+
expect(out.attrs.background).toEqual({ color: '006A72' });
201+
expect(out.attrs.rowspan).toBe(2);
202+
const row2Cells = row2.elements.filter((el) => el.name === 'w:tc');
203+
expect(row2Cells).toHaveLength(2);
204+
expect(row2Cells[0]._vMergeConsumed).toBe(true);
205+
});
206+
207+
it('preserves later merge-column alignment after removing an earlier continuation cell', () => {
208+
const firstRestart = {
209+
name: 'w:tc',
210+
elements: [
211+
{ name: 'w:tcPr', elements: [{ name: 'w:vMerge', attributes: { 'w:val': 'restart' } }] },
212+
{ name: 'w:p' },
213+
],
214+
};
215+
const secondRestart = {
216+
name: 'w:tc',
217+
elements: [
218+
{
219+
name: 'w:tcPr',
220+
elements: [
221+
{ name: 'w:vMerge', attributes: { 'w:val': 'restart' } },
222+
{ name: 'w:shd', attributes: { 'w:fill': '006A72' } },
223+
],
224+
},
225+
{ name: 'w:p' },
226+
],
227+
};
228+
const firstContinue = {
229+
name: 'w:tc',
230+
elements: [{ name: 'w:tcPr', elements: [{ name: 'w:vMerge' }] }, { name: 'w:p' }],
231+
};
232+
const secondContinue = {
233+
name: 'w:tc',
234+
elements: [{ name: 'w:tcPr', elements: [{ name: 'w:vMerge' }] }, { name: 'w:p' }],
235+
};
236+
237+
const row1 = { name: 'w:tr', elements: [firstRestart, secondRestart] };
238+
const row2 = { name: 'w:tr', elements: [firstContinue, secondContinue] };
239+
const table = { name: 'w:tbl', elements: [row1, row2] };
240+
const params = {
241+
docx: {},
242+
nodeListHandler: { handler: vi.fn(() => 'CONTENT') },
243+
path: [],
244+
editor: createEditorStub(),
245+
};
246+
247+
const outFirst = handleTableCellNode({
248+
params,
249+
node: firstRestart,
250+
table,
251+
row: row1,
252+
columnIndex: 0,
253+
columnWidth: null,
254+
allColumnWidths: [90, 100],
255+
_referencedStyles: null,
256+
});
257+
258+
const outSecond = handleTableCellNode({
259+
params,
260+
node: secondRestart,
261+
table,
262+
row: row1,
263+
columnIndex: 1,
264+
columnWidth: null,
265+
allColumnWidths: [90, 100],
266+
_referencedStyles: null,
267+
});
268+
269+
expect(outFirst.attrs.rowspan).toBe(2);
270+
expect(outSecond.attrs.rowspan).toBe(2);
271+
expect(outSecond.attrs.background).toEqual({ color: '006A72' });
272+
const row2Cells = row2.elements.filter((el) => el.name === 'w:tc');
273+
expect(row2Cells).toHaveLength(2);
274+
expect(row2Cells.every((tc) => tc._vMergeConsumed)).toBe(true);
275+
});
276+
139277
it('blends percentage table shading into a solid background color', () => {
140278
const cellNode = { name: 'w:tc', elements: [{ name: 'w:p' }] };
141279
const row = { name: 'w:tr', elements: [cellNode] };

packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ const encode = (params, encodedAttrs) => {
102102
skipOccupiedColumns();
103103

104104
const startColumn = currentColumnIndex;
105+
106+
// Cell was consumed by a vertical merge (rowspan) above.
107+
// At this point skipOccupiedColumns() has already advanced past the spanned columns
108+
// using pendingRowSpans, so we just skip encoding without touching currentColumnIndex.
109+
if (node._vMergeConsumed) return;
110+
105111
const columnWidth = gridColumnWidths?.[startColumn] || null;
106112

107113
const result = tcTranslator.encode({

packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,51 @@ describe('w:tr translator', () => {
186186
expect(trPrTranslator.encode).not.toHaveBeenCalled();
187187
expect(tcTranslator.encode).not.toHaveBeenCalled();
188188
});
189+
190+
it('skips vMerge-consumed cells without advancing the next encoded cell column', () => {
191+
const rowWithConsumedCell = {
192+
name: 'w:tr',
193+
elements: [
194+
{ name: 'w:tc', elements: [], _vMergeConsumed: true },
195+
{ name: 'w:tc', elements: [] },
196+
{ name: 'w:tc', elements: [] },
197+
],
198+
};
199+
const params = {
200+
nodes: [rowWithConsumedCell],
201+
extraParams: {
202+
row: rowWithConsumedCell,
203+
columnWidths: [100, 150, 200],
204+
activeRowSpans: [1, 0, 0],
205+
},
206+
};
207+
208+
const result = translator.encode(params, {});
209+
210+
expect(tcTranslator.encode).toHaveBeenCalledTimes(2);
211+
expect(tcTranslator.encode).toHaveBeenNthCalledWith(
212+
1,
213+
expect.objectContaining({
214+
extraParams: expect.objectContaining({
215+
node: rowWithConsumedCell.elements[1],
216+
columnIndex: 1,
217+
columnWidth: 150,
218+
}),
219+
}),
220+
);
221+
expect(tcTranslator.encode).toHaveBeenNthCalledWith(
222+
2,
223+
expect.objectContaining({
224+
extraParams: expect.objectContaining({
225+
node: rowWithConsumedCell.elements[2],
226+
columnIndex: 2,
227+
columnWidth: 200,
228+
}),
229+
}),
230+
);
231+
expect(result.content).toHaveLength(2);
232+
expect(result.content.map((cell) => cell.attrs.columnIndex)).toEqual([1, 2]);
233+
});
189234
});
190235

191236
describe('decode', () => {

0 commit comments

Comments
 (0)