feat: implement keyboard delete confirmation for clipboard history

- Added `useKeyboardDeleteConfirmation` hook for enhanced delete interactions.
- Integrated keyboard shortcuts for delete confirmations in clipboard history
This commit is contained in:
Sergey Kurdin 2025-06-23 13:42:25 -04:00
parent 307127c296
commit 6ef092d896
9 changed files with 284 additions and 49 deletions

View File

@ -13,7 +13,7 @@ const useDeleteConfirmationTimer = ({
onConfirmedDelete: () => Promise<void>
onConfirmedReset?: () => void
selectedHistoryItems: UniqueIdentifier[]
hoveringHistoryRowId: Signal<UniqueIdentifier | null> | null
hoveringHistoryRowId: Signal<UniqueIdentifier | null>
timerDuration?: number
}) => {
const timerRef = useRef(null) as React.MutableRefObject<NodeJS.Timeout | null>
@ -34,15 +34,17 @@ const useDeleteConfirmationTimer = ({
if (timerRef.current) {
clearTimeout(timerRef.current)
}
if (hoveringHistoryRowId?.value && selectedHistoryItems.length === 0) {
if (selectedHistoryItems.length === 0) {
seHoveringHistoryIdDelete(hoveringHistoryRowId.value)
}
setShowConfirmation(true)
timerRef.current = setTimeout(() => {
resetTimer()
}, timerDuration)
}, [timerDuration, resetTimer, selectedHistoryItems])
}, [timerDuration, resetTimer, selectedHistoryItems, hoveringHistoryRowId])
useEffect(() => {
return () => {

View File

@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { UniqueIdentifier } from '@dnd-kit/core'
import { Signal } from '@preact/signals-react'
import { useHotkeys } from 'react-hotkeys-hook'
const useKeyboardDeleteConfirmation = ({
onConfirmedDelete,
keyboardSelectedItemId,
onConfirmedReset,
selectedHistoryItems,
timerDuration = 3000,
}: {
onConfirmedDelete: () => Promise<void>
onConfirmedReset?: () => void
selectedHistoryItems: UniqueIdentifier[]
keyboardSelectedItemId: Signal<UniqueIdentifier | null>
timerDuration?: number
}) => {
const timerRef = useRef(null) as React.MutableRefObject<NodeJS.Timeout | null>
const [showConfirmation, setShowConfirmation] = useState(false)
const [keyboardItemIdDelete, setKeyboardItemIdDelete] =
useState<UniqueIdentifier | null>(null)
const resetTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
setKeyboardItemIdDelete(null)
setShowConfirmation(false)
onConfirmedReset?.()
}, [onConfirmedReset])
const startTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
// Only proceed if there's a keyboard selected item and no multi-selection
if (!keyboardSelectedItemId.value || selectedHistoryItems.length > 0) {
return
}
setKeyboardItemIdDelete(keyboardSelectedItemId.value)
setShowConfirmation(true)
timerRef.current = setTimeout(() => {
resetTimer()
}, timerDuration)
}, [timerDuration, resetTimer, selectedHistoryItems, keyboardSelectedItemId])
// Reset confirmation when the keyboard selected item changes
useEffect(() => {
if (showConfirmation && keyboardItemIdDelete !== keyboardSelectedItemId.value) {
resetTimer()
}
}, [keyboardSelectedItemId.value, showConfirmation, keyboardItemIdDelete, resetTimer])
// Reset confirmation when there are selected items (multi-selection mode)
useEffect(() => {
if (showConfirmation && selectedHistoryItems.length > 0) {
resetTimer()
}
}, [selectedHistoryItems.length, showConfirmation, resetTimer])
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [])
useHotkeys(
['delete', 'backspace'],
async (e) => {
e.preventDefault()
// Only handle keyboard delete when there's a keyboard selected item and no multi-selection
if (!keyboardSelectedItemId.value || selectedHistoryItems.length > 0) {
return
}
if (showConfirmation) {
await onConfirmedDelete()
resetTimer()
} else {
startTimer()
}
},
{
enableOnFormTags: false,
}
)
return {
showConfirmation,
keyboardItemIdDelete,
resetTimer,
}
}
export default useKeyboardDeleteConfirmation

View File

