├── .eslintrc.js ├── .gitattributes ├── .github ├── README.md ├── actions │ └── build │ │ └── action.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ ├── pages.yml │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── jest.config.ts ├── package.json ├── packages ├── pointer-lock-movement-example │ ├── README.md │ ├── hooks │ │ └── useEvent.ts │ ├── index.html │ ├── package.json │ ├── pages │ │ ├── $.mdx │ │ ├── _theme.tsx │ │ ├── assets │ │ │ └── magnifying-glass.png │ │ ├── inputNumber$.tsx │ │ ├── inputNumber.scss │ │ ├── inputNumber2$.tsx │ │ ├── layout.scss │ │ ├── magnifyingGlass$.tsx │ │ └── magnifyingGlass.scss │ └── vite.config.ts └── pointer-lock-movement │ ├── README.md │ ├── package.json │ └── src │ ├── index.ts │ ├── pointer-lock-movement.ts │ ├── shim.d.ts │ └── utils │ ├── requestCursor.ts │ └── requestScreen.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | ecmaFeatures: { 6 | jsx: true, 7 | }, 8 | ecmaVersion: 'latest', 9 | sourceType: 'module', 10 | tsconfigRootDir: __dirname, 11 | project: ['./tsconfig.json'], 12 | }, 13 | env: { 14 | browser: true, 15 | node: true, 16 | }, 17 | ignorePatterns: ['**/node_modules', '**/dist', '*.js'], 18 | extends: [ 19 | 'eslint:recommended', 20 | 'plugin:react/recommended', 21 | 'plugin:react/jsx-runtime', 22 | 'plugin:@typescript-eslint/recommended', 23 | 'plugin:promise/recommended', 24 | 'plugin:react-hooks/recommended', 25 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 26 | ], 27 | plugins: ['react', '@typescript-eslint'], 28 | settings: { 29 | react: { 30 | createClass: 'createReactClass', 31 | pragma: 'React', 32 | fragment: 'Fragment', 33 | version: '16.8', 34 | }, 35 | }, 36 | rules: { 37 | quotes: ['error', 'single'], 38 | 'react/prop-types': 'off', 39 | '@typescript-eslint/no-unused-vars': [ 40 | 'error', 41 | { 42 | varsIgnorePattern: '^_', 43 | }, 44 | ], 45 | '@typescript-eslint/semi': ['error', 'never'], 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.scss linguist-detectable=false 2 | *.css linguist-detectable=false 3 | 4 | -------------------------------------------------------------------------------- /.github/README.md: -------------------------------------------------------------------------------- 1 | # Pointer Lock Movement 2 | 3 | [![NPM](https://nodei.co/npm/pointer-lock-movement.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/pointer-lock-movement/) 4 | 5 | ![publish workflow](https://github.com/zheeeng/pointer-lock-movement/actions/workflows/publish.yml/badge.svg) 6 | ![pages workflow](https://github.com/zheeeng/pointer-lock-movement/actions/workflows/pages.yml/badge.svg) 7 | [![npm version](https://img.shields.io/npm/v/pointer-lock-movement.svg)](https://www.npmjs.com/package/pointer-lock-movement) 8 | 9 | A pointer lock movement manager for customizing your own creative UI. Inspired by [Figma](https://figma.com/)'s number input element: Dragging on an input label and moves a virtual cursor continuously in an infinite looping area and slides the input's figure value. 10 | 11 | ![pointer-lock-movement](https://user-images.githubusercontent.com/1303154/177069380-b92d44c9-73ed-45c6-ba50-d89b381d3b51.png) 12 | 13 | This tool toggles the pointer's lock state when user is interacting with a specific HTML element. Its registered callback is triggered when a mouse/trackPad/other pointing device delivers `PointerEvent` under the pointer-locked state. You can configure its behaviors as you like. 14 | 15 | ## 🧩 Installation 16 | 17 | ```bash 18 | yarn add pointer-lock-movement (or npm/pnpm) 19 | ``` 20 | 21 | ## 👇 Usage 22 | 23 | ```ts 24 | import { isSupportPointerLock, pointerLockMovement } from 'pointer-lock-movement' 25 | 26 | if (isSupportPointerLock()) { 27 | const cleanup = pointerLockMovement(TOGGLE_ELEMENT, OPTIONS); 28 | 29 | REQUEST_TO_DISPOSE_THE_LISTENED_EVENTS_CALLBACK(() => { 30 | cleanup() 31 | }) 32 | } 33 | ``` 34 | 35 | ## 📎 Example 36 | 37 | Enhance your input-number component: 38 | 39 | ```tsx 40 | const [value, setValue] = useState(0); 41 | 42 | const pointerLockerRef = useRef(null) 43 | 44 | useEffect( 45 | () => { 46 | if (!pointerLockerRef.current) { 47 | return 48 | } 49 | 50 | return pointerLockMovement( 51 | pointerLockerRef.current, 52 | { 53 | onMove: evt => setValue(val => val + evt.movementX), 54 | cursor: '⟺', 55 | } 56 | ) 57 | }, 58 | [], 59 | ) 60 | 61 | return ( 62 | 66 | ) 67 | ``` 68 | 69 | See more examples: 70 | 71 | 1. [Input Number](https://pointer-lock-movement.zheeeng.me/#/inputNumber) 72 | 2. [Magnifying Glass](https://pointer-lock-movement.zheeeng.me/#/magnifyingGlass) 73 | 74 | ## 👇 API 75 | 76 | | Name | signature | description | 77 | | ---- | --------- | ----------- | 78 | | __isSupportPointerLock__ | `() => boolean` | predicates pointer lock is supported | 79 | | __pointerLockMovement__ | `(element: Element, option?: PointerLockMovementOption) => () => void` | stars the pointer lock managing for a specific element and returns cleanup function 80 | 81 | ## 📝 Type Definition 82 | 83 | ```ts 84 | type MoveState = { 85 | status: 'moving' | 'stopped', 86 | movementX: number, 87 | movementY: number, 88 | offsetX: number, 89 | offsetY: number, 90 | } 91 | 92 | type PointerLockMovementOption = { 93 | onLock?: (locked: boolean) => void, 94 | onMove?: (event: PointerEvent, moveState: MoveState) => void, 95 | cursor?: string | HTMLElement | Partial, 96 | screen?: DOMRect | HTMLElement | Partial, 97 | zIndex?: number, 98 | loopBehavior?: 'loop' | 'stop' | 'infinite', 99 | trigger?: 'drag' | 'toggle', 100 | dragOffset?: number, 101 | disableOnActiveElement?: number, 102 | } 103 | ``` 104 | 105 | * `onLock` registers callback to listen locking state changing 106 | * `onMove` registers callback to listen pointer movement, it carries the corresponding event and the moving state. If the `loopBehavior` is configured to `stop` and the virtual cursor reached the edge of the screen, the `moveState.status` will be read as `stopped`. 107 | * `cursor` is used as the virtual cursor. By default, the cursor is an empty DIV element: 108 | * if it is a string, it will be used as the cursor's text content, 109 | * if it is an `HTMLElement`, it will be used as the virtual cursor, 110 | * if it is an object with a snake-case property names, it will be applied as the cursor's CSS style. 111 | * `screen` is used as the virtual screen, it usually defines the edges of the virtual cursor. By default, we count the edges of the browser's viewport. 112 | * if it is a DOMRect, it will be assumed as the size and position information of the virtual screen, 113 | * if it is an HTMLElement, it will be rendered into the DOM structure, 114 | * if it is an object with a snake-case property name, it will be regarded as the CSS style and render a virtual screen element with this style. 115 | * `zIndex` is used as the z-index CSS property of the virtual cursor/screen with the default value `99999`, it is useful when there are other elements over it. 116 | * `loopBehavior` is used to control the behavior of the virtual cursor when it reaches the edge of the screen. By default, it is `loop`. 117 | * `loop`: the virtual cursor will be moved to the other side of the screen 118 | * `stop`: the virtual cursor will be stopped at the edge of the screen 119 | * `infinite`: the virtual cursor will be moved out of the screen 120 | * `trigger` is used to control the triggering way of the virtual cursor. By default, it is `drag`. 121 | * `drag`: the virtual cursor movement will be toggled by pointer-down and pointer-up events. 122 | * `toggle`: the virtual cursor movement will be toggled by pointer events. 123 | * `dragOffset` prevent invoking the pointer locker immediately until your pointer moves over the offset pixels. 124 | * `disableOnActiveElement` prevent pointer locking on active element. e.g. After attaching this feature on an input element, you may wish to select text range while it got focus. 125 | -------------------------------------------------------------------------------- /.github/actions/build/action.yml: -------------------------------------------------------------------------------- 1 | name: Build PNPM Project 2 | description: Install PNPM dependencies and execute build scripts 3 | inputs: 4 | node-version: 5 | description: 'Specify the node version, defaults to 14.x' 6 | default: '14.x' 7 | runs: 8 | using: composite 9 | steps: 10 | - name: Setup Node 11 | uses: actions/setup-node@v2 12 | with: 13 | node-version: ${{ inputs.node-version }} 14 | 15 | - name: Cache PNPM modules 16 | uses: actions/cache@v2 17 | with: 18 | path: ~/.pnpm-store 19 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 20 | restore-keys: | 21 | ${{ runner.os }}- 22 | 23 | - name: Setup PNPM 24 | uses: pnpm/action-setup@v2.1.0 25 | with: 26 | version: 6 27 | run_install: true 28 | - name: Build 29 | run: pnpm build 30 | shell: bash 31 | 32 | - name: Lint 33 | run: pnpm lint 34 | shell: bash 35 | 36 | - name: TEST 37 | run: pnpm test 38 | shell: bash 39 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | - push 5 | - pull_request 6 | - workflow_call 7 | 8 | jobs: 9 | install-build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Build 17 | uses: ./.github/actions/build -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '17 21 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | pages: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - name: Build 16 | uses: ./.github/actions/build 17 | 18 | - name: Deploy 19 | uses: crazy-max/ghaction-github-pages@v2 20 | with: 21 | target_branch: gh-pages 22 | build_dir: packages/pointer-lock-movement-example/dist 23 | fqdn: pointer-lock-movement.zheeeng.me 24 | author: Zheeeng 25 | jekyll: false 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | if: github.repository == 'zheeeng/pointer-lock-movement' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Build 17 | uses: ./.github/actions/build 18 | 19 | - name: Publish 20 | uses: JS-DevTools/npm-publish@v1 21 | with: 22 | token: ${{ secrets.NPM_TOKEN }} 23 | package: ./packages/pointer-lock-movement/package.json 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | lib 5 | *.local 6 | *.tgz 7 | .pnpm-debug.log 8 | .vscode 9 | coverage -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Zheeeng 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 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types' 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testMatch: ['**/*.spec.{ts,tsx}'], 6 | testEnvironment: 'jsdom', 7 | collectCoverage: true, 8 | } 9 | 10 | export default config -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Zheeeng ", 3 | "private": false, 4 | "scripts": { 5 | "test": "jest --passWithNoTests", 6 | "dev": "npm-run-all --parallel dev:*", 7 | "dev:lib": "pnpm --filter pointer-lock-movement dev", 8 | "dev:example": "pnpm --filter pointer-lock-movement-example dev", 9 | "build": "pnpm build:lib && pnpm build:example", 10 | "build:lib": "pnpm --filter pointer-lock-movement build", 11 | "build:example": "pnpm --filter pointer-lock-movement-example build", 12 | "lint": "eslint --ext .ts,.tsx .", 13 | "lint:fix": "eslint --fix --ext .ts,.tsx ." 14 | }, 15 | "devDependencies": { 16 | "@mdx-js/mdx": "^1.6.22", 17 | "@mdx-js/react": "^1.6.22", 18 | "@types/jest": "^28.1.8", 19 | "@types/node": "^18.17.0", 20 | "@typescript-eslint/eslint-plugin": "^5.62.0", 21 | "@typescript-eslint/parser": "^5.62.0", 22 | "eslint": "^8.45.0", 23 | "eslint-plugin-promise": "^6.1.1", 24 | "eslint-plugin-react": "^7.33.1", 25 | "eslint-plugin-react-hooks": "^4.6.0", 26 | "jest": "^28.1.3", 27 | "jest-environment-jsdom": "^28.1.3", 28 | "npm-run-all": "^4.1.5", 29 | "pnpm": "^7.33.5", 30 | "ts-jest": "^28.0.8", 31 | "ts-node": "^10.9.2", 32 | "typescript": "^4.9.5" 33 | }, 34 | "engines": { 35 | "node": ">=14" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/README.md: -------------------------------------------------------------------------------- 1 | ../pointer-lock-movement/README.md -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/hooks/useEvent.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect, useRef } from 'react' 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export const useEvent = unknown>(handler: F): F => { 5 | const handlerRef = useRef(null) 6 | 7 | useLayoutEffect(() => { 8 | handlerRef.current = handler 9 | }) 10 | 11 | // eslint-disable-next-line react-hooks/exhaustive-deps 12 | return useCallback(((...args: Parameters) => handlerRef.current?.(...args)) as F, []) 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 🔒 Pointer Lock Movement 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pointer-lock-movement-example", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vite serve", 6 | "build": "rm -rf dist && vite build --outDir dist" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "pointer-lock-movement": "workspace:*", 13 | "react": "^17.0.2", 14 | "react-dom": "^17.0.2", 15 | "react-router-dom": "^5.3.4", 16 | "styled-css-base": "^0.0.10" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^18.17.0", 20 | "@types/react": "^17.0.62", 21 | "@types/react-router-dom": "^5.3.3", 22 | "@vitejs/plugin-react": "^1.3.2", 23 | "sass": "^1.64.1", 24 | "serve": "^13.0.4", 25 | "vite": "^2.9.16", 26 | "vite-pages-theme-doc": "^3.4.0", 27 | "vite-plugin-mdx": "^3.6.0", 28 | "vite-plugin-react-pages": "^3.3.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/$.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | --- 4 | 5 | import README from 'pointer-lock-movement/README.md' 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/_theme.tsx: -------------------------------------------------------------------------------- 1 | import { createTheme } from 'vite-pages-theme-doc' 2 | 3 | export default createTheme({ 4 | logo: '🔒 Pointer Lock Movement', 5 | topNavs: [ 6 | { 7 | label: 'Source of examples', 8 | href: 'https://github.com/zheeeng/pointer-lock-movement/tree/main/packages/pointer-lock-movement-example/pages', 9 | }, 10 | { 11 | label: 'Github ⭐', 12 | href: 'https://github.com/zheeeng/pointer-lock-movement', 13 | }, 14 | ], 15 | }) 16 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/assets/magnifying-glass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zheeeng/pointer-lock-movement/c92f42024f2e25304e6021db6443a7220bc75bc9/packages/pointer-lock-movement-example/pages/assets/magnifying-glass.png -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/inputNumber$.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @title Input Number 3 | */ 4 | 5 | import './layout.scss' 6 | 7 | import React, { useEffect, useState, useRef } from 'react' 8 | import { pointerLockMovement, type PointerLockMovementOption } from 'pointer-lock-movement' 9 | import './inputNumber.scss' 10 | import { useEvent } from '../hooks/useEvent' 11 | 12 | const resizeHandler = '⟺' 13 | 14 | type InputNumberProps = { 15 | value?: number, 16 | onChange?: (value: number) => void, 17 | } 18 | & Pick 19 | & React.HTMLAttributes 20 | 21 | const InputNumber = React.memo( 22 | function InputNumber ({ value = 0, onChange, cursor, className, loopBehavior, trigger, ...labelProps }) { 23 | const [typingValue, setTypingValue] = useState(() => value.toString()) 24 | const [localValue, setLocalValue] = useState(255) 25 | 26 | useEffect( 27 | () => { 28 | setLocalValue(value) 29 | setTypingValue(value.toString()) 30 | }, 31 | [value] 32 | ) 33 | 34 | const handleInputChange = useEvent( 35 | (e: React.ChangeEvent) => { 36 | setTypingValue(e.currentTarget.value) 37 | }, 38 | ) 39 | 40 | const handleInputKeyDown = useEvent( 41 | (e: React.KeyboardEvent) => { 42 | if (e.key === 'Enter') { 43 | e.currentTarget.blur() 44 | } 45 | }, 46 | ) 47 | 48 | const handleInputBlur = useEvent( 49 | (e: React.FocusEvent) => { 50 | const newValue = +e.currentTarget.value 51 | 52 | if (!isNaN(newValue)) { 53 | setLocalValue(newValue) 54 | onChange?.(value) 55 | } else { 56 | setLocalValue(localValue) 57 | setTypingValue(localValue.toString()) 58 | } 59 | }, 60 | ) 61 | 62 | const handlePointerLockChange = useEvent( 63 | (lock: boolean) => { 64 | if (!lock) { 65 | setLocalValue(+typingValue) 66 | } 67 | }, 68 | ) 69 | 70 | const handlePointerLockMovement = useEvent( 71 | (event: PointerEvent) => { 72 | setTypingValue((+typingValue + event.movementX).toString()) 73 | } 74 | ) 75 | 76 | const pointerLockerRef = useRef(null) 77 | 78 | useEffect( 79 | () => { 80 | if (!pointerLockerRef.current) { 81 | return 82 | } 83 | 84 | return pointerLockMovement( 85 | pointerLockerRef.current, 86 | { 87 | onLock: handlePointerLockChange, 88 | onMove: handlePointerLockMovement, 89 | cursor, 90 | loopBehavior, 91 | trigger, 92 | } 93 | ) 94 | }, 95 | [handlePointerLockChange, handlePointerLockMovement, cursor, loopBehavior, trigger], 96 | ) 97 | 98 | 99 | return ( 100 | 106 | ) 107 | } 108 | ) 109 | 110 | const customInputContent = '⭐️' 111 | 112 | const createCustomCursorElement = () => { 113 | const githubMonaLoading = document.createElement('img') 114 | githubMonaLoading.src = 'https://github.githubassets.com/images/mona-loading-dimmed.gif' 115 | githubMonaLoading.width = 20 116 | githubMonaLoading.height = 20 117 | return githubMonaLoading 118 | } 119 | 120 | const customCursorElement = createCustomCursorElement() 121 | const customCursorElementCode = `(${createCustomCursorElement.toString()})()` 122 | 123 | const customCursorElementStyle = { 124 | color: 'indianred', 125 | border: '1px dashed indianred', 126 | borderRadius: '50%', 127 | width: '32px', 128 | height: '32px', 129 | } 130 | 131 | const Example = () => { 132 | const [loopBehavior, setLoopBehavior] = useState<'loop' | 'stop' | 'infinite'>() 133 | const [trigger, setTrigger] = useState<'drag' | 'toggle'>() 134 | 135 | const handleLoopBehaviorChange = useEvent( 136 | (event: React.ChangeEvent) => { 137 | if (event.target.value === '(empty)') { 138 | setLoopBehavior(undefined) 139 | } else { 140 | setLoopBehavior(event.target.value as 'loop' | 'stop' | 'infinite') 141 | } 142 | }, 143 | ) 144 | 145 | const handleTriggerChange = useEvent( 146 | (event: React.ChangeEvent) => { 147 | if (event.target.value === '(empty)') { 148 | setTrigger(undefined) 149 | } else { 150 | setTrigger(event.target.value as 'drag' | 'toggle') 151 | } 152 | }, 153 | ) 154 | 155 | return ( 156 |
157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 208 | 209 | 210 |
VariantExampleContent
Raw
{JSON.stringify(resizeHandler)}
Custom cursor content
{JSON.stringify(customInputContent)}
Custom cursor element
{customCursorElementCode}
Custom cursor CSS
{JSON.stringify(customCursorElementStyle, null, 4)}
190 | 199 | 207 |
211 |
212 | ) 213 | } 214 | 215 | export default Example -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/inputNumber.scss: -------------------------------------------------------------------------------- 1 | label.inputNumber { 2 | border: 1px solid darkgray; 3 | height: 40px; 4 | user-select: none; 5 | display: inline-flex; 6 | border-radius: 4px; 7 | overflow: hidden; 8 | 9 | &:hover { 10 | border-color: rgba($color: darkgray, $alpha: 0.8); 11 | box-shadow: 0 0 0 1px rgba($color: darkgray, $alpha: 0.8); 12 | } 13 | 14 | div { 15 | width: 40px; 16 | height: 100%; 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | background: darkgray; 21 | border-right: 1px solid darkgray; 22 | cursor: ew-resize; 23 | 24 | i { 25 | position: absolute; 26 | top: 0; right: 0; bottom: 0; left: 0; 27 | margin: auto; 28 | } 29 | } 30 | 31 | input { 32 | width: 120px; 33 | padding: 8px 16px; 34 | text-align: center; 35 | border: none; 36 | outline: none; 37 | margin-left: 0; 38 | } 39 | } -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/inputNumber2$.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @title Input Number 2 3 | */ 4 | 5 | import './layout.scss' 6 | 7 | import React, { useEffect, useState, useRef } from 'react' 8 | import { pointerLockMovement, type PointerLockMovementOption } from 'pointer-lock-movement' 9 | import './inputNumber.scss' 10 | import { useEvent } from '../hooks/useEvent' 11 | 12 | type InputNumberProps = { 13 | value?: number, 14 | onChange?: (value: number) => void, 15 | dragOffset?: number, 16 | } 17 | & Pick 18 | & React.HTMLAttributes 19 | 20 | const InputNumber = React.memo( 21 | function InputNumber ({ value = 0, onChange, trigger, dragOffset, disableOnActiveElement, className, ...labelProps }) { 22 | const [typingValue, setTypingValue] = useState(() => value.toString()) 23 | const [localValue, setLocalValue] = useState(255) 24 | 25 | useEffect( 26 | () => { 27 | setLocalValue(value) 28 | setTypingValue(value.toString()) 29 | }, 30 | [value] 31 | ) 32 | 33 | const handleInputChange = useEvent( 34 | (e: React.ChangeEvent) => { 35 | setTypingValue(e.currentTarget.value) 36 | }, 37 | ) 38 | 39 | const handleInputKeyDown = useEvent( 40 | (e: React.KeyboardEvent) => { 41 | if (e.key === 'Enter') { 42 | e.currentTarget.blur() 43 | } 44 | }, 45 | ) 46 | 47 | const handleInputBlur = useEvent( 48 | (e: React.FocusEvent) => { 49 | const newValue = +e.currentTarget.value 50 | 51 | if (!isNaN(newValue)) { 52 | setLocalValue(newValue) 53 | onChange?.(value) 54 | } else { 55 | setLocalValue(localValue) 56 | setTypingValue(localValue.toString()) 57 | } 58 | }, 59 | ) 60 | 61 | const handlePrepareLock = useEvent( 62 | (e: PointerEvent) => { 63 | e.preventDefault() 64 | } 65 | ) 66 | 67 | const handleCancelPrepareLock = useEvent( 68 | () => { 69 | pointerLockerRef.current?.focus() 70 | }) 71 | 72 | const handlePointerLockChange = useEvent( 73 | (lock: boolean) => { 74 | if (!lock) { 75 | setLocalValue(+typingValue) 76 | } 77 | }, 78 | ) 79 | 80 | const handlePointerLockMovement = useEvent( 81 | (event: PointerEvent) => { 82 | setTypingValue((+typingValue + event.movementX).toString()) 83 | } 84 | ) 85 | 86 | const pointerLockerRef = useRef(null) 87 | 88 | useEffect( 89 | () => { 90 | if (!pointerLockerRef.current) { 91 | return 92 | } 93 | 94 | return pointerLockMovement( 95 | pointerLockerRef.current, 96 | { 97 | onPrepareLock: handlePrepareLock, 98 | onCancelPrepareLock: handleCancelPrepareLock, 99 | onLock: handlePointerLockChange, 100 | onMove: handlePointerLockMovement, 101 | cursor: '⟺', 102 | trigger, 103 | dragOffset, 104 | disableOnActiveElement, 105 | } 106 | ) 107 | }, 108 | [handlePointerLockChange, handlePointerLockMovement, dragOffset, trigger, disableOnActiveElement, handlePrepareLock, handleCancelPrepareLock], 109 | ) 110 | 111 | 112 | return ( 113 | 116 | ) 117 | } 118 | ) 119 | 120 | const Example = () => { 121 | const [dragOffset, setDragOffset] = useState(10) 122 | const [trigger, setTrigger] = useState<'drag' | 'toggle'>() 123 | const [disableOnActiveElement, setDisableOnActiveElement] = useState(true) 124 | 125 | const handleDragOffsetChange = useEvent( 126 | (event: React.ChangeEvent) => setDragOffset(Math.max(Math.min(parseInt(event.target.value) ?? 10, 100), 0)) 127 | ) 128 | 129 | const handleTriggerChange = useEvent( 130 | (event: React.ChangeEvent) => { 131 | if (event.target.value === '(empty)') { 132 | setTrigger(undefined) 133 | } else { 134 | setTrigger(event.target.value as 'drag' | 'toggle') 135 | } 136 | }, 137 | ) 138 | 139 | const handleDisableOnActiveElementChange = useEvent( 140 | (event: React.ChangeEvent) => setDisableOnActiveElement(event.target.checked) 141 | ) 142 | 143 | return ( 144 |
145 |
146 | 150 |
151 | 152 |
153 | 154 |
155 | 163 |
164 | 165 |
166 | 167 |
168 | 172 |
173 | 174 |
175 | 176 |
177 |

Example

178 |
179 | 180 |
181 | 182 |
183 | ) 184 | } 185 | 186 | export default Example -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/layout.scss: -------------------------------------------------------------------------------- 1 | .layout { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | flex-direction: column; 9 | 10 | > * { 11 | margin-bottom: 20px; 12 | } 13 | 14 | table { 15 | width: 500px; 16 | } 17 | 18 | pre { 19 | padding: 8px; 20 | background-color: whitesmoke; 21 | white-space: pre-wrap; 22 | } 23 | 24 | th, td { 25 | border: 1px solid black; 26 | padding: 8px; 27 | } 28 | 29 | td label { 30 | margin-right: 20px; 31 | } 32 | 33 | .control-panel { 34 | position: absolute; 35 | right: 0; 36 | top: 0; 37 | padding: 20px; 38 | 39 | pre { 40 | opacity: 0.5; 41 | &:hover { 42 | opacity: 1; 43 | } 44 | } 45 | } 46 | } 47 | 48 | .simple-style { 49 | @import "styled-css-base/presets/simple/index"; 50 | } -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/magnifyingGlass$.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @title Magnifying Glass 3 | */ 4 | 5 | import './layout.scss' 6 | 7 | import React, { useEffect, useState, useRef } from 'react' 8 | import type { PointerLockMovementOption } from 'pointer-lock-movement' 9 | import { pointerLockMovement } from 'pointer-lock-movement' 10 | import './magnifyingGlass.scss' 11 | import { useEvent } from '../hooks/useEvent' 12 | 13 | const interestImageSize = 800 14 | const magnifyingGlassImageSize = 270 15 | 16 | type MagnifyingGlassProps = { 17 | interest: string, 18 | showHiddenPart: boolean, 19 | } 20 | & Pick 21 | & React.HTMLAttributes 22 | 23 | const MagnifyingGlass = React.memo( 24 | function MagnifyingGlass ({ interest, showHiddenPart, screen, loopBehavior, trigger }) { 25 | const imgRef = useRef(null) 26 | 27 | const [{ x, y }, setPosition] = useState({ x: 0, y: 0 }) 28 | 29 | const handlePointerLockMovement = useEvent( 30 | (event: MouseEvent) => { 31 | setPosition({ 32 | x: Math.max(magnifyingGlassImageSize - interestImageSize, Math.min(x - event.movementX, 0)), 33 | y: Math.max(magnifyingGlassImageSize - interestImageSize, Math.min(y - event.movementY, 0)), 34 | }) 35 | } 36 | ) 37 | 38 | useEffect( 39 | () => { 40 | if (!imgRef.current) { 41 | return 42 | } 43 | 44 | return pointerLockMovement( 45 | imgRef.current, 46 | { 47 | cursor: '🐱', 48 | screen, 49 | onMove: handlePointerLockMovement, 50 | loopBehavior, 51 | trigger, 52 | } 53 | ) 54 | }, 55 | [handlePointerLockMovement, loopBehavior, screen, trigger], 56 | ) 57 | 58 | return ( 59 |
60 |
61 | 67 |
68 |
69 |
Find out the {interest}
70 |
71 |
72 | ) 73 | } 74 | ) 75 | 76 | const createCustomScreenDomRect = () => { 77 | const domRect = new DOMRect() 78 | 79 | domRect.width = 750 80 | domRect.height = 750 81 | domRect.x = 150 82 | domRect.y = 150 83 | 84 | return domRect 85 | } 86 | 87 | const customScreenDomRect = createCustomScreenDomRect() 88 | const customScreenDomRectCode = `(${createCustomScreenDomRect.toString()})()` 89 | 90 | const createCustomScreenElement = () => { 91 | const htmlElement = document.createElement('div') 92 | htmlElement.style.width = '800px' 93 | htmlElement.style.height = '800px' 94 | htmlElement.style.border = '1px double blueviolet' 95 | return htmlElement 96 | } 97 | const customScreenElement = createCustomScreenElement() 98 | const customScreenElementCode = `(${createCustomScreenElement.toString()})()` 99 | 100 | const customScreenCSS = { 101 | color: 'indianred', 102 | border: '1px dashed indianred', 103 | width: '80%', 104 | height: '80%', 105 | } 106 | 107 | const Example = () => { 108 | const [interest, setInterest] = useState('cat') 109 | 110 | const [showHiddenPart, setShowHiddenPart] = useState(false) 111 | 112 | const handleShowHiddenPartChange = useEvent( 113 | (event: React.ChangeEvent) => { 114 | setShowHiddenPart(event.target.checked) 115 | } 116 | ) 117 | 118 | const handleKeydown = useEvent( 119 | (e: React.KeyboardEvent) => { 120 | if (e.key === 'Enter') { 121 | setInterest(e.currentTarget.value) 122 | } 123 | } 124 | ) 125 | 126 | const [loopBehavior, setLoopBehavior] = useState<'loop' | 'stop' | 'infinite'>() 127 | const [trigger, setTrigger] = useState<'drag' | 'toggle'>() 128 | const [screen, setScreen] = useState<'DOMRect' | 'HTMLElement' | 'CSS'>() 129 | const [screenConfig, setScreenConfig] = useState>() 130 | const [screenDetail, setScreenDetail] = useState('') 131 | 132 | const handleLoopBehaviorChange = useEvent( 133 | (event: React.ChangeEvent) => { 134 | if (event.target.value === '(empty)') { 135 | setLoopBehavior(undefined) 136 | } else { 137 | setLoopBehavior(event.target.value as 'loop' | 'stop' | 'infinite') 138 | } 139 | }, 140 | ) 141 | 142 | const handleTriggerChange = useEvent( 143 | (event: React.ChangeEvent) => { 144 | if (event.target.value === '(empty)') { 145 | setTrigger(undefined) 146 | } else { 147 | setTrigger(event.target.value as 'drag' | 'toggle') 148 | } 149 | }, 150 | ) 151 | 152 | const handleScreenChange = useEvent( 153 | (event: React.ChangeEvent) => { 154 | if (event.target.value === '(empty)') { 155 | setScreen(undefined) 156 | setScreenDetail('') 157 | } else { 158 | const newScreenType = event.target.value as 'DOMRect' | 'HTMLElement' | 'CSS' 159 | setScreen(newScreenType) 160 | switch (newScreenType) { 161 | case 'DOMRect': { 162 | setScreenConfig(customScreenDomRect) 163 | setScreenDetail(customScreenDomRectCode) 164 | break 165 | } 166 | case 'HTMLElement': { 167 | setScreenConfig(customScreenElement) 168 | setScreenDetail(customScreenElementCode) 169 | break 170 | } 171 | case 'CSS': { 172 | setScreenConfig(customScreenCSS) 173 | setScreenDetail(JSON.stringify(customScreenCSS, null, 4)) 174 | break 175 | } 176 | } 177 | } 178 | }, 179 | ) 180 | 181 | return ( 182 |
183 |
184 |
185 | 189 | 193 |
194 |
195 | 204 | 212 | 221 |
222 | {screenDetail && ( 223 |
224 |                         {screenDetail}
225 |                     
) 226 | } 227 |
228 | 229 | 233 |
234 | ) 235 | } 236 | 237 | export default Example -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/pages/magnifyingGlass.scss: -------------------------------------------------------------------------------- 1 | .magnifyingGlass { 2 | position: relative; 3 | margin-top: -640px; 4 | margin-left: -640px; 5 | 6 | .content { 7 | position: absolute; 8 | top: 150px; 9 | left: 50px; 10 | width: 270px; 11 | height: 270px; 12 | border-radius: 50%; 13 | overflow: hidden; 14 | } 15 | 16 | .image { 17 | height: 800px !important; 18 | width: 800px !important; 19 | max-width: unset !important; 20 | max-height: unset !important; 21 | cursor: all-scroll; 22 | } 23 | 24 | .glass { 25 | position: absolute; 26 | top: 0; 27 | left: 0; 28 | width: 640px; 29 | height: 640px; 30 | background-image: url(./assets/magnifying-glass.png); 31 | background-position: center; 32 | background-repeat: no-repeat; 33 | background-size: contain; 34 | pointer-events: none; 35 | 36 | 37 | $body: #e0c34c; 38 | $stroke: #111827; 39 | $shadow: #db277897; 40 | 41 | .findOut { 42 | margin-top: 100%; 43 | font-size: 3em; 44 | font-weight: bold; 45 | letter-spacing: 10px; 46 | color: $body; 47 | text-shadow: -2px 0 $stroke, 0 -2px $stroke, 2px 0 $stroke, 0 2px $stroke, 48 | 2px 2px $stroke, -2px -2px $stroke, -2px 2px $stroke, 2px -2px $stroke, 49 | 6px 6px $shadow; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement-example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import * as path from 'path' 3 | import react from '@vitejs/plugin-react' 4 | import mdx from 'vite-plugin-mdx' 5 | import pages from 'vite-plugin-react-pages' 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | mdx(), 11 | pages({ 12 | pagesDir: path.join(__dirname, 'pages'), 13 | useHashRouter: true, 14 | }), 15 | ], 16 | }) 17 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement/README.md: -------------------------------------------------------------------------------- 1 | # Pointer Lock Movement 2 | 3 | [![NPM](https://nodei.co/npm/pointer-lock-movement.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/pointer-lock-movement/) 4 | 5 | ![publish workflow](https://github.com/zheeeng/pointer-lock-movement/actions/workflows/publish.yml/badge.svg) 6 | ![pages workflow](https://github.com/zheeeng/pointer-lock-movement/actions/workflows/pages.yml/badge.svg) 7 | [![npm version](https://img.shields.io/npm/v/pointer-lock-movement.svg)](https://www.npmjs.com/package/pointer-lock-movement) 8 | 9 | A pointer lock movement manager for customizing your own creative UI. Inspired by [Figma](https://figma.com/)'s number input element: Dragging on an input label and moves a virtual cursor continuously in an infinite looping area and slides the input's figure value. 10 | 11 | ![pointer-lock-movement](https://user-images.githubusercontent.com/1303154/177069380-b92d44c9-73ed-45c6-ba50-d89b381d3b51.png) 12 | 13 | This tool toggles the pointer's lock state when user is interacting with a specific HTML element. Its registered callback is triggered when a mouse/trackPad/other pointing device delivers `PointerEvent` under the pointer-locked state. You can configure its behaviors as you like. 14 | 15 | ## 🧩 Installation 16 | 17 | ```bash 18 | yarn add pointer-lock-movement (or npm/pnpm) 19 | ``` 20 | 21 | ## 👇 Usage 22 | 23 | ```ts 24 | import { isSupportPointerLock, pointerLockMovement } from 'pointer-lock-movement' 25 | 26 | if (isSupportPointerLock()) { 27 | const cleanup = pointerLockMovement(TOGGLE_ELEMENT, OPTIONS); 28 | 29 | REQUEST_TO_DISPOSE_THE_LISTENED_EVENTS_CALLBACK(() => { 30 | cleanup() 31 | }) 32 | } 33 | ``` 34 | 35 | ## 📎 Example 36 | 37 | Enhance your input-number component: 38 | 39 | ```tsx 40 | const [value, setValue] = useState(0); 41 | 42 | const pointerLockerRef = useRef(null) 43 | 44 | useEffect( 45 | () => { 46 | if (!pointerLockerRef.current) { 47 | return 48 | } 49 | 50 | return pointerLockMovement( 51 | pointerLockerRef.current, 52 | { 53 | onMove: evt => setValue(val => val + evt.movementX), 54 | cursor: '⟺', 55 | } 56 | ) 57 | }, 58 | [], 59 | ) 60 | 61 | return ( 62 | 66 | ) 67 | ``` 68 | 69 | See more examples: 70 | 71 | 1. [Input Number](https://pointer-lock-movement.zheeeng.me/#/inputNumber) 72 | 2. [Magnifying Glass](https://pointer-lock-movement.zheeeng.me/#/magnifyingGlass) 73 | 74 | ## 👇 API 75 | 76 | | Name | signature | description | 77 | | ---- | --------- | ----------- | 78 | | __isSupportPointerLock__ | `() => boolean` | predicates pointer lock is supported | 79 | | __pointerLockMovement__ | `(element: Element, option?: PointerLockMovementOption) => () => void` | stars the pointer lock managing for a specific element and returns cleanup function 80 | 81 | ## 📝 Type Definition 82 | 83 | ```ts 84 | type MoveState = { 85 | status: 'moving' | 'stopped', 86 | movementX: number, 87 | movementY: number, 88 | offsetX: number, 89 | offsetY: number, 90 | } 91 | 92 | type PointerLockMovementOption = { 93 | onLock?: (locked: boolean) => void, 94 | onPrepareLock?: (event: PointerEvent) => void, 95 | onCancelPrepareLock?: (event: PointerEvent) => void, 96 | onMove?: (event: PointerEvent, moveState: MoveState) => void, 97 | cursor?: string | HTMLElement | Partial, 98 | screen?: DOMRect | HTMLElement | Partial, 99 | zIndex?: number, 100 | loopBehavior?: 'loop' | 'stop' | 'infinite', 101 | trigger?: 'drag' | 'toggle', 102 | dragOffset?: number, 103 | disableOnActiveElement?: number, 104 | } 105 | ``` 106 | 107 | * `onLock` registers callback to listen locking state changing 108 | * `onPrepareLock` registers callback to listen detecting drag offset 109 | * `onCancelPrepareLock` registers callback to listen canceling requesting locker, it triggers on drag movement offset doesn't reach the passed option `dragOffset`. 110 | * `onMove` registers callback to listen pointer movement, it carries the corresponding event and the moving state. If the `loopBehavior` is configured to `stop` and the virtual cursor reached the edge of the screen, the `moveState.status` will be read as `stopped`. 111 | * `cursor` is used as the virtual cursor. By default, the cursor is an empty DIV element: 112 | * if it is a string, it will be used as the cursor's text content, 113 | * if it is an `HTMLElement`, it will be used as the virtual cursor, 114 | * if it is an object with a snake-case property names, it will be applied as the cursor's CSS style. 115 | * `screen` is used as the virtual screen, it usually defines the edges of the virtual cursor. By default, we count the edges of the browser's viewport. 116 | * if it is a DOMRect, it will be assumed as the size and position information of the virtual screen, 117 | * if it is an HTMLElement, it will be rendered into the DOM structure, 118 | * if it is an object with a snake-case property name, it will be regarded as the CSS style and render a virtual screen element with this style. 119 | * `zIndex` is used as the z-index CSS property of the virtual cursor/screen with the default value `99999`, it is useful when there are other elements over it. 120 | * `loopBehavior` is used to control the behavior of the virtual cursor when it reaches the edge of the screen. By default, it is `loop`. 121 | * `loop`: the virtual cursor will be moved to the other side of the screen 122 | * `stop`: the virtual cursor will be stopped at the edge of the screen 123 | * `infinite`: the virtual cursor will be moved out of the screen 124 | * `trigger` is used to control the triggering way of the virtual cursor. By default, it is `drag`. 125 | * `drag`: the virtual cursor movement will be toggled by pointer-down and pointer-up events. 126 | * `toggle`: the virtual cursor movement will be toggled by pointer events. 127 | * `dragOffset` prevent invoking the pointer locker immediately until your pointer moves over the offset pixels. 128 | * `disableOnActiveElement` prevent pointer locking on active element. e.g. After attaching this feature on an input element, you may wish to select text range while it got focus. **It only works for `drag` trigger.** 129 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pointer-lock-movement", 3 | "version": "0.2.0", 4 | "author": "Zheeeng ", 5 | "description": "A pointer lock movement manager for customizing your own creative UI.", 6 | "keywords": [ 7 | "Pointer Lock API", 8 | "Mouse Lock API", 9 | "pointer capture", 10 | "mouse capture", 11 | "movement", 12 | "cursor", 13 | "pointer", 14 | "mouse", 15 | "figma", 16 | "creative UI", 17 | "typescript", 18 | "vite", 19 | "codata" 20 | ], 21 | "repository": "zheeeng/pointer-lock-movement", 22 | "license": "MIT", 23 | "main": "dist/index.js", 24 | "module": "dist/index.mjs", 25 | "types": "dist/index.d.ts", 26 | "exports": { 27 | ".": { 28 | "require": "./dist/index.js", 29 | "types": "./dist/index.d.ts", 30 | "default": "./dist/index.mjs" 31 | }, 32 | "./README.md": "./README.md", 33 | "./package.json": "./package.json", 34 | "./*": "./dist/*" 35 | }, 36 | "files": [ 37 | "dist" 38 | ], 39 | "scripts": { 40 | "dev": "pnpm build --watch", 41 | "build": "tsup src/index.ts --format cjs,esm --dts --clean" 42 | }, 43 | "devDependencies": { 44 | "@types/react": "^18.2.16", 45 | "tsup": "^6.7.0" 46 | }, 47 | "peerDependencies": { 48 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 49 | }, 50 | "dependencies": { 51 | "react": "^18.2.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { PointerLockMovementOption, MoveState } from './pointer-lock-movement' 2 | export { isSupportPointerLock, pointerLockMovement } from './pointer-lock-movement' 3 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement/src/pointer-lock-movement.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 2 | /// 3 | 4 | import { requestScreen, clearScreen } from './utils/requestScreen' 5 | import { requestCursor, clearCursor } from './utils/requestCursor' 6 | 7 | const HISTORY_LENGTH = 8 8 | const CONFIDENCE_THRESHOLD = 2.0 9 | 10 | export type MoveState = { 11 | status: 'moving' | 'stopped', 12 | movementX: number, 13 | movementY: number, 14 | offsetX: number, 15 | offsetY: number, 16 | } 17 | 18 | export type PointerLockMovementOption = { 19 | onLock?: (locked: boolean) => void, 20 | onPrepareLock?: (event: PointerEvent) => void, 21 | onCancelPrepareLock?: (event: PointerEvent) => void, 22 | onMove?: (event: PointerEvent, moveState: MoveState) => void, 23 | cursor?: string | HTMLElement | Partial, 24 | screen?: DOMRect | HTMLElement | Partial, 25 | zIndex?: number, 26 | loopBehavior?: 'loop' | 'stop' | 'infinite', 27 | trigger?: 'drag' | 'toggle', 28 | dragOffset?: number, 29 | disableOnActiveElement?: boolean, 30 | } 31 | 32 | type Iteration = (payload: Payload) => Iteration 33 | 34 | export type CoData< 35 | Context extends Record, 36 | Payload, 37 | > = (context: Context, effect: (context: Context) => void) => Iteration 38 | 39 | export function isSupportPointerLock () { 40 | return 'pointerLockElement' in document || 'mozPointerLockElement' in document || 'webkitPointerLockElement' in document 41 | } 42 | 43 | function assertSupportPointerLock () { 44 | if (isSupportPointerLock()) { 45 | return 46 | } 47 | 48 | throw new Error('Your browser does not support pointer lock') 49 | } 50 | 51 | export const pointerLockMovement = ( 52 | element: Element, 53 | { 54 | loopBehavior = 'loop', 55 | trigger = 'drag', 56 | zIndex = 99999, 57 | ...customOption 58 | }: PointerLockMovementOption = {} 59 | ) => { 60 | const options = { ...customOption, loopBehavior, trigger, zIndex } 61 | 62 | function requestPointerLock () { 63 | (element.requestPointerLock ?? element.mozRequestPointerLock ?? element.webkitRequestPointerLock).call(element) 64 | } 65 | 66 | function exitPointerLock () { 67 | (document.exitPointerLock ?? document.mozExitPointerLock ?? document.webkitExitPointerLock).call(document) 68 | } 69 | 70 | function isLocked () { 71 | return element === document.pointerLockElement || element === document.mozPointerLockElement || element === document.webkitPointerLockElement 72 | } 73 | 74 | type MoveContext = { 75 | event: PointerEvent, 76 | status: 'moving' | 'stopped', 77 | startX: number, 78 | startY: number, 79 | x: number, 80 | y: number, 81 | historyX: number[], 82 | historyY: number[], 83 | movementX: number, 84 | movementY: number, 85 | maxWidth: number, 86 | maxHeight: number 87 | } 88 | 89 | const move: CoData = (context, effect) => payload => { 90 | let { movementX, movementY } = payload 91 | 92 | const historyX = [...context.historyX, movementX].slice(-HISTORY_LENGTH) 93 | const historyY = [...context.historyY, movementY].slice(-HISTORY_LENGTH) 94 | 95 | const shouldValidate = historyX.length === HISTORY_LENGTH && historyY.length === HISTORY_LENGTH 96 | 97 | if (shouldValidate) { 98 | const averageX = historyX.reduce((sum, value) => sum + value, 0) / historyX.length 99 | const averageY = historyY.reduce((sum, value) => sum + value, 0) / historyY.length 100 | 101 | const standardDeviationX = Math.sqrt(historyX.reduce((sum, value) => sum + Math.pow(value - averageX, 2), 0) / historyX.length) 102 | const standardDeviationY = Math.sqrt(historyY.reduce((sum, value) => sum + Math.pow(value - averageY, 2), 0) / historyY.length) 103 | 104 | if (standardDeviationX > 0) { 105 | const zScoreX = Math.abs(movementX - averageX) / standardDeviationX 106 | if (zScoreX > CONFIDENCE_THRESHOLD) { 107 | movementX = averageX 108 | } 109 | } 110 | 111 | if (standardDeviationY > 0) { 112 | const zScoreY = Math.abs(movementY - averageY) / standardDeviationY 113 | if (zScoreY > CONFIDENCE_THRESHOLD) { 114 | movementY = averageY 115 | } 116 | } 117 | } 118 | 119 | const contextPatch: Pick = { 120 | event: payload, 121 | movementX, 122 | movementY, 123 | x: context.x + context.movementX, 124 | y: context.y + context.movementY, 125 | historyX, 126 | historyY, 127 | status: 'moving', 128 | } 129 | 130 | if (options.loopBehavior === 'loop') { 131 | if (contextPatch.x > context.maxWidth) { 132 | contextPatch.x -= context.maxWidth 133 | } else if (contextPatch.x < 0) { 134 | contextPatch.x += context.maxWidth 135 | } 136 | 137 | if (contextPatch.y > context.maxHeight) { 138 | contextPatch.y -= context.maxHeight 139 | } else if (contextPatch.y < 0) { 140 | contextPatch.y += context.maxHeight 141 | } 142 | } else if (options.loopBehavior === 'stop') { 143 | if (contextPatch.x > context.maxWidth) { 144 | contextPatch.x = context.maxWidth 145 | context.status = 'stopped' 146 | } else if (contextPatch.x < 0) { 147 | contextPatch.x = 0 148 | contextPatch.status = 'stopped' 149 | } 150 | 151 | if (contextPatch.y > context.maxHeight) { 152 | contextPatch.y = context.maxHeight 153 | context.status = 'stopped' 154 | } else if (contextPatch.y < 0) { 155 | contextPatch.y = 0 156 | contextPatch.status = 'stopped' 157 | } 158 | } 159 | 160 | const newContext = { ...context, ...contextPatch } 161 | 162 | effect(newContext) 163 | 164 | if (newContext.event.defaultPrevented) { 165 | return move(context, effect) 166 | } 167 | 168 | return move(newContext, effect) 169 | } 170 | 171 | function startup () { 172 | let nextFn: Iteration | undefined 173 | 174 | const localState = { 175 | targetOnActiveElement: false, 176 | isDetecting: false, 177 | startX: 0, 178 | startY: 0, 179 | } 180 | 181 | function detectMoveOffset (event: Event) { 182 | if (!(event instanceof PointerEvent) || !event.buttons || !options.dragOffset) { 183 | return 184 | } 185 | 186 | const offset = Math.sqrt(Math.pow(event.clientX - localState.startX, 2) + Math.pow(event.clientY - localState.startY, 2)) 187 | 188 | if (offset < options.dragOffset) { 189 | return 190 | } 191 | 192 | element.removeEventListener('pointermove', detectMoveOffset) 193 | localState.isDetecting = false 194 | 195 | active(event) 196 | } 197 | 198 | function deActive () { 199 | exitPointerLock() 200 | 201 | options.onLock?.(false) 202 | document.removeEventListener('pointermove', handlePointerMove) 203 | 204 | nextFn = undefined 205 | clearCursor() 206 | clearScreen() 207 | } 208 | 209 | function handlePointerMove (event: PointerEvent) { 210 | if (options.trigger === 'drag' && !event.buttons) { 211 | deActive() 212 | } else { 213 | handleContinueMove(event) 214 | } 215 | } 216 | 217 | function active (pointerEvent: PointerEvent) { 218 | const virtualScreen = requestScreen(options.screen, { zIndex: options.zIndex }) 219 | 220 | const virtualCursor = requestCursor(options.cursor, { zIndex: options.zIndex }) 221 | 222 | nextFn = move( 223 | { 224 | event: pointerEvent, 225 | status: 'moving', 226 | startX: pointerEvent.clientX, 227 | startY: pointerEvent.clientY, 228 | movementX: 0, 229 | movementY: 0, 230 | historyX: [], 231 | historyY: [], 232 | x: pointerEvent.clientX - virtualScreen.x, 233 | y: pointerEvent.clientY - virtualScreen.y, 234 | maxWidth: virtualScreen.width, 235 | maxHeight: virtualScreen.height, 236 | }, 237 | ({ event, status, x, y, startX, startY, movementX, movementY }) => { 238 | virtualCursor.style.transform = `translate3D(${x}px, ${y}px, 0px)` 239 | 240 | options.onMove?.( 241 | event, 242 | { 243 | status, 244 | offsetX: x - startX, 245 | offsetY: y - startY, 246 | movementX, 247 | movementY, 248 | } 249 | ) 250 | } 251 | )(pointerEvent) 252 | 253 | document.addEventListener('pointermove', handlePointerMove) 254 | 255 | document.addEventListener('pointerlockchange', function handlePointerLockChange () { 256 | if (isLocked()) { 257 | return 258 | } 259 | 260 | document.removeEventListener('pointerlockchange', handlePointerLockChange) 261 | deActive() 262 | }) 263 | 264 | options.onLock?.(true) 265 | requestPointerLock() 266 | } 267 | 268 | function handleContinueMove (event: PointerEvent) { 269 | nextFn = nextFn?.(event) 270 | } 271 | 272 | function handleDeActive () { 273 | if (!isLocked()) { 274 | return 275 | } 276 | 277 | deActive() 278 | } 279 | 280 | function handleActive (event: PointerEvent) { 281 | if (isLocked()) { 282 | return 283 | } 284 | 285 | active(event) 286 | } 287 | 288 | function handleDragActive (event: Event) { 289 | if (!(event instanceof PointerEvent) || event.button !== 0) { 290 | return 291 | } 292 | 293 | if (localState.targetOnActiveElement && options.disableOnActiveElement) { 294 | return 295 | } 296 | 297 | handleActive(event) 298 | } 299 | 300 | function handleToggleActive (event: Event) { 301 | if (!(event instanceof PointerEvent) || event.button !== 0) { 302 | return 303 | } 304 | 305 | if (isLocked()) { 306 | handleDeActive() 307 | } else { 308 | handleActive(event) 309 | } 310 | } 311 | 312 | function handleDragOffsetActive (event: Event) { 313 | if (!(event instanceof PointerEvent) || event.button !== 0 || !options.dragOffset) { 314 | return 315 | } 316 | 317 | if (localState.targetOnActiveElement && options.disableOnActiveElement) { 318 | return 319 | } 320 | 321 | if (isLocked()) { 322 | return 323 | } 324 | 325 | options?.onPrepareLock?.(event) 326 | 327 | localState.isDetecting = true 328 | localState.startX = event.clientX 329 | localState.startY = event.clientY 330 | 331 | element.addEventListener('pointermove', detectMoveOffset) 332 | 333 | element.addEventListener('pointerup', function unbindEvent (event: Event) { 334 | if (localState.isDetecting) { 335 | 336 | if (event instanceof PointerEvent) { 337 | options?.onCancelPrepareLock?.(event) 338 | } 339 | } 340 | 341 | element.removeEventListener('pointerup', unbindEvent) 342 | element.removeEventListener('pointermove', detectMoveOffset) 343 | localState.isDetecting = false 344 | }) 345 | } 346 | 347 | function markElementIsActiveElement (event: Event) { 348 | if (!(event instanceof PointerEvent) || event.button !== 0 || !options.disableOnActiveElement) { 349 | return 350 | } 351 | 352 | if (event.target === document.activeElement) { 353 | localState.targetOnActiveElement = true 354 | } else { 355 | localState.targetOnActiveElement = false 356 | } 357 | } 358 | 359 | assertSupportPointerLock() 360 | 361 | if (options.disableOnActiveElement) { 362 | element.addEventListener('pointerdown', markElementIsActiveElement) 363 | } 364 | 365 | if (options.trigger === 'drag') { 366 | if (options.dragOffset) { 367 | element.addEventListener('pointerdown', handleDragOffsetActive) 368 | document.addEventListener('pointerup', handleDeActive) 369 | 370 | return () => { 371 | element.removeEventListener('pointermove', handleDragOffsetActive) 372 | document.removeEventListener('pointerup', handleDeActive) 373 | element.removeEventListener('pointerdown', markElementIsActiveElement) 374 | } 375 | } 376 | 377 | element.addEventListener('pointerdown', handleDragActive) 378 | document.addEventListener('pointerup', handleDeActive) 379 | 380 | return () => { 381 | element.removeEventListener('pointerdown', handleDragActive) 382 | document.removeEventListener('pointerup', handleDeActive) 383 | element.removeEventListener('pointerdown', markElementIsActiveElement) 384 | } 385 | } else { 386 | element.addEventListener('pointerdown', handleToggleActive) 387 | 388 | return () => { 389 | element.removeEventListener('pointerdown', handleToggleActive) 390 | element.removeEventListener('pointerdown', markElementIsActiveElement) 391 | } 392 | } 393 | 394 | } 395 | 396 | return startup() 397 | } 398 | -------------------------------------------------------------------------------- /packages/pointer-lock-movement/src/shim.d.ts: -------------------------------------------------------------------------------- 1 | // compatibility with old browsers 2 | declare global { 3 | interface Document { 4 | mozPointerLockElement: Element 5 | webkitPointerLockElement: Element 6 | 7 | mozExitPointerLock(): void 8 | webkitExitPointerLock(): void 9 | 10 | addEventListener(type: 'pointerlockchange', listener: (event: Event) => void): void 11 | addEventListener(type: 'mozpointerlockchange', listener: (event: Event) => void): void 12 | addEventListener(type: 'webkitpointerlockchange', listener: (event: Event) => void): void 13 | 14 | removeEventListener(type: 'pointerlockchange', listener: (event: Event) => void): void 15 | removeEventListener(type: 'mozpointerlockchange', listener: (event: Event) => void): void 16 | removeEventListener(type: 'webkitpointerlockchange', listener: (event: Event) => void): void 17 | } 18 | 19 | interface Element { 20 | mozRequestPointerLock(): void 21 | webkitRequestPointerLock(): void 22 | } 23 | } 24 | 25 | export {} -------------------------------------------------------------------------------- /packages/pointer-lock-movement/src/utils/requestCursor.ts: -------------------------------------------------------------------------------- 1 | let cursorElement: HTMLElement | undefined 2 | 3 | export function requestCursor ( 4 | customCursor?: string | HTMLElement | Partial, 5 | { zIndex} : { zIndex?: number } = {} 6 | ): HTMLElement { 7 | const cursorEl = document.createElement('div') 8 | const cursorChildWrapper = document.createElement('div') 9 | cursorEl.appendChild(cursorChildWrapper) 10 | 11 | if (customCursor instanceof HTMLElement) { 12 | cursorChildWrapper.appendChild(customCursor) 13 | } else if (typeof customCursor === 'string') { 14 | cursorChildWrapper.textContent = customCursor 15 | } else if (typeof customCursor === 'object' && customCursor !== null) { 16 | Object.assign(cursorChildWrapper.style, customCursor) 17 | } 18 | 19 | cursorEl.style.position = 'fixed' 20 | cursorEl.style.top = '0px' 21 | cursorEl.style.left = '0px' 22 | 23 | cursorChildWrapper.style.transform = 'translate(-50%, -50%)' 24 | 25 | if (zIndex !== undefined) { 26 | cursorEl.style.zIndex = zIndex.toString() 27 | } 28 | 29 | cursorElement = cursorEl 30 | document.body.appendChild(cursorEl) 31 | 32 | return cursorEl 33 | } 34 | 35 | export function clearCursor (): void { 36 | cursorElement?.remove() 37 | cursorElement = undefined 38 | } -------------------------------------------------------------------------------- /packages/pointer-lock-movement/src/utils/requestScreen.ts: -------------------------------------------------------------------------------- 1 | let screenElement: HTMLElement | undefined 2 | 3 | export function requestScreen ( 4 | customScreen?: DOMRect | HTMLElement | Partial, 5 | { zIndex} : { zIndex?: number } = {} 6 | ): { width: number, height: number, x: number, y: number } { 7 | if (!customScreen) { 8 | return { width: window.innerWidth, height: window.innerHeight, x: 0, y: 0 } 9 | } 10 | 11 | if (customScreen instanceof DOMRect) { 12 | return { width: customScreen.width, height: customScreen.height, x: customScreen.x, y: customScreen.y } 13 | } 14 | 15 | const screenEl = customScreen instanceof HTMLElement ? customScreen : document.createElement('div') 16 | 17 | if (typeof customScreen === 'object' && customScreen !== null && !(customScreen instanceof HTMLElement)) { 18 | Object.assign(screenEl.style, customScreen) 19 | } 20 | 21 | screenEl.style.position = 'fixed' 22 | screenEl.style.display = 'flex' 23 | screenEl.style.alignItems = 'center' 24 | screenEl.style.justifyContent = 'center' 25 | screenEl.style.top = screenEl.style.top || '0px' 26 | screenEl.style.left = screenEl.style.left || '0px' 27 | screenEl.style.width = screenEl.style.width || '0px' 28 | screenEl.style.height = screenEl.style.height || '0px' 29 | 30 | if (zIndex !== undefined) { 31 | screenEl.style.zIndex = zIndex.toString() 32 | } 33 | 34 | screenElement = screenEl 35 | document.body.appendChild(screenEl) 36 | 37 | const { width, height, x, y } = screenEl.getBoundingClientRect() 38 | 39 | return { width, height, x, y } 40 | } 41 | 42 | export function clearScreen (): void { 43 | screenElement?.remove() 44 | screenElement = undefined 45 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "jsx": "react-jsx", 5 | "lib": ["ES2015", "DOM"], 6 | "declaration": true, 7 | "moduleResolution": "Node", 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "strictNullChecks": true, 12 | "noImplicitAny": true, 13 | "strictFunctionTypes": true, 14 | "strictBindCallApply": true, 15 | "noUncheckedIndexedAccess": true, 16 | "strictPropertyInitialization": true, 17 | "alwaysStrict": true, 18 | "noErrorTruncation": true 19 | } 20 | } 21 | --------------------------------------------------------------------------------