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 ( <Modal // HACK: The built-in `full` size also sets 100% height, which I don't // want; and the docs suggest it will accept px values, but it // doesn't. But I discovered that invalid size values are treated // as 100% width and auto height, so, okay! ^_^` Probably a bug, // but I intend to use it for now! size="full-hack" isOpen={isOpen} onClose={onClose} > <ModalOverlay> <ModalContent> <ModalHeader textAlign="center"> Upload PNG for {item.name} </ModalHeader> <ModalCloseButton /> <ModalBody paddingBottom="2" display="flex" flexDirection="column" alignItems="center" textAlign="center" > {(step === 1 || step === 2) && ( <AppearanceLayerSupportScreenshotStep layer={layer} step={step} onUpload={onUpload} /> )} {step === 3 && ( <AppearanceLayerSupportReviewStep imageWithAlphaUrl={imageWithAlphaUrl} numWarnings={numWarnings} conflictMode={conflictMode} onChangeConflictMode={setConflictMode} /> )} </ModalBody> <ModalFooter> <Button colorScheme="red" onClick={() => setStep(1)}> Restart </Button> <Box flex="1 1 0" /> {uploadError && ( <Box color="red.400" fontSize="sm" marginRight="2" textAlign="right" > {uploadError.message} </Box> )} <Button onClick={onClose}>Close</Button> {step === 3 && ( <Button colorScheme="green" marginLeft="2" onClick={onSubmitFinalImage} isLoading={isUploading} > Upload </Button> )} </ModalFooter> </ModalContent> </ModalOverlay> </Modal> ); } function AppearanceLayerSupportScreenshotStep({ layer, step, onUpload }) { return ( <> <Box> <b>Step {step}:</b> Take a screenshot of exactly the 600×600 Flash region, then upload it below. <br /> The border will turn green once the entire region is in view. </Box> <Box display="flex" alignItems="center" maxWidth="600px" width="100%" marginTop="2" > <input key={step} type="file" accept="image/png" onChange={onUpload} /> <Box flex="1 1 0" /> <Button as="a" href="https://support.mozilla.org/en-US/kb/firefox-screenshots" target="_blank" size="xs" marginLeft="1" colorScheme="gray" > Firefox help <ExternalLinkIcon marginLeft="1" /> </Button> <Button as="a" href="https://umaar.com/dev-tips/156-element-screenshot/" target="_blank" size="xs" marginLeft="1" colorScheme="gray" > Chrome help <ExternalLinkIcon marginLeft="1" /> </Button> </Box> <AppearanceLayerSupportFlashPlayer swfUrl={layer.swfUrl} backgroundColor={step === 1 ? "black" : "white"} /> </> ); } function AppearanceLayerSupportReviewStep({ imageWithAlphaUrl, numWarnings, conflictMode, onChangeConflictMode, }) { if (imageWithAlphaUrl == null) { return <Box>Generating image…</Box>; } const ratioBad = numWarnings / (600 * 600); const ratioGood = 1 - ratioBad; return ( <> <Box> <b>Step 3:</b> Does this look correct? If so, let's upload it! </Box> <Box fontSize="sm" color="gray.500"> ({Math.floor(ratioGood * 10000) / 100}% match,{" "} {Math.floor(ratioBad * 10000) / 100}% mismatch.) </Box> <Box // Checkerboard pattern: https://stackoverflow.com/a/35362074/107415 backgroundImage="linear-gradient(45deg, #c0c0c0 25%, transparent 25%), linear-gradient(-45deg, #c0c0c0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c0c0c0 75%), linear-gradient(-45deg, transparent 75%, #c0c0c0 75%)" backgroundSize="20px 20px" backgroundPosition="0 0, 0 10px, 10px -10px, -10px 0px" marginTop="2" > {imageWithAlphaUrl && ( <img src={imageWithAlphaUrl} width={600} height={600} alt="Generated layer PNG, on a checkered background" /> )} </Box> <Box> {numWarnings > 0 && ( <Box display="flex" flexDirection="row" alignItems="center" justifyContent="center" width="600px" marginTop="2" > <Box flex="0 1 auto" marginRight="2"> When pixels conflict, we use… </Box> <Select flex="0 0 200px" value={conflictMode} onChange={(e) => onChangeConflictMode(e.target.value)} > <option value="onBlack">the version on black</option> <option value="onWhite">the version on white</option> <option value="transparent">transparent pixels</option> <option value="moreColorful">the more colorful pixels</option> </Select> </Box> )} </Box> </> ); } 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 ( <Box data-hint="No: Don't screenshot this node! Use the one below!" borderWidth="3px" borderStyle="dashed" borderColor={borderColor} marginTop="4" padding="1px" backgroundColor={backgroundColor} > <Box // In Chrome on macOS, I observe that I need to shift the SWF // one pixel to the left in order to capture it correctly. // // So, in Chrome, who are using a DevTools procedure, we add a // hint that this is the node to use. // // In Firefox, the GUI to target the SWF seems to work just // fine. So, the margin hack and these hints don't matter! data-hint="Yes: Screenshot this node! This is the one!" backgroundColor={backgroundColor} > <Box data-hint="No: Don't screenshot this node! Use the one above!" width="600px" height="600px" // In Chrome on macOS, I observe that I need to shift the SWF // one pixel to the left in order to capture it correctly. // // But this disrupts the Firefox capture! So here, we do a cheap // browser detection, to shift left only in Chrome. marginLeft={navigator.userAgent.includes("Chrome") ? "-1px" : "0"} ref={regionRef} > <object type="application/x-shockwave-flash" data={safeImageUrl(swfUrl)} width="100%" height="100%" > <param name="wmode" value="transparent" /> </object> </Box> </Box> </Box> ); } 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;