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