A table looks simple until a user merges two cells in the middle of a row, deletes a column that a merged cell spans, and then pastes the result into a spreadsheet. HTML tables are a grid with holes in it: a cell withcolspan="2" covers the space of a neighbour, and that neighbour must not exist in the markup. Every table feature in an editor is a rule about keeping that grid consistent.
Work on a grid, not on the markup
Commands like “insert column” and “merge cells” are much easier to write against a grid model than against the DOM. Read the table once into a two-dimensional array, where each slot points to the cell that covers it. A merged cell occupies several slots that all point to the same cell:
type Slot = { cell: HTMLTableCellElement; origin: boolean };
// grid[row][col] is the cell covering that position. origin is true only for the
// top-left slot of a cell, the one that holds its markup.
function readGrid(table: HTMLTableElement): Slot[][] {
const grid: Slot[][] = [];
[...table.rows].forEach((row, r) => {
grid[r] = grid[r] ?? [];
let c = 0;
for (const cell of [...row.cells]) {
while (grid[r][c]) c++; // skip slots covered by a rowspan above
const rowspan = cell.rowSpan, colspan = cell.colSpan;
for (let dr = 0; dr < rowspan; dr++) {
grid[r + dr] = grid[r + dr] ?? [];
for (let dc = 0; dc < colspan; dc++) {
grid[r + dr][c + dc] = { cell, origin: dr === 0 && dc === 0 };
}
}
c += colspan;
}
});
return grid;
}With that structure, inserting a column is a loop over rows that finds the right slot, and deleting a column has one rule to remember: if a merged cell spans the deleted column, shrink its colspan instead of removing it. Writing the rule once, against the grid, avoids a dozen special cases in the DOM code.
Merging and splitting
Merging is the operation that most often leaves broken markup. The merged cell takes the top-left position, its colspan and rowspan grow to cover the selection, and every other cell in the selection is removed. The content of the removed cells is usually moved into the merged cell, one paragraph after another, so the user does not lose text. Decide that rule before you write the code, because users will test it with a cell that contains a list.
Splitting is the reverse, but it has no single correct answer. A cell with colspan="3" split into three cells can put the content in the first one, or repeat it across all three. Pick the behaviour that matches your spreadsheets, and document it in the help text.
Validate spans when you save
colspan and rowspan come from the document, so they must be checked when it is read back, especially after paste. A value of 10000 creates a huge grid in memory and a layout nobody can use. A value of zero or a negative number is invalid HTML. Clamp both to a sensible range:
const MAX_SPAN = 50;
function cleanSpan(value: string | null): number {
const n = Number.parseInt(value ?? "1", 10);
if (!Number.isFinite(n) || n < 1) return 1;
return Math.min(n, MAX_SPAN);
}Apply it in the same place where other attributes are checked, and set the cell’s spans from the cleaned value. Pasted tables from Word often carry colspanvalues that are correct for Word’s own grid and wrong for yours. Rebuild the grid after the clamp, and check that every row still has the expected number of slots.
Copying a table to a spreadsheet
Users copy tables to Excel, Google Sheets and Numbers all the time. A browser puts the selection on the clipboard as HTML and as plain text, and the spreadsheet reads whichever it understands best. Three things make that paste go wrong:
- Merged cells. Spreadsheets handle a merged cell by filling the area, and a merge across several columns often lands in the wrong column. Warn users when they copy a table with merges, or offer a copy without them.
- Numbers stored as text. A cell that holds
1,250with a thousands separator pastes as text in some locales. Keep the value in the cell and format it with CSS or a data attribute, so the number stays a number in the spreadsheet. - Hidden content. A table cell containing a
display:noneelement, or a nested table, can paste as extra columns. Strip hidden elements from the copied fragment before it reaches the clipboard.
Test the copy with the spreadsheet you actually use, because each one has its own rules. A table that pastes perfectly into one can still break in another.
Mark up headers so screen readers can follow the table
A screen reader reads a table cell by cell, and it announces the header that goes with each cell. That only works when the header is marked as a header. Use th for header cells, set scope="col" or scope="row" on them, and give the table a caption that says what it shows. A new table in the editor should start with a header row, and the user should be able to turn it off deliberately, not by accident.
A short test list
- Merge a 2 by 2 block that contains a list, then split it. Check that no text disappears.
- Delete a column that a merged cell spans. The merged cell should narrow, not vanish.
- Paste a Word table with merged headers, and check the spans are clamped and the row count is unchanged.
- Copy a table with a number formatted with a thousands separator into a spreadsheet, and confirm it stays numeric.
- Navigate the table with a screen reader and hear the column header read for each cell.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.