@ -63,7 +63,6 @@ export const ClipboardHistoryIconMenu = ({
onDelete,
setIsDeleting,
isDark,
setSelectHistoryItem,
setSelectedHistoryItems,
showSelectHistoryItems,
}: ClipboardHistoryIconMenuProps) => {
@ -85,12 +84,6 @@ export const ClipboardHistoryIconMenu = ({
setShowSelectHistoryItems(!showSelectHistoryItems)
})
useHotkeys(['control+s'], () => {
if (hoveringHistoryRowId.value) {
setSelectHistoryItem(hoveringHistoryRowId.value)
}
})
useHotkeys(['alt+h', 'meta+h'], () => {
setIsHistoryEnabled(!isHistoryEnabled)
})

View File

@ -387,22 +387,22 @@ export function ClipboardHistoryRowComponent({
const pinnedTopOffsetFirst = !isPinnedTopFirst ? 'top-[-10px]' : 'top-[5px]'
const bgToolsPanel = `${
isKeyboardSelected
? 'bg-blue-50 dark:bg-blue-950/80'
: !isPinnedTop && isOverPinned && !isNowItem
? 'bg-orange-50 dark:!bg-transparent'
: isDeleting || isDeleteConfirmationFromContext.value
? 'bg-red-50 dark:bg-red-950/80'
: contextMenuOpen.value
? `bg-slate-100 dark:bg-slate-900 ${
isNowItem ? 'bg-teal-50/80 dark:bg-sky-900/80' : ''
}`
: isCopiedOrPasted
? 'dark:bg-green-950/80'
: isSaved
? 'dark:bg-sky-950/80'
: isSelected
? 'bg-yellow-50 dark:bg-amber-950/80'
!isPinnedTop && isOverPinned && !isNowItem
? 'bg-orange-50 dark:!bg-transparent'
: isDeleting || isDeleteConfirmationFromContext.value
? 'bg-red-50 dark:bg-red-950/80'
: isSelected
? 'bg-yellow-50 dark:bg-amber-950/80'
: isKeyboardSelected
? 'bg-blue-50 dark:bg-blue-950/80'
: contextMenuOpen.value
? `bg-slate-100 dark:bg-slate-900 ${
isNowItem ? 'bg-teal-50/80 dark:bg-sky-900/80' : ''
}`
: isCopiedOrPasted
? 'dark:bg-green-950/80'
: isSaved
? 'dark:bg-sky-950/80'
: isNowItem
? 'bg-teal-50/90 dark:bg-sky-950'
: 'bg-white dark:bg-slate-950/80'
@ -501,6 +501,10 @@ export function ClipboardHistoryRowComponent({
>
<Box
className={`rounded-md justify-start duration-300 history-box relative px-3 py-1 hover:shadow-sm my-0.5 shadow-none border-2 flex flex-col ${
isKeyboardSelected
? 'ring-2 scale-[.98] ring-blue-400 dark:!ring-blue-600 ring-offset-1 !shadow-sm ring-offset-white dark:ring-offset-gray-800'
: ''
} ${
index === 0 &&
clipboard.updatedAt > Date.now() - MINUTE_IN_MS &&
!isCopiedOrPasted &&
@ -508,23 +512,23 @@ export function ClipboardHistoryRowComponent({
!isKeyboardSelected &&
!isSelected
? 'bg-teal-50 hover:border-slate-300 dark:bg-sky-900/40 dark:hover:border-slate-700 hover:bg-teal-50/90 hover:dark:bg-sky-950'
: isKeyboardSelected
? `bg-blue-50 ring-2 scale-[.98] ring-blue-400 dark:!ring-blue-600 ring-offset-1 !shadow-sm border-blue-300 dark:bg-blue-950/80 dark:hover:border-blue-800 hover:bg-blue-50/80 ring-offset-white dark:ring-offset-gray-800 ${
isPinnedTop ? ' dark:!bg-amber-950' : ''
}`
: (isDeleting || isDeleteConfirmationFromContext.value) &&
: (isDeleting || isDeleteConfirmationFromContext.value) &&
!isDragPreview
? 'border-red-400 bg-red-50 dark:bg-red-950/80 dark:border-red-900/80 dark:hover:border-red-800'
: isSelected
? `bg-amber-50 border-amber-300 dark:bg-amber-950/80 dark:border-amber-900/80 hover:border-amber-300/80 dark:hover:border-amber-800 hover:bg-amber-50/80 ${
isPinnedTop ? '!border dark:!bg-amber-950' : ''
}`
: isKeyboardSelected
? `bg-blue-50 border-blue-300 dark:bg-blue-950/80 dark:hover:border-blue-800 hover:bg-blue-50/80 ${
isPinnedTop ? ' dark:!bg-amber-950' : ''
}`
: contextMenuOpen.value
? 'bg-slate-100 dark:bg-slate-950/80 border-slate-300 dark:border-slate-600'
: isSaved && !isDragPreview
? 'bg-sky-50 border-sky-600 dark:bg-sky-950/80 dark:border-sky-900/80 dark:hover:border-sky-800'
: isCopiedOrPasted && !isDragPreview
? `bg-green-50 border-green-600 dark:bg-green-950/80 dark:border-green-800`
: isSelected
? `bg-amber-50 border-amber-300 dark:bg-amber-950/80 dark:border-amber-900/80 hover:border-amber-300/80 dark:hover:border-amber-800 hover:bg-amber-50/80 ${
isPinnedTop ? '!border dark:!bg-amber-950' : ''
}`
: `hover:bg-white dark:hover:bg-slate-950/80 ${
isLargeView
? 'border-slate-500 bg-white dark:bg-slate-950 hover:dark:border-slate-500'

View File

@ -70,12 +70,6 @@ export const ClipboardHistoryWindowIcons = ({
setShowSelectHistoryItems(!showSelectHistoryItems)
})
useHotkeys(['control+s'], () => {
if (hoveringHistoryRowId.value) {
setSelectHistoryItem(hoveringHistoryRowId.value)
}
})
useHotkeys(['alt+h', 'meta+h'], () => {
setIsHistoryEnabled(!isHistoryEnabled)
})

View File

@ -235,7 +235,7 @@ export default function ClipboardHistoryRowContextMenu({
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
e.stopPropagation()
if (isSelected && selectedHistoryItems && selectedHistoryItems.length > 1) {
// Multi-select delete
if (pendingDeleteId === 'multi') {
@ -836,7 +836,9 @@ export default function ClipboardHistoryRowContextMenu({
<ContextMenuSeparator />
{isSelected && selectedHistoryItems && selectedHistoryItems.length > 1 ? (
<ContextMenuItem
className={pendingDeleteId === 'multi' ? 'bg-red-500/20 dark:bg-red-600/20' : ''}
className={
pendingDeleteId === 'multi' ? 'bg-red-500/20 dark:bg-red-600/20' : ''
}
onSelect={async e => {
e.preventDefault()
@ -882,7 +884,9 @@ export default function ClipboardHistoryRowContextMenu({
</ContextMenuItem>
) : (
<ContextMenuItem
className={pendingDeleteId === historyId ? 'bg-red-500/20 dark:bg-red-600/20' : ''}
className={
pendingDeleteId === historyId ? 'bg-red-500/20 dark:bg-red-600/20' : ''
}
onSelect={async e => {
e.preventDefault()

View File

@ -35,6 +35,7 @@ import {
showClipsMoveOnBoardId,
showDetailsClipId,
showHistoryDeleteConfirmationId,
showHistoryMultiDeleteConfirmationIds,
showKeyboardNavContextMenuClipId,
showKeyboardNavContextMenuHistoryId,
showLargeViewClipId,
@ -122,6 +123,7 @@ import {
} from '~/hooks/use-copypaste-history-item'
import { useDebounce } from '~/hooks/use-debounce'
import useDeleteConfirmationTimer from '~/hooks/use-delete-confirmation-items'
import useKeyboardDeleteConfirmation from '~/hooks/use-keyboard-delete-confirmation'
import { useSignal } from '~/hooks/use-signal'
import {
specialCopiedItem,
@ -276,6 +278,38 @@ export default function ClipboardHistoryPage() {
},
})
const { showConfirmation: showConfirmationKeyboardDelete, keyboardItemIdDelete, resetTimer: resetKeyboardDeleteTimer } =
useKeyboardDeleteConfirmation({
keyboardSelectedItemId: keyboardSelectedItemId,
selectedHistoryItems,
onConfirmedDelete: async () => {
if (keyboardSelectedItemId.value) {
// Calculate next selection before deletion
const currentIndex = clipboardHistory.findIndex(
item => item.historyId === keyboardSelectedItemId.value
)
let nextSelectedId: UniqueIdentifier | null = null
if (currentIndex !== -1) {
if (currentIndex < clipboardHistory.length - 1) {
// Select next item
nextSelectedId = clipboardHistory[currentIndex + 1].historyId
} else if (currentIndex > 0) {
// Select previous item
nextSelectedId = clipboardHistory[currentIndex - 1].historyId
}
// If only one item, nextSelectedId remains null
}
await deleteClipboardHistoryByIds({
historyIds: [keyboardSelectedItemId.value],
})
// Update selection to the calculated next item
keyboardSelectedItemId.value = nextSelectedId
}
},
})
const isPinnedPanelHoverOpen = useMemo(() => {
return isPinnedPanelKeepOpen.value || isPinnedPanelHovering.value
}, [isPinnedPanelHovering.value, isPinnedPanelKeepOpen.value])
@ -455,6 +489,8 @@ export default function ClipboardHistoryPage() {
currentNavigationContext.value === null) &&
keyboardSelectedItemId.value
) {
// Reset keyboard delete confirmation when copying
resetKeyboardDeleteTimer()
setCopiedItem(keyboardSelectedItemId.value)
} else if (
(currentNavigationContext.value === 'history' ||
@ -485,6 +521,9 @@ export default function ClipboardHistoryPage() {
e => {
e.preventDefault()
// Reset delete confirmation when navigating
showHistoryDeleteConfirmationId.value = null
if (keyboardSelectedBoardId.value) {
const clipsOnBoard = clipItems
.filter(
@ -647,10 +686,46 @@ export default function ClipboardHistoryPage() {
}
}
useHotkeys(['control+s'], e => {
if (hoveringHistoryRowId.value) {
setSelectHistoryItem(hoveringHistoryRowId.value)
}
})
useHotkeys(['tab'], handleTabNavigation('forward'), {
enabled: !shouldKeyboardNavigationBeDisabled.value,
})
useHotkeys(
['space'],
() => {
if (
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null
) {
if (keyboardSelectedItemId.value) {
// Reset keyboard delete confirmation when selecting
resetKeyboardDeleteTimer()
setSelectHistoryItem(keyboardSelectedItemId.value)
const currentItemIndex = clipboardHistory.findIndex(
item => item.historyId === keyboardSelectedItemId.value
)
const nextItem = clipboardHistory[currentItemIndex + 1]
if (nextItem) {
keyboardSelectedItemId.value = nextItem.historyId
if (showLargeViewHistoryId.value) {
showLargeViewHistoryId.value = nextItem.historyId
}
}
}
}
},
{
enabled: !shouldKeyboardNavigationBeDisabled.value,
}
)
useHotkeys(['shift+tab'], handleTabNavigation('backward'), {
enabled: !shouldKeyboardNavigationBeDisabled.value,
})
@ -658,6 +733,18 @@ export default function ClipboardHistoryPage() {
useHotkeys(
'esc',
() => {
// Clear any delete timeout when escaping
if (deleteTimeoutRef.current) {
clearTimeout(deleteTimeoutRef.current)
deleteTimeoutRef.current = null
}
// Reset delete confirmation on escape
showHistoryDeleteConfirmationId.value = null
// Reset keyboard delete confirmation on escape
resetKeyboardDeleteTimer()
// Escape closes large view first, then performs normal escape behavior
if (showKeyboardNavContextMenuHistoryId.value) {
showKeyboardNavContextMenuHistoryId.value = null
@ -682,6 +769,19 @@ export default function ClipboardHistoryPage() {
['arrowdown'],
e => {
e.preventDefault()
// Clear any delete timeout when navigating away
if (deleteTimeoutRef.current) {
clearTimeout(deleteTimeoutRef.current)
deleteTimeoutRef.current = null
}
// Reset delete confirmation when navigating to a different item
showHistoryDeleteConfirmationId.value = null
// Reset keyboard delete confirmation when navigating
resetKeyboardDeleteTimer()
const currentItemIndex = clipboardHistory.findIndex(
item => item.historyId === keyboardSelectedItemId.value
)
@ -705,6 +805,19 @@ export default function ClipboardHistoryPage() {
['arrowup'],
e => {
e.preventDefault()
// Clear any delete timeout when navigating away
if (deleteTimeoutRef.current) {
clearTimeout(deleteTimeoutRef.current)
deleteTimeoutRef.current = null
}
// Reset delete confirmation when navigating to a different item
showHistoryDeleteConfirmationId.value = null
// Reset keyboard delete confirmation when navigating
resetKeyboardDeleteTimer()
if (
currentNavigationContext.value === 'history' ||
currentNavigationContext.value === null
@ -966,6 +1079,9 @@ export default function ClipboardHistoryPage() {
}
)
// Store timeout reference to clear it if needed
const deleteTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
@ -975,6 +1091,11 @@ export default function ClipboardHistoryPage() {
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
// Clean up delete timeout on unmount
if (deleteTimeoutRef.current) {
clearTimeout(deleteTimeoutRef.current)
deleteTimeoutRef.current = null
}
}
}, [
clipboardHistory,
@ -1225,13 +1346,22 @@ export default function ClipboardHistoryPage() {
const hasIsDeleting = (historyId: UniqueIdentifier) => {
return (
(showConfirmation && selectedHistoryItems.includes(historyId)) ||
// Keyboard delete confirmation - only for the specific keyboard selected item
(showConfirmationKeyboardDelete &&
historyId === keyboardItemIdDelete &&
historyId === keyboardSelectedItemId.value) ||
// Mouse delete confirmation - only when keyboard delete is NOT active
(showConfirmation && !showConfirmationKeyboardDelete && selectedHistoryItems.includes(historyId)) ||
// Single item delete confirmation
historyId === showHistoryDeleteConfirmationId.value ||
(showConfirmation && historyId === hoveringHistoryIdDelete) ||
// Hovering delete confirmation - only when keyboard delete is NOT active
(showConfirmation && !showConfirmationKeyboardDelete && historyId === hoveringHistoryIdDelete) ||
// Drag over trash
historyId === dragOverTrashId ||
(Boolean(dragOverTrashId) &&
Boolean(activeDragId) &&
selectedHistoryItems.includes(historyId)) ||
// Menu deleting
(isMenuDeleting && selectedHistoryItems.includes(historyId))
)
}
@ -2107,6 +2237,7 @@ export default function ClipboardHistoryPage() {
className="pointer-events-auto rounded-full bg-slate-300 dark:bg-slate-600 hover:bg-slate-200 hover:dark:bg-slate-700"
onClick={() => {
scrollToTopHistoryList(true)
resetKeyboardNavigation()
}}
>
<Text className="text-mute text-xs text-center px-3">

View File

@ -13,7 +13,10 @@ import { atomWithStore } from 'jotai-zustand'
import { createStore } from 'zustand/vanilla'
import DOMPurify from '../components/libs/dompurify'
import { DEFAULT_SPECIAL_PASTE_OPERATIONS, DEFAULT_SPECIAL_PASTE_CATEGORIES } from './constants'
import {
DEFAULT_SPECIAL_PASTE_CATEGORIES,
DEFAULT_SPECIAL_PASTE_OPERATIONS,
} from './constants'
import {
availableVersionBody,
availableVersionDate,
@ -549,7 +552,6 @@ export const settingsStore = createStore<SettingsStoreState & Settings>()((set,
}))
}
return set(() => ({ [name]: value }))
} catch (e) {
console.error(e)

View File

@ -37,6 +37,9 @@ export const resetTimeModalInterval = signal<NodeJS.Timeout | null>(null)
// Clipboard History Signals
export const showHistoryDeleteConfirmationId = signal<UniqueIdentifier | null>(null)
export const showHistoryMultiDeleteConfirmationIds = 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)
@ -147,7 +150,7 @@ export function resetMenuCreateOrEdit() {
export function resetKeyboardNavigation() {
currentNavigationContext.value = null
keyboardSelectedItemId.value = null
hoveringHistoryRowId.value = null
// hoveringHistoryRowId.value = null
keyboardSelectedBoardId.value = null
keyboardSelectedClipId.value = null
currentBoardIndex.value = 0