feat(core): add context menu for navigation and explorer (#13216)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a customizable context menu component for desktop
interfaces, enabling right-click menus in various UI elements.
* Added context menu support to document list items and navigation tree
nodes, allowing users to access additional operations via right-click.
* **Improvements**
* Enhanced submenu and menu item components to support both dropdown and
context menu variants based on context.
* Updated click handling in workbench links to prevent unintended
actions on non-left mouse button clicks.
* **Chores**
* Added `@radix-ui/react-context-menu` as a dependency to relevant
frontend packages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-07-16 12:40:10 +08:00
committed by GitHub
parent d44771dfe9
commit cdff5c3117
12 changed files with 247 additions and 106 deletions

View File

@@ -34,6 +34,7 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-dropdown-menu": "^2.1.3", "@radix-ui/react-dropdown-menu": "^2.1.3",
"@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-popover": "^1.1.3",

View File

@@ -0,0 +1,58 @@
import * as RadixContextMenu from '@radix-ui/react-context-menu';
import clsx from 'clsx';
import type { RefAttributes } from 'react';
import * as styles from '../styles.css';
import { DesktopMenuContext } from './context';
import * as desktopStyles from './styles.css';
export type ContextMenuProps = RadixContextMenu.ContextMenuProps &
RadixContextMenu.ContextMenuTriggerProps &
RefAttributes<HTMLSpanElement> & {
items: React.ReactNode;
contentProps?: RadixContextMenu.ContextMenuContentProps;
};
const ContextMenuContextValue = {
type: 'context-menu',
} as const;
export const ContextMenu = ({
children,
onOpenChange,
dir,
modal,
items,
contentProps,
...props
}: ContextMenuProps) => {
return (
<DesktopMenuContext.Provider value={ContextMenuContextValue}>
<RadixContextMenu.Root
onOpenChange={onOpenChange}
dir={dir}
modal={modal}
>
<RadixContextMenu.Trigger {...props}>
{children}
</RadixContextMenu.Trigger>
<RadixContextMenu.Portal>
<RadixContextMenu.Content
className={clsx(
styles.menuContent,
desktopStyles.contentAnimation,
contentProps?.className
)}
style={{
zIndex: 'var(--affine-z-index-popover)',
...contentProps?.style,
}}
{...contentProps}
>
{items}
</RadixContextMenu.Content>
</RadixContextMenu.Portal>
</RadixContextMenu.Root>
</DesktopMenuContext.Provider>
);
};

View File

@@ -0,0 +1,9 @@
import { createContext } from 'react';
interface DesktopMenuContextValue {
type: 'dropdown-menu' | 'context-menu';
}
export const DesktopMenuContext = createContext<DesktopMenuContextValue>({
type: 'dropdown-menu',
});

View File

@@ -1,13 +1,30 @@
import * as ContextMenu from '@radix-ui/react-context-menu';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useContext } from 'react';
import type { MenuItemProps } from '../menu.types'; import type { MenuItemProps } from '../menu.types';
import { useMenuItem } from '../use-menu-item'; import { useMenuItem } from '../use-menu-item';
import { DesktopMenuContext } from './context';
export const DesktopMenuItem = (props: MenuItemProps) => { export const DesktopMenuItem = (props: MenuItemProps) => {
const { type } = useContext(DesktopMenuContext);
const { className, children, otherProps } = useMenuItem(props); const { className, children, otherProps } = useMenuItem(props);
if (type === 'dropdown-menu') {
return ( return (
<DropdownMenu.Item className={className} {...otherProps}> <DropdownMenu.Item className={className} {...otherProps}>
{children} {children}
</DropdownMenu.Item> </DropdownMenu.Item>
); );
}
if (type === 'context-menu') {
return (
<ContextMenu.Item className={className} {...otherProps}>
{children}
</ContextMenu.Item>
);
}
return null;
}; };

View File

