diff --git a/packages/canvas/container/src/CanvasContainer.vue b/packages/canvas/container/src/CanvasContainer.vue index 921101b866..3b4a1d0360 100644 --- a/packages/canvas/container/src/CanvasContainer.vue +++ b/packages/canvas/container/src/CanvasContainer.vue @@ -8,10 +8,18 @@ :windowGetClickEventTarget="target" :resize="canvasState.type === 'absolute'" :multiStateLength="multiStateLength" + :isMultiDragging="isMultiDragging" @select-slot="selectSlot" @setting="settingModel" > + @@ -62,7 +70,9 @@ import CanvasViewerSwitcher from './components/CanvasViewerSwitcher.vue' import CanvasResize from './components/CanvasResize.vue' import CanvasDivider from './components/CanvasDivider.vue' import CanvasResizeBorder from './components/CanvasResizeBorder.vue' +import CanvasMultiDragIndicator from './components/CanvasMultiDragIndicator.vue' import { useMultiSelect } from './composables/useMultiSelect' +import { useMultiDrag } from './composables/useMultiDrag' import { canvasState, onMouseUp, @@ -92,7 +102,8 @@ export default { CanvasDivider, CanvasResizeBorder, CanvasRouterJumper, - CanvasViewerSwitcher + CanvasViewerSwitcher, + CanvasMultiDragIndicator }, props: { controller: Object, @@ -113,9 +124,30 @@ export default { const containerPanel = ref(null) const insertContainer = ref(false) - const { multiSelectedStates } = useMultiSelect() + const DRAG_TYPE = { + // 无拖拽 + NONE: 'none', + // 单选拖拽 + SINGLE: 'single', + // 多选拖拽 + MULTI: 'multi' + } + + // 当前拖拽类型状态 + const currentDragType = ref(DRAG_TYPE.NONE) + + const { multiSelectedStates, isMouseDown } = useMultiSelect() const multiStateLength = computed(() => multiSelectedStates.value.length) + const { + startMultiDrag, + moveMultiDrag, + endMultiDrag, + isMultiDragging, + getMultiDragPositionText, + multiDragState, + cleanupDragState + } = useMultiDrag() const computedSelectState = computed(() => { if (multiSelectedStates.value.length === 1) { @@ -125,13 +157,37 @@ export default { return initialRectState }) + // 强制清除所有拖拽指示状态 + const clearAllDragStates = () => { + clearLineState() + cleanupDragState() + currentDragType.value = DRAG_TYPE.NONE + } + const setCurrentNode = async (event) => { const { clientX, clientY } = event const element = getElement(event.target) closeMenu() + + if (!element) return + + // 优先处理右键菜单 + if (event.button === 2) { + openMenu(event) + return + } + let node = getCurrent().schema if (element) { + // 首先尝试处理多选拖拽开始 + if (startMultiDrag(event, element)) { + // 设置为多选拖拽状态 + currentDragType.value = DRAG_TYPE.MULTI + return + } + + // 只有当不是多选拖拽的情况下,才进行选择操作 const currentElement = querySelectById(getCurrent().schema?.id) // 如果是点击右键则打开右键菜单 @@ -150,10 +206,14 @@ export default { } } + // 处理单节点拖拽开始 if (event.button === 0 && element !== element.ownerDocument.body) { const { x, y } = element.getBoundingClientRect() - - dragStart(node, element, { offsetX: clientX - x, offsetY: clientY - y }) + if (multiStateLength.value === 1) { + dragStart(node, element, { offsetX: clientX - x, offsetY: clientY - y }) + // 设置为单选拖拽状态 + currentDragType.value = DRAG_TYPE.SINGLE + } } } } @@ -207,9 +267,10 @@ export default { const doc = iframe.value.contentDocument const win = iframe.value.contentWindow + // eslint-disable-next-line @typescript-eslint/no-unused-vars let isScrolling = false - // 以下是内部iframe监听的事件 + // 监听鼠标按下事件 win.addEventListener('mousedown', (event) => { handleCanvasEvent(() => { // html元素使用scroll和mouseup事件处理 @@ -223,6 +284,10 @@ export default { return } + isMouseDown.value = true + // 重置拖拽状态 + currentDragType.value = DRAG_TYPE.NONE + insertPosition.value = false insertContainer.value = false setCurrentNode(event) @@ -236,32 +301,92 @@ export default { isScrolling = true }) - win.addEventListener('mouseup', (event) => { - if (event.target !== doc.documentElement || isScrolling) { - return - } + // 监听鼠标移动事件 + win.addEventListener('mousemove', (ev) => { + handleCanvasEvent(() => { + // 根据当前拖拽类型执行相应操作 + switch (currentDragType.value) { + case DRAG_TYPE.MULTI: + moveMultiDrag(ev) + break + case DRAG_TYPE.SINGLE: + dragMove(ev, true) + break + case DRAG_TYPE.NONE: + // 如果尚未确定拖拽类型,尝试确定 + if (isMouseDown.value) { + if (multiDragState.keydown) { + currentDragType.value = DRAG_TYPE.MULTI + moveMultiDrag(ev) + } else if (dragState.element) { + currentDragType.value = DRAG_TYPE.SINGLE + dragMove(ev, true) + } + } + break + } + }) + }) + + // 监听拖拽结束事件 + win.addEventListener('mouseup', (ev) => { + handleCanvasEvent(() => { + if (ev.button === 0 && isMouseDown.value) { + isMouseDown.value = false + + // 判断是否需要切换到单选状态 + // 只有当点击多选节点但没有拖动时,才需要切换到单选状态 + if (multiDragState.keydown && !multiDragState.dragStarted && multiStateLength.value > 1) { + const element = getElement(ev.target) + if (element) { + const clickedNodeId = element?.getAttribute(NODE_UID) + // 只有点击的是多选节点中的一个时才切换到单选 + if (clickedNodeId && multiSelectedStates.value.some((state) => state.id === clickedNodeId)) { + selectNode(clickedNodeId) + } + } + } + } + + // 根据当前拖拽类型执行相应的结束操作 + switch (currentDragType.value) { + case DRAG_TYPE.MULTI: + endMultiDrag() + break + case DRAG_TYPE.SINGLE: + onMouseUp(ev) + break + } - insertPosition.value = false - insertContainer.value = false - setCurrentNode(event) - target.value = event.target + clearAllDragStates() + }) }) + // 监听拖拽过程事件 win.addEventListener('dragover', (ev) => { ev.dataTransfer.dropEffect = 'move' ev.preventDefault() - dragMove(ev) + + // 根据当前拖拽类型执行相应操作 + if (currentDragType.value === DRAG_TYPE.MULTI) { + moveMultiDrag(ev) + } else { + dragMove(ev) + } }) + // 监听放置事件 win.addEventListener('drop', (ev) => { ev.preventDefault() - onMouseUp(ev) - }) - win.addEventListener('mousemove', (ev) => { - handleCanvasEvent(() => { - dragMove(ev, true) - }) + // 根据当前拖拽类型执行相应的结束操作 + if (currentDragType.value === DRAG_TYPE.MULTI) { + endMultiDrag() + } else { + onMouseUp(ev) + } + + clearAllDragStates() }) // 阻止浏览器默认的右键菜单功能 @@ -328,6 +453,7 @@ export default { document.addEventListener('canvasReady', canvasReady) return { + isMouseDown, iframe, dragState, hoverState, @@ -348,7 +474,10 @@ export default { insertPosition, insertContainer, loading, - srcAttrName + srcAttrName, + isMultiDragging, + multiDragState, + getMultiDragPositionText } } } diff --git a/packages/canvas/container/src/components/CanvasAction.vue b/packages/canvas/container/src/components/CanvasAction.vue index 029a4375e0..3b16ecb492 100644 --- a/packages/canvas/container/src/components/CanvasAction.vue +++ b/packages/canvas/container/src/components/CanvasAction.vue @@ -1,7 +1,7 @@ + 正在拖拽 {{ multiStateLength }} 个组件 + {{ getMultiDragPositionText }} + + + + + + 放置 {{ multiStateLength }} 个组件到容器内 + + + + + + + {{ index + 1 }} + + {{ node.componentName }} + ID: {{ node.props.id }} + + + + + + + + + diff --git a/packages/canvas/container/src/composables/useMultiDrag.ts b/packages/canvas/container/src/composables/useMultiDrag.ts new file mode 100644 index 0000000000..b8383a655f --- /dev/null +++ b/packages/canvas/container/src/composables/useMultiDrag.ts @@ -0,0 +1,919 @@ +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 + 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(), // 存储每个节点的偏移量 + mouse: null, // 鼠标位置 + position: null, // 放置位置 + targetNodeId: null // 当前点击的节点ID +} + +// 拖拽阈值,鼠标移动超过这个距离才会触发拖拽 +const DRAG_THRESHOLD = 5 + +export const useMultiDrag = () => { + const multiDragState = reactive({ ...initialMultiDragState }) + const { multiSelectedStates } = useMultiSelect() + const multiStateLength = computed(() => (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 + + // 如果目标是body,特殊处理 + if (targetId === 'body') { + // 对于body,允许放置到内部、上方和下方 + if (position !== POSITION.IN && position !== POSITION.TOP && position !== POSITION.BOTTOM) { + // 强制将position设置为IN,因为body只能放置到内部、上方或下方 + lineState.position = POSITION.IN + } + + // 检查所有节点是否都允许放置到body内 + for (const node of nodes) { + if (!allowInsert({ isContainer: true }, node)) { + return false + } + } + return true + } + + // 如果目标节点的父节点是body,特殊处理 + if (targetParentId === 'body') { + // 允许在body的直接子节点前后放置 + if (position === POSITION.TOP || position === POSITION.BOTTOM) { + // 检查所有节点是否都允许放置到body内 + 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 + } + + // 处理 body 元素的放置逻辑 + const handleBodyPlacement = (event: MouseEvent, body: HTMLElement): boolean => { + // 获取body中的所有顶级节点 + const { getSchema } = useCanvas() + const bodySchema = getSchema() + const bodyChildren = bodySchema.children || [] + + // 如果body中没有子节点,直接放置到body内部 + 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 + } + + // 如果body中有子节点,需要判断放置位置 + const { clientY } = event + let closestNode: HTMLElement | null = null + let closestDistance = Infinity + let position: PositionType = POSITION.IN // 默认放置到body内部 + + // 遍历body的直接子节点,找到最接近鼠标位置的节点 + 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) + + // 更新lineState + Object.assign(lineState, { + id: nodeId, + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + position: position, + forbidden: isForbidden, + configure + }) + } else { + // 如果没有找到合适的节点,放置到body内部 + 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) + + // 更新lineState + 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) + + // 更新lineState + 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() + + // 更新lineState,显示在最后一个子节点下方 + 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 + } + } + } + + // 更新lineState + 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 + } + + // 更新lineState + 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 // 返回true表示已处理,但不启动拖拽 + } + + const targetElement = getElement(event.target as HTMLElement) + + // 特殊处理:如果没有找到目标元素,检查是否是body元素或其直接子元素 + if (!targetElement) { + const doc = getDocument() + const body = doc.body + + // 如果鼠标在body区域内,则视为拖拽到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 // 返回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' + + // 如果目标是body,使用页面schema作为目标节点 + 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[] = [] + + // 收集要移动的节点ID,用于后续检查 + 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 + } + + // 如果源节点的父节点是目标节点,且放置位置是IN,则跳过(避免循环引用) + 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 => { + const positions = new Map() + + // 获取所有节点的初始位置 + 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, + 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) => { + // 对于body特殊处理 + 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 + } + + // 如果没有特定位置或找不到目标子节点,则默认插入到body内部 + insertNode({ node: useCanvas().getSchema(), data: op.targetNodeData.data }, POSITION.IN, false) + } else { + insertNode(op.targetNodeData, op.position, false) + } + } + + // 更新多选状态 + const updateMultiSelectionAfterDrag = (operations: InsertOperation[]) => { + // 延迟执行,确保DOM已更新 + setTimeout(() => { + // 重建多选状态 + const newMultiSelection: SelectState[] = [] + + // 收集所有操作后的节点ID + 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 + + // 如果目标位置不允许放置或没有目标ID,直接返回 + 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 = 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 + } +} diff --git a/packages/canvas/container/src/composables/useMultiSelect.ts b/packages/canvas/container/src/composables/useMultiSelect.ts index c8d6936ead..136b3fc87a 100644 --- a/packages/canvas/container/src/composables/useMultiSelect.ts +++ b/packages/canvas/container/src/composables/useMultiSelect.ts @@ -69,6 +69,8 @@ const createTinyPopoverSchema = (props: Record = {}, content: Node } export const useMultiSelect = () => { + const isMouseDown = ref(false) + /** * 添加state到多选列表 * @param {SelectionState} selectState @@ -83,8 +85,8 @@ export const useMultiSelect = () => { // 多选 if (isMultiple) { const isExistNode = multiSelectedStates.value.some((state) => state.id === selectState.id) - // 如果多选列表已经存在选中的state,则将选中的state移出多选列表 - if (isExistNode) { + // 如果多选列表已经存在选中的state且鼠标抬起,则将选中的state移出多选列表 + if (isExistNode && !isMouseDown.value) { multiSelectedStates.value = multiSelectedStates.value.filter((state) => state.id !== selectState.id) } else { multiSelectedStates.value = multiSelectedStates.value.concat(selectState) @@ -296,6 +298,7 @@ export const useMultiSelect = () => { return { multiSelectedStates, + isMouseDown, toggleMultiSelection, refreshSelectionState, clearMultiSelection, diff --git a/packages/canvas/container/src/container.ts b/packages/canvas/container/src/container.ts index 7fcd518fcc..8c152d6d2d 100644 --- a/packages/canvas/container/src/container.ts +++ b/packages/canvas/container/src/container.ts @@ -20,7 +20,7 @@ import { NODE_LOOP, NODE_INACTIVE_UID } from '../../common' -import { useCanvas, useLayout, useTranslate, useMaterial } from '@opentiny/tiny-engine-meta-register' +import { useCanvas, useTranslate, useMaterial } from '@opentiny/tiny-engine-meta-register' import { utils } from '@opentiny/tiny-engine-utils' import { isVsCodeEnv } from '@opentiny/tiny-engine-common/js/environments' import Builtin from '../../render/src/builtin/builtin.json' //TODO 画布内外应该分开 @@ -46,9 +46,11 @@ export const POSITION = Object.freeze({ IN: 'in', OUT: 'out', REPLACE: 'replace' -}) +} as const) + +export type PositionType = typeof POSITION[keyof typeof POSITION] -const initialDragState = { +export const initialDragState = { keydown: false, draging: false, data: null as Node | null, @@ -525,7 +527,7 @@ export const allowInsert = (configure: any = hoverState.configure || {}, data: N return flag } -const isAncestor = (ancestor: string | Node, descendant: string | Node) => { +export const isAncestor = (ancestor: string | Node, descendant: string | Node) => { const ancestorId = typeof ancestor === 'string' ? ancestor : ancestor.id let descendantId = typeof descendant === 'string' ? descendant : descendant.id @@ -641,8 +643,6 @@ const setHoverRect = (element?: Element, data?: Node | null) => { forbidden: posLine.forbidden }) } - - useLayout().closePlugin() } // 设置元素hover状态 @@ -867,7 +867,7 @@ export const hoverNode = (id: string, data: Node) => { export const insertNode = ( node: { node: Node; parent: Node; data: Node }, - position: string = POSITION.IN, + position: PositionType = POSITION.IN, select = true ) => { if (!node.parent) {