mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -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:
|
except Exception as e:
|
||||||
self._logger.debug(f"Failed to load supporters data: {e}")
|
self._logger.debug(f"Failed to load supporters data: {e}")
|
||||||
|
|
||||||
return {
|
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
|
||||||
"specialThanks": [],
|
|
||||||
"allSupporters": [],
|
|
||||||
"totalCount": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_supporters(self, request: web.Request) -> web.Response:
|
async def get_supporters(self, request: web.Request) -> web.Response:
|
||||||
"""Return supporters data as JSON."""
|
"""Return supporters data as JSON."""
|
||||||
@@ -253,9 +249,7 @@ class SupportersHandler:
|
|||||||
return web.json_response({"success": True, "supporters": supporters})
|
return web.json_response({"success": True, "supporters": supporters})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error loading supporters: %s", exc, exc_info=True)
|
self._logger.error("Error loading supporters: %s", exc, exc_info=True)
|
||||||
return web.json_response(
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
{"success": False, "error": str(exc)}, status=500
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsHandler:
|
class SettingsHandler:
|
||||||
@@ -263,15 +257,17 @@ class SettingsHandler:
|
|||||||
|
|
||||||
# Settings keys that should NOT be synced to frontend.
|
# Settings keys that should NOT be synced to frontend.
|
||||||
# All other settings are synced by default.
|
# All other settings are synced by default.
|
||||||
_NO_SYNC_KEYS = frozenset({
|
_NO_SYNC_KEYS = frozenset(
|
||||||
# Internal/performance settings (not used by frontend)
|
{
|
||||||
"hash_chunk_size_mb",
|
# Internal/performance settings (not used by frontend)
|
||||||
"download_stall_timeout_seconds",
|
"hash_chunk_size_mb",
|
||||||
# Complex internal structures retrieved via separate endpoints
|
"download_stall_timeout_seconds",
|
||||||
"folder_paths",
|
# Complex internal structures retrieved via separate endpoints
|
||||||
"libraries",
|
"folder_paths",
|
||||||
"active_library",
|
"libraries",
|
||||||
})
|
"active_library",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
_PROXY_KEYS = {
|
_PROXY_KEYS = {
|
||||||
"proxy_enabled",
|
"proxy_enabled",
|
||||||
@@ -1226,6 +1222,7 @@ class CustomWordsHandler:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
from ...services.custom_words_service import get_custom_words_service
|
from ...services.custom_words_service import get_custom_words_service
|
||||||
|
|
||||||
self._service = get_custom_words_service()
|
self._service = get_custom_words_service()
|
||||||
|
|
||||||
async def search_custom_words(self, request: web.Request) -> web.Response:
|
async def search_custom_words(self, request: web.Request) -> web.Response:
|
||||||
@@ -1234,6 +1231,7 @@ class CustomWordsHandler:
|
|||||||
Query parameters:
|
Query parameters:
|
||||||
search: The search term to match against.
|
search: The search term to match against.
|
||||||
limit: Maximum number of results to return (default: 20).
|
limit: Maximum number of results to return (default: 20).
|
||||||
|
offset: Number of results to skip (default: 0).
|
||||||
category: Optional category filter. Can be:
|
category: Optional category filter. Can be:
|
||||||
- A category name (e.g., "character", "artist", "general")
|
- A category name (e.g., "character", "artist", "general")
|
||||||
- Comma-separated category IDs (e.g., "4,11" for character)
|
- Comma-separated category IDs (e.g., "4,11" for character)
|
||||||
@@ -1243,6 +1241,7 @@ class CustomWordsHandler:
|
|||||||
try:
|
try:
|
||||||
search_term = request.query.get("search", "")
|
search_term = request.query.get("search", "")
|
||||||
limit = int(request.query.get("limit", "20"))
|
limit = int(request.query.get("limit", "20"))
|
||||||
|
offset = max(0, int(request.query.get("offset", "0")))
|
||||||
category_param = request.query.get("category", "")
|
category_param = request.query.get("category", "")
|
||||||
enriched_param = request.query.get("enriched", "").lower() == "true"
|
enriched_param = request.query.get("enriched", "").lower() == "true"
|
||||||
|
|
||||||
@@ -1252,13 +1251,14 @@ class CustomWordsHandler:
|
|||||||
categories = self._parse_category_param(category_param)
|
categories = self._parse_category_param(category_param)
|
||||||
|
|
||||||
results = self._service.search_words(
|
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({
|
return web.json_response({"success": True, "words": results})
|
||||||
"success": True,
|
|
||||||
"words": results
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Error searching custom words: %s", exc, exc_info=True)
|
logger.error("Error searching custom words: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
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:
|
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
search = request.query.get("search", "").strip()
|
search = request.query.get("search", "").strip()
|
||||||
limit = min(int(request.query.get("limit", "15")), 50)
|
limit = min(int(request.query.get("limit", "15")), 100)
|
||||||
matching_paths = await self._service.search_relative_paths(search, limit)
|
offset = max(0, int(request.query.get("offset", "0")))
|
||||||
|
matching_paths = await self._service.search_relative_paths(
|
||||||
|
search, limit, offset
|
||||||
|
)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "relative_paths": matching_paths}
|
{"success": True, "relative_paths": matching_paths}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -383,7 +383,9 @@ class BaseModelService(ABC):
|
|||||||
# Check user setting for hiding early access updates
|
# Check user setting for hiding early access updates
|
||||||
hide_early_access = False
|
hide_early_access = False
|
||||||
try:
|
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:
|
except Exception:
|
||||||
hide_early_access = False
|
hide_early_access = False
|
||||||
|
|
||||||
@@ -413,7 +415,11 @@ class BaseModelService(ABC):
|
|||||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||||
if callable(bulk_method):
|
if callable(bulk_method):
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||||
@@ -426,7 +432,9 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
tasks = [
|
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
|
for model_id in ordered_ids
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
@@ -590,9 +598,15 @@ class BaseModelService(ABC):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter by valid sub-types based on scanner type
|
# 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
|
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
|
continue
|
||||||
|
|
||||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||||
@@ -838,7 +852,7 @@ class BaseModelService(ABC):
|
|||||||
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
||||||
|
|
||||||
async def search_relative_paths(
|
async def search_relative_paths(
|
||||||
self, search_term: str, limit: int = 15
|
self, search_term: str, limit: int = 15, offset: int = 0
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Search model relative file paths for autocomplete functionality"""
|
"""Search model relative file paths for autocomplete functionality"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
@@ -849,6 +863,7 @@ class BaseModelService(ABC):
|
|||||||
# Get model roots for path calculation
|
# Get model roots for path calculation
|
||||||
model_roots = self.scanner.get_model_roots()
|
model_roots = self.scanner.get_model_roots()
|
||||||
|
|
||||||
|
# Collect all matching paths first (needed for proper sorting and offset)
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
file_path = model.get("file_path", "")
|
file_path = model.get("file_path", "")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
@@ -877,12 +892,12 @@ class BaseModelService(ABC):
|
|||||||
):
|
):
|
||||||
matching_paths.append(relative_path)
|
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)
|
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
|
||||||
matching_paths.sort(
|
matching_paths.sort(
|
||||||
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
|
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:
|
if self._tag_index is None:
|
||||||
try:
|
try:
|
||||||
from .tag_fts_index import get_tag_fts_index
|
from .tag_fts_index import get_tag_fts_index
|
||||||
|
|
||||||
self._tag_index = get_tag_fts_index()
|
self._tag_index = get_tag_fts_index()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to initialize TagFTSIndex: {e}")
|
logger.warning(f"Failed to initialize TagFTSIndex: {e}")
|
||||||
@@ -59,14 +60,16 @@ class CustomWordsService:
|
|||||||
self,
|
self,
|
||||||
search_term: str,
|
search_term: str,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
categories: Optional[List[int]] = None,
|
categories: Optional[List[int]] = None,
|
||||||
enriched: bool = False
|
enriched: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Search tags using TagFTSIndex with category filtering.
|
"""Search tags using TagFTSIndex with category filtering.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
search_term: The search term to match against.
|
search_term: The search term to match against.
|
||||||
limit: Maximum number of results to return.
|
limit: Maximum number of results to return.
|
||||||
|
offset: Number of results to skip.
|
||||||
categories: Optional list of category IDs to filter by.
|
categories: Optional list of category IDs to filter by.
|
||||||
enriched: If True, always return enriched results with category
|
enriched: If True, always return enriched results with category
|
||||||
and post_count (default behavior now).
|
and post_count (default behavior now).
|
||||||
@@ -76,7 +79,9 @@ class CustomWordsService:
|
|||||||
"""
|
"""
|
||||||
tag_index = self._get_tag_index()
|
tag_index = self._get_tag_index()
|
||||||
if tag_index is not None:
|
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
|
return results
|
||||||
|
|
||||||
logger.debug("TagFTSIndex not available, returning empty results")
|
logger.debug("TagFTSIndex not available, returning empty results")
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ class TagFTSIndex:
|
|||||||
_DEFAULT_FILENAME = "tag_fts.sqlite"
|
_DEFAULT_FILENAME = "tag_fts.sqlite"
|
||||||
_CSV_FILENAME = "danbooru_e621_merged.csv"
|
_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.
|
"""Initialize the FTS index.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -92,7 +94,9 @@ class TagFTSIndex:
|
|||||||
if directory:
|
if directory:
|
||||||
os.makedirs(directory, exist_ok=True)
|
os.makedirs(directory, exist_ok=True)
|
||||||
except Exception as exc:
|
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:
|
def _resolve_default_db_path(self) -> str:
|
||||||
"""Resolve the default database path."""
|
"""Resolve the default database path."""
|
||||||
@@ -173,13 +177,15 @@ class TagFTSIndex:
|
|||||||
# Set schema version
|
# Set schema version
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||||
("schema_version", str(SCHEMA_VERSION))
|
("schema_version", str(SCHEMA_VERSION)),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
self._schema_initialized = True
|
self._schema_initialized = True
|
||||||
self._needs_rebuild = needs_rebuild
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -206,13 +212,20 @@ class TagFTSIndex:
|
|||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
# Old schema without version, needs rebuild
|
# 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)
|
self._drop_old_tables(conn)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
current_version = int(row[0])
|
current_version = int(row[0])
|
||||||
if current_version < SCHEMA_VERSION:
|
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)
|
self._drop_old_tables(conn)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -246,7 +259,9 @@ class TagFTSIndex:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.exists(self._csv_path):
|
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
|
return
|
||||||
|
|
||||||
self._indexing_in_progress = True
|
self._indexing_in_progress = True
|
||||||
@@ -314,22 +329,24 @@ class TagFTSIndex:
|
|||||||
# Update metadata
|
# Update metadata
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||||
("last_build_time", str(time.time()))
|
("last_build_time", str(time.time())),
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||||
("tag_count", str(total_inserted))
|
("tag_count", str(total_inserted)),
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||||
("schema_version", str(SCHEMA_VERSION))
|
("schema_version", str(SCHEMA_VERSION)),
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
logger.info(
|
logger.info(
|
||||||
"Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs",
|
"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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -350,7 +367,7 @@ class TagFTSIndex:
|
|||||||
# Insert into tags table (with aliases)
|
# Insert into tags table (with aliases)
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
|
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
|
||||||
rows
|
rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build a map of tag_name -> aliases for FTS insertion
|
# Build a map of tag_name -> aliases for FTS insertion
|
||||||
@@ -362,7 +379,7 @@ class TagFTSIndex:
|
|||||||
placeholders = ",".join("?" * len(tag_names))
|
placeholders = ",".join("?" * len(tag_names))
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})",
|
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 ...")
|
# 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
|
alias = alias[1:] # Remove leading slash
|
||||||
if alias:
|
if alias:
|
||||||
alias_parts.append(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:
|
else:
|
||||||
searchable_text = tag_name
|
searchable_text = tag_name
|
||||||
fts_rows.append((rowid, searchable_text))
|
fts_rows.append((rowid, searchable_text))
|
||||||
|
|
||||||
if fts_rows:
|
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:
|
def ensure_ready(self) -> bool:
|
||||||
"""Ensure the index is ready, building if necessary.
|
"""Ensure the index is ready, building if necessary.
|
||||||
@@ -420,7 +441,8 @@ class TagFTSIndex:
|
|||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
categories: Optional[List[int]] = None,
|
categories: Optional[List[int]] = None,
|
||||||
limit: int = 20
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""Search tags using FTS5 with prefix matching.
|
"""Search tags using FTS5 with prefix matching.
|
||||||
|
|
||||||
@@ -431,6 +453,7 @@ class TagFTSIndex:
|
|||||||
query: The search query string.
|
query: The search query string.
|
||||||
categories: Optional list of category IDs to filter by.
|
categories: Optional list of category IDs to filter by.
|
||||||
limit: Maximum number of results to return.
|
limit: Maximum number of results to return.
|
||||||
|
offset: Number of results to skip.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dictionaries with tag_name, category, post_count,
|
List of dictionaries with tag_name, category, post_count,
|
||||||
@@ -466,9 +489,9 @@ class TagFTSIndex:
|
|||||||
)
|
)
|
||||||
AND t.category IN ({placeholders})
|
AND t.category IN ({placeholders})
|
||||||
ORDER BY t.post_count DESC
|
ORDER BY t.post_count DESC
|
||||||
LIMIT ?
|
LIMIT ? OFFSET ?
|
||||||
"""
|
"""
|
||||||
params = [fts_query] + categories + [limit]
|
params = [fts_query] + categories + [limit, offset]
|
||||||
else:
|
else:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
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
|
JOIN tags t ON f.rowid = t.rowid
|
||||||
WHERE f.searchable_text MATCH ?
|
WHERE f.searchable_text MATCH ?
|
||||||
ORDER BY t.post_count DESC
|
ORDER BY t.post_count DESC
|
||||||
LIMIT ?
|
LIMIT ? OFFSET ?
|
||||||
"""
|
"""
|
||||||
params = [fts_query, limit]
|
params = [fts_query, limit, offset]
|
||||||
|
|
||||||
cursor = conn.execute(sql, params)
|
cursor = conn.execute(sql, params)
|
||||||
results = []
|
results = []
|
||||||
@@ -502,7 +525,9 @@ class TagFTSIndex:
|
|||||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||||
return []
|
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.
|
"""Find which alias matched the query, if any.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -269,10 +269,14 @@ class AutoComplete {
|
|||||||
this.modelType = modelType;
|
this.modelType = modelType;
|
||||||
this.behavior = getModelBehavior(modelType);
|
this.behavior = getModelBehavior(modelType);
|
||||||
this.options = {
|
this.options = {
|
||||||
maxItems: 20,
|
maxItems: 100,
|
||||||
|
pageSize: 20,
|
||||||
|
visibleItems: 15, // Fixed at 15 items for balanced UX
|
||||||
|
itemHeight: 40,
|
||||||
minChars: 1,
|
minChars: 1,
|
||||||
debounceDelay: 200,
|
debounceDelay: 200,
|
||||||
showPreview: this.behavior.enablePreview ?? false,
|
showPreview: this.behavior.enablePreview ?? false,
|
||||||
|
enableVirtualScroll: true,
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -286,6 +290,15 @@ class AutoComplete {
|
|||||||
this.previewTooltipPromise = null;
|
this.previewTooltipPromise = null;
|
||||||
this.searchType = 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
|
// Command mode state
|
||||||
this.activeCommand = null; // Current active command (e.g., { categories: [4, 11], label: 'Character' })
|
this.activeCommand = null; // Current active command (e.g., { categories: [4, 11], label: 'Character' })
|
||||||
this.showingCommands = false; // Whether showing command list dropdown
|
this.showingCommands = false; // Whether showing command list dropdown
|
||||||
@@ -297,6 +310,7 @@ class AutoComplete {
|
|||||||
this.onKeyDown = null;
|
this.onKeyDown = null;
|
||||||
this.onBlur = null;
|
this.onBlur = null;
|
||||||
this.onDocumentClick = null;
|
this.onDocumentClick = null;
|
||||||
|
this.onScroll = null;
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
@@ -314,7 +328,7 @@ class AutoComplete {
|
|||||||
this.dropdown.style.cssText = `
|
this.dropdown.style.cssText = `
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
overflow-y: visible;
|
overflow: hidden;
|
||||||
background-color: rgba(40, 44, 52, 0.95);
|
background-color: rgba(40, 44, 52, 0.95);
|
||||||
border: 1px solid rgba(226, 232, 240, 0.2);
|
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -326,6 +340,28 @@ class AutoComplete {
|
|||||||
-webkit-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
|
// Custom scrollbar styles with new color scheme
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
@@ -343,6 +379,26 @@ class AutoComplete {
|
|||||||
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
|
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(226, 232, 240, 0.4);
|
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);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
@@ -410,6 +466,14 @@ class AutoComplete {
|
|||||||
|
|
||||||
// Mark this element as having autocomplete events bound
|
// Mark this element as having autocomplete events bound
|
||||||
this.inputElement._autocompleteEventsBound = true;
|
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() {
|
render() {
|
||||||
this.dropdown.innerHTML = '';
|
|
||||||
this.selectedIndex = -1;
|
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
|
// Early return if no items to prevent empty dropdown
|
||||||
if (!this.items || this.items.length === 0) {
|
if (!this.items || this.items.length === 0) {
|
||||||
|
if (this.contentContainer) {
|
||||||
|
this.contentContainer.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
this.dropdown.innerHTML = '';
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if items are enriched (have tag_name, category, post_count)
|
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
// 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) => {
|
// Check if items are enriched (have tag_name, category, post_count)
|
||||||
const item = document.createElement('div');
|
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||||||
item.className = 'comfy-autocomplete-item';
|
|
||||||
|
|
||||||
// Get the display text and path for insertion
|
this.items.forEach((itemData, index) => {
|
||||||
const displayText = isEnriched ? itemData.tag_name : itemData;
|
const item = document.createElement('div');
|
||||||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
item.className = 'comfy-autocomplete-item';
|
||||||
|
|
||||||
if (isEnriched) {
|
// Get the display text and path for insertion
|
||||||
// Render enriched item with category badge and post count
|
const displayText = isEnriched ? itemData.tag_name : itemData;
|
||||||
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||||||
} else {
|
|
||||||
// Create highlighted content for simple items, wrapped in a span
|
if (isEnriched) {
|
||||||
// to prevent flex layout from breaking up the text
|
// Render enriched item with category badge and post count
|
||||||
const nameSpan = document.createElement('span');
|
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
||||||
nameSpan.className = 'lm-autocomplete-name';
|
} else {
|
||||||
nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm);
|
// Create highlighted content for simple items, wrapped in a span
|
||||||
nameSpan.style.cssText = `
|
// to prevent flex layout from breaking up the text
|
||||||
flex: 1;
|
const nameSpan = document.createElement('span');
|
||||||
min-width: 0;
|
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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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
|
// 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() {
|
show() {
|
||||||
if (!this.items || this.items.length === 0) {
|
if (!this.items || this.items.length === 0) {
|
||||||
this.hide();
|
this.hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position dropdown at cursor position using TextAreaCaretHelper
|
// For virtual scrolling, render items first so positionAtCursor can measure width correctly
|
||||||
this.positionAtCursor();
|
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||||
this.dropdown.style.display = 'block';
|
this.dropdown.style.display = 'block';
|
||||||
this.isVisible = true;
|
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() {
|
positionAtCursor() {
|
||||||
@@ -1189,7 +1582,11 @@ class AutoComplete {
|
|||||||
|
|
||||||
// Measure the content width
|
// Measure the content width
|
||||||
let maxWidth = 200; // minimum 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 => {
|
items.forEach(item => {
|
||||||
const itemWidth = item.scrollWidth + 24; // Add padding
|
const itemWidth = item.scrollWidth + 24; // Add padding
|
||||||
maxWidth = Math.max(maxWidth, itemWidth);
|
maxWidth = Math.max(maxWidth, itemWidth);
|
||||||
@@ -1215,6 +1612,18 @@ class AutoComplete {
|
|||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
this.showingCommands = false;
|
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
|
// Hide preview tooltip
|
||||||
this.hidePreview();
|
this.hidePreview();
|
||||||
|
|
||||||
@@ -1228,7 +1637,10 @@ class AutoComplete {
|
|||||||
|
|
||||||
selectItem(index) {
|
selectItem(index) {
|
||||||
// Remove previous selection
|
// 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) {
|
if (prevSelected) {
|
||||||
prevSelected.classList.remove('comfy-autocomplete-item-selected');
|
prevSelected.classList.remove('comfy-autocomplete-item-selected');
|
||||||
prevSelected.style.backgroundColor = '';
|
prevSelected.style.backgroundColor = '';
|
||||||
@@ -1237,19 +1649,57 @@ class AutoComplete {
|
|||||||
// Add new selection
|
// Add new selection
|
||||||
if (index >= 0 && index < this.items.length) {
|
if (index >= 0 && index < this.items.length) {
|
||||||
this.selectedIndex = index;
|
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
|
// For virtual scrolling, we need to ensure the item is rendered
|
||||||
item.scrollIntoView({ block: 'nearest' });
|
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;
|
||||||
|
|
||||||
// Show preview for selected item
|
// If item is not visible, scroll to make it visible
|
||||||
if (this.options.showPreview) {
|
if (itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||||
if (typeof this.behavior.showPreview === 'function') {
|
this.scrollContainer.scrollTop = itemTop - containerHeight / 2;
|
||||||
this.behavior.showPreview(this, this.items[index], item);
|
// Re-render visible items after scroll
|
||||||
} else if (this.previewTooltip) {
|
this.updateVisibleItems();
|
||||||
this.showPreviewForItem(this.items[index], item);
|
}
|
||||||
|
|
||||||
|
// 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;
|
this.onDocumentClick = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.onScroll && this.scrollContainer) {
|
||||||
|
this.scrollContainer.removeEventListener('scroll', this.onScroll);
|
||||||
|
this.onScroll = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof this.behavior.destroy === 'function') {
|
if (typeof this.behavior.destroy === 'function') {
|
||||||
this.behavior.destroy(this);
|
this.behavior.destroy(this);
|
||||||
} else if (this.previewTooltip) {
|
} else if (this.previewTooltip) {
|
||||||
|
|||||||
Reference in New Issue
Block a user