(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 (
@@ -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}
/>
diff --git a/src/components/folder-tree/useFolderContextMenu.ts b/src/components/folder-tree/useFolderContextMenu.ts
index 958667fe..41b0612d 100644
--- a/src/components/folder-tree/useFolderContextMenu.ts
+++ b/src/components/folder-tree/useFolderContextMenu.ts
@@ -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,
- 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(null)
- const menuRef = useRef(null)
-
- const closeContextMenu = useCallback(() => setContextMenu(null), [])
- useContextMenuDismiss(contextMenu, menuRef, closeContextMenu)
+ const {
+ closeContextMenu,
+ contextMenu,
+ contextMenuRef,
+ openContextMenuFromPointer,
+ } = useSidebarContextMenu()
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent) => {
- 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,
}
}
diff --git a/src/components/sidebar/SidebarViewActions.tsx b/src/components/sidebar/SidebarViewActions.tsx
index c0654f22..7f3f5fef 100644
--- a/src/components/sidebar/SidebarViewActions.tsx
+++ b/src/components/sidebar/SidebarViewActions.tsx
@@ -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(null)
-
- useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
-
- const handleKeyDown = (event: KeyboardEvent) => {
- 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 (
onSubmit(value)}
+ onBlur={() => { void submitValue() }}
onChange={(event) => setValue(event.target.value)}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
diff --git a/src/components/sidebar/sidebarHooks.ts b/src/components/sidebar/sidebarHooks.ts
index b55b6aca..b3a71e21 100644
--- a/src/components/sidebar/sidebarHooks.ts
+++ b/src/components/sidebar/sidebarHooks.ts
@@ -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, isOpen: boolean, onClose: () => void) {
+export interface SidebarMenuPosition {
+ x: number
+ y: number
+}
+
+export interface SidebarContextMenuState {
+ 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 | 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(
+ ref: RefObject,
+ isOpen: boolean,
+ onClose: () => void,
+) {
useEffect(() => {
if (!isOpen) return
const handler = (event: MouseEvent) => {
@@ -19,6 +66,97 @@ export function useOutsideClick(ref: RefObject, isOpen: bool
}, [ref, isOpen, onClose])
}
+export function useDismissableSidebarLayer(
+ ref: RefObject,
+ 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() {
+ const [contextMenu, setContextMenu] = useState | null>(null)
+ const contextMenuRef = useRef(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(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) => {
+ 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(() => {
diff --git a/src/components/sidebar/useSidebarTypeInteractions.ts b/src/components/sidebar/useSidebarTypeInteractions.ts
index aefc1f7d..a2d12340 100644
--- a/src/components/sidebar/useSidebarTypeInteractions.ts
+++ b/src/components/sidebar/useSidebarTypeInteractions.ts
@@ -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(null)
- const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
- const [contextMenuType, setContextMenuType] = useState(null)
const [renamingType, setRenamingType] = useState(null)
const [renameInitialValue, setRenameInitialValue] = useState('')
const [showCustomize, setShowCustomize] = useState(false)
- const contextMenuRef = useRef(null)
const popoverRef = useRef(null)
const customizeRef = useRef(null)
-
- const closeContextMenu = useCallback(() => {
- setContextMenuPos(null)
- setContextMenuType(null)
- }, [])
+ const {
+ closeContextMenu,
+ contextMenu,
+ contextMenuRef,
+ openContextMenuFromPointer,
+ } = useSidebarContextMenu()
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) => {
diff --git a/src/components/sidebar/useSidebarViewItemInteractions.ts b/src/components/sidebar/useSidebarViewItemInteractions.ts
index f51a54bc..430b92e2 100644
--- a/src/components/sidebar/useSidebarViewItemInteractions.ts
+++ b/src/components/sidebar/useSidebarViewItemInteractions.ts
@@ -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): 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(null)
const [customizePos, setCustomizePos] = useState(null)
const [isRenaming, setIsRenaming] = useState(false)
- const contextMenuRef = useRef(null)
const customizeRef = useRef(null)
const rowRef = useRef(null)
- const closeContextMenu = useCallback(() => setContextMenuPos(null), [])
+ const {
+ closeContextMenu,
+ contextMenu,
+ contextMenuRef,
+ openContextMenuAt,
+ openContextMenuFromPointer,
+ } = useSidebarContextMenu()
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
- setContextMenuPos: Dispatch>
setCustomizePos: Dispatch>
}) {
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({