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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.128.13",
|
||||
"version": "0.128.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
23
frontend/src/lib/readerKeys.test.ts
Normal file
23
frontend/src/lib/readerKeys.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
frontend/src/lib/readerKeys.ts
Normal file
19
frontend/src/lib/readerKeys.ts
Normal 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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
|
||||
import { fileUrl, ApiError } from '$lib/api/client';
|
||||
import { isTypingTarget } from '$lib/readerKeys';
|
||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
import { updateReadProgress } from '$lib/api/read_progress';
|
||||
@@ -675,9 +676,9 @@
|
||||
);
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Don't hijack keys while the user is typing in an input.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
// Don't hijack keys while the user is typing or focused on a native
|
||||
// control (input/textarea/select/contenteditable own their own keys).
|
||||
if (isTypingTarget(e.target)) return;
|
||||
// `?` opens the keyboard-shortcut help overlay. While it's open,
|
||||
// swallow every key here so paging can't happen behind it — the
|
||||
// Sheet owns its own Escape/close.
|
||||
@@ -687,6 +688,20 @@
|
||||
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
|
||||
// modes, including when the bars are hidden. Handled before
|
||||
// the per-mode switches so it doesn't get shadowed.
|
||||
|
||||
Reference in New Issue
Block a user