import gsap from "gsap"; const DURATION = 0.11; export const useButtonNavigation = >({ buttons, initialButton, onButtonClick, navigation, disabled, }: { buttons: T; initialButton: keyof T; onButtonClick?: (buttonName: keyof T) => void; navigation: Record< keyof T, { up?: keyof T | "last" | [buttonName: keyof T, blocked: boolean]; down?: keyof T | "last" | [buttonName: keyof T, blocked: boolean]; left?: keyof T | [buttonName: keyof T, blocked: boolean]; right?: keyof T | [buttonName: keyof T, blocked: boolean]; horizontalMode?: "navigate" | "preview"; } >; disabled?: Ref; }) => { type Entry = keyof T; const confirmationModal = useConfirmationModal(); const selectedButton = ref(initialButton); const selectorPosition = ref(buttons[initialButton]!); const nextButton = ref(); const isAnimating = ref(false); const blockInteractions = computed( () => confirmationModal.isOpen || disabled?.value || isAnimating.value, ); const getNavigationTarget = ( value: Entry | [buttonName: Entry, blocked: boolean] | undefined, ): { target: Entry | "last"; blocked: boolean } | null => { if (!value) return null; if (Array.isArray(value)) { return { target: value[0]!, blocked: value[1] === false }; } return { target: value, blocked: false }; }; const buildNavigationGraph = ( nav: typeof navigation, ): Map> => { const edges = new Map>(); for (const [button, navConfig] of Object.entries(nav) as [ Entry, (typeof nav)[Entry], ][]) { if (!edges.has(button)) { edges.set(button, new Set()); } const up = getNavigationTarget(navConfig.up); const down = getNavigationTarget(navConfig.down); const left = getNavigationTarget(navConfig.left); const right = getNavigationTarget(navConfig.right); // handle blocked paths if (up && up.target !== "last" && !up.blocked) { edges.get(button)!.add(up.target); } if (down && down.target !== "last" && !down.blocked) { edges.get(button)!.add(down.target); } if (left && !left.blocked) { edges.get(button)!.add(left.target); } if (right && !right.blocked) { edges.get(button)!.add(right.target); } if (up?.target === "last" || down?.target === "last") { for (const [otherButton, otherNav] of Object.entries(nav) as [ Entry, (typeof nav)[Entry], ][]) { if (otherButton === button) continue; const otherUp = getNavigationTarget(otherNav.up); const otherDown = getNavigationTarget(otherNav.down); const otherLeft = getNavigationTarget(otherNav.left); const otherRight = getNavigationTarget(otherNav.right); if ( (otherUp?.target === button && !otherUp.blocked) || (otherDown?.target === button && !otherDown.blocked) || (otherLeft?.target === button && !otherLeft.blocked) || (otherRight?.target === button && !otherRight.blocked) ) { edges.get(button)!.add(otherButton); } } } } return edges; }; const findPath = ( graph: Map>, from: Entry, to: Entry, ): Array | null => { if (from === to) return []; const queue: Array<{ button: Entry; path: Array }> = [ { button: from, path: [] }, ]; const visited = new Set([from]); while (queue.length > 0) { const { button, path } = queue.shift()!; const neighbors = graph.get(button); if (!neighbors) continue; for (const neighbor of neighbors) { if (visited.has(neighbor)) continue; const newPath = [...path, neighbor]; if (neighbor === to) { return newPath; } visited.add(neighbor); queue.push({ button: neighbor, path: newPath }); } } return null; }; const graph = buildNavigationGraph(navigation); const animateToButton = (targetButton: Entry, path?: Array | null) => { const pathButtons = path && path.length > 0 ? path : [targetButton]; isAnimating.value = true; const timeline = gsap.timeline({ onComplete: () => { isAnimating.value = false; }, }); selectorPosition.value = [...selectorPosition.value]; for (const button of pathButtons) { const buttonRect = buttons[button]!; timeline.to( selectorPosition.value, { [0]: buttonRect[0], [1]: buttonRect[1], [2]: buttonRect[2], [3]: buttonRect[3], duration: DURATION, ease: "none", }, "+=0", ); } selectedButton.value = targetButton; }; const { onClick } = useScreen(); onClick((x: number, y: number) => { if (blockInteractions.value) return; for (const [buttonName, buttonRect] of Object.entries(buttons) as [ Entry, Rect, ][]) { const [sx, sy, sw, sh] = buttonRect; if (x >= sx && x <= sx + sw && y >= sy && y <= sy + sh) { if (selectedButton.value === buttonName) { onButtonClick?.(buttonName); } else { const path = findPath(graph, selectedButton.value, buttonName); if ( (navigation[buttonName].down === "last" && navigation[selectedButton.value]!.up === buttonName) || (navigation[buttonName].up === "last" && navigation[selectedButton.value]!.down === buttonName) ) { nextButton.value = selectedButton.value; } animateToButton(buttonName, path); } break; } } }); useKeyDown((key) => { if (blockInteractions.value) return; const currentButton = selectedButton.value as Entry; const currentNav = navigation[currentButton]; if (!currentNav) return; let targetButton: Entry | undefined; switch (key) { case "NDS_UP": { const upConfig = getNavigationTarget(currentNav.up); if (!upConfig) return; if (upConfig.target === "last") { if (nextButton.value) { targetButton = nextButton.value; } else { const leftConfig = getNavigationTarget(currentNav.left); const rightConfig = getNavigationTarget(currentNav.right); targetButton = leftConfig?.target ?? rightConfig?.target; } } else { const targetNav = navigation[upConfig.target]; const targetDownConfig = getNavigationTarget(targetNav?.down); if (targetDownConfig?.target === "last") { nextButton.value = selectedButton.value as Entry; } targetButton = upConfig.target; } break; } case "NDS_DOWN": { const downConfig = getNavigationTarget(currentNav.down); if (!downConfig) return; if (downConfig.target === "last") { if (nextButton.value) { targetButton = nextButton.value; } else { const leftConfig = getNavigationTarget(currentNav.left); const rightConfig = getNavigationTarget(currentNav.right); targetButton = leftConfig?.target ?? rightConfig?.target; } } else { const targetNav = navigation[downConfig.target]; const targetUpConfig = getNavigationTarget(targetNav?.up); if (targetUpConfig?.target === "last") { nextButton.value = selectedButton.value as Entry; } targetButton = downConfig.target; } break; } case "NDS_LEFT": { const leftConfig = getNavigationTarget(currentNav.left); if (!leftConfig) return; if (currentNav.horizontalMode === "preview") { nextButton.value = leftConfig.target; } else { targetButton = leftConfig.target; } break; } case "NDS_RIGHT": { const rightConfig = getNavigationTarget(currentNav.right); if (!rightConfig) return; if (currentNav.horizontalMode === "preview") { nextButton.value = rightConfig.target; } else { targetButton = rightConfig.target; } break; } case "NDS_START": case "NDS_A": { onButtonClick?.(selectedButton.value); break; } default: return; } if (targetButton) { const path = findPath(graph, selectedButton.value, targetButton); animateToButton(targetButton, path); } }); const select = (targetButton: Entry) => { if (isAnimating.value || disabled?.value) return; const path = findPath(graph, selectedButton.value, targetButton); animateToButton(targetButton, path); }; return { selected: readonly(selectedButton), selectorPosition, select, }; };