Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"Keyboard shortcuts" settings page #9071

Merged
merged 25 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4c9183f
WIP: Add "Keyboard shortcuts" settings page
somebody1234 Feb 13, 2024
6cc67f2
Copy shortcut handler implementation from GUI2
somebody1234 Feb 13, 2024
91772ec
Fix type errors; finish refactoring `inputBindings.ts`
somebody1234 Feb 14, 2024
d6b8207
Add icons and colors (back) to `inputBindings.ts`
somebody1234 Feb 14, 2024
ddc3602
Reach parity on refactored `inputBindings.ts` (fix mouse event handling)
somebody1234 Feb 14, 2024
0bf12df
Fix `AssetSearchBar` stealing keyboard events
somebody1234 Feb 15, 2024
e2f3885
Fix display for settings page
somebody1234 Feb 15, 2024
14e0895
Load saved keyboard shortcuts from `LocalStorage`
somebody1234 Feb 15, 2024
0285d97
Make keyboard shortcuts page scrollable; hide certain shortcuts
somebody1234 Feb 15, 2024
e0267c9
Fix issues with resetting keybinds; add "Reset All Keybinds" button
somebody1234 Feb 15, 2024
3245a73
Unnest folder structure
somebody1234 Feb 15, 2024
6034a4b
Initial implementation of keyboard shortcut capture
somebody1234 Feb 15, 2024
9a0c953
Move stray modals
somebody1234 Feb 15, 2024
98317d3
Merge branch 'develop' into wip/sb/keyboard-shortcuts-settings
somebody1234 Feb 15, 2024
7e4bc0c
Fix test errors
somebody1234 Feb 15, 2024
2421919
Fix bug causing shortcuts modal to not appear
somebody1234 Feb 15, 2024
ba4f16a
Fix bug in `EditableSpan`
somebody1234 Feb 15, 2024
fc13d2d
Fix capturing and displaying Ctrl+Space
somebody1234 Feb 20, 2024
78a56cd
Merge branch 'develop' into wip/sb/keyboard-shortcuts-settings
somebody1234 Feb 21, 2024
06a1cd4
Merge branch 'develop' into wip/sb/keyboard-shortcuts-settings
somebody1234 Feb 26, 2024
f9d390d
Remove duplicate input bindings
somebody1234 Feb 26, 2024
b88ff74
Merge branch 'develop' into wip/sb/keyboard-shortcuts-settings
somebody1234 Feb 26, 2024
e9ff773
Fix list of keys on macOS
somebody1234 Feb 26, 2024
ff06dfe
Prevent `Delete` from focusing search bar on macOS
somebody1234 Feb 26, 2024
e09330d
Error on duplicate shortcut
somebody1234 Feb 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/ide-desktop/lib/assets/cross.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions app/ide-desktop/lib/assets/keyboard_shortcuts.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions app/ide-desktop/lib/assets/reload_in_circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion app/ide-desktop/lib/assets/tick.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions app/ide-desktop/lib/dashboard/.prettierrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ module.exports = {
'^#[/]App',
'^#[/]appUtils',
'',
'^#[/]configurations[/]',
'',
'^#[/]data[/]',
'',
'^#[/]hooks[/]',
Expand All @@ -39,6 +41,8 @@ module.exports = {
'',
'^#[/]components[/]',
'',
'^#[/]modals[/]',
'',
'^#[/]services[/]',
'',
'^#[/]utilities[/]',
Expand Down
8 changes: 1 addition & 7 deletions app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,7 @@ test.test('upload file', async ({ page }) => {
const fileChooser = await fileChooserPromise
const name = 'foo.txt'
const content = 'hello world'
await fileChooser.setFiles([
{
name,
buffer: Buffer.from(content),
mimeType: 'text/plain',
},
])
await fileChooser.setFiles([{ name, buffer: Buffer.from(content), mimeType: 'text/plain' }])

await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows.nth(0)).toBeVisible()
Expand Down
121 changes: 93 additions & 28 deletions app/ide-desktop/lib/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,18 @@ import * as detect from 'enso-common/src/detect'

import * as appUtils from '#/appUtils'

import * as inputBindingsModule from '#/configurations/inputBindings'

import * as navigateHooks from '#/hooks/navigateHooks'

import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider from '#/providers/BackendProvider'
import LocalStorageProvider from '#/providers/LocalStorageProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider'
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
import LoggerProvider from '#/providers/LoggerProvider'
import type * as loggerProvider from '#/providers/LoggerProvider'
import ModalProvider from '#/providers/ModalProvider'
import SessionProvider from '#/providers/SessionProvider'
import ShortcutManagerProvider from '#/providers/ShortcutManagerProvider'

import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import EnterOfflineMode from '#/pages/authentication/EnterOfflineMode'
Expand All @@ -66,10 +68,39 @@ import Subscribe from '#/pages/subscribe/Subscribe'
import type Backend from '#/services/Backend'
import LocalBackend from '#/services/LocalBackend'

import ShortcutManager, * as shortcutManagerModule from '#/utilities/ShortcutManager'
import LocalStorage from '#/utilities/LocalStorage'

import * as authServiceModule from '#/authentication/service'

// ============================
// === Global configuration ===
// ============================

declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly inputBindings: Partial<
Readonly<Record<inputBindingsModule.DashboardBindingKey, string[]>>
>
}
}

LocalStorage.registerKey('inputBindings', {
tryParse: value =>
typeof value !== 'object' || value == null
? null
: Object.fromEntries(
// This is SAFE, as it is a readonly upcast.
// eslint-disable-next-line no-restricted-syntax
Object.entries(value as Readonly<Record<string, unknown>>).flatMap(kv => {
const [k, v] = kv
return Array.isArray(v) && v.every((item): item is string => typeof item === 'string')
? [[k, v]]
: []
})
),
})

// ======================
// === getMainPageUrl ===
// ======================
Expand Down Expand Up @@ -113,8 +144,9 @@ export default function App(props: AppProps) {
// This is a React component even though it does not contain JSX.
// eslint-disable-next-line no-restricted-syntax
const Router = detect.isOnElectron() ? router.MemoryRouter : router.BrowserRouter
/** Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
* will redirect the user between the login/register pages and the dashboard. */
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
// will redirect the user between the login/register pages and the dashboard.
return (
<>
<toastify.ToastContainer
Expand All @@ -127,7 +159,9 @@ export default function App(props: AppProps) {
limit={3}
/>
<Router basename={getMainPageUrl().pathname}>
<AppRouter {...props} />
<LocalStorageProvider>
<AppRouter {...props} />
</LocalStorageProvider>
</Router>
</>
)
Expand All @@ -145,32 +179,67 @@ export default function App(props: AppProps) {
function AppRouter(props: AppProps) {
const { logger, supportsLocalBackend, isAuthenticationDisabled, shouldShowDashboard } = props
const { onAuthenticated, projectManagerUrl } = props
const { localStorage } = localStorageProvider.useLocalStorage()
const navigate = navigateHooks.useNavigate()
if (detect.IS_DEV_MODE) {
// @ts-expect-error This is used exclusively for debugging.
window.navigate = navigate
}
const [shortcutManager] = React.useState(() => ShortcutManager.createWithDefaults())
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const isTargetEditable =
event.target instanceof HTMLInputElement ||
(event.target instanceof HTMLElement && event.target.isContentEditable)
const shouldHandleEvent = isTargetEditable
? !shortcutManagerModule.isTextInputEvent(event)
: true
if (shouldHandleEvent && shortcutManager.handleKeyboardEvent(event)) {
event.preventDefault()
// This is required to prevent the event from propagating to the event handler
// that focuses the search input.
event.stopImmediatePropagation()
const savedInputBindings = localStorage.get('inputBindings')
for (const k in savedInputBindings) {
// This is UNSAFE, hence the `?? []` below.
// eslint-disable-next-line no-restricted-syntax
const bindingKey = k as inputBindingsModule.DashboardBindingKey
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
for (const oldBinding of inputBindingsRaw.metadata[bindingKey].bindings ?? []) {
inputBindingsRaw.delete(bindingKey, oldBinding)
}
for (const newBinding of savedInputBindings[bindingKey] ?? []) {
inputBindingsRaw.add(bindingKey, newBinding)
}
}
document.body.addEventListener('keydown', onKeyDown)
return () => {
document.body.removeEventListener('keydown', onKeyDown)
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
const inputBindings = React.useMemo(() => {
const updateLocalStorage = () => {
localStorage.set(
'inputBindings',
Object.fromEntries(
Object.entries(inputBindingsRaw.metadata).map(kv => {
const [k, v] = kv
return [k, v.bindings]
})
)
)
}
}, [shortcutManager])
return {
/** Transparently pass through `handler()`. */
get handler() {
return inputBindingsRaw.handler
},
/** Transparently pass through `attach()`. */
get attach() {
return inputBindingsRaw.attach
},
reset: (bindingKey: inputBindingsModule.DashboardBindingKey) => {
inputBindingsRaw.reset(bindingKey)
updateLocalStorage()
},
add: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => {
inputBindingsRaw.add(bindingKey, binding)
updateLocalStorage()
},
delete: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => {
inputBindingsRaw.delete(bindingKey, binding)
updateLocalStorage()
},
/** Transparently pass through `metadata`. */
get metadata() {
return inputBindingsRaw.metadata
},
}
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
const mainPageUrl = getMainPageUrl()
const authService = React.useMemo(() => {
const authConfig = { navigate, ...props }
Expand Down Expand Up @@ -242,9 +311,7 @@ function AppRouter(props: AppProps) {
</router.Routes>
)
let result = routes
result = (
<ShortcutManagerProvider shortcutManager={shortcutManager}>{result}</ShortcutManagerProvider>
)
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
result = <ModalProvider>{result}</ModalProvider>
result = (
<AuthProvider
Expand All @@ -258,8 +325,6 @@ function AppRouter(props: AppProps) {
</AuthProvider>
)
result = <BackendProvider initialBackend={initialBackend}>{result}</BackendProvider>
/** {@link BackendProvider} depends on {@link LocalStorageProvider}. */
result = <LocalStorageProvider>{result}</LocalStorageProvider>
result = (
<SessionProvider
mainPageUrl={mainPageUrl}
Expand Down
31 changes: 11 additions & 20 deletions app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import * as React from 'react'
import CrossIcon from 'enso-assets/cross.svg'
import TickIcon from 'enso-assets/tick.svg'

import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'

import * as shortcutManagerModule from '#/utilities/ShortcutManager'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'

// ====================
// === EditableSpan ===
Expand All @@ -31,7 +31,7 @@ export interface EditableSpanProps {
export default function EditableSpan(props: EditableSpanProps) {
const { 'data-testid': dataTestId, className, editable = false, children } = props
const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
const inputBindings = inputBindingsProvider.useInputBindings()
const [isSubmittable, setIsSubmittable] = React.useState(true)
const inputRef = React.useRef<HTMLInputElement>(null)
const cancelled = React.useRef(false)
Expand All @@ -44,8 +44,8 @@ export default function EditableSpan(props: EditableSpanProps) {

React.useEffect(() => {
if (editable) {
return shortcutManager.registerKeyboardHandlers({
[shortcutManagerModule.KeyboardAction.cancelEditName]: () => {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
cancelEditName: () => {
onCancel()
cancelled.current = true
inputRef.current?.blur()
Expand All @@ -54,7 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) {
} else {
return
}
}, [editable, shortcutManager, onCancel])
}, [editable, onCancel, /* should never change */ inputBindings])

React.useEffect(() => {
cancelled.current = false
Expand Down Expand Up @@ -86,21 +86,12 @@ export default function EditableSpan(props: EditableSpanProps) {
event.currentTarget.form?.requestSubmit()
}
}}
onContextMenu={event => {
event.stopPropagation()
}}
onKeyDown={event => {
if (
!event.isPropagationStopped() &&
((event.ctrlKey &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
/^[xcvzy]$/.test(event.key)) ||
(event.ctrlKey &&
event.shiftKey &&
!event.altKey &&
!event.metaKey &&
/[Z]/.test(event.key)))
) {
// This is an event that will be handled by the input.
if (event.key !== 'Escape') {
// The input may handle the event.
event.stopPropagation()
}
}}
Expand Down
Loading
Loading