diff --git a/src/index.ts b/src/index.ts index 0c8ecaa2..f0259cc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ export { animations } from "./plugins/animations"; export { selections } from "./plugins/multiDrag/plugins/selections"; export { swap } from "./plugins/swap"; export { place } from "./plugins/place"; +export { insertion } from "./plugins/insertion"; export * from "./utils"; const scrollConfig: { @@ -454,6 +455,10 @@ export function remapNodes(parent: HTMLElement, force?: boolean) { parents.set(parent, { ...parentData, enabledNodes: enabledNodeRecords }); config.remapFinished(parentData); + + parentData.config.plugins?.forEach((plugin: DNDPlugin) => { + plugin(parent)?.remapFinished?.(); + }); } export function remapFinished() { @@ -476,13 +481,16 @@ export function handleDragstart(data: NodeEventData) { export function dragstartClasses( el: HTMLElement | Node | Element, draggingClass: string | undefined, - dropZoneClass: string | undefined + dropZoneClass: string | undefined, + dragPlaceholderClass: string | undefined ) { addClass([el], draggingClass); setTimeout(() => { removeClass([el], draggingClass); + addClass([el], dragPlaceholderClass); + addClass([el], dropZoneClass); }); } @@ -507,7 +515,7 @@ export function initDrag(eventData: NodeDragEventData): DragState { return dragState; } -function validateDragHandle(data: NodeEventData): boolean { +export function validateDragHandle(data: NodeEventData): boolean { if (!(data.e instanceof DragEvent) && !(data.e instanceof TouchEvent)) return false; @@ -567,7 +575,8 @@ export function dragstart(data: NodeDragEventData) { dragstartClasses( dragState.draggedNode.el, config.draggingClass, - config.dropZoneClass + config.dropZoneClass, + config.dragPlaceholderClass ); } @@ -988,7 +997,7 @@ function touchmove(data: NodeTouchEventData, touchState: TouchState) { } } -function handleScroll() { +export function handleScroll() { for (const direction of Object.keys(scrollConfig)) { const [x, y] = scrollConfig[direction]; diff --git a/src/plugins/insertion/index.ts b/src/plugins/insertion/index.ts new file mode 100644 index 00000000..ac992331 --- /dev/null +++ b/src/plugins/insertion/index.ts @@ -0,0 +1,528 @@ +import type { + NodeDragEventData, + ParentConfig, + NodeTouchEventData, + NodeRecord, + ParentEventData, + ParentData, + Node, + NodeData, + SetupNodeData, +} from "../../types"; +import { + state, + parents, + handleEnd as originalHandleEnd, + parentValues, + setParentValues, + handleScroll, + nodes, + dragstart, +} from "../../index"; +import { eventCoordinates, removeClass } from "../../utils"; + +export const insertionState = { + draggedOverNodes: Array>(), + targetIndex: 0, + ascending: false, +}; + +interface InsertionConfig extends ParentConfig {} + +// WIP: This is a work in progress and not yet fully functional +export function insertion( + insertionConfig: Partial> = {} +) { + return (parent: HTMLElement) => { + const parentData = parents.get(parent); + + if (!parentData) return; + + const insertionParentConfig = { + ...parentData.config, + insertionConfig: insertionConfig, + } as InsertionConfig; + + return { + setup() { + insertionParentConfig.handleDragstart = + insertionConfig.handleDragstart || handleDragstart; + + insertionParentConfig.handleDragoverNode = + insertionConfig.handleDragoverNode || handleDragoverNode; + + insertionParentConfig.handleDragoverParent = + insertionConfig.handleDragoverParent || handleDragoverParent; + + insertionParentConfig.handleEnd = + insertionConfig.handleEnd || handleEnd; + + parentData.config = insertionParentConfig; + + const observer = parentResizeObserver(); + + setPosition(parentData, parent); + + observer.observe(parent); + + const div = document.createElement("div"); + + div.id = "insertion-point"; + + div.style.position = "absolute"; + + div.style.backgroundColor = "green"; + + div.style.display = "none"; + + div.style.zIndex = "1000"; + + document.body.appendChild(div); + }, + setupNodeRemap(data: SetupNodeData) { + setPosition(data.nodeData, data.node); + + const observer = nodeResizeObserver(); + + observer.observe(data.node); + }, + + remapFinished() { + defineRanges(parentData.enabledNodes); + }, + }; + }; +} + +function handleDragstart(data: NodeDragEventData) { + if (!(data.e instanceof DragEvent)) return; + + dragstart({ + e: data.e, + targetData: data.targetData, + }); + + setTimeout(() => { + for (const node of data.targetData.parent.data.enabledNodes) { + setPosition(node.data, node.el); + } + defineRanges(data.targetData.parent.data.enabledNodes); + }); +} + +function ascendingVertical(node: NodeRecord, nextNode?: NodeRecord) { + const center = node.data.top + node.data.height / 2; + + if (!nextNode) { + return { + y: [center, center + node.data.height], + x: [node.data.left, node.data.right], + vertical: true, + }; + } + + const nextNodeCenter = nextNode.data.top + nextNode.data.height / 2; + + return { + y: [center, center + Math.abs(center - nextNodeCenter) / 2], + x: [node.data.left, node.data.right], + vertical: true, + }; +} + +function ascendingHorizontal( + node: NodeRecord, + nextNode?: NodeRecord, + lastInRow = false +) { + const center = node.data.left + node.data.width / 2; + + if (!nextNode) { + return { + x: [center, center + node.data.width], + y: [node.data.top, node.data.bottom], + vertical: false, + }; + } + + if (lastInRow) { + return { + x: [center, center + node.data.width], + y: [node.data.top, node.data.bottom], + vertical: false, + }; + } else { + const nextNodeCenter = nextNode.data.left + nextNode.data.width / 2; + + return { + x: [center, center + Math.abs(center - nextNodeCenter) / 2], + y: [node.data.top, node.data.bottom], + vertical: false, + }; + } +} + +// function descendingVertical(node: NodeRecord, prevNode?: NodeRecord) { +// const center = node.data.top + node.data.height / 2; + +// if (!prevNode) { +// return { +// y: [center - node.data.height, center], +// x: [node.data.left, node.data.right], +// vertical: true, +// }; +// } + +// return { +// y: [ +// prevNode.data.bottom + Math.abs(prevNode.data.bottom - node.data.top) / 2, +// center, +// ], +// x: [node.data.left, node.data.right], +// vertical: true, +// }; +// } + +function descendingHorizontal( + node: NodeRecord, + prevNode?: NodeRecord +) { + const center = node.data.left + node.data.width / 2; + + if (!prevNode) { + return { + x: [center - node.data.width, center], + y: [node.data.top, node.data.bottom], + vertical: false, + }; + } + + return { + x: [ + prevNode.data.right + Math.abs(prevNode.data.right - node.data.left) / 2, + center, + ], + y: [node.data.top, node.data.bottom], + vertical: false, + }; +} + +function defineRanges(enabledNodes: Array>) { + enabledNodes.forEach((node, index) => { + node.data.range = {}; + + let aboveOrBelowPrevious = false; + + let aboveOrBelowAfter = false; + + let nextNode = enabledNodes[index + 1]; + + if (enabledNodes[index - 1]) { + aboveOrBelowPrevious = + node.data.top > enabledNodes[index - 1].data.bottom || + node.data.bottom < enabledNodes[index - 1].data.top; + } + + if (enabledNodes[index + 1]) { + aboveOrBelowAfter = + node.data.top > enabledNodes[index + 1].data.bottom || + node.data.bottom < enabledNodes[index + 1].data.top; + } + + if (aboveOrBelowAfter && !aboveOrBelowPrevious) { + node.data.range.ascending = ascendingHorizontal( + node, + enabledNodes[index + 1], + true + ); + node.data.range.descending = descendingHorizontal( + node, + enabledNodes[index - 1] + ); + } else if (!aboveOrBelowPrevious && !aboveOrBelowAfter) { + node.data.range.ascending = ascendingHorizontal( + node, + enabledNodes[index + 1] + ); + node.data.range.descending = descendingHorizontal( + node, + enabledNodes[index - 1] + ); + } else if (aboveOrBelowPrevious && !nextNode) { + node.data.range.ascending = ascendingHorizontal(node); + } else if (aboveOrBelowPrevious && !aboveOrBelowAfter) { + node.data.range.ascending = ascendingVertical(node); + } else if (aboveOrBelowAfter && aboveOrBelowPrevious) { + node.data.range.ascending = ascendingVertical( + node, + enabledNodes[index + 1] + ); + } + }); +} + +function setPosition(data: NodeData | ParentData, el: HTMLElement) { + const { top, bottom, left, right, height, width } = + el.getBoundingClientRect(); + + data.top = top; + data.bottom = bottom; + data.left = left; + data.right = right; + data.height = height; + data.width = width; +} + +function nodeResizeObserver() { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { target } = entry; + + const nodeData = nodes.get(target as Node); + + if (!nodeData) return; + + setPosition(nodeData, target as Node); + } + }); + + return observer; +} + +function parentResizeObserver() { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { target } = entry; + + if (!(target instanceof HTMLElement)) return; + + const parentData = parents.get(target); + + if (!parentData) return; + + setPosition(parentData, target); + } + }); + + return observer; +} + +export function handleDragoverNode(data: NodeDragEventData) { + if (!state) return; + + if (data.targetData.parent.el !== state.lastParent.el) return; + + data.e.preventDefault(); + + const { x, y } = eventCoordinates(data.e as DragEvent); + + state.coordinates.y = y; + + state.coordinates.x = x; + + handleScroll(); + + const foundRange = findClosest([data.targetData.node]); + + if (!foundRange) return; + + const position = foundRange[0].data.range[foundRange[1]]; + + positionInsertionPoint( + position, + foundRange[1] === "ascending", + foundRange[0] + ); + + data.e.stopPropagation(); + + data.e.preventDefault(); +} + +function findClosest(enabledNodes: NodeRecord[]) { + let foundRange: [NodeRecord, string] | null = null; + + for (let x = 0; x < enabledNodes.length; x++) { + if (!state || !enabledNodes[x].data.range) continue; + + if (enabledNodes[x].data.range.ascending) { + if ( + state.coordinates.y > enabledNodes[x].data.range.ascending.y[0] && + state.coordinates.y < enabledNodes[x].data.range.ascending.y[1] && + state.coordinates.x > enabledNodes[x].data.range.ascending.x[0] && + state.coordinates.x < enabledNodes[x].data.range.ascending.x[1] + ) { + foundRange = [enabledNodes[x], "ascending"]; + + return foundRange; + } + } + + if (enabledNodes[x].data.range.descending) { + if ( + state.coordinates.y > enabledNodes[x].data.range.descending.y[0] && + state.coordinates.y < enabledNodes[x].data.range.descending.y[1] && + state.coordinates.x > enabledNodes[x].data.range.descending.x[0] && + state.coordinates.x < enabledNodes[x].data.range.descending.x[1] + ) { + foundRange = [enabledNodes[x], "descending"]; + + return foundRange; + } + } + } +} + +export function handleDragoverParent(data: ParentEventData) { + if (!state) return; + + if (data.targetData.parent.el !== state.lastParent.el) return; + + const { x, y } = eventCoordinates(data.e as DragEvent); + + state.coordinates.y = y; + + state.coordinates.x = x; + + handleScroll(); + + const enabledNodes = data.targetData.parent.data.enabledNodes; + + const foundRange = findClosest(enabledNodes); + + if (!foundRange) return; + + const position = foundRange[0].data.range[foundRange[1]]; + + positionInsertionPoint( + position, + foundRange[1] === "ascending", + foundRange[0] + ); + + data.e.stopPropagation(); + + data.e.preventDefault(); +} + +function positionInsertionPoint( + position: { x: number[]; y: number[]; vertical: boolean }, + ascending: boolean, + node: NodeRecord +) { + if (!state) return; + + const div = document.getElementById("insertion-point"); + + if (!div) return; + + if (node.el === state.draggedNodes[0].el) return; + + if (position.vertical) { + const topPosition = + position.y[ascending ? 1 : 0] - div.getBoundingClientRect().height / 2; + + div.style.top = `${topPosition}px`; + + const leftCoordinate = position.x[0]; + + const rightCoordinate = position.x[1]; + + div.style.left = `${leftCoordinate}px`; + + div.style.right = `${rightCoordinate}px`; + + div.style.height = "10px"; + + div.style.width = rightCoordinate - leftCoordinate + "px"; + } else { + const leftPosition = + position.x[ascending ? 1 : 0] - div.getBoundingClientRect().width / 2; + + div.style.left = `${leftPosition}px`; + + const topCoordinate = position.y[0]; + + const bottomCoordinate = position.y[1]; + + div.style.top = `${topCoordinate}px`; + + div.style.bottom = `${bottomCoordinate}px`; + + div.style.width = "10px"; + + div.style.height = bottomCoordinate - topCoordinate + "px"; + } + + insertionState.draggedOverNodes = [node]; + + insertionState.targetIndex = node.data.index; + + insertionState.ascending = ascending; + + div.style.display = "block"; +} + +function handleEnd(data: NodeDragEventData | NodeTouchEventData) { + if (!state) return; + + if (state.transferred || state.lastParent.el !== state.initialParent.el) + return; + + const draggedParentValues = parentValues( + state.initialParent.el, + state.initialParent.data + ); + + const draggedValues = state.draggedNodes.map((node) => node.data.value); + + const newParentValues = [ + ...draggedParentValues.filter((x) => !draggedValues.includes(x)), + ]; + + let index = insertionState.draggedOverNodes[0].data.index; + + if ( + insertionState.targetIndex > state.draggedNodes[0].data.index && + !insertionState.ascending + ) { + index--; + } else if ( + insertionState.targetIndex < state.draggedNodes[0].data.index && + insertionState.ascending + ) { + index++; + } + + newParentValues.splice(index, 0, ...draggedValues); + + setParentValues(data.targetData.parent.el, data.targetData.parent.data, [ + ...newParentValues, + ]); + + const dropZoneClass = + "touchedNode" in state + ? data.targetData.parent.data.config.touchDropZoneClass + : data.targetData.parent.data.config.dropZoneClass; + + removeClass( + insertionState.draggedOverNodes.map((node) => node.el), + dropZoneClass + ); + + const div = document.getElementById("insertion-point"); + + if (!div) return; + + div.style.display = "none"; + + const dragPlaceholderClass = + data.targetData.parent.data.config.dragPlaceholderClass; + + removeClass( + state.draggedNodes.map((node) => node.el), + dragPlaceholderClass + ); + + originalHandleEnd(data); +} diff --git a/src/plugins/insertion/utils.ts b/src/plugins/insertion/utils.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/types.ts b/src/types.ts index 62fc34c1..cbf73879 100644 --- a/src/types.ts +++ b/src/types.ts @@ -204,6 +204,8 @@ export interface ParentData { * The abort controllers for the parent. */ abortControllers: Record; + + [key: string]: any; } /** @@ -227,6 +229,8 @@ export interface NodeData { * The abort controllers for the node. */ abortControllers: Record; + + [key: string]: any; } /** @@ -414,6 +418,10 @@ export interface DNDPluginData { * Called when the parent is dragged over. */ tearDownNodeRemap?: TearDownNode; + /** + * Called when all nodes have finished remapping for a given parent + */ + remapFinished?: () => void; } /** @@ -434,6 +442,8 @@ export type SetupNode = (data: SetupNodeData) => void; export type TearDownNode = (data: TearDownNodeData) => void; +export type RemapFinished = (data: ParentData) => void; + /** * The payload of when the setupNode function is called in a given plugin. */ diff --git a/tests/pages/insertion/horizontal.vue b/tests/pages/insertion/horizontal.vue new file mode 100644 index 00000000..f634f6de --- /dev/null +++ b/tests/pages/insertion/horizontal.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/tests/pages/insertion/mixed.vue b/tests/pages/insertion/mixed.vue new file mode 100644 index 00000000..9c303129 --- /dev/null +++ b/tests/pages/insertion/mixed.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/tests/pages/insertion/vertical.vue b/tests/pages/insertion/vertical.vue new file mode 100644 index 00000000..74e79ea4 --- /dev/null +++ b/tests/pages/insertion/vertical.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/tests/pages/multi-drag/index.vue b/tests/pages/multi-drag/index.vue new file mode 100644 index 00000000..60c519d1 --- /dev/null +++ b/tests/pages/multi-drag/index.vue @@ -0,0 +1,105 @@ + + + + +