Table Node Type

Category: Complex • Detailed implementation guide with view integration notes

Schema Definition

Schema definition for the Table node type:

{
  table: {
    content: 'tableRow+',
    group: 'block',
    attrs: {
      colCount: { default: 0 }
    }
  },
  tableRow: {
    content: '(tableCell | tableHeader)+',
  }, 
  tableCell: {
    content: 'block+',
    tableRole: 'cell',
    isolating: true,
    attrs: {
      colspan: { default: 1 },
      rowspan: { default: 1 }
    }
  },
  tableHeader: {
    content: 'block+',
    attrs: {
      colspan: { default: 1 },
      rowspan: { default: 1 }
    }
  }
}

Model Representation

Example model representation:

{
  type: 'table',
  attrs: { colCount: 3 },
  children: [
    {
      type: 'tableRow',
      children: [
        {
          type: 'tableHeader',
          attrs: { colspan: 1, rowspan: 1 },
          children: [
            {
              type: 'paragraph',
              children: [{ type: 'text', text: 'Header' }]
            }
          ]
        }
      ]
    }
  ]
}

HTML Serialization

Converting model to HTML:

function serializeTable(node) {
  let html = '<table><tbody>';
  node.children.forEach(row => {
    html += serializeTableRow(row);
  });
  html += '</tbody></table>';
  return html;
}

function serializeTableRow(node) {
  let html = '<tr>';
  node.children.forEach(cell => {
    html += serializeTableCell(cell);
  });
  html += '</tr>';
  return html;
}

function serializeTableCell(node) {
  const tag = node.type === 'tableHeader' ? 'th' : 'td';
  const attrs = [];
  if (node.attrs.colspan > 1) {
    attrs.push('colspan="' + node.attrs.colspan + '"');
  }
  if (node.attrs.rowspan > 1) {
    attrs.push('rowspan="' + node.attrs.rowspan + '"');
  }
  return '<' + tag + (attrs.length ? ' ' + attrs.join(' ') : '') + '>' +
         serializeChildren(node.children) +
         '</' + tag + '>';
}

HTML Deserialization

Parsing HTML to model:

function parseTable(domNode) {
  const tbody = domNode.querySelector('tbody') || domNode;
  const rows = Array.from(tbody.querySelectorAll('tr'))
    .map(row => parseTableRow(row));
  
  // Calculate column count
  const colCount = Math.max(...rows.map(row => 
    row.children.reduce((sum, cell) => sum + (cell.attrs.colspan || 1), 0)
  ));
  
  return {
    type: 'table',
    attrs: { colCount },
    children: rows
  };
}

function parseTableRow(domNode) {
  const cells = Array.from(domNode.childNodes)
    .filter(node => node.tagName === 'TD' || node.tagName === 'TH')
    .map(cell => parseTableCell(cell));
  
  return {
    type: 'tableRow',
    children: cells
  };
}

function parseTableCell(domNode) {
  const type = domNode.tagName === 'TH' ? 'tableHeader' : 'tableCell';
  const colspan = parseInt(domNode.getAttribute('colspan')) || 1;
  const rowspan = parseInt(domNode.getAttribute('rowspan')) || 1;
  
  return {
    type,
    attrs: { colspan, rowspan },
    children: parseChildren(domNode.childNodes)
  };
}

View Integration

View Integration Notes: Pay special attention to contenteditable behavior, selection handling, and event management when implementing this node type in your view layer.

View integration code:

// Rendering
const table = document.createElement('table');
const tbody = document.createElement('tbody');
node.children.forEach(row => {
  const tr = renderTableRow(row);
  tbody.appendChild(tr);
});
table.appendChild(tbody);

// Cell editing
function makeCellEditable(cell) {
  cell.contentEditable = 'true';
  cell.addEventListener('input', handleCellInput);
  cell.addEventListener('blur', handleCellBlur);
}

// Table manipulation
function insertRow(table, index) {
  const rowCount = table.children[0].children.length;
  const newRow = createEmptyRow(rowCount);
  // Insert at index
}

function insertColumn(table, index) {
  // Add cell to each row at index
}

Common Issues

Common Pitfalls: These are issues frequently encountered when implementing this node type. Review carefully before implementation.

Common issues and solutions:

// Issue: Table structure validation
// Solution: Ensure consistent column count
function validateTable(table) {
  const colCount = table.attrs.colCount;
  table.children.forEach(row => {
    const actualCols = row.children.reduce((sum, cell) => 
      sum + (cell.attrs.colspan || 1), 0
    );
    if (actualCols !== colCount) {
      // Fix column count mismatch
    }
  });
}

// Issue: Empty cells
// Solution: Always have at least one block
if (cell.children.length === 0) {
  cell.children.push({
    type: 'paragraph',
    children: []
  });
}

// Issue: Colspan/rowspan calculations
// Solution: Track merged cells
function getCellAt(table, row, col) {
  // Account for colspan/rowspan
}

Implementation

Complete implementation example:

class TableNode {
  constructor(attrs, children) {
    this.type = 'table';
    this.attrs = { colCount: attrs?.colCount || 0 };
    this.children = children || [];
  }
  
  toDOM() {
    const table = document.createElement('table');
    const tbody = document.createElement('tbody');
    
    this.children.forEach(row => {
      tbody.appendChild(row.toDOM());
    });
    
    table.appendChild(tbody);
    return table;
  }
  
  static fromDOM(domNode) {
    const tbody = domNode.querySelector('tbody') || domNode;
    const rows = Array.from(tbody.querySelectorAll('tr'))
      .map(row => TableRowNode.fromDOM(row));
    
    const colCount = Math.max(...rows.map(row => 
      row.getColumnCount()
    ));
    
    return new TableNode({ colCount }, rows);
  }
}