import * as React from "react";
import { useApolloClient } from "@apollo/client";
import {
Button,
Box,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
useToast,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import { safeImageUrl } from "../../util";
import useSupport from "./useSupport";
/**
* AppearanceLayerSupportUploadModal helps Support users create and upload PNGs for
* broken appearance layers. Useful when the auto-converters are struggling,
* e.g. the SWF uses a color filter our server-side Flash player can't support!
*/
function AppearanceLayerSupportUploadModal({ item, layer, isOpen, onClose }) {
const [step, setStep] = React.useState(1);
const [imageOnBlackUrl, setImageOnBlackUrl] = React.useState(null);
const [imageOnWhiteUrl, setImageOnWhiteUrl] = React.useState(null);
const [imageWithAlphaUrl, setImageWithAlphaUrl] = React.useState(null);
const [imageWithAlphaBlob, setImageWithAlphaBlob] = React.useState(null);
const [numWarnings, setNumWarnings] = React.useState(null);
const [isUploading, setIsUploading] = React.useState(false);
const [uploadError, setUploadError] = React.useState(null);
const [conflictMode, setConflictMode] = React.useState("onBlack");
const { supportSecret } = useSupport();
const toast = useToast();
const apolloClient = useApolloClient();
// Once both images are ready, merge them!
React.useEffect(() => {
if (!imageOnBlackUrl || !imageOnWhiteUrl) {
return;
}
setImageWithAlphaUrl(null);
setNumWarnings(null);
setIsUploading(false);
mergeIntoImageWithAlpha(
imageOnBlackUrl,
imageOnWhiteUrl,
conflictMode,
).then(([url, blob, numWarnings]) => {
setImageWithAlphaUrl(url);
setImageWithAlphaBlob(blob);
setNumWarnings(numWarnings);
});
}, [imageOnBlackUrl, imageOnWhiteUrl, conflictMode]);
const onUpload = React.useCallback(
(e) => {
const file = e.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (re) => {
switch (step) {
case 1:
setImageOnBlackUrl(re.target.result);
setStep(2);
return;
case 2:
setImageOnWhiteUrl(re.target.result);
setStep(3);
return;
default:
throw new Error(`unexpected step ${step}`);
}
};
reader.readAsDataURL(file);
},
[step],
);
const onSubmitFinalImage = React.useCallback(async () => {
setIsUploading(true);
setUploadError(null);
try {
const res = await fetch(`/api/uploadLayerImage?layerId=${layer.id}`, {
method: "POST",
headers: {
"DTI-Support-Secret": supportSecret,
},
body: imageWithAlphaBlob,
});
if (!res.ok) {
setIsUploading(false);
setUploadError(
new Error(`Network error: ${res.status} ${res.statusText}`),
);
return;
}
setIsUploading(false);
onClose();
toast({
status: "success",
title: "Image successfully uploaded",
description: "It might take a few seconds to update in the app!",
});
// NOTE: I tried to do this as a cache update, but I couldn't ever get
// the fragment with size parameters to work :/ (Other fields would
// update, but not these!) Ultimately the eviction is the only
// reliable method I found :/
apolloClient.cache.evict({
id: `AppearanceLayer:${layer.id}`,
fieldName: "imageUrl",
});
apolloClient.cache.evict({
id: `AppearanceLayer:${layer.id}`,
fieldName: "imageUrlV2",
});
} catch (e) {
setIsUploading(false);
setUploadError(e);
}
}, [
imageWithAlphaBlob,
supportSecret,
layer.id,
toast,
onClose,
apolloClient.cache,
]);
return (
Upload PNG for {item.name}
{(step === 1 || step === 2) && (
)}
{step === 3 && (
)}
{uploadError && (
{uploadError.message}
)}
{step === 3 && (
)}
);
}
function AppearanceLayerSupportScreenshotStep({ layer, step, onUpload }) {
return (
<>
Step {step}: Take a screenshot of exactly the 600×600 Flash
region, then upload it below.
The border will turn green once the entire region is in view.
>
);
}
function AppearanceLayerSupportReviewStep({
imageWithAlphaUrl,
numWarnings,
conflictMode,
onChangeConflictMode,
}) {
if (imageWithAlphaUrl == null) {
return Generating image…;
}
const ratioBad = numWarnings / (600 * 600);
const ratioGood = 1 - ratioBad;
return (
<>
Step 3: Does this look correct? If so, let's upload it!
({Math.floor(ratioGood * 10000) / 100}% match,{" "}
{Math.floor(ratioBad * 10000) / 100}% mismatch.)
{imageWithAlphaUrl && (
)}
{numWarnings > 0 && (
When pixels conflict, we use…
)}
>
);
}
function AppearanceLayerSupportFlashPlayer({ swfUrl, backgroundColor }) {
const [isVisible, setIsVisible] = React.useState(null);
const regionRef = React.useRef(null);
// We detect whether the entire SWF region is visible, because Flash only
// bothers to render in visible places. So, screenshotting a SWF container
// that isn't fully visible will fill the not-visible space with black,
// instead of the actual SWF content. We change the border color to hint this
// to the user!
React.useLayoutEffect(() => {
const region = regionRef.current;
if (!region) {
return;
}
const scrollParent = region.closest(".chakra-modal__overlay");
if (!scrollParent) {
throw new Error(`could not find .chakra-modal__overlay scroll parent`);
}
const onMountOrScrollOrResize = () => {
const regionBox = region.getBoundingClientRect();
const scrollParentBox = scrollParent.getBoundingClientRect();
const isVisible =
regionBox.left > scrollParentBox.left &&
regionBox.right < scrollParentBox.right &&
regionBox.top > scrollParentBox.top &&
regionBox.bottom < scrollParentBox.bottom;
setIsVisible(isVisible);
};
onMountOrScrollOrResize();
scrollParent.addEventListener("scroll", onMountOrScrollOrResize);
window.addEventListener("resize", onMountOrScrollOrResize);
return () => {
scrollParent.removeEventListener("scroll", onMountOrScrollOrResize);
window.removeEventListener("resize", onMountOrScrollOrResize);
};
}, []);
let borderColor;
if (isVisible === null) {
borderColor = "gray.400";
} else if (isVisible === false) {
borderColor = "red.400";
} else if (isVisible === true) {
borderColor = "green.400";
}
return (
);
}
async function mergeIntoImageWithAlpha(
imageOnBlackUrl,
imageOnWhiteUrl,
conflictMode,
) {
const [imageOnBlack, imageOnWhite] = await Promise.all([
readImageDataFromUrl(imageOnBlackUrl),
readImageDataFromUrl(imageOnWhiteUrl),
]);
const [imageWithAlphaData, numWarnings] = mergeDataIntoImageWithAlpha(
imageOnBlack,
imageOnWhite,
conflictMode,
);
const [imageWithAlphaUrl, imageWithAlphaBlob] =
await writeImageDataToUrlAndBlob(imageWithAlphaData);
return [imageWithAlphaUrl, imageWithAlphaBlob, numWarnings];
}
function mergeDataIntoImageWithAlpha(imageOnBlack, imageOnWhite, conflictMode) {
const imageWithAlpha = new ImageData(600, 600);
let numWarnings = 0;
for (let x = 0; x < 600; x++) {
for (let y = 0; y < 600; y++) {
const pixelIndex = (600 * y + x) << 2;
const rOnBlack = imageOnBlack.data[pixelIndex];
const gOnBlack = imageOnBlack.data[pixelIndex + 1];
const bOnBlack = imageOnBlack.data[pixelIndex + 2];
const rOnWhite = imageOnWhite.data[pixelIndex];
const gOnWhite = imageOnWhite.data[pixelIndex + 1];
const bOnWhite = imageOnWhite.data[pixelIndex + 2];
if (rOnWhite < rOnBlack || gOnWhite < gOnBlack || bOnWhite < bOnBlack) {
if (numWarnings < 100) {
console.warn(
`[${x}x${y}] color on white should be lighter than color on ` +
`black, see pixel ${x}x${y}: ` +
`#${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
`${gOnWhite.toString(16)}` +
` vs ` +
`#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
`${gOnWhite.toString(16)}. ` +
`Using conflict mode ${conflictMode} to fall back.`,
);
}
const [r, g, b, a] = resolveConflict(
[rOnBlack, gOnBlack, bOnBlack],
[rOnWhite, gOnWhite, bOnWhite],
conflictMode,
);
imageWithAlpha.data[pixelIndex] = r;
imageWithAlpha.data[pixelIndex + 1] = g;
imageWithAlpha.data[pixelIndex + 2] = b;
imageWithAlpha.data[pixelIndex + 3] = a;
numWarnings++;
continue;
}
// The true alpha is how close together the on-white and on-black colors
// are. If they're totally the same, it's 255 opacity. If they're totally
// different, it's 0 opacity. In between, it scales linearly with the
// difference!
const alpha = 255 - (rOnWhite - rOnBlack);
// Check that the alpha derived from other channels makes sense too.
const alphaByB = 255 - (bOnWhite - bOnBlack);
const alphaByG = 255 - (gOnWhite - gOnBlack);
const highestAlpha = Math.max(Math.max(alpha, alphaByB), alphaByG);
const lowestAlpha = Math.min(Math.min(alpha, alphaByB, alphaByG));
if (highestAlpha - lowestAlpha > 2) {
if (numWarnings < 100) {
console.warn(
`[${x}x${y}] derived alpha values don't match: ` +
`${alpha} vs ${alphaByB} vs ${alphaByG}. ` +
`Colors: #${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
`${gOnWhite.toString(16)}` +
` vs ` +
`#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
`${gOnWhite.toString(16)}. ` +
`Using conflict mode ${conflictMode} to fall back.`,
);
}
const [r, g, b, a] = resolveConflict(
[rOnBlack, gOnBlack, bOnBlack],
[rOnWhite, gOnWhite, bOnWhite],
conflictMode,
);
imageWithAlpha.data[pixelIndex] = r;
imageWithAlpha.data[pixelIndex + 1] = g;
imageWithAlpha.data[pixelIndex + 2] = b;
imageWithAlpha.data[pixelIndex + 3] = a;
numWarnings++;
continue;
}
// And the true color is the color on black, divided by the true alpha.
// We can derive this from the definition of the color on black, which is
// simply the true color times the true alpha. Divide to undo!
const alphaRatio = alpha / 255;
const rOnAlpha = Math.round(rOnBlack / alphaRatio);
const gOnAlpha = Math.round(gOnBlack / alphaRatio);
const bOnAlpha = Math.round(bOnBlack / alphaRatio);
imageWithAlpha.data[pixelIndex] = rOnAlpha;
imageWithAlpha.data[pixelIndex + 1] = gOnAlpha;
imageWithAlpha.data[pixelIndex + 2] = bOnAlpha;
imageWithAlpha.data[pixelIndex + 3] = alpha;
}
}
return [imageWithAlpha, numWarnings];
}
/**
* readImageDataFromUrl reads an image URL to ImageData, by drawing it on a
* canvas and reading ImageData back from it.
*/
async function readImageDataFromUrl(url) {
const image = new Image();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = url;
});
const canvas = document.createElement("canvas");
canvas.width = 600;
canvas.height = 600;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, 600, 600);
return ctx.getImageData(0, 0, 600, 600);
}
/**
* writeImageDataToUrl writes an ImageData to a data URL and Blob, by drawing
* it on a canvas and reading the URL and Blob back from it.
*/
async function writeImageDataToUrlAndBlob(imageData) {
const canvas = document.createElement("canvas");
canvas.width = 600;
canvas.height = 600;
const ctx = canvas.getContext("2d");
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, "image/png"),
);
return [dataUrl, blob];
}
function resolveConflict(
[rOnBlack, gOnBlack, bOnBlack],
[rOnWhite, gOnWhite, bOnWhite],
conflictMode,
) {
if (conflictMode === "onBlack") {
return [rOnBlack, gOnBlack, bOnBlack, 255];
} else if (conflictMode === "onWhite") {
return [rOnWhite, gOnWhite, bOnWhite, 255];
} else if (conflictMode === "transparent") {
return [0, 0, 0, 0];
} else if (conflictMode === "moreColorful") {
const sOnBlack = computeSaturation(rOnBlack, gOnBlack, bOnBlack);
const sOnWhite = computeSaturation(rOnWhite, gOnWhite, bOnWhite);
if (sOnBlack > sOnWhite) {
return [rOnBlack, gOnBlack, bOnBlack, 255];
} else {
return [rOnWhite, gOnWhite, bOnWhite, 255];
}
} else {
throw new Error(`unexpected conflict mode ${conflictMode}`);
}
}
/**
* Returns the given color's saturation, as a ratio from 0 to 1.
* Adapted from https://css-tricks.com/converting-color-spaces-in-javascript/
*/
function computeSaturation(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const cmin = Math.min(r, g, b);
const cmax = Math.max(r, g, b);
const delta = cmax - cmin;
const l = (cmax + cmin) / 2;
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
return s;
}
export default AppearanceLayerSupportUploadModal;