better code: centralized ctrl+s behavior
Massimo Melina committed
Apr 26, 2026 at 21:23 UTC
51c4bf2098b896284c4ae862e27946feb920c71b
4 files changed
+24
-13
admin/src/CustomHtmlPage.ts
+4
-9
@@ -4,8 +4,8 @@ import { createElement as h, Fragment, useEffect, useMemo, useState } from 'reac
4
import { Field, SelectField } from '@hfs/mui-grid-form'
5
import { apiCall, useApiEx } from './api'
6
import { Alert, Box } from '@mui/material'
7
-import { Dict, HTTP_MESSAGES, isCtrlKey, prefix, md, isNumeric, CFG } from './misc'
8
-import { hTooltip, IconBtn, reloadBtn, wikiLink } from './mui'
7
+import { Dict, HTTP_MESSAGES, prefix, md, isNumeric, CFG } from './misc'
8
+import { hTooltip, IconBtn, reloadBtn, useCtrlShortcutButton, wikiLink } from './mui'
9
import { Save } from '@mui/icons-material'
10
import _ from 'lodash'
11
import { useDebounce } from 'usehooks-ts'
@@ -60,8 +60,9 @@ export default function CustomHtmlPage({ setTitleSide }: PageProps) {
60
}),
61
reloadBtn(reload),
62
h(IconBtn, {
63
+ ref: useCtrlShortcutButton(['s', 'Enter']).ref,
64
icon: Save,
64
- title: "Save\n(ctrl+enter)",
65
+ title: "Save\n(ctrl+s)",
66
modified: anyChange,
67
doneAnimation: true,
68
onClick: save,
@@ -78,12 +79,6 @@ export default function CustomHtmlPage({ setTitleSide }: PageProps) {
79
onValueChange(v: string) {
80
setAll(all => ({ ...all, [section]: v }))
81
},
81
- onKeyDown(ev) {
82
- if (['s','S','Enter'].includes(isCtrlKey(ev) as any)) {
83
- void save()
84
- ev.preventDefault()
85
- }
86
- },
82
}),
83
)
84
admin/src/VfsMenuBar.ts
+3
-2
@@ -10,7 +10,7 @@ import { reloadVfs } from './VfsPage'
10
import { prefix, VFS_STORED_KEYS } from './misc'
11
import { state, undoVfs, useSnapState } from './state'
12
import _ from 'lodash'
13
-import { Btn, Flex, reloadBtn, useBreakpoint } from './mui'
13
+import { Btn, Flex, reloadBtn, useBreakpoint, useCtrlShortcutButton } from './mui'
14
import { apiCall, ApiObject, useApi } from './api'
15
import VfsPathField from './VfsPathField'
16
import { alertDialog, promptDialog } from './dialog'
@@ -28,8 +28,9 @@ export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusA
28
},
29
h(AddVfsBtn),
30
h(Btn, {
31
+ ref: useCtrlShortcutButton(['s']).ref,
32
icon: Save,
32
- title: "Save",
33
+ title: "Save\n(ctrl+s)",
34
disabled: !vfsModified && "No changes to save",
35
modified: vfsModified,
36
doneAnimation: true,
admin/src/VfsTree.ts
+1
-1
@@ -9,7 +9,7 @@ import {
9
} from '@mui/icons-material'
10
import { Box, Typography } from '@mui/material'
11
import { deleteVfs, id2vfsNode, isDescendantUri, reindexVfs, VfsNodeAdmin } from './VfsPage'
12
-import { getOrSet, onlyTruthy, pathEncode, prefix, toMutable, wantArray, Who, with_ } from './misc'
12
+import { onlyTruthy, pathEncode, prefix, toMutable, wantArray, Who, with_ } from './misc'
13
import { Flex, iconTooltip, useToggleButton } from './mui'
14
import VfsMenuBar from './VfsMenuBar'
15
import { ApiObject } from './api'
admin/src/mui.ts
+16
-1
@@ -11,7 +11,8 @@ import { Box, BoxProps, ButtonProps, CircularProgress, IconButton, IconButtonPro
11
Tooltip, TooltipProps, useMediaQuery, Button } from '@mui/material'
12
import type { Breakpoint } from '@mui/material/styles'
13
import {
14
- anyDialogOpen, closeDialog, formatPerc, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_, Functionable, callable
14
+ anyDialogOpen, closeDialog, formatPerc, callable, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_, Functionable,
15
+ domOn, isMac,
16
} from './misc'
17
import { dontBotherWithKeys, restartAnimation, useBatch, useStateMounted } from '@hfs/shared'
18
import { mergeSx, Promisable, StringField } from '@hfs/mui-grid-form'
@@ -107,6 +108,20 @@ export function reloadBtn(onClick: any, props?: any) {
108
return h(IconBtn, { icon: Refresh, title: "Reload", onClick, ...props })
109
}
110
111
+export function useCtrlShortcutButton(keys: readonly string[]) {
112
+ const ref = useRef<HTMLButtonElement>(null)
113
+ useEffect(() =>
114
+ domOn('keydown', ev => {
115
+ const key = (ev.ctrlKey || isMac && ev.metaKey) && ev.key.toLowerCase()
116
+ const btn = ref.current
117
+ if (!key || !btn || !keys.some(x => x.toLowerCase() === key)) return
118
+ ev.preventDefault() // capture at window level because focused widgets or body can bypass the page subtree
119
+ btn.click() // click the button so shortcuts reuse button loading, errors, and success animation
120
+ }, { capture: true })
121
+ , [keys.join('\n')])
122
+ return { ref }
123
+}
124
+
125
// modify look to convey that a form has been modified
126
export function propsForModifiedValues(modified: boolean | undefined) {
127
return modified ? { sx: { outline: '2px solid', animation: '.5s blink 2' } } : undefined