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:
DemonGatanjieu
2026-09-23 10:08:10 +00:00
co-authored by Claude Opus 5.5
10 changed files with 640 additions and 72 deletions
+9 -3
View File
@@ -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
View File
@@ -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,
+202
View File
@@ -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
View File
@@ -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)
+3 -2
View File
@@ -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
+18
View File
@@ -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...',
+44 -7
View File
@@ -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;
}
+170
View File
@@ -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 };
}
+2 -1
View File
@@ -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';
+119
View File
@@ -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;
}