Files
planet/frontend/public/earth/js/presentation-controller.js
linkong f14ff6ec0f
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.56.0
2026-05-13 18:21:03 +08:00

194 lines
6.0 KiB
JavaScript

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