[WIP] Add ResourcesEditor based on a generic SortableVirtualizedItemList

TODO:
* Use SortableVirtualizedItemList for ObjectsList and ObjectsGroupsList
* Add missing features (sorting, delete, thumbnail for images, properties edition) to ResourcesEditor
This commit is contained in:
Florian Rival
2018-01-30 00:58:21 +01:00
parent 24a8dfc5f0
commit c65e5c3e49
14 changed files with 682 additions and 38 deletions

View File

@@ -13,6 +13,7 @@ declare type gdExternalEvents = EmscriptenObject;
declare type gdSerializerElement = EmscriptenObject;
declare type gdInitialInstance = EmscriptenObject;
declare type gdBaseEvent = EmscriptenObject;
declare type gdResource = EmscriptenObject;
//Represents all objects that have serializeTo and unserializeFrom methods.
declare type gdSerializable = EmscriptenObject;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import BaseEditor from './BaseEditor';
import ResourcesList from '../../ResourcesList';
export default class ResourcesEditor extends BaseEditor {
updateToolbar() {
// TODO
}
render() {
const { project, layoutName } = this.props;
return (
<ResourcesList
{...this.props}
ref={editor => (this.editor = editor)}
project={project}
/>
);
}
}

View File

@@ -47,6 +47,7 @@ import ExternalEventsEditor from './Editors/ExternalEventsEditor';
import SceneEditor from './Editors/SceneEditor';
import ExternalLayoutEditor from './Editors/ExternalLayoutEditor';
import StartPage from './Editors/StartPage';
import ResourcesEditor from './Editors/ResourcesEditor';
import {
type PreferencesState,
getThemeName,
@@ -545,6 +546,36 @@ export default class MainFrame extends Component<*, State> {
);
};
openResources = () => {
this.setState(
{
editorTabs: openEditorTab(this.state.editorTabs, {
name: 'Resources',
editorCreator: () => (
<ResourcesEditor
project={this.state.currentProject}
setToolbar={this.setEditorToolbar}
onDeleteResource={(resource: gdResource, cb: boolean => void) => {
// TODO: Project wide refactoring of objects/events using the resource
cb(true);
}}
onRenameResource={(
resource: gdResource,
newName: string,
cb: boolean => void
) => {
// TODO: Project wide refactoring of objects/events using the resource
cb(true);
}}
/>
),
key: 'resources',
}),
},
() => this.updateToolbar()
);
};
openStartPage = () => {
this.setState(
{
@@ -814,6 +845,7 @@ export default class MainFrame extends Component<*, State> {
onCloseProject={this.askToCloseProject}
onExportProject={this.openExportDialog}
onOpenPreferences={() => this.openPreferences(true)}
onOpenResources={() => this.openResources(true)}
/>
)}
</Drawer>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import ResourcesLoader from '../ObjectsRendering/ResourcesLoader';
import ResourceSelector from '../ResourcesEditor/ResourceSelector';
import ResourceSelector from '../ResourcesList/ResourceSelector';
import ImageThumbnail from './ImageThumbnail';
export default ({

View File

@@ -1,21 +1,6 @@
// @flow
import { mapFor } from '../Utils/MapFor';
//TODO: Layout, ExternalEvents and ExternalLayout should be moved to a common type definition file
//for all GDevelop.js
type Layout = {
getName: Function,
setName: Function,
};
type ExternalLayout = {
getName: Function,
setName: Function,
};
type ExternalEvents = {
getName: Function,
setName: Function,
};
export const enumerateLayouts = (project: any) =>
mapFor(0, project.getLayoutsCount(), i => project.getLayoutAt(i));
@@ -30,7 +15,7 @@ export const enumerateExternalLayouts = (project: any) =>
);
export const filterProjectItemsList = (
list: Array<Layout> | Array<ExternalLayout> | Array<ExternalEvents>,
list: Array<gdLayout> | Array<gdExternalLayout> | Array<gdExternalEvents>,
searchText: string
) => {
if (!searchText) return list;

View File

@@ -1,4 +1,5 @@
import React, { Component } from 'react';
// @flow
import * as React from 'react';
import { List, ListItem } from 'material-ui/List';
import TextField from 'material-ui/TextField';
import SearchBar from 'material-ui-search-bar';
@@ -65,7 +66,10 @@ const ThemableProjectStructureItem = ({ muiTheme, ...otherProps }) => (
const ProjectStructureItem = muiThemeable()(ThemableProjectStructureItem);
class ThemableItem extends Component {
class ThemableItem extends React.Component<*, *> {
textField: ?Object;
_iconMenu: ?Object;
componentDidUpdate(prevProps) {
if (!prevProps.editingName && this.props.editingName) {
setTimeout(() => {
@@ -126,7 +130,7 @@ class ThemableItem extends Component {
onKeyPress={event => {
if (event.charCode === 13) {
// enter key pressed
this.textField.blur();
if (this.textField) this.textField.blur();
this.props.onRename(event.target.value);
}
}}
@@ -157,33 +161,64 @@ const Item = muiThemeable()(ThemableItem);
const AddItem = makeAddItem(ListItem);
export default class ProjectManager extends React.Component {
type Props = {
project: gdProject,
onDeleteLayout: (gdLayout) => void,
onDeleteExternalEvents: (gdExternalEvents) => void,
onDeleteExternalLayout: (gdExternalLayout) => void,
onRenameLayout: (string, string) => void,
onRenameExternalEvents: (string, string) => void,
onRenameExternalLayout: (string, string) => void,
onOpenLayout: (string) => void,
onOpenExternalEvents: (string) => void,
onOpenExternalLayout: (string) => void,
onSaveProject: () => void,
onCloseProject: () => void,
onExportProject: () => void,
onOpenPreferences: () => void,
onOpenResources: () => void,
onAddLayout: () => void,
onAddExternalEvents: () => void,
onAddExternalLayout: () => void,
};
type State = {|
renamedItemKind: ?string,
renamedItemName: string,
searchText: string,
projectPropertiesDialogOpen: boolean,
variablesEditorOpen: boolean,
|};
export default class ProjectManager extends React.Component<Props, State> {
state = {
renamedItemKind: null,
renamedItemName: '',
searchText: '',
projectPropertiesDialogOpen: false,
variablesEditorOpen: false,
};
_onEditName = (kind, name) => {
_onEditName = (kind: ?string, name: string) => {
this.setState({
renamedItemKind: kind,
renamedItemName: name,
});
};
_copyLayout = layout => {
_copyLayout = (layout: gdLayout) => {
Clipboard.set(LAYOUT_CLIPBOARD_KIND, {
layout: serializeToJSObject(layout),
name: layout.getName(),
});
};
_cutLayout = layout => {
_cutLayout = (layout: gdLayout) => {
this._copyLayout(layout);
this.props.onDeleteLayout(layout);
};
_pasteLayout = index => {
_pasteLayout = (index: number) => {
if (!Clipboard.has(LAYOUT_CLIPBOARD_KIND)) return;
const { layout: copiedLayout, name } = Clipboard.get(LAYOUT_CLIPBOARD_KIND);
@@ -207,19 +242,19 @@ export default class ProjectManager extends React.Component {
this.forceUpdate();
};
_copyExternalEvents = externalEvents => {
_copyExternalEvents = (externalEvents: gdExternalEvents) => {
Clipboard.set(EXTERNAL_EVENTS_CLIPBOARD_KIND, {
externalEvents: serializeToJSObject(externalEvents),
name: externalEvents.getName(),
});
};
_cutExternalEvents = externalEvents => {
_cutExternalEvents = (externalEvents: gdExternalEvents) => {
this._copyExternalEvents(externalEvents);
this.props.onDeleteExternalEvents(externalEvents);
};
_pasteExternalEvents = index => {
_pasteExternalEvents = (index: number) => {
if (!Clipboard.has(EXTERNAL_EVENTS_CLIPBOARD_KIND)) return;
const { externalEvents: copiedExternalEvents, name } = Clipboard.get(
@@ -244,19 +279,19 @@ export default class ProjectManager extends React.Component {
this.forceUpdate();
};
_copyExternalLayout = externalLayout => {
_copyExternalLayout = (externalLayout: gdExternalLayout) => {
Clipboard.set(EXTERNAL_LAYOUT_CLIPBOARD_KIND, {
externalLayout: serializeToJSObject(externalLayout),
name: externalLayout.getName(),
});
};
_cutExternalLayout = externalLayout => {
_cutExternalLayout = (externalLayout: gdExternalLayout) => {
this._copyExternalLayout(externalLayout);
this.props.onDeleteExternalLayout(externalLayout);
};
_pasteExternalLayout = index => {
_pasteExternalLayout = (index: number) => {
if (!Clipboard.has(EXTERNAL_LAYOUT_CLIPBOARD_KIND)) return;
const { externalLayout: copiedExternalLayout, name } = Clipboard.get(
@@ -328,10 +363,6 @@ export default class ProjectManager extends React.Component {
<div style={styles.container}>
<List style={styles.list}>
{this._renderMenu()}
{/* <ProjectStructureItem
primaryText="Resources"
leftIcon={<ListIcon src="res/ribbon_default/image32.png" />}
/> */}
<ProjectStructureItem
primaryText="Game settings"
leftIcon={
@@ -354,6 +385,12 @@ export default class ProjectManager extends React.Component {
leftIcon={<ListIcon src="res/ribbon_default/editname32.png" />}
onClick={() => this.setState({ variablesEditorOpen: true })}
/>,
<ListItem
key="resources"
primaryText="Resources"
leftIcon={<ListIcon src="res/ribbon_default/image32.png" />}
onClick={() => this.props.onOpenResources()}
/>,
]}
/>
<ProjectStructureItem
@@ -367,7 +404,7 @@ export default class ProjectManager extends React.Component {
enumerateLayouts(project),
searchText
)
.map((layout, i) => {
.map((layout: gdLayout, i: number) => {
const name = layout.getName();
return (
<Item

View File

@@ -0,0 +1,19 @@
// @flow
export const filterResourcesList = (
list: Array<gdResource>,
searchText: string
): Array<gdResource> => {
if (!searchText) return list;
const lowercaseSearchText = searchText.toLowerCase();
return list.filter((resource: gdResource) => {
return (
resource
.getName()
.toLowerCase()
.indexOf(lowercaseSearchText) !== -1
);
});
};

View File

@@ -0,0 +1,325 @@
// @flow
import React, { Component } from 'react';
import { AutoSizer } from 'react-virtualized';
import SortableVirtualizedItemList from '../UI/SortableVirtualizedItemList';
import Paper from 'material-ui/Paper';
import SearchBar from 'material-ui-search-bar';
import newNameGenerator from '../Utils/NewNameGenerator';
import Clipboard from '../Utils/Clipboard';
import {
serializeToJSObject,
unserializeFromJSObject,
} from '../Utils/Serializer';
import { showWarningBox } from '../UI/Messages/MessageBox';
import { filterResourcesList } from './EnumerateResources';
const CLIPBOARD_KIND = 'Resource';
const styles = {
container: {
flex: 1,
display: 'flex',
height: '100%',
flexDirection: 'column',
},
listContainer: {
flex: 1,
},
};
type State = {|
renamedResource: ?gdResource,
searchText: string,
|};
type Props = {
project: gdProject,
onDeleteResource: (resource: gdResource, cb: (boolean) => void) => void,
onRenameResource: (
resource: gdResource,
newName: string,
cb: (boolean) => void
) => void,
};
export default class ResourcesList extends React.Component<Props, State> {
static defaultProps = {
onDeleteResource: (resource: gdResource, cb: boolean => void) => cb(true),
onRenameResource: (
resource: gdResource,
newName: string,
cb: boolean => void
) => cb(true),
};
sortableList: any;
state: State = {
renamedResource: null,
searchText: '',
};
shouldComponentUpdate(nextProps: Props, nextState: State) {
// The component is costly to render, so avoid any re-rendering as much
// as possible.
// We make the assumption that no changes to resources list is made outside
// from the component.
// If a change is made, the component won't notice it: you have to manually
// call forceUpdate.
if (
this.state.renamedResource !== nextState.renamedResource ||
this.state.searchText !== nextState.searchText
)
return true;
if (this.props.project !== nextProps.project) return true;
return false;
}
addResource = (objectType: string) => {
// const { project, objectsContainer } = this.props;
// const name = newNameGenerator(
// 'NewObject',
// name =>
// objectsContainer.hasObjectNamed(name) || project.hasObjectNamed(name)
// );
// const object = objectsContainer.insertNewObject(
// project,
// objectType,
// name,
// objectsContainer.getObjectsCount()
// );
// this.setState(
// {
// newObjectDialogOpen: false,
// },
// () => {
// if (this.props.onEditResource) {
// this.props.onEditResource(object);
// }
// }
// );
};
_deleteResource = (resource: gdResource) => {
// const { object, global } = resource;
// const { project, objectsContainer } = this.props;
// //eslint-disable-next-line
// const answer = confirm(
// "Are you sure you want to remove this object? This can't be undone."
// );
// if (!answer) return;
// this.props.onDeleteResource(resource, doRemove => {
// if (!doRemove) return;
// if (global) {
// project.removeObject(object.getName());
// } else {
// objectsContainer.removeObject(object.getName());
// }
// this.forceUpdate();
// });
};
_copyResource = (resource: gdResource) => {
// const { object } = resource;
// Clipboard.set(CLIPBOARD_KIND, {
// type: object.getType(),
// name: object.getName(),
// object: serializeToJSObject(object),
// });
};
_cutResource = (resource: gdResource) => {
// this._copyResource(resource);
// this._deleteResource(resource);
};
_pasteResource = (resource: gdResource) => {
// if (!Clipboard.has(CLIPBOARD_KIND)) return;
// const { object: pasteObject, global } = resource;
// const { object: copiedObject, type, name } = Clipboard.get(CLIPBOARD_KIND);
// const { project, objectsContainer, onObjectPasted } = this.props;
// const newName = newNameGenerator(
// 'CopyOf' + name,
// name =>
// objectsContainer.hasObjectNamed(name) || project.hasObjectNamed(name)
// );
// const newObject = global
// ? project.insertNewObject(
// project,
// type,
// newName,
// project.getObjectPosition(pasteObject.getName())
// )
// : objectsContainer.insertNewObject(
// project,
// type,
// newName,
// objectsContainer.getObjectPosition(pasteObject.getName())
// );
// unserializeFromJSObject(
// newObject,
// copiedObject,
// 'unserializeFrom',
// project
// );
// this.forceUpdate();
// if (onObjectPasted) onObjectPasted(newObject);
};
_editName = (resource: ?gdResource) => {
this.setState(
{
renamedResource: resource,
},
() => this.sortableList.getWrappedInstance().forceUpdateGrid()
);
};
_rename = (resource: gdResource, newName: string) => {
const { project } = this.props;
this.setState({
renamedResource: null,
});
if (resource.getName() === newName) return;
if (project.getResourcesManager().hasResource(newName)) {
showWarningBox('Another resource with this name already exists');
return;
}
this.props.onRenameResource(resource, newName, doRename => {
if (!doRename) return;
resource.setName(newName);
this.forceUpdate();
});
};
_move = (oldIndex: number, newIndex: number) => {
// const { project, objectsContainer } = this.props;
// const isInContainerResourcesList =
// oldIndex < this.containerResourcesList.length;
// if (isInContainerResourcesList) {
// objectsContainer.moveObject(
// oldIndex,
// Math.min(newIndex, this.containerResourcesList.length - 1)
// );
// } else {
// const projectOldIndex = oldIndex - this.containerResourcesList.length;
// const projectNewIndex = newIndex - this.containerResourcesList.length;
// project.moveObject(
// projectOldIndex,
// Math.min(projectNewIndex, this.projectResourcesList.length - 1)
// );
// }
this.forceUpdateList();
};
forceUpdateList = () => {
this.forceUpdate();
this.sortableList.getWrappedInstance().forceUpdateGrid();
};
_renderResourceMenuTemplate = (resource: gdResource) => {
return [
{
label: 'Edit resource',
enabled: false,
click: () => {
/*TODO*/
},
},
{ type: 'separator' },
{
label: 'Rename',
click: () => this._editName(resource),
},
{
label: 'Delete',
click: () => this._deleteResource(resource),
},
{ type: 'separator' },
{
label: 'Add a new resource...',
enabled: false,
click: () => {
/*TODO*/
},
},
{ type: 'separator' },
{
label: 'Copy',
enabled: false,
click: () => this._copyResource(resource),
},
{
label: 'Cut',
enabled: false,
click: () => this._cutResource(resource),
},
{
label: 'Paste',
enabled: Clipboard.has(CLIPBOARD_KIND),
click: () => this._pasteResource(resource),
},
];
};
render() {
const { project } = this.props;
const { searchText } = this.state;
const resourcesManager = project.getResourcesManager();
const allResourcesList = resourcesManager
.getAllResourcesList() // TODO: This should be renamed to getAllResourcesNames
.toJSArray()
.map(resourceName => resourcesManager.getResource(resourceName));
const filteredList = filterResourcesList(allResourcesList, searchText);
const fullList = filteredList.concat({
key: 'add-item-row',
});
// Force List component to be mounted again if project or objectsContainer
// has been changed. Avoid accessing to invalid objects that could
// crash the app.
const listKey = project.ptr;
return (
<Paper style={styles.container}>
<div style={styles.listContainer}>
<AutoSizer>
{({ height, width }) => (
<SortableVirtualizedItemList
key={listKey}
ref={sortableList => (this.sortableList = sortableList)}
fullList={fullList}
width={width}
height={height}
renamedItem={this.state.renamedResource}
onAddNewItem={() => {}}
onRename={this._rename}
onSortEnd={({ oldIndex, newIndex }) =>
this._move(oldIndex, newIndex)}
helperClass="sortable-helper"
distance={30}
buildMenuTemplate={this._renderResourceMenuTemplate}
/>
)}
</AutoSizer>
</div>
<SearchBar
value={searchText}
onRequestSearch={() => {}}
onChange={text =>
this.setState({
searchText: text,
})}
/>
</Paper>
);
}
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { ListItem } from 'material-ui/List';
import IconMenu from '../Menu/IconMenu';
import ListIcon from '../ListIcon';
import IconButton from 'material-ui/IconButton';
import TextField from 'material-ui/TextField';
import MoreVertIcon from 'material-ui/svg-icons/navigation/more-vert';
import muiThemeable from 'material-ui/styles/muiThemeable';
import { type Item } from '.';
const styles = {
itemName: {
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
textField: {
top: -16,
},
};
type Props = {
index: number,
item: Item,
onRename: (string) => void,
editingName: boolean,
getThumbnail?: () => string,
selected: true,
onItemSelected: () => void,
buildMenuTemplate: Item => any,
};
class ThemableItemRow extends React.Component<Props, *> {
_renderItemMenu(item) {
return (
<IconMenu
ref={iconMenu => (this._iconMenu = iconMenu)}
iconButtonElement={
<IconButton>
<MoreVertIcon />
</IconButton>
}
buildMenuTemplate={() => this.props.buildMenuTemplate(item)}
/>
);
}
componentDidUpdate(prevProps) {
if (!prevProps.editingName && this.props.editingName) {
setTimeout(() => {
if (this.textField) this.textField.focus();
}, 100);
}
}
_onContextMenu = event => {
if (this._iconMenu) this._iconMenu.open(event);
};
render() {
const { item, selected, style, getThumbnail, muiTheme } = this.props;
const itemName = item.getName();
const label = this.props.editingName ? (
<TextField
id="rename-item-field"
ref={textField => (this.textField = textField)}
defaultValue={itemName}
onBlur={e => this.props.onRename(e.target.value)}
onKeyPress={event => {
if (event.charCode === 13) {
// enter key pressed
this.textField.blur();
}
}}
fullWidth
style={styles.textField}
/>
) : (
<div
style={{
...styles.itemName,
color: selected ? muiTheme.listItem.selectedTextColor : undefined,
}}
>
{itemName}
</div>
);
const itemStyle = {
borderBottom: `1px solid ${muiTheme.listItem.separatorColor}`,
backgroundColor: selected
? muiTheme.listItem.selectedBackgroundColor
: undefined,
};
return (
<ListItem
style={{ ...itemStyle, ...style }}
onContextMenu={this._onContextMenu}
primaryText={label}
leftIcon={getThumbnail && <ListIcon src={getThumbnail()} />}
rightIconButton={this._renderItemMenu(item)}
onClick={() => {
if (!this.props.onItemSelected) return;
if (this.props.editingName) return;
this.props.onItemSelected(selected ? '' : itemName);
}}
/>
);
}
}
const ItemRow = muiThemeable()(ThemableItemRow);
export default ItemRow;

View File

@@ -0,0 +1,108 @@
// @flow
import * as React from 'react';
import { List } from 'react-virtualized';
import { ListItem } from 'material-ui/List';
import ItemRow from './ItemRow';
import { makeAddItem } from '../ListAddItem';
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
const listItemHeight = 48; // TODO: Move this into theme?
const AddItemRow = makeAddItem(ListItem);
const SortableItemRow = SortableElement(props => {
const { style, ...otherProps } = props;
return (
<div style={style}>
<ItemRow {...otherProps} />
</div>
);
});
const SortableAddItemRow = SortableElement(props => {
return <AddItemRow {...props} />;
});
export type Item = {
key: string | number,
getName: () => string,
};
type ItemsListProps = {
height: number,
width: number,
fullList: Array<Item>,
selectedItem: ?Item,
onAddNewItem: () => void,
onRename: (Item, string) => void,
getThumbnail?: Item => string,
onItemSelected: Item => void,
renamedItem: ?Item,
addNewItemLabel: React.Node | string,
buildMenuTemplate: Item => any,
};
class ItemsList extends React.Component<ItemsListProps, *> {
list: any;
forceUpdateGrid() {
if (this.list) this.list.forceUpdateGrid();
}
render() {
const {
height,
width,
fullList,
selectedItem,
addNewItemLabel,
renamedItem,
getThumbnail,
} = this.props;
return (
<List
ref={list => (this.list = list)}
height={height}
rowCount={fullList.length}
rowHeight={listItemHeight}
rowRenderer={({ index, key, style }) => {
const item = fullList[index];
if (item.key === 'add-item-row') {
return (
<SortableAddItemRow
index={fullList.length}
key={key}
style={style}
disabled
onClick={this.props.onAddNewItem}
primaryText={addNewItemLabel}
/>
);
}
const nameBeingEdited = renamedItem === item;
return (
<SortableItemRow
index={index}
key={item.key}
item={item}
style={style}
onRename={newName => this.props.onRename(item, newName)}
editingName={nameBeingEdited}
getThumbnail={getThumbnail ? () => getThumbnail(item) : undefined}
selected={item === selectedItem}
onItemSelected={this.props.onItemSelected}
buildMenuTemplate={this.props.buildMenuTemplate}
/>
);
}}
width={width}
/>
);
}
}
const SortableItemsList = SortableContainer(ItemsList, { withRef: true });
export default SortableItemsList;

View File

@@ -18,7 +18,7 @@ import BrowserExamples from './ProjectCreation/BrowserExamples';
import BrowserProjectOpener from './ProjectsStorage/BrowserProjectOpener';
import BrowserSaveDialog from './ProjectsStorage/BrowserSaveDialog';
import BrowserIntroDialog from './MainFrame/BrowserIntroDialog';
import browserResourceSources from './ResourcesEditor/BrowserResourceSources';
import browserResourceSources from './ResourcesList/BrowserResourceSources';
import BrowserS3PreviewLauncher from './Export/BrowserExporters/BrowserS3PreviewLauncher';
import { getBrowserExporters } from './Export/BrowserExporters';
@@ -26,7 +26,7 @@ import { getBrowserExporters } from './Export/BrowserExporters';
import ExternalEditor from './ExternalEditor';
import optionalRequire from './Utils/OptionalRequire.js';
import LocalExamples from './ProjectCreation/LocalExamples';
import localResourceSources from './ResourcesEditor/LocalResourceSources';
import localResourceSources from './ResourcesList/LocalResourceSources';
import LocalProjectWriter from './ProjectsStorage/LocalProjectWriter';
import LocalProjectOpener from './ProjectsStorage/LocalProjectOpener';
import LocalPreviewLauncher from './Export/LocalExporters/LocalPreviewLauncher';