├── .github └── workflows │ ├── create-release-dev.yml │ └── create-release.yml ├── .gitignore ├── README.md ├── automation └── release.js ├── docs ├── directional-controls.png ├── large-buttons.jpg ├── measurement-hud.jpg ├── module-settings.jpg └── small-buttons.jpg ├── lang ├── de.json └── en.json ├── module.json ├── package-lock.json ├── package.json ├── src ├── browser │ └── Screen.js ├── config │ ├── GestureCalibrationMenu.js │ ├── ModuleConstants.js │ └── TouchSettings.js ├── foundryvtt │ ├── FoundryCanvas.js │ ├── FoundryModules.js │ └── FoundryUser.js ├── logic │ ├── AppTouchPointerEventsManager.js │ ├── CanvasTouchPointerEventsManager.js │ ├── DirectionalArrows.js │ ├── EasyTarget.js │ ├── FakeTouchEvent.js │ ├── MouseButton.js │ ├── Touch.js │ ├── TouchContext.js │ ├── TouchPointerEventsManager.js │ ├── TouchVTTMouseInteractionManager.js │ ├── Vectors.js │ └── WindowAppAdapter.js ├── tools │ ├── DrawingTools.js │ ├── EnlargeButtonsTool.js │ ├── MeasuredTemplateManagement.js │ ├── MeasurementHud.js │ ├── SnapToGridTool.js │ ├── TokenEraserTool.js │ ├── UtilityControls.js │ └── WallTools.js ├── touch-vtt.js └── utils │ ├── DragDropTouch.js │ ├── EventUtils.js │ ├── Injection.js │ ├── MathUtils.js │ ├── ObjectUtils.js │ └── libWrapper.js ├── style └── touch-vtt.css ├── templates ├── gesture-calibration.hbs ├── measured-template-hud.hbs ├── measurement-hud.hbs └── settings-override.hbs └── webpack.config.js /.github/workflows/create-release-dev.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - develop 5 | 6 | name: Create Development Release 7 | 8 | jobs: 9 | build: 10 | name: Create Development Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Get the version 18 | id: get_version 19 | run: echo "FULLVERSION=`git describe`" >> "$GITHUB_OUTPUT" 20 | - id: set_vars 21 | run: | 22 | FULLVERSION=${{ steps.get_version.outputs.FULLVERSION }} 23 | echo "ZIPURL=${{ github.server_url }}/${{ github.repository }}/releases/download/${FULLVERSION}/touch-vtt-${FULLVERSION}.zip" >> "$GITHUB_OUTPUT" 24 | echo "MODULEJSONURL=${{ github.server_url }}/${{ github.repository }}/releases/download/${FULLVERSION}/module.json" >> "$GITHUB_OUTPUT" 25 | echo "VERSION=${FULLVERSION#v}" >> $GITHUB_OUTPUT 26 | - name: Use Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20.x 30 | - name: Update module.json 31 | uses: restackio/update-json-file-action@2.1 32 | with: 33 | file: module.json 34 | fields: "{\"version\": \"${{ steps.set_vars.outputs.VERSION }}\", \"download\": \"${{ steps.set_vars.outputs.ZIPURL }}\", \"manifest\": \"${{ steps.set_vars.outputs.MODULEJSONURL }}\"}" 35 | - name: Build Project 36 | run: | 37 | npm install 38 | npm run build 39 | zip touch-vtt-${{ steps.get_version.outputs.FULLVERSION }}.zip module.json dist/* lang/* templates/* 40 | - name: Create Release And Upload Asset 41 | id: create-release-upload 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | draft: false 45 | prerelease: false 46 | target_commitish: develop 47 | tag_name: ${{ steps.get_version.outputs.FULLVERSION }} 48 | name: ${{ steps.get_version.outputs.FULLVERSION }} 49 | files: | 50 | touch-vtt-${{ steps.get_version.outputs.FULLVERSION }}.zip 51 | module.json 52 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | check_current_branch: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | branch: ${{ steps.check_step.outputs.branch }} 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Get current branch 19 | id: check_step 20 | # 1. Get the list of branches ref where this tag exists 21 | # 2. Remove 'origin/' from that result 22 | # 3. Put that string in output 23 | # => We can now use function 'contains(list, item)'' 24 | run: | 25 | raw=$(git branch -r --contains ${{ github.ref }}) 26 | branch="$(echo ${raw//origin\//} | tr -d '\n')" 27 | echo "{name}=branch" >> $GITHUB_OUTPUT 28 | echo "Branches where this tag exists : $branch." 29 | 30 | build: 31 | name: Create Release 32 | runs-on: ubuntu-latest 33 | # Wait for check step to finish 34 | needs: check_current_branch 35 | # only run if tag is present on branch 'main' 36 | if: contains(${{ needs.check_current_branch.outputs.branch }}, 'main') 37 | steps: 38 | - name: Checkout code 39 | uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | - name: Get the version 43 | id: get_version 44 | run: echo "FULLVERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" 45 | - id: set_vars 46 | run: | 47 | FULLVERSION=${{ steps.get_version.outputs.FULLVERSION }} 48 | echo "ZIPURL=${{ github.server_url }}/${{ github.repository }}/releases/download/${FULLVERSION}/touch-vtt-${FULLVERSION}.zip" >> "$GITHUB_OUTPUT" 49 | echo "MODULEJSONURL=${{ github.server_url }}/${{ github.repository }}/releases/download/${FULLVERSION}/module.json" >> "$GITHUB_OUTPUT" 50 | echo "VERSION=${FULLVERSION#v}" >> $GITHUB_OUTPUT 51 | - name: Use Node.js 52 | uses: actions/setup-node@v4 53 | with: 54 | node-version: 20.x 55 | - name: Update module.json 56 | uses: restackio/update-json-file-action@2.1 57 | with: 58 | file: module.json 59 | fields: "{\"version\": \"${{ steps.set_vars.outputs.VERSION }}\", \"download\": \"${{ steps.set_vars.outputs.ZIPURL }}\"}" 60 | - name: Build Project 61 | run: | 62 | npm install 63 | npm run build 64 | zip touch-vtt-${{ steps.get_version.outputs.FULLVERSION }}.zip module.json dist/* lang/* templates/* 65 | - name: Create Release And Upload Asset 66 | id: create-release-upload 67 | uses: softprops/action-gh-release@v2 68 | with: 69 | draft: false 70 | prerelease: false 71 | tag_name: ${{ steps.get_version.outputs.FULLVERSION }} 72 | name: ${{ steps.get_version.outputs.FULLVERSION }} 73 | files: | 74 | touch-vtt-${{ steps.get_version.outputs.FULLVERSION }}.zip 75 | module.json 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Foundry Version 12](https://img.shields.io/badge/Foundry%20Version-12-green) 2 | ![Download Count](https://img.shields.io/github/downloads/Oromis/touch-vtt/total?color=purple&label=Downloads%20%28Total%29) 3 | ![Forge Installs](https://img.shields.io/badge/dynamic/json?label=Forge%20Installs&query=package.installs&suffix=%25&url=https%3A%2F%2Fforge-vtt.com%2Fapi%2Fbazaar%2Fpackage%2Ftouch-vtt&colorB=4aa94a) 4 | 5 | # TouchVTT 6 | 7 | Introduces touch screen support to FoundryVTT. If you have a tablet, a PC or a TV equipped with a 8 | touch screen and want to play on FoundryVTT, this module is for you! 9 | 10 | Features: 11 | - Use two-finger pinching and panning gestures to zoom and pan the map. Panning can be turned off for this gesture 12 | in the settings (use three fingers to pan if you change this). 13 | - Move tokens by dragging them with your finger - just as you would with the mouse 14 | - Move three fingers around on the canvas to pan the scene - no zooming in this mode. 15 | - Rotate tokens using the (optional) directional buttons added to the token right-click menu (HUD) 16 | - Target hostile tokens quickly by tapping them. 17 | - Use your ruler with waypoints and move your token along the measured path using a Touch-Only UI 18 | - Need to right-click to access the corresponding functionality on a game world entity? Just long-press (0.5s) 19 | with your finger. 20 | - Move windows around and interact with their content intuitively 21 | - Removing measurement templates usually requires you to press the DELETE key on your keyboard. TouchVTT 22 | adds an eraser tool to the measurement templates menu that can be used with touch controls. First tap 23 | the eraser tool, then tap the template you want to remove. 24 | - Additional wall placement tools that work with touch controls 25 | - Enlarge the menu buttons (on the left) to make them easier to use with touch controls 26 | - Play/Pause button for GMs under Foundry logo 27 | 28 | Primary use cases: 29 | - You and your group play in person and you want to use Foundry to visualize gameplay - just put a touchscreen 30 | device in the middle of the table, install TouchVTT and you'll be good to go! 31 | - You like playing on your couch where a touch device is just so much more convenient than a laptop 32 | 33 | ### Settings 34 | 35 | ![TouchVTT Module Settings Screenshot](docs/module-settings.jpg) 36 | 37 | #### Zoom / Pan Gestures 38 | 39 | - **Zoom & Pan with 2 fingers:** Combined zooming and panning behavior. Should feel most natural and is thus the 40 | default. Due to the varying accuracy of touch sensors, you may experience some jittering while moving the 41 | camera using this mode - the less accurate your touch sensor, the more jitter you will have. 42 | - **Zoom with 2 fingers, pan with 3 fingers:** Pinch 2 fingers to zoom, move 3 fingers on the canvas to pan. 43 | Use this if you experience issues with the default mode (or just use if because you like it better :) ) 44 | 45 | #### Direction arrows in HUD 46 | 47 | - **On:** Shows arrow buttons in a token's HUD (right-click menu) allowing users to change the direction the token 48 | is facing without the use of a keyboard: 49 | ![Directional Arrows Screenshot](docs/directional-controls.png) 50 | - **Off:** Disables the above-mentioned arrows, shows the default token HUD 51 | 52 | This is a drop-down setting instead of a checkbox because there might be other methods to change token direction 53 | in the future. 54 | 55 | #### Targeting behavior 56 | 57 | - **Off:** Targeting is unchanged from how FoundryVTT handles it natively 58 | - **Allow single target:** Tap a token you don't own to target it. Other targets will be released. Tap this token or any other token again to un-target it. 59 | - **Allow multiple targets:** Tap a token you don't own to target it in addition to any other targets you selected. Tap this token again to un-target it. 60 | 61 | #### Measurement HUD 62 | 63 | - **Off:** No touch UI for setting ruler waypoints or moving your token along a measured path 64 | - **Show right:** Shows a touch-friendly UI to the top-right of where you currently drag your ruler. 65 | Use another finger to set waypoints (Flag icon) or to move your token (footprints icon). Recommended for right-handed people. 66 | - **Show left:** Same as `Show right`, just on the left-hand side, optimized for left-handed people. 67 | 68 | ![Measurement HUD Screenshot](docs/measurement-hud.jpg) 69 | 70 | #### Enlarge buttons in on-screen UI 71 | 72 | - **Checked:** Makes buttons in the left-hand menu structure easier to hit by making them larger: 73 | ![Large Buttons Screenshot](docs/large-buttons.jpg) 74 | - **Unchecked:** Default button size 75 | ![Small Buttons Screenshot](docs/small-buttons.jpg) 76 | 77 | ### Compatibility with other modules 78 | 79 | This module changes the behavior of several aspects of FoundryVTT by overriding many methods (especially wall and 80 | measurement controls at the moment). I implemented all that with compatibility to other modules in mind by using 81 | [libWrapper](https://foundryvtt.com/packages/lib-wrapper/). If you experience any issues that could stem from module 82 | incompatibility, please install and activate libWrapper. 83 | 84 | The [Lock View](https://foundryvtt.com/packages/LockView/) module is supported. Touch zooming and panning is disabled 85 | when those features are locked in "Lock View". 86 | 87 | ### Changelog 88 | 89 | - **2.2.8:** Added zoom/pan gesture sensitivity calibration in the settings 90 | - **2.2.0:** Added two-finger scrolling gesture in all application windows 91 | - **2.1.0:** Added scroll support for directories with draggable items 92 | - **2.0.7:** Added support for placing and rotating pre-made Measured Templates 93 | - **2.0.3:** Added settings to enable/disable core functionality, and GM overrides for client settings 94 | - **2.0.1:** Added initial support for drag and drop 95 | - **2.0.0:** Large rework based on PointerEvents 96 | - **1.12.1:** Added option to disable pan/zoom gestures, added play/pause button, added Token eraser tool 97 | - **1.12.0:** Compatibility with FoundryVTT versions 11 and 12 98 | - **1.10.0:** Compatibility with FoundryVTT version 10 99 | - **1.9.1:** Compatibility with FoundryVTT version 9 100 | - **1.8.1:** Fixed missing template for Measurement HUD, added German Translation files 101 | - **1.8.0:** Added a Measurement HUD, a UI for the ruler allowing touch-based creation of waypoints and moving tokens along a measured path. 102 | - **1.7.2:** Fixed not being able to move tokens as a GM due to a conflict with the EasyTarget module 103 | - **1.7.1:** Restricted targeting to the select tool 104 | - **1.7.0:** Added ability to target unowned tokens by simply tapping them with your finger. 105 | - **1.6.0:** Added button to delete the currently selected drawing. 106 | - **1.5.1:** Fixed players being able to rotate their token while the game is paused. Moved enlarge buttons feature from wall tools menu to module settings. 107 | - **1.5.0:** Added arrow buttons to the token HUD, allowing touchscreen users to rotate their token (long-press the token to activate the HUD). Added missing translation files. 108 | - **1.4.0:** Added snap-to-grid toggle for token movement controls 109 | - **1.3.1:** Fixed bug with compatibility between TouchVTT and LockView 110 | - **1.3.0:** Added setting to split zoom and pan gestures. 3 and 4 finger gestures now always pan the map. Added compatibility with [Lock View](https://foundryvtt.com/packages/LockView/). 111 | - **1.2.3:** Added support for module compatibility library [libWrapper](https://foundryvtt.com/packages/lib-wrapper/) 112 | - **1.2.0:** Added support for wall tools 113 | - **1.1.0:** Added touch support for measurement templates 114 | - **1.0.0:** Initial release. Zooming & panning with 2 fingers, token movement 115 | 116 | ### About 117 | 118 | Disclaimer: I also made the "Touch20" browser extension for Roll20, TouchVTT is my contribution to FoundryVTT. 119 | 120 | Feel free to suggest features and report bugs via Github issues! 121 | 122 | If you want to show your support for my work financially, feel free to donate via PayPal - it's greatly appreciated! 123 | 124 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=JTE9BL67E6TUL&source=url) 125 | -------------------------------------------------------------------------------- /automation/release.js: -------------------------------------------------------------------------------- 1 | const MODULE_JSON_PATH = './module.json' 2 | 3 | const fs = require('fs') 4 | const packageJson = require('../package.json') 5 | 6 | const moduleJson = JSON.parse(fs.readFileSync(MODULE_JSON_PATH, { encoding: 'utf-8' })) 7 | moduleJson.version = packageJson.version 8 | moduleJson.download = `https://github.com/Oromis/touch-vtt/releases/download/v${packageJson.version}/touch-vtt-v${packageJson.version}.zip` 9 | 10 | console.log(`Setting module version to ${moduleJson.version}, download URL to ${moduleJson.download}`) 11 | 12 | fs.writeFileSync(MODULE_JSON_PATH, JSON.stringify(moduleJson, null, 2), { encoding: 'utf-8' }) 13 | -------------------------------------------------------------------------------- /docs/directional-controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oromis/touch-vtt/ecd6d3a8f748b3fbafd198061ffa201c0e227319/docs/directional-controls.png -------------------------------------------------------------------------------- /docs/large-buttons.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oromis/touch-vtt/ecd6d3a8f748b3fbafd198061ffa201c0e227319/docs/large-buttons.jpg -------------------------------------------------------------------------------- /docs/measurement-hud.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oromis/touch-vtt/ecd6d3a8f748b3fbafd198061ffa201c0e227319/docs/measurement-hud.jpg -------------------------------------------------------------------------------- /docs/module-settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oromis/touch-vtt/ecd6d3a8f748b3fbafd198061ffa201c0e227319/docs/module-settings.jpg -------------------------------------------------------------------------------- /docs/small-buttons.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oromis/touch-vtt/ecd6d3a8f748b3fbafd198061ffa201c0e227319/docs/small-buttons.jpg -------------------------------------------------------------------------------- /lang/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "TOUCHVTT.Erase": "Löschen", 3 | "TOUCHVTT.ToggleWallChain": "Wand-Verkettung Umschalten", 4 | "TOUCHVTT.UndoWall": "Letzte Wand rückgängig machen", 5 | "TOUCHVTT.DeleteWall": "Lösche letzte Wand", 6 | "TOUCHVTT.DeleteDrawing": "Lösche gewählte Zeichnung", 7 | "TOUCHVTT.DeleteToken": "Lösche gewählte Token", 8 | "TOUCHVTT.SnapToGrid": "Token am Raster ausrichten", 9 | "TOUCHVTT.Rotate0": "Drehen nach Oben", 10 | "TOUCHVTT.Rotate45": "Drehen nach Oben-Rechts", 11 | "TOUCHVTT.Rotate90": "Drehen nach Rechts", 12 | "TOUCHVTT.Rotate135": "Drehen nach Unten-Rechts", 13 | "TOUCHVTT.Rotate180": "Drehen nach Unten", 14 | "TOUCHVTT.Rotate225": "Drehen nach Unten-Links", 15 | "TOUCHVTT.Rotate270": "Drehen nach Links", 16 | "TOUCHVTT.Rotate315": "Drehen nach Oben-Links" 17 | } -------------------------------------------------------------------------------- /lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "TOUCHVTT.Erase": "Erase", 3 | "TOUCHVTT.ToggleWallChain": "Toggle Wall Chaining", 4 | "TOUCHVTT.UndoWall": "Undo Last Wall", 5 | "TOUCHVTT.DeleteWall": "Delete Selected Wall", 6 | "TOUCHVTT.DeleteDrawing": "Delete Selected Drawing", 7 | "TOUCHVTT.DeleteToken": "Delete Selected Token", 8 | "TOUCHVTT.SnapToGrid": "Snap Tokens to Grid", 9 | "TOUCHVTT.Rotate0": "Rotate Up", 10 | "TOUCHVTT.Rotate45": "Rotate Up-Right", 11 | "TOUCHVTT.Rotate90": "Rotate Right", 12 | "TOUCHVTT.Rotate135": "Rotate Down-Right", 13 | "TOUCHVTT.Rotate180": "Rotate Down", 14 | "TOUCHVTT.Rotate225": "Rotate Down-Left", 15 | "TOUCHVTT.Rotate270": "Rotate Left", 16 | "TOUCHVTT.Rotate315": "Rotate Up-Left" 17 | } -------------------------------------------------------------------------------- /module.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "touch-vtt", 3 | "title": "TouchVTT", 4 | "description": "Adds touch screen support to FoundryVTT", 5 | "authors": [ 6 | { 7 | "name": "Oromis | David", 8 | "email": "david.bauske@googlemail.com" 9 | }, 10 | { 11 | "name": "Aioros", 12 | "email": "aioros83@gmail.com", 13 | "discord": "MrAioros" 14 | } 15 | ], 16 | "version": "2.2.21", 17 | "compatibility": { 18 | "minimum": "11", 19 | "verified": "12.331" 20 | }, 21 | "scripts": [ 22 | "dist/touch-vtt.js" 23 | ], 24 | "styles": [ 25 | "dist/touch-vtt.css" 26 | ], 27 | "languages": [ 28 | { 29 | "lang": "en", 30 | "name": "English", 31 | "path": "lang/en.json" 32 | }, 33 | { 34 | "lang": "de", 35 | "name": "Deutsch", 36 | "path": "lang/de.json" 37 | } 38 | ], 39 | "url": "https://github.com/Oromis/touch-vtt", 40 | "download": "https://github.com/Oromis/touch-vtt/releases/download/v2.2.21/touch-vtt-v2.2.21.zip", 41 | "manifest": "https://raw.githubusercontent.com/Oromis/touch-vtt/main/module.json", 42 | "license": "https://www.gnu.org/licenses/gpl-3.0.en.html", 43 | "readme": "README.md", 44 | "bugs": "https://github.com/Oromis/touch-vtt/issues" 45 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "touch-vtt", 3 | "version": "2.2.21", 4 | "description": "Adds touch support to FoundryVTT", 5 | "main": "src/touch-vtt.js", 6 | "scripts": { 7 | "build": "webpack --config webpack.config.js --mode production", 8 | "dev": "webpack --config webpack.config.js --watch --mode development --devtool inline-cheap-module-source-map", 9 | "version": "node automation/release.js && git add module.json", 10 | "postversion": "git push && git push --tags", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@github.com/Oromis/touchvtt.git" 16 | }, 17 | "keywords": [ 18 | "FoundryVTT" 19 | ], 20 | "author": { 21 | "name": "David Bauske", 22 | "email": "david.bauske@googlemail.com" 23 | }, 24 | "contributors": [ 25 | { 26 | "name": "Aioros", 27 | "email": "aioros83@gmail.com" 28 | } 29 | ], 30 | "license": "GPL-3.0", 31 | "bugs": { 32 | "url": "https://github.com/Oromis/touch-vtt/issues" 33 | }, 34 | "homepage": "https://github.com/Oromis/touch-vtt#readme", 35 | "devDependencies": { 36 | "css-loader": "^6.8.1", 37 | "mini-css-extract-plugin": "^2.7.6", 38 | "webpack": "^5.87.0", 39 | "webpack-cli": "^5.1.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/browser/Screen.js: -------------------------------------------------------------------------------- 1 | class Screen { 2 | get center() { 3 | return { 4 | x: window.innerWidth / 2, 5 | y: window.innerHeight / 2 6 | } 7 | } 8 | 9 | get size() { 10 | return { 11 | x: window.innerWidth, 12 | y: window.innerHeight 13 | } 14 | } 15 | 16 | toRelativeCoordinates({ x, y }) { 17 | const size = this.size 18 | return { 19 | x: x / size.x, 20 | y: y / size.y 21 | } 22 | } 23 | } 24 | 25 | export default new Screen() 26 | -------------------------------------------------------------------------------- /src/config/GestureCalibrationMenu.js: -------------------------------------------------------------------------------- 1 | import {MODULE_NAME, MODULE_DISPLAY_NAME} from './ModuleConstants.js' 2 | import CanvasTouchPointerEventsManager from '../logic/CanvasTouchPointerEventsManager.js' 3 | import {getSetting, ZOOM_THRESHOLD_SETTING, PAN_THRESHOLD_SETTING} from './TouchSettings.js' 4 | 5 | export class GestureCalibrationMenu extends FormApplication { 6 | constructor() { 7 | super() 8 | this.canvasTouchPointerEventsManager = null 9 | } 10 | 11 | static get defaultOptions() { 12 | return foundry.utils.mergeObject(super.defaultOptions, { 13 | classes: ['form'], 14 | popOut: true, 15 | template: `/modules/${MODULE_NAME}/templates/gesture-calibration.hbs`, 16 | id: `${MODULE_NAME}-gesture-calibration-form`, 17 | title: `${MODULE_DISPLAY_NAME} - Gesture Calibration`, 18 | resizable: true, 19 | width: 800, 20 | height: 600, 21 | }) 22 | } 23 | 24 | getData() { 25 | return { 26 | zoomThresholdSetting: getSetting(ZOOM_THRESHOLD_SETTING), 27 | panThresholdSetting: getSetting(PAN_THRESHOLD_SETTING) 28 | } 29 | } 30 | 31 | async _updateObject(event, formData) { 32 | const data = expandObject(formData) 33 | for (let setting in data) { 34 | game.settings.set(MODULE_NAME, setting, data[setting]) 35 | } 36 | } 37 | 38 | async close(...args) { 39 | super.close(...args) 40 | this.canvasTouchPointerEventsManager = null 41 | game.settings.sheet.maximize() 42 | } 43 | 44 | activateListeners(html) { 45 | super.activateListeners(html) 46 | Object.values(ui.windows).forEach(app => app.minimize()) 47 | this.canvasTouchPointerEventsManager = CanvasTouchPointerEventsManager.init($("#touch-vtt-calibration").get(0)) 48 | 49 | const zoomThresholdInput = html.find(`input[name="zoomThreshold"]`) 50 | zoomThresholdInput.change(() => { 51 | this.canvasTouchPointerEventsManager.setForcedZoomThreshold(zoomThresholdInput.val()) 52 | }) 53 | 54 | const panThresholdInput = html.find(`input[name="panThreshold"]`) 55 | panThresholdInput.change(() => { 56 | this.canvasTouchPointerEventsManager.setForcedPanThreshold(panThresholdInput.val()) 57 | }) 58 | } 59 | } -------------------------------------------------------------------------------- /src/config/ModuleConstants.js: -------------------------------------------------------------------------------- 1 | export const MODULE_DISPLAY_NAME = "TouchVTT" 2 | export const MODULE_NAME = "touch-vtt" 3 | -------------------------------------------------------------------------------- /src/config/TouchSettings.js: -------------------------------------------------------------------------------- 1 | import {MODULE_NAME, MODULE_DISPLAY_NAME} from './ModuleConstants.js' 2 | import {updateButtonSize} from '../tools/EnlargeButtonsTool' 3 | import {toggleUtilityControls} from '../tools/UtilityControls.js' 4 | import {GestureCalibrationMenu} from './GestureCalibrationMenu.js' 5 | 6 | export const CORE_FUNCTIONALITY = "core" 7 | 8 | export const GESTURE_MODE_SETTING = "gestureMode" 9 | export const GESTURE_MODE_OFF = "off" 10 | export const GESTURE_MODE_COMBINED = "combined" 11 | export const GESTURE_MODE_SPLIT = "split" 12 | 13 | export const DIRECTIONAL_ARROWS_SETTING = "directionalArrows" 14 | export const DIRECTIONAL_ARROWS_OFF = "off" 15 | export const DIRECTIONAL_ARROWS_ON = "on" 16 | 17 | export const LARGE_BUTTONS_SETTING = "largeButtons" 18 | 19 | export const PAUSE_BUTTON_SETTING = "pauseButton" 20 | 21 | export const REMOVE_HOVER_EFFECTS = "removeHover" 22 | 23 | export const DISABLE_DBLCLICK = "disableDblclick" 24 | export const DISABLE_DRAGDROP = "disableDragDrop" 25 | 26 | export const EASY_TARGET_SETTING = "easyTarget" 27 | export const EASY_TARGET_OFF = "off" 28 | export const EASY_TARGET_SINGLE = "single" 29 | export const EASY_TARGET_MULTIPLE = "multiple" 30 | 31 | export const MEASUREMENT_HUD_SETTING = "measurementHud" 32 | export const MEASUREMENT_HUD_OFF = "off" 33 | export const MEASUREMENT_HUD_RIGHT = "right" 34 | export const MEASUREMENT_HUD_LEFT = "left" 35 | 36 | export const CANVAS_RIGHT_CLICK_TIMEOUT = "canvasRightClickTimeout" 37 | export const CANVAS_LONG_PRESS_TIMEOUT = "canvasLongPressTimeout" 38 | 39 | export const ZOOM_THRESHOLD_SETTING = "zoomThreshold" 40 | export const PAN_THRESHOLD_SETTING = "panThreshold" 41 | 42 | export const DEBUG_MODE_SETTING = "debugMode" 43 | 44 | export function getSetting(settingName) { 45 | let overrideSettingValue 46 | try { 47 | overrideSettingValue = game.settings.get(MODULE_NAME, settingName + "_override") 48 | } catch(e) { 49 | overrideSettingValue = "override_off" 50 | } 51 | if (overrideSettingValue == "override_off") { 52 | return game.settings.get(MODULE_NAME, settingName) 53 | } 54 | if (game.settings.settings.get(MODULE_NAME + "." + settingName).type.name == "Boolean") { 55 | overrideSettingValue = (overrideSettingValue == "on") 56 | } 57 | return overrideSettingValue 58 | } 59 | 60 | class SettingsOverrideMenu extends FormApplication { 61 | constructor() { 62 | super() 63 | } 64 | 65 | static get defaultOptions() { 66 | return foundry.utils.mergeObject(super.defaultOptions, { 67 | classes: ['form'], 68 | popOut: true, 69 | template: `/modules/${MODULE_NAME}/templates/settings-override.hbs`, 70 | id: `${MODULE_NAME}-settings-override-form`, 71 | title: `${MODULE_DISPLAY_NAME} - Settings Override`, 72 | }) 73 | } 74 | 75 | getData() { 76 | const touchVttOverrideSettings = [...game.settings.settings].filter(s => s[0].startsWith(MODULE_NAME) && s[0].endsWith("_override")) 77 | const data = { 78 | settings: Object.fromEntries( 79 | [...touchVttOverrideSettings] 80 | .map(s => { 81 | s[0] = s[0].split(".")[1] 82 | var settingValue = game.settings.get(MODULE_NAME, s[0]) 83 | s[1].currentValue = settingValue 84 | return s 85 | }) 86 | ), 87 | } 88 | // Send data to the template 89 | return data 90 | } 91 | 92 | async _updateObject(event, formData) { 93 | const data = expandObject(formData) 94 | for (let setting in data) { 95 | game.settings.set(MODULE_NAME, setting, data[setting]) 96 | } 97 | this.reloadConfirm() 98 | } 99 | 100 | async reloadConfirm() { 101 | const reload = await Dialog.confirm({ 102 | title: game.i18n.localize("SETTINGS.ReloadPromptTitle"), 103 | content: `

${game.i18n.localize("SETTINGS.ReloadPromptBody")}

` 104 | }) 105 | if ( !reload ) return 106 | if ( game.user.isGM ) game.socket.emit("reload") 107 | foundry.utils.debouncedReload() 108 | } 109 | 110 | activateListeners(html) { 111 | super.activateListeners(html) 112 | } 113 | } 114 | 115 | export function registerTouchSettings() { 116 | // Overrides 117 | 118 | game.settings.register(MODULE_NAME, CORE_FUNCTIONALITY + "_override", { 119 | name: "Core Functionality", 120 | hint: "Caution: disabling this option will remove all TouchVTT functionality, and other options will be ignored", 121 | scope: "world", 122 | config: false, 123 | type: String, 124 | choices: { 125 | ["on"]: "On", 126 | ["off"]: "Off", 127 | ["override_off"]: "Don't override" 128 | }, 129 | default: "override_off", 130 | }) 131 | 132 | game.settings.register(MODULE_NAME, GESTURE_MODE_SETTING + "_override", { 133 | name: "Zoom / Pan Gestures", 134 | hint: "Select the gesture to use for zooming & panning the game canvas", 135 | scope: "world", 136 | config: false, 137 | type: String, 138 | choices: { 139 | [GESTURE_MODE_COMBINED]: "Zoom & Pan with 2 fingers", 140 | [GESTURE_MODE_SPLIT]: "Zoom with 2 fingers, pan with 3 fingers", 141 | [GESTURE_MODE_OFF]: "No zoom or pan gestures", 142 | ["override_off"]: "Don't override" 143 | }, 144 | default: "override_off", 145 | }) 146 | 147 | game.settings.register(MODULE_NAME, DIRECTIONAL_ARROWS_SETTING + "_override", { 148 | name: "Direction arrows in Token HUD", 149 | hint: "Enables / disables the addition of arrow buttons used to rotate a token in the token's right-click menu", 150 | scope: "world", 151 | config: false, 152 | type: String, 153 | choices: { 154 | [DIRECTIONAL_ARROWS_ON]: "On", 155 | [DIRECTIONAL_ARROWS_OFF]: "Off", 156 | ["override_off"]: "Don't override" 157 | }, 158 | default: "override_off", 159 | }) 160 | 161 | game.settings.register(MODULE_NAME, EASY_TARGET_SETTING + "_override", { 162 | name: "Targeting behavior", 163 | hint: "Controls if and how unowned tokens can be targeted via the touch interface", 164 | scope: "world", 165 | config: false, 166 | type: String, 167 | choices: { 168 | [EASY_TARGET_OFF]: "Disabled", 169 | [EASY_TARGET_SINGLE]: "Allow single target", 170 | [EASY_TARGET_MULTIPLE]: "Allow multiple targets", 171 | ["override_off"]: "Don't override" 172 | }, 173 | default: "override_off", 174 | }) 175 | 176 | game.settings.register(MODULE_NAME, MEASUREMENT_HUD_SETTING + "_override", { 177 | name: "Measurement HUD", 178 | hint: "Shows a UI while measuring distance with the ruler, allowing you to set waypoints or move your token", 179 | scope: "world", 180 | config: false, 181 | type: String, 182 | choices: { 183 | [MEASUREMENT_HUD_OFF]: "Disabled", 184 | [MEASUREMENT_HUD_RIGHT]: "Show right", 185 | [MEASUREMENT_HUD_LEFT]: "Show left", 186 | ["override_off"]: "Don't override" 187 | }, 188 | default: "override_off", 189 | }) 190 | 191 | game.settings.register(MODULE_NAME, LARGE_BUTTONS_SETTING + "_override", { 192 | name: "Enlarge buttons in on-screen UI", 193 | hint: "Increases the size of menu bar buttons to make them easier to use with touch controls", 194 | scope: "world", 195 | config: false, 196 | type: String, 197 | choices: { 198 | ["on"]: "On", 199 | ["off"]: "Off", 200 | ["override_off"]: "Don't override" 201 | }, 202 | default: "override_off", 203 | }) 204 | 205 | game.settings.register(MODULE_NAME, PAUSE_BUTTON_SETTING + "_override", { 206 | name: "Show a play/pause button", 207 | hint: "Adds a play/pause button to the UI controls", 208 | scope: "world", 209 | config: false, 210 | type: String, 211 | choices: { 212 | ["on"]: "On", 213 | ["off"]: "Off", 214 | ["override_off"]: "Don't override" 215 | }, 216 | default: "override_off", 217 | }) 218 | 219 | game.settings.register(MODULE_NAME, REMOVE_HOVER_EFFECTS + "_override", { 220 | name: "Remove hover effects", 221 | hint: "Disable hover effects on touch devices", 222 | scope: "world", 223 | config: false, 224 | type: String, 225 | choices: { 226 | ["on"]: "On", 227 | ["off"]: "Off", 228 | ["override_off"]: "Don't override" 229 | }, 230 | default: "override_off", 231 | }) 232 | 233 | game.settings.register(MODULE_NAME, DISABLE_DBLCLICK + "_override", { 234 | name: "Disable Double Clicks", 235 | hint: "Disable double clicks on the canvas. This might help with accidental opening of actor sheets on IR frames", 236 | scope: "world", 237 | config: false, 238 | type: String, 239 | choices: { 240 | ["on"]: "On", 241 | ["off"]: "Off", 242 | ["override_off"]: "Don't override" 243 | }, 244 | default: "override_off", 245 | }) 246 | 247 | game.settings.register(MODULE_NAME, DISABLE_DRAGDROP + "_override", { 248 | name: "Disable Drag and Drop", 249 | hint: "Disable touch drag and drop. This might help with accidental copying of items", 250 | scope: "world", 251 | config: false, 252 | type: String, 253 | choices: { 254 | ["on"]: "On", 255 | ["off"]: "Off", 256 | ["override_off"]: "Don't override" 257 | }, 258 | default: "override_off", 259 | }) 260 | 261 | game.settings.register(MODULE_NAME, CANVAS_RIGHT_CLICK_TIMEOUT + "_override", { 262 | name: "Canvas right-click timer (ms)", 263 | hint: "How long a touch on the canvas takes to become a right click", 264 | scope: "world", 265 | config: false, 266 | type: String, 267 | choices: { 268 | ["on"]: "On", 269 | ["off"]: "Off", 270 | ["override_off"]: "Don't override" 271 | }, 272 | default: "override_off", 273 | }) 274 | 275 | game.settings.register(MODULE_NAME, CANVAS_LONG_PRESS_TIMEOUT + "_override", { 276 | name: "Canvas ping timer (ms)", 277 | hint: "How long a touch on the canvas takes to become a ping", 278 | scope: "world", 279 | config: false, 280 | type: String, 281 | choices: { 282 | ["on"]: "On", 283 | ["off"]: "Off", 284 | ["override_off"]: "Don't override" 285 | }, 286 | default: "override_off", 287 | }) 288 | 289 | // Client settings 290 | 291 | game.settings.register(MODULE_NAME, CORE_FUNCTIONALITY, { 292 | name: "Core Functionality" + (game.settings.get(MODULE_NAME, CORE_FUNCTIONALITY + "_override") == "override_off" ? "" : " *"), 293 | hint: "Caution: disabling this option will remove all TouchVTT functionality, and other options will be ignored", 294 | scope: "client", 295 | config: true, 296 | requiresReload: true, 297 | type: Boolean, 298 | default: true, 299 | }) 300 | 301 | game.settings.register(MODULE_NAME, GESTURE_MODE_SETTING, { 302 | name: "Zoom / Pan Gestures" + (game.settings.get(MODULE_NAME, GESTURE_MODE_SETTING + "_override") == "override_off" ? "" : " *"), 303 | hint: "Select the gesture to use for zooming & panning the game canvas", 304 | scope: "client", 305 | config: true, 306 | type: String, 307 | choices: { 308 | [GESTURE_MODE_COMBINED]: "Zoom & Pan with 2 fingers", 309 | [GESTURE_MODE_SPLIT]: "Zoom with 2 fingers, pan with 3 fingers", 310 | [GESTURE_MODE_OFF]: "No zoom or pan gestures", 311 | }, 312 | default: GESTURE_MODE_COMBINED, 313 | }) 314 | 315 | game.settings.register(MODULE_NAME, ZOOM_THRESHOLD_SETTING, { 316 | name: "Zoom Sensitivity", 317 | hint: "Sensitivity of the zoom gesture (if enabled)", 318 | scope: "client", 319 | config: false, 320 | type: Number, 321 | range: { 322 | min: 0, 323 | max: 100, 324 | step: 1 325 | }, 326 | default: 100, 327 | }) 328 | 329 | game.settings.register(MODULE_NAME, PAN_THRESHOLD_SETTING, { 330 | name: "Pan Sensitivity", 331 | hint: "Sensitivity of the pan gesture (if enabled)", 332 | scope: "client", 333 | config: false, 334 | type: Number, 335 | range: { 336 | min: 0, 337 | max: 100, 338 | step: 1 339 | }, 340 | default: 100, 341 | }) 342 | 343 | game.settings.register(MODULE_NAME, DIRECTIONAL_ARROWS_SETTING, { 344 | name: "Direction arrows in Token HUD" + (game.settings.get(MODULE_NAME, DIRECTIONAL_ARROWS_SETTING + "_override") == "override_off" ? "" : " *"), 345 | hint: "Enables / disables the addition of arrow buttons used to rotate a token in the token's right-click menu", 346 | scope: "client", 347 | config: true, 348 | type: String, 349 | choices: { 350 | [DIRECTIONAL_ARROWS_ON]: "On", 351 | [DIRECTIONAL_ARROWS_OFF]: "Off", 352 | }, 353 | default: DIRECTIONAL_ARROWS_ON, 354 | }) 355 | 356 | game.settings.register(MODULE_NAME, EASY_TARGET_SETTING, { 357 | name: "Targeting behavior" + (game.settings.get(MODULE_NAME, EASY_TARGET_SETTING + "_override") == "override_off" ? "" : " *"), 358 | hint: "Controls if and how unowned tokens can be targeted via the touch interface", 359 | scope: "client", 360 | config: true, 361 | type: String, 362 | choices: { 363 | [EASY_TARGET_OFF]: "Disabled", 364 | [EASY_TARGET_SINGLE]: "Allow single target", 365 | [EASY_TARGET_MULTIPLE]: "Allow multiple targets", 366 | }, 367 | default: EASY_TARGET_SINGLE, 368 | }) 369 | 370 | game.settings.register(MODULE_NAME, MEASUREMENT_HUD_SETTING, { 371 | name: "Measurement HUD" + (game.settings.get(MODULE_NAME, MEASUREMENT_HUD_SETTING + "_override") == "override_off" ? "" : " *"), 372 | hint: "Shows a UI while measuring distance with the ruler, allowing you to set waypoints or move your token", 373 | scope: "client", 374 | config: true, 375 | type: String, 376 | choices: { 377 | [MEASUREMENT_HUD_OFF]: "Disabled", 378 | [MEASUREMENT_HUD_RIGHT]: "Show right", 379 | [MEASUREMENT_HUD_LEFT]: "Show left", 380 | }, 381 | default: MEASUREMENT_HUD_RIGHT, 382 | }) 383 | 384 | game.settings.register(MODULE_NAME, LARGE_BUTTONS_SETTING, { 385 | name: "Enlarge buttons in on-screen UI" + (game.settings.get(MODULE_NAME, LARGE_BUTTONS_SETTING + "_override") == "override_off" ? "" : " *"), 386 | hint: "Increases the size of menu bar buttons to make them easier to use with touch controls", 387 | scope: "client", 388 | config: true, 389 | type: Boolean, 390 | default: false, 391 | onChange: enabled => updateButtonSize(enabled), 392 | }) 393 | 394 | game.settings.register(MODULE_NAME, PAUSE_BUTTON_SETTING, { 395 | name: "Show a play/pause button" + (game.settings.get(MODULE_NAME, PAUSE_BUTTON_SETTING + "_override") == "override_off" ? "" : " *"), 396 | hint: "Adds a play/pause button to the UI controls", 397 | scope: "client", 398 | config: true, 399 | type: Boolean, 400 | default: true, 401 | onChange: enabled => toggleUtilityControls(enabled), 402 | }) 403 | 404 | game.settings.register(MODULE_NAME, REMOVE_HOVER_EFFECTS, { 405 | name: "Remove hover effects" + (game.settings.get(MODULE_NAME, REMOVE_HOVER_EFFECTS + "_override") == "override_off" ? "" : " *"), 406 | hint: "Disable hover effects on touch devices", 407 | scope: "client", 408 | config: true, 409 | requiresReload: true, 410 | type: Boolean, 411 | default: false, 412 | }) 413 | 414 | game.settings.register(MODULE_NAME, DISABLE_DBLCLICK, { 415 | name: "Disable Double Clicks" + (game.settings.get(MODULE_NAME, DISABLE_DBLCLICK + "_override") == "override_off" ? "" : " *"), 416 | hint: "Disable double clicks on the canvas. This might help with accidental opening of actor sheets on IR frames", 417 | scope: "client", 418 | config: true, 419 | requiresReload: true, 420 | type: Boolean, 421 | default: false, 422 | }) 423 | 424 | game.settings.register(MODULE_NAME, DISABLE_DRAGDROP, { 425 | name: "Disable Drag and Drop" + (game.settings.get(MODULE_NAME, DISABLE_DRAGDROP + "_override") == "override_off" ? "" : " *"), 426 | hint: "Disable touch drag and drop. This might help with accidental copying of items", 427 | scope: "client", 428 | config: true, 429 | requiresReload: true, 430 | type: Boolean, 431 | default: false, 432 | }) 433 | 434 | game.settings.register(MODULE_NAME, CANVAS_RIGHT_CLICK_TIMEOUT, { 435 | name: "Canvas right-click timer (ms)" + (game.settings.get(MODULE_NAME, CANVAS_RIGHT_CLICK_TIMEOUT + "_override") == "override_off" ? "" : " *"), 436 | hint: "How long a touch on the canvas takes to become a right click", 437 | scope: "client", 438 | config: true, 439 | type: Number, 440 | range: { 441 | min: 100, 442 | step: 50, 443 | max: 3000 444 | }, 445 | default: 400, 446 | }) 447 | 448 | game.settings.register(MODULE_NAME, CANVAS_LONG_PRESS_TIMEOUT, { 449 | name: "Canvas ping timer (ms)" + (game.settings.get(MODULE_NAME, CANVAS_LONG_PRESS_TIMEOUT + "_override") == "override_off" ? "" : " *"), 450 | hint: "How long a touch on the canvas takes to become a ping", 451 | scope: "client", 452 | config: true, 453 | type: Number, 454 | range: { 455 | min: 100, 456 | step: 50, 457 | max: 3000 458 | }, 459 | default: 1000, 460 | }) 461 | 462 | game.settings.register(MODULE_NAME, DEBUG_MODE_SETTING, { 463 | name: "Enable Debug Mode", 464 | hint: "Sends additional log messages to the developer console", 465 | scope: "client", 466 | config: true, 467 | requiresReload: true, 468 | type: Boolean, 469 | default: false, 470 | }) 471 | 472 | // Override menu 473 | game.settings.registerMenu(MODULE_NAME, "SettingsOverrideMenu", { 474 | name: "Client Settings Overrides", 475 | label: "Configure Overrides", 476 | hint: "Configure which client settings are forced by the GM.", 477 | icon: "fas fa-bars", 478 | type: SettingsOverrideMenu, 479 | restricted: true 480 | }) 481 | 482 | // Testing new calibration menu 483 | game.settings.registerMenu(MODULE_NAME, "GestureCalibrationMenu", { 484 | name: "Gesture Sensitivity Calibration", 485 | label: "Calibrate Touch Gestures", 486 | hint: "Gesture detection can be influenced by your display size and resolution. Use this tool to calibrate if you have issues with sensitivity.", 487 | icon: "fas fa-wrench", 488 | type: GestureCalibrationMenu, 489 | restricted: false 490 | }) 491 | 492 | // Hook to disable overridden settings 493 | Hooks.on("renderSettingsConfig", (settingsConfig, settingsElem, settingsInfo) => { 494 | var touchVttSettings = settingsInfo.categories.find(c => c.id == MODULE_NAME).settings 495 | let overridePresent = false 496 | touchVttSettings.forEach(setting => { 497 | let overridden = setting.name.endsWith("*") 498 | let input = settingsElem.find(`[name='${setting.id}']`) 499 | input.prop("disabled", overridden) 500 | overridePresent |= overridden 501 | }) 502 | if (overridePresent) { 503 | settingsElem.find(`[data-tab='${MODULE_NAME}'] h2`).after($("").html("Some settings, indicated with an asterisk (*), are being overridden by the GM. The values selected here might not be accurate.")) 504 | } 505 | }) 506 | 507 | } 508 | -------------------------------------------------------------------------------- /src/foundryvtt/FoundryCanvas.js: -------------------------------------------------------------------------------- 1 | import ObjectUtils from '../utils/ObjectUtils.js' 2 | import FoundryUser from './FoundryUser.js' 3 | import FoundryModules from './FoundryModules.js' 4 | 5 | const LOCK_VIEW_KEY = 'LockView' 6 | 7 | class FoundryCanvas { 8 | get raw() { 9 | return canvas 10 | } 11 | 12 | get zoom() { 13 | return this.raw.stage.scale.x 14 | } 15 | 16 | pan({ x, y, zoom }) { 17 | this.raw.pan({ x, y, scale: zoom }) 18 | } 19 | 20 | zoom(scale) { 21 | this.raw.pan({ scale }) 22 | } 23 | 24 | get worldTransform() { 25 | return this.raw.stage.transform.worldTransform 26 | } 27 | 28 | screenToWorld({ x, y }) { 29 | return this.worldTransform.applyInverse({ x, y }) 30 | } 31 | 32 | worldToScreen({ x, y }) { 33 | return this.worldTransform.apply({ x, y }) 34 | } 35 | 36 | worldToScreenLength(length) { 37 | return this.worldTransform.apply({ x: length, y: 0 }).x - this.worldTransform.apply({ x: 0, y: 0 }).x 38 | } 39 | 40 | getWorldTransformWith({ zoom }, { discrete = true } = {}) { 41 | const copy = ObjectUtils.cloneObject(this.worldTransform) 42 | if (discrete) { 43 | zoom = Math.round(zoom * 100) / 100 //< PIXI rounds zoom values to 2 decimals for some reason 44 | } 45 | 46 | // No rotation => we can just assign the zoom level to the matrix' diagonal 47 | copy.a = copy.d = zoom 48 | return copy 49 | } 50 | 51 | get gridSize() { 52 | return this.raw.grid.size 53 | } 54 | 55 | toRelativeCoordinates({ x, y }) { 56 | const size = this.worldSize 57 | return { 58 | x: x / size.x, 59 | y: y / size.y 60 | } 61 | } 62 | 63 | get worldSize() { 64 | return { 65 | x: this.raw.scene.data.width, 66 | y: this.raw.scene.data.height, 67 | } 68 | } 69 | 70 | get worldCenter() { 71 | const size = this.worldSize 72 | return { 73 | x: size.x / 2, 74 | y: size.y / 2, 75 | } 76 | } 77 | 78 | isZoomAllowed() { 79 | if (FoundryUser.isGm) { 80 | return true 81 | } 82 | if (!FoundryModules.isActive(LOCK_VIEW_KEY)) { 83 | return true 84 | } 85 | return !this.raw.scene.getFlag(LOCK_VIEW_KEY, 'lockZoom') 86 | } 87 | 88 | isPanAllowed() { 89 | if (FoundryUser.isGm) { 90 | return true 91 | } 92 | if (!FoundryModules.isActive(LOCK_VIEW_KEY)) { 93 | return true 94 | } 95 | return !this.raw.scene.getFlag(LOCK_VIEW_KEY, 'lockPan') 96 | } 97 | 98 | get ruler() { 99 | const layer = canvas && canvas.controls 100 | return layer && layer.ruler 101 | } 102 | } 103 | 104 | export default new FoundryCanvas() 105 | -------------------------------------------------------------------------------- /src/foundryvtt/FoundryModules.js: -------------------------------------------------------------------------------- 1 | class FoundryModules { 2 | isActive(key) { 3 | const module = game.modules.get(key) 4 | return module != null && module.active 5 | } 6 | } 7 | 8 | export default new FoundryModules() 9 | -------------------------------------------------------------------------------- /src/foundryvtt/FoundryUser.js: -------------------------------------------------------------------------------- 1 | class FoundryUser { 2 | get isGm() { 3 | return this.raw.isGM 4 | } 5 | 6 | get raw() { 7 | return game.user 8 | } 9 | } 10 | 11 | export default new FoundryUser() 12 | -------------------------------------------------------------------------------- /src/logic/AppTouchPointerEventsManager.js: -------------------------------------------------------------------------------- 1 | import TouchPointerEventsManager from './TouchPointerEventsManager.js' 2 | import {dispatchModifiedEvent} from "./FakeTouchEvent.js" 3 | 4 | class AppTouchPointerEventsManager extends TouchPointerEventsManager { 5 | constructor(selector) { 6 | super(selector) 7 | this.selector = selector 8 | this.scrollStart = null 9 | } 10 | 11 | contextMenuCanceler(e) { 12 | e.preventDefault() 13 | e.stopPropagation() 14 | } 15 | 16 | onStartMultiTouch(event) { 17 | dispatchModifiedEvent(event, "pointerup") 18 | } 19 | 20 | handleTouchMove(event) { 21 | this.updateActiveTouch(event) 22 | 23 | switch (this.touchIds.length) { 24 | case 2: 25 | case 3: 26 | case 4: 27 | if (this.gesturesEnabled()) { 28 | this.handleMultiFingerScroll(event) 29 | } 30 | break 31 | default: 32 | } 33 | } 34 | 35 | handleTouchEnd(event) { 36 | this.cleanUpTouch(event) 37 | this.scrollStart = null 38 | document.removeEventListener("contextmenu", this.contextMenuCanceler, true) 39 | } 40 | 41 | handleMultiFingerScroll() { 42 | document.addEventListener("contextmenu", this.contextMenuCanceler, true) 43 | const touchIds = this.touchIds 44 | const firstTouch = this.touches[touchIds[0]] 45 | 46 | const scrollable = this.findFirstScrollableParent(firstTouch.target) 47 | if (scrollable) { 48 | if (!this.scrollStart) { 49 | this.scrollStart = scrollable.scrollTop 50 | } 51 | scrollable.scrollTop = this.scrollStart + firstTouch.start.y - firstTouch.current.y 52 | } 53 | } 54 | 55 | findFirstScrollableParent(element) { 56 | let found = null 57 | while (!found && element?.closest(this.selector)) { 58 | if (element.scrollHeight > element.clientHeight + 50) { 59 | found = element 60 | } 61 | element = element.parentElement 62 | } 63 | return found 64 | } 65 | 66 | } 67 | 68 | AppTouchPointerEventsManager.init = function init(element) { 69 | return new AppTouchPointerEventsManager(element) 70 | } 71 | 72 | export default AppTouchPointerEventsManager 73 | -------------------------------------------------------------------------------- /src/logic/CanvasTouchPointerEventsManager.js: -------------------------------------------------------------------------------- 1 | import {MODULE_DISPLAY_NAME} from '../config/ModuleConstants.js' 2 | import {dispatchCopy, dispatchModifiedEvent} from "./FakeTouchEvent.js" 3 | import Vectors from './Vectors.js' 4 | import FoundryCanvas from '../foundryvtt/FoundryCanvas.js' 5 | import Screen from '../browser/Screen.js' 6 | import {getSetting, DEBUG_MODE_SETTING, ZOOM_THRESHOLD_SETTING, PAN_THRESHOLD_SETTING, GESTURE_MODE_SETTING, GESTURE_MODE_SPLIT, GESTURE_MODE_OFF} from '../config/TouchSettings.js' 7 | import TouchPointerEventsManager from './TouchPointerEventsManager.js' 8 | 9 | // This class is similar in structure to the original CanvasTouchToMouseAdapter, but it doesn't capture/prevent events 10 | // It only hooks into the PointerEvents and tracks them for specific fixes (by dispatching additional events) and multi-touch management 11 | 12 | class CanvasTouchPointerEventsManager extends TouchPointerEventsManager { 13 | constructor(element) { 14 | super(element) 15 | 16 | this._forcedZoomThreshold = null 17 | this._forcedPanThreshold = null 18 | 19 | this.GESTURE_STATUSES = {NONE: 0, WAITING: 1, ACTIVE: 2} 20 | this._zoomGesture = { 21 | status: this.GESTURE_STATUSES.NONE, 22 | initiatingCoords: null, 23 | initiatingWorldCoords: null, 24 | activationCoords: null, 25 | activationWorldCoords: null 26 | } 27 | this._panGesture = { 28 | status: this.GESTURE_STATUSES.NONE, 29 | initiatingCoords: null, 30 | activationCoords: null 31 | } 32 | 33 | // Fix for some trackpads sending pointerdown of type mouse without any previous move event 34 | const trackPadPointerUp = (evt) => { 35 | evt.preventDefault() 36 | evt.stopPropagation() 37 | evt.stopImmediatePropagation() 38 | document.body.removeEventListener("pointerup", trackPadPointerUp) 39 | dispatchModifiedEvent(evt, "pointerup") 40 | return false 41 | }; 42 | document.body.addEventListener("pointerdown", evt => { 43 | if (evt.isTrusted && !evt.pressure && evt.target === element) { 44 | evt.preventDefault() 45 | evt.stopPropagation() 46 | evt.stopImmediatePropagation() 47 | dispatchModifiedEvent(evt, "pointermove") 48 | dispatchModifiedEvent(evt, "pointerdown") 49 | document.body.addEventListener("pointerup", trackPadPointerUp) 50 | return false 51 | } 52 | }, { 53 | capture: true, 54 | passive: false, 55 | }) 56 | 57 | // New v11 fix (started in v2.2.3): we completely block these events as soon as possible. 58 | // We dispatch a pointermove to the location first (not for pointerup), then we dispatch a clone of the original. Except touchstart, that one is gone. 59 | // Chosen to also include v12 on 2025-03-26 to support Image Hover and similar. 60 | if (true || game.release.generation < 12) { 61 | Array("pointerdown", "pointermove", "pointerup", "pointerleave", "touchstart").forEach(e => { 62 | window.addEventListener(e, evt => { 63 | if (evt.isTrusted && (evt instanceof TouchEvent || ["touch", "pen"].includes(evt.pointerType)) && evt.target === element) { 64 | evt.preventDefault() 65 | evt.stopPropagation() 66 | evt.stopImmediatePropagation() 67 | 68 | if (["pointerdown", "pointermove"].includes(evt.type)) { 69 | dispatchModifiedEvent(evt, "pointermove", {button: -1, buttons: 0}) 70 | } 71 | 72 | if (evt.type !== "touchstart" && !(evt.type == "pointerleave" && evt.pointerType != "pen")) { 73 | dispatchCopy(evt) 74 | } 75 | return false 76 | } 77 | }, { 78 | capture: true, 79 | passive: false, 80 | }) 81 | }) 82 | 83 | // Force hover check on every placeable in the active layer on every pointerdown/pointerup 84 | // Also pointermove, but only our own 85 | Array("pointerdown", "pointerup", "pointermove").forEach(e => { 86 | document.body.addEventListener(e, (evt) => { 87 | if (evt.touchvttTrusted || evt.type !== "pointermove" && (evt.isTrusted && (evt instanceof TouchEvent || ["touch", "pen"].includes(evt.pointerType)) && evt.target === element)) { 88 | const mousePos = canvas.mousePosition 89 | // We do all the hoverOuts first 90 | canvas.activeLayer.placeables.forEach(p => { 91 | if (!p.bounds.contains(mousePos.x, mousePos.y)) { 92 | if (p.hover && p.mouseInteractionManager.state < MouseInteractionManager.INTERACTION_STATES.DRAG) { 93 | p._onHoverOut(new PointerEvent("pointerleave", {buttons: 0})) 94 | } 95 | } 96 | }) 97 | canvas.activeLayer.placeables.forEach(p => { 98 | if (p.bounds.contains(mousePos.x, mousePos.y)) { 99 | if (!p.hover) { 100 | p._onHoverIn(new PointerEvent("pointerenter", {buttons: 0})) 101 | } 102 | } 103 | }) 104 | 105 | } 106 | }, true) 107 | }) 108 | 109 | } 110 | } 111 | 112 | onTouchAdded(event) { 113 | if (this.touchIds.length > 1) { 114 | // This is to cancel any drag-style action (usually a selection rectangle) when we start having multiple touches 115 | const cancelEvent = new MouseEvent("contextmenu", {clientX: 0, clientY: 0, bubbles: true, cancelable: true, view: window, button: 2}) 116 | event.target.dispatchEvent(cancelEvent) 117 | canvas.mouseInteractionManager.callback("clearTimeouts"); 118 | } 119 | } 120 | 121 | onTouchRemoved(event) { 122 | if (this.touchIds.length > 0) { 123 | this.disableGestures() 124 | if (getSetting(DEBUG_MODE_SETTING)) { 125 | console.log(MODULE_DISPLAY_NAME + ": disabled gestures") 126 | } 127 | } else { 128 | this.enableGestures() 129 | if (getSetting(DEBUG_MODE_SETTING)) { 130 | console.log(MODULE_DISPLAY_NAME + ": enabled gestures") 131 | } 132 | } 133 | canvas.mouseInteractionManager.callback("clearTimeouts"); 134 | 135 | this._zoomGesture = { 136 | status: this.GESTURE_STATUSES.NONE, 137 | initiatingCoords: null, 138 | initiatingWorldCoords: null, 139 | activationCoords: null, 140 | activationWorldCoords: null 141 | } 142 | this._panGesture = { 143 | status: this.GESTURE_STATUSES.NONE, 144 | initiatingCoords: null, 145 | activationCoords: null 146 | } 147 | } 148 | 149 | handleTouchMove(event) { 150 | this.updateActiveTouch(event) 151 | 152 | switch (this.touchIds.length) { 153 | case 2: 154 | if (this.gesturesEnabled()) { 155 | if (this.useSplitGestures()) { 156 | this.handleTwoFingerZoom() 157 | } else { 158 | this.handleTwoFingerZoomAndPan() 159 | } 160 | } 161 | break 162 | 163 | case 3: 164 | case 4: 165 | if (this.gesturesEnabled()) { 166 | this.handleMultiFingerPan() 167 | } 168 | break 169 | 170 | default: 171 | } 172 | } 173 | 174 | handleTwoFingerZoomAndPan() { 175 | this.handleTwoFingerZoom() 176 | this.handleMultiFingerPan() 177 | } 178 | 179 | handleTwoFingerZoom() { 180 | if (!FoundryCanvas.isZoomAllowed()) { 181 | return 182 | } 183 | 184 | const touchIds = this.touchIds 185 | const firstTouch = this.touches[touchIds[0]] 186 | const secondTouch = this.touches[touchIds[1]] 187 | 188 | if (this._zoomGesture.status == this.GESTURE_STATUSES.NONE) { 189 | this._zoomGesture.initiatingCoords = [{...firstTouch.current}, {...secondTouch.current}] 190 | this._zoomGesture.initiatingWorldCoords = [FoundryCanvas.screenToWorld({...firstTouch.current}), FoundryCanvas.screenToWorld({...secondTouch.current})] 191 | this._zoomGesture.status = this.GESTURE_STATUSES.WAITING 192 | } 193 | const initiatingDistance = Vectors.distance(this._zoomGesture.initiatingCoords[0], this._zoomGesture.initiatingCoords[1]) 194 | const currentDistance = Vectors.distance(firstTouch.current, secondTouch.current) 195 | 196 | if (this._zoomGesture.status < this.GESTURE_STATUSES.ACTIVE) { 197 | if (Math.abs(currentDistance - initiatingDistance) > this.zoomThresholdFunction(this.getZoomThreshold())) { 198 | this._zoomGesture.activationCoords = [{...firstTouch.current}, {...secondTouch.current}] 199 | this._zoomGesture.activationWorldCoords = [FoundryCanvas.screenToWorld({...firstTouch.current}), FoundryCanvas.screenToWorld({...secondTouch.current})] 200 | this._zoomGesture.status = this.GESTURE_STATUSES.ACTIVE 201 | } 202 | } 203 | 204 | if (this._zoomGesture.status == this.GESTURE_STATUSES.ACTIVE) { 205 | FoundryCanvas.zoom(this.calcZoom()) 206 | } 207 | 208 | } 209 | 210 | calcZoom() { 211 | const touchIds = this.touchIds 212 | const initiatingWorldDistance = Vectors.distance(this._zoomGesture.initiatingWorldCoords[0], this._zoomGesture.initiatingWorldCoords[1]) 213 | const activationWorldDistance = Vectors.distance(this._zoomGesture.activationWorldCoords[0], this._zoomGesture.activationWorldCoords[1]) 214 | const newScreenDistance = Vectors.distance(this.touches[touchIds[0]].current, this.touches[touchIds[1]].current) 215 | const newScale = newScreenDistance / ((initiatingWorldDistance + activationWorldDistance) / 2) 216 | return newScale 217 | } 218 | 219 | zoomThresholdFunction(threshold) { 220 | if (threshold == 0) return Infinity 221 | if (threshold > 80) { 222 | return -threshold/2 + 50 223 | } else if (threshold > 30) { 224 | return -threshold + 90 225 | } else { 226 | return -10 * threshold + 360 227 | } 228 | } 229 | 230 | setForcedZoomThreshold(threshold) { 231 | this._forcedZoomThreshold = threshold 232 | } 233 | 234 | unsetForcedZoomThreshold(threshold) { 235 | this._forcedZoomThreshold = null 236 | } 237 | 238 | getZoomThreshold() { 239 | if (this._forcedZoomThreshold !== null) { 240 | return this._forcedZoomThreshold 241 | } 242 | return getSetting(ZOOM_THRESHOLD_SETTING) 243 | } 244 | 245 | setForcedPanThreshold(threshold) { 246 | this._forcedPanThreshold = threshold 247 | } 248 | 249 | unsetForcedPanThreshold(threshold) { 250 | this._forcedPanThreshold = null 251 | } 252 | 253 | getPanThreshold() { 254 | if (this._forcedPanThreshold !== null) { 255 | return this._forcedPanThreshold 256 | } 257 | return getSetting(PAN_THRESHOLD_SETTING) 258 | } 259 | 260 | calcPanCorrection(transform, touch) { 261 | const touchedPointOnWorldAfter = transform.applyInverse(touch.current) 262 | return Vectors.subtract(touchedPointOnWorldAfter, touch.world) 263 | } 264 | 265 | handleMultiFingerPan() { 266 | if (!FoundryCanvas.isPanAllowed()) { 267 | return 268 | } 269 | 270 | const touchIds = this.touchIds 271 | const adjustedTransform = FoundryCanvas.worldTransform 272 | 273 | const firstTouch = this.touches[touchIds[0]] 274 | if (this._panGesture.status == this.GESTURE_STATUSES.NONE) { 275 | this._panGesture.initiatingCoords = {...firstTouch.current} 276 | this._panGesture.status = this.GESTURE_STATUSES.WAITING 277 | } 278 | const currentDistance = Vectors.distance(firstTouch.current, this._panGesture.initiatingCoords) 279 | if (this._panGesture.status < this.GESTURE_STATUSES.ACTIVE) { 280 | if (currentDistance > this.panThresholdFunction(this.getPanThreshold())) { 281 | this._panGesture.status = this.GESTURE_STATUSES.ACTIVE 282 | } 283 | } 284 | 285 | if (this._panGesture.status == this.GESTURE_STATUSES.ACTIVE) { 286 | //let panCorrection 287 | //if (touchIds.length === 2) { 288 | // panCorrection = Vectors.centerBetween( 289 | // this.calcPanCorrection(adjustedTransform, this.touches[touchIds[0]]), 290 | // this.calcPanCorrection(adjustedTransform, this.touches[touchIds[1]]), 291 | // ) 292 | //} else { 293 | // panCorrection = Vectors.centerOf( 294 | // this.calcPanCorrection(adjustedTransform, this.touches[touchIds[0]]), 295 | // this.calcPanCorrection(adjustedTransform, this.touches[touchIds[1]]), 296 | // this.calcPanCorrection(adjustedTransform, this.touches[touchIds[2]]), 297 | // ) 298 | //} 299 | 300 | // It seems to me that panning to the center between the touches is disorienting and creates unwanted movement 301 | // I prefer trying out this version where we anchor to the first touch, I'll leave the existing above in case we want to revert 302 | let panCorrection = this.calcPanCorrection(adjustedTransform, this.touches[touchIds[0]]) 303 | 304 | const centerBefore = FoundryCanvas.screenToWorld(Screen.center) 305 | const worldCenter = Vectors.subtract(centerBefore, panCorrection) 306 | 307 | FoundryCanvas.pan({ x: worldCenter.x, y: worldCenter.y }) 308 | } 309 | } 310 | 311 | panThresholdFunction(threshold) { 312 | if (threshold == 0) return Infinity 313 | if (threshold > 50) { 314 | return -4/5 * threshold + 80 315 | } else if (threshold > 20) { 316 | return -2 * threshold + 140 317 | } else { 318 | return -10 * threshold + 300 319 | } 320 | } 321 | 322 | useSplitGestures() { 323 | return getSetting(GESTURE_MODE_SETTING) === GESTURE_MODE_SPLIT 324 | } 325 | 326 | useNoGestures() { 327 | return getSetting(GESTURE_MODE_SETTING) === GESTURE_MODE_OFF 328 | } 329 | 330 | isTouchPointerEvent(event) { 331 | return super.isTouchPointerEvent(event) || event.touchvttTrusted 332 | } 333 | 334 | gesturesEnabled() { 335 | return this._gesturesEnabled && !this.useNoGestures() 336 | } 337 | 338 | } 339 | 340 | CanvasTouchPointerEventsManager.init = function init(element) { 341 | return new CanvasTouchPointerEventsManager(element) 342 | } 343 | 344 | export default CanvasTouchPointerEventsManager 345 | -------------------------------------------------------------------------------- /src/logic/DirectionalArrows.js: -------------------------------------------------------------------------------- 1 | import {wrapMethod} from '../utils/Injection' 2 | import {getSetting, DIRECTIONAL_ARROWS_ON, DIRECTIONAL_ARROWS_SETTING} from '../config/TouchSettings' 3 | 4 | export function initDirectionalArrows() { 5 | if (tokenHudExists()) { 6 | wrapMethod('TokenHUD.prototype.activateListeners', function (originalMethod, html, ...args) { 7 | const superResult = originalMethod(html, ...args) 8 | if (areDirectionalArrowsEnabled() && !getActiveToken()?.document?.lockRotation) { 9 | injectArrowHtml(html) 10 | } 11 | return superResult 12 | }) 13 | } 14 | } 15 | 16 | function injectArrowHtml(html) { 17 | const leftColumn = html.find('.col.left') 18 | const middleColumn = html.find('.col.middle') 19 | const rightColumn = html.find('.col.right') 20 | 21 | addArrow(middleColumn, 0) 22 | addArrow(rightColumn, 45) 23 | addArrow(rightColumn, 90) 24 | addArrow(rightColumn, 135) 25 | addArrow(middleColumn, 180) 26 | addArrow(leftColumn, 225) 27 | addArrow(leftColumn, 270) 28 | addArrow(leftColumn, 315) 29 | } 30 | 31 | function addArrow(parent, angle) { 32 | const title = game.i18n.localize(`TOUCHVTT.Rotate${angle}`) 33 | const arrow = $( 34 | `
35 | 36 |
` 37 | ) 38 | arrow.on('click', () => { 39 | const activeToken = getActiveToken() 40 | if (canControl(activeToken)) { 41 | activeToken.rotate((angle + 180) % 360) 42 | } 43 | }) 44 | parent.prepend(arrow) 45 | } 46 | 47 | function tokenHudExists() { 48 | return typeof TokenHUD === 'function' && 49 | typeof TokenHUD.prototype === 'object' && 50 | typeof TokenHUD.prototype.activateListeners === 'function' 51 | } 52 | 53 | function getActiveToken() { 54 | return canvas && canvas.hud && canvas.hud.token && canvas.hud.token.object 55 | } 56 | 57 | function areDirectionalArrowsEnabled() { 58 | return getSetting(DIRECTIONAL_ARROWS_SETTING) === DIRECTIONAL_ARROWS_ON 59 | } 60 | 61 | function canControl(token) { 62 | return token != null && 63 | typeof token._canDrag === 'function' && 64 | token._canDrag() 65 | } 66 | -------------------------------------------------------------------------------- /src/logic/EasyTarget.js: -------------------------------------------------------------------------------- 1 | import {getSetting, EASY_TARGET_OFF, EASY_TARGET_SETTING, EASY_TARGET_SINGLE} from '../config/TouchSettings' 2 | 3 | export function callbackForEasyTarget(event, events) { 4 | if (event == "clickLeft") { 5 | const token = events[0].target 6 | if (isEasyTargetEnabled() && isSelectToolActive() && token instanceof Token && isUnownedToken(token.mouseInteractionManager, event)) { 7 | // The user usually cannot click this token => we'll select it 8 | targetToken(token) 9 | } 10 | } 11 | } 12 | 13 | function targetToken(token) { 14 | const releaseOthers = getSettingValue() === EASY_TARGET_SINGLE 15 | token.setTarget(!token.isTargeted, { releaseOthers }) 16 | } 17 | 18 | function getSettingValue() { 19 | return getSetting(EASY_TARGET_SETTING) 20 | } 21 | 22 | function isEasyTargetEnabled() { 23 | return getSettingValue() !== EASY_TARGET_OFF 24 | } 25 | 26 | function isSelectToolActive() { 27 | return game.activeTool === 'select' 28 | } 29 | 30 | function isUnownedToken(mouseInteractionManager, event) { 31 | return typeof mouseInteractionManager.can === 'function' && !mouseInteractionManager.can('clickLeft', event) 32 | } 33 | -------------------------------------------------------------------------------- /src/logic/FakeTouchEvent.js: -------------------------------------------------------------------------------- 1 | // Used to dispatch an artificial PointerEvent based on an original one, with optional customized buttons and position offset 2 | // Starting from v1.13 the original event is not prevented anymore, we only do this as additions where necessary to fix stuff 3 | 4 | export function dispatchModifiedEvent(originalEvent, newEventType = originalEvent.type, {trusted = true, button = originalEvent.button, buttons = originalEvent.buttons} = {}, offset = 0) { 5 | const mouseEventInitProperties = { 6 | clientX: (originalEvent.clientX || originalEvent.touches[0]?.clientX) + offset, 7 | clientY: (originalEvent.clientY || originalEvent.touches[0]?.clientY) + offset, 8 | screenX: (originalEvent.screenX || originalEvent.touches[0]?.screenX) + offset, 9 | screenY: (originalEvent.screenY || originalEvent.touches[0]?.screenY) + offset, 10 | ctrlKey: originalEvent.ctrlKey || false, 11 | altKey: originalEvent.altKey || false, 12 | shiftKey: originalEvent.shiftKey || false, 13 | metaKey: originalEvent.metaKey || false, 14 | button: button, 15 | buttons: buttons, 16 | relatedTarget: originalEvent.relatedTarget || null, 17 | region: originalEvent.region || null, 18 | detail: 0, 19 | view: window, 20 | sourceCapabilities: originalEvent.sourceCapabilities, 21 | bubbles: true, 22 | cancelable: true, 23 | composed: true, 24 | } 25 | 26 | const pointerEventInit = { 27 | pointerId: originalEvent.pointerId, 28 | pointerType: "mouse", 29 | isPrimary: true, 30 | ...mouseEventInitProperties, 31 | } 32 | 33 | var target = originalEvent.nativeEvent ? originalEvent.nativeEvent.target : originalEvent.target 34 | 35 | let pointerEvent 36 | if (newEventType.startsWith("mouse")) { 37 | pointerEvent = new MouseEvent(newEventType, mouseEventInitProperties) 38 | } else { 39 | pointerEvent = new PointerEvent(newEventType, pointerEventInit) 40 | } 41 | 42 | pointerEvent.touchvttTrusted = trusted 43 | 44 | //console.log("dispatching modified", pointerEvent, originalEvent) 45 | target.dispatchEvent(pointerEvent) 46 | } 47 | 48 | export function dispatchCopy(originalEvent) { 49 | let newEventObject = {} 50 | for (let key in originalEvent) { 51 | newEventObject[key] = originalEvent[key] 52 | } 53 | newEventObject.pointerType = "mouse" 54 | let newEvent = new originalEvent.constructor(originalEvent.type, newEventObject) 55 | newEvent.touchvttTrusted = true 56 | var target = originalEvent.nativeEvent ? originalEvent.nativeEvent.target : originalEvent.target 57 | 58 | //console.log("dispatching copied", newEvent, originalEvent) 59 | target.dispatchEvent(newEvent) 60 | } 61 | -------------------------------------------------------------------------------- /src/logic/MouseButton.js: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | left: 0, 3 | right: 2 4 | }) 5 | -------------------------------------------------------------------------------- /src/logic/Touch.js: -------------------------------------------------------------------------------- 1 | import FoundryCanvas from '../foundryvtt/FoundryCanvas.js' 2 | import TouchContext from './TouchContext.js' 3 | import Vectors from './Vectors.js' 4 | import {idOf} from '../utils/EventUtils.js' 5 | 6 | class Touch { 7 | constructor(event, touch, { context = TouchContext.PRIMARY_CLICK } = {}) { 8 | this.id = idOf(event) 9 | this.start = Object.freeze({ x: touch.clientX, y: touch.clientY }) 10 | this.last = this.start 11 | this.current = this.last 12 | this.context = context 13 | this.clientX = touch.clientX 14 | this.clientY = touch.clientY 15 | this.screenX = touch.screenX 16 | this.screenY = touch.screenY 17 | this.target = event.target 18 | this.latestEvent = event 19 | 20 | if (canvas.app) { 21 | this.world = FoundryCanvas.screenToWorld(this.current) //< Position in the world where the user touched 22 | } 23 | this.movementDistance = 0 24 | this.movement = Vectors.zero 25 | 26 | //// console.log(`New Touch: ${context.name}, ID ${this.id}`) 27 | } 28 | 29 | get identifier() { 30 | return this.id 31 | } 32 | 33 | update(event, touch) { 34 | this.latestEvent = event 35 | this.last = this.current 36 | this.current = Object.freeze({ x: touch.clientX, y: touch.clientY }) 37 | this.movementDistance += Vectors.distance(this.last, this.current) 38 | this.movement = Vectors.add(this.movement, Vectors.subtract(this.current, this.last)) 39 | } 40 | } 41 | 42 | export default Touch 43 | -------------------------------------------------------------------------------- /src/logic/TouchContext.js: -------------------------------------------------------------------------------- 1 | import MouseButton from './MouseButton.js' 2 | 3 | class TouchContext { 4 | constructor({ name, forwardingEvents = [], mouseButton = null, isFinal = true } = {}) { 5 | this.name = name 6 | this.forwardingEvents = forwardingEvents 7 | this.mouseButton = mouseButton 8 | this.isFinal = isFinal 9 | } 10 | 11 | forwardsEvent(event) { 12 | let eventType = event 13 | if (typeof event === 'object' && typeof event.type === 'string') { 14 | eventType = event.type 15 | } 16 | return this.forwardingEvents.indexOf(eventType) !== -1 17 | } 18 | } 19 | 20 | const ALL_EVENTS = [ 21 | 'pointerdown', 'pointermove', 'pointerup', 'pointercancel', 22 | 'touchstart', 'touchmove', 'touchend', 'touchcancel' 23 | ] 24 | 25 | const PRIMARY_CLICK = Object.freeze(new TouchContext({ 26 | name: 'primary-click', 27 | forwardingEvents: ALL_EVENTS, 28 | mouseButton: MouseButton.left, 29 | isFinal: false, 30 | })) 31 | const SECONDARY_CLICK = Object.freeze(new TouchContext({ 32 | name: 'secondary-click', 33 | forwardingEvents: ALL_EVENTS, 34 | mouseButton: MouseButton.right, 35 | })) 36 | const ZOOM_PAN_GESTURE = Object.freeze(new TouchContext({ 37 | name: 'zoom-pan', 38 | })) 39 | 40 | export default Object.freeze({ 41 | PRIMARY_CLICK, 42 | SECONDARY_CLICK, 43 | ZOOM_PAN_GESTURE, 44 | }) 45 | -------------------------------------------------------------------------------- /src/logic/TouchPointerEventsManager.js: -------------------------------------------------------------------------------- 1 | import {MODULE_DISPLAY_NAME} from '../config/ModuleConstants.js' 2 | import {getSetting, DEBUG_MODE_SETTING} from '../config/TouchSettings.js' 3 | import Touch from './Touch.js' 4 | 5 | class TouchPointerEventsManager { 6 | constructor(element) { 7 | this.touches = {} 8 | const touchHandler = this.handleTouch.bind(this) 9 | for (const eventType of this.getListenerEvents()) { 10 | if (element instanceof HTMLElement) { 11 | element.addEventListener(eventType, touchHandler, this.getEventListenerOptions()) 12 | } else { 13 | document.addEventListener(eventType, (event) => { 14 | if (event.target.closest(element)) { 15 | touchHandler(event) 16 | } 17 | }, this.getEventListenerOptions()) 18 | } 19 | } 20 | 21 | this._gesturesEnabled = true 22 | } 23 | 24 | preHandleAll(event) {} 25 | 26 | preHandleTouch(event) {} 27 | 28 | handleTouch(event) { 29 | const preLength = this.touchIds.length 30 | 31 | this.preHandleAll(event) 32 | 33 | if (!this.isTouchPointerEvent(event)) { 34 | return 35 | } 36 | 37 | this.preHandleTouch(event) 38 | 39 | if (this.shouldHandleEvent(event)) { 40 | // shouldHandleEvent excludes our fake events at this time 41 | switch (event.type) { 42 | case 'pointerdown': 43 | this.handleTouchStart(event) 44 | break 45 | 46 | case 'pointermove': 47 | this.handleTouchMove(event) 48 | break 49 | 50 | case 'pointerup': 51 | this.handleTouchEnd(event) 52 | //this.handleEndAll(event) 53 | break 54 | 55 | case 'pointercancel': 56 | this.handleEndAll(event) 57 | break 58 | 59 | default: 60 | console.warn(`Unknown touch event type ${event.type}`) 61 | break 62 | } 63 | } 64 | if (preLength != this.touchIds.length && getSetting(DEBUG_MODE_SETTING)) { 65 | console.log(MODULE_DISPLAY_NAME + ": touches changed: " + preLength + " -> " + this.touchIds.length) 66 | } 67 | } 68 | 69 | onStartMultiTouch(event) { 70 | 71 | } 72 | 73 | onTouchAdded(event) { 74 | 75 | } 76 | 77 | onTouchRemoved(event) { 78 | 79 | } 80 | 81 | handleTouchStart(event) { 82 | const prevTouches = this.touchIds.length 83 | this.updateActiveTouch(event) 84 | if (prevTouches <= 1 && this.touchIds.length > 1) { 85 | this.onStartMultiTouch(event) 86 | } 87 | } 88 | 89 | handleTouchEnd(event) { 90 | this.cleanUpTouch(event) 91 | } 92 | 93 | handleEndAll(event) { 94 | this.cleanUpAll() 95 | } 96 | 97 | handleTouchMove(event) { 98 | this.updateActiveTouch(event) 99 | } 100 | 101 | updateActiveTouch(event) { 102 | var id = event.pointerId 103 | if (this.touches[id] != null) { 104 | this.touches[id].update(event, event) 105 | } else { 106 | if (event.type == "pointerdown" && event.buttons == 1 && event.pointerType != "pen") { 107 | this.touches[id] = new Touch(event, event) 108 | this.onTouchAdded(event) 109 | } 110 | } 111 | } 112 | 113 | cleanUpAll() { 114 | this.touches = {} 115 | this.onTouchRemoved(event) 116 | } 117 | 118 | cleanUpTouch(event) { 119 | delete this.touches[event.pointerId] 120 | this.onTouchRemoved(event) 121 | } 122 | 123 | getEventListenerOptions() { 124 | return { 125 | capture: true, 126 | passive: false, 127 | } 128 | } 129 | 130 | getListenerEvents() { 131 | return ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'] 132 | } 133 | 134 | shouldHandleEvent(event) { 135 | return event.isTrusted || event.touchvttTrusted 136 | } 137 | 138 | isTouchPointerEvent(event) { 139 | return event instanceof PointerEvent && ["touch", "pen"].includes(event.pointerType) 140 | } 141 | 142 | get touchIds() { 143 | return Object.keys(this.touches) 144 | } 145 | 146 | disableGestures() { 147 | this._gesturesEnabled = false 148 | } 149 | 150 | enableGestures() { 151 | this._gesturesEnabled = true 152 | } 153 | 154 | gesturesEnabled() { 155 | return this._gesturesEnabled 156 | } 157 | } 158 | 159 | TouchPointerEventsManager.init = function init(element) { 160 | return new TouchPointerEventsManager(element) 161 | } 162 | 163 | export default TouchPointerEventsManager 164 | -------------------------------------------------------------------------------- /src/logic/TouchVTTMouseInteractionManager.js: -------------------------------------------------------------------------------- 1 | import {MODULE_DISPLAY_NAME} from '../config/ModuleConstants.js' 2 | import {getSetting, DEBUG_MODE_SETTING} from '../config/TouchSettings.js' 3 | 4 | export class TouchVTTMouseInteractionManager { 5 | constructor(object, layer, permissions={}, callbacks={}, options={}) { 6 | this.object = object; 7 | this.layer = layer; 8 | this.permissions = permissions; 9 | this.callbacks = callbacks; 10 | 11 | /** 12 | * Interaction options which configure handling workflows 13 | * @type {{target: PIXI.DisplayObject, dragResistance: number}} 14 | */ 15 | this.options = options; 16 | 17 | /** 18 | * The current interaction state 19 | * @type {number} 20 | */ 21 | if (getSetting(DEBUG_MODE_SETTING)) { 22 | this._state = this.states.NONE; 23 | Object.defineProperty(this, 'state', { 24 | get() { 25 | return this._state; 26 | }, 27 | set(value) { 28 | console.log(MODULE_DISPLAY_NAME + ": " + this.object.constructor.name, this._state + " -> " + value + " (" + (new Error()).stack?.split("\n")[2]?.trim().split(" ")[1] + ")") 29 | this._state = value; 30 | } 31 | }); 32 | } else { 33 | this.state = this.states.NONE; 34 | } 35 | 36 | /** 37 | * Bound interaction data object to populate with custom data. 38 | * @type {Object} 39 | */ 40 | this.interactionData = {}; 41 | 42 | /** 43 | * The drag handling time 44 | * @type {number} 45 | */ 46 | this.dragTime = 0; 47 | 48 | /** 49 | * The time of the last left-click event 50 | * @type {number} 51 | */ 52 | this.lcTime = 0; 53 | 54 | /** 55 | * The time of the last right-click event 56 | * @type {number} 57 | */ 58 | this.rcTime = 0; 59 | 60 | /** 61 | * A flag for whether we are right-click dragging 62 | * @type {boolean} 63 | */ 64 | this._dragRight = false; 65 | 66 | /** 67 | * An optional ControlIcon instance for the object 68 | * @type {ControlIcon} 69 | */ 70 | this.controlIcon = this.options.target ? this.object[this.options.target] : undefined; 71 | 72 | /** 73 | * The view id pertaining to the PIXI Application. 74 | * If not provided, default to canvas.app.view.id 75 | * @type {ControlIcon} 76 | */ 77 | const app = this.options.application ?? canvas.app; 78 | this.viewId = app.view.id; 79 | } 80 | 81 | /** 82 | * Bound handlers which can be added and removed 83 | * @type {Object} 84 | */ 85 | #handlers = {}; 86 | 87 | /** 88 | * Enumerate the states of a mouse interaction workflow. 89 | * 0: NONE - the object is inactive 90 | * 1: HOVER - the mouse is hovered over the object 91 | * 2: CLICKED - the object is clicked 92 | * 3: DRAG - the object is being dragged 93 | * 4: DROP - the object is being dropped 94 | * @enum {number} 95 | */ 96 | static INTERACTION_STATES = { 97 | NONE: 0, 98 | HOVER: 1, 99 | CLICKED: 2, 100 | DRAG: 3, 101 | DROP: 4 102 | }; 103 | 104 | /** 105 | * Enumerate the states of handle outcome. 106 | * -2: SKIPPED - the handler has been skipped by previous logic 107 | * -1: DISALLOWED - the handler has dissallowed further process 108 | * 1: REFUSED - the handler callback has been processed and is refusing further process 109 | * 2: ACCEPTED - the handler callback has been processed and is accepting further process 110 | * @enum {number} 111 | */ 112 | static _HANDLER_OUTCOME = { 113 | SKIPPED: -2, 114 | DISALLOWED: -1, 115 | REFUSED: 1, 116 | ACCEPTED: 2 117 | }; 118 | 119 | /** 120 | * The number of milliseconds of mouse click depression to consider it a long press. 121 | * @type {number} 122 | */ 123 | static LONG_PRESS_DURATION_MS = 99999999; 124 | 125 | /** 126 | * Global timeout for the long-press event. 127 | * @type {number|null} 128 | */ 129 | static longPressTimeout = null; 130 | 131 | /* -------------------------------------------- */ 132 | 133 | /** 134 | * Get the target. 135 | * @type {PIXI.DisplayObject} 136 | */ 137 | get target() { 138 | return this.options.target ? this.object[this.options.target] : this.object; 139 | } 140 | 141 | /** 142 | * Is this mouse manager in a dragging state? 143 | * @type {boolean} 144 | */ 145 | get isDragging() { 146 | return this.state >= this.states.DRAG; 147 | } 148 | 149 | /* -------------------------------------------- */ 150 | 151 | /** 152 | * Activate interactivity for the handled object 153 | */ 154 | activate() { 155 | 156 | // Remove existing listeners 157 | this.state = this.states.NONE; 158 | this.target.removeAllListeners(); 159 | 160 | // Create bindings for all handler functions 161 | this.#handlers = { 162 | mouseover: this.#handleMouseOver.bind(this), 163 | mouseout: this.#handleMouseOut.bind(this), 164 | mousedown: this.#handleMouseDown.bind(this), 165 | rightdown: this.#handleRightDown.bind(this), 166 | mousemove: this.#handleMouseMove.bind(this), 167 | mouseup: this.#handleMouseUp.bind(this), 168 | contextmenu: this.#handleDragCancel.bind(this) 169 | }; 170 | 171 | // Activate hover events to start the workflow 172 | this.#activateHoverEvents(); 173 | 174 | // Set the target as interactive 175 | this.target.eventMode = "static"; 176 | return this; 177 | } 178 | 179 | /* -------------------------------------------- */ 180 | 181 | /** 182 | * Test whether the current user has permission to perform a step of the workflow 183 | * @param {string} action The action being attempted 184 | * @param {Event|PIXI.FederatedEvent} event The event being handled 185 | * @returns {boolean} Can the action be performed? 186 | */ 187 | can(action, event) { 188 | const fn = this.permissions[action]; 189 | if ( typeof fn === "boolean" ) return fn; 190 | if ( fn instanceof Function ) return fn.call(this.object, game.user, event); 191 | return true; 192 | } 193 | 194 | /* -------------------------------------------- */ 195 | 196 | /** 197 | * Execute a callback function associated with a certain action in the workflow 198 | * @param {string} action The action being attempted 199 | * @param {Event|PIXI.FederatedEvent} event The event being handled 200 | * @param {...*} args Additional callback arguments. 201 | * @returns {boolean} A boolean which may indicate that the event was handled by the callback. 202 | * Events which do not specify a callback are assumed to have been handled as no-op. 203 | */ 204 | callback(action, event, ...args) { 205 | const fn = this.callbacks[action]; 206 | if ( fn instanceof Function ) { 207 | this.#assignInteractionData(event); 208 | return fn.call(this.object, event, ...args) ?? true; 209 | } 210 | return true; 211 | } 212 | 213 | /* -------------------------------------------- */ 214 | 215 | /** 216 | * A reference to the possible interaction states which can be observed 217 | * @returns {Object} 218 | */ 219 | get states() { 220 | return this.constructor.INTERACTION_STATES; 221 | } 222 | 223 | /* -------------------------------------------- */ 224 | 225 | /** 226 | * A reference to the possible interaction states which can be observed 227 | * @returns {Object} 228 | */ 229 | get handlerOutcomes() { 230 | return this.constructor._HANDLER_OUTCOME; 231 | } 232 | 233 | /* -------------------------------------------- */ 234 | /* Listener Activation and Deactivation */ 235 | /* -------------------------------------------- */ 236 | 237 | /** 238 | * Activate a set of listeners which handle hover events on the target object 239 | */ 240 | #activateHoverEvents() { 241 | // Disable and re-register mouseover and mouseout handlers 242 | this.target.off("pointerover", this.#handlers.mouseover).on("pointerover", this.#handlers.mouseover); 243 | this.target.off("pointerout", this.#handlers.mouseout).on("pointerout", this.#handlers.mouseout); 244 | } 245 | 246 | /* -------------------------------------------- */ 247 | 248 | /** 249 | * Activate a new set of listeners for click events on the target object. 250 | */ 251 | #activateClickEvents() { 252 | this.#deactivateClickEvents(); 253 | this.target.on("pointerdown", this.#handlers.mousedown); 254 | this.target.on("pointerup", this.#handlers.mouseup); 255 | //this.target.on("mouseupoutside", this.#handlers.mouseup); 256 | this.target.on("pointerupoutside", this.#handlers.mouseup); 257 | this.target.on("rightdown", this.#handlers.rightdown); 258 | this.target.on("rightup", this.#handlers.mouseup); 259 | this.target.on("rightupoutside", this.#handlers.mouseup); 260 | } 261 | 262 | /* -------------------------------------------- */ 263 | 264 | /** 265 | * Deactivate event listeners for click events on the target object. 266 | */ 267 | #deactivateClickEvents() { 268 | this.target.off("pointerdown", this.#handlers.mousedown); 269 | this.target.off("pointerup", this.#handlers.mouseup); 270 | //this.target.off("mouseupoutside", this.#handlers.mouseup); 271 | this.target.off("pointerupoutside", this.#handlers.mouseup); 272 | this.target.off("rightdown", this.#handlers.rightdown); 273 | this.target.off("rightup", this.#handlers.mouseup); 274 | this.target.off("rightupoutside", this.#handlers.mouseup); 275 | } 276 | 277 | /* -------------------------------------------- */ 278 | 279 | /** 280 | * Activate events required for handling a drag-and-drop workflow 281 | */ 282 | #activateDragEvents() { 283 | this.#deactivateDragEvents(); 284 | this.layer.on("pointermove", this.#handlers.mousemove); 285 | if ( !this._dragRight ) { 286 | canvas.app.view.addEventListener("contextmenu", this.#handlers.contextmenu, {capture: true}); 287 | } 288 | } 289 | 290 | /* -------------------------------------------- */ 291 | 292 | /** 293 | * Deactivate events required for handling drag-and-drop workflow. 294 | * @param {boolean} [silent] Set to true to activate the silent mode. 295 | */ 296 | #deactivateDragEvents(silent) { 297 | this.layer.off("pointermove", this.#handlers.mousemove); 298 | canvas.app.view.removeEventListener("contextmenu", this.#handlers.contextmenu, {capture: true}); 299 | } 300 | 301 | /* -------------------------------------------- */ 302 | /* Hover In and Hover Out */ 303 | /* -------------------------------------------- */ 304 | 305 | /** 306 | * Handle mouse-over events which activate downstream listeners and do not stop propagation. 307 | * @param {PIXI.FederatedEvent} event 308 | */ 309 | #handleMouseOver(event) { 310 | // Verify if the event can be handled 311 | const action = "hoverIn"; 312 | if ( (this.state !== this.states.NONE) || !(event.nativeEvent.target.id === this.viewId) ) { 313 | return this.#debug(action, event, this.handlerOutcomes.SKIPPED); 314 | } 315 | if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); 316 | 317 | // Invoke the callback function 318 | const handled = this.callback(action, event); 319 | if ( !handled ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); 320 | 321 | // Advance the workflow state and activate click events 322 | this.state = Math.max(this.state || 0, this.states.HOVER); 323 | this.#activateClickEvents(); 324 | return this.#debug(action, event); 325 | } 326 | 327 | /* -------------------------------------------- */ 328 | 329 | /** 330 | * Handle mouse-out events which terminate hover workflows and do not stop propagation. 331 | * @param {PIXI.FederatedEvent} event 332 | */ 333 | #handleMouseOut(event) { 334 | if ( event.pointerType === "touch" ) return; // Ignore Touch events 335 | const action = "hoverOut"; 336 | if ( (this.state !== this.states.HOVER) || !(event.nativeEvent.target.id === this.viewId) ) { 337 | return this.#debug(action, event, this.handlerOutcomes.SKIPPED); 338 | } 339 | if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); 340 | 341 | // Was the mouse-out event handled by the callback? 342 | if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); 343 | 344 | // Downgrade the workflow state and deactivate click events 345 | if ( this.state === this.states.HOVER ) { 346 | this.state = this.states.NONE; 347 | this.#deactivateClickEvents(); 348 | } 349 | return this.#debug(action, event); 350 | } 351 | 352 | /* -------------------------------------------- */ 353 | /* Left Click and Double Click */ 354 | /* -------------------------------------------- */ 355 | 356 | /** 357 | * Handle mouse-down events which activate downstream listeners. 358 | * Stop further propagation only if the event is allowed by either single or double-click. 359 | * @param {PIXI.FederatedEvent} event 360 | */ 361 | #handleMouseDown(event) { 362 | if ( event.button !== 0 ) return; // Only support standard left-click 363 | if ( ![this.states.HOVER, this.states.CLICKED, this.states.DRAG].includes(this.state) ) return; 364 | 365 | // Determine double vs single click 366 | const now = Date.now(); 367 | const isDouble = (now - this.lcTime) <= 250; 368 | this.lcTime = now; 369 | 370 | // Set the origin point from layer local position 371 | this.interactionData.origin = event.getLocalPosition(this.layer); 372 | 373 | // Activate a timeout to detect long presses 374 | if ( !isDouble ) { 375 | clearTimeout(this.constructor.longPressTimeout); 376 | this.constructor.longPressTimeout = setTimeout(() => { 377 | this.#handleLongPress(event, this.interactionData.origin); 378 | }, MouseInteractionManager.LONG_PRESS_DURATION_MS); 379 | } 380 | 381 | // Dispatch to double and single-click handlers 382 | if ( isDouble && this.can("clickLeft2", event) ) return this.#handleClickLeft2(event); 383 | else return this.#handleClickLeft(event); 384 | } 385 | 386 | /* -------------------------------------------- */ 387 | 388 | /** 389 | * Handle mouse-down which trigger a single left-click workflow. 390 | * @param {PIXI.FederatedEvent} event 391 | */ 392 | #handleClickLeft(event) { 393 | const action = "clickLeft"; 394 | if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); 395 | this._dragRight = false; 396 | 397 | // Was the left-click event handled by the callback? 398 | if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); 399 | 400 | // Upgrade the workflow state and activate drag event handlers 401 | if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED; 402 | canvas.currentMouseManager = this; 403 | if ( (this.state < this.states.DRAG) && this.can("dragStart", event) ) this.#activateDragEvents(); 404 | return this.#debug(action, event); 405 | } 406 | 407 | /* -------------------------------------------- */ 408 | 409 | /** 410 | * Handle mouse-down which trigger a single left-click workflow. 411 | * @param {PIXI.FederatedEvent} event 412 | */ 413 | #handleClickLeft2(event) { 414 | const action = "clickLeft2"; 415 | if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); 416 | return this.#debug(action, event); 417 | } 418 | 419 | /* -------------------------------------------- */ 420 | 421 | /** 422 | * Handle a long mouse depression to trigger a long-press workflow. 423 | * @param {PIXI.FederatedEvent} event The mousedown event. 424 | * @param {PIXI.Point} origin The original canvas co-ordinates of the mouse click 425 | */ 426 | #handleLongPress(event, origin) { 427 | const action = "longPress"; 428 | if ( this.callback(action, event, origin) === false ) { 429 | return this.#debug(action, event, this.handlerOutcomes.REFUSED); 430 | } 431 | return this.#debug(action, event); 432 | } 433 | 434 | /* -------------------------------------------- */ 435 | /* Right Click and Double Click */ 436 | /* -------------------------------------------- */ 437 | 438 | /** 439 | * Handle right-click mouse-down events. 440 | * Stop further propagation only if the event is allowed by either single or double-click. 441 | * @param {PIXI.FederatedEvent} event 442 | */ 443 | #handleRightDown(event) { 444 | if ( ![this.states.HOVER, this.states.CLICKED, this.states.DRAG].includes(this.state) ) return; 445 | if ( event.button !== 2 ) return; // Only support standard left-click 446 | 447 | // Determine double vs single click 448 | const now = Date.now(); 449 | const isDouble = (now - this.rcTime) <= 250; 450 | this.rcTime = now; 451 | 452 | // Update event data 453 | this.interactionData.origin = event.getLocalPosition(this.layer); 454 | 455 | // Dispatch to double and single-click handlers 456 | if ( isDouble && this.can("clickRight2", event) ) return this.#handleClickRight2(event); 457 | else return this.#handleClickRight(event); 458 | } 459 | 460 | /* -------------------------------------------- */ 461 | 462 | /** 463 | * Handle single right-click actions. 464 | * @param {PIXI.FederatedEvent} event 465 | */ 466 | #handleClickRight(event) { 467 | const action = "clickRight"; 468 | if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); 469 | this._dragRight = true; 470 | 471 | // Was the right-click event handled by the callback? 472 | if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); 473 | 474 | // Upgrade the workflow state and activate drag event handlers 475 | if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED; 476 | canvas.currentMouseManager = this; 477 | if ( (this.state < this.states.DRAG) && this.can("dragRight", event) ) this.#activateDragEvents(); 478 | return this.#debug(action, event); 479 | } 480 | 481 | /* -------------------------------------------- */ 482 | 483 | /** 484 | * Handle double right-click actions. 485 | * @param {PIXI.FederatedEvent} event 486 | */ 487 | #handleClickRight2(event) { 488 | const action = "clickRight2"; 489 | if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); 490 | return this.#debug(action, event); 491 | } 492 | 493 | /* -------------------------------------------- */ 494 | /* Drag and Drop */ 495 | /* -------------------------------------------- */ 496 | 497 | /** 498 | * Handle mouse movement during a drag workflow 499 | * @param {PIXI.FederatedEvent} event 500 | */ 501 | #handleMouseMove(event) { 502 | if ( ![this.states.CLICKED, this.states.DRAG].includes(this.state) ) return; 503 | 504 | // Limit dragging to 60 updates per second 505 | const now = Date.now(); 506 | if ( (now - this.dragTime) < canvas.app.ticker.elapsedMS ) return; 507 | this.dragTime = now; 508 | 509 | // Update interaction data 510 | const data = this.interactionData; 511 | data.destination = event.getLocalPosition(this.layer); 512 | 513 | // Handling rare case when origin is not defined 514 | // FIXME: The root cause should be identified and this code removed 515 | if ( data.origin === undefined ) data.origin = new PIXI.Point().copyFrom(data.destination); 516 | 517 | // Begin a new drag event 518 | if ( this.state === this.states.CLICKED ) { 519 | const dx = data.destination.x - data.origin.x; 520 | const dy = data.destination.y - data.origin.y; 521 | const dz = Math.hypot(dx, dy); 522 | const r = this.options.dragResistance || (canvas.dimensions.size / 4); 523 | if ( dz >= r ) { 524 | return this.#handleDragStart(event); 525 | } 526 | } 527 | 528 | // Continue a drag event 529 | else return this.#handleDragMove(event); 530 | } 531 | 532 | /* -------------------------------------------- */ 533 | 534 | /** 535 | * Handle the beginning of a new drag start workflow, moving all controlled objects on the layer 536 | * @param {PIXI.FederatedEvent} event 537 | */ 538 | #handleDragStart(event) { 539 | clearTimeout(this.constructor.longPressTimeout); 540 | const action = this._dragRight ? "dragRightStart" : "dragLeftStart"; 541 | if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); 542 | const handled = this.callback(action, event); 543 | if ( handled ) this.state = this.states.DRAG; 544 | return this.#debug(action, event, handled ? this.handlerOutcomes.ACCEPTED : this.handlerOutcomes.REFUSED); 545 | } 546 | 547 | /* -------------------------------------------- */ 548 | 549 | /** 550 | * Handle the continuation of a drag workflow, moving all controlled objects on the layer 551 | * @param {PIXI.FederatedEvent} event 552 | */ 553 | #handleDragMove(event) { 554 | clearTimeout(this.constructor.longPressTimeout); 555 | const action = this._dragRight ? "dragRightMove" : "dragLeftMove"; 556 | if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); 557 | const handled = this.callback(action, event); 558 | if ( handled ) this.state = this.states.DRAG; 559 | return this.#debug(action, event, handled ? this.handlerOutcomes.ACCEPTED : this.handlerOutcomes.REFUSED); 560 | } 561 | 562 | /* -------------------------------------------- */ 563 | 564 | /** 565 | * Handle mouse up events which may optionally conclude a drag workflow 566 | * @param {PIXI.FederatedEvent} event 567 | */ 568 | #handleMouseUp(event) { 569 | if (getSetting(DEBUG_MODE_SETTING)) { 570 | console.log(MODULE_DISPLAY_NAME + ": " + this.object.constructor.name, "handleMouseUp from state " + this.state + ": ", event.target.constructor.name, event.constructor.name, event.type, event.pointerType, 571 | event.nativeEvent?.constructor.name, event.nativeEvent?.target.tagName, event.nativeEvent?.type, event.nativeEvent?.pointerType, "trust:" + event.nativeEvent?.isTrusted + "," + event.nativeEvent?.touchvttTrusted 572 | ) 573 | } 574 | 575 | if (ui.controls.activeControl == "walls" && typeof window.Touch !== "undefined" && event.nativeEvent instanceof Touch) { 576 | return true; 577 | } 578 | 579 | // We force clear timeouts (this callback is implemented in the wrapper in touch-vtt.js) 580 | // Because in some mixed touch/mouse combos the combination of events doesn't trigger any useful interaction event to automatically reset them 581 | this.callback("clearTimeouts"); 582 | 583 | clearTimeout(this.constructor.longPressTimeout); 584 | // If this is a touch hover event, treat it as a drag 585 | if ( (this.state === this.states.HOVER) && (event.pointerType === "touch") ) { 586 | this.state = this.states.DRAG; 587 | } 588 | 589 | // Save prior state 590 | const priorState = this.state; 591 | 592 | // Update event data 593 | this.interactionData.destination = event.getLocalPosition(this.layer); 594 | 595 | // Handling of a degenerate case: 596 | // When the manager is in a clicked state and that the button is released in another object 597 | const emulateHoverOut = (this.state === this.states.CLICKED) && !event.defaultPrevented 598 | && (event.target !== this.object) && (event.target?.parent !== this.object); 599 | if ( emulateHoverOut ) { 600 | event.stopPropagation(); 601 | this.state = this.states.HOVER; 602 | this.#deactivateClickEvents(); 603 | this.#handleMouseOut(event); 604 | } 605 | 606 | if ( this.state >= this.states.DRAG ) { 607 | event.stopPropagation(); 608 | if ( event.type.startsWith("right") && !this._dragRight ) return; 609 | this.#handleDragDrop(event); 610 | } 611 | 612 | // Continue a multi-click drag workflow 613 | if ( event.defaultPrevented ) { 614 | this.state = priorState; 615 | return this.#debug("mouseUp", event, this.handlerOutcomes.SKIPPED); 616 | } 617 | 618 | // Cancel the workflow 619 | return this.#handleDragCancel(event); 620 | } 621 | 622 | /* -------------------------------------------- */ 623 | 624 | /** 625 | * Handle the conclusion of a drag workflow, placing all dragged objects back on the layer 626 | * @param {PIXI.FederatedEvent} event 627 | */ 628 | #handleDragDrop(event) { 629 | const action = this._dragRight ? "dragRightDrop" : "dragLeftDrop"; 630 | if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); 631 | 632 | // Was the drag-drop event handled by the callback? 633 | if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED); 634 | 635 | // Update the workflow state 636 | this.state = this.states.DROP; 637 | return this.#debug(action, event); 638 | } 639 | 640 | /* -------------------------------------------- */ 641 | 642 | /** 643 | * Handle the cancellation of a drag workflow, resetting back to the original state 644 | * @param {PIXI.FederatedEvent} event 645 | */ 646 | #handleDragCancel(event) { 647 | this.cancel(event); 648 | } 649 | 650 | /* -------------------------------------------- */ 651 | 652 | /** 653 | * A public method to handle directly an event into this manager, according to its type. 654 | * Note: drag events are not handled. 655 | * @param {PIXI.FederatedEvent} event 656 | * @returns {boolean} Has the event been processed? 657 | */ 658 | handleEvent(event) { 659 | switch ( event.type ) { 660 | case "pointerover": 661 | this.#handleMouseOver(event); 662 | break; 663 | case "pointerout": 664 | this.#handleMouseOut(event); 665 | break; 666 | case "pointerup": 667 | this.#handleMouseUp(event); 668 | break; 669 | case "pointerdown": 670 | if ( event.button === 2 ) this.#handleRightDown(event); 671 | else this.#handleMouseDown(event); 672 | break; 673 | default: 674 | return false; 675 | } 676 | return true; 677 | } 678 | 679 | /* -------------------------------------------- */ 680 | 681 | /** 682 | * A public method to cancel a current interaction workflow from this manager. 683 | * @param {PIXI.FederatedEvent} event The event that initiates the cancellation 684 | */ 685 | cancel(event) { 686 | const action = this._dragRight ? "dragRightCancel" : "dragLeftCancel"; 687 | const endState = this.state; 688 | if ( endState <= this.states.HOVER ) return this.#debug(action, event, this.handlerOutcomes.SKIPPED); 689 | 690 | // Dispatch a cancellation callback 691 | if ( endState >= this.states.DRAG ) { 692 | if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED); 693 | } 694 | 695 | // Continue a multi-click drag workflow if the default event was prevented in the callback 696 | if ( event.defaultPrevented ) { 697 | this.state = this.states.DRAG; 698 | return this.#debug(action, event, this.handlerOutcomes.SKIPPED); 699 | } 700 | 701 | // Reset the interaction data and state and deactivate drag events 702 | this.interactionData = {}; 703 | this.state = this.states.HOVER; 704 | canvas.currentMouseManager = null; 705 | this.#deactivateDragEvents(); 706 | return this.#debug(action, event); 707 | } 708 | 709 | /* -------------------------------------------- */ 710 | 711 | /** 712 | * Display a debug message in the console (if mouse interaction debug is activated). 713 | * @param {string} action Which action to display? 714 | * @param {Event|PIXI.FederatedEvent} event Which event to display? 715 | * @param {number} [outcome=this.handlerOutcomes.ACCEPTED] The handler outcome. 716 | */ 717 | #debug(action, event, outcome=this.handlerOutcomes.ACCEPTED) { 718 | if ( CONFIG.debug.mouseInteraction ) { 719 | const name = this.object.constructor.name; 720 | const targetName = event.target?.constructor.name; 721 | const {eventPhase, type, button} = event; 722 | const state = Object.keys(this.states)[this.state.toString()]; 723 | let msg = `${name} | ${action} | state:${state} | target:${targetName} | phase:${eventPhase} | type:${type} | ` 724 | + `btn:${button} | skipped:${outcome <= -2} | allowed:${outcome > -1} | handled:${outcome > 1}`; 725 | console.debug(msg); 726 | } 727 | } 728 | 729 | /* -------------------------------------------- */ 730 | 731 | /** 732 | * Reset the mouse manager. 733 | * @param {object} [options] 734 | * @param {boolean} [options.interactionData=true] Reset the interaction data? 735 | * @param {boolean} [options.state=true] Reset the state? 736 | */ 737 | reset({interactionData=true, state=true}={}) { 738 | if ( CONFIG.debug.mouseInteraction ) { 739 | console.debug(`${this.object.constructor.name} | Reset | interactionData:${interactionData} | state:${state}`); 740 | } 741 | if ( interactionData ) this.interactionData = {}; 742 | if ( state ) this.state = MouseInteractionManager.INTERACTION_STATES.NONE; 743 | } 744 | 745 | /* -------------------------------------------- */ 746 | 747 | /** 748 | * Assign the interaction data to the event. 749 | * @param {PIXI.FederatedEvent} event 750 | */ 751 | #assignInteractionData(event) { 752 | this.interactionData.object = this.object; 753 | event.interactionData = this.interactionData; 754 | 755 | // Add deprecated event data references 756 | for ( const k of Object.keys(this.interactionData) ) { 757 | if ( event.hasOwnProperty(k) ) continue; 758 | /** 759 | * @deprecated since v11 760 | * @ignore 761 | */ 762 | Object.defineProperty(event, k, { 763 | get() { 764 | const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`; 765 | foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 12}); 766 | return this.interactionData[k]; 767 | }, 768 | set(value) { 769 | const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`; 770 | foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 12}); 771 | this.interactionData[k] = value; 772 | } 773 | }); 774 | } 775 | } 776 | } -------------------------------------------------------------------------------- /src/logic/Vectors.js: -------------------------------------------------------------------------------- 1 | class Vectors { 2 | get zero() { 3 | return { x: 0, y: 0 } 4 | } 5 | 6 | add(a, b) { 7 | return { 8 | x: a.x + b.x, 9 | y: a.y + b.y, 10 | } 11 | } 12 | 13 | subtract(a, b) { 14 | return { 15 | x: a.x - b.x, 16 | y: a.y - b.y, 17 | } 18 | } 19 | 20 | divideElements(a, b) { 21 | return { 22 | x: a.x / b.x, 23 | y: a.y / b.y, 24 | } 25 | } 26 | 27 | divideScalar(vector, scalar) { 28 | return { 29 | x: vector.x / scalar, 30 | y: vector.y / scalar, 31 | } 32 | } 33 | 34 | distance(a, b) { 35 | const diff = this.subtract(a, b) 36 | return this.length(diff) 37 | } 38 | 39 | distanceSquared(a, b) { 40 | const diff = this.subtract(a, b) 41 | return this.lengthSquared(diff) 42 | } 43 | 44 | centerBetween(a, b) { 45 | const diff = this.subtract(a, b) 46 | return this.add(a, this.divideScalar(diff, 2)) 47 | } 48 | 49 | centerOf(a, b, c) { 50 | const vectors = [a, b, c] 51 | const distances = [ 52 | this.distanceSquared(a, b) + this.distanceSquared(a, c), 53 | this.distanceSquared(b, a) + this.distanceSquared(b, c), 54 | this.distanceSquared(c, a) + this.distanceSquared(c, b), 55 | ] 56 | 57 | const centerIndex = distances.reduce((acc, currentValue, currentIndex) => { 58 | return currentValue < acc.distance ? 59 | { index: currentIndex, distance: currentValue } : 60 | acc 61 | }, { distance: Number.MAX_VALUE, index: -1 }).index 62 | 63 | const otherIndices = vectors.map((e, i) => i) 64 | otherIndices.splice(centerIndex, 1) 65 | 66 | let result = vectors[centerIndex] 67 | for (const otherIndex of otherIndices) { 68 | const diff = this.subtract(vectors[centerIndex], vectors[otherIndex]) 69 | result = this.add(result, this.divideScalar(diff, 2)) 70 | } 71 | return result 72 | } 73 | 74 | length(vector) { 75 | return Math.sqrt((vector.x ** 2) + (vector.y ** 2)) 76 | } 77 | 78 | lengthSquared(vector) { 79 | return (vector.x ** 2) + (vector.y ** 2) 80 | } 81 | 82 | abs(vector) { 83 | return { 84 | x: Math.abs(vector.x), 85 | y: Math.abs(vector.y), 86 | } 87 | } 88 | 89 | isEqual(a, b) { 90 | return a.x === b.x && a.y === b.y 91 | } 92 | } 93 | 94 | export default new Vectors() 95 | -------------------------------------------------------------------------------- /src/logic/WindowAppAdapter.js: -------------------------------------------------------------------------------- 1 | import {dispatchModifiedEvent} from './FakeTouchEvent.js' 2 | import {wrapMethod} from '../utils/Injection' 3 | import {MODULE_NAME, MODULE_DISPLAY_NAME} from '../config/ModuleConstants' 4 | import Vectors from './Vectors.js' 5 | import AppTouchPointerEventsManager from './AppTouchPointerEventsManager.js' 6 | import { getSetting, DISABLE_DRAGDROP } from '../config/TouchSettings.js' 7 | 8 | const STYLE_ID = `${MODULE_NAME}-draggable_apps_styles` 9 | 10 | const appStyle = ` 11 | .app, .application { 12 | touch-action: none; 13 | } 14 | 15 | .app .window-header, .application .window-header, .app .window-title, .application .window-title { 16 | touch-action: none; 17 | } 18 | 19 | .directory-item .handlebar { 20 | display: none; 21 | flex: 0 0 20px; 22 | align-self: center; 23 | font-size: 1.6em; 24 | z-index: 10; 25 | } 26 | 27 | .directory-item.document .handlebar { 28 | height: var(--sidebar-item-height); 29 | padding: 14px 0 0 4px; 30 | } 31 | 32 | .directory-item.folder .handlebar { 33 | line-height: 24px; 34 | margin: 0 4px 0 0; 35 | } 36 | 37 | .directory-item.compendium .handlebar { 38 | position: absolute; 39 | left: 6px; 40 | } 41 | 42 | body.touchvtt-using-touch .directory-item.compendium { 43 | flex-direction: row; 44 | } 45 | 46 | body.touchvtt-using-touch .directory-item.compendium .compendium-banner { 47 | pointer-events: none; 48 | } 49 | 50 | body.touchvtt-using-touch .directory-item .handlebar { 51 | display: flex; 52 | } 53 | ` 54 | 55 | function createStyleElement() { 56 | const style = document.createElement('style') 57 | style.setAttribute('id', STYLE_ID) 58 | style.innerHTML = appStyle 59 | document.head.append(style) 60 | return style 61 | } 62 | 63 | class WindowAppAdapter { 64 | constructor() { 65 | 66 | if (!getSetting(DISABLE_DRAGDROP)) { 67 | // Drag and Drop polyfill for touch events (https://github.com/Bernardo-Castilho/dragdroptouch) 68 | import('../utils/DragDropTouch.js') // This is an async import 69 | .then(() => { console.log(MODULE_DISPLAY_NAME + ": Loaded Drag and Drop polyfill") }) 70 | } 71 | 72 | this.lastClickInfo = {target: null, time: 0, touch: false} 73 | this.touchManager = AppTouchPointerEventsManager.init(".app:not(#touch-vtt-gesture-calibration-form), .application:not(#touch-vtt-gesture-calibration-form)") 74 | 75 | this.lastPointerDownCoords = null 76 | 77 | /**** Fix for small touch movements when trying to click - START */ 78 | // This includes, for example, the pf2e combat tracker sortable 79 | // We intercept/cancel touch move events between pointerdown and pointerup 80 | const cancelMoveEvent = ((evt) => { 81 | const evtCoords = {x: evt.clientX || evt.touches?.[0]?.clientX, y: evt.clientY || evt.touches?.[0]?.clientY} 82 | if (Vectors.distance(evtCoords, this.lastPointerDownCoords) < 10) { 83 | evt.preventDefault() 84 | evt.stopPropagation() 85 | evt.stopImmediatePropagation() 86 | return false 87 | } 88 | }).bind(this) 89 | document.addEventListener("pointerdown", evt => { 90 | if (evt.target.closest(".app, .application")) { 91 | this.lastPointerDownCoords = {x: evt.clientX, y: evt.clientY} 92 | Array("pointermove", "touchmove", "mousemove").forEach(e => { 93 | document.getElementById("combat-tracker").addEventListener(e, cancelMoveEvent, true) 94 | }) 95 | } 96 | }, true) 97 | document.addEventListener("pointerup", evt => { 98 | if (evt.target.closest(".app, .application")) { 99 | this.lastPointerDownCoords = null 100 | Array("pointermove", "touchmove", "mousemove").forEach(e => { 101 | document.getElementById("combat-tracker").removeEventListener(e, cancelMoveEvent, true) 102 | }) 103 | } 104 | }, true) 105 | /**** Fix for small touch movements when trying to click - END */ 106 | 107 | /*** Double-click management - Start ***/ 108 | // In both v11 and v12 (but in an especially weird way in v11) double clicks on app windows are triggered inconsistently for touch events 109 | // In v12, touching a window header triggers a dblclick 110 | // In v11, when rendered, double touching the header doesn't triggere a dblclick (I assume it's some interaction with the draggable), 111 | // but after double touching a different section of the window, the behavior becomes the same as v12 112 | // The brutal approach here is to just hijack and cancel any dblclick event on an app, and create our own as best as we can 113 | 114 | // Reminder: this would be cleaner using evt.sourceCapabilities.firesTouchEvents, but it's not supported by Firefox and Safari yet. 115 | // If updated in the future, we don't need to keep track of lastClickInfo.touch anymore, and we just filter by that in both listeners. 116 | 117 | // Cancel any native dblclick event on apps 118 | document.body.addEventListener("dblclick", (evt) => { 119 | const isInApp = !!evt.target.closest(".app, .application") 120 | if (evt.isTrusted && isInApp && this.lastClickInfo.touch) { // we only cancel native dblclick if the last click we received was touch-based 121 | evt.preventDefault() 122 | evt.stopImmediatePropagation() 123 | evt.stopPropagation() 124 | return false 125 | } 126 | }, true) 127 | 128 | // Manage click events and decide if we trigger an artificial double click 129 | document.body.addEventListener("click", (evt) => { 130 | if (!!evt.target.closest(".app, .application")) { 131 | this.manageTouchDblClick.call(this, evt) 132 | } 133 | }) 134 | 135 | /*** Double-click management - End ***/ 136 | 137 | // Avoid error on Drag and Drop polyfill 138 | wrapMethod('DragDrop.prototype._handleDragStart', function(originalMethod, event) { 139 | if (event.dataTransfer.items) { 140 | return originalMethod.call(this, event) 141 | } else { 142 | this.callback(event, "dragstart") 143 | if ( Object.keys(event.dataTransfer._data).length ) event.stopPropagation() 144 | } 145 | }, 'MIXED') 146 | 147 | } 148 | 149 | manageTouchDblClick(clickEvent) { 150 | const isTouch = ["touch", "pen"].includes(clickEvent.pointerType) 151 | if (isTouch && Date.now() - this.lastClickInfo.time < 500 && clickEvent.target == this.lastClickInfo.target) { 152 | dispatchModifiedEvent(clickEvent, "dblclick") 153 | this.lastClickInfo = {target: null, time: 0, touch: isTouch} 154 | } 155 | this.lastClickInfo = {target: clickEvent.target, time: Date.now(), touch: isTouch} 156 | } 157 | 158 | fixDirectoryScrolling(directory, usingTouch) { 159 | const directoryList = directory.element.find(".directory-list") 160 | if (directoryList.length > 0) { 161 | if (!usingTouch) { 162 | directoryList.find(".directory-item .handlebar").remove() 163 | directoryList.find(`li[draggable="false"].directory-item`).each(function(index, element) { 164 | element.draggable = true 165 | }) 166 | } else { 167 | directoryList.find(`li[draggable="true"].directory-item`).each(function(index, element) { 168 | element.draggable = false 169 | let handlebar = document.createElement("i") 170 | handlebar.className = "handlebar fas fa-grip-vertical"; 171 | handlebar.addEventListener("pointerdown", e => { 172 | element.draggable = true 173 | }, true) 174 | handlebar.addEventListener("pointerup", e => { 175 | if (["touch", "pen"].includes(e.pointerType)) { 176 | element.draggable = false 177 | } 178 | }, true) 179 | if (element.classList.contains("folder") || element.classList.contains("folder-like")) { 180 | element.getElementsByTagName("header")[0].prepend(handlebar) 181 | } else { 182 | element.prepend(handlebar) 183 | } 184 | }) 185 | } 186 | 187 | } 188 | } 189 | 190 | } 191 | 192 | WindowAppAdapter.init = function init() { 193 | createStyleElement() 194 | return new WindowAppAdapter() 195 | } 196 | 197 | export default WindowAppAdapter 198 | -------------------------------------------------------------------------------- /src/tools/DrawingTools.js: -------------------------------------------------------------------------------- 1 | export function installDrawingToolsControls(menuStructure) { 2 | const category = menuStructure.find(c => c.name === 'drawings') 3 | if (category == null || !Array.isArray(category.tools)) return 4 | 5 | category.tools.push({ 6 | // Simulate hitting del with a drawing selected 7 | name: 'Delete', 8 | title: 'TOUCHVTT.DeleteDrawing', 9 | icon: 'fas fa-eraser', 10 | button: true, 11 | onClick: () => canvas.drawings._onDeleteKey() 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/tools/EnlargeButtonsTool.js: -------------------------------------------------------------------------------- 1 | import {MODULE_NAME} from '../config/ModuleConstants' 2 | import {getSetting, LARGE_BUTTONS_SETTING} from '../config/TouchSettings' 3 | 4 | const STYLE_ID = `${MODULE_NAME}-bug_button_styles` 5 | // CSS needed to be more specific. Unsure if just using !important would be better 6 | const largeButtonStyle = ` 7 | #controls ol.control-tools > li.scene-control, #controls ol.control-tools > li.control-tool { 8 | width: 50px; 9 | height: 50px; 10 | line-height: 50px; 11 | font-size: 28px; 12 | } 13 | #controls ol.control-tools { 14 | left: 72px; 15 | } 16 | ` 17 | 18 | function createStyleElement() { 19 | const style = document.createElement('style') 20 | style.setAttribute('id', STYLE_ID) 21 | document.head.append(style) 22 | return style 23 | } 24 | 25 | export function updateButtonSize(useLargeButtons) { 26 | const style = document.getElementById(STYLE_ID) 27 | if (style != null) { 28 | if (useLargeButtons) { 29 | style.innerHTML = largeButtonStyle 30 | } else { 31 | style.innerHTML = '' 32 | } 33 | } 34 | } 35 | 36 | export function initEnlargeButtonTool() { 37 | createStyleElement() 38 | updateButtonSize(getSetting(LARGE_BUTTONS_SETTING) || false) 39 | } 40 | -------------------------------------------------------------------------------- /src/tools/MeasuredTemplateManagement.js: -------------------------------------------------------------------------------- 1 | import {MODULE_NAME} from '../config/ModuleConstants' 2 | import FoundryCanvas from '../foundryvtt/FoundryCanvas' 3 | import {injectMethodCondition, wrapMethod} from '../utils/Injection.js' 4 | import {dispatchModifiedEvent} from '../logic/FakeTouchEvent.js' 5 | import {getSetting, MEASUREMENT_HUD_LEFT, MEASUREMENT_HUD_OFF, MEASUREMENT_HUD_SETTING} from '../config/TouchSettings.js' 6 | 7 | const TOOL_NAME_ERASE = 'erase' 8 | 9 | class TouchMeasuredTemplateHud extends Application { 10 | constructor({ touchPointerEventsManager, templateManager }) { 11 | super() 12 | 13 | this._touchPointerEventsManager = touchPointerEventsManager 14 | this._templateManager = templateManager 15 | this._worldPosition = null 16 | this._screenPosition = {} 17 | this._currentTemplate = null 18 | } 19 | 20 | static get defaultOptions() { 21 | return foundry.utils.mergeObject(super.defaultOptions, { 22 | id: "touch-measured-template-hud", 23 | template: `/modules/${MODULE_NAME}/templates/measured-template-hud.hbs`, 24 | popOut: false, 25 | width: 200, 26 | height: 100, 27 | left: 150, 28 | top: 80, 29 | scale: 1, 30 | minimizable: false, 31 | resizable: false, 32 | dragDrop: [], 33 | tabs: [], 34 | scrollY: [], 35 | }) 36 | } 37 | 38 | getData(...args) { 39 | const data = super.getData(...args) 40 | data.id = this.options.id 41 | data.top = this._screenPosition.top 42 | data.left = this._screenPosition.left 43 | data.offsetX = this.calcOffsetX() 44 | data.showRotate = true 45 | data.showConfirm = true 46 | data.showCancel = true 47 | return data 48 | } 49 | 50 | activateListeners(html) { 51 | html.find('.rotate').on('pointerdown', () => { 52 | this._currentTemplate.document.updateSource({direction: this._currentTemplate.document.direction + 15}) 53 | this._currentTemplate.refresh() 54 | }) 55 | 56 | html.find('.confirm').on('pointerdown', () => { 57 | this._templateManager.toggleMeasuredTemplateTouchManagementListeners(false) 58 | this._templateManager._touchMode = false 59 | // We send mousedown/mouseup events to be as close as possible to the expected behavior 60 | canvas.app.view.dispatchEvent(new PointerEvent("pointerdown", {pointerType: "mouse", isPrimary: true, clientX: this._screenPosition.left, clientY: this._screenPosition.top, button: 0, buttons: 1})) 61 | canvas.app.view.dispatchEvent(new PointerEvent("pointerup", {pointerType: "mouse", isPrimary: true, clientX: this._screenPosition.left, clientY: this._screenPosition.top, button: 0, buttons: 1})) 62 | canvas.hud.touchMeasuredTemplate.clear() 63 | }) 64 | 65 | html.find('.cancel').on('pointerdown', () => { 66 | this._templateManager.toggleMeasuredTemplateTouchManagementListeners(false) 67 | this._templateManager._touchMode = false 68 | canvas.app.view.dispatchEvent(new MouseEvent("contextmenu", {clientX: 0, clientY: 0, bubbles: true, cancelable: true, view: window, button: 2})) 69 | canvas.hud.touchMeasuredTemplate.clear() 70 | }) 71 | } 72 | 73 | async show(template) { 74 | this._currentTemplate = template 75 | const worldPosition = {x: template.document.x, y: template.document.y} 76 | if (this._templateManager._touchMode) { 77 | this._worldPosition = worldPosition 78 | const screenPosition = FoundryCanvas.worldToScreen(worldPosition) 79 | this.setScreenPosition({ 80 | left: screenPosition.x, 81 | top: screenPosition.y, 82 | }) 83 | 84 | const states = this.constructor.RENDER_STATES 85 | await this.render(this._state <= states.NONE) 86 | 87 | this._touchPointerEventsManager.disableGestures() 88 | } 89 | } 90 | 91 | clear() { 92 | const states = this.constructor.RENDER_STATES 93 | if (this._state <= states.NONE) return 94 | this._state = states.CLOSING 95 | 96 | this.element.hide() 97 | this._state = states.NONE 98 | 99 | this._touchPointerEventsManager.enableGestures() 100 | } 101 | 102 | setScreenPosition({top, left}) { 103 | this._screenPosition = { top, left } 104 | } 105 | 106 | calcOffsetX() { 107 | const offset = FoundryCanvas.worldToScreenLength(FoundryCanvas.gridSize) * 0.75 108 | if (getSetting(MEASUREMENT_HUD_SETTING) === MEASUREMENT_HUD_LEFT) { 109 | return `calc(-100% - ${offset}px)` 110 | } else { 111 | return `${offset}px` 112 | } 113 | } 114 | } 115 | 116 | export class MeasuredTemplateManager { 117 | 118 | constructor() { 119 | this._touchMode = false 120 | this._touchEventReplacer = this.touchEventReplacer.bind(this) 121 | } 122 | 123 | 124 | initMeasuredTemplateHud(touchPointerEventsManager) { 125 | if (canvas.hud.touchMeasuredTemplate == null) { 126 | canvas.hud.touchMeasuredTemplate = new TouchMeasuredTemplateHud({ touchPointerEventsManager, templateManager: this }) 127 | 128 | wrapMethod("MeasuredTemplate.prototype._applyRenderFlags", function(originalMethod, flags) { 129 | if (flags.refreshPosition) { 130 | canvas.hud.touchMeasuredTemplate.show(this) 131 | } 132 | return originalMethod.call(this, flags) 133 | }) 134 | } 135 | } 136 | 137 | touchEventReplacer(evt) { 138 | // An event gets here if the listeners have been activated; we replace them all with pointermove until the template is confirmed 139 | if (evt.isTrusted || evt.touchvttTrusted) { 140 | if (evt instanceof TouchEvent || evt instanceof PointerEvent && (["touch", "pen"].includes(evt.pointerType) || evt.touchvttTrusted)) { 141 | this._touchMode = true 142 | } else { 143 | this._touchMode = false 144 | return this.toggleMeasuredTemplateTouchManagementListeners(false) 145 | } 146 | } 147 | if (this._touchMode && (evt.isTrusted || evt.touchvttTrusted) && evt.target.tagName == "CANVAS") { 148 | evt.preventDefault() 149 | evt.stopPropagation() 150 | if (evt instanceof PointerEvent) { 151 | dispatchModifiedEvent(evt, "pointermove", {trusted: false, button: -1, buttons: 0}) 152 | } 153 | return false 154 | } else { 155 | return true 156 | } 157 | } 158 | 159 | toggleMeasuredTemplateTouchManagementListeners(activate = true) { 160 | // When active, we capture all relevant events to see if they need to be replaced 161 | if (activate) { 162 | ["pointerdown", "pointerup", "pointermove", "pointercancel", "touchstart", "touchmove", "touchend"].forEach(e => { 163 | window.document.addEventListener(e, this._touchEventReplacer, true) 164 | }) 165 | } else { 166 | ["pointerdown", "pointerup", "pointermove", "pointercancel", "touchstart", "touchmove", "touchend"].forEach(e => { 167 | window.document.removeEventListener(e, this._touchEventReplacer, true) 168 | }) 169 | } 170 | } 171 | 172 | onTemplatePreviewCreated(template) { 173 | if (this.isPremade(template)) { 174 | // This is a pre-made template that we want to place, so we activate our listeners 175 | this.toggleMeasuredTemplateTouchManagementListeners(true) 176 | } 177 | } 178 | 179 | initMeasuredTemplateManagement() { 180 | const isEraserActive = () => game.activeTool === TOOL_NAME_ERASE 181 | const shouldIgnoreEvent = () => !isEraserActive() && !this._touchMode 182 | 183 | injectMethodCondition('TemplateLayer.prototype._onDragLeftStart', shouldIgnoreEvent) 184 | injectMethodCondition('TemplateLayer.prototype._onDragLeftMove', shouldIgnoreEvent) 185 | injectMethodCondition('TemplateLayer.prototype._onDragLeftDrop', shouldIgnoreEvent) 186 | injectMethodCondition('TemplateLayer.prototype._onDragLeftCancel', shouldIgnoreEvent) 187 | 188 | wrapMethod('MeasuredTemplate.prototype._onClickLeft', function(callOriginal, ...args) { 189 | if (isEraserActive()) { 190 | this.document.delete() 191 | // v11 only: we dispatch a left click on the canvas because the template shape was lingering while dragging after the deletion 192 | if (game.release.generation < 12) { 193 | setTimeout(() => { canvas.app.view.dispatchEvent(new MouseEvent("contextmenu", {bubbles: true, cancelable: true, view: window, button: 2})) }, 0) 194 | } 195 | } else { 196 | callOriginal(...args) 197 | } 198 | }, 'MIXED') 199 | } 200 | 201 | isPremade(template) { 202 | // Explaining the condition here for future reference: 203 | // A pre-made template doesn't have an id, and has a distance already set, usually larger than half a square. 204 | // A template created by dragging triggers two draws: on dragStart, it doesn't have an id and has distance 1 (in v11) or half a square (in v12); on confirmation, it has an id, and has a set distance 205 | // When you move an existing template, it doesn't have an id, and it has a set distance 206 | const gridSize = game.release.generation < 12 ? canvas.grid.grid.options.dimensions.distance : canvas.grid.distance 207 | return !template.id && template.document.distance > gridSize 208 | } 209 | 210 | } 211 | 212 | MeasuredTemplateManager.init = function init() { 213 | return new MeasuredTemplateManager() 214 | } 215 | 216 | export function installMeasurementTemplateEraser(menuStructure) { 217 | const measurementCategory = menuStructure.find(c => c.name === 'measure') 218 | if (measurementCategory != null) { 219 | const clearIndex = measurementCategory.tools.findIndex(t => t.name === 'clear') 220 | if (clearIndex !== -1) { 221 | measurementCategory.tools.splice(clearIndex, 0, { 222 | name: TOOL_NAME_ERASE, 223 | title: 'TOUCHVTT.Erase', 224 | icon: 'fas fa-eraser' 225 | }) 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/tools/MeasurementHud.js: -------------------------------------------------------------------------------- 1 | import {MODULE_NAME} from '../config/ModuleConstants' 2 | import {wrapMethod} from '../utils/Injection' 3 | import FoundryCanvas from '../foundryvtt/FoundryCanvas' 4 | import Vectors from '../logic/Vectors.js' 5 | import {getSetting, MEASUREMENT_HUD_LEFT, MEASUREMENT_HUD_OFF, MEASUREMENT_HUD_SETTING} from '../config/TouchSettings.js' 6 | 7 | class TouchMeasurementHud extends Application { 8 | constructor({ touchPointerEventsManager }) { 9 | super() 10 | 11 | this._touchPointerEventsManager = touchPointerEventsManager 12 | this._worldPosition = null 13 | this._screenPosition = {} 14 | } 15 | 16 | static get defaultOptions() { 17 | return foundry.utils.mergeObject(super.defaultOptions, { 18 | id: "touch-measurement-hud", 19 | template: `/modules/${MODULE_NAME}/templates/measurement-hud.hbs`, 20 | popOut: false, 21 | width: 200, 22 | height: 100, 23 | left: 150, 24 | top: 80, 25 | scale: 1, 26 | minimizable: false, 27 | resizable: false, 28 | dragDrop: [], 29 | tabs: [], 30 | scrollY: [], 31 | }) 32 | } 33 | 34 | getData(...args) { 35 | const data = super.getData(...args) 36 | data.id = this.options.id 37 | data.top = this._screenPosition.top 38 | data.left = this._screenPosition.left 39 | data.offsetX = this.calcOffsetX() 40 | data.showRuler = !Vectors.isEqual(this._worldPosition ?? {}, this.lastWaypoint) 41 | data.showMove = this.canMoveToken() 42 | return data 43 | } 44 | 45 | activateListeners(html) { 46 | html.find('.waypoint').on('pointerdown', () => { 47 | const ruler = FoundryCanvas.ruler 48 | if (ruler != null && typeof ruler._addWaypoint === 'function') { 49 | ruler._addWaypoint(this._worldPosition) 50 | this.render() 51 | } 52 | }) 53 | 54 | html.find('.move').on('pointerdown', () => { 55 | const ruler = FoundryCanvas.ruler 56 | if (ruler != null && typeof ruler.moveToken === 'function') { 57 | var token = ruler.token || ruler._getMovementToken() 58 | token.document.locked = false 59 | ruler.moveToken() 60 | this.render() 61 | } 62 | }) 63 | } 64 | 65 | async show(worldPosition) { 66 | this._worldPosition = worldPosition 67 | const screenPosition = FoundryCanvas.worldToScreen(worldPosition) 68 | this.setScreenPosition({ 69 | left: screenPosition.x, 70 | top: screenPosition.y, 71 | }) 72 | 73 | const states = this.constructor.RENDER_STATES 74 | await this.render(this._state <= states.NONE) 75 | 76 | this._touchPointerEventsManager.disableGestures() 77 | } 78 | 79 | clear() { 80 | const states = this.constructor.RENDER_STATES 81 | if (this._state <= states.NONE) return 82 | this._state = states.CLOSING 83 | 84 | this.element.hide() 85 | this._state = states.NONE 86 | 87 | this._touchPointerEventsManager.enableGestures() 88 | } 89 | 90 | setScreenPosition({top, left}) { 91 | this._screenPosition = { top, left } 92 | } 93 | 94 | get lastWaypoint() { 95 | const ruler = FoundryCanvas.ruler 96 | return (ruler && ruler.waypoints[ruler.waypoints.length - 1]) ?? {} 97 | } 98 | 99 | canMoveToken() { 100 | const ruler = FoundryCanvas.ruler 101 | if (ruler == null) { 102 | return false 103 | } 104 | if (game.paused && !game.user.isGM) { 105 | return false 106 | } 107 | if (!ruler.visible || !ruler.destination) return false 108 | return ruler._getMovementToken(ruler.origin) != null 109 | } 110 | 111 | calcOffsetX() { 112 | const offset = FoundryCanvas.worldToScreenLength(FoundryCanvas.gridSize) * 0.75 113 | if (getSettingValue() === MEASUREMENT_HUD_LEFT) { 114 | return `calc(-100% - ${offset}px)` 115 | } else { 116 | return `${offset}px` 117 | } 118 | } 119 | } 120 | 121 | export function initMeasurementHud({ touchPointerEventsManager }) { 122 | if (canvas.hud.touchMeasurement == null) { 123 | canvas.hud.touchMeasurement = new TouchMeasurementHud({ touchPointerEventsManager }) 124 | 125 | wrapMethod('Ruler.prototype._onMouseMove', function (wrapped, event, ...args) { 126 | // I think here we're storing "touch" or "mouse" somewhere in the interactionData so we can check it later 127 | if (event.interactionData != null && event.interactionData.destination != null) { 128 | if (game.release.generation < 12) { 129 | event.interactionData.destination.originType = event.pointerType 130 | } else { 131 | event.interactionData.destination.originType = event.data?.originalEvent?.pointerType 132 | } 133 | } 134 | return wrapped.call(this, event, ...args) 135 | }) 136 | 137 | wrapMethod('Ruler.prototype.measure', function (wrapped, destination, ...args) { 138 | const segments = wrapped.call(this, destination, ...args) 139 | if (Array.isArray(segments) && isOwnRuler(this) && isEnabled() && destination?.originType === 'touch') { 140 | if (segments.length > 0) { 141 | const lastSegment = segments[segments.length - 1] 142 | canvas.hud.touchMeasurement.show(lastSegment.ray.B) 143 | } else { 144 | canvas.hud.touchMeasurement.clear() 145 | } 146 | } 147 | return segments 148 | }) 149 | 150 | wrapMethod('Ruler.prototype.clear', function (wrapped, ...args) { 151 | const superResult = wrapped.call(this, ...args) 152 | if (isOwnRuler(this)) { 153 | canvas.hud.touchMeasurement.clear() 154 | } 155 | return superResult 156 | }) 157 | } 158 | } 159 | 160 | function isOwnRuler(ruler) { 161 | return FoundryCanvas.ruler === ruler 162 | } 163 | 164 | function getSettingValue() { 165 | return getSetting(MEASUREMENT_HUD_SETTING) 166 | } 167 | 168 | function isEnabled() { 169 | return getSettingValue() !== MEASUREMENT_HUD_OFF 170 | } 171 | -------------------------------------------------------------------------------- /src/tools/SnapToGridTool.js: -------------------------------------------------------------------------------- 1 | let isActive = true 2 | 3 | export function isSnapToGridEnabled() { 4 | return isActive 5 | } 6 | 7 | export function installSnapToGrid(menuStructure) { 8 | const measurementCategory = menuStructure.find(c => c.name === 'token') 9 | if (measurementCategory != null) { 10 | measurementCategory.tools.push({ 11 | name: 'snap', 12 | title: 'TOUCHVTT.SnapToGrid', 13 | icon: 'fas fa-border-all', 14 | toggle: true, 15 | active: isActive, 16 | onClick: active => isActive = active 17 | }) 18 | } 19 | } 20 | 21 | // The logic for this was originally in the FakeTouchEvent module (not used in v12), having it here seems clearer anyway. 22 | export function callbackForSnapToGrid(event, events) { 23 | if (event.startsWith("dragLeft") && !isActive) { 24 | events.forEach(e => { 25 | e.shiftKey = true 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/tools/TokenEraserTool.js: -------------------------------------------------------------------------------- 1 | export function installTokenEraser(menuStructure) { 2 | 3 | if (game.user.isGM) { 4 | const category = menuStructure.find(c => c.name === 'token') 5 | if (category == null || !Array.isArray(category.tools)) return 6 | 7 | category.tools.push({ 8 | // Simulate hitting del with a token selected 9 | name: 'Delete', 10 | title: 'TOUCHVTT.DeleteToken', 11 | icon: 'fas fa-eraser', 12 | button: true, 13 | onClick: () => canvas.tokens._onDeleteKey() 14 | }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/tools/UtilityControls.js: -------------------------------------------------------------------------------- 1 | import {getSetting, PAUSE_BUTTON_SETTING} from '../config/TouchSettings' 2 | 3 | export function installUtilityControls() { 4 | $("#touch-vtt-controls").remove() 5 | 6 | const controls = $("
") 7 | .attr("id", "touch-vtt-controls") 8 | controls.toggleClass("hidden", !getSetting(PAUSE_BUTTON_SETTING)) 9 | 10 | // Pause button (GM only) 11 | if (game.user.isGM) { 12 | $(" 25 | 26 | -------------------------------------------------------------------------------- /templates/measured-template-hud.hbs: -------------------------------------------------------------------------------- 1 |
5 | {{#if showCancel}} 6 |
7 |
8 | 9 |
10 |
11 | {{/if}} 12 | {{#if showRotate}} 13 |
14 |
15 | 16 |
17 |
18 | {{/if}} 19 | {{#if showConfirm}} 20 |
21 |
22 | 23 |
24 |
25 | {{/if}} 26 |
27 | -------------------------------------------------------------------------------- /templates/measurement-hud.hbs: -------------------------------------------------------------------------------- 1 |
5 | {{#if showMove}} 6 |
7 | 8 |
9 | {{/if}} 10 | {{#if showRuler}} 11 |
12 | 13 |
14 | {{/if}} 15 |
16 | -------------------------------------------------------------------------------- /templates/settings-override.hbs: -------------------------------------------------------------------------------- 1 |
2 |

You can choose to force settings for all clients here. Selections on the client side will be ignored.

3 | {{#each settings}} 4 |
5 | 6 | 9 |
10 | {{/each}} 11 | 16 |
-------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 3 | 4 | module.exports = { 5 | entry: './src/touch-vtt.js', 6 | output: { 7 | filename: 'touch-vtt.js', 8 | path: path.resolve(__dirname, 'dist'), 9 | }, 10 | plugins: [new MiniCssExtractPlugin({ 11 | filename: 'touch-vtt.css' 12 | })], 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.css$/i, 17 | use: [MiniCssExtractPlugin.loader, "css-loader"], 18 | }, 19 | ], 20 | }, 21 | } 22 | --------------------------------------------------------------------------------