/* @flow */ import React from 'react'; import classNames from 'classnames'; import Flux from '../lib/flux'; import i18n from '../i18n'; import ChannelSectionStore from '../stores/ChannelSectionStore'; import SelectedChannelStore from '../stores/SelectedChannelStore'; import SearchStore from '../stores/SearchStore'; import PopoutStore from '../stores/PopoutStore'; import ChannelStore from '../stores/ChannelStore'; import VideoThemeStore from '../stores/VideoThemeStore'; import Popout from './common/Popout'; import {SearchBarIcon} from './common/SearchBar'; import SearchPopout from './SearchPopout'; import {Editor} from 'draft-js'; import type {EditorState} from 'draft-js'; import * as SearchActionCreators from '../actions/SearchActionCreators'; import PopoutActionCreators from '../actions/PopoutActionCreators'; import {ComponentActions, SearchTokenTypes, ThemeTypes, SEARCH_POPOUT_ID} from '../Constants'; import * as DraftJSUtils from '../utils/DraftJSUtils'; import * as SearchUtils from '../utils/SearchUtils'; import * as DraftJSSearchUtils from '../utils/DraftJSSearchUtils'; import {ComponentDispatch} from '../utils/ComponentDispatchUtils'; import AppAnalyticsUtils from '../utils/AppAnalyticsUtils'; import QueryTokenizer from '../lib/QueryTokenizer'; import SearchTokens from '../lib/SearchTokens'; import lodash from 'lodash'; import 'draft-js/dist/Draft.css'; import '../styles/search.styl'; import '../styles/common/search_tokens.styl'; // Used to better position the autocomplete popout for search Certainly not // perfect, but a start - some changes will need to happen in the Popout or // Popouts components to better handle positioning in relation to animations, // etc const POPOUT_OFFSET = -12; const MAX_LENGTH = 512; const throttledSearch = lodash.throttle(SearchActionCreators.search, 500); class Search extends React.PureComponent { state = { focused: false, }; props: { searchId: ?string, isSearching: boolean, editorState: ?EditorState, hasResults: boolean, theme: string, }; _editorRef: ?Editor; _popoutRef: ?Popout; _searchPopoutRef: ?SearchPopout; constructor(props) { super(props); lodash.bindAll(this, [ 'search', 'handleSetSearchQuery', 'handleFocusSearch', 'handleClearSearch', 'focusEditor', 'blurEditor', 'setEditorRef', 'setPopoutRef', 'setSearchPopoutRef', 'renderPopout', 'onDownArrow', 'onUpArrow', 'onTab', 'onEscape', 'onBlur', 'onFocus', 'handleReturn', 'handleBeforeInput', 'handleKeyCommand', 'handlePastedText', 'handlePastedFiles', 'handleDroppedFiles', 'handleDrop', 'handleKeyBind', 'setEditorState', ]); } componentWillMount() { SearchUtils.clearTokenCache(); } componentDidMount() { ComponentDispatch.subscribe(ComponentActions.PERFORM_SEARCH, this.search); ComponentDispatch.subscribe(ComponentActions.SET_SEARCH_QUERY, this.handleSetSearchQuery); ComponentDispatch.subscribe(ComponentActions.FOCUS_SEARCH, this.handleFocusSearch); } componentWillUpdate(nextProps) { if (nextProps.searchId !== this.props.searchId) { SearchUtils.clearTokenCache(); } } componentDidUpdate(prevProps) { const {editorState, searchId} = this.props; const {focused} = this.state; if (focused) { this.ensurePopoutIsOpen(); } if (editorState !== prevProps.editorState) { const tokens = SearchUtils.tokenizeQuery(DraftJSUtils.getFirstTextBlock(editorState)); const cursorScope = DraftJSSearchUtils.getSelectionScope(tokens, editorState); SearchActionCreators.updateAutocompleteQuery(searchId, tokens, cursorScope); if (this._editorRef) { DraftJSUtils.scrollCursorIntoView(this._editorRef.refs.editor); } } } componentWillUnmount() { ComponentDispatch.unsubscribe(ComponentActions.PERFORM_SEARCH, this.search); ComponentDispatch.unsubscribe(ComponentActions.SET_SEARCH_QUERY, this.handleSetSearchQuery); ComponentDispatch.unsubscribe(ComponentActions.FOCUS_SEARCH, this.handleFocusSearch); } handleSetSearchQuery({ query, anchor, focus, performSearch, replace, }: { query: string, anchor?: number, focus?: number, performSearch?: boolean, replace?: boolean, }) { let {editorState} = this.props; const currentText = DraftJSUtils.getFirstTextBlock(editorState); // Always append a space to an injected query if it doesn't already have one if (query.charAt(query.length - 1) !== ' ') { query += ' '; } // Always prepend a space to a query if it's not at anchor 0 and there // isn't one beforehand already if (anchor && currentText.charAt(anchor - 1) !== ' ' && query.charAt(0) !== ' ') { query = ' ' + query; } if (replace) { editorState = DraftJSUtils.replaceAllContent(query, editorState); anchor = 0; } else { editorState = DraftJSUtils.updateContent(query, editorState, anchor, focus); } editorState = DraftJSUtils.truncateContent(editorState, MAX_LENGTH); editorState = this.tokenize(editorState); const offset = anchor + query.length; editorState = DraftJSUtils.setCollapsedSelection(offset, editorState); this.setEditorState(editorState); if (performSearch) { this.search(DraftJSUtils.getFirstTextBlock(editorState)); } } renderPopout() { return ; } tokenize(editorState) { const tokens = SearchUtils.tokenizeQuery(DraftJSUtils.getFirstTextBlock(editorState)).filter( token => token.type !== QueryTokenizer.NON_TOKEN_TYPE ); return DraftJSUtils.applyTokensAsEntities(tokens, editorState, SearchTokens); } search(queryString: ?string) { const {searchId, isSearching} = this.props; if (!queryString) { const {editorState} = this.props; queryString = DraftJSUtils.getFirstTextBlock(editorState); } if (searchId && !isSearching) { const tokens = SearchUtils.tokenizeQuery(queryString); const query = SearchUtils.getSearchQueryFromTokens(tokens); // Remove any filters from the history that do not have answers for (let i = 0; i < tokens.length; i++) { if (!SearchUtils.filterHasAnswer(tokens[i], tokens[i + 1])) { queryString = queryString.substring(0, tokens[i].start) + queryString.substring(tokens[i].end); } } // If we have an empty query, abort the search if (!tokens.length || Object.keys(query).length == 0) { return false; } throttledSearch(searchId, query, queryString); this.onBlur(); } return true; } clearSearch() { const {searchId} = this.props; if (searchId) { SearchActionCreators.clearSearchState(searchId); } } handleClearSearch(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); this.clearSearch(); } ensurePopoutIsOpen() { if (this._popoutRef && !PopoutStore.isOpen(SEARCH_POPOUT_ID)) { this._popoutRef.open(); } } handleFocusSearch({prefillCurrentChannel}: {prefillCurrentChannel?: boolean}) { if (!prefillCurrentChannel || !DraftJSUtils.isEmpty(this.props.editorState)) { this.focusEditor(); return; } const selectedChannelId = SelectedChannelStore.getChannelId(); const channel = ChannelStore.getChannel(selectedChannelId); if (channel == null || channel.isPrivate()) { this.focusEditor(); return; } this.handleSetSearchQuery({ query: SearchTokens[SearchTokenTypes.FILTER_IN].key + channel.toString(true) + ' ', replace: true, }); } focusEditor() { if (this._editorRef) { // FIXME: Leaving for reference, probably don't need // process.nextTick(this._editorRef.focus); this._editorRef.focus(); } } blurEditor() { if (this._editorRef) { this._editorRef.blur(); } } setEditorRef(ref: Editor) { this._editorRef = ref; } setPopoutRef(ref: Popout) { this._popoutRef = ref; } setSearchPopoutRef(ref: SearchPopout) { this._searchPopoutRef = ref; } onFocus() { AppAnalyticsUtils.trackWithMetadata('search_opened'); this.setState({focused: true}); } onBlur() { this.setState({focused: false}, () => { PopoutActionCreators.close(SEARCH_POPOUT_ID); if (DraftJSUtils.isEmpty(this.props.editorState)) { this.clearSearch(); } }); } onDownArrow(event: KeyboardEvent) { event.preventDefault(); if (this._searchPopoutRef) { this._searchPopoutRef.focusNextOption(); } return true; } onUpArrow(event: KeyboardEvent) { event.preventDefault(); if (this._searchPopoutRef) { this._searchPopoutRef.focusPreviousOption(); } return true; } onTab(event: KeyboardEvent) { event.preventDefault(); if (this._searchPopoutRef) { this._searchPopoutRef.selectOption(); } return true; } handleReturn(event: KeyboardEvent) { event.preventDefault(); let handled = false; if (this._searchPopoutRef) { handled = this._searchPopoutRef.selectOption(); } if (!handled) { this.search(); } return true; } onEscape(event: KeyboardEvent) { event.preventDefault(); event.stopPropagation(); this.blurEditor(); return true; } handleBeforeInput(character: string) { let {editorState} = this.props; const {focused} = this.state; const query = DraftJSUtils.getFirstTextBlock(editorState); if (query.length >= MAX_LENGTH) { return true; } editorState = DraftJSUtils.updateContent(character, editorState); // Probs never needed, but here as a safety (i.e. if character ever // happened to be more than 1 char) editorState = DraftJSUtils.truncateContent(editorState, MAX_LENGTH); editorState = this.tokenize(editorState); this.setEditorState(editorState); if (!focused) { this.setState({focused: true}); } return true; } handleKeyCommand(command: string) { let {editorState} = this.props; const {focused} = this.state; switch (command) { case 'backspace': case 'backspace-word': case 'backspace-to-start-of-line': case 'delete': case 'delete-word': editorState = DraftJSUtils.deleteContent(command, editorState); editorState = this.tokenize(editorState); this.setEditorState(editorState); if (!focused) { this.setState({focused: true}); } return true; case 'transpose-characters': case 'move-selection-to-start-of-block': case 'move-selection-to-end-of-block': editorState = DraftJSUtils.miscCommand(command, editorState); editorState = this.tokenize(editorState); this.setEditorState(editorState); return true; // Misc commands that should never execute case 'split-block': case 'underline': case 'bold': case 'italic': return true; } } handlePastedText(text: string) { let {editorState} = this.props; const {focused} = this.state; text = text.replace(/\n/g, ''); editorState = DraftJSUtils.updateContent(text, editorState); editorState = DraftJSUtils.truncateContent(editorState, MAX_LENGTH); editorState = this.tokenize(editorState); if (!focused) { this.setState({focused: true}); } this.setEditorState(editorState); return true; } handlePastedFiles() { return true; } handleDroppedFiles() { return true; } handleDrop() { return true; } setEditorState(editorState) { const {searchId} = this.props; SearchActionCreators.setSearchState(searchId, editorState); } // Due to how `contenteditable` handles cmd-left/right and home/end, we have // to manually set the cursor position to the beginning or end, otherwise the // cursor will only jump by html block elements which is not useful handleKeyBind(event: KeyboardEvent) { const {key, metaKey, shiftKey} = event; let {editorState} = this.props; // // Appears to be a bug in DraftJS when certain commands propogate event.stopPropagation(); if (key === 'Home' || (key === 'ArrowLeft' && metaKey)) { event.preventDefault(); if (shiftKey) { editorState = DraftJSUtils.setToStartSelection(editorState); } else { editorState = DraftJSUtils.setCollapsedStartSelection(editorState); } this.setEditorState(editorState); return true; } if (key === 'End' || (key === 'ArrowRight' && metaKey)) { event.preventDefault(); if (shiftKey) { editorState = DraftJSUtils.setToEndSelection(editorState); } else { editorState = DraftJSUtils.setCollapsedEndSelection(editorState); } this.setEditorState(editorState); return true; } // Ensure we properly handle all the rest of the inputs return DraftJSUtils.getDefaultKeyBinding(event); } renderInput() { const {editorState} = this.props; return ( ); } render() { const {searchId, editorState, hasResults, theme} = this.props; const {focused} = this.state; const hasContent = !!DraftJSUtils.getFirstTextBlock(editorState).length; if (!searchId) { return null; } return (
{this.renderInput()}
); } } export default Flux.connectStores([SearchStore, ChannelSectionStore, VideoThemeStore], () => { const searchId = SearchStore.getCurrentSearchId(); const isSearching = searchId ? SearchStore.isSearching(searchId) : false; const editorState = (searchId && SearchStore.getEditorState(searchId)) || DraftJSUtils.createEmptyEditorState(DraftJSSearchUtils.generateDecorators(SearchTokens)); return { searchId, isSearching, editorState, hasResults: SearchStore.hasResults(searchId), theme: VideoThemeStore.theme, }; })(Search); // WEBPACK FOOTER // // ./discord_app/components/Search.js