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.
This commit is contained in:
Jamie Wong 2020-07-13 22:04:19 -07:00 committed by GitHub
parent 9ed1eb192c
commit 668bb032ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 545 additions and 254 deletions

View File

@ -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<ProfileGroup>('setProfileGroup')
// Set the index into the profile group to view
export const setProfileIndexToView = actionCreator<number>('setProfileIndexToView')
export const setGLCanvas = actionCreator<HTMLCanvasElement | null>('setGLCanvas')
// Set which top-level view should be displayed
export const setViewMode = actionCreator<ViewMode>('setViewMode')
// Set whether or not recursion should be flattened when viewing flamegraphs
export const setFlattenRecursion = actionCreator<boolean>('setFlattenRecursion')
// Set whether a file drag is currently active. Used to indicate that the
// application is a valid drop target.
export const setSearchQuery = actionCreator<string>('setSearchQuery')
export const setSearchIsActive = actionCreator<boolean>('setSearchIsActive')
export const setDragActive = actionCreator<boolean>('setDragActive')
// Set whether the application is currently in a loading state. Used to
// display a loading progress bar.
export const setLoading = actionCreator<boolean>('setLoading')
// Set whether the application is in an errored state.
export const setError = actionCreator<boolean>('setError')
// Set whether parameters defined by the URL encoded k=v pairs after the # in the URL
export const setHashParams = actionCreator<HashParams>('setHashParams')
export namespace sandwichView {
// Set the table sorting method used for the sandwich view.
export const setTableSortMethod = actionCreator<SortMethod>('sandwichView.setTableSortMethod')
export const setSelectedFrame = actionCreatorWithIndex<Frame | null>(

View File

@ -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<App
viewMode: setter<ViewMode>(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART),
searchQuery: setter<string>(actions.setSearchQuery, ''),
searchIsActive: setter<boolean>(actions.setSearchIsActive, false),
glCanvas: setter<HTMLCanvasElement | null>(actions.setGLCanvas, null),
dragActive: setter<boolean>(actions.setDragActive, false),

View File

@ -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

View File

@ -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<SortIconProps, {}> {
}
}
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(<span className={highlightedClassName}>{text.slice(range[0], range[1])}</span>)
last = range[1]
}
spans.push(text.slice(last))
return <span>{spans}</span>
}
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) => {
</td>
<td title={frame.file} className={css(style.textCell)}>
<ColorChit color={getCSSColorForFrame(frame)} />
{frame.name}
{matchedRanges
? highlightRanges(
frame.name,
matchedRanges,
css(style.matched, selected && style.matchedSelected),
)
: frame.name}
</td>
</tr>
)
@ -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<ScrollableListView | null>(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 <table className={css(style.tableView)}>{rows}</table>
},
[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 (
<div className={css(commonStyle.vbox, style.profileTableView)}>
<table className={css(style.tableView)}>
<thead className={css(style.tableHeader)}>
<tr>
<th className={css(style.numericCell)} onClick={onTotalClick}>
<SortIcon
activeDirection={sortMethod.field === SortField.TOTAL ? sortMethod.direction : null}
/>
Total
</th>
<th className={css(style.numericCell)} onClick={onSelfClick}>
<SortIcon
activeDirection={sortMethod.field === SortField.SELF ? sortMethod.direction : null}
/>
Self
</th>
<th className={css(style.textCell)} onClick={onSymbolNameClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
}
/>
Symbol Name
</th>
</tr>
</thead>
</table>
<ScrollableListView
ref={listViewCallback}
axis={'y'}
items={listItems}
className={css(style.scrollView)}
renderItems={renderItems}
/>
</div>
)
})
if (rows.length === 0) {
if (searchIsActive) {
rows.push(
<tr>
<td className={css(style.emptyState)}>
No symbol names match query "{searchQuery}".
</td>
</tr>,
)
} else {
rows.push(
<tr>
<td className={css(style.emptyState)}>No symbols found.</td>
</tr>,
)
}
}
return <table className={css(style.tableView)}>{rows}</table>
},
[
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 (
<div className={css(commonStyle.vbox, style.profileTableView)}>
<table className={css(style.tableView)}>
<thead className={css(style.tableHeader)}>
<tr>
<th className={css(style.numericCell)} onClick={onTotalClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.TOTAL ? sortMethod.direction : null
}
/>
Total
</th>
<th className={css(style.numericCell)} onClick={onSelfClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.SELF ? sortMethod.direction : null
}
/>
Self
</th>
<th className={css(style.textCell)} onClick={onSymbolNameClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
}
/>
Symbol Name
</th>
</tr>
</thead>
</table>
<ScrollableListView
axis={'y'}
items={listItems}
className={css(style.scrollView)}
renderItems={renderItems}
initialIndexInView={
selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame)
}
/>
</div>
)
},
)
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 (
<ProfileTableView
@ -381,6 +461,8 @@ export const ProfileTableViewContainer = memo((ownProps: ProfileTableViewContain
sortMethod={tableSortMethod}
setSelectedFrame={setSelectedFrame}
setSortMethod={setSortMethod}
searchIsActive={searchIsActive}
searchQuery={searchQuery}
/>
)
})

