tracky-mouse 2.2.0 → 2.3.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 -0
- package/package.json +5 -2
- package/tracky-mouse.css +11 -11
- package/tracky-mouse.js +267 -215
package/README.md
CHANGED
|
@@ -161,6 +161,7 @@ Arguments:
|
|
|
161
161
|
- `config.afterDispatch()` (optional): a function to call after a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
|
|
162
162
|
- `config.beforePointerDownDispatch()` (optional): a function to call before a `pointerdown` event is dispatched. Likely to be merged with `config.beforeDispatch()` in the future.
|
|
163
163
|
- `config.afterReleaseDrag()` (optional): a function to call after a drag is released. May be merged with `config.afterDispatch()` in the future.
|
|
164
|
+
- `config.isHeld()` (optional): a function that returns true if the next dwell should be a release (triggering `pointerup`). Not needed for basic `config.shouldDrag(el)` usage. Honestly I don't remember what this is for.
|
|
164
165
|
|
|
165
166
|
Returns an object with the following properties:
|
|
166
167
|
- `paused`: a getter/setter for whether dwell clicking is paused. Use this to implement a pause/resume button, in conjunction with `config.dwellClickEvenIfPaused`.
|
|
@@ -257,6 +258,7 @@ const config = {
|
|
|
257
258
|
// especially `beforePointerDownDispatch` which could be supplanted by passing an `Event` to `beforeDispatch`.
|
|
258
259
|
beforePointerDownDispatch: () => { window.pointers = []; },
|
|
259
260
|
afterReleaseDrag: () => { window.pointers = []; },
|
|
261
|
+
isHeld: () => { return window.pointer_active; },
|
|
260
262
|
};
|
|
261
263
|
const dwellClicker = TrackyMouse.initDwellClicking(config);
|
|
262
264
|
// dwellClicker.paused = !dwellClicker.paused; // toggle
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tracky-mouse",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Add facial mouse accessibility to JavaScript applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"webcam",
|
|
16
16
|
"head-tracker",
|
|
17
17
|
"head-tracking",
|
|
18
|
+
"facial-recognition",
|
|
18
19
|
"face-tracker",
|
|
19
20
|
"face-tracking",
|
|
20
21
|
"headmouse",
|
|
@@ -24,13 +25,15 @@
|
|
|
24
25
|
"eye-tracking",
|
|
25
26
|
"eye-gaze",
|
|
26
27
|
"accessibility",
|
|
28
|
+
"assistive-technology",
|
|
27
29
|
"cursor",
|
|
28
30
|
"pointer",
|
|
29
31
|
"pointing",
|
|
30
32
|
"input-method",
|
|
31
33
|
"hands-free",
|
|
32
34
|
"handsfree",
|
|
33
|
-
"desktop-automation"
|
|
35
|
+
"desktop-automation",
|
|
36
|
+
"telekinesis"
|
|
34
37
|
],
|
|
35
38
|
"repository": {
|
|
36
39
|
"type": "git",
|
package/tracky-mouse.css
CHANGED
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
|
|
109
109
|
.tracky-mouse-ui .tracky-mouse-labeled-slider {
|
|
110
110
|
display: inline-flex;
|
|
111
|
-
|
|
111
|
+
flex-direction: column;
|
|
112
112
|
margin-bottom: 18px;
|
|
113
113
|
margin-top: 5px;
|
|
114
114
|
flex: 1;
|
|
@@ -119,20 +119,16 @@
|
|
|
119
119
|
flex: 1;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
.tracky-mouse-ui .tracky-mouse-labeled-slider .tracky-mouse-
|
|
123
|
-
.tracky-mouse-ui .tracky-mouse-labeled-slider .tracky-mouse-max-label {
|
|
122
|
+
.tracky-mouse-ui .tracky-mouse-labeled-slider .tracky-mouse-slider-labels {
|
|
124
123
|
opacity: 0.8;
|
|
125
|
-
position: absolute;
|
|
126
|
-
bottom: -12px;
|
|
127
124
|
pointer-events: none;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
.tracky-mouse-ui .tracky-mouse-labeled-slider .tracky-mouse-min-label {
|
|
125
|
+
display: flex;
|
|
126
|
+
width: 100%;
|
|
131
127
|
left: 0;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
.tracky-mouse-ui .tracky-mouse-labeled-slider .tracky-mouse-max-label {
|
|
135
128
|
right: 0;
|
|
129
|
+
justify-content: space-between;
|
|
130
|
+
/* If they come this close, wrap the slider label text */
|
|
131
|
+
gap: 10px;
|
|
136
132
|
}
|
|
137
133
|
|
|
138
134
|
.tracky-mouse-canvas-container-container {
|
|
@@ -274,6 +270,10 @@ body:not(.tracky-mouse-manual-takeback) .tracky-mouse-manual-takeback-indicator
|
|
|
274
270
|
color: rgb(135 0 191);
|
|
275
271
|
}
|
|
276
272
|
|
|
273
|
+
.tracky-mouse-controls {
|
|
274
|
+
overflow: auto;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
277
|
.tracky-mouse-controls details {
|
|
278
278
|
border: 1px solid rgba(0, 0, 0, 0.3);
|
|
279
279
|
border-radius: 4px;
|
package/tracky-mouse.js
CHANGED
|
@@ -35,16 +35,16 @@ TrackyMouse.loadDependencies = function ({ statsJs = false } = {}) {
|
|
|
35
35
|
return Promise.all(scriptFiles.map(loadScript));
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
const
|
|
38
|
+
const isSelectorValid = ((dummyElement) =>
|
|
39
39
|
(selector) => {
|
|
40
|
-
try {
|
|
40
|
+
try { dummyElement.querySelector(selector); } catch { return false; }
|
|
41
41
|
return true;
|
|
42
42
|
})(document.createDocumentFragment());
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
const
|
|
45
|
+
const dwellClickers = [];
|
|
46
46
|
|
|
47
|
-
const
|
|
47
|
+
const initDwellClicking = (config) => {
|
|
48
48
|
/*
|
|
49
49
|
Arguments:
|
|
50
50
|
- `config.targets` (required): a CSS selector for the elements to click. Anything else will be ignored.
|
|
@@ -59,6 +59,8 @@ const init_dwell_clicking = (config) => {
|
|
|
59
59
|
- `config.click({x, y, target})` (required): a function to trigger a click on the given target element.
|
|
60
60
|
- `config.beforeDispatch()` (optional): a function to call before a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
|
|
61
61
|
- `config.afterDispatch()` (optional): a function to call after a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
|
|
62
|
+
- `config.beforePointerDownDispatch()` (optional): a function to call before a `pointerdown` event is dispatched. Likely to be merged with `config.beforeDispatch()` in the future.
|
|
63
|
+
- `config.isHeld()` (optional): a function that returns true if the next dwell should be a release (triggering `pointerup`).
|
|
62
64
|
*/
|
|
63
65
|
if (typeof config !== "object") {
|
|
64
66
|
throw new Error("configuration object required for initDwellClicking");
|
|
@@ -69,7 +71,7 @@ const init_dwell_clicking = (config) => {
|
|
|
69
71
|
if (typeof config.targets !== "string") {
|
|
70
72
|
throw new Error("config.targets must be a string (a CSS selector)");
|
|
71
73
|
}
|
|
72
|
-
if (!
|
|
74
|
+
if (!isSelectorValid(config.targets)) {
|
|
73
75
|
throw new Error("config.targets is not a valid CSS selector");
|
|
74
76
|
}
|
|
75
77
|
if (config.click === undefined) {
|
|
@@ -126,89 +128,89 @@ const init_dwell_clicking = (config) => {
|
|
|
126
128
|
if (typeof rule.to !== "string" && typeof rule.to !== "function" && !(rule.to instanceof Element) && rule.to !== null) {
|
|
127
129
|
throw new Error(`config.retarget[${i}].to must be a CSS selector string, an Element, a function, or null`);
|
|
128
130
|
}
|
|
129
|
-
if (typeof rule.from === "string" && !
|
|
131
|
+
if (typeof rule.from === "string" && !isSelectorValid(rule.from)) {
|
|
130
132
|
throw new Error(`config.retarget[${i}].from is not a valid CSS selector`);
|
|
131
133
|
}
|
|
132
|
-
if (typeof rule.to === "string" && !
|
|
134
|
+
if (typeof rule.to === "string" && !isSelectorValid(rule.to)) {
|
|
133
135
|
throw new Error(`config.retarget[${i}].to is not a valid CSS selector`);
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
let
|
|
149
|
-
let
|
|
140
|
+
// trackyMouseContainer.querySelector(".tracky-mouse-canvas").classList.add("inset-deep");
|
|
141
|
+
|
|
142
|
+
const circleRadiusMax = 50; // dwell indicator size in pixels
|
|
143
|
+
const hoverTimespan = 500; // how long between the dwell indicator appearing and triggering a click
|
|
144
|
+
const averagingWindowTimespan = 500;
|
|
145
|
+
const inactiveAtStartupTimespan = 1500; // (should be at least averagingWindowTimespan, but more importantly enough to make it not awkward when enabling dwell clicking)
|
|
146
|
+
const inactiveAfterReleaseTimespan = 1000; // after click or drag release (from dwell or otherwise)
|
|
147
|
+
const inactiveAfterHoveredTimespan = 1000; // after dwell click indicator appears; does not control the time to finish that dwell click, only to click on something else after this is canceled (but it doesn't control that directly)
|
|
148
|
+
const inactiveAfterInvalidTimespan = 1000; // after a dwell click is canceled due to an element popping up in front, or existing in front at the center of the other element
|
|
149
|
+
const inactiveAfterFocusedTimespan = 1000; // after page becomes focused after being unfocused
|
|
150
|
+
let recentPoints = [];
|
|
151
|
+
let inactiveUntilTime = performance.now();
|
|
150
152
|
let paused = false;
|
|
151
|
-
let
|
|
152
|
-
let
|
|
153
|
+
let hoverCandidate;
|
|
154
|
+
let dwellDragging = null;
|
|
153
155
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
+
const deactivateForAtLeast = (timespan) => {
|
|
157
|
+
inactiveUntilTime = Math.max(inactiveUntilTime, performance.now() + timespan);
|
|
156
158
|
};
|
|
157
|
-
|
|
159
|
+
deactivateForAtLeast(inactiveAtStartupTimespan);
|
|
158
160
|
|
|
159
161
|
const halo = document.createElement("div");
|
|
160
162
|
halo.className = "tracky-mouse-hover-halo";
|
|
161
163
|
halo.style.display = "none";
|
|
162
164
|
document.body.appendChild(halo);
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
document.body.appendChild(
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
165
|
+
const dwellIndicator = document.createElement("div");
|
|
166
|
+
dwellIndicator.className = "tracky-mouse-dwell-indicator";
|
|
167
|
+
dwellIndicator.style.width = `${circleRadiusMax}px`;
|
|
168
|
+
dwellIndicator.style.height = `${circleRadiusMax}px`;
|
|
169
|
+
dwellIndicator.style.display = "none";
|
|
170
|
+
document.body.appendChild(dwellIndicator);
|
|
171
|
+
|
|
172
|
+
const onPointerMove = (e) => {
|
|
173
|
+
recentPoints.push({ x: e.clientX, y: e.clientY, time: performance.now() });
|
|
172
174
|
};
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
const onPointerUpOrCancel = (_e) => {
|
|
176
|
+
deactivateForAtLeast(inactiveAfterReleaseTimespan);
|
|
177
|
+
dwellDragging = null;
|
|
176
178
|
};
|
|
177
179
|
|
|
178
|
-
let
|
|
179
|
-
let
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
let pageFocused = document.visibilityState === "visible"; // guess/assumption
|
|
181
|
+
let mouseInsidePage = true; // assumption
|
|
182
|
+
const onFocus = () => {
|
|
183
|
+
pageFocused = true;
|
|
184
|
+
deactivateForAtLeast(inactiveAfterFocusedTimespan);
|
|
183
185
|
};
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
+
const onBlur = () => {
|
|
187
|
+
pageFocused = false;
|
|
186
188
|
};
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
+
const onMouseLeavePage = () => {
|
|
190
|
+
mouseInsidePage = false;
|
|
189
191
|
};
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
+
const onMouseEnterPage = () => {
|
|
193
|
+
mouseInsidePage = true;
|
|
192
194
|
};
|
|
193
195
|
|
|
194
|
-
window.addEventListener("pointermove",
|
|
195
|
-
window.addEventListener("pointerup",
|
|
196
|
-
window.addEventListener("pointercancel",
|
|
197
|
-
window.addEventListener("focus",
|
|
198
|
-
window.addEventListener("blur",
|
|
199
|
-
document.addEventListener("mouseleave",
|
|
200
|
-
document.addEventListener("mouseenter",
|
|
196
|
+
window.addEventListener("pointermove", onPointerMove);
|
|
197
|
+
window.addEventListener("pointerup", onPointerUpOrCancel);
|
|
198
|
+
window.addEventListener("pointercancel", onPointerUpOrCancel);
|
|
199
|
+
window.addEventListener("focus", onFocus);
|
|
200
|
+
window.addEventListener("blur", onBlur);
|
|
201
|
+
document.addEventListener("mouseleave", onMouseLeavePage);
|
|
202
|
+
document.addEventListener("mouseenter", onMouseEnterPage);
|
|
201
203
|
|
|
202
|
-
const
|
|
204
|
+
const getHoverCandidate = (clientX, clientY) => {
|
|
203
205
|
|
|
204
|
-
if (!
|
|
206
|
+
if (!pageFocused || !mouseInsidePage) return null;
|
|
205
207
|
|
|
206
208
|
let target = document.elementFromPoint(clientX, clientY);
|
|
207
209
|
if (!target) {
|
|
208
210
|
return null;
|
|
209
211
|
}
|
|
210
212
|
|
|
211
|
-
let
|
|
213
|
+
let hoverCandidate = {
|
|
212
214
|
x: clientX,
|
|
213
215
|
y: clientY,
|
|
214
216
|
time: performance.now(),
|
|
@@ -221,33 +223,33 @@ const init_dwell_clicking = (config) => {
|
|
|
221
223
|
typeof from === "function" ? from(target) :
|
|
222
224
|
target.matches(from)
|
|
223
225
|
) {
|
|
224
|
-
const
|
|
226
|
+
const toElement =
|
|
225
227
|
(to instanceof Element || to === null) ? to :
|
|
226
228
|
typeof to === "function" ? to(target) :
|
|
227
229
|
(target.closest(to) || target.querySelector(to));
|
|
228
|
-
if (
|
|
230
|
+
if (toElement === null) {
|
|
229
231
|
return null;
|
|
230
|
-
} else if (
|
|
231
|
-
const
|
|
232
|
+
} else if (toElement) {
|
|
233
|
+
const toRect = toElement.getBoundingClientRect();
|
|
232
234
|
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
hoverCandidate.x > toRect.left - withinMargin &&
|
|
236
|
+
hoverCandidate.y > toRect.top - withinMargin &&
|
|
237
|
+
hoverCandidate.x < toRect.right + withinMargin &&
|
|
238
|
+
hoverCandidate.y < toRect.bottom + withinMargin
|
|
237
239
|
) {
|
|
238
|
-
target =
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
target = toElement;
|
|
241
|
+
hoverCandidate.x = Math.min(
|
|
242
|
+
toRect.right - 1,
|
|
241
243
|
Math.max(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
+
toRect.left,
|
|
245
|
+
hoverCandidate.x,
|
|
244
246
|
),
|
|
245
247
|
);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
+
hoverCandidate.y = Math.min(
|
|
249
|
+
toRect.bottom - 1,
|
|
248
250
|
Math.max(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
toRect.top,
|
|
252
|
+
hoverCandidate.y,
|
|
251
253
|
),
|
|
252
254
|
);
|
|
253
255
|
retargeted = true;
|
|
@@ -267,14 +269,14 @@ const init_dwell_clicking = (config) => {
|
|
|
267
269
|
if (!config.noCenter?.(target)) {
|
|
268
270
|
// Nudge hover previews to the center of buttons and things
|
|
269
271
|
const rect = target.getBoundingClientRect();
|
|
270
|
-
|
|
271
|
-
|
|
272
|
+
hoverCandidate.x = rect.left + rect.width / 2;
|
|
273
|
+
hoverCandidate.y = rect.top + rect.height / 2;
|
|
272
274
|
}
|
|
273
|
-
|
|
274
|
-
return
|
|
275
|
+
hoverCandidate.target = target;
|
|
276
|
+
return hoverCandidate;
|
|
275
277
|
};
|
|
276
278
|
|
|
277
|
-
const
|
|
279
|
+
const getEventOptions = ({ x, y }) => {
|
|
278
280
|
return {
|
|
279
281
|
view: window, // needed for offsetX/Y calculation
|
|
280
282
|
clientX: x,
|
|
@@ -287,7 +289,7 @@ const init_dwell_clicking = (config) => {
|
|
|
287
289
|
};
|
|
288
290
|
};
|
|
289
291
|
|
|
290
|
-
const
|
|
292
|
+
const averagePoints = (points) => {
|
|
291
293
|
const average = { x: 0, y: 0 };
|
|
292
294
|
for (const pointer of points) {
|
|
293
295
|
average.x += pointer.x;
|
|
@@ -300,78 +302,78 @@ const init_dwell_clicking = (config) => {
|
|
|
300
302
|
|
|
301
303
|
const update = () => {
|
|
302
304
|
const time = performance.now();
|
|
303
|
-
|
|
304
|
-
if (
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
const
|
|
305
|
+
recentPoints = recentPoints.filter((pointRecord) => time < pointRecord.time + averagingWindowTimespan);
|
|
306
|
+
if (recentPoints.length) {
|
|
307
|
+
const latestPoint = recentPoints[recentPoints.length - 1];
|
|
308
|
+
recentPoints.push({ x: latestPoint.x, y: latestPoint.y, time });
|
|
309
|
+
const averagePoint = averagePoints(recentPoints);
|
|
308
310
|
// debug
|
|
309
|
-
// const
|
|
311
|
+
// const canvasPoint = toCanvasCoords({clientX: averagePoint.x, clientY: averagePoint.y});
|
|
310
312
|
// ctx.fillStyle = "red";
|
|
311
|
-
// ctx.fillRect(
|
|
312
|
-
const
|
|
313
|
+
// ctx.fillRect(canvasPoint.x, canvasPoint.y, 10, 10);
|
|
314
|
+
const recentMovementAmount = Math.hypot(latestPoint.x - averagePoint.x, latestPoint.y - averagePoint.y);
|
|
313
315
|
|
|
314
316
|
// Invalidate in case an element pops up in front of the element you're hovering over, e.g. a submenu
|
|
315
|
-
// (that use case doesn't actually work in jspaint because the menu pops up before the
|
|
317
|
+
// (that use case doesn't actually work in jspaint because the menu pops up before the hoverCandidate exists)
|
|
316
318
|
// (TODO: disable hovering to open submenus in facial mouse mode in jspaint)
|
|
317
319
|
// or an element occludes the center of an element you're hovering over, in which case it
|
|
318
320
|
// could be confusing if it showed a dwell click indicator over a different element than it would click
|
|
319
321
|
// (but TODO: just move the indicator off center in that case)
|
|
320
|
-
if (
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
document.body.appendChild(
|
|
322
|
+
if (hoverCandidate && !dwellDragging) {
|
|
323
|
+
const apparentHoverCandidate = getHoverCandidate(hoverCandidate.x, hoverCandidate.y);
|
|
324
|
+
const showOccluderIndicator = (occluder) => {
|
|
325
|
+
const occluderIndicator = document.createElement("div");
|
|
326
|
+
const occluderRect = occluder.getBoundingClientRect();
|
|
327
|
+
const outlineWidth = 4;
|
|
328
|
+
occluderIndicator.style.pointerEvents = "none";
|
|
329
|
+
occluderIndicator.style.zIndex = 1000001;
|
|
330
|
+
occluderIndicator.style.display = "block";
|
|
331
|
+
occluderIndicator.style.position = "fixed";
|
|
332
|
+
occluderIndicator.style.left = `${occluderRect.left + outlineWidth}px`;
|
|
333
|
+
occluderIndicator.style.top = `${occluderRect.top + outlineWidth}px`;
|
|
334
|
+
occluderIndicator.style.width = `${occluderRect.width - outlineWidth * 2}px`;
|
|
335
|
+
occluderIndicator.style.height = `${occluderRect.height - outlineWidth * 2}px`;
|
|
336
|
+
occluderIndicator.style.outline = `${outlineWidth}px dashed red`;
|
|
337
|
+
occluderIndicator.style.boxShadow = `0 0 ${outlineWidth}px ${outlineWidth}px maroon`;
|
|
338
|
+
document.body.appendChild(occluderIndicator);
|
|
337
339
|
setTimeout(() => {
|
|
338
|
-
|
|
339
|
-
},
|
|
340
|
+
occluderIndicator.remove();
|
|
341
|
+
}, inactiveAfterInvalidTimespan * 0.5);
|
|
340
342
|
};
|
|
341
|
-
if (
|
|
343
|
+
if (apparentHoverCandidate) {
|
|
342
344
|
if (
|
|
343
|
-
|
|
345
|
+
apparentHoverCandidate.target !== hoverCandidate.target &&
|
|
344
346
|
// !retargeted &&
|
|
345
347
|
!config.isEquivalentTarget?.(
|
|
346
|
-
|
|
348
|
+
apparentHoverCandidate.target, hoverCandidate.target
|
|
347
349
|
)
|
|
348
350
|
) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
351
|
+
hoverCandidate = null;
|
|
352
|
+
deactivateForAtLeast(inactiveAfterInvalidTimespan);
|
|
353
|
+
showOccluderIndicator(apparentHoverCandidate.target);
|
|
352
354
|
}
|
|
353
355
|
} else {
|
|
354
|
-
let occluder = document.elementFromPoint(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
let occluder = document.elementFromPoint(hoverCandidate.x, hoverCandidate.y);
|
|
357
|
+
hoverCandidate = null;
|
|
358
|
+
deactivateForAtLeast(inactiveAfterInvalidTimespan);
|
|
359
|
+
showOccluderIndicator(occluder || document.body);
|
|
358
360
|
}
|
|
359
361
|
}
|
|
360
362
|
|
|
361
|
-
let
|
|
362
|
-
let
|
|
363
|
-
let
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
(
|
|
369
|
-
*
|
|
370
|
-
if (time >
|
|
371
|
-
if (config.isHeld?.() ||
|
|
363
|
+
let circlePosition = latestPoint;
|
|
364
|
+
let circleOpacity = 0;
|
|
365
|
+
let circleRadius = 0;
|
|
366
|
+
if (hoverCandidate) {
|
|
367
|
+
circlePosition = hoverCandidate;
|
|
368
|
+
circleOpacity = 0.4;
|
|
369
|
+
circleRadius =
|
|
370
|
+
(hoverCandidate.time - time + hoverTimespan) / hoverTimespan
|
|
371
|
+
* circleRadiusMax;
|
|
372
|
+
if (time > hoverCandidate.time + hoverTimespan) {
|
|
373
|
+
if (config.isHeld?.() || dwellDragging) {
|
|
372
374
|
config.beforeDispatch?.();
|
|
373
|
-
|
|
374
|
-
Object.assign(
|
|
375
|
+
hoverCandidate.target.dispatchEvent(new PointerEvent("pointerup",
|
|
376
|
+
Object.assign(getEventOptions(hoverCandidate), {
|
|
375
377
|
button: 0,
|
|
376
378
|
buttons: 0,
|
|
377
379
|
})
|
|
@@ -380,70 +382,70 @@ const init_dwell_clicking = (config) => {
|
|
|
380
382
|
} else {
|
|
381
383
|
config.beforePointerDownDispatch?.();
|
|
382
384
|
config.beforeDispatch?.();
|
|
383
|
-
|
|
384
|
-
Object.assign(
|
|
385
|
+
hoverCandidate.target.dispatchEvent(new PointerEvent("pointerdown",
|
|
386
|
+
Object.assign(getEventOptions(hoverCandidate), {
|
|
385
387
|
button: 0,
|
|
386
388
|
buttons: 1,
|
|
387
389
|
})
|
|
388
390
|
));
|
|
389
391
|
config.afterDispatch?.();
|
|
390
|
-
if (config.shouldDrag?.(
|
|
391
|
-
|
|
392
|
+
if (config.shouldDrag?.(hoverCandidate.target)) {
|
|
393
|
+
dwellDragging = hoverCandidate.target;
|
|
392
394
|
} else {
|
|
393
395
|
config.beforeDispatch?.();
|
|
394
|
-
|
|
395
|
-
Object.assign(
|
|
396
|
+
hoverCandidate.target.dispatchEvent(new PointerEvent("pointerup",
|
|
397
|
+
Object.assign(getEventOptions(hoverCandidate), {
|
|
396
398
|
button: 0,
|
|
397
399
|
buttons: 0,
|
|
398
400
|
})
|
|
399
401
|
));
|
|
400
|
-
config.click(
|
|
402
|
+
config.click(hoverCandidate);
|
|
401
403
|
config.afterDispatch?.();
|
|
402
404
|
}
|
|
403
405
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
+
hoverCandidate = null;
|
|
407
|
+
deactivateForAtLeast(inactiveAfterHoveredTimespan);
|
|
406
408
|
}
|
|
407
409
|
}
|
|
408
410
|
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
+
if (dwellDragging) {
|
|
412
|
+
dwellIndicator.classList.add("tracky-mouse-for-release");
|
|
411
413
|
} else {
|
|
412
|
-
|
|
414
|
+
dwellIndicator.classList.remove("tracky-mouse-for-release");
|
|
413
415
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
let
|
|
421
|
-
|
|
422
|
-
(
|
|
423
|
-
|
|
424
|
-
if (
|
|
425
|
-
let rect =
|
|
426
|
-
const
|
|
427
|
-
let ancestor =
|
|
428
|
-
let
|
|
416
|
+
dwellIndicator.style.display = "";
|
|
417
|
+
dwellIndicator.style.opacity = circleOpacity;
|
|
418
|
+
dwellIndicator.style.transform = `scale(${circleRadius / circleRadiusMax})`;
|
|
419
|
+
dwellIndicator.style.left = `${circlePosition.x - circleRadiusMax / 2}px`;
|
|
420
|
+
dwellIndicator.style.top = `${circlePosition.y - circleRadiusMax / 2}px`;
|
|
421
|
+
|
|
422
|
+
let haloTarget =
|
|
423
|
+
dwellDragging ||
|
|
424
|
+
(hoverCandidate || getHoverCandidate(latestPoint.x, latestPoint.y) || {}).target;
|
|
425
|
+
|
|
426
|
+
if (haloTarget && (!paused || config.dwellClickEvenIfPaused?.(haloTarget))) {
|
|
427
|
+
let rect = haloTarget.getBoundingClientRect();
|
|
428
|
+
const computedStyle = getComputedStyle(haloTarget);
|
|
429
|
+
let ancestor = haloTarget;
|
|
430
|
+
let borderRadiusScale = 1; // for border radius mimicry, given parents with transform: scale()
|
|
429
431
|
while (ancestor instanceof HTMLElement) {
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
432
|
+
const ancestorComputedStyle = getComputedStyle(ancestor);
|
|
433
|
+
if (ancestorComputedStyle.transform) {
|
|
432
434
|
// Collect scale transforms
|
|
433
|
-
const match =
|
|
435
|
+
const match = ancestorComputedStyle.transform.match(/(?:scale|matrix)\((\d+(?:\.\d+)?)/);
|
|
434
436
|
if (match) {
|
|
435
|
-
|
|
437
|
+
borderRadiusScale *= Number(match[1]);
|
|
436
438
|
}
|
|
437
439
|
}
|
|
438
|
-
if (
|
|
440
|
+
if (ancestorComputedStyle.overflow !== "visible") {
|
|
439
441
|
// Clamp to visible region if in scrollable area
|
|
440
442
|
// This lets you see the hover halo when scrolled to the middle of a large canvas
|
|
441
|
-
const
|
|
443
|
+
const scrollAreaRect = ancestor.getBoundingClientRect();
|
|
442
444
|
rect = {
|
|
443
|
-
left: Math.max(rect.left,
|
|
444
|
-
top: Math.max(rect.top,
|
|
445
|
-
right: Math.min(rect.right,
|
|
446
|
-
bottom: Math.min(rect.bottom,
|
|
445
|
+
left: Math.max(rect.left, scrollAreaRect.left),
|
|
446
|
+
top: Math.max(rect.top, scrollAreaRect.top),
|
|
447
|
+
right: Math.min(rect.right, scrollAreaRect.right),
|
|
448
|
+
bottom: Math.min(rect.bottom, scrollAreaRect.bottom),
|
|
447
449
|
};
|
|
448
450
|
rect.width = rect.right - rect.left;
|
|
449
451
|
rect.height = rect.bottom - rect.top;
|
|
@@ -468,40 +470,40 @@ const init_dwell_clicking = (config) => {
|
|
|
468
470
|
"borderBottomLeftRadius",
|
|
469
471
|
]) {
|
|
470
472
|
// Unfortunately, getComputedStyle can return percentages, probably other units, probably also "auto"
|
|
471
|
-
if (
|
|
472
|
-
halo.style[prop] = `${parseFloat(
|
|
473
|
+
if (computedStyle[prop].endsWith("px")) {
|
|
474
|
+
halo.style[prop] = `${parseFloat(computedStyle[prop]) * borderRadiusScale}px`;
|
|
473
475
|
} else {
|
|
474
|
-
halo.style[prop] =
|
|
476
|
+
halo.style[prop] = computedStyle[prop];
|
|
475
477
|
}
|
|
476
478
|
}
|
|
477
479
|
} else {
|
|
478
480
|
halo.style.display = "none";
|
|
479
481
|
}
|
|
480
482
|
|
|
481
|
-
if (time <
|
|
483
|
+
if (time < inactiveUntilTime) {
|
|
482
484
|
return;
|
|
483
485
|
}
|
|
484
|
-
if (
|
|
485
|
-
if (!
|
|
486
|
-
|
|
487
|
-
x:
|
|
488
|
-
y:
|
|
486
|
+
if (recentMovementAmount < 5) {
|
|
487
|
+
if (!hoverCandidate) {
|
|
488
|
+
hoverCandidate = {
|
|
489
|
+
x: averagePoint.x,
|
|
490
|
+
y: averagePoint.y,
|
|
489
491
|
time: performance.now(),
|
|
490
|
-
target:
|
|
492
|
+
target: dwellDragging || null,
|
|
491
493
|
};
|
|
492
|
-
if (!
|
|
493
|
-
|
|
494
|
+
if (!dwellDragging) {
|
|
495
|
+
hoverCandidate = getHoverCandidate(hoverCandidate.x, hoverCandidate.y);
|
|
494
496
|
}
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
+
if (hoverCandidate && (paused && !config.dwellClickEvenIfPaused?.(hoverCandidate.target))) {
|
|
498
|
+
hoverCandidate = null;
|
|
497
499
|
}
|
|
498
500
|
}
|
|
499
501
|
}
|
|
500
|
-
if (
|
|
501
|
-
if (
|
|
502
|
+
if (recentMovementAmount > 100) {
|
|
503
|
+
if (dwellDragging) {
|
|
502
504
|
config.beforeDispatch?.();
|
|
503
505
|
window.dispatchEvent(new PointerEvent("pointerup",
|
|
504
|
-
Object.assign(
|
|
506
|
+
Object.assign(getEventOptions(averagePoint), {
|
|
505
507
|
button: 0,
|
|
506
508
|
buttons: 0,
|
|
507
509
|
})
|
|
@@ -510,29 +512,29 @@ const init_dwell_clicking = (config) => {
|
|
|
510
512
|
config.afterReleaseDrag?.();
|
|
511
513
|
}
|
|
512
514
|
}
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
+
if (recentMovementAmount > 60) {
|
|
516
|
+
hoverCandidate = null;
|
|
515
517
|
}
|
|
516
518
|
}
|
|
517
519
|
};
|
|
518
|
-
let
|
|
520
|
+
let rafId;
|
|
519
521
|
const animate = () => {
|
|
520
|
-
|
|
522
|
+
rafId = requestAnimationFrame(animate);
|
|
521
523
|
update();
|
|
522
524
|
};
|
|
523
|
-
|
|
525
|
+
rafId = requestAnimationFrame(animate);
|
|
524
526
|
|
|
525
527
|
const dispose = () => {
|
|
526
|
-
cancelAnimationFrame(
|
|
528
|
+
cancelAnimationFrame(rafId);
|
|
527
529
|
halo.remove();
|
|
528
|
-
|
|
529
|
-
window.removeEventListener("pointermove",
|
|
530
|
-
window.removeEventListener("pointerup",
|
|
531
|
-
window.removeEventListener("pointercancel",
|
|
532
|
-
window.removeEventListener("focus",
|
|
533
|
-
window.removeEventListener("blur",
|
|
534
|
-
document.removeEventListener("mouseleave",
|
|
535
|
-
document.removeEventListener("mouseenter",
|
|
530
|
+
dwellIndicator.remove();
|
|
531
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
532
|
+
window.removeEventListener("pointerup", onPointerUpOrCancel);
|
|
533
|
+
window.removeEventListener("pointercancel", onPointerUpOrCancel);
|
|
534
|
+
window.removeEventListener("focus", onFocus);
|
|
535
|
+
window.removeEventListener("blur", onBlur);
|
|
536
|
+
document.removeEventListener("mouseleave", onMouseLeavePage);
|
|
537
|
+
document.removeEventListener("mouseenter", onMouseEnterPage);
|
|
536
538
|
};
|
|
537
539
|
|
|
538
540
|
const dwellClicker = {
|
|
@@ -544,16 +546,16 @@ const init_dwell_clicking = (config) => {
|
|
|
544
546
|
},
|
|
545
547
|
dispose,
|
|
546
548
|
};
|
|
547
|
-
|
|
549
|
+
dwellClickers.push(dwellClicker);
|
|
548
550
|
return dwellClicker;
|
|
549
551
|
};
|
|
550
552
|
|
|
551
553
|
TrackyMouse.initDwellClicking = function (config) {
|
|
552
|
-
return
|
|
554
|
+
return initDwellClicking(config);
|
|
553
555
|
};
|
|
554
556
|
TrackyMouse.cleanupDwellClicking = function () {
|
|
555
|
-
for (const
|
|
556
|
-
|
|
557
|
+
for (const dwellClicker of dwellClickers) {
|
|
558
|
+
dwellClicker.dispose();
|
|
557
559
|
}
|
|
558
560
|
};
|
|
559
561
|
|
|
@@ -972,6 +974,19 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
972
974
|
// description: "Makes Tracky Mouse active as soon as it's launched.",
|
|
973
975
|
// description: "Automatically starts Tracky Mouse as soon as it's run.",
|
|
974
976
|
},
|
|
977
|
+
{
|
|
978
|
+
// For "experimental" label:
|
|
979
|
+
// - I'm preferring language that doesn't assume a new build is coming soon, fixing everything
|
|
980
|
+
// - I considered adding "⚠︎" but it feels a little too alarming
|
|
981
|
+
// label: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"Planned refinements include: visual and auditory feedback, improved detection accuracy, and separate settings for durations to toggle on and off.\">experimental</span>)",
|
|
982
|
+
// label: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"• Missing visual and auditory feedback.\n• Missing settings for duration(s) to toggle on and off.\n• Affected by false positive blink detections, especially when looking downward.\">Experimental</span>)",
|
|
983
|
+
label: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"• There is currently no visual or auditory feedback.\n• There are no settings for duration(s) to toggle on and off.\n• It is affected by false positive blink detections, especially when looking downward.\">Experimental</span>)",
|
|
984
|
+
className: "tracky-mouse-close-eyes-to-toggle",
|
|
985
|
+
key: "closeEyesToToggle",
|
|
986
|
+
type: "checkbox",
|
|
987
|
+
default: false,
|
|
988
|
+
description: "If enabled, you can start or stop mouse control by holding both your eyes shut for a few seconds.",
|
|
989
|
+
},
|
|
975
990
|
{
|
|
976
991
|
label: "Run at login",
|
|
977
992
|
className: "tracky-mouse-run-at-login",
|
|
@@ -982,6 +997,17 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
982
997
|
description: "If enabled, Tracky Mouse will automatically start when you log into your computer.",
|
|
983
998
|
// description: "Makes Tracky Mouse start automatically when you log into your computer.",
|
|
984
999
|
},
|
|
1000
|
+
{
|
|
1001
|
+
label: "Check for updates",
|
|
1002
|
+
className: "tracky-mouse-check-for-updates",
|
|
1003
|
+
key: "checkForUpdates",
|
|
1004
|
+
type: "checkbox",
|
|
1005
|
+
default: true,
|
|
1006
|
+
platform: "desktop",
|
|
1007
|
+
description: "If enabled, Tracky Mouse will automatically check for updates when it starts.",
|
|
1008
|
+
// description: "Notifies you of new versions of Tracky Mouse.",
|
|
1009
|
+
// description: "Notifies you when a new version of Tracky Mouse is available.",
|
|
1010
|
+
},
|
|
985
1011
|
],
|
|
986
1012
|
},
|
|
987
1013
|
];
|
|
@@ -1079,8 +1105,10 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
1079
1105
|
<span class="tracky-mouse-label-text">${setting.label}</span>
|
|
1080
1106
|
<span class="tracky-mouse-labeled-slider">
|
|
1081
1107
|
<input type="range" min="${setting.min}" max="${setting.max}" class="${setting.className}">
|
|
1082
|
-
<span class="tracky-mouse-
|
|
1083
|
-
|
|
1108
|
+
<span class="tracky-mouse-slider-labels">
|
|
1109
|
+
<span class="tracky-mouse-min-label">${setting.labels.min}</span>
|
|
1110
|
+
<span class="tracky-mouse-max-label">${setting.labels.max}</span>
|
|
1111
|
+
</span>
|
|
1084
1112
|
</span>
|
|
1085
1113
|
`;
|
|
1086
1114
|
} else if (setting.type === "checkbox") {
|
|
@@ -1277,6 +1305,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
1277
1305
|
var mouthInfo;
|
|
1278
1306
|
var headTilt = { pitch: 0, yaw: 0, roll: 0 };
|
|
1279
1307
|
var headTiltFilters = { pitch: null, yaw: null, roll: null };
|
|
1308
|
+
var lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
|
|
1280
1309
|
// ## State related to switching between head trackers
|
|
1281
1310
|
var useClmTracking = true;
|
|
1282
1311
|
var showClmTracking = useClmTracking;
|
|
@@ -1290,6 +1319,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
1290
1319
|
right: false,
|
|
1291
1320
|
middle: false,
|
|
1292
1321
|
};
|
|
1322
|
+
var mouseButtonUntilMouthCloses = -1;
|
|
1293
1323
|
var lastMouseDownTime = -Infinity;
|
|
1294
1324
|
var mouseNeedsInitPos = true;
|
|
1295
1325
|
|
|
@@ -1563,6 +1593,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
1563
1593
|
pointsBasedOnFaceScore = 0;
|
|
1564
1594
|
faceScore = 0;
|
|
1565
1595
|
faceConvergence = 0;
|
|
1596
|
+
lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
|
|
1566
1597
|
|
|
1567
1598
|
startStopButton.textContent = "Start";
|
|
1568
1599
|
startStopButton.setAttribute("aria-pressed", "false");
|
|
@@ -2257,16 +2288,26 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
2257
2288
|
const thresholdHigh = 0.25;
|
|
2258
2289
|
const thresholdLow = 0.15;
|
|
2259
2290
|
mouth.thresholdMet = mouth.heightRatio > (prevThresholdMet ? thresholdLow : thresholdHigh);
|
|
2260
|
-
mouth.active = mouth.thresholdMet;
|
|
2261
|
-
// Preserve mouse button state; could be simpler as a separate variable.
|
|
2262
|
-
mouth.mouseButton = mouthInfo?.mouseButton ?? -1;
|
|
2291
|
+
mouth.active = mouth.thresholdMet; // TODO: maybe default to false, have this only set externally in gesture handling code
|
|
2263
2292
|
return mouth;
|
|
2264
2293
|
}
|
|
2265
2294
|
|
|
2266
|
-
const prevMouthOpen = mouthInfo?.
|
|
2295
|
+
const prevMouthOpen = mouthInfo?.thresholdMet;
|
|
2267
2296
|
|
|
2268
2297
|
blinkInfo = detectBlinks();
|
|
2269
2298
|
mouthInfo = detectMouthOpen();
|
|
2299
|
+
if (blinkInfo.rightEye.open || blinkInfo.leftEye.open) {
|
|
2300
|
+
lastTimeWhenAnEyeWasOpen = performance.now();
|
|
2301
|
+
}
|
|
2302
|
+
if (performance.now() - lastTimeWhenAnEyeWasOpen > 2000) {
|
|
2303
|
+
if (s.closeEyesToToggle) {
|
|
2304
|
+
paused = !paused;
|
|
2305
|
+
updatePaused();
|
|
2306
|
+
// TODO: handle edge cases
|
|
2307
|
+
// TODO: try to keep variable names meaningful
|
|
2308
|
+
lastTimeWhenAnEyeWasOpen = Infinity;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2270
2311
|
|
|
2271
2312
|
blinkInfo.used = false;
|
|
2272
2313
|
mouthInfo.used = false;
|
|
@@ -2280,6 +2321,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
2280
2321
|
}
|
|
2281
2322
|
}
|
|
2282
2323
|
// TODO: maybe split into a "simple"/mouth-only mode vs "with eye modifiers" mode?
|
|
2324
|
+
// (or just hold out for a full I/O binding system)
|
|
2283
2325
|
if (s.clickingMode === "open-mouth") {
|
|
2284
2326
|
mouthInfo.used = true;
|
|
2285
2327
|
blinkInfo.used = true;
|
|
@@ -2289,17 +2331,27 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
|
|
|
2289
2331
|
// Keep same button held if eye is opened,
|
|
2290
2332
|
// so you can continue to scroll a webpage without trying to
|
|
2291
2333
|
// read with one eye closed (for example).
|
|
2292
|
-
if (mouthInfo.
|
|
2334
|
+
if (mouthInfo.thresholdMet && !prevMouthOpen) {
|
|
2293
2335
|
if (blinkInfo.rightEye.active) {
|
|
2294
|
-
|
|
2336
|
+
mouseButtonUntilMouthCloses = 1;
|
|
2295
2337
|
} else if (blinkInfo.leftEye.active) {
|
|
2296
|
-
|
|
2338
|
+
mouseButtonUntilMouthCloses = 2;
|
|
2339
|
+
} else if (!blinkInfo.rightEye.open && !blinkInfo.leftEye.open) {
|
|
2340
|
+
mouseButtonUntilMouthCloses = -1;
|
|
2297
2341
|
} else {
|
|
2298
|
-
|
|
2342
|
+
mouseButtonUntilMouthCloses = 0;
|
|
2299
2343
|
}
|
|
2300
2344
|
}
|
|
2301
|
-
if (mouthInfo.
|
|
2302
|
-
clickButton =
|
|
2345
|
+
if (mouthInfo.thresholdMet) {
|
|
2346
|
+
clickButton = mouseButtonUntilMouthCloses;
|
|
2347
|
+
if (clickButton === -1) {
|
|
2348
|
+
// Show as passive / not clicking in visuals
|
|
2349
|
+
mouthInfo.active = false;
|
|
2350
|
+
// TODO: show eyes as yellow too regardless of eye state?
|
|
2351
|
+
}
|
|
2352
|
+
// TODO: DRY mapping
|
|
2353
|
+
blinkInfo.rightEye.active = clickButton === 1;
|
|
2354
|
+
blinkInfo.leftEye.active = clickButton === 2;
|
|
2303
2355
|
}
|
|
2304
2356
|
}
|
|
2305
2357
|
|