= {
};
export type ReactNodeViewOptions = {
- props?: P;
+ props?: Partial
;
component?: React.ComponentType
;
componentKey?: (node: PMNode) => string;
shouldUpdate?: ShouldUpdate;
diff --git a/packages/editor/src/extensions/table-cell/table-cell.ts b/packages/editor/src/extensions/table-cell/table-cell.ts
index e56a58df9..1f68093ba 100644
--- a/packages/editor/src/extensions/table-cell/table-cell.ts
+++ b/packages/editor/src/extensions/table-cell/table-cell.ts
@@ -17,18 +17,115 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
-import TiptapTableCell from "@tiptap/extension-table-cell";
import { addStyleAttribute } from "./utils.js";
+import { Attribute } from "@tiptap/core";
+import { mergeAttributes, Node } from "@tiptap/core";
-export const TableCell = TiptapTableCell.extend({
- addAttributes() {
+export interface TableCellOptions {
+ /**
+ * The HTML attributes for a table cell node.
+ * @default {}
+ * @example { class: 'foo' }
+ */
+ HTMLAttributes: Record;
+}
+
+/**
+ * This extension allows you to create table cells.
+ * @see https://www.tiptap.dev/api/nodes/table-cell
+ */
+export const TableCell = Node.create({
+ name: "tableCell",
+
+ addOptions() {
return {
- ...this.parent?.(),
- backgroundColor: addStyleAttribute("backgroundColor", "background-color"),
- color: addStyleAttribute("color", "color"),
- borderWidth: addStyleAttribute("borderWidth", "border-width", "px"),
- borderStyle: addStyleAttribute("borderStyle", "border-style"),
- borderColor: addStyleAttribute("borderColor", "border-color")
+ HTMLAttributes: {}
};
+ },
+
+ content: "block+",
+
+ addAttributes() {
+ return addTableCellAttributes();
+ },
+
+ tableRole: "cell",
+
+ isolating: true,
+
+ parseHTML() {
+ return [{ tag: "td" }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "td",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0
+ ];
}
});
+
+export function addTableCellAttributes(): Record {
+ return {
+ colwidth: {
+ default: null,
+ parseHTML(element) {
+ const widthAttr =
+ element.getAttribute("data-colwidth") ||
+ element.getAttribute("colwidth");
+ const widths =
+ widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
+ ? widthAttr.split(",").map((s) => Number(s))
+ : null;
+ const colspan = Number(element.getAttribute("colspan") || 1);
+
+ // migrate to data-colwidth attribute
+ if (element.hasAttribute("colwidth")) {
+ element.setAttribute(
+ "data-colwidth",
+ element.getAttribute("colwidth")!
+ );
+ element.removeAttribute("colwidth");
+ }
+
+ return widths && widths.length == colspan ? widths : null;
+ },
+ renderHTML(attributes) {
+ if (!attributes.colwidth) {
+ return {};
+ }
+ return {
+ "data-colwidth": attributes.colwidth.join(",")
+ };
+ }
+ },
+ colspan: {
+ default: 1,
+ parseHTML(element) {
+ return Number(element.getAttribute("colspan") || 1);
+ },
+ renderHTML(attributes) {
+ return {
+ colspan: attributes.colspan || 1
+ };
+ }
+ },
+ rowspan: {
+ default: 1,
+ parseHTML(element) {
+ return Number(element.getAttribute("rowspan") || 1);
+ },
+ renderHTML(attributes) {
+ return {
+ rowspan: attributes.rowspan || 1
+ };
+ }
+ },
+ backgroundColor: addStyleAttribute("backgroundColor", "background-color"),
+ color: addStyleAttribute("color", "color"),
+ borderWidth: addStyleAttribute("borderWidth", "border-width", "px"),
+ borderStyle: addStyleAttribute("borderStyle", "border-style"),
+ borderColor: addStyleAttribute("borderColor", "border-color")
+ };
+}
diff --git a/packages/editor/src/extensions/table-header/table-header.ts b/packages/editor/src/extensions/table-header/table-header.ts
index a2f899213..5187029e4 100644
--- a/packages/editor/src/extensions/table-header/table-header.ts
+++ b/packages/editor/src/extensions/table-header/table-header.ts
@@ -17,18 +17,50 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
-import TipTapTableHeader from "@tiptap/extension-table-header";
-import { addStyleAttribute } from "../table-cell/utils.js";
+import { mergeAttributes, Node } from "@tiptap/core";
+import { addTableCellAttributes } from "../table-cell/table-cell.js";
-export const TableHeader = TipTapTableHeader.extend({
- addAttributes() {
+export interface TableHeaderOptions {
+ /**
+ * The HTML attributes for a table header node.
+ * @default {}
+ * @example { class: 'foo' }
+ */
+ HTMLAttributes: Record;
+}
+
+/**
+ * This extension allows you to create table headers.
+ * @see https://www.tiptap.dev/api/nodes/table-header
+ */
+export const TableHeader = Node.create({
+ name: "tableHeader",
+
+ addOptions() {
return {
- ...this.parent?.(),
- backgroundColor: addStyleAttribute("backgroundColor", "background-color"),
- color: addStyleAttribute("color", "color"),
- borderWidth: addStyleAttribute("borderWidth", "border-width", "px"),
- borderStyle: addStyleAttribute("borderStyle", "border-style"),
- borderColor: addStyleAttribute("borderColor", "border-color")
+ HTMLAttributes: {}
};
+ },
+
+ content: "block+",
+
+ addAttributes() {
+ return addTableCellAttributes();
+ },
+
+ tableRole: "header_cell",
+
+ isolating: true,
+
+ parseHTML() {
+ return [{ tag: "th" }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "th",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0
+ ];
}
});
diff --git a/packages/editor/src/extensions/table/TableView.ts b/packages/editor/src/extensions/table/TableView.ts
new file mode 100644
index 000000000..70a45614a
--- /dev/null
+++ b/packages/editor/src/extensions/table/TableView.ts
@@ -0,0 +1,49 @@
+import { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { NodeView } from "@tiptap/pm/view";
+import { updateColumnsOnResize } from "./prosemirror-tables/tableview.js";
+
+export class TableView implements NodeView {
+ node: ProseMirrorNode;
+
+ cellMinWidth: number;
+
+ dom: HTMLElement;
+
+ table: HTMLTableElement;
+
+ colgroup: HTMLTableColElement;
+
+ contentDOM: HTMLElement;
+
+ constructor(node: ProseMirrorNode, cellMinWidth: number) {
+ this.node = node;
+ this.cellMinWidth = cellMinWidth;
+ 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: ProseMirrorNode) {
+ if (node.type !== this.node.type) {
+ return false;
+ }
+
+ this.node = node;
+ updateColumnsOnResize(node, this.colgroup, this.table, this.cellMinWidth);
+
+ return true;
+ }
+
+ ignoreMutation(
+ mutation: MutationRecord | { type: "selection"; target: Element }
+ ) {
+ return (
+ mutation.type === "attributes" &&
+ (mutation.target === this.table ||
+ this.colgroup.contains(mutation.target))
+ );
+ }
+}
diff --git a/packages/editor/src/extensions/table/actions.ts b/packages/editor/src/extensions/table/actions.ts
index 6dfc48d44..420b3ba1d 100644
--- a/packages/editor/src/extensions/table/actions.ts
+++ b/packages/editor/src/extensions/table/actions.ts
@@ -18,9 +18,9 @@ along with this program. If not, see .
*/
import { Editor } from "@tiptap/core";
-import { selectedRect, TableRect } from "@tiptap/pm/tables";
import { EditorState, TextSelection, Transaction } from "prosemirror-state";
import { Node } from "prosemirror-model";
+import { selectedRect, TableRect } from "./prosemirror-tables/commands.js";
type TableCell = {
cell: Node;
diff --git a/packages/editor/src/extensions/table/component.tsx b/packages/editor/src/extensions/table/component.tsx
index 58df8987d..d9ee1f43a 100644
--- a/packages/editor/src/extensions/table/component.tsx
+++ b/packages/editor/src/extensions/table/component.tsx
@@ -23,7 +23,6 @@ import { Node as ProsemirrorNode } from "prosemirror-model";
import { Editor } from "../../types.js";
import { Editor as TiptapEditor } from "@tiptap/core";
import { useCallback, useEffect, useRef } from "react";
-import { updateColumnsOnResize } from "@tiptap/pm/tables";
import { EditorView, NodeView } from "prosemirror-view";
import {
InsertColumnRight,
@@ -37,14 +36,17 @@ import {
findSelectedDOMNode,
hasSameAttributes
} from "../../utils/prosemirror.js";
-import { DesktopOnly } from "../../components/responsive/index.js";
+import { DesktopOnly, MobileOnly } from "../../components/responsive/index.js";
import { TextDirections } from "../text-direction/index.js";
import { strings } from "@notesnook/intl";
import SimpleBar from "simplebar-react";
import { useIsMobile } from "../../toolbar/stores/toolbar-store.js";
+import { updateColumnsOnResize } from "./prosemirror-tables/tableview.js";
-export function TableComponent(props: ReactNodeViewProps) {
- const { editor, node, forwardRef } = props;
+export function TableComponent(
+ props: ReactNodeViewProps & { cellMinWidth: number }
+) {
+ const { editor, node, forwardRef, cellMinWidth } = props;
const colgroupRef = useRef(null);
const tableRef = useRef();
const { textDirection } = node.attrs;
@@ -53,24 +55,37 @@ export function TableComponent(props: ReactNodeViewProps) {
useEffect(() => {
if (!colgroupRef.current || !tableRef.current) return;
- updateColumnsOnResize(node, colgroupRef.current, tableRef.current, 50);
- }, [node]);
-
- const renderScrollContent = useCallback(() => {
- return (
-
-
{
- forwardRef?.(ref);
- tableRef.current = ref || undefined;
- }}
- >
-
- {/* */}
-
-
+ updateColumnsOnResize(
+ node,
+ colgroupRef.current,
+ tableRef.current,
+ cellMinWidth
);
- }, [forwardRef, textDirection]);
+ }, [node, cellMinWidth]);
+
+ // useEffect(() => {
+ // function transactionListener({
+ // editor
+ // }: {
+ // transaction: Transaction;
+ // editor: TiptapEditor;
+ // }) {
+ // if (!colgroupRef.current || !tableRef.current) return;
+
+ // const cellsInRow = tableRef.current?.rows[0]?.cells.length || 0;
+ // if (colgroupRef.current?.childElementCount !== cellsInRow)
+ // updateColumns(
+ // selectedRect(editor.state).table,
+ // colgroupRef.current,
+ // tableRef.current,
+ // cellMinWidth
+ // );
+ // }
+ // editor.on("transaction", transactionListener);
+ // return () => {
+ // editor.off("transaction", transactionListener);
+ // };
+ // }, []);
return (
<>
@@ -85,33 +100,59 @@ export function TableComponent(props: ReactNodeViewProps) {
table={tableRef}
textDirection={textDirection}
/>
+
+
+
{
+ forwardRef?.(ref);
+ tableRef.current = ref || undefined;
+ }}
+ >
+
+ {/* */}
+
+
+
-
- {isMobile ? (
- {renderScrollContent()}
- ) : (
- {renderScrollContent()}
- )}
+
+
+
{
+ forwardRef?.(ref);
+ tableRef.current = ref || undefined;
+ }}
+ >
+
+ {/* */}
+
+
+
>
);
}
export function TableNodeView(editor: TiptapEditor) {
class TableNode
- extends ReactNodeView>
+ extends ReactNodeView<
+ ReactNodeViewProps & { cellMinWidth: number }
+ >
implements NodeView
{
- constructor(node: ProsemirrorNode) {
+ constructor(node: ProsemirrorNode, cellMinWidth: number) {
super(
node,
editor,
() => 0, // todo
{
component: TableComponent,
+ props: { cellMinWidth },
+ forceEnableSelection: true,
shouldUpdate: (prev, next) => {
return (
!hasSameAttributes(prev.attrs, next.attrs) ||
- prev.childCount !== next.childCount
+ prev.childCount !== next.childCount ||
+ // compare columns
+ prev.firstChild?.childCount !== next.firstChild?.childCount
);
},
contentDOMFactory: () => {
@@ -194,6 +235,7 @@ function TableRowToolbar(props: TableToolbarProps) {
bg: "background",
flexWrap: "nowrap",
borderRadius: "default",
+ border: "1px solid var(--border)",
flexDirection: "column",
opacity: 0.4,
":hover": {
@@ -272,6 +314,7 @@ function TableColumnToolbar(props: TableToolbarProps) {
bg: "background",
flexWrap: "nowrap",
borderRadius: "default",
+ border: "1px solid var(--border)",
opacity: 0.4,
":hover": {
opacity: 1
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/cellselection.ts b/packages/editor/src/extensions/table/prosemirror-tables/cellselection.ts
new file mode 100644
index 000000000..a1402909a
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/cellselection.ts
@@ -0,0 +1,466 @@
+// 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.js";
+import { CellAttrs, inSameTable, pointsAtCell, removeColSpan } from "./util.js";
+import { findParentNodeOfTypeClosestToPos } from "../../../utils/prosemirror.js";
+
+/**
+ * @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 = {};
+ 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 < $to.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,
+ oldState: EditorState,
+ 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/packages/editor/src/extensions/table/prosemirror-tables/columnresizing.ts b/packages/editor/src/extensions/table/prosemirror-tables/columnresizing.ts
new file mode 100644
index 000000000..f51120747
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/columnresizing.ts
@@ -0,0 +1,417 @@
+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.js";
+import { TableMap } from "./tablemap.js";
+import { TableView, updateColumnsOnResize } from "./tableview.js";
+import { cellAround, CellAttrs, getClientX } from "./util.js";
+
+/**
+ * @public
+ */
+export const columnResizingPluginKey = new PluginKey(
+ "tableColumnResizing"
+);
+
+/**
+ * @public
+ */
+export type ColumnResizingOptions = {
+ /**
+ * Minimum width of a cell /column. The column cannot be resized smaller than this.
+ */
+ cellMinWidth?: number;
+ /**
+ * The default minWidth of a cell / column when it doesn't have an explicit width (i.e.: it has not been resized manually)
+ */
+ defaultCellMinWidth?: number;
+ /**
+ * A custom node view for the rendering table nodes. By default, the plugin
+ * uses the {@link TableView} class. You can explicitly set this to `null` to
+ * not use a custom node view.
+ */
+ View?:
+ | (new (
+ node: ProsemirrorNode,
+ cellMinWidth: number,
+ view: EditorView
+ ) => NodeView)
+ | null;
+ showResizeHandleOnSelection?: boolean;
+};
+
+/**
+ * @public
+ */
+export type Dragging = { startX: number; startWidth: number };
+
+/**
+ * @public
+ */
+export function columnResizing({
+ cellMinWidth = 25,
+ defaultCellMinWidth = 100,
+ View = TableView,
+ showResizeHandleOnSelection = false
+}: ColumnResizingOptions = {}): Plugin {
+ const plugin = new Plugin({
+ key: columnResizingPluginKey,
+ state: {
+ init(_, state) {
+ const nodeViews = plugin.spec?.props?.nodeViews;
+ const tableName = tableNodeTypes(state.schema).table.name;
+ if (View && nodeViews) {
+ nodeViews[tableName] = (node, view) => {
+ return new View(node, defaultCellMinWidth, view);
+ };
+ }
+
+ return { dragging: false, decorations: DecorationSet.empty };
+ },
+ apply(tr, prev, _, state) {
+ return createResizeState(tr, state, prev, showResizeHandleOnSelection);
+ }
+ },
+ props: {
+ handleDOMEvents: {
+ touchstart: (view, event) => {
+ handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth);
+ },
+ mousedown: (view, event) => {
+ handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth);
+ }
+ },
+
+ decorations: (state) => {
+ const pluginState = columnResizingPluginKey.getState(state);
+ return pluginState?.decorations || DecorationSet.empty;
+ },
+
+ nodeViews: {}
+ }
+ });
+ return plugin;
+}
+
+type ResizeState = {
+ dragging: Dragging | false;
+ decorations: DecorationSet;
+};
+
+function createResizeState(
+ tr: Transaction,
+ state: EditorState,
+ prevState: ResizeState,
+ showResizeHandleOnSelection: boolean
+): ResizeState {
+ const action = tr.getMeta(columnResizingPluginKey);
+ const copy: ResizeState = { ...prevState };
+ copy.decorations = tr.docChanged
+ ? copy.decorations.map(tr.mapping, tr.doc)
+ : copy.decorations;
+
+ if (!copy.dragging) {
+ const cell = edgeCell(state, state.selection.from, "right");
+ if (cell === -1) {
+ copy.decorations = DecorationSet.empty;
+ } else {
+ const handles = createColumnResizeHandles(
+ state,
+ cell,
+ prevState,
+ showResizeHandleOnSelection
+ );
+ if (handles) {
+ copy.decorations = handles;
+ }
+ }
+ }
+
+ if (action && action.setDragging !== undefined)
+ copy.dragging = action.setDragging;
+ return copy;
+}
+
+function handleMouseDown(
+ view: EditorView,
+ event: MouseEvent | TouchEvent,
+ cellMinWidth: number,
+ defaultCellMinWidth: number
+): boolean {
+ if (!view.editable) return false;
+
+ const win = view.dom.ownerDocument.defaultView ?? window;
+
+ const pluginState = columnResizingPluginKey.getState(view.state);
+ if (!pluginState || pluginState.dragging) return false;
+
+ if (
+ event.target instanceof HTMLElement &&
+ event.target.closest(".column-resize-handle") == null
+ ) {
+ console.log("No handle target");
+ return false;
+ }
+
+ const clientX = getClientX(event);
+ if (clientX === null) return false;
+
+ const activeHandle = edgeCell(
+ view.state,
+ view.posAtDOM(event.target as Node, 0),
+ "right"
+ );
+ const cell = view.state.doc.nodeAt(activeHandle);
+ if (!cell) {
+ console.log("No cell at handle");
+ return false;
+ }
+
+ const width = currentColWidth(view, activeHandle, cell.attrs);
+ view.dispatch(
+ view.state.tr.setMeta(columnResizingPluginKey, {
+ setDragging: { startX: clientX, startWidth: width }
+ })
+ );
+
+ function finish(event: MouseEvent | TouchEvent) {
+ win.removeEventListener("mouseup", finish);
+ win.removeEventListener("mousemove", move);
+ win.removeEventListener("touchend", finish);
+ win.removeEventListener("touchcancel", finish);
+ win.removeEventListener("touchmove", move);
+
+ const clientX = getClientX(event);
+ if (clientX === null) {
+ console.log("No clientX on finish", event);
+ return;
+ }
+
+ const pluginState = columnResizingPluginKey.getState(view.state);
+ if (pluginState?.dragging) {
+ if (event instanceof TouchEvent)
+ (view as any).domObserver.connectSelection();
+ updateColumnWidth(
+ view,
+ activeHandle,
+ draggedWidth(pluginState.dragging, clientX, cellMinWidth)
+ );
+ view.dispatch(
+ view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null })
+ );
+ }
+ }
+
+ function move(event: MouseEvent | TouchEvent): void {
+ if (event instanceof MouseEvent && !event.which) return finish(event);
+ const clientX = getClientX(event);
+ if (clientX === null) return;
+ const pluginState = columnResizingPluginKey.getState(view.state);
+ if (pluginState?.dragging) {
+ const dragged = draggedWidth(pluginState.dragging, clientX, cellMinWidth);
+ displayColumnWidth(view, activeHandle, dragged, defaultCellMinWidth);
+ }
+ }
+
+ displayColumnWidth(view, activeHandle, width, defaultCellMinWidth);
+
+ win.addEventListener("mouseup", finish);
+ win.addEventListener("mousemove", move);
+ win.addEventListener("touchend", finish);
+ win.addEventListener("touchcancel", finish);
+ win.addEventListener("touchmove", move);
+ event.preventDefault();
+ if (event instanceof TouchEvent)
+ (view as any).domObserver.disconnectSelection();
+ 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 edgeCell(
+ state: EditorState,
+ pos: number,
+ side: "left" | "right"
+): number {
+ const $cell = cellAround(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,
+ clientX: number,
+ resizeMinWidth: number
+): number {
+ const offset = clientX - dragging.startX;
+ return Math.max(resizeMinWidth, dragging.startWidth + offset);
+}
+
+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.setNodeAttribute(start + pos, "colwidth", colwidth);
+ }
+ if (tr.docChanged) view.dispatch(tr);
+}
+
+function displayColumnWidth(
+ view: EditorView,
+ cell: number,
+ width: number,
+ defaultCellMinWidth: 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;
+ const tableElement = dom as HTMLTableElement;
+ updateColumnsOnResize(
+ table,
+ dom.firstChild as HTMLTableColElement,
+ tableElement,
+ defaultCellMinWidth,
+ col,
+ width
+ );
+}
+
+function zeroes(n: number): 0[] {
+ return Array(n).fill(0);
+}
+
+export function createColumnResizeHandles(
+ state: EditorState,
+ activeCellPos: number,
+ resizeState: ResizeState,
+ showResizeHandleOnSelection: boolean
+): DecorationSet | null {
+ if (activeCellPos === -1) return null;
+ const decorations = [];
+ const activeCell = state.doc.resolve(activeCellPos);
+ const table = activeCell.node(-1);
+ if (!table) return null;
+
+ const map = TableMap.get(table);
+ const start = activeCell.start(-1);
+ const totalCells = map.height * map.width;
+ const cellIndex = map.map.indexOf(activeCell.pos - start);
+
+ const oldDecorations = resizeState.decorations.find();
+ if (oldDecorations.length === totalCells) {
+ const activeCellDecoration = oldDecorations.find(
+ (c) => c.spec.index === cellIndex
+ );
+ if (!activeCellDecoration?.spec.active && showResizeHandleOnSelection) {
+ const oldActiveIndex = oldDecorations.findIndex((d) => d.spec.active);
+ const oldActive = oldDecorations[oldActiveIndex];
+ if (oldActive)
+ oldDecorations[oldActiveIndex] = Decoration.widget(
+ oldActive.from,
+ createResizeHandle(false),
+ {
+ active: false,
+ index: oldActive.spec.index
+ }
+ );
+
+ oldDecorations[cellIndex] = Decoration.widget(
+ oldDecorations[cellIndex].from,
+ createResizeHandle(true),
+ {
+ active: true,
+ index: cellIndex
+ }
+ );
+
+ return DecorationSet.create(state.doc, oldDecorations);
+ }
+
+ return null;
+ }
+
+ for (let i = 0; i < totalCells; i++) {
+ const cellPos = map.map[i];
+ const pos = start + cellPos + table.nodeAt(cellPos)!.nodeSize - 1;
+ decorations.push(
+ Decoration.widget(
+ pos,
+ createResizeHandle(showResizeHandleOnSelection && cellIndex === i),
+ {
+ active: showResizeHandleOnSelection && cellIndex === i,
+ index: i
+ }
+ )
+ );
+ }
+ return DecorationSet.create(state.doc, decorations);
+}
+
+function createResizeHandle(active: boolean) {
+ const dom = document.createElement("div");
+ dom.className = "column-resize-handle";
+ if (active) dom.classList.add("active");
+ dom.onmouseenter = () => {
+ dom.classList.add("active");
+ };
+ dom.onmouseleave = () => {
+ dom.classList.remove("active");
+ };
+ return dom;
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/commands.ts b/packages/editor/src/extensions/table/prosemirror-tables/commands.ts
new file mode 100644
index 000000000..b5feaf45b
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/commands.ts
@@ -0,0 +1,1006 @@
+// This file defines a number of table-related commands.
+
+import {
+ Fragment,
+ Node,
+ NodeType,
+ ResolvedPos,
+ Slice
+} from "prosemirror-model";
+import {
+ Command,
+ EditorState,
+ TextSelection,
+ Transaction
+} from "prosemirror-state";
+
+import { CellSelection } from "./cellselection.js";
+import type { Direction } from "./input.js";
+import { tableNodeTypes, TableRole } from "./schema.js";
+import { Rect, TableMap } from "./tablemap.js";
+import {
+ addColSpan,
+ cellAround,
+ CellAttrs,
+ cellWrapping,
+ columnIsHeader,
+ isInTable,
+ moveCellForward,
+ removeColSpan,
+ selectionCell
+} from "./util.js";
+import { moveColumn } from "./utils/move-column.js";
+import { moveRow } from "./utils/move-row.js";
+
+/**
+ * @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,
+ defaultCellAttrs?: { colwidth?: 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(defaultCellAttrs)!
+ );
+ }
+ }
+ return tr;
+}
+
+/**
+ * Command to add a column before the column with the selection.
+ *
+ * @public
+ */
+export function addColumnBefore(
+ state: EditorState,
+ dispatch?: (tr: Transaction) => void,
+ defaultCellAttrs?: { colwidth?: number[] }
+): boolean {
+ if (!isInTable(state)) return false;
+ if (dispatch) {
+ const rect = selectedRect(state);
+ dispatch(addColumn(state.tr, rect, rect.left, defaultCellAttrs));
+ }
+ return true;
+}
+
+/**
+ * Command to add a column after the column with the selection.
+ *
+ * @public
+ */
+export function addColumnAfter(
+ state: EditorState,
+ dispatch?: (tr: Transaction) => void,
+ defaultCellAttrs?: { colwidth?: number[] }
+): boolean {
+ if (!isInTable(state)) return false;
+ if (dispatch) {
+ const rect = selectedRect(state);
+ dispatch(addColumn(state.tr, rect, rect.right, defaultCellAttrs));
+ }
+ 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,
+ defaultCellAttrs?: { colwidth?: 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(defaultCellAttrs);
+ 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,
+ defaultCellAttrs?: { colwidth?: number[] }
+): boolean {
+ if (!isInTable(state)) return false;
+ if (dispatch) {
+ const rect = selectedRect(state);
+ dispatch(addRow(state.tr, rect, rect.top, defaultCellAttrs));
+ }
+ return true;
+}
+
+/**
+ * Add a table row after the selection.
+ *
+ * @public
+ */
+export function addRowAfter(
+ state: EditorState,
+ dispatch?: (tr: Transaction) => void,
+ defaultCellAttrs?: { colwidth?: number[] }
+): boolean {
+ if (!isInTable(state)) return false;
+ if (dispatch) {
+ const rect = selectedRect(state);
+ dispatch(addRow(state.tr, rect, rect.bottom, defaultCellAttrs));
+ }
+ 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);
+
+ const seen = new Set();
+
+ for (let col = 0, index = row * map.width; col < map.width; col++, index++) {
+ const pos = map.map[index];
+
+ // Skip cells that are checked already
+ if (seen.has(pos)) continue;
+ seen.add(pos);
+
+ 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.height && 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 = {};
+ 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
+): 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;
+}
+
+/**
+ * Deletes the content of the selected cells, if they are not empty.
+ *
+ * @public
+ */
+export 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;
+}
+
+/**
+ * Options for moveTableRow
+ *
+ * @public
+ */
+export interface MoveTableRowOptions {
+ /**
+ * The source row index to move from.
+ */
+ from: number;
+
+ /**
+ * The destination row index to move to.
+ */
+ to: number;
+
+ /**
+ * Whether to select the moved row after the operation.
+ *
+ * @default true
+ */
+ select?: boolean;
+
+ /**
+ * Optional position to resolve table from. If not provided, uses the current selection.
+ */
+ pos?: number;
+}
+
+/**
+ * Move a table row from index `from` to index `to`.
+ *
+ * @public
+ */
+export function moveTableRow(options: MoveTableRowOptions): Command {
+ return (state, dispatch) => {
+ const {
+ from: originIndex,
+ to: targetIndex,
+ select = true,
+ pos = state.selection.from
+ } = options;
+ const tr = state.tr;
+ if (moveRow({ tr, originIndex, targetIndex, select, pos })) {
+ dispatch?.(tr);
+ return true;
+ }
+ return false;
+ };
+}
+
+/**
+ * Options for moveTableColumn
+ *
+ * @public
+ */
+export interface MoveTableColumnOptions {
+ /**
+ * The source column index to move from.
+ */
+ from: number;
+
+ /**
+ * The destination column index to move to.
+ */
+ to: number;
+
+ /**
+ * Whether to select the moved column after the operation.
+ *
+ * @default true
+ */
+ select?: boolean;
+
+ /**
+ * Optional position to resolve table from. If not provided, uses the current selection.
+ */
+ pos?: number;
+}
+
+/**
+ * Move a table column from index `from` to index `to`.
+ *
+ * @public
+ */
+export function moveTableColumn(options: MoveTableColumnOptions): Command {
+ return (state, dispatch) => {
+ const {
+ from: originIndex,
+ to: targetIndex,
+ select = true,
+ pos = state.selection.from
+ } = options;
+ const tr = state.tr;
+ if (moveColumn({ tr, originIndex, targetIndex, select, pos })) {
+ dispatch?.(tr);
+ return true;
+ }
+ return false;
+ };
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/copypaste.ts b/packages/editor/src/extensions/table/prosemirror-tables/copypaste.ts
new file mode 100644
index 000000000..103a6687d
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/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.js";
+import { tableNodeTypes } from "./schema.js";
+import { ColWidths, Rect, TableMap } from "./tablemap.js";
+import { CellAttrs, removeColSpan } from "./util.js";
+
+/**
+ * @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/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts b/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts
new file mode 100644
index 000000000..848be68ac
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts
@@ -0,0 +1,153 @@
+// 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.js";
+import { TableMap } from "./tablemap.js";
+import { CellAttrs, removeColSpan } from "./util.js";
+
+/**
+ * @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
+ });
+ } else if (prob.type == "zero_sized") {
+ const pos = tr.mapping.map(tablePos);
+ tr.delete(pos, pos + table.nodeSize);
+ }
+ }
+ 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/packages/editor/src/extensions/table/prosemirror-tables/index.ts b/packages/editor/src/extensions/table/prosemirror-tables/index.ts
new file mode 100644
index 000000000..cceace57f
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/index.ts
@@ -0,0 +1,101 @@
+// 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.js";
+import { fixTables, fixTablesKey } from "./fixtables.js";
+import {
+ handleKeyDown,
+ handleMouseDown,
+ handlePaste,
+ handleTripleClick
+} from "./input.js";
+import { tableEditingKey } from "./util.js";
+
+/**
+ * @public
+ */
+export type TableEditingOptions = {
+ /**
+ * Whether to allow table node selection.
+ *
+ * By default, any node selection wrapping a table will be converted into a
+ * CellSelection wrapping all cells in the table. You can pass `true` to allow
+ * the selection to remain a NodeSelection.
+ *
+ * @default false
+ */
+ 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,
+ touchstart: handleMouseDown
+ },
+
+ createSelectionBetween(view) {
+ return tableEditingKey.getState(view.state) != null
+ ? view.state.selection
+ : null;
+ },
+
+ handleTripleClick,
+
+ handleKeyDown,
+
+ handlePaste
+ },
+
+ appendTransaction(transactions, oldState, state) {
+ return normalizeSelection(
+ state,
+ fixTables(state, oldState),
+ oldState,
+ allowTableNodeSelection
+ );
+ }
+ });
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/input.ts b/packages/editor/src/extensions/table/prosemirror-tables/input.ts
new file mode 100644
index 000000000..e989f76af
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/input.ts
@@ -0,0 +1,315 @@
+// This file defines a number of helpers for wiring up user input to
+// table-related functionality.
+
+import { keydownHandler } from "prosemirror-keymap";
+import { Fragment, ResolvedPos, Slice } from "prosemirror-model";
+import {
+ Command,
+ EditorState,
+ Selection,
+ TextSelection,
+ Transaction
+} from "prosemirror-state";
+
+import { EditorView } from "prosemirror-view";
+import { CellSelection } from "./cellselection.js";
+import { deleteCellSelection } from "./commands.js";
+import { clipCells, fitSlice, insertCells, pastedCells } from "./copypaste.js";
+import { tableNodeTypes } from "./schema.js";
+import { TableMap } from "./tablemap.js";
+import {
+ cellAround,
+ getClientX,
+ getClientY,
+ inSameTable,
+ isInTable,
+ nextCell,
+ selectionCell,
+ tableEditingKey
+} from "./util.js";
+import { columnResizingPluginKey } from "./columnresizing.js";
+
+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;
+}
+
+/**
+ * @internal
+ */
+export 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)
+ );
+ };
+}
+
+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 | TouchEvent
+): void {
+ if (startEvent.ctrlKey || startEvent.metaKey) return;
+ if (columnResizingPluginKey.getState(view.state)?.dragging) 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 (startEvent instanceof TouchEvent) {
+ const selectedCell = view.domAtPos(view.state.selection.from).node;
+ if (startDOMCell?.contains(selectedCell)) {
+ $anchor = cellUnderMouse(view, startEvent);
+ if (!$anchor) return;
+ setCellSelection($anchor, startEvent);
+ }
+ } 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 | DragEvent | TouchEvent
+ ): 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);
+ view.root.removeEventListener("touchmove", move);
+ view.root.removeEventListener("touchend", stop);
+ view.root.removeEventListener("touchcancel", stop);
+ if (tableEditingKey.getState(view.state) != null) {
+ (view as any).domObserver.suppressSelectionUpdates();
+ view.dispatch(view.state.tr.setMeta(tableEditingKey, -1));
+ }
+ }
+
+ function move(_event: Event): void {
+ const event = _event as MouseEvent | DragEvent | TouchEvent;
+ 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);
+ view.root.addEventListener("touchmove", move);
+ view.root.addEventListener("touchend", stop);
+ view.root.addEventListener("touchcancel", stop);
+}
+
+// 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 | DragEvent | TouchEvent
+): ResolvedPos | null {
+ const clientX = getClientX(event);
+ const clientY = getClientY(event);
+ if (clientX == null || clientY == null) return null;
+
+ const mousePos = view.posAtCoords({
+ left: clientX,
+ top: clientY
+ });
+ if (!mousePos) return null;
+ return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null;
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/schema.ts b/packages/editor/src/extensions/table/prosemirror-tables/schema.ts
new file mode 100644
index 000000000..211e3f34f
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/schema.ts
@@ -0,0 +1,215 @@
+// Helper for creating a schema that supports tables.
+
+import {
+ AttributeSpec,
+ Attrs,
+ Node,
+ NodeSpec,
+ NodeType,
+ Schema
+} from "prosemirror-model";
+import { CellAttrs, MutableAttrs } from "./util.js";
+
+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 or type name used to validate values of this attribute.
+ *
+ * See [validate](https://prosemirror.net/docs/ref/#model.AttributeSpec.validate).
+ */
+ validate?: string | ((value: unknown) => void);
+
+ /**
+ * 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 {
+ /**
+ * 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_row" | "table_cell" | "table_header",
+ NodeSpec
+>;
+
+function validateColwidth(value: unknown) {
+ if (value === null) {
+ return;
+ }
+ if (!Array.isArray(value)) {
+ throw new TypeError("colwidth must be null or an array");
+ }
+ for (const item of value) {
+ if (typeof item !== "number") {
+ throw new TypeError("colwidth must be null or an array of numbers");
+ }
+ }
+}
+
+/**
+ * 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 = {
+ colspan: { default: 1, validate: "number" },
+ rowspan: { default: 1, validate: "number" },
+ colwidth: { default: null, validate: validateColwidth }
+ };
+ for (const prop in extraAttrs)
+ cellAttrs[prop] = {
+ default: extraAttrs[prop].default,
+ validate: extraAttrs[prop].validate
+ };
+
+ 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 {
+ 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/packages/editor/src/extensions/table/prosemirror-tables/tablemap.ts b/packages/editor/src/extensions/table/prosemirror-tables/tablemap.ts
new file mode 100644
index 000000000..a2873df26
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/tablemap.ts
@@ -0,0 +1,383 @@
+// 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.js";
+
+/**
+ * @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;
+ }
+ | {
+ type: "zero_sized";
+ };
+
+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();
+ 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 = {};
+ 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++;
+ }
+
+ if (width === 0 || height === 0)
+ (problems || (problems = [])).push({ type: "zero_sized" });
+
+ 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 = {};
+ 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/packages/editor/src/extensions/table/prosemirror-tables/tableview.ts b/packages/editor/src/extensions/table/prosemirror-tables/tableview.ts
new file mode 100644
index 000000000..e053602f2
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/tableview.ts
@@ -0,0 +1,98 @@
+import { Node } from "prosemirror-model";
+import { NodeView } from "prosemirror-view";
+import { CellAttrs } from "./util.js";
+
+/**
+ * @public
+ */
+export class TableView implements NodeView {
+ public dom: HTMLDivElement;
+ public table: HTMLTableElement;
+ public colgroup: HTMLTableColElement;
+ public contentDOM: HTMLTableSectionElement;
+
+ constructor(public node: Node, public defaultCellMinWidth: number) {
+ this.dom = document.createElement("div");
+ this.dom.className = "tableWrapper";
+ this.table = this.dom.appendChild(document.createElement("table"));
+ this.table.style.setProperty(
+ "--default-cell-min-width",
+ `${defaultCellMinWidth}px`
+ );
+ this.colgroup = this.table.appendChild(document.createElement("colgroup"));
+ updateColumnsOnResize(node, this.colgroup, this.table, defaultCellMinWidth);
+ 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.defaultCellMinWidth
+ );
+ return true;
+ }
+
+ ignoreMutation(record: any): boolean {
+ return (
+ record.type == "attributes" &&
+ (record.target == this.table || this.colgroup.contains(record.target))
+ );
+ }
+}
+
+/**
+ * @public
+ */
+export function updateColumnsOnResize(
+ node: Node,
+ colgroup: HTMLTableColElement,
+ table: HTMLTableElement,
+ defaultCellMinWidth: 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 || defaultCellMinWidth;
+ if (!hasWidth) fixedWidth = false;
+ if (!nextDOM) {
+ const col = document.createElement("col");
+ col.style.width = cssWidth;
+ colgroup.appendChild(col);
+ } 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/packages/editor/src/extensions/table/prosemirror-tables/util.ts b/packages/editor/src/extensions/table/prosemirror-tables/util.ts
new file mode 100644
index 000000000..3372a97b4
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/util.ts
@@ -0,0 +1,218 @@
+// 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.js";
+import { tableNodeTypes } from "./schema.js";
+import { Rect, TableMap } from "./tablemap.js";
+
+/**
+ * @public
+ */
+export type MutableAttrs = Record;
+
+/**
+ * @public
+ */
+export interface CellAttrs {
+ colspan: number;
+ rowspan: number;
+ colwidth: number[] | null;
+}
+
+/**
+ * @public
+ */
+export const tableEditingKey = new PluginKey("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}`);
+}
+
+/**
+ * @public
+ */
+export 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;
+}
+
+export function getClientX(event: MouseEvent | TouchEvent): number | null {
+ return event instanceof MouseEvent
+ ? event.clientX
+ : event.touches.length > 0
+ ? event.touches[0].clientX
+ : event.changedTouches.length > 0
+ ? event.changedTouches[0].clientX
+ : null;
+}
+
+export function getClientY(event: MouseEvent | TouchEvent): number | null {
+ return event instanceof MouseEvent
+ ? event.clientY
+ : event.touches.length > 0
+ ? event.touches[0].clientY
+ : event.changedTouches.length > 0
+ ? event.changedTouches[0].clientY
+ : null;
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/convert.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/convert.ts
new file mode 100644
index 000000000..96b62b1c9
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/convert.ts
@@ -0,0 +1,116 @@
+import type { Node } from "prosemirror-model";
+import { TableMap } from "../tablemap.js";
+
+/**
+ * This function will transform the table node into a matrix of rows and columns
+ * respecting merged cells, for example this table:
+ *
+ * ```
+ * ┌──────┬──────┬─────────────┐
+ * │ A1 │ B1 │ C1 │
+ * ├──────┼──────┴──────┬──────┤
+ * │ A2 │ B2 │ │
+ * ├──────┼─────────────┤ D1 │
+ * │ A3 │ B3 │ C3 │ │
+ * └──────┴──────┴──────┴──────┘
+ * ```
+ *
+ * will be converted to the below:
+ *
+ * ```javascript
+ * [
+ * [A1, B1, C1, null],
+ * [A2, B2, null, D1],
+ * [A3, B3, C3, null],
+ * ]
+ * ```
+ * @internal
+ */
+export function convertTableNodeToArrayOfRows(
+ tableNode: Node
+): (Node | null)[][] {
+ const map = TableMap.get(tableNode);
+ const rows: (Node | null)[][] = [];
+ const rowCount = map.height;
+ const colCount = map.width;
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ const row: (Node | null)[] = [];
+ for (let colIndex = 0; colIndex < colCount; colIndex++) {
+ const cellIndex = rowIndex * colCount + colIndex;
+ const cellPos = map.map[cellIndex];
+ if (rowIndex > 0) {
+ const topCellIndex = cellIndex - colCount;
+ const topCellPos = map.map[topCellIndex];
+ if (cellPos === topCellPos) {
+ row.push(null);
+ continue;
+ }
+ }
+ if (colIndex > 0) {
+ const leftCellIndex = cellIndex - 1;
+ const leftCellPos = map.map[leftCellIndex];
+ if (cellPos === leftCellPos) {
+ row.push(null);
+ continue;
+ }
+ }
+ row.push(tableNode.nodeAt(cellPos));
+ }
+ rows.push(row);
+ }
+
+ return rows;
+}
+
+/**
+ * Convert an array of rows to a table node.
+ *
+ * @internal
+ */
+export function convertArrayOfRowsToTableNode(
+ tableNode: Node,
+ arrayOfNodes: (Node | null)[][]
+): Node {
+ const newRows: Node[] = [];
+ const map = TableMap.get(tableNode);
+ const rowCount = map.height;
+ const colCount = map.width;
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ const oldRow: Node = tableNode.child(rowIndex);
+ const newCells: Node[] = [];
+
+ for (let colIndex = 0; colIndex < colCount; colIndex++) {
+ const cell = arrayOfNodes[rowIndex][colIndex];
+ if (!cell) {
+ continue;
+ }
+
+ const cellPos = map.map[rowIndex * map.width + colIndex];
+ const oldCell = tableNode.nodeAt(cellPos);
+ if (!oldCell) {
+ continue;
+ }
+
+ const newCell = oldCell.type.createChecked(
+ cell.attrs,
+ cell.content,
+ cell.marks
+ );
+ newCells.push(newCell);
+ }
+
+ const newRow = oldRow.type.createChecked(
+ oldRow.attrs,
+ newCells,
+ oldRow.marks
+ );
+ newRows.push(newRow);
+ }
+
+ const newTable = tableNode.type.createChecked(
+ tableNode.attrs,
+ newRows,
+ tableNode.marks
+ );
+ return newTable;
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/get-cells.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/get-cells.ts
new file mode 100644
index 000000000..3476a6153
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/get-cells.ts
@@ -0,0 +1,72 @@
+import type { Selection } from "prosemirror-state";
+
+import { TableMap } from "../tablemap.js";
+import { FindNodeResult, findTable } from "./query.js";
+
+/**
+ * Returns an array of cells in a column at the specified column index.
+ *
+ * @internal
+ */
+export function getCellsInColumn(
+ columnIndex: number,
+ selection: Selection
+): FindNodeResult[] | undefined {
+ const table = findTable(selection.$from);
+ if (!table) {
+ return;
+ }
+
+ const map = TableMap.get(table.node);
+
+ if (columnIndex < 0 || columnIndex > map.width - 1) {
+ return;
+ }
+
+ const cells = map.cellsInRect({
+ left: columnIndex,
+ right: columnIndex + 1,
+ top: 0,
+ bottom: map.height
+ });
+
+ return cells.map((nodePos) => {
+ const node = table.node.nodeAt(nodePos)!;
+ const pos = nodePos + table.start;
+ return { pos, start: pos + 1, node, depth: table.depth + 2 };
+ });
+}
+
+/**
+ * Returns an array of cells in a row at the specified row index.
+ *
+ * @internal
+ */
+export function getCellsInRow(
+ rowIndex: number,
+ selection: Selection
+): FindNodeResult[] | undefined {
+ const table = findTable(selection.$from);
+ if (!table) {
+ return;
+ }
+
+ const map = TableMap.get(table.node);
+
+ if (rowIndex < 0 || rowIndex > map.height - 1) {
+ return;
+ }
+
+ const cells = map.cellsInRect({
+ left: 0,
+ right: map.width,
+ top: rowIndex,
+ bottom: rowIndex + 1
+ });
+
+ return cells.map((nodePos) => {
+ const node = table.node.nodeAt(nodePos)!;
+ const pos = nodePos + table.start;
+ return { pos, start: pos + 1, node, depth: table.depth + 2 };
+ });
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/move-column.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-column.ts
new file mode 100644
index 000000000..f0bcaaa69
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-column.ts
@@ -0,0 +1,88 @@
+import type { Node } from "prosemirror-model";
+import type { Transaction } from "prosemirror-state";
+
+import {
+ convertArrayOfRowsToTableNode,
+ convertTableNodeToArrayOfRows
+} from "./convert.js";
+import { getSelectionRangeInColumn } from "./selection-range.js";
+import { moveRowInArrayOfRows } from "./move-row-in-array-of-rows.js";
+import { findTable } from "./query.js";
+import { transpose } from "./transpose.js";
+import { TableMap } from "../tablemap.js";
+import { CellSelection } from "../cellselection.js";
+
+/**
+ * Parameters for moving a column in a table.
+ *
+ * @internal
+ */
+export interface MoveColumnParams {
+ tr: Transaction;
+ originIndex: number;
+ targetIndex: number;
+ select: boolean;
+ pos: number;
+}
+
+/**
+ * Move a column from index `origin` to index `target`.
+ *
+ * @internal
+ */
+export function moveColumn(moveColParams: MoveColumnParams): boolean {
+ const { tr, originIndex, targetIndex, select, pos } = moveColParams;
+ const $pos = tr.doc.resolve(pos);
+ const table = findTable($pos);
+ if (!table) return false;
+
+ const indexesOriginColumn = getSelectionRangeInColumn(
+ tr,
+ originIndex
+ )?.indexes;
+ const indexesTargetColumn = getSelectionRangeInColumn(
+ tr,
+ targetIndex
+ )?.indexes;
+
+ if (!indexesOriginColumn || !indexesTargetColumn) return false;
+
+ if (indexesOriginColumn.includes(targetIndex)) return false;
+
+ const newTable = moveTableColumn(
+ table.node,
+ indexesOriginColumn,
+ indexesTargetColumn,
+ 0
+ );
+
+ tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTable);
+
+ if (!select) return true;
+
+ const map = TableMap.get(newTable);
+ const start = table.start;
+ const index = targetIndex;
+ const lastCell = map.positionAt(map.height - 1, index, newTable);
+ const $lastCell = tr.doc.resolve(start + lastCell);
+
+ const firstCell = map.positionAt(0, index, newTable);
+ const $firstCell = tr.doc.resolve(start + firstCell);
+
+ tr.setSelection(CellSelection.colSelection($lastCell, $firstCell));
+ return true;
+}
+
+function moveTableColumn(
+ table: Node,
+ indexesOrigin: number[],
+ indexesTarget: number[],
+ direction: -1 | 1 | 0
+) {
+ let rows = transpose(convertTableNodeToArrayOfRows(table));
+
+ rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction);
+ rows = transpose(rows);
+
+ return convertArrayOfRowsToTableNode(table, rows);
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row-in-array-of-rows.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row-in-array-of-rows.ts
new file mode 100644
index 000000000..fd05d0c59
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row-in-array-of-rows.ts
@@ -0,0 +1,31 @@
+/**
+ * Move a row in an array of rows.
+ *
+ * @internal
+ */
+export function moveRowInArrayOfRows(
+ rows: T[],
+ indexesOrigin: number[],
+ indexesTarget: number[],
+ directionOverride: -1 | 1 | 0,
+): T[] {
+ const direction = indexesOrigin[0] > indexesTarget[0] ? -1 : 1;
+
+ const rowsExtracted = rows.splice(indexesOrigin[0], indexesOrigin.length);
+ const positionOffset = rowsExtracted.length % 2 === 0 ? 1 : 0;
+ let target: number;
+
+ if (directionOverride === -1 && direction === 1) {
+ target = indexesTarget[0] - 1;
+ } else if (directionOverride === 1 && direction === -1) {
+ target = indexesTarget[indexesTarget.length - 1] - positionOffset + 1;
+ } else {
+ target =
+ direction === -1
+ ? indexesTarget[0]
+ : indexesTarget[indexesTarget.length - 1] - positionOffset;
+ }
+
+ rows.splice(target, 0, ...rowsExtracted);
+ return rows;
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row.ts
new file mode 100644
index 000000000..86a015731
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row.ts
@@ -0,0 +1,80 @@
+import type { Node } from "prosemirror-model";
+import type { Transaction } from "prosemirror-state";
+
+import {
+ convertArrayOfRowsToTableNode,
+ convertTableNodeToArrayOfRows
+} from "./convert.js";
+import { getSelectionRangeInRow } from "./selection-range.js";
+import { moveRowInArrayOfRows } from "./move-row-in-array-of-rows.js";
+import { findTable } from "./query.js";
+import { TableMap } from "../tablemap.js";
+import { CellSelection } from "../cellselection.js";
+
+/**
+ * Parameters for moving a row in a table.
+ *
+ * @internal
+ */
+export interface MoveRowParams {
+ tr: Transaction;
+ originIndex: number;
+ targetIndex: number;
+ select: boolean;
+ pos: number;
+}
+
+/**
+ * Move a row from index `origin` to index `target`.
+ *
+ * @internal
+ */
+export function moveRow(moveRowParams: MoveRowParams): boolean {
+ const { tr, originIndex, targetIndex, select, pos } = moveRowParams;
+ const $pos = tr.doc.resolve(pos);
+ const table = findTable($pos);
+ if (!table) return false;
+
+ const indexesOriginRow = getSelectionRangeInRow(tr, originIndex)?.indexes;
+ const indexesTargetRow = getSelectionRangeInRow(tr, targetIndex)?.indexes;
+
+ if (!indexesOriginRow || !indexesTargetRow) return false;
+
+ if (indexesOriginRow.includes(targetIndex)) return false;
+
+ const newTable = moveTableRow(
+ table.node,
+ indexesOriginRow,
+ indexesTargetRow,
+ 0
+ );
+
+ tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTable);
+
+ if (!select) return true;
+
+ const map = TableMap.get(newTable);
+ const start = table.start;
+ const index = targetIndex;
+ const lastCell = map.positionAt(index, map.width - 1, newTable);
+ const $lastCell = tr.doc.resolve(start + lastCell);
+
+ const firstCell = map.positionAt(index, 0, newTable);
+ const $firstCell = tr.doc.resolve(start + firstCell);
+
+ tr.setSelection(CellSelection.rowSelection($lastCell, $firstCell));
+ return true;
+}
+
+function moveTableRow(
+ table: Node,
+ indexesOrigin: number[],
+ indexesTarget: number[],
+ direction: -1 | 1 | 0
+) {
+ let rows = convertTableNodeToArrayOfRows(table);
+
+ rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction);
+
+ return convertArrayOfRowsToTableNode(table, rows);
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/query.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/query.ts
new file mode 100644
index 000000000..8d404f11d
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/query.ts
@@ -0,0 +1,117 @@
+import type { Node, ResolvedPos } from "prosemirror-model";
+import type { Selection } from "prosemirror-state";
+import { CellSelection } from "../cellselection.js";
+import { cellAround, cellNear, inSameTable } from "../util.js";
+
+/**
+ * Checks if the given object is a `CellSelection` instance.
+ *
+ * @internal
+ */
+function isCellSelection(value: unknown): value is CellSelection {
+ return value instanceof CellSelection;
+}
+
+/**
+ * Find the closest table node for a given position.
+ *
+ * @public
+ */
+export function findTable($pos: ResolvedPos): FindNodeResult | null {
+ return findParentNode((node) => node.type.spec.tableRole === "table", $pos);
+}
+
+/**
+ * Try to find the anchor and head cell in the same table by using the given
+ * anchor and head as hit points, or fallback to the selection's anchor and
+ * head.
+ *
+ * @public
+ */
+export function findCellRange(
+ selection: Selection,
+ anchorHit?: number,
+ headHit?: number
+): [ResolvedPos, ResolvedPos] | null {
+ if (anchorHit == null && headHit == null && isCellSelection(selection)) {
+ return [selection.$anchorCell, selection.$headCell];
+ }
+
+ const anchor: number = anchorHit ?? headHit ?? selection.anchor;
+ const head: number = headHit ?? anchorHit ?? selection.head;
+
+ const doc = selection.$head.doc;
+
+ const $anchorCell = findCellPos(doc, anchor);
+ const $headCell = findCellPos(doc, head);
+
+ if ($anchorCell && $headCell && inSameTable($anchorCell, $headCell)) {
+ return [$anchorCell, $headCell];
+ }
+ return null;
+}
+
+/**
+ * Try to find a resolved pos of a cell by using the given pos as a hit point.
+ *
+ * @public
+ */
+export function findCellPos(doc: Node, pos: number): ResolvedPos | undefined {
+ const $pos = doc.resolve(pos);
+ return cellAround($pos) || cellNear($pos);
+}
+
+/**
+ * Result of finding a parent node.
+ *
+ * @public
+ */
+export interface FindNodeResult {
+ /**
+ * The closest parent node that satisfies the predicate.
+ */
+ node: Node;
+
+ /**
+ * The position directly before the node.
+ */
+ pos: number;
+
+ /**
+ * The position at the start of the node.
+ */
+ start: number;
+
+ /**
+ * The depth of the node.
+ */
+ depth: number;
+}
+
+/**
+ * Find the closest parent node that satisfies the predicate.
+ *
+ * @internal
+ */
+function findParentNode(
+ /**
+ * The predicate to test the parent node.
+ */
+ predicate: (node: Node) => boolean,
+ /**
+ * The position to start searching from.
+ */
+ $pos: ResolvedPos
+): FindNodeResult | null {
+ for (let depth = $pos.depth; depth >= 0; depth -= 1) {
+ const node = $pos.node(depth);
+
+ if (predicate(node)) {
+ const pos = depth === 0 ? 0 : $pos.before(depth);
+ const start = $pos.start(depth);
+ return { node, pos, start, depth };
+ }
+ }
+
+ return null;
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/selection-range.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/selection-range.ts
new file mode 100644
index 000000000..b9536f4e8
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/selection-range.ts
@@ -0,0 +1,191 @@
+import type { ResolvedPos } from "prosemirror-model";
+import type { Transaction } from "prosemirror-state";
+
+import { getCellsInColumn, getCellsInRow } from "./get-cells.js";
+
+export type CellSelectionRange = {
+ $anchor: ResolvedPos;
+ $head: ResolvedPos;
+ // an array of column/row indexes
+ indexes: number[];
+};
+
+/**
+ * Returns a range of rectangular selection spanning all merged cells around a
+ * column at index `columnIndex`.
+ *
+ * Original implementation from Atlassian (Apache License 2.0)
+ *
+ * https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-column.ts#editor/editor-tables/src/utils/get-selection-range-in-column.ts
+ *
+ * @internal
+ */
+export function getSelectionRangeInColumn(
+ tr: Transaction,
+ startColIndex: number,
+ endColIndex: number = startColIndex
+): CellSelectionRange | undefined {
+ let startIndex = startColIndex;
+ let endIndex = endColIndex;
+
+ // looking for selection start column (startIndex)
+ for (let i = startColIndex; i >= 0; i--) {
+ const cells = getCellsInColumn(i, tr.selection);
+ if (cells) {
+ cells.forEach((cell) => {
+ const maybeEndIndex = cell.node.attrs.colspan + i - 1;
+ if (maybeEndIndex >= startIndex) {
+ startIndex = i;
+ }
+ if (maybeEndIndex > endIndex) {
+ endIndex = maybeEndIndex;
+ }
+ });
+ }
+ }
+ // looking for selection end column (endIndex)
+ for (let i = startColIndex; i <= endIndex; i++) {
+ const cells = getCellsInColumn(i, tr.selection);
+ if (cells) {
+ cells.forEach((cell) => {
+ const maybeEndIndex = cell.node.attrs.colspan + i - 1;
+ if (cell.node.attrs.colspan > 1 && maybeEndIndex > endIndex) {
+ endIndex = maybeEndIndex;
+ }
+ });
+ }
+ }
+
+ // filter out columns without cells (where all rows have colspan > 1 in the same column)
+ const indexes = [];
+ for (let i = startIndex; i <= endIndex; i++) {
+ const maybeCells = getCellsInColumn(i, tr.selection);
+ if (maybeCells && maybeCells.length > 0) {
+ indexes.push(i);
+ }
+ }
+ startIndex = indexes[0];
+ endIndex = indexes[indexes.length - 1];
+
+ const firstSelectedColumnCells = getCellsInColumn(startIndex, tr.selection);
+ const firstRowCells = getCellsInRow(0, tr.selection);
+ if (!firstSelectedColumnCells || !firstRowCells) {
+ return;
+ }
+
+ const $anchor = tr.doc.resolve(
+ firstSelectedColumnCells[firstSelectedColumnCells.length - 1].pos
+ );
+
+ let headCell;
+ for (let i = endIndex; i >= startIndex; i--) {
+ const columnCells = getCellsInColumn(i, tr.selection);
+ if (columnCells && columnCells.length > 0) {
+ for (let j = firstRowCells.length - 1; j >= 0; j--) {
+ if (firstRowCells[j].pos === columnCells[0].pos) {
+ headCell = columnCells[0];
+ break;
+ }
+ }
+ if (headCell) {
+ break;
+ }
+ }
+ }
+ if (!headCell) {
+ return;
+ }
+
+ const $head = tr.doc.resolve(headCell.pos);
+ return { $anchor, $head, indexes };
+}
+
+/**
+ * Returns a range of rectangular selection spanning all merged cells around a
+ * row at index `rowIndex`.
+ *
+ * Original implementation from Atlassian (Apache License 2.0)
+ *
+ * https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-row.ts#editor/editor-tables/src/utils/get-selection-range-in-row.ts
+ *
+ * @internal
+ */
+export function getSelectionRangeInRow(
+ tr: Transaction,
+ startRowIndex: number,
+ endRowIndex: number = startRowIndex
+): CellSelectionRange | undefined {
+ let startIndex = startRowIndex;
+ let endIndex = endRowIndex;
+
+ // looking for selection start row (startIndex)
+ for (let i = startRowIndex; i >= 0; i--) {
+ const cells = getCellsInRow(i, tr.selection);
+ if (cells) {
+ cells.forEach((cell) => {
+ const maybeEndIndex = cell.node.attrs.rowspan + i - 1;
+ if (maybeEndIndex >= startIndex) {
+ startIndex = i;
+ }
+ if (maybeEndIndex > endIndex) {
+ endIndex = maybeEndIndex;
+ }
+ });
+ }
+ }
+ // looking for selection end row (endIndex)
+ for (let i = startRowIndex; i <= endIndex; i++) {
+ const cells = getCellsInRow(i, tr.selection);
+ if (cells) {
+ cells.forEach((cell) => {
+ const maybeEndIndex = cell.node.attrs.rowspan + i - 1;
+ if (cell.node.attrs.rowspan > 1 && maybeEndIndex > endIndex) {
+ endIndex = maybeEndIndex;
+ }
+ });
+ }
+ }
+
+ // filter out rows without cells (where all columns have rowspan > 1 in the same row)
+ const indexes = [];
+ for (let i = startIndex; i <= endIndex; i++) {
+ const maybeCells = getCellsInRow(i, tr.selection);
+ if (maybeCells && maybeCells.length > 0) {
+ indexes.push(i);
+ }
+ }
+ startIndex = indexes[0];
+ endIndex = indexes[indexes.length - 1];
+
+ const firstSelectedRowCells = getCellsInRow(startIndex, tr.selection);
+ const firstColumnCells = getCellsInColumn(0, tr.selection);
+ if (!firstSelectedRowCells || !firstColumnCells) {
+ return;
+ }
+
+ const $anchor = tr.doc.resolve(
+ firstSelectedRowCells[firstSelectedRowCells.length - 1].pos
+ );
+
+ let headCell;
+ for (let i = endIndex; i >= startIndex; i--) {
+ const rowCells = getCellsInRow(i, tr.selection);
+ if (rowCells && rowCells.length > 0) {
+ for (let j = firstColumnCells.length - 1; j >= 0; j--) {
+ if (firstColumnCells[j].pos === rowCells[0].pos) {
+ headCell = rowCells[0];
+ break;
+ }
+ }
+ if (headCell) {
+ break;
+ }
+ }
+ }
+ if (!headCell) {
+ return;
+ }
+
+ const $head = tr.doc.resolve(headCell.pos);
+ return { $anchor, $head, indexes };
+}
diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/transpose.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/transpose.ts
new file mode 100644
index 000000000..189ce1890
--- /dev/null
+++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/transpose.ts
@@ -0,0 +1,29 @@
+/**
+ * Transposes a 2D array by flipping columns to rows.
+ *
+ * Transposition is a familiar algebra concept where the matrix is flipped
+ * along its diagonal. For more details, see:
+ * https://en.wikipedia.org/wiki/Transpose
+ *
+ * @example
+ * ```javascript
+ * const arr = [
+ * ['a1', 'a2', 'a3'],
+ * ['b1', 'b2', 'b3'],
+ * ['c1', 'c2', 'c3'],
+ * ['d1', 'd2', 'd3'],
+ * ];
+ *
+ * const result = transpose(arr);
+ * result === [
+ * ['a1', 'b1', 'c1', 'd1'],
+ * ['a2', 'b2', 'c2', 'd2'],
+ * ['a3', 'b3', 'c3', 'd3'],
+ * ]
+ * ```
+ */
+export function transpose(array: T[][]): T[][] {
+ return array[0].map((_, i) => {
+ return array.map((column) => column[i]);
+ });
+}
diff --git a/packages/editor/src/extensions/table/table.ts b/packages/editor/src/extensions/table/table.ts
index a89b4ac1b..e344c10db 100644
--- a/packages/editor/src/extensions/table/table.ts
+++ b/packages/editor/src/extensions/table/table.ts
@@ -17,12 +17,465 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
-import { Table as TiptapTable, TableOptions } from "@tiptap/extension-table";
-import { tableEditing, columnResizing, TableView } from "@tiptap/pm/tables";
+import {
+ callOrReturn,
+ getExtensionField,
+ mergeAttributes,
+ Node,
+ ParentConfig
+} from "@tiptap/core";
+import { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
+import { EditorView, NodeView } from "@tiptap/pm/view";
+import { createColGroup } from "./utilities/createColGroup.js";
+import { createTable } from "./utilities/createTable.js";
+import { deleteTableWhenAllCellsSelected } from "./utilities/deleteTableWhenAllCellsSelected.js";
+import { TableView } from "./TableView.js";
import { TableNodeView } from "./component.js";
-import { Plugin, PluginKey } from "prosemirror-state";
+import {
+ addColumnAfter,
+ addColumnBefore,
+ addRowAfter,
+ addRowBefore,
+ deleteColumn,
+ deleteRow,
+ deleteTable,
+ goToNextCell,
+ mergeCells,
+ setCellAttr,
+ splitCell,
+ toggleHeader,
+ toggleHeaderCell
+} from "./prosemirror-tables/commands.js";
+import { fixTables } from "./prosemirror-tables/fixtables.js";
+import { CellSelection } from "./prosemirror-tables/cellselection.js";
+import { columnResizing } from "./prosemirror-tables/columnresizing.js";
+import { tableEditing } from "./prosemirror-tables/index.js";
+
+export interface TableOptions {
+ /**
+ * HTML attributes for the table element.
+ * @default {}
+ * @example { class: 'foo' }
+ */
+ HTMLAttributes: Record;
+
+ /**
+ * Enables the resizing of tables.
+ * @default false
+ * @example true
+ */
+ resizable: boolean;
+
+ /**
+ * The minimum width of a cell.
+ * @default 25
+ * @example 50
+ */
+ cellMinWidth: number;
+
+ showResizeHandleOnSelection: boolean;
+
+ /**
+ * The node view to render the table.
+ * @default TableView
+ */
+ View:
+ | (new (
+ node: ProseMirrorNode,
+ cellMinWidth: number,
+ view: EditorView
+ ) => NodeView)
+ | null;
+
+ /**
+ * Allow table node selection.
+ * @default false
+ * @example true
+ */
+ allowTableNodeSelection: boolean;
+
+ defaultCellAttrs: { colwidth?: number[] };
+}
+
+declare module "@tiptap/core" {
+ interface Commands {
+ table: {
+ /**
+ * Insert a table
+ * @param options The table attributes
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
+ */
+ insertTable: (options?: {
+ rows?: number;
+ cols?: number;
+ withHeaderRow?: boolean;
+ }) => ReturnType;
+
+ /**
+ * Add a column before the current column
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.addColumnBefore()
+ */
+ addColumnBefore: () => ReturnType;
+
+ /**
+ * Add a column after the current column
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.addColumnAfter()
+ */
+ addColumnAfter: () => ReturnType;
+
+ /**
+ * Delete the current column
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.deleteColumn()
+ */
+ deleteColumn: () => ReturnType;
+
+ /**
+ * Add a row before the current row
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.addRowBefore()
+ */
+ addRowBefore: () => ReturnType;
+
+ /**
+ * Add a row after the current row
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.addRowAfter()
+ */
+ addRowAfter: () => ReturnType;
+
+ /**
+ * Delete the current row
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.deleteRow()
+ */
+ deleteRow: () => ReturnType;
+
+ /**
+ * Delete the current table
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.deleteTable()
+ */
+ deleteTable: () => ReturnType;
+
+ /**
+ * Merge the currently selected cells
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.mergeCells()
+ */
+ mergeCells: () => ReturnType;
+
+ /**
+ * Split the currently selected cell
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.splitCell()
+ */
+ splitCell: () => ReturnType;
+
+ /**
+ * Toggle the header column
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.toggleHeaderColumn()
+ */
+ toggleHeaderColumn: () => ReturnType;
+
+ /**
+ * Toggle the header row
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.toggleHeaderRow()
+ */
+ toggleHeaderRow: () => ReturnType;
+
+ /**
+ * Toggle the header cell
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.toggleHeaderCell()
+ */
+ toggleHeaderCell: () => ReturnType;
+
+ /**
+ * Merge or split the currently selected cells
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.mergeOrSplit()
+ */
+ mergeOrSplit: () => ReturnType;
+
+ /**
+ * Set a cell attribute
+ * @param name The attribute name
+ * @param value The attribute value
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.setCellAttribute('align', 'right')
+ */
+ setCellAttribute: (name: string, value: any) => ReturnType;
+
+ /**
+ * Moves the selection to the next cell
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.goToNextCell()
+ */
+ goToNextCell: () => ReturnType;
+
+ /**
+ * Moves the selection to the previous cell
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.goToPreviousCell()
+ */
+ goToPreviousCell: () => ReturnType;
+
+ /**
+ * Try to fix the table structure if necessary
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.fixTables()
+ */
+ fixTables: () => ReturnType;
+
+ /**
+ * Set a cell selection inside the current table
+ * @param position The cell position
+ * @returns True if the command was successful, otherwise false
+ * @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 })
+ */
+ setCellSelection: (position: {
+ anchorCell: number;
+ headCell?: number;
+ }) => ReturnType;
+ };
+ }
+
+ interface NodeConfig {
+ /**
+ * A string or function to determine the role of the table.
+ * @default 'table'
+ * @example () => 'table'
+ */
+ tableRole?:
+ | string
+ | ((this: {
+ name: string;
+ options: Options;
+ storage: Storage;
+ parent: ParentConfig>["tableRole"];
+ }) => string);
+ }
+}
+
+/**
+ * This extension allows you to create tables.
+ * @see https://www.tiptap.dev/api/nodes/table
+ */
+export const Table = Node.create({
+ name: "table",
+
+ // @ts-ignore
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ resizable: false,
+ showResizeHandleOnSelection: false,
+ cellMinWidth: 25,
+ allowTableNodeSelection: false,
+ defaultCellAttrs: {}
+ };
+ },
+
+ content: "tableRow+",
+
+ tableRole: "table",
+
+ isolating: true,
+
+ group: "block",
+
+ parseHTML() {
+ return [{ tag: "table" }];
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ const { colgroup, tableWidth, tableMinWidth } = createColGroup(
+ node,
+ this.options.cellMinWidth
+ );
+
+ const table: DOMOutputSpec = [
+ "table",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ style: tableWidth
+ ? `width: ${tableWidth}`
+ : `min-width: ${tableMinWidth}`
+ }),
+ colgroup,
+ ["tbody", 0]
+ ];
+
+ return table;
+ },
+
+ addCommands() {
+ return {
+ insertTable:
+ ({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
+ ({ tr, dispatch, editor }) => {
+ const node = createTable(
+ editor.schema,
+ rows,
+ cols,
+ withHeaderRow,
+ undefined,
+ this.options.defaultCellAttrs
+ );
+
+ if (dispatch) {
+ const offset = tr.selection.from + 1;
+
+ tr.replaceSelectionWith(node)
+ .scrollIntoView()
+ .setSelection(TextSelection.near(tr.doc.resolve(offset)));
+ }
+
+ return true;
+ },
+ addColumnBefore:
+ () =>
+ ({ state, dispatch }) => {
+ return addColumnBefore(
+ state,
+ dispatch,
+ this.options.defaultCellAttrs
+ );
+ },
+ addColumnAfter:
+ () =>
+ ({ state, dispatch }) => {
+ return addColumnAfter(state, dispatch, this.options.defaultCellAttrs);
+ },
+ deleteColumn:
+ () =>
+ ({ state, dispatch }) => {
+ return deleteColumn(state, dispatch);
+ },
+ addRowBefore:
+ () =>
+ ({ state, dispatch }) => {
+ return addRowBefore(state, dispatch);
+ },
+ addRowAfter:
+ () =>
+ ({ state, dispatch }) => {
+ return addRowAfter(state, dispatch);
+ },
+ deleteRow:
+ () =>
+ ({ state, dispatch }) => {
+ return deleteRow(state, dispatch);
+ },
+ deleteTable:
+ () =>
+ ({ state, dispatch }) => {
+ return deleteTable(state, dispatch);
+ },
+ mergeCells:
+ () =>
+ ({ state, dispatch }) => {
+ return mergeCells(state, dispatch);
+ },
+ splitCell:
+ () =>
+ ({ state, dispatch }) => {
+ return splitCell(state, dispatch);
+ },
+ toggleHeaderColumn:
+ () =>
+ ({ state, dispatch }) => {
+ return toggleHeader("column")(state, dispatch);
+ },
+ toggleHeaderRow:
+ () =>
+ ({ state, dispatch }) => {
+ return toggleHeader("row")(state, dispatch);
+ },
+ toggleHeaderCell:
+ () =>
+ ({ state, dispatch }) => {
+ return toggleHeaderCell(state, dispatch);
+ },
+ mergeOrSplit:
+ () =>
+ ({ state, dispatch }) => {
+ if (mergeCells(state, dispatch)) {
+ return true;
+ }
+
+ return splitCell(state, dispatch);
+ },
+ setCellAttribute:
+ (name, value) =>
+ ({ state, dispatch }) => {
+ return setCellAttr(name, value)(state, dispatch);
+ },
+ goToNextCell:
+ () =>
+ ({ state, dispatch }) => {
+ return goToNextCell(1)(state, dispatch);
+ },
+ goToPreviousCell:
+ () =>
+ ({ state, dispatch }) => {
+ return goToNextCell(-1)(state, dispatch);
+ },
+ fixTables:
+ () =>
+ ({ state, dispatch }) => {
+ if (dispatch) {
+ fixTables(state);
+ }
+
+ return true;
+ },
+ setCellSelection:
+ (position) =>
+ ({ tr, dispatch }) => {
+ if (dispatch) {
+ const selection = CellSelection.create(
+ tr.doc,
+ position.anchorCell,
+ position.headCell
+ );
+
+ // @ts-ignore
+ tr.setSelection(selection);
+ }
+
+ return true;
+ }
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Tab: () => {
+ if (this.editor.commands.goToNextCell()) {
+ return true;
+ }
+
+ if (!this.editor.can().addRowAfter()) {
+ return false;
+ }
+
+ return this.editor.chain().addRowAfter().goToNextCell().run();
+ },
+ "Shift-Tab": () => this.editor.commands.goToPreviousCell(),
+ Backspace: deleteTableWhenAllCellsSelected,
+ "Mod-Backspace": deleteTableWhenAllCellsSelected,
+ Delete: deleteTableWhenAllCellsSelected,
+ "Mod-Delete": deleteTableWhenAllCellsSelected
+ };
+ },
-export const Table = TiptapTable.extend({
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable;
@@ -30,10 +483,10 @@ export const Table = TiptapTable.extend({
...(isResizable
? [
columnResizing({
- handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
View: TableNodeView(this.editor),
- lastColumnResizable: this.options.lastColumnResizable
+ showResizeHandleOnSelection:
+ this.options.showResizeHandleOnSelection
})
]
: [tiptapTableView(this.options.cellMinWidth)]),
@@ -41,6 +494,20 @@ export const Table = TiptapTable.extend({
allowTableNodeSelection: this.options.allowTableNodeSelection
})
];
+ },
+
+ extendNodeSchema(extension) {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: extension.storage
+ };
+
+ return {
+ tableRole: callOrReturn(
+ getExtensionField(extension, "tableRole", context)
+ )
+ };
}
});
diff --git a/packages/editor/src/extensions/table/utilities/createCell.ts b/packages/editor/src/extensions/table/utilities/createCell.ts
new file mode 100644
index 000000000..8873d8acf
--- /dev/null
+++ b/packages/editor/src/extensions/table/utilities/createCell.ts
@@ -0,0 +1,13 @@
+import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model";
+
+export function createCell(
+ cellType: NodeType,
+ cellContent?: Fragment | ProsemirrorNode | Array,
+ defaultCellAttrs?: { colwidth?: number[] }
+): ProsemirrorNode | null | undefined {
+ if (cellContent) {
+ return cellType.createChecked(defaultCellAttrs, cellContent);
+ }
+
+ return cellType.createAndFill(defaultCellAttrs);
+}
diff --git a/packages/editor/src/extensions/table/utilities/createColGroup.ts b/packages/editor/src/extensions/table/utilities/createColGroup.ts
new file mode 100644
index 000000000..2c4d305ab
--- /dev/null
+++ b/packages/editor/src/extensions/table/utilities/createColGroup.ts
@@ -0,0 +1,50 @@
+import { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
+
+/**
+ * Creates a colgroup element for a table node in ProseMirror.
+ *
+ * @param node - The ProseMirror node representing the table.
+ * @param cellMinWidth - The minimum width of a cell in the table.
+ * @param overrideCol - (Optional) The index of the column to override the width of.
+ * @param overrideValue - (Optional) The width value to use for the overridden column.
+ * @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table.
+ */
+export function createColGroup(
+ node: ProseMirrorNode,
+ cellMinWidth: number,
+ overrideCol?: number,
+ overrideValue?: any,
+) {
+ let totalWidth = 0
+ let fixedWidth = true
+ const cols: DOMOutputSpec[] = []
+ const row = node.firstChild
+
+ if (!row) {
+ return {}
+ }
+
+ for (let i = 0, col = 0; i < row.childCount; i += 1) {
+ const { colspan, colwidth } = row.child(i).attrs
+
+ for (let j = 0; j < colspan; j += 1, col += 1) {
+ const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j]
+ const cssWidth = hasWidth ? `${hasWidth}px` : ''
+
+ totalWidth += hasWidth || cellMinWidth
+
+ if (!hasWidth) {
+ fixedWidth = false
+ }
+
+ cols.push(['col', cssWidth ? { style: `width: ${cssWidth}` } : {}])
+ }
+ }
+
+ const tableWidth = fixedWidth ? `${totalWidth}px` : ''
+ const tableMinWidth = fixedWidth ? '' : `${totalWidth}px`
+
+ const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols]
+
+ return { colgroup, tableWidth, tableMinWidth }
+}
diff --git a/packages/editor/src/extensions/table/utilities/createTable.ts b/packages/editor/src/extensions/table/utilities/createTable.ts
new file mode 100644
index 000000000..6b6024c28
--- /dev/null
+++ b/packages/editor/src/extensions/table/utilities/createTable.ts
@@ -0,0 +1,50 @@
+import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
+
+import { createCell } from "./createCell.js";
+import { getTableNodeTypes } from "./getTableNodeTypes.js";
+
+export function createTable(
+ schema: Schema,
+ rowsCount: number,
+ colsCount: number,
+ withHeaderRow: boolean,
+ cellContent?: Fragment | ProsemirrorNode | Array,
+ defaultCellAttrs?: { colwidth?: number[] }
+): ProsemirrorNode {
+ const types = getTableNodeTypes(schema);
+ const headerCells: ProsemirrorNode[] = [];
+ const cells: ProsemirrorNode[] = [];
+
+ for (let index = 0; index < colsCount; index += 1) {
+ const cell = createCell(types.cell, cellContent, defaultCellAttrs);
+
+ if (cell) {
+ cells.push(cell);
+ }
+
+ if (withHeaderRow) {
+ const headerCell = createCell(
+ types.header_cell,
+ cellContent,
+ defaultCellAttrs
+ );
+
+ if (headerCell) {
+ headerCells.push(headerCell);
+ }
+ }
+ }
+
+ const rows: ProsemirrorNode[] = [];
+
+ for (let index = 0; index < rowsCount; index += 1) {
+ rows.push(
+ types.row.createChecked(
+ null,
+ withHeaderRow && index === 0 ? headerCells : cells
+ )
+ );
+ }
+
+ return types.table.createChecked(null, rows);
+}
diff --git a/packages/editor/src/extensions/table/utilities/deleteTableWhenAllCellsSelected.ts b/packages/editor/src/extensions/table/utilities/deleteTableWhenAllCellsSelected.ts
new file mode 100644
index 000000000..c054f4a9b
--- /dev/null
+++ b/packages/editor/src/extensions/table/utilities/deleteTableWhenAllCellsSelected.ts
@@ -0,0 +1,36 @@
+import { findParentNodeClosestToPos, KeyboardShortcutCommand } from '@tiptap/core'
+
+import { isCellSelection } from './isCellSelection.js'
+
+export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {
+ const { selection } = editor.state
+
+ if (!isCellSelection(selection)) {
+ return false
+ }
+
+ let cellCount = 0
+ const table = findParentNodeClosestToPos(selection.ranges[0].$from, node => {
+ return node.type.name === 'table'
+ })
+
+ table?.node.descendants(node => {
+ if (node.type.name === 'table') {
+ return false
+ }
+
+ if (['tableCell', 'tableHeader'].includes(node.type.name)) {
+ cellCount += 1
+ }
+ })
+
+ const allCellsSelected = cellCount === selection.ranges.length
+
+ if (!allCellsSelected) {
+ return false
+ }
+
+ editor.commands.deleteTable()
+
+ return true
+}
diff --git a/packages/editor/src/extensions/table/utilities/getTableNodeTypes.ts b/packages/editor/src/extensions/table/utilities/getTableNodeTypes.ts
new file mode 100644
index 000000000..aafee1cd4
--- /dev/null
+++ b/packages/editor/src/extensions/table/utilities/getTableNodeTypes.ts
@@ -0,0 +1,21 @@
+import { NodeType, Schema } from '@tiptap/pm/model'
+
+export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
+ if (schema.cached.tableNodeTypes) {
+ return schema.cached.tableNodeTypes
+ }
+
+ const roles: { [key: string]: NodeType } = {}
+
+ Object.keys(schema.nodes).forEach(type => {
+ const nodeType = schema.nodes[type]
+
+ if (nodeType.spec.tableRole) {
+ roles[nodeType.spec.tableRole] = nodeType
+ }
+ })
+
+ schema.cached.tableNodeTypes = roles
+
+ return roles
+}
diff --git a/packages/editor/src/extensions/table/utilities/isCellSelection.ts b/packages/editor/src/extensions/table/utilities/isCellSelection.ts
new file mode 100644
index 000000000..1bdedf69c
--- /dev/null
+++ b/packages/editor/src/extensions/table/utilities/isCellSelection.ts
@@ -0,0 +1,5 @@
+import { CellSelection } from "../prosemirror-tables/cellselection.js";
+
+export function isCellSelection(value: unknown): value is CellSelection {
+ return value instanceof CellSelection;
+}
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index d85687f73..1c16a8286 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -285,7 +285,11 @@ const useTiptap = (
Table.configure({
resizable: true,
allowTableNodeSelection: true,
- cellMinWidth: 50
+ cellMinWidth: 20,
+ showResizeHandleOnSelection: isMobile,
+ defaultCellAttrs: {
+ colwidth: [100]
+ }
}),
Clipboard,
TableRow,
@@ -385,6 +389,7 @@ const useTiptap = (
parseOptions: { preserveWhitespace: true }
}),
[
+ isMobile,
previewAttachment,
downloadAttachment,
openAttachmentPicker,
diff --git a/packages/editor/src/toolbar/tools/table.tsx b/packages/editor/src/toolbar/tools/table.tsx
index 594bc7d02..5c7c22673 100644
--- a/packages/editor/src/toolbar/tools/table.tsx
+++ b/packages/editor/src/toolbar/tools/table.tsx
@@ -41,8 +41,6 @@ import { useToolbarLocation } from "../stores/toolbar-store.js";
import { showPopup } from "../../components/popup-presenter/index.js";
import { useRefValue } from "../../hooks/use-ref-value.js";
import { strings } from "@notesnook/intl";
-import { selectedRect } from "@tiptap/pm/tables";
-import { TextSelection } from "@tiptap/pm/state";
export function TableSettings(props: ToolProps) {
const { editor } = props;
@@ -90,6 +88,7 @@ export function RowProperties(props: ToolProps) {
setIsMenuOpen(true)}
@@ -134,6 +133,7 @@ export function ColumnProperties(props: ToolProps) {
setIsMenuOpen(true)}
@@ -184,6 +184,7 @@ export function TableProperties(props: ToolProps) {
setIsMenuOpen(true)}
diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css
index cec91095b..59234251b 100644
--- a/packages/editor/styles/styles.css
+++ b/packages/editor/styles/styles.css
@@ -228,7 +228,7 @@ img.ProseMirror-separator {
.ProseMirror table {
border-collapse: collapse;
margin: 0;
- overflow: hidden;
+ overflow-y: hidden;
table-layout: fixed;
}
@@ -242,10 +242,6 @@ img.ProseMirror-separator {
vertical-align: top;
}
-.ProseMirror table .selectedCell {
- border-color: var(--accent);
-}
-
.ProseMirror table td > *,
.ProseMirror table th > * {
margin-bottom: 0;
@@ -258,8 +254,9 @@ img.ProseMirror-separator {
}
.ProseMirror table .selectedCell:after {
- background: var(--accent);
content: "";
+ background: var(--shade);
+ box-shadow: inset 0px 0px 0px 1px var(--accent);
left: 0;
right: 0;
top: 0;
@@ -267,27 +264,62 @@ img.ProseMirror-separator {
pointer-events: none;
position: absolute;
z-index: 2;
- opacity: 0.2;
}
.ProseMirror table .column-resize-handle {
- background-color: var(--accent);
+ background-color: transparent;
bottom: -2px;
position: absolute;
- right: 0px;
- pointer-events: none;
+ right: -5px;
top: 0;
- width: 5px;
+ width: 10px;
+
+ cursor: ew-resize;
+ cursor: col-resize;
+ z-index: 999;
+}
+
+.ProseMirror table .column-resize-handle.active::before {
+ position: absolute;
+ content: "";
+ top: calc(50% - 11px);
+ left: -4px;
+
+ width: 16px;
+ height: 18px;
+
+ background-color: var(--background);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+
+ cursor: ew-resize;
+ cursor: col-resize;
+}
+
+.ProseMirror table .column-resize-handle.active::after {
+ position: absolute;
+ content: "";
+ top: calc(50% - 10px);
+ left: -4px;
+
+ background-size: 16px;
+ background-color: var(--icon);
+ width: 18px;
+ height: 18px;
+ mask: url(https://api.iconify.design/ic:round-drag-indicator.svg) no-repeat 50% 50%;
+ mask-size: cover;
+
+ cursor: ew-resize;
+ cursor: col-resize;
}
.ProseMirror table p {
margin: 0;
}
-
+/*
.resize-cursor {
- cursor: ew-resize;
- cursor: col-resize;
-}
+
+} */
.drop-cursor {
background-color: var(--paragraph, var(--nn_primary_paragraph)) !important;