diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b836e5d..0d0690b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,12 +85,12 @@ DOM or live LiteGraph state. - `model_policies.py` owns shared backend rename and protected-category policy. - `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner. -### Frontend - -- `web/main.js` coordinates extension registration. `browser.js` owns the shared +- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared browser class and extracted-method wiring; `browser_entry.js` owns the single - browser instance plus floating/topbar/menu entry behavior; and - `interface_settings.js` owns language and theme preferences. + browser instance plus floating/topbar/menu entry behavior (with Dual-Binding PointerEvents drag capture, `lostpointercapture` fail-safe listeners, zero-drift viewport boundary clamping, flicker-free pre-mount coordinate binding and `anomalous-trigger-initializing` smooth opacity fade reveal, default safe placement in the top-left canvas area at `top: 80px; left: 80px;` gracefully avoiding the left sidebar dock, and versioned `anomalous_trigger_pos_v3` atomic JSON coordinate persistence); `entry_controls.js` + owns entry mode, trigger sizing/styling normalization, mathematical viewport-safe boundary + clamping (`clampFloatingTriggerPosition` with minimum safe boundary `minX=70` preventing left sidebar dock entrapment), clean `loadSavedTriggerPosition`/`saveTriggerPosition` storage drivers, and non-distorting coordinate validation + (`isValidSavedTriggerPosition`); `api/__init__.py` injects an aiohttp no-cache middleware (`Cache-Control: no-cache, no-store, must-revalidate`) for extension static files to eliminate browser memory/disk cache desynchronization across regular page refreshes; and `interface_settings.js` owns language and theme preferences. - `ui_sidebar.js` creates the browser shell and folder navigation. `ui_settings_hub.js` owns settings and model-card preferences; `ui_toolbox.js` owns the tool catalog, fixed shortcut bar, and tool dispatch; diff --git a/api/__init__.py b/api/__init__.py index e7920d3..aa45bd6 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -8,7 +8,20 @@ from .config import * from .notebooks import * from .parameters import * +from aiohttp import web + +@web.middleware +async def no_cache_extension_middleware(request, handler): + response = await handler(request) + if request.path.startswith('/extensions/Anomalous_Model_Browser'): + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response + def setup_routes(app): + if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares: + app.middlewares.append(no_cache_extension_middleware) app.router.add_get('/anomalous/folders', model_catalog.api_get_folders) app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types) app.router.add_get('/anomalous/models', model_catalog.api_get_models) diff --git a/web/main.js b/web/main.js index e73d058..ef1c4d9 100644 --- a/web/main.js +++ b/web/main.js @@ -1,5 +1,12 @@ +try { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('anomalous_btn_x'); + localStorage.removeItem('anomalous_btn_y'); + } +} catch (_) {} + import { app } from "../../scripts/app.js"; -import { createBrowserEntry } from "./modules/browser_entry.js"; +import { createBrowserEntry } from "./modules/browser_entry.js?v=20260921-flicker-free-1"; import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js"; export { setAbyssalScarletTheme }; diff --git a/web/modules/browser_entry.js b/web/modules/browser_entry.js index 30c4643..31d202d 100644 --- a/web/modules/browser_entry.js +++ b/web/modules/browser_entry.js @@ -1,11 +1,24 @@ +try { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('anomalous_btn_x'); + localStorage.removeItem('anomalous_btn_y'); + } +} catch (_) {} + import { app } from '../../../scripts/app.js'; import { AnomalousBrowser } from './browser.js'; import { clampFloatingTriggerPosition, + clearSavedTriggerPosition, + DEFAULT_SAFE_LEFT, + DEFAULT_SAFE_TOP, + isValidSavedTriggerPosition, + loadSavedTriggerPosition, normalizeEntryMode, normalizeFloatingTriggerSize, - normalizeFloatingTriggerStyle -} from './entry_controls.js'; + normalizeFloatingTriggerStyle, + saveTriggerPosition +} from './entry_controls.js?v=20260921-flicker-free-1'; import { createShortcutSettingControl, DEFAULT_BROWSER_SHORTCUT, @@ -107,18 +120,18 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { triggerButton.replaceChildren(icon, label); triggerButton.title = t('mainOpenTitle'); triggerButton.setAttribute('aria-label', t('mainOpenTitle')); - requestAnimationFrame(() => triggerBoundsUpdater?.()); + if (loadSavedTriggerPosition()) { + requestAnimationFrame(() => triggerBoundsUpdater?.()); + } } function resetPosition() { - localStorage.removeItem('anomalous_btn_x'); - localStorage.removeItem('anomalous_btn_y'); + clearSavedTriggerPosition(); if (!triggerButton) return; triggerButton.style.left = ''; triggerButton.style.top = ''; - triggerButton.style.right = '30px'; - triggerButton.style.bottom = '30px'; - requestAnimationFrame(() => triggerBoundsUpdater?.()); + triggerButton.style.right = ''; + triggerButton.style.bottom = ''; } function ensureBrowser() { @@ -241,7 +254,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { }); async function setup() { - const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=' + Date.now(); + const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20260921-flicker-free-1'; if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) { const link = document.createElement('link'); link.rel = 'stylesheet'; @@ -250,86 +263,6 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { document.head.appendChild(link); } - const btn = document.createElement('button'); - btn.id = 'anomalous-trigger-btn'; - btn.setAttribute('aria-label', 'Anomalous Model Browser'); - triggerButton = btn; - if (browserInstance) browserInstance.triggerButton = btn; - btn.title = t('mainOpenTitle'); - let isDragging = false; - let startX; - let startY; - let initialX; - let initialY; - - btn.addEventListener('mousedown', event => { - isDragging = true; - startX = event.clientX; - startY = event.clientY; - const rect = btn.getBoundingClientRect(); - initialX = rect.left; - initialY = rect.top; - btn.style.transition = 'none'; - }); - window.addEventListener('mousemove', event => { - if (!isDragging) return; - event.preventDefault(); - const nextPosition = clampFloatingTriggerPosition({ - x: initialX + (event.clientX - startX), - y: initialY + (event.clientY - startY), - width: btn.offsetWidth, - height: btn.offsetHeight, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - margin: 0 - }); - btn.style.left = nextPosition.x + 'px'; - btn.style.top = nextPosition.y + 'px'; - btn.style.right = 'auto'; - btn.style.bottom = 'auto'; - }); - window.addEventListener('mouseup', event => { - if (!isDragging) return; - isDragging = false; - btn.style.transition = 'transform 0.15s, box-shadow 0.15s'; - localStorage.setItem('anomalous_btn_x', btn.style.left); - localStorage.setItem('anomalous_btn_y', btn.style.top); - if (Math.abs(event.clientX - startX) < 5 && Math.abs(event.clientY - startY) < 5) open(); - }); - - let savedX = localStorage.getItem('anomalous_btn_x'); - let savedY = localStorage.getItem('anomalous_btn_y'); - const updateBtnBounds = () => { - savedX = localStorage.getItem('anomalous_btn_x'); - savedY = localStorage.getItem('anomalous_btn_y'); - const position = clampFloatingTriggerPosition({ - x: btn.style.left || savedX, - y: btn.style.top || savedY, - width: btn.offsetWidth, - height: btn.offsetHeight, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - }); - btn.style.left = position.x + 'px'; - btn.style.top = position.y + 'px'; - btn.style.right = 'auto'; - btn.style.bottom = 'auto'; - }; - triggerBoundsUpdater = updateBtnBounds; - - if (savedX && savedY && savedX !== 'NaN' && savedY !== 'NaN') { - btn.style.right = 'auto'; - btn.style.bottom = 'auto'; - btn.style.left = savedX; - btn.style.top = savedY; - } - setTimeout(updateBtnBounds, 200); - window.addEventListener('resize', () => { - if (btn.style.left) updateBtnBounds(); - syncVisibility(); - }); - - document.body.appendChild(btn); try { const registeredSettings = app.extensionManager?.setting; entryMode = normalizeEntryMode(registeredSettings?.get(ENTRY_MODE_SETTING_ID)); @@ -338,11 +271,181 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { } 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 = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60); + const initialClamped = clampFloatingTriggerPosition({ + x: savedInitialPos.x, + y: savedInitialPos.y, + width: estSize, + height: estSize, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + minX: 70 + }); + 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: 70 + }); + 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 estFallback = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60); + const btnW = btn.offsetWidth || estFallback; + 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: 70 + }); + 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(); installMaterialsShortcutFallback(); + // 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,W"; } diff --git a/web/modules/entry_controls.js b/web/modules/entry_controls.js index 5b01ece..7beaf24 100644 --- a/web/modules/entry_controls.js +++ b/web/modules/entry_controls.js @@ -1,3 +1,11 @@ +// Unconditionally purge legacy contaminated storage keys on module load +try { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('anomalous_btn_x'); + localStorage.removeItem('anomalous_btn_y'); + } +} catch (_) {} + export const FLOATING_TRIGGER_SIZES = new Set(['small', 'medium', 'large']); export const FLOATING_TRIGGER_STYLES = new Set(['icon', 'pill']); export const ENTRY_MODES = new Set(['floating', 'topbar', 'menu']); @@ -14,6 +22,74 @@ export function normalizeEntryMode(value) { return ENTRY_MODES.has(value) ? value : 'floating'; } +export const DEFAULT_SAFE_TOP = 80; +export const DEFAULT_SAFE_LEFT = 80; // Safe position outside ComfyUI left sidebar dock +export const DEFAULT_SAFE_MARGIN_RIGHT = 24; +export const DEFAULT_MIN_SAFE_X = 70; // Avoid ComfyUI left sidebar dock +export const DEFAULT_TRIGGER_POSITION = Object.freeze({ top: DEFAULT_SAFE_TOP, left: DEFAULT_SAFE_LEFT }); +export const TRIGGER_POSITION_STORAGE_KEY = 'anomalous_trigger_pos_v3'; +export const LEGACY_TRIGGER_X_KEY = 'anomalous_btn_x'; +export const LEGACY_TRIGGER_Y_KEY = 'anomalous_btn_y'; + +export function isValidSavedTriggerPosition(x, y) { + if (x == null || y == null) return false; + const px = Number.parseFloat(x); + const py = Number.parseFloat(y); + // Must be a finite number, py >= 0, and px >= DEFAULT_MIN_SAFE_X (70px) to prevent docking inside sidebar + return Number.isFinite(px) && Number.isFinite(py) && px >= DEFAULT_MIN_SAFE_X && py >= 0; +} + +export function loadSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) { + if (!storage) return null; + try { + const raw = storage.getItem(TRIGGER_POSITION_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (isValidSavedTriggerPosition(parsed?.x, parsed?.y)) { + return { x: Math.round(Number(parsed.x)), y: Math.round(Number(parsed.y)) }; + } else { + storage.removeItem(TRIGGER_POSITION_STORAGE_KEY); + } + } + } catch (_) {} + return null; +} + +export function saveTriggerPosition(pos, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) { + if (!storage || !pos) return; + const x = Number.parseFloat(pos.x); + const y = Number.parseFloat(pos.y); + if (!isValidSavedTriggerPosition(x, y)) return; + try { + storage.setItem(TRIGGER_POSITION_STORAGE_KEY, JSON.stringify({ + x: Math.round(x), + y: Math.round(y) + })); + storage.removeItem(LEGACY_TRIGGER_X_KEY); + storage.removeItem(LEGACY_TRIGGER_Y_KEY); + storage.removeItem('anomalous_trigger_pos_v2'); + } catch (_) {} +} + +export function clearSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) { + if (!storage) return; + try { + storage.removeItem(TRIGGER_POSITION_STORAGE_KEY); + storage.removeItem(LEGACY_TRIGGER_X_KEY); + storage.removeItem(LEGACY_TRIGGER_Y_KEY); + storage.removeItem('anomalous_trigger_pos_v2'); + } catch (_) {} +} + +export function sanitizeSavedTriggerPosition(x, y) { + if (!isValidSavedTriggerPosition(x, y)) return null; + return { x: Number.parseFloat(x), y: Number.parseFloat(y) }; +} + +export function normalizeSavedTriggerPosition(x, y) { + return sanitizeSavedTriggerPosition(x, y); +} + export function clampFloatingTriggerPosition({ x, y, @@ -21,19 +97,29 @@ export function clampFloatingTriggerPosition({ height, viewportWidth, viewportHeight, - margin = 30 + minX = DEFAULT_MIN_SAFE_X, + margin = 0 }) { const safeWidth = Math.max(1, Number(width) || 60); const safeHeight = Math.max(1, Number(height) || 60); - const maxX = Math.max(0, (Number(viewportWidth) || safeWidth) - safeWidth); - const maxY = Math.max(0, (Number(viewportHeight) || safeHeight) - safeHeight); - const fallbackX = Math.max(0, maxX - margin); - const fallbackY = Math.max(0, maxY - margin); + const rawVw = Number(viewportWidth) || (typeof window !== 'undefined' ? window.innerWidth : 1024) || 1024; + const rawVh = Number(viewportHeight) || (typeof window !== 'undefined' ? window.innerHeight : 768) || 768; + const safeVw = rawVw >= 300 ? rawVw : 1024; + const safeVh = rawVh >= 200 ? rawVh : 768; + const maxX = Math.max(minX, safeVw - safeWidth); + const maxY = Math.max(0, safeVh - safeHeight); + const parsedX = Number.parseFloat(x); const parsedY = Number.parseFloat(y); + const fallbackX = Math.max(minX, Math.min(maxX, DEFAULT_SAFE_LEFT)); + const fallbackY = Math.min(maxY, DEFAULT_SAFE_TOP); + + const targetX = Number.isFinite(parsedX) ? parsedX : fallbackX; + const targetY = Number.isFinite(parsedY) ? parsedY : fallbackY; + return { - x: Math.min(maxX, Math.max(0, Number.isFinite(parsedX) ? parsedX : fallbackX)), - y: Math.min(maxY, Math.max(0, Number.isFinite(parsedY) ? parsedY : fallbackY)) + x: Math.min(maxX, Math.max(minX, Math.round(targetX))), + y: Math.min(maxY, Math.max(0, Math.round(targetY))) }; } diff --git a/web/styles.css b/web/styles.css index 8ad0920..4456d33 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,4 +1,4 @@ -@import url("./styles/00-foundation-models.css?v=20260918-model-sources-1"); +@import url("./styles/00-foundation-models.css?v=20260921-flicker-free-1"); @import url("./styles/01-shell-tools.css?v=20260918-model-sources-1"); @import url("./styles/02-notebooks.css?v=20260918-model-sources-1"); @import url("./styles/03-gallery-recipes.css?v=20260918-model-sources-1"); diff --git a/web/styles/00-foundation-models.css b/web/styles/00-foundation-models.css index 9bf6dfd..8882c30 100644 --- a/web/styles/00-foundation-models.css +++ b/web/styles/00-foundation-models.css @@ -77,8 +77,10 @@ html.theme-abyssal-scarlet, --anomalous-trigger-font-size: 28px; --anomalous-trigger-pill-padding: 18px; position: fixed; - bottom: 30px; - right: 30px; + top: 80px; + left: 80px; + right: auto; + bottom: auto; width: var(--anomalous-trigger-size); height: var(--anomalous-trigger-size); border-radius: 50%; @@ -93,15 +95,20 @@ html.theme-abyssal-scarlet, box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12); display: flex !important; visibility: visible !important; - opacity: 1 !important; + opacity: 1; align-items: center !important; justify-content: center !important; user-select: none; - transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + transition: opacity 0.18s ease-out, transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease; gap: 8px; white-space: nowrap; } +#anomalous-trigger-btn.anomalous-trigger-initializing { + opacity: 0 !important; + pointer-events: none !important; +} + #anomalous-trigger-btn .anomalous-trigger-icon { display: flex; align-items: center; @@ -195,12 +202,30 @@ body:has(.anomalous-prompt-studio-drawer) #anomalous-trigger-btn { box-shadow: none; } -.anomalous-topbar-entry { +.anomalous-topbar-entry, +button:has(.anomalous-topbar-entry), +[data-testid="anomalous-topbar-entry"], +.side-bar-button:has(.anomalous-topbar-entry) { display: none !important; + visibility: hidden !important; + width: 0 !important; + height: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: none !important; + pointer-events: none !important; + overflow: hidden !important; } -html.anomalous-topbar-entry-enabled .anomalous-topbar-entry { +html.anomalous-topbar-entry-enabled .anomalous-topbar-entry, +html.anomalous-topbar-entry-enabled button:has(.anomalous-topbar-entry), +html.anomalous-topbar-entry-enabled .side-bar-button:has(.anomalous-topbar-entry) { display: inline-flex !important; + visibility: visible !important; + width: auto !important; + height: auto !important; + pointer-events: auto !important; + overflow: visible !important; } html:not(.anomalous-floating-entry-enabled) [data-setting-id="Anomalous.ModelBrowser.FloatingTriggerSize"],