refactor(materials): upgrade image snapshot inspector to wide studio modal with direct PNG workflow parser and dedicated scroll container
This commit is contained in:
@@ -90,6 +90,11 @@ export const i18n = {
|
||||
materialNodeApplied: '素材参数已应用',
|
||||
materialKeyParams: '核心生成参数',
|
||||
materialPromptText: '提示词',
|
||||
materialPositivePrompt: '正向提示词 (Positive)',
|
||||
materialNegativePrompt: '负向提示词 (Negative)',
|
||||
materialViewOriginal: '🔍 查看大图',
|
||||
materialDenoise: '降噪',
|
||||
materialScheduler: '调度',
|
||||
materialReferencedModels: '引用的模型清单 ({count})',
|
||||
materialIncludedNodes: '包含的节点清单 ({count})',
|
||||
materialExpandAll: '展开全部',
|
||||
@@ -874,6 +879,11 @@ export const i18n = {
|
||||
materialNodeApplied: 'Material parameters applied',
|
||||
materialKeyParams: 'Key Parameters',
|
||||
materialPromptText: 'Prompt',
|
||||
materialPositivePrompt: 'Positive Prompt',
|
||||
materialNegativePrompt: 'Negative Prompt',
|
||||
materialViewOriginal: '🔍 Full View',
|
||||
materialDenoise: 'Denoise',
|
||||
materialScheduler: 'Scheduler',
|
||||
materialReferencedModels: 'Referenced Models ({count})',
|
||||
materialIncludedNodes: 'Included Nodes ({count})',
|
||||
materialExpandAll: 'Expand',
|
||||
|
||||
+232
-50
@@ -160,42 +160,221 @@ export async function showMaterials() {
|
||||
await this.refreshMaterials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct client-side PNG chunk parser to safely extract embedded ComfyUI metadata
|
||||
* without requiring a server reboot or server-side re-encoding.
|
||||
*/
|
||||
async function parsePngMetadataFromUrl(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const buffer = await response.arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
if (view.byteLength < 32) return null;
|
||||
// PNG magic bytes: 137, 80, 78, 71, 13, 10, 26, 10
|
||||
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) return null;
|
||||
|
||||
let offset = 8;
|
||||
const utf8 = new TextDecoder('utf-8');
|
||||
const latin1 = new TextDecoder('iso-8859-1');
|
||||
const result = {};
|
||||
|
||||
while (offset + 8 <= buffer.byteLength) {
|
||||
const length = view.getUint32(offset);
|
||||
const type = String.fromCharCode(
|
||||
view.getUint8(offset + 4),
|
||||
view.getUint8(offset + 5),
|
||||
view.getUint8(offset + 6),
|
||||
view.getUint8(offset + 7),
|
||||
);
|
||||
const dataOffset = offset + 8;
|
||||
if (type === 'IDAT') break; // Reached image raster data, text metadata is prior
|
||||
|
||||
if (dataOffset + length > buffer.byteLength) break;
|
||||
|
||||
if (type === 'tEXt') {
|
||||
const bytes = new Uint8Array(buffer, dataOffset, length);
|
||||
const nullIdx = bytes.indexOf(0);
|
||||
if (nullIdx > -1) {
|
||||
const key = latin1.decode(bytes.subarray(0, nullIdx));
|
||||
const val = latin1.decode(bytes.subarray(nullIdx + 1));
|
||||
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
|
||||
}
|
||||
} else if (type === 'iTXt') {
|
||||
const bytes = new Uint8Array(buffer, dataOffset, length);
|
||||
const nullIdx = bytes.indexOf(0);
|
||||
if (nullIdx > -1) {
|
||||
const key = latin1.decode(bytes.subarray(0, nullIdx));
|
||||
let ptr = nullIdx + 1;
|
||||
const compFlag = bytes[ptr++];
|
||||
const compMethod = bytes[ptr++];
|
||||
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
|
||||
ptr++;
|
||||
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
|
||||
ptr++;
|
||||
if (compFlag === 0 && ptr <= bytes.length) {
|
||||
const val = utf8.decode(bytes.subarray(ptr));
|
||||
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += 12 + length;
|
||||
}
|
||||
return result.workflow || result.prompt || null;
|
||||
} catch (e) {
|
||||
console.warn('Client-side PNG metadata read skipped:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract generation parameters and full prompts from a ComfyUI workflow JSON.
|
||||
*/
|
||||
function extractWorkflowDetails(workflow) {
|
||||
const params = {};
|
||||
const positivePrompts = [];
|
||||
const negativePrompts = [];
|
||||
const allPrompts = [];
|
||||
|
||||
if (!workflow || typeof workflow !== 'object') {
|
||||
return { params, positivePrompts, negativePrompts, allPrompts };
|
||||
}
|
||||
|
||||
const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
const ntype = String(node.type || '').trim();
|
||||
const ntypeLower = ntype.toLowerCase();
|
||||
const widgets = Array.isArray(node.widgets_values) ? node.widgets_values : [];
|
||||
|
||||
if (ntypeLower === 'ksampler' || ntypeLower === 'ksampleradvanced') {
|
||||
const isAdv = ntypeLower === 'ksampleradvanced';
|
||||
const offset = isAdv ? 1 : 0;
|
||||
if (widgets[2 + offset] != null && params.steps == null) params.steps = widgets[2 + offset];
|
||||
if (widgets[3 + offset] != null && params.cfg == null) params.cfg = widgets[3 + offset];
|
||||
if (widgets[4 + offset] != null && params.sampler_name == null) params.sampler_name = widgets[4 + offset];
|
||||
if (widgets[5 + offset] != null && params.scheduler == null) params.scheduler = widgets[5 + offset];
|
||||
if (widgets[6 + offset] != null && params.denoise == null) params.denoise = widgets[6 + offset];
|
||||
} else if (ntypeLower === 'emptylatentimage') {
|
||||
if (widgets[0] && widgets[1] && params.resolution == null) {
|
||||
params.resolution = `${widgets[0]} × ${widgets[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (ntypeLower.includes('cliptextencode') || ntypeLower.includes('prompt')) {
|
||||
const nodeTitle = String(node.title || ntype).toLowerCase();
|
||||
for (const val of widgets) {
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
const textVal = val.trim();
|
||||
if (!allPrompts.includes(textVal)) allPrompts.push(textVal);
|
||||
if (nodeTitle.includes('neg') || nodeTitle.includes('负向')) {
|
||||
if (!negativePrompts.includes(textVal)) negativePrompts.push(textVal);
|
||||
} else {
|
||||
if (!positivePrompts.includes(textVal)) positivePrompts.push(textVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { params, positivePrompts, negativePrompts, allPrompts };
|
||||
}
|
||||
|
||||
export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'anomalous-material-detail-overlay';
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'anomalous-material-detail-dialog';
|
||||
|
||||
const close = text(dialog, 'button', '×', 'anomalous-material-detail-close');
|
||||
close.type = 'button';
|
||||
close.onclick = () => overlay.remove();
|
||||
|
||||
// 1. Left Media Stage
|
||||
const media = document.createElement('div');
|
||||
media.className = 'anomalous-material-detail-media';
|
||||
const image = document.createElement('img');
|
||||
image.src = imageUrl;
|
||||
image.alt = t('materialImageDetail');
|
||||
media.appendChild(image);
|
||||
|
||||
const mediaBar = document.createElement('div');
|
||||
mediaBar.className = 'anomalous-material-media-bar';
|
||||
const mediaResText = text(mediaBar, 'span', sourceImage?.filename || 'PNG Image');
|
||||
const fullViewBtn = text(mediaBar, 'button', t('materialViewOriginal') || '🔍 查看大图', 'anomalous-material-mini-btn');
|
||||
fullViewBtn.type = 'button';
|
||||
fullViewBtn.onclick = () => owner?.showGalleryViewer?.(imageUrl);
|
||||
media.appendChild(mediaBar);
|
||||
|
||||
// 2. Right Inspector Panel with 3-Tier Layout
|
||||
const side = document.createElement('div');
|
||||
side.className = 'anomalous-material-detail-side';
|
||||
text(side, 'h2', t('materialImageDetail'));
|
||||
const loading = text(side, 'p', t('materialInspecting'), 'anomalous-material-muted');
|
||||
|
||||
// Tier 1: Fixed Header
|
||||
const sideHeader = document.createElement('div');
|
||||
sideHeader.className = 'anomalous-material-side-header';
|
||||
text(sideHeader, 'h2', t('materialImageDetail'));
|
||||
text(sideHeader, 'p', t('materialSnapshotExplanation'));
|
||||
side.appendChild(sideHeader);
|
||||
|
||||
// Tier 2: Scrollable Body (Independent scroll container)
|
||||
const sideBody = document.createElement('div');
|
||||
sideBody.className = 'anomalous-material-side-body';
|
||||
const loading = text(sideBody, 'p', t('materialInspecting'), 'anomalous-material-muted');
|
||||
side.appendChild(sideBody);
|
||||
|
||||
// Tier 3: Sticky Footer
|
||||
const sideFooter = document.createElement('div');
|
||||
sideFooter.className = 'anomalous-material-side-footer';
|
||||
|
||||
const label = text(sideFooter, 'label', t('materialName'));
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.maxLength = 120;
|
||||
nameInput.placeholder = t('materialName');
|
||||
label.appendChild(nameInput);
|
||||
|
||||
text(sideFooter, 'small', t('materialSaveHint'), 'anomalous-material-muted');
|
||||
const saveBtn = text(sideFooter, 'button', `💾 ${t('materialSaveSnapshot')}`, 'anomalous-btn-primary');
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.disabled = true;
|
||||
side.appendChild(sideFooter);
|
||||
|
||||
dialog.append(media, side);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', event => { if (event.target === overlay) overlay.remove(); });
|
||||
|
||||
// Load data: parallel fetch from backend inspection AND direct PNG client-side parse
|
||||
try {
|
||||
const response = await fetch('/anomalous/inspect_image_material', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_image: sourceImage }),
|
||||
});
|
||||
const payload = await jsonResponse(response, 'material inspection failed');
|
||||
if (payload.status !== 'success') throw new Error(payload.message || 'material inspection failed');
|
||||
loading.remove();
|
||||
text(side, 'p', t('materialSnapshotExplanation'), 'anomalous-material-muted');
|
||||
const [inspectPayload, clientWorkflow] = await Promise.all([
|
||||
fetch('/anomalous/inspect_image_material', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_image: sourceImage }),
|
||||
}).then(r => jsonResponse(r, 'material inspection failed')),
|
||||
parsePngMetadataFromUrl(imageUrl).catch(() => null),
|
||||
]);
|
||||
|
||||
// 1. Key generation parameters (Steps, CFG, Sampler, Resolution)
|
||||
const params = payload.params || {};
|
||||
loading.remove();
|
||||
saveBtn.disabled = false;
|
||||
|
||||
const clientDetails = extractWorkflowDetails(clientWorkflow);
|
||||
|
||||
// Merge generation parameters
|
||||
const params = {
|
||||
...(clientDetails.params || {}),
|
||||
...(inspectPayload.params || {}),
|
||||
};
|
||||
|
||||
if (params.resolution) {
|
||||
mediaResText.textContent = `${params.resolution} · ${sourceImage?.filename || 'PNG'}`;
|
||||
}
|
||||
|
||||
// Section A: Essential Metric Grid
|
||||
const hasSamplingParams = params.steps || params.cfg || params.sampler_name || params.resolution;
|
||||
if (hasSamplingParams) {
|
||||
const metricGrid = document.createElement('div');
|
||||
@@ -214,28 +393,26 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
|
||||
addMetric('CFG', params.cfg);
|
||||
const samplerFull = [params.sampler_name, params.scheduler].filter(Boolean).join(' / ');
|
||||
addMetric(t('recipeCardSpecsSampler') || '采样', samplerFull);
|
||||
if (params.denoise != null) addMetric(t('materialDenoise') || '降噪', params.denoise);
|
||||
addMetric(t('recipeCardSpecsResolution') || '尺寸', params.resolution);
|
||||
|
||||
if (metricGrid.childElementCount) side.appendChild(metricGrid);
|
||||
if (metricGrid.childElementCount) sideBody.appendChild(metricGrid);
|
||||
}
|
||||
|
||||
// 2. Prompt Preview Card with copy and expand
|
||||
const promptFullText = (Array.isArray(payload.prompts) && payload.prompts.length)
|
||||
? payload.prompts.join('\n\n')
|
||||
: (payload.prompt_excerpt || '');
|
||||
|
||||
if (promptFullText) {
|
||||
// Section B: Prompts (Full, un-truncated prompts)
|
||||
const renderPromptCard = (titleText, promptStr) => {
|
||||
if (!promptStr || !promptStr.trim()) return;
|
||||
const promptCard = document.createElement('div');
|
||||
promptCard.className = 'anomalous-material-prompt-card';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'anomalous-material-prompt-bar';
|
||||
text(bar, 'span', t('materialPromptText') || '提示词', 'anomalous-material-prompt-bar-label');
|
||||
text(bar, 'span', titleText, 'anomalous-material-prompt-bar-label');
|
||||
|
||||
const btns = document.createElement('div');
|
||||
btns.className = 'anomalous-material-prompt-bar-btns';
|
||||
|
||||
if (promptFullText.length > 80 || promptFullText.includes('\n')) {
|
||||
if (promptStr.length > 90 || promptStr.includes('\n')) {
|
||||
const expandBtn = text(btns, 'button', t('materialExpandAll') || '展开', 'anomalous-material-mini-btn');
|
||||
expandBtn.type = 'button';
|
||||
expandBtn.onclick = () => {
|
||||
@@ -247,7 +424,7 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
|
||||
const copyBtn = text(btns, 'button', t('materialCopyPrompt') || '📋 复制', 'anomalous-material-mini-btn');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(promptFullText).then(() => {
|
||||
navigator.clipboard.writeText(promptStr).then(() => {
|
||||
copyBtn.textContent = t('materialCopied') || '✅ 已复制';
|
||||
setTimeout(() => { copyBtn.textContent = t('materialCopyPrompt') || '📋 复制'; }, 1500);
|
||||
});
|
||||
@@ -256,12 +433,25 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
|
||||
bar.appendChild(btns);
|
||||
promptCard.appendChild(bar);
|
||||
|
||||
const promptContent = text(promptCard, 'div', promptFullText, 'anomalous-material-prompt-content');
|
||||
side.appendChild(promptCard);
|
||||
const promptContent = text(promptCard, 'div', promptStr, 'anomalous-material-prompt-content');
|
||||
sideBody.appendChild(promptCard);
|
||||
};
|
||||
|
||||
const posText = clientDetails.positivePrompts.join('\n\n');
|
||||
const negText = clientDetails.negativePrompts.join('\n\n');
|
||||
|
||||
if (posText || negText) {
|
||||
if (posText) renderPromptCard(t('materialPositivePrompt') || '正向提示词', posText);
|
||||
if (negText) renderPromptCard(t('materialNegativePrompt') || '负向提示词', negText);
|
||||
} else {
|
||||
const fallbackText = (Array.isArray(inspectPayload.prompts) && inspectPayload.prompts.length)
|
||||
? inspectPayload.prompts.join('\n\n')
|
||||
: (inspectPayload.prompt_excerpt || '');
|
||||
if (fallbackText) renderPromptCard(t('materialPromptText') || '提示词', fallbackText);
|
||||
}
|
||||
|
||||
// 3. Collapsible: Referenced Models
|
||||
const models = Array.isArray(payload.model_references) ? payload.model_references : [];
|
||||
// Section C: Referenced Models (Collapsible)
|
||||
const models = Array.isArray(inspectPayload.model_references) ? inspectPayload.model_references : [];
|
||||
if (models.length) {
|
||||
const modelDetails = document.createElement('details');
|
||||
modelDetails.className = 'anomalous-material-accordion';
|
||||
@@ -283,11 +473,11 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
|
||||
modelContent.appendChild(row);
|
||||
}
|
||||
modelDetails.appendChild(modelContent);
|
||||
side.appendChild(modelDetails);
|
||||
sideBody.appendChild(modelDetails);
|
||||
}
|
||||
|
||||
// 4. Collapsible: Included Nodes
|
||||
const blocks = Array.isArray(payload.node_blocks) ? payload.node_blocks : [];
|
||||
// Section D: Included Nodes (Collapsible)
|
||||
const blocks = Array.isArray(inspectPayload.node_blocks) ? inspectPayload.node_blocks : [];
|
||||
if (blocks.length) {
|
||||
const nodeDetails = document.createElement('details');
|
||||
nodeDetails.className = 'anomalous-material-accordion';
|
||||
@@ -316,29 +506,20 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
|
||||
}
|
||||
nodeContent.appendChild(chipFlow);
|
||||
nodeDetails.appendChild(nodeContent);
|
||||
side.appendChild(nodeDetails);
|
||||
sideBody.appendChild(nodeDetails);
|
||||
}
|
||||
|
||||
// 5. Name input and save action
|
||||
const label = text(side, 'label', t('materialName'));
|
||||
const name = document.createElement('input');
|
||||
name.type = 'text';
|
||||
name.maxLength = 120;
|
||||
name.value = payload.suggested_name || '';
|
||||
label.appendChild(name);
|
||||
const hint = text(side, 'small', t('materialSaveHint'), 'anomalous-material-muted');
|
||||
hint.style.marginTop = 'auto';
|
||||
const save = text(side, 'button', `💾 ${t('materialSaveSnapshot')}`, 'anomalous-btn-primary');
|
||||
save.type = 'button';
|
||||
save.onclick = async () => {
|
||||
const value = name.value.trim();
|
||||
if (!value) { name.focus(); return; }
|
||||
save.disabled = true;
|
||||
// Tier 3 Setup: Name Input & Save
|
||||
nameInput.value = inspectPayload.suggested_name || '';
|
||||
saveBtn.onclick = async () => {
|
||||
const val = nameInput.value.trim();
|
||||
if (!val) { nameInput.focus(); return; }
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const saveResponse = await fetch('/anomalous/save_image_material', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_image: sourceImage, name: value }),
|
||||
body: JSON.stringify({ source_image: sourceImage, name: val }),
|
||||
});
|
||||
const savePayload = await jsonResponse(saveResponse, 'material save failed');
|
||||
if (savePayload.status !== 'success') throw new Error(savePayload.message || 'material save failed');
|
||||
@@ -348,11 +529,12 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
|
||||
} catch (error) {
|
||||
console.error('Could not save image material:', error);
|
||||
await anomalousAlert(t('materialSaveError'));
|
||||
save.disabled = false;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
name.focus();
|
||||
name.select();
|
||||
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
} catch (error) {
|
||||
console.error('Could not inspect image material:', error);
|
||||
loading.textContent = t('materialInspectError');
|
||||
|
||||
+164
-30
@@ -3651,6 +3651,7 @@ dialog.anomalous-gallery-viewer[open] {
|
||||
.anomalous-material-card-actions { display: flex; gap: 8px; margin-top: auto; padding: 0 14px 14px; }
|
||||
.anomalous-material-card-actions .anomalous-btn-primary { flex: 1; }
|
||||
|
||||
/* Professional Studio Material Detail Modal */
|
||||
.anomalous-material-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -3659,51 +3660,184 @@ dialog.anomalous-gallery-viewer[open] {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(3,4,7,.82);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(3, 4, 7, 0.88);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.anomalous-material-detail-dialog {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, .65fr);
|
||||
width: min(1100px, 94vw);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(440px, 1fr);
|
||||
width: min(1380px, 96vw);
|
||||
height: min(880px, 92vh);
|
||||
max-height: 92vh;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
background: #181a20;
|
||||
box-shadow: 0 30px 90px rgba(0,0,0,.65);
|
||||
background: #111216;
|
||||
box-shadow: 0 32px 100px rgba(0, 0, 0, 0.8);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.anomalous-material-detail-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(255,255,255,.16);
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 10;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 50%;
|
||||
background: rgba(12,13,16,.75);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
background: rgba(18, 20, 26, 0.7);
|
||||
color: #cbd5e1;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.anomalous-material-detail-media { min-height: 420px; overflow: hidden; background: #090a0d; }
|
||||
.anomalous-material-detail-media img { width: 100%; height: 100%; max-height: 90vh; object-fit: contain; }
|
||||
.anomalous-material-detail-side { display: flex; min-height: 0; flex-direction: column; gap: 14px; padding: 32px 28px 26px; overflow-y: auto; }
|
||||
.anomalous-material-detail-side h2 { margin: 0; }
|
||||
.anomalous-material-detail-side label { display: grid; gap: 7px; color: #cbd0dc; font-size: 12px; font-weight: 700; }
|
||||
.anomalous-material-detail-side input { padding: 10px 12px; border: 1px solid #494d59; border-radius: 8px; background: #111217; color: #fff; }
|
||||
.anomalous-material-detail-stats { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.anomalous-material-detail-stats span { padding: 6px 9px; border-radius: 999px; background: rgba(78,201,176,.12); color: #78d7c5; font-size: 11px; }
|
||||
.anomalous-material-prompt-excerpt { margin: 0; padding: 12px; border-left: 3px solid #5ccab4; background: rgba(255,255,255,.035); color: #c7ccd7; font-size: 12px; }
|
||||
.anomalous-material-detail-close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.anomalous-material-detail-dialog { grid-template-columns: 1fr; overflow-y: auto; }
|
||||
.anomalous-material-detail-media { min-height: 260px; max-height: 42vh; }
|
||||
/* Left Media Showcase */
|
||||
.anomalous-material-detail-media {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #07080b;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.anomalous-material-detail-media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.anomalous-material-media-bar {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
border-radius: 9px;
|
||||
background: rgba(14, 16, 22, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
font-size: 0.76rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Right Inspector Panel - 3-Tier Layout (Fixed Header + Scroll Body + Sticky Footer) */
|
||||
.anomalous-material-detail-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #14161c;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.anomalous-material-side-header {
|
||||
padding: 22px 24px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.anomalous-material-side-header h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.22rem;
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.anomalous-material-side-header p {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Scrollable Body */
|
||||
.anomalous-material-side-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.anomalous-material-side-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.anomalous-material-side-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.anomalous-material-side-body::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
/* Sticky Footer */
|
||||
.anomalous-material-side-footer {
|
||||
padding: 14px 24px 18px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #111318;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.anomalous-material-side-footer label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.anomalous-material-side-footer input {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #ffffff;
|
||||
font-size: 0.82rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.anomalous-material-side-footer input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.anomalous-material-detail-dialog {
|
||||
grid-template-columns: 1fr;
|
||||
height: 94vh;
|
||||
}
|
||||
.anomalous-material-detail-media {
|
||||
min-height: 280px;
|
||||
max-height: 40vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure wide widgets (like long prompts) span full width in the node cards */
|
||||
|
||||
Reference in New Issue
Block a user