feat(utils): add button navigation and selector
This commit is contained in:
BIN
public/images/utils/selector-corner.webp
Normal file
BIN
public/images/utils/selector-corner.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 B |
146
src/utils/buttonNavigation.ts
Normal file
146
src/utils/buttonNavigation.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
export type ButtonRect = [x: number, y: number, w: number, h: number];
|
||||
|
||||
export interface ButtonNavigationConfig<T extends string> {
|
||||
up?: T | "last";
|
||||
down?: T | "last";
|
||||
left?: T;
|
||||
right?: T;
|
||||
horizontalMode?: "navigate" | "preview";
|
||||
}
|
||||
|
||||
export class ButtonNavigation<T extends string> {
|
||||
private selectedButton: T;
|
||||
private nextButton?: T;
|
||||
private buttons: Record<T, ButtonRect>;
|
||||
private navigation: Record<T, ButtonNavigationConfig<T>>;
|
||||
private onButtonClick?: (buttonName: T) => void;
|
||||
private keydownHandler: (event: KeyboardEvent) => void;
|
||||
|
||||
constructor(config: {
|
||||
buttons: Record<T, ButtonRect>;
|
||||
initialButton: T;
|
||||
navigation: Record<T, ButtonNavigationConfig<T>>;
|
||||
onButtonClick?: (buttonName: T) => void;
|
||||
}) {
|
||||
this.buttons = config.buttons;
|
||||
this.selectedButton = config.initialButton;
|
||||
this.navigation = config.navigation;
|
||||
this.onButtonClick = config.onButtonClick;
|
||||
|
||||
// TODO: this should be handled by the nds itself i think, and dispatched
|
||||
this.keydownHandler = this.handleKeyPress.bind(this);
|
||||
window.addEventListener("keydown", this.keydownHandler);
|
||||
}
|
||||
|
||||
public getSelectedButton(): T {
|
||||
return this.selectedButton;
|
||||
}
|
||||
|
||||
public getSelectorPosition(): ButtonRect {
|
||||
return this.buttons[this.selectedButton];
|
||||
}
|
||||
|
||||
public handleTouch(x: number, y: number): void {
|
||||
for (const [buttonName, config] of Object.entries(this.buttons) as [
|
||||
T,
|
||||
ButtonRect,
|
||||
][]) {
|
||||
const [sx, sy, sw, sh] = config;
|
||||
if (x >= sx && x <= sx + sw && y >= sy && y <= sy + sh) {
|
||||
if (this.selectedButton === buttonName) {
|
||||
this.onButtonClick?.(buttonName);
|
||||
} else {
|
||||
if (
|
||||
(this.navigation[buttonName].down === "last" &&
|
||||
this.navigation[this.selectedButton]!.up === buttonName) ||
|
||||
(this.navigation[buttonName].up === "last" &&
|
||||
this.navigation[this.selectedButton]!.down === buttonName)
|
||||
) {
|
||||
this.nextButton = this.selectedButton;
|
||||
}
|
||||
|
||||
this.selectedButton = buttonName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyPress(event: KeyboardEvent): void {
|
||||
const currentButton = this.selectedButton;
|
||||
const currentNav = this.navigation[currentButton];
|
||||
|
||||
if (!currentNav) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
if (!currentNav.up) return;
|
||||
|
||||
if (currentNav.up === "last") {
|
||||
if (this.nextButton) {
|
||||
this.selectedButton = this.nextButton;
|
||||
} else {
|
||||
this.selectedButton = (currentNav.left ?? currentNav.right) as T;
|
||||
}
|
||||
} else {
|
||||
if (this.navigation[currentNav.up].down === "last") {
|
||||
this.nextButton = this.selectedButton;
|
||||
}
|
||||
this.selectedButton = currentNav.up;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "ArrowDown":
|
||||
if (!currentNav.down) return;
|
||||
|
||||
if (currentNav.down === "last") {
|
||||
if (this.nextButton) {
|
||||
this.selectedButton = this.nextButton;
|
||||
} else {
|
||||
this.selectedButton = (currentNav.left ?? currentNav.right) as T;
|
||||
}
|
||||
} else {
|
||||
if (this.navigation[currentNav.down].up === "last") {
|
||||
this.nextButton = this.selectedButton;
|
||||
}
|
||||
this.selectedButton = currentNav.down;
|
||||
}
|
||||
break;
|
||||
|
||||
case "ArrowLeft":
|
||||
if (!currentNav.left) return;
|
||||
|
||||
if (currentNav.horizontalMode === "preview") {
|
||||
this.nextButton = currentNav.left;
|
||||
} else {
|
||||
this.selectedButton = currentNav.left;
|
||||
}
|
||||
break;
|
||||
|
||||
case "ArrowRight":
|
||||
if (!currentNav.right) return;
|
||||
|
||||
if (currentNav.horizontalMode === "preview") {
|
||||
this.nextButton = currentNav.right;
|
||||
} else {
|
||||
this.selectedButton = currentNav.right;
|
||||
}
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
case " ":
|
||||
this.onButtonClick?.(this.selectedButton);
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
window.removeEventListener("keydown", this.keydownHandler);
|
||||
}
|
||||
}
|
||||
79
src/utils/buttonSelector.ts
Normal file
79
src/utils/buttonSelector.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ImageLoader } from "./loadImages";
|
||||
import type { ButtonRect } from "./buttonNavigation";
|
||||
|
||||
const ANIMATION_SPEED = 0.25;
|
||||
|
||||
export class ButtonSelector {
|
||||
private images = new ImageLoader({
|
||||
corner: "/images/utils/selector-corner.webp",
|
||||
});
|
||||
|
||||
private currentX = 0;
|
||||
private currentY = 0;
|
||||
private currentWidth = 0;
|
||||
private currentHeight = 0;
|
||||
|
||||
constructor(initialRect: ButtonRect) {
|
||||
[this.currentX, this.currentY, this.currentWidth, this.currentHeight] =
|
||||
initialRect;
|
||||
}
|
||||
|
||||
public render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
targetRect: ButtonRect,
|
||||
opacity: number = 1,
|
||||
): void {
|
||||
if (!this.images.isReady) return;
|
||||
|
||||
const [targetX, targetY, targetWidth, targetHeight] = targetRect;
|
||||
const dx = targetX - this.currentX;
|
||||
const dy = targetY - this.currentY;
|
||||
const dw = targetWidth - this.currentWidth;
|
||||
const dh = targetHeight - this.currentHeight;
|
||||
|
||||
if (
|
||||
Math.abs(dx) < 0.5 &&
|
||||
Math.abs(dy) < 0.5 &&
|
||||
Math.abs(dw) < 0.5 &&
|
||||
Math.abs(dh) < 0.5
|
||||
) {
|
||||
[this.currentX, this.currentY, this.currentWidth, this.currentHeight] =
|
||||
targetRect;
|
||||
} else {
|
||||
this.currentX += dx * ANIMATION_SPEED;
|
||||
this.currentY += dy * ANIMATION_SPEED;
|
||||
this.currentWidth += dw * ANIMATION_SPEED;
|
||||
this.currentHeight += dh * ANIMATION_SPEED;
|
||||
}
|
||||
|
||||
const x = Math.floor(this.currentX);
|
||||
const y = Math.floor(this.currentY);
|
||||
const w = Math.floor(this.currentWidth);
|
||||
const h = Math.floor(this.currentHeight);
|
||||
|
||||
const corner = this.images.require("corner");
|
||||
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
// top-left
|
||||
ctx.drawImage(corner, x, y);
|
||||
|
||||
// top-right
|
||||
ctx.save();
|
||||
ctx.scale(-1, 1);
|
||||
ctx.drawImage(corner, -(x + w), y);
|
||||
ctx.restore();
|
||||
|
||||
// bottom-left
|
||||
ctx.save();
|
||||
ctx.scale(1, -1);
|
||||
ctx.drawImage(corner, x, -(y + h));
|
||||
ctx.restore();
|
||||
|
||||
// bottom-right
|
||||
ctx.save();
|
||||
ctx.scale(-1, -1);
|
||||
ctx.drawImage(corner, -(x + w), -(y + h));
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user