mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(projectFolders): add new ui component
This commit is contained in:
parent
d86b924ca5
commit
dbb3eb02f7
10 changed files with 802 additions and 0 deletions
33
src/app/ui/tree-dnd/dnd-draggable.directive.ts
Normal file
33
src/app/ui/tree-dnd/dnd-draggable.directive.ts
Normal file
|
|
@ -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<HTMLElement>);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Inputs (signal-based)
|
||||
id = input.required<string>({ alias: 'dndDraggable' });
|
||||
dndContext = input.required<symbol>();
|
||||
|
||||
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?.());
|
||||
}
|
||||
}
|
||||
84
src/app/ui/tree-dnd/dnd-drop-target.directive.ts
Normal file
84
src/app/ui/tree-dnd/dnd-drop-target.directive.ts
Normal file
|
|
@ -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<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 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 });
|
||||
}
|
||||
}
|
||||
29
src/app/ui/tree-dnd/dnd.helpers.spec.ts
Normal file
29
src/app/ui/tree-dnd/dnd.helpers.spec.ts
Normal file
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
33
src/app/ui/tree-dnd/dnd.helpers.ts
Normal file
33
src/app/ui/tree-dnd/dnd.helpers.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { DragData, DropData } from './tree.types';
|
||||
|
||||
type AnyData = Record<string | symbol, unknown>;
|
||||
|
||||
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<DragData>;
|
||||
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<DropData>;
|
||||
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;
|
||||
}
|
||||
112
src/app/ui/tree-dnd/tree.component.html
Normal file
112
src/app/ui/tree-dnd/tree.component.html
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<div
|
||||
class="tree"
|
||||
#root
|
||||
>
|
||||
@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="tree__root-drop"
|
||||
[class.is-over]="rootOver()"
|
||||
[dndDropTarget]="{ id: '', where: 'root' }"
|
||||
[dndContext]="contextId"
|
||||
(activeChange)="rootOver.set($event)"
|
||||
(indicator)="onIndicator($event)"
|
||||
></div>
|
||||
|
||||
<!-- floating drop indicator -->
|
||||
<div
|
||||
class="tree__indicator"
|
||||
[style.display]="indicatorVisible() ? 'block' : 'none'"
|
||||
[style.top.px]="indicatorTop()"
|
||||
[style.left.px]="indicatorLeft()"
|
||||
[style.width.px]="indicatorWidth()"
|
||||
>
|
||||
<span class="tree__indicator-dot"></span>
|
||||
<span class="tree__indicator-line"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template
|
||||
#nodeTpl
|
||||
let-node
|
||||
let-level="level"
|
||||
>
|
||||
<div
|
||||
class="tree__item"
|
||||
[class.tree__item--folder]="isFolder(node)"
|
||||
[style.paddingLeft.px]="level * indent()"
|
||||
>
|
||||
<!-- before drop zone -->
|
||||
<div
|
||||
class="tree__drop tree__drop--before"
|
||||
[class.is-over]="isOver(node.id, 'before')"
|
||||
[dndDropTarget]="{ id: node.id, where: 'before' }"
|
||||
[dndContext]="contextId"
|
||||
(activeChange)="setOver(node.id, 'before', $event)"
|
||||
(indicator)="onIndicator($event)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="tree__row"
|
||||
[attr.data-id]="node.id"
|
||||
[class.is-dragging]="draggingId() === node.id"
|
||||
[class.is-over]="isOver(node.id, 'inside')"
|
||||
[dndDropTarget]="isFolder(node) ? { id: node.id, where: 'inside' } : null"
|
||||
[dndContext]="contextId"
|
||||
(activeChange)="setOver(node.id, 'inside', $event)"
|
||||
>
|
||||
<span
|
||||
class="tree__handle"
|
||||
[dndDraggable]="node.id"
|
||||
[dndContext]="contextId"
|
||||
title="Drag"
|
||||
>≡</span
|
||||
>
|
||||
@if (isFolder(node)) {
|
||||
<span
|
||||
class="tree__expander"
|
||||
(click)="$event.stopPropagation(); node.expanded = !node.expanded"
|
||||
>
|
||||
{{ node.expanded ? '▾' : '▸' }}
|
||||
</span>
|
||||
}
|
||||
@if (folderTpl() && isFolder(node)) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="folderTpl(); context: { $implicit: node }"
|
||||
></ng-container>
|
||||
} @else {
|
||||
@if (itemTpl(); as tpl) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="tpl; context: { $implicit: node }"
|
||||
></ng-container>
|
||||
} @else {
|
||||
{{ node.label }}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- after drop zone -->
|
||||
<div
|
||||
class="tree__drop tree__drop--after"
|
||||
[class.is-over]="isOver(node.id, 'after')"
|
||||
[dndDropTarget]="{ id: node.id, where: 'after' }"
|
||||
[dndContext]="contextId"
|
||||
(activeChange)="setOver(node.id, 'after', $event)"
|
||||
(indicator)="onIndicator($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@if (node.expanded && node.children?.length) {
|
||||
<div class="tree__group">
|
||||
@for (child of node.children; track child.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="nodeTpl; context: { $implicit: child, level: level + 1 }"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
102
src/app/ui/tree-dnd/tree.component.scss
Normal file
102
src/app/ui/tree-dnd/tree.component.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
187
src/app/ui/tree-dnd/tree.component.ts
Normal file
187
src/app/ui/tree-dnd/tree.component.ts
Normal file
|
|
@ -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<HTMLElement>);
|
||||
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<TreeNode[]>();
|
||||
readonly indent = input(16);
|
||||
|
||||
// Local state mirrored from input for internal reordering
|
||||
readonly nodes = signal<TreeNode[]>([]);
|
||||
private _syncEffect = effect(() => {
|
||||
this.nodes.set(this.data() ?? []);
|
||||
});
|
||||
|
||||
// Content templates (signal-based)
|
||||
readonly itemTpl = contentChild<TemplateRef<any>>('treeItem');
|
||||
readonly folderTpl = contentChild<TemplateRef<any>>('treeFolder');
|
||||
|
||||
// Output: notify consumers of moves
|
||||
moved = output<MoveInstruction>();
|
||||
|
||||
// drag highlight state (ids mapped to where states)
|
||||
private overMap = signal<Record<string, Partial<Record<DropWhere, boolean>>>>({});
|
||||
|
||||
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<boolean>(false);
|
||||
|
||||
draggingId = signal<string | null>(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]);
|
||||
}
|
||||
}
|
||||
30
src/app/ui/tree-dnd/tree.types.ts
Normal file
30
src/app/ui/tree-dnd/tree.types.ts
Normal file
|
|
@ -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<DropWhere, 'root'> | 'inside';
|
||||
}
|
||||
90
src/app/ui/tree-dnd/tree.utils.spec.ts
Normal file
90
src/app/ui/tree-dnd/tree.utils.spec.ts
Normal file
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
102
src/app/ui/tree-dnd/tree.utils.ts
Normal file
102
src/app/ui/tree-dnd/tree.utils.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue