From fbb7f16d29e80442fa3d9501490b18b7d448e67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Fri, 27 May 2022 12:58:42 +0200 Subject: [PATCH] Rework the Asset Store details page to show a preview of the animation(s) of the object (#3930) --- .../Builtin/SpriteExtension/Direction.cpp | 11 + .../Builtin/SpriteExtension/Direction.h | 7 + GDevelop.js/Bindings/Bindings.idl | 1 + GDevelop.js/types/gddirection.js | 1 + newIDE/app/src/AssetStore/AssetDetails.js | 363 +++++++++++----- newIDE/app/src/AssetStore/HexColorField.js | 2 +- newIDE/app/src/AssetStore/NewObjectDialog.js | 118 +----- newIDE/app/src/AssetStore/index.js | 399 +++++++++++++----- .../Editors/Physics2Editor/index.js | 14 +- .../SpriteEditor/AnimationPreview/index.js | 290 +++++++------ .../CollisionMasksEditor/index.js | 17 +- .../Editors/SpriteEditor/DirectionTools.js | 11 +- .../SpriteEditor/PointsEditor/index.js | 17 +- newIDE/app/src/ObjectsList/index.js | 2 + .../ResourcePropertiesEditor/index.js | 1 - .../ResourcePreview/ImagePreview.js | 223 ++++------ .../ResourcesList/ResourcePreview/index.js | 17 +- newIDE/app/src/UI/Search/CategoryChooser.js | 12 +- newIDE/app/src/UI/SelectField.js | 27 +- .../src/fixtures/GDevelopServicesTestData.js | 126 +++--- .../AssetStore/AssetDetails.stories.js | 64 ++- .../AssetStore/AssetStore.stories.js | 5 + 22 files changed, 1020 insertions(+), 708 deletions(-) diff --git a/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.cpp b/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.cpp index d519718e63..4a87038d23 100644 --- a/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.cpp +++ b/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.cpp @@ -4,8 +4,10 @@ * reserved. This project is released under the MIT License. */ #include "GDCore/Extensions/Builtin/SpriteExtension/Direction.h" + #include #include + #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& Direction::GetSpriteNames() const { + static std::vector 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); } diff --git a/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.h b/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.h index cf949422c7..aa2b9ae049 100644 --- a/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.h +++ b/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.h @@ -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& GetSpriteNames() const; + /** * \brief Check if the direction contains sprites. * diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index ec03d2d846..620cd4e61c 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -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); diff --git a/GDevelop.js/types/gddirection.js b/GDevelop.js/types/gddirection.js index 1faef70611..7bf36dc10a 100644 --- a/GDevelop.js/types/gddirection.js +++ b/GDevelop.js/types/gddirection.js @@ -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; diff --git a/newIDE/app/src/AssetStore/AssetDetails.js b/newIDE/app/src/AssetStore/AssetDetails.js index 7ee0b89def..8ecd56e84d 100644 --- a/newIDE/app/src/AssetStore/AssetDetails.js +++ b/newIDE/app/src/AssetStore/AssetDetails.js @@ -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, - onChooseResource: ChooseResourceFunction, resourceExternalEditors: Array, + 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(null); + const [isAssetAdded, setIsAssetAdded] = React.useState(false); + const [ + selectedAnimationName, + setSelectedAnimationName, + ] = React.useState(null); const [error, setError] = React.useState(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 = @@ -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 ( - Add an object from the store} - onRequestClose={onClose} - actions={[ - Back} onClick={onClose} />, - - } - label={Add to the game} - onClick={onAddAsset} - disabled={!canAddAsset} - id="add-asset-button" - /> - , - ]} - onApply={onAddAsset} - id={'asset-details-dialog'} - > + - - - {windowWidth => ( -
- - -
- )} -
- -
+ + + {assetShortHeader.name} - {' '} - {assetShortHeader.shortDescription && ( - - -{' '} - - {assetShortHeader.shortDescription} - - - )} -
- - {assetShortHeader.tags.map(tag => ( - - ))} - - {asset ? ( - + + + {asset && ( - By:{' '} + by{' '} {!!assetAuthors && assetAuthors.map(author => { return ( @@ -227,10 +218,184 @@ export const AssetDetails = ({ ); })} + )} + + +
+ {assetShortHeader.tags.slice(0, 5).map((tag, index) => ( + + {index !== 0 && } + { + onTagSelection(tag); + }} + /> + + ))} + {assetShortHeader.tags.length > 5 && ( + <> + + + + {assetShortHeader.tags.length - 5} tag(s) + + } + /> + + )} +
+
+
+ + + Adding to my project... + ) : isAssetAdded ? ( + Object added to my project + ) : ( + Add object to my project + ) + } + onClick={onAddAsset} + disabled={!canAddAsset} + id="add-asset-button" + /> + + + + + +
+ {asset ? ( + <> + {asset.objectType === 'sprite' && + animationResources && + direction && ( + 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') && ( + + )} + + ) : ( + + )} +
+ {assetAnimations && + assetAnimations.length > 1 && + typeof selectedAnimationName === 'string' && ( + + +
+ { + const previousAnimationIndex = assetAnimations.findIndex( + ({ name }) => name === selectedAnimationName + ); + const newAnimationIndex = + previousAnimationIndex === 0 + ? assetAnimations.length - 1 + : previousAnimationIndex - 1; + setSelectedAnimationName( + assetAnimations[newAnimationIndex].name + ); + }} + > + + +
+ + { + setSelectedAnimationName(newAnimationName); + }} + fullWidth + textAlign="center" + disableUnderline + > + {assetAnimations.map(animation => ( + + ))} + +
+ { + const previousAnimationIndex = assetAnimations.findIndex( + ({ name }) => name === selectedAnimationName + ); + const newAnimationIndex = + previousAnimationIndex === + assetAnimations.length - 1 + ? 0 + : previousAnimationIndex + 1; + setSelectedAnimationName( + assetAnimations[newAnimationIndex].name + ); + }} + > + + +
+
+
+ )} +
+ + {asset ? ( + {!!assetLicense && ( - License:{' '} + Type of License:{' '} {
-
+ ); }; diff --git a/newIDE/app/src/AssetStore/HexColorField.js b/newIDE/app/src/AssetStore/HexColorField.js index d735b30b31..5574fb720e 100644 --- a/newIDE/app/src/AssetStore/HexColorField.js +++ b/newIDE/app/src/AssetStore/HexColorField.js @@ -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)} /> diff --git a/newIDE/app/src/AssetStore/NewObjectDialog.js b/newIDE/app/src/AssetStore/NewObjectDialog.js index c0b3970d37..281f63910e 100644 --- a/newIDE/app/src/AssetStore/NewObjectDialog.js +++ b/newIDE/app/src/AssetStore/NewObjectDialog.js @@ -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 - ); - 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(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({ > - Search Asset Store} value="asset-store" /> + Asset Store} value="asset-store" /> New object from scratch} 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({ )} - {openedAsset !== null ? ( - onInstallAsset(openedAsset)} - onClose={() => setOpenedAsset(null)} - canInstall={!assetBeingInstalled} - isBeingInstalled={ - !!assetBeingInstalled && assetBeingInstalled.id === openedAsset.id - } - /> - ) : null} - {resourcesFetcher.renderResourceFetcherDialog()} - - The object was added to the list of objects. You can now use it on - the scene, in events, and customize it. - - } - show={assetWasInstalled} - /> ); } diff --git a/newIDE/app/src/AssetStore/index.js b/newIDE/app/src/AssetStore/index.js index 5e232e7c73..7d01241275 100644 --- a/newIDE/app/src/AssetStore/index.js +++ b/newIDE/app/src/AssetStore/index.js @@ -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, + resourceExternalEditors: Array, + 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(null); const shouldAutofocusSearchbar = useShouldAutofocusSearchbar(); + const [ + openedAssetShortHeader, + setOpenedAssetShortHeader, + ] = React.useState(null); + const [isFiltersPanelOpen, setIsFiltersPanelOpen] = React.useState(false); const [isOnHomePage, setIsOnHomePage] = React.useState(true); - const [isFiltersOpen, setIsFiltersOpen] = React.useState(false); + + const [ + assetBeingInstalled, + setAssetBeingInstalled, + ] = React.useState(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 ( - - {windowWidth => ( - - setIsOnHomePage(false)} - style={styles.searchBar} - ref={searchBar} - id="asset-store-search-bar" - /> - {!isOnHomePage && } - - {isOnHomePage ? ( - - - Discover - - - ) : ( - } - label={Back to discover} - primary={false} - onClick={() => { - filtersState.setChosenCategory(null); - setIsFiltersOpen(false); - setIsOnHomePage(true); + <> + + {windowWidth => ( + <> + + { + if (isOnHomePage) setIsOnHomePage(false); }} + style={styles.searchBar} + ref={searchBar} + id="asset-store-search-bar" /> - )} - - - - {!isFiltersOpen ? ( - - setIsFiltersOpen(true)}> - - - - ) : ( - - - - - - - Object filters - - - - setIsFiltersOpen(false)}> - - - - - setIsOnHomePage(false)} - /> - - - )} - - {isOnHomePage && assetPacks ? ( - { - const chosenCategory = { - node: { name: tag, allChildrenTags: [], children: [] }, - parentNodes: [], - }; - sendAssetPackOpened(tag); - filtersState.setChosenCategory(chosenCategory); - setIsFiltersOpen(true); - setIsOnHomePage(false); - }} - /> - ) : ( - ( - onOpenDetails(assetShortHeader)} - assetShortHeader={assetShortHeader} + {!isOnHomePage && } + + {isOnHomePage ? ( + + + Discover + + + ) : ( + } + label={ + openedAssetShortHeader ? ( + Back + ) : ( + Back to discover + ) + } + primary={false} + onClick={() => { + if (openedAssetShortHeader) { + // Going back from Asset page to search. + setOpenedAssetShortHeader(null); + } else { + // Going back from search to home. + resetToDefault(); + } + }} /> )} - noResultPlaceholder={ - clearAllFilters(assetFiltersState)} - /> + + - )} - - - )} - + > + {!openedAssetShortHeader && ( // Don't show filters on asset page. + + {!isFiltersPanelOpen ? ( + + setIsFiltersPanelOpen(true)}> + + + + ) : ( + + + + + + + Object filters + + + + setIsFiltersPanelOpen(false)} + > + + + + + { + if (isOnHomePage) setIsOnHomePage(false); + }} + /> + + + )} + + )} + {isOnHomePage && !assetPacks && } + {isOnHomePage && assetPacks && ( + + )} + {!isOnHomePage && !openedAssetShortHeader && ( + ( + { + sendAssetOpened({ + id: assetShortHeader.id, + name: assetShortHeader.name, + }); + setOpenedAssetShortHeader(assetShortHeader); + }} + assetShortHeader={assetShortHeader} + /> + )} + noResultPlaceholder={ + clearAllFilters(assetFiltersState)} + /> + } + /> + )} + {openedAssetShortHeader && ( + onInstallAsset(openedAssetShortHeader)} + onClose={() => setOpenedAssetShortHeader(null)} + canInstall={!assetBeingInstalled} + isBeingInstalled={ + !!assetBeingInstalled && + assetBeingInstalled.id === openedAssetShortHeader.id + } + /> + )} + + + {resourcesFetcher.renderResourceFetcherDialog()} + + )} + + ); }; diff --git a/newIDE/app/src/BehaviorsEditor/Editors/Physics2Editor/index.js b/newIDE/app/src/BehaviorsEditor/Editors/Physics2Editor/index.js index f3e4416bc9..a81210c7ce 100644 --- a/newIDE/app/src/BehaviorsEditor/Editors/Physics2Editor/index.js +++ b/newIDE/app/src/BehaviorsEditor/Editors/Physics2Editor/index.js @@ -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) => { { // The result from `getProperties` is temporary, and because this renderOverlay diff --git a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/AnimationPreview/index.js b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/AnimationPreview/index.js index 83d2f01811..ff6745de6f 100644 --- a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/AnimationPreview/index.js +++ b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/AnimationPreview/index.js @@ -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 { - 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( + 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 ( - - + return ( + + + {!hideControls && ( FPS: @@ -141,9 +183,13 @@ export default class AnimationPreview extends Component { 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 { { 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 { } label={Replay} - onClick={this.replay} + onClick={replay} /> : } - label={paused ? 'Play' : 'Pause'} - onClick={paused ? this.play : this.pause} + icon={!!pausedRef.current ? : } + label={!!pausedRef.current ? 'Play' : 'Pause'} + onClick={() => { + pausedRef.current = !pausedRef.current; + forceUdpate(); + }} /> - - ); - } -} + )} + + ); +}; + +export default AnimationPreview; diff --git a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/CollisionMasksEditor/index.js b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/CollisionMasksEditor/index.js index 1b6398918f..282a7a75f3 100644 --- a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/CollisionMasksEditor/index.js +++ b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/CollisionMasksEditor/index.js @@ -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: () => ( diff --git a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/DirectionTools.js b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/DirectionTools.js index 6fb35820be..5ce6f2fb7f 100644 --- a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/DirectionTools.js +++ b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/DirectionTools.js @@ -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 { flexBody > + 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()} /> )} diff --git a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/PointsEditor/index.js b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/PointsEditor/index.js index 50d7852855..c174bb0581 100644 --- a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/PointsEditor/index.js +++ b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/PointsEditor/index.js @@ -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: () => ( hasValidSprite && ( diff --git a/newIDE/app/src/ObjectsList/index.js b/newIDE/app/src/ObjectsList/index.js index ef3110bb33..81a6c8dbcc 100644 --- a/newIDE/app/src/ObjectsList/index.js +++ b/newIDE/app/src/ObjectsList/index.js @@ -218,6 +218,8 @@ export default class ObjectsList extends React.Component { object.setTags(getStringFromTags(this.props.selectedObjectTags)); onObjectCreated(object); + + this.forceUpdateList(); }; onAddNewObject = () => { diff --git a/newIDE/app/src/ResourcesEditor/ResourcePropertiesEditor/index.js b/newIDE/app/src/ResourcesEditor/ResourcePropertiesEditor/index.js index 96edd66777..d0bcd431f2 100644 --- a/newIDE/app/src/ResourcesEditor/ResourcePropertiesEditor/index.js +++ b/newIDE/app/src/ResourcesEditor/ResourcePropertiesEditor/index.js @@ -129,7 +129,6 @@ export default class ResourcePropertiesEditor extends React.Component< return ( diff --git a/newIDE/app/src/ResourcesList/ResourcePreview/ImagePreview.js b/newIDE/app/src/ResourcesList/ResourcePreview/ImagePreview.js index 2b7eb37f01..16837dffb4 100644 --- a/newIDE/app/src/ResourcesList/ResourcePreview/ImagePreview.js +++ b/newIDE/app/src/ResourcesList/ResourcePreview/ImagePreview.js @@ -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({ - errored: false, - imageWidth: null, - imageHeight: null, - imageZoomFactor: 1, - ...loadStateFrom(props), - }); - const isResizeObserverReady = React.useRef(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 ( {({ 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 ( - - zoomBy(-0.2)} - tooltip={t`Zoom out (you can also use Ctrl + Mouse wheel)`} - > - - -
- { - console.log(value); - zoomTo(Math.pow(10, value)); - }} - /> -
- zoomBy(+0.2)} - tooltip={t`Zoom in (you can also use Ctrl + Mouse wheel)`} - > - - - zoomTo(1)} - tooltip={t`Restore original size`} - > - - -
+ {!hideControls && ( + + zoomBy(-0.2)} + tooltip={t`Zoom out (you can also use Ctrl + Mouse wheel)`} + > + + +
+ { + zoomTo(Math.pow(10, value)); + }} + /> +
+ zoomBy(+0.2)} + tooltip={t`Zoom in (you can also use Ctrl + Mouse wheel)`} + > + + + zoomTo(1)} + tooltip={t`Restore original size`} + > + + +
+ )}
- + {!hideCheckeredBackground && }
{ 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 && ( Unable to load the image )} - {!state.errored && ( + {!errored && ( diff --git a/newIDE/app/src/ResourcesList/ResourcePreview/index.js b/newIDE/app/src/ResourcesList/ResourcePreview/index.js index 0ed22177de..06f11ec6c9 100644 --- a/newIDE/app/src/ResourcesList/ResourcePreview/index.js +++ b/newIDE/app/src/ResourcesList/ResourcePreview/index.js @@ -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 { 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 { ); case 'audio': diff --git a/newIDE/app/src/UI/Search/CategoryChooser.js b/newIDE/app/src/UI/Search/CategoryChooser.js index 0004021aaf..acf20ce051 100644 --- a/newIDE/app/src/UI/Search/CategoryChooser.js +++ b/newIDE/app/src/UI/Search/CategoryChooser.js @@ -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(function TagsTree({ : '' } defaultExpanded={[]} - onNodeSelect={() => onChoiceChange && onChoiceChange()} + onNodeSelect={() => {}} > { setChosenCategory(null); - onChoiceChange && onChoiceChange(); }} /> { + setChosenCategory(category); + if (onChoiceChange) onChoiceChange(category); + }} parentNodes={[]} /> @@ -92,7 +94,7 @@ const MemoizedTagsTree = React.memo(function TagsTree({ type Props = {| allItemsLabel: React.Node, filtersState: FiltersState, - onChoiceChange?: () => void, + onChoiceChange?: (?ChosenCategory) => void, allFilters: ?Filters, error: ?Error, |}; diff --git a/newIDE/app/src/UI/SelectField.js b/newIDE/app/src/UI/SelectField.js index 94c5409959..3527c3e679 100644 --- a/newIDE/app/src/UI/SelectField.js +++ b/newIDE/app/src/UI/SelectField.js @@ -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( React.useImperativeHandle(ref, () => ({ focus, })); + const selectCenterStyles = useSelectCenterStyles(); const onChange = props.onChange || undefined; @@ -105,12 +115,16 @@ const SelectField = React.forwardRef( } : 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( ); } ); + export default SelectField; diff --git a/newIDE/app/src/fixtures/GDevelopServicesTestData.js b/newIDE/app/src/fixtures/GDevelopServicesTestData.js index a8c5aca5b4..7aad1290ee 100644 --- a/newIDE/app/src/fixtures/GDevelopServicesTestData.js +++ b/newIDE/app/src/fixtures/GDevelopServicesTestData.js @@ -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], }; diff --git a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetDetails.stories.js b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetDetails.stories.js index ae9dd7aef8..3d4df8af2a 100644 --- a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetDetails.stories.js +++ b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetDetails.stories.js @@ -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 = () => ( - { - action('onChooseResource'); - return Promise.reject(); - }} - resourceSources={[]} - /> + + {}} + assetShortHeader={fakeAssetShortHeader1} + onAdd={() => {}} + onClose={() => {}} + canInstall={true} + isBeingInstalled={false} + /> + ); export const BeingInstalled = () => ( - { - action('onChooseResource'); - return Promise.reject(); - }} - resourceSources={[]} - /> + + {}} + assetShortHeader={fakeAssetShortHeader1} + onAdd={() => {}} + onClose={() => {}} + canInstall={true} + isBeingInstalled={true} + /> + ); diff --git a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetStore.stories.js b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetStore.stories.js index 4f8a3b96a8..85a0d4bae8 100644 --- a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetStore.stories.js +++ b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetStore.stories.js @@ -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} />