fix: Allow formatting toolbar to appear with cell selection (#10299)

This commit is contained in:
Tom Moor
2025-10-05 16:54:30 +02:00
committed by GitHub
parent 0df42cb4c7
commit ebf2029539
15 changed files with 220 additions and 69 deletions

View File

@@ -119,5 +119,6 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
gap: 6px;
padding: 6px;
`;

View File

@@ -1,5 +1,5 @@
import { NodeSelection } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { selectedRect } from "prosemirror-tables";
import * as React from "react";
import { Portal as ReactPortal } from "react-portal";
import styled, { css } from "styled-components";
@@ -15,6 +15,9 @@ import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
import { RowSelection } from "@shared/editor/selection/RowSelection";
import { isTableSelected } from "@shared/editor/queries/table";
type Props = {
align?: "start" | "end" | "center";
@@ -104,11 +107,11 @@ function usePosition({
// tables are an oddity, and need their own positioning logic
const isColSelection =
selection instanceof CellSelection && selection.isColSelection();
selection instanceof ColumnSelection && selection.isColSelection();
const isRowSelection =
selection instanceof CellSelection && selection.isRowSelection();
selection instanceof RowSelection && selection.isRowSelection();
if (isColSelection && isRowSelection) {
if (isTableSelected(view.state)) {
const rect = selectedRect(view.state);
const table = view.domAtPos(rect.tableStart);
const bounds = (table.node as HTMLElement).getBoundingClientRect();
@@ -349,7 +352,6 @@ const Background = styled.div<{ align: Props["align"] }>`
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
padding: 6px;
${(props) =>
props.align === "start" &&

View File

@@ -282,7 +282,8 @@ const LinkEditor: React.FC<Props> = ({
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
gap: 6px;
padding: 6px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`

View File

@@ -1,6 +1,5 @@
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";
@@ -8,7 +7,11 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import {
getColumnIndex,
getRowIndex,
isTableSelected,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
@@ -23,7 +26,6 @@ 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";
@@ -185,8 +187,6 @@ export default function SelectionToolbar(props: Props) {
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
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";
@@ -204,14 +204,12 @@ export default function SelectionToolbar(props: Props) {
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelection) {
} else if (isTableSelected(state)) {
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
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

@@ -31,6 +31,9 @@ export default styled.button.attrs((props) => ({
&:hover {
opacity: 1;
// extraArea overlaps slightly, this ensures the currently hovered button is on top
z-index: 1;
}
${(props) =>
@@ -44,7 +47,7 @@ export default styled.button.attrs((props) => ({
cursor: default;
}
${extraArea(4)}
${extraArea(5)}
${(props) =>
props.active &&

View File

@@ -157,6 +157,7 @@ const FlexibleWrapper = styled.div`
overflow: hidden;
display: flex;
gap: 6px;
padding: 6px;
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;

View File

@@ -17,6 +17,8 @@ import {
IndentIcon,
CopyIcon,
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import styled from "styled-components";
@@ -34,6 +36,11 @@ import {
isMobile as isMobileDevice,
isTouchDevice,
} from "@shared/utils/browser";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
export default function formattingMenuItems(
state: EditorState,
@@ -46,6 +53,7 @@ export default function formattingMenuItems(
const isEmpty = state.selection.empty;
const isMobile = isMobileDevice();
const isTouch = isTouchDevice();
const isTableCell = state.selection instanceof CellSelection;
const highlight = getMarksBetween(
state.selection.from,
@@ -166,11 +174,25 @@ export default function formattingMenuItems(
icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "separator",
},
{
name: "mergeCells",
tooltip: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
tooltip: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
visible: !isCodeBlock,
},
{
name: "checkbox_list",
@@ -179,7 +201,7 @@ export default function formattingMenuItems(
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "bullet_list",
@@ -187,7 +209,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "ordered_list",
@@ -195,7 +217,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "outdentList",

View File

@@ -1,36 +0,0 @@
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

@@ -3,7 +3,10 @@ export default {
// TypeScript files
"**/*.[tj]s?(x)": [
(f) => `prettier --write ${f.join(" ")}`,
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix --type-aware`),
(f) =>
f.length > 20
? `yarn lint --fix`
: `oxlint ${f.join(" ")} --fix --type-aware`,
() => `yarn build:i18n`,
() => "git add shared/i18n/locales/en_US/translation.json",
],

View File

@@ -31,6 +31,8 @@ import {
} from "../queries/table";
import { TableLayout } from "../types";
import { collapseSelection } from "./collapseSelection";
import { RowSelection } from "../selection/RowSelection";
import { ColumnSelection } from "../selection/ColumnSelection";
export function createTable({
rowsCount,
@@ -492,8 +494,8 @@ export function selectRow(index: number, expand = false): Command {
const $pos = state.doc.resolve(rect.tableStart + pos);
const rowSelection =
expand && state.selection instanceof CellSelection
? CellSelection.rowSelection(state.selection.$anchorCell, $pos)
: CellSelection.rowSelection($pos);
? RowSelection.rowSelection(state.selection.$anchorCell, $pos)
: RowSelection.rowSelection($pos);
dispatch(state.tr.setSelection(rowSelection));
return true;
}
@@ -509,8 +511,8 @@ export function selectColumn(index: number, expand = false): Command {
const $pos = state.doc.resolve(rect.tableStart + pos);
const colSelection =
expand && state.selection instanceof CellSelection
? CellSelection.colSelection(state.selection.$anchorCell, $pos)
: CellSelection.colSelection($pos);
? ColumnSelection.colSelection(state.selection.$anchorCell, $pos)
: ColumnSelection.colSelection($pos);
dispatch(state.tr.setSelection(colSelection));
return true;
}

View File

@@ -182,7 +182,8 @@ export default class TableCell extends Node {
}
const className = cn(EditorStyleHelper.tableGripRow, {
selected: isRowSelected(index)(state),
selected:
isRowSelected(index)(state) || isTableSelected(state),
first: index === 0,
last: visualIndex === rows.length - 1,
});

View File

@@ -4,7 +4,11 @@ import { Plugin } from "prosemirror-state";
import { DecorationSet, Decoration, EditorView } from "prosemirror-view";
import { addColumnBefore, selectColumn } from "../commands/table";
import { getCellAttrs, setCellAttrs } from "../lib/table";
import { getCellsInRow, isColumnSelected } from "../queries/table";
import {
getCellsInRow,
isColumnSelected,
isTableSelected,
} from "../queries/table";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { cn } from "../styles/utils";
import Node from "./Node";
@@ -115,7 +119,8 @@ export default class TableHeader extends Node {
if (cols) {
cols.forEach((pos, index) => {
const className = cn(EditorStyleHelper.tableGripColumn, {
selected: isColumnSelected(index)(state),
selected:
isColumnSelected(index)(state) || isTableSelected(state),
first: index === 0,
last: index === cols.length - 1,
});

View File

@@ -5,6 +5,8 @@ import {
isInTable,
selectedRect,
} from "prosemirror-tables";
import { ColumnSelection } from "../selection/ColumnSelection";
import { RowSelection } from "../selection/RowSelection";
/**
* Checks if the current selection is a column selection.
@@ -12,7 +14,7 @@ import {
* @returns True if the selection is a column selection, false otherwise.
*/
export function isColSelection(state: EditorState): boolean {
if (state.selection instanceof CellSelection) {
if (state.selection instanceof ColumnSelection) {
return state.selection.isColSelection();
}
return false;
@@ -24,14 +26,14 @@ export function isColSelection(state: EditorState): boolean {
* @returns True if the selection is a row selection, false otherwise.
*/
export function isRowSelection(state: EditorState): boolean {
if (state.selection instanceof CellSelection) {
if (state.selection instanceof RowSelection) {
return state.selection.isRowSelection();
}
return false;
}
export function getColumnIndex(state: EditorState): number | undefined {
if (state.selection instanceof CellSelection) {
if (state.selection instanceof ColumnSelection) {
if (state.selection.isColSelection()) {
const rect = selectedRect(state);
return rect.left;
@@ -42,7 +44,7 @@ export function getColumnIndex(state: EditorState): number | undefined {
}
export function getRowIndex(state: EditorState): number | undefined {
if (state.selection instanceof CellSelection) {
if (state.selection instanceof RowSelection) {
if (state.selection.isRowSelection()) {
const rect = selectedRect(state);
return rect.top;

View File

@@ -0,0 +1,73 @@
import { ResolvedPos, type Node } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { CellSelection, inSameTable, TableMap } from "prosemirror-tables";
import { Mappable } from "prosemirror-transform";
export class ColumnSelection extends CellSelection {
getBookmark(): ColumnBookmark {
return new ColumnBookmark(this.$anchorCell.pos, this.$headCell.pos);
}
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 ColumnSelection($anchorCell, $headCell);
}
}
export class ColumnBookmark {
constructor(
public anchor: number,
public head: number
) {}
map(mapping: Mappable): ColumnBookmark {
return new ColumnBookmark(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 ColumnSelection($anchorCell, $headCell);
} else {
return Selection.near($headCell, 1);
}
}
}

View File

@@ -0,0 +1,73 @@
import { ResolvedPos, type Node } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { CellSelection, inSameTable, TableMap } from "prosemirror-tables";
import { Mappable } from "prosemirror-transform";
export class RowSelection extends CellSelection {
getBookmark(): RowBookmark {
return new RowBookmark(this.$anchorCell.pos, this.$headCell.pos);
}
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 RowSelection($anchorCell, $headCell);
}
}
export class RowBookmark {
constructor(
public anchor: number,
public head: number
) {}
map(mapping: Mappable): RowBookmark {
return new RowBookmark(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 RowSelection($anchorCell, $headCell);
} else {
return Selection.near($headCell, 1);
}
}
}