diff --git a/.gitignore b/.gitignore index 773109d..c4a2ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -69,7 +69,14 @@ AI_SYSTEM_TEMPLATE.md # Test files test_*.js test*.mjs -tests/ +tests/* +!tests/package.json +!tests/ui_fixture.mjs +!tests/prompt_ui_lifecycle.mjs +!tests/prompt_material_source.mjs +!tests/translation_service_contracts.mjs +!tests/studio_contracts.mjs +!tests/material_workspace_flow.mjs # Local diagnostic/test artifacts grep_doctor.txt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5c6b397..6d5366b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -74,144 +74,28 @@ DOM or live LiteGraph state. ### Frontend -- `web/main.js` is the extension entry and shared browser-instance owner. -- `web/modules/entry_controls.js` and `shortcut_controls.js` own entry modes and - native command/keybinding integration. -- `ui_sidebar.js`, `ui_grid.js`, `ui_detail.js`, and `ui_gallery.js` own the - primary model-browser surfaces, including the frosted-glass capsule parameter inspection - button with lightweight vector SVG icons and GPU-accelerated micro-interactions; - the sidebar bottom action bar (`#anomalous-sidebar-actions`) features intuitive, highly restrained glyphic vector SVG icons (`SIDEBAR_ICONS`): - (1) Toolbox Hub (`#anomalous-toolbox-btn`): Precision 18px vector toolbox icon (`TOOLBOX`) anchoring the leftmost action slot, triggering an elevated 236px zero-scrollbar micro-utility drawer popover (`#anomalous-toolbox-modal`, z-index 999999, `overflow: hidden`) anchored directly above the action bar in the bottom-left corner (`bottom: 50px; left: 10px;`, collapsed `left: 68px`), presenting a high-density 3-column app/utility icon grid palette (`.anomalous-toolbox-grid`, `.anomalous-toolbox-tile`) hosting the Prompt Studio (`🎛️ 提示词工坊`, `openPromptStudio`) as the primary active online tool alongside utility helpers, with instant tooltips, emerald ready indicator dots (`.is-ready`), amber planned status dots, extensible tool registry (`this.registerToolboxItem`), zero obstruction to the central model grid, and mutual exclusivity against Settings Hub; - (2) Scan Wizard (`#anomalous-scan-btn`): Clean circular radar scope with range ring, crosshairs, and sweep beacon (`SCAN_RADAR_ICON_SVG`), animated only during active scanning; - (3) Model Doctor (`#anomalous-doctor-btn`): Sleek diagnostic medical stethoscope, instantly conveying health check semantics; - (4) AI Assistant (`#anomalous-assistant-btn`): Radiant AI copilot sparkles (`✦`), representing copilot intelligence; - (5) Material Library (`#anomalous-materials-btn`): 3D layered preset stack, distinguishing from individual model boxes; - (6) Settings Hub (`#anomalous-global-settings-btn`): Precision 8-tooth mechanical engineering gear with elevated popover (`#anomalous-settings-hub-modal`) mounted directly to container with `z-index: 999999` and dynamic responsive offset; hosts a clean 3-way Display Mode segmented controller (`#anomalous-view-mode-container` for Standard, Compact, and Aesthetic modes), UI scale controls, dynamic background atmosphere controls, folder manager, language toggles, a restrained inline 13px glyph question icon for Help (decoupling from `.anomalous-btn-icon` 32px box trap), and an intuitive reload arrow (`↺`) for window layout reset replacing esoteric drafting ruler emojis; - (7) Models & Floating Trigger: Precision 3D Isometric Asset Model Cube SVG (`box`), universally conveying 3D model, mesh, and asset package semantics with zero visual ambiguity; - adhering to a "low-stimulus idle, subtle-illumination on hover" philosophy where icons use calm `currentColor` in idle state and gently illuminate on interaction; - critical tool workbenches (`#anomalous-doctor-panel`, `#anomalous-assistant-panel`): Node Assistant automatically collapses the left folder tree sidebar (`.anomalous-sidebar-closed`) upon entry to expand the available horizontal workspace for node diagnostics, model replacements, and parameter notebooks, while preserving and cleanly restoring the user's preferred sidebar open/closed state when switching back to model browsing; strictly enforce 100% opaque, solid backgrounds (`background: var(--amb-bg-page, #131315) !important`) to eliminate background distraction during diagnostics; - the UI palette strictly adheres to a true neutral dark charcoal / obsidian studio aesthetic (`#131315`, `#1a1a1d`, `#202024`, `#28282d`) with zero cool-blue/navy cast, matching ComfyUI, Blender, and DaVinci Resolve workstation standards; - the sidebar action bar's circular astrological runic compass watermark (`#anomalous-sidebar-actions::before`) is completely disabled (`display: none !important`), and any backdrop textures are desaturated with `grayscale(100%)` to prevent blue color bleeding; - all primary action highlights, scan wizard controls, and switches use titanium white (`#e5e7eb` / `#ffffff`) with warm studio amber (`#f59e0b`) accents instead of cold blue/cyan; - the modal adopts a disciplined "workshop / archive" aesthetic: removing exaggerated `clip-path` chamfers in favor of clean 8px architectural engineering geometry (`border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1)`); - the master header (`#anomalous-header`) and sidebar brand bar (`#anomalous-brand-bar`) are tightly calibrated to a 48px slim industrial profile; header navigation tabs (`#anomalous-models-btn`, `#anomalous-gallery-btn`, `#anomalous-notebook-btn`) and window control buttons (`#anomalous-dock-btn`, `#anomalous-close`) are unified to a compact 28px height, 6px border radius, 13px vector icons, active tab tracking, and persistent bilingual text labels (`[ 📦 模型 ]`, `[ 🖼️ 图库 ]`, `[ 🔗 工作流 ]` - simplified from "工作流配方" to balance width and match ComfyUI workflow mental model), perfectly harmonizing with adjacent dock and close controls; - cards feature restored and refined top-right micro-action buttons (`.anomalous-card-action-btn`: Apply ➕, Edit ✏️, Scan 🎯) with 22px frosted glass styling, smooth hover fade-in, and auto-clipping avoidance against the base model badge (`.anomalous-card-badge`); - supports 3 unified Display Modes sharing identical DOM structure and UX logic: - - Compact Mode (紧凑模式, primary default): 140px min-width, 220px card height, tight 10px gaps/14px rows, 5px sidebar folder rows, optimized for high-density scanning and instant visual discovery (Blender Asset Browser inspired); - - Standard Mode (标准模式, comfortable): Balanced 200px cards, 340px height, comfortable whitespace rhythm; - - Aesthetic Mode (沉浸模式): Subtle 8% grayscale ambient archive watermark with frosted glass borders; - cards feature unified 6px/8px geometry, restrained elevation (`translateY(-2px)`, `scale(1.02)`), spatial depth shadows (`0 4px 14px rgba(0,0,0,0.35)`), - seamless docking fallbacks, and a micro-floating capsule tooltip system - (`.anomalous-tooltip-target`) providing unambiguous, bilingual action semantics across all vector tools. -- `ui_gallery_detail.js` owns the studio modal Image Detail Workbench, - multi-dimensional image navigation (keyboard shortcuts, floating glass arrows, - left vertical thumbnail rail), streamlined workflow/material action buttons, - seamless non-shifting click-to-copy Bento spec tiles with error retry state, - keyboard accessible focus indicators, full-width generation parameter saving to Material Library, - and segmented Bento parameter inspection with ComfyUI canvas injection (`app.loadGraphData`). -- `ui_recipes.js` and `ui_recipe_detail.js` own Workflow Recipes (工作流配方) - presentation, completely decoupled from the legacy dual-tab Workspace container (`owner.recipeContainer`), - eliminating the redundant outer "工作区 / 提示词笔记 / 工作流配方" tabs; features an integrated Studio topbar (`.anomalous-recipe-topbar`) with responsive `flex-wrap: wrap`, - a subtle 1px border-bottom (`rgba(255,255,255,0.05)`), scope filter micro-pills (`all`, `complete`, `partial`) with high-transparency default backgrounds, - unified search & tag dropdown, dual view modes (uniform 264px Bento grid & compact list view), and an inline topbar close button (`[✕]`); - cards feature 136px golden-ratio top covers, clean titles without badge clutter (relocating isolated readiness dots into a sleek frosted-glass - `anomalous-recipe-readiness-pill` chip at the bottom-left of the cover alongside base model pills), single-row ellipsis text tags (`white-space: nowrap; text-overflow: ellipsis`), - top-right scope pills, and one-click canvas load (`🚀 载入画布` / `🧩 追加画布`, powered by quantum energy fluid micro-textures `btn_energy_core.webp` with sharp text drop-shadows); - filter pills (`.anomalous-recipe-pill.is-active`, `.anomalous-workbench-pill.is-active`) feature micro-crystalline quantum glow underlays (`pill_active_glow.webp`); - cards without custom thumbnails automatically receive deterministic AI-generated abstract material blind-box artwork (`web/assets/default_cover_1.webp` ~ `default_cover_6.webp`), - eliminating dry grey placeholder boxes. Empty state displays (`.anomalous-recipe-empty`) seamlessly blend with the futuristic cyber library concept art (`empty_library_concept.webp`) - using vertical gradient alpha masks (`mask-image: linear-gradient(...)`). - Direct canvas drag-drop ("一拖直达画布") allows dragging any recipe card across the translucent - modal directly onto the ComfyUI canvas to immediately load the workflow into a fresh canvas - (or append subgraphs), mimicking native ComfyUI image-drop behavior. - Recipe Detail Studio View (`showRecipeDetail`) is completely overhauled: dynamically hides - outer `.anomalous-recipe-topbar` on entry and cleanly restores it on exit to reclaim 100% of the - vertical workspace; replaces clumsy text copy buttons with sleek SVG micro-copy icons with instant - green checkmark feedback; rebuilds the Parameters Tab (`renderRecipeParameters`) into a modern - Bento Matrix (`.anomalous-recipe-bento-grid`, steps/CFG/sampler/resolution/denoise/seed tiles, - independent base model card, and structured LoRA pill stacks); prompts section (`renderPromptSection`) - is upgraded into full-width streamlined cards eliminating the cramped two-column split, clumsy - "调整用途" buttons, and isolated square copy boxes, moving actions into a top-right action tray - (save to material + micro copy) and making the role badge interactively clickable; snapshot rail - replaces legacy `
` and vertical text badges with smooth hover-reveal micro-actions and a glowing - green active pulse dot (`.anomalous-preset-active-dot`); enforces 48px bottom safety padding to eliminate - clipping and overlap. -- `ui_notebooks.js` owns Prompt Notes (提示词笔记) presentation; eliminated intermediate dual tabs - (`.anomalous-nb-section-tabs`); resolves the long-text input bottleneck through 300ms Debounce on `rawArea.oninput`, - `DocumentFragment` batch mounting in `updateVisualTags`, and modern CSS Grid layout (`.anomalous-nb-tag-row`) - with hover-revealed copy buttons (`.anomalous-nb-copy-btn`); LoRA gallery performance is hardened via - `content-visibility: auto`, `contain-intrinsic-size: 70px`, and fixed `aspect-ratio: 1/1` preventing Layout Shift. -- `ui_materials.js` owns the unified Material Library (素材库) view and its lifecycle; - features a top-level unified toolbar with capsule category filter tabs (`[全部 | 工作流 | 参数方案 | 提示词]`), - integrated search bar with live tag filter dropdown, dedicated view switcher (`[网格 | 列表]`), - refresh and transfer center; cards feature strict uniform height (248px) with rich micro-skeuomorphic fallbacks for non-image assets (prompt code snippets - and 3D asset parameter previews) eliminating visual bumpiness, direct inline title editing, - floating quick actions, and lazy DOM rendering; clicking any material card safely opens the detail inspection view; - in node apply mode or when a canvas node is selected, cards and the detail header expose explicit `[⚡ 应用到节点]` action buttons (`.anomalous-material-card-apply-btn`) - to prevent accidental node parameter overwriting; multi-text drag-and-drop onto nodes presents an interactive selection dialog with role badges, - truncated text snippets, and `[展开全部 ▾]` toggles; default naming for gallery saves is streamlined to `[原图名] · 快照` (or `[原图名] · [节点名]`); - completely decoupled from Prompt Studio (no studio toggle, no docked side panel, and no card-level mixer buttons); delegates image inspection to `ui_gallery_detail.js`; - `node_material_actions.js` owns shared transactional node application and guarded undo; - the library and Node Assistant use `ui_material_application.js` for the same receipt. -- `translation_service.js` owns atomic prompt translation logic and language intelligence; - bridges directly to the ComfyUI backend `POST /anomalous/translate` with multi-engine fallback (DeepL API Key ➔ Google Chrome Dictionary Client (`dict-chrome-ex`, bypasses 429 rate limits) ➔ MyMemory Translation Engine); - features `hasChinese` heuristic detection, automatic target language deduction (Chinese ➔ English, English ➔ Simplified Chinese), -- `ui_prompt_translator.js` owns the standalone Quick Prompt Translator (提示词翻译助手); - mounted directly to `document.body` as an elevated sidebar drawer (`.anomalous-translator-modal.is-sidebar`, z-index 999999); registered in the Toolbox Hub (`openPromptTranslator`); - features live language selection, one-click ComfyUI canvas node prompt reading (`readSelectedNodePrompt`), - instant bilingual translation, interactive tag breakdown pill chips (`.anomalous-translator-chip`), - one-click copy, direct write to selected canvas text node (`writeToSelectedNode` via `applyNodeMaterialValues`), dock-side toggling (`[⇤ / ⇥]`), width resizing, and one-click dispatch into Prompt Studio. -- `ui_prompt_composer.js` owns the Prompt Studio Minimalist Workbench (提示词工坊极简侧边栏抽屉与拼装台): - an independent, high-efficiency companion tool launched from the Toolbox Hub (`openPromptStudio`); - rendered directly into `document.body` as an elevated sidebar drawer (`.anomalous-prompt-studio-drawer`, z-index 999991) with canvas passthrough (`pointer-events: none` backdrop, allowing uninterrupted graph navigation); - features Canvas Focus Mode: opening the studio drawer automatically collapses the heavy master browser to preserve 80%+ canvas workspace; - features dock-side switching (`[⇤ / ⇥]`, persisted to `localStorage.anomalous_studio_dock_side`), width resize handle (400px~800px, persisted to `localStorage.anomalous_studio_sidebar_width`), and ESC key dismissal; - features a resilient Minimalist Dual-Column Layout: Left Column (145px~155px) hosts high-density compact prompt pills (`.anomalous-source-card-compact`) with category dot accents and instant 1-tap track injection; Right Column expands to take all remaining width as the primary Assembly Stage (`.anomalous-assembly-track`); - each assembled block card features category pills, title editing, a quick translate button, and an interactive **Weight Wheel Pill** (`.anomalous-block-weight-pill`) supporting instant mouse wheel scrolling (+/- 0.05 step) and +/- click adjustments; - eliminates cumbersome static textareas: Assembled output uses a zero-footprint **Floating Action Dock** (`.anomalous-floating-action-dock`) that is hidden (0px height) when empty and smoothly slides up (38px height) when blocks exist, offering real-time token/block stats, `[🚀 写入节点]`, `[📋 复制]`, and `[⛶ 展开大窗]`; - clicking `[⛶]` launches an independent centered **Prompt Inspector Modal** (`.anomalous-prompt-inspector-modal`, 780x520px, z-index 999999) for deep full-text inspection, bilingual translation, tag normalization (`[🧹 规范化]`), and direct write-back to canvas text nodes; - `prompt_composition.js` provides bidirectional schema mapping (`planToWorkbenchDraft` and `workbenchDraftToSavedPlan`) ensuring - 100% roundtrip data integrity across `anomalous-prompt-plan-v1` and `version: 2`, lossless legacy dual-role splitting and trailing text retention, - `categorizePromptSnippet`, `smartSortPromptBlocks`, `assemblePromptBlocks`, as well as backward-compatible text joining; - `api/materials.py` validates prompt plans with extended categories (`general`, `specific`, `base`, `style`, `subject`, `trigger`) - while preserving schema version, part role, and card id. -- `material_drag.js` owns temporary canvas drop listeners, target hit testing and - window restoration. During active dragging, the main modal smoothly transitions to - full transparency (`opacity: 0; pointer-events: none`) to fully reveal the underlying - ComfyUI canvas, restoring on drop/cancel. Supports both node-targeted drops (materials replace, - prompt notes insert text) and whole-canvas drops via `dropOnCanvas` (recipes load new workflows - or append subgraphs). -- `material_inspector.js` owns shared image-metadata parsing and node-parameter - rendering used by the library and Image Detail Workbench. Node cards feature leading - aligned frosted-glass checkboxes with cyan micro-interactions for batch selection. - The workbench does not import the library UI. -- `recipe_parser.js`, `recipe_identity.js`, `recipe_diff.js`, and - `recipe_actions.js` own pure or transactional recipe behavior. -- `ui_doctor.js`, `model_picker.js`, and `graph_splice.js` own assistant and - explicit graph-edit behavior. Node Assistant parameter schemes are structured into distinct - scheme cards with parameter pills (steps, cfg, sampler, scheduler, denoise, prompt roles); - hovering over any parameter scheme card triggers a glassmorphic floating popover (`.anomalous-param-popover`) - displaying the complete key-value parameter table, prompt text box, one-click copy, and apply action; - parameter application requires an explicit `[⚡ 应用方案]` button click with async feedback (`⏳ 应用中...` -> `✅ 已应用`), - preventing unintentional canvas graph overwrites. -- Dual-Mode Theme Architecture (双形态主题架构与材质光影精细化): - - **Base Design System Tokens (`--amb-*`)**: Scoped on `:root` and overridden under `.theme-abyssal-scarlet`. Defines unified background, panel, card, text, border, and control radii tokens (`--amb-radius-control: 6px`, `--amb-radius-card: 10px`, `--amb-radius-panel: 12px`). - - **Normal Mode (Default / 标准中性黑曜石图书档案馆模式)**: Restrained, clean, neutral dark obsidian (`#0b0d13` / `#141519` / `#1c1e24`) styling; overlaid with the "Anime Celestial Library Archive" AI Concept Art with glowing holographic magic circles, golden blueprint wireframes, and constellation starmap textures (`assets/archive_library_bg.webp`) across `#anomalous-container::before` at `0.65` opacity with radial dark vignette; **全界面毛玻璃一体化融合 (Unified Frosted Glass Architecture)**: 侧边栏 (`#anomalous-sidebar-wrapper`, 24px 模糊,52% 透光) 与顶部导航栏 (`#anomalous-header`, 20px 模糊,48% 透光) 全面采用磨砂毛玻璃透光,品牌徽章与搜索框微晶质感化,底图星轨、宏大书阁与发光符文在全窗口贯通流淌,与透明网格主视图形成高度沉浸式二次元魔法图书馆终端界面;银白高对比主操作按钮 (`#D7D9E0` 搭配 `#15171C` 字色)。 - - **Abyssal Scarlet Easter Egg Mode (`.theme-abyssal-scarlet` / 深海血族彩蛋领域 - 丝绒与暗血轻量质感)**: Strictly scoped under `.theme-abyssal-scarlet`; eliminates all cheap neon outer glows; deep gothic velvet backgrounds (`#161014` / `#21171d` / `#2b1e26`), razor-sharp crimson (`#dc143c`) and antique gold (`#b38728`) borders, crimson primary buttons (`#87384E` with `#FFF5F7` text), and concept art backdrops `assets/abyssal_scarlet_mansion.webp` and `assets/abyssal_bg_concept.webp`. - - **Three-Tier Button Architecture**: Unifies button morphology across primary, secondary/ghost, and auxiliary actions. Primary buttons (`.anomalous-btn-primary`, `.anomalous-recipe-btn-primary-action`) feature solid fills, 32px height, 6px border-radius, clean SVG icons, and zero pseudo-element clutter (purged legacy `::before` diamonds, `::after` gradient lightbars, text-shadows, and heavy glows); secondary ghost buttons (`.anomalous-btn-ghost`) feature subtle borders and panel backgrounds; auxiliary action buttons provide clear, non-distracting tool semantics. - - **Floating Trigger Button & Anomalous Hypercube Core (悬浮入口魔晶重构)**: - - 重绘旧版扁平纸箱立方体为 **「异象超维魔方核 (Anomalous Hypercube Core)」**:外层等轴测立体超维立方体轮廓,内嵌悬浮发光微晶棱镜(`--amb-accent` 随主题自适应为天青/猩红),核心凝聚一枚四芒星辉奇点(`✦`),兼具 3D 模型容器特征与魔法档案馆星辰意象。 - - 悬浮球体容器 (`#anomalous-trigger-btn`) 升级为亚克力磨砂深色毛玻璃材质 (`backdrop-filter: blur(16px)`),悬停微移提亮,图标伴随 12° 优雅微旋与天青/猩红柔和微光,保持高度克制的同时极具辨识度与高级质感。 - - **Prompt Studio Dual-Column Workbench (提示词工坊侧边抽屉与双向自适应停靠)**: - - **Default Left Docking & Canvas-Facing Flow**: Defaults to left dock (`is-dock-left`), with Source Cards (190px) on the outer screen edge and Assembly Track on the inner side directly facing the ComfyUI canvas. - - **Bidirectional Column Flipping**: When toggled to right dock, the layout seamlessly swaps column visual order (`order: 1` for Assembly Track facing canvas on left, `order: 2` for Source Cards along right screen edge) so that the Assembly Track is ALWAYS adjacent to the ComfyUI canvas regardless of docking side. - - **Single-Line Unbroken Topbar**: Topbar strictly enforces `flex-wrap: nowrap`, keeping Title, Preset Name input (constrained max-width 140px), Node Extraction (`🎯`), Dock Toggle (`→`/`←`), More dropdown (`··· 更多`), and Close (`✕`) in a unified single row under any drawer width. - - **De-cluttered Left Panel**: Expanded left column from 145px to 190px, purged redundant text buttons into compact 24x24 icon buttons (`[📥]` and `[+]`), shortened category filter pills to clean 2-character chips (`[全部]`, `[底模]`, `[风格]`, `[主体]`, `[触发]`) fitting into a single row, and removed bulky instructional tip banners. - - **Dynamic Floating Action Dock & Popout Modal**: Floating Action Dock slides up only when blocks exist; provides single-click Node Direct Write and popout full-screen Prompt Inspector (`openPromptInspectorModal`) for deep multi-line tuning. - - **Trigger Button Lifecycle Suppression**: When Prompt Studio opens, the global floating trigger button (`#anomalous-trigger-btn`, z-index 2147483000) is automatically suppressed via `owner.setTriggerVisible(false)` and `body.anomalous-prompt-studio-open` (`display: none !important;`), preventing it from overlaying the workbench track, and cleanly restores when the studio drawer is closed. - - **Eye-Catching Card Preview Popover (词卡专属悬浮预览浮框)**: Completely eliminated unstyled native OS `title` tooltips. Implemented `.anomalous-card-preview-popover` (fixed positioning, dark obsidian glass, z-index 1000005, neon accent borders, category/role badges, monospace formatted prompt snippet container, and action hints). Compact source cards feature category-color left accent borders (`border-left: 3px solid`) for instant visual scanning. - - **Uniform Card Skeletons**: Recipe and material cards enforce "Preview -> 2-Line Clamped Title -> Key Specs -> Footer Action Bar" order, preventing Chinese title clipping; Model cards display titles and filenames at rest without `translateY` collapse and without `backdrop-filter: blur`, fading in action buttons on hover. - - **Custom Tooltip System (`[data-tooltip]`)**: Solid dark capsules (`rgba(18, 20, 26, 0.98)`), 350ms standard hover delay, and keyboard focus-visible support. +- `web/main.js` registers the extension and owns the shared browser instance. +- `ui_sidebar.js` creates the browser shell, navigation and settings. Its large + `createDOM` method remains a maintenance hotspot; new tool implementations + should live in their own modules rather than expanding that method. +- `ui_grid.js` and model-detail modules own model presentation; `ui_gallery.js` + and `ui_gallery_detail.js` own generated-image browsing and inspection. +- `ui_recipes.js` / `ui_recipe_detail.js`, `ui_notebooks.js`, and `ui_materials.js` + own their respective workspace surfaces and persistence flows. +- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer. Its child + views are `ui_prompt_source_deck.js`, `ui_prompt_workbench.js`, and + `ui_prompt_inspector.js`; see the frontend topic for state ownership. +- `ui_prompt_translator.js` owns the standalone translator. Both translator and + studio use `ui_lifecycle.js` for global listeners, request cancellation and + resize cleanup. Translation requests go through `translation_service.js`. +- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns + material-specific metadata and parameter rendering. +- `ui_doctor.js`, `model_picker.js`, `node_material_actions.js`, and + `graph_splice.js` own diagnostics and explicit graph changes. +- `locales.js` is the shared runtime string catalog. Existing inline bilingual + UI strings remain migration debt; new strings belong in the catalog. +- `styles.css` owns presentation and theme overrides. Color values, dimensions + and visual design descriptions are not duplicated as architectural contracts. ## Cross-system invariants diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 890b726..fb5d219 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -28,6 +28,7 @@ The main surfaces are: - Node Assistant/Model Doctor: selected-node actions, diagnostics, parameter presets, and missing-model recovery. +`ui_dom.js` owns generic `text` and `jsonResponse` helpers. `material_inspector.js` shares image metadata helpers and node-parameter rendering between `ui_materials.js` and `ui_gallery_detail.js`. The workbench does not import the library UI, keeping this dependency chain acyclic. Library discovery uses @@ -146,24 +147,73 @@ Network-backed enrichment is explicit and recoverable. An unavailable Civitai or translation service may produce a local error state; it must not block local browsing, editing, or already stored data. +## Prompt Studio ownership + +The studio has one standalone drawer; the former embedded side/full composer +and material import drawer are removed. Existing browser integration continues +to call `openPromptStudio(owner)`, and external prompt dispatch uses +`appendPromptToStudio(owner, text, isPositive, title)`. + +| Module | Owned state and responsibilities | +| --- | --- | +| `ui_prompt_composer.js` | Active drawer, docking width/side, trigger visibility and plan-loading request | +| `ui_prompt_workbench.js` | Owner-backed draft, active role, block editing, ordering, write/copy/save/export | +| `ui_prompt_source_deck.js` | Source cards, filters, new-card form, preview popover and current sync request | +| `ui_prompt_inspector.js` | Full-text inspection window, role tab and its translation lifetime | +| `prompt_studio_data.js` | Starter cards, display categories, draft/block initialization and synthesis | +| `prompt_composition.js` | Pure plan conversion, sorting and composition; no DOM ownership | +| `prompt_material_source.js` | All-page prompt discovery, detail loading and source-card deduplication | +| `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing | + +Child views receive their container and narrow callbacks. The workbench keeps +`owner.promptPlanDraft` for reopening and exposes only `updateAll` / `addBlock` +through `owner.sidePromptComposerControl`. The source deck keeps its state local +and returns extraction/refresh/sync actions instead of assigning new owner fields. + +Draft `plan.parts` is the authoritative editable representation. Full-text +inspector edits merge the enabled blocks in the edited role into one block, +preserving disabled blocks and the opposite role. The inspector explains this +behavior before editing. Copy, save and reopen derive text from the same parts. + +Every close route (button, Escape, backdrop, replacement and parent close) must +dispose the same view scope. Parent close also closes its inspector. An Escape +handled by an inspector must not close its parent in the same event dispatch. +Closing during resize releases move/up listeners and restores body cursor and +selection styles. UI reads are cancelled on close; a save already sent to the +server may still complete, but must not reopen or repaint a disposed view. + +Initial and explicit source synchronization share a paginated loader. List +summaries are identifiers, not prompt bodies: each unique filename is resolved +through `material_prompt_data.js`. Deduplication uses prompt role and text, so +the same text can still appear once in each role. A newer sync cancels the old +one and only the current result updates the deck. + +The translator captures graph, node, widget and widget value before an async +write. It revalidates that destination and the input after translation; close, +selection changes and intervening widget edits invalidate the pending write. +Explicit English translation applies to all input languages, including kana +and Korean. Transport errors and rejected bridge responses remain local errors; +provider-specific validation belongs to the backend translation route. + ## Visual styling and theme architecture -The extension enforces a strict **Dual-Mode Theme Architecture (双形态主题架构)** to guarantee default aesthetic restraint while enabling deep immersive customization: +`styles.css` is the source of truth for current visual values. Shared `--amb-*` +tokens express surfaces, text, borders and control shapes; theme overrides must +be scoped to `.theme-abyssal-scarlet` rather than changing unrelated surfaces. -1. **Normal Mode (Default / 标准中性黑曜石模式 - 暗物质材质与水印底纹)**: - - Base surfaces use clean, restrained dark obsidian tones (`#0a0a0c` / `#0d0d11`) with subtle, neutral glassmorphism borders (`rgba(255, 255, 255, 0.08)`). - - Introduces the "Dark Matter Material" AI Concept Art Watermark (`assets/normal_bg_concept.webp`) overlaid on `#anomalous-content::before` and `.anomalous-gallery-container::before` at ultra-low opacity (`0.04 ~ 0.05`) with `mix-blend-mode: luminosity`, banishing dead-black voids while strictly preserving foreground thumbnail readability. - - Sidebar active folder indicators and icons use neutral silver-slate (`#94a3b8`); the radar scan sweep uses clean cyan/blue. - - Model cards use neutral dark glass (`rgba(20, 20, 26, 0.85)`) with neutral silver plate badges (`#cbd5e1`). - - Tooltips utilize neutral dark slate glass (`rgba(18, 20, 28, 0.95)`) with neutral borders and directional positioning (`data-tooltip-pos="top|right|bottom|left"`). - - Prompt Mixer, Arranger, and Workbench controls utilize cyber cyan/blue (`#38bdf8`) accents for active pills, smart sort, dropzone highlights, and node extraction. - -2. **Abyssal Scarlet Easter Egg Mode (`.theme-abyssal-scarlet` / 深海血族彩蛋领域 - 丝绒与暗血轻量质感)**: - - Strictly scoped under `.theme-abyssal-scarlet` or `html.theme-abyssal-scarlet`; never leaks into default rules. - - **Zero Outer Neon Glows**: Complete elimination of aggressive `box-shadow: 0 0 xxpx` outer halos, `filter: drop-shadow`, and text glows across buttons, cards, pills, and sidebar actions. - - **Light Absorption Aesthetic**: Replaced cheap neon effects with deep sunken inner shadows (`box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.8)`), heavy velvet wine backgrounds (`rgba(50, 12, 22, 0.95)`), razor-sharp 1px crimson (`#dc143c`) or antique gold (`#b38728`) borders, and subtle red-blush silver text (`#e0d8db`). - - Activates deep abyssal palettes (`#0a0510` / `#0d080c`), submerged scarlet mansion concept overlays (`assets/abyssal_scarlet_mansion.webp`), gallery lattice backdrop (`assets/abyssal_bg_concept.webp`), and ambient crimson/purple radial gradient illumination. - - Model card badges shift to the Gothic Sovereign palette (Dark Gold, Amber, Abyssal Purple/Flux, Rose Crimson) with sunken inner shadows and sharp borders. - - Header drag interaction safely guards `.closest('button')`, `.closest('input')`, etc., ensuring that button child SVG icons and label spans do not swallow clicks via `e.preventDefault()`. +Studio drawer rules keep the source deck at the screen edge and the assembly +track next to the canvas. Common geometry is shared between dock directions; +direction-specific rules set column order and separators. The removed embedded +composer's `#anomalous-container.anomalous-docked` overrides must not return. +Before adding an override or `!important`, locate and edit the owning rule. +Older component and theme overrides elsewhere in this file still need a +separate, visually verified consolidation. +## Verification +Use `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` for actual +module open/close, nested Escape, resize cleanup, block insertion and delayed +node writes. `tests/prompt_material_source.mjs` covers pagination, deduplication +and cancellation; `tests/translation_service_contracts.mjs` covers the HTTP +bridge. The UI fixture simulates DOM and ComfyUI APIs and does not replace +checking the real host, layout, focus, drag gestures and theme rendering. diff --git a/tests/material_workspace_flow.mjs b/tests/material_workspace_flow.mjs new file mode 100644 index 0000000..874522d --- /dev/null +++ b/tests/material_workspace_flow.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { fixture, Element, all } from './ui_fixture.mjs'; + +const f = fixture(); +const record = { filename: 'parameters.json', kind: 'image_node_selection', name: 'Parameters', node_types: ['CLIPTextEncode'], selection: { scope: 'nodes' } }; +f.fetch = async url => ({ ok: true, json: async () => String(url).includes('material_full') + ? { status: 'success', data: record, node_blocks: [2, 3].map(id => ({ node_id: id, type: 'CLIPTextEncode', widgets_values: [`text ${id}`] })) } + : { status: 'success', materials: [record], page: 1, pages: 1, total: 1, tags: [] } }); +const materials = await f.module('ui_materials.js'); +const owner = { nbPanel: new Element('div') }; +owner.nbPanel.style.display = 'flex'; +f.document.body.appendChild(owner.nbPanel); +for (const key of ['showMaterials', 'refreshMaterials', 'openSavedMaterial', 'openMaterialLibrary']) owner[key] = materials[key].bind(owner); +await owner.openMaterialLibrary(); +assert.equal(owner.notebookContainer, undefined); +assert.equal(owner.materialApplyMode, true); +assert.equal(new URL(f.requests.at(-1)[0], 'http://test').searchParams.get('node_type'), 'CLIPTextEncode'); +assert.deepEqual(f.errors, []); +const card = owner.materialList.querySelector('article'); +assert.ok(card, 'current material card renders'); +await card.click(); +assert.equal(f.node.widgets[0].value, 'original', 'opening details must not write to canvas'); +assert.ok(owner.materialDetailView); +await card.querySelector('.anomalous-material-card-apply-btn').click(); +assert.deepEqual(f.errors, []); +assert.ok(owner.materialBlockDialog); +let choices = owner.materialBlockDialog.querySelectorAll('.anomalous-material-choice-card'); +await choices[1].querySelector('button').click(); +assert.equal(f.node.widgets[0].value, 'text 3'); +await f.button(owner.materialContext, 'Undo').click(); +assert.equal(f.node.widgets[0].value, 'original'); + +f.runTimers(); +await card.querySelector('.anomalous-material-card-apply-btn').click(); +const dialog = owner.materialBlockDialog; +f.app.canvas.selected_nodes = {}; +await dialog.querySelector('.anomalous-material-choice-card').querySelector('button').click(); +assert.equal(f.node.widgets[0].value, 'original'); +assert.ok(dialog.textContent.includes((await f.module('locales.js')).translate('materialTargetChanged'))); +dialog.close(); +f.app.canvas.selected_nodes = { 1: f.node }; + +const notes = await f.module('ui_notebooks.js'); +owner.showNotebooks = notes.showNotebooks.bind(owner); +owner.refreshNotebooks = () => {}; +await owner.showNotebooks(); +assert.equal(owner.materialContainer.style.display, 'none'); +assert.ok(owner.notebookContainer); +assert.equal(owner.notebookMaterialsTab, undefined); +await owner.showMaterials(); +assert.equal(owner.notebookContainer.style.display, 'none'); +assert.equal(owner.materialContainer.style.display, 'flex'); +assert.equal(all(owner.materialContainer).some(el => el.className === 'anomalous-nb-section-tabs'), false); +assert.deepEqual(f.errors, []); +console.log('material workspace: current cards, explicit apply, undo, stale selection and notes isolation passed'); diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/tests/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/prompt_material_source.mjs b/tests/prompt_material_source.mjs new file mode 100644 index 0000000..e98929a --- /dev/null +++ b/tests/prompt_material_source.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { loadPromptSourceCards, mergePromptSourceCards } from '../web/modules/prompt_material_source.js'; + +const requests = []; +globalThis.fetch = async (url, options) => { + requests.push([url, options]); + const parsed = new URL(url, 'http://test'); + const page = Number(parsed.searchParams.get('page')); + const filename = parsed.searchParams.get('filename'); + return { ok: true, json: async () => filename + ? { status: 'success', data: { kind: 'prompt_text', note: { promptEn: filename } } } + : { status: 'success', pages: 2, materials: page === 1 ? [{ filename: 'first.json' }] : [{ filename: 'first.json' }, { filename: 'last.json' }] } }; +}; +const controller = new AbortController(); +const cards = await loadPromptSourceCards(controller.signal); +assert.deepEqual(cards.map(card => card.content), ['first.json', 'last.json']); +assert.equal(requests.length, 4, 'two pages and two unique details'); +assert.equal(requests.every(([, options]) => options.signal === controller.signal), true); +const deck = []; +assert.equal(mergePromptSourceCards(deck, cards), 2); +assert.equal(mergePromptSourceCards(deck, cards), 0); +controller.abort(); +await assert.rejects(loadPromptSourceCards(controller.signal), { name: 'AbortError' }); +assert.equal(requests.length, 4); +console.log('prompt material source: later pages, deduplication, counts and cancellation passed'); diff --git a/tests/prompt_ui_lifecycle.mjs b/tests/prompt_ui_lifecycle.mjs new file mode 100644 index 0000000..b5ccf0f --- /dev/null +++ b/tests/prompt_ui_lifecycle.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { fixture, all } from './ui_fixture.mjs'; + +const f = fixture(); +const studio = await f.module('ui_prompt_composer.js'); +const translator = await f.module('ui_prompt_translator.js'); +const owner = { setTriggerVisible(value) { this.triggerVisible = value; } }; +const listeners = () => f.window.listenerCount() + f.document.listenerCount(); +const clean = () => { + assert.equal(listeners(), 0, 'all global listeners must be released'); + assert.equal(f.document.querySelector('.anomalous-prompt-studio-overlay'), null); + assert.equal(f.document.querySelector('.anomalous-translator-overlay'), null); + assert.equal(owner.sidePromptComposerControl, null); + assert.equal(owner.triggerVisible, true); +}; + +for (let i = 0; i < 3; i++) { + await studio.openPromptStudio(owner); + await f.flush(); + assert.equal(f.document.querySelectorAll('.anomalous-source-card-compact').length, 6); + owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive', category: 'base' }); + assert.equal(owner.promptPlanDraft.plan.positive, 'quality'); + f.window.dispatch('keydown', { key: 'Escape' }); + clean(); + owner.promptPlanDraft = null; +} + +await studio.openPromptStudio(owner); +await studio.openPromptStudio(owner); +assert.equal(f.document.querySelectorAll('.anomalous-prompt-studio-overlay').length, 1); +const beforeDrag = listeners(); +const handle = f.document.querySelector('.anomalous-studio-resize-handle'); +f.document.body.style.cursor = 'crosshair'; +handle.onmousedown({ button: 0, clientX: 100, preventDefault() {} }); +assert.equal(listeners(), beforeDrag + 2); +f.window.dispatch('mousemove', { clientX: 160 }); +assert.equal(f.document.querySelector('.anomalous-prompt-studio-drawer').style.width, '640px'); +studio.closePromptStudio(owner); +assert.equal(f.document.body.style.cursor, 'crosshair'); +clean(); + +await studio.openPromptStudio(owner); +owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive' }); +await f.button(f.document.body, '⛶').click(); +assert.ok(f.document.querySelector('.anomalous-prompt-inspector-overlay')); +f.window.dispatch('keydown', { key: 'Escape' }); +assert.equal(f.document.querySelector('.anomalous-prompt-inspector-overlay'), null); +assert.ok(f.document.querySelector('.anomalous-prompt-studio-overlay'), 'first Escape closes only inspector'); +f.window.dispatch('keydown', { key: 'Escape' }); +clean(); + +// Full-text edits remain authoritative after recomposition, save and reopening. +await studio.openPromptStudio(owner); +owner.sidePromptComposerControl.addBlock({ content: 'scene', role: 'positive' }); +owner.sidePromptComposerControl.addBlock({ content: 'bad anatomy', role: 'negative' }); +await f.button(f.document.body, 'Positive (').click(); +await f.button(f.document.body, '⛶').click(); +const inspectorText = f.document.querySelector('.anomalous-inspector-textarea'); +inspectorText.value = 'edited whole prompt'; +inspectorText.oninput(); +f.window.dispatch('keydown', { key: 'Escape' }); +await f.button(f.document.body, '📋').click(); +assert.equal(f.clipboard.at(-1), 'edited whole prompt'); +assert.equal(owner.promptPlanDraft.plan.negative, 'bad anatomy'); +const name = f.document.querySelector('.anomalous-prompt-name-input'); +name.value = 'round trip'; name.oninput(); +f.fetch = async url => ({ ok: true, json: async () => String(url).includes('/materials?') + ? { status: 'success', materials: [], pages: 1 } + : { status: 'success', material: { filename: 'saved.json' } } }); +await f.button(f.document.body, '💾').click(); +const savedRequest = f.requests.findLast(([url]) => url.includes('save_prompt_plan')); +const saved = JSON.parse(savedRequest[1].body); +assert.equal(saved.name, 'round trip'); +assert.equal(saved.plan.positive, 'edited whole prompt'); +assert.equal(saved.plan.negative, 'bad anatomy'); +await f.button(f.document.body, 'Sync Library').click(); +assert.ok(f.requests.at(-1)[0].includes('category=prompts')); +studio.closePromptStudio(owner); +await studio.openPromptStudio(owner); +assert.equal(owner.promptPlanDraft.plan.positive, 'edited whole prompt'); +await f.button(f.document.body, '✨').click(); +assert.equal(owner.promptPlanDraft.plan.positive, ''); +assert.equal(owner.promptPlanDraft.plan.negative, ''); +studio.closePromptStudio(owner); +clean(); + +// Closing while a saved plan is loading must not replace the draft or reopen. +const keptDraft = owner.promptPlanDraft; +let releasePlan; +f.fetch = async () => new Promise(resolve => { releasePlan = () => resolve({ ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_plan', name: 'late', plan: { positive: 'late text' } } }) }); }); +const loadingPlan = studio.showPromptComposer(owner, { filename: 'late.json' }); +await f.flush(); +studio.closePromptStudio(owner); +releasePlan(); await loadingPlan; +assert.equal(owner.promptPlanDraft, keptDraft); +clean(); + +for (const path of ['button', 'escape', 'backdrop']) { + translator.openPromptTranslator(owner); + translator.openPromptTranslator(owner); + assert.equal(f.document.querySelectorAll('.anomalous-translator-overlay').length, 1); + const overlay = f.document.querySelector('.anomalous-translator-overlay'); + if (path === 'button') f.document.querySelector('.anomalous-translator-close').click(); + if (path === 'escape') f.document.dispatch('keydown', { key: 'Escape' }); + if (path === 'backdrop') { overlay.classList.remove('is-sidebar'); overlay.onclick({ target: overlay }); } + clean(); +} + +// A delayed translation may not write into a new selection, overwrite an edited +// widget, or write after the translator closes. Kana must use the EN service too. +for (const change of ['selection', 'widget', 'close', 'none']) { + translator.openPromptTranslator(owner); + const target = f.document.querySelector('.is-target'); + target.value = 'かわいい'; + let release; + f.fetch = async (url, options) => { + assert.equal(JSON.parse(options.body).target_lang, 'en'); + return new Promise(resolve => { release = () => resolve({ ok: true, json: async () => ({ translated: 'cute' }) }); }); + }; + const pending = f.button(f.document.body, 'Translate to EN & Write').click(); + await f.flush(); + if (change === 'selection') f.app.canvas.selected_nodes = {}; + if (change === 'widget') f.node.widgets[0].value = 'edited'; + if (change === 'close') f.document.querySelector('.anomalous-translator-close').click(); + release?.(); await pending; + assert.equal(f.node.widgets[0].value, change === 'widget' ? 'edited' : change === 'none' ? 'cute' : 'original'); + f.document.querySelector('.anomalous-translator-close')?.click(); + f.app.canvas.selected_nodes = { 1: f.node }; + f.node.widgets[0].value = 'original'; + (await f.module('translation_service.js')).clearTranslationCache(); + clean(); +} +assert.deepEqual(f.errors, []); +console.log('prompt UI: reopen/close, Escape nesting, resize cleanup, real card insertion and delayed node writes passed'); diff --git a/tests/studio_contracts.mjs b/tests/studio_contracts.mjs new file mode 100644 index 0000000..aa4a3f5 --- /dev/null +++ b/tests/studio_contracts.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { materialPromptText, loadMaterialPrompts } from '../web/modules/material_prompt_data.js'; +import { composePromptPlan, joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan } from '../web/modules/prompt_composition.js'; +import { translate } from '../web/modules/locales.js'; + +assert.deepEqual(materialPromptText({ data: { kind: 'prompt_text', note: { promptEn: ' exact ', promptZh: 'translation' } } }), { positive: ' exact ', negative: '' }); +assert.deepEqual(materialPromptText({ data: { kind: 'prompt_plan', plan: { parts: [{ positive: 'base', negative: 'bad' }, { positive: 'skip', enabled: false }], positive: 'scene' } } }), { positive: 'base\nscene', negative: 'bad' }); +assert.deepEqual(materialPromptText({ data: { kind: 'image_node_selection' }, prompt_groups: { positive: ['yes'], negative: ['no'] }, node_blocks: [{ type: 'CheckpointLoaderSimple', widgets_values: ['not-prompt'] }] }), { positive: 'yes', negative: 'no' }); +assert.deepEqual(materialPromptText({ data: {}, node_blocks: [{ widgets_values: ['unknown role'] }] }), { positive: '', negative: '' }); +let lastUrl; +globalThis.fetch = async url => { lastUrl = url; return { ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_note_bundle', note: { promptEn: 'real detail' } } }) }; }; +assert.equal((await loadMaterialPrompts('note.json')).positive, 'real detail'); assert.ok(lastUrl.includes('include_workflow=0')); + +class Element { + constructor(tag) { this.tagName = tag; this.children = []; this.style = {}; this.value = ''; this.attrs = {}; this.className = ''; this.classList = { add() {}, remove() {}, toggle() {} }; this.dataset = {}; } + appendChild(child) { child.parent = this; this.children.push(child); return child; } + append(...children) { children.forEach(c => this.appendChild(c)); } + replaceChildren() { this.children.forEach(c => c.parent = null); this.children = []; } + remove() { if (this.parent) this.parent.children = this.parent.children.filter(c => c !== this); this.parent = null; } + setAttribute(k, v) { this.attrs[k] = v; } + addEventListener(key, fn) { (this.listeners ||= {})[key] = fn; } + focus() {} + get isConnected() { return this.tagName === 'body' || !!this.parent?.isConnected; } +} +const document = { body: new Element('body'), createElement: tag => new Element(tag), addEventListener: (key, fn) => events[key] = fn }; +const text = (parent, tag, value, cls = '') => { const el = document.createElement(tag); el.textContent = value; el.className = cls; parent.appendChild(el); return el; }; +const all = parent => [parent, ...parent.children.flatMap(all)]; +const events = {}; +let sent; +const context = vm.createContext({ console, document, app: {}, translate, t: translate, text, composePromptPlan, joinPromptText, + categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan, + window: { addEventListener: (key, fn) => events[key] = fn, removeEventListener: (key, fn) => { if (events[key] === fn) delete events[key]; } }, + selectedMaterialNode: () => null, promptWidgetTargets: () => [], bindMaterialDrag() {}, + anomalousConfirm: async () => true, anomalousAlert: async msg => { throw new Error(msg); }, showMaterialSaved() {}, + jsonResponse: response => response.json(), loadMaterialPrompts: async () => ({ positive: 'imported', negative: '' }), + URL, URLSearchParams, AbortController, setTimeout, clearTimeout, + fetch: async (url, options) => { + sent = [url, options]; + return { ok: true, json: async () => url.includes('save_prompt_plan') ? { status: 'success' } : { status: 'success', materials: [{ filename: 'p.json', name: 'Real prompt' }], total: 49, page: 1, pages: 2 } }; + }, +}); +async function moduleFor(file, extra) { + const source = fs.readFileSync(`web/modules/${file}`, 'utf8').replace(/^import[\s\S]*?;\s*/gm, ''); + const mod = new vm.SourceTextModule(source + `\nexport { ${extra} };`, { + context, + identifier: `file:///${process.cwd().replace(/\\/g, '/')}/web/modules/${file}`, + initializeImportMeta(meta, module) { meta.url = module.identifier; } + }); await mod.link(() => { throw new Error('unexpected import'); }); await mod.evaluate(); return mod.namespace; +} +const recipes = await moduleFor('ui_recipes.js', 'recipeMatchesFilter, updateRecipeFilterControls, getRecipeReadiness'); +assert.equal(recipes.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'complete'), false); +assert.equal(recipes.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'partial'), true); +assert.equal(recipes.recipeMatchesFilter({ name: 'old' }, '', new Set(), 'complete'), true); +const recipeOwner = { recipeTagSelect: new Element('select'), recipeSelectedTags: new Set(['B']) }; +recipes.updateRecipeFilterControls(recipeOwner, [{ data: { tags: ['A', 'B'] } }]); +assert.equal(recipeOwner.recipeTagSelect.children.length, 3); assert.equal(recipeOwner.recipeTagSelect.value, 'B'); +assert.equal(recipes.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' }, currentAvailability: 'missing' }] } }).status, 'missing'); +assert.equal(recipes.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' } }] } }).status, 'warning'); + +const drags = await moduleFor('material_drag.js', ''); +const graph = { getNodeOnPos: () => null }; +const surface = new Element('canvas'); surface.getBoundingClientRect = () => ({ left: 0, top: 0, right: 100, bottom: 100 }); +context.app.graph = graph; context.app.canvas = { graph, canvas: surface, convertEventToCanvasOffset: () => [50, 50] }; +const source = new Element('article'); let drops = 0; +drags.bindMaterialDrag(source, {}, { payload: () => ({}), dropOnCanvas: () => { drops++; } }); +const event = target => ({ target, clientX: 50, clientY: 50, dataTransfer: { setData() {} }, preventDefault() {}, stopPropagation() {}, stopImmediatePropagation() {} }); +source.listeners.dragstart(event(source)); await events.drop(event(new Element('button'))); assert.equal(drops, 0); +source.listeners.dragstart(event(source)); context.app.graph = {}; await events.drop(event(surface)); assert.equal(drops, 0); context.app.graph = graph; +source.listeners.dragstart(event(source)); await events.drop(event(surface)); assert.equal(drops, 1); assert.equal(events.drop, undefined); +console.log('studio contracts: recipe scope/tags/readiness, prompt detail shapes, active material drag cleanup passed'); diff --git a/tests/ui_fixture.mjs b/tests/ui_fixture.mjs new file mode 100644 index 0000000..2dae420 --- /dev/null +++ b/tests/ui_fixture.mjs @@ -0,0 +1,115 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +export const all = node => [node, ...node.children.flatMap(all)]; +export class Events { + listeners = new Map(); + addEventListener(type, fn) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(fn); } + removeEventListener(type, fn) { this.listeners.get(type)?.delete(fn); } + dispatch(type, event = {}) { for (const fn of [...(this.listeners.get(type) || [])]) fn(event); } + listenerCount() { return [...this.listeners.values()].reduce((sum, set) => sum + set.size, 0); } +} +export class Element extends Events { + constructor(tag) { + super(); + this.tagName = tag; this.children = []; this.dataset = {}; this.attrs = {}; + this.className = ''; this.value = ''; this._text = ''; this.disabled = false; + this.style = { setProperty(key, value) { this[key] = value; }, removeProperty(key) { delete this[key]; } }; + this.classList = { + contains: name => this.className.split(/\s+/).includes(name), + add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); }, + remove: (...names) => { this.className = this.className.split(/\s+/).filter(name => !names.includes(name)).join(' '); }, + toggle: (name, force) => { const enabled = force ?? !this.classList.contains(name); this.classList[enabled ? 'add' : 'remove'](name); return enabled; }, + }; + } + set textContent(value) { this.replaceChildren(); this._text = String(value); } + get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } + set innerHTML(value) { this.textContent = value; } + get innerHTML() { return this.textContent; } + appendChild(child) { child.remove(); child.parentNode = this; this.children.push(child); return child; } + append(...children) { children.forEach(child => this.appendChild(child)); } + insertBefore(child, before) { if (!before) return this.appendChild(child); child.remove(); child.parentNode = this; this.children.splice(this.children.indexOf(before), 0, child); return child; } + prepend(child) { child.remove(); child.parentNode = this; this.children.unshift(child); } + replaceChildren(...children) { for (const child of this.children) child.parentNode = null; this.children = []; this._text = ''; this.append(...children); } + remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; } + get parentElement() { return this.parentNode; } + get isConnected() { return this.tagName === 'body' || !!this.parentNode?.isConnected; } + get firstChild() { return this.children[0] || null; } + get options() { return this.children; } + cloneNode(deep = false) { + const copy = new Element(this.tagName); + copy.className = this.className; copy.value = this.value; copy._text = this._text; + copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset }; + if (deep) copy.append(...this.children.map(child => child.cloneNode(true))); + return copy; + } + setAttribute(name, value) { this.attrs[name] = value; } + removeAttribute(name) { delete this.attrs[name]; } + getAttribute(name) { return this.attrs[name] ?? null; } + matches(selector) { return selector.startsWith('.') ? this.classList.contains(selector.slice(1)) : this.tagName === selector; } + querySelectorAll(selector) { return all(this).slice(1).filter(child => selector.split(',').some(part => child.matches(part.trim()))); } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + closest(selector) { return this.matches(selector) ? this : this.parentNode?.closest(selector) || null; } + contains(child) { return all(this).includes(child); } + hasChildNodes() { return !!this.children.length; } + getBoundingClientRect() { return { width: parseFloat(this.style.width || this.style['--amb-translator-width']) || 580, height: 600, top: 0, left: 0, right: 580, bottom: 600 }; } + click() { if (!this.disabled) return this.onclick?.({ target: this, preventDefault() {}, stopPropagation() {} }); } + showModal() { this.open = true; } + close() { this.open = false; this.onclose?.(); } + focus() {} + scrollIntoView() {} +} + +export function fixture() { + const document = new Events(); + document.body = new Element('body'); + document.createElement = tag => new Element(tag); + document.createDocumentFragment = () => new Element('fragment'); + document.querySelector = selector => document.body.querySelector(selector); + document.querySelectorAll = selector => document.body.querySelectorAll(selector); + const window = new Events(); + Object.assign(window, { innerWidth: 1440, innerHeight: 900, anomalous_browser_lang: 'en' }); + const storage = new Map(); + const timers = new Map(); + let timerId = 0; + const errors = [], requests = [], clipboard = []; + const node = { id: 1, type: 'CLIPTextEncode', widgets: [{ name: 'text', type: 'customtext', value: 'original' }], widgets_values: ['original'] }; + const app = { graph: { getNodeById: id => id === node.id ? node : null }, canvas: { selected_nodes: { 1: node } } }; + const state = { + app, node, window, document, errors, requests, clipboard, + async fetch(url) { return { ok: true, json: async () => String(url).includes('/materials?') ? { status: 'success', materials: [], pages: 1 } : { status: 'success', translated: 'translated' } }; }, + }; + const context = vm.createContext({ console, URL, URLSearchParams, Blob, AbortController, DOMException, structuredClone, + document, window, app, errors, anomalous_browser_lang: 'en', + setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id), + requestAnimationFrame: callback => callback(), queueMicrotask, + localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) }, + navigator: { clipboard: { async writeText(text) { clipboard.push(text); } } }, confirm: () => true, + fetch: async (url, options = {}) => { requests.push([url, options]); return state.fetch(url, options); }, + }); + const modules = new Map(); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web/modules'); + const load = filename => { + if (modules.has(filename)) return modules.get(filename); + let source; + if (filename.endsWith('/scripts/app.js')) source = 'export const app = globalThis.app;'; + else if (filename.endsWith('/ui_dialog.js')) source = 'export async function anomalousAlert(message) { errors.push(message); } export async function anomalousConfirm() { return true; }'; + else if (filename.endsWith('/material_feedback.js')) source = 'export function showMaterialSaved() {}'; + else if (filename.endsWith('/ui_gallery_detail.js')) source = 'export function showImageWorkbench() {}'; + else source = fs.readFileSync(filename, 'utf8'); + const mod = new vm.SourceTextModule(source, { context, identifier: filename }); + modules.set(filename, mod); return mod; + }; + state.module = async name => { + const mod = load(path.resolve(root, name).replaceAll('\\', '/')); + if (mod.status === 'unlinked') await mod.link((specifier, parent) => load(path.resolve(path.dirname(parent.identifier), specifier).replaceAll('\\', '/'))); + if (mod.status === 'linked') await mod.evaluate(); + return mod.namespace; + }; + state.button = (rootNode, label) => all(rootNode).find(el => el.tagName === 'button' && el.textContent.includes(label)); + state.flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; + state.runTimers = () => { const pending = [...timers.values()]; timers.clear(); pending.forEach(callback => callback()); }; + return state; +} diff --git a/web/main.js b/web/main.js index f3b5451..80e3c84 100644 --- a/web/main.js +++ b/web/main.js @@ -269,14 +269,6 @@ class AnomalousBrowser { : 'balanced'; this.createDOM(); } - // [EXTRACTED] createDOM - // [EXTRACTED] showHelp - // [EXTRACTED] loadFolders - // [EXTRACTED] renderSidebar - // [EXTRACTED] loadModels - // [EXTRACTED] applyModelToCanvas - // [EXTRACTED] stopMediaInContainer - // [EXTRACTED] showDetail show() { if (this._idleReleaseTimer) { @@ -314,33 +306,7 @@ class AnomalousBrowser { const trigger = this.triggerButton || document.getElementById('anomalous-trigger-btn'); trigger?.classList.toggle('anomalous-trigger-hidden', !visible || entryMode !== 'floating'); } - // [EXTRACTED] showNotebooks - async translateText(text) { - if (!text || !text.trim()) return ""; - try { - const res = await fetch('/anomalous/translate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: text, target_lang: window.anomalous_browser_lang === 'zh' ? 'zh-CN' : 'en' }) - }); - const data = await res.json(); - return data.translated || text; - } catch (e) { return text; } - } - // [EXTRACTED] loadGalleryImages - // [EXTRACTED] showEditModal - // [EXTRACTED] showGeneratedGallery - // [EXTRACTED] showGallerySelectMode - // [EXTRACTED] showGalleryViewer - // [EXTRACTED] refreshNotebooks - // [EXTRACTED] saveCurrentNotebook - // [EXTRACTED] deleteCurrentNotebook - // [EXTRACTED] renderNotebookEditor - // [EXTRACTED] fillNotebookGalleries - // [EXTRACTED] sendNotebookToCanvas - // [EXTRACTED] setWidgetValuePath - // [EXTRACTED] hideAllPanels // [Doctor & Assistant Panel methods extracted to ui_doctor.js] @@ -634,7 +600,6 @@ class AnomalousBrowser { overlay.style.display = 'flex'; } - // [EXTRACTED] _openAdvancedModelSelector } diff --git a/web/modules/locales.js b/web/modules/locales.js index 518366d..47de241 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1,5 +1,7 @@ export const i18n = { zh: { + promptSourceSynced: '已同步 {count} 张新词卡', + promptFullTextEditHint: '修改全文会将当前方向已启用的词块合并为一个词块。', materialNoPromptContent: '这份素材没有已明确分类的提示词,请先在详情中检查提示词角色。', promptDrawerReplace: '替换这组文字', promptDrawerAppend: '加入这组文字', @@ -1106,6 +1108,8 @@ export const i18n = { recipeCardSpecsResolution: '尺寸', }, en: { + promptSourceSynced: 'Synced {count} new prompt cards', + promptFullTextEditHint: 'Editing the full text merges the enabled blocks in this direction into one block.', materialNoPromptContent: 'This material has no classified prompts. Check its prompt roles in details first.', promptDrawerReplace: 'Replace this text', promptDrawerAppend: 'Add this text', diff --git a/web/modules/material_drag.js b/web/modules/material_drag.js index c6a9f6c..600c206 100644 --- a/web/modules/material_drag.js +++ b/web/modules/material_drag.js @@ -1,6 +1,7 @@ import { app } from '../../../scripts/app.js'; import { translate as t } from './locales.js'; -import { materialNodeHeading, text } from './material_inspector.js'; +import { text } from './ui_dom.js'; +import { materialNodeHeading } from './material_inspector.js'; import { anomalousAlert } from './ui_dialog.js'; let activeDrag = null; diff --git a/web/modules/material_inspector.js b/web/modules/material_inspector.js index f99b093..ca3d7ee 100644 --- a/web/modules/material_inspector.js +++ b/web/modules/material_inspector.js @@ -1,15 +1,8 @@ +import { text } from './ui_dom.js'; /** Shared image metadata and node-parameter inspection for the library and workbench. */ import { translate } from './locales.js'; const t = (key, params) => translate(key, params); -export function text(parent, tag, value, className = '') { - const element = document.createElement(tag); - if (className) element.className = className; - element.textContent = value == null ? '' : String(value); - parent.appendChild(element); - return element; -} - export function sectionLabel(parent, value) { return text(parent, 'div', value, 'anomalous-material-section-label'); } @@ -75,13 +68,6 @@ export function lookupLocalModel(localModels, path) { return localModels[path] || localModels[fileBaseName(path)] || null; } -export async function jsonResponse(response, fallbackMessage) { - let payload = {}; - try { payload = await response.json(); } catch (error) { /* non-JSON server failure */ } - if (!response.ok) throw new Error(payload.message || fallbackMessage); - return payload; -} - export async function parsePngMetadataFromUrl(url, options = {}) { if (!url) return null; try { diff --git a/web/modules/material_prompt_data.js b/web/modules/material_prompt_data.js index 313e613..4147f90 100644 --- a/web/modules/material_prompt_data.js +++ b/web/modules/material_prompt_data.js @@ -1,5 +1,6 @@ import { composePromptPlan } from './prompt_composition.js'; -import { jsonResponse } from './material_inspector.js'; +import { jsonResponse } from './ui_dom.js'; + export function materialPromptText(payload) { const data = payload.data || {}; diff --git a/web/modules/prompt_material_source.js b/web/modules/prompt_material_source.js new file mode 100644 index 0000000..4641791 --- /dev/null +++ b/web/modules/prompt_material_source.js @@ -0,0 +1,50 @@ +import { loadMaterialPrompts } from './material_prompt_data.js'; +import { categorizePromptSnippet } from './prompt_composition.js'; +import { jsonResponse } from './ui_dom.js'; + +// Load every page through the same path for initial and explicit synchronization. +export async function loadPromptSourceCards(signal) { + const cards = []; + const filenames = new Set(); + let page = 1; + let pages = 1; + do { + signal.throwIfAborted(); + const response = await fetch(`/anomalous/materials?category=prompts&limit=100&page=${page}`, { signal }); + const payload = await jsonResponse(response, 'materialLoadError'); + if (payload.status !== 'success' || !Array.isArray(payload.materials)) throw new Error('materialLoadError'); + pages = Number(payload.pages) || 1; + for (const item of payload.materials) { + signal.throwIfAborted(); + if (filenames.has(item.filename)) continue; + filenames.add(item.filename); + const prompts = await loadMaterialPrompts(item.filename, signal); + for (const role of ['positive', 'negative']) { + const content = prompts[role]?.trim(); + if (!content) continue; + cards.push({ + id: `mat_${role}_${item.filename}`, filename: item.filename, + title: item.name || item.filename, content, role, + category: role === 'negative' ? 'base' : categorizePromptSnippet(content), + persisted: true, + }); + } + } + page++; + } while (page <= pages); + signal.throwIfAborted(); + return cards; +} + +export function mergePromptSourceCards(existing, incoming) { + const keys = new Set(existing.map(card => `${card.role}\0${card.content.trim()}`)); + let added = 0; + for (const card of incoming) { + const key = `${card.role}\0${card.content.trim()}`; + if (keys.has(key)) continue; + existing.push(card); + keys.add(key); + added++; + } + return added; +} diff --git a/web/modules/prompt_studio_data.js b/web/modules/prompt_studio_data.js new file mode 100644 index 0000000..be1bea0 --- /dev/null +++ b/web/modules/prompt_studio_data.js @@ -0,0 +1,109 @@ +import { categorizePromptSnippet, assemblePromptBlocks } from './prompt_composition.js'; +export const newDraft = () => ({ + name: '', + tags: [], + plan: { + version: 2, + parts: [], + positive: '', + negative: '', + }, +}); + +export const CATEGORY_META = { + base: { zh: '通用底模', en: 'Base Quality', color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.15)', border: 'rgba(56, 189, 248, 0.4)' }, + style: { zh: '风格氛围', en: 'Art Style', color: '#c084fc', bg: 'rgba(192, 132, 252, 0.15)', border: 'rgba(192, 132, 252, 0.4)' }, + subject: { zh: '主体内容', en: 'Subject', color: '#4ade80', bg: 'rgba(74, 222, 128, 0.15)', border: 'rgba(74, 222, 128, 0.4)' }, + trigger: { zh: 'LoRA/触发', en: 'LoRA / Trigger', color: '#fb923c', bg: 'rgba(251, 146, 60, 0.15)', border: 'rgba(251, 146, 60, 0.4)' }, +}; + +// Built-in starter prompt cards for left source deck +export const STARTER_SOURCE_PROMPTS = [ + { + id: 'preset_base_quality', + title: '画质底模词 (Quality Base)', + content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr, absurdres', + role: 'positive', + category: 'base', + }, + { + id: 'preset_real_detail', + title: '写实与材质增强 (Realistic Detail)', + content: 'highres, realistic skin texture, sharp focus, subsurface scattering, 35mm photograph', + role: 'positive', + category: 'base', + }, + { + id: 'preset_cinematic_light', + title: '电影胶片光影 (Cinematic Lighting)', + content: 'cinematic lighting, dramatic shadows, soft volumetric glow, ray tracing, atmospheric', + role: 'positive', + category: 'style', + }, + { + id: 'preset_anime_cyber', + title: '赛博霓虹风 (Cyberpunk Neon)', + content: 'anime style, cyberpunk aesthetic, vibrant neon reflections, futuristic metropolis backdrop', + role: 'positive', + category: 'style', + }, + { + id: 'preset_girl_face', + title: '美少女面部特写 (Portrait 1girl)', + content: '1girl, beautiful detailed expressive eyes, smile, delicate face, soft wind-blown hair', + role: 'positive', + category: 'subject', + }, + { + id: 'preset_neg_filter', + title: '通用负向过滤词 (Negative Base Filter)', + content: 'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, missing fingers, extra digits, cropped, blurry, watermark', + role: 'negative', + category: 'base', + }, +]; + +export function normalizeBlock(part, index = 0) { + const id = part.id || `blk_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`; + const role = part.role || (part.negative && !part.positive ? 'negative' : 'positive'); + const content = String(part.content ?? (role === 'positive' ? part.positive : part.negative) ?? '').trim(); + let category = part.category; + if (!CATEGORY_META[category]) { + category = categorizePromptSnippet(content); + } + const meta = CATEGORY_META[category] || CATEGORY_META.subject; + const defaultTitle = (window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en) + (index ? ` #${index + 1}` : ''); + return { + id, + title: part.name || part.title || defaultTitle, + content, + role, + category, + enabled: part.enabled !== false, + }; +} + +export function syncDraftSynthesizedText(draft) { + if (!draft || !draft.plan) return; + draft.plan.parts ||= []; + const posParts = draft.plan.parts.filter(p => p.role === 'positive'); + const negParts = draft.plan.parts.filter(p => p.role === 'negative'); + draft.plan.positive = assemblePromptBlocks(posParts, 'positive'); + draft.plan.negative = assemblePromptBlocks(negParts, 'negative'); +} + +// Full-text edits replace the enabled blocks of one role. Keep disabled blocks +// and the other role intact; synthesized fields are always derived from parts. +export function replaceDraftRoleText(draft, role, content) { + const parts = draft.plan.parts || []; + const first = parts.findIndex(part => part.role === role && part.enabled !== false); + const replacement = content.trim() ? normalizeBlock({ content, role }) : null; + const next = []; + parts.forEach((part, index) => { + if (index === first && replacement) next.push(replacement); + if (part.role !== role || part.enabled === false) next.push(part); + }); + if (first < 0 && replacement) next.push(replacement); + draft.plan.parts = next; + syncDraftSynthesizedText(draft); +} diff --git a/web/modules/translation_service.js b/web/modules/translation_service.js index 0142f30..18cd5da 100644 --- a/web/modules/translation_service.js +++ b/web/modules/translation_service.js @@ -52,10 +52,12 @@ export function normalizePromptFormatting(text, delimiter = ', ') { * @param {string} text - Raw prompt text to translate * @param {Object} [options] * @param {string} [options.targetLang] - Target language ('en' | 'zh-CN' | 'ja' | etc.) + * @param {AbortSignal} [options.signal] - Cancels requests when their owning view closes * @param {boolean} [options.bypassCache=false] - If true, ignores in-memory cache * @returns {Promise<{ ok: boolean, translated: string, targetLang: string, error?: string }>} */ export async function translatePromptText(text, options = {}) { + if (options.signal?.aborted) return { ok: false, cancelled: true, translated: '', targetLang: options.targetLang || 'en' }; const raw = String(text || '').trim(); if (!raw) { return { ok: true, translated: '', targetLang: options.targetLang || 'en' }; @@ -71,6 +73,7 @@ export async function translatePromptText(text, options = {}) { try { const response = await fetch('/anomalous/translate', { method: 'POST', + signal: options.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: raw, @@ -83,6 +86,7 @@ export async function translatePromptText(text, options = {}) { } const data = await response.json(); + options.signal?.throwIfAborted(); if (data.status === 'error' || data.error) { throw new Error(data.error || 'Translation service failed'); } @@ -93,7 +97,7 @@ export async function translatePromptText(text, options = {}) { } // Cache the successful result (limit cache to 500 items) - if (translationCache.size > 500) { + if (translationCache.size >= 500 && !translationCache.has(cacheKey)) { const oldestKey = translationCache.keys().next().value; translationCache.delete(oldestKey); } @@ -101,6 +105,7 @@ export async function translatePromptText(text, options = {}) { return { ok: true, translated, targetLang, engine: data.engine }; } catch (err) { + if (options.signal?.aborted || err.name === 'AbortError') return { ok: false, cancelled: true, translated: raw, targetLang }; console.warn('[Anomalous Translation] Translate failed:', err); return { ok: false, diff --git a/web/modules/ui_dom.js b/web/modules/ui_dom.js new file mode 100644 index 0000000..25b18fa --- /dev/null +++ b/web/modules/ui_dom.js @@ -0,0 +1,14 @@ +export function text(parent, tag, value, className = '') { + const element = document.createElement(tag); + if (className) element.className = className; + element.textContent = value == null ? '' : String(value); + parent.appendChild(element); + return element; +} + +export async function jsonResponse(response, fallbackMessage) { + let payload = {}; + try { payload = await response.json(); } catch { /* A server error may not be JSON. */ } + if (!response.ok) throw new Error(payload.message || fallbackMessage); + return payload; +} diff --git a/web/modules/ui_gallery_detail.js b/web/modules/ui_gallery_detail.js index 918879a..e5f76ea 100644 --- a/web/modules/ui_gallery_detail.js +++ b/web/modules/ui_gallery_detail.js @@ -15,13 +15,12 @@ import { showMaterialSaved } from './material_feedback.js'; import { app } from '../../../scripts/app.js'; import { translate } from './locales.js'; import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; +import { text, jsonResponse } from './ui_dom.js'; import { - text, sectionLabel, fileBaseName, appendLocalPreview, modelCustomNotes, - jsonResponse, parsePngMetadataFromUrl, extractWorkflowDetails, resolveLocalModels, diff --git a/web/modules/ui_lifecycle.js b/web/modules/ui_lifecycle.js new file mode 100644 index 0000000..eca177a --- /dev/null +++ b/web/modules/ui_lifecycle.js @@ -0,0 +1,57 @@ +// Resources belong to the view that creates them, including every close path. +export function createViewScope() { + const controller = new AbortController(); + const cleanups = new Set(); + const onDispose = cleanup => { + if (controller.signal.aborted) cleanup(); + else cleanups.add(cleanup); + return () => cleanups.delete(cleanup); + }; + return { + signal: controller.signal, + onDispose, + listen(target, event, handler, options) { + target.addEventListener(event, handler, options); + return onDispose(() => target.removeEventListener(event, handler, options)); + }, + dispose() { + if (controller.signal.aborted) return; + controller.abort(); + for (const cleanup of cleanups) cleanup(); + cleanups.clear(); + }, + }; +} + +export function bindDrawerResize(handle, panel, scope, { side, minWidth, setWidth, saveWidth, enabled = () => true }) { + let finish = null; + handle.onmousedown = event => { + if (event.button !== 0 || scope.signal.aborted || !enabled()) return; + finish?.(); + event.preventDefault(); + const startX = event.clientX; + const startWidth = panel.getBoundingClientRect().width; + const previousSelect = document.body.style.userSelect; + const previousCursor = document.body.style.cursor; + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'ew-resize'; + handle.classList.add('is-resizing'); + const move = moveEvent => { + const delta = (moveEvent.clientX - startX) * (side() === 'left' ? 1 : -1); + setWidth(Math.max(minWidth, Math.min(window.innerWidth * 0.85, startWidth + delta))); + }; + const stop = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + document.body.style.userSelect = previousSelect; + document.body.style.cursor = previousCursor; + handle.classList.remove('is-resizing'); + finish = null; + }; + const up = () => { stop(); saveWidth(panel.getBoundingClientRect().width); }; + finish = stop; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + scope.onDispose(() => { finish?.(); handle.onmousedown = null; }); +} diff --git a/web/modules/ui_material_application.js b/web/modules/ui_material_application.js index 84a4ab7..0f9ced2 100644 --- a/web/modules/ui_material_application.js +++ b/web/modules/ui_material_application.js @@ -1,6 +1,7 @@ import { app } from '../../../scripts/app.js'; import { translate as t } from './locales.js'; -import { text, materialNodeHeading } from './material_inspector.js'; +import { text } from './ui_dom.js'; +import { materialNodeHeading } from './material_inspector.js'; import { applyMaterialBlock, selectedMaterialNode } from './node_material_actions.js'; export function showMaterialApplication(parent, result, node) { diff --git a/web/modules/ui_materials.js b/web/modules/ui_materials.js index 9aba548..430a4be 100644 --- a/web/modules/ui_materials.js +++ b/web/modules/ui_materials.js @@ -7,7 +7,9 @@ import { app } from '../../../scripts/app.js'; import { translate, resolveLocale } from './locales.js'; import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; import { showImageWorkbench } from './ui_gallery_detail.js'; -import { text, sectionLabel, fileBaseName, jsonResponse, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js'; +import { text, jsonResponse } from './ui_dom.js'; +import { escapeHtml } from './safe_dom.js'; +import { sectionLabel, fileBaseName, renderDetailedNodeCards, applyPromptRolesToBlocks, renderMaterialPromptGroups, materialNodeHeading } from './material_inspector.js'; const t = (key, params) => translate(key, params); const isPromptMaterial = material => ['prompt_note_bundle', 'prompt_text'].includes(material.kind); @@ -32,7 +34,6 @@ function hideSiblingWorkspaceViews(owner) { if (owner.notebookContainer) owner.notebookContainer.style.display = 'none'; if (owner.notebookBody) owner.notebookBody.style.display = 'none'; if (owner.recipeView) owner.recipeView.style.display = 'none'; - if (owner.promptStudioContainer) owner.promptStudioContainer.style.display = 'none'; if (owner.recipeContainer) owner.recipeContainer.style.display = 'none'; owner.notebookNotesTab?.classList.remove('active'); owner.notebookRecipesTab?.classList.remove('active'); @@ -924,9 +925,8 @@ export async function refreshMaterials(page = this.materialPage || 1) { } export async function showMaterials() { - this.closePromptImportDrawer?.(); + this.nbPanel.style.display = 'flex'; - if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none'; if (!this.materialContainer) { this.materialContainer = text(this.nbPanel, 'div', '', 'anomalous-nb-container anomalous-material-container'); const header = text(this.materialContainer, 'div', '', 'anomalous-nb-header'); diff --git a/web/modules/ui_notebooks.js b/web/modules/ui_notebooks.js index 8f014df..e147d19 100644 --- a/web/modules/ui_notebooks.js +++ b/web/modules/ui_notebooks.js @@ -31,7 +31,6 @@ function restoreWorkspaceReturnPanel(owner) { } export function closeWorkspace() { - this.closePromptImportDrawer?.(); clearTimeout(this.materialSearchTimer); this.materialListController?.abort(); this.materialListController = null; @@ -58,7 +57,6 @@ export function closeWorkspace() { if (this.materialView) this.materialView.style.display = 'none'; if (this.notebookBody) this.notebookBody.style.display = 'none'; if (this.materialContainer) this.materialContainer.style.display = 'none'; - if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none'; if (this.recipeContainer) this.recipeContainer.style.display = 'none'; if (this.nbPanel) this.nbPanel.style.display = 'none'; restoreWorkspaceReturnPanel(this); @@ -67,9 +65,7 @@ export function closeWorkspace() { export async function showNotebooks() { - this.closePromptImportDrawer?.(); if (this.materialContainer) this.materialContainer.style.display = 'none'; - if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none'; if (this.recipeContainer) this.recipeContainer.style.display = 'none'; if (this.notebookContainer) this.notebookContainer.style.display = 'flex'; if (this.nbInitialized) { diff --git a/web/modules/ui_prompt_composer.js b/web/modules/ui_prompt_composer.js index 4bb8c97..b22bce8 100644 --- a/web/modules/ui_prompt_composer.js +++ b/web/modules/ui_prompt_composer.js @@ -1,346 +1,28 @@ -import { loadMaterialPrompts } from './material_prompt_data.js'; -import { bindMaterialDrag } from './material_drag.js'; -import { app } from '../../../scripts/app.js'; -import { translate as t } from './locales.js'; -import { text, jsonResponse, materialNodeHeading } from './material_inspector.js'; +import { createViewScope, bindDrawerResize } from './ui_lifecycle.js'; +import { createPromptWorkbench } from './ui_prompt_workbench.js'; +import { newDraft, normalizeBlock, syncDraftSynthesizedText } from './prompt_studio_data.js'; +import { categorizePromptSnippet, planToWorkbenchDraft } from './prompt_composition.js'; import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; -import { composePromptPlan, joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan } from './prompt_composition.js'; -import { applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js'; -import { showMaterialApplication } from './ui_material_application.js'; -import { showMaterialSaved } from './material_feedback.js'; -import { translatePromptText } from './translation_service.js'; +import { jsonResponse } from './ui_dom.js'; +import { translate as t } from './locales.js'; -const clone = value => JSON.parse(JSON.stringify(value)); -const newDraft = () => ({ - name: '', - tags: [], - plan: { - version: 2, - parts: [], - positive: '', - negative: '', - }, -}); - -export const CATEGORY_META = { - base: { zh: '通用底模', en: 'Base Quality', color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.15)', border: 'rgba(56, 189, 248, 0.4)' }, - style: { zh: '风格氛围', en: 'Art Style', color: '#c084fc', bg: 'rgba(192, 132, 252, 0.15)', border: 'rgba(192, 132, 252, 0.4)' }, - subject: { zh: '主体内容', en: 'Subject', color: '#4ade80', bg: 'rgba(74, 222, 128, 0.15)', border: 'rgba(74, 222, 128, 0.4)' }, - trigger: { zh: 'LoRA/触发', en: 'LoRA / Trigger', color: '#fb923c', bg: 'rgba(251, 146, 60, 0.15)', border: 'rgba(251, 146, 60, 0.4)' }, -}; - -// Built-in starter prompt cards for left source deck -const STARTER_SOURCE_PROMPTS = [ - { - id: 'preset_base_quality', - title: '画质底模词 (Quality Base)', - content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr, absurdres', - role: 'positive', - category: 'base', - }, - { - id: 'preset_real_detail', - title: '写实与材质增强 (Realistic Detail)', - content: 'highres, realistic skin texture, sharp focus, subsurface scattering, 35mm photograph', - role: 'positive', - category: 'base', - }, - { - id: 'preset_cinematic_light', - title: '电影胶片光影 (Cinematic Lighting)', - content: 'cinematic lighting, dramatic shadows, soft volumetric glow, ray tracing, atmospheric', - role: 'positive', - category: 'style', - }, - { - id: 'preset_anime_cyber', - title: '赛博霓虹风 (Cyberpunk Neon)', - content: 'anime style, cyberpunk aesthetic, vibrant neon reflections, futuristic metropolis backdrop', - role: 'positive', - category: 'style', - }, - { - id: 'preset_girl_face', - title: '美少女面部特写 (Portrait 1girl)', - content: '1girl, beautiful detailed expressive eyes, smile, delicate face, soft wind-blown hair', - role: 'positive', - category: 'subject', - }, - { - id: 'preset_neg_filter', - title: '通用负向过滤词 (Negative Base Filter)', - content: 'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, missing fingers, extra digits, cropped, blurry, watermark', - role: 'negative', - category: 'base', - }, -]; - -function normalizeBlock(part, index = 0) { - const id = part.id || `blk_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`; - const role = part.role || (part.negative && !part.positive ? 'negative' : 'positive'); - const content = String(part.content ?? (role === 'positive' ? part.positive : part.negative) ?? '').trim(); - let category = part.category; - if (!CATEGORY_META[category]) { - category = categorizePromptSnippet(content); - } - const meta = CATEGORY_META[category] || CATEGORY_META.subject; - const defaultTitle = (window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en) + (index ? ` #${index + 1}` : ''); - return { - id, - title: part.name || part.title || defaultTitle, - content, - role, - category, - enabled: part.enabled !== false, - }; -} - -export function syncDraftSynthesizedText(draft) { - if (!draft || !draft.plan) return; - draft.plan.parts ||= []; - const posParts = draft.plan.parts.filter(p => p.role === 'positive'); - const negParts = draft.plan.parts.filter(p => p.role === 'negative'); - draft.plan.positive = assemblePromptBlocks(posParts, 'positive'); - draft.plan.negative = assemblePromptBlocks(negParts, 'negative'); -} - -export function addPromptToDraft(owner, name, positive, negative = '') { - owner.promptPlanDraft ||= newDraft(); - const draft = owner.promptPlanDraft; - draft.plan.parts ||= []; - if (name && !draft.name) draft.name = String(name).slice(0, 120); - if (positive && positive.trim()) { - draft.plan.parts.push(normalizeBlock({ - title: name || (window.anomalous_browser_lang === 'zh' ? '导入正向词' : 'Imported Positive'), - content: positive.trim(), - role: 'positive', - category: categorizePromptSnippet(positive), - }, draft.plan.parts.length)); - } - if (negative && negative.trim()) { - draft.plan.parts.push(normalizeBlock({ - title: name ? `${name} (负向)` : (window.anomalous_browser_lang === 'zh' ? '导入负向词' : 'Imported Negative'), - content: negative.trim(), - role: 'negative', - category: 'base', - }, draft.plan.parts.length)); - } - syncDraftSynthesizedText(draft); - showPromptComposer(owner); -} - -export function openPromptInspectorModal(owner, draft, defaultRole = 'positive', onApplyToNode = null) { - const existing = document.querySelector('.anomalous-prompt-inspector-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.className = 'anomalous-prompt-inspector-overlay'; - - const modal = document.createElement('div'); - modal.className = 'anomalous-prompt-inspector-modal'; - overlay.appendChild(modal); - - let currentRole = defaultRole || 'positive'; - - // Header - const header = document.createElement('header'); - header.className = 'anomalous-inspector-header'; - const h3 = document.createElement('h3'); - h3.textContent = window.anomalous_browser_lang === 'zh' ? '🔍 提示词深度全览与精修' : '🔍 Prompt Inspector & Editor'; - h3.style.margin = '0'; - h3.style.fontSize = '0.92rem'; - h3.style.color = '#e2e8f0'; - header.appendChild(h3); - - // Role Tabs - const tabsWrap = document.createElement('div'); - tabsWrap.style.display = 'flex'; - tabsWrap.style.gap = '6px'; - - const posTab = document.createElement('button'); - posTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'positive' ? ' is-active' : ''}`; - posTab.textContent = `🔆 ${window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive'}`; - - const negTab = document.createElement('button'); - negTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'negative' ? ' is-active' : ''}`; - negTab.textContent = `🌙 ${window.anomalous_browser_lang === 'zh' ? '负向提示词' : 'Negative'}`; - - tabsWrap.appendChild(posTab); - tabsWrap.appendChild(negTab); - header.appendChild(tabsWrap); - - const closeBtn = document.createElement('button'); - closeBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; - closeBtn.textContent = '✕'; - closeBtn.title = window.anomalous_browser_lang === 'zh' ? '关闭 (Esc)' : 'Close (Esc)'; - closeBtn.onclick = () => overlay.remove(); - header.appendChild(closeBtn); - - // Body - const body = document.createElement('div'); - body.className = 'anomalous-inspector-body'; - - const textarea = document.createElement('textarea'); - textarea.className = 'anomalous-inspector-textarea'; - textarea.spellcheck = false; - - syncDraftSynthesizedText(draft); - textarea.value = draft?.plan?.[currentRole] || ''; - - const updateStats = () => { - const val = textarea.value; - const words = val.split(/[,,\s\n]+/).filter(Boolean).length; - statsEl.textContent = `${val.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'} · ~${words} ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`; - }; - - const updateTabStyles = () => { - posTab.classList.toggle('is-active', currentRole === 'positive'); - negTab.classList.toggle('is-active', currentRole === 'negative'); - textarea.value = draft?.plan?.[currentRole] || ''; - updateStats(); - }; - - posTab.onclick = () => { currentRole = 'positive'; updateTabStyles(); }; - negTab.onclick = () => { currentRole = 'negative'; updateTabStyles(); }; - - body.appendChild(textarea); - - // Footer - const footer = document.createElement('footer'); - footer.className = 'anomalous-inspector-footer'; - - const statsEl = document.createElement('div'); - statsEl.style.fontSize = '0.75rem'; - statsEl.style.color = '#94a3b8'; - statsEl.style.fontFamily = 'monospace'; - - textarea.oninput = () => { - if (draft?.plan) draft.plan[currentRole] = textarea.value; - updateStats(); - }; - updateStats(); - - footer.appendChild(statsEl); - - const actions = document.createElement('div'); - actions.style.display = 'flex'; - actions.style.gap = '6px'; - - // Normalize formatting - const normBtn = document.createElement('button'); - normBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; - normBtn.textContent = `🧹 ${window.anomalous_browser_lang === 'zh' ? '规范化' : 'Normalize'}`; - normBtn.title = window.anomalous_browser_lang === 'zh' ? '清理多余顿号、分号与重复逗号' : 'Clean formatting and punctuation'; - normBtn.onclick = () => { - const cleaned = textarea.value - .replace(/[,,、;;||\n\r]+/g, ', ') - .replace(/\s+,/g, ',') - .replace(/,\s*,/g, ', ') - .replace(/^\s*,\s*|\s*,\s*$/g, '') - .trim(); - textarea.value = cleaned; - if (draft?.plan) draft.plan[currentRole] = cleaned; - updateStats(); - }; - actions.appendChild(normBtn); - - // Translate - const transBtn = document.createElement('button'); - transBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; - transBtn.textContent = `🌐 ${window.anomalous_browser_lang === 'zh' ? '翻译' : 'Translate'}`; - transBtn.onclick = async () => { - const raw = textarea.value.trim(); - if (!raw) return; - const orig = transBtn.textContent; - transBtn.textContent = '⏳'; - try { - const res = await translatePromptText(raw); - if (res.ok && res.translated) { - textarea.value = res.translated; - if (draft?.plan) draft.plan[currentRole] = res.translated; - updateStats(); - } - } finally { - transBtn.textContent = orig; - } - }; - actions.appendChild(transBtn); - - // Copy - const copyBtn = document.createElement('button'); - copyBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; - copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`; - copyBtn.onclick = async () => { - await navigator.clipboard.writeText(textarea.value); - copyBtn.textContent = '✓'; - setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`; }, 1200); - }; - actions.appendChild(copyBtn); - - // Apply to node & close - if (typeof onApplyToNode === 'function') { - const applyBtn = document.createElement('button'); - applyBtn.className = 'anomalous-btn-primary anomalous-btn-sm'; - applyBtn.textContent = `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点并关闭' : 'Write Node & Close'}`; - applyBtn.onclick = async () => { - if (draft?.plan) draft.plan[currentRole] = textarea.value; - await onApplyToNode(currentRole, textarea.value); - overlay.remove(); - }; - actions.appendChild(applyBtn); - } - - footer.appendChild(actions); - - modal.appendChild(header); - modal.appendChild(body); - modal.appendChild(footer); - - overlay.onclick = (e) => { - if (e.target === overlay) overlay.remove(); - }; - - const onKey = (e) => { - if (e.key === 'Escape') { - window.removeEventListener('keydown', onKey); - overlay.remove(); - } - }; - window.addEventListener('keydown', onKey); - - document.body.appendChild(overlay); -} - -let activeStudioOverlay = null; -let activeStudioDrawer = null; -let activeCardPreviewPopover = null; - -export function hideCardPreviewPopover() { - if (activeCardPreviewPopover) { - activeCardPreviewPopover.remove(); - activeCardPreviewPopover = null; - } -} +let activeStudio = null; +let pendingPlanLoad = null; export function closePromptStudio(owner) { - hideCardPreviewPopover(); - if (activeStudioOverlay) { - activeStudioOverlay.remove(); - activeStudioOverlay = null; - activeStudioDrawer = null; - } - if (owner?.sidePromptComposerControl) { - owner.sidePromptComposerControl = null; - } + pendingPlanLoad?.abort(); + pendingPlanLoad = null; + if (!activeStudio) return; + const previous = activeStudio; + activeStudio = null; + previous.scope.dispose(); + previous.owner.sidePromptComposerControl = null; document.body.classList.remove('anomalous-prompt-studio-open'); - if (!owner?.modal?.classList.contains('visible')) { - owner?.setTriggerVisible?.(true); - } + if (!previous.owner.modal?.classList.contains('visible')) previous.owner.setTriggerVisible?.(true); } export async function openPromptStudio(owner = this) { closePromptStudio(owner); - owner.closePromptImportDrawer?.(); // Canvas Focus Mode: If left master browser is visible, auto-collapse it so canvas has 80%+ space if (owner?.modal?.classList.contains('visible')) { @@ -355,11 +37,12 @@ export async function openPromptStudio(owner = this) { const overlay = document.createElement('div'); overlay.className = 'anomalous-prompt-studio-overlay'; - activeStudioOverlay = overlay; + const scope = createViewScope(); + activeStudio = { scope, owner }; + scope.onDispose(() => overlay.remove()); const drawer = document.createElement('aside'); drawer.className = 'anomalous-prompt-studio-drawer'; - activeStudioDrawer = drawer; overlay.appendChild(drawer); // Dock side persistence (default to left) @@ -370,7 +53,7 @@ export async function openPromptStudio(owner = this) { // Sidebar width persistence & resize handle (default to 580px) const savedWidth = localStorage.getItem('anomalous_studio_sidebar_width') || '580'; - drawer.style.width = `${Math.max(420, Math.min(window.innerWidth * 0.85, parseInt(savedWidth, 10)))}px`; + drawer.style.width = `${Math.max(420, Math.min(window.innerWidth * 0.85, (parseInt(savedWidth, 10) || 580)))}px`; const resizeHandle = document.createElement('div'); resizeHandle.className = 'anomalous-studio-resize-handle'; @@ -379,45 +62,12 @@ export async function openPromptStudio(owner = this) { : 'Drag to resize sidebar, double-click to reset'; drawer.appendChild(resizeHandle); - let isResizing = false; - let startX = 0; - let startWidth = 520; - resizeHandle.onmousedown = (e) => { - isResizing = true; - startX = e.clientX; - startWidth = drawer.getBoundingClientRect().width; - resizeHandle.classList.add('is-resizing'); - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'ew-resize'; - - const onMouseMove = (moveEvt) => { - if (!isResizing) return; - const currentSide = drawer.classList.contains('is-dock-left') ? 'left' : 'right'; - let newWidth; - if (currentSide === 'left') { - const deltaX = moveEvt.clientX - startX; - newWidth = Math.max(380, Math.min(window.innerWidth * 0.85, startWidth + deltaX)); - } else { - const deltaX = startX - moveEvt.clientX; - newWidth = Math.max(380, Math.min(window.innerWidth * 0.85, startWidth + deltaX)); - } - drawer.style.width = `${newWidth}px`; - }; - - const onMouseUp = () => { - if (!isResizing) return; - isResizing = false; - resizeHandle.classList.remove('is-resizing'); - document.body.style.userSelect = ''; - document.body.style.cursor = ''; - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - localStorage.setItem('anomalous_studio_sidebar_width', String(Math.round(drawer.getBoundingClientRect().width))); - }; - - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - }; + bindDrawerResize(resizeHandle, drawer, scope, { + side: () => drawer.classList.contains('is-dock-left') ? 'left' : 'right', + minWidth: 380, + setWidth: width => { drawer.style.width = `${width}px`; }, + saveWidth: width => localStorage.setItem('anomalous_studio_sidebar_width', String(Math.round(width))), + }); resizeHandle.ondblclick = () => { drawer.style.width = '580px'; @@ -431,16 +81,10 @@ export async function openPromptStudio(owner = this) { closePromptStudio(owner); } }; - window.addEventListener('keydown', onKeydown); - const origClose = () => { - window.removeEventListener('keydown', onKeydown); - closePromptStudio(owner); - }; + scope.listen(window, 'keydown', onKeydown); - const composer = buildPromptComposer(owner, drawer, { - isSideStudio: true, - isDrawerMode: true, - onClose: origClose, + const composer = createPromptWorkbench(owner, drawer, scope, { + onClose: () => closePromptStudio(owner), onToggleDockSide: () => { const isLeft = drawer.classList.toggle('is-dock-left'); localStorage.setItem('anomalous_studio_dock_side', isLeft ? 'left' : 'right'); @@ -453,11 +97,10 @@ export async function openPromptStudio(owner = this) { } export async function showPromptComposer(owner, material) { - owner.closePromptImportDrawer?.(); - owner.materialDetailController?.abort(); + pendingPlanLoad?.abort(); if (material) { const controller = new AbortController(); - owner.materialDetailController = controller; + pendingPlanLoad = controller; try { if (owner.promptPlanDraft && !await anomalousConfirm(t('promptReplaceDraft'))) return; if (controller.signal.aborted) return; @@ -472,7 +115,7 @@ export async function showPromptComposer(owner, material) { if (error.name !== 'AbortError') await anomalousAlert(t('materialDetailLoadError')); return; } finally { - if (owner.materialDetailController === controller) owner.materialDetailController = null; + if (pendingPlanLoad === controller) pendingPlanLoad = null; } } @@ -480,19 +123,6 @@ export async function showPromptComposer(owner, material) { await openPromptStudio(owner); } -export function renderSidePromptComposer(owner, container, onClose) { - container.replaceChildren(); - owner.promptPlanDraft ||= newDraft(); - const composer = buildPromptComposer(owner, container, { - isSideStudio: true, - onClose: () => { - if (typeof onClose === 'function') onClose(); - }, - }); - owner.sidePromptComposerControl = composer; - return composer; -} - export function appendPromptToStudio(owner, textSnippet, isPositive = true, noteTitle = '') { if (!textSnippet || !textSnippet.trim()) return; const role = isPositive ? 'positive' : 'negative'; @@ -506,8 +136,7 @@ export function appendPromptToStudio(owner, textSnippet, isPositive = true, note if (owner.sidePromptComposerControl?.addBlock) { owner.sidePromptComposerControl.addBlock(block); - } else if (owner.workbenchComposerControl?.addBlock) { - owner.workbenchComposerControl.addBlock(block); + } else { owner.promptPlanDraft ||= newDraft(); owner.promptPlanDraft.plan.parts ||= []; @@ -517,1691 +146,3 @@ export function appendPromptToStudio(owner, textSnippet, isPositive = true, note } } -export function addBlockToMixer(owner, blockData) { - if (!blockData || !blockData.content?.trim()) return; - const block = normalizeBlock(blockData); - if (owner.sidePromptComposerControl?.addBlock) { - owner.sidePromptComposerControl.addBlock(block); - } else if (owner.workbenchComposerControl?.addBlock) { - owner.workbenchComposerControl.addBlock(block); - } else { - owner.promptPlanDraft ||= newDraft(); - owner.promptPlanDraft.plan.parts ||= []; - owner.promptPlanDraft.plan.parts.push(block); - syncDraftSynthesizedText(owner.promptPlanDraft); - } -} - -function showWorkbenchToast(message) { - const toast = document.createElement('div'); - toast.className = 'anomalous-mixer-toast'; - toast.textContent = message; - document.body.appendChild(toast); - setTimeout(() => toast.classList.add('is-show'), 10); - setTimeout(() => { - toast.classList.remove('is-show'); - setTimeout(() => toast.remove(), 300); - }, 2400); -} - -function buildPromptComposer(owner, container, options = {}) { - const isSide = !!options.isSideStudio; - const view = text(container, 'section', '', `anomalous-prompt-composer anomalous-prompt-workbench${isSide ? ' is-side-studio' : ''}`); - if (isSide) owner.sidePromptComposerView = view; - else owner.promptComposerView = view; - - let draft = owner.promptPlanDraft ||= newDraft(); - draft.plan ||= { version: 2, parts: [], positive: '', negative: '' }; - draft.plan.parts ||= []; - - // Migrate existing text if parts are empty - if (!draft.plan.parts.length) { - if (draft.plan.positive?.trim()) { - draft.plan.parts.push(normalizeBlock({ - title: window.anomalous_browser_lang === 'zh' ? '基础正向词' : 'Positive Base', - content: draft.plan.positive, - role: 'positive', - category: categorizePromptSnippet(draft.plan.positive), - }, 0)); - } - if (draft.plan.negative?.trim()) { - draft.plan.parts.push(normalizeBlock({ - title: window.anomalous_browser_lang === 'zh' ? '通用负向过滤' : 'Negative Filter', - content: draft.plan.negative, - role: 'negative', - category: 'base', - }, 1)); - } - } - syncDraftSynthesizedText(draft); - - let activeTab = 'positive'; // 'positive' | 'negative' - let sourceFilterCategory = 'all'; - let sourceFilterKeyword = ''; - let draggedBlockId = null; - let isOutputExpanded = false; - - // Local in-memory source prompt cards (merged starters + materials + user custom) - let sourceCards = [...STARTER_SOURCE_PROMPTS]; - let isCreatingNewCard = false; - - // 1. Studio Topbar (Streamlined with inline preset name input) - const topbar = text(view, 'header', '', 'anomalous-prompt-topbar'); - const topLeft = text(topbar, 'div', '', 'anomalous-prompt-topbar-left'); - text(topLeft, 'h3', window.anomalous_browser_lang === 'zh' ? '🎛️ 提示词工坊' : '🎛️ Prompt Studio'); - - // Inline Preset Name Input - const nameInput = text(topLeft, 'input', '', 'anomalous-prompt-name-input'); - nameInput.placeholder = window.anomalous_browser_lang === 'zh' ? '方案名称...' : 'Preset name...'; - nameInput.title = window.anomalous_browser_lang === 'zh' ? '输入组合方案名称' : 'Enter preset name'; - nameInput.maxLength = 120; - nameInput.value = draft.name || ''; - nameInput.oninput = () => { draft.name = nameInput.value; }; - - const topActions = text(topbar, 'div', '', 'anomalous-prompt-topbar-actions'); - - // Quick Extract from Selected Canvas Node - const extractQuickBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); - extractQuickBtn.innerHTML = '🎯'; - extractQuickBtn.title = window.anomalous_browser_lang === 'zh' - ? '从画布选中节点一键吸取提示词' - : 'Extract prompt from selected node'; - extractQuickBtn.onclick = () => extractPromptsFromSelectedNode(true); - - // Dock Side Switch (Left / Right) - if (options.isDrawerMode && typeof options.onToggleDockSide === 'function') { - const dockSideBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); - const isLeft = () => activeStudioDrawer?.classList.contains('is-dock-left'); - const updateDockBtn = () => { - dockSideBtn.innerHTML = isLeft() - ? `` - : ``; - dockSideBtn.title = isLeft() - ? (window.anomalous_browser_lang === 'zh' ? '停靠至右侧' : 'Dock to Right') - : (window.anomalous_browser_lang === 'zh' ? '停靠至左侧' : 'Dock to Left'); - }; - updateDockBtn(); - dockSideBtn.onclick = () => { - options.onToggleDockSide(); - updateDockBtn(); - }; - } - - // "More" Dropdown Menu (holds Save, New Draft, Tags, Export, Material Sync) - const moreWrap = text(topActions, 'div', '', 'anomalous-prompt-more-wrap'); - moreWrap.style.position = 'relative'; - moreWrap.style.display = 'inline-flex'; - const moreBtn = text(moreWrap, 'button', window.anomalous_browser_lang === 'zh' ? '··· 更多' : '··· More', 'anomalous-btn-ghost anomalous-btn-sm'); - const moreMenu = text(moreWrap, 'div', '', 'anomalous-prompt-more-menu'); - moreMenu.style.display = 'none'; - moreMenu.style.position = 'absolute'; - moreMenu.style.top = 'calc(100% + 4px)'; - moreMenu.style.right = '0'; - moreMenu.style.zIndex = '1000'; - moreMenu.style.minWidth = '150px'; - moreMenu.style.background = 'var(--amb-bg-panel, #1C1E24)'; - moreMenu.style.border = '1px solid var(--amb-border, rgba(255, 255, 255, 0.14))'; - moreMenu.style.borderRadius = 'var(--amb-radius-control, 6px)'; - moreMenu.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.6)'; - moreMenu.style.padding = '4px'; - - const saveBtn = text(moreMenu, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-btn-ghost anomalous-btn-sm'); - saveBtn.style.width = '100%'; - saveBtn.style.justifyContent = 'flex-start'; - saveBtn.style.border = 'none'; - saveBtn.title = t('promptSavePlan') || '保存方案至素材库'; - - const newBtn = text(moreMenu, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-btn-ghost anomalous-btn-sm'); - newBtn.style.width = '100%'; - newBtn.style.justifyContent = 'flex-start'; - newBtn.style.border = 'none'; - - const tagsBtn = text(moreMenu, 'button', `🏷️ ${window.anomalous_browser_lang === 'zh' ? '设置标签' : 'Edit Tags'}`, 'anomalous-btn-ghost anomalous-btn-sm'); - tagsBtn.style.width = '100%'; - tagsBtn.style.justifyContent = 'flex-start'; - tagsBtn.style.border = 'none'; - tagsBtn.onclick = () => { - closeMoreMenu(); - const current = (draft.tags || []).join(', '); - const val = prompt(window.anomalous_browser_lang === 'zh' ? '输入方案标签(用逗号或空格分隔):' : 'Enter preset tags (comma separated):', current); - if (val !== null) { - draft.tags = val.split(/[\s,,\n\r]+/).map(v => v.trim()).filter(Boolean); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `已更新标签 (${draft.tags.length})` : `Tags updated (${draft.tags.length})`); - } - }; - - const exportBtn = text(moreMenu, 'button', `📤 ${t('promptExportPlan')}`, 'anomalous-btn-ghost anomalous-btn-sm'); - exportBtn.style.width = '100%'; - exportBtn.style.justifyContent = 'flex-start'; - exportBtn.style.border = 'none'; - - const syncLibBtn = text(moreMenu, 'button', `📥 ${window.anomalous_browser_lang === 'zh' ? '同步素材库' : 'Sync Library'}`, 'anomalous-btn-ghost anomalous-btn-sm'); - syncLibBtn.style.width = '100%'; - syncLibBtn.style.justifyContent = 'flex-start'; - syncLibBtn.style.border = 'none'; - syncLibBtn.onclick = () => { - closeMoreMenu(); - syncMaterialsIntoSourceDeck(); - }; - - moreBtn.onclick = (e) => { - e.stopPropagation(); - const isHidden = moreMenu.style.display === 'none'; - moreMenu.style.display = isHidden ? 'block' : 'none'; - }; - const closeMoreMenu = () => { moreMenu.style.display = 'none'; }; - document.addEventListener?.('click', closeMoreMenu); - - const closeBtn = text(topActions, 'button', '✕', 'anomalous-btn-ghost anomalous-btn-sm anomalous-prompt-close-btn'); - closeBtn.title = t('close') || '关闭'; - closeBtn.onclick = () => { - if (typeof options.onClose === 'function') options.onClose(); - else owner.closeWorkspace?.(); - }; - - // Dedicated Insert Position control (placed in sticky Action Dock) - const posWrap = document.createElement('label'); - posWrap.className = 'anomalous-prompt-insert-pos'; - const posSelect = text(posWrap, 'select', '', 'anomalous-prompt-pos-select'); - for (const value of ['after', 'before']) { - text(posSelect, 'option', t(`promptInsert_${value}`)).value = value; - } - posSelect.value = owner.promptInsertPosition || 'after'; - posSelect.onchange = () => { - owner.promptInsertPosition = posSelect.value; - renderTargetBar(); - }; - - // Two-Column Split Grid (Adapts to vertical dual-zone in sidebar mode) - const workbenchGrid = text(view, 'div', '', 'anomalous-prompt-workbench-grid'); - - // ========================================================================= - // LEFT COLUMN: Ready-to-use Prompt Cards (词卡库) - // ========================================================================= - const leftPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-left-panel'); - const leftHeader = text(leftPanel, 'div', '', 'anomalous-workbench-col-header'); - const leftTitleWrap = text(leftHeader, 'div', '', 'anomalous-workbench-col-title'); - text(leftTitleWrap, 'strong', window.anomalous_browser_lang === 'zh' ? '词卡库' : 'Prompt Library'); - const leftCounter = text(leftTitleWrap, 'span', '', 'anomalous-sub-counter'); - - // Action button group in leftHeader - const leftHeaderActions = text(leftHeader, 'div', '', 'anomalous-workbench-header-actions'); - - // Button 1: One-click Sync / Import from Material Library - const importMaterialsBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn'); - importMaterialsBtn.innerHTML = ``; - importMaterialsBtn.title = window.anomalous_browser_lang === 'zh' ? '从素材库同步提示词' : 'Sync prompts from Material Library'; - - // Button 2: Create New Custom Card - const newCardTriggerBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn'); - newCardTriggerBtn.innerHTML = ``; - newCardTriggerBtn.title = window.anomalous_browser_lang === 'zh' ? '新建词卡' : 'Create new prompt card'; - - // Search and category filters bar - const leftFilterBar = text(leftPanel, 'div', '', 'anomalous-workbench-filter-bar'); - const leftSearch = text(leftFilterBar, 'input', '', 'anomalous-workbench-search-input'); - leftSearch.placeholder = window.anomalous_browser_lang === 'zh' ? '搜索词卡...' : 'Search cards...'; - leftSearch.oninput = () => { - sourceFilterKeyword = leftSearch.value.trim().toLowerCase(); - renderSourceCardsList(); - }; - - const leftCategoryPills = text(leftPanel, 'div', '', 'anomalous-workbench-category-pills'); - const filterCats = [ - { id: 'all', label: window.anomalous_browser_lang === 'zh' ? '全部' : 'All' }, - { id: 'base', label: window.anomalous_browser_lang === 'zh' ? '底模' : 'Base' }, - { id: 'style', label: window.anomalous_browser_lang === 'zh' ? '风格' : 'Style' }, - { id: 'subject', label: window.anomalous_browser_lang === 'zh' ? '主体' : 'Subject' }, - { id: 'trigger', label: window.anomalous_browser_lang === 'zh' ? '触发' : 'Trigger' }, - ]; - filterCats.forEach(cat => { - const pill = text(leftCategoryPills, 'button', cat.label, `anomalous-workbench-pill${sourceFilterCategory === cat.id ? ' is-active' : ''}`); - pill.onclick = () => { - sourceFilterCategory = cat.id; - leftCategoryPills.querySelectorAll('.anomalous-workbench-pill').forEach(el => el.classList.remove('is-active')); - pill.classList.add('is-active'); - renderSourceCardsList(); - }; - }); - - // Inline New Card Form (Hidden by default, shown on demand) - const newCardForm = text(leftPanel, 'div', '', 'anomalous-workbench-new-card-form'); - newCardForm.style.display = 'none'; - - // Source Cards List - const sourceCardsList = text(leftPanel, 'div', '', 'anomalous-source-cards-list'); - - // ========================================================================= - // RIGHT COLUMN: Assembler & Arranger Stage (顺序拼装调音台) - // ========================================================================= - const rightPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-right-panel'); - const rightHeader = text(rightPanel, 'div', '', 'anomalous-workbench-col-header'); - - // Role switch tabs (Positive / Negative) - const rightTabs = text(rightHeader, 'div', '', 'anomalous-mixer-tabs'); - - const rightActions = text(rightHeader, 'div', '', 'anomalous-mixer-actions'); - - // Right quick extract: suck into right mixer directly - const rightSuckNodeBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); - rightSuckNodeBtn.innerHTML = `${t('promptReadSelectedNode')}`; - rightSuckNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '直接将画布选中节点的提示词作为积木吸入当前拼装台' : 'Extract node prompt directly into current mixer track'; - - const smartSortBtn = text(rightActions, 'button', '', 'anomalous-mixer-smart-sort-btn'); - smartSortBtn.innerHTML = `${t('promptSortByCategory')}`; - smartSortBtn.title = window.anomalous_browser_lang === 'zh' ? '按 [通用底模 ➔ 风格氛围 ➔ 主体内容 ➔ LoRA/触发词] 自动排序' : 'Auto sort: [Base ➔ Style ➔ Subject ➔ Trigger]'; - - const clearRightBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost'); - clearRightBtn.innerHTML = ''; - clearRightBtn.title = window.anomalous_browser_lang === 'zh' ? '清空当前拼装池' : 'Clear current track'; - - // Blocks Container & Dropzone - const blocksContainer = text(rightPanel, 'div', '', 'anomalous-mixer-blocks-container anomalous-assembly-track'); - - blocksContainer.ondragover = (e) => { - e.preventDefault(); - const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); - if (snapDock && (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock'))) { - snapDock.classList.add('is-drag-over'); - blocksContainer.classList.add('is-drag-over-end'); - } - }; - blocksContainer.ondragleave = (e) => { - if (!blocksContainer.contains(e.relatedTarget)) { - const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); - snapDock?.classList.remove('is-drag-over'); - blocksContainer.classList.remove('is-drag-over-end'); - } - }; - blocksContainer.ondrop = (e) => { - if (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock')) { - e.preventDefault(); - const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); - snapDock?.classList.remove('is-drag-over'); - blocksContainer.classList.remove('is-drag-over-end'); - const jsonStr = e.dataTransfer.getData('application/json'); - if (jsonStr) { - try { - addSourceCardToMixer(JSON.parse(jsonStr)); - return; - } catch (err) {} - } - if (draggedBlockId) { - const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId); - if (fromIndex >= 0) { - const [moved] = draft.plan.parts.splice(fromIndex, 1); - draft.plan.parts.push(moved); - syncDraftSynthesizedText(draft); - renderBlocksList(); - updateOutputPreview(); - } - } - } - }; - - // ------------------------------------------------------------------------- - // Node Direct Write Handler - // ------------------------------------------------------------------------- - const targetWidgetIndexByNodeId = {}; - const applyPromptToCurrentNode = async (role, promptContent, triggerBtn = null) => { - const node = selectedMaterialNode(app); - if (!node) { - anomalousAlert(window.anomalous_browser_lang === 'zh' - ? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!' - : '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!'); - return; - } - const targets = promptWidgetTargets(node); - if (!targets.length) { - anomalousAlert(window.anomalous_browser_lang === 'zh' - ? '⚠️ 选中的节点中未找到可写入的提示词文本输入!' - : '⚠️ No writable prompt text widget found in selected node!'); - return; - } - const widgetIndex = targetWidgetIndexByNodeId[node.id] !== undefined ? targetWidgetIndexByNodeId[node.id] : targets[0].index; - try { - const value = joinPromptText(node.widgets[widgetIndex].value, promptContent, posSelect.value); - showMaterialApplication(view, applyNodeMaterialValues(app, node, [{ index: widgetIndex, value }]), node); - if (triggerBtn) { - const orig = triggerBtn.textContent; - triggerBtn.textContent = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已写入' : 'Written'); - setTimeout(() => { if (triggerBtn.isConnected) triggerBtn.textContent = orig; }, 1200); - } else { - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✅ 提示词已成功写入节点!' : '✅ Prompt written to node!'); - } - } catch (error) { - await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); - } - }; - - // ------------------------------------------------------------------------- - // Floating Action Dock (Minimalist & Slides Up Only When Content Exists) - // ------------------------------------------------------------------------- - const floatingDock = text(rightPanel, 'div', '', 'anomalous-floating-action-dock'); - floatingDock.style.display = 'none'; // Initially zero-height, hidden until content is added - - const dockLeft = text(floatingDock, 'div', '', 'anomalous-floating-dock-left'); - const dockStats = text(dockLeft, 'span', '', 'anomalous-dock-stats-text'); - - const dockRight = text(floatingDock, 'div', '', 'anomalous-floating-dock-right'); - - // 1. Write Node Button - const writeNodeBtn = text(dockRight, 'button', `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点' : 'Write Node'}`, 'anomalous-btn-primary anomalous-btn-sm'); - writeNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '将当前合成提示词写入选中的 ComfyUI 节点' : 'Write assembled prompt to selected node'; - writeNodeBtn.onclick = async () => { - syncDraftSynthesizedText(draft); - const compiledText = (draft.plan[activeTab] || '').trim(); - if (!compiledText) return; - await applyPromptToCurrentNode(activeTab, compiledText, writeNodeBtn); - }; - - // 2. Quick Copy Button - const copyOutputBtn = text(dockRight, 'button', '📋', 'anomalous-btn-ghost anomalous-btn-sm'); - copyOutputBtn.title = window.anomalous_browser_lang === 'zh' ? '复制当前合成的提示词' : 'Copy assembled prompt text'; - copyOutputBtn.onclick = async (e) => { - e.stopPropagation(); - syncDraftSynthesizedText(draft); - const compiledText = (draft.plan[activeTab] || '').trim(); - if (!compiledText) return; - await navigator.clipboard.writeText(compiledText); - copyOutputBtn.textContent = '✓'; - setTimeout(() => { if (copyOutputBtn.isConnected) copyOutputBtn.textContent = '📋'; }, 1200); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '已复制至剪贴板' : 'Copied to clipboard'); - }; - - // 3. Popout Full Inspector Modal Button - const popoutBtn = text(dockRight, 'button', '⛶', 'anomalous-btn-ghost anomalous-btn-sm'); - popoutBtn.title = window.anomalous_browser_lang === 'zh' ? '弹出独立大弹窗全览与精修' : 'Pop out full inspector modal'; - popoutBtn.onclick = () => { - openPromptInspectorModal(owner, draft, activeTab, async (role, textVal) => { - await applyPromptToCurrentNode(role, textVal); - }); - }; - - - // ------------------------------------------------------------------------- - // FEATURE IMPLEMENTATIONS: Node Extraction & Material Sync - // ------------------------------------------------------------------------- - - function detectNodePromptRole(node, widgetName = '') { - if (/neg|negative|反向|负向/i.test(widgetName)) return 'negative'; - if (/pos|positive|正面|正向/i.test(widgetName)) return 'positive'; - - if (app?.graph && Array.isArray(node?.outputs)) { - for (const output of node.outputs) { - if (!Array.isArray(output.links)) continue; - for (const linkId of output.links) { - const link = app.graph.links?.[linkId]; - if (!link) continue; - const targetNode = app.graph.getNodeById(link.target_id); - if (targetNode?.inputs && targetNode.inputs[link.target_slot]) { - const targetInput = targetNode.inputs[link.target_slot]; - const inputName = String(targetInput.name || '').toLowerCase(); - if (inputName.includes('neg') || inputName.includes('负')) return 'negative'; - if (inputName.includes('pos') || inputName.includes('正')) return 'positive'; - } - } - } - } - - const nodeText = `${node?.title || ''} ${node?.type || ''}`.toLowerCase(); - if (/neg|negative|反向|负向/.test(nodeText)) return 'negative'; - if (/pos|positive|正面|正向/.test(nodeText)) return 'positive'; - - return 'positive'; - } - - // Extract prompts from selected canvas node - function extractPromptsFromSelectedNode(intoRightMixer = false) { - const node = selectedMaterialNode(app); - if (!node) { - anomalousAlert(window.anomalous_browser_lang === 'zh' - ? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode 或包含 prompt 文本的节点)!' - : '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!'); - return; - } - - const heading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`; - let targets = promptWidgetTargets(node); - - // Safe fallback: only prompt/caption/text widgets or textarea, exclude settings/file paths - if (!targets.length && Array.isArray(node.widgets)) { - targets = node.widgets.flatMap((w, idx) => { - const name = String(w.name || '').toLowerCase(); - if (/filename|prefix|path|directory|save|load|ckpt|model|vae|seed|steps|cfg|denoise|sampler|scheduler/i.test(name)) { - return []; - } - if (typeof w.value === 'string' && w.value.trim().length > 0 && !w.options?.values) { - if (/prompt|caption|text|description|words|tags/i.test(name) || w.type === 'customtext' || w.type === 'string') { - return [{ index: idx, name: w.name || 'text' }]; - } - } - return []; - }); - } - - if (!targets.length) { - anomalousAlert(window.anomalous_browser_lang === 'zh' - ? `⚠️ 选中的节点【${heading}】中未检测到有效的提示词文本输入!` - : `⚠️ No valid prompt text found in selected node [${heading}]!`); - return; - } - - let extractedCount = 0; - targets.forEach(t => { - const rawVal = String(node.widgets[t.index]?.value || '').trim(); - if (!rawVal) return; - const role = detectNodePromptRole(node, t.name); - const cat = role === 'negative' ? 'base' : categorizePromptSnippet(rawVal); - const cardTitle = `${heading} · ${t.name}`; - - const newCard = { - id: `node_${node.id}_${t.index}_${Date.now()}`, - title: cardTitle, - content: rawVal, - role, - category: cat, - persisted: false, - }; - - // Add into left source deck - sourceCards.unshift(newCard); - extractedCount++; - - // If user clicked right panel, also insert into mixer track directly - if (intoRightMixer) { - addSourceCardToMixer(newCard); - } - }); - - if (extractedCount > 0) { - renderSourceCardsList(); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' - ? `已成功从节点【${heading}】提取 ${extractedCount} 段提示词${intoRightMixer ? '并加入组合' : '并加入左侧词库'}!` - : `Successfully extracted ${extractedCount} prompts from [${heading}]!`); - } else { - anomalousAlert(window.anomalous_browser_lang === 'zh' - ? `选中的节点【${heading}】文本内容为空!` - : `The text fields in node [${heading}] are empty!`); - } - } - - rightSuckNodeBtn.onclick = () => extractPromptsFromSelectedNode(true); - - let materialSyncController = null; - async function syncMaterialsIntoSourceDeck() { - materialSyncController?.abort(); - if (materialSyncController) { - materialSyncController.abort(); - } - const controller = new AbortController(); - materialSyncController = controller; - - importMaterialsBtn.disabled = true; - importMaterialsBtn.innerHTML = `⏳`; - - try { - const res = await fetch('/anomalous/materials?category=prompts&limit=150', { signal: controller.signal }); - const data = await jsonResponse(res, 'materials list'); - if (controller.signal.aborted) return; - if (Array.isArray(data.materials)) { - for (const item of data.materials) { - if (controller.signal.aborted) return; - try { - const prompts = await loadMaterialPrompts(item.filename); - if (prompts.positive && !sourceCards.some(c => c.content === prompts.positive.trim())) { - sourceCards.push({ - id: `mat_pos_${item.filename}`, - title: item.name ? `${item.name} (Pos)` : '素材正向', - content: prompts.positive.trim(), - role: 'positive', - category: categorizePromptSnippet(prompts.positive), - persisted: true, - filename: item.filename, - }); - } - if (prompts.negative && !sourceCards.some(c => c.content === prompts.negative.trim())) { - sourceCards.push({ - id: `mat_neg_${item.filename}`, - title: item.name ? `${item.name} (Neg)` : '素材负向', - content: prompts.negative.trim(), - role: 'negative', - category: 'base', - persisted: true, - filename: item.filename, - }); - } - } catch (err) {} - } - } - - if (controller.signal.aborted || !view.isConnected) return; - renderSourceCardsList(); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' - ? (addedCount > 0 ? `已从素材库成功同步并导入 ${addedCount} 条提示词卡片!` : '素材库提示词已是最新状态,未发现新词条。') - : `Synced from Material Library: ${addedCount} new prompts added!`); - } catch (error) { - if (error.name !== 'AbortError') { - await anomalousAlert(t('materialLoadError')); - } - } finally { - if (materialSyncController === controller) { - materialSyncController = null; - importMaterialsBtn.disabled = false; - importMaterialsBtn.innerHTML = ``; - } - } - } - - importMaterialsBtn.onclick = () => syncMaterialsIntoSourceDeck(); - - // Persist a single prompt card into library - async function saveCardToLibrary(card, btnEl = null) { - if (btnEl) { - btnEl.disabled = true; - btnEl.textContent = '⏳'; - } - const cardTitle = card.title?.trim() || (card.role === 'negative' ? (window.anomalous_browser_lang === 'zh' ? '负向词卡' : 'Negative Card') : (window.anomalous_browser_lang === 'zh' ? '正向词卡' : 'Positive Card')); - const body = { - name: cardTitle, - tags: ['prompt_card', card.category || 'base', card.role || 'positive'], - allow_duplicate: true, - plan: { - version: 2, - positive: card.role === 'positive' ? card.content : '', - negative: card.role === 'negative' ? card.content : '', - parts: [{ - id: card.id, - name: cardTitle, - category: card.category || 'base', - role: card.role || 'positive', - positive: card.role === 'positive' ? card.content : '', - negative: card.role === 'negative' ? card.content : '', - enabled: true, - }], - }, - }; - - try { - const response = await fetch('/anomalous/save_prompt_plan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const payload = await jsonResponse(response, 'save prompt card failed'); - if (payload.status !== 'success') throw new Error('save failed'); - card.persisted = true; - card.filename = payload.filename; - showWorkbenchToast(t('promptSaveCardSuccess')); - renderSourceCardsList(); - } catch (err) { - showWorkbenchToast(t('promptSaveCardFailed')); - if (btnEl) { - btnEl.disabled = false; - btnEl.textContent = `💾 ${t('promptSaveCard')}`; - } - } - } - - // ------------------------------------------------------------------------- - // RENDER: Left Panel Cards & Form - // ------------------------------------------------------------------------- - function renderNewCardFormUI() { - if (!isCreatingNewCard) { - newCardForm.style.display = 'none'; - newCardForm.replaceChildren(); - return; - } - newCardForm.style.display = 'flex'; - newCardForm.replaceChildren(); - - text(newCardForm, 'div', window.anomalous_browser_lang === 'zh' ? '✨ 新建提示词卡片' : '✨ New Prompt Card', 'anomalous-form-title'); - - const titleInput = text(newCardForm, 'input', '', 'anomalous-form-input'); - titleInput.placeholder = window.anomalous_browser_lang === 'zh' ? '卡片名称(如:赛博光影、角色面部)...' : 'Card name...'; - - // Role & Category selector row - const metaRow = text(newCardForm, 'div', '', 'anomalous-form-meta-row'); - - // Role radio group - const roleGroup = text(metaRow, 'div', '', 'anomalous-form-role-group'); - let selectedRole = activeTab || 'positive'; - - const posLabel = text(roleGroup, 'label', '', 'anomalous-role-label'); - const posRadio = text(posLabel, 'input', ''); - posRadio.type = 'radio'; - posRadio.name = 'new_card_role'; - posRadio.value = 'positive'; - posRadio.checked = selectedRole === 'positive'; - text(posLabel, 'span', t('promptRolePositive')); - - const negLabel = text(roleGroup, 'label', '', 'anomalous-role-label'); - const negRadio = text(negLabel, 'input', ''); - negRadio.type = 'radio'; - negRadio.name = 'new_card_role'; - negRadio.value = 'negative'; - negRadio.checked = selectedRole === 'negative'; - text(negLabel, 'span', t('promptRoleNegative')); - - posRadio.onchange = () => { if (posRadio.checked) selectedRole = 'positive'; }; - negRadio.onchange = () => { if (negRadio.checked) selectedRole = 'negative'; }; - - // Category dropdown - const catWrap = text(metaRow, 'div', '', 'anomalous-form-cat-wrap'); - text(catWrap, 'span', `${window.anomalous_browser_lang === 'zh' ? '分类' : 'Type'}: `); - let selectedCat = 'style'; - const catSelect = text(catWrap, 'select', '', 'anomalous-form-select'); - ['base', 'style', 'subject', 'trigger'].forEach(catKey => { - const meta = CATEGORY_META[catKey]; - const opt = text(catSelect, 'option', window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en); - opt.value = catKey; - if (catKey === selectedCat) opt.selected = true; - }); - catSelect.onchange = () => { selectedCat = catSelect.value; }; - - const contentInput = text(newCardForm, 'textarea', '', 'anomalous-form-textarea'); - contentInput.placeholder = window.anomalous_browser_lang === 'zh' ? '输入提示词内容,多个短语用逗号隔开...' : 'Enter prompt text...'; - contentInput.rows = 3; - - const formBtnRow = text(newCardForm, 'div', '', 'anomalous-form-btn-row'); - const submitBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '✓ 保存并加入库' : '✓ Save to Library', 'anomalous-btn-primary anomalous-btn-sm'); - - const translateBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '🌐 翻译' : '🌐 Translate', 'anomalous-btn-ghost anomalous-btn-sm anomalous-btn-card-translate'); - translateBtn.type = 'button'; - translateBtn.title = window.anomalous_browser_lang === 'zh' ? '一键双向翻译 (中/英互译)' : 'One-click bilingual translation'; - translateBtn.onclick = async () => { - const raw = contentInput.value.trim(); - if (!raw) return; - const originalText = translateBtn.textContent; - translateBtn.disabled = true; - translateBtn.textContent = '⏳ ...'; - try { - const res = await translatePromptText(raw); - if (res.ok && res.translated) { - contentInput.value = res.translated; - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Translated to ${res.targetLang}`); - } else { - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`); - } - } finally { - translateBtn.disabled = false; - translateBtn.textContent = originalText; - } - }; - - const cancelBtn = text(formBtnRow, 'button', t('cancel'), 'anomalous-btn-ghost anomalous-btn-sm'); - - submitBtn.onclick = async () => { - const rawContent = contentInput.value.trim(); - if (!rawContent) { - contentInput.focus(); - return; - } - submitBtn.disabled = true; - submitBtn.textContent = '⏳ ...'; - - const cardTitle = titleInput.value.trim() || (window.anomalous_browser_lang === 'zh' ? CATEGORY_META[selectedCat].zh : CATEGORY_META[selectedCat].en); - const newCard = { - id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, - title: cardTitle, - content: rawContent, - role: selectedRole, - category: selectedCat, - persisted: false, - }; - - try { - const body = { - name: cardTitle, - tags: ['prompt_card', selectedCat, selectedRole], - allow_duplicate: true, - plan: { - version: 2, - positive: selectedRole === 'positive' ? rawContent : '', - negative: selectedRole === 'negative' ? rawContent : '', - parts: [{ - id: newCard.id, - name: cardTitle, - category: selectedCat, - role: selectedRole, - positive: selectedRole === 'positive' ? rawContent : '', - negative: selectedRole === 'negative' ? rawContent : '', - enabled: true, - }], - }, - }; - const res = await fetch('/anomalous/save_prompt_plan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const payload = await jsonResponse(res, 'save prompt card'); - if (payload.status === 'success') { - newCard.persisted = true; - newCard.filename = payload.filename; - showWorkbenchToast(t('promptSaveCardSuccess')); - } - } catch (err) { - showWorkbenchToast(t('promptSaveCardFailed')); - } - - sourceCards.unshift(newCard); - isCreatingNewCard = false; - renderNewCardFormUI(); - renderSourceCardsList(); - }; - - cancelBtn.onclick = () => { - isCreatingNewCard = false; - renderNewCardFormUI(); - }; - - titleInput.focus(); - } - - newCardTriggerBtn.onclick = () => { - isCreatingNewCard = !isCreatingNewCard; - renderNewCardFormUI(); - }; - - function showCardPreviewPopover(card, anchorEl) { - hideCardPreviewPopover(); - if (!anchorEl?.isConnected) return; - - const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject; - const popover = document.createElement('div'); - popover.className = 'anomalous-card-preview-popover'; - - const header = document.createElement('div'); - header.className = 'anomalous-popover-header'; - - const tags = document.createElement('div'); - tags.className = 'anomalous-popover-tags'; - - const catBadge = document.createElement('span'); - catBadge.className = 'anomalous-popover-cat'; - catBadge.style.color = catMeta.color; - catBadge.style.background = catMeta.bg; - catBadge.style.borderColor = catMeta.border; - catBadge.textContent = window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en; - tags.appendChild(catBadge); - - const roleBadge = document.createElement('span'); - roleBadge.className = `anomalous-popover-role is-${card.role}`; - roleBadge.textContent = card.role === 'negative' - ? (window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative') - : (window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive'); - tags.appendChild(roleBadge); - header.appendChild(tags); - - const titleEl = document.createElement('div'); - titleEl.className = 'anomalous-popover-title'; - titleEl.textContent = card.title; - header.appendChild(titleEl); - - popover.appendChild(header); - - const body = document.createElement('div'); - body.className = 'anomalous-popover-body'; - const snippet = document.createElement('pre'); - snippet.className = 'anomalous-popover-snippet'; - snippet.textContent = card.content; - body.appendChild(snippet); - popover.appendChild(body); - - const footer = document.createElement('div'); - footer.className = 'anomalous-popover-footer'; - footer.textContent = window.anomalous_browser_lang === 'zh' - ? '💡 点击直接添加 · 拖拽自由调序' - : '💡 Click to add · Drag to assemble'; - popover.appendChild(footer); - - document.body.appendChild(popover); - activeCardPreviewPopover = popover; - - // Smart Positioning - const rect = anchorEl.getBoundingClientRect(); - const isDockLeft = activeStudioDrawer?.classList.contains('is-dock-left') ?? true; - const popoverHeight = popover.offsetHeight || 160; - const top = Math.max(12, Math.min(window.innerHeight - popoverHeight - 12, rect.top - 6)); - popover.style.top = `${top}px`; - - if (isDockLeft) { - popover.style.left = `${rect.right + 10}px`; - } else { - popover.style.left = `${Math.max(12, rect.left - 320)}px`; - } - } - - function renderSourceCardsList() { - hideCardPreviewPopover(); - sourceCardsList.replaceChildren(); - sourceCardsList.onscroll = () => hideCardPreviewPopover(); - - const filtered = sourceCards.filter(card => { - if (sourceFilterCategory !== 'all' && card.category !== sourceFilterCategory) return false; - if (sourceFilterKeyword) { - const matchTitle = card.title.toLowerCase().includes(sourceFilterKeyword); - const matchContent = card.content.toLowerCase().includes(sourceFilterKeyword); - if (!matchTitle && !matchContent) return false; - } - return true; - }); - - leftCounter.textContent = `(${filtered.length})`; - - if (!filtered.length) { - const empty = text(sourceCardsList, 'div', '', 'anomalous-source-empty'); - empty.innerHTML = ` -
🔍
-
${window.anomalous_browser_lang === 'zh' ? '未找到匹配的提示词卡片' : 'No matching prompt cards found'}
- `; - return; - } - - filtered.forEach(card => { - const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject; - const cardEl = text(sourceCardsList, 'div', '', `anomalous-source-card-compact is-cat-${card.category}`); - cardEl.setAttribute('draggable', 'true'); - - // Custom Eye-Catching Hover Preview Popover (replaces native OS browser title tooltip) - cardEl.onmouseenter = () => { - showCardPreviewPopover(card, cardEl); - }; - cardEl.onmouseleave = () => { - hideCardPreviewPopover(); - }; - - // Drag Start - cardEl.ondragstart = (e) => { - hideCardPreviewPopover(); - const payload = { - title: card.title, - content: card.content, - role: card.role, - category: card.category, - }; - e.dataTransfer.setData('application/json', JSON.stringify(payload)); - e.dataTransfer.setData('text/plain', card.content); - e.dataTransfer.effectAllowed = 'copyMove'; - cardEl.classList.add('is-dragging-source'); - workbenchGrid.classList.add('is-source-dragging'); - }; - - cardEl.ondragend = () => { - cardEl.classList.remove('is-dragging-source'); - workbenchGrid.classList.remove('is-source-dragging'); - }; - - const dot = text(cardEl, 'span', '', 'anomalous-source-card-dot'); - dot.style.backgroundColor = catMeta.color; - - const nameEl = text(cardEl, 'span', card.title, 'anomalous-source-card-name'); - - const addIcon = text(cardEl, 'span', '+', 'anomalous-source-card-add-icon'); - - cardEl.onclick = () => { - hideCardPreviewPopover(); - addSourceCardToMixer(card); - }; - }); - } - - // ------------------------------------------------------------------------- - // RENDER: Right Assembler Stage & Lego Blocks - // ------------------------------------------------------------------------- - function updateRightTabsUI() { - rightTabs.replaceChildren(); - const posCount = draft.plan.parts.filter(p => p.role === 'positive').length; - const negCount = draft.plan.parts.filter(p => p.role === 'negative').length; - - const tabs = [ - { key: 'positive', label: `${window.anomalous_browser_lang === 'zh' ? '正向拼装台' : 'Positive'} (${posCount})` }, - { key: 'negative', label: `${window.anomalous_browser_lang === 'zh' ? '负向拼装台' : 'Negative'} (${negCount})` }, - ]; - - for (const tab of tabs) { - const btn = text(rightTabs, 'button', tab.label, `anomalous-mixer-tab-btn${activeTab === tab.key ? ' is-active' : ''}`); - btn.onclick = () => { - activeTab = tab.key; - updateRightTabsUI(); - renderBlocksList(); - updateOutputPreview(); - renderTargetBar(); - }; - } - } - - function addSourceCardToMixer(cardData, targetIndex = null) { - const cardRole = cardData.role || 'positive'; - if (activeTab !== cardRole) { - activeTab = cardRole; - updateRightTabsUI(); - } - - const newBlock = normalizeBlock({ - title: cardData.title, - content: cardData.content, - role: cardRole, - category: cardData.category, - enabled: true, - }, draft.plan.parts.length); - newBlock._justAdded = true; - - if (targetIndex !== null && targetIndex >= 0) { - draft.plan.parts.splice(targetIndex, 0, newBlock); - } else { - draft.plan.parts.push(newBlock); - } - - syncDraftSynthesizedText(draft); - updateRightTabsUI(); - renderBlocksList(); - updateOutputPreview(); - renderTargetBar(); - - const count = draft.plan.parts.filter(p => p.role === activeTab).length; - showWorkbenchToast(window.anomalous_browser_lang === 'zh' - ? `已加入【${activeTab === 'positive' ? '正向' : '负向'}】拼装台(共 ${count} 块)` - : `Added to ${activeTab} track (${count} blocks)`); - } - - function renderBlocksList() { - blocksContainer.replaceChildren(); - const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab); - - if (!currentRoleParts.length) { - const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone'); - dropzoneNotice.innerHTML = ` -
- - - -
-
${window.anomalous_browser_lang === 'zh' ? '点击词卡或拖拽到这里拼装' : 'Click cards or drag here to assemble'}
-
${window.anomalous_browser_lang === 'zh' ? '支持自由拖拽调换次序,点击【智能排序】一键理顺' : 'Reorder freely anytime, or click Smart Sort.'}
- `; - // Accept drops on empty dropzone - setupDropzoneListeners(dropzoneNotice); - return; - } - - currentRoleParts.forEach((block, index) => { - const isJustAdded = !!block._justAdded; - if (isJustAdded) delete block._justAdded; - - const blockEl = text(blocksContainer, 'article', '', `anomalous-mixer-block${!block.enabled ? ' is-bypassed' : ''} is-role-${block.role}${isJustAdded ? ' is-just-added' : ''}`); - blockEl.setAttribute('draggable', 'true'); - blockEl.dataset ||= {}; - blockEl.dataset.blockId = block.id; - - if (isJustAdded) { - requestAnimationFrame(() => { - blockEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }); - } - - // Reorder Drag Listeners - blockEl.ondragstart = (e) => { - draggedBlockId = block.id; - blockEl.classList.add('is-dragging'); - e.dataTransfer.setData('text/plain', block.content); - e.dataTransfer.effectAllowed = 'move'; - }; - - blockEl.ondragend = () => { - draggedBlockId = null; - blockEl.classList.remove('is-dragging'); - blocksContainer.querySelectorAll('.anomalous-mixer-block').forEach(el => { - el.classList.remove('is-drag-over-top', 'is-drag-over-bottom'); - }); - }; - - blockEl.ondragover = (e) => { - e.preventDefault(); - const rect = blockEl.getBoundingClientRect(); - const mid = rect.top + rect.height / 2; - if (e.clientY < mid) { - blockEl.classList.add('is-drag-over-top'); - blockEl.classList.remove('is-drag-over-bottom'); - } else { - blockEl.classList.add('is-drag-over-bottom'); - blockEl.classList.remove('is-drag-over-top'); - } - }; - - blockEl.ondragleave = () => { - blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom'); - }; - - blockEl.ondrop = (e) => { - e.preventDefault(); - blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom'); - - // Case 1: Drop from Left Source Card - const jsonStr = e.dataTransfer.getData('application/json'); - if (jsonStr) { - try { - const parsed = JSON.parse(jsonStr); - const toIndex = draft.plan.parts.findIndex(p => p.id === block.id); - const insertAfter = e.clientY >= (blockEl.getBoundingClientRect().top + blockEl.offsetHeight / 2); - addSourceCardToMixer(parsed, insertAfter ? toIndex + 1 : toIndex); - return; - } catch (err) {} - } - - // Case 2: Reorder inside Right Track - if (!draggedBlockId || draggedBlockId === block.id) return; - const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId); - if (fromIndex < 0) return; - - const [moved] = draft.plan.parts.splice(fromIndex, 1); - const currentTargetIndex = draft.plan.parts.findIndex(p => p.id === block.id); - if (currentTargetIndex < 0) { - draft.plan.parts.push(moved); - } else { - const rect = blockEl.getBoundingClientRect(); - const insertAfter = e.clientY >= (rect.top + rect.height / 2); - const finalIndex = insertAfter ? currentTargetIndex + 1 : currentTargetIndex; - draft.plan.parts.splice(finalIndex, 0, moved); - } - - syncDraftSynthesizedText(draft); - renderBlocksList(); - updateOutputPreview(); - }; - - // Header - const blockHeader = text(blockEl, 'div', '', 'anomalous-mixer-block-header'); - const headerLeft = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-left'); - - const dragHandle = text(headerLeft, 'span', '⠿', 'anomalous-mixer-drag-handle'); - dragHandle.title = window.anomalous_browser_lang === 'zh' ? '抓取按住上下拖拽排序' : 'Drag to reorder'; - - // A/B Bypass Checkbox - const toggleWrap = text(headerLeft, 'label', '', 'anomalous-mixer-block-toggle'); - const checkbox = text(toggleWrap, 'input', ''); - checkbox.type = 'checkbox'; - checkbox.checked = !!block.enabled; - checkbox.title = window.anomalous_browser_lang === 'zh' ? '勾选参与拼装,取消勾选即旁路跳过(做A/B对比)' : 'Include or bypass in assembly'; - checkbox.onchange = () => { - block.enabled = checkbox.checked; - blockEl.classList.toggle('is-bypassed', !block.enabled); - syncDraftSynthesizedText(draft); - updateOutputPreview(); - }; - - // Category Badge - const catMeta = CATEGORY_META[block.category] || CATEGORY_META.subject; - const catBadge = text(headerLeft, 'span', window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en, 'anomalous-mixer-cat-badge'); - catBadge.style.color = catMeta.color; - catBadge.style.backgroundColor = catMeta.bg; - catBadge.style.borderColor = catMeta.border; - catBadge.title = window.anomalous_browser_lang === 'zh' ? '点击切换词性分类' : 'Cycle category'; - catBadge.onclick = () => { - const cats = ['base', 'style', 'subject', 'trigger']; - const nextIdx = (cats.indexOf(block.category) + 1) % cats.length; - block.category = cats[nextIdx]; - renderBlocksList(); - }; - - // Editable title - const titleInput = text(headerLeft, 'input', '', 'anomalous-mixer-block-title-input'); - titleInput.value = block.title || ''; - titleInput.oninput = () => { block.title = titleInput.value; }; - - // Weight Adjustment Pill with Mouse Wheel & Click - let currentWeight = block.weight !== undefined ? block.weight : 1.0; - const weightPill = text(headerLeft, 'span', '', 'anomalous-block-weight-pill'); - weightPill.title = window.anomalous_browser_lang === 'zh' - ? '滚轮上下滑动调节权重,或点击加减' - : 'Scroll wheel or click +/- to adjust weight'; - - const decBtn = text(weightPill, 'button', '-', 'anomalous-block-weight-btn'); - const weightVal = text(weightPill, 'span', currentWeight.toFixed(2)); - const incBtn = text(weightPill, 'button', '+', 'anomalous-block-weight-btn'); - - const applyBlockWeight = (newW) => { - newW = Math.round(Math.max(0.1, Math.min(3.0, newW)) * 100) / 100; - currentWeight = newW; - block.weight = newW; - weightVal.textContent = newW.toFixed(2); - let clean = block.content.replace(/^\((.+):[0-9.]+\)$/, '$1').replace(/^\((.+)\)$/, '$1').trim(); - if (newW !== 1.0) { - block.content = `(${clean}:${newW.toFixed(2)})`; - } else { - block.content = clean; - } - const txtArea = blockEl.querySelector('.anomalous-mixer-block-textarea'); - if (txtArea) txtArea.value = block.content; - syncDraftSynthesizedText(draft); - updateOutputPreview(); - }; - - decBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight - 0.05); }; - incBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight + 0.05); }; - - weightPill.onwheel = (e) => { - e.preventDefault(); - e.stopPropagation(); - const delta = e.deltaY < 0 ? 0.05 : -0.05; - applyBlockWeight(currentWeight + delta); - }; - - // Right Action micro buttons - const headerRight = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-right'); - - const transBtn = text(headerRight, 'button', '🌐', 'anomalous-mixer-block-btn is-translate'); - transBtn.title = window.anomalous_browser_lang === 'zh' ? '一键翻译此块提示词' : 'Translate this block'; - transBtn.onclick = async () => { - const raw = block.content || ''; - if (!raw.trim()) return; - const orig = transBtn.textContent; - transBtn.textContent = '⏳'; - try { - const res = await translatePromptText(raw); - if (res.ok && res.translated) { - block.content = res.translated; - textarea.value = res.translated; - syncDraftSynthesizedText(draft); - updateOutputPreview(); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 词块已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Block translated to ${res.targetLang}`); - } else { - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`); - } - } finally { - transBtn.textContent = orig; - } - }; - - const upBtn = text(headerRight, 'button', '▲', 'anomalous-mixer-block-btn'); - upBtn.title = window.anomalous_browser_lang === 'zh' ? '上移' : 'Move up'; - upBtn.disabled = index === 0; - upBtn.onclick = () => { - const realIdx = draft.plan.parts.findIndex(p => p.id === block.id); - if (realIdx < 0) return; - const myRole = block.role || activeTab; - let prevIdx = -1; - for (let i = realIdx - 1; i >= 0; i--) { - if ((draft.plan.parts[i].role || 'positive') === myRole) { - prevIdx = i; - break; - } - } - if (prevIdx >= 0) { - const temp = draft.plan.parts[realIdx]; - draft.plan.parts[realIdx] = draft.plan.parts[prevIdx]; - draft.plan.parts[prevIdx] = temp; - syncDraftSynthesizedText(draft); - renderBlocksList(); - updateOutputPreview(); - } - }; - - const downBtn = text(headerRight, 'button', '▼', 'anomalous-mixer-block-btn'); - downBtn.title = window.anomalous_browser_lang === 'zh' ? '下移' : 'Move down'; - downBtn.disabled = index === currentRoleParts.length - 1; - downBtn.onclick = () => { - const realIdx = draft.plan.parts.findIndex(p => p.id === block.id); - if (realIdx < 0) return; - const myRole = block.role || activeTab; - let nextIdx = -1; - for (let i = realIdx + 1; i < draft.plan.parts.length; i++) { - if ((draft.plan.parts[i].role || 'positive') === myRole) { - nextIdx = i; - break; - } - } - if (nextIdx >= 0) { - const temp = draft.plan.parts[realIdx]; - draft.plan.parts[realIdx] = draft.plan.parts[nextIdx]; - draft.plan.parts[nextIdx] = temp; - syncDraftSynthesizedText(draft); - renderBlocksList(); - updateOutputPreview(); - } - }; - - const delBtn = text(headerRight, 'button', '✕', 'anomalous-mixer-block-btn is-delete'); - delBtn.title = window.anomalous_browser_lang === 'zh' ? '移除此块' : 'Remove block'; - delBtn.onclick = () => { - const realIdx = draft.plan.parts.findIndex(p => p.id === block.id); - if (realIdx >= 0) { - draft.plan.parts.splice(realIdx, 1); - syncDraftSynthesizedText(draft); - updateRightTabsUI(); - renderBlocksList(); - updateOutputPreview(); - } - }; - - // Body - const blockBody = text(blockEl, 'div', '', 'anomalous-mixer-block-body'); - const textarea = text(blockBody, 'textarea', '', 'anomalous-mixer-block-textarea'); - textarea.value = block.content || ''; - textarea.rows = 1; - textarea.oninput = () => { - block.content = textarea.value; - syncDraftSynthesizedText(draft); - updateOutputPreview(); - }; - }); - - // Interactive Auto-snap Dock at end of track - const snapDock = text(blocksContainer, 'div', '', 'anomalous-assembly-snap-dock'); - snapDock.innerHTML = ` -
- - - - ${window.anomalous_browser_lang === 'zh' ? '拖拽卡片至此自动贴合至末尾' : 'Drop card here to snap to bottom'} -
- `; - setupDropzoneListeners(snapDock); - snapDock.onclick = () => { - leftSearch?.focus?.(); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '点击左侧词卡即可直接加入此处' : 'Click any card on left to add here'); - }; - } - - function setupDropzoneListeners(el) { - el.ondragover = (e) => { - e.preventDefault(); - el.classList.add('is-drag-over'); - }; - el.ondragleave = () => { - el.classList.remove('is-drag-over'); - }; - el.ondrop = (e) => { - e.preventDefault(); - el.classList.remove('is-drag-over'); - const jsonStr = e.dataTransfer.getData('application/json'); - if (jsonStr) { - try { - addSourceCardToMixer(JSON.parse(jsonStr)); - return; - } catch (err) {} - } - if (draggedBlockId) { - const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId); - if (fromIndex >= 0) { - const [moved] = draft.plan.parts.splice(fromIndex, 1); - draft.plan.parts.push(moved); - syncDraftSynthesizedText(draft); - renderBlocksList(); - updateOutputPreview(); - } - } - }; - } - - function updateOutputPreview() { - syncDraftSynthesizedText(draft); - const compiledText = (draft.plan[activeTab] || '').trim(); - const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab); - - if (!currentRoleParts.length && !compiledText) { - floatingDock.style.display = 'none'; - } else { - floatingDock.style.display = 'flex'; - const words = compiledText.split(/[,,\s\n]+/).filter(Boolean).length; - dockStats.innerHTML = `${activeTab === 'negative' ? '🌙' : '🔆'} ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块' : 'blocks'} · ~${words} ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`; - dockStats.title = activeTab === 'negative' - ? `${window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}` - : `${window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}`; - } - } - - // Smart Sort - smartSortBtn.onclick = () => { - if (!draft.plan.parts.length) return; - const roleParts = draft.plan.parts.filter(p => p.role === activeTab); - const otherParts = draft.plan.parts.filter(p => p.role !== activeTab); - const sorted = smartSortPromptBlocks(roleParts, activeTab); - draft.plan.parts = [...sorted, ...otherParts]; - - syncDraftSynthesizedText(draft); - renderBlocksList(); - updateOutputPreview(); - - smartSortBtn.classList.add('is-animating'); - setTimeout(() => smartSortBtn.classList.remove('is-animating'), 500); - }; - - // Clear track - clearRightBtn.onclick = async () => { - const msg = window.anomalous_browser_lang === 'zh' - ? `确定清空当前【${activeTab === 'positive' ? '正向' : '负向'}】拼装池吗?` - : `Clear ${activeTab} mixer track?`; - if (await anomalousConfirm(msg)) { - draft.plan.parts = draft.plan.parts.filter(p => p.role !== activeTab); - syncDraftSynthesizedText(draft); - updateRightTabsUI(); - renderBlocksList(); - updateOutputPreview(); - } - }; - - // Copy preview text - copyOutputBtn.onclick = async (e) => { - e?.stopPropagation?.(); - const textToCopy = (draft.plan[activeTab] || outputTextarea.value || '').trim(); - if (!textToCopy) return; - try { - await navigator.clipboard.writeText(textToCopy); - copyOutputBtn.textContent = '✅'; - setTimeout(() => { if (copyOutputBtn.isConnected) copyOutputBtn.textContent = `📋`; }, 1500); - } catch (err) { - await anomalousAlert(t('materialCopyError')); - } - }; - - // ------------------------------------------------------------------------- - // Target Node Direct Injection Toolbar (Compact sticky bar integrated into Action Dock) - // ------------------------------------------------------------------------- - const renderTargetBar = () => { - if (!view.isConnected) return; - targetBar.replaceChildren(); - const node = selectedMaterialNode(app); - const targets = promptWidgetTargets(node); - - const actions = text(targetBar, 'div', '', 'anomalous-prompt-target-actions'); - - if (!node || !targets.length) { - actions.appendChild(posWrap); - const emptyBtn = text(actions, 'button', `⬇️ ${window.anomalous_browser_lang === 'zh' ? '写入节点' : 'Write Node'}`, 'anomalous-btn-ghost anomalous-btn-sm'); - emptyBtn.title = t('promptSelectTextNode'); - emptyBtn.onclick = () => { - anomalousAlert(window.anomalous_browser_lang === 'zh' - ? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!' - : '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!'); - }; - return; - } - - // Compact target node tag - const nodeHeading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`; - const nodeBadge = text(actions, 'span', `#${node.id} ${nodeHeading}`.slice(0, 14), 'anomalous-target-node-badge'); - nodeBadge.title = t('materialApplyingTo', { name: nodeHeading, id: node.id }); - - if (targets.length > 1) { - const widgetSelect = text(actions, 'select', '', 'anomalous-target-widget-select'); - widgetSelect.setAttribute('aria-label', t('promptTargetWidget')); - for (const target of targets) { - text(widgetSelect, 'option', target.name).value = String(target.index); - } - const savedIndex = targetWidgetIndexByNodeId[node.id]; - if (savedIndex !== undefined && targets.some(t => t.index === savedIndex)) { - widgetSelect.value = String(savedIndex); - } else { - targetWidgetIndexByNodeId[node.id] = Number(widgetSelect.value); - } - widgetSelect.onchange = () => { - targetWidgetIndexByNodeId[node.id] = Number(widgetSelect.value); - }; - } - - actions.appendChild(posWrap); - - const applyRole = (role, label, isPrimary = false) => { - const btn = text(actions, 'button', label, isPrimary ? 'anomalous-btn-primary anomalous-btn-sm' : 'anomalous-btn-ghost anomalous-btn-sm'); - btn.type = 'button'; - btn.onclick = async () => { - try { - if (selectedMaterialNode(app) !== node) throw new Error('materialTargetChanged'); - const widgetSelectEl = actions.querySelector('select:not(.anomalous-prompt-pos-select)'); - const index = widgetSelectEl ? Number(widgetSelectEl.value) : targets[0].index; - if (!promptWidgetTargets(node).some(t => t.index === index)) throw new Error('materialTargetChanged'); - const content = draft.plan[role]; - if (!content || !content.trim()) { - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '当前拼装台暂无内容可写入' : 'No prompt content in current track'); - return; - } - const value = joinPromptText(node.widgets[index].value, content, posSelect.value); - showMaterialApplication(targetBar, applyNodeMaterialValues(app, node, [{ index, value }]), node); - btn.textContent = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已写入' : 'Written'); - setTimeout(() => { if (btn.isConnected) btn.textContent = label; }, 1200); - } catch (error) { - await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); - } - }; - }; - - const activeLabel = activeTab === 'positive' - ? `⬇️ ${window.anomalous_browser_lang === 'zh' ? '写入正向' : 'Write Pos'}` - : `⬇️ ${window.anomalous_browser_lang === 'zh' ? '写入负向' : 'Write Neg'}`; - applyRole(activeTab, activeLabel, true); - }; - - if (isSide) owner.refreshSidePromptTarget = renderTargetBar; - else owner.refreshPromptTarget = renderTargetBar; - - // Save Plan to Material Library - saveBtn.onclick = async () => { - if (!draft.name?.trim()) { - nameInput.focus(); - nameInput.classList.add('is-invalid'); - setTimeout(() => nameInput.classList.remove('is-invalid'), 1200); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!'); - return; - } - saveBtn.disabled = true; - syncDraftSynthesizedText(draft); - const saved = workbenchDraftToSavedPlan(draft); - const body = { - name: draft.name.trim(), - tags: draft.tags || [], - plan: saved.plan, - }; - - const send = () => fetch('/anomalous/save_prompt_plan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - try { - let response = await send(); - if (response.status === 409) { - const duplicate = await response.json(); - if (!await anomalousConfirm(t('materialDuplicateConfirm', { name: duplicate.name }))) return; - body.allow_duplicate = true; - response = await send(); - } - const payload = await jsonResponse(response, 'prompt save failed'); - if (payload.status !== 'success') throw new Error('prompt save failed'); - showMaterialSaved(owner, payload.material); - } catch (error) { - await anomalousAlert(t('materialSaveError')); - } finally { - saveBtn.disabled = false; - } - }; - - // Export Plan JSON - exportBtn.onclick = () => { - if (!draft.name?.trim()) { - nameInput.focus(); - nameInput.classList.add('is-invalid'); - setTimeout(() => nameInput.classList.remove('is-invalid'), 1200); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!'); - return; - } - syncDraftSynthesizedText(draft); - const saved = workbenchDraftToSavedPlan(draft); - const exportData = { - format: 'anomalous-prompt-plan-v1', - version: 2, - name: draft.name, - tags: draft.tags, - plan: saved.plan, - }; - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - if (blob.size > 2 * 1024 * 1024 || draft.tags.length > 20 || draft.tags.some(tag => tag.length > 60)) { - return anomalousAlert(t('promptExportError')); - } - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${draft.name || 'prompt-mixer-preset'}.json`; - a.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - }; - - // New Draft - newBtn.onclick = async () => { - if (await anomalousConfirm(t('promptReplaceDraft'))) { - owner.promptPlanDraft = draft = newDraft(); - draft.plan.parts.push(normalizeBlock({ - title: window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive Prompt', - content: '', - role: 'positive', - category: 'base', - }, 0)); - syncDraftSynthesizedText(draft); - if (!isSide) { - showPromptComposer(owner); - } else { - updateRightTabsUI(); - renderBlocksList(); - updateOutputPreview(); - renderTargetBar(); - nameInput.value = ''; - draft.tags = []; - } - } - }; - - // Initial silent sync from Material Library on mount - (async () => { - try { - const res = await fetch('/anomalous/materials?category=prompts&limit=80'); - const data = await jsonResponse(res, 'materials list'); - if (Array.isArray(data.materials)) { - for (const item of data.materials) { - try { - const prompts = await loadMaterialPrompts(item.filename); - if (prompts.positive && !sourceCards.some(c => c.content === prompts.positive.trim())) { - sourceCards.push({ - id: `mat_pos_${item.filename}`, - title: item.name ? `${item.name} (Pos)` : '素材正向', - content: prompts.positive.trim(), - role: 'positive', - category: categorizePromptSnippet(prompts.positive), - }); - } - if (prompts.negative && !sourceCards.some(c => c.content === prompts.negative.trim())) { - sourceCards.push({ - id: `mat_neg_${item.filename}`, - title: item.name ? `${item.name} (Neg)` : '素材负向', - content: prompts.negative.trim(), - role: 'negative', - category: 'base', - }); - } - } catch (e) {} - } - if (view.isConnected) { - renderSourceCardsList(); - } - } - } catch (e) {} - })(); - - // Initial render execution - renderNewCardFormUI(); - renderSourceCardsList(); - updateRightTabsUI(); - renderBlocksList(); - updateOutputPreview(); - renderTargetBar(); - - const control = { - updateAll: () => { - updateRightTabsUI(); - renderBlocksList(); - updateOutputPreview(); - renderSourceCardsList(); - }, - addBlock: (blockData) => { - addSourceCardToMixer(blockData); - }, - }; - - if (!isSide) owner.workbenchComposerControl = control; - return control; -} - -function applyPromptDrop(node, data, graph, parent) { - const targets = promptWidgetTargets(node); - const apply = index => { - if (app.graph !== graph || graph.getNodeById(node.id) !== node || !promptWidgetTargets(node).some(target => target.index === index)) { - throw new Error('materialTargetChanged'); - } - const value = joinPromptText(node.widgets[index].value, data.content, data.position); - showMaterialApplication(parent, applyNodeMaterialValues(app, node, [{ index, value }]), node); - }; - if (targets.length === 1) { - apply(targets[0].index); - return; - } - if (!targets.length) throw new Error('materialNoCompatibleValues'); - - const dialog = text(document.body, 'dialog', '', 'anomalous-material-choice'); - text(dialog, 'h3', t('promptChooseTarget')); - const status = text(dialog, 'p', ''); - status.setAttribute('role', 'alert'); - for (const target of targets) { - const choose = text(dialog, 'button', target.name, 'anomalous-btn-primary'); - choose.onclick = () => { - try { - apply(target.index); - dialog.close(); - } catch (error) { - status.textContent = t(error.message) === error.message ? t('materialApplyFailed') : t(error.message); - } - }; - } - const close = text(dialog, 'button', t('close'), 'anomalous-btn-ghost'); - close.onclick = () => dialog.close(); - dialog.onclose = () => dialog.remove(); - dialog.showModal(); -} - -async function openMaterialImportDrawer(owner, draft, onUpdated) { - owner.closePromptImportDrawer?.(); - const drawer = text(document.body, 'aside', '', 'anomalous-prompt-import-drawer'); - owner.promptImportDrawer = drawer; - - const onKeydown = e => { - if (e.key === 'Escape') { - owner.closePromptImportDrawer?.(); - } - }; - window.addEventListener('keydown', onKeydown); - owner.closePromptImportDrawer = () => { - window.removeEventListener('keydown', onKeydown); - drawer.remove(); - owner.promptImportDrawer = null; - }; - - const res = await fetch('/anomalous/materials?category=prompts&limit=48'); - const data = await jsonResponse(res, 'drawer materials'); - const list = text(drawer, 'div', '', 'anomalous-drawer-list'); - - for (const item of data.materials || []) { - const card = text(list, 'div', '', 'anomalous-drawer-item'); - text(card, 'div', item.name || 'Prompt Item', 'anomalous-drawer-item-title'); - const inspect = text(card, 'button', t('materialViewDetails'), 'anomalous-btn-ghost'); - inspect.onclick = async () => { - const prompts = await loadMaterialPrompts(item.filename); - const loadAll = text(card, 'button', t('promptDrawerLoadAll'), 'anomalous-btn-primary'); - loadAll.onclick = () => { - if (prompts.positive) draft.plan.positive = prompts.positive; - if (prompts.negative) draft.plan.negative = prompts.negative; - onUpdated?.(); - }; - }; - } -} - diff --git a/web/modules/ui_prompt_inspector.js b/web/modules/ui_prompt_inspector.js new file mode 100644 index 0000000..3bf5207 --- /dev/null +++ b/web/modules/ui_prompt_inspector.js @@ -0,0 +1,193 @@ +import { createViewScope } from './ui_lifecycle.js'; +import { syncDraftSynthesizedText, replaceDraftRoleText } from './prompt_studio_data.js'; +import { translatePromptText } from './translation_service.js'; +import { translate as t } from './locales.js'; + +let activeInspector = null; + +export function openPromptInspectorModal(draft, defaultRole = 'positive', onApplyToNode = null, onChange = () => {}) { + activeInspector?.dispose(); + const scope = createViewScope(); + activeInspector = scope; + + const overlay = document.createElement('div'); + overlay.className = 'anomalous-prompt-inspector-overlay'; + scope.onDispose(() => { overlay.remove(); if (activeInspector === scope) activeInspector = null; }); + + const modal = document.createElement('div'); + modal.className = 'anomalous-prompt-inspector-modal'; + overlay.appendChild(modal); + + let currentRole = defaultRole || 'positive'; + + // Header + const header = document.createElement('header'); + header.className = 'anomalous-inspector-header'; + const h3 = document.createElement('h3'); + h3.textContent = window.anomalous_browser_lang === 'zh' ? '🔍 提示词深度全览与精修' : '🔍 Prompt Inspector & Editor'; + h3.style.margin = '0'; + h3.style.fontSize = '0.92rem'; + h3.style.color = '#e2e8f0'; + header.appendChild(h3); + + // Role Tabs + const tabsWrap = document.createElement('div'); + tabsWrap.style.display = 'flex'; + tabsWrap.style.gap = '6px'; + + const posTab = document.createElement('button'); + posTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'positive' ? ' is-active' : ''}`; + posTab.textContent = `🔆 ${window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive'}`; + + const negTab = document.createElement('button'); + negTab.className = `anomalous-btn-ghost anomalous-btn-sm${currentRole === 'negative' ? ' is-active' : ''}`; + negTab.textContent = `🌙 ${window.anomalous_browser_lang === 'zh' ? '负向提示词' : 'Negative'}`; + + tabsWrap.appendChild(posTab); + tabsWrap.appendChild(negTab); + header.appendChild(tabsWrap); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; + closeBtn.textContent = '✕'; + closeBtn.title = window.anomalous_browser_lang === 'zh' ? '关闭 (Esc)' : 'Close (Esc)'; + closeBtn.onclick = () => scope.dispose(); + header.appendChild(closeBtn); + + // Body + const body = document.createElement('div'); + body.className = 'anomalous-inspector-body'; + + const editHint = document.createElement('small'); + editHint.textContent = t('promptFullTextEditHint'); + body.appendChild(editHint); + + const textarea = document.createElement('textarea'); + textarea.className = 'anomalous-inspector-textarea'; + textarea.spellcheck = false; + + syncDraftSynthesizedText(draft); + textarea.value = draft?.plan?.[currentRole] || ''; + + const updateStats = () => { + const val = textarea.value; + const words = val.split(/[,,\s\n]+/).filter(Boolean).length; + statsEl.textContent = `${val.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'} · ~${words} ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`; + }; + + const updateTabStyles = () => { + posTab.classList.toggle('is-active', currentRole === 'positive'); + negTab.classList.toggle('is-active', currentRole === 'negative'); + textarea.value = draft?.plan?.[currentRole] || ''; + updateStats(); + }; + + posTab.onclick = () => { currentRole = 'positive'; updateTabStyles(); }; + negTab.onclick = () => { currentRole = 'negative'; updateTabStyles(); }; + + body.appendChild(textarea); + + // Footer + const footer = document.createElement('footer'); + footer.className = 'anomalous-inspector-footer'; + + const statsEl = document.createElement('div'); + statsEl.style.fontSize = '0.75rem'; + statsEl.style.color = '#94a3b8'; + statsEl.style.fontFamily = 'monospace'; + + const saveEdits = () => { + replaceDraftRoleText(draft, currentRole, textarea.value); + onChange(); + updateStats(); + }; + textarea.oninput = saveEdits; + updateStats(); + + footer.appendChild(statsEl); + + const actions = document.createElement('div'); + actions.style.display = 'flex'; + actions.style.gap = '6px'; + + // Normalize formatting + const normBtn = document.createElement('button'); + normBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; + normBtn.textContent = `🧹 ${window.anomalous_browser_lang === 'zh' ? '规范化' : 'Normalize'}`; + normBtn.title = window.anomalous_browser_lang === 'zh' ? '清理多余顿号、分号与重复逗号' : 'Clean formatting and punctuation'; + normBtn.onclick = () => { + const cleaned = textarea.value + .replace(/[,,、;;||\n\r]+/g, ', ') + .replace(/\s+,/g, ',') + .replace(/,\s*,/g, ', ') + .replace(/^\s*,\s*|\s*,\s*$/g, '') + .trim(); + textarea.value = cleaned; + saveEdits(); + }; + actions.appendChild(normBtn); + + // Translate + const transBtn = document.createElement('button'); + transBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; + transBtn.textContent = `🌐 ${window.anomalous_browser_lang === 'zh' ? '翻译' : 'Translate'}`; + transBtn.onclick = async () => { + const raw = textarea.value.trim(); + const role = currentRole; + if (!raw) return; + const orig = transBtn.textContent; + transBtn.textContent = '⏳'; + try { + const res = await translatePromptText(raw, { signal: scope.signal }); + if (!scope.signal.aborted && currentRole === role && textarea.value.trim() === raw && res.ok && res.translated) { + textarea.value = res.translated; + saveEdits(); + } + } finally { + transBtn.textContent = orig; + } + }; + actions.appendChild(transBtn); + + // Copy + const copyBtn = document.createElement('button'); + copyBtn.className = 'anomalous-btn-ghost anomalous-btn-sm'; + copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`; + copyBtn.onclick = async () => { + await navigator.clipboard.writeText(textarea.value); + copyBtn.textContent = '✓'; + setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = `📋 ${window.anomalous_browser_lang === 'zh' ? '复制' : 'Copy'}`; }, 1200); + }; + actions.appendChild(copyBtn); + + // Apply to node & close + if (typeof onApplyToNode === 'function') { + const applyBtn = document.createElement('button'); + applyBtn.className = 'anomalous-btn-primary anomalous-btn-sm'; + applyBtn.textContent = `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点并关闭' : 'Write Node & Close'}`; + applyBtn.onclick = async () => { + if (await onApplyToNode(currentRole, textarea.value)) scope.dispose(); + }; + actions.appendChild(applyBtn); + } + + footer.appendChild(actions); + + modal.appendChild(header); + modal.appendChild(body); + modal.appendChild(footer); + + overlay.onclick = (e) => { + if (e.target === overlay) scope.dispose(); + }; + + const onKey = (e) => { + if (e.key === 'Escape') { + scope.dispose(); + } + }; + scope.listen(window, 'keydown', onKey); + + document.body.appendChild(overlay); + return () => scope.dispose(); +} diff --git a/web/modules/ui_prompt_source_deck.js b/web/modules/ui_prompt_source_deck.js new file mode 100644 index 0000000..a38c043 --- /dev/null +++ b/web/modules/ui_prompt_source_deck.js @@ -0,0 +1,516 @@ +import { app } from '../../../scripts/app.js'; +import { translate as t } from './locales.js'; +import { text, jsonResponse } from './ui_dom.js'; +import { materialNodeHeading } from './material_inspector.js'; +import { anomalousAlert } from './ui_dialog.js'; +import { promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js'; +import { categorizePromptSnippet } from './prompt_composition.js'; +import { CATEGORY_META, STARTER_SOURCE_PROMPTS } from './prompt_studio_data.js'; +import { showWorkbenchToast } from './ui_prompt_toast.js'; +import { loadPromptSourceCards, mergePromptSourceCards } from './prompt_material_source.js'; +import { translatePromptText } from './translation_service.js'; + +export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCardToMixer) { + const sourceCards = [...STARTER_SOURCE_PROMPTS]; + let sourceFilterCategory = 'all'; + let sourceFilterKeyword = ''; + let isCreatingNewCard = false; + let activeCardPreviewPopover = null; + function hideCardPreviewPopover() { + activeCardPreviewPopover?.remove(); + activeCardPreviewPopover = null; + } + scope.onDispose(hideCardPreviewPopover); + const leftPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-left-panel'); + const leftHeader = text(leftPanel, 'div', '', 'anomalous-workbench-col-header'); + const leftTitleWrap = text(leftHeader, 'div', '', 'anomalous-workbench-col-title'); + text(leftTitleWrap, 'strong', window.anomalous_browser_lang === 'zh' ? '词卡库' : 'Prompt Library'); + const leftCounter = text(leftTitleWrap, 'span', '', 'anomalous-sub-counter'); + + // Action button group in leftHeader + const leftHeaderActions = text(leftHeader, 'div', '', 'anomalous-workbench-header-actions'); + + // Button 1: One-click Sync / Import from Material Library + const importMaterialsBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn'); + importMaterialsBtn.innerHTML = ``; + importMaterialsBtn.title = window.anomalous_browser_lang === 'zh' ? '从素材库同步提示词' : 'Sync prompts from Material Library'; + + // Button 2: Create New Custom Card + const newCardTriggerBtn = text(leftHeaderActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm anomalous-icon-btn'); + newCardTriggerBtn.innerHTML = ``; + newCardTriggerBtn.title = window.anomalous_browser_lang === 'zh' ? '新建词卡' : 'Create new prompt card'; + + // Search and category filters bar + const leftFilterBar = text(leftPanel, 'div', '', 'anomalous-workbench-filter-bar'); + const leftSearch = text(leftFilterBar, 'input', '', 'anomalous-workbench-search-input'); + leftSearch.placeholder = window.anomalous_browser_lang === 'zh' ? '搜索词卡...' : 'Search cards...'; + leftSearch.oninput = () => { + sourceFilterKeyword = leftSearch.value.trim().toLowerCase(); + renderSourceCardsList(); + }; + + const leftCategoryPills = text(leftPanel, 'div', '', 'anomalous-workbench-category-pills'); + const filterCats = [ + { id: 'all', label: window.anomalous_browser_lang === 'zh' ? '全部' : 'All' }, + { id: 'base', label: window.anomalous_browser_lang === 'zh' ? '底模' : 'Base' }, + { id: 'style', label: window.anomalous_browser_lang === 'zh' ? '风格' : 'Style' }, + { id: 'subject', label: window.anomalous_browser_lang === 'zh' ? '主体' : 'Subject' }, + { id: 'trigger', label: window.anomalous_browser_lang === 'zh' ? '触发' : 'Trigger' }, + ]; + filterCats.forEach(cat => { + const pill = text(leftCategoryPills, 'button', cat.label, `anomalous-workbench-pill${sourceFilterCategory === cat.id ? ' is-active' : ''}`); + pill.onclick = () => { + sourceFilterCategory = cat.id; + leftCategoryPills.querySelectorAll('.anomalous-workbench-pill').forEach(el => el.classList.remove('is-active')); + pill.classList.add('is-active'); + renderSourceCardsList(); + }; + }); + + // Inline New Card Form (Hidden by default, shown on demand) + const newCardForm = text(leftPanel, 'div', '', 'anomalous-workbench-new-card-form'); + newCardForm.style.display = 'none'; + + // Source Cards List + const sourceCardsList = text(leftPanel, 'div', '', 'anomalous-source-cards-list'); + + function detectNodePromptRole(node, widgetName = '') { + if (/neg|negative|反向|负向/i.test(widgetName)) return 'negative'; + if (/pos|positive|正面|正向/i.test(widgetName)) return 'positive'; + + if (app?.graph && Array.isArray(node?.outputs)) { + for (const output of node.outputs) { + if (!Array.isArray(output.links)) continue; + for (const linkId of output.links) { + const link = app.graph.links?.[linkId]; + if (!link) continue; + const targetNode = app.graph.getNodeById(link.target_id); + if (targetNode?.inputs && targetNode.inputs[link.target_slot]) { + const targetInput = targetNode.inputs[link.target_slot]; + const inputName = String(targetInput.name || '').toLowerCase(); + if (inputName.includes('neg') || inputName.includes('负')) return 'negative'; + if (inputName.includes('pos') || inputName.includes('正')) return 'positive'; + } + } + } + } + + const nodeText = `${node?.title || ''} ${node?.type || ''}`.toLowerCase(); + if (/neg|negative|反向|负向/.test(nodeText)) return 'negative'; + if (/pos|positive|正面|正向/.test(nodeText)) return 'positive'; + + return 'positive'; + } + + // Extract prompts from selected canvas node + function extractPromptsFromSelectedNode(intoRightMixer = false) { + const node = selectedMaterialNode(app); + if (!node) { + anomalousAlert(window.anomalous_browser_lang === 'zh' + ? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode 或包含 prompt 文本的节点)!' + : '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!'); + return; + } + + const heading = materialNodeHeading(node) || node.title || node.type || `Node #${node.id}`; + let targets = promptWidgetTargets(node); + + // Safe fallback: only prompt/caption/text widgets or textarea, exclude settings/file paths + if (!targets.length && Array.isArray(node.widgets)) { + targets = node.widgets.flatMap((w, idx) => { + const name = String(w.name || '').toLowerCase(); + if (/filename|prefix|path|directory|save|load|ckpt|model|vae|seed|steps|cfg|denoise|sampler|scheduler/i.test(name)) { + return []; + } + if (typeof w.value === 'string' && w.value.trim().length > 0 && !w.options?.values) { + if (/prompt|caption|text|description|words|tags/i.test(name) || w.type === 'customtext' || w.type === 'string') { + return [{ index: idx, name: w.name || 'text' }]; + } + } + return []; + }); + } + + if (!targets.length) { + anomalousAlert(window.anomalous_browser_lang === 'zh' + ? `⚠️ 选中的节点【${heading}】中未检测到有效的提示词文本输入!` + : `⚠️ No valid prompt text found in selected node [${heading}]!`); + return; + } + + let extractedCount = 0; + targets.forEach(t => { + const rawVal = String(node.widgets[t.index]?.value || '').trim(); + if (!rawVal) return; + const role = detectNodePromptRole(node, t.name); + const cat = role === 'negative' ? 'base' : categorizePromptSnippet(rawVal); + const cardTitle = `${heading} · ${t.name}`; + + const newCard = { + id: `node_${node.id}_${t.index}_${Date.now()}`, + title: cardTitle, + content: rawVal, + role, + category: cat, + persisted: false, + }; + + // Add into left source deck + sourceCards.unshift(newCard); + extractedCount++; + + // If user clicked right panel, also insert into mixer track directly + if (intoRightMixer) { + addSourceCardToMixer(newCard); + } + }); + + if (extractedCount > 0) { + renderSourceCardsList(); + showWorkbenchToast(window.anomalous_browser_lang === 'zh' + ? `已成功从节点【${heading}】提取 ${extractedCount} 段提示词${intoRightMixer ? '并加入组合' : '并加入左侧词库'}!` + : `Successfully extracted ${extractedCount} prompts from [${heading}]!`); + } else { + anomalousAlert(window.anomalous_browser_lang === 'zh' + ? `选中的节点【${heading}】文本内容为空!` + : `The text fields in node [${heading}] are empty!`); + } + } + + let materialSyncController = null; + scope.onDispose(() => materialSyncController?.abort()); + async function syncMaterialsIntoSourceDeck(notify = true) { + materialSyncController?.abort(); + const controller = new AbortController(); + materialSyncController = controller; + const icon = importMaterialsBtn.innerHTML; + importMaterialsBtn.disabled = true; + try { + const cards = await loadPromptSourceCards(controller.signal); + if (scope.signal.aborted || controller.signal.aborted) return; + const addedCount = mergePromptSourceCards(sourceCards, cards); + renderSourceCardsList(); + if (notify) showWorkbenchToast(t('promptSourceSynced', { count: addedCount })); + } catch (error) { + if (!scope.signal.aborted && !controller.signal.aborted && notify) await anomalousAlert(t('materialLoadError')); + } finally { + if (materialSyncController === controller) { + materialSyncController = null; + importMaterialsBtn.disabled = false; + importMaterialsBtn.innerHTML = icon; + } + } + } + importMaterialsBtn.onclick = () => syncMaterialsIntoSourceDeck(); + // ------------------------------------------------------------------------- + // RENDER: Left Panel Cards & Form + // ------------------------------------------------------------------------- + function renderNewCardFormUI() { + if (!isCreatingNewCard) { + newCardForm.style.display = 'none'; + newCardForm.replaceChildren(); + return; + } + newCardForm.style.display = 'flex'; + newCardForm.replaceChildren(); + + text(newCardForm, 'div', window.anomalous_browser_lang === 'zh' ? '✨ 新建提示词卡片' : '✨ New Prompt Card', 'anomalous-form-title'); + + const titleInput = text(newCardForm, 'input', '', 'anomalous-form-input'); + titleInput.placeholder = window.anomalous_browser_lang === 'zh' ? '卡片名称(如:赛博光影、角色面部)...' : 'Card name...'; + + // Role & Category selector row + const metaRow = text(newCardForm, 'div', '', 'anomalous-form-meta-row'); + + // Role radio group + const roleGroup = text(metaRow, 'div', '', 'anomalous-form-role-group'); + let selectedRole = activeTab || 'positive'; + + const posLabel = text(roleGroup, 'label', '', 'anomalous-role-label'); + const posRadio = text(posLabel, 'input', ''); + posRadio.type = 'radio'; + posRadio.name = 'new_card_role'; + posRadio.value = 'positive'; + posRadio.checked = selectedRole === 'positive'; + text(posLabel, 'span', t('promptRolePositive')); + + const negLabel = text(roleGroup, 'label', '', 'anomalous-role-label'); + const negRadio = text(negLabel, 'input', ''); + negRadio.type = 'radio'; + negRadio.name = 'new_card_role'; + negRadio.value = 'negative'; + negRadio.checked = selectedRole === 'negative'; + text(negLabel, 'span', t('promptRoleNegative')); + + posRadio.onchange = () => { if (posRadio.checked) selectedRole = 'positive'; }; + negRadio.onchange = () => { if (negRadio.checked) selectedRole = 'negative'; }; + + // Category dropdown + const catWrap = text(metaRow, 'div', '', 'anomalous-form-cat-wrap'); + text(catWrap, 'span', `${window.anomalous_browser_lang === 'zh' ? '分类' : 'Type'}: `); + let selectedCat = 'style'; + const catSelect = text(catWrap, 'select', '', 'anomalous-form-select'); + ['base', 'style', 'subject', 'trigger'].forEach(catKey => { + const meta = CATEGORY_META[catKey]; + const opt = text(catSelect, 'option', window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en); + opt.value = catKey; + if (catKey === selectedCat) opt.selected = true; + }); + catSelect.onchange = () => { selectedCat = catSelect.value; }; + + const contentInput = text(newCardForm, 'textarea', '', 'anomalous-form-textarea'); + contentInput.placeholder = window.anomalous_browser_lang === 'zh' ? '输入提示词内容,多个短语用逗号隔开...' : 'Enter prompt text...'; + contentInput.rows = 3; + + const formBtnRow = text(newCardForm, 'div', '', 'anomalous-form-btn-row'); + const submitBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '✓ 保存并加入库' : '✓ Save to Library', 'anomalous-btn-primary anomalous-btn-sm'); + + const translateBtn = text(formBtnRow, 'button', window.anomalous_browser_lang === 'zh' ? '🌐 翻译' : '🌐 Translate', 'anomalous-btn-ghost anomalous-btn-sm anomalous-btn-card-translate'); + translateBtn.type = 'button'; + translateBtn.title = window.anomalous_browser_lang === 'zh' ? '一键双向翻译 (中/英互译)' : 'One-click bilingual translation'; + translateBtn.onclick = async () => { + const raw = contentInput.value.trim(); + if (!raw) return; + const originalText = translateBtn.textContent; + translateBtn.disabled = true; + translateBtn.textContent = '⏳ ...'; + try { + const res = await translatePromptText(raw, { signal: scope.signal }); + if (scope.signal.aborted || !contentInput.isConnected || contentInput.value.trim() !== raw) return; + if (res.ok && res.translated) { + contentInput.value = res.translated; + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Translated to ${res.targetLang}`); + } else { + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`); + } + } finally { + translateBtn.disabled = false; + translateBtn.textContent = originalText; + } + }; + + const cancelBtn = text(formBtnRow, 'button', t('cancel'), 'anomalous-btn-ghost anomalous-btn-sm'); + + submitBtn.onclick = async () => { + const rawContent = contentInput.value.trim(); + if (!rawContent) { + contentInput.focus(); + return; + } + submitBtn.disabled = true; + submitBtn.textContent = '⏳ ...'; + + const cardTitle = titleInput.value.trim() || (window.anomalous_browser_lang === 'zh' ? CATEGORY_META[selectedCat].zh : CATEGORY_META[selectedCat].en); + const newCard = { + id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + title: cardTitle, + content: rawContent, + role: selectedRole, + category: selectedCat, + persisted: false, + }; + + try { + const body = { + name: cardTitle, + tags: ['prompt_card', selectedCat, selectedRole], + allow_duplicate: true, + plan: { + version: 2, + positive: selectedRole === 'positive' ? rawContent : '', + negative: selectedRole === 'negative' ? rawContent : '', + parts: [{ + id: newCard.id, + name: cardTitle, + category: selectedCat, + role: selectedRole, + positive: selectedRole === 'positive' ? rawContent : '', + negative: selectedRole === 'negative' ? rawContent : '', + enabled: true, + }], + }, + }; + const res = await fetch('/anomalous/save_prompt_plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const payload = await jsonResponse(res, 'save prompt card'); + if (scope.signal.aborted) return; + if (payload.status === 'success') { + newCard.persisted = true; + newCard.filename = payload.filename; + showWorkbenchToast(t('promptSaveCardSuccess')); + } + } catch (err) { + if (scope.signal.aborted) return; + showWorkbenchToast(t('promptSaveCardFailed')); + } + + sourceCards.unshift(newCard); + isCreatingNewCard = false; + renderNewCardFormUI(); + renderSourceCardsList(); + }; + + cancelBtn.onclick = () => { + isCreatingNewCard = false; + renderNewCardFormUI(); + }; + + titleInput.focus(); + } + + newCardTriggerBtn.onclick = () => { + isCreatingNewCard = !isCreatingNewCard; + renderNewCardFormUI(); + }; + + function showCardPreviewPopover(card, anchorEl) { + hideCardPreviewPopover(); + if (!anchorEl?.isConnected) return; + + const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject; + const popover = document.createElement('div'); + popover.className = 'anomalous-card-preview-popover'; + + const header = document.createElement('div'); + header.className = 'anomalous-popover-header'; + + const tags = document.createElement('div'); + tags.className = 'anomalous-popover-tags'; + + const catBadge = document.createElement('span'); + catBadge.className = 'anomalous-popover-cat'; + catBadge.style.color = catMeta.color; + catBadge.style.background = catMeta.bg; + catBadge.style.borderColor = catMeta.border; + catBadge.textContent = window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en; + tags.appendChild(catBadge); + + const roleBadge = document.createElement('span'); + roleBadge.className = `anomalous-popover-role is-${card.role}`; + roleBadge.textContent = card.role === 'negative' + ? (window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative') + : (window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive'); + tags.appendChild(roleBadge); + header.appendChild(tags); + + const titleEl = document.createElement('div'); + titleEl.className = 'anomalous-popover-title'; + titleEl.textContent = card.title; + header.appendChild(titleEl); + + popover.appendChild(header); + + const body = document.createElement('div'); + body.className = 'anomalous-popover-body'; + const snippet = document.createElement('pre'); + snippet.className = 'anomalous-popover-snippet'; + snippet.textContent = card.content; + body.appendChild(snippet); + popover.appendChild(body); + + const footer = document.createElement('div'); + footer.className = 'anomalous-popover-footer'; + footer.textContent = window.anomalous_browser_lang === 'zh' + ? '💡 点击直接添加 · 拖拽自由调序' + : '💡 Click to add · Drag to assemble'; + popover.appendChild(footer); + + document.body.appendChild(popover); + activeCardPreviewPopover = popover; + + // Smart Positioning + const rect = anchorEl.getBoundingClientRect(); + const isDockLeft = drawer?.classList.contains('is-dock-left') ?? true; + const popoverHeight = popover.offsetHeight || 160; + const top = Math.max(12, Math.min(window.innerHeight - popoverHeight - 12, rect.top - 6)); + popover.style.top = `${top}px`; + + if (isDockLeft) { + popover.style.left = `${rect.right + 10}px`; + } else { + popover.style.left = `${Math.max(12, rect.left - 320)}px`; + } + } + + function renderSourceCardsList() { + hideCardPreviewPopover(); + sourceCardsList.replaceChildren(); + sourceCardsList.onscroll = () => hideCardPreviewPopover(); + + const filtered = sourceCards.filter(card => { + if (sourceFilterCategory !== 'all' && card.category !== sourceFilterCategory) return false; + if (sourceFilterKeyword) { + const matchTitle = card.title.toLowerCase().includes(sourceFilterKeyword); + const matchContent = card.content.toLowerCase().includes(sourceFilterKeyword); + if (!matchTitle && !matchContent) return false; + } + return true; + }); + + leftCounter.textContent = `(${filtered.length})`; + + if (!filtered.length) { + const empty = text(sourceCardsList, 'div', '', 'anomalous-source-empty'); + empty.innerHTML = ` +
🔍
+
${window.anomalous_browser_lang === 'zh' ? '未找到匹配的提示词卡片' : 'No matching prompt cards found'}
+ `; + return; + } + + filtered.forEach(card => { + const catMeta = CATEGORY_META[card.category] || CATEGORY_META.subject; + const cardEl = text(sourceCardsList, 'div', '', `anomalous-source-card-compact is-cat-${card.category}`); + cardEl.setAttribute('draggable', 'true'); + + // Custom Eye-Catching Hover Preview Popover (replaces native OS browser title tooltip) + cardEl.onmouseenter = () => { + showCardPreviewPopover(card, cardEl); + }; + cardEl.onmouseleave = () => { + hideCardPreviewPopover(); + }; + + // Drag Start + cardEl.ondragstart = (e) => { + hideCardPreviewPopover(); + const payload = { + title: card.title, + content: card.content, + role: card.role, + category: card.category, + }; + e.dataTransfer.setData('application/json', JSON.stringify(payload)); + e.dataTransfer.setData('text/plain', card.content); + e.dataTransfer.effectAllowed = 'copyMove'; + cardEl.classList.add('is-dragging-source'); + workbenchGrid.classList.add('is-source-dragging'); + }; + + cardEl.ondragend = () => { + cardEl.classList.remove('is-dragging-source'); + workbenchGrid.classList.remove('is-source-dragging'); + }; + + const dot = text(cardEl, 'span', '', 'anomalous-source-card-dot'); + dot.style.backgroundColor = catMeta.color; + + const nameEl = text(cardEl, 'span', card.title, 'anomalous-source-card-name'); + + const addIcon = text(cardEl, 'span', '+', 'anomalous-source-card-add-icon'); + + cardEl.onclick = () => { + hideCardPreviewPopover(); + addSourceCardToMixer(card); + }; + }); + } + + renderNewCardFormUI(); + renderSourceCardsList(); + void syncMaterialsIntoSourceDeck(false); + return { extractSelected: extractPromptsFromSelectedNode, refresh: renderSourceCardsList, sync: syncMaterialsIntoSourceDeck }; +} diff --git a/web/modules/ui_prompt_toast.js b/web/modules/ui_prompt_toast.js new file mode 100644 index 0000000..edd419f --- /dev/null +++ b/web/modules/ui_prompt_toast.js @@ -0,0 +1,11 @@ +export function showWorkbenchToast(message) { + const toast = document.createElement('div'); + toast.className = 'anomalous-mixer-toast'; + toast.textContent = message; + document.body.appendChild(toast); + setTimeout(() => toast.classList.add('is-show'), 10); + setTimeout(() => { + toast.classList.remove('is-show'); + setTimeout(() => toast.remove(), 300); + }, 2400); +} diff --git a/web/modules/ui_prompt_translator.js b/web/modules/ui_prompt_translator.js index 5e1cd06..e09058b 100644 --- a/web/modules/ui_prompt_translator.js +++ b/web/modules/ui_prompt_translator.js @@ -8,9 +8,10 @@ import { app } from '../../../scripts/app.js'; import { translatePromptText, splitPromptTags, normalizePromptFormatting, hasChinese } from './translation_service.js'; import { selectedMaterialNode, promptWidgetTargets, applyNodeMaterialValues } from './node_material_actions.js'; +import { createViewScope, bindDrawerResize } from './ui_lifecycle.js'; import { appendPromptToStudio } from './ui_prompt_composer.js'; -let activeTranslatorModal = null; +let activeTranslator = null; function t(zh, en) { return window.anomalous_browser_lang === 'zh' ? zh : en; @@ -96,15 +97,17 @@ function writeToSelectedNode(text) { * @param {Object} owner - AnomalousBrowser instance */ export function openPromptTranslator(owner) { - if (activeTranslatorModal) { - activeTranslatorModal.remove(); - activeTranslatorModal = null; - } + activeTranslator?.dispose(); + const scope = createViewScope(); + activeTranslator = scope; // Modal Overlay const overlay = document.createElement('div'); overlay.className = 'anomalous-translator-overlay'; - activeTranslatorModal = overlay; + scope.onDispose(() => { + overlay.remove(); + if (activeTranslator === scope) activeTranslator = null; + }); // Modal Window const modal = document.createElement('div'); @@ -125,48 +128,14 @@ export function openPromptTranslator(owner) { resizeHandle.className = 'anomalous-translator-resize-handle'; resizeHandle.title = t('拖动调整侧边栏宽度,双击恢复默认', 'Drag to resize sidebar, double-click to reset'); - let isResizing = false; - let startX = 0; - let startWidth = 380; let currentSide = localStorage.getItem('anomalous_translator_dock_side') || 'right'; - - resizeHandle.onmousedown = (e) => { - if (!modal.classList.contains('is-sidebar')) return; - isResizing = true; - startX = e.clientX; - startWidth = modal.getBoundingClientRect().width; - resizeHandle.classList.add('is-resizing'); - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'ew-resize'; - - const onMouseMove = (moveEvt) => { - if (!isResizing) return; - let newWidth; - if (currentSide === 'left') { - const deltaX = moveEvt.clientX - startX; - newWidth = Math.max(320, Math.min(window.innerWidth * 0.85, startWidth + deltaX)); - } else { - const deltaX = startX - moveEvt.clientX; - newWidth = Math.max(320, Math.min(window.innerWidth * 0.85, startWidth + deltaX)); - } - modal.style.setProperty('--amb-translator-width', `${newWidth}px`); - }; - - const onMouseUp = () => { - if (!isResizing) return; - isResizing = false; - resizeHandle.classList.remove('is-resizing'); - document.body.style.userSelect = ''; - document.body.style.cursor = ''; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - const finalWidth = modal.getBoundingClientRect().width; - localStorage.setItem('anomalous_translator_width', Math.round(finalWidth)); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }; + bindDrawerResize(resizeHandle, modal, scope, { + side: () => currentSide, + enabled: () => modal.classList.contains('is-sidebar'), + minWidth: 320, + setWidth: width => modal.style.setProperty('--amb-translator-width', `${width}px`), + saveWidth: width => localStorage.setItem('anomalous_translator_width', String(Math.round(width))), + }); resizeHandle.ondblclick = () => { if (!modal.classList.contains('is-sidebar')) return; @@ -280,8 +249,7 @@ export function openPromptTranslator(owner) { closeBtn.innerHTML = '×'; closeBtn.title = t('关闭 (Esc)', 'Close (Esc)'); closeBtn.onclick = () => { - overlay.remove(); - activeTranslatorModal = null; + scope.dispose(); }; headerRight.appendChild(closeBtn); header.appendChild(headerRight); @@ -494,8 +462,7 @@ export function openPromptTranslator(owner) { if (/[,、;;||]/.test(out) && !hasChinese(out)) { out = normalizePromptFormatting(out); } - overlay.remove(); - activeTranslatorModal = null; + scope.dispose(); if (typeof owner?.openPromptStudio === 'function') { owner.openPromptStudio(); } @@ -527,33 +494,40 @@ export function openPromptTranslator(owner) { return; } - if (!hasChinese(out)) { - out = normalizePromptFormatting(out); - targetTextarea.value = out; - updateTagChips(out); - const res = writeToSelectedNode(out); - showTranslatorToast(modal, res.message, !res.success); + if (translateAndWriteBtn.disabled || scope.signal.aborted) return; + // Capture the exact destination before awaiting a translation. + const graph = app.graph; + const node = selectedMaterialNode(app); + const target = node && promptWidgetTargets(node)[0]; + if (!target) { + showTranslatorToast(modal, t('请先选中可写入的提示词节点', 'Select a writable prompt node first'), true); return; } + const widget = node.widgets[target.index]; + const previousValue = widget.value; translateAndWriteBtn.disabled = true; translateAndWriteBtn.innerHTML = `⏳ ${t('反译写入中...', 'Translating & Writing...')}`; try { - const res = await translatePromptText(out, { targetLang: 'en' }); + const res = await translatePromptText(out, { targetLang: 'en', signal: scope.signal }); + if (scope.signal.aborted) return; if (res.ok && res.translated) { const enTags = normalizePromptFormatting(res.translated); - const writeRes = writeToSelectedNode(enTags); - if (writeRes.success) { - showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!')); - sourceTextarea.value = enTags; - } else { - showTranslatorToast(modal, writeRes.message, true); + if (app.graph !== graph || selectedMaterialNode(app) !== node || + graph.getNodeById(node.id) !== node || node.widgets[target.index] !== widget || + widget.value !== previousValue || targetTextarea.value.trim() !== out) { + showTranslatorToast(modal, t('目标或文本已改变,请重新点击翻译写入', 'Target or text changed. Translate and write again.'), true); + return; } + applyNodeMaterialValues(app, node, [{ index: target.index, value: enTags }]); + showTranslatorToast(modal, t('✓ 已反译为英文并成功写入节点!', '✓ Translated to EN & written to node!')); + sourceTextarea.value = enTags; } else { showTranslatorToast(modal, t(`反译失败: ${res.error || '网络错误'}`, `Translation failed`), true); } } catch (err) { + if (scope.signal.aborted) return; showTranslatorToast(modal, t(`反译异常: ${err.message}`, `Error: ${err.message}`), true); } finally { translateAndWriteBtn.disabled = false; @@ -597,6 +571,7 @@ export function openPromptTranslator(owner) { // Translation Handler async function doTranslate() { + if (translateBtn.disabled || scope.signal.aborted) return; const raw = sourceTextarea.value.trim(); if (!raw) { sourceTextarea.focus(); @@ -604,7 +579,8 @@ export function openPromptTranslator(owner) { } const selectedLang = langSelect.value; - const options = {}; + const previousOutput = targetTextarea.value; + const options = { signal: scope.signal }; if (selectedLang !== 'auto') { options.targetLang = selectedLang; } @@ -614,6 +590,8 @@ export function openPromptTranslator(owner) { try { const res = await translatePromptText(raw, options); + if (scope.signal.aborted || sourceTextarea.value.trim() !== raw || + langSelect.value !== selectedLang || targetTextarea.value !== previousOutput) return; if (res.ok && res.translated) { targetTextarea.value = res.translated; updateTagChips(res.translated); @@ -622,6 +600,7 @@ export function openPromptTranslator(owner) { showTranslatorToast(modal, t(`翻译失败: ${res.error || '网络错误'}`, `Failed: ${res.error || 'Network error'}`), true); } } catch (err) { + if (scope.signal.aborted) return; showTranslatorToast(modal, t(`翻译异常: ${err.message}`, `Error: ${err.message}`), true); } finally { translateBtn.disabled = false; @@ -649,20 +628,17 @@ export function openPromptTranslator(owner) { // Close on clicking backdrop (only active in centered modal mode) overlay.onclick = (e) => { if (e.target === overlay && !overlay.classList.contains('is-sidebar')) { - overlay.remove(); - activeTranslatorModal = null; + scope.dispose(); } }; // Close on Escape key const onKeyDown = (e) => { if (e.key === 'Escape') { - overlay.remove(); - activeTranslatorModal = null; - document.removeEventListener('keydown', onKeyDown); + scope.dispose(); } }; - document.addEventListener('keydown', onKeyDown); + scope.listen(document, 'keydown', onKeyDown); document.body.appendChild(overlay); sourceTextarea.focus(); diff --git a/web/modules/ui_prompt_workbench.js b/web/modules/ui_prompt_workbench.js new file mode 100644 index 0000000..0b06f18 --- /dev/null +++ b/web/modules/ui_prompt_workbench.js @@ -0,0 +1,895 @@ +import { app } from '../../../scripts/app.js'; +import { translate as t } from './locales.js'; +import { text, jsonResponse } from './ui_dom.js'; +import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; +import { joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, workbenchDraftToSavedPlan } from './prompt_composition.js'; +import { applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js'; +import { showMaterialApplication } from './ui_material_application.js'; +import { showMaterialSaved } from './material_feedback.js'; +import { translatePromptText } from './translation_service.js'; +import { CATEGORY_META, newDraft, normalizeBlock, syncDraftSynthesizedText } from './prompt_studio_data.js'; +import { showWorkbenchToast } from './ui_prompt_toast.js'; +import { openPromptInspectorModal } from './ui_prompt_inspector.js'; +import { createPromptSourceDeck } from './ui_prompt_source_deck.js'; + +export function createPromptWorkbench(owner, container, scope, options) { + const view = text(container, 'section', '', 'anomalous-prompt-composer anomalous-prompt-workbench is-side-studio'); + + let draft = owner.promptPlanDraft ||= newDraft(); + draft.plan ||= { version: 2, parts: [], positive: '', negative: '' }; + draft.plan.parts ||= []; + + // Migrate existing text if parts are empty + if (!draft.plan.parts.length) { + if (draft.plan.positive?.trim()) { + draft.plan.parts.push(normalizeBlock({ + title: window.anomalous_browser_lang === 'zh' ? '基础正向词' : 'Positive Base', + content: draft.plan.positive, + role: 'positive', + category: categorizePromptSnippet(draft.plan.positive), + }, 0)); + } + if (draft.plan.negative?.trim()) { + draft.plan.parts.push(normalizeBlock({ + title: window.anomalous_browser_lang === 'zh' ? '通用负向过滤' : 'Negative Filter', + content: draft.plan.negative, + role: 'negative', + category: 'base', + }, 1)); + } + } + syncDraftSynthesizedText(draft); + + let activeTab = 'positive'; // 'positive' | 'negative' + let draggedBlockId = null; + + // Local in-memory source prompt cards (merged starters + materials + user custom) + + // 1. Studio Topbar (Streamlined with inline preset name input) + const topbar = text(view, 'header', '', 'anomalous-prompt-topbar'); + const topLeft = text(topbar, 'div', '', 'anomalous-prompt-topbar-left'); + text(topLeft, 'h3', window.anomalous_browser_lang === 'zh' ? '🎛️ 提示词工坊' : '🎛️ Prompt Studio'); + + // Inline Preset Name Input + const nameInput = text(topLeft, 'input', '', 'anomalous-prompt-name-input'); + nameInput.placeholder = window.anomalous_browser_lang === 'zh' ? '方案名称...' : 'Preset name...'; + nameInput.title = window.anomalous_browser_lang === 'zh' ? '输入组合方案名称' : 'Enter preset name'; + nameInput.maxLength = 120; + nameInput.value = draft.name || ''; + nameInput.oninput = () => { draft.name = nameInput.value; }; + + const topActions = text(topbar, 'div', '', 'anomalous-prompt-topbar-actions'); + + // Quick Extract from Selected Canvas Node + const extractQuickBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); + extractQuickBtn.innerHTML = '🎯'; + extractQuickBtn.title = window.anomalous_browser_lang === 'zh' + ? '从画布选中节点一键吸取提示词' + : 'Extract prompt from selected node'; + extractQuickBtn.onclick = () => sourceDeck.extractSelected(true); + + // Dock Side Switch (Left / Right) + if (typeof options.onToggleDockSide === 'function') { + const dockSideBtn = text(topActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); + const isLeft = () => container?.classList.contains('is-dock-left'); + const updateDockBtn = () => { + dockSideBtn.innerHTML = isLeft() + ? `` + : ``; + dockSideBtn.title = isLeft() + ? (window.anomalous_browser_lang === 'zh' ? '停靠至右侧' : 'Dock to Right') + : (window.anomalous_browser_lang === 'zh' ? '停靠至左侧' : 'Dock to Left'); + }; + updateDockBtn(); + dockSideBtn.onclick = () => { + options.onToggleDockSide(); + updateDockBtn(); + }; + } + + // "More" Dropdown Menu (holds Save, New Draft, Tags, Export, Material Sync) + const moreWrap = text(topActions, 'div', '', 'anomalous-prompt-more-wrap'); + moreWrap.style.position = 'relative'; + moreWrap.style.display = 'inline-flex'; + const moreBtn = text(moreWrap, 'button', window.anomalous_browser_lang === 'zh' ? '··· 更多' : '··· More', 'anomalous-btn-ghost anomalous-btn-sm'); + const moreMenu = text(moreWrap, 'div', '', 'anomalous-prompt-more-menu'); + moreMenu.style.display = 'none'; + moreMenu.style.position = 'absolute'; + moreMenu.style.top = 'calc(100% + 4px)'; + moreMenu.style.right = '0'; + moreMenu.style.zIndex = '1000'; + moreMenu.style.minWidth = '150px'; + moreMenu.style.background = 'var(--amb-bg-panel, #1C1E24)'; + moreMenu.style.border = '1px solid var(--amb-border, rgba(255, 255, 255, 0.14))'; + moreMenu.style.borderRadius = 'var(--amb-radius-control, 6px)'; + moreMenu.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.6)'; + moreMenu.style.padding = '4px'; + + const saveBtn = text(moreMenu, 'button', `💾 ${t('promptSavePlan')}`, 'anomalous-btn-ghost anomalous-btn-sm'); + saveBtn.style.width = '100%'; + saveBtn.style.justifyContent = 'flex-start'; + saveBtn.style.border = 'none'; + saveBtn.title = t('promptSavePlan') || '保存方案至素材库'; + + const newBtn = text(moreMenu, 'button', `✨ ${t('promptNewDraft')}`, 'anomalous-btn-ghost anomalous-btn-sm'); + newBtn.style.width = '100%'; + newBtn.style.justifyContent = 'flex-start'; + newBtn.style.border = 'none'; + + const tagsBtn = text(moreMenu, 'button', `🏷️ ${window.anomalous_browser_lang === 'zh' ? '设置标签' : 'Edit Tags'}`, 'anomalous-btn-ghost anomalous-btn-sm'); + tagsBtn.style.width = '100%'; + tagsBtn.style.justifyContent = 'flex-start'; + tagsBtn.style.border = 'none'; + tagsBtn.onclick = () => { + closeMoreMenu(); + const current = (draft.tags || []).join(', '); + const val = prompt(window.anomalous_browser_lang === 'zh' ? '输入方案标签(用逗号或空格分隔):' : 'Enter preset tags (comma separated):', current); + if (val !== null) { + draft.tags = val.split(/[\s,,\n\r]+/).map(v => v.trim()).filter(Boolean); + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `已更新标签 (${draft.tags.length})` : `Tags updated (${draft.tags.length})`); + } + }; + + const exportBtn = text(moreMenu, 'button', `📤 ${t('promptExportPlan')}`, 'anomalous-btn-ghost anomalous-btn-sm'); + exportBtn.style.width = '100%'; + exportBtn.style.justifyContent = 'flex-start'; + exportBtn.style.border = 'none'; + + const syncLibBtn = text(moreMenu, 'button', `📥 ${window.anomalous_browser_lang === 'zh' ? '同步素材库' : 'Sync Library'}`, 'anomalous-btn-ghost anomalous-btn-sm'); + syncLibBtn.style.width = '100%'; + syncLibBtn.style.justifyContent = 'flex-start'; + syncLibBtn.style.border = 'none'; + syncLibBtn.onclick = () => { + closeMoreMenu(); + return sourceDeck.sync(); + }; + + moreBtn.onclick = (e) => { + e.stopPropagation(); + const isHidden = moreMenu.style.display === 'none'; + moreMenu.style.display = isHidden ? 'block' : 'none'; + }; + const closeMoreMenu = () => { moreMenu.style.display = 'none'; }; + scope.listen(document, 'click', closeMoreMenu); + + const closeBtn = text(topActions, 'button', '✕', 'anomalous-btn-ghost anomalous-btn-sm anomalous-prompt-close-btn'); + closeBtn.title = t('close') || '关闭'; + closeBtn.onclick = () => { + options.onClose(); + }; + + // Insertion preference lives with the other secondary actions. + const posWrap = document.createElement('label'); + posWrap.className = 'anomalous-prompt-insert-pos'; + const posSelect = text(posWrap, 'select', '', 'anomalous-prompt-pos-select'); + for (const value of ['after', 'before']) { + text(posSelect, 'option', t(`promptInsert_${value}`)).value = value; + } + posSelect.value = owner.promptInsertPosition || 'after'; + posSelect.setAttribute('aria-label', t('promptInsertPosition')); + moreMenu.appendChild(posWrap); + posSelect.onchange = () => { + owner.promptInsertPosition = posSelect.value; + + }; + + // Two-Column Split Grid (Adapts to vertical dual-zone in sidebar mode) + const workbenchGrid = text(view, 'div', '', 'anomalous-prompt-workbench-grid'); + + // ========================================================================= + // LEFT COLUMN: Ready-to-use Prompt Cards (词卡库) + // ========================================================================= + const sourceDeck = createPromptSourceDeck(workbenchGrid, container, scope, addSourceCardToMixer); + + // ========================================================================= + // RIGHT COLUMN: Assembler & Arranger Stage (顺序拼装调音台) + // ========================================================================= + const rightPanel = text(workbenchGrid, 'section', '', 'anomalous-workbench-right-panel'); + const rightHeader = text(rightPanel, 'div', '', 'anomalous-workbench-col-header'); + + // Role switch tabs (Positive / Negative) + const rightTabs = text(rightHeader, 'div', '', 'anomalous-mixer-tabs'); + + const rightActions = text(rightHeader, 'div', '', 'anomalous-mixer-actions'); + + // Right quick extract: suck into right mixer directly + const rightSuckNodeBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost anomalous-btn-sm'); + rightSuckNodeBtn.innerHTML = `${t('promptReadSelectedNode')}`; + rightSuckNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '直接将画布选中节点的提示词作为积木吸入当前拼装台' : 'Extract node prompt directly into current mixer track'; + + const smartSortBtn = text(rightActions, 'button', '', 'anomalous-mixer-smart-sort-btn'); + smartSortBtn.innerHTML = `${t('promptSortByCategory')}`; + smartSortBtn.title = window.anomalous_browser_lang === 'zh' ? '按 [通用底模 ➔ 风格氛围 ➔ 主体内容 ➔ LoRA/触发词] 自动排序' : 'Auto sort: [Base ➔ Style ➔ Subject ➔ Trigger]'; + + const clearRightBtn = text(rightActions, 'button', '', 'anomalous-btn-ghost'); + clearRightBtn.innerHTML = ''; + clearRightBtn.title = window.anomalous_browser_lang === 'zh' ? '清空当前拼装池' : 'Clear current track'; + + // Blocks Container & Dropzone + const blocksContainer = text(rightPanel, 'div', '', 'anomalous-mixer-blocks-container anomalous-assembly-track'); + + blocksContainer.ondragover = (e) => { + e.preventDefault(); + const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); + if (snapDock && (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock'))) { + snapDock.classList.add('is-drag-over'); + blocksContainer.classList.add('is-drag-over-end'); + } + }; + blocksContainer.ondragleave = (e) => { + if (!blocksContainer.contains(e.relatedTarget)) { + const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); + snapDock?.classList.remove('is-drag-over'); + blocksContainer.classList.remove('is-drag-over-end'); + } + }; + blocksContainer.ondrop = (e) => { + if (e.target === blocksContainer || e.target.closest('.anomalous-assembly-snap-dock')) { + e.preventDefault(); + const snapDock = blocksContainer.querySelector('.anomalous-assembly-snap-dock'); + snapDock?.classList.remove('is-drag-over'); + blocksContainer.classList.remove('is-drag-over-end'); + const jsonStr = e.dataTransfer.getData('application/json'); + if (jsonStr) { + try { + addSourceCardToMixer(JSON.parse(jsonStr)); + return; + } catch (err) {} + } + if (draggedBlockId) { + const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId); + if (fromIndex >= 0) { + const [moved] = draft.plan.parts.splice(fromIndex, 1); + draft.plan.parts.push(moved); + syncDraftSynthesizedText(draft); + renderBlocksList(); + updateOutputPreview(); + } + } + } + }; + + // ------------------------------------------------------------------------- + // Node Direct Write Handler + // ------------------------------------------------------------------------- + const applyPromptToCurrentNode = async (role, promptContent, triggerBtn = null) => { + const node = selectedMaterialNode(app); + if (!node) { + anomalousAlert(window.anomalous_browser_lang === 'zh' + ? '💡 请先在 ComfyUI 画布上点击选中一个提示词节点(例如 CLIPTextEncode)!' + : '💡 Please select a prompt node (e.g. CLIPTextEncode) on the ComfyUI canvas first!'); + return; + } + const targets = promptWidgetTargets(node); + if (!targets.length) { + anomalousAlert(window.anomalous_browser_lang === 'zh' + ? '⚠️ 选中的节点中未找到可写入的提示词文本输入!' + : '⚠️ No writable prompt text widget found in selected node!'); + return; + } + const widgetIndex = targets[0].index; + try { + const value = joinPromptText(node.widgets[widgetIndex].value, promptContent, posSelect.value); + showMaterialApplication(view, applyNodeMaterialValues(app, node, [{ index: widgetIndex, value }]), node); + if (triggerBtn) { + const orig = triggerBtn.textContent; + triggerBtn.textContent = '✅ ' + (window.anomalous_browser_lang === 'zh' ? '已写入' : 'Written'); + setTimeout(() => { if (triggerBtn.isConnected) triggerBtn.textContent = orig; }, 1200); + } else { + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✅ 提示词已成功写入节点!' : '✅ Prompt written to node!'); + } + return true; + } catch (error) { + await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); + } + }; + + // ------------------------------------------------------------------------- + // Floating Action Dock (Minimalist & Slides Up Only When Content Exists) + // ------------------------------------------------------------------------- + const floatingDock = text(rightPanel, 'div', '', 'anomalous-floating-action-dock'); + floatingDock.style.display = 'none'; // Initially zero-height, hidden until content is added + + const dockLeft = text(floatingDock, 'div', '', 'anomalous-floating-dock-left'); + const dockStats = text(dockLeft, 'span', '', 'anomalous-dock-stats-text'); + + const dockRight = text(floatingDock, 'div', '', 'anomalous-floating-dock-right'); + + // 1. Write Node Button + const writeNodeBtn = text(dockRight, 'button', `🚀 ${window.anomalous_browser_lang === 'zh' ? '写入节点' : 'Write Node'}`, 'anomalous-btn-primary anomalous-btn-sm'); + writeNodeBtn.title = window.anomalous_browser_lang === 'zh' ? '将当前合成提示词写入选中的 ComfyUI 节点' : 'Write assembled prompt to selected node'; + writeNodeBtn.onclick = async () => { + syncDraftSynthesizedText(draft); + const compiledText = (draft.plan[activeTab] || '').trim(); + if (!compiledText) return; + await applyPromptToCurrentNode(activeTab, compiledText, writeNodeBtn); + }; + + // 2. Quick Copy Button + const copyOutputBtn = text(dockRight, 'button', '📋', 'anomalous-btn-ghost anomalous-btn-sm'); + copyOutputBtn.title = window.anomalous_browser_lang === 'zh' ? '复制当前合成的提示词' : 'Copy assembled prompt text'; + // 3. Popout Full Inspector Modal Button + let closeInspector = null; + scope.onDispose(() => closeInspector?.()); + const popoutBtn = text(dockRight, 'button', '⛶', 'anomalous-btn-ghost anomalous-btn-sm'); + popoutBtn.title = window.anomalous_browser_lang === 'zh' ? '弹出独立大弹窗全览与精修' : 'Pop out full inspector modal'; + popoutBtn.onclick = () => { + closeInspector?.(); + closeInspector = openPromptInspectorModal(draft, activeTab, async (role, textVal) => { + return applyPromptToCurrentNode(role, textVal); + }, () => { + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + }); + }; + + + // ------------------------------------------------------------------------- + // FEATURE IMPLEMENTATIONS: Node Extraction & Material Sync + // ------------------------------------------------------------------------- + + rightSuckNodeBtn.onclick = () => sourceDeck.extractSelected(true); + + function updateRightTabsUI() { + rightTabs.replaceChildren(); + const posCount = draft.plan.parts.filter(p => p.role === 'positive').length; + const negCount = draft.plan.parts.filter(p => p.role === 'negative').length; + + const tabs = [ + { key: 'positive', label: `${window.anomalous_browser_lang === 'zh' ? '正向拼装台' : 'Positive'} (${posCount})` }, + { key: 'negative', label: `${window.anomalous_browser_lang === 'zh' ? '负向拼装台' : 'Negative'} (${negCount})` }, + ]; + + for (const tab of tabs) { + const btn = text(rightTabs, 'button', tab.label, `anomalous-mixer-tab-btn${activeTab === tab.key ? ' is-active' : ''}`); + btn.onclick = () => { + activeTab = tab.key; + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + + }; + } + } + + function addSourceCardToMixer(cardData, targetIndex = null) { + const cardRole = cardData.role || 'positive'; + if (activeTab !== cardRole) { + activeTab = cardRole; + updateRightTabsUI(); + } + + const newBlock = normalizeBlock({ + title: cardData.title, + content: cardData.content, + role: cardRole, + category: cardData.category, + enabled: true, + }, draft.plan.parts.length); + newBlock._justAdded = true; + + if (targetIndex !== null && targetIndex >= 0) { + draft.plan.parts.splice(targetIndex, 0, newBlock); + } else { + draft.plan.parts.push(newBlock); + } + + syncDraftSynthesizedText(draft); + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + + + const count = draft.plan.parts.filter(p => p.role === activeTab).length; + showWorkbenchToast(window.anomalous_browser_lang === 'zh' + ? `已加入【${activeTab === 'positive' ? '正向' : '负向'}】拼装台(共 ${count} 块)` + : `Added to ${activeTab} track (${count} blocks)`); + } + + function renderBlocksList() { + blocksContainer.replaceChildren(); + const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab); + + if (!currentRoleParts.length) { + const dropzoneNotice = text(blocksContainer, 'div', '', 'anomalous-assembly-dropzone'); + dropzoneNotice.innerHTML = ` +
+ + + +
+
${window.anomalous_browser_lang === 'zh' ? '点击词卡或拖拽到这里拼装' : 'Click cards or drag here to assemble'}
+
${window.anomalous_browser_lang === 'zh' ? '支持自由拖拽调换次序,点击【智能排序】一键理顺' : 'Reorder freely anytime, or click Smart Sort.'}
+ `; + // Accept drops on empty dropzone + setupDropzoneListeners(dropzoneNotice); + return; + } + + currentRoleParts.forEach((block, index) => { + const isJustAdded = !!block._justAdded; + if (isJustAdded) delete block._justAdded; + + const blockEl = text(blocksContainer, 'article', '', `anomalous-mixer-block${!block.enabled ? ' is-bypassed' : ''} is-role-${block.role}${isJustAdded ? ' is-just-added' : ''}`); + blockEl.setAttribute('draggable', 'true'); + blockEl.dataset ||= {}; + blockEl.dataset.blockId = block.id; + + if (isJustAdded) { + requestAnimationFrame(() => { + blockEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + } + + // Reorder Drag Listeners + blockEl.ondragstart = (e) => { + draggedBlockId = block.id; + blockEl.classList.add('is-dragging'); + e.dataTransfer.setData('text/plain', block.content); + e.dataTransfer.effectAllowed = 'move'; + }; + + blockEl.ondragend = () => { + draggedBlockId = null; + blockEl.classList.remove('is-dragging'); + blocksContainer.querySelectorAll('.anomalous-mixer-block').forEach(el => { + el.classList.remove('is-drag-over-top', 'is-drag-over-bottom'); + }); + }; + + blockEl.ondragover = (e) => { + e.preventDefault(); + const rect = blockEl.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + if (e.clientY < mid) { + blockEl.classList.add('is-drag-over-top'); + blockEl.classList.remove('is-drag-over-bottom'); + } else { + blockEl.classList.add('is-drag-over-bottom'); + blockEl.classList.remove('is-drag-over-top'); + } + }; + + blockEl.ondragleave = () => { + blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom'); + }; + + blockEl.ondrop = (e) => { + e.preventDefault(); + blockEl.classList.remove('is-drag-over-top', 'is-drag-over-bottom'); + + // Case 1: Drop from Left Source Card + const jsonStr = e.dataTransfer.getData('application/json'); + if (jsonStr) { + try { + const parsed = JSON.parse(jsonStr); + const toIndex = draft.plan.parts.findIndex(p => p.id === block.id); + const insertAfter = e.clientY >= (blockEl.getBoundingClientRect().top + blockEl.offsetHeight / 2); + addSourceCardToMixer(parsed, insertAfter ? toIndex + 1 : toIndex); + return; + } catch (err) {} + } + + // Case 2: Reorder inside Right Track + if (!draggedBlockId || draggedBlockId === block.id) return; + const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId); + if (fromIndex < 0) return; + + const [moved] = draft.plan.parts.splice(fromIndex, 1); + const currentTargetIndex = draft.plan.parts.findIndex(p => p.id === block.id); + if (currentTargetIndex < 0) { + draft.plan.parts.push(moved); + } else { + const rect = blockEl.getBoundingClientRect(); + const insertAfter = e.clientY >= (rect.top + rect.height / 2); + const finalIndex = insertAfter ? currentTargetIndex + 1 : currentTargetIndex; + draft.plan.parts.splice(finalIndex, 0, moved); + } + + syncDraftSynthesizedText(draft); + renderBlocksList(); + updateOutputPreview(); + }; + + // Header + const blockHeader = text(blockEl, 'div', '', 'anomalous-mixer-block-header'); + const headerLeft = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-left'); + + const dragHandle = text(headerLeft, 'span', '⠿', 'anomalous-mixer-drag-handle'); + dragHandle.title = window.anomalous_browser_lang === 'zh' ? '抓取按住上下拖拽排序' : 'Drag to reorder'; + + // A/B Bypass Checkbox + const toggleWrap = text(headerLeft, 'label', '', 'anomalous-mixer-block-toggle'); + const checkbox = text(toggleWrap, 'input', ''); + checkbox.type = 'checkbox'; + checkbox.checked = !!block.enabled; + checkbox.title = window.anomalous_browser_lang === 'zh' ? '勾选参与拼装,取消勾选即旁路跳过(做A/B对比)' : 'Include or bypass in assembly'; + checkbox.onchange = () => { + block.enabled = checkbox.checked; + blockEl.classList.toggle('is-bypassed', !block.enabled); + syncDraftSynthesizedText(draft); + updateOutputPreview(); + }; + + // Category Badge + const catMeta = CATEGORY_META[block.category] || CATEGORY_META.subject; + const catBadge = text(headerLeft, 'span', window.anomalous_browser_lang === 'zh' ? catMeta.zh : catMeta.en, 'anomalous-mixer-cat-badge'); + catBadge.style.color = catMeta.color; + catBadge.style.backgroundColor = catMeta.bg; + catBadge.style.borderColor = catMeta.border; + catBadge.title = window.anomalous_browser_lang === 'zh' ? '点击切换词性分类' : 'Cycle category'; + catBadge.onclick = () => { + const cats = ['base', 'style', 'subject', 'trigger']; + const nextIdx = (cats.indexOf(block.category) + 1) % cats.length; + block.category = cats[nextIdx]; + renderBlocksList(); + }; + + // Editable title + const titleInput = text(headerLeft, 'input', '', 'anomalous-mixer-block-title-input'); + titleInput.value = block.title || ''; + titleInput.oninput = () => { block.title = titleInput.value; }; + + // Weight Adjustment Pill with Mouse Wheel & Click + let currentWeight = block.weight !== undefined ? block.weight : 1.0; + const weightPill = text(headerLeft, 'span', '', 'anomalous-block-weight-pill'); + weightPill.title = window.anomalous_browser_lang === 'zh' + ? '滚轮上下滑动调节权重,或点击加减' + : 'Scroll wheel or click +/- to adjust weight'; + + const decBtn = text(weightPill, 'button', '-', 'anomalous-block-weight-btn'); + const weightVal = text(weightPill, 'span', currentWeight.toFixed(2)); + const incBtn = text(weightPill, 'button', '+', 'anomalous-block-weight-btn'); + + const applyBlockWeight = (newW) => { + newW = Math.round(Math.max(0.1, Math.min(3.0, newW)) * 100) / 100; + currentWeight = newW; + block.weight = newW; + weightVal.textContent = newW.toFixed(2); + let clean = block.content.replace(/^\((.+):[0-9.]+\)$/, '$1').replace(/^\((.+)\)$/, '$1').trim(); + if (newW !== 1.0) { + block.content = `(${clean}:${newW.toFixed(2)})`; + } else { + block.content = clean; + } + const txtArea = blockEl.querySelector('.anomalous-mixer-block-textarea'); + if (txtArea) txtArea.value = block.content; + syncDraftSynthesizedText(draft); + updateOutputPreview(); + }; + + decBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight - 0.05); }; + incBtn.onclick = (e) => { e.stopPropagation(); applyBlockWeight(currentWeight + 0.05); }; + + weightPill.onwheel = (e) => { + e.preventDefault(); + e.stopPropagation(); + const delta = e.deltaY < 0 ? 0.05 : -0.05; + applyBlockWeight(currentWeight + delta); + }; + + // Right Action micro buttons + const headerRight = text(blockHeader, 'div', '', 'anomalous-mixer-block-header-right'); + + const transBtn = text(headerRight, 'button', '🌐', 'anomalous-mixer-block-btn is-translate'); + transBtn.title = window.anomalous_browser_lang === 'zh' ? '一键翻译此块提示词' : 'Translate this block'; + transBtn.onclick = async () => { + const raw = block.content || ''; + if (!raw.trim()) return; + const orig = transBtn.textContent; + transBtn.textContent = '⏳'; + try { + const res = await translatePromptText(raw, { signal: scope.signal }); + if (scope.signal.aborted || !draft.plan.parts.includes(block) || block.content !== raw) return; + if (res.ok && res.translated) { + block.content = res.translated; + textarea.value = res.translated; + syncDraftSynthesizedText(draft); + updateOutputPreview(); + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `✓ 词块已翻译为${res.targetLang === 'en' ? '英文' : '中文'}` : `✓ Block translated to ${res.targetLang}`); + } else { + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? `翻译失败: ${res.error || '网络错误'}` : `Translation failed: ${res.error || 'Network error'}`); + } + } finally { + transBtn.textContent = orig; + } + }; + + const upBtn = text(headerRight, 'button', '▲', 'anomalous-mixer-block-btn'); + upBtn.title = window.anomalous_browser_lang === 'zh' ? '上移' : 'Move up'; + upBtn.disabled = index === 0; + upBtn.onclick = () => { + const realIdx = draft.plan.parts.findIndex(p => p.id === block.id); + if (realIdx < 0) return; + const myRole = block.role || activeTab; + let prevIdx = -1; + for (let i = realIdx - 1; i >= 0; i--) { + if ((draft.plan.parts[i].role || 'positive') === myRole) { + prevIdx = i; + break; + } + } + if (prevIdx >= 0) { + const temp = draft.plan.parts[realIdx]; + draft.plan.parts[realIdx] = draft.plan.parts[prevIdx]; + draft.plan.parts[prevIdx] = temp; + syncDraftSynthesizedText(draft); + renderBlocksList(); + updateOutputPreview(); + } + }; + + const downBtn = text(headerRight, 'button', '▼', 'anomalous-mixer-block-btn'); + downBtn.title = window.anomalous_browser_lang === 'zh' ? '下移' : 'Move down'; + downBtn.disabled = index === currentRoleParts.length - 1; + downBtn.onclick = () => { + const realIdx = draft.plan.parts.findIndex(p => p.id === block.id); + if (realIdx < 0) return; + const myRole = block.role || activeTab; + let nextIdx = -1; + for (let i = realIdx + 1; i < draft.plan.parts.length; i++) { + if ((draft.plan.parts[i].role || 'positive') === myRole) { + nextIdx = i; + break; + } + } + if (nextIdx >= 0) { + const temp = draft.plan.parts[realIdx]; + draft.plan.parts[realIdx] = draft.plan.parts[nextIdx]; + draft.plan.parts[nextIdx] = temp; + syncDraftSynthesizedText(draft); + renderBlocksList(); + updateOutputPreview(); + } + }; + + const delBtn = text(headerRight, 'button', '✕', 'anomalous-mixer-block-btn is-delete'); + delBtn.title = window.anomalous_browser_lang === 'zh' ? '移除此块' : 'Remove block'; + delBtn.onclick = () => { + const realIdx = draft.plan.parts.findIndex(p => p.id === block.id); + if (realIdx >= 0) { + draft.plan.parts.splice(realIdx, 1); + syncDraftSynthesizedText(draft); + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + } + }; + + // Body + const blockBody = text(blockEl, 'div', '', 'anomalous-mixer-block-body'); + const textarea = text(blockBody, 'textarea', '', 'anomalous-mixer-block-textarea'); + textarea.value = block.content || ''; + textarea.rows = 1; + textarea.oninput = () => { + block.content = textarea.value; + syncDraftSynthesizedText(draft); + updateOutputPreview(); + }; + }); + + // Interactive Auto-snap Dock at end of track + const snapDock = text(blocksContainer, 'div', '', 'anomalous-assembly-snap-dock'); + snapDock.innerHTML = ` +
+ + + + ${window.anomalous_browser_lang === 'zh' ? '拖拽卡片至此自动贴合至末尾' : 'Drop card here to snap to bottom'} +
+ `; + setupDropzoneListeners(snapDock); + snapDock.onclick = () => { + leftSearch?.focus?.(); + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '点击左侧词卡即可直接加入此处' : 'Click any card on left to add here'); + }; + } + + function setupDropzoneListeners(el) { + el.ondragover = (e) => { + e.preventDefault(); + el.classList.add('is-drag-over'); + }; + el.ondragleave = () => { + el.classList.remove('is-drag-over'); + }; + el.ondrop = (e) => { + e.preventDefault(); + el.classList.remove('is-drag-over'); + const jsonStr = e.dataTransfer.getData('application/json'); + if (jsonStr) { + try { + addSourceCardToMixer(JSON.parse(jsonStr)); + return; + } catch (err) {} + } + if (draggedBlockId) { + const fromIndex = draft.plan.parts.findIndex(p => p.id === draggedBlockId); + if (fromIndex >= 0) { + const [moved] = draft.plan.parts.splice(fromIndex, 1); + draft.plan.parts.push(moved); + syncDraftSynthesizedText(draft); + renderBlocksList(); + updateOutputPreview(); + } + } + }; + } + + function updateOutputPreview() { + syncDraftSynthesizedText(draft); + const compiledText = (draft.plan[activeTab] || '').trim(); + const currentRoleParts = draft.plan.parts.filter(p => p.role === activeTab); + + if (!currentRoleParts.length && !compiledText) { + floatingDock.style.display = 'none'; + } else { + floatingDock.style.display = 'flex'; + const words = compiledText.split(/[,,\s\n]+/).filter(Boolean).length; + dockStats.innerHTML = `${activeTab === 'negative' ? '🌙' : '🔆'} ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块' : 'blocks'} · ~${words} ${window.anomalous_browser_lang === 'zh' ? '词' : 'words'}`; + dockStats.title = activeTab === 'negative' + ? `${window.anomalous_browser_lang === 'zh' ? '负向' : 'Negative'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}` + : `${window.anomalous_browser_lang === 'zh' ? '正向' : 'Positive'}: ${compiledText.length} ${window.anomalous_browser_lang === 'zh' ? '字符' : 'chars'}, ${currentRoleParts.length} ${window.anomalous_browser_lang === 'zh' ? '块积木' : 'blocks'}`; + } + } + + // Smart Sort + smartSortBtn.onclick = () => { + if (!draft.plan.parts.length) return; + const roleParts = draft.plan.parts.filter(p => p.role === activeTab); + const otherParts = draft.plan.parts.filter(p => p.role !== activeTab); + const sorted = smartSortPromptBlocks(roleParts, activeTab); + draft.plan.parts = [...sorted, ...otherParts]; + + syncDraftSynthesizedText(draft); + renderBlocksList(); + updateOutputPreview(); + + smartSortBtn.classList.add('is-animating'); + setTimeout(() => smartSortBtn.classList.remove('is-animating'), 500); + }; + + // Clear track + clearRightBtn.onclick = async () => { + const msg = window.anomalous_browser_lang === 'zh' + ? `确定清空当前【${activeTab === 'positive' ? '正向' : '负向'}】拼装池吗?` + : `Clear ${activeTab} mixer track?`; + if (await anomalousConfirm(msg) && !scope.signal.aborted) { + draft.plan.parts = draft.plan.parts.filter(p => p.role !== activeTab); + syncDraftSynthesizedText(draft); + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + } + }; + + // Copy preview text + copyOutputBtn.onclick = async (e) => { + e?.stopPropagation?.(); + const textToCopy = (draft.plan[activeTab] || '').trim(); + if (!textToCopy) return; + try { + await navigator.clipboard.writeText(textToCopy); + copyOutputBtn.textContent = '✅'; + setTimeout(() => { if (copyOutputBtn.isConnected) copyOutputBtn.textContent = `📋`; }, 1500); + } catch (err) { + await anomalousAlert(t('materialCopyError')); + } + }; + + // ------------------------------------------------------------------------- + // Target Node Direct Injection Toolbar (Compact sticky bar integrated into Action Dock) + // ------------------------------------------------------------------------- + // Save Plan to Material Library + saveBtn.onclick = async () => { + if (!draft.name?.trim()) { + nameInput.focus(); + nameInput.classList.add('is-invalid'); + setTimeout(() => nameInput.classList.remove('is-invalid'), 1200); + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!'); + return; + } + saveBtn.disabled = true; + syncDraftSynthesizedText(draft); + const saved = workbenchDraftToSavedPlan(draft); + const body = { + name: draft.name.trim(), + tags: draft.tags || [], + plan: saved.plan, + }; + + const send = () => fetch('/anomalous/save_prompt_plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + try { + let response = await send(); + if (response.status === 409) { + const duplicate = await response.json(); + if (!await anomalousConfirm(t('materialDuplicateConfirm', { name: duplicate.name })) || scope.signal.aborted) return; + body.allow_duplicate = true; + response = await send(); + } + const payload = await jsonResponse(response, 'prompt save failed'); + if (payload.status !== 'success') throw new Error('prompt save failed'); + if (!scope.signal.aborted) showMaterialSaved(owner, payload.material); + } catch (error) { + if (!scope.signal.aborted) await anomalousAlert(t('materialSaveError')); + } finally { + saveBtn.disabled = false; + } + }; + + // Export Plan JSON + exportBtn.onclick = () => { + if (!draft.name?.trim()) { + nameInput.focus(); + nameInput.classList.add('is-invalid'); + setTimeout(() => nameInput.classList.remove('is-invalid'), 1200); + showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '请先在顶栏输入方案名称!' : 'Please enter preset name first!'); + return; + } + syncDraftSynthesizedText(draft); + const saved = workbenchDraftToSavedPlan(draft); + const exportData = { + format: 'anomalous-prompt-plan-v1', + version: 2, + name: draft.name, + tags: draft.tags, + plan: saved.plan, + }; + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + if (blob.size > 2 * 1024 * 1024 || draft.tags.length > 20 || draft.tags.some(tag => tag.length > 60)) { + return anomalousAlert(t('promptExportError')); + } + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${draft.name || 'prompt-mixer-preset'}.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + + // New Draft + newBtn.onclick = async () => { + if (await anomalousConfirm(t('promptReplaceDraft')) && !scope.signal.aborted) { + owner.promptPlanDraft = draft = newDraft(); + draft.plan.parts.push(normalizeBlock({ + title: window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive Prompt', + content: '', + role: 'positive', + category: 'base', + }, 0)); + syncDraftSynthesizedText(draft); + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + nameInput.value = ''; + draft.tags = []; + } + }; + + // Initial render execution + sourceDeck.refresh(); + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + + + const control = { + updateAll: () => { + updateRightTabsUI(); + renderBlocksList(); + updateOutputPreview(); + sourceDeck.refresh(); + }, + addBlock: (blockData) => { + addSourceCardToMixer(blockData); + }, + }; + + return control; +} diff --git a/web/modules/ui_recipes.js b/web/modules/ui_recipes.js index 91d0b69..0032b7e 100644 --- a/web/modules/ui_recipes.js +++ b/web/modules/ui_recipes.js @@ -887,9 +887,7 @@ function buildRecipeStudioTopbar(owner) { } export async function showRecipes() { - this.closePromptImportDrawer?.(); if (this.materialContainer) this.materialContainer.style.display = 'none'; - if (this.promptStudioContainer) this.promptStudioContainer.style.display = 'none'; if (this.notebookContainer) this.notebookContainer.style.display = 'none'; if (this.nbPanel) this.nbPanel.style.display = 'flex'; diff --git a/web/styles.css b/web/styles.css index eb7f39d..48b8a01 100644 --- a/web/styles.css +++ b/web/styles.css @@ -12305,172 +12305,6 @@ button:hover .anomalous-btn-icon { z-index: 0 !important; } -/* Sidebar / Docked Mode Dedicated Adapters for Prompt Studio */ -#anomalous-container.anomalous-docked .anomalous-prompt-workbench { - padding: 6px 8px 8px; - gap: 6px; -} - -#anomalous-container.anomalous-docked .anomalous-prompt-topbar { - padding: 4px 6px 6px; - gap: 6px; - flex-wrap: nowrap; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -#anomalous-container.anomalous-docked .anomalous-prompt-topbar-left { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; -} - -#anomalous-container.anomalous-docked .anomalous-prompt-topbar-left h3 { - font-size: 0.9rem; - margin: 0; - white-space: nowrap; -} - -#anomalous-container.anomalous-docked .anomalous-prompt-name-input { - flex: 1 1 100px; - min-width: 60px; - max-width: 200px; - height: 26px; - padding: 2px 6px; - font-size: 0.76rem; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - color: #f1f5f9; -} - -#anomalous-container.anomalous-docked .anomalous-prompt-name-input:focus { - border-color: #38bdf8; - background: rgba(0, 0, 0, 0.5); -} - -#anomalous-container.anomalous-docked .anomalous-prompt-name-input.is-invalid { - border-color: #ef4444; - box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); -} - -#anomalous-container.anomalous-docked .anomalous-prompt-topbar-actions { - gap: 4px; - flex-shrink: 0; -} - -#anomalous-container.anomalous-docked .anomalous-prompt-workbench-grid { - display: flex !important; - flex-direction: column !important; - min-height: 0 !important; - overflow: hidden !important; - gap: 6px !important; - padding: 4px 6px 6px !important; -} - -#anomalous-container.anomalous-docked .anomalous-workbench-right-panel { - order: 1 !important; - flex: 1 1 50% !important; - min-height: 150px !important; - max-height: 54% !important; - display: flex !important; - flex-direction: column !important; - overflow: hidden !important; - padding: 8px 8px 4px !important; - gap: 6px !important; -} - -#anomalous-container.anomalous-docked .anomalous-workbench-left-panel { - order: 2 !important; - flex: 1 1 50% !important; - min-height: 150px !important; - max-height: 50% !important; - display: flex !important; - flex-direction: column !important; - overflow: hidden !important; - padding: 8px 8px 6px !important; - gap: 6px !important; -} - -#anomalous-container.anomalous-docked .anomalous-source-cards-list { - display: grid !important; - grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)) !important; - gap: 6px !important; - overflow-y: auto !important; - align-content: start !important; - padding-right: 2px !important; -} - -#anomalous-container.anomalous-docked .anomalous-source-prompt-card { - padding: 5px 7px !important; - gap: 3px !important; - border-radius: 5px !important; - cursor: pointer !important; -} - -#anomalous-container.anomalous-docked .anomalous-source-card-snippet { - -webkit-line-clamp: 1 !important; - font-size: 0.68rem !important; - padding: 2px 4px !important; -} - -#anomalous-container.anomalous-docked .anomalous-mixer-blocks-container { - gap: 5px !important; - padding: 2px !important; -} - -#anomalous-container.anomalous-docked .anomalous-mixer-block { - border-radius: 5px !important; -} - -#anomalous-container.anomalous-docked .anomalous-mixer-block-header { - padding: 3px 6px !important; - gap: 4px !important; -} - -#anomalous-container.anomalous-docked .anomalous-mixer-block-body { - padding: 2px 6px 3px !important; -} - -#anomalous-container.anomalous-docked .anomalous-mixer-block-textarea { - min-height: 24px !important; - height: 24px !important; - font-size: 0.72rem !important; - padding: 2px 4px !important; -} - -#anomalous-container.anomalous-docked .anomalous-mixer-deck-output.anomalous-prompt-action-dock { - padding: 3px 6px !important; - gap: 3px !important; -} - -#anomalous-container.anomalous-docked .anomalous-mixer-output-summary { - min-height: 26px !important; - gap: 4px !important; -} - -#anomalous-container.anomalous-docked .anomalous-mixer-output-stats { - font-size: 0.68rem !important; -} - -#anomalous-container.anomalous-docked .anomalous-prompt-target-actions { - gap: 4px !important; -} - -#anomalous-container.anomalous-docked .anomalous-prompt-target-actions select { - height: 22px !important; - padding: 1px 4px !important; - font-size: 0.68rem !important; -} - -#anomalous-container.anomalous-docked .anomalous-assembly-snap-dock { - min-height: 32px !important; - padding: 4px 8px !important; - font-size: 0.68rem !important; - margin-top: 3px !important; -} - /* ========================================================================== Recipe Studio Dedicated Container & Topbar Close Button ========================================================================== */ @@ -12969,10 +12803,10 @@ button:hover .anomalous-btn-icon { flex-direction: column; } -/* Left Dock Mode (Default): Left Column = Cards (190px), Right Column = Assembly Track (faces canvas on right) */ -.anomalous-prompt-studio-drawer.is-dock-left .anomalous-prompt-workbench-grid { +/* Shared drawer geometry. Dock direction only changes column order and separators. */ +.anomalous-prompt-studio-drawer.is-dock-left .anomalous-prompt-workbench-grid, +.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-prompt-workbench-grid { display: grid !important; - grid-template-columns: 190px minmax(0, 1fr) !important; flex-direction: row !important; gap: 0 !important; padding: 0 !important; @@ -12980,13 +12814,20 @@ button:hover .anomalous-btn-icon { min-height: 0; } -.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-left-panel { - order: 1 !important; +.anomalous-prompt-studio-drawer.is-dock-left .anomalous-prompt-workbench-grid { + grid-template-columns: 190px minmax(0, 1fr) !important; +} + +.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-prompt-workbench-grid { + grid-template-columns: minmax(0, 1fr) 190px !important; +} + +.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-left-panel, +.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-left-panel { flex: none !important; width: 190px !important; max-height: 100% !important; padding: 8px 10px !important; - border-right: 1px solid rgba(255, 255, 255, 0.08) !important; border-left: none !important; background: rgba(18, 20, 24, 0.7) !important; box-sizing: border-box; @@ -12994,55 +12835,36 @@ button:hover .anomalous-btn-icon { flex-direction: column; } +.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-left-panel { + order: 1 !important; + border-right: 1px solid rgba(255, 255, 255, 0.08) !important; +} + +.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-left-panel { + order: 2 !important; + border-right: none !important; +} + +.anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-right-panel, +.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-right-panel { + flex: 1 !important; + max-height: 100% !important; + padding: 8px 12px 52px !important; + position: relative; + box-sizing: border-box; + background: rgba(14, 15, 18, 0.5) !important; + display: flex; + flex-direction: column; +} + .anomalous-prompt-studio-drawer.is-dock-left .anomalous-workbench-right-panel { order: 2 !important; - flex: 1 !important; - max-height: 100% !important; - padding: 8px 12px 52px !important; /* reserve bottom space for floating dock */ - position: relative; - box-sizing: border-box; - background: rgba(14, 15, 18, 0.5) !important; - display: flex; - flex-direction: column; -} - -/* Right Dock Mode: Right Column = Cards (190px), Left Column = Assembly Track (faces canvas on left) */ -.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-prompt-workbench-grid { - display: grid !important; - grid-template-columns: minmax(0, 1fr) 190px !important; - flex-direction: row !important; - gap: 0 !important; - padding: 0 !important; - flex: 1; - min-height: 0; } .anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-right-panel { order: 1 !important; - flex: 1 !important; - max-height: 100% !important; - padding: 8px 12px 52px !important; - position: relative; - box-sizing: border-box; border-right: 1px solid rgba(255, 255, 255, 0.08) !important; border-left: none !important; - background: rgba(14, 15, 18, 0.5) !important; - display: flex; - flex-direction: column; -} - -.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-workbench-left-panel { - order: 2 !important; - flex: none !important; - width: 190px !important; - max-height: 100% !important; - padding: 8px 10px !important; - border-left: none !important; - border-right: none !important; - background: rgba(18, 20, 24, 0.7) !important; - box-sizing: border-box; - display: flex; - flex-direction: column; } /* Column Headers & Action Buttons */