import { autoUpdate, computePosition, flip, offset, shift, platform, type Placement, type MiddlewareData, type ComputePositionConfig } from '@floating-ui/dom'; import { PortalUtils, PortalContentUtils, PortalEventUtils } from './portal-utils'; export class PopoverPortalManager { private portalContainer: HTMLElement | null = null; private measuredPopoverHeight: number | null = null; private component: any; private clonedPopover: HTMLElement | null = null; private cleanupAutoUpdate: (() => void) | null = null; private currentPlacement: Placement = 'top'; private currentMiddlewareData: MiddlewareData | null = null; private boundHandleWindowResize: (() => void) | null = null; constructor(component: any) { this.component = component; } private createPortalAppendContainer(): HTMLElement { const container = document.createElement('div'); container.style.position = 'absolute'; container.style.zIndex = '9999'; container.style.pointerEvents = 'none'; container.style.width = 'auto'; container.style.maxWidth = 'none'; container.style.minWidth = '200px'; container.className = 'nile-popover-portal-append'; return container; } positionPortalAppend(): void { if (!this.portalContainer || !this.component.isShow) return; this.measurePopoverHeight(); this.computeFloatingUIPosition(); } private measurePopoverHeight(): void { if (this.measuredPopoverHeight || !this.portalContainer) return; this.portalContainer.style.position = 'absolute'; this.portalContainer.style.visibility = 'hidden'; this.portalContainer.style.top = '0px'; this.portalContainer.style.left = '0px'; this.portalContainer.offsetHeight; this.measuredPopoverHeight = this.portalContainer.offsetHeight; this.portalContainer.style.visibility = ''; } private async computeFloatingUIPosition(): Promise { if (!this.portalContainer) return; const referenceElement = this.component.querySelector('[slot="anchor"]') || this.component; const floatingElement = this.portalContainer; try { const { x, y, placement, middlewareData } = await this.calculateFloatingUIPosition( referenceElement, floatingElement ); this.applyFloatingUIPosition(floatingElement, referenceElement, x, y, placement, middlewareData); } catch (error) { console.warn('Floating UI positioning failed, falling back to simple positioning:', error); this.fallbackPositioning(); } } private async calculateFloatingUIPosition( referenceElement: HTMLElement, floatingElement: HTMLElement ): Promise<{ x: number; y: number; placement: Placement; middlewareData: MiddlewareData }> { const boundary = PortalUtils.findBoundaryElements(referenceElement); const initialPlacement = this.getInitialPlacement(); const middleware = this.createFloatingUIMiddleware(boundary); return await computePosition(referenceElement, floatingElement, { placement: initialPlacement, strategy: 'fixed', middleware, platform: this.createCustomPlatform() }); } private getInitialPlacement(): Placement { return this.component.placement || 'top'; } private createFloatingUIMiddleware(boundary: Element[] | undefined): ComputePositionConfig['middleware'] { return [ offset(this.component.distance || 18), flip({ fallbackPlacements: this.getFallbackPlacements(), fallbackStrategy: 'bestFit', padding: 10, boundary: boundary }), shift({ padding: 10, crossAxis: true, boundary: boundary }) ]; } private getFallbackPlacements(): Placement[] { const basePlacement = this.component.placement || 'top'; const placements: Placement[] = []; if (basePlacement.startsWith('top')) { placements.push('bottom', 'right', 'left', 'top-start', 'top-end', 'bottom-start', 'bottom-end'); } else if (basePlacement.startsWith('bottom')) { placements.push('top', 'right', 'left', 'bottom-start', 'bottom-end', 'top-start', 'top-end'); } else if (basePlacement.startsWith('left')) { placements.push('right', 'top', 'bottom', 'left-start', 'left-end', 'right-start', 'right-end'); } else if (basePlacement.startsWith('right')) { placements.push('left', 'top', 'bottom', 'right-start', 'right-end', 'left-start', 'left-end'); } return placements; } private createCustomPlatform() { return platform; } private positionArrow(floatingElement: HTMLElement, referenceElement: HTMLElement, placement: Placement): void { if (!this.component.arrow) return; const arrowElement = floatingElement.querySelector('.popup__arrow') as HTMLElement; if (!arrowElement) return; const referenceRect = referenceElement.getBoundingClientRect(); const floatingRect = floatingElement.getBoundingClientRect(); const arrowSize = 18; let top = ''; let right = ''; let bottom = ''; let left = ''; const basePlacement = placement.split('-')[0]; switch (basePlacement) { case 'top': bottom = `calc(var(--arrow-size-diagonal) * -1)`; left = this.calculateArrowXPosition(referenceRect, floatingRect, placement, arrowSize); break; case 'bottom': top = `calc(var(--arrow-size-diagonal) * -1)`; left = this.calculateArrowXPosition(referenceRect, floatingRect, placement, arrowSize); break; case 'left': right = `calc(var(--arrow-size-diagonal) * -1)`; top = this.calculateArrowYPosition(referenceRect, floatingRect, placement, arrowSize); break; case 'right': left = `calc(var(--arrow-size-diagonal) * -1)`; top = this.calculateArrowYPosition(referenceRect, floatingRect, placement, arrowSize); break; } Object.assign(arrowElement.style, { top, right, bottom, left }); } private calculateArrowXPosition(referenceRect: DOMRect, floatingRect: DOMRect, placement: Placement, arrowSize: number): string { const referenceCenter = referenceRect.left + referenceRect.width / 2; const floatingLeft = floatingRect.left; const arrowOffset = referenceCenter - floatingLeft - arrowSize; if (placement.includes('start')) { return `${arrowSize}px`; } else if (placement.includes('end')) { return `calc(100% - ${arrowSize * 2}px)`; } else { return `${Math.max(arrowSize, Math.min(arrowOffset, floatingRect.width - arrowSize * 2))}px`; } } private calculateArrowYPosition(referenceRect: DOMRect, floatingRect: DOMRect, placement: Placement, arrowSize: number): string { const referenceCenter = referenceRect.top + referenceRect.height / 2; const floatingTop = floatingRect.top; const arrowOffset = referenceCenter - floatingTop - arrowSize; if (placement.includes('start')) { return `${arrowSize}px`; } else if (placement.includes('end')) { return `calc(100% - ${arrowSize * 2}px)`; } else { return `${Math.max(arrowSize, Math.min(arrowOffset, floatingRect.height - arrowSize * 2))}px`; } } private applyFloatingUIPosition( floatingElement: HTMLElement, referenceElement: HTMLElement, x: number, y: number, placement: Placement, middlewareData: MiddlewareData ): void { Object.assign(floatingElement.style, { left: `${x}px`, top: `${y}px`, position: 'fixed', pointerEvents: 'auto', width: 'auto', maxWidth: 'none', minWidth: '200px' }); this.currentPlacement = placement; this.currentMiddlewareData = middlewareData; PortalUtils.applyCollisionData(floatingElement, middlewareData, placement); floatingElement.className = `nile-popover-portal-append`; const referenceRect = (referenceElement as HTMLElement).getBoundingClientRect(); floatingElement.style.minWidth = `${Math.max(referenceRect.width, 200)}px`; this.positionArrow(floatingElement, referenceElement, placement); } private fallbackPositioning(): void { if (!this.portalContainer) return; const referenceElement = this.component.querySelector('[slot="anchor"]') || this.component; const rect = referenceElement.getBoundingClientRect(); const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; const spaceRight = viewportWidth - rect.right; const spaceLeft = rect.left; let topPosition: number; let leftPosition: number = rect.left; let placementClass: string; let maxHeight: number; if (spaceBelow > spaceAbove && spaceBelow > 200) { maxHeight = Math.max(spaceBelow - 20, 100); topPosition = rect.bottom + (this.component.distance || 18); placementClass = 'bottom'; } else if (spaceAbove > spaceBelow && spaceAbove > 200) { maxHeight = Math.max(spaceAbove - 20, 100); topPosition = Math.max(rect.top - maxHeight - (this.component.distance || 18), 10); placementClass = 'top'; } else if (spaceRight > spaceLeft && spaceRight > 200) { maxHeight = Math.max(Math.min(spaceRight, spaceBelow, spaceAbove) - 20, 100); topPosition = rect.top; leftPosition = rect.right + (this.component.distance || 18); placementClass = 'right'; } else { maxHeight = Math.max(Math.min(spaceLeft, spaceBelow, spaceAbove) - 20, 100); topPosition = rect.top; leftPosition = Math.max(rect.left - 200 - (this.component.distance || 18), 10); placementClass = 'left'; } if (placementClass === 'left' || placementClass === 'right') { this.portalContainer.style.left = `${leftPosition}px`; this.portalContainer.style.top = `${topPosition}px`; } else { this.portalContainer.style.left = `${rect.left}px`; this.portalContainer.style.top = `${topPosition}px`; } this.portalContainer.style.maxHeight = `${maxHeight}px`; this.portalContainer.style.pointerEvents = 'auto'; this.portalContainer.style.width = 'auto'; this.portalContainer.style.maxWidth = 'none'; this.portalContainer.style.minWidth = '200px'; this.portalContainer.className = `nile-popover-portal-append popover__box popover__box--${placementClass}`; this.calculateAndSetAutoSizeProperties(rect, topPosition, placementClass); this.positionArrow(this.portalContainer, referenceElement, placementClass as Placement); } private calculateAndSetAutoSizeProperties(rect: DOMRect, topPosition: number, placementClass: string): void { if (!this.portalContainer) return; const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; let availableHeight: number; let availableWidth: number = viewportWidth - rect.left - 10; if (placementClass === 'top') { availableHeight = rect.top - 10; } else if (placementClass === 'bottom') { availableHeight = viewportHeight - rect.bottom - 10; } else if (placementClass === 'left') { availableHeight = Math.min(viewportHeight - rect.top, rect.bottom); availableWidth = rect.left - 10; } else { availableHeight = Math.min(viewportHeight - rect.top, rect.bottom); availableWidth = viewportWidth - rect.right - 10; } this.portalContainer.style.setProperty('--auto-size-available-height', `${Math.max(availableHeight, 100)}px`); this.portalContainer.style.setProperty('--auto-size-available-width', `${Math.max(availableWidth, 200)}px`); } updatePortalAppendPosition(): void { if (this.component.portal && this.portalContainer) { this.positionPortalAppend(); } } handleWindowResize(): void { if (this.component.portal && this.portalContainer) { this.positionPortalAppend(); } } private setupAutoUpdatePositioning(): void { if (!this.portalContainer || !this.component) return; this.cleanupAutoUpdatePositioning(); this.cleanupAutoUpdate = autoUpdate( this.component, this.portalContainer, () => { this.computeFloatingUIPosition(); }, { ancestorScroll: true, ancestorResize: true, elementResize: true, layoutShift: true, animationFrame: true } ); } private cleanupAutoUpdatePositioning(): void { if (this.cleanupAutoUpdate) { this.cleanupAutoUpdate(); this.cleanupAutoUpdate = null; } } private injectStylesToDocument(): void { if (!this.portalContainer) return; const styleId = PortalUtils.generateStyleId(); if (document.getElementById(styleId)) return; const componentStyles = (this.component.constructor as any).styles; if (!componentStyles) return; const styleElement = document.createElement('style'); styleElement.id = styleId; styleElement.textContent = PortalUtils.extractStylesAsCSS(componentStyles); document.head.appendChild(styleElement); (this.portalContainer as any).__injectedStyleId = styleId; } private adoptStylesToPortalAppend(): void { if (!this.portalContainer) return; this.injectStylesToDocument(); } setupPortalAppend(): void { if (!this.component.portal) return; this.component.updateComplete.then(() => { setTimeout(() => { if (this.component.isShow) { this.clonedPopover = this.createPortalPopover(); this.portalContainer = this.createPortalAppendContainer(); this.portalContainer.appendChild(this.clonedPopover); document.body.appendChild(this.portalContainer); this.adoptStylesToPortalAppend(); this.clonedPopover.style.display = ''; this.positionPortalAppend(); this.setupPortalEventListeners(); this.setupAutoUpdatePositioning(); this.boundHandleWindowResize = this.handleWindowResize.bind(this); window.addEventListener('resize', this.boundHandleWindowResize); } }, 10); }); } private createPortalPopover(): HTMLElement { return PortalContentUtils.createPortalPopover(this.component); } private setupPortalEventListeners(): void { PortalEventUtils.setupPortalEventListeners(this.clonedPopover!, this.component); } cleanupPortalAppend(): void { this.cleanupAutoUpdatePositioning(); if (this.portalContainer && this.portalContainer.parentNode) { const injectedStyleId = (this.portalContainer as any).__injectedStyleId; if (injectedStyleId) { const styleElement = document.getElementById(injectedStyleId); if (styleElement) { styleElement.remove(); } } this.portalContainer.parentNode.removeChild(this.portalContainer); } if (this.boundHandleWindowResize) { window.removeEventListener('resize', this.boundHandleWindowResize); this.boundHandleWindowResize = null; } this.portalContainer = null; this.clonedPopover = null; this.measuredPopoverHeight = null; this.currentPlacement = 'top'; this.currentMiddlewareData = null; } get portalContainerElement(): HTMLElement | null { return this.portalContainer; } resetMeasuredHeight(): void { this.measuredPopoverHeight = null; } forceReposition(): void { if (this.portalContainer) { this.computeFloatingUIPosition(); } } getCurrentPlacement(): Placement { return this.currentPlacement; } getCurrentMiddlewareData(): MiddlewareData | null { return this.currentMiddlewareData; } isUsingFloatingUI(): boolean { return this.cleanupAutoUpdate !== null; } updatePositioningConfig(config: { offset?: number; padding?: number; boundary?: Element[] | 'viewport'; fallbackPlacements?: Placement[]; }): void { this.forceReposition(); } handleViewportChange(): void { if (this.portalContainer) { this.resetMeasuredHeight(); this.forceReposition(); } } isPositioningOptimal(): boolean { if (!this.portalContainer || !this.currentMiddlewareData) return true; const referenceElement = this.component.querySelector('[slot="anchor"]') || this.component; const rect = referenceElement.getBoundingClientRect(); const viewportHeight = window.innerHeight; const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; const isAbove = this.currentPlacement.startsWith('top'); const isBelow = this.currentPlacement.startsWith('bottom'); if (isAbove && spaceBelow > spaceAbove) return false; if (isBelow && spaceAbove > spaceBelow) return false; return true; } }