diff --git a/Todo b/Todo index ad4f87f..f124eae 100644 --- a/Todo +++ b/Todo @@ -34,14 +34,6 @@ In-Progress: - Integrate Tauri JSON for Settings - Create Migration Logic for Local Storage - - Integrate Tauri SQLITE for Caching - - Document Caching (What is being Loaded) - - Create Proper Cache-Clearing & Tie into Suwayomi Methods - - - Fix Tap-Hold to Context-Menu Instead of Select - - Cap Context-Menu Folders (Sub-In Move-Custom for Additionals) - - - Add Multi-Select for SeriesDetail GridView Notes from last time: - Storage has been configured, now just need protocols diff --git a/src/core/ui/index.ts b/src/core/ui/index.ts index a54f13a..6991c2e 100644 --- a/src/core/ui/index.ts +++ b/src/core/ui/index.ts @@ -1,2 +1,3 @@ export * from './idle'; -export * from './zoom'; \ No newline at end of file +export * from './zoom'; +export * from './touchscreen'; \ No newline at end of file diff --git a/src/core/ui/touchscreen.ts b/src/core/ui/touchscreen.ts new file mode 100644 index 0000000..e1203a6 --- /dev/null +++ b/src/core/ui/touchscreen.ts @@ -0,0 +1,222 @@ +export interface LongPressOptions { + onLongPress: (e: PointerEvent) => void; + duration?: number; + moveThreshold?: number; +} + +export function longPress(node: HTMLElement, opts: LongPressOptions) { + const { onLongPress, duration = 500, moveThreshold = 8 } = opts; + let timer: ReturnType | null = null; + let startX = 0, startY = 0; + let fired = false; + + function start(e: PointerEvent) { + if (e.button !== 0 && e.pointerType === "mouse") return; + startX = e.clientX; startY = e.clientY; fired = false; + timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration); + } + function move(e: PointerEvent) { + if (!timer) return; + const dx = e.clientX - startX, dy = e.clientY - startY; + if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel(); + } + function cancel() { if (timer) { clearTimeout(timer); timer = null; } } + + node.addEventListener("pointerdown", start); + node.addEventListener("pointermove", move); + node.addEventListener("pointerup", cancel); + node.addEventListener("pointerleave", cancel); + node.addEventListener("pointercancel",cancel); + + function suppressClick(e: MouseEvent) { if (fired) { fired = false; e.preventDefault(); e.stopPropagation(); } } + node.addEventListener("click", suppressClick, true); + + return { + get fired() { return fired; }, + destroy() { + cancel(); + node.removeEventListener("pointerdown", start); + node.removeEventListener("pointermove", move); + node.removeEventListener("pointerup", cancel); + node.removeEventListener("pointerleave", cancel); + node.removeEventListener("pointercancel",cancel); + node.removeEventListener("click", suppressClick, true); + }, + }; +} + +export interface TapOptions { + onTap: (e: PointerEvent) => void; + onDoubleTap?: (e: PointerEvent) => void; + doubleTapGap?: number; +} + +export function tap(node: HTMLElement, opts: TapOptions) { + const { onTap, onDoubleTap, doubleTapGap = 300 } = opts; + let lastTap = 0; + let pending: ReturnType | null = null; + let startX = 0, startY = 0; + const SLOP = 8; + + function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; } + function up(e: PointerEvent) { + const dx = e.clientX - startX, dy = e.clientY - startY; + if (Math.sqrt(dx * dx + dy * dy) > SLOP) return; + const now = Date.now(); + if (onDoubleTap && now - lastTap < doubleTapGap) { + if (pending) { clearTimeout(pending); pending = null; } + onDoubleTap(e); + lastTap = 0; + } else { + lastTap = now; + if (onDoubleTap) { + pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap); + } else { + onTap(e); + } + } + } + + node.addEventListener("pointerdown", down); + node.addEventListener("pointerup", up); + return { destroy() { + node.removeEventListener("pointerdown", down); + node.removeEventListener("pointerup", up); + }}; +} + +export interface SwipeOptions { + onSwipeLeft?: (e: PointerEvent) => void; + onSwipeRight?: (e: PointerEvent) => void; + onSwipeUp?: (e: PointerEvent) => void; + onSwipeDown?: (e: PointerEvent) => void; + threshold?: number; + lockAxis?: boolean; +} + +export function swipe(node: HTMLElement, opts: SwipeOptions) { + const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts; + let startX = 0, startY = 0, active = false; + + function down(e: PointerEvent) { + if (e.pointerType === "mouse") return; + startX = e.clientX; startY = e.clientY; active = true; + node.setPointerCapture(e.pointerId); + } + function up(e: PointerEvent) { + if (!active) return; active = false; + const dx = e.clientX - startX, dy = e.clientY - startY; + const ax = Math.abs(dx), ay = Math.abs(dy); + if (Math.max(ax, ay) < threshold) return; + if (lockAxis && ax > ay) { + if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); + } else if (lockAxis && ay >= ax) { + if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); + } else { + if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); } + else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); } + } + } + function cancel() { active = false; } + + node.addEventListener("pointerdown", down); + node.addEventListener("pointerup", up); + node.addEventListener("pointercancel", cancel); + return { destroy() { + node.removeEventListener("pointerdown", down); + node.removeEventListener("pointerup", up); + node.removeEventListener("pointercancel", cancel); + }}; +} + +export interface PinchOptions { + onPinch: (scale: number, origin: { x: number; y: number }) => void; + onPinchEnd?: (scale: number) => void; +} + +export function pinch(node: HTMLElement, opts: PinchOptions) { + const { onPinch, onPinchEnd } = opts; + const pointers = new Map(); + let initDist = 0, initMid = { x: 0, y: 0 }; + + function dist(a: PointerEvent, b: PointerEvent) { + const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + function mid(a: PointerEvent, b: PointerEvent) { + return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; + } + + function down(e: PointerEvent) { + pointers.set(e.pointerId, e); + node.setPointerCapture(e.pointerId); + if (pointers.size === 2) { + const [a, b] = [...pointers.values()]; + initDist = dist(a, b); + initMid = mid(a, b); + } + } + function move(e: PointerEvent) { + if (!pointers.has(e.pointerId)) return; + pointers.set(e.pointerId, e); + if (pointers.size !== 2 || initDist === 0) return; + const [a, b] = [...pointers.values()]; + onPinch(dist(a, b) / initDist, mid(a, b)); + } + function up(e: PointerEvent) { + if (pointers.size === 2 && onPinchEnd) { + const [a, b] = [...pointers.values()]; + onPinchEnd(dist(a, b) / initDist); + } + pointers.delete(e.pointerId); + initDist = 0; + } + + node.addEventListener("pointerdown", down); + node.addEventListener("pointermove", move); + node.addEventListener("pointerup", up); + node.addEventListener("pointercancel", up); + return { destroy() { + node.removeEventListener("pointerdown", down); + node.removeEventListener("pointermove", move); + node.removeEventListener("pointerup", up); + node.removeEventListener("pointercancel", up); + }}; +} + +export interface DragScrollOptions { + direction?: "x" | "y" | "both"; + onDragStart?: () => void; + onDragEnd?: () => void; +} + +export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) { + const { direction = "both", onDragStart, onDragEnd } = opts; + let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0; + + function down(e: PointerEvent) { + if (e.pointerType === "mouse") return; + active = true; + startX = e.clientX; startY = e.clientY; + scrollX = node.scrollLeft; scrollY = node.scrollTop; + node.setPointerCapture(e.pointerId); + onDragStart?.(); + } + function move(e: PointerEvent) { + if (!active) return; + if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY); + if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX); + } + function up() { if (active) { active = false; onDragEnd?.(); } } + + node.addEventListener("pointerdown", down); + node.addEventListener("pointermove", move); + node.addEventListener("pointerup", up); + node.addEventListener("pointercancel", up); + return { destroy() { + node.removeEventListener("pointerdown", down); + node.removeEventListener("pointermove", move); + node.removeEventListener("pointerup", up); + node.removeEventListener("pointercancel", up); + }}; +} \ No newline at end of file diff --git a/src/features/downloads/components/DownloadItem.svelte b/src/features/downloads/components/DownloadItem.svelte index ccb2100..8a16bad 100644 --- a/src/features/downloads/components/DownloadItem.svelte +++ b/src/features/downloads/components/DownloadItem.svelte @@ -1,6 +1,7 @@
{ e.stopPropagation(); onSelect(item.chapter.id, e); }} > {#if manga?.thumbnailUrl} diff --git a/src/features/library/components/Library.svelte b/src/features/library/components/Library.svelte index 46014da..4ca529c 100644 --- a/src/features/library/components/Library.svelte +++ b/src/features/library/components/Library.svelte @@ -13,6 +13,7 @@ import { sortLibrary } from "../lib/librarySort"; import { startLibraryUpdate } from "../lib/libraryUpdater"; import { createPaginator } from "@core/algorithms/paginate"; + import { longPress } from "@core/ui/touchscreen"; import { store, setCategories, setLibraryUpdates, addToast, setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters, @@ -197,34 +198,28 @@ function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); } function loadMore() { renderVisible = paginator.nextVisible(renderVisible); } - let longPressTimer: ReturnType | null = null; - let longPressFired = false; - let emptyLongPressTimer: ReturnType | null = null; + let cardLongPressFired = false; - function onRootPointerDown(e: PointerEvent) { - if (e.button !== 0) return; - if ((e.target as HTMLElement).closest("button, .card")) return; - emptyLongPressTimer = setTimeout(() => { - emptyLongPressTimer = null; - emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H }; - }, 500); + function rootLongPressAction(node: HTMLElement) { + return longPress(node, { + onLongPress(e) { + if ((e.target as HTMLElement).closest("button, .card")) return; + emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H }; + }, + }); } - function onRootPointerUp() { if (emptyLongPressTimer) { clearTimeout(emptyLongPressTimer); emptyLongPressTimer = null; } } - function onRootPointerLeave() { if (emptyLongPressTimer) { clearTimeout(emptyLongPressTimer); emptyLongPressTimer = null; } } - function onCardPointerDown(e: PointerEvent, m: Manga) { - if (e.button !== 0) return; - longPressFired = false; - longPressTimer = setTimeout(() => { - longPressTimer = null; - longPressFired = true; - ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m }; - }, 500); + + function cardLongPress(node: HTMLElement, m: Manga) { + return longPress(node, { + onLongPress(e) { + cardLongPressFired = true; + ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m }; + }, + }); } - function onCardPointerUp() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } } - function onCardPointerLeave() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } } function onCardClick(e: MouseEvent, m: Manga) { - if (longPressFired) { longPressFired = false; return; } + if (cardLongPressFired) { cardLongPressFired = false; return; } if (selectMode) { toggleSelect(m.id); return; } if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; } store.activeManga = m; @@ -523,14 +518,12 @@ class="root" role="presentation" bind:this={scrollEl} + use:rootLongPressAction oncontextmenu={(e) => { if ((e.target as HTMLElement).closest("button")) return; e.preventDefault(); emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H }; }} - onpointerdown={onRootPointerDown} - onpointerup={onRootPointerUp} - onpointerleave={onRootPointerLeave} > {#if store.settings.libraryBranches ?? true}
diff --git a/src/features/series/components/ChapterList.svelte b/src/features/series/components/ChapterList.svelte index d7f88ef..a729bd8 100644 --- a/src/features/series/components/ChapterList.svelte +++ b/src/features/series/components/ChapterList.svelte @@ -2,6 +2,7 @@ import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte"; import ContextMenu from "@shared/ui/ContextMenu.svelte"; import type { MenuEntry } from "@shared/ui/ContextMenu.svelte"; + import { longPress } from "@core/ui/touchscreen"; import type { Chapter } from "@types"; interface Props { @@ -32,6 +33,13 @@ const hasSelection = $derived(selectedIds.size > 0); + function chapterLongPress(node: HTMLElement, param: [Chapter, number]) { + const [ch, idx] = param; + return longPress(node, { + onLongPress(e) { ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx }; }, + }); + } + function formatDate(ts: string | null | undefined): string { if (!ts) return ""; const n = Number(ts); @@ -58,9 +66,11 @@ {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const isGridSelected = selectedIds.has(ch.id)}