Skip to content

Commit f2d46ad

Browse files
committed
fix(quick-tools): prevent scrollbars during editor gestures
1 parent b143d7e commit f2d46ad

4 files changed

Lines changed: 126 additions & 18 deletions

File tree

‎src/handlers/editorWorkaround.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { createEditorInteractionGuard } from "lib/editorInteractionGuard";
12
import { quickToolUsed } from "./quickTools";
23

3-
let debounceTimer;
44
let lastInput = null;
5+
const interactionGuard = createEditorInteractionGuard();
56

67
const setKeyboardInput = () => {
78
lastInput = "keyboard";
@@ -13,22 +14,37 @@ document.addEventListener("input", setKeyboardInput, true);
1314
document.addEventListener("compositionstart", setKeyboardInput, true);
1415

1516
function setTouched() {
16-
clearTimeout(debounceTimer);
17-
document.body.setAttribute("data-editor-touched", "true");
18-
debounceTimer = setTimeout(() => {
19-
document.body.removeAttribute("data-editor-touched");
20-
}, 200);
17+
interactionGuard.markActive();
2118
}
2219

2320
document.addEventListener(
2421
"pointerdown",
2522
(e) => {
2623
lastInput = "pointer";
27-
if (e.target.closest(".editor-container")) setTouched();
24+
if (e.target.closest(".editor-container")) {
25+
setTouched();
26+
return;
27+
}
28+
interactionGuard.suppress(e);
2829
},
2930
true,
3031
);
3132

33+
// Avoid toggling pointer-events on the scrollable quick-tools container. Older
34+
// Android WebViews repaint its normally hidden horizontal scrollbar each time.
35+
for (const eventName of [
36+
"touchstart",
37+
"mousedown",
38+
"click",
39+
"contextmenu",
40+
"wheel",
41+
]) {
42+
document.addEventListener(eventName, (e) => interactionGuard.suppress(e), {
43+
capture: true,
44+
passive: false,
45+
});
46+
}
47+
3248
document.addEventListener("selectionchange", () => {
3349
if (lastInput !== "pointer" || quickToolUsed) return;
3450
const sel = document.getSelection();

‎src/lib/editorInteractionGuard.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const GUARDED_TARGETS = "#quick-tools, .notification-item-container";
2+
3+
export const EDITOR_INTERACTION_GUARD_DURATION = 200;
4+
5+
interface EditorInteractionGuardOptions {
6+
now?: () => number;
7+
duration?: number;
8+
}
9+
10+
interface ClosestEventTarget extends EventTarget {
11+
closest?: (selector: string) => Element | null;
12+
}
13+
14+
export function createEditorInteractionGuard({
15+
now = () => performance.now(),
16+
duration = EDITOR_INTERACTION_GUARD_DURATION,
17+
}: EditorInteractionGuardOptions = {}) {
18+
let activeUntil = 0;
19+
20+
return {
21+
markActive() {
22+
activeUntil = now() + duration;
23+
},
24+
25+
suppress(event: Event) {
26+
const target = event.target as ClosestEventTarget | null;
27+
if (
28+
now() >= activeUntil ||
29+
typeof target?.closest !== "function" ||
30+
!target.closest(GUARDED_TARGETS)
31+
) {
32+
return false;
33+
}
34+
35+
if (event.cancelable) event.preventDefault();
36+
event.stopImmediatePropagation();
37+
return true;
38+
},
39+
};
40+
}

‎src/main.scss‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,6 @@ body {
9292
}
9393
}
9494

95-
&[data-editor-touched] {
96-
#quick-tools {
97-
pointer-events: none;
98-
}
99-
100-
.notification-item-container {
101-
pointer-events: none;
102-
}
103-
}
104-
10595
.main {
10696
position: relative;
10797
}
@@ -770,4 +760,3 @@ input[type="search"]::-webkit-search-results-decoration {
770760
}
771761
}
772762

773-
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "vitest";
3+
import { createEditorInteractionGuard } from "../../src/lib/editorInteractionGuard";
4+
5+
function createEvent(matchesGuardedTarget, cancelable = true) {
6+
let defaultPrevented = false;
7+
let propagationStopped = false;
8+
9+
return {
10+
cancelable,
11+
target: {
12+
closest() {
13+
return matchesGuardedTarget ? {} : null;
14+
},
15+
},
16+
preventDefault() {
17+
defaultPrevented = true;
18+
},
19+
stopImmediatePropagation() {
20+
propagationStopped = true;
21+
},
22+
get defaultPrevented() {
23+
return defaultPrevented;
24+
},
25+
get propagationStopped() {
26+
return propagationStopped;
27+
},
28+
};
29+
}
30+
31+
test("suppresses guarded controls briefly after an editor interaction", () => {
32+
let time = 100;
33+
const guard = createEditorInteractionGuard({ now: () => time });
34+
guard.markActive();
35+
const event = createEvent(true);
36+
37+
assert.equal(guard.suppress(event), true);
38+
assert.equal(event.defaultPrevented, true);
39+
assert.equal(event.propagationStopped, true);
40+
41+
time = 300;
42+
assert.equal(guard.suppress(createEvent(true)), false);
43+
});
44+
45+
test("does not suppress editor or unrelated events", () => {
46+
const guard = createEditorInteractionGuard({ now: () => 100 });
47+
guard.markActive();
48+
const event = createEvent(false);
49+
50+
assert.equal(guard.suppress(event), false);
51+
assert.equal(event.defaultPrevented, false);
52+
assert.equal(event.propagationStopped, false);
53+
});
54+
55+
test("stops non-cancelable guarded events without calling preventDefault", () => {
56+
const guard = createEditorInteractionGuard({ now: () => 100 });
57+
guard.markActive();
58+
const event = createEvent(true, false);
59+
60+
assert.equal(guard.suppress(event), true);
61+
assert.equal(event.defaultPrevented, false);
62+
assert.equal(event.propagationStopped, true);
63+
});

0 commit comments

Comments
 (0)