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