Command bar: add extrude command, nonlinear editing, etc (#1204)
* Tweak toaster look and feel * Add icons, tweak plus icon names * Rename commandBarMeta to commandBarConfig * Refactor command bar, add support for icons * Create a tailwind plugin for aria-pressed button state * Remove overlay from behind command bar * Clean up toolbar * Button and other style tweaks * Icon tweaks follow-up: make old icons work with new sizing * Delete unused static icons * More CSS tweaks * Small CSS tweak to project sidebar * Add command bar E2E test * fumpt * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * fix typo in a comment * Fix icon padding (built version only) * Update onboarding and warning banner icons padding * Misc minor style fixes * Get Extrude opening and canceling from command bar * Iconography tweaks * Get extrude kind of working * Refactor command bar config types and organization * Move command bar configs to be co-located with each other * Start building a state machine for the command bar * Start converting command bar to state machine * Add support for multiple args, confirmation step * Submission behavior, hotkeys, code organization * Add new test for extruding from command bar * Polish step back and selection hotkeys, CSS tweaks * Loading style tweaks * Validate selection inputs, polish UX of args re-editing * Prevent submission with multiple selection on singlular arg * Remove stray console logs * Tweak test, CSS nit, remove extrude "result" argument * Fix linting warnings * Show Ctrl+/ instead of ⌘K on all platforms but Mac * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Add "Enter sketch" to command bar * fix command bar test * Fix flaky cmd bar extrude test by waiting for engine select response * Cover both button labels '⌘K' and 'Ctrl+/' in test --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'
|
|||||||
import { getUtils } from './test-utils'
|
import { getUtils } from './test-utils'
|
||||||
import waitOn from 'wait-on'
|
import waitOn from 'wait-on'
|
||||||
import { Themes } from '../../src/lib/theme'
|
import { Themes } from '../../src/lib/theme'
|
||||||
|
import { platform } from '@tauri-apps/api/os'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
|
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
|
||||||
@ -643,7 +644,11 @@ test('Command bar works and can change a setting', async ({ page }) => {
|
|||||||
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||||
|
|
||||||
// First try opening the command bar and closing it
|
// First try opening the command bar and closing it
|
||||||
await page.getByRole('button', { name: '⌘K' }).click()
|
// It has a different label on mac and windows/linux, "Meta+K" and "Ctrl+/" respectively
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Ctrl+/' })
|
||||||
|
.or(page.getByRole('button', { name: '⌘K' }))
|
||||||
|
.click()
|
||||||
await expect(cmdSearchBar).toBeVisible()
|
await expect(cmdSearchBar).toBeVisible()
|
||||||
await page.keyboard.press('Escape')
|
await page.keyboard.press('Escape')
|
||||||
await expect(cmdSearchBar).not.toBeVisible()
|
await expect(cmdSearchBar).not.toBeVisible()
|
||||||
@ -658,12 +663,12 @@ test('Command bar works and can change a setting', async ({ page }) => {
|
|||||||
const themeOption = page.getByRole('option', { name: 'Set Theme' })
|
const themeOption = page.getByRole('option', { name: 'Set Theme' })
|
||||||
await expect(themeOption).toBeVisible()
|
await expect(themeOption).toBeVisible()
|
||||||
await themeOption.click()
|
await themeOption.click()
|
||||||
const themeInput = page.getByPlaceholder(Themes.System)
|
const themeInput = page.getByPlaceholder('Select an option')
|
||||||
await expect(themeInput).toBeVisible()
|
await expect(themeInput).toBeVisible()
|
||||||
await expect(themeInput).toBeFocused()
|
await expect(themeInput).toBeFocused()
|
||||||
// Select dark theme
|
// Select dark theme
|
||||||
await page.keyboard.press('ArrowDown')
|
await page.keyboard.press('ArrowDown')
|
||||||
await page.keyboard.press('ArrowUp')
|
await page.keyboard.press('ArrowDown')
|
||||||
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
|
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
|
||||||
'data-headlessui-state',
|
'data-headlessui-state',
|
||||||
'active'
|
'active'
|
||||||
@ -675,3 +680,59 @@ test('Command bar works and can change a setting', async ({ page }) => {
|
|||||||
// Check that the theme changed
|
// Check that the theme changed
|
||||||
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`)
|
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Can extrude from the command bar', async ({ page, context }) => {
|
||||||
|
await context.addInitScript(async (token) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'persistCode',
|
||||||
|
`const part001 = startSketchOn('-XZ')
|
||||||
|
|> startProfileAt([-6.95, 4.98], %)
|
||||||
|
|> line([25.1, 0.41], %)
|
||||||
|
|> line([0.73, -14.93], %)
|
||||||
|
|> line([-23.44, 0.52], %)
|
||||||
|
|> close(%)`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const u = getUtils(page)
|
||||||
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
|
await page.goto('/')
|
||||||
|
await u.waitForAuthSkipAppStart()
|
||||||
|
|
||||||
|
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||||
|
await page.keyboard.press('Meta+K')
|
||||||
|
await expect(cmdSearchBar).toBeVisible()
|
||||||
|
|
||||||
|
// Search for extrude command and choose it
|
||||||
|
await page.getByRole('option', { name: 'Extrude' }).click()
|
||||||
|
await expect(page.locator('#arg-form > label')).toContainText(
|
||||||
|
'Please select one face'
|
||||||
|
)
|
||||||
|
await expect(page.getByRole('button', { name: 'selection' })).toBeDisabled()
|
||||||
|
|
||||||
|
// Click to select face and set distance
|
||||||
|
await u.openAndClearDebugPanel()
|
||||||
|
await page.getByText('|> line([25.1, 0.41], %)').click()
|
||||||
|
await u.waitForCmdReceive('select_add')
|
||||||
|
await u.closeDebugPanel()
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
|
||||||
|
// Review step and argument hotkeys
|
||||||
|
await page.keyboard.press('2')
|
||||||
|
await expect(page.getByRole('button', { name: '5' })).toBeDisabled()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
|
||||||
|
// Check that the code was updated
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
await expect(page.locator('.cm-content')).toHaveText(
|
||||||
|
`const part001 = startSketchOn('-XZ')
|
||||||
|
|> startProfileAt([-6.95, 4.98], %)
|
||||||
|
|> line([25.1, 0.41], %)
|
||||||
|
|> line([0.73, -14.93], %)
|
||||||
|
|> line([-23.44, 0.52], %)
|
||||||
|
|> close(%)
|
||||||
|
|> extrude(5, %)`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 113 KiB |
Binary file not shown.
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
Binary file not shown.
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 54 KiB |
@ -8,7 +8,7 @@ import {
|
|||||||
createRoutesFromElements,
|
createRoutesFromElements,
|
||||||
} from 'react-router-dom'
|
} from 'react-router-dom'
|
||||||
import { GlobalStateProvider } from './components/GlobalStateProvider'
|
import { GlobalStateProvider } from './components/GlobalStateProvider'
|
||||||
import CommandBarProvider from 'components/CommandBar'
|
import CommandBarProvider from 'components/CommandBar/CommandBar'
|
||||||
import ModelingMachineProvider from 'components/ModelingMachineProvider'
|
import ModelingMachineProvider from 'components/ModelingMachineProvider'
|
||||||
import { BROWSER_FILE_NAME } from 'Router'
|
import { BROWSER_FILE_NAME } from 'Router'
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ import {
|
|||||||
settingsMachine,
|
settingsMachine,
|
||||||
} from './machines/settingsMachine'
|
} from './machines/settingsMachine'
|
||||||
import { ContextFrom } from 'xstate'
|
import { ContextFrom } from 'xstate'
|
||||||
import CommandBarProvider from 'components/CommandBar'
|
import CommandBarProvider from 'components/CommandBar/CommandBar'
|
||||||
import { TEST, VITE_KC_SENTRY_DSN } from './env'
|
import { TEST, VITE_KC_SENTRY_DSN } from './env'
|
||||||
import * as Sentry from '@sentry/react'
|
import * as Sentry from '@sentry/react'
|
||||||
import ModelingMachineProvider from 'components/ModelingMachineProvider'
|
import ModelingMachineProvider from 'components/ModelingMachineProvider'
|
||||||
|
@ -4,9 +4,11 @@ import { engineCommandManager } from './lang/std/engineConnection'
|
|||||||
import { useModelingContext } from 'hooks/useModelingContext'
|
import { useModelingContext } from 'hooks/useModelingContext'
|
||||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
import { ActionButton } from 'components/ActionButton'
|
import { ActionButton } from 'components/ActionButton'
|
||||||
|
import usePlatform from 'hooks/usePlatform'
|
||||||
|
|
||||||
export const Toolbar = () => {
|
export const Toolbar = () => {
|
||||||
const { setCommandBarOpen } = useCommandsContext()
|
const platform = usePlatform()
|
||||||
|
const { commandBarSend } = useCommandsContext()
|
||||||
const { state, send, context } = useModelingContext()
|
const { state, send, context } = useModelingContext()
|
||||||
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
||||||
const bgClassName =
|
const bgClassName =
|
||||||
@ -177,10 +179,15 @@ export const Toolbar = () => {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
onClick={() => send('extrude intent')}
|
onClick={() =>
|
||||||
disabled={!state.can('extrude intent')}
|
commandBarSend({
|
||||||
|
type: 'Find and select command',
|
||||||
|
data: { name: 'Extrude', ownerMachine: 'modeling' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!state.can('Extrude')}
|
||||||
title={
|
title={
|
||||||
state.can('extrude intent')
|
state.can('Extrude')
|
||||||
? 'extrude'
|
? 'extrude'
|
||||||
: 'sketches need to be closed, or not already extruded'
|
: 'sketches need to be closed, or not already extruded'
|
||||||
}
|
}
|
||||||
@ -204,10 +211,10 @@ export const Toolbar = () => {
|
|||||||
</menu>
|
</menu>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
onClick={() => setCommandBarOpen(true)}
|
onClick={() => commandBarSend({ type: 'Open' })}
|
||||||
className="rounded-r-full pr-4 self-stretch border-energy-10 hover:border-energy-10 dark:border-chalkboard-80 bg-energy-10/50 hover:bg-energy-10 dark:bg-chalkboard-80 dark:text-energy-10"
|
className="rounded-r-full pr-4 self-stretch border-energy-10 hover:border-energy-10 dark:border-chalkboard-80 bg-energy-10/50 hover:bg-energy-10 dark:bg-chalkboard-80 dark:text-energy-10"
|
||||||
>
|
>
|
||||||
⌘K
|
{platform === 'darwin' ? '⌘K' : 'Ctrl+/'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -7,6 +7,7 @@ import styles from './AppHeader.module.css'
|
|||||||
import { NetworkHealthIndicator } from './NetworkHealthIndicator'
|
import { NetworkHealthIndicator } from './NetworkHealthIndicator'
|
||||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
import { ActionButton } from './ActionButton'
|
import { ActionButton } from './ActionButton'
|
||||||
|
import usePlatform from 'hooks/usePlatform'
|
||||||
|
|
||||||
interface AppHeaderProps extends React.PropsWithChildren {
|
interface AppHeaderProps extends React.PropsWithChildren {
|
||||||
showToolbar?: boolean
|
showToolbar?: boolean
|
||||||
@ -22,7 +23,8 @@ export const AppHeader = ({
|
|||||||
className = '',
|
className = '',
|
||||||
enableMenu = false,
|
enableMenu = false,
|
||||||
}: AppHeaderProps) => {
|
}: AppHeaderProps) => {
|
||||||
const { setCommandBarOpen } = useCommandsContext()
|
const platform = usePlatform()
|
||||||
|
const { commandBarSend } = useCommandsContext()
|
||||||
const { auth } = useGlobalStateContext()
|
const { auth } = useGlobalStateContext()
|
||||||
const user = auth?.context?.user
|
const user = auth?.context?.user
|
||||||
|
|
||||||
@ -47,12 +49,12 @@ export const AppHeader = ({
|
|||||||
) : (
|
) : (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
onClick={() => setCommandBarOpen(true)}
|
onClick={() => commandBarSend({ type: 'Open' })}
|
||||||
className="text-sm self-center flex items-center w-fit gap-3"
|
className="text-sm self-center flex items-center w-fit gap-3"
|
||||||
>
|
>
|
||||||
Command Palette{' '}
|
Command Palette{' '}
|
||||||
<kbd className="bg-energy-10/50 dark:bg-chalkboard-100 dark:text-energy-10 inline-block px-1 py-0.5 border-energy-10 dark:border-chalkboard-90">
|
<kbd className="bg-energy-10/50 dark:bg-chalkboard-100 dark:text-energy-10 inline-block px-1 py-0.5 border-energy-10 dark:border-chalkboard-90">
|
||||||
⌘K
|
{platform === 'darwin' ? '⌘K' : 'Ctrl+/'}
|
||||||
</kbd>
|
</kbd>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
|
@ -24,13 +24,13 @@ export const PanelHeader = ({
|
|||||||
}: CollapsiblePanelProps) => {
|
}: CollapsiblePanelProps) => {
|
||||||
return (
|
return (
|
||||||
<summary className={styles.header}>
|
<summary className={styles.header}>
|
||||||
<div className="flex gap-2 align-center items-center flex-1">
|
<div className="flex gap-2 items-center flex-1">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon={icon}
|
icon={icon}
|
||||||
className="p-1"
|
className="p-1"
|
||||||
size="sm"
|
size="sm"
|
||||||
bgClassName={
|
bgClassName={
|
||||||
'dark:!bg-chalkboard-100 group-open:bg-chalkboard-80 dark:group-open:!bg-chalkboard-90 group-open:border dark:group-open:border-chalkboard-60 rounded-sm ' +
|
'dark:!bg-chalkboard-100 group-open:bg-chalkboard-80 dark:group-open:!bg-chalkboard-90 border border-transparent dark:group-open:border-chalkboard-60 rounded-sm ' +
|
||||||
(iconClassNames?.bg || '')
|
(iconClassNames?.bg || '')
|
||||||
}
|
}
|
||||||
iconClassName={
|
iconClassName={
|
||||||
|
@ -1,404 +0,0 @@
|
|||||||
import { Combobox, Dialog, Transition } from '@headlessui/react'
|
|
||||||
import {
|
|
||||||
Dispatch,
|
|
||||||
Fragment,
|
|
||||||
SetStateAction,
|
|
||||||
createContext,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
|
||||||
import Fuse from 'fuse.js'
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandArgument,
|
|
||||||
CommandArgumentOption,
|
|
||||||
} from '../lib/commands'
|
|
||||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
|
||||||
import { CustomIcon } from './CustomIcon'
|
|
||||||
|
|
||||||
type ComboboxOption = Command | CommandArgumentOption
|
|
||||||
type CommandArgumentData = [string, any]
|
|
||||||
|
|
||||||
export const CommandsContext = createContext(
|
|
||||||
{} as {
|
|
||||||
commands: Command[]
|
|
||||||
addCommands: (commands: Command[]) => void
|
|
||||||
removeCommands: (commands: Command[]) => void
|
|
||||||
commandBarOpen: boolean
|
|
||||||
setCommandBarOpen: Dispatch<SetStateAction<boolean>>
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export const CommandBarProvider = ({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) => {
|
|
||||||
const [commands, internalSetCommands] = useState([] as Command[])
|
|
||||||
const [commandBarOpen, setCommandBarOpen] = useState(false)
|
|
||||||
|
|
||||||
function sortCommands(a: Command, b: Command) {
|
|
||||||
if (b.owner === 'auth') return -1
|
|
||||||
if (a.owner === 'auth') return 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => console.log('commands updated', commands), [commands])
|
|
||||||
|
|
||||||
const addCommands = (newCommands: Command[]) => {
|
|
||||||
internalSetCommands((prevCommands) =>
|
|
||||||
[...newCommands, ...prevCommands].sort(sortCommands)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const removeCommands = (newCommands: Command[]) => {
|
|
||||||
internalSetCommands((prevCommands) =>
|
|
||||||
prevCommands
|
|
||||||
.filter((command) => !newCommands.includes(command))
|
|
||||||
.sort(sortCommands)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CommandsContext.Provider
|
|
||||||
value={{
|
|
||||||
commands,
|
|
||||||
addCommands,
|
|
||||||
removeCommands,
|
|
||||||
commandBarOpen,
|
|
||||||
setCommandBarOpen,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<CommandBar />
|
|
||||||
</CommandsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandBar = () => {
|
|
||||||
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
|
|
||||||
useHotkeys(['meta+k', 'meta+/'], () => {
|
|
||||||
if (commands?.length === 0) return
|
|
||||||
setCommandBarOpen(!commandBarOpen)
|
|
||||||
})
|
|
||||||
|
|
||||||
const [selectedCommand, setSelectedCommand] = useState<Command>()
|
|
||||||
const [commandArguments, setCommandArguments] = useState<CommandArgument[]>(
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
const [commandArgumentData, setCommandArgumentData] = useState<
|
|
||||||
CommandArgumentData[]
|
|
||||||
>([])
|
|
||||||
const [commandArgumentIndex, setCommandArgumentIndex] = useState<number>(0)
|
|
||||||
|
|
||||||
function clearState() {
|
|
||||||
setCommandBarOpen(false)
|
|
||||||
setSelectedCommand(undefined)
|
|
||||||
setCommandArguments([])
|
|
||||||
setCommandArgumentData([])
|
|
||||||
setCommandArgumentIndex(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCommand(command: Command) {
|
|
||||||
console.log('selecting command', command)
|
|
||||||
if (!('args' in command && command.args?.length)) {
|
|
||||||
submitCommand({ command })
|
|
||||||
} else {
|
|
||||||
setCommandArguments(command.args)
|
|
||||||
setSelectedCommand(command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepBack() {
|
|
||||||
if (!selectedCommand) {
|
|
||||||
clearState()
|
|
||||||
} else {
|
|
||||||
if (commandArgumentIndex === 0) {
|
|
||||||
setSelectedCommand(undefined)
|
|
||||||
} else {
|
|
||||||
setCommandArgumentIndex((prevIndex) => Math.max(0, prevIndex - 1))
|
|
||||||
}
|
|
||||||
if (commandArgumentData.length > 0) {
|
|
||||||
setCommandArgumentData((prevData) => prevData.slice(0, -1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCommandArgumentData(data: { name: any }) {
|
|
||||||
const transformedData = [
|
|
||||||
commandArguments[commandArgumentIndex].name,
|
|
||||||
data.name,
|
|
||||||
]
|
|
||||||
if (commandArgumentIndex + 1 === commandArguments.length) {
|
|
||||||
submitCommand({
|
|
||||||
dataArr: [
|
|
||||||
...commandArgumentData,
|
|
||||||
transformedData,
|
|
||||||
] as CommandArgumentData[],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setCommandArgumentData(
|
|
||||||
(prevData) => [...prevData, transformedData] as CommandArgumentData[]
|
|
||||||
)
|
|
||||||
setCommandArgumentIndex((prevIndex) => prevIndex + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitCommand({
|
|
||||||
command = selectedCommand,
|
|
||||||
dataArr = commandArgumentData,
|
|
||||||
}) {
|
|
||||||
console.log('submitting command', command, dataArr)
|
|
||||||
if (dataArr.length === 0) {
|
|
||||||
command?.callback()
|
|
||||||
} else {
|
|
||||||
const data = Object.fromEntries(dataArr)
|
|
||||||
console.log('submitting data', data)
|
|
||||||
command?.callback(data)
|
|
||||||
}
|
|
||||||
setCommandBarOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayValue(command: Command) {
|
|
||||||
if (
|
|
||||||
'args' in command &&
|
|
||||||
command.args &&
|
|
||||||
command.args?.length > 0 &&
|
|
||||||
'formatFunction' in command &&
|
|
||||||
command.formatFunction
|
|
||||||
) {
|
|
||||||
command.formatFunction(
|
|
||||||
command.args.map((c, i) =>
|
|
||||||
commandArgumentData[i] ? commandArgumentData[i][0] : `<${c.name}>`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return command.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root
|
|
||||||
show={commandBarOpen || false}
|
|
||||||
afterLeave={() => clearState()}
|
|
||||||
as={Fragment}
|
|
||||||
>
|
|
||||||
<Dialog
|
|
||||||
onClose={() => {
|
|
||||||
setCommandBarOpen(false)
|
|
||||||
}}
|
|
||||||
className="fixed inset-0 z-40 overflow-y-auto pb-4 pt-1"
|
|
||||||
>
|
|
||||||
<Transition.Child
|
|
||||||
enter="duration-100 ease-out"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="duration-75 ease-in"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel
|
|
||||||
className="relative w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
|
|
||||||
as="div"
|
|
||||||
>
|
|
||||||
{!(
|
|
||||||
commandArguments &&
|
|
||||||
commandArguments.length &&
|
|
||||||
selectedCommand
|
|
||||||
) ? (
|
|
||||||
<CommandComboBox
|
|
||||||
options={commands}
|
|
||||||
handleSelection={selectCommand}
|
|
||||||
stepBack={stepBack}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="px-4 text-sm flex flex-wrap gap-2">
|
|
||||||
<p className="pr-4 flex gap-2 items-center">
|
|
||||||
{selectedCommand &&
|
|
||||||
'icon' in selectedCommand &&
|
|
||||||
selectedCommand.icon && (
|
|
||||||
<CustomIcon
|
|
||||||
name={selectedCommand.icon}
|
|
||||||
className="w-5 h-5"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{getDisplayValue(selectedCommand)}
|
|
||||||
</p>
|
|
||||||
{commandArguments.map((arg, i) => (
|
|
||||||
<p
|
|
||||||
key={arg.name}
|
|
||||||
className={`w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
|
|
||||||
i === commandArgumentIndex
|
|
||||||
? 'bg-energy-10/50 dark:bg-energy-10/20 border-energy-10 dark:border-energy-10'
|
|
||||||
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{commandArgumentIndex >= i && commandArgumentData[i] ? (
|
|
||||||
commandArgumentData[i][1]
|
|
||||||
) : arg.defaultValue ? (
|
|
||||||
arg.defaultValue
|
|
||||||
) : (
|
|
||||||
<em>{arg.name}</em>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
|
|
||||||
<Argument
|
|
||||||
arg={commandArguments[commandArgumentIndex]}
|
|
||||||
appendCommandArgumentData={appendCommandArgumentData}
|
|
||||||
stepBack={stepBack}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Argument({
|
|
||||||
arg,
|
|
||||||
appendCommandArgumentData,
|
|
||||||
stepBack,
|
|
||||||
}: {
|
|
||||||
arg: CommandArgument
|
|
||||||
appendCommandArgumentData: Dispatch<SetStateAction<any>>
|
|
||||||
stepBack: () => void
|
|
||||||
}) {
|
|
||||||
const { setCommandBarOpen } = useCommandsContext()
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.focus()
|
|
||||||
inputRef.current.select()
|
|
||||||
}
|
|
||||||
}, [arg, inputRef])
|
|
||||||
|
|
||||||
return arg.type === 'select' ? (
|
|
||||||
<CommandComboBox
|
|
||||||
options={arg.options}
|
|
||||||
handleSelection={appendCommandArgumentData}
|
|
||||||
stepBack={stepBack}
|
|
||||||
placeholder="Select an option"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
appendCommandArgumentData({ name: inputRef.current?.value })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="flex items-center mx-4 my-4">
|
|
||||||
<span className="px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
|
|
||||||
{arg.name}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
|
|
||||||
placeholder="Enter a value"
|
|
||||||
defaultValue={arg.defaultValue}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.metaKey && event.key === 'k') setCommandBarOpen(false)
|
|
||||||
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
|
||||||
stepBack()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CommandBarProvider
|
|
||||||
|
|
||||||
function CommandComboBox({
|
|
||||||
options,
|
|
||||||
handleSelection,
|
|
||||||
stepBack,
|
|
||||||
placeholder,
|
|
||||||
}: {
|
|
||||||
options: ComboboxOption[]
|
|
||||||
handleSelection: Dispatch<SetStateAction<any>>
|
|
||||||
stepBack: () => void
|
|
||||||
placeholder?: string
|
|
||||||
}) {
|
|
||||||
const { setCommandBarOpen } = useCommandsContext()
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [filteredOptions, setFilteredOptions] = useState<ComboboxOption[]>()
|
|
||||||
|
|
||||||
const defaultOption =
|
|
||||||
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
|
|
||||||
|
|
||||||
const fuse = new Fuse(options, {
|
|
||||||
keys: ['name', 'description'],
|
|
||||||
threshold: 0.3,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const results = fuse.search(query).map((result) => result.item)
|
|
||||||
setFilteredOptions(query.length > 0 ? results : options)
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Combobox defaultValue={defaultOption} onChange={handleSelection}>
|
|
||||||
<div className="flex items-center gap-2 px-4 pb-2 border-solid border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-80">
|
|
||||||
<CustomIcon
|
|
||||||
name="search"
|
|
||||||
className="w-5 h-5 bg-energy-10/50 dark:bg-chalkboard-90 dark:text-energy-10"
|
|
||||||
/>
|
|
||||||
<Combobox.Input
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
className="w-full bg-transparent focus:outline-none selection:bg-energy-10/50 dark:selection:bg-energy-10/20 dark:focus:outline-none"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.metaKey && event.key === 'k') setCommandBarOpen(false)
|
|
||||||
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
|
||||||
stepBack()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
(defaultOption && defaultOption.name) ||
|
|
||||||
placeholder ||
|
|
||||||
'Search commands'
|
|
||||||
}
|
|
||||||
autoCapitalize="off"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck="false"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Combobox.Options
|
|
||||||
static
|
|
||||||
className="overflow-y-auto max-h-96 cursor-pointer"
|
|
||||||
>
|
|
||||||
{filteredOptions?.map((option) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={option.name}
|
|
||||||
value={option}
|
|
||||||
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
|
|
||||||
>
|
|
||||||
{'icon' in option && option.icon && (
|
|
||||||
<CustomIcon
|
|
||||||
name={option.icon}
|
|
||||||
className="w-5 h-5 dark:text-energy-10"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p className="flex-grow">{option.name} </p>
|
|
||||||
{'isCurrent' in option && option.isCurrent && (
|
|
||||||
<small className="text-chalkboard-70 dark:text-chalkboard-50">
|
|
||||||
current
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))}
|
|
||||||
</Combobox.Options>
|
|
||||||
</Combobox>
|
|
||||||
)
|
|
||||||
}
|
|
114
src/components/CommandBar/CommandArgOptionInput.tsx
Normal file
114
src/components/CommandBar/CommandArgOptionInput.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { Combobox } from '@headlessui/react'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { CommandArgumentOption } from 'lib/commandTypes'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
function CommandArgOptionInput({
|
||||||
|
options,
|
||||||
|
argName,
|
||||||
|
stepBack,
|
||||||
|
onSubmit,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
options: CommandArgumentOption<unknown>[]
|
||||||
|
argName: string
|
||||||
|
stepBack: () => void
|
||||||
|
onSubmit: (data: unknown) => void
|
||||||
|
placeholder?: string
|
||||||
|
}) {
|
||||||
|
const { commandBarSend, commandBarState } = useCommandsContext()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
const [argValue, setArgValue] = useState<(typeof options)[number]['value']>(
|
||||||
|
options.find((o) => 'isCurrent' in o && o.isCurrent)?.value ||
|
||||||
|
commandBarState.context.argumentsToSubmit[argName] ||
|
||||||
|
options[0].value
|
||||||
|
)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
|
||||||
|
|
||||||
|
const fuse = new Fuse(options, {
|
||||||
|
keys: ['name', 'description'],
|
||||||
|
threshold: 0.3,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
}, [inputRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const results = fuse.search(query).map((result) => result.item)
|
||||||
|
setFilteredOptions(query.length > 0 ? results : options)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
function handleSelectOption(option: CommandArgumentOption<unknown>) {
|
||||||
|
setArgValue(option)
|
||||||
|
onSubmit(option.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(argValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="arg-form" onSubmit={handleSubmit} ref={formRef}>
|
||||||
|
<Combobox value={argValue} onChange={handleSelectOption} name="options">
|
||||||
|
<div className="flex items-center mx-4 mt-4 mb-2">
|
||||||
|
<label
|
||||||
|
htmlFor="option-input"
|
||||||
|
className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
|
||||||
|
>
|
||||||
|
{argName}
|
||||||
|
</label>
|
||||||
|
<Combobox.Input
|
||||||
|
id="option-input"
|
||||||
|
ref={inputRef}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.metaKey && event.key === 'k')
|
||||||
|
commandBarSend({ type: 'Close' })
|
||||||
|
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
||||||
|
stepBack()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
(argValue as CommandArgumentOption<unknown>)?.name ||
|
||||||
|
placeholder ||
|
||||||
|
'Select an option for ' + argName
|
||||||
|
}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck="false"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Combobox.Options
|
||||||
|
static
|
||||||
|
className="overflow-y-auto max-h-96 cursor-pointer"
|
||||||
|
>
|
||||||
|
{filteredOptions?.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.name}
|
||||||
|
value={option}
|
||||||
|
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
|
||||||
|
>
|
||||||
|
<p className="flex-grow">{option.name} </p>
|
||||||
|
{'isCurrent' in option && option.isCurrent && (
|
||||||
|
<small className="text-chalkboard-70 dark:text-chalkboard-50">
|
||||||
|
current
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandArgOptionInput
|
166
src/components/CommandBar/CommandBar.tsx
Normal file
166
src/components/CommandBar/CommandBar.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Dialog, Popover, Transition } from '@headlessui/react'
|
||||||
|
import { Fragment, createContext, useEffect } from 'react'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { useMachine } from '@xstate/react'
|
||||||
|
import { commandBarMachine } from 'machines/commandBarMachine'
|
||||||
|
import { EventFrom, StateFrom } from 'xstate'
|
||||||
|
import CommandBarArgument from './CommandBarArgument'
|
||||||
|
import CommandComboBox from '../CommandComboBox'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import CommandBarReview from './CommandBarReview'
|
||||||
|
|
||||||
|
type CommandsContextType = {
|
||||||
|
commandBarState: StateFrom<typeof commandBarMachine>
|
||||||
|
commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandsContext = createContext<CommandsContextType>({
|
||||||
|
commandBarState: commandBarMachine.initialState,
|
||||||
|
commandBarSend: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CommandBarProvider = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const [commandBarState, commandBarSend] = useMachine(commandBarMachine, {
|
||||||
|
guards: {
|
||||||
|
'Arguments are ready': (context, _) => {
|
||||||
|
return context.selectedCommand?.args
|
||||||
|
? context.argumentsToSubmit.length ===
|
||||||
|
Object.keys(context.selectedCommand.args)?.length
|
||||||
|
: false
|
||||||
|
},
|
||||||
|
'Command has no arguments': (context, _event) => {
|
||||||
|
return (
|
||||||
|
!context.selectedCommand?.args ||
|
||||||
|
Object.keys(context.selectedCommand?.args).length === 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close the command bar when navigating
|
||||||
|
useEffect(() => {
|
||||||
|
commandBarSend({ type: 'Close' })
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandsContext.Provider
|
||||||
|
value={{
|
||||||
|
commandBarState,
|
||||||
|
commandBarSend,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CommandBar />
|
||||||
|
</CommandsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandBar = () => {
|
||||||
|
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||||
|
const {
|
||||||
|
context: { selectedCommand, currentArgument, commands },
|
||||||
|
} = commandBarState
|
||||||
|
const isSelectionArgument = currentArgument?.inputType === 'selection'
|
||||||
|
const WrapperComponent = isSelectionArgument ? Popover : Dialog
|
||||||
|
|
||||||
|
useHotkeys(['mod+k', 'mod+/'], () => {
|
||||||
|
if (commandBarState.context.commands.length === 0) return
|
||||||
|
if (commandBarState.matches('Closed')) {
|
||||||
|
commandBarSend({ type: 'Open' })
|
||||||
|
} else {
|
||||||
|
commandBarSend({ type: 'Close' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function stepBack() {
|
||||||
|
if (!currentArgument) {
|
||||||
|
if (commandBarState.matches('Review')) {
|
||||||
|
const entries = Object.entries(selectedCommand?.args || {})
|
||||||
|
|
||||||
|
commandBarSend({
|
||||||
|
type: commandBarState.matches('Review')
|
||||||
|
? 'Edit argument'
|
||||||
|
: 'Change current argument',
|
||||||
|
data: {
|
||||||
|
arg: {
|
||||||
|
name: entries[entries.length - 1][0],
|
||||||
|
...entries[entries.length - 1][1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
commandBarSend({ type: 'Deselect command' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const entries = Object.entries(selectedCommand?.args || {})
|
||||||
|
const index = entries.findIndex(
|
||||||
|
([key, _]) => key === currentArgument.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
commandBarSend({ type: 'Deselect command' })
|
||||||
|
} else {
|
||||||
|
commandBarSend({
|
||||||
|
type: 'Change current argument',
|
||||||
|
data: {
|
||||||
|
arg: { name: entries[index - 1][0], ...entries[index - 1][1] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root
|
||||||
|
show={!commandBarState.matches('Closed') || false}
|
||||||
|
afterLeave={() => {
|
||||||
|
if (selectedCommand?.onCancel) selectedCommand.onCancel()
|
||||||
|
commandBarSend({ type: 'Clear' })
|
||||||
|
}}
|
||||||
|
as={Fragment}
|
||||||
|
>
|
||||||
|
<WrapperComponent
|
||||||
|
open={!commandBarState.matches('Closed') || isSelectionArgument}
|
||||||
|
onClose={() => {
|
||||||
|
commandBarSend({ type: 'Close' })
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' +
|
||||||
|
(isSelectionArgument ? 'pointer-events-none' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Transition.Child
|
||||||
|
enter="duration-100 ease-out"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="duration-75 ease-in"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<WrapperComponent.Panel
|
||||||
|
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
|
||||||
|
as="div"
|
||||||
|
>
|
||||||
|
{commandBarState.matches('Selecting command') ? (
|
||||||
|
<CommandComboBox options={commands} />
|
||||||
|
) : commandBarState.matches('Gathering arguments') ? (
|
||||||
|
<CommandBarArgument stepBack={stepBack} />
|
||||||
|
) : (
|
||||||
|
commandBarState.matches('Review') && (
|
||||||
|
<CommandBarReview stepBack={stepBack} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</WrapperComponent.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</WrapperComponent>
|
||||||
|
</Transition.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandBarProvider
|
80
src/components/CommandBar/CommandBarArgument.tsx
Normal file
80
src/components/CommandBar/CommandBarArgument.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import CommandArgOptionInput from './CommandArgOptionInput'
|
||||||
|
import CommandBarBasicInput from './CommandBarBasicInput'
|
||||||
|
import CommandBarSelectionInput from './CommandBarSelectionInput'
|
||||||
|
import { CommandArgument } from 'lib/commandTypes'
|
||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import CommandBarHeader from './CommandBarHeader'
|
||||||
|
|
||||||
|
function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
|
||||||
|
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||||
|
const {
|
||||||
|
context: { currentArgument },
|
||||||
|
} = commandBarState
|
||||||
|
|
||||||
|
function onSubmit(data: unknown) {
|
||||||
|
if (!currentArgument) return
|
||||||
|
|
||||||
|
commandBarSend({
|
||||||
|
type: 'Submit argument',
|
||||||
|
data: {
|
||||||
|
[currentArgument.name]:
|
||||||
|
currentArgument.inputType === 'number'
|
||||||
|
? parseFloat((data as string) || '0')
|
||||||
|
: data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
currentArgument && (
|
||||||
|
<CommandBarHeader>
|
||||||
|
<ArgumentInput
|
||||||
|
arg={currentArgument}
|
||||||
|
stepBack={stepBack}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
</CommandBarHeader>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandBarArgument
|
||||||
|
|
||||||
|
function ArgumentInput({
|
||||||
|
arg,
|
||||||
|
stepBack,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
arg: CommandArgument<unknown> & { name: string }
|
||||||
|
stepBack: () => void
|
||||||
|
onSubmit: (event: any) => void
|
||||||
|
}) {
|
||||||
|
switch (arg.inputType) {
|
||||||
|
case 'options':
|
||||||
|
return (
|
||||||
|
<CommandArgOptionInput
|
||||||
|
options={arg.options}
|
||||||
|
argName={arg.name}
|
||||||
|
stepBack={stepBack}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
placeholder="Select an option"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'selection':
|
||||||
|
return (
|
||||||
|
<CommandBarSelectionInput
|
||||||
|
arg={arg}
|
||||||
|
stepBack={stepBack}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<CommandBarBasicInput
|
||||||
|
arg={arg}
|
||||||
|
stepBack={stepBack}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
66
src/components/CommandBar/CommandBarBasicInput.tsx
Normal file
66
src/components/CommandBar/CommandBarBasicInput.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { CommandArgument } from 'lib/commandTypes'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
|
||||||
|
function CommandBarBasicInput({
|
||||||
|
arg,
|
||||||
|
stepBack,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
arg: CommandArgument<unknown> & {
|
||||||
|
inputType: 'number' | 'string'
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
stepBack: () => void
|
||||||
|
onSubmit: (event: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { commandBarSend, commandBarState } = useCommandsContext()
|
||||||
|
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const inputType = arg.inputType === 'number' ? 'number' : 'text'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
inputRef.current.select()
|
||||||
|
}
|
||||||
|
}, [arg, inputRef])
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(inputRef.current?.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="arg-form" onSubmit={handleSubmit}>
|
||||||
|
<label className="flex items-center mx-4 my-4">
|
||||||
|
<span className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
|
||||||
|
{arg.name}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="arg-form"
|
||||||
|
name={inputType}
|
||||||
|
ref={inputRef}
|
||||||
|
type={inputType}
|
||||||
|
required
|
||||||
|
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
|
||||||
|
placeholder="Enter a value"
|
||||||
|
defaultValue={
|
||||||
|
(commandBarState.context.argumentsToSubmit[arg.name] as
|
||||||
|
| string
|
||||||
|
| undefined) || (arg.defaultValue as string)
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
||||||
|
stepBack()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandBarBasicInput
|
171
src/components/CommandBar/CommandBarHeader.tsx
Normal file
171
src/components/CommandBar/CommandBarHeader.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { CustomIcon } from '../CustomIcon'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ActionButton } from '../ActionButton'
|
||||||
|
import { Selections, getSelectionTypeDisplayText } from 'lib/selections'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
|
||||||
|
function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
|
||||||
|
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||||
|
const {
|
||||||
|
context: { selectedCommand, currentArgument, argumentsToSubmit },
|
||||||
|
} = commandBarState
|
||||||
|
const isReviewing = commandBarState.matches('Review')
|
||||||
|
const [showShortcuts, setShowShortcuts] = useState(false)
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'alt',
|
||||||
|
() => setShowShortcuts(true),
|
||||||
|
{ enableOnFormTags: true, enableOnContentEditable: true },
|
||||||
|
[showShortcuts]
|
||||||
|
)
|
||||||
|
useHotkeys(
|
||||||
|
'alt',
|
||||||
|
() => setShowShortcuts(false),
|
||||||
|
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
|
||||||
|
[showShortcuts]
|
||||||
|
)
|
||||||
|
useHotkeys(
|
||||||
|
[
|
||||||
|
'alt+1',
|
||||||
|
'alt+2',
|
||||||
|
'alt+3',
|
||||||
|
'alt+4',
|
||||||
|
'alt+5',
|
||||||
|
'alt+6',
|
||||||
|
'alt+7',
|
||||||
|
'alt+8',
|
||||||
|
'alt+9',
|
||||||
|
'alt+0',
|
||||||
|
],
|
||||||
|
(_, b) => {
|
||||||
|
if (b.keys && !Number.isNaN(parseInt(b.keys[0], 10))) {
|
||||||
|
if (!selectedCommand?.args) return
|
||||||
|
const argName = Object.keys(selectedCommand.args)[
|
||||||
|
parseInt(b.keys[0], 10) - 1
|
||||||
|
]
|
||||||
|
const arg = selectedCommand?.args[argName]
|
||||||
|
commandBarSend({
|
||||||
|
type: 'Change current argument',
|
||||||
|
data: { arg: { ...arg, name: argName } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
|
||||||
|
[argumentsToSubmit, selectedCommand]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedCommand &&
|
||||||
|
argumentsToSubmit && (
|
||||||
|
<>
|
||||||
|
<div className="px-4 text-sm flex gap-4 items-start">
|
||||||
|
<div className="flex flex-1 flex-wrap gap-2">
|
||||||
|
<p
|
||||||
|
data-command-name={selectedCommand?.name}
|
||||||
|
className="pr-4 flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
{selectedCommand &&
|
||||||
|
'icon' in selectedCommand &&
|
||||||
|
selectedCommand.icon && (
|
||||||
|
<CustomIcon name={selectedCommand.icon} className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
{selectedCommand?.name}
|
||||||
|
</p>
|
||||||
|
{Object.entries(selectedCommand?.args || {}).map(
|
||||||
|
([argName, arg], i) => (
|
||||||
|
<button
|
||||||
|
disabled={!isReviewing && currentArgument?.name === argName}
|
||||||
|
onClick={() => {
|
||||||
|
commandBarSend({
|
||||||
|
type: isReviewing
|
||||||
|
? 'Edit argument'
|
||||||
|
: 'Change current argument',
|
||||||
|
data: { arg: { ...arg, name: argName } },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
key={argName}
|
||||||
|
className={`relative w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
|
||||||
|
argName === currentArgument?.name
|
||||||
|
? 'disabled:bg-energy-10/50 dark:disabled:bg-energy-10/20 disabled:border-energy-10 dark:disabled:border-energy-10 disabled:text-chalkboard-100 dark:disabled:text-chalkboard-10'
|
||||||
|
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{argumentsToSubmit[argName] ? (
|
||||||
|
arg.inputType === 'selection' ? (
|
||||||
|
getSelectionTypeDisplayText(
|
||||||
|
argumentsToSubmit[argName] as Selections
|
||||||
|
)
|
||||||
|
) : typeof argumentsToSubmit[argName] === 'object' ? (
|
||||||
|
JSON.stringify(argumentsToSubmit[argName])
|
||||||
|
) : (
|
||||||
|
argumentsToSubmit[argName]
|
||||||
|
)
|
||||||
|
) : arg.payload ? (
|
||||||
|
arg.inputType === 'selection' ? (
|
||||||
|
getSelectionTypeDisplayText(arg.payload as Selections)
|
||||||
|
) : typeof arg.payload === 'object' ? (
|
||||||
|
JSON.stringify(arg.payload)
|
||||||
|
) : (
|
||||||
|
arg.payload
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<em>{argName}</em>
|
||||||
|
)}
|
||||||
|
{showShortcuts && (
|
||||||
|
<small className="absolute -top-[1px] right-full translate-x-1/2 px-0.5 rounded-sm bg-chalkboard-80 text-chalkboard-10 dark:bg-energy-10 dark:text-chalkboard-100">
|
||||||
|
<span className="sr-only">Hotkey: </span>
|
||||||
|
{i + 1}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isReviewing ? <ReviewingButton /> : <GatheringArgsButton />}
|
||||||
|
</div>
|
||||||
|
<div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewingButton() {
|
||||||
|
return (
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
autoFocus
|
||||||
|
type="submit"
|
||||||
|
form="review-form"
|
||||||
|
className="w-fit !p-0 rounded-sm border !border-chalkboard-100 dark:!border-energy-10 hover:shadow"
|
||||||
|
icon={{
|
||||||
|
icon: 'checkmark',
|
||||||
|
bgClassName:
|
||||||
|
'p-1 rounded-sm !bg-chalkboard-100 hover:!bg-chalkboard-110 dark:!bg-energy-20 dark:hover:!bg-energy-10',
|
||||||
|
iconClassName: '!text-energy-10 dark:!text-chalkboard-100',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Submit command</span>
|
||||||
|
</ActionButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GatheringArgsButton() {
|
||||||
|
return (
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
type="submit"
|
||||||
|
form="arg-form"
|
||||||
|
className="w-fit !p-0 rounded-sm"
|
||||||
|
icon={{
|
||||||
|
icon: 'arrowRight',
|
||||||
|
bgClassName: 'p-1 rounded-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Continue</span>
|
||||||
|
</ActionButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandBarHeader
|
81
src/components/CommandBar/CommandBarReview.tsx
Normal file
81
src/components/CommandBar/CommandBarReview.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import CommandBarHeader from './CommandBarHeader'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
|
||||||
|
function CommandBarReview({ stepBack }: { stepBack: () => void }) {
|
||||||
|
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||||
|
const {
|
||||||
|
context: { argumentsToSubmit, selectedCommand },
|
||||||
|
} = commandBarState
|
||||||
|
|
||||||
|
useHotkeys('backspace', stepBack, {
|
||||||
|
enableOnFormTags: true,
|
||||||
|
enableOnContentEditable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'1, 2, 3, 4, 5, 6, 7, 8, 9, 0',
|
||||||
|
(_, b) => {
|
||||||
|
if (b.keys && !Number.isNaN(parseInt(b.keys[0], 10))) {
|
||||||
|
if (!selectedCommand?.args) return
|
||||||
|
const argName = Object.keys(selectedCommand.args)[
|
||||||
|
parseInt(b.keys[0], 10) - 1
|
||||||
|
]
|
||||||
|
const arg = selectedCommand?.args[argName]
|
||||||
|
commandBarSend({
|
||||||
|
type: 'Edit argument',
|
||||||
|
data: { arg: { ...arg, name: argName } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
|
||||||
|
[argumentsToSubmit, selectedCommand]
|
||||||
|
)
|
||||||
|
|
||||||
|
Object.keys(argumentsToSubmit).forEach((key, i) => {
|
||||||
|
const arg = selectedCommand?.args ? selectedCommand?.args[key] : undefined
|
||||||
|
if (!arg) return
|
||||||
|
})
|
||||||
|
|
||||||
|
function submitCommand() {
|
||||||
|
commandBarSend({
|
||||||
|
type: 'Submit command',
|
||||||
|
data: argumentsToSubmit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandBarHeader>
|
||||||
|
<p className="px-4">Confirm {selectedCommand?.name}</p>
|
||||||
|
<form
|
||||||
|
id="review-form"
|
||||||
|
className="absolute opacity-0 inset-0 pointer-events-none"
|
||||||
|
onSubmit={submitCommand}
|
||||||
|
>
|
||||||
|
{Object.entries(argumentsToSubmit).map(([key, value], i) => {
|
||||||
|
const arg = selectedCommand?.args
|
||||||
|
? selectedCommand?.args[key]
|
||||||
|
: undefined
|
||||||
|
if (!arg) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
key={key}
|
||||||
|
type="text"
|
||||||
|
defaultValue={
|
||||||
|
typeof value === 'object'
|
||||||
|
? JSON.stringify(value)
|
||||||
|
: (value as string)
|
||||||
|
}
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</form>
|
||||||
|
</CommandBarHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandBarReview
|
114
src/components/CommandBar/CommandBarSelectionInput.tsx
Normal file
114
src/components/CommandBar/CommandBarSelectionInput.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useSelector } from '@xstate/react'
|
||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { useKclContext } from 'lang/KclSinglton'
|
||||||
|
import { CommandArgument } from 'lib/commandTypes'
|
||||||
|
import {
|
||||||
|
ResolvedSelectionType,
|
||||||
|
canSubmitSelectionArg,
|
||||||
|
getSelectionType,
|
||||||
|
getSelectionTypeDisplayText,
|
||||||
|
} from 'lib/selections'
|
||||||
|
import { modelingMachine } from 'machines/modelingMachine'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { StateFrom } from 'xstate'
|
||||||
|
|
||||||
|
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
|
||||||
|
snapshot.context.selectionRanges
|
||||||
|
|
||||||
|
function CommandBarSelectionInput({
|
||||||
|
arg,
|
||||||
|
stepBack,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
arg: CommandArgument<unknown> & { inputType: 'selection'; name: string }
|
||||||
|
stepBack: () => void
|
||||||
|
onSubmit: (data: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { code } = useKclContext()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const { commandBarSend } = useCommandsContext()
|
||||||
|
const [hasSubmitted, setHasSubmitted] = useState(false)
|
||||||
|
const selection = useSelector(arg.actor, selectionSelector)
|
||||||
|
const [selectionsByType, setSelectionsByType] = useState<
|
||||||
|
'none' | ResolvedSelectionType[]
|
||||||
|
>(
|
||||||
|
selection.codeBasedSelections[0]?.range[1] === code.length
|
||||||
|
? 'none'
|
||||||
|
: getSelectionType(selection)
|
||||||
|
)
|
||||||
|
const [canSubmitSelection, setCanSubmitSelection] = useState<boolean>(
|
||||||
|
canSubmitSelectionArg(selectionsByType, arg)
|
||||||
|
)
|
||||||
|
|
||||||
|
useHotkeys('tab', () => onSubmit(selection), {
|
||||||
|
enableOnFormTags: true,
|
||||||
|
enableOnContentEditable: true,
|
||||||
|
keyup: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [selection, inputRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectionsByType(
|
||||||
|
selection.codeBasedSelections[0]?.range[1] === code.length
|
||||||
|
? 'none'
|
||||||
|
: getSelectionType(selection)
|
||||||
|
)
|
||||||
|
}, [selection])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCanSubmitSelection(canSubmitSelectionArg(selectionsByType, arg))
|
||||||
|
}, [selectionsByType, arg])
|
||||||
|
|
||||||
|
function handleChange() {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!canSubmitSelection) {
|
||||||
|
setHasSubmitted(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="arg-form" onSubmit={handleSubmit}>
|
||||||
|
<label
|
||||||
|
className={
|
||||||
|
'relative flex items-center mx-4 my-4 ' +
|
||||||
|
(!hasSubmitted || canSubmitSelection || 'text-destroy-50')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{canSubmitSelection
|
||||||
|
? getSelectionTypeDisplayText(selection) + ' selected'
|
||||||
|
: `Please select ${arg.multiple ? 'one or more faces' : 'one face'}`}
|
||||||
|
<input
|
||||||
|
id="selection"
|
||||||
|
name="selection"
|
||||||
|
ref={inputRef}
|
||||||
|
required
|
||||||
|
placeholder="Select an entity with your mouse"
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
stepBack()
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
commandBarSend({ type: 'Close' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={handleChange}
|
||||||
|
value={JSON.stringify(selection || {})}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandBarSelectionInput
|
90
src/components/CommandComboBox.tsx
Normal file
90
src/components/CommandComboBox.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Combobox } from '@headlessui/react'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { Command } from 'lib/commandTypes'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { CustomIcon } from './CustomIcon'
|
||||||
|
|
||||||
|
function CommandComboBox({
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
options: Command[]
|
||||||
|
placeholder?: string
|
||||||
|
}) {
|
||||||
|
const { commandBarSend } = useCommandsContext()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
|
||||||
|
|
||||||
|
const defaultOption =
|
||||||
|
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
|
||||||
|
|
||||||
|
const fuse = new Fuse(options, {
|
||||||
|
keys: ['name', 'description'],
|
||||||
|
threshold: 0.3,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const results = fuse.search(query).map((result) => result.item)
|
||||||
|
setFilteredOptions(query.length > 0 ? results : options)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
function handleSelection(command: Command) {
|
||||||
|
commandBarSend({ type: 'Select command', data: { command } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox defaultValue={defaultOption} onChange={handleSelection}>
|
||||||
|
<div className="flex items-center gap-2 px-4 pb-2 border-solid border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-80">
|
||||||
|
<CustomIcon
|
||||||
|
name="search"
|
||||||
|
className="w-5 h-5 bg-energy-10/50 dark:bg-chalkboard-90 dark:text-energy-10"
|
||||||
|
/>
|
||||||
|
<Combobox.Input
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
className="w-full bg-transparent focus:outline-none selection:bg-energy-10/50 dark:selection:bg-energy-10/20 dark:focus:outline-none"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
(event.metaKey && event.key === 'k') ||
|
||||||
|
(event.key === 'Backspace' && !event.currentTarget.value)
|
||||||
|
) {
|
||||||
|
commandBarSend({ type: 'Close' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
(defaultOption && defaultOption.name) ||
|
||||||
|
placeholder ||
|
||||||
|
'Search commands'
|
||||||
|
}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck="false"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Combobox.Options
|
||||||
|
static
|
||||||
|
className="overflow-y-auto max-h-96 cursor-pointer"
|
||||||
|
>
|
||||||
|
{filteredOptions?.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.name}
|
||||||
|
value={option}
|
||||||
|
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
|
||||||
|
>
|
||||||
|
{'icon' in option && option.icon && (
|
||||||
|
<CustomIcon
|
||||||
|
name={option.icon}
|
||||||
|
className="w-5 h-5 dark:text-energy-10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="flex-grow">{option.name} </p>
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandComboBox
|
@ -3,6 +3,7 @@ export type CustomIconName =
|
|||||||
| 'arrowLeft'
|
| 'arrowLeft'
|
||||||
| 'arrowRight'
|
| 'arrowRight'
|
||||||
| 'arrowUp'
|
| 'arrowUp'
|
||||||
|
| 'checkmark'
|
||||||
| 'close'
|
| 'close'
|
||||||
| 'equal'
|
| 'equal'
|
||||||
| 'extrude'
|
| 'extrude'
|
||||||
@ -90,6 +91,22 @@ export const CustomIcon = ({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
case 'checkmark':
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M8.29956 13.5388L13.9537 6L14.7537 6.6L8.75367 14.6L8.00012 14.6536L5 11.6536L5.70709 10.9465L8.29956 13.5388Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
case 'close':
|
case 'close':
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
@ -40,7 +40,7 @@ export const FileMachineProvider = ({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { setCommandBarOpen } = useCommandsContext()
|
const { commandBarSend } = useCommandsContext()
|
||||||
const { project } = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
const { project } = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
||||||
|
|
||||||
const [state, send] = useMachine(fileMachine, {
|
const [state, send] = useMachine(fileMachine, {
|
||||||
@ -54,7 +54,7 @@ export const FileMachineProvider = ({
|
|||||||
event: EventFrom<typeof fileMachine>
|
event: EventFrom<typeof fileMachine>
|
||||||
) => {
|
) => {
|
||||||
if (event.data && 'name' in event.data) {
|
if (event.data && 'name' in event.data) {
|
||||||
setCommandBarOpen(false)
|
commandBarSend({ type: 'Close' })
|
||||||
navigate(
|
navigate(
|
||||||
`${paths.FILE}/${encodeURIComponent(
|
`${paths.FILE}/${encodeURIComponent(
|
||||||
context.selectedDirectory + sep + event.data.name
|
context.selectedDirectory + sep + event.data.name
|
||||||
|
@ -1,19 +1,11 @@
|
|||||||
import { useMachine } from '@xstate/react'
|
import { useMachine } from '@xstate/react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { paths } from '../Router'
|
import { paths } from '../Router'
|
||||||
import {
|
import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
|
||||||
authCommandBarConfig,
|
|
||||||
authMachine,
|
|
||||||
TOKEN_PERSIST_KEY,
|
|
||||||
} from '../machines/authMachine'
|
|
||||||
import withBaseUrl from '../lib/withBaseURL'
|
import withBaseUrl from '../lib/withBaseURL'
|
||||||
import React, { createContext, useEffect, useRef } from 'react'
|
import React, { createContext, useEffect, useRef } from 'react'
|
||||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||||
import {
|
import { SETTINGS_PERSIST_KEY, settingsMachine } from 'machines/settingsMachine'
|
||||||
SETTINGS_PERSIST_KEY,
|
|
||||||
settingsCommandBarConfig,
|
|
||||||
settingsMachine,
|
|
||||||
} from 'machines/settingsMachine'
|
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import { setThemeClass, Themes } from 'lib/theme'
|
import { setThemeClass, Themes } from 'lib/theme'
|
||||||
import {
|
import {
|
||||||
@ -23,8 +15,9 @@ import {
|
|||||||
Prop,
|
Prop,
|
||||||
StateFrom,
|
StateFrom,
|
||||||
} from 'xstate'
|
} from 'xstate'
|
||||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
|
||||||
import { isTauri } from 'lib/isTauri'
|
import { isTauri } from 'lib/isTauri'
|
||||||
|
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
|
||||||
|
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
|
||||||
|
|
||||||
type MachineContext<T extends AnyStateMachine> = {
|
type MachineContext<T extends AnyStateMachine> = {
|
||||||
state: StateFrom<T>
|
state: StateFrom<T>
|
||||||
@ -45,7 +38,6 @@ export const GlobalStateProvider = ({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { commands } = useCommandsContext()
|
|
||||||
|
|
||||||
// Settings machine setup
|
// Settings machine setup
|
||||||
const retrievedSettings = useRef(
|
const retrievedSettings = useRef(
|
||||||
@ -81,10 +73,9 @@ export const GlobalStateProvider = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
useStateMachineCommands({
|
useStateMachineCommands({
|
||||||
|
machineId: 'settings',
|
||||||
state: settingsState,
|
state: settingsState,
|
||||||
send: settingsSend,
|
send: settingsSend,
|
||||||
commands,
|
|
||||||
owner: 'settings',
|
|
||||||
commandBarConfig: settingsCommandBarConfig,
|
commandBarConfig: settingsCommandBarConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -121,11 +112,10 @@ export const GlobalStateProvider = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
useStateMachineCommands({
|
useStateMachineCommands({
|
||||||
|
machineId: 'auth',
|
||||||
state: authState,
|
state: authState,
|
||||||
send: authSend,
|
send: authSend,
|
||||||
commands,
|
|
||||||
commandBarConfig: authCommandBarConfig,
|
commandBarConfig: authCommandBarConfig,
|
||||||
owner: 'auth',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -15,23 +15,23 @@ const Loading = ({ children }: React.PropsWithChildren) => {
|
|||||||
data-testid="loading"
|
data-testid="loading"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 10 10" className="w-8 h-8">
|
<svg viewBox="0 0 10 10" className="w-8 h-8">
|
||||||
<circle cx="5" cy="5" r="4" stroke="var(--liquid-20)" fill="none" />
|
<circle cx="5" cy="5" r="4" stroke="var(--energy-50)" fill="none" />
|
||||||
<circle
|
<circle
|
||||||
cx="5"
|
cx="5"
|
||||||
cy="5"
|
cy="5"
|
||||||
r="4"
|
r="4"
|
||||||
stroke="var(--liquid-10)"
|
stroke="var(--energy-10)"
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeDasharray="4, 4"
|
strokeDasharray="4, 4"
|
||||||
className="animate-spin origin-center"
|
className="animate-spin origin-center"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-base mt-4 text-liquid-80 dark:text-liquid-20">
|
<p className="text-base mt-4 text-energy-80 dark:text-energy-30">
|
||||||
{children || 'Loading'}
|
{children || 'Loading'}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
'text-sm mt-4 text-liquid-90 dark:text-liquid-10 transition-opacity duration-500' +
|
'text-sm mt-4 text-energy-70 dark:text-energy-50 transition-opacity duration-500' +
|
||||||
(hasLongLoadTime ? ' opacity-100' : ' opacity-0')
|
(hasLongLoadTime ? ' opacity-100' : ' opacity-0')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -29,19 +29,26 @@ import {
|
|||||||
addNewSketchLn,
|
addNewSketchLn,
|
||||||
compareVec2Epsilon,
|
compareVec2Epsilon,
|
||||||
} from 'lang/std/sketch'
|
} from 'lang/std/sketch'
|
||||||
import { kclManager } from 'lang/KclSinglton'
|
import { kclManager, useKclContext } from 'lang/KclSinglton'
|
||||||
import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance'
|
import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance'
|
||||||
import {
|
import {
|
||||||
angleBetweenInfo,
|
angleBetweenInfo,
|
||||||
applyConstraintAngleBetween,
|
applyConstraintAngleBetween,
|
||||||
} from './Toolbar/SetAngleBetween'
|
} from './Toolbar/SetAngleBetween'
|
||||||
import { applyConstraintAngleLength } from './Toolbar/setAngleLength'
|
import { applyConstraintAngleLength } from './Toolbar/setAngleLength'
|
||||||
import { toast } from 'react-hot-toast'
|
|
||||||
import { pathMapToSelections } from 'lang/util'
|
import { pathMapToSelections } from 'lang/util'
|
||||||
import { useStore } from 'useStore'
|
import { useStore } from 'useStore'
|
||||||
import { handleSelectionBatch, handleSelectionWithShift } from 'lib/selections'
|
import {
|
||||||
|
canExtrudeSelection,
|
||||||
|
handleSelectionBatch,
|
||||||
|
handleSelectionWithShift,
|
||||||
|
isSelectionLastLine,
|
||||||
|
isSketchPipe,
|
||||||
|
} from 'lib/selections'
|
||||||
import { applyConstraintIntersect } from './Toolbar/Intersect'
|
import { applyConstraintIntersect } from './Toolbar/Intersect'
|
||||||
import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance'
|
import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance'
|
||||||
|
import useStateMachineCommands from 'hooks/useStateMachineCommands'
|
||||||
|
import { modelingMachineConfig } from 'lib/commandBarConfigs/modelingCommandConfig'
|
||||||
|
|
||||||
type MachineContext<T extends AnyStateMachine> = {
|
type MachineContext<T extends AnyStateMachine> = {
|
||||||
state: StateFrom<T>
|
state: StateFrom<T>
|
||||||
@ -59,6 +66,7 @@ export const ModelingMachineProvider = ({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) => {
|
}) => {
|
||||||
const { auth } = useGlobalStateContext()
|
const { auth } = useGlobalStateContext()
|
||||||
|
const { code } = useKclContext()
|
||||||
const token = auth?.context?.token
|
const token = auth?.context?.token
|
||||||
const streamRef = useRef<HTMLDivElement>(null)
|
const streamRef = useRef<HTMLDivElement>(null)
|
||||||
useSetupEngineManager(streamRef, token)
|
useSetupEngineManager(streamRef, token)
|
||||||
@ -68,8 +76,6 @@ export const ModelingMachineProvider = ({
|
|||||||
editorView: s.editorView,
|
editorView: s.editorView,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// const { commands } = useCommandsContext()
|
|
||||||
|
|
||||||
// Settings machine setup
|
// Settings machine setup
|
||||||
// const retrievedSettings = useRef(
|
// const retrievedSettings = useRef(
|
||||||
// localStorage?.getItem(MODELING_PERSIST_KEY) || '{}'
|
// localStorage?.getItem(MODELING_PERSIST_KEY) || '{}'
|
||||||
@ -83,7 +89,9 @@ export const ModelingMachineProvider = ({
|
|||||||
// >
|
// >
|
||||||
// )
|
// )
|
||||||
|
|
||||||
const [modelingState, modelingSend] = useMachine(modelingMachine, {
|
const [modelingState, modelingSend, modelingActor] = useMachine(
|
||||||
|
modelingMachine,
|
||||||
|
{
|
||||||
// context: persistedSettings,
|
// context: persistedSettings,
|
||||||
actions: {
|
actions: {
|
||||||
'Modify AST': () => {},
|
'Modify AST': () => {},
|
||||||
@ -156,7 +164,8 @@ export const ModelingMachineProvider = ({
|
|||||||
raw: {} as any,
|
raw: {} as any,
|
||||||
}
|
}
|
||||||
const lineCallExp = updatedPipeNode.body.find(
|
const lineCallExp = updatedPipeNode.body.find(
|
||||||
(exp) => exp.type === 'CallExpression' && exp.callee.name === 'line'
|
(exp) =>
|
||||||
|
exp.type === 'CallExpression' && exp.callee.name === 'line'
|
||||||
)
|
)
|
||||||
if (lineCallExp)
|
if (lineCallExp)
|
||||||
engineCommandManager.artifactMap[segmentId] = {
|
engineCommandManager.artifactMap[segmentId] = {
|
||||||
@ -258,11 +267,6 @@ export const ModelingMachineProvider = ({
|
|||||||
kclManager.executeAst()
|
kclManager.executeAst()
|
||||||
},
|
},
|
||||||
'set tool': () => {}, // TODO
|
'set tool': () => {}, // TODO
|
||||||
'toast extrude failed': () => {
|
|
||||||
toast.error(
|
|
||||||
'Extrude failed, sketches need to be closed, or not already extruded'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
'Set selection': assign(({ selectionRanges }, event) => {
|
'Set selection': assign(({ selectionRanges }, event) => {
|
||||||
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
|
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
|
||||||
const setSelections = event.data
|
const setSelections = event.data
|
||||||
@ -386,6 +390,17 @@ export const ModelingMachineProvider = ({
|
|||||||
'Selection contains line': () => true,
|
'Selection contains line': () => true,
|
||||||
'Selection contains point': () => true,
|
'Selection contains point': () => true,
|
||||||
'Selection is not empty': () => true,
|
'Selection is not empty': () => true,
|
||||||
|
'has valid extrude selection': ({ selectionRanges }) => {
|
||||||
|
// A user can begin extruding if they either have 1+ faces selected or nothing selected
|
||||||
|
// TODO: I believe this guard only allows for extruding a single face at a time
|
||||||
|
if (selectionRanges.codeBasedSelections.length < 1) return false
|
||||||
|
const isPipe = isSketchPipe(selectionRanges)
|
||||||
|
|
||||||
|
if (isSelectionLastLine(selectionRanges, code)) return true
|
||||||
|
if (!isPipe) return false
|
||||||
|
|
||||||
|
return canExtrudeSelection(selectionRanges)
|
||||||
|
},
|
||||||
'Selection is one face': ({ selectionRanges }) => {
|
'Selection is one face': ({ selectionRanges }) => {
|
||||||
return !!isCursorInSketchCommandRange(
|
return !!isCursorInSketchCommandRange(
|
||||||
engineCommandManager.artifactMap,
|
engineCommandManager.artifactMap,
|
||||||
@ -430,7 +445,9 @@ export const ModelingMachineProvider = ({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get angle info': async ({ selectionRanges }): Promise<SetSelections> => {
|
'Get angle info': async ({
|
||||||
|
selectionRanges,
|
||||||
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
|
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
}).enabled
|
}).enabled
|
||||||
@ -454,9 +471,8 @@ export const ModelingMachineProvider = ({
|
|||||||
'Get length info': async ({
|
'Get length info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } = await applyConstraintAngleLength(
|
const { modifiedAst, pathToNodeMap } =
|
||||||
{ selectionRanges }
|
await applyConstraintAngleLength({ selectionRanges })
|
||||||
)
|
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
await kclManager.updateAst(modifiedAst, true)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
@ -470,7 +486,27 @@ export const ModelingMachineProvider = ({
|
|||||||
'Get perpendicular distance info': async ({
|
'Get perpendicular distance info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect({
|
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
|
||||||
|
{
|
||||||
|
selectionRanges,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await kclManager.updateAst(modifiedAst, true)
|
||||||
|
return {
|
||||||
|
selectionType: 'completeSelection',
|
||||||
|
selection: pathMapToSelections(
|
||||||
|
kclManager.ast,
|
||||||
|
selectionRanges,
|
||||||
|
pathToNodeMap
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Get ABS X info': async ({
|
||||||
|
selectionRanges,
|
||||||
|
}): Promise<SetSelections> => {
|
||||||
|
const { modifiedAst, pathToNodeMap } =
|
||||||
|
await applyConstraintAbsDistance({
|
||||||
|
constraint: 'xAbs',
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
})
|
})
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
await kclManager.updateAst(modifiedAst, true)
|
||||||
@ -483,30 +519,14 @@ export const ModelingMachineProvider = ({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get ABS X info': async ({ selectionRanges }): Promise<SetSelections> => {
|
'Get ABS Y info': async ({
|
||||||
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance(
|
|
||||||
{
|
|
||||||
constraint: 'xAbs',
|
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
}
|
}): Promise<SetSelections> => {
|
||||||
)
|
const { modifiedAst, pathToNodeMap } =
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
await applyConstraintAbsDistance({
|
||||||
return {
|
|
||||||
selectionType: 'completeSelection',
|
|
||||||
selection: pathMapToSelections(
|
|
||||||
kclManager.ast,
|
|
||||||
selectionRanges,
|
|
||||||
pathToNodeMap
|
|
||||||
),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'Get ABS Y info': async ({ selectionRanges }): Promise<SetSelections> => {
|
|
||||||
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance(
|
|
||||||
{
|
|
||||||
constraint: 'yAbs',
|
constraint: 'yAbs',
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
await kclManager.updateAst(modifiedAst, true)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
@ -519,7 +539,8 @@ export const ModelingMachineProvider = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
devTools: true,
|
devTools: true,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
engineCommandManager.onPlaneSelected((plane_id: string) => {
|
engineCommandManager.onPlaneSelected((plane_id: string) => {
|
||||||
@ -538,13 +559,17 @@ export const ModelingMachineProvider = ({
|
|||||||
})
|
})
|
||||||
}, [modelingSend])
|
}, [modelingSend])
|
||||||
|
|
||||||
// useStateMachineCommands({
|
useStateMachineCommands({
|
||||||
// state: settingsState,
|
machineId: 'modeling',
|
||||||
// send: settingsSend,
|
state: modelingState,
|
||||||
// commands,
|
send: modelingSend,
|
||||||
// owner: 'settings',
|
actor: modelingActor,
|
||||||
// commandBarMeta: settingsCommandBarMeta,
|
commandBarConfig: modelingMachineConfig,
|
||||||
// })
|
onCancel: () => {
|
||||||
|
console.log('firing onCancel!!')
|
||||||
|
modelingSend({ type: 'Cancel' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModelingMachineContext.Provider
|
<ModelingMachineContext.Provider
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { GlobalStateProvider } from './GlobalStateProvider'
|
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||||
import CommandBarProvider from './CommandBar'
|
import CommandBarProvider from './CommandBar/CommandBar'
|
||||||
import {
|
import {
|
||||||
NETWORK_CONTENT,
|
NETWORK_CONTENT,
|
||||||
NetworkHealthIndicator,
|
NetworkHealthIndicator,
|
||||||
|
@ -1,8 +1,4 @@
|
|||||||
import {
|
import { faExclamation, faWifi } from '@fortawesome/free-solid-svg-icons'
|
||||||
faCheck,
|
|
||||||
faExclamation,
|
|
||||||
faWifi,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { Popover } from '@headlessui/react'
|
import { Popover } from '@headlessui/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ActionIcon } from './ActionIcon'
|
import { ActionIcon } from './ActionIcon'
|
||||||
@ -77,8 +73,8 @@ export const NetworkHealthIndicator = () => {
|
|||||||
data-testid="network-good"
|
data-testid="network-good"
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon={faCheck}
|
icon="checkmark"
|
||||||
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded'}
|
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded-sm'}
|
||||||
iconClassName={'text-succeed-80 dark:text-succeed-30'}
|
iconClassName={'text-succeed-80 dark:text-succeed-30'}
|
||||||
/>
|
/>
|
||||||
{NETWORK_CONTENT.good}
|
{NETWORK_CONTENT.good}
|
||||||
|
@ -143,7 +143,7 @@ function ProjectCard({
|
|||||||
className: 'p-1',
|
className: 'p-1',
|
||||||
size: 'xs',
|
size: 'xs',
|
||||||
bgClassName: 'bg-destroy-80',
|
bgClassName: 'bg-destroy-80',
|
||||||
iconClassName: 'text-destroy-20 dark:text-destroy-40',
|
iconClassName: '!text-destroy-20 dark:!text-destroy-40',
|
||||||
}}
|
}}
|
||||||
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
|
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -185,8 +185,7 @@ function ProjectCard({
|
|||||||
bgClassName: 'bg-destroy-80',
|
bgClassName: 'bg-destroy-80',
|
||||||
className: 'p-1',
|
className: 'p-1',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
iconClassName:
|
iconClassName: '!text-destroy-70 dark:!text-destroy-40',
|
||||||
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
|
|
||||||
}}
|
}}
|
||||||
className="hover:border-destroy-40 dark:hover:border-destroy-40"
|
className="hover:border-destroy-40 dark:hover:border-destroy-40"
|
||||||
>
|
>
|
||||||
|
@ -3,7 +3,7 @@ import { BrowserRouter } from 'react-router-dom'
|
|||||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
||||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||||
import { GlobalStateProvider } from './GlobalStateProvider'
|
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||||
import CommandBarProvider from './CommandBar'
|
import CommandBarProvider from './CommandBar/CommandBar'
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const projectWellFormed = {
|
const projectWellFormed = {
|
||||||
|
@ -243,15 +243,14 @@ export const Stream = ({ className = '' }) => {
|
|||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
!didDragInStream &&
|
!didDragInStream &&
|
||||||
(state.matches('Sketch.SketchIdle') ||
|
(state.matches('Sketch.SketchIdle') || state.matches('idle'))
|
||||||
state.matches('idle') ||
|
|
||||||
state.matches('awaiting selection'))
|
|
||||||
) {
|
) {
|
||||||
command.cmd = {
|
command.cmd = {
|
||||||
type: 'select_with_point',
|
type: 'select_with_point',
|
||||||
selected_at_window: { x, y },
|
selected_at_window: { x, y },
|
||||||
selection_type: 'add',
|
selection_type: 'add',
|
||||||
}
|
}
|
||||||
|
|
||||||
engineCommandManager.sendSceneCommand(command)
|
engineCommandManager.sendSceneCommand(command)
|
||||||
} else if (!didDragInStream && state.matches('Sketch.Move Tool')) {
|
} else if (!didDragInStream && state.matches('Sketch.Move Tool')) {
|
||||||
command.cmd = {
|
command.cmd = {
|
||||||
|
@ -64,7 +64,7 @@ export const TextEditor = ({
|
|||||||
|
|
||||||
const { settings: { context: { textWrapping } = {} } = {} } =
|
const { settings: { context: { textWrapping } = {} } = {} } =
|
||||||
useGlobalStateContext()
|
useGlobalStateContext()
|
||||||
const { setCommandBarOpen } = useCommandsContext()
|
const { commandBarSend } = useCommandsContext()
|
||||||
const { enable: convertEnabled, handleClick: convertCallback } =
|
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||||
useConvertToVariable()
|
useConvertToVariable()
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ export const TextEditor = ({
|
|||||||
{
|
{
|
||||||
key: 'Meta-k',
|
key: 'Meta-k',
|
||||||
run: () => {
|
run: () => {
|
||||||
setCommandBarOpen(true)
|
commandBarSend({ type: 'Open' })
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
} from 'react-router-dom'
|
} from 'react-router-dom'
|
||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import { GlobalStateProvider } from './GlobalStateProvider'
|
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||||
import CommandBarProvider from './CommandBar'
|
import CommandBarProvider from './CommandBar/CommandBar'
|
||||||
|
|
||||||
type User = Models['User_type']
|
type User = Models['User_type']
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { CommandsContext } from 'components/CommandBar'
|
import { CommandsContext } from 'components/CommandBar/CommandBar'
|
||||||
import { useContext } from 'react'
|
import { useContext } from 'react'
|
||||||
|
|
||||||
export const useCommandsContext = () => {
|
export const useCommandsContext = () => {
|
||||||
|
27
src/hooks/usePlatform.ts
Normal file
27
src/hooks/usePlatform.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Platform, platform } from '@tauri-apps/api/os'
|
||||||
|
import { isTauri } from 'lib/isTauri'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function usePlatform() {
|
||||||
|
const [platformName, setPlatformName] = useState<Platform | ''>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getPlatform() {
|
||||||
|
setPlatformName(await platform())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
void getPlatform()
|
||||||
|
} else {
|
||||||
|
if (navigator.userAgent.indexOf('Mac') !== -1) {
|
||||||
|
setPlatformName('darwin')
|
||||||
|
} else if (navigator.userAgent.indexOf('Win') !== -1) {
|
||||||
|
setPlatformName('win32')
|
||||||
|
} else if (navigator.userAgent.indexOf('Linux') !== -1) {
|
||||||
|
setPlatformName('linux')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setPlatformName])
|
||||||
|
|
||||||
|
return platformName
|
||||||
|
}
|
@ -1,46 +1,68 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { AnyStateMachine, StateFrom } from 'xstate'
|
import { AnyStateMachine, InterpreterFrom, StateFrom } from 'xstate'
|
||||||
import {
|
import { createMachineCommand } from '../lib/createMachineCommand'
|
||||||
Command,
|
|
||||||
CommandBarConfig,
|
|
||||||
createMachineCommand,
|
|
||||||
} from '../lib/commands'
|
|
||||||
import { useCommandsContext } from './useCommandsContext'
|
import { useCommandsContext } from './useCommandsContext'
|
||||||
|
import { modelingMachine } from 'machines/modelingMachine'
|
||||||
|
import { authMachine } from 'machines/authMachine'
|
||||||
|
import { settingsMachine } from 'machines/settingsMachine'
|
||||||
|
import { homeMachine } from 'machines/homeMachine'
|
||||||
|
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
|
||||||
|
|
||||||
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
|
// This might not be necessary, AnyStateMachine from xstate is working
|
||||||
|
export type AllMachines =
|
||||||
|
| typeof modelingMachine
|
||||||
|
| typeof settingsMachine
|
||||||
|
| typeof authMachine
|
||||||
|
| typeof homeMachine
|
||||||
|
|
||||||
|
interface UseStateMachineCommandsArgs<
|
||||||
|
T extends AllMachines,
|
||||||
|
S extends CommandSetSchema<T>
|
||||||
|
> {
|
||||||
|
machineId: T['id']
|
||||||
state: StateFrom<T>
|
state: StateFrom<T>
|
||||||
send: Function
|
send: Function
|
||||||
commandBarConfig?: CommandBarConfig<T>
|
actor?: InterpreterFrom<T>
|
||||||
commands: Command[]
|
commandBarConfig?: CommandSetConfig<T, S>
|
||||||
owner: string
|
onCancel?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useStateMachineCommands<T extends AnyStateMachine>({
|
export default function useStateMachineCommands<
|
||||||
|
T extends AnyStateMachine,
|
||||||
|
S extends CommandSetSchema<T>
|
||||||
|
>({
|
||||||
|
machineId,
|
||||||
state,
|
state,
|
||||||
send,
|
send,
|
||||||
|
actor,
|
||||||
commandBarConfig,
|
commandBarConfig,
|
||||||
owner,
|
onCancel,
|
||||||
}: UseStateMachineCommandsArgs<T>) {
|
}: UseStateMachineCommandsArgs<T, S>) {
|
||||||
const { addCommands, removeCommands } = useCommandsContext()
|
const { commandBarSend } = useCommandsContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newCommands = state.nextEvents
|
const newCommands = state.nextEvents
|
||||||
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
|
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
|
||||||
.map((type) =>
|
.map((type) =>
|
||||||
createMachineCommand<T>({
|
createMachineCommand<T, S>({
|
||||||
|
ownerMachine: machineId,
|
||||||
type,
|
type,
|
||||||
state,
|
state,
|
||||||
send,
|
send,
|
||||||
|
actor,
|
||||||
commandBarConfig,
|
commandBarConfig,
|
||||||
owner,
|
onCancel,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls
|
.filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls
|
||||||
|
|
||||||
addCommands(newCommands)
|
commandBarSend({ type: 'Add commands', data: { commands: newCommands } })
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
removeCommands(newCommands)
|
commandBarSend({
|
||||||
|
type: 'Remove commands',
|
||||||
|
data: { commands: newCommands },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [state])
|
}, [state])
|
||||||
}
|
}
|
||||||
|
@ -57,7 +57,7 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs;
|
@apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs focus-visible:ring-energy-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
@ -65,7 +65,7 @@ button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark button {
|
.dark button {
|
||||||
@apply border-chalkboard-70;
|
@apply border-chalkboard-70 focus-visible:ring-energy-10/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark button:hover {
|
.dark button:hover {
|
||||||
@ -88,6 +88,14 @@ a:not(.action-button) {
|
|||||||
@apply text-chalkboard-20 hover:text-energy-10;
|
@apply text-chalkboard-20 hover:text-energy-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
@apply selection:bg-energy-10/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input {
|
||||||
|
@apply selection:bg-energy-10/40;
|
||||||
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
|
@ -248,7 +248,8 @@ export function mutateObjExpProp(
|
|||||||
export function extrudeSketch(
|
export function extrudeSketch(
|
||||||
node: Program,
|
node: Program,
|
||||||
pathToNode: PathToNode,
|
pathToNode: PathToNode,
|
||||||
shouldPipe = true
|
shouldPipe = true,
|
||||||
|
distance = 4
|
||||||
): {
|
): {
|
||||||
modifiedAst: Program
|
modifiedAst: Program
|
||||||
pathToNode: PathToNode
|
pathToNode: PathToNode
|
||||||
@ -274,7 +275,7 @@ export function extrudeSketch(
|
|||||||
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
|
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
|
||||||
|
|
||||||
const extrudeCall = createCallExpressionStdLib('extrude', [
|
const extrudeCall = createCallExpressionStdLib('extrude', [
|
||||||
createLiteral(4),
|
createLiteral(distance),
|
||||||
shouldPipe
|
shouldPipe
|
||||||
? createPipeSubstitution()
|
? createPipeSubstitution()
|
||||||
: {
|
: {
|
||||||
|
17
src/lib/commandBarConfigs/authCommandConfig.ts
Normal file
17
src/lib/commandBarConfigs/authCommandConfig.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { CommandSetConfig } from 'lib/commandTypes'
|
||||||
|
import { authMachine } from 'machines/authMachine'
|
||||||
|
|
||||||
|
type AuthCommandSchema = {}
|
||||||
|
|
||||||
|
export const authCommandBarConfig: CommandSetConfig<
|
||||||
|
typeof authMachine,
|
||||||
|
AuthCommandSchema
|
||||||
|
> = {
|
||||||
|
'Log in': {
|
||||||
|
hide: 'both',
|
||||||
|
},
|
||||||
|
'Log out': {
|
||||||
|
args: [],
|
||||||
|
icon: 'arrowLeft',
|
||||||
|
},
|
||||||
|
}
|
87
src/lib/commandBarConfigs/homeCommandConfig.ts
Normal file
87
src/lib/commandBarConfigs/homeCommandConfig.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { CommandSetConfig } from 'lib/commandTypes'
|
||||||
|
import { homeMachine } from 'machines/homeMachine'
|
||||||
|
|
||||||
|
export type HomeCommandSchema = {
|
||||||
|
'Create project': {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
'Open project': {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
'Delete project': {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
'Rename project': {
|
||||||
|
oldName: string
|
||||||
|
newName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const homeCommandBarConfig: CommandSetConfig<
|
||||||
|
typeof homeMachine,
|
||||||
|
HomeCommandSchema
|
||||||
|
> = {
|
||||||
|
'Open project': {
|
||||||
|
icon: 'arrowRight',
|
||||||
|
description: 'Open a project',
|
||||||
|
args: {
|
||||||
|
name: {
|
||||||
|
inputType: 'options',
|
||||||
|
required: true,
|
||||||
|
options: (context) =>
|
||||||
|
context.projects.map((p) => ({
|
||||||
|
name: p.name!,
|
||||||
|
value: p.name!,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Create project': {
|
||||||
|
icon: 'folderPlus',
|
||||||
|
description: 'Create a project',
|
||||||
|
args: {
|
||||||
|
name: {
|
||||||
|
inputType: 'string',
|
||||||
|
required: true,
|
||||||
|
defaultValue: (context) => context.defaultProjectName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Delete project': {
|
||||||
|
icon: 'close',
|
||||||
|
description: 'Delete a project',
|
||||||
|
needsReview: true,
|
||||||
|
args: {
|
||||||
|
name: {
|
||||||
|
inputType: 'options',
|
||||||
|
required: true,
|
||||||
|
options: (context) =>
|
||||||
|
context.projects.map((p) => ({
|
||||||
|
name: p.name!,
|
||||||
|
value: p.name!,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Rename project': {
|
||||||
|
icon: 'folder',
|
||||||
|
description: 'Rename a project',
|
||||||
|
needsReview: true,
|
||||||
|
args: {
|
||||||
|
oldName: {
|
||||||
|
inputType: 'options',
|
||||||
|
required: true,
|
||||||
|
options: (context) =>
|
||||||
|
context.projects.map((p) => ({
|
||||||
|
name: p.name!,
|
||||||
|
value: p.name!,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
newName: {
|
||||||
|
inputType: 'string',
|
||||||
|
required: true,
|
||||||
|
defaultValue: (context) => context.defaultProjectName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
57
src/lib/commandBarConfigs/modelingCommandConfig.ts
Normal file
57
src/lib/commandBarConfigs/modelingCommandConfig.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { CommandSetConfig } from 'lib/commandTypes'
|
||||||
|
import { Selections } from 'lib/selections'
|
||||||
|
import { modelingMachine } from 'machines/modelingMachine'
|
||||||
|
|
||||||
|
export const EXTRUSION_RESULTS = [
|
||||||
|
'new',
|
||||||
|
'add',
|
||||||
|
'subtract',
|
||||||
|
'intersect',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ModelingCommandSchema = {
|
||||||
|
'Enter sketch': {}
|
||||||
|
Extrude: {
|
||||||
|
selection: Selections // & { type: 'face' } would be cool to lock that down
|
||||||
|
// result: (typeof EXTRUSION_RESULTS)[number]
|
||||||
|
distance: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modelingMachineConfig: CommandSetConfig<
|
||||||
|
typeof modelingMachine,
|
||||||
|
ModelingCommandSchema
|
||||||
|
> = {
|
||||||
|
'Enter sketch': {
|
||||||
|
description: 'Enter sketch mode.',
|
||||||
|
icon: 'sketch',
|
||||||
|
},
|
||||||
|
Extrude: {
|
||||||
|
description: 'Pull a sketch into 3D along its normal or perpendicular.',
|
||||||
|
icon: 'extrude',
|
||||||
|
needsReview: true,
|
||||||
|
args: {
|
||||||
|
selection: {
|
||||||
|
inputType: 'selection',
|
||||||
|
selectionTypes: ['face'],
|
||||||
|
multiple: false, // TODO: multiple selection
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
// result: {
|
||||||
|
// inputType: 'options',
|
||||||
|
// payload: 'add',
|
||||||
|
// required: true,
|
||||||
|
// options: EXTRUSION_RESULTS.map((r) => ({
|
||||||
|
// name: r,
|
||||||
|
// isCurrent: r === 'add',
|
||||||
|
// value: r,
|
||||||
|
// })),
|
||||||
|
// },
|
||||||
|
distance: {
|
||||||
|
inputType: 'number',
|
||||||
|
defaultValue: 5,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
141
src/lib/commandBarConfigs/settingsCommandConfig.ts
Normal file
141
src/lib/commandBarConfigs/settingsCommandConfig.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { CommandSetConfig } from '../commandTypes'
|
||||||
|
import {
|
||||||
|
BaseUnit,
|
||||||
|
Toggle,
|
||||||
|
UnitSystem,
|
||||||
|
baseUnitsUnion,
|
||||||
|
settingsMachine,
|
||||||
|
} from 'machines/settingsMachine'
|
||||||
|
import { CameraSystem, cameraSystems } from '../cameraControls'
|
||||||
|
import { Themes } from '../theme'
|
||||||
|
|
||||||
|
// SETTINGS MACHINE
|
||||||
|
export type SettingsCommandSchema = {
|
||||||
|
'Set Base Unit': {
|
||||||
|
baseUnit: BaseUnit
|
||||||
|
}
|
||||||
|
'Set Camera Controls': {
|
||||||
|
cameraControls: CameraSystem
|
||||||
|
}
|
||||||
|
'Set Default Project Name': {
|
||||||
|
defaultProjectName: string
|
||||||
|
}
|
||||||
|
'Set Text Wrapping': {
|
||||||
|
textWrapping: Toggle
|
||||||
|
}
|
||||||
|
'Set Theme': {
|
||||||
|
theme: Themes
|
||||||
|
}
|
||||||
|
'Set Unit System': {
|
||||||
|
unitSystem: UnitSystem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsCommandBarConfig: CommandSetConfig<
|
||||||
|
typeof settingsMachine,
|
||||||
|
SettingsCommandSchema
|
||||||
|
> = {
|
||||||
|
'Set Base Unit': {
|
||||||
|
icon: 'gear',
|
||||||
|
args: {
|
||||||
|
baseUnit: {
|
||||||
|
inputType: 'options',
|
||||||
|
required: true,
|
||||||
|
defaultValue: (context) => context.baseUnit,
|
||||||
|
options: (context) =>
|
||||||
|
Object.values(baseUnitsUnion).map((v) => ({
|
||||||
|
name: v,
|
||||||
|
value: v,
|
||||||
|
isCurrent: v === context.baseUnit,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Set Camera Controls': {
|
||||||
|
icon: 'gear',
|
||||||
|
args: {
|
||||||
|
cameraControls: {
|
||||||
|
inputType: 'options',
|
||||||
|
required: true,
|
||||||
|
defaultValue: (context) => context.cameraControls,
|
||||||
|
options: (context) =>
|
||||||
|
Object.values(cameraSystems).map((v) => ({
|
||||||
|
name: v,
|
||||||
|
value: v,
|
||||||
|
isCurrent: v === context.cameraControls,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Set Default Project Name': {
|
||||||
|
icon: 'gear',
|
||||||
|
hide: 'web',
|
||||||
|
args: {
|
||||||
|
defaultProjectName: {
|
||||||
|
inputType: 'string',
|
||||||
|
required: true,
|
||||||
|
defaultValue: (context) => context.defaultProjectName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Set Text Wrapping': {
|
||||||
|
icon: 'gear',
|
||||||
|
args: {
|
||||||
|
textWrapping: {
|
||||||
|
inputType: 'options',
|
||||||
|
required: true,
|
||||||
|
defaultValue: (context) => context.textWrapping,
|
||||||
|
options: (context) => [
|
||||||
|
{
|
||||||
|
name: 'On',
|
||||||
|
value: 'On' as Toggle,
|
||||||
|
isCurrent: context.textWrapping === 'On',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Off',
|
||||||
|
value: 'Off' as Toggle,
|
||||||
|
isCurrent: context.textWrapping === 'Off',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Set Theme': {
|
||||||
|
icon: 'gear',
|
||||||
|
args: {
|
||||||
|
theme: {
|
||||||
|
inputType: 'options',
|
||||||
|
required: true,
|
||||||
|
defaultValue: (context) => context.theme,
|
||||||
|
options: (context) =>
|
||||||
|
Object.values(Themes).map((v) => ({
|
||||||
|
name: v,
|
||||||
|
value: v,
|
||||||
|
isCurrent: v === context.theme,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Set Unit System': {
|
||||||
|
icon: 'gear',
|
||||||
|
args: {
|
||||||
|
unitSystem: {
|
||||||
|
inputType: 'options',
|
||||||
|
required: true,
|
||||||
|
defaultValue: (context) => context.unitSystem,
|
||||||
|
options: (context) => [
|
||||||
|
{
|
||||||
|
name: 'Imperial',
|
||||||
|
value: 'imperial' as UnitSystem,
|
||||||
|
isCurrent: context.unitSystem === 'imperial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Metric',
|
||||||
|
value: 'metric' as UnitSystem,
|
||||||
|
isCurrent: context.unitSystem === 'metric',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
136
src/lib/commandTypes.ts
Normal file
136
src/lib/commandTypes.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { CustomIconName } from 'components/CustomIcon'
|
||||||
|
import { AllMachines } from 'hooks/useStateMachineCommands'
|
||||||
|
import {
|
||||||
|
AnyStateMachine,
|
||||||
|
ContextFrom,
|
||||||
|
EventFrom,
|
||||||
|
InterpreterFrom,
|
||||||
|
} from 'xstate'
|
||||||
|
import { Selection } from './selections'
|
||||||
|
|
||||||
|
type Icon = CustomIconName
|
||||||
|
const PLATFORMS = ['both', 'web', 'desktop'] as const
|
||||||
|
const INPUT_TYPES = ['options', 'string', 'number', 'selection'] as const
|
||||||
|
export type CommandInputType = (typeof INPUT_TYPES)[number]
|
||||||
|
|
||||||
|
export type CommandSetSchema<T extends AnyStateMachine> = Partial<{
|
||||||
|
[EventType in EventFrom<T>['type']]: Record<string, any>
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type CommandSet<
|
||||||
|
T extends AllMachines,
|
||||||
|
Schema extends CommandSetSchema<T>
|
||||||
|
> = Partial<{
|
||||||
|
[EventType in EventFrom<T>['type']]: Command<
|
||||||
|
T,
|
||||||
|
EventFrom<T>['type'],
|
||||||
|
Schema[EventType]
|
||||||
|
>
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type CommandSetConfig<
|
||||||
|
T extends AllMachines,
|
||||||
|
Schema extends CommandSetSchema<T>
|
||||||
|
> = Partial<{
|
||||||
|
[EventType in EventFrom<T>['type']]: CommandConfig<
|
||||||
|
T,
|
||||||
|
EventFrom<T>['type'],
|
||||||
|
Schema[EventType]
|
||||||
|
>
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type Command<
|
||||||
|
T extends AnyStateMachine = AnyStateMachine,
|
||||||
|
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type'],
|
||||||
|
CommandSchema extends CommandSetSchema<T>[CommandName] = CommandSetSchema<T>[CommandName]
|
||||||
|
> = {
|
||||||
|
name: CommandName
|
||||||
|
ownerMachine: T['id']
|
||||||
|
needsReview: boolean
|
||||||
|
onSubmit: (data?: CommandSchema) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
args?: {
|
||||||
|
[ArgName in keyof CommandSchema]: CommandArgument<CommandSchema[ArgName], T>
|
||||||
|
}
|
||||||
|
description?: string
|
||||||
|
icon?: Icon
|
||||||
|
hide?: (typeof PLATFORMS)[number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandConfig<
|
||||||
|
T extends AnyStateMachine = AnyStateMachine,
|
||||||
|
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type'],
|
||||||
|
CommandSchema extends CommandSetSchema<T>[CommandName] = CommandSetSchema<T>[CommandName]
|
||||||
|
> = Omit<
|
||||||
|
Command<T, CommandName, CommandSchema>,
|
||||||
|
'name' | 'ownerMachine' | 'onSubmit' | 'onCancel' | 'args' | 'needsReview'
|
||||||
|
> & {
|
||||||
|
needsReview?: true
|
||||||
|
args?: {
|
||||||
|
[ArgName in keyof CommandSchema]: CommandArgumentConfig<
|
||||||
|
CommandSchema[ArgName],
|
||||||
|
T
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandArgumentConfig<
|
||||||
|
OutputType,
|
||||||
|
T extends AnyStateMachine = AnyStateMachine
|
||||||
|
> =
|
||||||
|
| {
|
||||||
|
description?: string
|
||||||
|
required: boolean
|
||||||
|
skip?: true
|
||||||
|
defaultValue?: OutputType | ((context: ContextFrom<T>) => OutputType)
|
||||||
|
payload?: OutputType
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
inputType: Extract<CommandInputType, 'options'>
|
||||||
|
options:
|
||||||
|
| CommandArgumentOption<OutputType>[]
|
||||||
|
| ((context: ContextFrom<T>) => CommandArgumentOption<OutputType>[])
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
inputType: Extract<CommandInputType, 'selection'>
|
||||||
|
selectionTypes: Selection['type'][]
|
||||||
|
multiple: boolean
|
||||||
|
}
|
||||||
|
| { inputType: Exclude<CommandInputType, 'options' | 'selection'> }
|
||||||
|
)
|
||||||
|
|
||||||
|
export type CommandArgument<
|
||||||
|
OutputType,
|
||||||
|
T extends AnyStateMachine = AnyStateMachine
|
||||||
|
> =
|
||||||
|
| {
|
||||||
|
description?: string
|
||||||
|
required: boolean
|
||||||
|
payload?: OutputType // Payload sets the initialized value and more importantly its type
|
||||||
|
defaultValue?: OutputType // Default value is used as the starting value for the input on this argument
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
inputType: Extract<CommandInputType, 'options'>
|
||||||
|
options: CommandArgumentOption<OutputType>[]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
inputType: Extract<CommandInputType, 'selection'>
|
||||||
|
selectionTypes: Selection['type'][]
|
||||||
|
actor: InterpreterFrom<T>
|
||||||
|
multiple: boolean
|
||||||
|
}
|
||||||
|
| { inputType: Exclude<CommandInputType, 'options' | 'selection'> }
|
||||||
|
)
|
||||||
|
|
||||||
|
export type CommandArgumentWithName<
|
||||||
|
OutputType,
|
||||||
|
T extends AnyStateMachine = AnyStateMachine
|
||||||
|
> = CommandArgument<OutputType, T> & {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandArgumentOption<A> = {
|
||||||
|
name: string
|
||||||
|
isCurrent?: boolean
|
||||||
|
value: A
|
||||||
|
}
|
@ -1,177 +0,0 @@
|
|||||||
import { AnyStateMachine, ContextFrom, EventFrom, StateFrom } from 'xstate'
|
|
||||||
import { isTauri } from './isTauri'
|
|
||||||
import { CustomIconName } from 'components/CustomIcon'
|
|
||||||
|
|
||||||
type Icon = CustomIconName
|
|
||||||
type Platform = 'both' | 'web' | 'desktop'
|
|
||||||
type InputType = 'select' | 'string' | 'interaction'
|
|
||||||
export type CommandArgumentOption = { name: string; isCurrent?: boolean }
|
|
||||||
|
|
||||||
// Command arguments can either be defined manually
|
|
||||||
// or flagged as needing to be looked up from the context.
|
|
||||||
// This is useful for things like settings, where
|
|
||||||
// we want to show the current setting value as the default.
|
|
||||||
// The lookup is done in createMachineCommand.
|
|
||||||
type CommandArgumentConfig<T extends AnyStateMachine> = {
|
|
||||||
name: string // TODO: I would love for this to be strongly-typed so we could guarantee it's a valid data payload key on the event type.
|
|
||||||
type: InputType
|
|
||||||
description?: string
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
type: 'select'
|
|
||||||
options?: CommandArgumentOption[]
|
|
||||||
getOptionsFromContext?: keyof ContextFrom<T>
|
|
||||||
defaultValue?: string
|
|
||||||
getDefaultValueFromContext?: keyof ContextFrom<T>
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'string'
|
|
||||||
defaultValue?: string
|
|
||||||
getDefaultValueFromContext?: keyof ContextFrom<T>
|
|
||||||
}
|
|
||||||
| { type: 'interaction' }
|
|
||||||
)
|
|
||||||
|
|
||||||
export type CommandBarConfig<T extends AnyStateMachine> = Partial<{
|
|
||||||
[EventType in EventFrom<T>['type']]:
|
|
||||||
| {
|
|
||||||
args: CommandArgumentConfig<T>[]
|
|
||||||
formatFunction?: (args: string[]) => string
|
|
||||||
icon?: Icon
|
|
||||||
hide?: Platform
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
hide?: Platform
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
|
|
||||||
export type Command = {
|
|
||||||
owner: string
|
|
||||||
name: string
|
|
||||||
callback: Function
|
|
||||||
icon?: Icon
|
|
||||||
args?: CommandArgument[]
|
|
||||||
formatFunction?: (args: string[]) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CommandArgument = {
|
|
||||||
name: string
|
|
||||||
defaultValue?: string
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
type: Extract<InputType, 'select'>
|
|
||||||
options: CommandArgumentOption[]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: Exclude<InputType, 'select'>
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
interface CreateMachineCommandProps<T extends AnyStateMachine> {
|
|
||||||
type: EventFrom<T>['type']
|
|
||||||
state: StateFrom<T>
|
|
||||||
commandBarConfig?: CommandBarConfig<T>
|
|
||||||
send: Function
|
|
||||||
owner: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creates a command with subcommands, ready for use in the CommandBar component,
|
|
||||||
// from a more terse Command Bar Meta definition.
|
|
||||||
export function createMachineCommand<T extends AnyStateMachine>({
|
|
||||||
type,
|
|
||||||
state,
|
|
||||||
commandBarConfig,
|
|
||||||
send,
|
|
||||||
owner,
|
|
||||||
}: CreateMachineCommandProps<T>): Command | null {
|
|
||||||
const lookedUpMeta = commandBarConfig && commandBarConfig[type]
|
|
||||||
if (!lookedUpMeta) return null
|
|
||||||
|
|
||||||
// Hide commands based on platform by returning `null`
|
|
||||||
// so the consumer can filter them out
|
|
||||||
if ('hide' in lookedUpMeta) {
|
|
||||||
const { hide } = lookedUpMeta
|
|
||||||
if (hide === 'both') return null
|
|
||||||
else if (hide === 'desktop' && isTauri()) return null
|
|
||||||
else if (hide === 'web' && !isTauri()) return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = ('icon' in lookedUpMeta && lookedUpMeta.icon) || undefined
|
|
||||||
const formatFunction =
|
|
||||||
('formatFunction' in lookedUpMeta && lookedUpMeta.formatFunction) ||
|
|
||||||
undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: type,
|
|
||||||
owner,
|
|
||||||
icon,
|
|
||||||
callback: (data: EventFrom<T, typeof type>) => {
|
|
||||||
if (data !== undefined && data !== null) {
|
|
||||||
send(type, { data })
|
|
||||||
} else {
|
|
||||||
send(type)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
...('args' in lookedUpMeta
|
|
||||||
? {
|
|
||||||
args: getCommandArgumentValuesFromContext(state, lookedUpMeta.args),
|
|
||||||
formatFunction,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCommandArgumentValuesFromContext<T extends AnyStateMachine>(
|
|
||||||
state: StateFrom<T>,
|
|
||||||
args: CommandArgumentConfig<T>[]
|
|
||||||
): CommandArgument[] {
|
|
||||||
function getDefaultValue(
|
|
||||||
arg: CommandArgumentConfig<T> & { type: 'string' | 'select' }
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
arg.type === 'select' ||
|
|
||||||
('getDefaultValueFromContext' in arg && arg.getDefaultValueFromContext)
|
|
||||||
) {
|
|
||||||
return state.context[arg.getDefaultValueFromContext]
|
|
||||||
} else {
|
|
||||||
return arg.defaultValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return args.map((arg) => {
|
|
||||||
switch (arg.type) {
|
|
||||||
case 'interaction':
|
|
||||||
return {
|
|
||||||
name: arg.name,
|
|
||||||
type: 'interaction',
|
|
||||||
}
|
|
||||||
case 'string':
|
|
||||||
return {
|
|
||||||
name: arg.name,
|
|
||||||
type: arg.type,
|
|
||||||
defaultValue: arg.getDefaultValueFromContext
|
|
||||||
? state.context[arg.getDefaultValueFromContext]
|
|
||||||
: arg.defaultValue,
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
name: arg.name,
|
|
||||||
type: arg.type,
|
|
||||||
defaultValue: getDefaultValue(arg),
|
|
||||||
options: arg.getOptionsFromContext
|
|
||||||
? state.context[arg.getOptionsFromContext].map(
|
|
||||||
(v: string | { name: string }) => ({
|
|
||||||
name: typeof v === 'string' ? v : v.name,
|
|
||||||
isCurrent: v === getDefaultValue(arg),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
: arg.getDefaultValueFromContext
|
|
||||||
? arg.options?.map((v) => ({
|
|
||||||
...v,
|
|
||||||
isCurrent: v.name === getDefaultValue(arg),
|
|
||||||
}))
|
|
||||||
: arg.options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
158
src/lib/createMachineCommand.ts
Normal file
158
src/lib/createMachineCommand.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { AnyStateMachine, EventFrom, InterpreterFrom, StateFrom } from 'xstate'
|
||||||
|
import { isTauri } from './isTauri'
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandArgument,
|
||||||
|
CommandArgumentConfig,
|
||||||
|
CommandConfig,
|
||||||
|
CommandSetConfig,
|
||||||
|
CommandSetSchema,
|
||||||
|
} from './commandTypes'
|
||||||
|
|
||||||
|
interface CreateMachineCommandProps<
|
||||||
|
T extends AnyStateMachine,
|
||||||
|
S extends CommandSetSchema<T>
|
||||||
|
> {
|
||||||
|
type: EventFrom<T>['type']
|
||||||
|
ownerMachine: T['id']
|
||||||
|
state: StateFrom<T>
|
||||||
|
send: Function
|
||||||
|
actor?: InterpreterFrom<T>
|
||||||
|
commandBarConfig?: CommandSetConfig<T, S>
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a command with subcommands, ready for use in the CommandBar component,
|
||||||
|
// from a more terse Command Bar Meta definition.
|
||||||
|
export function createMachineCommand<
|
||||||
|
T extends AnyStateMachine,
|
||||||
|
S extends CommandSetSchema<T>
|
||||||
|
>({
|
||||||
|
ownerMachine,
|
||||||
|
type,
|
||||||
|
state,
|
||||||
|
send,
|
||||||
|
actor,
|
||||||
|
commandBarConfig,
|
||||||
|
onCancel,
|
||||||
|
}: CreateMachineCommandProps<T, S>): Command<
|
||||||
|
T,
|
||||||
|
typeof type,
|
||||||
|
S[typeof type]
|
||||||
|
> | null {
|
||||||
|
const commandConfig = commandBarConfig && commandBarConfig[type]
|
||||||
|
if (!commandConfig) return null
|
||||||
|
|
||||||
|
// Hide commands based on platform by returning `null`
|
||||||
|
// so the consumer can filter them out
|
||||||
|
if ('hide' in commandConfig) {
|
||||||
|
const { hide } = commandConfig
|
||||||
|
if (hide === 'both') return null
|
||||||
|
else if (hide === 'desktop' && isTauri()) return null
|
||||||
|
else if (hide === 'web' && !isTauri()) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = ('icon' in commandConfig && commandConfig.icon) || undefined
|
||||||
|
|
||||||
|
const command: Command<T, typeof type, S[typeof type]> = {
|
||||||
|
name: type,
|
||||||
|
ownerMachine: ownerMachine,
|
||||||
|
icon,
|
||||||
|
needsReview: commandConfig.needsReview || false,
|
||||||
|
onSubmit: (data?: S[typeof type]) => {
|
||||||
|
if (data !== undefined && data !== null) {
|
||||||
|
send(type, { data })
|
||||||
|
} else {
|
||||||
|
send(type)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandConfig.args) {
|
||||||
|
const newArgs = buildCommandArguments(state, commandConfig.args, actor)
|
||||||
|
|
||||||
|
command.args = newArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onCancel) {
|
||||||
|
command.onCancel = onCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takes the args from a CommandConfig and creates
|
||||||
|
// a finalized CommandArgument object for each one,
|
||||||
|
// bundled together into the args for a Command.
|
||||||
|
function buildCommandArguments<
|
||||||
|
T extends AnyStateMachine,
|
||||||
|
S extends CommandSetSchema<T>,
|
||||||
|
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type']
|
||||||
|
>(
|
||||||
|
state: StateFrom<T>,
|
||||||
|
args: CommandConfig<T, CommandName, S>['args'],
|
||||||
|
actor?: InterpreterFrom<T>
|
||||||
|
): NonNullable<Command<T, CommandName, S>['args']> {
|
||||||
|
const newArgs = {} as NonNullable<Command<T, CommandName, S>['args']>
|
||||||
|
|
||||||
|
for (const arg in args) {
|
||||||
|
const argConfig = args[arg] as CommandArgumentConfig<S[typeof arg], T>
|
||||||
|
const newArg = buildCommandArgument(argConfig, state, actor)
|
||||||
|
newArgs[arg] = newArg
|
||||||
|
}
|
||||||
|
|
||||||
|
return newArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommandArgument<
|
||||||
|
O extends CommandSetSchema<T>,
|
||||||
|
T extends AnyStateMachine
|
||||||
|
>(
|
||||||
|
arg: CommandArgumentConfig<O, T>,
|
||||||
|
state: StateFrom<T>,
|
||||||
|
actor?: InterpreterFrom<T>
|
||||||
|
): CommandArgument<O, T> & { inputType: typeof arg.inputType } {
|
||||||
|
const baseCommandArgument = {
|
||||||
|
description: arg.description,
|
||||||
|
required: arg.required,
|
||||||
|
payload: arg.payload,
|
||||||
|
defaultValue:
|
||||||
|
arg.defaultValue instanceof Function
|
||||||
|
? arg.defaultValue(state.context)
|
||||||
|
: arg.defaultValue,
|
||||||
|
} satisfies Omit<CommandArgument<O, T>, 'inputType'>
|
||||||
|
|
||||||
|
if (arg.inputType === 'options') {
|
||||||
|
const options = arg.options
|
||||||
|
? arg.options instanceof Function
|
||||||
|
? arg.options(state.context)
|
||||||
|
: arg.options
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
throw new Error('Options must be provided for options input type')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputType: arg.inputType,
|
||||||
|
...baseCommandArgument,
|
||||||
|
options,
|
||||||
|
} satisfies CommandArgument<O, T> & { inputType: 'options' }
|
||||||
|
} else if (arg.inputType === 'selection') {
|
||||||
|
if (!actor)
|
||||||
|
throw new Error('Actor must be provided for selection input type')
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputType: arg.inputType,
|
||||||
|
...baseCommandArgument,
|
||||||
|
multiple: arg.multiple,
|
||||||
|
selectionTypes: arg.selectionTypes,
|
||||||
|
actor,
|
||||||
|
} satisfies CommandArgument<O, T> & { inputType: 'selection' }
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
inputType: arg.inputType,
|
||||||
|
...baseCommandArgument,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,6 +7,10 @@ import { EditorSelection } from '@codemirror/state'
|
|||||||
import { kclManager } from 'lang/KclSinglton'
|
import { kclManager } from 'lang/KclSinglton'
|
||||||
import { SelectionRange } from '@uiw/react-codemirror'
|
import { SelectionRange } from '@uiw/react-codemirror'
|
||||||
import { isOverlap } from 'lib/utils'
|
import { isOverlap } from 'lib/utils'
|
||||||
|
import { isCursorInSketchCommandRange } from 'lang/util'
|
||||||
|
import { Program } from 'lang/wasm'
|
||||||
|
import { doesPipeHaveCallExp } from 'lang/queryAst'
|
||||||
|
import { CommandArgument } from './commandTypes'
|
||||||
|
|
||||||
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
|
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
|
||||||
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
|
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
|
||||||
@ -371,3 +375,128 @@ function resetAndSetEngineEntitySelectionCmds(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSketchPipe(selectionRanges: Selections) {
|
||||||
|
return isCursorInSketchCommandRange(
|
||||||
|
engineCommandManager.artifactMap,
|
||||||
|
selectionRanges
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectionLastLine(
|
||||||
|
selectionRanges: Selections,
|
||||||
|
code: string,
|
||||||
|
i = 0
|
||||||
|
) {
|
||||||
|
return selectionRanges.codeBasedSelections[i].range[1] === code.length
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommonASTNode = {
|
||||||
|
selection: Selection
|
||||||
|
ast: Program
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCommonNodeFromSelection(
|
||||||
|
selectionRanges: Selections,
|
||||||
|
i: number
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
selection: selectionRanges.codeBasedSelections[i],
|
||||||
|
ast: kclManager.ast,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeHasExtrude(node: CommonASTNode) {
|
||||||
|
return doesPipeHaveCallExp({
|
||||||
|
calleeName: 'extrude',
|
||||||
|
...node,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeHasClose(node: CommonASTNode) {
|
||||||
|
return doesPipeHaveCallExp({
|
||||||
|
calleeName: 'close',
|
||||||
|
...node,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canExtrudeSelection(selection: Selections) {
|
||||||
|
const commonNodes = selection.codeBasedSelections.map((_, i) =>
|
||||||
|
buildCommonNodeFromSelection(selection, i)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
!!isSketchPipe(selection) &&
|
||||||
|
commonNodes.every((n) => nodeHasClose(n)) &&
|
||||||
|
commonNodes.every((n) => !nodeHasExtrude(n))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canExtrudeSelectionItem(selection: Selections, i: number) {
|
||||||
|
const commonNode = buildCommonNodeFromSelection(selection, i)
|
||||||
|
|
||||||
|
return (
|
||||||
|
!!isSketchPipe(selection) &&
|
||||||
|
nodeHasClose(commonNode) &&
|
||||||
|
!nodeHasExtrude(commonNode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This accounts for non-geometry selections under "other"
|
||||||
|
export type ResolvedSelectionType = [Selection['type'] | 'other', number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In the future, I'd like this function to properly return the type of each selected entity based on
|
||||||
|
* its code source range, so that we can show something like "0 objects" or "1 face" or "1 line, 2 edges",
|
||||||
|
* and then validate the selection in CommandBarSelectionInput.tsx and show the proper label.
|
||||||
|
* @param selection
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function getSelectionType(
|
||||||
|
selection: Selections
|
||||||
|
): ResolvedSelectionType[] {
|
||||||
|
return selection.codeBasedSelections
|
||||||
|
.map((s, i) => {
|
||||||
|
if (canExtrudeSelectionItem(selection, i)) {
|
||||||
|
return ['face', 1] as ResolvedSelectionType // This is implicitly determining what a face is, which is bad
|
||||||
|
} else {
|
||||||
|
return ['other', 1] as ResolvedSelectionType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.reduce((acc, [type, count]) => {
|
||||||
|
const foundIndex = acc.findIndex((item) => item && item[0] === type)
|
||||||
|
|
||||||
|
if (foundIndex === -1) {
|
||||||
|
return [...acc, [type, count]]
|
||||||
|
} else {
|
||||||
|
const temp = [...acc]
|
||||||
|
temp[foundIndex][1] += count
|
||||||
|
return temp
|
||||||
|
}
|
||||||
|
}, [] as ResolvedSelectionType[])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectionTypeDisplayText(
|
||||||
|
selection: Selections
|
||||||
|
): string | null {
|
||||||
|
const selectionsByType = getSelectionType(selection)
|
||||||
|
|
||||||
|
return (selectionsByType as Exclude<typeof selectionsByType, 'none'>)
|
||||||
|
.map(([type, count]) => `${count} ${type}${count > 1 ? 's' : ''}`)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canSubmitSelectionArg(
|
||||||
|
selectionsByType: 'none' | ResolvedSelectionType[],
|
||||||
|
argument: CommandArgument<unknown> & { inputType: 'selection' }
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
selectionsByType !== 'none' &&
|
||||||
|
selectionsByType.every(([type, count]) => {
|
||||||
|
const foundIndex = argument.selectionTypes.findIndex((s) => s === type)
|
||||||
|
return (
|
||||||
|
foundIndex !== -1 &&
|
||||||
|
(!argument.multiple ? count < 2 && count > 0 : count > 0)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { createMachine, assign } from 'xstate'
|
import { createMachine, assign } from 'xstate'
|
||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import withBaseURL from '../lib/withBaseURL'
|
import withBaseURL from '../lib/withBaseURL'
|
||||||
import { CommandBarConfig } from '../lib/commands'
|
|
||||||
import { isTauri } from 'lib/isTauri'
|
import { isTauri } from 'lib/isTauri'
|
||||||
import { invoke } from '@tauri-apps/api'
|
import { invoke } from '@tauri-apps/api'
|
||||||
import { VITE_KC_API_BASE_URL } from 'env'
|
import { VITE_KC_API_BASE_URL } from 'env'
|
||||||
@ -40,16 +39,6 @@ export type Events =
|
|||||||
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
|
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
|
||||||
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
|
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
|
||||||
|
|
||||||
export const authCommandBarConfig: CommandBarConfig<typeof authMachine> = {
|
|
||||||
'Log in': {
|
|
||||||
hide: 'both',
|
|
||||||
},
|
|
||||||
'Log out': {
|
|
||||||
args: [],
|
|
||||||
icon: 'arrowLeft',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authMachine = createMachine<UserContext, Events>(
|
export const authMachine = createMachine<UserContext, Events>(
|
||||||
{
|
{
|
||||||
id: 'Auth',
|
id: 'Auth',
|
||||||
|
425
src/machines/commandBarMachine.ts
Normal file
425
src/machines/commandBarMachine.ts
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
import { assign, createMachine } from 'xstate'
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandArgument,
|
||||||
|
CommandArgumentWithName,
|
||||||
|
} from 'lib/commandTypes'
|
||||||
|
import { Selections } from 'lib/selections'
|
||||||
|
|
||||||
|
export const commandBarMachine = createMachine(
|
||||||
|
{
|
||||||
|
/** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswdJNWcNbPFLNMr5AsRFWUtJcVSdRR2VVMUmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUG1klTsSi0awQDgYWhmqkWjnUslBWhOZyegU61GuZAAghACN8-HhYH92FxAXFQAlRA5FpJ5FjFPYtNDpIpLOkEW4GNs4eJxJoGvJxOVcT82gSrj0AEpgdCoABuYC+8ogNOYI3psQmYlkGUkU2slhc6mkmUUCIyKLc4i0lkU8iUyjUHLlVIuJEkAGUwK8Pg8oDr-WQQ2HPpTzrTIkagUzEDkLHZkQK1CUtKCHAiNlsdjkVAdFEc-ed2oGAOJYbgACzAJAj+FIUAAruhBtxYGQACJwUPveO6pMA43AhA5UqWXOzBjS-nOR3LySqXNODTyZwONTV-GXXXPUcfHqTlOM4TrSublb5-lLewlIVySRaOrmGw-jLSI8FRPf0zzjS8HHCOlogZE1ERUEUSgPa05yQ1ZjEQeZP2-VwDzUN1zEabxTlPAkG2bVt207Hs+wHZAm1wGAvi7EgSD7DsSG7XscG4K9oOnNNEVUeQZGSOxc0qaQ7HhdDBJFeRc35extGkNI+UAgNJDIls2xwSMqK4-tJBJAB3LAYl0-AHjYLtuBjLsACN0B4djOL7XixhvBIDxUT8qj5VIkTwtQHRk8oUQcD15LMXQclcdTa00xttMojjqO42BJAANSwShOAgRsIzICB+DASQHg1VAAGtSo1HK8sbMASVSgz3JgmdMnKGYzRlbIdGXA8EX8zd3RsTY9yyY4iLxID6ySiiLP0misrq-LeF0shWxIVBAzYShGwAM229BJFq3LVsa5q3INf5r1gg9JIfXqqh0TQsiFTYrCyJZRpsXJ4oJVUNU4MBjLsxznITX5rqgjzYMsaZtGXaVNhcZFKgRd0RXUdJ6mUXQXX+jpAeB0GyQIRbuNa-jbwQcVSmhAUeTkWQ3HyGSBVKDQMyRAKAsJwNiZBshVXVLUXLSnjoeTPjUxpnmpFtcVs1cPNBq5T8fR5DrKwaHEppIomwCBoWAFEIGcinJcg6XYZnTRhJsbQJU9VlQTVtQNbqcVZhsSFxH5zoWzeSr2ya1z0qKkqypwCrqpOlaGrDiX9WtqdZYSeSEeXEplCceo1xkvQLGCmUIp0Mwck8fWQMVIOQ4spODIHYqcFK8qqpqhPuAu8P+zoCDDRlzzTCkCVWWlSxrBceTygRFxZHZNJbE2VQ5AFAO6PeevI0bmiNpY7bJF2g6jvjs7E8u9KqfTxAkK2fZYuRvdYUGjQZnqYVxRKdx-ZOHBUAgHAQQ00AyD1tgJUQCwLQMEyHUG0Gh7SOmmDuGB6gOTyVBMkDeRJIBgLahA4oFo8zWlSPUT00o0KFA9NMYKrhKwbH2GaQ81cawEljGOdskM8B4OpgkWY9MV5ZjqLUN6MlqGojoRFEo6QWYb1PC8McuCbpD1gosLYrhkTZCxNIawhYxEfW0HhCKKgzT8lBAHLS809KX37Dwm+iIWZSEinjTRy51BFnvNofM7hGZfgXBYuaOlrG9wyiZMya1IxWRsnY4eDi2TONsK45IaghQbEkO4VIKlsaVE2AE8iQTxZN2WufCJMS7o6JSBKNQLp7CT35EKZw2wnDVDyCvBczDmg10NsbYyZS7aZHBNU58q8sTQgxnud+kVsjyQzHrTprDLh11DjY+AyjwFy00DQ2EaQBSLAPLIDGWRNw2BUiXHkwVCJeCAA */
|
||||||
|
context: {
|
||||||
|
commands: [] as Command[],
|
||||||
|
selectedCommand: undefined as Command | undefined,
|
||||||
|
currentArgument: undefined as
|
||||||
|
| (CommandArgument<unknown> & { name: string })
|
||||||
|
| undefined,
|
||||||
|
selectionRanges: {
|
||||||
|
otherSelections: [],
|
||||||
|
codeBasedSelections: [],
|
||||||
|
} as Selections,
|
||||||
|
argumentsToSubmit: {} as { [x: string]: unknown },
|
||||||
|
},
|
||||||
|
id: 'Command Bar',
|
||||||
|
initial: 'Closed',
|
||||||
|
states: {
|
||||||
|
Closed: {
|
||||||
|
on: {
|
||||||
|
Open: {
|
||||||
|
target: 'Selecting command',
|
||||||
|
},
|
||||||
|
|
||||||
|
'Find and select command': {
|
||||||
|
target: 'Command selected',
|
||||||
|
actions: [
|
||||||
|
'Find and select command',
|
||||||
|
'Initialize arguments to submit',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
'Add commands': {
|
||||||
|
target: 'Closed',
|
||||||
|
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
commands: (context, event) =>
|
||||||
|
[...context.commands, ...event.data.commands].sort(
|
||||||
|
sortCommands
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
'Remove commands': {
|
||||||
|
target: 'Closed',
|
||||||
|
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
commands: (context, event) =>
|
||||||
|
context.commands.filter(
|
||||||
|
(c) =>
|
||||||
|
!event.data.commands.some(
|
||||||
|
(c2) =>
|
||||||
|
c2.name === c.name &&
|
||||||
|
c2.ownerMachine === c.ownerMachine
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
internal: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'Selecting command': {
|
||||||
|
on: {
|
||||||
|
'Select command': {
|
||||||
|
target: 'Command selected',
|
||||||
|
actions: ['Set selected command', 'Initialize arguments to submit'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'Command selected': {
|
||||||
|
always: [
|
||||||
|
{
|
||||||
|
target: 'Closed',
|
||||||
|
cond: 'Command has no arguments',
|
||||||
|
actions: ['Execute command'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'Gathering arguments',
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
currentArgument: (context, event) => {
|
||||||
|
const { selectedCommand } = context
|
||||||
|
if (!(selectedCommand && selectedCommand.args))
|
||||||
|
return undefined
|
||||||
|
const argName = Object.keys(selectedCommand.args)[0]
|
||||||
|
return {
|
||||||
|
...selectedCommand.args[argName],
|
||||||
|
name: argName,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
'Gathering arguments': {
|
||||||
|
states: {
|
||||||
|
'Awaiting input': {
|
||||||
|
on: {
|
||||||
|
'Submit argument': {
|
||||||
|
target: 'Validating',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Validating: {
|
||||||
|
invoke: {
|
||||||
|
src: 'Validate argument',
|
||||||
|
id: 'validateArgument',
|
||||||
|
onDone: {
|
||||||
|
target: '#Command Bar.Checking Arguments',
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
argumentsToSubmit: (context, event) => {
|
||||||
|
const [argName, argData] = Object.entries(event.data)[0]
|
||||||
|
const { currentArgument } = context
|
||||||
|
if (!currentArgument) return {}
|
||||||
|
return {
|
||||||
|
...context.argumentsToSubmit,
|
||||||
|
[argName]: argData,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
onError: [
|
||||||
|
{
|
||||||
|
target: 'Awaiting input',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
initial: 'Awaiting input',
|
||||||
|
|
||||||
|
on: {
|
||||||
|
'Change current argument': {
|
||||||
|
target: 'Gathering arguments',
|
||||||
|
internal: true,
|
||||||
|
actions: ['Set current argument'],
|
||||||
|
},
|
||||||
|
|
||||||
|
'Deselect command': {
|
||||||
|
target: 'Selecting command',
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
selectedCommand: (_c, _e) => undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Review: {
|
||||||
|
entry: ['Clear current argument'],
|
||||||
|
on: {
|
||||||
|
'Submit command': {
|
||||||
|
target: 'Closed',
|
||||||
|
actions: ['Execute command'],
|
||||||
|
},
|
||||||
|
|
||||||
|
'Add argument': {
|
||||||
|
target: 'Gathering arguments',
|
||||||
|
actions: ['Set current argument'],
|
||||||
|
},
|
||||||
|
|
||||||
|
'Remove argument': {
|
||||||
|
target: 'Review',
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
argumentsToSubmit: (context, event) => {
|
||||||
|
const argName = Object.keys(event.data)[0]
|
||||||
|
const { argumentsToSubmit } = context
|
||||||
|
const newArgumentsToSubmit = { ...argumentsToSubmit }
|
||||||
|
newArgumentsToSubmit[argName] = undefined
|
||||||
|
return newArgumentsToSubmit
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
'Edit argument': {
|
||||||
|
target: 'Gathering arguments',
|
||||||
|
actions: ['Set current argument'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'Checking Arguments': {
|
||||||
|
invoke: {
|
||||||
|
src: 'Validate all arguments',
|
||||||
|
id: 'validateArguments',
|
||||||
|
onDone: [
|
||||||
|
{
|
||||||
|
target: 'Review',
|
||||||
|
cond: 'Command needs review',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'Closed',
|
||||||
|
actions: 'Execute command',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onError: [
|
||||||
|
{
|
||||||
|
target: 'Gathering arguments',
|
||||||
|
actions: ['Set current argument'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
Close: {
|
||||||
|
target: '.Closed',
|
||||||
|
},
|
||||||
|
|
||||||
|
Clear: {
|
||||||
|
target: '#Command Bar',
|
||||||
|
internal: true,
|
||||||
|
actions: ['Clear argument data'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
events: {} as
|
||||||
|
| { type: 'Open' }
|
||||||
|
| { type: 'Close' }
|
||||||
|
| { type: 'Clear' }
|
||||||
|
| {
|
||||||
|
type: 'Select command'
|
||||||
|
data: { command: Command }
|
||||||
|
}
|
||||||
|
| { type: 'Deselect command' }
|
||||||
|
| { type: 'Submit command'; data: { [x: string]: unknown } }
|
||||||
|
| {
|
||||||
|
type: 'Add argument'
|
||||||
|
data: { argument: CommandArgumentWithName<unknown> }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'Remove argument'
|
||||||
|
data: { [x: string]: CommandArgumentWithName<unknown> }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'Edit argument'
|
||||||
|
data: { arg: CommandArgumentWithName<unknown> }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'Add commands'
|
||||||
|
data: { commands: Command[] }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'Remove commands'
|
||||||
|
data: { commands: Command[] }
|
||||||
|
}
|
||||||
|
| { type: 'Submit argument'; data: { [x: string]: unknown } }
|
||||||
|
| {
|
||||||
|
type: 'done.invoke.validateArguments'
|
||||||
|
data: { [x: string]: unknown }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'error.platform.validateArguments'
|
||||||
|
data: { message: string; arg: CommandArgumentWithName<unknown> }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'Find and select command'
|
||||||
|
data: { name: string; ownerMachine: string }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'Change current argument'
|
||||||
|
data: { arg: CommandArgumentWithName<unknown> }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
predictableActionArguments: true,
|
||||||
|
preserveActionOrder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: {
|
||||||
|
'Execute command': (context, event) => {
|
||||||
|
const { selectedCommand } = context
|
||||||
|
if (!selectedCommand) return
|
||||||
|
if (selectedCommand?.args) {
|
||||||
|
selectedCommand?.onSubmit(
|
||||||
|
event.type === 'Submit command' ||
|
||||||
|
event.type === 'done.invoke.validateArguments'
|
||||||
|
? event.data
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
selectedCommand?.onSubmit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Clear current argument': assign({
|
||||||
|
currentArgument: undefined,
|
||||||
|
}),
|
||||||
|
'Set current argument': assign({
|
||||||
|
currentArgument: (context, event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'error.platform.validateArguments':
|
||||||
|
return event.data.arg
|
||||||
|
case 'Edit argument':
|
||||||
|
return event.data.arg
|
||||||
|
case 'Change current argument':
|
||||||
|
return event.data.arg
|
||||||
|
default:
|
||||||
|
return context.currentArgument
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Clear argument data': assign({
|
||||||
|
selectedCommand: undefined,
|
||||||
|
currentArgument: undefined,
|
||||||
|
argumentsToSubmit: {},
|
||||||
|
}),
|
||||||
|
'Set selected command': assign({
|
||||||
|
selectedCommand: (c, e) =>
|
||||||
|
e.type === 'Select command' ? e.data.command : c.selectedCommand,
|
||||||
|
}),
|
||||||
|
'Find and select command': assign({
|
||||||
|
selectedCommand: (c, e) => {
|
||||||
|
if (e.type !== 'Find and select command') return c.selectedCommand
|
||||||
|
const found = c.commands.find(
|
||||||
|
(cmd) =>
|
||||||
|
cmd.name === e.data.name &&
|
||||||
|
cmd.ownerMachine === e.data.ownerMachine
|
||||||
|
)
|
||||||
|
|
||||||
|
return !!found ? found : c.selectedCommand
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Initialize arguments to submit': assign({
|
||||||
|
argumentsToSubmit: (c, e) => {
|
||||||
|
if (
|
||||||
|
e.type !== 'Select command' &&
|
||||||
|
e.type !== 'Find and select command'
|
||||||
|
)
|
||||||
|
return c.argumentsToSubmit
|
||||||
|
const command =
|
||||||
|
'command' in e.data ? e.data.command : c.selectedCommand!
|
||||||
|
if (!command.args) return {}
|
||||||
|
const args: { [x: string]: unknown } = {}
|
||||||
|
for (const [argName, arg] of Object.entries(command.args)) {
|
||||||
|
args[argName] = arg.payload
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
guards: {
|
||||||
|
'Command needs review': (context, _) =>
|
||||||
|
context.selectedCommand?.needsReview || false,
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
'Validate argument': (context, event) => {
|
||||||
|
if (event.type !== 'Submit argument') return Promise.reject()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// TODO: figure out if we should validate argument data here or in the form itself,
|
||||||
|
// and if we should support people configuring a argument's validation function
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
'Validate all arguments': (context, _) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
for (const [argName, arg] of Object.entries(
|
||||||
|
context.argumentsToSubmit
|
||||||
|
)) {
|
||||||
|
let argConfig = context.selectedCommand!.args![argName]
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof arg !== typeof argConfig.payload &&
|
||||||
|
typeof arg !== typeof argConfig.defaultValue &&
|
||||||
|
'options' in argConfig &&
|
||||||
|
typeof arg !== typeof argConfig.options[0].value
|
||||||
|
) {
|
||||||
|
return reject({
|
||||||
|
message: 'Argument payload is of the wrong type',
|
||||||
|
arg: {
|
||||||
|
...argConfig,
|
||||||
|
name: argName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!arg && argConfig.required) {
|
||||||
|
return reject({
|
||||||
|
message: 'Argument payload is falsy but is required',
|
||||||
|
arg: {
|
||||||
|
...argConfig,
|
||||||
|
name: argName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(context.argumentsToSubmit)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delays: {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function sortCommands(a: Command, b: Command) {
|
||||||
|
if (b.ownerMachine === 'auth') return -1
|
||||||
|
if (a.ownerMachine === 'auth') return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
}
|
74
src/machines/commandBarMachine.typegen.ts
Normal file
74
src/machines/commandBarMachine.typegen.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// This file was automatically generated. Edits will be overwritten
|
||||||
|
|
||||||
|
export interface Typegen0 {
|
||||||
|
'@@xstate/typegen': true
|
||||||
|
internalEvents: {
|
||||||
|
'': { type: '' }
|
||||||
|
'done.invoke.validateArgument': {
|
||||||
|
type: 'done.invoke.validateArgument'
|
||||||
|
data: unknown
|
||||||
|
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||||
|
}
|
||||||
|
'done.invoke.validateArguments': {
|
||||||
|
type: 'done.invoke.validateArguments'
|
||||||
|
data: unknown
|
||||||
|
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||||
|
}
|
||||||
|
'error.platform.validateArgument': {
|
||||||
|
type: 'error.platform.validateArgument'
|
||||||
|
data: unknown
|
||||||
|
}
|
||||||
|
'error.platform.validateArguments': {
|
||||||
|
type: 'error.platform.validateArguments'
|
||||||
|
data: unknown
|
||||||
|
}
|
||||||
|
'xstate.init': { type: 'xstate.init' }
|
||||||
|
}
|
||||||
|
invokeSrcNameMap: {
|
||||||
|
'Validate all arguments': 'done.invoke.validateArguments'
|
||||||
|
'Validate argument': 'done.invoke.validateArgument'
|
||||||
|
}
|
||||||
|
missingImplementations: {
|
||||||
|
actions:
|
||||||
|
| 'Add arguments'
|
||||||
|
| 'Close dialog'
|
||||||
|
| 'Execute command'
|
||||||
|
| 'Open dialog'
|
||||||
|
delays: never
|
||||||
|
guards: never
|
||||||
|
services: never
|
||||||
|
}
|
||||||
|
eventsCausingActions: {
|
||||||
|
'Add arguments': 'done.invoke.validateArguments'
|
||||||
|
'Add commands': 'Add commands'
|
||||||
|
'Close dialog': 'Close'
|
||||||
|
'Execute command': '' | 'Submit'
|
||||||
|
'Open dialog': 'Open'
|
||||||
|
'Remove argument': 'Remove argument'
|
||||||
|
'Remove commands': 'Remove commands'
|
||||||
|
'Set current argument':
|
||||||
|
| 'Add argument'
|
||||||
|
| 'Edit argument'
|
||||||
|
| 'error.platform.validateArguments'
|
||||||
|
}
|
||||||
|
eventsCausingDelays: {}
|
||||||
|
eventsCausingGuards: {
|
||||||
|
'Arguments are ready': 'done.invoke.validateArguments'
|
||||||
|
'Command has no arguments': ''
|
||||||
|
}
|
||||||
|
eventsCausingServices: {
|
||||||
|
'Validate all arguments': 'done.invoke.validateArgument'
|
||||||
|
'Validate argument': 'Submit'
|
||||||
|
}
|
||||||
|
matchesStates:
|
||||||
|
| 'Checking Arguments'
|
||||||
|
| 'Closed'
|
||||||
|
| 'Command selected'
|
||||||
|
| 'Gathering arguments'
|
||||||
|
| 'Gathering arguments.Awaiting input'
|
||||||
|
| 'Gathering arguments.Validating'
|
||||||
|
| 'Review'
|
||||||
|
| 'Selecting command'
|
||||||
|
| { 'Gathering arguments'?: 'Awaiting input' | 'Validating' }
|
||||||
|
tags: never
|
||||||
|
}
|
@ -1,56 +1,6 @@
|
|||||||
import { assign, createMachine } from 'xstate'
|
import { assign, createMachine } from 'xstate'
|
||||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||||
import { CommandBarConfig } from '../lib/commands'
|
import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig'
|
||||||
|
|
||||||
export const homeCommandConfig: CommandBarConfig<typeof homeMachine> = {
|
|
||||||
'Create project': {
|
|
||||||
icon: 'folderPlus',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
type: 'string',
|
|
||||||
getDefaultValueFromContext: 'defaultProjectName',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Open project': {
|
|
||||||
icon: 'arrowRight',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
type: 'select',
|
|
||||||
getOptionsFromContext: 'projects',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Delete project': {
|
|
||||||
icon: 'close',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
type: 'select',
|
|
||||||
getOptionsFromContext: 'projects',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Rename project': {
|
|
||||||
icon: 'folder',
|
|
||||||
formatFunction: (args: string[]) =>
|
|
||||||
`Rename project "${args[0]}" to "${args[1]}"`,
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'oldName',
|
|
||||||
type: 'select',
|
|
||||||
getOptionsFromContext: 'projects',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'newName',
|
|
||||||
type: 'string',
|
|
||||||
getDefaultValueFromContext: 'defaultProjectName',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const homeMachine = createMachine(
|
export const homeMachine = createMachine(
|
||||||
{
|
{
|
||||||
@ -188,10 +138,10 @@ export const homeMachine = createMachine(
|
|||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
events: {} as
|
events: {} as
|
||||||
| { type: 'Open project'; data: { name: string } }
|
| { type: 'Open project'; data: HomeCommandSchema['Open project'] }
|
||||||
| { type: 'Rename project'; data: { oldName: string; newName: string } }
|
| { type: 'Rename project'; data: HomeCommandSchema['Rename project'] }
|
||||||
| { type: 'Create project'; data: { name: string } }
|
| { type: 'Create project'; data: HomeCommandSchema['Create project'] }
|
||||||
| { type: 'Delete project'; data: { name: string } }
|
| { type: 'Delete project'; data: HomeCommandSchema['Delete project'] }
|
||||||
| { type: 'navigate'; data: { name: string } }
|
| { type: 'navigate'; data: { name: string } }
|
||||||
| {
|
| {
|
||||||
type: 'done.invoke.read-projects'
|
type: 'done.invoke.read-projects'
|
||||||
|
File diff suppressed because one or more lines are too long
@ -32,14 +32,14 @@
|
|||||||
"Get vertical info": "done.invoke.get-vertical-info";
|
"Get vertical info": "done.invoke.get-vertical-info";
|
||||||
};
|
};
|
||||||
missingImplementations: {
|
missingImplementations: {
|
||||||
actions: "AST add line segment" | "AST start new sketch" | "Modify AST" | "Set selection" | "Update code selection cursors" | "create path" | "set tool" | "show default planes" | "sketch exit execute" | "toast extrude failed";
|
actions: "AST add line segment" | "AST start new sketch" | "Modify AST" | "Set selection" | "Update code selection cursors" | "create path" | "set tool" | "show default planes" | "sketch exit execute";
|
||||||
delays: never;
|
delays: never;
|
||||||
guards: "Selection contains axis" | "Selection contains edge" | "Selection contains face" | "Selection contains line" | "Selection contains point" | "Selection is not empty" | "Selection is one face";
|
guards: "Selection contains axis" | "Selection contains edge" | "Selection contains face" | "Selection contains line" | "Selection contains point" | "Selection is not empty" | "Selection is one face" | "has valid extrude selection";
|
||||||
services: "Get ABS X info" | "Get ABS Y info" | "Get angle info" | "Get horizontal info" | "Get length info" | "Get perpendicular distance info" | "Get vertical info";
|
services: "Get ABS X info" | "Get ABS Y info" | "Get angle info" | "Get horizontal info" | "Get length info" | "Get perpendicular distance info" | "Get vertical info";
|
||||||
};
|
};
|
||||||
eventsCausingActions: {
|
eventsCausingActions: {
|
||||||
"AST add line segment": "Add point";
|
"AST add line segment": "Add point";
|
||||||
"AST extrude": "" | "extrude intent";
|
"AST extrude": "Extrude";
|
||||||
"AST start new sketch": "Add point";
|
"AST start new sketch": "Add point";
|
||||||
"Add to code-based selection": "Deselect point" | "Deselect segment" | "Select all" | "Select edge" | "Select face" | "Select point" | "Select segment";
|
"Add to code-based selection": "Deselect point" | "Deselect segment" | "Select all" | "Select edge" | "Select face" | "Select point" | "Select segment";
|
||||||
"Add to other selection": "Select axis";
|
"Add to other selection": "Select axis";
|
||||||
@ -63,7 +63,7 @@
|
|||||||
"edit mode enter": "Enter sketch" | "Re-execute";
|
"edit mode enter": "Enter sketch" | "Re-execute";
|
||||||
"edit_mode_exit": "Cancel";
|
"edit_mode_exit": "Cancel";
|
||||||
"equip select": "CancelSketch" | "Constrain equal length" | "Constrain horizontally align" | "Constrain parallel" | "Constrain remove constraints" | "Constrain snap to X" | "Constrain snap to Y" | "Constrain vertically align" | "Deselect point" | "Deselect segment" | "Enter sketch" | "Make segment horizontal" | "Make segment vertical" | "Re-execute" | "Select default plane" | "Select point" | "Select segment" | "Set selection" | "done.invoke.get-abs-x-info" | "done.invoke.get-abs-y-info" | "done.invoke.get-angle-info" | "done.invoke.get-horizontal-info" | "done.invoke.get-length-info" | "done.invoke.get-perpendicular-distance-info" | "done.invoke.get-vertical-info" | "error.platform.get-abs-x-info" | "error.platform.get-abs-y-info" | "error.platform.get-angle-info" | "error.platform.get-horizontal-info" | "error.platform.get-length-info" | "error.platform.get-perpendicular-distance-info" | "error.platform.get-vertical-info";
|
"equip select": "CancelSketch" | "Constrain equal length" | "Constrain horizontally align" | "Constrain parallel" | "Constrain remove constraints" | "Constrain snap to X" | "Constrain snap to Y" | "Constrain vertically align" | "Deselect point" | "Deselect segment" | "Enter sketch" | "Make segment horizontal" | "Make segment vertical" | "Re-execute" | "Select default plane" | "Select point" | "Select segment" | "Set selection" | "done.invoke.get-abs-x-info" | "done.invoke.get-abs-y-info" | "done.invoke.get-angle-info" | "done.invoke.get-horizontal-info" | "done.invoke.get-length-info" | "done.invoke.get-perpendicular-distance-info" | "done.invoke.get-vertical-info" | "error.platform.get-abs-x-info" | "error.platform.get-abs-y-info" | "error.platform.get-angle-info" | "error.platform.get-horizontal-info" | "error.platform.get-length-info" | "error.platform.get-perpendicular-distance-info" | "error.platform.get-vertical-info";
|
||||||
"hide default planes": "Cancel" | "Select default plane" | "xstate.stop";
|
"hide default planes": "Cancel" | "Select default plane" | "Set selection" | "xstate.stop";
|
||||||
"reset sketch metadata": "Cancel" | "Select default plane";
|
"reset sketch metadata": "Cancel" | "Select default plane";
|
||||||
"set default plane id": "Select default plane";
|
"set default plane id": "Select default plane";
|
||||||
"set sketch metadata": "Enter sketch";
|
"set sketch metadata": "Enter sketch";
|
||||||
@ -72,9 +72,8 @@
|
|||||||
"set tool line": "Equip tool";
|
"set tool line": "Equip tool";
|
||||||
"set tool move": "Equip move tool" | "Re-execute" | "Set selection";
|
"set tool move": "Equip move tool" | "Re-execute" | "Set selection";
|
||||||
"show default planes": "Enter sketch";
|
"show default planes": "Enter sketch";
|
||||||
"sketch exit execute": "Cancel" | "Complete line" | "xstate.stop";
|
"sketch exit execute": "Cancel" | "Complete line" | "Set selection" | "xstate.stop";
|
||||||
"sketch mode enabled": "Enter sketch" | "Re-execute" | "Select default plane";
|
"sketch mode enabled": "Enter sketch" | "Re-execute" | "Select default plane";
|
||||||
"toast extrude failed": "";
|
|
||||||
};
|
};
|
||||||
eventsCausingDelays: {
|
eventsCausingDelays: {
|
||||||
|
|
||||||
@ -105,8 +104,7 @@
|
|||||||
"Selection is one face": "Enter sketch";
|
"Selection is one face": "Enter sketch";
|
||||||
"can move": "";
|
"can move": "";
|
||||||
"can move with execute": "";
|
"can move with execute": "";
|
||||||
"has no selection": "extrude intent";
|
"has valid extrude selection": "Extrude";
|
||||||
"has valid extrude selection": "" | "extrude intent";
|
|
||||||
"is editing existing sketch": "";
|
"is editing existing sketch": "";
|
||||||
};
|
};
|
||||||
eventsCausingServices: {
|
eventsCausingServices: {
|
||||||
@ -118,7 +116,7 @@
|
|||||||
"Get perpendicular distance info": "Constrain perpendicular distance";
|
"Get perpendicular distance info": "Constrain perpendicular distance";
|
||||||
"Get vertical info": "Constrain vertical distance";
|
"Get vertical info": "Constrain vertical distance";
|
||||||
};
|
};
|
||||||
matchesStates: "Sketch" | "Sketch no face" | "Sketch.Await ABS X info" | "Sketch.Await ABS Y info" | "Sketch.Await angle info" | "Sketch.Await horizontal distance info" | "Sketch.Await length info" | "Sketch.Await perpendicular distance info" | "Sketch.Await vertical distance info" | "Sketch.Line Tool" | "Sketch.Line Tool.Done" | "Sketch.Line Tool.Init" | "Sketch.Line Tool.No Points" | "Sketch.Line Tool.Point Added" | "Sketch.Line Tool.Segment Added" | "Sketch.Move Tool" | "Sketch.Move Tool.Move init" | "Sketch.Move Tool.Move with execute" | "Sketch.Move Tool.Move without re-execute" | "Sketch.Move Tool.No move" | "Sketch.SketchIdle" | "awaiting selection" | "checking selection" | "idle" | { "Sketch"?: "Await ABS X info" | "Await ABS Y info" | "Await angle info" | "Await horizontal distance info" | "Await length info" | "Await perpendicular distance info" | "Await vertical distance info" | "Line Tool" | "Move Tool" | "SketchIdle" | { "Line Tool"?: "Done" | "Init" | "No Points" | "Point Added" | "Segment Added";
|
matchesStates: "Sketch" | "Sketch no face" | "Sketch.Await ABS X info" | "Sketch.Await ABS Y info" | "Sketch.Await angle info" | "Sketch.Await horizontal distance info" | "Sketch.Await length info" | "Sketch.Await perpendicular distance info" | "Sketch.Await vertical distance info" | "Sketch.Line Tool" | "Sketch.Line Tool.Done" | "Sketch.Line Tool.Init" | "Sketch.Line Tool.No Points" | "Sketch.Line Tool.Point Added" | "Sketch.Line Tool.Segment Added" | "Sketch.Move Tool" | "Sketch.Move Tool.Move init" | "Sketch.Move Tool.Move with execute" | "Sketch.Move Tool.Move without re-execute" | "Sketch.Move Tool.No move" | "Sketch.SketchIdle" | "idle" | { "Sketch"?: "Await ABS X info" | "Await ABS Y info" | "Await angle info" | "Await horizontal distance info" | "Await length info" | "Await perpendicular distance info" | "Await vertical distance info" | "Line Tool" | "Move Tool" | "SketchIdle" | { "Line Tool"?: "Done" | "Init" | "No Points" | "Point Added" | "Segment Added";
|
||||||
"Move Tool"?: "Move init" | "Move with execute" | "Move without re-execute" | "No move"; }; };
|
"Move Tool"?: "Move init" | "Move with execute" | "Move without re-execute" | "No move"; }; };
|
||||||
tags: never;
|
tags: never;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { assign, createMachine } from 'xstate'
|
import { assign, createMachine } from 'xstate'
|
||||||
import { CommandBarConfig } from '../lib/commands'
|
|
||||||
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
|
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
|
||||||
import { CameraSystem, cameraSystems } from 'lib/cameraControls'
|
import { CameraSystem } from 'lib/cameraControls'
|
||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
|
|
||||||
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
|
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
|
||||||
@ -24,85 +23,6 @@ export type Toggle = 'On' | 'Off'
|
|||||||
|
|
||||||
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
||||||
|
|
||||||
export const settingsCommandBarConfig: CommandBarConfig<
|
|
||||||
typeof settingsMachine
|
|
||||||
> = {
|
|
||||||
'Set Base Unit': {
|
|
||||||
icon: 'gear',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'baseUnit',
|
|
||||||
type: 'select',
|
|
||||||
getDefaultValueFromContext: 'baseUnit',
|
|
||||||
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Set Camera Controls': {
|
|
||||||
icon: 'gear',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'cameraControls',
|
|
||||||
type: 'select',
|
|
||||||
getDefaultValueFromContext: 'cameraControls',
|
|
||||||
options: Object.values(cameraSystems).map((v) => ({ name: v })),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Set Default Directory': {
|
|
||||||
hide: 'both',
|
|
||||||
},
|
|
||||||
'Set Default Project Name': {
|
|
||||||
icon: 'gear',
|
|
||||||
hide: 'web',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'defaultProjectName',
|
|
||||||
type: 'string',
|
|
||||||
getDefaultValueFromContext: 'defaultProjectName',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Set Onboarding Status': {
|
|
||||||
hide: 'both',
|
|
||||||
},
|
|
||||||
'Set Text Wrapping': {
|
|
||||||
icon: 'gear',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'textWrapping',
|
|
||||||
type: 'select',
|
|
||||||
getDefaultValueFromContext: 'textWrapping',
|
|
||||||
options: [{ name: 'On' }, { name: 'Off' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Set Theme': {
|
|
||||||
icon: 'gear',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'theme',
|
|
||||||
type: 'select',
|
|
||||||
getDefaultValueFromContext: 'theme',
|
|
||||||
options: Object.values(Themes).map((v): { name: string } => ({
|
|
||||||
name: v,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Set Unit System': {
|
|
||||||
icon: 'gear',
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'unitSystem',
|
|
||||||
type: 'select',
|
|
||||||
getDefaultValueFromContext: 'unitSystem',
|
|
||||||
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const settingsMachine = createMachine(
|
export const settingsMachine = createMachine(
|
||||||
{
|
{
|
||||||
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
|
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
|
||||||
|
@ -17,7 +17,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
|
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
|
||||||
import Loading from '../components/Loading'
|
import Loading from '../components/Loading'
|
||||||
import { useMachine } from '@xstate/react'
|
import { useMachine } from '@xstate/react'
|
||||||
import { homeCommandConfig, homeMachine } from '../machines/homeMachine'
|
import { homeMachine } from '../machines/homeMachine'
|
||||||
import { ContextFrom, EventFrom } from 'xstate'
|
import { ContextFrom, EventFrom } from 'xstate'
|
||||||
import { paths } from '../Router'
|
import { paths } from '../Router'
|
||||||
import {
|
import {
|
||||||
@ -30,11 +30,12 @@ import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
|||||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
|
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
|
||||||
import { sep } from '@tauri-apps/api/path'
|
import { sep } from '@tauri-apps/api/path'
|
||||||
|
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
|
||||||
|
|
||||||
// This route only opens in the Tauri desktop context for now,
|
// This route only opens in the Tauri desktop context for now,
|
||||||
// as defined in Router.tsx, so we can use the Tauri APIs and types.
|
// as defined in Router.tsx, so we can use the Tauri APIs and types.
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { commands, setCommandBarOpen } = useCommandsContext()
|
const { commandBarSend } = useCommandsContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
||||||
const {
|
const {
|
||||||
@ -56,7 +57,7 @@ const Home = () => {
|
|||||||
event: EventFrom<typeof homeMachine>
|
event: EventFrom<typeof homeMachine>
|
||||||
) => {
|
) => {
|
||||||
if (event.data && 'name' in event.data) {
|
if (event.data && 'name' in event.data) {
|
||||||
setCommandBarOpen(false)
|
commandBarSend({ type: 'Close' })
|
||||||
navigate(
|
navigate(
|
||||||
`${paths.FILE}/${encodeURIComponent(
|
`${paths.FILE}/${encodeURIComponent(
|
||||||
context.defaultDirectory + sep + event.data.name
|
context.defaultDirectory + sep + event.data.name
|
||||||
@ -143,12 +144,11 @@ const Home = () => {
|
|||||||
|
|
||||||
const isSortByModified = sort?.includes('modified') || !sort || sort === null
|
const isSortByModified = sort?.includes('modified') || !sort || sort === null
|
||||||
|
|
||||||
useStateMachineCommands<typeof homeMachine>({
|
useStateMachineCommands({
|
||||||
commands,
|
machineId: 'home',
|
||||||
send,
|
send,
|
||||||
state,
|
state,
|
||||||
commandBarConfig: homeCommandConfig,
|
commandBarConfig: homeCommandBarConfig,
|
||||||
owner: 'home',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
|
import usePlatform from 'hooks/usePlatform'
|
||||||
import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
|
import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
|
||||||
import { useStore } from '../../useStore'
|
import { useStore } from '../../useStore'
|
||||||
import { Platform, platform } from '@tauri-apps/api/os'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
export default function CmdK() {
|
export default function CmdK() {
|
||||||
const { buttonDownInStream } = useStore((s) => ({
|
const { buttonDownInStream } = useStore((s) => ({
|
||||||
@ -9,14 +8,7 @@ export default function CmdK() {
|
|||||||
}))
|
}))
|
||||||
const dismiss = useDismiss()
|
const dismiss = useDismiss()
|
||||||
const next = useNextClick(onboardingPaths.USER_MENU)
|
const next = useNextClick(onboardingPaths.USER_MENU)
|
||||||
const [platformName, setPlatformName] = useState<Platform | ''>('')
|
const platformName = usePlatform()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getPlatform() {
|
|
||||||
setPlatformName(await platform())
|
|
||||||
}
|
|
||||||
void getPlatform()
|
|
||||||
}, [setPlatformName])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 grid items-end justify-center pointer-events-none">
|
<div className="fixed inset-0 z-50 grid items-end justify-center pointer-events-none">
|
||||||
@ -29,13 +21,13 @@ export default function CmdK() {
|
|||||||
<h2 className="text-2xl">Command Bar</h2>
|
<h2 className="text-2xl">Command Bar</h2>
|
||||||
<p className="my-4">
|
<p className="my-4">
|
||||||
Press{' '}
|
Press{' '}
|
||||||
{platformName === 'win32' ? (
|
{platformName === 'darwin' ? (
|
||||||
<>
|
<>
|
||||||
<kbd>Win</kbd> + <kbd>/</kbd>
|
<kbd>⌘</kbd> + <kbd>K</kbd>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<kbd>OS</kbd> + <kbd>K</kbd>
|
<kbd>Ctrl</kbd> + <kbd>/</kbd>
|
||||||
</>
|
</>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
to open the command bar. Try changing your theme with it.
|
to open the command bar. Try changing your theme with it.
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { ActionButton } from '../components/ActionButton'
|
import { ActionButton } from '../components/ActionButton'
|
||||||
import { isTauri } from '../lib/isTauri'
|
import { isTauri } from '../lib/isTauri'
|
||||||
import { invoke } from '@tauri-apps/api/tauri'
|
import { invoke } from '@tauri-apps/api/tauri'
|
||||||
@ -65,7 +64,7 @@ const SignIn = () => {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
onClick={signInTauri}
|
onClick={signInTauri}
|
||||||
icon={{ icon: faSignInAlt }}
|
icon={{ icon: 'arrowRight' }}
|
||||||
className="w-fit mt-4"
|
className="w-fit mt-4"
|
||||||
data-testid="sign-in-button"
|
data-testid="sign-in-button"
|
||||||
>
|
>
|
||||||
@ -80,7 +79,7 @@ const SignIn = () => {
|
|||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
window.location.href.replace('signin', '')
|
window.location.href.replace('signin', '')
|
||||||
)}`}
|
)}`}
|
||||||
icon={{ icon: faSignInAlt }}
|
icon={{ icon: 'arrowRight' }}
|
||||||
className="w-fit mt-4"
|
className="w-fit mt-4"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
|
@ -8497,9 +8497,9 @@ ws@^8.8.0:
|
|||||||
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
|
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
|
||||||
|
|
||||||
xstate@^4.38.2:
|
xstate@^4.38.2:
|
||||||
version "4.38.2"
|
version "4.38.3"
|
||||||
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.2.tgz#1b74544fc9c8c6c713ba77f81c6017e65aa89804"
|
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075"
|
||||||
integrity sha512-Fba/DwEPDLneHT3tbJ9F3zafbQXszOlyCJyQqqdzmtlY/cwE2th462KK48yaANf98jHlP6lJvxfNtN0LFKXPQg==
|
integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw==
|
||||||
|
|
||||||
y18n@^5.0.5:
|
y18n@^5.0.5:
|
||||||
version "5.0.8"
|
version "5.0.8"
|
||||||
|
Reference in New Issue
Block a user