fix: stop reader key navigation leaking behind open overlays

Arrows/j/k/Home/End bubbled to the reader's window handler while a
jump/settings/action/collections/tags/context overlay was open, paging the
chapter behind it (FE-1); native select/contenteditable weren't exempt (FE-4).
New unit-tested isTypingTarget() + an overlay-open early return fix both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 21:28:57 +02:00
parent c31830468c
commit cd5358dc9b
6 changed files with 63 additions and 6 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.128.13" version = "0.128.14"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.128.13" version = "0.128.14"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.128.13", "version": "0.128.14",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { isTypingTarget } from './readerKeys';
function el(tag: string, contentEditable = false): HTMLElement {
const node = document.createElement(tag);
if (contentEditable) node.setAttribute('contenteditable', 'true');
return node;
}
describe('isTypingTarget', () => {
it('is true for text-entry and native-key elements', () => {
expect(isTypingTarget(el('input'))).toBe(true);
expect(isTypingTarget(el('textarea'))).toBe(true);
expect(isTypingTarget(el('select'))).toBe(true);
expect(isTypingTarget(el('div', true))).toBe(true);
});
it('is false for ordinary elements and null', () => {
expect(isTypingTarget(el('div'))).toBe(false);
expect(isTypingTarget(el('button'))).toBe(false);
expect(isTypingTarget(null)).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
/**
* Whether a keyboard event's target is a place the user is typing (or otherwise
* expects native key handling), so the reader must NOT hijack arrows/j/k/Home/End
* for page navigation. Covers text inputs, textareas, native `<select>` dropdowns
* (whose Home/End/arrows are their own), and any contenteditable region.
*/
export function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
// `isContentEditable` is the correct runtime signal (accounts for inherited
// editability); the attribute check is a robust fallback for the explicit
// case and for environments that don't compute the property (jsdom).
const editable =
el.isContentEditable === true ||
el.getAttribute?.('contenteditable') === 'true' ||
el.getAttribute?.('contenteditable') === '';
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable;
}

View File

@@ -3,6 +3,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { afterNavigate, goto, invalidateAll } from '$app/navigation'; import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { fileUrl, ApiError } from '$lib/api/client'; import { fileUrl, ApiError } from '$lib/api/client';
import { isTypingTarget } from '$lib/readerKeys';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences'; import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte'; import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress'; import { updateReadProgress } from '$lib/api/read_progress';
@@ -675,9 +676,9 @@
); );
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
// Don't hijack keys while the user is typing in an input. // Don't hijack keys while the user is typing or focused on a native
const target = e.target as HTMLElement | null; // control (input/textarea/select/contenteditable own their own keys).
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return; if (isTypingTarget(e.target)) return;
// `?` opens the keyboard-shortcut help overlay. While it's open, // `?` opens the keyboard-shortcut help overlay. While it's open,
// swallow every key here so paging can't happen behind it — the // swallow every key here so paging can't happen behind it — the
// Sheet owns its own Escape/close. // Sheet owns its own Escape/close.
@@ -687,6 +688,20 @@
return; return;
} }
if (shortcutsOpen) return; if (shortcutsOpen) return;
// Any other overlay (jump/settings/action/collections/tags/context menu)
// owns its own keys; swallow reader paging so arrows/j/k/Home/End can't
// act on the chapter *behind* the overlay. Those overlays only
// stopPropagation() on Escape, so navigation keys otherwise bubble here.
if (
chapterJumpOpen ||
settingsOpen ||
contextMenuOpen ||
actionSheetOpen ||
collectionsModalOpen ||
tagsModalOpen
) {
return;
}
// Esc always exits fullscreen if active — applies in both // Esc always exits fullscreen if active — applies in both
// modes, including when the bars are hidden. Handled before // modes, including when the bars are hidden. Handled before
// the per-mode switches so it doesn't get shadowed. // the per-mode switches so it doesn't get shadowed.