@@ -4,8 +4,13 @@ import React, { useCallback, useImperativeHandle, useState } from 'react';
import type { MenuProps } from '../menu.types'; import type { MenuProps } from '../menu.types';
import * as styles from '../styles.css'; import * as styles from '../styles.css';
import { DesktopMenuContext } from './context';
import * as desktopStyles from './styles.css'; import * as desktopStyles from './styles.css';
const MenuContextValue = {
type: 'dropdown-menu',
} as const;
export const DesktopMenu = ({ export const DesktopMenu = ({
children, children,
items, items,
@@ -53,6 +58,7 @@ export const DesktopMenu = ({
const ContentWrapper = noPortal ? React.Fragment : DropdownMenu.Portal; const ContentWrapper = noPortal ? React.Fragment : DropdownMenu.Portal;
return ( return (
<DesktopMenuContext.Provider value={MenuContextValue}>
<DropdownMenu.Root <DropdownMenu.Root
modal={modal ?? false} modal={modal ?? false}
open={finalOpen} open={finalOpen}
@@ -85,5 +91,6 @@ export const DesktopMenu = ({
</DropdownMenu.Content> </DropdownMenu.Content>
</ContentWrapper> </ContentWrapper>
</DropdownMenu.Root> </DropdownMenu.Root>
</DesktopMenuContext.Provider>
); );
}; };

View File

@@ -1,11 +1,13 @@
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc'; import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import * as ContextMenu from '@radix-ui/react-context-menu';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react'; import { useContext, useMemo } from 'react';
import type { MenuSubProps } from '../menu.types'; import type { MenuSubProps } from '../menu.types';
import * as styles from '../styles.css'; import * as styles from '../styles.css';
import { useMenuItem } from '../use-menu-item'; import { useMenuItem } from '../use-menu-item';
import { DesktopMenuContext } from './context';
export const DesktopMenuSub = ({ export const DesktopMenuSub = ({
children: propsChildren, children: propsChildren,
@@ -19,12 +21,37 @@ export const DesktopMenuSub = ({
...otherSubContentOptions ...otherSubContentOptions
} = {}, } = {},
}: MenuSubProps) => { }: MenuSubProps) => {
const { type } = useContext(DesktopMenuContext);
const { className, children, otherProps } = useMenuItem({ const { className, children, otherProps } = useMenuItem({
children: propsChildren, children: propsChildren,
suffixIcon: <ArrowRightSmallIcon />, suffixIcon: <ArrowRightSmallIcon />,
...triggerOptions, ...triggerOptions,
}); });
const contentClassName = useMemo(
() => clsx(styles.menuContent, subContentClassName),
[subContentClassName]
);
if (type === 'context-menu') {
return (
<ContextMenu.Sub defaultOpen={defaultOpen} {...otherSubOptions}>
<ContextMenu.SubTrigger className={className} {...otherProps}>
{children}
</ContextMenu.SubTrigger>
<ContextMenu.Portal {...portalOptions}>
<ContextMenu.SubContent
className={contentClassName}
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherSubContentOptions}
>
{items}
</ContextMenu.SubContent>
</ContextMenu.Portal>
</ContextMenu.Sub>
);
}
return ( return (
<DropdownMenu.Sub defaultOpen={defaultOpen} {...otherSubOptions}> <DropdownMenu.Sub defaultOpen={defaultOpen} {...otherSubOptions}>
<DropdownMenu.SubTrigger className={className} {...otherProps}> <DropdownMenu.SubTrigger className={className} {...otherProps}>
@@ -32,10 +59,7 @@ export const DesktopMenuSub = ({
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.Portal {...portalOptions}> <DropdownMenu.Portal {...portalOptions}>
<DropdownMenu.SubContent <DropdownMenu.SubContent
className={useMemo( className={contentClassName}
() => clsx(styles.menuContent, subContentClassName),
[subContentClassName]
)}
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }} style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherSubContentOptions} {...otherSubContentOptions}
> >

View File

@@ -1,4 +1,5 @@
export * from './menu.types'; export * from './menu.types';
import { ContextMenu } from './desktop/context-menu';
import { DesktopMenuItem } from './desktop/item'; import { DesktopMenuItem } from './desktop/item';
import { DesktopMenu } from './desktop/root'; import { DesktopMenu } from './desktop/root';
import { DesktopMenuSeparator } from './desktop/separator'; import { DesktopMenuSeparator } from './desktop/separator';
@@ -19,6 +20,7 @@ const MenuSub = BUILD_CONFIG.isMobileEdition ? MobileMenuSub : DesktopMenuSub;
const Menu = BUILD_CONFIG.isMobileEdition ? MobileMenu : DesktopMenu; const Menu = BUILD_CONFIG.isMobileEdition ? MobileMenu : DesktopMenu;
export { export {
ContextMenu,
DesktopMenu, DesktopMenu,
DesktopMenuItem, DesktopMenuItem,
DesktopMenuSeparator, DesktopMenuSeparator,

View File

@@ -34,6 +34,7 @@
"@marsidev/react-turnstile": "^1.1.0", "@marsidev/react-turnstile": "^1.1.0",
"@preact/signals-core": "^1.8.0", "@preact/signals-core": "^1.8.0",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-context-menu": "^2.1.15",
"@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2",

View File

@@ -1,5 +1,6 @@
import { import {
Checkbox, Checkbox,
ContextMenu,
DragHandle as DragHandleIcon, DragHandle as DragHandleIcon,
Tooltip, Tooltip,
useDraggable, useDraggable,
@@ -29,7 +30,7 @@ import { PagePreview } from '../../page-list/page-content-preview';
import { DocExplorerContext } from '../context'; import { DocExplorerContext } from '../context';
import { quickActions } from '../quick-actions.constants'; import { quickActions } from '../quick-actions.constants';
import * as styles from './doc-list-item.css'; import * as styles from './doc-list-item.css';
import { MoreMenuButton } from './more-menu'; import { MoreMenuButton, MoreMenuContent } from './more-menu';
import { CardViewProperties, ListViewProperties } from './properties'; import { CardViewProperties, ListViewProperties } from './properties';
export type DocListItemView = 'list' | 'grid' | 'masonry'; export type DocListItemView = 'list' | 'grid' | 'masonry';
@@ -314,12 +315,19 @@ export const ListViewDoc = ({ docId }: DocListItemProps) => {
const t = useI18n(); const t = useI18n();
const docsService = useService(DocsService); const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId)); const doc = useLiveData(docsService.list.doc$(docId));
const contextValue = useContext(DocExplorerContext);
const showMoreOperation = useLiveData(contextValue.showMoreOperation$);
if (!doc) { if (!doc) {
return null; return null;
} }
return ( return (
<ContextMenu
asChild
disabled={!showMoreOperation}
items={<MoreMenuContent docId={docId} />}
>
<li className={styles.listViewRoot}> <li className={styles.listViewRoot}>
<DragHandle id={docId} className={styles.listDragHandle} /> <DragHandle id={docId} className={styles.listDragHandle} />
<Select id={docId} className={styles.listSelect} /> <Select id={docId} className={styles.listSelect} />
@@ -346,6 +354,7 @@ export const ListViewDoc = ({ docId }: DocListItemProps) => {
contentOptions={listMoreMenuContentOptions} contentOptions={listMoreMenuContentOptions}
/> />
</li> </li>
</ContextMenu>
); );
}; };

View File

@@ -1,4 +1,5 @@
import { import {
ContextMenu,
DropIndicator, DropIndicator,
type DropTargetDropEvent, type DropTargetDropEvent,
type DropTargetOptions, type DropTargetOptions,
@@ -465,6 +466,12 @@ export const NavigationPanelTreeNode = ({
})} })}
ref={rootRef} ref={rootRef}
{...otherProps} {...otherProps}
>
<ContextMenu
asChild
items={menuOperations.map(({ view, index }) => (
<Fragment key={index}>{view}</Fragment>
))}
> >
<div <div
className={clsx(styles.contentContainer, styles.draggedOverEffect)} className={clsx(styles.contentContainer, styles.draggedOverEffect)}
@@ -507,6 +514,7 @@ export const NavigationPanelTreeNode = ({
/> />
)} )}
</div> </div>
</ContextMenu>
<Collapsible.Content style={{ display: dragging ? 'none' : undefined }}> <Collapsible.Content style={{ display: dragging ? 'none' : undefined }}>
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */} {/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
<div className={styles.collapseContentPlaceholder}> <div className={styles.collapseContentPlaceholder}>

View File

@@ -70,6 +70,9 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
if (event.defaultPrevented) { if (event.defaultPrevented) {
return; return;
} }
if (event.button !== 0 && event.button !== 1) {
return;
}
const at = inferOpenAt(event); const at = inferOpenAt(event);
workbench.open(to, { at, replaceHistory, show: false }); workbench.open(to, { at, replaceHistory, show: false });
event.preventDefault(); event.preventDefault();

View File

@@ -320,6 +320,7 @@ __metadata:
"@emotion/styled": "npm:^11.14.0" "@emotion/styled": "npm:^11.14.0"
"@radix-ui/react-avatar": "npm:^1.1.2" "@radix-ui/react-avatar": "npm:^1.1.2"
"@radix-ui/react-collapsible": "npm:^1.1.2" "@radix-ui/react-collapsible": "npm:^1.1.2"
"@radix-ui/react-context-menu": "npm:^2.2.15"
"@radix-ui/react-dialog": "npm:^1.1.3" "@radix-ui/react-dialog": "npm:^1.1.3"
"@radix-ui/react-dropdown-menu": "npm:^2.1.3" "@radix-ui/react-dropdown-menu": "npm:^2.1.3"
"@radix-ui/react-popover": "npm:^1.1.3" "@radix-ui/react-popover": "npm:^1.1.3"
@@ -418,6 +419,7 @@ __metadata:
"@marsidev/react-turnstile": "npm:^1.1.0" "@marsidev/react-turnstile": "npm:^1.1.0"
"@preact/signals-core": "npm:^1.8.0" "@preact/signals-core": "npm:^1.8.0"
"@radix-ui/react-collapsible": "npm:^1.1.2" "@radix-ui/react-collapsible": "npm:^1.1.2"
"@radix-ui/react-context-menu": "npm:^2.1.15"
"@radix-ui/react-dialog": "npm:^1.1.3" "@radix-ui/react-dialog": "npm:^1.1.3"
"@radix-ui/react-popover": "npm:^1.1.3" "@radix-ui/react-popover": "npm:^1.1.3"
"@radix-ui/react-scroll-area": "npm:^1.2.2" "@radix-ui/react-scroll-area": "npm:^1.2.2"
@@ -11238,7 +11240,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@radix-ui/react-context-menu@npm:^2.2.3": "@radix-ui/react-context-menu@npm:^2.1.15, @radix-ui/react-context-menu@npm:^2.2.15, @radix-ui/react-context-menu@npm:^2.2.3":
version: 2.2.15 version: 2.2.15
resolution: "@radix-ui/react-context-menu@npm:2.2.15" resolution: "@radix-ui/react-context-menu@npm:2.2.15"
dependencies: dependencies: