diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 0cecfd3ad256..a465b54e95cc 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -91,6 +91,7 @@ import { maybeClearPseudoInsertMode } from '../canvas-toolbar-states' import { isSteganographyEnabled } from '../../../core/shared/stegano-text' import { updateCollaborativeProjectContents } from './collaborative-editing' import { updateProjectServerStateInStore } from './project-server-state' +import { ensureSceneIdsExist } from '../../../core/model/scene-utils' type DispatchResultFields = { nothingChanged: boolean @@ -819,6 +820,7 @@ function editorDispatchInner( if (dispatchedActions.length > 0) { // Run everything in a big chain. let result = processActions(boundDispatch, storedState, dispatchedActions, spyCollector) + result.unpatchedEditor = ensureSceneIdsExist(result.unpatchedEditor) const anyUndoOrRedo = dispatchedActions.some(isUndoOrRedo) diff --git a/editor/src/core/model/element-template-utils.ts b/editor/src/core/model/element-template-utils.ts index 8f013793a64b..ea7192651ed9 100644 --- a/editor/src/core/model/element-template-utils.ts +++ b/editor/src/core/model/element-template-utils.ts @@ -141,6 +141,40 @@ export function transformJSXComponentAtPath( : transformJSXComponentAtElementPath(components, lastElementPathPart, transform) } +export function transformJSXElementChildRecursively( + element: JSXElementChild, + transform: (_: JSXElementChild) => JSXElementChild, +): JSXElementChild { + switch (element.type) { + case 'ATTRIBUTE_FUNCTION_CALL': + case 'ATTRIBUTE_NESTED_ARRAY': + case 'ATTRIBUTE_NESTED_OBJECT': + case 'ATTRIBUTE_OTHER_JAVASCRIPT': + case 'ATTRIBUTE_VALUE': + case 'JSX_TEXT_BLOCK': + return element + case 'JSX_CONDITIONAL_EXPRESSION': + const whenTrue = transform(element.whenTrue) + const whenFalse = transform(element.whenTrue) + return transform({ ...element, whenTrue, whenFalse }) + case 'JSX_FRAGMENT': { + const children = element.children.map((c) => transform(c)) + return transform({ ...element, children }) + } + case 'JSX_MAP_EXPRESSION': + let elementsWithin: ElementsWithin = {} + for (const [key, value] of Object.entries(element.elementsWithin)) { + elementsWithin[key] = transform(value) as JSXElement + } + return transform({ ...element, elementsWithin }) + case 'JSX_ELEMENT': + const children = element.children.map((c) => transform(c)) + return transform({ ...element, children }) + default: + assertNever(element) + } +} + export function transformJSXComponentAtElementPath( components: Array, path: StaticElementPathPart, diff --git a/editor/src/core/model/scene-utils.ts b/editor/src/core/model/scene-utils.ts index 199c48e8c8fb..db7ae6292c55 100644 --- a/editor/src/core/model/scene-utils.ts +++ b/editor/src/core/model/scene-utils.ts @@ -1,5 +1,10 @@ import Hash from 'object-hash' -import type { SceneMetadata, StaticElementPath, PropertyPath } from '../shared/project-file-types' +import type { + SceneMetadata, + StaticElementPath, + PropertyPath, + TextFile, +} from '../shared/project-file-types' import { isTextFile, isParseSuccess } from '../shared/project-file-types' import type { UtopiaJSXComponent, @@ -21,6 +26,7 @@ import { ElementInstanceMetadata, walkElements, emptyComments, + getJSXAttribute, } from '../shared/element-template' import * as EP from '../shared/element-path' import * as PP from '../shared/property-path' @@ -37,14 +43,27 @@ import fastDeepEqual from 'fast-deep-equal' import { getModifiableJSXAttributeAtPath, jsxSimpleAttributeToValue, + setJSXValueAtPath, } from '../shared/jsx-attributes' import { stripNulls } from '../shared/array-utils' import { UTOPIA_UID_KEY } from './utopia-constants' import type { ProjectContentTreeRoot } from '../../components/assets' -import { getProjectFileByFilePath } from '../../components/assets' -import { getUtopiaJSXComponentsFromSuccess } from './project-file-utils' +import { + addFileToProjectContents, + getContentsTreeFromPath, + getProjectFileByFilePath, +} from '../../components/assets' +import { + getUtopiaJSXComponentsFromSuccess, + isRemixSceneAgainstImports, + isSceneAgainstImports, +} from './project-file-utils' import { generateConsistentUID, generateUID, getUtopiaID } from '../shared/uid-utils' import { emptySet } from '../shared/set-utils' +import { IS_TEST_ENVIRONMENT } from '../../common/env-vars' +import type { EditorState } from '../../components/editor/store/editor-state' +import { StoryboardFilePath } from '../../components/editor/store/editor-state' +import { transformJSXElementChildRecursively } from './element-template-utils' export const PathForSceneComponent = PP.create('component') export const PathForSceneDataUid = PP.create('data-uid') @@ -298,3 +317,104 @@ export function getStoryboardElementPath( } return null } + +const IdPropName = 'id' + +function getIdPropFromJSXElement(element: JSXElement): string | null { + const maybeIdProp = getJSXAttribute(element.props, IdPropName) + if ( + maybeIdProp == null || + maybeIdProp.type !== 'ATTRIBUTE_VALUE' || + typeof maybeIdProp.value !== 'string' + ) { + return null + } + return maybeIdProp.value +} + +function setIdPropOnJSXElement(element: JSXElement, idPropValueToUse: string): JSXElement | null { + const updatedProps = setJSXValueAtPath( + element.props, + PP.create(IdPropName), + jsExpressionValue(idPropValueToUse, emptyComments), + ) + + if (IS_TEST_ENVIRONMENT || isLeft(updatedProps)) { + return null + } + return { ...element, props: updatedProps.value } +} + +export function ensureSceneIdsExist(editor: EditorState): EditorState { + const storyboardFile = getContentsTreeFromPath(editor.projectContents, StoryboardFilePath) + if ( + storyboardFile == null || + storyboardFile.type !== 'PROJECT_CONTENT_FILE' || + storyboardFile.content.type !== 'TEXT_FILE' || + storyboardFile.content.fileContents.parsed.type !== 'PARSE_SUCCESS' + ) { + return editor + } + + let seenIdProps: Set = new Set() + let anyIdPropUpdated = false + const imports = storyboardFile.content.fileContents.parsed.imports + + const nextToplevelElements = storyboardFile.content.fileContents.parsed.topLevelElements.map( + (e) => { + if (e.type !== 'UTOPIA_JSX_COMPONENT') { + return e + } + + const nextRootElement = transformJSXElementChildRecursively(e.rootElement, (child) => { + const isConsideredScene = + isSceneAgainstImports(child, imports) || isRemixSceneAgainstImports(child, imports) + + if (child.type !== 'JSX_ELEMENT' || !isConsideredScene) { + return child + } + + const idPropValue = getIdPropFromJSXElement(child) + + if (idPropValue != null && !seenIdProps.has(idPropValue)) { + seenIdProps.add(idPropValue) + return child + } + + const idPropValueToUse = child.uid + const updatedChild = setIdPropOnJSXElement(child, idPropValueToUse) + if (updatedChild == null) { + return child + } + + seenIdProps.add(idPropValueToUse) + anyIdPropUpdated = true + return updatedChild + }) + + return { ...e, rootElement: nextRootElement } + }, + ) + + if (!anyIdPropUpdated) { + return editor + } + + const nextStoryboardFile: TextFile = { + ...storyboardFile.content, + fileContents: { + ...storyboardFile.content.fileContents, + parsed: { + ...storyboardFile.content.fileContents.parsed, + topLevelElements: nextToplevelElements, + }, + }, + } + + const nextProjectContents = addFileToProjectContents( + editor.projectContents, + StoryboardFilePath, + nextStoryboardFile, + ) + return { ...editor, projectContents: nextProjectContents } +} diff --git a/editor/src/core/third-party/utopia-api-components.ts b/editor/src/core/third-party/utopia-api-components.ts index 570821972117..a73a2f39fa72 100644 --- a/editor/src/core/third-party/utopia-api-components.ts +++ b/editor/src/core/third-party/utopia-api-components.ts @@ -9,7 +9,12 @@ import { defaultElementStyle, } from '../../components/editor/defaults' import type { JSExpression } from '../shared/element-template' -import { emptyComments, jsxAttributesEntry, jsxElementWithoutUID } from '../shared/element-template' +import { + emptyComments, + jsExpressionValue, + jsxAttributesEntry, + jsxElementWithoutUID, +} from '../shared/element-template' const BasicUtopiaComponentDescriptor = ( name: string, @@ -45,14 +50,53 @@ const BasicUtopiaComponentDescriptor = ( } } +const BasicUtopiaSceneDescriptor = ( + name: string, + supportsChildren: boolean, + styleProp: () => JSExpression, +): ComponentDescriptor => { + return { + properties: { + style: { + control: 'style-controls', + }, + }, + supportsChildren: supportsChildren, + variants: [ + { + insertMenuLabel: name, + importsToAdd: { + 'utopia-api': { + importedWithName: null, + importedFromWithin: [ + { + name: name, + alias: name, + }, + ], + importedAs: null, + }, + }, + elementToInsert: () => + jsxElementWithoutUID( + name, + [ + jsxAttributesEntry('id', jsExpressionValue('scene', emptyComments), emptyComments), + jsxAttributesEntry('style', styleProp(), emptyComments), + ], + [], + ), + }, + ], + } +} + export const UtopiaApiComponents: ComponentDescriptorsForFile = { Ellipse: BasicUtopiaComponentDescriptor('Ellipse', false, defaultElementStyle), Rectangle: BasicUtopiaComponentDescriptor('Rectangle', false, defaultRectangleElementStyle), View: BasicUtopiaComponentDescriptor('View', true, defaultElementStyle), FlexRow: BasicUtopiaComponentDescriptor('FlexRow', true, defaultFlexRowOrColStyle), FlexCol: BasicUtopiaComponentDescriptor('FlexCol', true, defaultFlexRowOrColStyle), - Scene: BasicUtopiaComponentDescriptor('Scene', true, () => defaultSceneElementStyle(null)), - RemixScene: BasicUtopiaComponentDescriptor('RemixScene', false, () => - defaultSceneElementStyle(null), - ), + Scene: BasicUtopiaSceneDescriptor('Scene', true, () => defaultSceneElementStyle(null)), + RemixScene: BasicUtopiaSceneDescriptor('RemixScene', false, () => defaultSceneElementStyle(null)), }