diff --git a/e2e/playwright/flow-tests.spec.ts b/e2e/playwright/flow-tests.spec.ts index d1fea1c8a..2021a969a 100644 --- a/e2e/playwright/flow-tests.spec.ts +++ b/e2e/playwright/flow-tests.spec.ts @@ -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') }) +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 }) => { const u = getUtils(page) @@ -1093,6 +1144,51 @@ test.describe('Command bar tests', () => { 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 }) => { await page.addInitScript(async () => { localStorage.setItem( diff --git a/src/App.tsx b/src/App.tsx index 8ffaa4bd9..641eaf150 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { useRefreshSettings } from 'hooks/useRefreshSettings' import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar' import { LowerRightControls } from 'components/LowerRightControls' import ModalContainer from 'react-modal-promise' +import useHotkeyWrapper from 'lib/hotkeyWrapper' export function App() { useRefreshSettings(paths.FILE + 'SETTINGS') @@ -63,8 +64,8 @@ export function App() { useHotkeys('backspace', (e) => { e.preventDefault() }) - useHotkeys( - isTauri() ? 'mod + ,' : 'shift + mod + ,', + useHotkeyWrapper( + [isTauri() ? 'mod + ,' : 'shift + mod + ,'], () => navigate(filePath + paths.SETTINGS), { splitKey: '|', diff --git a/src/components/CommandBar/CommandBar.tsx b/src/components/CommandBar/CommandBar.tsx index bec598108..c1a343b9e 100644 --- a/src/components/CommandBar/CommandBar.tsx +++ b/src/components/CommandBar/CommandBar.tsx @@ -1,11 +1,11 @@ import { Dialog, Popover, Transition } from '@headlessui/react' import { Fragment, useEffect } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' import { useCommandsContext } from 'hooks/useCommandsContext' import CommandBarArgument from './CommandBarArgument' import CommandComboBox from '../CommandComboBox' import CommandBarReview from './CommandBarReview' import { useLocation } from 'react-router-dom' +import useHotkeyWrapper from 'lib/hotkeyWrapper' export const CommandBar = () => { const { pathname } = useLocation() @@ -22,7 +22,7 @@ export const CommandBar = () => { }, [pathname]) // Hook up keyboard shortcuts - useHotkeys(['mod+k', 'mod+/'], () => { + useHotkeyWrapper(['mod+k', 'mod+/'], () => { if (commandBarState.context.commands.length === 0) return if (commandBarState.matches('Closed')) { commandBarSend({ type: 'Open' }) diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index b8c5a7593..6b2897233 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -8,7 +8,6 @@ import { Dialog, Disclosure } from '@headlessui/react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons' import { useFileContext } from 'hooks/useFileContext' -import { useHotkeys } from 'react-hotkeys-hook' import styles from './FileTree.module.css' import { sortProject } from 'lib/tauriFS' import { FILE_EXT } from 'lib/constants' @@ -16,6 +15,7 @@ import { CustomIcon } from './CustomIcon' import { codeManager, kclManager } from 'lib/singletons' import { useDocumentHasFocus } from 'hooks/useDocumentHasFocus' import { useLspContext } from './LspProvider' +import useHotkeyWrapper from 'lib/hotkeyWrapper' function getIndentationCSS(level: number) { return `calc(1rem * ${level + 1})` @@ -333,8 +333,8 @@ export const FileTreeMenu = () => { send({ type: 'Create file', data: { name: '', makeDir: true } }) } - useHotkeys('meta + n', createFile) - useHotkeys('meta + shift + n', createFolder) + useHotkeyWrapper(['meta + n'], createFile) + useHotkeyWrapper(['meta + shift + n'], createFolder) return ( <> diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 885bd548d..99a7dd62d 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -55,9 +55,9 @@ import { Models } from '@kittycad/lib/dist/types/src' import toast from 'react-hot-toast' import { EditorSelection } from '@uiw/react-codemirror' import { CoreDumpManager } from 'lib/coredump' -import { useHotkeys } from 'react-hotkeys-hook' import { useSearchParams } from 'react-router-dom' import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls' +import useHotkeyWrapper from 'lib/hotkeyWrapper' type MachineContext = { state: StateFrom @@ -103,7 +103,7 @@ export const ModelingMachineProvider = ({ htmlRef, token ) - useHotkeys('meta + shift + .', () => coreDump(coreDumpManager, true)) + useHotkeyWrapper(['meta + shift + .'], () => coreDump(coreDumpManager, true)) // Settings machine setup // const retrievedSettings = useRef( diff --git a/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx b/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx index 4745271ba..f015257de 100644 --- a/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx +++ b/src/components/ModelingSidebar/ModelingPanes/KclEditorPane.tsx @@ -36,10 +36,6 @@ import { import interact from '@replit/codemirror-interact' import { kclManager, editorManager, codeManager } from 'lib/singletons' 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, Extension } from '@codemirror/state' import { @@ -67,7 +63,6 @@ export const KclEditorPane = () => { ? getSystemTheme() : context.app.theme.current const { copilotLSP, kclLSP } = useLspContext() - const navigate = useNavigate() useEffect(() => { if (typeof window === 'undefined') return @@ -76,6 +71,8 @@ export const KclEditorPane = () => { 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) => { e.preventDefault() editorManager.undo() @@ -87,6 +84,7 @@ export const KclEditorPane = () => { const textWrapping = context.textEditor.textWrapping const cursorBlinking = context.textEditor.blinkingCursor + const codeMirrorHotkeys = codeManager.getCodemirrorHotkeys() const editorExtensions = useMemo(() => { const extensions = [ @@ -106,20 +104,7 @@ export const KclEditorPane = () => { ...completionKeymap, ...lintKeymap, indentWithTab, - { - key: 'Meta-k', - run: () => { - editorManager.commandBarSend({ type: 'Open' }) - return false - }, - }, - { - key: isTauri() ? 'Meta-,' : 'Meta-Shift-,', - run: () => { - navigate(makeUrlPathRelative(paths.SETTINGS)) - return false - }, - }, + ...codeMirrorHotkeys, { key: editorShortcutMeta.convertToVariable.codeMirror, run: () => { @@ -188,7 +173,13 @@ export const KclEditorPane = () => { } return extensions - }, [kclLSP, copilotLSP, textWrapping.current, cursorBlinking.current]) + }, [ + kclLSP, + copilotLSP, + textWrapping.current, + cursorBlinking.current, + codeMirrorHotkeys, + ]) const initialCode = useRef(codeManager.code) diff --git a/src/lang/codeManager.ts b/src/lang/codeManager.ts index 534896d09..da629e7e5 100644 --- a/src/lang/codeManager.ts +++ b/src/lang/codeManager.ts @@ -6,6 +6,7 @@ import { isTauri } from 'lib/isTauri' import { writeTextFile } from '@tauri-apps/plugin-fs' import toast from 'react-hot-toast' import { editorManager } from 'lib/singletons' +import { KeyBinding } from '@uiw/react-codemirror' const PERSIST_CODE_TOKEN = 'persistCode' @@ -13,6 +14,7 @@ export default class CodeManager { private _code: string = bracket #updateState: (arg: string) => void = () => {} private _currentFilePath: string | null = null + private _hotkeys: { [key: string]: () => void } = {} constructor() { if (isTauri()) { @@ -48,6 +50,20 @@ export default class CodeManager { 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) { this._currentFilePath = path } diff --git a/src/lib/hotkeyWrapper.ts b/src/lib/hotkeyWrapper.ts new file mode 100644 index 000000000..373f8f162 --- /dev/null +++ b/src/lib/hotkeyWrapper.ts @@ -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') +}