Files
DemonGatanjieuandClaude Opus 5.5 e0776891c8 feat(gallery): turn search terms into visible, removable search blocks
Splitting the query on spaces hid how it was read and made phrases like
"cyberpunk city" impossible to search.

- ui_search_chips.js: Enter or a comma (also "," / "、" inserted by an IME)
  turns the typed text into a block; blocks keep their spaces and match as a
  phrase; hash-like and numeric blocks are labelled; × removes a block,
  Backspace in an empty box removes the last one, one button clears all; text
  still being typed joins the search after a short pause; IME composition
  never commits a block
- gallery_images accepts repeated `term` params (q still works)

Verified: tests/test_image_search.py (phrase blocks vs split query, 107 Python
tests in total) and a jsdom walkthrough of blocks, IME commas, backspace,
remove, clear and the request parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 10:00:05 +00:00

171 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}