Merge pull request #8930 from streetwriters/feat/improve-tables-on-mobile

This commit is contained in:
Abdullah Atta
2025-11-27 11:12:43 +05:00
38 changed files with 5521 additions and 125 deletions

View File

@@ -32,9 +32,6 @@
"@tiptap/extension-placeholder": "2.6.6",
"@tiptap/extension-subscript": "2.6.6",
"@tiptap/extension-superscript": "2.6.6",
"@tiptap/extension-table": "2.6.6",
"@tiptap/extension-table-cell": "2.6.6",
"@tiptap/extension-table-header": "2.6.6",
"@tiptap/extension-table-row": "2.6.6",
"@tiptap/extension-task-item": "2.6.6",
"@tiptap/extension-task-list": "2.6.6",
@@ -1535,6 +1532,39 @@
"@styled-system/css": "^5.1.5"
}
},
"node_modules/@theme-ui/color-modes": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@theme-ui/color-modes/-/color-modes-0.16.2.tgz",
"integrity": "sha512-jWEWx53lxNgWCT38i/kwLV2rsvJz8lVZgi5oImnVwYba9VejXD23q1ckbNFJHosQ8KKXY87ht0KPC6BQFIiHtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@theme-ui/core": "^0.16.2",
"@theme-ui/css": "^0.16.2",
"deepmerge": "^4.2.2"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"react": ">=18"
}
},
"node_modules/@theme-ui/color-modes/node_modules/@theme-ui/core": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@theme-ui/core/-/core-0.16.2.tgz",
"integrity": "sha512-bBd/ltbwO9vIUjF1jtlOX6XN0IIOdf1vzBp2JCKsSOqdfn84m+XL8OogIe/zOhQ+aM94Nrq4+32tFJc8sFav4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@theme-ui/css": "^0.16.2",
"deepmerge": "^4.2.2"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"react": ">=18"
}
},
"node_modules/@theme-ui/components": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@theme-ui/components/-/components-0.16.1.tgz",
@@ -1580,6 +1610,39 @@
"@emotion/react": "^11.11.1"
}
},
"node_modules/@theme-ui/theme-provider": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@theme-ui/theme-provider/-/theme-provider-0.16.2.tgz",
"integrity": "sha512-LRnVevODcGqO0JyLJ3wht+PV3ZoZcJ7XXLJAJWDoGeII4vZcPQKwVy4Lpz/juHsZppQxKcB3U+sQDGBnP25irQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@theme-ui/color-modes": "^0.16.2",
"@theme-ui/core": "^0.16.2",
"@theme-ui/css": "^0.16.2"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"react": ">=18"
}
},
"node_modules/@theme-ui/theme-provider/node_modules/@theme-ui/core": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@theme-ui/core/-/core-0.16.2.tgz",
"integrity": "sha512-bBd/ltbwO9vIUjF1jtlOX6XN0IIOdf1vzBp2JCKsSOqdfn84m+XL8OogIe/zOhQ+aM94Nrq4+32tFJc8sFav4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@theme-ui/css": "^0.16.2",
"deepmerge": "^4.2.2"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"react": ">=18"
}
},
"node_modules/@tiptap/core": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.6.6.tgz",
@@ -1778,43 +1841,6 @@
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-table": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.6.6.tgz",
"integrity": "sha512-Ay/IClmB9R8MjnLobGnA9tI0+7ev4GUwvNf/JA2razI8CeaMCJ7CcAzG6pnIp4d7I6ELWYmAt3vwxoRlsAZcEw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.6.6.tgz",
"integrity": "sha512-XakU9qnlYAf/ux4q7zgiJs2pvkjOl9mVzQw5j55aQHYLiw0gXomEgUbrkn7jhA7N6WP9PlngS3quwIDfyoqLvw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.6.6.tgz",
"integrity": "sha512-BX2cVTrOZzIQAAWrNjD2Dzk/RpCJWUqgdW2bh27x0nJwKfMWfqLPoplTTuCZ+J9yK7rlNj3jEhKewe/yR1Tudw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.6.6.tgz",
@@ -3490,8 +3516,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "3.14.1",
@@ -3611,7 +3636,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -5134,7 +5158,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -5155,7 +5178,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5470,7 +5492,6 @@
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0"
}

