Drag and Drop With Merged Cells: How TAFNE Moves Columns Without Corrupting the Grid
Drag-and-drop column reordering sounds simple. It's not, once merged cells enter the picture.
A column in a regular flat table is easy to define: it's everything in the nth child position of every row. Grab that, move it, done.
But if any cell in the table has a colspan, if it spans multiple columns, the concept of "the nth column" breaks down completely. A cell with colspan="2" occupies two column positions but exists at one DOM node. Moving the column naively moves the wrong things, or moves nothing at all in rows where that spanning cell is the origin.
TAFNE's drag-and-drop system handles this correctly by delegating all column-position reasoning to the VisualGridMapper.
How Columns Are Tracked
When drag-and-drop is enabled, TAFNE injects a dedicated drag row at the top of the table, a row of handle cells, one per visual column:
for (let i = 0; i < maxCols; i++) {
dragRowHtml += <td class="drag-handle col-handle" data-col-index="${i}">::</td>;
}
Each handle is stamped with its visual column index as a data-col-index attribute. The maxCols value comes from mapper.maxCols, which is the total number of visual columns in the grid, accounting for all spanning. This is not the same as the number of cells in the first row.
When a column handle is grabbed, the drag starts with startColumnDrag(colIndex, e) where colIndex is the visual column index of the handle that was grabbed.
Finding Where to Drop
During the drag, mousemove events compute which column the cursor is closest to by measuring the bounding rect of each handle:
const colEdges = [];
$dragRow.find('.col-handle').each(function() {
const rect = this.getBoundingClientRect();
const idx = parseInt($(this).attr('data-col-index'), 10);
colEdges.push({ left: rect.left, right: rect.right, center: (rect.left + rect.right) / 2, colIdx: idx });
});
The center of each handle is computed. As the mouse moves, the nearest center determines the target column index. This gives the user a precise, responsive drop indicator that updates in real time.
Moving the Column: The Mapper's Role
On mouseup, moveColumn(fromIndex, toIndex) is called. This is where the VisualGridMapper does the actual work:
const mapper = new window.VisualGridMapper($table);
const movedElements = new Set();
const COL_OFFSET = 1; // account for the row-handle column
for (let r = 0; r < mapper.maxRows; r++) { const fromCellData = mapper.grid[r][mapperFrom];
if (fromCellData && fromCellData.isOrigin && !movedElements.has(fromCellData.element)) { movedElements.add(fromCellData.element); const $moving = $(fromCellData.element); // ... find target position and insert } }
The function iterates over every row. For each row, it checks whether the visual column at mapperFrom contains an origin cell, one that actually lives in the DOM, as opposed to a slot occupied by a spanning cell from another position.
This is the key distinction. A cell with colspan="3" appears in three column positions in the mapper's grid, but it only has one DOM element. The isOrigin flag marks which grid slot is the actual node. If we moved the DOM element every time we encountered it in the grid, we'd move it multiple times per drag, inserting it in the wrong place each iteration.
The movedElements Set tracks which elements have already been moved. Once a cell has been inserted in its new position, any subsequent encounter with that element in the grid is skipped.
Handling Spanning Cells at the Target
The insertion point depends on what's at the target column:
let targetCellData = rowData[mapperTo];
if (targetCellData && targetCellData.isOrigin && targetCellData.element !== fromCellData.element) { $(targetCellData.element).before($moving); } else if (!targetCellData) { $moving.closest('tr').append($moving); } else { // Target is inside a colspan, find the next origin cell to the right for (let c = mapperTo; c < mapper.maxCols; c++) { if (rowData[c] && rowData[c].isOrigin && rowData[c].element !== fromCellData.element) { foundOrigin = rowData[c].element; break; } } if (foundOrigin) $(foundOrigin).before($moving); else $moving.closest('tr').append($moving); }
If the target column has an origin cell, the moving cell is inserted before it. If the target is empty (past the end of a short row), the cell is appended. If the target is inside a colspan, meaning its origin is somewhere to the left, the function scans right until it finds the next origin, then inserts before that.
This last case is what makes the system robust against tables with horizontal spanning. The moving cell always lands in the correct visual position, regardless of the spanning patterns around it.
Saving the State
The last line of moveColumn:
if (typeof window.saveCurrentState === 'function') window.saveCurrentState();
The column move is pushed onto the undo stack. Ctrl+Z undoes it.
Getting drag-and-drop right with merged cells required the VisualGridMapper. Without the ability to query visual column position independently of DOM structure, this feature would either fail silently or corrupt tables that use spanning. The mapper is what makes it correct.
Source: github.com/carnworkstudios/TAFNE