feat(portal): implement drag and drop add/remove (#2383)

This commit is contained in:
Alex Inkin
2023-08-06 23:30:53 +04:00
committed by GitHub
parent 9f5a90ee9c
commit 0d079f0d89
16 changed files with 323 additions and 128 deletions

View File

@@ -20,7 +20,7 @@
</button>
<ng-template #content>
<app-actions
[actions]="(actions | toDesktopActions : id | async) || {}"
[actions]="actions"
(click)="dropdown.openChange.next(false)"
>
{{ title }}

View File

@@ -27,7 +27,6 @@
width: 2.5rem;
height: 2.5rem;
border-radius: 100%;
box-shadow: 0.25rem 0.25rem 0.25rem rgb(0 0 0 / 25%);
}
.title {

View File

@@ -16,7 +16,6 @@ import {
} from '@taiga-ui/core'
import { NavigationService } from '../navigation/navigation.service'
import { Action, ActionsComponent } from '../actions/actions.component'
import { ToDesktopActionsPipe } from '../../pipes/to-desktop-actions'
import { toRouterLink } from '../../utils/to-router-link'
@Component({
@@ -34,7 +33,6 @@ import { toRouterLink } from '../../utils/to-router-link'
TuiSvgModule,
TickerModule,
ActionsComponent,
ToDesktopActionsPipe,
],
})
export class CardComponent {

View File

@@ -0,0 +1,87 @@
import {
Directive,
ElementRef,
HostListener,
inject,
Input,
} from '@angular/core'
import { tuiGetActualTarget, tuiIsElement, tuiPx } from '@taiga-ui/cdk'
import { DrawerComponent } from './drawer.component'
import { DesktopService } from '../../services/desktop.service'
/**
* This directive is responsible for drag and drop of the drawer item.
* It saves item to desktop when dropped.
*/
@Directive({
selector: '[drawerItem]',
standalone: true,
host: {
'[style.touchAction]': '"none"',
},
})
export class DrawerItemDirective {
private readonly desktop = inject(DesktopService)
private readonly drawer = inject(DrawerComponent)
private readonly element: HTMLElement = inject(ElementRef).nativeElement
private x = NaN
private y = NaN
@Input()
drawerItem = ''
@HostListener('pointerdown.prevent.silent', ['$event'])
onStart(event: PointerEvent): void {
// This element is already on the desktop
if (this.desktop.items.includes(this.drawerItem)) return
const target = tuiGetActualTarget(event)
const { x, y, pointerId } = event
const { left, top } = this.element.getBoundingClientRect()
if (tuiIsElement(target)) {
target.releasePointerCapture(pointerId)
}
this.drawer.open = false
this.onPointer(x - left, y - top)
}
@HostListener('document:pointerup.silent')
onPointer(x = NaN, y = NaN): void {
// Some other element is dragged
if (Number.isNaN(this.x) && Number.isNaN(x)) return
this.x = x
this.y = y
this.process(NaN, NaN)
}
@HostListener('document:pointermove.silent', ['$event.x', '$event.y'])
onMove(x: number, y: number): void {
// This element is not dragged
if (Number.isNaN(this.x)) return
this.process(x, y)
this.desktop.add('')
}
private process(x: number, y: number) {
const { style } = this.element
const { items } = this.desktop
const dragged = !Number.isNaN(this.x + x)
style.pointerEvents = dragged ? 'none' : ''
style.position = dragged ? 'fixed' : ''
style.top = dragged ? tuiPx(y - this.y) : ''
style.left = dragged ? tuiPx(x - this.x) : ''
if (dragged || !items.includes('')) {
return
}
this.desktop.items = items.map(item => item || this.drawerItem)
this.desktop.reorder(this.desktop.order)
}
}

View File

@@ -22,6 +22,7 @@
empty: empty
"
appCard
[drawerItem]="item.key"
[id]="item.key"
[title]="item.value.title"
[icon]="item.value.icon"
@@ -37,6 +38,7 @@
empty: empty
"
appCard
[drawerItem]="item.manifest.id"
[id]="item.manifest.id"
[icon]="item.icon"
[title]="item.manifest.title"

View File

@@ -23,6 +23,7 @@ import { CardComponent } from '../card/card.component'
import { ServicesService } from '../../services/services.service'
import { SYSTEM_UTILITIES } from './drawer.const'
import { toRouterLink } from '../../utils/to-router-link'
import { DrawerItemDirective } from './drawer-item.directive'
@Component({
selector: 'app-drawer',
@@ -41,6 +42,7 @@ import { toRouterLink } from '../../utils/to-router-link'
TuiForModule,
TuiFilterPipeModule,
CardComponent,
DrawerItemDirective,
RouterLink,
],
})

View File

