Skip to content

Commit 514412f

Browse files
authored
feat(build): allow to download mask (#2084)
1 parent 3ad8c03 commit 514412f

File tree

2 files changed

+179
-14
lines changed

2 files changed

+179
-14
lines changed

apps/frontend/src/containers/Build/BuildDiffDetail.tsx

Lines changed: 178 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,33 @@ import { invariant } from "@argos/util/invariant";
1515
import { clsx } from "clsx";
1616
import { useAtomValue } from "jotai/react";
1717
import {
18+
BlendIcon,
1819
ChevronDownIcon,
1920
ChevronUpIcon,
2021
DownloadIcon,
22+
FileDownIcon,
23+
Layers2Icon,
2124
type LucideIcon,
2225
} from "lucide-react";
2326
import { useObjectRef } from "react-aria";
27+
import { toast } from "sonner";
2428

2529
import { DocumentType, graphql } from "@/gql";
2630
import { BuildType, ScreenshotDiffStatus } from "@/gql/graphql";
2731
import { BuildDialogs } from "@/pages/Build/BuildDialogs";
2832
import { Code } from "@/ui/Code";
2933
import { IconButton } from "@/ui/IconButton";
3034
import { ImageKitPicture, imgkit } from "@/ui/ImageKitPicture";
35+
import { Menu, MenuItem, MenuItemIcon, MenuTrigger } from "@/ui/Menu";
36+
import { Popover } from "@/ui/Popover";
3137
import { Time } from "@/ui/Time";
3238
import { Tooltip } from "@/ui/Tooltip";
3339
import { useEventCallback } from "@/ui/useEventCallback";
3440
import { useResizeObserver } from "@/ui/useResizeObserver";
3541
import { useColoredRects } from "@/util/color-detection/hook";
3642
import { Rect } from "@/util/color-detection/types";
3743
import { checkIsImageContentType } from "@/util/content-type";
44+
import { getErrorMessage } from "@/util/error";
3845
import { fetchImage } from "@/util/image";
3946
import { useTextContent } from "@/util/text";
4047

@@ -53,6 +60,7 @@ import { buildViewModeAtom } from "./BuildViewMode";
5360
import { DiffEditor, Editor, getLanguageFromContentType } from "./DiffEditor";
5461
import {
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+
270356
function 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

apps/frontend/src/containers/Build/Zoomer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ export function ZoomPane(props: {
446446
{children}
447447
</div>
448448
{controls && (
449-
<div className="opacity-0 transition group-focus-within/pane:opacity-100 group-hover/pane:opacity-100">
449+
<div className="opacity-0 transition group-focus-within/pane:opacity-100 group-hover/pane:opacity-100 group-has-[button[aria-expanded=true]]/pane:opacity-100">
450450
<div
451451
className={clsx(
452452
ZOOMER_CONTROLS_CLASS,

0 commit comments

Comments
 (0)