feat(projectFolders): better tree component using angular cdk drag and drop

This commit is contained in:
Johannes Millan 2025-09-20 14:03:08 +02:00
parent 0dcba57221
commit 268656d9cf
16 changed files with 1128 additions and 751 deletions

View file

@ -49,9 +49,9 @@
draggable="false"
>
<tree-dnd
[data]="treeNodes()"
[indent]="20"
(updated)="onTreeUpdated($event)"
[nodes]="treeNodes()"
(nodesChange)="onTreeUpdated($event)"
[indent]="16"
>
<!-- Template for folder nodes -->
<ng-template
@ -65,7 +65,7 @@
[container]="'action'"
[expanded]="expanded"
[icon]="expanded ? 'folder_open' : 'folder'"
[label]="node.data?.kind === 'folder' ? node.data.name : node.label"
[label]="node.data?.kind === 'folder' ? node.data.name : node.data.label"
[variant]="'nav'"
[showLabels]="showLabels()"
[showMoreButton]="false"
@ -142,7 +142,13 @@
<mat-icon [ngStyle]="{ 'font-size': '18px', width: '18px', height: '18px' }">
{{ data.isFolder ? 'folder' : 'assignment' }}
</mat-icon>
<span>{{ data.node.label }}</span>
<span>{{
data.node.data?.kind === 'folder'
? data.node.data.name
: data.node.data?.kind === 'project'
? data.node.data.project.title
: data.node.data?.tag.title
}}</span>
</div>
</ng-template>
</tree-dnd>

View file

@ -133,7 +133,6 @@ export class NavListTreeComponent {
if (node.kind === 'folder') {
return {
id: `folder-${node.id}`,
label: node.name,
isFolder: true,
expanded: node.isExpanded,
data: node,
@ -143,14 +142,12 @@ export class NavListTreeComponent {
if (node.kind === 'project') {
return {
id: `project-${node.project.id}`,
label: node.project.title,
isFolder: false,
data: node,
} satisfies TreeNode<MenuTreeViewNode>;
}
return {
id: `tag-${node.tag.id}`,
label: node.tag.title,
isFolder: false,
data: node,
} satisfies TreeNode<MenuTreeViewNode>;

View file

@ -1,129 +0,0 @@
import {
DestroyRef,
Directive,
effect,
ElementRef,
inject,
input,
TemplateRef,
ViewContainerRef,
Injector,
} from '@angular/core';
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview';
import { makeDragData } from './dnd.helpers';
@Directive({
selector: '[dndDraggable]',
standalone: true,
})
export class DndDraggableDirective {
private el = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
private vcr = inject(ViewContainerRef);
// Inputs (signal-based)
id = input.required<string>({ alias: 'dndDraggable' });
dndContext = input.required<symbol>();
dragPreviewTemplate = input<TemplateRef<any> | null>(null);
dragPreviewData = input<any>(null);
private cleanup: (() => void) | null = null;
private _bindEffect = effect(
() => {
// Rebind when id/context changes
if (this.cleanup) {
this.cleanup();
this.cleanup = null;
}
const id = this.id();
const ctx = this.dndContext();
this.cleanup = draggable({
element: this.el.nativeElement,
getInitialData: () => makeDragData(ctx, id),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: '8px', y: '8px' }),
nativeSetDragImage,
render: ({ container }) => {
const template = this.dragPreviewTemplate();
if (template) {
return this.renderCustomTemplate(template, container);
} else {
return this.renderDefaultPreview(container);
}
},
});
},
});
},
{ injector: inject(Injector) },
);
constructor() {
this.destroyRef.onDestroy(() => this.cleanup?.());
}
private renderCustomTemplate(
template: TemplateRef<any>,
container: HTMLElement,
): () => void {
// Create embedded view from the template
const context = {
$implicit: this.dragPreviewData(),
id: this.id(),
element: this.el.nativeElement,
};
const embeddedView = this.vcr.createEmbeddedView(template, context);
// Add the rendered template nodes to the container
embeddedView.rootNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
container.appendChild(node);
}
});
// Manually trigger change detection to ensure the view is properly rendered
embeddedView.detectChanges();
return () => {
embeddedView.destroy();
};
}
private renderDefaultPreview(container: HTMLElement): () => void {
// Create a clean preview with just the item's text content
const preview = document.createElement('div');
preview.style.cssText = `
background: var(--background-color, #ffffff);
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
padding: 8px 12px;
font-family: inherit;
font-size: inherit;
color: var(--text-color, #333);
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
`;
// Get the text content of the dragged item
const textElement = this.el.nativeElement.querySelector('.nav-label, .label, span');
const text =
textElement?.textContent?.trim() ||
this.el.nativeElement.textContent?.trim() ||
'Item';
preview.textContent = text;
container.appendChild(preview);
return () => {
preview.remove();
};
}
}

View file

