@deepkit/desktop-ui
Version:
Library for desktop UI widgets in Angular 10+
814 lines (715 loc) • 24.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,
booleanAttribute,
ChangeDetectorRef,
Component,
computed,
Directive,
effect,
ElementRef,
forwardRef,
HostBinding,
HostListener,
inject,
Injectable,
input,
model,
OnChanges,
OnDestroy,
OnInit,
Optional,
output,
OutputEmitterRef,
signal,
SkipSelf,
WritableSignal,
} from '@angular/core';
import { WindowComponent } from '../window/window.component';
import { AlignedButtonGroup, WindowState } from '../window/window-state';
import { FormComponent } from '../form/form.component';
import { ngValueAccessor, ValueAccessorBase } from '../../core/form';
import { isMacOs } from '../../core/utils';
import { IconComponent } from '../icon/icon.component';
import { RouterLinkActive } from '@angular/router';
import { injectElementRef, registerEventListener, RegisterEventListenerRemove } from '../app/utils';
import { DOCUMENT } from '@angular/common';
export abstract class ActiveComponent {
abstract active: WritableSignal<boolean>;
abstract registeredHotkey?: WritableSignal<string>;
abstract showHotkey?: WritableSignal<boolean>;
abstract destroy: OutputEmitterRef<void>;
abstract activate(): void;
}
/**
* 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.toLowerCase().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 = input<HotKey>('');
protected isMac = isMacOs();
protected metaKey = false;
protected ctrlKey = false;
protected shiftKey = false;
protected altKey = false;
protected key = '';
ngOnInit() {
this.parse();
}
ngOnChanges() {
this.parse();
}
protected parse() {
//reset all
this.metaKey = false;
this.ctrlKey = false;
this.shiftKey = false;
this.altKey = false;
this.key = '';
const hotkeyValue = this.hotkey();
if (!hotkeyValue) return;
const hotkeys = hotkeyValue.toLowerCase().trim().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, ActiveComponent, OnDestroy {
hotKeySize = hotKeySize;
destroy = output();
/**
* The icon for this button. Either a icon name same as for dui-icon, or an image path.
*/
icon = input<string>();
/**
* Change in the icon size. Should not be necessary usually.
*/
iconSize = input<number>();
iconRight = input(false, { alias: 'icon-right', transform: booleanAttribute });
iconColor = input<string>();
showHotkey = model<boolean>(false);
registeredHotkey = signal('');
/**
* Whether the button is active (pressed)
*/
active = model<boolean>(false);
/**
* Whether the button has no padding and smaller font size
*/
small = input(false, { transform: booleanAttribute });
/**
* Whether the button has smaller padding. Better for button with icons.
*/
tight = input(false, { transform: booleanAttribute });
/**
* Whether the button is highlighted.
*/
highlighted = input(false, { transform: booleanAttribute });
/**
* Whether the button is primary.
*/
primary = input(false, { transform: booleanAttribute });
/**
* Whether the button is focused on initial loading.
*/
autoFocus = input(false, { alias: 'auto-focus', transform: booleanAttribute });
/**
* The form to submit when this button is clicked.
*/
submitForm = input<FormComponent>();
disabled = input(false, { transform: booleanAttribute });
square = input(false, { transform: booleanAttribute });
textured = input(false, { transform: booleanAttribute });
iconOnly = computed(() => {
if (!this.icon()) return false;
for (const child of this.element.nativeElement.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
return false;
}
}
return true;
});
protected element = injectElementRef();
protected formComponent = inject(FormComponent, { optional: true });
protected routerLinkActive = inject(RouterLinkActive);
isDisabled = computed(() => {
if (this.formComponent && this.formComponent.disabled()) return true;
const submitForm = this.submitForm();
if (submitForm && (submitForm.invalid || submitForm.disabled() || submitForm.submitting())) {
return true;
}
return this.disabled();
});
constructor() {
this.element.nativeElement.removeAttribute('tabindex');
}
ngOnDestroy() {
this.destroy.emit();
}
protected isActive() {
return this.routerLinkActive.isActive || this.active();
}
activate() {
this.element.nativeElement.click();
}
ngOnInit() {
if (this.autoFocus()) {
setTimeout(() => {
this.element.nativeElement.focus();
}, 10);
}
}
protected async onClick() {
if (this.isDisabled()) return;
const submitForm = this.submitForm();
if (submitForm) {
void submitForm.submitForm();
}
}
}
export class HotkeyRegistry implements OnDestroy {
active: { key: HotKey, component: ActiveComponent }[] = [];
protected document = inject(DOCUMENT);
protected removeKeyDown?: RegisterEventListenerRemove;
protected removeKeyUp?: RegisterEventListenerRemove;
protected knownComponents = new Set<ActiveComponent>();
showHotKeyOn = 'alt';
constructor() {
this.removeKeyDown = registerEventListener(this.document, 'keydown', (event) => {
// If only alt is pressed (not other keys, we display the hotkey)
if (this.showHotKeyOn && event.key.toLowerCase() === this.showHotKeyOn) {
for (const item of this.active) {
item.component.showHotkey?.set(true);
}
return;
}
for (const item of this.active) {
if (isHotKeyActive(item.key, event)) {
this.activate(item.component);
event.preventDefault();
return;
}
}
});
this.removeKeyUp = registerEventListener(this.document, 'keydown', (event) => {
// If only alt is pressed (not other keys, we display the hotkey)
if (this.showHotKeyOn && event.key.toLowerCase() === this.showHotKeyOn) {
for (const item of this.active) {
item.component.showHotkey?.set(false);
}
return;
}
});
}
activate(component: ActiveComponent) {
if (component.active()) return;
component.activate();
component.active.set(true);
setTimeout(() => {
component.active.set(false);
}, 200);
}
ngOnDestroy() {
this.removeKeyDown?.();
this.removeKeyUp?.();
}
unregister(component: ActiveComponent) {
const index = this.active.findIndex(v => v.component === component);
if (index !== -1) {
this.active.splice(index, 1);
}
this.knownComponents.delete(component);
}
register(key: HotKey, component: ActiveComponent) {
this.active = this.active.filter(v => v.component !== component);
this.active.unshift({ key, component });
if (!this.knownComponents.has(component)) {
this.knownComponents.add(component);
component.destroy.subscribe(() => {
this.unregister(component);
});
}
}
}
/**
* Adds a hotkey to a button.
*
* ```html
* <dui-button hotkey="escape">Cancel</dui-button>
* <dui-button hotkey="cmd+s">Save</dui-button>
* ```
*/
export class HotkeyDirective implements OnDestroy {
hotkey = input.required<HotKey>();
protected oldButtonActive?: boolean;
protected hotkeyRegistry = inject(HotkeyRegistry);
protected activeComponent = inject(ActiveComponent, { optional: true });
protected element = injectElementRef();
registeredHotkey = signal('');
active = signal(false);
destroy = output();
ngOnDestroy() {
this.destroy.emit();
}
constructor() {
const component = this.activeComponent || this;
effect(() => {
const hotkey = this.hotkey();
component.registeredHotkey?.set(hotkey);
if (hotkey) {
this.hotkeyRegistry.register(hotkey, component);
} else {
this.hotkeyRegistry.unregister(component);
}
});
}
activate() {
this.element.nativeElement.click();
}
}
/**
* Used to group buttons together.
*/
export class ButtonGroupComponent implements AfterViewInit, OnDestroy, AlignedButtonGroup {
/**
* 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 = input<'static' | 'sidebar' | 'float' | 'right'>('static');
/**
* If set to none, buttons inside this group will be tightly packed together without any padding.
*/
padding = input<'normal' | 'none'>('normal');
get isPaddingNone() {
return this.padding() === 'none';
}
constructor(
private element: ElementRef<HTMLElement>,
protected cd: ChangeDetectorRef,
private windowState?: WindowState,
private windowComponent?: WindowComponent,
) {
effect(() => this.updatePaddingLeft());
}
/**
* @hidden
*/
activateOneTimeAnimation() {
(this.element.nativeElement as HTMLElement).classList.add('with-animation');
}
ngOnDestroy(): void {
if (this.windowState && this.windowState.buttonGroupAlignedToSidebar() === this) {
this.windowState.buttonGroupAlignedToSidebar.set(undefined);
}
}
protected transitionEnded() {
(this.element.nativeElement as HTMLElement).classList.remove('with-animation');
}
ngAfterViewInit(): void {
if (this.float() === 'sidebar' && this.windowState) {
this.windowState.buttonGroupAlignedToSidebar.set(this);
}
}
protected updatePaddingLeft() {
if (this.float() === 'sidebar' && this.windowComponent) {
const content = this.windowComponent.content();
if (content && content.isSidebarVisible()) {
const newLeft = Math.max(0, content.sidebarWidth() - 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 = input<'left' | 'center' | 'right'>('left');
}
/**
* Directive to open the native file chooser dialog.
* Can be used wth FormsModule (ngModel).
*
* ```html
* <dui-button duiFileChooser duiFileChooserChange="open($event)">Open File</dui-button>
* ```
*/
export class FileChooserDirective extends ValueAccessorBase<File[]> implements OnDestroy, OnChanges {
duiFileMultiple = input(false, { transform: booleanAttribute });
duiFileDirectory = input(false, { transform: booleanAttribute });
duiFileChooserChange = output<File[]>();
protected input: HTMLInputElement;
constructor() {
super();
const inputElement = document.createElement('input');
inputElement.setAttribute('type', 'file');
this.input = inputElement;
this.input.addEventListener('change', (event: any) => {
const files = event.target.files as FileList;
if (files.length) {
if (this.duiFileMultiple()) {
const result: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file) continue;
result.push(file);
}
this.setValue(result);
} else {
this.setValue([files.item(0)!]);
}
this.duiFileChooserChange.emit(this.value() || []);
}
});
}
ngOnDestroy() {
}
ngOnChanges(): void {
(this.input as any).webkitdirectory = this.duiFileDirectory();
this.input.multiple = this.duiFileMultiple();
}
protected 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 to open the native file picker dialog and return the selected files as Uint8Array.
* Can be used wth FormsModule (ngModel).
*
* ```html
* <dui-button duiFilePicker duiFilePickerChange="open($event)">Open File</dui-button>
* ```
*/
export class FilePickerDirective extends ValueAccessorBase<FilePickerItem[]> implements OnDestroy, AfterViewInit {
duiFileMultiple = input(false, { transform: booleanAttribute });
duiFileAutoOpen = input<boolean>(false);
duiFilePickerChange = output<FilePickerItem[]>();
protected input: HTMLInputElement;
constructor() {
super();
const inputElement = document.createElement('input');
inputElement.setAttribute('type', 'file');
this.input = inputElement;
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.setValue(res);
} else {
const file = files.item(0);
if (file) {
const data = await readFile(file);
if (data) {
this.setValue([{ data, name: file.name }]);
}
}
}
this.duiFilePickerChange.emit(this.value() || []);
}
});
}
ngOnDestroy() {
}
ngAfterViewInit() {
if (this.duiFileAutoOpen()) this.onClick();
}
protected onClick() {
this.input.multiple = this.duiFileMultiple();
this.input.click();
}
}
/**
* Directive to allow dropping files into an area.
* Can be used wth FormsModule (ngModel).
*
* ```html
* <div duiFileDrop (duiFileDropChange)="onFilesDropped($event)">
* Drop files here
* </div>
* ```
*/
export class FileDropDirective extends ValueAccessorBase<FilePickerItem[]> implements OnDestroy {
duiFileDropMultiple = input(false, { transform: booleanAttribute });
duiFileDropChange = output<FilePickerItem[]>();
i = signal(0);
protected onDragEnter(ev: any) {
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();
this.i.update(v => v + 1);
}
protected onDragOver(ev: any) {
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();
}
protected onDragLeave(ev: any) {
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();
this.i.update(v => v - 1);
}
protected 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()) {
this.setValue(res);
} else {
if (res.length) {
this.setValue([res[0]]);
} else {
this.setValue([]);
}
}
this.duiFileDropChange.emit(this.value() || []);
this.i.set(0);
}
ngOnDestroy() {
}
}