diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b21c5ae..3e6aa72 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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); diff --git a/api/gallery_routes.py b/api/gallery_routes.py index 81780ff..4a424d0 100644 --- a/api/gallery_routes.py +++ b/api/gallery_routes.py @@ -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, diff --git a/api/image_search.py b/api/image_search.py new file mode 100644 index 0000000..def6045 --- /dev/null +++ b/api/image_search.py @@ -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 diff --git a/api/model_resolution.py b/api/model_resolution.py index f603075..d916ec1 100644 --- a/api/model_resolution.py +++ b/api/model_resolution.py @@ -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) diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index d529ffa..cac6f8d 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -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 diff --git a/web/modules/locales.js b/web/modules/locales.js index 48f1194..65dbd0d 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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...', diff --git a/web/modules/ui_gallery.js b/web/modules/ui_gallery.js index 1dd7a35..f71afca 100644 --- a/web/modules/ui_gallery.js +++ b/web/modules/ui_gallery.js @@ -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; } diff --git a/web/modules/ui_search_chips.js b/web/modules/ui_search_chips.js new file mode 100644 index 0000000..5a2bcda --- /dev/null +++ b/web/modules/ui_search_chips.js @@ -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 = ''; + + 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 }; +} diff --git a/web/modules/ui_sidebar.js b/web/modules/ui_sidebar.js index 5718969..5c679d6 100644 --- a/web/modules/ui_sidebar.js +++ b/web/modules/ui_sidebar.js @@ -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'; diff --git a/web/styles/03-gallery-recipes.css b/web/styles/03-gallery-recipes.css index 68e5db3..17694e8 100644 --- a/web/styles/03-gallery-recipes.css +++ b/web/styles/03-gallery-recipes.css @@ -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; +}