diff --git a/src/common.ts b/src/common.ts index abf9f90..c65575e 100644 --- a/src/common.ts +++ b/src/common.ts @@ -2,13 +2,8 @@ import { DocumentSymbol, Position, Range, SymbolKind } from 'vscode'; import { config } from './extension/config'; +import { SymbolKindStr } from './utils'; -type SymbolKindStr = - 'File' | 'Module' | 'Namespace' | 'Package' | 'Class' | 'Method' | - 'Property' | 'Field' | 'Constructor' | 'Enum' | 'Interface' | - 'Function' | 'Variable' | 'Constant' | 'String' | 'Number' | - 'Boolean' | 'Array' | 'Object' | 'Key' | 'Null' | 'EnumMember' | - 'Struct' | 'Event' | 'Operator' | 'TypeParameter'; /** * Node of a tree of symbols. */ diff --git a/src/utils.ts b/src/utils.ts index 0099660..ace2374 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,12 @@ -export const SymbolKindList = [ +export type SymbolKindStr = + 'File' | 'Module' | 'Namespace' | 'Package' | 'Class' | 'Method' | + 'Property' | 'Field' | 'Constructor' | 'Enum' | 'Interface' | + 'Function' | 'Variable' | 'Constant' | 'String' | 'Number' | + 'Boolean' | 'Array' | 'Object' | 'Key' | 'Null' | 'EnumMember' | + 'Struct' | 'Event' | 'Operator' | 'TypeParameter'; + +export const SymbolKindList: SymbolKindStr[] = [ 'File', 'Module', 'Namespace', 'Package', 'Class', 'Method', 'Property', 'Field', 'Constructor', 'Enum', 'Interface', 'Function', 'Variable', 'Constant', 'String', 'Number', diff --git a/src/webview/components/inputArea.ts b/src/webview/components/inputArea.ts index e557ed6..7afefe6 100644 --- a/src/webview/components/inputArea.ts +++ b/src/webview/components/inputArea.ts @@ -1,17 +1,52 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { SymbolKindList, SymbolKindStr, camelToDash } from '../../utils'; import { Mode } from '../input'; +// import { camelToDash } from '../../common'; export class InputArea extends HTMLElement { private _textarea: HTMLTextAreaElement; private _mode: Mode = Mode.Nav; + + private _filtering = false; + + private _filterEle: HTMLDivElement; + + private _filterFocusing = 0; + + private _filterList: HTMLDivElement; + + private symbolElements: HTMLLIElement[] = []; + + public filteredSymbol: SymbolKindStr | null = null; + private init() { + function symbolList() { + return SymbolKindList.map(k => { + const dashCase = camelToDash(k); + return /*html*/` +
  • + + ${dashCase} +
  • + `; + }); + } const TEMPLATE = /*html*/` +
    + +
    +
    + +
    `; this.innerHTML = TEMPLATE; @@ -23,12 +58,79 @@ export class InputArea extends HTMLElement { this.init(); this._textarea = this.querySelector('textarea')!; - this._textarea.addEventListener('input', ()=>{ + this._filterEle = this.querySelector('#symbol-filter')!; + this._filterList = this.querySelector('#symbol-list')!; + this.symbolElements = Array.from(this.querySelectorAll('.symbol-item')); + this.symbolElements[0].classList.add('focused'); + this.symbolElements.forEach(ele => { + ele.addEventListener('click', () => { + this.setFilteredSymbol(ele.dataset.symbol! as SymbolKindStr); + this._textarea.focus(); + }); + }); + + this._textarea.addEventListener('keydown', (e) => { + if (this.filtering) { + // prevent the input from being handled by the parent + e.stopPropagation(); + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.updateFocusing(1); + break; + case 'ArrowUp': + e.preventDefault(); + this.updateFocusing(-1); + break; + case 'Enter': + e.preventDefault(); + this.symbolElements[this._filterFocusing].click(); + break; + } + } + + if ((this.filtering || this.filteredSymbol) && this.searchText.length === 0 + && ['Backspace', 'Delete'].includes(e.key) + ) { + this.exitFiltering(); + } + }); + this._textarea.addEventListener('input', (e)=>{ this.adjustHeight(); + if (this.filtering) { + this.filter(this.searchText); + } + if (this.filtering || this._textarea.value[0] === '@') { + // prevent the input from being handled by the parent + e.stopPropagation(); + e.preventDefault(); + this.filtering = true; + return; + } this.updateMode(); }); } + setFilteredSymbol(symbol: SymbolKindStr) { + this.filteredSymbol = symbol; + this._filterEle.querySelector('.icon')!.className = + `icon codicon codicon-symbol-${camelToDash(this.filteredSymbol)}`; + this._filterEle.title = `Search for ${this.filteredSymbol}`; + this.filtering = false; + this.filter(''); + this._textarea.value = ''; + } + + exitFiltering() { + this.filtering = false; + this.filter(''); + this._filterEle.querySelector('.icon')!.className = + 'icon codicon codicon-pencil'; + this._textarea.value = this.mode as string; + this._filterEle.classList.toggle('active', false); + this.filteredSymbol = null; + } + /** * pass the focus to the inner textarea */ @@ -41,22 +143,9 @@ export class InputArea extends HTMLElement { */ adjustHeight() { this._textarea.style.height = '0px'; - this._textarea.style.height = this._textarea.scrollHeight + 'px'; - } - - set value(value: string) { - this._textarea.value = value; - this.adjustHeight(); - this.updateMode(); - } - - get value(): string { - return this._textarea.value; + this._textarea.style.height = `calc(4px + ${this._textarea.scrollHeight}px)`; } - get searchText(): string { - return this._textarea.value.slice(1); - } /** * Update the mode according to the first character of the input. @@ -76,6 +165,89 @@ export class InputArea extends HTMLElement { console.log(this._mode); } + + /** + * clear the input area and set the mode to the given mode + * @param mode + */ + clear(mode: Mode = Mode.Nav) { + this._textarea.value = mode as string; + this.adjustHeight(); + this.mode = mode; + } + + private filter(pattern: string) { + // fuzzy match + const reg = new RegExp(pattern.split('').join('.*')); + this.symbolElements.forEach(ele => { + const dash = camelToDash(ele.dataset.symbol!); + const hidden = !reg.test(dash); + ele.classList.toggle('hidden', hidden); + ele.dataset.hidden = hidden.toString(); + }); + this.updateFocusing(); + } + + /** + * update the index of the symbol that is being focused + * try to move the focus to the next visible symbol + * @param move the number of symbols to move, hidden symbols are ignored + * @param checkHidden whether to check if all symbols are hidden, + * avoid checking when calling recursively + */ + private updateFocusing(move = 0, sign = 0, checkHidden = true) { + const len = this.symbolElements.length; + sign = move === 0 ? sign : Math.sign(move); + if (checkHidden && this.querySelector('.symbol-item:not(.hidden)') === null) { + return; + } + this.symbolElements[this._filterFocusing].classList.remove('focused'); + if (this.symbolElements[this._filterFocusing].dataset.hidden === 'true') { + // (sign || 1) : + // automatically move to the "next" symbol if the current one is "turned to" hidden + this._filterFocusing = (this._filterFocusing + (sign || 1) + len) % len; + this.updateFocusing(move, sign, false); + return; + } + if (move !== 0) { + this._filterFocusing = (this._filterFocusing + sign + len) % len; + // this.updateFocusing(move > 0 ? move - 1 : move + 1, false); + this.updateFocusing(move - sign, sign, false); + return; + } + // moved to expected position + this.symbolElements[this._filterFocusing].classList.add('focused'); + } + + + set value(value: string) { + this._textarea.value = value; + this.adjustHeight(); + this.updateMode(); + } + + get value(): string { + return this._textarea.value; + } + + get searchText(): string { + return this._textarea.value.slice(1); + } + + + private set filtering(value: boolean) { + this._filtering = value; + if (value) { + this._filterEle.classList.toggle('active', true); + } + this._filterList.classList.toggle('active', value); + } + + private get filtering() { + return this._filtering; + } + + set mode(mode: Mode) { this._mode = mode; this._textarea.value = mode as string + this._textarea.value.slice(1); @@ -90,16 +262,6 @@ export class InputArea extends HTMLElement { get mode(): Mode { return this._mode; } - - /** - * clear the input area and set the mode to the given mode - * @param mode - */ - clear(mode: Mode = Mode.Nav) { - this._textarea.value = mode as string; - this.adjustHeight(); - this.mode = mode; - } } @@ -108,12 +270,79 @@ const STYLE = /*css*/` background: transparent; color: var(--vscode-input-foreground); padding: 3px 0 3px 6px; - font-size: 14px; + font-size: inherit; width: 100%; resize: none; + line-break: anywhere; } #input-text:focus { outline: none; } + +div#symbol-list { + position: absolute; + display: none; + top: calc(11px + 1em); + min-width: 150px; + left: 1em; + background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-menu-border); + color: var(--vscode-dropdown-foreground); + border-radius: 3px; + box-shadow: 0 3px 7px 0 rgba(0, 0, 0, .13), 0 1px 2px 0 rgba(0, 0, 0, .11); +} + +div#symbol-list.active { + display: block; +} + +#symbol-list ul{ + list-style-type: none; +} + +#symbol-list .symbol-item { + display: flex; + padding: 2px 2px 2px 5px; + align-items: center; + gap: 5px; + font-size: var(--vscode-font-size); +} + +#symbol-list .symbol-item.hidden { + display: none; +} +#symbol-list .symbol-item.focused { + background: var(--vscode-list-hoverBackground); +} + +#symbol-list .symbol-item:hover { + background: var(--vscode-list-hoverBackground); +} + +#symbol-list span.symbol-name { + font-family: var(--vscode-font-family); +} + +div#symbol-filter { + position: absolute; + left: 13px; + top: 9px; + display: none; + align-items: center; + height: 1rem; + width: 1rem; + background: var(--vscode-inputOption-activeBackground); + padding: 1px; + justify-content: center; + border-radius: 3px; +} + +div#symbol-filter.active { + display: flex; +} + +div#symbol-filter.active ~ #input-text { + text-indent: calc(1rem + 5px); +} `; \ No newline at end of file diff --git a/src/webview/input.ts b/src/webview/input.ts index 3c278cd..6663389 100644 --- a/src/webview/input.ts +++ b/src/webview/input.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { SwitchButton } from './components/switchButton'; import { InputArea } from './components/inputArea'; -import { throttle } from '../utils'; +import { SymbolKindStr, throttle } from '../utils'; customElements.define('switch-button', SwitchButton); customElements.define('input-area', InputArea); @@ -21,7 +21,7 @@ const InputContainerHTML = /*html*/`
    { - Input.mode = this.inputArea.mode as Mode; - }); } /** @@ -163,11 +160,16 @@ export class Input { // As the most common case is adding at the end, this may improve performance a lot // In regex mode, we can't reuse the last search result. const reuse = value.startsWith(this.lastSearch) && this.inputArea.mode !== Mode.Regex; + const config = { + pattern, + mode: this.inputArea.mode || Mode.Normal, + filter: this.inputArea.filteredSymbol, + }; if (this.searcher && reuse) { - this.searcher.search(pattern); + this.searcher.search(config); } else { - this.searcher = new Searcher(outlineRoot, pattern); + this.searcher = new Searcher(outlineRoot, config); } this.lastSearch = value; } @@ -271,10 +273,6 @@ export class Input { document.querySelector('#outline-root')?.classList.toggle('searching', false); } - static getMode() { - return Input.mode; - } - } class TreeNode { @@ -332,8 +330,13 @@ class TreeNode { } } - match(search: RegExp | string) { + match(search: RegExp | string, symbol: SymbolKindStr | null = null) { if (!this.matched) return false; + if (symbol && symbol !== this.element.dataset.kind) { + this.matched = false; + this.element.classList.toggle('matched', false); + return false; + } const name = this.element.querySelector('.symbol-name')!; if (search instanceof RegExp) { const matched = name.textContent?.match(search); @@ -365,44 +368,50 @@ class TreeNode { } +interface SearchConfig { + pattern: string, + mode: Mode, + filter: SymbolKindStr | null, +} + class Searcher { private tree: TreeNode; private searchReg: RegExp | string | null = null; - constructor(root: HTMLDivElement, searchInit: string) { + constructor(root: HTMLDivElement, init: SearchConfig) { this.tree = new TreeNode(root); - this.search(searchInit); + this.search(init); } - search(search: string) { - switch (Input.getMode()) { + search(config: SearchConfig) { + switch (config.mode) { case Mode.Normal: - this.searchReg = search; + this.searchReg = config.pattern; break; case Mode.Regex: try { - this.searchReg = new RegExp(search); + this.searchReg = new RegExp(config.pattern); } catch (e) { this.searchReg = null; } break; case Mode.Fuzzy: - this.searchReg = new RegExp(search.split('').join('.*?')); + this.searchReg = new RegExp(config.pattern.split('').join('.*?')); break; default: this.searchReg = null; break; } if (this.searchReg) { - this.searchTree(this.tree, this.searchReg); + this.searchTree(this.tree, this.searchReg, config.filter); } } - private searchTree(node: TreeNode, search: RegExp | string): boolean { - const matched = node.match(search); + private searchTree(node: TreeNode, search: RegExp | string, symbol: SymbolKindStr | null = null): boolean { + const matched = node.match(search, symbol); let matchedChildren = false; for (const child of node.children) { - const matched = this.searchTree(child, search); + const matched = this.searchTree(child, search, symbol); matchedChildren = matchedChildren || matched; } node.setMatchedChildren(matchedChildren);