diff --git a/src/components/NoteList.contextMenu.test.tsx b/src/components/NoteList.contextMenu.test.tsx new file mode 100644 index 00000000..125e43d3 --- /dev/null +++ b/src/components/NoteList.contextMenu.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' + +describe('NoteList context menu', () => { + it('opens note actions from a right-clicked note item', () => { + const onOpenInNewWindow = vi.fn() + const onEnterNeighborhood = vi.fn() + const onBulkArchive = vi.fn() + const onBulkDeletePermanently = vi.fn() + + renderNoteList({ + onOpenInNewWindow, + onEnterNeighborhood, + onBulkArchive, + onBulkDeletePermanently, + }) + + fireEvent.contextMenu(screen.getByText('Build Laputa App')) + + expect(screen.getByTestId('note-list-context-menu')).toBeInTheDocument() + fireEvent.click(screen.getByText('Open in New Window')) + expect(onOpenInNewWindow).toHaveBeenCalledWith(mockEntries[0]) + + fireEvent.contextMenu(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText("Open note's neighborhood")) + expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0]) + + fireEvent.contextMenu(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Archive this note')) + expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path]) + + fireEvent.contextMenu(screen.getByText('Build Laputa App')) + fireEvent.click(screen.getByText('Delete this note')) + expect(onBulkDeletePermanently).toHaveBeenCalledWith([mockEntries[0].path]) + }) +}) diff --git a/src/components/note-list/NoteListContextMenu.tsx b/src/components/note-list/NoteListContextMenu.tsx new file mode 100644 index 00000000..02d7ccb4 --- /dev/null +++ b/src/components/note-list/NoteListContextMenu.tsx @@ -0,0 +1,96 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from 'react' +import type { AppLocale } from '../../lib/i18n' +import { trackEvent } from '../../lib/telemetry' +import type { VaultEntry } from '../../types' +import { NoteListContextMenuNode } from './NoteListContextMenuView' + +export type NoteListContextMenuState = { + x: number + y: number + entry: VaultEntry +} + +interface NoteListContextMenuParams { + locale?: AppLocale + onEnterNeighborhood?: (entry: VaultEntry) => void + onOpenInNewWindow?: (entry: VaultEntry) => void + onArchivePaths?: (paths: string[]) => void + onDeletePaths?: (paths: string[]) => void +} + +function hasNoteListContextActions({ + entry, + onEnterNeighborhood, + onOpenInNewWindow, + onArchivePaths, + onDeletePaths, +}: NoteListContextMenuParams & { entry: VaultEntry }) { + return Boolean( + onOpenInNewWindow + || (onEnterNeighborhood && entry.fileKind !== 'binary') + || (onArchivePaths && !entry.archived) + || onDeletePaths, + ) +} + +export function useNoteListContextMenu({ + locale = 'en', + onEnterNeighborhood, + onOpenInNewWindow, + onArchivePaths, + onDeletePaths, +}: NoteListContextMenuParams) { + const [ctxMenu, setCtxMenu] = useState(null) + const ctxMenuRef = useRef(null) + const closeContextMenu = useCallback(() => setCtxMenu(null), []) + + useEffect(() => { + if (!ctxMenu) return + + const handleOutsideClick = (event: MouseEvent) => { + if (ctxMenuRef.current && !ctxMenuRef.current.contains(event.target as Node)) closeContextMenu() + } + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeContextMenu() + } + + document.addEventListener('mousedown', handleOutsideClick) + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handleOutsideClick) + document.removeEventListener('keydown', handleEscape) + } + }, [ctxMenu, closeContextMenu]) + + const handleNoteContextMenu = useCallback((entry: VaultEntry, event: ReactMouseEvent) => { + if (!hasNoteListContextActions({ entry, onEnterNeighborhood, onOpenInNewWindow, onArchivePaths, onDeletePaths })) return + event.preventDefault() + event.stopPropagation() + trackEvent('note_item_context_menu_opened') + setCtxMenu({ x: event.clientX, y: event.clientY, entry }) + }, [onArchivePaths, onDeletePaths, onEnterNeighborhood, onOpenInNewWindow]) + + const contextMenuNode = ( + + ) + + return { + handleNoteContextMenu, + contextMenuNode, + } +} diff --git a/src/components/note-list/NoteListContextMenuView.tsx b/src/components/note-list/NoteListContextMenuView.tsx new file mode 100644 index 00000000..68b9951f --- /dev/null +++ b/src/components/note-list/NoteListContextMenuView.tsx @@ -0,0 +1,90 @@ +import type { RefObject } from 'react' +import { Archive, ArrowSquareOut, MapTrifold, Trash } from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' +import { translate, type AppLocale } from '../../lib/i18n' +import { trackEvent } from '../../lib/telemetry' +import type { VaultEntry } from '../../types' +import type { NoteListContextMenuState } from './NoteListContextMenu' + +export function NoteListContextMenuNode({ + ctxMenu, + ctxMenuRef, + locale, + onEnterNeighborhood, + onOpenInNewWindow, + onArchivePaths, + onDeletePaths, + onClose, +}: { + ctxMenu: NoteListContextMenuState | null + ctxMenuRef: RefObject + locale: AppLocale + onEnterNeighborhood?: (entry: VaultEntry) => void + onOpenInNewWindow?: (entry: VaultEntry) => void + onArchivePaths?: (paths: string[]) => void + onDeletePaths?: (paths: string[]) => void + onClose: () => void +}) { + if (!ctxMenu) return null + + const { entry } = ctxMenu + const selectAction = (action: string, run: () => void) => { + trackEvent('note_item_context_menu_action', { action }) + onClose() + run() + } + + return ( +
+ {onOpenInNewWindow && ( + + )} + {onEnterNeighborhood && entry.fileKind !== 'binary' && ( + + )} + {onArchivePaths && !entry.archived && ( + + )} + {onDeletePaths && ( + + )} +
+ ) +} diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index fc4b0b4c..90749094 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -30,6 +30,7 @@ import { useVisibleNotesSync, } from './noteListHooks' import { useChangesContextMenu } from './NoteListChangesMenu' +import { useNoteListContextMenu } from './NoteListContextMenu' import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents' import { useDateDisplayFormat } from '../../hooks/useAppPreferences' @@ -337,6 +338,13 @@ function useNoteListInteractionState({ locale, }: UseNoteListInteractionStateParams) { const changesContextMenu = useChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles, locale }) + const noteListContextMenu = useNoteListContextMenu({ + locale, + onEnterNeighborhood, + onOpenInNewWindow, + onArchivePaths: onBulkArchive, + onDeletePaths: onBulkDeletePermanently, + }) const { collapsedGroups, handleClickNote, @@ -382,6 +390,7 @@ function useNoteListInteractionState({ handleCreateNote, handleListKeyDown, multiSelect, + noteListContextMenu, noteListKeyboard, toggleGroup, } @@ -397,7 +406,8 @@ interface UseRenderItemParams { resolvedGetNoteStatus: (path: string) => NoteStatus getChangeStatus: (path: string) => ModifiedFile['status'] | undefined handleClickNote: (entry: VaultEntry, event: React.MouseEvent) => void - noteContextMenu?: ((entry: VaultEntry, event: React.MouseEvent) => void) | undefined + changesContextMenu?: ((entry: VaultEntry, event: React.MouseEvent) => void) | undefined + noteListContextMenu?: ((entry: VaultEntry, event: React.MouseEvent) => void) | undefined multiSelect: MultiSelectState noteListKeyboard: { highlightedPath: string | null } } @@ -412,11 +422,12 @@ function useRenderItem({ resolvedGetNoteStatus, getChangeStatus, handleClickNote, - noteContextMenu, + changesContextMenu, + noteListContextMenu, multiSelect, noteListKeyboard, }: UseRenderItemParams) { - const contextMenuHandler = isChangesView && onDiscardFile ? noteContextMenu : undefined + const contextMenuHandler = isChangesView && onDiscardFile ? changesContextMenu : noteListContextMenu return useCallback((entry: VaultEntry, options?: { forceSelected?: boolean }) => ( isDeletedNoteEntry(entry) ? ( @@ -581,7 +592,12 @@ function buildNoteListLayoutModel(params: { handleBulkArchive: params.interaction.handleBulkArchive, handleBulkDeletePermanently: params.interaction.handleBulkDeletePermanently, handleBulkUnarchive: params.interaction.handleBulkUnarchive, - contextMenuNode: params.interaction.changesContextMenu.contextMenuNode, + contextMenuNode: ( + <> + {params.interaction.changesContextMenu.contextMenuNode} + {params.interaction.noteListContextMenu.contextMenuNode} + + ), dialogNode: params.interaction.changesContextMenu.dialogNode, } } @@ -680,7 +696,8 @@ export function useNoteListModel({ resolvedGetNoteStatus, getChangeStatus: interaction.getChangeStatus, handleClickNote: interaction.handleClickNote, - noteContextMenu: interaction.changesContextMenu.handleNoteContextMenu, + changesContextMenu: interaction.changesContextMenu.handleNoteContextMenu, + noteListContextMenu: interaction.noteListContextMenu.handleNoteContextMenu, multiSelect: interaction.multiSelect, noteListKeyboard: interaction.noteListKeyboard, })