From 396f745aebf831dd1d4f842aeeb13f32eff8cd13 Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Thu, 25 Dec 2025 23:45:05 +0800 Subject: [PATCH 01/15] 1 --- ui/config/config.css | 66 +++++++++ ui/config/config.ts | 22 +++ ui/config/config_search.ts | 194 +++++++++++++++++++++++++++ ui/oopsyraidsy/oopsyraidsy_config.ts | 9 ++ ui/raidboss/raidboss_config.ts | 8 ++ 5 files changed, 299 insertions(+) create mode 100644 ui/config/config_search.ts diff --git a/ui/config/config.css b/ui/config/config.css index 2cd302df1bb..58822fef98b 100644 --- a/ui/config/config.css +++ b/ui/config/config.css @@ -429,3 +429,69 @@ input[type="checkbox"] { width: 15px; height: 15px; } + +.trigger-search-container.trigger-search-container { + grid-column: 1 / span 2; + margin: 10px 0 0 0; + padding: 0; + display: block; + position: relative; +} + +.trigger-search-input.trigger-search-input { + display: block; + width: 100%; + padding: 10px 40px 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 rgba(74, 144, 226, 0.2); +} + +.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 ae5c74b2b97..17a6d781298 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -79,6 +79,28 @@ const kDirectoryDefaultText = { ko: '(기본)', }; +// Text in the trigger search placeholder. +export const kTriggerSearchPlaceholder = { + en: 'Search triggers...', + de: 'Trigger suchen...', + fr: 'Rechercher des déclencheurs...', + ja: 'トリガーを検索...', + cn: '搜索触发器...', + tc: '搜索觸發器...', + ko: '트리거 검색...', +}; + +// Text shown when no search hits were found. +export const kNoSearchMatches = { + en: 'No matches found.', + de: 'Keine Treffer gefunden.', + fr: 'Aucun résultat trouvé.', + ja: '該当する結果が見つかりませんでした。', + cn: '未找到匹配项。', + tc: '未找到匹配項。', + ko: '일치하는 항목이 없습니다.', +}; + // 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..1f20bbe9ecc --- /dev/null +++ b/ui/config/config_search.ts @@ -0,0 +1,194 @@ +import { CactbotConfigurator, kNoSearchMatches, kTriggerSearchPlaceholder } from './config'; + +export interface SearchTriggerData { + id?: string; +} + +export interface SearchContainerData { + title?: string; +} + +export class ConfigSearch { + private searchInput: HTMLInputElement; + private clearButton: HTMLElement; + private noMatchesMessage: HTMLElement; + + 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.performSearch(); + }; + 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 { + this.searchInput.value = ''; + this.updateClearButton(); + this.showAll(); + } + + private multiPartMatch(text: string, term: string): boolean { + const lText = text.toLowerCase(); + const parts = term.toLowerCase().split(/\s+/).filter((p) => p !== ''); + + // Each part must be found within the text + for (const part of parts) { + if (!lText.includes(part)) + return false; + } + return true; + } + + public performSearch(): void { + const searchTerm = this.searchInput.value.trim(); + + if (searchTerm === '') { + this.showAll(); + return; + } + + const allTriggerContainers = this.container.querySelectorAll('.trigger-file-container'); + let anyVisible = false; + + allTriggerContainers.forEach((containerElement) => { + const triggerContainer = containerElement as HTMLElement & { + __containerData?: SearchContainerData; + }; + + let containerMatchesTitle = false; + + const title = triggerContainer.__containerData?.title; + if (title !== undefined && title !== null) { + const titleText = title.replace(/<[^>]*>/g, ''); + if (this.multiPartMatch(titleText, searchTerm)) + containerMatchesTitle = true; + } + + if (containerMatchesTitle) { + this.setContainerVisible(triggerContainer, true); + anyVisible = true; + } else { + const triggersInContainer = triggerContainer.querySelectorAll('.trigger'); + let hasVisibleTrigger = false; + + triggersInContainer.forEach((triggerDiv) => { + const triggerElement = triggerDiv as HTMLElement; + const triggerData = (triggerElement as HTMLElement & { + __triggerData?: SearchTriggerData; + }).__triggerData; + + if (triggerData === undefined) { + this.setTriggerVisible(triggerElement, true); + hasVisibleTrigger = true; + return; + } + + const shouldShow = this.checkTriggerMatch(triggerData, searchTerm); + this.setTriggerVisible(triggerElement, shouldShow); + + if (shouldShow) + hasVisibleTrigger = true; + }); + + this.setContainerVisible(triggerContainer, hasVisibleTrigger); + if (hasVisibleTrigger) + anyVisible = true; + } + }); + + this.updateExpansionVisibility(true); + this.noMatchesMessage.style.display = anyVisible ? 'none' : 'block'; + } + + private checkTriggerMatch(data: SearchTriggerData, term: string): boolean { + return data.id !== undefined && this.multiPartMatch(data.id, term); + } + + private showAll(): void { + const allTriggerDivs = this.container.querySelectorAll('.trigger'); + allTriggerDivs.forEach((triggerDiv) => this.setTriggerVisible(triggerDiv as HTMLElement, true)); + + const allContainers = this.container.querySelectorAll( + '.trigger-file-container', + ); + allContainers.forEach((cont) => { + const containerElement = cont as HTMLElement; + containerElement.style.display = ''; + containerElement.classList.add('collapsed'); + }); + + this.updateExpansionVisibility(false, true); + this.noMatchesMessage.style.display = 'none'; + } + + private setTriggerVisible(triggerElement: HTMLElement, visible: boolean): void { + const display = visible ? '' : 'none'; + triggerElement.style.display = display; + const nextSibling = triggerElement.nextElementSibling; + if (nextSibling !== null && nextSibling.classList.contains('trigger-details')) + (nextSibling as HTMLElement).style.display = display; + } + + private setContainerVisible(container: HTMLElement, visible: boolean): void { + container.style.display = visible ? '' : 'none'; + if (visible) { + const triggers = container.querySelectorAll('.trigger'); + triggers.forEach((t) => this.setTriggerVisible(t as HTMLElement, visible)); + } + } + + private updateExpansionVisibility(searching: boolean, forceCollapse?: boolean): void { + const allExpansionContainers = this.container.querySelectorAll('.trigger-expansion-container'); + allExpansionContainers.forEach((expansionElement) => { + const expansionContainer = expansionElement as HTMLElement; + const visibleFileContainers = expansionContainer.querySelectorAll( + '.trigger-file-container:not([style*="display: none"])', + ); + const hasVisible = visibleFileContainers.length > 0; + expansionContainer.style.display = hasVisible ? '' : 'none'; + + if (searching && hasVisible) + expansionContainer.classList.remove('collapsed'); + else if (forceCollapse) + expansionContainer.classList.add('collapsed'); + }); + } + + public static setContainerData(element: HTMLElement, data: SearchContainerData): void { + (element as HTMLElement & { __containerData?: SearchContainerData }).__containerData = data; + } + + public static setTriggerData(element: HTMLElement, data: SearchTriggerData): void { + (element as HTMLElement & { __triggerData?: SearchTriggerData }).__triggerData = data; + } +} diff --git a/ui/oopsyraidsy/oopsyraidsy_config.ts b/ui/oopsyraidsy/oopsyraidsy_config.ts index 82cc38e238f..a26b28047de 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 94212297f3d..f87ae8a3c7a 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'); From 9eacd2fd2b148e8a48d67028956c036103d1312c Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Fri, 26 Dec 2025 00:57:22 +0800 Subject: [PATCH 02/15] stylelintfix --- ui/config/config.css | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/config/config.css b/ui/config/config.css index 58822fef98b..b8f50bef3a9 100644 --- a/ui/config/config.css +++ b/ui/config/config.css @@ -432,7 +432,7 @@ input[type="checkbox"] { .trigger-search-container.trigger-search-container { grid-column: 1 / span 2; - margin: 10px 0 0 0; + margin: 10px 0 0; padding: 0; display: block; position: relative; @@ -441,7 +441,7 @@ input[type="checkbox"] { .trigger-search-input.trigger-search-input { display: block; width: 100%; - padding: 10px 40px 10px 40px; + padding: 10px 40px; font-size: 16px; text-align: left; border: 1px solid #ccc; @@ -458,7 +458,7 @@ input[type="checkbox"] { .trigger-search-input.trigger-search-input:focus { background-color: #fff; border-color: #4a90e2; - box-shadow: 0 2px 8px rgba(74, 144, 226, 0.2); + box-shadow: 0 2px 8px rgb(74 144 226 / 20%); } .trigger-search-input.trigger-search-input::placeholder { @@ -494,4 +494,3 @@ input[type="checkbox"] { font-style: italic; font-size: 16px; } - From cff0486c7a3e27122c07d0ea0513d349bff17763 Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Tue, 30 Dec 2025 13:36:54 +0800 Subject: [PATCH 03/15] ui/config: refine search box ui --- ui/config/config_search.ts | 141 +++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 53 deletions(-) diff --git a/ui/config/config_search.ts b/ui/config/config_search.ts index 1f20bbe9ecc..8ffcf4a7098 100644 --- a/ui/config/config_search.ts +++ b/ui/config/config_search.ts @@ -8,6 +8,17 @@ 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; @@ -57,13 +68,9 @@ export class ConfigSearch { this.showAll(); } - private multiPartMatch(text: string, term: string): boolean { - const lText = text.toLowerCase(); - const parts = term.toLowerCase().split(/\s+/).filter((p) => p !== ''); - - // Each part must be found within the text + private matchParts(text: string, parts: string[]): boolean { for (const part of parts) { - if (!lText.includes(part)) + if (!text.includes(part)) return false; } return true; @@ -77,35 +84,35 @@ export class ConfigSearch { return; } - const allTriggerContainers = this.container.querySelectorAll('.trigger-file-container'); - let anyVisible = false; + const searchParts = searchTerm.toLowerCase().split(/\s+/).filter((p) => p !== ''); + const visibleExpansionContainers = new Set(); - allTriggerContainers.forEach((containerElement) => { - const triggerContainer = containerElement as HTMLElement & { - __containerData?: SearchContainerData; - }; + const allTriggerContainers = this.container.querySelectorAll( + '.trigger-file-container', + ); + let anyVisible = false; + allTriggerContainers.forEach((triggerContainer) => { let containerMatchesTitle = false; - const title = triggerContainer.__containerData?.title; - if (title !== undefined && title !== null) { - const titleText = title.replace(/<[^>]*>/g, ''); - if (this.multiPartMatch(titleText, searchTerm)) - containerMatchesTitle = true; - } + // Use pre-calculated __searchText + if ( + triggerContainer.__searchText !== undefined && + this.matchParts(triggerContainer.__searchText, searchParts) + ) + containerMatchesTitle = true; if (containerMatchesTitle) { this.setContainerVisible(triggerContainer, true); anyVisible = true; } else { - const triggersInContainer = triggerContainer.querySelectorAll('.trigger'); + const triggersInContainer = triggerContainer.querySelectorAll( + '.trigger', + ); let hasVisibleTrigger = false; - triggersInContainer.forEach((triggerDiv) => { - const triggerElement = triggerDiv as HTMLElement; - const triggerData = (triggerElement as HTMLElement & { - __triggerData?: SearchTriggerData; - }).__triggerData; + triggersInContainer.forEach((triggerElement) => { + const triggerData = triggerElement.__triggerData; if (triggerData === undefined) { this.setTriggerVisible(triggerElement, true); @@ -113,41 +120,48 @@ export class ConfigSearch { return; } - const shouldShow = this.checkTriggerMatch(triggerData, searchTerm); + const shouldShow = this.checkTriggerMatch(triggerElement, searchParts); this.setTriggerVisible(triggerElement, shouldShow); if (shouldShow) hasVisibleTrigger = true; }); - this.setContainerVisible(triggerContainer, hasVisibleTrigger); + this.setContainerVisible(triggerContainer, hasVisibleTrigger, false); if (hasVisibleTrigger) anyVisible = true; } + + if (triggerContainer.style.display !== 'none') { + const expansion = triggerContainer.closest('.trigger-expansion-container'); + if (expansion instanceof HTMLElement) + visibleExpansionContainers.add(expansion); + } }); - this.updateExpansionVisibility(true); + this.updateExpansionVisibility(true, visibleExpansionContainers); this.noMatchesMessage.style.display = anyVisible ? 'none' : 'block'; } - private checkTriggerMatch(data: SearchTriggerData, term: string): boolean { - return data.id !== undefined && this.multiPartMatch(data.id, term); + 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 as HTMLElement, true)); + const allTriggerDivs = this.container.querySelectorAll('.trigger'); + allTriggerDivs.forEach((triggerDiv) => this.setTriggerVisible(triggerDiv, true)); - const allContainers = this.container.querySelectorAll( - '.trigger-file-container', - ); - allContainers.forEach((cont) => { - const containerElement = cont as HTMLElement; + const allContainers = this.container.querySelectorAll('.trigger-file-container'); + allContainers.forEach((containerElement) => { containerElement.style.display = ''; containerElement.classList.add('collapsed'); }); - this.updateExpansionVisibility(false, true); + this.updateExpansionVisibility(false, null, true); this.noMatchesMessage.style.display = 'none'; } @@ -155,26 +169,41 @@ export class ConfigSearch { const display = visible ? '' : 'none'; triggerElement.style.display = display; const nextSibling = triggerElement.nextElementSibling; - if (nextSibling !== null && nextSibling.classList.contains('trigger-details')) - (nextSibling as HTMLElement).style.display = display; + if (nextSibling instanceof HTMLElement && nextSibling.classList.contains('trigger-details')) + nextSibling.style.display = display; } - private setContainerVisible(container: HTMLElement, visible: boolean): void { + private setContainerVisible( + container: HTMLElement, + visible: boolean, + updateChildren: boolean = true, + ): void { container.style.display = visible ? '' : 'none'; - if (visible) { - const triggers = container.querySelectorAll('.trigger'); - triggers.forEach((t) => this.setTriggerVisible(t as HTMLElement, visible)); + if (visible && updateChildren) { + const triggers = container.querySelectorAll('.trigger'); + triggers.forEach((t) => this.setTriggerVisible(t, visible)); } } - private updateExpansionVisibility(searching: boolean, forceCollapse?: boolean): void { - const allExpansionContainers = this.container.querySelectorAll('.trigger-expansion-container'); - allExpansionContainers.forEach((expansionElement) => { - const expansionContainer = expansionElement as HTMLElement; - const visibleFileContainers = expansionContainer.querySelectorAll( - '.trigger-file-container:not([style*="display: none"])', - ); - const hasVisible = visibleFileContainers.length > 0; + 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; + } + expansionContainer.style.display = hasVisible ? '' : 'none'; if (searching && hasVisible) @@ -185,10 +214,16 @@ export class ConfigSearch { } public static setContainerData(element: HTMLElement, data: SearchContainerData): void { - (element as HTMLElement & { __containerData?: SearchContainerData }).__containerData = data; + 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 { - (element as HTMLElement & { __triggerData?: SearchTriggerData }).__triggerData = data; + const el = element as SearchTriggerElement; + el.__triggerData = data; + if (data.id !== undefined && data.id !== null) + el.__searchText = data.id.toLowerCase(); } } From 5ec644a92de40d608b59c48f5d1c50889a643dc0 Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Tue, 30 Dec 2025 13:43:53 +0800 Subject: [PATCH 04/15] ui/config: optimize search and add show hidden triggers button --- ui/config/config.ts | 11 ++++++++++ ui/config/config_search.ts | 43 +++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/ui/config/config.ts b/ui/config/config.ts index 17a6d781298..6945fad492e 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -101,6 +101,17 @@ export const kNoSearchMatches = { ko: '일치하는 항목이 없습니다.', }; +// Text shown when hidden triggers are available in a search. +export const kShowHiddenTriggers = { + en: 'Show ${num} other triggers for this zone', + de: 'Zeige ${num} andere Trigger für diesen Bereich', + fr: 'Afficher ${num} autres triggers pour cette zone', + ja: 'このゾーンの他の ${num} 個のトリガーを表示', + cn: '显示此区域的其他 ${num} 个触发器', + tc: '顯示此區域的其他 ${num} 個觸發器', + ko: '이 구역의 다른 트리거 ${num}개 표시', +}; + // 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 index 8ffcf4a7098..c4d3975688d 100644 --- a/ui/config/config_search.ts +++ b/ui/config/config_search.ts @@ -1,4 +1,9 @@ -import { CactbotConfigurator, kNoSearchMatches, kTriggerSearchPlaceholder } from './config'; +import { + CactbotConfigurator, + kNoSearchMatches, + kShowHiddenTriggers, + kTriggerSearchPlaceholder, +} from './config'; export interface SearchTriggerData { id?: string; @@ -104,12 +109,14 @@ export class ConfigSearch { if (containerMatchesTitle) { this.setContainerVisible(triggerContainer, true); + this.updateShowHiddenButton(triggerContainer, 0); anyVisible = true; } else { const triggersInContainer = triggerContainer.querySelectorAll( '.trigger', ); let hasVisibleTrigger = false; + let hiddenCount = 0; triggersInContainer.forEach((triggerElement) => { const triggerData = triggerElement.__triggerData; @@ -125,9 +132,13 @@ export class ConfigSearch { if (shouldShow) hasVisibleTrigger = true; + else + hiddenCount++; }); this.setContainerVisible(triggerContainer, hasVisibleTrigger, false); + this.updateShowHiddenButton(triggerContainer, hiddenCount); + if (hasVisibleTrigger) anyVisible = true; } @@ -163,6 +174,36 @@ export class ConfigSearch { 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 { From 41e1069092e42fec880d86309ed83c6b73741bf2 Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Tue, 30 Dec 2025 13:54:32 +0800 Subject: [PATCH 05/15] ui/config: further optimize search dom performance --- ui/config/config_search.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/ui/config/config_search.ts b/ui/config/config_search.ts index c4d3975688d..51778ea1dae 100644 --- a/ui/config/config_search.ts +++ b/ui/config/config_search.ts @@ -99,6 +99,7 @@ export class ConfigSearch { allTriggerContainers.forEach((triggerContainer) => { let containerMatchesTitle = false; + let containerVisible = false; // Use pre-calculated __searchText if ( @@ -111,6 +112,7 @@ export class ConfigSearch { this.setContainerVisible(triggerContainer, true); this.updateShowHiddenButton(triggerContainer, 0); anyVisible = true; + containerVisible = true; } else { const triggersInContainer = triggerContainer.querySelectorAll( '.trigger', @@ -139,11 +141,13 @@ export class ConfigSearch { this.setContainerVisible(triggerContainer, hasVisibleTrigger, false); this.updateShowHiddenButton(triggerContainer, hiddenCount); - if (hasVisibleTrigger) + if (hasVisibleTrigger) { anyVisible = true; + containerVisible = true; + } } - if (triggerContainer.style.display !== 'none') { + if (containerVisible) { const expansion = triggerContainer.closest('.trigger-expansion-container'); if (expansion instanceof HTMLElement) visibleExpansionContainers.add(expansion); @@ -208,10 +212,13 @@ export class ConfigSearch { private setTriggerVisible(triggerElement: HTMLElement, visible: boolean): void { const display = visible ? '' : 'none'; - triggerElement.style.display = display; + if (triggerElement.style.display !== display) + triggerElement.style.display = display; const nextSibling = triggerElement.nextElementSibling; - if (nextSibling instanceof HTMLElement && nextSibling.classList.contains('trigger-details')) - nextSibling.style.display = display; + if (nextSibling instanceof HTMLElement && nextSibling.classList.contains('trigger-details')) { + if (nextSibling.style.display !== display) + nextSibling.style.display = display; + } } private setContainerVisible( @@ -219,7 +226,9 @@ export class ConfigSearch { visible: boolean, updateChildren: boolean = true, ): void { - container.style.display = visible ? '' : 'none'; + 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)); @@ -245,7 +254,9 @@ export class ConfigSearch { hasVisible = visibleFileContainers.length > 0; } - expansionContainer.style.display = hasVisible ? '' : 'none'; + const display = hasVisible ? '' : 'none'; + if (expansionContainer.style.display !== display) + expansionContainer.style.display = display; if (searching && hasVisible) expansionContainer.classList.remove('collapsed'); From 31f06f3bd88daec832038da9cbf8f4f9725633a1 Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Tue, 30 Dec 2025 14:06:08 +0800 Subject: [PATCH 06/15] lint --- ui/config/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/config/config.ts b/ui/config/config.ts index b3cf902ac90..c156e073704 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -86,8 +86,8 @@ export const kTriggerSearchPlaceholder = { fr: 'Rechercher des déclencheurs...', ja: 'トリガーを検索...', cn: '搜索触发器...', - tc: '搜索觸發器...', ko: '트리거 검색...', + tc: '搜索觸發器...', }; // Text shown when no search hits were found. @@ -97,8 +97,8 @@ export const kNoSearchMatches = { fr: 'Aucun résultat trouvé.', ja: '該当する結果が見つかりませんでした。', cn: '未找到匹配项。', - tc: '未找到匹配項。', ko: '일치하는 항목이 없습니다.', + tc: '未找到匹配項。', }; // Text shown when hidden triggers are available in a search. @@ -108,8 +108,8 @@ export const kShowHiddenTriggers = { fr: 'Afficher ${num} autres triggers pour cette zone', ja: 'このゾーンの他の ${num} 個のトリガーを表示', cn: '显示此区域的其他 ${num} 个触发器', - tc: '顯示此區域的其他 ${num} 個觸發器', ko: '이 구역의 다른 트리거 ${num}개 표시', + tc: '顯示此區域的其他 ${num} 個觸發器', }; // Translating data folders to a category name. From 6b9e317152027f1d5064026cc4c3e117a09e2ff7 Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Wed, 31 Dec 2025 16:38:50 +0800 Subject: [PATCH 07/15] debounce --- ui/config/config_search.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/ui/config/config_search.ts b/ui/config/config_search.ts index 51778ea1dae..7c0bbf731ec 100644 --- a/ui/config/config_search.ts +++ b/ui/config/config_search.ts @@ -28,6 +28,7 @@ export class ConfigSearch { private searchInput: HTMLInputElement; private clearButton: HTMLElement; private noMatchesMessage: HTMLElement; + private searchTimeout?: number; constructor( private base: CactbotConfigurator, @@ -49,7 +50,7 @@ export class ConfigSearch { this.searchInput.placeholder = this.base.translate(kTriggerSearchPlaceholder); this.searchInput.oninput = () => { this.updateClearButton(); - this.performSearch(); + this.debouncedSearch(); }; searchContainer.appendChild(this.searchInput); @@ -68,11 +69,40 @@ export class ConfigSearch { } 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)) From 2499db487d0d716e29c55b8447fdaf74eca3225a Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Wed, 31 Dec 2025 16:55:25 +0800 Subject: [PATCH 08/15] combined search --- ui/config/config_search.ts | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/ui/config/config_search.ts b/ui/config/config_search.ts index 7c0bbf731ec..f81e52e5040 100644 --- a/ui/config/config_search.ts +++ b/ui/config/config_search.ts @@ -128,22 +128,28 @@ export class ConfigSearch { let anyVisible = false; allTriggerContainers.forEach((triggerContainer) => { - let containerMatchesTitle = false; let containerVisible = false; - // Use pre-calculated __searchText - if ( - triggerContainer.__searchText !== undefined && - this.matchParts(triggerContainer.__searchText, searchParts) - ) - containerMatchesTitle = true; + // 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); + } + } - if (containerMatchesTitle) { + // 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', ); @@ -154,12 +160,16 @@ export class ConfigSearch { const triggerData = triggerElement.__triggerData; if (triggerData === undefined) { - this.setTriggerVisible(triggerElement, true); - hasVisibleTrigger = true; + // 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, searchParts); + const shouldShow = this.checkTriggerMatch(triggerElement, remainingParts); this.setTriggerVisible(triggerElement, shouldShow); if (shouldShow) From 0eda585f57d7b6f0d51b245d459740cca19cc73f Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Wed, 14 Jan 2026 16:00:24 +0800 Subject: [PATCH 09/15] Update config.ts --- ui/config/config.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/ui/config/config.ts b/ui/config/config.ts index c156e073704..a292d71f7ca 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -81,35 +81,35 @@ const kDirectoryDefaultText = { // Text in the trigger search placeholder. export const kTriggerSearchPlaceholder = { - en: 'Search triggers...', - de: 'Trigger suchen...', - fr: 'Rechercher des déclencheurs...', - ja: 'トリガーを検索...', + 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: '搜索觸發器...', + ko: '트리거 검색...', // TODO: verify AI translation + tc: '搜索觸發器...', // TODO: verify AI translation }; // Text shown when no search hits were found. export const kNoSearchMatches = { - en: 'No matches found.', - de: 'Keine Treffer gefunden.', - fr: 'Aucun résultat trouvé.', - ja: '該当する結果が見つかりませんでした。', + 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: '未找到匹配項。', + ko: '일치하는 항목이 없습니다.', // TODO: verify AI translation + 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', - de: 'Zeige ${num} andere Trigger für diesen Bereich', - fr: 'Afficher ${num} autres triggers pour cette zone', - ja: 'このゾーンの他の ${num} 個のトリガーを表示', + 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} 個觸發器', + ko: '이 구역의 다른 트리거 ${num}개 표시', // TODO: verify AI translation + tc: '顯示此區域的其他 ${num} 個觸發器', // TODO: verify AI translation }; // Translating data folders to a category name. From 86b3fbbd9ea64426fdf72c3d9abb1a386c77772c Mon Sep 17 00:00:00 2001 From: Souma <33572696+Souma-Sumire@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:59:06 +0800 Subject: [PATCH 10/15] Update ui/config/config.ts Co-authored-by: Lee Jaehyuk --- ui/config/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/config/config.ts b/ui/config/config.ts index a292d71f7ca..08687ae167f 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -108,7 +108,7 @@ export const kShowHiddenTriggers = { fr: 'Afficher ${num} autres triggers pour cette zone', // TODO: verify AI translation ja: 'このゾーンの他の ${num} 個のトリガーを表示', // TODO: verify AI translation cn: '显示此区域的其他 ${num} 个触发器', - ko: '이 구역의 다른 트리거 ${num}개 표시', // TODO: verify AI translation + ko: '이 컨텐츠의 다른 트리거 ${num}개 표시하기', tc: '顯示此區域的其他 ${num} 個觸發器', // TODO: verify AI translation }; From 2da133acc58138045405fe6b4b15ac7847f34bfa Mon Sep 17 00:00:00 2001 From: Souma <33572696+Souma-Sumire@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:59:14 +0800 Subject: [PATCH 11/15] Update ui/config/config.ts Co-authored-by: Lee Jaehyuk --- ui/config/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/config/config.ts b/ui/config/config.ts index 08687ae167f..caa5bb3b091 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -97,7 +97,7 @@ export const kNoSearchMatches = { fr: 'Aucun résultat trouvé.', // TODO: verify AI translation ja: '該当する結果が見つかりませんでした。', // TODO: verify AI translation cn: '未找到匹配项。', - ko: '일치하는 항목이 없습니다.', // TODO: verify AI translation + ko: '일치하는 항목이 없습니다.', tc: '未找到匹配項。', // TODO: verify AI translation }; From 3c6b729e8877de5cb80e5b78133082f6a084aead Mon Sep 17 00:00:00 2001 From: Souma <33572696+Souma-Sumire@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:59:22 +0800 Subject: [PATCH 12/15] Update ui/config/config.ts Co-authored-by: Lee Jaehyuk --- ui/config/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/config/config.ts b/ui/config/config.ts index caa5bb3b091..e26bd250bc5 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -86,7 +86,7 @@ export const kTriggerSearchPlaceholder = { fr: 'Rechercher des déclencheurs...', // TODO: verify AI translation ja: 'トリガーを検索...', // TODO: verify AI translation cn: '搜索触发器...', - ko: '트리거 검색...', // TODO: verify AI translation + ko: '트리거 검색...', tc: '搜索觸發器...', // TODO: verify AI translation }; From bd26cf21460c73d43add5271e0b59ed65f90cbcf Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Thu, 29 Jan 2026 02:43:20 +0800 Subject: [PATCH 13/15] reusable search engine --- resources/text_search_engine.ts | 128 ++++++++++++++++++++++ ui/config/config.ts | 13 +-- ui/config/config_loader.ts | 14 +++ ui/config/config_search.ts | 135 ++++++++---------------- ui/raidboss/emulator/translations.ts | 4 + ui/raidboss/emulator/ui/EncounterTab.ts | 60 ++++++++++- ui/raidboss/raidemulator.html | 1 + ui/raidboss/raidemulator.ts | 5 +- util/coverage/coverage.ts | 34 +++--- webpack/constants.ts | 2 +- 10 files changed, 272 insertions(+), 124 deletions(-) create mode 100644 resources/text_search_engine.ts create mode 100644 ui/config/config_loader.ts diff --git a/resources/text_search_engine.ts b/resources/text_search_engine.ts new file mode 100644 index 00000000000..6b9fb09f366 --- /dev/null +++ b/resources/text_search_engine.ts @@ -0,0 +1,128 @@ +export interface SearchItem { + searchText?: string; +} + +export interface SearchContainer extends SearchItem { + items: T[]; +} + +export interface SearchResult { + containerMatches: boolean; + matchedItems: Set; + queryParts: string[]; +} + +export class TextSearchEngine { + public calculateDebounceTime(length: number): number { + if (length === 0) + return 0; + return Math.max(0, 100 - (length - 1) * 25); + } + + public parseQuery(query: string): string[] { + return query.toLowerCase().split(/\s+/).filter((p) => p !== ''); + } + + public matchParts(text: string, parts: string[]): boolean { + for (const part of parts) { + if (!text.includes(part)) + return false; + } + return true; + } + + public search( + query: string, + container: SearchContainer, + ): SearchResult { + const searchParts = this.parseQuery(query); + return this.searchParts(searchParts, container); + } + + public searchParts( + searchParts: string[], + container: SearchContainer, + ): SearchResult { + const result: SearchResult = { + containerMatches: false, + matchedItems: new Set(), + queryParts: searchParts, + }; + + if (searchParts.length === 0) { + result.containerMatches = true; + + return result; + } + + const containerMatchedParts = new Set(); + if (container.searchText !== undefined) { + for (const part of searchParts) { + if (container.searchText.includes(part)) + containerMatchedParts.add(part); + } + } + + const remainingParts = searchParts.filter((p) => !containerMatchedParts.has(p)); + + if (remainingParts.length === 0) { + result.containerMatches = true; + + return result; + } + + for (const item of container.items) { + if (item.searchText === undefined) { + if (containerMatchedParts.size > 0) { + result.matchedItems.add(item); + } + continue; + } + + if (this.matchParts(item.searchText, remainingParts)) { + result.matchedItems.add(item); + } + } + + return result; + } +} + +export const bindSearchInput = ( + input: HTMLInputElement, + engine: TextSearchEngine, + onSearch: () => void, + onInput?: () => void, +): void => { + let isComposing = false; + let searchTimeout: number | undefined; + + const triggerSearch = () => { + if (searchTimeout !== undefined) + window.clearTimeout(searchTimeout); + const length = input.value.trim().length; + const debounceTime = engine.calculateDebounceTime(length); + if (debounceTime > 0) { + searchTimeout = window.setTimeout(onSearch, debounceTime); + } else { + onSearch(); + } + }; + // tracking IME composition + input.addEventListener('compositionstart', () => { + isComposing = true; + }); + input.addEventListener('compositionend', () => { + isComposing = false; + if (onInput !== undefined) + onInput(); + triggerSearch(); + }); + input.addEventListener('input', () => { + if (isComposing) + return; + if (onInput !== undefined) + onInput(); + triggerSearch(); + }); +}; diff --git a/ui/config/config.ts b/ui/config/config.ts index e26bd250bc5..3654337a717 100644 --- a/ui/config/config.ts +++ b/ui/config/config.ts @@ -22,7 +22,7 @@ import { LooseTriggerSet, } from '../../types/trigger'; -import defaultOptions, { ConfigOptions } from './config_options'; +import { ConfigOptions } from './config_options'; // Load other config files import './general_config'; @@ -32,9 +32,6 @@ import '../oopsyraidsy/oopsyraidsy_config'; import '../radar/radar_config'; import '../raidboss/raidboss_config'; -import '../../resources/defaults.css'; -import './config.css'; - // Text in the butter bar, to prompt the user to reload after a config change. const kReloadText = { en: 'To apply configuration changes, reload cactbot overlays.', @@ -1214,11 +1211,3 @@ export class CactbotConfigurator { return sortedMap; } } - -UserConfig.getUserConfigLocation('config', defaultOptions, () => { - const options = { ...defaultOptions }; - new CactbotConfigurator( - options, - UserConfig.savedConfig, - ); -}); diff --git a/ui/config/config_loader.ts b/ui/config/config_loader.ts new file mode 100644 index 00000000000..26b43ce2a26 --- /dev/null +++ b/ui/config/config_loader.ts @@ -0,0 +1,14 @@ +import '../../resources/defaults.css'; +import './config.css'; +import UserConfig from '../../resources/user_config'; + +import { CactbotConfigurator } from './config'; +import defaultOptions from './config_options'; + +UserConfig.getUserConfigLocation('config', defaultOptions, () => { + const options = { ...defaultOptions }; + new CactbotConfigurator( + options, + UserConfig.savedConfig, + ); +}); diff --git a/ui/config/config_search.ts b/ui/config/config_search.ts index f81e52e5040..c5e6f79ffe9 100644 --- a/ui/config/config_search.ts +++ b/ui/config/config_search.ts @@ -1,3 +1,11 @@ +import { + bindSearchInput, + SearchContainer, + SearchItem, + SearchResult, + TextSearchEngine, +} from '../../resources/text_search_engine'; + import { CactbotConfigurator, kNoSearchMatches, @@ -14,21 +22,19 @@ export interface SearchContainerData { } // Custom element types to store data and avoid repetitive casts -interface SearchTriggerElement extends HTMLElement { +interface SearchTriggerElement extends HTMLElement, SearchItem { __triggerData?: SearchTriggerData; - __searchText?: string; } -interface SearchContainerElement extends HTMLElement { +interface SearchContainerElement extends HTMLElement, SearchItem { __containerData?: SearchContainerData; - __searchText?: string; } export class ConfigSearch { private searchInput: HTMLInputElement; private clearButton: HTMLElement; private noMatchesMessage: HTMLElement; - private searchTimeout?: number; + private engine: TextSearchEngine; constructor( private base: CactbotConfigurator, @@ -37,6 +43,7 @@ export class ConfigSearch { this.searchInput = document.createElement('input'); this.clearButton = document.createElement('div'); this.noMatchesMessage = document.createElement('div'); + this.engine = new TextSearchEngine(); this.buildUI(); } @@ -48,10 +55,18 @@ export class ConfigSearch { 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(); - }; + + bindSearchInput( + this.searchInput, + this.engine, + () => { + this.performSearch(); + }, + () => { + this.updateClearButton(); + }, + ); + searchContainer.appendChild(this.searchInput); this.clearButton.classList.add('trigger-search-clear'); @@ -69,48 +84,11 @@ export class ConfigSearch { } 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(); @@ -119,7 +97,7 @@ export class ConfigSearch { return; } - const searchParts = searchTerm.toLowerCase().split(/\s+/).filter((p) => p !== ''); + const searchParts = this.engine.parseQuery(searchTerm); const visibleExpansionContainers = new Set(); const allTriggerContainers = this.container.querySelectorAll( @@ -128,53 +106,36 @@ export class ConfigSearch { let anyVisible = false; allTriggerContainers.forEach((triggerContainer) => { - let containerVisible = false; + const triggersInContainer = triggerContainer.querySelectorAll( + '.trigger', + ); + const searchContainerInfo: SearchContainer = { + searchText: triggerContainer.searchText, + items: Array.from(triggersInContainer), + }; - // 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); - } - } + const result: SearchResult = this.engine.searchParts( + searchParts, + searchContainerInfo, + ); - // Remaining parts that need to match triggers - const remainingParts = searchParts.filter((p) => !containerMatchedParts.has(p)); + let containerVisible = false; + let hasVisibleTrigger = false; + let hiddenCount = 0; - // If all parts matched the container, show all triggers - if (remainingParts.length === 0) { + if (result.containerMatches) { 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); + const shouldShow = result.matchedItems.has(triggerElement); this.setTriggerVisible(triggerElement, shouldShow); if (shouldShow) hasVisibleTrigger = true; - else + else if (triggerElement.__triggerData !== undefined) hiddenCount++; }); @@ -198,14 +159,6 @@ export class ConfigSearch { 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)); @@ -309,13 +262,13 @@ export class ConfigSearch { const el = element as SearchContainerElement; el.__containerData = data; if (data.title !== undefined && data.title !== null) - el.__searchText = data.title.replace(/<[^>]*>/g, '').toLowerCase(); + 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(); + el.searchText = data.id.toLowerCase(); } } diff --git a/ui/raidboss/emulator/translations.ts b/ui/raidboss/emulator/translations.ts index 9cdf2de63d2..d04e2519e61 100644 --- a/ui/raidboss/emulator/translations.ts +++ b/ui/raidboss/emulator/translations.ts @@ -72,6 +72,10 @@ const emulatorButtons: Translation = { cn: '清除数据库', tc: '清除資料庫', }, + '.encounterSearchPlaceholder': { + en: 'Search encounters', + cn: '搜索战斗', + }, } as const; const emulatorTitle: Translation = { diff --git a/ui/raidboss/emulator/ui/EncounterTab.ts b/ui/raidboss/emulator/ui/EncounterTab.ts index 207e5ae6ed5..cb43cff91ff 100644 --- a/ui/raidboss/emulator/ui/EncounterTab.ts +++ b/ui/raidboss/emulator/ui/EncounterTab.ts @@ -1,5 +1,6 @@ import DTFuncs from '../../../../resources/datetime'; import { UnreachableCode } from '../../../../resources/not_reached'; +import { bindSearchInput, TextSearchEngine } from '../../../../resources/text_search_engine'; import Persistor from '../data/Persistor'; import PersistorEncounter from '../data/PersistorEncounter'; import { getTemplateChild, querySelectorAllSafe, querySelectorSafe } from '../EmulatorCommon'; @@ -32,6 +33,10 @@ export default class EncounterTab extends EventBus { currentZone?: string; currentDate?: string; currentEncounter?: number; + engine: TextSearchEngine = new TextSearchEngine(); + searchInput: HTMLInputElement; + private filteredEncounters?: EncounterMap; + constructor(private persistor: Persistor) { super(); @@ -46,10 +51,16 @@ export default class EncounterTab extends EventBus { 'template.encounterTabEncounterRow', ); this.$encounterInfoTemplate = getTemplateChild(document, 'template.encounter-info'); + + this.searchInput = querySelectorSafe(document, '#encounter-search-input') as HTMLInputElement; + bindSearchInput(this.searchInput, this.engine, () => { + this.refreshUI(); + }); } refresh(): void { this.encounters = {}; + this.filteredEncounters = undefined; void this.persistor.encounterSummaries.toArray().then((encounters: PersistorEncounter[]) => { for (const enc of encounters) { const zone = enc.zoneName; @@ -71,7 +82,46 @@ export default class EncounterTab extends EventBus { }); } + get displayEncounters(): EncounterMap { + return this.filteredEncounters ?? this.encounters; + } + + filterEncounters(): void { + const query = this.searchInput.value.trim().toLowerCase(); + const parts = this.engine.parseQuery(query); + if (parts.length === 0) { + this.filteredEncounters = undefined; + return; + } + + const ret: EncounterMap = {}; + for (const [zone, zoneMap] of Object.entries(this.encounters)) { + const zoneMatches = this.engine.matchParts(zone.toLowerCase(), parts); + const filteredZoneMap: ZoneMap = {}; + let hasDates = false; + + for (const [date, dates] of Object.entries(zoneMap)) { + const filteredDates = dates.filter((d) => { + if (zoneMatches) + return true; + return this.engine.matchParts(d.name.toLowerCase(), parts); + }); + + if (filteredDates.length > 0) { + filteredZoneMap[date] = filteredDates; + hasDates = true; + } + } + + if (hasDates) { + ret[zone] = filteredZoneMap; + } + } + this.filteredEncounters = ret; + } + refreshUI(): void { + this.filterEncounters(); this.refreshZones(); this.refreshDates(); this.refreshEncounters(); @@ -83,7 +133,7 @@ export default class EncounterTab extends EventBus { let clear = true; - const zones = new Set(Object.keys(this.encounters)); + const zones = new Set(Object.keys(this.displayEncounters)); for (const zone of [...zones].sort()) { const $row = this.$encounterTabRowTemplate.cloneNode(true); @@ -121,7 +171,7 @@ export default class EncounterTab extends EventBus { let clear = true; if (this.currentZone !== undefined) { - const zoneMap = this.encounters[this.currentZone]; + const zoneMap = this.displayEncounters[this.currentZone]; if (!zoneMap) return; const dates = new Set(Object.keys(zoneMap)); @@ -164,7 +214,7 @@ export default class EncounterTab extends EventBus { if (this.currentZone === undefined || this.currentDate === undefined) return; - const zoneMap = this.encounters[this.currentZone]; + const zoneMap = this.displayEncounters[this.currentZone]; if (!zoneMap) return; @@ -213,7 +263,9 @@ export default class EncounterTab extends EventBus { refreshInfo(): void { this.$infoColumn.innerHTML = ''; - const zoneMap = this.currentZone !== undefined ? this.encounters[this.currentZone] : undefined; + const zoneMap = this.currentZone !== undefined + ? this.displayEncounters[this.currentZone] + : undefined; if (!zoneMap) return; diff --git a/ui/raidboss/raidemulator.html b/ui/raidboss/raidemulator.html index 06369206191..c6f3e767430 100644 --- a/ui/raidboss/raidemulator.html +++ b/ui/raidboss/raidemulator.html @@ -186,6 +186,7 @@ +
{ for (const [key, value] of Object.entries(emulatorTranslations)) { querySelectorAllSafe(document, `.translate${key}`).forEach( (elem) => { - elem.innerHTML = translate(lang, value); + if (elem instanceof HTMLInputElement) + elem.placeholder = translate(lang, value); + else + elem.innerHTML = translate(lang, value); }, ); } diff --git a/util/coverage/coverage.ts b/util/coverage/coverage.ts index ae2bed5d23c..af03e8f6115 100644 --- a/util/coverage/coverage.ts +++ b/util/coverage/coverage.ts @@ -2,6 +2,7 @@ import contentList from '../../resources/content_list'; import ContentType from '../../resources/content_type'; import { isLang, Lang, langMap, langToLocale, languages } from '../../resources/languages'; import { UnreachableCode } from '../../resources/not_reached'; +import { bindSearchInput, TextSearchEngine } from '../../resources/text_search_engine'; import ZoneInfo from '../../resources/zone_info'; import { LocaleObject, LocaleText } from '../../types/trigger'; import { kDirectoryToCategory, kPrefixToCategory } from '../../ui/config/config'; @@ -1103,6 +1104,9 @@ aria-expanded="${ const buildZoneTable = (container: HTMLElement, lang: Lang, coverage: Coverage) => { buildZoneTableHeader(container, lang); + const engine = new TextSearchEngine(); + const rowSearchText = new Map(); + const checkedZoneIds: number[] = []; const tbody = document.createElement('tbody'); @@ -1226,19 +1230,22 @@ const buildZoneTable = (container: HTMLElement, lang: Lang, coverage: Coverage) container.appendChild(tbody); + container.querySelectorAll('.zone-table-content-row').forEach((row) => { + // `innerText` is vastly slower to scan all rows here, ~750ms with `innerText` + // vs ~45ms with `textContent` + if (row instanceof HTMLElement && (row.textContent !== null)) { + rowSearchText.set(row, row.textContent.toLowerCase()); + } + }); + const searchInput = document.getElementById('zone-table-filter'); if (searchInput === null || !(searchInput instanceof HTMLInputElement)) throw new UnreachableCode(); - let lastFilterValue = ''; - - const filter = () => { - const lcValue = searchInput.value.toLowerCase(); - if (lastFilterValue === lcValue) - return; - - lastFilterValue = lcValue; + const performFilter = () => { + const lcValue = searchInput.value.trim().toLowerCase(); + const parts = engine.parseQuery(lcValue); // Hide rows that don't match our filter container.querySelectorAll('.zone-table-content-row').forEach((row) => { @@ -1252,9 +1259,8 @@ const buildZoneTable = (container: HTMLElement, lang: Lang, coverage: Coverage) if (dataRow === null || !(dataRow instanceof HTMLElement)) return; - // `innerText` is vastly slower to scan all rows here, ~750ms with `innerText` - // vs ~45ms with `textContent` - const display = row.textContent?.toLowerCase().includes(lcValue); + const searchText = rowSearchText.get(row) ?? ''; + const display = engine.matchParts(searchText, parts); if (display) { row.classList.remove('d-none'); @@ -1301,12 +1307,10 @@ const buildZoneTable = (container: HTMLElement, lang: Lang, coverage: Coverage) }); }; - for (const ev of ['blur', 'change', 'keydown', 'keypress', 'keyup']) { - searchInput.addEventListener(ev, filter); - } + bindSearchInput(searchInput, engine, performFilter); // Fire an initial filter to hide unused category header rows - filter(); + performFilter(); }; const buildThemeSelect = (container: HTMLElement, lang: Lang) => { diff --git a/webpack/constants.ts b/webpack/constants.ts index 9d58e257f8c..1d8d18b6927 100644 --- a/webpack/constants.ts +++ b/webpack/constants.ts @@ -1,5 +1,5 @@ export const cactbotModules = { - config: 'ui/config/config', + config: 'ui/config/config_loader', coverage: 'util/coverage/coverage', rdmty: 'ui/dps/rdmty/dps', xephero: 'ui/dps/xephero/xephero', From 933119a4aa66f4b80fa587e1dd979e0e0426322c Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Thu, 29 Jan 2026 03:10:19 +0800 Subject: [PATCH 14/15] handle spacer row --- util/coverage/coverage.ts | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/util/coverage/coverage.ts b/util/coverage/coverage.ts index af03e8f6115..a0a19eb68c6 100644 --- a/util/coverage/coverage.ts +++ b/util/coverage/coverage.ts @@ -1305,6 +1305,55 @@ const buildZoneTable = (container: HTMLElement, lang: Lang, coverage: Coverage) row.classList.add('d-none'); } }); + + container.querySelectorAll('.spacer-row').forEach((row) => { + if (!(row instanceof HTMLElement)) + return; + + let hasVisibleAbove = false; + let prevSibling = row.previousSibling; + while (prevSibling !== null) { + if (!(prevSibling instanceof HTMLElement)) { + prevSibling = prevSibling.previousSibling; + continue; + } + + if (prevSibling.classList.contains('spacer-row')) + break; + if ( + prevSibling.classList.contains('zone-table-content-row') && + !prevSibling.classList.contains('d-none') + ) { + hasVisibleAbove = true; + break; + } + prevSibling = prevSibling.previousSibling; + } + + let hasVisibleBelow = false; + let nextSibling = row.nextSibling; + while (nextSibling !== null) { + if (!(nextSibling instanceof HTMLElement)) { + nextSibling = nextSibling.nextSibling; + continue; + } + + if ( + nextSibling.classList.contains('zone-table-content-row') && + !nextSibling.classList.contains('d-none') + ) { + hasVisibleBelow = true; + break; + } + nextSibling = nextSibling.nextSibling; + } + + if (hasVisibleAbove && hasVisibleBelow) { + row.classList.remove('d-none'); + } else { + row.classList.add('d-none'); + } + }); }; bindSearchInput(searchInput, engine, performFilter); From 371307ad1890cdd4419f1229e0305f535140158f Mon Sep 17 00:00:00 2001 From: Souma-Sumire <553469159@qq.com> Date: Thu, 29 Jan 2026 03:16:24 +0800 Subject: [PATCH 15/15] trans --- util/coverage/coverage.html | 2 +- util/coverage/coverage.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/util/coverage/coverage.html b/util/coverage/coverage.html index 55d70540db6..bc4e3cab2c4 100644 --- a/util/coverage/coverage.html +++ b/util/coverage/coverage.html @@ -47,7 +47,7 @@
-
Zone Filter
+
Zone Filter
diff --git a/util/coverage/coverage.ts b/util/coverage/coverage.ts index a0a19eb68c6..37b1e25ade2 100644 --- a/util/coverage/coverage.ts +++ b/util/coverage/coverage.ts @@ -624,6 +624,14 @@ const miscStrings = { ko: '이 항목으로의 링크', tc: '此條目連結', }, + zoneFilter: { + en: 'Zone Filter', + cn: '副本筛选', + }, + searchPlaceholder: { + en: 'Search...', + cn: '搜索...', + }, } as const; const translationGridHeaders = { @@ -1454,6 +1462,14 @@ document.addEventListener('DOMContentLoaded', () => { throw new UnreachableCode(); buildTranslationTable(translationGrid, lang, translationTotals); + const zoneFilterLabel = document.getElementById('zone-filter-label'); + if (zoneFilterLabel) + zoneFilterLabel.innerText = translate(miscStrings.zoneFilter, lang); + + const zoneTableFilter = document.getElementById('zone-table-filter'); + if (zoneTableFilter instanceof HTMLInputElement) + zoneTableFilter.placeholder = translate(miscStrings.searchPlaceholder, lang); + const zoneGrid = document.getElementById('zone-table'); if (!zoneGrid) throw new UnreachableCode();