Engineering

Tables in a rich text editor: merging, spans and copying to spreadsheets

HTML tables are a grid with holes. Work on the grid, merge and split with one rule each, clamp untrusted spans, and check what a copied table does in a spreadsheet.

8 min read

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:

Code example
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:

Code example
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:

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


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.