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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
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 { 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user