Provide prev/next buttons to cycle through search results, make search results more visually prominent (#304)
This PR addresses two key pieces of feedback provided on search in #38 1. Make the search results more visually prominent 2. Make it easier to find the matches by having some way of jumping to next For the visual prominence facet, I switched from yellow outlines to orange backgrounds. |Before|After| |-|-| ||| For the easy identification portion, I added prev/next buttons to each view, which can also be operated by hitting Enter for next or Shift+Enter for previous. 
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// This file contains a collection of classes which make it easier to perform
|
||||
// batch rendering of Canvas2D primitives. The advantage of this over just doing
|
||||
// ctx.beginPath() ... ctx.rect(...) ... ctx.endPath() is that you can construct
|
||||
// several different batch renderers are the same time, then decide on their
|
||||
// paint order at the end.
|
||||
//
|
||||
// See FlamechartPanZoomView.renderOverlays for an example of how this is used.
|
||||
|
||||
export interface TextArgs {
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export class BatchCanvasTextRenderer {
|
||||
private argsBatch: TextArgs[] = []
|
||||
|
||||
text(args: TextArgs) {
|
||||
this.argsBatch.push(args)
|
||||
}
|
||||
|
||||
fill(ctx: CanvasRenderingContext2D, color: string) {
|
||||
if (this.argsBatch.length === 0) return
|
||||
ctx.fillStyle = color
|
||||
for (let args of this.argsBatch) {
|
||||
ctx.fillText(args.text, args.x, args.y)
|
||||
}
|
||||
this.argsBatch = []
|
||||
}
|
||||
}
|
||||
|
||||
export interface RectArgs {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export class BatchCanvasRectRenderer {
|
||||
private argsBatch: RectArgs[] = []
|
||||
|
||||
rect(args: RectArgs) {
|
||||
this.argsBatch.push(args)
|
||||
}
|
||||
|
||||
private drawPath(ctx: CanvasRenderingContext2D) {
|
||||
ctx.beginPath()
|
||||
for (let args of this.argsBatch) {
|
||||
ctx.rect(args.x, args.y, args.w, args.h)
|
||||
}
|
||||
ctx.closePath()
|
||||
this.argsBatch = []
|
||||
}
|
||||
|
||||
fill(ctx: CanvasRenderingContext2D, color: string) {
|
||||
if (this.argsBatch.length === 0) return
|
||||
ctx.fillStyle = color
|
||||
this.drawPath(ctx)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
stroke(ctx: CanvasRenderingContext2D, color: string, lineWidth: number) {
|
||||
if (this.argsBatch.length === 0) return
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = lineWidth
|
||||
this.drawPath(ctx)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -1,7 +1,7 @@
|
||||
import {Frame, CallTreeNode} from './profile'
|
||||
|
||||
import {lastOf} from './utils'
|
||||
import {clamp} from './math'
|
||||
import {clamp, Rect, Vec2} from './math'
|
||||
|
||||
export interface FlamechartFrame {
|
||||
node: CallTreeNode
|
||||
@@ -90,6 +90,27 @@ export class Flamechart {
|
||||
return clamp(viewportWidth, minWidth, maxWidth)
|
||||
}
|
||||
|
||||
// Given a desired config-space viewport rectangle, clamp the rectangle so
|
||||
// that it fits within the given flamechart. This prevents the viewport from
|
||||
// extending past the bounds of the flamechart or zooming in too far.
|
||||
getClampedConfigSpaceViewportRect({
|
||||
configSpaceViewportRect,
|
||||
renderInverted,
|
||||
}: {
|
||||
configSpaceViewportRect: Rect
|
||||
renderInverted?: boolean
|
||||
}) {
|
||||
const configSpaceSize = new Vec2(this.getTotalWeight(), this.getLayers().length)
|
||||
const width = this.getClampedViewportWidth(configSpaceViewportRect.size.x)
|
||||
const size = configSpaceViewportRect.size.withX(width)
|
||||
const origin = Vec2.clamp(
|
||||
configSpaceViewportRect.origin,
|
||||
new Vec2(0, renderInverted ? 0 : -1),
|
||||
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
|
||||
)
|
||||
return new Rect(origin, configSpaceViewportRect.size.withX(width))
|
||||
}
|
||||
|
||||
constructor(private source: FlamechartDataSource) {
|
||||
const stack: FlamechartFrame[] = []
|
||||
const openFrame = (node: CallTreeNode, value: number) => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {Profile, Frame, CallTreeNode} from './profile'
|
||||
import {FuzzyMatch, fuzzyMatchStrings} from './fuzzy-find'
|
||||
import {Flamechart, FlamechartFrame} from './flamechart'
|
||||
import {Rect, Vec2} from './math'
|
||||
|
||||
export enum FlamechartType {
|
||||
CHRONO_FLAME_CHART,
|
||||
LEFT_HEAVY_FLAME_GRAPH,
|
||||
}
|
||||
|
||||
// A utility class for storing cached search results to avoid recomputation when
|
||||
// the search results & profile did not change.
|
||||
export class ProfileSearchResults {
|
||||
constructor(readonly profile: Profile, readonly searchQuery: string) {}
|
||||
|
||||
private matches: Map<Frame, FuzzyMatch> | null = null
|
||||
getMatchForFrame(frame: Frame): FuzzyMatch | null {
|
||||
if (!this.matches) {
|
||||
this.matches = new Map()
|
||||
this.profile.forEachFrame(frame => {
|
||||
const match = fuzzyMatchStrings(frame.name, this.searchQuery)
|
||||
if (match == null) return
|
||||
this.matches!.set(frame, match)
|
||||
})
|
||||
}
|
||||
return this.matches.get(frame) || null
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlamechartSearchMatch {
|
||||
configSpaceBounds: Rect
|
||||
node: CallTreeNode
|
||||
}
|
||||
|
||||
interface CachedFlamechartResult {
|
||||
matches: FlamechartSearchMatch[]
|
||||
indexForNode: Map<CallTreeNode, number>
|
||||
}
|
||||
|
||||
export class FlamechartSearchResults {
|
||||
constructor(readonly flamechart: Flamechart, readonly profileResults: ProfileSearchResults) {}
|
||||
|
||||
private matches: CachedFlamechartResult | null = null
|
||||
private getResults(): CachedFlamechartResult {
|
||||
if (this.matches == null) {
|
||||
const matches: FlamechartSearchMatch[] = []
|
||||
const indexForNode = new Map<CallTreeNode, number>()
|
||||
const visit = (frame: FlamechartFrame, depth: number) => {
|
||||
const {node} = frame
|
||||
if (this.profileResults.getMatchForFrame(node.frame)) {
|
||||
const configSpaceBounds = new Rect(
|
||||
new Vec2(frame.start, depth),
|
||||
new Vec2(frame.end - frame.start, 1),
|
||||
)
|
||||
indexForNode.set(node, matches.length)
|
||||
matches.push({configSpaceBounds, node})
|
||||
}
|
||||
|
||||
frame.children.forEach(child => {
|
||||
visit(child, depth + 1)
|
||||
})
|
||||
}
|
||||
|
||||
const layers = this.flamechart.getLayers()
|
||||
if (layers.length > 0) {
|
||||
layers[0].forEach(frame => visit(frame, 0))
|
||||
}
|
||||
|
||||
this.matches = {matches, indexForNode}
|
||||
}
|
||||
return this.matches
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.getResults().matches.length
|
||||
}
|
||||
|
||||
indexOf(node: CallTreeNode): number | null {
|
||||
const result = this.getResults().indexForNode.get(node)
|
||||
return result === undefined ? null : result
|
||||
}
|
||||
|
||||
at(index: number): FlamechartSearchMatch {
|
||||
const matches = this.getResults().matches
|
||||
if (index < 0 || index >= matches.length) {
|
||||
throw new Error(`Index ${index} out of bounds in list of ${matches.length} matches.`)
|
||||
}
|
||||
return matches[index]
|
||||
}
|
||||
}
|
||||
+21
-10
@@ -116,6 +116,13 @@ export class Profile {
|
||||
|
||||
protected frames = new KeyedSet<Frame>()
|
||||
|
||||
// Profiles store two call-trees.
|
||||
//
|
||||
// The "append order" call tree is the one in which nodes are ordered in
|
||||
// whatever order they were appended to their parent.
|
||||
//
|
||||
// The "grouped" call tree is one in which each node has at most one child per
|
||||
// frame. Nodes are ordered in decreasing order of weight
|
||||
protected appendOrderCalltreeRoot = new CallTreeNode(Frame.root, null)
|
||||
protected groupedCalltreeRoot = new CallTreeNode(Frame.root, null)
|
||||
|
||||
@@ -169,6 +176,17 @@ export class Profile {
|
||||
return this.totalNonIdleWeight
|
||||
}
|
||||
|
||||
// This is private because it should only be called in the ProfileBuilder
|
||||
// classes. Once a Profile instance has been constructed, it should be treated
|
||||
// as immutable.
|
||||
protected sortGroupedCallTree() {
|
||||
function visit(node: CallTreeNode) {
|
||||
node.children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight()))
|
||||
node.children.forEach(visit)
|
||||
}
|
||||
visit(this.groupedCalltreeRoot)
|
||||
}
|
||||
|
||||
forEachCallGrouped(
|
||||
openFrame: (node: CallTreeNode, value: number) => void,
|
||||
closeFrame: (node: CallTreeNode, value: number) => void,
|
||||
@@ -180,10 +198,7 @@ export class Profile {
|
||||
|
||||
let childTime = 0
|
||||
|
||||
const children = [...node.children]
|
||||
children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight()))
|
||||
|
||||
children.forEach(function (child) {
|
||||
node.children.forEach(function (child) {
|
||||
visit(child, start + childTime)
|
||||
childTime += child.getTotalWeight()
|
||||
})
|
||||
@@ -250,12 +265,6 @@ export class Profile {
|
||||
this.frames.forEach(fn)
|
||||
}
|
||||
|
||||
forEachSample(fn: (sample: CallTreeNode, weight: number) => void) {
|
||||
for (let i = 0; i < this.samples.length; i++) {
|
||||
fn(this.samples[i], this.weights[i])
|
||||
}
|
||||
}
|
||||
|
||||
getProfileWithRecursionFlattened(): Profile {
|
||||
const builder = new CallTreeProfileBuilder()
|
||||
|
||||
@@ -511,6 +520,7 @@ export class StackListProfileBuilder extends Profile {
|
||||
this.totalWeight,
|
||||
this.weights.reduce((a, b) => a + b, 0),
|
||||
)
|
||||
this.sortGroupedCallTree()
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -651,6 +661,7 @@ export class CallTreeProfileBuilder extends Profile {
|
||||
if (this.appendOrderStack.length > 1 || this.groupedOrderStack.length > 1) {
|
||||
throw new Error('Tried to complete profile construction with a non-empty stack')
|
||||
}
|
||||
this.sortGroupedCallTree()
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import {HashParams, getHashParams} from '../lib/hash-params'
|
||||
import {ProfileGroupState, profileGroup} from './profiles-state'
|
||||
import {SortMethod, SortField, SortDirection} from '../views/profile-table-view'
|
||||
import {useSelector} from '../lib/preact-redux'
|
||||
import {Profile} from '../lib/profile'
|
||||
import {FlamechartViewState} from './flamechart-view-state'
|
||||
import {SandwichViewState} from './sandwich-view-state'
|
||||
import {getProfileToView} from './getters'
|
||||
|
||||
export const enum ViewMode {
|
||||
CHRONO_FLAME_CHART,
|
||||
@@ -101,3 +105,30 @@ export function useAppSelector<T>(selector: (t: ApplicationState) => T, cacheArg
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
return useSelector(selector, cacheArgs)
|
||||
}
|
||||
|
||||
export interface ActiveProfileState {
|
||||
profile: Profile
|
||||
index: number
|
||||
chronoViewState: FlamechartViewState
|
||||
leftHeavyViewState: FlamechartViewState
|
||||
sandwichViewState: SandwichViewState
|
||||
}
|
||||
|
||||
export function useActiveProfileState(): ActiveProfileState | null {
|
||||
return useAppSelector(state => {
|
||||
const {profileGroup} = state
|
||||
if (!profileGroup) return null
|
||||
if (profileGroup.indexToView >= profileGroup.profiles.length) return null
|
||||
|
||||
const index = profileGroup.indexToView
|
||||
const profileState = profileGroup.profiles[index]
|
||||
return {
|
||||
...profileGroup.profiles[profileGroup.indexToView],
|
||||
profile: getProfileToView({
|
||||
profile: profileState.profile,
|
||||
flattenRecursion: state.flattenRecursion,
|
||||
}),
|
||||
index: profileGroup.indexToView,
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {h} from 'preact'
|
||||
import {Application, ActiveProfileState} from './application'
|
||||
import {getProfileToView, getCanvasContext} from '../store/getters'
|
||||
import {Application} from './application'
|
||||
import {getCanvasContext} from '../store/getters'
|
||||
import {actions} from '../store/actions'
|
||||
import {useActionCreator} from '../lib/preact-redux'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useAppSelector} from '../store'
|
||||
import {useAppSelector, useActiveProfileState} from '../store'
|
||||
import {ProfileSearchContextProvider} from './search-view'
|
||||
|
||||
const {
|
||||
setLoading,
|
||||
@@ -24,36 +25,21 @@ export const ApplicationContainer = memo(() => {
|
||||
[],
|
||||
)
|
||||
|
||||
const activeProfileState: ActiveProfileState | null = useAppSelector(state => {
|
||||
const {profileGroup} = state
|
||||
if (!profileGroup) return null
|
||||
if (profileGroup.indexToView >= profileGroup.profiles.length) return null
|
||||
|
||||
const index = profileGroup.indexToView
|
||||
const profileState = profileGroup.profiles[index]
|
||||
return {
|
||||
...profileGroup.profiles[profileGroup.indexToView],
|
||||
profile: getProfileToView({
|
||||
profile: profileState.profile,
|
||||
flattenRecursion: state.flattenRecursion,
|
||||
}),
|
||||
index: profileGroup.indexToView,
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Application
|
||||
activeProfileState={activeProfileState}
|
||||
canvasContext={canvasContext}
|
||||
setGLCanvas={useActionCreator(setGLCanvas, [])}
|
||||
setLoading={useActionCreator(setLoading, [])}
|
||||
setError={useActionCreator(setError, [])}
|
||||
setProfileGroup={useActionCreator(setProfileGroup, [])}
|
||||
setDragActive={useActionCreator(setDragActive, [])}
|
||||
setViewMode={useActionCreator(setViewMode, [])}
|
||||
setFlattenRecursion={useActionCreator(setFlattenRecursion, [])}
|
||||
setProfileIndexToView={useActionCreator(setProfileIndexToView, [])}
|
||||
{...appState}
|
||||
/>
|
||||
<ProfileSearchContextProvider>
|
||||
<Application
|
||||
activeProfileState={useActiveProfileState()}
|
||||
canvasContext={canvasContext}
|
||||
setGLCanvas={useActionCreator(setGLCanvas, [])}
|
||||
setLoading={useActionCreator(setLoading, [])}
|
||||
setError={useActionCreator(setError, [])}
|
||||
setProfileGroup={useActionCreator(setProfileGroup, [])}
|
||||
setDragActive={useActionCreator(setDragActive, [])}
|
||||
setViewMode={useActionCreator(setViewMode, [])}
|
||||
setFlattenRecursion={useActionCreator(setFlattenRecursion, [])}
|
||||
setProfileIndexToView={useActionCreator(setProfileIndexToView, [])}
|
||||
{...appState}
|
||||
/>
|
||||
</ProfileSearchContextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,16 +2,14 @@ import {h} from 'preact'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {FileSystemDirectoryEntry} from '../import/file-system-entry'
|
||||
|
||||
import {Profile, ProfileGroup} from '../lib/profile'
|
||||
import {ProfileGroup} from '../lib/profile'
|
||||
import {FontFamily, FontSize, Colors, Duration} from './style'
|
||||
import {importEmscriptenSymbolMap} from '../lib/emscripten'
|
||||
import {SandwichViewContainer} from './sandwich-view'
|
||||
import {saveToFile} from '../lib/file-format'
|
||||
import {ApplicationState, ViewMode, canUseXHR} from '../store'
|
||||
import {ApplicationState, ViewMode, canUseXHR, ActiveProfileState} from '../store'
|
||||
import {StatelessComponent} from '../lib/typed-redux'
|
||||
import {LeftHeavyFlamechartView, ChronoFlamechartView} from './flamechart-view-container'
|
||||
import {SandwichViewState} from '../store/sandwich-view-state'
|
||||
import {FlamechartViewState} from '../store/flamechart-view-state'
|
||||
import {CanvasContext} from '../gl/canvas-context'
|
||||
import {Graphics} from '../gl/graphics'
|
||||
import {Toolbar} from './toolbar'
|
||||
@@ -131,14 +129,6 @@ export class GLCanvas extends StatelessComponent<GLCanvasProps> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActiveProfileState {
|
||||
profile: Profile
|
||||
index: number
|
||||
chronoViewState: FlamechartViewState
|
||||
leftHeavyViewState: FlamechartViewState
|
||||
sandwichViewState: SandwichViewState
|
||||
}
|
||||
|
||||
export type ApplicationProps = ApplicationState & {
|
||||
setGLCanvas: (canvas: HTMLCanvasElement | null) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
|
||||
import {FlamechartWrapper} from './flamechart-wrapper'
|
||||
import {useAppSelector} from '../store'
|
||||
import {h} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
@@ -78,14 +78,9 @@ export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.SANDWICH_CALLEES, index)}
|
||||
{...callerCallee.calleeFlamegraph}
|
||||
// This overrides the setSelectedNode specified in useFlamechartSettesr
|
||||
setSelectedNode={noop}
|
||||
{...callerCallee.calleeFlamegraph}
|
||||
/*
|
||||
* TODO(jlfwong): When implementing search for the sandwich views,
|
||||
* change these flags
|
||||
* */
|
||||
{...useDummySearchProps()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Rect, AffineTransform, Vec2, clamp} from '../lib/math'
|
||||
import {CallTreeNode, Frame} from '../lib/profile'
|
||||
import {CallTreeNode} from '../lib/profile'
|
||||
import {Flamechart, FlamechartFrame} from '../lib/flamechart'
|
||||
import {CanvasContext} from '../gl/canvas-context'
|
||||
import {FlamechartRenderer} from '../gl/flamechart-renderer'
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import {style} from './flamechart-style'
|
||||
import {h, Component} from 'preact'
|
||||
import {css} from 'aphrodite'
|
||||
import {memoizeByReference} from '../lib/utils'
|
||||
import {FuzzyMatch, fuzzyMatchStrings} from '../lib/fuzzy-find'
|
||||
import {ProfileSearchResults} from '../lib/profile-search'
|
||||
import {BatchCanvasTextRenderer, BatchCanvasRectRenderer} from '../lib/canvas-2d-batch-renderers'
|
||||
|
||||
interface FlamechartFrameLabel {
|
||||
configSpaceBounds: Rect
|
||||
@@ -56,8 +56,7 @@ export interface FlamechartPanZoomViewProps {
|
||||
logicalSpaceViewportSize: Vec2
|
||||
setLogicalSpaceViewportSize: (size: Vec2) => void
|
||||
|
||||
searchIsActive: boolean
|
||||
searchQuery: string
|
||||
searchResults: ProfileSearchResults | null
|
||||
}
|
||||
|
||||
export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps, {}> {
|
||||
@@ -173,24 +172,6 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
|
||||
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
|
||||
|
||||
if (this.hoveredLabel) {
|
||||
let color = Colors.DARK_GRAY
|
||||
if (this.props.selectedNode === this.hoveredLabel.node) {
|
||||
color = Colors.DARK_BLUE
|
||||
}
|
||||
|
||||
ctx.lineWidth = 2 * devicePixelRatio
|
||||
ctx.strokeStyle = color
|
||||
|
||||
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
|
||||
ctx.strokeRect(
|
||||
Math.round(physicalViewBounds.left()),
|
||||
Math.round(physicalViewBounds.top()),
|
||||
Math.round(Math.max(0, physicalViewBounds.width())),
|
||||
Math.round(Math.max(0, physicalViewBounds.height())),
|
||||
)
|
||||
}
|
||||
|
||||
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
|
||||
@@ -201,14 +182,12 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
|
||||
const LABEL_PADDING_PX = 5 * window.devicePixelRatio
|
||||
|
||||
const memoizedFuzzyMatch = memoizeByReference((frame: Frame): FuzzyMatch | null => {
|
||||
return fuzzyMatchStrings(frame.name, this.props.searchQuery)
|
||||
})
|
||||
const frameMatchesSearchQuery = (frame: Frame): FuzzyMatch | null => {
|
||||
if (!this.props.searchIsActive) return null
|
||||
if (this.props.searchQuery.length === 0) return null
|
||||
return memoizedFuzzyMatch(frame)
|
||||
}
|
||||
const labelBatch = new BatchCanvasTextRenderer()
|
||||
const fadedLabelBatch = new BatchCanvasTextRenderer()
|
||||
const matchedTextHighlightBatch = new BatchCanvasRectRenderer()
|
||||
const directlySelectedOutlineBatch = new BatchCanvasRectRenderer()
|
||||
const indirectlySelectedOutlineBatch = new BatchCanvasRectRenderer()
|
||||
const matchedFrameBatch = new BatchCanvasRectRenderer()
|
||||
|
||||
const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
|
||||
const width = frame.end - frame.start
|
||||
@@ -244,7 +223,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
}
|
||||
|
||||
if (physicalLabelBounds.width() > minWidthToRender) {
|
||||
const match = frameMatchesSearchQuery(frame.node.frame)
|
||||
const match = this.props.searchResults?.getMatchForFrame(frame.node.frame)
|
||||
|
||||
const trimmedText = trimTextMid(
|
||||
ctx,
|
||||
@@ -262,44 +241,40 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
// actually do the highlighting.
|
||||
let lastEndIndex = 0
|
||||
let left = physicalLabelBounds.left() + LABEL_PADDING_PX
|
||||
ctx.fillStyle = Colors.YELLOW
|
||||
|
||||
ctx.beginPath()
|
||||
const padding = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2 - 2
|
||||
for (let [startIndex, endIndex] of rangesToHighlightInTrimmedText) {
|
||||
left += ctx.measureText(trimmedText.trimmedString.substring(lastEndIndex, startIndex))
|
||||
.width
|
||||
const highlightWidth = ctx.measureText(
|
||||
trimmedText.trimmedString.substring(startIndex, endIndex),
|
||||
).width
|
||||
ctx.rect(
|
||||
left,
|
||||
physicalLabelBounds.top() + padding,
|
||||
highlightWidth,
|
||||
physicalViewSpaceFrameHeight - 2 * padding,
|
||||
left += cachedMeasureTextWidth(
|
||||
ctx,
|
||||
trimmedText.trimmedString.substring(lastEndIndex, startIndex),
|
||||
)
|
||||
const highlightWidth = cachedMeasureTextWidth(
|
||||
ctx,
|
||||
trimmedText.trimmedString.substring(startIndex, endIndex),
|
||||
)
|
||||
matchedTextHighlightBatch.rect({
|
||||
x: left,
|
||||
y: physicalLabelBounds.top() + padding,
|
||||
w: highlightWidth,
|
||||
h: physicalViewSpaceFrameHeight - 2 * padding,
|
||||
})
|
||||
|
||||
left += highlightWidth
|
||||
lastEndIndex = endIndex
|
||||
}
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Note that this is specifying the position of the starting text
|
||||
// baseline.
|
||||
if (this.props.searchIsActive && this.props.searchQuery.length > 0 && !match) {
|
||||
ctx.fillStyle = Colors.LIGHT_GRAY
|
||||
} else {
|
||||
ctx.fillStyle = Colors.DARK_GRAY
|
||||
}
|
||||
ctx.fillText(
|
||||
trimmedText.trimmedString,
|
||||
physicalLabelBounds.left() + LABEL_PADDING_PX,
|
||||
Math.round(
|
||||
const batch = this.props.searchResults != null && !match ? fadedLabelBatch : labelBatch
|
||||
batch.text({
|
||||
text: trimmedText.trimmedString,
|
||||
|
||||
// This is specifying the position of the starting text baseline.
|
||||
x: physicalLabelBounds.left() + LABEL_PADDING_PX,
|
||||
y: Math.round(
|
||||
physicalLabelBounds.bottom() -
|
||||
(physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
for (let child of frame.children) {
|
||||
@@ -307,19 +282,14 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
}
|
||||
}
|
||||
|
||||
for (let frame of this.props.flamechart.getLayers()[0] || []) {
|
||||
renderFrameLabelAndChildren(frame)
|
||||
}
|
||||
|
||||
const frameOutlineWidth = 2 * window.devicePixelRatio
|
||||
ctx.strokeStyle = Colors.PALE_DARK_BLUE
|
||||
ctx.lineWidth = frameOutlineWidth
|
||||
const minConfigSpaceWidthToRenderOutline = (
|
||||
configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
|
||||
).x
|
||||
|
||||
const renderSpecialFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
|
||||
if (!this.props.selectedNode && !this.props.searchIsActive) return
|
||||
if (!this.props.selectedNode && this.props.searchResults == null) return
|
||||
const width = frame.end - frame.start
|
||||
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
|
||||
const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
|
||||
@@ -330,49 +300,68 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
|
||||
|
||||
if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
|
||||
let outlineColor: string | null = null
|
||||
if (this.props.searchResults?.getMatchForFrame(frame.node.frame)) {
|
||||
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
|
||||
matchedFrameBatch.rect({
|
||||
x: Math.round(physicalRectBounds.left() + frameOutlineWidth / 2),
|
||||
y: Math.round(physicalRectBounds.top() + frameOutlineWidth / 2),
|
||||
w: Math.round(Math.max(0, physicalRectBounds.width() - frameOutlineWidth)),
|
||||
h: Math.round(Math.max(0, physicalRectBounds.height() - frameOutlineWidth)),
|
||||
})
|
||||
}
|
||||
|
||||
if (this.props.selectedNode != null && frame.node.frame === this.props.selectedNode.frame) {
|
||||
if (frame.node === this.props.selectedNode) {
|
||||
outlineColor = Colors.DARK_BLUE
|
||||
} else if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
|
||||
outlineColor = Colors.PALE_DARK_BLUE
|
||||
}
|
||||
} else {
|
||||
if (frameMatchesSearchQuery(frame.node.frame)) {
|
||||
outlineColor = Colors.YELLOW
|
||||
}
|
||||
}
|
||||
let batch =
|
||||
frame.node === this.props.selectedNode
|
||||
? directlySelectedOutlineBatch
|
||||
: indirectlySelectedOutlineBatch
|
||||
|
||||
if (outlineColor != null) {
|
||||
if (ctx.strokeStyle !== outlineColor) {
|
||||
// If the outline color changed, stroke the existing path
|
||||
// constructed by previous ctx.rect calls, then update the stroke
|
||||
// style before drawing the next one.
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = outlineColor
|
||||
}
|
||||
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
|
||||
ctx.rect(
|
||||
Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
|
||||
Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
|
||||
Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
|
||||
Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
|
||||
)
|
||||
batch.rect({
|
||||
x: Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
|
||||
y: Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
|
||||
w: Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
|
||||
h: Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (let child of frame.children) {
|
||||
renderSpecialFrameOutlines(child, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
for (let frame of this.props.flamechart.getLayers()[0] || []) {
|
||||
renderSpecialFrameOutlines(frame)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
for (let frame of this.props.flamechart.getLayers()[0] || []) {
|
||||
renderFrameLabelAndChildren(frame)
|
||||
}
|
||||
|
||||
matchedFrameBatch.fill(ctx, Colors.ORANGE)
|
||||
matchedTextHighlightBatch.fill(ctx, Colors.YELLOW)
|
||||
fadedLabelBatch.fill(ctx, Colors.LIGHT_GRAY)
|
||||
labelBatch.fill(ctx, Colors.BLACK)
|
||||
indirectlySelectedOutlineBatch.stroke(ctx, Colors.PALE_DARK_BLUE, frameOutlineWidth)
|
||||
directlySelectedOutlineBatch.stroke(ctx, Colors.DARK_BLUE, frameOutlineWidth)
|
||||
|
||||
if (this.hoveredLabel) {
|
||||
let color = Colors.DARK_GRAY
|
||||
if (this.props.selectedNode === this.hoveredLabel.node) {
|
||||
color = Colors.DARK_BLUE
|
||||
}
|
||||
|
||||
ctx.lineWidth = 2 * devicePixelRatio
|
||||
ctx.strokeStyle = color
|
||||
|
||||
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
|
||||
ctx.strokeRect(
|
||||
Math.round(physicalViewBounds.left()),
|
||||
Math.round(physicalViewBounds.top()),
|
||||
Math.round(Math.max(0, physicalViewBounds.width())),
|
||||
Math.round(Math.max(0, physicalViewBounds.height())),
|
||||
)
|
||||
}
|
||||
|
||||
this.renderTimeIndicators()
|
||||
}
|
||||
@@ -754,10 +743,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
if (this.props.flamechart !== nextProps.flamechart) {
|
||||
this.hoveredLabel = null
|
||||
this.renderCanvas()
|
||||
} else if (
|
||||
this.props.searchQuery !== nextProps.searchQuery ||
|
||||
this.props.searchIsActive !== nextProps.searchIsActive
|
||||
) {
|
||||
} else if (this.props.searchResults !== nextProps.searchResults) {
|
||||
this.renderCanvas()
|
||||
} else if (this.props.selectedNode !== nextProps.selectedNode) {
|
||||
this.renderCanvas()
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {memo} from 'preact/compat'
|
||||
import {useContext, useMemo, useCallback} from 'preact/hooks'
|
||||
import {SearchView, ProfileSearchContext} from './search-view'
|
||||
import {
|
||||
FlamechartSearchMatch,
|
||||
FlamechartSearchResults,
|
||||
ProfileSearchResults,
|
||||
} from '../lib/profile-search'
|
||||
import {Rect, Vec2} from '../lib/math'
|
||||
import {h, createContext, ComponentChildren} from 'preact'
|
||||
import {Flamechart} from '../lib/flamechart'
|
||||
import {CallTreeNode} from '../lib/profile'
|
||||
|
||||
export const FlamechartSearchContext = createContext<FlamechartSearchData | null>(null)
|
||||
|
||||
export interface FlamechartSearchProps {
|
||||
flamechart: Flamechart
|
||||
selectedNode: CallTreeNode | null
|
||||
setSelectedNode: (node: CallTreeNode | null) => void
|
||||
configSpaceViewportRect: Rect
|
||||
setConfigSpaceViewportRect: (rect: Rect) => void
|
||||
children: ComponentChildren
|
||||
}
|
||||
|
||||
interface FlamechartSearchData {
|
||||
results: FlamechartSearchResults | null
|
||||
flamechart: Flamechart
|
||||
selectedNode: CallTreeNode | null
|
||||
setSelectedNode: (node: CallTreeNode | null) => void
|
||||
configSpaceViewportRect: Rect
|
||||
setConfigSpaceViewportRect: (rect: Rect) => void
|
||||
}
|
||||
|
||||
export const FlamechartSearchContextProvider = ({
|
||||
flamechart,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
configSpaceViewportRect,
|
||||
setConfigSpaceViewportRect,
|
||||
children,
|
||||
}: FlamechartSearchProps) => {
|
||||
const profileSearchResults: ProfileSearchResults | null = useContext(ProfileSearchContext)
|
||||
const flamechartSearchResults: FlamechartSearchResults | null = useMemo(() => {
|
||||
if (profileSearchResults == null) {
|
||||
return null
|
||||
}
|
||||
return new FlamechartSearchResults(flamechart, profileSearchResults)
|
||||
}, [flamechart, profileSearchResults])
|
||||
|
||||
return (
|
||||
<FlamechartSearchContext.Provider
|
||||
value={{
|
||||
results: flamechartSearchResults,
|
||||
flamechart,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
configSpaceViewportRect,
|
||||
setConfigSpaceViewportRect,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FlamechartSearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const FlamechartSearchView = memo(() => {
|
||||
const flamechartData = useContext(FlamechartSearchContext)
|
||||
|
||||
// TODO(jlfwong): This pattern is pretty gross, but I really don't want values
|
||||
// that can be undefined or null.
|
||||
const searchResults = flamechartData == null ? null : flamechartData.results
|
||||
const selectedNode = flamechartData == null ? null : flamechartData.selectedNode
|
||||
const setSelectedNode = flamechartData == null ? null : flamechartData.setSelectedNode
|
||||
const configSpaceViewportRect =
|
||||
flamechartData == null ? null : flamechartData.configSpaceViewportRect
|
||||
const setConfigSpaceViewportRect =
|
||||
flamechartData == null ? null : flamechartData.setConfigSpaceViewportRect
|
||||
const flamechart = flamechartData == null ? null : flamechartData.flamechart
|
||||
|
||||
const numResults = searchResults == null ? null : searchResults.count()
|
||||
const resultIndex: number | null = useMemo(() => {
|
||||
if (searchResults == null) return null
|
||||
if (selectedNode == null) return null
|
||||
return searchResults.indexOf(selectedNode)
|
||||
}, [searchResults, selectedNode])
|
||||
|
||||
const selectAndZoomToMatch = useCallback(
|
||||
(match: FlamechartSearchMatch) => {
|
||||
if (!setSelectedNode) return
|
||||
if (!flamechart) return
|
||||
if (!configSpaceViewportRect) return
|
||||
if (!setConfigSpaceViewportRect) return
|
||||
|
||||
// After the node is selected, we want to set the viewport so that the new
|
||||
// node can be seen clearly.
|
||||
//
|
||||
// TODO(jlfwong): The lack of animation here can be kind of jarring. It
|
||||
// would be nice to have some easier way for people to orient themselves
|
||||
// after the viewport shifted.
|
||||
const configSpaceResultBounds = match.configSpaceBounds
|
||||
|
||||
const viewportRect = new Rect(
|
||||
configSpaceResultBounds.origin.minus(new Vec2(0, 1)),
|
||||
configSpaceResultBounds.size.withY(configSpaceViewportRect.height()),
|
||||
)
|
||||
|
||||
setSelectedNode(match.node)
|
||||
setConfigSpaceViewportRect(
|
||||
flamechart.getClampedConfigSpaceViewportRect({configSpaceViewportRect: viewportRect}),
|
||||
)
|
||||
},
|
||||
[configSpaceViewportRect, setConfigSpaceViewportRect, setSelectedNode, flamechart],
|
||||
)
|
||||
|
||||
const {selectPrev, selectNext} = useMemo(() => {
|
||||
if (numResults == null || numResults === 0 || searchResults == null) {
|
||||
return {selectPrev: () => {}, selectNext: () => {}}
|
||||
}
|
||||
|
||||
return {
|
||||
selectPrev: () => {
|
||||
if (!searchResults?.at) return
|
||||
if (numResults == null || numResults === 0) return
|
||||
|
||||
let index = resultIndex == null ? numResults - 1 : resultIndex - 1
|
||||
if (index < 0) index = numResults - 1
|
||||
const result = searchResults.at(index)
|
||||
selectAndZoomToMatch(result)
|
||||
},
|
||||
|
||||
selectNext: () => {
|
||||
if (!searchResults?.at) return
|
||||
if (numResults == null || numResults === 0) return
|
||||
|
||||
let index = resultIndex == null ? 0 : resultIndex + 1
|
||||
if (index >= numResults) index = 0
|
||||
const result = searchResults.at(index)
|
||||
selectAndZoomToMatch(result)
|
||||
},
|
||||
}
|
||||
}, [numResults, resultIndex, searchResults, searchResults?.at, selectAndZoomToMatch])
|
||||
|
||||
return (
|
||||
<SearchView
|
||||
resultIndex={resultIndex}
|
||||
numResults={numResults}
|
||||
selectPrev={selectPrev}
|
||||
selectNext={selectNext}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -14,12 +14,11 @@ import {
|
||||
createGetCSSColorForFrame,
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {ActiveProfileState} from './application'
|
||||
import {Vec2, Rect} from '../lib/math'
|
||||
import {actions} from '../store/actions'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useAppSelector} from '../store'
|
||||
import {SearchViewProps} from './search-view'
|
||||
import {ActiveProfileState} from '../store'
|
||||
import {FlamechartSearchContextProvider} from './flamechart-search-view'
|
||||
|
||||
interface FlamechartSetters {
|
||||
setLogicalSpaceViewportSize: (logicalSpaceViewportSize: Vec2) => void
|
||||
@@ -66,24 +65,9 @@ export type FlamechartViewProps = {
|
||||
flamechartRenderer: FlamechartRenderer
|
||||
renderInverted: boolean
|
||||
getCSSColorForFrame: (frame: Frame) => string
|
||||
searchIsActive: boolean
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
setSearchIsActive: (active: boolean) => void
|
||||
} & FlamechartSetters &
|
||||
FlamechartViewState
|
||||
|
||||
const {setSearchQuery, setSearchIsActive} = actions
|
||||
|
||||
function useSearchViewProps(): SearchViewProps {
|
||||
return {
|
||||
searchIsActive: useAppSelector(state => state.searchIsActive, []),
|
||||
setSearchQuery: useActionCreator(setSearchQuery, []),
|
||||
searchQuery: useAppSelector(state => state.searchQuery, []),
|
||||
setSearchIsActive: useActionCreator(setSearchIsActive, []),
|
||||
}
|
||||
}
|
||||
|
||||
export const getChronoViewFlamechart = memoizeByShallowEquality(
|
||||
({
|
||||
profile,
|
||||
@@ -143,17 +127,26 @@ export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) =
|
||||
flamechart,
|
||||
})
|
||||
|
||||
const setters = useFlamechartSetters(FlamechartID.CHRONO, index)
|
||||
|
||||
return (
|
||||
<FlamechartView
|
||||
renderInverted={false}
|
||||
<FlamechartSearchContextProvider
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.CHRONO, index)}
|
||||
{...useSearchViewProps()}
|
||||
{...chronoViewState}
|
||||
/>
|
||||
selectedNode={chronoViewState.selectedNode}
|
||||
setSelectedNode={setters.setSelectedNode}
|
||||
configSpaceViewportRect={chronoViewState.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect}
|
||||
>
|
||||
<FlamechartView
|
||||
renderInverted={false}
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...chronoViewState}
|
||||
{...setters}
|
||||
/>
|
||||
</FlamechartSearchContextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -195,16 +188,25 @@ export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerPr
|
||||
flamechart,
|
||||
})
|
||||
|
||||
const setters = useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)
|
||||
|
||||
return (
|
||||
<FlamechartView
|
||||
renderInverted={false}
|
||||
<FlamechartSearchContextProvider
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)}
|
||||
{...useSearchViewProps()}
|
||||
{...leftHeavyViewState}
|
||||
/>
|
||||
selectedNode={leftHeavyViewState.selectedNode}
|
||||
setSelectedNode={setters.setSelectedNode}
|
||||
configSpaceViewportRect={leftHeavyViewState.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect}
|
||||
>
|
||||
<FlamechartView
|
||||
renderInverted={false}
|
||||
flamechart={flamechart}
|
||||
flamechartRenderer={flamechartRenderer}
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...leftHeavyViewState}
|
||||
{...setters}
|
||||
/>
|
||||
</FlamechartSearchContextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {h} from 'preact'
|
||||
import {h, Fragment} from 'preact'
|
||||
import {css} from 'aphrodite'
|
||||
|
||||
import {CallTreeNode} from '../lib/profile'
|
||||
@@ -14,7 +14,8 @@ import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
|
||||
import {Hovertip} from './hovertip'
|
||||
import {FlamechartViewProps} from './flamechart-view-container'
|
||||
import {StatelessComponent} from '../lib/typed-redux'
|
||||
import {SearchView} from './search-view'
|
||||
import {ProfileSearchContext} from './search-view'
|
||||
import {FlamechartSearchView} from './flamechart-search-view'
|
||||
|
||||
export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
|
||||
private configSpaceSize() {
|
||||
@@ -102,28 +103,28 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
|
||||
canvasContext={this.props.canvasContext}
|
||||
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
||||
/>
|
||||
<FlamechartPanZoomView
|
||||
canvasContext={this.props.canvasContext}
|
||||
flamechart={this.props.flamechart}
|
||||
flamechartRenderer={this.props.flamechartRenderer}
|
||||
renderInverted={false}
|
||||
onNodeHover={this.onNodeHover}
|
||||
onNodeSelect={this.onNodeClick}
|
||||
selectedNode={this.props.selectedNode}
|
||||
transformViewport={this.transformViewport}
|
||||
configSpaceViewportRect={this.props.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
||||
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
|
||||
searchQuery={this.props.searchQuery}
|
||||
searchIsActive={this.props.searchIsActive}
|
||||
/>
|
||||
<SearchView
|
||||
searchQuery={this.props.searchQuery}
|
||||
searchIsActive={this.props.searchIsActive}
|
||||
setSearchQuery={this.props.setSearchQuery}
|
||||
setSearchIsActive={this.props.setSearchIsActive}
|
||||
/>
|
||||
<ProfileSearchContext.Consumer>
|
||||
{searchResults => (
|
||||
<Fragment>
|
||||
<FlamechartPanZoomView
|
||||
canvasContext={this.props.canvasContext}
|
||||
flamechart={this.props.flamechart}
|
||||
flamechartRenderer={this.props.flamechartRenderer}
|
||||
renderInverted={false}
|
||||
onNodeHover={this.onNodeHover}
|
||||
onNodeSelect={this.onNodeClick}
|
||||
selectedNode={this.props.selectedNode}
|
||||
transformViewport={this.transformViewport}
|
||||
configSpaceViewportRect={this.props.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
||||
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
|
||||
searchResults={searchResults}
|
||||
/>
|
||||
<FlamechartSearchView />
|
||||
</Fragment>
|
||||
)}
|
||||
</ProfileSearchContext.Consumer>
|
||||
{this.renderTooltip()}
|
||||
{this.props.selectedNode && (
|
||||
<FlamechartDetailView
|
||||
|
||||
@@ -8,30 +8,14 @@ import {noop, formatPercent} from '../lib/utils'
|
||||
import {Hovertip} from './hovertip'
|
||||
import {FlamechartViewProps} from './flamechart-view-container'
|
||||
import {StatelessComponent} from '../lib/typed-redux'
|
||||
import {useCallback} from 'preact/hooks'
|
||||
import {SearchViewProps} from './search-view'
|
||||
|
||||
export function useDummySearchProps(): SearchViewProps {
|
||||
return {
|
||||
searchIsActive: false,
|
||||
searchQuery: '',
|
||||
setSearchQuery: useCallback((q: string) => {}, []),
|
||||
setSearchIsActive: useCallback((v: boolean) => {}, []),
|
||||
}
|
||||
}
|
||||
|
||||
export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
|
||||
private clampViewportToFlamegraph(viewportRect: Rect) {
|
||||
const {flamechart, renderInverted} = this.props
|
||||
const configSpaceSize = new Vec2(flamechart.getTotalWeight(), flamechart.getLayers().length)
|
||||
const width = this.props.flamechart.getClampedViewportWidth(viewportRect.size.x)
|
||||
const size = viewportRect.size.withX(width)
|
||||
const origin = Vec2.clamp(
|
||||
viewportRect.origin,
|
||||
new Vec2(0, renderInverted ? 0 : -1),
|
||||
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
|
||||
)
|
||||
return new Rect(origin, viewportRect.size.withX(width))
|
||||
return flamechart.getClampedConfigSpaceViewportRect({
|
||||
configSpaceViewportRect: viewportRect,
|
||||
renderInverted,
|
||||
})
|
||||
}
|
||||
private setConfigSpaceViewportRect = (configSpaceViewportRect: Rect) => {
|
||||
this.props.setConfigSpaceViewportRect(this.clampViewportToFlamegraph(configSpaceViewportRect))
|
||||
@@ -95,8 +79,7 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
|
||||
renderInverted={this.props.renderInverted}
|
||||
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
|
||||
searchIsActive={this.props.searchIsActive}
|
||||
searchQuery={this.props.searchQuery}
|
||||
searchResults={null}
|
||||
/>
|
||||
{this.renderTooltip()}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
import {useAppSelector} from '../store'
|
||||
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
|
||||
import {FlamechartWrapper} from './flamechart-wrapper'
|
||||
import {h} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
|
||||
@@ -87,14 +87,9 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.SANDWICH_INVERTED_CALLERS, index)}
|
||||
{...callerCallee.invertedCallerFlamegraph}
|
||||
// This overrides the setSelectedNode specified in useFlamechartSettesr
|
||||
setSelectedNode={noop}
|
||||
{...callerCallee.invertedCallerFlamegraph}
|
||||
/*
|
||||
* TODO(jlfwong): When implementing search for the sandwich views,
|
||||
* change these flags
|
||||
* */
|
||||
{...useDummySearchProps()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
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 {formatPercent} from '../lib/utils'
|
||||
import {FontSize, Colors, Sizes, commonStyle} from './style'
|
||||
import {ColorChit} from './color-chit'
|
||||
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 {useAppSelector, ActiveProfileState} from '../store'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useCallback, useMemo} from 'preact/hooks'
|
||||
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
|
||||
import {useCallback, useMemo, useContext} from 'preact/hooks'
|
||||
import {SandwichViewContext} from './sandwich-view'
|
||||
|
||||
export enum SortField {
|
||||
SYMBOL_NAME,
|
||||
@@ -68,13 +67,9 @@ class SortIcon extends Component<SortIconProps, {}> {
|
||||
}
|
||||
}
|
||||
|
||||
interface ProfileTableRowInfo {
|
||||
interface ProfileTableRowViewProps {
|
||||
frame: Frame
|
||||
matchedRanges: [number, number][] | null
|
||||
}
|
||||
|
||||
interface ProfileTableRowViewProps {
|
||||
info: ProfileTableRowInfo
|
||||
index: number
|
||||
profile: Profile
|
||||
selectedFrame: Frame | null
|
||||
@@ -100,15 +95,14 @@ function highlightRanges(
|
||||
}
|
||||
|
||||
const ProfileTableRowView = ({
|
||||
info,
|
||||
frame,
|
||||
matchedRanges,
|
||||
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()
|
||||
@@ -206,48 +200,21 @@ export const ProfileTableView = memo(
|
||||
[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
|
||||
}
|
||||
}
|
||||
if (sortMethod.direction === SortDirection.DESCENDING) {
|
||||
rowList.reverse()
|
||||
}
|
||||
|
||||
return rowList
|
||||
}, [profile, sortMethod, searchQuery, searchIsActive])
|
||||
const sandwichContext = useContext(SandwichViewContext)
|
||||
|
||||
const renderItems = useCallback(
|
||||
(firstIndex: number, lastIndex: number) => {
|
||||
if (!sandwichContext) return null
|
||||
|
||||
const rows: JSX.Element[] = []
|
||||
|
||||
for (let i = firstIndex; i <= lastIndex; i++) {
|
||||
const frame = sandwichContext.rowList[i]
|
||||
const match = sandwichContext.getSearchMatchForFrame(frame)
|
||||
rows.push(
|
||||
ProfileTableRowView({
|
||||
info: rowList[i],
|
||||
frame,
|
||||
matchedRanges: match == null ? null : match.matchedRanges,
|
||||
index: i,
|
||||
profile: profile,
|
||||
selectedFrame: selectedFrame,
|
||||
@@ -278,7 +245,7 @@ export const ProfileTableView = memo(
|
||||
return <table className={css(style.tableView)}>{rows}</table>
|
||||
},
|
||||
[
|
||||
rowList,
|
||||
sandwichContext,
|
||||
profile,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
@@ -288,9 +255,13 @@ export const ProfileTableView = memo(
|
||||
],
|
||||
)
|
||||
|
||||
const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [
|
||||
rowList,
|
||||
])
|
||||
const listItems: ListItem[] = useMemo(
|
||||
() =>
|
||||
sandwichContext == null
|
||||
? []
|
||||
: sandwichContext.rowList.map(f => ({size: Sizes.FRAME_HEIGHT})),
|
||||
[sandwichContext],
|
||||
)
|
||||
|
||||
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
|
||||
onSortClick,
|
||||
@@ -341,7 +312,7 @@ export const ProfileTableView = memo(
|
||||
className={css(style.scrollView)}
|
||||
renderItems={renderItems}
|
||||
initialIndexInView={
|
||||
selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame)
|
||||
selectedFrame == null ? null : sandwichContext?.getIndexForFrame(selectedFrame)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {memo} from 'preact/compat'
|
||||
import {useContext, useMemo} from 'preact/hooks'
|
||||
import {SearchView} from './search-view'
|
||||
import {h} from 'preact'
|
||||
import {SandwichViewContext} from './sandwich-view'
|
||||
|
||||
export const SandwichSearchView = memo(() => {
|
||||
const sandwichViewContext = useContext(SandwichViewContext)
|
||||
|
||||
const rowList = sandwichViewContext != null ? sandwichViewContext.rowList : null
|
||||
const resultIndex =
|
||||
sandwichViewContext?.selectedFrame != null
|
||||
? sandwichViewContext.getIndexForFrame(sandwichViewContext.selectedFrame)
|
||||
: null
|
||||
const numResults = rowList != null ? rowList.length : null
|
||||
|
||||
const {selectPrev, selectNext} = useMemo(() => {
|
||||
if (rowList == null || numResults == null || numResults === 0 || sandwichViewContext == null) {
|
||||
return {selectPrev: () => {}, selectNext: () => {}}
|
||||
}
|
||||
|
||||
return {
|
||||
selectPrev: () => {
|
||||
let index = resultIndex == null ? numResults - 1 : resultIndex - 1
|
||||
if (index < 0) index = numResults - 1
|
||||
sandwichViewContext.setSelectedFrame(rowList[index])
|
||||
},
|
||||
selectNext: () => {
|
||||
let index = resultIndex == null ? 0 : resultIndex + 1
|
||||
if (index >= numResults) index = 0
|
||||
sandwichViewContext.setSelectedFrame(rowList[index])
|
||||
},
|
||||
}
|
||||
}, [resultIndex, rowList, numResults, sandwichViewContext])
|
||||
|
||||
return (
|
||||
<SearchView
|
||||
resultIndex={resultIndex}
|
||||
numResults={numResults}
|
||||
selectPrev={selectPrev}
|
||||
selectNext={selectNext}
|
||||
/>
|
||||
)
|
||||
})
|
||||
+92
-36
@@ -1,18 +1,20 @@
|
||||
import {Frame} from '../lib/profile'
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {ProfileTableViewContainer} from './profile-table-view'
|
||||
import {h, JSX} from 'preact'
|
||||
import {ProfileTableViewContainer, SortField, SortDirection} from './profile-table-view'
|
||||
import {h, JSX, createContext} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
import {useCallback} from 'preact/hooks'
|
||||
import {useCallback, useMemo, useContext} from 'preact/hooks'
|
||||
import {commonStyle, Sizes, Colors, FontSize} from './style'
|
||||
import {actions} from '../store/actions'
|
||||
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, useActionCreator} from '../lib/preact-redux'
|
||||
import {SearchView} from './search-view'
|
||||
import {useAppSelector} from '../store'
|
||||
import {useDispatch} from '../lib/preact-redux'
|
||||
import {SandwichSearchView} from './sandwich-search-view'
|
||||
import {useAppSelector, ActiveProfileState} from '../store'
|
||||
import {sortBy} from '../lib/utils'
|
||||
import {ProfileSearchContext} from './search-view'
|
||||
import {FuzzyMatch} from '../lib/fuzzy-find'
|
||||
|
||||
interface SandwichViewProps {
|
||||
selectedFrame: Frame | null
|
||||
@@ -20,10 +22,6 @@ 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> {
|
||||
@@ -45,13 +43,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedFrame,
|
||||
searchIsActive,
|
||||
setSearchIsActive,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
} = this.props
|
||||
const {selectedFrame} = this.props
|
||||
let flamegraphViews: JSX.Element | null = null
|
||||
|
||||
if (selectedFrame) {
|
||||
@@ -84,12 +76,7 @@ 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}
|
||||
/>
|
||||
<SandwichSearchView />
|
||||
</div>
|
||||
{flamegraphViews}
|
||||
</div>
|
||||
@@ -143,7 +130,15 @@ interface SandwichViewContainerProps {
|
||||
glCanvas: HTMLCanvasElement
|
||||
}
|
||||
|
||||
const {setSearchQuery, setSearchIsActive} = actions
|
||||
interface SandwichViewContextData {
|
||||
rowList: Frame[]
|
||||
selectedFrame: Frame | null
|
||||
setSelectedFrame: (frame: Frame | null) => void
|
||||
getIndexForFrame: (frame: Frame) => number | null
|
||||
getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null
|
||||
}
|
||||
|
||||
export const SandwichViewContext = createContext<SandwichViewContextData | null>(null)
|
||||
|
||||
export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = ownProps
|
||||
@@ -163,17 +158,78 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
|
||||
[dispatch, index],
|
||||
)
|
||||
|
||||
const profile = activeProfileState.profile
|
||||
const tableSortMethod = useAppSelector(state => state.tableSortMethod, [])
|
||||
const profileSearchResults = useContext(ProfileSearchContext)
|
||||
|
||||
const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
|
||||
|
||||
const rowList: Frame[] = useMemo(() => {
|
||||
const rowList: Frame[] = []
|
||||
|
||||
profile.forEachFrame(frame => {
|
||||
if (profileSearchResults && !profileSearchResults.getMatchForFrame(frame)) {
|
||||
return
|
||||
}
|
||||
rowList.push(frame)
|
||||
})
|
||||
|
||||
switch (tableSortMethod.field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
sortBy(rowList, f => f.name.toLowerCase())
|
||||
break
|
||||
}
|
||||
case SortField.SELF: {
|
||||
sortBy(rowList, f => f.getSelfWeight())
|
||||
break
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
sortBy(rowList, f => f.getTotalWeight())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (tableSortMethod.direction === SortDirection.DESCENDING) {
|
||||
rowList.reverse()
|
||||
}
|
||||
|
||||
return rowList
|
||||
}, [profile, profileSearchResults, tableSortMethod])
|
||||
|
||||
const getIndexForFrame: (frame: Frame) => number | null = useMemo(() => {
|
||||
const indexByFrame = new Map<Frame, number>()
|
||||
for (let i = 0; i < rowList.length; i++) {
|
||||
indexByFrame.set(rowList[i], i)
|
||||
}
|
||||
return (frame: Frame) => {
|
||||
const index = indexByFrame.get(frame)
|
||||
return index == null ? null : index
|
||||
}
|
||||
}, [rowList])
|
||||
|
||||
const getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null = useMemo(() => {
|
||||
return (frame: Frame) => {
|
||||
if (profileSearchResults == null) return null
|
||||
return profileSearchResults.getMatchForFrame(frame)
|
||||
}
|
||||
}, [profileSearchResults])
|
||||
|
||||
const contextData: SandwichViewContextData = {
|
||||
rowList,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
getIndexForFrame,
|
||||
getSearchMatchForFrame,
|
||||
}
|
||||
|
||||
return (
|
||||
<SandwichView
|
||||
activeProfileState={activeProfileState}
|
||||
glCanvas={glCanvas}
|
||||
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, [])}
|
||||
/>
|
||||
<SandwichViewContext.Provider value={contextData}>
|
||||
<SandwichView
|
||||
activeProfileState={activeProfileState}
|
||||
glCanvas={glCanvas}
|
||||
setSelectedFrame={setSelectedFrame}
|
||||
selectedFrame={selectedFrame}
|
||||
profileIndex={index}
|
||||
/>
|
||||
</SandwichViewContext.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -17,7 +17,10 @@ interface RangeResult {
|
||||
interface ScrollableListViewProps {
|
||||
items: ListItem[]
|
||||
axis: 'x' | 'y'
|
||||
renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
|
||||
renderItems: (
|
||||
firstVisibleIndex: number,
|
||||
lastVisibleIndex: number,
|
||||
) => JSX.Element | JSX.Element[] | null
|
||||
className?: string
|
||||
initialIndexInView?: number | null
|
||||
}
|
||||
|
||||
+108
-27
@@ -1,23 +1,54 @@
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {h} from 'preact'
|
||||
import {useCallback, useRef, useEffect} from 'preact/hooks'
|
||||
import {h, createContext, ComponentChildren, Fragment} from 'preact'
|
||||
import {useCallback, useRef, useEffect, useMemo} from 'preact/hooks'
|
||||
import {memo} from 'preact/compat'
|
||||
import {Sizes, Colors, FontSize} from './style'
|
||||
import {ProfileSearchResults} from '../lib/profile-search'
|
||||
import {Profile} from '../lib/profile'
|
||||
import {useActiveProfileState, useAppSelector} from '../store'
|
||||
import {useActionCreator} from '../lib/preact-redux'
|
||||
import {actions} from '../store/actions'
|
||||
|
||||
function stopPropagation(ev: Event) {
|
||||
ev.stopPropagation()
|
||||
}
|
||||
|
||||
export interface SearchViewProps {
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
export const ProfileSearchContext = createContext<ProfileSearchResults | null>(null)
|
||||
|
||||
setSearchQuery: (query: string) => void
|
||||
setSearchIsActive: (active: boolean) => void
|
||||
export const ProfileSearchContextProvider = ({children}: {children: ComponentChildren}) => {
|
||||
const activeProfileState = useActiveProfileState()
|
||||
const profile: Profile | null = activeProfileState ? activeProfileState.profile : null
|
||||
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
|
||||
const searchQuery = useAppSelector(state => state.searchQuery, [])
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!profile || !searchIsActive || searchQuery.length === 0) {
|
||||
return null
|
||||
}
|
||||
return new ProfileSearchResults(profile, searchQuery)
|
||||
}, [searchIsActive, searchQuery, profile])
|
||||
|
||||
return (
|
||||
<ProfileSearchContext.Provider value={searchResults}>{children}</ProfileSearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const {setSearchQuery: setSearchQueryAction, setSearchIsActive: setSearchIsActiveAction} = actions
|
||||
|
||||
interface SearchViewProps {
|
||||
resultIndex: number | null
|
||||
numResults: number | null
|
||||
selectNext: () => void
|
||||
selectPrev: () => void
|
||||
}
|
||||
|
||||
export const SearchView = memo(
|
||||
({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => {
|
||||
({numResults, resultIndex, selectNext, selectPrev}: SearchViewProps) => {
|
||||
const searchQuery = useAppSelector(state => state.searchQuery, [])
|
||||
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
|
||||
const setSearchQuery = useActionCreator(setSearchQueryAction, [])
|
||||
const setSearchIsActive = useActionCreator(setSearchIsActiveAction, [])
|
||||
|
||||
const onInput = useCallback(
|
||||
(ev: Event) => {
|
||||
const value = (ev.target as HTMLInputElement).value
|
||||
@@ -28,6 +59,19 @@ export const SearchView = memo(
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
|
||||
|
||||
const selectPrevOrNextResult = useCallback(
|
||||
(ev: KeyboardEvent) => {
|
||||
if (ev.shiftKey) {
|
||||
selectPrev()
|
||||
} else {
|
||||
selectNext()
|
||||
}
|
||||
},
|
||||
[selectPrev, selectNext],
|
||||
)
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(ev: KeyboardEvent) => {
|
||||
ev.stopPropagation()
|
||||
@@ -37,6 +81,10 @@ export const SearchView = memo(
|
||||
setSearchIsActive(false)
|
||||
}
|
||||
|
||||
if (ev.key === 'Enter') {
|
||||
selectPrevOrNextResult(ev)
|
||||
}
|
||||
|
||||
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
|
||||
if (inputRef.current) {
|
||||
// If the input is already focused, select all
|
||||
@@ -49,7 +97,7 @@ export const SearchView = memo(
|
||||
ev.preventDefault()
|
||||
}
|
||||
},
|
||||
[setSearchIsActive],
|
||||
[setSearchIsActive, selectPrevOrNextResult],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,24 +129,37 @@ export const SearchView = memo(
|
||||
}
|
||||
}, [setSearchIsActive])
|
||||
|
||||
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={inputRef}
|
||||
/>
|
||||
|
||||
<span className={css(style.inputContainer)}>
|
||||
<input
|
||||
className={css(style.input)}
|
||||
value={searchQuery}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={stopPropagation}
|
||||
onKeyPress={stopPropagation}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</span>
|
||||
{numResults != null && (
|
||||
<Fragment>
|
||||
<span className={css(style.resultCount)}>
|
||||
{resultIndex == null ? '?' : resultIndex + 1}/{numResults}
|
||||
</span>
|
||||
<button className={css(style.icon, style.button)} onClick={selectPrev}>
|
||||
⬅️
|
||||
</button>
|
||||
<button className={css(style.icon, style.button)} onClick={selectNext}>
|
||||
➡️
|
||||
</button>
|
||||
</Fragment>
|
||||
)}
|
||||
<svg
|
||||
className={css(style.icon)}
|
||||
onClick={close}
|
||||
width="16"
|
||||
height="16"
|
||||
@@ -122,7 +183,7 @@ const style = StyleSheet.create({
|
||||
top: 0,
|
||||
right: 10,
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
width: 150,
|
||||
width: 16 * 13,
|
||||
borderWidth: 2,
|
||||
borderColor: Colors.BLACK,
|
||||
borderStyle: 'solid',
|
||||
@@ -131,12 +192,19 @@ const style = StyleSheet.create({
|
||||
background: Colors.DARK_GRAY,
|
||||
color: Colors.WHITE,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputContainer: {
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
fontSize: FontSize.LABEL,
|
||||
flex: 1,
|
||||
lineHeight: `${Sizes.TOOLBAR_HEIGHT}px`,
|
||||
color: Colors.WHITE,
|
||||
':focus': {
|
||||
border: 'none',
|
||||
@@ -147,10 +215,23 @@ const style = StyleSheet.create({
|
||||
background: Colors.DARK_BLUE,
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
display: 'inline-block',
|
||||
resultCount: {
|
||||
verticalAlign: 'middle',
|
||||
paddingTop: '0px',
|
||||
margin: '0 2px 0 4px',
|
||||
},
|
||||
icon: {
|
||||
flexShrink: 0,
|
||||
verticalAlign: 'middle',
|
||||
height: '100%',
|
||||
margin: '0px 2px 0px 2px',
|
||||
fontSize: FontSize.LABEL,
|
||||
},
|
||||
button: {
|
||||
display: 'inline',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
':focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ export enum Colors {
|
||||
GREEN = '#6FCF97',
|
||||
TRANSPARENT_GREEN = 'rgba(111, 207, 151, 0.2)',
|
||||
YELLOW = '#FEDC62',
|
||||
ORANGE = '#FFAC02',
|
||||
}
|
||||
|
||||
export enum Sizes {
|
||||
|
||||
Reference in New Issue
Block a user