@@ -15,26 +15,33 @@ import { invariant } from "@argos/util/invariant";
1515import { clsx } from "clsx" ;
1616import { useAtomValue } from "jotai/react" ;
1717import {
18+ BlendIcon ,
1819 ChevronDownIcon ,
1920 ChevronUpIcon ,
2021 DownloadIcon ,
22+ FileDownIcon ,
23+ Layers2Icon ,
2124 type LucideIcon ,
2225} from "lucide-react" ;
2326import { useObjectRef } from "react-aria" ;
27+ import { toast } from "sonner" ;
2428
2529import { DocumentType , graphql } from "@/gql" ;
2630import { BuildType , ScreenshotDiffStatus } from "@/gql/graphql" ;
2731import { BuildDialogs } from "@/pages/Build/BuildDialogs" ;
2832import { Code } from "@/ui/Code" ;
2933import { IconButton } from "@/ui/IconButton" ;
3034import { ImageKitPicture , imgkit } from "@/ui/ImageKitPicture" ;
35+ import { Menu , MenuItem , MenuItemIcon , MenuTrigger } from "@/ui/Menu" ;
36+ import { Popover } from "@/ui/Popover" ;
3137import { Time } from "@/ui/Time" ;
3238import { Tooltip } from "@/ui/Tooltip" ;
3339import { useEventCallback } from "@/ui/useEventCallback" ;
3440import { useResizeObserver } from "@/ui/useResizeObserver" ;
3541import { useColoredRects } from "@/util/color-detection/hook" ;
3642import { Rect } from "@/util/color-detection/types" ;
3743import { checkIsImageContentType } from "@/util/content-type" ;
44+ import { getErrorMessage } from "@/util/error" ;
3845import { fetchImage } from "@/util/image" ;
3946import { useTextContent } from "@/util/text" ;
4047
@@ -53,6 +60,7 @@ import { buildViewModeAtom } from "./BuildViewMode";
5360import { DiffEditor , Editor , getLanguageFromContentType } from "./DiffEditor" ;
5461import {
5562 overlayColorAtom ,
63+ overlayOpacityAtom ,
5664 overlayVisibleAtom ,
5765 useOverlayStyle ,
5866} from "./OverlayStyle" ;
@@ -245,15 +253,9 @@ const DownloadScreenshotButton = memo(
245253 isDisabled = { loading }
246254 onPress = { ( ) => {
247255 setLoading ( true ) ;
248- fetchImage ( props . url )
249- . then ( ( res ) => res . blob ( ) )
256+ fetchBlob ( props . url )
250257 . then ( ( blob ) => {
251- const a = document . createElement ( "a" ) ;
252- a . href = URL . createObjectURL ( blob ) ;
253- a . download = props . name ;
254- document . body . appendChild ( a ) ;
255- a . click ( ) ;
256- document . body . removeChild ( a ) ;
258+ downloadBlob ( blob , props . name ) ;
257259 } )
258260 . finally ( ( ) => {
259261 setLoading ( false ) ;
@@ -267,6 +269,90 @@ const DownloadScreenshotButton = memo(
267269 } ,
268270) ;
269271
272+ function downloadBlob ( blob : Blob , name : string ) {
273+ const objectUrl = URL . createObjectURL ( blob ) ;
274+ const a = document . createElement ( "a" ) ;
275+ a . href = objectUrl ;
276+ a . download = name ;
277+ document . body . appendChild ( a ) ;
278+ a . click ( ) ;
279+ document . body . removeChild ( a ) ;
280+ URL . revokeObjectURL ( objectUrl ) ;
281+ }
282+
283+ async function fetchBlob ( url : string ) {
284+ const response = await fetchImage ( url ) ;
285+ return response . blob ( ) ;
286+ }
287+
288+ async function loadImageElement ( url : string ) {
289+ const blob = await fetchBlob ( url ) ;
290+ const objectUrl = URL . createObjectURL ( blob ) ;
291+ return await new Promise < HTMLImageElement > ( ( resolve , reject ) => {
292+ const image = new Image ( ) ;
293+ image . onload = ( ) => {
294+ URL . revokeObjectURL ( objectUrl ) ;
295+ resolve ( image ) ;
296+ } ;
297+ image . onerror = ( ) => {
298+ URL . revokeObjectURL ( objectUrl ) ;
299+ reject ( new Error ( `Failed to load image: ${ url } ` ) ) ;
300+ } ;
301+ image . src = objectUrl ;
302+ } ) ;
303+ }
304+
305+ async function canvasToBlob ( canvas : HTMLCanvasElement ) {
306+ return await new Promise < Blob > ( ( resolve , reject ) => {
307+ canvas . toBlob ( ( blob ) => {
308+ if ( blob ) {
309+ resolve ( blob ) ;
310+ return ;
311+ }
312+ reject ( new Error ( "Failed to create image blob from canvas" ) ) ;
313+ } , "image/png" ) ;
314+ } ) ;
315+ }
316+
317+ async function createMaskedCompareBlob ( props : {
318+ compareUrl : string ;
319+ maskUrl : string ;
320+ color : string ;
321+ opacity : number ;
322+ } ) {
323+ const [ compareImage , maskImage ] = await Promise . all ( [
324+ loadImageElement ( props . compareUrl ) ,
325+ loadImageElement ( props . maskUrl ) ,
326+ ] ) ;
327+
328+ const width = compareImage . naturalWidth || compareImage . width ;
329+ const height = compareImage . naturalHeight || compareImage . height ;
330+ const canvas = document . createElement ( "canvas" ) ;
331+ canvas . width = width ;
332+ canvas . height = height ;
333+ const context = canvas . getContext ( "2d" ) ;
334+ invariant ( context , "Expected canvas to have a 2d context" ) ;
335+
336+ context . drawImage ( compareImage , 0 , 0 , width , height ) ;
337+
338+ const overlayCanvas = document . createElement ( "canvas" ) ;
339+ overlayCanvas . width = width ;
340+ overlayCanvas . height = height ;
341+ const overlayContext = overlayCanvas . getContext ( "2d" ) ;
342+ invariant ( overlayContext , "Expected overlay canvas to have a 2d context" ) ;
343+
344+ overlayContext . fillStyle = props . color ;
345+ overlayContext . globalAlpha = props . opacity ;
346+ overlayContext . fillRect ( 0 , 0 , width , height ) ;
347+ overlayContext . globalAlpha = 1 ;
348+ overlayContext . globalCompositeOperation = "destination-in" ;
349+ overlayContext . drawImage ( maskImage , 0 , 0 , width , height ) ;
350+
351+ context . drawImage ( overlayCanvas , 0 , 0 , width , height ) ;
352+
353+ return canvasToBlob ( canvas ) ;
354+ }
355+
270356function BuildScreenshotHeaderPlaceholder ( ) {
271357 return < div className = "h-10.5" /> ;
272358}
@@ -608,12 +694,91 @@ function DownloadCompareScreenshotButton({
608694 diff : BuildDiffDetailDocument ;
609695 buildId : string ;
610696} ) {
697+ const overlayColor = useAtomValue ( overlayColorAtom ) ;
698+ const overlayOpacity = useAtomValue ( overlayOpacityAtom ) ;
699+ const getName = ( identifier : string ) => {
700+ return `Build #${ buildId } - ${ diff . name } - ${ identifier } .png` ;
701+ } ;
702+ const runDownload = ( promise : Promise < void > ) => {
703+ toast . promise ( promise , {
704+ loading : "Downloading image…" ,
705+ success : "Image downloaded" ,
706+ error : ( data ) => getErrorMessage ( data ) ,
707+ } ) ;
708+ } ;
709+
710+ const { url : diffUrl , compareScreenshot } = diff ;
711+ invariant ( compareScreenshot ) ;
712+
713+ if ( ! diffUrl ) {
714+ return (
715+ < DownloadScreenshotButton
716+ url = { compareScreenshot . originalUrl }
717+ tooltip = "Download"
718+ name = { getName ( "head" ) }
719+ />
720+ ) ;
721+ }
722+
611723 return (
612- < DownloadScreenshotButton
613- url = { diff . compareScreenshot ! . originalUrl }
614- tooltip = "Download changes screenshot"
615- name = { `Build #${ buildId } - ${ diff . name } - new.png` }
616- />
724+ < MenuTrigger >
725+ < Tooltip placement = "left" content = "Download changes screenshot" >
726+ < IconButton variant = "contained" >
727+ < DownloadIcon />
728+ </ IconButton >
729+ </ Tooltip >
730+ < Popover placement = "bottom end" >
731+ < Menu aria-label = "Download changes screenshot" >
732+ < MenuItem
733+ onAction = { ( ) => {
734+ runDownload (
735+ fetchBlob ( compareScreenshot . originalUrl ) . then ( ( blob ) => {
736+ downloadBlob ( blob , getName ( "head" ) ) ;
737+ } ) ,
738+ ) ;
739+ } }
740+ >
741+ < MenuItemIcon >
742+ < FileDownIcon />
743+ </ MenuItemIcon >
744+ Download changes screenshot
745+ </ MenuItem >
746+ < MenuItem
747+ onAction = { ( ) => {
748+ runDownload (
749+ fetchBlob ( diffUrl ) . then ( ( blob ) => {
750+ downloadBlob ( blob , getName ( "mask" ) ) ;
751+ } ) ,
752+ ) ;
753+ } }
754+ >
755+ < MenuItemIcon >
756+ < BlendIcon />
757+ </ MenuItemIcon >
758+ Download changes mask
759+ </ MenuItem >
760+ < MenuItem
761+ onAction = { ( ) => {
762+ runDownload (
763+ createMaskedCompareBlob ( {
764+ compareUrl : compareScreenshot . originalUrl ,
765+ maskUrl : diffUrl ,
766+ color : overlayColor ,
767+ opacity : overlayOpacity ,
768+ } ) . then ( ( blob ) => {
769+ downloadBlob ( blob , getName ( "composite" ) ) ;
770+ } ) ,
771+ ) ;
772+ } }
773+ >
774+ < MenuItemIcon >
775+ < Layers2Icon />
776+ </ MenuItemIcon >
777+ Download composed changes
778+ </ MenuItem >
779+ </ Menu >
780+ </ Popover >
781+ </ MenuTrigger >
617782 ) ;
618783}
619784
0 commit comments