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
106 changes: 74 additions & 32 deletions src/components/TreeView/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,25 +213,20 @@ class TreeView extends Container {

window.addEventListener('keydown', this._updateModifierKeys);
window.addEventListener('keyup', this._updateModifierKeys);
window.addEventListener('mousedown', this._updateModifierKeys);
window.addEventListener('pointerdown', this._updateModifierKeys);

this.dom.addEventListener('mouseleave', this._onMouseLeave);

this._dragHandle.dom.addEventListener('mousemove', this._onDragMove);
this._dragHandle.on('destroy', (dom) => {
dom.removeEventListener('mousemove', this._onDragMove);
});
this.dom.addEventListener('pointerleave', this._onPointerLeave);
}

destroy() {
if (this._destroyed) return;

window.removeEventListener('keydown', this._updateModifierKeys);
window.removeEventListener('keyup', this._updateModifierKeys);
window.removeEventListener('mousedown', this._updateModifierKeys);
window.removeEventListener('mousemove', this._onMouseMove);
window.removeEventListener('pointerdown', this._updateModifierKeys);
window.removeEventListener('pointermove', this._onPointerMove);

this.dom.removeEventListener('mouseleave', this._onMouseLeave);
this.dom.removeEventListener('pointerleave', this._onPointerLeave);

if (this._dragScrollInterval) {
window.clearInterval(this._dragScrollInterval);
Expand All @@ -241,7 +236,7 @@ class TreeView extends Container {
super.destroy();
}

protected _updateModifierKeys = (evt: KeyboardEvent | MouseEvent) => {
protected _updateModifierKeys = (evt: KeyboardEvent | PointerEvent) => {
this._pressedCtrl = evt.ctrlKey || evt.metaKey;
this._pressedShift = evt.shiftKey;
};
Expand Down Expand Up @@ -565,7 +560,7 @@ class TreeView extends Container {
}

// Called when we start dragging a TreeViewItem.
protected _onChildDragStart(evt: MouseEvent, item: TreeViewItem) {
protected _onChildDragStart(evt: PointerEvent | DragEvent, item: TreeViewItem) {
if (!this.allowDrag || this._dragging) return;

this._dragItems = [];
Expand Down Expand Up @@ -622,7 +617,7 @@ class TreeView extends Container {
}

// Called when we stop dragging a TreeViewItem.
protected _onChildDragEnd(evt: MouseEvent, item: TreeViewItem) {
protected _onChildDragEnd(evt: PointerEvent, item: TreeViewItem) {
if (!this.allowDrag || !this._dragging) return;

this._dragItems.forEach(item => item.class.remove(CLASS_DRAGGED_ITEM));
Expand Down Expand Up @@ -769,7 +764,8 @@ class TreeView extends Container {
}

// Called when we drag over a TreeViewItem.
protected _onChildDragOver(evt: MouseEvent, item: TreeViewItem) {
// evt can be PointerEvent (from pointerover) or DragEvent (from dragover for HTML5 drag-and-drop)
protected _onChildDragOver(evt: PointerEvent | DragEvent, item: TreeViewItem) {
if (!this._allowDrag || !this._dragging) return;

if (item.allowDrop && this._dragItems.indexOf(item) === -1) {
Expand All @@ -782,16 +778,16 @@ class TreeView extends Container {
this._onDragMove(evt);
}

// Called when the mouse cursor leaves the tree view.
protected _onMouseLeave = (evt: MouseEvent) => {
// Called when the pointer leaves the tree view.
protected _onPointerLeave = (evt: PointerEvent) => {
if (!this._allowDrag || !this._dragging) return;

this._dragOverItem = null;
this._updateDragHandle();
};

// Called when the mouse moves while dragging
protected _onMouseMove = (evt: MouseEvent) => {
// Called when the pointer moves while dragging
protected _onPointerMove = (evt: PointerEvent) => {
if (!this._dragging) return;

// Determine if we need to scroll the treeview if we are dragging towards the edges
Expand All @@ -814,6 +810,49 @@ class TreeView extends Container {
} else if (evt.pageY > bottom - 32 && this._dragScrollElement.dom.scrollHeight > this._dragScrollElement.height + this._dragScrollElement.dom.scrollTop) {
this._dragScroll = 1;
}

// For mouse: if we have a drag target, continuously recalculate the drag area
// (pointerover only fires on element entry, but we need continuous updates for BEFORE/INSIDE/AFTER)
if (evt.pointerType === 'mouse' && this._dragOverItem) {
this._onDragMove(evt);
return;
}

// For touch/pen, find drop target by Y coordinate since finger may still be over dragged item
// (items are stacked vertically and don't overlap, so elementsFromPoint doesn't help)
if (evt.pointerType !== 'mouse') {
const contentsElements = this.dom.querySelectorAll('.pcui-treeview-item-contents');
let closestItem: TreeViewItem | null = null;
let closestDistance = Infinity;

for (const contentsElement of contentsElements) {
const rect = contentsElement.getBoundingClientRect();
// Skip items with no visible bounds (hidden/collapsed)
if (rect.height === 0) continue;

// DOM structure: .pcui-treeview-item > .pcui-treeview-item-contents
// PCUI convention: each Element's DOM node has a `ui` property referencing the Element instance
const item = (contentsElement.parentElement as any)?.ui as TreeViewItem;
// Skip if this is one of the items being dragged (instanceof also validates the ui property exists)
if (item && item instanceof TreeViewItem && this._dragItems.indexOf(item) === -1) {
// Find the item whose center is closest to the pointer Y
// (don't require pointer to be within bounds - handles gaps between items)
const centerY = (rect.top + rect.bottom) / 2;
const distance = Math.abs(evt.clientY - centerY);
if (distance < closestDistance) {
closestDistance = distance;
closestItem = item;
}
}
}

if (closestItem) {
this._onChildDragOver(evt, closestItem);
} else {
this._dragOverItem = null;
this._updateDragHandle();
}
}
};

// Scroll treeview if we are dragging towards the edges
Expand All @@ -822,18 +861,23 @@ class TreeView extends Container {
if (this._dragScroll === 0) return;

this._dragScrollElement.dom.scrollTop += this._dragScroll * 8;
this._dragOverItem = null;
this._updateDragHandle();
// Don't clear _dragOverItem here - pointer events will update it naturally
// as items scroll in/out of view. Clearing it causes flickering when the
// pointer is near the edge but over a valid drop target.
}

// Called while we drag the drag handle
protected _onDragMove = (evt: MouseEvent) => {
// evt can be PointerEvent or DragEvent - both have clientY which we need
protected _onDragMove = (evt: PointerEvent | DragEvent) => {
evt.preventDefault();
evt.stopPropagation();

if (!this._allowDrag || !this._dragOverItem) return;

const rect = this._dragHandle.dom.getBoundingClientRect();
// Use the target item's contents rect for area calculation, not the drag handle's
// (drag handle height varies by CSS class, but contents is always the same height)
// @ts-ignore
const rect = this._dragOverItem._containerContents.dom.getBoundingClientRect();
const area = Math.floor((evt.clientY - rect.top) / rect.height * 5);

const oldArea = this._dragArea;
Expand Down Expand Up @@ -1112,23 +1156,21 @@ class TreeView extends Container {
this._dragging = true;
this._updateDragHandle();

// handle mouse move to scroll when dragging if necessary
if (this.scrollable || this._dragScrollElement !== this) {
window.removeEventListener('mousemove', this._onMouseMove);
window.addEventListener('mousemove', this._onMouseMove);
if (!this._dragScrollInterval) {
this._dragScrollInterval = window.setInterval(() => {
this._scrollWhileDragging();
}, 1000 / 60);
}
// Handle pointer move for scrolling and touch drag-over detection
window.removeEventListener('pointermove', this._onPointerMove);
window.addEventListener('pointermove', this._onPointerMove);
if (!this._dragScrollInterval) {
this._dragScrollInterval = window.setInterval(() => {
this._scrollWhileDragging();
}, 1000 / 60);
}
} else {
this._dragOverItem = null;
this._updateDragHandle();

this._dragging = false;

window.removeEventListener('mousemove', this._onMouseMove);
window.removeEventListener('pointermove', this._onPointerMove);
if (this._dragScrollInterval) {
window.clearInterval(this._dragScrollInterval);
this._dragScrollInterval = null;
Expand Down
2 changes: 2 additions & 0 deletions src/components/TreeView/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
align-items: center;
height: 24px;
box-sizing: border-box;
touch-action: none; // Prevent browser from cancelling pointer events for touch gestures

&:hover {
cursor: pointer;
Expand Down Expand Up @@ -156,6 +157,7 @@
z-index: 4;
margin-top: -1px;
margin-left: -1px;
pointer-events: none; // Don't interfere with hit-testing during drag

&.before {
border-top: 4px solid $text-active;
Expand Down
104 changes: 90 additions & 14 deletions src/components/TreeViewItem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ class TreeViewItem extends Container {

protected _open = false;

protected _dragPointerId: number = -1;

// For touch/pen drag threshold - only start drag after moving beyond this distance
protected _dragStartX: number = 0;

protected _dragStartY: number = 0;

protected _dragThresholdMet: boolean = false;

/**
* Creates a new TreeViewItem.
*
Expand Down Expand Up @@ -193,8 +202,9 @@ class TreeViewItem extends Container {
dom.addEventListener('blur', this._onContentBlur);
dom.addEventListener('keydown', this._onContentKeyDown);
dom.addEventListener('dragstart', this._onContentDragStart);
dom.addEventListener('mousedown', this._onContentMouseDown);
dom.addEventListener('mouseover', this._onContentMouseOver);
dom.addEventListener('dragover', this._onContentDragOver);
dom.addEventListener('pointerdown', this._onContentPointerDown);
dom.addEventListener('pointerover', this._onContentPointerOver);
dom.addEventListener('click', this._onContentClick);
dom.addEventListener('dblclick', this._onContentDblClick);
dom.addEventListener('contextmenu', this._onContentContextMenu);
Expand All @@ -208,13 +218,15 @@ class TreeViewItem extends Container {
dom.removeEventListener('blur', this._onContentBlur);
dom.removeEventListener('keydown', this._onContentKeyDown);
dom.removeEventListener('dragstart', this._onContentDragStart);
dom.removeEventListener('mousedown', this._onContentMouseDown);
dom.removeEventListener('mouseover', this._onContentMouseOver);
dom.removeEventListener('dragover', this._onContentDragOver);
dom.removeEventListener('pointerdown', this._onContentPointerDown);
dom.removeEventListener('pointerover', this._onContentPointerOver);
dom.removeEventListener('click', this._onContentClick);
dom.removeEventListener('dblclick', this._onContentDblClick);
dom.removeEventListener('contextmenu', this._onContentContextMenu);

window.removeEventListener('mouseup', this._onContentMouseUp);
window.removeEventListener('pointermove', this._onContentPointerMove);
window.removeEventListener('pointerup', this._onContentPointerUp);

super.destroy();
}
Expand Down Expand Up @@ -263,28 +275,79 @@ class TreeViewItem extends Container {
}
};

protected _onContentMouseDown = (evt: MouseEvent) => {
protected _onContentPointerDown = (evt: PointerEvent) => {
if (!this._treeView || !this._treeView.allowDrag || !this.allowDrag) return;

this._treeView._updateModifierKeys(evt);
evt.stopPropagation();

// For touch/pen input, set up for potential drag (don't start until threshold is met)
if (evt.pointerType !== 'mouse') {
if (this.class.contains(CLASS_RENAME)) return;

this._dragPointerId = evt.pointerId;
this._dragStartX = evt.clientX;
this._dragStartY = evt.clientY;
this._dragThresholdMet = false;

window.addEventListener('pointermove', this._onContentPointerMove);
window.addEventListener('pointerup', this._onContentPointerUp);
}
};

protected _onContentMouseUp = (evt: MouseEvent) => {
protected _onContentPointerMove = (evt: PointerEvent) => {
// Only handle the pointer that initiated the potential drag
if (evt.pointerId !== this._dragPointerId) return;

// Check if drag threshold has been met (5 pixels)
const dx = evt.clientX - this._dragStartX;
const dy = evt.clientY - this._dragStartY;
const distance = Math.sqrt(dx * dx + dy * dy);

if (!this._dragThresholdMet && distance >= 5) {
this._dragThresholdMet = true;

// Now actually start the drag
if (this._treeView) {
this._treeView._onChildDragStart(evt, this);
}
}
};

protected _onContentPointerUp = (evt: PointerEvent) => {
// _dragPointerId === -1 means mouse drag via HTML5 dragstart
// _dragPointerId !== -1 means touch/pen drag - only handle matching pointer
const isMouseDrag = this._dragPointerId === -1;
const isTouchDrag = this._dragPointerId !== -1;

if (isTouchDrag && evt.pointerId !== this._dragPointerId) return;

evt.stopPropagation();
evt.preventDefault();

window.removeEventListener('mouseup', this._onContentMouseUp);
if (this._treeView) {
this._treeView._onChildDragEnd(evt, this);
window.removeEventListener('pointermove', this._onContentPointerMove);
window.removeEventListener('pointerup', this._onContentPointerUp);

// For mouse: always end the drag (HTML5 drag-and-drop handles the drag)
// For touch/pen: only end drag if threshold was met, otherwise it was just a tap
if (isMouseDrag || this._dragThresholdMet) {
evt.preventDefault();
if (this._treeView) {
this._treeView._onChildDragEnd(evt, this);
}
}

this._dragPointerId = -1;
this._dragThresholdMet = false;
};

protected _onContentMouseOver = (evt: MouseEvent) => {
protected _onContentPointerOver = (evt: PointerEvent) => {
evt.stopPropagation();

if (this._treeView) {
this._treeView._onChildDragOver(evt, this);
// Skip drag-over handling for touch/pen during drags - hit-testing in _onPointerMove handles this
if (!(this._treeView.isDragging && evt.pointerType !== 'mouse')) {
this._treeView._onChildDragOver(evt, this);
}
}

this.emit('hover', evt);
Expand All @@ -296,11 +359,24 @@ class TreeViewItem extends Container {

if (!this._treeView || !this._treeView.allowDrag) return;

// Skip if already dragging (e.g., drag was initiated by pointerdown for touch)
if (this._treeView.isDragging) return;

if (this.class.contains(CLASS_RENAME)) return;

this._treeView._onChildDragStart(evt, this);

window.addEventListener('mouseup', this._onContentMouseUp);
window.addEventListener('pointerup', this._onContentPointerUp);
};

// HTML5 dragover fires continuously while dragging over an element (unlike pointerover)
protected _onContentDragOver = (evt: DragEvent) => {
evt.preventDefault(); // Required to allow drop
evt.stopPropagation();

if (this._treeView) {
this._treeView._onChildDragOver(evt, this);
}
};

protected _onContentClick = (evt: MouseEvent) => {
Expand Down