diff --git a/packages/pastebar-app-ui/src/hooks/use-delete-confirmation-items.ts b/packages/pastebar-app-ui/src/hooks/use-delete-confirmation-items.ts index 7b3681b6..85c9fa3f 100644 --- a/packages/pastebar-app-ui/src/hooks/use-delete-confirmation-items.ts +++ b/packages/pastebar-app-ui/src/hooks/use-delete-confirmation-items.ts @@ -13,7 +13,7 @@ const useDeleteConfirmationTimer = ({ onConfirmedDelete: () => Promise onConfirmedReset?: () => void selectedHistoryItems: UniqueIdentifier[] - hoveringHistoryRowId: Signal | null + hoveringHistoryRowId: Signal timerDuration?: number }) => { const timerRef = useRef(null) as React.MutableRefObject @@ -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 () => { diff --git a/packages/pastebar-app-ui/src/hooks/use-keyboard-delete-confirmation.ts b/packages/pastebar-app-ui/src/hooks/use-keyboard-delete-confirmation.ts new file mode 100644 index 00000000..aa1416bf --- /dev/null +++ b/packages/pastebar-app-ui/src/hooks/use-keyboard-delete-confirmation.ts @@ -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 + onConfirmedReset?: () => void + selectedHistoryItems: UniqueIdentifier[] + keyboardSelectedItemId: Signal + timerDuration?: number +}) => { + const timerRef = useRef(null) as React.MutableRefObject + const [showConfirmation, setShowConfirmation] = useState(false) + const [keyboardItemIdDelete, setKeyboardItemIdDelete] = + useState(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 diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryIconMenu.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryIconMenu.tsx index 647c4ab6..7c0b427c 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryIconMenu.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryIconMenu.tsx @@ -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) }) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx index 0340e3f0..a7e7ac55 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx @@ -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({ > 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' diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryWindowIcons.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryWindowIcons.tsx index e0ae5d2f..9c3f84aa 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryWindowIcons.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryWindowIcons.tsx @@ -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) }) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx index 6d45d330..de640e72 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx @@ -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({ {isSelected && selectedHistoryItems && selectedHistoryItems.length > 1 ? ( { e.preventDefault() @@ -882,7 +884,9 @@ export default function ClipboardHistoryRowContextMenu({ ) : ( { e.preventDefault() diff --git a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx index 72adf682..ceeacacb 100644 --- a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx +++ b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx @@ -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(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() }} > diff --git a/packages/pastebar-app-ui/src/store/settingsStore.ts b/packages/pastebar-app-ui/src/store/settingsStore.ts index 7f870f13..674e66b1 100644 --- a/packages/pastebar-app-ui/src/store/settingsStore.ts +++ b/packages/pastebar-app-ui/src/store/settingsStore.ts @@ -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()((set, })) } - return set(() => ({ [name]: value })) } catch (e) { console.error(e) diff --git a/packages/pastebar-app-ui/src/store/signalStore.ts b/packages/pastebar-app-ui/src/store/signalStore.ts index 0841a81c..800387a2 100644 --- a/packages/pastebar-app-ui/src/store/signalStore.ts +++ b/packages/pastebar-app-ui/src/store/signalStore.ts @@ -37,6 +37,9 @@ export const resetTimeModalInterval = signal(null) // Clipboard History Signals export const showHistoryDeleteConfirmationId = signal(null) +export const showHistoryMultiDeleteConfirmationIds = signal( + null +) export const hoveringHistoryRowId = signal(null) export const showLargeViewHistoryId = signal(null) export const showKeyboardNavContextMenuHistoryId = signal(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