840 lines
28 KiB
TypeScript
840 lines
28 KiB
TypeScript
import { useCallback, useRef, useEffect } from 'react'
|
||
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
|
||
import type { DominoEvents, SceneElement, ArtboardElement } from './domino'
|
||
import { DOMINO_EL_PREFIX } from './constants'
|
||
import { useTransform } from './use-transform'
|
||
import { getElementWorldRect } from './math'
|
||
import { calculateSnap } from './interactions/snapping'
|
||
import { calculateResize } from './interactions/resizing'
|
||
|
||
function isTargetInputOrEditable(target: HTMLElement | null) {
|
||
if (!target) return false
|
||
|
||
// 1. 标准输入元素
|
||
const tagName = target.tagName.toLowerCase()
|
||
if (tagName === 'input' || tagName === 'textarea') return true
|
||
|
||
// 2. 可编辑元素
|
||
return target.isContentEditable
|
||
}
|
||
|
||
type InteractionStatus =
|
||
| 'none'
|
||
| 'idle'
|
||
| 'panning'
|
||
| 'selecting'
|
||
| 'dragging'
|
||
| 'resizing'
|
||
| 'rotating'
|
||
|
||
interface InteractionOptions extends DominoEvents {}
|
||
|
||
export function useInteractions(
|
||
containerRef: React.RefObject<HTMLElement | null>,
|
||
options: InteractionOptions,
|
||
) {
|
||
const { onClick, onElementClick, onTransformEnd } = options
|
||
const store = useDominoStoreInstance()
|
||
const statusRef = useRef<InteractionStatus>('none')
|
||
const startPos = useRef({ x: 0, y: 0 }) // Screen coordinates
|
||
const lastPos = useRef({ x: 0, y: 0 }) // Screen coordinates
|
||
|
||
const { screenToWorld } = useTransform()
|
||
const moveViewport = useDominoStore(state => state.moveViewport)
|
||
const setSelectionBox = useDominoStore(state => state.setSelectionBox)
|
||
const setSnapLines = useDominoStore(state => state.setSnapLines)
|
||
const setFocusedElementId = useDominoStore(state => state.setFocusedElementId)
|
||
const setSelectedIds = useDominoStore(state => state.setSelectedIds)
|
||
|
||
/**
|
||
* 直接通过 DOM API 更新容器状态,避免 React 频繁重绘
|
||
*/
|
||
const updateDOMOverlay = useCallback(
|
||
(gestureStatus: InteractionStatus = 'none', updateCursor = true) => {
|
||
const container = containerRef.current
|
||
if (!container) return
|
||
|
||
const state = store.getState()
|
||
const mainStatus = statusRef.current
|
||
|
||
// 更新类名:支持主状态和手势状态叠加
|
||
const statuses: InteractionStatus[] = [
|
||
'panning',
|
||
'selecting',
|
||
'dragging',
|
||
'resizing',
|
||
'rotating',
|
||
]
|
||
statuses.forEach(s => {
|
||
const isActive = s === mainStatus || s === gestureStatus
|
||
container.classList.toggle(`is-${s}`, isActive)
|
||
})
|
||
|
||
// 更新光标:仅由主状态 (PointerDown) 和工具模式 (Mode) 决定
|
||
if (updateCursor) {
|
||
if (mainStatus === 'panning') {
|
||
container.style.cursor = 'grabbing'
|
||
} else if (mainStatus === 'none') {
|
||
// 恢复到工具模式对应的光标
|
||
container.style.cursor =
|
||
state.mode === 'pan' ? 'grab' : 'url(/arrow-cursor.svg), auto'
|
||
}
|
||
}
|
||
},
|
||
[containerRef, store],
|
||
)
|
||
|
||
const setStatus = useCallback(
|
||
(newStatus: InteractionStatus, updateCursor = true) => {
|
||
statusRef.current = newStatus
|
||
updateDOMOverlay('none', updateCursor)
|
||
},
|
||
[updateDOMOverlay],
|
||
)
|
||
|
||
// 监听交互模式切换(手动订阅以保持 updateDOMOverlay 恒定)
|
||
useEffect(() => {
|
||
// 初始执行一次
|
||
updateDOMOverlay('none', true)
|
||
|
||
// 监听 store 中的模式变化
|
||
let lastMode = store.getState().mode
|
||
const unsubscribe = store.subscribe(state => {
|
||
if (state.mode !== lastMode) {
|
||
lastMode = state.mode
|
||
updateDOMOverlay('none', true)
|
||
}
|
||
})
|
||
return unsubscribe
|
||
}, [updateDOMOverlay, store])
|
||
|
||
const resizeHandle = useRef<string | null>(null) // 'nw', 'ne', 'sw', 'se', 'ml', 'mr'
|
||
const startRotation = useRef(0)
|
||
const startAngle = useRef(0)
|
||
const dragStartElements = useRef<
|
||
Record<
|
||
string,
|
||
{
|
||
x: number
|
||
y: number
|
||
width: number
|
||
height: number
|
||
fontSize?: number
|
||
originalParentId?: string
|
||
originalIndex?: number
|
||
}
|
||
>
|
||
>({})
|
||
const isSnappedToOriginal = useRef(false)
|
||
const hasMoved = useRef(false)
|
||
|
||
const onDoubleClickCapture = useCallback((e: React.MouseEvent) => {
|
||
const target = e.target as HTMLElement
|
||
if (isTargetInputOrEditable(target)) return
|
||
|
||
const state = store.getState()
|
||
const { elements: currentElements, selectedIds, focusedElementId } = state
|
||
const elementId = focusedElementId || selectedIds[0]
|
||
const element = elementId ? currentElements[elementId] : null
|
||
|
||
if (elementId && element?.type === 'text') {
|
||
setFocusedElementId(elementId)
|
||
setSelectedIds(selectedIds.filter(id => id !== elementId))
|
||
}
|
||
}, [])
|
||
|
||
const onPointerDown = useCallback(
|
||
(e: React.PointerEvent) => {
|
||
// Ignore right click for interactions to prevent conflict with browser context menu
|
||
if (e.button === 2) return
|
||
|
||
const target = e.target as HTMLElement
|
||
if (isTargetInputOrEditable(target)) return
|
||
|
||
const state = store.getState()
|
||
const {
|
||
elements: currentElements,
|
||
placeholders: currentPlaceholders,
|
||
selectedIds: currentSelectedIds,
|
||
mode: currentMode,
|
||
readOnly: isReadOnly,
|
||
} = state
|
||
|
||
const elementEl = target.closest('.domino-element') as HTMLElement | null
|
||
const rawId = elementEl?.id
|
||
const elementId = rawId?.startsWith(DOMINO_EL_PREFIX)
|
||
? rawId.slice(DOMINO_EL_PREFIX.length)
|
||
: rawId
|
||
const element = elementId
|
||
? currentElements[elementId] || currentPlaceholders[elementId]
|
||
: null
|
||
const isElement = !!(element && element.selectable !== false)
|
||
|
||
const handleEl = target.closest(
|
||
'.domino-resize-handle',
|
||
) as HTMLElement | null
|
||
const rotateEl = target.closest(
|
||
'.domino-rotate-handle',
|
||
) as HTMLElement | null
|
||
const multiSelectEl = target.closest(
|
||
'.domino-multi-select-area',
|
||
) as HTMLElement | null
|
||
const isBackground = target === containerRef.current
|
||
|
||
// Determine interaction type based on target and current state
|
||
let interactionType: InteractionStatus = 'idle'
|
||
|
||
if (isReadOnly) {
|
||
// If read-only, only allow panning or element focus/click
|
||
if (
|
||
e.button === 1 ||
|
||
(e.button === 0 && e.altKey) ||
|
||
currentMode === 'pan'
|
||
) {
|
||
interactionType = 'panning'
|
||
} else if (isElement && elementId) {
|
||
// Allow focusing/clicking elements even if read-only
|
||
state.setFocusedElementId(elementId)
|
||
if (!e.shiftKey) {
|
||
state.setSelectedIds([elementId])
|
||
}
|
||
// Stay in 'idle' for click detection in onPointerUp
|
||
interactionType = 'idle'
|
||
} else {
|
||
// No interaction for other cases in read-only mode
|
||
return
|
||
}
|
||
} else {
|
||
// Not read-only
|
||
if (
|
||
e.button === 1 ||
|
||
(e.button === 0 && e.altKey) ||
|
||
currentMode === 'pan'
|
||
) {
|
||
interactionType = 'panning'
|
||
} else if (isElement && elementId) {
|
||
const id = elementId
|
||
const uiState = state.elementUIStates[id]
|
||
|
||
// CRITICAL: If the element is already focused (editing text),
|
||
// we MUST BALE OUT to allow native cursor movement and avoid clearing focus.
|
||
if (
|
||
id === state.focusedElementId &&
|
||
element.type === 'text' &&
|
||
!store.getState().selectedIds.includes(id)
|
||
) {
|
||
setStatus('none')
|
||
return
|
||
}
|
||
|
||
if (uiState?.status === 'readonly') {
|
||
state.setFocusedElementId(id)
|
||
if (!e.shiftKey) {
|
||
state.setSelectedIds([id])
|
||
}
|
||
// Stay in 'idle' for click detection in onPointerUp
|
||
interactionType = 'idle'
|
||
} else if (element.type === 'placeholder') {
|
||
// Placeholder: Focusable but not selectable
|
||
state.setFocusedElementId(id)
|
||
if (!e.shiftKey) {
|
||
state.setSelectedIds([])
|
||
}
|
||
// Stay in 'idle' for click detection in onPointerUp
|
||
interactionType = 'idle'
|
||
} else {
|
||
// Element interaction: clicking selects it (shows dots)
|
||
state.setFocusedElementId(null)
|
||
|
||
let targetIds = currentSelectedIds
|
||
if (e.shiftKey) {
|
||
if (currentSelectedIds.includes(id)) {
|
||
// Toggle off
|
||
targetIds = currentSelectedIds.filter(sid => sid !== id)
|
||
} else {
|
||
// Toggle on
|
||
targetIds = [...currentSelectedIds, id]
|
||
}
|
||
state.setSelectedIds(targetIds)
|
||
} else {
|
||
if (!currentSelectedIds.includes(id)) {
|
||
// New single selection
|
||
targetIds = [id]
|
||
state.setSelectedIds(targetIds)
|
||
}
|
||
// If already selected, we keep targetIds = selectedIds to allow group drag
|
||
}
|
||
|
||
// Only start dragging if the clicked element is part of the final selection
|
||
if (targetIds.includes(id)) {
|
||
const draggableIds = targetIds.filter(tid => {
|
||
const el = currentElements[tid] || currentPlaceholders[tid]
|
||
return el?.draggable !== false
|
||
})
|
||
if (draggableIds.length === 0) {
|
||
setStatus('none')
|
||
return
|
||
}
|
||
// Store initial positions for all elements to be moved
|
||
const startPositions: Record<
|
||
string,
|
||
{
|
||
x: number
|
||
y: number
|
||
width: number
|
||
height: number
|
||
originalParentId?: string
|
||
originalIndex?: number
|
||
}
|
||
> = {}
|
||
draggableIds.forEach(did => {
|
||
const el = currentElements[did] || currentPlaceholders[did]
|
||
if (el) {
|
||
startPositions[did] = {
|
||
x: el.x,
|
||
y: el.y,
|
||
width: el.width,
|
||
height: el.height,
|
||
originalParentId: el.parentId,
|
||
originalIndex: el.parentId
|
||
? (
|
||
currentElements[el.parentId] as ArtboardElement
|
||
)?.childrenIds?.indexOf(did)
|
||
: state.elementOrder.indexOf(did),
|
||
}
|
||
}
|
||
})
|
||
dragStartElements.current = startPositions
|
||
interactionType = 'dragging'
|
||
} else {
|
||
interactionType = 'none'
|
||
}
|
||
}
|
||
} else if (handleEl) {
|
||
// Handle resizing
|
||
interactionType = 'resizing'
|
||
resizeHandle.current = handleEl.dataset.handle || ''
|
||
const id = currentSelectedIds[0]
|
||
const el = currentElements[id] || currentPlaceholders[id]
|
||
if (el) {
|
||
dragStartElements.current = {
|
||
[id]: {
|
||
x: el.x,
|
||
y: el.y,
|
||
width: el.width,
|
||
height: el.height,
|
||
fontSize: el.type === 'text' ? el.fontSize : undefined,
|
||
},
|
||
}
|
||
isSnappedToOriginal.current = false
|
||
}
|
||
} else if (rotateEl) {
|
||
// Handle rotating
|
||
interactionType = 'rotating'
|
||
const id = currentSelectedIds[0]
|
||
const el = currentElements[id] || currentPlaceholders[id]
|
||
if (el) {
|
||
startRotation.current = el.rotation || 0
|
||
const rect = containerRef.current?.getBoundingClientRect()
|
||
const worldPos = screenToWorld(e.clientX, e.clientY, rect)
|
||
const worldRect = getElementWorldRect(el, currentElements)
|
||
const centerX = worldRect.x + worldRect.width / 2
|
||
const centerY = worldRect.y + worldRect.height / 2
|
||
startAngle.current = Math.atan2(
|
||
worldPos.y - centerY,
|
||
worldPos.x - centerX,
|
||
)
|
||
}
|
||
} else if (multiSelectEl && currentSelectedIds.length > 1) {
|
||
// Handle dragging for multiple selected elements
|
||
const draggableIds = currentSelectedIds.filter(id => {
|
||
const el = currentElements[id] || currentPlaceholders[id]
|
||
return el?.draggable !== false
|
||
})
|
||
if (draggableIds.length > 0) {
|
||
const startPositions: Record<
|
||
string,
|
||
{
|
||
x: number
|
||
y: number
|
||
width: number
|
||
height: number
|
||
originalParentId?: string
|
||
originalIndex?: number
|
||
}
|
||
> = {}
|
||
draggableIds.forEach(id => {
|
||
const el = currentElements[id] || currentPlaceholders[id]
|
||
if (el) {
|
||
startPositions[id] = {
|
||
x: el.x,
|
||
y: el.y,
|
||
width: el.width,
|
||
height: el.height,
|
||
originalParentId: el.parentId,
|
||
originalIndex: el.parentId
|
||
? (
|
||
currentElements[el.parentId] as ArtboardElement
|
||
)?.childrenIds?.indexOf(id)
|
||
: state.elementOrder.indexOf(id),
|
||
}
|
||
}
|
||
})
|
||
dragStartElements.current = startPositions
|
||
interactionType = 'dragging'
|
||
} else {
|
||
interactionType = 'none'
|
||
}
|
||
} else if (isBackground) {
|
||
// Background interaction: only selection, no panning by default
|
||
state.setFocusedElementId(null)
|
||
if (!e.shiftKey) {
|
||
state.setSelectedIds([])
|
||
}
|
||
interactionType = 'selecting'
|
||
} else {
|
||
// If not an element, not background, and not a special UI handle, then bail out.
|
||
// This covers clicks on overlays that are not part of the canvas interaction.
|
||
return
|
||
}
|
||
}
|
||
|
||
startPos.current = { x: e.clientX, y: e.clientY }
|
||
lastPos.current = { x: e.clientX, y: e.clientY }
|
||
hasMoved.current = false
|
||
|
||
if (containerRef.current) {
|
||
containerRef.current.setPointerCapture(e.pointerId)
|
||
containerRef.current.focus()
|
||
}
|
||
|
||
setStatus(interactionType)
|
||
},
|
||
[setStatus, containerRef, screenToWorld, store],
|
||
)
|
||
|
||
const onPointerMove = useCallback(
|
||
(e: React.PointerEvent) => {
|
||
const status = statusRef.current
|
||
if (status === 'none') return
|
||
|
||
const dx = e.clientX - lastPos.current.x
|
||
const dy = e.clientY - lastPos.current.y
|
||
|
||
// 距离阈值检测:防止微小手抖触发误操作
|
||
const dist = Math.sqrt(
|
||
(e.clientX - startPos.current.x) ** 2 +
|
||
(e.clientY - startPos.current.y) ** 2,
|
||
)
|
||
const THRESHOLD = 3
|
||
const isThresholdMet = dist >= THRESHOLD || hasMoved.current
|
||
|
||
// 仅针对拖拽、缩放、旋转、框选应用阈值;平移(panning)保持即时响应
|
||
if (status !== 'panning' && status !== 'idle' && !isThresholdMet) {
|
||
return
|
||
}
|
||
|
||
e.preventDefault()
|
||
|
||
const state = store.getState()
|
||
const { scale } = state.viewport
|
||
|
||
const {
|
||
selectedIds: currentSelectedIds,
|
||
elements: currentElements,
|
||
placeholders: currentPlaceholders,
|
||
} = state
|
||
|
||
if (status === 'panning') {
|
||
moveViewport(dx, dy)
|
||
lastPos.current = { x: e.clientX, y: e.clientY }
|
||
} else if (status === 'dragging') {
|
||
const totalDx = (e.clientX - startPos.current.x) / scale
|
||
const totalDy = (e.clientY - startPos.current.y) / scale
|
||
|
||
// Only snap if not holding Command (Mac) or Control (Windows/Linux)
|
||
const shouldSnap = !e.metaKey && !e.ctrlKey
|
||
const { snapOffsetX, snapOffsetY, finalSnapLines } = calculateSnap(
|
||
currentSelectedIds,
|
||
dragStartElements.current,
|
||
currentElements,
|
||
currentPlaceholders,
|
||
totalDx,
|
||
totalDy,
|
||
scale,
|
||
shouldSnap,
|
||
)
|
||
|
||
state.setSnapLines(finalSnapLines)
|
||
|
||
// 脱离/吸附画板逻辑
|
||
const rect = containerRef.current?.getBoundingClientRect()
|
||
const mouseWorld = screenToWorld(e.clientX, e.clientY, rect)
|
||
|
||
// 查找当前鼠标下方的画板(排除当前选中的元素本身及其子树,避免循环嵌套)
|
||
const artboardUnderMouse = Object.values(currentElements)
|
||
.filter(
|
||
el => el.type === 'artboard' && !currentSelectedIds.includes(el.id),
|
||
)
|
||
.reverse()
|
||
.find(ab => {
|
||
const abWorld = getElementWorldRect(ab, currentElements)
|
||
return (
|
||
mouseWorld.x >= abWorld.x &&
|
||
mouseWorld.x <= abWorld.x + abWorld.width &&
|
||
mouseWorld.y >= abWorld.y &&
|
||
mouseWorld.y <= abWorld.y + abWorld.height
|
||
)
|
||
})
|
||
|
||
// 按照当前的视觉层级顺序(从小到大)进行排序,避免在批量移动到同一个父级时层级被打乱
|
||
const sortedSelectedIds = [...currentSelectedIds].sort((a, b) => {
|
||
const elA = currentElements[a]
|
||
const elB = currentElements[b]
|
||
if (elA?.parentId === elB?.parentId) {
|
||
const siblings = elA?.parentId
|
||
? (currentElements[elA.parentId] as ArtboardElement)?.childrenIds
|
||
: state.elementOrder
|
||
return siblings.indexOf(a) - siblings.indexOf(b)
|
||
}
|
||
return 0
|
||
})
|
||
|
||
sortedSelectedIds.forEach(id => {
|
||
const el = currentElements[id]
|
||
if (!el || el.type === 'artboard') return
|
||
|
||
// 仅处理选中项中的顶层元素
|
||
let isTopSelected = true
|
||
let pId = el.parentId
|
||
while (pId) {
|
||
if (currentSelectedIds.includes(pId)) {
|
||
isTopSelected = false
|
||
break
|
||
}
|
||
pId = currentElements[pId]?.parentId || ''
|
||
}
|
||
if (!isTopSelected) return
|
||
|
||
// 获取元素当前所属的顶层画板
|
||
let currentArtboard: SceneElement | null = null
|
||
let curr = el
|
||
while (curr.parentId) {
|
||
const p = currentElements[curr.parentId]
|
||
if (!p) break
|
||
if (p.type === 'artboard') {
|
||
currentArtboard = p
|
||
break
|
||
}
|
||
curr = p
|
||
}
|
||
|
||
const targetArtboardId = artboardUnderMouse?.id || null
|
||
|
||
// 情况 1: 如果当前在某个画板内
|
||
if (currentArtboard) {
|
||
const cbWorld = getElementWorldRect(
|
||
currentArtboard,
|
||
currentElements,
|
||
)
|
||
const isInsideCurrent =
|
||
mouseWorld.x >= cbWorld.x &&
|
||
mouseWorld.x <= cbWorld.x + cbWorld.width &&
|
||
mouseWorld.y >= cbWorld.y &&
|
||
mouseWorld.y <= cbWorld.y + cbWorld.height
|
||
|
||
// 如果鼠标移出了当前所在的画板,则执行切换(移入新画板或移回根级)
|
||
if (!isInsideCurrent) {
|
||
const worldRect = getElementWorldRect(el, currentElements)
|
||
const initialWorldX = worldRect.x - totalDx
|
||
const initialWorldY = worldRect.y - totalDy
|
||
|
||
const startMeta = dragStartElements.current[id]
|
||
const targetIndex =
|
||
targetArtboardId === startMeta?.originalParentId
|
||
? startMeta.originalIndex
|
||
: undefined
|
||
|
||
state.moveElementToParent(id, targetArtboardId, targetIndex)
|
||
|
||
// 关键:同步更新拖拽起始点引用,使其在新的坐标系下保持一致
|
||
if (dragStartElements.current[id]) {
|
||
let relX = initialWorldX
|
||
let relY = initialWorldY
|
||
if (targetArtboardId && artboardUnderMouse) {
|
||
const parentWorld = getElementWorldRect(
|
||
artboardUnderMouse,
|
||
currentElements,
|
||
)
|
||
relX -= parentWorld.x
|
||
relY -= parentWorld.y
|
||
}
|
||
dragStartElements.current[id].x = relX
|
||
dragStartElements.current[id].y = relY
|
||
}
|
||
}
|
||
} else if (targetArtboardId && el.parentId !== targetArtboardId) {
|
||
// 情况 2: 如果当前不在画板内,且鼠标进入了一个画板
|
||
const worldRect = getElementWorldRect(el, currentElements)
|
||
const initialWorldX = worldRect.x - totalDx
|
||
const initialWorldY = worldRect.y - totalDy
|
||
|
||
const startMeta = dragStartElements.current[id]
|
||
const targetIndex =
|
||
targetArtboardId === startMeta?.originalParentId
|
||
? startMeta.originalIndex
|
||
: undefined
|
||
|
||
state.moveElementToParent(id, targetArtboardId, targetIndex)
|
||
|
||
if (dragStartElements.current[id]) {
|
||
let relX = initialWorldX
|
||
let relY = initialWorldY
|
||
if (artboardUnderMouse) {
|
||
const parentWorld = getElementWorldRect(
|
||
artboardUnderMouse,
|
||
currentElements,
|
||
)
|
||
relX -= parentWorld.x
|
||
relY -= parentWorld.y
|
||
}
|
||
dragStartElements.current[id].x = relX
|
||
dragStartElements.current[id].y = relY
|
||
}
|
||
}
|
||
})
|
||
|
||
// Apply movement with snap offset
|
||
currentSelectedIds.forEach(id => {
|
||
const start = dragStartElements.current[id]
|
||
if (start) {
|
||
// Record history before first movement
|
||
if (!hasMoved.current && currentElements[id]) {
|
||
state.takeSnapshot()
|
||
hasMoved.current = true
|
||
}
|
||
|
||
state.updateElement(id, {
|
||
x: start.x + totalDx + snapOffsetX,
|
||
y: start.y + totalDy + snapOffsetY,
|
||
})
|
||
}
|
||
})
|
||
} else if (status === 'resizing' && currentSelectedIds.length > 0) {
|
||
const id = currentSelectedIds[0]
|
||
const startEl = dragStartElements.current[id]
|
||
if (!startEl) return
|
||
|
||
const handle = resizeHandle.current
|
||
if (!handle) return
|
||
|
||
const el = currentElements[id] || currentPlaceholders[id]
|
||
if (!el) return
|
||
|
||
const totalWdx = (e.clientX - startPos.current.x) / scale
|
||
const totalWdy = (e.clientY - startPos.current.y) / scale
|
||
|
||
const { updates, isSnappedToOriginal: snapped } = calculateResize(
|
||
id,
|
||
startEl,
|
||
el,
|
||
handle,
|
||
totalWdx,
|
||
totalWdy,
|
||
scale,
|
||
e.shiftKey,
|
||
e.altKey,
|
||
)
|
||
|
||
isSnappedToOriginal.current = snapped
|
||
|
||
// Record history before first movement
|
||
if (!hasMoved.current && currentElements[id]) {
|
||
state.takeSnapshot()
|
||
hasMoved.current = true
|
||
}
|
||
|
||
state.updateElement(id, updates)
|
||
} else if (status === 'rotating' && currentSelectedIds.length > 0) {
|
||
const id = currentSelectedIds[0]
|
||
const el = currentElements[id] || currentPlaceholders[id]
|
||
if (!el) return
|
||
|
||
const rect = containerRef.current?.getBoundingClientRect()
|
||
const worldPos = screenToWorld(e.clientX, e.clientY, rect)
|
||
const worldRect = getElementWorldRect(el, currentElements)
|
||
const centerX = worldRect.x + worldRect.width / 2
|
||
const centerY = worldRect.y + worldRect.height / 2
|
||
|
||
const currentAngle = Math.atan2(
|
||
worldPos.y - centerY,
|
||
worldPos.x - centerX,
|
||
)
|
||
const angleDiff = ((currentAngle - startAngle.current) * 180) / Math.PI
|
||
|
||
let newRotation = startRotation.current + angleDiff
|
||
|
||
// Snap to 15 degree increments if Shift is held
|
||
if (e.shiftKey) {
|
||
newRotation = Math.round(newRotation / 15) * 15
|
||
} else {
|
||
// Magnetic snapping to cardinal angles
|
||
const snapAngles = [0, 45, 90, 135, 180, 225, 270, 315]
|
||
const snapThreshold = 3
|
||
const normalized = ((newRotation % 360) + 360) % 360
|
||
for (const target of snapAngles) {
|
||
let diff = normalized - target
|
||
if (diff > 180) diff -= 360
|
||
if (diff < -180) diff += 360
|
||
if (Math.abs(diff) < snapThreshold) {
|
||
newRotation -= diff
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Record history before first movement
|
||
if (!hasMoved.current && currentElements[id]) {
|
||
state.takeSnapshot()
|
||
hasMoved.current = true
|
||
}
|
||
|
||
state.updateElement(id, { rotation: newRotation })
|
||
} else if (status === 'selecting') {
|
||
const rect = containerRef.current?.getBoundingClientRect()
|
||
const currentWorld = screenToWorld(e.clientX, e.clientY, rect)
|
||
const startWorld = screenToWorld(
|
||
startPos.current.x,
|
||
startPos.current.y,
|
||
rect,
|
||
)
|
||
|
||
const x = Math.min(startWorld.x, currentWorld.x)
|
||
const y = Math.min(startWorld.y, currentWorld.y)
|
||
const width = Math.abs(startWorld.x - currentWorld.x)
|
||
const height = Math.abs(startWorld.y - currentWorld.y)
|
||
|
||
const box = { x, y, width, height }
|
||
state.setSelectionBox(box)
|
||
state.setFocusedElementId(null)
|
||
|
||
// Update selection in real-time
|
||
const newSelectedIds = Object.values(currentElements)
|
||
.filter(el => {
|
||
if (
|
||
el.type === 'artboard' ||
|
||
el.type === 'placeholder' ||
|
||
el.selectable === false
|
||
)
|
||
return false
|
||
const worldRect = getElementWorldRect(el, currentElements)
|
||
const elXMax = worldRect.x + worldRect.width
|
||
const elYMax = worldRect.y + worldRect.height
|
||
const boxXMax = x + width
|
||
const boxYMax = y + height
|
||
|
||
return !(
|
||
elXMax < x ||
|
||
worldRect.x > boxXMax ||
|
||
elYMax < y ||
|
||
worldRect.y > boxYMax
|
||
)
|
||
})
|
||
.map(el => el.id)
|
||
|
||
state.setSelectedIds(newSelectedIds)
|
||
|
||
// Manual hover detection during selection (pointer is captured)
|
||
const hovered = Object.values(currentElements)
|
||
.reverse() // Check from top to bottom
|
||
.find(el => {
|
||
if (el.type === 'artboard' || el.selectable === false) return false
|
||
const worldRect = getElementWorldRect(el, currentElements)
|
||
return (
|
||
currentWorld.x >= worldRect.x &&
|
||
currentWorld.x <= worldRect.x + worldRect.width &&
|
||
currentWorld.y >= worldRect.y &&
|
||
currentWorld.y <= worldRect.y + worldRect.height
|
||
)
|
||
})
|
||
state.setHoveredElementId(hovered ? hovered.id : null)
|
||
}
|
||
|
||
lastPos.current = { x: e.clientX, y: e.clientY }
|
||
},
|
||
[moveViewport, screenToWorld, store, containerRef],
|
||
)
|
||
|
||
const onPointerUp = useCallback(
|
||
(e: React.PointerEvent) => {
|
||
// ONLY handle if we started an interaction in onPointerDown
|
||
if (statusRef.current === 'none') return
|
||
|
||
const state = store.getState()
|
||
const dist = Math.sqrt(
|
||
(e.clientX - startPos.current.x) ** 2 +
|
||
(e.clientY - startPos.current.y) ** 2,
|
||
)
|
||
|
||
// Click detection: distance moved is small
|
||
if (dist < 5) {
|
||
const target = e.target as HTMLElement
|
||
const elementEl = target.closest('.domino-element')
|
||
const id = elementEl?.id
|
||
|
||
onClick?.(e, id)
|
||
|
||
if (id) {
|
||
const element = state.elements[id] || state.placeholders[id]
|
||
if (element) {
|
||
onElementClick?.(id, element)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Transform end detection
|
||
if (hasMoved.current) {
|
||
onTransformEnd?.(state.selectedIds)
|
||
}
|
||
|
||
setStatus('none')
|
||
hasMoved.current = false
|
||
setSelectionBox(null)
|
||
setSnapLines(null)
|
||
if (containerRef.current) {
|
||
containerRef.current.releasePointerCapture(e.pointerId)
|
||
}
|
||
},
|
||
[
|
||
containerRef,
|
||
setSnapLines,
|
||
setSelectionBox,
|
||
setStatus,
|
||
onClick,
|
||
onElementClick,
|
||
onTransformEnd,
|
||
store,
|
||
],
|
||
)
|
||
|
||
const onPointerCancel = useCallback(
|
||
(e: React.PointerEvent) => {
|
||
setStatus('none')
|
||
setSelectionBox(null)
|
||
setSnapLines(null)
|
||
if (containerRef.current) {
|
||
containerRef.current.releasePointerCapture(e.pointerId)
|
||
}
|
||
},
|
||
[setStatus, setSelectionBox, setSnapLines, containerRef],
|
||
)
|
||
|
||
return {
|
||
onDoubleClickCapture,
|
||
onPointerDown,
|
||
onPointerMove,
|
||
onPointerUp,
|
||
onPointerCancel,
|
||
}
|
||
}
|