initial commit
This commit is contained in:
commit
2a8d0269b8
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,135 @@
|
|||
# @ngshack/input
|
||||
|
||||
Biblioteka komponentów input i nawigacji dla aplikacji Webland z obsługą gamepadów.
|
||||
|
||||
## Instalacja
|
||||
|
||||
```bash
|
||||
npm install @ngshack/input
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- Angular CLI 17+
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Generate Components
|
||||
```bash
|
||||
# Generate new component
|
||||
ng generate component components/my-component --project=input
|
||||
|
||||
# Generate service
|
||||
ng generate service services/my-service --project=input
|
||||
|
||||
# Generate module
|
||||
ng generate module modules/my-module --project=input
|
||||
```
|
||||
|
||||
### Build Library
|
||||
```bash
|
||||
ng build input
|
||||
```
|
||||
|
||||
### Test
|
||||
```bash
|
||||
ng test input
|
||||
```
|
||||
|
||||
### Publish
|
||||
```bash
|
||||
npm run build:libs
|
||||
npm publish dist/input
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Navigation Module
|
||||
|
||||
```typescript
|
||||
import { NavigationModule } from '@ngshack/input';
|
||||
|
||||
@Component({
|
||||
imports: [NavigationModule],
|
||||
template: `
|
||||
<div inputActionBinding="confirm" (action)="onConfirm()">
|
||||
<button>Confirm</button>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class MyComponent {
|
||||
onConfirm() {
|
||||
console.log('Confirmed!');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Action Binding Directive
|
||||
|
||||
```typescript
|
||||
import { ActionBindingDirective } from '@ngshack/input';
|
||||
|
||||
@Component({
|
||||
imports: [ActionBindingDirective],
|
||||
template: `
|
||||
<div inputActionBinding="back" (action)="goBack()">
|
||||
Back Button
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class MyComponent {
|
||||
goBack() {
|
||||
// Handle back action
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 🎮 Gamepad support
|
||||
- ⌨️ Keyboard navigation
|
||||
- 🖱️ Mouse/touch support
|
||||
- 🎯 Action binding system
|
||||
- 📱 Responsive design
|
||||
- 🎨 Gaming console theme
|
||||
|
||||
## Publishing the Library
|
||||
|
||||
Once the project is built, you can publish your library by following these steps:
|
||||
|
||||
1. Navigate to the `dist` directory:
|
||||
```bash
|
||||
cd dist/input
|
||||
```
|
||||
|
||||
2. Run the `npm publish` command to publish your library to the npm registry:
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"$schema": "../../node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"input": {
|
||||
"projectType": "library",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"flat": false,
|
||||
"inlineTemplate": false,
|
||||
"inlineStyle": false,
|
||||
"type": "Component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"flat": false,
|
||||
"type": "Directive"
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"flat": false,
|
||||
"type": "Pipe"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"flat": false,
|
||||
"type": "Service"
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"flat": false
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"flat": false,
|
||||
"type": "Guard"
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"flat": false,
|
||||
"type": "Interceptor"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "input",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:ng-packagr",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "tsconfig.lib.prod.json"
|
||||
},
|
||||
"development": {
|
||||
"tsConfig": "tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"tsConfig": "tsconfig.spec.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/input",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "@ngshack/input",
|
||||
"version": "0.0.1",
|
||||
"description": "Webland input components library",
|
||||
"author": "Webland Team",
|
||||
"license": "MIT",
|
||||
"keywords": ["angular", "input", "forms", "components", "webland"],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webland/webland.git",
|
||||
"directory": "app/packages/input"
|
||||
},
|
||||
"homepage": "https://github.com/webland/webland#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/webland/webland/issues"
|
||||
},
|
||||
"main": "bundles/webland-input.umd.js",
|
||||
"module": "fesm2022/webland-input.mjs",
|
||||
"typings": "index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": {
|
||||
"default": "./package.json"
|
||||
},
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"esm": "./fesm2022/webland-input.mjs",
|
||||
"default": "./fesm2022/webland-input.mjs"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^20.1.0",
|
||||
"@angular/core": "^20.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
],
|
||||
exports: [
|
||||
]
|
||||
})
|
||||
export class InputModule { }
|
|
@ -0,0 +1,143 @@
|
|||
import { EventEmitter } from "@angular/core";
|
||||
import { InputDevice } from "../input-device";
|
||||
|
||||
interface GamepadButtonState {
|
||||
value: number;
|
||||
angle?: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export class InputGamepad extends InputDevice {
|
||||
|
||||
static listenerInited = false;
|
||||
public static DeviceConnected = new EventEmitter<InputGamepad>();
|
||||
public static DeviceDisconnected = new EventEmitter<InputGamepad>();
|
||||
public static DeviceListChanged = new EventEmitter<InputGamepad[]>();
|
||||
private static gamepads: InputGamepad[] = [];
|
||||
private stateUpdateAgent?: number;
|
||||
private buttonState: GamepadButtonState[] = [];
|
||||
public axesState: GamepadButtonState[] = [];
|
||||
|
||||
constructor(
|
||||
private gamepad: Gamepad
|
||||
) {
|
||||
super(InputGamepad.GetName(gamepad), 'gamepad');
|
||||
}
|
||||
|
||||
public static GetName(gamepad: Gamepad) {
|
||||
return `${gamepad.id} #${gamepad.index}`;
|
||||
}
|
||||
|
||||
public getMapping() {
|
||||
return this.gamepad.mapping;
|
||||
}
|
||||
|
||||
private static attachEvents() {
|
||||
if (InputGamepad.listenerInited) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('gamepadconnected', e => this.updateGamepadStates(e));
|
||||
window.addEventListener('gamepaddisconnected', e => this.updateGamepadStates(e));
|
||||
|
||||
InputGamepad.listenerInited = true;
|
||||
}
|
||||
|
||||
public connect() {
|
||||
this.gamepad.buttons.forEach((button, index) => {
|
||||
this.buttonState.push({
|
||||
value: button.value,
|
||||
angle: 0,
|
||||
id: index,
|
||||
});
|
||||
});
|
||||
|
||||
let axisIndex = 0;
|
||||
|
||||
for (let i = 0; i < this.gamepad.axes.length; i+=2) {
|
||||
const axis = {
|
||||
value: 0,
|
||||
angle: 0,
|
||||
id: axisIndex++,
|
||||
};
|
||||
this.axesState.push(axis);
|
||||
}
|
||||
|
||||
this.stateUpdateAgent = setInterval(() => {
|
||||
this.gamepad.buttons.forEach((button, index) => {
|
||||
const state = this.buttonState.find(s => s.id === index);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const wasPressed = state.value > 0;
|
||||
const isPressed = button.value > 0;
|
||||
state.value = button.value;
|
||||
if (isPressed !== wasPressed) {
|
||||
this.setKey(`gamepad.button.${index}`, button.value);
|
||||
}
|
||||
});
|
||||
|
||||
axisIndex = 0;
|
||||
for (let i = 0; i < this.gamepad.axes.length; i+=2) {
|
||||
const state = this.axesState.find(s => s.id === axisIndex);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const left = Math.abs(Math.max(0, this.gamepad.axes[i]));
|
||||
const right = Math.abs(0 - Math.min(0, this.gamepad.axes[i]));
|
||||
const up = Math.abs(Math.max(0, this.gamepad.axes[i + 1]));
|
||||
const down = Math.abs(0 - Math.min(0, this.gamepad.axes[i + 1]));
|
||||
|
||||
const x = right - left;
|
||||
const y = up - down;
|
||||
|
||||
const value = Math.sqrt(x * x + y * y);
|
||||
const angle = Math.atan2(x, y);
|
||||
|
||||
if (value !== state.value || angle !== state.angle) {
|
||||
this.setKey(`gamepad.axis.${state.id}`, value, angle);
|
||||
}
|
||||
|
||||
state.value = value;
|
||||
state.angle = angle;
|
||||
axisIndex++;
|
||||
}
|
||||
}, 50) as unknown as number;
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
this.scheme?.unassignDevice(this);
|
||||
this.scheme = undefined;
|
||||
clearInterval(this.stateUpdateAgent);
|
||||
}
|
||||
|
||||
private static updateGamepadStates(event: GamepadEvent) {
|
||||
|
||||
console.log({event});
|
||||
|
||||
switch (event.type) {
|
||||
case 'gamepadconnected':
|
||||
const newGamepad = new InputGamepad(event.gamepad);
|
||||
this.DeviceConnected.emit(newGamepad);
|
||||
this.gamepads.push(newGamepad);
|
||||
newGamepad.connect();
|
||||
this.DeviceListChanged.emit(this.gamepads);
|
||||
break;
|
||||
|
||||
case 'gamepaddisconnected':
|
||||
const gamepad = this.gamepads.find(g => g.name === event.gamepad.id);
|
||||
if (gamepad) {
|
||||
gamepad.disconnect();
|
||||
this.DeviceDisconnected.emit(gamepad);
|
||||
this.gamepads.splice(this.gamepads.indexOf(gamepad), 1);
|
||||
this.DeviceListChanged.emit(this.gamepads);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static Init() {
|
||||
this.attachEvents();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import { InputDevice } from "../input-device";
|
||||
|
||||
export class InputKeyboard extends InputDevice {
|
||||
|
||||
static listenerInited = false;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
) {
|
||||
super(name, 'keyboard');
|
||||
this.attachEvents();
|
||||
}
|
||||
|
||||
private attachEvents() {
|
||||
if (InputKeyboard.listenerInited) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', e => this.catchKey(e, 1));
|
||||
window.addEventListener('keyup', e => this.catchKey(e, 0));
|
||||
InputKeyboard.listenerInited = true;
|
||||
}
|
||||
|
||||
private catchKey(event: KeyboardEvent, value: number = 1, angle: number = 0): boolean {
|
||||
const key = event.key;
|
||||
super.setKey(key, value, angle);
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import { InputScheme } from "./input-scheme";
|
||||
|
||||
export type ActionCallback = (value: number, data: unknown, angle?:number, timePressed?: number) => void;
|
||||
|
||||
export class InputAction {
|
||||
|
||||
private currentValue: number = 0;
|
||||
private downActions: ActionCallback[] = [];
|
||||
private upActions: ActionCallback[] = [];
|
||||
private changeActions: ActionCallback[] = [];
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public mapKeysToScheme(scheme: InputScheme, keys: (string | number)[]): this {
|
||||
scheme.mapKey(this, keys);
|
||||
return this;
|
||||
}
|
||||
|
||||
public fire(value: number, data: unknown = undefined, angle?: number, timePressed?: number) {
|
||||
const isPressed = value > 0;
|
||||
const wasPressed = this.currentValue > 0;
|
||||
if (isPressed !== wasPressed) {
|
||||
if (isPressed) {
|
||||
this.downActions.forEach(action => action(value, data, angle, timePressed));
|
||||
} else {
|
||||
this.upActions.forEach(action => action(value, data, angle, timePressed));
|
||||
}
|
||||
} else {
|
||||
this.changeActions.forEach(action => action(value, data, angle, timePressed));
|
||||
}
|
||||
|
||||
this.currentValue = value;
|
||||
}
|
||||
|
||||
public onDown(action: ActionCallback) {
|
||||
this.downActions.push(action);
|
||||
}
|
||||
|
||||
public onUp(action: ActionCallback) {
|
||||
this.upActions.push(action);
|
||||
}
|
||||
|
||||
public onChange(action: ActionCallback) {
|
||||
this.changeActions.push(action);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import { EventEmitter } from "@angular/core";
|
||||
import { InputScheme } from "./input-scheme";
|
||||
import { InputAction } from "./input-action";
|
||||
|
||||
export interface InputKeyState {
|
||||
key: string | number;
|
||||
value: number;
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
export abstract class InputDevice {
|
||||
|
||||
protected scheme?: InputScheme;
|
||||
protected keyStates: InputKeyState[] = [];
|
||||
public keyStateChanged = new EventEmitter<InputKeyState>();
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly type = 'unknown',
|
||||
) {
|
||||
}
|
||||
|
||||
public mapKey(action: InputAction, keys: (string | number)[]): this {
|
||||
this.scheme?.mapKey(action, keys);
|
||||
return this;
|
||||
}
|
||||
|
||||
public getScheme(): InputScheme | undefined {
|
||||
return this.scheme;
|
||||
}
|
||||
|
||||
public hasScheme(): boolean {
|
||||
return !!this.scheme;
|
||||
}
|
||||
|
||||
public setKey(key: string | number, value: number = 1, angle: number = 0): this {
|
||||
let exists = this.keyStates.find(k => k.key === key);
|
||||
if (!exists) {
|
||||
exists = {
|
||||
key,
|
||||
value,
|
||||
angle,
|
||||
};
|
||||
this.keyStates.push(exists);
|
||||
} else {
|
||||
exists.value = value;
|
||||
exists.angle = angle;
|
||||
}
|
||||
|
||||
this.keyStateChanged.emit(exists);
|
||||
this.scheme?.setKeyState(key, value, angle);
|
||||
return this;
|
||||
}
|
||||
|
||||
public assignScheme(scheme: InputScheme): this {
|
||||
this.scheme = scheme;
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import { BehaviorSubject } from "rxjs";
|
||||
import { InputAction } from "./input-action";
|
||||
import { InputDevice } from "./input-device";
|
||||
|
||||
interface InputActionBind {
|
||||
action: InputAction;
|
||||
keys: (string | number)[];
|
||||
}
|
||||
|
||||
export class InputScheme {
|
||||
|
||||
private data: unknown;
|
||||
private actions: InputActionBind[] = [];
|
||||
private devices: InputDevice[] = [];
|
||||
public deviceListChanged = new BehaviorSubject<InputDevice[]>([]);
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
) {
|
||||
this.devices = [];
|
||||
}
|
||||
|
||||
public clearKeyBindings() {
|
||||
this.actions = [];
|
||||
|
||||
}
|
||||
|
||||
public clearDeviceAssignments() {
|
||||
this.devices = [];
|
||||
this.deviceListChanged.next(this.devices);
|
||||
}
|
||||
|
||||
public unassignDevice(device: InputDevice) {
|
||||
this.devices.splice(this.devices.indexOf(device), 1);
|
||||
this.deviceListChanged.next(this.devices);
|
||||
}
|
||||
|
||||
public getDevices() {
|
||||
return this.devices;
|
||||
}
|
||||
|
||||
public getKeyForAction(action: string): (string | number)[] {
|
||||
return this.actions.find(a => a.action.name === action)?.keys || [];
|
||||
}
|
||||
|
||||
public setData(data: unknown) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public mapKey(action: InputAction, keys: (string | number)[]) {
|
||||
this.actions.push({ action, keys });
|
||||
}
|
||||
|
||||
public setKeyState(key: string | number, value: number, angle: number = 0) {
|
||||
const binds = this.actions.filter(a => a.keys.includes(key));
|
||||
if (binds.length) {
|
||||
binds.forEach(bind => bind.action.fire(value, this.data, angle));
|
||||
}
|
||||
}
|
||||
|
||||
public assignDevice(device: InputDevice): void {
|
||||
device.assignScheme(this);
|
||||
this.devices.push(device);
|
||||
this.deviceListChanged.next(this.devices);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import { Injectable } from "@angular/core";
|
||||
import { InputScheme } from "../models/input-scheme";
|
||||
import { InputAction } from "../models/input-action";
|
||||
import { InputDevice } from "../models/input-device";
|
||||
import { InputKeyboard } from "../models/devices/input-keyboard";
|
||||
import { InputGamepad } from "../models/devices/input-gamepad";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class InputService {
|
||||
|
||||
public actions: InputAction[] = [];
|
||||
public schemes: InputScheme[] = [];
|
||||
public devices: InputDevice[] = [];
|
||||
|
||||
public deviceListChanged = new BehaviorSubject<InputDevice[]>([]);
|
||||
|
||||
constructor() {
|
||||
this.defineKeyboard();
|
||||
this.defineGamepad();
|
||||
}
|
||||
|
||||
private defineKeyboard() {
|
||||
const keyboard = new InputKeyboard('Keyboard');
|
||||
this.devices.push(keyboard);
|
||||
this.deviceListChanged.next(this.devices);
|
||||
}
|
||||
|
||||
private defineGamepad() {
|
||||
InputGamepad.DeviceConnected.subscribe(gamepad => {
|
||||
this.devices.push(gamepad);
|
||||
this.deviceListChanged.next(this.devices);
|
||||
});
|
||||
InputGamepad.DeviceDisconnected.subscribe(gamepad => {
|
||||
this.devices = this.devices.filter(d => d.name !== gamepad.name);
|
||||
this.deviceListChanged.next(this.devices);
|
||||
});
|
||||
InputGamepad.Init();
|
||||
}
|
||||
|
||||
public defineAction(name: string): InputAction {
|
||||
const action = new InputAction(name);
|
||||
this.actions.push(action);
|
||||
return action;
|
||||
}
|
||||
|
||||
public defineScheme(name: string): InputScheme {
|
||||
const scheme = new InputScheme(name);
|
||||
this.schemes.push(scheme);
|
||||
return scheme;
|
||||
}
|
||||
|
||||
public getDevices(): InputDevice[] {
|
||||
return this.devices;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Directive, ElementRef, Input } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[inputActionBinding]',
|
||||
standalone: true
|
||||
})
|
||||
export class ActionBindingDirective {
|
||||
|
||||
@Input() inputActionBinding: string = '';
|
||||
|
||||
private element: HTMLElement;
|
||||
|
||||
constructor(
|
||||
elementRef: ElementRef,
|
||||
) {
|
||||
this.element = elementRef.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.element.setAttribute('action', this.inputActionBinding);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { Directive } from '@angular/core';
|
||||
import { KeyItemDirective, KeyItemEntry } from './key-item.directive';
|
||||
|
||||
@Directive({
|
||||
selector: '[inputKeyItemGroup]',
|
||||
standalone: true
|
||||
})
|
||||
export class KeyItemGroupDirective {
|
||||
|
||||
static groups: KeyItemGroupDirective[] = [];
|
||||
public items: KeyItemEntry[] = [];
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
KeyItemDirective.SetCurrentItem();
|
||||
KeyItemGroupDirective.groups.push(this);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
const index = KeyItemGroupDirective.groups.findIndex(g => g === this);
|
||||
if (index > -1) {
|
||||
KeyItemGroupDirective.groups.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
import { Directive, ElementRef, HostListener, Input, OnDestroy, OnInit, Optional, Host } from '@angular/core';
|
||||
import { KeyItemGroupDirective } from './key-item-group.directive';
|
||||
|
||||
export interface ActionBindings {
|
||||
[action: string]: () => undefined | boolean;
|
||||
}
|
||||
|
||||
export interface KeyItemEntry {
|
||||
element: HTMLKeyItemEntryElement;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface HTMLKeyItemEntryElement extends HTMLElement {
|
||||
keyId: number;
|
||||
active: string;
|
||||
actionBindings: ActionBindings;
|
||||
activate: () => void;
|
||||
deactivate: () => void;
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[inputKeyItem]',
|
||||
standalone: true
|
||||
})
|
||||
export class KeyItemDirective implements OnInit, OnDestroy {
|
||||
|
||||
@Input() active = 'active';
|
||||
@Input() activate: () => any = () => {};
|
||||
@Input() deactivate: () => any = () => {};
|
||||
@Input() focus: () => any = () => {};
|
||||
@Input() blur: () => any = () => {};
|
||||
@Input() actionBindings: ActionBindings = {};
|
||||
private static allItems: KeyItemEntry[] = [];
|
||||
private static current?: KeyItemEntry;
|
||||
private element: HTMLKeyItemEntryElement;
|
||||
private static NextId = 0;
|
||||
private id = KeyItemDirective.NextId++;
|
||||
|
||||
constructor(
|
||||
@Optional() private group: KeyItemGroupDirective,
|
||||
elementRef: ElementRef,
|
||||
) {
|
||||
this.element = elementRef.nativeElement as HTMLKeyItemEntryElement;
|
||||
}
|
||||
|
||||
public static FireAction(action: string): boolean {
|
||||
const item = KeyItemDirective.current;
|
||||
if (item) {
|
||||
return item.element.actionBindings?.[action]?.() ?? true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.element.active = this.active;
|
||||
this.element.activate = this.activate;
|
||||
this.element.deactivate = this.deactivate;
|
||||
this.element.keyId = this.id;
|
||||
this.element.actionBindings = this.actionBindings;
|
||||
this.element.focus = this.focus;
|
||||
this.element.blur = this.blur;
|
||||
const position = this.element.getBoundingClientRect();
|
||||
const item = {
|
||||
element: this.element,
|
||||
top: position.top + position.height / 2,
|
||||
left: position.left + position.width / 2,
|
||||
};
|
||||
KeyItemDirective.allItems.push(item);
|
||||
this.group?.items.push(item);
|
||||
if (this.group) {
|
||||
this.group.items = this.group.items.sort((a, b) => {
|
||||
const tabIndexA = a.element.tabIndex;
|
||||
const tabIndexB = b.element.tabIndex;
|
||||
return tabIndexA - tabIndexB;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
const index = KeyItemDirective.allItems.findIndex(item => item.element.keyId === this.id);
|
||||
if (index > -1) {
|
||||
KeyItemDirective.allItems.splice(index, 1);
|
||||
}
|
||||
|
||||
if (this.group) {
|
||||
const index = this.group.items.findIndex(item => item.element.keyId === this.id);
|
||||
if (index > -1) {
|
||||
this.group.items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('mouseenter')
|
||||
onHover() {
|
||||
if (this.active && !this.element.classList.contains(this.active)) {
|
||||
this.element.classList.add(this.active);
|
||||
}
|
||||
|
||||
this.focus();
|
||||
|
||||
const item = KeyItemDirective.allItems.find(item => item.element.keyId === this.id);
|
||||
KeyItemDirective.SetCurrentItem(item);
|
||||
}
|
||||
|
||||
@HostListener('mouseleave')
|
||||
onLeave() {
|
||||
if (this.active && this.element.classList.contains(this.active)) {
|
||||
this.element.classList.remove(this.active);
|
||||
}
|
||||
|
||||
this.blur();
|
||||
}
|
||||
|
||||
static ActivateCurrentItem() {
|
||||
this.current?.element.activate.bind(this.current?.element)();
|
||||
}
|
||||
|
||||
static getCurrentGroup() {
|
||||
if (KeyItemGroupDirective.groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return KeyItemGroupDirective.groups[KeyItemGroupDirective.groups.length - 1];
|
||||
}
|
||||
|
||||
static SelectNexItem(fromAngleDeg: number, toAngleDeg: number) {
|
||||
this.updateItems();
|
||||
let current = this.current;
|
||||
let allItems = this.allItems;
|
||||
const currentGroup = this.getCurrentGroup();
|
||||
if (currentGroup) {
|
||||
const isCurrentInCurrentGroup = this.current && currentGroup.items.some(i => i.element.keyId === this.current!.element.keyId);
|
||||
allItems = currentGroup.items;
|
||||
if (!isCurrentInCurrentGroup) {
|
||||
current = undefined;
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
const list = allItems.map(item => {
|
||||
const distance = Math.sqrt(Math.pow(item.top - this.current!.top, 2) + Math.pow(item.left - this.current!.left, 2));
|
||||
const angle = 90 + Math.atan2(item.top - this.current!.top, item.left - this.current!.left) * 180 / Math.PI;
|
||||
return {
|
||||
item,
|
||||
distance,
|
||||
angle,
|
||||
};
|
||||
})
|
||||
.filter(item => {
|
||||
const angle = item.angle;
|
||||
const normalizedAngle = angle < 0 ? angle + 360 : angle;
|
||||
return normalizedAngle >= fromAngleDeg && normalizedAngle <= toAngleDeg && item.distance > 0;
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
if (list.length > 0) {
|
||||
this.SetCurrentItem(list[0].item);
|
||||
} else {
|
||||
let index = allItems.findIndex(i => i.element.keyId === this.current!.element.keyId);
|
||||
index = (index + 1) % allItems.length;
|
||||
this.SetCurrentItem(allItems[index]);
|
||||
}
|
||||
} else {
|
||||
this.SetCurrentItem(allItems[0]);
|
||||
}
|
||||
}
|
||||
|
||||
static SetCurrentItem(item?: KeyItemEntry) {
|
||||
if (this.current?.element.active && this.current.element.classList.contains(this.current.element.active)) {
|
||||
this.current.element.classList.remove(this.current.element.active);
|
||||
}
|
||||
this.current?.element.blur();
|
||||
this.current = item;
|
||||
this.current?.element.focus();
|
||||
if (this.current?.element.active && !this.current.element.classList.contains(this.current.element.active)) {
|
||||
this.current.element.classList.add(this.current.element.active);
|
||||
}
|
||||
}
|
||||
|
||||
static updateItems() {
|
||||
this.allItems.forEach(item => {
|
||||
const position = item.element.getBoundingClientRect();
|
||||
item.top = position.top + position.height / 2;
|
||||
item.left = position.left + position.width / 2;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActionBindingDirective } from './directives/action-binding.directive';
|
||||
import { KeyItemDirective } from './directives/key-item.directive';
|
||||
import { KeyItemGroupDirective } from './directives/key-item-group.directive';
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ActionBindingDirective,
|
||||
KeyItemDirective,
|
||||
KeyItemGroupDirective,
|
||||
],
|
||||
exports: [
|
||||
ActionBindingDirective,
|
||||
KeyItemDirective,
|
||||
KeyItemGroupDirective,
|
||||
],
|
||||
})
|
||||
export class NavigationModule { }
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Public API Surface of input
|
||||
*/
|
||||
|
||||
export * from './lib/modules/input/input-module';
|
||||
|
||||
export * from './lib/modules/input/services/input.service';
|
||||
export * from './lib/modules/input/models/input-action';
|
||||
export * from './lib/modules/input/models/input-device';
|
||||
export * from './lib/modules/input/models/input-scheme';
|
||||
export * from './lib/modules/input/models/devices/input-gamepad';
|
||||
export * from './lib/modules/input/models/devices/input-keyboard';
|
||||
|
||||
|
||||
|
||||
export * from './lib/modules/navigation/navigation-module';
|
||||
|
||||
export * from './lib/modules/navigation/directives/action-binding.directive';
|
||||
export * from './lib/modules/navigation/directives/key-item-group.directive';
|
||||
export * from './lib/modules/navigation/directives/key-item.directive';
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"sourceMap": true,
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue