Migrate to new split sidebar from accordion-like panes (#2063)
* Split ModelingSidebar out into own component * Consolidate all ModelingPane components and config * Make ModelingSidebar a directory of components and config * Remove unused components * Proper pane styling * Make tooltip configurable to visually appear on hover only * Remove debug panel from App * Fix current tests * Rename to more intuitive names * Fix useEffect loop bug with showDebugPanel * Fix snapshot tests * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Rerun CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Merge branch 'main' into franknoirot/sidebar * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Rerun CI * Maybe some flakiness in the validation initScripts? * Avoid test flakiness by waiting for more signals that loading is completed * Don't assert, just wait for the element to be enabled * Don't let users accidentally click the gap between the pane and the side of the window * Firm up extrude from command bar test * Get rid of unused imports * Add setting to disable blinking cursor (#2065) * Add support for "current" marker in command bar for boolean settings * Add a cursorBlinking setting * Rename setting to blinkingCursor, honor it in the UI * Fix scroll layout bug in settings modal * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Rerun CI * CSS tweaks * Allow settings hotkey within KclEditorPane * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Rerun CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Rerun CI * Ensure the KCL code panel is closed for camera movement test * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Make sure that the camera position inputs are ready to be read from * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Remove repeat awaits * Make camera position fields in debug pane update when the pane is initialized * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Undo that CameraControls change because it made other things weird * retry fixing camera move test * Fix race condition where cam setting cam position parts were overwriting each other * Rerun CI * Rerun CI --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
27
src/components/ModelingSidebar/ModelingPane.module.css
Normal file
27
src/components/ModelingSidebar/ModelingPane.module.css
Normal file
@ -0,0 +1,27 @@
|
||||
.panel {
|
||||
@apply relative z-0 rounded-r max-w-full h-full flex-1;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
@apply bg-chalkboard-10/50 backdrop-blur-sm border border-chalkboard-20;
|
||||
scroll-margin-block-start: 41px;
|
||||
}
|
||||
|
||||
.header::before,
|
||||
.header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.dark) .panel {
|
||||
@apply bg-chalkboard-100/50 backdrop-blur-[3px] border-chalkboard-80;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply z-10 relative rounded-tr;
|
||||
@apply flex h-[41px] items-center justify-between gap-2 px-2;
|
||||
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
|
||||
@apply bg-chalkboard-10 border-b border-chalkboard-20;
|
||||
}
|
||||
|
||||
:global(.dark) .header {
|
||||
@apply bg-chalkboard-90 text-chalkboard-30 border-chalkboard-80;
|
||||
}
|
50
src/components/ModelingSidebar/ModelingPane.tsx
Normal file
50
src/components/ModelingSidebar/ModelingPane.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useStore } from 'useStore'
|
||||
import styles from './ModelingPane.module.css'
|
||||
|
||||
export interface ModelingPaneProps
|
||||
extends React.PropsWithChildren,
|
||||
React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
Menu?: React.ReactNode | React.FC
|
||||
detailsTestId?: string
|
||||
}
|
||||
|
||||
export const ModelingPaneHeader = ({
|
||||
title,
|
||||
Menu,
|
||||
}: Pick<ModelingPaneProps, 'title' | 'Menu'>) => {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className="flex gap-2 items-center flex-1">{title}</div>
|
||||
{Menu instanceof Function ? <Menu /> : Menu}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ModelingPane = ({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
Menu,
|
||||
detailsTestId,
|
||||
...props
|
||||
}: ModelingPaneProps) => {
|
||||
const { buttonDownInStream } = useStore((s) => ({
|
||||
buttonDownInStream: s.buttonDownInStream,
|
||||
}))
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
data-testid={detailsTestId}
|
||||
className={
|
||||
(buttonDownInStream ? 'pointer-events-none ' : 'pointer-events-auto ') +
|
||||
styles.panel +
|
||||
' group ' +
|
||||
(className || '')
|
||||
}
|
||||
>
|
||||
<ModelingPaneHeader title={title} Menu={Menu} />
|
||||
<div className="relative w-full">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
18
src/components/ModelingSidebar/ModelingPanes/DebugPane.tsx
Normal file
18
src/components/ModelingSidebar/ModelingPanes/DebugPane.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { AstExplorer } from '../../AstExplorer'
|
||||
import { EngineCommands } from '../../EngineCommands'
|
||||
import { CamDebugSettings } from 'clientSideScene/ClientSideSceneComp'
|
||||
|
||||
export const DebugPane = () => {
|
||||
return (
|
||||
<section
|
||||
data-testid="debug-panel"
|
||||
className="absolute inset-0 p-2 box-border overflow-auto"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<EngineCommands />
|
||||
<CamDebugSettings />
|
||||
<AstExplorer />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
.button {
|
||||
@apply flex justify-between items-center gap-2 px-2 py-1 text-left border-none rounded-sm;
|
||||
@apply font-mono !no-underline text-xs font-bold select-none text-chalkboard-90;
|
||||
@apply ui-active:bg-primary/10 ui-active:text-primary ui-active:text-inherit;
|
||||
@apply transition-colors ease-out;
|
||||
}
|
||||
|
||||
:global(.dark) .button {
|
||||
@apply !text-chalkboard-30;
|
||||
@apply ui-active:bg-chalkboard-90;
|
||||
}
|
||||
|
||||
.button small {
|
||||
@apply text-chalkboard-60;
|
||||
}
|
||||
|
||||
:global(.dark) .button small {
|
||||
@apply text-chalkboard-40;
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
import { Menu } from '@headlessui/react'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ActionIcon } from 'components/ActionIcon'
|
||||
import styles from './KclEditorMenu.module.css'
|
||||
import { useConvertToVariable } from 'hooks/useToolbarGuards'
|
||||
import { editorShortcutMeta } from './KclEditorPane'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { kclManager } from 'lib/singletons'
|
||||
|
||||
export const KclEditorMenu = ({ children }: PropsWithChildren) => {
|
||||
const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } =
|
||||
useConvertToVariable()
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<div
|
||||
className="relative"
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (e.eventPhase === 3 && target.closest('a') === null) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Button className="p-0 border-none relative">
|
||||
<ActionIcon
|
||||
icon="three-dots"
|
||||
className="p-1"
|
||||
size="sm"
|
||||
bgClassName={
|
||||
'!bg-transparent hover:!bg-primary/10 hover:dark:!bg-chalkboard-100 ui-open:!bg-primary/10 dark:ui-open:!bg-chalkboard-100 rounded-sm'
|
||||
}
|
||||
iconClassName={'!text-chalkboard-90 dark:!text-chalkboard-40'}
|
||||
/>
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute right-0 left-auto w-72 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => kclManager.format()}
|
||||
className={styles.button}
|
||||
>
|
||||
<span>Format code</span>
|
||||
<small>{editorShortcutMeta.formatCode.display}</small>
|
||||
</button>
|
||||
</Menu.Item>
|
||||
{convertToVarEnabled && (
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={handleConvertToVarClick}
|
||||
className={styles.button}
|
||||
>
|
||||
<span>Convert to Variable</span>
|
||||
<small>{editorShortcutMeta.convertToVariable.display}</small>
|
||||
</button>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item>
|
||||
<a
|
||||
className={styles.button}
|
||||
href="https://zoo.dev/docs/kcl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>Read the KCL docs</span>
|
||||
<small>
|
||||
zoo.dev
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="ml-1 align-text-top"
|
||||
width={12}
|
||||
/>
|
||||
</small>
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<a
|
||||
className={styles.button}
|
||||
href="https://zoo.dev/docs/kcl-samples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>KCL samples</span>
|
||||
<small>
|
||||
zoo.dev
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="ml-1 align-text-top"
|
||||
width={12}
|
||||
/>
|
||||
</small>
|
||||
</a>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</div>
|
||||
</Menu>
|
||||
)
|
||||
}
|
334
src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx
Normal file
334
src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx
Normal file
@ -0,0 +1,334 @@
|
||||
import { undo, redo } from '@codemirror/commands'
|
||||
import ReactCodeMirror, {
|
||||
Extension,
|
||||
ViewUpdate,
|
||||
SelectionRange,
|
||||
drawSelection,
|
||||
} from '@uiw/react-codemirror'
|
||||
import { TEST } from 'env'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { useConvertToVariable } from 'hooks/useToolbarGuards'
|
||||
import { Themes, getSystemTheme } from 'lib/theme'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useStore } from 'useStore'
|
||||
import { processCodeMirrorRanges } from 'lib/selections'
|
||||
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'
|
||||
import { lineHighlightField } from 'editor/highlightextension'
|
||||
import { roundOff } from 'lib/utils'
|
||||
import {
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
EditorView,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
indentWithTab,
|
||||
defaultKeymap,
|
||||
historyKeymap,
|
||||
history,
|
||||
} from '@codemirror/commands'
|
||||
import { lintGutter, lintKeymap, linter } from '@codemirror/lint'
|
||||
import { kclErrToDiagnostic } from 'lang/errors'
|
||||
import {
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
} from '@codemirror/language'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import interact from '@replit/codemirror-interact'
|
||||
import { engineCommandManager, sceneInfra, kclManager } from 'lib/singletons'
|
||||
import { useKclContext } from 'lang/KclProvider'
|
||||
import { ModelingMachineEvent } from 'machines/modelingMachine'
|
||||
import {
|
||||
NetworkHealthState,
|
||||
useNetworkStatus,
|
||||
} from 'components/NetworkHealthIndicator'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { paths } from 'lib/paths'
|
||||
import makeUrlPathRelative from 'lib/makeUrlPathRelative'
|
||||
import { useLspContext } from 'components/LspProvider'
|
||||
import { Prec, EditorState } from '@codemirror/state'
|
||||
import {
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
hasNextSnippetField,
|
||||
} from '@codemirror/autocomplete'
|
||||
|
||||
export const editorShortcutMeta = {
|
||||
formatCode: {
|
||||
codeMirror: 'Alt-Shift-f',
|
||||
display: 'Alt + Shift + F',
|
||||
},
|
||||
convertToVariable: {
|
||||
codeMirror: 'Ctrl-Shift-c',
|
||||
display: 'Ctrl + Shift + C',
|
||||
},
|
||||
}
|
||||
|
||||
export const KclEditorPane = () => {
|
||||
const {
|
||||
settings: { context },
|
||||
} = useSettingsAuthContext()
|
||||
const theme =
|
||||
context.app.theme.current === Themes.System
|
||||
? getSystemTheme()
|
||||
: context.app.theme.current
|
||||
const { editorView, setEditorView, isShiftDown } = useStore((s) => ({
|
||||
editorView: s.editorView,
|
||||
setEditorView: s.setEditorView,
|
||||
isShiftDown: s.isShiftDown,
|
||||
}))
|
||||
const { code, errors } = useKclContext()
|
||||
const lastEvent = useRef({ event: '', time: Date.now() })
|
||||
const { overallState } = useNetworkStatus()
|
||||
const isNetworkOkay = overallState === NetworkHealthState.Ok
|
||||
const { copilotLSP, kclLSP } = useLspContext()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const onlineCallback = () => kclManager.setCodeAndExecute(kclManager.code)
|
||||
window.addEventListener('online', onlineCallback)
|
||||
return () => window.removeEventListener('online', onlineCallback)
|
||||
}, [])
|
||||
|
||||
useHotkeys('mod+z', (e) => {
|
||||
e.preventDefault()
|
||||
if (editorView) {
|
||||
undo(editorView)
|
||||
}
|
||||
})
|
||||
useHotkeys('mod+shift+z', (e) => {
|
||||
e.preventDefault()
|
||||
if (editorView) {
|
||||
redo(editorView)
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
context: { selectionRanges },
|
||||
send,
|
||||
state,
|
||||
} = useModelingContext()
|
||||
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const textWrapping = settings.context.textEditor.textWrapping
|
||||
const cursorBlinking = settings.context.textEditor.blinkingCursor
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||
useConvertToVariable()
|
||||
|
||||
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
||||
const onChange = async (newCode: string) => {
|
||||
// If we are just fucking around in a snippet, return early and don't
|
||||
// trigger stuff below that might cause the component to re-render.
|
||||
// Otherwise we will not be able to tab thru the snippet portions.
|
||||
// We explicitly dont check HasPrevSnippetField because we always add
|
||||
// a ${} to the end of the function so that's fine.
|
||||
if (editorView && hasNextSnippetField(editorView.state)) {
|
||||
return
|
||||
}
|
||||
if (isNetworkOkay) kclManager.setCodeAndExecute(newCode)
|
||||
else kclManager.setCode(newCode)
|
||||
} //, []);
|
||||
const lastSelection = useRef('')
|
||||
const onUpdate = (viewUpdate: ViewUpdate) => {
|
||||
// If we are just fucking around in a snippet, return early and don't
|
||||
// trigger stuff below that might cause the component to re-render.
|
||||
// Otherwise we will not be able to tab thru the snippet portions.
|
||||
// We explicitly dont check HasPrevSnippetField because we always add
|
||||
// a ${} to the end of the function so that's fine.
|
||||
if (hasNextSnippetField(viewUpdate.view.state)) {
|
||||
return
|
||||
}
|
||||
if (!editorView) {
|
||||
setEditorView(viewUpdate.view)
|
||||
}
|
||||
const selString = stringifyRanges(
|
||||
viewUpdate?.state?.selection?.ranges || []
|
||||
)
|
||||
if (selString === lastSelection.current) {
|
||||
// onUpdate is noisy and is fired a lot by extensions
|
||||
// since we're only interested in selections changes we can ignore most of these.
|
||||
return
|
||||
}
|
||||
lastSelection.current = selString
|
||||
|
||||
if (
|
||||
// TODO find a less lazy way of getting the last
|
||||
Date.now() - useStore.getState().lastCodeMirrorSelectionUpdatedFromScene <
|
||||
150
|
||||
)
|
||||
return // update triggered by scene selection
|
||||
if (sceneInfra.selected) return // mid drag
|
||||
const ignoreEvents: ModelingMachineEvent['type'][] = [
|
||||
'Equip Line tool',
|
||||
'Equip tangential arc to',
|
||||
]
|
||||
if (ignoreEvents.includes(state.event.type)) return
|
||||
const eventInfo = processCodeMirrorRanges({
|
||||
codeMirrorRanges: viewUpdate.state.selection.ranges,
|
||||
selectionRanges,
|
||||
isShiftDown,
|
||||
})
|
||||
if (!eventInfo) return
|
||||
const deterministicEventInfo = {
|
||||
...eventInfo,
|
||||
engineEvents: eventInfo.engineEvents.map((e) => ({
|
||||
...e,
|
||||
cmd_id: 'static',
|
||||
})),
|
||||
}
|
||||
const stringEvent = JSON.stringify(deterministicEventInfo)
|
||||
if (
|
||||
stringEvent === lastEvent.current.event &&
|
||||
Date.now() - lastEvent.current.time < 500
|
||||
)
|
||||
return // don't repeat events
|
||||
lastEvent.current = { event: stringEvent, time: Date.now() }
|
||||
send(eventInfo.modelingEvent)
|
||||
eventInfo.engineEvents.forEach((event) =>
|
||||
engineCommandManager.sendSceneCommand(event)
|
||||
)
|
||||
}
|
||||
|
||||
const editorExtensions = useMemo(() => {
|
||||
const extensions = [
|
||||
drawSelection({
|
||||
cursorBlinkRate: cursorBlinking.current ? 1200 : 0,
|
||||
}),
|
||||
lineHighlightField,
|
||||
history(),
|
||||
closeBrackets(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
indentWithTab,
|
||||
{
|
||||
key: 'Meta-k',
|
||||
run: () => {
|
||||
commandBarSend({ type: 'Open' })
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
key: isTauri() ? 'Meta-,' : 'Meta-Shift-,',
|
||||
run: () => {
|
||||
navigate(makeUrlPathRelative(paths.SETTINGS))
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
key: editorShortcutMeta.formatCode.codeMirror,
|
||||
run: () => {
|
||||
kclManager.format()
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
key: editorShortcutMeta.convertToVariable.codeMirror,
|
||||
run: () => {
|
||||
if (convertEnabled) {
|
||||
convertCallback()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]),
|
||||
] as Extension[]
|
||||
|
||||
if (kclLSP) extensions.push(Prec.highest(kclLSP))
|
||||
if (copilotLSP) extensions.push(copilotLSP)
|
||||
|
||||
// These extensions have proven to mess with vitest
|
||||
if (!TEST) {
|
||||
extensions.push(
|
||||
lintGutter(),
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
lintGutter(),
|
||||
linter((_view) => {
|
||||
return kclErrToDiagnostic(errors)
|
||||
}),
|
||||
interact({
|
||||
rules: [
|
||||
// a rule for a number dragger
|
||||
{
|
||||
// the regexp matching the value
|
||||
regexp: /-?\b\d+\.?\d*\b/g,
|
||||
// set cursor to "ew-resize" on hover
|
||||
cursor: 'ew-resize',
|
||||
// change number value based on mouse X movement on drag
|
||||
onDrag: (text, setText, e) => {
|
||||
const multiplier =
|
||||
e.shiftKey && e.metaKey
|
||||
? 0.01
|
||||
: e.metaKey
|
||||
? 0.1
|
||||
: e.shiftKey
|
||||
? 10
|
||||
: 1
|
||||
|
||||
const delta = e.movementX * multiplier
|
||||
|
||||
const newVal = roundOff(
|
||||
Number(text) + delta,
|
||||
multiplier === 0.01 ? 2 : multiplier === 0.1 ? 1 : 0
|
||||
)
|
||||
|
||||
if (isNaN(newVal)) return
|
||||
setText(newVal.toString())
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
if (textWrapping.current) extensions.push(EditorView.lineWrapping)
|
||||
}
|
||||
|
||||
return extensions
|
||||
}, [kclLSP, textWrapping.current, cursorBlinking.current, convertCallback])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="code-mirror-override"
|
||||
className={'absolute inset-0 ' + (cursorBlinking.current ? 'blink' : '')}
|
||||
>
|
||||
<ReactCodeMirror
|
||||
value={code}
|
||||
extensions={editorExtensions}
|
||||
onChange={onChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={theme}
|
||||
onCreateEditor={(_editorView) => setEditorView(_editorView)}
|
||||
indentWithTab={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function stringifyRanges(ranges: readonly SelectionRange[]): string {
|
||||
return ranges.map(({ to, from }) => `${to}->${from}`).join('&')
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
import ReactJson from 'react-json-view'
|
||||
import { useKclContext } from 'lang/KclProvider'
|
||||
import { useResolvedTheme } from 'hooks/useResolvedTheme'
|
||||
|
||||
const ReactJsonTypeHack = ReactJson as any
|
||||
|
||||
export const LogsPane = () => {
|
||||
const theme = useResolvedTheme()
|
||||
const { logs } = useKclContext()
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div className="absolute inset-0 p-2 flex flex-col overflow-auto">
|
||||
<ReactJsonTypeHack
|
||||
src={logs}
|
||||
collapsed={1}
|
||||
collapseStringsAfterLength={60}
|
||||
enableClipboard={false}
|
||||
displayArrayKey={false}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={true}
|
||||
indentWidth={2}
|
||||
quotesOnKeys={false}
|
||||
name={false}
|
||||
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const KclErrorsPane = () => {
|
||||
const theme = useResolvedTheme()
|
||||
const { errors } = useKclContext()
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div className="absolute inset-0 p-2 flex flex-col overflow-auto">
|
||||
<ReactJsonTypeHack
|
||||
src={errors}
|
||||
collapsed={1}
|
||||
collapseStringsAfterLength={60}
|
||||
enableClipboard={false}
|
||||
displayArrayKey={false}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={true}
|
||||
indentWidth={2}
|
||||
quotesOnKeys={false}
|
||||
name={false}
|
||||
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
import { processMemory } from './MemoryPane'
|
||||
import { enginelessExecutor } from '../../../lib/testHelpers'
|
||||
import { initPromise, parse } from '../../../lang/wasm'
|
||||
|
||||
beforeAll(() => initPromise)
|
||||
|
||||
describe('processMemory', () => {
|
||||
it('should grab the values and remove and geo data', async () => {
|
||||
// Enable rotations #152
|
||||
const code = `
|
||||
const myVar = 5
|
||||
fn myFn = (a) => {
|
||||
return a - 2
|
||||
}
|
||||
const otherVar = myFn(5)
|
||||
|
||||
const theExtrude = startSketchOn('XY')
|
||||
|> startProfileAt([0, 0], %)
|
||||
|> lineTo([-2.4, myVar], %)
|
||||
|> lineTo([-0.76, otherVar], %)
|
||||
|> extrude(4, %)
|
||||
|
||||
const theSketch = startSketchOn('XY')
|
||||
|> startProfileAt([0, 0], %)
|
||||
|> lineTo([-3.35, 0.17], %)
|
||||
|> lineTo([0.98, 5.16], %)
|
||||
|> lineTo([2.15, 4.32], %)
|
||||
// |> rx(90, %)`
|
||||
const ast = parse(code)
|
||||
const programMemory = await enginelessExecutor(ast, {
|
||||
root: {},
|
||||
return: null,
|
||||
})
|
||||
const output = processMemory(programMemory)
|
||||
expect(output.myVar).toEqual(5)
|
||||
expect(output.otherVar).toEqual(3)
|
||||
expect(output).toEqual({
|
||||
myVar: 5,
|
||||
myFn: undefined,
|
||||
otherVar: 3,
|
||||
theExtrude: [
|
||||
{
|
||||
type: 'extrudePlane',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
faceId: expect.any(String),
|
||||
name: '',
|
||||
id: expect.any(String),
|
||||
sourceRange: [170, 194],
|
||||
},
|
||||
{
|
||||
type: 'extrudePlane',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
faceId: expect.any(String),
|
||||
name: '',
|
||||
id: expect.any(String),
|
||||
sourceRange: [202, 230],
|
||||
},
|
||||
],
|
||||
theSketch: [
|
||||
{ type: 'ToPoint', to: [-3.35, 0.17], from: [0, 0], name: '' },
|
||||
{ type: 'ToPoint', to: [0.98, 5.16], from: [-3.35, 0.17], name: '' },
|
||||
{ type: 'ToPoint', to: [2.15, 4.32], from: [0.98, 5.16], name: '' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
57
src/components/ModelingSidebar/ModelingPanes/MemoryPane.tsx
Normal file
57
src/components/ModelingSidebar/ModelingPanes/MemoryPane.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import ReactJson from 'react-json-view'
|
||||
import { useMemo } from 'react'
|
||||
import { ProgramMemory, Path, ExtrudeSurface } from 'lang/wasm'
|
||||
import { useKclContext } from 'lang/KclProvider'
|
||||
import { useResolvedTheme } from 'hooks/useResolvedTheme'
|
||||
|
||||
export const MemoryPane = () => {
|
||||
const theme = useResolvedTheme()
|
||||
const { programMemory } = useKclContext()
|
||||
const ProcessedMemory = useMemo(
|
||||
() => processMemory(programMemory),
|
||||
[programMemory]
|
||||
)
|
||||
return (
|
||||
<div className="h-full relative">
|
||||
<div className="absolute inset-0 p-2 flex flex-col items-start">
|
||||
<div className="overflow-auto h-full w-full pb-12">
|
||||
<ReactJson
|
||||
src={ProcessedMemory}
|
||||
collapsed={1}
|
||||
collapseStringsAfterLength={60}
|
||||
enableClipboard={false}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={true}
|
||||
indentWidth={2}
|
||||
quotesOnKeys={false}
|
||||
name={false}
|
||||
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const processMemory = (programMemory: ProgramMemory) => {
|
||||
const processedMemory: any = {}
|
||||
Object.keys(programMemory?.root || {}).forEach((key) => {
|
||||
const val = programMemory.root[key]
|
||||
if (typeof val.value !== 'function') {
|
||||
if (val.type === 'SketchGroup') {
|
||||
processedMemory[key] = val.value.map(({ __geoMeta, ...rest }: Path) => {
|
||||
return rest
|
||||
})
|
||||
} else if (val.type === 'ExtrudeGroup') {
|
||||
processedMemory[key] = val.value.map(({ ...rest }: ExtrudeSurface) => {
|
||||
return rest
|
||||
})
|
||||
} else {
|
||||
processedMemory[key] = val.value
|
||||
}
|
||||
} else if (key !== 'log') {
|
||||
processedMemory[key] = '__function__'
|
||||
}
|
||||
})
|
||||
return processedMemory
|
||||
}
|
67
src/components/ModelingSidebar/ModelingPanes/index.ts
Normal file
67
src/components/ModelingSidebar/ModelingPanes/index.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
IconDefinition,
|
||||
faBugSlash,
|
||||
faCode,
|
||||
faCodeCommit,
|
||||
faExclamationCircle,
|
||||
faSquareRootVariable,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { KclEditorMenu } from 'components/ModelingSidebar/ModelingPanes/KclEditorMenu'
|
||||
import { CustomIconName } from 'components/CustomIcon'
|
||||
import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane'
|
||||
import { ReactNode } from 'react'
|
||||
import type { PaneType } from 'useStore'
|
||||
import { MemoryPane } from './MemoryPane'
|
||||
import { KclErrorsPane, LogsPane } from './LoggingPanes'
|
||||
import { DebugPane } from './DebugPane'
|
||||
|
||||
export type Pane = {
|
||||
id: PaneType
|
||||
title: string
|
||||
icon: CustomIconName | IconDefinition
|
||||
Content: ReactNode | React.FC
|
||||
Menu?: ReactNode | React.FC
|
||||
keybinding: string
|
||||
}
|
||||
|
||||
export const topPanes: Pane[] = [
|
||||
{
|
||||
id: 'code',
|
||||
title: 'KCL Code',
|
||||
icon: faCode,
|
||||
Content: KclEditorPane,
|
||||
keybinding: 'shift + c',
|
||||
Menu: KclEditorMenu,
|
||||
},
|
||||
]
|
||||
|
||||
export const bottomPanes: Pane[] = [
|
||||
{
|
||||
id: 'variables',
|
||||
title: 'Variables',
|
||||
icon: faSquareRootVariable,
|
||||
Content: MemoryPane,
|
||||
keybinding: 'shift + v',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
title: 'Logs',
|
||||
icon: faCodeCommit,
|
||||
Content: LogsPane,
|
||||
keybinding: 'shift + l',
|
||||
},
|
||||
{
|
||||
id: 'kclErrors',
|
||||
title: 'KCL Errors',
|
||||
icon: faExclamationCircle,
|
||||
Content: KclErrorsPane,
|
||||
keybinding: 'shift + e',
|
||||
},
|
||||
{
|
||||
id: 'debug',
|
||||
title: 'Debug',
|
||||
icon: faBugSlash,
|
||||
Content: DebugPane,
|
||||
keybinding: 'shift + d',
|
||||
},
|
||||
]
|
11
src/components/ModelingSidebar/ModelingSidebar.module.css
Normal file
11
src/components/ModelingSidebar/ModelingSidebar.module.css
Normal file
@ -0,0 +1,11 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
row-gap: 0.25rem;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
padding-block: 1px;
|
||||
max-width: 100%;
|
||||
flex: 1 1 0;
|
||||
}
|
213
src/components/ModelingSidebar/ModelingSidebar.tsx
Normal file
213
src/components/ModelingSidebar/ModelingSidebar.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { Resizable } from 're-resizable'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { PaneType, useStore } from 'useStore'
|
||||
import { Tab } from '@headlessui/react'
|
||||
import { Pane, bottomPanes, topPanes } from './ModelingPanes'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { ActionIcon } from 'components/ActionIcon'
|
||||
import styles from './ModelingSidebar.module.css'
|
||||
import { ModelingPane } from './ModelingPane'
|
||||
|
||||
interface ModelingSidebarProps {
|
||||
paneOpacity: '' | 'opacity-20' | 'opacity-40'
|
||||
}
|
||||
|
||||
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
|
||||
const { buttonDownInStream, openPanes } = useStore((s) => ({
|
||||
buttonDownInStream: s.buttonDownInStream,
|
||||
openPanes: s.openPanes,
|
||||
}))
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const {
|
||||
app: { onboardingStatus },
|
||||
} = settings.context
|
||||
|
||||
const pointerEventsCssClass =
|
||||
buttonDownInStream || onboardingStatus.current === 'camera'
|
||||
? 'pointer-events-none '
|
||||
: 'pointer-events-auto'
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
className={`flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}
|
||||
defaultSize={{
|
||||
width: '550px',
|
||||
height: 'auto',
|
||||
}}
|
||||
minWidth={200}
|
||||
maxWidth={800}
|
||||
handleClasses={{
|
||||
right:
|
||||
(openPanes.length === 0 ? 'hidden ' : 'block ') +
|
||||
'translate-x-1/2 hover:bg-chalkboard-10 hover:dark:bg-chalkboard-110 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
|
||||
pointerEventsCssClass,
|
||||
}}
|
||||
>
|
||||
<div className={styles.grid + ' flex-1'}>
|
||||
<ModelingSidebarSection panes={topPanes} />
|
||||
<ModelingSidebarSection panes={bottomPanes} alignButtons="end" />
|
||||
</div>
|
||||
</Resizable>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelingSidebarSectionProps {
|
||||
panes: Pane[]
|
||||
alignButtons?: 'start' | 'end'
|
||||
}
|
||||
|
||||
function ModelingSidebarSection({
|
||||
panes,
|
||||
alignButtons = 'start',
|
||||
}: ModelingSidebarSectionProps) {
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const showDebugPanel = settings.context.modeling.showDebugPanel
|
||||
const paneIds = panes.map((pane) => pane.id)
|
||||
const { openPanes, setOpenPanes } = useStore((s) => ({
|
||||
openPanes: s.openPanes,
|
||||
setOpenPanes: s.setOpenPanes,
|
||||
}))
|
||||
const foundOpenPane = openPanes.find((pane) => paneIds.includes(pane))
|
||||
const [currentPane, setCurrentPane] = useState(
|
||||
foundOpenPane || ('none' as PaneType | 'none')
|
||||
)
|
||||
|
||||
const togglePane = useCallback(
|
||||
(newPane: PaneType | 'none') => {
|
||||
if (newPane === 'none') {
|
||||
setOpenPanes(openPanes.filter((p) => p !== currentPane))
|
||||
setCurrentPane('none')
|
||||
} else if (newPane === currentPane) {
|
||||
setCurrentPane('none')
|
||||
setOpenPanes(openPanes.filter((p) => p !== newPane))
|
||||
} else {
|
||||
setOpenPanes([...openPanes.filter((p) => p !== currentPane), newPane])
|
||||
setCurrentPane(newPane)
|
||||
}
|
||||
},
|
||||
[openPanes, setOpenPanes, currentPane, setCurrentPane]
|
||||
)
|
||||
|
||||
// Filter out the debug panel if it's not supposed to be shown
|
||||
// TODO: abstract out for allowing user to configure which panes to show
|
||||
const filteredPanes = showDebugPanel.current
|
||||
? panes
|
||||
: panes.filter((pane) => pane.id !== 'debug')
|
||||
useEffect(() => {
|
||||
if (
|
||||
!showDebugPanel.current &&
|
||||
currentPane === 'debug' &&
|
||||
openPanes.includes('debug')
|
||||
) {
|
||||
togglePane('debug')
|
||||
}
|
||||
}, [showDebugPanel.current, togglePane, openPanes])
|
||||
|
||||
return (
|
||||
<Tab.Group
|
||||
vertical
|
||||
selectedIndex={
|
||||
currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1
|
||||
}
|
||||
onChange={(index) => {
|
||||
const newPane = index === 0 ? 'none' : paneIds[index - 1]
|
||||
togglePane(newPane)
|
||||
}}
|
||||
>
|
||||
<Tab.List
|
||||
className={
|
||||
(alignButtons === 'start'
|
||||
? 'justify-start self-start'
|
||||
: 'justify-end self-end') +
|
||||
(currentPane === 'none'
|
||||
? ' rounded-r focus-within:!border-primary/50'
|
||||
: ' border-r-0') +
|
||||
' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 ' +
|
||||
(openPanes.length === 1 && currentPane === 'none' ? 'pr-0.5' : '')
|
||||
}
|
||||
>
|
||||
<Tab key="none" className="sr-only">
|
||||
No panes open
|
||||
</Tab>
|
||||
{filteredPanes.map((pane) => (
|
||||
<ModelingPaneButton
|
||||
key={pane.id}
|
||||
paneConfig={pane}
|
||||
currentPane={currentPane}
|
||||
togglePane={() => togglePane(pane.id)}
|
||||
/>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels
|
||||
as="article"
|
||||
className={
|
||||
'col-start-2 col-span-1 ' +
|
||||
(openPanes.length === 1
|
||||
? currentPane !== 'none'
|
||||
? `row-start-1 row-end-3`
|
||||
: `hidden`
|
||||
: ``)
|
||||
}
|
||||
>
|
||||
<Tab.Panel key="none" />
|
||||
{filteredPanes.map((pane) => (
|
||||
<Tab.Panel key={pane.id} className="h-full">
|
||||
<ModelingPane title={pane.title} Menu={pane.Menu}>
|
||||
{pane.Content instanceof Function ? (
|
||||
<pane.Content />
|
||||
) : (
|
||||
pane.Content
|
||||
)}
|
||||
</ModelingPane>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelingPaneButtonProps {
|
||||
paneConfig: Pane
|
||||
currentPane: PaneType | 'none'
|
||||
togglePane: () => void
|
||||
}
|
||||
|
||||
function ModelingPaneButton({
|
||||
paneConfig,
|
||||
currentPane,
|
||||
togglePane,
|
||||
}: ModelingPaneButtonProps) {
|
||||
useHotkeys(paneConfig.keybinding, togglePane, {
|
||||
scopes: ['modeling'],
|
||||
})
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={paneConfig.id}
|
||||
className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-none"
|
||||
onClick={togglePane}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={paneConfig.icon}
|
||||
className="p-1"
|
||||
size="sm"
|
||||
iconClassName={
|
||||
paneConfig.id === currentPane
|
||||
? ' !text-chalkboard-10'
|
||||
: '!text-chalkboard-80 dark:!text-chalkboard-30'
|
||||
}
|
||||
bgClassName={
|
||||
'rounded-sm ' +
|
||||
(paneConfig.id === currentPane ? '!bg-primary' : '!bg-transparent')
|
||||
}
|
||||
/>
|
||||
<Tooltip position="right" hoverOnly delay={800}>
|
||||
<span>{paneConfig.title}</span>
|
||||
<br />
|
||||
<span className="text-xs capitalize">{paneConfig.keybinding}</span>
|
||||
</Tooltip>
|
||||
</Tab>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user