diff --git a/src/handlers/editorWorkaround.js b/src/handlers/editorWorkaround.js index b7278a5b8..75866d8cb 100644 --- a/src/handlers/editorWorkaround.js +++ b/src/handlers/editorWorkaround.js @@ -1,7 +1,8 @@ +import { createEditorInteractionGuard } from "lib/editorInteractionGuard"; import { quickToolUsed } from "./quickTools"; -let debounceTimer; let lastInput = null; +const interactionGuard = createEditorInteractionGuard(); const setKeyboardInput = () => { lastInput = "keyboard"; @@ -13,22 +14,37 @@ document.addEventListener("input", setKeyboardInput, true); document.addEventListener("compositionstart", setKeyboardInput, true); function setTouched() { - clearTimeout(debounceTimer); - document.body.setAttribute("data-editor-touched", "true"); - debounceTimer = setTimeout(() => { - document.body.removeAttribute("data-editor-touched"); - }, 200); + interactionGuard.markActive(); } document.addEventListener( "pointerdown", (e) => { lastInput = "pointer"; - if (e.target.closest(".editor-container")) setTouched(); + if (e.target.closest(".editor-container")) { + setTouched(); + return; + } + interactionGuard.suppress(e); }, true, ); +// Avoid toggling pointer-events on the scrollable quick-tools container. Older +// Android WebViews repaint its normally hidden horizontal scrollbar each time. +for (const eventName of [ + "touchstart", + "mousedown", + "click", + "contextmenu", + "wheel", +]) { + document.addEventListener(eventName, (e) => interactionGuard.suppress(e), { + capture: true, + passive: false, + }); +} + document.addEventListener("selectionchange", () => { if (lastInput !== "pointer" || quickToolUsed) return; const sel = document.getSelection(); diff --git a/src/lib/editorInteractionGuard.ts b/src/lib/editorInteractionGuard.ts new file mode 100644 index 000000000..e741011b5 --- /dev/null +++ b/src/lib/editorInteractionGuard.ts @@ -0,0 +1,40 @@ +const GUARDED_TARGETS = "#quick-tools, .notification-item-container"; + +export const EDITOR_INTERACTION_GUARD_DURATION = 200; + +interface EditorInteractionGuardOptions { + now?: () => number; + duration?: number; +} + +interface ClosestEventTarget extends EventTarget { + closest?: (selector: string) => Element | null; +} + +export function createEditorInteractionGuard({ + now = () => performance.now(), + duration = EDITOR_INTERACTION_GUARD_DURATION, +}: EditorInteractionGuardOptions = {}) { + let activeUntil = 0; + + return { + markActive() { + activeUntil = now() + duration; + }, + + suppress(event: Event) { + const target = event.target as ClosestEventTarget | null; + if ( + now() >= activeUntil || + typeof target?.closest !== "function" || + !target.closest(GUARDED_TARGETS) + ) { + return false; + } + + if (event.cancelable) event.preventDefault(); + event.stopImmediatePropagation(); + return true; + }, + }; +} diff --git a/src/main.scss b/src/main.scss index e4dfb3357..d49def88b 100644 --- a/src/main.scss +++ b/src/main.scss @@ -92,16 +92,6 @@ body { } } - &[data-editor-touched] { - #quick-tools { - pointer-events: none; - } - - .notification-item-container { - pointer-events: none; - } - } - .main { position: relative; } @@ -770,4 +760,3 @@ input[type="search"]::-webkit-search-results-decoration { } } - diff --git a/tests/unit/editorInteractionGuard.test.js b/tests/unit/editorInteractionGuard.test.js new file mode 100644 index 000000000..9247aadc2 --- /dev/null +++ b/tests/unit/editorInteractionGuard.test.js @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { createEditorInteractionGuard } from "../../src/lib/editorInteractionGuard"; + +function createEvent(matchesGuardedTarget, cancelable = true) { + let defaultPrevented = false; + let propagationStopped = false; + + return { + cancelable, + target: { + closest() { + return matchesGuardedTarget ? {} : null; + }, + }, + preventDefault() { + defaultPrevented = true; + }, + stopImmediatePropagation() { + propagationStopped = true; + }, + get defaultPrevented() { + return defaultPrevented; + }, + get propagationStopped() { + return propagationStopped; + }, + }; +} + +test("suppresses guarded controls briefly after an editor interaction", () => { + let time = 100; + const guard = createEditorInteractionGuard({ now: () => time }); + guard.markActive(); + const event = createEvent(true); + + assert.equal(guard.suppress(event), true); + assert.equal(event.defaultPrevented, true); + assert.equal(event.propagationStopped, true); + + time = 300; + assert.equal(guard.suppress(createEvent(true)), false); +}); + +test("does not suppress editor or unrelated events", () => { + const guard = createEditorInteractionGuard({ now: () => 100 }); + guard.markActive(); + const event = createEvent(false); + + assert.equal(guard.suppress(event), false); + assert.equal(event.defaultPrevented, false); + assert.equal(event.propagationStopped, false); +}); + +test("stops non-cancelable guarded events without calling preventDefault", () => { + const guard = createEditorInteractionGuard({ now: () => 100 }); + guard.markActive(); + const event = createEvent(true, false); + + assert.equal(guard.suppress(event), true); + assert.equal(event.defaultPrevented, false); + assert.equal(event.propagationStopped, true); +});