Files
test1/components/image-editor/components/canvas/use-interactions.ts
2026-03-20 07:33:46 +00:00

840 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}