RSS Git Download  Clone
Raw Blame History 18kB 446 lines
import { Component, Inject, OnInit, OnDestroy, NgZone, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { BreakpointObserver } from '@angular/cdk/layout';

import { I18nService } from '../../services/i18n.service';
import { MainCommandService } from '../../services/main-command.service';
import { NavigationService } from '../../services/navigation.service';
import { SocketService } from '../../services/socket.service';
import { RedisStateService } from '../../services/redis-state.service';
import { SettingsService } from '../../services/settings.service';
import { DatabaseHeaderComponent } from './database-header.component';
import { DatabaseTreecontrolControlsComponent } from './database-treecontrol-controls.component';
import { DatabaseTreeComponent } from './database-tree.component';
import { ConsoleComponent } from '../console/console.component';

require('./database.component.scss');
const debounce = require('lodash/debounce');

@Component({
    selector: 'p3xr-database',
    standalone: true,
    imports: [
        CommonModule,
        RouterModule,
        DatabaseHeaderComponent,
        DatabaseTreecontrolControlsComponent,
        DatabaseTreeComponent,
        ConsoleComponent,
    ],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
    templateUrl: './database.component.html',
    styles: [`
        :host { display: block; }
    `],
})
export class DatabaseComponent implements OnInit, OnDestroy {

    readonly strings;

    isXs = false;
    hasConnection = false;
    hasConnections = false;
    resizerActive = false;

    resizeClicked = false;
    private resizerMouseoverOn = false;
    private resizeLeft: number | undefined = undefined;
    private bottomConsoleExpanded = false;
    private screenSizeIsSmall = false;

    private containerEl!: HTMLElement;
    private headerEl!: HTMLElement;
    private footerEl!: HTMLElement;
    private consoleHeaderEl!: HTMLElement;
    private resizerEl: HTMLElement | undefined;
    private resizeObserver!: ResizeObserver;
    private observedElement: HTMLElement | null = null;
    private resizeTimeoutId: any;

    private readonly unsubs: Array<() => void> = [];

    private readonly resizeMinWidth: number;
    private get bottomConsoleCollapsedHeight(): number {
        const panel = document.getElementById('p3xr-database-bottom-console-panel');
        if (panel) {
            const toolbar = panel.querySelector('#p3xr-console-header') as HTMLElement;
            const autocomplete = panel.querySelector('#p3xr-console-autocomplete') as HTMLElement;
            if (toolbar && autocomplete) {
                // +1 for the panel's border-top
                return toolbar.offsetHeight + autocomplete.offsetHeight + 1;
            }
        }
        return 88;
    }

    constructor(
        @Inject(NgZone) private readonly ngZone: NgZone,
        @Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver,
        @Inject(I18nService) private readonly i18n: I18nService,
        @Inject(MainCommandService) private readonly cmd: MainCommandService,
        @Inject(NavigationService) private readonly nav: NavigationService,
        @Inject(SocketService) private readonly socket: SocketService,
        @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef,
        @Inject(RedisStateService) private readonly state: RedisStateService,
        @Inject(SettingsService) private readonly settings: SettingsService,
    ) {
        this.strings = this.i18n.strings;
        this.resizeMinWidth = this.settings.resizeMinWidth;
    }

    ngOnInit(): void {
        this.syncFromGlobal();

        // Subscribe to socket events for reactive state updates
        const sub1 = this.socket.connections$.subscribe(() => this.syncFromGlobal());
        const sub2 = this.socket.redisDisconnected$.subscribe(() => {
            this.syncFromGlobal();
            this.nav.navigateTo('settings');
        });
        const sub3 = this.socket.configuration$.subscribe(() => this.syncFromGlobal());
        const sub4 = this.socket.stateChanged$.subscribe(() => {
            this.syncFromGlobal();
            setTimeout(() => this.rawResize(), 50);
        });
        this.unsubs.push(() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); });

        const xsSub = this.breakpointObserver.observe('(max-width: 599px)').subscribe(result => {
            const wasSmall = this.isXs;
            this.isXs = result.matches;
            if (!this.isXs && wasSmall) {
                clearTimeout(this.resizeTimeoutId);
                this.resizeTimeoutId = setTimeout(() => this.rawResize(), 4 * this.settings.debounce);
            }
            this.screenSizeIsSmall = this.isXs;
            this.cdr.markForCheck();
        });
        this.unsubs.push(() => xsSub.unsubscribe());

        // Init DOM references
        this.ngZone.runOutsideAngular(() => {
            setTimeout(() => this.initDom(), 0);
        });
    }

    ngOnDestroy(): void {
        this.unsubs.forEach(fn => fn());
        window.removeEventListener('resize', this.boundRawResize);
        document.removeEventListener('mousedown', this.boundOnDocumentMouseDown);
        this.destroyResizer();
        this.resizeObserver?.disconnect();
    }

    // --- Template methods ---

    goSettings(): void {
        this.nav.navigateTo('settings');
    }

    // --- Resize engine (ported from AngularJS) ---

    readonly resize = debounce(() => {
        this.resizeLeft = undefined;
        this.rawResize();
    }, 100);

    private readonly boundRawResize = () => this.rawResize();
    private readonly boundOnDocumentMouseDown = (e: MouseEvent) => this.onDocumentMouseDown(e);

    private initDom(): void {
        this.containerEl = document.getElementById('p3xr-database-content')!;
        this.headerEl = document.getElementById('p3xr-layout-header-container')!;
        this.footerEl = document.getElementById('p3xr-layout-footer-container')!;
        this.consoleHeaderEl = document.querySelector('p3xr-database-header') as HTMLElement;

        this.rawResize();
        window.addEventListener('resize', this.boundRawResize);
        document.addEventListener('mousedown', this.boundOnDocumentMouseDown);

        // Navigate to statistics if on bare /database
        if (this.nav.currentUrl === '/database' || this.nav.currentUrl === '/database/') {
            this.nav.navigateTo('database.statistics');
        }

        if (this.state.redisChanged()) {
            this.state.redisChanged.set(false);
            if (this.state.connection()) {
                this.cmd.refresh();
            }
        }

        this.state.page.set(1);

        setTimeout(() => this.rawResize(), 250);

        // ResizeObserver for tree controls
        this.resizeObserver = new ResizeObserver(entries => {
            if (!this.resizeClicked) {
                window.requestAnimationFrame(() => {
                    if (!Array.isArray(entries) || !entries.length) return;
                    this.rawResize();
                });
            }
        });
        this.watchResizeObserver();

        // Listen for events via Angular services
        const consoleSub1 = this.cmd.consoleActivate$.subscribe(() => {
            if (!this.isXs && !this.bottomConsoleExpanded) {
                this.bottomConsoleExpanded = true;
                this.rawResize();
                this.cmd.consoleEmbeddedResize$.next();
            }
        });
        const consoleSub2 = this.cmd.consoleDeactivate$.subscribe(() => {
            if (!this.isXs && this.bottomConsoleExpanded) {
                this.bottomConsoleExpanded = false;
                this.rawResize();
                this.cmd.consoleEmbeddedResize$.next();
            }
        });
        const stateSub = this.socket.stateChanged$.subscribe(() => this.watchResizeObserver());
        this.unsubs.push(() => { consoleSub1.unsubscribe(); consoleSub2.unsubscribe(); stateSub.unsubscribe(); });
    }

    private rawResize(): void {
        if (!this.containerEl || !this.headerEl || !this.footerEl || !this.consoleHeaderEl) return;

        let minus = 0;
        for (const el of [this.headerEl, this.footerEl, this.consoleHeaderEl]) {
            minus += el.offsetHeight;
        }
        const windowHeight = window.innerHeight;
        const outputPositionMinus = 11;

        const bottomConsolePanel = document.getElementById('p3xr-database-bottom-console-panel');
        const isDesktop = !this.isXs;
        let bottomConsoleHeight = 0;
        const hasDesktopConsole = isDesktop && this.state.connection() !== undefined;
        const availableHeight = Math.max(windowHeight - minus - outputPositionMinus, 100);
        if (hasDesktopConsole) {
            bottomConsoleHeight = this.getBottomConsoleHeight(availableHeight);
        }
        const containerHeight = Math.max(availableHeight, 0);
        this.containerEl.style.height = containerHeight + 'px';
        this.containerEl.style.maxHeight = containerHeight + 'px';

        const containerPosition = this.containerEl.getBoundingClientRect();
        if (!containerPosition || !Number.isFinite(containerPosition.height) || !Number.isFinite(containerPosition.width)) {
            return;
        }
        const contentAreaHeight = Math.max(containerPosition.height - bottomConsoleHeight, 0);

        // Bottom console panel
        if (bottomConsolePanel) {
            if (hasDesktopConsole && bottomConsoleHeight > 0) {
                const s = bottomConsolePanel.style;
                s.display = 'block';
                s.position = 'absolute';
                s.top = 'auto';
                s.left = '-1px';
                s.height = bottomConsoleHeight + 'px';
                s.width = 'auto';
                s.right = '-1px';
                s.bottom = '0';
            } else {
                bottomConsolePanel.style.display = 'none';
            }
        }

        // Tree control
        const treeControl = document.getElementById('p3xr-database-treecontrol-container');
        if (treeControl) {
            const treeControlControls = document.getElementById('p3xr-database-treecontrol-controls-container');
            if (!treeControlControls) {
                this.destroyResizer();
                return;
            }
            const treeControlControlsPosition = treeControlControls.getBoundingClientRect();

            treeControl.style.top = (containerPosition.top + treeControlControlsPosition.height) + 'px';
            treeControl.style.left = containerPosition.left + 'px';
            treeControl.style.height = (contentAreaHeight - treeControlControlsPosition.height) + 'px';
            treeControl.style.maxHeight = contentAreaHeight + 'px';

            if (this.resizeLeft !== undefined) {
                treeControl.style.width = (this.resizeLeft - containerPosition.left) + 'px';
            } else {
                treeControl.style.width = this.resizeMinWidth + 'px';
            }
            treeControl.style.minWidth = this.resizeMinWidth + 'px';

            const treeControlPosition = treeControl.getBoundingClientRect();

            if (!this.resizerEl) {
                this.decorateResizer();
            }
            const resizerWidth = 5;
            if (this.resizerEl) {
                this.resizerEl = document.getElementById('p3xr-database-content-sizer')!;
                if (this.resizerEl) {
                    this.resizerEl.addEventListener('mouseover', this.boundResizerMouseover);
                    this.resizerEl.addEventListener('mouseout', this.boundResizerMouseout);
                    this.resizerEl.style.top = containerPosition.top + 'px';
                    const resizerHeight = Math.max(contentAreaHeight - (bottomConsoleHeight > 0 ? 1 : 0), 0);
                    this.resizerEl.style.height = resizerHeight + 'px';
                    this.resizerEl.style.left = (containerPosition.left + treeControlPosition.width) + 'px';
                    this.resizerEl.style.width = resizerWidth + 'px';

                    treeControlControls.style.width = (containerPosition.left + treeControlPosition.width) + 'px';
                }
            }

            const content = document.getElementById('p3xr-database-content-container');
            if (content) {
                content.style.top = containerPosition.top + 'px';
                content.style.height = contentAreaHeight + 'px';
                content.style.left = (containerPosition.left + treeControlPosition.width + resizerWidth) + 'px';
                content.style.width = (containerPosition.width - treeControlPosition.width - resizerWidth) + 'px';
            }

            treeControlControls.style.width = treeControlPosition.width + 'px';
        } else {
            this.destroyResizer();
        }

        if (hasDesktopConsole && bottomConsoleHeight > 0) {
            this.cmd.consoleEmbeddedResize$.next();
        }
    }

    private getBottomConsoleHeight(containerHeight: number): number {
        if (this.bottomConsoleExpanded) {
            let expandedHeight = Math.max(Math.floor(containerHeight * 0.33), 220);
            expandedHeight = Math.min(expandedHeight, Math.max(containerHeight - 120, this.bottomConsoleCollapsedHeight));
            return expandedHeight;
        }
        return this.bottomConsoleCollapsedHeight;
    }

    // --- Resizer drag ---

    private readonly boundResizerMouseover = () => {
        this.resizerMouseoverOn = true;
        this.updateResizerColor();
    };
    private readonly boundResizerMouseout = () => {
        this.resizerMouseoverOn = false;
        this.updateResizerColor();
    };
    private readonly boundResizeClick = (event: MouseEvent) => this.resizeClick(event);
    private readonly boundDocumentMousemove = (event: MouseEvent) => this.documentMousemove(event);

    private updateResizerColor(): void {
        this.resizerActive = this.resizeClicked || this.resizerMouseoverOn;
    }

    private resizeClick(event: MouseEvent): void {
        if (event.type === 'mousedown' && (event.target as HTMLElement).id !== 'p3xr-database-content-sizer') return;
        if (event.type === 'mousedown') {
            this.resizeClicked = true;
            document.documentElement.style.cursor = 'ew-resize';
            document.body.classList.add('p3xr-not-selectable');
        } else if (event.type === 'mouseup') {
            document.documentElement.style.cursor = 'auto';
            this.resizeClicked = false;
            document.body.classList.remove('p3xr-not-selectable');
        }
        if (!this.resizeClicked) {
            this.rawResize();
        }
        event.stopPropagation();
        this.updateResizerColor();
    }

    private documentMousemove(event: MouseEvent): void {
        if (!this.resizeClicked || !this.containerEl) return;
        const containerPosition = this.containerEl.getBoundingClientRect();
        if (event.clientX < containerPosition.left + this.resizeMinWidth || event.clientX > window.innerWidth - this.resizeMinWidth) {
            document.documentElement.style.cursor = 'not-allowed';
        } else {
            document.documentElement.style.cursor = 'ew-resize';
            if (this.resizerEl) {
                this.resizerEl.style.left = event.clientX + 'px';
            }
            this.resizeLeft = event.clientX;
            this.rawResize();
        }
    }

    private decorateResizer(): void {
        this.resizerEl = document.getElementById('p3xr-database-content-sizer') ?? undefined;
        if (!this.resizerEl) return;
        this.resizerEl.addEventListener('mouseover', this.boundResizerMouseover);
        this.resizerEl.addEventListener('mouseout', this.boundResizerMouseout);
        document.addEventListener('mousemove', this.boundDocumentMousemove);
        document.addEventListener('mousedown', this.boundResizeClick);
        document.addEventListener('mouseup', this.boundResizeClick);
    }

    private destroyResizer(): void {
        if (this.resizerEl) {
            this.resizerEl.removeEventListener('mouseover', this.boundResizerMouseover);
            this.resizerEl.removeEventListener('mouseout', this.boundResizerMouseout);
            this.resizerEl = undefined;
        }
        document.removeEventListener('mousedown', this.boundResizeClick);
        document.removeEventListener('mouseup', this.boundResizeClick);
        document.removeEventListener('mousemove', this.boundDocumentMousemove);
    }

    // --- Bottom console expand/collapse ---

    private onDocumentMouseDown(event: MouseEvent): void {
        const bottomConsolePanel = document.getElementById('p3xr-database-bottom-console-panel');
        if (this.isXs || !bottomConsolePanel) return;
        if (bottomConsolePanel.contains(event.target as Node)) {
            // Toolbar action buttons/checkboxes: keep current state
            const actions = bottomConsolePanel.querySelector('.p3xr-console-toolbar-actions');
            if (actions && actions.contains(event.target as Node)) return;
            // Console content, input, toolbar title: expand
            if (!this.bottomConsoleExpanded) {
                this.bottomConsoleExpanded = true;
                this.rawResize();
                this.cmd.consoleEmbeddedResize$.next();
            }
            return;
        }
        if (this.bottomConsoleExpanded) {
            this.bottomConsoleExpanded = false;
            this.rawResize();
            this.cmd.consoleEmbeddedResize$.next();
        }
    }

    // --- ResizeObserver for tree controls ---

    private async watchResizeObserver(): Promise<void> {
        if (this.observedElement) {
            this.resizeObserver.unobserve(this.observedElement);
        }
        if (!this.state.connection()) return;
        if (this.isXs) {
            this.rawResize();
            return;
        }
        let elem: HTMLElement | null = null;
        while (elem === null) {
            elem = document.getElementById('p3xr-database-treecontrol-controls-container');
            if (!elem) {
                await new Promise(resolve => setTimeout(resolve));
            }
        }
        this.observedElement = elem;
        this.resizeObserver.observe(this.observedElement);
    }

    // --- State sync ---

    private syncFromGlobal(): void {
        this.hasConnection = this.state.connection() !== undefined;
        this.hasConnections = (this.state.connections()?.list?.length ?? 0) > 0;
    }

}