diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 6a25cd5048b4..31eae474f87c 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -175,7 +175,7 @@ window.prepareCaptureTransition=async destination=>{ 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),fit=Math.min(inner.width/imageWidth,inner.height/imageHeight); + 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}); @@ -185,7 +185,7 @@ window.prepareCaptureTransition=async destination=>{ 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*fit)/inner.width)+","+((imageHeight*fit)/inner.height)+")"}],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"}) ]; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 137c4d26adb5..b62812f25775 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4923,9 +4923,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {image.previewUrl ? ( (null); + const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null); useEffect(() => { let active = true; - void createComposerImageThumbnail(file, mode).then((src) => { - if (active) setPreview({ file, mode, src }); + void createComposerImageThumbnail(file).then((src) => { + if (active) setPreview({ file, src }); }); return () => { active = false; }; - }, [file, mode]); - const src = preview?.file === file && preview.mode === mode ? preview.src : null; - return src ? ( - {alt} - ) : ( - fallback - ); + }, [file]); + const src = preview?.file === file ? preview.src : null; + return src ? {alt} : fallback; }); diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 3a199a06ab31..69316200b7aa 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -178,61 +178,6 @@ describe("composer image thumbnails", () => { expect(await original.text()).toBe("original bytes"); }); - it.each([ - [3840, 2160, 796, 448], - [3840, 1600, 832, 347], - ])( - "preserves the full %ix%i snapshot within an 832x448 preview without replacing its square thumbnail", - async (sourceWidth, sourceHeight, width, height) => { - const bitmap = { width: sourceWidth, height: sourceHeight, close: vi.fn() }; - const decode = vi.fn(async () => bitmap); - const drawImage = vi.fn(); - vi.stubGlobal("createImageBitmap", decode); - vi.stubGlobal( - "OffscreenCanvas", - class { - constructor( - public width: number, - public height: number, - ) {} - getContext() { - return { drawImage }; - } - async convertToBlob() { - return new Blob([`${this.width}x${this.height}`], { type: "image/png" }); - } - }, - ); - const original = new File(["original bytes"], "window.png", { type: "image/png" }); - const square = await createComposerImageThumbnail(original); - const [snapshot, cachedSnapshot] = await Promise.all([ - createComposerImageThumbnail(original, "snapshot"), - createComposerImageThumbnail(original, "snapshot"), - ]); - - expect(snapshot).toBe( - `data:image/png;base64,${Buffer.from(`${width}x${height}`).toString("base64")}`, - ); - expect(drawImage).toHaveBeenLastCalledWith( - bitmap, - 0, - 0, - sourceWidth, - sourceHeight, - 0, - 0, - width, - height, - ); - expect(cachedSnapshot).toBe(snapshot); - expect(await createComposerImageThumbnail(original, "snapshot")).toBe(snapshot); - expect(square).toBe(`data:image/png;base64,${Buffer.from("256x256").toString("base64")}`); - expect(await createComposerImageThumbnail(original)).toBe(square); - expect(decode).toHaveBeenCalledTimes(2); - expect(await original.text()).toBe("original bytes"); - }, - ); - it("releases the decoded image when thumbnail encoding fails", async () => { const close = vi.fn(); vi.stubGlobal( diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index c87f0d487595..5e4c40cfd890 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -204,49 +204,31 @@ async function encodeCanvas( return blob?.type === mimeType ? blob : null; } -const COMPOSER_THUMBNAIL_SIZES = { - square: { width: 256, height: 256 }, - // The 208x112 snapshot tile at retina density and 2x app zoom. - snapshot: { width: 832, height: 448 }, -} as const; -export type ComposerImageThumbnailMode = keyof typeof COMPOSER_THUMBNAIL_SIZES; -const composerThumbnails = new WeakMap< - File, - Partial>> ->(); - -export function createComposerImageThumbnail( - file: File, - mode: ComposerImageThumbnailMode = "square", -): Promise { - const fileThumbnails = composerThumbnails.get(file); - const cached = fileThumbnails?.[mode]; +const composerThumbnails = new WeakMap>(); + +export function createComposerImageThumbnail(file: File): Promise { + const cached = composerThumbnails.get(file); if (cached) return cached; const thumbnail = (async () => { if (!canRecompress()) return null; let bitmap: ImageBitmap | undefined; try { bitmap = await createImageBitmap(file); - if (bitmap.width <= 0 || bitmap.height <= 0) return null; const side = Math.min(bitmap.width, bitmap.height); - const sourceWidth = mode === "square" ? side : bitmap.width; - const sourceHeight = mode === "square" ? side : bitmap.height; - const bounds = COMPOSER_THUMBNAIL_SIZES[mode]; - const scale = Math.min(1, bounds.width / sourceWidth, bounds.height / sourceHeight); - const width = Math.max(1, Math.round(sourceWidth * scale)); - const height = Math.max(1, Math.round(sourceHeight * scale)); - const surface = createCanvas(width, height); + if (side <= 0) return null; + const dimension = Math.min(256, side); + const surface = createCanvas(dimension, dimension); if (!surface) return null; surface.context.drawImage( bitmap, - (bitmap.width - sourceWidth) / 2, - (bitmap.height - sourceHeight) / 2, - sourceWidth, - sourceHeight, + (bitmap.width - side) / 2, + (bitmap.height - side) / 2, + side, + side, 0, 0, - width, - height, + dimension, + dimension, ); const blob = await encodeCanvas(surface.canvas, 1, "image/png"); return blob ? await readFileAsDataUrl(blob) : null; @@ -256,7 +238,7 @@ export function createComposerImageThumbnail( bitmap?.close(); } })(); - composerThumbnails.set(file, { ...fileThumbnails, [mode]: thumbnail }); + composerThumbnails.set(file, thumbnail); return thumbnail; }