From b0054d6f5fb694321cbce5a733ddce39a923bfb2 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Tue, 23 Jan 2024 11:15:41 +0100 Subject: [PATCH 01/11] wip - ensure scene id prop --- .../src/components/editor/store/dispatch.tsx | 146 +++++++++++++++++- 1 file changed, 143 insertions(+), 3 deletions(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 0cecfd3ad256..35eab783d44e 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -54,17 +54,17 @@ import { runLocalEditorAction, runUpdateProjectServerState, } from './editor-update' -import { fastForEach, isBrowserEnvironment } from '../../../core/shared/utils' +import { assertNever, fastForEach, isBrowserEnvironment } from '../../../core/shared/utils' import type { UiJsxCanvasContextData } from '../../canvas/ui-jsx-canvas' import type { ProjectContentTreeRoot } from '../../assets' -import { treeToContents, walkContentsTree } from '../../assets' +import { transformContentsTree, treeToContents, walkContentsTree } from '../../assets' import { isSendPreviewModel, restoreDerivedState, UPDATE_FNS } from '../actions/actions' import { getTransitiveReverseDependencies } from '../../../core/shared/project-contents-dependencies' import { reduxDevtoolsSendActions, reduxDevtoolsUpdateState, } from '../../../core/shared/redux-devtools' -import { isEmptyObject, pick } from '../../../core/shared/object-utils' +import { isEmptyObject, objectMap, pick } from '../../../core/shared/object-utils' import type { ProjectChanges } from './vscode-changes' import { emptyProjectChanges, @@ -91,6 +91,21 @@ 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 { + jsExpressionValue, + type ElementsWithin, + type JSXElement, + type JSXElementChild, + emptyComments, + getJSXAttribute, +} from '../../../core/shared/element-template' +import { + isRemixSceneAgainstImports, + isSceneAgainstImports, +} from '../../../core/model/project-file-utils' +import * as PP from '../../../core/shared/property-path' +import { setJSXValueAtPath } from '../../../core/shared/jsx-attributes' +import { isLeft } from '../../../core/shared/either' type DispatchResultFields = { nothingChanged: boolean @@ -798,6 +813,130 @@ function applyProjectChangesToEditor( } } +function walkJSXElementChild( + 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) + } +} + +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 (isLeft(updatedProps)) { + return element + } + return { ...element, props: updatedProps.value } +} + +function ensureSceneIdsExist(editor: EditorState): EditorState { + let seenIdProps: Set = new Set() + + const nextProjectContents = transformContentsTree(editor.projectContents, (tree) => { + if ( + tree.type !== 'PROJECT_CONTENT_FILE' || + tree.content.type !== 'TEXT_FILE' || + tree.content.fileContents.parsed.type !== 'PARSE_SUCCESS' + ) { + return tree + } + + const imports = tree.content.fileContents.parsed.imports + + const nextToplevelElements = tree.content.fileContents.parsed.topLevelElements.map((e) => { + if (e.type !== 'UTOPIA_JSX_COMPONENT') { + return e + } + + const nextRootElement = walkJSXElementChild(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) + return updatedChild + }) + + return { ...e, rootElement: nextRootElement } + }) + + // TODO: optic + return { + ...tree, + content: { + ...tree.content, + fileContents: { + ...tree.content.fileContents, + parsed: { ...tree.content.fileContents.parsed, topLevelElements: nextToplevelElements }, + }, + }, + } + }) + + return { ...editor, projectContents: nextProjectContents } +} + export const UTOPIA_IRRECOVERABLE_ERROR_MESSAGE = `Utopia has suffered from an irrecoverable error, please reload the editor.` function editorDispatchInner( boundDispatch: EditorDispatch, @@ -819,6 +958,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) From c09c1a879a866b4809008d96de48d9415efbd4ab Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 10:25:13 +0100 Subject: [PATCH 02/11] `IS_TEST_ENVIRONMENT` --- editor/src/components/editor/store/dispatch.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 35eab783d44e..0505965a05c8 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -1,4 +1,8 @@ -import { PERFORMANCE_MARKS_ALLOWED, PRODUCTION_ENV } from '../../../common/env-vars' +import { + IS_TEST_ENVIRONMENT, + PERFORMANCE_MARKS_ALLOWED, + PRODUCTION_ENV, +} from '../../../common/env-vars' import { isParseSuccess, isTextFile } from '../../../core/shared/project-file-types' import { codeNeedsParsing, @@ -868,7 +872,7 @@ function setIdPropOnJSXElement(element: JSXElement, idPropValueToUse: string): J jsExpressionValue(idPropValueToUse, emptyComments), ) - if (isLeft(updatedProps)) { + if (IS_TEST_ENVIRONMENT || isLeft(updatedProps)) { return element } return { ...element, props: updatedProps.value } From 8cc69176365fd0301439cb65f17cc5ee7342f429 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 11:00:57 +0100 Subject: [PATCH 03/11] return `null` from `setIdPropOnJSXElement` --- editor/src/components/editor/store/dispatch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 0505965a05c8..c372c76d8e10 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -873,7 +873,7 @@ function setIdPropOnJSXElement(element: JSXElement, idPropValueToUse: string): J ) if (IS_TEST_ENVIRONMENT || isLeft(updatedProps)) { - return element + return null } return { ...element, props: updatedProps.value } } From f304c8b7dc277410b1b3ff93520c3e6a3c1d8b10 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 11:43:22 +0100 Subject: [PATCH 04/11] look out for ref equality --- editor/src/components/editor/store/dispatch.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index c372c76d8e10..5645681254de 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -58,7 +58,12 @@ import { runLocalEditorAction, runUpdateProjectServerState, } from './editor-update' -import { assertNever, fastForEach, isBrowserEnvironment } from '../../../core/shared/utils' +import { + assertNever, + fastForEach, + identity, + isBrowserEnvironment, +} from '../../../core/shared/utils' import type { UiJsxCanvasContextData } from '../../canvas/ui-jsx-canvas' import type { ProjectContentTreeRoot } from '../../assets' import { transformContentsTree, treeToContents, walkContentsTree } from '../../assets' @@ -880,6 +885,7 @@ function setIdPropOnJSXElement(element: JSXElement, idPropValueToUse: string): J function ensureSceneIdsExist(editor: EditorState): EditorState { let seenIdProps: Set = new Set() + let anyIdPropUpdated = false const nextProjectContents = transformContentsTree(editor.projectContents, (tree) => { if ( @@ -919,6 +925,7 @@ function ensureSceneIdsExist(editor: EditorState): EditorState { } seenIdProps.add(idPropValueToUse) + anyIdPropUpdated = true return updatedChild }) @@ -938,6 +945,10 @@ function ensureSceneIdsExist(editor: EditorState): EditorState { } }) + if (!anyIdPropUpdated) { + return editor + } + return { ...editor, projectContents: nextProjectContents } } From 90a4b833cb6a1019aac7320cdc63471f2941bf73 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 13:11:31 +0100 Subject: [PATCH 05/11] remove TODO --- editor/src/components/editor/store/dispatch.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 5645681254de..0cd04b3c6e02 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -1,8 +1,4 @@ -import { - IS_TEST_ENVIRONMENT, - PERFORMANCE_MARKS_ALLOWED, - PRODUCTION_ENV, -} from '../../../common/env-vars' +import { IS_TEST_ENVIRONMENT, PERFORMANCE_MARKS_ALLOWED } from '../../../common/env-vars' import { isParseSuccess, isTextFile } from '../../../core/shared/project-file-types' import { codeNeedsParsing, @@ -58,12 +54,7 @@ import { runLocalEditorAction, runUpdateProjectServerState, } from './editor-update' -import { - assertNever, - fastForEach, - identity, - isBrowserEnvironment, -} from '../../../core/shared/utils' +import { assertNever, isBrowserEnvironment } from '../../../core/shared/utils' import type { UiJsxCanvasContextData } from '../../canvas/ui-jsx-canvas' import type { ProjectContentTreeRoot } from '../../assets' import { transformContentsTree, treeToContents, walkContentsTree } from '../../assets' @@ -932,7 +923,6 @@ function ensureSceneIdsExist(editor: EditorState): EditorState { return { ...e, rootElement: nextRootElement } }) - // TODO: optic return { ...tree, content: { From ada41d2083c25ce31d4e106b720054aecd493b35 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 14:28:37 +0100 Subject: [PATCH 06/11] only check the storyboard file --- .../src/components/editor/store/dispatch.tsx | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 0cd04b3c6e02..1a8401cadc93 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -1,5 +1,5 @@ import { IS_TEST_ENVIRONMENT, PERFORMANCE_MARKS_ALLOWED } from '../../../common/env-vars' -import { isParseSuccess, isTextFile } from '../../../core/shared/project-file-types' +import type { TextFile } from '../../../core/shared/project-file-types' import { codeNeedsParsing, codeNeedsPrinting, @@ -41,6 +41,7 @@ import type { EditorStoreUnpatched, } from './editor-state' import { + StoryboardFilePath, deriveState, persistentModelFromEditorModel, reconstructJSXMetadata, @@ -57,7 +58,7 @@ import { import { assertNever, isBrowserEnvironment } from '../../../core/shared/utils' import type { UiJsxCanvasContextData } from '../../canvas/ui-jsx-canvas' import type { ProjectContentTreeRoot } from '../../assets' -import { transformContentsTree, treeToContents, walkContentsTree } from '../../assets' +import { addFileToProjectContents, getContentsTreeFromPath, treeToContents } from '../../assets' import { isSendPreviewModel, restoreDerivedState, UPDATE_FNS } from '../actions/actions' import { getTransitiveReverseDependencies } from '../../../core/shared/project-contents-dependencies' import { @@ -875,21 +876,22 @@ function setIdPropOnJSXElement(element: JSXElement, idPropValueToUse: string): J } 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 nextProjectContents = transformContentsTree(editor.projectContents, (tree) => { - if ( - tree.type !== 'PROJECT_CONTENT_FILE' || - tree.content.type !== 'TEXT_FILE' || - tree.content.fileContents.parsed.type !== 'PARSE_SUCCESS' - ) { - return tree - } - - const imports = tree.content.fileContents.parsed.imports - - const nextToplevelElements = tree.content.fileContents.parsed.topLevelElements.map((e) => { + const nextToplevelElements = storyboardFile.content.fileContents.parsed.topLevelElements.map( + (e) => { if (e.type !== 'UTOPIA_JSX_COMPONENT') { return e } @@ -921,24 +923,29 @@ function ensureSceneIdsExist(editor: EditorState): EditorState { }) return { ...e, rootElement: nextRootElement } - }) - - return { - ...tree, - content: { - ...tree.content, - fileContents: { - ...tree.content.fileContents, - parsed: { ...tree.content.fileContents.parsed, topLevelElements: nextToplevelElements }, - }, - }, - } - }) + }, + ) 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 } } From acf46a3bca5262a6f9b49681056ba005a05946b9 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 14:33:57 +0100 Subject: [PATCH 07/11] insert scene with id --- .../core/third-party/utopia-api-components.ts | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) 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)), } From b6aa64e7e2b5e74a04dbdf89142ddd2ae676c6aa Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 15:09:38 +0100 Subject: [PATCH 08/11] `transformJSXComponentAtElementPathRecursively` --- .../src/components/editor/store/dispatch.tsx | 88 ++++++------------- .../src/core/model/element-template-utils.ts | 34 +++++++ 2 files changed, 63 insertions(+), 59 deletions(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 1a8401cadc93..ff8121b2194c 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -107,6 +107,7 @@ import { import * as PP from '../../../core/shared/property-path' import { setJSXValueAtPath } from '../../../core/shared/jsx-attributes' import { isLeft } from '../../../core/shared/either' +import { transformJSXComponentAtElementPathRecursively } from '../../../core/model/element-template-utils' type DispatchResultFields = { nothingChanged: boolean @@ -814,40 +815,6 @@ function applyProjectChangesToEditor( } } -function walkJSXElementChild( - 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) - } -} - const IdPropName = 'id' function getIdPropFromJSXElement(element: JSXElement): string | null { @@ -896,31 +863,34 @@ function ensureSceneIdsExist(editor: EditorState): EditorState { return e } - const nextRootElement = walkJSXElementChild(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 - }) + const nextRootElement = transformJSXComponentAtElementPathRecursively( + 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 } }, diff --git a/editor/src/core/model/element-template-utils.ts b/editor/src/core/model/element-template-utils.ts index 8f013793a64b..e4f5e62e3deb 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 transformJSXComponentAtElementPathRecursively( + 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, From 1359c25a1bdb887cafc7825f49b8a14e8bbde63f Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 15:18:17 +0100 Subject: [PATCH 09/11] `transformJSXElementChildrenRecursively` --- .../src/components/editor/store/dispatch.tsx | 55 +++++++++---------- .../src/core/model/element-template-utils.ts | 2 +- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index ff8121b2194c..316f0580af3b 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -107,7 +107,7 @@ import { import * as PP from '../../../core/shared/property-path' import { setJSXValueAtPath } from '../../../core/shared/jsx-attributes' import { isLeft } from '../../../core/shared/either' -import { transformJSXComponentAtElementPathRecursively } from '../../../core/model/element-template-utils' +import { transformJSXElementChildrenRecursively } from '../../../core/model/element-template-utils' type DispatchResultFields = { nothingChanged: boolean @@ -863,34 +863,31 @@ function ensureSceneIdsExist(editor: EditorState): EditorState { return e } - const nextRootElement = transformJSXComponentAtElementPathRecursively( - 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 - }, - ) + const nextRootElement = transformJSXElementChildrenRecursively(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 } }, diff --git a/editor/src/core/model/element-template-utils.ts b/editor/src/core/model/element-template-utils.ts index e4f5e62e3deb..426cdc489b8d 100644 --- a/editor/src/core/model/element-template-utils.ts +++ b/editor/src/core/model/element-template-utils.ts @@ -141,7 +141,7 @@ export function transformJSXComponentAtPath( : transformJSXComponentAtElementPath(components, lastElementPathPart, transform) } -export function transformJSXComponentAtElementPathRecursively( +export function transformJSXElementChildrenRecursively( element: JSXElementChild, transform: (_: JSXElementChild) => JSXElementChild, ): JSXElementChild { From d9aac8837197893ef39c3896fbe3451670bb91b0 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 15:19:41 +0100 Subject: [PATCH 10/11] `transformJSXElementChildRecursively` --- editor/src/components/editor/store/dispatch.tsx | 4 ++-- editor/src/core/model/element-template-utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 316f0580af3b..7b7fd595e97b 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -107,7 +107,7 @@ import { import * as PP from '../../../core/shared/property-path' import { setJSXValueAtPath } from '../../../core/shared/jsx-attributes' import { isLeft } from '../../../core/shared/either' -import { transformJSXElementChildrenRecursively } from '../../../core/model/element-template-utils' +import { transformJSXElementChildRecursively } from '../../../core/model/element-template-utils' type DispatchResultFields = { nothingChanged: boolean @@ -863,7 +863,7 @@ function ensureSceneIdsExist(editor: EditorState): EditorState { return e } - const nextRootElement = transformJSXElementChildrenRecursively(e.rootElement, (child) => { + const nextRootElement = transformJSXElementChildRecursively(e.rootElement, (child) => { const isConsideredScene = isSceneAgainstImports(child, imports) || isRemixSceneAgainstImports(child, imports) diff --git a/editor/src/core/model/element-template-utils.ts b/editor/src/core/model/element-template-utils.ts index 426cdc489b8d..ea7192651ed9 100644 --- a/editor/src/core/model/element-template-utils.ts +++ b/editor/src/core/model/element-template-utils.ts @@ -141,7 +141,7 @@ export function transformJSXComponentAtPath( : transformJSXComponentAtElementPath(components, lastElementPathPart, transform) } -export function transformJSXElementChildrenRecursively( +export function transformJSXElementChildRecursively( element: JSXElementChild, transform: (_: JSXElementChild) => JSXElementChild, ): JSXElementChild { From 621ec876e8907021e32457bf07ff2492a7a08df4 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Wed, 24 Jan 2024 15:26:52 +0100 Subject: [PATCH 11/11] move helper functions to scene-utils --- .../src/components/editor/store/dispatch.tsx | 129 +----------------- editor/src/core/model/scene-utils.ts | 126 ++++++++++++++++- 2 files changed, 129 insertions(+), 126 deletions(-) diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index 7b7fd595e97b..a465b54e95cc 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -1,5 +1,5 @@ -import { IS_TEST_ENVIRONMENT, PERFORMANCE_MARKS_ALLOWED } from '../../../common/env-vars' -import type { TextFile } from '../../../core/shared/project-file-types' +import { PERFORMANCE_MARKS_ALLOWED, PRODUCTION_ENV } from '../../../common/env-vars' +import { isParseSuccess, isTextFile } from '../../../core/shared/project-file-types' import { codeNeedsParsing, codeNeedsPrinting, @@ -41,7 +41,6 @@ import type { EditorStoreUnpatched, } from './editor-state' import { - StoryboardFilePath, deriveState, persistentModelFromEditorModel, reconstructJSXMetadata, @@ -55,17 +54,17 @@ import { runLocalEditorAction, runUpdateProjectServerState, } from './editor-update' -import { assertNever, isBrowserEnvironment } from '../../../core/shared/utils' +import { fastForEach, isBrowserEnvironment } from '../../../core/shared/utils' import type { UiJsxCanvasContextData } from '../../canvas/ui-jsx-canvas' import type { ProjectContentTreeRoot } from '../../assets' -import { addFileToProjectContents, getContentsTreeFromPath, treeToContents } from '../../assets' +import { treeToContents, walkContentsTree } from '../../assets' import { isSendPreviewModel, restoreDerivedState, UPDATE_FNS } from '../actions/actions' import { getTransitiveReverseDependencies } from '../../../core/shared/project-contents-dependencies' import { reduxDevtoolsSendActions, reduxDevtoolsUpdateState, } from '../../../core/shared/redux-devtools' -import { isEmptyObject, objectMap, pick } from '../../../core/shared/object-utils' +import { isEmptyObject, pick } from '../../../core/shared/object-utils' import type { ProjectChanges } from './vscode-changes' import { emptyProjectChanges, @@ -92,22 +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 { - jsExpressionValue, - type ElementsWithin, - type JSXElement, - type JSXElementChild, - emptyComments, - getJSXAttribute, -} from '../../../core/shared/element-template' -import { - isRemixSceneAgainstImports, - isSceneAgainstImports, -} from '../../../core/model/project-file-utils' -import * as PP from '../../../core/shared/property-path' -import { setJSXValueAtPath } from '../../../core/shared/jsx-attributes' -import { isLeft } from '../../../core/shared/either' -import { transformJSXElementChildRecursively } from '../../../core/model/element-template-utils' +import { ensureSceneIdsExist } from '../../../core/model/scene-utils' type DispatchResultFields = { nothingChanged: boolean @@ -815,107 +799,6 @@ function applyProjectChangesToEditor( } } -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 } -} - -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 } -} - export const UTOPIA_IRRECOVERABLE_ERROR_MESSAGE = `Utopia has suffered from an irrecoverable error, please reload the editor.` function editorDispatchInner( boundDispatch: EditorDispatch, 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 } +}