feat: add keyboard navigation context menus for clipboard and clip items

- Implemented context menu triggers for keyboard navigation on ClipboardHistory and ClipCard components.
- Enhanced keyboard interactions with contextual menu display
This commit is contained in:
Sergey Kurdin 2025-06-23 00:53:07 -04:00
parent e2731f97c0
commit 307127c296
6 changed files with 337 additions and 30 deletions

View File

@ -19,6 +19,7 @@ import {
isKeyAltPressed,
isKeyCtrlPressed,
showHistoryDeleteConfirmationId,
showKeyboardNavContextMenuHistoryId,
} from '~/store'
import {
ArrowDownToLine,
@ -255,6 +256,30 @@ export function ClipboardHistoryRowComponent({
})
}, [isExpanded, isWrapText])
useEffect(() => {
if (showKeyboardNavContextMenuHistoryId.value === clipboard?.historyId) {
if (contextMenuTriggerRef?.current) {
const targetElement = contextMenuTriggerRef.current
const rect = targetElement.getBoundingClientRect()
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
view: window,
clientX: rect.x + 50,
clientY: rect.y + 20,
button: 2, // Right mouse button
})
targetElement.dispatchEvent(contextMenuEvent)
}
}
}, [
showKeyboardNavContextMenuHistoryId.value,
clipboard?.historyId,
contextMenuTriggerRef?.current,
])
if (!clipboard) {
return null
}
@ -417,10 +442,17 @@ export function ClipboardHistoryRowComponent({
)}
<ContextMenuTrigger
ref={isHovering || isSelected ? contextMenuTriggerRef : null}
ref={contextMenuTriggerRef}
onOpenChange={isOpen => {
contextMenuOpen.value = isOpen
showHistoryDeleteConfirmationId.value = null
// Reset he keyboard nav signal when menu opens
if (
!isOpen &&
showKeyboardNavContextMenuHistoryId.value === clipboard.historyId
) {
showKeyboardNavContextMenuHistoryId.value = null
}
}}
historyId={clipboard.historyId}
copiedFromApp={clipboard.copiedFromApp}

View File

@ -48,7 +48,7 @@ interface ContextMenuTriggerProps {
setHistoryFilters?: Dispatch<SetStateAction<string[]>>
setAppFilters?: Dispatch<SetStateAction<string[]>>
onDeleteConfirmationChange?: (
historyId: UniqueIdentifier | null,
historyId: UniqueIdentifier | string | null,
isMultiSelect?: boolean
) => void
}

View File

@ -25,6 +25,7 @@ import {
settingsStoreAtom,
showClipFindKeyPressed,
showClipsMoveOnBoardId,
showKeyboardNavContextMenuClipId,
showLargeViewClipId,
showLinkedClipId,
} from '~/store'
@ -562,6 +563,30 @@ export function ClipCard({
}
}, [isKeyboardSelected])
useEffect(() => {
if (showKeyboardNavContextMenuClipId.value === clip.id) {
if (contextMenuTriggerRef?.current) {
const targetElement = contextMenuTriggerRef.current
const rect = targetElement.getBoundingClientRect()
contextMenuClipId.value = clip.id
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
view: window,
clientX: rect.x + 50,
clientY: rect.y + 20,
button: 2, // Right mouse button
})
targetElement.dispatchEvent(contextMenuEvent)
// Focus the first menu item after the context menu opens
}
}
}, [showKeyboardNavContextMenuClipId.value, clip.id, contextMenuTriggerRef?.current])
const isEditing = isClipNameEditing || isClipEdit
const copyDisabled =
@ -602,10 +627,18 @@ export function ClipCard({
<ContextMenu
onOpenChange={isOpen => {
contextMenuOpen.value = isOpen
if (!isOpen && showKeyboardNavContextMenuClipId.value === clip.id) {
showKeyboardNavContextMenuClipId.value = null
}
}}
>
<ContextMenuTrigger
disabled={(!isHover && !isSelected) || Boolean(globalSearchTerm)}
disabled={
(!isHover &&
!isSelected &&
showKeyboardNavContextMenuClipId.value !== clip.id) ||
Boolean(globalSearchTerm)
}
ref={contextMenuTriggerRef}
>
<Box className="relative">

View File

@ -394,18 +394,18 @@ export function GlobalSearch({ isDark }: { isDark: boolean }) {
)
}
} else if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault()
e.stopPropagation()
if (availableTabs.length > 1) {
e.preventDefault()
e.stopPropagation()
const currentTabIndex = availableTabs.indexOf(filter)
const nextTabIndex = (currentTabIndex + 1) % availableTabs.length
setFilter(availableTabs[nextTabIndex])
setSelectedIndex(-1)
}
} else if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault()
e.stopPropagation()
if (availableTabs.length > 1) {
e.preventDefault()
e.stopPropagation()
const currentTabIndex = availableTabs.indexOf(filter)
const prevTabIndex =
(currentTabIndex - 1 + availableTabs.length) % availableTabs.length
@ -422,6 +422,18 @@ export function GlobalSearch({ isDark }: { isDark: boolean }) {
e.preventDefault()
e.stopPropagation()
setShowSearchModal(false)
} else if (e.key === 'PageUp') {
e.preventDefault()
e.stopPropagation()
} else if (e.key === 'PageDown') {
e.preventDefault()
e.stopPropagation()
} else if (e.key === 'Home') {
e.preventDefault()
e.stopPropagation()
} else if (e.altKey && e.key === 'ArrowDown') {
e.preventDefault()
e.stopPropagation()
}
},
[filter, selectedIndex, handleCopySelectedItem, flattenedItems]

