525 lines
15 KiB
JavaScript
525 lines
15 KiB
JavaScript
|
/* @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 <SearchPopout ref={this.setSearchPopoutRef} />;
|
||
|
}
|
||
|
|
||
|
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 (
|
||
|
<Editor
|
||
|
ref={this.setEditorRef}
|
||
|
onDownArrow={this.onDownArrow}
|
||
|
onUpArrow={this.onUpArrow}
|
||
|
onTab={this.onTab}
|
||
|
onEscape={this.onEscape}
|
||
|
onBlur={this.onBlur}
|
||
|
onFocus={this.onFocus}
|
||
|
handleReturn={this.handleReturn}
|
||
|
handleBeforeInput={this.handleBeforeInput}
|
||
|
handleKeyCommand={this.handleKeyCommand}
|
||
|
handlePastedText={this.handlePastedText}
|
||
|
handlePastedFiles={this.handlePastedFiles}
|
||
|
handleDroppedFiles={this.handleDroppedFiles}
|
||
|
handleDrop={this.handleDrop}
|
||
|
keyBindingFn={this.handleKeyBind}
|
||
|
placeholder={i18n.Messages.SEARCH}
|
||
|
editorState={editorState}
|
||
|
onChange={this.setEditorState}
|
||
|
/>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
render() {
|
||
|
const {searchId, editorState, hasResults, theme} = this.props;
|
||
|
const {focused} = this.state;
|
||
|
const hasContent = !!DraftJSUtils.getFirstTextBlock(editorState).length;
|
||
|
if (!searchId) {
|
||
|
return null;
|
||
|
}
|
||
|
return (
|
||
|
<Popout
|
||
|
offsetX={POPOUT_OFFSET}
|
||
|
ref={this.setPopoutRef}
|
||
|
uniqueId={SEARCH_POPOUT_ID}
|
||
|
render={this.renderPopout}
|
||
|
position="bottom"
|
||
|
toggleClose={false}
|
||
|
animationType="none"
|
||
|
preventInvert>
|
||
|
<div className={classNames({'theme-dark': theme === ThemeTypes.DARK})}>
|
||
|
<div className={classNames('search', {open: hasContent, focused})} onClick={this.focusEditor}>
|
||
|
<div key={searchId} className={classNames('search-bar', {'search-bar-light': theme === ThemeTypes.LIGHT})}>
|
||
|
{this.renderInput()}
|
||
|
<SearchBarIcon handleClear={this.handleClearSearch} hasContent={hasContent || hasResults} />
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</Popout>
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
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
|