├── .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 | 
2 | 
3 | 
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 | 
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 | 
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 | 
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 | 
74 | - **Unchecked:** Default button size
75 | 
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 | [](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 | $("