初始化模版工程
This commit is contained in:
839
components/image-editor/components/canvas/use-interactions.ts
Normal file
839
components/image-editor/components/canvas/use-interactions.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user