diff --git a/newIDE/app/src/PlatformSpecificAssetsEditor/ImageResizer.js b/newIDE/app/src/PlatformSpecificAssetsEditor/ImageResizer.js index 916fe7123d20..047bb7532211 100644 --- a/newIDE/app/src/PlatformSpecificAssetsEditor/ImageResizer.js +++ b/newIDE/app/src/PlatformSpecificAssetsEditor/ImageResizer.js @@ -1,33 +1,13 @@ //@flow -import optionalRequire from '../Utils/OptionalRequire'; - -const fs = optionalRequire('fs-extra'); - -export const getImageFromPath = (path: string): Promise => { - const imageElement = document.createElement('img'); - - const file = fs.readFileSync(path, { encoding: 'base64' }); - - return new Promise((resolve, reject) => { - imageElement.addEventListener('error', (event: Event) => { - reject(event); - }); - imageElement.addEventListener('load', () => { - resolve(imageElement); - }); - imageElement.src = `data:image/png;base64,${file}`; - }); -}; export const resizeImage = ( - image: HTMLImageElement, - outputFile: string, + imageAsBlobDataUrl: string, { width, height, transparentBorderSize = 0, }: {| width: number, height: number, transparentBorderSize?: number |} -): Promise => { +): Promise => { return new Promise((resolve, reject) => { const canvasElement = document.createElement('canvas'); canvasElement.width = width; @@ -36,20 +16,27 @@ export const resizeImage = ( ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - - ctx.drawImage( - image, - transparentBorderSize, - transparentBorderSize, - width - 2 * transparentBorderSize, - height - 2 * transparentBorderSize - ); - - canvasElement.toBlob(blob => { - blob.arrayBuffer().then(buffer => { - fs.writeFileSync(outputFile, Buffer.from(buffer)); - resolve(true); - }); - }, 'image/png'); + const image = new Image(); + image.addEventListener('load', () => { + try { + ctx.drawImage( + image, + transparentBorderSize, + transparentBorderSize, + width - 2 * transparentBorderSize, + height - 2 * transparentBorderSize + ); + + canvasElement.toBlob(blob => { + resolve(URL.createObjectURL(blob)); + }, 'image/png'); + } catch (error) { + reject('An error occurred while generating an icon'); + } + }); + image.addEventListener('error', (e: Event) => { + reject('An error occurred while loading the input image'); + }); + image.src = imageAsBlobDataUrl; }); }; diff --git a/newIDE/app/src/PlatformSpecificAssetsEditor/PlatformSpecificAssetsDialog.js b/newIDE/app/src/PlatformSpecificAssetsEditor/PlatformSpecificAssetsDialog.js index dcf040e91f8c..1ee65526186f 100644 --- a/newIDE/app/src/PlatformSpecificAssetsEditor/PlatformSpecificAssetsDialog.js +++ b/newIDE/app/src/PlatformSpecificAssetsEditor/PlatformSpecificAssetsDialog.js @@ -5,21 +5,18 @@ import { I18n } from '@lingui/react'; import * as React from 'react'; import FlatButton from '../UI/FlatButton'; -import RaisedButton from '../UI/RaisedButton'; import Dialog, { DialogPrimaryButton } from '../UI/Dialog'; import { Line } from '../UI/Grid'; import ResourcesLoader from '../ResourcesLoader'; import ResourceSelectorWithThumbnail from '../ResourcesList/ResourceSelectorWithThumbnail'; import { type ResourceManagementProps } from '../ResourcesList/ResourceSource'; -import { getImageFromPath, resizeImage } from './ImageResizer'; +import { resizeImage } from './ImageResizer'; import { showErrorBox } from '../UI/Messages/MessageBox'; -import optionalRequire from '../Utils/OptionalRequire'; import Text from '../UI/Text'; -import { ColumnStackLayout } from '../UI/Layout'; +import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout'; import AlertMessage from '../UI/AlertMessage'; import ErrorBoundary from '../UI/ErrorBoundary'; -const path = optionalRequire('path'); const gd: libGDevelop = global.gd; type Props = {| @@ -70,6 +67,8 @@ const iosSizes = [ ]; class PlatformSpecificAssetsDialog extends React.Component { + inputRef: HTMLInputElement | null = null; + constructor(props: Props) { super(props); this.state = this._loadFrom(props.project); @@ -105,118 +104,108 @@ class PlatformSpecificAssetsDialog extends React.Component { } } - _generateFromFile = async () => { - const { project, resourceManagementProps } = this.props; - - const resourceSource = resourceManagementProps.resourceSources - .filter(source => source.kind === 'image') - .filter(source => source.name.startsWith('local-file-opener'))[0]; - - if (!resourceSource) { - throw new Error( - 'No supported resource source - only local files are supported.' - ); - } - - const resources = await resourceManagementProps.onChooseResource({ - initialSourceName: resourceSource.name, - multiSelection: false, - resourceKind: 'image', - }); - - if (!resources.length || !path) { + _generateFromFile = async e => { + if (!this.inputRef || !this.inputRef.files || !this.inputRef.files[0]) { + console.error("Could't find selected file. Aborting icon generation."); return; } + const chosenFileAsBlobDataUrl = URL.createObjectURL(this.inputRef.files[0]); + const { project, resourceManagementProps } = this.props; const resourcesManager = project.getResourcesManager(); - const projectPath = path.dirname(project.getProjectFile()); - const fullPath = path.resolve(projectPath, resources[0].getFile()); - const image = await getImageFromPath(fullPath); + try { + const results = await Promise.all([ + ...desktopSizes.map(async size => ({ + resourceName: `desktop-icon-${size}.png`, + blobDataUrl: await resizeImage(chosenFileAsBlobDataUrl, { + width: size, + height: size, + }), + })), + ...androidSizes.map(async size => ({ + resourceName: `android-icon-${size}.png`, + blobDataUrl: await resizeImage(chosenFileAsBlobDataUrl, { + width: size, + height: size, + }), + })), + (async () => ({ + resourceName: 'android-windowSplashScreenAnimatedIcon.png', + blobDataUrl: await resizeImage(chosenFileAsBlobDataUrl, { + width: androidWindowSplashScreenAnimatedIconRecommendedSize, + height: androidWindowSplashScreenAnimatedIconRecommendedSize, + transparentBorderSize: + androidWindowSplashScreenAnimatedIconRecommendedSize / 6, + }), + }))(), + ...iosSizes.map(async size => ({ + resourceName: `ios-icon-${size}.png`, + blobDataUrl: await resizeImage(chosenFileAsBlobDataUrl, { + width: size, + height: size, + }), + })), + ]); - // Important, we are responsible for deleting the resources that were given to us. - // Otherwise we have a memory leak. - resources.forEach(resource => resource.delete()); + results.forEach(({ resourceName, blobDataUrl }) => { + const metadata = JSON.stringify({ + extension: '.png', + // Used in local app to define the path where to save the image on + // the file system. + localFilePath: `./assets/${resourceName}`, + }); + if (!resourcesManager.hasResource(resourceName)) { + const imageResource = new gd.ImageResource(); + imageResource.setFile(blobDataUrl); + imageResource.setName(resourceName); + imageResource.setMetadata(metadata); - const results = await Promise.all([ - ...desktopSizes.map(size => - resizeImage(image, path.join(projectPath, `desktop-icon-${size}.png`), { - width: size, - height: size, - }) - ), - ...androidSizes.map(size => - resizeImage(image, path.join(projectPath, `android-icon-${size}.png`), { - width: size, - height: size, - }) - ), - resizeImage( - image, - path.join(projectPath, 'android-windowSplashScreenAnimatedIcon.png'), - { - width: androidWindowSplashScreenAnimatedIconRecommendedSize, - height: androidWindowSplashScreenAnimatedIconRecommendedSize, - transparentBorderSize: - androidWindowSplashScreenAnimatedIconRecommendedSize / 6, + resourcesManager.addResource(imageResource); + + // Important, we are responsible for deleting the resources that we created + // Otherwise we have a memory leak, as calling addResource is making a copy of the resource. + imageResource.delete(); + } else { + resourcesManager.getResource(resourceName).setFile(blobDataUrl); + resourcesManager.getResource(resourceName).setMetadata(metadata); } - ), - ...iosSizes.map(size => - resizeImage(image, path.join(projectPath, `ios-icon-${size}.png`), { - width: size, - height: size, - }) - ), - ]); + }); + + await resourceManagementProps.onFetchNewlyAddedResources(); - if (results.indexOf(false) !== -1) { + // Make sure the resources are (re)loaded. + ResourcesLoader.burstUrlsCacheForResources( + project, + results.map(({ resourceName }) => resourceName) + ); + setTimeout(() => { + this.setState({ + desktopIconResourceNames: desktopSizes.map( + size => `desktop-icon-${size}.png` + ), + androidIconResourceNames: androidSizes.map( + size => `android-icon-${size}.png` + ), + androidWindowSplashScreenAnimatedIconResourceName: + 'android-windowSplashScreenAnimatedIcon.png', + iosIconResourceNames: iosSizes.map(size => `ios-icon-${size}.png`), + }); + }, 200 /* Let a bit of time so that image files can be found */); + } catch (e) { showErrorBox({ - message: 'Some icons could not be generated!', + message: 'Some icons could not be generated.', rawError: undefined, errorId: 'icon-generation-error', doNotReport: true, }); - return; - } - - // Add resources to the game - const allResourcesNames = [ - ...desktopSizes.map(size => `desktop-icon-${size}.png`), - ...androidSizes.map(size => `android-icon-${size}.png`), - 'android-windowSplashScreenAnimatedIcon.png', - ...iosSizes.map(size => `ios-icon-${size}.png`), - ]; - allResourcesNames.forEach(resourceName => { - if (!resourcesManager.hasResource(resourceName)) { - const imageResource = new gd.ImageResource(); - imageResource.setFile(resourceName); - imageResource.setName(resourceName); - - resourcesManager.addResource(imageResource); - - // Important, we are responsible for deleting the resources that we created - // Otherwise we have a memory leak, as calling addResource is making a copy of the resource. - imageResource.delete(); - } else { - resourcesManager.getResource(resourceName).setFile(resourceName); + } finally { + // Reset input value so that if the user selects the same file again, + // the onChange callback is called. + if (this.inputRef) { + this.inputRef.value = ''; } - }); - - // Make sure the resources are (re)loaded. - ResourcesLoader.burstUrlsCacheForResources(project, allResourcesNames); - setTimeout(() => { - this.setState({ - desktopIconResourceNames: desktopSizes.map( - size => `desktop-icon-${size}.png` - ), - androidIconResourceNames: androidSizes.map( - size => `android-icon-${size}.png` - ), - androidWindowSplashScreenAnimatedIconResourceName: - 'android-windowSplashScreenAnimatedIcon.png', - iosIconResourceNames: iosSizes.map(size => `ios-icon-${size}.png`), - }); - }, 200 /* Let a bit of time so that image files can be found */); + } }; onApply = () => { @@ -297,22 +286,20 @@ class PlatformSpecificAssetsDialog extends React.Component { onApply={this.onApply} > - - {!!path ? ( - Generate icons from a file} - onClick={this._generateFromFile} - /> - ) : ( - - - Download GDevelop desktop version to generate the Android and - iOS icons of your game. - - - )} - + + + Generate icons from a file: + + (this.inputRef = _inputRef)} + /> + gd.games thumbnail