Feat: Implement CSS for Chrome & Link to Context-Menu

This commit is contained in:
Youwes09
2026-04-29 18:18:21 -05:00
parent 170493aa71
commit dd0cf9372d
6 changed files with 206 additions and 62 deletions
+13 -7
View File
@@ -7,10 +7,7 @@ Major Revisions:
Minor Revisions:
- 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)
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
Priority Bugs:
- Fix Library-Refresh System (TESTING)
@@ -24,6 +21,10 @@ Pending/On-Hold:
- Working on 3D Display Cards
- Add Flathub Support (Pending Video)
- Change Auto-Link Threshold
- Fix Auto-Link De-dupe for Images
- Optimize Auto-Link Latency (IP)
In-Progress:
- Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
@@ -31,14 +32,19 @@ In-Progress:
- Tracking
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
- Refactor Content-Filters, Change Source-Enabling (More Intuitive)
- 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:
- Fixed Search Issue
- Create Z-Index for Library Statistics
- Storage has been configured, now just need protocols
- Export/Import
- Migration
- Data-Clean
+3 -2
View File
@@ -8,5 +8,6 @@
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
}
--sidebar-width: 52px;
--titlebar-height: 36px;
}
+56 -20
View File
@@ -19,7 +19,7 @@
} from "../store/libraryState.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
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 LibraryGrid from "./LibraryGrid.svelte";
@@ -32,6 +32,7 @@
const CARD_MIN_W = 130;
const CARD_GAP = 16;
const COMPLETED_NAME = "Completed";
const CTX_FOLDER_CAP = 4;
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
@@ -68,11 +69,10 @@
let dragTabId: number | null = $state(null);
let dragOverTabId: number | null = $state(null);
const DT_TAB = "application/x-moku-tab";
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 tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir);
const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter);
@@ -198,14 +198,33 @@
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
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) {
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 onCardPointerLeave() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
function onCardClick(e: MouseEvent, m: Manga) {
if (longPressFired) { longPressFired = false; return; }
if (selectMode) { toggleSelect(m.id); return; }
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
store.activeManga = m;
@@ -271,6 +290,11 @@
} 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) {
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
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];
return { ...c, mangas: { nodes } };
}));
if (!inCat) bumpCategoryFrecency(cat.id);
try {
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
if (!inCat && !manga.inLibrary) {
@@ -296,6 +321,7 @@
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
const cat = res.createCategory.category;
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
bumpCategoryFrecency(cat.id);
if (!manga.inLibrary) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
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 }); }
}
const SIDEBAR_W = 52;
const TITLEBAR_H = 36;
function openCtx(e: MouseEvent, m: Manga) {
if (selectMode) { toggleSelect(m.id); return; }
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[] {
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);
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 [
{ 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: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
{ separator: true },
{ label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
...(catEntries.length ? [{ separator: true } as MenuEntry, ...catEntries] : []),
{ label: "Select", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
...(pinnedEntries.length ? [{ separator: true } as MenuEntry, ...pinnedEntries] : []),
...(overflowChildren.length ? [{ label: `More folders (${overflowChildren.length})`, icon: FolderSimple, onClick: () => {}, children: overflowChildren } as MenuEntry] : []),
{ separator: true },
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
];
@@ -393,12 +432,9 @@
refreshProgress = { finished: 0, total: 0 };
cancelUpdate = startLibraryUpdate({
onProgress(p) {
refreshProgress = p;
},
onProgress(p) { refreshProgress = p; },
async onDone({ entries, totalUpdated, newChapters }) {
refreshing = false;
cancelUpdate = null;
refreshing = false; cancelUpdate = null;
setLibraryUpdates(entries);
cache.clearGroup(CACHE_GROUPS.LIBRARY);
await loadData();
@@ -407,10 +443,7 @@
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
showToast(newChapters, totalUpdated);
},
onError() {
refreshing = false;
cancelUpdate = null;
},
onError() { refreshing = false; cancelUpdate = null; },
});
}
@@ -493,8 +526,11 @@
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("button")) return;
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}
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
+2 -2
View File
@@ -60,7 +60,7 @@
{/if}
<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; }
.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; }
@@ -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:hover { opacity: 1; }
</style>
</style>
+125 -31
View File
@@ -6,6 +6,7 @@
danger?: boolean;
disabled?: boolean;
separator?: never;
children?: MenuEntry[];
}
export interface MenuSeparator { separator: true }
export type MenuEntry = MenuItem | MenuSeparator;
@@ -19,8 +20,12 @@
let { x, y, items, onClose }: Props = $props();
let focused = $state(-1);
let el = $state<HTMLDivElement | undefined>(undefined);
let focused = $state(-1);
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(
items
@@ -30,27 +35,61 @@
$effect(() => { if (actionable.length && focused === -1) focused = actionable[0]; });
const pos = $derived.by(() => {
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
const menuW = 200, menuH = items.length * 34;
const vw = window.innerWidth / zoom, vh = window.innerHeight / zoom;
const sx = x / zoom, sy = y / zoom;
return {
function getZoom(): number {
const raw = parseFloat(document.documentElement.style.zoom || "1") || 1;
return raw > 10 ? raw / 100 : raw;
}
$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),
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) {
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) {
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) {
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") {
e.preventDefault();
const cur = actionable.indexOf(focused);
@@ -63,48 +102,87 @@
focused = actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
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) {
e.preventDefault();
const item = items[focused] as MenuItem;
if (item?.children?.length) { subOpen = focused; return; }
if (item && !item.disabled) { item.onClick(); onClose(); }
}
}
$effect(() => {
document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("touchstart", onTouchStartOutside, true);
document.addEventListener("keydown", onKey, true);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("touchstart", onTouchStartOutside, true);
document.removeEventListener("keydown", onKey, true);
document.removeEventListener("keydown", onKey, true);
};
});
</script>
<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()}>
{#each items as item, i}
{#if "separator" in item}
<div class="sep"></div>
{:else}
{@const mi = item as MenuItem}
<button
class="item"
class:danger={mi.danger}
class:disabled={mi.disabled}
class:focused={focused === i}
disabled={mi.disabled}
onclick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
onmouseenter={() => { if (!mi.disabled) focused = i; }}
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>
</button>
{@const hasSub = !!mi.children?.length}
<div class="item-wrap">
<button
class="item"
class:danger={mi.danger}
class:disabled={mi.disabled}
class:focused={focused === i}
class:has-sub={hasSub}
disabled={mi.disabled}
onclick={() => {
if (mi.disabled) return;
if (hasSub) { subOpen = subOpen === i ? -1 : i; return; }
mi.onClick(); onClose();
}}
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}
{/each}
</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);
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 {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 5px var(--sp-2); border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--text-secondary);
text-align: left; cursor: pointer; background: none; border: none; outline: none;
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.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-danger { color: var(--color-error); opacity: 0.7; }
.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); }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
+7
View File
@@ -48,6 +48,7 @@ function mergeSettings(saved: any): Settings {
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
readerPresets: saved?.settings?.readerPresets ?? [],
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
categoryFrecency: saved?.settings?.categoryFrecency ?? {},
};
}
@@ -317,6 +318,11 @@ class Store {
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; }
setActiveManga(next: Manga | null) { this.activeManga = 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 setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { store.setMangaReaderSettings(mangaId, settings); }
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 resetKeybinds() { store.resetKeybinds(); }
export function clearSearchCache() { store.clearSearchCache(); }