初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View 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,
}
}