Files
modeling-app/src/Toolbar.tsx
Pierre Jacquier 21e967ea7f More consistent error handling in modelingMachine codemods (#6910)
* First pass at consistency in modelingMachine codemods

* Add 'no kcl errors' guard instead of if check for all the existing ones

* Add more commands and improve consistency

* Add comments

* Fix test with old kcl that was showcasing the very behavior we're trying to fix
https://kittycadworkspace.slack.com/archives/C07A80B83FS/p1747231832870739?thread_ts=1747231178.515289&cid=C07A80B83FS

* Add test for sketch and helix

* Remove guard use and move hasErrors check closer to updateAst calls

* Revert "Remove guard use and move hasErrors check closer to updateAst calls"

This reverts commit 868ea4b605.

* Remove toasts from guards

* Remove some scene.settled calls

* Lint

* More shaky fixes

* Clean up and more test fixes

---------

Co-authored-by: Frank Noirot <frankjohnson1993@gmail.com>
2025-05-15 12:26:20 -04:00

596 lines
21 KiB
TypeScript

import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useAppState } from '@src/AppState'
import { ActionButton } from '@src/components/ActionButton'
import { ActionButtonDropdown } from '@src/components/ActionButtonDropdown'
import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { useKclContext } from '@src/lang/KclProvider'
import { isCursorInFunctionDefinition } from '@src/lang/queryAst'
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
import { isCursorInSketchCommandRange } from '@src/lang/util'
import { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { editorManager, kclManager } from '@src/lib/singletons'
import type {
ToolbarDropdown,
ToolbarItem,
ToolbarItemCallbackProps,
ToolbarItemResolved,
ToolbarItemResolvedDropdown,
ToolbarModeName,
} from '@src/lib/toolbar'
import { isToolbarItemResolvedDropdown, toolbarConfig } from '@src/lib/toolbar'
import { commandBarActor } from '@src/lib/singletons'
import { filterEscHotkey } from '@src/lib/hotkeyWrapper'
export function Toolbar({
className = '',
...props
}: React.HTMLAttributes<HTMLElement>) {
const { state, send, context } = useModelingContext()
const iconClassName =
'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'
const sketchPathId = useMemo(() => {
if (
isCursorInFunctionDefinition(
kclManager.ast,
context.selectionRanges.graphSelections[0]
)
)
return false
return isCursorInSketchCommandRange(
kclManager.artifactGraph,
context.selectionRanges
)
}, [kclManager.artifactGraph, context.selectionRanges])
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const { overallState, immediateState } = useNetworkContext()
const { isExecuting } = useKclContext()
const { isStreamReady } = useAppState()
const [showRichContent, setShowRichContent] = useState(false)
const disableAllButtons =
(overallState !== NetworkHealthState.Ok &&
overallState !== NetworkHealthState.Weak) ||
isExecuting ||
immediateState.type !== EngineConnectionStateType.ConnectionEstablished ||
!isStreamReady
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(
() => ({
modelingState: state,
modelingSend: send,
sketchPathId,
editorHasFocus: editorManager.editorView?.hasFocus,
}),
[
state,
send,
commandBarActor.send,
sketchPathId,
editorManager.editorView?.hasFocus,
]
)
const tooltipContentClassName = !showRichContent
? ''
: '!text-left text-wrap !text-xs !p-0 !pb-2 flex !max-w-none !w-72 flex-col items-stretch'
const richContentTimeout = useRef<number | null>(null)
const richContentClearTimeout = useRef<number | null>(null)
// On mouse enter, show rich content after a 1s delay
const handleMouseEnter = useCallback(() => {
// Cancel the clear timeout if it's already set
if (richContentClearTimeout.current) {
clearTimeout(richContentClearTimeout.current)
}
// Start our own timeout to show the rich content
richContentTimeout.current = window.setTimeout(() => {
setShowRichContent(true)
if (richContentClearTimeout.current) {
clearTimeout(richContentClearTimeout.current)
}
}, 1000)
}, [setShowRichContent])
// On mouse leave, clear the timeout and hide rich content
const handleMouseLeave = useCallback(() => {
// Clear the timeout to show rich content
if (richContentTimeout.current) {
clearTimeout(richContentTimeout.current)
}
// Start a timeout to hide the rich content
richContentClearTimeout.current = window.setTimeout(() => {
setShowRichContent(false)
if (richContentClearTimeout.current) {
clearTimeout(richContentClearTimeout.current)
}
}, 500)
}, [setShowRichContent])
/**
* Resolve all the callbacks and values for the current mode,
* so we don't need to worry about the other modes
*/
const currentModeItems: (
| ToolbarItemResolved
| ToolbarItemResolvedDropdown
| 'break'
)[] = useMemo(() => {
return toolbarConfig[currentMode].items.map((maybeIconConfig) => {
if (maybeIconConfig === 'break') {
return 'break'
} else if (isToolbarDropdown(maybeIconConfig)) {
return {
id: maybeIconConfig.id,
array: maybeIconConfig.array.map((item) => resolveItemConfig(item)),
}
} else {
return resolveItemConfig(maybeIconConfig)
}
})
function resolveItemConfig(
maybeIconConfig: ToolbarItem
): ToolbarItemResolved {
const isConfiguredAvailable = ['available', 'experimental'].includes(
maybeIconConfig.status
)
const isDisabled =
disableAllButtons ||
!isConfiguredAvailable ||
maybeIconConfig.disabled?.(state) === true ||
kclManager.hasErrors()
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: isDisabled,
disabledReason:
typeof maybeIconConfig.disabledReason === 'function'
? maybeIconConfig.disabledReason(state)
: maybeIconConfig.disabledReason,
disableHotkey: maybeIconConfig.disableHotkey?.(state),
status: maybeIconConfig.status,
}
}
}, [currentMode, disableAllButtons, configCallbackProps])
// To remember the last selected item in an ActionButtonDropdown
const [lastSelectedMultiActionItem, _] = useState(
new Map<
number /* index in currentModeItems */,
number /* index in maybeIconConfig */
>()
)
return (
<menu
data-current-mode={currentMode}
data-onboarding-id="toolbar"
className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-30 dark:border-chalkboard-80 border-t-0 shadow-sm"
>
<ul
{...props}
ref={toolbarButtonsRef}
className={
'has-[[aria-expanded=true]]:!pointer-events-none m-0 py-1 rounded-l-sm flex gap-1.5 items-center ' +
className
}
>
{/* A menu item will either be a vertical line break, a button with a dropdown, or a single button */}
{currentModeItems.map((maybeIconConfig, i) => {
// Vertical Line Break
if (maybeIconConfig === 'break') {
return (
<div
key={'break-' + i}
className="h-5 w-[1px] block bg-chalkboard-30 dark:bg-chalkboard-80"
/>
)
} else if (isToolbarItemResolvedDropdown(maybeIconConfig)) {
// A button with a dropdown
const selectedIcon =
maybeIconConfig.array.find((c) => c.isActive) ||
maybeIconConfig.array[lastSelectedMultiActionItem.get(i) ?? 0]
// Save the last selected item in the dropdown
lastSelectedMultiActionItem.set(
i,
maybeIconConfig.array.indexOf(selectedIcon)
)
return (
<ActionButtonDropdown
Element="button"
key={selectedIcon.id}
data-testid={selectedIcon.id + '-dropdown'}
data-onboarding-id={selectedIcon.id + '-dropdown'}
id={selectedIcon.id + '-dropdown'}
name={maybeIconConfig.id}
className={
(maybeIconConfig.array[0].alwaysDark
? 'dark bg-chalkboard-90 '
: '!bg-transparent ') +
'group/wrapper ' +
buttonBorderClassName +
' relative group !gap-0'
}
splitMenuItems={maybeIconConfig.array.map((itemConfig) => ({
id: itemConfig.id,
label: itemConfig.title,
hotkey: itemConfig.hotkey,
onClick: () => itemConfig.onClick(configCallbackProps),
disabled:
disableAllButtons ||
!['available', 'experimental'].includes(
itemConfig.status
) ||
itemConfig.disabled === true ||
itemConfig.disableHotkey === true,
status: itemConfig.status,
}))}
>
<div
className="contents"
// Mouse events do not fire on disabled buttons
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<ActionButton
Element="button"
id={selectedIcon.id}
data-testid={selectedIcon.id}
data-onboarding-id={selectedIcon.id}
iconStart={{
icon: selectedIcon.icon,
iconColor: selectedIcon.iconColor,
className: iconClassName,
bgClassName: bgClassName,
}}
className={
'!border-transparent !px-0 pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' +
buttonBgClassName
}
aria-pressed={selectedIcon.isActive}
disabled={
disableAllButtons ||
!['available', 'experimental'].includes(
selectedIcon.status
) ||
selectedIcon.disabled
}
name={selectedIcon.title}
// aria-description is still in ARIA 1.3 draft.
// eslint-disable-next-line jsx-a11y/aria-props
aria-description={selectedIcon.description}
onClick={() => selectedIcon.onClick(configCallbackProps)}
>
<span className={!selectedIcon.showTitle ? 'sr-only' : ''}>
{selectedIcon.title}
</span>
<ToolbarItemTooltip
itemConfig={selectedIcon}
configCallbackProps={configCallbackProps}
wrapperClassName="ui-open:!hidden"
contentClassName={tooltipContentClassName}
>
{showRichContent ? (
<ToolbarItemTooltipRichContent
itemConfig={selectedIcon}
/>
) : (
<ToolbarItemTooltipShortContent
status={selectedIcon.status}
title={selectedIcon.title}
hotkey={selectedIcon.hotkey}
/>
)}
</ToolbarItemTooltip>
</ActionButton>
</div>
</ActionButtonDropdown>
)
}
const itemConfig = maybeIconConfig
// A single button
return (
<div
className="relative"
key={itemConfig.id}
// Mouse events do not fire on disabled buttons
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<ActionButton
Element="button"
key={itemConfig.id}
id={itemConfig.id}
data-testid={itemConfig.id}
data-onboarding-id={itemConfig.id}
iconStart={{
icon: itemConfig.icon,
iconColor: itemConfig.iconColor,
className: iconClassName,
bgClassName: bgClassName,
}}
className={
'pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' +
buttonBorderClassName +
' ' +
buttonBgClassName +
(!itemConfig.showTitle ? ' !px-0' : '')
}
name={itemConfig.title}
// aria-description is still in ARIA 1.3 draft.
// eslint-disable-next-line jsx-a11y/aria-props
aria-description={itemConfig.description}
aria-pressed={itemConfig.isActive}
disabled={
disableAllButtons ||
!['available', 'experimental'].includes(itemConfig.status) ||
itemConfig.disabled
}
onClick={() => itemConfig.onClick(configCallbackProps)}
>
<span className={!itemConfig.showTitle ? 'sr-only' : ''}>
{itemConfig.title}
</span>
</ActionButton>
<ToolbarItemTooltip
itemConfig={itemConfig}
configCallbackProps={configCallbackProps}
contentClassName={tooltipContentClassName}
>
{showRichContent ? (
<ToolbarItemTooltipRichContent itemConfig={itemConfig} />
) : (
<ToolbarItemTooltipShortContent
status={itemConfig.status}
title={itemConfig.title}
hotkey={itemConfig.hotkey}
/>
)}
</ToolbarItemTooltip>
</div>
)
})}
</ul>
{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>
)}
</menu>
)
}
interface ToolbarItemContentsProps extends React.PropsWithChildren {
itemConfig: ToolbarItemResolved
configCallbackProps: ToolbarItemCallbackProps
wrapperClassName?: string
contentClassName?: string
}
/**
* 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
*/
const ToolbarItemTooltip = memo(function ToolbarItemContents({
itemConfig,
configCallbackProps,
wrapperClassName = '',
contentClassName = '',
children,
}: ToolbarItemContentsProps) {
/**
* GOTCHA: `useHotkeys` can only register one hotkey listener per component.
* TODO: make a global hotkey registration system. make them editable.
*/
useHotkeys(
itemConfig.hotkey || '',
() => {
itemConfig.onClick(configCallbackProps)
},
{
enabled:
['available', 'experimental'].includes(itemConfig.status) &&
!!itemConfig.hotkey &&
!itemConfig.disabled &&
!itemConfig.disableHotkey,
}
)
return (
<Tooltip
inert={false}
wrapperStyle={
isDesktop()
? // Without this, the tooltip disappears before being able to click on anything in it
({ WebkitAppRegion: 'no-drag' } as React.CSSProperties)
: {}
}
hoverOnly
position="bottom"
wrapperClassName={'!p-4 !pointer-events-auto ' + wrapperClassName}
contentClassName={contentClassName}
>
{children}
{kclManager.hasErrors() && (
<p className="text-xs p-1 text-chalkboard-70 dark:text-chalkboard-40">
<CustomIcon
name="exclamationMark"
className="w-4 h-4 inline-block mr-1 text-destroy-80 bg-destroy-10"
/>
Fix KCL errors to enable tools
</p>
)}
</Tooltip>
)
})
const ToolbarItemTooltipShortContent = ({
status,
title,
hotkey,
}: {
status: string
title: string
hotkey?: string | string[]
}) => (
<div
className={`text-sm flex flex-col ${
!['available', 'experimental'].includes(status)
? 'text-chalkboard-70 dark:text-chalkboard-40'
: ''
}`}
>
{status === 'experimental' && (
<div className="text-xs flex justify-center item-center gap-1 pb-1 border-b border-chalkboard-50">
<CustomIcon name="beaker" className="w-4 h-4" />
<span>Experimental</span>
</div>
)}
<div className={`flex gap-4 ${status === 'experimental' ? 'pt-1' : 'p-0'}`}>
{title}
{hotkey && (
<kbd className="inline-block ml-2 flex-none hotkey">
{filterEscHotkey(hotkey)}
</kbd>
)}
</div>
</div>
)
const ToolbarItemTooltipRichContent = ({
itemConfig,
}: {
itemConfig: ToolbarItemResolved
}) => {
const shouldBeEnabled = ['available', 'experimental'].includes(
itemConfig.status
)
const { state } = useModelingContext()
return (
<>
{itemConfig.status === 'experimental' && (
<div className="text-xs flex items-center justify-center self-stretch gap-1 p-1 border-b">
<CustomIcon name="beaker" className="w-4 h-4" />
<span className="block">Experimental</span>
</div>
)}
<div className="rounded-top flex items-center gap-2 pt-3 pb-2 px-2 bg-chalkboard-20/50 dark:bg-chalkboard-80/50">
{itemConfig.icon && (
<CustomIcon
className="w-5 h-5"
style={{ color: itemConfig.iconColor }}
name={itemConfig.icon}
/>
)}
<div
className={`text-sm flex-1 flex flex-col gap-1 ${
!shouldBeEnabled ? 'text-chalkboard-70 dark:text-chalkboard-40' : ''
}`}
>
{itemConfig.title}
</div>
{shouldBeEnabled && itemConfig.hotkey ? (
<kbd className="flex-none hotkey">
{filterEscHotkey(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' && (
<>
<span className="text-wrap font-sans flex-0 text-chalkboard-70 dark:text-chalkboard-40">
In development
</span>
<CustomIcon
name="lockClosed"
className="w-5 h-5 text-chalkboard-70 dark:text-chalkboard-40"
/>
</>
)
)}
</div>
<p className="px-2 my-2 text-ch font-sans">{itemConfig.description}</p>
{/* Add disabled reason if item is disabled */}
{itemConfig.disabled && itemConfig.disabledReason && (
<>
<hr className="border-chalkboard-20 dark:border-chalkboard-80" />
<p className="px-2 my-2 text-ch font-sans text-chalkboard-70 dark:text-chalkboard-40">
{typeof itemConfig.disabledReason === 'function'
? itemConfig.disabledReason(state)
: itemConfig.disabledReason}
</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}
onClick={openExternalBrowserIfDesktop(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>
</>
)}
</>
)
}
function isToolbarDropdown(
item: ToolbarItem | ToolbarDropdown
): item is ToolbarDropdown {
return 'array' in item
}