UNPKG

@deepkit/desktop-ui

Version:

Library for desktop UI widgets in Angular 10+

740 lines (643 loc) 22.5 kB
/* * Deepkit Framework * Copyright (C) 2021 Deepkit UG, Marc J. Schmidt * * This program is free software: you can redistribute it and/or modify * it under the terms of the MIT License. * * You should have received a copy of the MIT License along with this program. */ import { AfterViewInit, ApplicationRef, ChangeDetectorRef, Component, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Injector, Input, OnChanges, OnDestroy, OnInit, Optional, Output, SkipSelf, } from '@angular/core'; import { WindowComponent } from '../window/window.component'; import { WindowState } from '../window/window-state'; import { FormComponent } from '../form/form.component'; import { ngValueAccessor, ValueAccessorBase } from '../../core/form'; import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { isMacOs, isRouteActive } from '../../core/utils'; /** * hotkey has format of "ctrl+shift+alt+key", e.g "ctrl+s" or "shift+o" * and supports more than one, e.g. "ctrl+s,ctrl+o" */ export type HotKey = string; function hotKeySize(hotkey: HotKey) { const hotkeys = hotkey.split(','); let size = hotkeys.length - 1; const isMac = isMacOs(); //mac uses one char per key, windows uses '⊞ WIN' for meta key, 'CTRL' for ctrl key, 'ALT' for alt key for (const hotkey of hotkeys) { const keys = hotkey.split('+'); for (const key of keys) { if (key === 'ctrl') size += isMac ? 1 : 4; if (key === 'meta') size += isMac ? 1 : 4; if (key === 'shift') size += 1; if (key === 'alt') size += isMac ? 1 : 3; if (key !== 'ctrl' && key !== 'meta' && key !== 'alt' && key !== 'shift') size += key.length; } } return size; } function isHotKeyActive(hotkey: HotKey, event: KeyboardEvent) { const eventKey = event.key.toLowerCase(); const hotkeys = hotkey.split(','); for (const hotkey of hotkeys) { const keys = hotkey.split('+'); let match = true; for (const key of keys) { if (key === 'ctrl' && !event.ctrlKey) { match = false; break; } if (key === 'meta' && (!event.ctrlKey && !event.metaKey)) { match = false; break; } if (key === 'shift' && !event.shiftKey) { match = false; break; } if (key === 'alt' && !event.altKey) { match = false; break; } if (key !== 'ctrl' && key !== 'meta' && key !== 'alt' && key !== 'shift' && key !== eventKey) { match = false; break; } } if (match) return true; } return false; } @Component({ selector: 'dui-button-hotkey', standalone: false, styles: [` :host { display: inline-flex; flex-direction: row; } span { text-transform: uppercase; color: var(--text-grey); margin-left: 3px; font-size: 11px; } `], template: ` <span *ngIf="metaKey">{{isMac ? '⌘' : 'WIN'}}</span> <span *ngIf="ctrlKey">{{isMac ? '⌃' : 'CTRL'}}</span> <span *ngIf="altKey">{{isMac ? '⌥' : 'ALT'}}</span> <span *ngIf="shiftKey">⇧</span> <span *ngIf="key">{{key}}</span> ` }) export class ButtonHotkeyComponent implements OnChanges, OnInit { @Input() hotkey?: HotKey; isMac = isMacOs(); metaKey = false; ctrlKey = false; shiftKey = false; altKey = false; key = ''; ngOnInit() { this.parse(); } ngOnChanges() { this.parse(); } parse() { //reset all this.metaKey = false; this.ctrlKey = false; this.shiftKey = false; this.altKey = false; this.key = ''; if (!this.hotkey) return; const hotkeys = this.hotkey.split(','); for (const hotkey of hotkeys) { const keys = hotkey.split('+'); for (const key of keys) { if (key === 'ctrl') this.ctrlKey = true; if (key === 'meta') this.metaKey = true; if (key === 'shift') this.shiftKey = true; if (key === 'alt') this.altKey = true; if (key !== 'ctrl' && key !== 'shift' && key !== 'alt' && key !== 'meta') this.key = key; } } } } @Component({ selector: 'dui-button', standalone: false, template: ` <dui-icon *ngIf="icon && iconRight === false" [color]="iconColor" [name]="icon" [size]="iconSize"></dui-icon> <ng-content></ng-content> <dui-icon *ngIf="icon && iconRight !== false" [color]="iconColor" [name]="icon" [size]="iconSize"></dui-icon> <div *ngIf="showHotkey" class="show-hotkey" [style.width.px]="hotKeySize(showHotkey) * 6"></div> <dui-button-hotkey *ngIf="showHotkey" style="position: absolute; right: 6px; top: 0;" [hotkey]="showHotkey"></dui-button-hotkey> `, host: { '[attr.tabindex]': '1', '[class.icon]': '!!icon', '[class.small]': 'small !== false', '[class.tight]': 'tight !== false', '[class.active]': 'isActive()', '[class.highlighted]': 'highlighted !== false', '[class.primary]': 'primary !== false', '[class.icon-left]': 'iconRight === false', '[class.icon-right]': 'iconRight !== false', '[class.with-text]': 'hasText()', }, styleUrls: ['./button.component.scss'], }) export class ButtonComponent implements OnInit, AfterViewInit { hotKeySize = hotKeySize; /** * The icon for this button. Either a icon name same as for dui-icon, or an image path. */ @Input() icon?: string; /** * Change in the icon size. Should not be necessary usually. */ @Input() iconSize?: number; @Input() iconRight?: boolean | '' = false; @Input() iconColor?: string; @Input() showHotkey?: HotKey; /** * Whether the button is active (pressed) */ @Input() active: boolean | '' = false; @Input() routerLink?: string | UrlTree | any[]; @Input() routerLinkExact?: boolean; /** * Whether the button has no padding and smaller font size */ @Input() small: boolean | '' = false; /** * Whether the button has smaller padding. Better for button with icons. */ @Input() tight: boolean | '' = false; /** * Whether the button is highlighted. */ @Input() highlighted: boolean | '' = false; /** * Whether the button is primary. */ @Input() primary: boolean | '' = false; /** * Whether the button is focused on initial loading. */ @Input() focused: boolean | '' = false; /** * Whether the button is focused on initial loading. */ @Input() submitForm?: FormComponent; /** * Auto-detected but could be set manually as well. * Necessary for correct icon placement. */ withText?: boolean; protected detectedText: boolean = false; constructor( public element: ElementRef, @SkipSelf() public cdParent: ChangeDetectorRef, @Optional() public formComponent: FormComponent, @Optional() public router?: Router, @Optional() public activatedRoute?: ActivatedRoute, ) { this.element.nativeElement.removeAttribute('tabindex'); } hasText() { return this.withText === undefined ? this.detectedText : this.withText; } @Input() disabled: boolean | '' = false; @HostBinding('class.disabled') get isDisabled() { if (this.formComponent && this.formComponent.disabled) return true; if (this.submitForm && (this.submitForm.invalid || this.submitForm.disabled || this.submitForm.submitting)) { return true; } return false !== this.disabled; } @Input() square: boolean | '' = false; @HostBinding('class.square') get isRound() { return false !== this.square; } @Input() textured: boolean | '' = false; @HostBinding('class.textured') get isTextured() { return false !== this.textured; } ngOnInit() { if (this.focused !== false) { setTimeout(() => { this.element.nativeElement.focus(); }, 10); } } isActive() { if (this.routerLink && this.router) return isRouteActive(this); return false !== this.active; } ngAfterViewInit() { if (this.icon) { const content = this.element.nativeElement.innerText.trim(); const hasText = content !== this.icon && content.length > 0; if (hasText) { this.detectedText = true; this.cdParent.detectChanges(); } } } @HostListener('click') async onClick() { if (this.isDisabled) return; if (this.submitForm) { this.submitForm.submitForm(); } } } @Directive({ selector: '[hotkey]', standalone: false, }) export class HotkeyDirective { @Input() hotkey!: HotKey; protected oldButtonActive?: boolean | ''; protected active = false; constructor( private elementRef: ElementRef, private app: ApplicationRef, @Optional() private button?: ButtonComponent, ) { } @HostListener('document:keydown', ['$event']) onKeyDown(event: KeyboardEvent) { //if only alt is pressed (not other keys, we display the hotkey) if (event.key.toLowerCase() === 'alt') { if (this.button) { this.button.showHotkey = this.hotkey; return; } } const active = isHotKeyActive(this.hotkey, event); // console.log('keydown', event.key, this.hotkey, isHotKeyActive(this.hotkey, event)); if (!active) return; event.preventDefault(); if (this.active) return; this.active = true; this.elementRef.nativeElement.click(); if (this.button) { this.oldButtonActive = this.button.active; this.button.active = true; setTimeout(() => { if (this.button && this.oldButtonActive !== undefined) { this.button.active = this.oldButtonActive; this.oldButtonActive = undefined; this.button.cdParent.detectChanges(); this.active = false; this.app.tick(); } }, 40); } } @HostListener('document:keyup', ['$event']) onKeyUp(event: KeyboardEvent) { //if only alt is pressed (not other keys, we display the hotkey) if (event.key.toLowerCase() === 'alt') { if (this.button) { this.button.showHotkey = undefined; return; } } // console.log('keyup', event.key, this.hotkey, isHotKeyActive(this.hotkey, event)); // if (!isHotKeyActive(this.hotkey, event)) return; // event.preventDefault(); // this.elementRef.nativeElement.click(); // if (this.button && this.oldButtonActive !== undefined) { // this.button.active = this.oldButtonActive; // } } } /** * Used to group buttons together. */ @Component({ selector: 'dui-button-group', standalone: false, template: '<ng-content></ng-content>', host: { '[class.float-right]': 'float===\'right\'', '(transitionend)': 'transitionEnded()' }, styleUrls: ['./button-group.component.scss'] }) export class ButtonGroupComponent implements AfterViewInit, OnDestroy { /** * How the button should behave. * `sidebar` means it aligns with the sidebar. Is the sidebar open, this button-group has a left margin. * Is it closed, the margin is gone. */ @Input() float: 'static' | 'sidebar' | 'float' | 'right' = 'static'; @Input() padding: 'normal' | 'none' = 'normal'; @HostBinding('class.padding-none') get isPaddingNone() { return this.padding === 'none'; } // @HostBinding('class.ready') // protected init = false; constructor( private element: ElementRef<HTMLElement>, @SkipSelf() protected cd: ChangeDetectorRef, @Optional() private windowState?: WindowState, @Optional() private windowComponent?: WindowComponent, ) { } public activateOneTimeAnimation() { (this.element.nativeElement as HTMLElement).classList.add('with-animation'); } public sidebarMoved() { this.updatePaddingLeft(); } ngOnDestroy(): void { } transitionEnded() { (this.element.nativeElement as HTMLElement).classList.remove('with-animation'); } ngAfterViewInit(): void { if (this.float === 'sidebar' && this.windowState) { this.windowState.buttonGroupAlignedToSidebar = this; } this.updatePaddingLeft(); } updatePaddingLeft() { if (this.float === 'sidebar' && this.windowComponent) { if (this.windowComponent.content) { if (this.windowComponent.content!.isSidebarVisible()) { const newLeft = Math.max(0, this.windowComponent.content!.getSidebarWidth() - this.element.nativeElement.offsetLeft) + 'px'; if (this.element.nativeElement.style.paddingLeft == newLeft) { //no transition change, doesn't trigger transitionEnd (this.element.nativeElement as HTMLElement).classList.remove('with-animation'); return; } this.element.nativeElement.style.paddingLeft = newLeft; return; } } } this.element.nativeElement.style.paddingLeft = '0px'; } } @Component({ selector: 'dui-button-groups', standalone: false, template: ` <ng-content></ng-content> `, host: { '[class.align-left]': `align === 'left'`, '[class.align-center]': `align === 'center'`, '[class.align-right]': `align === 'right'`, }, styleUrls: ['./button-groups.component.scss'], }) export class ButtonGroupsComponent { @Input() align: 'left' | 'center' | 'right' = 'left'; } @Directive({ selector: '[duiFileChooser]', standalone: false, providers: [ngValueAccessor(FileChooserDirective)] }) export class FileChooserDirective extends ValueAccessorBase<any> implements OnDestroy, OnChanges { @Input() duiFileMultiple?: boolean | '' = false; @Input() duiFileDirectory?: boolean | '' = false; // @Input() duiFileChooser?: string | string[]; @Output() duiFileChooserChange = new EventEmitter<string | string[]>(); protected input: HTMLInputElement; constructor( protected injector: Injector, public readonly cd: ChangeDetectorRef, @SkipSelf() public readonly cdParent: ChangeDetectorRef, private app: ApplicationRef, ) { super(injector, cd, cdParent); const input = document.createElement('input'); input.setAttribute('type', 'file'); this.input = input; this.input.addEventListener('change', (event: any) => { const files = event.target.files as FileList; if (files.length) { if (this.duiFileMultiple !== false) { const paths: string[] = []; for (let i = 0; i < files.length; i++) { const file = files.item(i) as any as { path: string, name: string }; paths.push(file.path); } this.innerValue = paths; } else { const file = files.item(0) as any as { path: string, name: string }; this.innerValue = file.path; } this.duiFileChooserChange.emit(this.innerValue); this.app.tick(); } }); } ngOnDestroy() { } ngOnChanges(): void { (this.input as any).webkitdirectory = this.duiFileDirectory !== false; this.input.multiple = this.duiFileMultiple !== false; } @HostListener('click') onClick() { this.input.click(); } } export interface FilePickerItem { data: Uint8Array; name: string; } function readFile(file: File): Promise<Uint8Array | undefined> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (reader.result) { if (reader.result instanceof ArrayBuffer) { resolve(new Uint8Array(reader.result)); } else { resolve(undefined); } } }; reader.onerror = (error) => { console.log('Error: ', error); reject(); }; reader.readAsArrayBuffer(file); }); } @Directive({ selector: '[duiFilePicker]', standalone: false, providers: [ngValueAccessor(FileChooserDirective)] }) export class FilePickerDirective extends ValueAccessorBase<any> implements OnDestroy, AfterViewInit { @Input() duiFileMultiple?: boolean | '' = false; @Input() duiFileAutoOpen: boolean = false; @Output() duiFilePickerChange = new EventEmitter<FilePickerItem | FilePickerItem[]>(); protected input: HTMLInputElement; constructor( protected injector: Injector, public readonly cd: ChangeDetectorRef, @SkipSelf() public readonly cdParent: ChangeDetectorRef, private app: ApplicationRef, ) { super(injector, cd, cdParent); const input = document.createElement('input'); input.setAttribute('type', 'file'); this.input = input; this.input.addEventListener('change', async (event: any) => { const files = event.target.files as FileList; if (files.length) { if (this.duiFileMultiple !== false) { const res: FilePickerItem[] = []; for (let i = 0; i < files.length; i++) { const file = files.item(i); if (file) { const uint8Array = await readFile(file); if (uint8Array) { res.push({ data: uint8Array, name: file.name }); } } } this.innerValue = res; } else { const file = files.item(0); if (file) { this.innerValue = { data: await readFile(file), name: file.name }; } } this.duiFilePickerChange.emit(this.innerValue); this.app.tick(); } }); } ngOnDestroy() { } ngAfterViewInit() { if (this.duiFileAutoOpen) this.onClick(); } @HostListener('click') onClick() { this.input.multiple = this.duiFileMultiple !== false; this.input.click(); } } @Directive({ selector: '[duiFileDrop]', standalone: false, host: { '[class.file-drop-hover]': 'i > 0', }, providers: [ngValueAccessor(FileChooserDirective)] }) export class FileDropDirective extends ValueAccessorBase<any> implements OnDestroy { @Input() duiFileDropMultiple?: boolean | '' = false; @Output() duiFileDropChange = new EventEmitter<FilePickerItem | FilePickerItem[]>(); // hover = false; i: number = 0; constructor( protected injector: Injector, public readonly cd: ChangeDetectorRef, @SkipSelf() public readonly cdParent: ChangeDetectorRef, private app: ApplicationRef, ) { super(injector, cd, cdParent); } @HostListener('dragenter', ['$event']) onDragEnter(ev: any) { // Prevent default behavior (Prevent file from being opened) ev.preventDefault(); this.i++; this.cdParent.detectChanges(); } @HostListener('dragover', ['$event']) onDragOver(ev: any) { // Prevent default behavior (Prevent file from being opened) ev.preventDefault(); } @HostListener('dragleave', ['$event']) onDragLeave(ev: any) { // Prevent default behavior (Prevent file from being opened) ev.preventDefault(); this.i--; this.cdParent.detectChanges(); } @HostListener('drop', ['$event']) async onDrop(ev: any) { // Prevent default behavior (Prevent file from being opened) ev.preventDefault(); const res: FilePickerItem[] = []; if (ev.dataTransfer.items) { // Use DataTransferItemList interface to access the file(s) for (let i = 0; i < ev.dataTransfer.items.length; i++) { // If dropped items aren't files, reject them if (ev.dataTransfer.items[i].kind === 'file') { const file = ev.dataTransfer.items[i].getAsFile(); if (file) { const uint8Array = await readFile(file); if (uint8Array) { res.push({ data: uint8Array, name: file.name }); } } } } } else { // Use DataTransfer interface to access the file(s) for (let i = 0; i < ev.dataTransfer.files.length; i++) { const file = ev.dataTransfer.files.item(i); if (file) { const uint8Array = await readFile(file); if (uint8Array) { res.push({ data: uint8Array, name: file.name }); } } } } if (this.duiFileDropMultiple !== false) { this.innerValue = res; } else { if (res.length) { this.innerValue = res[0]; } else { this.innerValue = undefined; } } this.duiFileDropChange.emit(this.innerValue); this.i = 0; this.cdParent.detectChanges(); } ngOnDestroy() { } }