initial commit

This commit is contained in:
Michał Sieciechowicz 2025-08-01 19:34:19 +02:00
commit 2a8d0269b8
21 changed files with 995 additions and 0 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

135
README.md Normal file
View File

@ -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.

65
angular.json Normal file
View File

@ -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"
}
}
}
}
}
}

7
ng-package.json Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/input",
"lib": {
"entryFile": "src/public-api.ts"
}
}

41
package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [],
imports: [
CommonModule,
],
exports: [
]
})
export class InputModule { }

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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;
});
}
}

View File

@ -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 { }

21
src/public-api.ts Normal file
View File

@ -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';

19
tsconfig.lib.json Normal file
View File

@ -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"
]
}

11
tsconfig.lib.prod.json Normal file
View File

@ -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"
}
}

14
tsconfig.spec.json Normal file
View File

@ -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"
]
}