From 1304dafc18f93524ee8245b200c947d004b78e77 Mon Sep 17 00:00:00 2001
From: chris <kokosias@yahoo.gr>
Date: Mon, 28 Aug 2023 17:15:03 +0300
Subject: [PATCH] add table src

---
 .../MenuService/MenuService.js                |   1 +
 .../src/utilities/document/DocumentHelpers.js |   1 -
 .../TablesService/tableSrc/cellselection.ts   | 464 ++++++++++
 .../TablesService/tableSrc/columnresizing.ts  | 383 ++++++++
 .../src/TablesService/tableSrc/commands.ts    | 852 ++++++++++++++++++
 .../src/TablesService/tableSrc/copypaste.ts   | 381 ++++++++
 .../src/TablesService/tableSrc/fixtables.ts   | 150 +++
 .../src/TablesService/tableSrc/index.ts       | 136 +++
 .../src/TablesService/tableSrc/input.ts       | 310 +++++++
 .../src/TablesService/tableSrc/schema.ts      | 197 ++++
 .../src/TablesService/tableSrc/tablemap.ts    | 377 ++++++++
 .../src/TablesService/tableSrc/tableview.ts   |  86 ++
 .../src/TablesService/tableSrc/util.ts        | 195 ++++
 13 files changed, 3532 insertions(+), 1 deletion(-)
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/cellselection.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/columnresizing.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/commands.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/copypaste.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/fixtables.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/index.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/input.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/schema.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/tablemap.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/tableview.ts
 create mode 100644 wax-prosemirror-services/src/TablesService/tableSrc/util.ts

diff --git a/wax-prosemirror-core/src/config/defaultServices/MenuService/MenuService.js b/wax-prosemirror-core/src/config/defaultServices/MenuService/MenuService.js
index a311fd8dd..3b5d84185 100644
--- a/wax-prosemirror-core/src/config/defaultServices/MenuService/MenuService.js
+++ b/wax-prosemirror-core/src/config/defaultServices/MenuService/MenuService.js
@@ -1,3 +1,4 @@
+/* eslint-disable consistent-return */
 import { isPlainObject, isFunction } from 'lodash';
 import Service from '../../../Service';
 import Menu from './Menu';
diff --git a/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js b/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js
index 31cda6580..7dbbdf0e4 100644
--- a/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js
+++ b/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js
@@ -155,7 +155,6 @@ const findAllMarksWithSameId = (state, mark) => {
       }
     });
   });
