Lixen Copilot commited on
Commit
3d6248e
·
unverified ·
1 Parent(s): 2c2bf88

feat: keybind ui tweaks, redo/undo configuration (#517)

Browse files

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

ui/components/MenuBar.tsx CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import { CopyIcon, MinusIcon, SquareIcon, XIcon } from 'lucide-react'
4
  import Image from 'next/image'
5
- import { useCallback, useEffect, useState } from 'react'
6
  import { useTranslation } from 'react-i18next'
7
 
8
  import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas'
@@ -21,13 +21,11 @@ import { getConfig, startPipeline } from '@/lib/api/default/default'
21
  import { isTauri, openExternalUrl } from '@/lib/backend'
22
  import { exportCurrentProjectAs, importPages } from '@/lib/io/pagesIo'
23
  import { closeProject, redoOp, selectAllTextNodesOnCurrentPage, undoOp } from '@/lib/io/scene'
 
24
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
25
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
26
  import { useSelectionStore } from '@/lib/stores/selectionStore'
27
 
28
- const isMacOS = () =>
29
- typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
30
-
31
  const windowControls = {
32
  async close() {
33
  const { getCurrentWindow } = await import('@tauri-apps/api/window')
@@ -66,6 +64,8 @@ export function MenuBar() {
66
  const [settingsTab, setSettingsTab] = useState<TabId>('appearance')
67
  const hasPage = useSelectionStore((s) => s.pageId !== null)
68
  const hasScene = useScene().scene !== null
 
 
69
 
70
  const requirePageId = () => {
71
  const id = useSelectionStore.getState().pageId
@@ -173,8 +173,8 @@ export function MenuBar() {
173
  },
174
  ]
175
 
176
- const isNativeMacOS = isTauri() && isMacOS()
177
- const isWindowsTauri = isTauri() && !isMacOS()
178
 
179
  return (
180
  <div className='flex h-8 items-center border-b border-border bg-background text-[13px] text-foreground'>
@@ -264,7 +264,7 @@ export function MenuBar() {
264
  onSelect={() => void undoOp()}
265
  >
266
  {t('menu.undo')}
267
- <MenubarShortcut>{isMacOS() ? '⌘Z' : 'Ctrl+Z'}</MenubarShortcut>
268
  </MenubarItem>
269
  <MenubarItem
270
  data-testid='menu-edit-redo'
@@ -273,7 +273,7 @@ export function MenuBar() {
273
  onSelect={() => void redoOp()}
274
  >
275
  {t('menu.redo')}
276
- <MenubarShortcut>{isMacOS() ? '⇧⌘Z' : 'Ctrl+Shift+Z'}</MenubarShortcut>
277
  </MenubarItem>
278
  <MenubarSeparator />
279
  <MenubarItem
@@ -283,7 +283,7 @@ export function MenuBar() {
283
  onSelect={() => selectAllTextNodesOnCurrentPage()}
284
  >
285
  {t('menu.selectAll')}
286
- <MenubarShortcut>{isMacOS() ? '⌘A' : 'Ctrl+A'}</MenubarShortcut>
287
  </MenubarItem>
288
  </MenubarContent>
289
  </MenubarMenu>
 
2
 
3
  import { CopyIcon, MinusIcon, SquareIcon, XIcon } from 'lucide-react'
4
  import Image from 'next/image'
5
+ import { useCallback, useEffect, useMemo, useState } from 'react'
6
  import { useTranslation } from 'react-i18next'
7
 
8
  import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas'
 
21
  import { isTauri, openExternalUrl } from '@/lib/backend'
22
  import { exportCurrentProjectAs, importPages } from '@/lib/io/pagesIo'
23
  import { closeProject, redoOp, selectAllTextNodesOnCurrentPage, undoOp } from '@/lib/io/scene'
24
+ import { formatShortcutForDisplay, getPlatform } from '@/lib/shortcutUtils'
25
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
26
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
27
  import { useSelectionStore } from '@/lib/stores/selectionStore'
28
 
 
 
 
29
  const windowControls = {
30
  async close() {
31
  const { getCurrentWindow } = await import('@tauri-apps/api/window')
 
64
  const [settingsTab, setSettingsTab] = useState<TabId>('appearance')
65
  const hasPage = useSelectionStore((s) => s.pageId !== null)
66
  const hasScene = useScene().scene !== null
67
+ const shortcuts = usePreferencesStore((state) => state.shortcuts)
68
+ const isMac = useMemo(() => getPlatform() === 'mac', [])
69
 
70
  const requirePageId = () => {
71
  const id = useSelectionStore.getState().pageId
 
173
  },
174
  ]
175
 
176
+ const isNativeMacOS = isTauri() && isMac
177
+ const isWindowsTauri = isTauri() && !isMac
178
 
179
  return (
180
  <div className='flex h-8 items-center border-b border-border bg-background text-[13px] text-foreground'>
 
264
  onSelect={() => void undoOp()}
265
  >
266
  {t('menu.undo')}
267
+ <MenubarShortcut>{formatShortcutForDisplay(shortcuts.undo, isMac)}</MenubarShortcut>
268
  </MenubarItem>
269
  <MenubarItem
270
  data-testid='menu-edit-redo'
 
273
  onSelect={() => void redoOp()}
274
  >
275
  {t('menu.redo')}
276
+ <MenubarShortcut>{formatShortcutForDisplay(shortcuts.redo, isMac)}</MenubarShortcut>
277
  </MenubarItem>
278
  <MenubarSeparator />
279
  <MenubarItem
 
283
  onSelect={() => selectAllTextNodesOnCurrentPage()}
284
  >
285
  {t('menu.selectAll')}
286
+ <MenubarShortcut>{isMac ? '⌘A' : 'Ctrl+A'}</MenubarShortcut>
287
  </MenubarItem>
288
  </MenubarContent>
289
  </MenubarMenu>
ui/components/SettingsDialog.tsx CHANGED
@@ -19,7 +19,7 @@ import {
19
  AlertTriangleIcon,
20
  } from 'lucide-react'
21
  import { useTheme } from 'next-themes'
22
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
23
  import { useTranslation } from 'react-i18next'
24
 
25
  import {
@@ -39,6 +39,7 @@ import {
39
  import { Button } from '@/components/ui/button'
40
  import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
41
  import { Input } from '@/components/ui/input'
 
42
  import { Label } from '@/components/ui/label'
43
  import { ScrollArea } from '@/components/ui/scroll-area'
44
  import {
@@ -69,6 +70,7 @@ import { supportedLanguages } from '@/lib/i18n'
69
  import {
70
  areShortcutsEqual,
71
  formatShortcut,
 
72
  getPlatform,
73
  isKeyBlocked,
74
  isModifierKey,
@@ -688,6 +690,8 @@ const SHORTCUT_ITEMS = [
688
  key: 'decreaseBrushSize',
689
  labelKey: 'settings.shortcutDecreaseBrushSize',
690
  },
 
 
691
  ] as const
692
 
693
  function KeybindsPane() {
@@ -699,6 +703,7 @@ function KeybindsPane() {
699
  const [recordingKey, setRecordingKey] = useState<string | null>(null)
700
  const [error, setError] = useState<string | null>(null)
701
  const [isSaved, setIsSaved] = useState(false)
 
702
  const isMac = useMemo(() => getPlatform() === 'mac', [])
703
 
704
  // Optimized conflict detection
@@ -723,21 +728,27 @@ function KeybindsPane() {
723
  useEffect(() => {
724
  if (!recordingKey) {
725
  setError(null)
 
726
  return
727
  }
728
 
 
 
729
  const handleKeyDown = (e: KeyboardEvent) => {
730
  e.preventDefault()
731
  e.stopPropagation()
 
732
 
733
- // Early exit for modifier-only events
734
  if (isModifierKey(e.key)) {
 
735
  return
736
  }
737
 
738
  // Allow Escape to cancel recording
739
  if (e.key === 'Escape') {
740
  setRecordingKey(null)
 
741
  return
742
  }
743
 
@@ -750,29 +761,30 @@ function KeybindsPane() {
750
  const shortcut = formatShortcut(e, isMac)
751
  if (!shortcut) return
752
 
753
- // Conflict detection (now non-blocking)
754
- const existingAction = Object.entries(pendingShortcuts).find(
755
- ([action, val]) => val === shortcut && action !== recordingKey,
756
- )
757
-
758
- if (existingAction) {
759
- // Just a hint, we still allow it
760
- setError(t('settings.shortcutConflict'))
761
- }
762
-
763
  setPendingShortcuts((prev) => ({ ...prev, [recordingKey]: shortcut }))
764
  setRecordingKey(null)
765
  setIsSaved(false)
 
 
 
 
 
 
 
 
766
  }
767
 
768
  const handleClickOutside = () => {
769
  setRecordingKey(null)
 
770
  }
771
 
772
  window.addEventListener('keydown', handleKeyDown, { capture: true })
 
773
  window.addEventListener('click', handleClickOutside, { capture: true })
774
  return () => {
775
  window.removeEventListener('keydown', handleKeyDown, { capture: true })
 
776
  window.removeEventListener('click', handleClickOutside, {
777
  capture: true,
778
  })
@@ -813,6 +825,17 @@ function KeybindsPane() {
813
  setResetConfirmOpen(false)
814
  }
815
 
 
 
 
 
 
 
 
 
 
 
 
816
  return (
817
  <div className='flex h-full flex-col gap-6'>
818
  <div className='grow space-y-6 overflow-y-auto pr-2'>
@@ -820,30 +843,53 @@ function KeybindsPane() {
820
  <div className='divide-y divide-border overflow-hidden rounded-xl border border-border bg-card'>
821
  {SHORTCUT_ITEMS.map((item) => {
822
  const currentVal = pendingShortcuts[item.key]
823
- const hasConflict = (conflictCounts.get(currentVal) || 0) > 1
 
 
 
 
 
824
 
825
  return (
826
- <div key={item.key} className='flex items-center justify-between px-4 py-3'>
827
  <div className='flex items-center gap-2'>
828
  <span className='text-sm'>{t(item.labelKey)}</span>
829
  {hasConflict && (
830
- <div title={t('settings.shortcutConflict')}>
 
 
 
 
831
  <AlertTriangleIcon className='size-3.5 text-amber-500' />
832
  </div>
833
  )}
834
  </div>
835
  <Button
836
- variant={recordingKey === item.key ? 'default' : 'outline'}
837
  size='sm'
838
  onClick={(e) => {
839
  e.stopPropagation()
840
  setRecordingKey(item.key)
841
  }}
842
- className='h-8 min-w-16 font-mono uppercase'
843
  >
844
- {recordingKey === item.key
845
- ? error || t('settings.shortcutPressKey')
846
- : currentVal || 'NONE'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
847
  </Button>
848
  </div>
849
  )
 
19
  AlertTriangleIcon,
20
  } from 'lucide-react'
21
  import { useTheme } from 'next-themes'
22
+ import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
23
  import { useTranslation } from 'react-i18next'
24
 
25
  import {
 
39
  import { Button } from '@/components/ui/button'
40
  import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
41
  import { Input } from '@/components/ui/input'
42
+ import { Kbd } from '@/components/ui/kbd'
43
  import { Label } from '@/components/ui/label'
44
  import { ScrollArea } from '@/components/ui/scroll-area'
45
  import {
 
70
  import {
71
  areShortcutsEqual,
72
  formatShortcut,
73
+ formatModifierCombination,
74
  getPlatform,
75
  isKeyBlocked,
76
  isModifierKey,
 
690
  key: 'decreaseBrushSize',
691
  labelKey: 'settings.shortcutDecreaseBrushSize',
692
  },
693
+ { key: 'undo', labelKey: 'menu.undo' },
694
+ { key: 'redo', labelKey: 'menu.redo' },
695
  ] as const
696
 
697
  function KeybindsPane() {
 
703
  const [recordingKey, setRecordingKey] = useState<string | null>(null)
704
  const [error, setError] = useState<string | null>(null)
705
  const [isSaved, setIsSaved] = useState(false)
706
+ const [liveShortcut, setLiveShortcut] = useState<string | null>(null)
707
  const isMac = useMemo(() => getPlatform() === 'mac', [])
708
 
709
  // Optimized conflict detection
 
728
  useEffect(() => {
729
  if (!recordingKey) {
730
  setError(null)
731
+ setLiveShortcut(null)
732
  return
733
  }
734
 
735
+ setError(null)
736
+ setLiveShortcut(null)
737
  const handleKeyDown = (e: KeyboardEvent) => {
738
  e.preventDefault()
739
  e.stopPropagation()
740
+ setError(null)
741
 
742
+ // Early exit for modifier-only events - but update preview!
743
  if (isModifierKey(e.key)) {
744
+ setLiveShortcut(formatModifierCombination(e, isMac))
745
  return
746
  }
747
 
748
  // Allow Escape to cancel recording
749
  if (e.key === 'Escape') {
750
  setRecordingKey(null)
751
+ setLiveShortcut(null)
752
  return
753
  }
754
 
 
761
  const shortcut = formatShortcut(e, isMac)
762
  if (!shortcut) return
763
 
 
 
 
 
 
 
 
 
 
 
764
  setPendingShortcuts((prev) => ({ ...prev, [recordingKey]: shortcut }))
765
  setRecordingKey(null)
766
  setIsSaved(false)
767
+ setLiveShortcut(null)
768
+ }
769
+
770
+ const handleKeyUp = (e: KeyboardEvent) => {
771
+ if (isModifierKey(e.key)) {
772
+ const combo = formatModifierCombination(e, isMac)
773
+ setLiveShortcut(combo || null)
774
+ }
775
  }
776
 
777
  const handleClickOutside = () => {
778
  setRecordingKey(null)
779
+ setLiveShortcut(null)
780
  }
781
 
782
  window.addEventListener('keydown', handleKeyDown, { capture: true })
783
+ window.addEventListener('keyup', handleKeyUp, { capture: true })
784
  window.addEventListener('click', handleClickOutside, { capture: true })
785
  return () => {
786
  window.removeEventListener('keydown', handleKeyDown, { capture: true })
787
+ window.removeEventListener('keyup', handleKeyUp, { capture: true })
788
  window.removeEventListener('click', handleClickOutside, {
789
  capture: true,
790
  })
 
825
  setResetConfirmOpen(false)
826
  }
827
 
828
+ const renderShortcutKeys = (shortcutStr: string, kbdClass?: string) => {
829
+ const parts = shortcutStr.split('+')
830
+
831
+ return parts.map((part, i) => (
832
+ <Fragment key={i}>
833
+ <Kbd className={kbdClass}>{part}</Kbd>
834
+ {i < parts.length - 1 && <span className='text-muted-foreground'>+</span>}
835
+ </Fragment>
836
+ ))
837
+ }
838
+
839
  return (
840
  <div className='flex h-full flex-col gap-6'>
841
  <div className='grow space-y-6 overflow-y-auto pr-2'>
 
843
  <div className='divide-y divide-border overflow-hidden rounded-xl border border-border bg-card'>
844
  {SHORTCUT_ITEMS.map((item) => {
845
  const currentVal = pendingShortcuts[item.key]
846
+ const hasConflict = currentVal && (conflictCounts.get(currentVal) || 0) > 1
847
+ const conflictingItem = hasConflict
848
+ ? SHORTCUT_ITEMS.find(
849
+ (s) => s.key !== item.key && pendingShortcuts[s.key] === currentVal,
850
+ )
851
+ : null
852
 
853
  return (
854
+ <div key={item.key} className='flex items-center justify-between px-4 py-2'>
855
  <div className='flex items-center gap-2'>
856
  <span className='text-sm'>{t(item.labelKey)}</span>
857
  {hasConflict && (
858
+ <div
859
+ title={`${t('settings.shortcutConflict')}${
860
+ conflictingItem ? `: ${t(conflictingItem.labelKey)}` : ''
861
+ }`}
862
+ >
863
  <AlertTriangleIcon className='size-3.5 text-amber-500' />
864
  </div>
865
  )}
866
  </div>
867
  <Button
868
+ variant={recordingKey === item.key ? 'secondary' : 'ghost'}
869
  size='sm'
870
  onClick={(e) => {
871
  e.stopPropagation()
872
  setRecordingKey(item.key)
873
  }}
874
+ className='group h-8 w-fit px-2 font-mono uppercase'
875
  >
876
+ <div className='flex items-center gap-1'>
877
+ {recordingKey === item.key ? (
878
+ error ? (
879
+ <span className='text-xs text-destructive'>{error}</span>
880
+ ) : liveShortcut ? (
881
+ renderShortcutKeys(liveShortcut)
882
+ ) : (
883
+ <span className='text-xs text-muted-foreground italic'>
884
+ {t('settings.shortcutPressKey')}
885
+ </span>
886
+ )
887
+ ) : currentVal ? (
888
+ renderShortcutKeys(currentVal, 'bg-background')
889
+ ) : (
890
+ <span className='text-xs text-muted-foreground'>NONE</span>
891
+ )}
892
+ </div>
893
  </Button>
894
  </div>
895
  )
ui/components/ui/kbd.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from 'react'
2
+
3
+ import { cn } from '@/lib/utils'
4
+
5
+ export interface KbdProps extends React.HTMLAttributes<HTMLElement> {}
6
+
7
+ const Kbd = React.forwardRef<HTMLElement, KbdProps>(({ className, ...props }, ref) => {
8
+ return (
9
+ <kbd
10
+ ref={ref}
11
+ className={cn(
12
+ 'pointer-events-none inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded border border-b-2 bg-muted px-1.5 font-mono text-[10px] leading-none font-medium text-muted-foreground opacity-100 shadow-sm transition-all duration-200 select-none',
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ })
19
+ Kbd.displayName = 'Kbd'
20
+
21
+ export { Kbd }
ui/hooks/useKeyboardShortcuts.ts CHANGED
@@ -10,24 +10,47 @@ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
10
  export function useKeyboardShortcuts() {
11
  const setMode = useEditorUiStore((state) => state.setMode)
12
  const setBrushConfig = usePreferencesStore((state) => state.setBrushConfig)
 
13
  const isMac = useMemo(() => getPlatform() === 'mac', [])
14
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  useEffect(() => {
16
  const handleKeyDown = (event: KeyboardEvent) => {
17
- const target = event.target as HTMLElement
18
  const inTextField =
19
- target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
 
20
 
21
- // Undo / Redo — work globally, including from within text fields (the
22
- // browser's native `z` on an input would only undo keystrokes, not
23
- // scene ops). Cmd on macOS, Ctrl elsewhere.
 
24
  const mod = isMac ? event.metaKey : event.ctrlKey
25
- if (mod && (event.key === 'z' || event.key === 'Z')) {
 
26
  event.preventDefault()
27
- if (event.shiftKey) void redoOp()
28
- else void undoOp()
29
  return
30
  }
 
 
 
 
 
 
 
 
31
  if (mod && (event.key === 'y' || event.key === 'Y')) {
32
  event.preventDefault()
33
  void redoOp()
@@ -50,27 +73,15 @@ export function useKeyboardShortcuts() {
50
  return
51
  }
52
 
53
- const shortcut = formatShortcut(event, isMac)
54
- if (!shortcut) return
55
-
56
- // Pull latest shortcuts from store to avoid re-binding this listener
57
- const shortcuts = usePreferencesStore.getState().shortcuts
58
-
59
- // Tool Switching
60
- if (shortcut === shortcuts.select) {
61
- setMode('select')
62
- } else if (shortcut === shortcuts.block) {
63
- setMode('block')
64
- } else if (shortcut === shortcuts.brush) {
65
- setMode('brush')
66
- } else if (shortcut === shortcuts.eraser) {
67
- setMode('eraser')
68
- } else if (shortcut === shortcuts.repairBrush) {
69
- setMode('repairBrush')
70
  }
71
 
72
  // Brush Size
73
- else if (shortcut === shortcuts.increaseBrushSize) {
74
  const currentSize = usePreferencesStore.getState().brushConfig.size
75
  setBrushConfig({ size: Math.min(128, currentSize + 4) })
76
  } else if (shortcut === shortcuts.decreaseBrushSize) {
@@ -82,5 +93,5 @@ export function useKeyboardShortcuts() {
82
  window.addEventListener('keydown', handleKeyDown)
83
  return () => window.removeEventListener('keydown', handleKeyDown)
84
  // eslint-disable-next-line react-hooks/exhaustive-deps
85
- }, [isMac, setMode])
86
  }
 
10
  export function useKeyboardShortcuts() {
11
  const setMode = useEditorUiStore((state) => state.setMode)
12
  const setBrushConfig = usePreferencesStore((state) => state.setBrushConfig)
13
+ const shortcuts = usePreferencesStore((state) => state.shortcuts)
14
  const isMac = useMemo(() => getPlatform() === 'mac', [])
15
 
16
+ // Optimized tool mapping - built once and updated only when shortcuts change
17
+ const TOOL_MAP = useMemo(
18
+ (): Record<string, import('@/lib/types').ToolMode> => ({
19
+ [shortcuts.select]: 'select',
20
+ [shortcuts.block]: 'block',
21
+ [shortcuts.brush]: 'brush',
22
+ [shortcuts.eraser]: 'eraser',
23
+ [shortcuts.repairBrush]: 'repairBrush',
24
+ }),
25
+ [shortcuts],
26
+ )
27
+
28
  useEffect(() => {
29
  const handleKeyDown = (event: KeyboardEvent) => {
30
+ const target = event.target
31
  const inTextField =
32
+ target instanceof HTMLElement &&
33
+ (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
34
 
35
+ // Undo / Redo — these work globally, including from within text fields,
36
+ // as scene-level history should usually take precedence over native
37
+ // browser text-undo.
38
+ const shortcut = formatShortcut(event, isMac)
39
  const mod = isMac ? event.metaKey : event.ctrlKey
40
+
41
+ if (shortcut === shortcuts.undo) {
42
  event.preventDefault()
43
+ void undoOp()
 
44
  return
45
  }
46
+
47
+ if (shortcut === shortcuts.redo) {
48
+ event.preventDefault()
49
+ void redoOp()
50
+ return
51
+ }
52
+
53
+ // Legacy fallback: Redo on Ctrl+Y / Cmd+Y
54
  if (mod && (event.key === 'y' || event.key === 'Y')) {
55
  event.preventDefault()
56
  void redoOp()
 
73
  return
74
  }
75
 
76
+ // Tool Switching - O(1) direct matching
77
+ const matchingTool = shortcut ? TOOL_MAP[shortcut] : undefined
78
+ if (matchingTool) {
79
+ setMode(matchingTool)
80
+ return
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
82
 
83
  // Brush Size
84
+ if (shortcut === shortcuts.increaseBrushSize) {
85
  const currentSize = usePreferencesStore.getState().brushConfig.size
86
  setBrushConfig({ size: Math.min(128, currentSize + 4) })
87
  } else if (shortcut === shortcuts.decreaseBrushSize) {
 
93
  window.addEventListener('keydown', handleKeyDown)
94
  return () => window.removeEventListener('keydown', handleKeyDown)
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [isMac, setMode, TOOL_MAP, shortcuts])
97
  }
ui/lib/shortcutUtils.ts CHANGED
@@ -37,6 +37,18 @@ const MODIFIER_NAMES = new Set([
37
  'Command',
38
  ])
39
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  /**
41
  * Generates a standardized shortcut string from a keyboard event.
42
  * Format: [Ctrl+][Alt/Opt+][Shift+][Cmd/Win+]Key
@@ -58,7 +70,8 @@ export function formatShortcut(event: ShortcutEvent, isMac: boolean): string {
58
  }
59
 
60
  // Standardize single characters to uppercase (V, B, [)
61
- const displayKey = key.length === 1 ? key.toUpperCase() : key
 
62
  parts.push(displayKey)
63
 
64
  return parts.join('+')
@@ -108,3 +121,36 @@ export function areShortcutsEqual(a: Record<string, string>, b: Record<string, s
108
  export function isModifierKey(key: string): boolean {
109
  return MODIFIER_NAMES.has(key)
110
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  'Command',
38
  ])
39
 
40
+ /**
41
+ * Generates a string representing only the modifiers currently held.
42
+ */
43
+ export function formatModifierCombination(event: ShortcutEvent, isMac: boolean): string {
44
+ const parts: string[] = []
45
+ if (event.ctrlKey) parts.push('Ctrl')
46
+ if (event.altKey) parts.push(isMac ? 'Opt' : 'Alt')
47
+ if (event.shiftKey) parts.push('Shift')
48
+ if (event.metaKey) parts.push(isMac ? 'Cmd' : 'Win')
49
+ return parts.join('+')
50
+ }
51
+
52
  /**
53
  * Generates a standardized shortcut string from a keyboard event.
54
  * Format: [Ctrl+][Alt/Opt+][Shift+][Cmd/Win+]Key
 
70
  }
71
 
72
  // Standardize single characters to uppercase (V, B, [)
73
+ let displayKey = key.length === 1 ? key.toUpperCase() : key
74
+ if (displayKey === ' ') displayKey = 'Space'
75
  parts.push(displayKey)
76
 
77
  return parts.join('+')
 
121
  export function isModifierKey(key: string): boolean {
122
  return MODIFIER_NAMES.has(key)
123
  }
124
+
125
+ /**
126
+ * Formats a standardized shortcut string (e.g., 'Cmd+Shift+Z') for UI display.
127
+ * On Mac, it uses standard symbols (⌘, ⇧, ⌥, ⌃).
128
+ * On other platforms, it returns the string as-is with '+' separators.
129
+ */
130
+ export function formatShortcutForDisplay(shortcut: string, isMac: boolean): string {
131
+ if (!shortcut) return ''
132
+
133
+ const parts = shortcut.split('+')
134
+ if (!isMac) return parts.join('+')
135
+
136
+ // Mac symbol mapping
137
+ const symbols: Record<string, string> = {
138
+ Ctrl: '⌃',
139
+ Opt: '⌥',
140
+ Shift: '⇧',
141
+ Cmd: '⌘',
142
+ }
143
+
144
+ // Map parts to symbols if they exist, otherwise keep the key as is
145
+ const mappedParts = parts.map((part) => symbols[part] || part)
146
+
147
+ // Standard Mac menu order: Control, Option, Shift, Command
148
+ // We sort based on this order for consistency in the UI
149
+ const ORDER = ['⌃', '⌥', '⇧', '⌘']
150
+ const modifiers = mappedParts.filter((p) => ORDER.includes(p))
151
+ const key = mappedParts.find((p) => !ORDER.includes(p))
152
+
153
+ modifiers.sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b))
154
+
155
+ return modifiers.join('') + (key || '')
156
+ }
ui/lib/stores/preferencesStore.ts CHANGED
@@ -3,6 +3,8 @@
3
  import { create } from 'zustand'
4
  import { persist } from 'zustand/middleware'
5
 
 
 
6
  type PreferencesState = {
7
  brushConfig: {
8
  size: number
@@ -21,6 +23,8 @@ type PreferencesState = {
21
  repairBrush: string
22
  increaseBrushSize: string
23
  decreaseBrushSize: string
 
 
24
  }
25
  setShortcuts: (shortcuts: Partial<PreferencesState['shortcuts']>) => void
26
  resetShortcuts: () => void
@@ -40,6 +44,8 @@ const initialPreferences = {
40
  repairBrush: 'R',
41
  increaseBrushSize: ']',
42
  decreaseBrushSize: '[',
 
 
43
  },
44
  }
45
 
@@ -73,7 +79,7 @@ export const usePreferencesStore = create<PreferencesState>()(
73
  }),
74
  {
75
  name: 'koharu-config',
76
- version: 4,
77
  migrate: (persisted: any, version: number) => {
78
  if (version < 2 && persisted) {
79
  delete persisted.localLlm
@@ -92,6 +98,15 @@ export const usePreferencesStore = create<PreferencesState>()(
92
  }
93
  }
94
  }
 
 
 
 
 
 
 
 
 
95
  return persisted
96
  },
97
  partialize: (state) => ({
 
3
  import { create } from 'zustand'
4
  import { persist } from 'zustand/middleware'
5
 
6
+ import { getPlatform } from '@/lib/shortcutUtils'
7
+
8
  type PreferencesState = {
9
  brushConfig: {
10
  size: number
 
23
  repairBrush: string
24
  increaseBrushSize: string
25
  decreaseBrushSize: string
26
+ undo: string
27
+ redo: string
28
  }
29
  setShortcuts: (shortcuts: Partial<PreferencesState['shortcuts']>) => void
30
  resetShortcuts: () => void
 
44
  repairBrush: 'R',
45
  increaseBrushSize: ']',
46
  decreaseBrushSize: '[',
47
+ undo: getPlatform() === 'mac' ? 'Cmd+Z' : 'Ctrl+Z',
48
+ redo: getPlatform() === 'mac' ? 'Cmd+Shift+Z' : 'Ctrl+Shift+Z',
49
  },
50
  }
51
 
 
79
  }),
80
  {
81
  name: 'koharu-config',
82
+ version: 5,
83
  migrate: (persisted: any, version: number) => {
84
  if (version < 2 && persisted) {
85
  delete persisted.localLlm
 
98
  }
99
  }
100
  }
101
+ if (version < 5 && persisted?.shortcuts) {
102
+ const isMac = getPlatform() === 'mac'
103
+ if (!persisted.shortcuts.undo) {
104
+ persisted.shortcuts.undo = isMac ? 'Cmd+Z' : 'Ctrl+Z'
105
+ }
106
+ if (!persisted.shortcuts.redo) {
107
+ persisted.shortcuts.redo = isMac ? 'Cmd+Shift+Z' : 'Ctrl+Shift+Z'
108
+ }
109
+ }
110
  return persisted
111
  },
112
  partialize: (state) => ({