ProseMirror tables: connect the schema, cell selection, and commands

Add table node specifications from prosemirror-tables to the document schema, then install tableEditing() in the editor's plugins. The schema permits table content; the plugin supplies cell selection and table editing behavior. A table-shaped DOM element added outside the document model will not establish a table that ProseMirror can reliably edit and serialize. Official table module

For an original example, a room rota contains Morning and Afternoon columns. A coordinator wants to merge two adjacent cells for one all-day booking, then split that cell when the afternoon booking changes. The application needs a real cell selection, compatible table nodes, and a rectangular table after each operation. A bold paragraph inside a cell or a browser text selection does not express that editing state.

Extend the application's schema

The following fragment uses the basic ProseMirror schema as a starting point. A real application should extend its own existing node and mark specifications instead of replacing them and losing supported content.

import { Schema } from 'prosemirror-model';
import { schema as basicSchema } from 'prosemirror-schema-basic';
import {
  tableNodes, tableEditing, columnResizing,
  addRowAfter, mergeCells
} from 'prosemirror-tables';

const inspectionSchema = new Schema({
  nodes: basicSchema.spec.nodes.append(tableNodes({
    tableGroup: 'block',
    cellContent: 'paragraph+',
    cellAttributes: {}
  })),
  marks: basicSchema.spec.marks
});

const inspectionTablePlugins = [
  columnResizing(),
  tableEditing()
];

Use compatible installed packages and add these plugins to the existing EditorState setup. This fragment defines schema and plugin choices; it does not mount a complete editor, toolbar, or save service.

tableGroup: 'block' allows tables where the surrounding document accepts a block. cellContent: 'paragraph+' deliberately limits this rota editor's cells to paragraphs. If authors need lists inside a cell, add the relevant list nodes and change the content expression consciously. Copying a broad expression cannot install missing node types.

Keep tableEditing() near the end of the plugin list. Its table mouse and arrow-key handling is broad; more specific behavior such as column resizing should get its opportunity first. The module's README documents this ordering and the available table commands.

Run a command against the current selection

With view referring to the initialized EditorView, check whether adding a row is available:

const canAddInspectionRow = addRowAfter(view.state);

function addInspectionRow() {
  const applied = addRowAfter(
    view.state,
    tr => view.dispatch(tr)
  );
  if (applied) view.focus();
  return applied;
}

Without a dispatch callback, a command checks applicability. With one, it can dispatch its change. Update the toolbar's enabled state as the selection changes, rather than computing canAddInspectionRow only when the document opens.

The selection identifies the target table and row. Preserve it when an external toolbar receives pointer focus, using the application's established toolbar interaction. Test this in the actual editor: move into the Monday row, activate Add row, and verify the new row appears after Monday while its existing bookings remain unchanged.

Cell selection is different from selecting text

The module provides CellSelection for rectangular selections across cells. tableEditing() handles those selections and marks selected cells with the selectedCell CSS class. Include the module's appropriate editor styles so the selection is visible.

mergeCells(view.state) checks whether the current selection can be merged. A rectangular cell selection and a text cursor inside one cell are different states. Do not show every structural operation as enabled just because the document contains a table somewhere. With the two adjacent booking cells selected, perform the merge using mergeCells(view.state, tr => view.dispatch(tr)); the corresponding splitCell command reverses the spanning structure when applicable. Splitting does not reconstruct which original words belonged in each of the two cells, so review their content afterward.

Merging changes the document. Keep the application's Undo support available and make destructive row or column actions clear. The ProseMirror history guide covers connecting history commands. Table normalization and an Undo stack do not decide whether the coordinator intended to remove a booking.

Preserve structure through storage and reader rendering

Save the editor's supported document representation, such as view.state.doc.toJSON(), through its normal persistence route. Restore it using the same table-capable schema. If the application stores HTML, use schema-aware serialization and parsing, with the application's normal safe rendering policy.

The module maintains table invariants, including consistent row widths and nonoverlapping cells. Those checks do not validate booking times or guarantee readable published CSS. The schema guide explains representation round trips; editor plugins and reader styles remain separate responsibilities.

Complete the rota check by merging the two all-day booking cells, splitting them in a separate unsaved check, and inspecting the resulting content and column spans. Save the intended rota and reopen it. Verify that each booking still sits beneath its correct time heading in the restored model and reader view. On a narrow screen, keep any necessary table scrolling inside the table region so the surrounding instructions remain readable.

Keep reading