450 lines · 18.8 KB · TypeScript
RawDownload
import * as Effect from "effect/Effect";import type * as Fiber from "effect/Fiber"; import * as Electron from "electron"; const MIN_DURATION_MS = 280;const MAX_DURATION_MS = 680;const DURATION_DISTANCE_SCALE = 2_000;const EASING = "cubic-bezier(.2,.8,.2,1)";const TIMEOUT_MS = 6_000; type SnapShotAnimationDetails = {  readonly appName: string;  readonly windowTitle: string;  readonly appIconDataUrl?: string | undefined;}; export type SnapShotAnimationDestination = {  readonly frame: Electron.Rectangle;  /** Unit coordinates in T3's content area; GNOME supplies the real compositor origin. */  readonly relativeFrame?: Electron.Rectangle | undefined;  readonly backgroundColor: string;  readonly borderColor: string;  readonly borderWidth: number;  readonly cornerRadius: number;  readonly scaleFactor: number;  readonly details?: SnapShotAnimationDetails | undefined;}; export function snapShotAnimationDurationMs(  source: Electron.Rectangle,  target: Electron.Rectangle,): number {  const distance = Math.hypot(    source.x + source.width / 2 - target.x - target.width / 2,    source.y + source.height / 2 - target.y - target.height / 2,  );  return (    MAX_DURATION_MS -    (MAX_DURATION_MS - MIN_DURATION_MS) * Math.exp(-distance / DURATION_DISTANCE_SCALE)  );} type ActiveTransition = {  readonly id: string;  readonly source: Electron.Rectangle;  readonly snapshotDataUrl: string;  readonly overlays: Array<{    readonly window: Electron.BrowserWindow;    readonly requestedBounds: Electron.Rectangle;    bounds: Electron.Rectangle;  }>;  details?: SnapShotAnimationDetails | undefined;  timer?: Fiber.Fiber<void> | undefined;  flight?: Promise<void> | undefined;}; type SnapShotTransitionOptions = {  readonly showWindow?: ((window: Electron.BaseWindow) => void) | undefined;  readonly boundOverlayToCaptureDisplays?: boolean | undefined;  readonly waitForCompositorFrame?: boolean | undefined;  readonly alwaysOnTopLevel?:    | NonNullable<Parameters<Electron.BrowserWindow["setAlwaysOnTop"]>[1]>    | undefined;}; export function snapShotAnimationOverlayBounds(  displays: ReadonlyArray<Pick<Electron.Display, "bounds">>,): Electron.Rectangle {  const firstDisplay = displays[0];  const first = firstDisplay?.bounds ?? { x: 0, y: 0, width: 1, height: 1 };  let left = first.x;  let top = first.y;  let right = first.x + first.width;  let bottom = first.y + first.height;  for (const display of displays.slice(1)) {    const bounds = display.bounds;    left = Math.min(left, bounds.x);    top = Math.min(top, bounds.y);    right = Math.max(right, bounds.x + bounds.width);    bottom = Math.max(bottom, bounds.y + bounds.height);  }  return { x: left, y: top, width: right - left, height: bottom - top };} export function snapShotAnimationDisplayBounds(  displays: ReadonlyArray<Pick<Electron.Display, "bounds">>,  source: Electron.Rectangle,  destination: Electron.Rectangle,): Array<Electron.Rectangle> {  const flight = snapShotAnimationOverlayBounds([{ bounds: source }, { bounds: destination }]);  return displays    .map((display) => display.bounds)    .filter(      (bounds) =>        bounds.x < flight.x + flight.width &&        bounds.x + bounds.width > flight.x &&        bounds.y < flight.y + flight.height &&        bounds.y + bounds.height > flight.y,    );} function createWindow(  bounds: Electron.Rectangle,  alwaysOnTopLevel: SnapShotTransitionOptions["alwaysOnTopLevel"],): Electron.BrowserWindow {  const window = new Electron.BrowserWindow({    ...bounds,    alwaysOnTop: true,    backgroundColor: "#00000000",    focusable: false,    frame: false,    hasShadow: false,    resizable: false,    show: false,    skipTaskbar: true,    title: "T3 Code Snapshot Animation",    transparent: true,    webPreferences: {      backgroundThrottling: false,      contextIsolation: true,      nodeIntegration: false,      sandbox: true,    },  });  if (alwaysOnTopLevel) window.setAlwaysOnTop(true, alwaysOnTopLevel);  window.setIgnoreMouseEvents(true);  return window;} function transitionHtml(  sourceBounds: Electron.Rectangle,  overlayBounds: Electron.Rectangle,  flash: boolean,): string {  const source = {    x: sourceBounds.x - overlayBounds.x,    y: sourceBounds.y - overlayBounds.y,    width: sourceBounds.width,    height: sourceBounds.height,  };  return `<!doctype html><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent}#card{position:absolute;left:${source.x}px;top:${source.y}px;width:${source.width}px;height:${source.height}px;contain:strict;overflow:hidden;border-radius:12px;background:#fff;box-shadow:0 24px 70px rgba(0,0,0,.24);transform-origin:center;will-change:transform}#content{position:absolute;inset:0;overflow:hidden;border-radius:inherit;transform-origin:center;will-change:transform}#snapshot{position:absolute;inset:0;width:100%;height:100%;object-fit:fill;transform-origin:center;will-change:transform}#flash{position:absolute;inset:0;background:#fff;opacity:0;${flash ? "" : "display:none"}}#details{--scale:1;position:absolute;inset-inline:0;bottom:0;display:flex;align-items:center;gap:calc(6px * var(--scale));padding:calc(24px * var(--scale)) calc(10px * var(--scale)) calc(8px * var(--scale));background:linear-gradient(to top,rgba(0,0,0,.85),rgba(0,0,0,.55),transparent);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;opacity:0}#details:not([data-ready]){visibility:hidden}#icon,#fallback{width:calc(28px * var(--scale));height:calc(28px * var(--scale));flex:none;border-radius:calc(6px * var(--scale))}#icon{display:none;object-fit:cover}#fallback{display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.2);color:#fff;font-size:calc(10px * var(--scale));font-weight:500;text-transform:uppercase}#copy{min-width:0;flex:1}#app,#title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:calc(14px * var(--scale))}#app{color:#fff;font-size:calc(11px * var(--scale));font-weight:500}#title{color:rgba(255,255,255,.7);font-size:calc(9px * var(--scale))}#border{position:absolute;z-index:2;inset:0;box-sizing:border-box;border:0 solid transparent;border-radius:inherit;opacity:0}</style><div id="card"><div id="content"><img id="snapshot"><div id="details"><img id="icon"><div id="fallback"></div><div id="copy"><div id="app"></div><div id="title"></div></div></div></div><div id="border"></div><div id="flash"></div></div><script>const source=${JSON.stringify(source)},card=document.getElementById("card"),content=document.getElementById("content"),snapshot=document.getElementById("snapshot"),details=document.getElementById("details"),border=document.getElementById("border");const afterPaint=()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));window.setCaptureSnapshot=async src=>{snapshot.src=src;await snapshot.decode();await afterPaint()};window.rebaseCaptureSource=next=>{Object.assign(source,next);Object.assign(card.style,{left:source.x+"px",top:source.y+"px",width:source.width+"px",height:source.height+"px"})};window.startCaptureFlash=()=>document.getElementById("flash").animate([{opacity:.08},{offset:.38,opacity:.08},{offset:.68,opacity:.02},{opacity:0}],{duration:300,fill:"forwards",easing:"${EASING}"});const applyDetails=value=>{  if(!value)return;  details.dataset.ready="";  document.getElementById("app").textContent=value.appName;  document.getElementById("title").textContent=value.windowTitle||"Captured window";  document.getElementById("fallback").textContent=value.appName.slice(0,1);  if(value.appIconDataUrl){    const icon=document.getElementById("icon");icon.src=value.appIconDataUrl;icon.style.display="block";document.getElementById("fallback").style.display="none";  }};window.updateCaptureDetails=applyDetails;window.prepareCaptureTransition=async destination=>{  const target=destination.frame,borderWidth=Math.min(Math.max(0,destination.borderWidth),Math.max(0,(Math.min(target.width,target.height)-1)/2));  const inner={width:Math.max(1,target.width-borderWidth*2),height:Math.max(1,target.height-borderWidth*2)};  const sourceCenter={x:source.x+source.width/2,y:source.y+source.height/2},targetCenter={x:target.x+target.width/2,y:target.y+target.height/2};  const initial="translate3d("+(sourceCenter.x-targetCenter.x)+"px,"+(sourceCenter.y-targetCenter.y)+"px,0) scale("+(source.width/target.width)+","+(source.height/target.height)+")";  const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),cover=Math.max(inner.width/imageWidth,inner.height/imageHeight);  Object.assign(card.style,{left:target.x+"px",top:target.y+"px",width:target.width+"px",height:target.height+"px",borderRadius:destination.cornerRadius+"px",backgroundColor:destination.backgroundColor,boxShadow:"none"});  Object.assign(content.style,{inset:borderWidth+"px",borderRadius:Math.max(0,destination.cornerRadius-borderWidth)+"px"});  Object.assign(border.style,{borderWidth:borderWidth+"px",borderColor:destination.borderColor});  details.style.setProperty("--scale",String(destination.scaleFactor));  applyDetails(destination.details);  const options={duration:destination.durationMs,fill:"forwards",easing:"${EASING}"};  const animations=[    card.animate([{transform:initial},{transform:"translate3d(0,0,0) scale(1)"}],options),    content.animate([{transform:"scale("+(target.width/inner.width)+","+(target.height/inner.height)+")"},{transform:"scale(1)"}],options),    snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*cover)/inner.width)+","+((imageHeight*cover)/inner.height)+")"}],options),    border.animate([{opacity:0},{opacity:1}],options),    details.animate([{opacity:0},{opacity:1}],{delay:Math.max(0,destination.durationMs-125),duration:125,fill:"both",easing:"ease-in"})  ];  for(const animation of animations){animation.pause();animation.currentTime=0}  window.playCaptureTransition=async()=>{    for(const animation of animations)animation.play();    await Promise.all(animations.map(animation=>animation.finished.catch(()=>undefined)));    await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));  };  await afterPaint();};</script>`;} export class SnapShotTransition {  private active: ActiveTransition | undefined;  private readonly boundOverlayToCaptureDisplays: boolean;  private readonly waitForCompositorFrame: boolean;  private readonly alwaysOnTopLevel: SnapShotTransitionOptions["alwaysOnTopLevel"];  private readonly showWindow: (window: Electron.BaseWindow) => void;   constructor(options: SnapShotTransitionOptions = {}) {    this.boundOverlayToCaptureDisplays = options.boundOverlayToCaptureDisplays ?? false;    this.waitForCompositorFrame = options.waitForCompositorFrame ?? false;    this.alwaysOnTopLevel = options.alwaysOnTopLevel;    this.showWindow = options.showWindow ?? ((window) => window.showInactive());  }   async begin(    id: string,    source: Electron.Rectangle,    snapshotDataUrl: string,    flash: boolean,    destinationWindowBounds?: Electron.Rectangle,  ): Promise<void> {    this.dispose();    const sourceDisplay = Electron.screen.getDisplayMatching(source);    const requestedBounds = this.boundOverlayToCaptureDisplays      ? snapShotAnimationDisplayBounds(          [            sourceDisplay,            ...Electron.screen.getAllDisplays().filter((d) => d.id !== sourceDisplay.id),          ],          source,          destinationWindowBounds ?? source,        )      : [snapShotAnimationOverlayBounds(Electron.screen.getAllDisplays())];    const active: ActiveTransition = {      id,      source,      snapshotDataUrl,      overlays: [],    };    active.timer = Effect.runFork(      Effect.sleep(TIMEOUT_MS).pipe(        Effect.andThen(          Effect.sync(() => {            if (this.active === active) this.dispose();          }),        ),      ),    );    this.active = active;    try {      await this.showOverlays(active, requestedBounds, flash);    } catch (error) {      if (this.active === active) this.dispose();      throw error;    }  }   private async showOverlays(    active: ActiveTransition,    requestedBounds: ReadonlyArray<Electron.Rectangle>,    flash: boolean,  ): Promise<void> {    const overlays: ActiveTransition["overlays"] = [];    try {      for (const bounds of requestedBounds) {        const window = createWindow(bounds, this.alwaysOnTopLevel);        const overlay = { window, requestedBounds: bounds, bounds: window.getBounds() };        active.overlays.push(overlay);        overlays.push(overlay);      }      await Promise.all(        overlays.map(async (overlay) => {          await overlay.window.loadURL(            "data:text/html;charset=utf-8," +              encodeURIComponent(transitionHtml(active.source, overlay.bounds, flash)),          );          if (this.active === active && !overlay.window.isDestroyed()) {            await overlay.window.webContents.executeJavaScript(              `window.setCaptureSnapshot(${JSON.stringify(active.snapshotDataUrl)})`,            );          }        }),      );      for (const overlay of overlays) {        if (this.active !== active) return;        if (overlay.window.isDestroyed()) continue;        this.showWindow(overlay.window);        const bounds = overlay.window.getBounds();        if (          bounds.x !== overlay.bounds.x ||          bounds.y !== overlay.bounds.y ||          bounds.width !== overlay.bounds.width ||          bounds.height !== overlay.bounds.height        ) {          overlay.bounds = bounds;          await overlay.window.webContents.executeJavaScript(            `window.rebaseCaptureSource(${JSON.stringify({              x: active.source.x - bounds.x,              y: active.source.y - bounds.y,              width: active.source.width,              height: active.source.height,            })})`,          );        }      }      if (this.waitForCompositorFrame) {        // Cover the source with a composited snapshot before the caller reveals T3.        // Decoding and renderer animation frames alone can leave a transparent gap.        await Promise.all(          overlays.map(async (overlay) => {            if (this.active !== active || overlay.window.isDestroyed()) return;            await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 });          }),        );      }      if (this.active !== active) return;      if (flash) {        await Promise.all(          overlays.map(async (overlay) => {            if (overlay.window.isDestroyed()) return;            await overlay.window.webContents              .executeJavaScript("window.startCaptureFlash()")              .catch(() => undefined);          }),        );      }    } catch (error) {      for (const overlay of overlays) {        if (!overlay.window.isDestroyed()) overlay.window.destroy();      }      throw error;    }  }   animateTo(id: string, destination: SnapShotAnimationDestination): void {    const active = this.active;    if (!active || active.id !== id) return;    if (destination.details) active.details = destination.details;    if (active.flight) {      if (destination.details) {        for (const overlay of active.overlays) {          if (overlay.window.isDestroyed()) continue;          void overlay.window.webContents            .executeJavaScript(              "window.updateCaptureDetails(" + JSON.stringify(destination.details) + ")",            )            .catch(() => undefined);        }      }      return;    }    active.flight = this.runFlight(active, destination);  }   private async runFlight(    active: ActiveTransition,    destination: SnapShotAnimationDestination,  ): Promise<void> {    if (this.active !== active) return;    if (this.boundOverlayToCaptureDisplays) {      const missingBounds = snapShotAnimationDisplayBounds(        Electron.screen.getAllDisplays(),        active.source,        destination.frame,      ).filter(        (bounds) =>          !active.overlays.some(            (overlay) =>              !overlay.window.isDestroyed() &&              overlay.requestedBounds.x === bounds.x &&              overlay.requestedBounds.y === bounds.y &&              overlay.requestedBounds.width === bounds.width &&              overlay.requestedBounds.height === bounds.height,          ),      );      if (missingBounds.length > 0) {        await this.showOverlays(active, missingBounds, false).catch(() => undefined);      }    }    if (this.active !== active) return;    const durationMs = snapShotAnimationDurationMs(active.source, destination.frame);    const prepared = await Promise.allSettled(      active.overlays.map(async (overlay) => {        if (overlay.window.isDestroyed()) return;        await overlay.window.webContents.executeJavaScript(          `window.prepareCaptureTransition(${JSON.stringify({            ...destination,            frame: {              x: destination.frame.x - overlay.bounds.x,              y: destination.frame.y - overlay.bounds.y,              width: destination.frame.width,              height: destination.frame.height,            },            borderWidth: Math.max(0, destination.borderWidth),            cornerRadius: Math.max(0, destination.cornerRadius),            scaleFactor: Math.max(0.1, destination.scaleFactor),            details: active.details,            durationMs,          })})`,        );        if (this.active !== active || overlay.window.isDestroyed()) return;        if (this.waitForCompositorFrame) {          // Read back one pixel after preparation to flush queued compositor work          // before the animation clock starts on any of the display surfaces.          await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 });        }        return overlay;      }),    );    if (this.active !== active) return;    // One display failing must not cut the flight short on the others.    await Promise.allSettled(      prepared.map((result) => {        if (result.status !== "fulfilled" || !result.value || result.value.window.isDestroyed()) {          return;        }        return result.value.window.webContents.executeJavaScript("window.playCaptureTransition()");      }),    );  }   async waitForLanding(id: string): Promise<void> {    const active = this.active;    if (!active || active.id !== id) return;    await active.flight?.catch(() => undefined);  }   async complete(id: string): Promise<void> {    const active = this.active;    if (!active || active.id !== id) return;    await this.waitForLanding(id);    if (this.active === active) this.dispose();  }   dismiss(id: string): void {    if (this.active?.id === id) this.dispose();  }   dispose(): void {    const active = this.active;    this.active = undefined;    if (!active) return;    active.timer?.interruptUnsafe();    for (const overlay of active.overlays) {      if (!overlay.window.isDestroyed()) overlay.window.destroy();    }  }}