Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow user to create floating view panels #4521

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"@types/rbush": "^3.0.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@types/react-resizable": "^3.0.8",
"@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/set-value": "^4.0.1",
"@types/string-template": "^1.0.2",
Expand Down
3 changes: 2 additions & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"@jbrowse/product-core": "^2.18.0",
"@mui/icons-material": "^6.0.0",
"@mui/material": "^6.0.0",
"copy-to-clipboard": "^3.3.1"
"copy-to-clipboard": "^3.3.1",
"react-resizable": "^3.0.5"
},
"peerDependencies": {
"mobx": "^6.0.0",
Expand Down
53 changes: 53 additions & 0 deletions packages/app-core/src/ui/App/DraggableViewPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { useRef } from 'react'
import { Portal } from '@mui/material'
import { observer } from 'mobx-react'

import {
useClientPoint,
useFloating,
useInteractions,
} from '@floating-ui/react'
import Draggable, { DraggableEventHandler } from 'react-draggable'

const DraggableViewPanel = observer(function DraggableViewPanel({
children,
zIndex = 100,
onStop,
}: {
children: React.ReactNode
zIndex?: number
x?: number
y?: number
onStop?: DraggableEventHandler
}) {
const ref = useRef<HTMLDivElement>(null)
const { refs, floatingStyles, context } = useFloating({
placement: 'bottom-start',
})
const clientPoint = useClientPoint(context, { x: 100, y: 100 })
const { getFloatingProps } = useInteractions([clientPoint])
return (
<Portal>
{/* @ts-expect-error */}
<Draggable nodeRef={ref} handle=".viewHeader" onStop={onStop}>
<div
ref={ref}
style={{
position: 'fixed',
zIndex,
}}
>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
{children}
</div>
</div>
</Draggable>
</Portal>
)
})

export default DraggableViewPanel
57 changes: 57 additions & 0 deletions packages/app-core/src/ui/App/FloatingViewPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { ResizableBox } from 'react-resizable'
import { observer } from 'mobx-react'
import { AbstractViewModel, SessionWithDrawerWidgets } from '@jbrowse/core/util'
import { SnackbarMessage } from '@jbrowse/core/ui/SnackbarModel'

// locals
import StaticViewPanel from './StaticViewPanel'
import './test.css'
import useMeasure from '@jbrowse/core/util/useMeasure'
import DraggableViewPanel from './DraggableViewPanel'

type AppSession = SessionWithDrawerWidgets & {
snackbarMessages: SnackbarMessage[]
renameCurrentSession: (arg: string) => void
popSnackbarMessage: () => unknown
}

const FloatingViewPanel = observer(function ({
view,
session,
}: {
view: AbstractViewModel
session: AppSession
}) {
const zIndex = session.focusedViewId === view.id ? 101 : 100
const [ref, { height }] = useMeasure()
const [mode, setMode] = useState<'se' | 'e'>('se')
const [size, setSize] = useState<{ width: number; height: number }>()
const h = size !== undefined ? size.height : undefined

useEffect(() => {
if (h !== undefined && height !== undefined && height - h > 50) {
setMode('e')
}
}, [h, height])

return (
<DraggableViewPanel zIndex={zIndex}>
<ResizableBox
className="box"
height={(height || 100) + 20}
resizeHandles={[mode]}
onResize={(_event, { size }) => {
setSize(size)
}}
width={1000}
>
<div ref={ref}>
<StaticViewPanel view={view} session={session} />
</div>
</ResizableBox>
</DraggableViewPanel>
)
})

export default FloatingViewPanel
62 changes: 62 additions & 0 deletions packages/app-core/src/ui/App/StaticViewPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Suspense } from 'react'
import { ErrorBoundary } from '@jbrowse/core/ui/ErrorBoundary'
import { observer } from 'mobx-react'

// locals
import {
getEnv,
AbstractViewModel,
SessionWithDrawerWidgets,
} from '@jbrowse/core/util'
import { SnackbarMessage } from '@jbrowse/core/ui/SnackbarModel'

