Working canvas for 3D previews

This commit is contained in:
Clément Pasteau
2025-04-14 12:47:03 +02:00
parent 623535f7fd
commit fad39ed682
13 changed files with 637 additions and 282 deletions

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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()}

View File

@@ -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 = () => {

View File

@@ -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} />;
}

View File

@@ -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;

View File

@@ -0,0 +1,207 @@
// @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 maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
model.scale.set(scale, scale, scale);
model.position.sub(center);
scene.add(model);
camera.lookAt(new THREE.Vector3(0, 0, 0));
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 () => {
// Ensure the renderer is removed when the canvas is unmounted.
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>
);
};

View File

@@ -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} />} />;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -61,7 +61,11 @@ export const CompactTextAreaField = ({
const title = !markdownDescription
? label
: [label, ' - ', <MarkdownText source={markdownDescription} />];
: [
label,
' - ',
<MarkdownText key="markdown-desc" source={markdownDescription} />,
];
return (
<I18n>

View File

@@ -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

View File

@@ -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>
);