Merge main (gallery search, model hash index fix) into feature/audio-voice-studio
Conflicts resolved:
- entry trigger files, web/main.js, api/__init__.py: main carries the squashed
entry fix (09b06d1), identical to this branch before its later cleanup, so
this branch's versions are kept
- ARCHITECTURE.md, docs/architecture/backend.md, web/modules/ui_sidebar.js:
both sides' additions kept
Verified on the merged tree: 107 Python tests, all JS tests except the orphaned
material_parameter_cross_node.mjs, jsdom walkthroughs of the audio studio,
domain switching and the gallery search blocks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
This commit is contained in:
+9
-3
@@ -96,10 +96,16 @@ DOM or live LiteGraph state.
|
||||
- `api/romanizer.py` converts Japanese (text containing kana) and Korean to Latin
|
||||
script for transcripts; Han-only text is left unchanged, and missing optional
|
||||
dependencies (pykakasi, hangul-romanize) are reported, not hidden.
|
||||
- `api/image_search.py` powers the output gallery search (`gallery_images?q=`):
|
||||
it reads only the PNG text chunks before the pixel data (ComfyUI prompt and
|
||||
workflow, A1111 `parameters`), caches one record per image by mtime, and
|
||||
matches all query terms; hex terms of 8+ characters also match recorded model
|
||||
SHA256 values and, via `collect_model_hash_index` in `model_resolution.py`,
|
||||
local model files with that hash. Terms arrive as repeated `term` params, one
|
||||
phrase each. `ui_search_chips.js` is the reusable search-block input (Enter or a
|
||||
comma commits a block); `ui_gallery.js` places it above the gallery.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `web/main.js` coordinates extension registration. `browser.js` owns the shared
|
||||
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
|
||||
browser class and extracted-method wiring; `browser_entry.js` owns the single
|
||||
browser instance plus floating/topbar/menu entry behavior (pointer-capture drag,
|
||||
pre-mount positioning to avoid a flash, `anomalous_trigger_pos_v3` persistence);
|
||||
|
||||
+11
-1
@@ -8,6 +8,7 @@ import time
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .image_search import filter_images
|
||||
from .path_utils import require_filename, resolve_within
|
||||
|
||||
|
||||
@@ -55,11 +56,20 @@ def _invalidate_gallery_snapshot():
|
||||
_gallery_snapshot.update(root=None, expires=0, images=[])
|
||||
|
||||
|
||||
def _gallery_listing(output_dir, refresh, query):
|
||||
images = _gallery_images(output_dir, refresh)
|
||||
return filter_images(os.path.realpath(output_dir), images, query) if query else images
|
||||
|
||||
|
||||
async def api_get_gallery_images(request):
|
||||
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info."""
|
||||
try:
|
||||
page = max(1, int(request.query.get('page', 1)))
|
||||
limit = min(200, max(1, int(request.query.get('limit', 50))))
|
||||
images = await asyncio.to_thread(_gallery_images, folder_paths.get_output_directory(), request.query.get('refresh') == '1')
|
||||
# Repeated `term` params keep phrases whole; `q` is split on whitespace.
|
||||
terms = [term for term in request.query.getall('term', []) if term.strip()]
|
||||
query = terms or request.query.get('q', '').strip()
|
||||
images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query)
|
||||
total = len(images)
|
||||
start = (page - 1) * limit
|
||||
return web.json_response({"images": images[start:start + limit], "total": total,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Search output images by the generation info embedded in them.
|
||||
|
||||
Reads only the PNG text chunks before the pixel data (ComfyUI `prompt` /
|
||||
`workflow`, A1111/Forge `parameters`), caches one small record per file keyed by
|
||||
its mtime, and matches every query term against it. Terms that look like a
|
||||
hash also match model SHA256 values recorded in the workflow and, through the
|
||||
scanned model index, the local model files that carry that hash.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
MAX_CHUNK_BYTES = 32 * 1024 * 1024
|
||||
MAX_CACHED_RECORDS = 60_000
|
||||
MODEL_INDEX_SECONDS = 120
|
||||
HASH_TERM = re.compile(r"^[0-9a-f]{8,64}$", re.IGNORECASE)
|
||||
MODEL_FILE = re.compile(r"\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$", re.IGNORECASE)
|
||||
NUMERIC_KEYS = {"seed", "noise_seed", "steps", "cfg", "denoise", "width", "height"}
|
||||
|
||||
_record_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_model_index = {"expires": 0, "entries": {}}
|
||||
_model_index_lock = threading.Lock()
|
||||
|
||||
|
||||
def _decode_text(raw):
|
||||
"""tEXt is Latin-1 by spec, but ComfyUI/PIL may store UTF-8 bytes in it."""
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw.decode("latin-1", "replace")
|
||||
|
||||
|
||||
def read_png_text(path):
|
||||
"""Text chunks that precede IDAT, as {keyword: text}. Pixel data is never read."""
|
||||
chunks = {}
|
||||
with open(path, "rb") as handle:
|
||||
if handle.read(8) != PNG_SIGNATURE:
|
||||
return chunks
|
||||
while True:
|
||||
header = handle.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
length, kind = struct.unpack(">I4s", header)
|
||||
if kind in (b"IDAT", b"IEND") or length > MAX_CHUNK_BYTES:
|
||||
break
|
||||
data = handle.read(length)
|
||||
handle.seek(4, os.SEEK_CUR)
|
||||
try:
|
||||
if kind == b"tEXt":
|
||||
key, _, value = data.partition(b"\x00")
|
||||
chunks[key.decode("latin-1")] = _decode_text(value)
|
||||
elif kind == b"zTXt":
|
||||
key, _, rest = data.partition(b"\x00")
|
||||
chunks[key.decode("latin-1")] = _decode_text(zlib.decompress(rest[1:]))
|
||||
elif kind == b"iTXt":
|
||||
key, _, rest = data.partition(b"\x00")
|
||||
compressed, rest = rest[0], rest[2:]
|
||||
_, _, rest = rest.partition(b"\x00") # language tag
|
||||
_, _, text = rest.partition(b"\x00") # translated keyword
|
||||
chunks[key.decode("latin-1")] = (zlib.decompress(text) if compressed else text).decode("utf-8", "replace")
|
||||
except (zlib.error, IndexError, ValueError):
|
||||
continue
|
||||
return chunks
|
||||
|
||||
|
||||
def _normalise_model_ref(value):
|
||||
ref = str(value).replace("\\", "/").strip().lower()
|
||||
return {ref, ref.rsplit("/", 1)[-1]}
|
||||
|
||||
|
||||
def build_record(path):
|
||||
"""Searchable text, recorded model hashes and referenced model files for one image."""
|
||||
name = os.path.basename(path)
|
||||
texts = [name]
|
||||
hashes = set()
|
||||
models = set()
|
||||
if path.lower().endswith(".png"):
|
||||
try:
|
||||
chunks = read_png_text(path)
|
||||
except OSError:
|
||||
chunks = {}
|
||||
prompt = _load_json(chunks.get("prompt"))
|
||||
if isinstance(prompt, dict):
|
||||
for node in prompt.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
texts.append(str(node.get("class_type", "")))
|
||||
for key, value in (node.get("inputs") or {}).items():
|
||||
if isinstance(value, str):
|
||||
texts.append(value)
|
||||
if MODEL_FILE.search(value):
|
||||
models |= _normalise_model_ref(value)
|
||||
elif isinstance(value, (int, float)) and not isinstance(value, bool) and key in NUMERIC_KEYS:
|
||||
texts.append(f"{key}:{value} {value}")
|
||||
workflow = _load_json(chunks.get("workflow"))
|
||||
recorded = (workflow or {}).get("extra", {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
|
||||
if isinstance(recorded, dict):
|
||||
for key, entry in recorded.items():
|
||||
sha = entry.get("hash") if isinstance(entry, dict) else entry
|
||||
if isinstance(sha, str) and sha:
|
||||
hashes.add(sha.upper())
|
||||
model_name = str(key).split("_", 1)[-1]
|
||||
if MODEL_FILE.search(model_name):
|
||||
models |= _normalise_model_ref(model_name)
|
||||
if chunks.get("parameters"):
|
||||
texts.append(chunks["parameters"])
|
||||
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models}
|
||||
|
||||
|
||||
def _load_json(raw):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cached_record(path, mtime=None):
|
||||
"""Record for one image; `mtime` from the gallery listing avoids a stat per file."""
|
||||
if mtime is None:
|
||||
try:
|
||||
mtime = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
key = mtime
|
||||
with _cache_lock:
|
||||
cached = _record_cache.get(path)
|
||||
if cached and cached[0] == key:
|
||||
return cached[1]
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
record = build_record(path)
|
||||
with _cache_lock:
|
||||
if len(_record_cache) >= MAX_CACHED_RECORDS:
|
||||
_record_cache.clear()
|
||||
_record_cache[path] = (key, record)
|
||||
return record
|
||||
|
||||
|
||||
def _local_models_for_hash(term):
|
||||
"""Model file refs whose scanned SHA256 starts with `term` (cached briefly)."""
|
||||
# Imported lazily: api.utils re-exports gallery_routes, which imports this module.
|
||||
from .model_resolution import collect_model_hash_index
|
||||
|
||||
with _model_index_lock:
|
||||
if time.monotonic() >= _model_index["expires"]:
|
||||
_model_index["entries"] = collect_model_hash_index()
|
||||
_model_index["expires"] = time.monotonic() + MODEL_INDEX_SECONDS
|
||||
entries = _model_index["entries"]
|
||||
wanted = term.upper()
|
||||
refs = set()
|
||||
for key, value in entries.items():
|
||||
sha = str((value or {}).get("hash") or "").upper()
|
||||
if sha and sha.startswith(wanted):
|
||||
refs |= _normalise_model_ref(key)
|
||||
return refs
|
||||
|
||||
|
||||
def parse_query(query):
|
||||
"""Search terms: a list keeps each entry as one phrase; a string splits on whitespace."""
|
||||
raw = query if isinstance(query, (list, tuple)) else str(query or "").split()
|
||||
terms = []
|
||||
for term in raw:
|
||||
term = " ".join(str(term).lower().split())
|
||||
if term and term not in terms:
|
||||
terms.append(term)
|
||||
return terms
|
||||
|
||||
|
||||
def filter_images(output_dir, images, query):
|
||||
"""Keep gallery entries whose record matches every term (phrases may contain spaces)."""
|
||||
terms = parse_query(query)
|
||||
if not terms:
|
||||
return images
|
||||
hash_refs = {term: _local_models_for_hash(term) for term in terms if HASH_TERM.match(term)}
|
||||
matched = []
|
||||
for image in images:
|
||||
record = cached_record(os.path.join(output_dir, image.get("subfolder", ""), image["filename"]), image.get("mtime"))
|
||||
if record is None:
|
||||
continue
|
||||
if all(_term_matches(term, record, hash_refs) for term in terms):
|
||||
matched.append(image)
|
||||
return matched
|
||||
|
||||
|
||||
def _term_matches(term, record, hash_refs):
|
||||
if term in record["text"]:
|
||||
return True
|
||||
if term in hash_refs:
|
||||
upper = term.upper()
|
||||
if any(sha.startswith(upper) for sha in record["hashes"]):
|
||||
return True
|
||||
return bool(hash_refs[term] & record["models"])
|
||||
return False
|
||||
+62
-58
@@ -18,7 +18,7 @@ try:
|
||||
except ImportError:
|
||||
from model_policies import requires_hash_for_model_recovery
|
||||
from .metadata import get_metadata
|
||||
from .model_constants import RESOLVABLE_MODEL_TYPES
|
||||
from .model_constants import MODEL_EXTENSIONS, RESOLVABLE_MODEL_TYPES
|
||||
from .utils import atomic_write_json
|
||||
|
||||
def _parse_resolution_types(expected_types_raw):
|
||||
@@ -266,66 +266,70 @@ async def api_resolve_hash_batch(request):
|
||||
return web.json_response({"results": await asyncio.to_thread(resolve_batch)})
|
||||
|
||||
|
||||
def collect_model_hash_index():
|
||||
"""Scanned models keyed by relative path and basename -> {hash, size, url}.
|
||||
|
||||
Ambiguous keys (same name, different hash) are dropped. Shared by the
|
||||
all-hashes endpoint and the output gallery's hash search.
|
||||
"""
|
||||
hashes = {}
|
||||
ambiguous_keys = set()
|
||||
|
||||
def add_hash(key, value):
|
||||
if key in ambiguous_keys:
|
||||
return
|
||||
existing = hashes.get(key)
|
||||
if existing is None or existing == value:
|
||||
hashes[key] = value
|
||||
else:
|
||||
hashes.pop(key, None)
|
||||
ambiguous_keys.add(key)
|
||||
|
||||
types = RESOLVABLE_MODEL_TYPES
|
||||
seen_dirs = set()
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for base_dir in paths:
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith(MODEL_EXTENSIONS):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
size_bytes = 0
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
hash_val = ""
|
||||
if meta and meta.get("hash"):
|
||||
hash_val = meta["hash"]
|
||||
model_url = ""
|
||||
if meta:
|
||||
model_url = meta.get("source_url") or meta.get("civitai_url") or ""
|
||||
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
basename = os.path.basename(file_path)
|
||||
|
||||
val = {"hash": hash_val, "size": size_bytes, "url": model_url}
|
||||
add_hash(rel_path, val)
|
||||
add_hash(basename, val)
|
||||
except Exception:
|
||||
pass
|
||||
return hashes
|
||||
|
||||
|
||||
async def api_get_all_hashes(request):
|
||||
"""
|
||||
Returns a dictionary of all scanned models with their hash and size.
|
||||
Keyed by both relative path and basename for maximum frontend resilience.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
def fetch_all():
|
||||
hashes = {}
|
||||
ambiguous_keys = set()
|
||||
|
||||
def add_hash(key, value):
|
||||
if key in ambiguous_keys:
|
||||
return
|
||||
existing = hashes.get(key)
|
||||
if existing is None or existing == value:
|
||||
hashes[key] = value
|
||||
else:
|
||||
hashes.pop(key, None)
|
||||
ambiguous_keys.add(key)
|
||||
|
||||
types = RESOLVABLE_MODEL_TYPES
|
||||
seen_dirs = set()
|
||||
for t in types:
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(t)
|
||||
if not paths: continue
|
||||
for base_dir in paths:
|
||||
if base_dir in seen_dirs: continue
|
||||
seen_dirs.add(base_dir)
|
||||
if not os.path.exists(base_dir): continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith(MODEL_EXTENSIONS):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
size_bytes = 0
|
||||
|
||||
meta = get_metadata(file_path)
|
||||
hash_val = ""
|
||||
if meta and meta.get("hash"):
|
||||
hash_val = meta["hash"]
|
||||
model_url = ""
|
||||
if meta:
|
||||
model_url = meta.get("source_url") or meta.get("civitai_url") or ""
|
||||
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
if rel_path.startswith('.\\') or rel_path.startswith('./'):
|
||||
rel_path = rel_path[2:]
|
||||
rel_path = rel_path.replace('\\', '/')
|
||||
basename = os.path.basename(file_path)
|
||||
|
||||
val = {"hash": hash_val, "size": size_bytes, "url": model_url}
|
||||
add_hash(rel_path, val)
|
||||
add_hash(basename, val)
|
||||
except Exception:
|
||||
pass
|
||||
return hashes
|
||||
|
||||
hashes = await asyncio.to_thread(fetch_all)
|
||||
hashes = await asyncio.to_thread(collect_model_hash_index)
|
||||
return web.json_response(hashes)
|
||||
|
||||
@@ -63,8 +63,9 @@ live in `material_schema.py`, `material_assets.py`, and `material_store.py`;
|
||||
|
||||
The former mixed utility routes are separated: `media_routes.py` owns card
|
||||
thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded
|
||||
output snapshot and deletion, `translation_routes.py` owns provider fallback,
|
||||
`folder_types.py` owns configured visibility and scan scope, and `audio_catalog.py`
|
||||
output snapshot and deletion (searched through `image_search.py`, which reads
|
||||
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
|
||||
owns provider fallback, `folder_types.py` owns configured visibility and scan scope, and `audio_catalog.py`
|
||||
with `romanizer.py` owns Audio & Voice Studio scanning, ingestion (conversion to
|
||||
24 kHz mono PCM WAV via an explicitly resolved ffmpeg), streaming, and
|
||||
romanization. Voice files use the ComfyUI-F5-TTS layout (`Character.wav` main
|
||||
|
||||
@@ -477,6 +477,15 @@ export const i18n = {
|
||||
galleryScrollMore: '向下滚动加载更多',
|
||||
galleryLoadFailed: '加载失败',
|
||||
galleryEmpty: '图库为空',
|
||||
gallerySearchPlaceholder: '输入后按回车变成一个搜索块:提示词、模型、LoRA、seed、文件名或模型哈希',
|
||||
gallerySearchHelp: '每个搜索块单独匹配,所有块需同时满足;块里可以有空格,按整段短语匹配。8 位以上的十六进制按模型哈希匹配(例如 667B5E3B),改过名的模型文件也能找到。输入框为空时按退格删除最后一块。首次搜索需要读取图片信息,之后会快很多。',
|
||||
searchChipHash: '哈希',
|
||||
searchChipNumber: '数字',
|
||||
searchChipRemove: '移除这个搜索块',
|
||||
searchChipsClear: '清空搜索',
|
||||
gallerySearchCount: '找到 {count} 张',
|
||||
gallerySearchEmpty: '没有找到匹配的图片',
|
||||
gallerySearching: '正在搜索…',
|
||||
galleryClose: '关闭图库',
|
||||
galleryHistoryTitle: '历史生成图库: {name}',
|
||||
galleryScanning: '加载中,正在扫描图片元数据...',
|
||||
@@ -1844,6 +1853,15 @@ export const i18n = {
|
||||
galleryScrollMore: 'Scroll down to load more',
|
||||
galleryLoadFailed: 'Load failed',
|
||||
galleryEmpty: 'Gallery is empty',
|
||||
gallerySearchPlaceholder: 'Type and press Enter to add a search block: prompt, model, LoRA, seed, file name or model hash',
|
||||
gallerySearchHelp: 'Each search block matches on its own and all blocks must match; a block may contain spaces and matches as a phrase. Hex of 8+ characters matches model hashes (e.g. 667B5E3B), even for renamed model files. Backspace in an empty box removes the last block. The first search reads image info; later ones are much faster.',
|
||||
searchChipHash: 'hash',
|
||||
searchChipNumber: 'number',
|
||||
searchChipRemove: 'Remove this search block',
|
||||
searchChipsClear: 'Clear search',
|
||||
gallerySearchCount: '{count} found',
|
||||
gallerySearchEmpty: 'No matching images',
|
||||
gallerySearching: 'Searching…',
|
||||
galleryClose: 'Close Gallery',
|
||||
galleryHistoryTitle: 'Generated History: {name}',
|
||||
galleryScanning: 'Loading, scanning metadata...',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { translate } from './locales.js';
|
||||
import { showImageMaterialDetail } from './ui_materials.js';
|
||||
import { createSearchChips } from './ui_search_chips.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
@@ -25,14 +26,46 @@ export async function refreshGalleryImages() {
|
||||
|
||||
|
||||
|
||||
export async function loadGalleryImages(page = 1, reset = false) {
|
||||
if (this.galleryLoading) return;
|
||||
/** Search blocks above the output gallery; the terms live on the browser as `gallerySearchTerms`. */
|
||||
export function createGallerySearchBar(owner) {
|
||||
const search = createSearchChips({
|
||||
placeholder: t('gallerySearchPlaceholder'),
|
||||
help: t('gallerySearchHelp'),
|
||||
onChange: (terms) => {
|
||||
owner.gallerySearchTerms = terms;
|
||||
owner.loadGalleryImages(1, true, { refresh: false });
|
||||
},
|
||||
});
|
||||
search.element.classList.add('anomalous-gallery-search');
|
||||
owner.gallerySearchCount = search.countElement;
|
||||
return search.element;
|
||||
}
|
||||
|
||||
export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) {
|
||||
if (this.galleryLoading) {
|
||||
// A newer query arrived while loading: run it once the current request settles.
|
||||
if (reset) this.galleryReloadPending = true;
|
||||
return;
|
||||
}
|
||||
this.galleryLoading = true;
|
||||
this.gallerySentinel.textContent = t('galleryLoading');
|
||||
const terms = [...(this.gallerySearchTerms || [])];
|
||||
const termsKey = JSON.stringify(terms);
|
||||
const query = terms.length > 0;
|
||||
this.gallerySentinel.textContent = query && page === 1 ? t('gallerySearching') : t('galleryLoading');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/anomalous/gallery_images?page=${page}&limit=50${reset ? "&refresh=1" : ""}`);
|
||||
const params = new URLSearchParams({ page: String(page), limit: '50' });
|
||||
if (refresh) params.set('refresh', '1');
|
||||
terms.forEach(term => params.append('term', term));
|
||||
const res = await fetch(`/anomalous/gallery_images?${params}`);
|
||||
const data = await res.json();
|
||||
if (termsKey !== JSON.stringify(this.gallerySearchTerms || [])) {
|
||||
this.galleryReloadPending = true;
|
||||
return;
|
||||
}
|
||||
if (this.gallerySearchCount) {
|
||||
this.gallerySearchCount.textContent = query && Number.isFinite(data.total) ? t('gallerySearchCount', { count: data.total }) : '';
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
// Clear existing cards
|
||||
@@ -290,14 +323,18 @@ export async function loadGalleryImages(page = 1, reset = false) {
|
||||
}
|
||||
} else {
|
||||
this.galleryHasMore = false;
|
||||
this.gallerySentinel.textContent = reset ? t('galleryEmpty') : t('galleryNoMore');
|
||||
this.gallerySentinel.textContent = reset ? t(query ? 'gallerySearchEmpty' : 'galleryEmpty') : t('galleryNoMore');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load gallery images', e);
|
||||
this.gallerySentinel.textContent = t('galleryLoadFailed');
|
||||
} finally {
|
||||
this.galleryLoading = false;
|
||||
if (this.galleryReloadPending) {
|
||||
this.galleryReloadPending = false;
|
||||
void this.loadGalleryImages(1, true, { refresh: false });
|
||||
}
|
||||
}
|
||||
|
||||
this.galleryLoading = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { translate } from './locales.js';
|
||||
|
||||
/**
|
||||
* Search box whose terms are visible blocks. Enter (or a comma) turns the typed
|
||||
* text into a block; a block may contain spaces and matches as one phrase.
|
||||
* The text still being typed also takes part in the search.
|
||||
*/
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
const COMMIT_KEYS = new Set(['Enter', ',', ',', '、']);
|
||||
const DRAFT_DELAY_MS = 350;
|
||||
|
||||
/** 'hash' for 8+ hex characters, 'number' for digits (seed, steps…), else 'text'. */
|
||||
export function termKind(term) {
|
||||
const value = String(term || '').trim();
|
||||
if (/^\d+$/.test(value)) return 'number';
|
||||
if (/^[0-9a-f]{8,64}$/i.test(value)) return 'hash';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function normaliseTerm(term) {
|
||||
return String(term || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function createSearchChips({ placeholder = '', help = '', onChange } = {}) {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'anomalous-search-chips';
|
||||
if (help) root.title = help;
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'anomalous-search-chips-icon';
|
||||
icon.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'anomalous-search-chips-list';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'anomalous-search-chips-input';
|
||||
input.placeholder = placeholder;
|
||||
input.setAttribute('aria-label', placeholder);
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'anomalous-search-chips-count';
|
||||
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.type = 'button';
|
||||
clearBtn.className = 'anomalous-search-chips-clear';
|
||||
clearBtn.textContent = '×';
|
||||
clearBtn.title = t('searchChipsClear');
|
||||
clearBtn.setAttribute('aria-label', t('searchChipsClear'));
|
||||
|
||||
const chips = [];
|
||||
let lastEmitted = '[]';
|
||||
let timer = null;
|
||||
|
||||
const currentTerms = () => {
|
||||
const terms = [];
|
||||
for (const term of [...chips, normaliseTerm(input.value)]) {
|
||||
const key = term.toLowerCase();
|
||||
if (term && !terms.some(existing => existing.toLowerCase() === key)) terms.push(term);
|
||||
}
|
||||
return terms;
|
||||
};
|
||||
|
||||
const emit = () => {
|
||||
clearTimeout(timer);
|
||||
const terms = currentTerms();
|
||||
clearBtn.hidden = terms.length === 0;
|
||||
const key = JSON.stringify(terms.map(term => term.toLowerCase()));
|
||||
if (key === lastEmitted) return;
|
||||
lastEmitted = key;
|
||||
onChange?.(terms);
|
||||
};
|
||||
|
||||
const renderChips = () => {
|
||||
list.replaceChildren(...chips.map((term, index) => {
|
||||
const chip = document.createElement('span');
|
||||
const kind = termKind(term);
|
||||
chip.className = `anomalous-search-chip is-${kind}`;
|
||||
if (kind !== 'text') {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'anomalous-search-chip-kind';
|
||||
badge.textContent = t(kind === 'hash' ? 'searchChipHash' : 'searchChipNumber');
|
||||
chip.appendChild(badge);
|
||||
}
|
||||
const label = document.createElement('span');
|
||||
label.className = 'anomalous-search-chip-text';
|
||||
label.textContent = term;
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'anomalous-search-chip-remove';
|
||||
remove.textContent = '×';
|
||||
remove.title = t('searchChipRemove');
|
||||
remove.setAttribute('aria-label', t('searchChipRemove'));
|
||||
remove.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chips.splice(index, 1);
|
||||
renderChips();
|
||||
emit();
|
||||
input.focus();
|
||||
};
|
||||
chip.append(label, remove);
|
||||
return chip;
|
||||
}));
|
||||
input.placeholder = chips.length ? '' : placeholder;
|
||||
};
|
||||
|
||||
const commitDraft = () => {
|
||||
const term = normaliseTerm(input.value);
|
||||
input.value = '';
|
||||
if (term && !chips.some(existing => existing.toLowerCase() === term.toLowerCase())) chips.push(term);
|
||||
renderChips();
|
||||
emit();
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return; // IME candidate selection also uses Enter
|
||||
if (COMMIT_KEYS.has(e.key)) {
|
||||
e.preventDefault();
|
||||
commitDraft();
|
||||
} else if (e.key === 'Backspace' && !input.value && chips.length) {
|
||||
e.preventDefault();
|
||||
chips.pop();
|
||||
renderChips();
|
||||
emit();
|
||||
} else if (e.key === 'Escape' && input.value) {
|
||||
e.stopPropagation();
|
||||
input.value = '';
|
||||
emit();
|
||||
}
|
||||
});
|
||||
input.addEventListener('input', (e) => {
|
||||
// IMEs insert "," / "、" as text instead of a key event: treat them as separators too.
|
||||
if (!e.isComposing && /[,,、]/.test(input.value)) {
|
||||
const parts = input.value.split(/[,,、]/);
|
||||
const rest = parts.pop();
|
||||
for (const part of parts) {
|
||||
const term = normaliseTerm(part);
|
||||
if (term && !chips.some(existing => existing.toLowerCase() === term.toLowerCase())) chips.push(term);
|
||||
}
|
||||
input.value = rest.trimStart();
|
||||
renderChips();
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
clearBtn.hidden = currentTerms().length === 0;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(emit, DRAFT_DELAY_MS);
|
||||
});
|
||||
input.addEventListener('blur', () => { if (normaliseTerm(input.value)) commitDraft(); });
|
||||
clearBtn.onclick = () => {
|
||||
chips.length = 0;
|
||||
input.value = '';
|
||||
renderChips();
|
||||
emit();
|
||||
input.focus();
|
||||
};
|
||||
root.addEventListener('mousedown', (e) => {
|
||||
if (e.target === root || e.target === list) {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.hidden = true;
|
||||
root.append(icon, list, input, count, clearBtn);
|
||||
return { element: root, countElement: count, getTerms: currentTerms };
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { createSettingsHub } from './ui_settings_hub.js';
|
||||
import { createToolbox } from './ui_toolbox.js';
|
||||
import { createDomainSwitcher, getActiveDomain } from './ui_domain_switcher.js';
|
||||
import { renderAudioSidebar } from './ui_audio_sidebar.js';
|
||||
import { createGallerySearchBar } from './ui_gallery.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
@@ -564,7 +565,7 @@ export function createDOM() {
|
||||
|
||||
this.galleryGrid = document.createElement('div');
|
||||
this.galleryGrid.className = 'anomalous-gallery-grid';
|
||||
this.galleryPanel.appendChild(this.galleryGrid);
|
||||
this.galleryPanel.append(createGallerySearchBar(this), this.galleryGrid);
|
||||
|
||||
this.gallerySentinel = document.createElement('div');
|
||||
this.gallerySentinel.className = 'anomalous-gallery-sentinel';
|
||||
|
||||
@@ -2849,3 +2849,122 @@ dialog.anomalous-gallery-viewer[open] {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
/* Search blocks (ui_search_chips.js); used above the output gallery */
|
||||
.anomalous-search-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: #94a3b8;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.anomalous-search-chips:focus-within {
|
||||
border-color: rgba(129, 140, 248, 0.5);
|
||||
}
|
||||
|
||||
.anomalous-search-chips-icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.anomalous-search-chips-list {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.anomalous-search-chips-input {
|
||||
flex: 1 1 160px;
|
||||
min-width: 120px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #f1f5f9;
|
||||
font-size: 13px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.anomalous-search-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
max-width: 100%;
|
||||
padding: 2px 4px 2px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.4);
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
color: #e0e7ff;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.anomalous-search-chip.is-hash {
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #fde68a;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.anomalous-search-chip.is-number {
|
||||
border-color: rgba(52, 211, 153, 0.45);
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.anomalous-search-chip-kind {
|
||||
font-size: 10px;
|
||||
opacity: 0.75;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.anomalous-search-chip-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-search-chip-remove,
|
||||
.anomalous-search-chips-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.anomalous-search-chip-remove:hover,
|
||||
.anomalous-search-chips-clear:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.anomalous-search-chips-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.anomalous-search-chips-count {
|
||||
font-size: 12px;
|
||||
color: #a5b4fc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-search-chips-count:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.anomalous-gallery-search {
|
||||
margin: 12px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user