RSS Git Download  Clone
Raw Blame History 18kB 423 lines
import { Component, Inject, OnInit, OnDestroy, NgZone, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, effect } 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 { debounce } from 'lodash-es';

@Component({
    selector: 'p3xr-database',
    standalone: true,
    imports: [
        CommonModule,
        RouterModule,
        DatabaseHeaderComponent,
        DatabaseTreecontrolControlsComponent,
        DatabaseTreeComponent,
    ],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
    templateUrl: './database.component.html',
    styleUrls: ['./database.component.scss'],
    styles: [`
        :host { display: block; }
    `],
    encapsulation: ViewEncapsulation.None,
})
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 static readonly PANEL_WIDTH_KEY = 'p3xr-database-panel-width';
    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;

    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;

        // React to global console drawer open/close — re-run layout so tree + content
        // adjust for the newly-reserved vertical space. The drawer height is read as
        // a CSS custom property in rawResize() so one code path handles both states.
        effect(() => {
            this.state.consoleDrawerOpen();
            setTimeout(() => this.rawResize(), 160); // after drawer transition
        });
    }

    ngOnInit(): void {
        this.state.currentPage.set('database');
        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;

        // Load saved panel width and convert to absolute position
        const savedWidth = localStorage.getItem(DatabaseComponent.PANEL_WIDTH_KEY);
        if (savedWidth && this.containerEl) {
            const width = parseInt(savedWidth, 10);
            if (!isNaN(width) && width >= this.resizeMinWidth) {
                const containerLeft = this.containerEl.getBoundingClientRect().left;
                this.resizeLeft = containerLeft + width;
            }
        }

        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();

        // Bottom console is now a global drawer — LayoutComponent owns its state.
        // We still react to the drawer height changing so tree + content re-layout.
        const stateSub = this.socket.stateChanged$.subscribe(() => this.watchResizeObserver());
        this.unsubs.push(() => { 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;

        // Global console drawer consumes additional height when open — read CSS custom property
        // set by LayoutComponent (see console-drawer.component.scss).
        const drawerCssValue = getComputedStyle(document.documentElement)
            .getPropertyValue('--p3xr-console-drawer-height-active').trim();
        let drawerHeight = 0;
        if (drawerCssValue.endsWith('vh')) {
            drawerHeight = Math.round((parseFloat(drawerCssValue) / 100) * windowHeight);
        } else if (drawerCssValue.endsWith('px')) {
            drawerHeight = parseFloat(drawerCssValue);
        }

        const availableHeight = Math.max(windowHeight - minus - outputPositionMinus - drawerHeight, 100);
        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, 0);

        // 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';
                    this.resizerEl.style.height = Math.max(contentAreaHeight, 0) + '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();
        }
    }

    // --- 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;
            this.applyDragCursor('ew-resize');
            document.body.classList.add('p3xr-not-selectable');
        } else if (event.type === 'mouseup') {
            this.clearDragCursor();
            this.resizeClicked = false;
            document.body.classList.remove('p3xr-not-selectable');
            // Persist panel width
            if (this.resizeLeft !== undefined && this.containerEl) {
                const containerLeft = this.containerEl.getBoundingClientRect().left;
                const width = this.resizeLeft - containerLeft;
                if (width >= this.resizeMinWidth) {
                    localStorage.setItem(DatabaseComponent.PANEL_WIDTH_KEY, String(width));
                }
            }
        }
        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) {
            this.applyDragCursor('not-allowed');
        } else {
            this.applyDragCursor('ew-resize');
            if (this.resizerEl) {
                this.resizerEl.style.left = event.clientX + 'px';
            }
            this.resizeLeft = event.clientX;
            this.rawResize();
        }
    }

    /** Force cursor during drag. Static CSS class rules (`body.class *`) can be out-specificity'd
     *  by inline `style="cursor:pointer"` on tree nodes. Injecting a `<style>` with
     *  `*, *::before, *::after { cursor !important }` beats inline styles because `!important`
     *  always wins over non-`!important` inline styles. The element is appended last to `<head>`
     *  so it's at the end of the cascade. */
    private dragStyleEl: HTMLStyleElement | null = null;

    private applyDragCursor(cursor: 'ew-resize' | 'not-allowed'): void {
        if (!this.dragStyleEl) {
            this.dragStyleEl = document.createElement('style');
            this.dragStyleEl.setAttribute('data-p3xr-database-drag', '');
            document.head.appendChild(this.dragStyleEl);
        }
        this.dragStyleEl.textContent = `*, *::before, *::after { cursor: ${cursor} !important; }`;
    }

    private clearDragCursor(): void {
        this.dragStyleEl?.remove();
        this.dragStyleEl = null;
    }

    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 now lives in the global drawer (LayoutComponent). No page-level
    // mousedown handler is needed — the drawer manages its own open/close state.
    private onDocumentMouseDown(_event: MouseEvent): void {
        // kept for back-compat with existing listener registration — no-op
    }

    // --- 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;
    }

}