// ui elements
import ErrorMessage from '@jbrowse/core/ui/ErrorMessage'
import LoadingEllipses from '@jbrowse/core/ui/LoadingEllipses'

// locals
import ViewContainer from './ViewContainer'

type AppSession = SessionWithDrawerWidgets & {
snackbarMessages: SnackbarMessage[]
renameCurrentSession: (arg: string) => void
popSnackbarMessage: () => unknown
}

const StaticViewPanel = observer(function StaticViewPanel2({
view,
session,
}: {
view: AbstractViewModel
session: AppSession
}) {
const { pluginManager } = getEnv(session)
const viewType = pluginManager.getViewType(view.type)
if (!viewType) {
throw new Error(`unknown view type ${view.type}`)
}
const { ReactComponent } = viewType
return (
<ViewContainer
view={view}
onClose={() => {
session.removeView(view)
}}
onMinimize={() => {
view.setMinimized(!view.minimized)
}}
>
{!view.minimized ? (
<ErrorBoundary
FallbackComponent={({ error }) => <ErrorMessage error={error} />}
>
<Suspense fallback={<LoadingEllipses variant="h6" />}>
<ReactComponent model={view} session={session} />
</Suspense>
</ErrorBoundary>
) : null}
</ViewContainer>
)
})

export default StaticViewPanel
19 changes: 19 additions & 0 deletions packages/app-core/src/ui/App/StaticViewWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React, { useEffect, useRef } from 'react'
import { observer } from 'mobx-react'

const StaticViewWrapper = observer(function ({
children,
}: {
children: React.ReactNode
}) {
const scrollRef = useRef<HTMLDivElement>(null)

// scroll the view into view when first mounted. note: this effect will run
// only once, because of the empty array second param
useEffect(() => {
scrollRef.current?.scrollIntoView({ block: 'center' })
}, [])
return <div>{children}</div>
})

export default StaticViewWrapper
18 changes: 13 additions & 5 deletions packages/app-core/src/ui/App/ViewContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { observer } from 'mobx-react'
import { makeStyles } from 'tss-react/mui'

import ViewHeader from './ViewHeader'
import StaticViewWrapper from './StaticViewWrapper'

import type { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes/models'
import type { SessionWithFocusedViewAndDrawerWidgets } from '@jbrowse/core/util'
Expand All @@ -24,7 +25,7 @@ const useStyles = makeStyles()(theme => ({
},
}))

