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",
"@radix-ui/react-avatar": "^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-dropdown-menu": "^2.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 { useContext } from 'react';
import type { MenuItemProps } from '../menu.types';
import { useMenuItem } from '../use-menu-item';
import { DesktopMenuContext } from './context';
export const DesktopMenuItem = (props: MenuItemProps) => {
const { type } = useContext(DesktopMenuContext);
const { className, children, otherProps } = useMenuItem(props);
return (
<DropdownMenu.Item className={className} {...otherProps}>
{children}
</DropdownMenu.Item>
);
if (type === 'dropdown-menu') {
return (
<DropdownMenu.Item className={className} {...otherProps}>
{children}
</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 * as styles from '../styles.css';
import { DesktopMenuContext } from './context';
import * as desktopStyles from './styles.css';
const MenuContextValue = {
type: 'dropdown-menu',
} as const;
export const DesktopMenu = ({
children,
items,
@@ -53,37 +58,39 @@ export const DesktopMenu = ({
const ContentWrapper = noPortal ? React.Fragment : DropdownMenu.Portal;
return (
<DropdownMenu.Root
modal={modal ?? false}
open={finalOpen}
onOpenChange={handleOpenChange}
{...rootOptions}
>
<DropdownMenu.Trigger
asChild
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
<DesktopMenuContext.Provider value={MenuContextValue}>
<DropdownMenu.Root
modal={modal ?? false}
open={finalOpen}
onOpenChange={handleOpenChange}
{...rootOptions}
>
{children}
</DropdownMenu.Trigger>
<ContentWrapper {...portalOptions}>
<DropdownMenu.Content
className={clsx(
styles.menuContent,
desktopStyles.contentAnimation,
className
)}
sideOffset={4}
align="start"
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherContentOptions}
<DropdownMenu.Trigger
asChild
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{items}
</DropdownMenu.Content>
</ContentWrapper>
</DropdownMenu.Root>
{children}
</DropdownMenu.Trigger>
<ContentWrapper {...portalOptions}>
<DropdownMenu.Content
className={clsx(
styles.menuContent,
desktopStyles.contentAnimation,
className
)}
sideOffset={4}
align="start"
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherContentOptions}
>
{items}
</DropdownMenu.Content>
</ContentWrapper>
</DropdownMenu.Root>
</DesktopMenuContext.Provider>
);
};

View File

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

View File

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

View File

@@ -34,6 +34,7 @@
"@marsidev/react-turnstile": "^1.1.0",
"@preact/signals-core": "^1.8.0",
"@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-popover": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.2.2",

View File

@@ -1,5 +1,6 @@
import {
Checkbox,
ContextMenu,
DragHandle as DragHandleIcon,
Tooltip,
useDraggable,
@@ -29,7 +30,7 @@ import { PagePreview } from '../../page-list/page-content-preview';
import { DocExplorerContext } from '../context';
import { quickActions } from '../quick-actions.constants';
import * as styles from './doc-list-item.css';
import { MoreMenuButton } from './more-menu';
import { MoreMenuButton, MoreMenuContent } from './more-menu';
import { CardViewProperties, ListViewProperties } from './properties';
export type DocListItemView = 'list' | 'grid' | 'masonry';
@@ -314,38 +315,46 @@ export const ListViewDoc = ({ docId }: DocListItemProps) => {
const t = useI18n();
const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId));
const contextValue = useContext(DocExplorerContext);
const showMoreOperation = useLiveData(contextValue.showMoreOperation$);
if (!doc) {
return null;
}
return (
<li className={styles.listViewRoot}>
<DragHandle id={docId} className={styles.listDragHandle} />
<Select id={docId} className={styles.listSelect} />
<DocIcon id={docId} className={styles.listIcon} />
<div className={styles.listBrief}>
<DocTitle
id={docId}
className={styles.listTitle}
data-testid="doc-list-item-title"
<ContextMenu
asChild
disabled={!showMoreOperation}
items={<MoreMenuContent docId={docId} />}
>
<li className={styles.listViewRoot}>
<DragHandle id={docId} className={styles.listDragHandle} />
<Select id={docId} className={styles.listSelect} />
<DocIcon id={docId} className={styles.listIcon} />
<div className={styles.listBrief}>
<DocTitle
id={docId}
className={styles.listTitle}
data-testid="doc-list-item-title"
/>
<DocPreview id={docId} className={styles.listPreview} />
</div>
<div className={styles.listSpace} />
<ListViewProperties docId={docId} />
{quickActions.map(action => {
return (
<Tooltip key={action.key} content={t.t(action.name)}>
<action.Component doc={doc} />
</Tooltip>
);
})}
<MoreMenuButton
docId={docId}
contentOptions={listMoreMenuContentOptions}
/>
<DocPreview id={docId} className={styles.listPreview} />
</div>
<div className={styles.listSpace} />
<ListViewProperties docId={docId} />
{quickActions.map(action => {
return (
<Tooltip key={action.key} content={t.t(action.name)}>
<action.Component doc={doc} />
</Tooltip>
);
})}
<MoreMenuButton
docId={docId}
contentOptions={listMoreMenuContentOptions}
/>
</li>
</li>
</ContextMenu>
);
};

View File

@@ -1,4 +1,5 @@
import {
ContextMenu,
DropIndicator,
type DropTargetDropEvent,
type DropTargetOptions,
@@ -466,47 +467,54 @@ export const NavigationPanelTreeNode = ({
ref={rootRef}
{...otherProps}
>
<div
className={clsx(styles.contentContainer, styles.draggedOverEffect)}
data-open={!collapsed}
data-self-dragged-over={isSelfDraggedOver}
ref={dropTargetRef}
<ContextMenu
asChild
items={menuOperations.map(({ view, index }) => (
<Fragment key={index}>{view}</Fragment>
))}
>
{to ? (
<LinkComponent
to={to}
className={styles.linkItemRoot}
ref={dragRef}
draggable={false}
>
{content}
</LinkComponent>
) : (
<div ref={dragRef}>{content}</div>
)}
<CustomDragPreview>
<div className={styles.draggingContainer}>{content}</div>
</CustomDragPreview>
{treeInstruction &&
// Do not show drop indicator for self dragged over
!(treeInstruction.type !== 'reparent' && isSelfDraggedOver) &&
treeInstruction.type !== 'instruction-blocked' && (
<DropIndicator instruction={treeInstruction} />
<div
className={clsx(styles.contentContainer, styles.draggedOverEffect)}
data-open={!collapsed}
data-self-dragged-over={isSelfDraggedOver}
ref={dropTargetRef}
>
{to ? (
<LinkComponent
to={to}
className={styles.linkItemRoot}
ref={dragRef}
draggable={false}
>
{content}
</LinkComponent>
) : (
<div ref={dragRef}>{content}</div>
)}
{draggedOver &&
dropEffect &&
draggedOverPosition &&
!isSelfDraggedOver &&
draggedOverDraggable && (
<DropEffect
dropEffect={dropEffect({
source: draggedOverDraggable,
treeInstruction: treeInstruction,
})}
position={draggedOverPosition}
/>
)}
</div>
<CustomDragPreview>
<div className={styles.draggingContainer}>{content}</div>
</CustomDragPreview>
{treeInstruction &&
// Do not show drop indicator for self dragged over
!(treeInstruction.type !== 'reparent' && isSelfDraggedOver) &&
treeInstruction.type !== 'instruction-blocked' && (
<DropIndicator instruction={treeInstruction} />
)}
{draggedOver &&
dropEffect &&
draggedOverPosition &&
!isSelfDraggedOver &&
draggedOverDraggable && (
<DropEffect
dropEffect={dropEffect({
source: draggedOverDraggable,
treeInstruction: treeInstruction,
})}
position={draggedOverPosition}
/>
)}
</div>
</ContextMenu>
<Collapsible.Content style={{ display: dragging ? 'none' : undefined }}>
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
<div className={styles.collapseContentPlaceholder}>

View File

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