diff --git a/src/App.js b/src/App.js index 7e1409d..a77ae2b 100644 --- a/src/App.js +++ b/src/App.js @@ -18,6 +18,7 @@ import { TokenDataProvider } from "./contexts/TokenDataContext"; import { MapLoadingProvider } from "./contexts/MapLoadingContext"; import { SettingsProvider } from "./contexts/SettingsContext"; import { KeyboardProvider } from "./contexts/KeyboardContext"; +import { ImageSourcesProvider } from "./contexts/ImageSourceContext"; import { ToastProvider } from "./components/Toast"; @@ -29,38 +30,40 @@ function App() { - - - - - - {/* Legacy support camel case routes */} - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + {/* Legacy support camel case routes */} + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/map/MapGrid.js b/src/components/map/MapGrid.js index 1fe603b..5cfbf94 100644 --- a/src/components/map/MapGrid.js +++ b/src/components/map/MapGrid.js @@ -1,7 +1,8 @@ import React, { useEffect, useState } from "react"; import useImage from "use-image"; -import useDataSource from "../../hooks/useDataSource"; +import { useImageSource } from "../../contexts/ImageSourceContext"; + import { mapSources as defaultMapSources } from "../../maps"; import { getImageLightness } from "../../helpers/image"; @@ -17,7 +18,7 @@ function MapGrid({ map }) { mapSourceMap = map.resolutions[resolutionArray[0]]; } } - const mapSource = useDataSource(mapSourceMap, defaultMapSources); + const mapSource = useImageSource(mapSourceMap, defaultMapSources); const [mapImage, mapLoadingStatus] = useImage(mapSource); const [isImageLight, setIsImageLight] = useState(true); diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js index 404b52d..e5f5ab9 100644 --- a/src/components/map/MapInteraction.js +++ b/src/components/map/MapInteraction.js @@ -21,6 +21,10 @@ import TokenDataContext, { } from "../../contexts/TokenDataContext"; import { GridProvider } from "../../contexts/GridContext"; import { useKeyboard } from "../../contexts/KeyboardContext"; +import { + ImageSourcesStateContext, + ImageSourcesUpdaterContext, +} from "../../contexts/ImageSourceContext"; function MapInteraction({ map, @@ -182,6 +186,8 @@ function MapInteraction({ const auth = useAuth(); const settings = useSettings(); const tokenData = useTokenData(); + const imageSources = useContext(ImageSourcesStateContext); + const setImageSources = useContext(ImageSourcesUpdaterContext); const mapInteraction = { stageScale, @@ -232,7 +238,15 @@ function MapInteraction({ > - {mapLoaded && children} + + + {mapLoaded && children} + + diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js index e8d6358..a81abdf 100644 --- a/src/components/map/MapTile.js +++ b/src/components/map/MapTile.js @@ -2,7 +2,7 @@ import React from "react"; import Tile from "../Tile"; -import useDataSource from "../../hooks/useDataSource"; +import { useImageSource } from "../../contexts/ImageSourceContext"; import { mapSources as defaultMapSources, unknownSource } from "../../maps"; function MapTile({ @@ -15,11 +15,11 @@ function MapTile({ canEdit, badges, }) { - const isDefault = map.type === "default"; - const mapSource = useDataSource( - isDefault ? map : map.thumbnail, + const mapSource = useImageSource( + map, defaultMapSources, - unknownSource + unknownSource, + map.type === "file" ); return ( diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js index a607fb5..954f3f9 100644 --- a/src/components/map/MapToken.js +++ b/src/components/map/MapToken.js @@ -4,7 +4,6 @@ import { useSpring, animated } from "react-spring/konva"; import useImage from "use-image"; import Konva from "konva"; -import useDataSource from "../../hooks/useDataSource"; import useDebounce from "../../hooks/useDebounce"; import usePrevious from "../../hooks/usePrevious"; import useGridSnapping from "../../hooks/useGridSnapping"; @@ -17,6 +16,7 @@ import { useDebouncedStageScale, } from "../../contexts/MapInteractionContext"; import { useGridCellPixelSize } from "../../contexts/GridContext"; +import { useImageSource } from "../../contexts/ImageSourceContext"; import TokenStatus from "../token/TokenStatus"; import TokenLabel from "../token/TokenLabel"; @@ -44,7 +44,7 @@ function MapToken({ const gridCellPixelSize = useGridCellPixelSize(); - const tokenSource = useDataSource(token, tokenSources, unknownSource); + const tokenSource = useImageSource(token, tokenSources, unknownSource); const [tokenSourceImage, tokenSourceStatus] = useImage(tokenSource); const [tokenAspectRatio, setTokenAspectRatio] = useState(1); diff --git a/src/components/token/ListToken.js b/src/components/token/ListToken.js index d505c78..e21c1e6 100644 --- a/src/components/token/ListToken.js +++ b/src/components/token/ListToken.js @@ -2,16 +2,17 @@ import React, { useRef } from "react"; import { Box, Image } from "theme-ui"; import usePreventTouch from "../../hooks/usePreventTouch"; -import useDataSource from "../../hooks/useDataSource"; + +import { useImageSource } from "../../contexts/ImageSourceContext"; import { tokenSources, unknownSource } from "../../tokens"; function ListToken({ token, className }) { - const isDefault = token.type === "default"; - const tokenSource = useDataSource( - isDefault ? token : token.thumbnail, + const tokenSource = useImageSource( + token, tokenSources, - unknownSource + unknownSource, + token.type === "file" ); const imageRef = useRef(); diff --git a/src/components/token/TokenPreview.js b/src/components/token/TokenPreview.js index f731773..cb23670 100644 --- a/src/components/token/TokenPreview.js +++ b/src/components/token/TokenPreview.js @@ -6,11 +6,11 @@ import useImage from "use-image"; import usePreventOverscroll from "../../hooks/usePreventOverscroll"; import useStageInteraction from "../../hooks/useStageInteraction"; -import useDataSource from "../../hooks/useDataSource"; import useImageCenter from "../../hooks/useImageCenter"; import useResponsiveLayout from "../../hooks/useResponsiveLayout"; import { GridProvider } from "../../contexts/GridContext"; +import { useImageSource } from "../../contexts/ImageSourceContext"; import GridOnIcon from "../../icons/GridOnIcon"; import GridOffIcon from "../../icons/GridOffIcon"; @@ -27,7 +27,7 @@ function TokenPreview({ token }) { } }, [token, tokenSourceData]); - const tokenSource = useDataSource( + const tokenSource = useImageSource( tokenSourceData, tokenSources, unknownSource diff --git a/src/components/token/TokenTile.js b/src/components/token/TokenTile.js index 0f05157..29be8b2 100644 --- a/src/components/token/TokenTile.js +++ b/src/components/token/TokenTile.js @@ -2,7 +2,8 @@ import React from "react"; import Tile from "../Tile"; -import useDataSource from "../../hooks/useDataSource"; +import { useImageSource } from "../../contexts/ImageSourceContext"; + import { tokenSources as defaultTokenSources, unknownSource, @@ -17,11 +18,11 @@ function TokenTile({ canEdit, badges, }) { - const isDefault = token.type === "default"; - const tokenSource = useDataSource( - isDefault ? token : token.thumbnail, + const tokenSource = useImageSource( + token, defaultTokenSources, - unknownSource + unknownSource, + token.type === "file" ); return ( diff --git a/src/contexts/ImageSourceContext.js b/src/contexts/ImageSourceContext.js new file mode 100644 index 0000000..3f39f05 --- /dev/null +++ b/src/contexts/ImageSourceContext.js @@ -0,0 +1,155 @@ +import React, { useContext, useState, useEffect, useRef } from "react"; + +import { omit } from "../helpers/shared"; + +export const ImageSourcesStateContext = React.createContext(); +export const ImageSourcesUpdaterContext = React.createContext(() => {}); + +/** + * Helper to manage sharing of custom image sources between uses of useImageSource + */ +export function ImageSourcesProvider({ children }) { + const [imageSources, setImageSources] = useState({}); + + // Revoke url when no more references + useEffect(() => { + let sourcesToCleanup = []; + for (let source of Object.values(imageSources)) { + if (source.references <= 0) { + URL.revokeObjectURL(source.url); + sourcesToCleanup.push(source.id); + } + } + if (sourcesToCleanup.length > 0) { + setImageSources((prevSources) => omit(prevSources, sourcesToCleanup)); + } + }, [imageSources]); + + return ( + + + {children} + + + ); +} + +/** + * Get id from image data + */ +function getImageFileId(data, thumbnail) { + if (thumbnail) { + return `${data.id}-thumbnail`; + } + if (data.resolutions) { + // Check is a resolution is specified + if (data.quality && data.resolutions[data.quality]) { + return `${data.id}-${data.quality}`; + } else if (!data.file) { + // Fallback to the highest resolution + const resolutionArray = Object.keys(data.resolutions); + const resolution = resolutionArray[resolutionArray.length - 1]; + return `${data.id}-${resolution.id}`; + } + } + return data.id; +} + +/** + * Helper function to load either file or default image into a URL + */ +export function useImageSource(data, defaultSources, unknownSource, thumbnail) { + const imageSources = useContext(ImageSourcesStateContext); + if (imageSources === undefined) { + throw new Error( + "useImageSource must be used within a ImageSourcesProvider" + ); + } + const setImageSources = useContext(ImageSourcesUpdaterContext); + if (setImageSources === undefined) { + throw new Error( + "useImageSource must be used within a ImageSourcesProvider" + ); + } + + const imageSourcesRef = useRef(imageSources); + useEffect(() => { + imageSourcesRef.current = imageSources; + }, [imageSources]); + + useEffect(() => { + if (!data || data.type !== "file") { + return; + } + const id = getImageFileId(data, thumbnail); + + function addImageSource(file) { + if (file) { + const url = URL.createObjectURL(new Blob([file])); + setImageSources((prevSources) => ({ + ...prevSources, + [id]: { url, id, references: 1 }, + })); + } + } + + if (id in imageSourcesRef.current) { + // Increase references + setImageSources((prevSources) => ({ + ...prevSources, + [id]: { + ...prevSources[id], + references: prevSources[id].references + 1, + }, + })); + } else { + if (thumbnail) { + addImageSource(data.thumbnail.file); + } else if (data.resolutions) { + // Check is a resolution is specified + if (data.quality && data.resolutions[data.quality]) { + addImageSource(data.resolutions[data.quality].file); + } + // If no file available fallback to the highest resolution + else if (!data.file) { + const resolutionArray = Object.keys(data.resolutions); + addImageSource( + data.resolutions[resolutionArray[resolutionArray.length - 1]].file + ); + } else { + addImageSource(data.file); + } + } else { + addImageSource(data.file); + } + } + + return () => { + // Decrease references + if (id in imageSourcesRef.current) { + setImageSources((prevSources) => ({ + ...prevSources, + [id]: { + ...prevSources[id], + references: prevSources[id].references - 1, + }, + })); + } + }; + }, [data, unknownSource, thumbnail, setImageSources]); + + if (!data) { + return unknownSource; + } + + if (data.type === "default") { + return defaultSources[data.key]; + } + + if (data.type === "file") { + const id = getImageFileId(data, thumbnail); + return imageSources[id]?.url; + } + + return unknownSource; +} diff --git a/src/hooks/useDataSource.js b/src/hooks/useDataSource.js deleted file mode 100644 index 6feb5a0..0000000 --- a/src/hooks/useDataSource.js +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useState } from "react"; - -// Helper function to load either file or default data -// into a URL and ensure that it is revoked if needed -function useDataSource(data, defaultSources, unknownSource) { - const [dataSource, setDataSource] = useState(null); - useEffect(() => { - if (!data) { - setDataSource(unknownSource); - return; - } - let url = unknownSource; - if (data.type === "file") { - if (data.resolutions) { - // Check is a resolution is specified - if (data.quality && data.resolutions[data.quality]) { - url = URL.createObjectURL( - new Blob([data.resolutions[data.quality].file]) - ); - } - // If no file available fallback to the highest resolution - else if (!data.file) { - const resolutionArray = Object.keys(data.resolutions); - url = URL.createObjectURL( - new Blob([ - data.resolutions[resolutionArray[resolutionArray.length - 1]] - .file, - ]) - ); - } else { - url = URL.createObjectURL(new Blob([data.file])); - } - } else { - url = URL.createObjectURL(new Blob([data.file])); - } - } else if (data.type === "default") { - url = defaultSources[data.key]; - } - setDataSource(url); - - return () => { - if (data.type === "file" && url) { - // Remove file url after 5 seconds as we still may be using it while the next image loads - setTimeout(() => { - URL.revokeObjectURL(url); - }, 5000); - } - }; - }, [data, defaultSources, unknownSource]); - - return dataSource; -} - -export default useDataSource; diff --git a/src/hooks/useMapImage.js b/src/hooks/useMapImage.js index db28781..a67dc2b 100644 --- a/src/hooks/useMapImage.js +++ b/src/hooks/useMapImage.js @@ -1,12 +1,12 @@ import { useEffect, useState } from "react"; import useImage from "use-image"; -import useDataSource from "./useDataSource"; +import { useImageSource } from "../contexts/ImageSourceContext"; import { mapSources as defaultMapSources } from "../maps"; function useMapImage(map) { - const mapSource = useDataSource(map, defaultMapSources); + const mapSource = useImageSource(map, defaultMapSources); const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource); // Create a map source that only updates when the image is fully loaded