Wrapper for keybindings (codemirror and app) (#2421)
* start Signed-off-by: Jess Frazelle <github@jessfraz.com> * add hotkey wrapper Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> --------- Signed-off-by: Jess Frazelle <github@jessfraz.com>
This commit is contained in:
@ -775,6 +775,57 @@ test('Project settings can be set and override user settings', async ({
|
|||||||
await expect(page.locator('select[name="app-theme"]')).toHaveValue('light')
|
await expect(page.locator('select[name="app-theme"]')).toHaveValue('light')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Project settings can be opened with keybinding from the editor', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Start Sketch' })
|
||||||
|
.waitFor({ state: 'visible' })
|
||||||
|
|
||||||
|
// Put the cursor in the editor
|
||||||
|
await page.click('.cm-content')
|
||||||
|
|
||||||
|
// Open the settings modal with the browser keyboard shortcut
|
||||||
|
await page.keyboard.press('Meta+Shift+,')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Settings', exact: true })
|
||||||
|
).toBeVisible()
|
||||||
|
await page
|
||||||
|
.locator('select[name="app-theme"]')
|
||||||
|
.selectOption({ value: 'light' })
|
||||||
|
|
||||||
|
// Verify the toast appeared
|
||||||
|
await expect(
|
||||||
|
page.getByText(`Set theme to "light" for this project`)
|
||||||
|
).toBeVisible()
|
||||||
|
// Check that the theme changed
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
|
||||||
|
|
||||||
|
// Check that the user setting was not changed
|
||||||
|
await page.getByRole('radio', { name: 'User' }).click()
|
||||||
|
await expect(page.locator('select[name="app-theme"]')).toHaveValue('dark')
|
||||||
|
|
||||||
|
// Roll back to default "system" theme
|
||||||
|
await page
|
||||||
|
.getByText(
|
||||||
|
'themeRoll back themeRoll back to match defaultThe overall appearance of the appl'
|
||||||
|
)
|
||||||
|
.hover()
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Roll back theme ; Has tooltip: Roll back to match default',
|
||||||
|
})
|
||||||
|
.click()
|
||||||
|
await expect(page.locator('select[name="app-theme"]')).toHaveValue('system')
|
||||||
|
|
||||||
|
// Check that the project setting did not change
|
||||||
|
await page.getByRole('radio', { name: 'Project' }).click()
|
||||||
|
await expect(page.locator('select[name="app-theme"]')).toHaveValue('light')
|
||||||
|
})
|
||||||
|
|
||||||
test('Click through each onboarding step', async ({ page }) => {
|
test('Click through each onboarding step', async ({ page }) => {
|
||||||
const u = getUtils(page)
|
const u = getUtils(page)
|
||||||
|
|
||||||
@ -1093,6 +1144,51 @@ test.describe('Command bar tests', () => {
|
|||||||
await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
|
await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Command bar keybinding works from code editor and can change a setting', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Brief boilerplate
|
||||||
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||||
|
|
||||||
|
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||||
|
|
||||||
|
// Put the cursor in the code editor
|
||||||
|
await page.click('.cm-content')
|
||||||
|
|
||||||
|
// Now try the same, but with the keyboard shortcut, check focus
|
||||||
|
await page.keyboard.press('Meta+K')
|
||||||
|
await expect(cmdSearchBar).toBeVisible()
|
||||||
|
await expect(cmdSearchBar).toBeFocused()
|
||||||
|
|
||||||
|
// Try typing in the command bar
|
||||||
|
await page.keyboard.type('theme')
|
||||||
|
const themeOption = page.getByRole('option', {
|
||||||
|
name: 'Settings · app · theme',
|
||||||
|
})
|
||||||
|
await expect(themeOption).toBeVisible()
|
||||||
|
await themeOption.click()
|
||||||
|
const themeInput = page.getByPlaceholder('Select an option')
|
||||||
|
await expect(themeInput).toBeVisible()
|
||||||
|
await expect(themeInput).toBeFocused()
|
||||||
|
// Select dark theme
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
await expect(page.getByRole('option', { name: 'system' })).toHaveAttribute(
|
||||||
|
'data-headlessui-state',
|
||||||
|
'active'
|
||||||
|
)
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
|
||||||
|
// Check the toast appeared
|
||||||
|
await expect(
|
||||||
|
page.getByText(`Set theme to "system" for this project`)
|
||||||
|
).toBeVisible()
|
||||||
|
// Check that the theme changed
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
|
||||||
|
})
|
||||||
|
|
||||||
test('Can extrude from the command bar', async ({ page }) => {
|
test('Can extrude from the command bar', async ({ page }) => {
|
||||||
await page.addInitScript(async () => {
|
await page.addInitScript(async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
@ -23,6 +23,7 @@ import { useRefreshSettings } from 'hooks/useRefreshSettings'
|
|||||||
import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar'
|
import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar'
|
||||||
import { LowerRightControls } from 'components/LowerRightControls'
|
import { LowerRightControls } from 'components/LowerRightControls'
|
||||||
import ModalContainer from 'react-modal-promise'
|
import ModalContainer from 'react-modal-promise'
|
||||||
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useRefreshSettings(paths.FILE + 'SETTINGS')
|
useRefreshSettings(paths.FILE + 'SETTINGS')
|
||||||
@ -63,8 +64,8 @@ export function App() {
|
|||||||
useHotkeys('backspace', (e) => {
|
useHotkeys('backspace', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
})
|
})
|
||||||
useHotkeys(
|
useHotkeyWrapper(
|
||||||
isTauri() ? 'mod + ,' : 'shift + mod + ,',
|
[isTauri() ? 'mod + ,' : 'shift + mod + ,'],
|
||||||
() => navigate(filePath + paths.SETTINGS),
|
() => navigate(filePath + paths.SETTINGS),
|
||||||
{
|
{
|
||||||
splitKey: '|',
|
splitKey: '|',
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { Dialog, Popover, Transition } from '@headlessui/react'
|
import { Dialog, Popover, Transition } from '@headlessui/react'
|
||||||
import { Fragment, useEffect } from 'react'
|
import { Fragment, useEffect } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
|
||||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
import CommandBarArgument from './CommandBarArgument'
|
import CommandBarArgument from './CommandBarArgument'
|
||||||
import CommandComboBox from '../CommandComboBox'
|
import CommandComboBox from '../CommandComboBox'
|
||||||
import CommandBarReview from './CommandBarReview'
|
import CommandBarReview from './CommandBarReview'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
|
|
||||||
export const CommandBar = () => {
|
export const CommandBar = () => {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
@ -22,7 +22,7 @@ export const CommandBar = () => {
|
|||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
// Hook up keyboard shortcuts
|
// Hook up keyboard shortcuts
|
||||||
useHotkeys(['mod+k', 'mod+/'], () => {
|
useHotkeyWrapper(['mod+k', 'mod+/'], () => {
|
||||||
if (commandBarState.context.commands.length === 0) return
|
if (commandBarState.context.commands.length === 0) return
|
||||||
if (commandBarState.matches('Closed')) {
|
if (commandBarState.matches('Closed')) {
|
||||||
commandBarSend({ type: 'Open' })
|
commandBarSend({ type: 'Open' })
|
||||||
|
@ -8,7 +8,6 @@ import { Dialog, Disclosure } from '@headlessui/react'
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { useFileContext } from 'hooks/useFileContext'
|
import { useFileContext } from 'hooks/useFileContext'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
|
||||||
import styles from './FileTree.module.css'
|
import styles from './FileTree.module.css'
|
||||||
import { sortProject } from 'lib/tauriFS'
|
import { sortProject } from 'lib/tauriFS'
|
||||||
import { FILE_EXT } from 'lib/constants'
|
import { FILE_EXT } from 'lib/constants'
|
||||||
@ -16,6 +15,7 @@ import { CustomIcon } from './CustomIcon'
|
|||||||
import { codeManager, kclManager } from 'lib/singletons'
|
import { codeManager, kclManager } from 'lib/singletons'
|
||||||
import { useDocumentHasFocus } from 'hooks/useDocumentHasFocus'
|
import { useDocumentHasFocus } from 'hooks/useDocumentHasFocus'
|
||||||
import { useLspContext } from './LspProvider'
|
import { useLspContext } from './LspProvider'
|
||||||
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
|
|
||||||
function getIndentationCSS(level: number) {
|
function getIndentationCSS(level: number) {
|
||||||
return `calc(1rem * ${level + 1})`
|
return `calc(1rem * ${level + 1})`
|
||||||
@ -333,8 +333,8 @@ export const FileTreeMenu = () => {
|
|||||||
send({ type: 'Create file', data: { name: '', makeDir: true } })
|
send({ type: 'Create file', data: { name: '', makeDir: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
useHotkeys('meta + n', createFile)
|
useHotkeyWrapper(['meta + n'], createFile)
|
||||||
useHotkeys('meta + shift + n', createFolder)
|
useHotkeyWrapper(['meta + shift + n'], createFolder)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -55,9 +55,9 @@ import { Models } from '@kittycad/lib/dist/types/src'
|
|||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { EditorSelection } from '@uiw/react-codemirror'
|
import { EditorSelection } from '@uiw/react-codemirror'
|
||||||
import { CoreDumpManager } from 'lib/coredump'
|
import { CoreDumpManager } from 'lib/coredump'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
|
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
|
||||||
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
|
|
||||||
type MachineContext<T extends AnyStateMachine> = {
|
type MachineContext<T extends AnyStateMachine> = {
|
||||||
state: StateFrom<T>
|
state: StateFrom<T>
|
||||||
@ -103,7 +103,7 @@ export const ModelingMachineProvider = ({
|
|||||||
htmlRef,
|
htmlRef,
|
||||||
token
|
token
|
||||||
)
|
)
|
||||||
useHotkeys('meta + shift + .', () => coreDump(coreDumpManager, true))
|
useHotkeyWrapper(['meta + shift + .'], () => coreDump(coreDumpManager, true))
|
||||||
|
|
||||||
// Settings machine setup
|
// Settings machine setup
|
||||||
// const retrievedSettings = useRef(
|
// const retrievedSettings = useRef(
|
||||||
|
@ -36,10 +36,6 @@ import {
|
|||||||
import interact from '@replit/codemirror-interact'
|
import interact from '@replit/codemirror-interact'
|
||||||
import { kclManager, editorManager, codeManager } from 'lib/singletons'
|
import { kclManager, editorManager, codeManager } from 'lib/singletons'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
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 { useLspContext } from 'components/LspProvider'
|
||||||
import { Prec, EditorState, Extension } from '@codemirror/state'
|
import { Prec, EditorState, Extension } from '@codemirror/state'
|
||||||
import {
|
import {
|
||||||
@ -67,7 +63,6 @@ export const KclEditorPane = () => {
|
|||||||
? getSystemTheme()
|
? getSystemTheme()
|
||||||
: context.app.theme.current
|
: context.app.theme.current
|
||||||
const { copilotLSP, kclLSP } = useLspContext()
|
const { copilotLSP, kclLSP } = useLspContext()
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@ -76,6 +71,8 @@ export const KclEditorPane = () => {
|
|||||||
return () => window.removeEventListener('online', onlineCallback)
|
return () => window.removeEventListener('online', onlineCallback)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Since these already exist in the editor, we don't need to define them
|
||||||
|
// with the wrapper.
|
||||||
useHotkeys('mod+z', (e) => {
|
useHotkeys('mod+z', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
editorManager.undo()
|
editorManager.undo()
|
||||||
@ -87,6 +84,7 @@ export const KclEditorPane = () => {
|
|||||||
|
|
||||||
const textWrapping = context.textEditor.textWrapping
|
const textWrapping = context.textEditor.textWrapping
|
||||||
const cursorBlinking = context.textEditor.blinkingCursor
|
const cursorBlinking = context.textEditor.blinkingCursor
|
||||||
|
const codeMirrorHotkeys = codeManager.getCodemirrorHotkeys()
|
||||||
|
|
||||||
const editorExtensions = useMemo(() => {
|
const editorExtensions = useMemo(() => {
|
||||||
const extensions = [
|
const extensions = [
|
||||||
@ -106,20 +104,7 @@ export const KclEditorPane = () => {
|
|||||||
...completionKeymap,
|
...completionKeymap,
|
||||||
...lintKeymap,
|
...lintKeymap,
|
||||||
indentWithTab,
|
indentWithTab,
|
||||||
{
|
...codeMirrorHotkeys,
|
||||||
key: 'Meta-k',
|
|
||||||
run: () => {
|
|
||||||
editorManager.commandBarSend({ type: 'Open' })
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: isTauri() ? 'Meta-,' : 'Meta-Shift-,',
|
|
||||||
run: () => {
|
|
||||||
navigate(makeUrlPathRelative(paths.SETTINGS))
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: editorShortcutMeta.convertToVariable.codeMirror,
|
key: editorShortcutMeta.convertToVariable.codeMirror,
|
||||||
run: () => {
|
run: () => {
|
||||||
@ -188,7 +173,13 @@ export const KclEditorPane = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return extensions
|
return extensions
|
||||||
}, [kclLSP, copilotLSP, textWrapping.current, cursorBlinking.current])
|
}, [
|
||||||
|
kclLSP,
|
||||||
|
copilotLSP,
|
||||||
|
textWrapping.current,
|
||||||
|
cursorBlinking.current,
|
||||||
|
codeMirrorHotkeys,
|
||||||
|
])
|
||||||
|
|
||||||
const initialCode = useRef(codeManager.code)
|
const initialCode = useRef(codeManager.code)
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import { isTauri } from 'lib/isTauri'
|
|||||||
import { writeTextFile } from '@tauri-apps/plugin-fs'
|
import { writeTextFile } from '@tauri-apps/plugin-fs'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { editorManager } from 'lib/singletons'
|
import { editorManager } from 'lib/singletons'
|
||||||
|
import { KeyBinding } from '@uiw/react-codemirror'
|
||||||
|
|
||||||
const PERSIST_CODE_TOKEN = 'persistCode'
|
const PERSIST_CODE_TOKEN = 'persistCode'
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ export default class CodeManager {
|
|||||||
private _code: string = bracket
|
private _code: string = bracket
|
||||||
#updateState: (arg: string) => void = () => {}
|
#updateState: (arg: string) => void = () => {}
|
||||||
private _currentFilePath: string | null = null
|
private _currentFilePath: string | null = null
|
||||||
|
private _hotkeys: { [key: string]: () => void } = {}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
@ -48,6 +50,20 @@ export default class CodeManager {
|
|||||||
this.#updateState = setCode
|
this.#updateState = setCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerHotkey(hotkey: string, callback: () => void) {
|
||||||
|
this._hotkeys[hotkey] = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
getCodemirrorHotkeys(): KeyBinding[] {
|
||||||
|
return Object.keys(this._hotkeys).map((key) => ({
|
||||||
|
key,
|
||||||
|
run: () => {
|
||||||
|
this._hotkeys[key]()
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
updateCurrentFilePath(path: string) {
|
updateCurrentFilePath(path: string) {
|
||||||
this._currentFilePath = path
|
this._currentFilePath = path
|
||||||
}
|
}
|
||||||
|
37
src/lib/hotkeyWrapper.ts
Normal file
37
src/lib/hotkeyWrapper.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Options, useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { codeManager } from './singletons'
|
||||||
|
|
||||||
|
// Hotkey wrapper wraps hotkeys for the app (outside of the editor)
|
||||||
|
// With hotkeys inside the editor.
|
||||||
|
// This way we can have hotkeys defined in one place and not have to worry about
|
||||||
|
// conflicting hotkeys, or them only being implemented for the app but not
|
||||||
|
// inside the editor.
|
||||||
|
// TODO: would be nice if this didn't have to be a react hook. It's not needed
|
||||||
|
// for the code mirror stuff but but it is needed for the useHotkeys hook.
|
||||||
|
export default function useHotkeyWrapper(
|
||||||
|
hotkey: string[],
|
||||||
|
callback: () => void,
|
||||||
|
additionalOptions?: Options
|
||||||
|
) {
|
||||||
|
useHotkeys(hotkey, callback, additionalOptions)
|
||||||
|
useEffect(() => {
|
||||||
|
for (const key of hotkey) {
|
||||||
|
const keybinding = mapHotkeyToCodeMirrorHotkey(key)
|
||||||
|
codeManager.registerHotkey(keybinding, callback)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert hotkey to code mirror hotkey
|
||||||
|
// See: https://codemirror.net/docs/ref/#view.KeyBinding
|
||||||
|
function mapHotkeyToCodeMirrorHotkey(hotkey: string): string {
|
||||||
|
return hotkey
|
||||||
|
.replaceAll('+', '-')
|
||||||
|
.replaceAll(' ', '')
|
||||||
|
.replaceAll('mod', 'Meta')
|
||||||
|
.replaceAll('meta', 'Meta')
|
||||||
|
.replaceAll('ctrl', 'Ctrl')
|
||||||
|
.replaceAll('shift', 'Shift')
|
||||||
|
.replaceAll('alt', 'Alt')
|
||||||
|
}
|
Reference in New Issue
Block a user