~bigbes/lethe

ref: 136ae1f4c5c0068868f3cabeae0f947945a381dd lethe/web/src/lib/keyboard.ts -rw-r--r-- 2.6 KiB
136ae1f4 — Eugene Blikh docs(lethe-web-ui-foundation): record verify section, mark Verified a month ago
                                                                                
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
// Keyboard controller — side-effect-free at import time.
// The consumer wires document.addEventListener('keydown', controller.onKeyDown)
// and pairs the cleanup with teardown().

export type RouteName = 'home' | 'projects' | 'stats' | 'health'

export interface KeyboardOpts {
  go: (r: RouteName) => void
  openPalette: () => void
  closePalette: () => void
  isPaletteOpen: () => boolean
  cursor: { move(d: 1 | -1): void; activate(): void }
}

const G_TIMEOUT_MS = 800

function isEditableTarget(target: EventTarget | null): boolean {
  if (!(target instanceof Element)) return false
  const tag = target.tagName.toLowerCase()
  if (tag === 'input' || tag === 'textarea') return true
  if (target.hasAttribute('contenteditable')) return true
  return false
}

export function createKeyboardController(opts: KeyboardOpts): {
  onKeyDown(e: KeyboardEvent): void
  teardown(): void
} {
  let gPending = false
  let gTimer: ReturnType<typeof setTimeout> | null = null

  function clearG(): void {
    gPending = false
    if (gTimer !== null) {
      clearTimeout(gTimer)
      gTimer = null
    }
  }

  function onKeyDown(e: KeyboardEvent): void {
    // ⌘K / Ctrl+K → open palette
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
      e.preventDefault()
      opts.openPalette()
      return
    }

    // Esc → close palette if open
    if (e.key === 'Escape') {
      if (opts.isPaletteOpen()) {
        opts.closePalette()
      }
      return
    }

    // g-prefix navigation: consume 'g' then route letter
    if (gPending) {
      let route: RouteName | null = null
      if (e.key === 'h') route = 'home'
      else if (e.key === 'p') route = 'projects'
      else if (e.key === 's') route = 'stats'
      else if (e.key === 'i') route = 'health'

      if (route !== null) {
        clearG()
        opts.go(route)
        return
      }
      // unrecognised key after g — clear pending and fall through
      clearG()
    }

    if (e.key === 'g') {
      gPending = true
      gTimer = setTimeout(clearG, G_TIMEOUT_MS)
      return
    }

    // j / k — cursor movement (only when palette closed and target not editable)
    if (!opts.isPaletteOpen() && !isEditableTarget(e.target)) {
      if (e.key === 'j') { opts.cursor.move(1); return }
      if (e.key === 'k') { opts.cursor.move(-1); return }
    }

    // Enter — activate cursor item when palette closed and target is body
    if (e.key === 'Enter' && !opts.isPaletteOpen() && e.target === document.body) {
      opts.cursor.activate()
      return
    }
  }

  function teardown(): void {
    clearG()
  }

  return { onKeyDown, teardown }
}