tracky-mouse 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/audio/click-press.wav +0 -0
- package/audio/click-release.wav +0 -0
- package/audio/middle-click-press.wav +0 -0
- package/audio/middle-click-release.wav +0 -0
- package/audio/pause.wav +0 -0
- package/audio/unpause.wav +0 -0
- package/lib/face-landmarks-detection.min.js +1 -1
- package/lib/face_mesh/face_mesh.js +1 -1
- package/locales/ar/translation.json +8 -2
- package/locales/ar-EG/translation.json +8 -2
- package/locales/bg/translation.json +8 -2
- package/locales/bn/translation.json +8 -2
- package/locales/ca/translation.json +8 -2
- package/locales/ce/translation.json +8 -2
- package/locales/ceb/translation.json +9 -3
- package/locales/cs/translation.json +8 -2
- package/locales/da/translation.json +8 -2
- package/locales/de/translation.json +8 -2
- package/locales/el/translation.json +8 -2
- package/locales/emoji/emoji-translation-notes.md +1 -0
- package/locales/emoji/translation.json +12 -6
- package/locales/en/translation.json +8 -2
- package/locales/eo/translation.json +8 -2
- package/locales/es/translation.json +8 -2
- package/locales/eu/translation.json +8 -2
- package/locales/fa/translation.json +8 -2
- package/locales/fi/translation.json +8 -2
- package/locales/fr/translation.json +8 -2
- package/locales/gu/translation.json +8 -2
- package/locales/ha/translation.json +8 -2
- package/locales/he/translation.json +8 -2
- package/locales/hi/translation.json +8 -2
- package/locales/hr/translation.json +8 -2
- package/locales/hu/translation.json +8 -2
- package/locales/hy/translation.json +8 -2
- package/locales/id/translation.json +8 -2
- package/locales/it/translation.json +8 -2
- package/locales/ja/translation.json +8 -2
- package/locales/jv/translation.json +9 -3
- package/locales/ko/translation.json +8 -2
- package/locales/mr/translation.json +8 -2
- package/locales/ms/translation.json +8 -2
- package/locales/nan/translation.json +8 -2
- package/locales/nb/translation.json +8 -2
- package/locales/nl/translation.json +8 -2
- package/locales/pa/translation.json +8 -2
- package/locales/pl/translation.json +8 -2
- package/locales/pt/translation.json +8 -2
- package/locales/pt-BR/translation.json +8 -2
- package/locales/ro/translation.json +8 -2
- package/locales/ru/translation.json +8 -2
- package/locales/sk/translation.json +8 -2
- package/locales/sl/translation.json +8 -2
- package/locales/sr/translation.json +8 -2
- package/locales/sv/translation.json +8 -2
- package/locales/sw/translation.json +8 -2
- package/locales/ta/translation.json +8 -2
- package/locales/te/translation.json +8 -2
- package/locales/th/translation.json +8 -2
- package/locales/tl/translation.json +9 -3
- package/locales/tr/translation.json +8 -2
- package/locales/tt/translation.json +8 -2
- package/locales/uk/translation.json +8 -2
- package/locales/ur/translation.json +8 -2
- package/locales/uz/translation.json +8 -2
- package/locales/vi/translation.json +8 -2
- package/locales/war/translation.json +9 -3
- package/locales/zh/translation.json +8 -2
- package/locales/zh-simplified/translation.json +8 -2
- package/package.json +3 -1
- package/src/audio.js +145 -0
- package/src/autoscroll.js +189 -0
- package/src/input-simulator.js +518 -0
- package/tracky-mouse.css +32 -1
- package/tracky-mouse.js +457 -185
package/src/audio.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
|
|
2
|
+
/** @type {AudioContext | null} */
|
|
3
|
+
let actx = null;
|
|
4
|
+
|
|
5
|
+
const audioPath = new URL("../audio", import.meta.url).href;
|
|
6
|
+
const audioFiles = {
|
|
7
|
+
// https://opengameart.org/content/51-ui-sound-effects-buttons-switches-and-clicks (CC0)
|
|
8
|
+
clickPress: `${audioPath}/click-press.wav`,
|
|
9
|
+
clickRelease: `${audioPath}/click-release.wav`,
|
|
10
|
+
// https://opengameart.org/content/middle-mouse-click (original recordings for this project, released under CC0)
|
|
11
|
+
middleClickPress: `${audioPath}/middle-click-press.wav`,
|
|
12
|
+
middleClickRelease: `${audioPath}/middle-click-release.wav`,
|
|
13
|
+
// https://opengameart.org/content/9-sci-fi-computer-sounds-and-beeps (CC-BY 3.0)
|
|
14
|
+
pause: `${audioPath}/pause.wav`,
|
|
15
|
+
unpause: `${audioPath}/unpause.wav`,
|
|
16
|
+
};
|
|
17
|
+
const audioBuffers = {};
|
|
18
|
+
|
|
19
|
+
// Sound effects are disabled by default because the dwell clicker can be initialized without the UI,
|
|
20
|
+
// in which case there's no UI to disable the sound effects from.
|
|
21
|
+
// The actual default in the app is separate.
|
|
22
|
+
export let audioEnabled = false;
|
|
23
|
+
|
|
24
|
+
export function setAudioEnabled(enabled) {
|
|
25
|
+
audioEnabled = enabled;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function initAudio() {
|
|
29
|
+
if (actx === null) {
|
|
30
|
+
actx = new AudioContext();
|
|
31
|
+
|
|
32
|
+
// "User gesture" requirements cripple this accessibility feature,
|
|
33
|
+
// but we have to at least try to work around it.
|
|
34
|
+
const unsuspend = (event) => {
|
|
35
|
+
if (actx.state === "suspended") {
|
|
36
|
+
console.log("Starting suspended audio context via", event.type);
|
|
37
|
+
actx.resume();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
addEventListener("keydown", unsuspend);
|
|
41
|
+
addEventListener("pointerdown", unsuspend);
|
|
42
|
+
|
|
43
|
+
// Load audio files
|
|
44
|
+
for (const [key, url] of Object.entries(audioFiles)) {
|
|
45
|
+
fetch(url)
|
|
46
|
+
.then((response) => response.arrayBuffer())
|
|
47
|
+
.then((arrayBuffer) => actx.decodeAudioData(arrayBuffer))
|
|
48
|
+
.then((audioBuffer) => {
|
|
49
|
+
audioBuffers[key] = audioBuffer;
|
|
50
|
+
})
|
|
51
|
+
.catch((error) => {
|
|
52
|
+
console.error("Error loading audio file:", url, error);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function playSound(soundId, { delay = 0, playbackRate = 1, volume = 1 } = {}) {
|
|
59
|
+
if (audioEnabled && actx && actx.state === "running" && audioBuffers[soundId]) {
|
|
60
|
+
const gain = actx.createGain();
|
|
61
|
+
const source = actx.createBufferSource();
|
|
62
|
+
source.buffer = audioBuffers[soundId];
|
|
63
|
+
source.connect(gain).connect(actx.destination);
|
|
64
|
+
gain.gain.value = volume;
|
|
65
|
+
source.playbackRate.value = playbackRate;
|
|
66
|
+
source.start(actx.currentTime + delay);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class SleepSweep {
|
|
71
|
+
constructor(ctx = actx) {
|
|
72
|
+
this.ctx = ctx;
|
|
73
|
+
|
|
74
|
+
this.osc = this.ctx.createOscillator();
|
|
75
|
+
this.osc.type = "sine";
|
|
76
|
+
|
|
77
|
+
this.gain = this.ctx.createGain();
|
|
78
|
+
this.gain.gain.value = 0;
|
|
79
|
+
|
|
80
|
+
// Not sure how much this filter is actually doing
|
|
81
|
+
this.filter = this.ctx.createBiquadFilter();
|
|
82
|
+
this.filter.type = "lowpass";
|
|
83
|
+
this.filter.frequency.value = 800;
|
|
84
|
+
|
|
85
|
+
this.osc.connect(this.filter);
|
|
86
|
+
this.filter.connect(this.gain);
|
|
87
|
+
this.gain.connect(this.ctx.destination);
|
|
88
|
+
|
|
89
|
+
this.osc.start();
|
|
90
|
+
|
|
91
|
+
this.enabled = false;
|
|
92
|
+
this.active = false;
|
|
93
|
+
this.timeOfLastGestureTrigger = 0; // audio context time
|
|
94
|
+
this.maxEffectDurationAfterGestureTrigger = 2.0; // seconds
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
update(gestureProgress) {
|
|
98
|
+
const now = this.ctx.currentTime;
|
|
99
|
+
if (!this.enabled || !audioEnabled) {
|
|
100
|
+
this.gain.gain.setTargetAtTime(0, now, 0.05);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const effectStartFraction = 0.5;
|
|
105
|
+
|
|
106
|
+
if (gestureProgress < effectStartFraction) {
|
|
107
|
+
if (this.timeOfLastGestureTrigger + this.maxEffectDurationAfterGestureTrigger < now) {
|
|
108
|
+
this.gain.gain.setTargetAtTime(0, now, 0.05);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const effectProgress = (gestureProgress - effectStartFraction) / (1 - effectStartFraction);
|
|
114
|
+
|
|
115
|
+
const volume = effectProgress * effectProgress * 0.4;
|
|
116
|
+
|
|
117
|
+
const baseFreq = 120;
|
|
118
|
+
const freq = baseFreq + effectProgress * 40;
|
|
119
|
+
|
|
120
|
+
this.gain.gain.setTargetAtTime(volume, now, 0.05);
|
|
121
|
+
this.osc.frequency.setTargetAtTime(freq, now, 0.05);
|
|
122
|
+
|
|
123
|
+
this.filter.frequency.setTargetAtTime(800 + effectProgress * 1200, now, 0.05);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sleepModeWasToggled(nowInSleepMode) {
|
|
127
|
+
const now = this.ctx.currentTime;
|
|
128
|
+
const currentFreq = this.osc.frequency.value;
|
|
129
|
+
const targetFreq = currentFreq * (nowInSleepMode ? 0.5 : 2.0);
|
|
130
|
+
|
|
131
|
+
this.osc.frequency.cancelScheduledValues(now);
|
|
132
|
+
this.osc.frequency.setValueAtTime(currentFreq, now);
|
|
133
|
+
this.osc.frequency.exponentialRampToValueAtTime(targetFreq, now + (nowInSleepMode ? 1 : 0.15));
|
|
134
|
+
|
|
135
|
+
this.gain.gain.setTargetAtTime(0, now + 0.05, nowInSleepMode ? 0.2 : 0.1); // should be <= this.maxEffectDurationAfterGestureTrigger
|
|
136
|
+
|
|
137
|
+
playSound(nowInSleepMode ? "pause" : "unpause");
|
|
138
|
+
|
|
139
|
+
this.timeOfLastGestureTrigger = now;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setEnabled(enabled) {
|
|
143
|
+
this.enabled = enabled;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
|
|
2
|
+
const indicator = document.createElement("div");
|
|
3
|
+
indicator.innerHTML = `
|
|
4
|
+
<svg width="32" height="32" viewBox="0 0 32 32">
|
|
5
|
+
<!-- base shape -->
|
|
6
|
+
<circle cx="16" cy="16" r="13" fill="rgb(230, 230, 230)" stroke="rgb(50, 50, 50)" stroke-width="1" />
|
|
7
|
+
<!-- middle dot -->
|
|
8
|
+
<circle cx="16" cy="16" r="2" fill="rgb(0, 0, 0)" />
|
|
9
|
+
<!-- triangle above -->
|
|
10
|
+
<polygon data-axis="y" points="16,5 13,10 19,10" fill="rgb(0, 0, 0)" />
|
|
11
|
+
<!-- triangle below -->
|
|
12
|
+
<polygon data-axis="y" points="16,27 13,22 19,22" fill="rgb(0, 0, 0)" />
|
|
13
|
+
<!-- triangle left -->
|
|
14
|
+
<polygon data-axis="x" points="5,16 10,13 10,19" fill="rgb(0, 0, 0)" />
|
|
15
|
+
<!-- triangle right -->
|
|
16
|
+
<polygon data-axis="x" points="27,16 22,13 22,19" fill="rgb(0, 0, 0)" />
|
|
17
|
+
</svg>
|
|
18
|
+
`;
|
|
19
|
+
indicator.style.position = "fixed";
|
|
20
|
+
indicator.style.pointerEvents = "none";
|
|
21
|
+
indicator.style.transform = "translate(-50%, -50%)";
|
|
22
|
+
indicator.style.zIndex = "800000"; // below .tracky-mouse-cursor and inputFeedbackCanvas
|
|
23
|
+
|
|
24
|
+
const lockingClickRadius = 10; // pixels
|
|
25
|
+
const scrollSpeed = 0.01;
|
|
26
|
+
const scrollExponent = 1.6;
|
|
27
|
+
const deadZone = 10; // pixels (taxicab distance)
|
|
28
|
+
const maxDeltaTime = 100; // milliseconds
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
// Block clicks with a full-page transparent element while autoscrolling,
|
|
32
|
+
// so it doesn't doubly-act when stopping locked autoscroll with a click.
|
|
33
|
+
const clickBlocker = document.createElement("div");
|
|
34
|
+
clickBlocker.style.position = "fixed";
|
|
35
|
+
clickBlocker.style.left = "0";
|
|
36
|
+
clickBlocker.style.top = "0";
|
|
37
|
+
clickBlocker.style.width = "100%";
|
|
38
|
+
clickBlocker.style.height = "100%";
|
|
39
|
+
clickBlocker.style.zIndex = "1000000";
|
|
40
|
+
clickBlocker.style.pointerEvents = "auto"; // default
|
|
41
|
+
clickBlocker.style.backgroundColor = "transparent"; // default (but could override weird CSS ig)
|
|
42
|
+
clickBlocker.addEventListener("pointerdown", (event) => {
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
autoscroll.stopAutoscroll();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
addEventListener("keydown", (event) => {
|
|
49
|
+
if (event.key === "Escape" && autoscroll._start) {
|
|
50
|
+
event.stopPropagation();
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
autoscroll.stopAutoscroll();
|
|
53
|
+
}
|
|
54
|
+
}, { capture: true });
|
|
55
|
+
|
|
56
|
+
export const autoscroll = {
|
|
57
|
+
_start: null,
|
|
58
|
+
_currentScrollDelta: null,
|
|
59
|
+
_lastTimestamp: null,
|
|
60
|
+
_animationFrameRequest: null,
|
|
61
|
+
pointerDown(target, x, y, buttonIndex = 0, pointerId) {
|
|
62
|
+
if (buttonIndex !== 1) {
|
|
63
|
+
this.stopAutoscroll();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (target.closest("a")) return;
|
|
67
|
+
this.startAutoscroll(target, x, y, pointerId);
|
|
68
|
+
},
|
|
69
|
+
pointerUp(_target, x, y, buttonIndex = 0, _pointerId) {
|
|
70
|
+
// Allow any pointer to stop autoscroll, because it's more likely to be annoying if it gets stuck
|
|
71
|
+
if (buttonIndex !== 1 || !this._start) return;
|
|
72
|
+
if (Math.hypot(x - this._start.x, y - this._start.y) < lockingClickRadius) {
|
|
73
|
+
return; // lock autoscroll mode until next click
|
|
74
|
+
}
|
|
75
|
+
this.stopAutoscroll();
|
|
76
|
+
},
|
|
77
|
+
startAutoscroll(target, x, y, pointerId) {
|
|
78
|
+
indicator.style.left = `${x}px`;
|
|
79
|
+
indicator.style.top = `${y}px`;
|
|
80
|
+
document.body.appendChild(indicator);
|
|
81
|
+
document.body.appendChild(clickBlocker);
|
|
82
|
+
this._start = { x, y, target, pointerId };
|
|
83
|
+
this._currentScrollDelta = null;
|
|
84
|
+
this._lastTimestamp = performance.now();
|
|
85
|
+
// Update arrow visibility immediately, and start animation loop
|
|
86
|
+
this.updateAutoscroll();
|
|
87
|
+
},
|
|
88
|
+
stopAutoscroll() {
|
|
89
|
+
this._start = null;
|
|
90
|
+
this._currentScrollDelta = null;
|
|
91
|
+
this._lastTimestamp = null;
|
|
92
|
+
if (indicator.parentElement) {
|
|
93
|
+
document.body.removeChild(indicator);
|
|
94
|
+
}
|
|
95
|
+
if (clickBlocker.parentElement) {
|
|
96
|
+
document.body.removeChild(clickBlocker);
|
|
97
|
+
}
|
|
98
|
+
cancelAnimationFrame(this._animationFrameRequest);
|
|
99
|
+
this._animationFrameRequest = null;
|
|
100
|
+
},
|
|
101
|
+
pointerMove(_target, x, y, pointerId) {
|
|
102
|
+
// Don't allow other pointers to affect autoscroll started by a different pointer
|
|
103
|
+
if (!this._start || this._start.pointerId !== pointerId) return;
|
|
104
|
+
const diff = { x: x - this._start.x, y: y - this._start.y };
|
|
105
|
+
// Note: Don't return early if within deadzone,
|
|
106
|
+
// because we still want to update the indicator arrows.
|
|
107
|
+
if (Math.abs(diff.x) < deadZone) diff.x = 0;
|
|
108
|
+
if (Math.abs(diff.y) < deadZone) diff.y = 0;
|
|
109
|
+
diff.x -= Math.sign(diff.x) * deadZone;
|
|
110
|
+
diff.y -= Math.sign(diff.y) * deadZone;
|
|
111
|
+
|
|
112
|
+
// Note: there's a question of whether to apply the exponent or multiplier first.
|
|
113
|
+
// I think with exponent after multiplier, adjusting the exponent changes the
|
|
114
|
+
// average speed less for nominal values of input/exponent/multiplier,
|
|
115
|
+
// making it more intuitive to tweak the curvature,
|
|
116
|
+
// but tweaking the multiplier may be more intuitive, at least in a strict mathematical sense,
|
|
117
|
+
// with the exponent applied first.
|
|
118
|
+
// The set of curves expressible should be equal.
|
|
119
|
+
// As an aside, switching between the two orders could be easier if we used one variable
|
|
120
|
+
// instead of both `diff` and `scrollDelta`. I doubt it would harm clarity.
|
|
121
|
+
const scrollDelta = { x: diff.x * scrollSpeed, y: diff.y * scrollSpeed };
|
|
122
|
+
scrollDelta.x = Math.sign(scrollDelta.x) * Math.pow(Math.abs(scrollDelta.x), scrollExponent);
|
|
123
|
+
scrollDelta.y = Math.sign(scrollDelta.y) * Math.pow(Math.abs(scrollDelta.y), scrollExponent);
|
|
124
|
+
this._currentScrollDelta = scrollDelta;
|
|
125
|
+
},
|
|
126
|
+
getScrollable() {
|
|
127
|
+
let container = this._start.target;
|
|
128
|
+
let canScrollX = false;
|
|
129
|
+
let canScrollY = false;
|
|
130
|
+
while (container && container !== document.body) {
|
|
131
|
+
// This initial test gives a false positive on the demo section of the website
|
|
132
|
+
// Trying an actual scroll seems like a sure test, but could cause performance issues
|
|
133
|
+
canScrollX = container.scrollWidth > container.clientWidth;
|
|
134
|
+
canScrollY = container.scrollHeight > container.clientHeight;
|
|
135
|
+
if (canScrollX || canScrollY) {
|
|
136
|
+
if (canScrollX) {
|
|
137
|
+
const oldScrollLeft = container.scrollLeft;
|
|
138
|
+
container.scrollLeft = 1;
|
|
139
|
+
if (container.scrollLeft === 0) {
|
|
140
|
+
canScrollX = false;
|
|
141
|
+
}
|
|
142
|
+
container.scrollLeft = oldScrollLeft;
|
|
143
|
+
}
|
|
144
|
+
if (canScrollY) {
|
|
145
|
+
const oldScrollTop = container.scrollTop;
|
|
146
|
+
container.scrollTop = 1;
|
|
147
|
+
if (container.scrollTop === 0) {
|
|
148
|
+
canScrollY = false;
|
|
149
|
+
}
|
|
150
|
+
container.scrollTop = oldScrollTop;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (canScrollX || canScrollY) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
container = container.parentElement;
|
|
157
|
+
}
|
|
158
|
+
if (!container || container === document.body) {
|
|
159
|
+
// container = document.scrollingElement;
|
|
160
|
+
container = window;
|
|
161
|
+
canScrollX = document.scrollingElement.scrollWidth > document.scrollingElement.clientWidth;
|
|
162
|
+
canScrollY = document.scrollingElement.scrollHeight > document.scrollingElement.clientHeight;
|
|
163
|
+
}
|
|
164
|
+
return { container, canScrollX, canScrollY };
|
|
165
|
+
},
|
|
166
|
+
updateAutoscroll() {
|
|
167
|
+
const deltaTime = Math.min(maxDeltaTime, performance.now() - this._lastTimestamp);
|
|
168
|
+
// Note: we could optimize by not calling getScrollable every frame
|
|
169
|
+
const { container, canScrollX, canScrollY } = this.getScrollable();
|
|
170
|
+
const scrollDelta = this._currentScrollDelta;
|
|
171
|
+
if (scrollDelta) {
|
|
172
|
+
// Note: scrolling might be limited to integers, which could cause it to not scroll at low speeds,
|
|
173
|
+
// especially at high frame rates,
|
|
174
|
+
// and affect the accuracy of deltaTime-based movement. We could accumulate fractional scroll
|
|
175
|
+
// deltas and apply them when they reach a whole pixel.
|
|
176
|
+
container.scrollBy(scrollDelta.x * deltaTime, scrollDelta.y * deltaTime);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const arrow of indicator.querySelectorAll("[data-axis]")) {
|
|
180
|
+
const axis = arrow.dataset.axis;
|
|
181
|
+
arrow.style.display = (axis === "x" ? canScrollX : canScrollY) ? "" : "none";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this._lastTimestamp = performance.now();
|
|
185
|
+
|
|
186
|
+
cancelAnimationFrame(this._animationFrameRequest);
|
|
187
|
+
this._animationFrameRequest = requestAnimationFrame(() => this.updateAutoscroll());
|
|
188
|
+
},
|
|
189
|
+
};
|