mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
3 Commits
refacto/ed
...
3d-preview
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6d06fbcfe9 | ||
![]() |
37cc28a9ba | ||
![]() |
fad39ed682 |
@@ -27,7 +27,11 @@ type Props = {|
|
||||
const CompactPropertiesEditorRowField = (props: Props) => {
|
||||
const title = !props.markdownDescription
|
||||
? props.label
|
||||
: [props.label, ' - ', <MarkdownText source={props.markdownDescription} />];
|
||||
: [
|
||||
props.label,
|
||||
' - ',
|
||||
<MarkdownText key="markdown-desc" source={props.markdownDescription} />,
|
||||
];
|
||||
return (
|
||||
<LineStackLayout noMargin alignItems="center" expand>
|
||||
<div style={styles.leftColumn}>
|
||||
|
@@ -41,6 +41,7 @@ import { CreditsPackageStoreStateProvider } from '../AssetStore/CreditsPackages/
|
||||
import { ProductLicenseStoreStateProvider } from '../AssetStore/ProductLicense/ProductLicenseStoreContext';
|
||||
import { MarketingPlansStoreStateProvider } from '../MarketingPlans/MarketingPlansStoreContext';
|
||||
import { CourseChapterStoreStateProvider } from '../Course/CourseChapterStoreContext';
|
||||
import { Resource3DPreviewProvider } from '../ResourcesList/ResourcePreview/Resource3DPreviewContext';
|
||||
|
||||
type Props = {|
|
||||
authentication: Authentication,
|
||||
@@ -119,9 +120,13 @@ const Providers = ({
|
||||
<TutorialStateProvider>
|
||||
<AnnouncementsFeedStateProvider>
|
||||
<PrivateAssetsAuthorizationProvider>
|
||||
{children({
|
||||
i18n,
|
||||
})}
|
||||
<Resource3DPreviewProvider>
|
||||
{children(
|
||||
{
|
||||
i18n,
|
||||
}
|
||||
)}
|
||||
</Resource3DPreviewProvider>
|
||||
</PrivateAssetsAuthorizationProvider>
|
||||
</AnnouncementsFeedStateProvider>
|
||||
</TutorialStateProvider>
|
||||
|
@@ -30,11 +30,8 @@ import useAlertDialog from '../../UI/Alert/useAlertDialog';
|
||||
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
|
||||
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils';
|
||||
import * as THREE from 'three';
|
||||
import {
|
||||
PropertyCheckbox,
|
||||
PropertyField,
|
||||
PropertyResourceSelector,
|
||||
} from './PropertyFields';
|
||||
import { PropertyCheckbox, PropertyField } from './PropertyFields';
|
||||
import ResourceSelectorWithThumbnail from '../../ResourcesList/ResourceSelectorWithThumbnail';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
@@ -485,14 +482,18 @@ const Model3DEditor = ({
|
||||
<ScrollView ref={scrollView}>
|
||||
<ColumnStackLayout noMargin>
|
||||
{renderObjectNameField && renderObjectNameField()}
|
||||
<PropertyResourceSelector
|
||||
objectConfiguration={objectConfiguration}
|
||||
propertyName="modelResourceName"
|
||||
<ResourceSelectorWithThumbnail
|
||||
project={project}
|
||||
resourceKind="model3D"
|
||||
floatingLabelText={properties.get('modelResourceName').getLabel()}
|
||||
resourceManagementProps={resourceManagementProps}
|
||||
onChange={resourceName => {
|
||||
loadGltf(resourceName);
|
||||
resourceName={properties.get('modelResourceName').getValue()}
|
||||
onChange={newValue => {
|
||||
onChangeProperty('modelResourceName', newValue);
|
||||
loadGltf(newValue);
|
||||
forceUpdate();
|
||||
}}
|
||||
id={`model3d-object-modelResourceName`}
|
||||
/>
|
||||
<SelectField
|
||||
value={properties.get('materialType').getValue()}
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import ResourcesList from '../ResourcesList';
|
||||
import ResourcesList, { type ResourcesListInterface } from '../ResourcesList';
|
||||
import ResourcePropertiesEditor, {
|
||||
type ResourcePropertiesEditorInterface,
|
||||
} from './ResourcePropertiesEditor';
|
||||
@@ -73,7 +73,7 @@ export default class ResourcesEditor extends React.Component<Props, State> {
|
||||
resourceExternallyChangedCallbackId: ?string;
|
||||
editorMosaic: ?EditorMosaicInterface = null;
|
||||
_propertiesEditor: ?ResourcePropertiesEditorInterface = null;
|
||||
_resourcesList: ?ResourcesList = null;
|
||||
_resourcesList: ?ResourcesListInterface = null;
|
||||
resourcesLoader = ResourcesLoader;
|
||||
state = {
|
||||
selectedResource: null,
|
||||
@@ -91,7 +91,7 @@ export default class ResourcesEditor extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
refreshResourcesList() {
|
||||
if (this._resourcesList) this._resourcesList.forceUpdate();
|
||||
if (this._resourcesList) this._resourcesList.forceUpdateList();
|
||||
}
|
||||
|
||||
updateToolbar = () => {
|
||||
|
@@ -9,6 +9,7 @@ import Text from '../../UI/Text';
|
||||
import { getDefaultResourceThumbnail } from '..';
|
||||
import { getPixelatedImageRendering } from '../../Utils/CssHelpers';
|
||||
import { isProjectImageResourceSmooth } from '../ResourcePreview/ImagePreview';
|
||||
import Model3DPreview from '../ResourcePreview/Model3DPreview';
|
||||
|
||||
const paddingSize = 10;
|
||||
const styles = {
|
||||
@@ -126,6 +127,16 @@ export const ProjectResourceCard = ({
|
||||
switch (resource.getKind()) {
|
||||
case 'image':
|
||||
return <ImagePreview resource={resource} project={project} />;
|
||||
case 'model3D':
|
||||
return (
|
||||
<Model3DPreview
|
||||
modelUrl={ResourcesLoader.getResourceFullUrl(
|
||||
project,
|
||||
resourceName,
|
||||
{}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <DefaultPreview resource={resource} />;
|
||||
}
|
||||
|
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
|
||||
import ResourcePreviewContext from './Resource3DPreviewContext';
|
||||
import PlaceholderLoader from '../../UI/PlaceholderLoader';
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
minHeight: 0,
|
||||
height: '100%',
|
||||
},
|
||||
background: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'url("res/transparentback.png") repeat',
|
||||
},
|
||||
screenshot: {
|
||||
position: 'relative',
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
loader: {
|
||||
padding: 0,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
modelUrl: string,
|
||||
expand?: boolean,
|
||||
size?: number,
|
||||
|};
|
||||
|
||||
const Model3DPreview = ({ modelUrl, size, expand }: Props) => {
|
||||
const { getResourcePreview } = React.useContext(ResourcePreviewContext);
|
||||
const [imageDataUrl, setImageDataUrl] = React.useState(null);
|
||||
const theme = React.useContext(GDevelopThemeContext);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
async function loadPreviewImageUrl() {
|
||||
const dataUrl = await getResourcePreview(modelUrl);
|
||||
setImageDataUrl(dataUrl);
|
||||
}
|
||||
loadPreviewImageUrl();
|
||||
},
|
||||
[modelUrl, getResourcePreview]
|
||||
);
|
||||
|
||||
const loaderSize = size ? Math.max(size, 24) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...styles.container,
|
||||
flex: expand ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...styles.background,
|
||||
filter: theme.imagePreview.backgroundFilter || 'none',
|
||||
}}
|
||||
/>
|
||||
{imageDataUrl ? (
|
||||
<img
|
||||
src={imageDataUrl}
|
||||
alt="3D Model Preview"
|
||||
style={{
|
||||
...styles.screenshot,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderLoader size={loaderSize} style={styles.loader} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Model3DPreview;
|
@@ -0,0 +1,217 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
|
||||
|
||||
export type Resource3DPreviewState = {|
|
||||
getResourcePreview: (resourceUrl: string) => Promise<?string>,
|
||||
|};
|
||||
|
||||
const initialResource3DPreviewState = {
|
||||
getResourcePreview: async (_resourceUrl: string) => null,
|
||||
};
|
||||
|
||||
const Resource3DPreviewContext = React.createContext<Resource3DPreviewState>(
|
||||
initialResource3DPreviewState
|
||||
);
|
||||
|
||||
export default Resource3DPreviewContext;
|
||||
|
||||
const SingleCanvasRenderer = ({
|
||||
resourceUrl,
|
||||
onPreviewReady,
|
||||
}: {
|
||||
resourceUrl: string,
|
||||
onPreviewReady: (resourceUrl: string, dataUrl: string) => void,
|
||||
}) => {
|
||||
const containerRef = React.useRef();
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const width = 256;
|
||||
const height = 256;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
camera.position.set(2, 2, 4);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
});
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
|
||||
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x888888, 3);
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 3);
|
||||
hemiLight.position.set(0, 20, 0);
|
||||
mainLight.position.set(5, 10, 7.5);
|
||||
scene.add(ambientLight, hemiLight, mainLight);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
let model = null;
|
||||
|
||||
loader.load(
|
||||
resourceUrl,
|
||||
gltf => {
|
||||
model = gltf.scene;
|
||||
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const size = new THREE.Vector3();
|
||||
const center = new THREE.Vector3();
|
||||
box.getSize(size);
|
||||
box.getCenter(center);
|
||||
|
||||
const sphere = new THREE.Sphere();
|
||||
box.getBoundingSphere(sphere);
|
||||
|
||||
const scale = 1 / sphere.radius;
|
||||
model.scale.set(scale, scale, scale);
|
||||
|
||||
// Center horizontally
|
||||
model.position.x -= center.x * scale;
|
||||
model.position.z -= center.z * scale;
|
||||
|
||||
// Slight upward shift so base isn't too low
|
||||
model.position.y -= (center.y - size.y / 2) * scale;
|
||||
|
||||
scene.add(model);
|
||||
|
||||
// Aim camera slightly above center
|
||||
camera.lookAt(0, 0.75, 0);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
renderer.render(scene, camera);
|
||||
const screenshot = renderer.domElement.toDataURL();
|
||||
onPreviewReady(resourceUrl, screenshot);
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
error => {
|
||||
console.error('Failed to load 3D model:', error);
|
||||
onPreviewReady(resourceUrl, 'JsPlatform/Extensions/3d_model.svg');
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
renderer.dispose();
|
||||
};
|
||||
},
|
||||
[resourceUrl, onPreviewReady]
|
||||
);
|
||||
|
||||
return (
|
||||
// This component is here just to create a canvas and render the 3D model.
|
||||
// The canvas is not visible to the user.
|
||||
<div
|
||||
id="preview-3d-canvas"
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 256,
|
||||
height: 256,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
|};
|
||||
|
||||
export const Resource3DPreviewProvider = ({ children }: Props) => {
|
||||
const [currentResource, setCurrentResource] = React.useState<?string>(null);
|
||||
const queueRef = React.useRef<
|
||||
Array<{ url: string, resolve: (dataUrl: ?string) => void }>
|
||||
>([]);
|
||||
const previewCache = React.useRef<{ [url: string]: string }>({});
|
||||
const timeoutRef = React.useRef<?TimeoutID>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Ensure we clear everything when the component is unmounted.
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
queueRef.current = [];
|
||||
previewCache.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
const enqueueResource = React.useCallback((url: string): Promise<?string> => {
|
||||
return new Promise(resolve => {
|
||||
// If it's already in the cache, resolve immediately.
|
||||
if (previewCache.current[url]) {
|
||||
resolve(previewCache.current[url]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the timeout if it exists. (in case it just finished processing)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
|
||||
// Add the item to the queue.
|
||||
queueRef.current.push({ url, resolve });
|
||||
// If the queue didn't have items before,
|
||||
// then process it immediatelly.
|
||||
// Otherwise, let the queue process handle it.
|
||||
if (queueRef.current.length === 1) {
|
||||
setCurrentResource(url);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Called when a preview is generated.
|
||||
// - Resolves the promise with the data URL.
|
||||
// - Caches the data URL.
|
||||
// - Starts the next preview in the queue. (or hide the canvas if empty)
|
||||
const handlePreviewGenerated = React.useCallback(
|
||||
(url: string, dataUrl: string) => {
|
||||
// Save it in the cache for future use.
|
||||
previewCache.current[url] = dataUrl;
|
||||
// Resolve all the requests made for that URL.
|
||||
const queueItemsToResolve = queueRef.current.filter(
|
||||
item => item.url === url
|
||||
);
|
||||
queueItemsToResolve.forEach(item => {
|
||||
const { resolve } = item;
|
||||
if (resolve) resolve(dataUrl);
|
||||
});
|
||||
|
||||
// Remove the items from the queue.
|
||||
queueRef.current = queueRef.current.filter(item => item.url !== url);
|
||||
|
||||
// And trigger the next item to be processed.
|
||||
const nextItemToProcess = queueRef.current[0];
|
||||
if (nextItemToProcess) {
|
||||
setCurrentResource(nextItemToProcess.url);
|
||||
} else {
|
||||
// Auto-hide after 2s if no new resources.
|
||||
timeoutRef.current = setTimeout(() => setCurrentResource(null), 2000);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Resource3DPreviewContext.Provider
|
||||
value={{ getResourcePreview: enqueueResource }}
|
||||
>
|
||||
{children}
|
||||
{currentResource && (
|
||||
<SingleCanvasRenderer
|
||||
resourceUrl={currentResource}
|
||||
onPreviewReady={handlePreviewGenerated}
|
||||
/>
|
||||
)}
|
||||
</Resource3DPreviewContext.Provider>
|
||||
);
|
||||
};
|
@@ -7,6 +7,7 @@ import FontDownload from '@material-ui/icons/FontDownload';
|
||||
import File from '../../UI/CustomSvgIcons/File';
|
||||
import Video from '../../UI/CustomSvgIcons/Video';
|
||||
import Music from '../../UI/CustomSvgIcons/Music';
|
||||
import Model3DPreview from './Model3DPreview';
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
@@ -42,10 +43,20 @@ const ResourcePreview = (props: Props) => {
|
||||
);
|
||||
case 'audio':
|
||||
return <GenericIconPreview renderIcon={props => <Music {...props} />} />;
|
||||
case 'model3D':
|
||||
return (
|
||||
<Model3DPreview
|
||||
modelUrl={props.resourcesLoader.getResourceFullUrl(
|
||||
project,
|
||||
resourceName,
|
||||
{}
|
||||
)}
|
||||
expand
|
||||
/>
|
||||
);
|
||||
case 'json':
|
||||
case 'tilemap':
|
||||
case 'tileset':
|
||||
case 'model3D':
|
||||
case 'atlas':
|
||||
case 'spine':
|
||||
return <GenericIconPreview renderIcon={props => <File {...props} />} />;
|
||||
|
@@ -3,6 +3,7 @@ import * as React from 'react';
|
||||
import ResourcesLoader from '../../ResourcesLoader';
|
||||
import { type ResourceKind } from '../ResourceSource';
|
||||
import ImageThumbnail from './ImageThumbnail';
|
||||
import Model3DPreview from '../ResourcePreview/Model3DPreview';
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
@@ -17,7 +18,7 @@ type Props = {|
|
||||
size?: number,
|
||||
|};
|
||||
|
||||
export const resourcesKindsWithThumbnail = ['image'];
|
||||
export const resourcesKindsWithThumbnail = ['image', 'model3D'];
|
||||
|
||||
/**
|
||||
* Display the right thumbnail for any given resource of a project
|
||||
@@ -49,6 +50,17 @@ const ResourceThumbnail = ({
|
||||
size={size || 100}
|
||||
/>
|
||||
);
|
||||
case 'model3D':
|
||||
return (
|
||||
<Model3DPreview
|
||||
modelUrl={ResourcesLoader.getResourceFullUrl(
|
||||
project,
|
||||
resourceName,
|
||||
{}
|
||||
)}
|
||||
size={size || 100}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@@ -5,7 +5,6 @@ import { t } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AutoSizer } from 'react-virtualized';
|
||||
import SortableVirtualizedItemList from '../UI/SortableVirtualizedItemList';
|
||||
import Background from '../UI/Background';
|
||||
import SearchBar from '../UI/SearchBar';
|
||||
import { showWarningBox } from '../UI/Messages/MessageBox';
|
||||
@@ -21,6 +20,8 @@ import ResourcesLoader from '../ResourcesLoader';
|
||||
import { Column, Line } from '../UI/Grid';
|
||||
import { type ResourcesActionsMenuBuilder } from '../ProjectsStorage';
|
||||
import InfoBar from '../UI/Messages/InfoBar';
|
||||
import useForceUpdate from '../Utils/UseForceUpdate';
|
||||
import SortableVirtualizedItemList from '../UI/SortableVirtualizedItemList';
|
||||
|
||||
const styles = {
|
||||
listContainer: {
|
||||
@@ -53,15 +54,9 @@ export const getDefaultResourceThumbnail = (resource: gdResource) => {
|
||||
}
|
||||
};
|
||||
|
||||
type State = {|
|
||||
renamedResource: ?gdResource,
|
||||
searchText: string,
|
||||
resourcesWithErrors: { [string]: '' | 'error' | 'warning' },
|
||||
infoBarContent: ?{|
|
||||
message: React.Node,
|
||||
actionLabel?: React.Node,
|
||||
onActionClick?: () => void,
|
||||
|},
|
||||
export type ResourcesListInterface = {|
|
||||
forceUpdateList: () => void,
|
||||
checkMissingPaths: () => void,
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
@@ -80,262 +75,270 @@ type Props = {|
|
||||
getResourceActionsSpecificToStorageProvider?: ?ResourcesActionsMenuBuilder,
|
||||
|};
|
||||
|
||||
export default class ResourcesList extends React.Component<Props, State> {
|
||||
sortableList: any;
|
||||
state: State = {
|
||||
renamedResource: null,
|
||||
searchText: '',
|
||||
resourcesWithErrors: {},
|
||||
infoBarContent: null,
|
||||
};
|
||||
|
||||
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 ||
|
||||
this.state.infoBarContent !== nextState.infoBarContent
|
||||
)
|
||||
return true;
|
||||
|
||||
if (
|
||||
this.props.project !== nextProps.project ||
|
||||
this.props.selectedResource !== nextProps.selectedResource
|
||||
)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_deleteResource = (resource: gdResource) => {
|
||||
this.props.onDeleteResource(resource);
|
||||
};
|
||||
|
||||
_editName = (resource: ?gdResource) => {
|
||||
this.setState(
|
||||
const ResourcesList = React.memo<Props, ResourcesListInterface>(
|
||||
React.forwardRef<Props, ResourcesListInterface>(
|
||||
(
|
||||
{
|
||||
renamedResource: resource,
|
||||
},
|
||||
() => {
|
||||
if (this.sortableList) this.sortableList.forceUpdateGrid();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
_getResourceThumbnail = (resource: gdResource) => {
|
||||
switch (resource.getKind()) {
|
||||
case 'image':
|
||||
return ResourcesLoader.getResourceFullUrl(
|
||||
this.props.project,
|
||||
resource.getName(),
|
||||
{}
|
||||
);
|
||||
default:
|
||||
return getDefaultResourceThumbnail(resource);
|
||||
}
|
||||
};
|
||||
|
||||
_rename = (resource: gdResource, newName: string) => {
|
||||
const { project } = this.props;
|
||||
this.setState({
|
||||
renamedResource: null,
|
||||
});
|
||||
|
||||
if (resource.getName() === newName || newName.length === 0) return;
|
||||
|
||||
if (project.getResourcesManager().hasResource(newName)) {
|
||||
showWarningBox('Another resource with this name already exists', {
|
||||
delayToNextTick: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onRenameResource(resource, newName, doRename => {
|
||||
if (!doRename) return;
|
||||
resource.setName(newName);
|
||||
this.forceUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
_moveSelectionTo = (destinationResource: gdResource) => {
|
||||
const { project, selectedResource } = this.props;
|
||||
if (!selectedResource) return;
|
||||
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
resourcesManager.moveResource(
|
||||
resourcesManager.getResourcePosition(selectedResource.getName()),
|
||||
resourcesManager.getResourcePosition(destinationResource.getName())
|
||||
);
|
||||
this.forceUpdateList();
|
||||
};
|
||||
|
||||
forceUpdateList = () => {
|
||||
this.forceUpdate();
|
||||
if (this.sortableList) this.sortableList.forceUpdateGrid();
|
||||
};
|
||||
|
||||
_renderResourceMenuTemplate = (i18n: I18nType) => (
|
||||
resource: gdResource,
|
||||
_index: number
|
||||
): Array<MenuItemTemplate> => {
|
||||
const {
|
||||
getResourceActionsSpecificToStorageProvider,
|
||||
fileMetadata,
|
||||
} = this.props;
|
||||
let menu = [
|
||||
{
|
||||
label: i18n._(t`Rename`),
|
||||
click: () => this._editName(resource),
|
||||
},
|
||||
{
|
||||
label: i18n._(t`Delete`),
|
||||
click: () => this._deleteResource(resource),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: i18n._(t`Remove unused...`),
|
||||
submenu: allResourceKindsAndMetadata
|
||||
.map(({ displayName, kind }) => ({
|
||||
label: i18n._(displayName),
|
||||
click: () => {
|
||||
this.props.onRemoveUnusedResources(kind);
|
||||
},
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
label: i18n._(t`Resources (any kind)`),
|
||||
click: () => {
|
||||
allResourceKindsAndMetadata.forEach(resourceKindAndMetadata => {
|
||||
this.props.onRemoveUnusedResources(
|
||||
resourceKindAndMetadata.kind
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
];
|
||||
if (getResourceActionsSpecificToStorageProvider && fileMetadata) {
|
||||
menu.push({ type: 'separator' });
|
||||
menu = menu.concat(
|
||||
getResourceActionsSpecificToStorageProvider({
|
||||
project: this.props.project,
|
||||
fileMetadata,
|
||||
resource,
|
||||
i18n,
|
||||
informUser: this.openInfoBar,
|
||||
updateInterface: () => this.forceUpdateList(),
|
||||
cleanUserSelectionOfResources: () =>
|
||||
this.props.onSelectResource(null),
|
||||
})
|
||||
);
|
||||
}
|
||||
return menu;
|
||||
};
|
||||
|
||||
checkMissingPaths = () => {
|
||||
const { project } = this.props;
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
const resourceNames = resourcesManager.getAllResourceNames().toJSArray();
|
||||
const resourcesWithErrors = {};
|
||||
resourceNames.forEach(resourceName => {
|
||||
resourcesWithErrors[resourceName] = getResourceFilePathStatus(
|
||||
project,
|
||||
resourceName
|
||||
selectedResource,
|
||||
onSelectResource,
|
||||
onDeleteResource,
|
||||
onRenameResource,
|
||||
fileMetadata,
|
||||
onRemoveUnusedResources,
|
||||
getResourceActionsSpecificToStorageProvider,
|
||||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
const forceUpdate = useForceUpdate();
|
||||
const [renamedResource, setRenamedResource] = React.useState(null);
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
const [resourcesWithErrors, setResourcesWithErrors] = React.useState({});
|
||||
const [infoBarContent, setInfoBarContent] = React.useState(null);
|
||||
const sortableListRef = React.useRef(null);
|
||||
|
||||
const deleteResource = React.useCallback(
|
||||
(resource: gdResource) => {
|
||||
onDeleteResource(resource);
|
||||
},
|
||||
[onDeleteResource]
|
||||
);
|
||||
});
|
||||
this.setState({ resourcesWithErrors });
|
||||
this.forceUpdateList();
|
||||
};
|
||||
|
||||
openInfoBar = (
|
||||
infoBarContent: ?{|
|
||||
message: React.Node,
|
||||
actionLabel?: React.Node,
|
||||
onActionClick?: () => void,
|
||||
|}
|
||||
) => {
|
||||
this.setState({ infoBarContent });
|
||||
};
|
||||
const editName = React.useCallback((resource: ?gdResource) => {
|
||||
setRenamedResource(resource);
|
||||
if (sortableListRef.current) sortableListRef.current.forceUpdateGrid();
|
||||
}, []);
|
||||
|
||||
componentDidMount() {
|
||||
this.checkMissingPaths();
|
||||
}
|
||||
const getResourceThumbnail = React.useCallback(
|
||||
(resource: gdResource) => {
|
||||
switch (resource.getKind()) {
|
||||
case 'image':
|
||||
return ResourcesLoader.getResourceFullUrl(
|
||||
project,
|
||||
resource.getName(),
|
||||
{}
|
||||
);
|
||||
default:
|
||||
return getDefaultResourceThumbnail(resource);
|
||||
}
|
||||
},
|
||||
[project]
|
||||
);
|
||||
|
||||
render() {
|
||||
const { project, selectedResource, onSelectResource } = this.props;
|
||||
const { searchText, infoBarContent } = this.state;
|
||||
const forceUpdateList = React.useCallback(
|
||||
() => {
|
||||
// Force re-render of component
|
||||
forceUpdate();
|
||||
if (sortableListRef.current)
|
||||
sortableListRef.current.forceUpdateGrid();
|
||||
},
|
||||
[forceUpdate]
|
||||
);
|
||||
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
const allResourcesList = resourcesManager
|
||||
.getAllResourceNames()
|
||||
.toJSArray()
|
||||
.map(resourceName => resourcesManager.getResource(resourceName));
|
||||
const filteredList = filterResourcesList(allResourcesList, searchText);
|
||||
const renameResource = React.useCallback(
|
||||
(resource: gdResource, newName: string) => {
|
||||
setRenamedResource(null);
|
||||
|
||||
// Force List component to be mounted again if project
|
||||
// has been changed. Avoid accessing to invalid objects that could
|
||||
// crash the app.
|
||||
const listKey = project.ptr;
|
||||
if (resource.getName() === newName || newName.length === 0) return;
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<Line>
|
||||
<Column expand>
|
||||
<SearchBar
|
||||
value={searchText}
|
||||
onRequestSearch={() => {}}
|
||||
onChange={text =>
|
||||
this.setState({
|
||||
searchText: text,
|
||||
})
|
||||
}
|
||||
placeholder={t`Search resources`}
|
||||
if (project.getResourcesManager().hasResource(newName)) {
|
||||
showWarningBox('Another resource with this name already exists', {
|
||||
delayToNextTick: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onRenameResource(resource, newName, doRename => {
|
||||
if (!doRename) return;
|
||||
resource.setName(newName);
|
||||
// Force re-render
|
||||
forceUpdateList();
|
||||
});
|
||||
},
|
||||
[project, onRenameResource, forceUpdateList]
|
||||
);
|
||||
|
||||
const moveSelectionTo = React.useCallback(
|
||||
(destinationResource: gdResource) => {
|
||||
if (!selectedResource) return;
|
||||
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
resourcesManager.moveResource(
|
||||
resourcesManager.getResourcePosition(selectedResource.getName()),
|
||||
resourcesManager.getResourcePosition(destinationResource.getName())
|
||||
);
|
||||
forceUpdateList();
|
||||
},
|
||||
[project, selectedResource, forceUpdateList]
|
||||
);
|
||||
|
||||
const renderResourceMenuTemplate = React.useCallback(
|
||||
(i18n: I18nType) => (
|
||||
resource: gdResource,
|
||||
_index: number
|
||||
): Array<MenuItemTemplate> => {
|
||||
let menu = [
|
||||
{
|
||||
label: i18n._(t`Rename`),
|
||||
click: () => editName(resource),
|
||||
},
|
||||
{
|
||||
label: i18n._(t`Delete`),
|
||||
click: () => deleteResource(resource),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: i18n._(t`Remove unused...`),
|
||||
submenu: allResourceKindsAndMetadata
|
||||
.map(({ displayName, kind }) => ({
|
||||
label: i18n._(displayName),
|
||||
click: () => {
|
||||
onRemoveUnusedResources(kind);
|
||||
},
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
label: i18n._(t`Resources (any kind)`),
|
||||
click: () => {
|
||||
allResourceKindsAndMetadata.forEach(
|
||||
resourceKindAndMetadata => {
|
||||
onRemoveUnusedResources(resourceKindAndMetadata.kind);
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
];
|
||||
if (getResourceActionsSpecificToStorageProvider && fileMetadata) {
|
||||
menu.push({ type: 'separator' });
|
||||
menu = menu.concat(
|
||||
getResourceActionsSpecificToStorageProvider({
|
||||
project,
|
||||
fileMetadata,
|
||||
resource,
|
||||
i18n,
|
||||
informUser: setInfoBarContent,
|
||||
updateInterface: () => forceUpdateList(),
|
||||
cleanUserSelectionOfResources: () => onSelectResource(null),
|
||||
})
|
||||
);
|
||||
}
|
||||
return menu;
|
||||
},
|
||||
[
|
||||
project,
|
||||
fileMetadata,
|
||||
editName,
|
||||
deleteResource,
|
||||
onRemoveUnusedResources,
|
||||
getResourceActionsSpecificToStorageProvider,
|
||||
onSelectResource,
|
||||
forceUpdateList,
|
||||
]
|
||||
);
|
||||
|
||||
const checkMissingPaths = React.useCallback(
|
||||
() => {
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
const resourceNames = resourcesManager
|
||||
.getAllResourceNames()
|
||||
.toJSArray();
|
||||
const newResourcesWithErrors = {};
|
||||
resourceNames.forEach(resourceName => {
|
||||
newResourcesWithErrors[resourceName] = getResourceFilePathStatus(
|
||||
project,
|
||||
resourceName
|
||||
);
|
||||
});
|
||||
setResourcesWithErrors(newResourcesWithErrors);
|
||||
forceUpdateList();
|
||||
},
|
||||
[project, forceUpdateList]
|
||||
);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
forceUpdateList,
|
||||
checkMissingPaths,
|
||||
}));
|
||||
|
||||
// Check missing paths on mount and when project changes.
|
||||
React.useEffect(
|
||||
() => {
|
||||
checkMissingPaths();
|
||||
},
|
||||
[checkMissingPaths]
|
||||
);
|
||||
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
const allResourcesList = resourcesManager
|
||||
.getAllResourceNames()
|
||||
.toJSArray()
|
||||
.map(resourceName => resourcesManager.getResource(resourceName));
|
||||
const filteredList = filterResourcesList(allResourcesList, searchText);
|
||||
|
||||
// Force List component to be mounted again if project
|
||||
// has been changed. Avoid accessing to invalid objects that could
|
||||
// crash the app.
|
||||
const listKey = project.ptr;
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<Line>
|
||||
<Column expand>
|
||||
<SearchBar
|
||||
value={searchText}
|
||||
onRequestSearch={() => {}}
|
||||
onChange={text => setSearchText(text)}
|
||||
placeholder={t`Search resources`}
|
||||
/>
|
||||
</Column>
|
||||
</Line>
|
||||
<div style={styles.listContainer}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SortableVirtualizedItemList
|
||||
key={listKey}
|
||||
ref={sortableListRef}
|
||||
fullList={filteredList}
|
||||
width={width}
|
||||
height={height}
|
||||
getItemName={getResourceName}
|
||||
getItemThumbnail={getResourceThumbnail}
|
||||
selectedItems={selectedResource ? [selectedResource] : []}
|
||||
onItemSelected={onSelectResource}
|
||||
renamedItem={renamedResource}
|
||||
onRename={renameResource}
|
||||
onMoveSelectionToItem={moveSelectionTo}
|
||||
buildMenuTemplate={renderResourceMenuTemplate(i18n)}
|
||||
erroredItems={resourcesWithErrors}
|
||||
reactDndType="GD_RESOURCE"
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{!!infoBarContent && (
|
||||
<InfoBar
|
||||
duration={7000}
|
||||
visible
|
||||
hide={() => setInfoBarContent(null)}
|
||||
{...infoBarContent}
|
||||
/>
|
||||
</Column>
|
||||
</Line>
|
||||
<div style={styles.listContainer}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SortableVirtualizedItemList
|
||||
key={listKey}
|
||||
ref={sortableList => (this.sortableList = sortableList)}
|
||||
fullList={filteredList}
|
||||
width={width}
|
||||
height={height}
|
||||
getItemName={getResourceName}
|
||||
getItemThumbnail={this._getResourceThumbnail}
|
||||
selectedItems={selectedResource ? [selectedResource] : []}
|
||||
onItemSelected={onSelectResource}
|
||||
renamedItem={this.state.renamedResource}
|
||||
onRename={this._rename}
|
||||
onMoveSelectionToItem={this._moveSelectionTo}
|
||||
buildMenuTemplate={this._renderResourceMenuTemplate(i18n)}
|
||||
erroredItems={this.state.resourcesWithErrors}
|
||||
reactDndType="GD_RESOURCE"
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{!!infoBarContent && (
|
||||
<InfoBar
|
||||
duration={7000}
|
||||
visible
|
||||
hide={() => this.setState({ infoBarContent: null })}
|
||||
{...infoBarContent}
|
||||
/>
|
||||
)}
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
}
|
||||
)}
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
),
|
||||
// Prevent any update if project or selectedResource
|
||||
// are not changed. This is important to avoid
|
||||
// too many re-renders of the list.
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.project !== nextProps.project ||
|
||||
prevProps.selectedResource !== nextProps.selectedResource
|
||||
);
|
||||
|
||||
export default ResourcesList;
|
||||
|
@@ -61,7 +61,11 @@ export const CompactTextAreaField = ({
|
||||
|
||||
const title = !markdownDescription
|
||||
? label
|
||||
: [label, ' - ', <MarkdownText source={markdownDescription} />];
|
||||
: [
|
||||
label,
|
||||
' - ',
|
||||
<MarkdownText key="markdown-desc" source={markdownDescription} />,
|
||||
];
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
|
@@ -32,7 +32,11 @@ export const CompactToggleField = (props: Props) => {
|
||||
? null
|
||||
: !props.markdownDescription
|
||||
? props.label
|
||||
: [props.label, ' - ', <MarkdownText source={props.markdownDescription} />];
|
||||
: [
|
||||
props.label,
|
||||
' - ',
|
||||
<MarkdownText key="markdown-desc" source={props.markdownDescription} />,
|
||||
];
|
||||
|
||||
const label = (
|
||||
<Text
|
||||
|
@@ -11,9 +11,14 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
const PlaceholderLoader = props => (
|
||||
type Props = {|
|
||||
style?: any,
|
||||
size?: number,
|
||||
|};
|
||||
|
||||
const PlaceholderLoader = (props: Props) => (
|
||||
<div style={{ ...styles.containerStyle, ...props.style }}>
|
||||
<CircularProgress size={40} />
|
||||
<CircularProgress size={props.size || 40} />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
Reference in New Issue
Block a user