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

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