This adds some baseline tests for the redux store, and also fixes some bugs I found along the way. Bugs fixed: - Changing selection after something has been selected within the sandwich view now works - Table sort state is now preserved when switching between profiles Fixes #124
814 lines
23 KiB
TypeScript
814 lines
23 KiB
TypeScript
import {h, Component} from 'preact'
|
||
import {StyleSheet, css} from 'aphrodite'
|
||
import {FileSystemDirectoryEntry} from '../import/file-system-entry'
|
||
|
||
import {Profile, ProfileGroup} from '../lib/profile'
|
||
import {FontFamily, FontSize, Colors, Sizes, 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 {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'
|
||
|
||
const importModule = import('../import')
|
||
// Force eager loading of the module
|
||
importModule.then(() => {})
|
||
async function importProfiles(fileName: string, contents: string): Promise<ProfileGroup | null> {
|
||
return (await importModule).importProfileGroup(fileName, contents)
|
||
}
|
||
async function importFromFileSystemDirectoryEntry(entry: FileSystemDirectoryEntry) {
|
||
return (await importModule).importFromFileSystemDirectoryEntry(entry)
|
||
}
|
||
|
||
declare function require(x: string): any
|
||
const exampleProfileURL = require('../../sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
|
||
|
||
interface ToolbarProps extends ApplicationProps {
|
||
browseForFile(): void
|
||
saveFile(): void
|
||
}
|
||
|
||
export class Toolbar extends StatelessComponent<ToolbarProps> {
|
||
setTimeOrder = () => {
|
||
this.props.setViewMode(ViewMode.CHRONO_FLAME_CHART)
|
||
}
|
||
|
||
setLeftHeavyOrder = () => {
|
||
this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
|
||
}
|
||
|
||
setSandwichView = () => {
|
||
this.props.setViewMode(ViewMode.SANDWICH_VIEW)
|
||
}
|
||
|
||
renderLeftContent() {
|
||
if (!this.props.activeProfileState) return null
|
||
|
||
return (
|
||
<div className={css(style.toolbarLeft)}>
|
||
<div
|
||
className={css(
|
||
style.toolbarTab,
|
||
this.props.viewMode === ViewMode.CHRONO_FLAME_CHART && style.toolbarTabActive,
|
||
)}
|
||
onClick={this.setTimeOrder}
|
||
>
|
||
<span className={css(style.emoji)}>🕰</span>Time Order
|
||
</div>
|
||
<div
|
||
className={css(
|
||
style.toolbarTab,
|
||
this.props.viewMode === ViewMode.LEFT_HEAVY_FLAME_GRAPH && style.toolbarTabActive,
|
||
)}
|
||
onClick={this.setLeftHeavyOrder}
|
||
>
|
||
<span className={css(style.emoji)}>⬅️</span>Left Heavy
|
||
</div>
|
||
<div
|
||
className={css(
|
||
style.toolbarTab,
|
||
this.props.viewMode === ViewMode.SANDWICH_VIEW && style.toolbarTabActive,
|
||
)}
|
||
onClick={this.setSandwichView}
|
||
>
|
||
<span className={css(style.emoji)}>🥪</span>Sandwich
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
renderCenterContent() {
|
||
const {activeProfileState, profileGroup} = this.props
|
||
if (activeProfileState && profileGroup) {
|
||
const {index} = activeProfileState
|
||
if (profileGroup.profiles.length === 1) {
|
||
return activeProfileState.profile.getName()
|
||
} else {
|
||
function makeNavButton(content: string, disabled: boolean, onClick: () => void) {
|
||
return (
|
||
<button
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
className={css(
|
||
style.emoji,
|
||
style.toolbarProfileNavButton,
|
||
disabled && style.toolbarProfileNavButtonDisabled,
|
||
)}
|
||
>
|
||
{content}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
const prevButton = makeNavButton('⬅️', index === 0, () =>
|
||
this.props.setProfileIndexToView(index - 1),
|
||
)
|
||
const nextButton = makeNavButton('➡️', index >= profileGroup.profiles.length - 1, () =>
|
||
this.props.setProfileIndexToView(index + 1),
|
||
)
|
||
|
||
return (
|
||
<div className={css(style.toolbarCenter)}>
|
||
{prevButton}
|
||
{activeProfileState.profile.getName()}{' '}
|
||
<span className={css(style.toolbarProfileIndex)}>
|
||
({activeProfileState.index + 1}/{profileGroup.profiles.length})
|
||
</span>
|
||
{nextButton}
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
return '🔬speedscope'
|
||
}
|
||
|
||
renderRightContent() {
|
||
const importFile = (
|
||
<div className={css(style.toolbarTab)} onClick={this.props.browseForFile}>
|
||
<span className={css(style.emoji)}>⤵️</span>Import
|
||
</div>
|
||
)
|
||
const help = (
|
||
<div className={css(style.toolbarTab)}>
|
||
<a
|
||
href="https://github.com/jlfwong/speedscope#usage"
|
||
className={css(style.noLinkStyle)}
|
||
target="_blank"
|
||
>
|
||
<span className={css(style.emoji)}>❓</span>Help
|
||
</a>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className={css(style.toolbarRight)}>
|
||
{this.props.activeProfileState && (
|
||
<div className={css(style.toolbarTab)} onClick={this.props.saveFile}>
|
||
<span className={css(style.emoji)}>⤴️</span>Export
|
||
</div>
|
||
)}
|
||
{importFile}
|
||
{help}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
render() {
|
||
return (
|
||
<div className={css(style.toolbar)}>
|
||
{this.renderLeftContent()}
|
||
{this.renderCenterContent()}
|
||
{this.renderRightContent()}
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
|
||
interface GLCanvasProps {
|
||
setGLCanvas: (canvas: HTMLCanvasElement | null) => void
|
||
}
|
||
export class GLCanvas extends Component<GLCanvasProps, void> {
|
||
private canvas: HTMLCanvasElement | null = null
|
||
|
||
private ref = (canvas?: Element) => {
|
||
if (canvas instanceof HTMLCanvasElement) {
|
||
this.canvas = canvas
|
||
} else {
|
||
this.canvas = null
|
||
}
|
||
|
||
this.props.setGLCanvas(this.canvas)
|
||
}
|
||
|
||
private maybeResize() {
|
||
if (!this.canvas) return
|
||
let {width, height} = this.canvas.getBoundingClientRect()
|
||
width = Math.floor(width) * window.devicePixelRatio
|
||
height = Math.floor(height) * window.devicePixelRatio
|
||
|
||
// Still initializing: don't resize yet
|
||
if (width < 4 || height < 4) return
|
||
const oldWidth = this.canvas.width
|
||
const oldHeight = this.canvas.height
|
||
|
||
// Already at the right size
|
||
if (width === oldWidth && height === oldHeight) return
|
||
|
||
this.canvas.width = width
|
||
this.canvas.height = height
|
||
}
|
||
|
||
onWindowResize = () => {
|
||
this.maybeResize()
|
||
window.addEventListener('resize', this.onWindowResize)
|
||
}
|
||
componentDidMount() {
|
||
window.addEventListener('resize', this.onWindowResize)
|
||
requestAnimationFrame(() => this.maybeResize())
|
||
}
|
||
componentWillUnmount() {
|
||
window.removeEventListener('resize', this.onWindowResize)
|
||
}
|
||
render() {
|
||
return <canvas className={css(style.glCanvasView)} ref={this.ref} width={1} height={1} />
|
||
}
|
||
}
|
||
|
||
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
|
||
setError: (error: boolean) => void
|
||
setProfileGroup: (profileGroup: ProfileGroup) => void
|
||
setDragActive: (dragActive: boolean) => void
|
||
setViewMode: (viewMode: ViewMode) => void
|
||
setFlattenRecursion: (flattenRecursion: boolean) => void
|
||
setProfileIndexToView: (profileIndex: number) => void
|
||
activeProfileState: ActiveProfileState | null
|
||
}
|
||
|
||
export class Application extends StatelessComponent<ApplicationProps> {
|
||
private async loadProfile(loader: () => Promise<ProfileGroup | null>) {
|
||
this.props.setLoading(true)
|
||
await new Promise(resolve => setTimeout(resolve, 0))
|
||
|
||
if (!this.props.glCanvas) return
|
||
|
||
console.time('import')
|
||
|
||
let profileGroup: ProfileGroup | null = null
|
||
try {
|
||
profileGroup = await loader()
|
||
} catch (e) {
|
||
console.log('Failed to load format', e)
|
||
this.props.setError(true)
|
||
return
|
||
}
|
||
|
||
if (profileGroup == null) {
|
||
// TODO(jlfwong): Make this a nicer overlay
|
||
alert('Unrecognized format! See documentation about supported formats.')
|
||
this.props.setLoading(false)
|
||
return
|
||
}
|
||
|
||
if (this.props.hashParams.title) {
|
||
profileGroup = {
|
||
name: this.props.hashParams.title,
|
||
...profileGroup,
|
||
}
|
||
}
|
||
document.title = `${profileGroup.name} - speedscope`
|
||
|
||
for (let profile of profileGroup.profiles) {
|
||
await profile.demangle()
|
||
}
|
||
|
||
for (let profile of profileGroup.profiles) {
|
||
const title = this.props.hashParams.title || profile.getName()
|
||
profile.setName(title)
|
||
}
|
||
|
||
console.timeEnd('import')
|
||
|
||
this.props.setProfileGroup(profileGroup)
|
||
this.props.setLoading(false)
|
||
}
|
||
|
||
loadFromFile(file: File) {
|
||
this.loadProfile(async () => {
|
||
const reader = new FileReader()
|
||
const loadPromise = new Promise(resolve => reader.addEventListener('loadend', resolve))
|
||
reader.readAsText(file)
|
||
await loadPromise
|
||
|
||
if (typeof reader.result !== 'string') {
|
||
throw new Error('Expected ArrayBuffer')
|
||
}
|
||
|
||
const profiles = await importProfiles(file.name, reader.result)
|
||
if (profiles) {
|
||
for (let profile of profiles.profiles) {
|
||
if (!profile.getName()) {
|
||
profile.setName(file.name)
|
||
}
|
||
}
|
||
return profiles
|
||
}
|
||
|
||
if (this.props.profileGroup && this.props.activeProfileState) {
|
||
// If a profile is already loaded, it's possible the file being imported is
|
||
// a symbol map. If that's the case, we want to parse it, and apply the symbol
|
||
// mapping to the already loaded profile. This can be use to take an opaque
|
||
// profile and make it readable.
|
||
const map = importEmscriptenSymbolMap(reader.result)
|
||
if (map) {
|
||
const {profile, index} = this.props.activeProfileState
|
||
console.log('Importing as emscripten symbol map')
|
||
profile.remapNames(name => map.get(name) || name)
|
||
return {
|
||
name: this.props.profileGroup.name || 'profile',
|
||
indexToView: index,
|
||
profiles: [profile],
|
||
}
|
||
}
|
||
}
|
||
|
||
return null
|
||
})
|
||
}
|
||
|
||
loadExample = () => {
|
||
this.loadProfile(async () => {
|
||
const filename = 'perf-vertx-stacks-01-collapsed-all.txt'
|
||
const data = await fetch(exampleProfileURL).then(resp => resp.text())
|
||
return await importProfiles(filename, data)
|
||
})
|
||
}
|
||
|
||
onDrop = (ev: DragEvent) => {
|
||
this.props.setDragActive(false)
|
||
ev.preventDefault()
|
||
|
||
const firstItem = ev.dataTransfer.items[0]
|
||
if ('webkitGetAsEntry' in firstItem) {
|
||
const webkitEntry: FileSystemDirectoryEntry = firstItem.webkitGetAsEntry()
|
||
|
||
// Instrument.app file format is actually a directory.
|
||
if (webkitEntry.isDirectory && webkitEntry.name.endsWith('.trace')) {
|
||
console.log('Importing as Instruments.app .trace file')
|
||
this.loadProfile(async () => {
|
||
return await importFromFileSystemDirectoryEntry(webkitEntry)
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
let file: File | null = ev.dataTransfer.files.item(0)
|
||
if (file) {
|
||
this.loadFromFile(file)
|
||
}
|
||
}
|
||
|
||
onDragOver = (ev: DragEvent) => {
|
||
this.props.setDragActive(true)
|
||
ev.preventDefault()
|
||
}
|
||
|
||
onDragLeave = (ev: DragEvent) => {
|
||
this.props.setDragActive(false)
|
||
ev.preventDefault()
|
||
}
|
||
|
||
onWindowKeyPress = async (ev: KeyboardEvent) => {
|
||
if (ev.key === '1') {
|
||
this.props.setViewMode(ViewMode.CHRONO_FLAME_CHART)
|
||
} else if (ev.key === '2') {
|
||
this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
|
||
} else if (ev.key === '3') {
|
||
this.props.setViewMode(ViewMode.SANDWICH_VIEW)
|
||
} else if (ev.key === 'r') {
|
||
const {flattenRecursion} = this.props
|
||
this.props.setFlattenRecursion(!flattenRecursion)
|
||
} else if (ev.key === 'n') {
|
||
const {activeProfileState} = this.props
|
||
if (activeProfileState) {
|
||
this.props.setProfileIndexToView(activeProfileState.index + 1)
|
||
}
|
||
} else if (ev.key === 'p') {
|
||
const {activeProfileState} = this.props
|
||
if (activeProfileState) {
|
||
this.props.setProfileIndexToView(activeProfileState.index - 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
private saveFile = () => {
|
||
if (this.props.profileGroup) {
|
||
const {name, indexToView, profiles} = this.props.profileGroup
|
||
const profileGroup: ProfileGroup = {
|
||
name,
|
||
indexToView,
|
||
profiles: profiles.map(p => p.profile),
|
||
}
|
||
saveToFile(profileGroup)
|
||
}
|
||
}
|
||
|
||
private browseForFile = () => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.addEventListener('change', this.onFileSelect)
|
||
input.click()
|
||
}
|
||
|
||
private onWindowKeyDown = async (ev: KeyboardEvent) => {
|
||
// This has to be handled on key down in order to prevent the default
|
||
// page save action.
|
||
if (ev.key === 's' && (ev.ctrlKey || ev.metaKey)) {
|
||
ev.preventDefault()
|
||
this.saveFile()
|
||
} else if (ev.key === 'o' && (ev.ctrlKey || ev.metaKey)) {
|
||
ev.preventDefault()
|
||
this.browseForFile()
|
||
}
|
||
}
|
||
|
||
onDocumentPaste = (ev: Event) => {
|
||
ev.preventDefault()
|
||
ev.stopPropagation()
|
||
|
||
const pasted = (ev as ClipboardEvent).clipboardData.getData('text')
|
||
this.loadProfile(async () => {
|
||
return await importProfiles('From Clipboard', pasted)
|
||
})
|
||
}
|
||
|
||
componentDidMount() {
|
||
window.addEventListener('keydown', this.onWindowKeyDown)
|
||
window.addEventListener('keypress', this.onWindowKeyPress)
|
||
document.addEventListener('paste', this.onDocumentPaste)
|
||
this.maybeLoadHashParamProfile()
|
||
}
|
||
|
||
componentWillUnmount() {
|
||
window.removeEventListener('keydown', this.onWindowKeyDown)
|
||
window.removeEventListener('keypress', this.onWindowKeyPress)
|
||
document.removeEventListener('paste', this.onDocumentPaste)
|
||
}
|
||
|
||
async maybeLoadHashParamProfile() {
|
||
if (this.props.hashParams.profileURL) {
|
||
if (!canUseXHR) {
|
||
alert(
|
||
`Cannot load a profile URL when loading from "${window.location.protocol}" URL protocol`,
|
||
)
|
||
return
|
||
}
|
||
this.loadProfile(async () => {
|
||
const response = await fetch(this.props.hashParams.profileURL!)
|
||
let filename = new URL(this.props.hashParams.profileURL!).pathname
|
||
if (filename.includes('/')) {
|
||
filename = filename.slice(filename.lastIndexOf('/') + 1)
|
||
}
|
||
return await importProfiles(filename, await response.text())
|
||
})
|
||
} else if (this.props.hashParams.localProfilePath) {
|
||
// There isn't good cross-browser support for XHR of local files, even from
|
||
// other local files. To work around this restriction, we load the local profile
|
||
// as a JavaScript file which will invoke a global function.
|
||
;(window as any)['speedscope'] = {
|
||
loadFileFromBase64: (filename: string, base64source: string) => {
|
||
const source = atob(base64source)
|
||
this.loadProfile(() => importProfiles(filename, source))
|
||
},
|
||
}
|
||
|
||
const script = document.createElement('script')
|
||
script.src = `file:///${this.props.hashParams.localProfilePath}`
|
||
document.head.appendChild(script)
|
||
}
|
||
}
|
||
|
||
onFileSelect = (ev: Event) => {
|
||
const file = (ev.target as HTMLInputElement).files!.item(0)
|
||
if (file) {
|
||
this.loadFromFile(file)
|
||
}
|
||
}
|
||
|
||
renderLanding() {
|
||
return (
|
||
<div className={css(style.landingContainer)}>
|
||
<div className={css(style.landingMessage)}>
|
||
<p className={css(style.landingP)}>
|
||
👋 Hi there! Welcome to 🔬speedscope, an interactive{' '}
|
||
<a
|
||
className={css(style.link)}
|
||
href="http://www.brendangregg.com/FlameGraphs/cpuflamegraphs.html"
|
||
>
|
||
flamegraph
|
||
</a>{' '}
|
||
visualizer. Use it to help you make your software faster.
|
||
</p>
|
||
{canUseXHR ? (
|
||
<p className={css(style.landingP)}>
|
||
Drag and drop a profile file onto this window to get started, click the big blue
|
||
button below to browse for a profile to explore, or{' '}
|
||
<a tabIndex={0} className={css(style.link)} onClick={this.loadExample}>
|
||
click here
|
||
</a>{' '}
|
||
to load an example profile.
|
||
</p>
|
||
) : (
|
||
<p className={css(style.landingP)}>
|
||
Drag and drop a profile file onto this window to get started, or click the big blue
|
||
button below to browse for a profile to explore.
|
||
</p>
|
||
)}
|
||
<div className={css(style.browseButtonContainer)}>
|
||
<input
|
||
type="file"
|
||
name="file"
|
||
id="file"
|
||
onChange={this.onFileSelect}
|
||
className={css(style.hide)}
|
||
/>
|
||
<label for="file" className={css(style.browseButton)} tabIndex={0}>
|
||
Browse
|
||
</label>
|
||
</div>
|
||
|
||
<p className={css(style.landingP)}>
|
||
See the{' '}
|
||
<a
|
||
className={css(style.link)}
|
||
href="https://github.com/jlfwong/speedscope#usage"
|
||
target="_blank"
|
||
>
|
||
documentation
|
||
</a>{' '}
|
||
for information about supported file formats, keyboard shortcuts, and how to navigate
|
||
around the profile.
|
||
</p>
|
||
|
||
<p className={css(style.landingP)}>
|
||
speedscope is open source. Please{' '}
|
||
<a
|
||
className={css(style.link)}
|
||
target="_blank"
|
||
href="https://github.com/jlfwong/speedscope/issues"
|
||
>
|
||
report any issues on GitHub
|
||
</a>.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
renderError() {
|
||
return (
|
||
<div className={css(style.error)}>
|
||
<div>😿 Something went wrong.</div>
|
||
<div>Check the JS console for more details.</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
renderLoadingBar() {
|
||
return <div className={css(style.loading)} />
|
||
}
|
||
|
||
renderContent() {
|
||
const {viewMode, activeProfileState, error, loading, glCanvas} = this.props
|
||
|
||
if (error) {
|
||
return this.renderError()
|
||
}
|
||
|
||
if (loading) {
|
||
return this.renderLoadingBar()
|
||
}
|
||
|
||
if (!activeProfileState || !glCanvas) {
|
||
return this.renderLanding()
|
||
}
|
||
|
||
switch (viewMode) {
|
||
case ViewMode.CHRONO_FLAME_CHART: {
|
||
return <ChronoFlamechartView activeProfileState={activeProfileState} glCanvas={glCanvas} />
|
||
}
|
||
case ViewMode.LEFT_HEAVY_FLAME_GRAPH: {
|
||
return (
|
||
<LeftHeavyFlamechartView activeProfileState={activeProfileState} glCanvas={glCanvas} />
|
||
)
|
||
}
|
||
case ViewMode.SANDWICH_VIEW: {
|
||
return <SandwichViewContainer activeProfileState={activeProfileState} glCanvas={glCanvas} />
|
||
}
|
||
}
|
||
}
|
||
|
||
render() {
|
||
return (
|
||
<div
|
||
onDrop={this.onDrop}
|
||
onDragOver={this.onDragOver}
|
||
onDragLeave={this.onDragLeave}
|
||
className={css(style.root, this.props.dragActive && style.dragTargetRoot)}
|
||
>
|
||
<GLCanvas setGLCanvas={this.props.setGLCanvas} />
|
||
<Toolbar
|
||
saveFile={this.saveFile}
|
||
browseForFile={this.browseForFile}
|
||
{...this.props as ApplicationProps}
|
||
/>
|
||
<div className={css(style.contentContainer)}>{this.renderContent()}</div>
|
||
{this.props.dragActive && <div className={css(style.dragTarget)} />}
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
|
||
const style = StyleSheet.create({
|
||
glCanvasView: {
|
||
position: 'absolute',
|
||
width: '100vw',
|
||
height: '100vh',
|
||
zIndex: -1,
|
||
pointerEvents: 'none',
|
||
},
|
||
error: {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
height: '100%',
|
||
},
|
||
loading: {
|
||
height: 3,
|
||
marginBottom: -3,
|
||
background: Colors.DARK_BLUE,
|
||
transformOrigin: '0% 50%',
|
||
animationName: [
|
||
{
|
||
from: {
|
||
transform: `scaleX(0)`,
|
||
},
|
||
to: {
|
||
transform: `scaleX(1)`,
|
||
},
|
||
},
|
||
],
|
||
animationTimingFunction: 'cubic-bezier(0, 1, 0, 1)',
|
||
animationDuration: '30s',
|
||
},
|
||
root: {
|
||
width: '100vw',
|
||
height: '100vh',
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
position: 'relative',
|
||
fontFamily: FontFamily.MONOSPACE,
|
||
lineHeight: '20px',
|
||
},
|
||
dragTargetRoot: {
|
||
cursor: 'copy',
|
||
},
|
||
dragTarget: {
|
||
boxSizing: 'border-box',
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
height: '100%',
|
||
border: `5px dashed ${Colors.DARK_BLUE}`,
|
||
pointerEvents: 'none',
|
||
},
|
||
contentContainer: {
|
||
position: 'relative',
|
||
display: 'flex',
|
||
overflow: 'hidden',
|
||
flexDirection: 'column',
|
||
flex: 1,
|
||
},
|
||
landingContainer: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flex: 1,
|
||
},
|
||
landingMessage: {
|
||
maxWidth: 600,
|
||
},
|
||
landingP: {
|
||
marginBottom: 16,
|
||
},
|
||
hide: {
|
||
display: 'none',
|
||
},
|
||
browseButtonContainer: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
browseButton: {
|
||
marginBottom: 16,
|
||
height: 72,
|
||
flex: 1,
|
||
maxWidth: 256,
|
||
textAlign: 'center',
|
||
fontSize: FontSize.BIG_BUTTON,
|
||
lineHeight: '72px',
|
||
background: Colors.DARK_BLUE,
|
||
color: Colors.WHITE,
|
||
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
|
||
':hover': {
|
||
background: Colors.BRIGHT_BLUE,
|
||
},
|
||
},
|
||
link: {
|
||
color: Colors.BRIGHT_BLUE,
|
||
cursor: 'pointer',
|
||
textDecoration: 'none',
|
||
},
|
||
toolbar: {
|
||
height: Sizes.TOOLBAR_HEIGHT,
|
||
flexShrink: 0,
|
||
background: Colors.BLACK,
|
||
color: Colors.WHITE,
|
||
textAlign: 'center',
|
||
fontFamily: FontFamily.MONOSPACE,
|
||
fontSize: FontSize.TITLE,
|
||
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
|
||
userSelect: 'none',
|
||
},
|
||
toolbarLeft: {
|
||
position: 'absolute',
|
||
height: Sizes.TOOLBAR_HEIGHT,
|
||
overflow: 'hidden',
|
||
top: 0,
|
||
left: 0,
|
||
marginRight: 2,
|
||
textAlign: 'left',
|
||
},
|
||
toolbarCenter: {
|
||
paddingTop: 1,
|
||
height: Sizes.TOOLBAR_HEIGHT,
|
||
},
|
||
toolbarRight: {
|
||
height: Sizes.TOOLBAR_HEIGHT,
|
||
overflow: 'hidden',
|
||
position: 'absolute',
|
||
top: 0,
|
||
right: 0,
|
||
marginRight: 2,
|
||
textAlign: 'right',
|
||
},
|
||
toolbarProfileIndex: {
|
||
color: Colors.LIGHT_GRAY,
|
||
},
|
||
toolbarProfileNavButton: {
|
||
opacity: 0.8,
|
||
fontSize: FontSize.TITLE,
|
||
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
|
||
':hover': {
|
||
opacity: 1.0,
|
||
},
|
||
background: 'none',
|
||
border: 'none',
|
||
padding: 0,
|
||
marginLeft: '0.3em',
|
||
marginRight: '0.3em',
|
||
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
|
||
},
|
||
toolbarProfileNavButtonDisabled: {
|
||
opacity: 0.5,
|
||
':hover': {
|
||
opacity: 0.5,
|
||
},
|
||
},
|
||
toolbarTab: {
|
||
background: Colors.DARK_GRAY,
|
||
marginTop: Sizes.SEPARATOR_HEIGHT,
|
||
height: Sizes.TOOLBAR_TAB_HEIGHT,
|
||
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
|
||
paddingLeft: 2,
|
||
paddingRight: 8,
|
||
display: 'inline-block',
|
||
marginLeft: 2,
|
||
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
|
||
':hover': {
|
||
background: Colors.GRAY,
|
||
},
|
||
},
|
||
toolbarTabActive: {
|
||
background: Colors.BRIGHT_BLUE,
|
||
':hover': {
|
||
background: Colors.BRIGHT_BLUE,
|
||
},
|
||
},
|
||
noLinkStyle: {
|
||
textDecoration: 'none',
|
||
color: 'inherit',
|
||
},
|
||
emoji: {
|
||
display: 'inline-block',
|
||
verticalAlign: 'middle',
|
||
paddingTop: '0px',
|
||
marginRight: '0.3em',
|
||
},
|
||
})
|