mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Feat: TouchScreen Support for SeriesDetail & Modularity Revamp (#29
This commit is contained in:
@@ -34,14 +34,6 @@ In-Progress:
|
|||||||
|
|
||||||
- Integrate Tauri JSON for Settings
|
- Integrate Tauri JSON for Settings
|
||||||
- Create Migration Logic for Local Storage
|
- 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:
|
Notes from last time:
|
||||||
- Storage has been configured, now just need protocols
|
- Storage has been configured, now just need protocols
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './idle';
|
export * from './idle';
|
||||||
export * from './zoom';
|
export * from './zoom';
|
||||||
|
export * from './touchscreen';
|
||||||
@@ -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<typeof setTimeout> | 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<typeof setTimeout> | 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<number, PointerEvent>();
|
||||||
|
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);
|
||||||
|
}};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
import type { DownloadQueueItem } from "@types/index";
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
import { pageProgress } from "../lib/downloadQueue";
|
import { pageProgress } from "../lib/downloadQueue";
|
||||||
|
|
||||||
@@ -24,6 +25,12 @@
|
|||||||
const prog = $derived(pageProgress(item.progress, pages));
|
const prog = $derived(pageProgress(item.progress, pages));
|
||||||
const isError = $derived(item.state === "ERROR");
|
const isError = $derived(item.state === "ERROR");
|
||||||
const pct = $derived(Math.round(item.progress * 100));
|
const pct = $derived(Math.round(item.progress * 100));
|
||||||
|
|
||||||
|
function rowLongPress(node: HTMLElement) {
|
||||||
|
return longPress(node, {
|
||||||
|
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -32,6 +39,7 @@
|
|||||||
class:row-error={isError}
|
class:row-error={isError}
|
||||||
class:row-selected={isSelected}
|
class:row-selected={isSelected}
|
||||||
class:row-removing={isRemoving}
|
class:row-removing={isRemoving}
|
||||||
|
use:rowLongPress
|
||||||
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
||||||
>
|
>
|
||||||
{#if manga?.thumbnailUrl}
|
{#if manga?.thumbnailUrl}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { sortLibrary } from "../lib/librarySort";
|
import { sortLibrary } from "../lib/librarySort";
|
||||||
import { startLibraryUpdate } from "../lib/libraryUpdater";
|
import { startLibraryUpdate } from "../lib/libraryUpdater";
|
||||||
import { createPaginator } from "@core/algorithms/paginate";
|
import { createPaginator } from "@core/algorithms/paginate";
|
||||||
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
import {
|
import {
|
||||||
store, setCategories, setLibraryUpdates, addToast,
|
store, setCategories, setLibraryUpdates, addToast,
|
||||||
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
||||||
@@ -197,34 +198,28 @@
|
|||||||
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
||||||
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
||||||
|
|
||||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
let cardLongPressFired = false;
|
||||||
let longPressFired = false;
|
|
||||||
let emptyLongPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function onRootPointerDown(e: PointerEvent) {
|
function rootLongPressAction(node: HTMLElement) {
|
||||||
if (e.button !== 0) return;
|
return longPress(node, {
|
||||||
if ((e.target as HTMLElement).closest("button, .card")) return;
|
onLongPress(e) {
|
||||||
emptyLongPressTimer = setTimeout(() => {
|
if ((e.target as HTMLElement).closest("button, .card")) return;
|
||||||
emptyLongPressTimer = null;
|
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
||||||
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
},
|
||||||
}, 500);
|
});
|
||||||
}
|
}
|
||||||
function onRootPointerUp() { if (emptyLongPressTimer) { clearTimeout(emptyLongPressTimer); emptyLongPressTimer = null; } }
|
|
||||||
function onRootPointerLeave() { if (emptyLongPressTimer) { clearTimeout(emptyLongPressTimer); emptyLongPressTimer = null; } }
|
function cardLongPress(node: HTMLElement, m: Manga) {
|
||||||
function onCardPointerDown(e: PointerEvent, m: Manga) {
|
return longPress(node, {
|
||||||
if (e.button !== 0) return;
|
onLongPress(e) {
|
||||||
longPressFired = false;
|
cardLongPressFired = true;
|
||||||
longPressTimer = setTimeout(() => {
|
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
||||||
longPressTimer = null;
|
},
|
||||||
longPressFired = true;
|
});
|
||||||
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
function onCardPointerUp() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
|
|
||||||
function onCardPointerLeave() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
|
|
||||||
|
|
||||||
function onCardClick(e: MouseEvent, m: Manga) {
|
function onCardClick(e: MouseEvent, m: Manga) {
|
||||||
if (longPressFired) { longPressFired = false; return; }
|
if (cardLongPressFired) { cardLongPressFired = false; return; }
|
||||||
if (selectMode) { toggleSelect(m.id); return; }
|
if (selectMode) { toggleSelect(m.id); return; }
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
|
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
|
||||||
store.activeManga = m;
|
store.activeManga = m;
|
||||||
@@ -523,14 +518,12 @@
|
|||||||
class="root"
|
class="root"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
bind:this={scrollEl}
|
bind:this={scrollEl}
|
||||||
|
use:rootLongPressAction
|
||||||
oncontextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
||||||
}}
|
}}
|
||||||
onpointerdown={onRootPointerDown}
|
|
||||||
onpointerup={onRootPointerUp}
|
|
||||||
onpointerleave={onRootPointerLeave}
|
|
||||||
>
|
>
|
||||||
{#if store.settings.libraryBranches ?? true}
|
{#if store.settings.libraryBranches ?? true}
|
||||||
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
||||||
@@ -624,9 +617,7 @@
|
|||||||
libraryFilter={tab}
|
libraryFilter={tab}
|
||||||
onCardClick={onCardClick}
|
onCardClick={onCardClick}
|
||||||
onCardContextMenu={openCtx}
|
onCardContextMenu={openCtx}
|
||||||
onCardPointerDown={onCardPointerDown}
|
onCardLongPress={cardLongPress}
|
||||||
onCardPointerUp={onCardPointerUp}
|
|
||||||
onCardPointerLeave={onCardPointerLeave}
|
|
||||||
onLoadMore={loadMore}
|
onLoadMore={loadMore}
|
||||||
onRetry={() => retryCount++}
|
onRetry={() => retryCount++}
|
||||||
onExitSelectMode={exitSelectMode}
|
onExitSelectMode={exitSelectMode}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { resolvedCover } from "@core/cover/coverResolver";
|
import { resolvedCover } from "@core/cover/coverResolver";
|
||||||
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
import type { Manga, Category } from "@types";
|
import type { Manga, Category } from "@types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,9 +23,7 @@
|
|||||||
visibleCategories: Category[];
|
visibleCategories: Category[];
|
||||||
onCardClick: (e: MouseEvent, m: Manga) => void;
|
onCardClick: (e: MouseEvent, m: Manga) => void;
|
||||||
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
||||||
onCardPointerDown: (e: PointerEvent, m: Manga) => void;
|
onCardLongPress: (node: HTMLElement, m: Manga) => ReturnType<typeof longPress>;
|
||||||
onCardPointerUp: () => void;
|
|
||||||
onCardPointerLeave: () => void;
|
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onExitSelectMode: () => void;
|
onExitSelectMode: () => void;
|
||||||
@@ -38,7 +37,7 @@
|
|||||||
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
||||||
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
||||||
bulkWorking, visibleCategories,
|
bulkWorking, visibleCategories,
|
||||||
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
|
onCardClick, onCardContextMenu, onCardLongPress,
|
||||||
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -122,11 +121,9 @@
|
|||||||
class:card-selected={isSelected}
|
class:card-selected={isSelected}
|
||||||
class:select-mode={selectMode}
|
class:select-mode={selectMode}
|
||||||
class:anims={anims}
|
class:anims={anims}
|
||||||
|
use:onCardLongPress={m}
|
||||||
onclick={(e) => onCardClick(e, m)}
|
onclick={(e) => onCardClick(e, m)}
|
||||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
||||||
onpointerdown={(e) => onCardPointerDown(e, m)}
|
|
||||||
onpointerup={onCardPointerUp}
|
|
||||||
onpointerleave={onCardPointerLeave}
|
|
||||||
>
|
>
|
||||||
<div class="cover-wrap" class:completed={isCompleted}>
|
<div class="cover-wrap" class:completed={isCompleted}>
|
||||||
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte";
|
import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte";
|
||||||
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||||
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||||
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
import type { Chapter } from "@types";
|
import type { Chapter } from "@types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,6 +33,13 @@
|
|||||||
|
|
||||||
const hasSelection = $derived(selectedIds.size > 0);
|
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 {
|
function formatDate(ts: string | null | undefined): string {
|
||||||
if (!ts) return "";
|
if (!ts) return "";
|
||||||
const n = Number(ts);
|
const n = Number(ts);
|
||||||
@@ -58,9 +66,11 @@
|
|||||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||||
{@const isGridSelected = selectedIds.has(ch.id)}
|
{@const isGridSelected = selectedIds.has(ch.id)}
|
||||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
|
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
|
||||||
|
use:chapterLongPress={[ch, i]}
|
||||||
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
|
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
||||||
title={ch.name}>
|
title={ch.name}
|
||||||
|
>{#if isGridSelected}<span class="grid-cell-check">✓</span>{/if}
|
||||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
||||||
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
|
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
|
||||||
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
||||||
@@ -74,6 +84,7 @@
|
|||||||
{@const isSelected = selectedIds.has(ch.id)}
|
{@const isSelected = selectedIds.has(ch.id)}
|
||||||
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
|
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
|
||||||
|
use:chapterLongPress={[ch, idxInSorted]}
|
||||||
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)}
|
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)}
|
||||||
onkeydown={(e) => e.key === "Enter" && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))}
|
onkeydown={(e) => e.key === "Enter" && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
||||||
@@ -164,10 +175,11 @@
|
|||||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
||||||
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
||||||
|
.grid-cell-check { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--accent-fg); pointer-events: none; }
|
||||||
|
|
||||||
.pagination-bottom { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
.pagination-bottom { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
</style>
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import {
|
import {
|
||||||
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
|
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
|
||||||
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple,
|
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
|
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
|
||||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
@@ -452,6 +452,7 @@
|
|||||||
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
|
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
|
||||||
return [
|
return [
|
||||||
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
||||||
|
{ label: "Select", icon: CheckSquare, onClick: () => { const next = new Set(selectedIds); next.add(ch.id); selectedIds = next; } },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "Mark above as read", icon: ArrowFatLinesUp, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
|
{ label: "Mark above as read", icon: ArrowFatLinesUp, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
|
||||||
{ label: "Mark above as unread", icon: ArrowFatLineUp, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
|
{ label: "Mark above as unread", icon: ArrowFatLineUp, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
|
||||||
|
|||||||
Reference in New Issue
Block a user