View File

@@ -52,9 +52,6 @@
"@tiptap/extension-placeholder": "2.6.6",
"@tiptap/extension-subscript": "2.6.6",
"@tiptap/extension-superscript": "2.6.6",
"@tiptap/extension-table": "2.6.6",
"@tiptap/extension-table-cell": "2.6.6",
"@tiptap/extension-table-header": "2.6.6",
"@tiptap/extension-table-row": "2.6.6",
"@tiptap/extension-task-item": "2.6.6",
"@tiptap/extension-task-list": "2.6.6",

View File

@@ -18,11 +18,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Extension } from "@tiptap/core";
import { isInTable } from "@tiptap/pm/tables";
import { CodeBlock } from "../code-block/index.js";
import { showLinkPopup } from "../../toolbar/popups/link-popup.js";
import { isListActive } from "../../utils/list.js";
import { tiptapKeys } from "@notesnook/common";
import { isInTable } from "../table/prosemirror-tables/util.js";
export const KeyMap = Extension.create({
name: "key-map",

View File

@@ -54,7 +54,7 @@ export type ReactNodeViewProps<TAttributes = Attrs> = {
};
export type ReactNodeViewOptions<P> = {
props?: P;
props?: Partial<P>;
component?: React.ComponentType<P>;
componentKey?: (node: PMNode) => string;
shouldUpdate?: ShouldUpdate;

View File

@@ -17,18 +17,115 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<string, any>;
}
/**
* This extension allows you to create table cells.
* @see https://www.tiptap.dev/api/nodes/table-cell
*/
export const TableCell = Node.create<TableCellOptions>({
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<string, Attribute> {
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")
};
}

View File

@@ -17,18 +17,50 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<string, any>;
}
/**
* This extension allows you to create table headers.
* @see https://www.tiptap.dev/api/nodes/table-header
*/
export const TableHeader = Node.create<TableHeaderOptions>({
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
];
}
});

View File

@@ -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))
);
}
}

View File

@@ -18,9 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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;

View File

