@deepkit/desktop-ui
Version:
Library for desktop UI widgets in Angular 10+
446 lines (387 loc) • 14.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 { booleanAttribute, Component, contentChildren, Directive, effect, EmbeddedViewRef, forwardRef, inject, input, OnDestroy, output, Renderer2, signal, TemplateRef, viewChild, ViewContainerRef } from '@angular/core';
import { WindowMenuState } from '../window/window-menu';
import { Electron } from '../../core/utils';
import { injectElementRef, OnDomCreationDirective } from './utils';
import { DropdownComponent, DropdownContainerDirective, DropdownItemComponent, DropdownSplitterComponent, OpenDropdownDirective } from '../button/dropdown.component';
import { AdaptiveContainerComponent, AdaptiveHiddenContainer } from '../adaptive-container/adaptive-container.component';
import { ConnectedPosition } from '@angular/cdk/overlay';
import { OverlayStack } from './app';
import { NgTemplateOutlet } from '@angular/common';
import { ActiveComponent, ButtonHotkeyComponent, HotkeyRegistry } from '../button/button.component';
import { IconComponent } from '../icon/icon.component';
import type { BuiltTemplateItem } from './menu-electron';
export abstract class MenuBase implements ActiveComponent, OnDestroy {
destroy = output();
label = input<string>();
sublabel = input<string>();
icon = input<string>();
enabled = input<boolean>(true);
accelerator = input<string>();
role = input<string>();
hotkey = input<string>('');
visible = input<boolean>(true);
onlyMacOs = input(false, { transform: booleanAttribute });
noMacOs = input(false, { transform: booleanAttribute });
id = input<string>();
before = input<string>();
after = input<string>();
beforeGroupContaining = input<string>();
afterGroupContaining = input<string>();
template = viewChild('template', { read: TemplateRef<MenuBase> });
registeredHotkey = signal('');
click = output();
public type = '';
level = signal(0);
children = contentChildren(MenuBase, { descendants: false });
active = signal(false);
ngOnDestroy() {
this.destroy.emit();
}
buildTemplate(item: BuiltTemplateItem) {
}
activate() {
this.click.emit();
}
constructor() {
effect(() => {
for (const child of this.children()) {
child.level.set(this.level() + 1);
}
});
}
public validOs(): boolean {
if (Electron.isAvailable()) {
if (this.onlyMacOs() && Electron.getProcess().platform !== 'darwin') {
return false;
}
if (this.noMacOs() && Electron.getProcess().platform === 'darwin') {
return false;
}
}
return true;
}
}
export class MenuItemComponent extends MenuBase {
type = 'input';
}
export class MenuDivComponent extends MenuBase {
type = 'div';
}
export class MenuCheckboxDirective extends MenuBase {
checked = input<boolean>(false);
type = 'checkbox';
buildTemplate(item: BuiltTemplateItem) {
item.checked = this.checked();
}
}
export class MenuRadioComponent extends MenuBase {
checked = input<boolean>(false);
type = 'radio';
buildTemplate(item: BuiltTemplateItem) {
item.checked = this.checked();
}
}
export class MenuSeparatorComponent extends MenuBase {
type = 'separator';
}
export class MenuRenderDirective implements OnDestroy {
duiMenuRender = input.required<readonly MenuBase[]>();
template = input.required<TemplateRef<{ $implicit: MenuBase }>>();
rootAsButton = input(false, { transform: booleanAttribute });
protected elementRef = injectElementRef();
protected viewContainerRef = inject(ViewContainerRef);
protected createdViews: EmbeddedViewRef<any>[] = [];
protected renderer = inject(Renderer2);
constructor() {
effect(() => {
const items = this.duiMenuRender();
for (const comp of this.createdViews) {
comp.destroy();
}
this.createdViews = [];
for (const item of items) {
if (!item.visible()) continue;
const view = this.viewContainerRef.createEmbeddedView(this.template(), { $implicit: item });
this.createdViews.push(view);
}
});
}
ngOnDestroy() {
for (const comp of this.createdViews) {
comp.destroy();
}
this.createdViews = [];
}
}
export interface TemplateContext {
$implicit: MenuBase;
}
export class MenuItemTemplate {
static ngTemplateContextGuard(directive: unknown, context: unknown): context is TemplateContext {
return true;
}
}
export class MenuComponent implements OnDestroy {
/**
* If true, this menu is used for the application menu bar (only works in Electron).
*/
forApp = input(false, { transform: booleanAttribute });
moreLabel = input('More');
children = contentChildren(MenuBase, { descendants: false });
adaptiveContainer = viewChild.required(AdaptiveContainerComponent);
dropdownPositionsRoot: ConnectedPosition[] = [
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' },
];
dropdownPositions: ConnectedPosition[] = [
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top' },
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top' },
];
protected overlayStack = inject(OverlayStack);
protected windowMenuState = inject(WindowMenuState, { optional: true });
protected dropdowns = new Map<number, DropdownComponent>();
protected hotkeyRegistry = inject(HotkeyRegistry);
protected active = signal(false);
protected dropdownItemsMap = new WeakMap<Element, MenuBase>();
registerDropdownItem(item: Element, menu: MenuBase) {
this.dropdownItemsMap.set(item, menu);
}
constructor() {
this.windowMenuState?.addMenu(this);
effect(() => {
registerHotkey(this.hotkeyRegistry, this.children());
});
function increaseLevelBy(item: MenuBase, increase: number) {
item.level.set(item.level() + increase);
for (const child of item.children()) {
increaseLevelBy(child, increase);
}
}
effect(() => {
const hidden = this.adaptiveContainer().hiddenElements();
for (const item of this.children()) {
item.level.set(0);
}
for (const element of hidden) {
const item = this.dropdownItemsMap.get(element);
if (!item) continue;
item.level.set(1);
}
});
}
activate(item: MenuBase) {
item.activate();
this.close();
}
close() {
// Close all
for (const dropdown of this.dropdowns.values()) {
dropdown.close();
}
this.dropdowns.clear();
this.active.set(false);
}
lostFocus(event: FocusEvent) {
event.view?.addEventListener('click', e => (e.preventDefault(), e.stopImmediatePropagation()), { once: true, capture: true });
this.close();
}
protected hideSiblingsAndAbove(item: MenuBase) {
const level = item.level();
for (const [otherLevel, otherDropdown] of this.dropdowns.entries()) {
if (otherLevel >= level) {
otherDropdown.close();
this.dropdowns.delete(otherLevel);
}
}
this.active.set(this.dropdowns.size > 0);
}
dropdownHidden(dropdown: DropdownComponent, level: number) {
const otherDropdown = this.dropdowns.get(level);
if (otherDropdown === dropdown) {
this.dropdowns.delete(level);
}
this.active.set(this.dropdowns.size > 0);
}
dropdownShown(dropdown: DropdownComponent, level: number) {
// Close all other dropdowns with greater or same level
for (const [otherLevel, otherDropdown] of this.dropdowns.entries()) {
if (otherLevel >= level && otherDropdown !== dropdown) {
otherDropdown.close();
this.dropdowns.delete(otherLevel);
}
}
this.dropdowns.set(level, dropdown);
this.active.set(this.dropdowns.size > 0);
}
ngOnDestroy() {
this.windowMenuState?.removeMenu(this);
}
}
function registerHotkey(registry: HotkeyRegistry, items: readonly MenuBase[]): void {
for (const item of items) {
if (item.hotkey()) {
registry.register(item.hotkey(), item);
}
registerHotkey(registry, item.children());
}
}