Skip to content

Commit e59b422

Browse files
authored
"Keyboard shortcuts" settings page (#9071)
- Close enso-org/cloud-v2#896 - Add new settings page for viewing and editing keyboard shortcuts - Refactor shortcut manager to resemble GUI2's shortcuts module - Minor refactor moving `dashboard/layouts/dashboard` to `dashboard/layouts`; and moving all moals to `dashboard/modals` # Important Notes - The modal for capturing keyboard shortcuts has not been tested on macOS.
1 parent 259ad09 commit e59b422

File tree

78 files changed

+1877
-1204
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+1877
-1204
lines changed

app/ide-desktop/lib/assets/cross.svg

Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 14 additions & 0 deletions
Loading
Lines changed: 16 additions & 0 deletions
Loading

app/ide-desktop/lib/assets/tick.svg

Lines changed: 1 addition & 1 deletion
Loading

app/ide-desktop/lib/dashboard/.prettierrc.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ module.exports = {
2525
'^#[/]App',
2626
'^#[/]appUtils',
2727
'',
28+
'^#[/]configurations[/]',
29+
'',
2830
'^#[/]data[/]',
2931
'',
3032
'^#[/]hooks[/]',
@@ -39,6 +41,8 @@ module.exports = {
3941
'',
4042
'^#[/]components[/]',
4143
'',
44+
'^#[/]modals[/]',
45+
'',
4246
'^#[/]services[/]',
4347
'',
4448
'^#[/]utilities[/]',

app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,7 @@ test.test('upload file', async ({ page }) => {
3232
const fileChooser = await fileChooserPromise
3333
const name = 'foo.txt'
3434
const content = 'hello world'
35-
await fileChooser.setFiles([
36-
{
37-
name,
38-
buffer: Buffer.from(content),
39-
mimeType: 'text/plain',
40-
},
41-
])
35+
await fileChooser.setFiles([{ name, buffer: Buffer.from(content), mimeType: 'text/plain' }])
4236

4337
await test.expect(assetRows).toHaveCount(1)
4438
await test.expect(assetRows.nth(0)).toBeVisible()

app/ide-desktop/lib/dashboard/src/App.tsx

Lines changed: 93 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,18 @@ import * as detect from 'enso-common/src/detect'
4242

4343
import * as appUtils from '#/appUtils'
4444

45+
import * as inputBindingsModule from '#/configurations/inputBindings'
46+
4547
import * as navigateHooks from '#/hooks/navigateHooks'
4648

4749
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
4850
import BackendProvider from '#/providers/BackendProvider'
49-
import LocalStorageProvider from '#/providers/LocalStorageProvider'
51+
import InputBindingsProvider from '#/providers/InputBindingsProvider'
52+
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
5053
import LoggerProvider from '#/providers/LoggerProvider'
5154
import type * as loggerProvider from '#/providers/LoggerProvider'
5255
import ModalProvider from '#/providers/ModalProvider'
5356
import SessionProvider from '#/providers/SessionProvider'
54-
import ShortcutManagerProvider from '#/providers/ShortcutManagerProvider'
5557

5658
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
5759
import EnterOfflineMode from '#/pages/authentication/EnterOfflineMode'
@@ -66,10 +68,39 @@ import Subscribe from '#/pages/subscribe/Subscribe'
6668
import type Backend from '#/services/Backend'
6769
import LocalBackend from '#/services/LocalBackend'
6870

69-
import ShortcutManager, * as shortcutManagerModule from '#/utilities/ShortcutManager'
71+
import LocalStorage from '#/utilities/LocalStorage'
7072

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

75+
// ============================
76+
// === Global configuration ===
77+
// ============================
78+
79+
declare module '#/utilities/LocalStorage' {
80+
/** */
81+
interface LocalStorageData {
82+
readonly inputBindings: Partial<
83+
Readonly<Record<inputBindingsModule.DashboardBindingKey, string[]>>
84+
>
85+
}
86+
}
87+
88+
LocalStorage.registerKey('inputBindings', {
89+
tryParse: value =>
90+
typeof value !== 'object' || value == null
91+
? null
92+
: Object.fromEntries(
93+
// This is SAFE, as it is a readonly upcast.
94+
// eslint-disable-next-line no-restricted-syntax
95+
Object.entries(value as Readonly<Record<string, unknown>>).flatMap(kv => {
96+
const [k, v] = kv
97+
return Array.isArray(v) && v.every((item): item is string => typeof item === 'string')
98+
? [[k, v]]
99+
: []
100+
})
101+
),
102+
})
103+
73104
// ======================
74105
// === getMainPageUrl ===
75106
// ======================
@@ -113,8 +144,9 @@ export default function App(props: AppProps) {
113144
// This is a React component even though it does not contain JSX.
114145
// eslint-disable-next-line no-restricted-syntax
115146
const Router = detect.isOnElectron() ? router.MemoryRouter : router.BrowserRouter
116-
/** Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
117-
* will redirect the user between the login/register pages and the dashboard. */
147+
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
148+
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
149+
// will redirect the user between the login/register pages and the dashboard.
118150
return (
119151
<>
120152
<toastify.ToastContainer
@@ -127,7 +159,9 @@ export default function App(props: AppProps) {
127159
limit={3}
128160
/>
129161
<Router basename={getMainPageUrl().pathname}>
130-
<AppRouter {...props} />
162+
<LocalStorageProvider>
163+
<AppRouter {...props} />
164+
</LocalStorageProvider>
131165
</Router>
132166
</>
133167
)
@@ -145,32 +179,67 @@ export default function App(props: AppProps) {
145179
function AppRouter(props: AppProps) {
146180
const { logger, supportsLocalBackend, isAuthenticationDisabled, shouldShowDashboard } = props
147181
const { onAuthenticated, projectManagerUrl } = props
182+
const { localStorage } = localStorageProvider.useLocalStorage()
148183
const navigate = navigateHooks.useNavigate()
149184
if (detect.IS_DEV_MODE) {
150185
// @ts-expect-error This is used exclusively for debugging.
151186
window.navigate = navigate
152187
}
153-
const [shortcutManager] = React.useState(() => ShortcutManager.createWithDefaults())
188+
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
154189
React.useEffect(() => {
155-
const onKeyDown = (event: KeyboardEvent) => {
156-
const isTargetEditable =
157-
event.target instanceof HTMLInputElement ||
158-
(event.target instanceof HTMLElement && event.target.isContentEditable)
159-
const shouldHandleEvent = isTargetEditable
160-
? !shortcutManagerModule.isTextInputEvent(event)
161-
: true
162-
if (shouldHandleEvent && shortcutManager.handleKeyboardEvent(event)) {
163-
event.preventDefault()
164-
// This is required to prevent the event from propagating to the event handler
165-
// that focuses the search input.
166-
event.stopImmediatePropagation()
190+
const savedInputBindings = localStorage.get('inputBindings')
191+
for (const k in savedInputBindings) {
192+
// This is UNSAFE, hence the `?? []` below.
193+
// eslint-disable-next-line no-restricted-syntax
194+
const bindingKey = k as inputBindingsModule.DashboardBindingKey
195+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
196+
for (const oldBinding of inputBindingsRaw.metadata[bindingKey].bindings ?? []) {
197+
inputBindingsRaw.delete(bindingKey, oldBinding)
198+
}
199+
for (const newBinding of savedInputBindings[bindingKey] ?? []) {
200+
inputBindingsRaw.add(bindingKey, newBinding)
167201
}
168202
}
169-
document.body.addEventListener('keydown', onKeyDown)
170-
return () => {
171-
document.body.removeEventListener('keydown', onKeyDown)
203+
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
204+
const inputBindings = React.useMemo(() => {
205+
const updateLocalStorage = () => {
206+
localStorage.set(
207+
'inputBindings',
208+
Object.fromEntries(
209+
Object.entries(inputBindingsRaw.metadata).map(kv => {
210+
const [k, v] = kv
211+
return [k, v.bindings]
212+
})
213+
)
214+
)
172215
}
173-
}, [shortcutManager])
216+
return {
217+
/** Transparently pass through `handler()`. */
218+
get handler() {
219+
return inputBindingsRaw.handler
220+
},
221+
/** Transparently pass through `attach()`. */
222+
get attach() {
223+
return inputBindingsRaw.attach
224+
},
225+
reset: (bindingKey: inputBindingsModule.DashboardBindingKey) => {
226+
inputBindingsRaw.reset(bindingKey)
227+
updateLocalStorage()
228+
},
229+
add: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => {
230+
inputBindingsRaw.add(bindingKey, binding)
231+
updateLocalStorage()
232+
},
233+
delete: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => {
234+
inputBindingsRaw.delete(bindingKey, binding)
235+
updateLocalStorage()
236+
},
237+
/** Transparently pass through `metadata`. */
238+
get metadata() {
239+
return inputBindingsRaw.metadata
240+
},
241+
}
242+
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
174243
const mainPageUrl = getMainPageUrl()
175244
const authService = React.useMemo(() => {
176245
const authConfig = { navigate, ...props }
@@ -253,9 +322,7 @@ function AppRouter(props: AppProps) {
253322
</router.Routes>
254323
)
255324
let result = routes
256-
result = (
257-
<ShortcutManagerProvider shortcutManager={shortcutManager}>{result}</ShortcutManagerProvider>
258-
)
325+
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
259326
result = <ModalProvider>{result}</ModalProvider>
260327
result = (
261328
<AuthProvider
@@ -269,8 +336,6 @@ function AppRouter(props: AppProps) {
269336
</AuthProvider>
270337
)
271338
result = <BackendProvider initialBackend={initialBackend}>{result}</BackendProvider>
272-
/** {@link BackendProvider} depends on {@link LocalStorageProvider}. */
273-
result = <LocalStorageProvider>{result}</LocalStorageProvider>
274339
result = (
275340
<SessionProvider
276341
mainPageUrl={mainPageUrl}

app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import * as React from 'react'
44
import CrossIcon from 'enso-assets/cross.svg'
55
import TickIcon from 'enso-assets/tick.svg'
66

7-
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
7+
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
88

9-
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
9+
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
1010

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

4545
React.useEffect(() => {
4646
if (editable) {
47-
return shortcutManager.registerKeyboardHandlers({
48-
[shortcutManagerModule.KeyboardAction.cancelEditName]: () => {
47+
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
48+
cancelEditName: () => {
4949
onCancel()
5050
cancelled.current = true
5151
inputRef.current?.blur()
@@ -54,7 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) {
5454
} else {
5555
return
5656
}
57-
}, [editable, shortcutManager, onCancel])
57+
}, [editable, onCancel, /* should never change */ inputBindings])
5858

5959
React.useEffect(() => {
6060
cancelled.current = false
@@ -86,21 +86,12 @@ export default function EditableSpan(props: EditableSpanProps) {
8686
event.currentTarget.form?.requestSubmit()
8787
}
8888
}}
89+
onContextMenu={event => {
90+
event.stopPropagation()
91+
}}
8992
onKeyDown={event => {
90-
if (
91-
!event.isPropagationStopped() &&
92-
((event.ctrlKey &&
93-
!event.shiftKey &&
94-
!event.altKey &&
95-
!event.metaKey &&
96-
/^[xcvzy]$/.test(event.key)) ||
97-
(event.ctrlKey &&
98-
event.shiftKey &&
99-
!event.altKey &&
100-
!event.metaKey &&
101-
/[Z]/.test(event.key)))
102-
) {
103-
// This is an event that will be handled by the input.
93+
if (event.key !== 'Escape') {
94+
// The input may handle the event.
10495
event.stopPropagation()
10596
}
10697
}}

0 commit comments

Comments
 (0)