From 668bb032ba09103d1a9bff83cffcf229458c0648 Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Mon, 13 Jul 2020 22:04:19 -0700 Subject: [PATCH] Introduce filtering via Ctrl+F/Cmd+F into the sandwich view (#293) This is the first step towards fixing #38. I started with the easiest part from a UI-paradigm perspective, and also the place that's the most confusing that search doesn't work. Before this PR, browers' Cmd+F/Ctrl+F would *look* like it worked in the Sandwich view, but they wouldn't work fully because the view in the sandwich view is a virtualized table, meaning that it doesn't put all of the rows in the DOM. Instead, it only renders enough to fill the viewport to make rendering much faster. Here's what the changes from this PR look like in action: ![Kapture 2020-07-12 at 23 17 33](https://user-images.githubusercontent.com/150329/87276802-ef2b8780-c495-11ea-9856-9c834ea7f028.gif) Before closing #38, I'll be adding search functionality to the flamechart views too. --- src/store/actions.ts | 21 +- src/store/index.ts | 32 ++- src/store/profiles-state.ts | 3 + src/views/profile-table-view.tsx | 382 ++++++++++++++++++----------- src/views/sandwich-view.tsx | 30 ++- src/views/scrollable-list-view.tsx | 172 +++++++------ src/views/search-view.tsx | 146 +++++++++++ src/views/toolbar.tsx | 13 + 8 files changed, 545 insertions(+), 254 deletions(-) create mode 100644 src/views/search-view.tsx diff --git a/src/store/actions.ts b/src/store/actions.ts index 2b379df..68fc5e8 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -8,36 +8,19 @@ import {HashParams} from '../lib/hash-params' import {actionCreatorWithIndex} from './profiles-state' export namespace actions { - // Set the top-level profile group from which other data will be derived export const setProfileGroup = actionCreator('setProfileGroup') - - // Set the index into the profile group to view export const setProfileIndexToView = actionCreator('setProfileIndexToView') - export const setGLCanvas = actionCreator('setGLCanvas') - - // Set which top-level view should be displayed export const setViewMode = actionCreator('setViewMode') - - // Set whether or not recursion should be flattened when viewing flamegraphs export const setFlattenRecursion = actionCreator('setFlattenRecursion') - - // Set whether a file drag is currently active. Used to indicate that the - // application is a valid drop target. + export const setSearchQuery = actionCreator('setSearchQuery') + export const setSearchIsActive = actionCreator('setSearchIsActive') export const setDragActive = actionCreator('setDragActive') - - // Set whether the application is currently in a loading state. Used to - // display a loading progress bar. export const setLoading = actionCreator('setLoading') - - // Set whether the application is in an errored state. export const setError = actionCreator('setError') - - // Set whether parameters defined by the URL encoded k=v pairs after the # in the URL export const setHashParams = actionCreator('setHashParams') export namespace sandwichView { - // Set the table sorting method used for the sandwich view. export const setTableSortMethod = actionCreator('sandwichView.setTableSortMethod') export const setSelectedFrame = actionCreatorWithIndex( diff --git a/src/store/index.ts b/src/store/index.ts index da7f95e..aca59f4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -19,20 +19,43 @@ export const enum ViewMode { } export interface ApplicationState { + // The top-level profile group from which most other data will be derived + profileGroup: ProfileGroupState + + // Parameters defined by the URL encoded k=v pairs after the # in the URL hashParams: HashParams glCanvas: HTMLCanvasElement | null + // Which top-level view should be displayed + viewMode: ViewMode + + // True if recursion should be flattened when viewing flamegraphs flattenRecursion: boolean - viewMode: ViewMode + // The query used in top-level views + // + // An empty string indicates that the search is open by no filter is applied. + // searchIsActive is stored separately, because we may choose to persist the + // query even when the search input is closed. + searchQuery: string + searchIsActive: boolean + + // True when a file drag is currently active. Used to indicate that the + // application is a valid drop target. dragActive: boolean + + // True when the application is currently in a loading state. Used to + // display a loading progress bar. loading: boolean + + // True when the application is an error state, e.g. because the profile + // imported was invalid. error: boolean + // The table sorting method using for the sandwich view, specifying the column + // to sort by, and the direction to sort that clumn. tableSortMethod: SortMethod - - profileGroup: ProfileGroupState } const protocol = window.location.protocol @@ -56,6 +79,9 @@ export function createAppStore(initialState?: ApplicationState): redux.Store(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART), + searchQuery: setter(actions.setSearchQuery, ''), + searchIsActive: setter(actions.setSearchIsActive, false), + glCanvas: setter(actions.setGLCanvas, null), dragActive: setter(actions.setDragActive, false), diff --git a/src/store/profiles-state.ts b/src/store/profiles-state.ts index 9d2f579..9f1d0b2 100644 --- a/src/store/profiles-state.ts +++ b/src/store/profiles-state.ts @@ -12,7 +12,10 @@ import {objectsHaveShallowEquality} from '../lib/utils' export type ProfileGroupState = { name: string + + // The index within the list of profiles currently being viewed indexToView: number + profiles: ProfileState[] } | null diff --git a/src/views/profile-table-view.tsx b/src/views/profile-table-view.tsx index 752c010..ed04d35 100644 --- a/src/views/profile-table-view.tsx +++ b/src/views/profile-table-view.tsx @@ -1,17 +1,18 @@ -import {h, Component, JSX} from 'preact' +import {h, Component, JSX, ComponentChild} from 'preact' import {StyleSheet, css} from 'aphrodite' import {Profile, Frame} from '../lib/profile' import {sortBy, formatPercent} from '../lib/utils' import {FontSize, Colors, Sizes, commonStyle} from './style' import {ColorChit} from './color-chit' -import {ScrollableListView, ListItem} from './scrollable-list-view' +import {ListItem, ScrollableListView} from './scrollable-list-view' import {actions} from '../store/actions' import {createGetCSSColorForFrame, getFrameToColorBucket} from '../store/getters' import {ActiveProfileState} from './application' import {useActionCreator} from '../lib/preact-redux' import {useAppSelector} from '../store' import {memo} from 'preact/compat' -import {useCallback, useMemo, useRef} from 'preact/hooks' +import {useCallback, useMemo} from 'preact/hooks' +import {fuzzyMatchStrings} from '../lib/fuzzy-find' export enum SortField { SYMBOL_NAME, @@ -67,8 +68,13 @@ class SortIcon extends Component { } } -interface ProfileTableRowViewProps { +interface ProfileTableRowInfo { frame: Frame + matchedRanges: [number, number][] | null +} + +interface ProfileTableRowViewProps { + info: ProfileTableRowInfo index: number profile: Profile selectedFrame: Frame | null @@ -76,8 +82,33 @@ interface ProfileTableRowViewProps { getCSSColorForFrame: (frame: Frame) => string } -const ProfileTableRowView = (props: ProfileTableRowViewProps) => { - const {frame, profile, index, selectedFrame, setSelectedFrame, getCSSColorForFrame} = props +function highlightRanges( + text: string, + ranges: [number, number][], + highlightedClassName: string, +): JSX.Element { + const spans: ComponentChild[] = [] + let last = 0 + for (let range of ranges) { + spans.push(text.slice(last, range[0])) + spans.push({text.slice(range[0], range[1])}) + last = range[1] + } + spans.push(text.slice(last)) + + return {spans} +} + +const ProfileTableRowView = ({ + info, + profile, + index, + selectedFrame, + setSelectedFrame, + getCSSColorForFrame, +}: ProfileTableRowViewProps) => { + const {frame, matchedRanges} = info + const totalWeight = frame.getTotalWeight() const selfWeight = frame.getSelfWeight() const totalPerc = (100.0 * totalWeight) / profile.getTotalNonIdleWeight() @@ -107,7 +138,13 @@ const ProfileTableRowView = (props: ProfileTableRowViewProps) => { - {frame.name} + {matchedRanges + ? highlightRanges( + frame.name, + matchedRanges, + css(style.matched, selected && style.matchedSelected), + ) + : frame.name} ) @@ -120,166 +157,197 @@ interface ProfileTableViewProps { sortMethod: SortMethod setSelectedFrame: (frame: Frame | null) => void setSortMethod: (sortMethod: SortMethod) => void + searchQuery: string + searchIsActive: boolean } -export const ProfileTableView = memo((props: ProfileTableViewProps) => { - const { +export const ProfileTableView = memo( + ({ profile, sortMethod, setSortMethod, selectedFrame, setSelectedFrame, getCSSColorForFrame, - } = props + searchQuery, + searchIsActive, + }: ProfileTableViewProps) => { + const onSortClick = useCallback( + (field: SortField, ev: MouseEvent) => { + ev.preventDefault() - const onSortClick = useCallback( - (field: SortField, ev: MouseEvent) => { - ev.preventDefault() - - if (sortMethod.field == field) { - // Toggle - setSortMethod({ - field, - direction: - sortMethod.direction === SortDirection.ASCENDING - ? SortDirection.DESCENDING - : SortDirection.ASCENDING, - }) - } else { - // Set a sane default - switch (field) { - case SortField.SYMBOL_NAME: { - setSortMethod({field, direction: SortDirection.ASCENDING}) - break - } - case SortField.SELF: { - setSortMethod({field, direction: SortDirection.DESCENDING}) - break - } - case SortField.TOTAL: { - setSortMethod({field, direction: SortDirection.DESCENDING}) - break + if (sortMethod.field == field) { + // Toggle + setSortMethod({ + field, + direction: + sortMethod.direction === SortDirection.ASCENDING + ? SortDirection.DESCENDING + : SortDirection.ASCENDING, + }) + } else { + // Set a sane default + switch (field) { + case SortField.SYMBOL_NAME: { + setSortMethod({field, direction: SortDirection.ASCENDING}) + break + } + case SortField.SELF: { + setSortMethod({field, direction: SortDirection.DESCENDING}) + break + } + case SortField.TOTAL: { + setSortMethod({field, direction: SortDirection.DESCENDING}) + break + } } } + }, + [sortMethod, setSortMethod], + ) + + const rowList = useMemo((): {frame: Frame; matchedRanges: [number, number][] | null}[] => { + const rowList: ProfileTableRowInfo[] = [] + + profile.forEachFrame(frame => { + let matchedRanges: [number, number][] | null = null + if (searchIsActive) { + const match = fuzzyMatchStrings(frame.name, searchQuery) + if (match == null) return + matchedRanges = match.matchedRanges + } + rowList.push({frame, matchedRanges}) + }) + + switch (sortMethod.field) { + case SortField.SYMBOL_NAME: { + sortBy(rowList, f => f.frame.name.toLowerCase()) + break + } + case SortField.SELF: { + sortBy(rowList, f => f.frame.getSelfWeight()) + break + } + case SortField.TOTAL: { + sortBy(rowList, f => f.frame.getTotalWeight()) + break + } } - }, - [sortMethod, setSortMethod], - ) - - const frameList = useMemo((): Frame[] => { - const frameList: Frame[] = [] - - profile.forEachFrame(f => frameList.push(f)) - - // TODO(jlfwong): This is pretty inefficient to do this on every render, but doesn't - // seem to be a bottleneck, so we'll leave it alone. - switch (sortMethod.field) { - case SortField.SYMBOL_NAME: { - sortBy(frameList, f => f.name.toLowerCase()) - break - } - case SortField.SELF: { - sortBy(frameList, f => f.getSelfWeight()) - break - } - case SortField.TOTAL: { - sortBy(frameList, f => f.getTotalWeight()) - break - } - } - if (sortMethod.direction === SortDirection.DESCENDING) { - frameList.reverse() - } - - return frameList - }, [profile, sortMethod]) - - const listViewRef = useRef(null) - const listViewCallback = useCallback( - (listView: ScrollableListView | null) => { - if (listView === listViewRef.current) return - listViewRef.current = listView - if (!selectedFrame || !listView) return - const index = frameList.indexOf(selectedFrame) - if (index === -1) return - listView.scrollIndexIntoView(index) - }, - [listViewRef, selectedFrame, frameList], - ) - - const renderItems = useCallback( - (firstIndex: number, lastIndex: number) => { - const rows: JSX.Element[] = [] - - for (let i = firstIndex; i <= lastIndex; i++) { - rows.push( - ProfileTableRowView({ - frame: frameList[i], - index: i, - profile: profile, - selectedFrame: selectedFrame, - setSelectedFrame: setSelectedFrame, - getCSSColorForFrame: getCSSColorForFrame, - }), - ) + if (sortMethod.direction === SortDirection.DESCENDING) { + rowList.reverse() } - return {rows}
- }, - [frameList, profile, selectedFrame, setSelectedFrame, getCSSColorForFrame], - ) + return rowList + }, [profile, sortMethod, searchQuery, searchIsActive]) - const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT})) + const renderItems = useCallback( + (firstIndex: number, lastIndex: number) => { + const rows: JSX.Element[] = [] - const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [ - onSortClick, - ]) - const onSelfClick = useCallback((ev: MouseEvent) => onSortClick(SortField.SELF, ev), [ - onSortClick, - ]) - const onSymbolNameClick = useCallback( - (ev: MouseEvent) => onSortClick(SortField.SYMBOL_NAME, ev), - [onSortClick], - ) + for (let i = firstIndex; i <= lastIndex; i++) { + rows.push( + ProfileTableRowView({ + info: rowList[i], + index: i, + profile: profile, + selectedFrame: selectedFrame, + setSelectedFrame: setSelectedFrame, + getCSSColorForFrame: getCSSColorForFrame, + }), + ) + } - return ( -
- - - - - - - - -
- - Total - - - Self - - - Symbol Name -
- -
- ) -}) + if (rows.length === 0) { + if (searchIsActive) { + rows.push( + + + No symbol names match query "{searchQuery}". + + , + ) + } else { + rows.push( + + No symbols found. + , + ) + } + } + + return {rows}
+ }, + [ + rowList, + profile, + selectedFrame, + setSelectedFrame, + getCSSColorForFrame, + searchIsActive, + searchQuery, + ], + ) + + const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [ + rowList, + ]) + + const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [ + onSortClick, + ]) + const onSelfClick = useCallback((ev: MouseEvent) => onSortClick(SortField.SELF, ev), [ + onSortClick, + ]) + const onSymbolNameClick = useCallback( + (ev: MouseEvent) => onSortClick(SortField.SYMBOL_NAME, ev), + [onSortClick], + ) + + return ( +
+ + + + + + + + +
+ + Total + + + Self + + + Symbol Name +
+ f.frame === selectedFrame) + } + /> +
+ ) + }, +) const style = StyleSheet.create({ profileTableView: { @@ -347,6 +415,16 @@ const style = StyleSheet.create({ background: Colors.GREEN, right: 0, }, + matched: { + borderBottom: `2px solid ${Colors.BLACK}`, + }, + matchedSelected: { + borderColor: Colors.WHITE, + }, + emptyState: { + textAlign: 'center', + fontWeight: 'bold', + }, }) interface ProfileTableViewContainerProps { @@ -372,6 +450,8 @@ export const ProfileTableViewContainer = memo((ownProps: ProfileTableViewContain [index], ) const setSortMethod = useActionCreator(setTableSortMethod, []) + const searchIsActive = useAppSelector(state => state.searchIsActive, []) + const searchQuery = useAppSelector(state => state.searchQuery, []) return ( ) }) diff --git a/src/views/sandwich-view.tsx b/src/views/sandwich-view.tsx index 7eb54d7..c0ca0c7 100644 --- a/src/views/sandwich-view.tsx +++ b/src/views/sandwich-view.tsx @@ -10,7 +10,9 @@ import {StatelessComponent} from '../lib/typed-redux' import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view' import {CalleeFlamegraphView} from './callee-flamegraph-view' import {ActiveProfileState} from './application' -import {useDispatch} from '../lib/preact-redux' +import {useDispatch, useActionCreator} from '../lib/preact-redux' +import {SearchView} from './search-view' +import {useAppSelector} from '../store' interface SandwichViewProps { selectedFrame: Frame | null @@ -18,6 +20,10 @@ interface SandwichViewProps { activeProfileState: ActiveProfileState setSelectedFrame: (selectedFrame: Frame | null) => void glCanvas: HTMLCanvasElement + searchQuery: string + searchIsActive: boolean + setSearchQuery: (query: string | null) => void + setSearchIsActive: (active: boolean) => void } class SandwichView extends StatelessComponent { @@ -39,7 +45,13 @@ class SandwichView extends StatelessComponent { } render() { - const {selectedFrame} = this.props + const { + selectedFrame, + searchIsActive, + setSearchIsActive, + searchQuery, + setSearchQuery, + } = this.props let flamegraphViews: JSX.Element | null = null if (selectedFrame) { @@ -72,6 +84,12 @@ class SandwichView extends StatelessComponent {
+
{flamegraphViews}
@@ -81,6 +99,7 @@ class SandwichView extends StatelessComponent { const style = StyleSheet.create({ tableView: { + position: 'relative', flex: 1, }, panZoomViewWraper: { @@ -124,6 +143,8 @@ interface SandwichViewContainerProps { glCanvas: HTMLCanvasElement } +const {setSearchQuery, setSearchIsActive} = actions + export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => { const {activeProfileState, glCanvas} = ownProps const {sandwichViewState, index} = activeProfileState @@ -141,6 +162,7 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) }, [dispatch, index], ) + return ( state.searchQuery, [])} + setSearchQuery={useActionCreator(setSearchQuery, [])} + searchIsActive={useAppSelector(state => state.searchIsActive, [])} + setSearchIsActive={useActionCreator(setSearchIsActive, [])} /> ) }) diff --git a/src/views/scrollable-list-view.tsx b/src/views/scrollable-list-view.tsx index 8636d0a..23d535a 100644 --- a/src/views/scrollable-list-view.tsx +++ b/src/views/scrollable-list-view.tsx @@ -1,57 +1,77 @@ // A simple implementation of an efficient scrolling list view which // renders only items within the viewport + a couple extra items. -import {h, Component, JSX} from 'preact' +import {h, JSX} from 'preact' +import {useState, useCallback, useRef, useMemo, useEffect} from 'preact/hooks' export interface ListItem { size: number } +interface RangeResult { + firstVisibleIndex: number + lastVisibleIndex: number + invisiblePrefixSize: number +} + interface ScrollableListViewProps { items: ListItem[] axis: 'x' | 'y' renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[] className?: string + initialIndexInView?: number | null } -interface ScrollableListViewState { - firstVisibleIndex: number | null - lastVisibleIndex: number | null - invisiblePrefixSize: number | null - viewportSize: number | null - cachedTotalSize: number -} +export const ScrollableListView = ({ + items, + axis, + renderItems, + className, + initialIndexInView, +}: ScrollableListViewProps) => { + const [viewportSize, setViewportSize] = useState(null) + const [viewportScrollOffset, setViewportScrollOffset] = useState(0) -export class ScrollableListView extends Component< - ScrollableListViewProps, - ScrollableListViewState -> { - constructor(props: ScrollableListViewProps) { - super(props) - this.state = { - firstVisibleIndex: null, - lastVisibleIndex: null, - invisiblePrefixSize: null, - viewportSize: null, - cachedTotalSize: props.items.reduce((a, b) => a + b.size, 0), + const viewportRef = useRef(null) + + const widthOrHeight = axis === 'x' ? 'width' : 'height' + const leftOrTop = axis === 'x' ? 'left' : 'top' + const scrollLeftOrScrollTop = axis === 'x' ? 'scrollLeft' : 'scrollTop' + + // This is kind of a weird hack, but I'm not sure what the better of doing something like this is. + const offset = initialIndexInView + ? items.reduce((a, b, i) => (i < initialIndexInView ? a + b.size : a), 0) + : 0 + const initialScroll = useRef(offset) + + const viewportCallback = useCallback( + (viewport: HTMLDivElement | null) => { + if (viewport) { + requestAnimationFrame(() => { + setViewportSize(viewport.getBoundingClientRect()[widthOrHeight]) + if (initialScroll.current != null) { + console.log('executing initial scroll to ', initialScroll.current) + viewport.scrollTo({[leftOrTop]: initialScroll.current}) + initialScroll.current = null + } + }) + } else { + setViewportSize(null) + } + viewportRef.current = viewport + }, + [setViewportSize, widthOrHeight, leftOrTop], + ) + + const rangeResult: RangeResult | null = useMemo(() => { + if (viewportRef.current == null || viewportSize == null || viewportScrollOffset == null) { + return null } - } - - private viewport: HTMLDivElement | null = null - private viewportRef = (viewport: Element | null) => { - this.viewport = (viewport as HTMLDivElement) || null - } - - private recomputeVisibleIndices(props: ScrollableListViewProps) { - if (!this.viewport) return - const {items} = props - - const viewportSize = this.viewport.getBoundingClientRect().height // We render items up to a quarter viewport height outside of the // viewport both above and below to prevent flickering. - const minY = this.viewport.scrollTop - viewportSize / 4 - const maxY = this.viewport.scrollTop + viewportSize + viewportSize / 4 + const minY = viewportScrollOffset - viewportSize / 4 + const maxY = viewportScrollOffset + viewportSize + viewportSize / 4 let total = 0 let invisiblePrefixSize = 0 @@ -77,62 +97,54 @@ export class ScrollableListView extends Component< } const lastVisibleIndex = Math.min(i, items.length - 1) - this.setState({invisiblePrefixSize, firstVisibleIndex, lastVisibleIndex}) - } - private pendingScroll = 0 - public scrollIndexIntoView(index: number) { - this.pendingScroll = this.props.items.reduce((sum, cur, i) => { - if (i >= index) return sum - return sum + cur.size - }, 0) - } - private applyPendingScroll() { - if (!this.viewport) return - - const leftOrTop = this.props.axis === 'y' ? 'top' : 'left' - this.viewport.scrollTo({ - [leftOrTop]: this.pendingScroll, - }) - } - - componentWillReceiveProps(nextProps: ScrollableListViewProps) { - if (this.props.items !== nextProps.items) { - this.recomputeVisibleIndices(nextProps) + return { + firstVisibleIndex, + lastVisibleIndex, + invisiblePrefixSize, } - } + }, [viewportSize, viewportScrollOffset, items]) - componentDidMount() { - this.applyPendingScroll() - this.recomputeVisibleIndices(this.props) - window.addEventListener('resize', this.onWindowResize) - } + const totalSize = useMemo(() => items.reduce((a, b) => a + b.size, 0), [items]) - componentWillUnmount() { - window.removeEventListener('resize', this.onWindowResize) - } + const onViewportScroll = useCallback(() => { + if (viewportRef.current != null) { + setViewportScrollOffset(viewportRef.current[scrollLeftOrScrollTop]) + } + }, [scrollLeftOrScrollTop]) - onWindowResize = () => { - this.recomputeVisibleIndices(this.props) - } + useEffect(() => { + const resizeListener = () => { + if (viewportRef.current != null) { + setViewportSize(viewportRef.current.getBoundingClientRect()[widthOrHeight]) + } + } - onViewportScroll = (ev: UIEvent) => { - this.recomputeVisibleIndices(this.props) - } + window.addEventListener('resize', resizeListener) + return () => { + window.removeEventListener('resize', resizeListener) + } + }, [widthOrHeight]) - render() { - const {cachedTotalSize, firstVisibleIndex, lastVisibleIndex, invisiblePrefixSize} = this.state + const visibleItems = useMemo(() => { + return rangeResult + ? renderItems(rangeResult.firstVisibleIndex, rangeResult.lastVisibleIndex) + : null + }, [renderItems, rangeResult]) + const content = useMemo(() => { return ( -
-
-
- {firstVisibleIndex != null && - lastVisibleIndex != null && - this.props.renderItems(firstVisibleIndex, lastVisibleIndex)} -
+
+
+ {visibleItems}
) - } + }, [rangeResult?.invisiblePrefixSize, visibleItems, totalSize]) + + return ( +
+ {content} +
+ ) } diff --git a/src/views/search-view.tsx b/src/views/search-view.tsx new file mode 100644 index 0000000..c63c8b5 --- /dev/null +++ b/src/views/search-view.tsx @@ -0,0 +1,146 @@ +import {StyleSheet, css} from 'aphrodite' +import {h} from 'preact' +import {useCallback, useRef, useEffect} from 'preact/hooks' +import {memo} from 'preact/compat' +import {Sizes, Colors, FontSize} from './style' + +function stopPropagation(ev: Event) { + ev.stopPropagation() +} + +interface SearchViewProps { + searchQuery: string + searchIsActive: boolean + + setSearchQuery: (query: string | null) => void + setSearchIsActive: (active: boolean) => void +} + +export const SearchView = memo( + ({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => { + const onInput = useCallback( + (ev: Event) => { + const value = (ev.target as HTMLInputElement).value + setSearchQuery(value) + }, + [setSearchQuery], + ) + + const inputRef = useRef(null) + + const onKeyDown = useCallback( + (ev: KeyboardEvent) => { + ev.stopPropagation() + + // Hitting Esc should close the search box + if (ev.key === 'Escape') { + setSearchIsActive(false) + } + }, + [setSearchIsActive], + ) + + useEffect(() => { + const onWindowKeyDown = (ev: KeyboardEvent) => { + // Cmd+F or Ctrl+F open the search box + if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) { + // Prevent the browser's search menu from appearing + ev.preventDefault() + + if (inputRef.current) { + // If the search box is already open, then re-select it. + inputRef.current.select() + } else { + setSearchIsActive(true) + } + } + } + + window.addEventListener('keydown', onWindowKeyDown) + return () => { + window.removeEventListener('keydown', onWindowKeyDown) + } + }, [setSearchIsActive]) + + const focusInput = useCallback((node: HTMLInputElement | null) => { + if (node) { + requestAnimationFrame(() => { + node.select() + }) + } + inputRef.current = node + }, []) + + const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive]) + + if (!searchIsActive) return null + + return ( +
+ 🔍 + + + + + +
+ ) + }, +) + +const style = StyleSheet.create({ + searchView: { + position: 'absolute', + top: 0, + right: 10, + height: Sizes.TOOLBAR_HEIGHT, + width: 150, + borderWidth: 2, + borderColor: Colors.BLACK, + borderStyle: 'solid', + fontSize: FontSize.LABEL, + boxSizing: 'border-box', + background: Colors.DARK_GRAY, + color: Colors.WHITE, + display: 'flex', + }, + input: { + border: 'none', + background: 'none', + fontSize: FontSize.LABEL, + flex: 1, + color: Colors.WHITE, + ':focus': { + border: 'none', + outline: 'none', + }, + '::selection': { + color: Colors.WHITE, + background: Colors.DARK_BLUE, + }, + }, + icon: { + display: 'inline-block', + verticalAlign: 'middle', + paddingTop: '0px', + margin: '0 2px 0 4px', + }, +}) diff --git a/src/views/toolbar.tsx b/src/views/toolbar.tsx index b9efe5b..26e95da 100644 --- a/src/views/toolbar.tsx +++ b/src/views/toolbar.tsx @@ -108,6 +108,19 @@ function ToolbarCenterContent(props: ToolbarProps): JSX.Element { } }, [setProfileSelectShown]) + useEffect(() => { + const onWindowKeyPress = (ev: KeyboardEvent) => { + if (ev.key === 't') { + ev.preventDefault() + setProfileSelectShown(true) + } + } + window.addEventListener('keypress', onWindowKeyPress) + return () => { + window.removeEventListener('keypress', onWindowKeyPress) + } + }, [setProfileSelectShown]) + if (activeProfileState && profileGroup && profiles) { if (profileGroup.profiles.length === 1) { return {activeProfileState.profile.getName()}