diff --git a/frontend/cypress/component/shift-replace-block.cy.ts b/frontend/cypress/component/shift-replace-block.cy.ts new file mode 100644 index 00000000..a31f6a3c --- /dev/null +++ b/frontend/cypress/component/shift-replace-block.cy.ts @@ -0,0 +1,118 @@ +import { pinia } from "../support/component" + +import { setActivePinia } from "pinia" +import { createRouter, createMemoryHistory } from "vue-router" +// @ts-ignore +import { resourcesPlugin } from "frappe-ui" +import { spritePlugin } from "frappe-ui/icons" + +import StudioCanvas from "@/components/StudioCanvas.vue" +import Block from "@/utils/block" +import { COMPONENTS } from "@/data/components" +import { getBlockInstance, getComponentBlock } from "@/utils/serializer" +import getBlockTemplate from "@/utils/blockTemplate" +import { registerGlobalComponents } from "@/globals" +import useCanvasStore from "@/stores/canvasStore" + +// drags a component from the panel onto the block rendered at `componentId` +function dragOnto(componentId: string, componentName: string, shiftKey: boolean) { + const dataTransfer = new DataTransfer() + + return cy + .get(`.__studio_component__[data-component-id="${componentId}"]`) + .first() + .then(([element]) => { + const canvasStore = useCanvasStore() + canvasStore.handleDragStart({ target: element, dataTransfer } as unknown as DragEvent, componentName) + + const { left, top, width, height } = element.getBoundingClientRect() + const options = { + dataTransfer, + shiftKey, + force: true, + clientX: left + width / 2, + clientY: top + height / 2, + } + cy.wrap(element).trigger("dragover", options).trigger("drop", options) + cy.then(() => canvasStore.handleDragEnd()) + }) +} + +// a new block is auto-selected on nextTick and its editor overlay would swallow the drop +function clearSelection(canvas: any) { + cy.get(".editor").should("exist") + cy.then(() => canvas.clearSelection()) + cy.get(".editor").should("not.exist") +} + +describe("dropping a component on top of another block", () => { + // exposed StudioCanvas instance (defineExpose) used as canvasStore.activeCanvas + let canvas: any + + beforeEach(() => { + // block prop/slot init reads Block.components (done in main.ts in the real app) + Block.setComponents(COMPONENTS) + + setActivePinia(pinia) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: "/", component: { template: "
" } }], + }) + + const rootBlock = getBlockInstance(getBlockTemplate("body")) + + cy.viewport(1440, 900) + cy.mount(StudioCanvas as any, { + props: { componentTree: rootBlock }, + global: { + plugins: [pinia, router, resourcesPlugin, spritePlugin, { install: registerGlobalComponents }], + }, + }).then(({ wrapper }) => { + canvas = wrapper.vm + useCanvasStore().activeCanvas = canvas + }) + + cy.then(() => { + canvas.canvasProps.scale = 1 + canvas.canvasProps.translateX = 0 + canvas.canvasProps.translateY = 0 + }) + }) + + it("replaces the hovered block in place with shift", () => { + let button: Block, badge: Block + + cy.then(() => { + button = canvas.rootComponent.addChild(getComponentBlock("Button")) + badge = canvas.rootComponent.addChild(getComponentBlock("Badge")) + }) + + clearSelection(canvas) + + cy.then(() => dragOnto(button.componentId, "Avatar", true)) + + cy.then(() => { + const children = canvas.rootComponent.children + expect(children.map((child: Block) => child.componentName)).to.deep.equal(["Avatar", "Badge"]) + expect(canvas.rootComponent.getChildById(button.componentId)).to.be.null + expect(children[1].componentId).to.equal(badge.componentId) + }) + }) + + it("drops into the hovered block without shift", () => { + let container: Block + + cy.then(() => { + container = canvas.rootComponent.addChild(getBlockInstance(getBlockTemplate("empty-component"))) + }) + + clearSelection(canvas) + + cy.then(() => dragOnto(container.componentId, "Avatar", false)) + + cy.then(() => { + expect(canvas.rootComponent.children).to.have.length(1) + expect(container.children.map((child: Block) => child.componentName)).to.deep.equal(["Avatar"]) + }) + }) +}) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index f21dd9b5..b257697d 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -80,30 +80,34 @@ const useCanvasStore = defineStore("canvasStore", () => { }, 0) ev.dataTransfer.setData("componentName", componentName) - let element = document.createElement("div") - element.id = "placeholder" - - const root = document.querySelector(".__studio_component__[data-component-id='root']") - if (root) { - dropTarget.placeholder = root.appendChild(element) - } + insertDropPlaceholder() } } const handleDragEnd = () => { - const placeholder = document.getElementById("placeholder") - if (placeholder) { - placeholder.remove() + resetDropTarget() + dropTarget.placeholder = null + isDragging.value = false + } + + // append the placeholder to the dom directly to avoid re-rendering the whole canvas + const insertDropPlaceholder = () => { + const element = document.createElement("div") + element.id = "placeholder" + const root = document.querySelector(".__studio_component__[data-component-id='root']") + if (root) { + dropTarget.placeholder = root.appendChild(element) } + } + // detach the placeholder but hold on to it so it can be re-inserted on the next dragover + const resetDropTarget = () => { + dropTarget.placeholder?.remove() dropTarget.x = null dropTarget.y = null - dropTarget.placeholder = null dropTarget.parentComponent = null dropTarget.index = null dropTarget.slotName = null - - isDragging.value = false } // fragment mode @@ -195,6 +199,7 @@ const useCanvasStore = defineStore("canvasStore", () => { layerDraggingOverSlot, handleDragStart, handleDragEnd, + resetDropTarget, // fragment mode editingMode, showFragmentCanvas, diff --git a/frontend/src/utils/useCanvasDropZone.ts b/frontend/src/utils/useCanvasDropZone.ts index 84e77ab1..d678110a 100644 --- a/frontend/src/utils/useCanvasDropZone.ts +++ b/frontend/src/utils/useCanvasDropZone.ts @@ -15,38 +15,28 @@ export function useCanvasDropZone( ) { const { isOverDropZone } = useDropZone(canvasContainer, { onDrop: async (_files, ev) => { - const { parentComponent, index, slotName } = canvasStore.dropTarget - if (!parentComponent) return const componentName = ev.dataTransfer?.getData("componentName") - const isStudioComponent = Boolean(ev.dataTransfer?.getData("isStudioComponent")) - const isCustomVueComponent = Boolean(ev.dataTransfer?.getData("isCustomVueComponent")) - if (!componentName) return - const componentDef = Block.getComponents()?.[componentName] - let newBlock: Block - - if (componentDef?.blockTemplate) { - newBlock = getBlockInstance(getBlockTemplate(componentDef.blockTemplate as any)) - } else { - newBlock = getComponentBlock(componentName, isStudioComponent, isCustomVueComponent) - } - - if (slotName) { - parentComponent?.updateSlot(slotName, newBlock) - } else { - parentComponent?.addChild(newBlock, index) - } + const newBlock = createBlock(ev, componentName) + // holding shift replaces the hovered block instead of dropping into it + const parentComponent = ev.shiftKey ? replaceHoveredBlock(ev, newBlock) : dropIntoTarget(newBlock) + if (!parentComponent) return if (newBlock.editInFragmentMode()) { canvasStore.editOnCanvas( newBlock, - (editedBlock: Block) => parentComponent?.replaceChild(newBlock, editedBlock), + (editedBlock: Block) => parentComponent.replaceChild(newBlock, editedBlock), `Save ${componentName}` ) } }, onOver: (_files, ev) => { + if (ev.shiftKey) { + highlightBlockToReplace(ev) + return + } + const { parentComponent, slotName, index, layoutDirection } = findDropTarget(ev) if (parentComponent) { canvasStore.activeCanvas?.setHoveredBlock(parentComponent.componentId) @@ -55,16 +45,59 @@ export function useCanvasDropZone( }, }) - const getBlockElement = (block: Block) => { - const breakpoint = canvasStore.activeCanvas?.hoveredBreakpoint || canvasStore.activeCanvas?.activeBreakpoint - return document.querySelector(`.__studio_component__[data-component-id="${block.componentId}"][data-breakpoint="${breakpoint}"]`) as HTMLElement; + const createBlock = (ev: DragEvent, componentName: string) => { + const componentDef = Block.getComponents()?.[componentName] + if (componentDef?.blockTemplate) { + return getBlockInstance(getBlockTemplate(componentDef.blockTemplate as any)) + } + const isStudioComponent = Boolean(ev.dataTransfer?.getData("isStudioComponent")) + const isCustomVueComponent = Boolean(ev.dataTransfer?.getData("isCustomVueComponent")) + return getComponentBlock(componentName, isStudioComponent, isCustomVueComponent) } - const findDropTarget = (ev: DragEvent) => { - if (canvasStore.dropTarget.x === ev.x && canvasStore.dropTarget.y === ev.y) return {} + const dropIntoTarget = (newBlock: Block) => { + const { parentComponent, index, slotName } = canvasStore.dropTarget + if (!parentComponent) return null + + if (slotName) { + parentComponent.updateSlot(slotName, newBlock) + } else { + parentComponent.addChild(newBlock, index) + } + return parentComponent + } + const replaceHoveredBlock = (ev: DragEvent, newBlock: Block) => { + const blockToReplace = getBlockToReplace(ev) + const parentComponent = blockToReplace?.getParentBlock() + if (!blockToReplace || !parentComponent) return null + + if (blockToReplace.isSlotBlock()) { + newBlock.parentSlotName = blockToReplace.parentSlotName + } + parentComponent.replaceChild(blockToReplace, newBlock) + return parentComponent + } + + // no placeholder in replace mode, outline the block that is about to be replaced instead + const highlightBlockToReplace = (ev: DragEvent) => { + canvasStore.resetDropTarget() + const blockToReplace = getBlockToReplace(ev) + canvasStore.activeCanvas?.setHoveredBlock(blockToReplace?.componentId ?? null) + } + + const getBlockToReplace = (ev: DragEvent) => { + let blockToReplace = getBlockAtPoint(ev) + // replace the whole component instance, not the children it is composed of + while (blockToReplace?.isChildOfComponent) { + blockToReplace = blockToReplace.getParentBlock() + } + return blockToReplace?.isRoot() ? null : blockToReplace + } + + const getBlockAtPoint = (ev: DragEvent) => { const element = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement - const targetElement = element.closest(".__studio_component__") as HTMLElement + const targetElement = element?.closest(".__studio_component__") as HTMLElement // set the hoveredBreakpoint from the target element to show placeholder at the correct breakpoint canvas const breakpoint = targetElement?.dataset.breakpoint || canvasStore.activeCanvas?.activeBreakpoint || null @@ -72,13 +105,26 @@ export function useCanvasDropZone( canvasStore.activeCanvas?.setHoveredBreakpoint(breakpoint) } + const componentId = targetElement?.dataset.componentId + return (componentId && findBlock(componentId)) || null + } + + const getBlockElement = (block: Block) => { + const breakpoint = canvasStore.activeCanvas?.hoveredBreakpoint || canvasStore.activeCanvas?.activeBreakpoint + return document.querySelector(`.__studio_component__[data-component-id="${block.componentId}"][data-breakpoint="${breakpoint}"]`) as HTMLElement; + } + + const findDropTarget = (ev: DragEvent) => { + if (canvasStore.dropTarget.x === ev.x && canvasStore.dropTarget.y === ev.y) return {} + + const targetBlock = getBlockAtPoint(ev) let parentComponent = block.value let slotName = null let layoutDirection = "column" as LayoutDirection let index = parentComponent?.children.length || 0 - if (targetElement && targetElement.dataset.componentId) { - parentComponent = findBlock(targetElement.dataset.componentId) || parentComponent + if (targetBlock) { + parentComponent = targetBlock // Walk up the tree until we find a component that can have children while (parentComponent && !parentComponent.canHaveChildren()) { parentComponent = parentComponent.getParentBlock() @@ -146,8 +192,7 @@ export function useCanvasDropZone( index: number, layoutDirection: LayoutDirection ) => { - // append placeholder component to the dom directly - // to avoid re-rendering the whole canvas + // placeholder is detached while hovering in replace mode, re-inserted below const { placeholder } = canvasStore.dropTarget if (!parentComponent || !placeholder) return let newParent = getBlockElement(parentComponent)