feat(search): tag-based page search surface + per-page tags & collections
Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,36 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
onPrev,
|
||||
onNext,
|
||||
onToggle,
|
||||
onLongPress,
|
||||
testid = 'tap-zone'
|
||||
}: {
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onToggle: () => void;
|
||||
/**
|
||||
* Fires when a touch lingers > 450ms on any zone without
|
||||
* moving. The viewport-coord `anchor` lets the reader open a
|
||||
* sheet anchored to the press location. Desktop pointers
|
||||
* (`pointerType !== 'touch'`) never trigger this — right-click
|
||||
* is the desktop entry point and lives in the reader.
|
||||
*/
|
||||
onLongPress?: (anchor: { x: number; y: number }) => void;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
|
||||
const LONG_PRESS_MS = 450;
|
||||
const MOVE_TOLERANCE = 8;
|
||||
|
||||
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pressStart: { x: number; y: number } | null = null;
|
||||
// Self-expiring suppression: cleared either by an actual click
|
||||
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
|
||||
// timer below. The latter matters when the user lifts off the
|
||||
// tap zone after a long-press (finger slid onto the sheet) — no
|
||||
// click is synthesized, so without an expiry the flag would leak
|
||||
// and eat the next legitimate tap.
|
||||
const SUPPRESS_EXPIRY_MS = 500;
|
||||
let suppressNextClick = false;
|
||||
let suppressExpiryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearPress() {
|
||||
if (pressTimer != null) {
|
||||
clearTimeout(pressTimer);
|
||||
pressTimer = null;
|
||||
}
|
||||
pressStart = null;
|
||||
}
|
||||
|
||||
function clearSuppress() {
|
||||
suppressNextClick = false;
|
||||
if (suppressExpiryTimer != null) {
|
||||
clearTimeout(suppressExpiryTimer);
|
||||
suppressExpiryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!onLongPress) return;
|
||||
if (e.pointerType !== 'touch') return;
|
||||
clearPress();
|
||||
pressStart = { x: e.clientX, y: e.clientY };
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
pressTimer = setTimeout(() => {
|
||||
pressTimer = null;
|
||||
suppressNextClick = true;
|
||||
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
|
||||
suppressExpiryTimer = setTimeout(() => {
|
||||
suppressNextClick = false;
|
||||
suppressExpiryTimer = null;
|
||||
}, SUPPRESS_EXPIRY_MS);
|
||||
onLongPress?.({ x: startX, y: startY });
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (pressTimer == null || pressStart == null) return;
|
||||
const dx = e.clientX - pressStart.x;
|
||||
const dy = e.clientY - pressStart.y;
|
||||
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
clearPress();
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
clearPress();
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
// Scrolling while pressed almost always means the user is
|
||||
// panning, not deliberately holding. Cancel.
|
||||
clearPress();
|
||||
}
|
||||
|
||||
function withSuppress(handler: () => void) {
|
||||
return () => {
|
||||
if (suppressNextClick) {
|
||||
clearSuppress();
|
||||
return;
|
||||
}
|
||||
handler();
|
||||
};
|
||||
}
|
||||
|
||||
// onMount only runs on the client, so the window-scroll listener
|
||||
// is automatically guarded — no SSR check needed. The teardown
|
||||
// also clears any in-flight long-press timer.
|
||||
onMount(() => {
|
||||
if (!onLongPress) return;
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
clearPress();
|
||||
clearSuppress();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tap-zones" data-testid={testid}>
|
||||
<button
|
||||
type="button"
|
||||
class="zone left"
|
||||
onclick={onPrev}
|
||||
onclick={withSuppress(onPrev)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
aria-label="Previous page"
|
||||
data-testid="{testid}-left"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="zone center"
|
||||
onclick={onToggle}
|
||||
onclick={withSuppress(onToggle)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
aria-label="Toggle controls"
|
||||
data-testid="{testid}-center"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="zone right"
|
||||
onclick={onNext}
|
||||
onclick={withSuppress(onNext)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
aria-label="Next page"
|
||||
data-testid="{testid}-right"
|
||||
></button>
|
||||
|
||||
Reference in New Issue
Block a user