646 lines
24 KiB
Svelte
646 lines
24 KiB
Svelte
<script>
|
|
import { tick } from "svelte";
|
|
import { app } from "/scripts/app.js";
|
|
import justifiedLayout from "../../lib/justified-layout.js";
|
|
import { zoomGalleryRowHeight } from "../../lib/gallery-zoom.js";
|
|
import GalleryContextMenu from "./GalleryContextMenu.svelte";
|
|
import ConfirmModal from "../sidebar/ConfirmModal.svelte";
|
|
|
|
let {
|
|
images = [], workflowId = "",
|
|
viewMode = "justified", rowHeight = 120,
|
|
apiURL = (p) => p, fetchApi = null, toast = null,
|
|
onOpenViewer, onViewModeChange, onRowHeightChange,
|
|
onClearHistory, onCountChange, onDeleteImages, onDeleteFiles,
|
|
} = $props();
|
|
|
|
// --- selection ---
|
|
let selected = $state(new Set());
|
|
let anchor = $state(null);
|
|
|
|
// which hashes carry a saved layer stack (sidecar) → a "layers" badge on the
|
|
// card. hash → layer count. Cheap one-shot fetch, refreshed on the same event
|
|
// the viewer fires after an edit-save.
|
|
let editDocLayers = $state({});
|
|
function refreshEditDocs() {
|
|
if (!fetchApi) return;
|
|
fetchApi("/promptchain/edit-docs")
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((d) => { editDocLayers = d?.docs || {}; })
|
|
.catch(() => {});
|
|
}
|
|
$effect(() => {
|
|
if (fetchApi) refreshEditDocs();
|
|
const onSaved = () => refreshEditDocs();
|
|
window.addEventListener("promptchain:edit-docs-changed", onSaved);
|
|
return () => window.removeEventListener("promptchain:edit-docs-changed", onSaved);
|
|
});
|
|
|
|
let allVisible = $derived(images.filter(i => !i.orphaned));
|
|
|
|
// --- lineage view ---
|
|
// One card per chain TIP: upscales/inpaints/edits collapse into the latest
|
|
// version of THEIR branch. Different generations off the same source are
|
|
// separate works — they split into separate cards instead of burying each
|
|
// other under one root-keyed family. Toggled in ComfyUI settings →
|
|
// PromptChain → Gallery; the setting's onChange broadcasts the event below.
|
|
let lineageView = $state(app?.ui?.settings?.getSettingValue?.("PromptChain.LineageView") ?? true);
|
|
|
|
$effect(() => {
|
|
const onToggle = (e) => { lineageView = e.detail?.value !== false; };
|
|
window.addEventListener("promptchain:lineage-view-changed", onToggle);
|
|
return () => window.removeEventListener("promptchain:lineage-view-changed", onToggle);
|
|
});
|
|
|
|
// An image is absorbed only when something VISIBLE descends from it — the
|
|
// climb runs through filtered-out/deleted intermediates (parentOf covers
|
|
// the full workflow list), so a linear chain still collapses to one card
|
|
// while branches off a shared source each keep their own tip. parent_hash
|
|
// itself stays the full truthful tree; this is purely view grouping.
|
|
let lineageFamilies = $derived((() => {
|
|
if (!lineageView) return null;
|
|
const parentOf = new Map(images.map(i => [i.hash, i.parent_hash || null]));
|
|
const ancestorsOf = (hash) => {
|
|
const chain = [];
|
|
const seen = new Set([hash]); // cycle guard
|
|
let cur = parentOf.get(hash);
|
|
while (cur && !seen.has(cur)) {
|
|
chain.push(cur);
|
|
seen.add(cur);
|
|
cur = parentOf.get(cur);
|
|
}
|
|
return chain;
|
|
};
|
|
const absorbed = new Set();
|
|
for (const img of allVisible) {
|
|
for (const h of ancestorsOf(img.hash)) absorbed.add(h);
|
|
}
|
|
|
|
// images arrive created_at DESC, so the surviving tips are already in
|
|
// order — a new generation fronts ITS branch only. The count badge reads
|
|
// "versions in this chain"; a shared ancestor counts in every branch
|
|
// that descends from it.
|
|
const visible = new Set(allVisible.map((i) => i.hash));
|
|
const reps = [];
|
|
const counts = new Map(); // rep hash → chain member count
|
|
for (const img of allVisible) {
|
|
if (absorbed.has(img.hash)) continue;
|
|
reps.push(img);
|
|
counts.set(img.hash, 1 + ancestorsOf(img.hash).filter((h) => visible.has(h)).length);
|
|
}
|
|
return { reps, counts };
|
|
})());
|
|
|
|
// Reps keep their identity from `images`, so openInViewer's indexOf and all
|
|
// selection/keyboard logic work identically in both modes.
|
|
let visibleImages = $derived(lineageFamilies ? lineageFamilies.reps : allVisible);
|
|
let versionCounts = $derived(lineageFamilies?.counts ?? null);
|
|
|
|
function versionsOf(img) {
|
|
return versionCounts?.get(img.hash) ?? 0;
|
|
}
|
|
|
|
// Track the cursor by hash, not index, so filtering/orphan-removal
|
|
// can't leave it pointing past the end of visibleImages. cursorIdx
|
|
// is derived and becomes -1 when the cursor's image disappears.
|
|
let cursorHash = $state(null);
|
|
let cursorIdx = $derived(cursorHash ? visibleImages.findIndex(i => i.hash === cursorHash) : -1);
|
|
|
|
function selectItem(hash) { selected = new Set([hash]); anchor = hash; }
|
|
function toggleItem(hash) {
|
|
const next = new Set(selected);
|
|
next.has(hash) ? next.delete(hash) : next.add(hash);
|
|
selected = next;
|
|
if (next.has(hash)) anchor = hash;
|
|
}
|
|
function clearSelection() { selected = new Set(); anchor = null; cursorHash = null; }
|
|
|
|
// Read only .length so onCountChange doesn't fire on every item
|
|
// identity change — only when the visible count actually moves.
|
|
$effect(() => {
|
|
const n = visibleImages.length;
|
|
onCountChange?.(n);
|
|
});
|
|
|
|
// --- context menu ---
|
|
let ctxMenu = $state(null);
|
|
|
|
// --- delete confirmation ---
|
|
let confirmOpen = $state(false);
|
|
let confirmMsg = $state("");
|
|
let confirmHashes = [];
|
|
|
|
function requestDelete(hashes) {
|
|
if (!hashes.length) return;
|
|
confirmHashes = hashes;
|
|
if (hashes.length === 1) {
|
|
const img = visibleImages.find(i => i.hash === hashes[0]);
|
|
const name = img?.filename ?? "image";
|
|
confirmMsg = `Delete "${name}"?`;
|
|
} else {
|
|
confirmMsg = `Delete ${hashes.length} items?`;
|
|
}
|
|
confirmOpen = true;
|
|
}
|
|
|
|
function executeDelete() {
|
|
confirmOpen = false;
|
|
const hashes = confirmHashes;
|
|
confirmHashes = [];
|
|
if (hashes.length) {
|
|
onDeleteFiles?.(hashes);
|
|
clearSelection();
|
|
}
|
|
galleryEl?.focus();
|
|
}
|
|
|
|
function cancelDelete() {
|
|
confirmOpen = false;
|
|
confirmHashes = [];
|
|
galleryEl?.focus();
|
|
}
|
|
|
|
let suppressClick = false;
|
|
|
|
function handleItemClick(img, idx, event) {
|
|
if (suppressClick) { suppressClick = false; return; }
|
|
galleryEl?.focus();
|
|
cursorHash = img.hash;
|
|
if (event.ctrlKey || event.metaKey) { toggleItem(img.hash); return; }
|
|
if (event.shiftKey && anchor) {
|
|
const ai = visibleImages.findIndex(i => i.hash === anchor);
|
|
if (ai >= 0) {
|
|
const lo = Math.min(ai, idx), hi = Math.max(ai, idx);
|
|
selected = new Set(visibleImages.slice(lo, hi + 1).map(i => i.hash));
|
|
return;
|
|
}
|
|
}
|
|
clearSelection();
|
|
openInViewer(img);
|
|
}
|
|
|
|
function handleItemDblClick(img) {
|
|
openInViewer(img);
|
|
}
|
|
|
|
// The viewer's timeline (left/right) walks whatever array it receives, so
|
|
// hand it the same list the gallery shows: family reps in lineage view
|
|
// (older versions stay reachable via up/down), the full images array
|
|
// otherwise. Either way the start index must come from the array passed —
|
|
// gallery indexes diverge from it whenever orphaned records exist.
|
|
function openInViewer(img) {
|
|
const list = lineageFamilies ? visibleImages : images;
|
|
const idx = list.indexOf(img);
|
|
onOpenViewer?.(list, idx >= 0 ? idx : 0, workflowId);
|
|
}
|
|
|
|
function handleItemContext(img, idx, event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
galleryEl?.focus();
|
|
cursorHash = img.hash;
|
|
if (!selected.has(img.hash)) selectItem(img.hash);
|
|
ctxMenu = { x: event.clientX, y: event.clientY, item: img };
|
|
}
|
|
|
|
function handleBgContext(event) {
|
|
event.preventDefault();
|
|
clearSelection();
|
|
ctxMenu = { x: event.clientX, y: event.clientY, item: null };
|
|
}
|
|
|
|
function handleBgClick(event) {
|
|
if (!event.target.closest("[data-hash]")) clearSelection();
|
|
galleryEl?.focus();
|
|
}
|
|
|
|
function getSelectedHashes() {
|
|
const hashes = [...selected];
|
|
if (!hashes.length && ctxMenu?.item) hashes.push(ctxMenu.item.hash);
|
|
return hashes;
|
|
}
|
|
|
|
async function handleCtxAction(action) {
|
|
if (action === "open") {
|
|
const img = ctxMenu?.item;
|
|
if (img) openInViewer(img);
|
|
} else if (action === "detach") {
|
|
const hashes = getSelectedHashes();
|
|
if (hashes.length) { onDeleteImages?.(hashes); clearSelection(); }
|
|
} else if (action === "delete") {
|
|
const hashes = getSelectedHashes();
|
|
if (hashes.length) { onDeleteFiles?.(hashes); clearSelection(); }
|
|
} else if (action === "refresh") {
|
|
onClearHistory?.("refresh");
|
|
} else if (action === "clear") {
|
|
onClearHistory?.("clear");
|
|
} else if (action.startsWith("view:")) {
|
|
onViewModeChange?.(action.slice(5));
|
|
}
|
|
}
|
|
|
|
// --- keyboard ---
|
|
let galleryEl;
|
|
|
|
function handleKeydown(event) {
|
|
|
|
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key) && visibleImages.length > 0) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const cols = viewMode === "list" ? 1 : getColumns();
|
|
const curIdx = cursorIdx;
|
|
let next = curIdx;
|
|
if (curIdx < 0) { next = 0; }
|
|
else if (event.key === "ArrowDown") next = curIdx + cols;
|
|
else if (event.key === "ArrowUp") next = curIdx - cols;
|
|
else if (event.key === "ArrowRight") next = curIdx + 1;
|
|
else next = curIdx - 1;
|
|
next = Math.max(0, Math.min(next, visibleImages.length - 1));
|
|
const img = visibleImages[next];
|
|
cursorHash = img.hash;
|
|
if (event.shiftKey) {
|
|
if (!anchor) anchor = img.hash;
|
|
const ai = visibleImages.findIndex(i => i.hash === anchor);
|
|
const lo = Math.min(ai, next), hi = Math.max(ai, next);
|
|
selected = new Set(visibleImages.slice(lo, hi + 1).map(i => i.hash));
|
|
} else if (!event.ctrlKey) {
|
|
selectItem(img.hash);
|
|
}
|
|
scrollIntoView(img.hash);
|
|
}
|
|
if (event.key === " " && cursorIdx >= 0) {
|
|
event.preventDefault();
|
|
toggleItem(visibleImages[cursorIdx].hash);
|
|
}
|
|
if (event.key === "Enter" && cursorIdx >= 0) {
|
|
openInViewer(visibleImages[cursorIdx]);
|
|
}
|
|
if (event.key === "Escape") {
|
|
if (ctxMenu) ctxMenu = null;
|
|
else clearSelection();
|
|
}
|
|
if (event.key === "Delete" && selected.size > 0) {
|
|
event.preventDefault();
|
|
requestDelete([...selected]);
|
|
}
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "a") {
|
|
event.preventDefault();
|
|
selected = new Set(visibleImages.map(i => i.hash));
|
|
}
|
|
}
|
|
|
|
function getColumns() {
|
|
if (!galleryEl) return 1;
|
|
if (viewMode === "grid") {
|
|
const grid = galleryEl.querySelector(".pcr-gal-grid");
|
|
if (grid) return getComputedStyle(grid).gridTemplateColumns.split(" ").length;
|
|
}
|
|
const items = galleryEl.querySelectorAll("[data-hash]");
|
|
if (items.length < 2) return 1;
|
|
const firstTop = items[0].getBoundingClientRect().top;
|
|
let cols = 1;
|
|
for (let i = 1; i < items.length; i++) {
|
|
if (Math.abs(items[i].getBoundingClientRect().top - firstTop) < 5) cols++;
|
|
else break;
|
|
}
|
|
return cols;
|
|
}
|
|
|
|
function scrollIntoView(hash) {
|
|
galleryEl?.querySelector(`[data-hash="${CSS.escape(hash)}"]`)?.scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
// --- zoom ---
|
|
$effect(() => {
|
|
if (!galleryEl) return;
|
|
function onWheel(e) {
|
|
if (!e.ctrlKey && !e.metaKey) return;
|
|
e.preventDefault();
|
|
// In node mode the gallery sits inside a LiteGraph node — without
|
|
// stopPropagation, ComfyUI's canvas-level Ctrl+wheel zoom also
|
|
// fires, swallowing our step and making it feel like clicks don't
|
|
// take. Fullscreen mode doesn't hit this because nothing's above us.
|
|
e.stopPropagation();
|
|
const newH = zoomGalleryRowHeight(rowHeight, e.deltaY > 0 ? -1 : 1);
|
|
if (newH !== rowHeight) onRowHeightChange?.(newH);
|
|
}
|
|
galleryEl.addEventListener("wheel", onWheel, { passive: false });
|
|
return () => galleryEl.removeEventListener("wheel", onWheel);
|
|
});
|
|
|
|
// --- justified layout ---
|
|
// $state so effects re-run when the justified branch (re)binds it. The
|
|
// gallery mounts with images=[] on first open (fetch pending), so the
|
|
// branch — and this binding — only appear after the effects' first run;
|
|
// a plain let left them permanently without a ResizeObserver.
|
|
let wrapEl = $state(null);
|
|
let layoutBoxes = $state([]);
|
|
let layoutHeight = $state(0);
|
|
|
|
function reLayout() {
|
|
if (viewMode !== "justified" || !wrapEl) { layoutBoxes = []; layoutHeight = 0; return; }
|
|
const containerWidth = wrapEl.clientWidth;
|
|
if (containerWidth <= 0) return;
|
|
const aspects = visibleImages.map(i =>
|
|
(i.width && i.height) ? i.width / i.height : 1
|
|
);
|
|
const result = justifiedLayout(aspects, {
|
|
containerWidth,
|
|
targetRowHeight: rowHeight,
|
|
// Row heights only move when the row partitioning changes, so the
|
|
// default 0.25 tolerance swallows several zoom steps in a row at
|
|
// larger sizes. 0.1 keeps every ~15% rowHeight step visible.
|
|
targetRowHeightTolerance: 0.1,
|
|
boxSpacing: 4,
|
|
containerPadding: 4,
|
|
});
|
|
layoutBoxes = result.boxes || [];
|
|
layoutHeight = result.containerHeight || 0;
|
|
}
|
|
|
|
$effect(() => {
|
|
// visibleImages by identity, not length — a refresh can swap images
|
|
// (new aspect ratios) without changing the count.
|
|
const _ = [viewMode, rowHeight, visibleImages, wrapEl];
|
|
// Guard against the component unmounting between tick() and reLayout —
|
|
// wrapEl would be null and accessing .clientWidth inside reLayout would
|
|
// throw on a detached node.
|
|
tick().then(() => { if (wrapEl) reLayout(); });
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!wrapEl) return;
|
|
// Heals the load-time race where reLayout ran against a mid-settle
|
|
// container width (LiteGraph node DOM is still sizing): the observer
|
|
// fires as the width settles and re-layouts with the real value.
|
|
let lastW = 0;
|
|
const ro = new ResizeObserver(() => {
|
|
const w = wrapEl.offsetWidth;
|
|
if (w > 0 && w !== lastW) { lastW = w; reLayout(); }
|
|
});
|
|
ro.observe(wrapEl);
|
|
return () => ro.disconnect();
|
|
});
|
|
|
|
function thumbUrl(hash) { return apiURL(`/promptchain/thumb/${hash}`); }
|
|
// A thumbnail <img> can latch broken if its request raced the record (the
|
|
// thumb is generated server-side as part of recording). A broken <img> never
|
|
// retries itself, so cache-bust and re-fetch on the natural error event —
|
|
// bounded, event-driven, no polling.
|
|
function retryThumb(e) {
|
|
const img = e.currentTarget;
|
|
const n = +img.dataset.pcrRetry || 0;
|
|
if (n >= 3) return;
|
|
img.dataset.pcrRetry = String(n + 1);
|
|
const u = new URL(img.src, location.href);
|
|
u.searchParams.set("r", String(n + 1));
|
|
img.src = u.toString();
|
|
}
|
|
|
|
// A thumb that exhausted its retries while the record was still committing
|
|
// stays broken forever. The authoritative "record committed" signal is the
|
|
// generation-recorded event (fired AFTER recordGeneration's awaited POST, so
|
|
// the thumb is provably on disk by now) — re-fetch any latched-broken <img>
|
|
// when it lands. Event-driven recovery, no timers.
|
|
function healBrokenThumbs() {
|
|
if (!galleryEl) return;
|
|
for (const img of galleryEl.querySelectorAll("img")) {
|
|
if (img.complete && img.naturalWidth === 0) {
|
|
img.dataset.pcrRetry = "0";
|
|
const u = new URL(img.src, location.href);
|
|
u.searchParams.set("r", "heal");
|
|
img.src = u.toString();
|
|
}
|
|
}
|
|
}
|
|
$effect(() => {
|
|
const onRecorded = () => healBrokenThumbs();
|
|
window.addEventListener("promptchain:generation-recorded", onRecorded);
|
|
return () => window.removeEventListener("promptchain:generation-recorded", onRecorded);
|
|
});
|
|
</script>
|
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="pcr-gal" bind:this={galleryEl} tabindex="-1"
|
|
onclick={handleBgClick}
|
|
oncontextmenu={handleBgContext}
|
|
onkeydown={handleKeydown}
|
|
>
|
|
{#if visibleImages.length === 0}
|
|
<div class="pcr-gal-empty">No images yet</div>
|
|
{:else if viewMode === "list"}
|
|
<div class="pcr-gal-list">
|
|
{#each visibleImages as img, idx (img.hash)}
|
|
{@const sel = selected.has(img.hash)}
|
|
{@const cur = cursorIdx === idx}
|
|
<button
|
|
class="pcr-gal-lrow"
|
|
class:selected={sel}
|
|
class:focused={cur}
|
|
data-hash={img.hash}
|
|
onclick={(e) => handleItemClick(img, idx, e)}
|
|
ondblclick={() => handleItemDblClick(img)}
|
|
oncontextmenu={(e) => handleItemContext(img, idx, e)}
|
|
>
|
|
<img class="pcr-gal-lthumb" src={thumbUrl(img.hash)} alt="" loading="lazy" onerror={retryThumb} />
|
|
<span class="pcr-gal-lname">{img.filename}</span>
|
|
{#if editDocLayers[img.hash]}
|
|
<span class="pcr-gal-lvers" title="opens layered in Edit">▦ {editDocLayers[img.hash]} layers</span>
|
|
{/if}
|
|
{#if versionsOf(img) > 1}
|
|
<span class="pcr-gal-lvers">{versionsOf(img)} versions</span>
|
|
{/if}
|
|
<span class="pcr-gal-lmeta">{img.width}×{img.height}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{:else if viewMode === "grid"}
|
|
<div class="pcr-gal-grid" style="--gal-size:{rowHeight}px;">
|
|
{#each visibleImages as img, idx (img.hash)}
|
|
{@const sel = selected.has(img.hash)}
|
|
{@const cur = cursorIdx === idx}
|
|
<div
|
|
class="pcr-gal-gitem"
|
|
class:selected={sel}
|
|
class:focused={cur}
|
|
data-hash={img.hash}
|
|
role="button" tabindex="0"
|
|
onclick={(e) => handleItemClick(img, idx, e)}
|
|
ondblclick={() => handleItemDblClick(img)}
|
|
oncontextmenu={(e) => handleItemContext(img, idx, e)}
|
|
>
|
|
<img src={thumbUrl(img.hash)} alt={img.filename} loading="lazy" draggable="false" onerror={retryThumb} />
|
|
{#if versionsOf(img) > 1}
|
|
<div class="pcr-gal-vers" title="{versionsOf(img)} versions">{versionsOf(img)}</div>
|
|
{/if}
|
|
{#if editDocLayers[img.hash]}
|
|
<div class="pcr-gal-layers" title="{editDocLayers[img.hash]} layers — opens layered in Edit">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"><path d="M12 2 2 7l10 5 10-5-10-5Z" /><path d="m2 17 10 5 10-5" /><path d="m2 12 10 5 10-5" /></svg>
|
|
</div>
|
|
{/if}
|
|
{#if sel}<div class="pcr-gal-check"></div>{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="pcr-gal-justified" bind:this={wrapEl} style="height:{layoutHeight}px;">
|
|
{#each visibleImages as img, idx (img.hash)}
|
|
{#if layoutBoxes[idx]}
|
|
{@const box = layoutBoxes[idx]}
|
|
{@const sel = selected.has(img.hash)}
|
|
{@const cur = cursorIdx === idx}
|
|
<div
|
|
class="pcr-gal-jitem"
|
|
class:selected={sel}
|
|
class:focused={cur}
|
|
data-hash={img.hash}
|
|
style="left:{box.left}px;top:{box.top}px;width:{box.width}px;height:{box.height}px;"
|
|
role="button" tabindex="0"
|
|
onclick={(e) => handleItemClick(img, idx, e)}
|
|
ondblclick={() => handleItemDblClick(img)}
|
|
oncontextmenu={(e) => handleItemContext(img, idx, e)}
|
|
>
|
|
<img src={thumbUrl(img.hash)} alt={img.filename} loading="lazy" draggable="false" onerror={retryThumb} />
|
|
{#if versionsOf(img) > 1}
|
|
<div class="pcr-gal-vers" title="{versionsOf(img)} versions">{versionsOf(img)}</div>
|
|
{/if}
|
|
{#if editDocLayers[img.hash]}
|
|
<div class="pcr-gal-layers" title="{editDocLayers[img.hash]} layers — opens layered in Edit">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"><path d="M12 2 2 7l10 5 10-5-10-5Z" /><path d="m2 17 10 5 10-5" /><path d="m2 12 10 5 10-5" /></svg>
|
|
</div>
|
|
{/if}
|
|
{#if sel}<div class="pcr-gal-check"></div>{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if ctxMenu}
|
|
<GalleryContextMenu
|
|
x={ctxMenu.x} y={ctxMenu.y}
|
|
targetItem={ctxMenu.item}
|
|
{viewMode}
|
|
selectionCount={selected.size}
|
|
onAction={handleCtxAction}
|
|
onClose={() => { ctxMenu = null; suppressClick = true; }}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<ConfirmModal
|
|
open={confirmOpen}
|
|
title="Delete"
|
|
message={confirmMsg}
|
|
confirmLabel="Delete"
|
|
onConfirm={executeDelete}
|
|
onCancel={cancelDelete}
|
|
/>
|
|
|
|
<style>
|
|
.pcr-gal {
|
|
width: 100%; min-height: 100%;
|
|
user-select: none; outline: none;
|
|
position: relative;
|
|
}
|
|
.pcr-gal-empty {
|
|
padding: 20px; text-align: center;
|
|
color: rgba(255, 255, 255, 0.4); font-style: italic;
|
|
}
|
|
|
|
/* justified */
|
|
.pcr-gal-justified { position: relative; width: 100%; }
|
|
.pcr-gal-jitem {
|
|
position: absolute; overflow: hidden;
|
|
border: 2px solid transparent; border-radius: 4px;
|
|
cursor: pointer; box-sizing: border-box;
|
|
transition: transform 0.15s, box-shadow 0.15s;
|
|
}
|
|
.pcr-gal-jitem:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(0,0,0,0.5); z-index: 10; }
|
|
.pcr-gal-jitem.focused { border-color: rgba(255, 255, 255, 0.4); }
|
|
.pcr-gal-jitem.selected { border-color: rgba(79, 195, 247, 0.6); box-shadow: inset 0 0 0 1px rgba(79, 195, 247, 0.3); }
|
|
.pcr-gal-jitem img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
|
|
/* grid */
|
|
.pcr-gal-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(var(--gal-size, 120px), 1fr));
|
|
gap: 4px; padding: 4px;
|
|
}
|
|
.pcr-gal-gitem {
|
|
aspect-ratio: 1; overflow: hidden;
|
|
border: 2px solid transparent; border-radius: 4px;
|
|
cursor: pointer; position: relative;
|
|
transition: transform 0.15s;
|
|
}
|
|
.pcr-gal-gitem:hover { border-color: var(--border-color, #444); }
|
|
.pcr-gal-gitem.focused { border-color: rgba(255, 255, 255, 0.4); }
|
|
.pcr-gal-gitem.selected { border-color: rgba(79, 195, 247, 0.6); background: rgba(79, 195, 247, 0.15); }
|
|
.pcr-gal-gitem img { width: 100%; height: 100%; object-fit: cover; }
|
|
|
|
/* list */
|
|
.pcr-gal-list { display: flex; flex-direction: column; }
|
|
.pcr-gal-lrow {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 4px 8px; border: none; background: transparent;
|
|
color: var(--input-text, #ccc); cursor: pointer; text-align: left;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
|
}
|
|
.pcr-gal-lrow:hover { background: rgba(255, 255, 255, 0.04); }
|
|
.pcr-gal-lrow.selected { background: rgba(79, 195, 247, 0.12); }
|
|
.pcr-gal-lrow.focused { outline: 1px solid rgba(255, 255, 255, 0.3); outline-offset: -1px; }
|
|
.pcr-gal-lthumb {
|
|
width: 32px; height: 32px; object-fit: cover;
|
|
border-radius: 3px; flex-shrink: 0;
|
|
border: 1px solid #000000a6;
|
|
object-view-box: inset(8% 31% 49% 31%);
|
|
}
|
|
/* checkmark */
|
|
.pcr-gal-check {
|
|
position: absolute; top: 4px; left: 4px;
|
|
width: 14px; height: 14px; border-radius: 50%;
|
|
background: var(--p-button-text-primary-color, #4fc3f7);
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.pcr-gal-check::after {
|
|
content: ""; width: 6px; height: 3px;
|
|
border-left: 1.5px solid #000; border-bottom: 1.5px solid #000;
|
|
transform: rotate(-45deg); margin-top: -1px;
|
|
}
|
|
|
|
.pcr-gal-lname {
|
|
flex: 1; font-size: 11px; overflow: hidden;
|
|
text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
/* lineage version count */
|
|
.pcr-gal-vers {
|
|
position: absolute; bottom: 4px; right: 4px;
|
|
min-width: 16px; padding: 1px 5px;
|
|
border-radius: 9px; box-sizing: border-box;
|
|
background: rgba(0, 0, 0, 0.65);
|
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
color: #ddd; font-size: 10px; line-height: 13px;
|
|
text-align: center; pointer-events: none;
|
|
}
|
|
/* "has a saved layer stack" badge — top-right (vers is bottom-right, the
|
|
selection check is top-left) */
|
|
.pcr-gal-layers {
|
|
position: absolute; top: 4px; right: 4px;
|
|
width: 18px; height: 18px; padding: 3px;
|
|
box-sizing: border-box; border-radius: 5px;
|
|
background: rgba(20, 20, 20, 0.78);
|
|
box-shadow: 0 0 0 1px rgba(200, 89, 9, 0.6);
|
|
color: #ffb27d; display: flex; pointer-events: none;
|
|
}
|
|
.pcr-gal-layers svg { width: 100%; height: 100%; }
|
|
.pcr-gal-lvers {
|
|
font-size: 10px; color: var(--p-button-text-primary-color, #4fc3f7);
|
|
flex-shrink: 0;
|
|
}
|
|
.pcr-gal-lmeta {
|
|
font-size: 10px; color: var(--input-text, #888); flex-shrink: 0;
|
|
}
|
|
|
|
</style>
|