diff --git a/ui/config/config.css b/ui/config/config.css index 2cd302df1bb..b8f50bef3a9 100644 --- a/ui/config/config.css +++ b/ui/config/config.css @@ -429,3 +429,68 @@ input[type="checkbox"] { width: 15px; height: 15px; } + +.trigger-search-container.trigger-search-container { + grid-column: 1 / span 2; + margin: 10px 0 0; + padding: 0; + display: block; + position: relative; +} + +.trigger-search-input.trigger-search-input { + display: block; + width: 100%; + padding: 10px 40px; + font-size: 16px; + text-align: left; + border: 1px solid #ccc; + border-radius: 6px; + background-color: #fafafa; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: 12px center; + transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; + box-sizing: border-box; + outline: none; +} + +.trigger-search-input.trigger-search-input:focus { + background-color: #fff; + border-color: #4a90e2; + box-shadow: 0 2px 8px rgb(74 144 226 / 20%); +} + +.trigger-search-input.trigger-search-input::placeholder { + color: #bbb; + font-style: italic; +} + +.trigger-search-clear.trigger-search-clear { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + opacity: 0.6; + transition: opacity 0.2s; + display: none; +} + +.trigger-search-clear.trigger-search-clear:hover { + opacity: 1; +} + +.trigger-search-no-matches { + grid-column: 1 / span 2; + text-align: center; + padding: 30px; + color: #888; + font-style: italic; + font-size: 16px; +} diff --git a/ui/config/config.ts b/ui/config/config.ts index 61562988257..e26bd250bc5 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -79,6 +79,39 @@ const kDirectoryDefaultText = { tc: '(默認)', }; +// Text in the trigger search placeholder. +export const kTriggerSearchPlaceholder = { + en: 'Search triggers...', // TODO: verify AI translation + de: 'Trigger suchen...', // TODO: verify AI translation + fr: 'Rechercher des déclencheurs...', // TODO: verify AI translation + ja: 'トリガーを検索...', // TODO: verify AI translation + cn: '搜索触发器...', + ko: '트리거 검색...', + tc: '搜索觸發器...', // TODO: verify AI translation +}; + +// Text shown when no search hits were found. +export const kNoSearchMatches = { + en: 'No matches found.', // TODO: verify AI translation + de: 'Keine Treffer gefunden.', // TODO: verify AI translation + fr: 'Aucun résultat trouvé.', // TODO: verify AI translation + ja: '該当する結果が見つかりませんでした。', // TODO: verify AI translation + cn: '未找到匹配项。', + ko: '일치하는 항목이 없습니다.', + tc: '未找到匹配項。', // TODO: verify AI translation +}; + +// Text shown when hidden triggers are available in a search. +export const kShowHiddenTriggers = { + en: 'Show ${num} other triggers for this zone', // TODO: verify AI translation + de: 'Zeige ${num} andere Trigger für diesen Bereich', // TODO: verify AI translation + fr: 'Afficher ${num} autres triggers pour cette zone', // TODO: verify AI translation + ja: 'このゾーンの他の ${num} 個のトリガーを表示', // TODO: verify AI translation + cn: '显示此区域的其他 ${num} 个触发器', + ko: '이 컨텐츠의 다른 트리거 ${num}개 표시하기', + tc: '顯示此區域的其他 ${num} 個觸發器', // TODO: verify AI translation +}; + // Translating data folders to a category name. export const kPrefixToCategory = { '00-misc': { diff --git a/ui/config/config_search.ts b/ui/config/config_search.ts new file mode 100644 index 00000000000..f81e52e5040 --- /dev/null +++ b/ui/config/config_search.ts @@ -0,0 +1,321 @@ +import { + CactbotConfigurator, + kNoSearchMatches, + kShowHiddenTriggers, + kTriggerSearchPlaceholder, +} from './config'; + +export interface SearchTriggerData { + id?: string; +} + +export interface SearchContainerData { + title?: string; +} + +// Custom element types to store data and avoid repetitive casts +interface SearchTriggerElement extends HTMLElement { + __triggerData?: SearchTriggerData; + __searchText?: string; +} + +interface SearchContainerElement extends HTMLElement { + __containerData?: SearchContainerData; + __searchText?: string; +} + +export class ConfigSearch { + private searchInput: HTMLInputElement; + private clearButton: HTMLElement; + private noMatchesMessage: HTMLElement; + private searchTimeout?: number; + + constructor( + private base: CactbotConfigurator, + private container: HTMLElement, + ) { + this.searchInput = document.createElement('input'); + this.clearButton = document.createElement('div'); + this.noMatchesMessage = document.createElement('div'); + this.buildUI(); + } + + private buildUI(): void { + const searchContainer = document.createElement('div'); + searchContainer.classList.add('trigger-search-container'); + this.container.appendChild(searchContainer); + + this.searchInput.type = 'text'; + this.searchInput.classList.add('trigger-search-input'); + this.searchInput.placeholder = this.base.translate(kTriggerSearchPlaceholder); + this.searchInput.oninput = () => { + this.updateClearButton(); + this.debouncedSearch(); + }; + searchContainer.appendChild(this.searchInput); + + this.clearButton.classList.add('trigger-search-clear'); + this.clearButton.onclick = () => this.clearSearch(); + searchContainer.appendChild(this.clearButton); + + this.noMatchesMessage.classList.add('trigger-search-no-matches'); + this.noMatchesMessage.innerText = this.base.translate(kNoSearchMatches); + this.noMatchesMessage.style.display = 'none'; + this.container.appendChild(this.noMatchesMessage); + } + + private updateClearButton(): void { + this.clearButton.style.display = this.searchInput.value === '' ? 'none' : 'block'; + } + + private clearSearch(): void { + if (this.searchTimeout !== undefined) { + window.clearTimeout(this.searchTimeout); + this.searchTimeout = undefined; + } + this.searchInput.value = ''; + this.updateClearButton(); + this.showAll(); + } + + // Calculate debounce time based on search text length. + // Shorter text = longer debounce to reduce unnecessary searches. + // 1 char: 100ms, 2 chars: 75ms, 3 chars: 50ms, 4 chars: 25ms, 5+ chars: 0ms + private calculateDebounceTime(length: number): number { + if (length === 0) + return 0; + return Math.max(0, 100 - (length - 1) * 25); + } + + private debouncedSearch(): void { + if (this.searchTimeout !== undefined) + window.clearTimeout(this.searchTimeout); + + const length = this.searchInput.value.trim().length; + const debounceTime = this.calculateDebounceTime(length); + + if (debounceTime > 0) { + this.searchTimeout = window.setTimeout(() => { + this.performSearch(); + }, debounceTime); + } else { + this.performSearch(); + } + } + + private matchParts(text: string, parts: string[]): boolean { + for (const part of parts) { + if (!text.includes(part)) + return false; + } + return true; + } + + public performSearch(): void { + const searchTerm = this.searchInput.value.trim(); + + if (searchTerm === '') { + this.showAll(); + return; + } + + const searchParts = searchTerm.toLowerCase().split(/\s+/).filter((p) => p !== ''); + const visibleExpansionContainers = new Set(); + + const allTriggerContainers = this.container.querySelectorAll( + '.trigger-file-container', + ); + let anyVisible = false; + + allTriggerContainers.forEach((triggerContainer) => { + let containerVisible = false; + + // Check which search parts match the container title + const containerMatchedParts = new Set(); + if (triggerContainer.__searchText !== undefined) { + for (const part of searchParts) { + if (triggerContainer.__searchText.includes(part)) + containerMatchedParts.add(part); + } + } + + // Remaining parts that need to match triggers + const remainingParts = searchParts.filter((p) => !containerMatchedParts.has(p)); + + // If all parts matched the container, show all triggers + if (remainingParts.length === 0) { + this.setContainerVisible(triggerContainer, true); + this.updateShowHiddenButton(triggerContainer, 0); + anyVisible = true; + containerVisible = true; + } else { + // Check triggers against remaining parts + const triggersInContainer = triggerContainer.querySelectorAll( + '.trigger', + ); + let hasVisibleTrigger = false; + let hiddenCount = 0; + + triggersInContainer.forEach((triggerElement) => { + const triggerData = triggerElement.__triggerData; + + if (triggerData === undefined) { + // Non-trigger elements (like override warnings) are shown if container + // partially matches + const shouldShow = containerMatchedParts.size > 0 || remainingParts.length === 0; + this.setTriggerVisible(triggerElement, shouldShow); + if (shouldShow) + hasVisibleTrigger = true; + return; + } + + const shouldShow = this.checkTriggerMatch(triggerElement, remainingParts); + this.setTriggerVisible(triggerElement, shouldShow); + + if (shouldShow) + hasVisibleTrigger = true; + else + hiddenCount++; + }); + + this.setContainerVisible(triggerContainer, hasVisibleTrigger, false); + this.updateShowHiddenButton(triggerContainer, hiddenCount); + + if (hasVisibleTrigger) { + anyVisible = true; + containerVisible = true; + } + } + + if (containerVisible) { + const expansion = triggerContainer.closest('.trigger-expansion-container'); + if (expansion instanceof HTMLElement) + visibleExpansionContainers.add(expansion); + } + }); + + this.updateExpansionVisibility(true, visibleExpansionContainers); + this.noMatchesMessage.style.display = anyVisible ? 'none' : 'block'; + } + + private checkTriggerMatch( + element: SearchTriggerElement, + searchParts: string[], + ): boolean { + const searchText = element.__searchText; + return searchText !== undefined && this.matchParts(searchText, searchParts); + } + + private showAll(): void { + const allTriggerDivs = this.container.querySelectorAll('.trigger'); + allTriggerDivs.forEach((triggerDiv) => this.setTriggerVisible(triggerDiv, true)); + + const allContainers = this.container.querySelectorAll('.trigger-file-container'); + allContainers.forEach((containerElement) => { + containerElement.style.display = ''; + containerElement.classList.add('collapsed'); + }); + + this.updateExpansionVisibility(false, null, true); + this.noMatchesMessage.style.display = 'none'; + + const allButtons = this.container.querySelectorAll('.trigger-search-show-hidden'); + allButtons.forEach((b) => b.remove()); + } + + private updateShowHiddenButton(container: HTMLElement, count: number): void { + const kButtonClass = 'trigger-search-show-hidden'; + let button = container.querySelector(`.${kButtonClass}`); + + if (count <= 0) { + if (button) + button.remove(); + return; + } + + if (!button) { + button = document.createElement('input'); + button.type = 'button'; + button.classList.add(kButtonClass); + button.onclick = () => { + const triggers = container.querySelectorAll('.trigger'); + triggers.forEach((t) => this.setTriggerVisible(t, true)); + if (button) + button.remove(); + }; + container.appendChild(button); + } + + const text = this.base.translate(kShowHiddenTriggers).replace('${num}', count.toString()); + button.value = text; + } + + private setTriggerVisible(triggerElement: HTMLElement, visible: boolean): void { + const display = visible ? '' : 'none'; + if (triggerElement.style.display !== display) + triggerElement.style.display = display; + const nextSibling = triggerElement.nextElementSibling; + if (nextSibling instanceof HTMLElement && nextSibling.classList.contains('trigger-details')) { + if (nextSibling.style.display !== display) + nextSibling.style.display = display; + } + } + + private setContainerVisible( + container: HTMLElement, + visible: boolean, + updateChildren: boolean = true, + ): void { + const display = visible ? '' : 'none'; + if (container.style.display !== display) + container.style.display = display; + if (visible && updateChildren) { + const triggers = container.querySelectorAll('.trigger'); + triggers.forEach((t) => this.setTriggerVisible(t, visible)); + } + } + + private updateExpansionVisibility( + searching: boolean, + visibleSet: Set | null, + forceCollapse?: boolean, + ): void { + const allExpansionContainers = this.container.querySelectorAll( + '.trigger-expansion-container', + ); + allExpansionContainers.forEach((expansionContainer) => { + let hasVisible = false; + if (visibleSet) { + hasVisible = visibleSet.has(expansionContainer); + } else { + const visibleFileContainers = expansionContainer.querySelectorAll( + '.trigger-file-container:not([style*="display: none"])', + ); + hasVisible = visibleFileContainers.length > 0; + } + + const display = hasVisible ? '' : 'none'; + if (expansionContainer.style.display !== display) + expansionContainer.style.display = display; + + if (searching && hasVisible) + expansionContainer.classList.remove('collapsed'); + else if (forceCollapse) + expansionContainer.classList.add('collapsed'); + }); + } + + public static setContainerData(element: HTMLElement, data: SearchContainerData): void { + const el = element as SearchContainerElement; + el.__containerData = data; + if (data.title !== undefined && data.title !== null) + el.__searchText = data.title.replace(/<[^>]*>/g, '').toLowerCase(); + } + + public static setTriggerData(element: HTMLElement, data: SearchTriggerData): void { + const el = element as SearchTriggerElement; + el.__triggerData = data; + if (data.id !== undefined && data.id !== null) + el.__searchText = data.id.toLowerCase(); + } +} diff --git a/ui/oopsyraidsy/oopsyraidsy_config.ts b/ui/oopsyraidsy/oopsyraidsy_config.ts index 8ebaeecffdc..5cf93d9ab05 100644 --- a/ui/oopsyraidsy/oopsyraidsy_config.ts +++ b/ui/oopsyraidsy/oopsyraidsy_config.ts @@ -8,6 +8,7 @@ import { ConfigProcessedFile, ConfigProcessedFileMap, } from '../config/config'; +import { ConfigSearch } from '../config/config_search'; import { generateBuffTriggerIds } from './buff_map'; import oopsyFileData from './data/oopsy_manifest.txt'; @@ -60,6 +61,8 @@ class OopsyConfigurator { buildUI(container: HTMLElement, files: OopsyFileData) { const fileMap = this.processOopsyFiles(files); + new ConfigSearch(this.base, container); + const expansionDivs: { [expansion: string]: HTMLElement } = {}; for (const info of Object.values(fileMap)) { @@ -87,6 +90,9 @@ class OopsyConfigurator { const triggerContainer = document.createElement('div'); triggerContainer.classList.add('trigger-file-container', 'collapsed'); + + ConfigSearch.setContainerData(triggerContainer, { title: info.title }); + expansionDiv.appendChild(triggerContainer); const headerDiv = document.createElement('div'); @@ -116,6 +122,9 @@ class OopsyConfigurator { const triggerDiv = document.createElement('div'); triggerDiv.innerHTML = id; triggerDiv.classList.add('trigger'); + + ConfigSearch.setTriggerData(triggerDiv, { id: id }); + triggerOptions.appendChild(triggerDiv); // Build the trigger comment diff --git a/ui/raidboss/raidboss_config.ts b/ui/raidboss/raidboss_config.ts index d1afd22277c..c252f7e817d 100644 --- a/ui/raidboss/raidboss_config.ts +++ b/ui/raidboss/raidboss_config.ts @@ -33,6 +33,7 @@ import { ConfigLooseTriggerSet, ConfigProcessedFileMap, } from '../config/config'; +import { ConfigSearch } from '../config/config_search'; import raidbossFileData from './data/raidboss_manifest.txt'; import { RaidbossTriggerField, RaidbossTriggerOutput } from './popup-text'; @@ -624,6 +625,8 @@ class RaidbossConfigurator { buildUI(container: HTMLElement, raidbossFiles: RaidbossFileData, userOptions: RaidbossOptions) { const fileMap = this.processRaidbossFiles(raidbossFiles, userOptions); + new ConfigSearch(this.base, container); + const expansionDivs: { [expansion: string]: HTMLElement } = {}; for (const [key, info] of Object.entries(fileMap)) { @@ -657,6 +660,9 @@ class RaidbossConfigurator { const triggerContainer = document.createElement('div'); triggerContainer.classList.add('trigger-file-container', 'collapsed'); + + ConfigSearch.setContainerData(triggerContainer, { title: info.title }); + expansionDiv.appendChild(triggerContainer); const headerDiv = document.createElement('div'); @@ -760,6 +766,8 @@ class RaidbossConfigurator { const triggerDiv = document.createElement('div'); triggerDiv.classList.add('trigger'); + ConfigSearch.setTriggerData(triggerDiv, trig); + // Build the trigger label. const triggerId = document.createElement('div'); triggerId.classList.add('trigger-id');