Allow extensions made in the editor to specify dependencies on Cordova libraries or NPM packages (#2393)

Only show in developer changelog
This commit is contained in:
Arthur Pacaud
2021-03-02 00:01:11 +00:00
committed by GitHub
parent c89231ba38
commit 372deaec9c
11 changed files with 758 additions and 439 deletions
@@ -105,14 +105,15 @@ class GD_CORE_API DependencyMetadata {
* \brief Mark the dependency to be included in the export only if one other
* dependency is included in the export.
*/
DependencyMetadata& OnlyIfOtherDependencyIsExported(const gd::String& otherDependency) {
DependencyMetadata& OnlyIfOtherDependencyIsExported(
const gd::String& otherDependency) {
onlyIfOtherDependencyIsExported = otherDependency;
return *this;
};
/**
* \brief Get the name of another dependency that must be exported to have this
* one also exported.
* \brief Get the name of another dependency that must be exported to have
* this one also exported.
*/
const gd::String& GetOtherDependencyThatMustBeExported() const {
return onlyIfOtherDependencyIsExported;
@@ -132,6 +133,10 @@ class GD_CORE_API DependencyMetadata {
return extraData;
}
void CopyFrom(const DependencyMetadata& dependencyMetadata) {
*this = dependencyMetadata;
}
private:
gd::String name; ///< The name of the dependency.
gd::String exportName; ///< The name used to install the package (example:
@@ -144,7 +149,7 @@ class GD_CORE_API DependencyMetadata {
bool onlyIfSomeExtraSettingsNonEmpty; ///< If true, only use this dependency
///< if at least one of the extra
///< settings is set.
gd::String onlyIfOtherDependencyIsExported;
gd::String onlyIfOtherDependencyIsExported;
};
} // namespace gd
#endif // DEPENDENCYMETADATA_H
@@ -59,6 +59,10 @@ void EventsFunctionsExtension::SerializeTo(SerializerElement& element) const {
element.SetAttribute("previewIconUrl", previewIconUrl);
element.SetAttribute("iconUrl", iconUrl);
element.SetAttribute("helpPath", helpPath);
auto& dependenciesElement = element.AddChild("dependencies");
dependenciesElement.ConsiderAsArray();
for (auto& dependency : dependencies)
SerializeDependencyTo(dependency, dependenciesElement.AddChild(""));
SerializeEventsFunctionsTo(element.AddChild("eventsFunctions"));
eventsBasedBehaviors.SerializeElementsTo(
@@ -95,6 +99,13 @@ void EventsFunctionsExtension::UnserializeFrom(
}
}
dependencies.clear();
const auto& dependenciesElement = element.GetChild("dependencies");
dependenciesElement.ConsiderAsArray();
for (size_t i = 0; i < dependenciesElement.GetChildrenCount(); ++i)
dependencies.push_back(
UnserializeDependencyFrom(dependenciesElement.GetChild(i)));
UnserializeEventsFunctionsFrom(project, element.GetChild("eventsFunctions"));
eventsBasedBehaviors.UnserializeElementsFrom(
"eventsBasedBehavior", project, element.GetChild("eventsBasedBehaviors"));
@@ -9,6 +9,7 @@
#include <vector>
#include "GDCore/Extensions/Metadata/DependencyMetadata.h"
#include "GDCore/Project/EventsBasedBehavior.h"
#include "GDCore/Project/EventsFunctionsContainer.h"
#include "GDCore/String.h"
@@ -136,6 +137,35 @@ class GD_CORE_API EventsFunctionsExtension : public EventsFunctionsContainer {
return eventsBasedBehaviors;
}
/** \name Dependencies
*/
///@{
/**
* \brief Adds a new dependency.
*/
gd::DependencyMetadata& AddDependency() {
gd::DependencyMetadata dependency;
dependencies.push_back(dependency);
return dependencies.back();
};
/**
* \brief Adds a new dependency.
*/
void RemoveDependencyAt(size_t index) {
dependencies.erase(dependencies.begin() + index);
};
/**
* \brief Returns the list of dependencies.
*/
std::vector<gd::DependencyMetadata>& GetAllDependencies() {
return dependencies;
};
///@}
/** \name Serialization
*/
///@{
@@ -165,6 +195,24 @@ class GD_CORE_API EventsFunctionsExtension : public EventsFunctionsContainer {
*/
void Init(const gd::EventsFunctionsExtension& other);
void SerializeDependencyTo(const gd::DependencyMetadata& dependency,
gd::SerializerElement& serializer) const {
serializer.SetStringAttribute("type", dependency.GetDependencyType());
serializer.SetStringAttribute("exportName", dependency.GetExportName());
serializer.SetStringAttribute("name", dependency.GetName());
serializer.SetStringAttribute("version", dependency.GetVersion());
}
gd::DependencyMetadata UnserializeDependencyFrom(
gd::SerializerElement& serializer) {
gd::DependencyMetadata dependency;
dependency.SetDependencyType(serializer.GetStringAttribute("type"));
dependency.SetExportName(serializer.GetStringAttribute("exportName"));
dependency.SetName(serializer.GetStringAttribute("name"));
dependency.SetVersion(serializer.GetStringAttribute("version"));
return dependency;
}
gd::String version;
gd::String extensionNamespace;
gd::String shortDescription;
@@ -178,6 +226,7 @@ class GD_CORE_API EventsFunctionsExtension : public EventsFunctionsContainer {
gd::String helpPath; ///< The relative path to the help for this extension in
///< the documentation (or an absolute URL).
gd::SerializableWithNameList<EventsBasedBehavior> eventsBasedBehaviors;
std::vector<gd::DependencyMetadata> dependencies;
};
} // namespace gd
+6
View File
@@ -1100,6 +1100,8 @@ interface DependencyMetadata {
[Ref] DependencyMetadata OnlyIfSomeExtraSettingsNonEmpty();
[Ref] DependencyMetadata OnlyIfOtherDependencyIsExported([Const] DOMString otherDependency);
void CopyFrom([Const, Ref] DependencyMetadata dependencyMetadata);
};
interface ParameterMetadata {
@@ -2049,6 +2051,10 @@ interface EventsFunctionsExtension {
[Ref] EventsFunctionsExtension SetHelpPath([Const] DOMString helpPath);
[Const, Ref] DOMString GetHelpPath();
[Ref] DependencyMetadata AddDependency();
void RemoveDependencyAt(unsigned long index);
[Ref] VectorDependencyMetadata GetAllDependencies();
[Ref] EventsBasedBehaviorsList GetEventsBasedBehaviors();
void SerializeTo([Ref] SerializerElement element);
@@ -13,6 +13,7 @@ declare class gdDependencyMetadata {
getAllExtraSettings(): gdMapStringPropertyDescriptor;
onlyIfSomeExtraSettingsNonEmpty(): gdDependencyMetadata;
onlyIfOtherDependencyIsExported(otherDependency: string): gdDependencyMetadata;
copyFrom(dependencyMetadata: gdDependencyMetadata): void;
delete(): void;
ptr: number;
};
@@ -22,6 +22,9 @@ declare class gdEventsFunctionsExtension extends gdEventsFunctionsContainer {
getIconUrl(): string;
setHelpPath(helpPath: string): gdEventsFunctionsExtension;
getHelpPath(): string;
addDependency(): gdDependencyMetadata;
removeDependencyAt(index: number): void;
getAllDependencies(): gdVectorDependencyMetadata;
getEventsBasedBehaviors(): gdEventsBasedBehaviorsList;
serializeTo(element: gdSerializerElement): void;
unserializeFrom(project: gdProject, element: gdSerializerElement): void;
@@ -1,435 +0,0 @@
// @flow
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import * as React from 'react';
import FlatButton from '../UI/FlatButton';
import TextField from '../UI/TextField';
import CloudUpload from '@material-ui/icons/CloudUpload';
import { Column, Line } from '../UI/Grid';
import Dialog from '../UI/Dialog';
import HelpButton from '../UI/HelpButton';
import EventsFunctionsExtensionsContext, {
type EventsFunctionsExtensionsState,
} from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import RaisedButton from '../UI/RaisedButton';
import Window from '../Utils/Window';
import Text from '../UI/Text';
import SemiControlledTextField from '../UI/SemiControlledTextField';
import { ResourceStore } from '../AssetStore/ResourceStore';
import { ColumnStackLayout, TextFieldWithButtonLayout } from '../UI/Layout';
import axios from 'axios';
import useForceUpdate from '../Utils/UseForceUpdate';
import { useIsMounted } from '../Utils/UseIsMounted';
import { showErrorBox } from '../UI/Messages/MessageBox';
import {
getHelpLink,
isRelativePathToDocumentationRoot,
isDocumentationAbsoluteUrl,
} from '../Utils/HelpLink';
type Props = {|
eventsFunctionsExtension: gdEventsFunctionsExtension,
onClose: () => void,
open: boolean,
|};
const exportExtension = (
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState,
eventsFunctionsExtension: gdEventsFunctionsExtension
) => {
const eventsFunctionsExtensionWriter = eventsFunctionsExtensionsState.getEventsFunctionsExtensionWriter();
if (!eventsFunctionsExtensionWriter)
return Promise.reject(new Error('Not supported'));
return eventsFunctionsExtensionWriter
.chooseEventsFunctionExtensionFile()
.then(pathOrUrl => {
if (!pathOrUrl) return;
eventsFunctionsExtensionWriter
.writeEventsFunctionsExtension(eventsFunctionsExtension, pathOrUrl)
.then();
});
};
const downloadSvgAsBase64 = async (url: string): Promise<string> => {
try {
const response = await axios.get(url, { responseType: 'arraybuffer' });
const image = btoa(
new Uint8Array(response.data).reduce(
(data, byte) => data + String.fromCharCode(byte),
''
)
);
if (image.length > 100 * 1024) {
throw new Error(
`Icon is too big (size after base64 conversion: ${image.length})`
);
}
const contentType = response.headers
? response.headers['content-type'].toLowerCase()
: '';
if (contentType !== 'image/svg+xml')
throw new Error(
`Wrong content type. Got: "${contentType}", expected "image/svg+xml"`
);
return `data:${contentType};base64,${image}`;
} catch (err) {
console.error('Unable to import the icon.', err);
throw err;
}
};
const openGitHubIssue = () => {
const body = `
**⚠️ Please edit and complete this before submitting your extension:**
## Describe the extension
A clear and concise description of what the extension is, how useful it is.
## Checklist
- [ ] Extension has a proper name and description.
- [ ] Extension has tags (for example: "platform, brick, breakable").
- [ ] All behaviors have a description.
- [ ] All functions (actions, conditions, expressions) have descriptions.
- [ ] I confirm that this extension can be intergrated to this GitHub repository, distributed and MIT licensed.
## Extension file
Finally, attach the .json file of your extension here.
You also may have to create an account on GitHub before posting.
If your extension is high quality and useful, it will be added to the list of GDevelop community extensions.
When you're ready, remove this last paragraph and click on "Submit new issue". Thanks 🙌`;
Window.openExternalURL(
`https://github.com/4ian/GDevelop-extensions/issues/new?body=${encodeURIComponent(
body
)}&title=New%20extension`
);
};
type HelpPathTextFieldProps = {|
i18n: I18nType,
helpPath: string,
onChangeHelpPath: string => void,
|};
const HelpPathTextField = ({
i18n,
helpPath,
onChangeHelpPath,
}: HelpPathTextFieldProps) => {
const isAbsoluteUrl = isDocumentationAbsoluteUrl(helpPath);
const isRelativePath = isRelativePathToDocumentationRoot(helpPath);
const helperText = helpPath
? (isRelativePath
? i18n._(
t`This is a relative path that will open in the GDevelop wiki.`
)
: i18n._(t`This is link to a webpage.`)) +
` [${i18n._(t`Click here to test the link.`)}](${getHelpLink(helpPath)})`
: i18n._(
t`This can either be a URL to a web page, or a path starting with a slash that will be opened in the GDevelop wiki. Leave empty if there is no help page, although it's recommended you eventually write one if you distribute the extension.`
);
return (
<TextField
floatingLabelText={<Trans>Help page URL</Trans>}
value={helpPath}
onChange={(e, text) => {
onChangeHelpPath(text);
}}
errorText={
!!helpPath && !isAbsoluteUrl && !isRelativePath ? (
<Trans>
This is not a URL starting with "http://" or "https://".
</Trans>
) : null
}
helperMarkdownText={helperText}
fullWidth
/>
);
};
export default function OptionsEditorDialog({
eventsFunctionsExtension,
onClose,
open,
}: Props) {
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
const [resourceStoreOpen, setResourceStoreOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const forceUpdate = useForceUpdate();
const isMounted = useIsMounted();
const eventsFunctionsExtensionsState = React.useContext(
EventsFunctionsExtensionsContext
);
const eventsFunctionsExtensionWriter = eventsFunctionsExtensionsState.getEventsFunctionsExtensionWriter();
return (
<I18n>
{({ i18n }) => (
<Dialog
secondaryActions={[
<HelpButton key="help" helpPagePath="/extensions/create" />,
eventsFunctionsExtensionWriter ? (
<FlatButton
icon={<CloudUpload />}
key="export"
label={<Trans>Export extension</Trans>}
onClick={() => {
setExportDialogOpen(true);
}}
disabled={isLoading}
/>
) : null,
]}
actions={[
<FlatButton
label={<Trans>Close</Trans>}
primary={true}
keyboardFocused={true}
onClick={() => onClose()}
disabled={isLoading}
key={'close'}
/>,
]}
cannotBeDismissed={true}
open={open}
title={<Trans>Edit Extension Options</Trans>}
onRequestClose={isLoading ? () => {} : onClose}
>
<ColumnStackLayout noMargin>
<TextField
floatingLabelText={<Trans>Name</Trans>}
value={eventsFunctionsExtension.getName()}
disabled
fullWidth
/>
<TextFieldWithButtonLayout
renderButton={style => (
<RaisedButton
onClick={() => {
setResourceStoreOpen(true);
}}
primary
label={<Trans>Choose</Trans>}
disabled={isLoading}
style={style}
/>
)}
renderTextField={() => (
<SemiControlledTextField
floatingLabelText={<Trans>Icon URL</Trans>}
value={eventsFunctionsExtension.getPreviewIconUrl()}
onChange={text => {
eventsFunctionsExtension.setPreviewIconUrl(text);
}}
disabled
fullWidth
/>
)}
/>
<TextField
floatingLabelText={<Trans>Name displayed in editor</Trans>}
value={eventsFunctionsExtension.getFullName()}
onChange={(e, text) => {
eventsFunctionsExtension.setFullName(text);
forceUpdate();
}}
fullWidth
/>
<TextField
floatingLabelText={<Trans>Short description</Trans>}
value={eventsFunctionsExtension.getShortDescription()}
onChange={(e, text) => {
eventsFunctionsExtension.setShortDescription(text);
forceUpdate();
}}
multiline
fullWidth
rows={2}
rowsMax={2}
/>
<TextField
floatingLabelText={
<Trans>Description (markdown supported)</Trans>
}
value={eventsFunctionsExtension.getDescription()}
onChange={(e, text) => {
eventsFunctionsExtension.setDescription(text);
forceUpdate();
}}
multiline
fullWidth
rows={5}
rowsMax={5}
/>
<TextField
floatingLabelText={<Trans>Version</Trans>}
value={eventsFunctionsExtension.getVersion()}
onChange={(e, text) => {
eventsFunctionsExtension.setVersion(text);
forceUpdate();
}}
fullWidth
/>
<SemiControlledTextField
floatingLabelText={<Trans>Tags (comma separated)</Trans>}
value={eventsFunctionsExtension
.getTags()
.toJSArray()
.join(', ')}
onChange={text => {
const tags = eventsFunctionsExtension.getTags();
tags.clear();
text
.split(',')
.map(tag => tag.trim())
.filter(Boolean)
.forEach(tag => {
tags.push_back(tag);
});
forceUpdate();
}}
fullWidth
/>
<HelpPathTextField
i18n={i18n}
helpPath={eventsFunctionsExtension.getHelpPath()}
onChangeHelpPath={helpPath => {
eventsFunctionsExtension.setHelpPath(helpPath);
forceUpdate();
}}
/>
<TextField
floatingLabelText={
<Trans>Author (Name, email or GitHub handle)</Trans>
}
value={eventsFunctionsExtension.getAuthor()}
onChange={(e, text) => {
eventsFunctionsExtension.setAuthor(text);
forceUpdate();
}}
fullWidth
/>
</ColumnStackLayout>
{exportDialogOpen && (
<Dialog
secondaryActions={[
<HelpButton key="help" helpPagePath="/extensions/share" />,
]}
actions={[
<FlatButton
label={<Trans>Close</Trans>}
keyboardFocused={true}
onClick={() => {
setExportDialogOpen(false);
}}
key={'close'}
/>,
]}
open
cannotBeDismissed={false}
onRequestClose={() => {
setExportDialogOpen(false);
}}
>
<Column expand>
<Line>
<Text>
<Trans>
You can export the extension to a file to easily import it
in another project. If your extension is providing useful
and reusable functions or behaviors, consider sharing it
with the GDevelop community!
</Trans>
</Text>
</Line>
<Line>
<RaisedButton
icon={<CloudUpload />}
primary
label={<Trans>Export to a file</Trans>}
onClick={() => {
exportExtension(
eventsFunctionsExtensionsState,
eventsFunctionsExtension
);
}}
/>
<FlatButton
label={<Trans>Submit extension to the community</Trans>}
onClick={openGitHubIssue}
/>
</Line>
</Column>
</Dialog>
)}
{resourceStoreOpen && (
<Dialog
title={<Trans>Choose an icon for the extension</Trans>}
actions={[
<FlatButton
key="cancel"
label={<Trans>Cancel</Trans>}
primary={false}
onClick={() => {
setResourceStoreOpen(false);
}}
/>,
]}
cannotBeDismissed={false}
open
noMargin
>
<ResourceStore
onChoose={resource => {
setResourceStoreOpen(false);
setIsLoading(true);
downloadSvgAsBase64(resource.url)
.then(
base64Svg => {
if (!isMounted.current) return;
eventsFunctionsExtension.setPreviewIconUrl(
resource.url
);
eventsFunctionsExtension.setIconUrl(base64Svg);
},
rawError => {
if (!isMounted.current) return;
showErrorBox({
message: i18n._(
t`Unable to download the icon. Verify your internet connection or try again later.`
),
rawError,
errorId: 'icon-download-error',
});
}
)
.then(() => {
if (!isMounted.current) return;
setIsLoading(false);
});
}}
resourceKind={'svg'}
/>
</Dialog>
)}
</Dialog>
)}
</I18n>
);
}
@@ -0,0 +1,185 @@
//@flow
import React from 'react';
import { Trans, t } from '@lingui/macro';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Paper from '@material-ui/core/Paper';
import FlatButton from '../../UI/FlatButton';
import IconButton from '../../UI/IconButton';
import Add from '@material-ui/icons/Add';
import Delete from '@material-ui/icons/Delete';
import SemiControlledTextField from '../../UI/SemiControlledTextField';
import SelectField from '../../UI/SelectField';
import SelectOption from '../../UI/SelectOption';
import { Line, Column } from '../../UI/Grid';
import { mapVector } from '../../Utils/MapFor';
import newNameGenerator from '../../Utils/NewNameGenerator';
import useForceUpdate from '../../Utils/UseForceUpdate';
import BackgroundText from '../../UI/BackgroundText';
import { showWarningBox } from '../../UI/Messages/MessageBox';
const checkNameExists = (
name: string,
deps: gdVectorDependencyMetadata
): boolean => {
for (let i = 0; i < deps.size(); i++)
if (deps.at(i).getName() === name) return true;
return false;
};
type Props = {| eventsFunctionsExtension: gdEventsFunctionsExtension |};
export const ExtensionDependenciesEditor = ({
eventsFunctionsExtension,
}: Props) => {
const deps = eventsFunctionsExtension.getAllDependencies();
const forceUpdate = useForceUpdate();
const addDependency = () => {
eventsFunctionsExtension
.addDependency()
.setName(
newNameGenerator('New Dependency', (newName) =>
checkNameExists(newName, deps)
)
)
.setExportName('my-dependency')
.setVersion('1.0.0')
.setDependencyType('cordova');
forceUpdate();
};
return (
<Column>
<Line expand>
<TableContainer
component={({ children }) => (
<Paper elevation={4} style={{ minWidth: '100%' }}>
{children}
</Paper>
)}
>
<Table>
<TableHead>
<TableRow>
<TableCell>
<Trans>Name</Trans>
</TableCell>
<TableCell>
<Trans>Export name</Trans>
</TableCell>
<TableCell>
<Trans>Version</Trans>
</TableCell>
<TableCell>
<Trans>Dependency type</Trans>
</TableCell>
<TableCell>
<Trans>Action</Trans>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{mapVector<gdDependencyMetadata, TableRow>(
eventsFunctionsExtension.getAllDependencies(),
(dependency, index) => (
<TableRow key={dependency.getName()}>
<TableCell>
<SemiControlledTextField
commitOnBlur
value={dependency.getName()}
onChange={(newName) => {
if (checkNameExists(newName, deps))
showWarningBox(
`This name is already in use! Please use a unique name.`,
{ delayToNextTick: true }
);
else {
eventsFunctionsExtension
.getAllDependencies()
.at(index)
.setName(newName);
forceUpdate();
}
}}
/>
</TableCell>
<TableCell>
<SemiControlledTextField
commitOnBlur
value={dependency.getExportName()}
onChange={(newExportName) => {
dependency.setExportName(newExportName);
forceUpdate();
}}
/>
</TableCell>
<TableCell>
<SemiControlledTextField
commitOnBlur
value={dependency.getVersion()}
onChange={(newVersion) => {
dependency.setVersion(newVersion);
forceUpdate();
}}
/>
</TableCell>
<TableCell>
<SelectField
value={dependency.getDependencyType()}
onChange={(_, __, newType) => {
dependency.setDependencyType(newType);
forceUpdate();
}}
>
<SelectOption value="npm" primaryText={t`NPM`} />
<SelectOption
value="cordova"
primaryText={t`Cordova`}
/>
</SelectField>
</TableCell>
<TableCell>
<IconButton
tooltip={t`Remove`}
onClick={() => {
eventsFunctionsExtension.removeDependencyAt(index);
forceUpdate();
}}
>
<Delete />
</IconButton>
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
<FlatButton
icon={<Add />}
label={<Trans>Add</Trans>}
onClick={addDependency}
/>
</TableContainer>
</Line>
<Line>
<BackgroundText>
<Trans>
Dependencies allow to add additional libraries in the exported
games. NPM dependencies will be included for Electron builds
(Windows, macOS, Linux) and Cordova dependencies will be included
for Cordova builds (Android, iOS). Note that this is intended for
usage in JavaScript events only. If you are only using standard
events, you should not worry about this.
</Trans>
</BackgroundText>
</Line>
</Column>
);
};
@@ -0,0 +1,288 @@
//@flow
import React from 'react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import TextField from '../../UI/TextField';
import { ColumnStackLayout, TextFieldWithButtonLayout } from '../../UI/Layout';
import SemiControlledTextField from '../../UI/SemiControlledTextField';
import RaisedButton from '../../UI/RaisedButton';
import FlatButton from '../../UI/FlatButton';
import { ResourceStore } from '../../AssetStore/ResourceStore';
import Dialog from '../../UI/Dialog';
import useForceUpdate from '../../Utils/UseForceUpdate';
import {
getHelpLink,
isRelativePathToDocumentationRoot,
isDocumentationAbsoluteUrl,
} from '../../Utils/HelpLink';
import axios from 'axios';
import { useIsMounted } from '../../Utils/UseIsMounted';
import { showErrorBox } from '../../UI/Messages/MessageBox';
const downloadSvgAsBase64 = async (url: string): Promise<string> => {
try {
const response = await axios.get(url, { responseType: 'arraybuffer' });
const image = btoa(
new Uint8Array(response.data).reduce(
(data, byte) => data + String.fromCharCode(byte),
''
)
);
if (image.length > 100 * 1024) {
throw new Error(
`Icon is too big (size after base64 conversion: ${image.length})`
);
}
const contentType = response.headers
? response.headers['content-type'].toLowerCase()
: '';
if (contentType !== 'image/svg+xml')
throw new Error(
`Wrong content type. Got: "${contentType}", expected "image/svg+xml"`
);
return `data:${contentType};base64,${image}`;
} catch (err) {
console.error('Unable to import the icon.', err);
throw err;
}
};
type HelpPathTextFieldProps = {|
i18n: I18nType,
helpPath: string,
onChangeHelpPath: string => void,
|};
const HelpPathTextField = ({
i18n,
helpPath,
onChangeHelpPath,
}: HelpPathTextFieldProps) => {
const isAbsoluteUrl = isDocumentationAbsoluteUrl(helpPath);
const isRelativePath = isRelativePathToDocumentationRoot(helpPath);
const helperText = helpPath
? (isRelativePath
? i18n._(
t`This is a relative path that will open in the GDevelop wiki.`
)
: i18n._(t`This is link to a webpage.`)) +
` [${i18n._(t`Click here to test the link.`)}](${getHelpLink(helpPath)})`
: i18n._(
t`This can either be a URL to a web page, or a path starting with a slash that will be opened in the GDevelop wiki. Leave empty if there is no help page, although it's recommended you eventually write one if you distribute the extension.`
);
return (
<TextField
floatingLabelText={<Trans>Help page URL</Trans>}
value={helpPath}
onChange={(e, text) => {
onChangeHelpPath(text);
}}
errorText={
!!helpPath && !isAbsoluteUrl && !isRelativePath ? (
<Trans>
This is not a URL starting with "http://" or "https://".
</Trans>
) : null
}
helperMarkdownText={helperText}
fullWidth
/>
);
};
type Props = {|
eventsFunctionsExtension: gdEventsFunctionsExtension,
onLoadChange: (isLoading: boolean) => void,
isLoading: boolean,
|};
export const ExtensionOptionsEditor = ({
eventsFunctionsExtension,
onLoadChange,
isLoading,
}: Props) => {
const forceUpdate = useForceUpdate();
const [resourceStoreOpen, setResourceStoreOpen] = React.useState(false);
const isMounted = useIsMounted();
return (
<I18n>
{({ i18n }: { i18n: I18nType }) => (
<ColumnStackLayout noMargin>
<TextField
floatingLabelText={<Trans>Name</Trans>}
value={eventsFunctionsExtension.getName()}
disabled
fullWidth
/>
<TextFieldWithButtonLayout
renderButton={style => (
<RaisedButton
onClick={() => {
setResourceStoreOpen(true);
}}
primary
label={<Trans>Choose</Trans>}
disabled={isLoading}
style={style}
/>
)}
renderTextField={() => (
<SemiControlledTextField
floatingLabelText={<Trans>Icon URL</Trans>}
value={eventsFunctionsExtension.getPreviewIconUrl()}
onChange={text => {
eventsFunctionsExtension.setPreviewIconUrl(text);
}}
disabled
fullWidth
/>
)}
/>
<TextField
floatingLabelText={<Trans>Name displayed in editor</Trans>}
value={eventsFunctionsExtension.getFullName()}
onChange={(e, text) => {
eventsFunctionsExtension.setFullName(text);
forceUpdate();
}}
fullWidth
/>
<TextField
floatingLabelText={<Trans>Short description</Trans>}
value={eventsFunctionsExtension.getShortDescription()}
onChange={(e, text) => {
eventsFunctionsExtension.setShortDescription(text);
forceUpdate();
}}
multiline
fullWidth
rows={2}
rowsMax={2}
/>
<TextField
floatingLabelText={<Trans>Description (markdown supported)</Trans>}
value={eventsFunctionsExtension.getDescription()}
onChange={(e, text) => {
eventsFunctionsExtension.setDescription(text);
forceUpdate();
}}
multiline
fullWidth
rows={5}
rowsMax={5}
/>
<TextField
floatingLabelText={<Trans>Version</Trans>}
value={eventsFunctionsExtension.getVersion()}
onChange={(e, text) => {
eventsFunctionsExtension.setVersion(text);
forceUpdate();
}}
fullWidth
/>
<SemiControlledTextField
floatingLabelText={<Trans>Tags (comma separated)</Trans>}
value={eventsFunctionsExtension
.getTags()
.toJSArray()
.join(', ')}
onChange={text => {
const tags = eventsFunctionsExtension.getTags();
tags.clear();
text
.split(',')
.map(tag => tag.trim())
.filter(Boolean)
.forEach(tag => {
tags.push_back(tag);
});
forceUpdate();
}}
fullWidth
/>
<HelpPathTextField
i18n={i18n}
helpPath={eventsFunctionsExtension.getHelpPath()}
onChangeHelpPath={helpPath => {
eventsFunctionsExtension.setHelpPath(helpPath);
forceUpdate();
}}
/>
<TextField
floatingLabelText={
<Trans>Author (Name, email or GitHub handle)</Trans>
}
value={eventsFunctionsExtension.getAuthor()}
onChange={(e, text) => {
eventsFunctionsExtension.setAuthor(text);
forceUpdate();
}}
fullWidth
/>
{resourceStoreOpen && (
<Dialog
title={<Trans>Choose an icon for the extension</Trans>}
actions={[
<FlatButton
key="cancel"
label={<Trans>Cancel</Trans>}
primary={false}
onClick={() => {
setResourceStoreOpen(false);
}}
/>,
]}
cannotBeDismissed={false}
open
noMargin
>
<ResourceStore
onChoose={resource => {
setResourceStoreOpen(false);
onLoadChange(true);
downloadSvgAsBase64(resource.url)
.then(
base64Svg => {
if (!isMounted.current) return;
eventsFunctionsExtension.setPreviewIconUrl(
resource.url
);
eventsFunctionsExtension.setIconUrl(base64Svg);
},
rawError => {
if (!isMounted.current) return;
showErrorBox({
message: i18n._(
t`Unable to download the icon. Verify your internet connection or try again later.`
),
rawError,
errorId: 'icon-download-error',
});
}
)
.then(() => {
if (!isMounted.current) return;
onLoadChange(false);
});
}}
resourceKind={'svg'}
/>
</Dialog>
)}
</ColumnStackLayout>
)}
</I18n>
);
};
@@ -0,0 +1,192 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import Dialog from '../../UI/Dialog';
import FlatButton from '../../UI/FlatButton';
import CloudUpload from '@material-ui/icons/CloudUpload';
import { Column, Line } from '../../UI/Grid';
import HelpButton from '../../UI/HelpButton';
import EventsFunctionsExtensionsContext, {
type EventsFunctionsExtensionsState,
} from '../../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import RaisedButton from '../../UI/RaisedButton';
import Window from '../../Utils/Window';
import Text from '../../UI/Text';
import { ExtensionOptionsEditor } from './ExtensionOptionsEditor';
import { Tab, Tabs } from '../../UI/Tabs';
import { ExtensionDependenciesEditor } from './ExtensionDependenciesEditor';
const exportExtension = (
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState,
eventsFunctionsExtension: gdEventsFunctionsExtension
) => {
const eventsFunctionsExtensionWriter = eventsFunctionsExtensionsState.getEventsFunctionsExtensionWriter();
if (!eventsFunctionsExtensionWriter)
return Promise.reject(new Error('Not supported'));
return eventsFunctionsExtensionWriter
.chooseEventsFunctionExtensionFile()
.then(pathOrUrl => {
if (!pathOrUrl) return;
eventsFunctionsExtensionWriter
.writeEventsFunctionsExtension(eventsFunctionsExtension, pathOrUrl)
.then();
});
};
const openGitHubIssue = () => {
const body = `
**⚠️ Please edit and complete this before submitting your extension:**
## Describe the extension
A clear and concise description of what the extension is, how useful it is.
## Checklist
- [ ] Extension has a proper name and description.
- [ ] Extension has tags (for example: "platform, brick, breakable").
- [ ] All behaviors have a description.
- [ ] All functions (actions, conditions, expressions) have descriptions.
- [ ] I confirm that this extension can be intergrated to this GitHub repository, distributed and MIT licensed.
## Extension file
Finally, attach the .json file of your extension here.
You also may have to create an account on GitHub before posting.
If your extension is high quality and useful, it will be added to the list of GDevelop community extensions.
When you're ready, remove this last paragraph and click on "Submit new issue". Thanks 🙌`;
Window.openExternalURL(
`https://github.com/4ian/GDevelop-extensions/issues/new?body=${encodeURIComponent(
body
)}&title=New%20extension`
);
};
type TabName = 'options' | 'dependencies';
type Props = {|
eventsFunctionsExtension: gdEventsFunctionsExtension,
onClose: () => void,
open: boolean,
|};
export default function OptionsEditorDialog({
eventsFunctionsExtension,
onClose,
open,
}: Props) {
const [currentTab, setCurrentTab] = React.useState<TabName>('options');
const [isLoading, setIsLoading] = React.useState(false);
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
const eventsFunctionsExtensionsState = React.useContext(
EventsFunctionsExtensionsContext
);
const eventsFunctionsExtensionWriter = eventsFunctionsExtensionsState.getEventsFunctionsExtensionWriter();
return (
<Dialog
secondaryActions={[
<HelpButton key="help" helpPagePath="/extensions/create" />,
eventsFunctionsExtensionWriter ? (
<FlatButton
icon={<CloudUpload />}
key="export"
label={<Trans>Export extension</Trans>}
onClick={() => {
setExportDialogOpen(true);
}}
disabled={isLoading}
/>
) : null,
]}
actions={[
<FlatButton
label={<Trans>Close</Trans>}
primary={true}
keyboardFocused={true}
onClick={() => onClose()}
disabled={isLoading}
key={'close'}
/>,
]}
cannotBeDismissed={true}
open={open}
noTitleMargin
title={
<Tabs value={currentTab} onChange={setCurrentTab}>
<Tab label={<Trans>Options</Trans>} value="options" />
<Tab label={<Trans>Dependencies</Trans>} value="dependencies" />
</Tabs>
}
onRequestClose={isLoading ? () => {} : onClose}
>
{currentTab === 'options' && (
<ExtensionOptionsEditor
eventsFunctionsExtension={eventsFunctionsExtension}
onLoadChange={setIsLoading}
isLoading={isLoading}
/>
)}
{currentTab === 'dependencies' && (
<ExtensionDependenciesEditor
eventsFunctionsExtension={eventsFunctionsExtension}
/>
)}
{exportDialogOpen && (
<Dialog
secondaryActions={[
<HelpButton key="help" helpPagePath="/extensions/share" />,
]}
actions={[
<FlatButton
label={<Trans>Close</Trans>}
keyboardFocused={true}
onClick={() => {
setExportDialogOpen(false);
}}
key={'close'}
/>,
]}
open
cannotBeDismissed={false}
onRequestClose={() => {
setExportDialogOpen(false);
}}
>
<Column expand>
<Line>
<Text>
<Trans>
You can export the extension to a file to easily import it in
another project. If your extension is providing useful and
reusable functions or behaviors, consider sharing it with the
GDevelop community!
</Trans>
</Text>
</Line>
<Line>
<RaisedButton
icon={<CloudUpload />}
primary
label={<Trans>Export to a file</Trans>}
onClick={() => {
exportExtension(
eventsFunctionsExtensionsState,
eventsFunctionsExtension
);
}}
/>
<FlatButton
label={<Trans>Submit extension to the community</Trans>}
onClick={openGitHubIssue}
/>
</Line>
</Column>
</Dialog>
)}
</Dialog>
);
}
@@ -31,8 +31,22 @@ export const declareExtension = (
)
.setExtensionHelpPath(eventsFunctionsExtension.getHelpPath())
.setIconUrl(eventsFunctionsExtension.getIconUrl());
declareExtensionDependencies(extension, eventsFunctionsExtension);
};
/**
* Declare the dependencies of an extension from an events based extension.
*/
export const declareExtensionDependencies = (
extension: gdPlatformExtension,
eventsFunctionsExtension: gdEventsFunctionsExtension
) =>
mapVector<gdDependencyMetadata, void>(
eventsFunctionsExtension.getAllDependencies(),
dependency => extension.addDependency().copyFrom(dependency)
);
const getExtensionIconUrl = (extension: gdPlatformExtension) => {
return extension.getIconUrl() || 'res/function24.png';
};