Moved to global image source manager to allow resource sharing

This commit is contained in:
Mitchell McCaffrey 2021-03-12 17:35:26 +11:00
parent b6c6d9b553
commit 5231d14937
11 changed files with 231 additions and 110 deletions

View File

@ -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,6 +30,7 @@ function App() {
<AuthProvider>
<KeyboardProvider>
<ToastProvider>
<ImageSourcesProvider>
<Router>
<Switch>
<Route path="/donate">
@ -61,6 +63,7 @@ function App() {
</Route>
</Switch>
</Router>
</ImageSourcesProvider>
</ToastProvider>
</KeyboardProvider>
</AuthProvider>

View File

@ -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);

View File

@ -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({
>
<MapStageProvider value={mapStageRef}>
<TokenDataContext.Provider value={tokenData}>
<ImageSourcesStateContext.Provider
value={imageSources}
>
<ImageSourcesUpdaterContext.Provider
value={setImageSources}
>
{mapLoaded && children}
</ImageSourcesUpdaterContext.Provider>
</ImageSourcesStateContext.Provider>
</TokenDataContext.Provider>
</MapStageProvider>
</GridProvider>

View File

@ -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 (

View File

@ -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);

View File

@ -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();

View File

@ -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

View File

@ -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 (

View File

@ -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 (
<ImageSourcesStateContext.Provider value={imageSources}>
<ImageSourcesUpdaterContext.Provider value={setImageSources}>
{children}
</ImageSourcesUpdaterContext.Provider>
</ImageSourcesStateContext.Provider>
);
}
/**
* 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;
}

View File

@ -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;

View File

@ -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