Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/handlers/editorWorkaround.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand Down
40 changes: 40 additions & 0 deletions src/lib/editorInteractionGuard.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
11 changes: 0 additions & 11 deletions src/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,6 @@ body {
}
}

&[data-editor-touched] {
#quick-tools {
pointer-events: none;
}

.notification-item-container {
pointer-events: none;
}
}

.main {
position: relative;
}
Expand Down Expand Up @@ -770,4 +760,3 @@ input[type="search"]::-webkit-search-results-decoration {
}
}


63 changes: 63 additions & 0 deletions tests/unit/editorInteractionGuard.test.js
Original file line number Diff line number Diff line change
@@ -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);
});