@@ -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<HTMLTableColElement>(null);
const tableRef = useRef<HTMLTableElement>();
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 (
<div dir={textDirection}>
<table
ref={(ref) => {
forwardRef?.(ref);
tableRef.current = ref || undefined;
}}
>
<colgroup ref={colgroupRef} />
{/* <tbody /> */}
</table>
</div>
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}
/>
<SimpleBar autoHide style={{ overflowY: "hidden" }}>
<div dir={textDirection}>
<table
ref={(ref) => {
forwardRef?.(ref);
tableRef.current = ref || undefined;
}}
>
<colgroup ref={colgroupRef} />
{/* <tbody /> */}
</table>
</div>
</SimpleBar>
</DesktopOnly>
{isMobile ? (
<ScrollContainer>{renderScrollContent()}</ScrollContainer>
) : (
<SimpleBar>{renderScrollContent()}</SimpleBar>
)}
<MobileOnly>
<div dir={textDirection}>
<table
ref={(ref) => {
forwardRef?.(ref);
tableRef.current = ref || undefined;
}}
>
<colgroup ref={colgroupRef} />
{/* <tbody /> */}
</table>
</div>
</MobileOnly>
</>
);
}
export function TableNodeView(editor: TiptapEditor) {
class TableNode
extends ReactNodeView<ReactNodeViewProps<unknown>>
extends ReactNodeView<
ReactNodeViewProps<unknown> & { 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

View File

@@ -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<number, boolean> = {};
const rows = [];
for (let row = rect.top; row < rect.bottom; row++) {
const rowContent = [];
for (
let index = row * map.width + rect.left, col = rect.left;
col < rect.right;
col++, index++
) {
const pos = map.map[index];
if (seen[pos]) continue;
seen[pos] = true;
const cellRect = map.findCell(pos);
let cell = table.nodeAt(pos);
if (!cell) {
throw RangeError(`No cell with offset ${pos} found`);
}
const extraLeft = rect.left - cellRect.left;
const extraRight = cellRect.right - rect.right;
if (extraLeft > 0 || extraRight > 0) {
let attrs = cell.attrs as CellAttrs;
if (extraLeft > 0) {
attrs = removeColSpan(attrs, 0, extraLeft);
}
if (extraRight > 0) {
attrs = removeColSpan(
attrs,
attrs.colspan - extraRight,
extraRight
);
}
if (cellRect.left < rect.left) {
cell = cell.type.createAndFill(attrs);
if (!cell) {
throw RangeError(
`Could not create cell with attrs ${JSON.stringify(attrs)}`
);
}
} else {
cell = cell.type.create(attrs, cell.content);
}
}
if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) {
const attrs = {
...cell.attrs,
rowspan:
Math.min(cellRect.bottom, rect.bottom) -
Math.max(cellRect.top, rect.top)
};
if (cellRect.top < rect.top) {
cell = cell.type.createAndFill(attrs)!;
} else {
cell = cell.type.create(attrs, cell.content);
}
}
rowContent.push(cell);
}
rows.push(table.child(row).copy(Fragment.from(rowContent)));
}
const fragment =
this.isColSelection() && this.isRowSelection() ? table : rows;
return new Slice(Fragment.from(fragment), 1, 1);
}
public replace(tr: Transaction, content: Slice = Slice.empty): void {
const mapFrom = tr.steps.length,
ranges = this.ranges;
for (let i = 0; i < ranges.length; i++) {
const { $from, $to } = ranges[i],
mapping = tr.mapping.slice(mapFrom);
tr.replace(
mapping.map($from.pos),
mapping.map($to.pos),
i ? Slice.empty : content
);
}
const sel = Selection.findFrom(
tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)),
-1
);
if (sel) tr.setSelection(sel);
}
public replaceWith(tr: Transaction, node: Node): void {
this.replace(tr, new Slice(Fragment.from(node), 0, 0));
}
public forEachCell(f: (node: Node, pos: number) => void): void {
const table = this.$anchorCell.node(-1);
const map = TableMap.get(table);
const tableStart = this.$anchorCell.start(-1);
const cells = map.cellsInRect(
map.rectBetween(
this.$anchorCell.pos - tableStart,
this.$headCell.pos - tableStart
)
);
for (let i = 0; i < cells.length; i++) {
f(table.nodeAt(cells[i])!, tableStart + cells[i]);
}
}
// True if this selection goes all the way from the top to the
// bottom of the table.
public isColSelection(): boolean {
const anchorTop = this.$anchorCell.index(-1);
const headTop = this.$headCell.index(-1);
if (Math.min(anchorTop, headTop) > 0) return false;
const anchorBottom = anchorTop + this.$anchorCell.nodeAfter!.attrs.rowspan;
const headBottom = headTop + this.$headCell.nodeAfter!.attrs.rowspan;
return (
Math.max(anchorBottom, headBottom) == this.$headCell.node(-1).childCount
);
}
// Returns the smallest column selection that covers the given anchor
// and head cell.
public static colSelection(
$anchorCell: ResolvedPos,
$headCell: ResolvedPos = $anchorCell
): CellSelection {
const table = $anchorCell.node(-1);
const map = TableMap.get(table);
const tableStart = $anchorCell.start(-1);
const anchorRect = map.findCell($anchorCell.pos - tableStart);
const headRect = map.findCell($headCell.pos - tableStart);
const doc = $anchorCell.node(0);
if (anchorRect.top <= headRect.top) {
if (anchorRect.top > 0)
$anchorCell = doc.resolve(tableStart + map.map[anchorRect.left]);
if (headRect.bottom < map.height)
$headCell = doc.resolve(
tableStart +
map.map[map.width * (map.height - 1) + headRect.right - 1]
);
} else {
if (headRect.top > 0)
$headCell = doc.resolve(tableStart + map.map[headRect.left]);
if (anchorRect.bottom < map.height)
$anchorCell = doc.resolve(
tableStart +
map.map[map.width * (map.height - 1) + anchorRect.right - 1]
);
}
return new CellSelection($anchorCell, $headCell);
}
// True if this selection goes all the way from the left to the
// right of the table.
public isRowSelection(): boolean {
const table = this.$anchorCell.node(-1);
const map = TableMap.get(table);
const tableStart = this.$anchorCell.start(-1);
const anchorLeft = map.colCount(this.$anchorCell.pos - tableStart);
const headLeft = map.colCount(this.$headCell.pos - tableStart);
if (Math.min(anchorLeft, headLeft) > 0) return false;
const anchorRight = anchorLeft + this.$anchorCell.nodeAfter!.attrs.colspan;
const headRight = headLeft + this.$headCell.nodeAfter!.attrs.colspan;
return Math.max(anchorRight, headRight) == map.width;
}
public eq(other: unknown): boolean {
return (
other instanceof CellSelection &&
other.$anchorCell.pos == this.$anchorCell.pos &&
other.$headCell.pos == this.$headCell.pos
);
}
// Returns the smallest row selection that covers the given anchor
// and head cell.
public static rowSelection(
$anchorCell: ResolvedPos,
$headCell: ResolvedPos = $anchorCell
): CellSelection {
const table = $anchorCell.node(-1);
const map = TableMap.get(table);
const tableStart = $anchorCell.start(-1);
const anchorRect = map.findCell($anchorCell.pos - tableStart);
const headRect = map.findCell($headCell.pos - tableStart);
const doc = $anchorCell.node(0);
if (anchorRect.left <= headRect.left) {
if (anchorRect.left > 0)
$anchorCell = doc.resolve(
tableStart + map.map[anchorRect.top * map.width]
);
if (headRect.right < map.width)
$headCell = doc.resolve(
tableStart + map.map[map.width * (headRect.top + 1) - 1]
);
} else {
if (headRect.left > 0)
$headCell = doc.resolve(tableStart + map.map[headRect.top * map.width]);
if (anchorRect.right < map.width)
$anchorCell = doc.resolve(
tableStart + map.map[map.width * (anchorRect.top + 1) - 1]
);
}
return new CellSelection($anchorCell, $headCell);
}
public toJSON(): CellSelectionJSON {
return {
type: "cell",
anchor: this.$anchorCell.pos,
head: this.$headCell.pos
};
}
static fromJSON(doc: Node, json: CellSelectionJSON): CellSelection {
return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head));
}
static create(
doc: Node,
anchorCell: number,
headCell: number = anchorCell
): CellSelection {
return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell));
}
getBookmark(): CellBookmark {
return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos);
}
}
CellSelection.prototype.visible = false;
Selection.jsonID("cell", CellSelection);
/**
* @public
*/
export class CellBookmark {
constructor(public anchor: number, public head: number) {}
map(mapping: Mappable): CellBookmark {
return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head));
}
resolve(doc: Node): CellSelection | Selection {
const $anchorCell = doc.resolve(this.anchor),
$headCell = doc.resolve(this.head);
if (
$anchorCell.parent.type.spec.tableRole == "row" &&
$headCell.parent.type.spec.tableRole == "row" &&
$anchorCell.index() < $anchorCell.parent.childCount &&
$headCell.index() < $headCell.parent.childCount &&
inSameTable($anchorCell, $headCell)
)
return new CellSelection($anchorCell, $headCell);
else return Selection.near($headCell, 1);
}
}
export function drawCellSelection(state: EditorState): DecorationSource | null {
if (!(state.selection instanceof CellSelection)) return null;
const cells: Decoration[] = [];
state.selection.forEachCell((node, pos) => {
cells.push(
Decoration.node(pos, pos + node.nodeSize, { class: "selectedCell" })
);
});
return DecorationSet.create(state.doc, cells);
}
function isCellBoundarySelection({ $from, $to }: TextSelection) {
if ($from.pos == $to.pos || $from.pos < $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;
}

View File

@@ -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<ResizeState>(
"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<ResizeState>({
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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}

View File

@@ -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 });
}

