import { reactive, computed, toRaw } from 'vue'
import type { ComputedRef } from 'vue'
import type { PositionType } from '../container'
import { useMultiSelect } from './useMultiSelect'
import { useCanvas } from '@opentiny/tiny-engine-meta-register'
import { NODE_TAG, NODE_UID } from '../../../common'
import {
lineState,
querySelectById,
removeNode,
getController,
getElement,
getConfigure,
allowInsert,
POSITION,
insertNode,
syncNodeScroll,
dragState,
initialDragState,
isAncestor,
getDocument
} from '../container'
interface Position {
x: number
y: number
}
interface Offset {
offsetX: number
offsetY: number
initialX: number
initialY: number
}
interface NodeSchema {
id: string
componentName: string
children?: NodeSchema[]
[key: string]: any
}
interface MultiDragState {
keydown: boolean
draging: boolean
dragStarted: boolean
initialMousePos: Position | null
nodes: NodeSchema[]
offsets: Map<string, Offset>
mouse: Position | null
position: PositionType | null
targetNodeId: string | null
}
interface SelectState {
id: string
componentName: string
schema: NodeSchema
top?: number
left?: number
width?: number
height?: number
doc?: Document
[key: string]: any
}
interface InsertOperation {
sourceId: string
targetNodeData: {
parent: NodeSchema | null
node: NodeSchema
data: NodeSchema
}
position: PositionType
}
const initialMultiDragState: MultiDragState = {
keydown: false,
draging: false,
dragStarted: false,
initialMousePos: null,
nodes: [],
offsets: new Map<string, Offset>(),
mouse: null,
position: null,
targetNodeId: null
}
const DRAG_THRESHOLD = 5
export const useMultiDrag = () => {
const multiDragState = reactive<MultiDragState>({ ...initialMultiDragState })
const { multiSelectedStates } = useMultiSelect()
const multiStateLength = computed<number>(() => (multiSelectedStates.value as SelectState[]).length)
const startMultiDrag = (event: MouseEvent, element: HTMLElement): boolean => {
if (multiStateLength.value <= 1) return false
const clickedNodeId = element?.getAttribute(NODE_UID)
if (!clickedNodeId || !(multiSelectedStates.value as SelectState[]).some((state) => state.id === clickedNodeId)) {
return false
}
const { clientX, clientY } = event
multiDragState.keydown = true
multiDragState.dragStarted = false
multiDragState.draging = false
multiDragState.initialMousePos = { x: clientX, y: clientY }
multiDragState.targetNodeId = clickedNodeId
multiDragState.nodes = toRaw(multiSelectedStates.value as SelectState[]).map((state) => state.schema)
;(multiSelectedStates.value as SelectState[]).forEach((state) => {
const elem = querySelectById(state.id)
if (elem) {
const { x, y } = elem.getBoundingClientRect()
multiDragState.offsets.set(state.id, {
offsetX: clientX - x,
offsetY: clientY - y,
initialX: x,
initialY: y
})
}
})
return true
}
const calculateDropPosition = (
event: MouseEvent,
rect: DOMRect,
configure: { isContainer?: boolean } | null
): PositionType => {
const { clientX: mouseX, clientY: mouseY } = event
const yAbs = Math.min(20, rect.height / 3)
const xAbs = Math.min(20, rect.width / 3)
if (mouseY < rect.top + yAbs) {
return POSITION.TOP
} else if (mouseY > rect.bottom - yAbs) {
return POSITION.BOTTOM
} else if (mouseX < rect.left + xAbs) {
return POSITION.LEFT
} else if (mouseX > rect.right - xAbs) {
return POSITION.RIGHT
} else if (configure?.isContainer) {
return POSITION.IN
}
return POSITION.BOTTOM
}
const calculateDistance = (pos1: Position | null, pos2: Position | null): number => {
if (!pos1 || !pos2) return 0
const dx = pos1.x - pos2.x
const dy = pos1.y - pos2.y
return Math.sqrt(dx * dx + dy * dy)
}
const checkAllowInsert = (
configure: { isContainer?: boolean } | null,
nodes: NodeSchema[],
targetId: string,
position: PositionType
): boolean => {
if (!configure) return false
const { parent: targetParent } = useCanvas().getNodeWithParentById(targetId) || {}
const targetParentId = targetParent?.id
if (targetId === 'body') {
if (position !== POSITION.IN && position !== POSITION.TOP && position !== POSITION.BOTTOM) {
lineState.position = POSITION.IN
}
for (const node of nodes) {
if (!allowInsert({ isContainer: true }, node)) {
return false
}
}
return true
}
if (targetParentId === 'body') {
if (position === POSITION.TOP || position === POSITION.BOTTOM) {
for (const node of nodes) {
if (!allowInsert({ isContainer: true }, node)) {
return false
}
}
return true
}
}
for (const node of nodes) {
if (position === POSITION.IN && isAncestor(node.id, targetId)) {
return false
}
if (
(position === POSITION.TOP ||
position === POSITION.BOTTOM ||
position === POSITION.LEFT ||
position === POSITION.RIGHT) &&
node.id === targetParentId
) {
return false
}
if (position === POSITION.IN) {
if (!allowInsert(configure, node)) {
return false
}
} else {
const parentConfigure = targetParent ? getConfigure(targetParent.componentName) : { isContainer: true }
if (!allowInsert(parentConfigure, node)) {
return false
}
}
}
return true
}
const initDragState = (currentMousePos: Position): boolean => {
if (!multiDragState.dragStarted) {
const distance = calculateDistance(multiDragState.initialMousePos, currentMousePos)
if (distance < DRAG_THRESHOLD) {
return false
}
multiDragState.dragStarted = true
Object.assign(dragState, initialDragState)
}
if (!multiDragState.draging && multiDragState.dragStarted) {
multiDragState.draging = true
}
return multiDragState.draging
}
const handleBodyPlacement = (event: MouseEvent, body: HTMLElement): boolean => {
const { getSchema } = useCanvas()
const bodySchema = getSchema()
const bodyChildren = bodySchema.children || []
if (bodyChildren.length === 0) {
const bodyRect = body.getBoundingClientRect()
Object.assign(lineState, {
id: 'body',
top: bodyRect.top,
left: bodyRect.left,
width: bodyRect.width,
height: bodyRect.height,
position: POSITION.IN,
forbidden: false,
configure: { isContainer: true }
})
return true
}
const { clientY } = event
let closestNode: HTMLElement | null = null
let closestDistance = Infinity
let position: PositionType = POSITION.IN
for (const childSchema of bodyChildren) {
const childElement = querySelectById(childSchema.id)
if (!childElement) continue
const childRect = childElement.getBoundingClientRect()
const childMiddle = childRect.top + childRect.height / 2
const distance = Math.abs(clientY - childMiddle)
if (distance < closestDistance) {
closestDistance = distance
closestNode = childElement
position = clientY < childMiddle ? POSITION.TOP : POSITION.BOTTOM
}
}
if (closestNode) {
const nodeId = closestNode.getAttribute(NODE_UID)
const componentName = closestNode.getAttribute(NODE_TAG)
const configure = getConfigure(componentName)
const rect = closestNode.getBoundingClientRect()
const isForbidden = !checkAllowInsert(configure, multiDragState.nodes, nodeId!, position)
Object.assign(lineState, {
id: nodeId,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
position: position,
forbidden: isForbidden,
configure
})
} else {
const bodyRect = body.getBoundingClientRect()
Object.assign(lineState, {
id: 'body',
top: bodyRect.top,
left: bodyRect.left,
width: bodyRect.width,
height: bodyRect.height,
position: POSITION.IN,
forbidden: false,
configure: { isContainer: true }
})
}
return true
}
const handleSelfNodeDrag = (targetId: string, rect: DOMRect, configure: any, position: PositionType): boolean => {
const { getNodeWithParentById } = useCanvas()
const { parent } = getNodeWithParentById(targetId) || {}
if (!parent) {
lineState.forbidden = true
return true
}
const children = parent.children || []
const targetIndex = children.findIndex((child: NodeSchema) => child.id === targetId)
if ((position === POSITION.BOTTOM || position === POSITION.RIGHT) && targetIndex < children.length - 1) {
const nextSibling = children[targetIndex + 1]
if (nextSibling && !multiDragState.nodes.some((node) => node.id === nextSibling.id)) {
const nextElement = querySelectById(nextSibling.id)
if (nextElement) {
const nextRect = nextElement.getBoundingClientRect()
const nextComponentName = nextElement.getAttribute(NODE_TAG)
const nextConfigure = getConfigure(nextComponentName)
Object.assign(lineState, {
id: nextSibling.id,
top: nextRect.top,
left: nextRect.left,
width: nextRect.width,
height: nextRect.height,
position: POSITION.TOP,
forbidden: !checkAllowInsert(nextConfigure, multiDragState.nodes, nextSibling.id, POSITION.TOP),
configure: nextConfigure
})
return true
}
}
}
if (
position === POSITION.TOP ||
position === POSITION.LEFT ||
(position === POSITION.BOTTOM && targetIndex === children.length - 1) ||
(position === POSITION.RIGHT && targetIndex === children.length - 1)
) {
const isForbidden = !checkAllowInsert(configure, multiDragState.nodes, targetId, position)
Object.assign(lineState, {
id: targetId,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
position: position,
forbidden: isForbidden,
configure
})
return true
}
lineState.forbidden = true
return true
}
const handleContainerPlacement = (targetId: string, rect: DOMRect, configure: any, isForbidden: boolean): boolean => {
const { getNodeWithParentById, getSchema } = useCanvas()
const { node } = targetId === 'body' ? { node: getSchema() } : getNodeWithParentById(targetId) || {}
const children = node?.children || []
if (children.length > 0) {
const lastChild = children[children.length - 1]
if (!multiDragState.nodes.some((node) => node.id === lastChild.id)) {
const childElement = querySelectById(lastChild.id)
if (childElement) {
const childRect = childElement.getBoundingClientRect()
Object.assign(lineState, {
id: targetId,
top: childRect.top,
left: childRect.left,
width: childRect.width,
height: childRect.height,
position: POSITION.IN,
forbidden: isForbidden,
configure
})
return true
}
}
}
Object.assign(lineState, {
id: targetId,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
position: POSITION.IN,
forbidden: isForbidden,
configure
})
return true
}
const updateLineFeedback = (targetId: string, rect: DOMRect, configure: any, position: PositionType): void => {
const isForbidden = !checkAllowInsert(configure, multiDragState.nodes, targetId, position)
if (position === POSITION.IN && configure?.isContainer) {
handleContainerPlacement(targetId, rect, configure, isForbidden)
return
}
Object.assign(lineState, {
id: targetId,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
position,
forbidden: isForbidden,
configure
})
}
const moveMultiDrag = (event: MouseEvent): boolean => {
if (!multiDragState.keydown || multiStateLength.value <= 1) return false
const { clientX, clientY } = event
const currentMousePos: Position = { x: clientX, y: clientY }
multiDragState.mouse = currentMousePos
if (!initDragState(currentMousePos)) {
return true
}
const targetElement = getElement(event.target as HTMLElement)
if (!targetElement) {
const doc = getDocument()
const body = doc.body
if (
event.target === body ||
(event.target as HTMLElement).parentElement === body ||
event.target === doc.documentElement
) {
return handleBodyPlacement(event, body)
}
lineState.position = ''
lineState.forbidden = true
return true
}
const componentName = targetElement.getAttribute(NODE_TAG)
const configure = getConfigure(componentName)
const rect = targetElement.getBoundingClientRect()
const targetId = targetElement.getAttribute(NODE_UID) || 'body'
const position = calculateDropPosition(event, rect, configure)
const isDraggingSelf = multiDragState.nodes.some((node) => node.id === targetId)
if (isDraggingSelf && position !== POSITION.IN) {
return handleSelfNodeDrag(targetId, rect, configure, position)
}
updateLineFeedback(targetId, rect, configure, position)
return true
}
const shouldProcessDrag = (): boolean => {
if (multiStateLength.value <= 1) {
Object.assign(multiDragState, initialMultiDragState)
return false
}
if (!multiDragState.draging && !multiDragState.dragStarted && multiDragState.keydown) {
Object.assign(multiDragState, initialMultiDragState)
return true
}
if (!multiDragState.draging || !multiDragState.dragStarted) {
Object.assign(multiDragState, initialMultiDragState)
return false
}
return true
}
const getTargetNodeInfo = (targetId: string) => {
const { getNodeWithParentById, getSchema } = useCanvas()
const { node: targetNode, parent: targetParent } = getNodeWithParentById(targetId) || {}
const isBodyTarget = targetId === 'body'
const finalTargetNode = isBodyTarget ? getSchema() : targetNode
const finalTargetParent = isBodyTarget ? null : targetParent
return {
targetNode,
targetParent,
isBodyTarget,
finalTargetNode,
finalTargetParent
}
}
const collectDragOperations = (targetInfo: any, position: PositionType | null): InsertOperation[] => {
const { finalTargetNode, finalTargetParent } = targetInfo
const targetId = lineState.id as string
const operations: InsertOperation[] = []
const movingNodeIds = multiDragState.nodes.map((node) => node.id)
multiDragState.nodes.forEach((node) => {
const sourceId = node.id
const { node: sourceNode, parent: sourceParent } = useCanvas().getNodeWithParentById(sourceId) || {}
if (sourceId === targetId) {
return
}
if (position === POSITION.IN && sourceParent?.id === targetId) {
return
}
if (position !== POSITION.IN && finalTargetParent && movingNodeIds.includes(finalTargetParent.id)) {
return
}
const insertData = { ...sourceNode }
const targetNodeData = {
parent: toRaw(finalTargetParent),
node: toRaw(finalTargetNode),
data: { ...insertData, children: insertData.children || [] }
}
operations.push({
sourceId,
targetNodeData,
position: position as PositionType
})
})
return operations
}
const calculateRelativePositions = (nodeIds: string[]): Map<string, { top: number; left: number }> => {
const positions = new Map<string, { top: number; left: number }>()
nodeIds.forEach((id) => {
const elem = querySelectById(id)
if (elem) {
const rect = elem.getBoundingClientRect()
positions.set(id, {
top: rect.top,
left: rect.left
})
}
})
return positions
}
const sortOperationsByPosition = (
operations: InsertOperation[],
positions: Map<string, { top: number; left: number }>,
position: PositionType
): InsertOperation[] => {
const sortedOperations = [...operations].sort((a, b) => {
const posA = positions.get(a.sourceId)
const posB = positions.get(b.sourceId)
if (!posA || !posB) return 0
if (Math.abs(posA.top - posB.top) > 5) {
return posA.top - posB.top
}
return posA.left - posB.left
})
if (position === POSITION.BOTTOM || position === POSITION.RIGHT) {
return sortedOperations.reverse()
} else if (position === POSITION.IN) {
return sortedOperations
} else {
return sortedOperations
}
}
const insertNodeToTarget = (op: InsertOperation, isBodyTarget: boolean, targetId: string) => {
if (isBodyTarget) {
const { getNodeWithParentById } = useCanvas()
const { node: targetChildNode, parent: targetChildParent } = getNodeWithParentById(targetId) || {}
if (targetChildNode && targetChildParent) {
const targetNodeData = {
parent: toRaw(targetChildParent),
node: toRaw(targetChildNode),
data: op.targetNodeData.data
}
insertNode(targetNodeData, op.position, false)
return
}
insertNode({ node: useCanvas().getSchema(), data: op.targetNodeData.data }, POSITION.IN, false)
} else {
insertNode(op.targetNodeData, op.position, false)
}
}
const updateMultiSelectionAfterDrag = (operations: InsertOperation[]) => {
setTimeout(() => {
const newMultiSelection: SelectState[] = []
const newNodeIds = operations.map((op) => op.targetNodeData.data.id)
newNodeIds.forEach((nodeId) => {
const element = querySelectById(nodeId)
if (element) {
const { node } = useCanvas().getNodeWithParentById(nodeId) || {}
if (!node) return
const state: SelectState = {
id: nodeId,
componentName: element.getAttribute(NODE_TAG) || '',
schema: node
}
const rect = element.getBoundingClientRect()
Object.assign(state, {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
doc: getDocument()
})
newMultiSelection.push(state)
}
})
syncNodeScroll()
}, 100)
}
const executeDragOperations = (operations: InsertOperation[], targetInfo: any) => {
const { isBodyTarget } = targetInfo
const targetId = lineState.id as string
const position = lineState.position as PositionType
const nodeIds = operations.map((op) => op.sourceId)
const positions = calculateRelativePositions(nodeIds)
const sortedOperations = sortOperationsByPosition(operations, positions, position)
operations.forEach((op) => {
removeNode(op.sourceId)
})
if (isBodyTarget && position === POSITION.BOTTOM) {
const reorderedOperations = [...sortedOperations].reverse()
reorderedOperations.forEach((op) => {
insertNodeToTarget(op, isBodyTarget, targetId)
})
} else if (position === POSITION.IN) {
sortedOperations.forEach((op) => {
insertNodeToTarget(op, isBodyTarget, targetId)
})
} else {
sortedOperations.forEach((op) => {
insertNodeToTarget(op, isBodyTarget, targetId)
})
}
getController().addHistory()
updateMultiSelectionAfterDrag(sortedOperations)
}
const cleanupDragState = () => {
Object.assign(multiDragState, {
...initialMultiDragState,
nodes: []
})
}
const endMultiDrag = (): boolean => {
if (!shouldProcessDrag()) {
return false
}
const { position, forbidden, id: targetId } = lineState
if (forbidden || !targetId) {
cleanupDragState()
return true
}
const targetInfo = getTargetNodeInfo(targetId)
if (!targetInfo.finalTargetNode) {
cleanupDragState()
return true
}
const operations = collectDragOperations(targetInfo, position as PositionType)
if (operations.length > 0) {
executeDragOperations(operations, targetInfo)
}
cleanupDragState()
return true
}
const isMultiDragging = computed(() => {
return multiDragState.draging && multiDragState.dragStarted && multiStateLength.value > 1
})
const getMultiDragPositionText: ComputedRef<string> = computed(() => {
if (!isMultiDragging.value) return ''
const { position, forbidden, id } = lineState
let targetComponentName = ''
if (id && id !== 'body') {
const targetElement = querySelectById(id)
if (targetElement) {
targetComponentName = targetElement.getAttribute(NODE_TAG) || ''
}
} else if (id === 'body') {
targetComponentName = '页面'
}
if (forbidden) {
return `当前位置不允许放置 (${targetComponentName || '目标节点'})`
}
switch (position) {
case POSITION.TOP:
return `放置到 ${targetComponentName || '目标节点'} 上方`
case POSITION.BOTTOM:
return `放置到 ${targetComponentName || '目标节点'} 下方`
case POSITION.LEFT:
return `放置到 ${targetComponentName || '目标节点'} 左侧`
case POSITION.RIGHT:
return `放置到 ${targetComponentName || '目标节点'} 右侧`
case POSITION.IN:
return `放置到 ${targetComponentName || '容器'} 内部`
default:
return ''
}
})
return {
multiDragState,
getMultiDragPositionText,
startMultiDrag,
moveMultiDrag,
endMultiDrag,
cleanupDragState,
isMultiDragging
}
}