179 lines · 6.1 KB · TypeScript
RawDownload
import { scopedThreadKey } from "@t3tools/client-runtime/environment";import type { ScopedThreadRef, SnapShotSource } from "@t3tools/contracts"; import type { DraftId } from "../composerDraftStore";import { getDesktopSnapShotBridge } from "./desktopSnapShot"; type SnapShotTarget = DraftId | ScopedThreadRef; export type PendingSnapShotAnimation = {  readonly id: string;  readonly target: SnapShotTarget;  readonly source?: SnapShotSource | undefined;}; let pendingAnimations: ReadonlyArray<PendingSnapShotAnimation> = [];const destinationRequests = new Map<string, Promise<void>>();const destinationMountCounts = new Map<string, number>();const listeners = new Set<() => void>();const DESTINATION_TIMEOUT_MS = 2_000;const ARRIVAL_MAX_AGE_MS = 10_000; export function shouldAnimateSnapShotArrival(capturedAt: string, now = Date.now()): boolean {  const capturedTime = Date.parse(capturedAt);  const age = now - capturedTime;  return Number.isFinite(capturedTime) && age >= 0 && age <= ARRIVAL_MAX_AGE_MS;} function targetKey(target: SnapShotTarget): string {  return typeof target === "string" ? target.trim() : scopedThreadKey(target);} function emitChange(): void {  for (const listener of listeners) listener();} export function beginSnapShotAnimation(id: string, target: SnapShotTarget): void {  if (pendingAnimations.some((capture) => capture.id === id)) return;  destinationRequests.delete(id);  pendingAnimations = [{ id, target }, ...pendingAnimations];  emitChange();} export function finishSnapShotAnimation(id: string): void {  const next = pendingAnimations.filter((capture) => capture.id !== id);  destinationRequests.delete(id);  destinationMountCounts.delete(id);  if (next.length === pendingAnimations.length) return;  pendingAnimations = next;  emitChange();} export function updateSnapShotAnimationSource(id: string, source: SnapShotSource): void {  const index = pendingAnimations.findIndex((capture) => capture.id === id);  if (index < 0 || pendingAnimations[index]?.source === source) return;  pendingAnimations = pendingAnimations.map((capture, captureIndex) =>    captureIndex === index ? { ...capture, source } : capture,  );  emitChange();} export async function dismissSnapShotAnimation(id: string): Promise<void> {  if (!pendingAnimations.some((capture) => capture.id === id)) return;  finishSnapShotAnimation(id);  const bridge = getDesktopSnapShotBridge();  if (typeof bridge?.dismissSnapShotAnimation !== "function") return;  await bridge.dismissSnapShotAnimation(id).catch(() => undefined);} export function dismissAllSnapShotAnimations(): void {  const ids = pendingAnimations.map((capture) => capture.id);  if (ids.length === 0) return;  pendingAnimations = [];  destinationRequests.clear();  destinationMountCounts.clear();  emitChange();  const bridge = getDesktopSnapShotBridge();  if (typeof bridge?.dismissSnapShotAnimation !== "function") return;  for (const id of ids) {    void bridge.dismissSnapShotAnimation(id).catch(() => undefined);  }} export function getPendingSnapShotAnimations(): ReadonlyArray<PendingSnapShotAnimation> {  return pendingAnimations;} export function subscribeToPendingSnapShotAnimations(listener: () => void): () => void {  listeners.add(listener);  return () => listeners.delete(listener);} export function pendingSnapShotAnimationIdsForTarget(  pending: ReadonlyArray<PendingSnapShotAnimation>,  target: SnapShotTarget,): ReadonlyArray<string> {  const key = targetKey(target);  return pending    .filter((capture) => targetKey(capture.target) === key)    .map((capture) => capture.id);} export function setSnapShotAnimationDestination(  id: string,  target: HTMLElement,  source?: SnapShotSource,): void {  if (!target.isConnected || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {    return;  }  const bridge = getDesktopSnapShotBridge();  if (typeof bridge?.setSnapShotAnimationDestination !== "function") return;  const frame = target.getBoundingClientRect();  if (frame.width <= 0 || frame.height <= 0) return;  const style = window.getComputedStyle(target);  const cornerRadius = Number.parseFloat(style.borderTopLeftRadius);  const borderWidth = Number.parseFloat(style.borderTopWidth);  const request = bridge    .setSnapShotAnimationDestination({      id,      viewportFrame: {        x: frame.x,        y: frame.y,        width: frame.width,        height: frame.height,      },      backgroundColor: style.backgroundColor,      borderColor: style.borderTopColor,      borderWidth: Number.isFinite(borderWidth) ? borderWidth : 0,      cornerRadius: Number.isFinite(cornerRadius) ? cornerRadius : 0,      ...(source        ? {            details: {              appName: source.appName,              windowTitle: source.windowTitle,              ...(source.appIconDataUrl ? { appIconDataUrl: source.appIconDataUrl } : {}),            },          }        : {}),    })    .catch(() => undefined);  destinationRequests.set(id, request);} export async function waitForSnapShotAnimationDestination(id: string): Promise<void> {  const request = destinationRequests.get(id);  if (!request) return;  let timeout: ReturnType<typeof setTimeout> | undefined;  try {    await Promise.race([      request,      new Promise<void>((resolve) => {        timeout = setTimeout(resolve, DESTINATION_TIMEOUT_MS);      }),    ]);  } finally {    if (timeout) clearTimeout(timeout);  }} export function scheduleSnapShotAnimationDestination(id: string, start: () => void): () => void {  let active = true;  destinationMountCounts.set(id, (destinationMountCounts.get(id) ?? 0) + 1);  queueMicrotask(() => {    if (!active) return;    start();  });  return () => {    if (!active) return;    active = false;    const remainingMounts = Math.max(0, (destinationMountCounts.get(id) ?? 1) - 1);    if (remainingMounts === 0) destinationMountCounts.delete(id);    else destinationMountCounts.set(id, remainingMounts);    queueMicrotask(() => {      if ((destinationMountCounts.get(id) ?? 0) > 0) return;      if (!pendingAnimations.some((capture) => capture.id === id)) return;      void dismissSnapShotAnimation(id);    });  };}