From ea665b80eeb512941144efb8c8629cc53fdbec99 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 7 Jun 2026 07:57:34 -0400 Subject: [PATCH] feat: Inline editor menu (#12611) * wip * Mobile support * Address review feedback on inline menu - Mark selection-restore transaction as not added to history - Only open desktop inline menu when an anchor is available Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- app/components/primitives/components/Menu.tsx | 3 + app/editor/components/InlineMenu.tsx | 190 ++++++++++++++ app/editor/components/SelectionToolbar.tsx | 13 +- app/editor/components/ToolbarMenu.tsx | 69 +---- app/editor/components/useInlineMenuAnchor.ts | 152 +++++++++++ app/editor/extensions/SelectionToolbar.tsx | 8 +- app/editor/menus/mapMenuItems.ts | 80 ++++++ app/editor/menus/table.tsx | 25 +- app/editor/menus/tableCol.tsx | 240 +++++++++--------- app/editor/menus/tableRow.tsx | 131 +++++----- package.json | 2 + shared/editor/lib/ExtensionManager.ts | 12 + shared/editor/nodes/TableHeader.ts | 5 +- shared/editor/nodes/TableRow.ts | 5 +- shared/editor/types/index.ts | 14 + shared/i18n/locales/en_US/translation.json | 6 +- yarn.lock | 6 +- 17 files changed, 693 insertions(+), 268 deletions(-) create mode 100644 app/editor/components/InlineMenu.tsx create mode 100644 app/editor/components/useInlineMenuAnchor.ts create mode 100644 app/editor/menus/mapMenuItems.ts diff --git a/app/components/primitives/components/Menu.tsx b/app/components/primitives/components/Menu.tsx index e509f74088..153aeb431e 100644 --- a/app/components/primitives/components/Menu.tsx +++ b/app/components/primitives/components/Menu.tsx @@ -108,6 +108,9 @@ export const MenuExternalLink = styled.a` export const MenuSubTrigger = styled.div` ${BaseMenuItemCSS} + // Reserve space for the absolutely-positioned disclosure arrow so long + // labels truncate before it rather than overlapping. + padding-inline-end: 32px; `; export const MenuSeparator = styled.hr` diff --git a/app/editor/components/InlineMenu.tsx b/app/editor/components/InlineMenu.tsx new file mode 100644 index 0000000000..13980b13c2 --- /dev/null +++ b/app/editor/components/InlineMenu.tsx @@ -0,0 +1,190 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; +import { RemoveScroll } from "react-remove-scroll"; +import styled from "styled-components"; +import EventBoundary from "@shared/components/EventBoundary"; +import { collapseSelection } from "@shared/editor/commands/collapseSelection"; +import type { MenuItem } from "@shared/editor/types"; +import { useTranslation } from "react-i18next"; +import Scrollable from "~/components/Scrollable"; +import { toMenuItems, toMobileMenuItems } from "~/components/Menu/transformer"; +import * as Components from "~/components/primitives/components/Menu"; +import { + Drawer, + DrawerContent, + DrawerTitle, +} from "~/components/primitives/Drawer"; +import { MenuProvider } from "~/components/primitives/Menu/MenuContext"; +import type { MenuItem as TMenuItem, MenuItemWithChildren } from "~/types"; +import useMobile from "~/hooks/useMobile"; +import { mapMenuItems } from "../menus/mapMenuItems"; +import { useEditor } from "./EditorContext"; +import { useInlineMenuAnchor } from "./useInlineMenuAnchor"; + +type Props = { + items: MenuItem[]; + /** Whether the document is right-to-left. */ + rtl: boolean; +}; + +// The virtual anchor is an invisible zero-size element; the hook positions it +// over the selection and Radix anchors the menu to it. +const anchorStyle: React.CSSProperties = { + position: "fixed", + width: 0, + height: 0, +}; + +/** + * Renders a selection-toolbar menu inline — a vertical menu anchored to the + * selection with no trigger button — by holding a Radix dropdown `open` + * against a virtual anchor positioned over the selection. Radix provides the + * positioning, collision handling, submenus, and keyboard navigation. Page + * scroll is locked while open (via RemoveScroll, as Radix does for modal + * menus) without enabling Radix's modal mode, which conflicts with the menu + * being opened by an editor selection rather than a trigger. + */ +const InlineMenu: React.FC = ({ items, rtl }) => { + const { t } = useTranslation(); + const { commands, view } = useEditor(); + const { state } = view; + const isMobile = useMobile(); + const { + ref: anchorRef, + key: anchorKey, + side, + align, + sideOffset, + } = useInlineMenuAnchor(rtl); + + const mapped = React.useMemo( + () => mapMenuItems(items, commands, view, state), + [items, commands, view, state] + ); + + const preventFocus = React.useCallback((ev: Event) => { + ev.preventDefault(); + }, []); + + // Dismiss the menu by collapsing the selection so the toolbar stops matching. + const handleDismiss = React.useCallback(() => { + collapseSelection()(view.state, view.dispatch); + }, [view]); + + if (isMobile) { + return ( + + ); + } + + return ( + + + +
+ + + + + + {toMenuItems(mapped)} + + + + + + + ); +}; + +// Time for the drawer's close animation to play before the selection is +// collapsed (which unmounts the menu). +const DRAWER_CLOSE_MS = 500; + +type InlineMenuDrawerProps = { + items: TMenuItem[]; + ariaLabel: string; + /** Collapse the selection so the toolbar stops rendering the menu. */ + onDismiss: () => void; +}; + +/** + * Mobile presentation of the inline menu: a bottom drawer with submenu drill-in, + * matching the other menus. The menu is held open while the selection matches; + * closing animates the drawer out before collapsing the selection. + */ +function InlineMenuDrawer({ + items, + ariaLabel, + onDismiss, +}: InlineMenuDrawerProps) { + const [open, setOpen] = React.useState(true); + const [submenuName, setSubmenuName] = React.useState(); + + const close = React.useCallback(() => { + setOpen(false); + setTimeout(() => { + setSubmenuName(undefined); + onDismiss(); + }, DRAWER_CLOSE_MS); + }, [onDismiss]); + + const handleOpenChange = React.useCallback( + (isOpen: boolean) => { + if (!isOpen) { + close(); + } + }, + [close] + ); + + const menuItems = React.useMemo(() => { + if (!items.length || !submenuName) { + return items; + } + const submenu = items.find( + (item) => item.type === "submenu" && item.title === submenuName + ) as MenuItemWithChildren | undefined; + return submenu?.items ?? items; + }, [items, submenuName]); + + const content = toMobileMenuItems(menuItems, close, setSubmenuName); + + return ( + + + + {content} + + + ); +} + +const StyledScrollable = styled(Scrollable)` + max-height: 75vh; +`; + +export default InlineMenu; diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx index 7c65936369..af5ee74680 100644 --- a/app/editor/components/SelectionToolbar.tsx +++ b/app/editor/components/SelectionToolbar.tsx @@ -11,7 +11,7 @@ import { } from "@shared/editor/queries/getMarkRange"; import { isInCode } from "@shared/editor/queries/isInCode"; import { isInNotice } from "@shared/editor/queries/isInNotice"; -import type { MenuItem } from "@shared/editor/types"; +import { MenuType, type MenuItem } from "@shared/editor/types"; import useBoolean from "~/hooks/useBoolean"; import useEventListener from "~/hooks/useEventListener"; import useMobile from "~/hooks/useMobile"; @@ -24,6 +24,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor"; import FloatingToolbar from "./FloatingToolbar"; import LinkEditor from "./LinkEditor"; import ToolbarMenu from "./ToolbarMenu"; +import InlineMenu from "./InlineMenu"; import { isModKey } from "@shared/utils/keyboard"; type Props = { @@ -264,6 +265,16 @@ export function SelectionToolbar(props: Props) { setActiveToolbar(null); }; + // Inline menus render as a vertical menu anchored to the selection rather + // than as a horizontal toolbar with trigger buttons. + if ( + matched?.variant === MenuType.inline && + activeToolbar === Toolbar.Menu && + items.length + ) { + return ; + } + return ( () => { - if (!menuItem.name) { - return; - } - - if (commands[menuItem.name]) { - closeHistory(view); - commands[menuItem.name]( - typeof menuItem.attrs === "function" - ? menuItem.attrs(state) - : menuItem.attrs - ); - closeHistory(view); - } else if (menuItem.onClick) { - menuItem.onClick(); - } - }; - - const resolveChildren = ( - children: MenuItem[] | (() => MenuItem[]) | undefined - ): MenuItem[] | undefined => - typeof children === "function" ? children() : children; - - const mapChildren = (children: MenuItem[]): TMenuItem[] => - children.map((child) => { - if (child.name === "separator") { - return { type: "separator", visible: child.visible }; - } - if ("content" in child) { - return { - type: "custom", - visible: child.visible, - content: child.content, - }; - } - const resolvedChildren = resolveChildren(child.children); - if (resolvedChildren) { - const childWithPreventClose = resolvedChildren.find( - (c) => "preventCloseCondition" in c - ); - return { - type: "submenu", - title: child.label, - icon: child.icon, - visible: child.visible, - preventCloseCondition: childWithPreventClose?.preventCloseCondition, - items: mapChildren(resolvedChildren), - }; - } - return { - type: "button", - title: child.label, - icon: child.icon, - dangerous: child.dangerous, - visible: child.visible, - selected: - child.active !== undefined ? child.active(state) : undefined, - onClick: handleClick(child), - }; - }); - - const resolvedItemChildren = resolveChildren(item.children); - return resolvedItemChildren ? mapChildren(resolvedItemChildren) : []; + const resolvedItemChildren = + typeof item.children === "function" ? item.children() : item.children; + return resolvedItemChildren + ? mapMenuItems(resolvedItemChildren, commands, view, state) + : []; }, [isOpen, commands]); const handleCloseAutoFocus = useCallback((ev: Event) => { diff --git a/app/editor/components/useInlineMenuAnchor.ts b/app/editor/components/useInlineMenuAnchor.ts new file mode 100644 index 0000000000..13e3d7829f --- /dev/null +++ b/app/editor/components/useInlineMenuAnchor.ts @@ -0,0 +1,152 @@ +import { selectedRect } from "prosemirror-tables"; +import * as React from "react"; +import type { EditorView } from "prosemirror-view"; +import { ColumnSelection } from "@shared/editor/selection/ColumnSelection"; +import { RowSelection } from "@shared/editor/selection/RowSelection"; +import { isTableSelected } from "@shared/editor/queries/table"; +import { useEditor } from "./EditorContext"; + +type Side = "top" | "bottom" | "left" | "right"; +type Align = "start" | "center" | "end"; + +const DEFAULT_SIDE_OFFSET = 4; + +// Column and row menus open next to a grip handle. The grip is modelled as a +// strip just outside the cell edge so the two distances are independent: +// opening to the outside clears the grip (strip thickness + offset), while +// flipping across sits only a small gap (offset) away. +const OUTSIDE_CLEARANCE = 20; +const FLIP_GAP = 0; +const GRIP_INSET = OUTSIDE_CLEARANCE - FLIP_GAP; +const GRIP_SIDE_OFFSET = FLIP_GAP; + +type Anchor = { + /** Viewport rect to anchor the menu to. */ + top: number; + left: number; + width: number; + height: number; + /** Which side of the anchor the menu opens towards. */ + side: Side; + /** How the menu aligns along the anchor edge. */ + align: Align; + /** Distance in pixels between the anchor and the menu. */ + sideOffset: number; + /** Stable identifier for the anchored target, changes when it moves. */ + key: string; +}; + +/** + * Computes the rect and placement to anchor an inline selection menu to, based + * on the current table/column/row selection. The menu opens to the "outside" + * of the table (above a column, beside a row) to cover the least content, and + * is centered on the anchor for minimal pointer movement. Returns null when + * there is no supported selection. + * + * @param view - the editor view. + * @param rtl - whether the document is right-to-left. + * @returns the anchor, or null. + */ +function getAnchor(view: EditorView, rtl: boolean): Anchor | null { + const { state } = view; + const { selection } = state; + + if (isTableSelected(state)) { + const rect = selectedRect(state); + const bounds = ( + view.domAtPos(rect.tableStart).node as HTMLElement + ).getBoundingClientRect(); + // A horizontal line at the table's top edge so it stays near the top + // whether the menu opens above or flips below. + return { + top: bounds.top, + left: bounds.left, + width: bounds.width, + height: 0, + side: "top", + align: "start", + sideOffset: DEFAULT_SIDE_OFFSET, + key: `table-${rect.tableStart}`, + }; + } + + if (selection instanceof ColumnSelection && selection.isColSelection()) { + const rect = selectedRect(state); + const cell = ( + view.domAtPos(rect.tableStart).node as HTMLElement + ).querySelector(`tr > *:nth-child(${rect.left + 1})`); + if (cell instanceof HTMLElement) { + const bounds = cell.getBoundingClientRect(); + // A strip just above the column's top edge (the grip), spanning the + // column width so the menu centers on the column. + return { + top: bounds.top - GRIP_INSET, + left: bounds.left, + width: bounds.width, + height: GRIP_INSET, + side: "top", + align: "center", + sideOffset: GRIP_SIDE_OFFSET, + key: `col-${rect.tableStart}-${rect.left}`, + }; + } + } + + if (selection instanceof RowSelection && selection.isRowSelection()) { + const rect = selectedRect(state); + const cell = ( + view.domAtPos(rect.tableStart).node as HTMLElement + ).querySelector(`tr:nth-child(${rect.top + 1}) > *`); + if (cell instanceof HTMLElement) { + const bounds = cell.getBoundingClientRect(); + // A strip just outside the row's grip edge (left, or right in RTL), + // spanning the row height so the menu centers on the row. + return { + top: bounds.top, + left: rtl ? bounds.right : bounds.left - GRIP_INSET, + width: GRIP_INSET, + height: bounds.height, + side: rtl ? "right" : "left", + align: "center", + sideOffset: GRIP_SIDE_OFFSET, + key: `row-${rect.tableStart}-${rect.top}`, + }; + } + } + + return null; +} + +/** + * Positions an invisible virtual anchor element over the current table, column, + * or row selection so a Radix dropdown can anchor an inline menu to it. The + * returned `key` changes when the anchored target changes; spread it onto the + * menu root so Radix repositions for a new target. + * + * @param rtl - whether the document is right-to-left. + * @returns the anchor ref to attach to the virtual trigger, the target key, and + * the side/align the menu should open with. + */ +export function useInlineMenuAnchor(rtl: boolean) { + const { view } = useEditor(); + const ref = React.useRef(null); + const anchor = getAnchor(view, rtl); + + React.useLayoutEffect(() => { + const element = ref.current; + if (element && anchor) { + element.style.top = `${anchor.top}px`; + element.style.left = `${anchor.left}px`; + element.style.width = `${anchor.width}px`; + element.style.height = `${anchor.height}px`; + } + }); + + return { + ref, + key: anchor?.key, + side: anchor?.side ?? "top", + align: anchor?.align ?? "start", + sideOffset: anchor?.sideOffset ?? DEFAULT_SIDE_OFFSET, + }; +} diff --git a/app/editor/extensions/SelectionToolbar.tsx b/app/editor/extensions/SelectionToolbar.tsx index c08f8815d9..0c172be290 100644 --- a/app/editor/extensions/SelectionToolbar.tsx +++ b/app/editor/extensions/SelectionToolbar.tsx @@ -7,7 +7,10 @@ import Extension from "@shared/editor/lib/Extension"; import { isInNotice } from "@shared/editor/queries/isInNotice"; import { isMarkActive } from "@shared/editor/queries/isMarkActive"; import { isNodeActive } from "@shared/editor/queries/isNodeActive"; -import type { SelectionToolbarMenuDescriptor } from "@shared/editor/types"; +import { + MenuType, + type SelectionToolbarMenuDescriptor, +} from "@shared/editor/types"; import { SelectionToolbar } from "../components/SelectionToolbar"; import getAttachmentMenuItems from "../menus/attachment"; import getCodeMenuItems from "../menus/code"; @@ -62,16 +65,19 @@ export default class SelectionToolbarExtension extends Extension { }, { priority: 90, + variant: MenuType.inline, matches: (ctx) => ctx.isTableSelected, getItems: (ctx) => getTableMenuItems(ctx), }, { priority: 85, + variant: MenuType.inline, matches: (ctx) => ctx.colIndex !== undefined, getItems: (ctx) => getTableColMenuItems(ctx), }, { priority: 80, + variant: MenuType.inline, matches: (ctx) => ctx.rowIndex !== undefined, getItems: (ctx) => getTableRowMenuItems(ctx), }, diff --git a/app/editor/menus/mapMenuItems.ts b/app/editor/menus/mapMenuItems.ts new file mode 100644 index 0000000000..046d5df0ec --- /dev/null +++ b/app/editor/menus/mapMenuItems.ts @@ -0,0 +1,80 @@ +import type { EditorState } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { closeHistory } from "@shared/editor/lib/closeHistory"; +import type { CommandFactory } from "@shared/editor/lib/Extension"; +import type { MenuItem } from "@shared/editor/types"; +import type { MenuItem as TMenuItem } from "~/types"; + +const resolveChildren = ( + children: MenuItem[] | (() => MenuItem[]) | undefined +): MenuItem[] | undefined => + typeof children === "function" ? children() : children; + +/** + * Maps editor `MenuItem`s into the primitive `MenuItem`s consumed by + * `toMenuItems`. Shared by the toolbar dropdown and the inline menu so menu + * presentation stays consistent. Resolves nested children into submenus and + * binds each leaf to its editor command (or `onClick`). + * + * @param items - the editor menu items to map. + * @param commands - the editor command registry. + * @param view - the editor view, used to checkpoint history around commands. + * @param state - the editor state, used to resolve dynamic attrs and active state. + * @returns the mapped primitive menu items. + */ +export function mapMenuItems( + items: MenuItem[], + commands: Record, + view: EditorView, + state: EditorState +): TMenuItem[] { + const handleClick = (item: MenuItem) => () => { + if (!item.name) { + return; + } + if (commands[item.name]) { + closeHistory(view); + commands[item.name]( + typeof item.attrs === "function" ? item.attrs(state) : item.attrs + ); + closeHistory(view); + } else if (item.onClick) { + item.onClick(); + } + }; + + return items.map((item) => { + if (item.name === "separator") { + return { type: "separator", visible: item.visible }; + } + + if ("content" in item) { + return { type: "custom", visible: item.visible, content: item.content }; + } + + const resolvedChildren = resolveChildren(item.children); + if (resolvedChildren) { + const childWithPreventClose = resolvedChildren.find( + (child) => "preventCloseCondition" in child + ); + return { + type: "submenu", + title: item.label, + icon: item.icon, + visible: item.visible, + preventCloseCondition: childWithPreventClose?.preventCloseCondition, + items: mapMenuItems(resolvedChildren, commands, view, state), + }; + } + + return { + type: "button", + title: item.label, + icon: item.icon, + dangerous: item.dangerous, + visible: item.visible, + selected: item.active !== undefined ? item.active(state) : undefined, + onClick: handleClick(item), + }; + }); +} diff --git a/app/editor/menus/table.tsx b/app/editor/menus/table.tsx index 4db3a46fb9..8592d6c319 100644 --- a/app/editor/menus/table.tsx +++ b/app/editor/menus/table.tsx @@ -15,9 +15,7 @@ import { TableLayout } from "@shared/editor/types"; * @param ctx - the current selection context. * @returns an array of menu items. */ -export default function tableMenuItems( - ctx: SelectionContext -): MenuItem[] { +export default function tableMenuItems(ctx: SelectionContext): MenuItem[] { if (ctx.readOnly) { return []; } @@ -30,33 +28,32 @@ export default function tableMenuItems( return [ { name: "setTableAttr", - tooltip: isFullWidth ? t("Default width") : t("Full width"), + label: isFullWidth ? t("Default width") : t("Full width"), icon: , attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth }, - active: () => isFullWidth, }, { name: "distributeColumns", - tooltip: t("Distribute columns"), + label: t("Distribute columns"), icon: , }, { name: "separator", }, { - name: "deleteTable", - tooltip: t("Delete table"), - icon: , + name: "exportTable", + label: t("Export as CSV"), + attrs: { format: "csv", fileName: `${window.document.title}.csv` }, + icon: , }, { name: "separator", }, { - name: "exportTable", - tooltip: t("Export as CSV"), - label: "CSV", - attrs: { format: "csv", fileName: `${window.document.title}.csv` }, - icon: , + name: "deleteTable", + label: t("Delete table"), + dangerous: true, + icon: , }, ]; } diff --git a/app/editor/menus/tableCol.tsx b/app/editor/menus/tableCol.tsx index 017f88e1f8..bd6428320b 100644 --- a/app/editor/menus/tableCol.tsx +++ b/app/editor/menus/tableCol.tsx @@ -5,7 +5,6 @@ import { AlignCenterIcon, InsertLeftIcon, InsertRightIcon, - MoreIcon, PaletteIcon, TableHeaderColumnIcon, TableMergeCellsIcon, @@ -24,7 +23,11 @@ import { tableHasRowspan, } from "@shared/editor/queries/table"; import { t } from "i18next"; -import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types"; +import type { + MenuItem, + NodeAttrMark, + SelectionContext, +} from "@shared/editor/types"; import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon"; import CircleIcon from "~/components/Icons/CircleIcon"; import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker"; @@ -65,9 +68,7 @@ function getColumnColors(state: EditorState, colIndex: number): Set { * @param ctx - the current selection context. * @returns an array of menu items. */ -export default function tableColMenuItems( - ctx: SelectionContext -): MenuItem[] { +export default function tableColMenuItems(ctx: SelectionContext): MenuItem[] { if (ctx.readOnly) { return []; } @@ -94,60 +95,65 @@ export default function tableColMenuItems( return [ { - name: "setColumnAttr", - tooltip: t("Align left"), - icon: , - attrs: { index, alignment: "left" }, - active: isNodeActive(schema.nodes.th, { - colspan: 1, - rowspan: 1, - alignment: "left", - }), - }, - { - name: "setColumnAttr", - tooltip: t("Align center"), + label: t("Align"), icon: , - attrs: { index, alignment: "center" }, - active: isNodeActive(schema.nodes.th, { - colspan: 1, - rowspan: 1, - alignment: "center", - }), + children: [ + { + name: "setColumnAttr", + label: t("Align left"), + icon: , + attrs: { index, alignment: "left" }, + active: isNodeActive(schema.nodes.th, { + colspan: 1, + rowspan: 1, + alignment: "left", + }), + }, + { + name: "setColumnAttr", + label: t("Align center"), + icon: , + attrs: { index, alignment: "center" }, + active: isNodeActive(schema.nodes.th, { + colspan: 1, + rowspan: 1, + alignment: "center", + }), + }, + { + name: "setColumnAttr", + label: t("Align right"), + icon: , + attrs: { index, alignment: "right" }, + active: isNodeActive(schema.nodes.th, { + colspan: 1, + rowspan: 1, + alignment: "right", + }), + }, + ], }, { - name: "setColumnAttr", - tooltip: t("Align right"), - icon: , - attrs: { index, alignment: "right" }, - active: isNodeActive(schema.nodes.th, { - colspan: 1, - rowspan: 1, - alignment: "right", - }), - }, - { - name: "separator", - }, - { - name: "sortTable", - tooltip: t("Sort ascending"), - attrs: { index, direction: "asc" }, + label: t("Sort"), icon: , disabled: tableHasRowspan(state), + children: [ + { + name: "sortTable", + label: t("Sort ascending"), + attrs: { index, direction: "asc" }, + icon: , + }, + { + name: "sortTable", + label: t("Sort descending"), + attrs: { index, direction: "desc" }, + icon: , + }, + ], }, { - name: "sortTable", - tooltip: t("Sort descending"), - attrs: { index, direction: "desc" }, - icon: , - disabled: tableHasRowspan(state), - }, - { - name: "separator", - }, - { - tooltip: t("Background color"), + label: t("Background"), icon: colColors.size > 1 ? ( @@ -161,7 +167,7 @@ export default function tableColMenuItems( { name: "toggleColumnBackgroundAndCollapseSelection", label: t("None"), - icon: , + icon: , active: () => (hasBackground ? false : true), attrs: { color: null }, }, @@ -205,71 +211,69 @@ export default function tableColMenuItems( ], }, { - icon: , - children: [ - { - name: "toggleHeaderColumn", - label: t("Toggle header"), - icon: , - visible: index === 0, - }, - { - name: rtl ? "addColumnAfter" : "addColumnBefore", - label: rtl ? t("Insert after") : t("Insert before"), - icon: , - attrs: { index }, - }, - { - name: rtl ? "addColumnBefore" : "addColumnAfter", - label: rtl ? t("Insert before") : t("Insert after"), - icon: , - attrs: { index }, - }, - { - name: "moveTableColumn", - label: t("Move left"), - icon: , - attrs: { from: index, to: index - 1 }, - visible: index > 0, - }, - { - name: "moveTableColumn", - label: t("Move right"), - icon: , - attrs: { from: index, to: index + 1 }, - visible: index < tableMap.map.width - 1, - }, - { - name: "separator", - }, - { - name: "mergeCells", - label: t("Merge cells"), - icon: , - visible: isMultipleCellSelection(state), - }, - { - name: "splitCell", - label: t("Split cell"), - icon: , - visible: isMergedCellSelection(state), - }, - { - name: "distributeColumns", - visible: selectedCols.length > 1, - label: t("Distribute columns"), - icon: , - }, - { - name: "separator", - }, - { - name: "deleteColumn", - dangerous: true, - label: t("Delete"), - icon: , - }, - ], + name: "separator", + }, + { + name: "toggleHeaderColumn", + label: t("Toggle header"), + icon: , + visible: index === 0, + }, + { + name: rtl ? "addColumnAfter" : "addColumnBefore", + label: rtl ? t("Insert after") : t("Insert before"), + icon: , + attrs: { index }, + }, + { + name: rtl ? "addColumnBefore" : "addColumnAfter", + label: rtl ? t("Insert before") : t("Insert after"), + icon: , + attrs: { index }, + }, + { + name: "moveTableColumn", + label: t("Move left"), + icon: , + attrs: { from: index, to: index - 1 }, + visible: index > 0, + }, + { + name: "moveTableColumn", + label: t("Move right"), + icon: , + attrs: { from: index, to: index + 1 }, + visible: index < tableMap.map.width - 1, + }, + { + name: "separator", + }, + { + name: "mergeCells", + label: t("Merge cells"), + icon: , + visible: isMultipleCellSelection(state), + }, + { + name: "splitCell", + label: t("Split cell"), + icon: , + visible: isMergedCellSelection(state), + }, + { + name: "distributeColumns", + visible: selectedCols.length > 1, + label: t("Distribute columns"), + icon: , + }, + { + name: "separator", + }, + { + name: "deleteColumn", + dangerous: true, + label: t("Delete"), + icon: , }, ]; } diff --git a/app/editor/menus/tableRow.tsx b/app/editor/menus/tableRow.tsx index f9c2e2e36f..3d37d3736f 100644 --- a/app/editor/menus/tableRow.tsx +++ b/app/editor/menus/tableRow.tsx @@ -2,7 +2,6 @@ import { TrashIcon, InsertAboveIcon, InsertBelowIcon, - MoreIcon, PaletteIcon, TableHeaderRowIcon, TableSplitCellsIcon, @@ -16,7 +15,11 @@ import { isMultipleCellSelection, } from "@shared/editor/queries/table"; import { t } from "i18next"; -import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types"; +import type { + MenuItem, + NodeAttrMark, + SelectionContext, +} from "@shared/editor/types"; import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon"; import CircleIcon from "~/components/Icons/CircleIcon"; import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker"; @@ -56,9 +59,7 @@ function getRowColors(state: EditorState, rowIndex: number): Set { * @param ctx - the current selection context. * @returns an array of menu items. */ -export default function tableRowMenuItems( - ctx: SelectionContext -): MenuItem[] { +export default function tableRowMenuItems(ctx: SelectionContext): MenuItem[] { if (ctx.readOnly) { return []; } @@ -83,7 +84,42 @@ export default function tableRowMenuItems( return [ { - tooltip: t("Background color"), + name: "toggleHeaderRow", + label: t("Toggle header"), + icon: , + visible: index === 0, + }, + { + name: "addRowBefore", + label: t("Insert before"), + icon: , + attrs: { index }, + }, + { + name: "addRowAfter", + label: t("Insert after"), + icon: , + attrs: { index }, + }, + { + name: "moveTableRow", + label: t("Move up"), + icon: , + attrs: { from: index, to: index - 1 }, + visible: index > 0, + }, + { + name: "moveTableRow", + label: t("Move down"), + icon: , + attrs: { from: index, to: index + 1 }, + visible: index < tableMap.map.height - 1, + }, + { + name: "separator", + }, + { + label: t("Background"), icon: rowColors.size > 1 ? ( @@ -97,7 +133,7 @@ export default function tableRowMenuItems( { name: "toggleRowBackgroundAndCollapseSelection", label: t("None"), - icon: , + icon: , active: () => (hasBackground ? false : true), attrs: { color: null }, }, @@ -141,65 +177,28 @@ export default function tableRowMenuItems( ], }, { - icon: , - children: [ - { - name: "toggleHeaderRow", - label: t("Toggle header"), - icon: , - visible: index === 0, - }, - { - name: "addRowBefore", - label: t("Insert before"), - icon: , - attrs: { index }, - }, - { - name: "addRowAfter", - label: t("Insert after"), - icon: , - attrs: { index }, - }, - { - name: "moveTableRow", - label: t("Move up"), - icon: , - attrs: { from: index, to: index - 1 }, - visible: index > 0, - }, - { - name: "moveTableRow", - label: t("Move down"), - icon: , - attrs: { from: index, to: index + 1 }, - visible: index < tableMap.map.height - 1, - }, - { - name: "separator", - }, - { - name: "mergeCells", - label: t("Merge cells"), - icon: , - visible: isMultipleCellSelection(state), - }, - { - name: "splitCell", - label: t("Split cell"), - icon: , - visible: isMergedCellSelection(state), - }, - { - name: "separator", - }, - { - name: "deleteRow", - label: t("Delete"), - dangerous: true, - icon: , - }, - ], + name: "separator", + }, + { + name: "mergeCells", + label: t("Merge cells"), + icon: , + visible: isMultipleCellSelection(state), + }, + { + name: "splitCell", + label: t("Split cell"), + icon: , + visible: isMergedCellSelection(state), + }, + { + name: "separator", + }, + { + name: "deleteRow", + label: t("Delete"), + dangerous: true, + icon: , }, ]; } diff --git a/package.json b/package.json index 4e1c47dc79..4068371052 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "@radix-ui/react-one-time-password-field": "^0.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toolbar": "^1.1.11", @@ -223,6 +224,7 @@ "react-i18next": "^12.3.1", "react-merge-refs": "^2.1.1", "react-portal": "^4.3.0", + "react-remove-scroll": "^2.7.2", "react-router-dom": "^5.3.4", "react-use-measure": "^2.1.7", "react-virtualized-auto-sizer": "^1.0.26", diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts index c02f7821e4..d1accbc767 100644 --- a/shared/editor/lib/ExtensionManager.ts +++ b/shared/editor/lib/ExtensionManager.ts @@ -273,7 +273,19 @@ export default class ExtensionManager { return; } if (extension.focusAfterExecution) { + // Focusing a blurred editor (e.g. when the command is run from a + // menu that holds focus) can collapse a non-text selection such as + // a table cell selection. Restore it so selection-based commands + // operate on the intended selection. + const { selection } = view.state; view.focus(); + if (!view.state.selection.eq(selection)) { + view.dispatch( + view.state.tr + .setSelection(selection) + .setMeta("addToHistory", false) + ); + } } return callback(attrs)?.(view.state, view.dispatch, view); }; diff --git a/shared/editor/nodes/TableHeader.ts b/shared/editor/nodes/TableHeader.ts index 9941625d65..0386ad5895 100644 --- a/shared/editor/nodes/TableHeader.ts +++ b/shared/editor/nodes/TableHeader.ts @@ -6,6 +6,7 @@ import type { EditorView } from "prosemirror-view"; import { DecorationSet, Decoration } from "prosemirror-view"; import { isInTable, moveTableColumn, TableMap } from "prosemirror-tables"; import { addColumnBefore, selectColumn } from "../commands/table"; +import { isMobile } from "../../utils/browser"; import { getCellAttrs, isValidCellAlignment, @@ -326,7 +327,9 @@ export default class TableHeader extends Node { ) ); - if (!isDragging) { + // The add-column affordance is too small to tap on mobile, where + // columns can be added via the inline menu instead. + if (!isDragging && !isMobile()) { if (index === 0) { decorations.push(buildAddColumnDecoration(pos, index)); } diff --git a/shared/editor/nodes/TableRow.ts b/shared/editor/nodes/TableRow.ts index 69998793dd..2835f9b291 100644 --- a/shared/editor/nodes/TableRow.ts +++ b/shared/editor/nodes/TableRow.ts @@ -8,6 +8,7 @@ import { Decoration, DecorationSet } from "prosemirror-view"; import type { EditorView } from "prosemirror-view"; import { Plugin } from "prosemirror-state"; import { addRowBefore, selectRow, selectTable } from "../commands/table"; +import { isMobile } from "../../utils/browser"; import { getCellsInRow, getRowsInTable, @@ -339,7 +340,9 @@ export default class TableRow extends Node { ) ); - if (!isDragging) { + // The add-row affordance is too small to tap on mobile, where + // rows can be added via the inline menu instead. + if (!isDragging && !isMobile()) { if (index === 0) { decorations.push(buildAddRowDecoration(pos, index)); } diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 2e5fa73848..7d8181369a 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -17,6 +17,14 @@ export enum TableLayout { fullWidth = "full-width", } +/** How a selection toolbar menu is presented. */ +export enum MenuType { + /** A horizontal strip of buttons; nested options open behind a trigger. */ + toolbar = "toolbar", + /** A vertical menu rendered directly, anchored to the selection. */ + inline = "inline", +} + type Section = ({ t }: { t: TFunction }) => string; export type MenuItem = { @@ -140,6 +148,12 @@ export interface SelectionToolbarMenuDescriptor { priority: number; /** Toolbar alignment when this menu is active. Defaults to "center". */ align?: "center" | "start" | "end"; + /** + * How the menu is presented. "toolbar" (default) renders a horizontal strip + * of buttons; "inline" renders a vertical menu anchored to the selection + * without requiring a trigger button. + */ + variant?: MenuType; /** * Returns the menu items to display for the current selection. * diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 891a2aa297..637f688062 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -567,6 +567,7 @@ "Replacement": "Replacement", "Replace": "Replace", "Replace all": "Replace all", + "Options": "Options", "Go to link": "Go to link", "Open link": "Open link", "Remove link": "Remove link", @@ -647,10 +648,13 @@ "Edit image URL": "Edit image URL", "Default width": "Default width", "Distribute columns": "Distribute columns", - "Delete table": "Delete table", "Export as CSV": "Export as CSV", + "Delete table": "Delete table", + "Align": "Align", + "Sort": "Sort", "Sort ascending": "Sort ascending", "Sort descending": "Sort descending", + "Background": "Background", "Toggle header": "Toggle header", "Insert after": "Insert after", "Insert before": "Insert before", diff --git a/yarn.lock b/yarn.lock index b213a17b70..55e1e8f000 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4817,7 +4817,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-slot@npm:1.2.4": +"@radix-ui/react-slot@npm:1.2.4, @radix-ui/react-slot@npm:^1.2.3": version: 1.2.4 resolution: "@radix-ui/react-slot@npm:1.2.4" dependencies: @@ -15087,6 +15087,7 @@ __metadata: "@radix-ui/react-one-time-password-field": "npm:^0.1.8" "@radix-ui/react-popover": "npm:^1.1.15" "@radix-ui/react-select": "npm:^2.2.6" + "@radix-ui/react-slot": "npm:^1.2.3" "@radix-ui/react-switch": "npm:^1.2.6" "@radix-ui/react-tabs": "npm:^1.1.13" "@radix-ui/react-toolbar": "npm:^1.1.11" @@ -15299,6 +15300,7 @@ __metadata: react-merge-refs: "npm:^2.1.1" react-portal: "npm:^4.3.0" react-refresh: "npm:^0.18.0" + react-remove-scroll: "npm:^2.7.2" react-router-dom: "npm:^5.3.4" react-use-measure: "npm:^2.1.7" react-virtualized-auto-sizer: "npm:^1.0.26" @@ -16853,7 +16855,7 @@ __metadata: languageName: node linkType: hard -"react-remove-scroll@npm:^2.6.3": +"react-remove-scroll@npm:^2.6.3, react-remove-scroll@npm:^2.7.2": version: 2.7.2 resolution: "react-remove-scroll@npm:2.7.2" dependencies: