mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Implement CSS for Chrome & Link to Context-Menu
This commit is contained in:
@@ -7,10 +7,7 @@ Major Revisions:
|
|||||||
|
|
||||||
Minor Revisions:
|
Minor Revisions:
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
|
||||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
|
||||||
|
|
||||||
|
|
||||||
Priority Bugs:
|
Priority Bugs:
|
||||||
- Fix Library-Refresh System (TESTING)
|
- Fix Library-Refresh System (TESTING)
|
||||||
@@ -24,6 +21,10 @@ Pending/On-Hold:
|
|||||||
- Working on 3D Display Cards
|
- Working on 3D Display Cards
|
||||||
- Add Flathub Support (Pending Video)
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
|
- Change Auto-Link Threshold
|
||||||
|
- Fix Auto-Link De-dupe for Images
|
||||||
|
- Optimize Auto-Link Latency (IP)
|
||||||
|
|
||||||
In-Progress:
|
In-Progress:
|
||||||
- Fix Tracking Login
|
- Fix Tracking Login
|
||||||
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
@@ -31,14 +32,19 @@ In-Progress:
|
|||||||
- Tracking
|
- Tracking
|
||||||
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
|
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
|
||||||
|
|
||||||
- Refactor Content-Filters, Change Source-Enabling (More Intuitive)
|
|
||||||
|
|
||||||
- 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
|
- Integrate Tauri SQLITE for Caching
|
||||||
- Document Caching (What is being Loaded)
|
- Document Caching (What is being Loaded)
|
||||||
- Create Proper Cache-Clearing & Tie into Suwayomi Methods
|
- 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:
|
||||||
- Fixed Search Issue
|
- Storage has been configured, now just need protocols
|
||||||
- Create Z-Index for Library Statistics
|
- Export/Import
|
||||||
|
- Migration
|
||||||
|
- Data-Clean
|
||||||
@@ -8,5 +8,6 @@
|
|||||||
--sp-8: 32px;
|
--sp-8: 32px;
|
||||||
--sp-10: 40px;
|
--sp-10: 40px;
|
||||||
|
|
||||||
--sidebar-width: 52px;
|
--sidebar-width: 52px;
|
||||||
}
|
--titlebar-height: 36px;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
} from "../store/libraryState.svelte";
|
} from "../store/libraryState.svelte";
|
||||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
||||||
import type { Manga, Category, Chapter } from "@types";
|
import type { Manga, Category, Chapter } from "@types";
|
||||||
import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte";
|
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
|
||||||
|
|
||||||
import LibraryToolbar from "./LibraryToolbar.svelte";
|
import LibraryToolbar from "./LibraryToolbar.svelte";
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
const CARD_MIN_W = 130;
|
const CARD_MIN_W = 130;
|
||||||
const CARD_GAP = 16;
|
const CARD_GAP = 16;
|
||||||
const COMPLETED_NAME = "Completed";
|
const COMPLETED_NAME = "Completed";
|
||||||
|
const CTX_FOLDER_CAP = 4;
|
||||||
|
|
||||||
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
|
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
|
||||||
|
|
||||||
@@ -68,11 +69,10 @@
|
|||||||
let dragTabId: number | null = $state(null);
|
let dragTabId: number | null = $state(null);
|
||||||
let dragOverTabId: number | null = $state(null);
|
let dragOverTabId: number | null = $state(null);
|
||||||
|
|
||||||
|
|
||||||
const DT_TAB = "application/x-moku-tab";
|
const DT_TAB = "application/x-moku-tab";
|
||||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||||
|
|
||||||
const tab = $derived(store.libraryFilter);
|
const tab = $derived(store.libraryFilter);
|
||||||
const tabSortMode = $derived(store.settings.libraryTabSort[tab]?.mode ?? "az" as LibrarySortMode);
|
const tabSortMode = $derived(store.settings.libraryTabSort[tab]?.mode ?? "az" as LibrarySortMode);
|
||||||
const tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir);
|
const tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir);
|
||||||
const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter);
|
const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter);
|
||||||
@@ -198,14 +198,33 @@
|
|||||||
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
||||||
|
|
||||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let longPressFired = false;
|
||||||
|
let emptyLongPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
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 onRootPointerUp() { if (emptyLongPressTimer) { clearTimeout(emptyLongPressTimer); emptyLongPressTimer = null; } }
|
||||||
|
function onRootPointerLeave() { if (emptyLongPressTimer) { clearTimeout(emptyLongPressTimer); emptyLongPressTimer = null; } }
|
||||||
function onCardPointerDown(e: PointerEvent, m: Manga) {
|
function onCardPointerDown(e: PointerEvent, m: Manga) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
longPressTimer = setTimeout(() => { longPressTimer = null; enterSelectMode(m.id); }, 500);
|
longPressFired = false;
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
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 onCardPointerUp() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
|
||||||
function onCardPointerLeave() { 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 (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;
|
||||||
@@ -271,6 +290,11 @@
|
|||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bumpCategoryFrecency(catId: number) {
|
||||||
|
const prev = (store.settings as any).categoryFrecency ?? {};
|
||||||
|
updateSettings({ categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } } as any);
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
||||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
||||||
setCategories(store.categories.map(c => {
|
setCategories(store.categories.map(c => {
|
||||||
@@ -278,6 +302,7 @@
|
|||||||
const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga];
|
const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga];
|
||||||
return { ...c, mangas: { nodes } };
|
return { ...c, mangas: { nodes } };
|
||||||
}));
|
}));
|
||||||
|
if (!inCat) bumpCategoryFrecency(cat.id);
|
||||||
try {
|
try {
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
|
||||||
if (!inCat && !manga.inLibrary) {
|
if (!inCat && !manga.inLibrary) {
|
||||||
@@ -296,6 +321,7 @@
|
|||||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
||||||
const cat = res.createCategory.category;
|
const cat = res.createCategory.category;
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
||||||
|
bumpCategoryFrecency(cat.id);
|
||||||
if (!manga.inLibrary) {
|
if (!manga.inLibrary) {
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
||||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
||||||
@@ -342,24 +368,37 @@
|
|||||||
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
|
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_W = 52;
|
||||||
|
const TITLEBAR_H = 36;
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
if (selectMode) { toggleSelect(m.id); return; }
|
if (selectMode) { toggleSelect(m.id); return; }
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
const catEntries: MenuEntry[] = visibleCategories.map(cat => {
|
const frecency: Record<number, number> = (store.settings as any).categoryFrecency ?? {};
|
||||||
|
const sorted = [...visibleCategories].sort((a, b) => (frecency[b.id] ?? 0) - (frecency[a.id] ?? 0));
|
||||||
|
const pinned = sorted.slice(0, CTX_FOLDER_CAP);
|
||||||
|
const overflow = sorted.slice(CTX_FOLDER_CAP);
|
||||||
|
|
||||||
|
const makeCatEntry = (cat: Category): MenuEntry => {
|
||||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
||||||
return { label: inCat ? `Remove from ${cat.name}` : `Add to ${cat.name}`, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
|
return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const pinnedEntries = pinned.map(makeCatEntry);
|
||||||
|
const overflowChildren: MenuEntry[] = overflow.map(makeCatEntry);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||||
{ label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
|
{ label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
|
||||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
{ label: "Select", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
||||||
...(catEntries.length ? [{ separator: true } as MenuEntry, ...catEntries] : []),
|
...(pinnedEntries.length ? [{ separator: true } as MenuEntry, ...pinnedEntries] : []),
|
||||||
|
...(overflowChildren.length ? [{ label: `More folders (${overflowChildren.length})`, icon: FolderSimple, onClick: () => {}, children: overflowChildren } as MenuEntry] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
||||||
];
|
];
|
||||||
@@ -393,12 +432,9 @@
|
|||||||
refreshProgress = { finished: 0, total: 0 };
|
refreshProgress = { finished: 0, total: 0 };
|
||||||
|
|
||||||
cancelUpdate = startLibraryUpdate({
|
cancelUpdate = startLibraryUpdate({
|
||||||
onProgress(p) {
|
onProgress(p) { refreshProgress = p; },
|
||||||
refreshProgress = p;
|
|
||||||
},
|
|
||||||
async onDone({ entries, totalUpdated, newChapters }) {
|
async onDone({ entries, totalUpdated, newChapters }) {
|
||||||
refreshing = false;
|
refreshing = false; cancelUpdate = null;
|
||||||
cancelUpdate = null;
|
|
||||||
setLibraryUpdates(entries);
|
setLibraryUpdates(entries);
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||||
await loadData();
|
await loadData();
|
||||||
@@ -407,10 +443,7 @@
|
|||||||
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
||||||
showToast(newChapters, totalUpdated);
|
showToast(newChapters, totalUpdated);
|
||||||
},
|
},
|
||||||
onError() {
|
onError() { refreshing = false; cancelUpdate = null; },
|
||||||
refreshing = false;
|
|
||||||
cancelUpdate = null;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,8 +526,11 @@
|
|||||||
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, y: e.clientY };
|
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">
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.bar { display: flex; align-items: center; justify-content: space-between; height: 36px; padding: 0 6px 0 var(--sp-4); background: transparent; flex-shrink: 0; user-select: none; -webkit-app-region: drag; }
|
.bar { display: flex; align-items: center; justify-content: space-between; height: var(--titlebar-height); padding: 0 6px 0 var(--sp-4); background: transparent; flex-shrink: 0; user-select: none; -webkit-app-region: drag; }
|
||||||
.mac-spacer { width: 70px; flex-shrink: 0; -webkit-app-region: drag; }
|
.mac-spacer { width: 70px; flex-shrink: 0; -webkit-app-region: drag; }
|
||||||
.title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; opacity: 0.5; -webkit-app-region: drag; }
|
.title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; opacity: 0.5; -webkit-app-region: drag; }
|
||||||
.controls { display: flex; align-items: center; gap: 2px; -webkit-app-region: no-drag; }
|
.controls { display: flex; align-items: center; gap: 2px; -webkit-app-region: no-drag; }
|
||||||
@@ -71,4 +71,4 @@
|
|||||||
|
|
||||||
.fullscreen-controls { position: fixed; top: 0; right: 0; z-index: 9999; display: flex; align-items: center; gap: 2px; padding: 4px; opacity: 0; transition: opacity 0.2s ease; -webkit-app-region: no-drag; }
|
.fullscreen-controls { position: fixed; top: 0; right: 0; z-index: 9999; display: flex; align-items: center; gap: 2px; padding: 4px; opacity: 0; transition: opacity 0.2s ease; -webkit-app-region: no-drag; }
|
||||||
.fullscreen-controls:hover { opacity: 1; }
|
.fullscreen-controls:hover { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
separator?: never;
|
separator?: never;
|
||||||
|
children?: MenuEntry[];
|
||||||
}
|
}
|
||||||
export interface MenuSeparator { separator: true }
|
export interface MenuSeparator { separator: true }
|
||||||
export type MenuEntry = MenuItem | MenuSeparator;
|
export type MenuEntry = MenuItem | MenuSeparator;
|
||||||
@@ -19,8 +20,12 @@
|
|||||||
|
|
||||||
let { x, y, items, onClose }: Props = $props();
|
let { x, y, items, onClose }: Props = $props();
|
||||||
|
|
||||||
let focused = $state(-1);
|
let focused = $state(-1);
|
||||||
let el = $state<HTMLDivElement | undefined>(undefined);
|
let el = $state<HTMLDivElement | undefined>(undefined);
|
||||||
|
let measured = $state(false);
|
||||||
|
let pos = $state({ left: x, top: y });
|
||||||
|
let subOpen = $state(-1);
|
||||||
|
let subEls = $state<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
const actionable = $derived(
|
const actionable = $derived(
|
||||||
items
|
items
|
||||||
@@ -30,27 +35,61 @@
|
|||||||
|
|
||||||
$effect(() => { if (actionable.length && focused === -1) focused = actionable[0]; });
|
$effect(() => { if (actionable.length && focused === -1) focused = actionable[0]; });
|
||||||
|
|
||||||
const pos = $derived.by(() => {
|
function getZoom(): number {
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
|
const raw = parseFloat(document.documentElement.style.zoom || "1") || 1;
|
||||||
const menuW = 200, menuH = items.length * 34;
|
return raw > 10 ? raw / 100 : raw;
|
||||||
const vw = window.innerWidth / zoom, vh = window.innerHeight / zoom;
|
}
|
||||||
const sx = x / zoom, sy = y / zoom;
|
|
||||||
return {
|
$effect(() => {
|
||||||
|
if (!el) return;
|
||||||
|
const zoom = getZoom();
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const sidebarW = parseFloat(style.getPropertyValue('--sidebar-width')) || 52;
|
||||||
|
const titlebarH = parseFloat(style.getPropertyValue('--titlebar-height')) || 36;
|
||||||
|
const vw = window.innerWidth / zoom;
|
||||||
|
const vh = window.innerHeight / zoom;
|
||||||
|
const sx = x / zoom - sidebarW / zoom;
|
||||||
|
const sy = y / zoom - titlebarH / zoom;
|
||||||
|
const menuW = el.offsetWidth;
|
||||||
|
const menuH = el.offsetHeight;
|
||||||
|
pos = {
|
||||||
left: Math.max(4, sx + menuW > vw ? sx - menuW : sx),
|
left: Math.max(4, sx + menuW > vw ? sx - menuW : sx),
|
||||||
top: Math.max(4, sy + menuH > vh ? sy - menuH : sy),
|
top: Math.max(4, sy + menuH > vh ? sy - menuH : sy),
|
||||||
};
|
};
|
||||||
|
measured = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (subOpen < 0) return;
|
||||||
|
const sub = subEls[subOpen];
|
||||||
|
if (!sub) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const zoom = getZoom();
|
||||||
|
const vw = window.innerWidth / zoom;
|
||||||
|
const rect = sub.getBoundingClientRect();
|
||||||
|
if (rect.right / zoom > vw) sub.classList.add("sub-flip");
|
||||||
|
else sub.classList.remove("sub-flip");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function onMouseDown(e: MouseEvent) {
|
function onMouseDown(e: MouseEvent) {
|
||||||
if (el && !el.contains(e.target as Node)) onClose();
|
const inMain = el?.contains(e.target as Node);
|
||||||
|
const inSub = subOpen >= 0 && subEls[subOpen]?.contains(e.target as Node);
|
||||||
|
if (!inMain && !inSub) onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchStartOutside(e: TouchEvent) {
|
function onTouchStartOutside(e: TouchEvent) {
|
||||||
if (el && !el.contains(e.target as Node)) onClose();
|
const inMain = el?.contains(e.target as Node);
|
||||||
|
const inSub = subOpen >= 0 && subEls[subOpen]?.contains(e.target as Node);
|
||||||
|
if (!inMain && !inSub) onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (subOpen >= 0) { subOpen = -1; } else { onClose(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const cur = actionable.indexOf(focused);
|
const cur = actionable.indexOf(focused);
|
||||||
@@ -63,48 +102,87 @@
|
|||||||
focused = actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
focused = actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e.key === "ArrowRight" && focused >= 0) {
|
||||||
|
const item = items[focused] as MenuItem;
|
||||||
|
if (item?.children?.length) { subOpen = focused; return; }
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowLeft") { subOpen = -1; return; }
|
||||||
if (e.key === "Enter" && focused >= 0) {
|
if (e.key === "Enter" && focused >= 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = items[focused] as MenuItem;
|
const item = items[focused] as MenuItem;
|
||||||
|
if (item?.children?.length) { subOpen = focused; return; }
|
||||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
if (item && !item.disabled) { item.onClick(); onClose(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
document.addEventListener("mousedown", onMouseDown, true);
|
document.addEventListener("mousedown", onMouseDown, true);
|
||||||
document.addEventListener("touchstart", onTouchStartOutside, true);
|
document.addEventListener("touchstart", onTouchStartOutside, true);
|
||||||
document.addEventListener("keydown", onKey, true);
|
document.addEventListener("keydown", onKey, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", onMouseDown, true);
|
document.removeEventListener("mousedown", onMouseDown, true);
|
||||||
document.removeEventListener("touchstart", onTouchStartOutside, true);
|
document.removeEventListener("touchstart", onTouchStartOutside, true);
|
||||||
document.removeEventListener("keydown", onKey, true);
|
document.removeEventListener("keydown", onKey, true);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={el} class="menu" role="menu" tabindex="-1"
|
<div bind:this={el} class="menu" role="menu" tabindex="-1"
|
||||||
style="left:{pos.left}px;top:{pos.top}px"
|
style="left:{pos.left}px;top:{pos.top}px;visibility:{measured ? 'visible' : 'hidden'}"
|
||||||
oncontextmenu={(e) => e.preventDefault()}>
|
oncontextmenu={(e) => e.preventDefault()}>
|
||||||
{#each items as item, i}
|
{#each items as item, i}
|
||||||
{#if "separator" in item}
|
{#if "separator" in item}
|
||||||
<div class="sep"></div>
|
<div class="sep"></div>
|
||||||
{:else}
|
{:else}
|
||||||
{@const mi = item as MenuItem}
|
{@const mi = item as MenuItem}
|
||||||
<button
|
{@const hasSub = !!mi.children?.length}
|
||||||
class="item"
|
<div class="item-wrap">
|
||||||
class:danger={mi.danger}
|
<button
|
||||||
class:disabled={mi.disabled}
|
class="item"
|
||||||
class:focused={focused === i}
|
class:danger={mi.danger}
|
||||||
disabled={mi.disabled}
|
class:disabled={mi.disabled}
|
||||||
onclick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
class:focused={focused === i}
|
||||||
onmouseenter={() => { if (!mi.disabled) focused = i; }}
|
class:has-sub={hasSub}
|
||||||
onmouseleave={() => focused = -1}
|
disabled={mi.disabled}
|
||||||
>
|
onclick={() => {
|
||||||
<span class="icon" class:icon-danger={mi.danger}>
|
if (mi.disabled) return;
|
||||||
{#if mi.icon}<mi.icon size={13} weight="light" />{/if}
|
if (hasSub) { subOpen = subOpen === i ? -1 : i; return; }
|
||||||
</span>
|
mi.onClick(); onClose();
|
||||||
<span class="label">{mi.label}</span>
|
}}
|
||||||
</button>
|
onmouseenter={() => { if (!mi.disabled) { focused = i; subOpen = hasSub ? i : -1; } }}
|
||||||
|
onmouseleave={() => { focused = -1; }}
|
||||||
|
>
|
||||||
|
<span class="icon" class:icon-danger={mi.danger}>
|
||||||
|
{#if mi.icon}<mi.icon size={13} weight="light" />{/if}
|
||||||
|
</span>
|
||||||
|
<span class="label">{mi.label}</span>
|
||||||
|
{#if hasSub}<span class="sub-arrow">›</span>{/if}
|
||||||
|
</button>
|
||||||
|
{#if hasSub && subOpen === i}
|
||||||
|
<div bind:this={subEls[i]} class="menu submenu" role="menu"
|
||||||
|
onmouseenter={() => { subOpen = i; }}>
|
||||||
|
{#each mi.children as child}
|
||||||
|
{#if "separator" in child}
|
||||||
|
<div class="sep"></div>
|
||||||
|
{:else}
|
||||||
|
{@const sc = child as MenuItem}
|
||||||
|
<button
|
||||||
|
class="item"
|
||||||
|
class:danger={sc.danger}
|
||||||
|
class:disabled={sc.disabled}
|
||||||
|
disabled={sc.disabled}
|
||||||
|
onclick={() => { if (!sc.disabled) { sc.onClick(); onClose(); } }}
|
||||||
|
>
|
||||||
|
<span class="icon" class:icon-danger={sc.danger}>
|
||||||
|
{#if sc.icon}<sc.icon size={13} weight="light" />{/if}
|
||||||
|
</span>
|
||||||
|
<span class="label">{sc.label}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,12 +195,27 @@
|
|||||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.35), 0 16px 40px rgba(0,0,0,0.25);
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.35), 0 16px 40px rgba(0,0,0,0.25);
|
||||||
animation: scaleIn 0.1s ease both; transform-origin: top left;
|
animation: scaleIn 0.1s ease both; transform-origin: top left;
|
||||||
}
|
}
|
||||||
|
.item-wrap { position: relative; }
|
||||||
|
.submenu {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
z-index: 201;
|
||||||
|
animation: scaleIn 0.08s ease both;
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
.submenu.sub-flip {
|
||||||
|
left: auto;
|
||||||
|
right: 100%;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
.item {
|
.item {
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
width: 100%; padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
width: 100%; padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
||||||
font-size: var(--text-sm); color: var(--text-secondary);
|
font-size: var(--text-sm); color: var(--text-secondary);
|
||||||
text-align: left; cursor: pointer; background: none; border: none; outline: none;
|
text-align: left; cursor: pointer; background: none; border: none; outline: none;
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.item:hover:not(.disabled), .item.focused:not(.disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
.item:hover:not(.disabled), .item.focused:not(.disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
.item.danger { color: var(--color-error); }
|
.item.danger { color: var(--color-error); }
|
||||||
@@ -131,6 +224,7 @@
|
|||||||
.icon { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0; color: var(--text-faint); border-radius: var(--radius-sm); }
|
.icon { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0; color: var(--text-faint); border-radius: var(--radius-sm); }
|
||||||
.icon-danger { color: var(--color-error); opacity: 0.7; }
|
.icon-danger { color: var(--color-error); opacity: 0.7; }
|
||||||
.label { flex: 1; line-height: 1.3; }
|
.label { flex: 1; line-height: 1.3; }
|
||||||
|
.sub-arrow { font-size: 14px; color: var(--text-faint); line-height: 1; margin-left: auto; padding-left: var(--sp-1); }
|
||||||
.sep { height: 1px; background: var(--border-dim); margin: 3px var(--sp-1); }
|
.sep { height: 1px; background: var(--border-dim); margin: 3px var(--sp-1); }
|
||||||
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function mergeSettings(saved: any): Settings {
|
|||||||
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
|
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
|
||||||
readerPresets: saved?.settings?.readerPresets ?? [],
|
readerPresets: saved?.settings?.readerPresets ?? [],
|
||||||
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
||||||
|
categoryFrecency: saved?.settings?.categoryFrecency ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +318,11 @@ class Store {
|
|||||||
this.settings = { ...this.settings, mangaReaderSettings: next };
|
this.settings = { ...this.settings, mangaReaderSettings: next };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bumpCategoryFrecency(catId: number) {
|
||||||
|
const prev = this.settings.categoryFrecency ?? {};
|
||||||
|
this.settings = { ...this.settings, categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } };
|
||||||
|
}
|
||||||
|
|
||||||
setCategories(cats: Category[]) { this.categories = cats; }
|
setCategories(cats: Category[]) { this.categories = cats; }
|
||||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||||
@@ -355,6 +361,7 @@ export function updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset,
|
|||||||
export function deleteReaderPreset(id: string) { store.deleteReaderPreset(id); }
|
export function deleteReaderPreset(id: string) { store.deleteReaderPreset(id); }
|
||||||
export function setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { store.setMangaReaderSettings(mangaId, settings); }
|
export function setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { store.setMangaReaderSettings(mangaId, settings); }
|
||||||
export function clearMangaReaderSettings(mangaId: number) { store.clearMangaReaderSettings(mangaId); }
|
export function clearMangaReaderSettings(mangaId: number) { store.clearMangaReaderSettings(mangaId); }
|
||||||
|
export function bumpCategoryFrecency(catId: number) { store.bumpCategoryFrecency(catId); }
|
||||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||||
export function resetKeybinds() { store.resetKeybinds(); }
|
export function resetKeybinds() { store.resetKeybinds(); }
|
||||||
export function clearSearchCache() { store.clearSearchCache(); }
|
export function clearSearchCache() { store.clearSearchCache(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user