Add menu to code editor, put "Format code" and "Convert to variable" buttons in it (#426)
* Move format code button to menu item by extending CollapsiblePanel to take an optional menu React element. Signed-off-by: Frank Noirot <frank@kittycad.io> * Style tweaks * Add shortcuts for format and cmd bar to codemirror * Move convert to variable into code menu Signed off by Frank Noirot <frank@kittycad.io> * Add keyboard shortcut to convert to variable * Remove convert to variable from toolbar * Refactor: move TextEditor to its own component * Set a better convertToVar shortcut * Style and ergonomic polish for convertToVar modal * Use named constants for shortcuts 😇 * Try yet another keyboard shortcut * Fix formatting * remove isShiftDown from app.tsx --------- Signed-off-by: Frank Noirot <frank@kittycad.io> Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
This commit is contained in:
223
src/App.tsx
223
src/App.tsx
@ -2,7 +2,6 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
@ -10,30 +9,20 @@ import { DebugPanel } from './components/DebugPanel'
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { asyncParser } from './lang/abstractSyntaxTree'
|
import { asyncParser } from './lang/abstractSyntaxTree'
|
||||||
import { _executor } from './lang/executor'
|
import { _executor } from './lang/executor'
|
||||||
import CodeMirror, { Extension } from '@uiw/react-codemirror'
|
import { PaneType, useStore } from './useStore'
|
||||||
import { linter, lintGutter } from '@codemirror/lint'
|
|
||||||
import { ViewUpdate, EditorView } from '@codemirror/view'
|
|
||||||
import {
|
|
||||||
lineHighlightField,
|
|
||||||
addLineHighlight,
|
|
||||||
} from './editor/highlightextension'
|
|
||||||
import { PaneType, Selections, useStore } from './useStore'
|
|
||||||
import Server from './editor/lsp/server'
|
|
||||||
import Client from './editor/lsp/client'
|
|
||||||
import { Logs, KCLErrors } from './components/Logs'
|
import { Logs, KCLErrors } from './components/Logs'
|
||||||
import { CollapsiblePanel } from './components/CollapsiblePanel'
|
import { CollapsiblePanel } from './components/CollapsiblePanel'
|
||||||
import { MemoryPanel } from './components/MemoryPanel'
|
import { MemoryPanel } from './components/MemoryPanel'
|
||||||
import { useHotKeyListener } from './hooks/useHotKeyListener'
|
import { useHotKeyListener } from './hooks/useHotKeyListener'
|
||||||
import { Stream } from './components/Stream'
|
import { Stream } from './components/Stream'
|
||||||
import ModalContainer from 'react-modal-promise'
|
import ModalContainer from 'react-modal-promise'
|
||||||
import { FromServer, IntoServer } from './editor/lsp/codec'
|
|
||||||
import {
|
import {
|
||||||
EngineCommand,
|
EngineCommand,
|
||||||
EngineCommandManager,
|
EngineCommandManager,
|
||||||
} from './lang/std/engineConnection'
|
} from './lang/std/engineConnection'
|
||||||
import { isOverlap, throttle } from './lib/utils'
|
import { throttle } from './lib/utils'
|
||||||
import { AppHeader } from './components/AppHeader'
|
import { AppHeader } from './components/AppHeader'
|
||||||
import { KCLError, kclErrToDiagnostic } from './lang/errors'
|
import { KCLError } from './lang/errors'
|
||||||
import { Resizable } from 're-resizable'
|
import { Resizable } from 're-resizable'
|
||||||
import {
|
import {
|
||||||
faCode,
|
faCode,
|
||||||
@ -41,57 +30,42 @@ import {
|
|||||||
faSquareRootVariable,
|
faSquareRootVariable,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { TEST } from './env'
|
|
||||||
import { getNormalisedCoordinates } from './lib/utils'
|
import { getNormalisedCoordinates } from './lib/utils'
|
||||||
import { Themes, getSystemTheme } from './lib/theme'
|
|
||||||
import { isTauri } from './lib/isTauri'
|
import { isTauri } from './lib/isTauri'
|
||||||
import { useLoaderData, useParams } from 'react-router-dom'
|
import { useLoaderData } from 'react-router-dom'
|
||||||
import { writeTextFile } from '@tauri-apps/api/fs'
|
|
||||||
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
|
|
||||||
import { IndexLoaderData } from './Router'
|
import { IndexLoaderData } from './Router'
|
||||||
import { toast } from 'react-hot-toast'
|
|
||||||
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
import { onboardingPaths } from 'routes/Onboarding'
|
import { onboardingPaths } from 'routes/Onboarding'
|
||||||
import { LanguageServerClient } from 'editor/lsp'
|
|
||||||
import kclLanguage from 'editor/lsp/language'
|
|
||||||
import { CSSRuleObject } from 'tailwindcss/types/config'
|
|
||||||
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
||||||
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
|
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
|
||||||
|
import { CodeMenu } from 'components/CodeMenu'
|
||||||
|
import { TextEditor } from 'components/TextEditor'
|
||||||
|
import { Themes, getSystemTheme } from 'lib/theme'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
|
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
|
||||||
const pathParams = useParams()
|
|
||||||
const streamRef = useRef<HTMLDivElement>(null)
|
const streamRef = useRef<HTMLDivElement>(null)
|
||||||
useHotKeyListener()
|
useHotKeyListener()
|
||||||
const {
|
const {
|
||||||
editorView,
|
|
||||||
setEditorView,
|
|
||||||
setSelectionRanges,
|
|
||||||
selectionRanges,
|
|
||||||
addLog,
|
addLog,
|
||||||
addKCLError,
|
addKCLError,
|
||||||
code,
|
|
||||||
setCode,
|
setCode,
|
||||||
setAst,
|
setAst,
|
||||||
setError,
|
setError,
|
||||||
setProgramMemory,
|
setProgramMemory,
|
||||||
resetLogs,
|
resetLogs,
|
||||||
resetKCLErrors,
|
resetKCLErrors,
|
||||||
selectionRangeTypeMap,
|
|
||||||
setArtifactMap,
|
setArtifactMap,
|
||||||
engineCommandManager,
|
engineCommandManager,
|
||||||
setEngineCommandManager,
|
setEngineCommandManager,
|
||||||
highlightRange,
|
highlightRange,
|
||||||
setHighlightRange,
|
setHighlightRange,
|
||||||
setCursor2,
|
setCursor2,
|
||||||
sourceRangeMap,
|
|
||||||
setMediaStream,
|
setMediaStream,
|
||||||
setIsStreamReady,
|
setIsStreamReady,
|
||||||
isStreamReady,
|
isStreamReady,
|
||||||
isLSPServerReady,
|
|
||||||
setIsLSPServerReady,
|
|
||||||
buttonDownInStream,
|
buttonDownInStream,
|
||||||
formatCode,
|
|
||||||
openPanes,
|
openPanes,
|
||||||
setOpenPanes,
|
setOpenPanes,
|
||||||
didDragInStream,
|
didDragInStream,
|
||||||
@ -99,40 +73,25 @@ export function App() {
|
|||||||
streamDimensions,
|
streamDimensions,
|
||||||
setIsExecuting,
|
setIsExecuting,
|
||||||
defferedCode,
|
defferedCode,
|
||||||
defferedSetCode,
|
|
||||||
} = useStore((s) => ({
|
} = useStore((s) => ({
|
||||||
editorView: s.editorView,
|
|
||||||
setEditorView: s.setEditorView,
|
|
||||||
setSelectionRanges: s.setSelectionRanges,
|
|
||||||
selectionRanges: s.selectionRanges,
|
|
||||||
setGuiMode: s.setGuiMode,
|
|
||||||
addLog: s.addLog,
|
addLog: s.addLog,
|
||||||
code: s.code,
|
|
||||||
defferedCode: s.defferedCode,
|
defferedCode: s.defferedCode,
|
||||||
setCode: s.setCode,
|
setCode: s.setCode,
|
||||||
defferedSetCode: s.defferedSetCode,
|
|
||||||
setAst: s.setAst,
|
setAst: s.setAst,
|
||||||
setError: s.setError,
|
setError: s.setError,
|
||||||
setProgramMemory: s.setProgramMemory,
|
setProgramMemory: s.setProgramMemory,
|
||||||
resetLogs: s.resetLogs,
|
resetLogs: s.resetLogs,
|
||||||
resetKCLErrors: s.resetKCLErrors,
|
resetKCLErrors: s.resetKCLErrors,
|
||||||
selectionRangeTypeMap: s.selectionRangeTypeMap,
|
|
||||||
setArtifactMap: s.setArtifactNSourceRangeMaps,
|
setArtifactMap: s.setArtifactNSourceRangeMaps,
|
||||||
engineCommandManager: s.engineCommandManager,
|
engineCommandManager: s.engineCommandManager,
|
||||||
setEngineCommandManager: s.setEngineCommandManager,
|
setEngineCommandManager: s.setEngineCommandManager,
|
||||||
highlightRange: s.highlightRange,
|
highlightRange: s.highlightRange,
|
||||||
setHighlightRange: s.setHighlightRange,
|
setHighlightRange: s.setHighlightRange,
|
||||||
isShiftDown: s.isShiftDown,
|
|
||||||
setCursor: s.setCursor,
|
|
||||||
setCursor2: s.setCursor2,
|
setCursor2: s.setCursor2,
|
||||||
sourceRangeMap: s.sourceRangeMap,
|
|
||||||
setMediaStream: s.setMediaStream,
|
setMediaStream: s.setMediaStream,
|
||||||
isStreamReady: s.isStreamReady,
|
isStreamReady: s.isStreamReady,
|
||||||
setIsStreamReady: s.setIsStreamReady,
|
setIsStreamReady: s.setIsStreamReady,
|
||||||
isLSPServerReady: s.isLSPServerReady,
|
|
||||||
setIsLSPServerReady: s.setIsLSPServerReady,
|
|
||||||
buttonDownInStream: s.buttonDownInStream,
|
buttonDownInStream: s.buttonDownInStream,
|
||||||
formatCode: s.formatCode,
|
|
||||||
addKCLError: s.addKCLError,
|
addKCLError: s.addKCLError,
|
||||||
openPanes: s.openPanes,
|
openPanes: s.openPanes,
|
||||||
setOpenPanes: s.setOpenPanes,
|
setOpenPanes: s.setOpenPanes,
|
||||||
@ -147,13 +106,7 @@ export function App() {
|
|||||||
context: { token },
|
context: { token },
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
context: {
|
context: { showDebugPanel, onboardingStatus, cameraControls, theme },
|
||||||
showDebugPanel,
|
|
||||||
theme,
|
|
||||||
onboardingStatus,
|
|
||||||
textWrapping,
|
|
||||||
cameraControls,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} = useGlobalStateContext()
|
} = useGlobalStateContext()
|
||||||
|
|
||||||
@ -194,80 +147,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [loadedCode, setCode])
|
}, [loadedCode, setCode])
|
||||||
|
|
||||||
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
|
||||||
const onChange = (value: string, viewUpdate: ViewUpdate) => {
|
|
||||||
defferedSetCode(value)
|
|
||||||
if (isTauri() && pathParams.id) {
|
|
||||||
// Save the file to disk
|
|
||||||
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
|
||||||
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
|
|
||||||
(err) => {
|
|
||||||
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
|
|
||||||
console.error('error saving file', err)
|
|
||||||
toast.error('Error saving file, please check file permissions')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (editorView) {
|
|
||||||
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
|
||||||
}
|
|
||||||
} //, []);
|
|
||||||
const onUpdate = (viewUpdate: ViewUpdate) => {
|
|
||||||
if (!editorView) {
|
|
||||||
setEditorView(viewUpdate.view)
|
|
||||||
}
|
|
||||||
const ranges = viewUpdate.state.selection.ranges
|
|
||||||
|
|
||||||
const isChange =
|
|
||||||
ranges.length !== selectionRanges.codeBasedSelections.length ||
|
|
||||||
ranges.some(({ from, to }, i) => {
|
|
||||||
return (
|
|
||||||
from !== selectionRanges.codeBasedSelections[i].range[0] ||
|
|
||||||
to !== selectionRanges.codeBasedSelections[i].range[1]
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isChange) return
|
|
||||||
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
|
|
||||||
({ from, to }) => {
|
|
||||||
if (selectionRangeTypeMap[to]) {
|
|
||||||
return {
|
|
||||||
type: selectionRangeTypeMap[to],
|
|
||||||
range: [from, to],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'default',
|
|
||||||
range: [from, to],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const idBasedSelections = codeBasedSelections
|
|
||||||
.map(({ type, range }) => {
|
|
||||||
const hasOverlap = Object.entries(sourceRangeMap).filter(
|
|
||||||
([_, sourceRange]) => {
|
|
||||||
return isOverlap(sourceRange, range)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (hasOverlap.length) {
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
id: hasOverlap[0][0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean) as any
|
|
||||||
|
|
||||||
engineCommandManager?.cusorsSelected({
|
|
||||||
otherSelections: [],
|
|
||||||
idBasedSelections,
|
|
||||||
})
|
|
||||||
|
|
||||||
setSelectionRanges({
|
|
||||||
otherSelections: [],
|
|
||||||
codeBasedSelections,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const streamWidth = streamRef?.current?.offsetWidth
|
const streamWidth = streamRef?.current?.offsetWidth
|
||||||
const streamHeight = streamRef?.current?.offsetHeight
|
const streamHeight = streamRef?.current?.offsetHeight
|
||||||
|
|
||||||
@ -445,64 +324,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// So this is a bit weird, we need to initialize the lsp server and client.
|
|
||||||
// But the server happens async so we break this into two parts.
|
|
||||||
// Below is the client and server promise.
|
|
||||||
const { lspClient } = useMemo(() => {
|
|
||||||
const intoServer: IntoServer = new IntoServer()
|
|
||||||
const fromServer: FromServer = FromServer.create()
|
|
||||||
const client = new Client(fromServer, intoServer)
|
|
||||||
if (!TEST) {
|
|
||||||
Server.initialize(intoServer, fromServer).then((lspServer) => {
|
|
||||||
lspServer.start()
|
|
||||||
setIsLSPServerReady(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const lspClient = new LanguageServerClient({ client })
|
|
||||||
return { lspClient }
|
|
||||||
}, [setIsLSPServerReady])
|
|
||||||
|
|
||||||
// Here we initialize the plugin which will start the client.
|
|
||||||
// When we have multi-file support the name of the file will be a dep of
|
|
||||||
// this use memo, as well as the directory structure, which I think is
|
|
||||||
// a good setup becuase it will restart the client but not the server :)
|
|
||||||
// We do not want to restart the server, its just wasteful.
|
|
||||||
const kclLSP = useMemo(() => {
|
|
||||||
let plugin = null
|
|
||||||
if (isLSPServerReady && !TEST) {
|
|
||||||
// Set up the lsp plugin.
|
|
||||||
const lsp = kclLanguage({
|
|
||||||
// When we have more than one file, we'll need to change this.
|
|
||||||
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
|
|
||||||
workspaceFolders: null,
|
|
||||||
client: lspClient,
|
|
||||||
})
|
|
||||||
|
|
||||||
plugin = lsp
|
|
||||||
}
|
|
||||||
return plugin
|
|
||||||
}, [lspClient, isLSPServerReady])
|
|
||||||
|
|
||||||
const editorExtensions = useMemo(() => {
|
|
||||||
const extensions = [lineHighlightField] as Extension[]
|
|
||||||
|
|
||||||
if (kclLSP) extensions.push(kclLSP)
|
|
||||||
|
|
||||||
// These extensions have proven to mess with vitest
|
|
||||||
if (!TEST) {
|
|
||||||
extensions.push(
|
|
||||||
lintGutter(),
|
|
||||||
linter((_view) => {
|
|
||||||
return kclErrToDiagnostic(useStore.getState().kclErrors)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
return extensions
|
|
||||||
}, [kclLSP, textWrapping])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
|
className="h-screen overflow-hidden relative flex flex-col cursor-pointer select-none"
|
||||||
@ -546,31 +367,9 @@ export function App() {
|
|||||||
icon={faCode}
|
icon={faCode}
|
||||||
className="open:!mb-2"
|
className="open:!mb-2"
|
||||||
open={openPanes.includes('code')}
|
open={openPanes.includes('code')}
|
||||||
|
menu={<CodeMenu />}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1">
|
<TextEditor theme={editorTheme} />
|
||||||
<button
|
|
||||||
// disabled={!shouldFormat}
|
|
||||||
onClick={formatCode}
|
|
||||||
// className={`${!shouldFormat && 'text-gray-300'}`}
|
|
||||||
>
|
|
||||||
format
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="code-mirror-override"
|
|
||||||
className="full-height-subtract"
|
|
||||||
style={{ '--height-subtract': '4.25rem' } as CSSRuleObject}
|
|
||||||
>
|
|
||||||
<CodeMirror
|
|
||||||
className="h-full"
|
|
||||||
value={code}
|
|
||||||
extensions={editorExtensions}
|
|
||||||
onChange={onChange}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
theme={editorTheme}
|
|
||||||
onCreateEditor={(_editorView) => setEditorView(_editorView)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsiblePanel>
|
</CollapsiblePanel>
|
||||||
<section className="flex flex-col">
|
<section className="flex flex-col">
|
||||||
<MemoryPanel
|
<MemoryPanel
|
||||||
|
@ -8,7 +8,6 @@ import { EqualAngle } from './components/Toolbar/EqualAngle'
|
|||||||
import { Intersect } from './components/Toolbar/Intersect'
|
import { Intersect } from './components/Toolbar/Intersect'
|
||||||
import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance'
|
import { SetHorzVertDistance } from './components/Toolbar/SetHorzVertDistance'
|
||||||
import { SetAngleLength } from './components/Toolbar/setAngleLength'
|
import { SetAngleLength } from './components/Toolbar/setAngleLength'
|
||||||
import { ConvertToVariable } from './components/Toolbar/ConvertVariable'
|
|
||||||
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
|
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
|
||||||
import { SetAngleBetween } from './components/Toolbar/SetAngleBetween'
|
import { SetAngleBetween } from './components/Toolbar/SetAngleBetween'
|
||||||
import { Fragment, useEffect } from 'react'
|
import { Fragment, useEffect } from 'react'
|
||||||
@ -164,7 +163,6 @@ export const Toolbar = () => {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<ConvertToVariable />
|
|
||||||
<HorzVert horOrVert="horizontal" />
|
<HorzVert horOrVert="horizontal" />
|
||||||
<HorzVert horOrVert="vertical" />
|
<HorzVert horOrVert="vertical" />
|
||||||
<EqualLength />
|
<EqualLength />
|
||||||
|
@ -198,29 +198,25 @@ export const CreateNewVariable = ({
|
|||||||
isNewVariableNameUnique,
|
isNewVariableNameUnique,
|
||||||
setNewVariableName,
|
setNewVariableName,
|
||||||
shouldCreateVariable,
|
shouldCreateVariable,
|
||||||
setShouldCreateVariable,
|
setShouldCreateVariable = () => {},
|
||||||
showCheckbox = true,
|
showCheckbox = true,
|
||||||
}: {
|
}: {
|
||||||
isNewVariableNameUnique: boolean
|
isNewVariableNameUnique: boolean
|
||||||
newVariableName: string
|
newVariableName: string
|
||||||
setNewVariableName: (a: string) => void
|
setNewVariableName: (a: string) => void
|
||||||
shouldCreateVariable: boolean
|
shouldCreateVariable?: boolean
|
||||||
setShouldCreateVariable: (a: boolean) => void
|
setShouldCreateVariable?: (a: boolean) => void
|
||||||
showCheckbox?: boolean
|
showCheckbox?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label
|
<label htmlFor="create-new-variable" className="block mt-3 font-mono">
|
||||||
htmlFor="create-new-variable"
|
|
||||||
className="block text-sm font-medium text-gray-700 mt-3 font-mono"
|
|
||||||
>
|
|
||||||
Create new variable
|
Create new variable
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 flex flex-1">
|
<div className="mt-1 flex gap-2 items-center">
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink"
|
|
||||||
checked={shouldCreateVariable}
|
checked={shouldCreateVariable}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setShouldCreateVariable(e.target.checked)
|
setShouldCreateVariable(e.target.checked)
|
||||||
@ -232,7 +228,10 @@ export const CreateNewVariable = ({
|
|||||||
disabled={!shouldCreateVariable}
|
disabled={!shouldCreateVariable}
|
||||||
name="create-new-variable"
|
name="create-new-variable"
|
||||||
id="create-new-variable"
|
id="create-new-variable"
|
||||||
className={`shadow-sm font-[monospace] focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md font-mono pl-1 flex-shrink-0 ${
|
autoFocus={true}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
className={`font-mono flex-1 sm:text-sm px-2 py-1 rounded-sm bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-90 dark:text-chalkboard-10 ${
|
||||||
!shouldCreateVariable ? 'opacity-50' : ''
|
!shouldCreateVariable ? 'opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
value={newVariableName}
|
value={newVariableName}
|
||||||
|
19
src/components/CodeMenu.module.css
Normal file
19
src/components/CodeMenu.module.css
Normal file
@ -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 text-xs font-bold select-none text-chalkboard-90;
|
||||||
|
@apply ui-active:bg-liquid-10/50 ui-active:text-liquid-90;
|
||||||
|
@apply transition-colors ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .button {
|
||||||
|
@apply text-chalkboard-30;
|
||||||
|
@apply ui-active:bg-chalkboard-80 ui-active:text-liquid-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button small {
|
||||||
|
@apply text-chalkboard-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .button small {
|
||||||
|
@apply text-chalkboard-40;
|
||||||
|
}
|
59
src/components/CodeMenu.tsx
Normal file
59
src/components/CodeMenu.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Menu } from '@headlessui/react'
|
||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
import { faEllipsis } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { ActionIcon } from './ActionIcon'
|
||||||
|
import { useStore } from 'useStore'
|
||||||
|
import styles from './CodeMenu.module.css'
|
||||||
|
import { useConvertToVariable } from 'hooks/useToolbarGuards'
|
||||||
|
import { editorShortcutMeta } from './TextEditor'
|
||||||
|
|
||||||
|
export const CodeMenu = ({ children }: PropsWithChildren) => {
|
||||||
|
const { formatCode } = useStore((s) => ({
|
||||||
|
formatCode: s.formatCode,
|
||||||
|
}))
|
||||||
|
const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } =
|
||||||
|
useConvertToVariable()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.eventPhase === 3) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu.Button className="p-0 border-none relative">
|
||||||
|
<ActionIcon
|
||||||
|
icon={faEllipsis}
|
||||||
|
bgClassName={
|
||||||
|
'bg-chalkboard-20 dark:bg-chalkboard-110 hover:bg-liquid-10/50 hover:dark:bg-chalkboard-90 ui-active:bg-chalkboard-80 ui-active:dark:bg-chalkboard-90 rounded'
|
||||||
|
}
|
||||||
|
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-90 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50">
|
||||||
|
<Menu.Item>
|
||||||
|
<button onClick={() => formatCode()} 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.Items>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
.header {
|
.header {
|
||||||
@apply sticky top-0 z-10 cursor-pointer;
|
@apply sticky top-0 z-10 cursor-pointer;
|
||||||
@apply flex items-center gap-2 w-full p-2;
|
@apply flex items-center justify-between gap-2 w-full p-2;
|
||||||
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
|
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
|
||||||
@apply bg-chalkboard-20;
|
@apply bg-chalkboard-20;
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ export interface CollapsiblePanelProps
|
|||||||
title: string
|
title: string
|
||||||
icon?: IconDefinition
|
icon?: IconDefinition
|
||||||
open?: boolean
|
open?: boolean
|
||||||
|
menu?: React.ReactNode
|
||||||
iconClassNames?: {
|
iconClassNames?: {
|
||||||
bg?: string
|
bg?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
@ -18,21 +19,27 @@ export const PanelHeader = ({
|
|||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
iconClassNames,
|
iconClassNames,
|
||||||
|
menu,
|
||||||
}: CollapsiblePanelProps) => {
|
}: CollapsiblePanelProps) => {
|
||||||
return (
|
return (
|
||||||
<summary className={styles.header}>
|
<summary className={styles.header}>
|
||||||
<ActionIcon
|
<div className="flex gap-2 align-center flex-1">
|
||||||
icon={icon}
|
<ActionIcon
|
||||||
bgClassName={
|
icon={icon}
|
||||||
'bg-chalkboard-30 dark:bg-chalkboard-90 group-open:bg-chalkboard-80 rounded ' +
|
bgClassName={
|
||||||
(iconClassNames?.bg || '')
|
'bg-chalkboard-30 dark:bg-chalkboard-90 group-open:bg-chalkboard-80 rounded ' +
|
||||||
}
|
(iconClassNames?.bg || '')
|
||||||
iconClassName={
|
}
|
||||||
'text-chalkboard-90 dark:text-chalkboard-40 group-open:text-liquid-10 ' +
|
iconClassName={
|
||||||
(iconClassNames?.icon || '')
|
'text-chalkboard-90 dark:text-chalkboard-40 group-open:text-liquid-10 ' +
|
||||||
}
|
(iconClassNames?.icon || '')
|
||||||
/>
|
}
|
||||||
{title}
|
/>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="group-open:opacity-100 opacity-0 group-open:pointer-events-auto pointer-events-none">
|
||||||
|
{menu}
|
||||||
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -43,6 +50,7 @@ export const CollapsiblePanel = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
iconClassNames,
|
iconClassNames,
|
||||||
|
menu,
|
||||||
...props
|
...props
|
||||||
}: CollapsiblePanelProps) => {
|
}: CollapsiblePanelProps) => {
|
||||||
return (
|
return (
|
||||||
@ -50,7 +58,12 @@ export const CollapsiblePanel = ({
|
|||||||
{...props}
|
{...props}
|
||||||
className={styles.panel + ' group ' + (className || '')}
|
className={styles.panel + ' group ' + (className || '')}
|
||||||
>
|
>
|
||||||
<PanelHeader title={title} icon={icon} iconClassNames={iconClassNames} />
|
<PanelHeader
|
||||||
|
title={title}
|
||||||
|
icon={icon}
|
||||||
|
iconClassNames={iconClassNames}
|
||||||
|
menu={menu}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { useCalc, CreateNewVariable } from './AvailableVarsHelpers'
|
import { useCalc, CreateNewVariable } from './AvailableVarsHelpers'
|
||||||
|
import { ActionButton } from './ActionButton'
|
||||||
|
import { faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
export const SetVarNameModal = ({
|
export const SetVarNameModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -19,67 +22,65 @@ export const SetVarNameModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-10" onClose={onReject}>
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]"
|
||||||
|
onClose={onReject}
|
||||||
|
>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0 translate-y-4"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100 translate-y-0"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-75"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<Transition.Child
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
as={Fragment}
|
||||||
<Transition.Child
|
enter="ease-out duration-300"
|
||||||
as={Fragment}
|
enterFrom="opacity-0 scale-95"
|
||||||
enter="ease-out duration-300"
|
enterTo="opacity-100 scale-100"
|
||||||
enterFrom="opacity-0 scale-95"
|
leave="ease-in duration-200"
|
||||||
enterTo="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leave="ease-in duration-200"
|
leaveTo="opacity-0 scale-95"
|
||||||
leaveFrom="opacity-100 scale-100"
|
>
|
||||||
leaveTo="opacity-0 scale-95"
|
<Dialog.Panel className="rounded relative mx-auto px-4 py-8 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onResolve({
|
||||||
|
variableName: newVariableName,
|
||||||
|
})
|
||||||
|
toast.success(`Added variable ${newVariableName}`)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
<CreateNewVariable
|
||||||
<Dialog.Title
|
setNewVariableName={setNewVariableName}
|
||||||
as="h3"
|
newVariableName={newVariableName}
|
||||||
className="text-lg font-medium leading-6 text-gray-900 capitalize"
|
isNewVariableNameUnique={isNewVariableNameUnique}
|
||||||
|
shouldCreateVariable={true}
|
||||||
|
showCheckbox={false}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
type="submit"
|
||||||
|
disabled={!isNewVariableNameUnique}
|
||||||
|
icon={{ icon: faPlus }}
|
||||||
>
|
>
|
||||||
Set {valueName}
|
Add variable
|
||||||
</Dialog.Title>
|
</ActionButton>
|
||||||
|
<ActionButton Element="button" onClick={() => onReject(false)}>
|
||||||
<CreateNewVariable
|
Cancel
|
||||||
setNewVariableName={setNewVariableName}
|
</ActionButton>
|
||||||
newVariableName={newVariableName}
|
</div>
|
||||||
isNewVariableNameUnique={isNewVariableNameUnique}
|
</form>
|
||||||
shouldCreateVariable={true}
|
</Dialog.Panel>
|
||||||
setShouldCreateVariable={() => {}}
|
</Transition.Child>
|
||||||
/>
|
|
||||||
<div className="mt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!isNewVariableNameUnique}
|
|
||||||
className={`inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
|
|
||||||
!isNewVariableNameUnique
|
|
||||||
? 'opacity-50 cursor-not-allowed'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
onResolve({
|
|
||||||
variableName: newVariableName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Add variable
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition>
|
</Transition>
|
||||||
)
|
)
|
||||||
|
267
src/components/TextEditor.tsx
Normal file
267
src/components/TextEditor.tsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import ReactCodeMirror, {
|
||||||
|
Extension,
|
||||||
|
ViewUpdate,
|
||||||
|
keymap,
|
||||||
|
} from '@uiw/react-codemirror'
|
||||||
|
import { FromServer, IntoServer } from 'editor/lsp/codec'
|
||||||
|
import Server from '../editor/lsp/server'
|
||||||
|
import Client from '../editor/lsp/client'
|
||||||
|
import { TEST } from 'env'
|
||||||
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||||
|
import { useConvertToVariable } from 'hooks/useToolbarGuards'
|
||||||
|
import { Themes } from 'lib/theme'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { linter, lintGutter } from '@codemirror/lint'
|
||||||
|
import { Selections, useStore } from 'useStore'
|
||||||
|
import { LanguageServerClient } from 'editor/lsp'
|
||||||
|
import kclLanguage from 'editor/lsp/language'
|
||||||
|
import { isTauri } from 'lib/isTauri'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { writeTextFile } from '@tauri-apps/api/fs'
|
||||||
|
import { PROJECT_ENTRYPOINT } from 'lib/tauriFS'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
addLineHighlight,
|
||||||
|
lineHighlightField,
|
||||||
|
} from 'editor/highlightextension'
|
||||||
|
import { isOverlap } from 'lib/utils'
|
||||||
|
import { kclErrToDiagnostic } from 'lang/errors'
|
||||||
|
import { CSSRuleObject } from 'tailwindcss/types/config'
|
||||||
|
|
||||||
|
export const editorShortcutMeta = {
|
||||||
|
formatCode: {
|
||||||
|
codeMirror: 'Alt-Shift-f',
|
||||||
|
display: 'Alt + Shift + F',
|
||||||
|
},
|
||||||
|
convertToVariable: {
|
||||||
|
codeMirror: 'Ctrl-Shift-c',
|
||||||
|
display: 'Ctrl + Shift + C',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextEditor = ({
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
theme: Themes.Light | Themes.Dark
|
||||||
|
}) => {
|
||||||
|
const pathParams = useParams()
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
defferedSetCode,
|
||||||
|
editorView,
|
||||||
|
engineCommandManager,
|
||||||
|
formatCode,
|
||||||
|
isLSPServerReady,
|
||||||
|
selectionRanges,
|
||||||
|
selectionRangeTypeMap,
|
||||||
|
setEditorView,
|
||||||
|
setIsLSPServerReady,
|
||||||
|
setSelectionRanges,
|
||||||
|
sourceRangeMap,
|
||||||
|
} = useStore((s) => ({
|
||||||
|
code: s.code,
|
||||||
|
defferedCode: s.defferedCode,
|
||||||
|
defferedSetCode: s.defferedSetCode,
|
||||||
|
editorView: s.editorView,
|
||||||
|
engineCommandManager: s.engineCommandManager,
|
||||||
|
formatCode: s.formatCode,
|
||||||
|
isLSPServerReady: s.isLSPServerReady,
|
||||||
|
selectionRanges: s.selectionRanges,
|
||||||
|
selectionRangeTypeMap: s.selectionRangeTypeMap,
|
||||||
|
setCode: s.setCode,
|
||||||
|
setEditorView: s.setEditorView,
|
||||||
|
setIsLSPServerReady: s.setIsLSPServerReady,
|
||||||
|
setSelectionRanges: s.setSelectionRanges,
|
||||||
|
sourceRangeMap: s.sourceRangeMap,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings: {
|
||||||
|
context: { textWrapping },
|
||||||
|
},
|
||||||
|
} = useGlobalStateContext()
|
||||||
|
const { setCommandBarOpen } = useCommandsContext()
|
||||||
|
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||||
|
useConvertToVariable()
|
||||||
|
|
||||||
|
// So this is a bit weird, we need to initialize the lsp server and client.
|
||||||
|
// But the server happens async so we break this into two parts.
|
||||||
|
// Below is the client and server promise.
|
||||||
|
const { lspClient } = useMemo(() => {
|
||||||
|
const intoServer: IntoServer = new IntoServer()
|
||||||
|
const fromServer: FromServer = FromServer.create()
|
||||||
|
const client = new Client(fromServer, intoServer)
|
||||||
|
if (!TEST) {
|
||||||
|
Server.initialize(intoServer, fromServer).then((lspServer) => {
|
||||||
|
lspServer.start()
|
||||||
|
setIsLSPServerReady(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lspClient = new LanguageServerClient({ client })
|
||||||
|
return { lspClient }
|
||||||
|
}, [setIsLSPServerReady])
|
||||||
|
|
||||||
|
// Here we initialize the plugin which will start the client.
|
||||||
|
// When we have multi-file support the name of the file will be a dep of
|
||||||
|
// this use memo, as well as the directory structure, which I think is
|
||||||
|
// a good setup becuase it will restart the client but not the server :)
|
||||||
|
// We do not want to restart the server, its just wasteful.
|
||||||
|
const kclLSP = useMemo(() => {
|
||||||
|
let plugin = null
|
||||||
|
if (isLSPServerReady && !TEST) {
|
||||||
|
// Set up the lsp plugin.
|
||||||
|
const lsp = kclLanguage({
|
||||||
|
// When we have more than one file, we'll need to change this.
|
||||||
|
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
|
||||||
|
workspaceFolders: null,
|
||||||
|
client: lspClient,
|
||||||
|
})
|
||||||
|
|
||||||
|
plugin = lsp
|
||||||
|
}
|
||||||
|
return plugin
|
||||||
|
}, [lspClient, isLSPServerReady])
|
||||||
|
|
||||||
|
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
||||||
|
const onChange = (value: string, viewUpdate: ViewUpdate) => {
|
||||||
|
defferedSetCode(value)
|
||||||
|
if (isTauri() && pathParams.id) {
|
||||||
|
// Save the file to disk
|
||||||
|
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
||||||
|
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
|
||||||
|
(err) => {
|
||||||
|
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
|
||||||
|
console.error('error saving file', err)
|
||||||
|
toast.error('Error saving file, please check file permissions')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (editorView) {
|
||||||
|
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
||||||
|
}
|
||||||
|
} //, []);
|
||||||
|
const onUpdate = (viewUpdate: ViewUpdate) => {
|
||||||
|
if (!editorView) {
|
||||||
|
setEditorView(viewUpdate.view)
|
||||||
|
}
|
||||||
|
const ranges = viewUpdate.state.selection.ranges
|
||||||
|
|
||||||
|
const isChange =
|
||||||
|
ranges.length !== selectionRanges.codeBasedSelections.length ||
|
||||||
|
ranges.some(({ from, to }, i) => {
|
||||||
|
return (
|
||||||
|
from !== selectionRanges.codeBasedSelections[i].range[0] ||
|
||||||
|
to !== selectionRanges.codeBasedSelections[i].range[1]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isChange) return
|
||||||
|
const codeBasedSelections: Selections['codeBasedSelections'] = ranges.map(
|
||||||
|
({ from, to }) => {
|
||||||
|
if (selectionRangeTypeMap[to]) {
|
||||||
|
return {
|
||||||
|
type: selectionRangeTypeMap[to],
|
||||||
|
range: [from, to],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'default',
|
||||||
|
range: [from, to],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const idBasedSelections = codeBasedSelections
|
||||||
|
.map(({ type, range }) => {
|
||||||
|
const hasOverlap = Object.entries(sourceRangeMap).filter(
|
||||||
|
([_, sourceRange]) => {
|
||||||
|
return isOverlap(sourceRange, range)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (hasOverlap.length) {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
id: hasOverlap[0][0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as any
|
||||||
|
|
||||||
|
engineCommandManager?.cusorsSelected({
|
||||||
|
otherSelections: [],
|
||||||
|
idBasedSelections,
|
||||||
|
})
|
||||||
|
|
||||||
|
setSelectionRanges({
|
||||||
|
otherSelections: [],
|
||||||
|
codeBasedSelections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorExtensions = useMemo(() => {
|
||||||
|
const extensions = [
|
||||||
|
lineHighlightField,
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: 'Meta-k',
|
||||||
|
run: () => {
|
||||||
|
setCommandBarOpen(true)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: editorShortcutMeta.formatCode.codeMirror,
|
||||||
|
run: () => {
|
||||||
|
formatCode()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: editorShortcutMeta.convertToVariable.codeMirror,
|
||||||
|
run: () => {
|
||||||
|
if (convertEnabled) {
|
||||||
|
convertCallback()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
] as Extension[]
|
||||||
|
|
||||||
|
if (kclLSP) extensions.push(kclLSP)
|
||||||
|
|
||||||
|
// These extensions have proven to mess with vitest
|
||||||
|
if (!TEST) {
|
||||||
|
extensions.push(
|
||||||
|
lintGutter(),
|
||||||
|
linter((_view) => {
|
||||||
|
return kclErrToDiagnostic(useStore.getState().kclErrors)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions
|
||||||
|
}, [kclLSP, textWrapping])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="code-mirror-override"
|
||||||
|
className="full-height-subtract"
|
||||||
|
style={{ '--height-subtract': '4.25rem' } as CSSRuleObject}
|
||||||
|
>
|
||||||
|
<ReactCodeMirror
|
||||||
|
className="h-full"
|
||||||
|
value={code}
|
||||||
|
extensions={editorExtensions}
|
||||||
|
onChange={onChange}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
theme={theme}
|
||||||
|
onCreateEditor={(_editorView) => setEditorView(_editorView)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,61 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { create } from 'react-modal-promise'
|
|
||||||
import { useStore } from '../../useStore'
|
|
||||||
import { isNodeSafeToReplace } from '../../lang/queryAst'
|
|
||||||
import { SetVarNameModal } from '../SetVarNameModal'
|
|
||||||
import { moveValueIntoNewVariable } from '../../lang/modifyAst'
|
|
||||||
|
|
||||||
const getModalInfo = create(SetVarNameModal as any)
|
|
||||||
|
|
||||||
export const ConvertToVariable = () => {
|
|
||||||
const { guiMode, selectionRanges, ast, programMemory, updateAst } = useStore(
|
|
||||||
(s) => ({
|
|
||||||
guiMode: s.guiMode,
|
|
||||||
ast: s.ast,
|
|
||||||
updateAst: s.updateAst,
|
|
||||||
selectionRanges: s.selectionRanges,
|
|
||||||
programMemory: s.programMemory,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const [enableAngLen, setEnableAngLen] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ast) return
|
|
||||||
|
|
||||||
const { isSafe, value } = isNodeSafeToReplace(
|
|
||||||
ast,
|
|
||||||
selectionRanges.codeBasedSelections?.[0]?.range || []
|
|
||||||
)
|
|
||||||
const canReplace = isSafe && value.type !== 'Identifier'
|
|
||||||
const isOnlyOneSelection = selectionRanges.codeBasedSelections.length === 1
|
|
||||||
|
|
||||||
const _enableHorz = canReplace && isOnlyOneSelection
|
|
||||||
setEnableAngLen(_enableHorz)
|
|
||||||
}, [guiMode, selectionRanges])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!ast) return
|
|
||||||
try {
|
|
||||||
const { variableName } = await getModalInfo({
|
|
||||||
valueName: 'var',
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
const { modifiedAst: _modifiedAst } = moveValueIntoNewVariable(
|
|
||||||
ast,
|
|
||||||
programMemory,
|
|
||||||
selectionRanges.codeBasedSelections[0].range,
|
|
||||||
variableName
|
|
||||||
)
|
|
||||||
|
|
||||||
updateAst(_modifiedAst)
|
|
||||||
} catch (e) {
|
|
||||||
console.log('e', e)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!enableAngLen}
|
|
||||||
>
|
|
||||||
ConvertToVariable
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
56
src/hooks/useToolbarGuards.ts
Normal file
56
src/hooks/useToolbarGuards.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { SetVarNameModal } from 'components/SetVarNameModal'
|
||||||
|
import { moveValueIntoNewVariable } from 'lang/modifyAst'
|
||||||
|
import { isNodeSafeToReplace } from 'lang/queryAst'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { create } from 'react-modal-promise'
|
||||||
|
import { useStore } from 'useStore'
|
||||||
|
|
||||||
|
const getModalInfo = create(SetVarNameModal as any)
|
||||||
|
|
||||||
|
export function useConvertToVariable() {
|
||||||
|
const { guiMode, selectionRanges, ast, programMemory, updateAst } = useStore(
|
||||||
|
(s) => ({
|
||||||
|
guiMode: s.guiMode,
|
||||||
|
ast: s.ast,
|
||||||
|
updateAst: s.updateAst,
|
||||||
|
selectionRanges: s.selectionRanges,
|
||||||
|
programMemory: s.programMemory,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const [enable, setEnabled] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ast) return
|
||||||
|
|
||||||
|
const { isSafe, value } = isNodeSafeToReplace(
|
||||||
|
ast,
|
||||||
|
selectionRanges.codeBasedSelections?.[0]?.range || []
|
||||||
|
)
|
||||||
|
const canReplace = isSafe && value.type !== 'Identifier'
|
||||||
|
const isOnlyOneSelection = selectionRanges.codeBasedSelections.length === 1
|
||||||
|
|
||||||
|
const _enableHorz = canReplace && isOnlyOneSelection
|
||||||
|
setEnabled(_enableHorz)
|
||||||
|
}, [guiMode, selectionRanges])
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!ast) return
|
||||||
|
try {
|
||||||
|
const { variableName } = await getModalInfo({
|
||||||
|
valueName: 'var',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const { modifiedAst: _modifiedAst } = moveValueIntoNewVariable(
|
||||||
|
ast,
|
||||||
|
programMemory,
|
||||||
|
selectionRanges.codeBasedSelections[0].range,
|
||||||
|
variableName
|
||||||
|
)
|
||||||
|
|
||||||
|
updateAst(_modifiedAst)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('e', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enable, handleClick }
|
||||||
|
}
|
Reference in New Issue
Block a user