@ -1,88 +0,0 @@
import {
DestroyRef,
Directive,
effect,
ElementRef,
inject,
input,
output,
Injector,
} from '@angular/core';
import {
dropTargetForElements,
type ElementDropTargetEventBasePayload,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { DropData } from './tree.types';
import { asDragData, makeDropData } from './dnd.helpers';
@Directive({
selector: '[dndDropTarget]',
standalone: true,
})
export class DndDropTargetDirective {
private el = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
// Inputs (modern signal-based)
config = input<{ id: string; where: DropData['where'] } | null>(null, {
alias: 'dndDropTarget',
});
dndContext = input.required<symbol>();
// Outputs (modern signal-based)
activeChange = output<boolean>();
indicator = output<{
active: boolean;
element: HTMLElement;
where: DropData['where'];
}>();
private cleanup: (() => void) | null = null;
// Rebind drop target whenever config/context changes
private _bindEffect = effect(
() => {
// ensure cleanup of previous binding
this.cleanup?.();
this.cleanup = null;
const cfg = this.config();
if (!cfg) {
return; // disabled target
}
const where = cfg.where;
this.cleanup = dropTargetForElements({
element: this.el.nativeElement,
canDrop: ({ source }) =>
asDragData(source.data)?.uniqueContextId === this.dndContext(),
getData: () => makeDropData({ type: 'drop', id: cfg.id, where }),
onDragStart: (p) => this.onActive(p, where),
onDropTargetChange: (p) => this.onActive(p, where),
onDragLeave: () => {
this.activeChange.emit(false);
this.indicator.emit({ active: false, element: this.el.nativeElement, where });
},
onDrop: () => {
this.activeChange.emit(false);
this.indicator.emit({ active: false, element: this.el.nativeElement, where });
},
});
},
{ injector: inject(Injector) },
);
constructor() {
this.destroyRef.onDestroy(() => this.cleanup?.());
}
private onActive(
{ location, self }: ElementDropTargetEventBasePayload,
where: DropData['where'],
): void {
const list = location.current.dropTargets;
const innerMost = list[list.length - 1];
const isActive = innerMost?.element === self.element;
this.activeChange.emit(isActive);
this.indicator.emit({ active: isActive, element: this.el.nativeElement, where });
}
}

View file

@ -1,29 +0,0 @@
import { asDragData, asDropData, makeDragData, makeDropData } from './dnd.helpers';
describe('dnd.helpers', () => {
it('creates and parses DragData safely', () => {
const ctx = Symbol('ctx');
const anyData = makeDragData(ctx, 'X');
const parsed = asDragData(anyData);
expect(parsed).toBeTruthy();
expect(parsed!.id).toBe('X');
expect(parsed!.uniqueContextId).toBe(ctx);
});
it('rejects invalid DragData', () => {
const parsed = asDragData({ foo: 'bar' });
expect(parsed).toBeNull();
});
it('creates and parses DropData safely', () => {
const anyDrop = makeDropData({ type: 'drop', id: 'A', where: 'before' });
const parsed = asDropData(anyDrop);
expect(parsed).toEqual({ type: 'drop', id: 'A', where: 'before' });
});
it('handles root drop', () => {
const anyDrop = makeDropData({ type: 'drop', id: '', where: 'root' });
const parsed = asDropData(anyDrop);
expect(parsed).toEqual({ type: 'drop', id: '', where: 'root' });
});
});

View file

@ -1,30 +0,0 @@
import type { DragData, DropData } from './tree.types';
type AnyData = Record<string | symbol, unknown>;
export const makeDragData = (ctx: symbol, id: string): AnyData => {
const data: DragData = { type: 'item', id, uniqueContextId: ctx };
return data as unknown as AnyData;
};
export const makeDropData = (data: DropData): AnyData => data as unknown as AnyData;
export const asDragData = (data: unknown): DragData | null => {
if (!data || typeof data !== 'object') return null;
const d = data as Partial<DragData>;
return d.type === 'item' &&
typeof d.id === 'string' &&
typeof d.uniqueContextId === 'symbol'
? ({ type: 'item', id: d.id, uniqueContextId: d.uniqueContextId } as DragData)
: null;
};
export const asDropData = (data: unknown): DropData | null => {
if (!data || typeof data !== 'object') return null;
const d = data as Partial<DropData>;
if (d.type !== 'drop' || typeof d.where !== 'string') return null;
if (d.where === 'root') return { type: 'drop', id: '', where: 'root' };
if (typeof d.id === 'string')
return { type: 'drop', id: d.id, where: d.where as DropData['where'] };
return null;
};

View file

@ -0,0 +1,5 @@
export const TREE_CONSTANTS = {
DROP_FLASH_DURATION: 300,
ANIMATION_DURATION: 300,
DEFAULT_INDENT: 16,
} as const;

View file

@ -0,0 +1,220 @@
import { Injectable } from '@angular/core';
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import {
DropWhere,
MoveInstruction,
TreeNode,
TreeId,
HoverTarget,
DropListContext,
CanDropPredicate,
PointerPosition,
} from './tree.types';
// Function type definitions for dependency injection
type NodeFinderFn<TData = unknown> = (id: TreeId) => TreeNode<TData> | null;
type NodesFn<TData = unknown> = () => TreeNode<TData>[];
type SiblingsFn<TData = unknown> = (parentId: TreeId) => readonly TreeNode<TData>[];
type IsFolderFn<TData = unknown> = (node: TreeNode<TData>) => boolean;
type IsAncestorFn = (ancestorId: TreeId, descendantId: TreeId) => boolean;
// Drop zone calculation constants for folders
const FOLDER_THRESHOLD_RATIO = 0.2 as const; // 20% of item height for before/after zones (folders)
const FOLDER_MAX_THRESHOLD = 8 as const; // Maximum pixel threshold for folders
/**
* Service responsible for converting drag/drop interactions into tree move instructions.
*
* This service handles the complex logic of determining where a dragged item should be moved
* based on different types of drop events. It provides two main approaches:
*
* 1. **Hover-based detection**: Uses mouse position to determine precise drop zones
* 2. **Drop-list-based detection**: Uses CDK's container and index information
*
* The service is stateless and requires callback functions to access tree data,
* making it reusable across different tree component instances.
*/
@Injectable()
export class TreeDragService {
/**
* Builds a move instruction based on precise mouse hover position.
*
* This method is used when the user hovers over specific drop zones during drag.
* It provides pixel-perfect feedback for where the item will be dropped.
*
* @param dragId - ID of the item being dragged
* @param hover - Contains the target element, drop zone (before/after/inside), and target ID
* @param findNode - Function to find a node by ID in the tree
* @param getNodes - Function to get all root nodes
* @param isFolder - Function to check if a node can contain children
* @param isNodeAncestor - Function to prevent dropping into descendants
* @param canDrop - Function to validate if the drop is allowed by business rules
* @returns MoveInstruction or null if the drop is invalid
*/
buildInstructionFromHover<TData = unknown>(
dragId: TreeId,
hover: HoverTarget,
findNode: NodeFinderFn<TData>,
getNodes: NodesFn<TData>,
isFolder: IsFolderFn<TData>,
isNodeAncestor: IsAncestorFn,
canDrop: CanDropPredicate<TData>,
): MoveInstruction | null {
const dragNode = findNode(dragId);
if (!dragNode) return null;
// Handle dropping to root level (empty area at bottom of tree)
if (hover.where === 'root') {
if (!canDrop({ drag: dragNode, drop: null, where: 'root' })) return null;
const roots = getNodes().filter((n) => n.id !== dragId);
// If no other root items, place as first root item
if (!roots.length) return { itemId: dragId, targetId: '', where: 'inside' };
// Otherwise, place after the last root item
return { itemId: dragId, targetId: roots[roots.length - 1].id, where: 'after' };
}
// Can't drop on self
if (dragId === hover.id) return null;
const targetNode = findNode(hover.id);
// Can't drop on non-existent nodes or into descendants (would create cycles)
if (!targetNode || isNodeAncestor(dragId, hover.id)) return null;
// Handle dropping inside a folder
if (hover.where === 'inside') {
if (
!isFolder(targetNode) ||
!canDrop({ drag: dragNode, drop: targetNode, where: 'inside' })
)
return null;
return { itemId: dragId, targetId: hover.id, where: 'inside' };
}
// Handle dropping before/after an item (sibling positioning)
if (!canDrop({ drag: dragNode, drop: targetNode, where: hover.where })) return null;
return { itemId: dragId, targetId: hover.id, where: hover.where };
}
/**
* Builds a move instruction based on Angular CDK drop list container and index.
*
* This method is used when items are dropped into CDK drop lists. It's more reliable
* for list ordering but less precise than hover-based detection for folder targeting.
* The CDK provides the target container and the index where the item was dropped.
*
* @param dragId - ID of the item being dragged
* @param event - CDK drop event containing container data and drop index
* @param findNode - Function to find a node by ID in the tree
* @param getSiblings - Function to get siblings of a parent node
* @param isFolder - Function to check if a node can contain children
* @param isNodeAncestor - Function to prevent dropping into descendants
* @param canDrop - Function to validate if the drop is allowed by business rules
* @returns MoveInstruction or null if the drop is invalid
*/
buildInstructionFromDropEvent<TData = unknown>(
dragId: TreeId,
event: CdkDragDrop<DropListContext<TData>>,
findNode: NodeFinderFn<TData>,
getSiblings: SiblingsFn<TData>,
isFolder: IsFolderFn<TData>,
isNodeAncestor: IsAncestorFn,
canDrop: CanDropPredicate<TData>,
): MoveInstruction | null {
const dragNode = findNode(dragId);
if (!dragNode) return null;
// Extract drop container information
const containerData = event.container.data;
const parentId = containerData?.parentId ?? '';
// Prevent dropping into descendants (would create invalid tree structure)
if (parentId && isNodeAncestor(dragId, parentId)) return null;
// Get siblings in the target container, excluding the dragged item
const siblings = (containerData?.items ?? getSiblings(parentId)).filter(
(n) => n.id !== dragId,
) as TreeNode<TData>[];
// Clamp the drop index to valid range
const index = Math.min(Math.max(event.currentIndex, 0), siblings.length);
// Handle dropping into root level (top-level container)
if (!parentId) {
if (!canDrop({ drag: dragNode, drop: null, where: 'root' })) return null;
// If no siblings, this becomes the first root item
if (!siblings.length) return { itemId: dragId, targetId: '', where: 'inside' };
// If dropped past the end, place after the last sibling
if (index >= siblings.length)
return {
itemId: dragId,
targetId: siblings[siblings.length - 1].id,
where: 'after',
};
// Otherwise, place before the sibling at the drop index
return { itemId: dragId, targetId: siblings[index].id, where: 'before' };
}
// Handle dropping into a folder
const parentNode = findNode(parentId);
if (!parentNode || !isFolder(parentNode)) return null;
// If folder is empty, place as first child
if (!siblings.length) {
if (!canDrop({ drag: dragNode, drop: parentNode, where: 'inside' })) return null;
return { itemId: dragId, targetId: parentId, where: 'inside' };
}
// Place relative to sibling at the drop index
const targetId = siblings[Math.min(index, siblings.length - 1)].id;
const targetNode = findNode(targetId);
if (!targetNode) return null;
// If dropped past the end, place after the last sibling; otherwise before the target sibling
const where = index >= siblings.length ? 'after' : 'before';
if (!canDrop({ drag: dragNode, drop: targetNode, where })) return null;
return { itemId: dragId, targetId, where };
}
/**
* Calculates which drop zone (before/after/inside) the pointer is in.
* This uses proportional thresholds - folders get smaller before/after zones
* to make the "inside" zone easier to target.
*
* Example for a 40px tall item:
* - Files: 10px before, 20px inside, 10px after (25% each side)
* - Folders: 8px before, 24px inside, 8px after (20% each side)
*/
calculateHoverZone(
pointer: PointerPosition,
itemRect: DOMRect,
isTargetFolder?: boolean,
): DropWhere {
const offsetY = pointer.y - itemRect.top;
// Different logic for folders vs regular items
if (isTargetFolder) {
// Folders get smaller before/after zones so "inside" is easier to target
const threshold = Math.min(
itemRect.height * FOLDER_THRESHOLD_RATIO,
FOLDER_MAX_THRESHOLD,
);
if (offsetY < threshold) return 'before';
if (offsetY > itemRect.height - threshold) return 'after';
return 'inside';
} else {
// Regular items: no inside zone, just before/after based on which half
const midpoint = itemRect.height / 2;
return offsetY < midpoint ? 'before' : 'after';
}
}
/**
* Simple utility to check if a point is within a rectangle.
* Used for boundary detection during drag operations.
*/
isPointInRect(point: PointerPosition, rect: DOMRect): boolean {
return (
point.x >= rect.left &&
point.x <= rect.right &&
point.y >= rect.top &&
point.y <= rect.bottom
);
}
}

View file

@ -0,0 +1,81 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'tree-drop-zones',
standalone: true,
template: `
<div
class="drop drop--before"
data-drop-zone="before"
[attr.data-node-id]="nodeId()"
[class.is-over]="overBefore()"
></div>
<div
class="drop drop--after"
data-drop-zone="after"
[attr.data-node-id]="nodeId()"
[class.is-over]="overAfter()"
></div>
`,
styles: [
`
.drop {
height: 50%;
pointer-events: none;
width: 100%;
background: transparent;
position: absolute;
z-index: 1;
:host-context(.tree.is-dragging) & {
pointer-events: all;
background: rgba(0, 123, 255, 0.1);
z-index: 10;
}
&:hover {
background: rgba(0, 123, 255, 0.15);
}
}
.drop--before,
.drop--after {
}
.drop--before {
top: 0px;
:host-context(.tree.is-dragging) & {
background: rgba(255, 165, 0, 0.1); /* Light orange when dragging */
}
&.is-over {
background: rgba(
255,
165,
0,
0.25
) !important; /* Darker orange when hovering */
}
}
.drop--after {
bottom: 0px;
:host-context(.tree.is-dragging) & {
background: rgba(34, 139, 34, 0.1); /* Light green when dragging */
}
&.is-over {
background: rgba(34, 139, 34, 0.25) !important; /* Darker green when hovering */
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TreeDropZonesComponent {
readonly nodeId = input.required<string>();
readonly overBefore = input(false);
readonly overAfter = input(false);
}

View file

@ -0,0 +1,23 @@
import { TreeId, TreeNode } from './tree.types';
export const isTreeId = (value: unknown): value is TreeId =>
typeof value === 'string' && value.length > 0;
export const isTreeNode = <TData = unknown>(value: unknown): value is TreeNode<TData> => {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return 'id' in obj && isTreeId(obj['id']);
};
export const assertTreeId: (
value: unknown,
context?: string,
) => asserts value is TreeId = (
value: unknown,
context = 'value',
): asserts value is TreeId => {
if (!isTreeId(value)) {
throw new TypeError(`Expected ${context} to be a valid TreeId, got: ${typeof value}`);
}
};

View file

@ -0,0 +1,108 @@
import { computed, Injectable, signal, Signal } from '@angular/core';
import { DropWhere } from './tree.types';
interface IndicatorStyle {
readonly display: string;
readonly top: string;
readonly left: string;
readonly width: string;
}
interface ElementLayout {
readonly rect: DOMRect;
readonly paddingLeft: number;
}
interface LastTarget {
readonly element: HTMLElement;
readonly where: DropWhere;
}
@Injectable()
export class TreeIndicatorService {
private readonly _indicatorTop = signal(0);
private readonly _indicatorLeft = signal(0);
private readonly _indicatorWidth = signal(0);
private readonly _indicatorVisible = signal(false);
private _containerRect: DOMRect | null = null;
private _elementLayoutCache = new WeakMap<HTMLElement, ElementLayout>();
private _lastTarget: LastTarget | null = null;
readonly indicatorStyle: Signal<IndicatorStyle> = computed(
() =>
({
display: this._indicatorVisible() ? 'block' : 'none',
top: `${this._indicatorTop()}px`,
left: `${this._indicatorLeft()}px`,
width: `${this._indicatorWidth()}px`,
}) as const,
);
show(
element: HTMLElement,
where: DropWhere,
container: HTMLElement,
indent: number,
): void {
const sameTarget =
this._lastTarget?.element === element && this._lastTarget?.where === where;
if (sameTarget && this._indicatorVisible()) return;
this._lastTarget = { element, where };
this._containerRect ??= container.getBoundingClientRect();
const containerRect = this._containerRect;
let elementLayout = this._elementLayoutCache.get(element);
if (!elementLayout) {
const rect = element.getBoundingClientRect();
const itemEl = element.closest('.item') as HTMLElement | null;
const paddingLeft = itemEl ? this._parsePaddingLeft(itemEl) : 0;
elementLayout = { rect, paddingLeft } as const;
this._elementLayoutCache.set(element, elementLayout);
}
const elRect = elementLayout.rect;
const isAfter = where === 'after';
const y =
elRect.top -
containerRect.top +
(isAfter || where === 'inside' ? elRect.height : 0);
let left = 0;
let width = containerRect.width;
const itemEl = element.closest('.item') as HTMLElement | null;
if (itemEl) {
const itemRect = itemEl.getBoundingClientRect();
const paddingLeft = elementLayout.paddingLeft;
const extraIndent = where === 'inside' ? indent : 0;
left = itemRect.left - containerRect.left + paddingLeft + extraIndent;
width = Math.max(0, containerRect.width - left);
} else if (where === 'root') {
left = 0;
width = containerRect.width;
}
this._indicatorTop.set(Math.round(y));
this._indicatorLeft.set(Math.max(0, Math.round(left)));
this._indicatorWidth.set(Math.max(0, Math.round(width)));
this._indicatorVisible.set(true);
}
hide(): void {
this._indicatorVisible.set(false);
this._lastTarget = null;
this._containerRect = null;
}
clear(): void {
this.hide();
this._elementLayoutCache = new WeakMap();
}
private _parsePaddingLeft(element: HTMLElement): number {
const paddingLeftStr = getComputedStyle(element).paddingLeft || '0';
return parseFloat(paddingLeftStr) || 0;
}
}

View file

@ -3,34 +3,37 @@
role="tree"
aria-label="Tree"
[class.is-dragging]="draggingId()"
#root
[class.is-drag-invalid]="isDragInvalid()"
cdkDropListGroup
>
@for (node of nodes(); track node.id) {
<ng-container
*ngTemplateOutlet="nodeTpl; context: { $implicit: node, level: 0 }"
></ng-container>
}
<!-- root drop area: drop as last item in root -->
<div
class="root-drop"
[class.is-over]="rootOver()"
[dndDropTarget]="{ id: '', where: 'root' }"
[dndContext]="contextId"
(activeChange)="onRootActive($event)"
(indicator)="onIndicator($event)"
></div>
<!-- floating drop indicator -->
<div
class="indicator"
[style.display]="indicatorVisible() ? 'block' : 'none'"
[style.top.px]="indicatorTop()"
[style.left.px]="indicatorLeft()"
[style.width.px]="indicatorWidth()"
class="list list--root"
cdkDropList
[cdkDropListData]="{ parentId: 'root', items: nodes() }"
[cdkDropListEnterPredicate]="canEnterList"
(cdkDropListDropped)="onDrop($event)"
>
<span class="indicator-dot"></span>
<span class="indicator-line"></span>
@for (node of nodes(); track node.id) {
<ng-container
*ngTemplateOutlet="nodeTpl; context: { $implicit: node, level: 0 }"
></ng-container>
}
<div
class="root-drop"
data-drop-zone="root"
[class.is-root-over]="isRootOver()"
></div>
</div>
@if (draggingId()) {
<div
class="indicator"
[ngStyle]="indicatorStyle()"
>
<span class="indicator-dot"></span>
<span class="indicator-line"></span>
</div>
}
</div>
<ng-template
@ -38,88 +41,70 @@
let-node
let-level="level"
>
@let folder = node.children !== undefined;
@let overInside = isOver(node.id, 'inside');
@let overBefore = isOver(node.id, 'before');
@let overAfter = isOver(node.id, 'after');
@let indentPx = level * indent();
<div
class="item"
[class.is-folder]="isFolder(node)"
[class.is-dragging]="draggingId() === node.id"
[class.just-dropped]="justDroppedId() === node.id"
[class.is-over-inside]="isOver(node.id, 'inside')"
[class.is-over-before]="isOver(node.id, 'before')"
[class.is-over-after]="isOver(node.id, 'after')"
[style.paddingLeft.px]="level * indent()"
cdkDrag
[cdkDragData]="node.id"
[ngClass]="{
'is-folder': folder,
'is-dragging': draggingId() === node.id,
'was-just-dropped': justDroppedId() === node.id,
'is-over-inside': overInside,
'is-over-before': overBefore,
'is-over-after': overAfter,
}"
[attr.data-node-id]="node.id"
[style.--tree-indent.px]="indentPx"
(cdkDragStarted)="onDragStarted(node.id)"
(cdkDragEnded)="onDragEnded($event)"
(cdkDragMoved)="onDragMoved($event)"
data-drop-zone="inside"
[attr.role]="'treeitem'"
[attr.aria-level]="level + 1"
[attr.aria-expanded]="folder ? node.expanded : null"
>
<div
class="drop drop--before"
[dndDropTarget]="{ id: node.id, where: 'before' }"
[dndContext]="contextId"
(activeChange)="setOver(node.id, 'before', $event)"
(indicator)="onIndicator($event)"
></div>
<div
class="row"
[dndDraggable]="node.id"
[attr.data-id]="node.id"
[dndDropTarget]="isFolder(node) ? { id: node.id, where: 'inside' } : null"
[dndContext]="contextId"
[attr.role]="'treeitem'"
[attr.aria-level]="level + 1"
[attr.aria-expanded]="isFolder(node) ? node.expanded : null"
[dragPreviewTemplate]="dragPreviewTpl() ?? null"
[dragPreviewData]="{ node: node, isFolder: isFolder(node) }"
(activeChange)="setOver(node.id, 'inside', $event)"
(indicator)="onIndicator($event)"
>
@if (isFolder(node)) {
@if (folderTpl(); as tpl) {
<ng-container
*ngTemplateOutlet="
tpl;
context: {
$implicit: node,
expanded: node.expanded,
toggle: toggle.bind(this),
dragOver: isOver(node.id, 'inside'),
}
"
></ng-container>
} @else {
<span
class="expander"
(click)="$event.stopPropagation(); toggle(node)"
>
{{ node.expanded ? '▾' : '▸' }}
</span>
<span class="label">{{ node.label }}</span>
}
} @else {
@if (itemTpl(); as tpl) {
<ng-container
*ngTemplateOutlet="tpl; context: { $implicit: node }"
></ng-container>
} @else {
{{ node.label }}
}
@if (folder) {
@if (folderTpl(); as tpl) {
<ng-container
*ngTemplateOutlet="
tpl;
context: {
$implicit: node,
expanded: node.expanded,
toggle: toggle.bind(this),
dragOver: overInside,
}
"
></ng-container>
}
</div>
<div
class="drop drop--after"
[dndDropTarget]="{ id: node.id, where: 'after' }"
[dndContext]="contextId"
(activeChange)="setOver(node.id, 'after', $event)"
(indicator)="onIndicator($event)"
></div>
} @else {
@if (itemTpl(); as tpl) {
<ng-container
*ngTemplateOutlet="tpl; context: { $implicit: node }"
></ng-container>
}
}
</div>
@if (node.children?.length) {
@if (folder) {
@let childLevel = level + 1;
<div
class="group"
class="folder"
[@expandCollapse]="node.expanded ? 'expanded' : 'collapsed'"
cdkDropList
[cdkDropListData]="{ parentId: node.id, items: node.children ?? [] }"
[cdkDropListEnterPredicate]="canEnterList"
(cdkDropListDropped)="onDrop($event)"
>
@for (child of node.children; track child.id) {
@for (child of node.children ?? []; track child.id) {
<ng-container
*ngTemplateOutlet="nodeTpl; context: { $implicit: child, level: level + 1 }"
*ngTemplateOutlet="nodeTpl; context: { $implicit: child, level: childLevel }"
></ng-container>
}
</div>

View file

@ -5,32 +5,39 @@
.tree {
display: block;
position: relative;
&.is-dragging {
cursor: grabbing;
* {
cursor: grabbing !important;
}
&.is-drag-invalid {
cursor: not-allowed;
* {
cursor: not-allowed !important;
}
}
}
}
.item {
position: relative;
padding-left: var(--tree-indent, 0);
&.is-dragging {
opacity: 0.4;
pointer-events: none;
}
&.is-over-inside {
background: greenyellow;
background: rgba(138, 43, 226, 0.2); /* Purple for "inside" folder */
}
}
.row {
min-height: 28px;
user-select: none;
-webkit-user-select: none;
cursor: grab;
&.is-over {
background: greenyellow !important;
}
}
.item.just-dropped > .row {
.item.was-just-dropped {
background: gold;
}
@ -40,52 +47,20 @@
user-select: none;
}
.drop {
height: 12px;
pointer-events: none;
width: 100%;
background: transparent;
position: absolute;
z-index: 1;
.is-dragging & {
pointer-events: all;
background: rgba(0, 123, 255, 0.1);
}
&:hover {
background: rgba(0, 123, 255, 0.15);
}
.item:not(.is-folder) & {
height: 16px;
}
}
.drop--before,
.drop--after {
border-top: 2px solid transparent;
}
.drop--before {
top: 0;
}
.drop--after {
bottom: 0;
}
.group {
.folder {
margin-left: 0;
}
.root-drop {
height: 10px;
height: 20px;
border-top: 2px dashed transparent;
position: relative;
z-index: 5;
&.is-over {
border-top-color: #007bff;
background: rgba(0, 123, 255, 0.1);
&.is-root-over {
border-top-color: #dc3545;
background: rgba(220, 53, 69, 0.15); /* Red for root drop (end of list) */
z-index: 15;
}
&:hover {
@ -93,11 +68,22 @@
}
}
.drag-preview {
background: var(--tree-preview-bg, #ffffff);
border: 1px solid var(--tree-preview-border, #ddd);
border-radius: 4px;
padding: 6px 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
white-space: nowrap;
pointer-events: none;
}
/* indicator */
.indicator {
position: absolute;
height: 0;
pointer-events: none;
display: none;
}
.indicator-dot {

View file

@ -1,38 +1,49 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
contentChild,
DestroyRef,
effect,
ElementRef,
inject,
input,
model,
output,
signal,
TemplateRef,
} from '@angular/core';
import { trigger, state, style, transition, animate } from '@angular/animations';
import { NgTemplateOutlet } from '@angular/common';
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
import {
DragData,
DropData,
CdkDrag,
CdkDragDrop,
CdkDragEnd,
CdkDragMove,
CdkDropList,
DragDropModule,
} from '@angular/cdk/drag-drop';
import {
CanDropPredicate,
DropListContext,
DropWhere,
FolderTplContext,
HoverTarget,
ItemTplContext,
MoveInstruction,
PointerPosition,
TreeId,
TreeNode,
} from './tree.types';
import { moveNode } from './tree.utils';
import { DndDraggableDirective } from './dnd-draggable.directive';
import { DndDropTargetDirective } from './dnd-drop-target.directive';
import { asDragData, asDropData } from './dnd.helpers';
import { TreeDragService } from './tree-drag.service';
import { TreeIndicatorService } from './tree-indicator.service';
import { TREE_CONSTANTS } from './tree-constants';
import { assertTreeId } from './tree-guards';
@Component({
selector: 'tree-dnd',
standalone: true,
imports: [NgTemplateOutlet, DndDraggableDirective, DndDropTargetDirective],
imports: [NgTemplateOutlet, NgStyle, NgClass, DragDropModule],
providers: [TreeIndicatorService, TreeDragService],
templateUrl: './tree.component.html',
styleUrls: ['./tree.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@ -56,57 +67,68 @@ import { asDragData, asDropData } from './dnd.helpers';
),
transition('collapsed <=> expanded', [
style({ overflow: 'hidden' }),
animate('300ms ease-in-out'),
animate(`${TREE_CONSTANTS.ANIMATION_DURATION}ms ease-in-out`),
]),
]),
],
})
export class TreeDndComponent implements AfterViewInit {
private host = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
export class TreeDndComponent<TData = unknown> {
// === INJECTED DEPENDENCIES ===
private readonly _host = inject(ElementRef<HTMLElement>);
private readonly _destroyRef = inject(DestroyRef);
private readonly _dragService = inject(TreeDragService);
private readonly _indicatorService = inject(TreeIndicatorService);
// Unique context to keep interactions scoped to one tree
private ctx = Symbol('tree-dnd');
// expose to template
contextId = this.ctx;
// === PUBLIC INPUTS/OUTPUTS/MODELS ===
readonly nodes = model.required<readonly TreeNode<TData>[]>();
readonly indent = input(TREE_CONSTANTS.DEFAULT_INDENT);
readonly canDrop = input<CanDropPredicate<TData>>(() => true);
readonly moved = output<MoveInstruction>();
// Inputs (signal-based)
readonly data = input.required<TreeNode[]>();
readonly indent = input(16);
// Allow users to control drop permission (default: allow all valid moves)
readonly canDrop = input<
(ctx: { drag: TreeNode; drop: TreeNode | null; where: DropWhere }) => boolean
>(() => true);
// === PUBLIC CONTENT CHILDREN ===
readonly itemTpl = contentChild<TemplateRef<ItemTplContext<TData>>>('treeItem');
readonly folderTpl = contentChild<TemplateRef<FolderTplContext<TData>>>('treeFolder');
// Local state mirrored from input for internal reordering
readonly nodes = signal<TreeNode[]>([]);
private nodeCache = signal<Map<string, TreeNode>>(new Map());
// === PUBLIC SIGNALS ===
readonly draggingId = signal<TreeId | null>(null);
readonly justDroppedId = signal<TreeId | null>(null);
readonly isDragInvalid = signal<boolean>(false);
readonly isRootOver = signal<boolean>(false);
readonly indicatorStyle = this._indicatorService.indicatorStyle;
private _syncEffect = effect(() => {
const newNodes = this.data() ?? [];
this.nodes.set(newNodes);
this.rebuildNodeCache(newNodes);
});
// === PRIVATE STATE ===
/**
* Tracks which drop zones are currently being hovered over.
* Structure: { nodeId: { before: true, after: false, inside: false } }
* This allows multiple drop zones to be highlighted simultaneously if needed.
*/
private readonly _overMap = signal<Record<TreeId, Partial<Record<DropWhere, boolean>>>>(
{},
);
private _hoverTarget: HoverTarget | null = null;
private _treeRootEl: HTMLElement | null = null;
private _rootDropEl: HTMLElement | null = null;
private _dropHandled = false;
private _dropFlashTimer?: number;
// Content templates (signal-based)
readonly itemTpl = contentChild<TemplateRef<ItemTplContext>>('treeItem');
readonly folderTpl = contentChild<TemplateRef<FolderTplContext>>('treeFolder');
readonly dragPreviewTpl = contentChild<TemplateRef<any>>('treeDragPreview');
constructor() {
this._destroyRef.onDestroy(() => {
if (this._dropFlashTimer) clearTimeout(this._dropFlashTimer);
this._indicatorService.clear();
});
}
// Output: notify consumers of moves
moved = output<MoveInstruction>();
// Output: emit latest tree after internal changes
updated = output<TreeNode[]>();
// === PUBLIC METHODS ===
readonly canEnterList = (
drag: CdkDrag<TreeId>,
drop: CdkDropList<DropListContext<TData>>,
): boolean => this._canEnterListPredicate(drag, drop);
// drag highlight state (ids mapped to where states)
private overMap = signal<Record<string, Partial<Record<DropWhere, boolean>>>>({});
setOver(id: string, where: DropWhere, on: boolean): void {
// Track hover state
this.overMap.update((m) => {
setOver(id: TreeId, where: DropWhere, on: boolean): void {
this._overMap.update((m) => {
const current = m[id]?.[where] ?? false;
if (current === on) {
return m; // Atlaskit fires multiple hover events per frame; skip no-op updates to avoid needless change detection.
return m; // No change needed
}
const entry = { ...(m[id] ?? {}) };
entry[where] = on;
@ -114,281 +136,422 @@ export class TreeDndComponent implements AfterViewInit {
});
}
isOver(id: string, where: DropWhere): boolean {
return !!this.overMap()[id]?.[where];
isOver(id: TreeId, where: DropWhere): boolean {
return !!this._overMap()[id]?.[where];
}
rootOver = signal<boolean>(false);
draggingId = signal<string | null>(null);
// recent drop flash state
justDroppedId = signal<string | null>(null);
private _dropFlashTimer?: number;
// trackBy not needed with @for track expressions
toggle(node: TreeNode<TData>): void {
if (!node.children?.length) return;
node.expanded = !node.expanded;
const updatedNodes = [...this.nodes()] as TreeNode<TData>[];
this.nodes.set(updatedNodes);
}
// Indicator state
indicatorVisible = signal(false);
indicatorTop = signal(0);
indicatorLeft = signal(0);
indicatorWidth = signal(0);
private treeRootEl: HTMLElement | null = null;
private containerRect: DOMRect | null = null;
private elementLayoutCache: WeakMap<
HTMLElement,
{ rect: DOMRect; paddingLeft: number }
> = new WeakMap();
private lastIndicatorTarget: { element: HTMLElement; where: DropWhere } | null = null;
// === EVENT HANDLERS ===
onDrop(event: CdkDragDrop<DropListContext<TData>>): void {
const dragId = this._extractDragId(event.item.data);
this.draggingId.set(null);
this._dropHandled = true;
onIndicator(evt: { active: boolean; element: HTMLElement; where: DropWhere }): void {
if (!evt.active) {
this.indicatorVisible.set(false);
this.lastIndicatorTarget = null;
this.containerRect = null;
// if we have no dragId it means we dragged an invalid element, so we just reset
if (!dragId) {
this._scheduleItemReset(event.item);
this._clearHoverStates();
return;
}
// No special-casing: indicator follows active drop target
this.treeRootEl ??= this.host.nativeElement.querySelector('.tree') as HTMLElement;
const container = this.treeRootEl;
if (!container) {
const instruction = this._getDropInstruction(dragId, event);
const wasMoved = instruction ? this._applyInstruction(instruction) : false;
if (wasMoved) {
this._flashJustDropped(dragId);
}
// Always schedule reset to ensure proper cleanup
this._scheduleItemReset(event.item);
this._clearHoverStates();
}
onDragStarted(id: TreeId): void {
try {
assertTreeId(id, 'drag ID');
this.draggingId.set(id);
this.isDragInvalid.set(false);
this._hoverTarget = null;
this._dropHandled = false;
this.isRootOver.set(false);
this._treeRootEl = this._host.nativeElement.querySelector('.tree');
this._rootDropEl = this._host.nativeElement.querySelector('.root-drop');
this._indicatorService.clear();
} catch (error) {
console.error('Invalid drag start:', error);
}
}
onDragMoved(event: CdkDragMove<TreeId>): void {
const dragId = this._extractDragId(event.source.data);
if (!dragId) return;
const pointer = event.pointerPosition;
if (!pointer) return;
this._treeRootEl ??= this._host.nativeElement.querySelector('.tree');
this._rootDropEl ??= this._host.nativeElement.querySelector('.root-drop');
const target = this._findHoverTarget(pointer);
this._updateHover(dragId, target);
// Clear hover if dragged outside tree boundaries
if (!target && this._hoverTarget) {
this._setHoverTarget(null);
}
}
onDragEnded(event: CdkDragEnd<TreeId>): void {
const dragId = this._extractDragId(event.source.data);
if (!this._dropHandled && dragId) {
if (this._hoverTarget) {
const instruction = this._buildInstructionFromHover(dragId, this._hoverTarget);
if (instruction && this._applyInstruction(instruction)) {
this._flashJustDropped(dragId);
}
}
// Always reset when drag ends without a drop, regardless of hover target
this._scheduleItemReset(event.source);
}
this.draggingId.set(null);
this.isDragInvalid.set(false);
this._dropHandled = false;
this._clearHoverStates();
}
// === PRIVATE METHODS ===
private _canEnterListPredicate(
drag: CdkDrag<TreeId>,
drop: CdkDropList<DropListContext<TData>>,
): boolean {
const data = drop.data;
const dragId = this._extractDragId(drag.data);
if (!dragId || !data) {
return false;
}
if (data.parentId && this._isNodeAncestor(dragId, data.parentId)) {
return false;
}
const dragNode = this._findNode(dragId);
if (!dragNode) {
return false;
}
if (!data.parentId) {
return this.canDrop()({ drag: dragNode, drop: null, where: 'root' });
}
const parentNode = this._findNode(data.parentId);
if (!parentNode || parentNode.children === undefined) {
return false;
}
return this.canDrop()({ drag: dragNode, drop: parentNode, where: 'inside' });
}
/**
* Determines what element the user is hovering over during drag operations.
* This is complex because we need to:
* 1. Respect tree boundaries (don't allow drops outside the tree)
* 2. Handle the special "root drop" zone at the bottom
* 3. Calculate drop zones (before/after/inside) based on pointer position
* 4. Return the correct element for indicator positioning
*/
private _findHoverTarget(pointer: PointerPosition): HoverTarget | null {
// First check: Are we even within the tree boundaries?
if (this._treeRootEl) {
const treeRect = this._treeRootEl.getBoundingClientRect();
if (!this._dragService.isPointInRect(pointer, treeRect)) {
return null; // Outside tree boundaries - show invalid cursor
}
}
// Special case: Check if hovering over the root drop zone (bottom of tree)
if (this._rootDropEl) {
const rect = this._rootDropEl.getBoundingClientRect();
if (this._dragService.isPointInRect(pointer, rect)) {
return { id: '', where: 'root', element: this._rootDropEl };
}
}
// Find the actual DOM element under the mouse pointer
const element = document.elementFromPoint(pointer.x, pointer.y) as HTMLElement | null;
if (!element) return null;
// Navigate up the DOM to find the nearest tree item container
const itemEl = element.closest('.item') as HTMLElement | null;
if (!itemEl || !this._treeRootEl?.contains(itemEl)) {
return null; // Element not within this tree instance
}
// Extract the node ID from the data attribute
const nodeId = itemEl.getAttribute('data-node-id') as TreeId | null;
if (!nodeId) return null;
const itemRect = itemEl.getBoundingClientRect();
// Calculate drop zone: folders get larger "inside" zones for easier targeting
const targetNode = this._findNode(nodeId);
const isTargetFolder = targetNode ? targetNode.children !== undefined : false;
const where = this._dragService.calculateHoverZone(pointer, itemRect, isTargetFolder);
return { id: nodeId, where, element: itemEl };
}
private _updateHover(dragId: string, target: HoverTarget | null): void {
const isValid = target && this._isHoverAllowed(dragId, target);
// Show invalid cursor when hovering outside tree or over invalid targets
this.isDragInvalid.set(!isValid);
if (!isValid) {
this._setHoverTarget(null);
return;
}
const sameTarget =
this.lastIndicatorTarget?.element === evt.element &&
this.lastIndicatorTarget?.where === evt.where;
if (sameTarget && this.indicatorVisible()) {
// Skip recomputing layout for repeated pointer move events over the same drop zone
this._setHoverTarget(target);
}
private _isHoverAllowed(dragId: string, target: HoverTarget): boolean {
return this._buildInstructionFromHover(dragId, target) !== null;
}
/**
* Updates the current hover target and manages visual feedback.
* This handles the complex state transitions between different hover targets:
* - Clearing previous hover states (remove highlights)
* - Setting new hover states (add highlights)
* - Managing the drop indicator position
* - Handling special "root" hover state separately
*/
private _setHoverTarget(next: HoverTarget | null): void {
const prev = this._hoverTarget;
// Optimization: if we're hovering over the same target, just update indicator
if (prev && next && prev.id === next.id && prev.where === next.where) {
this._hoverTarget = next;
this._showIndicator(next);
return;
}
this.lastIndicatorTarget = { element: evt.element, where: evt.where };
this.containerRect ??= container.getBoundingClientRect();
const containerRect = this.containerRect;
let elementLayout = this.elementLayoutCache.get(evt.element);
if (!elementLayout) {
const rect = evt.element.getBoundingClientRect();
const itemEl = evt.element.closest('.item') as HTMLElement | null;
const paddingLeft = itemEl
? parseFloat(getComputedStyle(itemEl).paddingLeft || '0') || 0
: 0;
elementLayout = { rect, paddingLeft };
this.elementLayoutCache.set(evt.element, elementLayout);
}
const elRect = elementLayout.rect;
// Vertical position: before/after lines snap to top/bottom of the drop zone
// For inside: draw at the bottom of the row to suggest insertion as first child
const isAfter = evt.where === 'after';
const y =
elRect.top -
containerRect.top +
(isAfter || evt.where === 'inside' ? elRect.height : 0);
// Horizontal position: reflect the level the item will be dropped into
// - root: level 0 (no indent)
// - before/after: same level as the target item (its container padding-left)
// - inside: one level deeper than the target item
let left = 0;
let width = containerRect.width;
const itemEl = evt.element.closest('.item') as HTMLElement | null;
if (itemEl) {
const itemRect = itemEl.getBoundingClientRect();
const paddingLeft = this.elementLayoutCache.get(evt.element)?.paddingLeft ?? 0;
const extraIndent = evt.where === 'inside' ? this.indent() : 0;
left = itemRect.left - containerRect.left + paddingLeft + extraIndent;
width = Math.max(0, containerRect.width - left);
} else if (evt.where === 'root') {
left = 0;
width = containerRect.width;
// Clear previous hover visual feedback
if (prev) {
if (prev.where === 'root') {
this.isRootOver.set(false);
} else {
this.setOver(prev.id, prev.where, false);
}
}
this.indicatorTop.set(Math.round(y));
this.indicatorLeft.set(Math.max(0, Math.round(left)));
this.indicatorWidth.set(Math.max(0, Math.round(width)));
if (!this.indicatorVisible()) {
this.indicatorVisible.set(true);
// Handle clearing hover state
if (!next) {
this._hoverTarget = null;
this._indicatorService.hide();
return;
}
// Set new hover state and visual feedback
this._hoverTarget = next;
if (next.where === 'root') {
this.isRootOver.set(true);
} else {
this.setOver(next.id, next.where, true);
}
this._showIndicator(next);
}
onRootActive(active: boolean): void {
this.rootOver.set(active);
}
/**
* Determines the final move instruction when a drop occurs.
* This is complex because we need to handle two different types of drop detection:
* 1. Hover-based drops (precise positioning for folders)
* 2. Drop-list-based drops (reliable for list ordering)
*
* We prioritize hover instructions for "inside" folder drops because they provide
* better UX - users can see exactly where they're dropping via the indicator.
*/
private _getDropInstruction(
dragId: TreeId,
event: CdkDragDrop<DropListContext<TData>>,
): MoveInstruction | null {
// Get hover-based instruction (more precise for folders)
let hoverInstruction: MoveInstruction | null = null;
if (this._hoverTarget) {
hoverInstruction = this._buildInstructionFromHover(dragId, this._hoverTarget);
}
isFolder(n: TreeNode): boolean {
return !!n.isFolder || n.children !== undefined;
}
ngAfterViewInit(): void {
const cleanup: Array<() => void> = [];
// Register monitor only (drop-targets + draggables are directives)
cleanup.push(
monitorForElements({
canMonitor: ({ source }) => asDragData(source.data)?.uniqueContextId === this.ctx,
onDragStart: ({ source }) => {
const data = asDragData(source.data);
if (data) this.draggingId.set(data.id);
this.treeRootEl ??= this.host.nativeElement.querySelector(
'.tree',
) as HTMLElement;
this.containerRect = this.treeRootEl?.getBoundingClientRect() ?? null;
this.elementLayoutCache = new WeakMap();
},
onDrop: ({ location, source }) => {
this.draggingId.set(null);
const dropTargets = location.current.dropTargets;
if (!dropTargets.length) {
this.clearOverStates();
return;
}
const s = asDragData(source.data);
// Use innermost drop target (last in list for this adapter)
const target = asDropData(dropTargets[dropTargets.length - 1].data);
if (!s || !target) {
this.clearOverStates();
return;
}
// Validate drop operation
if (!this.isValidDrop(s, target)) {
this.clearOverStates();
return;
}
if (target.where === 'root') {
// Drop at the very end of the root list
const currentRoots = this.nodes();
const lastRoot = [...currentRoots].reverse().find((n) => n.id !== s.id);
const instr: MoveInstruction = lastRoot
? { itemId: s.id, targetId: lastRoot.id, where: 'after' }
: { itemId: s.id, targetId: '', where: 'inside' }; // empty root: insert as only item
const updatedNodes = moveNode(this.nodes(), instr);
this.nodes.set(updatedNodes);
this.rebuildNodeCache(updatedNodes);
this.moved.emit(instr);
this.updated.emit(this.nodes());
this.flashJustDropped(s.id);
this.clearOverStates();
return;
}
if (target.where === 'inside') {
const targetNode = this.findNode(target.id);
if (!targetNode) {
console.warn(`Drop target node with id '${target.id}' not found`);
this.clearOverStates();
return;
}
if (!this.isFolder(targetNode)) {
this.clearOverStates();
return; // disallow dropping inside items
}
}
const instr: MoveInstruction = {
itemId: s.id,
targetId: target.id,
where: target.where,
} as MoveInstruction;
const updatedNodes = moveNode(this.nodes(), instr);
this.nodes.set(updatedNodes);
this.rebuildNodeCache(updatedNodes);
this.moved.emit(instr);
this.updated.emit(this.nodes());
this.flashJustDropped(s.id);
this.clearOverStates();
},
}),
// Get drop-list-based instruction (more reliable for ordering)
const dropInstruction = this._dragService.buildInstructionFromDropEvent<TData>(
dragId,
event,
this._findNode.bind(this),
this._getSiblings.bind(this),
(node: TreeNode<TData>) => node.children !== undefined,
this._isNodeAncestor.bind(this),
this.canDrop(),
);
this.destroyRef.onDestroy(() => {
cleanup.forEach((fn) => fn());
if (this._dropFlashTimer) clearTimeout(this._dropFlashTimer);
this.justDroppedId.set(null);
});
// Prioritize hover instruction for "inside" folder drops
// This gives users better visual feedback and more precise control
if (hoverInstruction?.where === 'inside' && hoverInstruction.targetId) {
const targetNode = this._findNode(hoverInstruction.targetId);
if (targetNode && targetNode.children !== undefined) {
return hoverInstruction;
}
}
// Fall back to drop instruction for ordering, or hover instruction as last resort
return dropInstruction ?? hoverInstruction;
}
private clearOverStates(): void {
this.overMap.set({});
this.rootOver.set(false);
this.indicatorVisible.set(false);
this.lastIndicatorTarget = null;
this.containerRect = null;
this.elementLayoutCache = new WeakMap();
/**
* Resets a dragged item to its original position with proper timing.
* We use requestAnimationFrame to ensure the reset happens after Angular's
* change detection cycle, preventing visual glitches.
*/
private _scheduleItemReset(item: CdkDrag<TreeId>): void {
const reset = (): void => {
try {
item.reset();
// Access CDK internal API to ensure complete reset
const dragRef = (item as { _dragRef?: { reset: () => void } })._dragRef;
dragRef?.reset();
} catch (error) {
// Ignore reset errors - they can happen if the item is already destroyed
console.debug('Failed to reset drag item:', error);
}
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(reset);
} else {
reset();
}
}
private rebuildNodeCache(nodes: TreeNode[]): void {
const cache = new Map<string, TreeNode>();
private _showIndicator(target: HoverTarget): void {
if (!this._treeRootEl) return;
this._indicatorService.show(
target.element,
target.where,
this._treeRootEl,
this.indent(),
);
}
private _clearHoverStates(): void {
this._hoverTarget = null;
this._overMap.set({});
this.isRootOver.set(false);
this._indicatorService.hide();
this._rootDropEl = null;
}
private _traverseNodes(
nodes: TreeNode<TData>[],
callback: (node: TreeNode<TData>) => void,
): void {
const stack = [...nodes];
while (stack.length) {
const node = stack.pop()!;
cache.set(node.id, node);
if (node.children) {
callback(node);
if (node.children?.length) {
stack.push(...node.children);
}
}
this.nodeCache.set(cache);
}
private findNode(id: string): TreeNode | null {
return this.nodeCache().get(id) ?? null;
private _getSiblings(parentId: TreeId): TreeNode<TData>[] {
if (!parentId) return this.nodes() as TreeNode<TData>[];
return this._findNode(parentId)?.children ?? [];
}
private isValidDrop(dragData: DragData, dropData: DropData): boolean {
const dragNode = this.findNode(dragData.id);
if (!dragNode) return false;
if (dropData.where === 'root') {
return this.canDrop()({ drag: dragNode, drop: null, where: 'root' });
}
// Prevent dropping item onto itself
if (dragData.id === dropData.id) return false;
const targetNode = this.findNode(dropData.id);
if (!targetNode) return false;
if (this.isNodeAncestor(dragData.id, dropData.id)) return false;
if (dropData.where === 'inside' && !this.isFolder(targetNode)) return false;
return this.canDrop()({ drag: dragNode, drop: targetNode, where: dropData.where });
}
// removed disallowed hover evaluation
private isNodeAncestor(ancestorId: string, possibleDescendantId: string): boolean {
const ancestorNode = this.findNode(ancestorId);
if (!ancestorNode?.children) return false;
const stack = [...ancestorNode.children];
private readonly _findNode = (id: TreeId): TreeNode<TData> | null => {
const stack = [...this.nodes()] as TreeNode<TData>[];
while (stack.length) {
const node = stack.pop()!;
if (node.id === possibleDescendantId) return true;
if (node.children) stack.push(...node.children);
if (node.id === id) return node;
if (node.children?.length) {
stack.push(...node.children);
}
}
return false;
return null;
};
private _buildInstructionFromHover(
dragId: TreeId,
hover: HoverTarget,
): MoveInstruction | null {
return this._dragService.buildInstructionFromHover<TData>(
dragId,
hover,
this._findNode.bind(this),
() => this.nodes() as TreeNode<TData>[],
(node: TreeNode<TData>) => node.children !== undefined,
this._isNodeAncestor.bind(this),
this.canDrop(),
);
}
toggle(node: TreeNode): void {
if (!node.children?.length) return;
node.expanded = !node.expanded;
// trigger change and update cache
const updatedNodes = [...this.nodes()];
private _applyInstruction(instr: MoveInstruction): boolean {
const updatedNodes = moveNode<TData>(this.nodes() as TreeNode<TData>[], instr);
this.nodes.set(updatedNodes);
this.rebuildNodeCache(updatedNodes);
this.updated.emit(this.nodes());
this.moved.emit(instr);
return true;
}
// Imperative update: replace tree data and rebuild cache
update(nodes: TreeNode[]): void {
this.nodes.set(nodes ?? []);
this.rebuildNodeCache(this.nodes());
this.updated.emit(this.nodes());
}
/**
* Checks if one node is an ancestor of another.
* This prevents users from dropping a folder into itself or its descendants,
* which would create invalid tree structures.
*
* Example: Can't drag "Folder A" into "Folder A/Subfolder B"
*/
private readonly _isNodeAncestor = (
ancestorId: TreeId,
possibleDescendantId: TreeId,
): boolean => {
const ancestorNode = this._findNode(ancestorId);
if (!ancestorNode?.children) return false;
private flashJustDropped(id: string): void {
if (typeof window === 'undefined') {
return;
let found = false;
this._traverseNodes(ancestorNode.children as TreeNode<TData>[], (node) => {
if (node.id === possibleDescendantId) {
found = true;
}
});
return found;
};
/**
* Extracts and validates drag data as a TreeId.
*
* CDK drag events type item.data as 'any', but we set [cdkDragData]="node.id"
* so we expect it to be a TreeId (string). This function provides runtime validation:
* 1. Checks if data is a non-empty string
* 2. Verifies the string is actually a valid node ID in our tree
*
* This prevents issues from malformed drag data or external drag sources.
*/
private _extractDragId(data: unknown): TreeId | null {
if (typeof data === 'string' && data.length > 0) {
// Verify this is actually a node ID that exists in our tree
const node = this._findNode(data);
return node ? data : null;
}
return null;
}
private _flashJustDropped(id: TreeId): void {
if (typeof window === 'undefined') return;
this.justDroppedId.set(id);
if (this._dropFlashTimer) clearTimeout(this._dropFlashTimer);
this._dropFlashTimer = window.setTimeout(() => {
if (this.justDroppedId() === id) this.justDroppedId.set(null);
}, 300);
}, TREE_CONSTANTS.DROP_FLASH_DURATION);
}
}

View file

@ -1,43 +1,107 @@
export type TreeId = string;
export interface TreeNode<T = undefined> {
id: TreeId;
label?: string;
children?: TreeNode<T>[];
export interface TreeNode<TData = unknown> {
readonly id: TreeId;
children?: TreeNode<TData>[];
expanded?: boolean;
// mark as a folder even if children is empty
isFolder?: boolean;
data?: T;
readonly isFolder?: boolean;
readonly data?: TData;
}
export interface TreeNodeMutable<TData = unknown> extends TreeNode<TData> {
children?: TreeNodeMutable<TData>[];
}
export type DropWhere = 'before' | 'after' | 'inside' | 'root';
export const DROP_WHERE_VALUES = ['before', 'after', 'inside', 'root'] as const;
export const isDropWhere = (value: unknown): value is DropWhere =>
typeof value === 'string' && DROP_WHERE_VALUES.includes(value as DropWhere);
export interface DropData {
type: 'drop';
id: TreeId;
where: DropWhere;
readonly type: 'drop';
readonly id: TreeId;
readonly where: DropWhere;
}
export interface DragData {
type: 'item';
id: TreeId;
uniqueContextId: symbol;
readonly type: 'item';
readonly id: TreeId;
readonly uniqueContextId: symbol;
}
export type MoveTargetWhere = Exclude<DropWhere, 'root'> | 'inside';
export interface MoveInstruction {
itemId: TreeId;
targetId: TreeId | '';
where: Exclude<DropWhere, 'root'> | 'inside';
readonly itemId: TreeId;
readonly targetId: TreeId | '';
readonly where: MoveTargetWhere;
}
// Template context types
export interface ItemTplContext {
$implicit: TreeNode;
export interface ItemTplContext<TData = unknown> {
readonly $implicit: TreeNode<TData>;
}
export interface FolderTplContext {
$implicit: TreeNode;
expanded: boolean;
toggle: (node: TreeNode) => void;
dragOver: boolean;
export interface FolderTplContext<TData = unknown> {
readonly $implicit: TreeNode<TData>;
readonly expanded: boolean;
readonly toggle: (node: TreeNode<TData>) => void;
readonly dragOver: boolean;
}
export interface DragPreviewTplContext<TData = unknown> {
readonly $implicit: { node: TreeNode<TData>; isFolder: boolean };
readonly id: TreeId;
readonly element: HTMLElement;
}
// Predicate function types
export type CanDropPredicate<TData = unknown> = (ctx: CanDropContext<TData>) => boolean;
export interface CanDropContext<TData = unknown> {
readonly drag: TreeNode<TData>;
readonly drop: TreeNode<TData> | null;
readonly where: DropWhere;
}
// Internal service interfaces
export interface HoverTarget {
readonly id: TreeId;
readonly where: DropWhere;
readonly element: HTMLElement;
}
export interface DropListContext<TData = unknown> {
readonly parentId: TreeId;
readonly items: readonly TreeNode<TData>[];
}
// Utility types
export type TreeNodeWithChildren<TData = unknown> = TreeNode<TData> & {
children: readonly TreeNode<TData>[];
};
export type TreeNodeFolder<TData = unknown> = TreeNode<TData> & {
readonly isFolder: true;
};
// Event types
export interface TreeNodeEvent<TData = unknown> {
readonly node: TreeNode<TData>;
}
export interface TreeMoveEvent<TData = unknown> extends TreeNodeEvent<TData> {
readonly instruction: MoveInstruction;
}
export interface TreeUpdateEvent<TData = unknown> {
readonly nodes: readonly TreeNode<TData>[];
}
// Shared interfaces
export interface PointerPosition {
readonly x: number;
readonly y: number;
}

View file

@ -1,14 +1,19 @@
import { MoveInstruction, TreeId, TreeNode } from './tree.types';
export const cloneNodes = (nodes: TreeNode[]): TreeNode[] =>
export const cloneNodes = <TData = unknown>(
nodes: readonly TreeNode<TData>[],
): TreeNode<TData>[] =>
nodes.map((n) => ({
...n,
children: n.children ? cloneNodes(n.children) : undefined,
}));
export const getPath = (nodes: TreeNode[], targetId: TreeId): TreeId[] | null => {
export const getPath = <TData = unknown>(
nodes: readonly TreeNode<TData>[],
targetId: TreeId,
): TreeId[] | null => {
const path: TreeId[] = [];
const dfs = (list: TreeNode[], id: TreeId, acc: TreeId[]): boolean => {
const dfs = (list: readonly TreeNode<TData>[], id: TreeId, acc: TreeId[]): boolean => {
for (const node of list) {
acc.push(node.id);
if (node.id === id) return true;
@ -21,8 +26,8 @@ export const getPath = (nodes: TreeNode[], targetId: TreeId): TreeId[] | null =>
return found ? path : null;
};
export const isAncestor = (
nodes: TreeNode[],
export const isAncestor = <TData = unknown>(
nodes: readonly TreeNode<TData>[],
ancestorId: TreeId,
possibleDescendantId: TreeId,
): boolean => {
@ -30,11 +35,11 @@ export const isAncestor = (
return !!path?.includes(ancestorId);
};
export const findAndRemove = (
nodes: TreeNode[],
export const findAndRemove = <TData = unknown>(
nodes: TreeNode<TData>[],
id: TreeId,
): { node: TreeNode | null } => {
const stack: { parent: TreeNode | null; list: TreeNode[] }[] = [
): { node: TreeNode<TData> | null } => {
const stack: { parent: TreeNode<TData> | null; list: TreeNode<TData>[] }[] = [
{ parent: null, list: nodes },
];
while (stack.length) {
@ -51,7 +56,10 @@ export const findAndRemove = (
return { node: null };
};
export const getChildren = (nodes: TreeNode[], id: '' | TreeId): TreeNode[] => {
export const getChildren = <TData = unknown>(
nodes: TreeNode<TData>[],
id: '' | TreeId,
): TreeNode<TData>[] => {
if (id === '') return nodes;
const stack = [...nodes];
while (stack.length) {
@ -62,13 +70,17 @@ export const getChildren = (nodes: TreeNode[], id: '' | TreeId): TreeNode[] => {
return nodes;
};
export const moveNode = (data: TreeNode[], instr: MoveInstruction): TreeNode[] => {
if (instr.targetId && instr.itemId === instr.targetId) return data; // no-op
if (instr.targetId && isAncestor(data, instr.itemId, instr.targetId)) return data; // prevent into own child
export const moveNode = <TData = unknown>(
data: readonly TreeNode<TData>[],
instr: MoveInstruction,
): TreeNode<TData>[] => {
if (instr.targetId && instr.itemId === instr.targetId) return cloneNodes(data); // no-op
if (instr.targetId && isAncestor(data, instr.itemId, instr.targetId))
return cloneNodes(data); // prevent into own child
const nodes = cloneNodes(data);
const { node } = findAndRemove(nodes, instr.itemId);
if (!node) return data;
if (!node) return cloneNodes(data);
if (instr.where === 'inside') {
const children = getChildren(nodes, (instr.targetId as TreeId) ?? '');
@ -80,15 +92,18 @@ export const moveNode = (data: TreeNode[], instr: MoveInstruction): TreeNode[] =
// before/after among siblings of target
const parentChildren = getChildrenOfParent(nodes, instr.targetId);
const index = parentChildren.findIndex((n) => n.id === instr.targetId);
if (index === -1) return data;
if (index === -1) return cloneNodes(data);
const insertIndex = instr.where === 'before' ? index : index + 1;
parentChildren.splice(insertIndex, 0, node);
return nodes;
};
const getChildrenOfParent = (nodes: TreeNode[], id: TreeId): TreeNode[] => {
const getChildrenOfParent = <TData = unknown>(
nodes: TreeNode<TData>[],
id: TreeId,
): TreeNode<TData>[] => {
// returns the array that contains the node with id
const stack: { parent: TreeNode | null; list: TreeNode[] }[] = [
const stack: { parent: TreeNode<TData> | null; list: TreeNode<TData>[] }[] = [
{ parent: null, list: nodes },
];
while (stack.length) {