diff --git a/packages/editor/package-lock.json b/packages/editor/package-lock.json index d4c4525d2..b865641ed 100644 --- a/packages/editor/package-lock.json +++ b/packages/editor/package-lock.json @@ -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" } diff --git a/packages/editor/package.json b/packages/editor/package.json index 95e2116ac..e0427e201 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -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", diff --git a/packages/editor/src/extensions/key-map/key-map.ts b/packages/editor/src/extensions/key-map/key-map.ts index 6b91db572..49627861a 100644 --- a/packages/editor/src/extensions/key-map/key-map.ts +++ b/packages/editor/src/extensions/key-map/key-map.ts @@ -18,11 +18,11 @@ along with this program. If not, see . */ 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", diff --git a/packages/editor/src/extensions/react/types.ts b/packages/editor/src/extensions/react/types.ts index afa9ec3dc..405fbe7d7 100644 --- a/packages/editor/src/extensions/react/types.ts +++ b/packages/editor/src/extensions/react/types.ts @@ -54,7 +54,7 @@ export type ReactNodeViewProps = { }; export type ReactNodeViewOptions

= { - props?: P; + props?: Partial

; component?: React.ComponentType

; componentKey?: (node: PMNode) => string; shouldUpdate?: ShouldUpdate; diff --git a/packages/editor/src/extensions/table-cell/table-cell.ts b/packages/editor/src/extensions/table-cell/table-cell.ts index e56a58df9..1f68093ba 100644 --- a/packages/editor/src/extensions/table-cell/table-cell.ts +++ b/packages/editor/src/extensions/table-cell/table-cell.ts @@ -17,18 +17,115 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import TiptapTableCell from "@tiptap/extension-table-cell"; import { addStyleAttribute } from "./utils.js"; +import { Attribute } from "@tiptap/core"; +import { mergeAttributes, Node } from "@tiptap/core"; -export const TableCell = TiptapTableCell.extend({ - addAttributes() { +export interface TableCellOptions { + /** + * The HTML attributes for a table cell node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; +} + +/** + * This extension allows you to create table cells. + * @see https://www.tiptap.dev/api/nodes/table-cell + */ +export const TableCell = Node.create({ + name: "tableCell", + + addOptions() { return { - ...this.parent?.(), - backgroundColor: addStyleAttribute("backgroundColor", "background-color"), - color: addStyleAttribute("color", "color"), - borderWidth: addStyleAttribute("borderWidth", "border-width", "px"), - borderStyle: addStyleAttribute("borderStyle", "border-style"), - borderColor: addStyleAttribute("borderColor", "border-color") + HTMLAttributes: {} }; + }, + + content: "block+", + + addAttributes() { + return addTableCellAttributes(); + }, + + tableRole: "cell", + + isolating: true, + + parseHTML() { + return [{ tag: "td" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "td", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0 + ]; } }); + +export function addTableCellAttributes(): Record { + return { + colwidth: { + default: null, + parseHTML(element) { + const widthAttr = + element.getAttribute("data-colwidth") || + element.getAttribute("colwidth"); + const widths = + widthAttr && /^\d+(,\d+)*$/.test(widthAttr) + ? widthAttr.split(",").map((s) => Number(s)) + : null; + const colspan = Number(element.getAttribute("colspan") || 1); + + // migrate to data-colwidth attribute + if (element.hasAttribute("colwidth")) { + element.setAttribute( + "data-colwidth", + element.getAttribute("colwidth")! + ); + element.removeAttribute("colwidth"); + } + + return widths && widths.length == colspan ? widths : null; + }, + renderHTML(attributes) { + if (!attributes.colwidth) { + return {}; + } + return { + "data-colwidth": attributes.colwidth.join(",") + }; + } + }, + colspan: { + default: 1, + parseHTML(element) { + return Number(element.getAttribute("colspan") || 1); + }, + renderHTML(attributes) { + return { + colspan: attributes.colspan || 1 + }; + } + }, + rowspan: { + default: 1, + parseHTML(element) { + return Number(element.getAttribute("rowspan") || 1); + }, + renderHTML(attributes) { + return { + rowspan: attributes.rowspan || 1 + }; + } + }, + backgroundColor: addStyleAttribute("backgroundColor", "background-color"), + color: addStyleAttribute("color", "color"), + borderWidth: addStyleAttribute("borderWidth", "border-width", "px"), + borderStyle: addStyleAttribute("borderStyle", "border-style"), + borderColor: addStyleAttribute("borderColor", "border-color") + }; +} diff --git a/packages/editor/src/extensions/table-header/table-header.ts b/packages/editor/src/extensions/table-header/table-header.ts index a2f899213..5187029e4 100644 --- a/packages/editor/src/extensions/table-header/table-header.ts +++ b/packages/editor/src/extensions/table-header/table-header.ts @@ -17,18 +17,50 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import TipTapTableHeader from "@tiptap/extension-table-header"; -import { addStyleAttribute } from "../table-cell/utils.js"; +import { mergeAttributes, Node } from "@tiptap/core"; +import { addTableCellAttributes } from "../table-cell/table-cell.js"; -export const TableHeader = TipTapTableHeader.extend({ - addAttributes() { +export interface TableHeaderOptions { + /** + * The HTML attributes for a table header node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; +} + +/** + * This extension allows you to create table headers. + * @see https://www.tiptap.dev/api/nodes/table-header + */ +export const TableHeader = Node.create({ + name: "tableHeader", + + addOptions() { return { - ...this.parent?.(), - backgroundColor: addStyleAttribute("backgroundColor", "background-color"), - color: addStyleAttribute("color", "color"), - borderWidth: addStyleAttribute("borderWidth", "border-width", "px"), - borderStyle: addStyleAttribute("borderStyle", "border-style"), - borderColor: addStyleAttribute("borderColor", "border-color") + HTMLAttributes: {} }; + }, + + content: "block+", + + addAttributes() { + return addTableCellAttributes(); + }, + + tableRole: "header_cell", + + isolating: true, + + parseHTML() { + return [{ tag: "th" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "th", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0 + ]; } }); diff --git a/packages/editor/src/extensions/table/TableView.ts b/packages/editor/src/extensions/table/TableView.ts new file mode 100644 index 000000000..70a45614a --- /dev/null +++ b/packages/editor/src/extensions/table/TableView.ts @@ -0,0 +1,49 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { NodeView } from "@tiptap/pm/view"; +import { updateColumnsOnResize } from "./prosemirror-tables/tableview.js"; + +export class TableView implements NodeView { + node: ProseMirrorNode; + + cellMinWidth: number; + + dom: HTMLElement; + + table: HTMLTableElement; + + colgroup: HTMLTableColElement; + + contentDOM: HTMLElement; + + constructor(node: ProseMirrorNode, cellMinWidth: number) { + this.node = node; + this.cellMinWidth = cellMinWidth; + this.dom = document.createElement("div"); + this.dom.className = "tableWrapper"; + this.table = this.dom.appendChild(document.createElement("table")); + this.colgroup = this.table.appendChild(document.createElement("colgroup")); + updateColumnsOnResize(node, this.colgroup, this.table, cellMinWidth); + this.contentDOM = this.table.appendChild(document.createElement("tbody")); + } + + update(node: ProseMirrorNode) { + if (node.type !== this.node.type) { + return false; + } + + this.node = node; + updateColumnsOnResize(node, this.colgroup, this.table, this.cellMinWidth); + + return true; + } + + ignoreMutation( + mutation: MutationRecord | { type: "selection"; target: Element } + ) { + return ( + mutation.type === "attributes" && + (mutation.target === this.table || + this.colgroup.contains(mutation.target)) + ); + } +} diff --git a/packages/editor/src/extensions/table/actions.ts b/packages/editor/src/extensions/table/actions.ts index 6dfc48d44..420b3ba1d 100644 --- a/packages/editor/src/extensions/table/actions.ts +++ b/packages/editor/src/extensions/table/actions.ts @@ -18,9 +18,9 @@ along with this program. If not, see . */ import { Editor } from "@tiptap/core"; -import { selectedRect, TableRect } from "@tiptap/pm/tables"; import { EditorState, TextSelection, Transaction } from "prosemirror-state"; import { Node } from "prosemirror-model"; +import { selectedRect, TableRect } from "./prosemirror-tables/commands.js"; type TableCell = { cell: Node; diff --git a/packages/editor/src/extensions/table/component.tsx b/packages/editor/src/extensions/table/component.tsx index 58df8987d..d9ee1f43a 100644 --- a/packages/editor/src/extensions/table/component.tsx +++ b/packages/editor/src/extensions/table/component.tsx @@ -23,7 +23,6 @@ import { Node as ProsemirrorNode } from "prosemirror-model"; import { Editor } from "../../types.js"; import { Editor as TiptapEditor } from "@tiptap/core"; import { useCallback, useEffect, useRef } from "react"; -import { updateColumnsOnResize } from "@tiptap/pm/tables"; import { EditorView, NodeView } from "prosemirror-view"; import { InsertColumnRight, @@ -37,14 +36,17 @@ import { findSelectedDOMNode, hasSameAttributes } from "../../utils/prosemirror.js"; -import { DesktopOnly } from "../../components/responsive/index.js"; +import { DesktopOnly, MobileOnly } from "../../components/responsive/index.js"; import { TextDirections } from "../text-direction/index.js"; import { strings } from "@notesnook/intl"; import SimpleBar from "simplebar-react"; import { useIsMobile } from "../../toolbar/stores/toolbar-store.js"; +import { updateColumnsOnResize } from "./prosemirror-tables/tableview.js"; -export function TableComponent(props: ReactNodeViewProps) { - const { editor, node, forwardRef } = props; +export function TableComponent( + props: ReactNodeViewProps & { cellMinWidth: number } +) { + const { editor, node, forwardRef, cellMinWidth } = props; const colgroupRef = useRef(null); const tableRef = useRef(); const { textDirection } = node.attrs; @@ -53,24 +55,37 @@ export function TableComponent(props: ReactNodeViewProps) { useEffect(() => { if (!colgroupRef.current || !tableRef.current) return; - updateColumnsOnResize(node, colgroupRef.current, tableRef.current, 50); - }, [node]); - - const renderScrollContent = useCallback(() => { - return ( -

- { - forwardRef?.(ref); - tableRef.current = ref || undefined; - }} - > - - {/* */} -
-
+ updateColumnsOnResize( + node, + colgroupRef.current, + tableRef.current, + cellMinWidth ); - }, [forwardRef, textDirection]); + }, [node, cellMinWidth]); + + // useEffect(() => { + // function transactionListener({ + // editor + // }: { + // transaction: Transaction; + // editor: TiptapEditor; + // }) { + // if (!colgroupRef.current || !tableRef.current) return; + + // const cellsInRow = tableRef.current?.rows[0]?.cells.length || 0; + // if (colgroupRef.current?.childElementCount !== cellsInRow) + // updateColumns( + // selectedRect(editor.state).table, + // colgroupRef.current, + // tableRef.current, + // cellMinWidth + // ); + // } + // editor.on("transaction", transactionListener); + // return () => { + // editor.off("transaction", transactionListener); + // }; + // }, []); return ( <> @@ -85,33 +100,59 @@ export function TableComponent(props: ReactNodeViewProps) { table={tableRef} textDirection={textDirection} /> + +
+ { + forwardRef?.(ref); + tableRef.current = ref || undefined; + }} + > + + {/* */} +
+
+
- - {isMobile ? ( - {renderScrollContent()} - ) : ( - {renderScrollContent()} - )} + +
+ { + forwardRef?.(ref); + tableRef.current = ref || undefined; + }} + > + + {/* */} +
+
+
); } export function TableNodeView(editor: TiptapEditor) { class TableNode - extends ReactNodeView> + extends ReactNodeView< + ReactNodeViewProps & { cellMinWidth: number } + > implements NodeView { - constructor(node: ProsemirrorNode) { + constructor(node: ProsemirrorNode, cellMinWidth: number) { super( node, editor, () => 0, // todo { component: TableComponent, + props: { cellMinWidth }, + forceEnableSelection: true, shouldUpdate: (prev, next) => { return ( !hasSameAttributes(prev.attrs, next.attrs) || - prev.childCount !== next.childCount + prev.childCount !== next.childCount || + // compare columns + prev.firstChild?.childCount !== next.firstChild?.childCount ); }, contentDOMFactory: () => { @@ -194,6 +235,7 @@ function TableRowToolbar(props: TableToolbarProps) { bg: "background", flexWrap: "nowrap", borderRadius: "default", + border: "1px solid var(--border)", flexDirection: "column", opacity: 0.4, ":hover": { @@ -272,6 +314,7 @@ function TableColumnToolbar(props: TableToolbarProps) { bg: "background", flexWrap: "nowrap", borderRadius: "default", + border: "1px solid var(--border)", opacity: 0.4, ":hover": { opacity: 1 diff --git a/packages/editor/src/extensions/table/prosemirror-tables/cellselection.ts b/packages/editor/src/extensions/table/prosemirror-tables/cellselection.ts new file mode 100644 index 000000000..a1402909a --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/cellselection.ts @@ -0,0 +1,466 @@ +// This file defines a ProseMirror selection subclass that models +// table cell selections. The table plugin needs to be active to wire +// in the user interaction part of table selections (so that you +// actually get such selections when you select across cells). + +import { Fragment, Node, ResolvedPos, Slice } from "prosemirror-model"; +import { + EditorState, + NodeSelection, + Selection, + SelectionRange, + TextSelection, + Transaction +} from "prosemirror-state"; +import { Decoration, DecorationSet, DecorationSource } from "prosemirror-view"; + +import { Mappable } from "prosemirror-transform"; +import { TableMap } from "./tablemap.js"; +import { CellAttrs, inSameTable, pointsAtCell, removeColSpan } from "./util.js"; +import { findParentNodeOfTypeClosestToPos } from "../../../utils/prosemirror.js"; + +/** + * @public + */ +export interface CellSelectionJSON { + type: string; + anchor: number; + head: number; +} + +/** + * A [`Selection`](http://prosemirror.net/docs/ref/#state.Selection) + * subclass that represents a cell selection spanning part of a table. + * With the plugin enabled, these will be created when the user + * selects across cells, and will be drawn by giving selected cells a + * `selectedCell` CSS class. + * + * @public + */ +export class CellSelection extends Selection { + // A resolved position pointing _in front of_ the anchor cell (the one + // that doesn't move when extending the selection). + public $anchorCell: ResolvedPos; + + // A resolved position pointing in front of the head cell (the one + // moves when extending the selection). + public $headCell: ResolvedPos; + + // A table selection is identified by its anchor and head cells. The + // positions given to this constructor should point _before_ two + // cells in the same table. They may be the same, to select a single + // cell. + constructor($anchorCell: ResolvedPos, $headCell: ResolvedPos = $anchorCell) { + const table = $anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = $anchorCell.start(-1); + const rect = map.rectBetween( + $anchorCell.pos - tableStart, + $headCell.pos - tableStart + ); + + const doc = $anchorCell.node(0); + const cells = map + .cellsInRect(rect) + .filter((p) => p != $headCell.pos - tableStart); + // Make the head cell the first range, so that it counts as the + // primary part of the selection + cells.unshift($headCell.pos - tableStart); + const ranges = cells.map((pos) => { + const cell = table.nodeAt(pos); + if (!cell) { + throw RangeError(`No cell with offset ${pos} found`); + } + const from = tableStart + pos + 1; + return new SelectionRange( + doc.resolve(from), + doc.resolve(from + cell.content.size) + ); + }); + super(ranges[0].$from, ranges[0].$to, ranges); + this.$anchorCell = $anchorCell; + this.$headCell = $headCell; + } + + public map(doc: Node, mapping: Mappable): CellSelection | Selection { + const $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos)); + const $headCell = doc.resolve(mapping.map(this.$headCell.pos)); + if ( + pointsAtCell($anchorCell) && + pointsAtCell($headCell) && + inSameTable($anchorCell, $headCell) + ) { + const tableChanged = this.$anchorCell.node(-1) != $anchorCell.node(-1); + if (tableChanged && this.isRowSelection()) + return CellSelection.rowSelection($anchorCell, $headCell); + else if (tableChanged && this.isColSelection()) + return CellSelection.colSelection($anchorCell, $headCell); + else return new CellSelection($anchorCell, $headCell); + } + return TextSelection.between($anchorCell, $headCell); + } + + // Returns a rectangular slice of table rows containing the selected + // cells. + public content(): Slice { + const table = this.$anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = this.$anchorCell.start(-1); + + const rect = map.rectBetween( + this.$anchorCell.pos - tableStart, + this.$headCell.pos - tableStart + ); + const seen: Record = {}; + const rows = []; + for (let row = rect.top; row < rect.bottom; row++) { + const rowContent = []; + for ( + let index = row * map.width + rect.left, col = rect.left; + col < rect.right; + col++, index++ + ) { + const pos = map.map[index]; + if (seen[pos]) continue; + seen[pos] = true; + + const cellRect = map.findCell(pos); + let cell = table.nodeAt(pos); + if (!cell) { + throw RangeError(`No cell with offset ${pos} found`); + } + + const extraLeft = rect.left - cellRect.left; + const extraRight = cellRect.right - rect.right; + + if (extraLeft > 0 || extraRight > 0) { + let attrs = cell.attrs as CellAttrs; + if (extraLeft > 0) { + attrs = removeColSpan(attrs, 0, extraLeft); + } + if (extraRight > 0) { + attrs = removeColSpan( + attrs, + attrs.colspan - extraRight, + extraRight + ); + } + if (cellRect.left < rect.left) { + cell = cell.type.createAndFill(attrs); + if (!cell) { + throw RangeError( + `Could not create cell with attrs ${JSON.stringify(attrs)}` + ); + } + } else { + cell = cell.type.create(attrs, cell.content); + } + } + if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) { + const attrs = { + ...cell.attrs, + rowspan: + Math.min(cellRect.bottom, rect.bottom) - + Math.max(cellRect.top, rect.top) + }; + if (cellRect.top < rect.top) { + cell = cell.type.createAndFill(attrs)!; + } else { + cell = cell.type.create(attrs, cell.content); + } + } + rowContent.push(cell); + } + rows.push(table.child(row).copy(Fragment.from(rowContent))); + } + + const fragment = + this.isColSelection() && this.isRowSelection() ? table : rows; + return new Slice(Fragment.from(fragment), 1, 1); + } + + public replace(tr: Transaction, content: Slice = Slice.empty): void { + const mapFrom = tr.steps.length, + ranges = this.ranges; + for (let i = 0; i < ranges.length; i++) { + const { $from, $to } = ranges[i], + mapping = tr.mapping.slice(mapFrom); + tr.replace( + mapping.map($from.pos), + mapping.map($to.pos), + i ? Slice.empty : content + ); + } + const sel = Selection.findFrom( + tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)), + -1 + ); + if (sel) tr.setSelection(sel); + } + + public replaceWith(tr: Transaction, node: Node): void { + this.replace(tr, new Slice(Fragment.from(node), 0, 0)); + } + + public forEachCell(f: (node: Node, pos: number) => void): void { + const table = this.$anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = this.$anchorCell.start(-1); + + const cells = map.cellsInRect( + map.rectBetween( + this.$anchorCell.pos - tableStart, + this.$headCell.pos - tableStart + ) + ); + for (let i = 0; i < cells.length; i++) { + f(table.nodeAt(cells[i])!, tableStart + cells[i]); + } + } + + // True if this selection goes all the way from the top to the + // bottom of the table. + public isColSelection(): boolean { + const anchorTop = this.$anchorCell.index(-1); + const headTop = this.$headCell.index(-1); + if (Math.min(anchorTop, headTop) > 0) return false; + + const anchorBottom = anchorTop + this.$anchorCell.nodeAfter!.attrs.rowspan; + const headBottom = headTop + this.$headCell.nodeAfter!.attrs.rowspan; + + return ( + Math.max(anchorBottom, headBottom) == this.$headCell.node(-1).childCount + ); + } + + // Returns the smallest column selection that covers the given anchor + // and head cell. + public static colSelection( + $anchorCell: ResolvedPos, + $headCell: ResolvedPos = $anchorCell + ): CellSelection { + const table = $anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = $anchorCell.start(-1); + + const anchorRect = map.findCell($anchorCell.pos - tableStart); + const headRect = map.findCell($headCell.pos - tableStart); + const doc = $anchorCell.node(0); + + if (anchorRect.top <= headRect.top) { + if (anchorRect.top > 0) + $anchorCell = doc.resolve(tableStart + map.map[anchorRect.left]); + if (headRect.bottom < map.height) + $headCell = doc.resolve( + tableStart + + map.map[map.width * (map.height - 1) + headRect.right - 1] + ); + } else { + if (headRect.top > 0) + $headCell = doc.resolve(tableStart + map.map[headRect.left]); + if (anchorRect.bottom < map.height) + $anchorCell = doc.resolve( + tableStart + + map.map[map.width * (map.height - 1) + anchorRect.right - 1] + ); + } + return new CellSelection($anchorCell, $headCell); + } + + // True if this selection goes all the way from the left to the + // right of the table. + public isRowSelection(): boolean { + const table = this.$anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = this.$anchorCell.start(-1); + + const anchorLeft = map.colCount(this.$anchorCell.pos - tableStart); + const headLeft = map.colCount(this.$headCell.pos - tableStart); + if (Math.min(anchorLeft, headLeft) > 0) return false; + + const anchorRight = anchorLeft + this.$anchorCell.nodeAfter!.attrs.colspan; + const headRight = headLeft + this.$headCell.nodeAfter!.attrs.colspan; + return Math.max(anchorRight, headRight) == map.width; + } + + public eq(other: unknown): boolean { + return ( + other instanceof CellSelection && + other.$anchorCell.pos == this.$anchorCell.pos && + other.$headCell.pos == this.$headCell.pos + ); + } + + // Returns the smallest row selection that covers the given anchor + // and head cell. + public static rowSelection( + $anchorCell: ResolvedPos, + $headCell: ResolvedPos = $anchorCell + ): CellSelection { + const table = $anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = $anchorCell.start(-1); + + const anchorRect = map.findCell($anchorCell.pos - tableStart); + const headRect = map.findCell($headCell.pos - tableStart); + const doc = $anchorCell.node(0); + + if (anchorRect.left <= headRect.left) { + if (anchorRect.left > 0) + $anchorCell = doc.resolve( + tableStart + map.map[anchorRect.top * map.width] + ); + if (headRect.right < map.width) + $headCell = doc.resolve( + tableStart + map.map[map.width * (headRect.top + 1) - 1] + ); + } else { + if (headRect.left > 0) + $headCell = doc.resolve(tableStart + map.map[headRect.top * map.width]); + if (anchorRect.right < map.width) + $anchorCell = doc.resolve( + tableStart + map.map[map.width * (anchorRect.top + 1) - 1] + ); + } + return new CellSelection($anchorCell, $headCell); + } + + public toJSON(): CellSelectionJSON { + return { + type: "cell", + anchor: this.$anchorCell.pos, + head: this.$headCell.pos + }; + } + + static fromJSON(doc: Node, json: CellSelectionJSON): CellSelection { + return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head)); + } + + static create( + doc: Node, + anchorCell: number, + headCell: number = anchorCell + ): CellSelection { + return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell)); + } + + getBookmark(): CellBookmark { + return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos); + } +} + +CellSelection.prototype.visible = false; + +Selection.jsonID("cell", CellSelection); + +/** + * @public + */ +export class CellBookmark { + constructor(public anchor: number, public head: number) {} + + map(mapping: Mappable): CellBookmark { + return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head)); + } + + resolve(doc: Node): CellSelection | Selection { + const $anchorCell = doc.resolve(this.anchor), + $headCell = doc.resolve(this.head); + if ( + $anchorCell.parent.type.spec.tableRole == "row" && + $headCell.parent.type.spec.tableRole == "row" && + $anchorCell.index() < $anchorCell.parent.childCount && + $headCell.index() < $headCell.parent.childCount && + inSameTable($anchorCell, $headCell) + ) + return new CellSelection($anchorCell, $headCell); + else return Selection.near($headCell, 1); + } +} + +export function drawCellSelection(state: EditorState): DecorationSource | null { + if (!(state.selection instanceof CellSelection)) return null; + const cells: Decoration[] = []; + state.selection.forEachCell((node, pos) => { + cells.push( + Decoration.node(pos, pos + node.nodeSize, { class: "selectedCell" }) + ); + }); + return DecorationSet.create(state.doc, cells); +} + +function isCellBoundarySelection({ $from, $to }: TextSelection) { + if ($from.pos == $to.pos || $from.pos < $to.pos - 6) return false; // Cheap elimination + let afterFrom = $from.pos; + let beforeTo = $to.pos; + let depth = $from.depth; + for (; depth >= 0; depth--, afterFrom++) + if ($from.after(depth + 1) < $from.end(depth)) break; + for (let d = $to.depth; d >= 0; d--, beforeTo--) + if ($to.before(d + 1) > $to.start(d)) break; + return ( + afterFrom == beforeTo && + /row|table/.test($from.node(depth).type.spec.tableRole) + ); +} + +function isTextSelectionAcrossCells({ $from, $to }: TextSelection) { + let fromCellBoundaryNode: Node | undefined; + let toCellBoundaryNode: Node | undefined; + + for (let i = $from.depth; i > 0; i--) { + const node = $from.node(i); + if ( + node.type.spec.tableRole === "cell" || + node.type.spec.tableRole === "header_cell" + ) { + fromCellBoundaryNode = node; + break; + } + } + + for (let i = $to.depth; i > 0; i--) { + const node = $to.node(i); + if ( + node.type.spec.tableRole === "cell" || + node.type.spec.tableRole === "header_cell" + ) { + toCellBoundaryNode = node; + break; + } + } + + return fromCellBoundaryNode !== toCellBoundaryNode && $to.parentOffset === 0; +} + +export function normalizeSelection( + state: EditorState, + tr: Transaction | undefined, + oldState: EditorState, + allowTableNodeSelection: boolean +): Transaction | undefined { + const sel = (tr || state).selection; + const doc = (tr || state).doc; + let normalize: Selection | undefined; + let role: string | undefined; + if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) { + if (role == "cell" || role == "header_cell") { + normalize = CellSelection.create(doc, sel.from); + } else if (role == "row") { + const $cell = doc.resolve(sel.from + 1); + normalize = CellSelection.rowSelection($cell, $cell); + } else if (!allowTableNodeSelection) { + const map = TableMap.get(sel.node); + const start = sel.from + 1; + const lastCell = start + map.map[map.width * map.height - 1]; + normalize = CellSelection.create(doc, start + 1, lastCell); + } + } else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) { + normalize = TextSelection.create(doc, sel.from); + } else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) { + normalize = TextSelection.create(doc, sel.$from.start(), sel.$from.end()); + } + if (normalize) (tr || (tr = state.tr)).setSelection(normalize); + return tr; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/columnresizing.ts b/packages/editor/src/extensions/table/prosemirror-tables/columnresizing.ts new file mode 100644 index 000000000..f51120747 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/columnresizing.ts @@ -0,0 +1,417 @@ +import { Attrs, Node as ProsemirrorNode } from "prosemirror-model"; +import { EditorState, Plugin, PluginKey, Transaction } from "prosemirror-state"; +import { + Decoration, + DecorationSet, + EditorView, + NodeView +} from "prosemirror-view"; +import { tableNodeTypes } from "./schema.js"; +import { TableMap } from "./tablemap.js"; +import { TableView, updateColumnsOnResize } from "./tableview.js"; +import { cellAround, CellAttrs, getClientX } from "./util.js"; + +/** + * @public + */ +export const columnResizingPluginKey = new PluginKey( + "tableColumnResizing" +); + +/** + * @public + */ +export type ColumnResizingOptions = { + /** + * Minimum width of a cell /column. The column cannot be resized smaller than this. + */ + cellMinWidth?: number; + /** + * The default minWidth of a cell / column when it doesn't have an explicit width (i.e.: it has not been resized manually) + */ + defaultCellMinWidth?: number; + /** + * A custom node view for the rendering table nodes. By default, the plugin + * uses the {@link TableView} class. You can explicitly set this to `null` to + * not use a custom node view. + */ + View?: + | (new ( + node: ProsemirrorNode, + cellMinWidth: number, + view: EditorView + ) => NodeView) + | null; + showResizeHandleOnSelection?: boolean; +}; + +/** + * @public + */ +export type Dragging = { startX: number; startWidth: number }; + +/** + * @public + */ +export function columnResizing({ + cellMinWidth = 25, + defaultCellMinWidth = 100, + View = TableView, + showResizeHandleOnSelection = false +}: ColumnResizingOptions = {}): Plugin { + const plugin = new Plugin({ + key: columnResizingPluginKey, + state: { + init(_, state) { + const nodeViews = plugin.spec?.props?.nodeViews; + const tableName = tableNodeTypes(state.schema).table.name; + if (View && nodeViews) { + nodeViews[tableName] = (node, view) => { + return new View(node, defaultCellMinWidth, view); + }; + } + + return { dragging: false, decorations: DecorationSet.empty }; + }, + apply(tr, prev, _, state) { + return createResizeState(tr, state, prev, showResizeHandleOnSelection); + } + }, + props: { + handleDOMEvents: { + touchstart: (view, event) => { + handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth); + }, + mousedown: (view, event) => { + handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth); + } + }, + + decorations: (state) => { + const pluginState = columnResizingPluginKey.getState(state); + return pluginState?.decorations || DecorationSet.empty; + }, + + nodeViews: {} + } + }); + return plugin; +} + +type ResizeState = { + dragging: Dragging | false; + decorations: DecorationSet; +}; + +function createResizeState( + tr: Transaction, + state: EditorState, + prevState: ResizeState, + showResizeHandleOnSelection: boolean +): ResizeState { + const action = tr.getMeta(columnResizingPluginKey); + const copy: ResizeState = { ...prevState }; + copy.decorations = tr.docChanged + ? copy.decorations.map(tr.mapping, tr.doc) + : copy.decorations; + + if (!copy.dragging) { + const cell = edgeCell(state, state.selection.from, "right"); + if (cell === -1) { + copy.decorations = DecorationSet.empty; + } else { + const handles = createColumnResizeHandles( + state, + cell, + prevState, + showResizeHandleOnSelection + ); + if (handles) { + copy.decorations = handles; + } + } + } + + if (action && action.setDragging !== undefined) + copy.dragging = action.setDragging; + return copy; +} + +function handleMouseDown( + view: EditorView, + event: MouseEvent | TouchEvent, + cellMinWidth: number, + defaultCellMinWidth: number +): boolean { + if (!view.editable) return false; + + const win = view.dom.ownerDocument.defaultView ?? window; + + const pluginState = columnResizingPluginKey.getState(view.state); + if (!pluginState || pluginState.dragging) return false; + + if ( + event.target instanceof HTMLElement && + event.target.closest(".column-resize-handle") == null + ) { + console.log("No handle target"); + return false; + } + + const clientX = getClientX(event); + if (clientX === null) return false; + + const activeHandle = edgeCell( + view.state, + view.posAtDOM(event.target as Node, 0), + "right" + ); + const cell = view.state.doc.nodeAt(activeHandle); + if (!cell) { + console.log("No cell at handle"); + return false; + } + + const width = currentColWidth(view, activeHandle, cell.attrs); + view.dispatch( + view.state.tr.setMeta(columnResizingPluginKey, { + setDragging: { startX: clientX, startWidth: width } + }) + ); + + function finish(event: MouseEvent | TouchEvent) { + win.removeEventListener("mouseup", finish); + win.removeEventListener("mousemove", move); + win.removeEventListener("touchend", finish); + win.removeEventListener("touchcancel", finish); + win.removeEventListener("touchmove", move); + + const clientX = getClientX(event); + if (clientX === null) { + console.log("No clientX on finish", event); + return; + } + + const pluginState = columnResizingPluginKey.getState(view.state); + if (pluginState?.dragging) { + if (event instanceof TouchEvent) + (view as any).domObserver.connectSelection(); + updateColumnWidth( + view, + activeHandle, + draggedWidth(pluginState.dragging, clientX, cellMinWidth) + ); + view.dispatch( + view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }) + ); + } + } + + function move(event: MouseEvent | TouchEvent): void { + if (event instanceof MouseEvent && !event.which) return finish(event); + const clientX = getClientX(event); + if (clientX === null) return; + const pluginState = columnResizingPluginKey.getState(view.state); + if (pluginState?.dragging) { + const dragged = draggedWidth(pluginState.dragging, clientX, cellMinWidth); + displayColumnWidth(view, activeHandle, dragged, defaultCellMinWidth); + } + } + + displayColumnWidth(view, activeHandle, width, defaultCellMinWidth); + + win.addEventListener("mouseup", finish); + win.addEventListener("mousemove", move); + win.addEventListener("touchend", finish); + win.addEventListener("touchcancel", finish); + win.addEventListener("touchmove", move); + event.preventDefault(); + if (event instanceof TouchEvent) + (view as any).domObserver.disconnectSelection(); + return true; +} + +function currentColWidth( + view: EditorView, + cellPos: number, + { colspan, colwidth }: Attrs +): number { + const width = colwidth && colwidth[colwidth.length - 1]; + if (width) return width; + const dom = view.domAtPos(cellPos); + const node = dom.node.childNodes[dom.offset] as HTMLElement; + let domWidth = node.offsetWidth, + parts = colspan; + if (colwidth) + for (let i = 0; i < colspan; i++) + if (colwidth[i]) { + domWidth -= colwidth[i]; + parts--; + } + return domWidth / parts; +} + +function edgeCell( + state: EditorState, + pos: number, + side: "left" | "right" +): number { + const $cell = cellAround(state.doc.resolve(pos)); + if (!$cell) return -1; + if (side == "right") return $cell.pos; + const map = TableMap.get($cell.node(-1)), + start = $cell.start(-1); + const index = map.map.indexOf($cell.pos - start); + return index % map.width == 0 ? -1 : start + map.map[index - 1]; +} + +function draggedWidth( + dragging: Dragging, + clientX: number, + resizeMinWidth: number +): number { + const offset = clientX - dragging.startX; + return Math.max(resizeMinWidth, dragging.startWidth + offset); +} + +function updateColumnWidth( + view: EditorView, + cell: number, + width: number +): void { + const $cell = view.state.doc.resolve(cell); + const table = $cell.node(-1), + map = TableMap.get(table), + start = $cell.start(-1); + const col = + map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan - 1; + const tr = view.state.tr; + for (let row = 0; row < map.height; row++) { + const mapIndex = row * map.width + col; + // Rowspanning cell that has already been handled + if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue; + const pos = map.map[mapIndex]; + const attrs = table.nodeAt(pos)!.attrs as CellAttrs; + const index = attrs.colspan == 1 ? 0 : col - map.colCount(pos); + if (attrs.colwidth && attrs.colwidth[index] == width) continue; + const colwidth = attrs.colwidth + ? attrs.colwidth.slice() + : zeroes(attrs.colspan); + colwidth[index] = width; + tr.setNodeAttribute(start + pos, "colwidth", colwidth); + } + if (tr.docChanged) view.dispatch(tr); +} + +function displayColumnWidth( + view: EditorView, + cell: number, + width: number, + defaultCellMinWidth: number +): void { + const $cell = view.state.doc.resolve(cell); + const table = $cell.node(-1), + start = $cell.start(-1); + const col = + TableMap.get(table).colCount($cell.pos - start) + + $cell.nodeAfter!.attrs.colspan - + 1; + let dom: Node | null = view.domAtPos($cell.start(-1)).node; + while (dom && dom.nodeName != "TABLE") { + dom = dom.parentNode; + } + if (!dom) return; + const tableElement = dom as HTMLTableElement; + updateColumnsOnResize( + table, + dom.firstChild as HTMLTableColElement, + tableElement, + defaultCellMinWidth, + col, + width + ); +} + +function zeroes(n: number): 0[] { + return Array(n).fill(0); +} + +export function createColumnResizeHandles( + state: EditorState, + activeCellPos: number, + resizeState: ResizeState, + showResizeHandleOnSelection: boolean +): DecorationSet | null { + if (activeCellPos === -1) return null; + const decorations = []; + const activeCell = state.doc.resolve(activeCellPos); + const table = activeCell.node(-1); + if (!table) return null; + + const map = TableMap.get(table); + const start = activeCell.start(-1); + const totalCells = map.height * map.width; + const cellIndex = map.map.indexOf(activeCell.pos - start); + + const oldDecorations = resizeState.decorations.find(); + if (oldDecorations.length === totalCells) { + const activeCellDecoration = oldDecorations.find( + (c) => c.spec.index === cellIndex + ); + if (!activeCellDecoration?.spec.active && showResizeHandleOnSelection) { + const oldActiveIndex = oldDecorations.findIndex((d) => d.spec.active); + const oldActive = oldDecorations[oldActiveIndex]; + if (oldActive) + oldDecorations[oldActiveIndex] = Decoration.widget( + oldActive.from, + createResizeHandle(false), + { + active: false, + index: oldActive.spec.index + } + ); + + oldDecorations[cellIndex] = Decoration.widget( + oldDecorations[cellIndex].from, + createResizeHandle(true), + { + active: true, + index: cellIndex + } + ); + + return DecorationSet.create(state.doc, oldDecorations); + } + + return null; + } + + for (let i = 0; i < totalCells; i++) { + const cellPos = map.map[i]; + const pos = start + cellPos + table.nodeAt(cellPos)!.nodeSize - 1; + decorations.push( + Decoration.widget( + pos, + createResizeHandle(showResizeHandleOnSelection && cellIndex === i), + { + active: showResizeHandleOnSelection && cellIndex === i, + index: i + } + ) + ); + } + return DecorationSet.create(state.doc, decorations); +} + +function createResizeHandle(active: boolean) { + const dom = document.createElement("div"); + dom.className = "column-resize-handle"; + if (active) dom.classList.add("active"); + dom.onmouseenter = () => { + dom.classList.add("active"); + }; + dom.onmouseleave = () => { + dom.classList.remove("active"); + }; + return dom; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/commands.ts b/packages/editor/src/extensions/table/prosemirror-tables/commands.ts new file mode 100644 index 000000000..b5feaf45b --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/commands.ts @@ -0,0 +1,1006 @@ +// This file defines a number of table-related commands. + +import { + Fragment, + Node, + NodeType, + ResolvedPos, + Slice +} from "prosemirror-model"; +import { + Command, + EditorState, + TextSelection, + Transaction +} from "prosemirror-state"; + +import { CellSelection } from "./cellselection.js"; +import type { Direction } from "./input.js"; +import { tableNodeTypes, TableRole } from "./schema.js"; +import { Rect, TableMap } from "./tablemap.js"; +import { + addColSpan, + cellAround, + CellAttrs, + cellWrapping, + columnIsHeader, + isInTable, + moveCellForward, + removeColSpan, + selectionCell +} from "./util.js"; +import { moveColumn } from "./utils/move-column.js"; +import { moveRow } from "./utils/move-row.js"; + +/** + * @public + */ +export type TableRect = Rect & { + tableStart: number; + map: TableMap; + table: Node; +}; + +/** + * Helper to get the selected rectangle in a table, if any. Adds table + * map, table node, and table start offset to the object for + * convenience. + * + * @public + */ +export function selectedRect(state: EditorState): TableRect { + const sel = state.selection; + const $pos = selectionCell(state); + const table = $pos.node(-1); + const tableStart = $pos.start(-1); + const map = TableMap.get(table); + const rect = + sel instanceof CellSelection + ? map.rectBetween( + sel.$anchorCell.pos - tableStart, + sel.$headCell.pos - tableStart + ) + : map.findCell($pos.pos - tableStart); + return { ...rect, tableStart, map, table }; +} + +/** + * Add a column at the given position in a table. + * + * @public + */ +export function addColumn( + tr: Transaction, + { map, tableStart, table }: TableRect, + col: number, + defaultCellAttrs?: { colwidth?: number[] } +): Transaction { + let refColumn: number | null = col > 0 ? -1 : 0; + if (columnIsHeader(map, table, col + refColumn)) { + refColumn = col == 0 || col == map.width ? null : 0; + } + + for (let row = 0; row < map.height; row++) { + const index = row * map.width + col; + // If this position falls inside a col-spanning cell + if (col > 0 && col < map.width && map.map[index - 1] == map.map[index]) { + const pos = map.map[index]; + const cell = table.nodeAt(pos)!; + tr.setNodeMarkup( + tr.mapping.map(tableStart + pos), + null, + addColSpan(cell.attrs as CellAttrs, col - map.colCount(pos)) + ); + // Skip ahead if rowspan > 1 + row += cell.attrs.rowspan - 1; + } else { + const type = + refColumn == null + ? tableNodeTypes(table.type.schema).cell + : table.nodeAt(map.map[index + refColumn])!.type; + const pos = map.positionAt(row, col, table); + tr.insert( + tr.mapping.map(tableStart + pos), + type.createAndFill(defaultCellAttrs)! + ); + } + } + return tr; +} + +/** + * Command to add a column before the column with the selection. + * + * @public + */ +export function addColumnBefore( + state: EditorState, + dispatch?: (tr: Transaction) => void, + defaultCellAttrs?: { colwidth?: number[] } +): boolean { + if (!isInTable(state)) return false; + if (dispatch) { + const rect = selectedRect(state); + dispatch(addColumn(state.tr, rect, rect.left, defaultCellAttrs)); + } + return true; +} + +/** + * Command to add a column after the column with the selection. + * + * @public + */ +export function addColumnAfter( + state: EditorState, + dispatch?: (tr: Transaction) => void, + defaultCellAttrs?: { colwidth?: number[] } +): boolean { + if (!isInTable(state)) return false; + if (dispatch) { + const rect = selectedRect(state); + dispatch(addColumn(state.tr, rect, rect.right, defaultCellAttrs)); + } + return true; +} + +/** + * @public + */ +export function removeColumn( + tr: Transaction, + { map, table, tableStart }: TableRect, + col: number +) { + const mapStart = tr.mapping.maps.length; + for (let row = 0; row < map.height; ) { + const index = row * map.width + col; + const pos = map.map[index]; + const cell = table.nodeAt(pos)!; + const attrs = cell.attrs as CellAttrs; + // If this is part of a col-spanning cell + if ( + (col > 0 && map.map[index - 1] == pos) || + (col < map.width - 1 && map.map[index + 1] == pos) + ) { + tr.setNodeMarkup( + tr.mapping.slice(mapStart).map(tableStart + pos), + null, + removeColSpan(attrs, col - map.colCount(pos)) + ); + } else { + const start = tr.mapping.slice(mapStart).map(tableStart + pos); + tr.delete(start, start + cell.nodeSize); + } + row += attrs.rowspan; + } +} + +/** + * Command function that removes the selected columns from a table. + * + * @public + */ +export function deleteColumn( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + if (!isInTable(state)) return false; + if (dispatch) { + const rect = selectedRect(state); + const tr = state.tr; + if (rect.left == 0 && rect.right == rect.map.width) return false; + for (let i = rect.right - 1; ; i--) { + removeColumn(tr, rect, i); + if (i == rect.left) break; + const table = rect.tableStart + ? tr.doc.nodeAt(rect.tableStart - 1) + : tr.doc; + if (!table) { + throw RangeError("No table found"); + } + rect.table = table; + rect.map = TableMap.get(table); + } + dispatch(tr); + } + return true; +} + +/** + * @public + */ +export function rowIsHeader(map: TableMap, table: Node, row: number): boolean { + const headerCell = tableNodeTypes(table.type.schema).header_cell; + for (let col = 0; col < map.width; col++) + if (table.nodeAt(map.map[col + row * map.width])?.type != headerCell) + return false; + return true; +} + +/** + * @public + */ +export function addRow( + tr: Transaction, + { map, tableStart, table }: TableRect, + row: number, + defaultCellAttrs?: { colwidth?: number[] } +): Transaction { + let rowPos = tableStart; + for (let i = 0; i < row; i++) rowPos += table.child(i).nodeSize; + const cells = []; + let refRow: number | null = row > 0 ? -1 : 0; + if (rowIsHeader(map, table, row + refRow)) + refRow = row == 0 || row == map.height ? null : 0; + for (let col = 0, index = map.width * row; col < map.width; col++, index++) { + // Covered by a rowspan cell + if ( + row > 0 && + row < map.height && + map.map[index] == map.map[index - map.width] + ) { + const pos = map.map[index]; + const attrs = table.nodeAt(pos)!.attrs; + tr.setNodeMarkup(tableStart + pos, null, { + ...attrs, + rowspan: attrs.rowspan + 1 + }); + col += attrs.colspan - 1; + } else { + const type = + refRow == null + ? tableNodeTypes(table.type.schema).cell + : table.nodeAt(map.map[index + refRow * map.width])?.type; + const node = type?.createAndFill(defaultCellAttrs); + if (node) cells.push(node); + } + } + tr.insert(rowPos, tableNodeTypes(table.type.schema).row.create(null, cells)); + return tr; +} + +/** + * Add a table row before the selection. + * + * @public + */ +export function addRowBefore( + state: EditorState, + dispatch?: (tr: Transaction) => void, + defaultCellAttrs?: { colwidth?: number[] } +): boolean { + if (!isInTable(state)) return false; + if (dispatch) { + const rect = selectedRect(state); + dispatch(addRow(state.tr, rect, rect.top, defaultCellAttrs)); + } + return true; +} + +/** + * Add a table row after the selection. + * + * @public + */ +export function addRowAfter( + state: EditorState, + dispatch?: (tr: Transaction) => void, + defaultCellAttrs?: { colwidth?: number[] } +): boolean { + if (!isInTable(state)) return false; + if (dispatch) { + const rect = selectedRect(state); + dispatch(addRow(state.tr, rect, rect.bottom, defaultCellAttrs)); + } + return true; +} + +/** + * @public + */ +export function removeRow( + tr: Transaction, + { map, table, tableStart }: TableRect, + row: number +): void { + let rowPos = 0; + for (let i = 0; i < row; i++) rowPos += table.child(i).nodeSize; + const nextRow = rowPos + table.child(row).nodeSize; + + const mapFrom = tr.mapping.maps.length; + tr.delete(rowPos + tableStart, nextRow + tableStart); + + const seen = new Set(); + + for (let col = 0, index = row * map.width; col < map.width; col++, index++) { + const pos = map.map[index]; + + // Skip cells that are checked already + if (seen.has(pos)) continue; + seen.add(pos); + + if (row > 0 && pos == map.map[index - map.width]) { + // If this cell starts in the row above, simply reduce its rowspan + const attrs = table.nodeAt(pos)!.attrs as CellAttrs; + tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + tableStart), null, { + ...attrs, + rowspan: attrs.rowspan - 1 + }); + col += attrs.colspan - 1; + } else if (row < map.height && pos == map.map[index + map.width]) { + // Else, if it continues in the row below, it has to be moved down + const cell = table.nodeAt(pos)!; + const attrs = cell.attrs as CellAttrs; + const copy = cell.type.create( + { ...attrs, rowspan: cell.attrs.rowspan - 1 }, + cell.content + ); + const newPos = map.positionAt(row + 1, col, table); + tr.insert(tr.mapping.slice(mapFrom).map(tableStart + newPos), copy); + col += attrs.colspan - 1; + } + } +} + +/** + * Remove the selected rows from a table. + * + * @public + */ +export function deleteRow( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + if (!isInTable(state)) return false; + if (dispatch) { + const rect = selectedRect(state), + tr = state.tr; + if (rect.top == 0 && rect.bottom == rect.map.height) return false; + for (let i = rect.bottom - 1; ; i--) { + removeRow(tr, rect, i); + if (i == rect.top) break; + const table = rect.tableStart + ? tr.doc.nodeAt(rect.tableStart - 1) + : tr.doc; + if (!table) { + throw RangeError("No table found"); + } + rect.table = table; + rect.map = TableMap.get(rect.table); + } + dispatch(tr); + } + return true; +} + +function isEmpty(cell: Node): boolean { + const c = cell.content; + + return ( + c.childCount == 1 && c.child(0).isTextblock && c.child(0).childCount == 0 + ); +} + +function cellsOverlapRectangle({ width, height, map }: TableMap, rect: Rect) { + let indexTop = rect.top * width + rect.left, + indexLeft = indexTop; + let indexBottom = (rect.bottom - 1) * width + rect.left, + indexRight = indexTop + (rect.right - rect.left - 1); + for (let i = rect.top; i < rect.bottom; i++) { + if ( + (rect.left > 0 && map[indexLeft] == map[indexLeft - 1]) || + (rect.right < width && map[indexRight] == map[indexRight + 1]) + ) + return true; + indexLeft += width; + indexRight += width; + } + for (let i = rect.left; i < rect.right; i++) { + if ( + (rect.top > 0 && map[indexTop] == map[indexTop - width]) || + (rect.bottom < height && map[indexBottom] == map[indexBottom + width]) + ) + return true; + indexTop++; + indexBottom++; + } + return false; +} + +/** + * Merge the selected cells into a single cell. Only available when + * the selected cells' outline forms a rectangle. + * + * @public + */ +export function mergeCells( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const sel = state.selection; + if ( + !(sel instanceof CellSelection) || + sel.$anchorCell.pos == sel.$headCell.pos + ) + return false; + const rect = selectedRect(state), + { map } = rect; + if (cellsOverlapRectangle(map, rect)) return false; + if (dispatch) { + const tr = state.tr; + const seen: Record = {}; + let content = Fragment.empty; + let mergedPos: number | undefined; + let mergedCell: Node | undefined; + for (let row = rect.top; row < rect.bottom; row++) { + for (let col = rect.left; col < rect.right; col++) { + const cellPos = map.map[row * map.width + col]; + const cell = rect.table.nodeAt(cellPos); + if (seen[cellPos] || !cell) continue; + seen[cellPos] = true; + if (mergedPos == null) { + mergedPos = cellPos; + mergedCell = cell; + } else { + if (!isEmpty(cell)) content = content.append(cell.content); + const mapped = tr.mapping.map(cellPos + rect.tableStart); + tr.delete(mapped, mapped + cell.nodeSize); + } + } + } + if (mergedPos == null || mergedCell == null) { + return true; + } + + tr.setNodeMarkup(mergedPos + rect.tableStart, null, { + ...addColSpan( + mergedCell.attrs as CellAttrs, + mergedCell.attrs.colspan, + rect.right - rect.left - mergedCell.attrs.colspan + ), + rowspan: rect.bottom - rect.top + }); + if (content.size) { + const end = mergedPos + 1 + mergedCell.content.size; + const start = isEmpty(mergedCell) ? mergedPos + 1 : end; + tr.replaceWith(start + rect.tableStart, end + rect.tableStart, content); + } + tr.setSelection( + new CellSelection(tr.doc.resolve(mergedPos + rect.tableStart)) + ); + dispatch(tr); + } + return true; +} + +/** + * Split a selected cell, whose rowpan or colspan is greater than one, + * into smaller cells. Use the first cell type for the new cells. + * + * @public + */ +export function splitCell( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const nodeTypes = tableNodeTypes(state.schema); + return splitCellWithType(({ node }) => { + return nodeTypes[node.type.spec.tableRole as TableRole]; + })(state, dispatch); +} + +/** + * @public + */ +export interface GetCellTypeOptions { + node: Node; + row: number; + col: number; +} + +/** + * Split a selected cell, whose rowpan or colspan is greater than one, + * into smaller cells with the cell type (th, td) returned by getType function. + * + * @public + */ +export function splitCellWithType( + getCellType: (options: GetCellTypeOptions) => NodeType +): Command { + return (state, dispatch) => { + const sel = state.selection; + let cellNode: Node | null | undefined; + let cellPos: number | undefined; + if (!(sel instanceof CellSelection)) { + cellNode = cellWrapping(sel.$from); + if (!cellNode) return false; + cellPos = cellAround(sel.$from)?.pos; + } else { + if (sel.$anchorCell.pos != sel.$headCell.pos) return false; + cellNode = sel.$anchorCell.nodeAfter; + cellPos = sel.$anchorCell.pos; + } + if (cellNode == null || cellPos == null) { + return false; + } + if (cellNode.attrs.colspan == 1 && cellNode.attrs.rowspan == 1) { + return false; + } + if (dispatch) { + let baseAttrs = cellNode.attrs; + const attrs = []; + const colwidth = baseAttrs.colwidth; + if (baseAttrs.rowspan > 1) baseAttrs = { ...baseAttrs, rowspan: 1 }; + if (baseAttrs.colspan > 1) baseAttrs = { ...baseAttrs, colspan: 1 }; + const rect = selectedRect(state), + tr = state.tr; + for (let i = 0; i < rect.right - rect.left; i++) + attrs.push( + colwidth + ? { + ...baseAttrs, + colwidth: colwidth && colwidth[i] ? [colwidth[i]] : null + } + : baseAttrs + ); + let lastCell; + for (let row = rect.top; row < rect.bottom; row++) { + let pos = rect.map.positionAt(row, rect.left, rect.table); + if (row == rect.top) pos += cellNode.nodeSize; + for (let col = rect.left, i = 0; col < rect.right; col++, i++) { + if (col == rect.left && row == rect.top) continue; + tr.insert( + (lastCell = tr.mapping.map(pos + rect.tableStart, 1)), + getCellType({ node: cellNode, row, col }).createAndFill(attrs[i])! + ); + } + } + tr.setNodeMarkup( + cellPos, + getCellType({ node: cellNode, row: rect.top, col: rect.left }), + attrs[0] + ); + if (sel instanceof CellSelection) + tr.setSelection( + new CellSelection( + tr.doc.resolve(sel.$anchorCell.pos), + lastCell ? tr.doc.resolve(lastCell) : undefined + ) + ); + dispatch(tr); + } + return true; + }; +} + +/** + * Returns a command that sets the given attribute to the given value, + * and is only available when the currently selected cell doesn't + * already have that attribute set to that value. + * + * @public + */ +export function setCellAttr(name: string, value: unknown): Command { + return function (state, dispatch) { + if (!isInTable(state)) return false; + const $cell = selectionCell(state); + if ($cell.nodeAfter!.attrs[name] === value) return false; + if (dispatch) { + const tr = state.tr; + if (state.selection instanceof CellSelection) + state.selection.forEachCell((node, pos) => { + if (node.attrs[name] !== value) + tr.setNodeMarkup(pos, null, { + ...node.attrs, + [name]: value + }); + }); + else + tr.setNodeMarkup($cell.pos, null, { + ...$cell.nodeAfter!.attrs, + [name]: value + }); + dispatch(tr); + } + return true; + }; +} + +function deprecated_toggleHeader(type: ToggleHeaderType): Command { + return function (state, dispatch) { + if (!isInTable(state)) return false; + if (dispatch) { + const types = tableNodeTypes(state.schema); + const rect = selectedRect(state), + tr = state.tr; + const cells = rect.map.cellsInRect( + type == "column" + ? { + left: rect.left, + top: 0, + right: rect.right, + bottom: rect.map.height + } + : type == "row" + ? { + left: 0, + top: rect.top, + right: rect.map.width, + bottom: rect.bottom + } + : rect + ); + const nodes = cells.map((pos) => rect.table.nodeAt(pos)!); + for ( + let i = 0; + i < cells.length; + i++ // Remove headers, if any + ) + if (nodes[i].type == types.header_cell) + tr.setNodeMarkup( + rect.tableStart + cells[i], + types.cell, + nodes[i].attrs + ); + if (tr.steps.length == 0) + for ( + let i = 0; + i < cells.length; + i++ // No headers removed, add instead + ) + tr.setNodeMarkup( + rect.tableStart + cells[i], + types.header_cell, + nodes[i].attrs + ); + dispatch(tr); + } + return true; + }; +} + +function isHeaderEnabledByType( + type: "row" | "column", + rect: TableRect, + types: Record +): boolean { + // Get cell positions for first row or first column + const cellPositions = rect.map.cellsInRect({ + left: 0, + top: 0, + right: type == "row" ? rect.map.width : 1, + bottom: type == "column" ? rect.map.height : 1 + }); + + for (let i = 0; i < cellPositions.length; i++) { + const cell = rect.table.nodeAt(cellPositions[i]); + if (cell && cell.type !== types.header_cell) { + return false; + } + } + + return true; +} + +/** + * @public + */ +export type ToggleHeaderType = "column" | "row" | "cell"; + +/** + * Toggles between row/column header and normal cells (Only applies to first row/column). + * For deprecated behavior pass `useDeprecatedLogic` in options with true. + * + * @public + */ +export function toggleHeader( + type: ToggleHeaderType, + options?: { useDeprecatedLogic: boolean } | undefined +): Command { + options = options || { useDeprecatedLogic: false }; + + if (options.useDeprecatedLogic) return deprecated_toggleHeader(type); + + return function (state, dispatch) { + if (!isInTable(state)) return false; + if (dispatch) { + const types = tableNodeTypes(state.schema); + const rect = selectedRect(state), + tr = state.tr; + + const isHeaderRowEnabled = isHeaderEnabledByType("row", rect, types); + const isHeaderColumnEnabled = isHeaderEnabledByType( + "column", + rect, + types + ); + + const isHeaderEnabled = + type === "column" + ? isHeaderRowEnabled + : type === "row" + ? isHeaderColumnEnabled + : false; + + const selectionStartsAt = isHeaderEnabled ? 1 : 0; + + const cellsRect = + type == "column" + ? { + left: 0, + top: selectionStartsAt, + right: 1, + bottom: rect.map.height + } + : type == "row" + ? { + left: selectionStartsAt, + top: 0, + right: rect.map.width, + bottom: 1 + } + : rect; + + const newType = + type == "column" + ? isHeaderColumnEnabled + ? types.cell + : types.header_cell + : type == "row" + ? isHeaderRowEnabled + ? types.cell + : types.header_cell + : types.cell; + + rect.map.cellsInRect(cellsRect).forEach((relativeCellPos) => { + const cellPos = relativeCellPos + rect.tableStart; + const cell = tr.doc.nodeAt(cellPos); + + if (cell) { + tr.setNodeMarkup(cellPos, newType, cell.attrs); + } + }); + + dispatch(tr); + } + return true; + }; +} + +/** + * Toggles whether the selected row contains header cells. + * + * @public + */ +export const toggleHeaderRow: Command = toggleHeader("row", { + useDeprecatedLogic: true +}); + +/** + * Toggles whether the selected column contains header cells. + * + * @public + */ +export const toggleHeaderColumn: Command = toggleHeader("column", { + useDeprecatedLogic: true +}); + +/** + * Toggles whether the selected cells are header cells. + * + * @public + */ +export const toggleHeaderCell: Command = toggleHeader("cell", { + useDeprecatedLogic: true +}); + +function findNextCell($cell: ResolvedPos, dir: Direction): number | null { + if (dir < 0) { + const before = $cell.nodeBefore; + if (before) return $cell.pos - before.nodeSize; + for ( + let row = $cell.index(-1) - 1, rowEnd = $cell.before(); + row >= 0; + row-- + ) { + const rowNode = $cell.node(-1).child(row); + const lastChild = rowNode.lastChild; + if (lastChild) { + return rowEnd - 1 - lastChild.nodeSize; + } + rowEnd -= rowNode.nodeSize; + } + } else { + if ($cell.index() < $cell.parent.childCount - 1) { + return $cell.pos + $cell.nodeAfter!.nodeSize; + } + const table = $cell.node(-1); + for ( + let row = $cell.indexAfter(-1), rowStart = $cell.after(); + row < table.childCount; + row++ + ) { + const rowNode = table.child(row); + if (rowNode.childCount) return rowStart + 1; + rowStart += rowNode.nodeSize; + } + } + return null; +} + +/** + * Returns a command for selecting the next (direction=1) or previous + * (direction=-1) cell in a table. + * + * @public + */ +export function goToNextCell(direction: Direction): Command { + return function (state, dispatch) { + if (!isInTable(state)) return false; + const cell = findNextCell(selectionCell(state), direction); + if (cell == null) return false; + if (dispatch) { + const $cell = state.doc.resolve(cell); + dispatch( + state.tr + .setSelection(TextSelection.between($cell, moveCellForward($cell))) + .scrollIntoView() + ); + } + return true; + }; +} + +/** + * Deletes the table around the selection, if any. + * + * @public + */ +export function deleteTable( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const $pos = state.selection.$anchor; + for (let d = $pos.depth; d > 0; d--) { + const node = $pos.node(d); + if (node.type.spec.tableRole == "table") { + if (dispatch) + dispatch( + state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView() + ); + return true; + } + } + return false; +} + +/** + * Deletes the content of the selected cells, if they are not empty. + * + * @public + */ +export function deleteCellSelection( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const sel = state.selection; + if (!(sel instanceof CellSelection)) return false; + if (dispatch) { + const tr = state.tr; + const baseContent = tableNodeTypes(state.schema).cell.createAndFill()! + .content; + sel.forEachCell((cell, pos) => { + if (!cell.content.eq(baseContent)) + tr.replace( + tr.mapping.map(pos + 1), + tr.mapping.map(pos + cell.nodeSize - 1), + new Slice(baseContent, 0, 0) + ); + }); + if (tr.docChanged) dispatch(tr); + } + return true; +} + +/** + * Options for moveTableRow + * + * @public + */ +export interface MoveTableRowOptions { + /** + * The source row index to move from. + */ + from: number; + + /** + * The destination row index to move to. + */ + to: number; + + /** + * Whether to select the moved row after the operation. + * + * @default true + */ + select?: boolean; + + /** + * Optional position to resolve table from. If not provided, uses the current selection. + */ + pos?: number; +} + +/** + * Move a table row from index `from` to index `to`. + * + * @public + */ +export function moveTableRow(options: MoveTableRowOptions): Command { + return (state, dispatch) => { + const { + from: originIndex, + to: targetIndex, + select = true, + pos = state.selection.from + } = options; + const tr = state.tr; + if (moveRow({ tr, originIndex, targetIndex, select, pos })) { + dispatch?.(tr); + return true; + } + return false; + }; +} + +/** + * Options for moveTableColumn + * + * @public + */ +export interface MoveTableColumnOptions { + /** + * The source column index to move from. + */ + from: number; + + /** + * The destination column index to move to. + */ + to: number; + + /** + * Whether to select the moved column after the operation. + * + * @default true + */ + select?: boolean; + + /** + * Optional position to resolve table from. If not provided, uses the current selection. + */ + pos?: number; +} + +/** + * Move a table column from index `from` to index `to`. + * + * @public + */ +export function moveTableColumn(options: MoveTableColumnOptions): Command { + return (state, dispatch) => { + const { + from: originIndex, + to: targetIndex, + select = true, + pos = state.selection.from + } = options; + const tr = state.tr; + if (moveColumn({ tr, originIndex, targetIndex, select, pos })) { + dispatch?.(tr); + return true; + } + return false; + }; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/copypaste.ts b/packages/editor/src/extensions/table/prosemirror-tables/copypaste.ts new file mode 100644 index 000000000..103a6687d --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/copypaste.ts @@ -0,0 +1,381 @@ +// Utilities used for copy/paste handling. +// +// This module handles pasting cell content into tables, or pasting +// anything into a cell selection, as replacing a block of cells with +// the content of the selection. When pasting cells into a cell, that +// involves placing the block of pasted content so that its top left +// aligns with the selection cell, optionally extending the table to +// the right or bottom to make sure it is large enough. Pasting into a +// cell selection is different, here the cells in the selection are +// clipped to the selection's rectangle, optionally repeating the +// pasted cells when they are smaller than the selection. + +import { Fragment, Node, NodeType, Schema, Slice } from "prosemirror-model"; +import { Transform } from "prosemirror-transform"; + +import { EditorState, Transaction } from "prosemirror-state"; +import { CellSelection } from "./cellselection.js"; +import { tableNodeTypes } from "./schema.js"; +import { ColWidths, Rect, TableMap } from "./tablemap.js"; +import { CellAttrs, removeColSpan } from "./util.js"; + +/** + * @internal + */ +export type Area = { width: number; height: number; rows: Fragment[] }; + +// Utilities to help with copying and pasting table cells + +/** + * Get a rectangular area of cells from a slice, or null if the outer + * nodes of the slice aren't table cells or rows. + * + * @internal + */ +export function pastedCells(slice: Slice): Area | null { + if (!slice.size) return null; + let { content, openStart, openEnd } = slice; + while ( + content.childCount == 1 && + ((openStart > 0 && openEnd > 0) || + content.child(0).type.spec.tableRole == "table") + ) { + openStart--; + openEnd--; + content = content.child(0).content; + } + const first = content.child(0); + const role = first.type.spec.tableRole; + const schema = first.type.schema, + rows = []; + if (role == "row") { + for (let i = 0; i < content.childCount; i++) { + let cells = content.child(i).content; + const left = i ? 0 : Math.max(0, openStart - 1); + const right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1); + if (left || right) + cells = fitSlice( + tableNodeTypes(schema).row, + new Slice(cells, left, right) + ).content; + rows.push(cells); + } + } else if (role == "cell" || role == "header_cell") { + rows.push( + openStart || openEnd + ? fitSlice( + tableNodeTypes(schema).row, + new Slice(content, openStart, openEnd) + ).content + : content + ); + } else { + return null; + } + return ensureRectangular(schema, rows); +} + +// Compute the width and height of a set of cells, and make sure each +// row has the same number of cells. +function ensureRectangular(schema: Schema, rows: Fragment[]): Area { + const widths: ColWidths = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + for (let j = row.childCount - 1; j >= 0; j--) { + const { rowspan, colspan } = row.child(j).attrs; + for (let r = i; r < i + rowspan; r++) + widths[r] = (widths[r] || 0) + colspan; + } + } + let width = 0; + for (let r = 0; r < widths.length; r++) width = Math.max(width, widths[r]); + for (let r = 0; r < widths.length; r++) { + if (r >= rows.length) rows.push(Fragment.empty); + if (widths[r] < width) { + const empty = tableNodeTypes(schema).cell.createAndFill()!; + const cells = []; + for (let i = widths[r]; i < width; i++) { + cells.push(empty); + } + rows[r] = rows[r].append(Fragment.from(cells)); + } + } + return { height: rows.length, width, rows }; +} + +export function fitSlice(nodeType: NodeType, slice: Slice): Node { + const node = nodeType.createAndFill()!; + const tr = new Transform(node).replace(0, node.content.size, slice); + return tr.doc; +} + +/** + * Clip or extend (repeat) the given set of cells to cover the given + * width and height. Will clip rowspan/colspan cells at the edges when + * they stick out. + * + * @internal + */ +export function clipCells( + { width, height, rows }: Area, + newWidth: number, + newHeight: number +): Area { + if (width != newWidth) { + const added: number[] = []; + const newRows: Fragment[] = []; + for (let row = 0; row < rows.length; row++) { + const frag = rows[row], + cells = []; + for (let col = added[row] || 0, i = 0; col < newWidth; i++) { + let cell = frag.child(i % frag.childCount); + if (col + cell.attrs.colspan > newWidth) + cell = cell.type.createChecked( + removeColSpan( + cell.attrs as CellAttrs, + cell.attrs.colspan, + col + cell.attrs.colspan - newWidth + ), + cell.content + ); + cells.push(cell); + col += cell.attrs.colspan; + for (let j = 1; j < cell.attrs.rowspan; j++) + added[row + j] = (added[row + j] || 0) + cell.attrs.colspan; + } + newRows.push(Fragment.from(cells)); + } + rows = newRows; + width = newWidth; + } + + if (height != newHeight) { + const newRows = []; + for (let row = 0, i = 0; row < newHeight; row++, i++) { + const cells = [], + source = rows[i % height]; + for (let j = 0; j < source.childCount; j++) { + let cell = source.child(j); + if (row + cell.attrs.rowspan > newHeight) + cell = cell.type.create( + { + ...cell.attrs, + rowspan: Math.max(1, newHeight - cell.attrs.rowspan) + }, + cell.content + ); + cells.push(cell); + } + newRows.push(Fragment.from(cells)); + } + rows = newRows; + height = newHeight; + } + + return { width, height, rows }; +} + +// Make sure a table has at least the given width and height. Return +// true if something was changed. +function growTable( + tr: Transaction, + map: TableMap, + table: Node, + start: number, + width: number, + height: number, + mapFrom: number +): boolean { + const schema = tr.doc.type.schema; + const types = tableNodeTypes(schema); + let empty; + let emptyHead; + if (width > map.width) { + for (let row = 0, rowEnd = 0; row < map.height; row++) { + const rowNode = table.child(row); + rowEnd += rowNode.nodeSize; + const cells: Node[] = []; + let add: Node; + if (rowNode.lastChild == null || rowNode.lastChild.type == types.cell) + add = empty || (empty = types.cell.createAndFill()!); + else add = emptyHead || (emptyHead = types.header_cell.createAndFill()!); + for (let i = map.width; i < width; i++) cells.push(add); + tr.insert(tr.mapping.slice(mapFrom).map(rowEnd - 1 + start), cells); + } + } + if (height > map.height) { + const cells = []; + for ( + let i = 0, start = (map.height - 1) * map.width; + i < Math.max(map.width, width); + i++ + ) { + const header = + i >= map.width + ? false + : table.nodeAt(map.map[start + i])!.type == types.header_cell; + cells.push( + header + ? emptyHead || (emptyHead = types.header_cell.createAndFill()!) + : empty || (empty = types.cell.createAndFill()!) + ); + } + + const emptyRow = types.row.create(null, Fragment.from(cells)), + rows = []; + for (let i = map.height; i < height; i++) rows.push(emptyRow); + tr.insert(tr.mapping.slice(mapFrom).map(start + table.nodeSize - 2), rows); + } + return !!(empty || emptyHead); +} + +// Make sure the given line (left, top) to (right, top) doesn't cross +// any rowspan cells by splitting cells that cross it. Return true if +// something changed. +function isolateHorizontal( + tr: Transaction, + map: TableMap, + table: Node, + start: number, + left: number, + right: number, + top: number, + mapFrom: number +): boolean { + if (top == 0 || top == map.height) return false; + let found = false; + for (let col = left; col < right; col++) { + const index = top * map.width + col, + pos = map.map[index]; + if (map.map[index - map.width] == pos) { + found = true; + const cell = table.nodeAt(pos)!; + const { top: cellTop, left: cellLeft } = map.findCell(pos); + tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + start), null, { + ...cell.attrs, + rowspan: top - cellTop + }); + tr.insert( + tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)), + cell.type.createAndFill({ + ...cell.attrs, + rowspan: cellTop + cell.attrs.rowspan - top + })! + ); + col += cell.attrs.colspan - 1; + } + } + return found; +} + +// Make sure the given line (left, top) to (left, bottom) doesn't +// cross any colspan cells by splitting cells that cross it. Return +// true if something changed. +function isolateVertical( + tr: Transaction, + map: TableMap, + table: Node, + start: number, + top: number, + bottom: number, + left: number, + mapFrom: number +): boolean { + if (left == 0 || left == map.width) return false; + let found = false; + for (let row = top; row < bottom; row++) { + const index = row * map.width + left, + pos = map.map[index]; + if (map.map[index - 1] == pos) { + found = true; + const cell = table.nodeAt(pos)!; + const cellLeft = map.colCount(pos); + const updatePos = tr.mapping.slice(mapFrom).map(pos + start); + tr.setNodeMarkup( + updatePos, + null, + removeColSpan( + cell.attrs as CellAttrs, + left - cellLeft, + cell.attrs.colspan - (left - cellLeft) + ) + ); + tr.insert( + updatePos + cell.nodeSize, + cell.type.createAndFill( + removeColSpan(cell.attrs as CellAttrs, 0, left - cellLeft) + )! + ); + row += cell.attrs.rowspan - 1; + } + } + return found; +} + +/** + * Insert the given set of cells (as returned by `pastedCells`) into a + * table, at the position pointed at by rect. + * + * @internal + */ +export function insertCells( + state: EditorState, + dispatch: (tr: Transaction) => void, + tableStart: number, + rect: Rect, + cells: Area +): void { + let table = tableStart ? state.doc.nodeAt(tableStart - 1) : state.doc; + if (!table) { + throw new Error("No table found"); + } + let map = TableMap.get(table); + const { top, left } = rect; + const right = left + cells.width, + bottom = top + cells.height; + const tr = state.tr; + let mapFrom = 0; + + function recomp(): void { + table = tableStart ? tr.doc.nodeAt(tableStart - 1) : tr.doc; + if (!table) { + throw new Error("No table found"); + } + map = TableMap.get(table); + mapFrom = tr.mapping.maps.length; + } + + // Prepare the table to be large enough and not have any cells + // crossing the boundaries of the rectangle that we want to + // insert into. If anything about it changes, recompute the table + // map so that subsequent operations can see the current shape. + if (growTable(tr, map, table, tableStart, right, bottom, mapFrom)) recomp(); + if (isolateHorizontal(tr, map, table, tableStart, left, right, top, mapFrom)) + recomp(); + if ( + isolateHorizontal(tr, map, table, tableStart, left, right, bottom, mapFrom) + ) + recomp(); + if (isolateVertical(tr, map, table, tableStart, top, bottom, left, mapFrom)) + recomp(); + if (isolateVertical(tr, map, table, tableStart, top, bottom, right, mapFrom)) + recomp(); + + for (let row = top; row < bottom; row++) { + const from = map.positionAt(row, left, table), + to = map.positionAt(row, right, table); + tr.replace( + tr.mapping.slice(mapFrom).map(from + tableStart), + tr.mapping.slice(mapFrom).map(to + tableStart), + new Slice(cells.rows[row - top], 0, 0) + ); + } + recomp(); + tr.setSelection( + new CellSelection( + tr.doc.resolve(tableStart + map.positionAt(top, left, table)), + tr.doc.resolve(tableStart + map.positionAt(bottom - 1, right - 1, table)) + ) + ); + dispatch(tr); +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts b/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts new file mode 100644 index 000000000..848be68ac --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/fixtables.ts @@ -0,0 +1,153 @@ +// This file defines helpers for normalizing tables, making sure no +// cells overlap (which can happen, if you have the wrong col- and +// rowspans) and that each row has the same width. Uses the problems +// reported by `TableMap`. + +import { Node } from "prosemirror-model"; +import { EditorState, PluginKey, Transaction } from "prosemirror-state"; +import { tableNodeTypes, TableRole } from "./schema.js"; +import { TableMap } from "./tablemap.js"; +import { CellAttrs, removeColSpan } from "./util.js"; + +/** + * @public + */ +export const fixTablesKey = new PluginKey<{ fixTables: boolean }>("fix-tables"); + +/** + * Helper for iterating through the nodes in a document that changed + * compared to the given previous document. Useful for avoiding + * duplicate work on each transaction. + * + * @public + */ +function changedDescendants( + old: Node, + cur: Node, + offset: number, + f: (node: Node, pos: number) => void +): void { + const oldSize = old.childCount, + curSize = cur.childCount; + outer: for (let i = 0, j = 0; i < curSize; i++) { + const child = cur.child(i); + for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) { + if (old.child(scan) == child) { + j = scan + 1; + offset += child.nodeSize; + continue outer; + } + } + f(child, offset); + if (j < oldSize && old.child(j).sameMarkup(child)) + changedDescendants(old.child(j), child, offset + 1, f); + else child.nodesBetween(0, child.content.size, f, offset + 1); + offset += child.nodeSize; + } +} + +/** + * Inspect all tables in the given state's document and return a + * transaction that fixes them, if necessary. If `oldState` was + * provided, that is assumed to hold a previous, known-good state, + * which will be used to avoid re-scanning unchanged parts of the + * document. + * + * @public + */ +export function fixTables( + state: EditorState, + oldState?: EditorState +): Transaction | undefined { + let tr: Transaction | undefined; + const check = (node: Node, pos: number) => { + if (node.type.spec.tableRole == "table") + tr = fixTable(state, node, pos, tr); + }; + if (!oldState) state.doc.descendants(check); + else if (oldState.doc != state.doc) + changedDescendants(oldState.doc, state.doc, 0, check); + return tr; +} + +// Fix the given table, if necessary. Will append to the transaction +// it was given, if non-null, or create a new one if necessary. +export function fixTable( + state: EditorState, + table: Node, + tablePos: number, + tr: Transaction | undefined +): Transaction | undefined { + const map = TableMap.get(table); + if (!map.problems) return tr; + if (!tr) tr = state.tr; + + // Track which rows we must add cells to, so that we can adjust that + // when fixing collisions. + const mustAdd: number[] = []; + for (let i = 0; i < map.height; i++) mustAdd.push(0); + for (let i = 0; i < map.problems.length; i++) { + const prob = map.problems[i]; + if (prob.type == "collision") { + const cell = table.nodeAt(prob.pos); + if (!cell) continue; + const attrs = cell.attrs as CellAttrs; + for (let j = 0; j < attrs.rowspan; j++) mustAdd[prob.row + j] += prob.n; + tr.setNodeMarkup( + tr.mapping.map(tablePos + 1 + prob.pos), + null, + removeColSpan(attrs, attrs.colspan - prob.n, prob.n) + ); + } else if (prob.type == "missing") { + mustAdd[prob.row] += prob.n; + } else if (prob.type == "overlong_rowspan") { + const cell = table.nodeAt(prob.pos); + if (!cell) continue; + tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, { + ...cell.attrs, + rowspan: cell.attrs.rowspan - prob.n + }); + } else if (prob.type == "colwidth mismatch") { + const cell = table.nodeAt(prob.pos); + if (!cell) continue; + tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, { + ...cell.attrs, + colwidth: prob.colwidth + }); + } else if (prob.type == "zero_sized") { + const pos = tr.mapping.map(tablePos); + tr.delete(pos, pos + table.nodeSize); + } + } + let first, last; + for (let i = 0; i < mustAdd.length; i++) + if (mustAdd[i]) { + if (first == null) first = i; + last = i; + } + // Add the necessary cells, using a heuristic for whether to add the + // cells at the start or end of the rows (if it looks like a 'bite' + // was taken out of the table, add cells at the start of the row + // after the bite. Otherwise add them at the end). + for (let i = 0, pos = tablePos + 1; i < map.height; i++) { + const row = table.child(i); + const end = pos + row.nodeSize; + const add = mustAdd[i]; + if (add > 0) { + let role: TableRole = "cell"; + if (row.firstChild) { + role = row.firstChild.type.spec.tableRole; + } + const nodes: Node[] = []; + for (let j = 0; j < add; j++) { + const node = tableNodeTypes(state.schema)[role].createAndFill(); + + if (node) nodes.push(node); + } + const side = (i == 0 || first == i - 1) && last == i ? pos + 1 : end - 1; + tr.insert(tr.mapping.map(side), nodes); + } + pos = end; + } + return tr.setMeta(fixTablesKey, { fixTables: true }); +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/index.ts b/packages/editor/src/extensions/table/prosemirror-tables/index.ts new file mode 100644 index 000000000..cceace57f --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/index.ts @@ -0,0 +1,101 @@ +// This file defines a plugin that handles the drawing of cell +// selections and the basic user interactions for creating and working +// with such selections. It also makes sure that, after each +// transaction, the shapes of tables are normalized to be rectangular +// and not contain overlapping cells. + +import { Plugin } from "prosemirror-state"; + +import { drawCellSelection, normalizeSelection } from "./cellselection.js"; +import { fixTables, fixTablesKey } from "./fixtables.js"; +import { + handleKeyDown, + handleMouseDown, + handlePaste, + handleTripleClick +} from "./input.js"; +import { tableEditingKey } from "./util.js"; + +/** + * @public + */ +export type TableEditingOptions = { + /** + * Whether to allow table node selection. + * + * By default, any node selection wrapping a table will be converted into a + * CellSelection wrapping all cells in the table. You can pass `true` to allow + * the selection to remain a NodeSelection. + * + * @default false + */ + allowTableNodeSelection?: boolean; +}; + +/** + * Creates a [plugin](http://prosemirror.net/docs/ref/#state.Plugin) + * that, when added to an editor, enables cell-selection, handles + * cell-based copy/paste, and makes sure tables stay well-formed (each + * row has the same width, and cells don't overlap). + * + * You should probably put this plugin near the end of your array of + * plugins, since it handles mouse and arrow key events in tables + * rather broadly, and other plugins, like the gap cursor or the + * column-width dragging plugin, might want to get a turn first to + * perform more specific behavior. + * + * @public + */ +export function tableEditing({ + allowTableNodeSelection = false +}: TableEditingOptions = {}): Plugin { + return new Plugin({ + key: tableEditingKey, + + // This piece of state is used to remember when a mouse-drag + // cell-selection is happening, so that it can continue even as + // transactions (which might move its anchor cell) come in. + state: { + init() { + return null; + }, + apply(tr, cur) { + const set = tr.getMeta(tableEditingKey); + if (set != null) return set == -1 ? null : set; + if (cur == null || !tr.docChanged) return cur; + const { deleted, pos } = tr.mapping.mapResult(cur); + return deleted ? null : pos; + } + }, + + props: { + decorations: drawCellSelection, + + handleDOMEvents: { + mousedown: handleMouseDown, + touchstart: handleMouseDown + }, + + createSelectionBetween(view) { + return tableEditingKey.getState(view.state) != null + ? view.state.selection + : null; + }, + + handleTripleClick, + + handleKeyDown, + + handlePaste + }, + + appendTransaction(transactions, oldState, state) { + return normalizeSelection( + state, + fixTables(state, oldState), + oldState, + allowTableNodeSelection + ); + } + }); +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/input.ts b/packages/editor/src/extensions/table/prosemirror-tables/input.ts new file mode 100644 index 000000000..e989f76af --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/input.ts @@ -0,0 +1,315 @@ +// This file defines a number of helpers for wiring up user input to +// table-related functionality. + +import { keydownHandler } from "prosemirror-keymap"; +import { Fragment, ResolvedPos, Slice } from "prosemirror-model"; +import { + Command, + EditorState, + Selection, + TextSelection, + Transaction +} from "prosemirror-state"; + +import { EditorView } from "prosemirror-view"; +import { CellSelection } from "./cellselection.js"; +import { deleteCellSelection } from "./commands.js"; +import { clipCells, fitSlice, insertCells, pastedCells } from "./copypaste.js"; +import { tableNodeTypes } from "./schema.js"; +import { TableMap } from "./tablemap.js"; +import { + cellAround, + getClientX, + getClientY, + inSameTable, + isInTable, + nextCell, + selectionCell, + tableEditingKey +} from "./util.js"; +import { columnResizingPluginKey } from "./columnresizing.js"; + +type Axis = "horiz" | "vert"; + +/** + * @public + */ +export type Direction = -1 | 1; + +export const handleKeyDown = keydownHandler({ + ArrowLeft: arrow("horiz", -1), + ArrowRight: arrow("horiz", 1), + ArrowUp: arrow("vert", -1), + ArrowDown: arrow("vert", 1), + + "Shift-ArrowLeft": shiftArrow("horiz", -1), + "Shift-ArrowRight": shiftArrow("horiz", 1), + "Shift-ArrowUp": shiftArrow("vert", -1), + "Shift-ArrowDown": shiftArrow("vert", 1), + + Backspace: deleteCellSelection, + "Mod-Backspace": deleteCellSelection, + Delete: deleteCellSelection, + "Mod-Delete": deleteCellSelection +}); + +function maybeSetSelection( + state: EditorState, + dispatch: undefined | ((tr: Transaction) => void), + selection: Selection +): boolean { + if (selection.eq(state.selection)) return false; + if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView()); + return true; +} + +/** + * @internal + */ +export function arrow(axis: Axis, dir: Direction): Command { + return (state, dispatch, view) => { + if (!view) return false; + const sel = state.selection; + if (sel instanceof CellSelection) { + return maybeSetSelection( + state, + dispatch, + Selection.near(sel.$headCell, dir) + ); + } + if (axis != "horiz" && !sel.empty) return false; + const end = atEndOfCell(view, axis, dir); + if (end == null) return false; + if (axis == "horiz") { + return maybeSetSelection( + state, + dispatch, + Selection.near(state.doc.resolve(sel.head + dir), dir) + ); + } else { + const $cell = state.doc.resolve(end); + const $next = nextCell($cell, axis, dir); + let newSel; + if ($next) newSel = Selection.near($next, 1); + else if (dir < 0) + newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1); + else newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1); + return maybeSetSelection(state, dispatch, newSel); + } + }; +} + +function shiftArrow(axis: Axis, dir: Direction): Command { + return (state, dispatch, view) => { + if (!view) return false; + const sel = state.selection; + let cellSel: CellSelection; + if (sel instanceof CellSelection) { + cellSel = sel; + } else { + const end = atEndOfCell(view, axis, dir); + if (end == null) return false; + cellSel = new CellSelection(state.doc.resolve(end)); + } + + const $head = nextCell(cellSel.$headCell, axis, dir); + if (!$head) return false; + return maybeSetSelection( + state, + dispatch, + new CellSelection(cellSel.$anchorCell, $head) + ); + }; +} + +export function handleTripleClick(view: EditorView, pos: number): boolean { + const doc = view.state.doc, + $cell = cellAround(doc.resolve(pos)); + if (!$cell) return false; + view.dispatch(view.state.tr.setSelection(new CellSelection($cell))); + return true; +} + +/** + * @public + */ +export function handlePaste( + view: EditorView, + _: ClipboardEvent, + slice: Slice +): boolean { + if (!isInTable(view.state)) return false; + let cells = pastedCells(slice); + const sel = view.state.selection; + if (sel instanceof CellSelection) { + if (!cells) + cells = { + width: 1, + height: 1, + rows: [ + Fragment.from(fitSlice(tableNodeTypes(view.state.schema).cell, slice)) + ] + }; + const table = sel.$anchorCell.node(-1); + const start = sel.$anchorCell.start(-1); + const rect = TableMap.get(table).rectBetween( + sel.$anchorCell.pos - start, + sel.$headCell.pos - start + ); + cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top); + insertCells(view.state, view.dispatch, start, rect, cells); + return true; + } else if (cells) { + const $cell = selectionCell(view.state); + const start = $cell.start(-1); + insertCells( + view.state, + view.dispatch, + start, + TableMap.get($cell.node(-1)).findCell($cell.pos - start), + cells + ); + return true; + } else { + return false; + } +} + +export function handleMouseDown( + view: EditorView, + startEvent: MouseEvent | TouchEvent +): void { + if (startEvent.ctrlKey || startEvent.metaKey) return; + if (columnResizingPluginKey.getState(view.state)?.dragging) return; + + const startDOMCell = domInCell(view, startEvent.target as Node); + let $anchor; + if (startEvent.shiftKey && view.state.selection instanceof CellSelection) { + // Adding to an existing cell selection + setCellSelection(view.state.selection.$anchorCell, startEvent); + startEvent.preventDefault(); + } else if ( + startEvent.shiftKey && + startDOMCell && + ($anchor = cellAround(view.state.selection.$anchor)) != null && + cellUnderMouse(view, startEvent)?.pos != $anchor.pos + ) { + // Adding to a selection that starts in another cell (causing a + // cell selection to be created). + setCellSelection($anchor, startEvent); + startEvent.preventDefault(); + } else if (startEvent instanceof TouchEvent) { + const selectedCell = view.domAtPos(view.state.selection.from).node; + if (startDOMCell?.contains(selectedCell)) { + $anchor = cellUnderMouse(view, startEvent); + if (!$anchor) return; + setCellSelection($anchor, startEvent); + } + } else if (!startDOMCell) { + // Not in a cell, let the default behavior happen. + return; + } + + // Create and dispatch a cell selection between the given anchor and + // the position under the mouse. + function setCellSelection( + $anchor: ResolvedPos, + event: MouseEvent | DragEvent | TouchEvent + ): void { + let $head = cellUnderMouse(view, event); + const starting = tableEditingKey.getState(view.state) == null; + if (!$head || !inSameTable($anchor, $head)) { + if (starting) $head = $anchor; + else return; + } + const selection = new CellSelection($anchor, $head); + if (starting || !view.state.selection.eq(selection)) { + const tr = view.state.tr.setSelection(selection); + if (starting) tr.setMeta(tableEditingKey, $anchor.pos); + view.dispatch(tr); + } + } + + // Stop listening to mouse motion events. + function stop(): void { + view.root.removeEventListener("mouseup", stop); + view.root.removeEventListener("dragstart", stop); + view.root.removeEventListener("mousemove", move); + view.root.removeEventListener("touchmove", move); + view.root.removeEventListener("touchend", stop); + view.root.removeEventListener("touchcancel", stop); + if (tableEditingKey.getState(view.state) != null) { + (view as any).domObserver.suppressSelectionUpdates(); + view.dispatch(view.state.tr.setMeta(tableEditingKey, -1)); + } + } + + function move(_event: Event): void { + const event = _event as MouseEvent | DragEvent | TouchEvent; + const anchor = tableEditingKey.getState(view.state); + let $anchor; + if (anchor != null) { + // Continuing an existing cross-cell selection + $anchor = view.state.doc.resolve(anchor); + } else if (domInCell(view, event.target as Node) != startDOMCell) { + // Moving out of the initial cell -- start a new cell selection + $anchor = cellUnderMouse(view, startEvent); + if (!$anchor) return stop(); + } + if ($anchor) setCellSelection($anchor, event); + } + + view.root.addEventListener("mouseup", stop); + view.root.addEventListener("dragstart", stop); + view.root.addEventListener("mousemove", move); + view.root.addEventListener("touchmove", move); + view.root.addEventListener("touchend", stop); + view.root.addEventListener("touchcancel", stop); +} + +// Check whether the cursor is at the end of a cell (so that further +// motion would move out of the cell) +function atEndOfCell(view: EditorView, axis: Axis, dir: number): null | number { + if (!(view.state.selection instanceof TextSelection)) return null; + const { $head } = view.state.selection; + for (let d = $head.depth - 1; d >= 0; d--) { + const parent = $head.node(d), + index = dir < 0 ? $head.index(d) : $head.indexAfter(d); + if (index != (dir < 0 ? 0 : parent.childCount)) return null; + if ( + parent.type.spec.tableRole == "cell" || + parent.type.spec.tableRole == "header_cell" + ) { + const cellPos = $head.before(d); + const dirStr: "up" | "down" | "left" | "right" = + axis == "vert" ? (dir > 0 ? "down" : "up") : dir > 0 ? "right" : "left"; + return view.endOfTextblock(dirStr) ? cellPos : null; + } + } + return null; +} + +function domInCell(view: EditorView, dom: Node | null): Node | null { + for (; dom && dom != view.dom; dom = dom.parentNode) { + if (dom.nodeName == "TD" || dom.nodeName == "TH") { + return dom; + } + } + return null; +} + +function cellUnderMouse( + view: EditorView, + event: MouseEvent | DragEvent | TouchEvent +): ResolvedPos | null { + const clientX = getClientX(event); + const clientY = getClientY(event); + if (clientX == null || clientY == null) return null; + + const mousePos = view.posAtCoords({ + left: clientX, + top: clientY + }); + if (!mousePos) return null; + return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/schema.ts b/packages/editor/src/extensions/table/prosemirror-tables/schema.ts new file mode 100644 index 000000000..211e3f34f --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/schema.ts @@ -0,0 +1,215 @@ +// Helper for creating a schema that supports tables. + +import { + AttributeSpec, + Attrs, + Node, + NodeSpec, + NodeType, + Schema +} from "prosemirror-model"; +import { CellAttrs, MutableAttrs } from "./util.js"; + +function getCellAttrs(dom: HTMLElement | string, extraAttrs: Attrs): Attrs { + if (typeof dom === "string") { + return {}; + } + + const widthAttr = dom.getAttribute("data-colwidth"); + const widths = + widthAttr && /^\d+(,\d+)*$/.test(widthAttr) + ? widthAttr.split(",").map((s) => Number(s)) + : null; + const colspan = Number(dom.getAttribute("colspan") || 1); + const result: MutableAttrs = { + colspan, + rowspan: Number(dom.getAttribute("rowspan") || 1), + colwidth: widths && widths.length == colspan ? widths : null + } satisfies CellAttrs; + for (const prop in extraAttrs) { + const getter = extraAttrs[prop].getFromDOM; + const value = getter && getter(dom); + if (value != null) { + result[prop] = value; + } + } + return result; +} + +function setCellAttrs(node: Node, extraAttrs: Attrs): Attrs { + const attrs: MutableAttrs = {}; + if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan; + if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan; + if (node.attrs.colwidth) + attrs["data-colwidth"] = node.attrs.colwidth.join(","); + for (const prop in extraAttrs) { + const setter = extraAttrs[prop].setDOMAttr; + if (setter) setter(node.attrs[prop], attrs); + } + return attrs; +} + +/** + * @public + */ +export type getFromDOM = (dom: HTMLElement) => unknown; + +/** + * @public + */ +export type setDOMAttr = (value: unknown, attrs: MutableAttrs) => void; + +/** + * @public + */ +export interface CellAttributes { + /** + * The attribute's default value. + */ + default: unknown; + + /** + * A function or type name used to validate values of this attribute. + * + * See [validate](https://prosemirror.net/docs/ref/#model.AttributeSpec.validate). + */ + validate?: string | ((value: unknown) => void); + + /** + * A function to read the attribute's value from a DOM node. + */ + getFromDOM?: getFromDOM; + + /** + * A function to add the attribute's value to an attribute + * object that's used to render the cell's DOM. + */ + setDOMAttr?: setDOMAttr; +} + +/** + * @public + */ +export interface TableNodesOptions { + /** + * The content expression for table cells. + */ + cellContent: string; + + /** + * Additional attributes to add to cells. Maps attribute names to + * objects with the following properties: + */ + cellAttributes: { [key: string]: CellAttributes }; +} + +/** + * @public + */ +export type TableNodes = Record< + "table_row" | "table_cell" | "table_header", + NodeSpec +>; + +function validateColwidth(value: unknown) { + if (value === null) { + return; + } + if (!Array.isArray(value)) { + throw new TypeError("colwidth must be null or an array"); + } + for (const item of value) { + if (typeof item !== "number") { + throw new TypeError("colwidth must be null or an array of numbers"); + } + } +} + +/** + * This function creates a set of [node + * specs](http://prosemirror.net/docs/ref/#model.SchemaSpec.nodes) for + * `table`, `table_row`, and `table_cell` nodes types as used by this + * module. The result can then be added to the set of nodes when + * creating a schema. + * + * @public + */ +export function tableNodes(options: TableNodesOptions): TableNodes { + const extraAttrs = options.cellAttributes || {}; + const cellAttrs: Record = { + colspan: { default: 1, validate: "number" }, + rowspan: { default: 1, validate: "number" }, + colwidth: { default: null, validate: validateColwidth } + }; + for (const prop in extraAttrs) + cellAttrs[prop] = { + default: extraAttrs[prop].default, + validate: extraAttrs[prop].validate + }; + + return { + // table: { + // content: "table_row+", + // tableRole: "table", + // isolating: true, + // group: options.tableGroup, + // parseDOM: [{ tag: "table" }], + // toDOM() { + // return ["table", ["tbody", 0]]; + // } + // }, + table_row: { + content: "(table_cell | table_header)*", + tableRole: "row", + parseDOM: [{ tag: "tr" }], + toDOM() { + return ["tr", 0]; + } + }, + table_cell: { + content: options.cellContent, + attrs: cellAttrs, + tableRole: "cell", + isolating: true, + parseDOM: [ + { tag: "td", getAttrs: (dom) => getCellAttrs(dom, extraAttrs) } + ], + toDOM(node) { + return ["td", setCellAttrs(node, extraAttrs), 0]; + } + }, + table_header: { + content: options.cellContent, + attrs: cellAttrs, + tableRole: "header_cell", + isolating: true, + parseDOM: [ + { tag: "th", getAttrs: (dom) => getCellAttrs(dom, extraAttrs) } + ], + toDOM(node) { + return ["th", setCellAttrs(node, extraAttrs), 0]; + } + } + }; +} + +/** + * @public + */ +export type TableRole = "table" | "row" | "cell" | "header_cell"; + +/** + * @public + */ +export function tableNodeTypes(schema: Schema): Record { + let result = schema.cached.tableNodeTypes; + if (!result) { + result = schema.cached.tableNodeTypes = {}; + for (const name in schema.nodes) { + const type = schema.nodes[name], + role = type.spec.tableRole; + if (role) result[role] = type; + } + } + return result; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/tablemap.ts b/packages/editor/src/extensions/table/prosemirror-tables/tablemap.ts new file mode 100644 index 000000000..a2873df26 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/tablemap.ts @@ -0,0 +1,383 @@ +// Because working with row and column-spanning cells is not quite +// trivial, this code builds up a descriptive structure for a given +// table node. The structures are cached with the (persistent) table +// nodes as key, so that they only have to be recomputed when the +// content of the table changes. +// +// This does mean that they have to store table-relative, not +// document-relative positions. So code that uses them will typically +// compute the start position of the table and offset positions passed +// to or gotten from this structure by that amount. +import { Attrs, Node } from "prosemirror-model"; +import { CellAttrs } from "./util.js"; + +/** + * @public + */ +export type ColWidths = number[]; + +/** + * @public + */ +export type Problem = + | { + type: "colwidth mismatch"; + pos: number; + colwidth: ColWidths; + } + | { + type: "collision"; + pos: number; + row: number; + n: number; + } + | { + type: "missing"; + row: number; + n: number; + } + | { + type: "overlong_rowspan"; + pos: number; + n: number; + } + | { + type: "zero_sized"; + }; + +let readFromCache: (key: Node) => TableMap | undefined; +let addToCache: (key: Node, value: TableMap) => TableMap; + +// Prefer using a weak map to cache table maps. Fall back on a +// fixed-size cache if that's not supported. +if (typeof WeakMap != "undefined") { + // eslint-disable-next-line + let cache = new WeakMap(); + readFromCache = (key) => cache.get(key); + addToCache = (key, value) => { + cache.set(key, value); + return value; + }; +} else { + const cache: (Node | TableMap)[] = []; + const cacheSize = 10; + let cachePos = 0; + readFromCache = (key) => { + for (let i = 0; i < cache.length; i += 2) + if (cache[i] == key) return cache[i + 1] as TableMap; + }; + addToCache = (key, value) => { + if (cachePos == cacheSize) cachePos = 0; + cache[cachePos++] = key; + return (cache[cachePos++] = value); + }; +} + +/** + * @public + */ +export interface Rect { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * A table map describes the structure of a given table. To avoid + * recomputing them all the time, they are cached per table node. To + * be able to do that, positions saved in the map are relative to the + * start of the table, rather than the start of the document. + * + * @public + */ +export class TableMap { + constructor( + /** + * The number of columns + */ + public width: number, + /** + * The number of rows + */ + public height: number, + /** + * A width * height array with the start position of + * the cell covering that part of the table in each slot + */ + public map: number[], + /** + * An optional array of problems (cell overlap or non-rectangular + * shape) for the table, used by the table normalizer. + */ + public problems: Problem[] | null + ) {} + + // Find the dimensions of the cell at the given position. + findCell(pos: number): Rect { + for (let i = 0; i < this.map.length; i++) { + const curPos = this.map[i]; + if (curPos != pos) continue; + + const left = i % this.width; + const top = (i / this.width) | 0; + let right = left + 1; + let bottom = top + 1; + + for (let j = 1; right < this.width && this.map[i + j] == curPos; j++) { + right++; + } + for ( + let j = 1; + bottom < this.height && this.map[i + this.width * j] == curPos; + j++ + ) { + bottom++; + } + + return { left, top, right, bottom }; + } + throw new RangeError(`No cell with offset ${pos} found`); + } + + // Find the left side of the cell at the given position. + colCount(pos: number): number { + for (let i = 0; i < this.map.length; i++) { + if (this.map[i] == pos) { + return i % this.width; + } + } + throw new RangeError(`No cell with offset ${pos} found`); + } + + // Find the next cell in the given direction, starting from the cell + // at `pos`, if any. + nextCell(pos: number, axis: "horiz" | "vert", dir: number): null | number { + const { left, right, top, bottom } = this.findCell(pos); + if (axis == "horiz") { + if (dir < 0 ? left == 0 : right == this.width) return null; + return this.map[top * this.width + (dir < 0 ? left - 1 : right)]; + } else { + if (dir < 0 ? top == 0 : bottom == this.height) return null; + return this.map[left + this.width * (dir < 0 ? top - 1 : bottom)]; + } + } + + // Get the rectangle spanning the two given cells. + rectBetween(a: number, b: number): Rect { + const { + left: leftA, + right: rightA, + top: topA, + bottom: bottomA + } = this.findCell(a); + const { + left: leftB, + right: rightB, + top: topB, + bottom: bottomB + } = this.findCell(b); + return { + left: Math.min(leftA, leftB), + top: Math.min(topA, topB), + right: Math.max(rightA, rightB), + bottom: Math.max(bottomA, bottomB) + }; + } + + // Return the position of all cells that have the top left corner in + // the given rectangle. + cellsInRect(rect: Rect): number[] { + const result: number[] = []; + const seen: Record = {}; + for (let row = rect.top; row < rect.bottom; row++) { + for (let col = rect.left; col < rect.right; col++) { + const index = row * this.width + col; + const pos = this.map[index]; + + if (seen[pos]) continue; + seen[pos] = true; + + if ( + (col == rect.left && col && this.map[index - 1] == pos) || + (row == rect.top && row && this.map[index - this.width] == pos) + ) { + continue; + } + result.push(pos); + } + } + return result; + } + + // Return the position at which the cell at the given row and column + // starts, or would start, if a cell started there. + positionAt(row: number, col: number, table: Node): number { + for (let i = 0, rowStart = 0; ; i++) { + const rowEnd = rowStart + table.child(i).nodeSize; + if (i == row) { + let index = col + row * this.width; + const rowEndIndex = (row + 1) * this.width; + // Skip past cells from previous rows (via rowspan) + while (index < rowEndIndex && this.map[index] < rowStart) index++; + return index == rowEndIndex ? rowEnd - 1 : this.map[index]; + } + rowStart = rowEnd; + } + } + + // Find the table map for the given table node. + static get(table: Node): TableMap { + return readFromCache(table) || addToCache(table, computeMap(table)); + } +} + +// Compute a table map. +function computeMap(table: Node): TableMap { + if (table.type.spec.tableRole != "table") + throw new RangeError("Not a table node: " + table.type.name); + const width = findWidth(table), + height = table.childCount; + const map = []; + let mapPos = 0; + let problems: Problem[] | null = null; + const colWidths: ColWidths = []; + for (let i = 0, e = width * height; i < e; i++) map[i] = 0; + + for (let row = 0, pos = 0; row < height; row++) { + const rowNode = table.child(row); + pos++; + for (let i = 0; ; i++) { + while (mapPos < map.length && map[mapPos] != 0) mapPos++; + if (i == rowNode.childCount) break; + const cellNode = rowNode.child(i); + const { colspan, rowspan, colwidth } = cellNode.attrs; + for (let h = 0; h < rowspan; h++) { + if (h + row >= height) { + (problems || (problems = [])).push({ + type: "overlong_rowspan", + pos, + n: rowspan - h + }); + break; + } + const start = mapPos + h * width; + for (let w = 0; w < colspan; w++) { + if (map[start + w] == 0) map[start + w] = pos; + else + (problems || (problems = [])).push({ + type: "collision", + row, + pos, + n: colspan - w + }); + const colW = colwidth && colwidth[w]; + if (colW) { + const widthIndex = ((start + w) % width) * 2, + prev = colWidths[widthIndex]; + if ( + prev == null || + (prev != colW && colWidths[widthIndex + 1] == 1) + ) { + colWidths[widthIndex] = colW; + colWidths[widthIndex + 1] = 1; + } else if (prev == colW) { + colWidths[widthIndex + 1]++; + } + } + } + } + mapPos += colspan; + pos += cellNode.nodeSize; + } + const expectedPos = (row + 1) * width; + let missing = 0; + while (mapPos < expectedPos) if (map[mapPos++] == 0) missing++; + if (missing) + (problems || (problems = [])).push({ type: "missing", row, n: missing }); + pos++; + } + + if (width === 0 || height === 0) + (problems || (problems = [])).push({ type: "zero_sized" }); + + const tableMap = new TableMap(width, height, map, problems); + let badWidths = false; + + // For columns that have defined widths, but whose widths disagree + // between rows, fix up the cells whose width doesn't match the + // computed one. + for (let i = 0; !badWidths && i < colWidths.length; i += 2) + if (colWidths[i] != null && colWidths[i + 1] < height) badWidths = true; + if (badWidths) findBadColWidths(tableMap, colWidths, table); + + return tableMap; +} + +function findWidth(table: Node): number { + let width = -1; + let hasRowSpan = false; + for (let row = 0; row < table.childCount; row++) { + const rowNode = table.child(row); + let rowWidth = 0; + if (hasRowSpan) + for (let j = 0; j < row; j++) { + const prevRow = table.child(j); + for (let i = 0; i < prevRow.childCount; i++) { + const cell = prevRow.child(i); + if (j + cell.attrs.rowspan > row) rowWidth += cell.attrs.colspan; + } + } + for (let i = 0; i < rowNode.childCount; i++) { + const cell = rowNode.child(i); + rowWidth += cell.attrs.colspan; + if (cell.attrs.rowspan > 1) hasRowSpan = true; + } + if (width == -1) width = rowWidth; + else if (width != rowWidth) width = Math.max(width, rowWidth); + } + return width; +} + +function findBadColWidths( + map: TableMap, + colWidths: ColWidths, + table: Node +): void { + if (!map.problems) map.problems = []; + const seen: Record = {}; + for (let i = 0; i < map.map.length; i++) { + const pos = map.map[i]; + if (seen[pos]) continue; + seen[pos] = true; + const node = table.nodeAt(pos); + if (!node) { + throw new RangeError(`No cell with offset ${pos} found`); + } + + let updated = null; + const attrs = node.attrs as CellAttrs; + for (let j = 0; j < attrs.colspan; j++) { + const col = (i + j) % map.width; + const colWidth = colWidths[col * 2]; + if ( + colWidth != null && + (!attrs.colwidth || attrs.colwidth[j] != colWidth) + ) + (updated || (updated = freshColWidth(attrs)))[j] = colWidth; + } + if (updated) + map.problems.unshift({ + type: "colwidth mismatch", + pos, + colwidth: updated + }); + } +} + +function freshColWidth(attrs: Attrs): ColWidths { + if (attrs.colwidth) return attrs.colwidth.slice(); + const result: ColWidths = []; + for (let i = 0; i < attrs.colspan; i++) result.push(0); + return result; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/tableview.ts b/packages/editor/src/extensions/table/prosemirror-tables/tableview.ts new file mode 100644 index 000000000..e053602f2 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/tableview.ts @@ -0,0 +1,98 @@ +import { Node } from "prosemirror-model"; +import { NodeView } from "prosemirror-view"; +import { CellAttrs } from "./util.js"; + +/** + * @public + */ +export class TableView implements NodeView { + public dom: HTMLDivElement; + public table: HTMLTableElement; + public colgroup: HTMLTableColElement; + public contentDOM: HTMLTableSectionElement; + + constructor(public node: Node, public defaultCellMinWidth: number) { + this.dom = document.createElement("div"); + this.dom.className = "tableWrapper"; + this.table = this.dom.appendChild(document.createElement("table")); + this.table.style.setProperty( + "--default-cell-min-width", + `${defaultCellMinWidth}px` + ); + this.colgroup = this.table.appendChild(document.createElement("colgroup")); + updateColumnsOnResize(node, this.colgroup, this.table, defaultCellMinWidth); + this.contentDOM = this.table.appendChild(document.createElement("tbody")); + } + + update(node: Node): boolean { + if (node.type != this.node.type) return false; + this.node = node; + updateColumnsOnResize( + node, + this.colgroup, + this.table, + this.defaultCellMinWidth + ); + return true; + } + + ignoreMutation(record: any): boolean { + return ( + record.type == "attributes" && + (record.target == this.table || this.colgroup.contains(record.target)) + ); + } +} + +/** + * @public + */ +export function updateColumnsOnResize( + node: Node, + colgroup: HTMLTableColElement, + table: HTMLTableElement, + defaultCellMinWidth: number, + overrideCol?: number, + overrideValue?: number +): void { + let totalWidth = 0; + let fixedWidth = true; + let nextDOM = colgroup.firstChild as HTMLElement; + const row = node.firstChild; + if (!row) return; + + for (let i = 0, col = 0; i < row.childCount; i++) { + const { colspan, colwidth } = row.child(i).attrs as CellAttrs; + for (let j = 0; j < colspan; j++, col++) { + const hasWidth = + overrideCol == col ? overrideValue : colwidth && colwidth[j]; + const cssWidth = hasWidth ? hasWidth + "px" : ""; + totalWidth += hasWidth || defaultCellMinWidth; + if (!hasWidth) fixedWidth = false; + if (!nextDOM) { + const col = document.createElement("col"); + col.style.width = cssWidth; + colgroup.appendChild(col); + } else { + if (nextDOM.style.width != cssWidth) { + nextDOM.style.width = cssWidth; + } + nextDOM = nextDOM.nextSibling as HTMLElement; + } + } + } + + while (nextDOM) { + const after = nextDOM.nextSibling; + nextDOM.parentNode?.removeChild(nextDOM); + nextDOM = after as HTMLElement; + } + + if (fixedWidth) { + table.style.width = totalWidth + "px"; + table.style.minWidth = ""; + } else { + table.style.width = ""; + table.style.minWidth = totalWidth + "px"; + } +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/util.ts b/packages/editor/src/extensions/table/prosemirror-tables/util.ts new file mode 100644 index 000000000..3372a97b4 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/util.ts @@ -0,0 +1,218 @@ +// Various helper function for working with tables + +import { EditorState, NodeSelection, PluginKey } from "prosemirror-state"; + +import { Attrs, Node, ResolvedPos } from "prosemirror-model"; +import { CellSelection } from "./cellselection.js"; +import { tableNodeTypes } from "./schema.js"; +import { Rect, TableMap } from "./tablemap.js"; + +/** + * @public + */ +export type MutableAttrs = Record; + +/** + * @public + */ +export interface CellAttrs { + colspan: number; + rowspan: number; + colwidth: number[] | null; +} + +/** + * @public + */ +export const tableEditingKey = new PluginKey("selectingCells"); + +/** + * @public + */ +export function cellAround($pos: ResolvedPos): ResolvedPos | null { + for (let d = $pos.depth - 1; d > 0; d--) + if ($pos.node(d).type.spec.tableRole == "row") + return $pos.node(0).resolve($pos.before(d + 1)); + return null; +} + +export function cellWrapping($pos: ResolvedPos): null | Node { + for (let d = $pos.depth; d > 0; d--) { + // Sometimes the cell can be in the same depth. + const role = $pos.node(d).type.spec.tableRole; + if (role === "cell" || role === "header_cell") return $pos.node(d); + } + return null; +} + +/** + * @public + */ +export function isInTable(state: EditorState): boolean { + const $head = state.selection.$head; + for (let d = $head.depth; d > 0; d--) + if ($head.node(d).type.spec.tableRole == "row") return true; + return false; +} + +/** + * @internal + */ +export function selectionCell(state: EditorState): ResolvedPos { + const sel = state.selection as CellSelection | NodeSelection; + if ("$anchorCell" in sel && sel.$anchorCell) { + return sel.$anchorCell.pos > sel.$headCell.pos + ? sel.$anchorCell + : sel.$headCell; + } else if ( + "node" in sel && + sel.node && + sel.node.type.spec.tableRole == "cell" + ) { + return sel.$anchor; + } + const $cell = cellAround(sel.$head) || cellNear(sel.$head); + if ($cell) { + return $cell; + } + throw new RangeError(`No cell found around position ${sel.head}`); +} + +/** + * @public + */ +export function cellNear($pos: ResolvedPos): ResolvedPos | undefined { + for ( + let after = $pos.nodeAfter, pos = $pos.pos; + after; + after = after.firstChild, pos++ + ) { + const role = after.type.spec.tableRole; + if (role == "cell" || role == "header_cell") return $pos.doc.resolve(pos); + } + for ( + let before = $pos.nodeBefore, pos = $pos.pos; + before; + before = before.lastChild, pos-- + ) { + const role = before.type.spec.tableRole; + if (role == "cell" || role == "header_cell") + return $pos.doc.resolve(pos - before.nodeSize); + } +} + +/** + * @public + */ +export function pointsAtCell($pos: ResolvedPos): boolean { + return $pos.parent.type.spec.tableRole == "row" && !!$pos.nodeAfter; +} + +/** + * @public + */ +export function moveCellForward($pos: ResolvedPos): ResolvedPos { + return $pos.node(0).resolve($pos.pos + $pos.nodeAfter!.nodeSize); +} + +/** + * @internal + */ +export function inSameTable($cellA: ResolvedPos, $cellB: ResolvedPos): boolean { + return ( + $cellA.depth == $cellB.depth && + $cellA.pos >= $cellB.start(-1) && + $cellA.pos <= $cellB.end(-1) + ); +} + +/** + * @public + */ +export function findCell($pos: ResolvedPos): Rect { + return TableMap.get($pos.node(-1)).findCell($pos.pos - $pos.start(-1)); +} + +/** + * @public + */ +export function colCount($pos: ResolvedPos): number { + return TableMap.get($pos.node(-1)).colCount($pos.pos - $pos.start(-1)); +} + +/** + * @public + */ +export function nextCell( + $pos: ResolvedPos, + axis: "horiz" | "vert", + dir: number +): ResolvedPos | null { + const table = $pos.node(-1); + const map = TableMap.get(table); + const tableStart = $pos.start(-1); + + const moved = map.nextCell($pos.pos - tableStart, axis, dir); + return moved == null ? null : $pos.node(0).resolve(tableStart + moved); +} + +/** + * @public + */ +export function removeColSpan(attrs: CellAttrs, pos: number, n = 1): CellAttrs { + const result: CellAttrs = { ...attrs, colspan: attrs.colspan - n }; + + if (result.colwidth) { + result.colwidth = result.colwidth.slice(); + result.colwidth.splice(pos, n); + if (!result.colwidth.some((w) => w > 0)) result.colwidth = null; + } + return result; +} + +/** + * @public + */ +export function addColSpan(attrs: CellAttrs, pos: number, n = 1): Attrs { + const result = { ...attrs, colspan: attrs.colspan + n }; + if (result.colwidth) { + result.colwidth = result.colwidth.slice(); + for (let i = 0; i < n; i++) result.colwidth.splice(pos, 0, 0); + } + return result; +} + +/** + * @public + */ +export function columnIsHeader( + map: TableMap, + table: Node, + col: number +): boolean { + const headerCell = tableNodeTypes(table.type.schema).header_cell; + for (let row = 0; row < map.height; row++) + if (table.nodeAt(map.map[col + row * map.width])!.type != headerCell) + return false; + return true; +} + +export function getClientX(event: MouseEvent | TouchEvent): number | null { + return event instanceof MouseEvent + ? event.clientX + : event.touches.length > 0 + ? event.touches[0].clientX + : event.changedTouches.length > 0 + ? event.changedTouches[0].clientX + : null; +} + +export function getClientY(event: MouseEvent | TouchEvent): number | null { + return event instanceof MouseEvent + ? event.clientY + : event.touches.length > 0 + ? event.touches[0].clientY + : event.changedTouches.length > 0 + ? event.changedTouches[0].clientY + : null; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/convert.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/convert.ts new file mode 100644 index 000000000..96b62b1c9 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/convert.ts @@ -0,0 +1,116 @@ +import type { Node } from "prosemirror-model"; +import { TableMap } from "../tablemap.js"; + +/** + * This function will transform the table node into a matrix of rows and columns + * respecting merged cells, for example this table: + * + * ``` + * ┌──────┬──────┬─────────────┐ + * │ A1 │ B1 │ C1 │ + * ├──────┼──────┴──────┬──────┤ + * │ A2 │ B2 │ │ + * ├──────┼─────────────┤ D1 │ + * │ A3 │ B3 │ C3 │ │ + * └──────┴──────┴──────┴──────┘ + * ``` + * + * will be converted to the below: + * + * ```javascript + * [ + * [A1, B1, C1, null], + * [A2, B2, null, D1], + * [A3, B3, C3, null], + * ] + * ``` + * @internal + */ +export function convertTableNodeToArrayOfRows( + tableNode: Node +): (Node | null)[][] { + const map = TableMap.get(tableNode); + const rows: (Node | null)[][] = []; + const rowCount = map.height; + const colCount = map.width; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const row: (Node | null)[] = []; + for (let colIndex = 0; colIndex < colCount; colIndex++) { + const cellIndex = rowIndex * colCount + colIndex; + const cellPos = map.map[cellIndex]; + if (rowIndex > 0) { + const topCellIndex = cellIndex - colCount; + const topCellPos = map.map[topCellIndex]; + if (cellPos === topCellPos) { + row.push(null); + continue; + } + } + if (colIndex > 0) { + const leftCellIndex = cellIndex - 1; + const leftCellPos = map.map[leftCellIndex]; + if (cellPos === leftCellPos) { + row.push(null); + continue; + } + } + row.push(tableNode.nodeAt(cellPos)); + } + rows.push(row); + } + + return rows; +} + +/** + * Convert an array of rows to a table node. + * + * @internal + */ +export function convertArrayOfRowsToTableNode( + tableNode: Node, + arrayOfNodes: (Node | null)[][] +): Node { + const newRows: Node[] = []; + const map = TableMap.get(tableNode); + const rowCount = map.height; + const colCount = map.width; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const oldRow: Node = tableNode.child(rowIndex); + const newCells: Node[] = []; + + for (let colIndex = 0; colIndex < colCount; colIndex++) { + const cell = arrayOfNodes[rowIndex][colIndex]; + if (!cell) { + continue; + } + + const cellPos = map.map[rowIndex * map.width + colIndex]; + const oldCell = tableNode.nodeAt(cellPos); + if (!oldCell) { + continue; + } + + const newCell = oldCell.type.createChecked( + cell.attrs, + cell.content, + cell.marks + ); + newCells.push(newCell); + } + + const newRow = oldRow.type.createChecked( + oldRow.attrs, + newCells, + oldRow.marks + ); + newRows.push(newRow); + } + + const newTable = tableNode.type.createChecked( + tableNode.attrs, + newRows, + tableNode.marks + ); + return newTable; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/get-cells.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/get-cells.ts new file mode 100644 index 000000000..3476a6153 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/get-cells.ts @@ -0,0 +1,72 @@ +import type { Selection } from "prosemirror-state"; + +import { TableMap } from "../tablemap.js"; +import { FindNodeResult, findTable } from "./query.js"; + +/** + * Returns an array of cells in a column at the specified column index. + * + * @internal + */ +export function getCellsInColumn( + columnIndex: number, + selection: Selection +): FindNodeResult[] | undefined { + const table = findTable(selection.$from); + if (!table) { + return; + } + + const map = TableMap.get(table.node); + + if (columnIndex < 0 || columnIndex > map.width - 1) { + return; + } + + const cells = map.cellsInRect({ + left: columnIndex, + right: columnIndex + 1, + top: 0, + bottom: map.height + }); + + return cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos)!; + const pos = nodePos + table.start; + return { pos, start: pos + 1, node, depth: table.depth + 2 }; + }); +} + +/** + * Returns an array of cells in a row at the specified row index. + * + * @internal + */ +export function getCellsInRow( + rowIndex: number, + selection: Selection +): FindNodeResult[] | undefined { + const table = findTable(selection.$from); + if (!table) { + return; + } + + const map = TableMap.get(table.node); + + if (rowIndex < 0 || rowIndex > map.height - 1) { + return; + } + + const cells = map.cellsInRect({ + left: 0, + right: map.width, + top: rowIndex, + bottom: rowIndex + 1 + }); + + return cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos)!; + const pos = nodePos + table.start; + return { pos, start: pos + 1, node, depth: table.depth + 2 }; + }); +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/move-column.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-column.ts new file mode 100644 index 000000000..f0bcaaa69 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-column.ts @@ -0,0 +1,88 @@ +import type { Node } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; + +import { + convertArrayOfRowsToTableNode, + convertTableNodeToArrayOfRows +} from "./convert.js"; +import { getSelectionRangeInColumn } from "./selection-range.js"; +import { moveRowInArrayOfRows } from "./move-row-in-array-of-rows.js"; +import { findTable } from "./query.js"; +import { transpose } from "./transpose.js"; +import { TableMap } from "../tablemap.js"; +import { CellSelection } from "../cellselection.js"; + +/** + * Parameters for moving a column in a table. + * + * @internal + */ +export interface MoveColumnParams { + tr: Transaction; + originIndex: number; + targetIndex: number; + select: boolean; + pos: number; +} + +/** + * Move a column from index `origin` to index `target`. + * + * @internal + */ +export function moveColumn(moveColParams: MoveColumnParams): boolean { + const { tr, originIndex, targetIndex, select, pos } = moveColParams; + const $pos = tr.doc.resolve(pos); + const table = findTable($pos); + if (!table) return false; + + const indexesOriginColumn = getSelectionRangeInColumn( + tr, + originIndex + )?.indexes; + const indexesTargetColumn = getSelectionRangeInColumn( + tr, + targetIndex + )?.indexes; + + if (!indexesOriginColumn || !indexesTargetColumn) return false; + + if (indexesOriginColumn.includes(targetIndex)) return false; + + const newTable = moveTableColumn( + table.node, + indexesOriginColumn, + indexesTargetColumn, + 0 + ); + + tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTable); + + if (!select) return true; + + const map = TableMap.get(newTable); + const start = table.start; + const index = targetIndex; + const lastCell = map.positionAt(map.height - 1, index, newTable); + const $lastCell = tr.doc.resolve(start + lastCell); + + const firstCell = map.positionAt(0, index, newTable); + const $firstCell = tr.doc.resolve(start + firstCell); + + tr.setSelection(CellSelection.colSelection($lastCell, $firstCell)); + return true; +} + +function moveTableColumn( + table: Node, + indexesOrigin: number[], + indexesTarget: number[], + direction: -1 | 1 | 0 +) { + let rows = transpose(convertTableNodeToArrayOfRows(table)); + + rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction); + rows = transpose(rows); + + return convertArrayOfRowsToTableNode(table, rows); +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row-in-array-of-rows.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row-in-array-of-rows.ts new file mode 100644 index 000000000..fd05d0c59 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row-in-array-of-rows.ts @@ -0,0 +1,31 @@ +/** + * Move a row in an array of rows. + * + * @internal + */ +export function moveRowInArrayOfRows( + rows: T[], + indexesOrigin: number[], + indexesTarget: number[], + directionOverride: -1 | 1 | 0, +): T[] { + const direction = indexesOrigin[0] > indexesTarget[0] ? -1 : 1; + + const rowsExtracted = rows.splice(indexesOrigin[0], indexesOrigin.length); + const positionOffset = rowsExtracted.length % 2 === 0 ? 1 : 0; + let target: number; + + if (directionOverride === -1 && direction === 1) { + target = indexesTarget[0] - 1; + } else if (directionOverride === 1 && direction === -1) { + target = indexesTarget[indexesTarget.length - 1] - positionOffset + 1; + } else { + target = + direction === -1 + ? indexesTarget[0] + : indexesTarget[indexesTarget.length - 1] - positionOffset; + } + + rows.splice(target, 0, ...rowsExtracted); + return rows; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row.ts new file mode 100644 index 000000000..86a015731 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/move-row.ts @@ -0,0 +1,80 @@ +import type { Node } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; + +import { + convertArrayOfRowsToTableNode, + convertTableNodeToArrayOfRows +} from "./convert.js"; +import { getSelectionRangeInRow } from "./selection-range.js"; +import { moveRowInArrayOfRows } from "./move-row-in-array-of-rows.js"; +import { findTable } from "./query.js"; +import { TableMap } from "../tablemap.js"; +import { CellSelection } from "../cellselection.js"; + +/** + * Parameters for moving a row in a table. + * + * @internal + */ +export interface MoveRowParams { + tr: Transaction; + originIndex: number; + targetIndex: number; + select: boolean; + pos: number; +} + +/** + * Move a row from index `origin` to index `target`. + * + * @internal + */ +export function moveRow(moveRowParams: MoveRowParams): boolean { + const { tr, originIndex, targetIndex, select, pos } = moveRowParams; + const $pos = tr.doc.resolve(pos); + const table = findTable($pos); + if (!table) return false; + + const indexesOriginRow = getSelectionRangeInRow(tr, originIndex)?.indexes; + const indexesTargetRow = getSelectionRangeInRow(tr, targetIndex)?.indexes; + + if (!indexesOriginRow || !indexesTargetRow) return false; + + if (indexesOriginRow.includes(targetIndex)) return false; + + const newTable = moveTableRow( + table.node, + indexesOriginRow, + indexesTargetRow, + 0 + ); + + tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTable); + + if (!select) return true; + + const map = TableMap.get(newTable); + const start = table.start; + const index = targetIndex; + const lastCell = map.positionAt(index, map.width - 1, newTable); + const $lastCell = tr.doc.resolve(start + lastCell); + + const firstCell = map.positionAt(index, 0, newTable); + const $firstCell = tr.doc.resolve(start + firstCell); + + tr.setSelection(CellSelection.rowSelection($lastCell, $firstCell)); + return true; +} + +function moveTableRow( + table: Node, + indexesOrigin: number[], + indexesTarget: number[], + direction: -1 | 1 | 0 +) { + let rows = convertTableNodeToArrayOfRows(table); + + rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction); + + return convertArrayOfRowsToTableNode(table, rows); +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/query.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/query.ts new file mode 100644 index 000000000..8d404f11d --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/query.ts @@ -0,0 +1,117 @@ +import type { Node, ResolvedPos } from "prosemirror-model"; +import type { Selection } from "prosemirror-state"; +import { CellSelection } from "../cellselection.js"; +import { cellAround, cellNear, inSameTable } from "../util.js"; + +/** + * Checks if the given object is a `CellSelection` instance. + * + * @internal + */ +function isCellSelection(value: unknown): value is CellSelection { + return value instanceof CellSelection; +} + +/** + * Find the closest table node for a given position. + * + * @public + */ +export function findTable($pos: ResolvedPos): FindNodeResult | null { + return findParentNode((node) => node.type.spec.tableRole === "table", $pos); +} + +/** + * Try to find the anchor and head cell in the same table by using the given + * anchor and head as hit points, or fallback to the selection's anchor and + * head. + * + * @public + */ +export function findCellRange( + selection: Selection, + anchorHit?: number, + headHit?: number +): [ResolvedPos, ResolvedPos] | null { + if (anchorHit == null && headHit == null && isCellSelection(selection)) { + return [selection.$anchorCell, selection.$headCell]; + } + + const anchor: number = anchorHit ?? headHit ?? selection.anchor; + const head: number = headHit ?? anchorHit ?? selection.head; + + const doc = selection.$head.doc; + + const $anchorCell = findCellPos(doc, anchor); + const $headCell = findCellPos(doc, head); + + if ($anchorCell && $headCell && inSameTable($anchorCell, $headCell)) { + return [$anchorCell, $headCell]; + } + return null; +} + +/** + * Try to find a resolved pos of a cell by using the given pos as a hit point. + * + * @public + */ +export function findCellPos(doc: Node, pos: number): ResolvedPos | undefined { + const $pos = doc.resolve(pos); + return cellAround($pos) || cellNear($pos); +} + +/** + * Result of finding a parent node. + * + * @public + */ +export interface FindNodeResult { + /** + * The closest parent node that satisfies the predicate. + */ + node: Node; + + /** + * The position directly before the node. + */ + pos: number; + + /** + * The position at the start of the node. + */ + start: number; + + /** + * The depth of the node. + */ + depth: number; +} + +/** + * Find the closest parent node that satisfies the predicate. + * + * @internal + */ +function findParentNode( + /** + * The predicate to test the parent node. + */ + predicate: (node: Node) => boolean, + /** + * The position to start searching from. + */ + $pos: ResolvedPos +): FindNodeResult | null { + for (let depth = $pos.depth; depth >= 0; depth -= 1) { + const node = $pos.node(depth); + + if (predicate(node)) { + const pos = depth === 0 ? 0 : $pos.before(depth); + const start = $pos.start(depth); + return { node, pos, start, depth }; + } + } + + return null; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/selection-range.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/selection-range.ts new file mode 100644 index 000000000..b9536f4e8 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/selection-range.ts @@ -0,0 +1,191 @@ +import type { ResolvedPos } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; + +import { getCellsInColumn, getCellsInRow } from "./get-cells.js"; + +export type CellSelectionRange = { + $anchor: ResolvedPos; + $head: ResolvedPos; + // an array of column/row indexes + indexes: number[]; +}; + +/** + * Returns a range of rectangular selection spanning all merged cells around a + * column at index `columnIndex`. + * + * Original implementation from Atlassian (Apache License 2.0) + * + * https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-column.ts#editor/editor-tables/src/utils/get-selection-range-in-column.ts + * + * @internal + */ +export function getSelectionRangeInColumn( + tr: Transaction, + startColIndex: number, + endColIndex: number = startColIndex +): CellSelectionRange | undefined { + let startIndex = startColIndex; + let endIndex = endColIndex; + + // looking for selection start column (startIndex) + for (let i = startColIndex; i >= 0; i--) { + const cells = getCellsInColumn(i, tr.selection); + if (cells) { + cells.forEach((cell) => { + const maybeEndIndex = cell.node.attrs.colspan + i - 1; + if (maybeEndIndex >= startIndex) { + startIndex = i; + } + if (maybeEndIndex > endIndex) { + endIndex = maybeEndIndex; + } + }); + } + } + // looking for selection end column (endIndex) + for (let i = startColIndex; i <= endIndex; i++) { + const cells = getCellsInColumn(i, tr.selection); + if (cells) { + cells.forEach((cell) => { + const maybeEndIndex = cell.node.attrs.colspan + i - 1; + if (cell.node.attrs.colspan > 1 && maybeEndIndex > endIndex) { + endIndex = maybeEndIndex; + } + }); + } + } + + // filter out columns without cells (where all rows have colspan > 1 in the same column) + const indexes = []; + for (let i = startIndex; i <= endIndex; i++) { + const maybeCells = getCellsInColumn(i, tr.selection); + if (maybeCells && maybeCells.length > 0) { + indexes.push(i); + } + } + startIndex = indexes[0]; + endIndex = indexes[indexes.length - 1]; + + const firstSelectedColumnCells = getCellsInColumn(startIndex, tr.selection); + const firstRowCells = getCellsInRow(0, tr.selection); + if (!firstSelectedColumnCells || !firstRowCells) { + return; + } + + const $anchor = tr.doc.resolve( + firstSelectedColumnCells[firstSelectedColumnCells.length - 1].pos + ); + + let headCell; + for (let i = endIndex; i >= startIndex; i--) { + const columnCells = getCellsInColumn(i, tr.selection); + if (columnCells && columnCells.length > 0) { + for (let j = firstRowCells.length - 1; j >= 0; j--) { + if (firstRowCells[j].pos === columnCells[0].pos) { + headCell = columnCells[0]; + break; + } + } + if (headCell) { + break; + } + } + } + if (!headCell) { + return; + } + + const $head = tr.doc.resolve(headCell.pos); + return { $anchor, $head, indexes }; +} + +/** + * Returns a range of rectangular selection spanning all merged cells around a + * row at index `rowIndex`. + * + * Original implementation from Atlassian (Apache License 2.0) + * + * https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-row.ts#editor/editor-tables/src/utils/get-selection-range-in-row.ts + * + * @internal + */ +export function getSelectionRangeInRow( + tr: Transaction, + startRowIndex: number, + endRowIndex: number = startRowIndex +): CellSelectionRange | undefined { + let startIndex = startRowIndex; + let endIndex = endRowIndex; + + // looking for selection start row (startIndex) + for (let i = startRowIndex; i >= 0; i--) { + const cells = getCellsInRow(i, tr.selection); + if (cells) { + cells.forEach((cell) => { + const maybeEndIndex = cell.node.attrs.rowspan + i - 1; + if (maybeEndIndex >= startIndex) { + startIndex = i; + } + if (maybeEndIndex > endIndex) { + endIndex = maybeEndIndex; + } + }); + } + } + // looking for selection end row (endIndex) + for (let i = startRowIndex; i <= endIndex; i++) { + const cells = getCellsInRow(i, tr.selection); + if (cells) { + cells.forEach((cell) => { + const maybeEndIndex = cell.node.attrs.rowspan + i - 1; + if (cell.node.attrs.rowspan > 1 && maybeEndIndex > endIndex) { + endIndex = maybeEndIndex; + } + }); + } + } + + // filter out rows without cells (where all columns have rowspan > 1 in the same row) + const indexes = []; + for (let i = startIndex; i <= endIndex; i++) { + const maybeCells = getCellsInRow(i, tr.selection); + if (maybeCells && maybeCells.length > 0) { + indexes.push(i); + } + } + startIndex = indexes[0]; + endIndex = indexes[indexes.length - 1]; + + const firstSelectedRowCells = getCellsInRow(startIndex, tr.selection); + const firstColumnCells = getCellsInColumn(0, tr.selection); + if (!firstSelectedRowCells || !firstColumnCells) { + return; + } + + const $anchor = tr.doc.resolve( + firstSelectedRowCells[firstSelectedRowCells.length - 1].pos + ); + + let headCell; + for (let i = endIndex; i >= startIndex; i--) { + const rowCells = getCellsInRow(i, tr.selection); + if (rowCells && rowCells.length > 0) { + for (let j = firstColumnCells.length - 1; j >= 0; j--) { + if (firstColumnCells[j].pos === rowCells[0].pos) { + headCell = rowCells[0]; + break; + } + } + if (headCell) { + break; + } + } + } + if (!headCell) { + return; + } + + const $head = tr.doc.resolve(headCell.pos); + return { $anchor, $head, indexes }; +} diff --git a/packages/editor/src/extensions/table/prosemirror-tables/utils/transpose.ts b/packages/editor/src/extensions/table/prosemirror-tables/utils/transpose.ts new file mode 100644 index 000000000..189ce1890 --- /dev/null +++ b/packages/editor/src/extensions/table/prosemirror-tables/utils/transpose.ts @@ -0,0 +1,29 @@ +/** + * Transposes a 2D array by flipping columns to rows. + * + * Transposition is a familiar algebra concept where the matrix is flipped + * along its diagonal. For more details, see: + * https://en.wikipedia.org/wiki/Transpose + * + * @example + * ```javascript + * const arr = [ + * ['a1', 'a2', 'a3'], + * ['b1', 'b2', 'b3'], + * ['c1', 'c2', 'c3'], + * ['d1', 'd2', 'd3'], + * ]; + * + * const result = transpose(arr); + * result === [ + * ['a1', 'b1', 'c1', 'd1'], + * ['a2', 'b2', 'c2', 'd2'], + * ['a3', 'b3', 'c3', 'd3'], + * ] + * ``` + */ +export function transpose(array: T[][]): T[][] { + return array[0].map((_, i) => { + return array.map((column) => column[i]); + }); +} diff --git a/packages/editor/src/extensions/table/table.ts b/packages/editor/src/extensions/table/table.ts index a89b4ac1b..e344c10db 100644 --- a/packages/editor/src/extensions/table/table.ts +++ b/packages/editor/src/extensions/table/table.ts @@ -17,12 +17,465 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { Table as TiptapTable, TableOptions } from "@tiptap/extension-table"; -import { tableEditing, columnResizing, TableView } from "@tiptap/pm/tables"; +import { + callOrReturn, + getExtensionField, + mergeAttributes, + Node, + ParentConfig +} from "@tiptap/core"; +import { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { EditorView, NodeView } from "@tiptap/pm/view"; +import { createColGroup } from "./utilities/createColGroup.js"; +import { createTable } from "./utilities/createTable.js"; +import { deleteTableWhenAllCellsSelected } from "./utilities/deleteTableWhenAllCellsSelected.js"; +import { TableView } from "./TableView.js"; import { TableNodeView } from "./component.js"; -import { Plugin, PluginKey } from "prosemirror-state"; +import { + addColumnAfter, + addColumnBefore, + addRowAfter, + addRowBefore, + deleteColumn, + deleteRow, + deleteTable, + goToNextCell, + mergeCells, + setCellAttr, + splitCell, + toggleHeader, + toggleHeaderCell +} from "./prosemirror-tables/commands.js"; +import { fixTables } from "./prosemirror-tables/fixtables.js"; +import { CellSelection } from "./prosemirror-tables/cellselection.js"; +import { columnResizing } from "./prosemirror-tables/columnresizing.js"; +import { tableEditing } from "./prosemirror-tables/index.js"; + +export interface TableOptions { + /** + * HTML attributes for the table element. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; + + /** + * Enables the resizing of tables. + * @default false + * @example true + */ + resizable: boolean; + + /** + * The minimum width of a cell. + * @default 25 + * @example 50 + */ + cellMinWidth: number; + + showResizeHandleOnSelection: boolean; + + /** + * The node view to render the table. + * @default TableView + */ + View: + | (new ( + node: ProseMirrorNode, + cellMinWidth: number, + view: EditorView + ) => NodeView) + | null; + + /** + * Allow table node selection. + * @default false + * @example true + */ + allowTableNodeSelection: boolean; + + defaultCellAttrs: { colwidth?: number[] }; +} + +declare module "@tiptap/core" { + interface Commands { + table: { + /** + * Insert a table + * @param options The table attributes + * @returns True if the command was successful, otherwise false + * @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + */ + insertTable: (options?: { + rows?: number; + cols?: number; + withHeaderRow?: boolean; + }) => ReturnType; + + /** + * Add a column before the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.addColumnBefore() + */ + addColumnBefore: () => ReturnType; + + /** + * Add a column after the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.addColumnAfter() + */ + addColumnAfter: () => ReturnType; + + /** + * Delete the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteColumn() + */ + deleteColumn: () => ReturnType; + + /** + * Add a row before the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.addRowBefore() + */ + addRowBefore: () => ReturnType; + + /** + * Add a row after the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.addRowAfter() + */ + addRowAfter: () => ReturnType; + + /** + * Delete the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteRow() + */ + deleteRow: () => ReturnType; + + /** + * Delete the current table + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteTable() + */ + deleteTable: () => ReturnType; + + /** + * Merge the currently selected cells + * @returns True if the command was successful, otherwise false + * @example editor.commands.mergeCells() + */ + mergeCells: () => ReturnType; + + /** + * Split the currently selected cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.splitCell() + */ + splitCell: () => ReturnType; + + /** + * Toggle the header column + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderColumn() + */ + toggleHeaderColumn: () => ReturnType; + + /** + * Toggle the header row + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderRow() + */ + toggleHeaderRow: () => ReturnType; + + /** + * Toggle the header cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderCell() + */ + toggleHeaderCell: () => ReturnType; + + /** + * Merge or split the currently selected cells + * @returns True if the command was successful, otherwise false + * @example editor.commands.mergeOrSplit() + */ + mergeOrSplit: () => ReturnType; + + /** + * Set a cell attribute + * @param name The attribute name + * @param value The attribute value + * @returns True if the command was successful, otherwise false + * @example editor.commands.setCellAttribute('align', 'right') + */ + setCellAttribute: (name: string, value: any) => ReturnType; + + /** + * Moves the selection to the next cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.goToNextCell() + */ + goToNextCell: () => ReturnType; + + /** + * Moves the selection to the previous cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.goToPreviousCell() + */ + goToPreviousCell: () => ReturnType; + + /** + * Try to fix the table structure if necessary + * @returns True if the command was successful, otherwise false + * @example editor.commands.fixTables() + */ + fixTables: () => ReturnType; + + /** + * Set a cell selection inside the current table + * @param position The cell position + * @returns True if the command was successful, otherwise false + * @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 }) + */ + setCellSelection: (position: { + anchorCell: number; + headCell?: number; + }) => ReturnType; + }; + } + + interface NodeConfig { + /** + * A string or function to determine the role of the table. + * @default 'table' + * @example () => 'table' + */ + tableRole?: + | string + | ((this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>["tableRole"]; + }) => string); + } +} + +/** + * This extension allows you to create tables. + * @see https://www.tiptap.dev/api/nodes/table + */ +export const Table = Node.create({ + name: "table", + + // @ts-ignore + addOptions() { + return { + HTMLAttributes: {}, + resizable: false, + showResizeHandleOnSelection: false, + cellMinWidth: 25, + allowTableNodeSelection: false, + defaultCellAttrs: {} + }; + }, + + content: "tableRow+", + + tableRole: "table", + + isolating: true, + + group: "block", + + parseHTML() { + return [{ tag: "table" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + const { colgroup, tableWidth, tableMinWidth } = createColGroup( + node, + this.options.cellMinWidth + ); + + const table: DOMOutputSpec = [ + "table", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + style: tableWidth + ? `width: ${tableWidth}` + : `min-width: ${tableMinWidth}` + }), + colgroup, + ["tbody", 0] + ]; + + return table; + }, + + addCommands() { + return { + insertTable: + ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => + ({ tr, dispatch, editor }) => { + const node = createTable( + editor.schema, + rows, + cols, + withHeaderRow, + undefined, + this.options.defaultCellAttrs + ); + + if (dispatch) { + const offset = tr.selection.from + 1; + + tr.replaceSelectionWith(node) + .scrollIntoView() + .setSelection(TextSelection.near(tr.doc.resolve(offset))); + } + + return true; + }, + addColumnBefore: + () => + ({ state, dispatch }) => { + return addColumnBefore( + state, + dispatch, + this.options.defaultCellAttrs + ); + }, + addColumnAfter: + () => + ({ state, dispatch }) => { + return addColumnAfter(state, dispatch, this.options.defaultCellAttrs); + }, + deleteColumn: + () => + ({ state, dispatch }) => { + return deleteColumn(state, dispatch); + }, + addRowBefore: + () => + ({ state, dispatch }) => { + return addRowBefore(state, dispatch); + }, + addRowAfter: + () => + ({ state, dispatch }) => { + return addRowAfter(state, dispatch); + }, + deleteRow: + () => + ({ state, dispatch }) => { + return deleteRow(state, dispatch); + }, + deleteTable: + () => + ({ state, dispatch }) => { + return deleteTable(state, dispatch); + }, + mergeCells: + () => + ({ state, dispatch }) => { + return mergeCells(state, dispatch); + }, + splitCell: + () => + ({ state, dispatch }) => { + return splitCell(state, dispatch); + }, + toggleHeaderColumn: + () => + ({ state, dispatch }) => { + return toggleHeader("column")(state, dispatch); + }, + toggleHeaderRow: + () => + ({ state, dispatch }) => { + return toggleHeader("row")(state, dispatch); + }, + toggleHeaderCell: + () => + ({ state, dispatch }) => { + return toggleHeaderCell(state, dispatch); + }, + mergeOrSplit: + () => + ({ state, dispatch }) => { + if (mergeCells(state, dispatch)) { + return true; + } + + return splitCell(state, dispatch); + }, + setCellAttribute: + (name, value) => + ({ state, dispatch }) => { + return setCellAttr(name, value)(state, dispatch); + }, + goToNextCell: + () => + ({ state, dispatch }) => { + return goToNextCell(1)(state, dispatch); + }, + goToPreviousCell: + () => + ({ state, dispatch }) => { + return goToNextCell(-1)(state, dispatch); + }, + fixTables: + () => + ({ state, dispatch }) => { + if (dispatch) { + fixTables(state); + } + + return true; + }, + setCellSelection: + (position) => + ({ tr, dispatch }) => { + if (dispatch) { + const selection = CellSelection.create( + tr.doc, + position.anchorCell, + position.headCell + ); + + // @ts-ignore + tr.setSelection(selection); + } + + return true; + } + }; + }, + + addKeyboardShortcuts() { + return { + Tab: () => { + if (this.editor.commands.goToNextCell()) { + return true; + } + + if (!this.editor.can().addRowAfter()) { + return false; + } + + return this.editor.chain().addRowAfter().goToNextCell().run(); + }, + "Shift-Tab": () => this.editor.commands.goToPreviousCell(), + Backspace: deleteTableWhenAllCellsSelected, + "Mod-Backspace": deleteTableWhenAllCellsSelected, + Delete: deleteTableWhenAllCellsSelected, + "Mod-Delete": deleteTableWhenAllCellsSelected + }; + }, -export const Table = TiptapTable.extend({ addProseMirrorPlugins() { const isResizable = this.options.resizable && this.editor.isEditable; @@ -30,10 +483,10 @@ export const Table = TiptapTable.extend({ ...(isResizable ? [ columnResizing({ - handleWidth: this.options.handleWidth, cellMinWidth: this.options.cellMinWidth, View: TableNodeView(this.editor), - lastColumnResizable: this.options.lastColumnResizable + showResizeHandleOnSelection: + this.options.showResizeHandleOnSelection }) ] : [tiptapTableView(this.options.cellMinWidth)]), @@ -41,6 +494,20 @@ export const Table = TiptapTable.extend({ allowTableNodeSelection: this.options.allowTableNodeSelection }) ]; + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage + }; + + return { + tableRole: callOrReturn( + getExtensionField(extension, "tableRole", context) + ) + }; } }); diff --git a/packages/editor/src/extensions/table/utilities/createCell.ts b/packages/editor/src/extensions/table/utilities/createCell.ts new file mode 100644 index 000000000..8873d8acf --- /dev/null +++ b/packages/editor/src/extensions/table/utilities/createCell.ts @@ -0,0 +1,13 @@ +import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model"; + +export function createCell( + cellType: NodeType, + cellContent?: Fragment | ProsemirrorNode | Array, + defaultCellAttrs?: { colwidth?: number[] } +): ProsemirrorNode | null | undefined { + if (cellContent) { + return cellType.createChecked(defaultCellAttrs, cellContent); + } + + return cellType.createAndFill(defaultCellAttrs); +} diff --git a/packages/editor/src/extensions/table/utilities/createColGroup.ts b/packages/editor/src/extensions/table/utilities/createColGroup.ts new file mode 100644 index 000000000..2c4d305ab --- /dev/null +++ b/packages/editor/src/extensions/table/utilities/createColGroup.ts @@ -0,0 +1,50 @@ +import { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** + * Creates a colgroup element for a table node in ProseMirror. + * + * @param node - The ProseMirror node representing the table. + * @param cellMinWidth - The minimum width of a cell in the table. + * @param overrideCol - (Optional) The index of the column to override the width of. + * @param overrideValue - (Optional) The width value to use for the overridden column. + * @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table. + */ +export function createColGroup( + node: ProseMirrorNode, + cellMinWidth: number, + overrideCol?: number, + overrideValue?: any, +) { + let totalWidth = 0 + let fixedWidth = true + const cols: DOMOutputSpec[] = [] + const row = node.firstChild + + if (!row) { + return {} + } + + for (let i = 0, col = 0; i < row.childCount; i += 1) { + const { colspan, colwidth } = row.child(i).attrs + + for (let j = 0; j < colspan; j += 1, col += 1) { + const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j] + const cssWidth = hasWidth ? `${hasWidth}px` : '' + + totalWidth += hasWidth || cellMinWidth + + if (!hasWidth) { + fixedWidth = false + } + + cols.push(['col', cssWidth ? { style: `width: ${cssWidth}` } : {}]) + } + } + + const tableWidth = fixedWidth ? `${totalWidth}px` : '' + const tableMinWidth = fixedWidth ? '' : `${totalWidth}px` + + const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols] + + return { colgroup, tableWidth, tableMinWidth } +} diff --git a/packages/editor/src/extensions/table/utilities/createTable.ts b/packages/editor/src/extensions/table/utilities/createTable.ts new file mode 100644 index 000000000..6b6024c28 --- /dev/null +++ b/packages/editor/src/extensions/table/utilities/createTable.ts @@ -0,0 +1,50 @@ +import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"; + +import { createCell } from "./createCell.js"; +import { getTableNodeTypes } from "./getTableNodeTypes.js"; + +export function createTable( + schema: Schema, + rowsCount: number, + colsCount: number, + withHeaderRow: boolean, + cellContent?: Fragment | ProsemirrorNode | Array, + defaultCellAttrs?: { colwidth?: number[] } +): ProsemirrorNode { + const types = getTableNodeTypes(schema); + const headerCells: ProsemirrorNode[] = []; + const cells: ProsemirrorNode[] = []; + + for (let index = 0; index < colsCount; index += 1) { + const cell = createCell(types.cell, cellContent, defaultCellAttrs); + + if (cell) { + cells.push(cell); + } + + if (withHeaderRow) { + const headerCell = createCell( + types.header_cell, + cellContent, + defaultCellAttrs + ); + + if (headerCell) { + headerCells.push(headerCell); + } + } + } + + const rows: ProsemirrorNode[] = []; + + for (let index = 0; index < rowsCount; index += 1) { + rows.push( + types.row.createChecked( + null, + withHeaderRow && index === 0 ? headerCells : cells + ) + ); + } + + return types.table.createChecked(null, rows); +} diff --git a/packages/editor/src/extensions/table/utilities/deleteTableWhenAllCellsSelected.ts b/packages/editor/src/extensions/table/utilities/deleteTableWhenAllCellsSelected.ts new file mode 100644 index 000000000..c054f4a9b --- /dev/null +++ b/packages/editor/src/extensions/table/utilities/deleteTableWhenAllCellsSelected.ts @@ -0,0 +1,36 @@ +import { findParentNodeClosestToPos, KeyboardShortcutCommand } from '@tiptap/core' + +import { isCellSelection } from './isCellSelection.js' + +export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => { + const { selection } = editor.state + + if (!isCellSelection(selection)) { + return false + } + + let cellCount = 0 + const table = findParentNodeClosestToPos(selection.ranges[0].$from, node => { + return node.type.name === 'table' + }) + + table?.node.descendants(node => { + if (node.type.name === 'table') { + return false + } + + if (['tableCell', 'tableHeader'].includes(node.type.name)) { + cellCount += 1 + } + }) + + const allCellsSelected = cellCount === selection.ranges.length + + if (!allCellsSelected) { + return false + } + + editor.commands.deleteTable() + + return true +} diff --git a/packages/editor/src/extensions/table/utilities/getTableNodeTypes.ts b/packages/editor/src/extensions/table/utilities/getTableNodeTypes.ts new file mode 100644 index 000000000..aafee1cd4 --- /dev/null +++ b/packages/editor/src/extensions/table/utilities/getTableNodeTypes.ts @@ -0,0 +1,21 @@ +import { NodeType, Schema } from '@tiptap/pm/model' + +export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } { + if (schema.cached.tableNodeTypes) { + return schema.cached.tableNodeTypes + } + + const roles: { [key: string]: NodeType } = {} + + Object.keys(schema.nodes).forEach(type => { + const nodeType = schema.nodes[type] + + if (nodeType.spec.tableRole) { + roles[nodeType.spec.tableRole] = nodeType + } + }) + + schema.cached.tableNodeTypes = roles + + return roles +} diff --git a/packages/editor/src/extensions/table/utilities/isCellSelection.ts b/packages/editor/src/extensions/table/utilities/isCellSelection.ts new file mode 100644 index 000000000..1bdedf69c --- /dev/null +++ b/packages/editor/src/extensions/table/utilities/isCellSelection.ts @@ -0,0 +1,5 @@ +import { CellSelection } from "../prosemirror-tables/cellselection.js"; + +export function isCellSelection(value: unknown): value is CellSelection { + return value instanceof CellSelection; +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index d85687f73..1c16a8286 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -285,7 +285,11 @@ const useTiptap = ( Table.configure({ resizable: true, allowTableNodeSelection: true, - cellMinWidth: 50 + cellMinWidth: 20, + showResizeHandleOnSelection: isMobile, + defaultCellAttrs: { + colwidth: [100] + } }), Clipboard, TableRow, @@ -385,6 +389,7 @@ const useTiptap = ( parseOptions: { preserveWhitespace: true } }), [ + isMobile, previewAttachment, downloadAttachment, openAttachmentPicker, diff --git a/packages/editor/src/toolbar/tools/table.tsx b/packages/editor/src/toolbar/tools/table.tsx index 594bc7d02..5c7c22673 100644 --- a/packages/editor/src/toolbar/tools/table.tsx +++ b/packages/editor/src/toolbar/tools/table.tsx @@ -41,8 +41,6 @@ import { useToolbarLocation } from "../stores/toolbar-store.js"; import { showPopup } from "../../components/popup-presenter/index.js"; import { useRefValue } from "../../hooks/use-ref-value.js"; import { strings } from "@notesnook/intl"; -import { selectedRect } from "@tiptap/pm/tables"; -import { TextSelection } from "@tiptap/pm/state"; export function TableSettings(props: ToolProps) { const { editor } = props; @@ -90,6 +88,7 @@ export function RowProperties(props: ToolProps) { setIsMenuOpen(true)} @@ -134,6 +133,7 @@ export function ColumnProperties(props: ToolProps) { setIsMenuOpen(true)} @@ -184,6 +184,7 @@ export function TableProperties(props: ToolProps) { setIsMenuOpen(true)} diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css index cec91095b..59234251b 100644 --- a/packages/editor/styles/styles.css +++ b/packages/editor/styles/styles.css @@ -228,7 +228,7 @@ img.ProseMirror-separator { .ProseMirror table { border-collapse: collapse; margin: 0; - overflow: hidden; + overflow-y: hidden; table-layout: fixed; } @@ -242,10 +242,6 @@ img.ProseMirror-separator { vertical-align: top; } -.ProseMirror table .selectedCell { - border-color: var(--accent); -} - .ProseMirror table td > *, .ProseMirror table th > * { margin-bottom: 0; @@ -258,8 +254,9 @@ img.ProseMirror-separator { } .ProseMirror table .selectedCell:after { - background: var(--accent); content: ""; + background: var(--shade); + box-shadow: inset 0px 0px 0px 1px var(--accent); left: 0; right: 0; top: 0; @@ -267,27 +264,62 @@ img.ProseMirror-separator { pointer-events: none; position: absolute; z-index: 2; - opacity: 0.2; } .ProseMirror table .column-resize-handle { - background-color: var(--accent); + background-color: transparent; bottom: -2px; position: absolute; - right: 0px; - pointer-events: none; + right: -5px; top: 0; - width: 5px; + width: 10px; + + cursor: ew-resize; + cursor: col-resize; + z-index: 999; +} + +.ProseMirror table .column-resize-handle.active::before { + position: absolute; + content: ""; + top: calc(50% - 11px); + left: -4px; + + width: 16px; + height: 18px; + + background-color: var(--background); + border: 1px solid var(--border); + border-radius: 5px; + + cursor: ew-resize; + cursor: col-resize; +} + +.ProseMirror table .column-resize-handle.active::after { + position: absolute; + content: ""; + top: calc(50% - 10px); + left: -4px; + + background-size: 16px; + background-color: var(--icon); + width: 18px; + height: 18px; + mask: url(https://api.iconify.design/ic:round-drag-indicator.svg) no-repeat 50% 50%; + mask-size: cover; + + cursor: ew-resize; + cursor: col-resize; } .ProseMirror table p { margin: 0; } - +/* .resize-cursor { - cursor: ew-resize; - cursor: col-resize; -} + +} */ .drop-cursor { background-color: var(--paragraph, var(--nn_primary_paragraph)) !important;