@deepkit/desktop-ui
Version:
Library for desktop UI widgets in Angular 10+
740 lines (643 loc) • 22.5 kB
text/typescript
/*
* 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;
}
export class ButtonHotkeyComponent implements OnChanges, OnInit {
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;
}
}
}
}
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.
*/
icon?: string;
/**
* Change in the icon size. Should not be necessary usually.
*/
iconSize?: number;
iconRight?: boolean | '' = false;
iconColor?: string;
showHotkey?: HotKey;
/**
* Whether the button is active (pressed)
*/
active: boolean | '' = false;
routerLink?: string | UrlTree | any[];
routerLinkExact?: boolean;
/**
* Whether the button has no padding and smaller font size
*/
small: boolean | '' = false;
/**
* Whether the button has smaller padding. Better for button with icons.
*/
tight: boolean | '' = false;
/**
* Whether the button is highlighted.
*/
highlighted: boolean | '' = false;
/**
* Whether the button is primary.
*/
primary: boolean | '' = false;
/**
* Whether the button is focused on initial loading.
*/
focused: boolean | '' = false;
/**
* Whether the button is focused on initial loading.
*/
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,
public cdParent: ChangeDetectorRef,
public formComponent: FormComponent,
public router?: Router,
public activatedRoute?: ActivatedRoute,
) {
this.element.nativeElement.removeAttribute('tabindex');
}
hasText() {
return this.withText === undefined ? this.detectedText : this.withText;
}
disabled: boolean | '' = false;
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;
}
square: boolean | '' = false;
get isRound() {
return false !== this.square;
}
textured: boolean | '' = false;
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();
}
}
}
async onClick() {
if (this.isDisabled) return;
if (this.submitForm) {
this.submitForm.submitForm();
}
}
}
export class HotkeyDirective {
hotkey!: HotKey;
protected oldButtonActive?: boolean | '';
protected active = false;
constructor(
private elementRef: ElementRef,
private app: ApplicationRef,
private button?: ButtonComponent,
) {
}
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);
}
}
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.
*/
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.
*/
float: 'static' | 'sidebar' | 'float' | 'right' = 'static';
padding: 'normal' | 'none' = 'normal';
get isPaddingNone() {
return this.padding === 'none';
}
// @HostBinding('class.ready')
// protected init = false;
constructor(
private element: ElementRef<HTMLElement>,
protected cd: ChangeDetectorRef,
private windowState?: WindowState,
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';
}
}
export class ButtonGroupsComponent {
align: 'left' | 'center' | 'right' = 'left';
}
export class FileChooserDirective extends ValueAccessorBase<any> implements OnDestroy, OnChanges {
duiFileMultiple?: boolean | '' = false;
duiFileDirectory?: boolean | '' = false;
// @Input() duiFileChooser?: string | string[];
duiFileChooserChange = new EventEmitter<string | string[]>();
protected input: HTMLInputElement;
constructor(
protected injector: Injector,
public readonly cd: ChangeDetectorRef,
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;
}
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);
});
}
export class FilePickerDirective extends ValueAccessorBase<any> implements OnDestroy, AfterViewInit {
duiFileMultiple?: boolean | '' = false;
duiFileAutoOpen: boolean = false;
duiFilePickerChange = new EventEmitter<FilePickerItem | FilePickerItem[]>();
protected input: HTMLInputElement;
constructor(
protected injector: Injector,
public readonly cd: ChangeDetectorRef,
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();
}
onClick() {
this.input.multiple = this.duiFileMultiple !== false;
this.input.click();
}
}
export class FileDropDirective extends ValueAccessorBase<any> implements OnDestroy {
duiFileDropMultiple?: boolean | '' = false;
duiFileDropChange = new EventEmitter<FilePickerItem | FilePickerItem[]>();
// hover = false;
i: number = 0;
constructor(
protected injector: Injector,
public readonly cd: ChangeDetectorRef,
public readonly cdParent: ChangeDetectorRef,
private app: ApplicationRef,
) {
super(injector, cd, cdParent);
}
onDragEnter(ev: any) {
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();
this.i++;
this.cdParent.detectChanges();
}
onDragOver(ev: any) {
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();
}
onDragLeave(ev: any) {
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();
this.i--;
this.cdParent.detectChanges();
}
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() {
}
}