@@ -1,38 +0,0 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { Action } from '../components/actions/actions.component'
import { filter, map, Observable } from 'rxjs'
import { DesktopService } from '../routes/desktop/desktop.service'
@Pipe({
name: 'toDesktopActions',
standalone: true,
})
export class ToDesktopActionsPipe implements PipeTransform {
private readonly desktop = inject(DesktopService)
transform(
value: Record<string, readonly Action[]>,
id: string,
): Observable<Record<string, readonly Action[]>> {
return this.desktop.desktop$.pipe(
filter(Boolean),
map(desktop => {
const action = desktop.includes(id)
? {
icon: 'tuiIconMinus',
label: 'Remove from Desktop',
action: () => this.desktop.remove(id),
}
: {
icon: 'tuiIconPlus',
label: 'Add to Desktop',
action: () => this.desktop.add(id),
}
return {
manage: [action],
}
}),
)
}
}

View File

@@ -14,7 +14,9 @@ export class ToDesktopItemPipe implements PipeTransform {
transform(
packages: Record<string, PackageDataEntry>,
id: string,
): NavigationItem {
): NavigationItem | null {
if (!id) return null
const item = SYSTEM_UTILITIES[id]
const routerLink = toRouterLink(id)

View File

@@ -0,0 +1,37 @@
import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import {
endWith,
ignoreElements,
Observable,
shareReplay,
startWith,
take,
tap,
} from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { DesktopService } from '../../services/desktop.service'
/**
* This service loads initial values for desktop items
* and is used to show loading indicator.
*/
@Injectable({
providedIn: 'root',
})
export class DektopLoadingService extends Observable<boolean> {
private readonly desktop = inject(DesktopService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loading = this.patch.watch$('ui', 'desktop').pipe(
take(1),
tap(items => (this.desktop.items = items.filter(Boolean))),
ignoreElements(),
startWith(true),
endWith(false),
shareReplay(1),
)
constructor() {
super(subscriber => this.loading.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,40 @@
import {
Directive,
ElementRef,
HostBinding,
inject,
Input,
OnDestroy,
OnInit,
} from '@angular/core'
import { TuiTilesComponent } from '@taiga-ui/kit'
/**
* This directive is responsible for creating empty placeholder
* on the desktop when item is dragged from the drawer
*/
@Directive({
selector: '[desktopItem]',
standalone: true,
})
export class DesktopItemDirective implements OnInit, OnDestroy {
private readonly element: Element = inject(ElementRef).nativeElement
private readonly tiles = inject(TuiTilesComponent)
@Input()
desktopItem = ''
@HostBinding('class._empty')
get empty(): boolean {
return !this.desktopItem
}
ngOnInit() {
if (this.empty) this.tiles.element = this.element
}
// TODO: Remove after Taiga UI updated to 3.40.0
ngOnDestroy() {
if (this.tiles.element === this.element) this.tiles.element = null
}
}

View File

@@ -1,25 +1,41 @@
<ng-container *ngIf="desktop$ | async as desktop">
<tui-loader
*ngIf="loading$ | async; else data"
size="xl"
class="loader"
></tui-loader>
<ng-template #data>
<tui-svg
*ngIf="tile?.element"
class="remove"
src="tuiIconTrash2Large"
@tuiFadeIn
@tuiScaleIn
(pointerup)="onRemove()"
></tui-svg>
<tui-tiles
*ngIf="packages$ | async as packages"
class="tiles"
@tuiParentStop
[debounce]="500"
[order]="order"
(orderChange)="onReorder($event, desktop)"
[order]="desktop.order"
(orderChange)="onReorder($event)"
>
<tui-tile
*ngFor="let service of desktop; let index = index"
*ngFor="let item of desktop.items; let index = index"
class="item"
[style.order]="order.get(index)"
[style.order]="desktop.order.get(index)"
[desktopItem]="item"
>
<a
*ngIf="packages | toDesktopItem : service as item"
*ngIf="packages | toDesktopItem : item as desktopItem"
tuiTileHandle
appCard
[id]="service"
[title]="item.title"
[icon]="item.icon"
[routerLink]="item.routerLink"
@tuiFadeIn
[id]="item"
[title]="desktopItem.title"
[icon]="desktopItem.icon"
[routerLink]="desktopItem.routerLink"
></a>
</tui-tile>
</tui-tiles>
</ng-container>
</ng-template>

View File

@@ -1,3 +1,5 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
align-items: center;
@@ -9,10 +11,38 @@
padding: 1rem 0;
}
.loader {
height: 10rem;
width: 10rem;
}
.tiles {
width: 100%;
min-height: 5.5rem;
justify-content: center;
grid-template-columns: repeat(auto-fit, 12.5rem);
grid-auto-rows: 5.5rem;
gap: 2rem;
}
.remove {
@include transition(background);
position: fixed;
bottom: 0;
left: calc(50% - 3rem);
width: 6rem;
height: 6rem;
border-radius: 100%;
background: var(--tui-base-02);
z-index: 10;
&:hover {
background: var(--tui-base-01);
}
}
.item._dragged,
.item._empty {
border-radius: var(--tui-radius-l);
box-shadow: inset 0 0 0 0.5rem var(--tui-clear-active);
}

View File

@@ -1,35 +1,51 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
Component,
ElementRef,
inject,
QueryList,
ViewChild,
ViewChildren,
} from '@angular/core'
import { EMPTY_QUERY, TUI_PARENT_STOP } from '@taiga-ui/cdk'
import { tuiFadeIn, tuiScaleIn } from '@taiga-ui/core'
import { TuiTileComponent, TuiTilesComponent } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { DesktopService } from './desktop.service'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { DesktopService } from '../../services/desktop.service'
import { Observable } from 'rxjs'
import { DektopLoadingService } from './dektop-loading.service'
@Component({
templateUrl: 'desktop.component.html',
styleUrls: ['desktop.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [TUI_PARENT_STOP, tuiScaleIn, tuiFadeIn],
})
export class DesktopComponent {
private readonly desktop = inject(DesktopService)
@ViewChildren(TuiTileComponent, { read: ElementRef })
private readonly tiles: QueryList<ElementRef> = EMPTY_QUERY
readonly desktop$ = this.desktop.desktop$.pipe(
tap(() => (this.order = new Map())),
)
readonly packages$ =
readonly desktop = inject(DesktopService)
readonly loading$ = inject(DektopLoadingService)
readonly packages$: Observable<Record<string, PackageDataEntry>> =
inject<PatchDB<DataModel>>(PatchDB).watch$('package-data')
order = new Map()
@ViewChild(TuiTilesComponent)
readonly tile?: TuiTilesComponent
onReorder(order: Map<number, number>, desktop: readonly string[]) {
this.order = order
onRemove() {
const element = this.tile?.element
const index = this.tiles
.toArray()
.map(({ nativeElement }) => nativeElement)
.indexOf(element)
const items: string[] = []
this.desktop.remove(this.desktop.items[index])
}
Array.from(this.order.entries()).forEach(([index, order]) => {
items[order] = desktop[index]
})
this.desktop.save(items)
onReorder(order: Map<number, number>) {
this.desktop.reorder(order)
}
}

View File

@@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { TuiLoaderModule, TuiSvgModule } from '@taiga-ui/core'
import { TuiTilesModule } from '@taiga-ui/kit'
import { DesktopComponent } from './desktop.component'
import { CardComponent } from '../../components/card/card.component'
import { ToDesktopActionsPipe } from '../../pipes/to-desktop-actions'
import { ToDesktopItemPipe } from '../../pipes/to-desktop-item'
import { DesktopItemDirective } from './desktop-item.directive'
const ROUTES: Routes = [
{
@@ -18,8 +19,10 @@ const ROUTES: Routes = [
imports: [
CommonModule,
CardComponent,
DesktopItemDirective,
TuiSvgModule,
TuiLoaderModule,
TuiTilesModule,
ToDesktopActionsPipe,
ToDesktopItemPipe,
RouterModule.forChild(ROUTES),
],

View File

@@ -1,52 +0,0 @@
import { inject, Injectable } from '@angular/core'
import { TuiAlertService } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { BehaviorSubject, first } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Injectable({
providedIn: 'root',
})
export class DesktopService {
private readonly alerts = inject(TuiAlertService)
private readonly api = inject(ApiService)
readonly desktop$ = new BehaviorSubject<readonly string[] | undefined>(
undefined,
)
constructor() {
inject<PatchDB<DataModel>>(PatchDB)
.watch$('ui', 'desktop')
.pipe(first())
.subscribe(desktop => {
if (!this.desktop$.value) {
this.desktop$.next(desktop)
}
})
}
add(id: string) {
this.desktop$.next(this.desktop$.value?.concat(id))
this.save(this.desktop$.value)
}
remove(id: string) {
this.desktop$.next(this.desktop$.value?.filter(x => x !== id))
this.save(this.desktop$.value)
}
save(ids: readonly string[] = []) {
this.api
.setDbValue(['desktop'], ids)
.catch(() =>
this.alerts
.open(
'Desktop might be out of sync. Please refresh the page to fix it.',
{ status: 'warning' },
)
.subscribe(),
)
}
}

View File

@@ -0,0 +1,53 @@
import { inject, Injectable } from '@angular/core'
import { TuiAlertService } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Injectable({
providedIn: 'root',
})
export class DesktopService {
private readonly alerts = inject(TuiAlertService)
private readonly api = inject(ApiService)
order = new Map()
items: readonly string[] = []
add(id: string) {
if (this.items.includes(id)) return
this.items = this.items.concat(id)
this.save(this.items)
}
remove(id: string) {
if (!this.items.includes(id)) return
this.items = this.items.filter(x => x !== id)
this.save(this.items)
}
save(ids: readonly string[] = []) {
this.api
.setDbValue(['desktop'], Array.from(new Set(ids)))
.catch(() =>
this.alerts
.open(
'Desktop might be out of sync. Please refresh the page to fix it.',
{ status: 'warning' },
)
.subscribe(),
)
}
reorder(order: Map<number, number>) {
this.order = order
const items: string[] = [...this.items]
Array.from(this.order.entries()).forEach(([index, order]) => {
items[order] = this.items[index]
})
this.save(items)
}
}