View File

@@ -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
);
}
});
}

View File

@@ -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;
}

View File

@@ -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<string, AttributeSpec> = {
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<TableRole, NodeType> {
let result = schema.cached.tableNodeTypes;
if (!result) {
result = schema.cached.tableNodeTypes = {};
for (const name in schema.nodes) {
const type = schema.nodes[name],
role = type.spec.tableRole;
if (role) result[role] = type;
}
}
return result;
}

View File

@@ -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<Node, TableMap>();
readFromCache = (key) => cache.get(key);
addToCache = (key, value) => {
cache.set(key, value);
return value;
};
} else {
const cache: (Node | TableMap)[] = [];
const cacheSize = 10;
let cachePos = 0;
readFromCache = (key) => {
for (let i = 0; i < cache.length; i += 2)
if (cache[i] == key) return cache[i + 1] as TableMap;
};
addToCache = (key, value) => {
if (cachePos == cacheSize) cachePos = 0;
cache[cachePos++] = key;
return (cache[cachePos++] = value);
};
}
/**
* @public
*/
export interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
/**
* A table map describes the structure of a given table. To avoid
* recomputing them all the time, they are cached per table node. To
* be able to do that, positions saved in the map are relative to the
* start of the table, rather than the start of the document.
*
* @public
*/
export class TableMap {
constructor(
/**
* The number of columns
*/
public width: number,
/**
* The number of rows
*/
public height: number,
/**
* A width * height array with the start position of
* the cell covering that part of the table in each slot
*/
public map: number[],
/**
* An optional array of problems (cell overlap or non-rectangular
* shape) for the table, used by the table normalizer.
*/
public problems: Problem[] | null
) {}
// Find the dimensions of the cell at the given position.
findCell(pos: number): Rect {
for (let i = 0; i < this.map.length; i++) {
const curPos = this.map[i];
if (curPos != pos) continue;
const left = i % this.width;
const top = (i / this.width) | 0;
let right = left + 1;
let bottom = top + 1;
for (let j = 1; right < this.width && this.map[i + j] == curPos; j++) {
right++;
}
for (
let j = 1;
bottom < this.height && this.map[i + this.width * j] == curPos;
j++
) {
bottom++;
}
return { left, top, right, bottom };
}
throw new RangeError(`No cell with offset ${pos} found`);
}
// Find the left side of the cell at the given position.
colCount(pos: number): number {
for (let i = 0; i < this.map.length; i++) {
if (this.map[i] == pos) {
return i % this.width;
}
}
throw new RangeError(`No cell with offset ${pos} found`);
}
// Find the next cell in the given direction, starting from the cell
// at `pos`, if any.
nextCell(pos: number, axis: "horiz" | "vert", dir: number): null | number {
const { left, right, top, bottom } = this.findCell(pos);
if (axis == "horiz") {
if (dir < 0 ? left == 0 : right == this.width) return null;
return this.map[top * this.width + (dir < 0 ? left - 1 : right)];
} else {
if (dir < 0 ? top == 0 : bottom == this.height) return null;
return this.map[left + this.width * (dir < 0 ? top - 1 : bottom)];
}
}
// Get the rectangle spanning the two given cells.
rectBetween(a: number, b: number): Rect {
const {
left: leftA,
right: rightA,
top: topA,
bottom: bottomA
} = this.findCell(a);
const {
left: leftB,
right: rightB,
top: topB,
bottom: bottomB
} = this.findCell(b);
return {
left: Math.min(leftA, leftB),
top: Math.min(topA, topB),
right: Math.max(rightA, rightB),
bottom: Math.max(bottomA, bottomB)
};
}
// Return the position of all cells that have the top left corner in
// the given rectangle.
cellsInRect(rect: Rect): number[] {
const result: number[] = [];
const seen: Record<number, boolean> = {};
for (let row = rect.top; row < rect.bottom; row++) {
for (let col = rect.left; col < rect.right; col++) {
const index = row * this.width + col;
const pos = this.map[index];
if (seen[pos]) continue;
seen[pos] = true;
if (
(col == rect.left && col && this.map[index - 1] == pos) ||
(row == rect.top && row && this.map[index - this.width] == pos)
) {
continue;
}
result.push(pos);
}
}
return result;
}
// Return the position at which the cell at the given row and column
// starts, or would start, if a cell started there.
positionAt(row: number, col: number, table: Node): number {
for (let i = 0, rowStart = 0; ; i++) {
const rowEnd = rowStart + table.child(i).nodeSize;
if (i == row) {
let index = col + row * this.width;
const rowEndIndex = (row + 1) * this.width;
// Skip past cells from previous rows (via rowspan)
while (index < rowEndIndex && this.map[index] < rowStart) index++;
return index == rowEndIndex ? rowEnd - 1 : this.map[index];
}
rowStart = rowEnd;
}
}
// Find the table map for the given table node.
static get(table: Node): TableMap {
return readFromCache(table) || addToCache(table, computeMap(table));
}
}
// Compute a table map.
function computeMap(table: Node): TableMap {
if (table.type.spec.tableRole != "table")
throw new RangeError("Not a table node: " + table.type.name);
const width = findWidth(table),
height = table.childCount;
const map = [];
let mapPos = 0;
let problems: Problem[] | null = null;
const colWidths: ColWidths = [];
for (let i = 0, e = width * height; i < e; i++) map[i] = 0;
for (let row = 0, pos = 0; row < height; row++) {
const rowNode = table.child(row);
pos++;
for (let i = 0; ; i++) {
while (mapPos < map.length && map[mapPos] != 0) mapPos++;
if (i == rowNode.childCount) break;
const cellNode = rowNode.child(i);
const { colspan, rowspan, colwidth } = cellNode.attrs;
for (let h = 0; h < rowspan; h++) {
if (h + row >= height) {
(problems || (problems = [])).push({
type: "overlong_rowspan",
pos,
n: rowspan - h
});
break;
}
const start = mapPos + h * width;
for (let w = 0; w < colspan; w++) {
if (map[start + w] == 0) map[start + w] = pos;
else
(problems || (problems = [])).push({
type: "collision",
row,
pos,
n: colspan - w
});
const colW = colwidth && colwidth[w];
if (colW) {
const widthIndex = ((start + w) % width) * 2,
prev = colWidths[widthIndex];
if (
prev == null ||
(prev != colW && colWidths[widthIndex + 1] == 1)
) {
colWidths[widthIndex] = colW;
colWidths[widthIndex + 1] = 1;
} else if (prev == colW) {
colWidths[widthIndex + 1]++;
}
}
}
}
mapPos += colspan;
pos += cellNode.nodeSize;
}
const expectedPos = (row + 1) * width;
let missing = 0;
while (mapPos < expectedPos) if (map[mapPos++] == 0) missing++;
if (missing)
(problems || (problems = [])).push({ type: "missing", row, n: missing });
pos++;
}
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<number, boolean> = {};
for (let i = 0; i < map.map.length; i++) {
const pos = map.map[i];
if (seen[pos]) continue;
seen[pos] = true;
const node = table.nodeAt(pos);
if (!node) {
throw new RangeError(`No cell with offset ${pos} found`);
}
let updated = null;
const attrs = node.attrs as CellAttrs;
for (let j = 0; j < attrs.colspan; j++) {
const col = (i + j) % map.width;
const colWidth = colWidths[col * 2];
if (
colWidth != null &&
(!attrs.colwidth || attrs.colwidth[j] != colWidth)
)
(updated || (updated = freshColWidth(attrs)))[j] = colWidth;
}
if (updated)
map.problems.unshift({
type: "colwidth mismatch",
pos,
colwidth: updated
});
}
}
function freshColWidth(attrs: Attrs): ColWidths {
if (attrs.colwidth) return attrs.colwidth.slice();
const result: ColWidths = [];
for (let i = 0; i < attrs.colspan; i++) result.push(0);
return result;
}

