/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ 'use strict'; import 'vs/css!./findWidget'; import * as nls from 'vs/nls'; import * as Errors from 'vs/base/common/errors'; import * as DomUtils from 'vs/base/browser/dom'; import {IContextViewProvider} from 'vs/base/browser/ui/contextview/contextview'; import {InputBox, IMessage as InputBoxMessage} from 'vs/base/browser/ui/inputbox/inputBox'; import {FindInput} from 'vs/base/browser/ui/findinput/findInput'; import * as EditorBrowser from 'vs/editor/browser/editorBrowser'; import * as EditorCommon from 'vs/editor/common/editorCommon'; import {MATCHES_LIMIT, FIND_IDS} from 'vs/editor/contrib/find/common/findModel'; import {CommonKeybindings} from 'vs/base/common/keyCodes'; import {IKeybindingService} from 'vs/platform/keybinding/common/keybindingService'; import {INewFindReplaceState, FindReplaceStateChangedEvent, FindReplaceState} from 'vs/editor/contrib/find/common/findState'; import {Widget} from 'vs/base/browser/ui/widget'; export interface IFindController { replace(): void; replaceAll(): void; } const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); const NLS_PREVIOUS_MATCH_BTN_LABEL = nls.localize('label.previousMatchButton', "Previous match"); const NLS_NEXT_MATCH_BTN_LABEL = nls.localize('label.nextMatchButton', "Next match"); const NLS_TOGGLE_SELECTION_FIND_TITLE = nls.localize('label.toggleSelectionFind', "Find in selection"); const NLS_CLOSE_BTN_LABEL = nls.localize('label.closeButton', "Close"); const NLS_REPLACE_INPUT_LABEL = nls.localize('label.replace', "Replace"); const NLS_REPLACE_INPUT_PLACEHOLDER = nls.localize('placeholder.replace', "Replace"); const NLS_REPLACE_BTN_LABEL = nls.localize('label.replaceButton', "Replace"); const NLS_REPLACE_ALL_BTN_LABEL = nls.localize('label.replaceAllButton', "Replace All"); const NLS_TOGGLE_REPLACE_MODE_BTN_LABEL = nls.localize('label.toggleReplaceButton', "Toggle Replace mode"); const NLS_MATCHES_COUNT_LIMIT_TITLE = nls.localize('title.matchesCountLimit', "Only the first 999 results are highlighted, but all find operations work on the entire text."); export class FindWidget extends Widget implements EditorBrowser.IOverlayWidget { private static ID = 'editor.contrib.findWidget'; private static PART_WIDTH = 275; private static FIND_INPUT_AREA_WIDTH = FindWidget.PART_WIDTH - 54; private static REPLACE_INPUT_AREA_WIDTH = FindWidget.FIND_INPUT_AREA_WIDTH; private _codeEditor: EditorBrowser.ICodeEditor; private _state: FindReplaceState; private _controller: IFindController; private _contextViewProvider: IContextViewProvider; private _keybindingService: IKeybindingService; private _domNode: HTMLElement; private _findInput: FindInput; private _replaceInputBox: InputBox; private _toggleReplaceBtn: SimpleButton; private _prevBtn: SimpleButton; private _nextBtn: SimpleButton; private _toggleSelectionFind: SimpleCheckbox; private _closeBtn: SimpleButton; private _replaceBtn: SimpleButton; private _replaceAllBtn: SimpleButton; private _isVisible: boolean; private _isReplaceVisible: boolean; private focusTracker: DomUtils.IFocusTracker; constructor( codeEditor: EditorBrowser.ICodeEditor, controller: IFindController, state: FindReplaceState, contextViewProvider: IContextViewProvider, keybindingService: IKeybindingService ) { super(); this._codeEditor = codeEditor; this._controller = controller; this._state = state; this._contextViewProvider = contextViewProvider; this._keybindingService = keybindingService; this._isVisible = false; this._isReplaceVisible = false; this._register(this._state.addChangeListener((e) => this._onStateChanged(e))); this._buildDomNode(); this._updateButtons(); this.focusTracker = this._register(DomUtils.trackFocus(this._findInput.inputBox.inputElement)); this.focusTracker.addFocusListener(() => this._reseedFindScope()); this._register(this._codeEditor.addListener2(EditorCommon.EventType.ConfigurationChanged, (e:EditorCommon.IConfigurationChangedEvent) => { if (e.readOnly) { if (this._codeEditor.getConfiguration().readOnly) { // Hide replace part if editor becomes read only this._state.change({ isReplaceRevealed: false }, false); } this._updateButtons(); } })); this._register(this._codeEditor.addListener2(EditorCommon.EventType.CursorSelectionChanged, () => { if (this._isVisible) { this._updateToggleSelectionFindButton(); } })); this._codeEditor.addOverlayWidget(this); } private _reseedFindScope(): void { let selection = this._codeEditor.getSelection(); if (selection.startLineNumber !== selection.endLineNumber) { // Reseed find scope this._state.change({ searchScope: selection }, true); } } // ----- IOverlayWidget API public getId(): string { return FindWidget.ID; } public getDomNode(): HTMLElement { return this._domNode; } public getPosition(): EditorBrowser.IOverlayWidgetPosition { if (this._isVisible) { return { preference: EditorBrowser.OverlayWidgetPositionPreference.TOP_RIGHT_CORNER }; } return null; } // ----- React to state changes private _onStateChanged(e:FindReplaceStateChangedEvent): void { if (e.searchString) { this._findInput.setValue(this._state.searchString); this._updateButtons(); } if (e.replaceString) { this._replaceInputBox.value = this._state.replaceString; } if (e.isRevealed) { if (this._state.isRevealed) { this._reveal(true); } else { this._hide(true); } } if (e.isReplaceRevealed) { if (this._state.isReplaceRevealed) { if (!this._codeEditor.getConfiguration().readOnly && !this._isReplaceVisible) { this._isReplaceVisible = true; this._updateButtons(); } } else { if (this._isReplaceVisible) { this._isReplaceVisible = false; this._updateButtons(); } } } if (e.isRegex) { this._findInput.setRegex(this._state.isRegex); } if (e.wholeWord) { this._findInput.setWholeWords(this._state.wholeWord); } if (e.matchCase) { this._findInput.setCaseSensitive(this._state.matchCase); } if (e.searchScope) { if (this._state.searchScope) { this._toggleSelectionFind.checked = true; } else { this._toggleSelectionFind.checked = false; } this._updateToggleSelectionFindButton(); } if (e.searchString || e.matchesCount) { let showRedOutline = (this._state.searchString.length > 0 && this._state.matchesCount === 0); DomUtils.toggleClass(this._domNode, 'no-results', showRedOutline); let showMatchesCount = (this._state.searchString.length > 0); let matchesCount:string = String(this._state.matchesCount); let matchesCountTitle = ''; if (this._state.matchesCount >= MATCHES_LIMIT) { matchesCountTitle = NLS_MATCHES_COUNT_LIMIT_TITLE; matchesCount += '+'; } this._findInput.setMatchCountState({ isVisible: showMatchesCount, count: matchesCount, title: matchesCountTitle }); } } // ----- actions /** * If 'selection find' is ON we should not disable the button (its function is to cancel 'selection find'). * If 'selection find' is OFF we enable the button only if there is a multi line selection. */ private _updateToggleSelectionFindButton(): void { let selection = this._codeEditor.getSelection(); let isMultiLineSelection = selection ? (selection.startLineNumber !== selection.endLineNumber) : false; let isChecked = this._toggleSelectionFind.checked; this._toggleSelectionFind.setEnabled(this._isVisible && (isChecked || isMultiLineSelection)); } private _updateButtons(): void { this._findInput.setEnabled(this._isVisible); this._replaceInputBox.setEnabled(this._isVisible && this._isReplaceVisible); this._updateToggleSelectionFindButton(); this._closeBtn.setEnabled(this._isVisible); let findInputIsNonEmpty = (this._state.searchString.length > 0); this._prevBtn.setEnabled(this._isVisible && findInputIsNonEmpty); this._nextBtn.setEnabled(this._isVisible && findInputIsNonEmpty); this._replaceBtn.setEnabled(this._isVisible && this._isReplaceVisible && findInputIsNonEmpty); this._replaceAllBtn.setEnabled(this._isVisible && this._isReplaceVisible && findInputIsNonEmpty); DomUtils.toggleClass(this._domNode, 'replaceToggled', this._isReplaceVisible); this._toggleReplaceBtn.toggleClass('collapse', !this._isReplaceVisible); this._toggleReplaceBtn.toggleClass('expand', this._isReplaceVisible); this._toggleReplaceBtn.setExpanded(this._isReplaceVisible); let canReplace = !this._codeEditor.getConfiguration().readOnly; this._toggleReplaceBtn.setEnabled(this._isVisible && canReplace); } private _reveal(animate:boolean): void { if (!this._isVisible) { this._isVisible = true; this._updateButtons(); setTimeout(() => { DomUtils.addClass(this._domNode, 'visible'); if (!animate) { DomUtils.addClass(this._domNode, 'noanimation'); setTimeout(() => { DomUtils.removeClass(this._domNode, 'noanimation'); }, 200); } }, 0); this._codeEditor.layoutOverlayWidget(this); } } private _hide(focusTheEditor:boolean): void { if (this._isVisible) { this._isVisible = false; this._updateButtons(); DomUtils.removeClass(this._domNode, 'visible'); if (focusTheEditor) { this._codeEditor.focus(); } this._codeEditor.layoutOverlayWidget(this); } } // ----- Public public focusFindInput(): void { this._findInput.select(); // Edge browser requires focus() in addition to select() this._findInput.focus(); } public focusReplaceInput(): void { this._replaceInputBox.select(); // Edge browser requires focus() in addition to select() this._replaceInputBox.focus(); } private _onFindInputKeyDown(e:DomUtils.IKeyboardEvent): void { switch (e.asKeybinding()) { case CommonKeybindings.ENTER: this._codeEditor.getAction(FIND_IDS.NextMatchFindAction).run().done(null, Errors.onUnexpectedError); e.preventDefault(); return; case CommonKeybindings.SHIFT_ENTER: this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction).run().done(null, Errors.onUnexpectedError); e.preventDefault(); return; case CommonKeybindings.TAB: if (this._isReplaceVisible) { this._replaceInputBox.focus(); } else { this._findInput.focusOnCaseSensitive(); } e.preventDefault(); return; case CommonKeybindings.CTRLCMD_DOWN_ARROW: this._codeEditor.focus(); e.preventDefault(); return; } // getValue() is not updated right away setTimeout(() => { this._state.change({ searchString: this._findInput.getValue() }, true); }, 10); } private _onReplaceInputKeyDown(e:DomUtils.IKeyboardEvent): void { switch (e.asKeybinding()) { case CommonKeybindings.ENTER: this._controller.replace(); e.preventDefault(); return; case CommonKeybindings.CTRLCMD_ENTER: this._controller.replaceAll(); e.preventDefault(); return; case CommonKeybindings.TAB: this._findInput.focusOnCaseSensitive(); e.preventDefault(); return; case CommonKeybindings.SHIFT_TAB: this._findInput.focus(); e.preventDefault(); return; case CommonKeybindings.CTRLCMD_DOWN_ARROW: this._codeEditor.focus(); e.preventDefault(); return; } setTimeout(() => { this._state.change({ replaceString: this._replaceInputBox.value }, false); }, 10); } // ----- initialization private _keybindingLabelFor(actionId:string): string { let keybindings = this._keybindingService.lookupKeybindings(actionId); if (keybindings.length === 0) { return ''; } return ' (' + this._keybindingService.getLabelFor(keybindings[0]) + ')'; } private _buildFindPart(): HTMLElement { // Find input this._findInput = this._register(new FindInput(null, this._contextViewProvider, { width: FindWidget.FIND_INPUT_AREA_WIDTH, label: NLS_FIND_INPUT_LABEL, placeholder: NLS_FIND_INPUT_PLACEHOLDER, appendCaseSensitiveLabel: this._keybindingLabelFor(FIND_IDS.ToggleCaseSensitiveCommand), appendWholeWordsLabel: this._keybindingLabelFor(FIND_IDS.ToggleWholeWordCommand), appendRegexLabel: this._keybindingLabelFor(FIND_IDS.ToggleRegexCommand), validation: (value:string): InputBoxMessage => { if (value.length === 0) { return null; } if (!this._findInput.getRegex()) { return null; } try { let r = new RegExp(value); return null; } catch (e) { return { content: e.message }; } } })); this._register(this._findInput.onKeyDown((e) => this._onFindInputKeyDown(e))); this._register(this._findInput.onDidOptionChange(() => { this._state.change({ isRegex: this._findInput.getRegex(), wholeWord: this._findInput.getWholeWords(), matchCase: this._findInput.getCaseSensitive() }, true); })); this._register(this._findInput.onCaseSensitiveKeyDown((e) => { if (e.equals(CommonKeybindings.SHIFT_TAB)) { if (this._isReplaceVisible) { this._replaceInputBox.focus(); e.preventDefault(); } } })); // Previous button this._prevBtn = this._register(new SimpleButton({ label: NLS_PREVIOUS_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.PreviousMatchFindAction), className: 'previous', onTrigger: () => { this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction).run().done(null, Errors.onUnexpectedError); }, onKeyDown: (e) => {} })); // Next button this._nextBtn = this._register(new SimpleButton({ label: NLS_NEXT_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.NextMatchFindAction), className: 'next', onTrigger: () => { this._codeEditor.getAction(FIND_IDS.NextMatchFindAction).run().done(null, Errors.onUnexpectedError); }, onKeyDown: (e) => {} })); let findPart = document.createElement('div'); findPart.className = 'find-part'; findPart.appendChild(this._findInput.domNode); findPart.appendChild(this._prevBtn.domNode); findPart.appendChild(this._nextBtn.domNode); // Toggle selection button this._toggleSelectionFind = this._register(new SimpleCheckbox({ parent: findPart, title: NLS_TOGGLE_SELECTION_FIND_TITLE, onChange: () => { if (this._toggleSelectionFind.checked) { this._reseedFindScope(); } else { this._state.change({ searchScope: null }, true); } } })); // Close button this._closeBtn = this._register(new SimpleButton({ label: NLS_CLOSE_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.CloseFindWidgetCommand), className: 'close-fw', onTrigger: () => { this._state.change({ isRevealed: false }, false); }, onKeyDown: (e) => { if (e.equals(CommonKeybindings.TAB)) { if (this._isReplaceVisible) { if (this._replaceBtn.isEnabled()) { this._replaceBtn.focus(); } else { this._codeEditor.focus(); } e.preventDefault(); } } } })); findPart.appendChild(this._closeBtn.domNode); return findPart; } private _buildReplacePart(): HTMLElement { // Replace input let replaceInput = document.createElement('div'); replaceInput.className = 'replace-input'; replaceInput.style.width = FindWidget.REPLACE_INPUT_AREA_WIDTH + 'px'; this._replaceInputBox = this._register(new InputBox(replaceInput, null, { ariaLabel: NLS_REPLACE_INPUT_LABEL, placeholder: NLS_REPLACE_INPUT_PLACEHOLDER })); this._register(DomUtils.addStandardDisposableListener(this._replaceInputBox.inputElement, 'keydown', (e) => this._onReplaceInputKeyDown(e))); // Replace one button this._replaceBtn = this._register(new SimpleButton({ label: NLS_REPLACE_BTN_LABEL, className: 'replace', onTrigger: () => { this._controller.replace(); }, onKeyDown: (e) => { if (e.equals(CommonKeybindings.SHIFT_TAB)) { this._closeBtn.focus(); e.preventDefault(); } } })); // Replace all button this._replaceAllBtn = this._register(new SimpleButton({ label: NLS_REPLACE_ALL_BTN_LABEL, className: 'replace-all', onTrigger: () => { this._controller.replaceAll(); }, onKeyDown: (e) => {} })); let replacePart = document.createElement('div'); replacePart.className = 'replace-part'; replacePart.appendChild(replaceInput); replacePart.appendChild(this._replaceBtn.domNode); replacePart.appendChild(this._replaceAllBtn.domNode); return replacePart; } private _buildDomNode(): void { // Find part let findPart = this._buildFindPart(); // Replace part let replacePart = this._buildReplacePart(); // Toggle replace button this._toggleReplaceBtn = this._register(new SimpleButton({ label: NLS_TOGGLE_REPLACE_MODE_BTN_LABEL, className: 'toggle left', onTrigger: () => { this._state.change({ isReplaceRevealed: !this._isReplaceVisible }, true); }, onKeyDown: (e) => {} })); this._toggleReplaceBtn.toggleClass('expand', this._isReplaceVisible); this._toggleReplaceBtn.toggleClass('collapse', !this._isReplaceVisible); this._toggleReplaceBtn.setExpanded(this._isReplaceVisible); // Widget this._domNode = document.createElement('div'); this._domNode.className = 'editor-widget find-widget'; this._domNode.setAttribute('aria-hidden', 'false'); this._domNode.appendChild(this._toggleReplaceBtn.domNode); this._domNode.appendChild(findPart); this._domNode.appendChild(replacePart); } } interface ISimpleCheckboxOpts { parent: HTMLElement; title: string; onChange: () => void; } class SimpleCheckbox extends Widget { private static _COUNTER = 0; private _opts: ISimpleCheckboxOpts; private _domNode: HTMLElement; private _checkbox: HTMLInputElement; private _label: HTMLLabelElement; constructor(opts:ISimpleCheckboxOpts) { super(); this._opts = opts; this._domNode = document.createElement('div'); this._domNode.className = 'monaco-checkbox'; this._domNode.title = this._opts.title; this._checkbox = document.createElement('input'); this._checkbox.type = 'checkbox'; this._checkbox.className = 'checkbox'; this._checkbox.id = 'checkbox-' + SimpleCheckbox._COUNTER++; this._label = document.createElement('label'); this._label.className = 'label'; // Connect the label and the checkbox. Checkbox will get checked when the label recieves a click. this._label.htmlFor = this._checkbox.id; this._domNode.appendChild(this._checkbox); this._domNode.appendChild(this._label); this._opts.parent.appendChild(this._domNode); this.onchange(this._checkbox, (e) => { this._opts.onChange(); }); } public get domNode(): HTMLElement { return this._domNode; } public get checked(): boolean { return this._checkbox.checked; } public set checked(newValue:boolean) { this._checkbox.checked = newValue; } public focus(): void { this._checkbox.focus(); } private enable(): void { this._checkbox.removeAttribute('disabled'); } private disable(): void { this._checkbox.disabled = true; } public setEnabled(enabled:boolean): void { if (enabled) { this.enable(); } else { this.disable(); } } } interface ISimpleButtonOpts { label: string; className: string; onTrigger: ()=>void; onKeyDown: (e:DomUtils.IKeyboardEvent)=>void; } class SimpleButton extends Widget { private _opts: ISimpleButtonOpts; private _domNode: HTMLElement; constructor(opts:ISimpleButtonOpts) { super(); this._opts = opts; this._domNode = document.createElement('div'); this._domNode.title = this._opts.label; this._domNode.tabIndex = 0; this._domNode.className = 'button ' + this._opts.className; this._domNode.setAttribute('role', 'button'); this._domNode.setAttribute('aria-label', this._opts.label); this.onclick(this._domNode, (e) => { this._opts.onTrigger(); e.preventDefault(); }); this.onkeydown(this._domNode, (e) => { if (e.equals(CommonKeybindings.SPACE) || e.equals(CommonKeybindings.ENTER)) { this._opts.onTrigger(); e.preventDefault(); return; } this._opts.onKeyDown(e); }); } public get domNode(): HTMLElement { return this._domNode; } public isEnabled(): boolean { return (this._domNode.tabIndex >= 0); } public focus(): void { this._domNode.focus(); } public setEnabled(enabled:boolean): void { DomUtils.toggleClass(this._domNode, 'disabled', !enabled); this._domNode.setAttribute('aria-disabled', String(!enabled)); this._domNode.tabIndex = enabled ? 0 : -1; } public setExpanded(expanded:boolean): void { this._domNode.setAttribute('aria-expanded', String(expanded)); } public toggleClass(className:string, shouldHaveIt:boolean): void { DomUtils.toggleClass(this._domNode, className, shouldHaveIt); } }