diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx index e27290fe7b..9f28eb1a90 100644 --- a/app/editor/components/SelectionToolbar.tsx +++ b/app/editor/components/SelectionToolbar.tsx @@ -1,5 +1,6 @@ import some from "lodash/some"; import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; import * as React from "react"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; import { getMarkRange } from "@shared/editor/queries/getMarkRange"; @@ -22,6 +23,7 @@ import getImageMenuItems from "../menus/image"; import getNoticeMenuItems from "../menus/notice"; import getReadOnlyMenuItems from "../menus/readOnly"; import getTableMenuItems from "../menus/table"; +import getTableCellMenuItems from "../menus/tableCell"; import getTableColMenuItems from "../menus/tableCol"; import getTableRowMenuItems from "../menus/tableRow"; import { useEditor } from "./EditorContext"; @@ -183,6 +185,7 @@ export default function SelectionToolbar(props: Props) { const colIndex = getColumnIndex(state); const rowIndex = getRowIndex(state); const isTableSelection = colIndex !== undefined && rowIndex !== undefined; + const isCellSelection = selection instanceof CellSelection; const link = getMarkRange(selection.$from, state.schema.marks.link); const isImageSelection = selection instanceof NodeSelection && selection.node.type.name === "image"; @@ -202,6 +205,8 @@ export default function SelectionToolbar(props: Props) { items = getTableColMenuItems(state, colIndex, rtl, dictionary); } else if (rowIndex !== undefined) { items = getTableRowMenuItems(state, rowIndex, dictionary); + } else if (isCellSelection) { + items = getTableCellMenuItems(state, dictionary); } else if (isImageSelection) { items = readOnly ? [] : getImageMenuItems(state, dictionary); } else if (isAttachmentSelection) { diff --git a/app/editor/menus/tableCell.tsx b/app/editor/menus/tableCell.tsx new file mode 100644 index 0000000000..d55b321002 --- /dev/null +++ b/app/editor/menus/tableCell.tsx @@ -0,0 +1,36 @@ +import { TableSplitCellsIcon, TableMergeCellsIcon } from "outline-icons"; +import { EditorState } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; +import { + isMergedCellSelection, + isMultipleCellSelection, +} from "@shared/editor/queries/table"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +export default function tableCellMenuItems( + state: EditorState, + dictionary: Dictionary +): MenuItem[] { + const { selection } = state; + + // Only show menu items if we have a CellSelection + if (!(selection instanceof CellSelection)) { + return []; + } + + return [ + { + name: "mergeCells", + label: dictionary.mergeCells, + icon: , + visible: isMultipleCellSelection(state), + }, + { + name: "splitCell", + label: dictionary.splitCell, + icon: , + visible: isMergedCellSelection(state), + }, + ]; +} diff --git a/app/editor/menus/tableCol.tsx b/app/editor/menus/tableCol.tsx index 25a31aefc5..ff7ead10de 100644 --- a/app/editor/menus/tableCol.tsx +++ b/app/editor/menus/tableCol.tsx @@ -8,10 +8,17 @@ import { ArrowIcon, MoreIcon, TableHeaderColumnIcon, + TableMergeCellsIcon, + TableSplitCellsIcon, } from "outline-icons"; import { EditorState } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; import styled from "styled-components"; import { isNodeActive } from "@shared/editor/queries/isNodeActive"; +import { + isMergedCellSelection, + isMultipleCellSelection, +} from "@shared/editor/queries/table"; import { MenuItem } from "@shared/editor/types"; import { Dictionary } from "~/hooks/useDictionary"; @@ -21,7 +28,11 @@ export default function tableColMenuItems( rtl: boolean, dictionary: Dictionary ): MenuItem[] { - const { schema } = state; + const { schema, selection } = state; + + if (!(selection instanceof CellSelection)) { + return []; + } return [ { @@ -96,6 +107,21 @@ export default function tableColMenuItems( icon: , attrs: { index }, }, + { + name: "mergeCells", + label: dictionary.mergeCells, + icon: , + visible: isMultipleCellSelection(state), + }, + { + name: "splitCell", + label: dictionary.splitCell, + icon: , + visible: isMergedCellSelection(state), + }, + { + name: "separator", + }, { name: "deleteColumn", dangerous: true, diff --git a/app/editor/menus/tableRow.tsx b/app/editor/menus/tableRow.tsx index 6c48a9c2db..41cfa8720c 100644 --- a/app/editor/menus/tableRow.tsx +++ b/app/editor/menus/tableRow.tsx @@ -4,8 +4,15 @@ import { InsertBelowIcon, MoreIcon, TableHeaderRowIcon, + TableSplitCellsIcon, + TableMergeCellsIcon, } from "outline-icons"; import { EditorState } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; +import { + isMergedCellSelection, + isMultipleCellSelection, +} from "@shared/editor/queries/table"; import { MenuItem } from "@shared/editor/types"; import { Dictionary } from "~/hooks/useDictionary"; @@ -14,6 +21,11 @@ export default function tableRowMenuItems( index: number, dictionary: Dictionary ): MenuItem[] { + const { selection } = state; + if (!(selection instanceof CellSelection)) { + return []; + } + return [ { icon: , @@ -36,6 +48,21 @@ export default function tableRowMenuItems( icon: , attrs: { index }, }, + { + name: "mergeCells", + label: dictionary.mergeCells, + icon: , + visible: isMultipleCellSelection(state), + }, + { + name: "splitCell", + label: dictionary.splitCell, + icon: , + visible: isMergedCellSelection(state), + }, + { + name: "separator", + }, { name: "deleteRow", label: dictionary.deleteRow, diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index 7493659bc4..804abdb3a7 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -87,6 +87,8 @@ export default function useDictionary() { toggleHeader: t("Toggle header"), mathInline: t("Math inline (LaTeX)"), mathBlock: t("Math block (LaTeX)"), + mergeCells: t("Merge cells"), + splitCell: t("Split cell"), tip: t("Tip"), tipNotice: t("Tip notice"), warning: t("Warning"), diff --git a/package.json b/package.json index 8e45d9ce84..7e9b1b39a0 100644 --- a/package.json +++ b/package.json @@ -173,7 +173,7 @@ "node-fetch": "2.7.0", "nodemailer": "^6.10.0", "octokit": "^3.2.1", - "outline-icons": "^3.10.0", + "outline-icons": "^3.12.0", "oy-vey": "^0.12.1", "passport": "^0.7.0", "passport-google-oauth2": "^0.2.0", diff --git a/shared/editor/commands/table.ts b/shared/editor/commands/table.ts index 0192bdc271..7fc08eda42 100644 --- a/shared/editor/commands/table.ts +++ b/shared/editor/commands/table.ts @@ -17,6 +17,8 @@ import { deleteRow, deleteColumn, deleteTable, + mergeCells, + splitCell, } from "prosemirror-tables"; import { ProsemirrorHelper } from "../../utils/ProsemirrorHelper"; import { CSVHelper } from "../../utils/csv"; @@ -597,3 +599,21 @@ export function deleteCellSelection( } return false; } + +/** + * A command that splits a cell and collapses the selection. + * + * @returns The command + */ +export function splitCellAndCollapse(): Command { + return chainTransactions(splitCell, collapseSelection()); +} + +/** + * A command that merges selected cells and collapses the selection. + * + * @returns The command + */ +export function mergeCellsAndCollapse(): Command { + return chainTransactions(mergeCells, collapseSelection()); +} diff --git a/shared/editor/nodes/Table.ts b/shared/editor/nodes/Table.ts index aefbe753ae..0abbea450d 100644 --- a/shared/editor/nodes/Table.ts +++ b/shared/editor/nodes/Table.ts @@ -28,6 +28,8 @@ import { moveOutOfTable, createTableInner, deleteTableIfSelected, + splitCellAndCollapse, + mergeCellsAndCollapse, } from "../commands/table"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; import { FixTablesPlugin } from "../plugins/FixTables"; @@ -89,6 +91,8 @@ export default class Table extends Node { exportTable, toggleHeaderColumn: () => toggleHeader("column"), toggleHeaderRow: () => toggleHeader("row"), + mergeCells: () => mergeCellsAndCollapse(), + splitCell: () => splitCellAndCollapse(), }; } diff --git a/shared/editor/plugins/FixTables.ts b/shared/editor/plugins/FixTables.ts index 5e983e12b1..e88c679c23 100644 --- a/shared/editor/plugins/FixTables.ts +++ b/shared/editor/plugins/FixTables.ts @@ -5,16 +5,19 @@ import { changedDescendants } from "../lib/changedDescendants"; import { getCellsInColumn } from "../queries/table"; /** - * A ProseMirror plugin that fixes the last column in a table to ensure it fills the remaining width. + * A ProseMirror plugin that fixes various ways that tables can end up in an incorrect state: + * + * - The last column in a table should fill the remaining width + * - Header cells should only exist in the first row or column */ export class FixTablesPlugin extends Plugin { constructor() { super({ appendTransaction: (_transactions, oldState, state) => { let tr: Transaction | undefined; - const check = (node: Node) => { + const check = (node: Node, pos: number) => { if (node.type.spec.tableRole === "table") { - tr = this.fixTable(state, node, tr); + tr = this.fixTable(state, node, pos, tr); } }; if (!oldState) { @@ -30,6 +33,7 @@ export class FixTablesPlugin extends Plugin { private fixTable( state: EditorState, table: Node, + pos: number, tr: Transaction | undefined ): Transaction | undefined { let fixed = false; @@ -41,11 +45,11 @@ export class FixTablesPlugin extends Plugin { // If the table has only one column, remove the colwidth attribute on all cells if (map.width === 1) { const cells = getCellsInColumn(0)(state); - cells.forEach((pos) => { + cells.forEach((cellPos) => { const node = state.doc.nodeAt(pos); if (node?.attrs.colspan) { fixed = true; - tr = tr!.setNodeMarkup(pos, undefined, { + tr = tr!.setNodeMarkup(cellPos, undefined, { ...node?.attrs, colwidth: null, }); @@ -53,6 +57,28 @@ export class FixTablesPlugin extends Plugin { }); } + // If the table has header cells that are not in the first row or column + // then convert them to regular cells + const cellPositions = map.cellsInRect({ + left: 1, + top: 1, + right: map.width, + bottom: map.height, + }); + + for (let i = 0; i < cellPositions.length; i++) { + const cellPos = cellPositions[i]; + const cell = table.nodeAt(cellPos); + if (cell && cell.type === state.schema.nodes.th) { + fixed = true; + tr = tr!.setNodeMarkup( + cellPos + pos + 1, + state.schema.nodes.td, + cell.attrs + ); + } + } + return fixed ? tr : undefined; } } diff --git a/shared/editor/queries/table.ts b/shared/editor/queries/table.ts index 7bad794269..a0bb4efe8c 100644 --- a/shared/editor/queries/table.ts +++ b/shared/editor/queries/table.ts @@ -174,3 +174,46 @@ export function isTableSelected(state: EditorState): boolean { return false; } + +/** + * Check if multiple cells are selected in the editor. + * + * @param state The editor state + * @returns Boolean indicating if multiple cells are selected + */ +export function isMultipleCellSelection(state: EditorState): boolean { + const { selection } = state; + + return ( + selection instanceof CellSelection && + (selection.isColSelection() || + selection.isRowSelection() || + selection.$anchorCell.pos !== selection.$headCell.pos) + ); +} + +/** + * Check if the selection spans multiple merged cells. + * + * @param state The editor state + * @returns Boolean indicating if a merged cell is selected + */ +export function isMergedCellSelection(state: EditorState): boolean { + const { selection } = state; + if (selection instanceof CellSelection) { + // Check if any cell in the selection has a colspan or rowspan > 1 + let hasMergedCells = false; + selection.forEachCell((cell) => { + if (cell.attrs.colspan > 1 || cell.attrs.rowspan > 1) { + hasMergedCells = true; + return false; + } + + return true; + }); + + return hasMergedCells; + } + + return false; +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index e4c8d7ebbd..c1b387d92c 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -497,6 +497,8 @@ "Toggle header": "Toggle header", "Math inline (LaTeX)": "Math inline (LaTeX)", "Math block (LaTeX)": "Math block (LaTeX)", + "Merge cells": "Merge cells", + "Split cell": "Split cell", "Tip": "Tip", "Tip notice": "Tip notice", "Warning": "Warning", diff --git a/yarn.lock b/yarn.lock index 86cfcd194b..3adfd235ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2236,30 +2236,18 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz#b17a2171f9074df9e91bfb07ef99a892ac06412a" integrity sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg== -"@eslint-community/eslint-utils@^4.2.0": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" - integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== - dependencies: - eslint-visitor-keys "^3.3.0" - -"@eslint-community/eslint-utils@^4.7.0": +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0": version "4.7.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a" integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.10.0": +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1": version "4.12.1" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== -"@eslint-community/regexpp@^4.6.1": - version "4.10.0" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" - integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== - "@eslint/eslintrc@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" @@ -3578,7 +3566,7 @@ "@radix-ui/react-primitive" "2.0.1" "@radix-ui/react-use-layout-effect" "1.1.0" -"@radix-ui/react-portal@1.1.4", "@radix-ui/react-portal@^1.0.1": +"@radix-ui/react-portal@1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.4.tgz#ff5401ff63c8a825c46eea96d3aef66074b8c0c8" integrity sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA== @@ -3586,7 +3574,7 @@ "@radix-ui/react-primitive" "2.0.2" "@radix-ui/react-use-layout-effect" "1.1.0" -"@radix-ui/react-portal@1.1.9": +"@radix-ui/react-portal@1.1.9", "@radix-ui/react-portal@^1.0.1": version "1.1.9" resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472" integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ== @@ -3869,20 +3857,13 @@ dependencies: "@radix-ui/react-primitive" "2.0.1" -"@radix-ui/react-visually-hidden@1.2.3": +"@radix-ui/react-visually-hidden@1.2.3", "@radix-ui/react-visually-hidden@^1.2.2": version "1.2.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz#a8c38c8607735dc9f05c32f87ab0f9c2b109efbf" integrity sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug== dependencies: "@radix-ui/react-primitive" "2.1.3" -"@radix-ui/react-visually-hidden@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.2.tgz#aa6d0f95b0cd50f08b02393d25132f52ca7861dc" - integrity sha512-ORCmRUbNiZIv6uV5mhFrhsIKw4UX/N3syZtyqvry61tbGm4JlgQuSn0hk5TwCARsCjkcnuRkSdCE3xfb+ADHew== - dependencies: - "@radix-ui/react-primitive" "2.1.2" - "@radix-ui/rect@0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-0.1.1.tgz#95b5ba51f469bea6b1b841e2d427e17e37d38419" @@ -7691,12 +7672,7 @@ content-disposition@~0.5.2: dependencies: safe-buffer "5.1.2" -content-type@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity "sha1-4TjMdeBAxyexlm/l5fjJruJW/js= sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" - -content-type@^1.0.5: +content-type@^1.0.4, content-type@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -7833,16 +7809,7 @@ cross-fetch@^3.0.4, cross-fetch@^3.1.5: dependencies: node-fetch "^2.6.11" -cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82" - integrity sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -cross-spawn@^7.0.6: +cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -12402,12 +12369,12 @@ micromatch@4.0.5, micromatch@^4.0.2, micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" -mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": +mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity "sha1-u6vNwChZ9JhzAchW4zh85exDv3A= sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" -mime-db@^1.54.0: +"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: version "1.54.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== @@ -12949,10 +12916,10 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" -outline-icons@^3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.10.0.tgz#625ee232a807ccbc1cca2eda87849cf77ca25160" - integrity sha512-LSHIlZRgNtFoFHj5lDG6hfflNpVpO5kQl/jV7dYSytTcVVjfOECEDACSFsQ34JP7HT1vFaubp1EZSVrKFnIyVw== +outline-icons@^3.12.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.12.0.tgz#2bcc086c0086057b57b21991774d44ce826d2116" + integrity sha512-WnJr/yiJmWKLN2mol7tTcNkNwttYbQqldnAmBPjpHnH2aGJrXBlBEn35KsaiTLd664XDlWzU7XMfWDwhzrtbsA== own-keys@^1.0.0: version "1.0.1"