Reunite split sidebar, add ability to register action buttons to it (#3100)
* Rework ribbon to support panes and actions * Restore nice focus-within highlighting * A better export icon * Fix up some issues with tests due to sidebar and tooltip tweaks * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Re-run CI --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@ -1,39 +1,84 @@
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { Resizable } from 're-resizable'
|
||||
import { HTMLAttributes, useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { Tab } from '@headlessui/react'
|
||||
import {
|
||||
SidebarPane,
|
||||
SidebarType,
|
||||
bottomPanes,
|
||||
topPanes,
|
||||
} from './ModelingPanes'
|
||||
import { SidebarType, sidebarPanes } from './ModelingPanes'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { ActionIcon } from 'components/ActionIcon'
|
||||
import styles from './ModelingSidebar.module.css'
|
||||
import { ModelingPane } from './ModelingPane'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { CustomIconName } from 'components/CustomIcon'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface ModelingSidebarProps {
|
||||
paneOpacity: '' | 'opacity-20' | 'opacity-40'
|
||||
}
|
||||
|
||||
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const onboardingStatus = settings.context.app.onboardingStatus
|
||||
const { context } = useModelingContext()
|
||||
const { send, context } = useModelingContext()
|
||||
const pointerEventsCssClass =
|
||||
context.store?.buttonDownInStream ||
|
||||
onboardingStatus.current === 'camera' ||
|
||||
context.store?.openPanes.length === 0
|
||||
? 'pointer-events-none '
|
||||
: 'pointer-events-auto '
|
||||
const showDebugPanel = settings.context.modeling.showDebugPanel
|
||||
|
||||
const sidebarActions: SidebarAction[] = [
|
||||
{
|
||||
id: 'export',
|
||||
title: 'Export part',
|
||||
icon: 'floppyDiskArrow',
|
||||
iconClassName: '!p-0',
|
||||
keybinding: 'Ctrl + Shift + E',
|
||||
action: () =>
|
||||
commandBarSend({
|
||||
type: 'Find and select command',
|
||||
data: { name: 'Export', groupId: 'modeling' },
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
// // Filter out the debug panel if it's not supposed to be shown
|
||||
// // TODO: abstract out for allowing user to configure which panes to show
|
||||
const filteredPanes = useMemo(
|
||||
() =>
|
||||
(showDebugPanel.current
|
||||
? sidebarPanes
|
||||
: sidebarPanes.filter((pane) => pane.id !== 'debug')
|
||||
).filter(
|
||||
(pane) =>
|
||||
!pane.hideOnPlatform ||
|
||||
(isTauri()
|
||||
? pane.hideOnPlatform === 'web'
|
||||
: pane.hideOnPlatform === 'desktop')
|
||||
),
|
||||
[sidebarPanes, showDebugPanel.current]
|
||||
)
|
||||
|
||||
const togglePane = useCallback(
|
||||
(newPane: SidebarType) => {
|
||||
send({
|
||||
type: 'Set context',
|
||||
data: {
|
||||
openPanes: context.store?.openPanes.includes(newPane)
|
||||
? context.store?.openPanes.filter((pane) => pane !== newPane)
|
||||
: [...context.store?.openPanes, newPane],
|
||||
},
|
||||
})
|
||||
},
|
||||
[context.store?.openPanes, send]
|
||||
)
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
className={`flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}
|
||||
className={`group flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}
|
||||
defaultSize={{
|
||||
width: '550px',
|
||||
height: 'auto',
|
||||
@ -54,153 +99,64 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
|
||||
}}
|
||||
>
|
||||
<div id="app-sidebar" className={styles.grid + ' flex-1'}>
|
||||
<ModelingSidebarSection id="sidebar-top" panes={topPanes} />
|
||||
<ModelingSidebarSection
|
||||
id="sidebar-bottom"
|
||||
panes={bottomPanes}
|
||||
alignButtons="end"
|
||||
/>
|
||||
</div>
|
||||
</Resizable>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelingSidebarSectionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
panes: SidebarPane[]
|
||||
alignButtons?: 'start' | 'end'
|
||||
}
|
||||
|
||||
function ModelingSidebarSection({
|
||||
panes,
|
||||
alignButtons = 'start',
|
||||
className,
|
||||
...props
|
||||
}: ModelingSidebarSectionProps) {
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const showDebugPanel = settings.context.modeling.showDebugPanel
|
||||
const paneIds = panes.map((pane) => pane.id)
|
||||
const { send, context } = useModelingContext()
|
||||
const foundOpenPane = context.store?.openPanes.find((pane) =>
|
||||
paneIds.includes(pane)
|
||||
)
|
||||
const [currentPane, setCurrentPane] = useState(
|
||||
foundOpenPane || ('none' as SidebarType | 'none')
|
||||
)
|
||||
|
||||
const togglePane = useCallback(
|
||||
(newPane: SidebarType | 'none') => {
|
||||
if (newPane === 'none') {
|
||||
send({
|
||||
type: 'Set context',
|
||||
data: {
|
||||
openPanes: context.store?.openPanes.filter(
|
||||
(p) => p !== currentPane
|
||||
),
|
||||
},
|
||||
})
|
||||
setCurrentPane('none')
|
||||
} else if (newPane === currentPane) {
|
||||
setCurrentPane('none')
|
||||
send({
|
||||
type: 'Set context',
|
||||
data: {
|
||||
openPanes: context.store?.openPanes.filter((p) => p !== newPane),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
send({
|
||||
type: 'Set context',
|
||||
data: {
|
||||
openPanes: [
|
||||
...context.store?.openPanes.filter((p) => p !== currentPane),
|
||||
newPane,
|
||||
],
|
||||
},
|
||||
})
|
||||
setCurrentPane(newPane)
|
||||
}
|
||||
},
|
||||
[context.store?.openPanes, send, currentPane, setCurrentPane]
|
||||
)
|
||||
|
||||
// Filter out the debug panel if it's not supposed to be shown
|
||||
// TODO: abstract out for allowing user to configure which panes to show
|
||||
const filteredPanes = (
|
||||
showDebugPanel.current ? panes : panes.filter((pane) => pane.id !== 'debug')
|
||||
).filter(
|
||||
(pane) =>
|
||||
!pane.hideOnPlatform ||
|
||||
(isTauri()
|
||||
? pane.hideOnPlatform === 'web'
|
||||
: pane.hideOnPlatform === 'desktop')
|
||||
)
|
||||
useEffect(() => {
|
||||
if (
|
||||
!showDebugPanel.current &&
|
||||
currentPane === 'debug' &&
|
||||
context.store?.openPanes.includes('debug')
|
||||
) {
|
||||
togglePane('debug')
|
||||
}
|
||||
}, [showDebugPanel.current, togglePane, context.store?.openPanes])
|
||||
|
||||
return (
|
||||
<div className={'group contents ' + className} {...props}>
|
||||
<Tab.Group
|
||||
vertical
|
||||
selectedIndex={
|
||||
currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1
|
||||
}
|
||||
onChange={(index) => {
|
||||
const newPane = index === 0 ? 'none' : paneIds[index - 1]
|
||||
togglePane(newPane)
|
||||
}}
|
||||
>
|
||||
<Tab.List
|
||||
id={`${props.id}-ribbon`}
|
||||
<ul
|
||||
className={
|
||||
'pointer-events-auto ' +
|
||||
(alignButtons === 'start'
|
||||
? 'justify-start self-start'
|
||||
: 'justify-end self-end') +
|
||||
(currentPane === 'none'
|
||||
? ' rounded-r focus-within:!border-primary/50'
|
||||
: ' border-r-0') +
|
||||
' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 ' +
|
||||
'bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 ' +
|
||||
(context.store?.openPanes.length === 1 && currentPane === 'none'
|
||||
? 'pr-0.5'
|
||||
: '')
|
||||
(context.store?.openPanes.length === 0 ? 'rounded-r ' : '') +
|
||||
'relative z-[2] pointer-events-auto p-0 col-start-1 col-span-1 h-fit w-fit flex flex-col ' +
|
||||
'bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 '
|
||||
}
|
||||
>
|
||||
<Tab key="none" className="sr-only">
|
||||
No panes open
|
||||
</Tab>
|
||||
{filteredPanes.map((pane) => (
|
||||
<ModelingPaneButton
|
||||
key={pane.id}
|
||||
paneConfig={pane}
|
||||
currentPane={currentPane}
|
||||
togglePane={() => togglePane(pane.id)}
|
||||
/>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels
|
||||
id={`${props.id}-pane`}
|
||||
as="article"
|
||||
<ul
|
||||
id="pane-buttons-section"
|
||||
className={
|
||||
'w-fit p-2 flex flex-col gap-2 ' +
|
||||
(context.store?.openPanes.length >= 1 ? 'pr-0.5' : '')
|
||||
}
|
||||
>
|
||||
{filteredPanes.map((pane) => (
|
||||
<ModelingPaneButton
|
||||
key={pane.id}
|
||||
paneConfig={pane}
|
||||
paneIsOpen={context.store?.openPanes.includes(pane.id)}
|
||||
onClick={() => togglePane(pane.id)}
|
||||
aria-pressed={context.store?.openPanes.includes(pane.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<hr className="w-full border-chalkboard-20 dark:border-chalkboard-80" />
|
||||
<ul id="sidebar-actions" className="w-fit p-2 flex flex-col gap-2">
|
||||
{sidebarActions.map((action) => (
|
||||
<ModelingPaneButton
|
||||
key={action.id}
|
||||
paneConfig={{
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
keybinding: action.keybinding,
|
||||
iconClassName: action.iconClassName,
|
||||
iconSize: 'md',
|
||||
}}
|
||||
paneIsOpen={false}
|
||||
onClick={action.action}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
<ul
|
||||
id="pane-section"
|
||||
className={
|
||||
'col-start-2 col-span-1 ' +
|
||||
(context.store?.openPanes.length === 1
|
||||
? currentPane !== 'none'
|
||||
? `row-start-1 row-end-3`
|
||||
: `hidden`
|
||||
: ``)
|
||||
'ml-[-1px] col-start-2 col-span-1 flex flex-col gap-2 ' +
|
||||
(context.store?.openPanes.length >= 1
|
||||
? `row-start-1 row-end-3`
|
||||
: `hidden`)
|
||||
}
|
||||
>
|
||||
<Tab.Panel key="none" />
|
||||
{filteredPanes.map((pane) => (
|
||||
<Tab.Panel key={pane.id} className="h-full">
|
||||
{filteredPanes
|
||||
.filter((pane) => context?.store.openPanes.includes(pane.id))
|
||||
.map((pane) => (
|
||||
<ModelingPane
|
||||
key={pane.id}
|
||||
icon={pane.icon}
|
||||
id={`${pane.id}-pane`}
|
||||
title={pane.title}
|
||||
Menu={pane.Menu}
|
||||
@ -212,55 +168,76 @@ function ModelingSidebarSection({
|
||||
pane.Content
|
||||
)}
|
||||
</ModelingPane>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Resizable>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelingPaneButtonProps {
|
||||
paneConfig: SidebarPane
|
||||
currentPane: SidebarType | 'none'
|
||||
togglePane: () => void
|
||||
interface ModelingPaneButtonProps
|
||||
extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
paneConfig: {
|
||||
id: string
|
||||
title: string
|
||||
icon: CustomIconName | IconDefinition
|
||||
keybinding: string
|
||||
iconClassName?: string
|
||||
iconSize?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
onClick: () => void
|
||||
paneIsOpen: boolean
|
||||
}
|
||||
|
||||
function ModelingPaneButton({
|
||||
paneConfig,
|
||||
currentPane,
|
||||
togglePane,
|
||||
onClick,
|
||||
paneIsOpen,
|
||||
...props
|
||||
}: ModelingPaneButtonProps) {
|
||||
useHotkeys(paneConfig.keybinding, togglePane, {
|
||||
useHotkeys(paneConfig.keybinding, onClick, {
|
||||
scopes: ['modeling'],
|
||||
})
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={paneConfig.id}
|
||||
className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-none"
|
||||
onClick={togglePane}
|
||||
<button
|
||||
className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
|
||||
onClick={onClick}
|
||||
data-testid={paneConfig.title}
|
||||
{...props}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={paneConfig.icon}
|
||||
className="p-1"
|
||||
size="sm"
|
||||
className={'p-1 ' + paneConfig.iconClassName || ''}
|
||||
size={paneConfig.iconSize || 'sm'}
|
||||
iconClassName={
|
||||
paneConfig.id === currentPane
|
||||
paneIsOpen
|
||||
? ' !text-chalkboard-10'
|
||||
: '!text-chalkboard-80 dark:!text-chalkboard-30'
|
||||
}
|
||||
bgClassName={
|
||||
'rounded-sm ' +
|
||||
(paneConfig.id === currentPane ? '!bg-primary' : '!bg-transparent')
|
||||
'rounded-sm ' + (paneIsOpen ? '!bg-primary' : '!bg-transparent')
|
||||
}
|
||||
/>
|
||||
<Tooltip position="right" hoverOnly delay={800}>
|
||||
<span>{paneConfig.title}</span>
|
||||
<br />
|
||||
<span className="text-xs capitalize">{paneConfig.keybinding}</span>
|
||||
<Tooltip
|
||||
position="right"
|
||||
className="!max-w-none flex gap-4 items-center justify-between"
|
||||
hoverOnly
|
||||
delay={800}
|
||||
>
|
||||
<span className="flex-none">{paneConfig.title}</span>
|
||||
<kbd className="hotkey">{paneConfig.keybinding}</kbd>
|
||||
</Tooltip>
|
||||
</Tab>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export type SidebarAction = {
|
||||
id: string
|
||||
title: string
|
||||
icon: CustomIconName
|
||||
iconClassName?: string // Just until we get rid of FontAwesome icons
|
||||
keybinding: string
|
||||
action: () => void
|
||||
hideOnPlatform?: 'desktop' | 'web'
|
||||
}
|
||||
|
Reference in New Issue
Block a user