mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 13:12:12 -03:00
feat(autocomplete): implement virtual scrolling and pagination
- Add virtual scrolling with configurable visible items (default: 15) - Implement pagination with offset/limit for backend APIs - Support loading more items on scroll - Fix width calculation for suggestions dropdown - Update backend services to support offset parameter Files modified: - web/comfyui/autocomplete.js (virtual scroll, pagination) - py/services/base_model_service.py (offset support) - py/services/custom_words_service.py (offset support) - py/services/tag_fts_index.py (offset support) - py/routes/handlers/model_handlers.py (offset param) - py/routes/handlers/misc_handlers.py (offset param)
This commit is contained in:
@@ -240,11 +240,7 @@ class SupportersHandler:
|
||||
except Exception as e:
|
||||
self._logger.debug(f"Failed to load supporters data: {e}")
|
||||
|
||||
return {
|
||||
"specialThanks": [],
|
||||
"allSupporters": [],
|
||||
"totalCount": 0
|
||||
}
|
||||
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
|
||||
|
||||
async def get_supporters(self, request: web.Request) -> web.Response:
|
||||
"""Return supporters data as JSON."""
|
||||
@@ -253,9 +249,7 @@ class SupportersHandler:
|
||||
return web.json_response({"success": True, "supporters": supporters})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error loading supporters: %s", exc, exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class SettingsHandler:
|
||||
@@ -263,15 +257,17 @@ class SettingsHandler:
|
||||
|
||||
# Settings keys that should NOT be synced to frontend.
|
||||
# All other settings are synced by default.
|
||||
_NO_SYNC_KEYS = frozenset({
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
})
|
||||
_NO_SYNC_KEYS = frozenset(
|
||||
{
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
}
|
||||
)
|
||||
|
||||
_PROXY_KEYS = {
|
||||
"proxy_enabled",
|
||||
@@ -1226,6 +1222,7 @@ class CustomWordsHandler:
|
||||
|
||||
def __init__(self) -> None:
|
||||
from ...services.custom_words_service import get_custom_words_service
|
||||
|
||||
self._service = get_custom_words_service()
|
||||
|
||||
async def search_custom_words(self, request: web.Request) -> web.Response:
|
||||
@@ -1234,6 +1231,7 @@ class CustomWordsHandler:
|
||||
Query parameters:
|
||||
search: The search term to match against.
|
||||
limit: Maximum number of results to return (default: 20).
|
||||
offset: Number of results to skip (default: 0).
|
||||
category: Optional category filter. Can be:
|
||||
- A category name (e.g., "character", "artist", "general")
|
||||
- Comma-separated category IDs (e.g., "4,11" for character)
|
||||
@@ -1243,6 +1241,7 @@ class CustomWordsHandler:
|
||||
try:
|
||||
search_term = request.query.get("search", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
category_param = request.query.get("category", "")
|
||||
enriched_param = request.query.get("enriched", "").lower() == "true"
|
||||
|
||||
@@ -1252,13 +1251,14 @@ class CustomWordsHandler:
|
||||
categories = self._parse_category_param(category_param)
|
||||
|
||||
results = self._service.search_words(
|
||||
search_term, limit, categories=categories, enriched=enriched_param
|
||||
search_term,
|
||||
limit,
|
||||
offset=offset,
|
||||
categories=categories,
|
||||
enriched=enriched_param,
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"words": results
|
||||
})
|
||||
return web.json_response({"success": True, "words": results})
|
||||
except Exception as exc:
|
||||
logger.error("Error searching custom words: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
@@ -1268,8 +1268,11 @@ class ModelQueryHandler:
|
||||
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
search = request.query.get("search", "").strip()
|
||||
limit = min(int(request.query.get("limit", "15")), 50)
|
||||
matching_paths = await self._service.search_relative_paths(search, limit)
|
||||
limit = min(int(request.query.get("limit", "15")), 100)
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
matching_paths = await self._service.search_relative_paths(
|
||||
search, limit, offset
|
||||
)
|
||||
return web.json_response(
|
||||
{"success": True, "relative_paths": matching_paths}
|
||||
)
|
||||
|
||||
@@ -383,7 +383,9 @@ class BaseModelService(ABC):
|
||||
# Check user setting for hiding early access updates
|
||||
hide_early_access = False
|
||||
try:
|
||||
hide_early_access = bool(self.settings.get("hide_early_access_updates", False))
|
||||
hide_early_access = bool(
|
||||
self.settings.get("hide_early_access_updates", False)
|
||||
)
|
||||
except Exception:
|
||||
hide_early_access = False
|
||||
|
||||
@@ -413,7 +415,11 @@ class BaseModelService(ABC):
|
||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||
if callable(bulk_method):
|
||||
try:
|
||||
resolved = await bulk_method(self.model_type, ordered_ids, hide_early_access=hide_early_access)
|
||||
resolved = await bulk_method(
|
||||
self.model_type,
|
||||
ordered_ids,
|
||||
hide_early_access=hide_early_access,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||
@@ -426,7 +432,9 @@ class BaseModelService(ABC):
|
||||
|
||||
if resolved is None:
|
||||
tasks = [
|
||||
self.update_service.has_update(self.model_type, model_id, hide_early_access=hide_early_access)
|
||||
self.update_service.has_update(
|
||||
self.model_type, model_id, hide_early_access=hide_early_access
|
||||
)
|
||||
for model_id in ordered_ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -588,13 +596,19 @@ class BaseModelService(ABC):
|
||||
normalized_type = normalize_sub_type(resolve_sub_type(entry))
|
||||
if not normalized_type:
|
||||
continue
|
||||
|
||||
|
||||
# Filter by valid sub-types based on scanner type
|
||||
if self.model_type == "lora" and normalized_type not in VALID_LORA_SUB_TYPES:
|
||||
if (
|
||||
self.model_type == "lora"
|
||||
and normalized_type not in VALID_LORA_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
if self.model_type == "checkpoint" and normalized_type not in VALID_CHECKPOINT_SUB_TYPES:
|
||||
if (
|
||||
self.model_type == "checkpoint"
|
||||
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
|
||||
|
||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||
|
||||
sorted_types = sorted(
|
||||
@@ -838,7 +852,7 @@ class BaseModelService(ABC):
|
||||
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
||||
|
||||
async def search_relative_paths(
|
||||
self, search_term: str, limit: int = 15
|
||||
self, search_term: str, limit: int = 15, offset: int = 0
|
||||
) -> List[str]:
|
||||
"""Search model relative file paths for autocomplete functionality"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
@@ -849,6 +863,7 @@ class BaseModelService(ABC):
|
||||
# Get model roots for path calculation
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
# Collect all matching paths first (needed for proper sorting and offset)
|
||||
for model in cache.raw_data:
|
||||
file_path = model.get("file_path", "")
|
||||
if not file_path:
|
||||
@@ -877,12 +892,12 @@ class BaseModelService(ABC):
|
||||
):
|
||||
matching_paths.append(relative_path)
|
||||
|
||||
if len(matching_paths) >= limit * 2: # Get more for better sorting
|
||||
break
|
||||
|
||||
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
|
||||
matching_paths.sort(
|
||||
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
|
||||
)
|
||||
|
||||
return matching_paths[:limit]
|
||||
# Apply offset and limit
|
||||
start = min(offset, len(matching_paths))
|
||||
end = min(start + limit, len(matching_paths))
|
||||
return matching_paths[start:end]
|
||||
|
||||
@@ -49,6 +49,7 @@ class CustomWordsService:
|
||||
if self._tag_index is None:
|
||||
try:
|
||||
from .tag_fts_index import get_tag_fts_index
|
||||
|
||||
self._tag_index = get_tag_fts_index()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TagFTSIndex: {e}")
|
||||
@@ -59,14 +60,16 @@ class CustomWordsService:
|
||||
self,
|
||||
search_term: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
categories: Optional[List[int]] = None,
|
||||
enriched: bool = False
|
||||
enriched: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search tags using TagFTSIndex with category filtering.
|
||||
|
||||
Args:
|
||||
search_term: The search term to match against.
|
||||
limit: Maximum number of results to return.
|
||||
offset: Number of results to skip.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
enriched: If True, always return enriched results with category
|
||||
and post_count (default behavior now).
|
||||
@@ -76,7 +79,9 @@ class CustomWordsService:
|
||||
"""
|
||||
tag_index = self._get_tag_index()
|
||||
if tag_index is not None:
|
||||
results = tag_index.search(search_term, categories=categories, limit=limit)
|
||||
results = tag_index.search(
|
||||
search_term, categories=categories, limit=limit, offset=offset
|
||||
)
|
||||
return results
|
||||
|
||||
logger.debug("TagFTSIndex not available, returning empty results")
|
||||
|
||||
@@ -69,7 +69,9 @@ class TagFTSIndex:
|
||||
_DEFAULT_FILENAME = "tag_fts.sqlite"
|
||||
_CSV_FILENAME = "danbooru_e621_merged.csv"
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None, csv_path: Optional[str] = None) -> None:
|
||||
def __init__(
|
||||
self, db_path: Optional[str] = None, csv_path: Optional[str] = None
|
||||
) -> None:
|
||||
"""Initialize the FTS index.
|
||||
|
||||
Args:
|
||||
@@ -92,7 +94,9 @@ class TagFTSIndex:
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not create FTS index directory %s: %s", directory, exc)
|
||||
logger.warning(
|
||||
"Could not create FTS index directory %s: %s", directory, exc
|
||||
)
|
||||
|
||||
def _resolve_default_db_path(self) -> str:
|
||||
"""Resolve the default database path."""
|
||||
@@ -173,13 +177,15 @@ class TagFTSIndex:
|
||||
# Set schema version
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION))
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
self._schema_initialized = True
|
||||
self._needs_rebuild = needs_rebuild
|
||||
logger.debug("Tag FTS index schema initialized at %s", self._db_path)
|
||||
logger.debug(
|
||||
"Tag FTS index schema initialized at %s", self._db_path
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
@@ -206,13 +212,20 @@ class TagFTSIndex:
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
# Old schema without version, needs rebuild
|
||||
logger.info("Migrating tag FTS index to schema version %d (adding alias support)", SCHEMA_VERSION)
|
||||
logger.info(
|
||||
"Migrating tag FTS index to schema version %d (adding alias support)",
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
self._drop_old_tables(conn)
|
||||
return True
|
||||
|
||||
current_version = int(row[0])
|
||||
if current_version < SCHEMA_VERSION:
|
||||
logger.info("Migrating tag FTS index from version %d to %d", current_version, SCHEMA_VERSION)
|
||||
logger.info(
|
||||
"Migrating tag FTS index from version %d to %d",
|
||||
current_version,
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
self._drop_old_tables(conn)
|
||||
return True
|
||||
|
||||
@@ -246,7 +259,9 @@ class TagFTSIndex:
|
||||
return
|
||||
|
||||
if not os.path.exists(self._csv_path):
|
||||
logger.warning("CSV file not found at %s, cannot build tag index", self._csv_path)
|
||||
logger.warning(
|
||||
"CSV file not found at %s, cannot build tag index", self._csv_path
|
||||
)
|
||||
return
|
||||
|
||||
self._indexing_in_progress = True
|
||||
@@ -314,22 +329,24 @@ class TagFTSIndex:
|
||||
# Update metadata
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("last_build_time", str(time.time()))
|
||||
("last_build_time", str(time.time())),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("tag_count", str(total_inserted))
|
||||
("tag_count", str(total_inserted)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION))
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs",
|
||||
total_inserted, tags_with_aliases, elapsed
|
||||
total_inserted,
|
||||
tags_with_aliases,
|
||||
elapsed,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -350,7 +367,7 @@ class TagFTSIndex:
|
||||
# Insert into tags table (with aliases)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
|
||||
rows
|
||||
rows,
|
||||
)
|
||||
|
||||
# Build a map of tag_name -> aliases for FTS insertion
|
||||
@@ -362,7 +379,7 @@ class TagFTSIndex:
|
||||
placeholders = ",".join("?" * len(tag_names))
|
||||
cursor = conn.execute(
|
||||
f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})",
|
||||
tag_names
|
||||
tag_names,
|
||||
)
|
||||
|
||||
# Build FTS rows with (rowid, searchable_text) = (tags.rowid, "tag_name alias1 alias2 ...")
|
||||
@@ -379,13 +396,17 @@ class TagFTSIndex:
|
||||
alias = alias[1:] # Remove leading slash
|
||||
if alias:
|
||||
alias_parts.append(alias)
|
||||
searchable_text = f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
|
||||
searchable_text = (
|
||||
f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
|
||||
)
|
||||
else:
|
||||
searchable_text = tag_name
|
||||
fts_rows.append((rowid, searchable_text))
|
||||
|
||||
if fts_rows:
|
||||
conn.executemany("INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows)
|
||||
conn.executemany(
|
||||
"INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows
|
||||
)
|
||||
|
||||
def ensure_ready(self) -> bool:
|
||||
"""Ensure the index is ready, building if necessary.
|
||||
@@ -420,7 +441,8 @@ class TagFTSIndex:
|
||||
self,
|
||||
query: str,
|
||||
categories: Optional[List[int]] = None,
|
||||
limit: int = 20
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> List[Dict]:
|
||||
"""Search tags using FTS5 with prefix matching.
|
||||
|
||||
@@ -431,6 +453,7 @@ class TagFTSIndex:
|
||||
query: The search query string.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
limit: Maximum number of results to return.
|
||||
offset: Number of results to skip.
|
||||
|
||||
Returns:
|
||||
List of dictionaries with tag_name, category, post_count,
|
||||
@@ -466,9 +489,9 @@ class TagFTSIndex:
|
||||
)
|
||||
AND t.category IN ({placeholders})
|
||||
ORDER BY t.post_count DESC
|
||||
LIMIT ?
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [fts_query] + categories + [limit]
|
||||
params = [fts_query] + categories + [limit, offset]
|
||||
else:
|
||||
sql = """
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
||||
@@ -476,9 +499,9 @@ class TagFTSIndex:
|
||||
JOIN tags t ON f.rowid = t.rowid
|
||||
WHERE f.searchable_text MATCH ?
|
||||
ORDER BY t.post_count DESC
|
||||
LIMIT ?
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [fts_query, limit]
|
||||
params = [fts_query, limit, offset]
|
||||
|
||||
cursor = conn.execute(sql, params)
|
||||
results = []
|
||||
@@ -502,7 +525,9 @@ class TagFTSIndex:
|
||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||
return []
|
||||
|
||||
def _find_matched_alias(self, query: str, tag_name: str, aliases_str: str) -> Optional[str]:
|
||||
def _find_matched_alias(
|
||||
self, query: str, tag_name: str, aliases_str: str
|
||||
) -> Optional[str]:
|
||||
"""Find which alias matched the query, if any.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -269,13 +269,17 @@ class AutoComplete {
|
||||
this.modelType = modelType;
|
||||
this.behavior = getModelBehavior(modelType);
|
||||
this.options = {
|
||||
maxItems: 20,
|
||||
maxItems: 100,
|
||||
pageSize: 20,
|
||||
visibleItems: 15, // Fixed at 15 items for balanced UX
|
||||
itemHeight: 40,
|
||||
minChars: 1,
|
||||
debounceDelay: 200,
|
||||
showPreview: this.behavior.enablePreview ?? false,
|
||||
enableVirtualScroll: true,
|
||||
...options
|
||||
};
|
||||
|
||||
|
||||
this.dropdown = null;
|
||||
this.selectedIndex = -1;
|
||||
this.items = [];
|
||||
@@ -286,6 +290,15 @@ class AutoComplete {
|
||||
this.previewTooltipPromise = null;
|
||||
this.searchType = null;
|
||||
|
||||
// Virtual scrolling state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.hasMoreItems = true;
|
||||
this.isLoadingMore = false;
|
||||
this.currentPage = 0;
|
||||
this.scrollContainer = null;
|
||||
this.contentContainer = null;
|
||||
this.totalHeight = 0;
|
||||
|
||||
// Command mode state
|
||||
this.activeCommand = null; // Current active command (e.g., { categories: [4, 11], label: 'Character' })
|
||||
this.showingCommands = false; // Whether showing command list dropdown
|
||||
@@ -297,6 +310,7 @@ class AutoComplete {
|
||||
this.onKeyDown = null;
|
||||
this.onBlur = null;
|
||||
this.onDocumentClick = null;
|
||||
this.onScroll = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
@@ -309,12 +323,12 @@ class AutoComplete {
|
||||
createDropdown() {
|
||||
this.dropdown = document.createElement('div');
|
||||
this.dropdown.className = 'comfy-autocomplete-dropdown';
|
||||
|
||||
|
||||
// Apply new color scheme
|
||||
this.dropdown.style.cssText = `
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
overflow-y: visible;
|
||||
overflow: hidden;
|
||||
background-color: rgba(40, 44, 52, 0.95);
|
||||
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -325,7 +339,29 @@ class AutoComplete {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
`;
|
||||
|
||||
|
||||
if (this.options.enableVirtualScroll) {
|
||||
// Create scroll container for virtual scrolling
|
||||
this.scrollContainer = document.createElement('div');
|
||||
this.scrollContainer.className = 'comfy-autocomplete-scroll-container';
|
||||
this.scrollContainer.style.cssText = `
|
||||
overflow-y: auto;
|
||||
max-height: ${this.options.visibleItems * this.options.itemHeight}px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
// Create content container for virtual items
|
||||
this.contentContainer = document.createElement('div');
|
||||
this.contentContainer.className = 'comfy-autocomplete-content';
|
||||
this.contentContainer.style.cssText = `
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
this.scrollContainer.appendChild(this.contentContainer);
|
||||
this.dropdown.appendChild(this.scrollContainer);
|
||||
}
|
||||
|
||||
// Custom scrollbar styles with new color scheme
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@@ -343,9 +379,29 @@ class AutoComplete {
|
||||
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(226, 232, 240, 0.4);
|
||||
}
|
||||
.comfy-autocomplete-scroll-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-track {
|
||||
background: rgba(40, 44, 52, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(226, 232, 240, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(226, 232, 240, 0.4);
|
||||
}
|
||||
.comfy-autocomplete-loading {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
|
||||
// Append to body to avoid overflow issues
|
||||
document.body.appendChild(this.dropdown);
|
||||
|
||||
@@ -410,6 +466,14 @@ class AutoComplete {
|
||||
|
||||
// Mark this element as having autocomplete events bound
|
||||
this.inputElement._autocompleteEventsBound = true;
|
||||
|
||||
// Bind scroll event for virtual scrolling
|
||||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||||
this.onScroll = () => {
|
||||
this.handleScroll();
|
||||
};
|
||||
this.scrollContainer.addEventListener('scroll', this.onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -958,80 +1022,102 @@ class AutoComplete {
|
||||
}
|
||||
|
||||
render() {
|
||||
this.dropdown.innerHTML = '';
|
||||
this.selectedIndex = -1;
|
||||
|
||||
// Reset virtual scroll state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.currentPage = 0;
|
||||
this.hasMoreItems = true;
|
||||
this.isLoadingMore = false;
|
||||
|
||||
// Early return if no items to prevent empty dropdown
|
||||
if (!this.items || this.items.length === 0) {
|
||||
if (this.contentContainer) {
|
||||
this.contentContainer.innerHTML = '';
|
||||
} else {
|
||||
this.dropdown.innerHTML = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if items are enriched (have tag_name, category, post_count)
|
||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||
// Use virtual scrolling - only update visible items if dropdown is already visible
|
||||
// If not visible, updateVisibleItems() will be called from show() after display:block
|
||||
this.updateVirtualScrollHeight();
|
||||
if (this.isVisible && this.dropdown.style.display !== 'none') {
|
||||
this.updateVisibleItems();
|
||||
}
|
||||
} else {
|
||||
// Traditional rendering (fallback)
|
||||
this.dropdown.innerHTML = '';
|
||||
|
||||
this.items.forEach((itemData, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comfy-autocomplete-item';
|
||||
// Check if items are enriched (have tag_name, category, post_count)
|
||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||
|
||||
// Get the display text and path for insertion
|
||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||
this.items.forEach((itemData, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comfy-autocomplete-item';
|
||||
|
||||
if (isEnriched) {
|
||||
// Render enriched item with category badge and post count
|
||||
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
||||
} else {
|
||||
// Create highlighted content for simple items, wrapped in a span
|
||||
// to prevent flex layout from breaking up the text
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'lm-autocomplete-name';
|
||||
nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm);
|
||||
nameSpan.style.cssText = `
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
// Get the display text and path for insertion
|
||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||
|
||||
if (isEnriched) {
|
||||
// Render enriched item with category badge and post count
|
||||
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
||||
} else {
|
||||
// Create highlighted content for simple items, wrapped in a span
|
||||
// to prevent flex layout from breaking up the text
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'lm-autocomplete-name';
|
||||
nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm);
|
||||
nameSpan.style.cssText = `
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
item.appendChild(nameSpan);
|
||||
}
|
||||
|
||||
// Apply item styles with new color scheme
|
||||
item.style.cssText = `
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
item.appendChild(nameSpan);
|
||||
|
||||
// Hover and selection handlers
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index);
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
this.hidePreview();
|
||||
});
|
||||
|
||||
// Click handler
|
||||
item.addEventListener('click', () => {
|
||||
this.insertSelection(insertPath);
|
||||
});
|
||||
|
||||
this.dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
// Remove border from last item
|
||||
if (this.dropdown.lastChild) {
|
||||
this.dropdown.lastChild.style.borderBottom = 'none';
|
||||
}
|
||||
|
||||
// Apply item styles with new color scheme
|
||||
item.style.cssText = `
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
// Hover and selection handlers
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index);
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
this.hidePreview();
|
||||
});
|
||||
|
||||
// Click handler
|
||||
item.addEventListener('click', () => {
|
||||
this.insertSelection(insertPath);
|
||||
});
|
||||
|
||||
this.dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
// Remove border from last item
|
||||
if (this.dropdown.lastChild) {
|
||||
this.dropdown.lastChild.style.borderBottom = 'none';
|
||||
}
|
||||
|
||||
// Auto-select the first item with a small delay
|
||||
@@ -1163,16 +1249,323 @@ class AutoComplete {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll event for virtual scrolling and loading more items
|
||||
*/
|
||||
handleScroll() {
|
||||
if (!this.scrollContainer || this.isLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer;
|
||||
const scrollBottom = scrollTop + clientHeight;
|
||||
const threshold = this.options.itemHeight * 2; // Load more when within 2 items of bottom
|
||||
|
||||
// Check if we need to load more items
|
||||
if (scrollBottom >= scrollHeight - threshold && this.hasMoreItems) {
|
||||
this.loadMoreItems();
|
||||
}
|
||||
|
||||
// Update visible items for virtual scrolling
|
||||
if (this.options.enableVirtualScroll) {
|
||||
this.updateVisibleItems();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more items (pagination)
|
||||
*/
|
||||
async loadMoreItems() {
|
||||
if (this.isLoadingMore || !this.hasMoreItems || this.showingCommands) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingMore = true;
|
||||
this.currentPage++;
|
||||
|
||||
try {
|
||||
// Show loading indicator
|
||||
this.showLoadingIndicator();
|
||||
|
||||
// Get the current endpoint
|
||||
let endpoint = `/lm/${this.modelType}/relative-paths`;
|
||||
if (this.modelType === 'prompt') {
|
||||
if (this.searchType === 'embeddings') {
|
||||
endpoint = '/lm/embeddings/relative-paths';
|
||||
} else if (this.searchType === 'custom_words') {
|
||||
if (this.activeCommand?.categories) {
|
||||
const categories = this.activeCommand.categories.join(',');
|
||||
endpoint = `/lm/custom-words/search?category=${categories}`;
|
||||
} else {
|
||||
endpoint = '/lm/custom-words/search?enriched=true';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryVariations = this._generateQueryVariations(this.currentSearchTerm);
|
||||
const queriesToExecute = queryVariations.slice(0, 4);
|
||||
const offset = this.items.length;
|
||||
|
||||
// Execute all queries in parallel with offset
|
||||
const searchPromises = queriesToExecute.map(async (query) => {
|
||||
const url = endpoint.includes('?')
|
||||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
|
||||
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(url);
|
||||
const data = await response.json();
|
||||
return data.success ? (data.relative_paths || data.words || []) : [];
|
||||
} catch (error) {
|
||||
console.warn(`Search query failed for "${query}":`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const resultsArrays = await Promise.all(searchPromises);
|
||||
|
||||
// Merge and deduplicate results with existing items
|
||||
const seen = new Set(this.items.map(item => {
|
||||
const itemKey = typeof item === 'object' && item.tag_name
|
||||
? item.tag_name.toLowerCase()
|
||||
: String(item).toLowerCase();
|
||||
return itemKey;
|
||||
}));
|
||||
const newItems = [];
|
||||
|
||||
for (const resultArray of resultsArrays) {
|
||||
for (const item of resultArray) {
|
||||
const itemKey = typeof item === 'object' && item.tag_name
|
||||
? item.tag_name.toLowerCase()
|
||||
: String(item).toLowerCase();
|
||||
|
||||
if (!seen.has(itemKey)) {
|
||||
seen.add(itemKey);
|
||||
newItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we got fewer items than requested, we've reached the end
|
||||
if (newItems.length < this.options.pageSize) {
|
||||
this.hasMoreItems = false;
|
||||
}
|
||||
|
||||
// If we got new items, add them and re-render
|
||||
if (newItems.length > 0) {
|
||||
const currentLength = this.items.length;
|
||||
this.items.push(...newItems);
|
||||
|
||||
// Re-score and sort all items
|
||||
const scoredItems = this.items.map(item => {
|
||||
let bestScore = -1;
|
||||
let isExact = false;
|
||||
|
||||
for (const query of queriesToExecute) {
|
||||
const match = this._matchItem(item, query);
|
||||
if (match.matched) {
|
||||
const score = match.isExactMatch ? 1000 : 100;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
isExact = match.isExactMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { item, score: bestScore, isExact };
|
||||
});
|
||||
|
||||
scoredItems.sort((a, b) => {
|
||||
if (b.isExact !== a.isExact) {
|
||||
return b.isExact ? 1 : -1;
|
||||
}
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
this.items = scoredItems.map(s => s.item);
|
||||
|
||||
// Update render
|
||||
if (this.options.enableVirtualScroll) {
|
||||
this.updateVirtualScrollHeight();
|
||||
this.updateVisibleItems();
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
} else {
|
||||
this.hasMoreItems = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more items:', error);
|
||||
this.hasMoreItems = false;
|
||||
} finally {
|
||||
this.isLoadingMore = false;
|
||||
this.hideLoadingIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading indicator at the bottom of the list
|
||||
*/
|
||||
showLoadingIndicator() {
|
||||
if (!this.contentContainer) return;
|
||||
|
||||
let loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
|
||||
if (!loadingEl) {
|
||||
loadingEl = document.createElement('div');
|
||||
loadingEl.className = 'comfy-autocomplete-loading';
|
||||
loadingEl.textContent = 'Loading more...';
|
||||
loadingEl.style.cssText = `
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
font-size: 12px;
|
||||
`;
|
||||
this.contentContainer.appendChild(loadingEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading indicator
|
||||
*/
|
||||
hideLoadingIndicator() {
|
||||
if (!this.contentContainer) return;
|
||||
|
||||
const loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
|
||||
if (loadingEl) {
|
||||
loadingEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the total height of the virtual scroll container
|
||||
*/
|
||||
updateVirtualScrollHeight() {
|
||||
if (!this.contentContainer) return;
|
||||
|
||||
this.totalHeight = this.items.length * this.options.itemHeight;
|
||||
this.contentContainer.style.height = `${this.totalHeight}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update which items are visible based on scroll position
|
||||
*/
|
||||
updateVisibleItems() {
|
||||
if (!this.scrollContainer || !this.contentContainer) return;
|
||||
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
|
||||
// Calculate which items should be visible
|
||||
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 2);
|
||||
const endIndex = Math.min(
|
||||
this.items.length - 1,
|
||||
Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 2
|
||||
);
|
||||
|
||||
// Clear current content
|
||||
this.contentContainer.innerHTML = '';
|
||||
|
||||
// Create spacer for items before visible range
|
||||
if (startIndex > 0) {
|
||||
const topSpacer = document.createElement('div');
|
||||
topSpacer.style.height = `${startIndex * this.options.itemHeight}px`;
|
||||
this.contentContainer.appendChild(topSpacer);
|
||||
}
|
||||
|
||||
// Render visible items
|
||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const itemData = this.items[i];
|
||||
const itemEl = this.createItemElement(itemData, i, isEnriched);
|
||||
this.contentContainer.appendChild(itemEl);
|
||||
}
|
||||
|
||||
// Create spacer for items after visible range
|
||||
if (endIndex < this.items.length - 1) {
|
||||
const bottomSpacer = document.createElement('div');
|
||||
bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`;
|
||||
this.contentContainer.appendChild(bottomSpacer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single item element
|
||||
*/
|
||||
createItemElement(itemData, index, isEnriched) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comfy-autocomplete-item';
|
||||
item.dataset.index = index.toString();
|
||||
item.style.cssText = `
|
||||
height: ${this.options.itemHeight}px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||
|
||||
if (isEnriched) {
|
||||
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
||||
} else {
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'lm-autocomplete-name';
|
||||
nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm);
|
||||
nameSpan.style.cssText = `
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
item.appendChild(nameSpan);
|
||||
}
|
||||
|
||||
// Hover and selection handlers
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index);
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
this.hidePreview();
|
||||
});
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
this.insertSelection(insertPath);
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.items || this.items.length === 0) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Position dropdown at cursor position using TextAreaCaretHelper
|
||||
this.positionAtCursor();
|
||||
this.dropdown.style.display = 'block';
|
||||
this.isVisible = true;
|
||||
|
||||
// For virtual scrolling, render items first so positionAtCursor can measure width correctly
|
||||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||
this.dropdown.style.display = 'block';
|
||||
this.isVisible = true;
|
||||
this.updateVisibleItems();
|
||||
this.positionAtCursor();
|
||||
} else {
|
||||
// Position dropdown at cursor position using TextAreaCaretHelper
|
||||
this.positionAtCursor();
|
||||
this.dropdown.style.display = 'block';
|
||||
this.isVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
positionAtCursor() {
|
||||
@@ -1189,7 +1582,11 @@ class AutoComplete {
|
||||
|
||||
// Measure the content width
|
||||
let maxWidth = 200; // minimum width
|
||||
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
|
||||
// For virtual scrolling, query items from contentContainer; otherwise from dropdown
|
||||
const container = this.options.enableVirtualScroll && this.contentContainer
|
||||
? this.contentContainer
|
||||
: this.dropdown;
|
||||
const items = container.querySelectorAll('.comfy-autocomplete-item');
|
||||
items.forEach(item => {
|
||||
const itemWidth = item.scrollWidth + 24; // Add padding
|
||||
maxWidth = Math.max(maxWidth, itemWidth);
|
||||
@@ -1215,6 +1612,18 @@ class AutoComplete {
|
||||
this.selectedIndex = -1;
|
||||
this.showingCommands = false;
|
||||
|
||||
// Reset virtual scrolling state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.currentPage = 0;
|
||||
this.hasMoreItems = true;
|
||||
this.isLoadingMore = false;
|
||||
this.totalHeight = 0;
|
||||
|
||||
// Reset scroll position
|
||||
if (this.scrollContainer) {
|
||||
this.scrollContainer.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Hide preview tooltip
|
||||
this.hidePreview();
|
||||
|
||||
@@ -1228,28 +1637,69 @@ class AutoComplete {
|
||||
|
||||
selectItem(index) {
|
||||
// Remove previous selection
|
||||
const prevSelected = this.dropdown.querySelector('.comfy-autocomplete-item-selected');
|
||||
const container = this.options.enableVirtualScroll && this.contentContainer
|
||||
? this.contentContainer
|
||||
: this.dropdown;
|
||||
const prevSelected = container.querySelector('.comfy-autocomplete-item-selected');
|
||||
if (prevSelected) {
|
||||
prevSelected.classList.remove('comfy-autocomplete-item-selected');
|
||||
prevSelected.style.backgroundColor = '';
|
||||
}
|
||||
|
||||
|
||||
// Add new selection
|
||||
if (index >= 0 && index < this.items.length) {
|
||||
this.selectedIndex = index;
|
||||
const item = this.dropdown.children[index];
|
||||
item.classList.add('comfy-autocomplete-item-selected');
|
||||
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||
|
||||
// Scroll into view if needed
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
// Show preview for selected item
|
||||
if (this.options.showPreview) {
|
||||
if (typeof this.behavior.showPreview === 'function') {
|
||||
this.behavior.showPreview(this, this.items[index], item);
|
||||
} else if (this.previewTooltip) {
|
||||
this.showPreviewForItem(this.items[index], item);
|
||||
|
||||
// For virtual scrolling, we need to ensure the item is rendered
|
||||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||||
// Calculate if the item is currently visible
|
||||
const itemTop = index * this.options.itemHeight;
|
||||
const itemBottom = itemTop + this.options.itemHeight;
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
const scrollBottom = scrollTop + containerHeight;
|
||||
|
||||
// If item is not visible, scroll to make it visible
|
||||
if (itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||
this.scrollContainer.scrollTop = itemTop - containerHeight / 2;
|
||||
// Re-render visible items after scroll
|
||||
this.updateVisibleItems();
|
||||
}
|
||||
|
||||
// Find the item element using data-index attribute
|
||||
const selectedEl = container.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`);
|
||||
|
||||
if (selectedEl) {
|
||||
selectedEl.classList.add('comfy-autocomplete-item-selected');
|
||||
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||
|
||||
// Show preview for selected item
|
||||
if (this.options.showPreview) {
|
||||
if (typeof this.behavior.showPreview === 'function') {
|
||||
this.behavior.showPreview(this, this.items[index], selectedEl);
|
||||
} else if (this.previewTooltip) {
|
||||
this.showPreviewForItem(this.items[index], selectedEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Traditional rendering
|
||||
const item = container.children[index];
|
||||
if (item) {
|
||||
item.classList.add('comfy-autocomplete-item-selected');
|
||||
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||
|
||||
// Scroll into view if needed
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
// Show preview for selected item
|
||||
if (this.options.showPreview) {
|
||||
if (typeof this.behavior.showPreview === 'function') {
|
||||
this.behavior.showPreview(this, this.items[index], item);
|
||||
} else if (this.previewTooltip) {
|
||||
this.showPreviewForItem(this.items[index], item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1512,6 +1962,11 @@ class AutoComplete {
|
||||
this.onDocumentClick = null;
|
||||
}
|
||||
|
||||
if (this.onScroll && this.scrollContainer) {
|
||||
this.scrollContainer.removeEventListener('scroll', this.onScroll);
|
||||
this.onScroll = null;
|
||||
}
|
||||
|
||||
if (typeof this.behavior.destroy === 'function') {
|
||||
this.behavior.destroy(this);
|
||||
} else if (this.previewTooltip) {
|
||||
|
||||
Reference in New Issue
Block a user