Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions frontend/cypress/component/shift-replace-block.cy.ts
Original file line number Diff line number Diff line change
@@ -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: "<div />" } }],
})

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"])
})
})
})
31 changes: 18 additions & 13 deletions frontend/src/stores/canvasStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -195,6 +199,7 @@ const useCanvasStore = defineStore("canvasStore", () => {
layerDraggingOverSlot,
handleDragStart,
handleDragEnd,
resetDropTarget,
// fragment mode
editingMode,
showFragmentCanvas,
Expand Down
105 changes: 75 additions & 30 deletions frontend/src/utils/useCanvasDropZone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -55,30 +45,86 @@ 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
Comment on lines +76 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Slot clone drops fragment edits

When Shift-replacing a slot block with a fragment-editable component, slot insertion clones newBlock, but the save callback targets the original instance, causing the edited content not to be applied.

Context Used: Guidelines for reviewing Frappe Framework applicat... (source)

}

// 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
if (breakpoint !== canvasStore.activeCanvas?.hoveredBreakpoint) {
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()
Expand Down Expand Up @@ -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)
Expand Down
Loading