/* * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { aria, Event, InitModelOf, InputFieldKeyStrokeContext, keys, KeyStrokeContext, keyStrokeModifier, Outline, Page, PropertyChangeEvent, SearchOutlineDownKeyStroke, SearchOutlineEventMap, SearchOutlineLayout, SearchOutlineModel, SearchOutlineUpKeyStroke, SearchState, TreeAllChildNodesDeletedEvent, TreeNodesDeletedEvent, TreeNodesInsertedEvent } from '../../index'; import $ from 'jquery'; export class SearchOutline extends Outline implements SearchOutlineModel { declare model: SearchOutlineModel; declare eventMap: SearchOutlineEventMap; declare self: SearchOutline; searchQuery: string; maxSearchFieldLength = 60; minSearchTokenLength = 2; searchFieldKeyStrokeContext: KeyStrokeContext; searchStatus: string; $searchPanel: JQuery; $clearIcon: JQuery; $searchStatus: JQuery; $queryField: JQuery; protected _searchQueryValid = true; protected _searchStates = new Set(); protected _searchStateChangeHandler = this._onSearchStateChanged.bind(this); protected _searchStateDestroyHandler = this._onSearchStateDestroy.bind(this); protected override _init(model: InitModelOf) { super._init(model); this.updateSearchStates(); this.on('nodesInserted nodesDeleted allChildNodesDeleted', event => { if (!(event as unknown as TreeNodesInsertedEvent | TreeNodesDeletedEvent | TreeAllChildNodesDeletedEvent).parentNode) { this.updateSearchStates(); } }); this._setMaxSearchFieldLength(this.maxSearchFieldLength); this._setMinSearchTokenLength(this.minSearchTokenLength); this.search(); } protected override _initKeyStrokeContext() { super._initKeyStrokeContext(); this.searchFieldKeyStrokeContext = this._createKeyStrokeContextForSearchField(); this.searchFieldKeyStrokeContext.registerKeyStroke(new SearchOutlineDownKeyStroke(this)); this.searchFieldKeyStrokeContext.registerKeyStroke(new SearchOutlineUpKeyStroke(this)); } protected _createKeyStrokeContextForSearchField(): KeyStrokeContext { let keyStrokeContext = new InputFieldKeyStrokeContext(); keyStrokeContext.$scopeTarget = () => this.$searchPanel; keyStrokeContext.$bindTarget = () => this.$queryField; keyStrokeContext.registerStopPropagationKeys(keyStrokeModifier.NONE, [keys.ENTER, keys.BACKSPACE]); return keyStrokeContext; } protected override _render() { super._render(); // Override layout this.htmlComp.setLayout(new SearchOutlineLayout(this)); this.$container.addClass('search-outline'); this.$searchPanel = this.$container.prependDiv('search-outline-panel'); this.$queryField = this.$searchPanel.appendElement('', 'search-outline-field') .on('input', this._createOnQueryFieldInputFunction().bind(this)) .on('keypress', this._onQueryFieldKeyPress.bind(this)) as JQuery; this.$clearIcon = this.$searchPanel.appendSpan('clear-icon unfocusable action text-field-icon') .on('mousedown', this._onClearIconMouseDown.bind(this)); this.$searchStatus = this.$searchPanel.appendDiv('search-outline-status') .on('mousedown', this._onTitleMouseDown.bind(this)); this.session.keyStrokeManager.installKeyStrokeContext(this.searchFieldKeyStrokeContext); } protected override _remove() { this.session.keyStrokeManager.uninstallKeyStrokeContext(this.searchFieldKeyStrokeContext); this.$searchPanel.remove(); super._remove(); } protected override _renderProperties() { super._renderProperties(); this._renderSearchQuery(); this._renderSearchStatus(); } protected override _renderTitle() { super._renderTitle(); // Move before search panel if (this.titleVisible) { this.$title.insertBefore(this.$searchPanel); aria.linkElementWithLabel(this.$queryField, this.$title); } } setSearchQuery(searchQuery: string) { this.setProperty('searchQuery', searchQuery); } protected _setSearchQuery(searchQuery: string) { this._setProperty('searchQuery', searchQuery); this.search(); } protected _renderSearchQuery() { this.$queryField.val(this.searchQuery); this._updateHasText(); } protected _updateHasText() { this.$queryField.toggleClass('has-text', !!this.$queryField.val()); } setSearchStatus(searchStatus: string) { this.setProperty('searchStatus', searchStatus); } protected _updateSearchStatus() { if (!this._searchQueryValid) { return; } if (this.pending) { this.setSearchStatus(this.session.text('ui.SearchInProgressFor', this.searchQuery)); return; } const resultCount = this.resultCount; this.setSearchStatus(this.session.text('ui.NumSearchResults', this.limited ? resultCount + '+' : resultCount, this.searchQuery)); } protected _renderSearchStatus() { let animate = this.rendered; if (this.searchStatus && !this.$searchStatus.isVisible()) { if (animate) { this.$searchStatus.slideDown({ duration: 200, progress: this.revalidateLayout.bind(this) }); } else { this.$searchStatus.show(); } } else if (!this.searchStatus && this.$searchStatus.isVisible()) { if (animate) { this.$searchStatus.slideUp({ duration: 200, progress: this.revalidateLayout.bind(this) }); } else { this.$searchStatus.hide(); } } this.$searchStatus.textOrNbsp(this.searchStatus); this.$searchPanel.toggleClass('has-status', !!this.searchStatus); } setMaxSearchFieldLength(maxSearchFieldLength: number) { this.setProperty('maxSearchFieldLength', maxSearchFieldLength); } protected _setMaxSearchFieldLength(maxSearchFieldLength: number) { if (maxSearchFieldLength <= 0) { maxSearchFieldLength = 200; } this._setProperty('maxSearchFieldLength', maxSearchFieldLength); } setMinSearchTokenLength(minSearchTokenLength: number) { this.setProperty('minSearchTokenLength', minSearchTokenLength); } protected _setMinSearchTokenLength(minSearchTokenLength: number) { if (minSearchTokenLength <= 0) { minSearchTokenLength = 2; } this._setProperty('minSearchTokenLength', minSearchTokenLength); } search() { this._searchStates.forEach(searchState => searchState.setPending(true)); this._validateSearchQuery(); if (!this._searchQueryValid) { this.resetSearch(); return; } this._updateSearchStatus(); this._triggerSearch(); } protected _validateSearchQuery() { if (!this.searchQuery?.length) { this.setSearchStatus(null); this._searchQueryValid = false; return; } if (this.searchQuery.length > this.maxSearchFieldLength) { this.setSearchStatus(this.session.text('ui.SearchTermTooLong')); this._searchQueryValid = false; return; } // remove wildcards and split into tokens (i.e. non-empty strings without spaces) const tokens = this.searchQuery.replaceAll(/\*/g, '').split(' ').filter(Boolean); // at least one token must have min length for (const token of tokens) { if (token.length >= this.minSearchTokenLength) { this._searchQueryValid = true; return; } } this.setSearchStatus(this.session.text('ui.SearchTermTooShort')); this._searchQueryValid = false; } protected _triggerSearch() { this.trigger('search'); } resetSearch() { this._triggerResetSearch(); } protected _triggerResetSearch() { this.trigger('resetSearch'); } protected _createOnQueryFieldInputFunction(): (event: JQuery.TriggeredEvent) => void { let debounceFunction = $.debounce(() => this.setSearchQuery(this.$queryField.val())); return event => { this._updateHasText(); // debounced update search query debounceFunction(); }; } protected _onClearIconMouseDown(event: JQuery.MouseDownEvent) { this.setSearchQuery(''); // focus field if x is pressed when the field does not have the focus this.$queryField.focus(); // stay in field when x is pressed event.preventDefault(); } protected _onQueryFieldKeyPress(event: JQuery.KeyPressEvent) { if (event.which === keys.ENTER) { this._setSearchQuery(this.$queryField.val()); } } focusQueryField() { this.validateFocus(); } /** * Focus and select content AFTER the search outline was rendered (and therefore the query field filled). */ override validateFocus() { if (!this.rendered) { return; } let elementToFocus = this.$queryField[0]; if (this.session.focusManager.requestFocus(elementToFocus)) { elementToFocus.select(); } } updateSearchStates() { const searchStates = new Set(); for (const page of this._getRelevantSearchPages()) { if (page.searchState) { searchStates.add(page.searchState); } } this.setSearchStates(searchStates); } protected _getRelevantSearchPages(): SearchPage[] { if (this.compact) { const compactRootNode = this.compactRootNode(); if (compactRootNode) { return compactRootNode.childNodes; } } return this.nodes; } setSearchStates(searchStates: Set) { searchStates ||= new Set(); const searchStatesToAdd = new Set(); for (const searchState of searchStates) { if (this._searchStates.has(searchState)) { continue; } searchStatesToAdd.add(searchState); } const searchStatesToRemove = new Set(); for (const searchState of this._searchStates) { if (searchStates.has(searchState)) { continue; } searchStatesToRemove.add(searchState); } if (!searchStatesToAdd.size && !searchStatesToRemove.size) { return; } searchStatesToAdd.forEach(searchState => { this._searchStates.add(searchState); this._installSearchStateListeners(searchState); }); searchStatesToRemove.forEach(searchState => { this._searchStates.delete(searchState); this._uninstallSearchStateListeners(searchState); }); this._updateSearchStatus(); } protected _installSearchStateListeners(searchState: SearchState) { if (!searchState) { return; } searchState.on('propertyChange:resultCount propertyChange:limited propertyChange:pending', this._searchStateChangeHandler); searchState.one('destroy', this._searchStateDestroyHandler); } protected _uninstallSearchStateListeners(searchState: SearchState) { if (!searchState) { return; } searchState.off('propertyChange:resultCount propertyChange:limited propertyChange:pending', this._searchStateChangeHandler); searchState.off('destroy', this._searchStateDestroyHandler); } protected _onSearchStateChanged(event: PropertyChangeEvent) { this._updateSearchStatus(); } protected _onSearchStateDestroy(event: Event) { const searchStates = new Set(this._searchStates); searchStates.delete(event.source); this.setSearchStates(searchStates); } get resultCount(): number { let resultCount = 0; for (const searchState of this._searchStates) { resultCount += searchState.resultCount; } return resultCount; } get limited(): boolean { return Array.from(this._searchStates).some(state => state.limited); } get pending(): boolean { return Array.from(this._searchStates).some(state => state.pending); } } export interface SearchPage extends Page { searchState?: SearchState; }