├── .github ├── FUNDING.yml └── workflows │ └── deploy.yml ├── screenshot.gif ├── src ├── types.ts ├── index.ts ├── utils.ts ├── generator │ ├── midi.ts │ ├── keyboard.ts │ ├── gamepad.ts │ └── pointer.ts ├── memoize.ts ├── global.ts ├── combinator.ts └── Emitter.ts ├── docs ├── sandbox.md ├── components │ ├── examples │ │ ├── Pointer.js │ │ ├── Trail.js │ │ ├── Etch a Sketch.js │ │ ├── Interval.js │ │ ├── WASD.js │ │ ├── MIDI Controller.js │ │ ├── Position Interpolation.js │ │ ├── Gamepad.js │ │ ├── Keyboard.js │ │ ├── index.ts │ │ └── ZUI.js │ ├── Editor.vue │ └── Sandbox.vue ├── tsconfig.json ├── .vuepress │ ├── client.ts │ ├── public │ │ ├── logo.svg │ │ └── github-mark.svg │ ├── config.ts │ └── styles │ │ └── index.scss ├── modules.d.ts ├── index.md ├── ja │ └── guide.md └── guide.md ├── .prettierrc ├── .gitignore ├── typedoc.json ├── tsconfig.json ├── LICENSE ├── eslint.config.js ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: baku89 2 | -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baku89/bndr-js/HEAD/screenshot.gif -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Icon = {type: 'iconify'; icon: string} | string 2 | 3 | export type IconSequence = Icon[] 4 | -------------------------------------------------------------------------------- /docs/sandbox.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Sandbox 4 | sidebar: false 5 | pageClass: sandbox 6 | --- 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/components/examples/Pointer.js: -------------------------------------------------------------------------------- 1 | Bndr.pointer() 2 | .position() 3 | .lerp(vec2.lerp, 0.2) 4 | .on(([x, y]) => p.circle(x, y, 50)) 5 | 6 | Bndr.pointer() 7 | .down() 8 | .on(() => p.clear()) 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "bracketSpacing": false, 5 | "semi": false, 6 | "printWidth": 80, 7 | "trailingComma": "es5", 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "bndr-js": ["./src/index.ts"] 6 | } 7 | }, 8 | "include": ["./**/*.vue", "./**/*.ts"], 9 | "exclude": ["./**/*.js"] 10 | } 11 | -------------------------------------------------------------------------------- /docs/components/examples/Trail.js: -------------------------------------------------------------------------------- 1 | Bndr.pointer() 2 | .position() 3 | .trail(100, false) 4 | .on(pts => { 5 | p.clear() 6 | p.beginShape() 7 | for (const [x, y] of pts) { 8 | p.vertex(x, y) 9 | } 10 | p.endShape() 11 | }) 12 | -------------------------------------------------------------------------------- /docs/.vuepress/client.ts: -------------------------------------------------------------------------------- 1 | import {defineClientConfig} from '@vuepress/client' 2 | import Mermaid from 'vue-mermaid-string' 3 | 4 | import Sandbox from '../components/Sandbox.vue' 5 | 6 | export default defineClientConfig({ 7 | enhance({app}) { 8 | app.component('Sandbox', Sandbox) 9 | app.component('Mermaid', Mermaid) 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /docs/components/examples/Etch a Sketch.js: -------------------------------------------------------------------------------- 1 | Bndr.tuple( 2 | Bndr.midi() 3 | .note(0, 40) 4 | .map(v => (v / 127) * p.width), 5 | Bndr.midi() 6 | .note(0, 41) 7 | .map(v => (v / 127) * p.height) 8 | ) 9 | .trail(2) 10 | .on(([[px, py], [x, y]]) => { 11 | p.strokeWeight(20) 12 | p.line(px, py, x, y) 13 | }) 14 | 15 | Bndr.midi().note(0, 30).on(p.clear) 16 | -------------------------------------------------------------------------------- /docs/components/examples/Interval.js: -------------------------------------------------------------------------------- 1 | Bndr.combine( 2 | Bndr.keyboard() 3 | .pressed('s') 4 | .map(p => (p ? 1 : 0)), 5 | Bndr.keyboard() 6 | .pressed('a') 7 | .map(p => (p ? -1 : 0)) 8 | ) 9 | .interval() 10 | .map(v => v * 5) 11 | .filter(v => v !== 0) 12 | .fold(scalar.add, p.width / 2) 13 | .on(r => { 14 | p.clear() 15 | p.circle(p.width / 2, p.height / 2, r) 16 | }) 17 | -------------------------------------------------------------------------------- /docs/components/examples/WASD.js: -------------------------------------------------------------------------------- 1 | Bndr.combine( 2 | Bndr.keyboard().pressed('w').down().constant([0, -1]), 3 | Bndr.keyboard().pressed('a').down().constant([-1, 0]), 4 | Bndr.keyboard().pressed('s').down().constant([0, +1]), 5 | Bndr.keyboard().pressed('d').down().constant([+1, 0]) 6 | ) 7 | .map(v => vec2.scale(v, 40)) 8 | .fold(vec2.add, [p.width / 2, p.height / 2]) 9 | .on(([x, y]) => p.circle(x, y, 40)) 10 | -------------------------------------------------------------------------------- /docs/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*?raw' { 2 | const content: string 3 | export default content 4 | } 5 | 6 | declare module 'safer-eval' { 7 | function saferEval(code: string, context?: object): void 8 | export default saferEval 9 | } 10 | 11 | declare module '*.vue' { 12 | import {DefineComponent} from 'vue' 13 | const component: DefineComponent 14 | export default component 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {createScope, disposeAllEmitters, GeneratorPath} from './global.js' 2 | 3 | export * from './combinator.js' 4 | export * from './Emitter.js' 5 | export * from './generator/gamepad.js' 6 | export * from './generator/keyboard.js' 7 | export * from './generator/midi.js' 8 | export * from './generator/pointer.js' 9 | export type * from './types.js' 10 | export {createScope, disposeAllEmitters, type GeneratorPath} 11 | -------------------------------------------------------------------------------- /docs/components/examples/MIDI Controller.js: -------------------------------------------------------------------------------- 1 | Bndr.tuple( 2 | Bndr.midi() 3 | .note(0, 50) 4 | .map(v => (v / 127) * p.width), 5 | Bndr.midi() 6 | .note(0, 51) 7 | .map(v => (v / 127) * p.height) 8 | ).on(([x, y]) => p.circle(x, y, 40)) 9 | 10 | Bndr.midi() 11 | .note(0, 68) 12 | .map(v => { 13 | if (v) { 14 | p.noStroke() 15 | p.fill('black') 16 | } else { 17 | p.stroke('black') 18 | p.fill('white') 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /docs/components/examples/Position Interpolation.js: -------------------------------------------------------------------------------- 1 | const marker = ([x, y], r) => p.circle(x, y, r) 2 | 3 | const pos = Bndr.pointer().position() 4 | 5 | Bndr.tuple( 6 | pos, 7 | pos.lerp(vec2.lerp, 0.1), 8 | pos 9 | .interval() 10 | .trail(10) 11 | .map(pts => vec2.scale(vec2.add(...pts), 1 / pts.length)) 12 | ).on(([pos, lerp, average]) => { 13 | p.clear() 14 | marker(pos, 70) 15 | marker(lerp, 50) 16 | marker(average, 10) 17 | }) 18 | -------------------------------------------------------------------------------- /docs/components/examples/Gamepad.js: -------------------------------------------------------------------------------- 1 | const pos = Bndr.gamepad() 2 | .axis(0) 3 | .map(v => vec2.scale(v, 10)) 4 | .fold(vec2.add, [p.width / 2, p.height / 2]) 5 | 6 | const radius = Bndr.combine( 7 | Bndr.gamepad().button('a').down().constant(2), 8 | Bndr.gamepad().button('b').down().constant(0.5) 9 | ) 10 | .fold((v, s) => v * s, 100) 11 | .lerp(scalar.lerp, 0.3) 12 | 13 | Bndr.tuple(pos, radius).on(([[x, y], r]) => { 14 | p.circle(x, y, r) 15 | }) 16 | -------------------------------------------------------------------------------- /docs/components/examples/Keyboard.js: -------------------------------------------------------------------------------- 1 | Bndr.tuple( 2 | Bndr.keyboard() 3 | .pressed('space') 4 | .map(v => (v ? p.width : p.width / 4)) 5 | .lerp(scalar.lerp, 0.1), 6 | Bndr.combine( 7 | Bndr.keyboard().keydown('a').constant('GhostWhite'), 8 | Bndr.keyboard().keydown('s').constant('LightGray'), 9 | Bndr.keyboard().keydown('d').constant('DimGray') 10 | ) 11 | ).on(([radius, color]) => { 12 | p.clear() 13 | p.fill(color) 14 | p.circle(p.width / 2, p.height / 2, radius) 15 | }) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | /lib 5 | 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | pnpm-debug.log* 18 | lerna-debug.log* 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | .DS_Store 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | 31 | # For Vuepress 32 | .cache 33 | .temp 34 | docs/api 35 | dist -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src/index.ts"], 4 | "readme": "none", 5 | "out": "./docs/api", 6 | "plugin": ["typedoc-plugin-markdown"], 7 | "excludePrivate": true, 8 | "cleanOutputDir": true, 9 | "includeVersion": true, 10 | "hideInPageTOC": true, 11 | "groupOrder": [ 12 | "Constructors", 13 | "Generators", 14 | "Combinators", 15 | "Filters", 16 | "Common Filters", 17 | "Methods", 18 | "Properties", 19 | "Global Functions", 20 | "Emitters" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "module": "NodeNext", 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "inlineSourceMap": true, 11 | "declaration": true, 12 | "declarationMap": true, 13 | "skipDefaultLibCheck": true, 14 | "allowJs": true, 15 | 16 | /* Bundler mode */ 17 | "baseUrl": ".", 18 | "moduleResolution": "NodeNext", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "outDir": "./lib", 22 | 23 | /* Linting */ 24 | 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noFallthroughCasesInSwitch": true 28 | }, 29 | "include": ["src"], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /docs/components/examples/index.ts: -------------------------------------------------------------------------------- 1 | import Etch_a_Sketch from './Etch a Sketch.js?raw' 2 | import Gamepad from './Gamepad.js?raw' 3 | import Interval from './Interval.js?raw' 4 | import Keyboard from './Keyboard.js?raw' 5 | import MIDI_Controller from './MIDI Controller.js?raw' 6 | import Pointer from './Pointer.js?raw' 7 | import Position_Interpolation from './Position Interpolation.js?raw' 8 | import Trail from './Trail.js?raw' 9 | import WASD from './WASD.js?raw' 10 | import ZUI from './ZUI.js?raw' 11 | 12 | export default new Map([ 13 | ['Pointer', Pointer], 14 | ['Keyboard', Keyboard], 15 | ['MIDI Controller', MIDI_Controller], 16 | ['Gamepad', Gamepad], 17 | ['Interval', Interval], 18 | ['Trail', Trail], 19 | ['Etch a Sketch', Etch_a_Sketch], 20 | ['WASD', WASD], 21 | ['Position Interpolation', Position_Interpolation], 22 | ['ZUI (Zoom User Interface)', ZUI], 23 | ]) 24 | -------------------------------------------------------------------------------- /docs/.vuepress/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | undefined 2 | 3 | export function bindMaybe( 4 | value: Maybe, 5 | fn: (value: T) => U 6 | ): Maybe { 7 | if (value === undefined) return undefined 8 | return fn(value) 9 | } 10 | 11 | /** 12 | * Returns the first value that is not undefined among the arguments. 13 | */ 14 | export function chainMaybeValue(...values: Maybe[]): Maybe { 15 | return values.find(v => v !== undefined) 16 | } 17 | 18 | /** 19 | * Cancels the event's default behavior and propagation based on the options. 20 | * @param e The target event 21 | * @param options The options to control the behavior 22 | */ 23 | export function cancelEventBehavior( 24 | e: Pick, 25 | options?: {preventDefault?: boolean; stopPropagation?: boolean} 26 | ) { 27 | if (options?.preventDefault) e.preventDefault() 28 | if (options?.stopPropagation) e.stopPropagation() 29 | } 30 | -------------------------------------------------------------------------------- /docs/.vuepress/public/github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Baku Hashimoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy static content to Github Pages 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | 7 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: 'deploy' 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | - name: Set up Node 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: 20 30 | - name: Run install 31 | uses: borales/actions-yarn@v4 32 | with: 33 | cmd: install 34 | - name: Build 35 | uses: borales/actions-yarn@v4 36 | with: 37 | cmd: build:doc 38 | - name: Setup Pages 39 | uses: actions/configure-pages@v5 40 | - name: Upload Artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: docs/.vuepress/dist/ 44 | include-hidden-files: true 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v4 48 | -------------------------------------------------------------------------------- /src/generator/midi.ts: -------------------------------------------------------------------------------- 1 | import {Emitter} from '../Emitter.js' 2 | import {Memoized, memoizeFunction} from '../memoize.js' 3 | 4 | export type MIDIData = [number, number, number] 5 | 6 | /** 7 | * @group Emitters 8 | */ 9 | export class MidiEmitter extends Emitter { 10 | constructor() { 11 | super() 12 | this.#init() 13 | } 14 | 15 | async #init() { 16 | if (!navigator.requestMIDIAccess) { 17 | // eslint-disable-next-line no-console 18 | console.error('Cannot access MIDI devices on this browser') 19 | return 20 | } 21 | 22 | const midi = await navigator.requestMIDIAccess() 23 | 24 | if (!midi) { 25 | // eslint-disable-next-line no-console 26 | console.error('Cannot access MIDI devices on this browser') 27 | return 28 | } 29 | 30 | midi.inputs.forEach(input => { 31 | input.addEventListener('midimessage', evt => { 32 | const value = [...evt.data] as MIDIData 33 | this.emit(value) 34 | }) 35 | }) 36 | } 37 | 38 | /** 39 | * @group Filters 40 | */ 41 | @Memoized() 42 | note(channel: number, note: number): Emitter { 43 | return this.filterMap(([status, _note, velocity]: MIDIData) => { 44 | if (status === 176 + channel && _note === note) { 45 | return velocity 46 | } else { 47 | return undefined 48 | } 49 | }, 0) 50 | } 51 | } 52 | 53 | /** 54 | * @group Generators 55 | */ 56 | export const midi = memoizeFunction(() => new MidiEmitter()) 57 | -------------------------------------------------------------------------------- /docs/components/examples/ZUI.js: -------------------------------------------------------------------------------- 1 | // Adobe Illustrator-like viewport navigation 2 | // 3 | // Pan: Space + Drag / Scroll 4 | // Zoom: Z + Horizontal Drag / Alt + Scroll / Pinch 5 | 6 | const position = Bndr.pointer().position() 7 | const leftPressed = Bndr.pointer().left.pressed() 8 | 9 | let xform = mat2d.identity 10 | 11 | function draw() { 12 | p.resetMatrix() 13 | p.applyMatrix(...xform) 14 | p.clear() 15 | p.rect(0, 0, 100, 100) 16 | } 17 | 18 | draw() 19 | 20 | // Pan 21 | position 22 | .while( 23 | Bndr.or( 24 | Bndr.cascade(Bndr.keyboard().pressed('space'), leftPressed), 25 | Bndr.pointer().middle.pressed() 26 | ) 27 | ) 28 | .delta((prev, curt) => vec2.sub(curt, prev)) 29 | .on(delta => { 30 | xform = mat2d.multiply(mat2d.fromTranslation(delta), xform) 31 | draw() 32 | }) 33 | 34 | const zoomByScroll = Bndr.pointer() 35 | .scroll() 36 | .map(([, y]) => y) 37 | 38 | const zoomByDrag = position 39 | .while(Bndr.cascade(Bndr.keyboard().pressed('z'), leftPressed)) 40 | .delta((prev, curt) => vec2.sub(curt, prev)) 41 | .map(([x]) => -x) 42 | 43 | const zoomByPinch = Bndr.pointer() 44 | .pinch() 45 | .map(x => x * 2) 46 | 47 | const zoomCenter = position.stash( 48 | leftPressed.down(), 49 | Bndr.pointer().scroll({preventDefault: true}), 50 | zoomByPinch.constant(true) 51 | ) 52 | 53 | // Zoom 54 | Bndr.combine(zoomByScroll, zoomByDrag, zoomByPinch).on(delta => { 55 | const scale = mat2d.pivot( 56 | mat2d.fromScaling(vec2.of(1.003 ** -delta)), 57 | zoomCenter.value 58 | ) 59 | xform = mat2d.multiply(scale, xform) 60 | draw() 61 | }) 62 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | --- 4 | 5 |
6 |

7 | 8 | npm version 9 | 10 |   11 | 12 | npm license 13 | 14 |

15 |
16 | 17 | **Bndr** /ˈbaɪndɚ/ is a library designed to compose events from various user inputs and chain filters in a monadic manner, integrating them into a single event object. It accommodates input devices such as mice🖱️, styluses🖊️, touch inputs👆, keyboards⌨️, [MIDI](https://developer.mozilla.org/en-US/docs/Web/API/Web_MIDI_API) controllers🎹, and [gamepads](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API)🎮. Developed and maintained by [Baku Hashimoto](https://baku89.com). 18 | 19 | Potential use cases for this library include: 20 | 21 | - ⚡️ Associating user inputs with arbitrary triggers for VJing 22 | - 🎨 Introducing manual operations in generative art. 23 | 24 | To get a feel for how it works, please try out [this demo](https://baku89.github.io/bndr-js/). 25 | 26 | ## Supported Parameters 27 | 28 | - 👆 Pointer (mouse, stylus, touch) 29 | - All parameters supported in [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events). (pressure, tilt, multi-touch) 30 | - ⌨️ Keyboard 31 | - 🎹 MIDI 32 | - CC and velocity 33 | - 🎮 Gamepad 34 | - Vendor-specific button name support: JoyCon, PS5 Controller 35 | 36 | ## How to use 37 | 38 | - [Full API documentation](https://baku89.github.io/bndr-js/docs/) 39 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tsParser from '@typescript-eslint/parser' 3 | import prettierConfig from '@vue/eslint-config-prettier' 4 | import simpleImportSort from 'eslint-plugin-simple-import-sort' 5 | import unusedImports from 'eslint-plugin-unused-imports' 6 | import pluginVue from 'eslint-plugin-vue' 7 | import globals from 'globals' 8 | import eseslint from 'typescript-eslint' 9 | import vueParser from 'vue-eslint-parser' 10 | 11 | export default eseslint.config( 12 | eslint.configs.recommended, 13 | eseslint.configs.recommended, 14 | pluginVue.configs['flat/recommended'], 15 | { 16 | languageOptions: { 17 | parser: vueParser, 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | parserOptions: { 21 | parser: tsParser, 22 | }, 23 | globals: globals.browser, 24 | }, 25 | plugins: { 26 | 'simple-import-sort': simpleImportSort, 27 | 'unused-imports': unusedImports, 28 | }, 29 | rules: { 30 | 'arrow-body-style': 'off', 31 | 'prefer-arrow-callback': 'off', 32 | 'no-console': 'warn', 33 | 'no-debugger': 'warn', 34 | 'no-undef': 'off', 35 | eqeqeq: 'error', 36 | 'prefer-const': 'error', 37 | '@typescript-eslint/no-explicit-any': 'off', 38 | '@typescript-eslint/no-use-before-define': 'off', 39 | '@typescript-eslint/explicit-module-boundary-types': 'off', 40 | 'simple-import-sort/imports': 'error', 41 | 'unused-imports/no-unused-imports': 'error', 42 | 'vue/require-default-prop': 'off', 43 | 'vue/no-multiple-template-root': 'off', 44 | 'vue/multi-word-component-names': 'off', 45 | 'vue/no-v-model-argument': 'off', 46 | 'vue/attribute-hyphenation': 'off', 47 | 'vue/v-on-event-hyphenation': 'off', 48 | }, 49 | }, 50 | prettierConfig, 51 | { 52 | ignores: ['docs/.vuepress/.temp/**', 'docs/.vuepress/.cache/**'], 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /src/memoize.ts: -------------------------------------------------------------------------------- 1 | import {isObject, uniqueId} from 'lodash-es' 2 | 3 | import {Emitter} from './Emitter.js' 4 | 5 | const idForObject = new WeakMap() 6 | 7 | function replacer(_: string, value: any) { 8 | if ( 9 | value instanceof Element || 10 | value instanceof Window || 11 | value instanceof Emitter 12 | ) { 13 | if (!idForObject.has(value)) { 14 | idForObject.set(value, '__object_' + uniqueId()) 15 | } 16 | 17 | return idForObject.get(value) 18 | } 19 | 20 | return value 21 | } 22 | 23 | /** 24 | * Memoize an instance method. 25 | */ 26 | export function Memoized() { 27 | return ( 28 | _target: Emitter, 29 | _propertyKey: string, 30 | descriptor: TypedPropertyDescriptor 31 | ) => { 32 | if (descriptor.value) { 33 | descriptor.value = memoizeFunction(descriptor.value) 34 | } else if (descriptor.get) { 35 | descriptor.get = memoizeFunction(descriptor.get) 36 | } else { 37 | throw new Error('Memoize can only be applied to methods') 38 | } 39 | } 40 | } 41 | 42 | export function memoizeFunction( 43 | fn: (this: any, ...args: Args) => ReturnType 44 | ) { 45 | const memoizeMaps = new WeakMap>() 46 | 47 | const noThisObject = {} 48 | 49 | return function (this: any, ...args: Args): ReturnType { 50 | const thisObject = isObject(this) ? this : noThisObject 51 | 52 | let map = memoizeMaps.get(thisObject) 53 | 54 | if (!map) { 55 | map = new Map() 56 | memoizeMaps.set(thisObject, map) 57 | } 58 | 59 | const hash = JSON.stringify(args, replacer) 60 | 61 | const memoized = map.get(hash) 62 | 63 | if ( 64 | memoized === undefined || 65 | (memoized instanceof Emitter && memoized.disposed) 66 | ) { 67 | const ret = fn.apply(this, args) 68 | map.set(hash, ret) 69 | return ret 70 | } else { 71 | return memoized 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | import {type Emitter} from './Emitter.js' 2 | 3 | /** 4 | * Adds an Emitter instance to the global list, for disposing them later 5 | * @private 6 | */ 7 | export function addEmitterInstance(emitter: Emitter) { 8 | EmitterInstances.add(emitter) 9 | 10 | onEmitterCreatedCallbacks.forEach(cb => cb(emitter)) 11 | } 12 | 13 | /** 14 | * Stores all Emitter instances for resetting the listeners at once 15 | */ 16 | const EmitterInstances = new Set() 17 | 18 | const onEmitterCreatedCallbacks = new Set<(emitter: Emitter) => void>() 19 | 20 | /** 21 | * Disposes all Emitter instances 22 | * @group Global Functions 23 | */ 24 | export function disposeAllEmitters() { 25 | EmitterInstances.forEach(emitter => { 26 | emitter.dispose() 27 | }) 28 | } 29 | 30 | /** 31 | * Creates a scope for Emitter instances so that they can be disposed by calling the return value. 32 | * @example 33 | * ```ts 34 | * const dispose = createScope(() => { 35 | * Bndr.keyboard() 36 | * .pressed('a') 37 | * .on(console.log) 38 | * }) 39 | * dispose() 40 | * ``` 41 | * 42 | * @param fn The function to run in the scope 43 | * @returns A function that disposes all Emitter instances created in the scope 44 | */ 45 | export function createScope(fn: () => void) { 46 | const instances = new Set() 47 | 48 | function onCreated(emitter: Emitter) { 49 | instances.add(emitter) 50 | } 51 | 52 | try { 53 | onEmitterCreatedCallbacks.add(onCreated) 54 | fn() 55 | } finally { 56 | onEmitterCreatedCallbacks.delete(onCreated) 57 | } 58 | 59 | return () => { 60 | instances.forEach(instance => { 61 | instance.dispose() 62 | }) 63 | } 64 | } 65 | 66 | /** 67 | * The string representation of a {@link Emitter}. That can be used to create a new Emitter instance. 68 | * @example 69 | * "keyboard/command+s" 70 | * "keyboard/shift+enter" 71 | * "gamepad/b" 72 | * "gamepad/square" 73 | */ 74 | export type GeneratorPath = string 75 | -------------------------------------------------------------------------------- /docs/components/Editor.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 79 | 80 | 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bndr-js", 3 | "version": "0.18.6", 4 | "description": "A monadic library for composing and filtering various types of user inputs to generate event handling", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "module": "lib/index.js", 8 | "types": "lib/index.d.ts", 9 | "repository": "https://github.com/baku89/bndr-js", 10 | "author": "Baku Hashimoto ", 11 | "sideEffects": false, 12 | "scripts": { 13 | "dev": "concurrently npm:dev:api npm:dev:doc", 14 | "dev:api": "npm run build:api -- --watch", 15 | "dev:doc": "vuepress dev docs", 16 | "build": "tsc", 17 | "build:api": "typedoc src/index.ts", 18 | "build:doc": "npm run build:api; vuepress build docs", 19 | "lint": "eslint", 20 | "test": "echo \"No test specified\" && exit 0", 21 | "prepare": "npm run build", 22 | "preversion": "npm run test", 23 | "postversion": "git push && git push --tags && npm publish" 24 | }, 25 | "homepage": "https://baku89.github.io/bndr-js", 26 | "files": [ 27 | "lib" 28 | ], 29 | "license": "MIT", 30 | "devDependencies": { 31 | "@eslint/js": "^9.22.0", 32 | "@types/jest": "^29.5.1", 33 | "@types/lodash-es": "^4.17.12", 34 | "@types/p5": "^1.6.2", 35 | "@types/webmidi": "^2.0.7", 36 | "@typescript-eslint/parser": "^8.26.1", 37 | "@vue/eslint-config-prettier": "^10.2.0", 38 | "@vuepress/bundler-vite": "2.0.0-rc.2", 39 | "@vuepress/plugin-palette": "^2.0.0-rc.14", 40 | "@vuepress/theme-default": "2.0.0-rc.1", 41 | "@vueuse/core": "^10.11.0", 42 | "concurrently": "^8.2.2", 43 | "eslint": "^9.22.0", 44 | "eslint-plugin-simple-import-sort": "^12.1.1", 45 | "eslint-plugin-unused-imports": "^4.1.4", 46 | "eslint-plugin-vue": "^10.0.0", 47 | "monaco-editor": "^0.44.0", 48 | "monaco-editor-vue3": "^0.1.10", 49 | "monaco-themes": "^0.4.4", 50 | "p5": "^1.6.0", 51 | "prettier": "^3.5.3", 52 | "safer-eval": "^1.3.6", 53 | "stylus": "^0.62.0", 54 | "typedoc": "^0.24.7", 55 | "typedoc-plugin-markdown": "^3.17.1", 56 | "typescript": "^5.0.4", 57 | "typescript-eslint": "^8.26.1", 58 | "vite-plugin-eslint": "^1.8.1", 59 | "vite-plugin-monaco-editor": "^1.1.0", 60 | "vue": "^3.4.19", 61 | "vue-eslint-parser": "^10.1.1", 62 | "vue-mermaid-string": "^5.0.0", 63 | "vuepress": "2.0.0-rc.1" 64 | }, 65 | "dependencies": { 66 | "case": "^1.6.3", 67 | "linearly": "^0.20.3", 68 | "lodash-es": "^4.17.21" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |

Bndr

6 |

🖱️ ⌇ ⌨️ ⌇ 🎹 ⌇ 🎮 ⌇ 🖊️ ⌇ 👆

7 | 8 | 9 | 10 | DocSandboxAPIBecome a Sponsor 11 | 12 |

13 | 14 | npm version 15 | 16 | 17 | npm licence 18 | 19 |

20 | 21 |
22 | 23 | **Bndr** /ˈbaɪndɚ/ is a library designed to compose events from various user inputs and chain filters in a monadic manner, integrating them into a single event object. It accommodates input devices such as mice🖱️, styluses🖊️, touch inputs👆, keyboards⌨️, [MIDI](https://developer.mozilla.org/en-US/docs/Web/API/Web_MIDI_API) controllers🎹, and [gamepads](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API)🎮. Developed and maintained by [Baku Hashimoto](https://baku89.com). 24 | 25 | Potential use cases for this library include: 26 | 27 | - ⚡️ Associating user inputs with arbitrary triggers for VJing 28 | - 🎨 Introducing manual operations in generative art. 29 | 30 | To get a feel for how it works, please try out [this demo](https://baku89.github.io/bndr-js/). 31 | 32 | ## Supported Parameters 33 | 34 | - 👆 Pointer (mouse, stylus, touch) 35 | - All parameters supported in [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events). (pressure, tilt, multi-touch) 36 | - ⌨️ Keyboard 37 | - 🎹 MIDI 38 | - CC and velocity 39 | - 🎮 Gamepad 40 | - Vendor-specific button name support: JoyCon, PS5 Controller 41 | 42 | ## How to use 43 | 44 | - [Full API documentation](https://baku89.github.io/bndr-js/docs/) 45 | 46 | ### Installation 47 | 48 | ``` 49 | npm install bndr-js 50 | ``` 51 | 52 | ### Example 53 | 54 | ```js 55 | import {Bndr} from 'bndr-js' 56 | 57 | Bndr.pointer().on(pressed => 58 | console.log('Pointer %s', pressed ? 'pressed' : 'released') 59 | ) 60 | 61 | Bndr.pointer() 62 | .position() 63 | .lerp(vec2.lerp, 0.1) 64 | .on(([x, y]) => console.log('Pointer moved: [%f, %f]', x, y)) 65 | 66 | Bndr.keyboard() 67 | .hotkey('shift+c') 68 | .on(() => console.log('Hotkey shift+c pressed')) 69 | 70 | Bndr.keyboard() 71 | .key('a') 72 | .on(pressed => console.log(`Key 'a' ${pressed ? 'pressed' : 'released'}`)) 73 | 74 | Bndr.midi() 75 | .note(0, 50) 76 | .on(velocity => console.log('MIDI slider #50 moved: %d', velocity)) 77 | 78 | Bndr.gamepad() 79 | .axis(0) 80 | .on(([x, y]) => console.log('Gamepad axis #0 tilted: [%f, %f]', x, y)) 81 | ``` 82 | 83 | ## License 84 | 85 | This repository is published under an MIT License. See the included [LICENSE file](./LICENSE). 86 | -------------------------------------------------------------------------------- /docs/.vuepress/config.ts: -------------------------------------------------------------------------------- 1 | import {defineUserConfig} from 'vuepress' 2 | import {path} from '@vuepress/utils' 3 | import {defaultTheme} from '@vuepress/theme-default' 4 | import {viteBundler} from '@vuepress/bundler-vite' 5 | import monacoEditorPlugin, { 6 | type IMonacoEditorOpts, 7 | } from 'vite-plugin-monaco-editor' 8 | 9 | import {fileURLToPath} from 'url' 10 | 11 | import eslint from 'vite-plugin-eslint' 12 | 13 | const monacoEditorPluginDefault = (monacoEditorPlugin as any).default as ( 14 | options: IMonacoEditorOpts 15 | ) => any 16 | 17 | export default defineUserConfig({ 18 | title: 'Bndr', 19 | base: '/bndr-js/', 20 | alias: { 21 | 'bndr-js': path.resolve(__dirname, '../../src'), 22 | }, 23 | head: [ 24 | ['link', {rel: 'icon', href: './logo.svg'}], 25 | ['link', {rel: 'preconnect', href: 'https://fonts.googleapis.com'}], 26 | [ 27 | 'link', 28 | {rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: true}, 29 | ], 30 | [ 31 | 'link', 32 | { 33 | rel: 'stylesheet', 34 | href: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500&family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500;1,600&display=swap', 35 | }, 36 | ], 37 | ['link', {rel: 'icon', href: './logo.svg'}], 38 | [ 39 | 'link', 40 | { 41 | rel: 'stylesheet', 42 | href: 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200', 43 | }, 44 | ], 45 | ], 46 | theme: defaultTheme({ 47 | navbar: [ 48 | { 49 | text: 'Home', 50 | link: '/', 51 | }, 52 | { 53 | text: 'Guide', 54 | link: '/guide', 55 | }, 56 | { 57 | text: 'API', 58 | link: '/api', 59 | }, 60 | { 61 | text: 'Sandbox', 62 | link: '/sandbox', 63 | }, 64 | ], 65 | logo: '/logo.svg', 66 | repo: 'baku89/bndr-js', 67 | }), 68 | locales: { 69 | '/': { 70 | lang: 'English', 71 | title: 'Bndr', 72 | description: 73 | 'A monadic library designed to compose and filter events from various inputs devices', 74 | }, 75 | '/ja/': { 76 | lang: '日本語', 77 | title: 'Bndr', 78 | description: 79 | '様々な入力デバイスからのイベントをモナドとして合成・フィルターするライブラリ', 80 | }, 81 | }, 82 | bundler: viteBundler({ 83 | viteOptions: { 84 | plugins: [ 85 | monacoEditorPluginDefault({ 86 | languageWorkers: ['editorWorkerService', 'typescript'], 87 | }), 88 | eslint(), 89 | ], 90 | resolve: { 91 | alias: [ 92 | { 93 | find: 'bndr-js', 94 | replacement: fileURLToPath(new URL('../../src', import.meta.url)), 95 | }, 96 | ], 97 | }, 98 | }, 99 | }), 100 | markdown: { 101 | //@ts-ignore 102 | linkify: true, 103 | typographer: true, 104 | code: { 105 | lineNumbers: false, 106 | }, 107 | }, 108 | extendsMarkdown: md => { 109 | const defaultRender = md.renderer.rules.fence! 110 | 111 | md.renderer.rules.fence = (tokens, idx, options, env, self) => { 112 | const token = tokens[idx] 113 | if (token.tag === 'code' && token.info === 'mermaid') { 114 | const diagram = md.utils.escapeHtml(token.content) 115 | return `` 116 | } 117 | return defaultRender(tokens, idx, options, env, self) 118 | } 119 | }, 120 | }) 121 | -------------------------------------------------------------------------------- /docs/components/Sandbox.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 109 | 110 | 150 | -------------------------------------------------------------------------------- /src/combinator.ts: -------------------------------------------------------------------------------- 1 | import {debounce} from 'lodash-es' 2 | 3 | import {Emitter} from './Emitter.js' 4 | import {IconSequence} from './types.js' 5 | 6 | /** 7 | * Integrates multiple input events of the same type. The input event is triggered when any of the input events is triggered. 8 | * @param bndrs Input events to combine. 9 | * @returns A combined input event. 10 | * @group Combinators 11 | */ 12 | export function combine(...emitters: Emitter[]): Emitter { 13 | if (emitters.length === 0) throw new Error('Zero-length emitters') 14 | 15 | const ret = new Emitter({ 16 | sources: emitters, 17 | }) 18 | 19 | const emit = debounce((value: T) => ret.emit(value), 0) 20 | 21 | emitters.forEach(e => e.registerDerived(ret, emit)) 22 | 23 | ret.icon = emitters 24 | .map(e => e.icon) 25 | .filter((v: IconSequence | undefined): v is IconSequence => !!v) 26 | .reduce((seq: IconSequence, icon) => { 27 | if (seq.length === 0) { 28 | return icon 29 | } else { 30 | return [...seq, ', ', ...icon] 31 | } 32 | }, []) 33 | 34 | return ret 35 | } 36 | 37 | /** 38 | * * Creates a cascading emitter by combining multiple emitters. The resulting emitter emits `true` when the given emitters emit truthy values in a sequential manner from the beginning to the end of the list. 39 | * @param emitters Emitters to combine. 40 | * @returns A cascading emitter. 41 | * @group Combinators 42 | */ 43 | export function cascade(...emitters: Emitter[]): Emitter { 44 | const ret = new Emitter({ 45 | sources: emitters, 46 | }) 47 | 48 | const values = emitters.map(e => !!e.value) 49 | 50 | let prevCascadedIndex = -1 51 | let cascadedIndex = -1 52 | 53 | emitters.forEach((emitter, i) => { 54 | emitter.registerDerived(ret, value => { 55 | values[i] = !!value 56 | 57 | if (value) { 58 | if (cascadedIndex === i - 1) { 59 | cascadedIndex = i 60 | } 61 | } else { 62 | if (cascadedIndex === i) { 63 | cascadedIndex = values.findIndex(v => v) 64 | } 65 | } 66 | 67 | if ( 68 | prevCascadedIndex < cascadedIndex && 69 | cascadedIndex === emitters.length - 1 70 | ) { 71 | ret.emit(true) 72 | } else if ( 73 | cascadedIndex < prevCascadedIndex && 74 | cascadedIndex < emitters.length - 1 75 | ) { 76 | ret.emit(false) 77 | } 78 | 79 | prevCascadedIndex = cascadedIndex 80 | }) 81 | }) 82 | 83 | return ret 84 | } 85 | 86 | /** 87 | * Creates a emitter that emits `true` when all of the given emitters emit truthy values. 88 | * @param emitters Emitters to combine. 89 | * @returns A new emitter 90 | * @group Combinators 91 | */ 92 | export function and(...emitters: Emitter[]): Emitter { 93 | let prev = emitters.every(e => !!e.value) 94 | 95 | const ret = new Emitter({ 96 | sources: emitters, 97 | }) 98 | 99 | function handler() { 100 | const value = emitters.every(e => !!e.value) 101 | 102 | if (prev !== value) { 103 | ret.emit(value) 104 | } 105 | 106 | prev = value 107 | } 108 | 109 | emitters.forEach(emitter => emitter.on(handler)) 110 | 111 | return ret 112 | } 113 | 114 | /** 115 | * Creates a emitter that emits `true` when any of the given emitters emit truthy values. 116 | * @param emitters Emitters to combine. 117 | * @returns A new emitter 118 | * @group Combinators 119 | */ 120 | export function or(...emitters: Emitter[]): Emitter { 121 | let prev = emitters.some(e => !!e.value) 122 | 123 | const ret = new Emitter({ 124 | sources: emitters, 125 | }) 126 | 127 | function handler() { 128 | const value = emitters.some(e => !!e.value) 129 | 130 | if (prev !== value) { 131 | ret.emit(value) 132 | } 133 | 134 | prev = value 135 | } 136 | 137 | emitters.forEach(emitter => emitter.on(handler)) 138 | 139 | return ret 140 | } 141 | 142 | type UnwrapEmitter = T extends Emitter ? U : never 143 | 144 | type UnwrapEmitters = { 145 | [K in keyof T]: UnwrapEmitter 146 | } 147 | /** 148 | * Creates an input event with tuple type from given inputs. 149 | * @returns An integrated input event with the tuple type of given input events. 150 | * @group Combinators 151 | */ 152 | export function tuple( 153 | ...emitters: T 154 | ): Emitter> { 155 | let last = emitters.map(e => e.value) 156 | 157 | const ret = new Emitter({ 158 | sources: emitters, 159 | }) 160 | 161 | const emit = debounce(() => ret.emit(last), 0) 162 | 163 | emitters.forEach((e, i) => { 164 | e.registerDerived(ret, value => { 165 | last = [...last] 166 | last[i] = value 167 | if (last.every(v => v !== undefined)) { 168 | emit() 169 | } 170 | }) 171 | }) 172 | 173 | return ret 174 | } 175 | -------------------------------------------------------------------------------- /docs/ja/guide.md: -------------------------------------------------------------------------------- 1 | # ガイド 2 | 3 | ## インストール 4 | 5 | ```sh 6 | npm i bndr-js 7 | ``` 8 | 9 | ## 基本的な使い方 10 | 11 | 最も簡単な例として、ポインターが動いた時にコンソールに座標を表示するコードを書いてみましょう。 12 | 13 | ```ts 14 | import {Emitter} from 'bndr-js' 15 | 16 | Bndr.pointer().position().log(console.log) 17 | ``` 18 | 19 | ## Emitter 20 | 21 | Bndr における [Emitter](../api/classes/Emitter) とは、単一の型のイベントを発火するオブジェクトを指します。このライブラリにおける最も基本的かつ中心的な概念です。 22 | 23 | Window、HTMLElement、EventEmitter といったオブジェクトは、多くの場合、複数の種類のイベントを発火し、`addEventListener(eventName, callback)`のような形でイベントリスナーを登録します。一方 Bndr における Emitter はこれらとは異なり、常に単一のタイプのイベントのみを発火します。つまり、イベント名を区別する必要が無いため、[`on(callback)`](../api/classes/Emitter#on)のようにイベントリスナーを登録することになります。 24 | 25 | Bndr では、ポインターやキーボード、MIDI といった入力デバイスの種類ごとに、そのデバイスからのあらゆる入力を扱うための最も一般的なイベント型と紐づけられた Emitter が用意されています。その入力のうち、特定のものにのみ反応したり、イベントオブジェクトから値を取り出すには、[`Emitter.filter`](../api/classes/Emitter#filter) や [`Emitter.map`](../api/classes/Emitter#map) などをメゾッドチェーン状に繋げたり組み合わせます。そして必要な型の Emitter を得たところで、`on` メソッドを使ってコールバック関数を登録するというのが Bndr における基本的な処理です。 26 | 27 | 例えば、ポインティングデバイスからの入力に関するイベントには`pointermove`や`pointerdown` などがありますが、Bndr におけるポインターを扱うオブジェクトを返す関数 [`Bndr.pointer()`](../api#pointer) は、それらのイベントを区別せずに`PointerEvent` 型のイベントを発火し続けます。そのうち「マウスが押された瞬間」にのみ発火するエミッターを得るには、以下のように行います。 28 | 29 | ```ts 30 | import * as Bndr from 'bndr-js' 31 | 32 | const pointer: Bndr = Bndr.pointer() 33 | const mousedown: Bndr = pointer.filter( 34 | e => e.pointerType === 'mouse' && e.type === 'pointerdown' 35 | ) 36 | 37 | mousedown.on(e => console.log('Mouse pressed')) 38 | ``` 39 | 40 | Bndr では、良く使うフィルターや変換処理をビルトインで提供しているため、上記のコードは以下のように一行で書くこともできます。 41 | 42 | ```ts 43 | Bndr.mouse().down().on(e => console.log('Mouse pressed') 44 | ``` 45 | 46 | このような設計をとる利点は、一つのコールバック関数の中でイベントを無視するロジックや必要な値を取り出す処理を書くことなく、**その型 Emitter が発火した際にどのような副作用を起こすか**にのみ関心を分離することにあります。また、イベントが発火し、伝搬・変換する一連の処理を、Emitter というオブジェクトを中心としたモジュラー性の高い形で記述することで、コードの見通しを良くすることに繋がります。 47 | 48 | Bndr における処理は、Max/MSP や Quartz Composer、TouchDesigner などのビジュアルプログラミング環境においてパッチを組み合わせる様子にも似ています。ノードやパッチを組み合わせて一つの音楽やビジュアルを作り出すように、Bndr においても Emitter を組み合わせることで、所定のタイミングある型のイベントを発火するオブジェクトを構築することができます。上記の例は、以下のようなノード構成として捉えることも出来ます。 49 | 50 | ```mermaid 51 | graph TB; 52 | pointer[pointer: PointerEvent] 53 | mousedown[down: true] 54 | 55 | pointer --> mousedown 56 | ``` 57 | 58 | もう一つ例をあげてみましょう。以下は、ポインターの位置を取得し、それを lerp 補間でスムージング処理する Emitter です。 59 | 60 | ```ts 61 | const pointer = Bndr.pointer() 62 | const position = pointer.position() 63 | const lerped = position.lerp(vec2.lerp, 0.1) 64 | ``` 65 | 66 | このコードは、以下のようなノード構成として捉えることができます。 67 | 68 | ```mermaid 69 | graph TB; 70 | pointer[pointer: PointerEvent] 71 | position[position: vec2] 72 | lerped([lerped: vec2]) 73 | 74 | pointer --> position 75 | position --> lerped 76 | ``` 77 | 78 | ## イベントリスナーの登録 79 | 80 | イベントリスナーの登録や解除は、EventEmitter と同様に以下のようなパターンで行うことができます。 81 | 82 | ```ts 83 | emitter.on(([x, y]) => circle(x, y, 10)) 84 | emitter.once(console.info) 85 | emitter.off(console.info) 86 | ``` 87 | 88 | Bndr を使っているとはデバッグ用にコンソール出力する場面がよくありますが、そのようなときは[`Emitter.log`](../api/classes/Emitter#log) メソッドが便利です。また、[`Emitter.value`](../api/classes/Emitter#value) プロパティを使って、最後に発火されたイベントの値を取得することもできます。 89 | 90 | ```ts 91 | emitter.log() 92 | emitter.value // イベントがまだ一度も発火されていない場合は `undefined` 93 | ``` 94 | 95 | ## Emitter の種類 96 | 97 | Emitter には、おおよそ以下のような種類があります。 98 | 99 | - **ジェネレーター**: デバイスや通信など外部の入力を受け取り、それをイベントとして発火する Emitter です。例えば、[`Bndr.pointer`](../api#pointer)、[`Bndr.keyboard`](../api#keyboard)、[`Bndr.midi`](../api#midi) が返す Emitter がこれに当たります。 100 | - **フィルター**: ある単一の Emitter が発火したイベントを監視し、条件に応じてそれを無視するか、あるいはペイロードの値を変換したり、ディレイやスムージングといった時間的な処理など行った上で発火する Emitter です。既存の Emitter に対して[`Emitter.filter`](../api/classes/Emitter#filter)、[`Emitter.map`、`Emitter.delay`](../api/classes/Emitter#delay)、[`Emitter.throttle`](../api/classes/Emitter#throttle)、[`Emitter.lerp`](../api/classes/Emitter#lerp) などのメソッドを使って生成することができます。 101 | - **コンビネーター**: 複数の Emitter が発火したイベントを組み合わせ、新たなイベントとして発火する Emitter です。[`Bndr.tuple`](../api/classes/Emitter#reset)、[`Bndr.combine`](../api#combine) などのメソッドを使って生成することができます。 102 | 103 | これらは厳密には区別されていません。フィルターの Emitter は、上流からの発火が無くとも [`emitter.emit(value)`](../api/classes/Emitter#emit) で手動で発火することも出来ます。 104 | 105 | ## ステートフルな Emitter 106 | 107 | また、Emitter には状態を持つものと持たないものがあります。例えば、`Bndr.pointer()` は状態を持たない一方で、`Bndr.pointer().pressed()` は、ポインターが押された瞬間を検知するために、直前の状態を保持しています。また、 [`Emitter.fold`](../api/classes/Emitter#fold) メゾッドで生成される Emitter なども状態を持ちます。次の例は、ポインターが押された回数をカウントする Emitter です。 108 | 109 | ```ts 110 | const counter = Bndr.pointer() 111 | .pressed() 112 | .fold(count => count++, 0) 113 | ``` 114 | 115 | ステートフルな Emitter は、内部状態を外部から直接参照したり変更することは出来ません。その代わりに、[`Emitter.reset`](../api/classes/Emitter#reset) メソッドを使って内部状態をリセットすることができます。その Emitter がステートフルかどうかは、[`Emitter.stateful`](../api/classes/Emitter#stateful) プロパティで確認することができます。 116 | 117 | `Emitter.lerp` や`Emitter.delay`といった時間的な処理を行うメソッドを使って生成される Emitter もまたステートフルです。上流の Emitter が発火されると、その値を内部状態として保持し、`lerp`であれば値の補間が収まるまで継続的に発火し、`delay`の場合指定時間後に発火しますが、`reset`メゾッドを用いることで内部状態としてのスケジューラーをリセットし、以後の発火をキャンセルすることができます。 118 | 119 | ## イベントの分流と合流 120 | 121 | コンビネーターを用いて、より複雑なイベントの伝搬を記述することができます。以下は、ポインターの位置と押されているかどうかを組み合わせて、それらの値を文字列に変換する Emitter を生成する例です。 122 | 123 | ```ts 124 | const pointer = Bndr.pointer() 125 | const position = pointer.position() 126 | const pressed = pointer.primary.pressed() 127 | 128 | const description = Bndr.tuple(position, pressed).map( 129 | ([position, pressed]) => `position=${position} pressed=${pressed}` 130 | ) 131 | ``` 132 | 133 | ```mermaid 134 | graph TB; 135 | pointer[pointer: PointerEvent] 136 | position[position: vec2] 137 | primary[primary: PointerEvent] 138 | pressed[pressed: boolean] 139 | tuple["tuple: [vec2, boolean]"] 140 | description[description: string] 141 | 142 | pointer --> position 143 | pointer --> primary 144 | primary --> pressed 145 | 146 | position ---> tuple 147 | pressed --> tuple 148 | tuple --> description 149 | ``` 150 | 151 | ## ベクトル、トランスフォーム 152 | 153 | Bndr におけるベクトルや行列は、プレーンな 1 次元の数値配列として表現されています。例えば、位置は`[x, y]`、2 次元のアフィン変換は`[a, b, c, d, tx, ty]`といったようにです。これらのデータの操作は、[Linearly](https://baku89.github.io/linearly)や[gl-matrix](https://glmatrix.net/) などのライブラリを用いて操作することが出来ます。 154 | 155 | ```ts 156 | import {vec2, mat2d} from 'linearly' 157 | 158 | Bndr.pointer() 159 | .position() 160 | .map(p => vec2.scale(p, 0.5)) 161 | .on(([x, y]) => circle(x, y, 10)) 162 | ``` 163 | -------------------------------------------------------------------------------- /docs/.vuepress/styles/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --c-brand: #9a7458; 3 | --c-brand-light: #9a7458; 4 | --c-tip: var(--c-brand); 5 | --c-text: black; 6 | --c-text-lighter: #525368; 7 | --c-text-lightest: var(--c-brand); 8 | --c-border: var(--c-text); 9 | --c-border-dark: var(--c-border); 10 | --code-bg-color: black; 11 | --code-ln-color: white; 12 | --font-family: 'aktiv-grotesk', '游ゴシック体', YuGothic, '游ゴシック', 13 | 'Yu Gothic', 'Hiragino Kaku Gothic ProN', Osaka, sans-serif; 14 | --font-family-code: 'IBM Plex Mono', monospace; 15 | 16 | --border-radius: 6px; 17 | } 18 | 19 | html.dark { 20 | --c-brand: #c29d82; 21 | --c-text: white; 22 | --c-border: white; 23 | --c-border-dark: white; 24 | --c-bg: #000; 25 | --c-tip: var(--c-brand); 26 | --c-tip-bg: var(--code-bg-color); 27 | --c-text-lightest: var(--c-brand); 28 | } 29 | 30 | a { 31 | font-weight: inherit; 32 | } 33 | 34 | h1, 35 | h2, 36 | h3, 37 | h4, 38 | h5, 39 | h6 { 40 | font-weight: 500; 41 | } 42 | 43 | h3 { 44 | font-size: 1.15em; 45 | } 46 | 47 | .theme-default-content { 48 | h2 { 49 | border-top: 2px solid var(--c-border); 50 | margin-top: var(--navbar-height); 51 | padding: 0.2em 0 0; 52 | border-bottom: none; 53 | } 54 | 55 | img.diagram { 56 | html.dark & { 57 | filter: invert(1) hue-rotate(180deg); 58 | } 59 | } 60 | } 61 | 62 | // Navbar 63 | .navbar { 64 | background-color: transparent; 65 | background-repeat: repeat-x; 66 | border-bottom: none; 67 | 68 | .site-name { 69 | font-size: 1.4rem; 70 | font-weight: 400; 71 | background: var(--c-bg); 72 | width: min-content !important; 73 | } 74 | 75 | padding-top: calc(var(--navbar-padding-v) + 2px); 76 | } 77 | 78 | .navbar-item a, 79 | .navbar-dropdown-wrapper .navbar-dropdown-title, 80 | .navbar-dropdown-wrapper .navbar-dropdown-title-mobile { 81 | background: var(--c-bg); 82 | font-size: 1rem; 83 | font-weight: 400; 84 | } 85 | 86 | .navbar-item a { 87 | .external-link-icon { 88 | display: none; 89 | } 90 | } 91 | 92 | .navbar-item a[aria-label='GitHub'] { 93 | width: 1.4em; 94 | overflow: hidden; 95 | vertical-align: middle; 96 | background: none; 97 | 98 | &:before { 99 | display: inline-block; 100 | content: ''; 101 | width: 1.4em; 102 | height: 1.4em; 103 | background: currentColor; 104 | margin-bottom: 0.2em; 105 | mask: url('/github-mark.svg') no-repeat; 106 | mask-size: 100% 100%; 107 | vertical-align: middle; 108 | } 109 | 110 | @media (max-width: 719px) { 111 | width: auto; 112 | 113 | &:before { 114 | display: none; 115 | } 116 | } 117 | } 118 | .navbar-dropdown-title[aria-label='Select language'] { 119 | font-size: 1.5em; 120 | width: 1em; 121 | overflow: hidden; 122 | vertical-align: middle; 123 | background: none; 124 | 125 | &:before { 126 | display: inline-block; 127 | font-family: 'Material Symbols Outlined'; 128 | content: '\e8e2'; 129 | font-feature-settings: 'liga'; 130 | -webkit-font-feature-settings: 'liga'; 131 | font-variation-settings: 132 | 'FILL' 0, 133 | 'wght' 400, 134 | 'GRAD' 0, 135 | 'opsz' 24; 136 | vertical-align: middle; 137 | } 138 | } 139 | 140 | .toggle-color-mode-button { 141 | opacity: 1; 142 | } 143 | 144 | .custom-container { 145 | border: none !important; 146 | padding-left: 2rem !important; 147 | border-radius: var(--border-radius); 148 | 149 | // Make the border gradient 150 | background-image: linear-gradient( 151 | to right, 152 | var(--border-color) 0rem, 153 | color-mix(in srgb, var(--border-color) 50%, transparent) 0.75rem, 154 | color-mix(in srgb, var(--border-color) 20%, transparent) 1.5rem, 155 | color-mix(in srgb, var(--border-color) 5%, transparent) 2rem, 156 | transparent 3rem 157 | ); 158 | 159 | &.tip { 160 | --border-color: var(--c-tip); 161 | } 162 | 163 | &.warning { 164 | --border-color: var(--c-warning); 165 | } 166 | } 167 | 168 | // Code Blocks 169 | div[class*='language-']::before { 170 | font-size: 1rem; 171 | font-feature-settings: 'salt'; 172 | } 173 | 174 | main { 175 | margin-bottom: 2rem !important; 176 | 177 | // List 178 | li { 179 | list-style: none; 180 | margin-bottom: 0.5em; 181 | 182 | &:before { 183 | content: '❊'; 184 | color: var(--c-text-quote); 185 | display: inline-block; 186 | width: 1em; 187 | margin-left: -1em; 188 | margin-right: 0.2em; 189 | } 190 | } 191 | } 192 | 193 | // Sidebar 194 | .sidebar { 195 | border-right: none; 196 | 197 | .navbar-items { 198 | border: none; 199 | } 200 | 201 | .navbar-items a { 202 | font-weight: 500; 203 | } 204 | } 205 | 206 | .sidebar-item.sidebar-heading { 207 | font-weight: 500; 208 | } 209 | 210 | .sidebar-item:not(.sidebar-heading) { 211 | padding-left: 1.25rem; 212 | } 213 | 214 | // Code 215 | code { 216 | border-radius: var(--border-radius); 217 | padding: 0.25em; 218 | background: transparent; 219 | } 220 | 221 | p strong code { 222 | font-size: 1.15rem; 223 | background: none; 224 | color: var(--c-text); 225 | padding: 0; 226 | } 227 | 228 | // Home 229 | .home { 230 | .hero { 231 | text-align: left; 232 | 233 | h1 { 234 | font-weight: 400; 235 | font-size: 4rem; 236 | } 237 | 238 | .description { 239 | max-width: unset; 240 | margin-bottom: 2.5rem; 241 | } 242 | } 243 | 244 | .badges { 245 | margin-bottom: 2rem; 246 | } 247 | } 248 | 249 | // Table 250 | tr:nth-child(2n) { 251 | background-color: transparent; 252 | } 253 | 254 | th:nth-child(3) { 255 | min-width: 15em; 256 | } 257 | 258 | table { 259 | border-collapse: separate; 260 | border-spacing: 0; 261 | } 262 | th:not(:last-child), 263 | td:not(:last-child) { 264 | border-right: 0; 265 | } 266 | th:not(:first-child), 267 | td:not(:first-child) { 268 | border-left: 0; 269 | } 270 | th, 271 | tr:not(:last-child) td { 272 | border-bottom: 0; 273 | } 274 | th:first-child { 275 | border-radius: 18px 0 0 0; 276 | } 277 | th:last-child { 278 | border-radius: 0 18px 0 0; 279 | } 280 | tr:last-child td:first-child { 281 | border-radius: 0 0 0 18px; 282 | } 283 | tr:last-child td:last-child { 284 | border-radius: 0 0 18px 0; 285 | } 286 | 287 | td code { 288 | background-color: transparent !important; 289 | padding-left: 0; 290 | padding-right: 0; 291 | color: var(--c-text); 292 | } 293 | 294 | .sandbox .page-meta { 295 | display: none; 296 | } 297 | 298 | .sandbox .theme-default-content { 299 | max-width: unset; 300 | } 301 | 302 | .sandbox-code { 303 | font-size: 40px; 304 | font-family: var(--font-family-code); 305 | width: min-content; 306 | } 307 | 308 | p { 309 | line-height: 1.8; 310 | } 311 | -------------------------------------------------------------------------------- /docs/guide.md: -------------------------------------------------------------------------------- 1 | # Guide 2 | 3 | ## Install 4 | 5 | ```sh 6 | npm i bndr-js 7 | ``` 8 | 9 | ## Basic Usage 10 | 11 | The simplest sample code using Bndr is as follows. 12 | 13 | ```ts 14 | import {Emitter} from 'bndr-js' 15 | 16 | Bndr.pointer().position().log(console.log) 17 | ``` 18 | 19 | It logs the coordinates to the console when the pointer moves over the window. 20 | 21 | ## Emitter 22 | 23 | An Emitter in Bndr refers to an object that fires a single type of event. It is the most basic and central concept in this library. 24 | 25 | In many cases, objects such as Window, HTMLElement, and EventEmitter fire multiple types of events and register event listeners with the pattern like `addEventListener(eventName, callback)`. In contrast, an Emitter in Bndr always fires only a single type of event. This means that you don't need to distinguish event names, and you register event listeners with just `on(callback)`. 26 | 27 | For example, there are events such as `pointermove` and `pointerdown` for input from pointing devices, but, in Bndr, the function `Bndr.pointer()` that returns an Emitter for handling the pointer fires `PointerEvent` type events without distinguishing them. To get an Emitter that fires only when "the mouse is pressed", you can do the following. 28 | 29 | ```ts 30 | import * as Bndr from 'bndr-js' 31 | 32 | const pointer: Bndr = Bndr.pointer() 33 | const mousedown: Bndr = pointer.filter( 34 | e => e.pointerType === 'mouse' && e.type === 'pointerdown' 35 | ) 36 | 37 | mousedown.on(e => console.log('Mouse pressed')) 38 | ``` 39 | 40 | Bndr also provides built-in filters and transformation methods, so the above code can be written in one line as follows. 41 | 42 | ```ts 43 | Bndr.mouse().down().on(e => console.log('Mouse pressed') 44 | ``` 45 | 46 | The advantage of this design is that you can separate your concerns into “what side effects to cause when the Emitter of a certain type fires” without writing logic to ignore some of events or extract necessary values in a single callback function. Also, by describing a series of processes for firing, propagating, and transforming events in a modular form centered around the Emitter object, it leads to better code visibility. 47 | 48 | The processing in Bndr is similar to the way of combining patches in visual programming environments such as Max/MSP, Quartz Composer, and TouchDesigner. Just as you create music or visuals by combining nodes and patches, in Bndr, you can build an object that fires events of a certain type at a specific timing by combining Emitters. The above example can also be seen as a node structure like this. 49 | 50 | ```mermaid 51 | graph TB; 52 | pointer[pointer: PointerEvent] 53 | mousedown[down: true] 54 | 55 | pointer --> mousedown 56 | ``` 57 | 58 | Let's take another example. The following is an Emitter that gets the position of the pointer and smoothes it with lerp interpolation. 59 | 60 | ```ts 61 | const pointer: Emitter = Bndr.pointer() 62 | const position: Emitter = pointer.position() 63 | const lerped: Emitter = position.lerp(vec2.lerp, 0.2) 64 | ``` 65 | 66 | This code corresponds to a structure like this if it were to be represented in a node-based UI. 67 | 68 | ```mermaid 69 | graph TB; 70 | pointer[pointer: PointerEvent] 71 | position[position: vec2] 72 | lerped([lerped: vec2]) 73 | 74 | pointer --> position 75 | position --> lerped 76 | ``` 77 | 78 | ## (Un)registering event listeners 79 | 80 | Registering and unregistering event listeners can be done in the same way as with EventEmitter. 81 | 82 | ```ts 83 | emitter.on(([x, y]) => circle(x, y, 10)) 84 | emitter.once(console.info) 85 | emitter.off(console.info) 86 | ``` 87 | 88 | When using Bndr, you often need to output to the console for debugging purposes, and in such cases, the `log` method is useful. Also, you can use the `value` property to get the value of the last event fired. 89 | 90 | ```ts 91 | emitter.log() 92 | emitter.value // `undefined` when the Emitter has not yet fired any event. 93 | ``` 94 | 95 | ## Types of Emitters 96 | 97 | There are roughly the following types of Emitters. 98 | 99 | - **Generator**: An Emitter that receives external input such as devices and signals and fires it as an event. For example, the Emitters returned by `Bndr.pointer`, `Bndr.keyboard`, and `Bndr.midi` are of this type. 100 | - **Filter**: An Emitter that monitors events fired by a single Emitter and, depending on the condition, ignores it, transforms the payload, or fires it after performing time-based processing such as delay and smoothing. You can create a filter Emitters by using methods such as `Emitter.filter`, `Emitter.map`, `Emitter.delay`, `Emitter.throttle`, and `Emitter.lerp` on an existing Emitter. 101 | - **Combinator**: An Emitter that combines events fired by multiple Emitters and fires them as a new event. You can create a combinator Emitter by using methods such as `Bndr.merge` and `Bndr.combine`. 102 | 103 | These are not strictly distinguished. A filter Emitter can also be fired manually with `emitter.emit(value)` even if there is no firing from the upstream. 104 | 105 | ## Stateful Emitter 106 | 107 | Some of the methods create Emitters that hold state internally. 108 | 109 | For example, `Bndr.pointer()` is not stateful, while `Bndr.pointer().down()` holds the previous state to detect the moment when the pointer is pressed. Also, an Emitter generated by `Emitter.fold` method holds state. The following example is an Emitter that counts the number of times the pointer is pressed. 110 | 111 | ```ts 112 | const counter = Bndr.pointer() 113 | .down() 114 | .fold(count => count++, 0) 115 | ``` 116 | 117 | An internal state cannot be directly referenced or modified from the outside. Instead, you can reset it using the `Emitter.reset` method. You can also check whether an Emitter is stateful with the `Emitter.stateful` property. 118 | 119 | `Emitter.lerp` and `Emitter.delay` are also stateful Emitters. When the upstream Emitter is fired, the value is held as the internal state, and in the case of `lerp`, it continues to fire until the interpolation of the value is completed, and in the case of `delay`, it fires after the specified time. You can reset the internal state and cancel subsequent firing using the `reset` method. 120 | 121 | ## Branching and merging events 122 | 123 | You can describe more complex event propagation by using combinators. The following is an example of generating an Emitter that converts the position of the pointer and whether it is pressed into a string. 124 | 125 | ```ts 126 | const pointer = Bndr.pointer() 127 | const position = pointer.position() 128 | const pressed = pointer.primary.pressed() 129 | 130 | const description = Bndr.tuple(position, pressed).map( 131 | ([position, pressed]) => `position=${position} pressed=${pressed}` 132 | ) 133 | ``` 134 | 135 | ```mermaid 136 | graph TB; 137 | pointer[pointer: PointerEvent] 138 | position[position: vec2] 139 | primary[primary: PointerEvent] 140 | pressed[pressed: boolean] 141 | tuple["tuple: [vec2, boolean]"] 142 | description[description: string] 143 | 144 | pointer --> position 145 | pointer --> primary 146 | primary --> pressed 147 | 148 | position ---> tuple 149 | pressed --> tuple 150 | tuple --> description 151 | ``` 152 | 153 | ## Vector and Transform 154 | 155 | In Bndr, vectors and matrices are represented as plain 1D arrays of numbers. For example, a position is `[x, y]`, and a 2D affine transformation is `[a, b, c, d, tx, ty]`. These data can be manipulated using libraries such as [Linearly](https://baku89.github.io/linearly) or [gl-matrix](https://glmatrix.net/). 156 | 157 | ```ts 158 | import {vec2, mat2d} from 'linearly' 159 | 160 | Bndr.pointer() 161 | .position() 162 | .map(p => vec2.scale(p, 0.5)) 163 | .on(([x, y]) => circle(x, y, 10)) 164 | ``` 165 | -------------------------------------------------------------------------------- /src/generator/keyboard.ts: -------------------------------------------------------------------------------- 1 | import Case from 'case' 2 | 3 | import {Emitter, GeneratorOptions} from '../Emitter.js' 4 | import {Icon, IconSequence} from '../types.js' 5 | import {cancelEventBehavior} from '../utils.js' 6 | 7 | interface KeyboardGeneratorOptions extends GeneratorOptions { 8 | scope?: string 9 | capture?: boolean 10 | } 11 | 12 | interface HotkeyOptions extends KeyboardGeneratorOptions { 13 | repeat?: boolean 14 | } 15 | 16 | const isApple = /mac|ipod|iphone|ipad/i.test(navigator.userAgent) 17 | 18 | const MetaName = isApple ? 'command' : 'ctrl' 19 | const AltName = isApple ? 'option' : 'alt' 20 | 21 | const normalizedKeyName = new Map([ 22 | // Command / Meta / Ctrl 23 | ['⌘', MetaName], 24 | ['meta', MetaName], 25 | ['cmd', MetaName], 26 | ['ctrl', MetaName], 27 | 28 | // Option / Alt 29 | ['⌥', AltName], 30 | ['option', AltName], 31 | ['alt', AltName], 32 | 33 | // Others 34 | ['⇧', 'shift'], 35 | ['⌃', 'control'], 36 | ['return', 'enter'], 37 | ]) 38 | 39 | const KeyNameToIcon = new Map([ 40 | [ 41 | 'command', 42 | isApple ? {type: 'iconify', icon: 'mdi:apple-keyboard-command'} : 'Ctrl', 43 | ], 44 | [ 45 | 'option', 46 | isApple ? {type: 'iconify', icon: 'mdi:apple-keyboard-option'} : 'Alt', 47 | ], 48 | [ 49 | 'shift', 50 | isApple ? {type: 'iconify', icon: 'mdi:apple-keyboard-shift'} : 'Shift', 51 | ], 52 | ['control', {type: 'iconify', icon: 'mdi:apple-keyboard-control'}], 53 | ['up', {type: 'iconify', icon: 'mdi:arrow-up'}], 54 | ['down', {type: 'iconify', icon: 'mdi:arrow-down'}], 55 | ['left', {type: 'iconify', icon: 'mdi:arrow-left'}], 56 | ['right', {type: 'iconify', icon: 'mdi:arrow-right'}], 57 | ]) 58 | 59 | const KeyCodeToChar = new Map([ 60 | // Symbols 61 | ['Minus', '-'], 62 | ['Equal', '='], 63 | ['Comma', ','], 64 | ['Perild', '.'], 65 | ['Slash', '/'], 66 | ['Backquote', '`'], 67 | ['BracketLeft', '['], 68 | ['BracketRight', ']'], 69 | ['Backslash', '\\'], 70 | ['Semicolon', ';'], 71 | ['Quote', "'"], 72 | 73 | // Arrow keys 74 | ['ArrowUp', 'up'], 75 | ['ArrowDown', 'down'], 76 | ['ArrowLeft', 'left'], 77 | ['ArrowRight', 'right'], 78 | 79 | // Special keys 80 | ['MetaLeft', MetaName], 81 | ['MetaRight', MetaName], 82 | ['ShiftLeft', 'shift'], 83 | ['ShiftRight', 'shift'], 84 | ['ControlLeft', 'ctrl'], 85 | ['AltLeft', AltName], 86 | ['AltRight', AltName], 87 | ['Escape', 'esc'], 88 | ['Backspace', 'backspace'], 89 | ]) 90 | 91 | function normalizeHotkey(hotkey: string) { 92 | const keys = hotkey 93 | .toLowerCase() 94 | .replace(/ +?/g, '') 95 | .split('+') 96 | .filter(k => k !== '') 97 | .map(k => normalizedKeyName.get(k) ?? k) 98 | 99 | return convertKeysToHotkey(keys) 100 | } 101 | 102 | const SpecialKeys = new Set(['shift', MetaName, AltName, 'control']) 103 | 104 | const KeyOrder = new Map( 105 | [ 106 | MetaName, 107 | AltName, 108 | 'shift', 109 | 'control', 110 | 111 | 'up', 112 | 'down', 113 | 'left', 114 | 'right', 115 | 116 | 'space', 117 | 'enter', 118 | 'backspace', 119 | 'capslock', 120 | 'esc', 121 | ].map((k, i) => [k, i] as const) 122 | ) 123 | 124 | function normalizeCodeToKey(code: string) { 125 | if (code.startsWith('Key')) { 126 | return code.slice(3).toLowerCase() 127 | } 128 | if (code.startsWith('Digit')) { 129 | return code.slice(5) 130 | } 131 | 132 | const char = KeyCodeToChar.get(code) 133 | if (char) return char 134 | 135 | return code.toLowerCase() 136 | } 137 | 138 | interface KeyboardEmitterEvent { 139 | type: 'keydown' | 'keyup' 140 | key: string 141 | repeat: boolean 142 | pressedKeys: Set 143 | preventDefault: Event['preventDefault'] 144 | stopPropagation: Event['stopPropagation'] 145 | } 146 | 147 | function convertKeysToHotkey(keys: Iterable): string { 148 | const hotkey = [...keys].sort((a, b) => { 149 | const orderA = KeyOrder.get(a) ?? a.charCodeAt(0) + 0xff 150 | const orderB = KeyOrder.get(b) ?? b.charCodeAt(0) + 0xff 151 | return orderA - orderB 152 | }) 153 | return hotkey.join('+') 154 | } 155 | 156 | function hotkeyToIcon(hotkey: string): IconSequence { 157 | return hotkey 158 | .split('+') 159 | .reduce((keys: string[], k: string) => { 160 | if (k === '' && keys.at(-1) === '') { 161 | k = '+' 162 | } 163 | return [...keys, k] 164 | }, []) 165 | .filter(k => k !== '') 166 | .map(k => KeyNameToIcon.get(k) ?? Case.title(k)) 167 | } 168 | 169 | /** 170 | * @group Emitters 171 | */ 172 | export class KeyboardEmitter extends Emitter { 173 | constructor(target: Window | HTMLElement | string = window) { 174 | let dom: Element | Window 175 | if (typeof target === 'string') { 176 | const _dom = document.querySelector(target) 177 | if (!_dom) throw new Error('Invalid selector') 178 | dom = _dom 179 | } else { 180 | dom = target 181 | } 182 | 183 | const pressedKeys = new Set() 184 | 185 | const onReleaseCommand = () => { 186 | const releasedKeys = new Set() 187 | for (const key of pressedKeys) { 188 | if (!SpecialKeys.has(key)) { 189 | pressedKeys.delete(key) 190 | releasedKeys.add(key) 191 | } 192 | } 193 | 194 | for (const key of releasedKeys) { 195 | this.emit({ 196 | type: 'keyup', 197 | key, 198 | repeat: false, 199 | pressedKeys: new Set(pressedKeys), 200 | preventDefault: () => undefined, 201 | stopPropagation: () => undefined, 202 | }) 203 | } 204 | } 205 | 206 | const onKeydown = (e: KeyboardEvent) => { 207 | // Ignore if the target is an input 208 | if (e.target instanceof HTMLInputElement) return 209 | 210 | const key = normalizeCodeToKey(e.code) 211 | 212 | pressedKeys.add(key) 213 | 214 | this.emit({ 215 | type: 'keydown', 216 | key, 217 | repeat: e.repeat, 218 | pressedKeys: new Set(pressedKeys), 219 | preventDefault: e.preventDefault.bind(e), 220 | stopPropagation: e.stopPropagation.bind(e), 221 | }) 222 | } 223 | 224 | const onKeyup = (e: KeyboardEvent) => { 225 | // Ignore if the target is an input 226 | if (e.target instanceof HTMLInputElement) return 227 | 228 | const key = normalizeCodeToKey(e.code) 229 | pressedKeys.delete(key) 230 | 231 | if (key === 'command') { 232 | onReleaseCommand() 233 | } 234 | 235 | this.emit({ 236 | type: 'keyup', 237 | key, 238 | repeat: false, 239 | pressedKeys: new Set(pressedKeys), 240 | preventDefault: e.preventDefault.bind(e), 241 | stopPropagation: e.stopPropagation.bind(e), 242 | }) 243 | } 244 | 245 | dom.addEventListener('keydown', onKeydown) 246 | dom.addEventListener('keyup', onKeyup) 247 | 248 | const onPointerEvent = (e: PointerEvent) => { 249 | if (pressedKeys.has('command') && !e.metaKey) { 250 | pressedKeys.delete('command') 251 | onReleaseCommand() 252 | 253 | this.emit({ 254 | type: 'keyup', 255 | key: 'command', 256 | repeat: false, 257 | pressedKeys: new Set(pressedKeys), 258 | preventDefault: () => undefined, 259 | stopPropagation: () => undefined, 260 | }) 261 | } 262 | } 263 | 264 | window.addEventListener('pointermove', onPointerEvent) 265 | 266 | super({ 267 | onDispose() { 268 | dom.removeEventListener('keydown', onKeydown) 269 | dom.removeEventListener('keyup', onKeyup) 270 | window.removeEventListener('pointermove', onPointerEvent) 271 | }, 272 | }) 273 | } 274 | 275 | /** 276 | * @group Generators 277 | */ 278 | pressed(key: string, options?: KeyboardGeneratorOptions): Emitter { 279 | const ret = this.filter(e => e.key === key && !e.repeat) 280 | .on(e => cancelEventBehavior(e, options)) 281 | .map(e => e.type === 'keydown') 282 | 283 | ret.icon = hotkeyToIcon(key) 284 | 285 | return ret 286 | } 287 | 288 | /** 289 | * @group Generators 290 | */ 291 | keydown(key: string, options?: KeyboardGeneratorOptions): Emitter { 292 | const ret = this.pressed(key, options) 293 | .filter(e => e) 294 | .constant(true) 295 | 296 | ret.icon = hotkeyToIcon(key) 297 | 298 | return ret 299 | } 300 | 301 | /** 302 | * @group Generators 303 | */ 304 | keyup(key: string, options?: KeyboardGeneratorOptions): Emitter { 305 | const ret = this.pressed(key, options) 306 | .filter(e => !e) 307 | .constant(true) 308 | 309 | ret.icon = hotkeyToIcon(key) 310 | 311 | return ret 312 | } 313 | 314 | /** 315 | * @group Generators 316 | */ 317 | hotkey(hotkey: string, options?: HotkeyOptions): Emitter { 318 | const normalizedHotkey = normalizeHotkey(hotkey) 319 | 320 | const ret = this.filter( 321 | e => 322 | e.type === 'keydown' && 323 | convertKeysToHotkey(e.pressedKeys) === normalizedHotkey && 324 | (options?.repeat ? true : !e.repeat) 325 | ) 326 | .on(e => cancelEventBehavior(e, options)) 327 | .constant(true) 328 | 329 | ret.icon = hotkeyToIcon(normalizedHotkey) 330 | 331 | return ret 332 | } 333 | } 334 | 335 | /** 336 | * @group Generators 337 | */ 338 | export function keyboard( 339 | target: Window | HTMLElement | string = window 340 | ): KeyboardEmitter { 341 | return new KeyboardEmitter(target) 342 | } 343 | -------------------------------------------------------------------------------- /src/generator/gamepad.ts: -------------------------------------------------------------------------------- 1 | import Case from 'case' 2 | import {scalar, vec2} from 'linearly' 3 | import {isEqual} from 'lodash-es' 4 | 5 | import {Emitter} from '../Emitter.js' 6 | import {Memoized, memoizeFunction} from '../memoize.js' 7 | 8 | /** 9 | * Gamepad button name. In addition to [W3C specifications](https://w3c.github.io/gamepad/#remapping), it also supports vendor-specific names such as Nintendo Switch and PlayStation. 10 | */ 11 | export type ButtonName = 12 | | number 13 | // Generic 14 | | 'b' 15 | | 'a' 16 | | 'x' 17 | | 'y' 18 | | 'l' 19 | | 'r' 20 | | 'zl' 21 | | 'zr' 22 | | 'select' 23 | | 'start' 24 | | 'stick-left' 25 | | 'stick-right' 26 | | 'up' 27 | | 'down' 28 | | 'left' 29 | | 'right' 30 | | 'home' 31 | 32 | // JoyCon-specific. It distincts SL/SR of Joy-Con (L) and Joy-Con (R) 33 | | 'lsl' 34 | | 'lsr' 35 | | 'rsl' 36 | | 'rsr' 37 | | '+' 38 | | '-' 39 | | 'capture' 40 | 41 | // PlayStation Specific. X Button is omitted 42 | // https://controller.dl.playstation.net/controller/lang/en/DS_partnames.html 43 | | 'triangle' 44 | | 'circle' 45 | | 'square' 46 | | 'l1' 47 | | 'l2' 48 | | 'r1' 49 | | 'r2' 50 | | 'create' 51 | | 'option' 52 | | 'touch-pad' 53 | 54 | // Xbox Controller Specific. 55 | | 'lb' 56 | | 'rb' 57 | | 'lt' 58 | | 'rt' 59 | | 'view' 60 | | 'menu' 61 | | 'share' 62 | 63 | // https://w3c.github.io/gamepad/#remapping 64 | // The button name is based on Super Famicon controller 65 | const GenericButtonName = [ 66 | 'b', // buttons[0] Bottom button in right cluster 67 | 'a', // buttons[1] Right button in right cluster 68 | 'y', // buttons[2] Left button in right cluster 69 | 'x', // buttons[3] Top button in right cluster 70 | 71 | 'l', // buttons[4] Top left front button 72 | 'r', // buttons[5] Top right front button 73 | 'zl', // buttons[6] Bottom left front button 74 | 'zr', // buttons[7] Bottom right front button 75 | 76 | 'select', // buttons[8] Left button in center cluster 77 | 'start', // buttons[9] Right button in center cluster 78 | 79 | 'stick-left', // buttons[10] Left stick pressed button 80 | 'stick-right', // buttons[11] Right stick pressed button 81 | 82 | 'up', // buttons[12] Top button in left cluster 83 | 'down', // buttons[13] Bottom button in left cluster 84 | 'left', // buttons[14] Left button in left cluster 85 | 'right', // buttons[15] Right button in left cluster 86 | 87 | 'home', // buttons[16] Center button in center cluster 88 | ] as const 89 | 90 | export type AxisName = 'left' | 'right' | number 91 | 92 | type GamepadData = 93 | | {type: 'button'; name: ButtonName; pressed: boolean; id: string} 94 | | {type: 'axis'; name: AxisName; value: vec2; id: string} 95 | | {type: 'device'; devices: Gamepad[]} 96 | 97 | /** 98 | * @group Emitters 99 | */ 100 | export class GamepadEmitter extends Emitter { 101 | constructor() { 102 | super() 103 | 104 | this.#update() 105 | } 106 | 107 | #prevGamepads = new Map() 108 | 109 | #scanGamepads() { 110 | const gamepadsEntries = navigator 111 | .getGamepads() 112 | .filter(g => g !== null) 113 | .map(g => [g.index, g] as const) 114 | 115 | return new Map(gamepadsEntries) 116 | } 117 | 118 | #update() { 119 | if (this.disposed) return 120 | 121 | const gamepads = this.#scanGamepads() 122 | 123 | if (gamepads.size !== this.#prevGamepads.size) { 124 | this.emit({type: 'device', devices: [...gamepads.values()]}) 125 | } 126 | 127 | for (const [index, curt] of gamepads.entries()) { 128 | const info = Matchers.find(m => m.match(curt)) 129 | 130 | if (info && 'ignore' in info) continue 131 | 132 | const prev = this.#prevGamepads.get(index) 133 | 134 | if (!prev || prev === curt) continue 135 | 136 | // Emit button events 137 | for (const [i, {pressed}] of curt.buttons.entries()) { 138 | const prevPressed = prev.buttons[i]?.pressed ?? false 139 | 140 | if (pressed === prevPressed) continue 141 | 142 | const name = info?.buttons[i] ?? GenericButtonName[i] ?? i 143 | 144 | this.emit({ 145 | type: 'button', 146 | name, 147 | pressed, 148 | id: curt.id, 149 | }) 150 | } 151 | 152 | // Emit axis events 153 | for (let i = 0; i * 2 < curt.axes.length; i++) { 154 | const p: vec2 = [prev.axes[i * 2], prev.axes[i * 2 + 1]] 155 | const c: vec2 = [curt.axes[i * 2], curt.axes[i * 2 + 1]] 156 | 157 | if (!isEqual(p, c)) { 158 | const name = info?.axes[i] ?? i 159 | this.emit({type: 'axis', name, value: c, id: curt.id}) 160 | } 161 | } 162 | } 163 | 164 | this.#prevGamepads = gamepads 165 | 166 | requestAnimationFrame(this.#update.bind(this)) 167 | } 168 | 169 | /** 170 | * @group Generators 171 | */ 172 | @Memoized() 173 | devices(): Emitter { 174 | return this.filterMap(e => (e.type === 'device' ? e.devices : undefined)) 175 | } 176 | 177 | /** 178 | * Emits `true` if there is at least one gamepad connected. 179 | * @returns `true` if there is at least one gamepad connected. 180 | */ 181 | @Memoized() 182 | connected(): Emitter { 183 | return this.devices() 184 | .map(devices => devices.length > 0) 185 | .change() 186 | } 187 | 188 | /** 189 | * @group Generators 190 | */ 191 | @Memoized() 192 | button(name: ButtonName): Emitter { 193 | const ret = this.filterMap(e => { 194 | if (e.type === 'button' && e.name === name) return e.pressed 195 | return undefined 196 | }) 197 | const nameStr = name.toString() 198 | ret.icon = [ 199 | {type: 'iconify', icon: 'solar:gamepad-bold'}, 200 | nameStr.length > 3 ? Case.title(name.toString()) : nameStr.toUpperCase(), 201 | ] 202 | return ret 203 | } 204 | 205 | /** 206 | * @group Generators 207 | */ 208 | @Memoized() 209 | axis(name?: AxisName | null): Emitter { 210 | return this.filterMap(e => { 211 | if (e.type === 'axis' && (!name || e.name === name)) { 212 | return e.value 213 | } else { 214 | return undefined 215 | } 216 | }) 217 | } 218 | 219 | /** 220 | * Emits the direction in which the axis is tilted. Each axis is quantized into -1, 0, or 1. If the axis is not tilted, it emits `null`. 221 | * @example 222 | * [1, 0] // right 223 | * [0, -1] // up 224 | * [-1, 1] // down-left 225 | * 226 | * @param name If omitted, it will watch all axes. 227 | * @param options step: quantization step in degrees, threshold: minimum tilt value (0-1) to emit 228 | * @returns 229 | * @group Generators 230 | */ 231 | @Memoized() 232 | axisDirection( 233 | name?: AxisName | null, 234 | {step = 90, threshold = 0.5}: {step?: 45 | 90; threshold?: number} = {} 235 | ): Emitter { 236 | return this.axis(name) 237 | .map((dir): vec2 | null => { 238 | if (vec2.length(dir) < threshold) return null 239 | 240 | const angle = scalar.degrees(Math.atan2(dir[1], dir[0])) 241 | const quantizedAngle = scalar.quantize(angle, step) 242 | 243 | switch (quantizedAngle) { 244 | case -180: 245 | return [-1, 0] 246 | case -135: 247 | return [-1, -1] 248 | case -90: 249 | return [0, -1] 250 | case -45: 251 | return [1, -1] 252 | case 0: 253 | return [1, 0] 254 | case 45: 255 | return [1, 1] 256 | case 90: 257 | return [0, 1] 258 | case 135: 259 | return [-1, 1] 260 | case 180: 261 | return [-1, 0] 262 | default: 263 | throw new Error(`Unexpected angle: ${angle}`) 264 | } 265 | }) 266 | .change() 267 | } 268 | } 269 | 270 | type GamepadInfo = { 271 | match: (e: Gamepad) => boolean 272 | ignore?: boolean 273 | 274 | buttons?: ButtonName[] 275 | axes?: AxisName[] 276 | } 277 | 278 | const Matchers: GamepadInfo[] = [ 279 | { 280 | match: gamepad => gamepad.id.includes('Joy-Con (R)'), 281 | buttons: [ 282 | 'a', 283 | 'x', 284 | 'b', 285 | 'y', 286 | 'rsl', 287 | 'rsr', 288 | 6, 289 | 'zr', 290 | 'r', 291 | '+', 292 | 'stick-right', 293 | 11, 294 | 12, 295 | 13, 296 | 14, 297 | 15, 298 | 'home', 299 | ], 300 | axes: ['right'], 301 | }, 302 | { 303 | match: gamepad => gamepad.id.startsWith('Joy-Con (L)'), 304 | buttons: [ 305 | 'left', 306 | 'down', 307 | 'up', 308 | 'right', 309 | 'lsl', 310 | 'lsr', 311 | 'zl', 312 | 7, 313 | 'l', 314 | '-', 315 | 'stick-left', 316 | 11, 317 | 12, 318 | 13, 319 | 14, 320 | 15, 321 | 'capture', 322 | ], 323 | axes: ['left'], 324 | }, 325 | { 326 | match: gamepad => gamepad.id.startsWith('Joy-Con L+R'), 327 | buttons: [ 328 | 'a', 329 | 'b', 330 | 'y', 331 | 'x', 332 | 'l', 333 | 'r', 334 | 'zl', 335 | 'zr', 336 | '-', 337 | '+', 338 | 'stick-left', 339 | 'stick-right', 340 | 'up', 341 | 'down', 342 | 'left', 343 | 'right', 344 | 'home', 345 | 'capture', 346 | 'lsl', 347 | 'lsr', 348 | 'rsl', 349 | 'rsr', 350 | ], 351 | axes: ['left', 'right'], 352 | }, 353 | { 354 | // When you connect both JoyCon left and right to PC, it is recognized as double devices, 355 | // one of which is "Joy-Con L+R" and the other is "Joy-Con (L/R)". 356 | // But the latter is not actually usable, so we ignore it. 357 | match: gamepad => gamepad.id.startsWith('Joy-Con (L/R)'), 358 | ignore: true, 359 | }, 360 | { 361 | match: gamepad => gamepad.id.startsWith('Pro Controller'), 362 | buttons: [ 363 | 'b', 364 | 'a', 365 | 'x', 366 | 'y', 367 | 'l', 368 | 'r', 369 | 'zl', 370 | 'zr', 371 | '-', 372 | '+', 373 | 'stick-left', 374 | 'stick-right', 375 | 'up', 376 | 'down', 377 | 'left', 378 | 'right', 379 | 'home', 380 | 'capture', 381 | ], 382 | axes: ['left', 'right'], 383 | }, 384 | { 385 | match: gamepad => gamepad.id.startsWith('DualSense Wireless Controller'), 386 | buttons: [ 387 | 'x', 388 | 'square', 389 | 'circle', 390 | 'triangle', 391 | 'l1', 392 | 'r1', 393 | 'l2', 394 | 'r2', 395 | 'create', 396 | 'option', 397 | 'stick-left', 398 | 'stick-right', 399 | 'up', 400 | 'down', 401 | 'left', 402 | 'right', 403 | 'home', 404 | 'touch-pad', 405 | ], 406 | axes: ['left', 'right'], 407 | }, 408 | { 409 | match: gamepad => gamepad.id.includes('Xbox'), 410 | buttons: [ 411 | 'b', 412 | 'a', 413 | 'x', 414 | 'y', 415 | 'lb', 416 | 'rb', 417 | 'lt', 418 | 'rt', 419 | 'view', 420 | 'menu', 421 | 'stick-left', 422 | 'stick-right', 423 | 'up', 424 | 'down', 425 | 'left', 426 | 'right', 427 | 'home', 428 | 'share', 429 | ], 430 | 431 | axes: ['left', 'right'], 432 | }, 433 | ] 434 | 435 | /** 436 | * @group Generators 437 | */ 438 | export const gamepad = memoizeFunction(() => new GamepadEmitter()) 439 | -------------------------------------------------------------------------------- /src/generator/pointer.ts: -------------------------------------------------------------------------------- 1 | import {mat2d, vec2} from 'linearly' 2 | 3 | import {Emitter, EmitterOptions, GeneratorOptions} from '../Emitter.js' 4 | import {Memoized, memoizeFunction} from '../memoize.js' 5 | import {cancelEventBehavior} from '../utils.js' 6 | 7 | type PointerEmitterTarget = Document | HTMLElement | SVGElement | string 8 | 9 | interface PointerPressedGeneratorOptions extends GeneratorOptions { 10 | /** 11 | * Whether to capture the pointer. 12 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture 13 | */ 14 | pointerCapture?: boolean 15 | } 16 | 17 | interface PointerPositionGeneratorOptions extends GeneratorOptions { 18 | /** 19 | * 'client' means the position is relative to the client area of the window, and 'offset' means the position is relative to the offset of the target element. 20 | * @default 'client' 21 | */ 22 | coordinate?: 'client' | 'offset' 23 | } 24 | 25 | type PointerDragGeneratorOptions = PointerPressedGeneratorOptions & 26 | PointerPositionGeneratorOptions & { 27 | /** 28 | * The element to be the origin of the coordinate when options.coordinate is 'offset' 29 | */ 30 | origin?: HTMLElement 31 | /** 32 | * The selector to filter the event target. 33 | */ 34 | selector?: string 35 | } 36 | 37 | export interface DragData { 38 | /** 39 | * The type of the event. 40 | */ 41 | type: 'down' | 'drag' | 'up' 42 | /** 43 | * The position of the pointer when the drag started. 44 | */ 45 | start: vec2 46 | /** 47 | * The current position of the pointer. 48 | */ 49 | current: vec2 50 | /** 51 | * The delta of the pointer position. 52 | */ 53 | delta: vec2 54 | /** 55 | * The original pointer event. 56 | */ 57 | event: PointerEvent 58 | } 59 | 60 | type WithPointerCountData = 61 | | { 62 | type: 'pointerdown' | 'pointermove' 63 | 64 | /** 65 | * The list of pointer events. 66 | */ 67 | events: PointerEvent[] 68 | } 69 | | {type: 'pointerup'} 70 | 71 | interface GestureTransformData { 72 | justStarted: boolean 73 | start: mat2d 74 | current: mat2d 75 | delta: mat2d 76 | points: [vec2, vec2] 77 | } 78 | 79 | /** 80 | * @group Emitters 81 | */ 82 | export class PointerEmitter extends Emitter { 83 | #target: Exclude 84 | 85 | constructor( 86 | target: PointerEmitterTarget = document, 87 | options: Pick, 'sources'> = {} 88 | ) { 89 | let dom: Exclude 90 | if (typeof target === 'string') { 91 | const _dom = document.querySelector(target) as HTMLElement | null 92 | if (!_dom) throw new Error('Invalid selector') 93 | dom = _dom 94 | } else { 95 | dom = target 96 | } 97 | 98 | const onPointerEvent = (evt: any) => this.emit(evt) 99 | 100 | if (!options.sources) { 101 | // Register event listeners only when this is the generator emitter 102 | 103 | dom.addEventListener('pointermove', onPointerEvent) 104 | dom.addEventListener('pointerdown', onPointerEvent) 105 | dom.addEventListener('pointerup', onPointerEvent) 106 | dom.addEventListener('pointerleave', onPointerEvent) 107 | dom.addEventListener('pointercancel', onPointerEvent) 108 | dom.addEventListener('contextmenu', onPointerEvent) 109 | } 110 | 111 | super({ 112 | ...options, 113 | onDispose() { 114 | dom.removeEventListener('pointermove', onPointerEvent) 115 | dom.removeEventListener('pointerdown', onPointerEvent) 116 | dom.removeEventListener('pointerup', onPointerEvent) 117 | dom.removeEventListener('pointerleave', onPointerEvent) 118 | dom.removeEventListener('pointercancel', onPointerEvent) 119 | dom.removeEventListener('contextmenu', onPointerEvent) 120 | }, 121 | }) 122 | 123 | this.#target = dom 124 | } 125 | 126 | /** 127 | * Creates a generator that emits `true` when the pointer is pressed. 128 | * @group Filters 129 | */ 130 | @Memoized() 131 | pressed(options?: PointerPressedGeneratorOptions): Emitter { 132 | return this.filterMap(e => { 133 | if (e.type === 'pointermove') return 134 | 135 | cancelEventBehavior(e, options) 136 | 137 | if (e.type === 'contextmenu' && options.preventDefault) { 138 | return 139 | } 140 | 141 | if (options?.pointerCapture && e.type === 'pointerdown') { 142 | const element = e.target as HTMLElement 143 | element.setPointerCapture(e.pointerId) 144 | } 145 | 146 | return e.type === 'pointerdown' 147 | }, false) 148 | } 149 | 150 | /** 151 | * Creates a generator that emits the position of the pointer. 152 | * @group Filters 153 | */ 154 | @Memoized() 155 | position(options?: PointerPositionGeneratorOptions): Emitter { 156 | return this.map(event => { 157 | cancelEventBehavior(event, options) 158 | 159 | if ( 160 | options?.coordinate === 'offset' && 161 | this.#target instanceof HTMLElement 162 | ) { 163 | const {left, top} = this.#target.getBoundingClientRect() 164 | return [event.clientX - left, event.clientY - top] as vec2 165 | } 166 | 167 | return [event.clientX, event.clientY] 168 | }) 169 | } 170 | 171 | /** 172 | * Creates a generator that emits the pressure of the pointer. 173 | * @group Filters 174 | */ 175 | @Memoized() 176 | pressure(): Emitter { 177 | return this.map(e => e.pressure).change() 178 | } 179 | 180 | /** 181 | * @group Filters 182 | */ 183 | @Memoized() 184 | twist(): Emitter { 185 | return this.map(e => e.twist).change() 186 | } 187 | 188 | /** 189 | * @group Filters 190 | */ 191 | @Memoized() 192 | tilt(): Emitter { 193 | return this.map(e => [e.tiltX, e.tiltY] as vec2).change() 194 | } 195 | 196 | /** 197 | * Creates a generator that emits the size of the pointer. 198 | * @group Filters 199 | */ 200 | @Memoized() 201 | size(): Emitter { 202 | return this.map(e => [e.width, e.height] as vec2).change() 203 | } 204 | 205 | /** 206 | * Creates a generator that emits the pointer count. 207 | * @group Filters 208 | */ 209 | @Memoized() 210 | pointerCount(): Emitter { 211 | const pointers = new Set() 212 | 213 | return this.filterMap(e => { 214 | if (e.type === 'pointermove') return undefined 215 | 216 | if (e.type === 'pointerdown') { 217 | pointers.add(e.pointerId) 218 | } else { 219 | pointers.delete(e.pointerId) 220 | } 221 | 222 | return pointers.size 223 | }, 0).change() 224 | } 225 | 226 | /** 227 | * Creates a generator that emits the list of pointers when the pointer count is the given count. 228 | * @group Filters 229 | */ 230 | @Memoized() 231 | withPointerCount( 232 | count: number, 233 | options?: GeneratorOptions 234 | ): Emitter { 235 | const pointers = new Map() 236 | let prevPointerCount = 0 237 | 238 | return this.filterMap(e => { 239 | if (e.type === 'pointerdown' || e.type === 'pointermove') { 240 | pointers.set(e.pointerId, e) 241 | } else { 242 | pointers.delete(e.pointerId) 243 | } 244 | 245 | const wasExpectedCount = prevPointerCount === count 246 | const isExpectedCount = pointers.size === count 247 | prevPointerCount = pointers.size 248 | 249 | if (isExpectedCount) { 250 | cancelEventBehavior(e, options) 251 | return { 252 | type: wasExpectedCount ? 'pointermove' : 'pointerdown', 253 | events: [...pointers.values()], 254 | } 255 | } else { 256 | return wasExpectedCount ? {type: 'pointerup'} : undefined 257 | } 258 | }) 259 | } 260 | 261 | /** 262 | * Creates a emitter that emits when the pointer is dragged. 263 | * @group Filters 264 | */ 265 | @Memoized() 266 | drag(options?: PointerDragGeneratorOptions): Emitter { 267 | let dragging = false 268 | let start = vec2.zero 269 | let prev = vec2.zero 270 | 271 | return this.createDerived({ 272 | onReset() { 273 | dragging = false 274 | start = prev = vec2.zero 275 | }, 276 | propagate: (e, emit) => { 277 | cancelEventBehavior(e, options) 278 | 279 | if (options?.selector) { 280 | const target = e.target as HTMLElement 281 | if (!target.matches(options.selector)) return 282 | } 283 | 284 | // Compute current 285 | let current: vec2 = [e.clientX, e.clientY] 286 | 287 | if (options?.coordinate === 'offset') { 288 | const target = options?.origin ?? this.#target 289 | const {left, top} = 290 | target instanceof HTMLElement 291 | ? target.getBoundingClientRect() 292 | : {left: 0, top: 0} 293 | 294 | current = vec2.sub(current, [left, top]) 295 | } 296 | 297 | // Compute type and delta 298 | let type: DragData['type'] | undefined 299 | let delta = vec2.zero 300 | 301 | if (e.type === 'pointerdown') { 302 | if (options?.pointerCapture ?? true) { 303 | const element = e.target as HTMLElement 304 | element.setPointerCapture(e.pointerId) 305 | } 306 | 307 | type = 'down' 308 | start = current 309 | dragging = true 310 | } else if (e.type === 'pointermove') { 311 | if (!dragging || vec2.equals(prev, current)) return 312 | 313 | type = 'drag' 314 | delta = vec2.sub(current, prev) 315 | } else if ( 316 | e.type === 'pointerup' || 317 | e.type === 'pointercancel' || 318 | e.type === 'pointerleave' || 319 | (e.type === 'contextmenu' && !options.preventDefault) 320 | ) { 321 | if (!dragging) return 322 | type = 'up' 323 | dragging = false 324 | } 325 | 326 | if (type === undefined) { 327 | return 328 | } 329 | 330 | prev = current 331 | 332 | emit({type, start, current, delta, event: e}) 333 | }, 334 | }) 335 | } 336 | 337 | /** 338 | * @group Filters 339 | */ 340 | @Memoized() 341 | gestureTransform(options: GeneratorOptions): Emitter { 342 | return this.withPointerCount(2, options).fold( 343 | (state: GestureTransformData, e: WithPointerCountData) => { 344 | if (e.type === 'pointerdown') { 345 | const points = e.events.map(e => vec2.of(e.clientX, e.clientY)) as [ 346 | vec2, 347 | vec2, 348 | ] 349 | 350 | return { 351 | points, 352 | justStarted: true, 353 | start: mat2d.identity, 354 | current: mat2d.identity, 355 | delta: mat2d.identity, 356 | } 357 | } else if (e.type === 'pointermove') { 358 | const prevPoints = state.points 359 | const currentPoints = e.events.map(e => 360 | vec2.of(e.clientX, e.clientY) 361 | ) as [vec2, vec2] 362 | const delta = mat2d.fromPoints( 363 | [prevPoints[0], currentPoints[0]], 364 | [prevPoints[1], currentPoints[1]] 365 | ) 366 | 367 | if (!delta) throw new Error('Invalid delta') 368 | 369 | return { 370 | points: currentPoints, 371 | justStarted: false, 372 | start: state.start, 373 | current: mat2d.multiply(delta, state.current), 374 | delta, 375 | } 376 | } else { 377 | return undefined 378 | } 379 | }, 380 | { 381 | points: [vec2.zero, vec2.zero], 382 | justStarted: false, 383 | start: mat2d.identity, 384 | current: mat2d.identity, 385 | delta: mat2d.identity, 386 | } 387 | ) 388 | } 389 | 390 | /** 391 | * Creates an emitter that emits `true` at the moment the pointer is pressed. 392 | * @group Filters 393 | */ 394 | @Memoized() 395 | down(options?: GeneratorOptions): Emitter { 396 | return this.filterMap(e => { 397 | if (e.type === 'pointerdown') { 398 | cancelEventBehavior(e, options) 399 | return true 400 | } 401 | }) 402 | } 403 | 404 | /** 405 | * Creates an emitter that emits `true` at the moment the pointer is released. 406 | * @group Filters 407 | */ 408 | @Memoized() 409 | up(options?: GeneratorOptions): Emitter { 410 | return this.filterMap(e => { 411 | if (e.type.match(/^pointer(up|cancel|leave)$/)) { 412 | cancelEventBehavior(e, options) 413 | return true 414 | } 415 | }) 416 | } 417 | 418 | /** 419 | * Creates a emitter that emits only when the given button is pressed. 420 | * @param button Button to watch. 421 | * @returns A new emitter. 422 | * @group Properties 423 | */ 424 | @Memoized() 425 | button( 426 | button: number | 'primary' | 'secondary' | 'left' | 'middle' | 'right' 427 | ): PointerEmitter { 428 | const ret = new PointerEmitter(this.#target, { 429 | sources: this, 430 | }) 431 | 432 | const buttonIndex = 433 | typeof button === 'number' 434 | ? button 435 | : (PointerEmitter.ButtonNameToIndex.get(button) ?? 0) 436 | 437 | this.registerDerived(ret, value => { 438 | if (value.button === -1) { 439 | // For events other than up/down 440 | ret.emit(value) 441 | } else if (button === 'primary') { 442 | if (value.isPrimary) ret.emit(value) 443 | } else { 444 | if (value.button === buttonIndex) ret.emit(value) 445 | } 446 | }) 447 | 448 | return ret 449 | } 450 | 451 | /** 452 | * @group Filters 453 | */ 454 | get primary() { 455 | return this.button('primary') 456 | } 457 | 458 | /** 459 | * @group Filters 460 | */ 461 | get secondary() { 462 | return this.button('secondary') 463 | } 464 | 465 | /** 466 | * @group Filters 467 | */ 468 | get left() { 469 | return this.button('left') 470 | } 471 | 472 | /** 473 | * @group Filters 474 | */ 475 | get middle() { 476 | return this.button('middle') 477 | } 478 | 479 | /** 480 | * @group Filters 481 | */ 482 | get right() { 483 | return this.button('right') 484 | } 485 | 486 | private static ButtonNameToIndex = new Map([ 487 | ['secondary', 2], 488 | ['left', 0], 489 | ['middle', 1], 490 | ['right', 2], 491 | ]) 492 | 493 | /** 494 | * Creates a emitter that emits only when the pointer type is the given type. 495 | * @param type Pointer type to watch. 496 | * @returns A new emitter. 497 | * @group Filters 498 | */ 499 | @Memoized() 500 | pointerType( 501 | type: 'mouse' | 'pen' | 'touch', 502 | options?: GeneratorOptions 503 | ): PointerEmitter { 504 | const ret = new PointerEmitter(this.#target, { 505 | sources: this, 506 | }) 507 | 508 | this.registerDerived(ret, e => { 509 | if (e.pointerType === type) { 510 | cancelEventBehavior(e, options) 511 | ret.emit(e) 512 | } 513 | }) 514 | 515 | return ret 516 | } 517 | 518 | /** 519 | * @group Filters 520 | */ 521 | get mouse() { 522 | return this.pointerType('mouse') 523 | } 524 | 525 | /** 526 | * @group Filters 527 | */ 528 | get pen() { 529 | return this.pointerType('pen') 530 | } 531 | 532 | /** 533 | * @group Filters 534 | */ 535 | get touch() { 536 | return this.pointerType('touch') 537 | } 538 | 539 | /** 540 | * Creates a generator that emits the scroll delta of the pointer. 541 | * @group Generators 542 | */ 543 | @Memoized() 544 | scroll(options?: GeneratorOptions): Emitter { 545 | const ret = new Emitter({ 546 | onDispose: () => { 547 | this.#target.removeEventListener('wheel', handler as any) 548 | }, 549 | }) 550 | 551 | const handler = (e: WheelEvent) => { 552 | cancelEventBehavior(e, options) 553 | 554 | // NOTE: Exclude pinch gesture on trackpad by checking e.ctrlKey === true, 555 | // but it does not distinghish between pinch and ctrl+wheel. 556 | // https://github.com/pmndrs/use-gesture/discussions/518 557 | if (e.ctrlKey) return 558 | 559 | ret.emit([e.deltaX, e.deltaY]) 560 | } 561 | 562 | this.#target.addEventListener('wheel', handler as any, { 563 | passive: false, 564 | }) 565 | 566 | return ret 567 | } 568 | 569 | /** 570 | * Creates a generator that emits the pinch delta of the pointer. 571 | * @see https://kenneth.io/post/detecting-multi-touch-trackpad-gestures-in-javascript 572 | * @param options 573 | * @returns 574 | * @group Generators 575 | */ 576 | @Memoized() 577 | pinch(options?: GeneratorOptions): Emitter { 578 | const ret = new Emitter({ 579 | onDispose: () => { 580 | this.#target.removeEventListener('wheel', handler as any) 581 | }, 582 | }) 583 | 584 | const handler = (e: WheelEvent) => { 585 | cancelEventBehavior(e, options) 586 | 587 | // NOTE: Exclude pinch gesture on trackpad by checking e.ctrlKey === true, 588 | // but it does not distinghish between pinch and ctrl+wheel. 589 | // https://github.com/pmndrs/use-gesture/discussions/518 590 | if (!e.ctrlKey) return 591 | 592 | ret.emit(e.deltaY) 593 | } 594 | 595 | this.#target.addEventListener('wheel', handler as any, { 596 | passive: false, 597 | }) 598 | 599 | return ret 600 | } 601 | } 602 | 603 | /** 604 | * @group Generators 605 | */ 606 | export const pointer = memoizeFunction( 607 | (target: PointerEmitterTarget) => new PointerEmitter(target) 608 | ) 609 | 610 | /** 611 | * @group Generators 612 | */ 613 | export const mouse = (target: PointerEmitterTarget = document) => 614 | pointer(target).mouse 615 | 616 | /** 617 | * @group Generators 618 | */ 619 | export const pen = (target: PointerEmitterTarget = document) => 620 | pointer(target).pen 621 | 622 | /** 623 | * @group Generators 624 | */ 625 | export const touch = (target: PointerEmitterTarget = document) => 626 | pointer(target).touch 627 | -------------------------------------------------------------------------------- /src/Emitter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | debounce, 3 | DebounceSettings, 4 | identity, 5 | isEqual, 6 | range, 7 | throttle, 8 | ThrottleSettings, 9 | } from 'lodash-es' 10 | 11 | import {addEmitterInstance} from './global.js' 12 | import {Memoized} from './memoize.js' 13 | import {IconSequence} from './types.js' 14 | import {bindMaybe, chainMaybeValue, Maybe} from './utils.js' 15 | 16 | type Lerp = (a: T, b: T, t: number) => T 17 | type Listener = (value: T) => void 18 | 19 | export interface EmitterOptions { 20 | sources?: Emitter | Emitter[] 21 | value?: T 22 | icon?: IconSequence 23 | onDispose?: () => void 24 | onReset?: () => void 25 | } 26 | 27 | export interface GeneratorOptions extends AddEventListenerOptions { 28 | preventDefault?: boolean 29 | stopPropagation?: boolean 30 | } 31 | 32 | /** 33 | * A foundational value of the library, an instance representing a single *event emitter*. This could be user input from a mouse, keyboard, MIDI controller, gamepad etc., or the result of filtering or composing these inputs. Various operations can be attached by method chaining. 34 | * @group Emitters 35 | */ 36 | export class Emitter { 37 | constructor(options: EmitterOptions = {}) { 38 | this.#sources = new Set([options.sources ?? []].flat()) 39 | this.#onDispose = options.onDispose 40 | this.#onReset = options.onReset 41 | this.#value = options.value 42 | this.icon = options.icon 43 | 44 | addEmitterInstance(this) 45 | } 46 | 47 | readonly #listeners = new Set>() 48 | 49 | icon?: IconSequence 50 | 51 | /** 52 | * Stores all emitters that are upstream of the current emitter. 53 | */ 54 | readonly #sources: Set 55 | 56 | /** 57 | * Stores all deviced events and their listeners. They will not be unregistered by `removeAllListeners`. 58 | */ 59 | protected readonly derivedEmitters = new Map>() 60 | 61 | protected _disposed = false 62 | 63 | get disposed() { 64 | return this._disposed 65 | } 66 | 67 | readonly #onDispose?: () => void 68 | 69 | /** 70 | * @internal 71 | */ 72 | registerDerived(emitter: Emitter, listener: Listener) { 73 | this.on(listener) 74 | this.derivedEmitters.set(emitter, listener) 75 | } 76 | 77 | /** 78 | * @internal 79 | */ 80 | createDerived( 81 | options: EmitterOptions & { 82 | propagate: (e: T, emit: (v: U) => void) => void 83 | } 84 | ): Emitter { 85 | const emitter = new Emitter({ 86 | ...options, 87 | sources: this, 88 | }) 89 | 90 | const emit = emitter.emit.bind(emitter) 91 | 92 | const listener = (e: T) => { 93 | options.propagate(e, emit) 94 | } 95 | 96 | this.on(listener) 97 | 98 | this.derivedEmitters.set(emitter, listener) 99 | 100 | return emitter 101 | } 102 | 103 | /** 104 | * @internal 105 | */ 106 | private removeDerivedEmitter(event: Emitter) { 107 | const listener = this.derivedEmitters.get(event) 108 | if (listener) { 109 | this.off(listener) 110 | } 111 | 112 | this.derivedEmitters.delete(event) 113 | } 114 | 115 | /** 116 | * Disposes the emitter immediately and prevent to emit any value in the future 117 | * @group Event Handlers 118 | */ 119 | dispose() { 120 | this.removeAllListeners() 121 | 122 | this.#onDispose?.() 123 | 124 | for (const source of this.#sources) { 125 | source.removeDerivedEmitter(this) 126 | } 127 | 128 | this._disposed = true 129 | } 130 | 131 | readonly #onReset?: () => void 132 | 133 | /** 134 | * Returns `true` if the emitter has a state and can be reset. 135 | * @group Properties 136 | */ 137 | get stateful() { 138 | return !!this.#onReset 139 | } 140 | 141 | /** 142 | * Resets the state of the emitter. 143 | * @group Event Handlers 144 | */ 145 | reset() { 146 | this.#onReset?.() 147 | 148 | for (const derived of this.derivedEmitters.keys()) { 149 | derived.reset() 150 | } 151 | } 152 | 153 | /** 154 | * Stores the last emitted value. 155 | */ 156 | #value: Maybe 157 | 158 | /** 159 | * The latest value emitted from the emitter. If the emitter has never fired before, it just returns `undefined`. 160 | * @group Properties 161 | */ 162 | get value(): T | undefined { 163 | return this.#value 164 | } 165 | 166 | /** 167 | * Adds the `listener` function for the event. 168 | * @param listener The callback function 169 | * @group Event Handlers 170 | */ 171 | on(listener: Listener) { 172 | this.#listeners.add(listener) 173 | return this 174 | } 175 | 176 | /** 177 | * Removes the `listener` function from the event. 178 | * @param listener 179 | * @group Event Handlers 180 | */ 181 | off(listener: Listener) { 182 | this.#listeners.delete(listener) 183 | } 184 | 185 | /** 186 | * Manually emits the event. 187 | * @param value 188 | * @group Event Handlers 189 | */ 190 | emit(value: T) { 191 | this.#value = value 192 | for (const listener of this.#listeners) { 193 | listener(value) 194 | } 195 | } 196 | 197 | /** 198 | * Adds a *one-time* `listener` function for the event 199 | * @param listener 200 | * @group Event Handlers 201 | */ 202 | once(listener: Listener) { 203 | const _listener = (value: T) => { 204 | this.off(_listener) 205 | listener(value) 206 | } 207 | this.on(_listener) 208 | } 209 | 210 | /** 211 | * Removes all listeners. 212 | * @group Event Handlers 213 | */ 214 | removeAllListeners() { 215 | this.#listeners.forEach(listener => this.off(listener)) 216 | } 217 | 218 | /** 219 | * Transforms the payload of event with the given function. 220 | * @param fn A function to transform the payload 221 | * @returns A new emitter 222 | * @group Common Filters 223 | */ 224 | map(fn: (value: T) => U, initialValue?: U): Emitter { 225 | return this.createDerived({ 226 | value: chainMaybeValue(initialValue, bindMaybe(this.#value, fn)), 227 | propagate: (e, emit) => emit(fn(e)), 228 | }) 229 | } 230 | 231 | /** 232 | * Filters events with the given predicate function 233 | * @param fn Return truthy value to pass events 234 | * @returns A new emitter 235 | * @group Common Filters 236 | */ 237 | filter(fn: (value: T) => any): Emitter { 238 | return this.createDerived({ 239 | propagate(e, emit) { 240 | if (fn(e)) { 241 | emit(e) 242 | } 243 | }, 244 | }) 245 | } 246 | 247 | /** 248 | * Maps the current value to another type of value, and emits the mapped value only when the mapped value is not `undefined`. 249 | * @param fn A function to map the current value. Return `undefined` to skip emitting. 250 | * @group Common Filters 251 | */ 252 | filterMap(fn: (value: T) => U | undefined, initialValue?: U): Emitter { 253 | return this.createDerived({ 254 | value: chainMaybeValue(initialValue, bindMaybe(this.#value, fn)), 255 | propagate(e, emit) { 256 | const mapped = fn(e) 257 | if (mapped !== undefined) { 258 | emit(mapped) 259 | } 260 | }, 261 | }) 262 | } 263 | 264 | /** 265 | * Creates an emitter that emits at the moment the current value changes from falsy to truthy. 266 | * @group Common Filters 267 | */ 268 | @Memoized() 269 | down(): Emitter { 270 | const ret = this.fold((prev, curt) => !prev && !!curt, false) 271 | .filter(identity) 272 | .constant(true) 273 | ret.icon = this.icon 274 | return ret 275 | } 276 | 277 | /** 278 | * Creates an emitter that emits at the moment the current value changes from falsy to truthy. 279 | * @group Common Filters 280 | */ 281 | @Memoized() 282 | up(): Emitter { 283 | const ret = this.fold((prev, curt) => !!prev && !curt, true) 284 | .filter(identity) 285 | .constant(true) 286 | ret.icon = this.icon 287 | return ret 288 | } 289 | 290 | /** 291 | * Creates an emitter whose payload is negated. 292 | * @group Common Filters 293 | */ 294 | @Memoized() 295 | not(): Emitter { 296 | return this.map(v => !v, !this.#value) 297 | } 298 | 299 | /** 300 | * Emits only when the value is changed 301 | * @param equalFn A comparator function. The event will be emitted when the function returns falsy value. 302 | * @returns 303 | * @group Common Filters 304 | */ 305 | @Memoized() 306 | change(equalFn: (a: T, b: T) => boolean = isEqual): Emitter { 307 | return this.fold( 308 | (prev, curt) => { 309 | if (prev === undefined || !equalFn(prev, curt)) { 310 | return curt 311 | } else { 312 | return undefined 313 | } 314 | }, 315 | this.#value as unknown as T 316 | ) 317 | } 318 | 319 | /** 320 | * Emits while the given event is truthy. The event will be also emitted when the given emitter is changed from falsy to truthy when the `resetOnDown` flag is set to true. 321 | * @param emitter An emitter to filter the events 322 | * @param resetOnDown If set to `true`, the returned emitter will be reset when the given emitter is down. 323 | * @returns 324 | * @group Common Filters 325 | */ 326 | while(emitter: Emitter, resetOnDown = true) { 327 | const ret = this.createDerived({ 328 | propagate(e, emit) { 329 | if (emitter.value) { 330 | emit(e) 331 | } 332 | }, 333 | }) 334 | 335 | if (resetOnDown) { 336 | emitter.down().on(() => { 337 | ret.reset() 338 | if (this.value !== undefined) { 339 | ret.emit(this.value) 340 | } 341 | }) 342 | } 343 | 344 | return ret 345 | } 346 | 347 | /** 348 | * Splits the current emitter into multiple emitters. Each emitter emits only when the given emitter is changed to the corresponding index. 349 | * @param emitter An emitter to filter the current event 350 | * @param count The number of emitters to be created. 351 | * @param resetOnSwitch If set to `true`, the corresponding emitter will be reset when the index of current emitter is switched. 352 | * @returns 353 | * @group Commom Filters 354 | */ 355 | split(emitter: Emitter, count: number, resetOnSwitch = true) { 356 | const rets = range(0, count).map(i => 357 | this.createDerived({ 358 | propagate(e, emit) { 359 | const index = 360 | typeof emitter.value === 'number' 361 | ? emitter.value 362 | : emitter.value 363 | ? 1 364 | : 0 365 | 366 | if (index === i) { 367 | emit(e) 368 | } 369 | }, 370 | }) 371 | ) 372 | 373 | if (resetOnSwitch) { 374 | emitter 375 | .map(v => (typeof v === 'number' ? v : v ? 1 : 0)) 376 | .change() 377 | .on(i => { 378 | if (this.value === undefined) return 379 | 380 | rets[i].reset() 381 | rets[i].emit(this.value) 382 | }) 383 | } 384 | 385 | return rets 386 | } 387 | 388 | /** 389 | * Creates an emitter that emits a constant value every time the current emitter is emitted. 390 | * @see {@link https://lodash.com/docs/4.17.15#throttle} 391 | * @group Common Filters 392 | */ 393 | @Memoized() 394 | constant(value: U): Emitter { 395 | return this.createDerived({ 396 | value, 397 | propagate(_, emit) { 398 | emit(value) 399 | }, 400 | }) 401 | } 402 | 403 | /** 404 | * Creates throttled version of the current emitter. 405 | * @param wait Milliseconds to wait. 406 | * @param options 407 | * @see {@link https://lodash.com/docs/4.17.15#debounced} 408 | * @group Common Filters 409 | */ 410 | throttle(wait: number, options?: ThrottleSettings): Emitter { 411 | const propagate = throttle( 412 | (value, emit) => { 413 | if (this._disposed) return 414 | emit(value) 415 | }, 416 | wait, 417 | options 418 | ) 419 | 420 | return this.createDerived({ 421 | onDispose() { 422 | propagate.cancel() 423 | }, 424 | propagate, 425 | }) 426 | } 427 | 428 | /** 429 | * Creates debounced version of the current emitter. 430 | * @param wait Milliseconds to wait. 431 | * @param options 432 | * @returns A new emitter 433 | * @group Common Filters 434 | */ 435 | debounce(wait: number, options: DebounceSettings) { 436 | const propagate = debounce( 437 | (value, emit) => { 438 | if (this._disposed) return 439 | emit(value) 440 | }, 441 | wait, 442 | options 443 | ) 444 | 445 | return this.createDerived({ 446 | onDispose() { 447 | propagate.cancel() 448 | }, 449 | propagate, 450 | }) 451 | } 452 | 453 | /** 454 | * Creates delayed version of the current emitter. 455 | * @param wait Milliseconds to wait. 456 | * @param options 457 | * @returns A new emitter 458 | * @group Common Filters 459 | */ 460 | delay(wait: number) { 461 | let timer: NodeJS.Timeout | undefined = undefined 462 | 463 | return this.createDerived({ 464 | onDispose() { 465 | clearTimeout(timer) 466 | }, 467 | propagate(value, emit) { 468 | timer = setTimeout(() => emit(value), wait) 469 | }, 470 | }) 471 | } 472 | 473 | /** 474 | * @group Common Filters 475 | */ 476 | longPress(wait: number) { 477 | let timer: NodeJS.Timeout | undefined = undefined 478 | 479 | const pressed = this.createDerived({ 480 | onDispose() { 481 | clearTimeout(timer) 482 | }, 483 | propagate(value, emit) { 484 | if (value) { 485 | if (!timer) { 486 | timer = setTimeout(() => emit(value), wait) 487 | } 488 | } else { 489 | clearTimeout(timer) 490 | timer = undefined 491 | } 492 | }, 493 | }) 494 | 495 | return {pressed} 496 | } 497 | 498 | /** 499 | * Smoothen the change rate of the input value. 500 | * @param lerp A function to interpolate the current value and the target value. 501 | * @param rate The ratio of linear interpolation from the current value to the target value with each update. 502 | * @param threshold The threshold to determine whether the current value is close enough to the target value. If the difference between the current value and the target value is less than this value, the target value will be used as the current value and the interpolation will be stopped. 503 | * @returns A new emitter 504 | * @group Common Filters 505 | */ 506 | lerp(lerp: Lerp, rate: number, threshold = 1e-4): Emitter { 507 | let t = 1 508 | let start: Maybe, end: Maybe 509 | let curt: Maybe 510 | 511 | const update = () => { 512 | if (start === undefined || end === undefined) { 513 | return 514 | } 515 | 516 | t = 1 - (1 - t) * (1 - rate) 517 | 518 | curt = lerp(start, end, t) 519 | 520 | if (t < 1 - threshold) { 521 | // During lerping 522 | ret.emit(curt) 523 | requestAnimationFrame(update) 524 | } else { 525 | // On almost reached to the target value 526 | ret.emit(end) 527 | t = 1 528 | start = end = undefined 529 | } 530 | } 531 | 532 | const ret = this.createDerived({ 533 | onDispose() { 534 | start = end = undefined 535 | }, 536 | onReset: () => { 537 | curt = end 538 | start = end = undefined 539 | }, 540 | propagate(value) { 541 | const updating = start !== undefined && end !== undefined 542 | 543 | if (curt === undefined) { 544 | curt = value 545 | } 546 | 547 | t = 0 548 | start = curt 549 | end = value 550 | 551 | if (!updating) { 552 | update() 553 | } 554 | }, 555 | }) 556 | 557 | return ret 558 | } 559 | 560 | tween(lerp: Lerp, durationMs: number): Emitter { 561 | let startTime = 0 562 | let start: Maybe, target: Maybe 563 | 564 | const update = () => { 565 | if (start === null || target === null) { 566 | return 567 | } 568 | const elapsed = Date.now() - startTime 569 | const t = elapsed / durationMs 570 | 571 | if (t < 1) { 572 | ret.emit(lerp(start, target, t)) 573 | requestAnimationFrame(update) 574 | } else { 575 | ret.emit(target) 576 | start = target = undefined 577 | } 578 | } 579 | 580 | const ret = this.createDerived({ 581 | onDispose() { 582 | start = target = undefined 583 | }, 584 | onReset: () => { 585 | start = target = undefined 586 | }, 587 | propagate(value) { 588 | const updating = start !== undefined && target !== undefined 589 | 590 | startTime = Date.now() 591 | start = ret.value ?? value 592 | target = value 593 | 594 | if (!updating) { 595 | update() 596 | } 597 | }, 598 | }) 599 | 600 | return ret 601 | } 602 | 603 | /** 604 | * Reset the state of current emitter emitter when the given event is fired. 605 | * @param emitter The emitter that triggers the current emitter to be reset. 606 | * @param emitOnReset If set to `true`, the current emitter will be triggered when it is reset. 607 | * @returns The current emitter emitter 608 | * @group Common Filters 609 | */ 610 | resetBy(emitter: Emitter, emitOnReset = true): Emitter { 611 | const ret = this.createDerived({ 612 | propagate: (e, emit) => emit(e), 613 | }) 614 | 615 | emitter.on(value => { 616 | if (!value) return 617 | ret.reset() 618 | if (emitOnReset && this.value !== undefined) { 619 | ret.emit(this.value) 620 | } 621 | }) 622 | 623 | return ret 624 | } 625 | 626 | /** 627 | * Initializes with an `initialState` value. On each emitted event, calculates a new state based on the previous state and the current value, and emits this new state. 628 | * @param fn A function to calculate a new state 629 | * @param initialState An initial state value 630 | * @returns A new emitter 631 | * @group Common Filters 632 | */ 633 | fold( 634 | fn: (prev: U, value: T) => U | undefined, 635 | initialState: U 636 | ): Emitter { 637 | let state = initialState 638 | 639 | return this.createDerived({ 640 | onReset() { 641 | state = initialState 642 | }, 643 | propagate(value, emit) { 644 | const newState = fn(state, value) 645 | if (newState !== undefined) { 646 | emit(newState) 647 | state = newState 648 | } 649 | }, 650 | }) 651 | } 652 | 653 | /** 654 | * Creates an emitter that emits the current value when one of the given events is fired. 655 | * @param triggers Emitters to trigger the current emitter to emit. 656 | * @returns A new emitter 657 | * @group Common Filters 658 | */ 659 | stash(...triggers: Emitter[]) { 660 | const ret = new Emitter({ 661 | sources: this, 662 | }) 663 | 664 | triggers.forEach(trigger => { 665 | trigger.on(() => { 666 | ret.emit(this.value) 667 | }) 668 | }) 669 | 670 | return ret 671 | } 672 | 673 | /** 674 | * Creates an emitter that emits the ‘difference’ between the current value and the previous value. 675 | * @param fn A function to calculate the difference 676 | * @returns A new emitter 677 | * @group Common Filters 678 | */ 679 | delta(fn: (prev: T, curt: T) => U): Emitter { 680 | let prev: Maybe 681 | 682 | return this.createDerived({ 683 | onReset() { 684 | prev = undefined 685 | }, 686 | propagate(value, emit) { 687 | if (prev !== undefined) { 688 | emit(fn(prev, value)) 689 | } 690 | prev = value 691 | }, 692 | }) 693 | } 694 | 695 | /** 696 | * Creates an emitter that keeps to emit the last value of the current emitter at the specified interval. 697 | * @param ms The interval in milliseconds. Set `0` to use `requestAnimationFrame`. 698 | * @param immediate If set to `false`, the new emitter waits to emit until the current emitter emits any value. 699 | * @returns A new emitter. 700 | * @group Common Filters 701 | */ 702 | interval(ms = 0, immediate = false) { 703 | const ret = new Emitter({ 704 | sources: this, 705 | }) 706 | 707 | const update = () => { 708 | if (this._disposed) return 709 | 710 | ret.emit(this.value) 711 | 712 | if (ms <= 0) { 713 | requestAnimationFrame(update) 714 | } else { 715 | setTimeout(update, ms) 716 | } 717 | } 718 | 719 | if (immediate) { 720 | update() 721 | } else { 722 | if (this.value !== undefined) { 723 | update() 724 | } else { 725 | this.once(update) 726 | } 727 | } 728 | 729 | return ret 730 | } 731 | 732 | /** 733 | * Emits an array caching a specified number of values that were emitted in the past. 734 | * @param count The number of cache frames. Set `0` to store caches infinitely. 735 | * @param emitAtCount When set to `true`, events will not be emitted until the count of cache reaches to `count`. 736 | * @returns 737 | * @group Common Filters 738 | */ 739 | trail(count = 2, emitAtCount = true): Emitter { 740 | let ret = this.fold((prev, value) => { 741 | const arr = [value, ...prev] 742 | 743 | if (count === 0) return arr 744 | return arr.slice(0, count) 745 | }, []) 746 | 747 | if (emitAtCount) { 748 | ret = ret.filter(v => v.length === count) 749 | } 750 | 751 | return ret 752 | } 753 | 754 | /** 755 | * @group Event Handlers 756 | */ 757 | log(message = 'Bndr') { 758 | this.on(value => { 759 | // eslint-disable-next-line no-console 760 | console.log(`[${message}]`, 'Value=', value) 761 | }) 762 | return this 763 | } 764 | } 765 | --------------------------------------------------------------------------------