|
| 1 | +import type { GitNode, GitCommit, GitTree, GitBlob, GitBranch, GitTag } from "./Types"; |
| 2 | +import { NodeType } from "./Types"; |
| 3 | +import type { VisState } from "./Redraw"; |
| 4 | + |
| 5 | +type AnyNode = GitNode | GitCommit | GitTree | GitBlob | GitBranch | GitTag; |
| 6 | + |
| 7 | +let focusedNode: AnyNode | null = null; |
| 8 | +let focusableNodes: AnyNode[] = []; |
| 9 | +let focusIndex = -1; |
| 10 | + |
| 11 | +const TYPE_NAMES = ["Branch", "Commit", "Tree", "Blob", "HEAD", "Remote Branch", "Tag"]; |
| 12 | + |
| 13 | +export function getFocusedNode(): AnyNode | null { |
| 14 | + return focusedNode; |
| 15 | +} |
| 16 | + |
| 17 | +export function setFocusedNode(node: AnyNode | null): void { |
| 18 | + focusedNode = node; |
| 19 | + if (node) { |
| 20 | + focusIndex = focusableNodes.indexOf(node); |
| 21 | + announceNode(node); |
| 22 | + } else { |
| 23 | + focusIndex = -1; |
| 24 | + clearAnnouncement(); |
| 25 | + } |
| 26 | +} |
| 27 | + |
| 28 | +export function buildFocusableList(state: VisState): AnyNode[] { |
| 29 | + const nodes: AnyNode[] = []; |
| 30 | + |
| 31 | + // Add in visual column order (left to right) |
| 32 | + nodes.push(...state.HEADNodes); |
| 33 | + nodes.push(...state.BranchNodes); |
| 34 | + if (state.showTags) nodes.push(...state.TagNodes); |
| 35 | + nodes.push(...state.CommitNodes); |
| 36 | + if (state.showTrees) nodes.push(...state.TreeNodes); |
| 37 | + if (state.showBlobs) nodes.push(...state.BlobNodes); |
| 38 | + nodes.push(...state.RemoteBranchNodes); |
| 39 | + |
| 40 | + // Sort by x position first, then y position for consistent navigation |
| 41 | + focusableNodes = nodes.sort((a, b) => { |
| 42 | + const ax = a.xPos ?? 0; |
| 43 | + const bx = b.xPos ?? 0; |
| 44 | + const ay = a.yPos ?? 0; |
| 45 | + const by = b.yPos ?? 0; |
| 46 | + return ax - bx || ay - by; |
| 47 | + }); |
| 48 | + |
| 49 | + return focusableNodes; |
| 50 | +} |
| 51 | + |
| 52 | +function announceNode(node: AnyNode): void { |
| 53 | + const announcer = document.getElementById("node-announcer"); |
| 54 | + if (!announcer) return; |
| 55 | + |
| 56 | + const typeName = TYPE_NAMES[node.type] || "Node"; |
| 57 | + let label = ""; |
| 58 | + |
| 59 | + if ("name" in node && node.name) { |
| 60 | + label = node.name; |
| 61 | + } else if ("filename" in node && node.filename) { |
| 62 | + label = node.filename; |
| 63 | + } else if (node.hash) { |
| 64 | + label = node.hash.substring(0, 7); |
| 65 | + } |
| 66 | + |
| 67 | + const text = node.text ? `, ${node.text}` : ""; |
| 68 | + announcer.textContent = `${typeName}: ${label}${text}`; |
| 69 | +} |
| 70 | + |
| 71 | +function clearAnnouncement(): void { |
| 72 | + const announcer = document.getElementById("node-announcer"); |
| 73 | + if (announcer) { |
| 74 | + announcer.textContent = ""; |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +function findNearestNode( |
| 79 | + currentNode: AnyNode, |
| 80 | + direction: "left" | "right" | "up" | "down" |
| 81 | +): AnyNode | null { |
| 82 | + if (focusableNodes.length === 0) return null; |
| 83 | + |
| 84 | + const cx = currentNode.xPos ?? 0; |
| 85 | + const cy = currentNode.yPos ?? 0; |
| 86 | + |
| 87 | + let candidates: AnyNode[] = []; |
| 88 | + |
| 89 | + switch (direction) { |
| 90 | + case "left": |
| 91 | + candidates = focusableNodes.filter(n => (n.xPos ?? 0) < cx - 10); |
| 92 | + break; |
| 93 | + case "right": |
| 94 | + candidates = focusableNodes.filter(n => (n.xPos ?? 0) > cx + 10); |
| 95 | + break; |
| 96 | + case "up": |
| 97 | + candidates = focusableNodes.filter(n => (n.yPos ?? 0) < cy - 10); |
| 98 | + break; |
| 99 | + case "down": |
| 100 | + candidates = focusableNodes.filter(n => (n.yPos ?? 0) > cy + 10); |
| 101 | + break; |
| 102 | + } |
| 103 | + |
| 104 | + if (candidates.length === 0) return null; |
| 105 | + |
| 106 | + // Find nearest by distance |
| 107 | + let nearest = candidates[0]; |
| 108 | + let minDist = Infinity; |
| 109 | + |
| 110 | + for (const n of candidates) { |
| 111 | + const nx = n.xPos ?? 0; |
| 112 | + const ny = n.yPos ?? 0; |
| 113 | + const dist = Math.sqrt((nx - cx) ** 2 + (ny - cy) ** 2); |
| 114 | + if (dist < minDist) { |
| 115 | + minDist = dist; |
| 116 | + nearest = n; |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + return nearest; |
| 121 | +} |
| 122 | + |
| 123 | +function getNodesByTypeGroup(state: VisState): Map<string, AnyNode[]> { |
| 124 | + const groups = new Map<string, AnyNode[]>(); |
| 125 | + groups.set("head", state.HEADNodes); |
| 126 | + groups.set("branch", state.BranchNodes); |
| 127 | + if (state.showTags) groups.set("tag", state.TagNodes); |
| 128 | + groups.set("commit", state.CommitNodes); |
| 129 | + if (state.showTrees) groups.set("tree", state.TreeNodes); |
| 130 | + if (state.showBlobs) groups.set("blob", state.BlobNodes); |
| 131 | + groups.set("remote", state.RemoteBranchNodes); |
| 132 | + return groups; |
| 133 | +} |
| 134 | + |
| 135 | +function getNextTypeGroup(currentType: NodeType, state: VisState, reverse: boolean): AnyNode | null { |
| 136 | + const typeOrder = [ |
| 137 | + NodeType.head, |
| 138 | + NodeType.branch, |
| 139 | + NodeType.tag, |
| 140 | + NodeType.commit, |
| 141 | + NodeType.tree, |
| 142 | + NodeType.blob, |
| 143 | + NodeType.remotebranch, |
| 144 | + ]; |
| 145 | + |
| 146 | + const currentIndex = typeOrder.indexOf(currentType); |
| 147 | + const direction = reverse ? -1 : 1; |
| 148 | + |
| 149 | + for (let i = 1; i <= typeOrder.length; i++) { |
| 150 | + const nextIndex = (currentIndex + i * direction + typeOrder.length) % typeOrder.length; |
| 151 | + const nextType = typeOrder[nextIndex]; |
| 152 | + |
| 153 | + // Check if this type is visible |
| 154 | + if (nextType === NodeType.tag && !state.showTags) continue; |
| 155 | + if (nextType === NodeType.tree && !state.showTrees) continue; |
| 156 | + if (nextType === NodeType.blob && !state.showBlobs) continue; |
| 157 | + |
| 158 | + // Find first node of this type |
| 159 | + const node = focusableNodes.find(n => n.type === nextType); |
| 160 | + if (node) return node; |
| 161 | + } |
| 162 | + |
| 163 | + return null; |
| 164 | +} |
| 165 | + |
| 166 | +export function createKeyboardHandler( |
| 167 | + canvas: HTMLCanvasElement, |
| 168 | + state: VisState, |
| 169 | + onFocusChange: () => void |
| 170 | +): void { |
| 171 | + canvas.addEventListener("keydown", (e: KeyboardEvent) => { |
| 172 | + // Only handle keys when canvas is focused |
| 173 | + if (document.activeElement !== canvas) return; |
| 174 | + |
| 175 | + let handled = false; |
| 176 | + |
| 177 | + switch (e.key) { |
| 178 | + case "ArrowLeft": |
| 179 | + if (focusedNode) { |
| 180 | + const next = findNearestNode(focusedNode, "left"); |
| 181 | + if (next) { |
| 182 | + setFocusedNode(next); |
| 183 | + handled = true; |
| 184 | + } |
| 185 | + } |
| 186 | + break; |
| 187 | + |
| 188 | + case "ArrowRight": |
| 189 | + if (focusedNode) { |
| 190 | + const next = findNearestNode(focusedNode, "right"); |
| 191 | + if (next) { |
| 192 | + setFocusedNode(next); |
| 193 | + handled = true; |
| 194 | + } |
| 195 | + } else if (focusableNodes.length > 0) { |
| 196 | + // First focus - start with first node |
| 197 | + setFocusedNode(focusableNodes[0]); |
| 198 | + handled = true; |
| 199 | + } |
| 200 | + break; |
| 201 | + |
| 202 | + case "ArrowUp": |
| 203 | + if (focusedNode) { |
| 204 | + const next = findNearestNode(focusedNode, "up"); |
| 205 | + if (next) { |
| 206 | + setFocusedNode(next); |
| 207 | + handled = true; |
| 208 | + } |
| 209 | + } |
| 210 | + break; |
| 211 | + |
| 212 | + case "ArrowDown": |
| 213 | + if (focusedNode) { |
| 214 | + const next = findNearestNode(focusedNode, "down"); |
| 215 | + if (next) { |
| 216 | + setFocusedNode(next); |
| 217 | + handled = true; |
| 218 | + } |
| 219 | + } else if (focusableNodes.length > 0) { |
| 220 | + // First focus - start with first node |
| 221 | + setFocusedNode(focusableNodes[0]); |
| 222 | + handled = true; |
| 223 | + } |
| 224 | + break; |
| 225 | + |
| 226 | + case "Tab": |
| 227 | + if (focusedNode) { |
| 228 | + const next = getNextTypeGroup(focusedNode.type, state, e.shiftKey); |
| 229 | + if (next) { |
| 230 | + setFocusedNode(next); |
| 231 | + handled = true; |
| 232 | + } |
| 233 | + } else if (focusableNodes.length > 0) { |
| 234 | + setFocusedNode(focusableNodes[0]); |
| 235 | + handled = true; |
| 236 | + } |
| 237 | + break; |
| 238 | + |
| 239 | + case "Home": |
| 240 | + if (focusableNodes.length > 0) { |
| 241 | + setFocusedNode(focusableNodes[0]); |
| 242 | + handled = true; |
| 243 | + } |
| 244 | + break; |
| 245 | + |
| 246 | + case "End": |
| 247 | + if (focusableNodes.length > 0) { |
| 248 | + setFocusedNode(focusableNodes[focusableNodes.length - 1]); |
| 249 | + handled = true; |
| 250 | + } |
| 251 | + break; |
| 252 | + |
| 253 | + case "Escape": |
| 254 | + if (focusedNode) { |
| 255 | + setFocusedNode(null); |
| 256 | + handled = true; |
| 257 | + } |
| 258 | + break; |
| 259 | + |
| 260 | + case "Enter": |
| 261 | + case " ": |
| 262 | + // Select/activate the focused node (currently just keeps focus) |
| 263 | + if (focusedNode) { |
| 264 | + handled = true; |
| 265 | + } |
| 266 | + break; |
| 267 | + } |
| 268 | + |
| 269 | + if (handled) { |
| 270 | + e.preventDefault(); |
| 271 | + e.stopPropagation(); |
| 272 | + onFocusChange(); |
| 273 | + } |
| 274 | + }); |
| 275 | + |
| 276 | + // Clear focus when canvas loses focus |
| 277 | + canvas.addEventListener("blur", () => { |
| 278 | + if (focusedNode) { |
| 279 | + setFocusedNode(null); |
| 280 | + onFocusChange(); |
| 281 | + } |
| 282 | + }); |
| 283 | +} |
0 commit comments