Rework the Asset Store details page to show a preview of the animation(s) of the object (#3930)

This commit is contained in:
Clément Pasteau
2022-05-27 12:58:42 +02:00
committed by GitHub
parent 2c5374b87a
commit fbb7f16d29
22 changed files with 1020 additions and 708 deletions
@@ -4,8 +4,10 @@
* reserved. This project is released under the MIT License.
*/
#include "GDCore/Extensions/Builtin/SpriteExtension/Direction.h"
#include <iostream>
#include <vector>
#include "GDCore/CommonTools.h"
#include "GDCore/Extensions/Builtin/SpriteExtension/Sprite.h"
#include "GDCore/Serialization/SerializerElement.h"
@@ -29,6 +31,15 @@ const Sprite& Direction::GetSprite(std::size_t nb) const { return sprites[nb]; }
Sprite& Direction::GetSprite(std::size_t nb) { return sprites[nb]; }
const std::vector<gd::String>& Direction::GetSpriteNames() const {
static std::vector<gd::String> spriteNames;
spriteNames.clear();
for (std::size_t i = 0; i < sprites.size(); ++i) {
spriteNames.push_back(sprites[i].GetImageName());
}
return spriteNames;
}
void Direction::RemoveSprite(std::size_t index) {
if (index < sprites.size()) sprites.erase(sprites.begin() + index);
}
@@ -72,6 +72,13 @@ class GD_CORE_API Direction {
*/
Sprite& GetSprite(std::size_t nb);
/**
* \brief Return a vector of references to sprite names.
*
* \return A vector of all sprite names references.
*/
const std::vector<gd::String>& GetSpriteNames() const;
/**
* \brief Check if the direction contains sprites.
*
+1
View File
@@ -2507,6 +2507,7 @@ interface Direction {
boolean IsLooping();
void SetLoop(boolean enable);
float GetTimeBetweenFrames();
[Const, Ref] VectorString GetSpriteNames();
void SetTimeBetweenFrames(float time);
void SwapSprites(unsigned long first, unsigned long second);
void MoveSprite(unsigned long oldIndex, unsigned long newIndex);
+1
View File
@@ -10,6 +10,7 @@ declare class gdDirection {
isLooping(): boolean;
setLoop(enable: boolean): void;
getTimeBetweenFrames(): number;
getSpriteNames(): gdVectorString;
setTimeBetweenFrames(time: number): void;
swapSprites(first: number, second: number): void;
moveSprite(oldIndex: number, newIndex: number): void;
+264 -99
View File
@@ -1,7 +1,7 @@
// @flow
import { Trans } from '@lingui/macro';
import { t, Trans } from '@lingui/macro';
import * as React from 'react';
import { Column } from '../UI/Grid';
import { Column, Line, Spacer } from '../UI/Grid';
import Text from '../UI/Text';
import Chip from '@material-ui/core/Chip';
import RaisedButton from '../UI/RaisedButton';
@@ -9,47 +9,41 @@ import {
type AssetShortHeader,
type Asset,
type Author,
type ObjectAsset,
getAsset,
isPixelArt,
} from '../Utils/GDevelopServices/Asset';
import { getPixelatedImageRendering } from '../Utils/CssHelpers';
import LeftLoader from '../UI/LeftLoader';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import PlaceholderError from '../UI/PlaceholderError';
import {
type ResourceSource,
type ChooseResourceFunction,
} from '../ResourcesList/ResourceSource';
import { type ResourceSource } from '../ResourcesList/ResourceSource';
import { type ResourceExternalEditor } from '../ResourcesList/ResourceExternalEditor.flow';
import Add from '@material-ui/icons/Add';
import { ResponsiveLineStackLayout } from '../UI/Layout';
import { ResponsiveWindowMeasurer } from '../UI/Reponsive/ResponsiveWindowMeasurer';
import Dialog from '../UI/Dialog';
import FlatButton from '../UI/FlatButton';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import { AssetStoreContext } from './AssetStoreContext';
import Link from '@material-ui/core/Link';
import Window from '../Utils/Window';
import CheckeredBackground from '../ResourcesList/CheckeredBackground';
import Paper from '@material-ui/core/Paper';
import SelectField from '../UI/SelectField';
import SelectOption from '../UI/SelectOption';
import IconButton from '../UI/IconButton';
import ArrowBackIos from '@material-ui/icons/ArrowBackIos';
import ArrowForwardIos from '@material-ui/icons/ArrowForwardIos';
import ThemeContext from '../UI/Theme/ThemeContext';
import AnimationPreview from '../ObjectEditor/Editors/SpriteEditor/AnimationPreview';
import ScrollView from '../UI/ScrollView';
const FIXED_HEIGHT = 250;
const styles = {
previewImagePixelated: {
width: '100%',
imageRendering: getPixelatedImageRendering(),
padding: 15,
},
previewBackground: {
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: 10,
},
verticalPreviewBackground: {
width: 250,
},
horizontalPreviewBackground: {
height: 170,
width: 300,
maxHeight: FIXED_HEIGHT,
},
chip: {
marginBottom: 2,
@@ -61,17 +55,26 @@ const styles = {
maxHeight: '100%',
verticalAlign: 'middle',
pointerEvents: 'none',
objectFit: 'contain',
},
arrowContainer: {
padding: 6,
},
// The left arrow SVG icon from Material-UI is not centered.
leftArrowSvg: {
transform: 'translateX(5px)',
},
};
const makeFirstLetterUppercase = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1);
type Props = {|
project: gdProject,
layout: ?gdLayout,
objectsContainer: gdObjectsContainer,
resourceSources: Array<ResourceSource>,
onChooseResource: ChooseResourceFunction,
resourceExternalEditors: Array<ResourceExternalEditor>,
onTagSelection: (tag: string) => void,
assetShortHeader: AssetShortHeader,
onAdd: () => void,
@@ -80,21 +83,39 @@ type Props = {|
isBeingInstalled: boolean,
|};
const getObjectAssetResourcesByName = (
objectAsset: ObjectAsset
): { [string]: any /*(serialized gdResource)*/ } => {
const resourcesByName = {};
objectAsset.resources.forEach(resource => {
resourcesByName[resource.name] = resource;
});
return resourcesByName;
};
export const AssetDetails = ({
project,
layout,
objectsContainer,
resourceSources,
onChooseResource,
resourceExternalEditors,
onTagSelection,
assetShortHeader,
onAdd,
onClose,
canInstall,
isBeingInstalled,
}: Props) => {
const gdevelopTheme = React.useContext(ThemeContext);
const { authors, licenses } = React.useContext(AssetStoreContext);
const [asset, setAsset] = React.useState<?Asset>(null);
const [isAssetAdded, setIsAssetAdded] = React.useState<boolean>(false);
const [
selectedAnimationName,
setSelectedAnimationName,
] = React.useState<?string>(null);
const [error, setError] = React.useState<?Error>(null);
const loadAsset = React.useCallback(
() => {
@@ -102,8 +123,14 @@ export const AssetDetails = ({
try {
const loadedAsset = await getAsset(assetShortHeader);
setAsset(loadedAsset);
if (loadedAsset.objectType === 'sprite') {
// Only sprites have animations and we select the first one.
const firstAnimationName =
loadedAsset.objectAssets[0].object.animations[0].name;
setSelectedAnimationName(firstAnimationName);
}
} catch (error) {
console.log('Error while loading asset:', error);
console.error('Error while loading asset:', error);
setError(error);
}
})();
@@ -111,6 +138,11 @@ export const AssetDetails = ({
[assetShortHeader]
);
const isImageResourceSmooth = React.useMemo(
() => !isPixelArt(assetShortHeader),
[assetShortHeader]
);
React.useEffect(
() => {
loadAsset();
@@ -118,12 +150,14 @@ export const AssetDetails = ({
[loadAsset]
);
const canAddAsset = canInstall && !isBeingInstalled && !!asset;
const canAddAsset =
canInstall && !isBeingInstalled && !!asset && !isAssetAdded;
const onAddAsset = React.useCallback(
() => {
if (canAddAsset) onAdd();
setIsAssetAdded(true);
},
[onAdd, canAddAsset]
[onAdd, canAddAsset, setIsAssetAdded]
);
const assetAuthors: ?Array<Author> =
@@ -139,79 +173,36 @@ export const AssetDetails = ({
? licenses.find(({ name }) => name === asset.license)
: null;
// For sprite animations.
const assetResources =
asset && asset.objectAssets[0]
? getObjectAssetResourcesByName(asset.objectAssets[0])
: {};
const assetAnimations = asset
? asset.objectAssets[0].object.animations
: null;
const animation = assetAnimations
? assetAnimations.find(({ name }) => name === selectedAnimationName)
: null;
const direction = animation ? animation.directions[0] : null;
const animationResources =
asset && direction
? direction.sprites.map(sprite => assetResources[sprite.image])
: null;
return (
<Dialog
open
title={<Trans>Add an object from the store</Trans>}
onRequestClose={onClose}
actions={[
<FlatButton key="back" label={<Trans>Back</Trans>} onClick={onClose} />,
<LeftLoader
isLoading={isBeingInstalled || (!asset && !error)}
key="install"
>
<RaisedButton
primary
icon={<Add />}
label={<Trans>Add to the game</Trans>}
onClick={onAddAsset}
disabled={!canAddAsset}
id="add-asset-button"
/>
</LeftLoader>,
]}
onApply={onAddAsset}
id={'asset-details-dialog'}
>
<ScrollView>
<Column expand noMargin>
<ResponsiveLineStackLayout noMargin>
<ResponsiveWindowMeasurer>
{windowWidth => (
<div
style={{
...styles.previewBackground,
...(windowWidth === 'small'
? styles.horizontalPreviewBackground
: styles.verticalPreviewBackground),
}}
>
<CheckeredBackground />
<CorsAwareImage
style={{
...styles.previewImage,
...(isPixelArt(assetShortHeader)
? styles.previewImagePixelated
: undefined),
}}
src={assetShortHeader.previewImageUrls[0]}
alt={assetShortHeader.name}
/>
</div>
)}
</ResponsiveWindowMeasurer>
<Column expand>
<div>
<Line justifyContent="space-between" noMargin>
<Column>
<Line alignItems="center" noMargin>
<Text size="title" displayInlineAsSpan>
{assetShortHeader.name}
</Text>{' '}
{assetShortHeader.shortDescription && (
<React.Fragment>
-{' '}
<Text size="body" displayInlineAsSpan>
{assetShortHeader.shortDescription}
</Text>
</React.Fragment>
)}
</div>
<span>
{assetShortHeader.tags.map(tag => (
<Chip size="small" style={styles.chip} label={tag} key={tag} />
))}
</span>
{asset ? (
<React.Fragment>
</Text>
<Spacer />
{asset && (
<Text size="body">
<Trans>By:</Trans>{' '}
<Trans>by</Trans>{' '}
{!!assetAuthors &&
assetAuthors.map(author => {
return (
@@ -227,10 +218,184 @@ export const AssetDetails = ({
);
})}
</Text>
)}
</Line>
<Line alignItems="center">
<div style={{ flexWrap: 'wrap' }}>
{assetShortHeader.tags.slice(0, 5).map((tag, index) => (
<React.Fragment key={tag}>
{index !== 0 && <Spacer />}
<Chip
size="small"
style={styles.chip}
label={makeFirstLetterUppercase(tag)}
onClick={() => {
onTagSelection(tag);
}}
/>
</React.Fragment>
))}
{assetShortHeader.tags.length > 5 && (
<>
<Spacer />
<Chip
size="small"
style={styles.chip}
label={
<Trans>
+ {assetShortHeader.tags.length - 5} tag(s)
</Trans>
}
/>
</>
)}
</div>
</Line>
</Column>
<Column alignItems="center" justifyContent="center">
<LeftLoader
isLoading={isBeingInstalled || (!asset && !error)}
key="install"
>
<RaisedButton
primary
label={
isBeingInstalled ? (
<Trans>Adding to my project...</Trans>
) : isAssetAdded ? (
<Trans>Object added to my project</Trans>
) : (
<Trans>Add object to my project</Trans>
)
}
onClick={onAddAsset}
disabled={!canAddAsset}
id="add-asset-button"
/>
</LeftLoader>
</Column>
</Line>
<ResponsiveLineStackLayout noMargin>
<Column>
<div style={styles.previewBackground}>
{asset ? (
<>
{asset.objectType === 'sprite' &&
animationResources &&
direction && (
<AnimationPreview
resourceNames={animationResources.map(
({ name }) => name
)}
getImageResourceSource={(resourceName: string) => {
const resource = assetResources[resourceName];
return resource ? resource.file : '';
}}
isImageResourceSmooth={() => isImageResourceSmooth}
project={project}
timeBetweenFrames={direction.timeBetweenFrames}
isLooping // Always loop in the asset store.
hideCheckeredBackground
hideControls
initialZoom={140 / Math.max(asset.width, asset.height)}
fixedHeight={FIXED_HEIGHT}
/>
)}
{(asset.objectType === 'tiled' ||
asset.objectType === '9patch') && (
<CorsAwareImage
style={styles.previewImage}
src={asset.previewImageUrls[0]}
alt={asset.name}
/>
)}
</>
) : (
<PlaceholderLoader />
)}
</div>
{assetAnimations &&
assetAnimations.length > 1 &&
typeof selectedAnimationName === 'string' && (
<Paper
elevation={4}
variant="outlined"
style={{
backgroundColor: gdevelopTheme.list.itemsBackgroundColor,
}}
>
<Line justifyContent="center" alignItems="center" noMargin>
<div style={styles.arrowContainer}>
<IconButton
size="small"
onClick={() => {
const previousAnimationIndex = assetAnimations.findIndex(
({ name }) => name === selectedAnimationName
);
const newAnimationIndex =
previousAnimationIndex === 0
? assetAnimations.length - 1
: previousAnimationIndex - 1;
setSelectedAnimationName(
assetAnimations[newAnimationIndex].name
);
}}
>
<ArrowBackIos style={styles.leftArrowSvg} />
</IconButton>
</div>
<SelectField
value={selectedAnimationName}
onChange={(e, i, newAnimationName: string) => {
setSelectedAnimationName(newAnimationName);
}}
fullWidth
textAlign="center"
disableUnderline
>
{assetAnimations.map(animation => (
<SelectOption
key={animation.name}
value={animation.name}
primaryText={
makeFirstLetterUppercase(animation.name) ||
t`Default` // Display default for animations with no name.
}
/>
))}
</SelectField>
<div style={styles.arrowContainer}>
<IconButton
size="small"
onClick={() => {
const previousAnimationIndex = assetAnimations.findIndex(
({ name }) => name === selectedAnimationName
);
const newAnimationIndex =
previousAnimationIndex ===
assetAnimations.length - 1
? 0
: previousAnimationIndex + 1;
setSelectedAnimationName(
assetAnimations[newAnimationIndex].name
);
}}
>
<ArrowForwardIos />
</IconButton>
</div>
</Line>
</Paper>
)}
</Column>
<Column expand>
{asset ? (
<React.Fragment>
<Text size="body">
{!!assetLicense && (
<Trans>
License:{' '}
Type of License:{' '}
{
<Link
component="button"
@@ -259,6 +424,6 @@ export const AssetDetails = ({
</Column>
</ResponsiveLineStackLayout>
</Column>
</Dialog>
</ScrollView>
);
};
+1 -1
View File
@@ -100,7 +100,7 @@ export const HexColorField = ({
floatingLabelFixed
helperMarkdownText={helperMarkdownText}
type="text"
hintText={t`Code like #ff8844`}
hintText={t`#ff8844`}
value={colorString}
onChange={event => handleTextChange(event.target.value)}
/>
+7 -111
View File
@@ -12,25 +12,14 @@ import {
} from '../ObjectsList/EnumerateObjects';
import HelpButton from '../UI/HelpButton';
import { Column, Line } from '../UI/Grid';
import DismissableInfoBar from '../UI/Messages/DismissableInfoBar';
import { Tabs, Tab } from '../UI/Tabs';
import { AssetStore } from '.';
import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset';
import EventsFunctionsExtensionsContext from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import { installAsset } from './InstallAsset';
import { AssetDetails } from './AssetDetails';
import {
type ResourceSource,
type ChooseResourceFunction,
} from '../ResourcesList/ResourceSource';
import { type ResourceExternalEditor } from '../ResourcesList/ResourceExternalEditor.flow';
import {
sendAssetAddedToProject,
sendAssetOpened,
sendNewObjectCreated,
} from '../Utils/Analytics/EventSender';
import { showErrorBox } from '../UI/Messages/MessageBox';
import { useResourceFetcher } from '../ProjectsStorage/ResourceFetcher';
import { sendNewObjectCreated } from '../Utils/Analytics/EventSender';
import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
import ScrollView from '../UI/ScrollView';
import useDismissableTutorialMessage from '../Hints/useDismissableTutorialMessage';
@@ -94,11 +83,6 @@ export default function NewObjectDialog({
setNewObjectDialogDefaultTab,
getNewObjectDialogDefaultTab,
} = React.useContext(PreferencesContext);
const [assetWasInstalled, setAssetWasInstalled] = React.useState(false);
const [openedAsset, setOpenedAsset] = React.useState<null | AssetShortHeader>(
null
);
const [currentTab, setCurrentTab] = React.useState(
getNewObjectDialogDefaultTab()
);
@@ -122,69 +106,11 @@ export default function NewObjectDialog({
[allObjectMetadata]
);
const resourcesFetcher = useResourceFetcher();
React.useEffect(() => setNewObjectDialogDefaultTab(currentTab), [
setNewObjectDialogDefaultTab,
currentTab,
]);
const [
assetBeingInstalled,
setAssetBeingInstalled,
] = React.useState<?AssetShortHeader>(null);
const eventsFunctionsExtensionsState = React.useContext(
EventsFunctionsExtensionsContext
);
const onInstallAsset = React.useCallback(
(assetShortHeader: AssetShortHeader) => {
setAssetBeingInstalled(assetShortHeader);
(async () => {
try {
const installOutput = await installAsset({
assetShortHeader,
eventsFunctionsExtensionsState,
project,
objectsContainer,
events,
});
sendAssetAddedToProject({
id: assetShortHeader.id,
name: assetShortHeader.name,
});
console.log('Asset successfully installed.');
installOutput.createdObjects.forEach(object => {
onObjectAddedFromAsset(object);
});
await resourcesFetcher.ensureResourcesAreFetched(project);
setOpenedAsset(null);
setAssetWasInstalled(true);
} catch (error) {
console.error('Error while installing the asset:', error);
showErrorBox({
message: `There was an error while installing the asset "${
assetShortHeader.name
}". Verify your internet connection or try again later.`,
rawError: error,
errorId: 'install-asset-error',
});
}
setAssetBeingInstalled(null);
})();
},
[
resourcesFetcher,
eventsFunctionsExtensionsState,
project,
objectsContainer,
events,
onObjectAddedFromAsset,
]
);
const { DismissableTutorialMessage } = useDismissableTutorialMessage(
'intro-object-types'
);
@@ -212,7 +138,7 @@ export default function NewObjectDialog({
>
<Column noMargin expand>
<Tabs value={currentTab} onChange={setCurrentTab}>
<Tab label={<Trans>Search Asset Store</Trans>} value="asset-store" />
<Tab label={<Trans>Asset Store</Trans>} value="asset-store" />
<Tab
label={<Trans>New object from scratch</Trans>}
value="new-object"
@@ -224,13 +150,11 @@ export default function NewObjectDialog({
project={project}
objectsContainer={objectsContainer}
events={events}
onOpenDetails={assetShortHeader => {
setOpenedAsset(assetShortHeader);
sendAssetOpened({
id: assetShortHeader.id,
name: assetShortHeader.name,
});
}}
layout={layout}
onChooseResource={onChooseResource}
resourceSources={resourceSources}
resourceExternalEditors={resourceExternalEditors}
onObjectAddedFromAsset={onObjectAddedFromAsset}
/>
)}
{currentTab === 'new-object' && (
@@ -263,34 +187,6 @@ export default function NewObjectDialog({
</ScrollView>
)}
</Column>
{openedAsset !== null ? (
<AssetDetails
project={project}
layout={layout}
objectsContainer={objectsContainer}
resourceSources={resourceSources}
onChooseResource={onChooseResource}
resourceExternalEditors={resourceExternalEditors}
assetShortHeader={openedAsset}
onAdd={() => onInstallAsset(openedAsset)}
onClose={() => setOpenedAsset(null)}
canInstall={!assetBeingInstalled}
isBeingInstalled={
!!assetBeingInstalled && assetBeingInstalled.id === openedAsset.id
}
/>
) : null}
{resourcesFetcher.renderResourceFetcherDialog()}
<DismissableInfoBar
identifier="asset-installed-explanation"
message={
<Trans>
The object was added to the list of objects. You can now use it on
the scene, in events, and customize it.
</Trans>
}
show={assetWasInstalled}
/>
</Dialog>
);
}
+288 -111
View File
@@ -8,7 +8,16 @@ import DoubleChevronArrow from '../UI/CustomSvgIcons/DoubleChevronArrow';
import { Column, Line, Spacer } from '../UI/Grid';
import Background from '../UI/Background';
import ScrollView from '../UI/ScrollView';
import { sendAssetPackOpened } from '../Utils/Analytics/EventSender';
import {
sendAssetAddedToProject,
sendAssetOpened,
sendAssetPackOpened,
} from '../Utils/Analytics/EventSender';
import {
type ResourceSource,
type ChooseResourceFunction,
} from '../ResourcesList/ResourceSource';
import { type ResourceExternalEditor } from '../ResourcesList/ResourceExternalEditor.flow';
import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset';
import { BoxSearchResults } from '../UI/Search/BoxSearchResults';
import { type SearchBarInterface } from '../UI/SearchBar';
@@ -25,6 +34,12 @@ import { AssetsHome } from './AssetsHome';
import FlatButton from '../UI/FlatButton';
import Text from '../UI/Text';
import IconButton from '../UI/IconButton';
import { AssetDetails } from './AssetDetails';
import EventsFunctionsExtensionsContext from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import { installAsset } from './InstallAsset';
import { useResourceFetcher } from '../ProjectsStorage/ResourceFetcher';
import { showErrorBox } from '../UI/Messages/MessageBox';
import PlaceholderLoader from '../UI/PlaceholderLoader';
const styles = {
searchBar: {
@@ -37,16 +52,24 @@ type Props = {
project: gdProject,
objectsContainer: gdObjectsContainer,
events: gdEventsList,
onOpenDetails: AssetShortHeader => void,
focusOnMount?: boolean,
onObjectAddedFromAsset: (object: gdObject) => void,
layout: ?gdLayout,
resourceSources: Array<ResourceSource>,
resourceExternalEditors: Array<ResourceExternalEditor>,
onChooseResource: ChooseResourceFunction,
};
export const AssetStore = ({
project,
objectsContainer,
events,
onOpenDetails,
focusOnMount,
onObjectAddedFromAsset,
layout,
resourceSources,
resourceExternalEditors,
onChooseResource,
}: Props) => {
const {
assetPacks,
@@ -68,8 +91,110 @@ export const AssetStore = ({
const searchBar = React.useRef<?SearchBarInterface>(null);
const shouldAutofocusSearchbar = useShouldAutofocusSearchbar();
const [
openedAssetShortHeader,
setOpenedAssetShortHeader,
] = React.useState<?AssetShortHeader>(null);
const [isFiltersPanelOpen, setIsFiltersPanelOpen] = React.useState(false);
const [isOnHomePage, setIsOnHomePage] = React.useState(true);
const [isFiltersOpen, setIsFiltersOpen] = React.useState(false);
const [
assetBeingInstalled,
setAssetBeingInstalled,
] = React.useState<?AssetShortHeader>(null);
const eventsFunctionsExtensionsState = React.useContext(
EventsFunctionsExtensionsContext
);
const resourcesFetcher = useResourceFetcher();
const onInstallAsset = React.useCallback(
(assetShortHeader: AssetShortHeader) => {
setAssetBeingInstalled(assetShortHeader);
(async () => {
try {
const installOutput = await installAsset({
assetShortHeader,
eventsFunctionsExtensionsState,
project,
objectsContainer,
events,
});
sendAssetAddedToProject({
id: assetShortHeader.id,
name: assetShortHeader.name,
});
console.log('Asset successfully installed.');
installOutput.createdObjects.forEach(object => {
onObjectAddedFromAsset(object);
});
await resourcesFetcher.ensureResourcesAreFetched(project);
} catch (error) {
console.error('Error while installing the asset:', error);
showErrorBox({
message: `There was an error while installing the asset "${
assetShortHeader.name
}". Verify your internet connection or try again later.`,
rawError: error,
errorId: 'install-asset-error',
});
}
setAssetBeingInstalled(null);
})();
},
[
resourcesFetcher,
eventsFunctionsExtensionsState,
project,
objectsContainer,
events,
onObjectAddedFromAsset,
]
);
const resetToDefault = () => {
setSearchText('');
filtersState.setChosenCategory(null);
setOpenedAssetShortHeader(null);
clearAllFilters(assetFiltersState);
setIsFiltersPanelOpen(false);
setIsOnHomePage(true);
};
// When a pack is selected from the home page,
// we set it as the chosen category and open the filters panel.
const selectPack = (tag: string) => {
if (!assetPacks) return;
sendAssetPackOpened(tag);
const chosenCategory = {
node: { name: tag, allChildrenTags: [], children: [] },
parentNodes: [],
};
filtersState.setChosenCategory(chosenCategory);
setIsOnHomePage(false);
setIsFiltersPanelOpen(true);
};
// When a tag is selected from the asset details page,
// we set it as the chosen category, clear old filters and open the filters panel.
const selectTag = (tag: string) => {
const chosenCategory = {
node: { name: tag, allChildrenTags: [], children: [] },
parentNodes: [],
};
filtersState.setChosenCategory(chosenCategory);
clearAllFilters(assetFiltersState);
setOpenedAssetShortHeader(null);
setIsFiltersPanelOpen(true);
};
React.useEffect(
() => {
@@ -81,117 +206,169 @@ export const AssetStore = ({
);
return (
<ResponsiveWindowMeasurer>
{windowWidth => (
<Column expand noMargin useFullHeight>
<SearchBar
placeholder={t`Enter your Search`}
value={searchText}
onChange={setSearchText}
onRequestSearch={() => setIsOnHomePage(false)}
style={styles.searchBar}
ref={searchBar}
id="asset-store-search-bar"
/>
{!isOnHomePage && <Spacer />}
<Line justifyContent="left" noMargin={isOnHomePage}>
{isOnHomePage ? (
<Column>
<Text size="title">
<Trans>Discover</Trans>
</Text>
</Column>
) : (
<FlatButton
icon={<ArrowBack />}
label={<Trans>Back to discover</Trans>}
primary={false}
onClick={() => {
filtersState.setChosenCategory(null);
setIsFiltersOpen(false);
setIsOnHomePage(true);
<>
<ResponsiveWindowMeasurer>
{windowWidth => (
<>
<Column expand noMargin useFullHeight>
<SearchBar
placeholder={t`Search assets`}
value={searchText}
onChange={setSearchText}
onRequestSearch={() => {
if (isOnHomePage) setIsOnHomePage(false);
}}
style={styles.searchBar}
ref={searchBar}
id="asset-store-search-bar"
/>
)}
</Line>
<Line
expand
overflow={
'hidden' /* Somehow required on Chrome/Firefox to avoid children growing (but not on Safari) */
}
>
<Background
noFullHeight
noExpand
width={!isFiltersOpen ? 50 : windowWidth === 'small' ? 150 : 250}
>
{!isFiltersOpen ? (
<Line justifyContent="center">
<IconButton onClick={() => setIsFiltersOpen(true)}>
<Tune />
</IconButton>
</Line>
) : (
<ScrollView>
<Line justifyContent="space-between" alignItems="center">
<Column>
<Line alignItems="center">
<Tune />
<Subheader>
<Trans>Object filters</Trans>
</Subheader>
</Line>
</Column>
<IconButton onClick={() => setIsFiltersOpen(false)}>
<DoubleChevronArrow />
</IconButton>
</Line>
<Line justifyContent="space-between" alignItems="center">
<AssetStoreFilterPanel
assetFiltersState={assetFiltersState}
onChoiceChange={() => setIsOnHomePage(false)}
/>
</Line>
</ScrollView>
)}
</Background>
{isOnHomePage && assetPacks ? (
<AssetsHome
assetPacks={assetPacks}
onPackSelection={tag => {
const chosenCategory = {
node: { name: tag, allChildrenTags: [], children: [] },
parentNodes: [],
};
sendAssetPackOpened(tag);
filtersState.setChosenCategory(chosenCategory);
setIsFiltersOpen(true);
setIsOnHomePage(false);
}}
/>
) : (
<BoxSearchResults
baseSize={128}
onRetry={fetchAssetsAndFilters}
error={error}
searchItems={searchResults}
renderSearchItem={(assetShortHeader, size) => (
<AssetCard
size={size}
onOpenDetails={() => onOpenDetails(assetShortHeader)}
assetShortHeader={assetShortHeader}
{!isOnHomePage && <Spacer />}
<Line justifyContent="left" noMargin>
{isOnHomePage ? (
<Column>
<Text size="title">
<Trans>Discover</Trans>
</Text>
</Column>
) : (
<FlatButton
icon={<ArrowBack />}
label={
openedAssetShortHeader ? (
<Trans>Back</Trans>
) : (
<Trans>Back to discover</Trans>
)
}
primary={false}
onClick={() => {
if (openedAssetShortHeader) {
// Going back from Asset page to search.
setOpenedAssetShortHeader(null);
} else {
// Going back from search to home.
resetToDefault();
}
}}
/>
)}
noResultPlaceholder={
<NoResultPlaceholder
onClear={() => clearAllFilters(assetFiltersState)}
/>
</Line>
<Line
expand
overflow={
'hidden' /* Somehow required on Chrome/Firefox to avoid children growing (but not on Safari) */
}
/>
)}
</Line>
</Column>
)}
</ResponsiveWindowMeasurer>
>
{!openedAssetShortHeader && ( // Don't show filters on asset page.
<Background
noFullHeight
noExpand
width={
!isFiltersPanelOpen
? 50
: windowWidth === 'small'
? 205
: 250
}
>
{!isFiltersPanelOpen ? (
<Line justifyContent="center">
<IconButton onClick={() => setIsFiltersPanelOpen(true)}>
<Tune />
</IconButton>
</Line>
) : (
<ScrollView>
<Line
justifyContent="space-between"
alignItems="center"
>
<Column>
<Line alignItems="center">
<Tune />
<Subheader>
<Trans>Object filters</Trans>
</Subheader>
</Line>
</Column>
<IconButton
onClick={() => setIsFiltersPanelOpen(false)}
>
<DoubleChevronArrow />
</IconButton>
</Line>
<Line
justifyContent="space-between"
alignItems="center"
>
<AssetStoreFilterPanel
assetFiltersState={assetFiltersState}
onChoiceChange={() => {
if (isOnHomePage) setIsOnHomePage(false);
}}
/>
</Line>
</ScrollView>
)}
</Background>
)}
{isOnHomePage && !assetPacks && <PlaceholderLoader />}
{isOnHomePage && assetPacks && (
<AssetsHome
assetPacks={assetPacks}
onPackSelection={selectPack}
/>
)}
{!isOnHomePage && !openedAssetShortHeader && (
<BoxSearchResults
baseSize={128}
onRetry={fetchAssetsAndFilters}
error={error}
searchItems={searchResults}
renderSearchItem={(assetShortHeader, size) => (
<AssetCard
size={size}
onOpenDetails={() => {
sendAssetOpened({
id: assetShortHeader.id,
name: assetShortHeader.name,
});
setOpenedAssetShortHeader(assetShortHeader);
}}
assetShortHeader={assetShortHeader}
/>
)}
noResultPlaceholder={
<NoResultPlaceholder
onClear={() => clearAllFilters(assetFiltersState)}
/>
}
/>
)}
{openedAssetShortHeader && (
<AssetDetails
project={project}
layout={layout}
objectsContainer={objectsContainer}
resourceSources={resourceSources}
resourceExternalEditors={resourceExternalEditors}
onTagSelection={selectTag}
assetShortHeader={openedAssetShortHeader}
onAdd={() => onInstallAsset(openedAssetShortHeader)}
onClose={() => setOpenedAssetShortHeader(null)}
canInstall={!assetBeingInstalled}
isBeingInstalled={
!!assetBeingInstalled &&
assetBeingInstalled.id === openedAssetShortHeader.id
}
/>
)}
</Line>
</Column>
{resourcesFetcher.renderResourceFetcherDialog()}
</>
)}
</ResponsiveWindowMeasurer>
</>
);
};
@@ -8,7 +8,9 @@ import Checkbox from '../../../UI/Checkbox';
import SelectField from '../../../UI/SelectField';
import SelectOption from '../../../UI/SelectOption';
import SemiControlledTextField from '../../../UI/SemiControlledTextField';
import ImagePreview from '../../../ResourcesList/ResourcePreview/ImagePreview';
import ImagePreview, {
isProjectImageResourceSmooth,
} from '../../../ResourcesList/ResourcePreview/ImagePreview';
import ResourceSelector from '../../../ResourcesList/ResourceSelector';
import ResourcesLoader from '../../../ResourcesLoader';
import ShapePreview from './ShapePreview';
@@ -358,7 +360,15 @@ const Physics2Editor = (props: Props) => {
<ImagePreview
resourceName={image}
project={props.project}
resourcesLoader={resourcesLoader}
imageResourceSource={resourcesLoader.getResourceFullUrl(
props.project,
image,
{}
)}
isImageResourceSmooth={isProjectImageResourceSmooth(
props.project,
image
)}
fixedHeight={200}
renderOverlay={overlayProps => {
// The result from `getProperties` is temporary, and because this renderOverlay
@@ -1,7 +1,7 @@
// @flow
import { Trans } from '@lingui/macro';
import React, { Component } from 'react';
import React from 'react';
import { Column, Line, Spacer } from '../../../../UI/Grid';
import ImagePreview from '../../../../ResourcesList/ResourcePreview/ImagePreview';
import Replay from '@material-ui/icons/Replay';
@@ -11,21 +11,7 @@ import Timer from '@material-ui/icons/Timer';
import TextField from '../../../../UI/TextField';
import FlatButton from '../../../../UI/FlatButton';
import Text from '../../../../UI/Text';
type Props = {|
spritesContainer: Object,
resourcesLoader: Object,
project: Object,
timeBetweenFrames: number,
onChangeTimeBetweenFrames: number => void,
|};
type State = {|
currentFrameIndex: number,
currentFrameElapsedTime: number,
paused: boolean,
fps: number,
|};
import useForceUpdate from '../../../../Utils/UseForceUpdate';
const styles = {
timeField: {
@@ -37,99 +23,155 @@ const styles = {
},
};
export default class AnimationPreview extends Component<Props, State> {
state = {
currentFrameIndex: 0,
currentFrameElapsedTime: 0,
paused: false,
fps: Math.round(1 / this.props.timeBetweenFrames),
type Props = {|
resourceNames: string[],
getImageResourceSource: (resourceName: string) => string,
isImageResourceSmooth: (resourceName: string) => boolean,
project: gdProject,
timeBetweenFrames: number,
onChangeTimeBetweenFrames?: number => void,
isLooping: boolean,
hideCheckeredBackground?: boolean,
hideControls?: boolean,
initialZoom?: number,
fixedHeight?: number,
|};
const AnimationPreview = ({
resourceNames,
getImageResourceSource,
isImageResourceSmooth,
project,
timeBetweenFrames,
onChangeTimeBetweenFrames,
isLooping,
hideCheckeredBackground,
hideControls,
initialZoom,
fixedHeight,
}: Props) => {
const forceUdpate = useForceUpdate();
// Use state for elements that don't need to be read from inside the animation callback.
const [fps, setFps] = React.useState<number>(
Math.round(1 / timeBetweenFrames)
);
// Use useRef for mutable variables that we want to persist
// to be readable from inside the animation callback.
const requestRef = React.useRef();
const previousTimeRef = React.useRef();
const currentFrameElapsedTimeRef = React.useRef(0);
const timeBetweenFramesRef = React.useRef(timeBetweenFrames);
const pausedRef = React.useRef(false);
const currentFrameIndexRef = React.useRef(0);
const isLoopingRef = React.useRef(isLooping);
// When outside variables change, we need to update the animation callback.
React.useEffect(
() => {
if (timeBetweenFrames !== timeBetweenFramesRef.current) {
timeBetweenFramesRef.current = timeBetweenFrames;
setFps(Math.round(1 / timeBetweenFrames));
}
if (isLooping !== isLoopingRef.current) {
isLoopingRef.current = isLooping;
}
},
[timeBetweenFrames, isLooping]
);
const replay = () => {
currentFrameIndexRef.current = 0;
currentFrameElapsedTimeRef.current = 0;
pausedRef.current = false;
forceUdpate();
};
nextUpdate = null;
previousUpdateTimeInMs = 0;
// Variables used inside the requestAnimationFrame callback
// must be declared as mutable with useRef, otherwise they
// will not update between calls.
const updateAnimation = React.useCallback(
(updateTimeInMs: number) => {
// Mutable variables used inside the requestAnimationFrame callback
const previousUpdateTimeInMs = previousTimeRef.current;
const currentFrameIndex = currentFrameIndexRef.current;
const currentFrameElapsedTime = currentFrameElapsedTimeRef.current;
const currentTimeBetweenFrames = timeBetweenFramesRef.current;
const paused = pausedRef.current;
const isLooping = isLoopingRef.current;
const numberOfFrames = resourceNames.length;
componentDidMount() {
this.nextUpdate = requestAnimationFrame(this._updateAnimation);
if (previousUpdateTimeInMs) {
const elapsedTime = (updateTimeInMs - previousUpdateTimeInMs) / 1000;
let newFrameIndex = currentFrameIndex;
let newFrameElapsedTime =
currentFrameElapsedTime + (paused ? 0 : elapsedTime);
// Increase frame index if time elapsed is greater than time between frames.
if (newFrameElapsedTime > currentTimeBetweenFrames) {
const count = Math.floor(
newFrameElapsedTime / currentTimeBetweenFrames
);
newFrameIndex += count;
newFrameElapsedTime =
newFrameElapsedTime - count * currentTimeBetweenFrames;
if (newFrameElapsedTime < 0) newFrameElapsedTime = 0;
}
// Reset to 0 if we reached the end of the animation.
if (newFrameIndex >= numberOfFrames) {
newFrameIndex = isLooping
? newFrameIndex % numberOfFrames
: numberOfFrames - 1;
}
if (newFrameIndex < 0) newFrameIndex = 0; //May happen if there is no frame.
currentFrameIndexRef.current = newFrameIndex;
currentFrameElapsedTimeRef.current = newFrameElapsedTime;
// Ensure we trigger an update if the animation changes,
// as the refs will not do it.
if (currentFrameIndex !== newFrameIndex) {
forceUdpate();
}
}
requestRef.current = requestAnimationFrame(updateAnimation);
previousTimeRef.current = updateTimeInMs;
},
[forceUdpate, resourceNames]
);
React.useEffect(
() => {
requestRef.current = requestAnimationFrame(updateAnimation);
return () => {
if (requestRef.current) cancelAnimationFrame(requestRef.current);
};
},
[updateAnimation]
);
// When changing animation, the index can be out of bounds, so reset the animation.
if (currentFrameIndexRef.current >= resourceNames.length) {
currentFrameIndexRef.current = 0;
}
componentWillUnmount() {
if (this.nextUpdate) cancelAnimationFrame(this.nextUpdate);
}
const resourceName = resourceNames[currentFrameIndexRef.current];
replay = () =>
this.setState({
currentFrameIndex: 0,
currentFrameElapsedTime: 0,
paused: false,
});
play = () =>
this.setState({
paused: false,
});
pause = () =>
this.setState({
paused: true,
});
_updateAnimation = (updateTimeInMs: number) => {
const elapsedTime = this.previousUpdateTimeInMs
? (updateTimeInMs - this.previousUpdateTimeInMs) / 1000
: 0;
const { spritesContainer, timeBetweenFrames } = this.props;
const { currentFrameIndex, currentFrameElapsedTime, paused } = this.state;
let newFrameIndex = currentFrameIndex;
let newFrameElapsedTime = currentFrameElapsedTime;
newFrameElapsedTime += paused ? 0 : elapsedTime;
if (newFrameElapsedTime > timeBetweenFrames) {
const count = Math.floor(newFrameElapsedTime / timeBetweenFrames);
newFrameIndex += count;
newFrameElapsedTime = newFrameElapsedTime - count * timeBetweenFrames;
if (newFrameElapsedTime < 0) newFrameElapsedTime = 0;
}
if (newFrameIndex >= spritesContainer.getSpritesCount()) {
newFrameIndex = spritesContainer.isLooping()
? newFrameIndex % spritesContainer.getSpritesCount()
: spritesContainer.getSpritesCount() - 1;
}
if (newFrameIndex < 0) newFrameIndex = 0; //May happen if there is no frame.
this.setState({
currentFrameIndex: newFrameIndex,
currentFrameElapsedTime: newFrameElapsedTime,
});
this.nextUpdate = requestAnimationFrame(this._updateAnimation);
this.previousUpdateTimeInMs = updateTimeInMs;
};
render() {
const {
spritesContainer,
resourcesLoader,
project,
timeBetweenFrames,
onChangeTimeBetweenFrames,
} = this.props;
const { currentFrameIndex, paused, fps } = this.state;
const hasValidSprite =
currentFrameIndex < spritesContainer.getSpritesCount();
const sprite = hasValidSprite
? spritesContainer.getSprite(currentFrameIndex)
: null;
return (
<Column expand noOverflowParent>
<ImagePreview
resourceName={sprite ? sprite.getImageName() : ''}
resourcesLoader={resourcesLoader}
project={project}
/>
return (
<Column expand noOverflowParent>
<ImagePreview
resourceName={resourceName}
imageResourceSource={getImageResourceSource(resourceName)}
isImageResourceSmooth={isImageResourceSmooth(resourceName)}
initialZoom={initialZoom}
project={project}
hideCheckeredBackground={hideCheckeredBackground}
hideControls={hideControls}
fixedHeight={fixedHeight}
/>
{!hideControls && (
<Line noMargin alignItems="center">
<Text>
<Trans>FPS:</Trans>
@@ -141,9 +183,13 @@ export default class AnimationPreview extends Component<Props, State> {
onChange={(e, text) => {
const fps = parseFloat(text);
if (fps > 0) {
this.setState({ fps });
onChangeTimeBetweenFrames(parseFloat((1 / fps).toFixed(4)));
this.replay();
setFps(fps);
const newTimeBetweenFrames = parseFloat((1 / fps).toFixed(4));
timeBetweenFramesRef.current = newTimeBetweenFrames;
if (onChangeTimeBetweenFrames) {
onChangeTimeBetweenFrames(newTimeBetweenFrames);
}
replay();
}
}}
id="direction-time-between-frames"
@@ -157,13 +203,16 @@ export default class AnimationPreview extends Component<Props, State> {
<Timer style={styles.timeIcon} />
<TextField
margin="none"
value={timeBetweenFrames}
value={timeBetweenFramesRef.current}
onChange={(e, text) => {
const time = parseFloat(text);
if (time > 0) {
this.setState({ fps: Math.round(1 / time) });
onChangeTimeBetweenFrames(time);
this.replay();
setFps(Math.round(1 / time));
timeBetweenFramesRef.current = time;
if (onChangeTimeBetweenFrames) {
onChangeTimeBetweenFrames(time);
}
replay();
}
}}
id="direction-time-between-frames"
@@ -177,15 +226,20 @@ export default class AnimationPreview extends Component<Props, State> {
<FlatButton
icon={<Replay />}
label={<Trans>Replay</Trans>}
onClick={this.replay}
onClick={replay}
/>
<FlatButton
icon={paused ? <PlayArrow /> : <Pause />}
label={paused ? 'Play' : 'Pause'}
onClick={paused ? this.play : this.pause}
icon={!!pausedRef.current ? <PlayArrow /> : <Pause />}
label={!!pausedRef.current ? 'Play' : 'Pause'}
onClick={() => {
pausedRef.current = !pausedRef.current;
forceUdpate();
}}
/>
</Line>
</Column>
);
}
}
)}
</Column>
);
};
export default AnimationPreview;
@@ -7,7 +7,9 @@ import { Line, Column } from '../../../../UI/Grid';
import { mapFor } from '../../../../Utils/MapFor';
import PolygonsList from './PolygonsList';
import CollisionMasksPreview from './CollisionMasksPreview';
import ImagePreview from '../../../../ResourcesList/ResourcePreview/ImagePreview';
import ImagePreview, {
isProjectImageResourceSmooth,
} from '../../../../ResourcesList/ResourcePreview/ImagePreview';
import {
getCurrentElements,
allSpritesHaveSameCollisionMasksAs,
@@ -194,6 +196,7 @@ const CollisionMasksEditor = (props: Props) => {
screenSize === 'small' ? verticalMosaicNodes : horizontalMosaicNodes;
if (!props.object.getAnimationsCount()) return null;
const resourceName = hasValidSprite ? sprite.getImageName() : '';
const editors: { [string]: Editor } = {
preview: {
@@ -202,8 +205,16 @@ const CollisionMasksEditor = (props: Props) => {
renderEditor: () => (
<Background>
<ImagePreview
resourceName={hasValidSprite ? sprite.getImageName() : ''}
resourcesLoader={props.resourcesLoader}
resourceName={resourceName}
imageResourceSource={props.resourcesLoader.getResourceFullUrl(
props.project,
resourceName,
{}
)}
isImageResourceSmooth={isProjectImageResourceSmooth(
props.project,
resourceName
)}
project={props.project}
onSize={setCurrentSpriteSize}
renderOverlay={overlayProps =>
@@ -13,6 +13,7 @@ import AnimationPreview from './AnimationPreview';
import ResourcesLoader from '../../../ResourcesLoader';
import { type ResourceExternalEditor } from '../../../ResourcesList/ResourceExternalEditor.flow';
import { ResponsiveWindowMeasurer } from '../../../UI/Reponsive/ResponsiveWindowMeasurer';
import { isProjectImageResourceSmooth } from '../../../ResourcesList/ResourcePreview/ImagePreview';
const styles = {
container: {
@@ -169,13 +170,19 @@ export default class DirectionTools extends Component<Props, State> {
flexBody
>
<AnimationPreview
spritesContainer={direction}
resourcesLoader={resourcesLoader}
resourceNames={direction.getSpriteNames().toJSArray()}
getImageResourceSource={(name: string) =>
resourcesLoader.getResourceFullUrl(project, name, {})
}
isImageResourceSmooth={(name: string) =>
isProjectImageResourceSmooth(project, name)
}
project={project}
timeBetweenFrames={this.state.timeBetweenFrames}
onChangeTimeBetweenFrames={text =>
this.setState({ timeBetweenFrames: text })
}
isLooping={direction.isLooping()}
/>
</Dialog>
)}
@@ -6,7 +6,9 @@ import { Line, Column } from '../../../../UI/Grid';
import { mapFor } from '../../../../Utils/MapFor';
import PointsList from './PointsList';
import PointsPreview from './PointsPreview';
import ImagePreview from '../../../../ResourcesList/ResourcePreview/ImagePreview';
import ImagePreview, {
isProjectImageResourceSmooth,
} from '../../../../ResourcesList/ResourcePreview/ImagePreview';
import {
getCurrentElements,
allSpritesHaveSamePointsAs,
@@ -171,6 +173,7 @@ const PointsEditor = (props: Props) => {
screenSize === 'small' ? verticalMosaicNodes : horizontalMosaicNodes;
if (!props.object.getAnimationsCount()) return null;
const resourceName = hasValidSprite ? sprite.getImageName() : '';
const editors: { [string]: Editor } = {
preview: {
@@ -179,8 +182,16 @@ const PointsEditor = (props: Props) => {
renderEditor: () => (
<Background>
<ImagePreview
resourceName={hasValidSprite ? sprite.getImageName() : ''}
resourcesLoader={props.resourcesLoader}
resourceName={resourceName}
imageResourceSource={props.resourcesLoader.getResourceFullUrl(
props.project,
resourceName,
{}
)}
isImageResourceSmooth={isProjectImageResourceSmooth(
props.project,
resourceName
)}
project={props.project}
renderOverlay={overlayProps =>
hasValidSprite && (
+2
View File
@@ -218,6 +218,8 @@ export default class ObjectsList extends React.Component<Props, State> {
object.setTags(getStringFromTags(this.props.selectedObjectTags));
onObjectCreated(object);
this.forceUpdateList();
};
onAddNewObject = () => {
@@ -129,7 +129,6 @@ export default class ResourcePropertiesEditor extends React.Component<
return (
<ResourcePreview
resourceName={resources[0].getName()}
resourcePath={resources[0].getFile()}
resourcesLoader={resourcesLoader}
project={project}
/>
@@ -4,7 +4,6 @@ import { Trans } from '@lingui/macro';
import IconButton from '../../UI/IconButton';
import Measure from 'react-measure';
import * as React from 'react';
import ResourcesLoader from '../../ResourcesLoader';
import { Column } from '../../UI/Grid';
import MiniToolbar from '../../UI/MiniToolbar';
import ZoomIn from '@material-ui/icons/ZoomIn';
@@ -65,8 +64,9 @@ const styles = {
type Props = {|
project: gdProject,
resourceName: string,
resourcePath?: string,
resourcesLoader: typeof ResourcesLoader,
imageResourceSource: string,
isImageResourceSmooth: boolean,
initialZoom?: number,
fixedHeight?: number,
renderOverlay?: ({|
imageWidth: number,
@@ -76,17 +76,11 @@ type Props = {|
imageZoomFactor: number,
|}) => React.Node,
onSize?: (number, number) => void,
hideCheckeredBackground?: boolean,
hideControls?: boolean,
|};
type State = {|
errored: boolean,
imageWidth: ?number,
imageHeight: ?number,
imageSource: ?string,
imageZoomFactor: number,
|};
const resourceIsSmooth = (
export const isProjectImageResourceSmooth = (
project: gdProject,
resourceName: string
): boolean => {
@@ -97,123 +91,90 @@ const resourceIsSmooth = (
return imageResource.isSmooth();
};
const loadStateFrom = (newProps: {
project: gdProject,
resourceName: string,
resourcesLoader: typeof ResourcesLoader,
}) => {
return {
errored: false,
imageSource: newProps.resourcesLoader.getResourceFullUrl(
newProps.project,
newProps.resourceName,
{}
),
};
};
/**
* Display the preview for a resource of a project with kind "image".
*/
const ImagePreview = (props: Props) => {
const [state, setState] = React.useState<State>({
errored: false,
imageWidth: null,
imageHeight: null,
imageZoomFactor: 1,
...loadStateFrom(props),
});
const isResizeObserverReady = React.useRef<boolean>(false);
React.useEffect(
() => {
setState(state => ({
...state,
...loadStateFrom({
resourceName: props.resourceName,
project: props.project,
resourcesLoader: props.resourcesLoader,
}),
}));
},
[
props.resourceName,
props.project,
props.resourcesLoader,
props.resourcePath,
]
const ImagePreview = ({
project,
resourceName,
imageResourceSource,
isImageResourceSmooth,
fixedHeight,
renderOverlay,
onSize,
hideCheckeredBackground,
hideControls,
initialZoom,
}: Props) => {
const [errored, setErrored] = React.useState(false);
const [imageWidth, setImageWidth] = React.useState(null);
const [imageHeight, setImageHeight] = React.useState(null);
const [imageZoomFactor, setImageZoomFactor] = React.useState(
initialZoom || 1
);
const [isResizeObserverReady, setIsResizeObserverReady] = React.useState(
false
);
const handleImageError = () => {
setState(state => ({ ...state, errored: true }));
setErrored(true);
};
const adaptZoomToImage = (
containerHeight: number,
containerWidth: number
) => {
const { imageHeight, imageWidth } = state;
let zoomFactor;
if (!imageHeight || !imageWidth) {
zoomFactor = 1;
} else {
const idealZoomFactors = [
containerWidth / (imageWidth + 2 * MARGIN),
containerHeight / (imageHeight + 2 * MARGIN),
];
zoomFactor = getBoundedZoomFactor(Math.min(...idealZoomFactors));
}
setState(state => ({
...state,
imageZoomFactor: zoomFactor,
}));
const zoomFactor =
!imageHeight || !imageWidth
? 1
: getBoundedZoomFactor(
Math.min(
containerWidth / (imageWidth + 2 * MARGIN),
containerHeight / (imageHeight + 2 * MARGIN)
)
);
setImageZoomFactor(zoomFactor);
};
const handleImageLoaded = (e: any) => {
const imgElement = e.target;
const imageWidth = imgElement
const newImageWidth = imgElement
? imgElement.naturalWidth || imgElement.clientWidth
: 0;
const imageHeight = imgElement
const newImageHeight = imgElement
? imgElement.naturalHeight || imgElement.clientHeight
: 0;
setState(state => ({ ...state, imageWidth, imageHeight }));
if (props.onSize) props.onSize(imageWidth, imageHeight);
setImageHeight(newImageHeight);
setImageWidth(newImageWidth);
if (onSize) onSize(newImageWidth, newImageHeight);
};
const zoomBy = (imageZoomFactorDelta: number) => {
zoomTo(state.imageZoomFactor + imageZoomFactorDelta);
zoomTo(imageZoomFactor + imageZoomFactorDelta);
};
const zoomTo = (imageZoomFactor: number) => {
setState(state => ({
...state,
imageZoomFactor: getBoundedZoomFactor(imageZoomFactor),
}));
setImageZoomFactor(getBoundedZoomFactor(imageZoomFactor));
};
const theme = React.useContext(GDevelopThemeContext);
const frameBorderColor = theme.imagePreview.frameBorderColor || '#aaa';
const previewBorderColor = theme.imagePreview.borderColor || '#aaa';
return (
<Measure bounds>
{({ contentRect, measureRef }) => {
const containerWidth = contentRect.bounds.width;
const containerHeight = contentRect.bounds.height;
if (
!!containerWidth &&
!!containerHeight &&
!isResizeObserverReady.current
) {
adaptZoomToImage(containerHeight, containerWidth);
isResizeObserverReady.current = true;
// Once the image is loaded, adapt the zoom to the image size.
if (!isResizeObserverReady && !!containerWidth && !!containerHeight) {
if (!initialZoom) {
adaptZoomToImage(containerHeight, containerWidth);
}
setIsResizeObserverReady(true);
}
const { resourceName, renderOverlay, fixedHeight, project } = props;
const { imageHeight, imageWidth, imageSource, imageZoomFactor } = state;
const imageLoaded = !!imageWidth && !!imageHeight && !state.errored;
const imageLoaded = !!imageWidth && !!imageHeight && !errored;
// Centre-align the image and overlays
const imagePositionTop = Math.max(
@@ -236,7 +197,7 @@ const ImagePreview = (props: Props) => {
width: imageWidth ? imageWidth * imageZoomFactor : undefined,
height: imageHeight ? imageHeight * imageZoomFactor : undefined,
visibility: imageLoaded ? undefined : 'hidden', // TODO: Loader
...(!resourceIsSmooth(project, resourceName)
...(!isImageResourceSmooth
? styles.previewImagePixelated
: undefined),
};
@@ -263,57 +224,55 @@ const ImagePreview = (props: Props) => {
return (
<Column expand noMargin useFullHeight>
<MiniToolbar>
<IconButton
onClick={() => zoomBy(-0.2)}
tooltip={t`Zoom out (you can also use Ctrl + Mouse wheel)`}
>
<ZoomOut />
</IconButton>
<div style={styles.sliderContainer}>
<Slider
min={Math.log10(MIN_ZOOM_FACTOR)}
max={Math.log10(MAX_ZOOM_FACTOR)}
step={0.05}
value={Math.log10(state.imageZoomFactor)}
onChange={value => {
console.log(value);
zoomTo(Math.pow(10, value));
}}
/>
</div>
<IconButton
onClick={() => zoomBy(+0.2)}
tooltip={t`Zoom in (you can also use Ctrl + Mouse wheel)`}
>
<ZoomIn />
</IconButton>
<IconButton
onClick={() => zoomTo(1)}
tooltip={t`Restore original size`}
>
<ZoomOutMap />
</IconButton>
</MiniToolbar>
{!hideControls && (
<MiniToolbar>
<IconButton
onClick={() => zoomBy(-0.2)}
tooltip={t`Zoom out (you can also use Ctrl + Mouse wheel)`}
>
<ZoomOut />
</IconButton>
<div style={styles.sliderContainer}>
<Slider
min={Math.log10(MIN_ZOOM_FACTOR)}
max={Math.log10(MAX_ZOOM_FACTOR)}
step={0.05}
value={Math.log10(imageZoomFactor)}
onChange={value => {
zoomTo(Math.pow(10, value));
}}
/>
</div>
<IconButton
onClick={() => zoomBy(+0.2)}
tooltip={t`Zoom in (you can also use Ctrl + Mouse wheel)`}
>
<ZoomIn />
</IconButton>
<IconButton
onClick={() => zoomTo(1)}
tooltip={t`Restore original size`}
>
<ZoomOutMap />
</IconButton>
</MiniToolbar>
)}
<div
style={{
...styles.contentContainer,
height: fixedHeight || '100%',
}}
>
<CheckeredBackground />
{!hideCheckeredBackground && <CheckeredBackground />}
<div
dir={
'ltr' /* Force LTR layout to avoid issues with image positioning */
}
style={{
...styles.imagePreviewContainer,
border: `1px solid ${previewBorderColor}`,
}}
style={styles.imagePreviewContainer}
ref={measureRef}
onWheel={event => {
const { deltaY } = event;
if (shouldZoom(event)) {
if (!hideControls && shouldZoom(event)) {
zoomBy(-deltaY / 500);
event.preventDefault();
event.stopPropagation();
@@ -322,18 +281,18 @@ const ImagePreview = (props: Props) => {
}
}}
>
{!!state.errored && (
{!!errored && (
<PlaceholderMessage>
<Text>
<Trans>Unable to load the image</Trans>
</Text>
</PlaceholderMessage>
)}
{!state.errored && (
{!errored && (
<CorsAwareImage
style={imageStyle}
alt={resourceName}
src={imageSource}
src={imageResourceSource}
onError={handleImageError}
onLoad={handleImageLoaded}
/>
@@ -2,7 +2,7 @@
import * as React from 'react';
import ResourcesLoader from '../../ResourcesLoader';
import { type ResourceKind } from '../ResourceSource';
import ImagePreview from './ImagePreview';
import ImagePreview, { isProjectImageResourceSmooth } from './ImagePreview';
import GenericIconPreview from './GenericIconPreview';
import Audiotrack from '@material-ui/icons/Audiotrack';
import InsertDriveFile from '@material-ui/icons/InsertDriveFile';
@@ -12,7 +12,6 @@ import FontDownload from '@material-ui/icons/FontDownload';
type Props = {|
project: gdProject,
resourceName: string,
resourcePath?: string,
resourcesLoader: typeof ResourcesLoader,
onSize?: (number, number) => void,
|};
@@ -31,8 +30,7 @@ export default class ResourcePreview extends React.PureComponent<Props, State> {
UNSAFE_componentWillReceiveProps(newProps: Props) {
if (
newProps.resourceName !== this.props.resourceName ||
newProps.project !== this.props.project ||
newProps.resourcePath !== this.props.resourcePath
newProps.project !== this.props.project
) {
this.setState(this._loadFrom(newProps));
}
@@ -59,9 +57,16 @@ export default class ResourcePreview extends React.PureComponent<Props, State> {
<ImagePreview
project={this.props.project}
resourceName={this.props.resourceName}
resourcesLoader={this.props.resourcesLoader}
imageResourceSource={this.props.resourcesLoader.getResourceFullUrl(
this.props.project,
this.props.resourceName,
{}
)}
isImageResourceSmooth={isProjectImageResourceSmooth(
this.props.project,
this.props.resourceName
)}
onSize={this.props.onSize}
resourcePath={this.props.resourcePath}
/>
);
case 'audio':
+7 -5
View File
@@ -51,7 +51,7 @@ type MemoizedTagsTreeProps = {|
allItemsLabel: React.Node,
chosenCategory: ?ChosenCategory,
setChosenCategory: (?ChosenCategory) => void,
onChoiceChange?: () => void,
onChoiceChange?: (?ChosenCategory) => void,
allFilters: Filters,
|};
@@ -70,19 +70,21 @@ const MemoizedTagsTree = React.memo<MemoizedTagsTreeProps>(function TagsTree({
: ''
}
defaultExpanded={[]}
onNodeSelect={() => onChoiceChange && onChoiceChange()}
onNodeSelect={() => {}}
>
<TreeItem
nodeId=""
label={allItemsLabel}
onLabelClick={() => {
setChosenCategory(null);
onChoiceChange && onChoiceChange();
}}
/>
<TagsTreeItems
tagsTreeNodes={allFilters.tagsTree}
onChoose={setChosenCategory}
onChoose={category => {
setChosenCategory(category);
if (onChoiceChange) onChoiceChange(category);
}}
parentNodes={[]}
/>
</TreeView>
@@ -92,7 +94,7 @@ const MemoizedTagsTree = React.memo<MemoizedTagsTreeProps>(function TagsTree({
type Props = {|
allItemsLabel: React.Node,
filtersState: FiltersState,
onChoiceChange?: () => void,
onChoiceChange?: (?ChosenCategory) => void,
allFilters: ?Filters,
error: ?Error,
|};
+21 -6
View File
@@ -6,6 +6,18 @@ import TextField from '@material-ui/core/TextField';
import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow';
import { computeTextFieldStyleProps } from './TextField';
import { MarkdownText } from './MarkdownText';
import { makeStyles } from '@material-ui/core';
const INVALID_VALUE = '';
const stopPropagation = event => event.stopPropagation();
const useSelectCenterStyles = makeStyles({
root: {
textAlign: 'center',
},
});
export type SelectFieldInterface = {| focus: () => void |};
type ValueProps = {|
value: number | string,
@@ -32,6 +44,8 @@ type Props = {|
},
inputStyle?: {| fontSize?: 14, color?: string |},
margin?: 'none' | 'dense',
disableUnderline?: true,
textAlign?: 'center',
floatingLabelText?: React.Node,
helperMarkdownText?: ?string,
@@ -41,11 +55,6 @@ type Props = {|
hintText?: MessageDescriptor,
|};
export type SelectFieldInterface = {| focus: () => void |};
const INVALID_VALUE = '';
const stopPropagation = event => event.stopPropagation();
/**
* A select field based on Material-UI select field.
* To be used with `SelectOption`.
@@ -61,6 +70,7 @@ const SelectField = React.forwardRef<Props, SelectFieldInterface>(
React.useImperativeHandle(ref, () => ({
focus,
}));
const selectCenterStyles = useSelectCenterStyles();
const onChange = props.onChange || undefined;
@@ -105,12 +115,16 @@ const SelectField = React.forwardRef<Props, SelectFieldInterface>(
}
: undefined
}
InputProps={{ style: props.inputStyle }}
InputProps={{
style: props.inputStyle,
disableUnderline: !!props.disableUnderline,
}}
InputLabelProps={{
shrink: true,
}}
SelectProps={{
native: true,
classes: props.textAlign === 'center' ? selectCenterStyles : {},
}}
style={props.style}
inputRef={inputRef}
@@ -129,4 +143,5 @@ const SelectField = React.forwardRef<Props, SelectFieldInterface>(
);
}
);
export default SelectField;
@@ -8,10 +8,7 @@ import { User as FirebaseUser } from 'firebase/auth';
import { type Profile } from '../Utils/GDevelopServices/Authentication';
import { type Release } from '../Utils/GDevelopServices/Release';
import { type Build } from '../Utils/GDevelopServices/Build';
import {
type ExtensionShortHeader,
type SerializedExtension,
} from '../Utils/GDevelopServices/Extension';
import { type ExtensionShortHeader } from '../Utils/GDevelopServices/Extension';
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import { type Game, type ShowcasedGame } from '../Utils/GDevelopServices/Game';
import { type GameMetrics } from '../Utils/GDevelopServices/Analytics';
@@ -430,16 +427,16 @@ export const fakeAsset1: Asset = {
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
animationsCount: 6,
maxFramesCount: 6,
height: 36,
width: 36,
objectType: 'sprite',
gdevelopVersion: '5.0.0-beta100',
version: '1.0.0',
authors: ['test author'],
license: 'MIT',
tags: ['space shooter', 'tag2'],
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
dominantColors: [255],
objectAssets: [
{
@@ -457,16 +454,16 @@ export const fakeAssetWithBehaviorCustomizations1: Asset = {
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
animationsCount: 6,
maxFramesCount: 6,
height: 36,
width: 36,
objectType: 'sprite',
gdevelopVersion: '5.0.0-beta100',
version: '1.0.0',
authors: ['test author'],
license: 'MIT',
tags: ['space shooter', 'tag2'],
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
dominantColors: [255],
objectAssets: [
{
@@ -504,16 +501,16 @@ export const fakeAssetWithUnknownBehaviorCustomizations1: Asset = {
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
animationsCount: 6,
maxFramesCount: 6,
height: 36,
width: 36,
objectType: 'sprite',
gdevelopVersion: '5.0.0-beta100',
version: '1.0.0',
authors: ['test author'],
license: 'MIT',
tags: ['space shooter', 'tag2'],
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
dominantColors: [255],
objectAssets: [
{
@@ -540,16 +537,16 @@ export const fakeAssetWithFlashBehaviorCustomizations1: Asset = {
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
animationsCount: 6,
maxFramesCount: 6,
height: 36,
width: 36,
objectType: 'sprite',
gdevelopVersion: '5.0.0-beta100',
version: '1.0.0',
authors: ['test author'],
license: 'MIT',
tags: ['space shooter', 'tag2'],
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
dominantColors: [255],
objectAssets: [
{
@@ -576,16 +573,16 @@ export const fakeAssetWithEventCustomizationsAndFlashExtension1: Asset = {
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
animationsCount: 6,
maxFramesCount: 6,
height: 36,
width: 36,
objectType: 'sprite',
gdevelopVersion: '5.0.0-beta100',
authors: ['test author'],
license: 'MIT',
version: '1.0.0',
tags: ['space shooter', 'tag2'],
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
dominantColors: [255],
objectAssets: [
{
@@ -655,16 +652,16 @@ export const fakeAssetWithEventCustomizationsAndUnknownExtension1: Asset = {
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
animationsCount: 6,
maxFramesCount: 6,
height: 36,
width: 36,
objectType: 'sprite',
gdevelopVersion: '5.0.0-beta100',
authors: ['test author'],
license: 'MIT',
version: '1.0.0',
tags: ['space shooter', 'tag2'],
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
dominantColors: [255],
objectAssets: [
{
@@ -708,48 +705,27 @@ export const fakeAssetWithEventCustomizationsAndUnknownExtension1: Asset = {
};
export const fakeAssetShortHeader1: AssetShortHeader = {
id: '123',
name: 'My spaceship',
shortDescription:
'A spaceship that can be moved with the keyboard or by touching the screen',
previewImageUrls: ['res/GD-logo.png'],
tags: ['space shooter', 'tag2'],
license: 'MIT',
id: 'a4eb5460ffc062ece1f3ff45d24b07e40e9d4247d21602de70973049eb4f6ee5',
license:
'CC-BY 4.0 (Attribution to the artist is required, click for details)',
animationsCount: 6,
maxFramesCount: 6,
height: 36,
width: 36,
name: 'Dino Doux',
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
dominantColors: [255],
};
export const fakeAssetShortHeader2: AssetShortHeader = {
id: '456',
name: 'Zombie',
shortDescription: 'A zombie attacking the player and wandering around.',
previewImageUrls: ['res/GD-logo.png'],
tags: ['survival', 'tag2'],
license: 'MIT',
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
dominantColors: [255],
};
export const fakeAssetShortHeader3: AssetShortHeader = {
id: '789',
name: 'Sword',
shortDescription: 'A small sword.',
previewImageUrls: ['res/GD-logo.png'],
tags: ['medieval', 'tag2'],
license: 'MIT',
objectType: 'sprite',
animationsCount: 1,
maxFramesCount: 1,
width: 32,
height: 32,
previewImageUrls: [
'https://resources.gdevelop-app.com/assets/24x24 Dino Characters/Dino Doux_Crouch.png',
],
shortDescription: 'with 6 animations',
tags: [
'24x24 dino characters',
'side view',
'pixel art',
'character',
'player',
'enemy',
],
dominantColors: [255],
};
@@ -8,7 +8,7 @@ import { AssetDetails } from '../../../../AssetStore/AssetDetails';
import { fakeAssetShortHeader1 } from '../../../../fixtures/GDevelopServicesTestData';
import { testProject } from '../../../GDevelopJsInitializerDecorator';
import fakeResourceExternalEditors from '../../../FakeResourceExternalEditors';
const gd: libGDevelop = global.gd;
import { AssetStoreStateProvider } from '../../../../AssetStore/AssetStoreContext';
export default {
title: 'AssetStore/AssetStore/AssetDetails',
@@ -17,39 +17,37 @@ export default {
};
export const Default = () => (
<AssetDetails
canInstall={true}
isBeingInstalled={false}
onAdd={action('onAdd')}
onClose={action('onClose')}
assetShortHeader={fakeAssetShortHeader1}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
resourceExternalEditors={fakeResourceExternalEditors}
onChooseResource={() => {
action('onChooseResource');
return Promise.reject();
}}
resourceSources={[]}
/>
<AssetStoreStateProvider>
<AssetDetails
project={testProject.project}
layout={testProject.testLayout}
objectsContainer={testProject.testLayout}
resourceSources={[]}
resourceExternalEditors={fakeResourceExternalEditors}
onTagSelection={() => {}}
assetShortHeader={fakeAssetShortHeader1}
onAdd={() => {}}
onClose={() => {}}
canInstall={true}
isBeingInstalled={false}
/>
</AssetStoreStateProvider>
);
export const BeingInstalled = () => (
<AssetDetails
canInstall={false}
isBeingInstalled={true}
onAdd={action('onAdd')}
onClose={action('onClose')}
assetShortHeader={fakeAssetShortHeader1}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
resourceExternalEditors={fakeResourceExternalEditors}
onChooseResource={() => {
action('onChooseResource');
return Promise.reject();
}}
resourceSources={[]}
/>
<AssetStoreStateProvider>
<AssetDetails
project={testProject.project}
layout={testProject.testLayout}
objectsContainer={testProject.testLayout}
resourceSources={[]}
resourceExternalEditors={fakeResourceExternalEditors}
onTagSelection={() => {}}
assetShortHeader={fakeAssetShortHeader1}
onAdd={() => {}}
onClose={() => {}}
canInstall={true}
isBeingInstalled={true}
/>
</AssetStoreStateProvider>
);
@@ -20,6 +20,11 @@ export const Default = () => (
onOpenDetails={action('onOpenDetails')}
events={testProject.testLayout.getEvents()}
project={testProject.project}
layout={testProject.testLayout}
onChooseResource={() => Promise.reject('unimplemented')}
resourceSources={[]}
onObjectAddedFromAsset={() => {}}
resourceExternalEditors={[]}
objectsContainer={testProject.testLayout}
/>
</AssetStoreStateProvider>