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