const ViewContainer = observer(function ({
const ViewContainer = observer(function ViewContainer2({
view,
onClose,
onMinimize,
Expand All @@ -39,6 +40,7 @@ const ViewContainer = observer(function ({
const ref = useWidthSetter(view, theme.spacing(1))
const { classes, cx } = useStyles()
const session = getSession(view) as SessionWithFocusedViewAndDrawerWidgets
const { focusedViewId } = session

useEffect(() => {
function handleSelectView(e: Event) {
Expand All @@ -61,12 +63,18 @@ const ViewContainer = observer(function ({
elevation={12}
className={cx(
classes.viewContainer,
session.focusedViewId === view.id
? classes.focusedView
: classes.unfocusedView,
focusedViewId === view.id ? classes.focusedView : classes.unfocusedView,
)}
>
<ViewHeader view={view} onClose={onClose} onMinimize={onMinimize} />
{view.isFloating ? (
<div style={{ cursor: 'all-scroll' }}>
<ViewHeader view={view} onClose={onClose} onMinimize={onMinimize} />
</div>
) : (
<StaticViewWrapper>
<ViewHeader view={view} onClose={onClose} onMinimize={onMinimize} />
</StaticViewWrapper>
)}
<Paper>{children}</Paper>
</Paper>
)
Expand Down
1 change: 1 addition & 0 deletions packages/app-core/src/ui/App/ViewContainerTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const useStyles = makeStyles()(theme => ({
backgroundColor: theme.palette.secondary.light,
},
}))

const ViewContainerTitle = observer(function ({
view,
}: {
Expand Down
37 changes: 8 additions & 29 deletions packages/app-core/src/ui/App/ViewHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { makeStyles } from 'tss-react/mui'

import ViewContainerTitle from './ViewContainerTitle'
import ViewMenu from './ViewMenu'
import ViewHeaderButtons from './ViewHeaderButtons'

import type { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes/models'

Expand All @@ -30,32 +31,6 @@ const useStyles = makeStyles()(theme => ({
},
}))

const ViewButtons = observer(function ({
view,
onClose,
onMinimize,
}: {
view: IBaseViewModel
onClose: () => void
onMinimize: () => void
}) {
const { classes } = useStyles()
return (
<>
<IconButton data-testid="minimize_view" onClick={onMinimize}>
{view.minimized ? (
<AddIcon className={classes.icon} fontSize="small" />
) : (
<MinimizeIcon className={classes.icon} fontSize="small" />
)}
</IconButton>
<IconButton data-testid="close_view" onClick={onClose}>
<CloseIcon className={classes.icon} fontSize="small" />
</IconButton>
</>
)
})

const ViewHeader = observer(function ({
view,
onClose,
Expand All @@ -65,7 +40,7 @@ const ViewHeader = observer(function ({
onClose: () => void
onMinimize: () => void
}) {
const { classes } = useStyles()
const { classes, cx } = useStyles()
const scrollRef = useRef<HTMLDivElement>(null)
const session = getSession(view)

Expand All @@ -77,7 +52,7 @@ const ViewHeader = observer(function ({
}
}, [])
return (
<div ref={scrollRef} className={classes.viewHeader}>
<div ref={scrollRef} className={cx('viewHeader', classes.viewHeader)}>
<ViewMenu model={view} IconProps={{ className: classes.icon }} />
<div className={classes.grow} />
<div className={classes.viewTitle}>
Expand All @@ -87,7 +62,11 @@ const ViewHeader = observer(function ({
<ViewContainerTitle view={view} />
</div>
<div className={classes.grow} />
<ViewButtons onClose={onClose} onMinimize={onMinimize} view={view} />
<ViewHeaderButtons
onClose={onClose}
onMinimize={onMinimize}
view={view}
/>
</div>
)
})
Expand Down
53 changes: 53 additions & 0 deletions packages/app-core/src/ui/App/ViewHeaderButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react'
import { IconButton } from '@mui/material'
import { makeStyles } from 'tss-react/mui'
import { observer } from 'mobx-react'
import { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes/models'

// icons
import CloseIcon from '@mui/icons-material/Close'
import MinimizeIcon from '@mui/icons-material/Minimize'
import AddIcon from '@mui/icons-material/Add'
import OpenInNew from '@mui/icons-material/OpenInNew'

const useStyles = makeStyles()(theme => ({
icon: {
color: theme.palette.secondary.contrastText,
},
}))

const ViewHeaderButtons = observer(function ({
view,
onClose,
onMinimize,
}: {
view: IBaseViewModel
onClose: () => void
onMinimize: () => void
}) {
const { classes } = useStyles()
return (
<>
<IconButton
data-testid="open_in_new"
onClick={() => {
view.setIsFloating(!view.isFloating)
}}
>
<OpenInNew className={classes.icon} fontSize="small" />
</IconButton>
<IconButton data-testid="minimize_view" onClick={onMinimize}>
{view.minimized ? (
<AddIcon className={classes.icon} fontSize="small" />
) : (
<MinimizeIcon className={classes.icon} fontSize="small" />
)}
</IconButton>
<IconButton data-testid="close_view" onClick={onClose}>
<CloseIcon className={classes.icon} fontSize="small" />
</IconButton>
</>
)
})

export default ViewHeaderButtons
Loading
Loading