mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-05-18 05:05:36 +02:00
Merge pull request #8930 from streetwriters/feat/improve-tables-on-mobile
This commit is contained in:
113
packages/editor/package-lock.json
generated
113
packages/editor/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
49
packages/editor/src/extensions/table/TableView.ts
Normal file
49
packages/editor/src/extensions/table/TableView.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1006
packages/editor/src/extensions/table/prosemirror-tables/commands.ts
Normal file
1006
packages/editor/src/extensions/table/prosemirror-tables/commands.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
101
packages/editor/src/extensions/table/prosemirror-tables/index.ts
Normal file
101
packages/editor/src/extensions/table/prosemirror-tables/index.ts
Normal 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
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
315
packages/editor/src/extensions/table/prosemirror-tables/input.ts
Normal file
315
packages/editor/src/extensions/table/prosemirror-tables/input.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
218
packages/editor/src/extensions/table/prosemirror-tables/util.ts
Normal file
218
packages/editor/src/extensions/table/prosemirror-tables/util.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
13
packages/editor/src/extensions/table/utilities/createCell.ts
Normal file
13
packages/editor/src/extensions/table/utilities/createCell.ts
Normal 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);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CellSelection } from "../prosemirror-tables/cellselection.js";
|
||||
|
||||
export function isCellSelection(value: unknown): value is CellSelection {
|
||||
return value instanceof CellSelection;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user