2024-07-24 23:33:31 -04:00
|
|
|
import { useRef, useMemo, memo } from 'react'
|
2023-10-11 15:12:29 +11:00
|
|
|
import { isCursorInSketchCommandRange } from 'lang/util'
|
2024-03-22 16:55:30 +11:00
|
|
|
import { engineCommandManager, kclManager } from 'lib/singletons'
|
2023-10-11 13:36:54 +11:00
|
|
|
import { useModelingContext } from 'hooks/useModelingContext'
|
2023-12-06 14:44:13 -05:00
|
|
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
2024-06-04 08:32:24 -04:00
|
|
|
import { useNetworkContext } from 'hooks/useNetworkContext'
|
|
|
|
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
2023-12-06 14:44:13 -05:00
|
|
|
import { ActionButton } from 'components/ActionButton'
|
2024-02-19 17:23:03 +11:00
|
|
|
import { isSingleCursorInPipe } from 'lang/queryAst'
|
2024-03-22 16:55:30 +11:00
|
|
|
import { useKclContext } from 'lang/KclProvider'
|
2024-05-10 19:02:11 -04:00
|
|
|
import { ActionButtonDropdown } from 'components/ActionButtonDropdown'
|
2024-05-22 11:07:02 -04:00
|
|
|
import { useHotkeys } from 'react-hotkeys-hook'
|
|
|
|
import Tooltip from 'components/Tooltip'
|
2024-07-03 09:22:46 +10:00
|
|
|
import { useAppState } from 'AppState'
|
2024-07-24 23:33:31 -04:00
|
|
|
import { CustomIcon } from 'components/CustomIcon'
|
2024-07-05 13:40:16 +10:00
|
|
|
import {
|
2024-07-24 23:33:31 -04:00
|
|
|
toolbarConfig,
|
|
|
|
ToolbarItem,
|
|
|
|
ToolbarItemCallbackProps,
|
|
|
|
ToolbarItemResolved,
|
|
|
|
ToolbarModeName,
|
|
|
|
} from 'lib/toolbar'
|
2023-09-16 01:23:11 -04:00
|
|
|
|
2024-05-24 20:54:42 +10:00
|
|
|
export function Toolbar({
|
|
|
|
className = '',
|
|
|
|
...props
|
|
|
|
}: React.HTMLAttributes<HTMLElement>) {
|
2023-10-11 13:36:54 +11:00
|
|
|
const { state, send, context } = useModelingContext()
|
2024-05-24 20:54:42 +10:00
|
|
|
const { commandBarSend } = useCommandsContext()
|
2024-04-05 00:59:02 -04:00
|
|
|
const iconClassName =
|
2024-07-24 23:33:31 -04:00
|
|
|
'group-disabled:text-chalkboard-50 !text-inherit dark:group-enabled:group-hover:!text-inherit'
|
|
|
|
const bgClassName = '!bg-transparent'
|
|
|
|
const buttonBgClassName =
|
|
|
|
'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10'
|
|
|
|
const buttonBorderClassName =
|
|
|
|
'!border-transparent hover:!border-chalkboard-20 dark:enabled:hover:!border-primary pressed:!border-primary ui-open:!border-primary'
|
|
|
|
|
|
|
|
const sketchPathId = useMemo(() => {
|
2024-02-19 17:23:03 +11:00
|
|
|
if (!isSingleCursorInPipe(context.selectionRanges, kclManager.ast)) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return isCursorInSketchCommandRange(
|
2024-08-03 18:08:51 +10:00
|
|
|
engineCommandManager.artifactGraph,
|
2024-02-19 17:23:03 +11:00
|
|
|
context.selectionRanges
|
|
|
|
)
|
2024-08-03 18:08:51 +10:00
|
|
|
}, [engineCommandManager.artifactGraph, context.selectionRanges])
|
2024-05-24 20:54:42 +10:00
|
|
|
|
|
|
|
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
2024-06-04 08:32:24 -04:00
|
|
|
const { overallState } = useNetworkContext()
|
2024-02-26 21:02:33 +11:00
|
|
|
const { isExecuting } = useKclContext()
|
2024-07-03 09:22:46 +10:00
|
|
|
const { isStreamReady } = useAppState()
|
|
|
|
|
2024-02-26 21:02:33 +11:00
|
|
|
const disableAllButtons =
|
2024-06-04 08:32:24 -04:00
|
|
|
(overallState !== NetworkHealthState.Ok &&
|
|
|
|
overallState !== NetworkHealthState.Weak) ||
|
|
|
|
isExecuting ||
|
|
|
|
!isStreamReady
|
2023-10-04 12:35:50 -04:00
|
|
|
|
2024-07-24 23:33:31 -04:00
|
|
|
const currentMode =
|
|
|
|
(Object.entries(toolbarConfig).find(([_, mode]) =>
|
|
|
|
mode.check(state)
|
|
|
|
)?.[0] as ToolbarModeName) || 'modeling'
|
|
|
|
|
|
|
|
/** These are the props that will be passed to the callbacks in the toolbar config
|
|
|
|
* They are memoized to prevent unnecessary re-renders,
|
|
|
|
* but they still get a lot of churn from the state machine
|
|
|
|
* so I think there's a lot of room for improvement here
|
|
|
|
*/
|
|
|
|
const configCallbackProps: ToolbarItemCallbackProps = useMemo(
|
|
|
|
() => ({
|
|
|
|
modelingStateMatches: state.matches,
|
|
|
|
modelingSend: send,
|
|
|
|
commandBarSend,
|
|
|
|
sketchPathId,
|
|
|
|
}),
|
|
|
|
[state.matches, send, commandBarSend, sketchPathId]
|
2024-07-15 19:20:32 +10:00
|
|
|
)
|
2024-05-22 11:07:02 -04:00
|
|
|
|
2024-07-24 23:33:31 -04:00
|
|
|
/**
|
|
|
|
* Resolve all the callbacks and values for the current mode,
|
|
|
|
* so we don't need to worry about the other modes
|
|
|
|
*/
|
|
|
|
const currentModeItems: (
|
|
|
|
| ToolbarItemResolved
|
|
|
|
| ToolbarItemResolved[]
|
|
|
|
| 'break'
|
|
|
|
)[] = useMemo(() => {
|
|
|
|
return toolbarConfig[currentMode].items.map((maybeIconConfig) => {
|
|
|
|
if (maybeIconConfig === 'break') {
|
|
|
|
return 'break'
|
|
|
|
} else if (Array.isArray(maybeIconConfig)) {
|
|
|
|
return maybeIconConfig.map(resolveItemConfig)
|
|
|
|
} else {
|
|
|
|
return resolveItemConfig(maybeIconConfig)
|
|
|
|
}
|
|
|
|
})
|
2023-10-04 12:35:50 -04:00
|
|
|
|
2024-07-24 23:33:31 -04:00
|
|
|
function resolveItemConfig(
|
|
|
|
maybeIconConfig: ToolbarItem
|
|
|
|
): ToolbarItemResolved {
|
|
|
|
return {
|
|
|
|
...maybeIconConfig,
|
|
|
|
title:
|
|
|
|
typeof maybeIconConfig.title === 'string'
|
|
|
|
? maybeIconConfig.title
|
|
|
|
: maybeIconConfig.title(configCallbackProps),
|
|
|
|
description: maybeIconConfig.description,
|
|
|
|
links: maybeIconConfig.links || [],
|
|
|
|
isActive: maybeIconConfig.isActive?.(state),
|
|
|
|
hotkey:
|
|
|
|
typeof maybeIconConfig.hotkey === 'string'
|
|
|
|
? maybeIconConfig.hotkey
|
|
|
|
: maybeIconConfig.hotkey?.(state),
|
|
|
|
disabled:
|
|
|
|
disableAllButtons ||
|
|
|
|
maybeIconConfig.status !== 'available' ||
|
|
|
|
maybeIconConfig.disabled?.(state) === true,
|
|
|
|
disableHotkey: maybeIconConfig.disableHotkey?.(state),
|
|
|
|
status: maybeIconConfig.status,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [currentMode, disableAllButtons, configCallbackProps])
|
2023-02-12 10:56:45 +11:00
|
|
|
|
2024-05-24 20:54:42 +10:00
|
|
|
return (
|
2024-07-24 23:33:31 -04:00
|
|
|
<menu className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-20 dark:border-chalkboard-80 border-t-0 shadow-sm">
|
2023-12-06 14:44:13 -05:00
|
|
|
<ul
|
|
|
|
{...props}
|
2023-10-04 12:35:50 -04:00
|
|
|
ref={toolbarButtonsRef}
|
2024-07-24 23:33:31 -04:00
|
|
|
className={
|
|
|
|
'has-[[aria-expanded=true]]:!pointer-events-none m-0 py-1 rounded-l-sm flex gap-1.5 items-center ' +
|
|
|
|
className
|
|
|
|
}
|
2023-10-04 12:35:50 -04:00
|
|
|
>
|
2024-07-24 23:33:31 -04:00
|
|
|
{/* A menu item will either be a vertical line break, a button with a dropdown, or a single button */}
|
|
|
|
{currentModeItems.map((maybeIconConfig, i) => {
|
|
|
|
if (maybeIconConfig === 'break') {
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
key={'break-' + i}
|
|
|
|
className="h-5 w-[1px] block bg-chalkboard-30 dark:bg-chalkboard-80"
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
} else if (Array.isArray(maybeIconConfig)) {
|
|
|
|
return (
|
|
|
|
<ActionButtonDropdown
|
2024-02-11 12:59:00 +11:00
|
|
|
Element="button"
|
2024-07-24 23:33:31 -04:00
|
|
|
key={maybeIconConfig[0].id}
|
|
|
|
data-testid={maybeIconConfig[0].id + '-dropdown'}
|
|
|
|
id={maybeIconConfig[0].id + '-dropdown'}
|
|
|
|
name={maybeIconConfig[0].title}
|
|
|
|
className={
|
|
|
|
'group/wrapper ' +
|
|
|
|
buttonBorderClassName +
|
|
|
|
' !bg-transparent relative group !gap-0'
|
2024-02-11 12:59:00 +11:00
|
|
|
}
|
2024-07-24 23:33:31 -04:00
|
|
|
splitMenuItems={maybeIconConfig.map((itemConfig) => ({
|
|
|
|
id: itemConfig.id,
|
|
|
|
label: itemConfig.title,
|
|
|
|
hotkey: itemConfig.hotkey,
|
|
|
|
onClick: () => itemConfig.onClick(configCallbackProps),
|
|
|
|
disabled:
|
|
|
|
disableAllButtons ||
|
|
|
|
itemConfig.status !== 'available' ||
|
|
|
|
itemConfig.disabled === true,
|
|
|
|
status: itemConfig.status,
|
|
|
|
}))}
|
2024-02-11 12:59:00 +11:00
|
|
|
>
|
2024-07-24 23:33:31 -04:00
|
|
|
<ActionButton
|
|
|
|
Element="button"
|
|
|
|
id={maybeIconConfig[0].id}
|
|
|
|
data-testid={maybeIconConfig[0].id}
|
|
|
|
iconStart={{
|
|
|
|
icon: maybeIconConfig[0].icon,
|
|
|
|
className: iconClassName,
|
|
|
|
bgClassName: bgClassName,
|
|
|
|
}}
|
|
|
|
className={
|
|
|
|
'!border-transparent !px-0 pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' +
|
|
|
|
buttonBgClassName
|
|
|
|
}
|
|
|
|
aria-pressed={maybeIconConfig[0].isActive}
|
|
|
|
disabled={
|
|
|
|
disableAllButtons ||
|
|
|
|
maybeIconConfig[0].status !== 'available' ||
|
|
|
|
maybeIconConfig[0].disabled
|
|
|
|
}
|
|
|
|
name={maybeIconConfig[0].title}
|
2024-08-19 15:36:18 -04:00
|
|
|
// aria-description is still in ARIA 1.3 draft.
|
|
|
|
// eslint-disable-next-line jsx-a11y/aria-props
|
2024-07-24 23:33:31 -04:00
|
|
|
aria-description={maybeIconConfig[0].description}
|
|
|
|
onClick={() =>
|
|
|
|
maybeIconConfig[0].onClick(configCallbackProps)
|
|
|
|
}
|
2024-05-22 11:07:02 -04:00
|
|
|
>
|
2024-08-02 12:25:57 -04:00
|
|
|
<span
|
|
|
|
className={!maybeIconConfig[0].showTitle ? 'sr-only' : ''}
|
|
|
|
>
|
|
|
|
{maybeIconConfig[0].title}
|
|
|
|
</span>
|
2024-07-24 23:33:31 -04:00
|
|
|
</ActionButton>
|
2024-08-02 12:25:57 -04:00
|
|
|
<ToolbarItemTooltip
|
|
|
|
itemConfig={maybeIconConfig[0]}
|
|
|
|
configCallbackProps={configCallbackProps}
|
|
|
|
/>
|
2024-07-24 23:33:31 -04:00
|
|
|
</ActionButtonDropdown>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
const itemConfig = maybeIconConfig
|
|
|
|
|
|
|
|
return (
|
2024-08-02 12:25:57 -04:00
|
|
|
<div className="relative" key={itemConfig.id}>
|
|
|
|
<ActionButton
|
|
|
|
Element="button"
|
|
|
|
key={itemConfig.id}
|
|
|
|
id={itemConfig.id}
|
|
|
|
data-testid={itemConfig.id}
|
|
|
|
iconStart={{
|
|
|
|
icon: itemConfig.icon,
|
|
|
|
className: iconClassName,
|
|
|
|
bgClassName: bgClassName,
|
|
|
|
}}
|
|
|
|
className={
|
|
|
|
'pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' +
|
|
|
|
buttonBorderClassName +
|
|
|
|
' ' +
|
|
|
|
buttonBgClassName +
|
|
|
|
(!itemConfig.showTitle ? ' !px-0' : '')
|
|
|
|
}
|
|
|
|
name={itemConfig.title}
|
2024-08-19 15:36:18 -04:00
|
|
|
// aria-description is still in ARIA 1.3 draft.
|
|
|
|
// eslint-disable-next-line jsx-a11y/aria-props
|
2024-08-02 12:25:57 -04:00
|
|
|
aria-description={itemConfig.description}
|
|
|
|
aria-pressed={itemConfig.isActive}
|
|
|
|
disabled={
|
|
|
|
disableAllButtons ||
|
|
|
|
itemConfig.status !== 'available' ||
|
|
|
|
itemConfig.disabled
|
|
|
|
}
|
|
|
|
onClick={() => itemConfig.onClick(configCallbackProps)}
|
|
|
|
>
|
|
|
|
<span className={!itemConfig.showTitle ? 'sr-only' : ''}>
|
|
|
|
{itemConfig.title}
|
|
|
|
</span>
|
|
|
|
</ActionButton>
|
|
|
|
<ToolbarItemTooltip
|
2024-07-24 23:33:31 -04:00
|
|
|
itemConfig={itemConfig}
|
|
|
|
configCallbackProps={configCallbackProps}
|
|
|
|
/>
|
2024-08-02 12:25:57 -04:00
|
|
|
</div>
|
2024-07-24 23:33:31 -04:00
|
|
|
)
|
|
|
|
})}
|
2023-12-06 14:44:13 -05:00
|
|
|
</ul>
|
2024-07-24 23:33:31 -04:00
|
|
|
{state.matches('Sketch no face') && (
|
|
|
|
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 py-1 px-2 bg-chalkboard-10 dark:bg-chalkboard-90 border border-chalkboard-20 dark:border-chalkboard-80 rounded shadow-lg">
|
|
|
|
<p className="text-xs">Select a plane or face to start sketching</p>
|
|
|
|
</div>
|
|
|
|
)}
|
2024-05-08 09:57:16 -04:00
|
|
|
</menu>
|
2022-11-27 14:06:33 +11:00
|
|
|
)
|
|
|
|
}
|
2024-07-24 23:33:31 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* The single button and dropdown button share content, so we extract it here
|
|
|
|
* It contains a tooltip with the title, description, and links
|
|
|
|
* and a hotkey listener
|
|
|
|
*/
|
2024-08-02 12:25:57 -04:00
|
|
|
const ToolbarItemTooltip = memo(function ToolbarItemContents({
|
2024-07-24 23:33:31 -04:00
|
|
|
itemConfig,
|
|
|
|
configCallbackProps,
|
|
|
|
}: {
|
|
|
|
itemConfig: ToolbarItemResolved
|
|
|
|
configCallbackProps: ToolbarItemCallbackProps
|
|
|
|
}) {
|
|
|
|
useHotkeys(
|
|
|
|
itemConfig.hotkey || '',
|
|
|
|
() => {
|
|
|
|
itemConfig.onClick(configCallbackProps)
|
|
|
|
},
|
|
|
|
{
|
|
|
|
enabled:
|
|
|
|
itemConfig.status === 'available' &&
|
|
|
|
!!itemConfig.hotkey &&
|
|
|
|
!itemConfig.disabled &&
|
|
|
|
!itemConfig.disableHotkey,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
return (
|
2024-08-02 12:25:57 -04:00
|
|
|
<Tooltip
|
|
|
|
inert={false}
|
|
|
|
position="bottom"
|
|
|
|
wrapperClassName="!p-4 !pointer-events-auto"
|
|
|
|
contentClassName="!text-left text-wrap !text-xs !p-0 !pb-2 flex gap-2 !max-w-none !w-72 flex-col items-stretch"
|
|
|
|
>
|
|
|
|
<div className="rounded-top flex items-center gap-2 pt-3 pb-2 px-2 bg-chalkboard-20/50 dark:bg-chalkboard-80/50">
|
|
|
|
<span
|
|
|
|
className={`text-sm flex-1 ${
|
|
|
|
itemConfig.status !== 'available'
|
|
|
|
? 'text-chalkboard-70 dark:text-chalkboard-40'
|
|
|
|
: ''
|
|
|
|
}`}
|
|
|
|
>
|
|
|
|
{itemConfig.title}
|
|
|
|
</span>
|
|
|
|
{itemConfig.status === 'available' && itemConfig.hotkey ? (
|
|
|
|
<kbd className="flex-none hotkey">{itemConfig.hotkey}</kbd>
|
|
|
|
) : itemConfig.status === 'kcl-only' ? (
|
|
|
|
<>
|
|
|
|
<span className="text-wrap font-sans flex-0 text-chalkboard-70 dark:text-chalkboard-40">
|
|
|
|
KCL code only
|
|
|
|
</span>
|
|
|
|
<CustomIcon
|
|
|
|
name="code"
|
|
|
|
className="w-5 h-5 text-chalkboard-70 dark:text-chalkboard-40"
|
|
|
|
/>
|
|
|
|
</>
|
|
|
|
) : (
|
|
|
|
itemConfig.status === 'unavailable' && (
|
2024-07-24 23:33:31 -04:00
|
|
|
<>
|
|
|
|
<span className="text-wrap font-sans flex-0 text-chalkboard-70 dark:text-chalkboard-40">
|
2024-08-02 12:25:57 -04:00
|
|
|
In development
|
2024-07-24 23:33:31 -04:00
|
|
|
</span>
|
|
|
|
<CustomIcon
|
2024-08-02 12:25:57 -04:00
|
|
|
name="lockClosed"
|
2024-07-24 23:33:31 -04:00
|
|
|
className="w-5 h-5 text-chalkboard-70 dark:text-chalkboard-40"
|
|
|
|
/>
|
|
|
|
</>
|
2024-08-02 12:25:57 -04:00
|
|
|
)
|
2024-07-24 23:33:31 -04:00
|
|
|
)}
|
2024-08-02 12:25:57 -04:00
|
|
|
</div>
|
|
|
|
<p className="px-2 text-ch font-sans">{itemConfig.description}</p>
|
|
|
|
{itemConfig.links.length > 0 && (
|
|
|
|
<>
|
|
|
|
<hr className="border-chalkboard-20 dark:border-chalkboard-80" />
|
|
|
|
<ul className="p-0 px-1 m-0 flex flex-col">
|
|
|
|
{itemConfig.links.map((link) => (
|
|
|
|
<li key={link.label} className="contents">
|
|
|
|
<a
|
|
|
|
href={link.url}
|
|
|
|
target="_blank"
|
|
|
|
rel="noreferrer"
|
|
|
|
className="flex items-center rounded-sm p-1 no-underline text-inherit hover:bg-primary/10 hover:text-primary dark:hover:bg-chalkboard-70 dark:hover:text-inherit"
|
|
|
|
>
|
|
|
|
<span className="flex-1">Open {link.label}</span>
|
|
|
|
<CustomIcon name="link" className="w-4 h-4" />
|
|
|
|
</a>
|
|
|
|
</li>
|
|
|
|
))}
|
|
|
|
</ul>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
</Tooltip>
|
2024-07-24 23:33:31 -04:00
|
|
|
)
|
|
|
|
})
|