diff --git a/src/app/ui/tree-dnd/dnd-draggable.directive.ts b/src/app/ui/tree-dnd/dnd-draggable.directive.ts new file mode 100644 index 000000000..dff9c0f58 --- /dev/null +++ b/src/app/ui/tree-dnd/dnd-draggable.directive.ts @@ -0,0 +1,33 @@ +import { DestroyRef, Directive, effect, ElementRef, inject, input } from '@angular/core'; +import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { makeDragData } from './dnd.helpers'; + +@Directive({ + selector: '[dndDraggable]', + standalone: true, +}) +export class DndDraggableDirective { + private el = inject(ElementRef); + private destroyRef = inject(DestroyRef); + + // Inputs (signal-based) + id = input.required({ alias: 'dndDraggable' }); + dndContext = input.required(); + + private cleanup: (() => void) | null = null; + + private _bindEffect = effect(() => { + // Rebind when id/context changes + this.cleanup?.(); + const id = this.id(); + const ctx = this.dndContext(); + this.cleanup = draggable({ + element: this.el.nativeElement, + getInitialData: () => makeDragData(ctx, id), + }); + }); + + constructor() { + this.destroyRef.onDestroy(() => this.cleanup?.()); + } +} diff --git a/src/app/ui/tree-dnd/dnd-drop-target.directive.ts b/src/app/ui/tree-dnd/dnd-drop-target.directive.ts new file mode 100644 index 000000000..f4e6b669d --- /dev/null +++ b/src/app/ui/tree-dnd/dnd-drop-target.directive.ts @@ -0,0 +1,84 @@ +import { + DestroyRef, + Directive, + effect, + ElementRef, + inject, + input, + output, +} 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); + private destroyRef = inject(DestroyRef); + + // Inputs (modern signal-based) + config = input<{ id: string; where: DropData['where'] } | null>(null, { + alias: 'dndDropTarget', + }); + dndContext = input.required(); + + // Outputs (modern signal-based) + activeChange = output(); + 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 as any)?.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 }); + }, + }); + }); + + constructor() { + this.destroyRef.onDestroy(() => this.cleanup?.()); + } + + private onActive( + { location, self }: ElementDropTargetEventBasePayload, + where: DropData['where'], + ) { + const [innerMost] = location.current.dropTargets; + const isActive = innerMost?.element === self.element; + this.activeChange.emit(isActive); + this.indicator.emit({ active: isActive, element: this.el.nativeElement, where }); + } +} diff --git a/src/app/ui/tree-dnd/dnd.helpers.spec.ts b/src/app/ui/tree-dnd/dnd.helpers.spec.ts new file mode 100644 index 000000000..a1a4c000b --- /dev/null +++ b/src/app/ui/tree-dnd/dnd.helpers.spec.ts @@ -0,0 +1,29 @@ +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' } as any); + 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' }); + }); +}); diff --git a/src/app/ui/tree-dnd/dnd.helpers.ts b/src/app/ui/tree-dnd/dnd.helpers.ts new file mode 100644 index 000000000..c2a0f35d6 --- /dev/null +++ b/src/app/ui/tree-dnd/dnd.helpers.ts @@ -0,0 +1,33 @@ +import type { DragData, DropData } from './tree.types'; + +type AnyData = Record; + +export function makeDragData(ctx: symbol, id: string): AnyData { + const data: DragData = { type: 'item', id, uniqueContextId: ctx }; + return data as unknown as AnyData; +} + +export function makeDropData(data: DropData): AnyData { + return data as unknown as AnyData; +} + +export function asDragData(data: AnyData | null | undefined): DragData | null { + if (!data) return null; + const d = data as unknown as Partial; + return d && + d.type === 'item' && + typeof d.id === 'string' && + typeof d.uniqueContextId === 'symbol' + ? (d as DragData) + : null; +} + +export function asDropData(data: AnyData | null | undefined): DropData | null { + if (!data) return null; + const d = data as unknown as Partial; + if (!d || 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; +} diff --git a/src/app/ui/tree-dnd/tree.component.html b/src/app/ui/tree-dnd/tree.component.html new file mode 100644 index 000000000..caea4d08c --- /dev/null +++ b/src/app/ui/tree-dnd/tree.component.html @@ -0,0 +1,112 @@ +
+ @for (node of nodes(); track node.id) { + + } + +
+ + +
+ + +
+
+ + +
+ +
+ +
+ + @if (isFolder(node)) { + + {{ node.expanded ? '▾' : '▸' }} + + } + @if (folderTpl() && isFolder(node)) { + + } @else { + @if (itemTpl(); as tpl) { + + } @else { + {{ node.label }} + } + } +
+ + +
+
+ + @if (node.expanded && node.children?.length) { +
+ @for (child of node.children; track child.id) { + + } +
+ } +
diff --git a/src/app/ui/tree-dnd/tree.component.scss b/src/app/ui/tree-dnd/tree.component.scss new file mode 100644 index 000000000..5fb4b7397 --- /dev/null +++ b/src/app/ui/tree-dnd/tree.component.scss @@ -0,0 +1,102 @@ +:host { + display: block; +} + +.tree { + display: block; + position: relative; +} + +.tree__item { + position: relative; +} + +.tree__row { + display: flex; + gap: 0.5rem; + align-items: center; + min-height: 28px; + border-radius: 4px; + padding: 2px 6px; + user-select: none; + -webkit-user-select: none; +} + +.tree__handle { + cursor: grab; + padding: 0 4px; + user-select: none; + -webkit-user-select: none; +} + +.tree__row.is-over { + outline: 2px dashed #2684ff; +} + +.tree__row.is-dragging { + opacity: 0.5; +} + +.tree__item--folder > .tree__row { + font-weight: 600; +} + +.tree__expander { + width: 16px; + text-align: center; + user-select: none; +} + +.tree__drop { + height: 8px; +} + +.tree__drop--before, +.tree__drop--after { + border-top: 2px solid transparent; +} + +.tree__drop.is-over { + border-top-color: #2684ff; +} + +.tree__group { + margin-left: 0; +} + +.tree__root-drop { + height: 10px; + border-top: 2px dashed transparent; +} + +.tree__root-drop.is-over { + border-top-color: #2684ff; +} + +/* indicator */ +.tree__indicator { + position: absolute; + height: 0; + pointer-events: none; +} + +.tree__indicator-dot { + position: absolute; + top: -5px; + left: -7px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #2684ff; + box-shadow: 0 0 0 2px white; +} + +.tree__indicator-line { + display: block; + height: 2px; + background: #2684ff; + width: 100%; + position: absolute; + top: -1px; + left: 0; +} diff --git a/src/app/ui/tree-dnd/tree.component.ts b/src/app/ui/tree-dnd/tree.component.ts new file mode 100644 index 000000000..244d30328 --- /dev/null +++ b/src/app/ui/tree-dnd/tree.component.ts @@ -0,0 +1,187 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + effect, + inject, + input, + output, + signal, + TemplateRef, + contentChild, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { DropWhere, MoveInstruction, 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'; + +@Component({ + selector: 'tree-dnd', + standalone: true, + imports: [NgTemplateOutlet, DndDraggableDirective, DndDropTargetDirective], + templateUrl: './tree.component.html', + styleUrl: './tree.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TreeDndComponent implements AfterViewInit { + private host = inject(ElementRef); + private destroyRef = inject(DestroyRef); + + // Unique context to keep interactions scoped to one tree + private ctx = Symbol('tree-dnd'); + // expose to template + contextId = this.ctx; + + // Inputs (signal-based) + readonly data = input.required(); + readonly indent = input(16); + + // Local state mirrored from input for internal reordering + readonly nodes = signal([]); + private _syncEffect = effect(() => { + this.nodes.set(this.data() ?? []); + }); + + // Content templates (signal-based) + readonly itemTpl = contentChild>('treeItem'); + readonly folderTpl = contentChild>('treeFolder'); + + // Output: notify consumers of moves + moved = output(); + + // drag highlight state (ids mapped to where states) + private overMap = signal>>>({}); + + setOver(id: string, where: DropWhere, on: boolean) { + this.overMap.update((m) => { + const entry = { ...(m[id] ?? {}) }; + entry[where] = on; + return { ...m, [id]: entry }; + }); + } + + isOver = (id: string, where: DropWhere) => + computed(() => !!this.overMap()[id]?.[where]); + rootOver = signal(false); + + draggingId = signal(null); + // trackBy not needed with @for track expressions + + // Indicator state + indicatorVisible = signal(false); + indicatorTop = signal(0); + indicatorLeft = signal(0); + indicatorWidth = signal(0); + + onIndicator(evt: { active: boolean; element: HTMLElement; where: DropWhere }) { + if (!evt.active) { + this.indicatorVisible.set(false); + return; + } + // Only show for before/after/root + if (evt.where === 'inside') { + this.indicatorVisible.set(false); + return; + } + const container = this.host.nativeElement.querySelector('.tree') as HTMLElement; + const containerRect = container.getBoundingClientRect(); + const elRect = evt.element.getBoundingClientRect(); + + let y = elRect.top - containerRect.top + (evt.where === 'after' ? elRect.height : 0); + let left = 0; + let width = containerRect.width; + + // try align to row content + const row = evt.element.parentElement?.querySelector( + '.tree__row', + ) as HTMLElement | null; + if (row) { + const rowRect = row.getBoundingClientRect(); + left = rowRect.left - containerRect.left; + width = containerRect.width - left; + } + + 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); + } + + isFolder(n: TreeNode): boolean { + return !!n.isFolder || n.children !== undefined; + } + + ngAfterViewInit() { + const cleanup: Array<() => void> = []; + + // Register monitor only (drop-targets + draggables are directives) + cleanup.push( + monitorForElements({ + canMonitor: ({ source }) => + asDragData(source.data as any)?.uniqueContextId === this.ctx, + onDragStart: ({ source }) => { + const data = asDragData(source.data as any); + if (data) this.draggingId.set(data.id); + }, + onDrop: ({ location, source }) => { + this.draggingId.set(null); + const dropTargets = location.current.dropTargets; + if (!dropTargets.length) return; + const s = asDragData(source.data as any); + const target = asDropData(dropTargets[0].data as any); + if (!s || !target) return; + if (target.where === 'root') { + const instr: MoveInstruction = { + itemId: s.id, + targetId: '', + where: 'inside', + }; + this.nodes.set(moveNode(this.nodes(), instr)); + this.moved.emit(instr); + return; + } + if (target.where === 'inside') { + const targetNode = this.findNode(target.id); + if (!targetNode || !this.isFolder(targetNode)) { + return; // disallow dropping inside items + } + } + const instr: MoveInstruction = { + itemId: s.id, + targetId: target.id, + where: target.where, + } as MoveInstruction; + this.nodes.set(moveNode(this.nodes(), instr)); + this.moved.emit(instr); + }, + }), + ); + + this.destroyRef.onDestroy(() => { + cleanup.forEach((fn) => fn()); + }); + } + + private findNode(id: string): TreeNode | null { + const stack = [...this.nodes()]; + while (stack.length) { + const n = stack.pop()!; + if (n.id === id) return n; + if (n.children) stack.push(...n.children); + } + return null; + } + + toggle(node: TreeNode) { + if (!node.children?.length) return; + node.expanded = !node.expanded; + // trigger change + this.nodes.update((list) => [...list]); + } +} diff --git a/src/app/ui/tree-dnd/tree.types.ts b/src/app/ui/tree-dnd/tree.types.ts new file mode 100644 index 000000000..d26364c0e --- /dev/null +++ b/src/app/ui/tree-dnd/tree.types.ts @@ -0,0 +1,30 @@ +export type TreeId = string; + +export interface TreeNode { + id: TreeId; + label: string; + children?: TreeNode[]; + expanded?: boolean; + // mark as a folder even if children is empty + isFolder?: boolean; +} + +export type DropWhere = 'before' | 'after' | 'inside' | 'root'; + +export interface DropData { + type: 'drop'; + id: TreeId; + where: DropWhere; +} + +export interface DragData { + type: 'item'; + id: TreeId; + uniqueContextId: symbol; +} + +export interface MoveInstruction { + itemId: TreeId; + targetId: TreeId | ''; + where: Exclude | 'inside'; +} diff --git a/src/app/ui/tree-dnd/tree.utils.spec.ts b/src/app/ui/tree-dnd/tree.utils.spec.ts new file mode 100644 index 000000000..3c94b4bb4 --- /dev/null +++ b/src/app/ui/tree-dnd/tree.utils.spec.ts @@ -0,0 +1,90 @@ +import { moveNode, isAncestor, getPath } from './tree.utils'; +import type { TreeNode, MoveInstruction } from './tree.types'; + +function root(...children: TreeNode[]): TreeNode[] { + return children; +} + +describe('tree.utils', () => { + it('moves before a sibling', () => { + const data = root( + { id: 'A', label: 'A' }, + { id: 'B', label: 'B' }, + { id: 'C', label: 'C' }, + ); + + const instr: MoveInstruction = { itemId: 'C', targetId: 'A', where: 'before' }; + const result = moveNode(data, instr); + expect(result.map((n) => n.id)).toEqual(['C', 'A', 'B']); + }); + + it('moves after a sibling', () => { + const data = root( + { id: 'A', label: 'A' }, + { id: 'B', label: 'B' }, + { id: 'C', label: 'C' }, + ); + + const instr: MoveInstruction = { itemId: 'A', targetId: 'B', where: 'after' }; + const result = moveNode(data, instr); + expect(result.map((n) => n.id)).toEqual(['B', 'A', 'C']); + }); + + it('moves inside a folder (as first child)', () => { + const data = root( + { + id: 'A', + label: 'A', + isFolder: true, + expanded: true, + children: [{ id: 'A1', label: 'A1' }], + }, + { id: 'B', label: 'B' }, + ); + const instr: MoveInstruction = { itemId: 'B', targetId: 'A', where: 'inside' }; + const result = moveNode(data, instr); + const a = result.find((n) => n.id === 'A')!; + expect(a.children?.map((n) => n.id)).toEqual(['B', 'A1']); + }); + + it('prevents moving into own descendant', () => { + const data = root({ + id: 'A', + label: 'A', + expanded: true, + children: [{ id: 'A1', label: 'A1' }], + }); + const instr: MoveInstruction = { itemId: 'A', targetId: 'A1', where: 'inside' }; + const result = moveNode(data, instr); + // unchanged + expect(result).toEqual(data); + }); + + it('moves to root when target is empty string and where is inside', () => { + const data = root({ + id: 'A', + label: 'A', + expanded: true, + children: [{ id: 'A1', label: 'A1' }], + }); + const instr: MoveInstruction = { itemId: 'A1', targetId: '', where: 'inside' }; + const result = moveNode(data, instr); + expect(result.map((n) => n.id)).toEqual(['A1', 'A']); + }); + + it('isAncestor and getPath basics', () => { + const data = root( + { + id: 'A', + label: 'A', + children: [{ id: 'A1', label: 'A1', children: [{ id: 'A1a', label: 'A1a' }] }], + }, + { id: 'B', label: 'B' }, + ); + + expect(isAncestor(data, 'A', 'A1a')).toBeTrue(); + expect(isAncestor(data, 'A1', 'A1a')).toBeTrue(); + expect(isAncestor(data, 'B', 'A1a')).toBeFalse(); + expect(getPath(data, 'A1a')).toEqual(['A', 'A1', 'A1a']); + }); +}); diff --git a/src/app/ui/tree-dnd/tree.utils.ts b/src/app/ui/tree-dnd/tree.utils.ts new file mode 100644 index 000000000..878aea364 --- /dev/null +++ b/src/app/ui/tree-dnd/tree.utils.ts @@ -0,0 +1,102 @@ +import { MoveInstruction, TreeId, TreeNode } from './tree.types'; + +export function cloneNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.map((n) => ({ + ...n, + children: n.children ? cloneNodes(n.children) : undefined, + })); +} + +export function getPath(nodes: TreeNode[], targetId: TreeId): TreeId[] | null { + const path: TreeId[] = []; + const found = dfs(nodes, targetId, path); + return found ? path : null; + + function dfs(list: TreeNode[], id: TreeId, acc: TreeId[]): boolean { + for (const node of list) { + acc.push(node.id); + if (node.id === id) return true; + if (node.children && dfs(node.children, id, acc)) return true; + acc.pop(); + } + return false; + } +} + +export function isAncestor( + nodes: TreeNode[], + ancestorId: TreeId, + possibleDescendantId: TreeId, +): boolean { + const path = getPath(nodes, possibleDescendantId); + return !!path?.includes(ancestorId); +} + +export function findAndRemove(nodes: TreeNode[], id: TreeId): { node: TreeNode | null } { + const stack: { parent: TreeNode | null; list: TreeNode[] }[] = [ + { parent: null, list: nodes }, + ]; + while (stack.length) { + const { list } = stack.pop()!; + const idx = list.findIndex((n) => n.id === id); + if (idx !== -1) { + const [node] = list.splice(idx, 1); + return { node }; + } + for (const n of list) { + if (n.children?.length) stack.push({ parent: n, list: n.children }); + } + } + return { node: null }; +} + +export function getChildren(nodes: TreeNode[], id: '' | TreeId): TreeNode[] { + if (id === '') return nodes; + const stack = [...nodes]; + while (stack.length) { + const n = stack.pop()!; + if (n.id === id) return (n.children ??= []); + if (n.children) stack.push(...n.children); + } + return nodes; +} + +export function 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 + + const nodes = cloneNodes(data); + const { node } = findAndRemove(nodes, instr.itemId); + if (!node) return data; + + if (instr.where === 'inside') { + const children = getChildren(nodes, (instr.targetId as TreeId) ?? ''); + node.expanded ??= true; + children.unshift(node); + return nodes; + } + + // 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; + const insertIndex = instr.where === 'before' ? index : index + 1; + parentChildren.splice(insertIndex, 0, node); + return nodes; +} + +function getChildrenOfParent(nodes: TreeNode[], id: TreeId): TreeNode[] { + // returns the array that contains the node with id + const stack: { parent: TreeNode | null; list: TreeNode[] }[] = [ + { parent: null, list: nodes }, + ]; + while (stack.length) { + const ctx = stack.pop()!; + const idx = ctx.list.findIndex((n) => n.id === id); + if (idx !== -1) return ctx.list; + for (const n of ctx.list) { + if (n.children?.length) stack.push({ parent: n, list: n.children }); + } + } + return nodes; +}