openpencil/src/composables/use-keyboard.ts
Danila Poyarkov 1631d48c40 Reduce cyclomatic complexity below 20 in all functions
Extract dispatch branches into focused helpers:

use-canvas-input.ts (5 functions, was 21–61):
- onMouseDown → handleSelectDown, startPanDrag, tryStartRotation,
  tryStartResize, handleTextEditClick, duplicateAndDrag
- onMouseMove → dragNodes, updatePan, updateRotation,
  extendTextSelection, updateDrawShape, updateMarquee
- onMouseUp → commitDrag, commitDrawing, reparentOutsideNodes
- applyResize → constrainToAspectRatio
- computeAutoLayoutIndicatorForFrame → computeIndicatorPosition

use-keyboard.ts (was 39):
- onEventFired → shouldPreventDefault + Set-based lookups

server.ts (was 34):
- handleRequest → Partial<Record> command dispatch map

LayerTree.vue (was 22):
- updateDropTarget → classifyDropZone, resolveInsertPosition

use-text-edit.ts (was 27):
- onKeyDown → handleDeletion, handleHorizontalArrow, metaKeyActions map
2026-03-09 11:24:56 +03:00

204 lines
6.7 KiB
TypeScript

import { useBreakpoints, useEventListener, useMagicKeys, whenever } from '@vueuse/core'
import { computed } from 'vue'
import { useAIChat } from '@/composables/use-chat'
import { TOOL_SHORTCUTS, useEditorStore } from '@/stores/editor'
import { closeTab, createTab, activeTab as activeTabRef } from '@/stores/tabs'
import { openFileDialog } from './use-menu'
import type { ComputedRef } from 'vue'
function isEditing(e: Event) {
return e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
}
const PREVENT_MOD_ALT = new Set(['KeyK', 'KeyB'])
const PREVENT_MOD_SHIFT = new Set(['KeyK', 'KeyH', 'KeyL', 'KeyE', 'KeyS', 'KeyG', 'KeyZ'])
const PREVENT_MOD_ONLY = new Set([
'Backslash',
'KeyJ',
'KeyW',
'KeyN',
'KeyT',
'KeyZ',
'KeyY',
'Digit0',
'Digit1',
'Digit2',
'KeyD',
'KeyA',
'KeyS',
'KeyO',
'KeyG'
])
const PREVENT_SHIFT_ONLY = new Set(['Digit1', 'Digit2', 'KeyA'])
const PREVENT_PLAIN_KEY = new Set(['[', ']'])
const PREVENT_DELETE_KEY = new Set(['Backspace', 'Delete'])
function shouldPreventDefault(e: KeyboardEvent, hasPenState: boolean): boolean {
const mod = e.metaKey || e.ctrlKey
if (mod) {
if (e.altKey && PREVENT_MOD_ALT.has(e.code)) return true
if (e.shiftKey && PREVENT_MOD_SHIFT.has(e.code)) return true
if (!e.shiftKey && !e.altKey && PREVENT_MOD_ONLY.has(e.code)) return true
} else {
if (e.shiftKey && PREVENT_SHIFT_ONLY.has(e.code)) return true
if (!e.shiftKey && PREVENT_PLAIN_KEY.has(e.key)) return true
}
return PREVENT_DELETE_KEY.has(e.key) || (e.key === 'Enter' && hasPenState)
}
export function useKeyboard() {
const { activeTab } = useAIChat()
const store = useEditorStore()
const breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
useEventListener(window, 'copy', (e: ClipboardEvent) => {
if (isEditing(e)) return
e.preventDefault()
if (e.clipboardData) store.writeCopyData(e.clipboardData)
})
useEventListener(window, 'cut', (e: ClipboardEvent) => {
if (isEditing(e)) return
e.preventDefault()
if (e.clipboardData) store.writeCopyData(e.clipboardData)
store.deleteSelected()
})
useEventListener(window, 'paste', (e: ClipboardEvent) => {
if (isEditing(e)) return
e.preventDefault()
const html = e.clipboardData?.getData('text/html') ?? ''
if (html) store.pasteFromHTML(html)
})
const keys = useMagicKeys({
passive: false,
onEventFired(e) {
if (e.type !== 'keydown') return
if (isEditing(e)) return
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
const tool = TOOL_SHORTCUTS[e.key.toLowerCase()]
if (tool) {
store.setTool(tool)
return
}
}
if (shouldPreventDefault(e, !!store.state.penState)) e.preventDefault()
}
})
// Cross-platform mod: true when Meta (Mac) or Control (Win/Linux) is pressed with the combo.
// Checks that no extra modifiers are held beyond what the combo specifies.
function mod(combo: string): ComputedRef<boolean> {
const hasShift = combo.includes('shift')
const hasAlt = combo.includes('alt')
const base = computed(() => keys[`meta+${combo}`].value || keys[`control+${combo}`].value)
if (hasShift && hasAlt) return base
if (hasShift) return computed(() => base.value && !keys['alt'].value)
if (hasAlt) return computed(() => base.value && !keys['shift'].value)
return computed(() => base.value && !keys['shift'].value && !keys['alt'].value)
}
// --- Mod + Alt ---
whenever(mod('alt+keyk'), () => store.createComponentFromSelection())
whenever(mod('alt+keyb'), () => store.detachInstance())
// --- Mod + Shift ---
whenever(mod('shift+keyk'), () => store.createComponentSetFromComponents())
whenever(mod('shift+keyh'), () => store.toggleVisibility())
whenever(mod('shift+keyl'), () => store.toggleLock())
whenever(mod('shift+keye'), () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'PNG')
})
whenever(mod('shift+keys'), () => store.saveFigFileAs())
whenever(mod('shift+keyg'), () => store.ungroupSelected())
whenever(mod('shift+keyz'), () => store.redoAction())
// --- Mod + Key ---
whenever(mod('backslash'), () => {
store.state.showUI = !store.state.showUI
})
whenever(mod('keyj'), () => {
if (isMobile.value) {
store.state.activeRibbonTab = store.state.activeRibbonTab === 'ai' ? 'panels' : 'ai'
if (store.state.mobileDrawerSnap === 'closed') {
store.state.mobileDrawerSnap = 'half'
}
} else {
activeTab.value = activeTab.value === 'ai' ? 'design' : 'ai'
}
})
whenever(mod('keyw'), () => {
if (activeTabRef.value) closeTab(activeTabRef.value.id)
})
whenever(mod('keyn'), () => createTab())
whenever(mod('keyt'), () => createTab())
whenever(mod('keyz'), () => store.undoAction())
whenever(mod('keyy'), () => store.redoAction())
whenever(mod('digit0'), () => store.zoomTo100())
whenever(mod('digit1'), () => store.zoomToFit())
whenever(mod('digit2'), () => store.zoomToSelection())
whenever(mod('keyd'), () => store.duplicateSelected())
whenever(mod('keya'), () => store.selectAll())
whenever(mod('keys'), () => store.saveFigFile())
whenever(mod('keyo'), () => openFileDialog())
whenever(mod('keyg'), () => store.groupSelected())
// --- Shift (no mod) ---
whenever(
computed(() => keys['shift+digit1'].value && !keys['meta'].value && !keys['control'].value),
() => store.zoomToFit()
)
whenever(
computed(() => keys['shift+digit2'].value && !keys['meta'].value && !keys['control'].value),
() => store.zoomToSelection()
)
whenever(
computed(() => keys['shift+keya'].value && !keys['meta'].value && !keys['control'].value),
() => {
const node = store.selectedNode.value
if (node?.type === 'FRAME' && store.selectedNodes.value.length === 1) {
store.setLayoutMode(node.id, node.layoutMode === 'NONE' ? 'VERTICAL' : 'NONE')
} else if (store.selectedNodes.value.length > 0) {
store.wrapInAutoLayout()
}
}
)
// --- Plain keys (no modifiers) ---
function plain(key: string): ComputedRef<boolean> {
return computed(
() =>
keys[key].value &&
!keys['meta'].value &&
!keys['control'].value &&
!keys['shift'].value &&
!keys['alt'].value
)
}
whenever(plain('bracketright'), () => store.bringToFront())
whenever(plain('bracketleft'), () => store.sendToBack())
whenever(plain('backspace'), () => store.deleteSelected())
whenever(plain('delete'), () => store.deleteSelected())
whenever(plain('enter'), () => {
if (store.state.penState) store.penCommit(false)
})
whenever(plain('escape'), () => {
if (store.state.penState) {
store.penCancel()
return
}
store.clearSelection()
store.setTool('SELECT')
})
}