241 lines
6.8 KiB
Svelte
241 lines
6.8 KiB
Svelte
<script context="module">
|
|
export const Sides = {
|
|
Top: "Top",
|
|
Right: "Right",
|
|
Bottom: "Bottom",
|
|
Left: "Left",
|
|
}
|
|
</script>
|
|
|
|
<script>
|
|
import { onMount, onDestroy } from "svelte"
|
|
import { get } from "svelte/store"
|
|
import IndicatorSet from "./IndicatorSet.svelte"
|
|
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
|
|
import { builderStore } from "stores"
|
|
|
|
let dragInfo
|
|
let dropInfo
|
|
|
|
const getEdges = (bounds, mousePoint) => {
|
|
const { width, height, top, left } = bounds
|
|
return {
|
|
[Sides.Top]: [mousePoint[0], top],
|
|
[Sides.Right]: [left + width, mousePoint[1]],
|
|
[Sides.Bottom]: [mousePoint[0], top + height],
|
|
[Sides.Left]: [left, mousePoint[1]],
|
|
}
|
|
}
|
|
|
|
const calculatePointDelta = (point1, point2) => {
|
|
const deltaX = Math.abs(point1[0] - point2[0])
|
|
const deltaY = Math.abs(point1[1] - point2[1])
|
|
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
|
}
|
|
|
|
const getDOMNodeForComponent = component => {
|
|
const parent = component.closest(".component")
|
|
const children = Array.from(parent.childNodes)
|
|
return children?.find(node => node?.nodeType === 1)
|
|
}
|
|
|
|
// Callback when initially starting a drag on a draggable component
|
|
const onDragStart = e => {
|
|
const parent = e.target.closest(".component")
|
|
if (!parent?.classList.contains("draggable")) {
|
|
return
|
|
}
|
|
|
|
// Update state
|
|
dragInfo = {
|
|
target: parent.dataset.id,
|
|
parent: parent.dataset.parent,
|
|
}
|
|
builderStore.actions.selectComponent(dragInfo.target)
|
|
builderStore.actions.setDragging(true)
|
|
|
|
// Highlight being dragged by setting opacity
|
|
const child = getDOMNodeForComponent(e.target)
|
|
if (child) {
|
|
child.style.opacity = "0.5"
|
|
}
|
|
}
|
|
|
|
// Callback when drag stops (whether dropped or not)
|
|
const onDragEnd = e => {
|
|
// Reset opacity style
|
|
if (dragInfo) {
|
|
const child = getDOMNodeForComponent(e.target)
|
|
if (child) {
|
|
child.style.opacity = ""
|
|
}
|
|
}
|
|
|
|
// Reset state and styles
|
|
dragInfo = null
|
|
dropInfo = null
|
|
builderStore.actions.setDragging(false)
|
|
}
|
|
|
|
// Callback when on top of a component
|
|
const onDragOver = e => {
|
|
// Skip if we aren't validly dragging currently
|
|
if (!dragInfo || !dropInfo) {
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
const { droppableInside, bounds } = dropInfo
|
|
const { top, left, height, width } = bounds
|
|
const mouseY = e.clientY
|
|
const mouseX = e.clientX
|
|
const snapFactor = droppableInside ? 0.33 : 0.5
|
|
const snapLimitV = Math.min(40, height * snapFactor)
|
|
const snapLimitH = Math.min(40, width * snapFactor)
|
|
|
|
// Determine all sies we are within snap range of
|
|
let sides = []
|
|
if (mouseY <= top + snapLimitV) {
|
|
sides.push(Sides.Top)
|
|
} else if (mouseY >= top + height - snapLimitV) {
|
|
sides.push(Sides.Bottom)
|
|
}
|
|
if (mouseX < left + snapLimitH) {
|
|
sides.push(Sides.Left)
|
|
} else if (mouseX > left + width - snapLimitH) {
|
|
sides.push(Sides.Right)
|
|
}
|
|
|
|
// When no edges match, drop inside if possible
|
|
if (!sides.length) {
|
|
dropInfo.mode = droppableInside ? "inside" : null
|
|
dropInfo.side = null
|
|
return
|
|
}
|
|
|
|
// When one edge matches, use that edge
|
|
if (sides.length === 1) {
|
|
dropInfo.side = sides[0]
|
|
if ([Sides.Top, Sides.Left].includes(sides[0])) {
|
|
dropInfo.mode = "above"
|
|
} else {
|
|
dropInfo.mode = "below"
|
|
}
|
|
return
|
|
}
|
|
|
|
// When 2 edges match, work out which is closer
|
|
const mousePoint = [mouseX, mouseY]
|
|
const edges = getEdges(bounds, mousePoint)
|
|
const edge1 = edges[sides[0]]
|
|
const delta1 = calculatePointDelta(mousePoint, edge1)
|
|
const edge2 = edges[sides[1]]
|
|
const delta2 = calculatePointDelta(mousePoint, edge2)
|
|
const edge = delta1 < delta2 ? sides[0] : sides[1]
|
|
dropInfo.side = edge
|
|
if ([Sides.Top, Sides.Left].includes(edge)) {
|
|
dropInfo.mode = "above"
|
|
} else {
|
|
dropInfo.mode = "below"
|
|
}
|
|
}
|
|
|
|
// Callback when entering a potential drop target
|
|
const onDragEnter = e => {
|
|
// Skip if we aren't validly dragging currently
|
|
if (!dragInfo) {
|
|
return
|
|
}
|
|
|
|
const element = e.target.closest(".component:not(.block)")
|
|
if (
|
|
element &&
|
|
element.classList.contains("droppable") &&
|
|
element.dataset.id !== dragInfo.target
|
|
) {
|
|
// Do nothing if this is the same target
|
|
if (element.dataset.id === dropInfo?.target) {
|
|
return
|
|
}
|
|
|
|
// Ensure the dragging flag is always set.
|
|
// There's a bit of a race condition between the app reinitialisation
|
|
// after selecting the DND component and setting this the first time
|
|
if (!get(builderStore).isDragging) {
|
|
builderStore.actions.setDragging(true)
|
|
}
|
|
|
|
// Store target ID
|
|
const target = element.dataset.id
|
|
|
|
// Precompute and store some info to avoid recalculating everything in
|
|
// dragOver
|
|
const child = getDOMNodeForComponent(e.target)
|
|
const bounds = child.getBoundingClientRect()
|
|
dropInfo = {
|
|
target,
|
|
name: element.dataset.name,
|
|
droppableInside: element.classList.contains("empty"),
|
|
bounds,
|
|
}
|
|
} else {
|
|
dropInfo = null
|
|
}
|
|
}
|
|
|
|
// Callback when leaving a potential drop target.
|
|
// Since we don't style our targets, we don't need to unset anything.
|
|
const onDragLeave = () => {}
|
|
|
|
// Callback when dropping a drag on top of some component
|
|
const onDrop = e => {
|
|
e.preventDefault()
|
|
if (dropInfo?.mode) {
|
|
builderStore.actions.moveComponent(
|
|
dragInfo.target,
|
|
dropInfo.target,
|
|
dropInfo.mode
|
|
)
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
// Events fired on the draggable target
|
|
document.addEventListener("dragstart", onDragStart, false)
|
|
document.addEventListener("dragend", onDragEnd, false)
|
|
|
|
// Events fired on the drop targets
|
|
document.addEventListener("dragover", onDragOver, false)
|
|
document.addEventListener("dragenter", onDragEnter, false)
|
|
document.addEventListener("dragleave", onDragLeave, false)
|
|
document.addEventListener("drop", onDrop, false)
|
|
})
|
|
|
|
onDestroy(() => {
|
|
// Events fired on the draggable target
|
|
document.removeEventListener("dragstart", onDragStart, false)
|
|
document.removeEventListener("dragend", onDragEnd, false)
|
|
|
|
// Events fired on the drop targets
|
|
document.removeEventListener("dragover", onDragOver, false)
|
|
document.removeEventListener("dragenter", onDragEnter, false)
|
|
document.removeEventListener("dragleave", onDragLeave, false)
|
|
document.removeEventListener("drop", onDrop, false)
|
|
})
|
|
</script>
|
|
|
|
<IndicatorSet
|
|
componentId={dropInfo?.mode === "inside" ? dropInfo.target : null}
|
|
color="var(--spectrum-global-color-static-green-500)"
|
|
zIndex="930"
|
|
transition
|
|
prefix="Inside"
|
|
/>
|
|
|
|
<DNDPositionIndicator
|
|
{dropInfo}
|
|
color="var(--spectrum-global-color-static-green-500)"
|
|
zIndex="940"
|
|
transition
|
|
/>
|