refactor: consolidate sidebar item interactions
This commit is contained in:
@@ -309,6 +309,7 @@ type SidebarSelection =
|
||||
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
|
||||
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
|
||||
- `src/components/sidebar/sidebarHooks.ts` owns the shared sidebar interaction primitives for menu positioning/dismissal and inline rename input behavior. Folder, Type, and saved View rows keep their domain-specific actions local, but use those primitives so right-click menus and rename fields have the same outside-click, Escape, focus, blur, and submit semantics.
|
||||
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
|
||||
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
|
||||
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
|
||||
|
||||
@@ -287,6 +287,21 @@ describe('FolderTree', () => {
|
||||
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
|
||||
})
|
||||
|
||||
it('dismisses the folder context menu on Escape', () => {
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={vi.fn()}
|
||||
onDeleteFolder={vi.fn()}
|
||||
onStartRenameFolder={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.contextMenu(screen.getByText('projects'))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('folder-context-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens folder file actions from the context menu', () => {
|
||||
const onRevealFolder = vi.fn()
|
||||
const onCopyFolderPath = vi.fn()
|
||||
|
||||
@@ -1290,6 +1290,16 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Delete view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens and dismisses the View context menu from the keyboard', () => {
|
||||
renderViewActions()
|
||||
const row = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]')!
|
||||
fireEvent.keyDown(row, { key: 'F10', shiftKey: true })
|
||||
expect(screen.getByText('Edit view')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByText('Edit view')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onEditView from the context menu', () => {
|
||||
const onEditView = vi.fn()
|
||||
renderViewActions({ onEditView })
|
||||
|
||||
@@ -80,6 +80,12 @@ describe('Sidebar Type row actions', () => {
|
||||
expect(screen.getByText('Delete type')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses the type context menu on Escape', () => {
|
||||
openProjectsContextMenu()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByText('Rename type…')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onDeleteType from the context menu', () => {
|
||||
const onDeleteType = vi.fn()
|
||||
renderSidebar({ onDeleteType })
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import { type ComponentType } from 'react'
|
||||
import type { SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
import { SIDEBAR_ITEM_PADDING } from './sidebar/sidebarStyles'
|
||||
import { useSidebarInlineRenameInput } from './sidebar/sidebarHooks'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
|
||||
const SIDEBAR_COUNT_PILL_STYLE = {
|
||||
@@ -336,27 +338,28 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel, locale }: {
|
||||
onCancel: () => void
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onSubmit(value.trim()) }
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel() }
|
||||
}
|
||||
const {
|
||||
handleKeyDown,
|
||||
inputRef,
|
||||
setValue,
|
||||
submitValue,
|
||||
value,
|
||||
} = useSidebarInlineRenameInput({
|
||||
initialValue,
|
||||
onCancel,
|
||||
onSubmit: (nextValue) => onSubmit(nextValue.trim()),
|
||||
})
|
||||
|
||||
return (
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSubmit(value.trim())}
|
||||
onBlur={() => { void submitValue() }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={translate(locale ?? 'en', 'sidebar.section.name')}
|
||||
className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none"
|
||||
style={{ padding: '1px 4px' }}
|
||||
className="h-auto min-h-0 flex-1 rounded border-primary bg-background px-1 py-px text-[13px] font-medium text-foreground"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Folder } from '@phosphor-icons/react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useSidebarInlineRenameInput } from '../sidebar/sidebarHooks'
|
||||
|
||||
interface FolderNameInputProps {
|
||||
ariaLabel: string
|
||||
@@ -25,26 +25,18 @@ export function FolderNameInput({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: FolderNameInputProps) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current
|
||||
if (!input) return
|
||||
input.focus()
|
||||
if (selectTextOnFocus) input.select()
|
||||
}, [selectTextOnFocus])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (submittingRef.current) return false
|
||||
submittingRef.current = true
|
||||
try {
|
||||
return await onSubmit(value)
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
}
|
||||
}, [onSubmit, value])
|
||||
const {
|
||||
handleKeyDown,
|
||||
inputRef,
|
||||
setValue,
|
||||
submitValue,
|
||||
value,
|
||||
} = useSidebarInlineRenameInput({
|
||||
initialValue,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
selectTextOnFocus,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded" style={{ paddingTop: 6, paddingBottom: 6, paddingRight: 16, paddingLeft: leftInset, borderRadius: 4 }}>
|
||||
@@ -55,17 +47,8 @@ export function FolderNameInput({
|
||||
className="h-auto min-h-0 flex-1 rounded-sm px-2 py-[3px] text-[13px] font-medium"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={submitOnBlur ? () => { void handleSubmit() } : undefined}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
onBlur={submitOnBlur ? () => { void submitValue() } : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
data-testid={testId}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type RefObject } from 'react'
|
||||
import { useCallback, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import type { FolderNode } from '../../types'
|
||||
import type { FolderFileActions } from '../../hooks/useFileActions'
|
||||
import type { FolderContextMenuState } from './FolderContextMenu'
|
||||
import { useSidebarContextMenu } from '../sidebar/sidebarHooks'
|
||||
|
||||
interface UseFolderContextMenuInput {
|
||||
onDeleteFolder?: (folderPath: string) => void
|
||||
@@ -9,50 +9,21 @@ interface UseFolderContextMenuInput {
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
}
|
||||
|
||||
function useContextMenuDismiss(
|
||||
contextMenu: FolderContextMenuState | null,
|
||||
menuRef: RefObject<HTMLDivElement | null>,
|
||||
closeContextMenu: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.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)
|
||||
}
|
||||
}, [closeContextMenu, contextMenu, menuRef])
|
||||
}
|
||||
|
||||
export function useFolderContextMenu({
|
||||
onDeleteFolder,
|
||||
folderFileActions,
|
||||
onStartRenameFolder,
|
||||
}: UseFolderContextMenuInput) {
|
||||
const [contextMenu, setContextMenu] = useState<FolderContextMenuState | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const closeContextMenu = useCallback(() => setContextMenu(null), [])
|
||||
useContextMenuDismiss(contextMenu, menuRef, closeContextMenu)
|
||||
const {
|
||||
closeContextMenu,
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
openContextMenuFromPointer,
|
||||
} = useSidebarContextMenu<string>()
|
||||
|
||||
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setContextMenu({
|
||||
path: node.path,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}, [])
|
||||
openContextMenuFromPointer(node.path, event)
|
||||
}, [openContextMenuFromPointer])
|
||||
|
||||
const handleRenameFromMenu = useCallback((folderPath: string) => {
|
||||
closeContextMenu()
|
||||
@@ -73,15 +44,20 @@ export function useFolderContextMenu({
|
||||
closeContextMenu()
|
||||
folderFileActions?.copyFolderPath(folderPath)
|
||||
}, [closeContextMenu, folderFileActions])
|
||||
const menu = contextMenu ? {
|
||||
path: contextMenu.target,
|
||||
x: contextMenu.pos.x,
|
||||
y: contextMenu.pos.y,
|
||||
} : null
|
||||
|
||||
return {
|
||||
closeContextMenu,
|
||||
contextMenu,
|
||||
contextMenu: menu,
|
||||
handleCopyPathFromMenu,
|
||||
handleDeleteFromMenu,
|
||||
handleOpenMenu,
|
||||
handleRevealFromMenu,
|
||||
handleRenameFromMenu,
|
||||
menuRef,
|
||||
menuRef: contextMenuRef,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
useEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject,
|
||||
type ReactNode, type RefObject,
|
||||
} from 'react'
|
||||
import {
|
||||
Palette, PencilSimple, Trash,
|
||||
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
|
||||
import type { ViewDefinition, ViewFile } from '../../types'
|
||||
import { translate, type AppLocale } from '../../lib/i18n'
|
||||
import { TypeCustomizePopover } from '../TypeCustomizePopover'
|
||||
import { useSidebarInlineRenameInput } from './sidebarHooks'
|
||||
|
||||
export interface MenuPosition {
|
||||
x: number
|
||||
@@ -28,23 +29,13 @@ export function ViewRenameInput({
|
||||
onCancel: () => void
|
||||
onSubmit: (value: string) => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onSubmit(value)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
const {
|
||||
handleKeyDown,
|
||||
inputRef,
|
||||
setValue,
|
||||
submitValue,
|
||||
value,
|
||||
} = useSidebarInlineRenameInput({ initialValue, onCancel, onSubmit })
|
||||
|
||||
return (
|
||||
<Input
|
||||
@@ -52,7 +43,7 @@ export function ViewRenameInput({
|
||||
aria-label={translate(locale, 'sidebar.view.name')}
|
||||
className="h-6 min-w-0 flex-1 rounded border-primary bg-background px-1.5 py-0 text-[13px] font-medium"
|
||||
value={value}
|
||||
onBlur={() => onSubmit(value)}
|
||||
onBlur={() => { void submitValue() }}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react'
|
||||
import {
|
||||
useState, useMemo, useEffect, useCallback, useRef,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type RefObject,
|
||||
} from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
|
||||
import { buildTypeEntryMap } from '../../utils/typeColors'
|
||||
@@ -8,7 +12,50 @@ import type { AllNotesFileVisibility } from '../../utils/allNotesFileVisibility'
|
||||
|
||||
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
|
||||
|
||||
export function useOutsideClick(ref: RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
|
||||
export interface SidebarMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface SidebarContextMenuState<T> {
|
||||
target: T
|
||||
pos: SidebarMenuPosition
|
||||
}
|
||||
|
||||
interface PointerMenuEvent {
|
||||
clientX: number
|
||||
clientY: number
|
||||
preventDefault?: () => void
|
||||
stopPropagation?: () => void
|
||||
}
|
||||
|
||||
interface SidebarInlineRenameInputOptions {
|
||||
initialValue: string
|
||||
onCancel: () => void
|
||||
onSubmit: (value: string) => Promise<boolean> | boolean | void
|
||||
selectTextOnFocus?: boolean
|
||||
}
|
||||
|
||||
const KEYBOARD_MENU_FALLBACK: SidebarMenuPosition = { x: 20, y: 100 }
|
||||
|
||||
export function getPointerMenuPosition(event: PointerMenuEvent): SidebarMenuPosition {
|
||||
return { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
export function getElementMenuPosition(
|
||||
element: HTMLElement | null,
|
||||
fallback: SidebarMenuPosition = KEYBOARD_MENU_FALLBACK,
|
||||
): SidebarMenuPosition {
|
||||
const bounds = element?.getBoundingClientRect()
|
||||
if (!bounds) return fallback
|
||||
return { x: bounds.left + 16, y: bounds.top + bounds.height }
|
||||
}
|
||||
|
||||
export function useOutsideClick<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handler = (event: MouseEvent) => {
|
||||
@@ -19,6 +66,97 @@ export function useOutsideClick(ref: RefObject<HTMLElement | null>, isOpen: bool
|
||||
}, [ref, isOpen, onClose])
|
||||
}
|
||||
|
||||
export function useDismissableSidebarLayer<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
) {
|
||||
useOutsideClick(ref, isOpen, onClose)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [isOpen, onClose])
|
||||
}
|
||||
|
||||
export function useSidebarContextMenu<T>() {
|
||||
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState<T> | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const closeContextMenu = useCallback(() => setContextMenu(null), [])
|
||||
useDismissableSidebarLayer(contextMenuRef, !!contextMenu, closeContextMenu)
|
||||
|
||||
const openContextMenuAt = useCallback((target: T, pos: SidebarMenuPosition) => {
|
||||
setContextMenu({ target, pos })
|
||||
}, [])
|
||||
|
||||
const openContextMenuFromPointer = useCallback((target: T, event: PointerMenuEvent) => {
|
||||
event.preventDefault?.()
|
||||
event.stopPropagation?.()
|
||||
openContextMenuAt(target, getPointerMenuPosition(event))
|
||||
}, [openContextMenuAt])
|
||||
|
||||
return {
|
||||
closeContextMenu,
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
openContextMenuAt,
|
||||
openContextMenuFromPointer,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidebarInlineRenameInput({
|
||||
initialValue,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
selectTextOnFocus = true,
|
||||
}: SidebarInlineRenameInputOptions) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current
|
||||
if (!input) return
|
||||
input.focus()
|
||||
if (selectTextOnFocus) input.select()
|
||||
}, [selectTextOnFocus])
|
||||
|
||||
const submitValue = useCallback(async () => {
|
||||
if (submittingRef.current) return false
|
||||
submittingRef.current = true
|
||||
try {
|
||||
return await onSubmit(value)
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
}
|
||||
}, [onSubmit, value])
|
||||
|
||||
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void submitValue()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel, submitValue])
|
||||
|
||||
return {
|
||||
handleKeyDown,
|
||||
inputRef,
|
||||
setValue,
|
||||
submitValue,
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidebarSections(entries: VaultEntry[]) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const allSectionGroups = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { applyCustomization, useOutsideClick } from './sidebarHooks'
|
||||
import { applyCustomization, useOutsideClick, useSidebarContextMenu } from './sidebarHooks'
|
||||
|
||||
interface SidebarTypeGroup {
|
||||
type: string
|
||||
@@ -18,43 +18,39 @@ interface SidebarTypeInteractionsInput {
|
||||
|
||||
function useSidebarTypeState() {
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
|
||||
const [renamingType, setRenamingType] = useState<string | null>(null)
|
||||
const [renameInitialValue, setRenameInitialValue] = useState('')
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const customizeRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenuPos(null)
|
||||
setContextMenuType(null)
|
||||
}, [])
|
||||
const {
|
||||
closeContextMenu,
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
openContextMenuFromPointer,
|
||||
} = useSidebarContextMenu<string>()
|
||||
|
||||
const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), [])
|
||||
const closeCustomize = useCallback(() => setShowCustomize(false), [])
|
||||
const cancelRename = useCallback(() => setRenamingType(null), [])
|
||||
|
||||
useOutsideClick(customizeRef, showCustomize, closeCustomize)
|
||||
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
|
||||
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
|
||||
|
||||
return {
|
||||
cancelRename,
|
||||
closeContextMenu,
|
||||
closeCustomizeTarget,
|
||||
contextMenuPos,
|
||||
contextMenuPos: contextMenu?.pos ?? null,
|
||||
contextMenuRef,
|
||||
contextMenuType,
|
||||
contextMenuType: contextMenu?.target ?? null,
|
||||
customizeRef,
|
||||
customizeTarget,
|
||||
openContextMenuFromPointer,
|
||||
popoverRef,
|
||||
renameInitialValue,
|
||||
renamingType,
|
||||
setContextMenuPos,
|
||||
setContextMenuType,
|
||||
setCustomizeTarget,
|
||||
setRenameInitialValue,
|
||||
setRenamingType,
|
||||
@@ -114,10 +110,7 @@ export function useSidebarTypeInteractions({
|
||||
})
|
||||
|
||||
const handleContextMenu = useCallback((event: React.MouseEvent, type: string) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
state.setContextMenuPos({ x: event.clientX, y: event.clientY })
|
||||
state.setContextMenuType(type)
|
||||
state.openContextMenuFromPointer(type, event)
|
||||
}, [state])
|
||||
|
||||
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import type { ViewDefinition, ViewFile } from '../../types'
|
||||
import { useOutsideClick } from './sidebarHooks'
|
||||
import { getElementMenuPosition, useOutsideClick, useSidebarContextMenu } from './sidebarHooks'
|
||||
import type { MenuPosition, ViewDefinitionPatchHandler } from './SidebarViewActions'
|
||||
|
||||
interface SidebarViewItemInteractionInput {
|
||||
@@ -16,14 +16,6 @@ interface SidebarViewItemInteractionInput {
|
||||
|
||||
type RowKeyboardAction = 'select' | 'rename' | 'menu'
|
||||
|
||||
function getKeyboardMenuPosition(row: HTMLDivElement | null): MenuPosition {
|
||||
const bounds = row?.getBoundingClientRect()
|
||||
return {
|
||||
x: bounds ? bounds.left + 16 : 20,
|
||||
y: bounds ? bounds.top + bounds.height : 100,
|
||||
}
|
||||
}
|
||||
|
||||
function getRowKeyboardAction(event: KeyboardEvent<HTMLDivElement>): RowKeyboardAction | null {
|
||||
if (event.key === 'Enter' || event.key === ' ') return 'select'
|
||||
if (event.key === 'F2') return 'rename'
|
||||
@@ -43,30 +35,34 @@ function commitViewRename(
|
||||
}
|
||||
|
||||
function useViewInteractionState() {
|
||||
const [contextMenuPos, setContextMenuPos] = useState<MenuPosition | null>(null)
|
||||
const [customizePos, setCustomizePos] = useState<MenuPosition | null>(null)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const customizeRef = useRef<HTMLDivElement>(null)
|
||||
const rowRef = useRef<HTMLDivElement>(null)
|
||||
const closeContextMenu = useCallback(() => setContextMenuPos(null), [])
|
||||
const {
|
||||
closeContextMenu,
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
openContextMenuAt,
|
||||
openContextMenuFromPointer,
|
||||
} = useSidebarContextMenu<string>()
|
||||
const closeCustomize = useCallback(() => setCustomizePos(null), [])
|
||||
|
||||
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
|
||||
useOutsideClick(customizeRef, !!customizePos, closeCustomize)
|
||||
|
||||
return {
|
||||
closeContextMenu,
|
||||
closeCustomize,
|
||||
contextMenuPos,
|
||||
contextMenuPos: contextMenu?.pos ?? null,
|
||||
contextMenuRef,
|
||||
customizePos,
|
||||
customizeRef,
|
||||
isRenaming,
|
||||
rowRef,
|
||||
setContextMenuPos,
|
||||
setCustomizePos,
|
||||
setIsRenaming,
|
||||
openContextMenuAt,
|
||||
openContextMenuFromPointer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,30 +100,30 @@ function useViewMenuActions({
|
||||
onUpdateViewDefinition,
|
||||
closeContextMenu,
|
||||
contextMenuPos,
|
||||
openContextMenuAt,
|
||||
openContextMenuFromPointer,
|
||||
rowRef,
|
||||
setContextMenuPos,
|
||||
setCustomizePos,
|
||||
}: SidebarViewItemInteractionInput & {
|
||||
closeContextMenu: () => void
|
||||
contextMenuPos: MenuPosition | null
|
||||
openContextMenuAt: (target: string, pos: MenuPosition) => void
|
||||
openContextMenuFromPointer: (target: string, event: MouseEvent) => void
|
||||
rowRef: RefObject<HTMLDivElement | null>
|
||||
setContextMenuPos: Dispatch<SetStateAction<MenuPosition | null>>
|
||||
setCustomizePos: Dispatch<SetStateAction<MenuPosition | null>>
|
||||
}) {
|
||||
const hasMenuActions = !!(onEditView || onDeleteView || onUpdateViewDefinition)
|
||||
|
||||
const handleContextMenu = useCallback((event: MouseEvent) => {
|
||||
if (!hasMenuActions) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setCustomizePos(null)
|
||||
setContextMenuPos({ x: event.clientX, y: event.clientY })
|
||||
}, [hasMenuActions, setContextMenuPos, setCustomizePos])
|
||||
openContextMenuFromPointer(view.filename, event)
|
||||
}, [hasMenuActions, openContextMenuFromPointer, setCustomizePos, view.filename])
|
||||
|
||||
const openKeyboardContextMenu = useCallback(() => {
|
||||
setCustomizePos(null)
|
||||
setContextMenuPos(getKeyboardMenuPosition(rowRef.current))
|
||||
}, [rowRef, setContextMenuPos, setCustomizePos])
|
||||
openContextMenuAt(view.filename, getElementMenuPosition(rowRef.current))
|
||||
}, [openContextMenuAt, rowRef, setCustomizePos, view.filename])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
closeContextMenu()
|
||||
@@ -205,8 +201,9 @@ export function useSidebarViewItemInteractions({
|
||||
onUpdateViewDefinition,
|
||||
closeContextMenu: state.closeContextMenu,
|
||||
contextMenuPos: state.contextMenuPos,
|
||||
openContextMenuAt: state.openContextMenuAt,
|
||||
openContextMenuFromPointer: state.openContextMenuFromPointer,
|
||||
rowRef: state.rowRef,
|
||||
setContextMenuPos: state.setContextMenuPos,
|
||||
setCustomizePos: state.setCustomizePos,
|
||||
})
|
||||
const handleRowKeyDown = useViewRowKeyboardActions({
|
||||
|
||||
Reference in New Issue
Block a user