Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_spotlight_tour.js
T

526 lines
20 KiB
JavaScript
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.
/**
* ui_spotlight_tour.js - Interactive Spotlight Mask Tour for Anomalous Model Browser
*
* Provides a comfortable, smooth, and pleasant guided walkthrough:
* 1. Dark translucent backdrop with smooth gliding spotlight cutout (box-shadow).
* 2. Floating directional speech bubble card explaining key buttons step-by-step.
* 3. Keyboard navigation (ArrowRight/Enter, ArrowLeft, Escape) & viewport auto-scroll.
* 4. Zero CSS-bundle modifications (injected scoped stylesheet).
*/
import { translate as t } from './locales.js';
import { text } from './ui_dom.js';
let activeTourInstance = null;
const TOUR_STEPS = Object.freeze([
{
id: 'workspaces',
targetSelector: '#anomalous-models-btn',
fallbackSelector: '.anomalous-header-left',
icon: '🏠',
titleZh: '顶栏工作区切换',
titleEn: 'Workspace Navigation',
bodyZh: '用于在模型库、图库、工作流配方工坊和素材库之间切换。各个页面保持独立的视图状态与筛选条件。',
bodyEn: 'Switch between Model Browser, Gallery, Recipe Studio, and Material Library. Each maintains independent view and filter states.',
position: 'bottom',
},
{
id: 'update-notice',
targetSelector: '#anomalous-update-notice-btn',
icon: '💡',
titleZh: '(!) 更新引导与停靠设置',
titleEn: '(!) Update Guide & Docking',
bodyZh: '点击 (!) 可查看关键改动说明与本导览;右侧的 ◧ 按钮用于在 ComfyUI 侧边吸附模式与独立浮动窗口之间切换。',
bodyEn: 'Click (!) to review changes and launch this tour. The ◧ icon toggles sidebar docking vs a free-floating window.',
position: 'bottom',
},
{
id: 'scan',
targetSelector: '#anomalous-scan-btn',
icon: '🎯',
titleZh: '🎯 扫描 (向导与单模型直扫)',
titleEn: '🎯 Model Scanning',
bodyZh: '点击此按钮可打开扫描向导,对模型目录建立索引与哈希。在模型网格中悬浮卡片点击雷达图标,则仅原地扫描该单个模型。',
bodyEn: 'Click to open the scan wizard for folder indexing. You can also hover over any model card and click the radar icon to scan only that model.',
position: 'top',
},
{
id: 'doctor',
targetSelector: '#anomalous-doctor-btn',
icon: '🩺',
titleZh: '🩺 模型医生 (节点路径修复)',
titleEn: '🩺 Model Doctor (Node Repair)',
bodyZh: '用于排查当前画布中因模型缺失而报错的节点。系统通过文件的 SHA256 哈希值与文件名,匹配本地现有模型并执行路径替换。',
bodyEn: 'Inspects red error nodes in the active graph caused by missing models, matching local files by SHA256 hash and filename for replacement.',
position: 'top',
},
{
id: 'assistant',
targetSelector: '#anomalous-assistant-btn',
icon: '🤖',
titleZh: '🤖 节点助手 (模型替换与参数注入)',
titleEn: '🤖 Node Assistant',
bodyZh: '选中画布节点后,可在动作页直接更换该节点的模型或追加 LoRA;在参数方案页可将配方保存的采样器、步数等参数写入该节点。',
bodyEn: 'Select a canvas node to swap its model or insert a LoRA via Actions, or apply saved sampler/step parameters via Parameter Presets.',
position: 'top',
},
{
id: 'materials',
targetSelector: '#anomalous-materials-btn',
icon: '✨',
titleZh: '✨ 素材库 (资产归档与画布拖拽)',
titleEn: '✨ Material Library',
bodyZh: '用于管理已归档的图片、提示词与工作流片段。按住卡片拖拽到画布节点上可注入对应参数;拖拽到画布空白处可直接加载该工作流。',
bodyEn: 'Manages saved images, prompts, and workflow snippets. Drag a card onto a canvas node to inject values, or drop on empty canvas to load the workflow.',
position: 'top',
},
{
id: 'toolbox',
targetSelector: '#anomalous-toolbox-btn',
icon: '🧰',
titleZh: '🧰 实用工具箱',
titleEn: '🧰 Utility Toolbox',
bodyZh: '点击展开九宫格面板,收纳了模型来源中心(检测工作流模型对应的 Civitai/HuggingFace 链接)、提示词笔记、文件夹管理等工具。',
bodyEn: 'Opens the drawer hosting Model Sources Hub (detects Civitai/HuggingFace links for workflow models), Prompt Notes, and Folder Manager.',
position: 'top',
},
{
id: 'settings',
targetSelector: '#anomalous-global-settings-btn',
icon: '⚙️',
titleZh: '⚙️ 全局设置',
titleEn: '⚙️ Global Settings',
bodyZh: '用于切换界面中英文、调节 UI 缩放比例、设置卡片网格列数与密度、选择视频封面悬停播放模式,以及清理本地缓存。',
bodyEn: 'Adjust language (ZH/EN), UI zoom scaling, card grid density, hover-video playback behavior, and manage local cache.',
position: 'top',
},
]);
export function ensureTourStyles() {
if (typeof document === 'undefined') return;
if (document.querySelector?.('#anomalous-spotlight-tour-styles')) return;
const style = document.createElement('style');
style.id = 'anomalous-spotlight-tour-styles';
style.textContent = `
.anomalous-spotlight-overlay {
position: fixed;
inset: 0;
z-index: 999999;
pointer-events: auto;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
animation: anomalous-spotlight-fade-in 0.25s ease-out forwards;
}
@keyframes anomalous-spotlight-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.anomalous-spotlight-box {
position: absolute;
border-radius: 10px;
box-shadow: 0 0 0 9999px rgba(10, 12, 18, 0.78),
0 0 0 2px rgba(96, 165, 250, 0.9),
0 0 22px rgba(59, 130, 246, 0.45);
transition: all 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
box-sizing: border-box;
}
.anomalous-spotlight-card {
position: absolute;
width: 330px;
max-width: calc(100vw - 32px);
background: #18181f;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.65), 0 0 1px rgba(255, 255, 255, 0.2);
color: #f1f5f9;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
left 0.32s cubic-bezier(0.2, 0.8, 0.2, 1),
top 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 1000000;
}
.anomalous-spotlight-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.anomalous-spotlight-card-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
color: #60a5fa;
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 9999px;
padding: 2px 8px;
}
.anomalous-spotlight-card-close {
background: transparent;
border: none;
color: #94a3b8;
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.anomalous-spotlight-card-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.anomalous-spotlight-card-title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #f8fafc;
display: flex;
align-items: center;
gap: 6px;
line-height: 1.3;
}
.anomalous-spotlight-card-body {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: #cbd5e1;
}
.anomalous-spotlight-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.anomalous-spotlight-dots {
display: flex;
gap: 5px;
align-items: center;
}
.anomalous-spotlight-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.2);
transition: all 0.2s;
}
.anomalous-spotlight-dot.active {
width: 14px;
background: #3b82f6;
}
.anomalous-spotlight-btn-group {
display: flex;
gap: 8px;
align-items: center;
}
.anomalous-spotlight-btn {
font-size: 12px;
font-weight: 600;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
border: none;
}
.anomalous-spotlight-btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: #cbd5e1;
}
.anomalous-spotlight-btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.anomalous-spotlight-btn-secondary:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.anomalous-spotlight-btn-primary {
background: #2563eb;
color: #fff;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.4);
}
.anomalous-spotlight-btn-primary:hover {
background: #1d4ed8;
}
.anomalous-btn-tour {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: #2563eb;
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 7px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.anomalous-btn-tour:hover {
background: #1d4ed8;
border-color: rgba(255, 255, 255, 0.4);
}
.anomalous-update-guide-tour-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin: 2px auto 14px auto;
padding: 6px 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
color: #94a3b8;
font-size: 11.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
max-width: fit-content;
text-align: center;
user-select: none;
letter-spacing: 0.2px;
}
.anomalous-update-guide-tour-banner:hover {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(96, 165, 250, 0.4);
color: #93c5fd;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.anomalous-update-guide-tour-banner:active {
transform: translateY(0);
background: rgba(59, 130, 246, 0.2);
}
`;
(document.head || document.body)?.appendChild(style);
}
function resolveStepTarget(step) {
if (typeof document === 'undefined') return null;
let el = document.querySelector(step.targetSelector);
if ((!el || !el.isConnected) && step.fallbackSelector) {
el = document.querySelector(step.fallbackSelector);
}
return el && el.isConnected ? el : null;
}
function computeCardPosition(rect, position, cardWidth = 330, cardHeight = 220) {
const margin = 12;
const padding = 16;
let left = rect.left + rect.width / 2 - cardWidth / 2;
let top = 0;
if (position === 'top') {
top = rect.top - cardHeight - margin;
if (top < padding) {
top = rect.bottom + margin; // flip to bottom if offscreen
}
} else {
top = rect.bottom + margin;
if (top + cardHeight > window.innerHeight - padding) {
top = rect.top - cardHeight - margin; // flip to top if offscreen
}
}
// Clamp horizontally to viewport
left = Math.max(padding, Math.min(window.innerWidth - cardWidth - padding, left));
top = Math.max(padding, Math.min(window.innerHeight - cardHeight - padding, top));
return { left, top };
}
export function isSpotlightTourActive() {
return Boolean(activeTourInstance);
}
export function closeSpotlightTour() {
if (!activeTourInstance) return;
const { overlay, cleanupListeners } = activeTourInstance;
activeTourInstance = null;
if (typeof cleanupListeners === 'function') cleanupListeners();
if (overlay && overlay.parentNode) {
overlay.style.animation = 'none';
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.2s ease-out';
setTimeout(() => overlay.remove(), 200);
}
}
export function startSpotlightTour(owner) {
if (typeof document === 'undefined') return false;
if (activeTourInstance) closeSpotlightTour();
ensureTourStyles();
// Filter steps to those with targets present on current DOM
const availableSteps = TOUR_STEPS.filter(step => Boolean(resolveStepTarget(step)));
if (!availableSteps.length) {
console.warn('[AMB] No tour targets visible on screen.');
return false;
}
let currentIndex = 0;
const overlay = document.createElement('div');
overlay.className = 'anomalous-spotlight-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Spotlight Tour');
const spotlightBox = document.createElement('div');
spotlightBox.className = 'anomalous-spotlight-box';
const card = document.createElement('div');
card.className = 'anomalous-spotlight-card';
overlay.appendChild(spotlightBox);
overlay.appendChild(card);
document.body.appendChild(overlay);
const isZh = () => (window.anomalous_browser_lang === 'zh');
const renderCurrentStep = () => {
const step = availableSteps[currentIndex];
const target = resolveStepTarget(step);
if (!target) {
if (currentIndex < availableSteps.length - 1) {
currentIndex++;
renderCurrentStep();
} else {
closeSpotlightTour();
}
return;
}
// Scroll target into view if out of sight
target.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
const rect = target.getBoundingClientRect?.() || { left: 0, top: 0, width: 100, height: 40, right: 100, bottom: 40 };
const buffer = 5;
// Position spotlight box smoothly
spotlightBox.style.left = `${Math.max(0, rect.left - buffer)}px`;
spotlightBox.style.top = `${Math.max(0, rect.top - buffer)}px`;
spotlightBox.style.width = `${rect.width + buffer * 2}px`;
spotlightBox.style.height = `${rect.height + buffer * 2}px`;
// Card content
const titleText = isZh() ? step.titleZh : step.titleEn;
const bodyText = isZh() ? step.bodyZh : step.bodyEn;
const total = availableSteps.length;
const stepNum = currentIndex + 1;
card.replaceChildren();
const cardHeader = text(card, 'div', '', 'anomalous-spotlight-card-header');
const badge = text(cardHeader, 'span', '', 'anomalous-spotlight-card-badge');
text(badge, 'span', step.icon);
text(badge, 'span', isZh() ? `第 ${stepNum} / ${total} 步` : `Step ${stepNum} of ${total}`);
const closeBtn = text(cardHeader, 'button', '×', 'anomalous-spotlight-card-close');
closeBtn.type = 'button';
closeBtn.title = isZh() ? '退出导览 (Esc)' : 'Exit Tour (Esc)';
closeBtn.onclick = () => closeSpotlightTour();
text(card, 'h4', titleText, 'anomalous-spotlight-card-title');
text(card, 'p', bodyText, 'anomalous-spotlight-card-body');
const cardFooter = text(card, 'div', '', 'anomalous-spotlight-card-footer');
const dotsWrap = text(cardFooter, 'div', '', 'anomalous-spotlight-dots');
for (let i = 0; i < total; i++) {
text(dotsWrap, 'span', '', `anomalous-spotlight-dot ${i === currentIndex ? 'active' : ''}`);
}
const btnGroup = text(cardFooter, 'div', '', 'anomalous-spotlight-btn-group');
const prevBtn = text(btnGroup, 'button', isZh() ? '‹ 上一步' : '‹ Back', 'anomalous-spotlight-btn anomalous-spotlight-btn-secondary');
prevBtn.id = 'anomalous-tour-prev';
prevBtn.type = 'button';
prevBtn.disabled = currentIndex === 0;
prevBtn.onclick = () => {
if (currentIndex > 0) {
currentIndex--;
renderCurrentStep();
}
};
const nextBtnText = currentIndex === total - 1 ? (isZh() ? '完成体验 ✓' : 'Done ✓') : (isZh() ? '下一步 ›' : 'Next ›');
const nextBtn = text(btnGroup, 'button', nextBtnText, 'anomalous-spotlight-btn anomalous-spotlight-btn-primary');
nextBtn.id = 'anomalous-tour-next';
nextBtn.type = 'button';
nextBtn.onclick = () => {
if (currentIndex < total - 1) {
currentIndex++;
renderCurrentStep();
} else {
closeSpotlightTour();
}
};
// Position card
const cardPos = computeCardPosition(rect, step.position);
card.style.left = `${cardPos.left}px`;
card.style.top = `${cardPos.top}px`;
};
// Keyboard navigation
const onKeyDown = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
closeSpotlightTour();
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
if (currentIndex < availableSteps.length - 1) {
currentIndex++;
renderCurrentStep();
} else {
closeSpotlightTour();
}
} else if (e.key === 'ArrowLeft') {
if (currentIndex > 0) {
currentIndex--;
renderCurrentStep();
}
}
};
const onResize = () => {
renderCurrentStep();
};
const onOverlayClick = (e) => {
if (e.target === overlay) {
closeSpotlightTour();
}
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('resize', onResize);
overlay.addEventListener('click', onOverlayClick);
const cleanupListeners = () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('resize', onResize);
overlay.removeEventListener('click', onOverlayClick);
};
activeTourInstance = { overlay, cleanupListeners, owner };
renderCurrentStep();
return true;
}
if (typeof window !== 'undefined') {
window.anomalous_start_tour = () => startSpotlightTour(window.anomalousBrowserInstance);
}