/**
* The single-image view — a dedicated full page for one saved image and its sidecar, promoted to a
* top-level view (its own header tab) alongside Generate and Gallery. Sticky image on the left with
* its Re-Rolls / Variations / Resizes strips beneath it; on the right the prompt and negative each
* in their DPL / engine-roll / AI-translation / sent layers (with inline re-roll / make-variation
* actions), a curated details table (toggleable to a syntax-highlighted raw-JSON view) with
* Markdown / JSON copy, a clickable keyword cloud, prev/next navigation, and the file actions
* (open / reveal / download / convert / resize / delete).
*
* Two v1-2 features live here: **re-roll / variation** (regenerate a fresh image from a captured
* prompt layer, new seed) and **ancestry** (each derived image keeps its parent's id; the feed scan
* rebuilds the reverse child list). Re-roll / variation / resize don't navigate away — a live
* placeholder appears in the matching strip below the image and fills in when the new image lands.
*
* State lives in `App` (the current image, the feed, in-flight derivations) so the view keeps its
* place when you switch tabs. It stays mounted but hidden when inactive, so keyboard nav is gated on
* `active`.
* @module gui/components/SingleView
*/
import { useEffect, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { promptText, promptLayers, negativeLayers } from "../lib/gallery.js";
import { convertUrl } from "../lib/magick.js";
import { isOutputFile, openImageFile, revealImageFile } from "../lib/output.js";
import { effectiveKey } from "../lib/sessionKeys.js";
import { getProvider, providers } from "../lib/providers/index.js";
import { canDerive, hasSource, RESIZE_SCALES } from "../lib/derive.js";
import { msgs, layerMsg } from "./single/messages.js";
import { toMarkdown } from "../lib/single/markdown.js";
import { syntaxHighlightJson } from "../lib/single/json.js";
import { dialog } from "../lib/dialog.js";
import { MoreIcon } from "./icons.jsx";
import PromptCard from "./single/PromptCard.jsx";
import DetailTable from "./single/DetailTable.jsx";
import CopyButton from "./single/CopyButton.jsx";
import LineageHead from "./single/LineageHead.jsx";
import DerivedStrips from "./single/DerivedStrips.jsx";
import KeywordsCard from "./single/KeywordsCard.jsx";
// Fraction glyphs for sub-1 resize factors.
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const FRAC = {
0.25: "¼×",
0.5: "½×"
};
// First present value among several possible setting keys (providers name things differently).
const pick = (s, ...keys) => {
for (const k of keys) if (s && s[k] !== undefined && s[k] !== null && s[k] !== "") return s[k];
return undefined;
};
// App-orchestration / sidecar-bookkeeping keys that should never appear in the "All settings"
// dump - so old sidecars (written before the snapshot was provider-scoped) stop leaking another
// provider's metadata into the table.
const REST_DROP = new Set(["provider", "providerLabel", "providerParams", "prompt", "negativePrompt", "promptCount", "locale", "includeAdult", "autoFix", "autoKeyword", "autoAddFx", "autoAddArtists", "rewriteProvider", "wrapper", "wrapperName", "wrapperParams", "useAutoSections", "keys", "mode", "parent", "derivedKind", "derivedSource", "savedAt", "file", "image"]);
/**
* The single-image view.
* @param {object} props
* @param {object[]} props.items The feed (drives prev/next + ancestry resolution).
* @param {object|null} props.current The image being shown.
* @param {{available: boolean, formats: string[]}} props.magick ImageMagick capability.
* @param {object} props.settings App settings (rewrite provider + key for the keyword rebuild).
* @param {boolean} props.active Whether this view is the visible one (gates keyboard nav).
* @param {string} props.returnLabel Label for the Back button target (e.g. "Generate").
* @param {Function} props.onBack Leave the single view.
* @param {Function} props.onNavigate `(item)` — show another image (prev/next/parent/child).
* @param {Function} props.onDelete `(item)`.
* @param {Function} props.onSearch `(term)` — search the gallery for a keyword.
* @param {Function} props.onMetaUpdate `(path, meta)` — apply a saved sidecar to the feed + view.
* @param {Function} [props.onDerive] `(item, kind, source)` — re-roll / vary into a new image.
* @param {Function} [props.onResize] `(item, scale)` — resize into a new image (ImageMagick).
* @param {object[]} [props.derivations] In-flight derivations `{ id, parentPath, kind }`.
* @param {string} [props.deriveError] The last re-roll / variation / resize failure, if any.
* @returns {JSX.Element}
*/
export default function SingleView({
items,
current,
magick,
settings,
active,
returnLabel,
onBack,
onNavigate,
onDelete,
onSearch,
onMetaUpdate,
onDerive,
onResize,
onUpscale,
derivations = [],
deriveError
}) {
const intl = useIntl();
const [rawView, setRawView] = useState(false);
const [copyOpen, setCopyOpen] = useState(false);
const index = current ? items.findIndex(it => it.path === current.path) : -1;
const total = items.length;
const hasPrev = index > 0;
const hasNext = index >= 0 && index < total - 1;
useEffect(() => {
if (!active || !current) return undefined;
const onKey = e => {
if (e.target.tagName === "SELECT" || e.target.tagName === "INPUT") return;
if (e.key === "Escape") onBack();else if (e.key === "ArrowLeft" && hasPrev) onNavigate(items[index - 1]);else if (e.key === "ArrowRight" && hasNext) onNavigate(items[index + 1]);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [active, current, items, index, hasPrev, hasNext, onBack, onNavigate]);
if (!current) {
return /*#__PURE__*/_jsxDEV("div", {
className: "gallery-view",
children: /*#__PURE__*/_jsxDEV("div", {
className: "g-inner",
children: /*#__PURE__*/_jsxDEV("div", {
className: "g-empty",
children: [/*#__PURE__*/_jsxDEV("p", {
children: intl.formatMessage(msgs.noImage)
}, void 0, false), /*#__PURE__*/_jsxDEV("p", {
className: "g-empty-sub",
children: intl.formatMessage(msgs.noImageSub)
}, void 0, false)]
}, void 0, true)
}, void 0, false)
}, void 0, false);
}
const item = current;
const m = item.meta || {};
const onDisk = isOutputFile(item.path);
const p = promptLayers(m);
const n = negativeLayers(m);
const s = m.settings || {};
const prov = getProvider(m.provider);
const caps = prov?.capabilities || {};
const showCap = key => caps[key] !== false;
// --- Inline re-roll / make-variation actions, injected into the prompt layer rows ---
const deriveLocked = !canDerive(m);
const lockHint = intl.formatMessage(msgs.deriveLocked, {
provider: m.providerLabel || prov?.label || m.provider || "This provider"
});
const runDerive = async (kind, source) => {
const confirmMsg = kind === "reroll" ? intl.formatMessage(msgs.confirmReroll) : intl.formatMessage(msgs.confirmVary, {
layer: intl.formatMessage(layerMsg[source])
});
if (await dialog.confirm({
message: confirmMsg
})) onDerive(item, kind, source);
};
const varExtra = source => ({
key: `vary-${source}`,
label: intl.formatMessage(msgs.vary),
title: deriveLocked ? lockHint : hasSource(m, source) ? intl.formatMessage(msgs.varyTitle) : intl.formatMessage(msgs.layerMissing, {
layer: intl.formatMessage(layerMsg[source])
}),
disabled: deriveLocked || !hasSource(m, source),
onClick: () => runDerive("variation", source)
});
// Prompt-only inline actions: re-roll on the DPL recipe; make-variation on DPL / Sent / Translated.
const extrasFor = key => {
if (!onDisk || !onDerive || !item.meta) return [];
if (key === "dpl") {
// Re-roll only on the DPL source — "make variation" is offered on the Sent / AI layers instead.
return [{
key: "reroll-dpl",
label: intl.formatMessage(msgs.reroll),
title: deriveLocked ? lockHint : hasSource(m, "dpl") ? intl.formatMessage(msgs.rerollTitle) : intl.formatMessage(msgs.layerMissing, {
layer: intl.formatMessage(msgs.layerDpl)
}),
disabled: deriveLocked || !hasSource(m, "dpl"),
onClick: () => runDerive("reroll", "dpl")
}];
}
if (key === "final") return [varExtra("final")];
if (key === "ai") return [varExtra("ai")];
return [];
};
const size = pick(s, "width") && pick(s, "height") ? `${pick(s, "width")}×${pick(s, "height")}` : undefined;
const saved = m.savedAt ? new Date(m.savedAt).toLocaleString() : undefined;
const details = [[intl.formatMessage(msgs.dProvider), m.providerLabel || m.provider], [intl.formatMessage(msgs.dModel), pick(s, "model", "modelName", "checkpoint", "sd_model", "sd_model_hash")], ...(showCap("samplers") ? [[intl.formatMessage(msgs.dSampler), pick(s, "sampler", "samplerName", "sampler_name", "scheduler")]] : []), ...(showCap("steps") ? [[intl.formatMessage(msgs.dSteps), pick(s, "steps", "numSteps")]] : []), ...(showCap("cfg") ? [[intl.formatMessage(msgs.dCfg), pick(s, "cfg", "cfgScale", "cfg_scale", "guidance", "guidanceScale")]] : []), [intl.formatMessage(msgs.dSize), size], ...(showCap("seed") ? [[intl.formatMessage(msgs.dSeed), pick(s, "seed")]] : []), [intl.formatMessage(msgs.dSaved), saved], [intl.formatMessage(msgs.dFile), item.file]];
const shownKeys = new Set(["width", "height", "model", "modelName", "checkpoint", "sd_model", "sd_model_hash", "sampler", "samplerName", "sampler_name", "scheduler", "steps", "numSteps", "cfg", "cfgScale", "cfg_scale", "guidance", "guidanceScale", "seed", "negativePrompt", "prompt", "mode"]);
const restSettings = Object.entries(s).filter(([k, v]) => !shownKeys.has(k) && !REST_DROP.has(k) && v !== null && v !== "" && typeof v !== "object");
const markdown = toMarkdown(p.final || p.roll, n.final, details);
const rawJson = item.meta ? JSON.stringify(item.meta, null, 2) : "";
const onConvert = e => {
const fmt = e.target.value;
e.target.selectedIndex = 0;
if (!fmt) return;
const a = document.createElement("a");
a.href = convertUrl(item.file, fmt);
a.download = `${item.name || item.file}.${fmt}`;
document.body.appendChild(a);
a.click();
a.remove();
};
// --- Resize control (ImageMagick down/up-scale + provider AI Upscale) ---
const magickOk = magick.available;
// Providers that ship an AI upscale adapter; a key-gated one is offered but disabled until keyed.
const upscalers = providers.filter(pp => pp.capabilities?.upscale && pp.loadUpscale).map(pp => ({
id: pp.id,
label: pp.label,
ready: !pp.needsKey || !!effectiveKey(pp.id, settings)
}));
const hasUpscalers = upscalers.length > 0;
const onResizePick = e => {
const val = e.target.value;
e.target.selectedIndex = 0;
if (!val) return;
const [kind, raw] = val.split(":");
if (kind === "mag" && onResize) onResize(item, Number(raw));else if (kind === "ai" && onUpscale) onUpscale(item, raw);
};
return /*#__PURE__*/_jsxDEV("div", {
className: "gallery-view",
children: /*#__PURE__*/_jsxDEV("div", {
className: "g-inner",
children: /*#__PURE__*/_jsxDEV("div", {
className: "g-single",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "g-single-bar",
children: [/*#__PURE__*/_jsxDEV("button", {
className: "g-back",
onClick: onBack,
title: intl.formatMessage(msgs.backTitle),
children: intl.formatMessage(msgs.back, {
target: returnLabel || intl.formatMessage(msgs.galleryFallback)
})
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "g-single-nav",
children: [/*#__PURE__*/_jsxDEV("button", {
onClick: () => onNavigate(items[index - 1]),
disabled: !hasPrev,
title: intl.formatMessage(msgs.prevTitle),
children: intl.formatMessage(msgs.prev)
}, void 0, false), index >= 0 && /*#__PURE__*/_jsxDEV("span", {
className: "g-single-pos",
children: [index + 1, " / ", total]
}, void 0, true), /*#__PURE__*/_jsxDEV("button", {
onClick: () => onNavigate(items[index + 1]),
disabled: !hasNext,
title: intl.formatMessage(msgs.nextTitle),
children: intl.formatMessage(msgs.next)
}, void 0, false)]
}, void 0, true)]
}, void 0, true), /*#__PURE__*/_jsxDEV("div", {
className: "g-single-body",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "g-single-left",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "g-single-img",
children: [/*#__PURE__*/_jsxDEV("a", {
href: item.path,
target: "_blank",
rel: "noreferrer",
title: intl.formatMessage(msgs.openFull),
children: /*#__PURE__*/_jsxDEV("img", {
src: item.path,
alt: promptText(item) || item.file
}, void 0, false)
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "img-actions g-img-actions",
children: [onDisk && /*#__PURE__*/_jsxDEV("button", {
type: "button",
title: intl.formatMessage(msgs.openDefault),
"aria-label": intl.formatMessage(msgs.open),
onClick: () => openImageFile(item.path),
children: "↗"
}, void 0, false), onDisk && /*#__PURE__*/_jsxDEV("button", {
type: "button",
title: intl.formatMessage(msgs.revealTitle),
"aria-label": intl.formatMessage(msgs.reveal),
onClick: () => revealImageFile(item.path),
children: "⌖"
}, void 0, false), /*#__PURE__*/_jsxDEV("a", {
className: "ia-dl",
href: item.path,
download: item.file,
title: intl.formatMessage(msgs.downloadPng),
"aria-label": intl.formatMessage(msgs.downloadPng),
children: "⤓"
}, void 0, false), onDisk && /*#__PURE__*/_jsxDEV("button", {
type: "button",
className: "ia-del",
title: intl.formatMessage(msgs.deleteTitle),
"aria-label": intl.formatMessage(msgs.delete),
onClick: () => onDelete(item),
children: "✕"
}, void 0, false)]
}, void 0, true)]
}, void 0, true), /*#__PURE__*/_jsxDEV(DerivedStrips, {
item: item,
items: items,
derivations: derivations,
onNavigate: onNavigate
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("div", {
className: "g-single-meta",
children: [onDisk && /*#__PURE__*/_jsxDEV("div", {
className: "g-actions",
children: [/*#__PURE__*/_jsxDEV("span", {
className: `g-tool${magickOk ? "" : " is-locked"}`,
children: [/*#__PURE__*/_jsxDEV("select", {
className: "g-convert",
defaultValue: "",
onChange: onConvert,
title: magickOk ? intl.formatMessage(msgs.convertTitle) : intl.formatMessage(msgs.convertLocked),
children: [/*#__PURE__*/_jsxDEV("option", {
value: "",
children: intl.formatMessage(msgs.convertOption)
}, void 0, false), magickOk ? magick.formats.map(f => /*#__PURE__*/_jsxDEV("option", {
value: f,
children: f.toUpperCase()
}, f, false)) : /*#__PURE__*/_jsxDEV("option", {
value: "",
disabled: true,
children: intl.formatMessage(msgs.needsMagickOpt)
}, void 0, false)]
}, void 0, true), !magickOk && /*#__PURE__*/_jsxDEV("span", {
className: "g-tool-lock",
"aria-hidden": "true",
children: "🔒"
}, void 0, false)]
}, void 0, true), onResize && /*#__PURE__*/_jsxDEV("span", {
className: `g-tool${magickOk || hasUpscalers ? "" : " is-locked"}`,
children: [/*#__PURE__*/_jsxDEV("select", {
className: "g-resize",
defaultValue: "",
onChange: onResizePick,
title: magickOk ? intl.formatMessage(msgs.resizeTitle) : intl.formatMessage(msgs.resizeLocked),
children: [/*#__PURE__*/_jsxDEV("option", {
value: "",
children: intl.formatMessage(msgs.resizeOption)
}, void 0, false), /*#__PURE__*/_jsxDEV("optgroup", {
label: intl.formatMessage(msgs.resizeGroupMagick),
children: RESIZE_SCALES.map(sc => {
const lbl = FRAC[sc] || `${sc}×`;
const base = sc < 1 ? intl.formatMessage(msgs.resizeDown, {
label: lbl
}) : intl.formatMessage(msgs.resizeUp, {
label: lbl
});
return /*#__PURE__*/_jsxDEV("option", {
value: `mag:${sc}`,
disabled: !magickOk,
children: magickOk ? base : intl.formatMessage(msgs.resizeNeedsMagick, {
label: lbl
})
}, sc, false);
})
}, void 0, false), /*#__PURE__*/_jsxDEV("optgroup", {
label: intl.formatMessage(msgs.resizeGroupAi),
children: hasUpscalers ? upscalers.map(u => /*#__PURE__*/_jsxDEV("option", {
value: `ai:${u.id}`,
disabled: !u.ready,
children: u.ready ? intl.formatMessage(msgs.aiUpscale, {
provider: u.label
}) : intl.formatMessage(msgs.aiNeedsKey, {
provider: u.label
})
}, u.id, false)) : /*#__PURE__*/_jsxDEV("option", {
value: "ai:none",
disabled: true,
children: intl.formatMessage(msgs.aiUpscaleNone)
}, void 0, false)
}, void 0, false)]
}, void 0, true), !magickOk && !hasUpscalers && /*#__PURE__*/_jsxDEV("span", {
className: "g-tool-lock",
"aria-hidden": "true",
children: "🔒"
}, void 0, false)]
}, void 0, true)]
}, void 0, true), !item.meta && /*#__PURE__*/_jsxDEV("p", {
className: "g-note",
children: /*#__PURE__*/_jsxDEV(FormattedMessage, {
id: "single.noMeta",
defaultMessage: "No metadata sidecar was found for this image — it may pre-date sidecars or its <code>.json</code> file was removed.",
values: {
code: chunks => /*#__PURE__*/_jsxDEV("code", {
children: chunks
}, void 0, false)
}
}, void 0, false)
}, void 0, false), /*#__PURE__*/_jsxDEV(LineageHead, {
item: item,
items: items,
onNavigate: onNavigate
}, void 0, false), deriveError && /*#__PURE__*/_jsxDEV("p", {
className: "g-card-err",
children: intl.formatMessage(msgs.deriveError, {
error: deriveError
})
}, void 0, false), /*#__PURE__*/_jsxDEV(PromptCard, {
title: intl.formatMessage(msgs.promptTitle),
layers: p,
extrasFor: extrasFor
}, void 0, false), /*#__PURE__*/_jsxDEV(PromptCard, {
title: intl.formatMessage(msgs.negativeTitle),
layers: n
}, void 0, false), (item.meta || details.some(([, v]) => v !== undefined && v !== null && v !== "")) && /*#__PURE__*/_jsxDEV("section", {
className: "g-card",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "g-card-head",
children: [/*#__PURE__*/_jsxDEV("h3", {
className: "g-card-title",
children: intl.formatMessage(msgs.details)
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "g-card-actions",
children: [item.meta && /*#__PURE__*/_jsxDEV("button", {
className: `g-card-action${rawView ? " on" : ""}`,
onClick: () => setRawView(v => !v),
children: intl.formatMessage(rawView ? msgs.viewTable : msgs.viewRaw)
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "g-copy-menu",
children: [/*#__PURE__*/_jsxDEV("button", {
className: `g-card-action g-copy-menu-btn${copyOpen ? " on" : ""}`,
onClick: () => setCopyOpen(o => !o),
"aria-haspopup": "menu",
"aria-expanded": copyOpen,
"aria-label": intl.formatMessage(msgs.copyMenu),
title: intl.formatMessage(msgs.copyMenu),
children: /*#__PURE__*/_jsxDEV(MoreIcon, {}, void 0, false)
}, void 0, false), copyOpen && /*#__PURE__*/_jsxDEV(_Fragment, {
children: [/*#__PURE__*/_jsxDEV("div", {
className: "g-copy-scrim",
onClick: () => setCopyOpen(false),
"aria-hidden": "true"
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "g-copy-pop",
role: "menu",
"aria-label": intl.formatMessage(msgs.copyMenu),
children: [/*#__PURE__*/_jsxDEV(CopyButton, {
label: intl.formatMessage(msgs.copyMd),
title: intl.formatMessage(msgs.copyMdTitle),
text: markdown
}, void 0, false), item.meta && /*#__PURE__*/_jsxDEV(CopyButton, {
label: intl.formatMessage(msgs.copyJson),
title: intl.formatMessage(msgs.copyJsonTitle),
text: rawJson
}, void 0, false)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true), rawView && item.meta ? /*#__PURE__*/_jsxDEV("pre", {
className: "g-json",
dangerouslySetInnerHTML: {
__html: syntaxHighlightJson(rawJson)
}
}, void 0, false) : /*#__PURE__*/_jsxDEV(_Fragment, {
children: [/*#__PURE__*/_jsxDEV(DetailTable, {
rows: details
}, void 0, false), restSettings.length > 0 && /*#__PURE__*/_jsxDEV("details", {
className: "g-more",
children: [/*#__PURE__*/_jsxDEV("summary", {
children: intl.formatMessage(msgs.allSettings, {
count: restSettings.length
})
}, void 0, false), /*#__PURE__*/_jsxDEV(DetailTable, {
rows: restSettings.map(([k, v]) => [k, String(v)])
}, void 0, false)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true), /*#__PURE__*/_jsxDEV(KeywordsCard, {
text: p.final || p.roll,
saved: Array.isArray(m.keywords) ? m.keywords : null,
item: item,
settings: settings,
onSearch: onSearch,
onSaved: meta => onMetaUpdate?.(item.path, meta)
}, void 0, false)]
}, void 0, true)]
}, void 0, true)]
}, void 0, true)
}, void 0, false)
}, void 0, false);
}