Compare commits

..

11 Commits

Author SHA1 Message Date
Will Miao 93fc28b499 chore(ui): rebuild vue-widgets bundle
Rebuilt from the preceding three commits' sources:

- active-filters chip and its dead settings-toggled broadcast removed
  (the built bundle also no longer embeds the scripts/app.js test shim
  that the chip's settings.js import used to pull in)
- scrollbar inset re-measured on programmatic value changes
- app/api bindings canonicalized to "../../../scripts/*" externals and
  settings.js no longer inlined (bound at runtime via "../settings.js"
  to the vanilla module instance), per the new build guard
2026-09-04 19:02:36 +08:00
Will Miao 7afed1a14b fix(ui): cross-link right-click menu in active-filters settings tooltip
The loramanager.lora_active_filters_autocomplete tooltip only mentioned
the /activefilters and /noactivefilters commands, while the prompt-node
tag-autocomplete tooltip cross-links every toggle entry point (typing in
the node and the node's right-click menu). 6ba64ebb added the right-click
menu entry without extending the tooltip to match; align the wording with
the established pattern.
2026-09-04 19:02:31 +08:00
Will Miao e6f5142e48 fix(ui): re-measure autocomplete scrollbar inset on programmatic value changes
The --lm-vscrollbar-width inset from 634ea7f2 was only refreshed on input
events, mount and mode changes. Programmatic value updates (widget.setValue
from "send lora to workflow", external value-change events) change the
textarea content without an input event, leaving the corner clear (x)
button overlapping a freshly appeared classic scrollbar until the next
keystroke. Mount-time pending value replay was already covered.

- onExternalValueChange and widget.onSetValue now call
  updateVScrollbarWidth() alongside the hasText update
- tests: cover both paths by overriding textarea metrics to an overflowing
  state and asserting the 15px gutter lands in the CSS var
2026-09-04 19:02:31 +08:00
Will Miao 87f05fb66c build(vue-widgets): keep shared runtime modules external in the widget bundle
Guard against the inlined-shim bug class that broke the removed
active-filters chip: importing web/comfyui/* modules from widget source
inlines them into lora-manager-widgets.js, and their own relative imports
then resolve against the repo filesystem at build time instead of the
vanilla files' runtime URL layout.

A resolveId plugin (enforce: pre) now returns explicit external markers:

- scripts/app.js and scripts/api.js imported at any "../../scripts/*"
  depth are rewritten to the canonical "../../../scripts/*" specifier so
  every app/api binding in the bundle is the real ComfyUI module. The
  repo-root scripts/app.js is a unit-test shim (in-memory settings store)
  and must never be bundled; the canonical depth is the only one that
  resolves from the emitted bundle's served location.
- web/comfyui/settings.js is externalized to "../settings.js" so the
  bundle binds to the SAME vanilla module instance the ComfyUI extension
  loader already runs - real settings store, registerExtension side
  effect executed exactly once, no duplicated module state.

A companion plugin warns on any web/comfyui/* import from widget source,
since an inlined copy still duplicates module-level side effects.

Notes from validating the mechanism: rollup output.paths resolves
returned paths to absolute filesystem locations (rejected), and a depth
regex inside rollupOptions.external matches raw specifiers before
resolveId hooks run and would emit the shim-relative depth verbatim
(rejected) - hence explicit { id, external: true } returns.
2026-09-04 19:02:25 +08:00
Will Miao cf64e5baa8 fix(ui): remove per-node active-filters chip from loras widgets
The indicator chip added for /activefilters discoverability was broken by
design of its import path: AutocompleteTextWidget.vue imported
web/comfyui/settings.js into the vue-widgets bundle, and settings.js's
"../../scripts/app.js" import resolved at build time to the repo-root test
shim (scripts/app.js, an in-memory settings store). The chip therefore read
and wrote an orphaned in-memory Map: clicking it flipped only its own
visual state and never touched the real ComfyUI setting that
autocomplete.js consults (use_active_filters query param).

Beyond the defect, a persistent per-node control for a global persisted
setting misleads users and needs cross-instance sync machinery, which the
footer hint, slash commands, right-click menu entry and settings dialog
already cover.

- AutocompleteTextWidget.vue: remove the chip button, its state/handlers,
  the settings.js import (the shim-inlining pathway) and all chip styles
- AutocompleteTextWidget.test.ts: drop the chip indicator describe block
  and the settings.js module mock; beforeEach import no longer needed
- settings.js: drop the lora-manager:setting-toggled window broadcast and
  its export — the chip was its only consumer, so every
  setLoraManagerSettingValue write no longer dispatches a dead event
- autocomplete.activeFilters.test.js: drop the broadcast assertion test
- loraLoader.activeFiltersMenu.test.js: drop SETTING_TOGGLED_EVENT_NAME
  from the settings.js mock

Discoverability of /activefilters // /noactivefilters is unchanged:
command-list footer, first-run hint, node context menu, settings dialog.
2026-09-04 19:02:16 +08:00
Will Miao 634ea7f299 fix(ui): keep autocomplete corner buttons clear of the textarea scrollbar
The absolutely-positioned clear (x) and active-filters filter buttons sit at
the textarea's right edge, so when content overflows and a classic
(non-overlay) vertical scrollbar appears the buttons overlap it. Measure the
scrollbar gutter (offsetWidth - clientWidth) when content overflows and
expose it as --lm-vscrollbar-width on .input-wrapper; the buttons' right is
now calc(base + var) so they shift left of the scrollbar only while one is
present (0 otherwise, incl. overlay-scrollbar platforms).

Refreshed on input, mount, canvas/Vue-DOM mode change and via a ResizeObserver
on the textarea (widget resize). Rebuilt the vue-widgets bundle.
2026-09-04 12:41:31 +08:00
Will Miao 6ba64ebb3c feat(ui): improve /activefilters discoverability on loras nodes
Mirror the /noautocomplete discoverability pattern for the loras\nactive-filters search toggle:\n\n- autocomplete.js: extend the slash-command-list footer and the\n  one-time first-run hint to loras nodes, advertising\n  /activefilters and /noactivefilters\n- lora_loader.js: add an 'Active Filters Search: ON/OFF' entry to the\n  right-click menu of all loras-autocomplete node classes\n- settings.js: broadcast a 'lora-manager:setting-toggled' window event\n  on every setLoraManagerSettingValue write\n- AutocompleteTextWidget.vue: add a persistent filter indicator chip\n  (loras mode only) that reflects and toggles the setting and stays in\n  sync via the setting-toggled event\n- tests: footer/hint/event coverage, context-menu tests for all four\n  node classes, widget indicator tests; rebuild vue-widgets bundle
2026-09-03 22:42:45 +08:00
Will Miao 03569c62df feat(ui): point help new-content indicator at the updated tabs and elements
- Replace timestamp comparison (help_last_viewed vs a hardcoded date) with
  a content-version marker (data-help-content-version) read from the
  rendered modal markup, so badge state always reflects the content
  actually served
- Only mark content as viewed when the modal is opened while it contains
  new content; opening a stale pre-upgrade page no longer suppresses the
  badge after a refresh
- Flag the Replay Tutorial button itself with a 'New' chip (hidden by
  default, one-time glow animation) and scroll it into view when
  revealed; tab-level dots now mark getting-started and shortcuts
  instead of documentation
- Translate help.newContentBadge into all 9 locales, reusing the
  established help.documentation.newBadge renderings
- Add HelpManager content-version unit tests (12 cases)
2026-09-03 21:40:18 +08:00
Will Miao a61840b366 i18n: translate onboarding/shortcuts/trigger-word keys into all 9 locales
- Fill all 41 [TODO: Translate] placeholders per locale (new onboarding
  steps, Shortcuts cheat-sheet tab, trigger-word copy/edit tooltip)
- Retranslate stale onboarding bulk/contextMenu step contents to match
  the updated en.json source
- Follows docs/i18n-translation-guidelines.md term maps, register, and
  punctuation rules; HTML tags and key names preserved verbatim
2026-09-03 19:20:54 +08:00
Will Miao 726fc178f1 feat(ui): add R/F/D action shortcuts and unify keycap hint style
- Bind R=refresh, F=fetch metadata, D=download in PageControls via
  eventManager (plain letters only, skipped while typing or when a
  modal is open); triggers reuse the buttons' existing click handlers
- Show key-hint chips on the refresh/fetch/download/bulk toolbar
  buttons; convert the bulk chip to a semantic <kbd>
- Redesign shortcut hints as a neutral theme-adaptive keycap:
  --shortcut-* variables in base.css now derive from --text-muted
  with a bottom-edge shadow, shared by the toolbar chips, the header
  search cue, the help-modal cheat sheet, and onboarding key hints
- Add shared isTypingContext() helper to uiHelpers
- Add an Actions group (R/F/D) to the Shortcuts cheat-sheet tab

Verified with vitest (926 passing, incl. 6 new shortcut cases) and a
sandboxed E2E run in real Chrome (light/dark rendering, hover state,
'?' opening the Shortcuts tab, clean console)
2026-09-03 19:10:16 +08:00
Will Miao 8260bd022d feat(ui): improve discoverability of hidden interactions
- Expand onboarding tour from 8 to 11 steps: marquee drag-select,
  drag card to sidebar folder, and the three context menus
  (card / bulk / global); enrich bulk-mode step with range-select
  and exit tips
- Add Replay Tutorial button to help modal Getting Started tab
- Add Shortcuts cheat-sheet tab to help modal, opened directly via
  the '?' key when not typing
- Fix trigger-word tooltip to mention double-click to edit
- Keep checkpoint/embedding send tooltips truthful (no replace mode)

Sync new i18n keys to all locales (placeholders pending translation)
2026-09-03 18:16:00 +08:00
36 changed files with 2132 additions and 272 deletions
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "Massenoperationen",
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken. Wählen Sie mehrere Modelle aus und führen Sie Stapeloperationen durch. Mit <span class=\"onboarding-shortcut\">Strg+A</span> können Sie alle sichtbaren Modelle auswählen."
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken, um mehrere Modelle auszuwählen und Stapeloperationen durchzuführen.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> wählt alle sichtbaren Modelle aus, <span class=\"onboarding-shortcut\">Shift+Click</span> wählt einen Bereich aus.<br>• <span class=\"onboarding-shortcut\">Esc</span> oder ein Klick auf einen leeren Bereich verlässt den Massenmodus."
},
"searchOptions": {
"title": "Suchoptionen",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "Kontextmenü",
"content": "<strong>Rechtsklick</strong> auf eine Modellkarte öffnet ein Kontextmenü mit weiteren Aktionen."
"content": "<strong>Rechtsklick</strong> auf eine beliebige Modellkarte öffnet ein Kontextmenü mit Kartenaktionen wie Verschieben, Löschen oder Bearbeiten von Metadaten."
},
"marqueeSelect": {
"title": "Durch Ziehen auswählen",
"content": "Halten Sie die <strong>linke Maustaste</strong> auf einem leeren Bereich des Rasters gedrückt und ziehen Sie, um einen Auswahlrahmen aufzuziehen, der mehrere Karten gleichzeitig auswählt."
},
"dragToSidebar": {
"title": "Organisieren durch Ziehen",
"content": "Ziehen Sie eine Modellkarte auf einen Ordner in der Seitenleiste, um die Datei dorthin zu verschieben. Dies funktioniert auch mit mehreren ausgewählten Karten im Massenmodus."
},
"contextMenus": {
"title": "Weitere Kontextmenüs",
"content": "<strong>Rechtsklick auf eine ausgewählte Karte</strong> im Massenmodus öffnet die Massenaktionen. <strong>Rechtsklick auf einen leeren Bereich</strong> der Seite öffnet globale Aktionen wie das Prüfen auf Updates und das Verwalten ausgeschlossener Modelle."
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
"editWord": "Trigger Word bearbeiten",
"editPlaceholder": "Trigger Word bearbeiten",
"copyWord": "Trigger Word kopieren",
"copyOrEditWord": "Klicken zum Kopieren, Doppelklick zum Bearbeiten",
"deleteWord": "Trigger Word löschen",
"suggestions": {
"noSuggestions": "Keine Vorschläge verfügbar",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "Erste Schritte",
"updateVlogs": "Update-Vlogs",
"documentation": "Dokumentation"
"documentation": "Dokumentation",
"shortcuts": "Tastenkürzel"
},
"gettingStarted": {
"title": "Erste Schritte mit LoRA Manager"
"title": "Erste Schritte mit LoRA Manager",
"replayTutorial": "Tutorial erneut abspielen"
},
"shortcuts": {
"title": "Tastatur- & Mauskürzel",
"groups": {
"general": "Allgemein",
"actions": "Aktionen",
"selection": "Auswahl & Massenmodus",
"navigation": "Navigation",
"modelModal": "Modell- / Rezept-Dialog",
"mediaViewer": "Medienanzeige / Beispielgalerie"
},
"keys": {
"click": "Klick",
"drag": "Ziehen",
"rightClick": "Rechtsklick",
"letter": "Buchstabe",
"swipe": "Wischen"
},
"entries": {
"focusSearch": "Suche fokussieren",
"closeModal": "Dialog / Panel schließen",
"openShortcuts": "Dieses Tastenkürzel-Panel öffnen",
"refresh": "Modellliste aktualisieren",
"fetchMetadata": "Metadaten von CivitAI abrufen (nur Modellseiten)",
"downloadModel": "Ein Modell herunterladen (nur Modellseiten)",
"toggleBulkMode": "Massenmodus umschalten",
"selectAll": "Alle sichtbaren Modelle auswählen",
"rangeSelect": "Bereich auswählen",
"marqueeSelect": "Karten mit Auswahlrahmen auswählen (auf leerem Rasterbereich)",
"exitBulkMode": "Massenmodus verlassen",
"bulkActions": "Auf ausgewählter Karte: Menü für Massenaktionen",
"globalActions": "Auf leerem Seitenbereich: Menü für globale Aktionen (Updates prüfen, ausgeschlossene Modelle verwalten)",
"scrollPages": "Seiten scrollen",
"jumpAlphabet": "Zur Alphabetleiste springen",
"prevNext": "Vorheriges / nächstes Modell",
"deleteEntry": "Löschen",
"cycleMedia": "Medien durchblättern ([ / ] in der Beispielgalerie)",
"swipeTouch": "Medien auf Touch-Geräten durchblättern",
"closeViewer": "Medienanzeige schließen"
}
},
"updateVlogs": {
"title": "Neueste Updates",
@@ -1974,7 +2028,8 @@
"settings": "Einstellungen & Konfiguration",
"extensions": "Erweiterungen",
"newBadge": "NEU"
}
},
"newContentBadge": "NEU"
},
"update": {
"title": "Nach Updates suchen",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "Bulk Operations",
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span>. Select multiple models and perform batch operations. Use <span class=\"onboarding-shortcut\">Ctrl+A</span> to select all visible models."
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span> to select multiple models and perform batch operations.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> select all visible models, <span class=\"onboarding-shortcut\">Shift+Click</span> select a range.<br>• <span class=\"onboarding-shortcut\">Esc</span> or clicking an empty area exits bulk mode."
},
"searchOptions": {
"title": "Search Options",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "Context Menu",
"content": "<strong>Right-click</strong> any model card for a context menu with additional actions."
"content": "<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata."
},
"marqueeSelect": {
"title": "Drag to Select",
"content": "Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once."
},
"dragToSidebar": {
"title": "Organize by Dragging",
"content": "Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode."
},
"contextMenus": {
"title": "More Context Menus",
"content": "In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models."
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "Type to add or click suggestions below",
"editWord": "Edit trigger word",
"editPlaceholder": "Edit trigger word",
"copyWord": "Copy trigger word",
"copyOrEditWord": "Click to copy, double-click to edit",
"deleteWord": "Delete trigger word",
"suggestions": {
"noSuggestions": "No suggestions available",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "Getting Started",
"updateVlogs": "Update Vlogs",
"documentation": "Documentation"
"documentation": "Documentation",
"shortcuts": "Shortcuts"
},
"gettingStarted": {
"title": "Getting Started with LoRA Manager"
"title": "Getting Started with LoRA Manager",
"replayTutorial": "Replay Tutorial"
},
"shortcuts": {
"title": "Keyboard & Mouse Shortcuts",
"groups": {
"general": "General",
"actions": "Actions",
"selection": "Selection & Bulk Mode",
"navigation": "Navigation",
"modelModal": "Model / Recipe Modal",
"mediaViewer": "Media Viewer / Showcase"
},
"keys": {
"click": "Click",
"drag": "Drag",
"rightClick": "Right-click",
"letter": "Letter",
"swipe": "Swipe"
},
"entries": {
"focusSearch": "Focus search",
"closeModal": "Close modal / panel",
"openShortcuts": "Open this shortcuts panel",
"refresh": "Refresh model list",
"fetchMetadata": "Fetch metadata from CivitAI (model pages only)",
"downloadModel": "Download a model (model pages only)",
"toggleBulkMode": "Toggle bulk mode",
"selectAll": "Select all visible models",
"rangeSelect": "Range select",
"marqueeSelect": "Marquee-select cards (on empty grid area)",
"exitBulkMode": "Exit bulk mode",
"bulkActions": "On selected card: bulk actions menu",
"globalActions": "On empty page area: global actions menu (update check, manage excluded models)",
"scrollPages": "Scroll pages",
"jumpAlphabet": "Jump alphabet bar",
"prevNext": "Previous / next model",
"deleteEntry": "Delete",
"cycleMedia": "Cycle media ([ / ] in showcase gallery)",
"swipeTouch": "Cycle media on touch devices",
"closeViewer": "Close viewer"
}
},
"updateVlogs": {
"title": "Latest Updates",
@@ -1974,7 +2028,8 @@
"settings": "Settings & Configuration",
"extensions": "Extensions",
"newBadge": "NEW"
}
},
"newContentBadge": "New"
},
"update": {
"title": "Check for Updates",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "Operaciones por lotes",
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span>. Selecciona varios modelos y realiza operaciones por lotes. Usa <span class=\"onboarding-shortcut\">Ctrl+A</span> para seleccionar todos los modelos visibles."
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span> para seleccionar varios modelos y realizar operaciones por lotes.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> selecciona todos los modelos visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> selecciona un rango.<br>• <span class=\"onboarding-shortcut\">Esc</span> o hacer clic en un área vacía sale del modo por lotes."
},
"searchOptions": {
"title": "Opciones de búsqueda",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "Menú contextual",
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones adicionales."
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones de la tarjeta como mover, eliminar o editar metadatos."
},
"marqueeSelect": {
"title": "Arrastrar para seleccionar",
"content": "Mantén pulsado el <strong>botón izquierdo del ratón</strong> en un área vacía de la cuadrícula y arrastra para dibujar un rectángulo de selección que selecciona varias tarjetas a la vez."
},
"dragToSidebar": {
"title": "Organizar arrastrando",
"content": "Arrastra una tarjeta de modelo hasta una carpeta de la barra lateral para mover el archivo allí. Esto también funciona con varias tarjetas seleccionadas en el modo por lotes."
},
"contextMenus": {
"title": "Más menús contextuales",
"content": "En el modo por lotes, <strong>haz clic derecho en una tarjeta seleccionada</strong> para ver las acciones por lotes. <strong>Haz clic derecho en un área vacía</strong> de la página para ver acciones globales como comprobar actualizaciones y gestionar modelos excluidos."
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
"editWord": "Editar palabra de activación",
"editPlaceholder": "Editar palabra de activación",
"copyWord": "Copiar palabra de activación",
"copyOrEditWord": "Haz clic para copiar, doble clic para editar",
"deleteWord": "Eliminar palabra de activación",
"suggestions": {
"noSuggestions": "No hay sugerencias disponibles",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "Comenzando",
"updateVlogs": "Vlogs de actualización",
"documentation": "Documentación"
"documentation": "Documentación",
"shortcuts": "Atajos"
},
"gettingStarted": {
"title": "Comenzando con el gestor de LoRA"
"title": "Comenzando con el gestor de LoRA",
"replayTutorial": "Repetir tutorial"
},
"shortcuts": {
"title": "Atajos de teclado y ratón",
"groups": {
"general": "General",
"actions": "Acciones",
"selection": "Selección y modo por lotes",
"navigation": "Navegación",
"modelModal": "Modal de modelo / receta",
"mediaViewer": "Visor de medios / Ejemplos"
},
"keys": {
"click": "Clic",
"drag": "Arrastrar",
"rightClick": "Clic derecho",
"letter": "Letra",
"swipe": "Deslizar"
},
"entries": {
"focusSearch": "Enfocar la búsqueda",
"closeModal": "Cerrar modal / panel",
"openShortcuts": "Abrir este panel de atajos",
"refresh": "Actualizar la lista de modelos",
"fetchMetadata": "Obtener metadatos de CivitAI (solo páginas de modelos)",
"downloadModel": "Descargar un modelo (solo páginas de modelos)",
"toggleBulkMode": "Activar/desactivar el modo por lotes",
"selectAll": "Seleccionar todos los modelos visibles",
"rangeSelect": "Selección por rango",
"marqueeSelect": "Seleccionar tarjetas con un rectángulo de selección (en un área vacía de la cuadrícula)",
"exitBulkMode": "Salir del modo por lotes",
"bulkActions": "En una tarjeta seleccionada: menú de acciones por lotes",
"globalActions": "En un área vacía de la página: menú de acciones globales (comprobar actualizaciones, gestionar modelos excluidos)",
"scrollPages": "Desplazarse por las páginas",
"jumpAlphabet": "Saltar con la barra alfabética",
"prevNext": "Modelo anterior / siguiente",
"deleteEntry": "Eliminar",
"cycleMedia": "Cambiar de medio ([ / ] en la galería de ejemplos)",
"swipeTouch": "Cambiar de medio en dispositivos táctiles",
"closeViewer": "Cerrar el visor"
}
},
"updateVlogs": {
"title": "Últimas actualizaciones",
@@ -1974,7 +2028,8 @@
"settings": "Configuración",
"extensions": "Extensiones",
"newBadge": "NUEVO"
}
},
"newContentBadge": "NUEVO"
},
"update": {
"title": "Comprobar actualizaciones",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "Opérations groupées",
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span>. Sélectionnez plusieurs modèles et effectuez des opérations groupées. Utilisez <span class=\"onboarding-shortcut\">Ctrl+A</span> pour sélectionner tous les modèles visibles."
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span> pour sélectionner plusieurs modèles et effectuer des opérations groupées.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> sélectionne tous les modèles visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> sélectionne une plage.<br>• <span class=\"onboarding-shortcut\">Esc</span> ou un clic sur une zone vide quitte le mode groupé."
},
"searchOptions": {
"title": "Options de recherche",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "Menu contextuel",
"content": "<strong>Clic droit</strong> sur une carte de modèle pour accéder à un menu contextuel avec des actions supplémentaires."
"content": "<strong>Clic droit</strong> sur n'importe quelle carte de modèle pour ouvrir un menu contextuel avec des actions sur la carte comme déplacer, supprimer ou modifier les métadonnées."
},
"marqueeSelect": {
"title": "Glisser pour sélectionner",
"content": "Maintenez le <strong>bouton gauche de la souris</strong> enfoncé sur une zone vide de la grille et glissez pour tracer un rectangle de sélection qui sélectionne plusieurs cartes à la fois."
},
"dragToSidebar": {
"title": "Organiser par glisser-déposer",
"content": "Glissez une carte de modèle sur un dossier de la barre latérale pour y déplacer le fichier. Cela fonctionne aussi avec plusieurs cartes sélectionnées en mode groupé."
},
"contextMenus": {
"title": "Plus de menus contextuels",
"content": "En mode groupé, <strong>faites un clic droit sur une carte sélectionnée</strong> pour accéder aux actions groupées. <strong>Faites un clic droit sur une zone vide</strong> de la page pour accéder aux actions globales comme la vérification des mises à jour et la gestion des modèles exclus."
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
"editWord": "Modifier le mot-clé",
"editPlaceholder": "Modifier le mot-clé",
"copyWord": "Copier le mot-clé",
"copyOrEditWord": "Cliquez pour copier, double-cliquez pour modifier",
"deleteWord": "Supprimer le mot-clé",
"suggestions": {
"noSuggestions": "Aucune suggestion disponible",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "Commencer",
"updateVlogs": "Vlogs de mise à jour",
"documentation": "Documentation"
"documentation": "Documentation",
"shortcuts": "Raccourcis"
},
"gettingStarted": {
"title": "Premiers pas avec le Gestionnaire LoRA"
"title": "Premiers pas avec le Gestionnaire LoRA",
"replayTutorial": "Rejouer le tutoriel"
},
"shortcuts": {
"title": "Raccourcis clavier et souris",
"groups": {
"general": "Général",
"actions": "Actions",
"selection": "Sélection et mode groupé",
"navigation": "Navigation",
"modelModal": "Modale Modèle / Recipe",
"mediaViewer": "Visionneuse de médias / Galerie d'exemples"
},
"keys": {
"click": "Clic",
"drag": "Glisser",
"rightClick": "Clic droit",
"letter": "Lettre",
"swipe": "Balayage"
},
"entries": {
"focusSearch": "Donner le focus au champ de recherche",
"closeModal": "Fermer la fenêtre modale / le panneau",
"openShortcuts": "Ouvrir ce panneau de raccourcis",
"refresh": "Actualiser la liste des modèles",
"fetchMetadata": "Récupérer les métadonnées depuis CivitAI (pages de modèles uniquement)",
"downloadModel": "Télécharger un modèle (pages de modèles uniquement)",
"toggleBulkMode": "Activer/désactiver le mode groupé",
"selectAll": "Sélectionner tous les modèles visibles",
"rangeSelect": "Sélection d'une plage",
"marqueeSelect": "Sélection par glisser-déposer des cartes (sur une zone vide de la grille)",
"exitBulkMode": "Quitter le mode groupé",
"bulkActions": "Sur une carte sélectionnée : menu des actions groupées",
"globalActions": "Sur une zone vide de la page : menu des actions globales (vérification des mises à jour, gestion des modèles exclus)",
"scrollPages": "Faire défiler les pages",
"jumpAlphabet": "Sauter via la barre alphabétique",
"prevNext": "Modèle précédent / suivant",
"deleteEntry": "Supprimer",
"cycleMedia": "Parcourir les médias ([ / ] dans la galerie d'exemples)",
"swipeTouch": "Parcourir les médias sur les appareils tactiles",
"closeViewer": "Fermer la visionneuse"
}
},
"updateVlogs": {
"title": "Dernières mises à jour",
@@ -1974,7 +2028,8 @@
"settings": "Paramètres & Configuration",
"extensions": "Extensions",
"newBadge": "NOUVEAU"
}
},
"newContentBadge": "NOUVEAU"
},
"update": {
"title": "Vérifier les mises à jour",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "פעולות בכמות גדולה",
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span>. בחר מספר מודלים ובצע פעולות בכמות גדולה. השתמש ב-<span class=\"onboarding-shortcut\">Ctrl+A</span> כדי לבחור את כל המודלים הגלויים."
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span> כדי לבחור מספר מודלים ולבצע פעולות בכמות גדולה.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> בחר את כל המודלים הגלויים, <span class=\"onboarding-shortcut\">Shift+Click</span> בחר טווח.<br>• <span class=\"onboarding-shortcut\">Esc</span> או לחיצה על אזור ריק מוציאים ממצב בכמות גדולה."
},
"searchOptions": {
"title": "אפשרויות חיפוש",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "תפריט הקשר",
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות נוספות."
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות כרטיס כמו העברה, מחיקה או עריכת מטא-נתונים."
},
"marqueeSelect": {
"title": "גרור כדי לבחור",
"content": "החזק את <strong>לחצן העכבר השמאלי</strong> לחוץ על אזור ריק של הרשת וגרור כדי לצייר מסגרת בחירה שבוחרת מספר כרטיסים בבת אחת."
},
"dragToSidebar": {
"title": "ארגון באמצעות גרירה",
"content": "גרור כרטיס מודל אל תיקייה בסרגל הצד כדי להעביר את הקובץ לשם. פעולה זו עובדת גם עם מספר כרטיסים נבחרים במצב בכמות גדולה."
},
"contextMenus": {
"title": "תפריטי הקשר נוספים",
"content": "במצב בכמות גדולה, <strong>לחץ לחיצה ימנית על כרטיס נבחר</strong> לפעולות בכמות גדולה. <strong>לחץ לחיצה ימנית על אזור ריק</strong> בדף לפעולות גלובליות כמו בדיקת עדכונים וניהול מודלים מוחרגים."
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
"editWord": "עריכת מילת טריגר",
"editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר",
"copyOrEditWord": "לחץ כדי להעתיק, לחץ פעמיים כדי לערוך",
"deleteWord": "מחק מילת טריגר",
"suggestions": {
"noSuggestions": "אין הצעות זמינות",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "תחילת עבודה",
"updateVlogs": "בלוגי וידאו של עדכונים",
"documentation": "תיעוד"
"documentation": "תיעוד",
"shortcuts": "קיצורי דרך"
},
"gettingStarted": {
"title": "תחילת עבודה עם מנהל LoRA"
"title": "תחילת עבודה עם מנהל LoRA",
"replayTutorial": "הפעל את המדריך מחדש"
},
"shortcuts": {
"title": "קיצורי מקלדת ועכבר",
"groups": {
"general": "כללי",
"actions": "פעולות",
"selection": "בחירה ומצב בכמות גדולה",
"navigation": "ניווט",
"modelModal": "חלון מודל / מתכון",
"mediaViewer": "מציג מדיה / גלריית דוגמאות"
},
"keys": {
"click": "לחיצה",
"drag": "גרירה",
"rightClick": "לחיצה ימנית",
"letter": "אות",
"swipe": "החלקה"
},
"entries": {
"focusSearch": "העבר מיקוד לחיפוש",
"closeModal": "סגור חלון / פאנל",
"openShortcuts": "פתח את פאנל קיצורי הדרך הזה",
"refresh": "רענן את רשימת המודלים",
"fetchMetadata": "אחזר מטא-נתונים מ-CivitAI (דפי מודלים בלבד)",
"downloadModel": "הורד מודל (דפי מודלים בלבד)",
"toggleBulkMode": "הפעל/כבה מצב בכמות גדולה",
"selectAll": "בחר את כל המודלים הגלויים",
"rangeSelect": "בחר טווח",
"marqueeSelect": "בחר כרטיסים במסגרת בחירה (באזור ריק של הרשת)",
"exitBulkMode": "צא ממצב בכמות גדולה",
"bulkActions": "על כרטיס נבחר: תפריט פעולות בכמות גדולה",
"globalActions": "באזור ריק בדף: תפריט פעולות גלובליות (בדיקת עדכונים, ניהול מודלים מוחרגים)",
"scrollPages": "גלול בין דפים",
"jumpAlphabet": "קפוץ בעזרת סרגל האותיות",
"prevNext": "מודל קודם / הבא",
"deleteEntry": "מחק",
"cycleMedia": "עבור בין פריטי מדיה ([ / ] בגלריית הדוגמאות)",
"swipeTouch": "עבור בין פריטי מדיה במכשירי מגע",
"closeViewer": "סגור את המציג"
}
},
"updateVlogs": {
"title": "עדכונים אחרונים",
@@ -1974,7 +2028,8 @@
"settings": "הגדרות ותצורה",
"extensions": "הרחבות",
"newBadge": "חדש"
}
},
"newContentBadge": "חדש"
},
"update": {
"title": "בדוק עדכונים",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "一括操作",
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入ります。複数のモデルを選択して一括操作が可能です。<span class=\"onboarding-shortcut\">Ctrl+A</span>で表示中のモデルをすべて選択できます。"
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入り複数のモデルを選択して一括操作を実行できます。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>で表示中のモデルをすべて選択、<span class=\"onboarding-shortcut\">Shift+Click</span>で範囲選択。<br>• <span class=\"onboarding-shortcut\">Esc</span>キーまたは空白部分をクリックすると一括モードを終了します。"
},
"searchOptions": {
"title": "検索オプション",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "コンテキストメニュー",
"content": "<strong>モデルカードを右クリック</strong>すると追加の操作ができるコンテキストメニューが表示されます。"
"content": "<strong>モデルカードを右クリック</strong>すると、移動、削除、メタデータの編集などのカード操作を含むコンテキストメニューが表示されます。"
},
"marqueeSelect": {
"title": "ドラッグで選択",
"content": "グリッドの空白部分で<strong>マウスの左ボタン</strong>を押したままドラッグすると、複数のカードを一度に選択する矩形(マーキー)を描画できます。"
},
"dragToSidebar": {
"title": "ドラッグで整理",
"content": "モデルカードをサイドバーのフォルダにドラッグすると、ファイルをそこに移動できます。一括モードで複数選択したカードでも同様に機能します。"
},
"contextMenus": {
"title": "その他のコンテキストメニュー",
"content": "一括モードでは、<strong>選択したカードを右クリック</strong>すると一括操作メニューが表示されます。<strong>ページの空白部分を右クリック</strong>すると、更新の確認や除外モデルの管理などのグローバル操作メニューが表示されます。"
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー",
"copyOrEditWord": "クリックでコピー、ダブルクリックで編集",
"deleteWord": "トリガーワードを削除",
"suggestions": {
"noSuggestions": "提案はありません",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "はじめに",
"updateVlogs": "更新Vlog",
"documentation": "ドキュメント"
"documentation": "ドキュメント",
"shortcuts": "ショートカット"
},
"gettingStarted": {
"title": "LoRA Managerを始める"
"title": "LoRA Managerを始める",
"replayTutorial": "チュートリアルをもう一度再生"
},
"shortcuts": {
"title": "キーボード & マウスのショートカット",
"groups": {
"general": "一般",
"actions": "操作",
"selection": "選択 & 一括モード",
"navigation": "ナビゲーション",
"modelModal": "モデル / レシピモーダル",
"mediaViewer": "メディアビューア / ショーケース"
},
"keys": {
"click": "クリック",
"drag": "ドラッグ",
"rightClick": "右クリック",
"letter": "文字キー",
"swipe": "スワイプ"
},
"entries": {
"focusSearch": "検索にフォーカス",
"closeModal": "モーダル / パネルを閉じる",
"openShortcuts": "このショートカットパネルを開く",
"refresh": "モデルリストを更新",
"fetchMetadata": "CivitAIからメタデータを取得(モデルページのみ)",
"downloadModel": "モデルをダウンロード(モデルページのみ)",
"toggleBulkMode": "一括モードを切り替え",
"selectAll": "表示中のモデルをすべて選択",
"rangeSelect": "範囲選択",
"marqueeSelect": "カードを矩形選択(グリッドの空白部分で)",
"exitBulkMode": "一括モードを終了",
"bulkActions": "選択したカード上:一括操作メニュー",
"globalActions": "ページの空白部分:グローバル操作メニュー(更新の確認、除外モデルの管理)",
"scrollPages": "ページをスクロール",
"jumpAlphabet": "アルファベットバーへジャンプ",
"prevNext": "前 / 次のモデル",
"deleteEntry": "削除",
"cycleMedia": "メディアを切り替え(ショーケースギャラリーでは [ / ])",
"swipeTouch": "タッチデバイスでメディアを切り替え",
"closeViewer": "ビューアを閉じる"
}
},
"updateVlogs": {
"title": "最新の更新",
@@ -1974,7 +2028,8 @@
"settings": "設定&構成",
"extensions": "拡張機能",
"newBadge": "新着"
}
},
"newContentBadge": "新着"
},
"update": {
"title": "更新確認",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "일괄 작업",
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하세요. 여러 모델을 선택하 일괄 작업을 수행할 수 있습니다. <span class=\"onboarding-shortcut\">Ctrl+A</span>로 모든 표시된 모델을 선택하세요."
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하 여러 모델을 선택하 일괄 작업을 수행하세요.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>로 모든 표시된 모델을 선택하고, <span class=\"onboarding-shortcut\">Shift+Click</span>으로 범위를 선택할 수 있습니다.<br>• <span class=\"onboarding-shortcut\">Esc</span> 키를 누르거나 빈 영역을 클릭하면 일괄 모드가 종료됩니다."
},
"searchOptions": {
"title": "검색 옵션",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "컨텍스트 메뉴",
"content": "<strong>오른쪽 클릭</strong>으로 모델 카드의 추가 작업 메뉴를 사용할 수 있습니다."
"content": "모델 카드를 <strong>오른쪽 클릭</strong>하면 이동, 삭제, 메타데이터 편집 같은 카드 작업이 담긴 컨텍스트 메뉴를 사용할 수 있습니다."
},
"marqueeSelect": {
"title": "드래그로 선택",
"content": "그리드의 빈 영역에서 <strong>마우스 왼쪽 버튼</strong>을 누른 채 드래그하여 여러 카드를 한 번에 선택하는 선택 영역을 그리세요."
},
"dragToSidebar": {
"title": "드래그로 정리",
"content": "모델 카드를 사이드바의 폴더로 드래그하면 파일이 해당 폴더로 이동합니다. 일괄 모드에서 선택한 여러 카드에도 적용됩니다."
},
"contextMenus": {
"title": "더 많은 컨텍스트 메뉴",
"content": "일괄 모드에서는 <strong>선택한 카드를 오른쪽 클릭</strong>하여 일괄 작업을 사용할 수 있습니다. 페이지의 <strong>빈 영역을 오른쪽 클릭</strong>하면 업데이트 확인이나 제외된 모델 관리 같은 전역 작업을 사용할 수 있습니다."
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
"editWord": "트리거 단어 편집",
"editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사",
"copyOrEditWord": "클릭하여 복사, 더블 클릭하여 편집",
"deleteWord": "트리거 단어 삭제",
"suggestions": {
"noSuggestions": "사용 가능한 제안이 없습니다",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "시작하기",
"updateVlogs": "업데이트 영상",
"documentation": "문서"
"documentation": "문서",
"shortcuts": "단축키"
},
"gettingStarted": {
"title": "LoRA Manager 시작하기"
"title": "LoRA Manager 시작하기",
"replayTutorial": "튜토리얼 다시 보기"
},
"shortcuts": {
"title": "키보드 & 마우스 단축키",
"groups": {
"general": "일반",
"actions": "작업",
"selection": "선택 & 일괄 모드",
"navigation": "내비게이션",
"modelModal": "모델 / 레시피 모달",
"mediaViewer": "미디어 뷰어 / 쇼케이스"
},
"keys": {
"click": "클릭",
"drag": "드래그",
"rightClick": "오른쪽 클릭",
"letter": "문자 키",
"swipe": "스와이프"
},
"entries": {
"focusSearch": "검색창으로 포커스 이동",
"closeModal": "모달 / 패널 닫기",
"openShortcuts": "이 단축키 패널 열기",
"refresh": "모델 목록 새로고침",
"fetchMetadata": "CivitAI에서 메타데이터 가져오기 (모델 페이지만)",
"downloadModel": "모델 다운로드 (모델 페이지만)",
"toggleBulkMode": "일괄 모드 전환",
"selectAll": "표시된 모든 모델 선택",
"rangeSelect": "범위 선택",
"marqueeSelect": "드래그로 카드 선택 (빈 그리드 영역에서)",
"exitBulkMode": "일괄 모드 종료",
"bulkActions": "선택한 카드에서: 일괄 작업 메뉴",
"globalActions": "페이지 빈 영역에서: 전역 작업 메뉴 (업데이트 확인, 제외된 모델 관리)",
"scrollPages": "페이지 스크롤",
"jumpAlphabet": "알파벳 바로 이동",
"prevNext": "이전 / 다음 모델",
"deleteEntry": "삭제",
"cycleMedia": "미디어 전환 (쇼케이스 갤러리에서 [ / ])",
"swipeTouch": "터치 기기에서 미디어 전환",
"closeViewer": "뷰어 닫기"
}
},
"updateVlogs": {
"title": "최신 업데이트",
@@ -1974,7 +2028,8 @@
"settings": "설정 & 구성",
"extensions": "확장",
"newBadge": "신규"
}
},
"newContentBadge": "신규"
},
"update": {
"title": "업데이트 확인",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "Массовые операции",
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>. Выберите несколько моделей и выполните пакетные операции. Используйте <span class=\"onboarding-shortcut\">Ctrl+A</span> для выбора всех видимых моделей."
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>, чтобы выбрать несколько моделей и выполнить пакетные операции.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> выбрать все видимые модели, <span class=\"onboarding-shortcut\">Shift+Click</span> — выбрать диапазон.<br>• <span class=\"onboarding-shortcut\">Esc</span> или клик по пустой области выходит из массового режима."
},
"searchOptions": {
"title": "Опции поиска",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "Контекстное меню",
"content": "<strong>Правый клик</strong> по карточке модели откроет контекстное меню с дополнительными действиями."
"content": "<strong>Правый клик</strong> по любой карточке модели открывает контекстное меню с действиями над карточкой, такими как перемещение, удаление или редактирование метаданных."
},
"marqueeSelect": {
"title": "Выделение рамкой",
"content": "Удерживайте <strong>левую кнопку мыши</strong> на пустой области сетки и перетащите, чтобы нарисовать рамку, выделяющую сразу несколько карточек."
},
"dragToSidebar": {
"title": "Организация перетаскиванием",
"content": "Перетащите карточку модели на папку в боковой панели, чтобы переместить туда файл. Это также работает с несколькими выделенными карточками в массовом режиме."
},
"contextMenus": {
"title": "Другие контекстные меню",
"content": "В массовом режиме <strong>правый клик по выделенной карточке</strong> открывает меню массовых операций. <strong>Правый клик по пустой области</strong> страницы открывает глобальные действия, такие как проверка обновлений и управление исключёнными моделями."
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
"editWord": "Редактировать триггерное слово",
"editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово",
"copyOrEditWord": "Клик — скопировать, двойной клик — редактировать",
"deleteWord": "Удалить триггерное слово",
"suggestions": {
"noSuggestions": "Предложения недоступны",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "Начало работы",
"updateVlogs": "Видео обновлений",
"documentation": "Документация"
"documentation": "Документация",
"shortcuts": "Горячие клавиши"
},
"gettingStarted": {
"title": "Начало работы с LoRA Manager"
"title": "Начало работы с LoRA Manager",
"replayTutorial": "Повторить обучение"
},
"shortcuts": {
"title": "Горячие клавиши и действия мыши",
"groups": {
"general": "Общие",
"actions": "Действия",
"selection": "Выделение и массовый режим",
"navigation": "Навигация",
"modelModal": "Окно модели / рецепта",
"mediaViewer": "Просмотр медиа / Витрина"
},
"keys": {
"click": "Клик",
"drag": "Перетаскивание",
"rightClick": "Правый клик",
"letter": "Буква",
"swipe": "Свайп"
},
"entries": {
"focusSearch": "Переход к поиску",
"closeModal": "Закрыть модальное окно / панель",
"openShortcuts": "Открыть эту панель горячих клавиш",
"refresh": "Обновить список моделей",
"fetchMetadata": "Получить метаданные с CivitAI (только на страницах моделей)",
"downloadModel": "Загрузить модель (только на страницах моделей)",
"toggleBulkMode": "Переключить массовый режим",
"selectAll": "Выбрать все видимые модели",
"rangeSelect": "Выбор диапазона",
"marqueeSelect": "Выделение карточек рамкой (на пустой области сетки)",
"exitBulkMode": "Выйти из массового режима",
"bulkActions": "На выделенной карточке: меню массовых операций",
"globalActions": "На пустой области страницы: меню глобальных действий (проверка обновлений, управление исключёнными моделями)",
"scrollPages": "Прокрутка страниц",
"jumpAlphabet": "Переход по алфавитной панели",
"prevNext": "Предыдущая / следующая модель",
"deleteEntry": "Удалить",
"cycleMedia": "Переключение медиа ([ / ] в галерее витрины)",
"swipeTouch": "Переключение медиа на сенсорных устройствах",
"closeViewer": "Закрыть окно просмотра"
}
},
"updateVlogs": {
"title": "Последние обновления",
@@ -1974,7 +2028,8 @@
"settings": "Настройки и конфигурация",
"extensions": "Расширения",
"newBadge": "НОВОЕ"
}
},
"newContentBadge": "НОВОЕ"
},
"update": {
"title": "Проверить обновления",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "批量操作",
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式可多选模型并行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 全选所有可见模型。"
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式可多选模型并行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 全选所有可见模型<span class=\"onboarding-shortcut\">Shift+Click</span> 选择一个范围。<br>• 按 <span class=\"onboarding-shortcut\">Esc</span> 或点击空白区域退出批量模式。"
},
"searchOptions": {
"title": "搜索选项",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "右键菜单",
"content": "<strong>右键点击</strong>任意模型卡片可打开更多操作菜单。"
"content": "<strong>右键点击</strong>任意模型卡片可打开包含移动、删除或编辑元数据等卡片操作菜单。"
},
"marqueeSelect": {
"title": "拖动框选",
"content": "在网格的空白区域按住<strong>鼠标左键</strong>并拖动,绘制一个可同时选中多张卡片的框选区域。"
},
"dragToSidebar": {
"title": "拖放整理",
"content": "将模型卡片拖到侧边栏的文件夹上,即可把文件移动到该文件夹。批量模式下选中的多张卡片也可如此操作。"
},
"contextMenus": {
"title": "更多右键菜单",
"content": "在批量模式下,<strong>右键点击已选中的卡片</strong>可进行批量操作。<strong>右键点击页面空白区域</strong>可使用检查更新、管理已排除的模型等全局操作。"
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词",
"editPlaceholder": "编辑触发词",
"copyWord": "复制触发词",
"copyOrEditWord": "单击复制,双击编辑",
"deleteWord": "删除触发词",
"suggestions": {
"noSuggestions": "暂无建议",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "新手入门",
"updateVlogs": "更新日志",
"documentation": "文档"
"documentation": "文档",
"shortcuts": "快捷键"
},
"gettingStarted": {
"title": "LoRA 管理器新手入门"
"title": "LoRA 管理器新手入门",
"replayTutorial": "重播教程"
},
"shortcuts": {
"title": "键盘与鼠标快捷键",
"groups": {
"general": "通用",
"actions": "操作",
"selection": "选择与批量模式",
"navigation": "导航",
"modelModal": "模型 / 配方弹窗",
"mediaViewer": "媒体查看器 / 示例展示"
},
"keys": {
"click": "单击",
"drag": "拖动",
"rightClick": "右键点击",
"letter": "字母",
"swipe": "滑动"
},
"entries": {
"focusSearch": "聚焦搜索框",
"closeModal": "关闭弹窗 / 面板",
"openShortcuts": "打开本快捷键面板",
"refresh": "刷新模型列表",
"fetchMetadata": "从 CivitAI 获取元数据(仅模型页面)",
"downloadModel": "下载模型(仅模型页面)",
"toggleBulkMode": "切换批量模式",
"selectAll": "全选所有可见模型",
"rangeSelect": "范围选择",
"marqueeSelect": "框选卡片(在网格空白区域)",
"exitBulkMode": "退出批量模式",
"bulkActions": "在已选中的卡片上:批量操作菜单",
"globalActions": "在页面空白区域:全局操作菜单(检查更新、管理已排除的模型)",
"scrollPages": "滚动页面",
"jumpAlphabet": "字母索引栏跳转",
"prevNext": "上一个 / 下一个模型",
"deleteEntry": "删除",
"cycleMedia": "切换媒体(在示例展示中按 [ / ])",
"swipeTouch": "在触屏设备上切换媒体",
"closeViewer": "关闭查看器"
}
},
"updateVlogs": {
"title": "最新更新",
@@ -1974,7 +2028,8 @@
"settings": "设置与配置",
"extensions": "扩展",
"newBadge": "新"
}
},
"newContentBadge": "新"
},
"update": {
"title": "检查更新",
+61 -6
View File
@@ -96,7 +96,7 @@
},
"bulk": {
"title": "批次操作",
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批模式。可選取多個模型並執行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 選取所有可見模型。"
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批模式選取多個模型並執行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 選取所有可見模型<span class=\"onboarding-shortcut\">Shift+Click</span> 選取一段範圍。<br>• <span class=\"onboarding-shortcut\">Esc</span> 或點擊空白處離開批量模式。"
},
"searchOptions": {
"title": "搜尋選項",
@@ -116,7 +116,19 @@
},
"contextMenu": {
"title": "右鍵選單",
"content": "<strong>右鍵點擊</strong>任一模型卡片可開啟更多操作選單。"
"content": "<strong>右鍵點擊</strong>任一模型卡片可開啟包含移動、刪除或編輯中繼資料等卡片操作的右鍵選單。"
},
"marqueeSelect": {
"title": "拖曳框選",
"content": "在網格空白處按住<strong>滑鼠左鍵</strong>並拖曳,畫出框選範圍,一次選取多張卡片。"
},
"dragToSidebar": {
"title": "拖曳整理",
"content": "將模型卡片拖曳到側邊欄的資料夾上,即可將檔案移動到該處。在批量模式下選取多張卡片也可一起拖曳。"
},
"contextMenus": {
"title": "更多右鍵選單",
"content": "在批量模式下,<strong>右鍵點擊已選取的卡片</strong>可開啟批量操作選單。<strong>右鍵點擊頁面空白處</strong>可開啟全域操作選單,例如檢查更新與管理已排除的模型。"
}
}
},
@@ -1629,7 +1641,7 @@
"addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞",
"copyOrEditWord": "點擊複製,雙擊編輯",
"deleteWord": "刪除觸發詞",
"suggestions": {
"noSuggestions": "無可用建議",
@@ -1954,10 +1966,52 @@
"tabs": {
"gettingStarted": "快速開始",
"updateVlogs": "更新影片",
"documentation": "文件"
"documentation": "文件",
"shortcuts": "快捷鍵"
},
"gettingStarted": {
"title": "LoRA 管理器快速開始"
"title": "LoRA 管理器快速開始",
"replayTutorial": "重新播放教學"
},
"shortcuts": {
"title": "鍵盤與滑鼠快捷鍵",
"groups": {
"general": "一般",
"actions": "操作",
"selection": "選取與批量模式",
"navigation": "導覽",
"modelModal": "模型 / 配方彈窗",
"mediaViewer": "媒體檢視器 / 範例展示"
},
"keys": {
"click": "點擊",
"drag": "拖曳",
"rightClick": "右鍵點擊",
"letter": "字母鍵",
"swipe": "滑動"
},
"entries": {
"focusSearch": "聚焦搜尋欄",
"closeModal": "關閉彈窗 / 面板",
"openShortcuts": "開啟此快捷鍵面板",
"refresh": "重新整理模型列表",
"fetchMetadata": "從 CivitAI 擷取中繼資料(僅限模型頁面)",
"downloadModel": "下載模型(僅限模型頁面)",
"toggleBulkMode": "切換批量模式",
"selectAll": "選取所有可見模型",
"rangeSelect": "範圍選取",
"marqueeSelect": "框選卡片(在網格空白處拖曳)",
"exitBulkMode": "離開批量模式",
"bulkActions": "在已選取的卡片上:批量操作選單",
"globalActions": "在頁面空白處:全域操作選單(檢查更新、管理已排除的模型)",
"scrollPages": "捲動頁面",
"jumpAlphabet": "字母列跳轉",
"prevNext": "上一個 / 下一個模型",
"deleteEntry": "刪除",
"cycleMedia": "切換媒體(範例展示中的 [ / ])",
"swipeTouch": "在觸控裝置上滑動切換媒體",
"closeViewer": "關閉檢視器"
}
},
"updateVlogs": {
"title": "最新更新",
@@ -1974,7 +2028,8 @@
"settings": "設定與配置",
"extensions": "擴充功能",
"newBadge": "新"
}
},
"newContentBadge": "新"
},
"update": {
"title": "檢查更新",
+8 -3
View File
@@ -31,9 +31,14 @@ body {
--header-height: 48px;
--scrollbar-width: 8px;
--shortcut-bg: var(--color-accent-subtle);
--shortcut-border: var(--color-accent-border);
--shortcut-text: var(--text-primary);
/* Neutral "keycap" style for keyboard shortcut hints (GitHub/Linear-like).
Derived from --text-muted so it adapts to every theme/preset. */
--shortcut-bg: color-mix(in oklch, var(--text-muted) 10%, transparent);
--shortcut-bg-hover: color-mix(in oklch, var(--text-muted) 16%, transparent);
--shortcut-border: color-mix(in oklch, var(--text-muted) 30%, transparent);
--shortcut-border-hover: color-mix(in oklch, var(--text-muted) 45%, transparent);
--shortcut-text: var(--text-muted);
--shortcut-shadow: 0 1.5px 0 color-mix(in oklch, var(--text-muted) 30%, transparent);
--lora-accent-transparent: var(--color-accent-transparent);
+4 -4
View File
@@ -249,10 +249,10 @@
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--text-muted);
/* Subtle tint derived from text color so it adapts to both light & dark themes */
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
color: var(--shortcut-text);
background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs, 3px);
line-height: 1;
}
+107 -1
View File
@@ -167,6 +167,29 @@
box-shadow: var(--shadow-lg);
}
/* Replay Tutorial button: badge hidden until the button is flagged as new content */
.replay-tutorial-btn .new-content-badge {
display: none;
background-color: rgba(255, 255, 255, 0.22);
color: #fff;
box-shadow: none;
margin-left: 2px;
}
.replay-tutorial-btn.has-new-content .new-content-badge {
display: inline-flex;
}
/* One-time attention pulse when the button is flagged as new content */
@keyframes new-content-glow {
0% { box-shadow: 0 0 0 0 oklch(from var(--lora-accent) l c h / 55%); }
100% { box-shadow: 0 0 0 16px transparent; }
}
.replay-tutorial-btn.has-new-content {
animation: new-content-glow 1.2s ease-out 3;
}
/* Update video list styles */
.video-list {
display: flex;
@@ -304,4 +327,87 @@
/* Dark theme adjustments */
[data-theme="dark"] .video-container {
background-color: var(--surface-hover);
}
}
/* Replay tutorial button styles */
.help-actions {
margin-top: var(--space-3);
}
.replay-tutorial-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--border-radius-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition-base);
background-color: var(--lora-accent);
color: white;
border: none;
}
.replay-tutorial-btn:hover {
background-color: oklch(from var(--lora-accent) l c h / 85%);
}
/* Shortcuts tab styles */
.shortcuts-section {
margin-bottom: var(--space-3);
}
.shortcuts-section h4 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: var(--space-1);
}
.shortcuts-list {
list-style-type: none;
padding-left: var(--space-3);
}
.shortcuts-list li {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: var(--space-1);
}
.shortcut-keys {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
min-width: 150px;
}
.shortcut-sep {
color: var(--text-muted);
font-size: 0.75rem;
margin: 0 1px;
}
.shortcuts-list kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--shortcut-text);
background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs, 3px);
line-height: 1;
}
.shortcut-description {
font-size: 0.9em;
opacity: 0.85;
}
+7 -9
View File
@@ -202,15 +202,17 @@
align-items: center;
justify-content: center;
margin-left: 6px;
min-width: 16px;
height: 16px;
padding: 0 3px;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: 600;
line-height: 1;
text-transform: uppercase;
border-radius: var(--border-radius-xs);
background-color: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
color: var(--shortcut-text);
vertical-align: middle;
opacity: 0.8;
@@ -219,12 +221,8 @@
.control-group button:hover .shortcut-key {
opacity: 1;
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
}
[data-theme="dark"] .shortcut-key {
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
background-color: var(--shortcut-bg-hover);
border-color: var(--shortcut-border-hover);
}
/* Ensure correct vertical alignment for text+shortcut */
+1
View File
@@ -205,6 +205,7 @@
display: inline-block;
background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs);
padding: 2px 6px;
font-size: 0.8em;
+58 -1
View File
@@ -1,7 +1,8 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
@@ -146,6 +147,62 @@ export class PageControls {
// Page-specific event listeners
this.initPageSpecificListeners();
// Keyboard shortcuts for the actions toolbar (R / F / D)
this.registerKeyboardShortcuts();
}
/**
* Register keyboard shortcuts for the actions toolbar buttons
* (R = refresh, F = fetch metadata, D = download)
*/
registerKeyboardShortcuts() {
eventManager.addHandler('keydown', 'pageControls-actions', (e) => {
return this.handleActionShortcut(e);
}, {
priority: 90,
skipWhenModalOpen: true
});
}
/**
* Handle a keydown event for the actions toolbar shortcuts
* @param {KeyboardEvent} e
* @returns {boolean} True when the event was handled and propagation should stop
*/
handleActionShortcut(e) {
// Plain letters only — leave modified combos (Ctrl/Cmd/Alt) alone
if (e.ctrlKey || e.metaKey || e.altKey) {
return false;
}
// Don't hijack keys while typing in a text entry context
if (isTypingContext(e.target)) {
return false;
}
const actionByKey = {
r: 'refresh',
f: 'fetch',
d: 'download'
};
const action = actionByKey[e.key.toLowerCase()];
if (!action) {
return false;
}
// The button may not exist on this page (e.g. recipes has no
// fetch/download) — let other handlers run in that case
const button = document.querySelector(`[data-action="${action}"]`);
if (!button) {
return false;
}
e.preventDefault();
// Native disabled buttons ignore .click(), so an in-progress
// refresh is safe
button.click();
return true;
}
initExcludedViewControls() {
+2
View File
@@ -607,9 +607,11 @@ export function createModelCard(model, modelType) {
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
// Checkpoint send sets the widget value directly; no append/replace modes.
sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
} else if (modelType === MODEL_TYPES.EMBEDDING) {
// Embedding send always appends to the prompt; no replace mode.
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
} else {
+4 -4
View File
@@ -227,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
const escapedWord = escapeHtml(word);
const escapedAttr = escapeAttribute(word);
return `
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyWord')}">
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
@@ -455,7 +455,7 @@ function resetTriggerWordsUIState(section) {
// Restore click-to-copy functionality
tag.removeEventListener('click', startEditTriggerWord);
setupDisplayTriggerWordTag(tag);
tag.title = translate('modals.model.triggerWords.copyWord');
tag.title = translate('modals.model.triggerWords.copyOrEditWord');
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
@@ -503,7 +503,7 @@ function createTriggerWordTag(word, isEditMode = false) {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord');
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyOrEditWord');
const escapedWord = escapeHtml(word);
tag.innerHTML = `
@@ -537,7 +537,7 @@ function setupDisplayTriggerWordTag(tag) {
tag.addEventListener('click', handleDisplayTriggerWordClick);
tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
tag.title = translate('modals.model.triggerWords.copyWord');
tag.title = translate('modals.model.triggerWords.copyOrEditWord');
}
/**
+126 -66
View File
@@ -1,23 +1,16 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { onboardingManager } from './OnboardingManager.js';
/**
* Manages help modal functionality and tutorial update notifications
*/
export class HelpManager {
constructor() {
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0);
this.latestContentTimestamp = new Date('2025-10-11').getTime(); // Will be updated from server or config
// Version of the help content the user has seen. Compared against the
// data-help-content-version marker rendered into the help modal markup,
// so badge state is always derived from the content actually served.
this.viewedContentVersion = getStorageItem('help_viewed_content_version', null);
this.isInitialized = false;
// Default latest content data - could be fetched from server
this.latestVideoData = {
timestamp: new Date('2024-06-09').getTime(), // Default timestamp
walkthrough: {
id: 'hvKw31YpE-U',
title: 'Getting Started with LoRA Manager'
},
playlistUpdated: true
};
}
/**
@@ -34,9 +27,6 @@ export class HelpManager {
// Check if we need to show the badge
this.updateHelpBadge();
// Fetch latest video data (could be implemented to fetch from remote source)
this.fetchLatestVideoData();
this.isInitialized = true;
return this;
}
@@ -55,77 +45,147 @@ export class HelpManager {
const tabButtons = document.querySelectorAll('.help-tabs .tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', (event) => {
// Remove active class from all buttons and panes
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
pane.classList.remove('active');
});
// Add active class to clicked button
event.currentTarget.classList.add('active');
// Show corresponding tab content
const tabId = event.currentTarget.getAttribute('data-tab');
document.getElementById(tabId).classList.add('active');
this.activateHelpTab(event.currentTarget.getAttribute('data-tab'));
});
});
// Replay tutorial button in the Getting Started tab
const replayTutorialBtn = document.getElementById('replayTutorialBtn');
if (replayTutorialBtn) {
replayTutorialBtn.addEventListener('click', () => {
// Close the help modal, then restart the onboarding tutorial
if (window.modalManager) {
window.modalManager.closeModal('helpModal');
}
onboardingManager.reset();
onboardingManager.startTutorial();
});
}
// Global "?" shortcut opens the help modal on the Shortcuts tab
document.addEventListener('keydown', (event) => {
if (event.key !== '?') return;
if (this.isTypingContext(event.target)) return;
if (window.modalManager?.isAnyModalOpen()) return;
event.preventDefault();
this.openHelpModal('shortcuts');
});
}
/**
* Check if the event target is a text entry context where "?" is literal input
*/
isTypingContext(target) {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
/**
* Activate a specific help modal tab by its data-tab id
* @param {string} tabId - The tab id (matches data-tab and pane element id)
*/
activateHelpTab(tabId) {
const tabButton = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
const tabPane = document.getElementById(tabId);
if (!tabButton || !tabPane) return;
// Remove active class from all buttons and panes
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
pane.classList.remove('active');
});
// Activate the requested tab
tabButton.classList.add('active');
tabPane.classList.add('active');
}
/**
* Open the help modal
* @param {string} [tabId] - Optional tab id to activate after opening
*/
openHelpModal() {
openHelpModal(tabId) {
// Use modalManager to open the help modal
if (window.modalManager) {
window.modalManager.toggleModal('helpModal');
// Add visual indicator to Documentation tab if there's new content
this.updateDocumentationTabIndicator();
// Update the last viewed timestamp
this.markContentAsViewed();
// Hide the badge
this.hideHelpBadge();
if (!window.modalManager) return;
const hadNewContent = this.hasNewContent();
window.modalManager.toggleModal('helpModal');
if (tabId) {
this.activateHelpTab(tabId);
}
// Only acknowledge the content as viewed when the user opened the
// modal while it actually contained new content. Opening a stale
// (pre-upgrade) page must not suppress the badge after a refresh.
if (hadNewContent) {
this.updateNewContentTabIndicators();
this.markContentAsViewed();
}
// Hide the badge
this.hideHelpBadge();
}
/**
* Add visual indicator to Documentation tab for new content
* Add visual indicator to tabs that received new content
*/
updateDocumentationTabIndicator() {
const docTab = document.querySelector('.tab-btn[data-tab="documentation"]');
if (docTab && this.hasNewContent()) {
docTab.classList.add('has-new-content');
updateNewContentTabIndicators() {
if (!this.hasNewContent()) return;
// Tabs updated in the 2026-09-03 discoverability release:
// getting-started (Replay Tutorial button) and shortcuts (new cheat-sheet tab)
const NEW_CONTENT_TABS = ['getting-started', 'shortcuts'];
NEW_CONTENT_TABS.forEach(tabId => {
const tab = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
if (tab) {
tab.classList.add('has-new-content');
}
});
// Point the indicator at the specific new element inside the
// Getting Started tab, and scroll it into view so it is not lost
// below the fold of the modal body.
const replayBtn = document.getElementById('replayTutorialBtn');
if (replayBtn) {
replayBtn.classList.add('has-new-content');
const gettingStartedActive = document.querySelector('#getting-started.tab-pane.active');
if (gettingStartedActive && typeof replayBtn.scrollIntoView === 'function') {
replayBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
/**
* Mark content as viewed by saving current timestamp
* Mark content as viewed by persisting the version rendered in the DOM.
* No-op when the served markup carries no version marker (stale assets),
* so viewing old content never suppresses the badge for new content.
*/
markContentAsViewed() {
this.lastViewedTimestamp = Date.now();
setStorageItem('help_last_viewed', this.lastViewedTimestamp);
const currentVersion = this.getCurrentContentVersion();
if (!currentVersion) return;
this.viewedContentVersion = currentVersion;
setStorageItem('help_viewed_content_version', this.viewedContentVersion);
}
/**
* Fetch latest video data (could be implemented to actually fetch from a remote source)
* Read the help content version from the rendered modal markup
* @returns {string|null} Version marker, or null if the served markup has none
*/
fetchLatestVideoData() {
// In a real implementation, you'd fetch this from your server
// For now, we'll just use the hardcoded data from constructor
// Update the timestamp with the latest data
this.latestContentTimestamp = Math.max(this.latestContentTimestamp, this.latestVideoData.timestamp);
// Check again if we need to show the badge with this new data
this.updateHelpBadge();
getCurrentContentVersion() {
const marker = document.querySelector('[data-help-content-version]');
return marker ? marker.getAttribute('data-help-content-version') : null;
}
/**
* Update help badge visibility based on timestamps
* Update help badge visibility based on viewed vs. served content version
*/
updateHelpBadge() {
if (this.hasNewContent()) {
@@ -134,13 +194,13 @@ export class HelpManager {
this.hideHelpBadge();
}
}
/**
* Check if there's new content the user hasn't seen
* Check if the served help content is newer than what the user has viewed
*/
hasNewContent() {
// If user has never viewed the help, or the content is newer than last viewed
return this.lastViewedTimestamp === 0 || this.latestContentTimestamp > this.lastViewedTimestamp;
const currentVersion = this.getCurrentContentVersion();
return Boolean(currentVersion) && currentVersion !== this.viewedContentVersion;
}
/**
+22 -2
View File
@@ -43,7 +43,7 @@ export class OnboardingManager {
{
target: '.controls .action-buttons [data-action="bulk"]',
title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'),
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span>. Select multiple models and perform batch operations. Use <span class="onboarding-shortcut">Ctrl+A</span> to select all visible models.'),
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span> to select multiple models and perform batch operations.<br>• <span class="onboarding-shortcut">Ctrl/Cmd+A</span> select all visible models, <span class="onboarding-shortcut">Shift+Click</span> select a range.<br>• <span class="onboarding-shortcut">Esc</span> or clicking an empty area exits bulk mode.'),
position: 'bottom'
},
{
@@ -71,10 +71,30 @@ export class OnboardingManager {
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.marqueeSelect.title', {}, 'Drag to Select'),
content: () => translate('onboarding.steps.marqueeSelect.content', {}, 'Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '#folderSidebar',
title: () => translate('onboarding.steps.dragToSidebar.title', {}, 'Organize by Dragging'),
content: () => translate('onboarding.steps.dragToSidebar.content', {}, 'Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode.'),
position: 'right'
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'),
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with additional actions.'),
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.contextMenus.title', {}, 'More Context Menus'),
content: () => translate('onboarding.steps.contextMenus.content', {}, 'In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
}
+14
View File
@@ -311,6 +311,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) {
toast.append(closeBtn);
}
/**
* Check whether the event target is a text-entry context (input, textarea,
* select, or contenteditable) where single-letter shortcuts should be treated
* as literal input.
* @param {EventTarget|null} target - The DOM event target
* @returns {boolean}
*/
export function isTypingContext(target) {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
+4 -4
View File
@@ -65,7 +65,7 @@
</select>
</div>
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span><span>{{ t('common.actions.refresh') }}</span> <kbd class="shortcut-key">R</kbd></span></button>
<button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i>
</button>
@@ -78,11 +78,11 @@
{% if page_id != 'recipes' %}
<div class="control-group">
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span><span>{{ t('loras.controls.fetch.action') }}</span> <kbd class="shortcut-key">F</kbd></span></button>
</div>
<div class="control-group">
<button data-action="download" title="{{ t('loras.controls.download.title') }}">
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
<i class="fas fa-cloud-download-alt"></i> <span><span>{{ t('loras.controls.download.action') }}</span> <kbd class="shortcut-key">D</kbd></span>
</button>
</div>
{% endif %}
@@ -96,7 +96,7 @@
{% endif %}
<div class="control-group">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <kbd class="shortcut-key">B</kbd></span>
</button>
</div>
<div class="control-group">
+129 -1
View File
@@ -1,5 +1,5 @@
<!-- Help Modal -->
<div id="helpModal" class="modal">
<div id="helpModal" class="modal" data-help-content-version="2026-09-03">
<div class="modal-content help-modal">
<button class="close" onclick="modalManager.closeModal('helpModal')">&times;</button>
<div class="help-header">
@@ -10,6 +10,7 @@
<button class="tab-btn active" data-tab="getting-started">{{ t('help.tabs.gettingStarted') }}</button>
<button class="tab-btn" data-tab="update-vlogs">{{ t('help.tabs.updateVlogs') }}</button>
<button class="tab-btn" data-tab="documentation">{{ t('help.tabs.documentation') }}</button>
<button class="tab-btn" data-tab="shortcuts">{{ t('help.tabs.shortcuts') }}</button>
</div>
<div class="help-content">
@@ -39,6 +40,13 @@
<li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li>
</ul>
</div>
<div class="help-actions">
<button id="replayTutorialBtn" class="replay-tutorial-btn">
<i class="fas fa-graduation-cap"></i>
<span>{{ t('help.gettingStarted.replayTutorial') }}</span>
<span class="new-content-badge">{{ t('help.newContentBadge') }}</span>
</button>
</div>
</div>
<!-- Update Vlogs Tab -->
@@ -136,6 +144,126 @@
</ul>
</div>
</div>
<!-- Shortcuts Tab -->
<div class="tab-pane" id="shortcuts">
<h3>{{ t('help.shortcuts.title') }}</h3>
<div class="shortcuts-section">
<h4><i class="fas fa-keyboard"></i> {{ t('help.shortcuts.groups.general') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>Ctrl</kbd><span class="shortcut-sep">/</span><kbd>Cmd</kbd><span class="shortcut-sep">+</span><kbd>F</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.focusSearch') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.closeModal') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>?</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.openShortcuts') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-bolt"></i> {{ t('help.shortcuts.groups.actions') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>R</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.refresh') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>F</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.fetchMetadata') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>D</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.downloadModel') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-object-group"></i> {{ t('help.shortcuts.groups.selection') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>B</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.toggleBulkMode') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Ctrl</kbd><span class="shortcut-sep">/</span><kbd>Cmd</kbd><span class="shortcut-sep">+</span><kbd>A</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.selectAll') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Shift</kbd><span class="shortcut-sep">+</span><kbd>{{ t('help.shortcuts.keys.click') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.rangeSelect') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.drag') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.marqueeSelect') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.exitBulkMode') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.rightClick') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.bulkActions') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.rightClick') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.globalActions') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-arrows-alt-v"></i> {{ t('help.shortcuts.groups.navigation') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>PageUp</kbd><span class="shortcut-sep">/</span><kbd>PageDown</kbd><span class="shortcut-sep">/</span><kbd>Home</kbd><span class="shortcut-sep">/</span><kbd>End</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.scrollPages') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Alt</kbd><span class="shortcut-sep">+</span><kbd>{{ t('help.shortcuts.keys.letter') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.jumpAlphabet') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-window-restore"></i> {{ t('help.shortcuts.groups.modelModal') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>&larr;</kbd><span class="shortcut-sep">/</span><kbd>&rarr;</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.prevNext') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Delete</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.deleteEntry') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-images"></i> {{ t('help.shortcuts.groups.mediaViewer') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>&larr;</kbd><span class="shortcut-sep">/</span><kbd>&rarr;</kbd><span class="shortcut-sep">/</span><kbd>[</kbd><span class="shortcut-sep">/</span><kbd>]</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.cycleMedia') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.swipe') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.swipeTouch') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.closeViewer') }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
@@ -133,4 +133,124 @@ describe('AutoComplete active-filters flag', () => {
expect(call[0]).not.toContain('base_model=');
}
});
const typeLorasSlashCommand = async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = 1;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
return autoComplete;
};
it('shows the active-filters state below the loras slash command list', async () => {
await typeLorasSlashCommand();
const footer = document.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('Active Filters Search: OFF');
expect(footer.textContent).toContain('/activefilters to enable');
});
it('shows how to disable active-filters search in the footer when it is on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
await typeLorasSlashCommand();
const footer = document.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('Active Filters Search: ON');
expect(footer.textContent).toContain('/noactivefilters to disable');
});
it('shows a dismissible first-run hint on loras suggestions and remembers dismissal', async () => {
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
relative_paths: ['models/example.safetensors'],
}),
});
const triggerSearch = async () => {
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
return autoComplete;
};
const autoComplete = await triggerSearch();
const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
expect(hint).not.toBeNull();
expect(hint.textContent).toContain('/activefilters');
hint.querySelector('button').click();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
expect(localStorage.getItem('lm:activefilters-tip-dismissed')).toBe('1');
// A fresh instance no longer shows the hint once dismissed
const autoComplete2 = await triggerSearch();
expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
it('does not show the loras first-run hint when active-filters search is already on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
relative_paths: ['models/example.safetensors'],
}),
});
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
});
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
SETTINGS_MODULE,
LORA_LOADER_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
SETTINGS_MODULE: new URL("../../../web/comfyui/settings.js", import.meta.url).pathname,
LORA_LOADER_MODULE: new URL("../../../web/comfyui/lora_loader.js", import.meta.url).pathname,
}));
const extensionState = { current: null };
const registerExtensionMock = vi.fn((extension) => {
extensionState.current = extension;
});
vi.mock(APP_MODULE, () => ({
app: {
registerExtension: registerExtensionMock,
graph: {},
},
}));
vi.mock(API_MODULE, () => ({
api: {
addEventListener: vi.fn(),
},
}));
const showToastMock = vi.fn();
vi.mock(UTILS_MODULE, () => ({
collectActiveLorasFromChain: vi.fn(),
updateConnectedTriggerWords: vi.fn(),
mergeLoras: vi.fn(),
chainCallback: (proto, property, callback) => {
proto[property] = callback;
},
getAllGraphNodes: vi.fn(),
getNodeFromGraph: vi.fn(),
getWidgetByName: vi.fn(),
getWidgetSerializedValue: vi.fn(),
showToast: showToastMock,
}));
const getActiveFiltersPreferenceMock = vi.fn();
const setSettingValueMock = vi.fn();
vi.mock(SETTINGS_MODULE, () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
"loramanager.lora_active_filters_autocomplete",
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
setLoraManagerSettingValue: setSettingValueMock,
}));
async function registerNodeType(comfyClass) {
await import(LORA_LOADER_MODULE);
const extension = extensionState.current;
expect(extension).toBeDefined();
const nodeType = { comfyClass, prototype: {} };
await extension.beforeRegisterNodeDef(nodeType, {}, {});
return nodeType;
}
function getMenuOption(nodeType, enabled) {
getActiveFiltersPreferenceMock.mockReturnValue(enabled);
const options = [];
nodeType.prototype.getExtraMenuOptions(null, options);
return options.find(
(option) =>
option &&
typeof option.content === "string" &&
option.content.startsWith("Active Filters Search:")
);
}
describe("Lora Loader active-filters context menu", () => {
beforeEach(() => {
vi.resetModules();
extensionState.current = null;
registerExtensionMock.mockClear();
showToastMock.mockClear();
getActiveFiltersPreferenceMock.mockReset();
setSettingValueMock.mockReset();
setSettingValueMock.mockResolvedValue(true);
});
it.each([
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
])("adds the toggle entry to the %s context menu", async (comfyClass) => {
const nodeType = await registerNodeType(comfyClass);
const option = getMenuOption(nodeType, false);
expect(option).toBeDefined();
expect(option.content).toContain("Active Filters Search: OFF");
expect(option.content).toContain("/activefilters to enable");
});
it("shows the disable hint when active-filters search is on", async () => {
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
const option = getMenuOption(nodeType, true);
expect(option.content).toContain("Active Filters Search: ON");
expect(option.content).toContain("/noactivefilters to disable");
});
it("toggles the setting and toasts feedback", async () => {
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
const enableOption = getMenuOption(nodeType, false);
await enableOption.callback();
expect(setSettingValueMock).toHaveBeenCalledWith(
"loramanager.lora_active_filters_autocomplete",
true
);
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({ summary: "Active Filters Search Enabled" })
);
const disableOption = getMenuOption(nodeType, true);
await disableOption.callback();
expect(setSettingValueMock).toHaveBeenCalledWith(
"loramanager.lora_active_filters_autocomplete",
false
);
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({ summary: "Active Filters Search Disabled" })
);
});
});
@@ -43,6 +43,12 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
openCivitaiByMetadata: openCivitaiByMetadataMock,
updatePanelPositions: updatePanelPositionsMock,
// Faithful stand-in for the real helper in uiHelpers.js
isTypingContext: (target) => {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
},
}));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
@@ -1200,4 +1206,87 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
vi.useRealTimers();
});
});
});
describe('PageControls action keyboard shortcuts', () => {
async function setupLorasControls() {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
return new LorasControls();
}
function keydownEvent(key, { target = document.body, ...init } = {}) {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init });
Object.defineProperty(event, 'target', { value: target });
return event;
}
it('registers a pageControls-actions keydown handler with the event manager', async () => {
await setupLorasControls();
const { eventManager } = await import('../../../static/js/utils/EventManager.js');
const keydownHandlers = eventManager.handlers.get('keydown') || [];
expect(keydownHandlers.some((h) => h.source === 'pageControls-actions')).toBe(true);
});
it('triggers refresh, fetch, and download via the R / F / D keys', async () => {
const controls = await setupLorasControls();
expect(controls.handleActionShortcut(keydownEvent('r'))).toBe(true);
expect(refreshModelsMock).toHaveBeenCalledWith(false);
expect(controls.handleActionShortcut(keydownEvent('f'))).toBe(true);
expect(fetchCivitaiMetadataMock).toHaveBeenCalledTimes(1);
expect(controls.handleActionShortcut(keydownEvent('d'))).toBe(true);
expect(downloadManagerMock.showDownloadModal).toHaveBeenCalledTimes(1);
});
it('handles a real keydown dispatched on the document', async () => {
await setupLorasControls();
const event = new KeyboardEvent('keydown', { key: 'r', bubbles: true, cancelable: true });
document.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(refreshModelsMock).toHaveBeenCalledWith(false);
});
it('ignores R / F / D while typing in an input', async () => {
const controls = await setupLorasControls();
const input = document.getElementById('searchInput');
for (const key of ['r', 'f', 'd']) {
const event = keydownEvent(key, { target: input });
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
}
expect(refreshModelsMock).not.toHaveBeenCalled();
expect(fetchCivitaiMetadataMock).not.toHaveBeenCalled();
expect(downloadManagerMock.showDownloadModal).not.toHaveBeenCalled();
});
it('ignores R / F / D when combined with modifier keys', async () => {
const controls = await setupLorasControls();
const event = keydownEvent('r', { ctrlKey: true });
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(refreshModelsMock).not.toHaveBeenCalled();
});
it('passes the event through when the action button does not exist', async () => {
const controls = await setupLorasControls();
// Recipes page has no fetch/download buttons
document.querySelector('[data-action="fetch"]').closest('.control-group').remove();
const event = keydownEvent('f');
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(fetchCivitaiMetadataMock).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,12 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
openCivitaiByMetadata: vi.fn(),
updatePanelPositions: vi.fn(),
// Faithful stand-in for the real helper in uiHelpers.js
isTypingContext: (target) => {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
},
}));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
@@ -0,0 +1,162 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
const HELP_MANAGER_MODULE = new URL('../../../static/js/managers/HelpManager.js', import.meta.url).pathname;
const VIEWED_KEY = 'lora_manager_help_viewed_content_version';
function setupDom({ versionMarker = null } = {}) {
const markerAttr = versionMarker ? ` data-help-content-version="${versionMarker}"` : '';
document.body.innerHTML = `
<div class="help-toggle" id="helpToggleBtn">
<span class="update-badge"></span>
</div>
<div id="helpModal" class="modal"${markerAttr}>
<div class="help-tabs">
<button class="tab-btn" data-tab="getting-started"></button>
<button class="tab-btn" data-tab="shortcuts"></button>
</div>
<div class="tab-pane active" id="getting-started">
<button id="replayTutorialBtn" class="replay-tutorial-btn"></button>
</div>
</div>
`;
}
describe('HelpManager content-version badge logic', () => {
let HelpManager;
beforeEach(async () => {
({ HelpManager } = await import(HELP_MANAGER_MODULE));
});
function badgeIsVisible() {
return document.querySelector('#helpToggleBtn .update-badge').classList.contains('visible');
}
it('has no new content when the served markup carries no version marker', () => {
setupDom({ versionMarker: null });
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(false);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(false);
});
it('has new content when a version marker exists and nothing has been viewed yet', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(true);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(true);
});
it('has no new content once the stored viewed version matches the marker', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2026-09-03');
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(false);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(false);
});
it('has new content again when the marker moves to a newer version', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2025-10-11');
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(true);
});
it('markContentAsViewed persists the DOM marker version', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
manager.markContentAsViewed();
expect(localStorage.getItem(VIEWED_KEY)).toBe('2026-09-03');
expect(manager.hasNewContent()).toBe(false);
});
it('markContentAsViewed is a no-op without a version marker (stale assets)', () => {
setupDom({ versionMarker: null });
const manager = new HelpManager();
manager.markContentAsViewed();
expect(localStorage.getItem(VIEWED_KEY)).toBeNull();
});
it('opening the help modal without new content does not mark it as viewed', () => {
// Regression test: on a stale (pre-upgrade) page the user may open the
// help modal before refreshing; that must not suppress the badge for
// the new content they have not seen yet.
setupDom({ versionMarker: null });
window.modalManager = { toggleModal: vi.fn() };
const manager = new HelpManager();
manager.openHelpModal();
expect(localStorage.getItem(VIEWED_KEY)).toBeNull();
expect(manager.hasNewContent()).toBe(false);
delete window.modalManager;
});
it('opening the help modal with new content marks it as viewed and hides the badge', () => {
setupDom({ versionMarker: '2026-09-03' });
window.modalManager = { toggleModal: vi.fn() };
const manager = new HelpManager();
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(true);
manager.openHelpModal();
expect(localStorage.getItem(VIEWED_KEY)).toBe('2026-09-03');
expect(badgeIsVisible()).toBe(false);
delete window.modalManager;
});
it('adds new-content indicators to the getting-started and shortcuts tabs', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(document.querySelector('.help-tabs .tab-btn[data-tab="getting-started"]').classList.contains('has-new-content')).toBe(true);
expect(document.querySelector('.help-tabs .tab-btn[data-tab="shortcuts"]').classList.contains('has-new-content')).toBe(true);
});
it('flags the Replay Tutorial button and scrolls it into view', () => {
setupDom({ versionMarker: '2026-09-03' });
const replayBtn = document.getElementById('replayTutorialBtn');
replayBtn.scrollIntoView = vi.fn();
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(replayBtn.classList.contains('has-new-content')).toBe(true);
expect(replayBtn.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' });
});
it('does not flag the Replay Tutorial button when the content is not new', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2026-09-03');
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(document.getElementById('replayTutorialBtn').classList.contains('has-new-content')).toBe(false);
});
it('does not scroll the Replay Tutorial button when the getting-started tab is inactive', () => {
setupDom({ versionMarker: '2026-09-03' });
document.getElementById('getting-started').classList.remove('active');
const replayBtn = document.getElementById('replayTutorialBtn');
replayBtn.scrollIntoView = vi.fn();
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(replayBtn.scrollIntoView).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,10 @@
<template>
<div class="autocomplete-text-widget">
<div class="input-wrapper">
<div
ref="inputWrapperRef"
class="input-wrapper"
:style="{ '--lm-vscrollbar-width': vScrollbarWidth + 'px' }"
>
<textarea
ref="textareaRef"
:placeholder="placeholder"
@@ -59,9 +63,49 @@ const isVueDomMode = ref(typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesM
const onModeChange = (event: Event) => {
const customEvent = event as CustomEvent<{ isVueDomMode: boolean }>
isVueDomMode.value = customEvent.detail.isVueDomMode
// Canvas vs Vue DOM mode use different paddings/geometry.
updateVScrollbarWidth()
}
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const inputWrapperRef = ref<HTMLElement | null>(null)
// Width of the textarea's own vertical scrollbar gutter. When the content
// overflows and a classic (non-overlay) scrollbar is shown, the scrollbar
// occupies the textarea's rightmost pixels and the absolutely-positioned
// corner clear (x) button would overlap it. We expose this width as a CSS
// var so the button can shift left of the scrollbar; it is 0 when there is
// no scrollbar (content fits, or platform overlay scrollbars that float
// over the content).
const vScrollbarWidth = ref(0)
let scrollbarResizeObserver: ResizeObserver | null = null
const updateVScrollbarWidth = () => {
const ta = textareaRef.value
if (!ta) return
const overflowsY = ta.scrollHeight > ta.clientHeight
vScrollbarWidth.value = overflowsY ? ta.offsetWidth - ta.clientWidth : 0
}
const observeScrollbarWidth = () => {
unobserveScrollbarWidth()
const ta = textareaRef.value
if (!ta || typeof ResizeObserver === 'undefined') {
return
}
scrollbarResizeObserver = new ResizeObserver(() => {
updateVScrollbarWidth()
})
scrollbarResizeObserver.observe(ta)
}
const unobserveScrollbarWidth = () => {
if (scrollbarResizeObserver) {
scrollbarResizeObserver.disconnect()
scrollbarResizeObserver = null
}
}
const hasText = ref(false)
// Show clear button when there is text
@@ -79,6 +123,9 @@ const updateHasTextState = () => {
}
const onInput = (event: Event) => {
// Content may grow/shrink past the overflow threshold on each edit.
updateVScrollbarWidth()
// A clear via execCommand captures the full-text selection in the browser's
// undo entry; Ctrl+Z restores the content together with that selection.
// Collapse the caret so the restored text is not left selected.
@@ -153,6 +200,9 @@ const onWheel = (event: WheelEvent) => {
// Handle external value changes (e.g., from "send lora to workflow")
const onExternalValueChange = () => {
// The DOM value was set synchronously by the caller; the new content may
// have toggled the vertical scrollbar on/off.
updateVScrollbarWidth()
updateHasTextState()
}
@@ -162,6 +212,9 @@ const setupWidgetOnSetValue = () => {
props.widget.onSetValue = (value: string) => {
// The DOM value is already set by setValue, just update our state
hasText.value = value.length > 0
// Programmatic sets can toggle the scrollbar; re-measure so the
// corner clear button stays clear of it.
updateVScrollbarWidth()
}
}
}
@@ -252,11 +305,17 @@ onMounted(() => {
// Setup widget.onSetValue callback
setupWidgetOnSetValue()
// Keep the corner buttons clear of the textarea's vertical scrollbar.
updateVScrollbarWidth()
observeScrollbarWidth()
// Listen for custom event dispatched by main.ts
document.addEventListener('lora-manager:vue-mode-change', onModeChange)
})
onUnmounted(() => {
unobserveScrollbarWidth()
// Clean up textarea reference
if (props.widget.inputEl === textareaRef.value) {
props.widget.inputEl = undefined
@@ -333,7 +392,7 @@ onUnmounted(() => {
/* Clear button styles */
.clear-button {
position: absolute;
right: 6px;
right: calc(6px + var(--lm-vscrollbar-width, 0px));
bottom: 6px; /* Changed from top to bottom */
width: 18px;
height: 18px;
@@ -371,7 +430,7 @@ onUnmounted(() => {
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button {
right: 8px;
right: calc(8px + var(--lm-vscrollbar-width, 0px));
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
@@ -16,7 +16,7 @@ import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
function createMockWidget() {
return {
callback: vi.fn(),
onSetValue: undefined,
onSetValue: undefined as ((v: string) => void) | undefined,
inputEl: undefined,
metadataWidget: undefined,
name: 'text',
@@ -134,3 +134,148 @@ describe('AutocompleteTextWidget clear button', () => {
expect(widget.callback).toHaveBeenLastCalledWith('hello world')
})
})
/**
* Tests for the vertical-scrollbar inset.
*
* When the textarea content overflows and a classic (non-overlay) scrollbar
* is shown, the absolutely-positioned corner clear (x) button would sit on
* top of the scrollbar. The component measures the scrollbar gutter and
* exposes it as the --lm-vscrollbar-width CSS var on .input-wrapper so the
* button shifts left of the scrollbar. jsdom does no layout, so overflow is
* simulated by overriding the scroll/dimension props.
*/
describe('AutocompleteTextWidget vertical scrollbar inset', () => {
function overrideTextareaMetrics(
ta: HTMLTextAreaElement,
metrics: { scrollHeight: number; clientHeight: number; offsetWidth: number; clientWidth: number }
) {
Object.defineProperty(ta, 'scrollHeight', { configurable: true, value: metrics.scrollHeight })
Object.defineProperty(ta, 'clientHeight', { configurable: true, value: metrics.clientHeight })
Object.defineProperty(ta, 'offsetWidth', { configurable: true, value: metrics.offsetWidth })
Object.defineProperty(ta, 'clientWidth', { configurable: true, value: metrics.clientWidth })
}
it('exposes the scrollbar width as a CSS var when the content overflows', async () => {
const { wrapper } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
overrideTextareaMetrics(textarea, {
scrollHeight: 200,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 305, // 15px scrollbar gutter
})
textarea.dispatchEvent(new Event('input'))
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
})
it('keeps the CSS var at 0px when there is no vertical scrollbar', async () => {
const { wrapper } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
overrideTextareaMetrics(textarea, {
scrollHeight: 100,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 320,
})
textarea.dispatchEvent(new Event('input'))
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('0px')
})
it('clears the inset once overflowing content is removed', async () => {
const { wrapper } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
overrideTextareaMetrics(textarea, {
scrollHeight: 200,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 305,
})
textarea.dispatchEvent(new Event('input'))
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
// Content now fits: no scrollbar → inset cleared
overrideTextareaMetrics(textarea, {
scrollHeight: 100,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 320,
})
textarea.dispatchEvent(new Event('input'))
await nextTick()
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('0px')
})
it('re-measures the inset when the value is set programmatically via onSetValue', async () => {
const { wrapper, widget } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
// Start with no overflow
overrideTextareaMetrics(textarea, {
scrollHeight: 100,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 320,
})
await nextTick()
// Simulate an external setValue (e.g. "send lora to workflow"): the DOM
// value is set by the caller and widget.onSetValue fires without an
// input event. Content now overflows → inset must be re-measured.
overrideTextareaMetrics(textarea, {
scrollHeight: 200,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 305, // 15px scrollbar gutter
})
if (!widget.onSetValue) throw new Error('onSetValue not installed by component')
widget.onSetValue('long content')
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
})
it('re-measures the inset on external value-change events', async () => {
const { wrapper } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
// Start with no overflow
overrideTextareaMetrics(textarea, {
scrollHeight: 100,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 320,
})
await nextTick()
// The lora-manager:autocomplete-value-changed event fires when the
// widget value is set externally; content now overflows → re-measure.
overrideTextareaMetrics(textarea, {
scrollHeight: 200,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 305, // 15px scrollbar gutter
})
textarea.dispatchEvent(
new CustomEvent('lora-manager:autocomplete-value-changed', {
detail: { value: 'long content' },
})
)
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
})
})
+78 -9
View File
@@ -1,12 +1,86 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
import { resolve } from 'path'
import { dirname, resolve } from 'path'
// Specifiers that must stay external. The bundle is emitted to
// web/comfyui/vue-widgets/, and ComfyUI serves that directory's parent
// (web/comfyui) at /extensions/ComfyUI-Lora-Manager/, so one "../" from the
// bundle reaches web/comfyui modules and three "../../.." reach ComfyUI's
// own runtime scripts at runtime.
//
// scripts/app.js and scripts/api.js are intentionally NOT listed here: they
// are externalized by the keep-runtime-modules-external plugin below, which
// also rewrites the shallower "../../scripts/*" specifiers used by modules
// inlined from web/comfyui/ so every binding dedupes into a single import.
const EXTERNAL_SPECIFIERS = [
'../loras_widget.js',
'../autocomplete.js',
'../preview_tooltip.js'
]
export default defineConfig({
plugins: [
vue(),
cssInjectedByJsPlugin() // Inject CSS into JS for ComfyUI compatibility
cssInjectedByJsPlugin(), // Inject CSS into JS for ComfyUI compatibility
// Keep shared runtime modules external instead of inlining them into
// the bundle. This guards against the inlined-shim bug class (the
// removed active-filters chip ended up writing settings to a dead
// in-memory store this way):
//
// 1. Modules under web/comfyui/ import the repo-root scripts/app.js
// TEST SHIM as "../../scripts/app.js" — a depth that resolves to
// the shim on the build filesystem but 404s at the bundle's
// runtime location. Rewrite to the canonical bundle-depth
// specifier so every app/api binding in the bundle is the REAL
// ComfyUI module.
// 2. web/comfyui/settings.js must never be duplicated into the
// bundle: it registers settings via a module-level side effect
// and owns module state. Externalize it to "../settings.js" so
// the bundle binds to the SAME vanilla module instance that the
// ComfyUI extension loader already loaded.
{
name: 'lora-manager:keep-runtime-modules-external',
enforce: 'pre',
resolveId(source, importer) {
const scriptsMatch = source.match(/^(\.\.\/)+scripts\/(app|api)\.js$/)
if (scriptsMatch) {
return { id: `../../../scripts/${scriptsMatch[2]}.js`, external: true }
}
if (
importer &&
/[\\/]web[\\/]comfyui[\\/]settings\.js$/.test(
resolve(dirname(importer), source)
)
) {
return { id: '../settings.js', external: true }
}
return null
},
},
// Warning twin of the rewrite above: importing web/comfyui/* from
// widget source inlines that module into the bundle, duplicating any
// module-level side effects/state it owns. The settings.js and
// scripts/app|api.js imports are made safe by the plugin above, but
// review any further such import deliberately.
{
name: 'lora-manager:warn-web-comfyui-imports',
enforce: 'pre',
resolveId(source, importer) {
if (
importer &&
/[\\/]vue-widgets[\\/]src[\\/]/.test(importer) &&
/[\\/]web[\\/]comfyui[\\/]/.test(source)
) {
this.warn(
`[vue-widgets] Inlining web/comfyui module "${source}" into the bundle. ` +
'settings.js and scripts/app|api.js imports are externalized by this config, ' +
'but the inlined copy still duplicates module-level side effects — verify that is intended.'
)
}
return null
},
},
],
resolve: {
alias: {
@@ -20,13 +94,7 @@ export default defineConfig({
fileName: 'lora-manager-widgets'
},
rollupOptions: {
external: [
'../../../scripts/app.js',
'../../../scripts/api.js',
'../loras_widget.js',
'../autocomplete.js',
'../preview_tooltip.js'
],
external: EXTERNAL_SPECIFIERS,
output: {
dir: '../web/comfyui/vue-widgets',
entryFileNames: 'lora-manager-widgets.js',
@@ -41,3 +109,4 @@ export default defineConfig({
'process.env.NODE_ENV': JSON.stringify('production')
}
})
+48 -16
View File
@@ -23,6 +23,8 @@ import { showToast } from "./utils.js";
// localStorage key for the one-time "how to disable" hint in the dropdown
const FIRST_RUN_HINT_DISMISSED_KEY = 'lm:autocomplete-disable-tip-dismissed';
// localStorage key for the one-time "try active filters search" hint (loras nodes)
const ACTIVE_FILTERS_HINT_DISMISSED_KEY = 'lm:activefilters-tip-dismissed';
// Command definitions for category filtering
const TAG_COMMANDS = {
@@ -1763,22 +1765,31 @@ class AutoComplete {
}
/**
* Render a state hint below the slash command list so the autocomplete
* toggle commands explain themselves. Only applies to prompt nodes.
* Render a state hint below the slash command list so the toggle commands
* explain themselves. Prompt nodes advertise /autocomplete, loras nodes
* advertise /activefilters.
*/
_renderCommandListFooter() {
this._removeCommandListFooter();
if (this.modelType !== 'prompt') {
let text = null;
if (this.modelType === 'prompt') {
const enabled = getPromptTagAutocompletePreference();
text = enabled
? 'Tag autocomplete is ON — /noautocomplete to disable'
: 'Tag autocomplete is OFF — /autocomplete to enable';
} else if (this.modelType === 'loras') {
const enabled = getLoraActiveFiltersAutocompletePreference();
text = enabled
? 'Active Filters Search: ON — /noactivefilters to disable'
: 'Active Filters Search: OFF — /activefilters to enable';
} else {
return;
}
const enabled = getPromptTagAutocompletePreference();
const footer = document.createElement('div');
footer.className = 'lm-autocomplete-command-footer';
footer.textContent = enabled
? 'Tag autocomplete is ON — /noautocomplete to disable'
: 'Tag autocomplete is OFF — /autocomplete to enable';
footer.textContent = text;
footer.style.cssText = `
padding: 6px 12px;
font-size: 11px;
@@ -1801,23 +1812,44 @@ class AutoComplete {
}
/**
* Show a one-time, dismissible hint inside the dropdown telling users how
* to disable tag autocomplete. Dismissal is persisted in localStorage.
* Show a one-time, dismissible hint inside the dropdown surfacing the
* toggle commands: prompt nodes advertise /noautocomplete, loras nodes
* advertise /activefilters. Dismissal is persisted in localStorage.
*/
_maybeShowFirstRunHint() {
if (this.firstRunHint) {
return;
}
if (this.modelType !== 'prompt'
|| this.showingCommands
|| this.searchType !== 'custom_words'
|| this.activeCommand) {
let hintText = null;
let storageKey = null;
if (this.modelType === 'prompt') {
// Only hint during plain tag searches, not command/embedding modes
if (this.showingCommands
|| this.searchType !== 'custom_words'
|| this.activeCommand) {
return;
}
hintText = 'Tip: type /noautocomplete to turn off these suggestions';
storageKey = FIRST_RUN_HINT_DISMISSED_KEY;
} else if (this.modelType === 'loras') {
// Only advertise active-filters search while it is disabled
if (this.showingCommands || this.activeCommand) {
return;
}
if (getLoraActiveFiltersAutocompletePreference()) {
return;
}
hintText = 'Tip: type /activefilters to search within the LoRA Manager page filters';
storageKey = ACTIVE_FILTERS_HINT_DISMISSED_KEY;
} else {
return;
}
let dismissed = false;
try {
dismissed = localStorage.getItem(FIRST_RUN_HINT_DISMISSED_KEY) === '1';
dismissed = localStorage.getItem(storageKey) === '1';
} catch (e) {
// localStorage unavailable - fall through and show the hint
}
@@ -1839,7 +1871,7 @@ class AutoComplete {
`;
const text = document.createElement('span');
text.textContent = 'Tip: type /noautocomplete to turn off these suggestions';
text.textContent = hintText;
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
@@ -1856,7 +1888,7 @@ class AutoComplete {
`;
closeBtn.addEventListener('click', () => {
try {
localStorage.setItem(FIRST_RUN_HINT_DISMISSED_KEY, '1');
localStorage.setItem(storageKey, '1');
} catch (e) {
}
this._removeFirstRunHint();
+70 -6
View File
@@ -1,5 +1,11 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import {
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
getLoraActiveFiltersAutocompletePreference,
setLoraManagerSettingValue,
} from "./settings.js";
import { showToast } from "./utils.js";
import {
collectActiveLorasFromChain,
updateConnectedTriggerWords,
@@ -12,6 +18,65 @@ import {
} from "./utils.js";
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
// Node classes whose "text" widget uses the loras autocomplete. Kept in sync
// with the broadcast-compatible classes in handleLoraCodeUpdate below.
const LORA_AUTOCOMPLETE_NODE_CLASSES = [
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
];
// Expose the active-filters search toggle in the node's right-click menu so
// users can discover the switch where the behavior actually happens, instead
// of only via the /activefilters and /noactivefilters slash commands. Mirrors
// the tag-autocomplete menu entry on Prompt (LoraManager) nodes.
function addActiveFiltersSearchMenuOption(nodeType) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
getExtraMenuOptions?.apply?.(this, arguments);
options.push(null);
const filtersSearchEnabled = getLoraActiveFiltersAutocompletePreference();
options.push({
content: filtersSearchEnabled
? "Active Filters Search: ON (/noactivefilters to disable)"
: "Active Filters Search: OFF (/activefilters to enable)",
callback: async () => {
const newValue = !filtersSearchEnabled;
try {
const success = await setLoraManagerSettingValue(
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
newValue
);
if (!success) {
throw new Error("settings API unavailable");
}
showToast({
severity: newValue ? "success" : "secondary",
summary: newValue
? "Active Filters Search Enabled"
: "Active Filters Search Disabled",
detail: newValue
? "LoRA autocomplete now respects the active filters of the LoRA Manager page. Type /noactivefilters in the LoRA field to disable."
: "LoRA autocomplete searches the full library again. Type /activefilters in the LoRA field to re-enable.",
life: 3000,
});
} catch (error) {
console.error("[Lora Manager] Failed to toggle setting:", error);
showToast({
severity: "error",
summary: "Error",
detail: "Failed to toggle active filters search setting",
life: 3000,
});
}
},
});
};
}
app.registerExtension({
name: "LoraManager.LoraLoader",
@@ -35,12 +100,7 @@ app.registerExtension({
// Handle broadcast mode (for Desktop/non-browser support)
if (numericNodeId === -1) {
// Find all compatible nodes in the current graph
const compatibleClasses = new Set([
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
]);
const compatibleClasses = new Set(LORA_AUTOCOMPLETE_NODE_CLASSES);
const targetNodes = getAllGraphNodes(app.graph)
.map(({ node }) => node)
.filter((node) => compatibleClasses.has(node?.comfyClass));
@@ -108,6 +168,10 @@ app.registerExtension({
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (LORA_AUTOCOMPLETE_NODE_CLASSES.includes(nodeType.comfyClass)) {
addActiveFiltersSearchMenuOption(nodeType);
}
if (nodeType.comfyClass == "Lora Loader (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Enable widget serialization
+2 -1
View File
@@ -450,7 +450,7 @@ app.registerExtension({
name: "Search LoRA autocomplete within active filters",
type: "boolean",
defaultValue: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT,
tooltip: "When enabled, LoRA autocomplete suggestions respect the active filters (folder/base model/tags) set in the LoRA Manager page. Commands /activefilters and /noactivefilters toggle this mode.",
tooltip: "When enabled, LoRA autocomplete suggestions respect the active filters (folder/base model/tags) set in the LoRA Manager page. You can also toggle it by typing /activefilters or /noactivefilters in the LoRA field, or from the node's right-click menu.",
category: ["LoRA Manager", "Autocomplete", "LoRA Active Filters"],
},
{
@@ -597,6 +597,7 @@ app.registerExtension({
export {
PROMPT_TAG_AUTOCOMPLETE_SETTING_ID,
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
getWheelSensitivity,
getAutoPathCorrectionPreference,
getAutocompleteAppendCommaPreference,
+112 -80
View File
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-4e322fec] {
.autocomplete-text-widget[data-v-793d67d2] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-4e322fec] {
.input-wrapper[data-v-793d67d2] {
position: relative;
flex: 1;
display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-4e322fec] {
.text-input[data-v-793d67d2] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -2152,7 +2152,7 @@ to { transform: rotate(360deg);
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-4e322fec] {
.text-input.vue-dom-mode[data-v-793d67d2] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2161,14 +2161,14 @@ to { transform: rotate(360deg);
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-4e322fec]:focus {
.text-input[data-v-793d67d2]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-4e322fec] {
.clear-button[data-v-793d67d2] {
position: absolute;
right: 6px;
right: calc(6px + var(--lm-vscrollbar-width, 0px));
bottom: 6px; /* Changed from top to bottom */
width: 18px;
height: 18px;
@@ -2189,31 +2189,31 @@ to { transform: rotate(360deg);
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-4e322fec] {
.input-wrapper:hover .clear-button[data-v-793d67d2] {
opacity: 0.7;
pointer-events: auto;
}
.clear-button[data-v-4e322fec]:hover {
.clear-button[data-v-793d67d2]:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg[data-v-4e322fec] {
.clear-button svg[data-v-793d67d2] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-4e322fec] {
right: 8px;
.text-input.vue-dom-mode ~ .clear-button[data-v-793d67d2] {
right: calc(8px + var(--lm-vscrollbar-width, 0px));
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button[data-v-4e322fec]:hover {
.text-input.vue-dom-mode ~ .clear-button[data-v-793d67d2]:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-4e322fec] {
.text-input.vue-dom-mode ~ .clear-button svg[data-v-793d67d2] {
width: 14px;
height: 14px;
}
@@ -2481,8 +2481,9 @@ to { transform: rotate(360deg);
}
})();
var _a;
import { app as app$1 } from "../../../scripts/app.js";
import { api as api$1 } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import "../settings.js";
/**
* @vue/shared v3.5.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors
@@ -11052,7 +11053,7 @@ const _sfc_main$o = /* @__PURE__ */ defineComponent({
const EditButton = /* @__PURE__ */ _export_sfc(_sfc_main$o, [["__scopeId", "data-v-8da8aa4b"]]);
const _hoisted_1$k = { class: "section" };
const _hoisted_2$j = { class: "section__header" };
const _hoisted_3$h = { class: "section__content" };
const _hoisted_3$g = { class: "section__content" };
const _hoisted_4$f = {
key: 0,
class: "section__placeholder"
@@ -11082,7 +11083,7 @@ const _sfc_main$n = /* @__PURE__ */ defineComponent({
onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("edit"))
})
]),
createBaseVNode("div", _hoisted_3$h, [
createBaseVNode("div", _hoisted_3$g, [
__props.selected.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$f, " All models ")) : (openBlock(), createElementBlock("div", _hoisted_5$d, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.selected, (name) => {
return openBlock(), createBlock(FilterChip, {
@@ -11101,7 +11102,7 @@ const _sfc_main$n = /* @__PURE__ */ defineComponent({
const BaseModelSection = /* @__PURE__ */ _export_sfc(_sfc_main$n, [["__scopeId", "data-v-12f059e2"]]);
const _hoisted_1$j = { class: "section" };
const _hoisted_2$i = { class: "section__columns" };
const _hoisted_3$g = { class: "section__column" };
const _hoisted_3$f = { class: "section__column" };
const _hoisted_4$e = { class: "section__column-header" };
const _hoisted_5$c = { class: "section__column-content" };
const _hoisted_6$c = {
@@ -11137,7 +11138,7 @@ const _sfc_main$m = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "TAGS")
], -1)),
createBaseVNode("div", _hoisted_2$i, [
createBaseVNode("div", _hoisted_3$g, [
createBaseVNode("div", _hoisted_3$f, [
createBaseVNode("div", _hoisted_4$e, [
_cache[2] || (_cache[2] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
createVNode(EditButton, {
@@ -11183,7 +11184,7 @@ const _sfc_main$m = /* @__PURE__ */ defineComponent({
const TagsSection = /* @__PURE__ */ _export_sfc(_sfc_main$m, [["__scopeId", "data-v-b869b780"]]);
const _hoisted_1$i = { class: "section" };
const _hoisted_2$h = { class: "section__columns" };
const _hoisted_3$f = { class: "section__column" };
const _hoisted_3$e = { class: "section__column" };
const _hoisted_4$d = { class: "section__column-header" };
const _hoisted_5$b = { class: "section__content" };
const _hoisted_6$b = {
@@ -11231,7 +11232,7 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "FOLDERS")
], -1)),
createBaseVNode("div", _hoisted_2$h, [
createBaseVNode("div", _hoisted_3$f, [
createBaseVNode("div", _hoisted_3$e, [
createBaseVNode("div", _hoisted_4$d, [
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
createBaseVNode("button", {
@@ -11299,7 +11300,7 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
const FoldersSection = /* @__PURE__ */ _export_sfc(_sfc_main$l, [["__scopeId", "data-v-af9caf84"]]);
const _hoisted_1$h = { class: "section" };
const _hoisted_2$g = { class: "section__header" };
const _hoisted_3$e = { class: "section__toggle" };
const _hoisted_3$d = { class: "section__toggle" };
const _hoisted_4$c = ["checked"];
const _hoisted_5$a = { class: "section__columns" };
const _hoisted_6$a = { class: "section__column" };
@@ -11355,7 +11356,7 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
return openBlock(), createElementBlock("div", _hoisted_1$h, [
createBaseVNode("div", _hoisted_2$g, [
_cache[4] || (_cache[4] = createBaseVNode("span", { class: "section__title" }, "NAME PATTERNS", -1)),
createBaseVNode("label", _hoisted_3$e, [
createBaseVNode("label", _hoisted_3$d, [
createBaseVNode("input", {
type: "checkbox",
checked: __props.useRegex,
@@ -11439,7 +11440,7 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
const NamePatternsSection = /* @__PURE__ */ _export_sfc(_sfc_main$k, [["__scopeId", "data-v-9995b5ed"]]);
const _hoisted_1$g = { class: "section" };
const _hoisted_2$f = { class: "section__toggles" };
const _hoisted_3$d = { class: "toggle-item" };
const _hoisted_3$c = { class: "toggle-item" };
const _hoisted_4$b = ["aria-checked"];
const _hoisted_5$9 = { class: "toggle-item" };
const _hoisted_6$9 = ["aria-checked"];
@@ -11457,7 +11458,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "LICENSE")
], -1)),
createBaseVNode("div", _hoisted_2$f, [
createBaseVNode("label", _hoisted_3$d, [
createBaseVNode("label", _hoisted_3$c, [
_cache[3] || (_cache[3] = createBaseVNode("span", {
class: "toggle-item__label",
title: "Use the model without crediting the creator"
@@ -11497,7 +11498,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$j, [["__scopeId", "data-v-07ddd3df"]]);
const _hoisted_1$f = { class: "preview" };
const _hoisted_2$e = { class: "preview__title" };
const _hoisted_3$c = ["disabled"];
const _hoisted_3$b = ["disabled"];
const _hoisted_4$a = {
key: 0,
class: "preview__tooltip"
@@ -11556,7 +11557,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
d: "M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"
})
], -1)
])], 10, _hoisted_3$c)
])], 10, _hoisted_3$b)
], 32),
createVNode(Transition, { name: "tooltip" }, {
default: withCtx(() => [
@@ -11668,7 +11669,7 @@ const _sfc_main$h = /* @__PURE__ */ defineComponent({
const LoraPoolSummaryView = /* @__PURE__ */ _export_sfc(_sfc_main$h, [["__scopeId", "data-v-83235a00"]]);
const _hoisted_1$d = { class: "lora-pool-modal__header" };
const _hoisted_2$c = { class: "lora-pool-modal__title-container" };
const _hoisted_3$b = { class: "lora-pool-modal__title" };
const _hoisted_3$a = { class: "lora-pool-modal__title" };
const _hoisted_4$9 = {
key: 0,
class: "lora-pool-modal__subtitle"
@@ -11728,7 +11729,7 @@ const _sfc_main$g = /* @__PURE__ */ defineComponent({
}, [
createBaseVNode("div", _hoisted_1$d, [
createBaseVNode("div", _hoisted_2$c, [
createBaseVNode("h3", _hoisted_3$b, toDisplayString(__props.title), 1),
createBaseVNode("h3", _hoisted_3$a, toDisplayString(__props.title), 1),
__props.subtitle ? (openBlock(), createElementBlock("p", _hoisted_4$9, toDisplayString(__props.subtitle), 1)) : createCommentVNode("", true)
]),
createBaseVNode("button", {
@@ -11756,7 +11757,7 @@ const _sfc_main$g = /* @__PURE__ */ defineComponent({
const ModalWrapper = /* @__PURE__ */ _export_sfc(_sfc_main$g, [["__scopeId", "data-v-7b4de03d"]]);
const _hoisted_1$c = { class: "search-container" };
const _hoisted_2$b = { class: "model-list" };
const _hoisted_3$a = ["checked", "onChange"];
const _hoisted_3$9 = ["checked", "onChange"];
const _hoisted_4$8 = { class: "model-checkbox-visual" };
const _hoisted_5$6 = {
key: 0,
@@ -11866,7 +11867,7 @@ const _sfc_main$f = /* @__PURE__ */ defineComponent({
checked: isSelected(model.name),
onChange: ($event) => toggleModel(model.name),
class: "model-checkbox"
}, null, 40, _hoisted_3$a),
}, null, 40, _hoisted_3$9),
createBaseVNode("span", _hoisted_4$8, [
isSelected(model.name) ? (openBlock(), createElementBlock("svg", _hoisted_5$6, [..._cache[4] || (_cache[4] = [
createBaseVNode("path", { d: "M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" }, null, -1)
@@ -11887,7 +11888,7 @@ const _sfc_main$f = /* @__PURE__ */ defineComponent({
const BaseModelModal = /* @__PURE__ */ _export_sfc(_sfc_main$f, [["__scopeId", "data-v-e02ca44a"]]);
const _hoisted_1$b = { class: "search-container" };
const _hoisted_2$a = ["onClick"];
const _hoisted_3$9 = {
const _hoisted_3$8 = {
key: 0,
class: "no-results"
};
@@ -12031,7 +12032,7 @@ const _sfc_main$e = /* @__PURE__ */ defineComponent({
onClick: ($event) => toggleTag(tag.tag)
}, toDisplayString(tag.tag), 11, _hoisted_2$a);
}), 128)),
visibleTags.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_3$9, " No tags found ")) : createCommentVNode("", true),
visibleTags.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_3$8, " No tags found ")) : createCommentVNode("", true),
hasMoreTags.value ? (openBlock(), createElementBlock("div", _hoisted_4$7, " Scroll to load more... ")) : createCommentVNode("", true)
], 544)
]),
@@ -12046,7 +12047,7 @@ const _hoisted_2$9 = {
key: 1,
class: "tree-node__toggle-spacer"
};
const _hoisted_3$8 = { class: "tree-node__checkbox-label" };
const _hoisted_3$7 = { class: "tree-node__checkbox-label" };
const _hoisted_4$6 = ["checked"];
const _hoisted_5$5 = {
key: 0,
@@ -12107,7 +12108,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
createBaseVNode("path", { d: "M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" }, null, -1)
])], 2))
])) : (openBlock(), createElementBlock("span", _hoisted_2$9)),
createBaseVNode("label", _hoisted_3$8, [
createBaseVNode("label", _hoisted_3$7, [
createBaseVNode("input", {
type: "checkbox",
class: "tree-node__checkbox",
@@ -12153,7 +12154,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
const FolderTreeNode = /* @__PURE__ */ _export_sfc(_sfc_main$d, [["__scopeId", "data-v-90187dd4"]]);
const _hoisted_1$9 = { class: "search-container" };
const _hoisted_2$8 = { class: "folder-tree" };
const _hoisted_3$7 = {
const _hoisted_3$6 = {
key: 1,
class: "no-results"
};
@@ -12252,7 +12253,7 @@ const _sfc_main$c = /* @__PURE__ */ defineComponent({
onToggleExpand: toggleExpand,
onToggleSelect: toggleSelect
}, null, 8, ["node", "selected", "expanded", "variant"]);
}), 128)) : (openBlock(), createElementBlock("div", _hoisted_3$7, " No folders found "))
}), 128)) : (openBlock(), createElementBlock("div", _hoisted_3$6, " No folders found "))
])
]),
_: 1
@@ -12658,7 +12659,7 @@ const _sfc_main$b = /* @__PURE__ */ defineComponent({
const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$b, [["__scopeId", "data-v-ed73eab5"]]);
const _hoisted_1$8 = { class: "last-used-preview" };
const _hoisted_2$7 = { class: "last-used-preview__content" };
const _hoisted_3$6 = ["src", "onError"];
const _hoisted_3$5 = ["src", "onError"];
const _hoisted_4$5 = {
key: 1,
class: "last-used-preview__thumb last-used-preview__thumb--placeholder"
@@ -12710,7 +12711,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
src: previewUrls.value[lora.name],
class: "last-used-preview__thumb",
onError: ($event) => onImageError(lora.name)
}, null, 40, _hoisted_3$6)) : (openBlock(), createElementBlock("div", _hoisted_4$5, [..._cache[0] || (_cache[0] = [
}, null, 40, _hoisted_3$5)) : (openBlock(), createElementBlock("div", _hoisted_4$5, [..._cache[0] || (_cache[0] = [
createBaseVNode("svg", {
viewBox: "0 0 16 16",
fill: "currentColor"
@@ -13151,7 +13152,7 @@ const _sfc_main$8 = /* @__PURE__ */ defineComponent({
const DualRangeSlider = /* @__PURE__ */ _export_sfc(_sfc_main$8, [["__scopeId", "data-v-e0c8dc9f"]]);
const _hoisted_1$5 = { class: "randomizer-settings" };
const _hoisted_2$5 = { class: "setting-section" };
const _hoisted_3$5 = { class: "count-mode-tabs" };
const _hoisted_3$4 = { class: "count-mode-tabs" };
const _hoisted_4$4 = ["checked"];
const _hoisted_5$3 = ["checked"];
const _hoisted_6$3 = { class: "slider-container" };
@@ -13216,7 +13217,7 @@ const _sfc_main$7 = /* @__PURE__ */ defineComponent({
], -1)),
createBaseVNode("div", _hoisted_2$5, [
_cache[20] || (_cache[20] = createBaseVNode("label", { class: "setting-label" }, "LoRA Count", -1)),
createBaseVNode("div", _hoisted_3$5, [
createBaseVNode("div", _hoisted_3$4, [
createBaseVNode("label", {
class: normalizeClass(["count-mode-tab", { active: __props.countMode === "fixed" }])
}, [
@@ -13847,7 +13848,7 @@ const _sfc_main$6 = /* @__PURE__ */ defineComponent({
const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$6, [["__scopeId", "data-v-ca6e8cec"]]);
const _hoisted_1$4 = { class: "cycler-settings" };
const _hoisted_2$4 = { class: "setting-section progress-section" };
const _hoisted_3$4 = { class: "progress-label" };
const _hoisted_3$3 = { class: "progress-label" };
const _hoisted_4$3 = ["title"];
const _hoisted_5$2 = { class: "progress-counter" };
const _hoisted_6$2 = { class: "progress-index" };
@@ -13972,7 +13973,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
class: normalizeClass(["progress-info", { disabled: __props.isPauseDisabled }]),
onClick: handleOpenSelector
}, [
createBaseVNode("span", _hoisted_3$4, toDisplayString(__props.isWorkflowExecuting ? "Using LoRA:" : "Next LoRA:"), 1),
createBaseVNode("span", _hoisted_3$3, toDisplayString(__props.isWorkflowExecuting ? "Using LoRA:" : "Next LoRA:"), 1),
createBaseVNode("span", {
class: normalizeClass(["progress-name clickable", { disabled: __props.isPauseDisabled, "no-lora": __props.isNoLora }]),
title: __props.currentLoraFilename
@@ -14172,7 +14173,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
const LoraCyclerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-f0663be4"]]);
const _hoisted_1$3 = { class: "search-container" };
const _hoisted_2$3 = { class: "lora-list" };
const _hoisted_3$3 = ["onMouseenter", "onClick"];
const _hoisted_3$2 = ["onMouseenter", "onClick"];
const _hoisted_4$2 = { class: "lora-index" };
const _hoisted_5$1 = ["title"];
const _hoisted_6$1 = {
@@ -14353,7 +14354,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
title: item.lora.file_name
}, toDisplayString(item.lora.file_name), 9, _hoisted_5$1),
__props.currentIndex === item.index ? (openBlock(), createElementBlock("span", _hoisted_6$1, "Current")) : createCommentVNode("", true)
], 42, _hoisted_3$3);
], 42, _hoisted_3$2);
}), 128)),
filteredList.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_7$1, " No LoRAs found ")) : createCommentVNode("", true)
])
@@ -14969,7 +14970,7 @@ const _hoisted_2$2 = {
class: "json-content",
ref: "contentRef"
};
const _hoisted_3$2 = ["innerHTML"];
const _hoisted_3$1 = ["innerHTML"];
const _hoisted_4$1 = {
key: 1,
class: "placeholder"
@@ -15064,7 +15065,7 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
hasMetadata.value ? (openBlock(), createElementBlock("pre", {
key: 0,
innerHTML: highlightedJson.value
}, null, 8, _hoisted_3$2)) : (openBlock(), createElementBlock("div", _hoisted_4$1, "No metadata available"))
}, null, 8, _hoisted_3$1)) : (openBlock(), createElementBlock("div", _hoisted_4$1, "No metadata available"))
], 512)
]);
};
@@ -15137,8 +15138,7 @@ function useAutocomplete(textareaRef, modelType = "loras", options = {}) {
};
}
const _hoisted_1$1 = { class: "autocomplete-text-widget" };
const _hoisted_2$1 = { class: "input-wrapper" };
const _hoisted_3$1 = ["placeholder", "spellcheck"];
const _hoisted_2$1 = ["placeholder", "spellcheck"];
const _sfc_main$1 = /* @__PURE__ */ defineComponent({
__name: "AutocompleteTextWidget",
props: {
@@ -15156,8 +15156,35 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
const onModeChange = (event) => {
const customEvent = event;
isVueDomMode.value = customEvent.detail.isVueDomMode;
updateVScrollbarWidth();
};
const textareaRef = ref(null);
const inputWrapperRef = ref(null);
const vScrollbarWidth = ref(0);
let scrollbarResizeObserver = null;
const updateVScrollbarWidth = () => {
const ta = textareaRef.value;
if (!ta) return;
const overflowsY = ta.scrollHeight > ta.clientHeight;
vScrollbarWidth.value = overflowsY ? ta.offsetWidth - ta.clientWidth : 0;
};
const observeScrollbarWidth = () => {
unobserveScrollbarWidth();
const ta = textareaRef.value;
if (!ta || typeof ResizeObserver === "undefined") {
return;
}
scrollbarResizeObserver = new ResizeObserver(() => {
updateVScrollbarWidth();
});
scrollbarResizeObserver.observe(ta);
};
const unobserveScrollbarWidth = () => {
if (scrollbarResizeObserver) {
scrollbarResizeObserver.disconnect();
scrollbarResizeObserver = null;
}
};
const hasText = ref(false);
const showClearButton = computed(() => hasText.value);
useAutocomplete(
@@ -15169,6 +15196,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false;
};
const onInput = (event) => {
updateVScrollbarWidth();
if (event.inputType === "historyUndo") {
const ta = textareaRef.value;
if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) {
@@ -15211,12 +15239,14 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
}
};
const onExternalValueChange = () => {
updateVScrollbarWidth();
updateHasTextState();
};
const setupWidgetOnSetValue = () => {
if (props.widget) {
props.widget.onSetValue = (value) => {
hasText.value = value.length > 0;
updateVScrollbarWidth();
};
}
};
@@ -15266,9 +15296,12 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
props.widget.callback(textareaRef.value.value);
}
setupWidgetOnSetValue();
updateVScrollbarWidth();
observeScrollbarWidth();
document.addEventListener("lora-manager:vue-mode-change", onModeChange);
});
onUnmounted(() => {
unobserveScrollbarWidth();
if (props.widget.inputEl === textareaRef.value) {
props.widget.inputEl = void 0;
}
@@ -15285,7 +15318,12 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
});
return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$1, [
createBaseVNode("div", _hoisted_2$1, [
createBaseVNode("div", {
ref_key: "inputWrapperRef",
ref: inputWrapperRef,
class: "input-wrapper",
style: normalizeStyle({ "--lm-vscrollbar-width": vScrollbarWidth.value + "px" })
}, [
createBaseVNode("textarea", {
ref_key: "textareaRef",
ref: textareaRef,
@@ -15296,7 +15334,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
"data-capture-wheel": "true",
onInput,
onWheel
}, null, 46, _hoisted_3$1),
}, null, 46, _hoisted_2$1),
showClearButton.value ? (openBlock(), createElementBlock("button", {
key: 0,
type: "button",
@@ -15324,12 +15362,12 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
})
], -1)
])])) : createCommentVNode("", true)
])
], 4)
]);
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-4e322fec"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-793d67d2"]]);
const _hoisted_1 = { class: "lora-info-tabs" };
const _hoisted_2 = { class: "tab-content notes-tab" };
const _hoisted_3 = { class: "info-field" };
@@ -15746,12 +15784,6 @@ function createModeChangeCallback(node, updateDownstreamLoaders2, nodeSpecificCa
updateDownstreamLoaders2(node);
};
}
const app = {};
const api = {
fetchApi: (...args) => fetch(...args),
addEventListener: (eventName, handler) => document.addEventListener(eventName, handler),
removeEventListener: (eventName, handler) => document.removeEventListener(eventName, handler)
};
let _loraSyntaxFormatCache = null;
let _loraSyntaxFormatRefreshPromise = null;
async function _fetchLoraSyntaxFormat() {
@@ -16108,8 +16140,8 @@ const AUTOCOMPLETE_TEXT_MIN_WIDTH_DEFAULT = 400;
const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300;
const AUTOCOMPLETE_METADATA_VERSION = 1;
const LORA_MANAGER_WIDGET_IDS_PROPERTY = "__lm_widget_ids";
const originalGraphToPrompt = app$1.graphToPrompt.bind(app$1);
app$1.graphToPrompt = async (...args) => {
const originalGraphToPrompt = app.graphToPrompt.bind(app);
app.graphToPrompt = async (...args) => {
const result = await originalGraphToPrompt(...args);
stripAutocompleteMetadataFromPromptResult(result);
return result;
@@ -16118,7 +16150,7 @@ function forwardMiddleMouseToCanvas(container) {
if (!container) return;
container.addEventListener("pointerdown", (event) => {
if (event.button === 1) {
const canvas = app$1.canvas;
const canvas = app.canvas;
if (canvas && typeof canvas.processMouseDown === "function") {
canvas.processMouseDown(event);
}
@@ -16126,7 +16158,7 @@ function forwardMiddleMouseToCanvas(container) {
});
container.addEventListener("pointermove", (event) => {
if ((event.buttons & 4) === 4) {
const canvas = app$1.canvas;
const canvas = app.canvas;
if (canvas && typeof canvas.processMouseMove === "function") {
canvas.processMouseMove(event);
}
@@ -16134,7 +16166,7 @@ function forwardMiddleMouseToCanvas(container) {
});
container.addEventListener("pointerup", (event) => {
if (event.button === 1) {
const canvas = app$1.canvas;
const canvas = app.canvas;
if (canvas && typeof canvas.processMouseUp === "function") {
canvas.processMouseUp(event);
}
@@ -16252,7 +16284,7 @@ function createLoraRandomizerWidget(node) {
const vueApp = createApp(LoraRandomizerWidget, {
widget,
node,
api: api$1
api
});
vueApp.use(PrimeVue, {
unstyled: true,
@@ -16327,7 +16359,7 @@ function createLoraCyclerWidget(node) {
const vueApp = createApp(LoraCyclerWidget, {
widget,
node,
api: api$1
api
});
vueApp.use(PrimeVue, {
unstyled: true,
@@ -16515,13 +16547,13 @@ function applyAutocompleteTextLayoutFix(widget, _container, isVueMode) {
}
const initVueDomModeListener = () => {
var _a2, _b;
if ((_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.addEventListener) {
app$1.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", () => {
if ((_b = (_a2 = app.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.addEventListener) {
app.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", () => {
requestAnimationFrame(() => {
var _a3, _b2, _c, _d, _e2, _f;
const isVueDomMode = ((_c = (_b2 = (_a3 = app$1.ui) == null ? void 0 : _a3.settings) == null ? void 0 : _b2.getSettingValue) == null ? void 0 : _c.call(_b2, "Comfy.VueNodes.Enabled")) ?? false;
if ((_d = app$1.graph) == null ? void 0 : _d.nodes) {
for (const node of app$1.graph.nodes) {
const isVueDomMode = ((_c = (_b2 = (_a3 = app.ui) == null ? void 0 : _a3.settings) == null ? void 0 : _b2.getSettingValue) == null ? void 0 : _c.call(_b2, "Comfy.VueNodes.Enabled")) ?? false;
if ((_d = app.graph) == null ? void 0 : _d.nodes) {
for (const node of app.graph.nodes) {
const textWidget = (_e2 = node.widgets) == null ? void 0 : _e2.find(
(w2) => w2.type === "AUTOCOMPLETE_TEXT_LORAS"
);
@@ -16536,7 +16568,7 @@ const initVueDomModeListener = () => {
const grid = nodeEl.querySelector('[data-testid="node-widgets"]');
if (!grid) continue;
const nodeId = nodeEl.getAttribute("data-node-id");
const node = (_a4 = app$1.graph) == null ? void 0 : _a4.getNodeById(nodeId);
const node = (_a4 = app.graph) == null ? void 0 : _a4.getNodeById(nodeId);
if (!node) continue;
const rows = [];
let needsFix = false;
@@ -16557,7 +16589,7 @@ const initVueDomModeListener = () => {
}
}
});
(_f = app$1.canvas) == null ? void 0 : _f.setDirty(true, true);
(_f = app.canvas) == null ? void 0 : _f.setDirty(true, true);
document.dispatchEvent(new CustomEvent("lora-manager:vue-mode-change", {
detail: { isVueDomMode }
}));
@@ -16565,12 +16597,12 @@ const initVueDomModeListener = () => {
});
}
};
if ((_a = app$1.ui) == null ? void 0 : _a.settings) {
if ((_a = app.ui) == null ? void 0 : _a.settings) {
initVueDomModeListener();
} else {
const checkAppReady = setInterval(() => {
var _a2;
if ((_a2 = app$1.ui) == null ? void 0 : _a2.settings) {
if ((_a2 = app.ui) == null ? void 0 : _a2.settings) {
initVueDomModeListener();
clearInterval(checkAppReady);
}
@@ -16609,8 +16641,8 @@ function createLoraInfoWidget(node) {
const vueApp = createApp(LoraInfoWidget, {
widget,
node,
api: api$1,
app: app$1,
api,
app,
isVueMode: typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode
});
vueApp.use(PrimeVue, {
@@ -16705,7 +16737,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
}
);
widget.metadataWidget = metadataWidget;
const spellcheck = ((_c = (_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
const spellcheck = ((_c = (_b = (_a2 = app.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
const maxHeight = modelType === "loras" ? AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT : void 0;
const vueApp = createApp(AutocompleteTextWidget, {
widget,
@@ -16743,7 +16775,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
const minHeight = modelType === "loras" ? void 0 : AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT;
return { widget, minWidth, minHeight };
}
app$1.registerExtension({
app.registerExtension({
name: "LoraManager.VueWidgets",
getCustomWidgets() {
return {
File diff suppressed because one or more lines are too long