diff --git a/web/css/css-variables.css b/web/css/css-variables.css index ce0e278..7d58fe6 100644 --- a/web/css/css-variables.css +++ b/web/css/css-variables.css @@ -1,151 +1,138 @@ -/** - * CSS Custom Properties (Variables) - * Central definitions for the entire project - */ +:root { + --ml-color-primary: #5af; + --ml-color-primary-light: #7cf; + --ml-color-primary-lighter: #7bf; + --ml-color-success: #5f5; + --ml-color-error: #f00; + --ml-color-error-light: #f55; + --ml-color-error-lighter: #f77; + --ml-color-warning: #fa0; + --ml-color-white: #fff; + --ml-color-black: #000; + --ml-color-gray-100: #ddd; + --ml-color-gray-200: #ccc; + --ml-color-gray-300: #aaa; + --ml-color-gray-400: #999; + --ml-color-gray-500: #888; + --ml-color-gray-600: #666; + --ml-color-gray-700: #555; + --ml-color-gray-800: #444; + --ml-color-gray-900: #333; + --ml-color-bg-dark-1: #2a2a2a; + --ml-color-bg-dark-2: #1e1e1e; + --ml-color-bg-dark-3: #1a1a1a; + --ml-color-bg-dark-4: #0d0d0d; + --ml-color-bg-hover: #3a3a3a; + --ml-overlay-dark-20: rgba(0, 0, 0, 0.2); + --ml-overlay-dark-50: rgba(0, 0, 0, 0.5); + --ml-overlay-dark-70: rgba(0, 0, 0, 0.7); + --ml-overlay-dark-80: rgba(0, 0, 0, 0.8); + --ml-overlay-light-05: rgba(255, 255, 255, 0.05); + --ml-overlay-light-08: rgba(255, 255, 255, 0.08); + --ml-overlay-light-10: rgba(255, 255, 255, 0.1); + --ml-overlay-light-15: rgba(255, 255, 255, 0.15); + --ml-overlay-light-20: rgba(255, 255, 255, 0.2); + --ml-bg-primary-10: rgba(90, 170, 255, 0.1); + --ml-bg-primary-20: rgba(90, 170, 255, 0.2); + --ml-bg-primary-30: rgba(90, 170, 255, 0.3); + --ml-bg-primary-40: rgba(90, 170, 255, 0.4); + --ml-bg-primary-50: rgba(90, 170, 255, 0.5); + --ml-bg-success-10: rgba(80, 255, 80, 0.1); + --ml-bg-success-50: rgba(80, 255, 80, 0.5); + --ml-bg-error-10: rgba(255, 0, 0, 0.1); + --ml-bg-error-20: rgba(255, 0, 0, 0.2); + --ml-bg-gray-30: rgba(100, 100, 100, 0.3); + --ml-bg-gray-40: rgba(120, 120, 120, 0.4); + --ml-line-height-tight: 1.3; + --ml-line-height-normal: 1.5; + --ml-line-height-relaxed: 1.6; + --ml-line-height-editor: 19.5px; + --ml-width-dropdown-min: 250px; + --ml-width-dropdown-max: 400px; + --ml-width-input-inline-sm: 120px; + --ml-width-input-inline-md: 200px; + --ml-width-dialog-sm: 320px; + --ml-width-dialog-md: 800px; + --ml-z-dropdown: 9998; + --ml-z-dialog: 9999; + --ml-z-rename-dialog: 10000; + --ml-z-json-editor: 10001; + --ml-z-floating-button: 10000; + --ml-z-notification: 100000; + --ml-z-context-menu: 100001; + --ml-z-tooltip: 100002; + --ml-spacing-1: 1px; + --ml-spacing-2: 2px; + --ml-spacing-3: 3px; + --ml-spacing-4: 4px; + --ml-spacing-5: 5px; + --ml-spacing-6: 6px; + --ml-spacing-8: 8px; + --ml-spacing-10: 10px; + --ml-spacing-12: 12px; + --ml-spacing-15: 15px; + --ml-spacing-16: 16px; + --ml-spacing-20: 20px; + --ml-spacing-24: 24px; + --ml-spacing-32: 32px; + --ml-spacing-40: 40px; + --ml-spacing-48: 48px; + --ml-radius-sm: 2px; + --ml-radius-md: 4px; + --ml-radius-lg: 6px; + --ml-radius-xl: 8px; + --ml-radius-2xl: 12px; + --ml-radius-3xl: 16px; + --ml-font-xs: 10px; + --ml-font-sm: 11px; + --ml-font-md: 12px; + --ml-font-base: 13px; + --ml-font-lg: 14px; + --ml-font-xl: 16px; + --ml-font-2xl: 18px; + --ml-font-3xl: 20px; + --ml-font-4xl: 24px; + --ml-font-5xl: 48px; + --ml-transition-fast: 0.1s; + --ml-transition-normal: 0.2s; + --ml-transition-all-fast: all 0.1s; + --ml-transition-all: all 0.2s; + --ml-transition-bg-fast: background 0.1s; + --ml-transition-bg: background 0.2s; + --ml-transition-color: color 0.2s; + --ml-transition-border: border-color 0.2s; + --ml-transition-opacity: opacity 0.2s; + --ml-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.8); + --ml-gradient-dark: linear-gradient(135deg, var(--ml-color-bg-dark-1) 0%, var(--ml-color-bg-dark-2) 100%); + --ml-drag-color-reorder: var(--ml-color-primary); + --ml-drag-color-move: var(--ml-color-warning); + --ml-drag-color-error: var(--ml-color-error); + --ml-drag-color-clone: var(--ml-color-success); -.model-linker-container, -[class*="model-linker-"]{ - /* ===== COLORS - PRIMARY ===== */ - --color-primary: #5af; /* Main blue */ - --color-primary-light: #7cf; /* Lighter blue (hover) */ - --color-primary-lighter: #7bf; /* Lightest blue */ - - /* ===== COLORS - STATES ===== */ - --color-success: #5f5; /* Green success */ - --color-error: #f00; /* Red error */ - --color-error-light: #f55; /* Lighter red */ - --color-error-lighter: #f77; /* Lightest red */ - --color-warning: #fa0; /* Orange warning */ - - /* ===== COLORS - GRAYSCALE ===== */ - --color-white: #fff; - --color-black: #000; - --color-gray-100: #ddd; /* Very light */ - --color-gray-200: #ccc; /* Light */ - --color-gray-300: #aaa; /* Medium light */ - --color-gray-400: #999; /* Disabled text */ - --color-gray-500: #888; /* Medium */ - --color-gray-600: #666; /* Medium dark */ - --color-gray-700: #555; /* Dark */ - --color-gray-800: #444; /* Very dark */ - --color-gray-900: #333; /* Darkest */ - - /* ===== COLORS - BACKGROUND ===== */ - --color-bg-dark-1: #2a2a2a; /* Gradient start */ - --color-bg-dark-2: #1e1e1e; /* Gradient end */ - --color-bg-dark-3: #1a1a1a; /* JSON editor container */ - --color-bg-dark-4: #0d0d0d; /* JSON editor line numbers */ - --color-bg-hover: #3a3a3a; /* Generic hover background */ - - /* ===== RGBA - OVERLAYS (Dark) ===== */ - --overlay-dark-20: rgba(0, 0, 0, 0.2); - --overlay-dark-50: rgba(0, 0, 0, 0.5); - --overlay-dark-70: rgba(0, 0, 0, 0.7); - --overlay-dark-80: rgba(0, 0, 0, 0.8); - - /* ===== RGBA - OVERLAYS (Light) ===== */ - --overlay-light-05: rgba(255, 255, 255, 0.05); - --overlay-light-08: rgba(255, 255, 255, 0.08); - --overlay-light-10: rgba(255, 255, 255, 0.1); - --overlay-light-15: rgba(255, 255, 255, 0.15); - --overlay-light-20: rgba(255, 255, 255, 0.2); - - /* ===== RGBA - PRIMARY BACKGROUNDS ===== */ - --bg-primary-10: rgba(90, 170, 255, 0.1); - --bg-primary-20: rgba(90, 170, 255, 0.2); - --bg-primary-30: rgba(90, 170, 255, 0.3); - --bg-primary-40: rgba(90, 170, 255, 0.4); - --bg-primary-50: rgba(90, 170, 255, 0.5); - - /* ===== RGBA - SUCCESS BACKGROUNDS ===== */ - --bg-success-10: rgba(80, 255, 80, 0.1); - --bg-success-50: rgba(80, 255, 80, 0.5); - - /* ===== RGBA - ERROR BACKGROUNDS ===== */ - --bg-error-10: rgba(255, 0, 0, 0.1); - --bg-error-20: rgba(255, 0, 0, 0.2); - - /* ===== RGBA - GRAY BACKGROUNDS ===== */ - --bg-gray-30: rgba(100, 100, 100, 0.3); - --bg-gray-40: rgba(120, 120, 120, 0.4); - - /* ===== LINE HEIGHTS ===== */ - --line-height-tight: 1.3; - --line-height-normal: 1.5; - --line-height-relaxed: 1.6; - --line-height-editor: 19.5px; /* JSON editor specific */ - - /* ===== COMMON WIDTHS ===== */ - --width-dropdown-min: 250px; - --width-dropdown-max: 400px; - --width-input-inline-sm: 120px; - --width-input-inline-md: 200px; - --width-dialog-sm: 320px; - --width-dialog-md: 800px; - - /* ===== Z-INDEX SCALE ===== */ - --z-dropdown: 9998; - --z-dialog: 9999; - --z-rename-dialog: 10000; - --z-json-editor: 10001; - - /* ===== SPACING ===== */ - --spacing-1: 1px; - --spacing-2: 2px; - --spacing-3: 3px; - --spacing-4: 4px; - --spacing-5: 5px; - --spacing-6: 6px; - --spacing-8: 8px; - --spacing-10: 10px; - --spacing-12: 12px; - --spacing-15: 15px; - --spacing-16: 16px; - --spacing-20: 20px; - --spacing-24: 24px; - --spacing-32: 32px; - --spacing-40: 40px; - --spacing-48: 48px; - - /* ===== BORDER RADIUS ===== */ - --radius-sm: 2px; - --radius-md: 4px; - --radius-lg: 6px; - --radius-xl: 8px; - --radius-2xl: 12px; - - /* ===== FONT SIZES ===== */ - --font-xs: 10px; - --font-sm: 11px; - --font-md: 12px; - --font-base: 13px; - --font-lg: 14px; - --font-xl: 16px; - --font-2xl: 18px; - --font-3xl: 20px; - --font-4xl: 24px; - --font-5xl: 48px; - - /* ===== TRANSITIONS ===== */ - --transition-fast: 0.1s; - --transition-normal: 0.2s; - --transition-all-fast: all 0.1s; - --transition-all: all 0.2s; - --transition-bg-fast: background 0.1s; - --transition-bg: background 0.2s; - --transition-color: color 0.2s; - --transition-border: border-color 0.2s; - --transition-opacity: opacity 0.2s; - - /* ===== SHADOWS ===== */ - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.8); - - /* ===== GRADIENTS ===== */ - --gradient-dark: linear-gradient(135deg, var(--color-bg-dark-1) 0%, var(--color-bg-dark-2) 100%); - - /* ===== DRAG & DROP ===== */ - --drag-color-reorder: var(--color-primary); - --drag-color-move: var(--color-warning); - --drag-color-error: var(--color-error); - --drag-color-clone: var(--color-success); + --ml-bg: #121212; + --ml-bg-soft: #171717; + --ml-surface-elevated: #232323; + --ml-card-bg: #1a1a1a; + --ml-card-bg-alt: #1d1d1d; + --ml-panel-bg: #202020; + --ml-panel-bg-strong: #262626; + --ml-border: rgba(255, 255, 255, 0.08); + --ml-border-strong: rgba(255, 255, 255, 0.14); + --ml-divider: rgba(255, 255, 255, 0.08); + --ml-text: #f3f3f3; + --ml-text-muted: #b1b1b1; + --ml-text-dim: #7b7b7b; + --ml-text-soft: #dfdfdf; + --ml-accent: #72d38a; + --ml-accent-hover: #88de9e; + --ml-accent-blue: #72d38a; + --ml-warning: #e1b24d; + --ml-confidence-high: #72d38a; + --ml-confidence-medium: #e1b24d; + --ml-confidence-low: #e06a6a; + --ml-link-color: #d8d8d8; + --ml-shadow-elevated: 0 18px 42px rgba(0, 0, 0, 0.28); + --ml-ui-radius-sm: var(--ml-radius-xl); + --ml-ui-radius-md: var(--ml-radius-2xl); + --ml-ui-radius-lg: var(--ml-radius-3xl); } diff --git a/web/css/linker-main.css b/web/css/linker-main.css new file mode 100644 index 0000000..31aec73 --- /dev/null +++ b/web/css/linker-main.css @@ -0,0 +1,2020 @@ +#model-linker-modal button { + font-size: 15px; +} + +.ml-missing-summary { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 10px; + padding: 10px 12px; + border-radius: var(--ml-ui-radius-lg); + background: linear-gradient(135deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.02) 100%); + border: 1px solid var(--ml-border-strong); + box-shadow: var(--ml-shadow-elevated); +} +.ml-missing-summary-title { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} +.ml-missing-summary-count { + font-size: 15px; + font-weight: 700; + color: var(--ml-text); + letter-spacing: -0.02em; +} +.ml-missing-summary-meta { + font-size: 11px; + color: var(--ml-text-muted); +} + +/* Card Styles */ +.ml-card { + background: var(--ml-card-bg); + border: 1px solid transparent; + border-radius: 0; + padding: 10px 12px; + margin-bottom: 4px; + transition: transform 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.018); + font-family: "Segoe UI Variable", "Segoe UI", sans-serif; +} +.ml-card:nth-child(even) { + background: var(--ml-card-bg-alt); +} +.ml-card:hover { + transform: translateY(-1px); + box-shadow: + inset 0 0 0 1px rgba(255,255,255,0.05), + 0 10px 24px rgba(0,0,0,0.14); +} + +/* Card Header */ +.ml-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} +.ml-card-title-wrap { + min-width: 0; + flex: 1; + display: grid; + gap: 4px; +} +.ml-card-title-meta { + display: grid; + gap: 2px; +} +.ml-card-title { + font-size: 15px; + line-height: 1.24; + font-weight: 700; + color: var(--ml-text); + margin: 0; + word-break: break-word; + letter-spacing: -0.01em; +} +.ml-card-title-eyebrow { + display: block; + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + font-size: 10px; + line-height: 1.35; + color: var(--ml-text-dim); + word-break: break-all; +} +.ml-card-title-primary { + display: block; + color: var(--ml-text-soft); +} +.ml-card-title-secondary { + display: block; + font-size: 12px; + line-height: 1.4; + color: var(--ml-text-muted); + word-break: break-word; +} +.ml-card-subtitle { + margin-top: 0; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} +.ml-node-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 999px; + font-size: 10px; + color: #c8c8c8; + white-space: nowrap; + flex-shrink: 0; +} +.ml-node-chip.is-locatable { + cursor: pointer; + padding-right: 9px; + color: #f1f1f1; + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.12); + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease; +} +.ml-node-chip.is-locatable:hover { + background: rgba(255,255,255,0.12); + border-color: rgba(255,255,255,0.18); + color: #ffffff; + transform: translateY(-1px); +} +.ml-node-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + opacity: 0.95; +} +.ml-node-chip-icon svg { + width: 12px; + height: 12px; + display: block; +} +.ml-category-chip { + display: inline-flex; + padding: 2px 7px; + background: rgba(255,255,255,0.045); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 999px; + font-size: 9px; + color: #d3d3d3; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} +.ml-card-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; + flex-shrink: 0; +} +.ml-inline-civitai-link { + color: var(--ml-text-soft); + font-size: 14px; + font-weight: 700; + text-decoration: none; + letter-spacing: -0.01em; +} +.ml-inline-civitai-link:hover { + color: #ffffff; + text-decoration: none; +} + +/* Two-Column Layout */ +.ml-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +@media (max-width: 700px) { + .ml-missing-summary { + flex-direction: column; + align-items: flex-start; + } + .ml-columns { + grid-template-columns: 1fr; + } + .ml-card-header { + flex-direction: column; + } + .ml-card-actions { + justify-content: flex-start; + } +} +.ml-column { + min-width: 0; + background: var(--ml-panel-bg); + border: 1px solid transparent; + border-radius: 0; + padding: 10px; + box-shadow: inset 0 0 0 1px rgba(148,163,184,0.06); +} +.ml-column-header { + font-size: 10px; + font-weight: 700; + color: var(--ml-text-muted); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--ml-divider); +} +.ml-muted-note, +.ml-no-matches { + padding: 8px 10px; + border-radius: var(--ml-ui-radius-sm); + background: rgba(255,255,255,0.018); + border: 1px solid transparent; + color: var(--ml-text-muted); + font-size: 11px; +} +.ml-inline-note-action { + transition: color 0.15s ease, background 0.15s ease; +} +.ml-inline-note-action:hover { + color: var(--ml-text); + background: rgba(255,255,255,0.05); +} + +/* Filename Chips */ +.ml-chip { + display: inline-flex; + align-items: center; + padding: 5px 10px; + background: rgba(255,255,255,0.07); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 999px; + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + font-size: 12px; + color: var(--ml-text); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ml-chip:hover { + background: #444; +} + +/* Confidence Badges */ +.ml-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + min-width: 36px; + text-align: center; + border: 1px solid transparent; +} +.ml-badge-high { + background: rgba(114,211,138,0.14); + color: #a7e6b6; + border-color: rgba(114,211,138,0.22); +} +.ml-badge-medium { + background: rgba(225,178,77,0.15); + color: #efd391; + border-color: rgba(225,178,77,0.2); +} +.ml-badge-low { + background: rgba(224,106,106,0.14); + color: #efb1b1; + border-color: rgba(224,106,106,0.2); +} +.ml-badge-neutral { + background: rgba(255,255,255,0.06); + color: #d4d4d4; + border-color: rgba(255,255,255,0.1); +} + +/* Match Row */ +.ml-match-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: var(--ml-ui-radius-sm); + margin-bottom: 4px; + background: rgba(255,255,255,0.016); + border: 1px solid transparent; + transition: background 0.15s ease, box-shadow 0.15s ease; +} +.ml-match-row:hover { + background: rgba(255,255,255,0.032); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); +} +.ml-match-row.ml-best-match { + background: rgba(114,211,138,0.08); + box-shadow: inset 0 0 0 1px rgba(114,211,138,0.16); +} +.ml-match-filename { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + font-size: 11.5px; + color: var(--ml-text-soft); +} + +/* Buttons */ +.ml-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 30px; + padding: 6px 11px; + border: 1px solid transparent; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + white-space: nowrap; + text-decoration: none; + box-sizing: border-box; +} +.ml-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.ml-btn-primary { + background: var(--ml-accent); + color: white; +} +.ml-btn-primary:hover:not(:disabled) { + background: var(--ml-accent-hover); +} +.ml-btn-secondary { + background: rgba(255,255,255,0.03); + color: var(--ml-text); + border-color: rgba(255,255,255,0.1); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); +} +.ml-btn-secondary:hover:not(:disabled) { + background: rgba(255,255,255,0.05); + border-color: rgba(255,255,255,0.16); + transform: translateY(-1px); +} +.ml-btn-link { + background: rgba(255,255,255,0.03); + color: var(--ml-text); + border-color: rgba(255,255,255,0.1); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); +} +.ml-btn-link:hover:not(:disabled) { + background: rgba(255,255,255,0.05); + color: var(--ml-text); + border-color: rgba(255,255,255,0.16); + transform: translateY(-1px); +} +.ml-btn-download { + background: var(--ml-accent); + color: #101010; + box-shadow: 0 8px 18px rgba(114,211,138,0.18); +} +.ml-btn-download:hover:not(:disabled) { + background: var(--ml-accent-hover); + transform: translateY(-1px); +} +.ml-btn-danger { + background: #f44336; + color: white; +} +.ml-btn-danger:hover:not(:disabled) { + background: #d32f2f; +} +.ml-btn-sm { + min-height: 24px; + padding: 2px 7px; + font-size: 9px; + border-radius: 8px; +} +.ml-btn-ghost { + background: rgba(255,255,255,0.018); + color: var(--ml-text-muted); + border-color: rgba(255,255,255,0.08); + box-shadow: none; +} +.ml-btn-ghost:hover:not(:disabled) { + background: rgba(255,255,255,0.04); + color: var(--ml-text); + border-color: rgba(255,255,255,0.14); +} +.ml-btn-utility { + padding-inline: 6px; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--ml-text-soft); +} +.ml-btn-link-compact { + min-width: 58px; + gap: 4px; + padding-inline: 5px; + font-size: 8.75px; +} +.ml-btn-icon-only { + width: 24px; + min-width: 24px; + padding: 0; + aspect-ratio: 1; +} +.ml-btn-icon { + font-size: 12px; +} +.ml-btn-icon svg { + width: 13px; + height: 13px; + display: block; +} +.ml-card-actions .ml-btn, +.ml-match-row .ml-btn, +.ml-combo-row .ml-btn { + min-height: 24px; +} +.ml-search-source-btn { + border-radius: 999px; + padding-inline: 11px; + background: rgba(255,255,255,0.03); + color: var(--ml-text); + border-color: rgba(255,255,255,0.1); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); +} +.ml-search-source-btn.ml-btn-primary { + background: rgba(255,255,255,0.03); + color: var(--ml-text); + border-color: rgba(255,255,255,0.1); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); +} +.ml-search-source-btn:hover:not(:disabled), +.ml-search-source-btn.ml-btn-primary:hover:not(:disabled) { + background: rgba(255,255,255,0.05); + color: var(--ml-text); + border-color: rgba(255,255,255,0.16); +} + +/* Download Section */ +.ml-download-section { + display: grid; + gap: 8px; + padding: 10px; + background: var(--ml-surface-elevated); + border-radius: 0; + border: 1px solid transparent; + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05); +} +.ml-download-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} +.ml-download-actions .ml-btn-download, +.ml-download-actions .ml-btn-download:hover:not(:disabled), +.ml-btn.ml-btn-download.ml-footer-btn, +.ml-btn.ml-btn-download.ml-footer-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.03); + color: var(--ml-text); + border-color: rgba(255,255,255,0.1); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); +} +.ml-download-actions .ml-btn-download:hover:not(:disabled), +.ml-btn.ml-btn-download.ml-footer-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.05); + color: var(--ml-text); + border-color: rgba(255,255,255,0.16); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); + transform: translateY(-1px); +} +.ml-btn-download-main { + justify-content: flex-start; + padding-inline: 10px; + font-size: 8.9px; +} +.ml-btn-download-side { + min-width: 132px; + font-size: 8.6px; +} +.ml-status-inline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.ml-download-info { + font-size: 11px; + color: var(--ml-text-muted); + margin-top: 0; + line-height: 1.55; +} +.ml-download-source { + color: var(--ml-accent); + font-weight: 700; +} +.ml-download-size { + color: var(--ml-text-dim); + margin-left: 8px; +} +.ml-search-source-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.ml-search-source-picker { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + margin-left: auto; +} +.ml-search-source-picker-label { + color: var(--ml-text-muted); + font-size: 11px; + white-space: nowrap; +} +.ml-search-source-select { + min-height: 30px; + padding: 5px 10px; + background: rgba(255,255,255,0.04); + color: var(--ml-text); + border: 1px solid var(--ml-border); + border-radius: 10px; + font-size: 11px; + outline: none; + appearance: none; +} +.ml-search-source-select:focus { + border-color: rgba(114,211,138,0.4); + box-shadow: 0 0 0 3px rgba(114,211,138,0.12); +} +.ml-search-results { + margin-top: 8px; + display: none; +} +.ml-combo-section { + position: relative; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--ml-divider); +} +.ml-combo-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} +.ml-combo-label { + opacity: 0.9; + font-size: 10px; + color: var(--ml-text-muted); + font-weight: 600; + min-width: 42px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.ml-combo-input { + flex: 1; + min-width: 0; + min-height: 30px; + padding: 5px 10px; + background: rgba(255,255,255,0.04); + color: var(--ml-text); + border: 1px solid var(--ml-border); + border-radius: 10px; + font-size: 12px; + outline: none; + transition: border-color 0.16s ease, box-shadow 0.16s ease, background 0.16s ease; +} +.ml-combo-input:focus { + border-color: rgba(114,211,138,0.4); + box-shadow: 0 0 0 3px rgba(114,211,138,0.12); +} +.ml-combo-list { + position: absolute; + top: calc(100% + 4px); + left: 44px; + right: 0; + width: auto; + max-height: 220px; + overflow: auto; + display: none; + z-index: var(--ml-z-notification); + background: linear-gradient(180deg, var(--ml-panel-bg-strong) 0%, var(--ml-card-bg) 100%); + border: 1px solid var(--ml-border-strong); + border-radius: 12px; + box-shadow: var(--ml-shadow-elevated); +} +.ml-combo-option { + padding: 8px 10px; + cursor: pointer; + border-bottom: 1px solid rgba(255,255,255,0.04); + font-size: 12px; + color: var(--ml-text); + background: transparent; + transition: background 0.14s ease, color 0.14s ease; +} +.ml-combo-option:last-child { + border-bottom: none; +} +.ml-combo-option:hover, +.ml-combo-option.is-highlighted { + background: rgba(114,211,138,0.12); +} +.ml-combo-option-row { + display: flex; + align-items: center; + gap: 6px; +} +.ml-combo-option code { + flex: 1 1 0%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--ml-text); +} +.ml-combo-folder { + margin-top: 4px; + font-size: 10px; + color: var(--ml-text-dim); + opacity: 0.9; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Status Messages */ +.ml-status { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + margin-top: 8px; + border: 1px solid rgba(255,255,255,0.06); +} +.ml-status-icon { + font-size: 16px; + flex-shrink: 0; +} +.ml-status-error { + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.3); + color: #ef9a9a; +} +.ml-status-success { + background: rgba(76, 175, 80, 0.1); + border: 1px solid rgba(76, 175, 80, 0.3); + color: #a5d6a7; +} +.ml-status-info { + background: rgba(33, 150, 243, 0.1); + border: 1px solid rgba(33, 150, 243, 0.3); + color: #90caf9; +} +.ml-status-warning { + background: rgba(255, 152, 0, 0.1); + border: 1px solid rgba(255, 152, 0, 0.3); + color: #ffcc80; +} + +/* Progress Bar */ +.ml-progress-container { + margin-top: 8px; +} +.ml-progress-bar { + height: 6px; + background: #333; + border-radius: 3px; + overflow: hidden; +} +.ml-progress-fill { + height: 100%; + background: var(--ml-accent); + transition: width 0.3s ease; +} +.ml-progress-text { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 6px; + font-size: 11px; + color: var(--ml-text-muted); +} + +/* Scrollbar */ +.ml-scrollable { + overflow-y: auto; +} +.ml-scrollable::-webkit-scrollbar { + width: 8px; +} +.ml-scrollable::-webkit-scrollbar-track { + background: transparent; +} +.ml-scrollable::-webkit-scrollbar-thumb { + background: #444; + border-radius: 4px; +} +.ml-scrollable::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Footer */ +.ml-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 5px 18px; + background: linear-gradient(to top, rgba(18,18,18,0.98) 0%, rgba(18,18,18,0.92) 70%, transparent 100%); + border-top: 1px solid var(--ml-border); +} +.ml-footer-btn { + min-height: 32px; + min-width: 144px; + padding: 7px 13px; + font-size: 12px; + border-radius: 9px; + line-height: 1; + backdrop-filter: blur(10px); +} +.ml-btn.ml-footer-btn, +.ml-btn.ml-btn-primary.ml-footer-btn, +.ml-btn.ml-btn-download.ml-footer-btn { + background: rgba(255,255,255,0.03); + color: var(--ml-text); + border-color: rgba(255,255,255,0.1); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); +} +.ml-btn.ml-footer-btn:hover:not(:disabled), +.ml-btn.ml-btn-primary.ml-footer-btn:hover:not(:disabled), +.ml-btn.ml-btn-download.ml-footer-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.05); + color: var(--ml-text); + border-color: rgba(255,255,255,0.16); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); + transform: translateY(-1px); +} + +/* Tabs */ +.ml-tabs { + display: flex; + align-items: flex-end; + gap: 6px; + padding: 7px 16px 0 16px; + margin-bottom: 0; + background: + linear-gradient(180deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.005) 100%); + position: relative; + z-index: 2; + border-bottom: 1px solid var(--ml-border); +} +.ml-tab { + position: relative; + padding: 8px 13px 9px 13px; + background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.015) 100%); + border: 1px solid rgba(255,255,255,0.1); + border-bottom: none; + border-top-left-radius: 11px; + border-top-right-radius: 11px; + color: var(--ml-text-muted, #888); + font-size: 12px; + font-weight: 600; + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + transform: translateY(4px); + opacity: 0.8; + transition: transform 0.18s ease, opacity 0.18s ease, color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; +} +.ml-tab:hover { + color: var(--ml-text-muted, #888); + background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.015) 100%); + border-color: rgba(255,255,255,0.14); + transform: translateY(2px); + opacity: 0.96; +} +.ml-tab.ml-tab-active { + color: var(--ml-text, #eef2f7); + background: var(--ml-bg); + border-color: rgba(255,255,255,0.16); + box-shadow: + 0 -8px 18px rgba(0,0,0,0.1), + 0 6px 10px rgba(0,0,0,0.08), + inset 0 1px 0 rgba(255,255,255,0.03); + transform: translateY(1px); + opacity: 1; + z-index: 3; +} +.ml-tab.ml-tab-active::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -6px; + height: 8px; + background: var(--ml-bg); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} + +/* Loaded Models Tab */ +.ml-loaded-models { + padding: 0; +} +.ml-loaded-models-header { + padding: 16px 20px; + border-bottom: 1px solid var(--ml-border); +} +.ml-loaded-models-title { + font-size: 15px; + font-weight: 600; + color: var(--ml-text); + margin: 0 0 8px 0; +} +.ml-loaded-models-subtitle { + font-size: 12px; + color: var(--ml-text-muted); +} +.ml-models-list { + padding: 12px 20px; + display: flex; + flex-direction: column; + gap: 8px; +} +.ml-model-chip { + display: inline-flex; + align-items: center; + padding: 6px 12px; + background: #3a3a3a; + border-radius: 6px; + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + font-size: 12px; + color: var(--ml-text); +} +.ml-model-chip-category { + padding: 2px 6px; + background: #555; + border-radius: 4px; + font-size: 10px; + color: #aaa; + margin-left: 8px; + text-transform: uppercase; +} +.ml-model-chip-strength { + padding: 2px 6px; + background: #4CAF50; + border-radius: 4px; + font-size: 10px; + color: white; + margin-left: 8px; +} + +.model-linker-selected { + margin: 8px 0; + padding: 10px 12px; + background: rgba(78, 161, 255, 0.12); + border: 1px solid rgba(78, 161, 255, 0.28); + border-radius: 0; + color: var(--ml-text); + font-size: 12px; +} + +/* Link styling */ +.ml-link { + color: var(--ml-link-color); + text-decoration: none; +} +.ml-link:hover { + text-decoration: underline; +} + +/* Context menu */ +.ml-context-menu { + position: fixed; + background: var(--comfy-menu-bg, #202020); + border: 1px solid var(--border-color, #555555); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); + z-index: var(--ml-z-context-menu); + min-width: 160px; + overflow: hidden; +} +.ml-context-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 12px; + color: var(--input-text, #e0e0e0); + cursor: pointer; + transition: background 0.15s ease; +} +.ml-context-menu-item:hover { + background: rgba(255,255,255,0.1); +} +.ml-context-menu-item-icon { + font-size: 14px; + width: 20px; + text-align: center; +} +.ml-context-menu-divider { + height: 1px; + background: var(--border-color, #555555); + margin: 4px 0; +} + +/* Model Info Dialog (similar to rgthree's RgthreeLoraInfoDialog) */ +.ml-info-dialog-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + z-index: var(--ml-z-tooltip); + display: flex; + align-items: center; + justify-content: center; +} +.ml-info-dialog { + background: var(--comfy-menu-bg, #202020); + border: 2px solid var(--border-color, #555555); + border-radius: 8px; + width: 500px; + max-width: 90vw; + max-height: 80vh; + min-width: 420px; + min-height: 320px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + resize: both; +} +.ml-info-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #555555); + background: rgba(0,0,0,0.2); +} +.ml-info-dialog-header h3 { + margin: 0; + font-size: 16px; + color: var(--input-text, #e0e0e0); +} +.ml-info-dialog-close { + background: none; + border: none; + font-size: 24px; + color: var(--input-text, #e0e0e0); + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} +.ml-info-dialog-close:hover { + background: rgba(255,255,255,0.1); +} +.ml-info-dialog-content { + padding: 16px 20px; + overflow-y: auto; + flex: 1; +} +.ml-info-dialog-loading { + text-align: center; + color: var(--ml-text-muted, #888); + padding: 20px; +} +.ml-info-section { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color, #555555); +} +.ml-info-section-header { + font-size: 12px; + font-weight: 600; + color: var(--ml-text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} +.ml-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; +} +.ml-info-label { + color: var(--ml-text-muted, #888); + font-size: 13px; +} +.ml-info-value { + color: var(--input-text, #e0e0e0); + font-size: 13px; + font-weight: 500; +} +.ml-info-value.ml-info-active { + color: #4CAF50; +} +.ml-info-value.ml-info-inactive { + color: #888; +} +.ml-info-section-civitai { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} +.ml-info-civitai-link { + display: flex; + flex-direction: column; + gap: 8px; +} +.ml-info-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: #2196F3; + color: white; + border-radius: 6px; + text-decoration: none; + font-size: 13px; +} +.ml-info-link:hover { + background: #1976D2; +} +.ml-info-loading-small { + color: var(--ml-text-muted, #888); + font-size: 12px; + font-style: italic; +} +.ml-info-not-found { + color: #888; + font-size: 12px; +} + +/* Info Area (type/base model tags) */ +.ml-info-area { + display: flex; + gap: 8px; + margin-bottom: 16px; +} +.ml-info-tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + background: rgba(255,255,255,0.1); + border-radius: 4px; + font-size: 11px; + font-weight: 600; + color: var(--input-text, #e0e0e0); +} +.ml-info-tag.-type-lora { background: rgba(33,150,243,0.2); color: #64B5F6; } +.ml-info-tag.-type-checkpoint { background: rgba(76,175,80,0.2); color: #81C784; } +.ml-info-tag.-type-vae { background: rgba(156,39,176,0.2); color: #BA68C8; } +.ml-info-tag.-basemodel-sd1 { background: rgba(244,67,54,0.2); color: #EF9A9A; } +.ml-info-tag.-basemodel-sdxl { background: rgba(33,150,243,0.2); color: #64B5F6; } +.ml-info-tag.-basemodel-sdxl-turbo { background: rgba(33,150,243,0.2); color: #64B5F6; } +.ml-info-tag.-basemodel-flux.1 { background: rgba(255,152,0,0.2); color: #FFB74D; } +.ml-info-tag.-basemodel-flux.1-d { background: rgba(255,152,0,0.2); color: #FFB74D; } + +/* Info Table */ +.ml-info-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; +} +.ml-info-table tr { + border-bottom: 1px solid rgba(255,255,255,0.05); +} +.ml-info-table tr:last-child { + border-bottom: none; +} +.ml-info-table td { + padding: 8px 4px; + font-size: 13px; + vertical-align: top; +} +.ml-info-table td:first-child { + color: var(--ml-text-muted, #888); + white-space: nowrap; + width: 140px; +} +.ml-info-table td:last-child { + color: var(--input-text, #e0e0e0); + word-break: break-word; +} +.ml-info-trained-words-label { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} +.ml-info-trained-words-meta { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--ml-text-muted, #888); +} +.ml-info-trained-words-hint { + margin-bottom: 8px; + font-size: 11px; + color: var(--ml-text-muted, #888); +} +.ml-info-copy-trained-words { + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.05); + color: var(--ml-link-color, #8fc2ff); + border-radius: 999px; + padding: 2px 10px; + font-size: 11px; + cursor: pointer; + transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease; +} +.ml-info-copy-trained-words:hover:not(:disabled) { + background: rgba(78,161,255,0.14); + border-color: rgba(78,161,255,0.28); + color: #d6eaff; +} +.ml-info-copy-trained-words:disabled { + opacity: 0.45; + cursor: default; +} +.ml-info-trained-words-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; +} +.ml-info-trained-word { + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.04); + color: var(--ml-text); + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + line-height: 1.2; + cursor: pointer; + transition: transform 0.14s ease, background 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease; +} +.ml-info-trained-word:hover { + transform: translateY(-1px); + background: rgba(255,255,255,0.07); + border-color: rgba(255,255,255,0.18); +} +.ml-info-trained-word.is-selected { + background: rgba(78,161,255,0.18); + border-color: rgba(78,161,255,0.4); + color: #d8ebff; + box-shadow: 0 0 0 1px rgba(78,161,255,0.15) inset; +} +.ml-info-description-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} +.ml-info-description { + line-height: 1.55; + color: var(--input-text, #e0e0e0); + max-height: 220px; + overflow: hidden; + position: relative; +} +.ml-info-description:not(.is-expanded)::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 56px; + background: linear-gradient(180deg, rgba(32,32,32,0) 0%, rgba(32,32,32,0.92) 100%); + pointer-events: none; +} +.ml-info-description.is-expanded { + max-height: none; + overflow: visible; +} +.ml-info-description p, +.ml-info-description ul, +.ml-info-description ol, +.ml-info-description blockquote, +.ml-info-description pre { + margin: 0 0 10px 0; +} +.ml-info-description h1, +.ml-info-description h2, +.ml-info-description h3, +.ml-info-description h4, +.ml-info-description h5, +.ml-info-description h6 { + margin: 14px 0 8px 0; + color: var(--ml-text); + line-height: 1.3; +} +.ml-info-description ul, +.ml-info-description ol { + padding-left: 18px; +} +.ml-info-description code, +.ml-info-description pre { + background: rgba(255,255,255,0.05); + border-radius: 6px; +} +.ml-info-description code { + padding: 1px 5px; + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + font-size: 12px; +} +.ml-info-description pre { + padding: 10px 12px; + overflow-x: auto; + white-space: pre-wrap; +} +.ml-info-description a { + color: var(--ml-link-color, #8fc2ff); + text-decoration: none; +} +.ml-info-description a:hover { + text-decoration: underline; +} +.ml-info-description-actions { + display: flex; + justify-content: flex-end; +} +.ml-info-description-toggle { + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.05); + color: var(--ml-link-color, #8fc2ff); + border-radius: 999px; + padding: 4px 12px; + font-size: 11px; + cursor: pointer; + transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease; +} +.ml-info-description-toggle:hover { + background: rgba(78,161,255,0.14); + border-color: rgba(78,161,255,0.28); + color: #d6eaff; +} +.ml-info-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + background: rgba(255,255,255,0.14); + color: var(--ml-text-muted, #b4becb); + border-radius: 50%; + font-size: 10px; + font-weight: 700; + cursor: help; + margin-left: 4px; + vertical-align: middle; + border: 1px solid rgba(255,255,255,0.12); + user-select: none; +} +.ml-info-help:hover { + background: rgba(78,161,255,0.18); + color: #d9ecff; + border-color: rgba(78,161,255,0.3); +} + +/* Images Section */ +.ml-info-images { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color, #555555); +} +.ml-info-images-header { + font-size: 12px; + font-weight: 600; + color: var(--ml-text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} +.ml-info-images-layout { + column-count: 3; + column-gap: 14px; +} +.ml-info-image-item { + border-radius: 6px; + overflow: hidden; + background: rgba(0,0,0,0.2); + border: 1px solid rgba(255,255,255,0.06); + break-inside: avoid; + margin-bottom: 14px; +} +.ml-info-image-item figure { + margin: 0; + position: relative; + display: block; + background: rgba(0,0,0,0.3); +} +.ml-info-image-item img { + width: 100%; + height: auto; + display: block; + object-fit: contain; + transition: transform 0.24s ease, filter 0.24s ease; +} +.ml-info-image-item figcaption { + position: absolute; + inset: auto 0 0 0; + max-height: 60%; + overflow-y: auto; + padding: 10px; + font-size: 11px; + color: #e9eef5; + background: linear-gradient(180deg, rgba(7,10,14,0) 0%, rgba(7,10,14,0.82) 16%, rgba(7,10,14,0.94) 100%); + opacity: 0; + transform: translateY(10px); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; +} +.ml-info-image-item figcaption span { + display: block; + margin-bottom: 4px; + line-height: 1.4; +} +.ml-info-image-item figcaption span label { + font-weight: 600; + color: #b9c8d8; + margin-right: 4px; + text-transform: lowercase; +} +.ml-info-image-link { + color: #8fc2ff; + text-decoration: none; + display: inline-flex; + margin-bottom: 6px; + pointer-events: auto; + font-weight: 700; +} +.ml-info-image-item:hover img { + transform: scale(1.01); + filter: brightness(0.82); +} +.ml-info-image-item:hover figcaption, +.ml-info-image-item:focus-within figcaption { + opacity: 1; + transform: translateY(0); +} +@media (max-width: 900px) { + .ml-info-images-layout { + column-count: 2; + } +} +@media (max-width: 640px) { + .ml-info-images-layout { + column-count: 1; + } +} + +/* CivitAI Logo */ +.ml-info-civitai-logo { + width: 16px; + height: 16px; +} + +/* Footer */ +.ml-info-dialog-footer { + padding: 12px 20px; + border-top: 1px solid var(--border-color, #555555); + display: flex; + justify-content: flex-end; + gap: 8px; +} +.ml-info-dialog-close-btn { + padding: 8px 16px; +} +.ml-info-error { + color: #f44336; + font-size: 12px; +} + +.ml-options-wrap { + max-width: 1120px; + margin: 0 auto; + padding: 0 0 0 0; + height: 100%; +} +.ml-options-shell { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 0; + height: 100%; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 22px; + overflow: hidden; + background: #171717; +} +.ml-options-sidebar { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 18px; + height: 100%; + padding: 18px 14px; + background: #242424; + border-right: 1px solid rgba(255,255,255,0.08); + overflow: hidden; + min-width: 0; +} +.ml-options-sidebar-title { + margin: 0; + font-size: 17px; + font-weight: 700; + color: var(--ml-text); +} +.ml-options-sidebar-group { + display: grid; + gap: 8px; + min-height: 0; + align-content: start; + flex: 0 0 auto; +} +.ml-options-sidebar-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(255,255,255,0.45); +} +.ml-options-nav { + display: grid; + gap: 6px; + align-content: start; + grid-auto-rows: min-content; +} +.ml-options-nav-btn { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-height: 48px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--ml-text-muted); + font-size: 14px; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: background 0.16s ease, color 0.16s ease; +} +.ml-options-nav-btn:hover { + color: var(--ml-text); + background: rgba(255,255,255,0.04); +} +.ml-options-nav-btn.is-active { + color: #ffffff; + background: #3a3a40; +} +.ml-options-nav-meta { + font-size: 11px; + font-weight: 600; + color: rgba(255,255,255,0.42); +} +.ml-options-nav-btn.is-active .ml-options-nav-meta { + color: rgba(255,255,255,0.65); +} +.ml-options-main { + display: grid; + gap: 0; + min-height: 0; + min-width: 0; + height: 100%; + align-content: start; + align-items: start; + padding: 18px 26px 0px 26px; + background: #171717; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: auto; + scrollbar-color: rgba(255,255,255,0.34) rgba(255,255,255,0.08); +} +.ml-options-main::-webkit-scrollbar { + width: 12px; +} +.ml-options-main::-webkit-scrollbar-track { + background: rgba(255,255,255,0.06); + border-radius: 999px; +} +.ml-options-main::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.34); + border-radius: 999px; + border: 2px solid rgba(23,23,23,0.95); +} +.ml-options-main::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.48); +} +.ml-options-card { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + box-shadow: none; + align-self: start; +} +.ml-options-title { + margin: 0; + font-size: 17px; + font-weight: 700; + color: var(--ml-text); +} +.ml-options-section { + display: grid; + gap: 0; + align-content: start; + align-items: start; + scroll-margin-top: 24px; + min-width: 0; +} +.ml-options-section.is-hidden { + display: none; +} +.ml-options-section-head { + display: block; + padding: 0 0 10px 0; +} +.ml-options-section-title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--ml-text); +} +.ml-options-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; + align-content: start; + align-items: start; + min-width: 0; +} +.ml-options-stack { + display: grid; + gap: 0; + align-content: start; + min-width: 0; +} +.ml-options-panel { + display: grid; + gap: 0; + align-content: start; + padding: 0; + border: none; + background: transparent; + min-width: 0; +} +.ml-options-panel-title { + margin: 0 0 6px 0; + font-size: 16px; + font-weight: 700; + color: var(--ml-text); +} +.ml-options-field { + display: flex; + flex-direction: column; + gap: 0; + align-items: stretch; +} +.ml-options-label { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: #d8d8dd; +} +.ml-options-inline-link { + border: none; + background: none; + font-size: 11px; + font-weight: 700; + padding: 0; + cursor: pointer; + text-decoration: none; + color: rgba(255,255,255,0.6); +} +.ml-options-inline-link:hover { + color: #ffffff; + text-decoration: underline; +} +.ml-tooltip-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.12); + color: rgba(255,255,255,0.7); + font-size: 11px; + font-weight: 700; + background: rgba(255,255,255,0.03); + cursor: help; + flex: 0 0 auto; +} +.ml-global-tooltip { + position: fixed; + min-width: 220px; + max-width: min(320px, calc(100vw - 24px)); + padding: 9px 11px; + border-radius: 10px; + background: rgba(34, 34, 38, 0.98); + border: 1px solid rgba(255,255,255,0.08); + color: var(--ml-text); + font-size: 12px; + font-weight: 400; + line-height: 1.5; + white-space: normal; + text-align: left; + box-shadow: 0 12px 28px rgba(0,0,0,0.32); + pointer-events: none; + z-index: var(--ml-z-tooltip); +} +.ml-options-input-row { + display: grid; + grid-template-columns: minmax(150px, 1fr) minmax(0, 380px) 42px; + align-items: center; + gap: 10px; + padding: 14px 0; + border-bottom: 1px solid rgba(255,255,255,0.1); + min-width: 0; +} +.ml-options-input-row > .ml-options-label { + min-width: 0; + padding-right: 8px; +} +.ml-options-input { + width: 100%; + min-width: 0; + min-height: 42px; + padding: 11px 14px; + background: #0f1013; + color: var(--ml-text); + border: 1px solid rgba(255,255,255,0.16); + border-radius: 8px; + font-size: 13px; + outline: none; + transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease; +} +.ml-options-input:focus { + background: #0f1013; + border-color: rgba(78,161,255,0.45); + box-shadow: none; +} +.ml-options-visibility-btn { + width: 42px; + min-width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 8px; + background: #0f1013; + color: var(--ml-text-muted); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} +.ml-options-visibility-btn:hover { + background: #15171b; + color: var(--ml-text); + border-color: rgba(255,255,255,0.2); +} +.ml-options-visibility-btn[aria-pressed="true"] { + color: var(--ml-text); + border-color: rgba(255,255,255,0.2); + background: #15171b; +} +.ml-options-visibility-btn svg { + width: 18px; + height: 18px; + display: block; +} +.ml-options-toggle-list { + display: grid; + gap: 0; +} +.ml-options-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 0; + border-bottom: 1px solid rgba(255,255,255,0.1); + border-radius: 0; + border-top: none; + border-left: none; + border-right: none; + background: transparent; + cursor: pointer; + transition: background 0.16s ease; + min-width: 0; +} +.ml-options-toggle-row:hover { + background: transparent; +} +.ml-options-toggle-copy { + display: grid; + gap: 0; + min-width: 0; +} +.ml-options-toggle-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 13px; + font-weight: 500; + color: #d8d8dd; +} +.ml-options-toggle-control { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 46px; + padding-top: 1px; + flex: 0 0 auto; + margin-left: auto; +} +.ml-options-switch-input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.ml-options-switch { + position: relative; + width: 46px; + height: 26px; + border-radius: 999px; + background: #555560; + border: 1px solid rgba(255,255,255,0.06); + transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} +.ml-options-switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 999px; + background: #f2f6fb; + box-shadow: 0 3px 8px rgba(0,0,0,0.3); + transition: transform 0.16s ease; +} +.ml-options-switch-input:checked + .ml-options-switch { + background: #4c97ff; + border-color: #4c97ff; + box-shadow: none; +} +.ml-options-switch-input:checked + .ml-options-switch::after { + transform: translateX(20px); +} +.ml-options-number-row { + display: grid; + grid-template-columns: minmax(150px, 1fr) minmax(72px, 118px); + gap: 12px; + align-items: center; + padding: 16px 0; + border-bottom: 1px solid rgba(255,255,255,0.1); + min-width: 0; +} +.ml-options-number-copy { + display: grid; + gap: 0; + min-width: 0; +} +.ml-options-number-row .ml-options-input { + width: 100%; + min-width: 0; +} +.ml-options-actions { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-end; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,0.08); + gap: 10px; + flex: 0 0 auto; +} +.ml-options-actions .ml-footer-btn { + width: 100%; + min-width: 0; +} +.ml-options-actions .ml-btn.ml-btn-primary.ml-footer-btn { + background: #3a3a40; + border-color: rgba(255,255,255,0.12); +} +.ml-options-actions .ml-btn.ml-btn-primary.ml-footer-btn:hover:not(:disabled) { + background: #474750; + border-color: rgba(255,255,255,0.18); +} +.ml-options-status { + font-size: 12px; + color: var(--ml-text-muted); + text-align: left; +} +.ml-options-status.is-dirty { + color: #ffd48a; +} +.ml-options-status.is-saved { + color: #9dd8a9; +} +@media (max-width: 980px) { + .ml-options-shell { + grid-template-columns: 1fr; + border-radius: 16px; + } + .ml-options-sidebar { + border-right: none; + border-bottom: 1px solid rgba(255,255,255,0.08); + height: auto; + } +} +@media (max-width: 900px) { + .ml-options-main { + padding: 16px 18px 0px 18px; + } + .ml-options-input-row { + grid-template-columns: minmax(120px, 1fr) minmax(0, 1fr) 42px; + gap: 8px; + } + .ml-options-number-row { + grid-template-columns: minmax(120px, 1fr) minmax(64px, 96px); + gap: 8px; + } + .ml-options-toggle-row { + gap: 10px; + } +} +@media (max-width: 760px) { + .ml-options-input-row { + grid-template-columns: 1fr; + } + .ml-options-input-row > .ml-options-label { + padding-right: 0; + } + .ml-options-visibility-btn { + justify-self: start; + } + .ml-options-toggle-row, + .ml-options-number-row { + grid-template-columns: 1fr; + flex-direction: column; + align-items: flex-start; + } + .ml-options-toggle-control { + min-width: 0; + margin-left: 0; + } + .ml-options-number-row { + grid-template-columns: 1fr; + } +} +@media (max-width: 640px) { + .ml-options-section-head, + .ml-options-actions, + .ml-options-toggle-row, + .ml-options-number-row { + grid-template-columns: 1fr; + flex-direction: column; + align-items: flex-start; + } + .ml-options-toggle-control { + min-width: 0; + } + .ml-options-number-row { + grid-template-columns: 1fr; + } + .ml-options-input { + width: 100%; + } +} +.ml-download-target { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,0.06); + display: grid; + gap: 8px; +} +.ml-download-target-grid { + display: grid; + grid-template-columns: minmax(120px, 33%) minmax(0, 1fr); + grid-template-rows: auto auto; + gap: 10px; + align-items: start; +} +.ml-download-target-row { + display: grid; + gap: 6px; +} +@media (max-width: 720px) { + .ml-download-target-grid { + grid-template-columns: 1fr; + grid-template-rows: auto; + } +} +.ml-download-target-label { + display: flex; + align-items: flex-end; + font-size: 11px; + font-weight: 700; + color: var(--ml-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + line-height: 1.2; +} +.ml-download-target-input, +.ml-download-target-select { + display: block; + width: 100%; + height: 40px; + margin: 3px; + padding: 8px 10px; + background: rgba(255,255,255,0.04); + color: var(--ml-text); + border: 1px solid var(--ml-border); + border-radius: 10px; + font-size: 12px; + line-height: 1.2; + outline: none; + box-sizing: border-box; + vertical-align: top; +} +.ml-download-target-select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; +} +.ml-download-target-input:focus, +.ml-download-target-select:focus { + border-color: rgba(114,211,138,0.4); + box-shadow: 0 0 0 3px rgba(114,211,138,0.12); +} +.ml-download-target-wrap { + position: relative; + display: flex; + align-items: stretch; +} +@media (max-width: 720px) { + .ml-download-target-label { + min-height: 0; + } +} +.ml-download-target-list { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + max-height: 220px; + overflow: auto; + display: none; + z-index: var(--ml-z-notification); + background: linear-gradient(180deg, var(--ml-panel-bg-strong) 0%, var(--ml-card-bg) 100%); + border: 1px solid var(--ml-border-strong); + border-radius: 12px; + box-shadow: var(--ml-shadow-elevated); +} +.ml-download-target-option { + padding: 8px 10px; + cursor: pointer; + border-bottom: 1px solid rgba(255,255,255,0.04); + color: var(--ml-text); + font-size: 12px; +} +.ml-download-target-option:last-child { + border-bottom: none; +} +.ml-download-target-option:hover, +.ml-download-target-option.is-highlighted { + background: rgba(114,211,138,0.12); +} +.ml-search-source-select option, +.ml-download-target-select option { + background: var(--ml-panel-bg-strong); + color: var(--ml-text); +} + diff --git a/web/css/linker-shell.css b/web/css/linker-shell.css new file mode 100644 index 0000000..a18c282 --- /dev/null +++ b/web/css/linker-shell.css @@ -0,0 +1,409 @@ +.model-linker-backdrop { + position: fixed; + inset: 0; + background: transparent; + z-index: var(--ml-z-dropdown); + display: none; +} + +.model-linker-modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 1100px; + height: 700px; + max-width: 95vw; + max-height: 95vh; + min-width: 640px; + min-height: 420px; + padding: 0; + display: none; + flex-direction: column; + overflow: hidden; + resize: both; + z-index: var(--ml-z-dialog); + color: var(--input-text, #fff); + background: var(--comfy-menu-bg, #202020); + border: 2px solid var(--border-color, #555); + border-radius: var(--ml-ui-radius-sm); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8); +} + +.ml-context-menu { + position: fixed; + display: none; + z-index: var(--ml-z-context-menu); +} + +.ml-context-menu-item { + cursor: pointer; +} + +.ml-global-tooltip { + display: none; +} + +.ml-dialog-shell { + display: flex; + flex-direction: column; +} + +.ml-dialog-topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--ml-spacing-5); + border-bottom: 1px solid var(--border-color); + background: var(--comfy-menu-bg, #202020); +} + +.ml-dialog-brand, +.ml-dialog-controls, +.ml-dialog-control-row { + display: flex; + align-items: center; + gap: var(--ml-spacing-8); +} + +#model-linker-drag-handle { + display: flex; + align-items: center; + justify-content: center; + height: var(--ml-spacing-24); + padding: 0 var(--ml-spacing-6); + opacity: 0.9; + cursor: grab; + user-select: none; + border: 1px solid var(--border-color); + border-radius: var(--ml-radius-md); +} + +.ml-dialog-title { + margin: 0; + color: var(--input-text); + font-size: var(--ml-font-2xl); + font-weight: 600; +} + +.ml-window-btn { + display: flex; + align-items: center; + justify-content: center; + color: var(--input-text); + cursor: pointer; + border-radius: var(--ml-radius-md); +} + +.ml-window-btn--fullscreen { + min-width: var(--ml-spacing-32); + height: 30px; + padding: var(--ml-spacing-2) var(--ml-spacing-8); + font-size: var(--ml-font-xl); + background: none; + border: 1px solid var(--border-color); +} + +.ml-window-btn--close { + width: 30px; + height: 30px; + padding: 0; + font-size: var(--ml-font-4xl); + background: none; + border: none; +} + +#model-linker-body { + display: flex; + gap: var(--ml-spacing-12); + padding: var(--ml-spacing-10) var(--ml-spacing-5) var(--ml-spacing-5); + flex: 1; + min-height: 0; + align-items: stretch; + position: relative; + background: var(--ml-bg, #222); +} + +#model-linker-content { + overflow-y: auto; + flex: 1; + min-height: 0; + background: var(--ml-bg, #222); +} + +#model-linker-queue { + width: 320px; + min-width: 240px; + max-width: 70%; + display: flex; + flex-direction: column; + padding-left: var(--ml-spacing-12); + border-left: 1px solid var(--border-color); +} + +#model-linker-splitter { + width: var(--ml-spacing-6); + min-width: var(--ml-spacing-6); + opacity: 0.4; + cursor: col-resize; + background: var(--border-color); + border-radius: var(--ml-spacing-3); +} + +#queue-toggle-icon { + position: absolute; + top: 50%; + right: var(--ml-spacing-6); + z-index: 1000; + padding: var(--ml-spacing-2) var(--ml-spacing-6); + transform: translateY(-50%); + opacity: 0.9; + cursor: pointer; + background: var(--comfy-input-bg, #2f2f2f); + border: 1px solid var(--border-color); + border-radius: var(--ml-radius-md); +} + +.ml-queue-panel, +.ml-queue-stack { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; +} + +.ml-queue-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--ml-spacing-8); +} + +.ml-queue-title { + font-weight: 600; +} + +.ml-queue-actions { + display: flex; + gap: var(--ml-spacing-6); +} + +.ml-queue-list { + overflow-y: auto; + flex: 1; + min-height: 0; + padding: var(--ml-spacing-8); + background: var(--comfy-input-bg, #2f2f2f); + border: 1px solid var(--border-color); + border-radius: var(--ml-radius-md); +} + +.ml-queue-empty { + opacity: 0.7; +} + +.ml-queue-items { + display: flex; + flex-direction: column; + gap: var(--ml-spacing-6); +} + +.ml-queue-item { + padding: var(--ml-spacing-6); + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-color); + border-radius: var(--ml-radius-md); +} + +.ml-queue-item-title, +.ml-selected-label { + font-weight: 600; +} + +.ml-queue-item-meta, +.ml-queue-item-selection { + font-size: var(--ml-font-md); +} + +.ml-queue-item-meta { + opacity: 0.9; +} + +.ml-queue-item-actions, +.ml-selected-bar-inner { + display: flex; + align-items: center; + gap: var(--ml-spacing-8); + flex-wrap: wrap; +} + +.ml-queue-item-actions { + margin-top: var(--ml-spacing-6); +} + +.model-linker-selected { + display: none; +} + +.ml-selected-label { + color: #4caf50; +} + +.ml-selected-code { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.ml-inline-note-link { + cursor: pointer; + color: var(--ml-accent-blue); +} + +.ml-footer { + position: sticky; + bottom: 0; + background: var(--ml-bg, #222); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.ml-notification { + position: fixed; + top: 0; + left: 50%; + display: flex; + align-items: center; + gap: var(--ml-spacing-12); + min-width: 300px; + max-width: 600px; + padding: var(--ml-spacing-12) var(--ml-spacing-24); + color: #fff; + font-size: var(--ml-font-lg); + font-weight: 500; + text-align: center; + transform: translateX(-50%); + border-radius: 0 0 var(--ml-ui-radius-sm) var(--ml-ui-radius-sm); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: var(--ml-z-notification); + animation: ml-slide-down 0.3s ease; +} + +.ml-notification--success { + background: #28a745; +} + +.ml-notification--error { + background: #dc3545; +} + +.ml-notification--info { + background: #007acc; +} + +.ml-notification-icon { + font-size: var(--ml-font-2xl); + font-weight: 700; +} + +.ml-notification-close { + width: var(--ml-spacing-24); + height: var(--ml-spacing-24); + margin-left: auto; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; + color: #fff; + font-size: var(--ml-font-3xl); + cursor: pointer; + background: none; + border: none; + border-radius: var(--ml-radius-md); +} + +.ml-popup-auto-resolve-btn, +.model-linker-floating-button { + cursor: pointer; + font-weight: 600; + transition: all 0.2s ease; + white-space: nowrap; +} + +.ml-popup-auto-resolve-btn { + padding: var(--ml-spacing-6) var(--ml-spacing-12); + color: #fff; + font-size: var(--ml-font-md); + background: #4caf50; + border: none; + border-radius: var(--ml-radius-lg); +} + +.ml-popup-auto-resolve-btn:hover { + background: #45a049; +} + +.ml-popup-inline-actions { + display: flex; + align-items: center; + gap: var(--ml-spacing-16); + padding: 0 var(--ml-spacing-16); + margin-bottom: var(--ml-spacing-8); +} + +.ml-popup-actions-wrap { + display: flex; + justify-content: flex-end; + padding: 0 var(--ml-spacing-16); + margin-bottom: var(--ml-spacing-8); +} + +.ml-resolved-entry { + transition: all 0.3s ease; + background: rgba(76, 175, 80, 0.2); + border: 1px solid #4caf50; + border-radius: var(--ml-radius-lg); +} + +.ml-resolved-badge { + display: inline-flex; + align-items: center; + padding: var(--ml-spacing-4) var(--ml-spacing-12); + color: #fff; + font-size: var(--ml-font-md); + font-weight: 600; + background: #4caf50; + border-radius: var(--ml-radius-md); +} + +.model-linker-floating-button { + position: fixed; + top: var(--ml-spacing-10); + right: var(--ml-spacing-10); + z-index: var(--ml-z-floating-button); + padding: var(--ml-spacing-8) var(--ml-spacing-16); + color: var(--input-text, #fff); + font-size: var(--ml-font-lg); + background: var(--comfy-input-bg, #353535); + border: 2px solid var(--primary-color, #007acc); + border-radius: var(--ml-radius-lg); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.model-linker-floating-button:hover { + background: var(--primary-color, #007acc); + transform: scale(1.05); +} + +@keyframes ml-slide-down { + from { + opacity: 0; + transform: translateX(-50%) translateY(-100%); + } + + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} diff --git a/web/linker.js b/web/linker.js index cfc5844..7cda7e0 100644 --- a/web/linker.js +++ b/web/linker.js @@ -1,8052 +1,5681 @@ -/** - * ComfyUI Model Linker Extension - Frontend - * - * Provides a menu button and dialog interface for relinking missing models in workflows. - */ - -// Import ComfyUI APIs -// These paths are relative to the ComfyUI web directory +/** + * ComfyUI Model Linker Extension - Frontend + * + * Provides a menu button and dialog interface for relinking missing models in workflows. + */ + +// Import ComfyUI APIs +// These paths are relative to the ComfyUI web directory import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; import { $el, ComfyDialog } from "../../../scripts/ui.js"; - -class LinkerManagerDialog extends ComfyDialog { - constructor() { - super(); - this.currentWorkflow = null; - this.missingModels = []; - this.allModels = null; // list of all available models for dropdown - this.downloadDirectories = null; - this.capabilities = null; - this.downloadSubfolders = new Map(); - this.pendingResolutions = []; - this.pendingIndex = new Map(); // key -> index in pendingResolutions - this.activeDownloads = {}; // Track active downloads - this.searchResultCache = new Map(); - this.cachedAnalysisData = null; - this.cachedWorkflowSignature = null; - this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); - this.activeTabStorageKey = 'model_linker_active_tab'; - this.activeTab = this.restoreActiveTab(); // Default tab - this.fullscreen = false; - this._dragging = false; - this._dragStart = null; - this._analysisProgressToken = null; - this._locateAnimationFrame = null; - - // Inject global styles for the redesigned UI - this.injectStyles(); - +import { loadStylesWhenNeeded } from "./utils/css-loader.js"; + +class LinkerManagerDialog extends ComfyDialog { + constructor() { + super(); + this.currentWorkflow = null; + this.missingModels = []; + this.allModels = null; // list of all available models for dropdown + this.downloadDirectories = null; + this.capabilities = null; + this.downloadSubfolders = new Map(); + this.pendingResolutions = []; + this.pendingIndex = new Map(); // key -> index in pendingResolutions + this.activeDownloads = {}; // Track active downloads + this.searchResultCache = new Map(); + this.cachedAnalysisData = null; + this.cachedWorkflowSignature = null; + this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); + this.activeTabStorageKey = 'model_linker_active_tab'; + this.activeTab = this.restoreActiveTab(); // Default tab + this.fullscreen = false; + this._dragging = false; + this._dragStart = null; + this._analysisProgressToken = null; + this._locateAnimationFrame = null; + // Create backdrop overlay for click-outside-to-close this.backdrop = $el("div.model-linker-backdrop", { - parent: document.body, - style: { - position: "fixed", - top: "0", - left: "0", - width: "100vw", - height: "100vh", - backgroundColor: "transparent", - zIndex: "99998", - display: "none" - } + parent: document.body }); - + // Create context menu for model chips this.contextMenu = $el("div.ml-context-menu", { - parent: document.body, - style: { - position: "fixed", - display: "none", - zIndex: "100001" - } + parent: document.body }, [ $el("div.ml-context-menu-item", { - onclick: () => this.handleContextMenuAction('showInfo'), - style: { cursor: "pointer" } + onclick: () => this.handleContextMenuAction('showInfo') }, [ - $el("span.ml-context-menu-item-icon", { textContent: "ℹ" }), - $el("span", { textContent: "Show Info" }) - ]), + $el("span.ml-context-menu-item-icon", { textContent: "ℹ" }), + $el("span", { textContent: "Show Info" }) + ]), $el("div.ml-context-menu-divider"), $el("div.ml-context-menu-item", { - onclick: () => this.handleContextMenuAction('civitai'), - style: { cursor: "pointer" } + onclick: () => this.handleContextMenuAction('civitai') }, [ - $el("span.ml-context-menu-item-icon", { textContent: "🌐" }), - $el("span", { textContent: "Open in CivitAI" }) - ]), + $el("span.ml-context-menu-item-icon", { textContent: "🌐" }), + $el("span", { textContent: "Open in CivitAI" }) + ]), $el("div.ml-context-menu-divider"), $el("div.ml-context-menu-item", { - onclick: () => this.handleContextMenuAction('openFolder'), - style: { cursor: "pointer" } + onclick: () => this.handleContextMenuAction('openFolder') }, [ - $el("span.ml-context-menu-item-icon", { textContent: "📁" }), - $el("span", { textContent: "Open Containing Folder" }) - ]) - ]); - - this.tooltipElement = $el("div.ml-global-tooltip", { - parent: document.body, - style: { - display: "none" - } - }); - - // Selected model for context menu - this._contextMenuModel = null; - - // Create dialog element using $el - this.element = $el("div.comfy-modal", { + $el("span.ml-context-menu-item-icon", { textContent: "📁" }), + $el("span", { textContent: "Open Containing Folder" }) + ]) + ]); + + this.tooltipElement = $el("div.ml-global-tooltip", { parent: document.body }); + + // Selected model for context menu + this._contextMenuModel = null; + + // Create dialog element using $el + this.element = $el("div.comfy-modal.model-linker-modal", { id: "model-linker-modal", - parent: document.body, - style: { - position: "fixed", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - width: "1100px", - height: "700px", - maxWidth: "95vw", - maxHeight: "95vh", - backgroundColor: "var(--comfy-menu-bg, #202020)", - color: "var(--input-text, #ffffff)", - border: "2px solid var(--border-color, #555555)", - borderRadius: "8px", - padding: "0", - zIndex: "99999", - boxShadow: "0 4px 20px rgba(0,0,0,0.8)", - display: "none", - flexDirection: "column", - resize: "both", - overflow: "hidden", - minWidth: "640px", - minHeight: "420px" - } + parent: document.body }, [ - this.createHeader(), - this.createContent(), - this.createFooter() - ]); - - // Add click listener to hide context menu when clicking outside - this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e); - document.addEventListener('click', this.boundHandleContextMenuClick); - } - - /** - * Build stable cache key for a missing model entry - */ - getMissingSearchKey(missing) { - return `${missing.node_id}:${missing.widget_index}`; - } - - /** - * Get or initialize search state for a missing model entry - */ - getSearchState(missing) { - const key = this.getMissingSearchKey(missing); - if (!this.searchResultCache.has(key)) { - this.searchResultCache.set(key, { - selectedSource: 'all', - results: { - popular: null, - model_list: null, - huggingface: null, - civitai: null - }, - lastAttemptSources: [], - lastAttemptFound: null - }); - } - return this.searchResultCache.get(key); - } - - /** - * Merge new search results into cached per-source results. - * Empty responses do not delete previously found results. - */ - mergeSearchResults(existingResults = {}, newResults = {}) { - return { - popular: newResults.popular || existingResults.popular || null, - model_list: newResults.model_list || existingResults.model_list || null, - huggingface: newResults.huggingface || existingResults.huggingface || null, - civitai: newResults.civitai || existingResults.civitai || null, - lora_manager_archive: newResults.lora_manager_archive || existingResults.lora_manager_archive || null - }; - } - - getSearchIconHtml() { - return ``; - } - - getLocateIconHtml() { - return ``; - } - - showTooltip(target) { - if (!target || !this.tooltipElement) return; - const text = target.getAttribute('data-tooltip'); - if (!text) return; - - this.tooltipElement.textContent = text; - this.tooltipElement.style.display = 'block'; - - const rect = target.getBoundingClientRect(); - const tooltipRect = this.tooltipElement.getBoundingClientRect(); - const margin = 12; - const maxLeft = Math.max(margin, window.innerWidth - tooltipRect.width - margin); - let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); - left = Math.min(Math.max(margin, left), maxLeft); - - let top = rect.top - tooltipRect.height - 10; - if (top < margin) { - top = Math.min(window.innerHeight - tooltipRect.height - margin, rect.bottom + 10); - } - top = Math.max(margin, top); - - this.tooltipElement.style.left = `${Math.round(left)}px`; - this.tooltipElement.style.top = `${Math.round(top)}px`; - this.tooltipElement.setAttribute('data-visible', 'true'); - } - - hideTooltip() { - if (!this.tooltipElement) return; - this.tooltipElement.style.display = 'none'; - this.tooltipElement.removeAttribute('data-visible'); - } - - bindTooltips(container) { - if (!container) return; - - container.querySelectorAll('.ml-tooltip-badge').forEach((badge) => { - badge.addEventListener('mouseenter', () => this.showTooltip(badge)); - badge.addEventListener('focus', () => this.showTooltip(badge)); - badge.addEventListener('mouseleave', () => this.hideTooltip()); - badge.addEventListener('blur', () => this.hideTooltip()); - }); - } - - getValidTab(tab) { - return ['missing', 'loaded', 'options'].includes(tab) ? tab : 'missing'; - } - - restoreActiveTab() { - try { - return this.getValidTab(localStorage.getItem(this.activeTabStorageKey)); - } catch (error) { - console.warn('Model Linker: Failed to restore active tab:', error); - return 'missing'; - } - } - - persistActiveTab(tab) { - try { - localStorage.setItem(this.activeTabStorageKey, this.getValidTab(tab)); - } catch (error) { - console.warn('Model Linker: Failed to persist active tab:', error); - } - } - - /** - * Return true when at least one downloadable source was found - */ - hasSearchResults(data = {}) { - return !!(data.popular || data.model_list || data.huggingface || data.civitai || data.lora_manager_archive); - } - - /** - * Convert source ids to readable labels - */ - getSearchSourceLabel(source) { - const labels = { - all: 'Everything', - local: 'Local Database', - huggingface: 'HuggingFace', - civitai: 'CivitAI', - lora_manager_archive: 'LoRA Manager Archive' - }; - return labels[source] || source; - } - - /** - * Update source selector buttons and helper text for one card - */ - syncSearchSourceUi(missing, container) { - if (!container) return; - - const state = this.getSearchState(missing); - const selectEl = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`); - if (selectEl) { - selectEl.value = state.selectedSource; - } - } - - /** - * Set current search source for one card - */ - setSearchSource(missing, source, container) { - const state = this.getSearchState(missing); - state.selectedSource = source || 'all'; - this.syncSearchSourceUi(missing, container); - } - - renderDownloadSourceSection(missing, downloadSource) { - const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - const isFromWorkflow = downloadSource.url_source === 'workflow'; - const isCivitaiSource = downloadSource.source === 'civitai'; - const sourceLabels = { - popular: 'Popular Models', - model_list: 'Model Database', - huggingface: 'HuggingFace', - civitai: 'CivitAI', - workflow: 'Workflow' - }; - const sourceLabel = isFromWorkflow ? 'Workflow' : (sourceLabels[downloadSource.source] || 'Online'); - const downloadFilename = downloadSource.filename || filename; - const formattedDownloadName = this.formatFilename(downloadFilename, 45); - const confidenceDisplay = typeof downloadSource.confidence === 'number' - ? `${Math.round(downloadSource.confidence)}% match` - : ''; - - let sizeDisplay = ''; - if (downloadSource.size) { - sizeDisplay = typeof downloadSource.size === 'number' - ? this.formatBytes(downloadSource.size) - : downloadSource.size; - } - - const modelCardUrl = downloadSource.model_url || this.getModelCardUrl(downloadSource.url); - - let html = `
${fallbackText}
`; - const textOnly = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); - - descEl.innerHTML = finalHtml; - descEl.classList.remove('is-expanded'); - - const shouldCollapse = textOnly.length > 520 || finalHtml.length > 900; - if (actionsEl) { - actionsEl.style.display = shouldCollapse ? '' : 'none'; - } - if (toggleBtn) { - toggleBtn.textContent = 'Show more'; - } - if (!shouldCollapse) { - descEl.classList.add('is-expanded'); - } - - const row = descEl.closest('tr'); - if (row) row.style.display = ''; - } else { - const row = descEl.closest('tr'); - if (row) row.style.display = 'none'; - } - } - - // Update images - this.updateInfoDialogImages(dialog, data.images || data.modelImages || []); - } - - /** - * Update images in the info dialog - */ - updateInfoDialogImages(dialog, images) { - const imagesContainer = dialog.querySelector('.ml-info-images'); - if (!imagesContainer) return; - if (!images.length) { - imagesContainer.innerHTML = ''; - return; - } - - const visibleImages = images.slice(0, 8).filter(img => img?.url); - - const renderImageCard = (img) => { - const captionParts = []; - if (img.civitaiUrl) { - captionParts.push(`civitai`); - } - if (img.seed) captionParts.push(` ${this.escapeHtml(img.seed)}`); - if (img.steps) captionParts.push(` ${this.escapeHtml(img.steps)}`); - if (img.cfg) captionParts.push(` ${this.escapeHtml(img.cfg)}`); - if (img.sampler) captionParts.push(` ${this.escapeHtml(img.sampler)}`); - if (img.model) captionParts.push(` ${this.escapeHtml(this.truncateText(img.model, 72))}`); - if (img.positive) captionParts.push(` ${this.escapeHtml(this.truncateText(img.positive, 180))}`); - if (img.negative) captionParts.push(` ${this.escapeHtml(this.truncateText(img.negative, 180))}`); - - return ` -${fallbackText}
`; + const textOnly = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + + descEl.innerHTML = finalHtml; + descEl.classList.remove('is-expanded'); + + const shouldCollapse = textOnly.length > 520 || finalHtml.length > 900; + if (actionsEl) { + actionsEl.style.display = shouldCollapse ? '' : 'none'; + } + if (toggleBtn) { + toggleBtn.textContent = 'Show more'; + } + if (!shouldCollapse) { + descEl.classList.add('is-expanded'); + } + + const row = descEl.closest('tr'); + if (row) row.style.display = ''; + } else { + const row = descEl.closest('tr'); + if (row) row.style.display = 'none'; + } + } + + // Update images + this.updateInfoDialogImages(dialog, data.images || data.modelImages || []); + } + + /** + * Update images in the info dialog + */ + updateInfoDialogImages(dialog, images) { + const imagesContainer = dialog.querySelector('.ml-info-images'); + if (!imagesContainer) return; + if (!images.length) { + imagesContainer.innerHTML = ''; + return; + } + + const visibleImages = images.slice(0, 8).filter(img => img?.url); + + const renderImageCard = (img) => { + const captionParts = []; + if (img.civitaiUrl) { + captionParts.push(`civitai`); + } + if (img.seed) captionParts.push(` ${this.escapeHtml(img.seed)}`); + if (img.steps) captionParts.push(` ${this.escapeHtml(img.steps)}`); + if (img.cfg) captionParts.push(` ${this.escapeHtml(img.cfg)}`); + if (img.sampler) captionParts.push(` ${this.escapeHtml(img.sampler)}`); + if (img.model) captionParts.push(` ${this.escapeHtml(this.truncateText(img.model, 72))}`); + if (img.positive) captionParts.push(` ${this.escapeHtml(this.truncateText(img.positive, 180))}`); + if (img.negative) captionParts.push(` ${this.escapeHtml(this.truncateText(img.negative, 180))}`); + + return ` +Loading loaded models...
'; - - try { - const workflow = this.getCurrentWorkflow(); - if (!workflow) { - this.contentElement.innerHTML = 'No workflow loaded. Please load a workflow first.
'; - return; - } - - const response = await api.fetchApi('/model_linker/loaded', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workflow }) - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = await response.json(); - this.displayLoadedModels(this.contentElement, data); - - } catch (error) { - console.error('Model Linker: Error loading loaded models:', error); - if (this.contentElement) { - this.contentElement.innerHTML = `Error: ${error.message}
`; - } - } - } - - displayLoadedModels(container, data) { - const loadedModels = data.loaded_models || []; - const total = data.total || 0; - - if (total === 0) { - container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info'); - return; - } - - const categoryDisplayNames = { - 'checkpoints': 'checkpoint', - 'loras': 'lora', - 'vae': 'vae', - 'controlnet': 'controlnet', - 'embeddings': 'embedding', - 'upscale_models': 'upscale_model', - 'diffusion_models': 'unet', - 'clip': 'clip', - 'clip_vision': 'clip_vision', - 'hypernetworks': 'hypernetwork' - }; - - const byCategory = {}; - - for (const model of loadedModels) { - const cat = model.category || 'unknown'; - if (!byCategory[cat]) { - byCategory[cat] = { active: [], inactive: [] }; - } - - // Determine if model is active or inactive - // For LoraLoaderV2/LoraManager: check model.active field - // For other nodes: check model.connected field (false means not connected or bypassed) - let isActive = true; - if (model.is_lora_v2) { - // For text-based lora loaders, check both active flag AND connected status - isActive = model.active !== false && model.connected !== false; - } else { - // For regular nodes, check connected status - isActive = model.connected !== false; - } - - if (isActive) { - byCategory[cat].active.push(model); - } else { - byCategory[cat].inactive.push(model); - } - } - - const activeCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.active.length, 0); - const inactiveCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.inactive.length, 0); - - const buildCategoryStrings = (filter) => { - const result = {}; - for (const [category, modelsObj] of Object.entries(byCategory)) { - const displayCat = categoryDisplayNames[category] || category; - const models = filter === 'active' ? modelsObj.active : filter === 'inactive' ? modelsObj.inactive : [...modelsObj.active, ...modelsObj.inactive]; - const parts = models.map(model => { - const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - let name = fullName; - if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i)) { - name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - } - const strength = model.strength !== null && model.strength !== undefined - ? model.strength.toFixed(2) - : '1.00'; - return `<${displayCat}:${name}:${strength}>`; - }); - result[category] = parts.join(' '); - } - return Object.values(result).join(' '); - }; - - const activeString = buildCategoryStrings('active'); - const inactiveString = buildCategoryStrings('inactive'); - const allString = buildCategoryStrings('all'); - - const self = this; - - let html = ` -LoraManager / LoraLoaderV2 nodes distinguish active/inactive
-${allString}
-
- Loading loaded models...
'; + + try { + const workflow = this.getCurrentWorkflow(); + if (!workflow) { + this.contentElement.innerHTML = 'No workflow loaded. Please load a workflow first.
'; + return; + } + + const response = await api.fetchApi('/model_linker/loaded', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow }) + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = await response.json(); + this.displayLoadedModels(this.contentElement, data); + + } catch (error) { + console.error('Model Linker: Error loading loaded models:', error); + if (this.contentElement) { + this.contentElement.innerHTML = `Error: ${error.message}
`; + } + } + } + + displayLoadedModels(container, data) { + const loadedModels = data.loaded_models || []; + const total = data.total || 0; + + if (total === 0) { + container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info'); + return; + } + + const categoryDisplayNames = { + 'checkpoints': 'checkpoint', + 'loras': 'lora', + 'vae': 'vae', + 'controlnet': 'controlnet', + 'embeddings': 'embedding', + 'upscale_models': 'upscale_model', + 'diffusion_models': 'unet', + 'clip': 'clip', + 'clip_vision': 'clip_vision', + 'hypernetworks': 'hypernetwork' + }; + + const byCategory = {}; + + for (const model of loadedModels) { + const cat = model.category || 'unknown'; + if (!byCategory[cat]) { + byCategory[cat] = { active: [], inactive: [] }; + } + + // Determine if model is active or inactive + // For LoraLoaderV2/LoraManager: check model.active field + // For other nodes: check model.connected field (false means not connected or bypassed) + let isActive = true; + if (model.is_lora_v2) { + // For text-based lora loaders, check both active flag AND connected status + isActive = model.active !== false && model.connected !== false; + } else { + // For regular nodes, check connected status + isActive = model.connected !== false; + } + + if (isActive) { + byCategory[cat].active.push(model); + } else { + byCategory[cat].inactive.push(model); + } + } + + const activeCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.active.length, 0); + const inactiveCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.inactive.length, 0); + + const buildCategoryStrings = (filter) => { + const result = {}; + for (const [category, modelsObj] of Object.entries(byCategory)) { + const displayCat = categoryDisplayNames[category] || category; + const models = filter === 'active' ? modelsObj.active : filter === 'inactive' ? modelsObj.inactive : [...modelsObj.active, ...modelsObj.inactive]; + const parts = models.map(model => { + const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; + let name = fullName; + if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i)) { + name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); + } + const strength = model.strength !== null && model.strength !== undefined + ? model.strength.toFixed(2) + : '1.00'; + return `<${displayCat}:${name}:${strength}>`; + }); + result[category] = parts.join(' '); + } + return Object.values(result).join(' '); + }; + + const activeString = buildCategoryStrings('active'); + const inactiveString = buildCategoryStrings('inactive'); + const allString = buildCategoryStrings('all'); + + const self = this; + + let html = ` +LoraManager / LoraLoaderV2 nodes distinguish active/inactive
+${allString}
+
+ ${orig}${label}${label}No workflow loaded. Please load a workflow first.
'; - return; - } - - const workflowSignature = this.getWorkflowSignature(workflow); - if ( - workflowSignature && - this.cachedWorkflowSignature === workflowSignature && - this.cachedAnalysisData - ) { - this.displayMissingModels(this.contentElement, this.cachedAnalysisData); - this.reconnectActiveDownloads(); - return; - } - - const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - this._analysisProgressToken = analysisId; - this.contentElement.innerHTML = this.renderAnalysisProgress({ - status: 'starting', - message: 'Starting analysis...', - current: 0, - total: 0 - }); - - // Call analyze endpoint - const progressPromise = this.pollAnalysisProgress(analysisId, analysisId); - const response = await api.fetchApi('/model_linker/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workflow, analysis_id: analysisId }) - }); - this._analysisProgressToken = null; - await progressPromise; - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = await response.json(); - this.cachedWorkflowSignature = workflowSignature; - this.cachedAnalysisData = data; - this.searchResultCache.clear(); - this.displayMissingModels(this.contentElement, data); - - // Reconnect any active downloads to their new progress divs - this.reconnectActiveDownloads(); - - } catch (error) { - this._analysisProgressToken = null; - console.error('Model Linker: Error loading workflow data:', error); - if (this.contentElement) { - this.contentElement.innerHTML = `Error: ${error.message}
`; - } - } - } - - /** - * Get current workflow from ComfyUI - */ - getCurrentWorkflow() { - // Try to get workflow from app - if (app?.graph) { - try { - // Use ComfyUI's workflow serialization - const workflow = app.graph.serialize(); - return workflow; - } catch (e) { - console.warn('Model Linker: Could not serialize workflow from graph:', e); - } - } - return null; - } - - /** - * Locate and focus a node in the ComfyUI canvas - */ - locateNodeInGraph(nodeId) { - try { - if (!app?.graph) { - this.showNotification('Cannot locate node - graph not available', 'error'); - return; - } - - // Find the node in the graph - const node = app.graph.getNodeById(nodeId); - if (!node) { - this.showNotification(`Node #${nodeId} not found in graph`, 'error'); - return; - } - - let locatedWithAnimation = false; - - // Focus on the node in the canvas with animated panning when possible - if (app.canvas?.ds && app.canvas?.canvas) { - locatedWithAnimation = this.animateCanvasToNode(node); - } - - if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) { - // Alternative method for older versions - const canvasNode = app.graph._nodes.get(nodeId); - if (canvasNode && canvasNode.setSelected && canvasNode.graph) { - canvasNode.setSelected(true); - // Scroll to node - app.canvas.scrollToNode(canvasNode); - } - } else if (!locatedWithAnimation && app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') { - // Alternative for other versions - app.ui.nodeGraph.scrollToNode(node); - } else if (!locatedWithAnimation && app.canvas && typeof app.canvas.centerOnNode === 'function') { - // Final fallback for versions where animation path is unavailable - app.canvas.centerOnNode(node); - } - - // Also try to flash/select the node - // Deselect all nodes first - if (app.graph && app.graph.nodes) { - app.graph.nodes.forEach(n => { - if (n.selected) n.selected = false; - }); - } - - // Select and highlight our node - node.selected = true; - - this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info'); - } catch (e) { - console.error('Model Linker: Error locating node:', e); - this.showNotification('Error locating node: ' + e.message, 'error'); - } - } - - animateCanvasToNode(node) { - const canvas = app?.canvas; - const ds = canvas?.ds; - const htmlCanvas = canvas?.canvas; - if (!canvas || !ds || !htmlCanvas) return false; - - const rect = node.boundingRect || [node.pos?.[0] || 0, node.pos?.[1] || 0, node.size?.[0] || 0, node.size?.[1] || 0]; - const centerX = rect[0] + (rect[2] || 0) / 2; - const centerY = rect[1] + (rect[3] || 0) / 2; - const startScale = Number.isFinite(ds.scale) && ds.scale > 0 ? ds.scale : 1; - const dpr = window.devicePixelRatio || 1; - const canvasWidth = htmlCanvas.width || htmlCanvas.clientWidth || 0; - const canvasHeight = htmlCanvas.height || htmlCanvas.clientHeight || 0; - - if (!canvasWidth || !canvasHeight) return false; - - const viewportWidth = canvasWidth / dpr; - const viewportHeight = canvasHeight / dpr; - const nodeWidth = Math.max(rect[2] || 0, 1); - const nodeHeight = Math.max(rect[3] || 0, 1); - const paddingX = 140; - const paddingY = 96; - const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2)); - const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2)); - const targetScale = Math.max(0.15, Math.min(1, fitScaleX, fitScaleY)); - - const getCenteredOffset = (scale) => { - const viewWidth = canvasWidth / (scale * dpr); - const viewHeight = canvasHeight / (scale * dpr); - return { - x: -centerX + (viewWidth / 2), - y: -centerY + (viewHeight / 2) - }; - }; - - const startOffset = { - x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x, - y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y - }; - const targetOffset = getCenteredOffset(targetScale); - - if (this._locateAnimationFrame) { - cancelAnimationFrame(this._locateAnimationFrame); - this._locateAnimationFrame = null; - } - - const durationMs = 380; - const startTime = performance.now(); - const easeInOutCubic = (t) => t < 0.5 - ? 4 * t * t * t - : 1 - Math.pow(-2 * t + 2, 3) / 2; - - const tick = (now) => { - const progress = Math.min(1, (now - startTime) / durationMs); - const eased = easeInOutCubic(progress); - - ds.scale = startScale + ((targetScale - startScale) * eased); - ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased); - ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased); - canvas.setDirty?.(true, true); - app.graph?.setDirtyCanvas?.(true, true); - - if (progress < 1) { - this._locateAnimationFrame = requestAnimationFrame(tick); - } else { - this._locateAnimationFrame = null; - } - }; - - this._locateAnimationFrame = requestAnimationFrame(tick); - return true; - } - - /** - * Reconnect active downloads to their new progress div elements after UI refresh - */ - reconnectActiveDownloads() { - if (!this.contentElement) return; - - for (const [downloadId, info] of Object.entries(this.activeDownloads)) { - const { missing } = info; - if (!missing) continue; - - // Find the new progress div by ID - const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; - const newProgressDiv = this.contentElement.querySelector(`#${progressId}`); - const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); - - if (newProgressDiv) { - // Update the reference - info.progressDiv = newProgressDiv; - info.downloadBtn = newDownloadBtn; - - // Show that download is in progress - newProgressDiv.style.display = 'block'; - newProgressDiv.innerHTML = ` -${label}`;
- html += `No workflow loaded. Please load a workflow first.
'; + return; + } + + const workflowSignature = this.getWorkflowSignature(workflow); + if ( + workflowSignature && + this.cachedWorkflowSignature === workflowSignature && + this.cachedAnalysisData + ) { + this.displayMissingModels(this.contentElement, this.cachedAnalysisData); + this.reconnectActiveDownloads(); + return; + } + + const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + this._analysisProgressToken = analysisId; + this.contentElement.innerHTML = this.renderAnalysisProgress({ + status: 'starting', + message: 'Starting analysis...', + current: 0, + total: 0 + }); + + // Call analyze endpoint + const progressPromise = this.pollAnalysisProgress(analysisId, analysisId); + const response = await api.fetchApi('/model_linker/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflow, analysis_id: analysisId }) + }); + this._analysisProgressToken = null; + await progressPromise; + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = await response.json(); + this.cachedWorkflowSignature = workflowSignature; + this.cachedAnalysisData = data; + this.searchResultCache.clear(); + this.displayMissingModels(this.contentElement, data); + + // Reconnect any active downloads to their new progress divs + this.reconnectActiveDownloads(); + + } catch (error) { + this._analysisProgressToken = null; + console.error('Model Linker: Error loading workflow data:', error); + if (this.contentElement) { + this.contentElement.innerHTML = `Error: ${error.message}
`; + } + } + } + + /** + * Get current workflow from ComfyUI + */ + getCurrentWorkflow() { + // Try to get workflow from app + if (app?.graph) { + try { + // Use ComfyUI's workflow serialization + const workflow = app.graph.serialize(); + return workflow; + } catch (e) { + console.warn('Model Linker: Could not serialize workflow from graph:', e); + } + } + return null; + } + + /** + * Locate and focus a node in the ComfyUI canvas + */ + locateNodeInGraph(nodeId) { + try { + if (!app?.graph) { + this.showNotification('Cannot locate node - graph not available', 'error'); + return; + } + + // Find the node in the graph + const node = app.graph.getNodeById(nodeId); + if (!node) { + this.showNotification(`Node #${nodeId} not found in graph`, 'error'); + return; + } + + let locatedWithAnimation = false; + + // Focus on the node in the canvas with animated panning when possible + if (app.canvas?.ds && app.canvas?.canvas) { + locatedWithAnimation = this.animateCanvasToNode(node); + } + + if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) { + // Alternative method for older versions + const canvasNode = app.graph._nodes.get(nodeId); + if (canvasNode && canvasNode.setSelected && canvasNode.graph) { + canvasNode.setSelected(true); + // Scroll to node + app.canvas.scrollToNode(canvasNode); + } + } else if (!locatedWithAnimation && app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') { + // Alternative for other versions + app.ui.nodeGraph.scrollToNode(node); + } else if (!locatedWithAnimation && app.canvas && typeof app.canvas.centerOnNode === 'function') { + // Final fallback for versions where animation path is unavailable + app.canvas.centerOnNode(node); + } + + // Also try to flash/select the node + // Deselect all nodes first + if (app.graph && app.graph.nodes) { + app.graph.nodes.forEach(n => { + if (n.selected) n.selected = false; + }); + } + + // Select and highlight our node + node.selected = true; + + this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info'); + } catch (e) { + console.error('Model Linker: Error locating node:', e); + this.showNotification('Error locating node: ' + e.message, 'error'); + } + } + + animateCanvasToNode(node) { + const canvas = app?.canvas; + const ds = canvas?.ds; + const htmlCanvas = canvas?.canvas; + if (!canvas || !ds || !htmlCanvas) return false; + + const rect = node.boundingRect || [node.pos?.[0] || 0, node.pos?.[1] || 0, node.size?.[0] || 0, node.size?.[1] || 0]; + const centerX = rect[0] + (rect[2] || 0) / 2; + const centerY = rect[1] + (rect[3] || 0) / 2; + const startScale = Number.isFinite(ds.scale) && ds.scale > 0 ? ds.scale : 1; + const dpr = window.devicePixelRatio || 1; + const canvasWidth = htmlCanvas.width || htmlCanvas.clientWidth || 0; + const canvasHeight = htmlCanvas.height || htmlCanvas.clientHeight || 0; + + if (!canvasWidth || !canvasHeight) return false; + + const viewportWidth = canvasWidth / dpr; + const viewportHeight = canvasHeight / dpr; + const nodeWidth = Math.max(rect[2] || 0, 1); + const nodeHeight = Math.max(rect[3] || 0, 1); + const paddingX = 140; + const paddingY = 96; + const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2)); + const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2)); + const targetScale = Math.max(0.15, Math.min(1, fitScaleX, fitScaleY)); + + const getCenteredOffset = (scale) => { + const viewWidth = canvasWidth / (scale * dpr); + const viewHeight = canvasHeight / (scale * dpr); + return { + x: -centerX + (viewWidth / 2), + y: -centerY + (viewHeight / 2) + }; + }; + + const startOffset = { + x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x, + y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y + }; + const targetOffset = getCenteredOffset(targetScale); + + if (this._locateAnimationFrame) { + cancelAnimationFrame(this._locateAnimationFrame); + this._locateAnimationFrame = null; + } + + const durationMs = 380; + const startTime = performance.now(); + const easeInOutCubic = (t) => t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2; + + const tick = (now) => { + const progress = Math.min(1, (now - startTime) / durationMs); + const eased = easeInOutCubic(progress); + + ds.scale = startScale + ((targetScale - startScale) * eased); + ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased); + ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased); + canvas.setDirty?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + + if (progress < 1) { + this._locateAnimationFrame = requestAnimationFrame(tick); + } else { + this._locateAnimationFrame = null; + } + }; + + this._locateAnimationFrame = requestAnimationFrame(tick); + return true; + } + + /** + * Reconnect active downloads to their new progress div elements after UI refresh + */ + reconnectActiveDownloads() { + if (!this.contentElement) return; + + for (const [downloadId, info] of Object.entries(this.activeDownloads)) { + const { missing } = info; + if (!missing) continue; + + // Find the new progress div by ID + const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`; + const newProgressDiv = this.contentElement.querySelector(`#${progressId}`); + const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`); + + if (newProgressDiv) { + // Update the reference + info.progressDiv = newProgressDiv; + info.downloadBtn = newDownloadBtn; + + // Show that download is in progress + newProgressDiv.style.display = 'block'; + newProgressDiv.innerHTML = ` +${label}`;
+ html += `