Add table cell merge/unmerge functionality (#9322)

* Add table cell merge/unmerge functionality

- Add new tableCell menu with merge and unmerge options
- Update SelectionToolbar to show tableCell menu for CellSelection
- Add mergeCells and splitCell commands to Table node
- Add dictionary entries for merge/split cell tooltips
- Use placeholder icons (PlusIcon for merge, MoreIcon for split)

Fixes #6977

* fixes

* fix: Header cells end up floating with some effort

* refactor

* collapse

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
codegen-sh[bot]
2025-06-08 22:05:29 -04:00
committed by GitHub
parent f3b4640c7a
commit dc3952212f
12 changed files with 211 additions and 53 deletions

View File

@@ -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) {

View File

@@ -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: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
];
}

View File

@@ -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: <InsertRightIcon />,
attrs: { index },
},
{
name: "mergeCells",
label: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
},
{
name: "deleteColumn",
dangerous: true,

View File

@@ -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: <MoreIcon />,
@@ -36,6 +48,21 @@ export default function tableRowMenuItems(
icon: <InsertBelowIcon />,
attrs: { index },
},
{
name: "mergeCells",
label: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
},
{
name: "deleteRow",
label: dictionary.deleteRow,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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