Lixen Copilot commited on
feat: keybind ui tweaks, redo/undo configuration (#517)
Browse filesCo-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- ui/components/MenuBar.tsx +9 -9
- ui/components/SettingsDialog.tsx +66 -20
- ui/components/ui/kbd.tsx +21 -0
- ui/hooks/useKeyboardShortcuts.ts +38 -27
- ui/lib/shortcutUtils.ts +47 -1
- ui/lib/stores/preferencesStore.ts +16 -1
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() &&
|
| 177 |
-
const isWindowsTauri = isTauri() && !
|
| 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>{
|
| 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>{
|
| 277 |
</MenubarItem>
|
| 278 |
<MenubarSeparator />
|
| 279 |
<MenubarItem
|
|
@@ -283,7 +283,7 @@ export function MenuBar() {
|
|
| 283 |
onSelect={() => selectAllTextNodesOnCurrentPage()}
|
| 284 |
>
|
| 285 |
{t('menu.selectAll')}
|
| 286 |
-
<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-
|
| 827 |
<div className='flex items-center gap-2'>
|
| 828 |
<span className='text-sm'>{t(item.labelKey)}</span>
|
| 829 |
{hasConflict && (
|
| 830 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
<AlertTriangleIcon className='size-3.5 text-amber-500' />
|
| 832 |
</div>
|
| 833 |
)}
|
| 834 |
</div>
|
| 835 |
<Button
|
| 836 |
-
variant={recordingKey === item.key ? '
|
| 837 |
size='sm'
|
| 838 |
onClick={(e) => {
|
| 839 |
e.stopPropagation()
|
| 840 |
setRecordingKey(item.key)
|
| 841 |
}}
|
| 842 |
-
className='h-8
|
| 843 |
>
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 18 |
const inTextField =
|
| 19 |
-
target
|
|
|
|
| 20 |
|
| 21 |
-
// Undo / Redo — work globally, including from within text fields
|
| 22 |
-
//
|
| 23 |
-
//
|
|
|
|
| 24 |
const mod = isMac ? event.metaKey : event.ctrlKey
|
| 25 |
-
|
|
|
|
| 26 |
event.preventDefault()
|
| 27 |
-
|
| 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 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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:
|
| 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) => ({
|