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