-  console.log(allMarksWithSameId);
   return allMarksWithSameId;
 };
 
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/cellselection.ts b/wax-prosemirror-services/src/TablesService/tableSrc/cellselection.ts
new file mode 100644
index 000000000..e092dfe8e
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/cellselection.ts
@@ -0,0 +1,464 @@
+// This file defines a ProseMirror selection subclass that models
+// table cell selections. The table plugin needs to be active to wire
+// in the user interaction part of table selections (so that you
+// actually get such selections when you select across cells).
+
+import { Fragment, Node, ResolvedPos, Slice } from 'prosemirror-model';
+import {
+  EditorState,
+  NodeSelection,
+  Selection,
+  SelectionRange,
+  TextSelection,
+  Transaction,
+} from 'prosemirror-state';
+import { Decoration, DecorationSet, DecorationSource } from 'prosemirror-view';
+
+import { Mappable } from 'prosemirror-transform';
+import { TableMap } from './tablemap';
+import { CellAttrs, inSameTable, pointsAtCell, removeColSpan } from './util';
+
+/**
+ * @public
+ */
+export interface CellSelectionJSON {
+  type: string;
+  anchor: number;
+  head: number;
+}
+
+/**
+ * A [`Selection`](http://prosemirror.net/docs/ref/#state.Selection)
+ * subclass that represents a cell selection spanning part of a table.
+ * With the plugin enabled, these will be created when the user
+ * selects across cells, and will be drawn by giving selected cells a
+ * `selectedCell` CSS class.
+ *
+ * @public
+ */
+export class CellSelection extends Selection {
+  // A resolved position pointing _in front of_ the anchor cell (the one
+  // that doesn't move when extending the selection).
+  public $anchorCell: ResolvedPos;
+
+  // A resolved position pointing in front of the head cell (the one
+  // moves when extending the selection).
+  public $headCell: ResolvedPos;
+
+  // A table selection is identified by its anchor and head cells. The
+  // positions given to this constructor should point _before_ two
+  // cells in the same table. They may be the same, to select a single
+  // cell.
+  constructor($anchorCell: ResolvedPos, $headCell: ResolvedPos = $anchorCell) {
+    const table = $anchorCell.node(-1);
+    const map = TableMap.get(table);
+    const tableStart = $anchorCell.start(-1);
+    const rect = map.rectBetween(
+      $anchorCell.pos - tableStart,
+      $headCell.pos - tableStart,
+    );
+
+    const doc = $anchorCell.node(0);
+    const cells = map
+      .cellsInRect(rect)
+      .filter((p) => p != $headCell.pos - tableStart);
+    // Make the head cell the first range, so that it counts as the
+    // primary part of the selection
+    cells.unshift($headCell.pos - tableStart);
+    const ranges = cells.map((pos) => {
+      const cell = table.nodeAt(pos);
+      if (!cell) {
+        throw RangeError(`No cell with offset ${pos} found`);
+      }
+      const from = tableStart + pos + 1;
+      return new SelectionRange(
+        doc.resolve(from),
+        doc.resolve(from + cell.content.size),
+      );
+    });
+    super(ranges[0].$from, ranges[0].$to, ranges);
+    this.$anchorCell = $anchorCell;
+    this.$headCell = $headCell;
+  }
+
+  public map(doc: Node, mapping: Mappable): CellSelection | Selection {
+    const $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos));
+    const $headCell = doc.resolve(mapping.map(this.$headCell.pos));
+    if (
+      pointsAtCell($anchorCell) &&
+      pointsAtCell($headCell) &&
+      inSameTable($anchorCell, $headCell)
+    ) {
+      const tableChanged = this.$anchorCell.node(-1) != $anchorCell.node(-1);
+      if (tableChanged && this.isRowSelection())
+        return CellSelection.rowSelection($anchorCell, $headCell);
+      else if (tableChanged && this.isColSelection())
+        return CellSelection.colSelection($anchorCell, $headCell);
+      else return new CellSelection($anchorCell, $headCell);
+    }
+    return TextSelection.between($anchorCell, $headCell);
+  }
+
+  // Returns a rectangular slice of table rows containing the selected
+  // cells.
+  public content(): Slice {
+    const table = this.$anchorCell.node(-1);
+    const map = TableMap.get(table);
+    const tableStart = this.$anchorCell.start(-1);
+
+    const rect = map.rectBetween(
+      this.$anchorCell.pos - tableStart,
+      this.$headCell.pos - tableStart,
+    );
+    const seen: Record<number, boolean> = {};
+    const rows = [];
+    for (let row = rect.top; row < rect.bottom; row++) {
+      const rowContent = [];
+      for (
+        let index = row * map.width + rect.left, col = rect.left;
+        col < rect.right;
+        col++, index++
+      ) {
+        const pos = map.map[index];
+        if (seen[pos]) continue;
+        seen[pos] = true;
+
+        const cellRect = map.findCell(pos);
+        let cell = table.nodeAt(pos);
+        if (!cell) {
+          throw RangeError(`No cell with offset ${pos} found`);
+        }
+
+        const extraLeft = rect.left - cellRect.left;
+        const extraRight = cellRect.right - rect.right;
+
+        if (extraLeft > 0 || extraRight > 0) {
+          let attrs = cell.attrs as CellAttrs;
+          if (extraLeft > 0) {
+            attrs = removeColSpan(attrs, 0, extraLeft);
+          }
+          if (extraRight > 0) {
+            attrs = removeColSpan(
+              attrs,
+              attrs.colspan - extraRight,
+              extraRight,
+            );
+          }
+          if (cellRect.left < rect.left) {
+            cell = cell.type.createAndFill(attrs);
+            if (!cell) {
+              throw RangeError(
+                `Could not create cell with attrs ${JSON.stringify(attrs)}`,
+              );
+            }
+          } else {
+            cell = cell.type.create(attrs, cell.content);
+          }
+        }
+        if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) {
+          const attrs = {
+            ...cell.attrs,
+            rowspan:
+              Math.min(cellRect.bottom, rect.bottom) -
+              Math.max(cellRect.top, rect.top),
+          };
+          if (cellRect.top < rect.top) {
+            cell = cell.type.createAndFill(attrs)!;
+          } else {
+            cell = cell.type.create(attrs, cell.content);
+          }
+        }
+        rowContent.push(cell);
+      }
+      rows.push(table.child(row).copy(Fragment.from(rowContent)));
+    }
+
+    const fragment =
+      this.isColSelection() && this.isRowSelection() ? table : rows;
+    return new Slice(Fragment.from(fragment), 1, 1);
+  }
+
+  public replace(tr: Transaction, content: Slice = Slice.empty): void {
+    const mapFrom = tr.steps.length,
+      ranges = this.ranges;
+    for (let i = 0; i < ranges.length; i++) {
+      const { $from, $to } = ranges[i],
+        mapping = tr.mapping.slice(mapFrom);
+      tr.replace(
+        mapping.map($from.pos),
+        mapping.map($to.pos),
+        i ? Slice.empty : content,
+      );
+    }
+    const sel = Selection.findFrom(
+      tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)),
+      -1,
+    );
+    if (sel) tr.setSelection(sel);
+  }
+
+  public replaceWith(tr: Transaction, node: Node): void {
+    this.replace(tr, new Slice(Fragment.from(node), 0, 0));
+  }
+
+  public forEachCell(f: (node: Node, pos: number) => void): void {
+    const table = this.$anchorCell.node(-1);
+    const map = TableMap.get(table);
+    const tableStart = this.$anchorCell.start(-1);
+
+    const cells = map.cellsInRect(
+      map.rectBetween(
+        this.$anchorCell.pos - tableStart,
+        this.$headCell.pos - tableStart,
+      ),
+    );
+    for (let i = 0; i < cells.length; i++) {
+      f(table.nodeAt(cells[i])!, tableStart + cells[i]);
+    }
+  }
+
+  // True if this selection goes all the way from the top to the
+  // bottom of the table.
+  public isColSelection(): boolean {
+    const anchorTop = this.$anchorCell.index(-1);
+    const headTop = this.$headCell.index(-1);
+    if (Math.min(anchorTop, headTop) > 0) return false;
+
+    const anchorBottom = anchorTop + this.$anchorCell.nodeAfter!.attrs.rowspan;
+    const headBottom = headTop + this.$headCell.nodeAfter!.attrs.rowspan;
+
+    return (
+      Math.max(anchorBottom, headBottom) == this.$headCell.node(-1).childCount
+    );
+  }
+
+  // Returns the smallest column selection that covers the given anchor
+  // and head cell.
+  public static colSelection(
+    $anchorCell: ResolvedPos,
+    $headCell: ResolvedPos = $anchorCell,
+  ): CellSelection {
+    const table = $anchorCell.node(-1);
+    const map = TableMap.get(table);
+    const tableStart = $anchorCell.start(-1);
+
+    const anchorRect = map.findCell($anchorCell.pos - tableStart);
+    const headRect = map.findCell($headCell.pos - tableStart);
+    const doc = $anchorCell.node(0);
+
+    if (anchorRect.top <= headRect.top) {
+      if (anchorRect.top > 0)
+        $anchorCell = doc.resolve(tableStart + map.map[anchorRect.left]);
+      if (headRect.bottom < map.height)
+        $headCell = doc.resolve(
+          tableStart +
+            map.map[map.width * (map.height - 1) + headRect.right - 1],
+        );
+    } else {
+      if (headRect.top > 0)
+        $headCell = doc.resolve(tableStart + map.map[headRect.left]);
+      if (anchorRect.bottom < map.height)
+        $anchorCell = doc.resolve(
+          tableStart +
+            map.map[map.width * (map.height - 1) + anchorRect.right - 1],
+        );
+    }
+    return new CellSelection($anchorCell, $headCell);
+  }
+
+  // True if this selection goes all the way from the left to the
+  // right of the table.
+  public isRowSelection(): boolean {
+    const table = this.$anchorCell.node(-1);
+    const map = TableMap.get(table);
+    const tableStart = this.$anchorCell.start(-1);
+
+    const anchorLeft = map.colCount(this.$anchorCell.pos - tableStart);
+    const headLeft = map.colCount(this.$headCell.pos - tableStart);
+    if (Math.min(anchorLeft, headLeft) > 0) return false;
+
+    const anchorRight = anchorLeft + this.$anchorCell.nodeAfter!.attrs.colspan;
+    const headRight = headLeft + this.$headCell.nodeAfter!.attrs.colspan;
+    return Math.max(anchorRight, headRight) == map.width;
+  }
+
+  public eq(other: unknown): boolean {
+    return (
+      other instanceof CellSelection &&
+      other.$anchorCell.pos == this.$anchorCell.pos &&
+      other.$headCell.pos == this.$headCell.pos
+    );
+  }
+
+  // Returns the smallest row selection that covers the given anchor
+  // and head cell.
+  public static rowSelection(
+    $anchorCell: ResolvedPos,
+    $headCell: ResolvedPos = $anchorCell,
+  ): CellSelection {
+    const table = $anchorCell.node(-1);
+    const map = TableMap.get(table);
+    const tableStart = $anchorCell.start(-1);
+
+    const anchorRect = map.findCell($anchorCell.pos - tableStart);
+    const headRect = map.findCell($headCell.pos - tableStart);
+    const doc = $anchorCell.node(0);
+
+    if (anchorRect.left <= headRect.left) {
+      if (anchorRect.left > 0)
+        $anchorCell = doc.resolve(
+          tableStart + map.map[anchorRect.top * map.width],
+        );
+      if (headRect.right < map.width)
+        $headCell = doc.resolve(
+          tableStart + map.map[map.width * (headRect.top + 1) - 1],
+        );
+    } else {
+      if (headRect.left > 0)
+        $headCell = doc.resolve(tableStart + map.map[headRect.top * map.width]);
+      if (anchorRect.right < map.width)
+        $anchorCell = doc.resolve(
+          tableStart + map.map[map.width * (anchorRect.top + 1) - 1],
+        );
+    }
+    return new CellSelection($anchorCell, $headCell);
+  }
+
+  public toJSON(): CellSelectionJSON {
+    return {
+      type: 'cell',
+      anchor: this.$anchorCell.pos,
+      head: this.$headCell.pos,
+    };
+  }
+
+  static fromJSON(doc: Node, json: CellSelectionJSON): CellSelection {
+    return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head));
+  }
+
+  static create(
+    doc: Node,
+    anchorCell: number,
+    headCell: number = anchorCell,
+  ): CellSelection {
+    return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell));
+  }
+
+  getBookmark(): CellBookmark {
+    return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos);
+  }
+}
+
+CellSelection.prototype.visible = false;
+
+Selection.jsonID('cell', CellSelection);
+
+/**
+ * @public
+ */
+export class CellBookmark {
+  constructor(public anchor: number, public head: number) {}
+
+  map(mapping: Mappable): CellBookmark {
+    return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head));
+  }
+
+  resolve(doc: Node): CellSelection | Selection {
+    const $anchorCell = doc.resolve(this.anchor),
+      $headCell = doc.resolve(this.head);
+    if (
+      $anchorCell.parent.type.spec.tableRole == 'row' &&
+      $headCell.parent.type.spec.tableRole == 'row' &&
+      $anchorCell.index() < $anchorCell.parent.childCount &&
+      $headCell.index() < $headCell.parent.childCount &&
+      inSameTable($anchorCell, $headCell)
+    )
+      return new CellSelection($anchorCell, $headCell);
+    else return Selection.near($headCell, 1);
+  }
+}
+
+export function drawCellSelection(state: EditorState): DecorationSource | null {
+  if (!(state.selection instanceof CellSelection)) return null;
+  const cells: Decoration[] = [];
+  state.selection.forEachCell((node, pos) => {
+    cells.push(
+      Decoration.node(pos, pos + node.nodeSize, { class: 'selectedCell' }),
+    );
+  });
+  return DecorationSet.create(state.doc, cells);
+}
+
+function isCellBoundarySelection({ $from, $to }: TextSelection) {
+  if ($from.pos == $to.pos || $from.pos < $from.pos - 6) return false; // Cheap elimination
+  let afterFrom = $from.pos;
+  let beforeTo = $to.pos;
+  let depth = $from.depth;
+  for (; depth >= 0; depth--, afterFrom++)
+    if ($from.after(depth + 1) < $from.end(depth)) break;
+  for (let d = $to.depth; d >= 0; d--, beforeTo--)
+    if ($to.before(d + 1) > $to.start(d)) break;
+  return (
+    afterFrom == beforeTo &&
+    /row|table/.test($from.node(depth).type.spec.tableRole)
+  );
+}
+
+function isTextSelectionAcrossCells({ $from, $to }: TextSelection) {
+  let fromCellBoundaryNode: Node | undefined;
+  let toCellBoundaryNode: Node | undefined;
+
+  for (let i = $from.depth; i > 0; i--) {
+    const node = $from.node(i);
+    if (
+      node.type.spec.tableRole === 'cell' ||
+      node.type.spec.tableRole === 'header_cell'
+    ) {
+      fromCellBoundaryNode = node;
+      break;
+    }
+  }
+
+  for (let i = $to.depth; i > 0; i--) {
+    const node = $to.node(i);
+    if (
+      node.type.spec.tableRole === 'cell' ||
+      node.type.spec.tableRole === 'header_cell'
+    ) {
+      toCellBoundaryNode = node;
+      break;
+    }
+  }
+
+  return fromCellBoundaryNode !== toCellBoundaryNode && $to.parentOffset === 0;
+}
+
+export function normalizeSelection(
+  state: EditorState,
+  tr: Transaction | undefined,
+  allowTableNodeSelection: boolean,
+): Transaction | undefined {
+  const sel = (tr || state).selection;
+  const doc = (tr || state).doc;
+  let normalize: Selection | undefined;
+  let role: string | undefined;
+  if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) {
+    if (role == 'cell' || role == 'header_cell') {
+      normalize = CellSelection.create(doc, sel.from);
+    } else if (role == 'row') {
+      const $cell = doc.resolve(sel.from + 1);
+      normalize = CellSelection.rowSelection($cell, $cell);
+    } else if (!allowTableNodeSelection) {
+      const map = TableMap.get(sel.node);
+      const start = sel.from + 1;
+      const lastCell = start + map.map[map.width * map.height - 1];
+      normalize = CellSelection.create(doc, start + 1, lastCell);
+    }
+  } else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) {
+    normalize = TextSelection.create(doc, sel.from);
+  } else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) {
+    normalize = TextSelection.create(doc, sel.$from.start(), sel.$from.end());
+  }
+  if (normalize) (tr || (tr = state.tr)).setSelection(normalize);
+  return tr;
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/columnresizing.ts b/wax-prosemirror-services/src/TablesService/tableSrc/columnresizing.ts
new file mode 100644
index 000000000..ed2019206
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/columnresizing.ts
@@ -0,0 +1,383 @@
+import { Attrs, Node as ProsemirrorNode } from 'prosemirror-model';
+import { EditorState, Plugin, PluginKey, Transaction } from 'prosemirror-state';
+import {
+  Decoration,
+  DecorationSet,
+  EditorView,
+  NodeView,
+} from 'prosemirror-view';
+import { tableNodeTypes } from './schema';
+import { TableMap } from './tablemap';
+import { TableView, updateColumnsOnResize } from './tableview';
+import { cellAround, CellAttrs, pointsAtCell } from './util';
+
+/**
+ * @public
+ */
+export const columnResizingPluginKey = new PluginKey<ResizeState>(
+  'tableColumnResizing',
+);
+
+/**
+ * @public
+ */
+export type ColumnResizingOptions = {
+  handleWidth?: number;
+  cellMinWidth?: number;
+  lastColumnResizable?: boolean;
+  View?: new (
+    node: ProsemirrorNode,
+    cellMinWidth: number,
+    view: EditorView,
+  ) => NodeView;
+};
+
+/**
+ * @public
+ */
+export type Dragging = { startX: number; startWidth: number };
+
+/**
+ * @public
+ */
+export function columnResizing({
+  handleWidth = 5,
+  cellMinWidth = 25,
+  View = TableView,
+  lastColumnResizable = true,
+}: ColumnResizingOptions = {}): Plugin {
+  const plugin = new Plugin<ResizeState>({
+    key: columnResizingPluginKey,
+    state: {
+      init(_, state) {
+        plugin.spec!.props!.nodeViews![
+          tableNodeTypes(state.schema).table.name
+        ] = (node, view) => new View(node, cellMinWidth, view);
+        return new ResizeState(-1, false);
+      },
+      apply(tr, prev) {
+        return prev.apply(tr);
+      },
+    },
+    props: {
+      attributes: (state): Record<string, string> => {
+        const pluginState = columnResizingPluginKey.getState(state);
+        return pluginState && pluginState.activeHandle > -1
+          ? { class: 'resize-cursor' }
+          : {};
+      },
+
+      handleDOMEvents: {
+        mousemove: (view, event) => {
+          handleMouseMove(
+            view,
+            event,
+            handleWidth,
+            cellMinWidth,
+            lastColumnResizable,
+          );
+        },
+        mouseleave: (view) => {
+          handleMouseLeave(view);
+        },
+        mousedown: (view, event) => {
+          handleMouseDown(view, event, cellMinWidth);
+        },
+      },
+
+      decorations: (state) => {
+        const pluginState = columnResizingPluginKey.getState(state);
+        if (pluginState && pluginState.activeHandle > -1) {
+          return handleDecorations(state, pluginState.activeHandle);
+        }
+      },
+
+      nodeViews: {},
+    },
+  });
+  return plugin;
+}
+
+/**
+ * @public
+ */
+export class ResizeState {
+  constructor(public activeHandle: number, public dragging: Dragging | false) {}
+
+  apply(tr: Transaction): ResizeState {
+    // eslint-disable-next-line @typescript-eslint/no-this-alias
+    const state = this;
+    const action = tr.getMeta(columnResizingPluginKey);
+    if (action && action.setHandle != null)
+      return new ResizeState(action.setHandle, false);
+    if (action && action.setDragging !== undefined)
+      return new ResizeState(state.activeHandle, action.setDragging);
+    if (state.activeHandle > -1 && tr.docChanged) {
+      let handle = tr.mapping.map(state.activeHandle, -1);
+      if (!pointsAtCell(tr.doc.resolve(handle))) {
+        handle = -1;
+      }
+      return new ResizeState(handle, state.dragging);
+    }
+    return state;
+  }
+}
+
+function handleMouseMove(
+  view: EditorView,
+  event: MouseEvent,
+  handleWidth: number,
+  cellMinWidth: number,
+  lastColumnResizable: boolean,
+): void {
+  const pluginState = columnResizingPluginKey.getState(view.state);
+  if (!pluginState) return;
+
+  if (!pluginState.dragging) {
+    const target = domCellAround(event.target as HTMLElement);
+    let cell = -1;
+    if (target) {
+      const { left, right } = target.getBoundingClientRect();
+      if (event.clientX - left <= handleWidth)
+        cell = edgeCell(view, event, 'left', handleWidth);
+      else if (right - event.clientX <= handleWidth)
+        cell = edgeCell(view, event, 'right', handleWidth);
+    }
+
+    if (cell != pluginState.activeHandle) {
+      if (!lastColumnResizable && cell !== -1) {
+        const $cell = view.state.doc.resolve(cell);
+        const table = $cell.node(-1);
+        const map = TableMap.get(table);
+        const tableStart = $cell.start(-1);
+        const col =
+          map.colCount($cell.pos - tableStart) +
+          $cell.nodeAfter!.attrs.colspan -
+          1;
+
+        if (col == map.width - 1) {
+          return;
+        }
+      }
+
+      updateHandle(view, cell);
+    }
+  }
+}
+
+function handleMouseLeave(view: EditorView): void {
+  const pluginState = columnResizingPluginKey.getState(view.state);
+  if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging)
+    updateHandle(view, -1);
+}
+
+function handleMouseDown(
+  view: EditorView,
+  event: MouseEvent,
+  cellMinWidth: number,
+): boolean {
+  const pluginState = columnResizingPluginKey.getState(view.state);
+  if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging)
+    return false;
+
+  const cell = view.state.doc.nodeAt(pluginState.activeHandle)!;
+  const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
+  view.dispatch(
+    view.state.tr.setMeta(columnResizingPluginKey, {
+      setDragging: { startX: event.clientX, startWidth: width },
+    }),
+  );
+
+  function finish(event: MouseEvent) {
+    window.removeEventListener('mouseup', finish);
+    window.removeEventListener('mousemove', move);
+    const pluginState = columnResizingPluginKey.getState(view.state);
+    if (pluginState?.dragging) {
+      updateColumnWidth(
+        view,
+        pluginState.activeHandle,
+        draggedWidth(pluginState.dragging, event, cellMinWidth),
+      );
+      view.dispatch(
+        view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }),
+      );
+    }
+  }
+
+  function move(event: MouseEvent): void {
+    if (!event.which) return finish(event);
+    const pluginState = columnResizingPluginKey.getState(view.state);
+    if (!pluginState) return;
+    if (pluginState.dragging) {
+      const dragged = draggedWidth(pluginState.dragging, event, cellMinWidth);
+      displayColumnWidth(view, pluginState.activeHandle, dragged, cellMinWidth);
+    }
+  }
+
+  window.addEventListener('mouseup', finish);
+  window.addEventListener('mousemove', move);
+  event.preventDefault();
+  return true;
+}
+
+function currentColWidth(
+  view: EditorView,
+  cellPos: number,
+  { colspan, colwidth }: Attrs,
+): number {
+  const width = colwidth && colwidth[colwidth.length - 1];
+  if (width) return width;
+  const dom = view.domAtPos(cellPos);
+  const node = dom.node.childNodes[dom.offset] as HTMLElement;
+  let domWidth = node.offsetWidth,
+    parts = colspan;
+  if (colwidth)
+    for (let i = 0; i < colspan; i++)
+      if (colwidth[i]) {
+        domWidth -= colwidth[i];
+        parts--;
+      }
+  return domWidth / parts;
+}
+
+function domCellAround(target: HTMLElement | null): HTMLElement | null {
+  while (target && target.nodeName != 'TD' && target.nodeName != 'TH')
+    target =
+      target.classList && target.classList.contains('ProseMirror')
+        ? null
+        : (target.parentNode as HTMLElement);
+  return target;
+}
+
+function edgeCell(
+  view: EditorView,
+  event: MouseEvent,
+  side: 'left' | 'right',
+  handleWidth: number,
+): number {
+  // posAtCoords returns inconsistent positions when cursor is moving
+  // across a collapsed table border. Use an offset to adjust the
+  // target viewport coordinates away from the table border.
+  const offset = side == 'right' ? -handleWidth : handleWidth;
+  const found = view.posAtCoords({
+    left: event.clientX + offset,
+    top: event.clientY,
+  });
+  if (!found) return -1;
+  const { pos } = found;
+  const $cell = cellAround(view.state.doc.resolve(pos));
+  if (!$cell) return -1;
+  if (side == 'right') return $cell.pos;
+  const map = TableMap.get($cell.node(-1)),
+    start = $cell.start(-1);
+  const index = map.map.indexOf($cell.pos - start);
+  return index % map.width == 0 ? -1 : start + map.map[index - 1];
+}
+
+function draggedWidth(
+  dragging: Dragging,
+  event: MouseEvent,
+  cellMinWidth: number,
+): number {
+  const offset = event.clientX - dragging.startX;
+  return Math.max(cellMinWidth, dragging.startWidth + offset);
+}
+
+function updateHandle(view: EditorView, value: number): void {
+  view.dispatch(
+    view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }),
+  );
+}
+
+function updateColumnWidth(
+  view: EditorView,
+  cell: number,
+  width: number,
+): void {
+  const $cell = view.state.doc.resolve(cell);
+  const table = $cell.node(-1),
+    map = TableMap.get(table),
+    start = $cell.start(-1);
+  const col =
+    map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan - 1;
+  const tr = view.state.tr;
+  for (let row = 0; row < map.height; row++) {
+    const mapIndex = row * map.width + col;
+    // Rowspanning cell that has already been handled
+    if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue;
+    const pos = map.map[mapIndex];
+    const attrs = table.nodeAt(pos)!.attrs as CellAttrs;
+    const index = attrs.colspan == 1 ? 0 : col - map.colCount(pos);
+    if (attrs.colwidth && attrs.colwidth[index] == width) continue;
+    const colwidth = attrs.colwidth
+      ? attrs.colwidth.slice()
+      : zeroes(attrs.colspan);
+    colwidth[index] = width;
+    tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth: colwidth });
+  }
+  if (tr.docChanged) view.dispatch(tr);
+}
+
+function displayColumnWidth(
+  view: EditorView,
+  cell: number,
+  width: number,
+  cellMinWidth: number,
+): void {
+  const $cell = view.state.doc.resolve(cell);
+  const table = $cell.node(-1),
+    start = $cell.start(-1);
+  const col =
+    TableMap.get(table).colCount($cell.pos - start) +
+    $cell.nodeAfter!.attrs.colspan -
+    1;
+  let dom: Node | null = view.domAtPos($cell.start(-1)).node;
+  while (dom && dom.nodeName != 'TABLE') {
+    dom = dom.parentNode;
+  }
+  if (!dom) return;
+  updateColumnsOnResize(
+    table,
+    dom.firstChild as HTMLTableColElement,
+    dom as HTMLTableElement,
+    cellMinWidth,
+    col,
+    width,
+  );
+}
+
+function zeroes(n: number): 0[] {
+  return Array(n).fill(0);
+}
+
+export function handleDecorations(
+  state: EditorState,
+  cell: number,
+): DecorationSet {
+  const decorations = [];
+  const $cell = state.doc.resolve(cell);
+  const table = $cell.node(-1);
+  if (!table) {
+    return DecorationSet.empty;
+  }
+  const map = TableMap.get(table);
+  const start = $cell.start(-1);
+  const col = map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan;
+  for (let row = 0; row < map.height; row++) {
+    const index = col + row * map.width - 1;
+    // For positions that have either a different cell or the end
+    // of the table to their right, and either the top of the table or
+    // a different cell above them, add a decoration
+    if (
+      (col == map.width || map.map[index] != map.map[index + 1]) &&
+      (row == 0 || map.map[index] != map.map[index - map.width])
+    ) {
+      const cellPos = map.map[index];
+      const pos = start + cellPos + table.nodeAt(cellPos)!.nodeSize - 1;
+      const dom = document.createElement('div');
+      dom.className = 'column-resize-handle';
+      decorations.push(Decoration.widget(pos, dom));
+    }
+  }
+  return DecorationSet.create(state.doc, decorations);
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/commands.ts b/wax-prosemirror-services/src/TablesService/tableSrc/commands.ts
new file mode 100644
index 000000000..2f8a9c704
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/commands.ts
@@ -0,0 +1,852 @@
+// This file defines a number of table-related commands.
+
+import { Fragment, Node, NodeType, ResolvedPos } from 'prosemirror-model';
+import {
+  Command,
+  EditorState,
+  TextSelection,
+  Transaction,
+} from 'prosemirror-state';
+
+import { CellSelection } from './cellselection';
+import type { Direction } from './input';
+import { tableNodeTypes, TableRole } from './schema';
+import { Rect, TableMap } from './tablemap';
+import {
+  addColSpan,
+  cellAround,
+  CellAttrs,
+  cellWrapping,
+  columnIsHeader,
+  isInTable,
+  moveCellForward,
+  removeColSpan,
+  selectionCell,
+} from './util';
+
+/**
+ * @public
+ */
+export type TableRect = Rect & {
+  tableStart: number;
+  map: TableMap;
+  table: Node;
+};
+
+/**
+ * Helper to get the selected rectangle in a table, if any. Adds table
+ * map, table node, and table start offset to the object for
+ * convenience.
+ *
+ * @public
+ */
+export function selectedRect(state: EditorState): TableRect {
+  const sel = state.selection;
+  const $pos = selectionCell(state);
+  const table = $pos.node(-1);
+  const tableStart = $pos.start(-1);
+  const map = TableMap.get(table);
+  const rect =
+    sel instanceof CellSelection
+      ? map.rectBetween(
+          sel.$anchorCell.pos - tableStart,
+          sel.$headCell.pos - tableStart,
+        )
+      : map.findCell($pos.pos - tableStart);
+  return { ...rect, tableStart, map, table };
+}
+
+/**
+ * Add a column at the given position in a table.
+ *
+ * @public
+ */
+export function addColumn(
+  tr: Transaction,
+  { map, tableStart, table }: TableRect,
+  col: number,
+): Transaction {
+  let refColumn: number | null = col > 0 ? -1 : 0;
+  if (columnIsHeader(map, table, col + refColumn)) {
+    refColumn = col == 0 || col == map.width ? null : 0;
+  }
+
+  for (let row = 0; row < map.height; row++) {
+    const index = row * map.width + col;
+    // If this position falls inside a col-spanning cell
+    if (col > 0 && col < map.width && map.map[index - 1] == map.map[index]) {
+      const pos = map.map[index];
+      const cell = table.nodeAt(pos)!;
+      tr.setNodeMarkup(
+        tr.mapping.map(tableStart + pos),
+        null,
+        addColSpan(cell.attrs as CellAttrs, col - map.colCount(pos)),
+      );
+      // Skip ahead if rowspan > 1
+      row += cell.attrs.rowspan - 1;
+    } else {
+      const type =
+        refColumn == null
+          ? tableNodeTypes(table.type.schema).cell
+          : table.nodeAt(map.map[index + refColumn])!.type;
+      const pos = map.positionAt(row, col, table);
+      tr.insert(tr.mapping.map(tableStart + pos), type.createAndFill()!);
+    }
+  }
+  return tr;
+}
+
+/**
+ * Command to add a column before the column with the selection.
+ *
+ * @public
+ */
+export function addColumnBefore(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  if (!isInTable(state)) return false;
+  if (dispatch) {
+    const rect = selectedRect(state);
+    dispatch(addColumn(state.tr, rect, rect.left));
+  }
+  return true;
+}
+
+/**
+ * Command to add a column after the column with the selection.
+ *
+ * @public
+ */
+export function addColumnAfter(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  if (!isInTable(state)) return false;
+  if (dispatch) {
+    const rect = selectedRect(state);
+    dispatch(addColumn(state.tr, rect, rect.right));
+  }
+  return true;
+}
+
+/**
+ * @public
+ */
+export function removeColumn(
+  tr: Transaction,
+  { map, table, tableStart }: TableRect,
+  col: number,
+) {
+  const mapStart = tr.mapping.maps.length;
+  for (let row = 0; row < map.height; ) {
+    const index = row * map.width + col;
+    const pos = map.map[index];
+    const cell = table.nodeAt(pos)!;
+    const attrs = cell.attrs as CellAttrs;
+    // If this is part of a col-spanning cell
+    if (
+      (col > 0 && map.map[index - 1] == pos) ||
+      (col < map.width - 1 && map.map[index + 1] == pos)
+    ) {
+      tr.setNodeMarkup(
+        tr.mapping.slice(mapStart).map(tableStart + pos),
+        null,
+        removeColSpan(attrs, col - map.colCount(pos)),
+      );
+    } else {
+      const start = tr.mapping.slice(mapStart).map(tableStart + pos);
+      tr.delete(start, start + cell.nodeSize);
+    }
+    row += attrs.rowspan;
+  }
+}
+
+/**
+ * Command function that removes the selected columns from a table.
+ *
+ * @public
+ */
+export function deleteColumn(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  if (!isInTable(state)) return false;
+  if (dispatch) {
+    const rect = selectedRect(state);
+    const tr = state.tr;
+    if (rect.left == 0 && rect.right == rect.map.width) return false;
+    for (let i = rect.right - 1; ; i--) {
+      removeColumn(tr, rect, i);
+      if (i == rect.left) break;
+      const table = rect.tableStart
+        ? tr.doc.nodeAt(rect.tableStart - 1)
+        : tr.doc;
+      if (!table) {
+        throw RangeError('No table found');
+      }
+      rect.table = table;
+      rect.map = TableMap.get(table);
+    }
+    dispatch(tr);
+  }
+  return true;
+}
+
+/**
+ * @public
+ */
+export function rowIsHeader(map: TableMap, table: Node, row: number): boolean {
+  const headerCell = tableNodeTypes(table.type.schema).header_cell;
+  for (let col = 0; col < map.width; col++)
+    if (table.nodeAt(map.map[col + row * map.width])?.type != headerCell)
+      return false;
+  return true;
+}
+
+/**
+ * @public
+ */
+export function addRow(
+  tr: Transaction,
+  { map, tableStart, table }: TableRect,
+  row: number,
+): Transaction {
+  let rowPos = tableStart;
+  for (let i = 0; i < row; i++) rowPos += table.child(i).nodeSize;
+  const cells = [];
+  let refRow: number | null = row > 0 ? -1 : 0;
+  if (rowIsHeader(map, table, row + refRow))
+    refRow = row == 0 || row == map.height ? null : 0;
+  for (let col = 0, index = map.width * row; col < map.width; col++, index++) {
+    // Covered by a rowspan cell
+    if (
+      row > 0 &&
+      row < map.height &&
+      map.map[index] == map.map[index - map.width]
+    ) {
+      const pos = map.map[index];
+      const attrs = table.nodeAt(pos)!.attrs;
+      tr.setNodeMarkup(tableStart + pos, null, {
+        ...attrs,
+        rowspan: attrs.rowspan + 1,
+      });
+      col += attrs.colspan - 1;
+    } else {
+      const type =
+        refRow == null
+          ? tableNodeTypes(table.type.schema).cell
+          : table.nodeAt(map.map[index + refRow * map.width])?.type;
+      const node = type?.createAndFill();
+      if (node) cells.push(node);
+    }
+  }
+  tr.insert(rowPos, tableNodeTypes(table.type.schema).row.create(null, cells));
+  return tr;
+}
+
+/**
+ * Add a table row before the selection.
+ *
+ * @public
+ */
+export function addRowBefore(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  if (!isInTable(state)) return false;
+  if (dispatch) {
+    const rect = selectedRect(state);
+    dispatch(addRow(state.tr, rect, rect.top));
+  }
+  return true;
+}
+
+/**
+ * Add a table row after the selection.
+ *
+ * @public
+ */
+export function addRowAfter(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  if (!isInTable(state)) return false;
+  if (dispatch) {
+    const rect = selectedRect(state);
+    dispatch(addRow(state.tr, rect, rect.bottom));
+  }
+  return true;
+}
+
+/**
+ * @public
+ */
+export function removeRow(
+  tr: Transaction,
+  { map, table, tableStart }: TableRect,
+  row: number,
+): void {
+  let rowPos = 0;
+  for (let i = 0; i < row; i++) rowPos += table.child(i).nodeSize;
+  const nextRow = rowPos + table.child(row).nodeSize;
+
+  const mapFrom = tr.mapping.maps.length;
+  tr.delete(rowPos + tableStart, nextRow + tableStart);
+
+  for (let col = 0, index = row * map.width; col < map.width; col++, index++) {
+    const pos = map.map[index];
+    if (row > 0 && pos == map.map[index - map.width]) {
+      // If this cell starts in the row above, simply reduce its rowspan
+      const attrs = table.nodeAt(pos)!.attrs as CellAttrs;
+      tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + tableStart), null, {
+        ...attrs,
+        rowspan: attrs.rowspan - 1,
+      });
+      col += attrs.colspan - 1;
+    } else if (row < map.width && pos == map.map[index + map.width]) {
+      // Else, if it continues in the row below, it has to be moved down
+      const cell = table.nodeAt(pos)!;
+      const attrs = cell.attrs as CellAttrs;
+      const copy = cell.type.create(
+        { ...attrs, rowspan: cell.attrs.rowspan - 1 },
+        cell.content,
+      );
+      const newPos = map.positionAt(row + 1, col, table);
+      tr.insert(tr.mapping.slice(mapFrom).map(tableStart + newPos), copy);
+      col += attrs.colspan - 1;
+    }
+  }
+}
+
+/**
+ * Remove the selected rows from a table.
+ *
+ * @public
+ */
+export function deleteRow(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  if (!isInTable(state)) return false;
+  if (dispatch) {
+    const rect = selectedRect(state),
+      tr = state.tr;
+    if (rect.top == 0 && rect.bottom == rect.map.height) return false;
+    for (let i = rect.bottom - 1; ; i--) {
+      removeRow(tr, rect, i);
+      if (i == rect.top) break;
+      const table = rect.tableStart
+        ? tr.doc.nodeAt(rect.tableStart - 1)
+        : tr.doc;
+      if (!table) {
+        throw RangeError('No table found');
+      }
+      rect.table = table;
+      rect.map = TableMap.get(rect.table);
+    }
+    dispatch(tr);
+  }
+  return true;
+}
+
+function isEmpty(cell: Node): boolean {
+  const c = cell.content;
+
+  return (
+    c.childCount == 1 && c.child(0).isTextblock && c.child(0).childCount == 0
+  );
+}
+
+function cellsOverlapRectangle({ width, height, map }: TableMap, rect: Rect) {
+  let indexTop = rect.top * width + rect.left,
+    indexLeft = indexTop;
+  let indexBottom = (rect.bottom - 1) * width + rect.left,
+    indexRight = indexTop + (rect.right - rect.left - 1);
+  for (let i = rect.top; i < rect.bottom; i++) {
+    if (
+      (rect.left > 0 && map[indexLeft] == map[indexLeft - 1]) ||
+      (rect.right < width && map[indexRight] == map[indexRight + 1])
+    )
+      return true;
+    indexLeft += width;
+    indexRight += width;
+  }
+  for (let i = rect.left; i < rect.right; i++) {
+    if (
+      (rect.top > 0 && map[indexTop] == map[indexTop - width]) ||
+      (rect.bottom < height && map[indexBottom] == map[indexBottom + width])
+    )
+      return true;
+    indexTop++;
+    indexBottom++;
+  }
+  return false;
+}
+
+/**
+ * Merge the selected cells into a single cell. Only available when
+ * the selected cells' outline forms a rectangle.
+ *
+ * @public
+ */
+export function mergeCells(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  const sel = state.selection;
+  if (
+    !(sel instanceof CellSelection) ||
+    sel.$anchorCell.pos == sel.$headCell.pos
+  )
+    return false;
+  const rect = selectedRect(state),
+    { map } = rect;
+  if (cellsOverlapRectangle(map, rect)) return false;
+  if (dispatch) {
+    const tr = state.tr;
+    const seen: Record<number, boolean> = {};
+    let content = Fragment.empty;
+    let mergedPos: number | undefined;
+    let mergedCell: Node | undefined;
+    for (let row = rect.top; row < rect.bottom; row++) {
+      for (let col = rect.left; col < rect.right; col++) {
+        const cellPos = map.map[row * map.width + col];
+        const cell = rect.table.nodeAt(cellPos);
+        if (seen[cellPos] || !cell) continue;
+        seen[cellPos] = true;
+        if (mergedPos == null) {
+          mergedPos = cellPos;
+          mergedCell = cell;
+        } else {
+          if (!isEmpty(cell)) content = content.append(cell.content);
+          const mapped = tr.mapping.map(cellPos + rect.tableStart);
+          tr.delete(mapped, mapped + cell.nodeSize);
+        }
+      }
+    }
+    if (mergedPos == null || mergedCell == null) {
+      return true;
+    }
+
+    tr.setNodeMarkup(mergedPos + rect.tableStart, null, {
+      ...addColSpan(
+        mergedCell.attrs as CellAttrs,
+        mergedCell.attrs.colspan,
+        rect.right - rect.left - mergedCell.attrs.colspan,
+      ),
+      rowspan: rect.bottom - rect.top,
+    });
+    if (content.size) {
+      const end = mergedPos + 1 + mergedCell.content.size;
+      const start = isEmpty(mergedCell) ? mergedPos + 1 : end;
+      tr.replaceWith(start + rect.tableStart, end + rect.tableStart, content);
+    }
+    tr.setSelection(
+      new CellSelection(tr.doc.resolve(mergedPos + rect.tableStart)),
+    );
+    dispatch(tr);
+  }
+  return true;
+}
+
+/**
+ * Split a selected cell, whose rowpan or colspan is greater than one,
+ * into smaller cells. Use the first cell type for the new cells.
+ *
+ * @public
+ */
+export function splitCell(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  const nodeTypes = tableNodeTypes(state.schema);
+  return splitCellWithType(({ node }) => {
+    return nodeTypes[node.type.spec.tableRole as TableRole];
+  })(state, dispatch);
+}
+
+/**
+ * @public
+ */
+export interface GetCellTypeOptions {
+  node: Node;
+  row: number;
+  col: number;
+}
+
+/**
+ * Split a selected cell, whose rowpan or colspan is greater than one,
+ * into smaller cells with the cell type (th, td) returned by getType function.
+ *
+ * @public
+ */
+export function splitCellWithType(
+  getCellType: (options: GetCellTypeOptions) => NodeType,
+): Command {
+  return (state, dispatch) => {
+    const sel = state.selection;
+    let cellNode: Node | null | undefined;
+    let cellPos: number | undefined;
+    if (!(sel instanceof CellSelection)) {
+      cellNode = cellWrapping(sel.$from);
+      if (!cellNode) return false;
+      cellPos = cellAround(sel.$from)?.pos;
+    } else {
+      if (sel.$anchorCell.pos != sel.$headCell.pos) return false;
+      cellNode = sel.$anchorCell.nodeAfter;
+      cellPos = sel.$anchorCell.pos;
+    }
+    if (cellNode == null || cellPos == null) {
+      return false;
+    }
+    if (cellNode.attrs.colspan == 1 && cellNode.attrs.rowspan == 1) {
+      return false;
+    }
+    if (dispatch) {
+      let baseAttrs = cellNode.attrs;
+      const attrs = [];
+      const colwidth = baseAttrs.colwidth;
+      if (baseAttrs.rowspan > 1) baseAttrs = { ...baseAttrs, rowspan: 1 };
+      if (baseAttrs.colspan > 1) baseAttrs = { ...baseAttrs, colspan: 1 };
+      const rect = selectedRect(state),
+        tr = state.tr;
+      for (let i = 0; i < rect.right - rect.left; i++)
+        attrs.push(
+          colwidth
+            ? {
+                ...baseAttrs,
+                colwidth: colwidth && colwidth[i] ? [colwidth[i]] : null,
+              }
+            : baseAttrs,
+        );
+      let lastCell;
+      for (let row = rect.top; row < rect.bottom; row++) {
+        let pos = rect.map.positionAt(row, rect.left, rect.table);
+        if (row == rect.top) pos += cellNode.nodeSize;
+        for (let col = rect.left, i = 0; col < rect.right; col++, i++) {
+          if (col == rect.left && row == rect.top) continue;
+          tr.insert(
+            (lastCell = tr.mapping.map(pos + rect.tableStart, 1)),
+            getCellType({ node: cellNode, row, col }).createAndFill(attrs[i])!,
+          );
+        }
+      }
+      tr.setNodeMarkup(
+        cellPos,
+        getCellType({ node: cellNode, row: rect.top, col: rect.left }),
+        attrs[0],
+      );
+      if (sel instanceof CellSelection)
+        tr.setSelection(
+          new CellSelection(
+            tr.doc.resolve(sel.$anchorCell.pos),
+            lastCell ? tr.doc.resolve(lastCell) : undefined,
+          ),
+        );
+      dispatch(tr);
+    }
+    return true;
+  };
+}
+
+/**
+ * Returns a command that sets the given attribute to the given value,
+ * and is only available when the currently selected cell doesn't
+ * already have that attribute set to that value.
+ *
+ * @public
+ */
+export function setCellAttr(name: string, value: unknown): Command {
+  return function (state, dispatch) {
+    if (!isInTable(state)) return false;
+    const $cell = selectionCell(state);
+    if ($cell.nodeAfter!.attrs[name] === value) return false;
+    if (dispatch) {
+      const tr = state.tr;
+      if (state.selection instanceof CellSelection)
+        state.selection.forEachCell((node, pos) => {
+          if (node.attrs[name] !== value)
+            tr.setNodeMarkup(pos, null, {
+              ...node.attrs,
+              [name]: value,
+            });
+        });
+      else
+        tr.setNodeMarkup($cell.pos, null, {
+          ...$cell.nodeAfter!.attrs,
+          [name]: value,
+        });
+      dispatch(tr);
+    }
+    return true;
+  };
+}
+
+function deprecated_toggleHeader(type: ToggleHeaderType): Command {
+  return function (state, dispatch) {
+    if (!isInTable(state)) return false;
+    if (dispatch) {
+      const types = tableNodeTypes(state.schema);
+      const rect = selectedRect(state),
+        tr = state.tr;
+      const cells = rect.map.cellsInRect(
+        type == 'column'
+          ? {
+              left: rect.left,
+              top: 0,
+              right: rect.right,
+              bottom: rect.map.height,
+            }
+          : type == 'row'
+          ? {
+              left: 0,
+              top: rect.top,
+              right: rect.map.width,
+              bottom: rect.bottom,
+            }
+          : rect,
+      );
+      const nodes = cells.map((pos) => rect.table.nodeAt(pos)!);
+      for (
+        let i = 0;
+        i < cells.length;
+        i++ // Remove headers, if any
+      )
+        if (nodes[i].type == types.header_cell)
+          tr.setNodeMarkup(
+            rect.tableStart + cells[i],
+            types.cell,
+            nodes[i].attrs,
+          );
+      if (tr.steps.length == 0)
+        for (
+          let i = 0;
+          i < cells.length;
+          i++ // No headers removed, add instead
+        )
+          tr.setNodeMarkup(
+            rect.tableStart + cells[i],
+            types.header_cell,
+            nodes[i].attrs,
+          );
+      dispatch(tr);
+    }
+    return true;
+  };
+}
+
+function isHeaderEnabledByType(
+  type: 'row' | 'column',
+  rect: TableRect,
+  types: Record<string, NodeType>,
+): boolean {
+  // Get cell positions for first row or first column
+  const cellPositions = rect.map.cellsInRect({
+    left: 0,
+    top: 0,
+    right: type == 'row' ? rect.map.width : 1,
+    bottom: type == 'column' ? rect.map.height : 1,
+  });
+
+  for (let i = 0; i < cellPositions.length; i++) {
+    const cell = rect.table.nodeAt(cellPositions[i]);
+    if (cell && cell.type !== types.header_cell) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+/**
+ * @public
+ */
+export type ToggleHeaderType = 'column' | 'row' | 'cell';
+
+/**
+ * Toggles between row/column header and normal cells (Only applies to first row/column).
+ * For deprecated behavior pass `useDeprecatedLogic` in options with true.
+ *
+ * @public
+ */
+export function toggleHeader(
+  type: ToggleHeaderType,
+  options?: { useDeprecatedLogic: boolean } | undefined,
+): Command {
+  options = options || { useDeprecatedLogic: false };
+
+  if (options.useDeprecatedLogic) return deprecated_toggleHeader(type);
+
+  return function (state, dispatch) {
+    if (!isInTable(state)) return false;
+    if (dispatch) {
+      const types = tableNodeTypes(state.schema);
+      const rect = selectedRect(state),
+        tr = state.tr;
+
+      const isHeaderRowEnabled = isHeaderEnabledByType('row', rect, types);
+      const isHeaderColumnEnabled = isHeaderEnabledByType(
+        'column',
+        rect,
+        types,
+      );
+
+      const isHeaderEnabled =
+        type === 'column'
+          ? isHeaderRowEnabled
+          : type === 'row'
+          ? isHeaderColumnEnabled
+          : false;
+
+      const selectionStartsAt = isHeaderEnabled ? 1 : 0;
+
+      const cellsRect =
+        type == 'column'
+          ? {
+              left: 0,
+              top: selectionStartsAt,
+              right: 1,
+              bottom: rect.map.height,
+            }
+          : type == 'row'
+          ? {
+              left: selectionStartsAt,
+              top: 0,
+              right: rect.map.width,
+              bottom: 1,
+            }
+          : rect;
+
+      const newType =
+        type == 'column'
+          ? isHeaderColumnEnabled
+            ? types.cell
+            : types.header_cell
+          : type == 'row'
+          ? isHeaderRowEnabled
+            ? types.cell
+            : types.header_cell
+          : types.cell;
+
+      rect.map.cellsInRect(cellsRect).forEach((relativeCellPos) => {
+        const cellPos = relativeCellPos + rect.tableStart;
+        const cell = tr.doc.nodeAt(cellPos);
+
+        if (cell) {
+          tr.setNodeMarkup(cellPos, newType, cell.attrs);
+        }
+      });
+
+      dispatch(tr);
+    }
+    return true;
+  };
+}
+
+/**
+ * Toggles whether the selected row contains header cells.
+ *
+ * @public
+ */
+export const toggleHeaderRow: Command = toggleHeader('row', {
+  useDeprecatedLogic: true,
+});
+
+/**
+ * Toggles whether the selected column contains header cells.
+ *
+ * @public
+ */
+export const toggleHeaderColumn: Command = toggleHeader('column', {
+  useDeprecatedLogic: true,
+});
+
+/**
+ * Toggles whether the selected cells are header cells.
+ *
+ * @public
+ */
+export const toggleHeaderCell: Command = toggleHeader('cell', {
+  useDeprecatedLogic: true,
+});
+
+function findNextCell($cell: ResolvedPos, dir: Direction): number | null {
+  if (dir < 0) {
+    const before = $cell.nodeBefore;
+    if (before) return $cell.pos - before.nodeSize;
+    for (
+      let row = $cell.index(-1) - 1, rowEnd = $cell.before();
+      row >= 0;
+      row--
+    ) {
+      const rowNode = $cell.node(-1).child(row);
+      const lastChild = rowNode.lastChild;
+      if (lastChild) {
+        return rowEnd - 1 - lastChild.nodeSize;
+      }
+      rowEnd -= rowNode.nodeSize;
+    }
+  } else {
+    if ($cell.index() < $cell.parent.childCount - 1) {
+      return $cell.pos + $cell.nodeAfter!.nodeSize;
+    }
+    const table = $cell.node(-1);
+    for (
+      let row = $cell.indexAfter(-1), rowStart = $cell.after();
+      row < table.childCount;
+      row++
+    ) {
+      const rowNode = table.child(row);
+      if (rowNode.childCount) return rowStart + 1;
+      rowStart += rowNode.nodeSize;
+    }
+  }
+  return null;
+}
+
+/**
+ * Returns a command for selecting the next (direction=1) or previous
+ * (direction=-1) cell in a table.
+ *
+ * @public
+ */
+export function goToNextCell(direction: Direction): Command {
+  return function (state, dispatch) {
+    if (!isInTable(state)) return false;
+    const cell = findNextCell(selectionCell(state), direction);
+    if (cell == null) return false;
+    if (dispatch) {
+      const $cell = state.doc.resolve(cell);
+      dispatch(
+        state.tr
+          .setSelection(TextSelection.between($cell, moveCellForward($cell)))
+          .scrollIntoView(),
+      );
+    }
+    return true;
+  };
+}
+
+/**
+ * Deletes the table around the selection, if any.
+ *
+ * @public
+ */
+export function deleteTable(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  const $pos = state.selection.$anchor;
+  for (let d = $pos.depth; d > 0; d--) {
+    const node = $pos.node(d);
+    if (node.type.spec.tableRole == 'table') {
+      if (dispatch)
+        dispatch(
+          state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView(),
+        );
+      return true;
+    }
+  }
+  return false;
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/copypaste.ts b/wax-prosemirror-services/src/TablesService/tableSrc/copypaste.ts
new file mode 100644
index 000000000..565bc815c
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/copypaste.ts
@@ -0,0 +1,381 @@
+// Utilities used for copy/paste handling.
+//
+// This module handles pasting cell content into tables, or pasting
+// anything into a cell selection, as replacing a block of cells with
+// the content of the selection. When pasting cells into a cell, that
+// involves placing the block of pasted content so that its top left
+// aligns with the selection cell, optionally extending the table to
+// the right or bottom to make sure it is large enough. Pasting into a
+// cell selection is different, here the cells in the selection are
+// clipped to the selection's rectangle, optionally repeating the
+// pasted cells when they are smaller than the selection.
+
+import { Fragment, Node, NodeType, Schema, Slice } from 'prosemirror-model';
+import { Transform } from 'prosemirror-transform';
+
+import { EditorState, Transaction } from 'prosemirror-state';
+import { CellSelection } from './cellselection';
+import { tableNodeTypes } from './schema';
+import { ColWidths, Rect, TableMap } from './tablemap';
+import { CellAttrs, removeColSpan } from './util';
+
+/**
+ * @internal
+ */
+export type Area = { width: number; height: number; rows: Fragment[] };
+
+// Utilities to help with copying and pasting table cells
+
+/**
+ * Get a rectangular area of cells from a slice, or null if the outer
+ * nodes of the slice aren't table cells or rows.
+ *
+ * @internal
+ */
+export function pastedCells(slice: Slice): Area | null {
+  if (!slice.size) return null;
+  let { content, openStart, openEnd } = slice;
+  while (
+    content.childCount == 1 &&
+    ((openStart > 0 && openEnd > 0) ||
+      content.child(0).type.spec.tableRole == 'table')
+  ) {
+    openStart--;
+    openEnd--;
+    content = content.child(0).content;
+  }
+  const first = content.child(0);
+  const role = first.type.spec.tableRole;
+  const schema = first.type.schema,
+    rows = [];
+  if (role == 'row') {
+    for (let i = 0; i < content.childCount; i++) {
+      let cells = content.child(i).content;
+      const left = i ? 0 : Math.max(0, openStart - 1);
+      const right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1);
+      if (left || right)
+        cells = fitSlice(
+          tableNodeTypes(schema).row,
+          new Slice(cells, left, right),
+        ).content;
+      rows.push(cells);
+    }
+  } else if (role == 'cell' || role == 'header_cell') {
+    rows.push(
+      openStart || openEnd
+        ? fitSlice(
+            tableNodeTypes(schema).row,
+            new Slice(content, openStart, openEnd),
+          ).content
+        : content,
+    );
+  } else {
+    return null;
+  }
+  return ensureRectangular(schema, rows);
+}
+
+// Compute the width and height of a set of cells, and make sure each
+// row has the same number of cells.
+function ensureRectangular(schema: Schema, rows: Fragment[]): Area {
+  const widths: ColWidths = [];
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i];
+    for (let j = row.childCount - 1; j >= 0; j--) {
+      const { rowspan, colspan } = row.child(j).attrs;
+      for (let r = i; r < i + rowspan; r++)
+        widths[r] = (widths[r] || 0) + colspan;
+    }
+  }
+  let width = 0;
+  for (let r = 0; r < widths.length; r++) width = Math.max(width, widths[r]);
+  for (let r = 0; r < widths.length; r++) {
+    if (r >= rows.length) rows.push(Fragment.empty);
+    if (widths[r] < width) {
+      const empty = tableNodeTypes(schema).cell.createAndFill()!;
+      const cells = [];
+      for (let i = widths[r]; i < width; i++) {
+        cells.push(empty);
+      }
+      rows[r] = rows[r].append(Fragment.from(cells));
+    }
+  }
+  return { height: rows.length, width, rows };
+}
+
+export function fitSlice(nodeType: NodeType, slice: Slice): Node {
+  const node = nodeType.createAndFill()!;
+  const tr = new Transform(node).replace(0, node.content.size, slice);
+  return tr.doc;
+}
+
+/**
+ * Clip or extend (repeat) the given set of cells to cover the given
+ * width and height. Will clip rowspan/colspan cells at the edges when
+ * they stick out.
+ *
+ * @internal
+ */
+export function clipCells(
+  { width, height, rows }: Area,
+  newWidth: number,
+  newHeight: number,
+): Area {
+  if (width != newWidth) {
+    const added: number[] = [];
+    const newRows: Fragment[] = [];
+    for (let row = 0; row < rows.length; row++) {
+      const frag = rows[row],
+        cells = [];
+      for (let col = added[row] || 0, i = 0; col < newWidth; i++) {
+        let cell = frag.child(i % frag.childCount);
+        if (col + cell.attrs.colspan > newWidth)
+          cell = cell.type.createChecked(
+            removeColSpan(
+              cell.attrs as CellAttrs,
+              cell.attrs.colspan,
+              col + cell.attrs.colspan - newWidth,
+            ),
+            cell.content,
+          );
+        cells.push(cell);
+        col += cell.attrs.colspan;
+        for (let j = 1; j < cell.attrs.rowspan; j++)
+          added[row + j] = (added[row + j] || 0) + cell.attrs.colspan;
+      }
+      newRows.push(Fragment.from(cells));
+    }
+    rows = newRows;
+    width = newWidth;
+  }
+
+  if (height != newHeight) {
+    const newRows = [];
+    for (let row = 0, i = 0; row < newHeight; row++, i++) {
+      const cells = [],
+        source = rows[i % height];
+      for (let j = 0; j < source.childCount; j++) {
+        let cell = source.child(j);
+        if (row + cell.attrs.rowspan > newHeight)
+          cell = cell.type.create(
+            {
+              ...cell.attrs,
+              rowspan: Math.max(1, newHeight - cell.attrs.rowspan),
+            },
+            cell.content,
+          );
+        cells.push(cell);
+      }
+      newRows.push(Fragment.from(cells));
+    }
+    rows = newRows;
+    height = newHeight;
+  }
+
+  return { width, height, rows };
+}
+
+// Make sure a table has at least the given width and height. Return
+// true if something was changed.
+function growTable(
+  tr: Transaction,
+  map: TableMap,
+  table: Node,
+  start: number,
+  width: number,
+  height: number,
+  mapFrom: number,
+): boolean {
+  const schema = tr.doc.type.schema;
+  const types = tableNodeTypes(schema);
+  let empty;
+  let emptyHead;
+  if (width > map.width) {
+    for (let row = 0, rowEnd = 0; row < map.height; row++) {
+      const rowNode = table.child(row);
+      rowEnd += rowNode.nodeSize;
+      const cells: Node[] = [];
+      let add: Node;
+      if (rowNode.lastChild == null || rowNode.lastChild.type == types.cell)
+        add = empty || (empty = types.cell.createAndFill()!);
+      else add = emptyHead || (emptyHead = types.header_cell.createAndFill()!);
+      for (let i = map.width; i < width; i++) cells.push(add);
+      tr.insert(tr.mapping.slice(mapFrom).map(rowEnd - 1 + start), cells);
+    }
+  }
+  if (height > map.height) {
+    const cells = [];
+    for (
+      let i = 0, start = (map.height - 1) * map.width;
+      i < Math.max(map.width, width);
+      i++
+    ) {
+      const header =
+        i >= map.width
+          ? false
+          : table.nodeAt(map.map[start + i])!.type == types.header_cell;
+      cells.push(
+        header
+          ? emptyHead || (emptyHead = types.header_cell.createAndFill()!)
+          : empty || (empty = types.cell.createAndFill()!),
+      );
+    }
+
+    const emptyRow = types.row.create(null, Fragment.from(cells)),
+      rows = [];
+    for (let i = map.height; i < height; i++) rows.push(emptyRow);
+    tr.insert(tr.mapping.slice(mapFrom).map(start + table.nodeSize - 2), rows);
+  }
+  return !!(empty || emptyHead);
+}
+
+// Make sure the given line (left, top) to (right, top) doesn't cross
+// any rowspan cells by splitting cells that cross it. Return true if
+// something changed.
+function isolateHorizontal(
+  tr: Transaction,
+  map: TableMap,
+  table: Node,
+  start: number,
+  left: number,
+  right: number,
+  top: number,
+  mapFrom: number,
+): boolean {
+  if (top == 0 || top == map.height) return false;
+  let found = false;
+  for (let col = left; col < right; col++) {
+    const index = top * map.width + col,
+      pos = map.map[index];
+    if (map.map[index - map.width] == pos) {
+      found = true;
+      const cell = table.nodeAt(pos)!;
+      const { top: cellTop, left: cellLeft } = map.findCell(pos);
+      tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + start), null, {
+        ...cell.attrs,
+        rowspan: top - cellTop,
+      });
+      tr.insert(
+        tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)),
+        cell.type.createAndFill({
+          ...cell.attrs,
+          rowspan: cellTop + cell.attrs.rowspan - top,
+        })!,
+      );
+      col += cell.attrs.colspan - 1;
+    }
+  }
+  return found;
+}
+
+// Make sure the given line (left, top) to (left, bottom) doesn't
+// cross any colspan cells by splitting cells that cross it. Return
+// true if something changed.
+function isolateVertical(
+  tr: Transaction,
+  map: TableMap,
+  table: Node,
+  start: number,
+  top: number,
+  bottom: number,
+  left: number,
+  mapFrom: number,
+): boolean {
+  if (left == 0 || left == map.width) return false;
+  let found = false;
+  for (let row = top; row < bottom; row++) {
+    const index = row * map.width + left,
+      pos = map.map[index];
+    if (map.map[index - 1] == pos) {
+      found = true;
+      const cell = table.nodeAt(pos)!;
+      const cellLeft = map.colCount(pos);
+      const updatePos = tr.mapping.slice(mapFrom).map(pos + start);
+      tr.setNodeMarkup(
+        updatePos,
+        null,
+        removeColSpan(
+          cell.attrs as CellAttrs,
+          left - cellLeft,
+          cell.attrs.colspan - (left - cellLeft),
+        ),
+      );
+      tr.insert(
+        updatePos + cell.nodeSize,
+        cell.type.createAndFill(
+          removeColSpan(cell.attrs as CellAttrs, 0, left - cellLeft),
+        )!,
+      );
+      row += cell.attrs.rowspan - 1;
+    }
+  }
+  return found;
+}
+
+/**
+ * Insert the given set of cells (as returned by `pastedCells`) into a
+ * table, at the position pointed at by rect.
+ *
+ * @internal
+ */
+export function insertCells(
+  state: EditorState,
+  dispatch: (tr: Transaction) => void,
+  tableStart: number,
+  rect: Rect,
+  cells: Area,
+): void {
+  let table = tableStart ? state.doc.nodeAt(tableStart - 1) : state.doc;
+  if (!table) {
+    throw new Error('No table found');
+  }
+  let map = TableMap.get(table);
+  const { top, left } = rect;
+  const right = left + cells.width,
+    bottom = top + cells.height;
+  const tr = state.tr;
+  let mapFrom = 0;
+
+  function recomp(): void {
+    table = tableStart ? tr.doc.nodeAt(tableStart - 1) : tr.doc;
+    if (!table) {
+      throw new Error('No table found');
+    }
+    map = TableMap.get(table);
+    mapFrom = tr.mapping.maps.length;
+  }
+
+  // Prepare the table to be large enough and not have any cells
+  // crossing the boundaries of the rectangle that we want to
+  // insert into. If anything about it changes, recompute the table
+  // map so that subsequent operations can see the current shape.
+  if (growTable(tr, map, table, tableStart, right, bottom, mapFrom)) recomp();
+  if (isolateHorizontal(tr, map, table, tableStart, left, right, top, mapFrom))
+    recomp();
+  if (
+    isolateHorizontal(tr, map, table, tableStart, left, right, bottom, mapFrom)
+  )
+    recomp();
+  if (isolateVertical(tr, map, table, tableStart, top, bottom, left, mapFrom))
+    recomp();
+  if (isolateVertical(tr, map, table, tableStart, top, bottom, right, mapFrom))
+    recomp();
+
+  for (let row = top; row < bottom; row++) {
+    const from = map.positionAt(row, left, table),
+      to = map.positionAt(row, right, table);
+    tr.replace(
+      tr.mapping.slice(mapFrom).map(from + tableStart),
+      tr.mapping.slice(mapFrom).map(to + tableStart),
+      new Slice(cells.rows[row - top], 0, 0),
+    );
+  }
+  recomp();
+  tr.setSelection(
+    new CellSelection(
+      tr.doc.resolve(tableStart + map.positionAt(top, left, table)),
+      tr.doc.resolve(tableStart + map.positionAt(bottom - 1, right - 1, table)),
+    ),
+  );
+  dispatch(tr);
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/fixtables.ts b/wax-prosemirror-services/src/TablesService/tableSrc/fixtables.ts
new file mode 100644
index 000000000..e35bc6520
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/fixtables.ts
@@ -0,0 +1,150 @@
+// This file defines helpers for normalizing tables, making sure no
+// cells overlap (which can happen, if you have the wrong col- and
+// rowspans) and that each row has the same width. Uses the problems
+// reported by `TableMap`.
+
+import { Node } from 'prosemirror-model';
+import { EditorState, PluginKey, Transaction } from 'prosemirror-state';
+import { tableNodeTypes, TableRole } from './schema';
+import { TableMap } from './tablemap';
+import { CellAttrs, removeColSpan } from './util';
+
+/**
+ * @public
+ */
+export const fixTablesKey = new PluginKey<{ fixTables: boolean }>('fix-tables');
+
+/**
+ * Helper for iterating through the nodes in a document that changed
+ * compared to the given previous document. Useful for avoiding
+ * duplicate work on each transaction.
+ *
+ * @public
+ */
+function changedDescendants(
+  old: Node,
+  cur: Node,
+  offset: number,
+  f: (node: Node, pos: number) => void,
+): void {
+  const oldSize = old.childCount,
+    curSize = cur.childCount;
+  outer: for (let i = 0, j = 0; i < curSize; i++) {
+    const child = cur.child(i);
+    for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) {
+      if (old.child(scan) == child) {
+        j = scan + 1;
+        offset += child.nodeSize;
+        continue outer;
+      }
+    }
+    f(child, offset);
+    if (j < oldSize && old.child(j).sameMarkup(child))
+      changedDescendants(old.child(j), child, offset + 1, f);
+    else child.nodesBetween(0, child.content.size, f, offset + 1);
+    offset += child.nodeSize;
+  }
+}
+
+/**
+ * Inspect all tables in the given state's document and return a
+ * transaction that fixes them, if necessary. If `oldState` was
+ * provided, that is assumed to hold a previous, known-good state,
+ * which will be used to avoid re-scanning unchanged parts of the
+ * document.
+ *
+ * @public
+ */
+export function fixTables(
+  state: EditorState,
+  oldState?: EditorState,
+): Transaction | undefined {
+  let tr: Transaction | undefined;
+  const check = (node: Node, pos: number) => {
+    if (node.type.spec.tableRole == 'table')
+      tr = fixTable(state, node, pos, tr);
+  };
+  if (!oldState) state.doc.descendants(check);
+  else if (oldState.doc != state.doc)
+    changedDescendants(oldState.doc, state.doc, 0, check);
+  return tr;
+}
+
+// Fix the given table, if necessary. Will append to the transaction
+// it was given, if non-null, or create a new one if necessary.
+export function fixTable(
+  state: EditorState,
+  table: Node,
+  tablePos: number,
+  tr: Transaction | undefined,
+): Transaction | undefined {
+  const map = TableMap.get(table);
+  if (!map.problems) return tr;
+  if (!tr) tr = state.tr;
+
+  // Track which rows we must add cells to, so that we can adjust that
+  // when fixing collisions.
+  const mustAdd: number[] = [];
+  for (let i = 0; i < map.height; i++) mustAdd.push(0);
+  for (let i = 0; i < map.problems.length; i++) {
+    const prob = map.problems[i];
+    if (prob.type == 'collision') {
+      const cell = table.nodeAt(prob.pos);
+      if (!cell) continue;
+      const attrs = cell.attrs as CellAttrs;
+      for (let j = 0; j < attrs.rowspan; j++) mustAdd[prob.row + j] += prob.n;
+      tr.setNodeMarkup(
+        tr.mapping.map(tablePos + 1 + prob.pos),
+        null,
+        removeColSpan(attrs, attrs.colspan - prob.n, prob.n),
+      );
+    } else if (prob.type == 'missing') {
+      mustAdd[prob.row] += prob.n;
+    } else if (prob.type == 'overlong_rowspan') {
+      const cell = table.nodeAt(prob.pos);
+      if (!cell) continue;
+      tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, {
+        ...cell.attrs,
+        rowspan: cell.attrs.rowspan - prob.n,
+      });
+    } else if (prob.type == 'colwidth mismatch') {
+      const cell = table.nodeAt(prob.pos);
+      if (!cell) continue;
+      tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, {
+        ...cell.attrs,
+        colwidth: prob.colwidth,
+      });
+    }
+  }
+  let first, last;
+  for (let i = 0; i < mustAdd.length; i++)
+    if (mustAdd[i]) {
+      if (first == null) first = i;
+      last = i;
+    }
+  // Add the necessary cells, using a heuristic for whether to add the
+  // cells at the start or end of the rows (if it looks like a 'bite'
+  // was taken out of the table, add cells at the start of the row
+  // after the bite. Otherwise add them at the end).
+  for (let i = 0, pos = tablePos + 1; i < map.height; i++) {
+    const row = table.child(i);
+    const end = pos + row.nodeSize;
+    const add = mustAdd[i];
+    if (add > 0) {
+      let role: TableRole = 'cell';
+      if (row.firstChild) {
+        role = row.firstChild.type.spec.tableRole;
+      }
+      const nodes: Node[] = [];
+      for (let j = 0; j < add; j++) {
+        const node = tableNodeTypes(state.schema)[role].createAndFill();
+
+        if (node) nodes.push(node);
+      }
+      const side = (i == 0 || first == i - 1) && last == i ? pos + 1 : end - 1;
+      tr.insert(tr.mapping.map(side), nodes);
+    }
+    pos = end;
+  }
+  return tr.setMeta(fixTablesKey, { fixTables: true });
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/index.ts b/wax-prosemirror-services/src/TablesService/tableSrc/index.ts
new file mode 100644
index 000000000..db21e4cb2
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/index.ts
@@ -0,0 +1,136 @@
+// This file defines a plugin that handles the drawing of cell
+// selections and the basic user interactions for creating and working
+// with such selections. It also makes sure that, after each
+// transaction, the shapes of tables are normalized to be rectangular
+// and not contain overlapping cells.
+
+import { Plugin } from 'prosemirror-state';
+
+import { drawCellSelection, normalizeSelection } from './cellselection';
+import { fixTables, fixTablesKey } from './fixtables';
+import {
+  handleKeyDown,
+  handleMouseDown,
+  handlePaste,
+  handleTripleClick,
+} from './input';
+import { tableEditingKey } from './util';
+
+export { CellBookmark, CellSelection } from './cellselection';
+export type { CellSelectionJSON } from './cellselection';
+export {
+  columnResizing,
+  columnResizingPluginKey,
+  ResizeState,
+} from './columnresizing';
+export type { ColumnResizingOptions, Dragging } from './columnresizing';
+export * from './commands';
+export {
+  clipCells as __clipCells,
+  insertCells as __insertCells,
+  pastedCells as __pastedCells,
+} from './copypaste';
+export type { Area as __Area } from './copypaste';
+export type { Direction } from './input';
+export { tableNodes, tableNodeTypes } from './schema';
+export type {
+  CellAttributes,
+  getFromDOM,
+  setDOMAttr,
+  TableNodes,
+  TableNodesOptions,
+  TableRole,
+} from './schema';
+export { TableMap } from './tablemap';
+export type { ColWidths, Problem, Rect } from './tablemap';
+export { TableView, updateColumnsOnResize } from './tableview';
+export {
+  addColSpan,
+  cellAround,
+  colCount,
+  columnIsHeader,
+  findCell,
+  inSameTable,
+  isInTable,
+  moveCellForward,
+  nextCell,
+  pointsAtCell,
+  removeColSpan,
+  selectionCell,
+} from './util';
+export type { MutableAttrs } from './util';
+export { fixTables, handlePaste, fixTablesKey };
+export { tableEditingKey };
+
+/**
+ * @public
+ */
+export type TableEditingOptions = {
+  allowTableNodeSelection?: boolean;
+};
+
+/**
+ * Creates a [plugin](http://prosemirror.net/docs/ref/#state.Plugin)
+ * that, when added to an editor, enables cell-selection, handles
+ * cell-based copy/paste, and makes sure tables stay well-formed (each
+ * row has the same width, and cells don't overlap).
+ *
+ * You should probably put this plugin near the end of your array of
+ * plugins, since it handles mouse and arrow key events in tables
+ * rather broadly, and other plugins, like the gap cursor or the
+ * column-width dragging plugin, might want to get a turn first to
+ * perform more specific behavior.
+ *
+ * @public
+ */
+export function tableEditing({
+  allowTableNodeSelection = false,
+}: TableEditingOptions = {}): Plugin {
+  return new Plugin({
+    key: tableEditingKey,
+
+    // This piece of state is used to remember when a mouse-drag
+    // cell-selection is happening, so that it can continue even as
+    // transactions (which might move its anchor cell) come in.
+    state: {
+      init() {
+        return null;
+      },
+      apply(tr, cur) {
+        const set = tr.getMeta(tableEditingKey);
+        if (set != null) return set == -1 ? null : set;
+        if (cur == null || !tr.docChanged) return cur;
+        const { deleted, pos } = tr.mapping.mapResult(cur);
+        return deleted ? null : pos;
+      },
+    },
+
+    props: {
+      decorations: drawCellSelection,
+
+      handleDOMEvents: {
+        mousedown: handleMouseDown,
+      },
+
+      createSelectionBetween(view) {
+        return tableEditingKey.getState(view.state) != null
+          ? view.state.selection
+          : null;
+      },
+
+      handleTripleClick,
+
+      handleKeyDown,
+
+      handlePaste,
+    },
+
+    appendTransaction(_, oldState, state) {
+      return normalizeSelection(
+        state,
+        fixTables(state, oldState),
+        allowTableNodeSelection,
+      );
+    },
+  });
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/input.ts b/wax-prosemirror-services/src/TablesService/tableSrc/input.ts
new file mode 100644
index 000000000..96e7b63f3
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/input.ts
@@ -0,0 +1,310 @@
+// This file defines a number of helpers for wiring up user input to
+// table-related functionality.
+
+import { Fragment, ResolvedPos, Slice } from 'prosemirror-model';
+import {
+  Command,
+  EditorState,
+  Selection,
+  TextSelection,
+  Transaction,
+} from 'prosemirror-state';
+import { keydownHandler } from 'prosemirror-keymap';
+
+import {
+  cellAround,
+  inSameTable,
+  isInTable,
+  tableEditingKey,
+  nextCell,
+  selectionCell,
+} from './util';
+import { CellSelection } from './cellselection';
+import { TableMap } from './tablemap';
+import { clipCells, fitSlice, insertCells, pastedCells } from './copypaste';
+import { tableNodeTypes } from './schema';
+import { EditorView } from 'prosemirror-view';
+
+type Axis = 'horiz' | 'vert';
+
+/**
+ * @public
+ */
+export type Direction = -1 | 1;
+
+export const handleKeyDown = keydownHandler({
+  ArrowLeft: arrow('horiz', -1),
+  ArrowRight: arrow('horiz', 1),
+  ArrowUp: arrow('vert', -1),
+  ArrowDown: arrow('vert', 1),
+
+  'Shift-ArrowLeft': shiftArrow('horiz', -1),
+  'Shift-ArrowRight': shiftArrow('horiz', 1),
+  'Shift-ArrowUp': shiftArrow('vert', -1),
+  'Shift-ArrowDown': shiftArrow('vert', 1),
+
+  Backspace: deleteCellSelection,
+  'Mod-Backspace': deleteCellSelection,
+  Delete: deleteCellSelection,
+  'Mod-Delete': deleteCellSelection,
+});
+
+function maybeSetSelection(
+  state: EditorState,
+  dispatch: undefined | ((tr: Transaction) => void),
+  selection: Selection,
+): boolean {
+  if (selection.eq(state.selection)) return false;
+  if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
+  return true;
+}
+
+function arrow(axis: Axis, dir: Direction): Command {
+  return (state, dispatch, view) => {
+    if (!view) return false;
+    const sel = state.selection;
+    if (sel instanceof CellSelection) {
+      return maybeSetSelection(
+        state,
+        dispatch,
+        Selection.near(sel.$headCell, dir),
+      );
+    }
+    if (axis != 'horiz' && !sel.empty) return false;
+    const end = atEndOfCell(view, axis, dir);
+    if (end == null) return false;
+    if (axis == 'horiz') {
+      return maybeSetSelection(
+        state,
+        dispatch,
+        Selection.near(state.doc.resolve(sel.head + dir), dir),
+      );
+    } else {
+      const $cell = state.doc.resolve(end);
+      const $next = nextCell($cell, axis, dir);
+      let newSel;
+      if ($next) newSel = Selection.near($next, 1);
+      else if (dir < 0)
+        newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1);
+      else newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1);
+      return maybeSetSelection(state, dispatch, newSel);
+    }
+  };
+}
+
+function shiftArrow(axis: Axis, dir: Direction): Command {
+  return (state, dispatch, view) => {
+    if (!view) return false;
+    const sel = state.selection;
+    let cellSel: CellSelection;
+    if (sel instanceof CellSelection) {
+      cellSel = sel;
+    } else {
+      const end = atEndOfCell(view, axis, dir);
+      if (end == null) return false;
+      cellSel = new CellSelection(state.doc.resolve(end));
+    }
+
+    const $head = nextCell(cellSel.$headCell, axis, dir);
+    if (!$head) return false;
+    return maybeSetSelection(
+      state,
+      dispatch,
+      new CellSelection(cellSel.$anchorCell, $head),
+    );
+  };
+}
+
+function deleteCellSelection(
+  state: EditorState,
+  dispatch?: (tr: Transaction) => void,
+): boolean {
+  const sel = state.selection;
+  if (!(sel instanceof CellSelection)) return false;
+  if (dispatch) {
+    const tr = state.tr;
+    const baseContent = tableNodeTypes(state.schema).cell.createAndFill()!
+      .content;
+    sel.forEachCell((cell, pos) => {
+      if (!cell.content.eq(baseContent))
+        tr.replace(
+          tr.mapping.map(pos + 1),
+          tr.mapping.map(pos + cell.nodeSize - 1),
+          new Slice(baseContent, 0, 0),
+        );
+    });
+    if (tr.docChanged) dispatch(tr);
+  }
+  return true;
+}
+
+export function handleTripleClick(view: EditorView, pos: number): boolean {
+  const doc = view.state.doc,
+    $cell = cellAround(doc.resolve(pos));
+  if (!$cell) return false;
+  view.dispatch(view.state.tr.setSelection(new CellSelection($cell)));
+  return true;
+}
+
+/**
+ * @public
+ */
+export function handlePaste(
+  view: EditorView,
+  _: ClipboardEvent,
+  slice: Slice,
+): boolean {
+  if (!isInTable(view.state)) return false;
+  let cells = pastedCells(slice);
+  const sel = view.state.selection;
+  if (sel instanceof CellSelection) {
+    if (!cells)
+      cells = {
+        width: 1,
+        height: 1,
+        rows: [
+          Fragment.from(
+            fitSlice(tableNodeTypes(view.state.schema).cell, slice),
+          ),
+        ],
+      };
+    const table = sel.$anchorCell.node(-1);
+    const start = sel.$anchorCell.start(-1);
+    const rect = TableMap.get(table).rectBetween(
+      sel.$anchorCell.pos - start,
+      sel.$headCell.pos - start,
+    );
+    cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top);
+    insertCells(view.state, view.dispatch, start, rect, cells);
+    return true;
+  } else if (cells) {
+    const $cell = selectionCell(view.state);
+    const start = $cell.start(-1);
+    insertCells(
+      view.state,
+      view.dispatch,
+      start,
+      TableMap.get($cell.node(-1)).findCell($cell.pos - start),
+      cells,
+    );
+    return true;
+  } else {
+    return false;
+  }
+}
+
+export function handleMouseDown(
+  view: EditorView,
+  startEvent: MouseEvent,
+): void {
+  if (startEvent.ctrlKey || startEvent.metaKey) return;
+
+  const startDOMCell = domInCell(view, startEvent.target as Node);
+  let $anchor;
+  if (startEvent.shiftKey && view.state.selection instanceof CellSelection) {
+    // Adding to an existing cell selection
+    setCellSelection(view.state.selection.$anchorCell, startEvent);
+    startEvent.preventDefault();
+  } else if (
+    startEvent.shiftKey &&
+    startDOMCell &&
+    ($anchor = cellAround(view.state.selection.$anchor)) != null &&
+    cellUnderMouse(view, startEvent)?.pos != $anchor.pos
+  ) {
+    // Adding to a selection that starts in another cell (causing a
+    // cell selection to be created).
+    setCellSelection($anchor, startEvent);
+    startEvent.preventDefault();
+  } else if (!startDOMCell) {
+    // Not in a cell, let the default behavior happen.
+    return;
+  }
+
+  // Create and dispatch a cell selection between the given anchor and
+  // the position under the mouse.
+  function setCellSelection($anchor: ResolvedPos, event: MouseEvent): void {
+    let $head = cellUnderMouse(view, event);
+    const starting = tableEditingKey.getState(view.state) == null;
+    if (!$head || !inSameTable($anchor, $head)) {
+      if (starting) $head = $anchor;
+      else return;
+    }
+    const selection = new CellSelection($anchor, $head);
+    if (starting || !view.state.selection.eq(selection)) {
+      const tr = view.state.tr.setSelection(selection);
+      if (starting) tr.setMeta(tableEditingKey, $anchor.pos);
+      view.dispatch(tr);
+    }
+  }
+
+  // Stop listening to mouse motion events.
+  function stop(): void {
+    view.root.removeEventListener('mouseup', stop);
+    view.root.removeEventListener('dragstart', stop);
+    view.root.removeEventListener('mousemove', move);
+    if (tableEditingKey.getState(view.state) != null)
+      view.dispatch(view.state.tr.setMeta(tableEditingKey, -1));
+  }
+
+  function move(_event: Event): void {
+    const event = _event as MouseEvent;
+    const anchor = tableEditingKey.getState(view.state);
+    let $anchor;
+    if (anchor != null) {
+      // Continuing an existing cross-cell selection
+      $anchor = view.state.doc.resolve(anchor);
+    } else if (domInCell(view, event.target as Node) != startDOMCell) {
+      // Moving out of the initial cell -- start a new cell selection
+      $anchor = cellUnderMouse(view, startEvent);
+      if (!$anchor) return stop();
+    }
+    if ($anchor) setCellSelection($anchor, event);
+  }
+
+  view.root.addEventListener('mouseup', stop);
+  view.root.addEventListener('dragstart', stop);
+  view.root.addEventListener('mousemove', move);
+}
+
+// Check whether the cursor is at the end of a cell (so that further
+// motion would move out of the cell)
+function atEndOfCell(view: EditorView, axis: Axis, dir: number): null | number {
+  if (!(view.state.selection instanceof TextSelection)) return null;
+  const { $head } = view.state.selection;
+  for (let d = $head.depth - 1; d >= 0; d--) {
+    const parent = $head.node(d),
+      index = dir < 0 ? $head.index(d) : $head.indexAfter(d);
+    if (index != (dir < 0 ? 0 : parent.childCount)) return null;
+    if (
+      parent.type.spec.tableRole == 'cell' ||
+      parent.type.spec.tableRole == 'header_cell'
+    ) {
+      const cellPos = $head.before(d);
+      const dirStr: 'up' | 'down' | 'left' | 'right' =
+        axis == 'vert' ? (dir > 0 ? 'down' : 'up') : dir > 0 ? 'right' : 'left';
+      return view.endOfTextblock(dirStr) ? cellPos : null;
+    }
+  }
+  return null;
+}
+
+function domInCell(view: EditorView, dom: Node | null): Node | null {
+  for (; dom && dom != view.dom; dom = dom.parentNode) {
+    if (dom.nodeName == 'TD' || dom.nodeName == 'TH') {
+      return dom;
+    }
+  }
+  return null;
+}
+
+function cellUnderMouse(
+  view: EditorView,
+  event: MouseEvent,
+): ResolvedPos | null {
+  const mousePos = view.posAtCoords({
+    left: event.clientX,
+    top: event.clientY,
+  });
+  if (!mousePos) return null;
+  return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null;
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/schema.ts b/wax-prosemirror-services/src/TablesService/tableSrc/schema.ts
new file mode 100644
index 000000000..b9012f818
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/schema.ts
@@ -0,0 +1,197 @@
+// Helper for creating a schema that supports tables.
+
+import {
+  AttributeSpec,
+  Attrs,
+  Node,
+  NodeSpec,
+  NodeType,
+  Schema,
+} from 'prosemirror-model';
+import { CellAttrs, MutableAttrs } from './util';
+
+function getCellAttrs(dom: HTMLElement | string, extraAttrs: Attrs): Attrs {
+  if (typeof dom === 'string') {
+    return {};
+  }
+
+  const widthAttr = dom.getAttribute('data-colwidth');
+  const widths =
+    widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
+      ? widthAttr.split(',').map((s) => Number(s))
+      : null;
+  const colspan = Number(dom.getAttribute('colspan') || 1);
+  const result: MutableAttrs = {
+    colspan,
+    rowspan: Number(dom.getAttribute('rowspan') || 1),
+    colwidth: widths && widths.length == colspan ? widths : null,
+  } satisfies CellAttrs;
+  for (const prop in extraAttrs) {
+    const getter = extraAttrs[prop].getFromDOM;
+    const value = getter && getter(dom);
+    if (value != null) {
+      result[prop] = value;
+    }
+  }
+  return result;
+}
+
+function setCellAttrs(node: Node, extraAttrs: Attrs): Attrs {
+  const attrs: MutableAttrs = {};
+  if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan;
+  if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan;
+  if (node.attrs.colwidth)
+    attrs['data-colwidth'] = node.attrs.colwidth.join(',');
+  for (const prop in extraAttrs) {
+    const setter = extraAttrs[prop].setDOMAttr;
+    if (setter) setter(node.attrs[prop], attrs);
+  }
+  return attrs;
+}
+
+/**
+ * @public
+ */
+export type getFromDOM = (dom: HTMLElement) => unknown;
+
+/**
+ * @public
+ */
+export type setDOMAttr = (value: unknown, attrs: MutableAttrs) => void;
+
+/**
+ * @public
+ */
+export interface CellAttributes {
+  /**
+   * The attribute's default value.
+   */
+  default: unknown;
+
+  /**
+   * A function to read the attribute's value from a DOM node.
+   */
+  getFromDOM?: getFromDOM;
+
+  /**
+   * A function to add the attribute's value to an attribute
+   * object that's used to render the cell's DOM.
+   */
+  setDOMAttr?: setDOMAttr;
+}
+
+/**
+ * @public
+ */
+export interface TableNodesOptions {
+  /**
+   * A group name (something like `"block"`) to add to the table
+   * node type.
+   */
+  tableGroup?: string;
+
+  /**
+   * The content expression for table cells.
+   */
+  cellContent: string;
+
+  /**
+   * Additional attributes to add to cells. Maps attribute names to
+   * objects with the following properties:
+   */
+  cellAttributes: { [key: string]: CellAttributes };
+}
+
+/**
+ * @public
+ */
+export type TableNodes = Record<
+  'table' | 'table_row' | 'table_cell' | 'table_header',
+  NodeSpec
+>;
+
+/**
+ * This function creates a set of [node
+ * specs](http://prosemirror.net/docs/ref/#model.SchemaSpec.nodes) for
+ * `table`, `table_row`, and `table_cell` nodes types as used by this
+ * module. The result can then be added to the set of nodes when
+ * creating a schema.
+ *
+ * @public
+ */
+export function tableNodes(options: TableNodesOptions): TableNodes {
+  const extraAttrs = options.cellAttributes || {};
+  const cellAttrs: Record<string, AttributeSpec> = {
+    colspan: { default: 1 },
+    rowspan: { default: 1 },
+    colwidth: { default: null },
+  };
+  for (const prop in extraAttrs)
+    cellAttrs[prop] = { default: extraAttrs[prop].default };
+
+  return {
+    table: {
+      content: 'table_row+',
+      tableRole: 'table',
+      isolating: true,
+      group: options.tableGroup,
+      parseDOM: [{ tag: 'table' }],
+      toDOM() {
+        return ['table', ['tbody', 0]];
+      },
+    },
+    table_row: {
+      content: '(table_cell | table_header)*',
+      tableRole: 'row',
+      parseDOM: [{ tag: 'tr' }],
+      toDOM() {
+        return ['tr', 0];
+      },
+    },
+    table_cell: {
+      content: options.cellContent,
+      attrs: cellAttrs,
+      tableRole: 'cell',
+      isolating: true,
+      parseDOM: [
+        { tag: 'td', getAttrs: (dom) => getCellAttrs(dom, extraAttrs) },
+      ],
+      toDOM(node) {
+        return ['td', setCellAttrs(node, extraAttrs), 0];
+      },
+    },
+    table_header: {
+      content: options.cellContent,
+      attrs: cellAttrs,
+      tableRole: 'header_cell',
+      isolating: true,
+      parseDOM: [
+        { tag: 'th', getAttrs: (dom) => getCellAttrs(dom, extraAttrs) },
+      ],
+      toDOM(node) {
+        return ['th', setCellAttrs(node, extraAttrs), 0];
+      },
+    },
+  };
+}
+
+/**
+ * @public
+ */
+export type TableRole = 'table' | 'row' | 'cell' | 'header_cell';
+
+/**
+ * @public
+ */
+export function tableNodeTypes(schema: Schema): Record<TableRole, NodeType> {
+  let result = schema.cached.tableNodeTypes;
+  if (!result) {
+    result = schema.cached.tableNodeTypes = {};
+    for (const name in schema.nodes) {
+      const type = schema.nodes[name],
+        role = type.spec.tableRole;
+      if (role) result[role] = type;
+    }
+  }
+  return result;
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/tablemap.ts b/wax-prosemirror-services/src/TablesService/tableSrc/tablemap.ts
new file mode 100644
index 000000000..bf633e25b
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/tablemap.ts
@@ -0,0 +1,377 @@
+// Because working with row and column-spanning cells is not quite
+// trivial, this code builds up a descriptive structure for a given
+// table node. The structures are cached with the (persistent) table
+// nodes as key, so that they only have to be recomputed when the
+// content of the table changes.
+//
+// This does mean that they have to store table-relative, not
+// document-relative positions. So code that uses them will typically
+// compute the start position of the table and offset positions passed
+// to or gotten from this structure by that amount.
+import { Attrs, Node } from 'prosemirror-model';
+import { CellAttrs } from './util';
+
+/**
+ * @public
+ */
+export type ColWidths = number[];
+
+/**
+ * @public
+ */
+export type Problem =
+  | {
+      type: 'colwidth mismatch';
+      pos: number;
+      colwidth: ColWidths;
+    }
+  | {
+      type: 'collision';
+      pos: number;
+      row: number;
+      n: number;
+    }
+  | {
+      type: 'missing';
+      row: number;
+      n: number;
+    }
+  | {
+      type: 'overlong_rowspan';
+      pos: number;
+      n: number;
+    };
+
+let readFromCache: (key: Node) => TableMap | undefined;
+let addToCache: (key: Node, value: TableMap) => TableMap;
+
+// Prefer using a weak map to cache table maps. Fall back on a
+// fixed-size cache if that's not supported.
+if (typeof WeakMap != 'undefined') {
+  // eslint-disable-next-line
+  let cache = new WeakMap<Node, TableMap>();
+  readFromCache = (key) => cache.get(key);
+  addToCache = (key, value) => {
+    cache.set(key, value);
+    return value;
+  };
+} else {
+  const cache: (Node | TableMap)[] = [];
+  const cacheSize = 10;
+  let cachePos = 0;
+  readFromCache = (key) => {
+    for (let i = 0; i < cache.length; i += 2)
+      if (cache[i] == key) return cache[i + 1] as TableMap;
+  };
+  addToCache = (key, value) => {
+    if (cachePos == cacheSize) cachePos = 0;
+    cache[cachePos++] = key;
+    return (cache[cachePos++] = value);
+  };
+}
+
+/**
+ * @public
+ */
+export interface Rect {
+  left: number;
+  top: number;
+  right: number;
+  bottom: number;
+}
+
+/**
+ * A table map describes the structure of a given table. To avoid
+ * recomputing them all the time, they are cached per table node. To
+ * be able to do that, positions saved in the map are relative to the
+ * start of the table, rather than the start of the document.
+ *
+ * @public
+ */
+export class TableMap {
+  constructor(
+    /**
+     * The number of columns
+     */
+    public width: number,
+    /**
+     * The number of rows
+     */
+    public height: number,
+    /**
+     * A width * height array with the start position of
+     * the cell covering that part of the table in each slot
+     */
+    public map: number[],
+    /**
+     * An optional array of problems (cell overlap or non-rectangular
+     * shape) for the table, used by the table normalizer.
+     */
+    public problems: Problem[] | null,
+  ) {}
+
+  // Find the dimensions of the cell at the given position.
+  findCell(pos: number): Rect {
+    for (let i = 0; i < this.map.length; i++) {
+      const curPos = this.map[i];
+      if (curPos != pos) continue;
+
+      const left = i % this.width;
+      const top = (i / this.width) | 0;
+      let right = left + 1;
+      let bottom = top + 1;
+
+      for (let j = 1; right < this.width && this.map[i + j] == curPos; j++) {
+        right++;
+      }
+      for (
+        let j = 1;
+        bottom < this.height && this.map[i + this.width * j] == curPos;
+        j++
+      ) {
+        bottom++;
+      }
+
+      return { left, top, right, bottom };
+    }
+    throw new RangeError(`No cell with offset ${pos} found`);
+  }
+
+  // Find the left side of the cell at the given position.
+  colCount(pos: number): number {
+    for (let i = 0; i < this.map.length; i++) {
+      if (this.map[i] == pos) {
+        return i % this.width;
+      }
+    }
+    throw new RangeError(`No cell with offset ${pos} found`);
+  }
+
+  // Find the next cell in the given direction, starting from the cell
+  // at `pos`, if any.
+  nextCell(pos: number, axis: 'horiz' | 'vert', dir: number): null | number {
+    const { left, right, top, bottom } = this.findCell(pos);
+    if (axis == 'horiz') {
+      if (dir < 0 ? left == 0 : right == this.width) return null;
+      return this.map[top * this.width + (dir < 0 ? left - 1 : right)];
+    } else {
+      if (dir < 0 ? top == 0 : bottom == this.height) return null;
+      return this.map[left + this.width * (dir < 0 ? top - 1 : bottom)];
+    }
+  }
+
+  // Get the rectangle spanning the two given cells.
+  rectBetween(a: number, b: number): Rect {
+    const {
+      left: leftA,
+      right: rightA,
+      top: topA,
+      bottom: bottomA,
+    } = this.findCell(a);
+    const {
+      left: leftB,
+      right: rightB,
+      top: topB,
+      bottom: bottomB,
+    } = this.findCell(b);
+    return {
+      left: Math.min(leftA, leftB),
+      top: Math.min(topA, topB),
+      right: Math.max(rightA, rightB),
+      bottom: Math.max(bottomA, bottomB),
+    };
+  }
+
+  // Return the position of all cells that have the top left corner in
+  // the given rectangle.
+  cellsInRect(rect: Rect): number[] {
+    const result: number[] = [];
+    const seen: Record<number, boolean> = {};
+    for (let row = rect.top; row < rect.bottom; row++) {
+      for (let col = rect.left; col < rect.right; col++) {
+        const index = row * this.width + col;
+        const pos = this.map[index];
+
+        if (seen[pos]) continue;
+        seen[pos] = true;
+
+        if (
+          (col == rect.left && col && this.map[index - 1] == pos) ||
+          (row == rect.top && row && this.map[index - this.width] == pos)
+        ) {
+          continue;
+        }
+        result.push(pos);
+      }
+    }
+    return result;
+  }
+
+  // Return the position at which the cell at the given row and column
+  // starts, or would start, if a cell started there.
+  positionAt(row: number, col: number, table: Node): number {
+    for (let i = 0, rowStart = 0; ; i++) {
+      const rowEnd = rowStart + table.child(i).nodeSize;
+      if (i == row) {
+        let index = col + row * this.width;
+        const rowEndIndex = (row + 1) * this.width;
+        // Skip past cells from previous rows (via rowspan)
+        while (index < rowEndIndex && this.map[index] < rowStart) index++;
+        return index == rowEndIndex ? rowEnd - 1 : this.map[index];
+      }
+      rowStart = rowEnd;
+    }
+  }
+
+  // Find the table map for the given table node.
+  static get(table: Node): TableMap {
+    return readFromCache(table) || addToCache(table, computeMap(table));
+  }
+}
+
+// Compute a table map.
+function computeMap(table: Node): TableMap {
+  if (table.type.spec.tableRole != 'table')
+    throw new RangeError('Not a table node: ' + table.type.name);
+  const width = findWidth(table),
+    height = table.childCount;
+  const map = [];
+  let mapPos = 0;
+  let problems: Problem[] | null = null;
+  const colWidths: ColWidths = [];
+  for (let i = 0, e = width * height; i < e; i++) map[i] = 0;
+
+  for (let row = 0, pos = 0; row < height; row++) {
+    const rowNode = table.child(row);
+    pos++;
+    for (let i = 0; ; i++) {
+      while (mapPos < map.length && map[mapPos] != 0) mapPos++;
+      if (i == rowNode.childCount) break;
+      const cellNode = rowNode.child(i);
+      const { colspan, rowspan, colwidth } = cellNode.attrs;
+      for (let h = 0; h < rowspan; h++) {
+        if (h + row >= height) {
+          (problems || (problems = [])).push({
+            type: 'overlong_rowspan',
+            pos,
+            n: rowspan - h,
+          });
+          break;
+        }
+        const start = mapPos + h * width;
+        for (let w = 0; w < colspan; w++) {
+          if (map[start + w] == 0) map[start + w] = pos;
+          else
+            (problems || (problems = [])).push({
+              type: 'collision',
+              row,
+              pos,
+              n: colspan - w,
+            });
+          const colW = colwidth && colwidth[w];
+          if (colW) {
+            const widthIndex = ((start + w) % width) * 2,
+              prev = colWidths[widthIndex];
+            if (
+              prev == null ||
+              (prev != colW && colWidths[widthIndex + 1] == 1)
+            ) {
+              colWidths[widthIndex] = colW;
+              colWidths[widthIndex + 1] = 1;
+            } else if (prev == colW) {
+              colWidths[widthIndex + 1]++;
+            }
+          }
+        }
+      }
+      mapPos += colspan;
+      pos += cellNode.nodeSize;
+    }
+    const expectedPos = (row + 1) * width;
+    let missing = 0;
+    while (mapPos < expectedPos) if (map[mapPos++] == 0) missing++;
+    if (missing)
+      (problems || (problems = [])).push({ type: 'missing', row, n: missing });
+    pos++;
+  }
+
+  const tableMap = new TableMap(width, height, map, problems);
+  let badWidths = false;
+
+  // For columns that have defined widths, but whose widths disagree
+  // between rows, fix up the cells whose width doesn't match the
+  // computed one.
+  for (let i = 0; !badWidths && i < colWidths.length; i += 2)
+    if (colWidths[i] != null && colWidths[i + 1] < height) badWidths = true;
+  if (badWidths) findBadColWidths(tableMap, colWidths, table);
+
+  return tableMap;
+}
+
+function findWidth(table: Node): number {
+  let width = -1;
+  let hasRowSpan = false;
+  for (let row = 0; row < table.childCount; row++) {
+    const rowNode = table.child(row);
+    let rowWidth = 0;
+    if (hasRowSpan)
+      for (let j = 0; j < row; j++) {
+        const prevRow = table.child(j);
+        for (let i = 0; i < prevRow.childCount; i++) {
+          const cell = prevRow.child(i);
+          if (j + cell.attrs.rowspan > row) rowWidth += cell.attrs.colspan;
+        }
+      }
+    for (let i = 0; i < rowNode.childCount; i++) {
+      const cell = rowNode.child(i);
+      rowWidth += cell.attrs.colspan;
+      if (cell.attrs.rowspan > 1) hasRowSpan = true;
+    }
+    if (width == -1) width = rowWidth;
+    else if (width != rowWidth) width = Math.max(width, rowWidth);
+  }
+  return width;
+}
+
+function findBadColWidths(
+  map: TableMap,
+  colWidths: ColWidths,
+  table: Node,
+): void {
+  if (!map.problems) map.problems = [];
+  const seen: Record<number, boolean> = {};
+  for (let i = 0; i < map.map.length; i++) {
+    const pos = map.map[i];
+    if (seen[pos]) continue;
+    seen[pos] = true;
+    const node = table.nodeAt(pos);
+    if (!node) {
+      throw new RangeError(`No cell with offset ${pos} found`);
+    }
+
+    let updated = null;
+    const attrs = node.attrs as CellAttrs;
+    for (let j = 0; j < attrs.colspan; j++) {
+      const col = (i + j) % map.width;
+      const colWidth = colWidths[col * 2];
+      if (
+        colWidth != null &&
+        (!attrs.colwidth || attrs.colwidth[j] != colWidth)
+      )
+        (updated || (updated = freshColWidth(attrs)))[j] = colWidth;
+    }
+    if (updated)
+      map.problems.unshift({
+        type: 'colwidth mismatch',
+        pos,
+        colwidth: updated,
+      });
+  }
+}
+
+function freshColWidth(attrs: Attrs): ColWidths {
+  if (attrs.colwidth) return attrs.colwidth.slice();
+  const result: ColWidths = [];
+  for (let i = 0; i < attrs.colspan; i++) result.push(0);
+  return result;
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/tableview.ts b/wax-prosemirror-services/src/TablesService/tableSrc/tableview.ts
new file mode 100644
index 000000000..c3130bc85
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/tableview.ts
@@ -0,0 +1,86 @@
+import { Node } from 'prosemirror-model';
+import { NodeView } from 'prosemirror-view';
+import { CellAttrs } from './util';
+
+/**
+ * @public
+ */
+export class TableView implements NodeView {
+  public dom: HTMLDivElement;
+  public table: HTMLTableElement;
+  public colgroup: HTMLTableColElement;
+  public contentDOM: HTMLTableSectionElement;
+
+  constructor(public node: Node, public cellMinWidth: number) {
+    this.dom = document.createElement('div');
+    this.dom.className = 'tableWrapper';
+    this.table = this.dom.appendChild(document.createElement('table'));
+    this.colgroup = this.table.appendChild(document.createElement('colgroup'));
+    updateColumnsOnResize(node, this.colgroup, this.table, cellMinWidth);
+    this.contentDOM = this.table.appendChild(document.createElement('tbody'));
+  }
+
+  update(node: Node): boolean {
+    if (node.type != this.node.type) return false;
+    this.node = node;
+    updateColumnsOnResize(node, this.colgroup, this.table, this.cellMinWidth);
+    return true;
+  }
+
+  ignoreMutation(record: MutationRecord): boolean {
+    return (
+      record.type == 'attributes' &&
+      (record.target == this.table || this.colgroup.contains(record.target))
+    );
+  }
+}
+
+/**
+ * @public
+ */
+export function updateColumnsOnResize(
+  node: Node,
+  colgroup: HTMLTableColElement,
+  table: HTMLTableElement,
+  cellMinWidth: number,
+  overrideCol?: number,
+  overrideValue?: number,
+): void {
+  let totalWidth = 0;
+  let fixedWidth = true;
+  let nextDOM = colgroup.firstChild as HTMLElement;
+  const row = node.firstChild;
+  if (!row) return;
+
+  for (let i = 0, col = 0; i < row.childCount; i++) {
+    const { colspan, colwidth } = row.child(i).attrs as CellAttrs;
+    for (let j = 0; j < colspan; j++, col++) {
+      const hasWidth =
+        overrideCol == col ? overrideValue : colwidth && colwidth[j];
+      const cssWidth = hasWidth ? hasWidth + 'px' : '';
+      totalWidth += hasWidth || cellMinWidth;
+      if (!hasWidth) fixedWidth = false;
+      if (!nextDOM) {
+        colgroup.appendChild(document.createElement('col')).style.width =
+          cssWidth;
+      } else {
+        if (nextDOM.style.width != cssWidth) nextDOM.style.width = cssWidth;
+        nextDOM = nextDOM.nextSibling as HTMLElement;
+      }
+    }
+  }
+
+  while (nextDOM) {
+    const after = nextDOM.nextSibling;
+    nextDOM.parentNode?.removeChild(nextDOM);
+    nextDOM = after as HTMLElement;
+  }
+
+  if (fixedWidth) {
+    table.style.width = totalWidth + 'px';
+    table.style.minWidth = '';
+  } else {
+    table.style.width = '';
+    table.style.minWidth = totalWidth + 'px';
+  }
+}
diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/util.ts b/wax-prosemirror-services/src/TablesService/tableSrc/util.ts
new file mode 100644
index 000000000..70fd658f9
--- /dev/null
+++ b/wax-prosemirror-services/src/TablesService/tableSrc/util.ts
@@ -0,0 +1,195 @@
+// Various helper function for working with tables
+
+import { EditorState, NodeSelection, PluginKey } from 'prosemirror-state';
+
+import { Attrs, Node, ResolvedPos } from 'prosemirror-model';
+import { CellSelection } from './cellselection';
+import { tableNodeTypes } from './schema';
+import { Rect, TableMap } from './tablemap';
+
+/**
+ * @public
+ */
+export type MutableAttrs = Record<string, unknown>;
+
+/**
+ * @public
+ */
+export interface CellAttrs {
+  colspan: number;
+  rowspan: number;
+  colwidth: number[] | null;
+}
+
+/**
+ * @public
+ */
+export const tableEditingKey = new PluginKey<number>('selectingCells');
+
+/**
+ * @public
+ */
+export function cellAround($pos: ResolvedPos): ResolvedPos | null {
+  for (let d = $pos.depth - 1; d > 0; d--)
+    if ($pos.node(d).type.spec.tableRole == 'row')
+      return $pos.node(0).resolve($pos.before(d + 1));
+  return null;
+}
+
+export function cellWrapping($pos: ResolvedPos): null | Node {
+  for (let d = $pos.depth; d > 0; d--) {
+    // Sometimes the cell can be in the same depth.
+    const role = $pos.node(d).type.spec.tableRole;
+    if (role === 'cell' || role === 'header_cell') return $pos.node(d);
+  }
+  return null;
+}
+
+/**
+ * @public
+ */
+export function isInTable(state: EditorState): boolean {
+  const $head = state.selection.$head;
+  for (let d = $head.depth; d > 0; d--)
+    if ($head.node(d).type.spec.tableRole == 'row') return true;
+  return false;
+}
+
+/**
+ * @internal
+ */
+export function selectionCell(state: EditorState): ResolvedPos {
+  const sel = state.selection as CellSelection | NodeSelection;
+  if ('$anchorCell' in sel && sel.$anchorCell) {
+    return sel.$anchorCell.pos > sel.$headCell.pos
+      ? sel.$anchorCell
+      : sel.$headCell;
+  } else if (
+    'node' in sel &&
+    sel.node &&
+    sel.node.type.spec.tableRole == 'cell'
+  ) {
+    return sel.$anchor;
+  }
+  const $cell = cellAround(sel.$head) || cellNear(sel.$head);
+  if ($cell) {
+    return $cell;
+  }
+  throw new RangeError(`No cell found around position ${sel.head}`);
+}
+
+function cellNear($pos: ResolvedPos): ResolvedPos | undefined {
+  for (
+    let after = $pos.nodeAfter, pos = $pos.pos;
+    after;
+    after = after.firstChild, pos++
+  ) {
+    const role = after.type.spec.tableRole;
+    if (role == 'cell' || role == 'header_cell') return $pos.doc.resolve(pos);
+  }
+  for (
+    let before = $pos.nodeBefore, pos = $pos.pos;
+    before;
+    before = before.lastChild, pos--
+  ) {
+    const role = before.type.spec.tableRole;
+    if (role == 'cell' || role == 'header_cell')
+      return $pos.doc.resolve(pos - before.nodeSize);
+  }
+}
+
+/**
+ * @public
+ */
+export function pointsAtCell($pos: ResolvedPos): boolean {
+  return $pos.parent.type.spec.tableRole == 'row' && !!$pos.nodeAfter;
+}
+
+/**
+ * @public
+ */
+export function moveCellForward($pos: ResolvedPos): ResolvedPos {
+  return $pos.node(0).resolve($pos.pos + $pos.nodeAfter!.nodeSize);
+}
+
+/**
+ * @internal
+ */
+export function inSameTable($cellA: ResolvedPos, $cellB: ResolvedPos): boolean {
+  return (
+    $cellA.depth == $cellB.depth &&
+    $cellA.pos >= $cellB.start(-1) &&
+    $cellA.pos <= $cellB.end(-1)
+  );
+}
+
+/**
+ * @public
+ */
+export function findCell($pos: ResolvedPos): Rect {
+  return TableMap.get($pos.node(-1)).findCell($pos.pos - $pos.start(-1));
+}
+
+/**
+ * @public
+ */
+export function colCount($pos: ResolvedPos): number {
+  return TableMap.get($pos.node(-1)).colCount($pos.pos - $pos.start(-1));
+}
+
+/**
+ * @public
+ */
+export function nextCell(
+  $pos: ResolvedPos,
+  axis: 'horiz' | 'vert',
+  dir: number,
+): ResolvedPos | null {
+  const table = $pos.node(-1);
+  const map = TableMap.get(table);
+  const tableStart = $pos.start(-1);
+
+  const moved = map.nextCell($pos.pos - tableStart, axis, dir);
+  return moved == null ? null : $pos.node(0).resolve(tableStart + moved);
+}
+
+/**
+ * @public
+ */
+export function removeColSpan(attrs: CellAttrs, pos: number, n = 1): CellAttrs {
+  const result: CellAttrs = { ...attrs, colspan: attrs.colspan - n };
+
+  if (result.colwidth) {
+    result.colwidth = result.colwidth.slice();
+    result.colwidth.splice(pos, n);
+    if (!result.colwidth.some((w) => w > 0)) result.colwidth = null;
+  }
+  return result;
+}
+
+/**
+ * @public
+ */
+export function addColSpan(attrs: CellAttrs, pos: number, n = 1): Attrs {
+  const result = { ...attrs, colspan: attrs.colspan + n };
+  if (result.colwidth) {
+    result.colwidth = result.colwidth.slice();
+    for (let i = 0; i < n; i++) result.colwidth.splice(pos, 0, 0);
+  }
+  return result;
+}
+
+/**
+ * @public
+ */
+export function columnIsHeader(
+  map: TableMap,
+  table: Node,
+  col: number,
+): boolean {
+  const headerCell = tableNodeTypes(table.type.schema).header_cell;
+  for (let row = 0; row < map.height; row++)
+    if (table.nodeAt(map.map[col + row * map.width])!.type != headerCell)
+      return false;
+  return true;
+}
-- 
GitLab