zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { defineExtension } from "zeb/ui/editor-extension";

/**
 * Table extension — rows of cells, the first row a header.
 *
 *   <Editor extensions={[tableExtension()]} />   then `/table`
 *
 * Tab and Shift-Tab move between cells (Tab in the last cell adds a row);
 * Enter moves down. The caret in a table opens a panel to add or delete a
 * row or a column. Cells hold text and marks, not blocks.
 */

/** The cell around the caret: its row and column, and the table it is in. */
function cellAround(state) {
  const $from = state.selection.$from;
  for (let depth = $from.depth; depth > 2; depth--) {
    const name = $from.node(depth).type.name;
    if (name === "table_cell" || name === "table_header") {
      return { row: $from.index(depth - 2), col: $from.index(depth - 1), table: $from.node(depth - 2), tablePos: $from.before(depth - 2) };
    }
  }
  return null;
}

/** Position just inside the cell at (row, col). */
function cellStart(table, tablePos, row, col) {
  let pos = tablePos + 1;
  for (let r = 0; r < row; r++) pos += table.child(r).nodeSize;
  pos += 1;
  const cells = table.child(row);
  for (let c = 0; c < col; c++) pos += cells.child(c).nodeSize;
  return pos + 1;
}

function rowEnd(table, tablePos, row) {
  let pos = tablePos + 1;
  for (let r = 0; r <= row; r++) pos += table.child(r).nodeSize;
  return pos;
}

function commands(schema, pm) {
  const n = schema.nodes;
  const moveTo = (tr, cell, row, col) => tr.setSelection(pm.TextSelection.create(tr.doc, cellStart(tr.doc.nodeAt(cell.tablePos), cell.tablePos, row, col)));
  const plainRow = (cols) => n.table_row.create(null, Array.from({ length: cols }, () => n.table_cell.createAndFill()));
  const addRow = (state, dispatch) => {
    const cell = cellAround(state);
    if (!cell) return false;
    if (dispatch) {
      const tr = state.tr.insert(rowEnd(cell.table, cell.tablePos, cell.row), plainRow(cell.table.child(cell.row).childCount));
      dispatch(moveTo(tr, cell, cell.row + 1, cell.col).scrollIntoView());
    }
    return true;
  };
  const step = (dir) => (state, dispatch) => {
    const cell = cellAround(state);
    if (!cell) return false;
    const cols = cell.table.child(cell.row).childCount;
    const index = cell.row * cols + cell.col + dir;
    if (index < 0) return true;
    if (index >= cell.table.childCount * cols) return addRow(state, dispatch);
    if (dispatch) dispatch(moveTo(state.tr, cell, Math.floor(index / cols), index % cols).scrollIntoView());
    return true;
  };
  return {
    insertTable: (rows = 3, cols = 3) => (state, dispatch) => {
      const header = n.table_row.create(null, Array.from({ length: cols }, () => n.table_header.createAndFill()));
      const table = n.table.create(null, [header, ...Array.from({ length: rows - 1 }, () => plainRow(cols))]);
      if (dispatch) {
        const tr = state.tr.replaceSelectionWith(table);
        const $at = tr.doc.resolve(tr.mapping.map(state.selection.from, -1));
        dispatch(tr.setSelection(pm.Selection.near($at, 1)).scrollIntoView());
      }
      return true;
    },
    tableNextCell: step(1),
    tablePreviousCell: step(-1),
    tableCellBelow: (state, dispatch) => {
      const cell = cellAround(state);
      if (!cell) return false;
      if (cell.row + 1 >= cell.table.childCount) return addRow(state, dispatch);
      if (dispatch) dispatch(moveTo(state.tr, cell, cell.row + 1, cell.col).scrollIntoView());
      return true;
    },
    tableAddRow: addRow,
    tableAddColumn: (state, dispatch) => {
      const cell = cellAround(state);
      if (!cell) return false;
      if (dispatch) {
        const tr = state.tr;
        for (let r = cell.table.childCount - 1; r >= 0; r--) {
          const type = r === 0 && cell.table.child(0).child(0).type === n.table_header ? n.table_header : n.table_cell;
          tr.insert(cellStart(cell.table, cell.tablePos, r, cell.col) - 1 + cell.table.child(r).child(cell.col).nodeSize, type.createAndFill());
        }
        dispatch(tr);
      }
      return true;
    },
    tableDeleteRow: (state, dispatch) => {
      const cell = cellAround(state);
      if (!cell) return false;
      if (dispatch) {
        if (cell.table.childCount === 1) dispatch(state.tr.delete(cell.tablePos, cell.tablePos + cell.table.nodeSize));
        else dispatch(state.tr.delete(rowEnd(cell.table, cell.tablePos, cell.row) - cell.table.child(cell.row).nodeSize, rowEnd(cell.table, cell.tablePos, cell.row)));
      }
      return true;
    },
    tableDeleteColumn: (state, dispatch) => {
      const cell = cellAround(state);
      if (!cell) return false;
      if (dispatch) {
        const tr = state.tr;
        if (cell.table.child(0).childCount === 1) tr.delete(cell.tablePos, cell.tablePos + cell.table.nodeSize);
        else for (let r = cell.table.childCount - 1; r >= 0; r--) {
          const start = cellStart(cell.table, cell.tablePos, r, cell.col) - 1;
          tr.delete(start, start + cell.table.child(r).child(cell.col).nodeSize);
        }
        dispatch(tr);
      }
      return true;
    },
  };
}

const CELL = "border border-border px-3 py-1.5 align-top";
const HEADER = "border border-border bg-muted px-3 py-1.5 text-left font-semibold";

export function tableExtension() {
  return defineExtension({
    name: "table",
    nodes: {
      table: {
        spec: { content: "table_row+", group: "block", isolating: true },
        render: (_node, r) => r.h("div", { class: "my-4 overflow-x-auto", "data-table": "" }, r.h("table", { class: "w-full border-collapse text-sm" }, r.h("tbody", {}, r.content))),
        parse: [{ tag: "table" }],
      },
      table_row: {
        spec: { content: "(table_cell | table_header)+" },
        render: (_node, r) => r.h("tr", {}, r.content),
        parse: [{ tag: "tr" }],
      },
      table_header: {
        spec: { content: "inline*", isolating: true },
        render: (_node, r) => r.h("th", { class: HEADER }, r.content),
        parse: [{ tag: "th" }],
      },
      table_cell: {
        spec: { content: "inline*", isolating: true },
        render: (_node, r) => r.h("td", { class: CELL }, r.content),
        parse: [{ tag: "td" }],
      },
    },
    commands,
    keymap: (schema, pm) => {
      const c = commands(schema, pm);
      return { Tab: c.tableNextCell, "Shift-Tab": c.tablePreviousCell, Enter: c.tableCellBelow };
    },
    insert: [{ id: "table", label: "Table", hint: "Rows and columns", keys: "table spreadsheet rows columns", run: (api) => api.exec("insertTable", 3, 3) }],
    actions: [
      { label: "Add row", run: (editor) => editor.exec("tableAddRow") },
      { label: "Add column", run: (editor) => editor.exec("tableAddColumn") },
      { label: "Delete row", run: (editor) => editor.exec("tableDeleteRow") },
      { label: "Delete column", run: (editor) => editor.exec("tableDeleteColumn") },
    ],
  });
}

export default tableExtension;