201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
import { useEffect } from 'react'
|
|
import type React from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
useDominoStore,
|
|
useDominoStoreInstance,
|
|
type ImageElement,
|
|
type TextElement,
|
|
} from '../components/canvas'
|
|
|
|
import {
|
|
copyImageToClipboard,
|
|
copyTextElementToClipboard,
|
|
} from '../utils/helper'
|
|
import { useZoomActions } from './use-zoom-actions'
|
|
|
|
export interface UseShortcutsProps {
|
|
rootRef: React.RefObject<HTMLDivElement | null>
|
|
onDelete?: (ids: string[]) => void
|
|
onPaste?: (e: ClipboardEvent) => void
|
|
}
|
|
|
|
export function useShortcuts({ rootRef, onDelete, onPaste }: UseShortcutsProps) {
|
|
const store = useDominoStoreInstance()
|
|
const { stepZoom } = useZoomActions(rootRef)
|
|
const mode = useDominoStore(s => s.mode)
|
|
const setMode = useDominoStore(s => s.setMode)
|
|
const setViewport = useDominoStore(s => s.setViewport)
|
|
const undo = useDominoStore(s => s.undo)
|
|
const redo = useDominoStore(s => s.redo)
|
|
const selectedIds = useDominoStore(s => s.selectedIds)
|
|
const focusedElementId = useDominoStore(s => s.focusedElementId)
|
|
const readOnly = useDominoStore(s => s.readOnly)
|
|
|
|
useEffect(() => {
|
|
const isInputActive = () => {
|
|
// 1. 如果有元素正在被聚焦(比如正在编辑文字),禁用快捷键
|
|
if (focusedElementId) {
|
|
const { elements } = store.getState()
|
|
const focusedEl = elements[focusedElementId]
|
|
if (focusedEl?.type === 'text') return true
|
|
}
|
|
|
|
// 2. 如果原生 DOM 的输入框处于激活状态,禁用快捷键
|
|
const activeElement = document.activeElement
|
|
if (!activeElement) return false
|
|
return (
|
|
activeElement.tagName.toLowerCase() === 'input' ||
|
|
activeElement.tagName.toLowerCase() === 'textarea' ||
|
|
(activeElement as HTMLElement).isContentEditable
|
|
)
|
|
}
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (isInputActive()) return
|
|
|
|
const isMod = e.metaKey || e.ctrlKey
|
|
const isShift = e.shiftKey
|
|
|
|
// 1. Space -> Pan Mode
|
|
if (e.code === 'Space' && mode !== 'pan') {
|
|
e.preventDefault()
|
|
setMode('pan')
|
|
}
|
|
|
|
// 2. Undo/Redo
|
|
if (isMod && e.key.toLowerCase() === 'z') {
|
|
e.preventDefault()
|
|
if (isShift) redo()
|
|
else undo()
|
|
} else if (isMod && e.key.toLowerCase() === 'y') {
|
|
e.preventDefault()
|
|
redo()
|
|
}
|
|
|
|
// 3. Zoom Shortcuts
|
|
if (isMod && (e.key === '=' || e.key === '+')) {
|
|
e.preventDefault()
|
|
stepZoom(1)
|
|
}
|
|
if (isMod && (e.key === '-' || e.key === '_')) {
|
|
e.preventDefault()
|
|
stepZoom(-1)
|
|
}
|
|
if (e.key === '0') {
|
|
e.preventDefault()
|
|
setViewport({ scale: 1 })
|
|
}
|
|
|
|
// 4. Delete
|
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
if (selectedIds.length > 0 && !readOnly) {
|
|
e.preventDefault()
|
|
onDelete?.(selectedIds)
|
|
}
|
|
}
|
|
|
|
// 5. Select All
|
|
if (isMod && e.key.toLowerCase() === 'a') {
|
|
e.preventDefault()
|
|
const { elements, setSelectedIds } = store.getState()
|
|
setSelectedIds(Object.keys(elements))
|
|
}
|
|
|
|
// 6. Escape
|
|
if (e.key === 'Escape') {
|
|
const { setSelectedIds, setFocusedElementId } = store.getState()
|
|
setSelectedIds([])
|
|
setFocusedElementId(null)
|
|
if (mode !== 'select') setMode('select')
|
|
}
|
|
|
|
// 7. Arrow Keys
|
|
const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
|
|
if (ARROW_KEYS.includes(e.key) && selectedIds.length > 0 && !readOnly) {
|
|
e.preventDefault()
|
|
const step = isShift ? 10 : 1
|
|
const dx =
|
|
e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0
|
|
const dy =
|
|
e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0
|
|
const { moveElements } = store.getState()
|
|
moveElements(selectedIds, dx, dy)
|
|
}
|
|
|
|
// 8. Copy
|
|
if (isMod && e.key.toLowerCase() === 'c') {
|
|
const { elements } = store.getState()
|
|
const selectedElements = selectedIds
|
|
.map(id => elements[id])
|
|
.filter(Boolean)
|
|
|
|
if (selectedElements.length === 0) return
|
|
|
|
e.preventDefault()
|
|
if (selectedElements.length === 1) {
|
|
const el = selectedElements[0]
|
|
if (el.type === 'text') {
|
|
const textEl = el as TextElement
|
|
copyTextElementToClipboard(textEl).then(success => {
|
|
if (success) toast.success('已复制文本')
|
|
})
|
|
} else if (el.type === 'image') {
|
|
const imageEl = el as ImageElement
|
|
copyImageToClipboard(imageEl.id).then(success => {
|
|
if (success) toast.success('已复制图片')
|
|
else toast.error('复制失败')
|
|
})
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
const texts = selectedElements
|
|
.filter((el): el is TextElement => el.type === 'text')
|
|
.map(el => el.content)
|
|
if (texts.length > 0) {
|
|
navigator.clipboard
|
|
.writeText(texts.join('\n'))
|
|
.then(() => toast.success(`已复制 ${texts.length} 条内容`))
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
if (e.code === 'Space' && !isInputActive()) {
|
|
setMode('select')
|
|
}
|
|
}
|
|
|
|
const handlePaste = (e: ClipboardEvent) => {
|
|
if (isInputActive()) return
|
|
if (!onPaste) return
|
|
onPaste(e)
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
window.addEventListener('keyup', handleKeyUp)
|
|
window.addEventListener('paste', handlePaste)
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
window.removeEventListener('keyup', handleKeyUp)
|
|
window.removeEventListener('paste', handlePaste)
|
|
}
|
|
}, [
|
|
stepZoom,
|
|
mode,
|
|
setMode,
|
|
setViewport,
|
|
undo,
|
|
redo,
|
|
selectedIds,
|
|
focusedElementId,
|
|
readOnly,
|
|
store,
|
|
rootRef,
|
|
onDelete,
|
|
onPaste,
|
|
])
|
|
}
|