import { createConnectorPath } from "./callout-connector.js"; const DEFAULT_CONNECTOR_READY_TIMEOUT_MS = 1200; const DEFAULT_CONNECTOR_DRAW_MS = 420; function nextAnimationFrame() { return new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); }); } function isCurrentContext(context) { return !context || typeof context.isCurrent !== "function" || context.isCurrent(); } function waitForContext(context, durationMs) { if (context && typeof context.wait === "function") { return context.wait(durationMs); } return new Promise((resolve) => { window.setTimeout(() => resolve(isCurrentContext(context)), durationMs); }); } function getDefaultCardTarget() { // TODO: replace the singleton card fallback with a presentation/card token check // before BGP/News migrate here, so connectors only attach to their owning card. const mobilePopup = document.getElementById("earth-mobile-popup"); if (mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")) { return mobilePopup; } const infoPanel = document.getElementById("info-panel"); if (infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")) { return infoPanel; } return null; } export class PresentationController { constructor({ connector, pathFactory = createConnectorPath, requestAnimationFrame = nextAnimationFrame, } = {}) { this.connector = connector; this.pathFactory = pathFactory; this.requestAnimationFrame = requestAnimationFrame; this.active = null; this.timeoutId = null; } isActive(owner = null) { if (!this.active) return false; return owner ? this.active.request.owner === owner : true; } clearTimeout() { if (!this.timeoutId) return; window.clearTimeout(this.timeoutId); this.timeoutId = null; } dismiss(reason = "dismiss") { if (!this.active) return false; const active = this.active; this.active = null; this.clearTimeout(); active.request.connector?.instance?.hide?.(); this.connector?.hide?.(); active.request.card?.hide?.(); active.request.onDismiss?.(reason); return true; } scheduleLifetime(request) { this.clearTimeout(); if (request.lifetime?.mode !== "timeout") return; const timeoutMs = Number(request.lifetime.timeoutMs); if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return; this.timeoutId = window.setTimeout(() => { this.dismiss("timeout"); }, timeoutMs); } getConnectorInstance(request) { return request.connector?.instance || this.connector || null; } getConnectorPath(request) { const connectorRequest = request.connector; if (!connectorRequest?.enabled) return null; const source = connectorRequest.sourceProvider?.(); if (!source) return null; const target = connectorRequest.targetProvider?.() || getDefaultCardTarget(); if (!target) return null; const sourceIsRect = Number.isFinite(source.x) && Number.isFinite(source.y) && Number.isFinite(source.width) && Number.isFinite(source.height); const sourceAnchor = sourceIsRect ? { x: source.x + source.width * 0.5, y: source.y + source.height * 0.5 } : source; const options = { ...(connectorRequest.options || {}), ...(sourceIsRect && !connectorRequest.options?.sourceRect ? { sourceRect: source } : {}), }; return this.pathFactory(sourceAnchor, target, options); } update({ animate = false } = {}) { if (!this.active) return false; const { request } = this.active; const connector = this.getConnectorInstance(request); if (!connector || request.connector?.enabled !== true) return false; if (!animate && connector.isAnimating?.()) return true; const path = this.getConnectorPath(request); if (!path) { connector.hide?.(); return false; } return connector.render(path, { animate }) === true; } async waitForConnector(request, context) { const timeoutMs = Number(request.connector?.readyTimeoutMs) || DEFAULT_CONNECTOR_READY_TIMEOUT_MS; const startedAt = performance.now(); let ready = false; while (isCurrentContext(context) && this.active?.request === request) { ready = this.update({ animate: !ready }); if (ready) break; if (performance.now() - startedAt >= timeoutMs) break; await this.requestAnimationFrame(); } return ready; } async present(request, { context } = {}) { if (!request?.id || !request.card || !isCurrentContext(context)) return false; this.dismiss("replace"); this.active = { id: request.id, request }; request.card.render?.({ reveal: false }); await this.requestAnimationFrame(); if (!isCurrentContext(context) || this.active?.request !== request) { this.dismiss("interrupted"); return false; } const animateOnReveal = request.connector?.animateOnReveal === true; const connectorReady = request.connector?.enabled === true && !animateOnReveal ? await this.waitForConnector(request, context) : request.connector?.enabled === true; if (!isCurrentContext(context) || this.active?.request !== request) { this.dismiss("interrupted"); return false; } if (connectorReady && !animateOnReveal) { const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS; const drawCompleted = await waitForContext(context, drawMs); if (!drawCompleted || this.active?.request !== request) { this.dismiss("interrupted"); return false; } } request.card.render?.({ reveal: true }); await this.requestAnimationFrame(); if (!isCurrentContext(context) || this.active?.request !== request) { this.dismiss("interrupted"); return false; } const finalConnectorAnimated = this.update({ animate: animateOnReveal || !connectorReady, }); if (animateOnReveal && finalConnectorAnimated) { const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS; const drawCompleted = await waitForContext(context, drawMs); if (!drawCompleted || this.active?.request !== request) { this.dismiss("interrupted"); return false; } } this.scheduleLifetime(request); return true; } }