1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef, SnapShotSource } from "@t3tools/contracts";
3
4import type { DraftId } from "../composerDraftStore";
5import { getDesktopSnapShotBridge } from "./desktopSnapShot";
6
7type SnapShotTarget = DraftId | ScopedThreadRef;
8
9export type PendingSnapShotAnimation = {
10 readonly id: string;
11 readonly target: SnapShotTarget;
12 readonly source?: SnapShotSource | undefined;
13};
14
15let pendingAnimations: ReadonlyArray<PendingSnapShotAnimation> = [];
16const destinationRequests = new Map<string, Promise<void>>();
17const destinationMountCounts = new Map<string, number>();
18const listeners = new Set<() => void>();
19const DESTINATION_TIMEOUT_MS = 2_000;
20const ARRIVAL_MAX_AGE_MS = 10_000;
21
22export function shouldAnimateSnapShotArrival(capturedAt: string, now = Date.now()): boolean {
23 const capturedTime = Date.parse(capturedAt);
24 const age = now - capturedTime;
25 return Number.isFinite(capturedTime) && age >= 0 && age <= ARRIVAL_MAX_AGE_MS;
26}
27
28function targetKey(target: SnapShotTarget): string {
29 return typeof target === "string" ? target.trim() : scopedThreadKey(target);
30}
31
32function emitChange(): void {
33 for (const listener of listeners) listener();
34}
35
36export function beginSnapShotAnimation(id: string, target: SnapShotTarget): void {
37 if (pendingAnimations.some((capture) => capture.id === id)) return;
38 destinationRequests.delete(id);
39 pendingAnimations = [{ id, target }, ...pendingAnimations];
40 emitChange();
41}
42
43export function finishSnapShotAnimation(id: string): void {
44 const next = pendingAnimations.filter((capture) => capture.id !== id);
45 destinationRequests.delete(id);
46 destinationMountCounts.delete(id);
47 if (next.length === pendingAnimations.length) return;
48 pendingAnimations = next;
49 emitChange();
50}
51
52export function updateSnapShotAnimationSource(id: string, source: SnapShotSource): void {
53 const index = pendingAnimations.findIndex((capture) => capture.id === id);
54 if (index < 0 || pendingAnimations[index]?.source === source) return;
55 pendingAnimations = pendingAnimations.map((capture, captureIndex) =>
56 captureIndex === index ? { ...capture, source } : capture,
57 );
58 emitChange();
59}
60
61export async function dismissSnapShotAnimation(id: string): Promise<void> {
62 if (!pendingAnimations.some((capture) => capture.id === id)) return;
63 finishSnapShotAnimation(id);
64 const bridge = getDesktopSnapShotBridge();
65 if (typeof bridge?.dismissSnapShotAnimation !== "function") return;
66 await bridge.dismissSnapShotAnimation(id).catch(() => undefined);
67}
68
69export function dismissAllSnapShotAnimations(): void {
70 const ids = pendingAnimations.map((capture) => capture.id);
71 if (ids.length === 0) return;
72 pendingAnimations = [];
73 destinationRequests.clear();
74 destinationMountCounts.clear();
75 emitChange();
76 const bridge = getDesktopSnapShotBridge();
77 if (typeof bridge?.dismissSnapShotAnimation !== "function") return;
78 for (const id of ids) {
79 void bridge.dismissSnapShotAnimation(id).catch(() => undefined);
80 }
81}
82
83export function getPendingSnapShotAnimations(): ReadonlyArray<PendingSnapShotAnimation> {
84 return pendingAnimations;
85}
86
87export function subscribeToPendingSnapShotAnimations(listener: () => void): () => void {
88 listeners.add(listener);
89 return () => listeners.delete(listener);
90}
91
92export function pendingSnapShotAnimationIdsForTarget(
93 pending: ReadonlyArray<PendingSnapShotAnimation>,
94 target: SnapShotTarget,
95): ReadonlyArray<string> {
96 const key = targetKey(target);
97 return pending
98 .filter((capture) => targetKey(capture.target) === key)
99 .map((capture) => capture.id);
100}
101
102export function setSnapShotAnimationDestination(
103 id: string,
104 target: HTMLElement,
105 source?: SnapShotSource,
106): void {
107 if (!target.isConnected || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
108 return;
109 }
110 const bridge = getDesktopSnapShotBridge();
111 if (typeof bridge?.setSnapShotAnimationDestination !== "function") return;
112 const frame = target.getBoundingClientRect();
113 if (frame.width <= 0 || frame.height <= 0) return;
114 const style = window.getComputedStyle(target);
115 const cornerRadius = Number.parseFloat(style.borderTopLeftRadius);
116 const borderWidth = Number.parseFloat(style.borderTopWidth);
117 const request = bridge
118 .setSnapShotAnimationDestination({
119 id,
120 viewportFrame: {
121 x: frame.x,
122 y: frame.y,
123 width: frame.width,
124 height: frame.height,
125 },
126 backgroundColor: style.backgroundColor,
127 borderColor: style.borderTopColor,
128 borderWidth: Number.isFinite(borderWidth) ? borderWidth : 0,
129 cornerRadius: Number.isFinite(cornerRadius) ? cornerRadius : 0,
130 ...(source
131 ? {
132 details: {
133 appName: source.appName,
134 windowTitle: source.windowTitle,
135 ...(source.appIconDataUrl ? { appIconDataUrl: source.appIconDataUrl } : {}),
136 },
137 }
138 : {}),
139 })
140 .catch(() => undefined);
141 destinationRequests.set(id, request);
142}
143
144export async function waitForSnapShotAnimationDestination(id: string): Promise<void> {
145 const request = destinationRequests.get(id);
146 if (!request) return;
147 let timeout: ReturnType<typeof setTimeout> | undefined;
148 try {
149 await Promise.race([
150 request,
151 new Promise<void>((resolve) => {
152 timeout = setTimeout(resolve, DESTINATION_TIMEOUT_MS);
153 }),
154 ]);
155 } finally {
156 if (timeout) clearTimeout(timeout);
157 }
158}
159
160export function scheduleSnapShotAnimationDestination(id: string, start: () => void): () => void {
161 let active = true;
162 destinationMountCounts.set(id, (destinationMountCounts.get(id) ?? 0) + 1);
163 queueMicrotask(() => {
164 if (!active) return;
165 start();
166 });
167 return () => {
168 if (!active) return;
169 active = false;
170 const remainingMounts = Math.max(0, (destinationMountCounts.get(id) ?? 1) - 1);
171 if (remainingMounts === 0) destinationMountCounts.delete(id);
172 else destinationMountCounts.set(id, remainingMounts);
173 queueMicrotask(() => {
174 if ((destinationMountCounts.get(id) ?? 0) > 0) return;
175 if (!pendingAnimations.some((capture) => capture.id === id)) return;
176 void dismissSnapShotAnimation(id);
177 });
178 };
179}