View File

@@ -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";
}
}

View File

@@ -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<string, unknown>;
/**
* @public
*/
export interface CellAttrs {
colspan: number;
rowspan: number;
colwidth: number[] | null;
}
/**
* @public
*/
export const tableEditingKey = new PluginKey<number>("selectingCells");
/**
* @public
*/
export function cellAround($pos: ResolvedPos): ResolvedPos | null {
for (let d = $pos.depth - 1; d > 0; d--)
if ($pos.node(d).type.spec.tableRole == "row")
return $pos.node(0).resolve($pos.before(d + 1));
return null;
}
export function cellWrapping($pos: ResolvedPos): null | Node {
for (let d = $pos.depth; d > 0; d--) {
// Sometimes the cell can be in the same depth.
const role = $pos.node(d).type.spec.tableRole;
if (role === "cell" || role === "header_cell") return $pos.node(d);
}
return null;
}
/**
* @public
*/
export function isInTable(state: EditorState): boolean {
const $head = state.selection.$head;
for (let d = $head.depth; d > 0; d--)
if ($head.node(d).type.spec.tableRole == "row") return true;
return false;
}
/**
* @internal
*/
export function selectionCell(state: EditorState): ResolvedPos {
const sel = state.selection as CellSelection | NodeSelection;
if ("$anchorCell" in sel && sel.$anchorCell) {
return sel.$anchorCell.pos > sel.$headCell.pos
? sel.$anchorCell
: sel.$headCell;
} else if (
"node" in sel &&
sel.node &&
sel.node.type.spec.tableRole == "cell"
) {
return sel.$anchor;
}
const $cell = cellAround(sel.$head) || cellNear(sel.$head);
if ($cell) {
return $cell;
}
throw new RangeError(`No cell found around position ${sel.head}`);
}
/**
* @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;
}

View File

@@ -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;
}

View File

@@ -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 };
});
}

View File

@@ -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);
}

View File

@@ -0,0 +1,31 @@
/**
* Move a row in an array of rows.
*
* @internal
*/
export function moveRowInArrayOfRows<T>(
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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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<T>(array: T[][]): T[][] {
return array[0].map((_, i) => {
return array.map((column) => column[i]);
});
}

View File

@@ -17,12 +17,465 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<string, any>;
/**
* 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<ReturnType> {
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<Options, Storage> {
/**
* 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<NodeConfig<Options>>["tableRole"];
}) => string);
}
}
/**
* This extension allows you to create tables.
* @see https://www.tiptap.dev/api/nodes/table
*/
export const Table = Node.create<TableOptions>({
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<TableOptions>({
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable;
@@ -30,10 +483,10 @@ export const Table = TiptapTable.extend<TableOptions>({
...(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<TableOptions>({
allowTableNodeSelection: this.options.allowTableNodeSelection
})
];
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
};
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context)
)
};
}
});

View File

@@ -0,0 +1,13 @@
import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model";
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
defaultCellAttrs?: { colwidth?: number[] }
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(defaultCellAttrs, cellContent);
}
return cellType.createAndFill(defaultCellAttrs);
}

View File

@@ -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 }
}

View File

@@ -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<ProsemirrorNode>,
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);
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
import { CellSelection } from "../prosemirror-tables/cellselection.js";
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection;
}

View File

@@ -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,

View File

@@ -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) {
<ToolButton
icon={props.icon}
title={props.title}
variant={props.variant}
buttonRef={buttonRef}
toggled={isMenuOpen}
onClick={() => setIsMenuOpen(true)}
@@ -134,6 +133,7 @@ export function ColumnProperties(props: ToolProps) {
<ToolButton
icon={props.icon}
title={props.title}
variant={props.variant}
buttonRef={buttonRef}
toggled={isMenuOpen}
onClick={() => setIsMenuOpen(true)}
@@ -184,6 +184,7 @@ export function TableProperties(props: ToolProps) {
<ToolButton
icon={props.icon}
title={props.title}
variant={props.variant}
buttonRef={buttonRef}
toggled={isMenuOpen}
onClick={() => setIsMenuOpen(true)}

View File

@@ -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;