A card on the canvas (right-click > Job sheet) takes many prompts, a line each, the runs of each, a rest between pictures and the same seeds if wanted; with no prompt it runs the canvas as it is. The page runs it from copies of the workflow kept at the start, as saves run, so the canvas stays as it is; pause, go on and stop from any page, a failed or interrupted run pauses it, a reload goes on and another page takes over a sheet whose page is gone. run_workflow(sheet=...) lets an AI hand over prompts or settings by name; the sheet is that call's run step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
458 lines
19 KiB
JavaScript
458 lines
19 KiB
JavaScript
import { app } from '../../../scripts/app.js';
|
|
import { AnomalousBrowser } from './browser.js';
|
|
import { watchMcpActions } from './mcp_bridge.js';
|
|
import { canvasMenuItems, nodeMenuItems } from './canvas_menu.js';
|
|
import { watchSaves } from './ui_snapshot_bar.js';
|
|
import { watchJobSheetCard } from './ui_job_sheet.js';
|
|
import { openQuickSearch } from './ui_quick_search.js';
|
|
import {
|
|
clampFloatingTriggerPosition,
|
|
clearSavedTriggerPosition,
|
|
DEFAULT_MIN_SAFE_X,
|
|
loadSavedTriggerPosition,
|
|
normalizeEntryMode,
|
|
normalizeFloatingTriggerSize,
|
|
normalizeFloatingTriggerStyle,
|
|
removeLegacyTriggerPosition,
|
|
saveTriggerPosition
|
|
} from './entry_controls.js';
|
|
import {
|
|
createShortcutSettingControl,
|
|
DEFAULT_BROWSER_SHORTCUT,
|
|
} from './shortcut_controls.js';
|
|
|
|
export const ENTRY_MODE_SETTING_ID = 'Anomalous.ModelBrowser.EntryMode';
|
|
export const SHORTCUT_SETTING_ID = 'Anomalous.ModelBrowser.Shortcut';
|
|
export const FLOATING_TRIGGER_SIZE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerSize';
|
|
export const FLOATING_TRIGGER_STYLE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerStyle';
|
|
export const OPEN_BROWSER_COMMAND_ID = 'Anomalous.ModelBrowser.Open';
|
|
// The canvas's quick search bar (ui_quick_search.js); Alt+B unless rebound in ComfyUI's keybindings.
|
|
export const QUICK_SEARCH_COMMAND_ID = 'Anomalous.ModelBrowser.QuickSearch';
|
|
export const RESET_TRIGGER_POSITION_COMMAND_ID = 'Anomalous.ModelBrowser.ResetFloatingTriggerPosition';
|
|
|
|
export function createBrowserEntry({ translate, getCurrentLanguage }) {
|
|
let entryMode = 'floating';
|
|
let floatingTriggerSize = 'medium';
|
|
let floatingTriggerStyle = 'icon';
|
|
let browserInstance = null;
|
|
let triggerButton = null;
|
|
let triggerBoundsUpdater = null;
|
|
const t = translate;
|
|
|
|
function getSettingTranslationPatches() {
|
|
const category = t('mainInterfaceCategory');
|
|
return {
|
|
[FLOATING_TRIGGER_STYLE_SETTING_ID]: {
|
|
name: t('mainFloatingTriggerStyleSetting'),
|
|
category: ['Anomalous Model Browser', category, 'floating-trigger-style'],
|
|
tooltip: t('mainFloatingTriggerStyleTooltip'),
|
|
options: [
|
|
{ value: 'icon', text: t('mainFloatingTriggerStyleIcon') },
|
|
{ value: 'pill', text: t('mainFloatingTriggerStylePill') }
|
|
]
|
|
},
|
|
[FLOATING_TRIGGER_SIZE_SETTING_ID]: {
|
|
name: t('mainFloatingTriggerSizeSetting'),
|
|
category: ['Anomalous Model Browser', category, 'floating-trigger-size'],
|
|
tooltip: t('mainFloatingTriggerSizeTooltip'),
|
|
options: [
|
|
{ value: 'small', text: t('mainFloatingTriggerSizeSmall') },
|
|
{ value: 'medium', text: t('mainFloatingTriggerSizeMedium') },
|
|
{ value: 'large', text: t('mainFloatingTriggerSizeLarge') }
|
|
]
|
|
},
|
|
[SHORTCUT_SETTING_ID]: {
|
|
name: t('mainShortcutSetting'),
|
|
category: ['Anomalous Model Browser', category, 'shortcut'],
|
|
tooltip: t('mainShortcutTooltip')
|
|
},
|
|
[ENTRY_MODE_SETTING_ID]: {
|
|
name: t('mainEntryModeSetting'),
|
|
category: ['Anomalous Model Browser', category, 'entry-mode'],
|
|
tooltip: t('mainEntryModeTooltip'),
|
|
options: [
|
|
{ value: 'floating', text: t('mainEntryModeFloating') },
|
|
{ value: 'topbar', text: t('mainEntryModeTopbar') },
|
|
{ value: 'menu', text: t('mainEntryModeMenu') }
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
function refreshRegisteredSettings() {
|
|
const settingsApi = app.extensionManager?.setting;
|
|
const registry = settingsApi?.settings?.value || settingsApi?.settings;
|
|
if (!registry || typeof registry !== 'object') return;
|
|
for (const [id, patch] of Object.entries(getSettingTranslationPatches())) {
|
|
if (registry[id]) registry[id] = { ...registry[id], ...patch };
|
|
}
|
|
}
|
|
|
|
function syncVisibility() {
|
|
document.documentElement.classList.toggle('anomalous-topbar-entry-enabled', entryMode === 'topbar');
|
|
document.documentElement.classList.toggle('anomalous-floating-entry-enabled', entryMode === 'floating');
|
|
const browserIsOpen = browserInstance?.modal?.classList.contains('visible') === true;
|
|
triggerButton?.classList.toggle('anomalous-trigger-hidden', entryMode !== 'floating' || browserIsOpen);
|
|
}
|
|
|
|
function applyPresentation() {
|
|
if (!triggerButton) return;
|
|
triggerButton.dataset.size = floatingTriggerSize;
|
|
triggerButton.dataset.style = floatingTriggerStyle;
|
|
const icon = document.createElement('span');
|
|
icon.className = 'anomalous-trigger-icon';
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;display:block;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
|
|
const label = document.createElement('span');
|
|
label.className = 'anomalous-trigger-label';
|
|
label.textContent = t('mainFloatingTriggerLabel');
|
|
triggerButton.replaceChildren(icon, label);
|
|
triggerButton.title = t('mainOpenTitle');
|
|
triggerButton.setAttribute('aria-label', t('mainOpenTitle'));
|
|
if (loadSavedTriggerPosition()) {
|
|
requestAnimationFrame(() => triggerBoundsUpdater?.());
|
|
}
|
|
}
|
|
|
|
function resetPosition() {
|
|
clearSavedTriggerPosition();
|
|
if (!triggerButton) return;
|
|
triggerButton.style.left = '';
|
|
triggerButton.style.top = '';
|
|
triggerButton.style.right = '';
|
|
triggerButton.style.bottom = '';
|
|
}
|
|
|
|
function ensureBrowser() {
|
|
if (browserInstance) return browserInstance;
|
|
try {
|
|
browserInstance = new AnomalousBrowser();
|
|
browserInstance.entryMode = entryMode;
|
|
browserInstance.triggerButton = triggerButton;
|
|
window.anomalousBrowserInstance = browserInstance;
|
|
triggerButton?.classList.remove('anomalous-trigger-error');
|
|
if (triggerButton) {
|
|
triggerButton.title = t('mainOpenTitle');
|
|
triggerButton.setAttribute('aria-label', t('mainOpenTitle'));
|
|
}
|
|
syncVisibility();
|
|
return browserInstance;
|
|
} catch (error) {
|
|
console.error('[Anomalous Model Browser] UI initialization failed:', error);
|
|
triggerButton?.classList.add('anomalous-trigger-error');
|
|
if (triggerButton) {
|
|
triggerButton.title = getCurrentLanguage() === 'zh' ? t('mainRetryInit') : t('mainRetryInitEn');
|
|
triggerButton.setAttribute('aria-label', triggerButton.title);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Opened by the user: the selected node may pick the page (browser.show).
|
|
function open() {
|
|
ensureBrowser()?.show({ followSelection: true });
|
|
}
|
|
|
|
// The shortcut and the Extensions menu entry: open, or close when it is open.
|
|
function toggle() {
|
|
const browser = ensureBrowser();
|
|
if (browser?.modal?.classList.contains('visible')) browser.close();
|
|
else browser?.show({ followSelection: true });
|
|
}
|
|
|
|
const translationPatches = getSettingTranslationPatches();
|
|
const settings = [
|
|
{
|
|
id: FLOATING_TRIGGER_STYLE_SETTING_ID,
|
|
...translationPatches[FLOATING_TRIGGER_STYLE_SETTING_ID],
|
|
type: 'combo',
|
|
defaultValue: 'icon',
|
|
onChange(value) {
|
|
floatingTriggerStyle = normalizeFloatingTriggerStyle(value);
|
|
applyPresentation();
|
|
}
|
|
},
|
|
{
|
|
id: FLOATING_TRIGGER_SIZE_SETTING_ID,
|
|
...translationPatches[FLOATING_TRIGGER_SIZE_SETTING_ID],
|
|
type: 'combo',
|
|
defaultValue: 'medium',
|
|
onChange(value) {
|
|
floatingTriggerSize = normalizeFloatingTriggerSize(value);
|
|
applyPresentation();
|
|
}
|
|
},
|
|
{
|
|
id: SHORTCUT_SETTING_ID,
|
|
...translationPatches[SHORTCUT_SETTING_ID],
|
|
type: () => createShortcutSettingControl({ app, commandId: OPEN_BROWSER_COMMAND_ID, translate: t }),
|
|
defaultValue: '',
|
|
telemetry: { trackChanges: false }
|
|
},
|
|
{
|
|
id: ENTRY_MODE_SETTING_ID,
|
|
...translationPatches[ENTRY_MODE_SETTING_ID],
|
|
type: 'combo',
|
|
defaultValue: 'floating',
|
|
onChange(value) {
|
|
entryMode = normalizeEntryMode(value);
|
|
if (browserInstance) browserInstance.entryMode = entryMode;
|
|
syncVisibility();
|
|
}
|
|
}
|
|
];
|
|
|
|
window.addEventListener('anomalous-language-change', () => {
|
|
applyPresentation();
|
|
refreshRegisteredSettings();
|
|
});
|
|
|
|
/** Size before the button is measurable; matches the size presets in 00-foundation-models.css. */
|
|
function estimatedTriggerSize() {
|
|
return floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
|
|
}
|
|
|
|
async function setup() {
|
|
removeLegacyTriggerPosition();
|
|
watchSaves(ensureBrowser);
|
|
watchJobSheetCard(ensureBrowser);
|
|
// Beside this file, not under a fixed name: the plugin folder may be called anything
|
|
// (a ZIP from GitHub unpacks as Anomalous_Model_Browser-main), and ComfyUI serves it by that name.
|
|
const cssPath = new URL('../styles.css', import.meta.url).pathname;
|
|
const cssUrl = cssPath + '?v=20261007-theme-2';
|
|
if (!document.querySelector(`link[href^="${cssPath}"]`)) {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.type = 'text/css';
|
|
link.href = cssUrl;
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
try {
|
|
const registeredSettings = app.extensionManager?.setting;
|
|
entryMode = normalizeEntryMode(registeredSettings?.get(ENTRY_MODE_SETTING_ID));
|
|
floatingTriggerSize = normalizeFloatingTriggerSize(registeredSettings?.get(FLOATING_TRIGGER_SIZE_SETTING_ID));
|
|
floatingTriggerStyle = normalizeFloatingTriggerStyle(registeredSettings?.get(FLOATING_TRIGGER_STYLE_SETTING_ID));
|
|
} catch (error) {
|
|
console.warn('[Anomalous Model Browser] Unable to read entry preferences:', error);
|
|
}
|
|
|
|
const btn = document.createElement('button');
|
|
btn.id = 'anomalous-trigger-btn';
|
|
btn.setAttribute('aria-label', 'Anomalous Model Browser');
|
|
// Prevent FOUC: Start hidden until presentation & exact coordinates are bound
|
|
btn.classList.add('anomalous-trigger-initializing');
|
|
triggerButton = btn;
|
|
if (browserInstance) browserInstance.triggerButton = btn;
|
|
btn.title = t('mainOpenTitle');
|
|
|
|
// Pre-configure content & presentation BEFORE mounting
|
|
applyPresentation();
|
|
|
|
// Pre-calculate and assign exact target coordinates BEFORE appending to DOM
|
|
const savedInitialPos = loadSavedTriggerPosition();
|
|
if (savedInitialPos) {
|
|
const estSize = estimatedTriggerSize();
|
|
const initialClamped = clampFloatingTriggerPosition({
|
|
x: savedInitialPos.x,
|
|
y: savedInitialPos.y,
|
|
width: estSize,
|
|
height: estSize,
|
|
viewportWidth: window.innerWidth,
|
|
viewportHeight: window.innerHeight,
|
|
minX: DEFAULT_MIN_SAFE_X
|
|
});
|
|
btn.style.position = 'fixed';
|
|
btn.style.left = initialClamped.x + 'px';
|
|
btn.style.top = initialClamped.y + 'px';
|
|
btn.style.right = 'auto';
|
|
btn.style.bottom = 'auto';
|
|
}
|
|
|
|
let isDragging = false;
|
|
let hasMoved = false;
|
|
let startPointerX = 0;
|
|
let startPointerY = 0;
|
|
let startBtnLeft = 0;
|
|
let startBtnTop = 0;
|
|
let currentDragX = null;
|
|
let currentDragY = null;
|
|
let activePointerId = null;
|
|
|
|
const onPointerMove = (event) => {
|
|
if (!isDragging) return;
|
|
const dx = event.clientX - startPointerX;
|
|
const dy = event.clientY - startPointerY;
|
|
if (!hasMoved && Math.hypot(dx, dy) >= 4) {
|
|
hasMoved = true;
|
|
}
|
|
if (!hasMoved) return;
|
|
event.preventDefault();
|
|
const btnW = btn.offsetWidth || 60;
|
|
const btnH = btn.offsetHeight || 60;
|
|
const clamped = clampFloatingTriggerPosition({
|
|
x: startBtnLeft + dx,
|
|
y: startBtnTop + dy,
|
|
width: btnW,
|
|
height: btnH,
|
|
viewportWidth: window.innerWidth,
|
|
viewportHeight: window.innerHeight,
|
|
minX: DEFAULT_MIN_SAFE_X
|
|
});
|
|
currentDragX = clamped.x;
|
|
currentDragY = clamped.y;
|
|
btn.style.left = clamped.x + 'px';
|
|
btn.style.top = clamped.y + 'px';
|
|
btn.style.right = 'auto';
|
|
btn.style.bottom = 'auto';
|
|
};
|
|
|
|
const finishDrag = (event) => {
|
|
if (!isDragging) return;
|
|
isDragging = false;
|
|
|
|
btn.removeEventListener('pointermove', onPointerMove);
|
|
btn.removeEventListener('pointerup', finishDrag);
|
|
btn.removeEventListener('pointercancel', finishDrag);
|
|
btn.removeEventListener('lostpointercapture', finishDrag);
|
|
window.removeEventListener('pointermove', onPointerMove);
|
|
window.removeEventListener('pointerup', finishDrag);
|
|
window.removeEventListener('pointercancel', finishDrag);
|
|
|
|
if (activePointerId != null) {
|
|
try {
|
|
if (btn.hasPointerCapture(activePointerId)) {
|
|
btn.releasePointerCapture(activePointerId);
|
|
}
|
|
} catch (_) {}
|
|
activePointerId = null;
|
|
}
|
|
|
|
btn.style.transition = '';
|
|
|
|
if (hasMoved && currentDragX != null && currentDragY != null) {
|
|
saveTriggerPosition({ x: currentDragX, y: currentDragY });
|
|
} else if (!hasMoved) {
|
|
open();
|
|
}
|
|
};
|
|
|
|
btn.addEventListener('pointerdown', (event) => {
|
|
if (event.button !== 0) return;
|
|
isDragging = true;
|
|
hasMoved = false;
|
|
startPointerX = event.clientX;
|
|
startPointerY = event.clientY;
|
|
activePointerId = event.pointerId;
|
|
|
|
const rect = btn.getBoundingClientRect();
|
|
startBtnLeft = rect.left;
|
|
startBtnTop = rect.top;
|
|
currentDragX = rect.left;
|
|
currentDragY = rect.top;
|
|
|
|
btn.style.transition = 'none';
|
|
|
|
try {
|
|
btn.setPointerCapture(event.pointerId);
|
|
} catch (_) {}
|
|
|
|
btn.addEventListener('pointermove', onPointerMove, { passive: false });
|
|
btn.addEventListener('pointerup', finishDrag);
|
|
btn.addEventListener('pointercancel', finishDrag);
|
|
btn.addEventListener('lostpointercapture', finishDrag);
|
|
window.addEventListener('pointermove', onPointerMove, { passive: false });
|
|
window.addEventListener('pointerup', finishDrag);
|
|
window.addEventListener('pointercancel', finishDrag);
|
|
});
|
|
|
|
const updateBtnBounds = () => {
|
|
const saved = loadSavedTriggerPosition();
|
|
if (saved) {
|
|
const btnW = btn.offsetWidth || estimatedTriggerSize();
|
|
const btnH = btn.offsetHeight || btnW;
|
|
const clamped = clampFloatingTriggerPosition({
|
|
x: saved.x,
|
|
y: saved.y,
|
|
width: btnW,
|
|
height: btnH,
|
|
viewportWidth: window.innerWidth,
|
|
viewportHeight: window.innerHeight,
|
|
minX: DEFAULT_MIN_SAFE_X
|
|
});
|
|
btn.style.left = clamped.x + 'px';
|
|
btn.style.top = clamped.y + 'px';
|
|
btn.style.right = 'auto';
|
|
btn.style.bottom = 'auto';
|
|
} else {
|
|
btn.style.left = '';
|
|
btn.style.top = '';
|
|
btn.style.right = '';
|
|
btn.style.bottom = '';
|
|
}
|
|
};
|
|
triggerBoundsUpdater = updateBtnBounds;
|
|
|
|
window.addEventListener('resize', () => {
|
|
updateBtnBounds();
|
|
syncVisibility();
|
|
});
|
|
|
|
// Mount pre-positioned and styled element to DOM
|
|
document.body.appendChild(btn);
|
|
syncVisibility();
|
|
ensureBrowser();
|
|
// For the Anomalous_TTS node's character menu (its docs/INTERFACE.md §6).
|
|
// Canvas actions asked for by AI apps through the MCP endpoint (api/mcp_bridge.py).
|
|
watchMcpActions(ensureBrowser);
|
|
window.anomalous_open_voice = (character) => {
|
|
const browser = ensureBrowser();
|
|
if (!browser) return false;
|
|
browser.show();
|
|
browser.openVoice(String(character || ''));
|
|
return true;
|
|
};
|
|
|
|
// Reveal smoothly on first animation frame without any positional jump or flicker
|
|
requestAnimationFrame(() => {
|
|
updateBtnBounds();
|
|
btn.classList.remove('anomalous-trigger-initializing');
|
|
});
|
|
|
|
window.anomalousDragGhostImg = new Image();
|
|
window.anomalousDragGhostImg.src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='76' height='76' x='2' y='2' fill='%23140812' fill-opacity='0.85' rx='16' stroke='%23f59e0b' stroke-width='2'/><text x='40' y='50' font-family='sans-serif' font-size='32' font-weight='bold' fill='%23f59e0b' text-anchor='middle'>W</text></svg>";
|
|
}
|
|
|
|
return {
|
|
settings,
|
|
actionBarButtons: [{
|
|
icon: 'pi pi-box',
|
|
label: t('mainTopbarTriggerLabel'),
|
|
tooltip: t('mainOpenTitle'),
|
|
class: 'anomalous-topbar-entry',
|
|
onClick: open
|
|
}],
|
|
commands: [
|
|
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: toggle },
|
|
{ id: RESET_TRIGGER_POSITION_COMMAND_ID, label: t('mainResetFloatingTriggerPosition'), function: resetPosition },
|
|
{ id: QUICK_SEARCH_COMMAND_ID, label: t('quickSearchTitle'), function: () => openQuickSearch(ensureBrowser) },
|
|
],
|
|
keybindings: [
|
|
{ combo: DEFAULT_BROWSER_SHORTCUT, commandId: OPEN_BROWSER_COMMAND_ID },
|
|
{ combo: { key: 'b', alt: true }, commandId: QUICK_SEARCH_COMMAND_ID },
|
|
],
|
|
menuCommands: [{
|
|
path: ['Extensions', 'Anomalous Model Browser'],
|
|
commands: [OPEN_BROWSER_COMMAND_ID, QUICK_SEARCH_COMMAND_ID, RESET_TRIGGER_POSITION_COMMAND_ID]
|
|
}],
|
|
setup,
|
|
// The canvas's right-click menus: AMB's entries as Settings -> Canvas sets them (canvas_menu.js).
|
|
getNodeMenuItems: node => nodeMenuItems(node, ensureBrowser),
|
|
getCanvasMenuItems: () => canvasMenuItems(ensureBrowser),
|
|
open,
|
|
ensureBrowser,
|
|
resetPosition,
|
|
syncVisibility
|
|
};
|
|
}
|