wunderbaum
Version:
JavaScript tree/grid/treegrid control.
538 lines (493 loc) • 19.5 kB
text/typescript
/*!
* Wunderbaum - ext-dnd
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* @VERSION, @DATE (https://github.com/mar10/wunderbaum)
*/
import * as util from "./util";
import { EventCallbackType, onEvent } from "./util";
import { Wunderbaum } from "./wunderbaum";
import { WunderbaumExtension } from "./wb_extension_base";
import { WunderbaumNode } from "./wb_node";
import {
DndOptionsType,
DropEffectType,
DropRegionType,
DropRegionTypeSet,
} from "./types";
import { DebouncedFunction, throttle } from "./debounce";
const nodeMimeType = "application/x-wunderbaum-node";
export class DndExtension extends WunderbaumExtension<DndOptionsType> {
// public dropMarkerElem?: HTMLElement;
protected srcNode: WunderbaumNode | null = null;
protected lastTargetNode: WunderbaumNode | null = null;
protected lastEnterStamp = 0;
protected lastAllowedDropRegions: DropRegionTypeSet | null = null;
protected lastDropEffect: DropEffectType | null = null;
protected lastDropRegion: DropRegionType | false = false;
protected currentScrollDir: number = 0;
// protected autoScrollThrottled: DebouncedFunction<(pageY: number) => number>;
protected applyScrollDirThrottled: DebouncedFunction<() => void>;
constructor(tree: Wunderbaum) {
super(tree, "dnd", {
autoExpandMS: 1500, // Expand nodes after n milliseconds of hovering
// dropMarkerInsertOffsetX: -16, // Additional offset for drop-marker with hitMode = "before"/"after"
// dropMarkerOffsetX: -24, // Absolute position offset for .fancytree-drop-marker relatively to ..fancytree-title (icon/img near a node accepting drop)
// #1021 `document.body` is not available yet
// dropMarkerParent: "body", // Root Container used for drop marker (could be a shadow root)
multiSource: false, // true: Drag multiple (i.e. selected) nodes. Also a callback() is allowed
effectAllowed: "all", // Restrict the possible cursor shapes and modifier operations (can also be set in the dragStart event)
dropEffectDefault: "move", // Default dropEffect ('copy', 'link', or 'move') when no modifier is pressed (override in drag, dragOver).
guessDropEffect: true, // Calculate from `effectAllowed` and modifier keys)
preventForeignNodes: false, // Prevent dropping nodes from different Wunderbaum trees
preventLazyParents: true, // Prevent dropping items on unloaded lazy Wunderbaum tree nodes
preventNonNodes: false, // Prevent dropping items other than Wunderbaum tree nodes
preventRecursion: true, // Prevent dropping nodes on own descendants
preventSameParent: false, // Prevent dropping nodes under same direct parent
preventVoidMoves: true, // Prevent dropping nodes 'before self', etc. (move only)
serializeClipboardData: true, // Serialize node data to dataTransfer object
scroll: true, // Enable auto-scrolling while dragging
scrollSensitivity: 20, // Active top/bottom margin in pixel
// scrollnterval: 50, // Generate event every 50 ms
scrollSpeed: 5, // Scroll pixel per 50 ms
// setTextTypeJson: false, // Allow dragging of nodes to different IE windows
sourceCopyHook: null, // Optional callback passed to `toDict` on dragStart @since 2.38
// Events (drag support)
dragStart: null, // Callback(sourceNode, data), return true, to enable dnd drag
drag: null, // Callback(sourceNode, data)
dragEnd: null, // Callback(sourceNode, data)
// Events (drop support)
dragEnter: null, // Callback(targetNode, data), return true, to enable dnd drop
dragOver: null, // Callback(targetNode, data)
dragExpand: null, // Callback(targetNode, data), return false to prevent autoExpand
drop: null, // Callback(targetNode, data)
dragLeave: null, // Callback(targetNode, data)
});
this.applyScrollDirThrottled = throttle(this._applyScrollDir, 50);
}
init() {
super.init();
// Store the current scroll parent, which may be the tree
// container, any enclosing div, or the document.
// #761: scrollParent() always needs a container child
// $temp = $("<span>").appendTo(this.$container);
// this.$scrollParent = $temp.scrollParent();
// $temp.remove();
const tree = this.tree;
const dndOpts = tree.options.dnd!;
// Enable drag support if dragStart() is specified:
if (dndOpts.dragStart) {
onEvent(
tree.element,
"dragstart drag dragend",
(<EventCallbackType>this.onDragEvent).bind(this)
);
}
// Enable drop support if dragEnter() is specified:
if (dndOpts.dragEnter) {
onEvent(
tree.element,
"dragenter dragover dragleave drop",
(<EventCallbackType>this.onDropEvent).bind(this)
);
}
}
/** Cleanup classes after target node is no longer hovered. */
protected _leaveNode(): void {
// We remove the marker on dragenter from the previous target:
const ltn = this.lastTargetNode;
this.lastEnterStamp = 0;
if (ltn) {
ltn.setClass(
"wb-drop-target wb-drop-over wb-drop-after wb-drop-before",
false
);
this.lastTargetNode = null;
}
}
/** */
protected unifyDragover(res: any): DropRegionTypeSet | false {
if (res === false) {
return false;
} else if (res instanceof Set) {
return res.size > 0 ? res : false;
} else if (res === true) {
return new Set<DropRegionType>(["over", "before", "after"]);
} else if (typeof res === "string" || util.isArray(res)) {
res = <DropRegionTypeSet>util.toSet(res);
return res.size > 0 ? res : false;
}
throw new Error("Unsupported drop region definition: " + res);
}
/**
* Calculates the drop region based on the drag event and the allowed drop regions.
*/
protected _calcDropRegion(
e: DragEvent,
allowed: DropRegionTypeSet | null
): DropRegionType | false {
const rowHeight = this.tree.options.rowHeightPx!;
const dy = e.offsetY;
if (!allowed) {
return false;
} else if (allowed.size === 3) {
return dy < 0.25 * rowHeight
? "before"
: dy > 0.75 * rowHeight
? "after"
: "over";
} else if (allowed.size === 1 && allowed.has("over")) {
return "over";
} else {
// Only 'before' and 'after':
return dy > rowHeight / 2 ? "after" : "before";
}
// return "over";
}
/**
* Guess drop effect (copy/link/move) using opinionated conventions.
*
* Default: dnd.dropEffectDefault
*/
protected _guessDropEffect(e: DragEvent): DropEffectType {
// const nativeDropEffect = e.dataTransfer?.dropEffect;
// if (nativeDropEffect && nativeDropEffect !== "none") {
// return nativeDropEffect;
// }
const dndOpts: DndOptionsType = this.treeOpts.dnd;
const ea = dndOpts.effectAllowed ?? "all";
const canCopy = ["all", "copy", "copyLink", "copyMove"].includes(ea);
const canLink = ["all", "link", "copyLink", "linkMove"].includes(ea);
const canMove = ["all", "move", "copyMove", "linkMove"].includes(ea);
let res = dndOpts.dropEffectDefault!;
if (dndOpts.guessDropEffect) {
if (util.isMac) {
if (e.altKey && canCopy) {
res = "copy";
}
if (e.metaKey && canMove) {
res = "move"; // command key
}
if (e.altKey && e.metaKey && canLink) {
res = "link";
}
} else {
if (e.ctrlKey && canCopy) {
res = "copy";
}
if (e.shiftKey && canMove) {
res = "move";
}
if (e.altKey && canLink) {
res = "link";
}
}
}
return res;
}
/** Don't allow void operation ('drop on self').*/
protected _isVoidDrop(
targetNode: WunderbaumNode,
srcNode: WunderbaumNode | null,
dropRegion: DropRegionType | false
): boolean {
// this.tree.logDebug(
// `_isVoidDrop: ${srcNode} -> ${dropRegion} ${targetNode}`
// );
// TODO: should be checked on move only
if (!this.treeOpts.dnd.preventVoidMoves || !srcNode) {
return false;
}
if (
(dropRegion === "before" && targetNode === srcNode.getNextSibling()) ||
(dropRegion === "after" && targetNode === srcNode.getPrevSibling())
) {
// this.tree.logDebug("Prevented before/after self");
return true;
}
// Don't allow dropping nodes on own parent (or self)
return srcNode === targetNode || srcNode.parent === targetNode;
}
/* Implement auto scrolling when drag cursor is in top/bottom area of scroll parent. */
protected _applyScrollDir(): void {
if (this.isDragging() && this.currentScrollDir) {
const dndOpts = this.tree.options.dnd!;
const sp = this.tree.element; // scroll parent
const scrollTop = sp.scrollTop;
if (this.currentScrollDir < 0) {
sp.scrollTop = Math.max(0, scrollTop - dndOpts.scrollSpeed!);
} else if (this.currentScrollDir > 0) {
sp.scrollTop = scrollTop + dndOpts.scrollSpeed!;
}
}
}
/* Implement auto scrolling when drag cursor is in top/bottom area of scroll parent. */
protected _autoScroll(viewportY: number): number {
const tree = this.tree;
const dndOpts = tree.options.dnd!;
const sensitivity = dndOpts.scrollSensitivity;
const sp = tree.element; // scroll parent
const headerHeight = tree.headerElement.clientHeight; // May be 0
// const height = sp.clientHeight - headerHeight;
// const height = sp.offsetHeight + headerHeight;
const height = sp.offsetHeight;
const scrollTop = sp.scrollTop;
// tree.logDebug(
// `autoScroll: height=${height}, scrollTop=${scrollTop}, viewportY=${viewportY}`
// );
this.currentScrollDir = 0;
if (
scrollTop > 0 &&
viewportY > 0 &&
viewportY <= sensitivity! + headerHeight
) {
// Mouse in top 20px area: scroll up
// sp.scrollTop = Math.max(0, scrollTop - dndOpts.scrollSpeed);
this.currentScrollDir = -1;
} else if (
scrollTop < sp.scrollHeight - height &&
viewportY >= height - sensitivity!
) {
// Mouse in bottom 20px area: scroll down
// sp.scrollTop = scrollTop + dndOpts.scrollSpeed;
this.currentScrollDir = +1;
}
if (this.currentScrollDir) {
this.applyScrollDirThrottled();
}
return sp.scrollTop - scrollTop;
}
/** Return true if a drag operation currently in progress. */
isDragging(): boolean {
return !!this.srcNode;
}
/**
* Handle dragstart, drag and dragend events for the source node.
*/
protected onDragEvent(e: DragEvent) {
// const tree = this.tree;
const dndOpts: DndOptionsType = this.treeOpts.dnd;
const srcNode = Wunderbaum.getNode(e);
if (!srcNode) {
this.tree.logWarn(`onDragEvent.${e.type}: no node`);
return;
}
if (["dragstart", "dragend"].includes(e.type)) {
this.tree.logDebug(`onDragEvent.${e.type} srcNode: ${srcNode}`, e);
}
// --- dragstart ---
if (e.type === "dragstart") {
// Set a default definition of allowed effects
e.dataTransfer!.effectAllowed = dndOpts.effectAllowed!; //"copyMove"; // "all";
if (srcNode.isEditingTitle()) {
srcNode.logDebug("Prevented dragging node in edit mode.");
e.preventDefault();
return false;
}
// Let user cancel the drag operation, override effectAllowed, etc.:
const res = srcNode._callEvent("dnd.dragStart", { event: e });
if (!res) {
e.preventDefault();
return false;
}
const nodeData = srcNode.toDict(true, (n: any) => {
// We don't want to re-use the key on drop:
n._orgKey = n.key;
delete n.key;
});
nodeData._treeId = srcNode.tree.id;
if (dndOpts.serializeClipboardData) {
if (typeof dndOpts.serializeClipboardData === "function") {
e.dataTransfer!.setData(
nodeMimeType,
dndOpts.serializeClipboardData(nodeData, srcNode)
);
} else {
e.dataTransfer!.setData(nodeMimeType, JSON.stringify(nodeData));
}
}
// e.dataTransfer!.setData("text/html", $(node.span).html());
if (!e.dataTransfer?.types.includes("text/plain")) {
e.dataTransfer!.setData("text/plain", srcNode.title);
}
this.srcNode = srcNode;
setTimeout(() => {
// Decouple this call, so the CSS is applied to the node, but not to
// the system generated drag image
srcNode.setClass("wb-drag-source");
}, 0);
// --- drag ---
} else if (e.type === "drag") {
if (dndOpts.drag) {
srcNode._callEvent("dnd.drag", { event: e });
}
// --- dragend ---
} else if (e.type === "dragend") {
srcNode.setClass("wb-drag-source", false);
this.srcNode = null;
if (this.lastTargetNode) {
this._leaveNode();
}
srcNode._callEvent("dnd.dragEnd", { event: e });
}
return true;
}
/**
* Handle dragenter, dragover, dragleave, drop events.
*/
protected onDropEvent(e: DragEvent) {
// const isLink = event.dataTransfer.types.includes("text/uri-list");
const srcNode = this.srcNode;
const srcTree = srcNode ? srcNode.tree : null;
const targetNode = Wunderbaum.getNode(e)!;
const dndOpts: DndOptionsType = this.treeOpts.dnd;
const dt = e.dataTransfer!;
const dropRegion = this._calcDropRegion(e, this.lastAllowedDropRegions);
/** Helper to log a message if predicate is false. */
const _t = (pred: any, msg: string) => {
if (pred) {
this.tree.log(`Prevented drop operation (${msg}).`);
}
return pred;
};
if (!targetNode) {
this._leaveNode();
return;
}
if (["drop"].includes(e.type)) {
this.tree.logDebug(
`onDropEvent.${e.type} targetNode: ${targetNode}, ea: ${dt?.effectAllowed}, ` +
`de: ${dt?.dropEffect}, cy: ${e.offsetY}, r: ${dropRegion}, srcNode: ${srcNode}`,
e
);
}
// --- dragenter ---
if (e.type === "dragenter") {
// this.tree.logWarn(` onDropEvent.${e.type} targetNode: ${targetNode}`, e);
this.lastAllowedDropRegions = null;
// `dragleave` is not reliable with event delegation, so we generate it
// from dragenter:
if (this.lastTargetNode && this.lastTargetNode !== targetNode) {
this._leaveNode();
}
this.lastTargetNode = targetNode;
this.lastEnterStamp = Date.now();
if (
// Don't drop on status node:
_t(targetNode.isStatusNode(), "is status node") ||
// Prevent dropping nodes from different Wunderbaum trees:
_t(
dndOpts.preventForeignNodes && targetNode.tree !== srcTree,
"preventForeignNodes"
) ||
// Prevent dropping items on unloaded lazy Wunderbaum tree nodes:
_t(
dndOpts.preventLazyParents && !targetNode.isLoaded(),
"preventLazyParents"
) ||
// Prevent dropping items other than Wunderbaum tree nodes:
_t(dndOpts.preventNonNodes && !srcNode, "preventNonNodes") ||
// Prevent dropping nodes on own descendants:
_t(
dndOpts.preventRecursion && srcNode?.isAncestorOf(targetNode),
"preventRecursion"
) ||
// Prevent dropping nodes under same direct parent:
_t(
dndOpts.preventSameParent &&
srcNode &&
targetNode.parent === srcNode.parent,
"preventSameParent"
) ||
// Don't allow void operation ('drop on self'): TODO: should be checked on move only
_t(
dndOpts.preventVoidMoves && targetNode === srcNode,
"preventVoidMoves"
)
) {
dt.dropEffect = "none";
// this.tree.log("Prevented drop operation");
return true; // Prevent drop operation
}
// User may return a set of regions (or `false` to prevent drop)
// Figure out a drop effect (copy/link/move) using opinated conventions.
dt.dropEffect = this._guessDropEffect(e) || "none";
let regionSet = targetNode._callEvent("dnd.dragEnter", {
event: e,
sourceNode: srcNode,
});
//
regionSet = this.unifyDragover(regionSet);
if (!regionSet) {
dt.dropEffect = "none";
return true; // Prevent drop operation
}
this.lastAllowedDropRegions = regionSet;
this.lastDropEffect = dt.dropEffect;
const region = this._calcDropRegion(e, this.lastAllowedDropRegions);
targetNode.setClass("wb-drop-target");
targetNode.setClass("wb-drop-over", region === "over");
targetNode.setClass("wb-drop-before", region === "before");
targetNode.setClass("wb-drop-after", region === "after");
e.preventDefault(); // Allow drop (Drop operation is denied by default)
return false;
// --- dragover ---
} else if (e.type === "dragover") {
const viewportY = e.clientY - this.tree.element.offsetTop;
this._autoScroll(viewportY);
dt.dropEffect = this._guessDropEffect(e) || "none";
targetNode._callEvent("dnd.dragOver", { event: e, sourceNode: srcNode });
const region = this._calcDropRegion(e, this.lastAllowedDropRegions);
this.lastDropRegion = region;
this.lastDropEffect = dt.dropEffect;
if (
dndOpts.autoExpandMS! > 0 &&
targetNode.isExpandable(true) &&
!targetNode._isLoading &&
Date.now() - this.lastEnterStamp > dndOpts.autoExpandMS! &&
targetNode._callEvent("dnd.dragExpand", {
event: e,
sourceNode: srcNode,
}) !== false
) {
targetNode.setExpanded();
}
if (!region || this._isVoidDrop(targetNode, srcNode, region)) {
return; // We already rejected in dragenter
}
targetNode.setClass("wb-drop-over", region === "over");
targetNode.setClass("wb-drop-before", region === "before");
targetNode.setClass("wb-drop-after", region === "after");
e.preventDefault(); // Allow drop (Drop operation is denied by default)
return false;
// --- dragleave ---
} else if (e.type === "dragleave") {
// NOTE: we cannot trust this event, since it is always fired,
// Instead we remove the marker on dragenter
targetNode._callEvent("dnd.dragLeave", { event: e, sourceNode: srcNode });
// --- drop ---
} else if (e.type === "drop") {
e.stopPropagation(); // prevent browser from opening links?
e.preventDefault(); // #69 prevent iOS browser from opening links
this._leaveNode();
const region = this.lastDropRegion;
let nodeData = e.dataTransfer?.getData(nodeMimeType);
nodeData = nodeData ? JSON.parse(nodeData) : null;
const srcNode = this.srcNode;
const lastDropEffect = this.lastDropEffect;
setTimeout(() => {
// Decouple this call, because drop actions may prevent the dragend event
// from being fired on some browsers
targetNode._callEvent("dnd.drop", {
event: e,
region: region,
suggestedDropMode: region === "over" ? "appendChild" : region,
suggestedDropEffect: lastDropEffect,
// suggestedDropEffect: e.dataTransfer?.dropEffect,
sourceNode: srcNode,
sourceNodeData: nodeData,
});
}, 10);
}
return false;
}
}