Files
mobcat40-ComfyUI-PromptChain/svelte-src/components/ImageViewer.svelte
T
chrmc40 68cbef51ec Lineage subway tree view + viewer/modal polish
- Lineage "subway" lanes: branch columns, spine + S-curve connectors, per-node
  expand/fold, header expand-all toggle, right-click Copy Path
- Lineage fixes: show family when an image is opened by file path; new-gen banner
  moved off the lineage header to top-center; transparent scrollbar corner;
  hover-only branch controls; stale-refetch staleness guard + edge-keyed
  connectors + anomaly instrumentation
- Modal-setup persistence: 3D scene, inpaint mask, prompt, upscale dials restore
  across reopen
- Re-pose modal: auto-restore last setup, Re-Apply, pan/zoom + before/after Compare
- Upscale modal: before/after Compare wipe
- Pose Studio: name-your-file scene export; $name{} block rename mirroring
- docs: README wordmark hero
2026-06-18 22:06:57 -07:00

3275 lines
126 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onDestroy } from "svelte";
import UpscaleOptionsModal from "./UpscaleOptionsModal.svelte";
import InpaintModal from "./InpaintModal.svelte";
import EditModal from "./EditModal.svelte";
import RePoseModal from "./RePoseModal.svelte";
import ConfirmModal from "./sidebar/ConfirmModal.svelte";
import LineageContextMenu from "./LineageContextMenu.svelte";
import { buildRender, ancestry } from "../lib/lineage-lanes.js";
import { buildFigureRegions, matchRegionByOverlap } from "../lib/region-binding.js";
let {
images = [],
startIndex = 0,
workflowId = "",
apiURL = (p) => p,
fetchApi = null,
onClose = () => {},
onDelete = null,
onUpscale = null,
onUpscalePrepare = null,
onUpscaleBackground = null,
onUpscaleEnlarge = null,
onInpaintPrepare = null,
onInpaintRun = null,
onInpaintSave = null,
onInpaintUploadReference = null,
onInpaintInstallPack = null,
onMountPromptEditor = null,
onEditPrepare = null,
onEditSave = null,
onInpaintRegion = null,
onReposeCaps = null, // () => Promise<{recipes}> — installable pose-transfer recipes
onReposeRun = null, // (opts) => tracker — background pose-transfer render
onMountPoser = null, // (el, {width,height,outputMode}) => Promise<handle> — detached 3D poser
autoEdit = false,
} = $props();
// Two independent axes:
// - currentIndex: timeline position (left/right arrows, scrubber, thumb clicks)
// - displayedHash: which image is shown (may differ during lineage navigation)
// When timeline navigation happens, displayedHash syncs to the timeline image.
// When lineage navigation happens (up/down), only displayedHash changes.
let currentIndex = $state(startIndex);
let displayedHash = $state(images[startIndex]?.hash || null);
let imageInfo = $state(null);
let zoom = $state(1);
let panX = $state(0);
let panY = $state(0);
let isPanning = $state(false);
let panStart = { x: 0, y: 0, panX: 0, panY: 0 };
let imageLoaded = $state(false);
let imageError = $state(false);
let containerEl;
let viewerEl;
// gesture state (Ctrl+drag)
let isGesturing = $state(false);
let gestureStart = { x: 0, y: 0, time: 0 };
let viewerTransformY = $state(0);
let viewerOpacity = $state(1);
const H_SWIPE_RATIO = 0.12;
const H_VELOCITY = 0.6;
const V_CLOSE_DIST = 160;
const V_CLOSE_VELOCITY = 1.25;
const V_NEXT_DIST = 90;
const V_NEXT_VELOCITY = 0.9;
const BOUNCE_MS = 300;
// history strip state
let historyExpanded = $state(false);
let isDraggingScrubber = $state(false);
let scrubberDragStartX = 0;
let scrubberDragStartPercent = 0;
let historyGridEl = $state(null);
const STRIP_VISIBLE = 6;
const GRID_COLUMNS = 4;
const GRID_THUMB_SIZE = 56.5;
const GRID_GAP = 4;
const GRID_MAX_HEIGHT = 280;
// compare mode state
let compareMode = $state(false);
let compareSliderPos = $state(50);
let compareClipPercent = $state(50);
let isDraggingSlider = $state(false);
let compareDropdownOpen = $state(false);
let upscaling = $state(false);
let upscalePreparing = $state(false);
let upscaleModalOpen = $state(false);
let upscalePrepared = $state(null);
let upscalePreviewUrl = $state("");
let upscaleProgress = $state(null);
let upscaleUnsub = null;
let upscaleTracker = null;
let compareTargetHash = $state(null);
let compareTargetLabel = $state("");
let mainImageEl = $state(null);
let isOrphaned = $derived(imageInfo?.orphaned === 1);
// ── orphan re-attachment ──
// A DB-tracked image whose file went missing can be re-attached by picking
// the file wherever it lives now; the server accepts it only when its
// sha256 equals the record's hash, so the wrong file can't be attached.
let reattachInputEl = $state(null);
let reattaching = $state(false);
let reattachMsg = $state("");
let imageReloadNonce = $state(0);
let canReattach = $derived(
!!fetchApi && /^[0-9a-f]{64}$/.test(displayedHash || "")
&& !images.find(i => i.hash === displayedHash)?._directUrl
);
// the /promptchain/image URL 404'd before a re-attach — bust past any
// cached failure once the record is healed
function mainImageSrc(hash) {
const url = imageUrl(hash);
if (!imageReloadNonce) return url;
return url + (url.includes("?") ? "&" : "?") + "r=" + imageReloadNonce;
}
async function handleReattachPick(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || !fetchApi || !displayedHash) return;
reattaching = true;
reattachMsg = "";
try {
const fd = new FormData();
fd.append("hash", displayedHash);
fd.append("image", file);
const res = await fetchApi("/promptchain/reattach", { method: "POST", body: fd });
if (!res.ok) {
const err = await res.json().catch(() => null);
reattachMsg = res.status === 409
? "That file's content doesn't match this image."
: `Re-attach failed: ${err?.error || res.status}`;
return;
}
imageInfo = await res.json();
imageReloadNonce += 1;
imageError = false;
imageLoaded = false;
fetchApi(`/promptchain/lineage/${displayedHash}`)
.then(r => (r.ok ? r.json() : null))
.then(data => { if (data) lineageData = data; })
.catch(() => {});
} catch (err) {
reattachMsg = `Re-attach failed: ${err.message}`;
} finally {
reattaching = false;
}
}
// animation/UX timers — tracked for cleanup on unmount.
// `$effect(() => () => cleanup)` doesn't work: the outer function has
// no tracked dependencies, so Svelte never invokes the returned
// teardown. onDestroy is the correct primitive for one-shot cleanup.
let closeTimer = null;
let bounceTimer = null;
let linkCopiedTimer = null;
onDestroy(() => {
clearTimeout(closeTimer);
clearTimeout(bounceTimer);
clearTimeout(linkCopiedTimer);
clearTimeout(newGenTimer);
upscaleUnsub?.(); // run continues server-side; we just stop listening
});
// toolbar state
let zoomDropdownOpen = $state(false);
let linkCopied = $state(false);
const ZOOM_PRESETS = [50, 100, 200, 300, 400];
let zoomDisplayText = $derived(`${Math.round(zoom * 100)}%`);
let zoomSliderValue = $derived(Math.max(0, Math.min(100, 25 * Math.log2(zoom / 0.25))));
// which hashes have a saved layer stack (sidecar) → a "layers" badge in the
// strip. hash → layer count. Fetched once and refreshed 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(); });
// lineage state
let lineageData = $state(null);
let lineageList = $derived((() => {
if (!lineageData) return [];
// family = the FULL provenance tree (root-first DFS, siblings included),
// so up/down reaches e.g. two upscales of the same source from each other.
// Fallback to the vertical ancestor line for servers without the field.
if (lineageData.family?.length) return lineageData.family;
return [...(lineageData.ancestors || []), lineageData.image, ...(lineageData.descendants || [])].filter(Boolean);
})());
let lineageCurrentIdx = $derived(
lineageList.findIndex(item => item?.hash === displayedHash)
);
// Re-pose restore walks these — the current image first, then its whole family
// — so a re-posed result (or a later edit of it) resumes the setup that was
// saved on its source image, not just the exact hash it ran on.
let reposeLineageKeys = $derived(
[displayedHash, ...lineageList.map((i) => i?.hash)].filter((h, i, a) => h && a.indexOf(h) === i)
);
// ── lineage strip bundling ──
// Content-keyed lineage attaches EVERY reuse of the same source bytes to one
// family, so a much-reused image buries this run's chain in sibling branches.
// The strip shows the SPINE (root → … → current) plus the current image's
// own descendants; every other branch collapses into an expandable bundle
// chip hanging off the spine node it forks from.
let expandedBundles = $state(new Set());
function toggleBundle(at) {
const next = new Set(expandedBundles);
if (next.has(at)) next.delete(at); else next.add(at);
expandedBundles = next;
}
// The strip is scoped to an ANCHOR — the image whose run the user is
// inspecting — not to whatever node they're currently looking at. Without
// this, stepping up to the root would recompute the spine from the root and
// unbundle every sibling branch. Lineage-internal moves (arrows, strip
// clicks) keep the anchor; any other way the displayed image changes
// (timeline, gallery jump, save handoffs) re-anchors to the new image.
let lineageFocusHash = $state(null);
// Holds the hash a lineageJump is about to land on, so the re-anchor effect
// skips exactly that transition and can't swallow an unrelated re-anchor if
// writes ever coalesce. Untracked plain-let: writing it never re-runs the
// effect (which would defeat the skip).
let keepLineageFocusFor = null;
$effect(() => {
const h = displayedHash;
if (keepLineageFocusFor === h) { keepLineageFocusFor = null; return; }
keepLineageFocusFor = null;
lineageFocusHash = h;
});
function lineageJump(hash) {
keepLineageFocusFor = hash;
displayedHash = hash;
}
// Topology primitives for the family tree, hoisted out of lineageDisplay so the
// strip, the context-menu "expand branches" gate, and the subway lane builder
// all read ONE source. byHash/childrenOf rebuild parent→children edges from each
// node's parent_hash (a content sha256, so acyclic by construction). roots is the
// true root plus any node orphaned by a deleted-and-absent parent, so the lane
// builder can surface it as its own root rather than dropping it.
let lineageTree = $derived((() => {
const list = lineageList;
const byHash = new Map(list.map(f => [f.hash, f]));
const childrenOf = new Map();
for (const f of list) {
if (!f.parent_hash || !byHash.has(f.parent_hash)) continue;
if (!childrenOf.has(f.parent_hash)) childrenOf.set(f.parent_hash, []);
childrenOf.get(f.parent_hash).push(f);
}
for (const kids of childrenOf.values()) {
kids.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
}
const roots = list.filter(f => !f.parent_hash || !byHash.has(f.parent_hash)).map(f => f.hash);
return { byHash, childrenOf, roots };
})());
let lineageDisplay = $derived((() => {
const asNodes = (list) => list.map(item => ({ kind: "node", item }));
if (lineageList.length < 2) return asNodes(lineageList);
const { byHash, childrenOf } = lineageTree;
if (!byHash.has(displayedHash)) return asNodes(lineageList);
// parent_hash is a content sha256, so the family is acyclic by
// construction — the visited set is cheap defense against malformed data
// looping the browser.
const subtreeNodes = (rootHash) => {
const out = [];
const seen = new Set();
const stack = [rootHash];
while (stack.length) {
const h = stack.pop();
if (seen.has(h)) continue;
seen.add(h);
const f = byHash.get(h);
if (!f) continue;
out.push(f);
const kids = (childrenOf.get(h) || []).slice().reverse();
for (const k of kids) stack.push(k.hash);
}
return out;
};
const buildFrom = (anchorHash) => {
const spine = [];
let cur = anchorHash;
const climbed = new Set();
while (cur && byHash.has(cur) && !climbed.has(cur)) {
climbed.add(cur);
spine.push(cur);
cur = byHash.get(cur).parent_hash;
}
spine.reverse();
const spineSet = new Set(spine);
// the anchor run's results stay individually visible
const ownSubtree = new Set();
const seedStack = [anchorHash];
while (seedStack.length) {
const h = seedStack.pop();
if (ownSubtree.has(h)) continue;
ownSubtree.add(h);
for (const k of childrenOf.get(h) || []) seedStack.push(k.hash);
}
const display = [];
for (const h of spine) {
display.push({ kind: "node", item: byHash.get(h) });
const offSpine = (childrenOf.get(h) || [])
.filter(k => !spineSet.has(k.hash) && !ownSubtree.has(k.hash));
if (!offSpine.length) continue;
const branchNodes = offSpine.flatMap(k => subtreeNodes(k.hash));
if (expandedBundles.has(h)) {
display.push({ kind: "collapse", at: h, count: branchNodes.length, preview: [] });
for (const f of branchNodes) display.push({ kind: "node", item: f, branch: true });
} else {
display.push({ kind: "bundle", at: h, count: branchNodes.length, preview: branchNodes.slice(0, 2) });
}
}
for (const f of subtreeNodes(anchorHash)) {
if (f.hash !== anchorHash) display.push({ kind: "node", item: f });
}
return display;
};
const anchor = lineageFocusHash && byHash.has(lineageFocusHash) ? lineageFocusHash : displayedHash;
let display = buildFrom(anchor);
// the displayed image must always be on-strip — if it ended up inside a
// collapsed bundle (e.g. a timeline jump onto a sibling branch), rebuild
// the strip around it instead
if (anchor !== displayedHash && !display.some(d => d.kind === "node" && d.item.hash === displayedHash)) {
display = buildFrom(displayedHash);
}
return display;
})());
let lineageVisible = $derived(lineageDisplay.filter(d => d.kind === "node").map(d => d.item));
let lineageDisplayCurrentIdx = $derived(
lineageDisplay.findIndex(d => d.kind === "node" && d.item.hash === displayedHash)
);
let lineageHiddenCount = $derived(lineageList.length - lineageVisible.length);
// ── subway lanes (right-click "Expand branches") ──
// brokenOut holds CHILD-SUBTREE ROOT hashes the user has peeled into their own
// vertical column. The rail switches from the compact strip to the lane view
// whenever anything is broken out. subwayAnchor reuses the stable lineageFocusHash
// so navigating between nodes never re-partitions the lanes, and the displayed
// image is always on column 0 (anchored on the anchor's root→tip spine).
let brokenOut = $state(new Set());
let subwayAnchor = $derived(
lineageFocusHash && lineageTree.byHash.has(lineageFocusHash) ? lineageFocusHash : displayedHash
);
let laneRender = $derived(buildRender(lineageTree, subwayAnchor, brokenOut));
let lanesActive = $derived(brokenOut.size > 0 && lineageList.length > 1);
// The child that CONTINUES a node's lane — "expand branches" peels the SIBLINGS,
// never this. Matches buildLane's primary rule (spine-ward child, else oldest).
function primaryChildHash(item) {
const kids = lineageTree.childrenOf.get(item?.hash) || [];
if (!kids.length) return null;
const a = ancestry(lineageTree, subwayAnchor);
return (kids.find(k => a.set.has(k.hash)) || kids[0]).hash;
}
function canExpand(item) {
const primary = primaryChildHash(item);
return (lineageTree.childrenOf.get(item?.hash) || [])
.some(k => k.hash !== primary && !brokenOut.has(k.hash));
}
function expandBranches(item) {
const primary = primaryChildHash(item);
const next = new Set(brokenOut);
for (const k of lineageTree.childrenOf.get(item?.hash) || [])
if (k.hash !== primary) next.add(k.hash);
brokenOut = next;
}
function foldLane(childRoot) {
// folding a column folds its whole broken-out subtree with it
const next = new Set(brokenOut);
const stack = [childRoot];
while (stack.length) {
const h = stack.pop();
next.delete(h);
for (const k of lineageTree.childrenOf.get(h) || []) stack.push(k.hash);
}
brokenOut = next;
}
function collapseAllLanes() { brokenOut = new Set(); }
// the family has at least one fork worth fanning out
let hasBranches = $derived((() => {
for (const kids of lineageTree.childrenOf.values()) if (kids.length > 1) return true;
return false;
})());
// header "expand" affordance — fan EVERY branch out at once (the full subway):
// add every off-primary child across the whole family to brokenOut.
function expandAll() {
const a = ancestry(lineageTree, subwayAnchor);
const next = new Set(brokenOut);
for (const kids of lineageTree.childrenOf.values()) {
if (kids.length < 2) continue;
const primary = (kids.find(k => a.set.has(k.hash)) || kids[0]).hash;
for (const k of kids) if (k.hash !== primary) next.add(k.hash);
}
brokenOut = next;
}
// Keep the displayed image materialized if it sits inside a folded branch: peel
// the chain of off-primary roots from the anchor spine down to it. Guarded so it
// only ever ADDS new roots (never loops).
$effect(() => {
if (!lanesActive) return;
if (laneRender.columns.some(c => c.nodes.some(n => n.hash === displayedHash))) return;
const { byHash, childrenOf } = lineageTree;
const a = ancestry(lineageTree, subwayAnchor);
const toBreak = [];
let cur = displayedHash;
const guard = new Set();
while (cur && byHash.has(cur) && !guard.has(cur) && !a.set.has(cur)) {
guard.add(cur);
const parent = byHash.get(cur).parent_hash;
if (!parent || !byHash.has(parent)) break;
const kids = childrenOf.get(parent) || [];
const primary = (kids.find(k => a.set.has(k.hash)) || kids[0])?.hash;
if (cur !== primary) toBreak.push(cur);
cur = parent;
}
const additions = toBreak.filter(h => !brokenOut.has(h));
if (additions.length) {
const next = new Set(brokenOut);
for (const h of additions) next.add(h);
brokenOut = next;
}
});
// SVG connector geometry — edges are DATA from laneRender; the rAF only MEASURES
// pixel positions of known nodes (never infers topology from the DOM). The SVG
// lives inside the scrolling wrapper so it translates with scroll for free; we
// recompute only on expand/fold/resize/thumbnail-load — never on scroll, never on
// a timer (event/observer-driven per CLAUDE.md).
let lanesWrapEl = $state(null);
let connectorGeom = $state([]);
let connectorBox = $state({ w: 0, h: 0 });
let measureScheduled = false;
// Row pitch for vertically offsetting broken-out columns so children sit one
// generation below their parent. MUST match .pcr-viewer-lineage-node height (64px)
// + .pcr-lin-col gap (22px).
const LANE_ROW_H = 86;
// Layout coords relative to a content root — independent of scroll AND of CSS
// transform, so the same measurement serves the P3 zoom panel unchanged.
function contentXY(el, root) {
let x = 0, y = 0;
for (let n = el; n && n !== root; n = n.offsetParent) { x += n.offsetLeft; y += n.offsetTop; }
return { x, y, w: el.offsetWidth, h: el.offsetHeight };
}
function measureConnectors() {
const wrap = lanesWrapEl;
if (!wrap) return;
const geom = [];
for (const e of laneRender.edges) {
const a = wrap.querySelector(`[data-hash="${e.from}"]`);
const b = wrap.querySelector(`[data-hash="${e.to}"]`);
if (!a || !b) continue;
const ra = contentXY(a, wrap), rb = contentXY(b, wrap);
const px = ra.x + ra.w / 2, pb = ra.y + ra.h; // parent bottom-center
const cx = rb.x + rb.w / 2, ct = rb.y; // child top-center
// key by edge identity, not array index — when the edge set changes, a
// keyed {#each} won't reuse a stale path d-string under a shifted index
// (that was the one-frame connector "points at the old node" jitter).
const key = `${e.from}>${e.to}`;
if (Math.abs(px - cx) < 1) {
geom.push({ key, d: `M ${px} ${pb} L ${cx} ${ct}` }); // spine: straight down
} else {
const my = (pb + ct) / 2; // branch: smooth S-curve
geom.push({ key, d: `M ${px} ${pb} C ${px} ${my}, ${cx} ${my}, ${cx} ${ct}` });
}
}
connectorGeom = geom;
connectorBox = { w: wrap.scrollWidth, h: wrap.scrollHeight };
}
function scheduleMeasure() {
if (measureScheduled) return;
measureScheduled = true;
requestAnimationFrame(() => { measureScheduled = false; measureConnectors(); });
}
$effect(() => {
if (!lanesActive) return;
const cols = laneRender.columns; // read → track expand/fold/family changes
if (cols) scheduleMeasure();
});
$effect(() => {
if (!lanesWrapEl) return;
const ro = new ResizeObserver(() => scheduleMeasure());
ro.observe(lanesWrapEl);
return () => ro.disconnect();
});
let compareTargets = $derived(
lineageList.filter(item => item?.hash !== displayedHash)
);
let hasCompareTargets = $derived(compareTargets.length > 0);
let compareImageUrl = $derived(compareTargetHash ? imageUrl(compareTargetHash) : null);
let compareTargetItem = $derived(
compareTargetHash ? lineageList.find((item) => item?.hash === compareTargetHash) || null : null
);
let currentImage = $derived(images[currentIndex] || null);
let scrubberPercent = $derived(
images.length > 1 ? (currentIndex / (images.length - 1)) * 100 : 0
);
let visibleThumbs = $derived((() => {
const start = Math.max(0, Math.min(currentIndex, images.length - STRIP_VISIBLE));
return images.slice(start, start + STRIP_VISIBLE).map((img, i) => ({
...img,
globalIndex: start + i,
}));
})());
// fetch metadata + lineage when displayed hash changes.
// Intentionally do NOT reset imageLoaded here — keeping the previous
// frame visible while the browser decodes the new src eliminates the
// flicker-to-spinner on every navigation. onerror resets it so a
// broken image doesn't leave a stale frame under the error overlay.
$effect(() => {
if (!displayedHash || !fetchApi) return;
imageError = false;
reattachMsg = "";
const img = images.find(i => i.hash === displayedHash);
const onMetaError = (label) => (e) => {
imageInfo = null;
console.error(`[PromptChain] ${label} fetch failed for ${displayedHash}:`, e);
};
if (img?._directUrl) {
// Browse-opened image. browse/meta hashes the file and returns its full
// DB record when the file is tracked, so surface the family and align
// displayedHash to that content hash — lineage, the current-node
// highlight, and the edit/inpaint/upscale handoffs then all resolve like
// a gallery open. A path-keyed entry (input scope) never reached the hash
// endpoints, so the image's whole family was invisible.
const scope = img._browseScope || "output";
const path = img._browsePath || displayedHash;
const openedHash = displayedHash;
lineageData = null;
fetchApi(`/promptchain/browse/meta?scope=${scope}&path=${encodeURIComponent(path)}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
imageInfo = data;
if (!data?.hash || displayedHash !== openedHash) return;
// input scope is path-keyed: re-key so the effect re-runs down the
// hash branch (which fetches lineage). output already carries its
// hash, so re-key is a no-op there — fetch the family directly.
if (displayedHash !== data.hash) { displayedHash = data.hash; return; }
fetchApi(`/promptchain/lineage/${data.hash}`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (displayedHash === data.hash) lineageData = d; })
.catch((e) => console.error(`[PromptChain] lineage fetch failed for ${data.hash}:`, e));
})
.catch(onMetaError("browse meta"));
} else {
// DB-tracked image — use hash-based endpoints
fetchApi(`/promptchain/image-meta/${displayedHash}`)
.then(r => r.ok ? r.json() : null)
.then(data => { imageInfo = data; })
.catch(onMetaError("image meta"));
// Capture the hash this fetch was issued for: displayedHash can change
// (fast navigation / save-then-navigate) before the response lands, and a
// late loser must NOT overwrite the current family — same staleness guard
// the browse branch above uses. Deliberately not nulling lineageData first:
// in-family navigation keeps the same family, so blanking it would flicker
// the tree on every step.
const reqHash = displayedHash;
fetchApi(`/promptchain/lineage/${reqHash}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (displayedHash !== reqHash) return; // stale response — a newer hash is shown
lineageData = data;
reportLineageAnomaly(reqHash, data);
})
.catch((e) => {
if (displayedHash !== reqHash) return;
lineageData = null;
console.error(`[PromptChain] lineage fetch failed for ${reqHash}:`, e);
});
}
});
// Re-anchor the timeline to wherever lineage navigation landed: when the
// displayed image is a member of this workflow's timeline, left/right and
// the scrubber should continue from ITS slot, not the last timeline spot.
// Images outside the timeline (other workflows' outputs) leave the index
// alone so left/right resumes where the user left off.
$effect(() => {
const idx = images.findIndex((i) => i.hash === displayedHash);
if (idx >= 0 && idx !== currentIndex) currentIndex = idx;
});
// update hash URI when displayed image changes
$effect(() => {
if (!displayedHash) return;
const wid = workflowId || "";
const params = new URLSearchParams();
// browse-opened images carry their file PATH as a pseudo-hash — write it
// as scope+path params so a refresh can rebuild the same entry (a path is
// meaningless to the hash-based restore endpoints)
const img = images.find((i) => i.hash === displayedHash);
if (img?._browsePath) {
params.set("s", img._browseScope || "output");
params.set("p", img._browsePath);
} else {
params.set("h", displayedHash);
if (wid) params.set("w", wid);
}
try {
history.replaceState(null, "", `#pcr/view?${params}`);
} catch (e) {
// Strict CSP or some embedded contexts block history API — not
// fatal, just skip the URI sync.
console.warn("[PromptChain] history update blocked:", e);
}
});
// TEMP (inpaint-target diagnosis): trace every displayedHash transition so a
// stray post-save revert to the source is caught with its trigger stack
// instead of inferred. Remove once the splice fix is confirmed in the wild.
$effect(() => {
console.trace("[PCR][viewer] displayedHash ->", displayedHash, "currentIndex", currentIndex);
});
function imageUrl(hash) {
// support direct URLs for images without DB hash (browse preview)
const img = images.find(i => i.hash === hash);
if (img?._directUrl) return img._directUrl;
return apiURL(`/promptchain/image/${hash}`);
}
function thumbUrl(hash) {
const img = images.find(i => i.hash === hash);
if (img?._directUrl) return img._directUrl;
return apiURL(`/promptchain/thumb/${hash}`);
}
// timeline navigation — moves scrubber position and syncs displayed image
// zoom/pan is preserved across navigation (only resets on double-click or first open)
function jumpTo(idx) {
if (idx >= 0 && idx < images.length && idx !== currentIndex) {
currentIndex = idx;
displayedHash = images[idx].hash;
exitCompare();
}
}
function navigate(delta) { jumpTo(currentIndex + delta); }
function resetZoom() { zoom = 1; panX = 0; panY = 0; }
// lineage navigation — only changes displayed image, not timeline position
function navigateLineage(delta) {
// walk the VISIBLE strip only — collapsed bundles are skipped; expanding
// one brings its branch into the walk
const idx = lineageVisible.findIndex(item => item.hash === displayedHash);
if (idx < 0) return;
const next = idx + delta;
if (next >= 0 && next < lineageVisible.length) {
lineageJump(lineageVisible[next].hash);
exitCompare();
}
}
// compare mode
function enterCompareWith(targetHash, label) {
if (compareMode && compareTargetHash === targetHash) { exitCompare(); return; }
const fresh = !compareMode;
compareTargetHash = targetHash;
compareTargetLabel = label;
compareMode = true;
compareDropdownOpen = false;
if (fresh) {
compareSliderPos = 50;
compareClipPercent = 50;
// recalculate clip to account for current pan offset
requestAnimationFrame(updateCompareClip);
}
}
function exitCompare() {
if (!compareMode) return;
compareMode = false;
compareTargetHash = null;
compareTargetLabel = "";
compareDropdownOpen = false;
}
function handleSliderDown(e) {
e.preventDefault();
e.stopPropagation();
isDraggingSlider = true;
}
function handleSliderMove(e) {
if (!isDraggingSlider || !containerEl || !mainImageEl) return;
const cRect = containerEl.getBoundingClientRect();
const iRect = mainImageEl.getBoundingClientRect();
compareSliderPos = Math.max(0, Math.min(100, ((e.clientX - cRect.left) / cRect.width) * 100));
compareClipPercent = Math.max(0, Math.min(100, ((e.clientX - iRect.left) / iRect.width) * 100));
}
function handleSliderUp() { isDraggingSlider = false; }
// recalculate clip-path when zoom/pan changes while in compare mode
function updateCompareClip() {
if (!compareMode || !containerEl || !mainImageEl) return;
const cRect = containerEl.getBoundingClientRect();
const iRect = mainImageEl.getBoundingClientRect();
const clientX = cRect.left + (compareSliderPos / 100) * cRect.width;
compareClipPercent = Math.max(0, Math.min(100, ((clientX - iRect.left) / iRect.width) * 100));
}
// image zoom/pan
function handleWheel(e) {
e.preventDefault();
const step = e.deltaY > 0 ? -0.15 : 0.15;
const newZoom = Math.max(0.5, Math.min(8, zoom + step));
if (newZoom === zoom) return;
const rect = containerEl.getBoundingClientRect();
const mx = e.clientX - rect.left - rect.width / 2;
const my = e.clientY - rect.top - rect.height / 2;
const ratio = newZoom / zoom;
panX = mx - (mx - panX) * ratio;
panY = my - (my - panY) * ratio;
zoom = newZoom;
requestAnimationFrame(updateCompareClip);
}
function handlePointerDown(e) {
if (e.button !== 0) return;
if (e.target.closest(".pcr-viewer-compare-slider")) return;
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
// gesture mode
isGesturing = true;
gestureStart = { x: e.clientX, y: e.clientY, time: Date.now() };
if (viewerEl) viewerEl.style.transition = "none";
} else {
// pan mode
isPanning = true;
panStart = { x: e.clientX, y: e.clientY, panX, panY };
}
containerEl.setPointerCapture(e.pointerId);
}
function handlePointerMove(e) {
if (isPanning) {
panX = panStart.panX + (e.clientX - panStart.x);
panY = panStart.panY + (e.clientY - panStart.y);
updateCompareClip();
return;
}
if (isGesturing) {
const dy = e.clientY - gestureStart.y;
// only vertical down shows visual feedback (dismiss gesture)
if (dy > 0 && Math.abs(dy) > Math.abs(e.clientX - gestureStart.x)) {
const progress = Math.min(dy / window.innerHeight, 1);
viewerTransformY = dy;
viewerOpacity = 1 - (1 - Math.pow(1 - progress, 1.6));
}
}
}
function handlePointerUp(e) {
if (isPanning) {
isPanning = false;
containerEl.releasePointerCapture(e.pointerId);
return;
}
if (isGesturing) {
isGesturing = false;
containerEl.releasePointerCapture(e.pointerId);
const dx = e.clientX - gestureStart.x;
const dy = e.clientY - gestureStart.y;
const dt = Math.max(1, Date.now() - gestureStart.time);
const vx = dx / dt;
const vy = dy / dt;
// horizontal swipe → navigate timeline
if (Math.abs(dx) > Math.abs(dy) &&
(Math.abs(dx) > window.innerWidth * H_SWIPE_RATIO || Math.abs(vx) > H_VELOCITY)) {
resetGesture();
navigate(dx < 0 ? 1 : -1);
return;
}
// vertical down → dismiss
if (Math.abs(dy) > Math.abs(dx) && (dy > V_CLOSE_DIST || vy > V_CLOSE_VELOCITY)) {
if (viewerEl) viewerEl.style.transition = `transform 0.3s ease, opacity 0.3s ease`;
viewerTransformY = window.innerHeight;
viewerOpacity = 0;
closeTimer = setTimeout(() => onClose(), 180);
return;
}
// vertical up → lineage child
if (Math.abs(dy) > Math.abs(dx) && (dy < -V_NEXT_DIST || vy < -V_NEXT_VELOCITY)) {
resetGesture();
navigateLineage(1);
return;
}
// no gesture triggered → bounce back
if (viewerEl) viewerEl.style.transition = `transform ${BOUNCE_MS}ms ease, opacity ${BOUNCE_MS}ms ease`;
viewerTransformY = 0;
viewerOpacity = 1;
bounceTimer = setTimeout(() => { if (viewerEl) viewerEl.style.transition = ""; }, BOUNCE_MS);
}
}
function resetGesture() {
viewerTransformY = 0;
viewerOpacity = 1;
if (viewerEl) viewerEl.style.transition = "";
}
function handleDblClick() { resetZoom(); }
// Two-step flow: read the image's embedded workflow first so the options
// modal can offer (and default) only the modes that workflow supports, then
// build on confirm.
async function openUpscaleOptions() {
if (upscaling || upscalePreparing || !onUpscale || !onUpscalePrepare) return;
// Lineage navigation can land on an image outside the timeline array —
// those are always DB-tracked, so a bare hash entry is enough.
const entry = images.find((i) => i.hash === displayedHash) || { hash: displayedHash };
upscalePreparing = true;
try {
const prepared = await onUpscalePrepare(entry);
if (prepared) {
upscalePrepared = prepared;
upscalePreviewUrl = imageUrl(displayedHash);
upscaleProgress = null;
upscaleModalOpen = true;
}
} finally {
upscalePreparing = false;
}
}
async function confirmUpscale(options) {
if (upscaling || !upscalePrepared) return;
if (upscaleFromEdit && upscaleEditCtx) {
options = { ...options, previewOnly: true }; // handoff renders are temp-only; Edit's Save persists
upscaleEditCtx.canvasMode = options.canvasMode || "keep";
if (upscaleEditCtx.canvasMode === "grow" && onUpscaleEnlarge) {
// Queue the Background enlargement first — it's a quick pure-model
// pass, done long before the region's diffusion render.
const tw = Math.round(upscaleEditCtx.docWidth * options.upscaleBy);
const th = Math.round(upscaleEditCtx.docHeight * options.upscaleBy);
upscaleEditCtx.bgPromise = onUpscaleEnlarge(upscaleEditCtx.bgName, tw, th, options.plainEngine || "ultrasharp");
upscaleEditCtx.bgPromise.catch(() => {}); // surfaced at Add to Edit
} else if (options.ultrasharpUnder && onUpscaleEnlarge) {
// Keep/native path: a pure-model pass of the same crop at footprint
// dims (ESRGAN up, lanczos back) — the faithful-sharp under-layer the
// handback slips beneath the diffusion render.
upscaleEditCtx.usPromise = onUpscaleEnlarge(
upscaleEditCtx.cropName, upscaleEditCtx.rect.w, upscaleEditCtx.rect.h, options.plainEngine || "ultrasharp");
upscaleEditCtx.usPromise.catch(() => {}); // best-effort — surfaced at Add to Edit
}
}
if (options?.target === "background" && onUpscaleBackground) {
// Modal stays up as the progress surface; closing it lets the run
// finish in the background (the bridge toasts on completion).
const tracker = onUpscaleBackground(upscalePrepared, options);
upscaleTracker = tracker;
upscaleUnsub?.();
upscaleUnsub = tracker.subscribe((state) => {
upscaleProgress = state;
// Completion jumps straight to the result and announces it — the
// user shouldn't have to click through a Done screen. Edit handoffs
// instead wait on the Done screen's "Add to Edit".
if (state.phase === "done" && state.resultHash && !upscaleFromEdit) {
showNewGenBanner(state.elapsedSec);
viewUpscaleResult(state.resultHash);
} else if (state.phase === "cancelled") {
cancelUpscale(); // back to the viewer exactly as it was
}
});
return;
}
upscaling = true;
upscaleModalOpen = false;
try {
const ok = await onUpscale(upscalePrepared, options);
if (ok) onClose(); // reveal the freshly opened workflow tab
} finally {
upscaling = false;
upscalePrepared = null;
}
}
function cancelUpscale() {
upscaleModalOpen = false;
upscalePrepared = null;
upscaleProgress = null;
upscaleFromEdit = false;
upscaleEditCtx = null;
if (upscalePreviewUrl.startsWith("blob:")) { URL.revokeObjectURL(upscalePreviewUrl); upscalePreviewUrl = ""; }
upscaleUnsub?.();
upscaleUnsub = null;
upscaleTracker = null;
}
function cancelUpscaleRun() {
upscaleTracker?.cancel?.(); // tracker emits "cancelled" → modal closes
}
// ── re-pose (3D-poser pose transfer, from Edit) ──
let reposeModalOpen = $state(false);
let reposePrepared = $state(null); // { sourceUrl, referenceFilename, parentFilename, width, height, caps }
let reposeProgress = $state(null);
let reposeTracker = null, reposeUnsub = null;
// Edit → Re-pose: upload the flattened composite as the appearance reference,
// probe which recipes are installable, and open the three-pane Re-pose modal.
// Nothing touches the parent workflow — the poser is detached and the render is
// queued in the background (the result records as a lineage child).
async function openReposeFromEdit({ compositeBlob }) {
if (!onReposeCaps || !onReposeRun || !onMountPoser || !onInpaintUploadReference) return;
try {
const referenceFilename = await onInpaintUploadReference(
new File([compositeBlob], "repose-source.png", { type: "image/png" }));
if (!referenceFilename) return;
const caps = await onReposeCaps();
reposePrepared = {
sourceUrl: URL.createObjectURL(compositeBlob),
referenceFilename,
parentFilename: editEntry?.filename || "",
width: editPrepared?.data?.width || 0,
height: editPrepared?.data?.height || 0,
caps,
};
reposeProgress = null;
reposeModalOpen = true;
} catch (e) {
console.error("[PromptChain] repose-from-edit failed", e);
}
}
function confirmRepose(opts) {
if (!onReposeRun || !reposePrepared) return;
const tracker = onReposeRun({
...opts,
referenceFilename: reposePrepared.referenceFilename,
parentFilename: reposePrepared.parentFilename,
});
reposeTracker = tracker;
reposeUnsub?.();
reposeUnsub = tracker.subscribe((state) => {
// Cancel STOPS the render but keeps the modal open at the config (poser +
// prompt + settings intact) so the user can tweak and re-run — only the X /
// backdrop (enabled once not running) tears it down. Stay open on done too,
// so the result + actions (Add to Edit / Close) remain.
if (state.phase === "cancelled") { reposeProgress = null; return; }
reposeProgress = state;
});
}
// "Add to Edit": bring the re-posed result back into the editor as a new layer
// — same as the inpaint/upscale handoff. The editor stays open with the layer;
// the user saves/flattens from there.
async function handleReposeToEdit(doneState) {
if (editModalRef?.addLayerFromResult && doneState?.resultUrl) {
const url = doneState.resultUrl + (doneState.resultUrl.includes("?") ? "&" : "?") + "rand=" + Math.random();
try { await editModalRef.addLayerFromResult(url, null); }
catch (e) { console.error("[PromptChain] add repose layer failed", e); }
}
closeRepose();
}
function closeRepose() {
reposeModalOpen = false;
if (reposePrepared?.sourceUrl?.startsWith?.("blob:")) URL.revokeObjectURL(reposePrepared.sourceUrl);
reposePrepared = null;
reposeProgress = null;
reposeUnsub?.();
reposeUnsub = null;
reposeTracker = null;
}
// ── inpaint ──
let inpaintModalOpen = $state(false);
let inpaintPrepared = $state(null);
let inpaintPreparing = $state(false);
async function openInpaint() {
if (inpaintPreparing || !onInpaintPrepare || !onInpaintRun) return;
const entry = images.find((i) => i.hash === displayedHash) || { hash: displayedHash };
inpaintPreparing = true;
try {
const prepared = await onInpaintPrepare(entry);
if (prepared) {
inpaintPrepared = { ...prepared, sourceUrl: imageUrl(displayedHash) };
inpaintModalOpen = true;
}
} finally {
inpaintPreparing = false;
}
}
function inpaintSaved(entry) {
showNewGenBanner(null);
if (!entry?.hash) return;
// The result is recorded under its own fresh workflow id, so it is NOT in
// this viewer's timeline snapshot. Splice it in right after the source it
// derived from and anchor BOTH the timeline index and the displayed hash to
// it — otherwise displayedHash points at an image the filmstrip/scrubber
// don't know about, and a single stray navigation snaps back to the source,
// silently re-inpainting the original on the next pass.
const existingIdx = images.findIndex((i) => i.hash === entry.hash);
if (existingIdx >= 0) {
currentIndex = existingIdx;
} else {
const srcIdx = images.findIndex((i) => i.hash === displayedHash);
const insertAt = srcIdx >= 0 ? srcIdx + 1 : images.length;
images = [...images.slice(0, insertAt), entry, ...images.slice(insertAt)];
currentIndex = insertAt;
}
displayedHash = entry.hash; // lineage links it to the source
refreshEditDocs(); // the save may have written or cleared a layer sidecar
window.dispatchEvent(new CustomEvent("promptchain:edit-docs-changed")); // sync the gallery badge
}
// ── edit (airbrush touch-up) ──
let editModalOpen = $state(false);
let editPrepared = $state(null);
// Figure regions for the Edit isolate workflow: one row per region entity
// of the displayed image's pose scene (name + prompt text + silhouette mask
// URL). Feeds Edit's "select figure" buttons and Inpaint's region-prompt
// chips. imageInfo lands async, so this re-derives when the DB row arrives.
let editFigureRegions = $derived(editPrepared?.data?.workflow
? buildFigureRegions({
workflow: editPrepared.data.workflow,
regionsRaw: imageInfo?.regions,
poseFilesOk: editPrepared.data.pose_files_ok,
apiURL,
})
: []);
let editPreparing = $state(false);
let editEntry = null; // the image entry Edit was opened on (reused for inpaint handoff)
let editModalRef = $state(null); // EditModal instance, for addLayerFromResult
let inpaintEditMask = $state(null); // the region mask handed from Edit
let inpaintFromEdit = $state(false);
async function openEdit(targetHash = displayedHash) {
if (editPreparing || !onEditPrepare || !onEditSave) return;
const entry = images.find((i) => i.hash === targetHash) || { hash: targetHash };
editPreparing = true;
try {
const prepared = await onEditPrepare(entry);
if (prepared) {
editEntry = entry;
editPrepared = { ...prepared, sourceUrl: imageUrl(targetHash) };
editModalOpen = true;
}
} finally {
editPreparing = false;
}
}
// Sidebar "Edit" lands the user straight in the editor for the clicked image.
let autoEditFired = false;
$effect(() => {
if (autoEdit && !autoEditFired && displayedHash) {
autoEditFired = true;
openEdit();
}
});
// Edit → Inpaint handoff: open the full Inpaint modal on the edited composite
// with the region pre-masked; its result returns to Edit as a layer.
let inpaintRegionPrefill = $state(null); // auto-resolved region prompt (regioned workflow)
let inpaintRegionName = $state(""); // which figure it resolved to, for the modal note
let inpaintMovedContent = $state(false); // the painted content was moved off its scene position
let inpaintCondOffset = $state(null); // {x,y} move delta → shifts region masks to the content's origin
async function openInpaintFromEdit({ compositeBlob, maskCanvas, sceneRect = null, condOffset = null }) {
if (!onInpaintPrepare || !onInpaintRun || !editEntry) return;
try {
const prepared = await onInpaintPrepare(editEntry);
if (!prepared) return;
const name = onInpaintUploadReference
? await onInpaintUploadReference(new File([compositeBlob], "edit-composite.png", { type: "image/png" }))
: null;
inpaintEditMask = maskCanvas;
inpaintFromEdit = true;
inpaintPrepared = {
...prepared,
data: { ...prepared.data, input_ref: name || prepared.data?.input_ref },
sourceUrl: URL.createObjectURL(compositeBlob),
};
// Moved content keeps the FULL $block source (no flattening): the cond
// masks are read at the content's ORIGIN via condOffset on the backend,
// so the moved figure follows its own region prompt to where it landed.
// The client-side flat resolve is only the fallback when the backend
// can't do regional at all (no pose / non-regional source).
inpaintMovedContent = !!condOffset;
inpaintCondOffset = condOffset;
if (!condOffset && !prepared.caps?.regionalAvailable) {
await resolveInpaintRegionPrefill(maskCanvas, sceneRect);
}
inpaintModalOpen = true;
} catch (e) {
console.error("[PromptChain] inpaint-from-edit failed", e);
}
}
async function resolveInpaintRegionPrefill(maskCanvas, sceneRect) {
inpaintRegionPrefill = null;
inpaintRegionName = "";
const regions = editFigureRegions;
const dw = editPrepared?.data?.width || 0, dh = editPrepared?.data?.height || 0;
if (!regions.length || !(dw > 0) || !(dh > 0)) return;
// For a moved copy, probe a filled rect at the ORIGIN; else the painted
// mask itself sits over the figure it covers.
let probe = maskCanvas;
if (sceneRect) {
probe = document.createElement("canvas");
probe.width = dw; probe.height = dh;
const ctx = probe.getContext("2d");
ctx.fillStyle = "#fff";
ctx.fillRect(sceneRect.x, sceneRect.y, sceneRect.w, sceneRect.h);
}
const match = await matchRegionByOverlap(probe, regions, dw, dh);
if (!match) return;
const parsed = parseRegions(imageInfo?.regions);
const global = (parsed?.global || "").trim();
const negative = (parsed?.negative || "").trim();
inpaintRegionName = match.name;
inpaintRegionPrefill = [global, match.text].filter(Boolean).join(", ")
+ (negative ? `\n\nNegative Prompt:\n${negative}` : "");
}
function closeInpaintFromEdit() {
inpaintModalOpen = false; inpaintFromEdit = false; inpaintPrepared = null; inpaintEditMask = null;
inpaintRegionPrefill = null; inpaintRegionName = ""; inpaintMovedContent = false; inpaintCondOffset = null;
}
async function handleInpaintToEdit(doneState) {
const t = doneState?.temp;
if (t && editModalRef?.addLayerFromResult) {
const url = apiURL(`/view?filename=${encodeURIComponent(t.filename)}&subfolder=${encodeURIComponent(t.subfolder || "")}&type=${t.type || "temp"}&rand=${Math.random()}`);
try { await editModalRef.addLayerFromResult(url, doneState.appliedMask || null); } catch (e) { console.error("[PromptChain] add inpaint layer failed", e); }
}
closeInpaintFromEdit();
}
// Edit → Upscale handoff: the upscale pipeline runs on the region crop, and
// the render returns to Edit either 1:1 in a GROWN canvas (the document
// scales to the quality the user picked) or squeezed back into the region —
// the user decides in the modal, asked only when the render outgrows the
// region's footprint.
let upscaleFromEdit = $state(false);
let upscaleEditCtx = $state(null); // { rect, bgName, docWidth, docHeight, canvasMode?, bgPromise? }
// Reshape a whole-image upscale prep for an Edit region crop: the uploaded
// crop becomes the input. Scene-level modes (regional masks, 3D-pose depth)
// survive ANY rect on the source frame — a partial rect rides as
// data.editRect and the graph build crops the pose masks + depth render to
// it. Only a grown/resized document loses the scene-frame correspondence,
// so scene modes are disabled there.
function regionUpscalePrepared(prepared, inputRef, rect, dw, dh, sceneRect = null) {
const caps = { ...prepared.caps };
const sourceFrame = dw === prepared.data.width && dh === prepared.data.height;
const fullCanvas = sourceFrame && rect.x === 0 && rect.y === 0 && rect.w === dw && rect.h === dh;
if (caps.graftable && !sourceFrame) {
caps.modes = {
...caps.modes,
regional: { ok: false, reason: "The document was resized or grown past the source image's frame — the per-figure masks and 3D pose depth can't line up with it anymore. Describe just this figure in the Prompt below instead (the reference shows the workflow's character blocks)." },
};
caps.sources = { ...caps.sources, pose: { ok: false } };
caps.defaultDepthSource = "image";
if (caps.defaultMode === "regional") caps.defaultMode = caps.modes.depth?.ok ? "depth" : "prompt";
}
// Ticks/defaults anchor to the CANVAS (user spec: the quality tick is
// what the document ends up at). Canvas already past 2K → default 1.0 =
// re-detail the region in place, no growth offered by default.
const docLongest = Math.max(dw || 0, dh || 0);
caps.defaultUpscaleBy = docLongest > 0 ? Math.min(8, Math.max(1, Math.round((2048 / docLongest) * 100) / 100)) : 2;
return {
...prepared,
data: {
...prepared.data,
input_ref: inputRef,
width: rect.w,
height: rect.h,
filename: fullCanvas ? prepared.data.filename : "edit region",
// Scene modes can run on any source-frame rect now, so those keep the
// REAL flag (the runner's gate is mode-aware — prompt-only crops never
// wait on pose files). Off-frame documents can't pose-hint at all.
pose_files_ok: sourceFrame ? prepared.data.pose_files_ok : true,
// sceneRect = layer-provenance override: a moved copy's pixels sit at
// `rect`, but its scene location (where the pose masks/depth should be
// cropped) is where it was cut/copied from. Same dims by construction.
...(sourceFrame && !fullCanvas
? { editRect: { x: (sceneRect || rect).x, y: (sceneRect || rect).y, w: rect.w, h: rect.h, docW: dw, docH: dh } }
: {}),
},
caps,
};
}
async function openUpscaleFromEdit({ cropBlob, bgBlob, rect, sceneRect = null, docWidth, docHeight }) {
if (!onUpscalePrepare || !onUpscaleBackground || !onInpaintUploadReference || !editEntry) return;
try {
const prepared = await onUpscalePrepare(editEntry);
if (!prepared) return;
const upload = (blob, name) => onInpaintUploadReference(new File([blob], name, { type: "image/png" }));
const [cropName, bgName] = await Promise.all([
upload(cropBlob, "edit-region.png"),
upload(bgBlob, "edit-background.png"),
]);
if (!cropName || !bgName) return;
upscaleEditCtx = { rect, bgName, cropName, docWidth, docHeight };
upscaleFromEdit = true;
upscalePrepared = regionUpscalePrepared(prepared, cropName, rect, docWidth, docHeight, sceneRect);
upscalePreviewUrl = URL.createObjectURL(cropBlob);
upscaleProgress = null;
upscaleModalOpen = true;
} catch (e) {
console.error("[PromptChain] upscale-from-edit failed", e);
}
}
// Server-log relay (CLIENT ERROR lines in comfyui.log) — the grow-vs-fallback
// decision below is otherwise invisible outside the browser console.
function reportHandback(where, message) {
try {
fetchApi("/promptchain/client-error", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ where, message, stack: "" }),
}).catch(() => {});
} catch { /* diagnostics must never break the handback */ }
}
// A healthy lineage response contains the queried node WITH its parent present
// in the same family. If the node is absent, or its parent_hash points outside
// the returned family (so the tree renders it as a stray root), log it via
// comfyui.log — that is the data signature behind a node appearing
// "mis-attributed" until a refresh. Event-driven (fires only on a landed
// response), no timers. We couldn't capture the live case, so this catches the
// next one. A true root (parent_hash null) is healthy and never logs.
function reportLineageAnomaly(reqHash, data) {
const fam = data?.family;
if (!Array.isArray(fam) || fam.length === 0) return;
const me = fam.find(f => f?.hash === reqHash);
const parentOrphaned = !!(me?.parent_hash && !fam.some(f => f?.hash === me.parent_hash));
if (me && !parentOrphaned) return; // healthy
const fams = fam.map(f => (f?.hash || "").slice(0, 8)).join(",");
reportHandback(
"lineage-tree-anomaly",
`node ${(reqHash || "").slice(0, 8)} ${me ? "orphaned: parent " + (me.parent_hash || "").slice(0, 8) + " absent" : "ABSENT"} from its own family [${fams}]`
);
}
async function handleUpscaleToEdit(doneState) {
const ctx = upscaleEditCtx;
const viewUrl = (img) => apiURL(`/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${img.type || "output"}&rand=${Math.random()}`);
reportHandback("upscale-handback", `branch check: filename=${doneState?.filename || "NONE"} canvasMode=${ctx?.canvasMode || "NONE"} bgPromise=${!!ctx?.bgPromise} applyFn=${!!editModalRef?.applyCanvasUpscale}`);
try {
if (doneState?.filename && ctx?.canvasMode === "grow" && ctx.bgPromise && editModalRef?.applyCanvasUpscale) {
let bg = null;
try {
bg = await ctx.bgPromise; // usually already done — it queued first and ESRGAN is quick
} catch (e) {
console.error("[PromptChain] background enlarge failed — falling back to in-place", e);
reportHandback("upscale-handback", `bg enlarge REJECTED → in-place fallback: ${e?.message || e}`);
}
if (bg) {
await editModalRef.applyCanvasUpscale({ bgUrl: viewUrl(bg), regionUrl: viewUrl(doneState) });
reportHandback("upscale-handback", `GROW applied: bg=${bg.filename} (${bg.type || "output"})`);
} else {
await editModalRef.addUpscaledLayerFromResult(viewUrl(doneState));
}
} else if (doneState?.filename && editModalRef?.addUpscaledLayerFromResult) {
let under = null;
if (ctx?.usPromise) {
try {
under = await ctx.usPromise; // pure-model pass, long done by the time tiles finish
} catch (e) {
reportHandback("upscale-handback", `UltraSharp under-layer REJECTED — inserting without it: ${e?.message || e}`);
}
}
await editModalRef.addUpscaledLayerFromResult(viewUrl(doneState), under ? viewUrl(under) : null);
reportHandback("upscale-handback", `KEEP path: squeezed into footprint${under ? " + UltraSharp under-layer" : ""}`);
}
} catch (e) {
console.error("[PromptChain] add upscale layer failed", e);
reportHandback("upscale-handback", `handback threw: ${e?.message || e}`);
}
cancelUpscale();
}
function viewUpscaleResult(hash) {
cancelUpscale();
displayedHash = hash; // lineage already links it to the source
}
let newGenBanner = $state(null);
let newGenTimer = null;
function showNewGenBanner(elapsedSec) {
clearTimeout(newGenTimer);
newGenBanner = elapsedSec ? `New generation! Generated in ${elapsedSec}s` : "New generation!";
newGenTimer = setTimeout(() => { newGenBanner = null; }, 6000);
}
function handleKeydown(e) {
if (upscaleModalOpen || inpaintModalOpen || editModalOpen || confirmDeleteOpen) return; // a modal owns the keyboard while open
if (e.key === "Escape") {
if (compareDropdownOpen) { compareDropdownOpen = false; return; }
if (historyExpanded) { historyExpanded = false; return; }
if (compareMode) { exitCompare(); return; }
onClose();
return;
}
if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && hasCompareTargets) {
e.preventDefault();
if (compareMode) exitCompare();
else compareDropdownOpen = !compareDropdownOpen;
return;
}
if (e.key === "ArrowLeft") { navigate(-1); return; }
if (e.key === "ArrowRight") { navigate(1); return; }
if (e.key === "ArrowUp") { e.preventDefault(); navigateLineage(-1); return; }
if (e.key === "ArrowDown") { e.preventDefault(); navigateLineage(1); return; }
}
// scrubber
function scrubberIndexFromX(clientX, trackEl) {
const rect = trackEl.getBoundingClientRect();
const percent = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
return Math.round((percent / 100) * (images.length - 1));
}
function handleScrubberTrackDown(e) {
e.preventDefault();
const idx = scrubberIndexFromX(e.clientX, e.currentTarget);
jumpTo(idx);
isDraggingScrubber = true;
scrubberDragStartX = e.clientX;
scrubberDragStartPercent = (idx / (images.length - 1)) * 100;
}
function handleScrubberHandleDown(e) {
e.preventDefault();
e.stopPropagation();
isDraggingScrubber = true;
scrubberDragStartX = e.clientX;
scrubberDragStartPercent = scrubberPercent;
}
function handleWindowMouseMove(e) {
if (isDraggingSlider) { handleSliderMove(e); return; }
if (!isDraggingScrubber) return;
const track = document.querySelector('.pcr-viewer-scrubber-track');
if (!track) return;
const idx = scrubberIndexFromX(e.clientX, track);
jumpTo(idx);
}
function handleWindowMouseUp() {
isDraggingScrubber = false;
isDraggingSlider = false;
}
// expanded grid
function toggleGrid() {
historyExpanded = !historyExpanded;
if (historyExpanded) {
requestAnimationFrame(() => scrollGridToCurrent());
}
}
function scrollGridToCurrent() {
if (!historyGridEl) return;
const rowHeight = GRID_THUMB_SIZE + GRID_GAP;
const row = Math.floor(currentIndex / GRID_COLUMNS);
const target = row * rowHeight - GRID_MAX_HEIGHT / 2 + rowHeight / 2;
historyGridEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
}
// The grid follows the selection, not just the open-toggle — navigation,
// the scrubber, and handoff returns (which insert + select the new latest
// image) all land mid-list while the grid sits wherever it last scrolled.
$effect(() => {
void currentIndex;
void images.length;
if (historyExpanded) requestAnimationFrame(() => scrollGridToCurrent());
});
function setZoomLevel(percent) {
zoom = percent / 100;
panX = 0;
panY = 0;
zoomDropdownOpen = false;
requestAnimationFrame(updateCompareClip);
}
function handleZoomSlider(e) {
zoom = 0.25 * Math.pow(2, parseInt(e.currentTarget.value) / 25);
panX = 0;
panY = 0;
requestAnimationFrame(updateCompareClip);
}
async function copyLink() {
try {
await navigator.clipboard.writeText(window.location.href);
linkCopied = true;
linkCopiedTimer = setTimeout(() => { linkCopied = false; }, 2000);
} catch {}
}
// Reveal opens Explorer on the SERVER's desktop — only offer it when this
// browser IS the server machine (loopback origin). A host browsing its own
// LAN IP also hides it: safe failure.
const isLocalClient = ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname);
function openFolder() {
if (!fetchApi) return;
const entry = images.find((i) => i.hash === displayedHash) || { hash: displayedHash };
const params = entry._browsePath != null
? `scope=${encodeURIComponent(entry._browseScope || "output")}&path=${encodeURIComponent(entry._browsePath)}`
: `hash=${encodeURIComponent(entry.hash)}`;
fetchApi(`/promptchain/reveal-file?${params}`).catch(() => {});
}
let deleting = $state(false);
let confirmDeleteOpen = $state(false);
// null = delete the displayed image (toolbar button); a hash = delete that
// specific lineage node (right-click menu), which may not be the one on screen.
let deleteTargetHash = $state(null);
function requestDelete() {
if (!onDelete || deleting) return;
deleteTargetHash = null;
confirmDeleteOpen = true;
}
function cancelDelete() {
confirmDeleteOpen = false;
deleteTargetHash = null;
}
// ── lineage strip right-click menu ──
// Server-desktop file actions (open file/folder, properties) only mean
// anything when this browser IS the server machine — same gate as the toolbar
// Open Folder button. isWindows is safe to read from the client here: a
// loopback origin guarantees client and server are one machine.
const isWindows = /windows/i.test(navigator.userAgent);
let lineageMenu = $state(null); // { x, y, item } | null
function openLineageMenu(e, item) {
e.preventDefault();
e.stopPropagation();
lineageMenu = { x: e.clientX, y: e.clientY, item };
}
let copyPathTimer = null;
let copyPathToast = $state(false);
async function copyTextToClipboard(text) {
try { await navigator.clipboard.writeText(text); return true; } catch {}
// The async clipboard API can refuse after an awaited fetch (transient
// activation expires); a hidden textarea + execCommand still copies.
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.top = "-1000px";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch { return false; }
}
async function copyPath(hash) {
try {
const r = await fetchApi?.(`/promptchain/file-path?hash=${encodeURIComponent(hash)}`);
const d = r && r.ok ? await r.json() : null;
if (!d?.path) return;
if (await copyTextToClipboard(d.path)) {
copyPathToast = true;
clearTimeout(copyPathTimer);
copyPathTimer = setTimeout(() => { copyPathToast = false; }, 1800);
}
} catch {}
}
function lineageMenuAction(action, item) {
const hash = item?.hash;
if (!hash) return;
if (action === "expand-branches") {
expandBranches(item);
} else if (action === "copy-path") {
copyPath(hash);
} else if (action === "edit") {
lineageJump(hash); // bring it on screen so editDocHash matches what we edit
openEdit(hash);
} else if (action === "delete") {
deleteTargetHash = hash;
confirmDeleteOpen = true;
} else if (action === "open-file") {
fetchApi?.(`/promptchain/open-file?hash=${encodeURIComponent(hash)}`).catch(() => {});
} else if (action === "open-folder") {
fetchApi?.(`/promptchain/reveal-file?hash=${encodeURIComponent(hash)}`).catch(() => {});
} else if (action === "properties") {
fetchApi?.(`/promptchain/file-properties?hash=${encodeURIComponent(hash)}`).catch(() => {});
}
}
async function handleDelete() {
confirmDeleteOpen = false;
if (!onDelete || deleting) return;
const hash = deleteTargetHash || displayedHash;
deleteTargetHash = null;
// Land on the PREVIOUS member of the same lineage chain (its parent/ancestor)
// if one exists, else the next member — only fall back to the flat timeline
// when the image has no lineage relatives. lineageVisible is the same ordered
// strip Up/Down navigation walks; capture the target BEFORE the delete mutates
// displayedHash and the lineage refetches.
const linIdx = lineageVisible.findIndex(item => item?.hash === hash);
let landHash = null;
if (linIdx >= 0) {
const prev = lineageVisible[linIdx - 1]?.hash;
const next = lineageVisible[linIdx + 1]?.hash;
landHash = (prev && prev !== hash) ? prev : ((next && next !== hash) ? next : null);
}
deleting = true;
try {
await onDelete(hash);
const idx = images.findIndex(i => i.hash === hash);
images = images.filter(i => i.hash !== hash);
if (images.length === 0) {
// Single-image opens (sidebar / deep-link) carry a 1-element timeline,
// but the strip still holds the rest of the family — land on a surviving
// relative instead of tearing the whole viewer down. Only close when
// nothing else remains (e.g. a standalone browsed file with no lineage).
const survivors = lineageList.filter(item => item?.hash && item.hash !== hash);
if (survivors.length) {
const removedAt = lineageList.findIndex(item => item?.hash === hash);
const next = survivors.find(s => s.hash === landHash)
|| survivors[Math.min(Math.max(0, removedAt), survivors.length - 1)];
images = survivors;
currentIndex = Math.max(0, survivors.findIndex(s => s.hash === next.hash));
displayedHash = next.hash;
} else {
onClose();
}
return;
}
// Prefer the lineage neighbor (it survives — only `hash` was deleted). Point
// currentIndex at it when it's in the timeline; otherwise keep a valid
// timeline anchor and let the view follow displayedHash (lineage refetches).
if (landHash) {
const li = images.findIndex(i => i.hash === landHash);
currentIndex = li >= 0 ? li : Math.min(idx >= 0 ? idx : currentIndex, images.length - 1);
displayedHash = landHash;
} else if (idx >= 0) {
const nextIdx = Math.min(idx, images.length - 1);
currentIndex = nextIdx;
displayedHash = images[nextIdx].hash;
} else {
// no lineage relative and the deleted version wasn't in the timeline —
// stay anchored on the current timeline image
displayedHash = images[Math.min(currentIndex, images.length - 1)].hash;
}
} catch (e) {
console.error("[PromptChain] viewer delete failed", e);
}
deleting = false;
}
function formatSeed(seed) { return seed != null ? String(seed) : null; }
// Regional gens record {global, regions:[{id,name,text}], negative} so the
// prompt panel can show the $block structure, not just the flattened text.
function parseRegions(raw) {
if (!raw) return null;
try {
const obj = JSON.parse(raw);
return Array.isArray(obj?.regions) && obj.regions.length ? obj : null;
} catch {
return null;
}
}
function timeAgo(epochSec) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - epochSec));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
// Custom hover flyout for the lineage thumbnails — a left-pointing card that
// appears instantly (the native title lagged ~1s) with richer info.
let hoverTip = $state(null); // { item, top, left }
function showTip(e, item) {
const r = e.currentTarget.getBoundingClientRect();
const top = Math.max(56, Math.min(window.innerHeight - 56, r.top + r.height / 2));
hoverTip = { item, top, left: r.right + 9 };
}
function hideTip() { hoverTip = null; }
function copySeed() {
if (imageInfo?.seed != null) {
navigator.clipboard.writeText(String(imageInfo.seed)).catch(() => {});
}
}
</script>
<svelte:window
onkeydown={handleKeydown}
onmousemove={handleWindowMouseMove}
onmouseup={handleWindowMouseUp}
onclick={(e) => {
if (compareDropdownOpen && !e.target.closest(".pcr-viewer-compare-container")) compareDropdownOpen = false;
if (zoomDropdownOpen && !e.target.closest(".pcr-viewer-zoom-control")) zoomDropdownOpen = false;
}}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-viewer" bind:this={viewerEl} style="transform: translateY({viewerTransformY}px); opacity: {viewerOpacity}">
<!-- left: lineage panel -->
{#snippet lineageNode(item, lane)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-node"
data-hash={item.hash}
class:current={item.hash === displayedHash}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={() => {
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
lineageJump(item.hash); exitCompare();
}}
onmouseenter={(e) => showTip(e, item)}
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" onload={scheduleMeasure} />
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === laneRender.rootHash}
class:dot-current={item.hash === displayedHash}
class:dot-tip={item.hash === lane.nodes[lane.nodes.length - 1].hash && item.hash !== displayedHash}
></div>
{#if editDocLayers[item.hash]}
<div class="pcr-viewer-lineage-layers">
<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 laneRender.junctionsByNode.get(item.hash)?.hidden.length}
<button class="pcr-lin-expander" title="Break this node's {laneRender.junctionsByNode.get(item.hash).hidden.length} other branch(es) into their own columns"
onclick={(e) => { e.stopPropagation(); expandBranches(item); }}
>⊞{laneRender.junctionsByNode.get(item.hash).hidden.length}</button>
{/if}
{#if brokenOut.has(lane.startHash) && item.hash === lane.startHash}
<button class="pcr-lin-fold" title="Fold this column back into its branch"
onclick={(e) => { e.stopPropagation(); foldLane(lane.startHash); }}
>⊟</button>
{/if}
</div>
{/snippet}
{#if lineageList.length > 1}
<div class="pcr-viewer-lineage pcr-viewer-lineage-visible" class:lanes={lanesActive}>
<div class="pcr-viewer-lineage-header">
<span class="pcr-lin-count">{lineageCurrentIdx + 1} / {lineageList.length}</span>
{#if hasBranches}
<button
class="pcr-lin-toggle"
class:on={lanesActive}
onclick={() => (lanesActive ? collapseAllLanes() : expandAll())}
title={lanesActive ? "Collapse the subway back to the strip" : "Expand every branch into a subway map"}
>{lanesActive ? "⊟" : "⊞"}</button>
{/if}
</div>
{#if lanesActive}
<div class="pcr-lin-lanes" bind:this={lanesWrapEl}>
<svg class="pcr-lin-connectors" width={connectorBox.w} height={connectorBox.h} aria-hidden="true">
{#each connectorGeom as g (g.key)}<path d={g.d} />{/each}
</svg>
{#each laneRender.columns as lane (lane.startHash)}
<div class="pcr-lin-col" style="margin-top: {lane.topRow * LANE_ROW_H}px">
{#each lane.nodes as item (item.hash)}
{@render lineageNode(item, lane)}
{/each}
</div>
{/each}
</div>
{:else}
<div class="pcr-viewer-lineage-strip">
{#each lineageDisplay as d, i (d.kind === "node" ? d.item.hash : `${d.kind}:${d.at}`)}
{#if d.kind === "node"}
{@const item = d.item}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-node"
class:current={i === lineageDisplayCurrentIdx}
class:ancestor={i < lineageDisplayCurrentIdx}
class:descendant={i > lineageDisplayCurrentIdx}
class:branch={d.branch}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={() => {
// With the compare dropdown open, the strip doubles as the
// target picker — clicking a thumbnail compares against it
// instead of navigating away.
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
lineageJump(item.hash); exitCompare();
}}
onmouseenter={(e) => showTip(e, item)}
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" />
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === lineageList[0]?.hash}
class:dot-current={i === lineageDisplayCurrentIdx}
class:dot-tip={i === lineageDisplay.length - 1 && i !== lineageDisplayCurrentIdx}
></div>
{#if editDocLayers[item.hash]}
<div class="pcr-viewer-lineage-layers">
<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}
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-bundle"
class:expanded={d.kind === "collapse"}
onclick={() => toggleBundle(d.at)}
title={d.kind === "collapse"
? "Collapse this branch"
: `${d.count} other generation${d.count > 1 ? "s" : ""} branch off here — click to expand`}
>
{#if d.kind === "bundle"}
<div class="pcr-viewer-lineage-bundle-stack">
{#each d.preview as p (p.hash)}
<img src={thumbUrl(p.hash)} alt="" draggable="false" loading="lazy" />
{/each}
</div>
<span class="pcr-viewer-lineage-bundle-count">+{d.count}</span>
{:else}
<span class="pcr-viewer-lineage-bundle-count">▴</span>
{/if}
</div>
{/if}
{/each}
</div>
{#if lineageHiddenCount > 0}
<div class="pcr-viewer-lineage-hidden">{lineageHiddenCount} hidden</div>
{/if}
{/if}
</div>
{:else}
<div class="pcr-viewer-lineage"></div>
{/if}
{#if hoverTip}
<div class="pcr-viewer-tip" style="top:{hoverTip.top}px; left:{hoverTip.left}px;">
<div class="pcr-viewer-tip-name">{hoverTip.item.filename || hoverTip.item.hash?.slice(0, 12)}</div>
{#if hoverTip.item.created_at}<div>{timeAgo(hoverTip.item.created_at)}</div>{/if}
{#if hoverTip.item.width && hoverTip.item.height}<div>{hoverTip.item.width}×{hoverTip.item.height}</div>{/if}
{#if editDocLayers[hoverTip.item.hash]}<div class="pcr-viewer-tip-layers">▦ {editDocLayers[hoverTip.item.hash]} layers — opens layered in Edit</div>{/if}
</div>
{/if}
<!-- center: image -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pcr-viewer-center"
bind:this={containerEl}
onwheel={handleWheel}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
ondblclick={handleDblClick}
style="cursor: {isPanning ? 'grabbing' : 'grab'}"
>
<!-- Transient notices live over the image, not the corners: top-left is the
lineage header and top-right is the close/counter. The native ComfyUI toast
can't be used here — it caps at z-index 10000, one below this viewer. -->
{#if newGenBanner}
<div class="pcr-viewer-newgen-banner">✨ {newGenBanner}</div>
{/if}
{#if copyPathToast}
<div class="pcr-viewer-newgen-banner">📋 Path copied to clipboard</div>
{/if}
{#if displayedHash}
{#if !imageLoaded && !imageError}
<div class="pcr-viewer-spinner"></div>
{/if}
<img
bind:this={mainImageEl}
src={mainImageSrc(displayedHash)}
alt=""
draggable="false"
class="pcr-viewer-image"
class:hidden={!imageLoaded}
style="transform: translate({panX}px, {panY}px) scale({zoom})"
onload={() => { imageLoaded = true; }}
onerror={() => { imageError = true; imageLoaded = false; }}
/>
{#if compareMode && compareImageUrl}
<img
src={compareImageUrl}
alt="Compare"
draggable="false"
class="pcr-viewer-image pcr-viewer-after-image"
style="transform: translate({panX}px, {panY}px) scale({zoom}); clip-path: inset(0 0 0 {compareClipPercent}%);"
/>
{/if}
{#if imageError}
<div class="pcr-viewer-error">
<div>{isOrphaned ? 'Source file deleted' : 'Image not found'}</div>
{#if canReattach}
<div class="pcr-viewer-error-hint">
This image's record points to a file that no longer exists. If the
file was moved or renamed, locate it — it's verified against the
image's content hash, so the wrong file can't be attached.
</div>
<button class="pcr-viewer-reattach-btn" disabled={reattaching} onclick={() => reattachInputEl?.click()}>
{reattaching ? "Verifying…" : "Locate file…"}
</button>
{#if reattachMsg}<div class="pcr-viewer-error-msg">{reattachMsg}</div>{/if}
<input bind:this={reattachInputEl} type="file" accept="image/*" style="display:none" onchange={handleReattachPick} />
{/if}
</div>
{/if}
{#if compareMode}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-viewer-compare-slider" style="left: {compareSliderPos}%;" onmousedown={handleSliderDown}>
<div class="pcr-viewer-compare-handle">
<div class="pcr-viewer-compare-line"></div>
<div class="pcr-viewer-compare-circle">
<svg width="16" height="16" viewBox="0 -960 960 960" fill="currentColor">
<path d="M200-160q-33 0-56.5-23.5T120-240v-480q0-33 23.5-56.5T200-800h160v80H200v480h160v80H200Zm240 80v-800h80v80h240q33 0 56.5 23.5T840-720v480q0 33-23.5 56.5T760-160H520v80h-80Zm80-160h240v-480H520v480Zm-320 0v-480 480Zm560 0v-480 480Z"/>
</svg>
</div>
<div class="pcr-viewer-compare-line"></div>
</div>
</div>
<div class="pcr-viewer-compare-label pcr-viewer-compare-label-before">
{imageInfo?.filename || 'Current'}
{#if imageInfo?.created_at}<div class="pcr-viewer-compare-label-age">{timeAgo(imageInfo.created_at)}</div>{/if}
</div>
<div class="pcr-viewer-compare-label pcr-viewer-compare-label-after">
{compareTargetLabel || 'Compare'}
{#if compareTargetItem?.created_at}<div class="pcr-viewer-compare-label-age">{timeAgo(compareTargetItem.created_at)}</div>{/if}
</div>
{/if}
{/if}
{#if currentIndex > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-viewer-nav pcr-viewer-nav-left"
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); navigate(-1); }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</div>
{/if}
{#if currentIndex < images.length - 1}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-viewer-nav pcr-viewer-nav-right"
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); navigate(1); }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg>
</div>
{/if}
</div>
<!-- right: metadata -->
<div class="pcr-viewer-meta">
<div class="pcr-viewer-meta-header">
<span class="pcr-viewer-counter">{currentIndex + 1} / {images.length}</span>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-viewer-close" onclick={onClose}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</div>
</div>
<!-- history strip -->
{#if images.length > 1}
<div class="pcr-viewer-history" class:expanded={historyExpanded}>
<div class="pcr-viewer-history-header">
<div class="pcr-viewer-history-thumbs">
{#each visibleThumbs as thumb (thumb.hash)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-history-thumb"
class:current={thumb.globalIndex === currentIndex}
onclick={() => jumpTo(thumb.globalIndex)}
>
<img src={thumbUrl(thumb.hash)} alt="" draggable="false" loading="lazy" />
</div>
{/each}
<div class="pcr-viewer-history-fade"></div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-viewer-history-expand" onclick={toggleGrid} title={historyExpanded ? "Collapse" : "Expand"}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
{#if historyExpanded}
<path d="M7 14l5-5 5 5z"/>
{:else}
<path d="M7 10l5 5 5-5z"/>
{/if}
</svg>
</div>
</div>
<!-- scrubber -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-viewer-scrubber-track" onmousedown={handleScrubberTrackDown}>
<div class="pcr-viewer-scrubber-fill" style="width: {scrubberPercent}%"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pcr-viewer-scrubber-handle"
style="left: {scrubberPercent}%"
onmousedown={handleScrubberHandleDown}
></div>
</div>
<!-- expanded grid -->
{#if historyExpanded}
<div class="pcr-viewer-history-grid" bind:this={historyGridEl}>
{#each images as img, idx (img.hash)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-grid-thumb"
class:current={idx === currentIndex}
onclick={() => jumpTo(idx)}
>
<img src={thumbUrl(img.hash)} alt="" draggable="false" loading="lazy" />
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- toolbar -->
<div class="pcr-viewer-toolbar">
<!-- zoom row -->
<div class="pcr-viewer-toolbar-row">
<div class="pcr-viewer-zoom-control">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-viewer-zoom-btn" onclick={() => { zoomDropdownOpen = !zoomDropdownOpen; }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
<line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/>
</svg>
<span>{zoomDisplayText}</span>
</div>
{#if zoomDropdownOpen}
<div class="pcr-viewer-zoom-dropdown">
{#each ZOOM_PRESETS as level}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-viewer-zoom-preset" onclick={() => setZoomLevel(level)}>{level}%</div>
{/each}
</div>
{/if}
</div>
<input type="range" class="pcr-viewer-zoom-slider" min="0" max="100" value={zoomSliderValue} oninput={handleZoomSlider} />
</div>
<!-- compare row -->
{#if hasCompareTargets}
<div class="pcr-viewer-toolbar-row pcr-viewer-compare-container">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-toolbar-btn"
class:active={compareMode || compareDropdownOpen}
onclick={() => { compareDropdownOpen = !compareDropdownOpen; }}
title="Compare (C)"
>
<svg width="16" height="16" viewBox="0 -960 960 960" fill="currentColor">
<path d="M200-160q-33 0-56.5-23.5T120-240v-480q0-33 23.5-56.5T200-800h160v80H200v480h160v80H200Zm240 80v-800h80v80h240q33 0 56.5 23.5T840-720v480q0 33-23.5 56.5T760-160H520v80h-80Zm80-160h240v-480H520v480Z"/>
</svg>
<span>Compare</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M7 10l5 5 5-5z"/></svg>
</div>
{#if compareDropdownOpen}
<div class="pcr-viewer-compare-dropdown">
{#each compareTargets as target}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-compare-dropdown-item"
class:active={compareTargetHash === target.hash}
onclick={() => enterCompareWith(target.hash, target.filename || target.hash.slice(0, 8))}
>
{target.filename || target.hash.slice(0, 8)}
</div>
{/each}
{#if compareMode}
<div class="pcr-viewer-compare-dropdown-divider"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-viewer-compare-dropdown-item pcr-viewer-compare-clear" onclick={exitCompare}>Clear</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- upscale row — HIDDEN (consolidation test): Edit is the single entry
point for inpaint/upscale; flip `false` back to restore the button -->
{#if false && onUpscale}
<div class="pcr-viewer-toolbar-row">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-toolbar-btn"
onclick={() => { if (!upscaling && !upscalePreparing) openUpscaleOptions(); }}
title="Upscale this image"
>
<svg width="16" height="16" viewBox="0 -960 960 960" fill="currentColor">
<path d="M120-120v-320h80v184l504-504H520v-80h320v320h-80v-184L256-200h184v80H120Z"/>
</svg>
<span>{upscalePreparing ? "Reading..." : upscaling ? "Building..." : "Upscale"}</span>
</div>
</div>
{/if}
<!-- inpaint row — HIDDEN (consolidation test): Edit is the single entry
point for inpaint/upscale; flip `false` back to restore the button -->
{#if false && onInpaintRun}
<div class="pcr-viewer-toolbar-row">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-toolbar-btn"
onclick={() => { if (!inpaintPreparing) openInpaint(); }}
title="Paint a region and regenerate it"
>
<svg width="16" height="16" viewBox="0 -960 960 960" fill="currentColor">
<path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 32.5-156t88-127Q256-817 330-848.5T487-880q79 0 150 26.5T761-781q53 46 86 108.5T880-538q0 103-64 160.5T660-320h-75q-18 0-31.5 14T540-275q0 20 14.5 38t14.5 43q0 26-24.5 60T480-80Z"/>
</svg>
<span>{inpaintPreparing ? "Reading..." : "Inpaint"}</span>
</div>
</div>
{/if}
<!-- edit row -->
{#if onEditSave}
<div class="pcr-viewer-toolbar-row">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-toolbar-btn"
onclick={() => { if (!editPreparing) openEdit(); }}
title="Edit, inpaint or upscale this image"
>
<svg width="16" height="16" viewBox="0 -960 960 960" fill="currentColor">
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h357l-80 80H200v560h560v-278l80-80v358q0 33-23.5 56.5T760-120H200Zm280-360ZM360-360v-170l367-367q12-12 27-18t30-6q16 0 30.5 6t26.5 18l56 57q11 12 17 26.5t6 29.5q0 15-5.5 29.5T897-728L530-360H360Zm481-424-56-56 56 56ZM440-440h56l232-232-28-28-29-28-231 231v57Zm260-260-29-28 29 28 28 28-28-28Z"/>
</svg>
<span>{editPreparing ? "Reading..." : "Edit"}</span>
{#if editDocLayers[displayedHash]}
<span class="pcr-viewer-edit-layers" title="This image has {editDocLayers[displayedHash]} saved layers — Edit opens it layered, un-flattened">
<svg width="13" height="13" 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>
{editDocLayers[displayedHash]}
</span>
{/if}
</div>
</div>
{/if}
<!-- copy link row -->
<div class="pcr-viewer-toolbar-row pcr-viewer-toolbar-row-equal">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#if onDelete}
<div class="pcr-viewer-toolbar-btn pcr-viewer-delete" onclick={requestDelete} title="Delete file">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
<span>{deleting ? 'Deleting...' : 'Delete'}</span>
</div>
{/if}
{#if isLocalClient}
<div class="pcr-viewer-toolbar-btn" onclick={openFolder} title="Reveal file in Explorer">
<svg width="16" height="16" viewBox="0 -960 960 960" fill="currentColor">
<path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Z"/>
</svg>
<span>Open Folder</span>
</div>
{/if}
<div class="pcr-viewer-toolbar-btn" class:active={linkCopied} onclick={copyLink} title="Copy shareable link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<span>{linkCopied ? 'Copied!' : 'Copy Link'}</span>
</div>
</div>
</div>
<!-- metadata content -->
<div class="pcr-viewer-meta-content">
{#if imageInfo}
<!-- image info -->
<div class="pcr-meta-heading">Image Info</div>
<div class="pcr-meta-card">
<div class="pcr-meta-grid-2">
{#if imageInfo.width && imageInfo.height}
<div class="pcr-meta-field">
<span class="pcr-meta-label">Dimensions</span>
<span class="pcr-meta-val">{imageInfo.width} × {imageInfo.height}</span>
</div>
{/if}
{#if imageInfo.file_size}
<div class="pcr-meta-field">
<span class="pcr-meta-label">Size</span>
<span class="pcr-meta-val">{imageInfo.file_size > 1048576 ? (imageInfo.file_size / 1048576).toFixed(1) + ' MB' : Math.round(imageInfo.file_size / 1024) + ' KB'}</span>
</div>
{/if}
</div>
{#if imageInfo.created_at}
<div class="pcr-meta-field">
<span class="pcr-meta-label">Generated</span>
<span class="pcr-meta-val">{new Date(imageInfo.created_at * 1000).toLocaleDateString()}</span>
</div>
{/if}
{#if imageInfo.filename}
<div class="pcr-meta-field">
<span class="pcr-meta-label">Filename</span>
<span class="pcr-meta-val pcr-meta-break">{imageInfo.filename}</span>
</div>
{/if}
{#if isOrphaned}
<div class="pcr-meta-field">
<span class="pcr-meta-label pcr-label-orphan">Status</span>
<span class="pcr-meta-val pcr-meta-orphan">Source deleted</span>
</div>
{/if}
</div>
<!-- prompt -->
{#if imageInfo.prompt || imageInfo.negative}
{@const regionData = parseRegions(imageInfo.regions)}
<div class="pcr-meta-heading">Prompt</div>
<div class="pcr-meta-card">
{#if regionData}
<!-- regional gen: per-$block breakdown instead of the flattened compile -->
{#each regionData.regions as region (region.id)}
<div class="pcr-meta-field">
<span class="pcr-meta-label pcr-label-region">${region.name}</span>
<p class="pcr-meta-prompt">{region.text}</p>
</div>
{/each}
{#if regionData.global}
<div class="pcr-meta-field">
<span class="pcr-meta-label pcr-label-pos">Global:</span>
<p class="pcr-meta-prompt">{regionData.global}</p>
</div>
{/if}
{:else if imageInfo.prompt}
<div class="pcr-meta-field">
<span class="pcr-meta-label pcr-label-pos">Positive Prompt:</span>
<p class="pcr-meta-prompt">{imageInfo.prompt}</p>
</div>
{/if}
{#if imageInfo.negative}
<div class="pcr-meta-field">
<span class="pcr-meta-label pcr-label-neg">Negative Prompt:</span>
<p class="pcr-meta-prompt pcr-text-neg">{imageInfo.negative}</p>
</div>
{/if}
</div>
{/if}
<!-- generation settings -->
{#if imageInfo.model || imageInfo.seed != null || imageInfo.steps}
<div class="pcr-meta-heading">Generation Settings</div>
<div class="pcr-meta-card">
{#if imageInfo.model}
<div class="pcr-meta-field">
<span class="pcr-meta-label">Model</span>
<span class="pcr-meta-val pcr-meta-break">{imageInfo.model}</span>
</div>
{/if}
{#if imageInfo.seed != null}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-meta-field pcr-clickable" onclick={copySeed} title="Click to copy">
<span class="pcr-meta-label">Seed</span>
<span class="pcr-meta-val">{formatSeed(imageInfo.seed)}</span>
</div>
{/if}
<div class="pcr-meta-grid-2">
{#if imageInfo.steps}
<div class="pcr-meta-field">
<span class="pcr-meta-label">Steps</span>
<span class="pcr-meta-val">{imageInfo.steps}</span>
</div>
{/if}
{#if imageInfo.cfg}
<div class="pcr-meta-field">
<span class="pcr-meta-label">CFG</span>
<span class="pcr-meta-val">{imageInfo.cfg}</span>
</div>
{/if}
{#if imageInfo.sampler}
<div class="pcr-meta-field">
<span class="pcr-meta-label">Sampler</span>
<span class="pcr-meta-val">{imageInfo.sampler}</span>
</div>
{/if}
{#if imageInfo.denoise != null}
<div class="pcr-meta-field">
<span class="pcr-meta-label">Denoise</span>
<span class="pcr-meta-val">{imageInfo.denoise}</span>
</div>
{/if}
</div>
</div>
{/if}
{:else if currentImage}
<div class="pcr-viewer-meta-empty">Loading metadata...</div>
{/if}
</div>
</div>
</div>
<UpscaleOptionsModal
open={upscaleModalOpen}
imageKey={displayedHash || ""}
caps={upscalePrepared?.caps}
{fetchApi}
filename={upscalePrepared?.data?.filename || ""}
width={upscalePrepared?.data?.width || 0}
height={upscalePrepared?.data?.height || 0}
previewUrl={upscalePreviewUrl}
progress={upscaleProgress}
onConfirm={confirmUpscale}
onCancel={cancelUpscale}
onCancelRun={cancelUpscaleRun}
onViewResult={viewUpscaleResult}
onUseInEdit={upscaleFromEdit ? handleUpscaleToEdit : null}
elevated={upscaleFromEdit}
docWidth={upscaleFromEdit ? upscaleEditCtx?.docWidth || 0 : 0}
docHeight={upscaleFromEdit ? upscaleEditCtx?.docHeight || 0 : 0}
onInstallPack={onInpaintInstallPack}
mountPromptEditor={onMountPromptEditor}
/>
<InpaintModal
open={inpaintModalOpen}
imageKey={displayedHash || ""}
sourceUrl={inpaintPrepared?.sourceUrl || ""}
width={inpaintPrepared?.data?.width || 0}
height={inpaintPrepared?.data?.height || 0}
filename={inpaintPrepared?.data?.filename || ""}
caps={inpaintPrepared?.caps}
prefillPrompt={inpaintPrepared?.caps?.prefillPrompt
|| (imageInfo?.prompt ? imageInfo.prompt + (imageInfo.negative ? `\n\nNegative Prompt:\n${imageInfo.negative}` : "") : "")}
referencePrompt={inpaintPrepared?.caps?.referencePrompt
|| (imageInfo?.prompt ? imageInfo.prompt + (imageInfo.negative ? `\n\nNegative Prompt:\n${imageInfo.negative}` : "") : "")}
{fetchApi}
{apiURL}
onRun={(options) => onInpaintRun(inpaintPrepared, { ...options, condOffset: inpaintFromEdit ? inpaintCondOffset : null })}
onSave={(doneState, prefix) => onInpaintSave(doneState, prefix)}
onUploadReference={onInpaintUploadReference}
onInstallPack={onInpaintInstallPack}
mountPromptEditor={onMountPromptEditor}
onSaved={inpaintSaved}
regionPrompts={editFigureRegions}
forcedPrefill={inpaintFromEdit ? inpaintRegionPrefill : null}
forcedPrefillLabel={inpaintRegionName}
movedContent={inpaintFromEdit ? inpaintMovedContent : false}
initialMask={inpaintFromEdit ? inpaintEditMask : null}
onUseInEdit={inpaintFromEdit ? handleInpaintToEdit : null}
elevated={inpaintFromEdit}
onCancel={() => { if (inpaintFromEdit) closeInpaintFromEdit(); else { inpaintModalOpen = false; inpaintPrepared = null; } }}
/>
<EditModal
bind:this={editModalRef}
open={editModalOpen}
sourceUrl={editPrepared?.sourceUrl || ""}
width={editPrepared?.data?.width || 0}
height={editPrepared?.data?.height || 0}
filename={editPrepared?.data?.filename || ""}
caps={editPrepared?.caps}
editDocHash={editPrepared ? (displayedHash || "") : ""}
{fetchApi}
{apiURL}
onSave={(blob, prefix) => onEditSave(editPrepared, blob, prefix)}
onSaved={inpaintSaved}
onOpenInpaint={onInpaintRegion ? openInpaintFromEdit : null}
onOpenUpscale={onUpscalePrepare && onUpscaleBackground && onInpaintUploadReference ? openUpscaleFromEdit : null}
onOpenRepose={onReposeCaps && onReposeRun && onMountPoser && onInpaintUploadReference ? openReposeFromEdit : null}
figureRegions={editFigureRegions}
suspended={(inpaintFromEdit && inpaintModalOpen) || (upscaleFromEdit && upscaleModalOpen) || reposeModalOpen}
onCancel={() => { editModalOpen = false; editPrepared = null; editEntry = null; }}
/>
<RePoseModal
open={reposeModalOpen}
imageKey={displayedHash || ""}
lineageKeys={reposeLineageKeys}
{fetchApi}
sourceUrl={reposePrepared?.sourceUrl || ""}
width={reposePrepared?.width || 0}
height={reposePrepared?.height || 0}
caps={reposePrepared?.caps}
progress={reposeProgress}
onMountPoser={onMountPoser}
mountPromptEditor={onMountPromptEditor}
onRun={confirmRepose}
onUseInEdit={handleReposeToEdit}
onCancel={() => { if (reposeProgress && ["building","queueing","running"].includes(reposeProgress.phase)) reposeTracker?.cancel?.(); else closeRepose(); }}
/>
<ConfirmModal
open={confirmDeleteOpen}
title="Delete image"
message="Permanently delete this image? This removes the file from disk and can't be undone."
confirmLabel="Delete"
onConfirm={handleDelete}
onCancel={cancelDelete}
/>
{#if lineageMenu}
<LineageContextMenu
x={lineageMenu.x}
y={lineageMenu.y}
canExpand={lineageMenu.item ? canExpand(lineageMenu.item) : false}
canEdit={!!onEditSave}
canDelete={!!onDelete}
isLocal={isLocalClient}
{isWindows}
orphaned={lineageMenu.item?.orphaned === 1}
onAction={(action) => lineageMenuAction(action, lineageMenu.item)}
onClose={() => { lineageMenu = null; }}
/>
{/if}
<style>
.pcr-viewer {
position: fixed;
inset: 0;
z-index: 10001;
background: #0a0a0a;
display: flex;
font-family: system-ui, -apple-system, sans-serif;
color: #ccc;
}
.pcr-viewer-lineage {
width: 0;
background: #111;
border-right: 1px solid #222;
overflow-y: auto;
flex-shrink: 0;
}
.pcr-viewer-lineage-visible {
width: 88px;
display: flex;
flex-direction: column;
}
.pcr-viewer-lineage-header {
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 8px;
box-sizing: border-box;
font-size: 11px;
color: #888;
border-bottom: 1px solid #222;
flex-shrink: 0;
}
.pcr-lin-count { white-space: nowrap; }
.pcr-viewer-lineage-strip {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.pcr-viewer-lineage-strip::-webkit-scrollbar { width: 4px; }
.pcr-viewer-lineage-strip::-webkit-scrollbar-track { background: transparent; }
.pcr-viewer-lineage-strip::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
.pcr-viewer-lineage-node {
position: relative;
width: 64px;
height: 64px;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
flex-shrink: 0;
transition: border-color 0.15s, background 0.15s;
}
.pcr-viewer-lineage-node.current {
border-color: #4fc3f7;
background: rgba(79, 195, 247, 0.15);
}
.pcr-viewer-lineage-node:hover:not(.current) {
background: rgba(255, 255, 255, 0.1);
border-color: #555;
}
.pcr-viewer-lineage-node img {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
/* "has a saved layer stack" badge — top-left so it clears the bottom-right dot */
.pcr-viewer-lineage-layers {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
padding: 2px;
box-sizing: border-box;
border-radius: 4px;
background: rgba(20, 20, 20, 0.82);
color: #ffb27d;
display: flex;
pointer-events: none;
box-shadow: 0 0 0 1px rgba(200, 89, 9, 0.6);
}
.pcr-viewer-lineage-layers svg { width: 100%; height: 100%; }
.pcr-viewer-lineage-dot {
position: absolute;
bottom: 3px;
right: 3px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid #0a0a0a;
}
.dot-root { background: #66bb6a; }
.dot-current { background: #4fc3f7; }
.dot-tip { background: #ffa726; }
.pcr-viewer-lineage-node.orphaned {
opacity: 0.4;
border: 2px dashed #c42020;
}
/* off-spine branch nodes revealed by expanding a bundle */
.pcr-viewer-lineage-node.branch {
width: 54px;
height: 54px;
margin-left: 14px;
opacity: 0.9;
}
.pcr-viewer-lineage-bundle {
position: relative;
width: 64px;
height: 38px;
border-radius: 4px;
border: 2px dashed #444;
background: #181818;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: border-color 0.15s, background 0.15s;
}
.pcr-viewer-lineage-bundle:hover {
border-color: #777;
background: #222;
}
.pcr-viewer-lineage-bundle.expanded {
height: 16px;
border-style: solid;
}
.pcr-viewer-lineage-bundle-stack {
position: absolute;
inset: 0;
}
.pcr-viewer-lineage-bundle-stack img {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.35;
pointer-events: none;
}
.pcr-viewer-lineage-bundle-stack img:nth-child(2) {
transform: translate(5px, 5px);
opacity: 0.18;
}
.pcr-viewer-lineage-bundle-count {
position: relative;
z-index: 1;
font-size: 12px;
font-weight: 700;
color: #ddd;
text-shadow: 0 1px 2px #000;
line-height: 1;
}
.pcr-viewer-lineage-hidden {
padding: 4px 0 8px;
text-align: center;
font-size: 10px;
color: #666;
flex-shrink: 0;
}
/* ── subway lanes: the rail grows horizontally into parallel vertical lanes ── */
.pcr-viewer-lineage-visible.lanes {
width: auto;
max-width: 62vw;
}
.pcr-lin-lanes {
position: relative;
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 22px;
padding: 8px 12px;
overflow: auto;
}
.pcr-lin-lanes::-webkit-scrollbar { width: 7px; height: 7px; }
.pcr-lin-lanes::-webkit-scrollbar-track { background: transparent; }
.pcr-lin-lanes::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
/* both axes scroll here, so kill the default white corner where they meet */
.pcr-lin-lanes::-webkit-scrollbar-corner { background: transparent; }
.pcr-lin-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
flex: 0 0 auto;
}
/* SVG overlay lives inside the scroll content, so it translates with scroll for
free — geometry is only recomputed on expand/fold/resize/img-load. */
.pcr-lin-connectors {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: visible;
}
.pcr-lin-connectors path {
stroke: #4a4a4a;
stroke-width: 2;
fill: none;
}
.pcr-lin-expander {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 3px;
box-sizing: border-box;
font-size: 10px;
font-weight: 600;
line-height: 1;
border-radius: 4px;
border: 1px solid rgba(120, 120, 120, 0.6);
background: rgba(20, 20, 20, 0.82);
color: #cdd6e0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.pcr-lin-expander:hover {
background: rgba(60, 60, 60, 0.95);
border-color: #888;
}
.pcr-lin-fold {
position: absolute;
bottom: 2px;
left: 2px;
width: 16px;
height: 16px;
padding: 0;
font-size: 11px;
line-height: 1;
border-radius: 4px;
border: 1px solid rgba(120, 120, 120, 0.6);
background: rgba(20, 20, 20, 0.82);
color: #cdd6e0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.pcr-lin-fold:hover {
background: rgba(60, 60, 60, 0.95);
border-color: #888;
}
/* ⊞/⊟ are actions, not status — keep them off the thumb until the node is
hovered so the strip isn't cluttered at rest (dot + layers badge stay). */
.pcr-lin-expander,
.pcr-lin-fold {
opacity: 0;
pointer-events: none;
transition: opacity 0.12s;
}
.pcr-viewer-lineage-node:hover .pcr-lin-expander,
.pcr-viewer-lineage-node:hover .pcr-lin-fold {
opacity: 1;
pointer-events: auto;
}
.pcr-lin-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
flex-shrink: 0;
border: 1px solid #333;
border-radius: 4px;
background: #1a1a1a;
color: #9bb4c7;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.pcr-lin-toggle:hover { background: #262626; border-color: #555; color: #cfe2f0; }
.pcr-lin-toggle.on { background: rgba(79, 195, 247, 0.15); border-color: #4fc3f7; color: #4fc3f7; }
/* compare-picking: dropdown open → strip thumbnails are click targets */
.pcr-viewer-lineage-node.compare-candidate {
border-color: rgba(255, 221, 114, 0.6);
box-shadow: 0 0 6px rgba(255, 221, 114, 0.3);
}
.pcr-viewer-lineage-node.compare-candidate:hover {
border-color: rgb(255, 221, 114);
box-shadow: 0 0 8px rgba(255, 221, 114, 0.55);
}
/* in compare mode: colors match the on-image before/after labels */
.pcr-viewer-lineage-node.compare-left {
border-color: rgb(96, 206, 255);
box-shadow: 0 0 8px rgba(96, 206, 255, 0.5);
}
.pcr-viewer-lineage-node.compare-right {
border-color: rgb(255, 221, 114);
box-shadow: 0 0 8px rgba(255, 221, 114, 0.5);
}
.pcr-viewer-lineage-node.compare-left::after,
.pcr-viewer-lineage-node.compare-right::after {
position: absolute;
top: 2px;
left: 2px;
font-size: 9px;
font-weight: 700;
line-height: 1;
padding: 2px 4px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.75);
}
.pcr-viewer-lineage-node.compare-left::after { content: "L"; color: rgb(96, 206, 255); }
.pcr-viewer-lineage-node.compare-right::after { content: "R"; color: rgb(255, 221, 114); }
.pcr-viewer-newgen-banner {
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
padding: 7px 13px;
font-size: 12.5px;
font-weight: 600;
color: #7ec97e;
background: rgba(20, 30, 20, 0.9);
border: 1px solid rgba(126, 201, 126, 0.45);
border-radius: 6px;
animation: pcr-newgen-in 0.25s ease-out, pcr-newgen-out 0.6s ease-in 5.4s forwards;
pointer-events: none;
}
@keyframes pcr-newgen-in {
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes pcr-newgen-out {
to { opacity: 0; }
}
.pcr-label-orphan { color: #c42020; }
.pcr-meta-orphan { color: #c42020; font-weight: 600; }
/* center: image */
.pcr-viewer-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
user-select: none;
}
.pcr-viewer-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center center;
}
.pcr-viewer-image.hidden { display: none; }
.pcr-viewer-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #4fc3f7;
border-radius: 50%;
animation: pcr-viewer-spin 0.8s linear infinite;
}
@keyframes pcr-viewer-spin { to { transform: rotate(360deg); } }
.pcr-viewer-error {
color: #888;
font-style: italic;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
max-width: 360px;
text-align: center;
}
.pcr-viewer-error-hint {
font-size: 12px;
font-style: normal;
color: #777;
line-height: 1.5;
}
.pcr-viewer-reattach-btn {
font-style: normal;
font-size: 12.5px;
padding: 6px 14px;
border-radius: 4px;
border: 1px solid #555;
background: #2a2a2a;
color: #ddd;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.pcr-viewer-reattach-btn:hover:not(:disabled) {
background: #333;
border-color: #777;
}
.pcr-viewer-reattach-btn:disabled { opacity: 0.5; cursor: default; }
.pcr-viewer-error-msg {
font-size: 12px;
font-style: normal;
color: #e08080;
}
/* nav arrows */
.pcr-viewer-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: color 0.15s, background 0.15s;
border-radius: 4px;
z-index: 5;
}
.pcr-viewer-nav:hover { color: #fff; background: rgba(255, 255, 255, 0.1); }
.pcr-viewer-nav-left { left: 12px; }
.pcr-viewer-nav-right { right: 12px; }
/* right: metadata panel */
.pcr-viewer-meta {
width: 280px;
background: #111;
border-left: 1px solid #222;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.pcr-viewer-meta-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #222;
}
.pcr-viewer-counter { font-size: 12px; color: #888; }
.pcr-viewer-close {
cursor: pointer;
color: #888;
display: flex;
align-items: center;
padding: 4px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.pcr-viewer-close:hover { color: #ff6b6b; background: #331111; }
/* history strip */
.pcr-viewer-history {
padding: 8px 12px;
border-bottom: 1px solid #222;
background: #0d0d0d;
}
.pcr-viewer-history-header {
display: flex;
align-items: center;
gap: 6px;
}
.pcr-viewer-history-thumbs {
display: flex;
gap: 6px;
position: relative;
overflow: hidden;
flex: 1;
}
.pcr-viewer-history-thumb {
flex-shrink: 0;
width: 40px;
height: 40px;
border: 2px solid transparent;
border-radius: 4px;
background: #222;
cursor: pointer;
overflow: hidden;
transition: border-color 0.15s, transform 0.15s;
}
.pcr-viewer-history-thumb.current { border-color: #4fc3f7; }
.pcr-viewer-history-thumb:hover:not(.current) { border-color: #666; transform: scale(1.05); }
.pcr-viewer-history-thumb img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.pcr-viewer-history-fade {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 40px;
background: linear-gradient(to right, transparent, #0d0d0d);
pointer-events: none;
}
/* expand button */
.pcr-viewer-history-expand {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
color: #888;
cursor: pointer;
transition: all 0.15s;
}
.pcr-viewer-history-expand:hover { background: #252525; color: #ccc; border-color: #444; }
.pcr-viewer-history.expanded .pcr-viewer-history-expand { background: #252525; color: #4fc3f7; border-color: #4fc3f7; }
/* scrubber */
.pcr-viewer-scrubber-track {
position: relative;
height: 6px;
margin-top: 8px;
background: #333;
border-radius: 3px;
cursor: pointer;
}
.pcr-viewer-scrubber-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: #4fc3f7;
border-radius: 3px;
pointer-events: none;
}
.pcr-viewer-scrubber-handle {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
background: #4fc3f7;
border-radius: 50%;
cursor: grab;
transition: background 0.15s;
}
.pcr-viewer-scrubber-handle:hover { background: #80d4f9; }
.pcr-viewer-scrubber-handle:active { cursor: grabbing; background: #29b6f6; }
/* expanded grid */
.pcr-viewer-history-grid {
display: grid;
grid-template-columns: repeat(4, 56.5px);
grid-auto-rows: 56.5px;
gap: 4px;
margin-top: 10px;
padding: 4px;
max-height: 280px;
overflow-y: auto;
overflow-x: hidden;
background: #111;
border-radius: 4px;
border: 1px solid #222;
}
.pcr-viewer-history-grid::-webkit-scrollbar { width: 6px; }
.pcr-viewer-history-grid::-webkit-scrollbar-track { background: #1a1a1a; border-radius: 3px; }
.pcr-viewer-history-grid::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
.pcr-viewer-history-grid::-webkit-scrollbar-thumb:hover { background: #555; }
.pcr-viewer-grid-thumb {
width: 56.5px;
height: 56.5px;
border: 2px solid transparent;
border-radius: 4px;
background: #222;
cursor: pointer;
overflow: hidden;
transition: border-color 0.15s, transform 0.1s;
}
.pcr-viewer-grid-thumb.current { border-color: #4fc3f7; box-shadow: 0 0 8px rgba(79, 195, 247, 0.4); }
.pcr-viewer-grid-thumb:hover:not(.current) { border-color: #666; transform: scale(1.05); z-index: 1; }
.pcr-viewer-grid-thumb img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
/* metadata content */
.pcr-viewer-meta-content {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
}
.pcr-viewer-meta-content::-webkit-scrollbar { width: 4px; }
.pcr-viewer-meta-content::-webkit-scrollbar-track { background: transparent; }
.pcr-viewer-meta-content::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
.pcr-meta-heading {
font-size: 11px;
text-transform: uppercase;
color: #4fc3f7;
letter-spacing: 0.5px;
padding: 14px 0 6px;
}
.pcr-meta-card {
background: #1a1a1a;
border-radius: 6px;
padding: 10px 12px;
}
.pcr-meta-field {
padding: 4px 0;
}
.pcr-meta-label {
font-size: 10px;
text-transform: uppercase;
color: #666;
letter-spacing: 0.5px;
display: block;
margin-bottom: 2px;
}
.pcr-meta-val {
font-size: 14px;
color: #e0e0e0;
font-weight: 500;
display: block;
}
.pcr-meta-break { word-break: break-all; }
.pcr-meta-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.pcr-clickable {
cursor: pointer;
border-radius: 4px;
padding: 4px;
margin: 0 -4px;
transition: background 0.15s;
}
.pcr-clickable:hover { background: rgba(255, 255, 255, 0.05); }
.pcr-label-pos { color: #4fc3f7; }
.pcr-label-neg { color: #ff5252; }
.pcr-label-region { color: #ffa726; }
.pcr-meta-prompt {
margin: 4px 0 8px;
font-size: 13px;
line-height: 1.5;
color: #d3d3d3;
max-height: 200px;
overflow-y: auto;
word-wrap: break-word;
}
.pcr-text-neg { color: #888; }
.pcr-viewer-meta-empty {
color: #666;
font-style: italic;
font-size: 12px;
padding: 20px 0;
text-align: center;
}
/* toolbar */
.pcr-viewer-toolbar {
padding: 12px;
border-bottom: 1px solid #222;
display: flex;
flex-direction: column;
gap: 8px;
}
.pcr-viewer-toolbar-row {
display: flex;
align-items: center;
gap: 8px;
}
/* equal-height variant for button-only rows: labels that wrap to two lines
(Open Folder / Copy Link) must not leave one-line siblings (Delete)
shorter. Not on the base row class — stretching the zoom row top-aligns
the range input's track. */
.pcr-viewer-toolbar-row-equal { align-items: stretch; }
.pcr-viewer-toolbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #222;
border: 1px solid #333;
border-radius: 4px;
color: #ccc;
font-size: 12px;
cursor: pointer;
flex: 1;
justify-content: center;
transition: background 0.15s, border-color 0.15s;
}
.pcr-viewer-toolbar-btn:hover { background: #2a2a2a; }
.pcr-viewer-toolbar-btn.active { background: rgba(79, 195, 247, 0.2); border-color: #4fc3f7; color: #4fc3f7; }
/* current image carries a saved layer stack — a badge inside the Edit button */
.pcr-viewer-edit-layers {
display: inline-flex; align-items: center; gap: 3px;
margin-left: 9px; padding: 2px 7px; border-radius: 10px;
background: rgba(200, 89, 9, 0.2); color: #ffb27d;
font-size: 11px; line-height: 1; font-weight: 600;
}
.pcr-viewer-edit-layers svg { flex: none; }
/* lineage thumbnail hover flyout — fixed (escapes the strip's overflow),
left-pointing arrow at the thumbnail, instant */
.pcr-viewer-tip {
position: fixed; transform: translateY(-50%);
z-index: 10010; pointer-events: none;
background: #1c1c1c; border: 1px solid #3a3a3a; border-radius: 6px;
padding: 8px 11px; font-size: 12px; color: #b8b8b8; line-height: 1.5;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55); white-space: nowrap;
animation: pcr-tip-in 0.09s ease-out;
}
@keyframes pcr-tip-in { from { opacity: 0; transform: translate(-4px, -50%); } to { opacity: 1; transform: translate(0, -50%); } }
.pcr-viewer-tip::before {
content: ""; position: absolute; left: -6px; top: 50%; margin-top: -6px;
border-top: 6px solid transparent; border-bottom: 6px solid transparent; border-right: 6px solid #3a3a3a;
}
.pcr-viewer-tip::after {
content: ""; position: absolute; left: -5px; top: 50%; margin-top: -5px;
border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-right: 5px solid #1c1c1c;
}
.pcr-viewer-tip-name { color: #fff; font-weight: 500; }
.pcr-viewer-tip-layers { color: #ffb27d; margin-top: 2px; }
.pcr-viewer-delete { color: #e74c3c; border-color: rgba(231, 76, 60, 0.3); }
.pcr-viewer-delete:hover { background: rgba(231, 76, 60, 0.15); }
.pcr-viewer-upscale-disabled { opacity: 0.4; cursor: default; }
.pcr-viewer-upscale-disabled:hover { background: transparent; }
/* zoom controls */
.pcr-viewer-zoom-control { position: relative; }
.pcr-viewer-zoom-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: #222;
border: 1px solid #333;
border-radius: 4px;
color: #ccc;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
}
.pcr-viewer-zoom-btn:hover { background: #2a2a2a; }
.pcr-viewer-zoom-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: #222;
border: 1px solid #333;
border-radius: 4px;
overflow: hidden;
z-index: 100;
}
.pcr-viewer-zoom-preset {
padding: 6px 12px;
font-size: 12px;
color: #ccc;
cursor: pointer;
transition: background 0.1s;
}
.pcr-viewer-zoom-preset:hover { background: #333; }
.pcr-viewer-zoom-slider {
flex: 1;
height: 4px;
-webkit-appearance: none;
background: #333;
border-radius: 2px;
cursor: pointer;
}
.pcr-viewer-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #4fc3f7;
cursor: pointer;
}
/* compare dropdown */
.pcr-viewer-compare-container { position: relative; }
.pcr-viewer-compare-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
max-height: 300px;
overflow-y: auto;
background: #252525;
border: 1px solid #404040;
border-radius: 6px;
padding: 4px;
z-index: 100;
}
.pcr-viewer-compare-dropdown-item {
padding: 6px 10px;
font-size: 12px;
color: #ccc;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.1s;
}
.pcr-viewer-compare-dropdown-item:hover { background: rgba(255, 255, 255, 0.1); }
.pcr-viewer-compare-dropdown-item.active { color: #4fc3f7; background: rgba(79, 195, 247, 0.15); }
.pcr-viewer-compare-clear { color: #f48fb1; }
.pcr-viewer-compare-dropdown-divider {
height: 1px;
background: #333;
margin: 4px 0;
}
/* compare overlay: after image */
.pcr-viewer-after-image {
position: absolute;
z-index: 1;
}
/* compare slider */
.pcr-viewer-compare-slider {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
background: transparent;
cursor: ew-resize;
z-index: 20;
transform: translateX(-50%);
}
.pcr-viewer-compare-handle {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.pcr-viewer-compare-line {
width: 2px;
flex: 1;
background: white;
opacity: 0.8;
}
.pcr-viewer-compare-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
color: #333;
flex-shrink: 0;
}
/* compare labels */
.pcr-viewer-compare-label {
position: absolute;
top: 16px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.7);
font-size: 12px;
border-radius: 4px;
z-index: 25;
}
.pcr-viewer-compare-label-before { left: 16px; color: rgb(96, 206, 255); }
.pcr-viewer-compare-label-after { right: 16px; color: rgb(255, 221, 114); text-align: right; }
.pcr-viewer-compare-label-age {
font-size: 10px;
opacity: 0.75;
margin-top: 2px;
}
</style>