mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: TouchScreen Support for SeriesDetail & Modularity Revamp (#29
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { longPress } from "@core/ui/touchscreen";
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
import { pageProgress } from "../lib/downloadQueue";
|
||||
|
||||
@@ -24,6 +25,12 @@
|
||||
const prog = $derived(pageProgress(item.progress, pages));
|
||||
const isError = $derived(item.state === "ERROR");
|
||||
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>
|
||||
|
||||
<div
|
||||
@@ -32,6 +39,7 @@
|
||||
class:row-error={isError}
|
||||
class:row-selected={isSelected}
|
||||
class:row-removing={isRemoving}
|
||||
use:rowLongPress
|
||||
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
||||
>
|
||||
{#if manga?.thumbnailUrl}
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
let longPressFired = false;
|
||||
let emptyLongPressTimer: ReturnType<typeof setTimeout> | 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}
|
||||
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
||||
@@ -624,9 +617,7 @@
|
||||
libraryFilter={tab}
|
||||
onCardClick={onCardClick}
|
||||
onCardContextMenu={openCtx}
|
||||
onCardPointerDown={onCardPointerDown}
|
||||
onCardPointerUp={onCardPointerUp}
|
||||
onCardPointerLeave={onCardPointerLeave}
|
||||
onCardLongPress={cardLongPress}
|
||||
onLoadMore={loadMore}
|
||||
onRetry={() => retryCount++}
|
||||
onExitSelectMode={exitSelectMode}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { longPress } from "@core/ui/touchscreen";
|
||||
import type { Manga, Category } from "@types";
|
||||
|
||||
interface Props {
|
||||
@@ -22,9 +23,7 @@
|
||||
visibleCategories: Category[];
|
||||
onCardClick: (e: MouseEvent, m: Manga) => void;
|
||||
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
||||
onCardPointerDown: (e: PointerEvent, m: Manga) => void;
|
||||
onCardPointerUp: () => void;
|
||||
onCardPointerLeave: () => void;
|
||||
onCardLongPress: (node: HTMLElement, m: Manga) => ReturnType<typeof longPress>;
|
||||
onLoadMore: () => void;
|
||||
onRetry: () => void;
|
||||
onExitSelectMode: () => void;
|
||||
@@ -38,7 +37,7 @@
|
||||
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
||||
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
||||
bulkWorking, visibleCategories,
|
||||
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
|
||||
onCardClick, onCardContextMenu, onCardLongPress,
|
||||
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -122,11 +121,9 @@
|
||||
class:card-selected={isSelected}
|
||||
class:select-mode={selectMode}
|
||||
class:anims={anims}
|
||||
use:onCardLongPress={m}
|
||||
onclick={(e) => onCardClick(e, m)}
|
||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
||||
onpointerdown={(e) => onCardPointerDown(e, m)}
|
||||
onpointerup={onCardPointerUp}
|
||||
onpointerleave={onCardPointerLeave}
|
||||
>
|
||||
<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" />
|
||||
|
||||
@@ -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)}
|
||||
<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)}
|
||||
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>
|
||||
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
|
||||
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
||||
@@ -74,6 +84,7 @@
|
||||
{@const isSelected = selectedIds.has(ch.id)}
|
||||
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||
<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)}
|
||||
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 }; }}>
|
||||
@@ -164,10 +175,11 @@
|
||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||
.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-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; }
|
||||
.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: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); }
|
||||
</style>
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import {
|
||||
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
|
||||
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple,
|
||||
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
|
||||
} from "phosphor-svelte";
|
||||
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
|
||||
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;
|
||||
return [
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
|
||||
Reference in New Issue
Block a user