View File

@ -35,6 +35,8 @@ import {
showClipsMoveOnBoardId,
showDetailsClipId,
showHistoryDeleteConfirmationId,
showKeyboardNavContextMenuClipId,
showKeyboardNavContextMenuHistoryId,
showLargeViewClipId,
showLargeViewHistoryId,
showOrganizeLayout,
@ -118,14 +120,14 @@ import {
useCopyPasteHistoryItem,
usePasteHistoryItem,
} from '~/hooks/use-copypaste-history-item'
import { useDebounce } from '~/hooks/use-debounce'
import useDeleteConfirmationTimer from '~/hooks/use-delete-confirmation-items'
import { useSignal } from '~/hooks/use-signal'
import {
specialCopiedItem,
specialPastedItem,
specialPastedItemCountDown,
} from '~/hooks/use-special-copypaste-history-item'
import { useDebounce } from '~/hooks/use-debounce'
import useDeleteConfirmationTimer from '~/hooks/use-delete-confirmation-items'
import { useSignal } from '~/hooks/use-signal'
import {
ClipboardHistoryIconMenu,
@ -344,9 +346,18 @@ export default function ClipboardHistoryPage() {
const pastedItemValue = useMemo(() => pastedItem, [pastedItem])
const copiedItemValue = useMemo(() => copiedItem, [copiedItem])
const specialCopiedItemValue = useMemo(() => specialCopiedItem.value, [specialCopiedItem.value])
const specialPastedItemValue = useMemo(() => specialPastedItem.value, [specialPastedItem.value])
const specialPastingCountDown = useMemo(() => specialPastedItemCountDown.value, [specialPastedItemCountDown.value])
const specialCopiedItemValue = useMemo(
() => specialCopiedItem.value,
[specialCopiedItem.value]
)
const specialPastedItemValue = useMemo(
() => specialPastedItem.value,
[specialPastedItem.value]
)
const specialPastingCountDown = useMemo(
() => specialPastedItemCountDown.value,
[specialPastedItemCountDown.value]
)
const clipboardHistory = hasSearchOrFilter ? foundClipboardHistory : allClipboardHistory
@ -450,7 +461,8 @@ export default function ClipboardHistoryPage() {
currentNavigationContext.value === null) &&
clipboardHistory.length > 0
) {
setCopiedItem(clipboardHistory[0]?.historyId)
// TODO: Fix this
// setCopiedItem(clipboardHistory[0]?.historyId)
}
currentNavigationContext.value = null
keyboardSelectedItemId.value = null
@ -647,6 +659,14 @@ export default function ClipboardHistoryPage() {
'esc',
() => {
// Escape closes large view first, then performs normal escape behavior
if (showKeyboardNavContextMenuHistoryId.value) {
showKeyboardNavContextMenuHistoryId.value = null
}
if (showKeyboardNavContextMenuClipId.value) {
showKeyboardNavContextMenuClipId.value = null
}
if (showLargeViewHistoryId.value) {
showLargeViewHistoryId.value = null
} else {
@ -776,6 +796,176 @@ export default function ClipboardHistoryPage() {
}
)
useHotkeys(
['home'],
e => {
e.preventDefault()
if (
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null
) {
// Navigate to first history item
if (clipboardHistory.length > 0) {
keyboardSelectedItemId.value = clipboardHistory[0].historyId
scrollToTopHistoryList()
if (showLargeViewHistoryId.value) {
showLargeViewHistoryId.value = clipboardHistory[0].historyId
}
}
} else if (currentNavigationContext.value === 'board') {
// Navigate to first clip in first board with clips
const boardsWithClips = clipItems
.filter(item => item.isBoard && item.tabId === currentTab)
.filter(board =>
clipItems.some(
clip =>
clip.isClip && clip.parentId === board.itemId && clip.tabId === currentTab
)
)
.sort((a, b) => a.orderNumber - b.orderNumber)
if (boardsWithClips.length > 0) {
const firstBoard = boardsWithClips[0]
const firstBoardClips = clipItems
.filter(
item =>
item.isClip &&
item.parentId === firstBoard.itemId &&
item.tabId === currentTab
)
.sort((a, b) => a.orderNumber - b.orderNumber)
if (firstBoardClips.length > 0) {
keyboardSelectedBoardId.value = firstBoard.itemId
keyboardSelectedClipId.value = firstBoardClips[0].itemId
currentBoardIndex.value = 0
}
}
}
},
{
enabled: !shouldKeyboardNavigationBeDisabled.value,
}
)
useHotkeys(
['pageup'],
e => {
e.preventDefault()
if (
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null
) {
// Move up by 5 items in history
const currentIndex = clipboardHistory.findIndex(
item => item.historyId === keyboardSelectedItemId.value
)
const newIndex = Math.max(0, currentIndex - 5)
if (clipboardHistory[newIndex]) {
keyboardSelectedItemId.value = clipboardHistory[newIndex].historyId
if (showLargeViewHistoryId.value) {
showLargeViewHistoryId.value = clipboardHistory[newIndex].historyId
}
}
} else if (
currentNavigationContext.value === 'board' &&
keyboardSelectedBoardId.value
) {
// Move up by 5 clips in current board
const clipsOnBoard = clipItems
.filter(
item =>
item.isClip &&
item.parentId === keyboardSelectedBoardId.value &&
item.tabId === currentTab
)
.sort((a, b) => a.orderNumber - b.orderNumber)
const currentIndex = clipsOnBoard.findIndex(
clip => clip.itemId === keyboardSelectedClipId.value
)
const newIndex = Math.max(0, currentIndex - 5)
if (clipsOnBoard[newIndex]) {
keyboardSelectedClipId.value = clipsOnBoard[newIndex].itemId
}
}
},
{
enabled: !shouldKeyboardNavigationBeDisabled.value,
}
)
useHotkeys(
['pagedown'],
e => {
e.preventDefault()
if (
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null
) {
// Move down by 5 items in history
const currentIndex = clipboardHistory.findIndex(
item => item.historyId === keyboardSelectedItemId.value
)
const newIndex = Math.min(clipboardHistory.length - 1, currentIndex + 5)
if (clipboardHistory[newIndex]) {
keyboardSelectedItemId.value = clipboardHistory[newIndex].historyId
if (showLargeViewHistoryId.value) {
showLargeViewHistoryId.value = clipboardHistory[newIndex].historyId
}
}
} else if (
currentNavigationContext.value === 'board' &&
keyboardSelectedBoardId.value
) {
// Move down by 5 clips in current board
const clipsOnBoard = clipItems
.filter(
item =>
item.isClip &&
item.parentId === keyboardSelectedBoardId.value &&
item.tabId === currentTab
)
.sort((a, b) => a.orderNumber - b.orderNumber)
const currentIndex = clipsOnBoard.findIndex(
clip => clip.itemId === keyboardSelectedClipId.value
)
const newIndex = Math.min(clipsOnBoard.length - 1, currentIndex + 5)
if (clipsOnBoard[newIndex]) {
keyboardSelectedClipId.value = clipsOnBoard[newIndex].itemId
}
}
},
{
enabled: !shouldKeyboardNavigationBeDisabled.value,
}
)
useHotkeys(
['alt+arrowdown'],
e => {
e.preventDefault()
if (
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null
) {
// Open context menu for selected history item
if (keyboardSelectedItemId.value) {
showKeyboardNavContextMenuHistoryId.value = keyboardSelectedItemId.value
}
} else if (currentNavigationContext.value === 'board') {
// Open context menu for selected clip item
if (keyboardSelectedClipId.value) {
showKeyboardNavContextMenuClipId.value = keyboardSelectedClipId.value
}
}
},
{
enabled: !shouldKeyboardNavigationBeDisabled.value,
}
)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
@ -805,6 +995,18 @@ export default function ClipboardHistoryPage() {
}
}, [currentNavigationContext.value, keyboardSelectedItemId.value, clipboardHistory])
useEffect(() => {
if (keyboardSelectedItemId.value && listRef.current) {
const selectedIndex = clipboardHistory.findIndex(
item => item.historyId === keyboardSelectedItemId.value
)
if (selectedIndex !== -1) {
// @ts-expect-error - scrollToItem is not in the types
listRef.current.scrollToItem?.(selectedIndex, 'smart')
}
}
}, [keyboardSelectedItemId.value, clipboardHistory])
useEffect(() => {
const listenToClipboardUnlisten = listen(
'clipboard://clipboard-monitor/update',
@ -1489,11 +1691,17 @@ export default function ClipboardHistoryPage() {
historyId === pastedItemValue
? pastingCountDown
: historyId === specialPastedItemValue
? specialPastingCountDown
: undefined
? specialPastingCountDown
: undefined
}
isPasted={
historyId === pastedItemValue ||
historyId === specialPastedItemValue
}
isCopied={
historyId === copiedItemValue ||
historyId === specialCopiedItemValue
}
isPasted={historyId === pastedItemValue || historyId === specialPastedItemValue}
isCopied={historyId === copiedItemValue || historyId === specialCopiedItemValue}
isSaved={historyId === savingItem}
setSavingItem={setSavingItem}
isDeleting={hasIsDeleting(historyId)}
@ -1533,7 +1741,9 @@ export default function ClipboardHistoryPage() {
isSingleClickToCopyPaste={
isSingleClickToCopyPaste
}
historyPreviewLineLimit={historyPreviewLineLimit}
historyPreviewLineLimit={
historyPreviewLineLimit
}
/>
</Box>
)
@ -2101,10 +2311,13 @@ export default function ClipboardHistoryPage() {
historyId === pastedItemValue
? pastingCountDown
: historyId === specialPastedItemValue
? specialPastingCountDown
: undefined
? specialPastingCountDown
: undefined
}
isPasted={
historyId === pastedItemValue ||
historyId === specialPastedItemValue
}
isPasted={historyId === pastedItemValue || historyId === specialPastedItemValue}
isKeyboardSelected={
(currentNavigationContext.value ===
'history' ||
@ -2112,7 +2325,10 @@ export default function ClipboardHistoryPage() {
null) &&
historyId === keyboardSelectedItemId.value
}
isCopied={historyId === copiedItemValue || historyId === specialCopiedItemValue}
isCopied={
historyId === copiedItemValue ||
historyId === specialCopiedItemValue
}
isSaved={historyId === savingItem}
setSavingItem={setSavingItem}
key={historyId}
@ -2162,7 +2378,9 @@ export default function ClipboardHistoryPage() {
isSingleClickToCopyPaste={
isSingleClickToCopyPaste
}
historyPreviewLineLimit={historyPreviewLineLimit}
historyPreviewLineLimit={
historyPreviewLineLimit
}
index={index}
style={style}
/>
@ -2455,12 +2673,19 @@ export default function ClipboardHistoryPage() {
pastingCountDown={
inLargeViewItem.historyId === pastedItemValue
? pastingCountDown
: inLargeViewItem.historyId === specialPastedItemValue
? specialPastingCountDown
: null
: inLargeViewItem.historyId ===
specialPastedItemValue
? specialPastingCountDown
: null
}
isPasted={
inLargeViewItem.historyId === pastedItemValue ||
inLargeViewItem.historyId === specialPastedItemValue
}
isCopied={
inLargeViewItem.historyId === copiedItemValue ||
inLargeViewItem.historyId === specialCopiedItemValue
}
isPasted={inLargeViewItem.historyId === pastedItemValue || inLargeViewItem.historyId === specialPastedItemValue}
isCopied={inLargeViewItem.historyId === copiedItemValue || inLargeViewItem.historyId === specialCopiedItemValue}
isSaved={inLargeViewItem.historyId === savingItem}
isMp3={
inLargeViewItem.isLink &&

View File

@ -39,6 +39,7 @@ export const resetTimeModalInterval = signal<NodeJS.Timeout | null>(null)
export const showHistoryDeleteConfirmationId = signal<UniqueIdentifier | null>(null)
export const hoveringHistoryRowId = signal<UniqueIdentifier | null>(null)
export const showLargeViewHistoryId = signal<UniqueIdentifier | null>(null)
export const showKeyboardNavContextMenuHistoryId = signal<UniqueIdentifier | null>(null)
export const isHistoryCopyPasting = signal(false)
// Tabs Dashboard Signals
@ -55,6 +56,7 @@ export const forceSaveClipNameEditingError = signal(false)
export const hoveringClipIdBoardId = signal<string | null>(null)
export const showDeleteClipConfirmationId = signal<UniqueIdentifier | null>(null)
export const contextMenuClipId = signal<UniqueIdentifier | null>(null)
export const showKeyboardNavContextMenuClipId = signal<UniqueIdentifier | null>(null)
export const showDeleteImageClipConfirmationId = signal<UniqueIdentifier | null>(null)
export const isDeletingSelectedClips = signal(false)
export const addSelectedTextToClipBoard = signal<string | null>(null)
@ -243,7 +245,10 @@ effect(() => {
newMenuItemId.value ||
addSelectedTextToMenu.value ||
// Text selection states
addSelectedTextToClipBoard.value
addSelectedTextToClipBoard.value ||
// Context menu states
showKeyboardNavContextMenuHistoryId.value ||
showKeyboardNavContextMenuClipId.value
) {
// console.log('Disabling keyboard navigation due to edit or delete actions')
// Disable keyboard navigation when editing