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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 };