tracky-mouse 2.7.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/lib/face-landmarks-detection.min.js +1 -1
- package/lib/face_mesh/face_mesh.js +1 -1
- package/locales/ar/translation.json +5 -1
- package/locales/ar-EG/translation.json +5 -1
- package/locales/bg/translation.json +5 -1
- package/locales/bn/translation.json +5 -1
- package/locales/ca/translation.json +5 -1
- package/locales/ce/translation.json +5 -1
- package/locales/ceb/translation.json +6 -2
- package/locales/cs/translation.json +5 -1
- package/locales/da/translation.json +5 -1
- package/locales/de/translation.json +5 -1
- package/locales/el/translation.json +5 -1
- package/locales/emoji/emoji-translation-notes.md +1 -0
- package/locales/emoji/translation.json +9 -5
- package/locales/en/translation.json +5 -1
- package/locales/eo/translation.json +5 -1
- package/locales/es/translation.json +5 -1
- package/locales/eu/translation.json +5 -1
- package/locales/fa/translation.json +5 -1
- package/locales/fi/translation.json +5 -1
- package/locales/fr/translation.json +5 -1
- package/locales/gu/translation.json +5 -1
- package/locales/ha/translation.json +5 -1
- package/locales/he/translation.json +5 -1
- package/locales/hi/translation.json +5 -1
- package/locales/hr/translation.json +5 -1
- package/locales/hu/translation.json +5 -1
- package/locales/hy/translation.json +5 -1
- package/locales/id/translation.json +5 -1
- package/locales/it/translation.json +5 -1
- package/locales/ja/translation.json +5 -1
- package/locales/jv/translation.json +6 -2
- package/locales/ko/translation.json +5 -1
- package/locales/mr/translation.json +5 -1
- package/locales/ms/translation.json +5 -1
- package/locales/nan/translation.json +5 -1
- package/locales/nb/translation.json +5 -1
- package/locales/nl/translation.json +5 -1
- package/locales/pa/translation.json +5 -1
- package/locales/pl/translation.json +5 -1
- package/locales/pt/translation.json +5 -1
- package/locales/pt-BR/translation.json +5 -1
- package/locales/ro/translation.json +5 -1
- package/locales/ru/translation.json +5 -1
- package/locales/sk/translation.json +5 -1
- package/locales/sl/translation.json +5 -1
- package/locales/sr/translation.json +5 -1
- package/locales/sv/translation.json +5 -1
- package/locales/sw/translation.json +5 -1
- package/locales/ta/translation.json +5 -1
- package/locales/te/translation.json +5 -1
- package/locales/th/translation.json +5 -1
- package/locales/tl/translation.json +6 -2
- package/locales/tr/translation.json +5 -1
- package/locales/tt/translation.json +5 -1
- package/locales/uk/translation.json +5 -1
- package/locales/ur/translation.json +5 -1
- package/locales/uz/translation.json +5 -1
- package/locales/vi/translation.json +5 -1
- package/locales/war/translation.json +6 -2
- package/locales/zh/translation.json +5 -1
- package/locales/zh-simplified/translation.json +5 -1
- package/package.json +2 -2
- package/{audio.js → src/audio.js} +1 -1
- package/src/autoscroll.js +189 -0
- package/src/input-simulator.js +518 -0
- package/tracky-mouse.css +33 -2
- package/tracky-mouse.js +166 -58
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { autoscroll } from "./autoscroll.js";
|
|
2
|
+
|
|
3
|
+
// Pointer event simulation logic should be built into tracky-mouse in the future.
|
|
4
|
+
// These simulated events connect the Tracky Mouse head tracker to the Tracky Mouse dwell clicker,
|
|
5
|
+
// as well as any other pointermove/pointerenter/pointerleave/click handlers on the page.
|
|
6
|
+
|
|
7
|
+
/** a special value so other code can detect these simulated events */
|
|
8
|
+
export const TM_POINTER_ID = 1234567890;
|
|
9
|
+
|
|
10
|
+
export class InputSimulator {
|
|
11
|
+
pointerId = TM_POINTER_ID;
|
|
12
|
+
buttonStates = {
|
|
13
|
+
0: false,
|
|
14
|
+
1: false,
|
|
15
|
+
2: false,
|
|
16
|
+
};
|
|
17
|
+
lastElOver = null;
|
|
18
|
+
simulatedMousePosition = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
19
|
+
getEventOptions({ x, y }) {
|
|
20
|
+
return {
|
|
21
|
+
view: window, // needed so the browser can calculate offsetX/Y from the clientX/Y
|
|
22
|
+
clientX: x,
|
|
23
|
+
clientY: y,
|
|
24
|
+
pointerId: this.pointerId,
|
|
25
|
+
pointerType: "mouse",
|
|
26
|
+
isPrimary: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
getCurrentRotation(el) {
|
|
30
|
+
// Source: https://stackoverflow.com/a/54492696/2624876
|
|
31
|
+
const st = window.getComputedStyle(el, null);
|
|
32
|
+
const tm = st.getPropertyValue("-webkit-transform") ||
|
|
33
|
+
st.getPropertyValue("-moz-transform") ||
|
|
34
|
+
st.getPropertyValue("-ms-transform") ||
|
|
35
|
+
st.getPropertyValue("-o-transform") ||
|
|
36
|
+
st.getPropertyValue("transform") ||
|
|
37
|
+
"none";
|
|
38
|
+
if (tm !== "none") {
|
|
39
|
+
const [a, b] = tm.split('(')[1].split(')')[0].split(',');
|
|
40
|
+
return Math.round(Math.atan2(a, b) * (180 / Math.PI));
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
pointerMove(x, y) {
|
|
45
|
+
this.simulatedMousePosition = { x, y };
|
|
46
|
+
// TODO: handle persistent button state
|
|
47
|
+
const target = this.targetFromPoint(x, y);
|
|
48
|
+
if (target !== this.lastElOver) {
|
|
49
|
+
if (this.lastElOver) {
|
|
50
|
+
const event = new PointerEvent("pointerleave", Object.assign(this.getEventOptions({ x, y }), {
|
|
51
|
+
button: 0,
|
|
52
|
+
buttons: 1,
|
|
53
|
+
bubbles: false,
|
|
54
|
+
cancelable: false,
|
|
55
|
+
}));
|
|
56
|
+
this.lastElOver.dispatchEvent(event);
|
|
57
|
+
}
|
|
58
|
+
const event = new PointerEvent("pointerenter", Object.assign(this.getEventOptions({ x, y }), {
|
|
59
|
+
button: 0,
|
|
60
|
+
buttons: 1,
|
|
61
|
+
bubbles: false,
|
|
62
|
+
cancelable: false,
|
|
63
|
+
}));
|
|
64
|
+
target.dispatchEvent(event);
|
|
65
|
+
this.lastElOver = target;
|
|
66
|
+
}
|
|
67
|
+
const event = new PointerEvent("pointermove", Object.assign(this.getEventOptions({ x, y }), {
|
|
68
|
+
button: 0,
|
|
69
|
+
buttons: 1,
|
|
70
|
+
bubbles: true,
|
|
71
|
+
cancelable: true,
|
|
72
|
+
}));
|
|
73
|
+
target.dispatchEvent(event);
|
|
74
|
+
|
|
75
|
+
// TODO: support double click, triple click selection behaviors, dragging selection
|
|
76
|
+
// TODO: avoid starting selection in links or other draggable/interactive elements
|
|
77
|
+
if (this.textSelectionStart && this.buttonStates[0]) {
|
|
78
|
+
const textSelectionEnd = this.caretPositionFromPoint(x, y);
|
|
79
|
+
const selection = window.getSelection();
|
|
80
|
+
if (textSelectionEnd && selection) {
|
|
81
|
+
selection.setBaseAndExtent(
|
|
82
|
+
this.textSelectionStart.offsetNode,
|
|
83
|
+
this.textSelectionStart.offset,
|
|
84
|
+
textSelectionEnd.offsetNode,
|
|
85
|
+
textSelectionEnd.offset
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
autoscroll.pointerMove(target, x, y, this.pointerId);
|
|
91
|
+
}
|
|
92
|
+
pointerDown(target, x, y, buttonIndex = 0) {
|
|
93
|
+
// TODO: handle nuance to moving across elements (nested elements, pointer capture)
|
|
94
|
+
this.buttonStates[buttonIndex] = true;
|
|
95
|
+
const event = new PointerEvent("pointerdown", Object.assign(this.getEventOptions({ x, y }), {
|
|
96
|
+
button: buttonIndex,
|
|
97
|
+
buttons: this.buttonStates[0] * 1 + this.buttonStates[1] * 2 + this.buttonStates[2] * 4,
|
|
98
|
+
bubbles: true,
|
|
99
|
+
cancelable: true,
|
|
100
|
+
}));
|
|
101
|
+
const result = target.dispatchEvent(event);
|
|
102
|
+
this.pointerDownElement = target;
|
|
103
|
+
|
|
104
|
+
// TODO: don't deselect when starting autoscroll
|
|
105
|
+
// TODO: also dispatch mouse* events and let mousedown cancel selection too
|
|
106
|
+
if (result) {
|
|
107
|
+
window.getSelection()?.removeAllRanges();
|
|
108
|
+
this.textSelectionStart = this.caretPositionFromPoint(x, y);
|
|
109
|
+
} else {
|
|
110
|
+
this.textSelectionStart = null;
|
|
111
|
+
}
|
|
112
|
+
// TODO: allow preventing MMB scroll? but make sure not to break
|
|
113
|
+
// autoscroll ending behavior
|
|
114
|
+
// FIXME: using gamepad, it fails to stop autoscroll with MMB because it starts immediately again
|
|
115
|
+
autoscroll.pointerDown(target, x, y, buttonIndex, this.pointerId);
|
|
116
|
+
}
|
|
117
|
+
pointerUp(target, x, y, buttonIndex = 0) {
|
|
118
|
+
// TODO: handle nuance to moving across elements (nested elements, pointer capture), event cancellation?
|
|
119
|
+
this.buttonStates[buttonIndex] = false;
|
|
120
|
+
const event = new PointerEvent("pointerup", Object.assign(this.getEventOptions({ x, y }), {
|
|
121
|
+
button: buttonIndex,
|
|
122
|
+
buttons: this.buttonStates[0] * 1 + this.buttonStates[1] * 2 + this.buttonStates[2] * 4,
|
|
123
|
+
bubbles: true,
|
|
124
|
+
cancelable: true,
|
|
125
|
+
}));
|
|
126
|
+
target.dispatchEvent(event);
|
|
127
|
+
if (buttonIndex === 0) {
|
|
128
|
+
if (this.pointerDownElement === target) {
|
|
129
|
+
this.click(target, x, y);
|
|
130
|
+
}
|
|
131
|
+
} else if (buttonIndex === 2) {
|
|
132
|
+
const contextMenuEvent = new MouseEvent("contextmenu", Object.assign(this.getEventOptions({ x, y }), {
|
|
133
|
+
button: buttonIndex,
|
|
134
|
+
bubbles: true,
|
|
135
|
+
cancelable: true,
|
|
136
|
+
}));
|
|
137
|
+
const contextMenuEventResult = target.dispatchEvent(contextMenuEvent);
|
|
138
|
+
if (contextMenuEventResult) {
|
|
139
|
+
this.showContextMenu(target, x, y);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this.pointerDownElement = null;
|
|
143
|
+
|
|
144
|
+
// TODO: support also MMB to open links in a new tab
|
|
145
|
+
autoscroll.pointerUp(target, x, y, buttonIndex, this.pointerId);
|
|
146
|
+
}
|
|
147
|
+
setMouseButtonState(buttonIndex, pressed) {
|
|
148
|
+
if (this.buttonStates[buttonIndex] !== pressed) {
|
|
149
|
+
const { x, y } = this.simulatedMousePosition;
|
|
150
|
+
const target = this.targetFromPoint(x, y);
|
|
151
|
+
if (pressed) {
|
|
152
|
+
this.pointerDown(target, x, y, buttonIndex);
|
|
153
|
+
} else {
|
|
154
|
+
this.pointerUp(target, x, y, buttonIndex);
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
dropdownToCloseFunction = new WeakMap();
|
|
161
|
+
openDropdown(dropdown, { focus = true } = {}) {
|
|
162
|
+
if (this.dropdownToCloseFunction.has(dropdown)) {
|
|
163
|
+
return; // avoid double opening
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const flyout = document.createElement("ul");
|
|
167
|
+
|
|
168
|
+
// fake button displayed on top just in case you use arrow keys, because the value shouldn't change in the original select
|
|
169
|
+
// (an alternative hack might be to override `value` with a getter)
|
|
170
|
+
const dropdownDisplayButton = dropdown.cloneNode(true);
|
|
171
|
+
dropdownDisplayButton.value = dropdown.value;
|
|
172
|
+
dropdownDisplayButton.style.pointerEvents = "none";
|
|
173
|
+
|
|
174
|
+
const dropdownValueWhenOpened = dropdown.value;
|
|
175
|
+
let dropdownValueToBeWhenClosed = dropdown.value;
|
|
176
|
+
|
|
177
|
+
let highlightIndex = dropdown.selectedIndex;
|
|
178
|
+
const buttons = [];
|
|
179
|
+
function updateHighlightStyles() {
|
|
180
|
+
for (let optionIndex = 0; optionIndex < buttons.length; optionIndex++) {
|
|
181
|
+
buttons[optionIndex].style.backgroundColor = highlightIndex === optionIndex ? "Highlight" : "transparent";
|
|
182
|
+
buttons[optionIndex].style.color = highlightIndex === optionIndex ? "HighlightText" : "";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (let optionIndex = 0; optionIndex < dropdown.options.length; optionIndex++) {
|
|
187
|
+
const option = dropdown.options[optionIndex];
|
|
188
|
+
const li = document.createElement("li");
|
|
189
|
+
flyout.append(li);
|
|
190
|
+
const button = document.createElement("button");
|
|
191
|
+
button.textContent = option.textContent;
|
|
192
|
+
button.dataset.value = option.value;
|
|
193
|
+
button.disabled = option.disabled;
|
|
194
|
+
li.append(button);
|
|
195
|
+
button.style.padding = "5px";
|
|
196
|
+
button.style.border = "none";
|
|
197
|
+
button.style.width = "100%";
|
|
198
|
+
button.style.textAlign = "left";
|
|
199
|
+
button.style.display = "block";
|
|
200
|
+
button.style.cssText += option.style.cssText;
|
|
201
|
+
|
|
202
|
+
// Hover effect
|
|
203
|
+
button.addEventListener("pointerenter", () => {
|
|
204
|
+
if (button.disabled) return;
|
|
205
|
+
highlightIndex = buttons.indexOf(button);
|
|
206
|
+
updateHighlightStyles();
|
|
207
|
+
});
|
|
208
|
+
button.addEventListener("click", () => {
|
|
209
|
+
if (button.disabled) return;
|
|
210
|
+
dropdownValueToBeWhenClosed = button.dataset.value;
|
|
211
|
+
dropdownDisplayButton.value = button.dataset.value;
|
|
212
|
+
this.closeDropdown(dropdown);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
buttons.push(button);
|
|
216
|
+
}
|
|
217
|
+
updateHighlightStyles();
|
|
218
|
+
|
|
219
|
+
document.body.append(flyout, dropdownDisplayButton);
|
|
220
|
+
|
|
221
|
+
flyout.style.zIndex = "100";
|
|
222
|
+
flyout.style.overflow = "auto";
|
|
223
|
+
flyout.style.background = "white";
|
|
224
|
+
flyout.style.color = "black";
|
|
225
|
+
flyout.style.border = "1px solid gray";
|
|
226
|
+
flyout.style.outline = "0";
|
|
227
|
+
flyout.style.padding = "0";
|
|
228
|
+
flyout.style.margin = "0";
|
|
229
|
+
flyout.style.listStyle = "none";
|
|
230
|
+
flyout.style.boxSizing = "border-box";
|
|
231
|
+
flyout.style.userSelect = "none";
|
|
232
|
+
|
|
233
|
+
// Handle opening downwards, upwards or both directions as needed, limited to the full page height
|
|
234
|
+
let animationFrameId = null;
|
|
235
|
+
const positionElements = () => {
|
|
236
|
+
const dropdownRect = dropdown.getBoundingClientRect();
|
|
237
|
+
dropdownDisplayButton.style.position = "fixed";
|
|
238
|
+
dropdownDisplayButton.style.top = `${dropdownRect.top}px`;
|
|
239
|
+
dropdownDisplayButton.style.left = `${dropdownRect.left}px`;
|
|
240
|
+
dropdownDisplayButton.style.width = `${dropdownRect.width}px`;
|
|
241
|
+
flyout.style.position = "fixed";
|
|
242
|
+
flyout.style.top = `${dropdownRect.bottom}px`;
|
|
243
|
+
flyout.style.left = `${dropdownRect.left}px`;
|
|
244
|
+
flyout.style.width = `${dropdownRect.width}px`;
|
|
245
|
+
if (flyout.getBoundingClientRect().bottom > window.innerHeight) {
|
|
246
|
+
flyout.style.top = `${dropdownRect.top - flyout.getBoundingClientRect().height}px`;
|
|
247
|
+
}
|
|
248
|
+
if (flyout.getBoundingClientRect().top < 0) {
|
|
249
|
+
flyout.style.top = "0px";
|
|
250
|
+
}
|
|
251
|
+
flyout.style.maxHeight = "100vh";
|
|
252
|
+
animationFrameId = requestAnimationFrame(positionElements);
|
|
253
|
+
};
|
|
254
|
+
positionElements();
|
|
255
|
+
|
|
256
|
+
flyout.tabIndex = 0;
|
|
257
|
+
if (focus) {
|
|
258
|
+
flyout.focus();
|
|
259
|
+
flyout.addEventListener("blur", () => {
|
|
260
|
+
this.closeDropdown(dropdown);
|
|
261
|
+
}, { once: true });
|
|
262
|
+
}
|
|
263
|
+
flyout.addEventListener("keydown", (event) => {
|
|
264
|
+
// TODO: should Esc/Enter be global? (maybe even arrow keys?)
|
|
265
|
+
if (event.key === "Escape" || event.key === "Enter") {
|
|
266
|
+
this.closeDropdown(dropdown);
|
|
267
|
+
}
|
|
268
|
+
const dx = (event.key === "ArrowRight") - (event.key === "ArrowLeft");
|
|
269
|
+
const dy = (event.key === "ArrowDown") - (event.key === "ArrowUp");
|
|
270
|
+
if (dy !== 0 || dx !== 0) {
|
|
271
|
+
const newIndex = highlightIndex === -1 ? 0 : ((highlightIndex + dy + buttons.length) % buttons.length);
|
|
272
|
+
highlightIndex = newIndex;
|
|
273
|
+
updateHighlightStyles();
|
|
274
|
+
buttons[newIndex].scrollIntoView({ block: "nearest", container: "nearest" });
|
|
275
|
+
dropdownValueToBeWhenClosed = buttons[newIndex].dataset.value;
|
|
276
|
+
dropdownDisplayButton.value = buttons[newIndex].dataset.value;
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
let flyoutPointerDownOutsideHandler;
|
|
281
|
+
addEventListener("pointerdown", flyoutPointerDownOutsideHandler = (event) => {
|
|
282
|
+
if (!event.target?.closest || (event.target.closest("ul") !== flyout && event.target.closest("select") !== dropdown)) {
|
|
283
|
+
this.closeDropdown(dropdown);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
flyout.addEventListener("pointerdown", (event) => {
|
|
288
|
+
event.preventDefault(); // prevent starting text selection
|
|
289
|
+
});
|
|
290
|
+
flyout.addEventListener("contextmenu", (event) => {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const closeFunction = () => {
|
|
295
|
+
cancelAnimationFrame(animationFrameId);
|
|
296
|
+
removeEventListener("pointerdown", flyoutPointerDownOutsideHandler);
|
|
297
|
+
if (!flyout || this._closingDropdown) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
this._closingDropdown = true; // TODO: should this flag be scoped to each dropdown or stay global?
|
|
301
|
+
if (dropdownValueWhenOpened !== dropdownValueToBeWhenClosed) {
|
|
302
|
+
dropdown.value = dropdownValueToBeWhenClosed;
|
|
303
|
+
dropdown.dispatchEvent(new Event("input", { bubbles: true }));
|
|
304
|
+
dropdown.dispatchEvent(new Event("change", { bubbles: true }));
|
|
305
|
+
}
|
|
306
|
+
flyout.remove(); // Can trigger blur event in Chromium-based browsers
|
|
307
|
+
dropdownDisplayButton.remove();
|
|
308
|
+
this._closingDropdown = false;
|
|
309
|
+
};
|
|
310
|
+
this.dropdownToCloseFunction.set(dropdown, closeFunction);
|
|
311
|
+
}
|
|
312
|
+
closeDropdown(dropdown) {
|
|
313
|
+
this.dropdownToCloseFunction.get(dropdown)?.();
|
|
314
|
+
this.dropdownToCloseFunction.delete(dropdown);
|
|
315
|
+
}
|
|
316
|
+
click(target, x, y) {
|
|
317
|
+
if (target.matches("input[type='range']")) {
|
|
318
|
+
// Special handling for sliders
|
|
319
|
+
// TODO: support continuous dragging
|
|
320
|
+
const rect = target.getBoundingClientRect();
|
|
321
|
+
const vertical = target.getAttribute("orient") === "vertical" ||
|
|
322
|
+
(this.getCurrentRotation(target) !== 0) ||
|
|
323
|
+
rect.height > rect.width;
|
|
324
|
+
const min = Number(target.min);
|
|
325
|
+
const max = Number(target.max);
|
|
326
|
+
const style = window.getComputedStyle(target);
|
|
327
|
+
const isRTL = style.direction === "rtl";
|
|
328
|
+
const fraction = vertical
|
|
329
|
+
? (y - rect.top) / rect.height
|
|
330
|
+
: (isRTL ? (rect.right - x) / rect.width : (x - rect.left) / rect.width);
|
|
331
|
+
target.value = fraction * (max - min) + min;
|
|
332
|
+
target.dispatchEvent(new Event("input", { bubbles: true }));
|
|
333
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
334
|
+
} else if (target.matches("option")) {
|
|
335
|
+
const select = target.closest("select");
|
|
336
|
+
if (select) {
|
|
337
|
+
select.value = target.value;
|
|
338
|
+
select.dispatchEvent(new Event("input", { bubbles: true }));
|
|
339
|
+
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
340
|
+
}
|
|
341
|
+
} else if (target.matches("select")) {
|
|
342
|
+
// Special handling for dropdowns
|
|
343
|
+
if (target.getAttribute("size")) {
|
|
344
|
+
// Fallback logic assuming all options are the same height
|
|
345
|
+
// Do any browsers actually not give you <option> elements with document.getElementFromPoint?
|
|
346
|
+
// I assumed they wouldn't when I wrote this, but it's great that they do, or Firefox does at least
|
|
347
|
+
const rect = target.getBoundingClientRect();
|
|
348
|
+
const fraction = (y - rect.top) / rect.height;
|
|
349
|
+
const newValue = target.options[Math.floor(fraction * target.options.length)].value;
|
|
350
|
+
if (newValue !== target.value) {
|
|
351
|
+
target.value = newValue;
|
|
352
|
+
target.dispatchEvent(new Event("input", { bubbles: true }));
|
|
353
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
354
|
+
}
|
|
355
|
+
} else if (this.dropdownToCloseFunction.has(target)) {
|
|
356
|
+
this.closeDropdown(target);
|
|
357
|
+
} else {
|
|
358
|
+
this.openDropdown(target);
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
// Normal click
|
|
362
|
+
// HTMLElement has click() but SVGElement does not
|
|
363
|
+
if (target.click) {
|
|
364
|
+
target.click();
|
|
365
|
+
} else {
|
|
366
|
+
const event = new MouseEvent("click", Object.assign(this.getEventOptions({ x, y }), {
|
|
367
|
+
button: 0,
|
|
368
|
+
bubbles: true,
|
|
369
|
+
cancelable: true,
|
|
370
|
+
}));
|
|
371
|
+
target.dispatchEvent(event);
|
|
372
|
+
}
|
|
373
|
+
if (target.matches("input, textarea")) {
|
|
374
|
+
target.focus();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
showContextMenu(target, x, y) {
|
|
379
|
+
const commands = ["copy", "cut", "paste", "delete", "selectAll", "undo", "redo"];
|
|
380
|
+
const supportedCommands = commands.filter(cmd => document.queryCommandSupported(cmd));
|
|
381
|
+
const enabledCommands = supportedCommands.filter(cmd => document.queryCommandEnabled(cmd));
|
|
382
|
+
console.log({ supportedCommands, enabledCommands });
|
|
383
|
+
|
|
384
|
+
const execCommandItem = (label, command) => {
|
|
385
|
+
return {
|
|
386
|
+
label,
|
|
387
|
+
visible: supportedCommands.includes(command),
|
|
388
|
+
enabled: enabledCommands.includes(command),
|
|
389
|
+
action: () => document.execCommand(command),
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const menuItems = [
|
|
394
|
+
// TODO: avoid executing first item when dismissing context menu
|
|
395
|
+
// For now, include a no-op first item
|
|
396
|
+
{ label: '' }, // not enabled: false so that another item isn't highlighted with a gray background
|
|
397
|
+
|
|
398
|
+
{
|
|
399
|
+
label: 'Open Link in New Tab',
|
|
400
|
+
visible: target.matches("a[href]"),
|
|
401
|
+
action: () => {
|
|
402
|
+
const a = target?.closest('a');
|
|
403
|
+
if (a?.href) {
|
|
404
|
+
return !!window.open(a.href, '_blank');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
{ separator: true, visible: target.matches("a[href]") },
|
|
409
|
+
|
|
410
|
+
execCommandItem('Copy', 'copy'),
|
|
411
|
+
execCommandItem('Cut', 'cut'),
|
|
412
|
+
execCommandItem('Paste', 'paste'),
|
|
413
|
+
execCommandItem('Delete', 'delete'),
|
|
414
|
+
execCommandItem('Select All', 'selectAll'),
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
// Create an invisible select element for context menu positioning, in order to reuse the dropdown code
|
|
418
|
+
const select = document.createElement('select');
|
|
419
|
+
select.style.position = 'absolute';
|
|
420
|
+
select.style.left = `${x + window.scrollX}px`;
|
|
421
|
+
const height = 16; // arbitrary (but maybe not zero? and should be accounted for if it's not zero)
|
|
422
|
+
select.style.height = `${height}px`;
|
|
423
|
+
select.style.top = `${y - height + window.scrollY}px`;
|
|
424
|
+
select.style.opacity = '0';
|
|
425
|
+
select.style.pointerEvents = 'none';
|
|
426
|
+
select.tabIndex = -1;
|
|
427
|
+
|
|
428
|
+
const optionToMenuItem = new WeakMap();
|
|
429
|
+
for (const item of menuItems) {
|
|
430
|
+
if (item.visible === false) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const option = document.createElement('option');
|
|
434
|
+
option.textContent = item.label;
|
|
435
|
+
option.disabled = item.enabled === false || item.separator === true;
|
|
436
|
+
if (!item.label) {
|
|
437
|
+
option.style.fontSize = '0';
|
|
438
|
+
option.style.padding = '0';
|
|
439
|
+
}
|
|
440
|
+
if (item.separator) {
|
|
441
|
+
option.style.borderTop = '1px solid #ccc';
|
|
442
|
+
option.style.fontSize = '4px';
|
|
443
|
+
option.style.marginTop = '4px';
|
|
444
|
+
}
|
|
445
|
+
select.appendChild(option);
|
|
446
|
+
optionToMenuItem.set(option, item);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
document.body.appendChild(select);
|
|
450
|
+
// technically the dropdown should have focus, but
|
|
451
|
+
// 1. this is an interface to allow for (virtual-)mouse-only access, so keyboard is not so important
|
|
452
|
+
// 2. I think it's more important to keep showing the selection you're about to copy/cut/delete
|
|
453
|
+
target.focus();
|
|
454
|
+
this.openDropdown(select, { focus: false });
|
|
455
|
+
|
|
456
|
+
select.addEventListener("change", () => {
|
|
457
|
+
const item = optionToMenuItem.get(select.options[select.selectedIndex]);
|
|
458
|
+
select.remove();
|
|
459
|
+
target.focus();
|
|
460
|
+
if (item.action && item.enabled !== false) {
|
|
461
|
+
const result = item.action();
|
|
462
|
+
this.showToast(item.label + (result === false ? " not allowed" : ""));
|
|
463
|
+
}
|
|
464
|
+
}, { once: true });
|
|
465
|
+
}
|
|
466
|
+
showToast(message, position = this.simulatedMousePosition) {
|
|
467
|
+
const { x, y } = position;
|
|
468
|
+
const toast = document.createElement("div");
|
|
469
|
+
toast.textContent = message;
|
|
470
|
+
toast.style.position = "fixed";
|
|
471
|
+
toast.style.left = `${x}px`;
|
|
472
|
+
toast.style.top = `${y}px`;
|
|
473
|
+
toast.style.background = "rgba(0, 0, 0, 0.7)";
|
|
474
|
+
toast.style.color = "white";
|
|
475
|
+
toast.style.padding = "2px 5px";
|
|
476
|
+
toast.style.borderRadius = "3px";
|
|
477
|
+
toast.style.pointerEvents = "none";
|
|
478
|
+
toast.style.animation = "tracky-mouse-fade-out 2s ease-in-out forwards 2s";
|
|
479
|
+
document.body.appendChild(toast);
|
|
480
|
+
setTimeout(() => {
|
|
481
|
+
toast.remove();
|
|
482
|
+
}, 4000);
|
|
483
|
+
}
|
|
484
|
+
caretPositionFromPoint(x, y) {
|
|
485
|
+
// Firefox (standard)
|
|
486
|
+
if (document.caretPositionFromPoint) {
|
|
487
|
+
return document.caretPositionFromPoint(x, y);
|
|
488
|
+
}
|
|
489
|
+
// Chrome/Edge/Safari (non-standard)
|
|
490
|
+
if (document.caretRangeFromPoint) {
|
|
491
|
+
const range = document.caretRangeFromPoint(x, y);
|
|
492
|
+
if (range) {
|
|
493
|
+
return { offsetNode: range.startContainer, offset: range.startOffset };
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
throw new Error('Neither caretPositionFromPoint nor caretRangeFromPoint is supported.');
|
|
498
|
+
}
|
|
499
|
+
targetFromPoint(x, y) {
|
|
500
|
+
const skip = ".tracky-mouse-click-through, .tracky-mouse-click-through *";
|
|
501
|
+
const fallback = document.body; // would documentElement make more sense?
|
|
502
|
+
|
|
503
|
+
let target = document.elementFromPoint(x, y);
|
|
504
|
+
if (!target) {
|
|
505
|
+
return fallback;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (target.matches(skip)) {
|
|
509
|
+
const elements = document.elementsFromPoint(x, y);
|
|
510
|
+
target = elements.find(el => !el.matches(skip));
|
|
511
|
+
if (!target) {
|
|
512
|
+
return fallback;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return target || fallback;
|
|
517
|
+
}
|
|
518
|
+
}
|
package/tracky-mouse.css
CHANGED
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
gap: 10px;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
.tracky-mouse-
|
|
164
|
+
.tracky-mouse-camera-area {
|
|
165
165
|
flex: 1;
|
|
166
166
|
flex-basis: 0;
|
|
167
167
|
min-width: 0;
|
|
@@ -214,6 +214,14 @@
|
|
|
214
214
|
color: white;
|
|
215
215
|
padding: 5px;
|
|
216
216
|
animation: tracky-mouse-jello-vertical 1s ease 0s 1 normal none;
|
|
217
|
+
white-space: pre-wrap;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.tracky-mouse-error-message pre {
|
|
221
|
+
white-space: pre-wrap;
|
|
222
|
+
font-size: small;
|
|
223
|
+
background: rgba(0, 0, 0, 0.5);
|
|
224
|
+
color: white;
|
|
217
225
|
}
|
|
218
226
|
|
|
219
227
|
@keyframes tracky-mouse-jello-vertical {
|
|
@@ -246,6 +254,21 @@
|
|
|
246
254
|
}
|
|
247
255
|
}
|
|
248
256
|
|
|
257
|
+
.tracky-mouse-toast {
|
|
258
|
+
position: fixed;
|
|
259
|
+
top: 25px + env(titlebar-area-height, 0px);
|
|
260
|
+
top: calc(25px + env(titlebar-area-height, 0px));
|
|
261
|
+
right: 25px;
|
|
262
|
+
z-index: 100;
|
|
263
|
+
background-color: rgba(255, 233, 38, 0.8);
|
|
264
|
+
color: black;
|
|
265
|
+
padding: 10px 20px;
|
|
266
|
+
border-radius: 5px;
|
|
267
|
+
animation: tracky-mouse-fade-out 3s 3s ease-out forwards;
|
|
268
|
+
white-space: pre-wrap;
|
|
269
|
+
font-family: sans-serif;
|
|
270
|
+
}
|
|
271
|
+
|
|
249
272
|
/* Screen overlay in electron app */
|
|
250
273
|
|
|
251
274
|
.tracky-mouse-hide-near-cursor {
|
|
@@ -258,9 +281,17 @@
|
|
|
258
281
|
user-select: none;
|
|
259
282
|
}
|
|
260
283
|
|
|
284
|
+
#tracky-mouse-screen-overlay-work-area {
|
|
285
|
+
position: fixed;
|
|
286
|
+
top: 0;
|
|
287
|
+
left: 0;
|
|
288
|
+
width: 100%;
|
|
289
|
+
height: 100%;
|
|
290
|
+
}
|
|
291
|
+
|
|
261
292
|
#tracky-mouse-screen-overlay-message {
|
|
262
293
|
color: yellow;
|
|
263
|
-
position:
|
|
294
|
+
position: absolute;
|
|
264
295
|
bottom: 0;
|
|
265
296
|
left: 50%;
|
|
266
297
|
transform: translateX(-50%);
|