View File

@ -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<SandwichViewProps> {
@ -39,7 +45,13 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
}
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<SandwichViewProps> {
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
<div className={css(style.tableView)}>
<ProfileTableViewContainer activeProfileState={this.props.activeProfileState} />
<SearchView
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchIsActive={searchIsActive}
setSearchIsActive={setSearchIsActive}
/>
</div>
{flamegraphViews}
</div>
@ -81,6 +99,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
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 (
<SandwichView
activeProfileState={activeProfileState}
@ -148,6 +170,10 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
setSelectedFrame={setSelectedFrame}
selectedFrame={callerCallee ? callerCallee.selectedFrame : null}
profileIndex={index}
searchQuery={useAppSelector(state => state.searchQuery, [])}
setSearchQuery={useActionCreator(setSearchQuery, [])}
searchIsActive={useAppSelector(state => state.searchIsActive, [])}
setSearchIsActive={useActionCreator(setSearchIsActive, [])}
/>
)
})

View File

@ -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<number | null>(null)
const [viewportScrollOffset, setViewportScrollOffset] = useState<number>(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<HTMLDivElement | null>(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<number | null>(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 (
<div className={this.props.className} ref={this.viewportRef} onScroll={this.onViewportScroll}>
<div style={{height: cachedTotalSize}}>
<div style={{transform: `translateY(${invisiblePrefixSize}px)`}}>
{firstVisibleIndex != null &&
lastVisibleIndex != null &&
this.props.renderItems(firstVisibleIndex, lastVisibleIndex)}
</div>
<div style={{height: totalSize}}>
<div style={{transform: `translateY(${rangeResult?.invisiblePrefixSize || 0}px)`}}>
{visibleItems}
</div>
</div>
)
}
}, [rangeResult?.invisiblePrefixSize, visibleItems, totalSize])
return (
<div className={className} ref={viewportCallback} onScroll={onViewportScroll}>
{content}
</div>
)
}

146
src/views/search-view.tsx Normal file
View File

@ -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<HTMLInputElement | null>(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 (
<div className={css(style.searchView)}>
<span className={css(style.icon)}>🔍</span>
<input
className={css(style.input)}
value={searchQuery}
onInput={onInput}
onKeyDown={onKeyDown}
onKeyUp={stopPropagation}
onKeyPress={stopPropagation}
ref={focusInput}
/>
<svg
onClick={close}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.99999 4.16217L11.6427 10.8048M11.6427 4.16217L4.99999 10.8048"
stroke="#BDBDBD"
/>
</svg>
</div>
)
},
)
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',
},
})

View File

@ -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 <Fragment>{activeProfileState.profile.getName()}</Fragment>