All files / src/hooks useKeyboardNavigation.ts

94.82% Statements 55/58
85.36% Branches 35/41
93.33% Functions 14/15
95.74% Lines 45/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124                                                    39x       39x                 4x 4x   3x 6x 3x 3x                   3x 3x   3x 6x   3x       3x 3x 2x   1x             13x 13x 12x 13x 8x 5x       7x       264x 264x 264x             44x 39x       44x 44x 44x 44x 44x 44x   44x 19x 19x 13x 13x 7x 7x 3x   19x 19x      
import { useEffect, useMemo, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import { filterEntries, sortByModified, buildRelationshipGroups } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
 
interface Tab {
  entry: VaultEntry
  content: string
}
 
interface KeyboardNavigationOptions {
  tabs: Tab[]
  activeTabPath: string | null
  entries: VaultEntry[]
  selection: SidebarSelection
  allContent: Record<string, string>
  onSwitchTab: (path: string) => void
  onReplaceActiveTab: (entry: VaultEntry) => void
  onSelectNote: (entry: VaultEntry) => void
}
 
function computeVisibleNotes(
  entries: VaultEntry[],
  selection: SidebarSelection,
  allContent: Record<string, string>,
): VaultEntry[] {
  Iif (selection.kind === 'entity') {
    return buildRelationshipGroups(selection.entry, entries, allContent)
      .flatMap((g) => g.entries)
  }
  return [...filterEntries(entries, selection)].sort(sortByModified)
}
 
function navigateTab(
  tabsRef: React.RefObject<Tab[]>,
  activeTabPathRef: React.RefObject<string | null>,
  onSwitchTab: React.RefObject<(path: string) => void>,
  direction: 1 | -1,
) {
  const currentTabs = tabsRef.current!
  if (currentTabs.length === 0) return
 
  const currentPath = activeTabPathRef.current
  const currentIndex = currentTabs.findIndex((t) => t.entry.path === currentPath)
  const nextIndex = (currentIndex + direction + currentTabs.length) % currentTabs.length
  onSwitchTab.current!(currentTabs[nextIndex].entry.path)
}
 
function navigateNote(
  visibleNotesRef: React.RefObject<VaultEntry[]>,
  activeTabPathRef: React.RefObject<string | null>,
  onReplace: React.RefObject<(entry: VaultEntry) => void>,
  onSelect: React.RefObject<(entry: VaultEntry) => void>,
  direction: 1 | -1,
) {
  const notes = visibleNotesRef.current!
  Iif (notes.length === 0) return
 
  const currentPath = activeTabPathRef.current
  const currentIndex = notes.findIndex((n) => n.path === currentPath)
 
  const nextIndex = currentIndex === -1
    ? (direction === 1 ? 0 : notes.length - 1)
    : (currentIndex + direction + notes.length) % notes.length
 
  const nextNote = notes[nextIndex]
  if (currentPath) {
    onReplace.current!(nextNote)
  } else {
    onSelect.current!(nextNote)
  }
}
 
type ShortcutKind = 'tab' | 'note' | null
 
function classifyShortcut(e: KeyboardEvent, inTauri: boolean): ShortcutKind {
  const mod = e.metaKey || e.ctrlKey
  if (!mod) return null
  const isTabShortcut = inTauri ? (e.altKey && !e.shiftKey) : (e.shiftKey && !e.altKey)
  if (isTabShortcut && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return 'tab'
  if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) return 'note'
  return null
}
 
function arrowDirection(key: string): 1 | -1 {
  return (key === 'ArrowRight' || key === 'ArrowDown') ? 1 : -1
}
 
function useLatestRef<T>(value: T): React.RefObject<T> {
  const ref = useRef(value)
  useEffect(() => { ref.current = value })
  return ref
}
 
export function useKeyboardNavigation({
  tabs, activeTabPath, entries, selection, allContent,
  onSwitchTab, onReplaceActiveTab, onSelectNote,
}: KeyboardNavigationOptions) {
  const visibleNotes = useMemo(
    () => computeVisibleNotes(entries, selection, allContent),
    [entries, selection, allContent],
  )
 
  const tabsRef = useLatestRef(tabs)
  const activeTabPathRef = useLatestRef(activeTabPath)
  const visibleNotesRef = useLatestRef(visibleNotes)
  const onSwitchTabRef = useLatestRef(onSwitchTab)
  const onReplaceRef = useLatestRef(onReplaceActiveTab)
  const onSelectNoteRef = useLatestRef(onSelectNote)
 
  useEffect(() => {
    const inTauri = isTauri()
    const handleKeyDown = (e: KeyboardEvent) => {
      const kind = classifyShortcut(e, inTauri)
      if (!kind) return
      e.preventDefault()
      if (kind === 'tab') navigateTab(tabsRef, activeTabPathRef, onSwitchTabRef, arrowDirection(e.key))
      else navigateNote(visibleNotesRef, activeTabPathRef, onReplaceRef, onSelectNoteRef, arrowDirection(e.key))
    }
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [tabsRef, activeTabPathRef, visibleNotesRef, onSwitchTabRef, onReplaceRef, onSelectNoteRef])
}