diff --git a/.gitignore b/.gitignore
index d0a27eba0cbf..4c6b61cf414c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -141,3 +141,6 @@ custom-shell.nix
# Benchmark results.
editor/benchmark
+
+*storybook.log
+storybook-static
diff --git a/nexus-builder/.eslintrc.cjs b/nexus-builder/.eslintrc.cjs
new file mode 100644
index 000000000000..0c7686cf5259
--- /dev/null
+++ b/nexus-builder/.eslintrc.cjs
@@ -0,0 +1,14 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:prettier/recommended', 'plugin:storybook/recommended'],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+}
diff --git a/nexus-builder/.storybook/main.ts b/nexus-builder/.storybook/main.ts
new file mode 100644
index 000000000000..f0fdd1efc842
--- /dev/null
+++ b/nexus-builder/.storybook/main.ts
@@ -0,0 +1,24 @@
+import type { StorybookConfig } from '@storybook/react-vite';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+const config: StorybookConfig = {
+ "stories": [
+ "../packages/**/*.mdx",
+ "../packages/**/*.stories.@(js|jsx|mjs|ts|tsx)"
+ ],
+ "addons": [
+ "@chromatic-com/storybook",
+ "@storybook/addon-vitest",
+ "@storybook/addon-a11y",
+ "@storybook/addon-docs",
+ "@storybook/addon-onboarding"
+ ],
+ "framework": "@storybook/react-vite",
+ async viteFinal(config) {
+ if (config.plugins) {
+ config.plugins.push(tsconfigPaths());
+ }
+ return config;
+ },
+};
+export default config;
diff --git a/nexus-builder/.storybook/preview.ts b/nexus-builder/.storybook/preview.ts
new file mode 100644
index 000000000000..9017263ad9fc
--- /dev/null
+++ b/nexus-builder/.storybook/preview.ts
@@ -0,0 +1,21 @@
+import type { Preview } from '@storybook/react-vite';
+
+const preview: Preview = {
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+
+ a11y: {
+ // 'todo' - show a11y violations in the test UI only
+ // 'error' - fail CI on a11y violations
+ // 'off' - skip a11y checks entirely
+ test: 'todo',
+ },
+ },
+};
+
+export default preview;
diff --git a/nexus-builder/.storybook/vitest.setup.ts b/nexus-builder/.storybook/vitest.setup.ts
new file mode 100644
index 000000000000..44922d55e49d
--- /dev/null
+++ b/nexus-builder/.storybook/vitest.setup.ts
@@ -0,0 +1,7 @@
+import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
+import { setProjectAnnotations } from '@storybook/react-vite';
+import * as projectAnnotations from './preview';
+
+// This is an important step to apply the right configuration when testing your stories.
+// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
+setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
\ No newline at end of file
diff --git a/nexus-builder/package.json b/nexus-builder/package.json
new file mode 100644
index 000000000000..9f0d81c4cc07
--- /dev/null
+++ b/nexus-builder/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "nexus-builder",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A monorepo for Nexus Builder.",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "@chromatic-com/storybook": "^4.1.3",
+ "@storybook/addon-a11y": "^10.1.4",
+ "@storybook/addon-docs": "^10.1.4",
+ "@storybook/addon-onboarding": "^10.1.4",
+ "@storybook/addon-vitest": "^10.1.4",
+ "@storybook/react-vite": "^10.1.4",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "@typescript-eslint/eslint-plugin": "^8.48.1",
+ "@typescript-eslint/parser": "^8.48.1",
+ "@vitest/browser-playwright": "^4.0.15",
+ "@vitest/coverage-v8": "^4.0.15",
+ "eslint": "^9.39.1",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-storybook": "^10.1.4",
+ "playwright": "^1.57.0",
+ "prettier": "^3.7.4",
+ "react": "^19.2.1",
+ "react-dom": "^19.2.1",
+ "storybook": "^10.1.4",
+ "typescript": "^5.9.3",
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "^4.0.15"
+ }
+}
diff --git a/nexus-builder/packages/core/package.json b/nexus-builder/packages/core/package.json
new file mode 100644
index 000000000000..92b282dbb93e
--- /dev/null
+++ b/nexus-builder/packages/core/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@builder/core",
+ "version": "1.0.0",
+ "private": true,
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "test": "echo \"Error: no test specified\"",
+ "build": "tsc"
+ },
+ "dependencies": {
+ "react": "^19.2.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.7",
+ "eslint": "^9.39.1",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/nexus-builder/packages/core/src/components/common/actions/index.ts b/nexus-builder/packages/core/src/components/common/actions/index.ts
new file mode 100644
index 000000000000..9272ebc26903
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/actions/index.ts
@@ -0,0 +1,71 @@
+export type LeftMenuPanel =
+ | 'filebrowser'
+ | 'dependencylist'
+ | 'genericExternalResources'
+ | 'googleFontsResources'
+ | 'insertmenu'
+ | 'projectsettings'
+ | 'githuboptions'
+
+export type CenterPanel = 'canvas' | 'misccodeeditor'
+
+export type InspectorPanel = 'inspector'
+
+export type CodeEditorPanel = 'codeEditor'
+
+export type NavigatorPanel = 'navigator'
+
+export type EditorPanel =
+ | LeftMenuPanel
+ | CenterPanel
+ | CodeEditorPanel
+ | InspectorPanel
+ | NavigatorPanel
+
+export type EditorPane = 'leftmenu' | 'center' | 'inspector' | 'rightmenu'
+
+export function paneForPanel(panel: EditorPanel | null): EditorPane | null {
+ switch (panel) {
+ case null:
+ return null
+ case 'filebrowser':
+ return 'leftmenu'
+ case 'dependencylist':
+ return 'leftmenu'
+ case 'genericExternalResources':
+ return 'leftmenu'
+ case 'googleFontsResources':
+ return 'leftmenu'
+ case 'githuboptions':
+ return 'leftmenu'
+ case 'insertmenu':
+ return 'rightmenu'
+ case 'projectsettings':
+ return 'leftmenu'
+ case 'navigator':
+ return 'center'
+ case 'canvas':
+ return 'center'
+ case 'misccodeeditor':
+ return 'center'
+ case 'inspector':
+ return 'rightmenu'
+ case 'codeEditor':
+ return 'center'
+ default:
+ const _exhaustiveCheck: never = panel
+ throw new Error(`Unhandled panel ${panel}`)
+ }
+}
+
+export interface SetFocus {
+ action: 'SET_FOCUS'
+ focusedPanel: EditorPanel | null
+}
+
+export function setFocus(panel: EditorPanel | null): SetFocus {
+ return {
+ action: 'SET_FOCUS',
+ focusedPanel: panel,
+ }
+}
diff --git a/nexus-builder/packages/core/src/components/common/ellipsis-spinner.tsx b/nexus-builder/packages/core/src/components/common/ellipsis-spinner.tsx
new file mode 100644
index 000000000000..8a7fc3a096ca
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/ellipsis-spinner.tsx
@@ -0,0 +1,101 @@
+/** @jsxRuntime classic */
+/** @jsx jsx */
+import { jsx, css, keyframes } from '@emotion/react'
+import React from 'react'
+
+const ellipsis1Anim = keyframes`{
+ 0% {
+ transform: scale(0);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}`
+
+const ellipsis2Anim = keyframes`{
+ 0% {
+ transform: translate(0, 0);
+ }
+ 100% {
+ transform: translate(19px, 0);
+ }
+}`
+
+const ellipsis3Anim = ellipsis2Anim
+
+const ellipsis4Anim = keyframes`{
+ 0% {
+ transform: scale(1);
+ }
+ 100% {
+ transform: scale(0);
+ }
+}`
+
+// Don't even waste your time trying to type the `css` prop because it will haunt you forever
+const Ellipsis = (props: any) => (
+
+)
+
+export const EllipsisSpinner = React.memo(() => {
+ return (
+
+ )
+})
diff --git a/nexus-builder/packages/core/src/components/common/notice.ts b/nexus-builder/packages/core/src/components/common/notice.ts
new file mode 100644
index 000000000000..c42fc2b0245d
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/notice.ts
@@ -0,0 +1,29 @@
+import type React from 'react'
+import { v4 as UUID } from 'uuid'
+
+/**
+ * - _Error_: High visibility
+ * - _Warning_: High visisibility
+ * - _Primary_: Medium high visibility. Use for CTAs, nudges
+ * - _Success_: Medium visibility. Use for async or higher-failure-probability positive outcomes
+ * - _Notice_: Medium visibility. Use for synchronous info, confirmation, or to stand out from background
+ * - _Info_: Low visibility. Use to display information that can blend into background
+ *
+ * */
+
+export type NoticeLevel = 'ERROR' | 'WARNING' | 'PRIMARY' | 'SUCCESS' | 'NOTICE' | 'INFO'
+export interface Notice {
+ message: React.ReactChild
+ level: NoticeLevel
+ persistent: boolean
+ id: string
+}
+
+export function notice(
+ message: React.ReactChild,
+ level: NoticeLevel = 'INFO',
+ persistent: boolean = false,
+ id: string = UUID(),
+): Notice {
+ return { message: message, persistent: persistent, level: level, id: id }
+}
diff --git a/nexus-builder/packages/core/src/components/common/notices.tsx b/nexus-builder/packages/core/src/components/common/notices.tsx
new file mode 100644
index 000000000000..c1f9ee4e2b8d
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/notices.tsx
@@ -0,0 +1,182 @@
+/** @jsxRuntime classic */
+/** @jsx jsx */
+import { jsx } from '@emotion/react'
+import * as EditorActions from '../editor/actions/action-creators'
+import React from 'react'
+import { UtopiaStyles, SimpleFlexRow, UtopiaTheme, SimpleFlexColumn } from '../../uuiui'
+import type { Notice, NoticeLevel } from './notice'
+import { useDispatch } from '../editor/store/dispatch-context'
+import { assertNever } from '../../core/shared/utils'
+import { when } from '../../utils/react-conditionals'
+
+interface NoticeProps extends Notice {
+ style?: React.CSSProperties
+ onClick?: () => void
+}
+
+export const getStylesForLevel = (level: NoticeLevel): React.CSSProperties => {
+ let resultingStyle = UtopiaStyles.noticeStyles.info
+
+ switch (level) {
+ case 'WARNING':
+ return UtopiaStyles.noticeStyles.warning
+ case 'ERROR':
+ return UtopiaStyles.noticeStyles.error
+ case 'SUCCESS':
+ return UtopiaStyles.noticeStyles.success
+ case 'PRIMARY':
+ return UtopiaStyles.noticeStyles.primary
+ case 'INFO':
+ return UtopiaStyles.noticeStyles.info
+ case 'NOTICE':
+ return UtopiaStyles.noticeStyles.notice
+ default:
+ assertNever(level)
+ }
+}
+
+export const getPrefixForLevel = (level: NoticeLevel): string => {
+ switch (level) {
+ case 'WARNING':
+ return '﹗'
+ case 'ERROR':
+ return '⚠️'
+ case 'SUCCESS':
+ return '✓'
+ default:
+ return ''
+ }
+}
+
+const ToastTimeout = 5500
+
+/**
+ * Show information in a stack of toasts at the bottom of the editor
+ *
+ * **Layout**: use as flex child with fixed height
+ * **Level**: see NoticeLevel jsdoc
+ */
+export const Toast: React.FunctionComponent> = (props) => {
+ const dispatch = useDispatch()
+ const deleteToast = React.useCallback(() => {
+ dispatch([EditorActions.removeToast(props.id)])
+ }, [dispatch, props.id])
+
+ React.useEffect(() => {
+ // timeout type conflicts between node and browser – but this will work for both, trust me
+ const timeoutId: any = props.persistent ? null : setTimeout(deleteToast, ToastTimeout)
+ return function cleanup() {
+ clearTimeout(timeoutId)
+ }
+ }, [props.persistent, deleteToast])
+
+ return (
+
+
+ {getPrefixForLevel(props.level)}
+ {props.message}
+
+ {when(
+ props.persistent,
+
+ ×
+
,
+ )}
+
+ )
+}
+
+interface NotificationBarProps {
+ message: React.ReactChild
+ level?: NoticeLevel
+ style?: React.CSSProperties
+ onClick?: () => void
+}
+/**
+ * Show information atop the editor
+ *
+ * **Layout**: use as flex child with fixed height
+ * **Level**: see NoticeLevel jsdoc
+ */
+export const NotificationBar: React.FunctionComponent<
+ React.PropsWithChildren
+> = (props) => (
+
+ {props.message} {props.children}
+
+)
+
+interface InfoBoxProps {
+ message: React.ReactChild
+ level?: NoticeLevel
+ style?: React.CSSProperties
+ onClick?: () => void
+}
+/**
+ * Displays information in color-coded box eg in the inspector or navigator
+ *
+ * **Layout**: takes full width, sizes itself to height
+ * **Level**: see NoticeLevel jsdoc
+ */
+export const InfoBox: React.FunctionComponent> = (props) => (
+
+ {props.message}
+ {props.children}
+
+)
diff --git a/nexus-builder/packages/core/src/components/common/shared-strategies/convert-strategies-common.ts b/nexus-builder/packages/core/src/components/common/shared-strategies/convert-strategies-common.ts
new file mode 100644
index 000000000000..58f98f839298
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/shared-strategies/convert-strategies-common.ts
@@ -0,0 +1,30 @@
+import type { ElementPath } from 'utopia-shared/src/types'
+import { MetadataUtils } from '../../../core/model/element-metadata-utils'
+import type { ElementPathTrees } from '../../../core/shared/element-path-tree'
+import type { ElementInstanceMetadataMap } from '../../../core/shared/element-template'
+import {
+ isElementNonDOMElement,
+ replaceNonDOMElementPathsWithTheirChildrenRecursive,
+} from '../../canvas/canvas-strategies/strategies/fragment-like-helpers'
+import type { AllElementProps } from '../../editor/store/editor-state'
+
+export type FlexDirectionRowColumn = 'row' | 'column' // a limited subset as we never guess row-reverse or column-reverse
+export type FlexAlignItems = 'center' | 'flex-end'
+
+export function getChildrenPathsForContainer(
+ metadata: ElementInstanceMetadataMap,
+ elementPathTree: ElementPathTrees,
+ path: ElementPath,
+ allElementProps: AllElementProps,
+) {
+ return MetadataUtils.getChildrenPathsOrdered(elementPathTree, path).flatMap((child) =>
+ isElementNonDOMElement(metadata, allElementProps, elementPathTree, child)
+ ? replaceNonDOMElementPathsWithTheirChildrenRecursive(
+ metadata,
+ allElementProps,
+ elementPathTree,
+ [child],
+ )
+ : child,
+ )
+}
diff --git a/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-flex-strategy.spec.browser2.tsx b/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-flex-strategy.spec.browser2.tsx
new file mode 100644
index 000000000000..1624f36f38f8
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-flex-strategy.spec.browser2.tsx
@@ -0,0 +1,1820 @@
+import { navigatorEntryToKey } from '../../../components/editor/store/editor-state'
+import { BakedInStoryboardUID } from '../../../core/model/scene-utils'
+import * as EP from '../../../core/shared/element-path'
+import { shiftModifier } from '../../../utils/modifiers'
+import { expectSingleUndo2Saves, selectComponentsForTest } from '../../../utils/utils.test-utils'
+import { getRegularNavigatorTargets } from '../../canvas/canvas-strategies/strategies/fragment-like-helpers.test-utils'
+import { pressKey } from '../../canvas/event-helpers.test-utils'
+import type { EditorRenderResult } from '../../canvas/ui-jsx.test-utils'
+import {
+ getPrintedUiJsCode,
+ makeTestProjectCodeWithSnippet,
+ renderTestEditorWithCode,
+ TestAppUID,
+ TestScenePath,
+ TestSceneUID,
+} from '../../canvas/ui-jsx.test-utils'
+import { selectComponents } from '../../editor/actions/action-creators'
+import type { FlexDirection } from '../../inspector/common/css-utils'
+import type { FlexAlignment, FlexJustifyContent } from '../../inspector/inspector-common'
+import { MaxContent } from '../../inspector/inspector-common'
+import { getNavigatorTargetsFromEditorState } from '../../navigator/navigator-utils'
+
+type LTWH = [
+ left: number,
+ top: number,
+ width: number | string,
+ height: number | string,
+ uid?: string,
+]
+type Size = [width: number, height: number, uid?: string]
+type FlexProps = {
+ left: number
+ top: number
+ padding?: string
+ gap?: number
+ alignItems?: FlexAlignment
+ justifyContent?: FlexJustifyContent
+ display: 'flex'
+ flexDirection?: FlexDirection
+ width: string | number
+ height: string | number
+}
+
+describe('Smart Convert To Flex', () => {
+ it('handles zero children well', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 500, 150],
+ children: [],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+
+ it('converts a horizontal layout with zero padding and a gap of 15', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 500, 150],
+ children: [
+ [0, 0, 50, 50],
+ [65, 0, 50, 50],
+ [130, 0, 50, 50],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 15,
+ },
+ children: [
+ [50, 50],
+ [50, 50],
+ [50, 50],
+ ],
+ }),
+ )
+ })
+
+ it('converts a horizontal parent but with clearly vertical children as a vertical layout', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 500, 150],
+ children: [
+ [0, 0, 50, 50],
+ [0, 60, 50, 50],
+ [0, 120, 50, 50],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 10,
+ },
+ children: [
+ [50, 50],
+ [50, 50],
+ [50, 50],
+ ],
+ }),
+ )
+ })
+
+ it('converts a vertical parent with ambiguous children as a vertical layout', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 200, 300],
+ children: [
+ [0, 0, 50, 50],
+ [60, 60, 50, 50],
+ [120, 120, 50, 50],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 10,
+ },
+ children: [
+ [50, 50],
+ [50, 50],
+ [50, 50],
+ ],
+ }),
+ )
+ })
+
+ it('converts horizontal layout with symmetric 25px padding and a gap of 15', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 230, 150],
+ children: [
+ [25, 0, 50, 50],
+ [90, 0, 50, 50],
+ [155, 0, 50, 50],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 15,
+ padding: '0 25px',
+ },
+ children: [
+ [50, 50],
+ [50, 50],
+ [50, 50],
+ ],
+ }),
+ )
+ })
+
+ it('converts horizontal layout with horizontal 25px padding, and vertical 20px', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 230, 150],
+ children: [
+ [25, 20, 50, 50],
+ [90, 20, 50, 50],
+ [155, 20, 50, 50],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 15,
+ padding: '20px 25px',
+ },
+ children: [
+ [50, 50],
+ [50, 50],
+ [50, 50],
+ ],
+ }),
+ )
+ })
+
+ it('converts horizontal layout with symmetric 25px padding and no gap', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 230, 150],
+ children: [
+ [25, 0, 50, 50],
+ [75, 0, 50, 50],
+ [125, 0, 50, 50],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'row',
+ padding: '0 25px',
+ },
+ children: [
+ [50, 50],
+ [50, 50],
+ [50, 50],
+ ],
+ }),
+ )
+ })
+
+ it('converts horizontal layout with single child 100% wide', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 230, 150],
+ children: [[0, 0, '100%', 50]],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+
+ it('converts horizontal layout with single child 100% height', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 230, 150],
+ children: [[0, 0, 50, '100%']],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+
+ it('converts vertical layout with single child 100% wide', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 100, 300],
+ children: [[0, 0, '100%', 50]],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+
+ it('single overflowing child does not make negative padding', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 100, 100],
+ children: [[0, 0, 150, 50]],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+
+ it('can convert zero-sized element with absolute children into a flex layout', async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ const { top: firstChildTopBeforeFlexConversion, left: firstChildLeftBeforeFlexConversion } =
+ renderResult.renderedDOM.getByTestId('first-child').style
+
+ const containerPath = EP.appendNewElementPath(TestScenePath, ['root', 'zero-sized'])
+ await selectComponentsForTest(renderResult, [containerPath])
+ await expectSingleUndo2Saves(renderResult, () => pressShiftA(renderResult))
+
+ const { top: containerTop, left: containerLeft } =
+ renderResult.getEditorState().editor.allElementProps[EP.toString(containerPath)]['style']
+
+ expect({
+ top: firstChildTopBeforeFlexConversion,
+ left: firstChildLeftBeforeFlexConversion,
+ }).toEqual({
+ top: `${containerTop}px`,
+ left: `${containerLeft}px`,
+ })
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+`),
+ )
+ })
+
+ it('converts groups correctly', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(
+ `
+
+ `,
+ ),
+
+ 'await-first-dom-report',
+ )
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+
+ it('converts groups with constraints that imply a certain row-like layout', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(
+ `
+
+
+
+ Hello World
+
+
+
+
+
+ `,
+ ),
+
+ 'await-first-dom-report',
+ )
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+})
+
+describe('Smart Convert to Flex Reordering Children if Needed', () => {
+ it('converts a horizontal layout with children out of order', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 500, 150],
+ children: [
+ [130, 0, 50, 50, 'c'],
+ [65, 0, 50, 50, 'b'],
+ [0, 0, 50, 50, 'a'],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 15,
+ },
+ children: [
+ [50, 50, 'a'],
+ [50, 50, 'b'],
+ [50, 50, 'c'],
+ ],
+ }),
+ )
+ })
+
+ it('reordering is disabled if non-jsx elements are among the children', async () => {
+ const editor = await renderTestEditorWithCode(projectWithTextChild, 'await-first-dom-report')
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/parent')])
+
+ const originalElementOrder = [
+ 'regular-sb/parent',
+ 'regular-sb/parent/first',
+ 'regular-sb/parent/second',
+ ]
+
+ expect(
+ getNavigatorTargetsFromEditorState(editor.getEditorState().editor).navigatorTargets.map(
+ navigatorEntryToKey,
+ ),
+ ).toEqual(originalElementOrder)
+
+ await expectSingleUndo2Saves(editor, () => pressShiftA(editor))
+
+ expect(
+ getNavigatorTargetsFromEditorState(editor.getEditorState().editor).navigatorTargets.map(
+ navigatorEntryToKey,
+ ),
+ ).toEqual(originalElementOrder)
+ })
+})
+
+describe('Smart Convert to Flex alignItems', () => {
+ it('all elements aligned at the start become alignItems flex-start, but we omit that for simplicity', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 500, 150],
+ children: [
+ [0, 0, 50, 60],
+ [65, 0, 50, 30],
+ [130, 0, 50, 60],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 15,
+ },
+ children: [
+ [50, 60],
+ [50, 30],
+ [50, 60],
+ ],
+ }),
+ )
+ })
+
+ it('elements aligned at their center become alignItems center', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 500, 150],
+ children: [
+ [0, 0, 50, 60],
+ [65, 15, 50, 30],
+ [130, 0, 50, 60],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 15,
+ alignItems: 'center',
+ },
+ children: [
+ [50, 60],
+ [50, 30],
+ [50, 60],
+ ],
+ }),
+ )
+ })
+
+ it('elements aligned at their bottom become alignItems flex-end', async () => {
+ const editor = await renderProjectWith({
+ parent: [50, 50, 500, 150],
+ children: [
+ [0, 0, 50, 60],
+ [65, 30, 50, 30],
+ [130, 0, 50, 60],
+ ],
+ })
+
+ await convertParentToFlex(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeReferenceProjectWith({
+ parent: {
+ left: 50,
+ top: 50,
+ width: MaxContent,
+ height: MaxContent,
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 15,
+ alignItems: 'flex-end',
+ },
+ children: [
+ [50, 60],
+ [50, 30],
+ [50, 60],
+ ],
+ }),
+ )
+ })
+})
+
+describe('Smart Convert To Flex if Fragment Children', () => {
+ it('adding flex layout to a container with non-dom element children', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(`
+
+
+
+
+
+
+
+
+
+
+ {true ? (
+
+ ) : null}
+
`),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [
+ EP.fromString(`${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:container`),
+ ])
+
+ await expectSingleUndo2Saves(editor, async () => {
+ await pressKey('a', { modifiers: shiftModifier })
+ })
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+
+
+
+
+
+
+
+
+
+ {true ? (
+
+ ) : null}
+
`),
+ )
+ })
+
+ it('reordering elements in the same group is allowed', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [
+ EP.fromString(`${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:container`),
+ ])
+
+ await expectSingleUndo2Saves(editor, async () => {
+ await pressKey('a', { modifiers: shiftModifier })
+ })
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+ `),
+ )
+
+ expect(getRegularNavigatorTargets(editor)).toEqual([
+ 'utopia-storyboard-uid/scene-aaa',
+ 'utopia-storyboard-uid/scene-aaa/app-entity',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-1',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-1/fragment-1-child-1',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-1/fragment-1-child-2',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/first-child', // <- reordered here from the 1st place
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-2',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-2/fragment-2-child-1',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-2/fragment-2-child-2',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/second-child', // <- reordered here from the 2nd place
+ ])
+ })
+
+ it('reordering elements with overlapping groups is not allowed', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ const originalOrder: string[] = [
+ 'utopia-storyboard-uid/scene-aaa',
+ 'utopia-storyboard-uid/scene-aaa/app-entity',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-1',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-1/fragment-1-child-1',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-1/fragment-1-child-2',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-2',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-2/fragment-2-child-1',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/fragment-2/fragment-2-child-2',
+ 'utopia-storyboard-uid/scene-aaa/app-entity:container/element',
+ ]
+
+ expect(getRegularNavigatorTargets(editor)).toEqual(originalOrder)
+
+ await selectComponentsForTest(editor, [
+ EP.fromString(`${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:container`),
+ ])
+
+ await expectSingleUndo2Saves(editor, async () => {
+ await pressKey('a', { modifiers: shiftModifier })
+ })
+
+ expect(getRegularNavigatorTargets(editor)).toEqual(originalOrder)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(``),
+ )
+ })
+})
+
+describe('Smart convert to flex centered layout', () => {
+ it('adds centered layout to a single span child', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(`
+
+
+ This will be centered
+
+
+ `),
+ 'await-first-dom-report',
+ )
+
+ const targetPath = EP.appendNewElementPath(TestScenePath, ['a'])
+ await selectComponentsForTest(editor, [targetPath])
+
+ await expectSingleUndo2Saves(editor, () => pressShiftA(editor))
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+
+ This will be centered
+
+
+ `),
+ )
+ })
+ it('adds centered layout if children are clustered around the x axis', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ const targetPath = EP.appendNewElementPath(TestScenePath, ['root', 'container'])
+ await selectComponentsForTest(editor, [targetPath])
+
+ await expectSingleUndo2Saves(editor, () => pressShiftA(editor))
+
+ const { alignItems, justifyContent, flexDirection } =
+ editor.renderedDOM.getByTestId('container').style
+ expect({ alignItems, justifyContent, flexDirection }).toEqual({
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ flexDirection: 'row',
+ })
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(``),
+ )
+ })
+ it('adds centered layout if children are clustered around the y axis', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ const targetPath = EP.appendNewElementPath(TestScenePath, ['root', 'container'])
+ await selectComponentsForTest(editor, [targetPath])
+
+ await expectSingleUndo2Saves(editor, () => pressShiftA(editor))
+
+ const { alignItems, justifyContent, flexDirection } =
+ editor.renderedDOM.getByTestId('container').style
+ expect({ alignItems, justifyContent, flexDirection }).toEqual({
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ flexDirection: 'column',
+ })
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(``),
+ )
+ })
+})
+
+function renderProjectWith(input: { parent: LTWH; children: Array }) {
+ const [parentL, parentT, parentW, parentH] = input.parent
+ return renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(`
+
+
+ ${input.children
+ .map((child, i) => {
+ const [childL, childT, childW, childH, maybeUid] = child
+ const uid = `child-${maybeUid ?? i}`
+ return `
`
+ })
+ .join('\n')}
+
+
+ `),
+ 'await-first-dom-report',
+ )
+}
+
+function makeReferenceProjectWith(input: { parent: FlexProps; children: Array }): string {
+ return makeTestProjectCodeWithSnippet(`
+
+
+ ${input.children
+ .map((child, i) => {
+ const [childW, childH, maybeUid] = child
+ const uid = `child-${maybeUid ?? i}`
+ return `
`
+ })
+ .join('\n')}
+
+
+ `)
+}
+
+const projectWithTextChild = `import * as React from 'react'
+import { Storyboard } from 'utopia-api'
+
+export var storyboard = (
+
+
+
+ first
+
+ hello
+
+ second
+
+
+
+)
+`
+
+async function convertParentToFlex(editor: EditorRenderResult) {
+ const targetPath = EP.appendNewElementPath(TestScenePath, ['a', 'parent'])
+ await editor.dispatch([selectComponents([targetPath], false)], true)
+
+ await expectSingleUndo2Saves(editor, () => pressShiftA(editor))
+}
+
+async function pressShiftA(editor: EditorRenderResult) {
+ await pressKey('A', { modifiers: shiftModifier })
+}
diff --git a/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-flex-strategy.ts b/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-flex-strategy.ts
new file mode 100644
index 000000000000..f1963d1ee0c4
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-flex-strategy.ts
@@ -0,0 +1,854 @@
+import { MetadataUtils } from '../../../core/model/element-metadata-utils'
+import { last, mapDropNulls, sortBy } from '../../../core/shared/array-utils'
+import { isLeft } from '../../../core/shared/either'
+import * as EP from '../../../core/shared/element-path'
+import type { ElementPathTrees } from '../../../core/shared/element-path-tree'
+import type {
+ ElementInstanceMetadata,
+ ElementInstanceMetadataMap,
+} from '../../../core/shared/element-template'
+import { isJSXElementLike, jsxFragment } from '../../../core/shared/element-template'
+import {
+ boundingRectangleArray,
+ getRectCenter,
+ nullIfInfinity,
+ zeroCanvasRect,
+ type CanvasRectangle,
+ anyRectanglesIntersect,
+ zeroRectIfNullOrInfinity,
+ roundTo,
+} from '../../../core/shared/math-utils'
+import type { ElementPath } from '../../../core/shared/project-file-types'
+import * as PP from '../../../core/shared/property-path'
+import { assertNever, fastForEach } from '../../../core/shared/utils'
+import { absolute } from '../../../utils/utils'
+import {
+ getElementFragmentLikeType,
+ isElementNonDOMElement,
+ replaceFragmentLikePathsWithTheirChildrenRecursive,
+ replaceNonDOMElementPathsWithTheirChildrenRecursive,
+} from '../../canvas/canvas-strategies/strategies/fragment-like-helpers'
+import type { JSXFragmentConversion } from '../../canvas/canvas-strategies/strategies/group-conversion-helpers'
+import {
+ actuallyConvertFramentToFrame,
+ getCommandsForConversionToDesiredType,
+} from '../../canvas/canvas-strategies/strategies/group-conversion-helpers'
+import { treatElementAsGroupLike } from '../../canvas/canvas-strategies/strategies/group-helpers'
+import type { CanvasFrameAndTarget } from '../../canvas/canvas-types'
+import type { CanvasCommand } from '../../canvas/commands/commands'
+import { rearrangeChildren } from '../../canvas/commands/rearrange-children-command'
+import { reorderElement } from '../../canvas/commands/reorder-element-command'
+import { setProperty, setPropertyOmitNullProp } from '../../canvas/commands/set-property-command'
+import { showToastCommand } from '../../canvas/commands/show-toast-command'
+import type { AllElementProps } from '../../editor/store/editor-state'
+import type { FlexDirection } from '../../inspector/common/css-utils'
+import {
+ childIs100PercentSizedInEitherDirection,
+ convertWidthToFlexGrowOptionally,
+ getSafeGroupChildConstraintsArray,
+ nukeAllAbsolutePositioningPropsCommands,
+ onlyChildIsSpan,
+ sizeToVisualDimensions,
+} from '../../inspector/inspector-common'
+import { setHugContentForAxis } from '../../inspector/inspector-strategies/hug-contents-strategy'
+import type { FlexAlignItems, FlexDirectionRowColumn } from './convert-strategies-common'
+import { getChildrenPathsForContainer } from './convert-strategies-common'
+
+function checkConstraintsForThreeElementRow(
+ allElementProps: AllElementProps,
+ firstChildPath: ElementPath,
+ secondChildPath: ElementPath,
+ thirdChildPath: ElementPath,
+): boolean {
+ // We're looking for this pattern of constraints:
+ // - ['left', 'width']
+ // - ['left', 'right']
+ // - ['right', 'width']
+ const firstChildConstraints = getSafeGroupChildConstraintsArray(allElementProps, firstChildPath)
+ const secondChildConstraints = getSafeGroupChildConstraintsArray(allElementProps, secondChildPath)
+ const thirdChildConstraints = getSafeGroupChildConstraintsArray(allElementProps, thirdChildPath)
+ return (
+ firstChildConstraints.length === 2 &&
+ firstChildConstraints.includes('left') &&
+ firstChildConstraints.includes('width') &&
+ secondChildConstraints.length === 2 &&
+ secondChildConstraints.includes('left') &&
+ secondChildConstraints.includes('right') &&
+ thirdChildConstraints.length === 2 &&
+ thirdChildConstraints.includes('right') &&
+ thirdChildConstraints.includes('width')
+ )
+}
+
+function anyOverlappingFrames(...elementMetadataEntries: Array): boolean {
+ const globalFrames = mapDropNulls((metadata) => metadata.globalFrame, elementMetadataEntries)
+ return anyRectanglesIntersect(globalFrames)
+}
+
+function laidOutAsRow(...elementMetadataEntries: Array): boolean {
+ if (anyOverlappingFrames(...elementMetadataEntries)) {
+ return false
+ } else {
+ for (let frontIndex: number = 0; frontIndex < elementMetadataEntries.length - 1; frontIndex++) {
+ const backIndex = frontIndex + 1
+ const frontMetadata = elementMetadataEntries[frontIndex]
+ const backMetadata = elementMetadataEntries[backIndex]
+ if (
+ zeroRectIfNullOrInfinity(frontMetadata.globalFrame).x >
+ zeroRectIfNullOrInfinity(backMetadata.globalFrame).x
+ ) {
+ return false
+ }
+ }
+ return true
+ }
+}
+
+type CommandsOrNotApplicable = Array | 'not-applicable'
+
+function convertSingleChildWith100PercentSize(
+ metadata: ElementInstanceMetadataMap,
+ allElementProps: AllElementProps,
+ elementPathTree: ElementPathTrees,
+ parentFlexDirection: FlexDirection | null,
+ path: ElementPath,
+ childrenPaths: Array,
+ direction: FlexDirectionRowColumn,
+): CommandsOrNotApplicable {
+ const firstChildPath = childrenPaths[0]
+
+ const { childWidth100Percent, childHeight100Percent } = childIs100PercentSizedInEitherDirection(
+ metadata,
+ firstChildPath,
+ )
+
+ const optionalCenterAlignCommands = onlyChildIsSpan(metadata, childrenPaths)
+ ? [
+ setProperty('always', path, PP.create('style', 'alignItems'), 'center'),
+ setProperty('always', path, PP.create('style', 'justifyContent'), 'center'),
+ setHugContentForAxis('horizontal', firstChildPath, parentFlexDirection),
+ setHugContentForAxis('vertical', firstChildPath, parentFlexDirection),
+ ]
+ : []
+
+ if (childrenPaths.length === 1 && (childWidth100Percent || childHeight100Percent)) {
+ return [
+ ...ifElementIsFragmentLikeFirstConvertItToFrame(
+ metadata,
+ allElementProps,
+ elementPathTree,
+ path,
+ ),
+ setProperty('always', path, PP.create('style', 'display'), 'flex'),
+ setProperty('always', path, PP.create('style', 'flexDirection'), direction),
+ ...(childWidth100Percent
+ ? []
+ : [setHugContentForAxis('horizontal', path, parentFlexDirection)]),
+ ...(childHeight100Percent
+ ? []
+ : [setHugContentForAxis('vertical', path, parentFlexDirection)]),
+ ...childrenPaths.flatMap((child) => [
+ ...nukeAllAbsolutePositioningPropsCommands(child),
+ ...convertWidthToFlexGrowOptionally(metadata, child, direction),
+ ]),
+ ...optionalCenterAlignCommands,
+ ]
+ }
+
+ return 'not-applicable'
+}
+
+function convertThreeElementGroupRow(
+ metadata: ElementInstanceMetadataMap,
+ allElementProps: AllElementProps,
+ elementPathTree: ElementPathTrees,
+ path: ElementPath,
+ childrenPaths: Array,
+ parentFlexDirection: FlexDirection | null,
+ direction: FlexDirectionRowColumn,
+): CommandsOrNotApplicable {
+ if (childrenPaths.length === 3) {
+ const childrenMetadata = mapDropNulls(
+ (childPath) => MetadataUtils.findElementByElementPath(metadata, childPath),
+ childrenPaths,
+ ).map((element) => {
+ return {
+ ...element,
+ localFrame: MetadataUtils.getLocalFrame(element.elementPath, metadata, null),
+ }
+ })
+ if (childrenMetadata.length === 3) {
+ // This should make the logic independent of the ordering within the code,
+ // as their logical order does not necessarily relate to their visual position.
+ const childrenMetadataOrderedByHorizontalPosition = sortBy(
+ childrenMetadata,
+ (first, second) => {
+ return (
+ zeroRectIfNullOrInfinity(first.localFrame).x -
+ zeroRectIfNullOrInfinity(second.localFrame).x
+ )
+ },
+ )
+ // Get the reordered metadata and paths.
+ const [firstChildElement, secondChildElement, thirdChildElement] =
+ childrenMetadataOrderedByHorizontalPosition
+ const [firstChildPath, secondChildPath, thirdChildPath] =
+ childrenMetadataOrderedByHorizontalPosition.map((meta) => meta.elementPath)
+ if (laidOutAsRow(firstChildElement, secondChildElement, thirdChildElement)) {
+ if (
+ checkConstraintsForThreeElementRow(
+ allElementProps,
+ firstChildPath,
+ secondChildPath,
+ thirdChildPath,
+ )
+ ) {
+ const firstFrame = zeroRectIfNullOrInfinity(firstChildElement.localFrame)
+ const secondFrame = zeroRectIfNullOrInfinity(secondChildElement.localFrame)
+
+ const gapValue = roundTo(secondFrame.x - firstFrame.width, 2)
+
+ return [
+ // Configure the parent element.
+ ...getCommandsForConversionToDesiredType(
+ metadata,
+ elementPathTree,
+ allElementProps,
+ [path],
+ 'group',
+ 'frame',
+ ),
+ ...maybeAlignElementsToCenter(
+ metadata,
+ childrenPaths,
+ path,
+ direction,
+ parentFlexDirection,
+ ),
+ setProperty('always', path, PP.create('style', 'display'), 'flex'),
+ setProperty('always', path, PP.create('style', 'flexDirection'), 'row'),
+ setProperty('always', path, PP.create('style', 'gap'), gapValue),
+ // Configure and reorder the "first" element.
+ ...nukeAllAbsolutePositioningPropsCommands(firstChildPath),
+ setProperty('always', firstChildPath, PP.create('style', 'flexGrow'), 0),
+ reorderElement('always', firstChildPath, absolute(0)),
+ // Configure and reorder the "second" element.
+ ...nukeAllAbsolutePositioningPropsCommands(secondChildPath),
+ setProperty('always', secondChildPath, PP.create('style', 'flexGrow'), 1),
+ reorderElement('always', secondChildPath, absolute(1)),
+ // Configure and reorder the "third" element.
+ ...nukeAllAbsolutePositioningPropsCommands(thirdChildPath),
+ setProperty('always', thirdChildPath, PP.create('style', 'flexGrow'), 0),
+ reorderElement('always', thirdChildPath, absolute(2)),
+ ]
+ }
+ }
+ }
+ }
+
+ return 'not-applicable'
+}
+
+export function convertLayoutToFlexCommands(
+ metadata: ElementInstanceMetadataMap,
+ elementPathTree: ElementPathTrees,
+ elementPaths: Array,
+ allElementProps: AllElementProps,
+): Array {
+ return elementPaths.flatMap((path) => {
+ const parentInstance = MetadataUtils.findElementByElementPath(metadata, path)
+ if (parentInstance == null) {
+ return []
+ }
+
+ if (MetadataUtils.isConditionalFromMetadata(parentInstance)) {
+ // we do not support retargeting to children of conditionals, the behavior is under design / development
+ return [
+ showToastCommand(
+ 'Cannot be converted to Flex yet',
+ 'NOTICE',
+ 'cannot-convert-children-to-flex',
+ ),
+ ]
+ }
+
+ const childrenPaths = getChildrenPathsForContainer(
+ metadata,
+ elementPathTree,
+ path,
+ allElementProps,
+ )
+
+ const parentFlexDirection =
+ MetadataUtils.findElementByElementPath(metadata, path)?.specialSizeMeasurements
+ .parentFlexDirection ?? null
+
+ if (childrenPaths.length === 0) {
+ // fall back to a simple prop-setting without any kind of guessing
+ return [setProperty('always', path, PP.create('style', 'display'), 'flex')]
+ }
+
+ const { direction, averageGap, sortedChildren, padding, alignItems } = guessMatchingFlexSetup(
+ metadata,
+ path,
+ childrenPaths,
+ )
+ const sortedChildrenPaths = sortedChildren.map((c) => EP.dynamicPathToStaticPath(c.target))
+
+ // Special case: We only have a single child which has a size of 100%.
+ const possibleSingleChildWith100PercentSize = convertSingleChildWith100PercentSize(
+ metadata,
+ allElementProps,
+ elementPathTree,
+ parentFlexDirection,
+ path,
+ childrenPaths,
+ direction,
+ )
+ if (possibleSingleChildWith100PercentSize !== 'not-applicable') {
+ return possibleSingleChildWith100PercentSize
+ }
+
+ // Special case: Group with 3 child elements in a row with some specific `data-constraints`.
+ const possibleThreeElementGroupRow = convertThreeElementGroupRow(
+ metadata,
+ allElementProps,
+ elementPathTree,
+ path,
+ childrenPaths,
+ parentFlexDirection,
+ direction,
+ )
+ if (possibleThreeElementGroupRow !== 'not-applicable') {
+ return possibleThreeElementGroupRow
+ }
+
+ const rearrangedChildrenPaths = rearrangedPathsWithFlexConversionMeasurementBoundariesIntact(
+ metadata,
+ allElementProps,
+ elementPathTree,
+ path,
+ sortedChildrenPaths,
+ )
+
+ // FIXME: `childrenPaths` doesn't include text elements yet, and this causes `rearrangeChildren` throw an error
+ const containerHasNoTextChildren = getElementTextChildrenCount(parentInstance) === 0
+
+ const rearrangeCommands =
+ rearrangedChildrenPaths != null && containerHasNoTextChildren
+ ? [
+ rearrangeChildren(
+ 'always',
+ path,
+ rearrangedChildrenPaths.map(EP.dynamicPathToStaticPath),
+ ),
+ ]
+ : [
+ showToastCommand(
+ "Couldn't preserve visual order of children (yet)",
+ 'NOTICE',
+ 'cannot-convert-children-to-flex',
+ ),
+ ]
+
+ return [
+ ...ifElementIsFragmentLikeFirstConvertItToFrame(
+ metadata,
+ allElementProps,
+ elementPathTree,
+ path,
+ ),
+ setProperty('always', path, PP.create('style', 'display'), 'flex'),
+ setProperty('always', path, PP.create('style', 'flexDirection'), direction),
+ ...setPropertyOmitNullProp('always', path, PP.create('style', 'gap'), averageGap),
+ setHugContentForAxis('horizontal', path, parentFlexDirection),
+ setHugContentForAxis('vertical', path, parentFlexDirection),
+ ...setPropertyOmitNullProp('always', path, PP.create('style', 'padding'), padding),
+ ...setPropertyOmitNullProp('always', path, PP.create('style', 'alignItems'), alignItems),
+ ...childrenPaths.flatMap((child) => [
+ ...nukeAllAbsolutePositioningPropsCommands(child),
+ ...sizeToVisualDimensions(metadata, elementPathTree, child),
+ ]),
+ ...rearrangeCommands,
+ ...maybeAlignElementsToCenter(metadata, childrenPaths, path, direction, parentFlexDirection),
+ ]
+ })
+}
+
+function ifElementIsFragmentLikeFirstConvertItToFrame(
+ metadata: ElementInstanceMetadataMap,
+ allElementProps: AllElementProps,
+ elementPathTrees: ElementPathTrees,
+ target: ElementPath,
+): Array {
+ const type = getElementFragmentLikeType(
+ metadata,
+ allElementProps,
+ elementPathTrees,
+ target,
+ 'sizeless-div-considered-fragment-like',
+ )
+ const elementInstace = MetadataUtils.findElementByElementPath(metadata, target)
+ if (
+ elementInstace == null ||
+ isLeft(elementInstace.element) ||
+ !isJSXElementLike(elementInstace.element.value)
+ ) {
+ return []
+ }
+
+ if (type == 'fragment' || type == 'sizeless-div' || treatElementAsGroupLike(metadata, target)) {
+ const childInstances = mapDropNulls(
+ (path) => MetadataUtils.findElementByElementPath(metadata, path),
+ replaceFragmentLikePathsWithTheirChildrenRecursive(
+ metadata,
+ allElementProps,
+ elementPathTrees,
+ MetadataUtils.getChildrenPathsOrdered(elementPathTrees, target),
+ ),
+ )
+
+ const childrenBoundingFrame =
+ boundingRectangleArray(
+ mapDropNulls(
+ (rect) => nullIfInfinity(rect),
+ childInstances.map((c) => c.globalFrame),
+ ),
+ ) ?? zeroCanvasRect
+
+ const instance: JSXFragmentConversion = {
+ element: jsxFragment(
+ elementInstace.element.value.uid,
+ elementInstace.element.value.children,
+ false,
+ ),
+ childInstances: childInstances,
+ childrenBoundingFrame: childrenBoundingFrame,
+ specialSizeMeasurements: elementInstace.specialSizeMeasurements,
+ }
+ return actuallyConvertFramentToFrame(metadata, elementPathTrees, instance, target)
+ }
+
+ return []
+}
+
+const NEAR_CENTER_LINE_TOLERANCE_MULTIPLIER = 0.2
+
+function isElementNearCenterLine(
+ parentGlobalFrame: CanvasRectangle,
+ parentFlexDirection: FlexDirectionRowColumn,
+ metadata: ElementInstanceMetadataMap,
+ elementPath: ElementPath,
+): boolean {
+ const childGlobalFrame = nullIfInfinity(
+ MetadataUtils.getFrameInCanvasCoords(elementPath, metadata),
+ )
+ if (childGlobalFrame == null) {
+ return false
+ }
+
+ const parentCenter = getRectCenter(parentGlobalFrame)
+ const childCenter = getRectCenter(childGlobalFrame)
+ switch (parentFlexDirection) {
+ case 'column':
+ return (
+ Math.abs(childCenter.x - parentCenter.x) <
+ parentGlobalFrame.width * NEAR_CENTER_LINE_TOLERANCE_MULTIPLIER
+ )
+ case 'row':
+ return (
+ Math.abs(childCenter.y - parentCenter.y) <
+ parentGlobalFrame.height * NEAR_CENTER_LINE_TOLERANCE_MULTIPLIER
+ )
+ default:
+ assertNever(parentFlexDirection)
+ }
+}
+
+function maybeAlignElementsToCenter(
+ metadata: ElementInstanceMetadataMap,
+ childrenPaths: ElementPath[],
+ targetPath: ElementPath,
+ detectedDirection: FlexDirectionRowColumn,
+ parentFlexDirection: FlexDirection | null,
+) {
+ if (onlyChildIsSpan(metadata, childrenPaths)) {
+ return [
+ setProperty('always', targetPath, PP.create('style', 'alignItems'), 'center'),
+ setProperty('always', targetPath, PP.create('style', 'justifyContent'), 'center'),
+ setHugContentForAxis('horizontal', childrenPaths[0], parentFlexDirection),
+ setHugContentForAxis('vertical', childrenPaths[0], parentFlexDirection),
+ ]
+ }
+
+ const parentGlobalFrame = nullIfInfinity(
+ MetadataUtils.getFrameInCanvasCoords(targetPath, metadata),
+ )
+ if (parentGlobalFrame == null) {
+ return []
+ }
+
+ const allChildrenAlongCenterLine = childrenPaths.every((child) =>
+ isElementNearCenterLine(parentGlobalFrame, detectedDirection, metadata, child),
+ )
+ if (!allChildrenAlongCenterLine) {
+ return []
+ }
+
+ return [
+ setProperty('always', targetPath, PP.create('style', 'alignItems'), 'center'),
+ setProperty('always', targetPath, PP.create('style', 'justifyContent'), 'flex-start'),
+ ]
+}
+
+function guessMatchingFlexSetup(
+ metadata: ElementInstanceMetadataMap,
+ target: ElementPath,
+ children: Array,
+): {
+ direction: FlexDirectionRowColumn
+ sortedChildren: Array
+ averageGap: number | null
+ padding: string | null
+ alignItems: FlexAlignItems | null
+} {
+ const result = guessLayoutDirection(metadata, target, children)
+
+ if (result.sortedChildren.length === 0) {
+ return { ...result, padding: null, alignItems: null }
+ }
+
+ const padding: string | null = guessPadding(
+ result.direction,
+ result.parentRect,
+ result.sortedChildren,
+ )
+
+ const alignItems: FlexAlignItems | null = guessAlignItems(result.direction, result.sortedChildren)
+
+ return { ...result, padding: padding, alignItems: alignItems }
+}
+
+function guessLayoutDirection(
+ metadata: ElementInstanceMetadataMap,
+ target: ElementPath,
+ children: Array,
+): {
+ direction: FlexDirectionRowColumn
+ sortedChildren: Array
+ parentRect: CanvasRectangle
+ averageGap: number | null
+} {
+ const parentRect = MetadataUtils.getFrameOrZeroRectInCanvasCoords(target, metadata)
+ const firstGuess: FlexDirectionRowColumn = parentRect.width > parentRect.height ? 'row' : 'column'
+ const firstGuessResult = detectConfigurationInDirection(
+ metadata,
+ children,
+ firstGuess,
+ parentRect,
+ )
+ if (firstGuessResult.childrenDontOverlap) {
+ return firstGuessResult
+ }
+ const secondGuess = firstGuess === 'row' ? 'column' : 'row'
+ const secondGuessResult = detectConfigurationInDirection(
+ metadata,
+ children,
+ secondGuess,
+ parentRect,
+ )
+ if (secondGuessResult.childrenDontOverlap) {
+ return secondGuessResult
+ }
+
+ // since none of the directions are great, let's fall back to our first guess
+ return firstGuessResult
+}
+
+function guessPadding(
+ direction: FlexDirectionRowColumn,
+ parentRect: CanvasRectangle,
+ sortedChildren: Array,
+): string | null {
+ const firstChild = sortedChildren[0]
+ const lastChild = last(sortedChildren)!
+
+ const paddingLeft = firstChild.frame.x - parentRect.x
+ const paddingRight =
+ parentRect.x + parentRect.width - (lastChild?.frame.x + lastChild?.frame.width)
+ const horizontalPadding = Math.max(0, Math.min(paddingLeft, paddingRight))
+ const paddingTop = firstChild.frame.y - parentRect.y
+ const paddingBottom =
+ parentRect.y + parentRect.height - (lastChild?.frame.y + lastChild?.frame.height)
+ const verticalPadding = Math.max(0, Math.min(paddingTop, paddingBottom))
+
+ if (horizontalPadding === 0 && verticalPadding === 0) {
+ return null
+ }
+
+ return `${appendPx(verticalPadding)} ${appendPx(horizontalPadding)}`
+}
+
+function appendPx(value: number): string {
+ return value === 0 ? '0' : `${value}px`
+}
+
+function detectConfigurationInDirection(
+ metadata: ElementInstanceMetadataMap,
+ children: Array,
+ direction: FlexDirectionRowColumn,
+ parentRect: CanvasRectangle,
+): {
+ childrenDontOverlap: boolean
+ direction: FlexDirectionRowColumn
+ sortedChildren: Array
+ averageGap: number | null
+ parentRect: CanvasRectangle
+} {
+ const childFrames: Array = children.map((child) => ({
+ target: child,
+ frame: MetadataUtils.getFrameOrZeroRectInCanvasCoords(child, metadata),
+ }))
+ const sortedChildren = sortBy(childFrames, (l, r) =>
+ direction === 'row' ? l.frame.x - r.frame.x : l.frame.y - r.frame.y,
+ )
+
+ if (children.length < 2) {
+ return {
+ childrenDontOverlap: true,
+ direction: direction,
+ parentRect: parentRect,
+ sortedChildren: sortedChildren,
+ averageGap: null,
+ }
+ }
+
+ let childrenDontOverlap: boolean = true
+ let gapSum = 0
+
+ fastForEach(sortedChildren, (child, i) => {
+ if (i === 0) {
+ return
+ } else {
+ const prevFrame: CanvasRectangle = sortedChildren[i - 1].frame
+ const gap =
+ direction === 'row'
+ ? child.frame.x - (prevFrame.x + prevFrame.width)
+ : child.frame.y - (prevFrame.y + prevFrame.height)
+
+ gapSum += gap
+ childrenDontOverlap = childrenDontOverlap && gap > -1
+ }
+ })
+
+ const averageGap = Math.max(0, gapSum / (sortedChildren.length - 1))
+
+ return {
+ childrenDontOverlap: childrenDontOverlap,
+ sortedChildren: sortedChildren,
+ direction: direction,
+ averageGap: averageGap === 0 ? null : averageGap,
+ parentRect: parentRect,
+ }
+}
+
+function guessAlignItems(
+ direction: FlexDirectionRowColumn,
+ children: Array,
+): FlexAlignItems | null {
+ if (children.length < 2) {
+ return null
+ }
+ const leftOrTop: 'x' | 'y' = direction === 'column' ? 'x' : 'y'
+ const widthOrHeight: 'width' | 'height' = direction === 'column' ? 'width' : 'height'
+
+ let allAlignedAtStart: boolean = true
+ let allAlignedAtCenter: boolean = true
+ let allAlignedAtEnd: boolean = true
+
+ for (let index = 1; index < children.length; index++) {
+ const previousElement = children[index - 1].frame
+ const currentElement = children[index].frame
+
+ // check for flex-start
+ if (previousElement[leftOrTop] !== currentElement[leftOrTop]) {
+ allAlignedAtStart = false
+ }
+
+ // check for center
+ if (
+ previousElement[leftOrTop] + previousElement[widthOrHeight] / 2 !==
+ currentElement[leftOrTop] + currentElement[widthOrHeight] / 2
+ ) {
+ allAlignedAtCenter = false
+ }
+
+ // check for flex-end
+ if (
+ previousElement[leftOrTop] + previousElement[widthOrHeight] !==
+ currentElement[leftOrTop] + currentElement[widthOrHeight]
+ ) {
+ allAlignedAtEnd = false
+ }
+ }
+
+ if (allAlignedAtStart) {
+ return null // we omit flex-start as that is the default anyways. Improvement: check if it _is_ the default computed style!
+ }
+ if (allAlignedAtCenter) {
+ return 'center'
+ }
+ if (allAlignedAtEnd) {
+ return 'flex-end'
+ }
+
+ // fallback: null, which implicitly means a default of flex-start
+ return null
+}
+
+interface NonDOMElementWithLeaves {
+ element: ElementPath // path to a non-DOM element
+ leaves: Set // stringified element paths to the leaves in the tree of this element
+}
+
+interface TopLevelChildrenAndGroups {
+ topLevelChildren: Set // children that are DOM elements and are immediate children of a the parent
+ nonDOMElementsWithLeaves: Array // children that are non-DOM elements
+}
+
+function getTopLevelChildrenAndMeasurementBoundaries(
+ metadata: ElementInstanceMetadataMap,
+ allElementProps: AllElementProps,
+ pathTrees: ElementPathTrees,
+ parentPath: ElementPath,
+): TopLevelChildrenAndGroups {
+ let topLevelChildren: Array = []
+ let maesurementBoundaries: Array = []
+
+ const childrenPaths = MetadataUtils.getChildrenPathsOrdered(pathTrees, parentPath)
+
+ for (const child of childrenPaths) {
+ if (isElementNonDOMElement(metadata, allElementProps, pathTrees, child)) {
+ maesurementBoundaries.push({
+ element: child,
+ leaves: new Set(
+ replaceNonDOMElementPathsWithTheirChildrenRecursive(
+ metadata,
+ allElementProps,
+ pathTrees,
+ [child],
+ ).map(EP.toString),
+ ),
+ })
+ } else {
+ topLevelChildren.push(EP.toString(child))
+ }
+ }
+
+ return {
+ topLevelChildren: new Set(topLevelChildren),
+ nonDOMElementsWithLeaves: maesurementBoundaries,
+ }
+}
+
+/**
+ * Checks whether a prefix of `sortedChildren` is made up of the elements of `siblings`
+ * If the elements of `siblings` is a prefix of `sortedChildren`, the prefix is dropped and the rest of `sortedChildren` is returned
+ * Otherwise, null is returned, signaling failure
+ */
+function checkAllChildrenPartOfSingleGroup(
+ siblings: Set,
+ sortedChildren: Array,
+): Array | null {
+ const workingSiblings = new Set([...siblings])
+ let workingChildren = sortedChildren
+
+ while (workingSiblings.size > 0) {
+ if (workingChildren.length === 0) {
+ // this is an invariant violation, since `siblings` should be a subset of `sortedChildren`
+ return null
+ }
+
+ const child = workingChildren[0]
+ const childPathString = EP.toString(child)
+
+ if (!workingSiblings.has(childPathString)) {
+ // this child was reordered here from another measurement unit, which we disallow for now
+ return null
+ }
+
+ workingSiblings.delete(childPathString)
+ workingChildren = workingChildren.slice(1)
+ }
+ return workingChildren
+}
+
+/**
+ * returns a list of element paths, so that non-dom element children are swapped out for their
+ */
+function rearrangedPathsWithFlexConversionMeasurementBoundariesIntact(
+ metadata: ElementInstanceMetadataMap,
+ allElementProps: AllElementProps,
+ pathTrees: ElementPathTrees,
+ parentPath: ElementPath,
+ sortedChildren: Array,
+): Array | null {
+ const childrenAndGroups = getTopLevelChildrenAndMeasurementBoundaries(
+ metadata,
+ allElementProps,
+ pathTrees,
+ parentPath,
+ )
+
+ let workingSortedChildren = sortedChildren
+ let finalReorderedPaths: Array = []
+
+ while (workingSortedChildren.length > 0) {
+ const child = workingSortedChildren[0]
+ const childPathString = EP.toString(child)
+
+ if (childrenAndGroups.topLevelChildren.has(childPathString)) {
+ finalReorderedPaths.push(child)
+ workingSortedChildren = workingSortedChildren.slice(1)
+ } else {
+ const measurementBoundaryWithChild = childrenAndGroups.nonDOMElementsWithLeaves.find((g) =>
+ g.leaves.has(childPathString),
+ )
+
+ if (measurementBoundaryWithChild == null) {
+ return null
+ }
+
+ const restOfChildren = checkAllChildrenPartOfSingleGroup(
+ measurementBoundaryWithChild.leaves,
+ workingSortedChildren,
+ )
+
+ if (restOfChildren == null) {
+ return null
+ }
+
+ if (!EP.pathsEqual(finalReorderedPaths.at(-1) ?? null, child)) {
+ finalReorderedPaths.push(measurementBoundaryWithChild.element)
+ }
+
+ workingSortedChildren = restOfChildren
+ }
+ }
+
+ return finalReorderedPaths
+}
+
+function getElementTextChildrenCount(instance: ElementInstanceMetadata): number {
+ if (
+ isLeft(instance.element) ||
+ !(
+ instance.element.value.type === 'JSX_ELEMENT' ||
+ instance.element.value.type === 'JSX_FRAGMENT'
+ )
+ ) {
+ return 0
+ }
+
+ return instance.element.value.children.filter((e) => e.type === 'JSX_TEXT_BLOCK').length
+}
diff --git a/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-grid-strategy.ts b/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-grid-strategy.ts
new file mode 100644
index 000000000000..8299cbd6cb21
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/shared-strategies/convert-to-grid-strategy.ts
@@ -0,0 +1,136 @@
+import type { ElementPath } from 'utopia-shared/src/types'
+import type { ElementPathTrees } from '../../../core/shared/element-path-tree'
+import type { ElementInstanceMetadataMap } from '../../../core/shared/element-template'
+import type { AllElementProps } from '../../editor/store/editor-state'
+import type { CanvasCommand } from '../../canvas/commands/commands'
+import {
+ flexContainerProps,
+ gridContainerProps,
+ nukeAllAbsolutePositioningPropsCommands,
+ prunePropsCommands,
+ sizeToVisualDimensions,
+} from '../../inspector/inspector-common'
+import { getChildrenPathsForContainer } from './convert-strategies-common'
+import type { CanvasFrameAndTarget } from '../../canvas/canvas-types'
+import { MetadataUtils } from '../../../core/model/element-metadata-utils'
+import { setProperty } from '../../canvas/commands/set-property-command'
+import * as PP from '../../../core/shared/property-path'
+
+function guessLayoutInfoAlongAxis(
+ children: Array,
+ sortFn: (a: CanvasFrameAndTarget, b: CanvasFrameAndTarget) => number,
+ comesAfter: (a: CanvasFrameAndTarget, b: CanvasFrameAndTarget) => boolean,
+ gapBetween: (a: CanvasFrameAndTarget, b: CanvasFrameAndTarget) => number,
+): { nChildren: number; averageGap: number } {
+ if (children.length === 0) {
+ return { nChildren: 0, averageGap: 0 }
+ }
+
+ const sortedChildren = children.sort(sortFn)
+ let childrenAlongAxis = 1
+ let gaps: number[] = []
+ let currentChild = sortedChildren[0]
+ for (const child of sortedChildren.slice(1)) {
+ if (comesAfter(currentChild, child)) {
+ childrenAlongAxis += 1
+ gaps.push(gapBetween(currentChild, child))
+ currentChild = child
+ }
+ }
+
+ const averageGap =
+ gaps.length === 0 ? 0 : Math.floor(gaps.reduce((a, b) => a + b, 0) / gaps.length)
+
+ return {
+ nChildren: childrenAlongAxis,
+ averageGap: averageGap,
+ }
+}
+
+function guessMatchingGridSetup(
+ children: Array,
+ isFlexContainer: boolean,
+): {
+ gap: number
+ numberOfColumns: number
+ numberOfRows: number
+} {
+ const horizontalData = guessLayoutInfoAlongAxis(
+ children,
+ (a, b) => a.frame.x - b.frame.x,
+ (a, b) => a.frame.x + a.frame.width <= b.frame.x,
+ (a, b) => b.frame.x - (a.frame.x + a.frame.width),
+ )
+ const verticalData = guessLayoutInfoAlongAxis(
+ children,
+ (a, b) => a.frame.y - b.frame.y,
+ (a, b) => a.frame.y + a.frame.height <= b.frame.y,
+ (a, b) => b.frame.y - (a.frame.y + a.frame.height),
+ )
+
+ const minRowsOrCols = isFlexContainer ? 1 : 2
+
+ return {
+ gap: (horizontalData.averageGap + verticalData.averageGap) / 2,
+ numberOfColumns: Math.max(minRowsOrCols, horizontalData.nChildren),
+ numberOfRows: Math.max(minRowsOrCols, verticalData.nChildren),
+ }
+}
+
+export function convertLayoutToGridCommands(
+ metadata: ElementInstanceMetadataMap,
+ elementPathTree: ElementPathTrees,
+ elementPaths: Array,
+ allElementProps: AllElementProps,
+): Array {
+ return elementPaths.flatMap((elementPath) => {
+ const childrenPaths = getChildrenPathsForContainer(
+ metadata,
+ elementPathTree,
+ elementPath,
+ allElementProps,
+ )
+ const childFrames: Array = childrenPaths.map((child) => ({
+ target: child,
+ frame: MetadataUtils.getFrameOrZeroRectInCanvasCoords(child, metadata),
+ }))
+
+ const isFlexContainer = MetadataUtils.isFlexLayoutedContainer(
+ MetadataUtils.findElementByElementPath(metadata, elementPath),
+ )
+
+ const { gap, numberOfColumns, numberOfRows } = guessMatchingGridSetup(
+ childFrames,
+ isFlexContainer,
+ )
+
+ let commands = [
+ ...prunePropsCommands(flexContainerProps, elementPath),
+ ...prunePropsCommands(gridContainerProps, elementPath),
+ ...childrenPaths.flatMap((child) => [
+ ...nukeAllAbsolutePositioningPropsCommands(child),
+ ...sizeToVisualDimensions(metadata, elementPathTree, child),
+ ]),
+ setProperty('always', elementPath, PP.create('style', 'display'), 'grid'),
+ setProperty('always', elementPath, PP.create('style', 'gap'), gap),
+ setProperty(
+ 'always',
+ elementPath,
+ PP.create('style', 'gridTemplateColumns'),
+ Array(numberOfColumns).fill('1fr').join(' '),
+ ),
+ setProperty(
+ 'always',
+ elementPath,
+ PP.create('style', 'gridTemplateRows'),
+ Array(numberOfRows).fill('1fr').join(' '),
+ ),
+ ]
+
+ if (!isFlexContainer) {
+ commands.push(setProperty('always', elementPath, PP.create('style', 'gap'), 10))
+ }
+
+ return commands
+ })
+}
diff --git a/nexus-builder/packages/core/src/components/common/size-warnings.ts b/nexus-builder/packages/core/src/components/common/size-warnings.ts
new file mode 100644
index 000000000000..c7b73ad046ba
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/size-warnings.ts
@@ -0,0 +1 @@
+export const ChildWithPercentageSize = 'Dynamic scene has child with percentage size'
diff --git a/nexus-builder/packages/core/src/components/common/user-preferences.ts b/nexus-builder/packages/core/src/components/common/user-preferences.ts
new file mode 100644
index 000000000000..4786f9061d77
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/common/user-preferences.ts
@@ -0,0 +1,64 @@
+import localforage from 'localforage'
+import type { StoredLayout } from '../canvas/stored-layout'
+import { gridMenuDefaultPanels } from '../canvas/stored-layout'
+
+export type UserPreferences = {
+ panelsLayout: PanelsLayout
+}
+
+type PanelsLayout = {
+ // the default layout for new projects
+ default: StoredLayout
+ // per-project layouts
+ project: {
+ [key: string]: StoredLayout
+ }
+}
+
+export function getProjectStoredLayoutOrDefault(
+ layout: PanelsLayout,
+ projectId: string,
+): StoredLayout {
+ const projectLayout = layout.project[projectId]
+ return projectLayout ?? layout.default
+}
+
+export const USER_PREFERENCES_KEY = 'utopia.userPreferences'
+
+export function defaultUserPreferences(): UserPreferences {
+ return {
+ panelsLayout: {
+ default: gridMenuDefaultPanels(),
+ project: {},
+ },
+ }
+}
+
+export async function saveUserPreferences(prefs: Partial): Promise {
+ const currentPrefs = await loadUserPreferences()
+ const newPrefs = {
+ ...currentPrefs,
+ ...prefs,
+ }
+ await localforage.setItem(USER_PREFERENCES_KEY, newPrefs)
+}
+
+export async function saveUserPreferencesDefaultLayout(layout: StoredLayout): Promise {
+ const prefs = await loadUserPreferences()
+ prefs.panelsLayout.default = layout
+ await localforage.setItem(USER_PREFERENCES_KEY, prefs)
+}
+
+export async function saveUserPreferencesProjectLayout(
+ projectId: string,
+ layout: StoredLayout,
+): Promise {
+ const prefs = await loadUserPreferences()
+ prefs.panelsLayout.project[projectId] = layout
+ return saveUserPreferences(prefs)
+}
+
+export async function loadUserPreferences(): Promise {
+ const stored = await localforage.getItem(USER_PREFERENCES_KEY)
+ return stored ?? defaultUserPreferences()
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/action-creators.ts b/nexus-builder/packages/core/src/components/editor/actions/action-creators.ts
new file mode 100644
index 000000000000..a8c9383b9f1a
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/action-creators.ts
@@ -0,0 +1,1942 @@
+import type { LoginState } from '../../../common/user'
+import type { LayoutTargetableProp } from '../../../core/layout/layout-helpers-new'
+import type {
+ JSExpression,
+ JSXElement,
+ JSXElementName,
+ ElementInstanceMetadataMap,
+ JSXElementChild,
+ TopLevelElement,
+} from '../../../core/shared/element-template'
+import type {
+ CanvasPoint,
+ CanvasRectangle,
+ Size,
+ WindowPoint,
+} from '../../../core/shared/math-utils'
+import type {
+ PackageStatus,
+ RequestedNpmDependency,
+} from '../../../core/shared/npm-dependency-types'
+import type {
+ Imports,
+ NodeModules,
+ ParsedTextFile,
+ ProjectFile,
+ PropertyPath,
+ ElementPath,
+ ImageFile,
+ ExportDetail,
+} from '../../../core/shared/project-file-types'
+import type { BuildType } from '../../../core/workers/common/worker-types'
+import type { Key, KeysPressed } from '../../../utils/keyboard'
+import type { IndexPosition } from '../../../utils/utils'
+import type { CSSCursor } from '../../../uuiui-deps'
+import type { ProjectContentTreeRoot } from '../../assets'
+import CanvasActions from '../../canvas/canvas-actions'
+import type { PinOrFlexFrameChange, SelectionLocked } from '../../canvas/canvas-types'
+import type { CanvasCommand } from '../../canvas/commands/commands'
+import type { EditorPane, EditorPanel } from '../../common/actions'
+import type { Notice } from '../../common/notice'
+import type { CodeResultCache, PropertyControlsInfo } from '../../custom-code/code-file'
+import type { ElementContextMenuInstance } from '../../element-context-menu'
+import type { FontSettings } from '../../inspector/common/css-utils'
+import type { CSSTarget } from '../../inspector/sections/header-section/target-selector'
+import type { InsertableComponent, StylePropOption } from '../../shared/project-components'
+import type {
+ AddFolder,
+ AddMissingDimensions,
+ AddTextFile,
+ Alignment,
+ AlignSelectedViews,
+ Atomic,
+ ClearHighlightedViews,
+ ClearImageFileBlob,
+ ClearParseOrPrintInFlight,
+ ClosePopup,
+ CloseTextEditor,
+ CopySelectionToClipboard,
+ DeleteFile,
+ DeleteView,
+ DistributeSelectedViews,
+ Distribution,
+ DuplicateSelected,
+ DuplicateSpecificElements,
+ EditorAction,
+ EditorDispatch,
+ FinishCheckpointTimer,
+ HideModal,
+ InsertDroppedImage,
+ InsertImageIntoUI,
+ InsertJSXElement,
+ MoveSelectedBackward,
+ MoveSelectedForward,
+ MoveSelectedToBack,
+ MoveSelectedToFront,
+ OpenCodeEditorFile,
+ OpenPopup,
+ OpenTextEditor,
+ AddToast,
+ RemoveToast,
+ Redo,
+ RedrawOldCanvasControls,
+ RenameStyleSelector,
+ ResetPins,
+ SaveAsset,
+ SaveCurrentFile,
+ SaveDOMReport,
+ SaveImageDetails,
+ SaveImageDoNothing,
+ SaveImageInsertWith,
+ SaveImageReplace,
+ SaveImageSwitchMode,
+ SelectAllSiblings,
+ SelectComponents,
+ SetAspectRatioLock,
+ SetCanvasFrames,
+ SetCodeEditorBuildErrors,
+ SetCodeEditorLintErrors,
+ SetCodeEditorVisibility,
+ SetCursorOverlay,
+ SetFilebrowserRenamingTarget,
+ SetHighlightedViews,
+ SetLeftMenuExpanded,
+ SetLeftMenuTab,
+ SetMainUIFile,
+ SetNavigatorRenamingTarget,
+ SetPackageStatus,
+ SetPanelVisibility,
+ SetProjectID,
+ SetProjectName,
+ SetProjectDescription,
+ SetProp,
+ SetRightMenuExpanded,
+ SetRightMenuTab,
+ SetSafeMode,
+ SetSaveError,
+ SetShortcut,
+ SetStoredFontSettings,
+ SetZIndex,
+ ShowContextMenu,
+ ShowModal,
+ StartCheckpointTimer,
+ SwitchEditorMode,
+ ToggleCanvasIsLive,
+ ToggleCollapse,
+ ToggleHidden,
+ ToggleInterfaceDesignerAdditionalControls,
+ TogglePane,
+ ToggleProperty,
+ TransientActions,
+ Undo,
+ UnsetProperty,
+ UnwrapElements,
+ UpdateText,
+ UpdateCodeResultCache,
+ UpdateDuplicationState,
+ UpdateEditorMode,
+ UpdateFile,
+ UpdateFilePath,
+ UpdateFrameDimensions,
+ UpdateFromWorker,
+ UpdateJSXElementName,
+ UpdateKeysPressed,
+ UpdateNodeModulesContents,
+ UpdatePackageJson,
+ UpdatePropertyControlsInfo,
+ CloseDesignerFile,
+ SetFocusedElement,
+ AddImports,
+ ScrollToElement,
+ WorkerParsedUpdate,
+ SetScrollAnimation,
+ SetFollowSelectionEnabled,
+ SetLoginState,
+ ResetCanvas,
+ SetFilebrowserDropTarget,
+ SetForkedFromProjectID,
+ SetCurrentTheme,
+ FocusFormulaBar,
+ UpdateFormulaBarMode,
+ InsertInsertable,
+ ToggleFocusedOmniboxTab,
+ SetPropTransient,
+ ClearTransientProps,
+ AddTailwindConfig,
+ FocusClassNameInput,
+ WrapInElement,
+ DecrementResizeOptionsSelectedIndex,
+ IncrementResizeOptionsSelectedIndex,
+ SetResizeOptionsTargetOptions,
+ ForceParseFile,
+ RemoveFromNodeModulesContents,
+ RunEscapeHatch,
+ UpdateMouseButtonsPressed,
+ ToggleSelectionLock,
+ ElementPaste,
+ SetGithubState,
+ UpdateProjectContents,
+ UpdateGithubSettings,
+ SetImageDragSessionState as SetDragSessionState,
+ UpdateGithubOperations,
+ UpdateBranchContents,
+ UpdateAgainstGithub,
+ UpdateGithubData,
+ RemoveFileConflict,
+ SetRefreshingDependencies,
+ SetUserConfiguration,
+ SetHoveredViews,
+ ClearHoveredViews,
+ ApplyCommandsAction,
+ WorkerCodeAndParsedUpdate,
+ UpdateColorSwatches,
+ PasteProperties,
+ CopyProperties,
+ MergeWithPrevUndo,
+ SetConditionalOverriddenCondition,
+ SwitchConditionalBranches,
+ UpdateConditionalExpression,
+ CutSelectionToClipboard,
+ ExecutePostActionMenuChoice,
+ StartPostActionSession,
+ ClearPostActionSession,
+ ScrollToElementBehaviour,
+ OpenCodeEditor,
+ SetMapCountOverride,
+ TruncateHistory,
+ RunDOMWalker,
+ WrapInElementWith,
+ ScrollToPosition,
+ UpdateProjectServerState,
+ UpdateTopLevelElementsFromCollaborationUpdate,
+ DeleteFileFromCollaboration,
+ UpdateExportsDetailFromCollaborationUpdate,
+ UpdateImportsFromCollaborationUpdate,
+ UpdateCodeFromCollaborationUpdate,
+ SetCommentFilterMode,
+ SetForking,
+ SetCollaborators,
+ ExtractPropertyControlsFromDescriptorFiles,
+ SetSharingDialogOpen,
+ SetCodeEditorComponentDescriptorErrors,
+ AddNewPage,
+ UpdateRemixRoute,
+ AddNewFeaturedRoute,
+ RemoveFeaturedRoute,
+ ResetOnlineState,
+ IncreaseOnlineStateFailureCount,
+ AddCollapsedViews,
+ ReplaceMappedElement,
+ ReplaceTarget,
+ InsertAsChildTarget,
+ ReplaceKeepChildrenAndStyleTarget,
+ WrapTarget,
+ ReplaceElementInScope,
+ ElementReplacementPath,
+ ReplaceJSXElement,
+ ToggleDataCanCondense,
+ UpdateMetadataInEditorState,
+ SetErrorBoundaryHandling,
+ SetImportWizardOpen,
+ UpdateImportOperations,
+ UpdateProjectRequirements,
+ UpdateImportStatus,
+} from '../action-types'
+import type { InsertionSubjectWrapper, Mode } from '../editor-modes'
+import { EditorModes, insertionSubject } from '../editor-modes'
+import type {
+ ImageDragSessionState,
+ DuplicationState,
+ ErrorMessages,
+ GithubState,
+ LeftMenuTab,
+ ModalDialog,
+ OriginalFrame,
+ ProjectGithubSettings,
+ RightMenuTab,
+ GithubOperation,
+ GithubData,
+ UserConfiguration,
+ ThemeSetting,
+ ColorSwatch,
+ PostActionMenuData,
+ ErrorBoundaryHandling,
+} from '../store/editor-state'
+import type { InsertionPath } from '../store/insertion-path'
+import type { TextProp } from '../../text-editor/text-editor'
+import type { PostActionChoice } from '../../canvas/canvas-strategies/post-action-options/post-action-options'
+import type { ProjectServerState } from '../store/project-server-state'
+import type { SetHuggingParentToFixed } from '../../canvas/canvas-strategies/strategies/convert-to-absolute-and-move-strategy'
+import type { CommentFilterMode } from '../../inspector/sections/comment-section'
+import type { Collaborator } from '../../../core/shared/multiplayer'
+import type { PageTemplate } from '../../canvas/remix/remix-utils'
+import type { Bounds } from 'utopia-vscode-common'
+import type { ElementPathTrees } from '../../../core/shared/element-path-tree'
+import type {
+ ImportOperation,
+ ImportOperationAction,
+ ImportStatus,
+} from '../../../core/shared/import/import-operation-types'
+import type { ProjectRequirements } from '../../../core/shared/import/project-health-check/utopia-requirements-types'
+
+export function clearSelection(): EditorAction {
+ return {
+ action: 'CLEAR_SELECTION',
+ }
+}
+
+export const replaceTarget: ReplaceTarget = { type: 'replace-target' }
+export const wrapTarget: WrapTarget = { type: 'wrap-target' }
+export const replaceKeepChildrenAndStyleTarget: ReplaceKeepChildrenAndStyleTarget = {
+ type: 'replace-target-keep-children-and-style',
+}
+export function insertAsChildTarget(indexPosition?: IndexPosition): InsertAsChildTarget {
+ return { type: 'insert-as-child', indexPosition: indexPosition }
+}
+
+export function insertJSXElement(
+ element: JSXElement,
+ target: ElementPath | null,
+ importsToAdd: Imports,
+ indexPosition?: IndexPosition,
+): InsertJSXElement {
+ return {
+ action: 'INSERT_JSX_ELEMENT',
+ jsxElement: element,
+ target: target,
+ importsToAdd: importsToAdd,
+ indexPosition: indexPosition ?? null,
+ }
+}
+
+export function replaceJSXElement(
+ element: JSXElement,
+ target: ElementPath,
+ importsToAdd: Imports,
+ behaviour: ReplaceKeepChildrenAndStyleTarget | ReplaceTarget,
+): ReplaceJSXElement {
+ return {
+ action: 'REPLACE_JSX_ELEMENT',
+ jsxElement: element,
+ target: target,
+ importsToAdd: importsToAdd,
+ behaviour: behaviour,
+ }
+}
+
+export function replaceMappedElement(
+ element: JSXElement,
+ target: ElementPath,
+ importsToAdd: Imports,
+): ReplaceMappedElement {
+ return {
+ action: 'REPLACE_MAPPED_ELEMENT',
+ jsxElement: element,
+ target: target,
+ importsToAdd: importsToAdd,
+ }
+}
+
+export function replaceElementInScope(
+ target: ElementPath,
+ replacementPath: ElementReplacementPath,
+): ReplaceElementInScope {
+ return {
+ action: 'REPLACE_ELEMENT_IN_SCOPE',
+ target: target,
+ replacementPath: replacementPath,
+ }
+}
+
+export function deleteView(target: ElementPath): DeleteView {
+ return {
+ action: 'DELETE_VIEW',
+ target: target,
+ }
+}
+
+export function deleteSelected(): EditorAction {
+ return {
+ action: 'DELETE_SELECTED',
+ }
+}
+
+export function unsetProperty(element: ElementPath, property: PropertyPath): UnsetProperty {
+ return {
+ action: 'UNSET_PROPERTY',
+ element: element,
+ property: property,
+ }
+}
+
+export function toggleHidden(targets: Array = []): ToggleHidden {
+ return {
+ action: 'TOGGLE_HIDDEN',
+ targets: targets,
+ }
+}
+
+export function toggleDataCanCondense(targets: Array): ToggleDataCanCondense {
+ return {
+ action: 'TOGGLE_DATA_CAN_CONDENSE',
+ targets: targets,
+ }
+}
+
+export function transientActions(
+ actions: Array,
+ elementsToRerender: Array,
+): TransientActions {
+ return {
+ action: 'TRANSIENT_ACTIONS',
+ transientActions: actions,
+ elementsToRerender: elementsToRerender,
+ }
+}
+
+export function mergeWithPrevUndo(actions: Array): MergeWithPrevUndo {
+ return {
+ action: 'MERGE_WITH_PREV_UNDO',
+ actions: actions,
+ }
+}
+
+export function selectComponents(
+ target: Array,
+ addToSelection: boolean,
+): SelectComponents {
+ return {
+ action: 'SELECT_COMPONENTS',
+ target: target,
+ addToSelection: addToSelection,
+ }
+}
+
+export function updateEditorMode(mode: Mode): UpdateEditorMode {
+ return {
+ action: 'UPDATE_EDITOR_MODE',
+ mode: mode,
+ }
+}
+
+export function switchEditorMode(
+ mode: Mode,
+ unlessMode?: 'select' | 'live' | 'insert' | 'textEdit',
+): SwitchEditorMode {
+ return {
+ action: 'SWITCH_EDITOR_MODE',
+ mode: mode,
+ unlessMode: unlessMode,
+ }
+}
+
+export function duplicateSelected(): DuplicateSelected {
+ return {
+ action: 'DUPLICATE_SELECTED',
+ }
+}
+
+export function duplicateSpecificElements(paths: Array): DuplicateSpecificElements {
+ return {
+ action: 'DUPLICATE_SPECIFIC_ELEMENTS',
+ paths: paths,
+ }
+}
+
+export function updateDuplicationState(
+ duplicationState: DuplicationState | null,
+): UpdateDuplicationState {
+ return {
+ action: 'UPDATE_DUPLICATION_STATE',
+ duplicationState: duplicationState,
+ }
+}
+
+export function setCanvasFrames(
+ framesAndTargets: Array,
+ keepChildrenGlobalCoords: boolean,
+ originalFrames: Array | null = null,
+): SetCanvasFrames {
+ return {
+ action: 'SET_CANVAS_FRAMES',
+ framesAndTargets: framesAndTargets,
+ keepChildrenGlobalCoords: keepChildrenGlobalCoords,
+ originalFrames: originalFrames,
+ }
+}
+
+export function setPanelVisibility(
+ target: EditorPanel | EditorPane,
+ visible: boolean,
+): SetPanelVisibility {
+ return {
+ action: 'SET_PANEL_VISIBILITY',
+ target: target,
+ visible: visible,
+ }
+}
+
+export function toggleFocusedOmniboxTab(): ToggleFocusedOmniboxTab {
+ return {
+ action: 'TOGGLE_FOCUSED_OMNIBOX_TAB',
+ }
+}
+
+export function togglePanel(panel: EditorPanel | EditorPane): TogglePane {
+ return {
+ action: 'TOGGLE_PANE',
+ target: panel,
+ }
+}
+
+export function openPopup(popupId: string): OpenPopup {
+ return {
+ action: 'OPEN_POPUP',
+ popupId: popupId,
+ }
+}
+
+export function closePopup(): ClosePopup {
+ return {
+ action: 'CLOSE_POPUP',
+ }
+}
+
+export function elementPaste(
+ element: JSXElementChild,
+ importsToAdd: Imports,
+ originalElementPath: ElementPath,
+ duplicateNameMap?: Map,
+): ElementPaste {
+ return {
+ element: element,
+ importsToAdd: importsToAdd,
+ originalElementPath: originalElementPath,
+ duplicateNameMap: duplicateNameMap,
+ }
+}
+
+export function copySelectionToClipboard(): CopySelectionToClipboard {
+ return {
+ action: 'COPY_SELECTION_TO_CLIPBOARD',
+ }
+}
+
+export function cutSelectionToClipboard(): CutSelectionToClipboard {
+ return {
+ action: 'CUT_SELECTION_TO_CLIPBOARD',
+ }
+}
+
+export function copyProperties(): CopyProperties {
+ return {
+ action: 'COPY_PROPERTIES',
+ }
+}
+
+export function pasteProperties(type: 'style' | 'layout'): PasteProperties {
+ return {
+ action: 'PASTE_PROPERTIES',
+ type: type,
+ }
+}
+
+export function openTextEditor(
+ target: ElementPath,
+ mousePosition: WindowPoint | null, // if mousePosition is zero, the whole text will be selected
+): OpenTextEditor {
+ return {
+ action: 'OPEN_TEXT_EDITOR',
+ target: target,
+ mousePosition: mousePosition,
+ }
+}
+
+export function closeTextEditor(): CloseTextEditor {
+ return {
+ action: 'CLOSE_TEXT_EDITOR',
+ }
+}
+
+export function toggleCollapse(target: ElementPath): ToggleCollapse {
+ return {
+ action: 'TOGGLE_COLLAPSE',
+ target: target,
+ }
+}
+
+export function addCollapsedViews(collapsedViews: ElementPath[]): AddCollapsedViews {
+ return {
+ action: 'ADD_COLLAPSED_VIEWS',
+ collapsedViews: collapsedViews,
+ }
+}
+
+export function enableInsertModeForJSXElement(
+ element: JSXElement,
+ uid: string,
+ importsToAdd: Imports,
+ size: Size | null,
+ options?: {
+ textEdit?: boolean
+ wrapInContainer?: InsertionSubjectWrapper
+ },
+): SwitchEditorMode {
+ return switchEditorMode(
+ EditorModes.insertMode([
+ insertionSubject(
+ uid,
+ element,
+ size,
+ importsToAdd,
+ null,
+ options?.textEdit ?? false,
+ options?.wrapInContainer ?? null,
+ ),
+ ]),
+ )
+}
+
+export function addToast(toastContent: Notice): AddToast {
+ return {
+ action: 'ADD_TOAST',
+ toast: toastContent,
+ }
+}
+
+export function removeToast(id: string): RemoveToast {
+ return {
+ action: 'REMOVE_TOAST',
+ id: id,
+ }
+}
+
+export function showToast(toastContent: Notice): AddToast {
+ return addToast(toastContent)
+}
+
+export function setForking(forking: boolean): SetForking {
+ return {
+ action: 'SET_FORKING',
+ forking: forking,
+ }
+}
+
+let selectionControlTimer: any // TODO maybe this should live inside the editormodel
+export function hideAndShowSelectionControls(dispatch: EditorDispatch): void {
+ dispatch([CanvasActions.setSelectionControlsVisibility(false)], 'canvas')
+ if (selectionControlTimer != null) {
+ window.clearTimeout(selectionControlTimer)
+ }
+ selectionControlTimer = window.setTimeout(() => {
+ dispatch([CanvasActions.setSelectionControlsVisibility(true)], 'canvas')
+ selectionControlTimer = null
+ }, 2000)
+}
+
+export function setLeftMenuTab(tab: LeftMenuTab): SetLeftMenuTab {
+ return {
+ action: 'SET_LEFT_MENU_TAB',
+ tab: tab,
+ }
+}
+
+export function setLeftMenuExpanded(expanded: boolean): SetLeftMenuExpanded {
+ return {
+ action: 'SET_LEFT_MENU_EXPANDED',
+ expanded: expanded,
+ }
+}
+
+export function setRightMenuTab(tab: RightMenuTab): SetRightMenuTab {
+ return {
+ action: 'SET_RIGHT_MENU_TAB',
+ tab: tab,
+ }
+}
+
+export function setRightMenuExpanded(expanded: boolean): SetRightMenuExpanded {
+ return {
+ action: 'SET_RIGHT_MENU_EXPANDED',
+ expanded: expanded,
+ }
+}
+
+export function setHighlightedView(target: ElementPath): SetHighlightedViews {
+ return {
+ action: 'SET_HIGHLIGHTED_VIEWS',
+ targets: [target],
+ }
+}
+
+export function setHighlightedViews(targets: ElementPath[]): SetHighlightedViews {
+ return {
+ action: 'SET_HIGHLIGHTED_VIEWS',
+ targets: targets,
+ }
+}
+
+export function setHoveredView(target: ElementPath): SetHoveredViews {
+ return {
+ action: 'SET_HOVERED_VIEWS',
+ targets: [target],
+ }
+}
+
+export function setHoveredViews(targets: ElementPath[]): SetHoveredViews {
+ return {
+ action: 'SET_HOVERED_VIEWS',
+ targets: targets,
+ }
+}
+
+export function clearHighlightedViews(): ClearHighlightedViews {
+ return {
+ action: 'CLEAR_HIGHLIGHTED_VIEWS',
+ }
+}
+
+export function clearHoveredViews(): ClearHoveredViews {
+ return {
+ action: 'CLEAR_HOVERED_VIEWS',
+ }
+}
+
+export function updateKeys(keys: KeysPressed): UpdateKeysPressed {
+ return {
+ action: 'UPDATE_KEYS_PRESSED',
+ keys: keys,
+ }
+}
+
+export function updateMouseButtonsPressed(
+ added: number | null,
+ removed: number | null,
+): UpdateMouseButtonsPressed {
+ return {
+ action: 'UPDATE_MOUSE_BUTTONS_PRESSED',
+ added: added,
+ removed: removed,
+ }
+}
+
+export function hideModal(): HideModal {
+ return {
+ action: 'HIDE_MODAL',
+ }
+}
+
+export function showModal(modal: ModalDialog): ShowModal {
+ return {
+ action: 'SHOW_MODAL',
+ modal: modal,
+ }
+}
+
+export function toggleInterfaceDesignerAdditionalControls(): ToggleInterfaceDesignerAdditionalControls {
+ return {
+ action: 'TOGGLE_INTERFACEDESIGNER_ADDITIONAL_CONTROLS',
+ }
+}
+
+export function saveImageSwitchMode(): SaveImageSwitchMode {
+ return {
+ type: 'SAVE_IMAGE_SWITCH_MODE',
+ }
+}
+
+export function saveImageDoNothing(): SaveImageDoNothing {
+ return {
+ type: 'SAVE_IMAGE_DO_NOTHING',
+ }
+}
+
+export function saveImageReplace(): SaveImageReplace {
+ return {
+ type: 'SAVE_IMAGE_REPLACE',
+ }
+}
+
+export function saveImageInsertWith(
+ parentPath: InsertionPath | null,
+ frame: CanvasRectangle,
+ multiplier: number,
+): SaveImageInsertWith {
+ return {
+ type: 'SAVE_IMAGE_INSERT_WITH',
+ parentPath: parentPath,
+ frame: frame,
+ multiplier: multiplier,
+ }
+}
+
+export function saveCurrentFile(): SaveCurrentFile {
+ return {
+ action: 'SAVE_CURRENT_FILE',
+ }
+}
+
+export function saveImageDetails(
+ imageSize: Size | null,
+ afterSave: SaveImageSwitchMode | SaveImageDoNothing | SaveImageInsertWith | SaveImageReplace,
+): SaveImageDetails {
+ return {
+ imageSize: imageSize,
+ afterSave: afterSave,
+ }
+}
+
+export function saveAsset(
+ fileName: string,
+ fileType: string,
+ base64: string,
+ hash: number,
+ imageDetails: SaveImageDetails | null,
+ gitBlobSha: string,
+): SaveAsset {
+ return {
+ action: 'SAVE_ASSET',
+ fileName: fileName,
+ fileType: fileType,
+ base64: base64,
+ hash: hash,
+ imageDetails: imageDetails,
+ gitBlobSha: gitBlobSha,
+ }
+}
+
+export function resetPins(target: ElementPath): ResetPins {
+ return {
+ action: 'RESET_PINS',
+ target: target,
+ }
+}
+
+export function unwrapElements(targets: ElementPath[]): UnwrapElements {
+ return {
+ action: 'UNWRAP_ELEMENTS',
+ targets: targets,
+ }
+}
+
+export function wrapInElement(
+ targets: Array,
+ whatToWrapWith: WrapInElementWith,
+): WrapInElement {
+ return {
+ action: 'WRAP_IN_ELEMENT',
+ targets: targets,
+ whatToWrapWith: whatToWrapWith,
+ }
+}
+
+export function setCursorOverlay(cursor: CSSCursor | null): SetCursorOverlay {
+ return {
+ action: 'SET_CURSOR_OVERLAY',
+ cursor: cursor,
+ }
+}
+
+export function setZIndex(target: ElementPath, index: number): SetZIndex {
+ return {
+ action: 'SET_Z_INDEX',
+ target: target,
+ indexPosition: {
+ type: 'absolute',
+ index: index,
+ },
+ }
+}
+
+export function moveSelectedBackward(): MoveSelectedBackward {
+ return {
+ action: 'MOVE_SELECTED_BACKWARD',
+ }
+}
+
+export function moveSelectedToBack(): MoveSelectedToBack {
+ return {
+ action: 'MOVE_SELECTED_TO_BACK',
+ }
+}
+
+export function moveSelectedForward(): MoveSelectedForward {
+ return {
+ action: 'MOVE_SELECTED_FORWARD',
+ }
+}
+
+export function moveSelectedToFront(): MoveSelectedToFront {
+ return {
+ action: 'MOVE_SELECTED_TO_FRONT',
+ }
+}
+
+export function updateFrameDimensions(
+ element: ElementPath,
+ width: number,
+ height: number,
+): UpdateFrameDimensions {
+ return {
+ action: 'UPDATE_FRAME_DIMENSIONS',
+ element: element,
+ width: width,
+ height: height,
+ }
+}
+
+export function setNavigatorRenamingTarget(target: ElementPath | null): SetNavigatorRenamingTarget {
+ return {
+ action: 'SET_NAVIGATOR_RENAMING_TARGET',
+ target: target,
+ }
+}
+
+export function redrawOldCanvasControls(): RedrawOldCanvasControls {
+ return {
+ action: 'REDRAW_OLD_CANVAS_CONTROLS',
+ }
+}
+
+export function setStoredFontSettings(fontSettings: FontSettings): SetStoredFontSettings {
+ return {
+ action: 'SET_STORED_FONT_SETTINGS',
+ fontSettings: fontSettings,
+ }
+}
+
+export function setProjectID(id: string): SetProjectID {
+ return {
+ action: 'SET_PROJECT_ID',
+ id: id,
+ }
+}
+
+export function setForkedFromProjectID(id: string | null): SetForkedFromProjectID {
+ return {
+ action: 'SET_FORKED_FROM_PROJECT_ID',
+ id: id,
+ }
+}
+
+export function atomic(actions: Array): Atomic {
+ return {
+ action: 'ATOMIC',
+ actions: actions,
+ }
+}
+
+export function selectAllSiblings(): SelectAllSiblings {
+ return {
+ action: 'SELECT_ALL_SIBLINGS',
+ }
+}
+
+export function undo(): Undo {
+ return {
+ action: 'UNDO',
+ }
+}
+
+export function redo(): Redo {
+ return {
+ action: 'REDO',
+ }
+}
+
+export function updateCodeResultCache(
+ codeResultCache: CodeResultCache,
+ buildType: BuildType,
+): UpdateCodeResultCache {
+ return {
+ action: 'UPDATE_CODE_RESULT_CACHE',
+ codeResultCache: codeResultCache,
+ buildType: buildType,
+ }
+}
+
+export function setCodeEditorVisibility(value: boolean): SetCodeEditorVisibility {
+ return {
+ action: 'SET_CODE_EDITOR_VISIBILITY',
+ value: value,
+ }
+}
+
+export function openCodeEditor(): OpenCodeEditor {
+ return {
+ action: 'OPEN_CODE_EDITOR',
+ }
+}
+
+export function setProjectName(projectName: string): SetProjectName {
+ return {
+ action: 'SET_PROJECT_NAME',
+ name: projectName,
+ }
+}
+
+export function setProjectDescription(projectDescription: string): SetProjectDescription {
+ return {
+ action: 'SET_PROJECT_DESCRIPTION',
+ description: projectDescription,
+ }
+}
+
+export function alignSelectedViews(alignment: Alignment): AlignSelectedViews {
+ return {
+ action: 'ALIGN_SELECTED_VIEWS',
+ alignment: alignment,
+ }
+}
+
+export function distributeSelectedViews(distribution: Distribution): DistributeSelectedViews {
+ return {
+ action: 'DISTRIBUTE_SELECTED_VIEWS',
+ distribution: distribution,
+ }
+}
+
+export function showContextMenu(
+ menuName: ElementContextMenuInstance,
+ event: MouseEvent,
+): ShowContextMenu {
+ return {
+ action: 'SHOW_CONTEXT_MENU',
+ menuName: menuName,
+ event: event,
+ }
+}
+
+export function updateFilePath(oldPath: string, newPath: string): UpdateFilePath {
+ return {
+ action: 'UPDATE_FILE_PATH',
+ oldPath: oldPath,
+ newPath: newPath,
+ }
+}
+
+export function updateRemixRoute(
+ oldPath: string,
+ newPath: string,
+ oldRoute: string,
+ newRoute: string,
+): UpdateRemixRoute {
+ return {
+ action: 'UPDATE_REMIX_ROUTE',
+ oldPath: oldPath,
+ newPath: newPath,
+ oldRoute: oldRoute,
+ newRoute: newRoute,
+ }
+}
+
+export function deleteFile(filename: string): DeleteFile {
+ return {
+ action: 'DELETE_FILE',
+ filename: filename,
+ }
+}
+
+export function deleteFileFromCollaboration(filename: string): DeleteFileFromCollaboration {
+ return {
+ action: 'DELETE_FILE_FROM_COLLABORATION',
+ filename: filename,
+ }
+}
+
+export function addFolder(parentPath: string, fileName: string): AddFolder {
+ return {
+ action: 'ADD_FOLDER',
+ parentPath: parentPath,
+ fileName: fileName,
+ }
+}
+
+export function openCodeEditorFile(
+ filename: string,
+ forceShowCodeEditor: boolean,
+ bounds: Bounds | null = null,
+): OpenCodeEditorFile {
+ return {
+ action: 'OPEN_CODE_EDITOR_FILE',
+ filename: filename,
+ forceShowCodeEditor: forceShowCodeEditor,
+ bounds: bounds,
+ }
+}
+
+export function closeDesignerFile(filename: string): CloseDesignerFile {
+ return {
+ action: 'CLOSE_DESIGNER_FILE',
+ filename: filename,
+ }
+}
+
+export function updateFile(
+ filePath: string,
+ file: ProjectFile,
+ addIfNotInFiles: boolean,
+): UpdateFile {
+ return {
+ action: 'UPDATE_FILE',
+ filePath: filePath,
+ file: file,
+ addIfNotInFiles: addIfNotInFiles,
+ fromCollaboration: false,
+ }
+}
+
+export function updateFileFromCollaboration(
+ filePath: string,
+ file: ProjectFile,
+ addIfNotInFiles: boolean,
+): UpdateFile {
+ return {
+ action: 'UPDATE_FILE',
+ filePath: filePath,
+ file: file,
+ addIfNotInFiles: addIfNotInFiles,
+ fromCollaboration: true,
+ }
+}
+
+export function updateProjectContents(contents: ProjectContentTreeRoot): UpdateProjectContents {
+ return {
+ action: 'UPDATE_PROJECT_CONTENTS',
+ contents: contents,
+ }
+}
+
+export function updateBranchContents(
+ contents: ProjectContentTreeRoot | null,
+): UpdateBranchContents {
+ return {
+ action: 'UPDATE_BRANCH_CONTENTS',
+ contents: contents,
+ }
+}
+
+export function updateGithubSettings(
+ settings: Partial,
+): UpdateGithubSettings {
+ return {
+ action: 'UPDATE_GITHUB_SETTINGS',
+ settings: settings,
+ }
+}
+
+export function updateGithubData(data: Partial): UpdateGithubData {
+ return {
+ action: 'UPDATE_GITHUB_DATA',
+ data: data,
+ }
+}
+
+export function removeFileConflict(path: string): RemoveFileConflict {
+ return {
+ action: 'REMOVE_FILE_CONFLICT',
+ path: path,
+ }
+}
+
+export function workerCodeAndParsedUpdate(
+ filePath: string,
+ code: string,
+ parsed: ParsedTextFile,
+ versionNumber: number,
+): WorkerCodeAndParsedUpdate {
+ return {
+ type: 'WORKER_CODE_AND_PARSED_UPDATE',
+ filePath: filePath,
+ code: code,
+ parsed: parsed,
+ versionNumber: versionNumber,
+ }
+}
+
+export function workerParsedUpdate(
+ filePath: string,
+ parsed: ParsedTextFile,
+ versionNumber: number,
+): WorkerParsedUpdate {
+ return {
+ type: 'WORKER_PARSED_UPDATE',
+ filePath: filePath,
+ parsed: parsed,
+ versionNumber: versionNumber,
+ }
+}
+
+export function updateFromWorker(
+ updates: Array,
+): UpdateFromWorker {
+ return {
+ action: 'UPDATE_FROM_WORKER',
+ updates: updates,
+ }
+}
+
+export function clearParseOrPrintInFlight(): ClearParseOrPrintInFlight {
+ return {
+ action: 'CLEAR_PARSE_OR_PRINT_IN_FLIGHT',
+ }
+}
+
+export function clearImageFileBlob(uiFilePath: string, elementID: string): ClearImageFileBlob {
+ return {
+ action: 'CLEAR_IMAGE_FILE_BLOB',
+ uiFilePath: uiFilePath,
+ elementID: elementID,
+ }
+}
+
+export function addTextFile(parentPath: string, fileName: string): AddTextFile {
+ return {
+ action: 'ADD_TEXT_FILE',
+ fileName: fileName,
+ parentPath: parentPath,
+ }
+}
+
+export function addNewPage(
+ parentPath: string,
+ template: PageTemplate,
+ newPageName: string,
+): AddNewPage {
+ return {
+ action: 'ADD_NEW_PAGE',
+ template: template,
+ parentPath: parentPath,
+ newPageName: newPageName,
+ }
+}
+
+export function addNewFeaturedRoute(featuredRoute: string): AddNewFeaturedRoute {
+ return {
+ action: 'ADD_NEW_FEATURED_ROUTE',
+ featuredRoute: featuredRoute,
+ }
+}
+
+export function removeFeaturedRoute(routeToRemove: string): RemoveFeaturedRoute {
+ return {
+ action: 'REMOVE_FEATURED_ROUTE',
+ routeToRemove: routeToRemove,
+ }
+}
+
+export function setMainUIFile(uiFile: string): SetMainUIFile {
+ return {
+ action: 'SET_MAIN_UI_FILE',
+ uiFile: uiFile,
+ }
+}
+
+export function saveDOMReport(
+ elementMetadata: ElementInstanceMetadataMap,
+ cachedPaths: Array,
+ invalidatedPaths: Array,
+): SaveDOMReport {
+ return {
+ action: 'SAVE_DOM_REPORT',
+ elementMetadata: elementMetadata,
+ cachedPaths: cachedPaths,
+ invalidatedPaths: invalidatedPaths,
+ }
+}
+
+export function updateMetadataInEditorState(
+ newFinalMetadata: ElementInstanceMetadataMap,
+ tree: ElementPathTrees,
+): UpdateMetadataInEditorState {
+ return {
+ action: 'UPDATE_METADATA_IN_EDITOR_STATE',
+ newFinalMetadata: newFinalMetadata,
+ tree: tree,
+ }
+}
+
+export function runDOMWalker(restrictToElements: Array | null): RunDOMWalker {
+ return {
+ action: 'RUN_DOM_WALKER',
+ restrictToElements: restrictToElements,
+ }
+}
+
+/** WARNING: you probably don't want to use setProp, instead you should use a domain-specific action! */
+export function setProp_UNSAFE(
+ target: ElementPath,
+ propertyPath: PropertyPath,
+ value: JSExpression,
+ importsToAdd: Imports = {},
+): SetProp {
+ return {
+ action: 'SET_PROP',
+ target: target,
+ propertyPath: propertyPath,
+ value: value,
+ importsToAdd: importsToAdd,
+ }
+}
+
+export function setPropTransient(
+ target: ElementPath,
+ propertyPath: PropertyPath,
+ value: JSExpression,
+): SetPropTransient {
+ return {
+ action: 'SET_PROP_TRANSIENT',
+ target: target,
+ propertyPath: propertyPath,
+ value: value,
+ }
+}
+
+export function clearTransientProps(): ClearTransientProps {
+ return {
+ action: 'CLEAR_TRANSIENT_PROPS',
+ }
+}
+
+export function renamePropKey(
+ target: ElementPath,
+ cssTargetPath: CSSTarget,
+ value: Array,
+): RenameStyleSelector {
+ return {
+ action: 'RENAME_PROP_KEY',
+ target,
+ cssTargetPath,
+ value,
+ }
+}
+
+export function setCodeEditorBuildErrors(buildErrors: ErrorMessages): SetCodeEditorBuildErrors {
+ return {
+ action: 'SET_CODE_EDITOR_BUILD_ERRORS',
+ buildErrors: buildErrors,
+ }
+}
+
+export function setCodeEditorLintErrors(lintErrors: ErrorMessages): SetCodeEditorLintErrors {
+ return {
+ action: 'SET_CODE_EDITOR_LINT_ERRORS',
+ lintErrors: lintErrors,
+ }
+}
+
+export function setCodeEditorComponentDescriptorErrors(
+ componentDescriptorErrors: ErrorMessages,
+): SetCodeEditorComponentDescriptorErrors {
+ return {
+ action: 'SET_CODE_EDITOR_COMPONENT_DESCRIPTOR_ERRORS',
+ componentDescriptorErrors: componentDescriptorErrors,
+ }
+}
+
+export function setFilebrowserRenamingTarget(
+ filename: string | null,
+): SetFilebrowserRenamingTarget {
+ return {
+ action: 'SET_FILEBROWSER_RENAMING_TARGET',
+ filename: filename,
+ }
+}
+
+export function toggleProperty(
+ target: ElementPath,
+ togglePropValue: (element: JSXElement) => JSXElement,
+): ToggleProperty {
+ return {
+ action: 'TOGGLE_PROPERTY',
+ target: target,
+ togglePropValue: togglePropValue,
+ }
+}
+
+export function insertImageIntoUI(imagePath: string): InsertImageIntoUI {
+ return {
+ action: 'INSERT_IMAGE_INTO_UI',
+ imagePath: imagePath,
+ }
+}
+
+export function updateJSXElementName(
+ target: ElementPath,
+ elementName: { type: 'JSX_ELEMENT'; name: JSXElementName } | { type: 'JSX_FRAGMENT' },
+ importsToAdd: Imports,
+): UpdateJSXElementName {
+ return {
+ action: 'UPDATE_JSX_ELEMENT_NAME',
+ target: target,
+ elementName: elementName,
+ importsToAdd: importsToAdd,
+ }
+}
+
+export function addImports(importsToAdd: Imports, target: ElementPath): AddImports {
+ return {
+ action: 'ADD_IMPORTS',
+ target: target,
+ importsToAdd: importsToAdd,
+ }
+}
+
+export function setAspectRatioLock(target: ElementPath, locked: boolean): SetAspectRatioLock {
+ return {
+ action: 'SET_ASPECT_RATIO_LOCK',
+ target: target,
+ locked: locked,
+ }
+}
+
+export function toggleCanvasIsLive(): ToggleCanvasIsLive {
+ return {
+ action: 'TOGGLE_CANVAS_IS_LIVE',
+ }
+}
+
+export function setSafeMode(value: boolean): SetSafeMode {
+ return {
+ action: 'SET_SAFE_MODE',
+ value: value,
+ }
+}
+
+export function setSaveError(value: boolean): SetSaveError {
+ return {
+ action: 'SET_SAVE_ERROR',
+ value: value,
+ }
+}
+
+export function insertDroppedImage(
+ image: ImageFile,
+ path: string,
+ position: CanvasPoint,
+): InsertDroppedImage {
+ return {
+ action: 'INSERT_DROPPED_IMAGE',
+ image: image,
+ path: path,
+ position: position,
+ }
+}
+
+export function removeFromNodeModulesContents(
+ modulesToRemove: Array,
+): RemoveFromNodeModulesContents {
+ return {
+ action: 'REMOVE_FROM_NODE_MODULES_CONTENTS',
+ modulesToRemove: modulesToRemove,
+ }
+}
+
+export function updateNodeModulesContents(contentsToAdd: NodeModules): UpdateNodeModulesContents {
+ return {
+ action: 'UPDATE_NODE_MODULES_CONTENTS',
+ contentsToAdd: contentsToAdd,
+ }
+}
+
+export function updatePackageJson(dependencies: Array): UpdatePackageJson {
+ return {
+ action: 'UPDATE_PACKAGE_JSON',
+ dependencies: dependencies,
+ }
+}
+
+export function startCheckpointTimer(): StartCheckpointTimer {
+ return {
+ action: 'START_CHECKPOINT_TIMER',
+ }
+}
+
+export function finishCheckpointTimer(): FinishCheckpointTimer {
+ return {
+ action: 'FINISH_CHECKPOINT_TIMER',
+ }
+}
+
+export function addMissingDimensions(
+ target: ElementPath,
+ existingSize: CanvasRectangle,
+): AddMissingDimensions {
+ return {
+ action: 'ADD_MISSING_DIMENSIONS',
+ existingSize: existingSize,
+ target: target,
+ }
+}
+
+export function setPackageStatus(packageName: string, status: PackageStatus): SetPackageStatus {
+ return {
+ action: 'SET_PACKAGE_STATUS',
+ packageName: packageName,
+ status: status,
+ }
+}
+
+export function setShortcut(shortcutName: string, newKey: Key): SetShortcut {
+ return {
+ action: 'SET_SHORTCUT',
+ shortcutName: shortcutName,
+ newKey: newKey,
+ }
+}
+
+export function updatePropertyControlsInfo(
+ propertyControlsInfo: PropertyControlsInfo,
+): UpdatePropertyControlsInfo {
+ return {
+ action: 'UPDATE_PROPERTY_CONTROLS_INFO',
+ propertyControlsInfo: propertyControlsInfo,
+ }
+}
+
+export function updateText(target: ElementPath, text: string, textProp: TextProp): UpdateText {
+ return {
+ action: 'UPDATE_TEXT',
+ target: target,
+ text: text,
+ textProp: textProp,
+ }
+}
+
+export function truncateHistory(): TruncateHistory {
+ return {
+ action: 'TRUNCATE_HISTORY',
+ }
+}
+
+export function setFocusedElement(
+ focusedElementElementPath: ElementPath | null,
+): SetFocusedElement {
+ return {
+ action: 'SET_FOCUSED_ELEMENT',
+ focusedElementPath: focusedElementElementPath,
+ }
+}
+
+export function scrollToElement(
+ focusedElementElementPath: ElementPath,
+ behaviour: ScrollToElementBehaviour,
+): ScrollToElement {
+ return {
+ action: 'SCROLL_TO_ELEMENT',
+ target: focusedElementElementPath,
+ behaviour: behaviour,
+ }
+}
+
+export function scrollToPosition(
+ target: CanvasRectangle,
+ behaviour: ScrollToElementBehaviour,
+): ScrollToPosition {
+ return {
+ action: 'SCROLL_TO_POSITION',
+ target: target,
+ behaviour: behaviour,
+ }
+}
+
+export function setScrollAnimation(value: boolean): SetScrollAnimation {
+ return {
+ action: 'SET_SCROLL_ANIMATION',
+ value: value,
+ }
+}
+
+export function setFollowSelectionEnabled(value: boolean): SetFollowSelectionEnabled {
+ return {
+ action: 'SET_FOLLOW_SELECTION_ENABLED',
+ value: value,
+ }
+}
+
+export function setLoginState(loginState: LoginState): SetLoginState {
+ return {
+ action: 'SET_LOGIN_STATE',
+ loginState: loginState,
+ }
+}
+
+export function setGithubState(githubState: Partial): SetGithubState {
+ return {
+ action: 'SET_GITHUB_STATE',
+ githubState: githubState,
+ }
+}
+
+export function setUserConfiguration(userConfiguration: UserConfiguration): SetUserConfiguration {
+ return {
+ action: 'SET_USER_CONFIGURATION',
+ userConfiguration: userConfiguration,
+ }
+}
+
+export type GithubOperationType = 'add' | 'remove'
+
+export function updateGithubOperations(
+ operation: GithubOperation,
+ type: GithubOperationType,
+): UpdateGithubOperations {
+ return {
+ action: 'UPDATE_GITHUB_OPERATIONS',
+ operation: operation,
+ type: type,
+ }
+}
+
+export function setRefreshingDependencies(value: boolean): SetRefreshingDependencies {
+ return {
+ action: 'SET_REFRESHING_DEPENDENCIES',
+ value: value,
+ }
+}
+
+export function resetCanvas(): ResetCanvas {
+ return {
+ action: 'RESET_CANVAS',
+ }
+}
+
+export function updateImportOperations(
+ operations: ImportOperation[],
+ type: ImportOperationAction,
+): UpdateImportOperations {
+ return {
+ action: 'UPDATE_IMPORT_OPERATIONS',
+ operations: operations,
+ type: type,
+ }
+}
+
+export function updateImportStatus(importStatus: ImportStatus): UpdateImportStatus {
+ return {
+ action: 'UPDATE_IMPORT_STATUS',
+ importStatus: importStatus,
+ }
+}
+
+export function updateProjectRequirements(
+ requirements: Partial,
+): UpdateProjectRequirements {
+ return {
+ action: 'UPDATE_PROJECT_REQUIREMENTS',
+ requirements: requirements,
+ }
+}
+
+export function setImportWizardOpen(open: boolean): SetImportWizardOpen {
+ return {
+ action: 'SET_IMPORT_WIZARD_OPEN',
+ open: open,
+ }
+}
+
+export function setFilebrowserDropTarget(target: string | null): SetFilebrowserDropTarget {
+ return {
+ action: 'SET_FILEBROWSER_DROPTARGET',
+ target: target,
+ }
+}
+
+export function setCurrentTheme(theme: ThemeSetting): SetCurrentTheme {
+ return {
+ action: 'SET_CURRENT_THEME',
+ theme: theme,
+ }
+}
+
+export function focusClassNameInput(): FocusClassNameInput {
+ return {
+ action: 'FOCUS_CLASS_NAME_INPUT',
+ }
+}
+
+export function focusFormulaBar(): FocusFormulaBar {
+ return {
+ action: 'FOCUS_FORMULA_BAR',
+ }
+}
+
+export function updateFormulaBarMode(value: 'css' | 'content'): UpdateFormulaBarMode {
+ return {
+ action: 'UPDATE_FORMULA_BAR_MODE',
+ value: value,
+ }
+}
+
+export function insertInsertable(
+ insertionPath: InsertionPath | null,
+ toInsert: InsertableComponent,
+ styleProps: StylePropOption,
+ indexPosition: IndexPosition | null,
+): InsertInsertable {
+ return {
+ action: 'INSERT_INSERTABLE',
+ insertionPath: insertionPath,
+ toInsert: toInsert,
+ styleProps: styleProps,
+ indexPosition: indexPosition,
+ }
+}
+
+export function addTailwindConfig(): AddTailwindConfig {
+ return {
+ action: 'ADD_TAILWIND_CONFIG',
+ }
+}
+
+export function decrementResizeOptionsSelectedIndex(): DecrementResizeOptionsSelectedIndex {
+ return {
+ action: 'DECREMENT_RESIZE_OPTIONS_SELECTED_INDEX',
+ }
+}
+
+export function incrementResizeOptionsSelectedIndex(): IncrementResizeOptionsSelectedIndex {
+ return {
+ action: 'INCREMENT_RESIZE_OPTIONS_SELECTED_INDEX',
+ }
+}
+
+export function setResizeOptionsTargetOptions(
+ propertyTargetOptions: Array,
+ index: number | null,
+): SetResizeOptionsTargetOptions {
+ return {
+ action: 'SET_RESIZE_OPTIONS_TARGET_OPTIONS',
+ propertyTargetOptions: propertyTargetOptions,
+ index: index,
+ }
+}
+
+export function forceParseFile(filePath: string): ForceParseFile {
+ return {
+ action: 'FORCE_PARSE_FILE',
+ filePath: filePath,
+ }
+}
+
+export function runEscapeHatch(
+ targets: Array,
+ setHuggingParentToFixed: SetHuggingParentToFixed,
+): RunEscapeHatch {
+ return {
+ action: 'RUN_ESCAPE_HATCH',
+ targets: targets,
+ setHuggingParentToFixed: setHuggingParentToFixed,
+ }
+}
+
+export function toggleSelectionLock(
+ targets: Array,
+ newValue: SelectionLocked,
+): ToggleSelectionLock {
+ return {
+ action: 'TOGGLE_SELECTION_LOCK',
+ targets: targets,
+ newValue: newValue,
+ }
+}
+
+export function updateAgainstGithub(
+ branchLatestContent: ProjectContentTreeRoot,
+ specificCommitContent: ProjectContentTreeRoot,
+ latestCommit: string,
+): UpdateAgainstGithub {
+ return {
+ action: 'UPDATE_AGAINST_GITHUB',
+ branchLatestContent: branchLatestContent,
+ specificCommitContent: specificCommitContent,
+ latestCommit: latestCommit,
+ }
+}
+
+export function setImageDragSessionState(
+ imageDragSessionState: ImageDragSessionState,
+): SetDragSessionState {
+ return {
+ action: 'SET_IMAGE_DRAG_SESSION_STATE',
+ imageDragSessionState: imageDragSessionState,
+ }
+}
+
+export function applyCommandsAction(commands: CanvasCommand[]): ApplyCommandsAction {
+ return {
+ action: 'APPLY_COMMANDS',
+ commands: commands,
+ }
+}
+
+export function updateColorSwatches(colorSwatches: Array): UpdateColorSwatches {
+ return {
+ action: 'UPDATE_COLOR_SWATCHES',
+ colorSwatches: colorSwatches,
+ }
+}
+
+export function setConditionalOverriddenCondition(
+ target: ElementPath,
+ condition: boolean | null,
+): SetConditionalOverriddenCondition {
+ return {
+ action: 'SET_CONDITIONAL_OVERRIDDEN_CONDITION',
+ target: target,
+ condition: condition,
+ }
+}
+
+export function setMapCountOverride(
+ target: ElementPath,
+ value: number | null,
+): SetMapCountOverride {
+ return {
+ action: 'SET_MAP_COUNT_OVERRIDE',
+ target: target,
+ value: value,
+ }
+}
+
+export function updateConditionalExpression(
+ target: ElementPath,
+ expression: string,
+): UpdateConditionalExpression {
+ return {
+ action: 'UPDATE_CONIDTIONAL_EXPRESSION',
+ target: target,
+ expression: expression,
+ }
+}
+
+export function switchConditionalBranches(target: ElementPath): SwitchConditionalBranches {
+ return {
+ action: 'SWITCH_CONDITIONAL_BRANCHES',
+ target: target,
+ }
+}
+
+export function executePostActionMenuChoice(choice: PostActionChoice): ExecutePostActionMenuChoice {
+ return {
+ action: 'EXECUTE_POST_ACTION_MENU_CHOICE',
+ choice: choice,
+ }
+}
+
+export function startPostActionSession(data: PostActionMenuData): StartPostActionSession {
+ return {
+ action: 'START_POST_ACTION_SESSION',
+ data: data,
+ }
+}
+
+export function clearPostActionData(): ClearPostActionSession {
+ return {
+ action: 'CLEAR_POST_ACTION_SESSION',
+ }
+}
+
+export function updateProjectServerState(
+ projectServerState: Partial,
+): UpdateProjectServerState {
+ return {
+ action: 'UPDATE_PROJECT_SERVER_STATE',
+ serverState: projectServerState,
+ }
+}
+
+export function updateTopLevelElementsFromCollaborationUpdate(
+ fullPath: string,
+ topLevelElements: Array,
+): UpdateTopLevelElementsFromCollaborationUpdate {
+ return {
+ action: 'UPDATE_TOP_LEVEL_ELEMENTS_FROM_COLLABORATION_UPDATE',
+ fullPath: fullPath,
+ topLevelElements: topLevelElements,
+ }
+}
+
+export function updateExportsDetailFromCollaborationUpdate(
+ fullPath: string,
+ exportsDetail: Array,
+): UpdateExportsDetailFromCollaborationUpdate {
+ return {
+ action: 'UPDATE_EXPORTS_DETAIL_FROM_COLLABORATION_UPDATE',
+ fullPath: fullPath,
+ exportsDetail: exportsDetail,
+ }
+}
+
+export function updateImportsFromCollaborationUpdate(
+ fullPath: string,
+ imports: Imports,
+): UpdateImportsFromCollaborationUpdate {
+ return {
+ action: 'UPDATE_IMPORTS_FROM_COLLABORATION_UPDATE',
+ fullPath: fullPath,
+ imports: imports,
+ }
+}
+
+export function updateCodeFromCollaborationUpdate(
+ fullPath: string,
+ code: string,
+): UpdateCodeFromCollaborationUpdate {
+ return {
+ action: 'UPDATE_CODE_FROM_COLLABORATION_UPDATE',
+ fullPath: fullPath,
+ code: code,
+ }
+}
+
+export function setCommentFilterMode(commentFilterMode: CommentFilterMode): SetCommentFilterMode {
+ return {
+ action: 'SET_COMMENT_FILTER_MODE',
+ commentFilterMode: commentFilterMode,
+ }
+}
+
+export function setCollaborators(collaborators: Collaborator[]): SetCollaborators {
+ return {
+ action: 'SET_COLLABORATORS',
+ collaborators: collaborators,
+ }
+}
+
+export function extractPropertyControlsFromDescriptorFiles(
+ paths: string[],
+): ExtractPropertyControlsFromDescriptorFiles {
+ return {
+ action: 'EXTRACT_PROPERTY_CONTROLS_FROM_DESCRIPTOR_FILES',
+ paths: paths,
+ }
+}
+
+export function setSharingDialogOpen(open: boolean): SetSharingDialogOpen {
+ return {
+ action: 'SET_SHARING_DIALOG_OPEN',
+ open: open,
+ }
+}
+
+export function resetOnlineState(): ResetOnlineState {
+ return {
+ action: 'RESET_ONLINE_STATE',
+ }
+}
+
+export function increaseOnlineStateFailureCount(): IncreaseOnlineStateFailureCount {
+ return {
+ action: 'INCREASE_ONLINE_STATE_FAILURE_COUNT',
+ }
+}
+
+export function setErrorBoundaryHandling(
+ errorBoundaryHandling: ErrorBoundaryHandling,
+): SetErrorBoundaryHandling {
+ return {
+ action: 'SET_ERROR_BOUNDARY_HANDLING',
+ errorBoundaryHandling: errorBoundaryHandling,
+ }
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/action-utils.ts b/nexus-builder/packages/core/src/components/editor/actions/action-utils.ts
new file mode 100644
index 000000000000..ae7d431c9904
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/action-utils.ts
@@ -0,0 +1,367 @@
+import { safeIndex } from '../../../core/shared/array-utils'
+import type { EditorAction } from '../action-types'
+import { isFromVSCodeAction } from './actions-from-vscode'
+
+export function isTransientAction(action: EditorAction): boolean {
+ switch (action.action) {
+ case 'CLEAR_INTERACTION_SESSION':
+ return !action.applyChanges
+
+ case 'MERGE_WITH_PREV_UNDO':
+ return action.actions.every(isTransientAction)
+
+ case 'SHOW_DROP_TARGET_HINT':
+ case 'HIDE_DROP_TARGET_HINT':
+ case 'CLOSE_POPUP':
+ case 'OPEN_POPUP':
+ case 'ZOOM':
+ case 'ZOOMUI':
+ case 'SHOW_CONTEXT_MENU':
+ case 'UPDATE_KEYS_PRESSED':
+ case 'UPDATE_MOUSE_BUTTONS_PRESSED':
+ case 'SET_SELECTION_CONTROLS_VISIBILITY':
+ case 'SCROLL_CANVAS':
+ case 'POSITION_CANVAS':
+ case 'SET_FOCUS':
+ case 'UNDO':
+ case 'REDO':
+ case 'CLEAR_SELECTION':
+ case 'CANVAS_ACTION':
+ case 'TRANSIENT_ACTIONS':
+ case 'UPDATE_EDITOR_MODE':
+ case 'SWITCH_EDITOR_MODE':
+ case 'INSERT_IMAGE_INTO_UI':
+ case 'SET_PANEL_VISIBILITY':
+ case 'TOGGLE_FOCUSED_OMNIBOX_TAB':
+ case 'TOGGLE_PANE':
+ case 'COPY_SELECTION_TO_CLIPBOARD':
+ case 'COPY_PROPERTIES':
+ case 'OPEN_TEXT_EDITOR':
+ case 'CLOSE_TEXT_EDITOR':
+ case 'SET_LEFT_MENU_TAB':
+ case 'SET_LEFT_MENU_EXPANDED':
+ case 'SET_RIGHT_MENU_TAB':
+ case 'SET_RIGHT_MENU_EXPANDED':
+ case 'TOGGLE_COLLAPSE':
+ case 'ADD_COLLAPSED_VIEWS':
+ case 'ADD_TOAST':
+ case 'REMOVE_TOAST':
+ case 'SET_HIGHLIGHTED_VIEWS':
+ case 'CLEAR_HIGHLIGHTED_VIEWS':
+ case 'SET_HOVERED_VIEWS':
+ case 'CLEAR_HOVERED_VIEWS':
+ case 'HIDE_MODAL':
+ case 'SHOW_MODAL':
+ case 'TOGGLE_INTERFACEDESIGNER_ADDITIONAL_CONTROLS':
+ case 'SET_CURSOR_OVERLAY':
+ case 'SET_NAVIGATOR_RENAMING_TARGET':
+ case 'REDRAW_OLD_CANVAS_CONTROLS':
+ case 'UPDATE_FRAME_DIMENSIONS':
+ case 'SET_STORED_FONT_SETTINGS':
+ case 'SELECT_ALL_SIBLINGS':
+ case 'SET_PROJECT_ID':
+ case 'SET_CODE_EDITOR_VISIBILITY':
+ case 'OPEN_CODE_EDITOR':
+ case 'CLOSE_DESIGNER_FILE':
+ case 'UPDATE_CODE_RESULT_CACHE':
+ case 'SET_CODE_EDITOR_BUILD_ERRORS':
+ case 'SET_CODE_EDITOR_LINT_ERRORS':
+ case 'SET_CODE_EDITOR_COMPONENT_DESCRIPTOR_ERRORS':
+ case 'SAVE_DOM_REPORT':
+ case 'UPDATE_METADATA_IN_EDITOR_STATE':
+ case 'RUN_DOM_WALKER':
+ case 'SET_FILEBROWSER_RENAMING_TARGET':
+ case 'UPDATE_DUPLICATION_STATE':
+ case 'CLEAR_IMAGE_FILE_BLOB':
+ case 'CLEAR_PARSE_OR_PRINT_IN_FLIGHT':
+ case 'UPDATE_FROM_WORKER':
+ case 'SELECT_COMPONENTS':
+ case 'TOGGLE_CANVAS_IS_LIVE':
+ case 'RENAME_PROP_KEY':
+ case 'SET_SAFE_MODE':
+ case 'SET_SAVE_ERROR':
+ case 'REMOVE_FROM_NODE_MODULES_CONTENTS':
+ case 'UPDATE_NODE_MODULES_CONTENTS':
+ case 'START_CHECKPOINT_TIMER':
+ case 'SET_PACKAGE_STATUS':
+ case 'SET_SHORTCUT':
+ case 'UPDATE_PROPERTY_CONTROLS_INFO':
+ case 'SEND_LINTER_REQUEST_MESSAGE':
+ case 'MARK_VSCODE_BRIDGE_READY':
+ case 'SELECT_FROM_FILE_AND_POSITION':
+ case 'SEND_CODE_EDITOR_INITIALISATION':
+ case 'SET_FOCUSED_ELEMENT':
+ case 'SCROLL_TO_ELEMENT':
+ case 'SCROLL_TO_POSITION':
+ case 'SET_SCROLL_ANIMATION':
+ case 'SET_FOLLOW_SELECTION_ENABLED':
+ case 'UPDATE_CONFIG_FROM_VSCODE':
+ case 'SET_LOGIN_STATE':
+ case 'SET_GITHUB_STATE':
+ case 'SET_USER_CONFIGURATION':
+ case 'RESET_CANVAS':
+ case 'SET_FILEBROWSER_DROPTARGET':
+ case 'SET_FORKED_FROM_PROJECT_ID':
+ case 'SET_CURRENT_THEME':
+ case 'FOCUS_CLASS_NAME_INPUT':
+ case 'FOCUS_FORMULA_BAR':
+ case 'UPDATE_FORMULA_BAR_MODE':
+ case 'SET_PROP_TRANSIENT':
+ case 'CLEAR_TRANSIENT_PROPS':
+ case 'DECREMENT_RESIZE_OPTIONS_SELECTED_INDEX':
+ case 'INCREMENT_RESIZE_OPTIONS_SELECTED_INDEX':
+ case 'SET_RESIZE_OPTIONS_TARGET_OPTIONS':
+ case 'OPEN_CODE_EDITOR_FILE':
+ case 'HIDE_VSCODE_LOADING_SCREEN':
+ case 'SET_INDEXED_DB_FAILED':
+ case 'FORCE_PARSE_FILE':
+ case 'CREATE_INTERACTION_SESSION':
+ case 'UPDATE_INTERACTION_SESSION':
+ case 'UPDATE_DRAG_INTERACTION_DATA':
+ case 'SET_USERS_PREFERRED_STRATEGY':
+ case 'TOGGLE_SELECTION_LOCK':
+ case 'UPDATE_GITHUB_OPERATIONS':
+ case 'SET_REFRESHING_DEPENDENCIES':
+ case 'UPDATE_GITHUB_DATA':
+ case 'REMOVE_FILE_CONFLICT':
+ case 'CLEAR_POST_ACTION_SESSION':
+ case 'START_POST_ACTION_SESSION':
+ case 'TRUNCATE_HISTORY':
+ case 'UPDATE_PROJECT_SERVER_STATE':
+ case 'SET_COMMENT_FILTER_MODE':
+ case 'SET_FORKING':
+ case 'SET_COLLABORATORS':
+ case 'EXTRACT_PROPERTY_CONTROLS_FROM_DESCRIPTOR_FILES':
+ case 'SET_SHARING_DIALOG_OPEN':
+ case 'RESET_ONLINE_STATE':
+ case 'INCREASE_ONLINE_STATE_FAILURE_COUNT':
+ case 'SET_ERROR_BOUNDARY_HANDLING':
+ case 'SET_IMPORT_WIZARD_OPEN':
+ case 'UPDATE_IMPORT_OPERATIONS':
+ case 'UPDATE_IMPORT_STATUS':
+ case 'UPDATE_PROJECT_REQUIREMENTS':
+ return true
+
+ case 'TRUE_UP_ELEMENTS':
+ case 'EXECUTE_POST_ACTION_MENU_CHOICE':
+ case 'NEW':
+ case 'LOAD':
+ case 'ATOMIC':
+ case 'DELETE_SELECTED':
+ case 'DELETE_VIEW':
+ case 'UNSET_PROPERTY':
+ case 'INSERT_JSX_ELEMENT':
+ case 'REPLACE_JSX_ELEMENT':
+ case 'INSERT_ATTRIBUTE_OTHER_JAVASCRIPT_INTO_ELEMENT':
+ case 'MOVE_SELECTED_TO_BACK':
+ case 'MOVE_SELECTED_TO_FRONT':
+ case 'MOVE_SELECTED_BACKWARD':
+ case 'MOVE_SELECTED_FORWARD':
+ case 'SET_Z_INDEX':
+ case 'DUPLICATE_SELECTED':
+ case 'DUPLICATE_SPECIFIC_ELEMENTS':
+ case 'RENAME_COMPONENT':
+ case 'PASTE_PROPERTIES':
+ case 'TOGGLE_PROPERTY':
+ case 'deprecated_TOGGLE_ENABLED_PROPERTY':
+ case 'RESET_PINS':
+ case 'WRAP_IN_ELEMENT':
+ case 'UNWRAP_ELEMENTS':
+ case 'SET_CANVAS_FRAMES':
+ case 'SET_PROJECT_NAME':
+ case 'SET_PROJECT_DESCRIPTION':
+ case 'ALIGN_SELECTED_VIEWS':
+ case 'DISTRIBUTE_SELECTED_VIEWS':
+ case 'TOGGLE_HIDDEN':
+ case 'TOGGLE_DATA_CAN_CONDENSE':
+ case 'UPDATE_FILE_PATH':
+ case 'UPDATE_REMIX_ROUTE':
+ case 'ADD_FOLDER':
+ case 'DELETE_FILE':
+ case 'DELETE_FILE_FROM_VSCODE':
+ case 'DELETE_FILE_FROM_COLLABORATION':
+ case 'ADD_TEXT_FILE':
+ case 'ADD_NEW_PAGE':
+ case 'ADD_NEW_FEATURED_ROUTE':
+ case 'REMOVE_FEATURED_ROUTE':
+ case 'UPDATE_FILE':
+ case 'UPDATE_PROJECT_CONTENTS':
+ case 'UPDATE_BRANCH_CONTENTS':
+ case 'UPDATE_GITHUB_SETTINGS':
+ case 'UPDATE_FROM_CODE_EDITOR':
+ case 'SET_MAIN_UI_FILE':
+ case 'SET_PROP':
+ case 'SAVE_CURRENT_FILE':
+ case 'UPDATE_JSX_ELEMENT_NAME':
+ case 'ADD_IMPORTS':
+ case 'SET_ASPECT_RATIO_LOCK':
+ case 'INSERT_DROPPED_IMAGE':
+ case 'UPDATE_PACKAGE_JSON':
+ case 'FINISH_CHECKPOINT_TIMER':
+ case 'ADD_MISSING_DIMENSIONS':
+ case 'UPDATE_TEXT':
+ case 'INSERT_INSERTABLE':
+ case 'ADD_TAILWIND_CONFIG':
+ case 'RUN_ESCAPE_HATCH':
+ case 'SET_IMAGE_DRAG_SESSION_STATE':
+ case 'UPDATE_AGAINST_GITHUB':
+ case 'APPLY_COMMANDS':
+ case 'UPDATE_COLOR_SWATCHES':
+ case 'SET_CONDITIONAL_OVERRIDDEN_CONDITION':
+ case 'SET_MAP_COUNT_OVERRIDE':
+ case 'SWITCH_CONDITIONAL_BRANCHES':
+ case 'UPDATE_CONIDTIONAL_EXPRESSION':
+ case 'CUT_SELECTION_TO_CLIPBOARD':
+ case 'UPDATE_TOP_LEVEL_ELEMENTS_FROM_COLLABORATION_UPDATE':
+ case 'UPDATE_EXPORTS_DETAIL_FROM_COLLABORATION_UPDATE':
+ case 'UPDATE_IMPORTS_FROM_COLLABORATION_UPDATE':
+ case 'UPDATE_CODE_FROM_COLLABORATION_UPDATE':
+ case 'REPLACE_MAPPED_ELEMENT':
+ case 'REPLACE_ELEMENT_IN_SCOPE':
+ return false
+ case 'SAVE_ASSET':
+ return (
+ action.imageDetails?.afterSave.type === 'SAVE_IMAGE_DO_NOTHING' ||
+ action.imageDetails?.afterSave.type === 'SAVE_IMAGE_SWITCH_MODE'
+ )
+ default:
+ const _exhaustiveCheck: never = action
+ throw new Error(`Unknown action ${JSON.stringify(action)}`)
+ }
+}
+
+export function isUndoOrRedo(action: EditorAction): boolean {
+ switch (action.action) {
+ case 'TRANSIENT_ACTIONS':
+ return action.transientActions.some(isUndoOrRedo)
+ case 'ATOMIC':
+ case 'MERGE_WITH_PREV_UNDO':
+ return action.actions.some(isUndoOrRedo)
+ case 'UNDO':
+ case 'REDO':
+ return true
+ default:
+ return false
+ }
+}
+
+export function isParsedModelUpdate(action: EditorAction): boolean {
+ switch (action.action) {
+ case 'TRANSIENT_ACTIONS':
+ return action.transientActions.some(isParsedModelUpdate)
+ case 'ATOMIC':
+ case 'MERGE_WITH_PREV_UNDO':
+ return action.actions.some(isParsedModelUpdate)
+ case 'UPDATE_FROM_WORKER':
+ return action.updates.some((update) => update.type === 'WORKER_PARSED_UPDATE')
+ default:
+ return false
+ }
+}
+
+export function isFromVSCode(action: EditorAction): boolean {
+ switch (action.action) {
+ case 'TRANSIENT_ACTIONS':
+ return action.transientActions.some(isFromVSCode)
+ case 'ATOMIC':
+ case 'MERGE_WITH_PREV_UNDO':
+ return action.actions.some(isFromVSCode)
+ default:
+ return isFromVSCodeAction(action)
+ }
+}
+
+export function isClearInteractionSession(action: EditorAction): boolean {
+ switch (action.action) {
+ case 'TRANSIENT_ACTIONS':
+ return action.transientActions.some(isClearInteractionSession)
+ case 'ATOMIC':
+ case 'MERGE_WITH_PREV_UNDO':
+ return action.actions.some(isClearInteractionSession)
+ case 'CLEAR_INTERACTION_SESSION':
+ return true
+ default:
+ return false
+ }
+}
+
+export function isCreateOrUpdateInteractionSession(action: EditorAction): boolean {
+ switch (action.action) {
+ case 'TRANSIENT_ACTIONS':
+ return action.transientActions.some(isCreateOrUpdateInteractionSession)
+ case 'ATOMIC':
+ case 'MERGE_WITH_PREV_UNDO':
+ return action.actions.some(isCreateOrUpdateInteractionSession)
+ case 'CREATE_INTERACTION_SESSION':
+ case 'UPDATE_INTERACTION_SESSION':
+ return true
+ default:
+ return false
+ }
+}
+
+export function shouldApplyClearInteractionSessionResult(action: EditorAction): boolean {
+ switch (action.action) {
+ case 'TRANSIENT_ACTIONS':
+ return action.transientActions.some(shouldApplyClearInteractionSessionResult)
+ case 'ATOMIC':
+ case 'MERGE_WITH_PREV_UNDO':
+ return action.actions.some(shouldApplyClearInteractionSessionResult)
+ case 'CLEAR_INTERACTION_SESSION':
+ return action.applyChanges
+ default:
+ return false
+ }
+}
+
+export function isWorkerUpdate(action: EditorAction): boolean {
+ return (
+ action.action === 'UPDATE_FROM_WORKER' ||
+ (action.action === 'MERGE_WITH_PREV_UNDO' && checkAnyWorkerUpdates(action.actions))
+ )
+}
+
+export function checkAnyWorkerUpdates(actions: ReadonlyArray): boolean {
+ return actions.some(isWorkerUpdate)
+}
+
+export function onlyActionIsWorkerParsedUpdate(actions: ReadonlyArray): boolean {
+ const firstAction = safeIndex(actions, 0)
+ if (firstAction == null || actions.length != 1) {
+ return false
+ } else {
+ return (
+ (firstAction.action === 'UPDATE_FROM_WORKER' &&
+ firstAction.updates.some((update) => update.type === 'WORKER_PARSED_UPDATE')) ||
+ (firstAction.action === 'MERGE_WITH_PREV_UNDO' &&
+ onlyActionIsWorkerParsedUpdate(firstAction.actions))
+ )
+ }
+}
+
+function simpleStringifyAction(action: EditorAction, indentation: number): string {
+ switch (action.action) {
+ case 'TRANSIENT_ACTIONS':
+ return `TRANSIENT_ACTIONS: ${simpleStringifyActions(
+ action.transientActions,
+ indentation + 1,
+ )}`
+ case 'ATOMIC':
+ return `ATOMIC: ${simpleStringifyActions(action.actions, indentation + 1)}`
+ case 'MERGE_WITH_PREV_UNDO':
+ return `MERGE_WITH_PREV_UNDO: ${simpleStringifyActions(action.actions, indentation + 1)}`
+ default:
+ return action.action
+ }
+}
+
+export function simpleStringifyActions(
+ actions: ReadonlyArray,
+ indentation: number = 1,
+): string {
+ const spacing = ' '.repeat(indentation)
+ const spacingBeforeClose = ' '.repeat(indentation - 1)
+ return `[\n${spacing}${actions
+ .map((a) => simpleStringifyAction(a, indentation))
+ .join(`,\n${spacing}`)}\n${spacingBeforeClose}]`
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/actions-from-vscode.ts b/nexus-builder/packages/core/src/components/editor/actions/actions-from-vscode.ts
new file mode 100644
index 000000000000..17b37e960de4
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/actions-from-vscode.ts
@@ -0,0 +1,157 @@
+import type { UtopiaVSCodeConfig } from 'utopia-vscode-common'
+
+export interface DeleteFileFromVSCode {
+ // Exactly the same as the regular DeleteFile action, but signifies this came from the code editor
+ action: 'DELETE_FILE_FROM_VSCODE'
+ filename: string
+}
+
+export function deleteFileFromVSCode(filename: string): DeleteFileFromVSCode {
+ return {
+ action: 'DELETE_FILE_FROM_VSCODE',
+ filename: filename,
+ }
+}
+
+export interface HideVSCodeLoadingScreen {
+ action: 'HIDE_VSCODE_LOADING_SCREEN'
+}
+
+export function hideVSCodeLoadingScreen(): HideVSCodeLoadingScreen {
+ return {
+ action: 'HIDE_VSCODE_LOADING_SCREEN',
+ }
+}
+
+export interface MarkVSCodeBridgeReady {
+ action: 'MARK_VSCODE_BRIDGE_READY'
+ ready: boolean
+}
+
+export function markVSCodeBridgeReady(ready: boolean): MarkVSCodeBridgeReady {
+ return {
+ action: 'MARK_VSCODE_BRIDGE_READY',
+ ready: ready,
+ }
+}
+
+export interface SelectFromFileAndPosition {
+ action: 'SELECT_FROM_FILE_AND_POSITION'
+ filePath: string
+ line: number
+ column: number
+}
+
+export function selectFromFileAndPosition(
+ filePath: string,
+ line: number,
+ column: number,
+): SelectFromFileAndPosition {
+ return {
+ action: 'SELECT_FROM_FILE_AND_POSITION',
+ filePath: filePath,
+ line: line,
+ column: column,
+ }
+}
+
+export interface SendCodeEditorInitialisation {
+ action: 'SEND_CODE_EDITOR_INITIALISATION'
+}
+
+export function sendCodeEditorInitialisation(): SendCodeEditorInitialisation {
+ return {
+ action: 'SEND_CODE_EDITOR_INITIALISATION',
+ }
+}
+
+export interface SendLinterRequestMessage {
+ action: 'SEND_LINTER_REQUEST_MESSAGE'
+ filePath: string
+ content: string
+}
+
+export function sendLinterRequestMessage(
+ filePath: string,
+ content: string,
+): SendLinterRequestMessage {
+ return {
+ action: 'SEND_LINTER_REQUEST_MESSAGE',
+ filePath: filePath,
+ content: content,
+ }
+}
+
+export interface SetIndexedDBFailed {
+ action: 'SET_INDEXED_DB_FAILED'
+ indexedDBFailed: boolean
+}
+
+export function setIndexedDBFailed(indexedDBFailed: boolean): SetIndexedDBFailed {
+ return {
+ action: 'SET_INDEXED_DB_FAILED',
+ indexedDBFailed: indexedDBFailed,
+ }
+}
+
+export interface UpdateConfigFromVSCode {
+ action: 'UPDATE_CONFIG_FROM_VSCODE'
+ config: UtopiaVSCodeConfig
+}
+
+export function updateConfigFromVSCode(config: UtopiaVSCodeConfig): UpdateConfigFromVSCode {
+ return {
+ action: 'UPDATE_CONFIG_FROM_VSCODE',
+ config: config,
+ }
+}
+
+export interface UpdateFromCodeEditor {
+ action: 'UPDATE_FROM_CODE_EDITOR'
+ filePath: string
+ savedContent: string
+ unsavedContent: string | null
+}
+
+export function updateFromCodeEditor(
+ filePath: string,
+ savedContent: string,
+ unsavedContent: string | null,
+): UpdateFromCodeEditor {
+ return {
+ action: 'UPDATE_FROM_CODE_EDITOR',
+ filePath: filePath,
+ savedContent: savedContent,
+ unsavedContent: unsavedContent,
+ }
+}
+
+export type FromVSCodeAction =
+ | DeleteFileFromVSCode
+ | HideVSCodeLoadingScreen
+ | MarkVSCodeBridgeReady
+ | SelectFromFileAndPosition
+ | SendCodeEditorInitialisation
+ | SendLinterRequestMessage
+ | SetIndexedDBFailed
+ | UpdateConfigFromVSCode
+ | UpdateFromCodeEditor
+
+export function isFromVSCodeAction(
+ action: { action: string } & unknown,
+): action is FromVSCodeAction {
+ switch (action.action) {
+ case 'DELETE_FILE_FROM_VSCODE':
+ case 'HIDE_VSCODE_LOADING_SCREEN':
+ case 'MARK_VSCODE_BRIDGE_READY':
+ case 'SELECT_FROM_FILE_AND_POSITION':
+ case 'SEND_CODE_EDITOR_INITIALISATION':
+ case 'SEND_LINTER_REQUEST_MESSAGE':
+ case 'SET_INDEXED_DB_FAILED':
+ case 'UPDATE_CONFIG_FROM_VSCODE':
+ case 'UPDATE_FROM_CODE_EDITOR':
+ return true
+ default:
+ return false
+ }
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/actions.spec.browser2.tsx b/nexus-builder/packages/core/src/components/editor/actions/actions.spec.browser2.tsx
new file mode 100644
index 000000000000..29d6d0b6c519
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/actions.spec.browser2.tsx
@@ -0,0 +1,7872 @@
+import * as EP from '../../../core/shared/element-path'
+import {
+ BakedInStoryboardUID,
+ BakedInStoryboardVariableName,
+} from '../../../core/model/scene-utils'
+import type { EditorRenderResult } from '../../../components/canvas/ui-jsx.test-utils'
+import {
+ formatTestProjectCode,
+ getPrintedUiJsCode,
+ makeTestProjectCodeWithSnippet,
+ renderTestEditorWithCode,
+ renderTestEditorWithModel,
+ TestAppUID,
+ TestScenePath,
+ TestSceneUID,
+} from '../../../components/canvas/ui-jsx.test-utils'
+import {
+ applyCommandsAction,
+ clearSelection,
+ deleteSelected,
+ deleteView,
+ selectComponents,
+ setLeftMenuTab,
+ truncateHistory,
+ undo,
+ unwrapElements,
+} from './action-creators'
+import type { ElementPath } from '../../../core/shared/project-file-types'
+import type { ElementPaste } from '../action-types'
+import type { InsertionPath } from '../store/insertion-path'
+import {
+ childInsertionPath,
+ conditionalClauseInsertionPath,
+ replaceWithSingleElement,
+ wrapInFragmentAndAppendElements,
+} from '../store/insertion-path'
+import { getElementFromRenderResult } from './actions.test-utils'
+import {
+ expectNoAction,
+ expectSingleUndoNSaves,
+ searchInComponentPicker,
+ selectComponentsForTest,
+ setFeatureForBrowserTestsUseInDescribeBlockOnly,
+} from '../../../utils/utils.test-utils'
+import {
+ firePasteEvent,
+ keyDown,
+ MockClipboardHandlers,
+ mouseClickAtPoint,
+ mouseDoubleClickAtPoint,
+ mouseDragFromPointWithDelta,
+ openContextMenuAndClickOnItem,
+ pressKey,
+} from '../../canvas/event-helpers.test-utils'
+import { cmdModifier, shiftCmdModifier } from '../../../utils/modifiers'
+import {
+ FOR_TESTS_setNextGeneratedUid,
+ FOR_TESTS_setNextGeneratedUids,
+} from '../../../core/model/element-template-utils.test-utils'
+import {
+ createModifiedProject,
+ createTestProjectWithMultipleFiles,
+} from '../../../sample-projects/sample-project-utils.test-utils'
+import {
+ LeftMenuTab,
+ navigatorEntryToKey,
+ PlaygroundFilePath,
+ StoryboardFilePath,
+} from '../store/editor-state'
+import { CanvasControlsContainerID } from '../../canvas/controls/new-canvas-controls'
+import { windowPoint } from '../../../core/shared/math-utils'
+import { assertNever } from '../../../core/shared/utils'
+import { MetadataUtils } from '../../../core/model/element-metadata-utils'
+import { maybeConditionalExpression } from '../../../core/model/conditionals'
+import {
+ PropsPreservedPasteHerePostActionChoiceId,
+ PropsPreservedPastePostActionChoiceId,
+ PropsReplacedPastePostActionChoiceId,
+ PropsReplacedPasteHerePostActionChoiceId,
+} from '../../canvas/canvas-strategies/post-action-options/post-action-paste'
+import { getDomRectCenter } from '../../../core/shared/dom-utils'
+import { FloatingPostActionMenuTestId } from '../../canvas/controls/select-mode/post-action-menu'
+import { safeIndex } from '../../../core/shared/array-utils'
+import { updateSelectedViews } from '../../canvas/commands/update-selected-views-command'
+import { getNavigatorTargetsFromEditorState } from '../../navigator/navigator-utils'
+
+async function deleteFromScene(
+ inputSnippet: string,
+ targets: ElementPath[],
+): Promise<{ code: string; selection: ElementPath[] }> {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(inputSnippet),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([selectComponents(targets, true)], true)
+ await renderResult.dispatch([deleteSelected()], true)
+
+ return {
+ code: getPrintedUiJsCode(renderResult.getEditorState()),
+ selection: renderResult.getEditorState().editor.selectedViews,
+ }
+}
+
+function makeTargetPath(suffix: string): ElementPath {
+ return EP.fromString(`${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:${suffix}`)
+}
+
+describe('actions', () => {
+ describe('DELETE_SELECTED', () => {
+ const tests: {
+ name: string
+ input: string
+ targets: ElementPath[]
+ wantCode: string
+ wantSelection: ElementPath[]
+ }[] = [
+ {
+ name: 'delete selected element',
+ input: `
+
+
+
+
+ `,
+ targets: [makeTargetPath('aaa/bbb')],
+ wantCode: `
+
+
+
+ `,
+ wantSelection: [makeTargetPath('aaa')],
+ },
+ {
+ name: 'delete multiple elements',
+ input: `
+
+
+
+
+
+ `,
+ targets: [makeTargetPath('aaa/bbb'), makeTargetPath('aaa/ddd')],
+ wantCode: `
+
+
+
+ `,
+ wantSelection: [makeTargetPath('aaa')],
+ },
+ {
+ name: 'delete empty fragments (single fragment)',
+ input: `
+
+
+
+
+
+
+
+ `,
+ targets: [makeTargetPath('aaa/000/ccc')],
+ wantCode: `
+
+
+
+
+ `,
+ wantSelection: [makeTargetPath('aaa')],
+ },
+ {
+ name: "don't delete fragments if not empty",
+ input: `
+
+
+
+
+
+
+
+
+ `,
+ targets: [makeTargetPath('aaa/000/eee')],
+ wantCode: `
+
+
+
+
+
+
+
+ `,
+ wantSelection: [makeTargetPath('aaa/000/ccc')],
+ },
+ {
+ name: 'delete empty fragments (multiple targets)',
+ input: `
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ targets: [
+ makeTargetPath('xxx/000/eee'),
+ makeTargetPath('xxx/001/fff'),
+ makeTargetPath('xxx/001/ggg'),
+ ],
+ wantCode: `
+
+
+
+
+
+
+
+ `,
+ wantSelection: [makeTargetPath('xxx/000/ccc'), makeTargetPath('xxx')],
+ },
+ {
+ name: 'delete map expression',
+ input: `
+
+ {
+ // @utopia/uid=6bb
+ [0,1,2,3].map(() => ())
+ }
+
+
+ `,
+ targets: [makeTargetPath('aaa/6bb')],
+ wantCode: `
+
+
+
+ `,
+ wantSelection: [makeTargetPath('aaa')],
+ },
+ {
+ name: 'delete expression',
+ input: `
+
+ {
+ // @utopia/uid=d16
+ (() => )()
+ }
+
+
+ `,
+ targets: [makeTargetPath('aaa/d16')],
+ wantCode: `
+
+
+
+ `,
+ wantSelection: [makeTargetPath('aaa')],
+ },
+ {
+ name: 'delete group child selects next sibling',
+ input: `
+
+
+
+
+
+
+
+
+ `,
+ targets: [makeTargetPath('view/group/child3')],
+ wantCode: `
+
+
+
+
+
+
+
+ `,
+ wantSelection: [makeTargetPath('view/group/child1')],
+ },
+ {
+ name: 'delete group child selects next sibling (multiple selection)',
+ input: `
+
+
+
+
+
+
+
+
+
+ `,
+ targets: [makeTargetPath('view/group/child3'), makeTargetPath('view/foo/bar')],
+ wantCode: `
+
+
+
+
+
+
+
+
+ `,
+ wantSelection: [makeTargetPath('view/group/child1'), makeTargetPath('view/foo')],
+ },
+ {
+ name: 'delete last group child deletes the group',
+ input: `
+
+
+
+
+
+ `,
+ targets: [makeTargetPath('view/group/child1')],
+ wantCode: `
+
+ `,
+ wantSelection: [makeTargetPath('view')],
+ },
+ {
+ name: 'recursively delete empty parents when groups or fragments',
+ input: `
+
+ `,
+ targets: [makeTargetPath(`root/g1/g2/f1/child`)],
+ wantCode: `
+
+ `,
+ wantSelection: [makeTargetPath(`root`)],
+ },
+ {
+ name: 'recursively delete empty parents when groups or fragments and stops',
+ input: `
+
+ `,
+ targets: [makeTargetPath(`root/g1/g2/f1/child`)],
+ wantCode: `
+
+ `,
+ wantSelection: [makeTargetPath(`root/g1/stop-here`)],
+ },
+ {
+ name: 'recursively delete empty parents when groups or fragments with multiselect',
+ input: `
+
+ `,
+ targets: [makeTargetPath(`root/g1/g2/f1/child`), makeTargetPath(`root/g1/delete-me`)],
+ wantCode: `
+
+ `,
+ wantSelection: [makeTargetPath(`root`)],
+ },
+ ]
+ tests.forEach((tt, idx) => {
+ it(`(${idx + 1}) ${tt.name}`, async () => {
+ const got = await deleteFromScene(tt.input, tt.targets)
+ expect(got.code).toEqual(makeTestProjectCodeWithSnippet(tt.wantCode))
+ expect(got.selection).toEqual(tt.wantSelection)
+ })
+ })
+
+ it('can delete render props from an element in a map expression', async () => {
+ const editor = await renderTestEditorWithModel(
+ createModifiedProject({
+ [StoryboardFilePath]: `import * as React from 'react'
+ import * as Utopia from 'utopia-api'
+ import { Storyboard, Scene } from 'utopia-api'
+
+ export function Card({ header, children }) {
+ return (
+
+
{header}
+ {children}
+
+ )
+ }
+
+ export var storyboard = (
+
+
+ {
+ // @utopia/uid=map
+ ["test"].map(() => (
+ woot}
+ >
+ Card contents
+
+ ))
+ }
+
+
+ )
+
+ `,
+ ['/utopia/components.utopia.js']: `import { Card } from './storyboard'
+
+ const Components = {
+ '/utopia/storyboard': {
+ Card: {
+ component: Card,
+ properties: {
+ header: {
+ control: 'jsx',
+ },
+ },
+ variants: [],
+ },
+ },
+ }
+
+ export default Components
+ `,
+ }),
+ 'await-first-dom-report',
+ )
+
+ expect(
+ getNavigatorTargetsFromEditorState(editor.getEditorState().editor).navigatorTargets.map(
+ navigatorEntryToKey,
+ ),
+ ).toEqual([
+ 'regular-sb/scene',
+ 'regular-sb/scene/map',
+ 'regular-sb/scene/map/card~~~1',
+ 'render-prop-sb/scene/map/card~~~1/prop-label-header-header',
+ 'render-prop-value-sb/scene/map/card~~~1/render-prop-element-header',
+ 'render-prop-sb/scene/map/card~~~1/prop-label-children-children',
+ 'regular-sb/scene/map/card~~~1/child-element',
+ ])
+
+ const renderPropEntry = editor.renderedDOM.getByTestId(
+ 'NavigatorItemTestId-renderpropvalue_sb/scene/map/card~~~1/render_prop_element_header',
+ )
+
+ await mouseClickAtPoint(renderPropEntry, { x: 2, y: 2 })
+
+ await pressKey('Backspace')
+
+ expect(
+ getNavigatorTargetsFromEditorState(editor.getEditorState().editor).navigatorTargets.map(
+ navigatorEntryToKey,
+ ),
+ ).toEqual([
+ 'regular-sb/scene',
+ 'regular-sb/scene/map',
+ 'regular-sb/scene/map/card~~~1',
+ 'render-prop-sb/scene/map/card~~~1/prop-label-header-header',
+ 'slot_sb/scene/map/card~~~1/prop-label-header',
+ 'render-prop-sb/scene/map/card~~~1/prop-label-children-children',
+ 'regular-sb/scene/map/card~~~1/child-element',
+ ])
+ })
+ })
+
+ describe('PASTE_JSX_ELEMENTS', () => {
+ const clipboardMock = new MockClipboardHandlers().mock()
+
+ async function runPaste(editor: EditorRenderResult) {
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+ }
+
+ type PasteTest = {
+ name: string
+ startingCode: string
+ elements: (renderResult: EditorRenderResult) => Array
+ pasteInto: InsertionPath
+ want: string
+ generatesSaveCount?: number
+ }
+ const tests: Array = [
+ {
+ name: 'a single element',
+ startingCode: `
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['aaa'])),
+ want: `
+
+ `,
+ },
+ {
+ name: 'multiple elements',
+ startingCode: `
+
+ `,
+ elements: (renderResult) => {
+ const fooPath = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb'])
+ const barPath = EP.appendNewElementPath(TestScenePath, ['aaa', 'ccc'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, fooPath),
+ originalElementPath: fooPath,
+ importsToAdd: {},
+ },
+ {
+ element: getElementFromRenderResult(renderResult, barPath),
+ originalElementPath: barPath,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['aaa'])),
+ want: `
+
+
foo
+
bar
+
baz
+
foo
+
bar
+
+ `,
+ },
+ {
+ name: 'a fragment',
+ startingCode: `
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'dbc'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['root', 'aaa'])),
+ want: `
+
+
+
+ foo
+
+
+ bar
+
+
+
+ hello
+
+
+ there
+
+
+
+
+ hello
+ there
+
+
+ `,
+ },
+ {
+ name: 'an empty fragment',
+ startingCode: `
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'dbc'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['root', 'aaa'])),
+ want: `
+
+ `,
+ },
+ {
+ name: 'a conditional',
+ startingCode: `
+
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
true
+ ): (
+
false
+ )
+ }
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'conditional'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['root', 'aaa'])),
+ want: `
+
+
+
foo
+
bar
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+ true
+
+ ) : (
+
false
+ )
+ }
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
true
+ ): (
+
false
+ )
+ }
+
+ `,
+ },
+ {
+ name: 'an element inside a fragment',
+ startingCode: `
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'bbb'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['root', 'dbc'])),
+ want: `
+
+
+
+ foo
+
+
+ bar
+
+
+
+ bar
+
+
+ `,
+ },
+ {
+ name: 'multiple elements inside a fragment',
+ startingCode: `
+
+ `,
+ elements: (renderResult) => {
+ const fooPath = EP.appendNewElementPath(TestScenePath, ['root', 'bbb'])
+ const barPath = EP.appendNewElementPath(TestScenePath, ['root', 'ccc'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, fooPath),
+ originalElementPath: fooPath,
+ importsToAdd: {},
+ },
+ {
+ element: getElementFromRenderResult(renderResult, barPath),
+ originalElementPath: barPath,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['root', 'dbc'])),
+ want: `
+
+
+ foo
+
+ bar
+
+
+ baz
+
+
+
+ bar
+
+
+ baz
+
+
+ `,
+ },
+ {
+ name: 'an element inside an empty conditional branch (true)',
+ startingCode: `
+
+ {
+ // @utopia/uid=conditional
+ true ? null :
foo
+ }
+
bar
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'bbb'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: conditionalClauseInsertionPath(
+ EP.appendNewElementPath(TestScenePath, ['root', 'conditional']),
+ 'true-case',
+ replaceWithSingleElement(),
+ ),
+ want: `
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+ bar
+
+ ) : (
+
foo
+ )
+ }
+
bar
+
+ `,
+ },
+ {
+ name: 'an element inside an empty conditional branch (false)',
+ startingCode: `
+
+ {
+ // @utopia/uid=conditional
+ true ?
foo
: null
+ }
+
bar
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'bbb'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: conditionalClauseInsertionPath(
+ EP.appendNewElementPath(TestScenePath, ['root', 'conditional']),
+ 'false-case',
+ replaceWithSingleElement(),
+ ),
+ want: `
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+ foo
+
+ ) : (
+
+ bar
+
+ )
+ }
+
+ bar
+
+
+
+ `,
+ },
+ {
+ name: 'multiple elements into an empty conditional branch (true)',
+ startingCode: `
+
+ {
+ // @utopia/uid=conditional
+ true ? null :
foo
+ }
+
bar
+
baz
+
+ `,
+ elements: (renderResult) => {
+ const barPath = EP.appendNewElementPath(TestScenePath, ['root', 'bbb'])
+ const bazPath = EP.appendNewElementPath(TestScenePath, ['root', 'ccc'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, barPath),
+ originalElementPath: barPath,
+ importsToAdd: {},
+ },
+ {
+ element: getElementFromRenderResult(renderResult, bazPath),
+ originalElementPath: bazPath,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: conditionalClauseInsertionPath(
+ EP.appendNewElementPath(TestScenePath, ['root', 'conditional']),
+ 'true-case',
+ replaceWithSingleElement(),
+ ),
+ want: `
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+
+ bar
+
+
+ baz
+
+
+ ) : (
+
+ foo
+
+ )
+ }
+
+ bar
+
+
+ baz
+
+
+ `,
+ },
+ {
+ name: 'multiple elements into an empty conditional branch (false)',
+ startingCode: `
+
+ {
+ // @utopia/uid=conditional
+ true ?
foo
: null
+ }
+
bar
+
baz
+
+ `,
+ elements: (renderResult) => {
+ const barPath = EP.appendNewElementPath(TestScenePath, ['root', 'bbb'])
+ const bazPath = EP.appendNewElementPath(TestScenePath, ['root', 'ccc'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, barPath),
+ originalElementPath: barPath,
+ importsToAdd: {},
+ },
+ {
+ element: getElementFromRenderResult(renderResult, bazPath),
+ originalElementPath: bazPath,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: conditionalClauseInsertionPath(
+ EP.appendNewElementPath(TestScenePath, ['root', 'conditional']),
+ 'false-case',
+ wrapInFragmentAndAppendElements('wrapper-fragment'),
+ ),
+ want: `
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+ foo
+
+ ) : (
+
+
+ bar
+
+
+ baz
+
+
+ )
+ }
+
+ bar
+
+
+ baz
+
+
+ `,
+ },
+ {
+ name: 'a fragment inside an empty conditional branch',
+ startingCode: `
+
+ {
+ // @utopia/uid=conditional
+ true ? null :
foo
+ }
+
+ bar
+ baz
+
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'dbc'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: conditionalClauseInsertionPath(
+ EP.appendNewElementPath(TestScenePath, ['root', 'conditional']),
+ 'true-case',
+ replaceWithSingleElement(),
+ ),
+ want: `
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+
+ bar
+
+
+ baz
+
+
+ ) :
foo
+ }
+
+ bar
+ baz
+
+
+ `,
+ },
+ {
+ name: 'multiple fragments inside an empty conditional branch',
+ startingCode: `
+
+ {
+ // @utopia/uid=conditional
+ true ? null :
foo
+ }
+
+ bar
+ baz
+
+
+ qux
+ waldo
+
+
+ `,
+ elements: (renderResult) => {
+ const firstPath = EP.appendNewElementPath(TestScenePath, ['root', 'dbc'])
+ const secondPath = EP.appendNewElementPath(TestScenePath, ['root', 'c69'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, firstPath),
+ originalElementPath: firstPath,
+ importsToAdd: {},
+ },
+ {
+ element: getElementFromRenderResult(renderResult, secondPath),
+ originalElementPath: secondPath,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: conditionalClauseInsertionPath(
+ EP.appendNewElementPath(TestScenePath, ['root', 'conditional']),
+ 'true-case',
+ replaceWithSingleElement(),
+ ),
+ want: `
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+
+
+ bar
+
+
+ baz
+
+
+
+
+ qux
+
+
+ waldo
+
+
+
+ ) :
foo
+ }
+
+ bar
+ baz
+
+
+ qux
+ waldo
+
+
+ `,
+ },
+ {
+ name: 'an active conditional branch',
+ startingCode: `
+
+ {
+ // @utopia/uid=conditional
+ true ?
foo
: null
+ }
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'conditional', 'aaa'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['root'])),
+ want: `
+
+ {
+ // @utopia/uid=conditional
+ true ?
foo
: null
+ }
+
foo
+
+ `,
+ },
+ {
+ name: 'a flex container',
+ startingCode: `
+
+
+
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.fromString(
+ `${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:fragment/element-to-paste`,
+ )
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: childInsertionPath(
+ EP.fromString(
+ `${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:fragment/flex-container`,
+ ),
+ ),
+ want: `
+
+
+
+
+ `,
+ },
+ {
+ name: 'a conditional clause with an element that doesnt support children',
+ startingCode: `
+
+ {
+ // @utopia/uid=conditional
+ true ?
![]()
: null
+ }
+
bar
+
baz
+
+ `,
+ elements: (renderResult) => {
+ const path1 = EP.appendNewElementPath(TestScenePath, ['root', 'bbb'])
+ const path2 = EP.appendNewElementPath(TestScenePath, ['root', 'ccc'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path1),
+ originalElementPath: path1,
+ importsToAdd: {},
+ },
+ {
+ element: getElementFromRenderResult(renderResult, path2),
+ originalElementPath: path2,
+ importsToAdd: {},
+ },
+ ]
+ },
+ pasteInto: conditionalClauseInsertionPath(
+ EP.appendNewElementPath(TestScenePath, ['root', 'conditional']),
+ 'true-case',
+ wrapInFragmentAndAppendElements('wrapper-fragment'),
+ ),
+ want: `
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+
+ bar
+
+
+ baz
+
+
+
+ ) : null
+ }
+
+ bar
+
+
+ baz
+
+
+ `,
+ },
+ {
+ name: 'into a group',
+ startingCode: `
+
+ `,
+ elements: (renderResult) => {
+ const path = EP.appendNewElementPath(TestScenePath, ['root', 'foo'])
+ return [
+ {
+ element: getElementFromRenderResult(renderResult, path),
+ originalElementPath: path,
+ importsToAdd: {},
+ },
+ ]
+ },
+ generatesSaveCount: 2,
+ pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['root', 'group'])),
+ want: `
+
+ `,
+ },
+ ]
+ tests.forEach((test, i) => {
+ it(`${i + 1}/${tests.length} ${test.name}`, async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(test.startingCode),
+ 'await-first-dom-report',
+ )
+
+ const copiedPaths = test.elements(renderResult).map((e) => e.originalElementPath)
+ await selectComponentsForTest(renderResult, copiedPaths)
+
+ await pressKey('c', { modifiers: cmdModifier })
+
+ if (test.pasteInto.type === 'CHILD_INSERTION') {
+ await selectComponentsForTest(renderResult, [test.pasteInto.intendedParentPath])
+ } else if (test.pasteInto.type === 'CONDITIONAL_CLAUSE_INSERTION') {
+ const conditional = maybeConditionalExpression(
+ MetadataUtils.findElementByElementPath(
+ renderResult.getEditorState().editor.jsxMetadata,
+ test.pasteInto.intendedParentPath,
+ ),
+ )!
+
+ const targetUid =
+ test.pasteInto.clause === 'true-case'
+ ? conditional.whenTrue.uid
+ : test.pasteInto.clause === 'false-case'
+ ? conditional.whenFalse.uid
+ : assertNever(test.pasteInto.clause)
+
+ const targetPath = EP.appendToPath(test.pasteInto.intendedParentPath, targetUid)
+ await selectComponentsForTest(renderResult, [targetPath])
+ } else {
+ assertNever(test.pasteInto)
+ }
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ await expectSingleUndoNSaves(renderResult, test.generatesSaveCount ?? 2, async () => {
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+ })
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(test.want),
+ )
+ })
+ })
+
+ it('can copy-paste end-to-end', async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa/bbb')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+ await pressKey('Esc')
+
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+ `),
+ )
+ })
+
+ it('can copy-paste an expression end-to-end', async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=d54
+ (() => (
))()
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa/d54')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+ await pressKey('Esc')
+
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+ {
+ // @utopia/uid=d54
+ (() => (
+
+ ))()
+ }
+ {
+ // @utopia/uid=d54
+ (() => (
+
+ ))()
+ }
+
+ `),
+ )
+ })
+
+ it('copy-paste into an expression pastes as sibling', async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa/ddd')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ const childrenOfBBB = MetadataUtils.getChildrenOrdered(
+ renderResult.getEditorState().editor.jsxMetadata,
+ renderResult.getEditorState().editor.elementPathTree,
+ makeTargetPath('aaa/bbb'),
+ )
+
+ await selectComponentsForTest(renderResult, [childrenOfBBB[0].elementPath])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+ await pressKey('Esc')
+
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {(() => (
+
+ ))()}
+
+
+
+
+ `),
+ )
+ })
+
+ it('pasting a fragment into a different file imports React', async () => {
+ const editor = await renderTestEditorWithModel(
+ createTestProjectWithMultipleFiles({
+ [StoryboardFilePath]: `
+ import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+ import { Playground } from '/src/playground.js'
+
+ export var storyboard = (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ `,
+ [PlaygroundFilePath]: `
+ export var Playground = () => {
+ return (
+
+ )
+ }
+
+ `,
+ }),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene-2/fragment')])
+
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(editor, [
+ EP.fromString('sb/scene-1/playground:pg-root/pg-container'),
+ ])
+
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+
+ FOR_TESTS_setNextGeneratedUids(['child1', 'child2', 'parent'])
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(editor.getEditorState(), PlaygroundFilePath))
+ .toEqual(`import * as React from 'react'
+export var Playground = () => {
+ return (
+
+ )
+}
+`)
+ })
+
+ it('pasting back into original parent pastes into the right position', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+import { Storyboard } from 'utopia-api'
+
+export var storyboard = (
+
+
+
+)
+`,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/container/child`)])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/container`)])
+ const canvasControlsLayer = editor.renderedDOM.getByTestId(CanvasControlsContainerID)
+ const div = editor.renderedDOM.getByTestId('container')
+ const divBounds = div.getBoundingClientRect()
+ const divCorner = {
+ x: divBounds.x + 5,
+ y: divBounds.y + 4,
+ }
+
+ await mouseDragFromPointWithDelta(
+ canvasControlsLayer,
+ divCorner,
+ windowPoint({ x: 300, y: 300 }),
+ )
+
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Storyboard } from 'utopia-api'
+
+export var storyboard = (
+
+
+
+)
+`)
+ })
+
+ describe('pasting component instances', () => {
+ const project = `import * as React from 'react'
+ import { Storyboard, Scene } from 'utopia-api'
+
+ const App = (props) => (
+
+
+
+
+ )
+
+ const ThisComponent = (props) => (
+
+
+ Hello there 1!
+
+
Hello there 2!
+
Hello there 3!
+
+ )
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `
+
+ it('cannot paste a component instance into its own definition', async () => {
+ const editor = await renderTestEditorWithCode(project, 'await-first-dom-report')
+
+ /**
+ * Opening the component in the navigator isn't strictly necessary, since simply selecting the target paths is enough,
+ * but it makes the tests less synthetic and might be useful for debugging this test
+ */
+ await mouseDoubleClickAtPoint(editor.renderedDOM.getAllByText('ThisComponent')[0], {
+ x: 2,
+ y: 2,
+ })
+ expect(
+ getNavigatorTargetsFromEditorState(
+ editor.getEditorState().editor,
+ ).visibleNavigatorTargets.map(navigatorEntryToKey),
+ ).toEqual([
+ 'regular-sb/scene',
+ 'regular-sb/scene/app',
+ 'regular-sb/scene/app:app-root',
+ 'regular-sb/scene/app:app-root/component-1',
+ 'regular-sb/scene/app:app-root/component-1:custom-root',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/hello-1',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/aap',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/aat',
+ 'regular-sb/scene/app:app-root/component-2',
+ ])
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene/app:app-root/component-2')])
+
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(editor, [
+ EP.fromString('sb/scene/app:app-root/component-1:custom-root/hello-1'),
+ ])
+
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+ firePasteEvent(canvasRoot)
+
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(editor.getEditorState().editor.toasts.length).toEqual(1)
+ expect(editor.getEditorState().editor.toasts[0].message).toEqual(
+ 'Cannot insert component instance into component definition',
+ )
+ })
+
+ it('cannot paste a component instance into its own definition, transitively', async () => {
+ const editor = await renderTestEditorWithCode(project, 'await-first-dom-report')
+
+ await mouseDoubleClickAtPoint(editor.renderedDOM.getAllByText('ThisComponent')[0], {
+ x: 2,
+ y: 2,
+ })
+
+ expect(
+ getNavigatorTargetsFromEditorState(
+ editor.getEditorState().editor,
+ ).visibleNavigatorTargets.map(navigatorEntryToKey),
+ ).toEqual([
+ 'regular-sb/scene',
+ 'regular-sb/scene/app',
+ 'regular-sb/scene/app:app-root',
+ 'regular-sb/scene/app:app-root/component-1',
+ 'regular-sb/scene/app:app-root/component-1:custom-root',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/hello-1',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/aap',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/aat',
+ 'regular-sb/scene/app:app-root/component-2',
+ ])
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene/app')])
+
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(editor, [
+ EP.fromString('sb/scene/app:app-root/component-1:custom-root/hello-1'),
+ ])
+
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+ firePasteEvent(canvasRoot)
+
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(editor.getEditorState().editor.toasts.length).toEqual(1)
+ expect(editor.getEditorState().editor.toasts[0].message).toEqual(
+ 'Cannot insert component instance into component definition',
+ )
+ })
+
+ it('cannot paste a component instance into its own definition, via a Scene', async () => {
+ const editor = await renderTestEditorWithCode(project, 'await-first-dom-report')
+
+ await mouseDoubleClickAtPoint(editor.renderedDOM.getAllByText('ThisComponent')[0], {
+ x: 2,
+ y: 2,
+ })
+
+ expect(
+ getNavigatorTargetsFromEditorState(
+ editor.getEditorState().editor,
+ ).visibleNavigatorTargets.map(navigatorEntryToKey),
+ ).toEqual([
+ 'regular-sb/scene',
+ 'regular-sb/scene/app',
+ 'regular-sb/scene/app:app-root',
+ 'regular-sb/scene/app:app-root/component-1',
+ 'regular-sb/scene/app:app-root/component-1:custom-root',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/hello-1',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/aap',
+ 'regular-sb/scene/app:app-root/component-1:custom-root/aat',
+ 'regular-sb/scene/app:app-root/component-2',
+ ])
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene')])
+
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(editor, [
+ EP.fromString('sb/scene/app:app-root/component-1:custom-root/hello-1'),
+ ])
+
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+ firePasteEvent(canvasRoot)
+
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(editor.getEditorState().editor.toasts.length).toEqual(1)
+ expect(editor.getEditorState().editor.toasts[0].message).toEqual(
+ 'Cannot insert component instance into component definition',
+ )
+ })
+ })
+
+ describe('repeated paste', () => {
+ async function pasteNTimes(editor: EditorRenderResult, n: number) {
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+
+ for (let counter = 0; counter < n; counter += 1) {
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ clipboardMock.resetDoneSignal()
+ }
+ }
+
+ it('repeated paste in autolayout', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ const targetPath = makeTargetPath('root/container/div')
+
+ await selectComponentsForTest(editor, [targetPath])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await pasteNTimes(editor, 4)
+
+ await pressKey('Esc')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(
+ getNavigatorTargetsFromEditorState(editor.getEditorState().editor).navigatorTargets.map(
+ navigatorEntryToKey,
+ ),
+ ).toEqual([
+ 'regular-utopia-storyboard-uid/scene-aaa',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:root',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:root/container',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:root/container/div',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:root/container/aag',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:root/container/aai',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:root/container/aak',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:root/container/aam',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:root/container/last',
+ ])
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Scene, Storyboard, View, Group } from 'utopia-api'
+
+export var App = (props) => {
+ return (
+
+ )
+}
+
+export var storyboard = (props) => {
+ return (
+
+
+
+
+
+ )
+}
+`)
+ })
+ it('repeatedly pasting an absolute element onto the storyboard', async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ const targetPath = makeTargetPath('sb/ccc')
+
+ await selectComponentsForTest(renderResult, [targetPath])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [])
+
+ await pasteNTimes(renderResult, 4)
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(
+ getNavigatorTargetsFromEditorState(
+ renderResult.getEditorState().editor,
+ ).navigatorTargets.map(navigatorEntryToKey),
+ ).toEqual([
+ 'regular-utopia-storyboard-uid/scene-aaa',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb/container',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb/ccc',
+ 'regular-utopia-storyboard-uid/aai',
+ 'regular-utopia-storyboard-uid/aak',
+ 'regular-utopia-storyboard-uid/aam',
+ 'regular-utopia-storyboard-uid/aao',
+ ])
+ expect(getPrintedUiJsCode(renderResult.getEditorState()))
+ .toEqual(`import * as React from 'react'
+import { Scene, Storyboard, View, Group } from 'utopia-api'
+
+export var App = (props) => {
+ return (
+
+ )
+}
+
+export var storyboard = (props) => {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+`)
+ })
+
+ it('repeatedly pasting an absolute element into a container', async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ const targetPath = makeTargetPath('sb/ccc')
+
+ await selectComponentsForTest(renderResult, [targetPath])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [targetPath])
+
+ await pasteNTimes(renderResult, 4)
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(
+ getNavigatorTargetsFromEditorState(
+ renderResult.getEditorState().editor,
+ ).navigatorTargets.map(navigatorEntryToKey),
+ ).toEqual([
+ 'regular-utopia-storyboard-uid/scene-aaa',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb/container',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb/ccc',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb/aai',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb/aak',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb/aam',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:sb/aao',
+ ])
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(``),
+ )
+ })
+ })
+
+ describe('paste next to multiselection', () => {
+ it('paste next to multiselection of flex elements', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [
+ makeTargetPath('root/container/div'),
+ makeTargetPath('root/container/last'),
+ ])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(``),
+ )
+ })
+
+ it('paste next to multiselection of absolute elements', async () => {
+ const editor = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(``),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [
+ makeTargetPath('root/container/div'),
+ makeTargetPath('root/container/last'),
+ ])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+`),
+ )
+ })
+ })
+
+ describe('paste into a conditional', () => {
+ describe('root', () => {
+ it('pastes the element below the conditional', async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true ?
: null
+ }
+
foo
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/bbb')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/conditional')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {
+ // @utopia/uid=conditional
+ true ?
: null
+ }
+
foo
+
foo
+
+ `),
+ )
+ })
+ })
+ describe('non-empty branch', () => {
+ it(`when it supports children, it's inserted as a child`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true ?
: null
+ }
+
foo
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/bbb')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/conditional/aaa')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+ ) : null
+ }
+
foo
+
+ `),
+ )
+ })
+ it(`when it does not support children, it's wrapped in a fragment`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true ?

: null
+ }
+
foo
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/bbb')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/conditional/aaa')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+
+ foo
+
+
+
+ ) : null
+ }
+
+ foo
+
+
+
+ `),
+ )
+ })
+ })
+ describe('empty branch', () => {
+ it(`replaces the slot`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true ?
: null
+ }
+
foo
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/bbb')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/conditional/d84')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
+ ) : (
+
+ foo
+
+ )
+ }
+
foo
+
+ `),
+ )
+ })
+
+ it('applies flex props if the conditional is in a flex container', async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(`
+ {
+ // @utopia/uid=conditional
+ true ? null : null
+ }
+

+
`),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/bbb')])
+ await pressKey('x', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/conditional/d84')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+

+ ) : null
+ }
+
+ `),
+ )
+
+ const img = renderResult.renderedDOM.getByTestId('bbb')
+ const { top, left, position } = img.style
+ expect({ top, left, position }).toEqual({ left: '', top: '', position: '' })
+ })
+ })
+ describe('pasting an element creates new layout properties for the new parent layout', () => {
+ const copyPasteLayoutTestCases: Array<{
+ name: string
+ input: string
+ targets: Array
+ result: string
+ }> = [
+ {
+ name: `paste an absolute element into a flex layout`,
+ input: ``,
+ targets: [makeTargetPath('root/bbb')],
+ result: ``,
+ },
+ {
+ name: `paste an absolute element with % values into a flex layout`,
+ input: ``,
+ targets: [makeTargetPath('root/bbb')],
+ result: ``,
+ },
+ {
+ name: `paste a flex child with px size into a flex layout`,
+ input: ``,
+ targets: [makeTargetPath('root/bbb/ddd')],
+ result: ``,
+ },
+ {
+ name: `paste a flex child with flexGrow into a flex layout`,
+ input: ``,
+ targets: [makeTargetPath('root/bbb/ddd')],
+ result: ``,
+ },
+ {
+ name: `paste a flex child into a flow layout`,
+ input: ``,
+ targets: [makeTargetPath('root/bbb/ddd')],
+ result: ``,
+ },
+ {
+ name: 'paste an element into an absolute layout',
+ input: ` `,
+ targets: [makeTargetPath('root/source')],
+ result: ` `,
+ },
+ {
+ name: 'paste an element into an absolute layout - element will be centered',
+ input: ` `,
+ targets: [makeTargetPath('root/source')],
+ result: ` `,
+ },
+ {
+ name: 'paste an absolute element into a flow layout - element will be absolute',
+ input: ``,
+ targets: [makeTargetPath('root/bbb')],
+ result: ``,
+ },
+ {
+ name: 'trying to paste a div into a span is not allowed',
+ input: ``,
+ targets: [makeTargetPath('root/bbb')],
+ result: ``,
+ },
+ {
+ name: 'it is possible to paste a h1 element into a span',
+ input: `
+ hi
+
hello
+ `,
+ targets: [makeTargetPath('root/bbb')],
+ result: `
+
+ hihello
+
+
hello
+ `,
+ },
+ {
+ name: 'paste 2 absolute elements - elements will keep their position to each other',
+ input: ``,
+ targets: [makeTargetPath('root/hello'), makeTargetPath('root/bello')],
+ result: ``,
+ },
+ ]
+
+ copyPasteLayoutTestCases.forEach((tt, idx) => {
+ it(`(${idx + 1}) [copy] ${tt.name}`, async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(tt.input),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, tt.targets)
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/ccc')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(tt.result),
+ )
+ })
+ })
+
+ const cutPasteLayoutTestCases: Array<{
+ name: string
+ input: string
+ targets: Array
+ result: string
+ }> = [
+ {
+ name: `paste an absolute element into a flex layout`,
+ input: ``,
+ targets: [makeTargetPath('root/bbb')],
+ result: ``,
+ },
+ {
+ name: 'paste an element into an absolute layout',
+ input: ` `,
+ targets: [makeTargetPath('root/source')],
+ result: ` `,
+ },
+ ]
+
+ cutPasteLayoutTestCases.forEach((tt, idx) => {
+ it(`(${idx + 1}) [cut] ${tt.name}`, async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(tt.input),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, tt.targets)
+ await pressKey('x', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('root/ccc')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(tt.result),
+ )
+ })
+ })
+
+ const copyPasteToStoryboardTestCases: Array<{
+ name: string
+ input: string
+ targets: Array
+ result: string
+ }> = [
+ {
+ name: `paste an absolute element into the storyboard`,
+ input: ``,
+ targets: [makeTargetPath('root/bbb')],
+ result: `Hello!
`,
+ },
+ {
+ name: `paste a flex child into the storyboard`,
+ input: ``,
+ targets: [makeTargetPath('root/bbb/ddd')],
+ result: ``,
+ },
+ {
+ name: 'paste 2 absolute elements to the storyboard - elements will keep their position to each other',
+ input: ``,
+ targets: [makeTargetPath('root/hello'), makeTargetPath('root/bello')],
+ result: `hello
+ bello
`,
+ },
+ ]
+
+ copyPasteToStoryboardTestCases.forEach((tt, idx) => {
+ it(`(${idx + 1}) ${tt.name}`, async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(tt.input),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, tt.targets)
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [EP.fromString(BakedInStoryboardUID)])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ // Wait for the next frame
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ await pressKey('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(`
+ import * as React from 'react'
+ import { Scene, Storyboard, View, Group } from 'utopia-api'
+
+ export var App = (props) => {
+ return (${tt.input})
+ }
+
+ export var ${BakedInStoryboardVariableName} = (props) => {
+ return (
+
+
+
+
+ ${tt.result}
+
+ )
+ }
+ `),
+ )
+ })
+ })
+ })
+ })
+
+ describe('paste into a fragment', () => {
+ const template = (innards: string) => `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const App = () => {
+ return (
+ ${innards}
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+
+ )
+ `
+
+ it('into a fragment with flex layout', async () => {
+ const editor = await renderTestEditorWithCode(
+ template(``),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/paste-me')])
+
+ await pressKey('x', { modifiers: cmdModifier })
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene/app:root/paste-here')])
+
+ await runPaste(editor)
+
+ const pastedElement = editor.renderedDOM.getByTestId('paste-me')
+ const { top, left, position, width, height } = pastedElement.style
+ expect({ top, left, position, width, height }).toEqual({
+ height: '35px',
+ left: '',
+ position: '',
+ top: '',
+ width: '36px',
+ })
+ })
+
+ it('into a fragment with absolute layout', async () => {
+ const editor = await renderTestEditorWithCode(
+ template(`
+
+
+ `),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/paste-me')])
+
+ await pressKey('x', { modifiers: cmdModifier })
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene/app:root')])
+
+ await runPaste(editor)
+
+ const pastedElement = editor.renderedDOM.getByTestId('paste-me')
+ const { top, left, position, width, height } = pastedElement.style
+ expect({ top, left, position, width, height }).toEqual({
+ height: '35px',
+ left: '122px',
+ position: 'absolute',
+ top: '52px',
+ width: '36px',
+ })
+ })
+ })
+
+ describe('pasting fragments with children', () => {
+ const Child1TestId = 'child-1'
+ const Child2TestId = 'child-2'
+ const template = (innards: string) => `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const App = () => {
+ return (
+ ${innards}
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+
+
+
+
+ )
+ `
+
+ // only the props of the fragemnt's children are asserted in the following tests, since
+ // previous tests already establish that pasting an element into a container with children
+ // works as intended
+
+ it('paste into an absolute layout', async () => {
+ const editor = await renderTestEditorWithCode(
+ template(`
+
+ `),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/fragment')])
+ await pressKey('x', { modifiers: cmdModifier })
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene/app:root/container')])
+
+ await runPaste(editor)
+
+ {
+ const { position, top, left, width, height } =
+ editor.renderedDOM.getByTestId(Child1TestId).style
+ expect({ position, top, left, width, height }).toEqual({
+ height: '112px',
+ left: '15px',
+ position: 'absolute',
+ top: '39px',
+ width: '110px',
+ })
+ }
+
+ {
+ const { position, top, left, width, height } =
+ editor.renderedDOM.getByTestId(Child2TestId).style
+ expect({ position, top, left, width, height }).toEqual({
+ height: '112px',
+ left: '196px',
+ position: 'absolute',
+ top: '39px',
+ width: '100px',
+ })
+ }
+ })
+
+ it('paste into a flex layout', async () => {
+ const editor = await renderTestEditorWithCode(
+ template(`
+
+ `),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/fragment')])
+ await pressKey('x', { modifiers: cmdModifier })
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene/app:root/container')])
+
+ await runPaste(editor)
+
+ {
+ const { position, top, left, width, height } =
+ editor.renderedDOM.getByTestId(Child1TestId).style
+ expect({ position, top, left, width, height }).toEqual({
+ height: '112px',
+ left: '',
+ position: '',
+ top: '',
+ width: '110px',
+ })
+ }
+
+ {
+ const { position, top, left, width, height } =
+ editor.renderedDOM.getByTestId(Child2TestId).style
+ expect({ position, top, left, width, height }).toEqual({
+ height: '112px',
+ left: '',
+ position: '',
+ top: '',
+ width: '100px',
+ })
+ }
+ })
+
+ it('elements with relative sizing and pins are converted to visual size, with pins removed', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+ const App = () => {
+ return (
+
+ )
+ }
+ export var storyboard = (
+
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/outer/fragment')])
+ await pressKey('x', { modifiers: cmdModifier })
+ await editor.getDispatchFollowUpActionsFinished()
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene/app:root/container')])
+
+ await runPaste(editor)
+
+ {
+ const { position, top, left, width, height } =
+ editor.renderedDOM.getByTestId(Child1TestId).style
+ expect({ position, top, left, width, height }).toEqual({
+ height: '48px',
+ left: '46px',
+ position: 'absolute',
+ top: '118.5px',
+ width: '91px',
+ })
+ }
+
+ {
+ const { position, top, left, width, height } =
+ editor.renderedDOM.getByTestId(Child2TestId).style
+ expect({ position, top, left, width, height }).toEqual({
+ height: '73px',
+ left: '194px',
+ position: 'absolute',
+ top: '100px',
+ width: '70.5px',
+ })
+ }
+ })
+ })
+
+ describe('Paste to Replace', () => {
+ const pasteToReplaceTestCases: Array<{
+ name: string
+ input: string
+ copyTargets: Array
+ pasteTargets: Array
+ expectedSelectedViews: Array | null
+ result: string
+ }> = [
+ {
+ name: `paste to replace an absolute element`,
+ input: ``,
+ copyTargets: [makeTargetPath('root/bbb')],
+ pasteTargets: [makeTargetPath('root/ddd')],
+ expectedSelectedViews: [makeTargetPath('root/aai')],
+ result: `
+
+ Hello!
+
+
+ Hello!
+
+
`,
+ },
+ {
+ name: `paste to replace a flex child`,
+ input: ``,
+ copyTargets: [makeTargetPath('root/bbb')],
+ pasteTargets: [makeTargetPath('root/ddd/fff')],
+ expectedSelectedViews: [makeTargetPath('root/ddd/aak')],
+ result: ``,
+ },
+ {
+ name: `paste to replace an absolute element with multiselection`,
+ input: ``,
+ copyTargets: [makeTargetPath('root/bbb'), makeTargetPath('root/fff/ggg')],
+ pasteTargets: [makeTargetPath('root/ddd')],
+ expectedSelectedViews: [makeTargetPath('root/aak'), makeTargetPath('root/aaz')],
+ result: `
+
+ Hello!
+
+
+ Hello!
+
+
+ second element
+
+
+
`,
+ },
+ {
+ name: `paste to replace multiselected absolute elements with multiselected absolute elements`,
+ input: `
+
+ Hello!
+
+
+
+ second element
+
+
+
`,
+ copyTargets: [makeTargetPath('root/bbb'), makeTargetPath('root/fff')],
+ pasteTargets: [makeTargetPath('root/jjj'), makeTargetPath('root/ddd')],
+ expectedSelectedViews: [
+ makeTargetPath('root/aak'),
+ makeTargetPath('root/aaz'),
+ makeTargetPath('root/aau'),
+ makeTargetPath('root/abl'),
+ ],
+ result: `
+
+ Hello!
+
+
+ Hello!
+
+
+ second element
+
+
+ second element
+
+
+ Hello!
+
+
+ second element
+
+
`,
+ },
+ {
+ name: `paste to replace an absolute element in a conditional clause`,
+ input: `
+
+ Hello!
+
+ {
+ //@utopia/uid=cond
+ false
+ ? null
+ : (
+
+ )
+ }
+
`,
+ copyTargets: [makeTargetPath('root/bbb')],
+ pasteTargets: [makeTargetPath('root/cond/ddd')],
+ expectedSelectedViews: [makeTargetPath('root/cond/aai')],
+ result: `
+
+ Hello!
+
+ {
+ //@utopia/uid=cond
+ false
+ ? null
+ : (
+
+ Hello!
+
+ )
+ }
+
`,
+ },
+ {
+ name: `paste to replace an absolute element in a conditional clause with multiselection`,
+ input: `
+
+ Hello!
+
+ {
+ //@utopia/uid=cond
+ false
+ ? null
+ : (
+
+ )
+ }
+
+
`,
+ copyTargets: [makeTargetPath('root/bbb'), makeTargetPath('root/fff/ggg')],
+ pasteTargets: [makeTargetPath('root/cond/ddd')],
+ expectedSelectedViews: null,
+ result: `
+
+ Hello!
+
+ {
+ //@utopia/uid=cond
+ false
+ ? null
+ : (
+
+
+ Hello!
+
+
+ second element
+
+
+ )
+ }
+
+
`,
+ },
+ ]
+
+ pasteToReplaceTestCases.forEach((tt, idx) => {
+ it(`(${idx + 1}) ${tt.name}`, async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(tt.input),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, tt.copyTargets)
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, tt.pasteTargets)
+ await pressKey('v', { modifiers: shiftCmdModifier })
+
+ // Wait for the next frame
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(tt.result),
+ )
+ if (tt.expectedSelectedViews != null) {
+ expect(renderResult.getEditorState().editor.selectedViews).toEqual(
+ tt.expectedSelectedViews,
+ )
+ }
+ })
+ })
+ })
+
+ describe('pasting with props replaced', () => {
+ it('copy pasting element with code in props', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log("Hello world!")
+
+ const colors = { backgroundColor: '#cee5ff' }
+
+ return (
+
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/scene/app:root`)])
+
+ await expectNoAction(editor, () => pressKey('c', { modifiers: cmdModifier }))
+
+ await selectComponentsForTest(editor, [])
+
+ await runPaste(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Scene, Storyboard } from 'utopia-api'
+
+const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log('Hello world!')
+
+ const colors = { backgroundColor: '#cee5ff' }
+
+ return (
+
+ )
+}
+
+export var storyboard = (
+
+
+
+
+
+
+)
+`)
+ })
+
+ it('copy element with code in child and grandchild', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log("Hello world!")
+
+ const grandParentLabel = "grandParent"
+ const parentLabel = "parent"
+
+ return (
+
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/scene/app:root`)])
+
+ await expectNoAction(editor, () => pressKey('c', { modifiers: cmdModifier }))
+
+ await selectComponentsForTest(editor, [])
+
+ await runPaste(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Scene, Storyboard } from 'utopia-api'
+
+const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log('Hello world!')
+
+ const grandParentLabel = 'grandParent'
+ const parentLabel = 'parent'
+
+ return (
+
+ )
+}
+
+export var storyboard = (
+
+
+
+
+
+
+)
+`)
+ })
+
+ it('copy element wrapped in fragment', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log("Hello world!")
+
+ return (
+
+
+
+
+
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/scene/app:root`)])
+
+ await expectNoAction(editor, () => pressKey('c', { modifiers: cmdModifier }))
+
+ await selectComponentsForTest(editor, [])
+
+ await runPaste(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Scene, Storyboard } from 'utopia-api'
+
+const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log('Hello world!')
+
+ return (
+
+
+
+
+
+ )
+}
+
+export var storyboard = (
+
+
+
+
+
+
+
+
+
+
+)
+`)
+ })
+
+ it('copy conditional with code in the true branch', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log('Hello world!')
+
+ return (
+
+ {
+ // @utopia/uid=cond
+ true ? (
+
+ ) : null}
+
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/scene/app:root/cond`)])
+
+ await expectNoAction(editor, () => pressKey('c', { modifiers: cmdModifier }))
+
+ await selectComponentsForTest(editor, [])
+
+ await runPaste(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Scene, Storyboard } from 'utopia-api'
+
+const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log('Hello world!')
+
+ return (
+
+ {
+ // @utopia/uid=cond
+ true ? (
+
+ ) : null
+ }
+
+ )
+}
+
+export var storyboard = (
+
+
+
+
+ {
+ // @utopia/uid=cond
+ true ? (
+
+ ) : null
+ }
+
+)
+`)
+ })
+
+ it('copy conditional with code in the false branch', async () => {
+ /**
+ * The gotcha here is that the false branch only has metadata if
+ * the conditional is toggled to display the false branch
+ */
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log('Hello world!')
+
+ return (
+
+ {
+ // @utopia/uid=cond
+ // @utopia/conditional=false
+ true ? null : (
+
+ )
+ }
+
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/scene/app:root/cond`)])
+
+ await expectNoAction(editor, () => pressKey('c', { modifiers: cmdModifier }))
+
+ await selectComponentsForTest(editor, [])
+
+ await runPaste(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Scene, Storyboard } from 'utopia-api'
+
+const App = () => {
+ const width = 44
+ const height = 33
+
+ const hello = () => console.log('Hello world!')
+
+ return (
+
+ {
+ // @utopia/uid=cond
+ // @utopia/conditional=false
+ true ? null : (
+
+ )
+ }
+
+ )
+}
+
+export var storyboard = (
+
+
+
+
+ {
+ // @utopia/uid=cond
+ // @utopia/conditional=false
+ true ? null : (
+
+ )
+ }
+
+)
+`)
+ })
+ })
+
+ describe('toggling to pasting with props preserved', () => {
+ it('copy element with code in child and grandchild', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const width = 88
+
+ const App = () => {
+ const width = 44
+
+ return (
+
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/scene/app:root`)])
+
+ await expectNoAction(editor, () => pressKey('c', { modifiers: cmdModifier }))
+
+ await selectComponentsForTest(editor, [])
+
+ const canvasRoot = editor.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ await clipboardMock.pasteDone
+ await editor.getDispatchFollowUpActionsFinished()
+
+ // open the post-action menu
+ const floatingPostActionMenu = editor.renderedDOM.getByTestId(FloatingPostActionMenuTestId)
+ await mouseClickAtPoint(floatingPostActionMenu, { x: 2, y: 2 })
+
+ expect(editor.getEditorState().postActionInteractionSession?.activeChoiceId).toEqual(
+ PropsReplacedPastePostActionChoiceId,
+ )
+
+ await pressKey('2')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(editor.getEditorState().postActionInteractionSession?.activeChoiceId).toEqual(
+ PropsPreservedPastePostActionChoiceId,
+ )
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Scene, Storyboard } from 'utopia-api'
+
+const width = 88
+
+const App = () => {
+ const width = 44
+
+ return (
+
+ )
+}
+
+export var storyboard = (
+
+
+
+
+
+
+)
+`)
+ })
+ })
+
+ describe('ending the paste session', () => {
+ async function setupPasteSession(): Promise {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa/bbb')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa')])
+
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ return renderResult
+ }
+
+ function expectResultsToBeCommitted(editor: EditorRenderResult) {
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+ `),
+ )
+ }
+
+ it('the paste session ends on non-transient action', async () => {
+ const renderResult = await setupPasteSession()
+ expect(renderResult.getEditorState().postActionInteractionSession).not.toBeNull()
+
+ keyDown('Backspace')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(renderResult.getEditorState().postActionInteractionSession).toBeNull()
+ expect(
+ getNavigatorTargetsFromEditorState(
+ renderResult.getEditorState().editor,
+ ).navigatorTargets.map(navigatorEntryToKey),
+ ).toEqual([
+ 'regular-utopia-storyboard-uid/scene-aaa',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/bbb',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/bbb/ccc',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/bbb/ddd',
+ ])
+ })
+
+ it('the paste session ends on selection change', async () => {
+ const renderResult = await setupPasteSession()
+ expect(renderResult.getEditorState().postActionInteractionSession).not.toBeNull()
+
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa/bbb')])
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(renderResult.getEditorState().postActionInteractionSession).toBeNull()
+ expectResultsToBeCommitted(renderResult)
+ expect(renderResult.getEditorState().editor.selectedViews.map(EP.toString)).toEqual([
+ 'utopia-storyboard-uid/scene-aaa/app-entity:aaa/bbb',
+ ])
+ })
+
+ it('the paste session ends on keydown', async () => {
+ const renderResult = await setupPasteSession()
+ expect(renderResult.getEditorState().postActionInteractionSession).not.toBeNull()
+
+ keyDown('Esc')
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(renderResult.getEditorState().postActionInteractionSession).toBeNull()
+ expectResultsToBeCommitted(renderResult)
+ expect(renderResult.getEditorState().editor.selectedViews.map(EP.toString)).toEqual([
+ 'utopia-storyboard-uid/scene-aaa/app-entity',
+ ])
+ })
+
+ it('the paste session ends on a new paste event', async () => {
+ const renderResult = await setupPasteSession()
+ clipboardMock.resetDoneSignal()
+ const canvasRoot = renderResult.renderedDOM.getByTestId('canvas-root')
+
+ firePasteEvent(canvasRoot)
+
+ await clipboardMock.pasteDone
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(renderResult.getEditorState().postActionInteractionSession).not.toBeNull()
+ expect(
+ getNavigatorTargetsFromEditorState(
+ renderResult.getEditorState().editor,
+ ).navigatorTargets.map(navigatorEntryToKey),
+ ).toEqual([
+ 'regular-utopia-storyboard-uid/scene-aaa',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/bbb',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/bbb/ccc',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/bbb/ddd',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/aat', // <- the pasted element
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/aat/aai',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/aat/aao',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/abi',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/abi/aax',
+ 'regular-utopia-storyboard-uid/scene-aaa/app-entity:aaa/abi/abd',
+ ])
+ })
+ })
+
+ xdescribe('Pasting with steganography enabled', () => {
+ it('steganography data is cleaned from replaced props', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Storyboard, Scene } from 'utopia-api'
+
+ const MyComponent = ({ title }) => {
+ return heeeello
+ }
+
+ const hello = 'hello'
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString('sb/scene/component')])
+ await pressKey('c', { modifiers: cmdModifier })
+ await runPaste(editor)
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
+ formatTestProjectCode(`import * as React from 'react'
+ import { Storyboard, Scene } from 'utopia-api'
+
+ const MyComponent = ({ title }) => {
+ return heeeello
+ }
+
+ const hello = 'hello'
+
+ export var storyboard = (
+
+
+
+
+
+
+ )
+`),
+ )
+ })
+ })
+ })
+
+ describe('PASTE_HERE', () => {
+ const clipboardMock = new MockClipboardHandlers().mock()
+
+ it(`Paste here can place an element on the empty canvas`, async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa/bbb/ccc')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ const canvasControlsLayer = renderResult.renderedDOM.getByTestId(CanvasControlsContainerID)
+ const element = renderResult.renderedDOM.getByTestId('aaa')
+ const elementBounds = element.getBoundingClientRect()
+
+ await mouseClickAtPoint(canvasControlsLayer, elementBounds)
+
+ const targetPoint = {
+ x: elementBounds.x + elementBounds.width + 20,
+ y: elementBounds.y,
+ } // empty canvas
+ await openContextMenuAndClickOnItem(
+ renderResult,
+ canvasControlsLayer,
+ targetPoint,
+ 'Paste Here',
+ )
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(`
+ import * as React from 'react'
+ import { Scene, Storyboard, View, Group } from 'utopia-api'
+
+ export var App = (props) => {
+ return (${testCode})
+ }
+
+ export var ${BakedInStoryboardVariableName} = (props) => {
+ return (
+
+
+
+
+
+
+ )
+ }
+ `),
+ )
+ })
+ it(`Paste here inserts an element to the root div when targeting an element deeper in the hierarchy`, async () => {
+ const testCode = (expected: string) => `
+
+ `
+ const expectedDiv = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode('')),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, [makeTargetPath('aaa/bbb/ccc')])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ const canvasControlsLayer = renderResult.renderedDOM.getByTestId(CanvasControlsContainerID)
+
+ const element = renderResult.renderedDOM.getByTestId('ddd')
+ const elementCenter = getDomRectCenter(element.getBoundingClientRect())
+ await mouseClickAtPoint(canvasControlsLayer, elementCenter)
+
+ await openContextMenuAndClickOnItem(
+ renderResult,
+ canvasControlsLayer,
+ elementCenter,
+ 'Paste Here',
+ )
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(`
+ import * as React from 'react'
+ import { Scene, Storyboard, View, Group } from 'utopia-api'
+
+ export var App = (props) => {
+ return (${testCode(expectedDiv)})
+ }
+
+ export var ${BakedInStoryboardVariableName} = (props) => {
+ return (
+
+
+
+
+
+ )
+ }
+ `),
+ )
+ })
+ it(`Paste here inserts an element to the Scene if there are multiple elements in a Scene`, async () => {
+ const renderResult = await renderTestEditorWithCode(
+ formatTestProjectCode(`
+ import * as React from 'react'
+ import { Scene, Storyboard, View, Group } from 'utopia-api'
+
+ export var App = (props) => {
+ return (
+
+ hello
+
+ )
+ }
+
+ export var ${BakedInStoryboardVariableName} = (props) => {
+ return (
+
+
+
+
+
+
+ )
+ }
+ `),
+ 'await-first-dom-report',
+ )
+ await selectComponentsForTest(renderResult, [
+ EP.fromString(`${BakedInStoryboardUID}/${TestSceneUID}/aaa/bbb/ddd`),
+ ])
+ await pressKey('c', { modifiers: cmdModifier })
+
+ const canvasControlsLayer = renderResult.renderedDOM.getByTestId(CanvasControlsContainerID)
+ const element = renderResult.renderedDOM.getByTestId('ddd')
+ const elementBounds = element.getBoundingClientRect()
+
+ await mouseClickAtPoint(canvasControlsLayer, elementBounds)
+
+ await openContextMenuAndClickOnItem(
+ renderResult,
+ canvasControlsLayer,
+ elementBounds,
+ 'Paste Here',
+ )
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(`
+ import * as React from 'react'
+ import { Scene, Storyboard, View, Group } from 'utopia-api'
+
+ export var App = (props) => {
+ return (
+
+ hello
+
+ )
+ }
+
+ export var ${BakedInStoryboardVariableName} = (props) => {
+ return (
+
+
+
+
+
+
+
+ )
+ }
+ `),
+ )
+ })
+ describe('post action menu for paste', () => {
+ it('copy and paste an element with code', async () => {
+ const editor = await renderTestEditorWithCode(
+ `import * as React from 'react'
+ import { Scene, Storyboard } from 'utopia-api'
+
+ const width = 88
+
+ const App = () => {
+ const width = 44
+
+ return (
+
+ )
+ }
+
+ export var storyboard = (
+
+
+
+
+
+ )
+ `,
+ 'await-first-dom-report',
+ )
+
+ await selectComponentsForTest(editor, [EP.fromString(`sb/scene/app:root`)])
+
+ await expectNoAction(editor, () => pressKey('c', { modifiers: cmdModifier }))
+
+ await selectComponentsForTest(editor, [])
+
+ const canvasControlsLayer = editor.renderedDOM.getByTestId(CanvasControlsContainerID)
+ const element = editor.renderedDOM.getByTestId('scene')
+ const elementBounds = element.getBoundingClientRect()
+
+ await mouseClickAtPoint(canvasControlsLayer, elementBounds)
+
+ const targetPoint = {
+ x: elementBounds.x + elementBounds.width + 20,
+ y: elementBounds.y,
+ } // empty canvas
+
+ await openContextMenuAndClickOnItem(editor, canvasControlsLayer, targetPoint, 'Paste Here')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(editor.getEditorState().postActionInteractionSession?.activeChoiceId).toEqual(
+ PropsReplacedPasteHerePostActionChoiceId,
+ )
+
+ // open the post-action menu
+ const floatingPostActionMenu = editor.renderedDOM.getByTestId(FloatingPostActionMenuTestId)
+ await mouseClickAtPoint(floatingPostActionMenu, { x: 2, y: 2 })
+
+ await pressKey('2')
+ await editor.getDispatchFollowUpActionsFinished()
+
+ expect(editor.getEditorState().postActionInteractionSession?.activeChoiceId).toEqual(
+ PropsPreservedPasteHerePostActionChoiceId,
+ )
+
+ expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(`import * as React from 'react'
+import { Scene, Storyboard } from 'utopia-api'
+
+const width = 88
+
+const App = () => {
+ const width = 44
+
+ return (
+
+ )
+}
+
+export var storyboard = (
+
+
+
+
+
+
+)
+`)
+ })
+ })
+ })
+ describe('UNWRAP_ELEMENTS', () => {
+ it(`Unwraps a fragment-like element`, async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/bbb')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(
+ ``,
+ ),
+ )
+ })
+ it(`Unwraps an absolute element and keeps the visual position of its children`, async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/bbb')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(
+ ``,
+ ),
+ )
+ })
+ it(`Unwraps a flex element`, async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/bbb')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(
+ ``,
+ ),
+ )
+ })
+ it(`Doesn't unwrap an image, as it cannot have child elements, no changes in the code result`, async () => {
+ const testCode = `
+
+

+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/bbb')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(
+ `
+

+
`,
+ ),
+ )
+ })
+ it(`Unwrap on an element without children deletes the element`, async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/bbb')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(
+ ``,
+ ),
+ )
+ })
+ it('can do multiselect unwrap', async () => {
+ const testCode = `
+
+
+
+
+
+
+
+ {
+ // @utopia/uid=cond
+ true ?
:
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch(
+ [
+ unwrapElements([
+ makeTargetPath('aaa/unwrap-div'),
+ makeTargetPath('aaa/unwrap-view'),
+ makeTargetPath('aaa/nested/fragment'),
+ makeTargetPath('aaa/cond'),
+ ]),
+ ],
+ true,
+ )
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+ it('when doing multiselect unwrap for a subtree, predictably limit to the topmost ancestor', async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch(
+ [unwrapElements([makeTargetPath('aaa/group1'), makeTargetPath('aaa/group1/div/group2')])],
+ true,
+ )
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+ it('when doing multiselect unwrap for a subtree, predictably limit to the topmost ancestor (immediate child)', async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch(
+ [unwrapElements([makeTargetPath('aaa/group1'), makeTargetPath('aaa/group1/div')])],
+ true,
+ )
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+ it('can do multiselect unwrap with subtrees and isolate elements', async () => {
+ const testCode = `
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch(
+ [
+ unwrapElements([
+ makeTargetPath('aaa/group1'),
+ makeTargetPath('aaa/group1/div'),
+ makeTargetPath('aaa/another-div/unwrap-this'),
+ ]),
+ ],
+ true,
+ )
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+
+
+
+
+
+
+
+

+
+
+ `),
+ )
+ })
+ it(`Unwraps a fragment`, async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/fragment')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+ describe('conditionals', () => {
+ it(`Unwraps a conditional`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true ?
foo
:
bar
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/conditional')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+ it(`Unwraps a conditional (false)`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ false ?
foo
:
bar
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/conditional')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+ it(`Unwraps a conditional (override)`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ // @utopia/conditional=false
+ true ?
foo
:
bar
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/conditional')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+ it(`Unwraps a conditional with inline content`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true ? 'hello' : 'goodbye'
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/conditional')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ hello
+
+ `),
+ )
+ })
+ it(`Unwraps a conditional containing a conditional`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true ? true ?
foo
:
bar
:
baz
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/conditional')])], true)
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {
+ true ? (
+
foo
+ ): (
+
bar
+ )
+ }
+
+ `),
+ )
+ })
+ it(`Unwraps a conditional inside a conditional`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true
+ ? true /* @utopia/uid=conditional2 */ ?
foo
:
bar
+ :
baz
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch(
+ [unwrapElements([makeTargetPath('aaa/conditional/conditional2')])],
+ true,
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+
foo
+ ): (
+
baz
+ )
+ }
+
+ `),
+ )
+ })
+ it(`Unwraps a conditional inside a conditional with literal content`, async () => {
+ const testCode = `
+
+ {
+ // @utopia/uid=conditional
+ true
+ ? true /* @utopia/uid=conditional2 */ ? 'foo' :
'bar'
+ :
baz
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch(
+ [unwrapElements([makeTargetPath('aaa/conditional/conditional2')])],
+ true,
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ {
+ // @utopia/uid=conditional
+ true ? (
+ 'foo'
+ ): (
+
baz
+ )
+ }
+
+ `),
+ )
+ })
+ })
+ describe('groups', () => {
+ it('makes sure unwrapped children have pins and keep their frame intact', async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/group/unwrap-me')])], true)
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ })
+ it('selects all unwrapped children on multiselect unwrap', async () => {
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch(
+ [unwrapElements([makeTargetPath('aaa/group1'), makeTargetPath('aaa/group2')])],
+ true,
+ )
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+
+ const selection = [...renderResult.getEditorState().editor.selectedViews].sort(
+ comparePathStrings,
+ )
+ expect(selection).toEqual(
+ [
+ makeTargetPath('aaa/foo'),
+ makeTargetPath('aaa/bar'),
+ makeTargetPath('aaa/baz'),
+ makeTargetPath('aaa/qux'),
+ makeTargetPath('aaa/waldo'),
+ ].sort(comparePathStrings),
+ )
+ })
+ })
+ })
+
+ describe('WRAP_IN_ELEMENT', () => {
+ it(`Wraps 2 elements`, async () => {
+ const testUID = 'bbb'
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(
+ renderResult,
+ [makeTargetPath('aaa/ccc'), makeTargetPath('aaa/ddd')],
+ testUID,
+ 'div',
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(
+ ``,
+ ),
+ )
+ expect(renderResult.getEditorState().editor.selectedViews.map(EP.toString)).toEqual([
+ `utopia-storyboard-uid/scene-aaa/app-entity:aaa/${testUID}`,
+ ])
+ })
+ it(`Wraps 2 elements inside a flex layout`, async () => {
+ const testUID = 'zzz'
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(
+ renderResult,
+ [makeTargetPath('aaa/bbb/ddd'), makeTargetPath('aaa/bbb/eee')],
+ testUID,
+ 'div',
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(
+ ``,
+ ),
+ )
+ })
+ it(`Wraps 2 elements with a fragment`, async () => {
+ const testUID = 'zzz'
+ const testCode = `
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(
+ renderResult,
+ [makeTargetPath('aaa/ccc'), makeTargetPath('aaa/ddd')],
+ testUID,
+ 'Fragment',
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(
+ ` `,
+ ),
+ )
+ })
+ describe('groups', () => {
+ it('cannot wrap an empty group', async () => {
+ const testCode = `
+
+
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(renderResult, [makeTargetPath('aaa/group')], 'foo', 'Group')
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+
+
+ `),
+ )
+ expect(renderResult.getEditorState().editor.toasts.length).toEqual(1)
+ expect(renderResult.getEditorState().editor.toasts[0].message).toEqual(
+ 'Empty Groups cannot be wrapped',
+ )
+ })
+
+ it('can group conditionals', async () => {
+ const testCode = `
+
+
+ {
+ // @utopia/uid=cond
+ true ?
: null
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ formatTestProjectCode(makeTestProjectCodeWithSnippet(testCode)),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(
+ renderResult,
+ [makeTargetPath('aaa/foo'), makeTargetPath('aaa/cond')],
+ 'grp',
+ 'Group',
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(
+ makeTestProjectCodeWithSnippet(`
+
+
+
+ {
+ // @utopia/uid=cond
+ true ? : null
+ }
+
+
+ `),
+ ),
+ )
+
+ expect(renderResult.getEditorState().editor.toasts).toHaveLength(1)
+ const firstToast = safeIndex(renderResult.getEditorState().editor.toasts, 0)
+ expect(firstToast?.level).toEqual('INFO')
+ expect(firstToast?.message).toEqual(
+ "Added `contain: 'layout'` to the parent of the newly added element.",
+ )
+ })
+
+ it('cannot group empty conditionals', async () => {
+ const testCode = `
+
+
+ {
+ // @utopia/uid=cond
+ true ? null : null
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ formatTestProjectCode(makeTestProjectCodeWithSnippet(testCode)),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(
+ renderResult,
+ [makeTargetPath('aaa/foo'), makeTargetPath('aaa/cond')],
+ 'grp',
+ 'Group',
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(
+ makeTestProjectCodeWithSnippet(`
+
+
+ {
+ // @utopia/uid=cond
+ true ? null : null
+ }
+
+ `),
+ ),
+ )
+
+ expect(renderResult.getEditorState().editor.toasts.length).toEqual(1)
+ expect(renderResult.getEditorState().editor.toasts[0].message).toEqual(
+ 'Not all targets can be wrapped into a Group',
+ )
+ })
+
+ it('cannot group conditionals with active branch that cannot be a group child', async () => {
+ const testCode = `
+
+
+ {
+ // @utopia/uid=cond
+ true ? 42 : null
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ formatTestProjectCode(makeTestProjectCodeWithSnippet(testCode)),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(
+ renderResult,
+ [makeTargetPath('aaa/foo'), makeTargetPath('aaa/cond')],
+ 'grp',
+ 'Group',
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(
+ makeTestProjectCodeWithSnippet(`
+
+
+ {
+ // @utopia/uid=cond
+ true ? 42 : null
+ }
+
+ `),
+ ),
+ )
+
+ expect(renderResult.getEditorState().editor.toasts.length).toEqual(1)
+ expect(renderResult.getEditorState().editor.toasts[0].message).toEqual(
+ 'Not all targets can be wrapped into a Group',
+ )
+ })
+
+ it('can wrap nested conditionals', async () => {
+ const testCode = `
+
+
+ {
+ // @utopia/uid=cond1
+ true ? (
+ // @utopia/uid=cond2
+ true ? (
+
+ ) : null
+ ) : null
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ formatTestProjectCode(makeTestProjectCodeWithSnippet(testCode)),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(
+ renderResult,
+ [makeTargetPath('aaa/foo'), makeTargetPath('aaa/cond1')],
+ 'grp',
+ 'Group',
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(
+ makeTestProjectCodeWithSnippet(`
+
+
+
+ {
+ // @utopia/uid=cond1
+ true ? (
+ // @utopia/uid=cond2
+ true ? (
+
+ ) : null
+ ) : null
+ }
+
+
+ `),
+ ),
+ )
+
+ expect(renderResult.getEditorState().editor.toasts).toHaveLength(1)
+ const firstToast = safeIndex(renderResult.getEditorState().editor.toasts, 0)
+ expect(firstToast?.level).toEqual('INFO')
+ expect(firstToast?.message).toEqual(
+ "Added `contain: 'layout'` to the parent of the newly added element.",
+ )
+ })
+
+ it(`doesn't wrap nested conditionals with invalid group child in the active branch`, async () => {
+ const testCode = `
+
+
+ {
+ // @utopia/uid=cond1
+ true ? (
+ // @utopia/uid=cond2
+ true ? (
+ 42
+ ) : null
+ ) : null
+ }
+
+ `
+ const renderResult = await renderTestEditorWithCode(
+ formatTestProjectCode(makeTestProjectCodeWithSnippet(testCode)),
+ 'await-first-dom-report',
+ )
+
+ await wrapInElement(
+ renderResult,
+ [makeTargetPath('aaa/foo'), makeTargetPath('aaa/cond1')],
+ 'grp',
+ 'Group',
+ )
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ formatTestProjectCode(
+ makeTestProjectCodeWithSnippet(`
+
+
+ {
+ // @utopia/uid=cond1
+ true ? (
+ // @utopia/uid=cond2
+ true ? (
+ 42
+ ) : null
+ ) : null
+ }
+
+ `),
+ ),
+ )
+
+ expect(renderResult.getEditorState().editor.toasts.length).toEqual(1)
+ expect(renderResult.getEditorState().editor.toasts[0].message).toEqual(
+ 'Not all targets can be wrapped into a Group',
+ )
+ })
+ })
+ })
+ describe('SELECT_COMPONENTS', () => {
+ it('Can not select the same element path twice', async () => {
+ const testCode = ``
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(testCode),
+ 'await-first-dom-report',
+ )
+ await renderResult.dispatch(
+ [selectComponents([makeTargetPath('aaa'), makeTargetPath('aaa')], false)],
+ true,
+ )
+ await renderResult.getDispatchFollowUpActionsFinished()
+ expect(renderResult.getEditorState().editor.selectedViews).toEqual([makeTargetPath('aaa')])
+ })
+ describe('setting the left pane when selection changes', () => {
+ const deselectActions = (renderResult: EditorRenderResult) => [
+ async () => renderResult.dispatch([selectComponents([], false)], true),
+ async () => renderResult.dispatch([clearSelection()], true),
+ async () =>
+ renderResult.dispatch([applyCommandsAction([updateSelectedViews('always', [])])], true),
+ ]
+
+ const selectActions = (renderResult: EditorRenderResult) => [
+ async (paths: ElementPath[]) =>
+ renderResult.dispatch([selectComponents(paths, false)], false),
+ async (paths: ElementPath[]) =>
+ renderResult.dispatch(
+ [applyCommandsAction([updateSelectedViews('always', paths)])],
+ true,
+ ),
+ ]
+
+ it('does not jump when all elements are deselected', async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(`
+ `),
+ 'await-first-dom-report',
+ )
+
+ const entryPoints = deselectActions(renderResult)
+
+ const pages = [LeftMenuTab.Github, LeftMenuTab.Pages, LeftMenuTab.Project]
+
+ for await (const page of pages) {
+ for await (const deselect of entryPoints) {
+ await selectComponentsForTest(renderResult, [
+ EP.appendNewElementPath(TestScenePath, ['root', 'child1']),
+ ])
+ await renderResult.dispatch([setLeftMenuTab(page)], true)
+ await deselect()
+ expect(renderResult.getEditorState().editor.selectedViews.map(EP.toString)).toEqual([])
+
+ // stays on the same page
+ expect(renderResult.getEditorState().editor.leftMenu.selectedTab).toEqual(page)
+ }
+ }
+ })
+
+ it('does not jump when an element is selected and the pages tab is selected', async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(`
+ `),
+ 'await-first-dom-report',
+ )
+
+ const entryPoints = selectActions(renderResult)
+
+ for await (const select of entryPoints) {
+ await selectComponentsForTest(renderResult, [
+ EP.appendNewElementPath(TestScenePath, ['root', 'child1']),
+ ])
+ await renderResult.dispatch([setLeftMenuTab(LeftMenuTab.Pages)], true)
+ const pathToSelect = EP.appendNewElementPath(TestScenePath, ['root', 'child2'])
+ await select([pathToSelect])
+ expect(renderResult.getEditorState().editor.selectedViews.map(EP.toString)).toEqual([
+ EP.toString(pathToSelect),
+ ])
+
+ // stays on the pages tab
+ expect(renderResult.getEditorState().editor.leftMenu.selectedTab).toEqual(
+ LeftMenuTab.Pages,
+ )
+ }
+ })
+
+ it('jumps when an element is selected', async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(`
+ `),
+ 'await-first-dom-report',
+ )
+
+ const entryPoints = selectActions(renderResult)
+
+ const pages = [
+ { start: LeftMenuTab.Github, end: LeftMenuTab.Navigator },
+ { start: LeftMenuTab.Project, end: LeftMenuTab.Navigator },
+ { start: LeftMenuTab.Navigator, end: LeftMenuTab.Navigator },
+ { start: LeftMenuTab.UIInsert, end: LeftMenuTab.Navigator },
+
+ { start: LeftMenuTab.Pages, end: LeftMenuTab.Pages }, // this doesn't jump, but it's included for completeness
+ ]
+
+ for await (const { start, end } of pages) {
+ for await (const select of entryPoints) {
+ await selectComponentsForTest(renderResult, [
+ EP.appendNewElementPath(TestScenePath, ['root', 'child1']),
+ ])
+ await renderResult.dispatch([setLeftMenuTab(start)], true)
+ const pathToSelect = EP.appendNewElementPath(TestScenePath, ['root', 'child2'])
+ await select([pathToSelect])
+ expect(renderResult.getEditorState().editor.selectedViews.map(EP.toString)).toEqual([
+ EP.toString(pathToSelect),
+ ])
+ expect(renderResult.getEditorState().editor.leftMenu.selectedTab).toEqual(end)
+ }
+ }
+ })
+ })
+ })
+
+ describe('TRUNCATE_HISTORY', () => {
+ it('truncates history', async () => {
+ const renderResult = await renderTestEditorWithCode(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ 'await-first-dom-report',
+ )
+
+ // these go into the undo stack
+ await renderResult.dispatch(
+ [deleteView(EP.fromString('utopia-storyboard-uid/scene-aaa/app-entity:root/bar'))],
+ true,
+ )
+ await renderResult.dispatch(
+ [deleteView(EP.fromString('utopia-storyboard-uid/scene-aaa/app-entity:root/foo'))],
+ true,
+ )
+ expect(renderResult.getEditorState().history.next.length).toBe(0)
+ expect(renderResult.getEditorState().history.previous.length).toBe(2)
+
+ // truncate the stack
+ await renderResult.dispatch([truncateHistory()], true)
+ expect(renderResult.getEditorState().history.next.length).toBe(0)
+ expect(renderResult.getEditorState().history.previous.length).toBe(0)
+
+ // try to undo, nothing happens
+ await renderResult.dispatch([undo()], true)
+ expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual(
+ makeTestProjectCodeWithSnippet(`
+
+ `),
+ )
+ expect(renderResult.getEditorState().history.next.length).toBe(0)
+ expect(renderResult.getEditorState().history.previous.length).toBe(0)
+ })
+ })
+})
+
+function comparePathStrings(a: ElementPath, b: ElementPath): number {
+ return EP.toString(a).localeCompare(EP.toString(b))
+}
+
+async function wrapInElement(
+ renderResult: EditorRenderResult,
+ pathsToWrap: ElementPath[],
+ uid: string,
+ query: string,
+) {
+ await selectComponentsForTest(renderResult, pathsToWrap)
+ await pressKey('w') // open the wrap menu
+ FOR_TESTS_setNextGeneratedUid(uid)
+ await searchInComponentPicker(renderResult, query)
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/actions.spec.tsx b/nexus-builder/packages/core/src/components/editor/actions/actions.spec.tsx
new file mode 100644
index 000000000000..037c806da975
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/actions.spec.tsx
@@ -0,0 +1,1272 @@
+import * as Chai from 'chai'
+import { contentsTreeOptic, walkContentsTree } from '../../../components/assets'
+import { getLayoutPropertyOr } from '../../../core/layout/getLayoutProperty'
+import {
+ emptyTextFile,
+ getFileWithCssImport,
+ sampleCode,
+} from '../../../core/model/new-project-files'
+import {
+ BakedInStoryboardUID,
+ BakedInStoryboardVariableName,
+} from '../../../core/model/scene-utils'
+import {
+ ScenePathForTestUiJsFile,
+ sampleImportsForTests,
+} from '../../../core/model/test-ui-js-file.test-utils'
+import { mapEither, right } from '../../../core/shared/either'
+import * as EP from '../../../core/shared/element-path'
+import type {
+ ElementInstanceMetadataMap,
+ JSXAttributes,
+ JSXElement,
+ TopLevelElement,
+ UtopiaJSXComponent,
+} from '../../../core/shared/element-template'
+import {
+ clearExpressionUniqueIDs,
+ defaultPropsParam,
+ elementInstanceMetadata,
+ emptyAttributeMetadata,
+ emptyComments,
+ emptyComputedStyle,
+ emptySpecialSizeMeasurements,
+ isUtopiaJSXComponent,
+ jsExpressionValue,
+ jsxAttributeNestedObjectSimple,
+ jsxAttributesFromMap,
+ jsxElement,
+ jsxElementName,
+ unparsedCode,
+ utopiaJSXComponent,
+} from '../../../core/shared/element-template'
+import { clearModifiableAttributeUniqueIDs } from '../../../core/shared/jsx-attributes'
+import { getModifiableJSXAttributeAtPath } from '../../../core/shared/jsx-attribute-utils'
+import type { CanvasRectangle, LocalRectangle } from '../../../core/shared/math-utils'
+import { canvasRectangle, zeroRectangle } from '../../../core/shared/math-utils'
+import { resolvedNpmDependency } from '../../../core/shared/npm-dependency-types'
+import { filtered, fromField, fromTypeGuard } from '../../../core/shared/optics/optic-creators'
+import { unsafeGet } from '../../../core/shared/optics/optic-utilities'
+import type { Optic } from '../../../core/shared/optics/optics'
+import { forceNotNull } from '../../../core/shared/optional-utils'
+import type {
+ ParseSuccess,
+ RevisionsStateType,
+ TextFile,
+ TextFileContents,
+} from '../../../core/shared/project-file-types'
+import {
+ RevisionsState,
+ exportFunction,
+ importAlias,
+ importDetails,
+ isParseSuccess,
+ isTextFile,
+ isUnparsed,
+ parseSuccess,
+ textFile,
+ textFileContents,
+ unparsed,
+} from '../../../core/shared/project-file-types'
+import * as PP from '../../../core/shared/property-path'
+import { NO_OP } from '../../../core/shared/utils'
+import { DefaultThirdPartyControlDefinitions } from '../../../core/third-party/third-party-controls'
+import { addImport } from '../../../core/workers/common/project-file-utils'
+import { printCode, printCodeOptions } from '../../../core/workers/parser-printer/parser-printer'
+import {
+ complexDefaultProjectPreParsed,
+ parseProjectContents,
+} from '../../../sample-projects/sample-project-utils.test-utils'
+import { styleStringInArray } from '../../../utils/common-constants'
+import { deepFreeze } from '../../../utils/deep-freeze'
+import Utils from '../../../utils/utils'
+import { createFakeMetadataForComponents } from '../../../utils/utils.test-utils'
+import {
+ contentsToTree,
+ getProjectFileByFilePath,
+ walkContentsTreeForParseSuccess,
+} from '../../assets'
+import { getFrameChange } from '../../canvas/canvas-utils'
+import { generateCodeResultCache } from '../../custom-code/code-file'
+import { cssNumber } from '../../inspector/common/css-utils'
+import { getComponentGroups, insertableComponent } from '../../shared/project-components'
+import type { EditorState, PersistentModel } from '../store/editor-state'
+import {
+ StoryboardFilePath,
+ createEditorState,
+ defaultUserState,
+ deriveState,
+ editorModelFromPersistentModel,
+ emptyCollaborativeEditingSupport,
+ withUnderlyingTargetFromEditorState,
+} from '../store/editor-state'
+import { childInsertionPath } from '../store/insertion-path'
+import { unpatchedCreateRemixDerivedDataMemo } from '../store/remix-derived-data'
+import {
+ insertInsertable,
+ resetCanvas,
+ setCanvasFrames,
+ setFocusedElement,
+ setProp_UNSAFE,
+ updateFilePath,
+ updateFromWorker,
+ updateTopLevelElementsFromCollaborationUpdate,
+ workerCodeAndParsedUpdate,
+} from './action-creators'
+import { UPDATE_FNS, replaceFilePath } from './actions'
+import { CURRENT_PROJECT_VERSION } from './migrations/migrations'
+import { getUidMappings } from '../../../core/model/get-uid-mappings'
+import { simpleDefaultProject } from '../../../sample-projects/sample-project-utils'
+import { InjectedCSSFilePrefix } from '../../../core/webpack-loaders/css-loader'
+import { renderTestEditorWithModel } from '../../../components/canvas/ui-jsx.test-utils'
+
+const chaiExpect = Chai.expect
+
+function storyboardComponent(numberOfScenes: number): UtopiaJSXComponent {
+ let scenes: Array = []
+ for (let sceneIndex = 0; sceneIndex < numberOfScenes; sceneIndex++) {
+ scenes.push(
+ jsxElement(
+ 'Scene',
+ `scene-${sceneIndex}`,
+ jsxAttributesFromMap({
+ 'data-uid': jsExpressionValue(`scene-${sceneIndex}`, emptyComments),
+ }),
+ [
+ jsxElement(
+ `MyView${sceneIndex + 1}`,
+ `main-component-${sceneIndex}`,
+ jsxAttributesFromMap({
+ 'data-uid': jsExpressionValue(`main-component-${sceneIndex}`, emptyComments),
+ style: jsExpressionValue(
+ {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ width: 375,
+ height: 812,
+ },
+ emptyComments,
+ ),
+ }),
+ [],
+ ),
+ ],
+ ),
+ )
+ }
+ return utopiaJSXComponent(
+ BakedInStoryboardVariableName,
+ false,
+ 'var',
+ 'block',
+ [],
+ null,
+ [],
+ jsxElement(
+ 'Storyboard',
+ BakedInStoryboardUID,
+ jsxAttributesFromMap({
+ 'data-uid': jsExpressionValue(BakedInStoryboardUID, emptyComments),
+ }),
+ scenes,
+ ),
+ null,
+ false,
+ emptyComments,
+ )
+}
+
+const originalModel = deepFreeze(
+ parseSuccess(
+ addImport(
+ '/code.js',
+ [],
+ 'utopia-api',
+ null,
+ [importAlias('View'), importAlias('Scene'), importAlias('Storyboard')],
+ null,
+ sampleImportsForTests,
+ ).imports,
+ [
+ utopiaJSXComponent(
+ 'MyView1',
+ true,
+ 'var',
+ 'block',
+ [],
+ defaultPropsParam,
+ [],
+ jsxElement(
+ jsxElementName('View', []),
+ 'aaa',
+ jsxAttributesFromMap({
+ 'data-uid': jsExpressionValue('aaa', emptyComments),
+ }),
+ [
+ jsxElement(
+ jsxElementName('View', []),
+ 'bbb',
+ jsxAttributesFromMap({
+ test: jsxAttributeNestedObjectSimple(
+ jsxAttributesFromMap({ prop: jsExpressionValue(5, emptyComments) }),
+ emptyComments,
+ ),
+ 'data-uid': jsExpressionValue('bbb', emptyComments),
+ }),
+ [],
+ ),
+ ],
+ ),
+ null,
+ false,
+ emptyComments,
+ ),
+ storyboardComponent(1),
+ ],
+ {},
+ null,
+ null,
+ [exportFunction('whatever')],
+ {},
+ ),
+)
+const testEditor: EditorState = deepFreeze({
+ ...createEditorState(NO_OP),
+ projectContents: contentsToTree({
+ [StoryboardFilePath]: textFile(
+ textFileContents('', originalModel, RevisionsState.ParsedAhead),
+ null,
+ originalModel,
+ 0,
+ ),
+ }),
+ jsxMetadata: createFakeMetadataForComponents(originalModel.topLevelElements),
+})
+
+describe('SET_PROP', () => {
+ it('updates a simple value property', () => {
+ const action = setProp_UNSAFE(
+ EP.appendNewElementPath(ScenePathForTestUiJsFile, ['aaa', 'bbb']),
+ PP.create('test', 'prop'),
+ jsExpressionValue(100, emptyComments),
+ )
+ const newEditor = UPDATE_FNS.SET_PROP(action, testEditor)
+ const newUiJsFile = getProjectFileByFilePath(
+ newEditor.projectContents,
+ StoryboardFilePath,
+ ) as TextFile
+ expect(isTextFile(newUiJsFile)).toBeTruthy()
+ expect(isParseSuccess(newUiJsFile.fileContents.parsed)).toBeTruthy()
+ const newTopLevelElements: TopLevelElement[] = (newUiJsFile.fileContents.parsed as ParseSuccess)
+ .topLevelElements
+ const updatedRoot = newTopLevelElements[0] as UtopiaJSXComponent
+ expect(isUtopiaJSXComponent(updatedRoot)).toBeTruthy()
+ const updatedViewProps = Utils.pathOr(
+ [],
+ ['rootElement', 'children', 0, 'props'],
+ updatedRoot,
+ )
+ const updatedTestProp = getModifiableJSXAttributeAtPath(
+ updatedViewProps,
+ PP.create('test', 'prop'),
+ )
+ chaiExpect(mapEither(clearModifiableAttributeUniqueIDs, updatedTestProp)).to.deep.equal(
+ right(clearExpressionUniqueIDs(jsExpressionValue(100, emptyComments))),
+ )
+ })
+})
+
+describe('RESET_CANVAS', () => {
+ it('keeps css imports when resetting the canvas', async () => {
+ const action = resetCanvas()
+ const project = simpleDefaultProject({
+ storyboardFile: getFileWithCssImport(),
+ additionalFiles: { '/src/app.css': emptyTextFile() },
+ })
+ const updatedProjectContents = parseProjectContents(project.projectContents)
+
+ const projectPreParsed = {
+ ...project,
+ projectContents: updatedProjectContents,
+ }
+
+ const editor = await renderTestEditorWithModel(projectPreParsed, 'await-first-dom-report')
+ expect(document.getElementById(`${InjectedCSSFilePrefix}/src/app.css`)).not.toBeNull()
+ const newEditor = UPDATE_FNS.RESET_CANVAS(action, editor.getEditorState().editor)
+ expect(document.getElementById(`${InjectedCSSFilePrefix}/src/app.css`)).not.toBeNull()
+ })
+})
+
+describe('SET_CANVAS_FRAMES', () => {
+ it('Updates the frame of the child correctly', () => {
+ const action = setCanvasFrames(
+ [
+ getFrameChange(
+ EP.appendNewElementPath(ScenePathForTestUiJsFile, ['aaa', 'bbb']),
+ canvasRectangle({ x: 20, y: 20, width: 50, height: 50 }),
+ false,
+ ),
+ ],
+ false,
+ )
+ const newEditor = UPDATE_FNS.SET_CANVAS_FRAMES(action, testEditor)
+ const newUiJsFile = getProjectFileByFilePath(
+ newEditor.projectContents,
+ StoryboardFilePath,
+ ) as TextFile
+ expect(isTextFile(newUiJsFile)).toBeTruthy()
+ expect(isParseSuccess(newUiJsFile.fileContents.parsed)).toBeTruthy()
+ const newTopLevelElements: TopLevelElement[] = (newUiJsFile.fileContents.parsed as ParseSuccess)
+ .topLevelElements
+ const updatedRoot = newTopLevelElements[0] as UtopiaJSXComponent
+ expect(isUtopiaJSXComponent(updatedRoot)).toBeTruthy()
+ const updatedViewProps = Utils.pathOr(
+ [],
+ ['rootElement', 'children', 0, 'props'],
+ updatedRoot,
+ )
+ const leftProp = getLayoutPropertyOr(
+ undefined,
+ 'left',
+ right(updatedViewProps),
+ styleStringInArray,
+ )
+ const top = getLayoutPropertyOr(undefined, 'top', right(updatedViewProps), styleStringInArray)
+ const width = getLayoutPropertyOr(
+ undefined,
+ 'width',
+ right(updatedViewProps),
+ styleStringInArray,
+ )
+ const height = getLayoutPropertyOr(
+ undefined,
+ 'height',
+ right(updatedViewProps),
+ styleStringInArray,
+ )
+ chaiExpect(leftProp).to.deep.equal(cssNumber(20))
+ chaiExpect(top).to.deep.equal(cssNumber(20))
+ chaiExpect(width).to.deep.equal(cssNumber(50))
+ chaiExpect(height).to.deep.equal(cssNumber(50))
+ })
+})
+
+describe('LOAD', () => {
+ it('Parses all UIJS files and bins any previously stored parsed model data', () => {
+ const firstUIJSFile = StoryboardFilePath
+ const secondUIJSFile = '/src/some/other/file.js'
+ const initialFileContents: TextFileContents = textFileContents(
+ sampleCode,
+ unparsed,
+ RevisionsState.CodeAhead,
+ )
+ const loadedModel: PersistentModel = {
+ appID: null,
+ forkedFromProjectId: null,
+ projectVersion: CURRENT_PROJECT_VERSION,
+ projectDescription: '',
+ projectContents: contentsToTree({
+ [firstUIJSFile]: textFile(initialFileContents, null, null, 0),
+ [secondUIJSFile]: textFile(initialFileContents, null, null, 0),
+ }),
+ exportsInfo: [],
+ codeEditorErrors: {
+ buildErrors: {},
+ lintErrors: {},
+ componentDescriptorErrors: {},
+ },
+ lastUsedFont: null,
+ hiddenInstances: [],
+ fileBrowser: {
+ minimised: false,
+ },
+ dependencyList: {
+ minimised: false,
+ },
+ projectSettings: {
+ minimised: false,
+ },
+ navigator: {
+ minimised: false,
+ },
+ githubSettings: {
+ targetRepository: null,
+ originCommit: null,
+ branchName: null,
+ pendingCommit: null,
+ branchLoaded: false,
+ },
+ colorSwatches: [],
+ }
+
+ const action = {
+ action: 'LOAD' as const,
+ model: loadedModel,
+ nodeModules: {},
+ packageResult: {},
+ codeResultCache: generateCodeResultCache({}, {}, [], {}, NO_OP, {}, []),
+ title: '',
+ projectId: '',
+ storedState: null,
+ safeMode: false,
+ }
+
+ const startingState = deepFreeze(createEditorState(NO_OP))
+ const result = UPDATE_FNS.LOAD(action, startingState, NO_OP, emptyCollaborativeEditingSupport())
+ const newFirstFileContents = (
+ getProjectFileByFilePath(result.projectContents, firstUIJSFile) as TextFile
+ ).fileContents
+ expect(isUnparsed(newFirstFileContents.parsed)).toBeTruthy()
+ expect(newFirstFileContents.code).toEqual(initialFileContents.code)
+ const newSecondFileContents = (
+ getProjectFileByFilePath(result.projectContents, secondUIJSFile) as TextFile
+ ).fileContents
+ expect(isUnparsed(newSecondFileContents.parsed)).toBeTruthy()
+ expect(newSecondFileContents.code).toEqual(initialFileContents.code)
+ })
+})
+
+describe('UPDATE_FILE_PATH', () => {
+ it('updates the files in a directory and imports related to it', () => {
+ const project = complexDefaultProjectPreParsed()
+ const editorState = editorModelFromPersistentModel(project, NO_OP)
+ const actualResult = UPDATE_FNS.UPDATE_FILE_PATH(
+ updateFilePath('/src', '/src2'),
+ editorState,
+ defaultUserState,
+ )
+ let filesAndTheirImports: { [filename: string]: Array } = {}
+ walkContentsTreeForParseSuccess(actualResult.projectContents, (fullPath, success) => {
+ filesAndTheirImports[fullPath] = Object.keys(success.imports).sort()
+ })
+ expect(filesAndTheirImports).toMatchInlineSnapshot(`
+ Object {
+ "/src2/app.js": Array [
+ "/src2/card.js",
+ "react",
+ ],
+ "/src2/card.js": Array [
+ "non-existant-dummy-library",
+ "react",
+ ],
+ "/src2/index.js": Array [
+ "./app.js",
+ "react",
+ "react-dom",
+ ],
+ "/utopia/storyboard.js": Array [
+ "/src2/app.js",
+ "react",
+ "utopia-api",
+ ],
+ }
+ `)
+ })
+})
+
+describe('INSERT_INSERTABLE', () => {
+ it('inserts an element into the project with the given values', () => {
+ const project = complexDefaultProjectPreParsed()
+ const editorState = editorModelFromPersistentModel(project, NO_OP)
+ const derivedState = deriveState(
+ editorState,
+ null,
+ 'unpatched',
+ unpatchedCreateRemixDerivedDataMemo,
+ )
+
+ const insertableGroups = getComponentGroups(
+ 'insert',
+ { ['utopia-api']: { status: 'loaded' } },
+ { ['utopia-api']: DefaultThirdPartyControlDefinitions['utopia-api'] },
+ editorState.projectContents,
+ [resolvedNpmDependency('utopia-api', '0.5.1')],
+ StoryboardFilePath,
+ )
+ const utopiaApiGroup = forceNotNull(
+ 'Group should exist.',
+ insertableGroups.find((group) => {
+ return (
+ group.source.type === 'PROJECT_DEPENDENCY_GROUP' &&
+ group.source.dependencyName === 'utopia-api'
+ )
+ }),
+ )
+ const menuInsertable = forceNotNull(
+ 'Component should exist.',
+ utopiaApiGroup.insertableComponents.find((insertable) => {
+ return insertable.name === 'View'
+ }),
+ )
+
+ const targetPath = EP.elementPath([
+ ['storyboard-entity', 'scene-1-entity', 'app-entity'],
+ ['app-outer-div', 'card-instance'],
+ ['card-outer-div'],
+ ])
+
+ const action = insertInsertable(
+ childInsertionPath(targetPath),
+ menuInsertable,
+ 'do-not-add',
+ null,
+ )
+
+ const actualResult = UPDATE_FNS.INSERT_INSERTABLE(action, editorState)
+ const cardFile = getProjectFileByFilePath(actualResult.projectContents, '/src/card.js')
+ if (cardFile != null && isTextFile(cardFile)) {
+ const parsed = cardFile.fileContents.parsed
+ if (isParseSuccess(parsed)) {
+ const printedCode = printCode(
+ '/src/card.js',
+ printCodeOptions(false, true, true, true),
+ parsed.imports,
+ parsed.topLevelElements,
+ parsed.jsxFactoryFunction,
+ parsed.exportsDetail,
+ )
+ expect(printedCode).toMatchInlineSnapshot(`
+ "import * as React from 'react'
+ import { Spring } from 'non-existant-dummy-library'
+ import { View } from 'utopia-api'
+ export var Card = (props) => {
+ return (
+
+ )
+ }
+ "
+ `)
+ } else {
+ throw new Error('File does not contain parse success.')
+ }
+ } else {
+ throw new Error('File is not a text file.')
+ }
+ })
+
+ it('inserts an element into the project with the given values, also adding style props', () => {
+ const project = complexDefaultProjectPreParsed()
+ const editorState = editorModelFromPersistentModel(project, NO_OP)
+ const derivedState = deriveState(
+ editorState,
+ null,
+ 'unpatched',
+ unpatchedCreateRemixDerivedDataMemo,
+ )
+
+ const insertableGroups = getComponentGroups(
+ 'insert',
+ { ['utopia-api']: { status: 'loaded' } },
+ { ['utopia-api']: DefaultThirdPartyControlDefinitions['utopia-api'] },
+ editorState.projectContents,
+ [resolvedNpmDependency('utopia-api', '0.5.1')],
+ StoryboardFilePath,
+ )
+ const utopiaApiGroup = forceNotNull(
+ 'Group should exist.',
+ insertableGroups.find((group) => {
+ return (
+ group.source.type === 'PROJECT_DEPENDENCY_GROUP' &&
+ group.source.dependencyName === 'utopia-api'
+ )
+ }),
+ )
+ const menuInsertable = forceNotNull(
+ 'Component should exist.',
+ utopiaApiGroup.insertableComponents.find((insertable) => {
+ return insertable.name === 'View'
+ }),
+ )
+
+ const targetPath = EP.elementPath([
+ ['storyboard-entity', 'scene-1-entity', 'app-entity'],
+ ['app-outer-div', 'card-instance'],
+ ['card-outer-div'],
+ ])
+
+ const action = insertInsertable(
+ childInsertionPath(targetPath),
+ menuInsertable,
+ 'add-size',
+ null,
+ )
+
+ const actualResult = UPDATE_FNS.INSERT_INSERTABLE(action, editorState)
+ const cardFile = getProjectFileByFilePath(actualResult.projectContents, '/src/card.js')
+ if (cardFile != null && isTextFile(cardFile)) {
+ const parsed = cardFile.fileContents.parsed
+ if (isParseSuccess(parsed)) {
+ const printedCode = printCode(
+ '/src/card.js',
+ printCodeOptions(false, true, true, true),
+ parsed.imports,
+ parsed.topLevelElements,
+ parsed.jsxFactoryFunction,
+ parsed.exportsDetail,
+ )
+ expect(printedCode).toMatchInlineSnapshot(`
+ "import * as React from 'react'
+ import { Spring } from 'non-existant-dummy-library'
+ import { View } from 'utopia-api'
+ export var Card = (props) => {
+ return (
+
+ )
+ }
+ "
+ `)
+ } else {
+ throw new Error('File does not contain parse success.')
+ }
+ } else {
+ throw new Error('File is not a text file.')
+ }
+ })
+
+ it('inserts an element into the project with the given values, and duplicate name, also adding style props', () => {
+ const project = complexDefaultProjectPreParsed('View')
+ const editorState = editorModelFromPersistentModel(project, NO_OP)
+
+ const insertableGroups = getComponentGroups(
+ 'insert',
+ { ['utopia-api']: { status: 'loaded' } },
+ { ['utopia-api']: DefaultThirdPartyControlDefinitions['utopia-api'] },
+ editorState.projectContents,
+ [resolvedNpmDependency('utopia-api', '0.5.1')],
+ StoryboardFilePath,
+ )
+ const utopiaApiGroup = forceNotNull(
+ 'Group should exist.',
+ insertableGroups.find((group) => {
+ return (
+ group.source.type === 'PROJECT_DEPENDENCY_GROUP' &&
+ group.source.dependencyName === 'utopia-api'
+ )
+ }),
+ )
+ const viewInsertable = insertableComponent(
+ {
+ './test.js': importDetails(null, [importAlias('View')], null),
+ },
+ () => jsxElement('View', 'view', jsxAttributesFromMap({}), []),
+ 'View',
+ [],
+ null,
+ { type: 'file-root' },
+ null,
+ )
+
+ const targetPath = EP.elementPath([
+ ['storyboard-entity', 'scene-1-entity', 'app-entity'],
+ ['app-outer-div', 'card-instance'],
+ ['card-outer-div'],
+ ])
+
+ const action = insertInsertable(
+ childInsertionPath(targetPath),
+ viewInsertable,
+ 'add-size',
+ null,
+ )
+
+ const actualResult = UPDATE_FNS.INSERT_INSERTABLE(action, editorState)
+ const cardFile = getProjectFileByFilePath(actualResult.projectContents, '/src/card.js')
+ if (cardFile != null && isTextFile(cardFile)) {
+ const parsed = cardFile.fileContents.parsed
+ if (isParseSuccess(parsed)) {
+ const printedCode = printCode(
+ '/src/card.js',
+ printCodeOptions(false, true, true, true),
+ parsed.imports,
+ parsed.topLevelElements,
+ parsed.jsxFactoryFunction,
+ parsed.exportsDetail,
+ )
+ expect(printedCode).toMatchInlineSnapshot(`
+ "import * as React from 'react'
+ import { View } from 'non-existant-dummy-library'
+ import { View as View_2 } from './test.js'
+ export var Card = (props) => {
+ return (
+
+ )
+ }
+ "
+ `)
+ } else {
+ throw new Error('File does not contain parse success.')
+ }
+ } else {
+ throw new Error('File is not a text file.')
+ }
+ })
+
+ it('inserts an img element into the project, also adding style props', () => {
+ const project = complexDefaultProjectPreParsed()
+ const editorState = editorModelFromPersistentModel(project, NO_OP)
+ const derivedState = deriveState(
+ editorState,
+ null,
+ 'unpatched',
+ unpatchedCreateRemixDerivedDataMemo,
+ )
+
+ const insertableGroups = getComponentGroups(
+ 'insert',
+ {},
+ {},
+ editorState.projectContents,
+ [],
+ StoryboardFilePath,
+ )
+ const htmlGroup = forceNotNull(
+ 'Group should exist.',
+ insertableGroups.find((group) => {
+ return group.source.type === 'HTML_GROUP'
+ }),
+ )
+ const imgInsertable = forceNotNull(
+ 'Component should exist.',
+ htmlGroup.insertableComponents.find((insertable) => {
+ return insertable.name === 'img'
+ }),
+ )
+
+ const targetPath = EP.elementPath([
+ ['storyboard-entity', 'scene-1-entity', 'app-entity'],
+ ['app-outer-div', 'card-instance'],
+ ['card-outer-div'],
+ ])
+
+ const action = insertInsertable(childInsertionPath(targetPath), imgInsertable, 'add-size', null)
+
+ const actualResult = UPDATE_FNS.INSERT_INSERTABLE(action, editorState)
+ const cardFile = getProjectFileByFilePath(actualResult.projectContents, '/src/card.js')
+ if (cardFile != null && isTextFile(cardFile)) {
+ const parsed = cardFile.fileContents.parsed
+ if (isParseSuccess(parsed)) {
+ const printedCode = printCode(
+ '/src/card.js',
+ printCodeOptions(false, true, true, true),
+ parsed.imports,
+ parsed.topLevelElements,
+ parsed.jsxFactoryFunction,
+ parsed.exportsDetail,
+ )
+ expect(printedCode).toMatchInlineSnapshot(`
+ "import * as React from 'react'
+ import { Spring } from 'non-existant-dummy-library'
+ export var Card = (props) => {
+ return (
+
+
+
+

+
+ )
+ }
+ "
+ `)
+ } else {
+ throw new Error('File does not contain parse success.')
+ }
+ } else {
+ throw new Error('File is not a text file.')
+ }
+ })
+
+ it('inserts an img element into the project, also adding style props, added at the back', () => {
+ const project = complexDefaultProjectPreParsed()
+ const editorState = editorModelFromPersistentModel(project, NO_OP)
+ const derivedState = deriveState(
+ editorState,
+ null,
+ 'unpatched',
+ unpatchedCreateRemixDerivedDataMemo,
+ )
+
+ const insertableGroups = getComponentGroups(
+ 'insert',
+ {},
+ {},
+ editorState.projectContents,
+ [],
+ StoryboardFilePath,
+ )
+ const htmlGroup = forceNotNull(
+ 'Group should exist.',
+ insertableGroups.find((group) => {
+ return group.source.type === 'HTML_GROUP'
+ }),
+ )
+ const imgInsertable = forceNotNull(
+ 'Component should exist.',
+ htmlGroup.insertableComponents.find((insertable) => {
+ return insertable.name === 'img'
+ }),
+ )
+
+ const targetPath = EP.elementPath([
+ ['storyboard-entity', 'scene-1-entity', 'app-entity'],
+ ['app-outer-div', 'card-instance'],
+ ['card-outer-div'],
+ ])
+
+ const action = insertInsertable(childInsertionPath(targetPath), imgInsertable, 'add-size', {
+ type: 'back',
+ })
+
+ const actualResult = UPDATE_FNS.INSERT_INSERTABLE(action, editorState)
+ const cardFile = getProjectFileByFilePath(actualResult.projectContents, '/src/card.js')
+ if (cardFile != null && isTextFile(cardFile)) {
+ const parsed = cardFile.fileContents.parsed
+ if (isParseSuccess(parsed)) {
+ const printedCode = printCode(
+ '/src/card.js',
+ printCodeOptions(false, true, true, true),
+ parsed.imports,
+ parsed.topLevelElements,
+ parsed.jsxFactoryFunction,
+ parsed.exportsDetail,
+ )
+ expect(printedCode).toMatchInlineSnapshot(`
+ "import * as React from 'react'
+ import { Spring } from 'non-existant-dummy-library'
+ export var Card = (props) => {
+ return (
+
+

+
+
+
+ )
+ }
+ "
+ `)
+ } else {
+ throw new Error('File does not contain parse success.')
+ }
+ } else {
+ throw new Error('File is not a text file.')
+ }
+ })
+})
+
+describe('SET_FOCUSED_ELEMENT', () => {
+ it('prevents focusing a non-focusable element', () => {
+ const project = complexDefaultProjectPreParsed()
+ let editorState = editorModelFromPersistentModel(project, NO_OP)
+ const derivedState = deriveState(editorState, null, 'unpatched', () => right(null))
+ const pathToFocus = EP.fromString('storyboard-entity/scene-1-entity/app-entity:app-outer-div')
+ const underlyingElement = forceNotNull(
+ 'Should be able to find this.',
+ withUnderlyingTargetFromEditorState(pathToFocus, editorState, null, (_, element) => element),
+ )
+ const divElementMetadata = elementInstanceMetadata(
+ pathToFocus,
+ right(underlyingElement),
+ zeroRectangle as CanvasRectangle,
+ zeroRectangle as CanvasRectangle,
+ false,
+ false,
+ emptySpecialSizeMeasurements,
+ emptyComputedStyle,
+ emptyAttributeMetadata,
+ null,
+ null,
+ 'not-a-conditional',
+ null,
+ null,
+ null,
+ )
+ const fakeMetadata: ElementInstanceMetadataMap = {
+ [EP.toString(pathToFocus)]: divElementMetadata,
+ }
+ editorState = {
+ ...editorState,
+ jsxMetadata: fakeMetadata,
+ }
+ const action = setFocusedElement(pathToFocus)
+ const updatedEditorState = UPDATE_FNS.SET_FOCUSED_ELEMENT(action, editorState, derivedState)
+ expect(updatedEditorState).toBe(editorState)
+ })
+ it('focuses a focusable element without a problem', () => {
+ const project = complexDefaultProjectPreParsed()
+ let editorState = editorModelFromPersistentModel(project, NO_OP)
+ const pathToFocus = EP.fromString(
+ 'storyboard-entity/scene-1-entity/app-entity:app-outer-div/card-instance',
+ )
+ const underlyingElement = forceNotNull(
+ 'Should be able to find this.',
+ withUnderlyingTargetFromEditorState(pathToFocus, editorState, null, (_, element) => element),
+ )
+ const cardElementMetadata = elementInstanceMetadata(
+ pathToFocus,
+ right(underlyingElement),
+ zeroRectangle as CanvasRectangle,
+ zeroRectangle as CanvasRectangle,
+ false,
+ false,
+ emptySpecialSizeMeasurements,
+ emptyComputedStyle,
+ emptyAttributeMetadata,
+ null,
+ null,
+ 'not-a-conditional',
+ null,
+ null,
+ null,
+ )
+ const fakeMetadata: ElementInstanceMetadataMap = {
+ [EP.toString(pathToFocus)]: cardElementMetadata,
+ }
+ editorState = {
+ ...editorState,
+ jsxMetadata: fakeMetadata,
+ }
+ const action = setFocusedElement(pathToFocus)
+ const derivedState = deriveState(
+ editorState,
+ null,
+ 'unpatched',
+ unpatchedCreateRemixDerivedDataMemo,
+ )
+ const updatedEditorState = UPDATE_FNS.SET_FOCUSED_ELEMENT(action, editorState, derivedState)
+ expect(updatedEditorState.focusedElementPath).toEqual(pathToFocus)
+ })
+})
+
+function textFileFromEditorStateOptic(filename: string): Optic {
+ return fromField('projectContents')
+ .compose(contentsTreeOptic)
+ .compose(filtered(({ fullPath }) => fullPath === filename))
+ .compose(fromField('file'))
+ .compose(fromTypeGuard(isTextFile))
+}
+
+function versionNumberOptic(filename: string): Optic {
+ return textFileFromEditorStateOptic(filename).compose(fromField('versionNumber'))
+}
+function parsedTextFileOptic(filename: string): Optic {
+ return textFileFromEditorStateOptic(filename)
+ .compose(fromField('fileContents'))
+ .compose(fromField('parsed'))
+ .compose(fromTypeGuard(isParseSuccess))
+}
+
+describe('UPDATE_FROM_WORKER', () => {
+ it('should prevent all updates from applying if any are stale', () => {
+ // Setup and getting some starting values.
+ const project = complexDefaultProjectPreParsed()
+ const startingEditorState = editorModelFromPersistentModel(project, NO_OP)
+ const storyboardFile = unsafeGet(parsedTextFileOptic(StoryboardFilePath), startingEditorState)
+ const updatedStoryboardFile: ParseSuccess = {
+ ...storyboardFile,
+ topLevelElements: [...storyboardFile.topLevelElements, unparsedCode('// Nonsense')],
+ }
+ const appJSFile = unsafeGet(parsedTextFileOptic('/src/app.js'), startingEditorState)
+ const updatedAppJSFile: ParseSuccess = {
+ ...appJSFile,
+ topLevelElements: [...appJSFile.topLevelElements, unparsedCode('// Other nonsense.')],
+ }
+ const versionNumberOfStoryboard = unsafeGet(
+ versionNumberOptic(StoryboardFilePath),
+ startingEditorState,
+ )
+ const versionNumberOfAppJS = unsafeGet(versionNumberOptic('/src/app.js'), startingEditorState)
+
+ // Create the action and fire it.
+ const updateToCheck = updateFromWorker([
+ workerCodeAndParsedUpdate(
+ StoryboardFilePath,
+ '// Not relevant.',
+ updatedStoryboardFile,
+ versionNumberOfStoryboard + 1,
+ ),
+ workerCodeAndParsedUpdate(
+ '/src/app.js',
+ '// Not relevant.',
+ updatedAppJSFile,
+ versionNumberOfAppJS - 1,
+ ),
+ ])
+ const updatedEditorState = UPDATE_FNS.UPDATE_FROM_WORKER(
+ updateToCheck,
+ startingEditorState,
+ defaultUserState,
+ NO_OP,
+ )
+
+ // Check that the model hasn't changed, because of the stale revised time.
+ expect(updatedEditorState).toEqual({
+ ...startingEditorState,
+ previousParseOrPrintSkipped: true,
+ })
+ })
+ it('should apply all if none are stale', () => {
+ // Setup and getting some starting values.
+ const project = complexDefaultProjectPreParsed()
+ const startingEditorState = editorModelFromPersistentModel(project, NO_OP)
+ const storyboardFile = unsafeGet(parsedTextFileOptic(StoryboardFilePath), startingEditorState)
+ const updatedStoryboardFile: ParseSuccess = {
+ ...storyboardFile,
+ topLevelElements: [...storyboardFile.topLevelElements, unparsedCode('// Nonsense')],
+ }
+ const appJSFile = unsafeGet(parsedTextFileOptic('/src/app.js'), startingEditorState)
+ const updatedAppJSFile: ParseSuccess = {
+ ...appJSFile,
+ topLevelElements: [...appJSFile.topLevelElements, unparsedCode('// Other nonsense.')],
+ }
+ const versionNumberOfStoryboard = unsafeGet(
+ versionNumberOptic(StoryboardFilePath),
+ startingEditorState,
+ )
+ const versionNumberOfAppJS = unsafeGet(versionNumberOptic('/src/app.js'), startingEditorState)
+
+ // Create the action and fire it.
+ const updateToCheck = updateFromWorker([
+ workerCodeAndParsedUpdate(
+ StoryboardFilePath,
+ '// Not relevant.',
+ updatedStoryboardFile,
+ versionNumberOfStoryboard + 1,
+ ),
+ workerCodeAndParsedUpdate(
+ '/src/app.js',
+ '// Not relevant.',
+ updatedAppJSFile,
+ versionNumberOfAppJS + 1,
+ ),
+ ])
+ const updatedEditorState = UPDATE_FNS.UPDATE_FROM_WORKER(
+ updateToCheck,
+ startingEditorState,
+ defaultUserState,
+ NO_OP,
+ )
+
+ // Get the same values that we started with but from the updated editor state.
+ const updatedStoryboardVersionNumberFromState = unsafeGet(
+ versionNumberOptic(StoryboardFilePath),
+ updatedEditorState,
+ )
+ const updatedAppJSVersionNumberFromState = unsafeGet(
+ versionNumberOptic('/src/app.js'),
+ updatedEditorState,
+ )
+ const updatedStoryboardFileFromState = unsafeGet(
+ parsedTextFileOptic(StoryboardFilePath),
+ updatedEditorState,
+ )
+ const updatedAppJSFileFromState = unsafeGet(
+ parsedTextFileOptic('/src/app.js'),
+ updatedEditorState,
+ )
+
+ // Check that the changes were applied into the model.
+ expect(updatedStoryboardVersionNumberFromState).toBeGreaterThanOrEqual(
+ versionNumberOfStoryboard,
+ )
+ expect(updatedAppJSVersionNumberFromState).toBeGreaterThanOrEqual(versionNumberOfAppJS)
+ expect(updatedStoryboardFileFromState).toStrictEqual(updatedStoryboardFile)
+ expect(updatedAppJSFileFromState).toStrictEqual(updatedAppJSFile)
+ })
+})
+
+describe('UPDATE_TOP_LEVEL_ELEMENTS_FROM_COLLABORATION', () => {
+ it('fixes up the uids if there are duplicates', () => {
+ // Setup and getting some starting values.
+ const project = complexDefaultProjectPreParsed()
+ const startingEditorState = editorModelFromPersistentModel(project, NO_OP)
+ const appJSFile = unsafeGet(parsedTextFileOptic('/src/app.js'), startingEditorState)
+ // Doubling up the top level elements should definitely create some duplicates.
+ const newTopLevelElements = [...appJSFile.topLevelElements, ...appJSFile.topLevelElements]
+ const action = updateTopLevelElementsFromCollaborationUpdate('/src/app.js', newTopLevelElements)
+ const updatedEditorState = UPDATE_FNS.UPDATE_TOP_LEVEL_ELEMENTS_FROM_COLLABORATION_UPDATE(
+ action,
+ startingEditorState,
+ )
+ const uniqueUIDsResult = getUidMappings(updatedEditorState.projectContents)
+ expect(uniqueUIDsResult.duplicateIDs.size).toEqual(0)
+ })
+})
+
+describe('replaceFilePath', () => {
+ it('only marks the relevant files as parsed ahead', () => {
+ const project = complexDefaultProjectPreParsed()
+ const editorState = editorModelFromPersistentModel(project, NO_OP)
+ const replaceResults = replaceFilePath(
+ '/src/app.js',
+ '/src/app2.js',
+ editorState.projectContents,
+ editorState.codeResultCache.curriedRequireFn,
+ )
+ if (replaceResults.type === 'SUCCESS') {
+ expect(replaceResults.updatedFiles).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "newPath": "/src/app2.js",
+ "oldPath": "/src/app.js",
+ },
+ ]
+ `)
+ let textFilesAndRevisionStates: { [filename: string]: RevisionsStateType } = {}
+ walkContentsTree(replaceResults.projectContents, (fullPath, file) => {
+ if (isTextFile(file)) {
+ textFilesAndRevisionStates[fullPath] = file.fileContents.revisionsState
+ }
+ })
+ expect(textFilesAndRevisionStates).toMatchInlineSnapshot(`
+ Object {
+ "/package.json": "BOTH_MATCH",
+ "/public/index.html": "BOTH_MATCH",
+ "/src/app2.js": "PARSED_AHEAD",
+ "/src/card.js": "BOTH_MATCH",
+ "/src/index.js": "PARSED_AHEAD",
+ "/utopia/storyboard.js": "PARSED_AHEAD",
+ }
+ `)
+ } else {
+ throw new Error('Should have returned a success.')
+ }
+ })
+})
diff --git a/nexus-builder/packages/core/src/components/editor/actions/actions.test-utils.ts b/nexus-builder/packages/core/src/components/editor/actions/actions.test-utils.ts
new file mode 100644
index 000000000000..c0383441e7e8
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/actions.test-utils.ts
@@ -0,0 +1,22 @@
+import { MetadataUtils } from '../../../core/model/element-metadata-utils'
+import { isLeft } from '../../../core/shared/either'
+import type { JSXElementChild } from '../../../core/shared/element-template'
+import type { ElementPath } from '../../../core/shared/project-file-types'
+import type { EditorRenderResult } from '../../canvas/ui-jsx.test-utils'
+import * as EP from '../../../core/shared/element-path'
+
+export function getElementFromRenderResult(
+ renderResult: EditorRenderResult,
+ path: ElementPath,
+): JSXElementChild {
+ const element = MetadataUtils.findElementByElementPath(
+ renderResult.getEditorState().editor.jsxMetadata,
+ path,
+ )
+ if (element == null) {
+ throw new Error(`Could not find element ${EP.toString(path)}`)
+ } else if (isLeft(element.element)) {
+ throw new Error(`Element ${element.element} for ${EP.toString(path)} is invalid.`)
+ }
+ return element.element.value
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/actions.tsx b/nexus-builder/packages/core/src/components/editor/actions/actions.tsx
new file mode 100644
index 000000000000..1f910fb5b9b0
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/actions.tsx
@@ -0,0 +1,6665 @@
+import { produce } from 'immer'
+import update from 'immutability-helper'
+import localforage from 'localforage'
+import { imagePathURL } from '../../../common/server'
+import { roundAttributeLayoutValues } from '../../../core/layout/layout-utils'
+import {
+ getSimpleAttributeAtPath,
+ getZIndexOrderedViewsWithoutDirectChildren,
+ MetadataUtils,
+} from '../../../core/model/element-metadata-utils'
+import type { InsertChildAndDetails } from '../../../core/model/element-template-utils'
+import {
+ elementPathForNonChildInsertions,
+ elementPathFromInsertionPath,
+ findJSXElementChildAtPath,
+ generateUidWithExistingComponents,
+ getIndexInParent,
+ insertJSXElementChildren,
+ renameJsxElementChild,
+ renameJsxElementChildWithoutId,
+ transformJSXComponentAtPath,
+} from '../../../core/model/element-template-utils'
+import {
+ applyToAllUIJSFiles,
+ applyUtopiaJSXComponentsChanges,
+ fileExists,
+ fileTypeFromFileName,
+ getFilePathMappings,
+ getUtopiaJSXComponentsFromSuccess,
+ saveFile,
+ saveTextFileContents,
+ switchToFileType,
+ uniqueProjectContentID,
+ updateFileContents,
+} from '../../../core/model/project-file-utils'
+import { getStoryboardElementPath, PathForSceneDataLabel } from '../../../core/model/scene-utils'
+import type { Either } from '../../../core/shared/either'
+import {
+ defaultEither,
+ eitherToMaybe,
+ foldEither,
+ forceRight,
+ isLeft,
+ isRight,
+ left,
+ right,
+ traverseEither,
+} from '../../../core/shared/either'
+import * as EP from '../../../core/shared/element-path'
+import type {
+ Comment,
+ ElementInstanceMetadataMap,
+ JSXAttributes,
+ JSExpressionValue,
+ JSXElement,
+ JSXElementChildren,
+ JSXElementChild,
+ JSXConditionalExpression,
+ JSXFragment,
+ JSExpression,
+} from '../../../core/shared/element-template'
+import {
+ deleteJSXAttribute,
+ emptyComments,
+ emptyJsxMetadata,
+ getJSXAttribute,
+ isImportStatement,
+ isJSXAttributeValue,
+ isJSXConditionalExpression,
+ isJSXElement,
+ modifiableAttributeIsPartOfAttributeValue,
+ jsExpressionOtherJavaScript,
+ jsxAttributesFromMap,
+ jsExpressionValue,
+ jsxConditionalExpression,
+ jsxElement,
+ jsxElementName,
+ jsxFragment,
+ jsxTextBlock,
+ walkElements,
+ modifiableAttributeIsAttributeValue,
+ isJSExpression,
+ isJSXMapExpression,
+ getDefinedElsewhereFromElementChild,
+ isJSXFragment,
+ jsxConditionalExpressionConditionOptic,
+ isJSExpressionOtherJavaScript,
+ setJSXAttributesAttribute,
+} from '../../../core/shared/element-template'
+import type { ValueAtPath } from '../../../core/shared/jsx-attributes'
+import {
+ setJSXValuesAtPaths,
+ unsetJSXValueAtPath,
+ unsetJSXValuesAtPaths,
+ valueAtPath,
+} from '../../../core/shared/jsx-attributes'
+import { getJSXAttributesAtPath, setJSXValueAtPath } from '../../../core/shared/jsx-attribute-utils'
+import type {
+ CanvasPoint,
+ CanvasRectangle,
+ LocalRectangle,
+ Size,
+ CanvasVector,
+ MaybeInfinityCanvasRectangle,
+} from '../../../core/shared/math-utils'
+import {
+ canvasRectangle,
+ isInfinityRectangle,
+ isFiniteRectangle,
+ rectangleIntersection,
+ canvasPoint,
+ getRectCenter,
+ localRectangle,
+ zeroRectIfNullOrInfinity,
+ roundPointToNearestWhole,
+ boundingRectangleArray,
+ zeroRectangle,
+} from '../../../core/shared/math-utils'
+import type {
+ PackageStatusMap,
+ RequestedNpmDependency,
+} from '../../../core/shared/npm-dependency-types'
+import { requestedNpmDependency } from '../../../core/shared/npm-dependency-types'
+import { arrayToMaybe, forceNotNull, optionalMap } from '../../../core/shared/optional-utils'
+import type {
+ ElementPath,
+ Imports,
+ NodeModules,
+ ParseSuccess,
+ ProjectContents,
+ ProjectFile,
+ PropertyPath,
+ StaticElementPath,
+ TextFile,
+} from '../../../core/shared/project-file-types'
+import {
+ assetFile,
+ directory,
+ imageFile,
+ isImageFile,
+ isDirectory,
+ parseSuccess,
+} from '../../../core/shared/project-file-types'
+import {
+ codeFile,
+ importStatementFromImportDetails,
+ isAssetFile,
+ isParseSuccess,
+ isTextFile,
+ RevisionsState,
+ textFile,
+ textFileContents,
+ unparsed,
+} from '../../../core/shared/project-file-types'
+import * as PP from '../../../core/shared/property-path'
+import { assertNever, fastForEach, getProjectLockedKey, identity } from '../../../core/shared/utils'
+import { emptyImports, mergeImports } from '../../../core/workers/common/project-file-utils'
+import {
+ createParseAndPrintOptions,
+ createParseFile,
+ createPrintAndReparseFile,
+ type UtopiaTsWorkers,
+} from '../../../core/workers/common/worker-types'
+import type { IndexPosition } from '../../../utils/utils'
+import Utils from '../../../utils/utils'
+import type { ProjectContentTreeRoot } from '../../assets'
+import {
+ isProjectContentFile,
+ packageJsonFileFromProjectContents,
+ zipContentsTree,
+} from '../../assets'
+import {
+ addFileToProjectContents,
+ contentsToTree,
+ getProjectFileByFilePath,
+ removeFromProjectContents,
+ treeToContents,
+ walkContentsTreeForParseSuccess,
+} from '../../assets'
+import type { CanvasFrameAndTarget, PinOrFlexFrameChange } from '../../canvas/canvas-types'
+import { pinSizeChange } from '../../canvas/canvas-types'
+import {
+ canvasPanelOffsets,
+ duplicate,
+ getFrameChange,
+ updateFramesOfScenesAndComponents,
+} from '../../canvas/canvas-utils'
+import type { SetFocus } from '../../common/actions'
+import { openMenu } from '../../context-menu-side-effect'
+import type { CodeResultCache, CurriedUtopiaRequireFn } from '../../custom-code/code-file'
+import {
+ codeCacheToBuildResult,
+ generateCodeResultCache,
+ normalisePathSuccessOrThrowError,
+ normalisePathToUnderlyingTarget,
+} from '../../custom-code/code-file'
+import { getFilePathToImport } from '../../filebrowser/filepath-utils'
+import { getFrameAndMultiplier } from '../../images'
+import type {
+ AddFolder,
+ AddImports,
+ AddMissingDimensions,
+ AddTailwindConfig,
+ AddTextFile,
+ AddToast,
+ Alignment,
+ AlignSelectedViews,
+ ClearHighlightedViews,
+ ClearImageFileBlob,
+ ClearParseOrPrintInFlight,
+ ClearTransientProps,
+ ClosePopup,
+ CloseTextEditor,
+ DeleteFile,
+ DeleteView,
+ DistributeSelectedViews,
+ Distribution,
+ DuplicateSpecificElements,
+ EditorAction,
+ EditorDispatch,
+ EditorModel,
+ FinishCheckpointTimer,
+ ForceParseFile,
+ HideModal,
+ InsertDroppedImage,
+ InsertImageIntoUI,
+ InsertInsertable,
+ InsertJSXElement,
+ Load,
+ NewProject,
+ OpenCodeEditorFile,
+ OpenPopup,
+ OpenTextEditor,
+ RemoveFromNodeModulesContents,
+ RemoveToast,
+ RenameComponent,
+ RenameStyleSelector,
+ ResetCanvas,
+ ResetPins,
+ RunEscapeHatch,
+ SaveAsset,
+ SaveCurrentFile,
+ SaveDOMReport,
+ ScrollToElement,
+ SelectAllSiblings,
+ SelectComponents,
+ SetAspectRatioLock,
+ SetCanvasFrames,
+ SetCodeEditorBuildErrors,
+ SetCodeEditorLintErrors,
+ SetCodeEditorVisibility,
+ SetCurrentTheme,
+ SetCursorOverlay,
+ SetFilebrowserDropTarget,
+ SetFilebrowserRenamingTarget,
+ SetFocusedElement,
+ SetFollowSelectionEnabled,
+ SetForkedFromProjectID,
+ SetGithubState,
+ SetHighlightedViews,
+ SetImageDragSessionState,
+ SetLeftMenuExpanded,
+ SetLeftMenuTab,
+ SetLoginState,
+ SetMainUIFile,
+ SetNavigatorRenamingTarget,
+ SetPackageStatus,
+ SetPanelVisibility,
+ SetProjectDescription,
+ SetProjectID,
+ SetProjectName,
+ SetProp,
+ SetPropTransient,
+ SetResizeOptionsTargetOptions,
+ SetRightMenuExpanded,
+ SetRightMenuTab,
+ SetSafeMode,
+ SetSaveError,
+ SetScrollAnimation,
+ SetShortcut,
+ SetStoredFontSettings,
+ SetZIndex,
+ ShowContextMenu,
+ ShowModal,
+ StartCheckpointTimer,
+ SwitchEditorMode,
+ ToggleCollapse,
+ ToggleHidden,
+ ToggleInterfaceDesignerAdditionalControls,
+ TogglePane,
+ ToggleProperty,
+ ToggleSelectionLock,
+ UnsetProperty,
+ UnwrapElements,
+ UpdateText,
+ UpdateCodeResultCache,
+ UpdateDuplicationState,
+ UpdateEditorMode,
+ UpdateFile,
+ UpdateFilePath,
+ UpdateFormulaBarMode,
+ UpdateFrameDimensions,
+ UpdateFromWorker,
+ UpdateGithubSettings,
+ UpdateJSXElementName,
+ UpdateKeysPressed,
+ UpdateMouseButtonsPressed,
+ UpdateNodeModulesContents,
+ UpdatePackageJson,
+ UpdateProjectContents,
+ UpdatePropertyControlsInfo,
+ WrapInElement,
+ UpdateGithubOperations,
+ UpdateBranchContents,
+ UpdateAgainstGithub,
+ UpdateGithubData,
+ RemoveFileConflict,
+ SetRefreshingDependencies,
+ SetUserConfiguration,
+ SetHoveredViews,
+ ClearHoveredViews,
+ ApplyCommandsAction,
+ UpdateColorSwatches,
+ PasteProperties,
+ CopyProperties,
+ SetConditionalOverriddenCondition,
+ SwitchConditionalBranches,
+ UpdateConditionalExpression,
+ SetMapCountOverride,
+ ScrollToPosition,
+ UpdateTopLevelElementsFromCollaborationUpdate,
+ DeleteFileFromCollaboration,
+ UpdateExportsDetailFromCollaborationUpdate,
+ UpdateImportsFromCollaborationUpdate,
+ UpdateCodeFromCollaborationUpdate,
+ SetCommentFilterMode,
+ SetForking,
+ InsertAttributeOtherJavascriptIntoElement,
+ SetCollaborators,
+ ExtractPropertyControlsFromDescriptorFiles,
+ SetCodeEditorComponentDescriptorErrors,
+ SetSharingDialogOpen,
+ AddNewPage,
+ UpdateRemixRoute,
+ AddNewFeaturedRoute,
+ RemoveFeaturedRoute,
+ AddCollapsedViews,
+ ReplaceMappedElement,
+ ReplaceElementInScope,
+ ReplaceJSXElement,
+ ToggleDataCanCondense,
+ UpdateMetadataInEditorState,
+ SetErrorBoundaryHandling,
+ SetImportWizardOpen,
+ UpdateImportOperations,
+ UpdateProjectRequirements,
+ UpdateImportStatus,
+} from '../action-types'
+import { isAlignment, isLoggedIn } from '../action-types'
+import type { Mode } from '../editor-modes'
+import { isCommentMode, isFollowMode, isTextEditMode } from '../editor-modes'
+import { EditorModes, isLiveMode, isSelectMode } from '../editor-modes'
+import * as History from '../history'
+import type { StateHistory } from '../history'
+import {
+ createLoadedPackageStatusMapFromDependencies,
+ dependenciesFromPackageJson,
+ dependenciesFromPackageJsonContents,
+ dependenciesWithEditorRequirements,
+ findLatestVersion,
+ updateDependenciesInEditorState,
+} from '../npm-dependency/npm-dependency'
+import {
+ deleteAssetFile,
+ saveAsset as saveAssetToServer,
+ saveUserConfiguration,
+ updateAssetFileName,
+ updateGithubRepository,
+} from '../server'
+import type {
+ CanvasBase64Blobs,
+ DerivedState,
+ EditorState,
+ PersistentModel,
+ RightMenuTab,
+ SimpleParseSuccess,
+ UIFileBase64Blobs,
+ UserConfiguration,
+ UserState,
+ EditorStoreUnpatched,
+ NavigatorEntry,
+ TrueUpTarget,
+ TrueUpHuggingElement,
+ CollaborativeEditingSupport,
+ ProjectGithubSettings,
+} from '../store/editor-state'
+import {
+ trueUpChildrenOfGroupChanged,
+ trueUpHuggingElement,
+ trueUpGroupElementChanged,
+ modifyUnderlyingTargetJSXElement,
+ getAllComponentDescriptorErrors,
+ updatePackageJsonInEditorState,
+ modifyUnderlyingTarget,
+ modifyUnderlyingParseSuccessOnly,
+} from '../store/editor-state'
+import {
+ BaseCanvasOffset,
+ BaseCanvasOffsetLeftPane,
+ editorModelFromPersistentModel,
+ getAllBuildErrors,
+ getAllLintErrors,
+ getCurrentTheme,
+ getElementPathsInBounds,
+ getHighlightBoundsForFile,
+ getMainUIFromModel,
+ getOpenFilename,
+ getOpenTextFileKey,
+ getOpenUIJSFileKey,
+ LeftMenuTab,
+ mergeStoredEditorStateIntoEditorState,
+ modifyOpenJsxElementAtPath,
+ modifyParseSuccessAtPath,
+ modifyParseSuccessWithSimple,
+ modifyUnderlyingElementForOpenFile,
+ modifyUnderlyingTargetElement,
+ removeElementAtPath,
+ StoryboardFilePath,
+ updateMainUIInEditorState,
+ withUnderlyingTarget,
+ modifyOpenJsxElementOrConditionalAtPath,
+ modifyOpenJsxChildAtPath,
+ isConditionalClauseNavigatorEntry,
+} from '../store/editor-state'
+import { loadStoredState } from '../stored-state'
+import { applyMigrations } from './migrations/migrations'
+
+import { defaultConfig } from 'utopia-vscode-common'
+import { reorderElement } from '../../../components/canvas/commands/reorder-element-command'
+import type { BuiltInDependencies } from '../../../core/es-modules/package-manager/built-in-dependencies-list'
+import { fetchNodeModules } from '../../../core/es-modules/package-manager/fetch-packages'
+import { resolveModule } from '../../../core/es-modules/package-manager/module-resolution'
+import { UTOPIA_UID_KEY } from '../../../core/model/utopia-constants'
+import { mapDropNulls, uniqBy } from '../../../core/shared/array-utils'
+import type { TreeConflicts } from '../../../core/shared/github/helpers'
+import { mergeProjectContents } from '../../../core/shared/github/helpers'
+import { emptySet } from '../../../core/shared/set-utils'
+import {
+ fixUtopiaElement,
+ generateConsistentUID,
+ getUtopiaID,
+ setUtopiaID,
+} from '../../../core/shared/uid-utils'
+import {
+ DefaultPostCSSConfig,
+ DefaultTailwindConfig,
+ PostCSSPath,
+ TailwindConfigPath,
+} from '../../../core/tailwind/tailwind-config'
+import {
+ initVSCodeBridge,
+ sendCodeEditorDecorations,
+ sendOpenFileMessage,
+ sendSelectedElement,
+ sendSetFollowSelectionEnabledMessage,
+ sendSetVSCodeTheme,
+} from '../../../core/vscode/vscode-bridge'
+import { createClipboardDataFromSelection, Clipboard } from '../../../utils/clipboard'
+import {
+ ExportDetailKeepDeepEquality,
+ ImportDetailsKeepDeepEquality,
+ NavigatorStateKeepDeepEquality,
+ ParseSuccessKeepDeepEquality,
+ TopLevelElementKeepDeepEquality,
+} from '../store/store-deep-equality-instances'
+import type { MouseButtonsPressed } from '../../../utils/mouse'
+import { addButtonPressed, removeButtonPressed } from '../../../utils/mouse'
+import { stripLeadingSlash } from '../../../utils/path-utils'
+import { pickCanvasStateFromEditorState } from '../../canvas/canvas-strategies/canvas-strategies'
+import { getEscapeHatchCommands } from '../../canvas/canvas-strategies/strategies/convert-to-absolute-and-move-strategy'
+import { canCopyElement } from '../../canvas/canvas-strategies/strategies/reparent-helpers/reparent-helpers'
+import {
+ getReparentOutcome,
+ pathToReparent,
+} from '../../canvas/canvas-strategies/strategies/reparent-utils'
+import {
+ areAllSelectedElementsNonAbsolute,
+ flattenSelection,
+} from '../../canvas/canvas-strategies/strategies/shared-move-strategies-helpers'
+import type { CanvasCommand } from '../../canvas/commands/commands'
+import { foldAndApplyCommandsSimple } from '../../canvas/commands/commands'
+import type { UiJsxCanvasContextData } from '../../canvas/ui-jsx-canvas'
+import { notice } from '../../common/notice'
+import type { ShortcutConfiguration } from '../shortcut-definitions'
+import { ElementInstanceMetadataMapKeepDeepEquality } from '../store/store-deep-equality-instances'
+import {
+ addImports,
+ addToast,
+ clearImageFileBlob,
+ enableInsertModeForJSXElement,
+ finishCheckpointTimer,
+ insertJSXElement,
+ openCodeEditorFile,
+ replaceMappedElement,
+ scrollToPosition,
+ selectComponents,
+ setCodeEditorBuildErrors,
+ setCodeEditorComponentDescriptorErrors,
+ setCodeEditorLintErrors,
+ setFocusedElement,
+ setPackageStatus,
+ setScrollAnimation,
+ showToast,
+ updateFile,
+ updateFromWorker,
+ updateNodeModulesContents,
+ updatePackageJson,
+ workerCodeAndParsedUpdate,
+} from './action-creators'
+import { addToastToState, includeToast, removeToastFromState } from './toast-helpers'
+import { AspectRatioLockedProp } from '../../aspect-ratio'
+import {
+ getDependenciesStatus,
+ refreshDependencies,
+ removeModulesFromNodeModules,
+} from '../../../core/shared/dependencies'
+import { styleStringInArray } from '../../../utils/common-constants'
+import { collapseTextElements } from '../../../components/text-editor/text-handling'
+import { LayoutPropertyList, StyleProperties } from '../../inspector/common/css-utils'
+import {
+ isUtopiaPropOrCommentFlag,
+ makeUtopiaFlagComment,
+ removePropOrFlagComment,
+ saveToPropOrFlagComment,
+} from '../../../core/shared/utopia-flags'
+import { modify, toArrayOf } from '../../../core/shared/optics/optic-utilities'
+import { fromField, traverseArray } from '../../../core/shared/optics/optic-creators'
+import type { ConditionalClauseInsertBehavior, InsertionPath } from '../store/insertion-path'
+import {
+ commonInsertionPathFromArray,
+ getElementPathFromInsertionPath,
+ isConditionalClauseInsertionPath,
+ childInsertionPath,
+ conditionalClauseInsertionPath,
+ replaceWithSingleElement,
+ replaceWithElementsWrappedInFragmentBehaviour,
+} from '../store/insertion-path'
+import { getConditionalCaseCorrespondingToBranchPath } from '../../../core/model/conditionals'
+import { deleteProperties } from '../../canvas/commands/delete-properties-command'
+import { treatElementAsFragmentLike } from '../../canvas/canvas-strategies/strategies/fragment-like-helpers'
+import {
+ fixParentContainingBlockSettings,
+ isTextContainingConditional,
+ unwrapConditionalClause,
+ unwrapTextContainingConditional,
+ wrapElementInsertions,
+} from './wrap-unwrap-helpers'
+import { encodeUtopiaDataToHtml } from '../../../utils/clipboard-utils'
+import type {
+ DeleteFileFromVSCode,
+ HideVSCodeLoadingScreen,
+ MarkVSCodeBridgeReady,
+ SelectFromFileAndPosition,
+ SendCodeEditorInitialisation,
+ SetIndexedDBFailed,
+ UpdateConfigFromVSCode,
+ UpdateFromCodeEditor,
+} from './actions-from-vscode'
+import {
+ addToTrueUpElements,
+ getCommandsForPushIntendedBounds,
+} from '../../../core/model/true-up-targets'
+import {
+ groupStateFromJSXElement,
+ invalidGroupStateToString,
+ isEmptyGroup,
+ isMaybeGroupForWrapping,
+ isInvalidGroupState,
+ treatElementAsGroupLike,
+} from '../../canvas/canvas-strategies/strategies/group-helpers'
+import {
+ createPinChangeCommandsForElementInsertedIntoGroup,
+ createPinChangeCommandsForElementBecomingGroupChild,
+ elementCanBeAGroupChild,
+} from '../../canvas/canvas-strategies/strategies/group-conversion-helpers'
+import { addElements } from '../../canvas/commands/add-elements-command'
+import { deleteElement } from '../../canvas/commands/delete-element-command'
+import { queueTrueUpElement } from '../../canvas/commands/queue-true-up-command'
+import {
+ getFilesToUpdate,
+ processWorkerUpdates,
+} from '../../../core/shared/parser-projectcontents-utils'
+import { getUidMappings, getAllUniqueUidsFromMapping } from '../../../core/model/get-uid-mappings'
+import { getLayoutProperty } from '../../../core/layout/getLayoutProperty'
+import { resultForFirstApplicableStrategy } from '../../inspector/inspector-strategies/inspector-strategy'
+import { reparentToUnwrapAsAbsoluteStrategy } from '../one-shot-unwrap-strategies/reparent-to-unwrap-as-absolute-strategy'
+import { convertToAbsoluteAndReparentToUnwrapStrategy } from '../one-shot-unwrap-strategies/convert-to-absolute-and-reparent-to-unwrap'
+import { addHookForProjectChanges } from '../store/collaborative-editing'
+import { arrayDeepEquality, objectDeepEquality } from '../../../utils/deep-equality'
+import type { ProjectServerState } from '../store/project-server-state'
+import { updateFileIfPossible } from './can-update-file'
+import {
+ getParseFileResult,
+ getPrintAndReparseCodeResult,
+} from '../../../core/workers/parser-printer/parser-printer-worker'
+import { isSteganographyEnabled } from '../../../core/shared/stegano-text'
+import type { TextFileContentsWithPath } from '../../../core/property-controls/property-controls-local'
+import {
+ updatePropertyControlsOnDescriptorFileDelete,
+ isComponentDescriptorFile,
+ createModuleEvaluator,
+ maybeUpdatePropertyControls,
+} from '../../../core/property-controls/property-controls-local'
+import {
+ addNewFeaturedRouteToPackageJson,
+ addOrReplaceFeaturedRouteToPackageJson,
+ isInsideRemixFolder,
+ remixFilenameMatchPrefix,
+ renameRemixFile,
+ removeFeaturedRouteFromPackageJson,
+} from '../../canvas/remix/remix-utils'
+import type { FixUIDsState } from '../../../core/workers/parser-printer/uid-fix'
+import { fixTopLevelElementsUIDs } from '../../../core/workers/parser-printer/uid-fix'
+import { nextSelectedTab } from '../../navigator/left-pane/left-pane-utils'
+import { getDefaultedRemixRootDir } from '../store/remix-derived-data'
+import { isReplaceKeepChildrenAndStyleTarget } from '../../navigator/navigator-item/component-picker-context-menu'
+import { canCondenseJSXElementChild } from '../../../utils/can-condense'
+import { getNavigatorTargetsFromEditorState } from '../../navigator/navigator-utils'
+import { getParseCacheOptions } from '../../../core/shared/parse-cache-utils'
+import { styleP } from '../../inspector/inspector-common'
+import {
+ getUpdateOperationResult,
+ notifyOperationFinished,
+ notifyOperationStarted,
+ notifyImportStatusToDiscord,
+} from '../../../core/shared/import/import-operation-service'
+import { updateRequirements } from '../../../core/shared/import/project-health-check/utopia-requirements-service'
+import {
+ applyValuesAtPath,
+ deleteValuesAtPath,
+ maybeCssPropertyFromInlineStyle,
+} from '../../canvas/commands/utils/property-utils'
+import type { HuggingElementContentsStatus } from '../../../components/canvas/hugging-utils'
+import { getHuggingElementContentsStatus } from '../../../components/canvas/hugging-utils'
+import { createStoryboardFileIfNecessary } from '../../../core/shared/import/project-health-check/requirements/requirement-storyboard'
+import { setProperty } from '../../canvas/commands/set-property-command'
+
+export const MIN_CODE_PANE_REOPEN_WIDTH = 100
+
+export function updateSelectedLeftMenuTab(editorState: EditorState, tab: LeftMenuTab): EditorState {
+ return {
+ ...editorState,
+ leftMenu: {
+ ...editorState.leftMenu,
+ selectedTab: tab,
+ },
+ }
+}
+
+export function updateLeftMenuExpanded(editorState: EditorState, expanded: boolean): EditorState {
+ return {
+ ...editorState,
+ leftMenu: {
+ ...editorState.leftMenu,
+ visible: expanded,
+ },
+ }
+}
+
+export function setLeftMenuTabFromFocusedPanel(editorState: EditorState): EditorState {
+ switch (editorState.focusedPanel) {
+ case 'misccodeeditor':
+ return updateSelectedLeftMenuTab(editorState, LeftMenuTab.Project)
+ case 'inspector':
+ case 'canvas':
+ case 'codeEditor':
+ default:
+ return editorState
+ }
+}
+
+export function updateSelectedRightMenuTab(
+ editorState: EditorState,
+ tab: RightMenuTab,
+): EditorState {
+ return {
+ ...editorState,
+ rightMenu: {
+ ...editorState.rightMenu,
+ selectedTab: tab,
+ },
+ }
+}
+
+export function updateRightMenuExpanded(editorState: EditorState, expanded: boolean): EditorState {
+ return {
+ ...editorState,
+ rightMenu: {
+ ...editorState.rightMenu,
+ visible: expanded,
+ },
+ }
+}
+
+function applyUpdateToJSXElement(
+ element: JSXElement,
+ updateFn: (props: JSXAttributes) => Either,
+): JSXElement {
+ const result = updateFn(element.props)
+ if (isLeft(result)) {
+ return element
+ } else {
+ return {
+ ...element,
+ props: result.value,
+ }
+ }
+}
+
+function setPropertyOnTarget(
+ editor: EditorModel,
+ target: ElementPath,
+ updateFn: (props: JSXAttributes) => Either,
+): EditorModel {
+ return modifyOpenJsxElementAtPath(
+ target,
+ (e: JSXElement) => applyUpdateToJSXElement(e, updateFn),
+ editor,
+ )
+}
+
+export function editorMoveMultiSelectedTemplates(
+ builtInDependencies: BuiltInDependencies,
+ targets: ElementPath[],
+ indexPosition: IndexPosition,
+ newParent: InsertionPath | null,
+ editor: EditorModel,
+): {
+ editor: EditorModel
+ newPaths: Array
+} {
+ if (newParent == null) {
+ return {
+ editor: editor,
+ newPaths: [],
+ }
+ }
+
+ let updatedTargets: Array = [...targets]
+ let newPaths: Array = []
+ const updatedEditor = targets.reduce((working, target, i) => {
+ let templateToMove = updatedTargets[i]
+
+ const outcomeResult = getReparentOutcome(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ editor.allElementProps,
+ builtInDependencies,
+ editor.projectContents,
+ editor.nodeModules.files,
+ pathToReparent(target),
+ newParent,
+ 'on-complete', // TODO make sure this is the right pick here
+ null,
+ )
+ if (outcomeResult == null) {
+ return working
+ } else {
+ const { commands: reparentCommands, newPath } = outcomeResult
+ const reorderCommand = reorderElement('on-complete', newPath, indexPosition)
+
+ const withCommandsApplied = foldAndApplyCommandsSimple(working, [
+ ...reparentCommands,
+ reorderCommand,
+ ])
+
+ // when moving multiselected elements that are in a hierarchy the editor has the ancestor with a new path
+ updatedTargets = updatedTargets.map((path) => {
+ const newChildPath = EP.replaceIfAncestor(path, templateToMove, newPath)
+ return Utils.defaultIfNull(path, newChildPath)
+ })
+ newPaths.push(newPath)
+
+ return withCommandsApplied
+ }
+ }, editor)
+ return {
+ editor: updatedEditor,
+ newPaths: newPaths,
+ }
+}
+
+export function replaceInsideMap(
+ targets: ElementPath[],
+ intendedParentPath: StaticElementPath,
+ insertBehavior: ConditionalClauseInsertBehavior,
+ editor: EditorModel,
+): {
+ editor: EditorModel
+ newPaths: Array
+} {
+ const elements: Array = mapDropNulls((path) => {
+ const instance = MetadataUtils.findElementByElementPath(editor.jsxMetadata, path)
+ if (instance == null || isLeft(instance.element)) {
+ return null
+ }
+
+ return instance.element.value
+ }, targets)
+
+ let newPaths: Array = targets.map((target) =>
+ elementPathForNonChildInsertions(insertBehavior, intendedParentPath, EP.toUid(target)),
+ )
+
+ // TODO: handle multiple elements - currently we're taking the first one
+ const elementToReplace = elements.find((element) => isJSXElement(element))
+
+ if (elementToReplace != null && isJSXElement(elementToReplace)) {
+ const editorAfterReplace = UPDATE_FNS.REPLACE_MAPPED_ELEMENT(
+ replaceMappedElement(elementToReplace, intendedParentPath, emptyImports()),
+ editor,
+ )
+ const updatedEditor = foldAndApplyCommandsSimple(editorAfterReplace, [
+ ...targets.map((path) => deleteElement('always', path)),
+ ])
+ return {
+ editor: updatedEditor,
+ newPaths: newPaths,
+ }
+ }
+
+ // if we couldn't find the JSXElement to replace, we just return the editor as is
+ return {
+ editor: editor,
+ newPaths: newPaths,
+ }
+}
+
+export function insertIntoWrapper(
+ targets: ElementPath[],
+ newParent: InsertionPath,
+ editor: EditorModel,
+): {
+ editor: EditorModel
+ newPaths: Array
+} {
+ const elements: Array = mapDropNulls((path) => {
+ const instance = MetadataUtils.findElementByElementPath(editor.jsxMetadata, path)
+ if (instance == null || isLeft(instance.element)) {
+ return null
+ }
+
+ return instance.element.value
+ }, targets)
+
+ let newPaths: Array = targets.map((target) =>
+ elementPathFromInsertionPath(newParent, EP.toUid(target)),
+ )
+
+ const updatedEditor = foldAndApplyCommandsSimple(editor, [
+ ...targets.map((path) => deleteElement('always', path)),
+ addElements('always', newParent, elements),
+ ])
+
+ return {
+ editor: updatedEditor,
+ newPaths: newPaths,
+ }
+}
+
+export function reparentElementToUnwrap(
+ target: ElementPath,
+ insertionPath: InsertionPath,
+ indexPosition: IndexPosition,
+ editor: EditorModel,
+ builtInDependencies: BuiltInDependencies,
+): { editor: EditorModel; newPath: ElementPath | null } {
+ const result = resultForFirstApplicableStrategy([
+ reparentToUnwrapAsAbsoluteStrategy(
+ pathToReparent(target),
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ editor.allElementProps,
+ insertionPath,
+ indexPosition,
+ builtInDependencies,
+ editor.projectContents,
+ editor.nodeModules.files,
+ ),
+ convertToAbsoluteAndReparentToUnwrapStrategy(
+ pathToReparent(target),
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ editor.allElementProps,
+ insertionPath,
+ indexPosition,
+ builtInDependencies,
+ editor.projectContents,
+ editor.nodeModules.files,
+ ),
+ ])
+
+ if (result == null) {
+ return { editor: editor, newPath: null }
+ }
+
+ const updatedEditor = foldAndApplyCommandsSimple(editor, result.commands)
+
+ return {
+ editor: updatedEditor,
+ newPath: result.data.newPath,
+ }
+}
+
+export function restoreEditorState(
+ currentEditor: EditorModel,
+ desiredEditor: EditorModel,
+): EditorModel {
+ // FIXME Ask Team Components to check over these
+ return {
+ id: currentEditor.id,
+ forkedFromProjectId: currentEditor.forkedFromProjectId,
+ appID: currentEditor.appID,
+ projectName: currentEditor.projectName,
+ projectDescription: currentEditor.projectDescription,
+ projectVersion: currentEditor.projectVersion,
+ isLoaded: currentEditor.isLoaded,
+ trueUpElementsAfterDomWalkerRuns: [], // <- we reset the elements true-up value
+ spyMetadata: desiredEditor.spyMetadata,
+ domMetadata: desiredEditor.domMetadata,
+ jsxMetadata: desiredEditor.jsxMetadata,
+ elementPathTree: desiredEditor.elementPathTree,
+ projectContents: desiredEditor.projectContents,
+ nodeModules: currentEditor.nodeModules,
+ codeResultCache: currentEditor.codeResultCache,
+ propertyControlsInfo: currentEditor.propertyControlsInfo,
+ selectedViews: desiredEditor.selectedViews,
+ highlightedViews: currentEditor.highlightedViews,
+ hoveredViews: currentEditor.hoveredViews,
+ hiddenInstances: desiredEditor.hiddenInstances,
+ displayNoneInstances: desiredEditor.displayNoneInstances,
+ warnedInstances: desiredEditor.warnedInstances,
+ lockedElements: desiredEditor.lockedElements,
+ mode: EditorModes.selectMode(null, false, 'none'),
+ focusedPanel: currentEditor.focusedPanel,
+ keysPressed: {},
+ mouseButtonsPressed: emptySet(),
+ openPopupId: null,
+ toasts: currentEditor.toasts,
+ cursorStack: {
+ fixed: null,
+ mouseOver: [],
+ },
+ leftMenu: {
+ selectedTab: currentEditor.leftMenu.selectedTab,
+ visible: currentEditor.leftMenu.visible,
+ },
+ rightMenu: {
+ selectedTab: currentEditor.rightMenu.selectedTab,
+ visible: currentEditor.rightMenu.visible,
+ },
+ interfaceDesigner: {
+ codePaneVisible: currentEditor.interfaceDesigner.codePaneVisible,
+ additionalControls: currentEditor.interfaceDesigner.additionalControls,
+ },
+ canvas: {
+ elementsToRerender: currentEditor.canvas.elementsToRerender,
+ interactionSession: null,
+ scale: currentEditor.canvas.scale,
+ snappingThreshold: currentEditor.canvas.snappingThreshold,
+ realCanvasOffset: currentEditor.canvas.realCanvasOffset,
+ roundedCanvasOffset: currentEditor.canvas.roundedCanvasOffset,
+ textEditor: null,
+ selectionControlsVisible: currentEditor.canvas.selectionControlsVisible,
+ cursor: null,
+ duplicationState: null,
+ base64Blobs: {},
+ mountCount: currentEditor.canvas.mountCount, // QUESTION should undo-redo forcibly remount the canvas?
+ canvasContentInvalidateCount: currentEditor.canvas.canvasContentInvalidateCount + 1,
+ domWalkerInvalidateCount: currentEditor.canvas.domWalkerInvalidateCount + 1,
+ openFile: currentEditor.canvas.openFile,
+ scrollAnimation: currentEditor.canvas.scrollAnimation,
+ transientProperties: null,
+ resizeOptions: currentEditor.canvas.resizeOptions,
+ domWalkerAdditionalElementsToUpdate: currentEditor.canvas.domWalkerAdditionalElementsToUpdate,
+ controls: currentEditor.canvas.controls,
+ },
+ inspector: {
+ visible: currentEditor.inspector.visible,
+ classnameFocusCounter: currentEditor.inspector.classnameFocusCounter,
+ },
+ fileBrowser: {
+ minimised: currentEditor.fileBrowser.minimised,
+ dropTarget: null,
+ renamingTarget: currentEditor.fileBrowser.renamingTarget,
+ },
+ dependencyList: {
+ minimised: currentEditor.dependencyList.minimised,
+ },
+ genericExternalResources: {
+ minimised: currentEditor.genericExternalResources.minimised,
+ },
+ googleFontsResources: {
+ minimised: currentEditor.googleFontsResources.minimised,
+ },
+ projectSettings: {
+ minimised: currentEditor.projectSettings.minimised,
+ },
+ navigator: {
+ minimised: currentEditor.navigator.minimised,
+ dropTargetHint: null,
+ collapsedViews: desiredEditor.navigator.collapsedViews,
+ renamingTarget: null,
+ highlightedTargets: [],
+ hiddenInNavigator: [],
+ },
+ topmenu: {
+ formulaBarMode: desiredEditor.topmenu.formulaBarMode,
+ formulaBarFocusCounter: currentEditor.topmenu.formulaBarFocusCounter,
+ },
+ home: {
+ visible: currentEditor.home.visible,
+ },
+ lastUsedFont: currentEditor.lastUsedFont,
+ modal: null,
+ localProjectList: currentEditor.localProjectList,
+ projectList: currentEditor.projectList,
+ showcaseProjects: currentEditor.showcaseProjects,
+ thumbnailLastGenerated: currentEditor.thumbnailLastGenerated,
+ pasteTargetsToIgnore: desiredEditor.pasteTargetsToIgnore,
+ codeEditorErrors: currentEditor.codeEditorErrors,
+ parseOrPrintInFlight: false,
+ previousParseOrPrintSkipped: desiredEditor.previousParseOrPrintSkipped,
+ safeMode: currentEditor.safeMode,
+ saveError: currentEditor.saveError,
+ vscodeBridgeReady: currentEditor.vscodeBridgeReady,
+ vscodeReady: currentEditor.vscodeReady,
+ focusedElementPath: desiredEditor.focusedElementPath,
+ config: defaultConfig(),
+ vscodeLoadingScreenVisible: currentEditor.vscodeLoadingScreenVisible,
+ indexedDBFailed: currentEditor.indexedDBFailed,
+ forceParseFiles: currentEditor.forceParseFiles,
+ allElementProps: desiredEditor.allElementProps,
+ currentAllElementProps: desiredEditor.currentAllElementProps,
+ variablesInScope: desiredEditor.variablesInScope,
+ currentVariablesInScope: desiredEditor.currentVariablesInScope,
+ githubSettings: currentEditor.githubSettings,
+ imageDragSessionState: currentEditor.imageDragSessionState,
+ githubOperations: currentEditor.githubOperations,
+ importState: currentEditor.importState,
+ projectRequirements: currentEditor.projectRequirements,
+ importWizardOpen: currentEditor.importWizardOpen,
+ branchOriginContents: currentEditor.branchOriginContents,
+ githubData: currentEditor.githubData,
+ refreshingDependencies: currentEditor.refreshingDependencies,
+ colorSwatches: currentEditor.colorSwatches,
+ internalClipboard: currentEditor.internalClipboard,
+ filesModifiedByAnotherUser: currentEditor.filesModifiedByAnotherUser,
+ activeFrames: currentEditor.activeFrames,
+ commentFilterMode: currentEditor.commentFilterMode,
+ forking: currentEditor.forking,
+ collaborators: currentEditor.collaborators,
+ sharingDialogOpen: currentEditor.sharingDialogOpen,
+ editorRemixConfig: currentEditor.editorRemixConfig,
+ propertiesUpdatedDuringInteraction: {},
+ }
+}
+
+function restoreEditorStateFromHistory(
+ currentEditor: EditorModel,
+ history: StateHistory,
+): EditorModel {
+ const poppedEditor = history.current.editor
+ return restoreEditorState(currentEditor, poppedEditor)
+}
+
+export function restoreDerivedState(history: StateHistory): DerivedState {
+ const poppedDerived = history.current.derived
+
+ return {
+ autoFocusedPaths: poppedDerived.autoFocusedPaths,
+ controls: [],
+ elementWarnings: poppedDerived.elementWarnings,
+ projectContentsChecksums: poppedDerived.projectContentsChecksums,
+ branchOriginContentsChecksums: poppedDerived.branchOriginContentsChecksums,
+ remixData: poppedDerived.remixData,
+ filePathMappings: poppedDerived.filePathMappings,
+ }
+}
+
+function deleteElements(
+ targets: ElementPath[],
+ editor: EditorModel,
+ options: {
+ trueUpHuggingElements: boolean
+ },
+): EditorModel {
+ const openUIJSFilePath = getOpenUIJSFileKey(editor)
+ if (openUIJSFilePath == null) {
+ console.error(`Attempted to delete element(s) with no UI file open.`)
+ return editor
+ } else {
+ const targetStaticUIDs = targets.map(EP.toStaticUid)
+ const updatedEditor = targets.reduce((working, targetPath) => {
+ const underlyingTarget = normalisePathToUnderlyingTarget(working.projectContents, targetPath)
+
+ if (underlyingTarget.type === 'NORMALISE_PATH_ELEMENT_NOT_FOUND') {
+ return working // The element has likely already been deleted
+ }
+
+ const targetSuccess = normalisePathSuccessOrThrowError(underlyingTarget)
+
+ function deleteElementFromParseSuccess(success: ParseSuccess): ParseSuccess {
+ const utopiaComponents = getUtopiaJSXComponentsFromSuccess(success)
+ const withTargetRemoved = removeElementAtPath(targetPath, utopiaComponents, success.imports)
+ return modifyParseSuccessWithSimple((simpleSuccess: SimpleParseSuccess) => {
+ return {
+ ...simpleSuccess,
+ utopiaComponents: withTargetRemoved.components,
+ imports: withTargetRemoved.imports,
+ }
+ }, success)
+ }
+ return modifyParseSuccessAtPath(
+ targetSuccess.filePath,
+ working,
+ deleteElementFromParseSuccess,
+ )
+ }, editor)
+ const withUpdatedSelectedViews = {
+ ...updatedEditor,
+ selectedViews: EP.filterPaths(updatedEditor.selectedViews, targets),
+ }
+ const siblings = targets
+ .flatMap((target) => {
+ return MetadataUtils.getSiblingsOrdered(editor.jsxMetadata, editor.elementPathTree, target)
+ })
+ .map((entry) => entry.elementPath)
+
+ const trueUpGroupElementsChanged = siblings.map(trueUpGroupElementChanged)
+
+ const trueUps: Array = [...trueUpGroupElementsChanged]
+
+ if (options.trueUpHuggingElements) {
+ const trueUpHuggingElements = mapDropNulls((path): TrueUpHuggingElement | null => {
+ if (EP.isStoryboardPath(path) || shouldCascadeDelete(editor, path)) {
+ return null
+ }
+
+ const metadata = MetadataUtils.findElementByElementPath(editor.jsxMetadata, path)
+ if (metadata == null || isLeft(metadata.element)) {
+ return null
+ }
+ const frame = MetadataUtils.getLocalFrame(path, editor.jsxMetadata, null)
+ if (frame == null || !isFiniteRectangle(frame)) {
+ return null
+ }
+
+ const jsxProps = isJSXElement(metadata.element.value)
+ ? right(metadata.element.value.props)
+ : null
+
+ const children = MetadataUtils.getChildrenUnordered(editor.jsxMetadata, path)
+
+ const childrenFrame =
+ boundingRectangleArray(
+ mapDropNulls((child) => {
+ const childFrame = child.globalFrame
+ if (childFrame == null || !isFiniteRectangle(childFrame)) {
+ return null
+ }
+ return childFrame
+ }, children),
+ ) ?? canvasRectangle(zeroRectangle)
+
+ const hasHorizontalPosition =
+ jsxProps != null &&
+ (getLayoutProperty('left', jsxProps, styleStringInArray).value != null ||
+ getLayoutProperty('right', jsxProps, styleStringInArray).value != null)
+ const hasVerticalPosition =
+ jsxProps != null &&
+ (getLayoutProperty('top', jsxProps, styleStringInArray).value != null ||
+ getLayoutProperty('bottom', jsxProps, styleStringInArray).value != null)
+
+ function combineFrames(main: CanvasRectangle, backup: CanvasRectangle): CanvasRectangle {
+ return canvasRectangle({
+ x: hasHorizontalPosition ? main.x : backup.x,
+ y: hasVerticalPosition ? main.y : backup.y,
+ width: main.width !== 0 ? main.width : backup.width,
+ height: main.height !== 0 ? main.height : backup.height,
+ })
+ }
+ const huggingElementContentsStatus: HuggingElementContentsStatus =
+ getHuggingElementContentsStatus(editor.jsxMetadata, path)
+ return trueUpHuggingElement(
+ path,
+ canvasRectangle(frame),
+ combineFrames(canvasRectangle(frame), childrenFrame),
+ huggingElementContentsStatus,
+ )
+ }, uniqBy(targets.map(EP.parentPath), EP.pathsEqual))
+ trueUps.push(...trueUpHuggingElements)
+ }
+
+ return addToTrueUpElements(withUpdatedSelectedViews, ...trueUps)
+ }
+}
+
+function shouldCascadeDelete(editor: EditorState, path: ElementPath): boolean {
+ return (
+ // it's a group
+ treatElementAsGroupLike(editor.jsxMetadata, path) ||
+ // it's a framgent
+ treatElementAsFragmentLike(
+ editor.jsxMetadata,
+ editor.allElementProps,
+ editor.elementPathTree,
+ path,
+ 'sizeless-div-not-considered-fragment-like',
+ )
+ // TODO it's hug?
+ )
+}
+
+function duplicateMany(paths: ElementPath[], editor: EditorModel): EditorModel {
+ const targetParent = EP.getCommonParent(paths)
+ const duplicateResult = duplicate(paths, targetParent, editor)
+ if (duplicateResult == null) {
+ return editor
+ } else {
+ return duplicateResult.updatedEditorState
+ }
+}
+
+function indexPositionForAdjustment(
+ target: StaticElementPath | ElementPath,
+ editor: EditorModel,
+ index: 'back' | 'front' | 'backward' | 'forward',
+): IndexPosition {
+ switch (index) {
+ case 'back':
+ return { type: 'back' }
+ case 'front':
+ return { type: 'front' }
+ case 'backward':
+ case 'forward':
+ const openUIJSFileKey = getOpenUIJSFileKey(editor)
+ if (openUIJSFileKey != null) {
+ const current = withUnderlyingTarget(target, editor.projectContents, 0, (success) => {
+ return getIndexInParent(success.topLevelElements, EP.asStatic(target))
+ })
+ return {
+ type: 'absolute',
+ index: index === 'backward' ? Math.max(current - 1, 0) : current + 1,
+ }
+ } else {
+ throw new Error('no open ui JS file found')
+ }
+ }
+}
+
+function setZIndexOnSelected(
+ editor: EditorModel,
+ index: 'back' | 'front' | 'backward' | 'forward',
+): EditorModel {
+ const selectedViews = editor.selectedViews
+
+ return selectedViews.reduce((working, selectedView) => {
+ const siblings = MetadataUtils.getSiblingsOrdered(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ selectedView,
+ )
+ const currentIndex = MetadataUtils.getIndexInParent(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ selectedView,
+ )
+ const isFirstSiblingMovedBackwards =
+ currentIndex === 0 && (index === 'back' || index === 'backward')
+
+ const isLastSiblingMovedForward =
+ currentIndex === siblings.length - 1 && (index === 'front' || index === 'forward')
+
+ const isElementRootOfConditionalBranch =
+ getConditionalCaseCorrespondingToBranchPath(selectedView, editor.jsxMetadata) != null
+
+ if (
+ isFirstSiblingMovedBackwards ||
+ isLastSiblingMovedForward ||
+ isElementRootOfConditionalBranch
+ ) {
+ return working
+ }
+
+ const indexPosition = indexPositionForAdjustment(selectedView, working, index)
+
+ const reorderElementCommand = reorderElement('always', selectedView, indexPosition)
+
+ return foldAndApplyCommandsSimple(working, [reorderElementCommand])
+ }, editor)
+}
+
+function setModeState(mode: Mode, editor: EditorModel): EditorModel {
+ return update(editor, {
+ mode: { $set: mode },
+ })
+}
+
+function updateNavigatorCollapsedState(
+ selectedViews: Array,
+ navigator: EditorModel['navigator'],
+): EditorModel['navigator'] {
+ const allCollapsedViews = navigator.collapsedViews
+ let collapsedWithChildrenSelected: ElementPath[] = []
+ let collapsedNoChildrenSelected: ElementPath[] = []
+ selectedViews.forEach((selectedView) => {
+ allCollapsedViews.forEach((collapsedView) => {
+ if (
+ EP.isDescendantOfOrEqualTo(selectedView, collapsedView) &&
+ !EP.pathsEqual(selectedView, collapsedView)
+ ) {
+ if (!EP.containsPath(collapsedView, collapsedWithChildrenSelected)) {
+ collapsedWithChildrenSelected.push(collapsedView)
+ }
+ } else {
+ if (!EP.containsPath(collapsedView, collapsedNoChildrenSelected)) {
+ collapsedNoChildrenSelected.push(collapsedView)
+ }
+ }
+ })
+ })
+ if (selectedViews.length == 0) {
+ collapsedNoChildrenSelected = allCollapsedViews
+ }
+
+ return update(navigator, {
+ collapsedViews: {
+ $set: collapsedNoChildrenSelected.filter(
+ (path) => !EP.containsPath(path, collapsedWithChildrenSelected),
+ ),
+ },
+ })
+}
+
+interface ReplaceFilePathSuccess {
+ type: 'SUCCESS'
+ projectContents: ProjectContentTreeRoot
+ updatedFiles: Array<{ oldPath: string; newPath: string }>
+ renamedOptionalPrefix: boolean
+}
+
+interface ReplaceFilePathFailure {
+ type: 'FAILURE'
+ errorMessage: string
+}
+
+type ReplaceFilePathResult = ReplaceFilePathFailure | ReplaceFilePathSuccess
+
+export function replaceFilePath(
+ oldPath: string,
+ newPath: string,
+ projectContentsTree: ProjectContentTreeRoot,
+ curriedRequireFn: CurriedUtopiaRequireFn,
+): ReplaceFilePathResult {
+ // FIXME: Reimplement this in a way that doesn't require converting to and from `ProjectContents`.
+ const projectContents = treeToContents(projectContentsTree)
+ // if there is no file in projectContents it's probably a non-empty directory
+ let error: string | null = null
+ let updatedProjectContents: ProjectContents = {
+ ...projectContents,
+ }
+ let updatedFiles: Array<{ oldPath: string; newPath: string }> = []
+
+ const remixRootDir = getDefaultedRemixRootDir(projectContentsTree, curriedRequireFn)
+
+ let renamedOptionalPrefix = false
+ Utils.fastForEach(Object.keys(projectContents), (filename) => {
+ if (
+ filename === oldPath ||
+ filename.startsWith(oldPath + '/') ||
+ remixFilenameMatchPrefix(remixRootDir, filename, oldPath)
+ ) {
+ // TODO make sure the prefix search only happens when it makes sense so
+ const projectFile = projectContents[filename]
+
+ const maybeNewFilePathForRemix = isInsideRemixFolder(remixRootDir, filename)
+ ? renameRemixFile({
+ remixRootDir: remixRootDir,
+ filename: filename,
+ oldPath: oldPath,
+ newPath: newPath,
+ })
+ : null
+ if (maybeNewFilePathForRemix?.renamedOptionalPrefix) {
+ renamedOptionalPrefix = true
+ }
+
+ const newFilePath = maybeNewFilePathForRemix?.filename ?? filename.replace(oldPath, newPath)
+
+ const fileType = isDirectory(projectFile) ? 'DIRECTORY' : fileTypeFromFileName(newFilePath)
+ if (fileType == null) {
+ // Can't identify the file type.
+ error = `Can't rename ${filename} to ${newFilePath}.`
+ } else {
+ const updatedProjectFile = switchToFileType(projectFile, fileType)
+ if (updatedProjectFile == null) {
+ // Appears this file can't validly be changed.
+ error = `Can't rename ${filename} to ${newFilePath}.`
+ } else {
+ // Remove the old file.
+ delete updatedProjectContents[filename]
+ updatedProjectContents[newFilePath] = updatedProjectFile
+ updatedFiles.push({ oldPath: filename, newPath: newFilePath })
+ }
+ }
+ }
+ })
+
+ // Correct any imports in files that have changed because of the above file movements.
+ Utils.fastForEach(Object.keys(updatedProjectContents), (filename) => {
+ const projectFile = updatedProjectContents[filename]
+ // Only for successfully parsed text files, with some protection for files that are yet to be parsed.
+ if (
+ isTextFile(projectFile) &&
+ isParseSuccess(projectFile.fileContents.parsed) &&
+ projectFile.fileContents.revisionsState !== RevisionsState.CodeAhead
+ ) {
+ let updatedParseResult: ParseSuccess = projectFile.fileContents.parsed
+ fastForEach(updatedFiles, (updatedFile) => {
+ fastForEach(Object.keys(updatedParseResult.imports), (importSource) => {
+ // Only do this for import sources that look like file paths.
+ if (importSource.startsWith('.') || importSource.startsWith('/')) {
+ const resolveResult = resolveModule(projectContentsTree, {}, filename, importSource)
+
+ if (
+ resolveResult.type === 'RESOLVE_SUCCESS' &&
+ resolveResult.success.path === updatedFile.oldPath
+ ) {
+ // Create new absolute import path and shift the import in this file to represent that.
+ const importFromParse = updatedParseResult.imports[importSource]
+ let updatedImports: Imports = {
+ ...updatedParseResult.imports,
+ }
+ delete updatedImports[importSource]
+ // If an absolute path was used before, use the updated absolute path.
+ const newImportPath = importSource.startsWith('/')
+ ? updatedFile.newPath
+ : getFilePathToImport(updatedFile.newPath, filename)
+ updatedImports[newImportPath] = importFromParse
+
+ // Update the parse result to be incorporated later.
+ updatedParseResult = {
+ ...updatedParseResult,
+ imports: updatedImports,
+ }
+ }
+ }
+ })
+
+ // Update the top level element import statements.
+ const updatedTopLevelElements = updatedParseResult.topLevelElements.map(
+ (topLevelElement) => {
+ if (isImportStatement(topLevelElement)) {
+ const resolveResult = resolveModule(
+ projectContentsTree,
+ {},
+ filename,
+ topLevelElement.module,
+ )
+ if (
+ resolveResult.type === 'RESOLVE_SUCCESS' &&
+ resolveResult.success.path === updatedFile.oldPath
+ ) {
+ // If an absolute path was used before, use the updated absolute path.
+ const newImportPath = topLevelElement.module.startsWith('/')
+ ? updatedFile.newPath
+ : getFilePathToImport(updatedFile.newPath, filename)
+ const importDefinition = forceNotNull(
+ 'Import should exist.',
+ updatedParseResult.imports[newImportPath],
+ )
+ return importStatementFromImportDetails(newImportPath, importDefinition)
+ } else {
+ return topLevelElement
+ }
+ } else {
+ return topLevelElement
+ }
+ },
+ )
+
+ updatedParseResult = {
+ ...updatedParseResult,
+ topLevelElements: updatedTopLevelElements,
+ }
+ })
+
+ // Only mark these as parsed ahead if they have meaningfully changed,
+ // or if the filename has been changed for this file.
+ const oldFilename =
+ updatedFiles.find((updatedFile) => updatedFile.newPath === filename) ?? filename
+ if (
+ oldFilename !== filename ||
+ !ParseSuccessKeepDeepEquality(projectFile.fileContents.parsed, updatedParseResult).areEqual
+ ) {
+ updatedProjectContents[filename] = saveTextFileContents(
+ projectFile,
+ textFileContents(
+ projectFile.fileContents.code,
+ updatedParseResult,
+ RevisionsState.ParsedAhead,
+ ),
+ projectFile.lastSavedContents == null,
+ )
+ }
+ }
+ })
+ // Check if we discovered an error.
+ if (error == null) {
+ return {
+ type: 'SUCCESS',
+ projectContents: contentsToTree(updatedProjectContents),
+ updatedFiles: updatedFiles,
+ renamedOptionalPrefix: renamedOptionalPrefix,
+ }
+ } else {
+ return {
+ type: 'FAILURE',
+ errorMessage: error,
+ }
+ }
+}
+
+function loadModel(newModel: EditorModel, oldModel: EditorModel): EditorModel {
+ return setLeftMenuTabFromFocusedPanel({
+ ...newModel,
+ isLoaded: true,
+ localProjectList: oldModel.projectList,
+ projectList: oldModel.projectList,
+ showcaseProjects: oldModel.showcaseProjects,
+ })
+}
+
+let checkpointTimeoutId: number | undefined = undefined
+let canvasScrollAnimationTimer: number | undefined = undefined
+
+function updateSelectedComponentsFromEditorPosition(
+ editor: EditorState,
+ dispatch: EditorDispatch,
+ filePath: string,
+ line: number,
+): EditorState {
+ if (Object.keys(editor.jsxMetadata).length === 0) {
+ // Looks like the canvas has errored out, so leave it alone for now.
+ return editor
+ }
+
+ const highlightBoundsForUids = getHighlightBoundsForFile(editor, filePath)
+ const allElementPathsOptic = traverseArray().compose(fromField('elementPath'))
+
+ // TODO this is wasteful here, instead we should get the allElementPaths through a more conservative way, for example taking all the keys of JSXMetadata
+ const navigatorTargets = getNavigatorTargetsFromEditorState(editor)
+
+ const newlySelectedElements = getElementPathsInBounds(
+ line,
+ highlightBoundsForUids,
+ toArrayOf(
+ allElementPathsOptic,
+ navigatorTargets.navigatorTargets.filter((t) => !isConditionalClauseNavigatorEntry(t)),
+ ),
+ )
+
+ if (newlySelectedElements.length === 0) {
+ return editor
+ }
+
+ return UPDATE_FNS.SELECT_COMPONENTS(
+ selectComponents(newlySelectedElements, false),
+ editor,
+ dispatch,
+ )
+}
+
+function normalizeGithubData(editor: EditorModel): EditorModel {
+ const { githubSettings } = editor
+ const hasRepo = githubSettings.targetRepository != null
+ const hasBranch = githubSettings.branchName != null
+ return {
+ ...editor,
+ githubSettings: {
+ ...githubSettings,
+ branchName: hasRepo ? githubSettings.branchName : null,
+ branchLoaded: hasRepo && hasBranch && githubSettings.branchLoaded,
+ originCommit: hasRepo && hasBranch ? githubSettings.originCommit : null,
+ pendingCommit: hasRepo && hasBranch ? githubSettings.pendingCommit : null,
+ },
+ githubData: {
+ ...editor.githubData,
+ upstreamChanges: null,
+ currentBranchPullRequests: null,
+ },
+ }
+}
+
+function updateCodeEditorVisibility(editor: EditorModel, codePaneVisible: boolean): EditorModel {
+ return {
+ ...editor,
+ interfaceDesigner: {
+ ...editor.interfaceDesigner,
+ codePaneVisible: codePaneVisible,
+ },
+ }
+}
+
+// JS Editor Actions:
+export const UPDATE_FNS = {
+ NEW: (
+ action: NewProject,
+ oldEditor: EditorModel,
+ workers: UtopiaTsWorkers,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ const newPersistentModel = applyMigrations(action.persistentModel)
+ const newModel = editorModelFromPersistentModel(newPersistentModel, dispatch)
+ return {
+ ...loadModel(newModel, oldEditor),
+ nodeModules: {
+ skipDeepFreeze: true,
+ files: action.nodeModules,
+ projectFilesBuildResults: {},
+ packageStatus: action.packageResult,
+ },
+ codeResultCache: action.codeResultCache,
+ }
+ },
+ LOAD: (
+ action: Load,
+ oldEditor: EditorModel,
+ dispatch: EditorDispatch,
+ collaborativeEditingSupport: CollaborativeEditingSupport,
+ ): EditorModel => {
+ const migratedModel = applyMigrations(action.model)
+ const parsedProjectFiles = applyToAllUIJSFiles(
+ migratedModel.projectContents,
+ (filename: string, file: TextFile) => {
+ const lastSavedFileContents = optionalMap((lastSaved) => {
+ return textFileContents(lastSaved.code, unparsed, RevisionsState.CodeAhead)
+ }, file.lastSavedContents)
+ return textFile(
+ textFileContents(file.fileContents.code, unparsed, RevisionsState.CodeAhead),
+ lastSavedFileContents,
+ null,
+ file.versionNumber + 1,
+ )
+ },
+ )
+
+ const parsedModel = {
+ ...migratedModel,
+ projectContents: parsedProjectFiles,
+ }
+
+ let newModel: EditorModel = {
+ ...editorModelFromPersistentModel(parsedModel, dispatch),
+ projectName: action.title,
+ id: action.projectId,
+ nodeModules: {
+ skipDeepFreeze: true,
+ files: action.nodeModules,
+ projectFilesBuildResults: {},
+ packageStatus: action.packageResult,
+ },
+ codeResultCache: action.codeResultCache,
+ safeMode: action.safeMode,
+ }
+
+ const newModelMergedWithStoredStateAndStoryboardFile: EditorModel =
+ mergeStoredEditorStateIntoEditorState(action.storedState, newModel)
+
+ initVSCodeBridge(
+ newModelMergedWithStoredStateAndStoryboardFile.projectContents,
+ dispatch,
+ StoryboardFilePath,
+ )
+ if (collaborativeEditingSupport.session != null) {
+ addHookForProjectChanges(collaborativeEditingSupport.session, dispatch)
+ }
+
+ return loadModel(newModelMergedWithStoredStateAndStoryboardFile, oldEditor)
+ },
+ SET_HIGHLIGHTED_VIEWS: (action: SetHighlightedViews, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ highlightedViews: action.targets,
+ }
+ },
+ SET_HOVERED_VIEWS: (action: SetHoveredViews, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ hoveredViews: action.targets,
+ }
+ },
+ CLEAR_HIGHLIGHTED_VIEWS: (action: ClearHighlightedViews, editor: EditorModel): EditorModel => {
+ if (editor.highlightedViews.length === 0) {
+ return editor
+ }
+ return {
+ ...editor,
+ highlightedViews: [],
+ }
+ },
+ CLEAR_HOVERED_VIEWS: (action: ClearHoveredViews, editor: EditorModel): EditorModel => {
+ if (editor.hoveredViews.length === 0) {
+ return editor
+ }
+ return {
+ ...editor,
+ hoveredViews: [],
+ }
+ },
+ UNDO: (editor: EditorModel, stateHistory: StateHistory): EditorModel => {
+ if (History.canUndo(stateHistory)) {
+ const history = History.undo(editor.id, stateHistory, 'run-side-effects')
+ return restoreEditorStateFromHistory(editor, history)
+ } else {
+ return editor
+ }
+ },
+ REDO: (editor: EditorModel, stateHistory: StateHistory): EditorModel => {
+ if (History.canRedo(stateHistory)) {
+ const history = History.redo(editor.id, stateHistory, 'run-side-effects')
+ return restoreEditorStateFromHistory(editor, history)
+ } else {
+ return editor
+ }
+ },
+ UNSET_PROPERTY: (action: UnsetProperty, editor: EditorModel): EditorModel => {
+ // TODO also queue group true up, just like for SET_PROP
+ // TODO this used to fire a toast if the prop couldn't be removed
+ return foldAndApplyCommandsSimple(editor, [
+ deleteProperties('always', action.element, [action.property]),
+ ])
+ },
+ SET_PROP: (action: SetProp, editor: EditorModel): EditorModel => {
+ let setPropFailedMessage: string | null = null
+ let newSelectedViews: Array = editor.selectedViews
+ const prop = maybeCssPropertyFromInlineStyle(action.propertyPath)
+ const valueForStyleProp =
+ action.value.type === 'ATTRIBUTE_VALUE' &&
+ (typeof action.value.value === 'number' || typeof action.value.value === 'string')
+ ? action.value.value
+ : null
+
+ const editorWithPropSet =
+ prop == null || valueForStyleProp == null
+ ? applyValuesAtPath(editor, action.target, [
+ { path: action.propertyPath, value: action.value },
+ ]).editorStateWithChanges
+ : foldAndApplyCommandsSimple(editor, [
+ setProperty('always', action.target, PP.create('style', prop), valueForStyleProp),
+ ])
+
+ if (isJSXElement(action.value)) {
+ newSelectedViews = [EP.appendToPath(action.target, action.value.uid)]
+ }
+ let updatedEditor = modifyUnderlyingTargetElement(
+ action.target,
+ editorWithPropSet,
+ (element) => {
+ if (!isJSXElement(element)) {
+ return element
+ }
+ if (
+ PP.contains(
+ [
+ PP.create('style', 'top'),
+ PP.create('style', 'bottom'),
+ PP.create('style', 'left'),
+ PP.create('style', 'right'),
+ PP.create('style', 'width'),
+ PP.create('style', 'height'),
+ ],
+ action.propertyPath,
+ )
+ ) {
+ // TODO: refactor this to read from the plugins
+ const maybeInvalidGroupState = groupStateFromJSXElement(
+ element,
+ action.target,
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ editor.allElementProps,
+ editor.projectContents,
+ )
+ if (
+ isInvalidGroupState(maybeInvalidGroupState) &&
+ /**
+ * we want to exempt 'child-has-missing-pins' from this list, because SET_PROP maybe what the user is doing to _fix_ the situation highlighted by 'child-has-missing-pins'
+ * if 'child-has-missing-pins' prevents us from SET_PROP, that means we prevent ourselves from re-adding those missing props!
+ */
+ maybeInvalidGroupState !== 'child-has-missing-pins'
+ ) {
+ setPropFailedMessage = invalidGroupStateToString(maybeInvalidGroupState)
+ return element
+ }
+ }
+ return {
+ ...element,
+ // TODO: refactor this to use commands
+ props: roundAttributeLayoutValues(styleStringInArray, element.props),
+ }
+ },
+ (success, _, underlyingFilePath) => {
+ const updatedImports = mergeImports(
+ underlyingFilePath,
+ getFilePathMappings(editor.projectContents),
+ success.imports,
+ action.importsToAdd,
+ ).imports
+ return { ...success, imports: updatedImports }
+ },
+ )
+
+ updatedEditor = addToTrueUpElements(updatedEditor, trueUpGroupElementChanged(action.target))
+
+ if (setPropFailedMessage != null) {
+ const toastAction = showToast(notice(setPropFailedMessage, 'ERROR'))
+ updatedEditor = UPDATE_FNS.ADD_TOAST(toastAction, editor)
+ }
+
+ return {
+ ...updatedEditor,
+ selectedViews: newSelectedViews,
+ }
+ },
+ SET_CANVAS_FRAMES: (action: SetCanvasFrames, editor: EditorModel): EditorModel => {
+ return setCanvasFramesInnerNew(editor, action.framesAndTargets, null)
+ },
+ SET_Z_INDEX: (action: SetZIndex, editor: EditorModel): EditorModel => {
+ return foldAndApplyCommandsSimple(editor, [
+ reorderElement('always', action.target, action.indexPosition),
+ ])
+ },
+ DELETE_SELECTED: (editor: EditorModel, dispatch: EditorDispatch): EditorModel => {
+ // This function returns whether the given path will have the following deletion behavior:
+ // 1. when deleting one of its children, the next sibling will be selected
+ // 2. when deleting the last chilren, it is removed as well so as not to remain empty
+ function behavesLikeGroupOrFragmentForDeletion(
+ metadata: ElementInstanceMetadataMap,
+ path: ElementPath,
+ ): boolean {
+ return (
+ MetadataUtils.isFragmentFromMetadata(metadata[EP.toString(path)]) ||
+ treatElementAsGroupLike(metadata, path)
+ )
+ }
+
+ // find all parents of the current path which can be bulk-deleted
+ function deletableParents(
+ metadata: ElementInstanceMetadataMap,
+ path: ElementPath,
+ selected: ElementPath[],
+ ): ElementPath[] {
+ let result: Array = []
+ let parent: ElementPath = EP.parentPath(path)
+ while (!EP.isStoryboardPath(parent)) {
+ const children = MetadataUtils.getChildrenUnordered(metadata, parent)
+ const count = 1 + children.filter((c) => EP.containsPath(c.elementPath, selected)).length
+ if (!behavesLikeGroupOrFragmentForDeletion(metadata, parent) || children.length > count) {
+ break
+ }
+ result.push(parent)
+ parent = EP.parentPath(parent)
+ }
+ return result
+ }
+
+ let bubbledUpDeletions: Array = []
+
+ const staticSelectedElements = editor.selectedViews.map((path, _, allSelectedPaths) => {
+ const siblings = MetadataUtils.getSiblingsOrdered(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ path,
+ )
+ const selectedSiblings = allSelectedPaths.filter((p) =>
+ siblings.some((sibling) => EP.pathsEqual(sibling.elementPath, p)),
+ )
+
+ const parentPath = EP.parentPath(path)
+
+ const mustDeleteEmptyParent = behavesLikeGroupOrFragmentForDeletion(
+ editor.jsxMetadata,
+ parentPath,
+ )
+
+ const parentWillBeEmpty =
+ MetadataUtils.getChildrenOrdered(editor.jsxMetadata, editor.elementPathTree, parentPath)
+ .length === selectedSiblings.length
+
+ if (mustDeleteEmptyParent && parentWillBeEmpty) {
+ const bubbledUp = [
+ parentPath,
+ ...deletableParents(editor.jsxMetadata, parentPath, allSelectedPaths),
+ ]
+ bubbledUpDeletions.push(...bubbledUp)
+ return EP.getCommonParent(bubbledUpDeletions, true) ?? parentPath
+ }
+
+ return path
+ })
+
+ const withElementDeleted = deleteElements(staticSelectedElements, editor, {
+ trueUpHuggingElements: true,
+ })
+
+ const newSelectedViews = uniqBy(
+ mapDropNulls((view) => {
+ const parentPath = EP.parentPath(view)
+ if (behavesLikeGroupOrFragmentForDeletion(editor.jsxMetadata, parentPath)) {
+ // there may be bubbled up deletions, so find out which is the actual parent
+ // where the bubbles stopped
+ const parentsBubbledUp = [
+ view,
+ ...deletableParents(editor.jsxMetadata, parentPath, staticSelectedElements),
+ ].map(EP.parentPath)
+ const actualParent = EP.getCommonParent(parentsBubbledUp, true) ?? parentPath
+
+ if (
+ EP.pathsEqual(actualParent, parentPath) ||
+ behavesLikeGroupOrFragmentForDeletion(editor.jsxMetadata, actualParent)
+ ) {
+ const ignorePaths = [...staticSelectedElements, ...parentsBubbledUp] // ignore these paths when looking for a sibling
+ const target = MetadataUtils.getChildrenOrdered(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ actualParent,
+ ).find((element) => !EP.containsPath(element.elementPath, ignorePaths))
+ if (target != null) {
+ return target.elementPath
+ }
+ }
+ }
+
+ const parent = MetadataUtils.findElementByElementPath(editor.jsxMetadata, parentPath)
+ if (
+ parent != null &&
+ isRight(parent.element) &&
+ isJSXConditionalExpression(parent.element.value)
+ ) {
+ const isTrueBranch = EP.toUid(view) === getUtopiaID(parent.element.value.whenTrue)
+
+ const branchPath = withUnderlyingTarget(
+ parentPath,
+ withElementDeleted.projectContents,
+ null,
+ (_, element) => {
+ if (isJSXConditionalExpression(element) && element.uid === EP.toUid(parentPath)) {
+ return EP.appendToPath(
+ parentPath,
+ getUtopiaID(isTrueBranch ? element.whenTrue : element.whenFalse),
+ )
+ }
+ return null
+ },
+ )
+ if (branchPath != null) {
+ return branchPath
+ }
+ }
+ return EP.isStoryboardPath(parentPath) ? null : parentPath
+ }, staticSelectedElements),
+ EP.pathsEqual,
+ ).filter((path) => {
+ // remove descendants of already-deleted elements during multiselect
+ return !EP.containsPath(path, bubbledUpDeletions)
+ })
+
+ return {
+ ...withElementDeleted,
+ selectedViews: newSelectedViews,
+ }
+ },
+ DELETE_VIEW: (action: DeleteView, editor: EditorModel): EditorModel => {
+ const updatedEditor = deleteElements([action.target], editor, { trueUpHuggingElements: false })
+ const parentPath = EP.parentPath(action.target)
+ const newSelection = EP.isStoryboardPath(parentPath) ? [] : [parentPath]
+ return {
+ ...updatedEditor,
+ selectedViews: newSelection,
+ }
+ },
+ DUPLICATE_SELECTED: (editor: EditorModel): EditorModel => {
+ return duplicateMany(editor.selectedViews, editor)
+ },
+ DUPLICATE_SPECIFIC_ELEMENTS: (
+ action: DuplicateSpecificElements,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ return duplicateMany(action.paths, editor)
+ },
+ UPDATE_DUPLICATION_STATE: (action: UpdateDuplicationState, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ duplicationState: action.duplicationState,
+ },
+ }
+ },
+ MOVE_SELECTED_TO_BACK: (editor: EditorModel): EditorModel => {
+ return setZIndexOnSelected(editor, 'back')
+ },
+ MOVE_SELECTED_TO_FRONT: (editor: EditorModel): EditorModel => {
+ return setZIndexOnSelected(editor, 'front')
+ },
+ MOVE_SELECTED_BACKWARD: (editor: EditorModel): EditorModel => {
+ return setZIndexOnSelected(editor, 'backward')
+ },
+ MOVE_SELECTED_FORWARD: (editor: EditorModel): EditorModel => {
+ return setZIndexOnSelected(editor, 'forward')
+ },
+ SELECT_COMPONENTS: (
+ action: SelectComponents,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ let newlySelectedPaths: Array
+ if (action.addToSelection) {
+ newlySelectedPaths = action.target.reduce((working, path) => {
+ return EP.addPathIfMissing(path, working)
+ }, editor.selectedViews)
+ } else {
+ newlySelectedPaths = EP.uniqueElementPaths(action.target)
+ }
+
+ const updatedEditor: EditorModel = {
+ ...editor,
+ selectedViews: newlySelectedPaths,
+ leftMenu: {
+ visible: editor.leftMenu.visible,
+ selectedTab: nextSelectedTab(editor.leftMenu.selectedTab, newlySelectedPaths),
+ },
+ navigator:
+ newlySelectedPaths === editor.selectedViews
+ ? editor.navigator
+ : updateNavigatorCollapsedState(newlySelectedPaths, editor.navigator),
+ pasteTargetsToIgnore: [],
+ }
+
+ return updatedEditor
+ },
+ CLEAR_SELECTION: (editor: EditorModel, derived: DerivedState): EditorModel => {
+ if (editor.selectedViews.length === 0) {
+ return UPDATE_FNS.SET_FOCUSED_ELEMENT(setFocusedElement(null), editor, derived)
+ }
+
+ const newlySelectedPaths: Array = []
+
+ return {
+ ...editor,
+ leftMenu: {
+ visible: editor.leftMenu.visible,
+ selectedTab: nextSelectedTab(editor.leftMenu.selectedTab, newlySelectedPaths),
+ },
+ selectedViews: [],
+ navigator: updateNavigatorCollapsedState([], editor.navigator),
+ pasteTargetsToIgnore: newlySelectedPaths,
+ }
+ },
+ SELECT_ALL_SIBLINGS: (
+ action: SelectAllSiblings,
+ editor: EditorModel,
+ derived: DerivedState,
+ ): EditorModel => {
+ const selectedElements = editor.selectedViews
+ const uniqueParents = uniqBy(
+ Utils.stripNulls(selectedElements.map(EP.parentPath)),
+ EP.pathsEqual,
+ )
+ const additionalTargets = Utils.flatMapArray((uniqueParent) => {
+ const children = MetadataUtils.getImmediateChildrenOrdered(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ uniqueParent,
+ )
+ return children
+ .map((child) => child.elementPath)
+ .filter((childPath) => {
+ return !EP.containsPath(childPath, selectedElements)
+ })
+ }, uniqueParents)
+
+ const nextSelectedViews = [...editor.selectedViews, ...additionalTargets]
+
+ return {
+ ...editor,
+ leftMenu: {
+ visible: editor.leftMenu.visible,
+ selectedTab: nextSelectedTab(editor.leftMenu.selectedTab, nextSelectedViews),
+ },
+ selectedViews: nextSelectedViews,
+ pasteTargetsToIgnore: [],
+ }
+ },
+ UPDATE_EDITOR_MODE: (action: UpdateEditorMode, editor: EditorModel): EditorModel => {
+ return setModeState(action.mode, editor)
+ },
+ SWITCH_EDITOR_MODE: (
+ action: SwitchEditorMode,
+ editor: EditorModel,
+ userState: UserState,
+ ): EditorModel => {
+ // TODO this should probably be merged with UPDATE_EDITOR_MODE
+ if (action.unlessMode === editor.mode.type) {
+ // FIXME: this is a bit unfortunate as this action should just do what its name suggests, without additional flags.
+ // For now there's not much more that we can do since the action here can be (and is) evaluated also for transient states
+ // (e.g. a `textEdit` mode after an `insertMode`) created with wildcard patches.
+ return editor
+ }
+ if (isTextEditMode(action.mode)) {
+ if (
+ !MetadataUtils.targetTextEditable(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ action.mode.editedText,
+ )
+ ) {
+ // If the target of text edit mode isn't editable, then ignore the requested change.
+ console.error(`Invalid target for text edit mode: ${EP.toString(action.mode.editedText)}`)
+ return editor
+ }
+ }
+ if (isCommentMode(action.mode) && !isLoggedIn(userState.loginState)) {
+ return editor
+ }
+ return setModeState(action.mode, editor)
+ },
+ TOGGLE_CANVAS_IS_LIVE: (editor: EditorModel, derived: DerivedState): EditorModel => {
+ // same as UPDATE_EDITOR_MODE, but clears the drag state
+ if (isLiveMode(editor.mode)) {
+ return setModeState(EditorModes.selectMode(editor.mode.controlId, false, 'none'), editor)
+ } else {
+ return setModeState(
+ EditorModes.liveMode(isSelectMode(editor.mode) ? editor.mode.controlId : null),
+ editor,
+ )
+ }
+ },
+ ADD_TOAST: (action: AddToast, editor: EditorModel): EditorModel => {
+ return addToastToState(editor, action.toast)
+ },
+ SET_FORKING: (action: SetForking, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ forking: action.forking,
+ }
+ },
+ SET_COLLABORATORS: (action: SetCollaborators, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ collaborators: action.collaborators,
+ }
+ },
+ UPDATE_GITHUB_OPERATIONS: (action: UpdateGithubOperations, editor: EditorModel): EditorModel => {
+ const operations = [...editor.githubOperations]
+ switch (action.type) {
+ case 'add':
+ operations.push(action.operation)
+ break
+ case 'remove':
+ const idx = operations.indexOf(action.operation)
+ if (idx >= 0) {
+ operations.splice(idx, 1)
+ }
+ break
+ default:
+ const _exhaustiveCheck: never = action.type
+ throw new Error('Unknown operation type.')
+ }
+ return {
+ ...editor,
+ githubOperations: operations,
+ }
+ },
+ UPDATE_IMPORT_OPERATIONS: (action: UpdateImportOperations, editor: EditorModel): EditorModel => {
+ const resultImportOperations = getUpdateOperationResult(
+ editor.importState.importOperations,
+ action.operations,
+ action.type,
+ )
+ return {
+ ...editor,
+ importState: { ...editor.importState, importOperations: resultImportOperations },
+ }
+ },
+ UPDATE_IMPORT_STATUS: (action: UpdateImportStatus, editor: EditorModel): EditorModel => {
+ const newImportState = {
+ ...editor.importState,
+ importStatus: action.importStatus,
+ }
+ // side effect ☢️
+ notifyImportStatusToDiscord(newImportState, editor.projectName)
+ return {
+ ...editor,
+ importState: newImportState,
+ }
+ },
+ UPDATE_PROJECT_REQUIREMENTS: (
+ action: UpdateProjectRequirements,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ const result = updateRequirements(dispatch, editor.projectRequirements, action.requirements)
+ return {
+ ...editor,
+ projectRequirements: result,
+ }
+ },
+ SET_IMPORT_WIZARD_OPEN: (action: SetImportWizardOpen, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ importWizardOpen: action.open,
+ }
+ },
+ SET_REFRESHING_DEPENDENCIES: (
+ action: SetRefreshingDependencies,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ refreshingDependencies: action.value,
+ }
+ },
+ REMOVE_TOAST: (action: RemoveToast, editor: EditorModel): EditorModel => {
+ return removeToastFromState(editor, action.id)
+ },
+ TOGGLE_HIDDEN: (action: ToggleHidden, editor: EditorModel): EditorModel => {
+ const targets = action.targets.length > 0 ? action.targets : editor.selectedViews
+ return targets.reduce((working, target) => {
+ if (working.hiddenInstances.some((element) => EP.pathsEqual(element, target))) {
+ return update(working, {
+ hiddenInstances: {
+ $set: working.hiddenInstances.filter((element) => !EP.pathsEqual(element, target)),
+ },
+ })
+ } else {
+ return update(working, {
+ hiddenInstances: { $set: working.hiddenInstances.concat(target) },
+ })
+ }
+ }, editor)
+ },
+ TOGGLE_DATA_CAN_CONDENSE: (action: ToggleDataCanCondense, editor: EditorModel): EditorModel => {
+ let working = { ...editor }
+ for (const path of action.targets) {
+ working = modifyOpenJsxChildAtPath(
+ path,
+ (element) => {
+ const canCondense = canCondenseJSXElementChild(element)
+ if (canCondense === true) {
+ return removePropOrFlagComment(element, 'can-condense')
+ }
+ // Note: we just return the element if we can not annotate it
+ return (
+ saveToPropOrFlagComment(element, {
+ type: 'can-condense',
+ value: true,
+ }) ?? element
+ )
+ },
+ working,
+ )
+ }
+ return working
+ },
+ RENAME_COMPONENT: (action: RenameComponent, editor: EditorModel): EditorModel => {
+ const { name } = action
+ const target = action.target
+ let propsTransform: (props: JSXAttributes) => Either
+ if (name == null) {
+ propsTransform = (props) => unsetJSXValueAtPath(props, PathForSceneDataLabel)
+ } else {
+ propsTransform = (props) =>
+ setJSXValueAtPath(props, PathForSceneDataLabel, jsExpressionValue(name, emptyComments))
+ }
+ return modifyOpenJsxElementAtPath(
+ target,
+ (element) => {
+ const updatedElementProps = propsTransform(element.props)
+ return foldEither(
+ () => element,
+ (elementProps) => {
+ return {
+ ...element,
+ props: elementProps,
+ }
+ },
+ updatedElementProps,
+ )
+ },
+ editor,
+ )
+ },
+ INSERT_JSX_ELEMENT: (action: InsertJSXElement, editor: EditorModel): EditorModel => {
+ let newSelectedViews: ElementPath[] = []
+ const parentPath =
+ action.target == null
+ ? // action.target == null means Canvas, which means storyboard root element
+ forceNotNull(
+ 'found no element path for the storyboard root',
+ getStoryboardElementPath(editor.projectContents, editor.canvas.openFile?.filename),
+ )
+ : action.target
+
+ const withNewElement = modifyUnderlyingTargetElement(
+ parentPath,
+ editor,
+ (element) => element,
+ (success, _, underlyingFilePath) => {
+ const components = getUtopiaJSXComponentsFromSuccess(success)
+
+ const updatedImports = mergeImports(
+ underlyingFilePath,
+ getFilePathMappings(editor.projectContents),
+ success.imports,
+ action.importsToAdd,
+ )
+
+ const { imports, duplicateNameMapping } = updatedImports
+
+ const fixedElement = renameJsxElementChild(action.jsxElement, duplicateNameMapping)
+
+ const withInsertedElement = insertJSXElementChildren(
+ childInsertionPath(parentPath),
+ [fixedElement],
+ components,
+ action.indexPosition,
+ )
+
+ const uid = getUtopiaID(fixedElement)
+ const newPath = EP.appendToPath(parentPath, uid)
+ newSelectedViews.push(newPath)
+
+ const updatedTopLevelElements = applyUtopiaJSXComponentsChanges(
+ success.topLevelElements,
+ withInsertedElement.components,
+ )
+
+ return {
+ ...success,
+ topLevelElements: updatedTopLevelElements,
+ imports: imports,
+ }
+ },
+ )
+
+ return {
+ ...withNewElement,
+ leftMenu: { visible: editor.leftMenu.visible, selectedTab: LeftMenuTab.Navigator },
+ selectedViews: newSelectedViews,
+ }
+ },
+ REPLACE_JSX_ELEMENT: (action: ReplaceJSXElement, editor: EditorModel): EditorModel => {
+ const withNewElement = modifyUnderlyingParseSuccessOnly(
+ action.target,
+ editor,
+ (success, underlyingFilePath) => {
+ const startingComponents = getUtopiaJSXComponentsFromSuccess(success)
+
+ const originalElement = findJSXElementChildAtPath(
+ startingComponents,
+ EP.dynamicPathToStaticPath(action.target),
+ )
+
+ if (originalElement == null) {
+ return success
+ }
+
+ const { imports, duplicateNameMapping } = mergeImports(
+ underlyingFilePath,
+ getFilePathMappings(editor.projectContents),
+ success.imports,
+ action.importsToAdd,
+ )
+
+ const fixedElement = (() => {
+ const elemenWithOriginalUid = setUtopiaID(
+ action.jsxElement,
+ getUtopiaID(originalElement),
+ ) as JSXElement
+
+ const renamedJsxElement = renameJsxElementChild(
+ elemenWithOriginalUid,
+ duplicateNameMapping,
+ )
+ if (
+ !isReplaceKeepChildrenAndStyleTarget(action.behaviour) ||
+ originalElement.type !== 'JSX_ELEMENT'
+ ) {
+ return renamedJsxElement
+ }
+
+ // apply the style of original element on the new element
+ const renamedJsxElementWithOriginalStyle = applyUpdateToJSXElement(
+ renamedJsxElement,
+ (props) => {
+ const styleProps = getJSXAttribute(originalElement.props, 'style')
+ if (styleProps == null) {
+ return right(deleteJSXAttribute(props, 'style'))
+ } else {
+ return right(setJSXAttributesAttribute(props, 'style', styleProps))
+ }
+ },
+ )
+
+ if (originalElement.children.length > 0) {
+ // apply the children of original element on the new element
+ return {
+ ...renamedJsxElementWithOriginalStyle,
+ children: originalElement.children,
+ }
+ }
+ return renamedJsxElementWithOriginalStyle
+ })()
+
+ const updatedComponents = transformJSXComponentAtPath(
+ startingComponents,
+ EP.dynamicPathToStaticPath(action.target),
+ () => fixedElement,
+ )
+
+ const updatedTopLevelElements = applyUtopiaJSXComponentsChanges(
+ success.topLevelElements,
+ updatedComponents,
+ )
+
+ return {
+ ...success,
+ topLevelElements: updatedTopLevelElements,
+ imports: imports,
+ }
+ },
+ )
+
+ return {
+ ...withNewElement,
+ leftMenu: { visible: editor.leftMenu.visible, selectedTab: LeftMenuTab.Navigator },
+ }
+ },
+ REPLACE_MAPPED_ELEMENT: (action: ReplaceMappedElement, editor: EditorModel): EditorModel => {
+ let newSelectedViews: ElementPath[] = []
+ const parentPath =
+ action.target == null
+ ? // action.target == null means Canvas, which means storyboard root element
+ forceNotNull(
+ 'found no element path for the storyboard root',
+ getStoryboardElementPath(editor.projectContents, editor.canvas.openFile?.filename),
+ )
+ : EP.isIndexedElement(action.target)
+ ? EP.parentPath(action.target)
+ : action.target
+
+ const withNewElement = modifyUnderlyingTarget(
+ parentPath,
+ editor,
+ (element) => element,
+ (success, _, underlyingFilePath): ParseSuccess => {
+ const startingComponents = getUtopiaJSXComponentsFromSuccess(success)
+ const updatedImports = mergeImports(
+ underlyingFilePath,
+ getFilePathMappings(editor.projectContents),
+ success.imports,
+ action.importsToAdd,
+ )
+
+ const renamedJsxElement = renameJsxElementChild(
+ action.jsxElement,
+ updatedImports.duplicateNameMapping,
+ )
+
+ const withInsertedElement = transformJSXComponentAtPath(
+ startingComponents,
+ EP.dynamicPathToStaticPath(parentPath),
+ (parentElement) => {
+ if (!isJSXMapExpression(parentElement)) {
+ return parentElement
+ }
+ const mapFunction = parentElement.mapFunction
+ if (!isJSExpressionOtherJavaScript(mapFunction)) {
+ return parentElement
+ }
+
+ const uidToUse = Object.keys(mapFunction.elementsWithin)[0] ?? renamedJsxElement.uid
+ return {
+ ...parentElement,
+ mapFunction: {
+ ...mapFunction,
+ elementsWithin: { [uidToUse]: { ...renamedJsxElement, uid: uidToUse } },
+ },
+ }
+ },
+ )
+
+ const updatedTopLevelElements = applyUtopiaJSXComponentsChanges(
+ success.topLevelElements,
+ withInsertedElement,
+ )
+
+ return {
+ ...success,
+ topLevelElements: updatedTopLevelElements,
+ imports: updatedImports.imports,
+ }
+ },
+ )
+ return {
+ ...withNewElement,
+ leftMenu: { visible: editor.leftMenu.visible, selectedTab: LeftMenuTab.Navigator },
+ selectedViews: newSelectedViews,
+ }
+ },
+ REPLACE_ELEMENT_IN_SCOPE: (action: ReplaceElementInScope, editor: EditorModel): EditorModel => {
+ const replaceChildWithUid = (
+ element: JSXElementChild,
+ uid: string,
+ replaceWith: JSXElementChild,
+ ): JSXElementChild => {
+ if (element.type !== 'JSX_ELEMENT' && element.type !== 'JSX_FRAGMENT') {
+ return element
+ }
+
+ return {
+ ...element,
+ children: element.children.map((c) => (c.uid !== uid ? c : replaceWith)),
+ }
+ }
+
+ const updateMapExpression = (
+ element: JSXElementChild,
+ valueToMap: JSExpression,
+ ): JSXElementChild => {
+ if (element.type !== 'JSX_MAP_EXPRESSION') {
+ return element
+ }
+ return {
+ ...element,
+ valueToMap: valueToMap,
+ }
+ }
+
+ const replacePropertyValue = (
+ element: JSXElementChild,
+ propertyPath: PropertyPath,
+ replaceWith: JSExpression,
+ ): JSXElementChild => {
+ if (element.type !== 'JSX_ELEMENT') {
+ return element
+ }
+ return {
+ ...element,
+ props: defaultEither(
+ element.props,
+ setJSXValueAtPath(element.props, propertyPath, replaceWith),
+ ),
+ }
+ }
+
+ return modifyUnderlyingTarget(action.target, editor, (element) => {
+ const replacementPath = action.replacementPath
+ switch (replacementPath.type) {
+ case 'replace-child-with-uid':
+ return replaceChildWithUid(element, replacementPath.uid, replacementPath.replaceWith)
+ case 'replace-property-value':
+ return replacePropertyValue(
+ element,
+ replacementPath.propertyPath,
+ replacementPath.replaceWith,
+ )
+ case 'update-map-expression':
+ return updateMapExpression(element, replacementPath.valueToMap)
+ default:
+ assertNever(replacementPath)
+ }
+ })
+ },
+ INSERT_ATTRIBUTE_OTHER_JAVASCRIPT_INTO_ELEMENT: (
+ action: InsertAttributeOtherJavascriptIntoElement,
+ editor: EditorModel,
+ ): EditorModel => {
+ const withNewElement = modifyUnderlyingTargetJSXElement(action.parent, editor, (element) => {
+ return {
+ ...element,
+ children: [action.expression],
+ }
+ })
+ return {
+ ...withNewElement,
+ leftMenu: { visible: editor.leftMenu.visible, selectedTab: LeftMenuTab.Navigator },
+ }
+ },
+ WRAP_IN_ELEMENT: (action: WrapInElement, editor: EditorModel): EditorModel => {
+ const orderedActionTargets = getZIndexOrderedViewsWithoutDirectChildren(
+ action.targets,
+ getNavigatorTargetsFromEditorState(editor).navigatorTargets,
+ )
+
+ const parentPath = commonInsertionPathFromArray(
+ editor.jsxMetadata,
+ orderedActionTargets.map((actionTarget) => {
+ return MetadataUtils.getReparentTargetOfTarget(editor.jsxMetadata, actionTarget)
+ }),
+ replaceWithSingleElement(),
+ )
+ if (parentPath == null) {
+ return editor
+ }
+ // If any of the targets are a root element, we check that the parentPath is its parent
+ // If not, we bail and do nothing
+ // If it is, we add the new element as the root element of the parent instance
+ const anyTargetIsARootElement = orderedActionTargets.some(EP.isRootElementOfInstance)
+ const targetThatIsRootElementOfCommonParent = orderedActionTargets.find(
+ (elementPath) =>
+ EP.isRootElementOfInstance(elementPath) &&
+ EP.isParentOf(getElementPathFromInsertionPath(parentPath), elementPath),
+ )
+
+ if (anyTargetIsARootElement) {
+ const showToastAction = showToast(
+ notice(`Root elements can't be wrapped into other elements.`),
+ )
+ return UPDATE_FNS.ADD_TOAST(showToastAction, editor)
+ }
+
+ const anyTargetIsAnEmptyGroup = orderedActionTargets.some((path) =>
+ isEmptyGroup(editor.jsxMetadata, path),
+ )
+ if (anyTargetIsAnEmptyGroup) {
+ return UPDATE_FNS.ADD_TOAST(
+ showToast(notice('Empty Groups cannot be wrapped', 'ERROR')),
+ editor,
+ )
+ }
+
+ if (
+ isMaybeGroupForWrapping(action.whatToWrapWith.element, action.whatToWrapWith.importsToAdd) &&
+ orderedActionTargets.some((path) => {
+ return !elementCanBeAGroupChild(
+ MetadataUtils.getJsxElementChildFromMetadata(editor.jsxMetadata, path),
+ path,
+ editor.jsxMetadata,
+ )
+ })
+ ) {
+ return UPDATE_FNS.ADD_TOAST(
+ showToast(notice('Not all targets can be wrapped into a Group', 'ERROR')),
+ editor,
+ )
+ }
+
+ const detailsOfUpdate = null
+ const { updatedEditor, newPath } = wrapElementInsertions(
+ editor,
+ action.targets,
+ parentPath,
+ action.whatToWrapWith.element,
+ action.whatToWrapWith.importsToAdd,
+ anyTargetIsARootElement,
+ targetThatIsRootElementOfCommonParent,
+ )
+ if (newPath == null) {
+ return editor
+ }
+ const withFixedParents = fixParentContainingBlockSettings(updatedEditor, newPath)
+
+ // TODO maybe update frames and position
+ const frameChanges: Array = []
+ const withWrapperViewAdded = {
+ ...setCanvasFramesInnerNew(
+ includeToast(detailsOfUpdate, withFixedParents),
+ frameChanges,
+ null,
+ ),
+ }
+
+ const wrapperUID = generateUidWithExistingComponents(editor.projectContents)
+ const intendedParentPath = EP.dynamicPathToStaticPath(newPath)
+ const insertionBehavior =
+ action.targets.length === 1
+ ? replaceWithSingleElement()
+ : replaceWithElementsWrappedInFragmentBehaviour(wrapperUID)
+
+ let insertionResult: {
+ editor: EditorModel
+ newPaths: Array
+ }
+
+ if (isJSXMapExpression(action.whatToWrapWith.element)) {
+ // in maps we do not insert directly, but replace contents
+ insertionResult = replaceInsideMap(
+ orderedActionTargets,
+ intendedParentPath,
+ insertionBehavior,
+ includeToast(detailsOfUpdate, withWrapperViewAdded),
+ )
+ } else if (isJSXConditionalExpression(action.whatToWrapWith.element)) {
+ // for conditionals we're inserting into the true-case according to behavior
+ insertionResult = insertIntoWrapper(
+ orderedActionTargets,
+ conditionalClauseInsertionPath(intendedParentPath, 'true-case', insertionBehavior),
+ includeToast(detailsOfUpdate, withWrapperViewAdded),
+ )
+ } else {
+ // otherwise we fall back to standard child insertion
+ insertionResult = insertIntoWrapper(
+ orderedActionTargets,
+ childInsertionPath(intendedParentPath),
+ includeToast(detailsOfUpdate, withWrapperViewAdded),
+ )
+ }
+
+ const editorWithElementsInserted = insertionResult.editor
+ const newPaths = insertionResult.newPaths
+
+ return {
+ ...editorWithElementsInserted,
+ selectedViews: [intendedParentPath],
+ leftMenu: { visible: editor.leftMenu.visible, selectedTab: LeftMenuTab.Navigator },
+ highlightedViews: [],
+ trueUpElementsAfterDomWalkerRuns: [
+ ...editorWithElementsInserted.trueUpElementsAfterDomWalkerRuns,
+ ...newPaths.map(trueUpGroupElementChanged),
+ ],
+ }
+ },
+ UNWRAP_ELEMENTS: (
+ action: UnwrapElements,
+ editor: EditorModel,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorModel => {
+ let groupTrueUps: ElementPath[] = []
+ let viewsToDelete: ElementPath[] = []
+ let newSelection: ElementPath[] = []
+
+ // order paths by depth
+ const orderedPaths = EP.getOrderedPathsByDepth(action.targets)
+
+ // make sure to trim descendant paths, so that unwrapping on a subtree only works on the first ancestor
+ const flattenedPaths = flattenSelection(orderedPaths)
+
+ const withViewsUnwrapped: EditorState = flattenedPaths.reduce((workingEditor, target) => {
+ const supportsChildren = MetadataUtils.targetSupportsChildren(
+ workingEditor.projectContents,
+ workingEditor.jsxMetadata,
+ target,
+ workingEditor.elementPathTree,
+ workingEditor.propertyControlsInfo,
+ )
+
+ const elementIsFragmentLike = treatElementAsFragmentLike(
+ workingEditor.jsxMetadata,
+ workingEditor.allElementProps,
+ workingEditor.elementPathTree,
+ target,
+ )
+
+ if (!(supportsChildren || elementIsFragmentLike)) {
+ return workingEditor
+ }
+
+ viewsToDelete.push(target)
+
+ const parentPath = MetadataUtils.getReparentTargetOfTarget(editor.jsxMetadata, target)
+
+ const indexPosition: IndexPosition = indexPositionForAdjustment(
+ target,
+ workingEditor,
+ 'forward',
+ )
+ const children = MetadataUtils.getChildrenOrdered(
+ workingEditor.jsxMetadata,
+ workingEditor.elementPathTree,
+ target,
+ ).reverse() // children are reversed so when they are readded one by one as 'forward' index they keep their original order
+ const isGroupChild = treatElementAsGroupLike(workingEditor.jsxMetadata, EP.parentPath(target))
+
+ if (parentPath != null && isConditionalClauseInsertionPath(parentPath)) {
+ return unwrapConditionalClause(workingEditor, target, parentPath)
+ }
+ if (elementIsFragmentLike) {
+ if (isTextContainingConditional(target, workingEditor.jsxMetadata)) {
+ return unwrapTextContainingConditional(workingEditor, target)
+ }
+
+ const { editor: withChildrenMoved, newPaths } = editorMoveMultiSelectedTemplates(
+ builtInDependencies,
+ children.map((child) => child.elementPath),
+ indexPosition,
+ parentPath,
+ workingEditor,
+ )
+
+ return {
+ ...withChildrenMoved,
+ selectedViews: newPaths,
+ canvas: {
+ ...withChildrenMoved.canvas,
+ domWalkerInvalidateCount: workingEditor.canvas.domWalkerInvalidateCount + 1,
+ },
+ }
+ } else {
+ const parentFrame =
+ parentPath == null
+ ? (Utils.zeroRectangle as CanvasRectangle)
+ : MetadataUtils.getFrameOrZeroRectInCanvasCoords(
+ parentPath.intendedParentPath,
+ workingEditor.jsxMetadata,
+ )
+
+ const withChildrenMoved = children.reduce((working, child) => {
+ if (parentPath == null) {
+ return working
+ }
+ const result = reparentElementToUnwrap(
+ child.elementPath,
+ parentPath,
+ indexPosition,
+ working,
+ builtInDependencies,
+ )
+ if (result.newPath != null) {
+ const newPath = result.newPath
+ newSelection.push(newPath)
+ if (isGroupChild) {
+ groupTrueUps.push(newPath)
+ return foldAndApplyCommandsSimple(
+ result.editor,
+ createPinChangeCommandsForElementBecomingGroupChild(
+ workingEditor.jsxMetadata,
+ child,
+ newPath,
+ parentFrame,
+ localRectangle(parentFrame),
+ ),
+ )
+ }
+ return result.editor
+ }
+ return working
+ }, workingEditor)
+
+ return {
+ ...withChildrenMoved,
+ canvas: {
+ ...withChildrenMoved.canvas,
+ domWalkerInvalidateCount: workingEditor.canvas.domWalkerInvalidateCount + 1,
+ },
+ }
+ }
+ }, editor)
+
+ function adjustPathAfterWrap(paths: ElementPath[], path: ElementPath) {
+ return paths
+ .filter((other) => EP.isDescendantOf(path, other))
+ .reduce((current, ancestor) => {
+ return EP.replaceIfAncestor(current, ancestor, EP.parentPath(ancestor)) ?? current
+ }, path)
+ }
+ const adjustedViewsToDelete = viewsToDelete.map((path) => {
+ // make sure the paths to delete reflect the updated paths as per the unwrapping if there are nested
+ // selected views under a common ancestor
+ return adjustPathAfterWrap(viewsToDelete, path)
+ })
+ const adjustedGroupTrueUps = groupTrueUps.map((path) => {
+ return adjustPathAfterWrap(groupTrueUps, path)
+ })
+
+ const withViewsDeleted = deleteElements(adjustedViewsToDelete, withViewsUnwrapped, {
+ trueUpHuggingElements: false,
+ })
+ return {
+ ...withViewsDeleted,
+ selectedViews: newSelection,
+ leftMenu: {
+ visible: withViewsDeleted.leftMenu.visible,
+ selectedTab: LeftMenuTab.Navigator,
+ },
+ trueUpElementsAfterDomWalkerRuns: [
+ ...withViewsDeleted.trueUpElementsAfterDomWalkerRuns,
+ ...adjustedGroupTrueUps.map(trueUpGroupElementChanged),
+ ],
+ }
+ },
+ SET_PANEL_VISIBILITY: (action: SetPanelVisibility, editor: EditorModel): EditorModel => {
+ switch (action.target) {
+ case 'leftmenu':
+ return {
+ ...editor,
+ leftMenu: {
+ ...editor.leftMenu,
+ visible: action.visible,
+ },
+ }
+ case 'navigator':
+ return {
+ ...editor,
+ navigator: {
+ ...editor.navigator,
+ minimised: !action.visible,
+ },
+ }
+ case 'filebrowser':
+ return {
+ ...editor,
+ fileBrowser: {
+ ...editor.fileBrowser,
+ minimised: !action.visible,
+ },
+ }
+ case 'dependencylist':
+ return {
+ ...editor,
+ dependencyList: {
+ ...editor.dependencyList,
+ minimised: !action.visible,
+ },
+ }
+ case 'genericExternalResources':
+ return {
+ ...editor,
+ genericExternalResources: {
+ ...editor.dependencyList,
+ minimised: !action.visible,
+ },
+ }
+ case 'googleFontsResources':
+ return {
+ ...editor,
+ googleFontsResources: {
+ ...editor.dependencyList,
+ minimised: !action.visible,
+ },
+ }
+ case 'inspector':
+ return {
+ ...editor,
+ inspector: {
+ ...editor.inspector,
+ visible: action.visible,
+ },
+ }
+ case 'rightmenu':
+ return {
+ ...editor,
+ rightMenu: {
+ ...editor.rightMenu,
+ visible: action.visible,
+ },
+ }
+ case 'codeEditor':
+ return {
+ ...editor,
+ interfaceDesigner: {
+ ...editor.interfaceDesigner,
+ codePaneVisible: action.visible,
+ },
+ }
+ case 'misccodeeditor':
+ case 'canvas':
+ case 'center':
+ case 'insertmenu':
+ case 'projectsettings':
+ case 'githuboptions':
+ return editor
+ default:
+ const _exhaustiveCheck: never = action.target
+ return editor
+ }
+ },
+ TOGGLE_FOCUSED_OMNIBOX_TAB: (editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ topmenu: {
+ ...editor.topmenu,
+ formulaBarMode: editor.topmenu.formulaBarMode === 'css' ? 'content' : 'css',
+ },
+ }
+ },
+ TOGGLE_PANE: (action: TogglePane, editor: EditorModel): EditorModel => {
+ switch (action.target) {
+ case 'leftmenu':
+ return {
+ ...editor,
+ leftMenu: {
+ ...editor.leftMenu,
+ visible: !editor.leftMenu.visible,
+ },
+ }
+ case 'rightmenu':
+ return {
+ ...editor,
+ rightMenu: {
+ ...editor.rightMenu,
+ visible: !editor.rightMenu.visible,
+ },
+ }
+ case 'dependencylist':
+ return {
+ ...editor,
+ dependencyList: {
+ ...editor.dependencyList,
+ minimised: !editor.dependencyList.minimised,
+ },
+ }
+ case 'genericExternalResources':
+ return {
+ ...editor,
+ genericExternalResources: {
+ ...editor.genericExternalResources,
+ minimised: !editor.genericExternalResources.minimised,
+ },
+ }
+ case 'googleFontsResources':
+ return {
+ ...editor,
+ googleFontsResources: {
+ ...editor.googleFontsResources,
+ minimised: !editor.googleFontsResources.minimised,
+ },
+ }
+ case 'filebrowser':
+ return {
+ ...editor,
+ fileBrowser: {
+ ...editor.fileBrowser,
+ minimised: !editor.fileBrowser.minimised,
+ },
+ }
+ case 'navigator':
+ return {
+ ...editor,
+ navigator: {
+ ...editor.navigator,
+ minimised: !editor.navigator.minimised,
+ },
+ }
+ case 'inspector':
+ return {
+ ...editor,
+ inspector: {
+ ...editor.inspector,
+ visible: !editor.inspector.visible,
+ },
+ }
+ case 'projectsettings':
+ return {
+ ...editor,
+ projectSettings: {
+ ...editor.projectSettings,
+ minimised: !editor.projectSettings.minimised,
+ },
+ }
+
+ case 'codeEditor':
+ return updateCodeEditorVisibility(editor, !editor.interfaceDesigner.codePaneVisible)
+ case 'canvas':
+ case 'misccodeeditor':
+ case 'center':
+ case 'insertmenu':
+ case 'githuboptions':
+ return editor
+ default:
+ const _exhaustiveCheck: never = action.target
+ return editor
+ }
+ },
+ TOGGLE_INTERFACEDESIGNER_ADDITIONAL_CONTROLS: (
+ action: ToggleInterfaceDesignerAdditionalControls,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ interfaceDesigner: {
+ ...editor.interfaceDesigner,
+ additionalControls: !editor.interfaceDesigner.additionalControls,
+ },
+ }
+ },
+
+ OPEN_POPUP: (action: OpenPopup, editor: EditorModel): EditorModel => {
+ return update(editor, {
+ openPopupId: { $set: action.popupId },
+ })
+ },
+ CLOSE_POPUP: (action: ClosePopup, editor: EditorModel): EditorModel => {
+ if (editor.openPopupId == null) {
+ return editor
+ }
+
+ return update(editor, {
+ openPopupId: { $set: null },
+ })
+ },
+ PASTE_PROPERTIES: (action: PasteProperties, editor: EditorModel): EditorModel => {
+ if (editor.internalClipboard.styleClipboard.length === 0) {
+ return editor
+ }
+ return editor.selectedViews.reduce((working, target) => {
+ return setPropertyOnTarget(working, target, (attributes) => {
+ const filterForNames = action.type === 'layout' ? LayoutPropertyList : StyleProperties
+ const originalPropsToUnset = filterForNames.map((propName) => PP.create('style', propName))
+ const withOriginalPropertiesCleared = unsetJSXValuesAtPaths(
+ attributes,
+ originalPropsToUnset,
+ )
+
+ const propsToSet = editor.internalClipboard.styleClipboard.filter(
+ (styleClipboardData: ValueAtPath) => {
+ const propName = PP.lastPartToString(styleClipboardData.path)
+ return filterForNames.includes(propName) ? styleClipboardData : null
+ },
+ )
+
+ return foldEither(
+ () => {
+ return right(attributes)
+ },
+ (withPropertiesCleared) => {
+ return setJSXValuesAtPaths(withPropertiesCleared, propsToSet)
+ },
+ withOriginalPropertiesCleared,
+ )
+ })
+ }, editor)
+ },
+ COPY_SELECTION_TO_CLIPBOARD: (
+ editor: EditorModel,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorModel => {
+ const canReparent = traverseEither(
+ (target) => canCopyElement(editor, target),
+ editor.selectedViews,
+ )
+
+ if (isLeft(canReparent)) {
+ const showToastAction = showToast(notice(canReparent.value))
+ return UPDATE_FNS.ADD_TOAST(showToastAction, editor)
+ }
+
+ return copySelectionToClipboardMutating(editor, builtInDependencies)
+ },
+ CUT_SELECTION_TO_CLIPBOARD: (
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorModel => {
+ const canReparent = traverseEither(
+ (target) => canCopyElement(editor, target),
+ editor.selectedViews,
+ )
+
+ if (isLeft(canReparent)) {
+ const showToastAction = showToast(notice(canReparent.value))
+ return UPDATE_FNS.ADD_TOAST(showToastAction, editor)
+ }
+
+ const isEmptyGroupOnStoryboard = editor.selectedViews.some(
+ (path) => EP.isStoryboardChild(path) && isEmptyGroup(editor.jsxMetadata, path),
+ )
+ if (isEmptyGroupOnStoryboard) {
+ return UPDATE_FNS.ADD_TOAST(
+ showToast(notice('Empty Groups on the storyboard cannot be cut', 'ERROR')),
+ editor,
+ )
+ }
+
+ const editorWithCopyData = copySelectionToClipboardMutating(editor, builtInDependencies)
+
+ return UPDATE_FNS.DELETE_SELECTED(editorWithCopyData, dispatch)
+ },
+ COPY_PROPERTIES: (action: CopyProperties, editor: EditorModel): EditorModel => {
+ if (editor.selectedViews.length === 0) {
+ return editor
+ } else {
+ const target = editor.selectedViews[0]
+ const styleProps = editor.currentAllElementProps[EP.toString(target)]?.style ?? {}
+ const styleClipboardData = Object.keys(styleProps).map((name) =>
+ valueAtPath(PP.create('style', name), jsExpressionValue(styleProps[name], emptyComments)),
+ )
+ return {
+ ...editor,
+ internalClipboard: {
+ styleClipboard: styleClipboardData,
+ elements: [],
+ },
+ }
+ }
+ },
+ OPEN_TEXT_EDITOR: (action: OpenTextEditor, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ textEditor: {
+ elementPath: action.target,
+ triggerMousePosition: action.mousePosition,
+ },
+ },
+ }
+ },
+ CLOSE_TEXT_EDITOR: (action: CloseTextEditor, editor: EditorModel): EditorModel => {
+ return update(editor, {
+ canvas: {
+ textEditor: {
+ $set: null,
+ },
+ },
+ })
+ },
+ SET_LEFT_MENU_TAB: (action: SetLeftMenuTab, editor: EditorModel): EditorModel => {
+ let result: EditorModel = updateSelectedLeftMenuTab(editor, action.tab)
+ // Show the menu if it's not already visible.
+ if (!result.leftMenu.visible) {
+ result = {
+ ...result,
+ leftMenu: {
+ ...result.leftMenu,
+ visible: true,
+ },
+ }
+ }
+ return result
+ },
+ SET_LEFT_MENU_EXPANDED: (action: SetLeftMenuExpanded, editor: EditorModel): EditorModel => {
+ return updateLeftMenuExpanded(editor, action.expanded)
+ },
+ SET_RIGHT_MENU_TAB: (action: SetRightMenuTab, editor: EditorModel): EditorModel => {
+ return updateSelectedRightMenuTab(editor, action.tab)
+ },
+ SET_RIGHT_MENU_EXPANDED: (action: SetRightMenuExpanded, editor: EditorModel): EditorModel => {
+ return updateRightMenuExpanded(editor, action.expanded)
+ },
+ TOGGLE_COLLAPSE: (action: ToggleCollapse, editor: EditorModel): EditorModel => {
+ if (editor.navigator.collapsedViews.some((element) => EP.pathsEqual(element, action.target))) {
+ return {
+ ...editor,
+ navigator: NavigatorStateKeepDeepEquality(editor.navigator, {
+ ...editor.navigator,
+ collapsedViews: editor.navigator.collapsedViews.filter(
+ (element) => !EP.pathsEqual(element, action.target),
+ ),
+ }).value,
+ }
+ } else {
+ return {
+ ...editor,
+ navigator: NavigatorStateKeepDeepEquality(editor.navigator, {
+ ...editor.navigator,
+ collapsedViews: editor.navigator.collapsedViews.concat(action.target),
+ }).value,
+ }
+ }
+ },
+ ADD_COLLAPSED_VIEWS: (action: AddCollapsedViews, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ navigator: {
+ ...editor.navigator,
+ collapsedViews: uniqBy(
+ [...editor.navigator.collapsedViews, ...action.collapsedViews],
+ EP.pathsEqual,
+ ),
+ },
+ }
+ },
+ UPDATE_KEYS_PRESSED: (action: UpdateKeysPressed, editor: EditorModel): EditorModel => {
+ if (Utils.shallowEqual(action.keys, editor.keysPressed)) {
+ return editor
+ }
+
+ return update(editor, {
+ keysPressed: { $set: action.keys },
+ })
+ },
+ UPDATE_MOUSE_BUTTONS_PRESSED: (
+ action: UpdateMouseButtonsPressed,
+ editor: EditorModel,
+ ): EditorModel => {
+ let mouseButtonsPressed: MouseButtonsPressed = editor.mouseButtonsPressed
+ if (action.added != null) {
+ mouseButtonsPressed = addButtonPressed(mouseButtonsPressed, action.added)
+ }
+ if (action.removed != null) {
+ mouseButtonsPressed = removeButtonPressed(mouseButtonsPressed, action.removed)
+ }
+ return {
+ ...editor,
+ mouseButtonsPressed: mouseButtonsPressed,
+ }
+ },
+ HIDE_MODAL: (action: HideModal, editor: EditorModel): EditorModel => {
+ return update(editor, {
+ modal: { $set: null },
+ })
+ },
+ SHOW_MODAL: (action: ShowModal, editor: EditorModel): EditorModel => {
+ return update(editor, {
+ modal: { $set: action.modal },
+ })
+ },
+ RESET_PINS: (action: ResetPins, editor: EditorModel): EditorModel => {
+ const target = action.target
+ const frame = MetadataUtils.getLocalFrame(target, editor.jsxMetadata, null)
+
+ if (frame == null || isInfinityRectangle(frame)) {
+ return editor
+ }
+ const commands = [
+ deleteProperties('always', target, [
+ PP.create('style', 'left'),
+ PP.create('style', 'right'),
+ PP.create('style', 'top'),
+ PP.create('style', 'bottom'),
+ ]),
+ ]
+ return foldAndApplyCommandsSimple(editor, commands)
+ },
+ SET_CURSOR_OVERLAY: (action: SetCursorOverlay, editor: EditorModel): EditorModel => {
+ if (editor.canvas.cursor === action.cursor) {
+ return editor
+ }
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ cursor: action.cursor,
+ },
+ }
+ },
+ UPDATE_FRAME_DIMENSIONS: (action: UpdateFrameDimensions, editor: EditorModel): EditorModel => {
+ const initialFrame = MetadataUtils.getLocalFrame(action.element, editor.jsxMetadata, null)
+
+ if (initialFrame == null || isInfinityRectangle(initialFrame)) {
+ return editor
+ }
+
+ let frame = {
+ x: initialFrame.x,
+ y: initialFrame.y,
+ width: action.width,
+ height: action.height,
+ } as LocalRectangle
+
+ const parentPath = EP.parentPath(action.element)
+ let offset = { x: 0, y: 0 } as CanvasPoint
+ if (parentPath != null) {
+ const parentFrame = MetadataUtils.getFrameInCanvasCoords(parentPath, editor.jsxMetadata)
+ if (parentFrame != null && isFiniteRectangle(parentFrame)) {
+ offset = { x: parentFrame.x, y: parentFrame.y } as CanvasPoint
+ }
+ }
+ const canvasFrame = Utils.getCanvasRectangleWithCanvasOffset(offset, frame)
+
+ const isParentFlex = MetadataUtils.isParentYogaLayoutedContainerAndElementParticipatesInLayout(
+ action.element,
+ editor.jsxMetadata,
+ )
+ const frameChanges: Array = [
+ getFrameChange(action.element, canvasFrame, isParentFlex),
+ ]
+ const withFrameUpdated = setCanvasFramesInnerNew(editor, frameChanges, null)
+ return {
+ ...withFrameUpdated,
+ trueUpElementsAfterDomWalkerRuns: [
+ ...withFrameUpdated.trueUpElementsAfterDomWalkerRuns,
+ trueUpGroupElementChanged(action.element),
+ ],
+ }
+ },
+ SET_NAVIGATOR_RENAMING_TARGET: (
+ action: SetNavigatorRenamingTarget,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ navigator: {
+ ...editor.navigator,
+ renamingTarget: action.target,
+ },
+ }
+ },
+ SET_STORED_FONT_SETTINGS: (action: SetStoredFontSettings, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ lastUsedFont: action.fontSettings,
+ }
+ },
+ SAVE_CURRENT_FILE: (action: SaveCurrentFile, editor: EditorModel): EditorModel => {
+ const openFilePath = getOpenTextFileKey(editor)
+ if (openFilePath != null) {
+ return {
+ ...editor,
+ projectContents: saveFileInProjectContents(editor.projectContents, openFilePath),
+ }
+ }
+ return editor
+ },
+ SAVE_ASSET: (
+ action: SaveAsset,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ userState: UserState,
+ ): EditorModel => {
+ const replaceImage = action.imageDetails?.afterSave.type === 'SAVE_IMAGE_REPLACE'
+ const assetFilename = replaceImage
+ ? action.fileName
+ : uniqueProjectContentID(action.fileName, editor.projectContents)
+ const notNullProjectID = Utils.forceNotNull('No project ID', editor.id)
+
+ const width = Utils.pathOr(undefined, ['imageDetails', 'imageSize', 'width'], action)
+ const height = Utils.pathOr(undefined, ['imageDetails', 'imageSize', 'height'], action)
+
+ const imageURL = imagePathURL(assetFilename)
+ const imageAttribute = jsExpressionValue(imageURL, emptyComments)
+
+ const newUID = generateUidWithExistingComponents(editor.projectContents)
+ const openUIJSFile = getOpenUIJSFileKey(editor)
+
+ let actionsToRunAfterSave: Array = []
+ // Bit weird, but when replacing an image, we need to change the URLs only once the image has been saved.
+ if (action.imageDetails != null) {
+ if (replaceImage) {
+ const imageWithoutHashURL = imagePathURL(assetFilename)
+ const propertyPath = PP.create('src')
+ walkContentsTreeForParseSuccess(editor.projectContents, (filePath, success) => {
+ walkElements(getUtopiaJSXComponentsFromSuccess(success), (element, elementPath) => {
+ if (isJSXElement(element)) {
+ const srcAttribute = getJSXAttribute(element.props, 'src')
+ if (srcAttribute != null && isJSXAttributeValue(srcAttribute)) {
+ const srcValue: JSExpressionValue = srcAttribute
+ if (
+ typeof srcValue.value === 'string' &&
+ srcValue.value.startsWith(imageWithoutHashURL)
+ ) {
+ // Balazs: I think this code was already dormant / broken, keeping it as a comment for reference
+ // actionsToRunAfterSave.push(
+ // setPropWithElementPath_UNSAFE(elementPath, propertyPath, imageAttribute),
+ // )
+ }
+ }
+ }
+ })
+ })
+ } else {
+ if (action.imageDetails.afterSave.type !== 'SAVE_IMAGE_DO_NOTHING') {
+ actionsToRunAfterSave.push(
+ clearImageFileBlob(Utils.forceNotNull('Need an open UI file.', openUIJSFile), newUID),
+ )
+ }
+ }
+ }
+
+ let projectFile: ProjectFile
+ if (action.imageDetails == null) {
+ // Assume stock ASSET_FILE case when there's no image details.
+ projectFile = assetFile(undefined, action.gitBlobSha)
+ } else {
+ // Assume IMAGE_FILE otherwise.
+ projectFile = imageFile(
+ action.fileType,
+ undefined,
+ width,
+ height,
+ action.hash,
+ action.gitBlobSha,
+ )
+ }
+ actionsToRunAfterSave.push(updateFile(assetFilename, projectFile, true))
+
+ // Side effects.
+ let editorWithToast = editor
+ if (isLoggedIn(userState.loginState) && editor.id != null) {
+ saveAssetToServer(notNullProjectID, action.fileType, action.base64, assetFilename)
+ .then((checksum) => {
+ dispatch(
+ [
+ ...actionsToRunAfterSave,
+ showToast(notice(`Succesfully uploaded ${assetFilename}`, 'INFO')),
+ ],
+ 'everyone',
+ )
+ })
+ .catch(() => {
+ dispatch([showToast(notice(`Failed to upload ${assetFilename}`, 'ERROR'))])
+ })
+ } else {
+ editorWithToast = UPDATE_FNS.ADD_TOAST(
+ showToast(notice(`Please log in to upload assets`, 'ERROR', true)),
+ editor,
+ )
+ }
+
+ const updatedProjectContents = addFileToProjectContents(
+ editor.projectContents,
+ assetFilename,
+ projectFile,
+ )
+
+ let updatedBlobs: CanvasBase64Blobs = editor.canvas.base64Blobs
+
+ if (openUIJSFile != null) {
+ const existingFileBlobs = Utils.defaultIfNull(
+ {},
+ editor.canvas.base64Blobs[action.fileName],
+ )
+ const updatedFileBlobs: UIFileBase64Blobs = {
+ ...existingFileBlobs,
+ [newUID]: {
+ base64: action.base64,
+ },
+ }
+
+ updatedBlobs = {
+ ...editor.canvas.base64Blobs,
+ [openUIJSFile]: updatedFileBlobs,
+ }
+ }
+
+ if (action.imageDetails == null) {
+ return editor
+ } else {
+ switch (action.imageDetails.afterSave.type) {
+ case 'SAVE_IMAGE_SWITCH_MODE': {
+ // TODO make a default image and put it in defaults
+ const imageElement = jsxElement(
+ jsxElementName('img', []),
+ newUID,
+ jsxAttributesFromMap({
+ alt: jsExpressionValue('', emptyComments),
+ src: imageAttribute,
+ style: jsExpressionValue({ width: width, height: height }, emptyComments),
+ 'data-uid': jsExpressionValue(newUID, emptyComments),
+ [AspectRatioLockedProp]: jsExpressionValue(true, emptyComments),
+ }),
+ [],
+ )
+ const size = width != null && height != null ? { width: width, height: height } : null
+ const switchMode = enableInsertModeForJSXElement(imageElement, newUID, {}, size)
+ const editorInsertEnabled = UPDATE_FNS.SWITCH_EDITOR_MODE(
+ switchMode,
+ editorWithToast,
+ userState,
+ )
+ return {
+ ...editorInsertEnabled,
+ projectContents: updatedProjectContents,
+ canvas: {
+ ...editorInsertEnabled.canvas,
+ base64Blobs: updatedBlobs,
+ },
+ }
+ }
+ case 'SAVE_IMAGE_INSERT_WITH': {
+ const parent =
+ action.imageDetails.afterSave.parentPath == null
+ ? null
+ : MetadataUtils.resolveReparentTargetParentToPath(
+ editor.jsxMetadata,
+ action.imageDetails.afterSave.parentPath,
+ )
+ const relativeFrame = MetadataUtils.getFrameRelativeTo(
+ parent,
+ editor.jsxMetadata,
+ action.imageDetails.afterSave.frame,
+ )
+
+ const imageElement = jsxElement(
+ jsxElementName('img', []),
+ newUID,
+ jsxAttributesFromMap({
+ alt: jsExpressionValue('', emptyComments),
+ src: imageAttribute,
+ style: MetadataUtils.isFlexLayoutedContainer(
+ MetadataUtils.findElementByElementPath(editor.jsxMetadata, parent),
+ )
+ ? jsExpressionValue(
+ {
+ width: relativeFrame.width,
+ height: relativeFrame.height,
+ },
+ emptyComments,
+ )
+ : jsExpressionValue(
+ {
+ position: 'absolute',
+ left: relativeFrame.x,
+ top: relativeFrame.y,
+ width: relativeFrame.width,
+ height: relativeFrame.height,
+ },
+ emptyComments,
+ ),
+ 'data-uid': jsExpressionValue(newUID, emptyComments),
+ [AspectRatioLockedProp]: jsExpressionValue(true, emptyComments),
+ }),
+ [],
+ )
+
+ const insertJSXElementAction = insertJSXElement(imageElement, parent, {})
+
+ const withComponentCreated = UPDATE_FNS.INSERT_JSX_ELEMENT(insertJSXElementAction, {
+ ...editorWithToast,
+ projectContents: updatedProjectContents,
+ })
+ return {
+ ...withComponentCreated,
+ projectContents: withComponentCreated.projectContents,
+ canvas: {
+ ...withComponentCreated.canvas,
+ base64Blobs: updatedBlobs,
+ },
+ }
+ }
+ case 'SAVE_IMAGE_REPLACE':
+ return editorWithToast
+ case 'SAVE_IMAGE_DO_NOTHING':
+ return editorWithToast
+ }
+ }
+ },
+ INSERT_IMAGE_INTO_UI: (
+ action: InsertImageIntoUI,
+ editor: EditorModel,
+ userState: UserState,
+ ): EditorModel => {
+ const possiblyAnImage = getProjectFileByFilePath(editor.projectContents, action.imagePath)
+ if (possiblyAnImage != null && isImageFile(possiblyAnImage)) {
+ const newUID = generateUidWithExistingComponents(editor.projectContents)
+ const imageURL = imagePathURL(action.imagePath)
+ const imageSrcAttribute = jsExpressionValue(imageURL, emptyComments)
+ const width = Utils.optionalMap((w) => w / 2, possiblyAnImage.width)
+ const height = Utils.optionalMap((h) => h / 2, possiblyAnImage.height)
+ const imageElement = jsxElement(
+ jsxElementName('img', []),
+ newUID,
+ jsxAttributesFromMap({
+ alt: jsExpressionValue('', emptyComments),
+ src: imageSrcAttribute,
+ style: jsExpressionValue(
+ {
+ width: width,
+ height: height,
+ },
+ emptyComments,
+ ),
+ 'data-uid': jsExpressionValue(newUID, emptyComments),
+ 'data-label': jsExpressionValue('Image', emptyComments),
+ [AspectRatioLockedProp]: jsExpressionValue(true, emptyComments),
+ }),
+ [],
+ )
+ const size = width != null && height != null ? { width: width, height: height } : null
+ const switchMode = enableInsertModeForJSXElement(imageElement, newUID, {}, size)
+ return UPDATE_FNS.SWITCH_EDITOR_MODE(switchMode, editor, userState)
+ } else {
+ return editor
+ }
+ },
+ SET_PROJECT_ID: (action: SetProjectID, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ id: action.id,
+ }
+ },
+ UPDATE_CODE_RESULT_CACHE: (action: UpdateCodeResultCache, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ codeResultCache: {
+ skipDeepFreeze: true,
+ cache: {
+ ...editor.codeResultCache.cache,
+ ...action.codeResultCache.cache,
+ },
+ exportsInfo: action.codeResultCache.exportsInfo,
+ error: action.codeResultCache.error,
+ curriedRequireFn: action.codeResultCache.curriedRequireFn,
+ curriedResolveFn: action.codeResultCache.curriedResolveFn,
+ projectModules: action.codeResultCache.projectModules,
+ evaluationCache: action.codeResultCache.evaluationCache,
+ },
+ }
+ },
+ SET_CODE_EDITOR_VISIBILITY: (
+ action: SetCodeEditorVisibility,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ interfaceDesigner: {
+ ...editor.interfaceDesigner,
+ codePaneVisible: action.value,
+ },
+ }
+ },
+ OPEN_CODE_EDITOR: (editor: EditorModel): EditorModel => {
+ return updateCodeEditorVisibility(editor, true)
+ },
+ SET_PROJECT_NAME: (action: SetProjectName, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ projectName: action.name,
+ }
+ },
+
+ SET_PROJECT_DESCRIPTION: (action: SetProjectDescription, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ projectDescription: action.description,
+ }
+ },
+ ALIGN_SELECTED_VIEWS: (action: AlignSelectedViews, editor: EditorModel): EditorModel => {
+ return alignOrDistributeSelectedViews(editor, action.alignment)
+ },
+ DISTRIBUTE_SELECTED_VIEWS: (
+ action: DistributeSelectedViews,
+ editor: EditorModel,
+ ): EditorModel => {
+ return alignOrDistributeSelectedViews(editor, action.distribution)
+ },
+ SHOW_CONTEXT_MENU: (action: ShowContextMenu, editor: EditorModel): EditorModel => {
+ // side effect!
+ openMenu(action.menuName, action.event)
+ return editor
+ },
+ UPDATE_FILE_PATH: (
+ action: UpdateFilePath,
+ editor: EditorModel,
+ userState: UserState,
+ ): EditorModel => {
+ return updateFilePath(editor, userState, {
+ oldPath: action.oldPath,
+ newPath: action.newPath,
+ })
+ },
+ UPDATE_REMIX_ROUTE: (
+ action: UpdateRemixRoute,
+ editor: EditorModel,
+ userState: UserState,
+ ): EditorModel => {
+ const withUpdatedFilePath = updateFilePath(editor, userState, {
+ oldPath: action.oldPath,
+ newPath: action.newPath,
+ })
+
+ const withUpdatedFeaturedRoute = updatePackageJsonInEditorState(
+ withUpdatedFilePath,
+ addOrReplaceFeaturedRouteToPackageJson(action.oldRoute, action.newRoute),
+ )
+
+ return withUpdatedFeaturedRoute
+ },
+ SET_FOCUS: (action: SetFocus, editor: EditorModel): EditorModel => {
+ if (editor.focusedPanel === action.focusedPanel) {
+ return editor
+ } else {
+ return setLeftMenuTabFromFocusedPanel({
+ ...editor,
+ focusedPanel: action.focusedPanel,
+ })
+ }
+ },
+ OPEN_CODE_EDITOR_FILE: (action: OpenCodeEditorFile, editor: EditorModel): EditorModel => {
+ // Side effect.
+ sendOpenFileMessage(action.filename, action.bounds)
+ if (action.forceShowCodeEditor) {
+ return {
+ ...editor,
+ interfaceDesigner: {
+ ...editor.interfaceDesigner,
+ codePaneVisible: true,
+ },
+ }
+ } else {
+ return editor
+ }
+ },
+ UPDATE_FILE: (
+ action: UpdateFile,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorModel => {
+ if (
+ !action.addIfNotInFiles &&
+ getProjectFileByFilePath(editor.projectContents, action.filePath) == null
+ ) {
+ return editor
+ }
+
+ const { file } = action
+
+ const existing = getProjectFileByFilePath(editor.projectContents, action.filePath)
+ const updatedFile = updateFileIfPossible(file, existing)
+
+ if (updatedFile === 'cant-update') {
+ return editor
+ }
+
+ const updatedProjectContents = addFileToProjectContents(
+ editor.projectContents,
+ action.filePath,
+ updatedFile,
+ )
+
+ let updatedNodeModulesFiles = editor.nodeModules.files
+ let packageLoadingStatus: PackageStatusMap = {}
+
+ // Ensure dependencies are updated if the `package.json` file has been changed.
+ if (action.filePath === '/package.json' && isTextFile(updatedFile)) {
+ const packageJson = packageJsonFileFromProjectContents(editor.projectContents)
+ const currentDeps =
+ packageJson != null && isTextFile(packageJson)
+ ? dependenciesFromPackageJsonContents(packageJson.fileContents.code)
+ : null
+ void refreshDependencies(
+ dispatch,
+ updatedFile.fileContents.code,
+ currentDeps,
+ builtInDependencies,
+ editor.nodeModules.files,
+ )
+ }
+
+ return {
+ ...editor,
+ projectContents: updatedProjectContents,
+ canvas: {
+ ...editor.canvas,
+ canvasContentInvalidateCount:
+ editor.canvas.canvasContentInvalidateCount + (isTextFile(updatedFile) ? 0 : 1),
+ domWalkerInvalidateCount:
+ editor.canvas.domWalkerInvalidateCount + (isTextFile(updatedFile) ? 0 : 1),
+ },
+ nodeModules: {
+ ...editor.nodeModules,
+ files: updatedNodeModulesFiles,
+ packageStatus: {
+ ...editor.nodeModules.packageStatus,
+ ...packageLoadingStatus,
+ },
+ },
+ }
+ },
+ UPDATE_PROJECT_CONTENTS: (action: UpdateProjectContents, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ projectContents: action.contents,
+ }
+ },
+ UPDATE_BRANCH_CONTENTS: (action: UpdateBranchContents, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ branchOriginContents: action.contents,
+ }
+ },
+ UPDATE_GITHUB_SETTINGS: (action: UpdateGithubSettings, editor: EditorModel): EditorModel => {
+ const newGithubSettings: ProjectGithubSettings = {
+ ...editor.githubSettings,
+ ...action.settings,
+ }
+ if (editor.id != null) {
+ void updateGithubRepository(
+ editor.id,
+ newGithubSettings.targetRepository == null
+ ? null
+ : {
+ owner: newGithubSettings.targetRepository.owner,
+ repository: newGithubSettings.targetRepository.repository,
+ branch: newGithubSettings.branchName,
+ },
+ )
+ }
+ return normalizeGithubData({
+ ...editor,
+ githubSettings: newGithubSettings,
+ })
+ },
+ UPDATE_GITHUB_DATA: (action: UpdateGithubData, editor: EditorModel): EditorModel => {
+ // merge the existing and the new public repos
+ const combinedPublicRepos = [
+ ...editor.githubData.publicRepositories,
+ ...(action.data.publicRepositories ?? []),
+ ]
+
+ // sort public repos by descending updatedAt
+ const sortedCombinedPublicRepos = combinedPublicRepos.sort((a, b) => {
+ if (a.updatedAt == null) {
+ return 1
+ } else if (b.updatedAt == null) {
+ return -1
+ } else {
+ return b.updatedAt.localeCompare(a.updatedAt)
+ }
+ })
+
+ // remove duplicate entries
+ const uniquePublicRepos = uniqBy(sortedCombinedPublicRepos, (a, b) => a.fullName === b.fullName)
+
+ return {
+ ...editor,
+ githubData: {
+ ...editor.githubData,
+ ...action.data,
+ lastUpdatedAt: Date.now(),
+ publicRepositories: uniquePublicRepos,
+ },
+ }
+ },
+ REMOVE_FILE_CONFLICT: (action: RemoveFileConflict, editor: EditorModel): EditorModel => {
+ let updatedConflicts: TreeConflicts = { ...editor.githubData.treeConflicts }
+ delete updatedConflicts[action.path]
+ const treeConflictsRemain = Object.keys(updatedConflicts).length > 0
+ const newOriginCommit = treeConflictsRemain
+ ? editor.githubSettings.originCommit
+ : editor.githubSettings.pendingCommit
+ const newPendingCommit = treeConflictsRemain ? editor.githubSettings.pendingCommit : null
+ return {
+ ...editor,
+ githubSettings: {
+ ...editor.githubSettings,
+ originCommit: newOriginCommit,
+ pendingCommit: newPendingCommit,
+ },
+ githubData: {
+ ...editor.githubData,
+ treeConflicts: updatedConflicts,
+ },
+ }
+ },
+ UPDATE_FROM_WORKER: (
+ action: UpdateFromWorker,
+ editor: EditorModel,
+ userState: UserState,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ let workingProjectContents: ProjectContentTreeRoot = editor.projectContents
+ let anyParsedUpdates: boolean = false
+
+ // This prevents partial updates to the model which can then cause UIDs to clash between files.
+ // Where updates to files A and B resulted in new UIDs in each but as the update to one of those
+ // files ends up stale only the model in one of them gets updated which clashes with the UIDs in
+ // the old version of the other.
+ for (const fileUpdate of action.updates) {
+ const existing = getProjectFileByFilePath(editor.projectContents, fileUpdate.filePath)
+ if (existing != null && isTextFile(existing)) {
+ anyParsedUpdates = true
+ const updateIsStale = fileUpdate.versionNumber < existing.versionNumber
+ if (updateIsStale && action.updates.length > 1) {
+ return {
+ ...editor,
+ parseOrPrintInFlight: false,
+ previousParseOrPrintSkipped: true,
+ }
+ }
+ }
+ }
+
+ // Apply the updates to the projectContents.
+ const updateResult = processWorkerUpdates(workingProjectContents, action.updates)
+ anyParsedUpdates = anyParsedUpdates || updateResult.anyParsedUpdates
+ workingProjectContents = updateResult.projectContents
+
+ if (anyParsedUpdates) {
+ // Clear any cached paths since UIDs will have been regenerated and property paths may no longer exist
+ // FIXME take a similar approach as ElementPath cache culling to the PropertyPath cache culling. Or don't even clear it.
+ PP.clearPropertyPathCache()
+ }
+ return {
+ ...editor,
+ projectContents:
+ // If we are in the process of cloning a Github repository, do not create storyboard
+ // it will be created in the requirements check phase
+ userState.githubState.gitRepoToLoad != null
+ ? workingProjectContents
+ : createStoryboardFileIfNecessary(workingProjectContents),
+ canvas: {
+ ...editor.canvas,
+ canvasContentInvalidateCount: anyParsedUpdates
+ ? editor.canvas.canvasContentInvalidateCount + 1
+ : editor.canvas.canvasContentInvalidateCount,
+ domWalkerInvalidateCount: anyParsedUpdates
+ ? editor.canvas.domWalkerInvalidateCount + 1
+ : editor.canvas.domWalkerInvalidateCount,
+ },
+ parseOrPrintInFlight: false,
+ previousParseOrPrintSkipped: false,
+ }
+ },
+ UPDATE_FROM_CODE_EDITOR: (
+ action: UpdateFromCodeEditor,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ builtInDependencies: BuiltInDependencies,
+ serverState: ProjectServerState,
+ ): EditorModel => {
+ // Prevents updates from VS Code when the user is not the owner of the project.
+ // Also fixes an issue with the collaboration where VS Code is firing one of these updates
+ // immediately after it loads which then causes the collaboration update to fail because it's
+ // viewed as being "stale".
+ if (serverState.isMyProject === 'yes') {
+ const existing = getProjectFileByFilePath(editor.projectContents, action.filePath)
+
+ const manualSave = action.unsavedContent == null
+ const code = action.unsavedContent ?? action.savedContent
+
+ let updatedFile: ProjectFile
+ if (existing == null || !isTextFile(existing)) {
+ const contents = textFileContents(code, unparsed, RevisionsState.CodeAhead)
+ const lastSavedContents = manualSave
+ ? null
+ : textFileContents(action.savedContent, unparsed, RevisionsState.CodeAhead)
+
+ updatedFile = textFile(contents, lastSavedContents, null, 0)
+ } else {
+ updatedFile = updateFileContents(code, existing, manualSave)
+ }
+
+ const updateAction = updateFile(action.filePath, updatedFile, true)
+ return UPDATE_FNS.UPDATE_FILE(updateAction, editor, dispatch, builtInDependencies)
+ } else {
+ return editor
+ }
+ },
+ CLEAR_PARSE_OR_PRINT_IN_FLIGHT: (
+ action: ClearParseOrPrintInFlight,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ parseOrPrintInFlight: false,
+ }
+ },
+ CLEAR_IMAGE_FILE_BLOB: (action: ClearImageFileBlob, editor: EditorModel): EditorModel => {
+ if (action.uiFilePath in editor.canvas.base64Blobs) {
+ const uiFileBlobs: UIFileBase64Blobs = editor.canvas.base64Blobs[action.uiFilePath]
+ if (action.elementID in uiFileBlobs) {
+ let updatedUIFileBlobs: UIFileBase64Blobs = { ...uiFileBlobs }
+ delete updatedUIFileBlobs[action.elementID]
+ const updateBase64Blobs = {
+ ...editor.canvas.base64Blobs,
+ [action.uiFilePath]: updatedUIFileBlobs,
+ }
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ base64Blobs: updateBase64Blobs,
+ },
+ }
+ }
+ }
+ return editor
+ },
+ ADD_FOLDER: (action: AddFolder, editor: EditorModel): EditorModel => {
+ const pathPrefix = action.parentPath == '' ? '' : action.parentPath + '/'
+ const newFolderKey = uniqueProjectContentID(
+ pathPrefix + action.fileName,
+ editor.projectContents,
+ )
+ return {
+ ...editor,
+ projectContents: addFileToProjectContents(editor.projectContents, newFolderKey, directory()),
+ }
+ },
+ ADD_TEXT_FILE: (action: AddTextFile, editor: EditorModel): EditorModel => {
+ const withAddedFile = addTextFile(
+ editor,
+ action.parentPath,
+ action.fileName,
+ codeFile('', null),
+ )
+ return UPDATE_FNS.OPEN_CODE_EDITOR_FILE(
+ openCodeEditorFile(withAddedFile.newFileKey, false),
+ withAddedFile.editorState,
+ )
+ },
+ ADD_NEW_PAGE: (action: AddNewPage, editor: EditorModel): EditorModel => {
+ const newFileName = `${action.newPageName}.jsx` // TODO maybe reuse the original extension?
+
+ const templateFile = getProjectFileByFilePath(editor.projectContents, action.template.path)
+ if (templateFile == null || !isTextFile(templateFile)) {
+ // nothing to do
+ return editor
+ }
+
+ // 1. add the new page to the featured routes
+ const withPackageJson = updatePackageJsonInEditorState(
+ editor,
+ addNewFeaturedRouteToPackageJson(action.newPageName),
+ )
+
+ // 2. Parse the file upfront.
+ const existingUIDs = new Set(
+ getAllUniqueUidsFromMapping(getUidMappings(editor.projectContents).filePathToUids),
+ )
+ const parsedResult = getParseFileResult(
+ createParseFile(newFileName, templateFile.fileContents.code, null, 1),
+ createParseAndPrintOptions(
+ getFilePathMappings(editor.projectContents),
+ existingUIDs,
+ isSteganographyEnabled(),
+ getParseCacheOptions(),
+ ),
+ )
+
+ // 3. write the new text file
+ const withTextFile = addTextFile(
+ withPackageJson,
+ action.parentPath,
+ newFileName,
+ textFile(
+ textFileContents(
+ templateFile.fileContents.code,
+ parsedResult.parseResult,
+ RevisionsState.CodeAhead,
+ ),
+ null,
+ null,
+ 1,
+ ),
+ )
+
+ // 4. open the text file
+ return UPDATE_FNS.OPEN_CODE_EDITOR_FILE(
+ openCodeEditorFile(withTextFile.newFileKey, false),
+ withTextFile.editorState,
+ )
+ },
+ ADD_NEW_FEATURED_ROUTE: (action: AddNewFeaturedRoute, editor: EditorModel): EditorModel => {
+ return updatePackageJsonInEditorState(
+ editor,
+ addNewFeaturedRouteToPackageJson(action.featuredRoute),
+ )
+ },
+ REMOVE_FEATURED_ROUTE: (action: RemoveFeaturedRoute, editor: EditorModel): EditorModel => {
+ return updatePackageJsonInEditorState(
+ editor,
+ removeFeaturedRouteFromPackageJson(action.routeToRemove),
+ )
+ },
+ DELETE_FILE: (
+ action: DeleteFile | DeleteFileFromVSCode | DeleteFileFromCollaboration,
+ editor: EditorModel,
+ derived: DerivedState,
+ userState: UserState,
+ ): EditorModel => {
+ const file = getProjectFileByFilePath(editor.projectContents, action.filename)
+
+ // Don't delete package.json, otherwise it will bring about the end of days.
+ if (file == null || action.filename === 'package.json') {
+ return editor
+ }
+
+ const updatedProjectContents = removeFromProjectContents(
+ editor.projectContents,
+ action.filename,
+ )
+
+ const selectedFile = getOpenFilename(editor)
+ const updatedCanvas = selectedFile === action.filename ? null : editor.canvas.openFile
+
+ switch (file.type) {
+ case 'DIRECTORY': {
+ // this deletes directory contents too
+ const updatedEditor = {
+ ...editor,
+ projectContents: updatedProjectContents,
+ }
+ const oldFolderRegex = new RegExp('^' + action.filename)
+ const filesToDelete: Array = Object.keys(updatedEditor.projectContents).filter(
+ (key) => oldFolderRegex.test(key),
+ )
+ return filesToDelete.reduce((working, filename) => {
+ return UPDATE_FNS.DELETE_FILE(
+ { action: 'DELETE_FILE', filename: filename },
+ working,
+ derived,
+ userState,
+ )
+ }, updatedEditor)
+ }
+ case 'TEXT_FILE': {
+ let nextEditor = {
+ ...editor,
+ projectContents: updatedProjectContents,
+ }
+ if (isComponentDescriptorFile(action.filename)) {
+ // update property controls
+ nextEditor = {
+ ...nextEditor,
+ propertyControlsInfo: updatePropertyControlsOnDescriptorFileDelete(
+ editor.propertyControlsInfo,
+ action.filename,
+ ),
+ }
+ }
+ return removeErrorMessagesForFile(nextEditor, action.filename)
+ }
+ case 'ASSET_FILE':
+ case 'IMAGE_FILE': {
+ if (isLoggedIn(userState.loginState) && editor.id != null) {
+ // Side effect
+ void deleteAssetFile(editor.id, action.filename)
+ }
+
+ return {
+ ...editor,
+ projectContents: updatedProjectContents,
+ canvas: {
+ ...editor.canvas,
+ openFile: updatedCanvas,
+ },
+ }
+ }
+ default:
+ return editor
+ }
+ },
+ SET_MAIN_UI_FILE_OLDWORLD: (action: SetMainUIFile, editor: EditorModel): EditorModel => {
+ return updateMainUIInEditorState(editor, action.uiFile)
+ },
+ SET_CODE_EDITOR_BUILD_ERRORS: (
+ action: SetCodeEditorBuildErrors,
+ editor: EditorModel,
+ ): EditorModel => {
+ const allBuildErrorsInState = getAllBuildErrors(editor.codeEditorErrors)
+ const allBuildErrorsInAction = Utils.flatMapArray(
+ (filename) => action.buildErrors[filename],
+ Object.keys(action.buildErrors),
+ )
+ if (allBuildErrorsInState.length === 0 && allBuildErrorsInAction.length === 0) {
+ return editor
+ } else {
+ const updatedCodeEditorErrors = Object.keys(action.buildErrors).reduce((acc, filename) => {
+ return {
+ ...acc,
+ buildErrors: {
+ ...acc.buildErrors,
+ [filename]: action.buildErrors[filename],
+ },
+ }
+ }, editor.codeEditorErrors)
+ return {
+ ...editor,
+ codeEditorErrors: updatedCodeEditorErrors,
+ jsxMetadata: emptyJsxMetadata,
+ domMetadata: emptyJsxMetadata,
+ spyMetadata: emptyJsxMetadata,
+ }
+ }
+ },
+ SET_CODE_EDITOR_LINT_ERRORS: (
+ action: SetCodeEditorLintErrors,
+ editor: EditorModel,
+ ): EditorModel => {
+ const allLintErrorsInState = getAllLintErrors(editor.codeEditorErrors)
+ const allLintErrorsInAction = Utils.flatMapArray(
+ (filename) => action.lintErrors[filename],
+ Object.keys(action.lintErrors),
+ )
+ if (allLintErrorsInState.length === 0 && allLintErrorsInAction.length === 0) {
+ return editor
+ } else {
+ const updatedCodeEditorErrors = Object.keys(action.lintErrors).reduce((acc, filename) => {
+ return {
+ ...acc,
+ lintErrors: {
+ ...acc.lintErrors,
+ [filename]: action.lintErrors[filename],
+ },
+ }
+ }, editor.codeEditorErrors)
+ return {
+ ...editor,
+ codeEditorErrors: updatedCodeEditorErrors,
+ }
+ }
+ },
+ SET_CODE_EDITOR_COMPONENT_DESCRIPTOR_ERRORS: (
+ action: SetCodeEditorComponentDescriptorErrors,
+ editor: EditorModel,
+ ): EditorModel => {
+ const allComponentDescriptorErrorsInState = getAllComponentDescriptorErrors(
+ editor.codeEditorErrors,
+ )
+ const allComponentDescriptorErrorsInAction = Utils.flatMapArray(
+ (filename) => action.componentDescriptorErrors[filename],
+ Object.keys(action.componentDescriptorErrors),
+ )
+ if (
+ allComponentDescriptorErrorsInState.length === 0 &&
+ allComponentDescriptorErrorsInAction.length === 0
+ ) {
+ return editor
+ } else {
+ const updatedCodeEditorErrors = Object.keys(action.componentDescriptorErrors).reduce(
+ (acc, filename) => {
+ return {
+ ...acc,
+ componentDescriptorErrors: {
+ ...acc.componentDescriptorErrors,
+ [filename]: action.componentDescriptorErrors[filename],
+ },
+ }
+ },
+ editor.codeEditorErrors,
+ )
+ return {
+ ...editor,
+ codeEditorErrors: updatedCodeEditorErrors,
+ }
+ }
+ },
+ SAVE_DOM_REPORT: (
+ action: SaveDOMReport,
+ editor: EditorModel,
+ spyCollector: UiJsxCanvasContextData,
+ ): EditorModel => {
+ // Note: If this DOM report only includes values for a single canvas
+ // it will wipe out any spy data that any other canvas may have produced.
+
+ // Calculate the spy metadata given what has been collected.
+ const spyResult = spyCollector.current.spyValues.metadata
+
+ const finalDomMetadata = ElementInstanceMetadataMapKeepDeepEquality(
+ editor.domMetadata,
+ action.elementMetadata,
+ ).value
+ const finalSpyMetadata = ElementInstanceMetadataMapKeepDeepEquality(
+ editor.spyMetadata,
+ spyResult,
+ ).value
+
+ const stayedTheSame =
+ editor.domMetadata === finalDomMetadata && editor.spyMetadata === finalSpyMetadata
+
+ if (stayedTheSame) {
+ return editor
+ } else {
+ return {
+ ...editor,
+ // TODO move the reconstructMetadata call here, and remove currentAllElementProps
+ domMetadata: finalDomMetadata,
+ spyMetadata: finalSpyMetadata,
+ currentAllElementProps: {
+ ...spyCollector.current.spyValues.allElementProps,
+ },
+ currentVariablesInScope: { ...spyCollector.current.spyValues.variablesInScope },
+ }
+ }
+ },
+ UPDATE_METADATA_IN_EDITOR_STATE: (
+ action: UpdateMetadataInEditorState,
+ editor: EditorModel,
+ spyCollector: UiJsxCanvasContextData,
+ ): EditorModel => {
+ return {
+ ...editor,
+ // TODO move the reconstructMetadata call here, and remove currentAllElementProps
+ currentAllElementProps: {
+ ...spyCollector.current.spyValues.allElementProps,
+ },
+ currentVariablesInScope: { ...spyCollector.current.spyValues.variablesInScope },
+ }
+ },
+ TRUE_UP_ELEMENTS: (editor: EditorModel): EditorModel => {
+ const commandsToRun = getCommandsForPushIntendedBounds(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ editor.trueUpElementsAfterDomWalkerRuns,
+ 'live-metadata',
+ )
+ const editorAfterTrueUps = foldAndApplyCommandsSimple(editor, commandsToRun)
+ return {
+ ...editorAfterTrueUps,
+ trueUpElementsAfterDomWalkerRuns: [],
+ }
+ },
+ // NB: this can only update attribute values and part of attribute value,
+ // If you want other types of JSXAttributes, that needs to be added
+ RENAME_PROP_KEY: (action: RenameStyleSelector, editor: EditorModel): EditorModel => {
+ return setPropertyOnTarget(editor, action.target, (props) => {
+ const originalPropertyPath = PP.createFromArray(action.cssTargetPath.path)
+ const newPropertyPath = PP.createFromArray(action.value)
+ const originalValue = getJSXAttributesAtPath(props, originalPropertyPath).attribute
+ const attributesWithUnsetKey = unsetJSXValueAtPath(props, originalPropertyPath)
+ if (
+ modifiableAttributeIsAttributeValue(originalValue) ||
+ modifiableAttributeIsPartOfAttributeValue(originalValue)
+ ) {
+ if (isRight(attributesWithUnsetKey)) {
+ const setResult = setJSXValueAtPath(
+ attributesWithUnsetKey.value,
+ newPropertyPath,
+ jsExpressionValue(originalValue.value, emptyComments),
+ )
+ return setResult
+ } else {
+ return attributesWithUnsetKey
+ }
+ } else {
+ return left(
+ `Original value was not a JSXAttributeValue or PartofJSXAttributeValue, was ${originalValue.type}`,
+ )
+ }
+ })
+ },
+ SET_FILEBROWSER_RENAMING_TARGET: (
+ action: SetFilebrowserRenamingTarget,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ fileBrowser: {
+ ...editor.fileBrowser,
+ renamingTarget: action.filename,
+ },
+ }
+ },
+ TOGGLE_PROPERTY: (action: ToggleProperty, editor: EditorModel): EditorModel => {
+ return modifyOpenJsxElementAtPath(action.target, action.togglePropValue, editor)
+ },
+ UPDATE_JSX_ELEMENT_NAME: (action: UpdateJSXElementName, editor: EditorModel): EditorModel => {
+ const updatedEditor = UPDATE_FNS.ADD_IMPORTS(
+ addImports(action.importsToAdd, action.target),
+ editor,
+ )
+
+ return modifyOpenJsxElementOrConditionalAtPath(
+ action.target,
+ (element) => {
+ switch (element.type) {
+ case 'JSX_CONDITIONAL_EXPRESSION':
+ return element
+ case 'JSX_ELEMENT':
+ if (action.elementName.type === 'JSX_FRAGMENT') {
+ return jsxFragment(element.uid, element.children, true)
+ } else {
+ return {
+ ...element,
+ name: action.elementName.name,
+ }
+ }
+ case 'JSX_FRAGMENT':
+ if (action.elementName.type === 'JSX_FRAGMENT') {
+ return element
+ }
+ return jsxElement(
+ action.elementName.name,
+ element.uid,
+ jsxAttributesFromMap({
+ 'data-uid': jsExpressionValue(element.uid, emptyComments),
+ }),
+ element.children,
+ )
+ default:
+ assertNever(element)
+ }
+ },
+ updatedEditor,
+ )
+ },
+ SET_CONDITIONAL_OVERRIDDEN_CONDITION: (
+ action: SetConditionalOverriddenCondition,
+ editor: EditorModel,
+ ): EditorModel => {
+ return modifyOpenJsxElementOrConditionalAtPath(
+ action.target,
+ (element) => {
+ if (!isJSXConditionalExpression(element)) {
+ return element
+ }
+
+ function isNotConditionalFlag(c: Comment): boolean {
+ return !isUtopiaPropOrCommentFlag(c, 'conditional')
+ }
+
+ const leadingComments = [...element.comments.leadingComments.filter(isNotConditionalFlag)]
+ if (action.condition != null) {
+ leadingComments.push(
+ makeUtopiaFlagComment({ type: 'conditional', value: action.condition }),
+ )
+ }
+
+ return {
+ ...element,
+ comments: {
+ leadingComments: leadingComments,
+ trailingComments: element.comments.trailingComments.filter(isNotConditionalFlag),
+ questionTokenComments: element.comments.questionTokenComments,
+ },
+ }
+ },
+ editor,
+ )
+ },
+ SET_MAP_COUNT_OVERRIDE: (action: SetMapCountOverride, editor: EditorModel): EditorModel => {
+ return modifyOpenJsxChildAtPath(
+ action.target,
+ (element) => {
+ if (!isJSXMapExpression(element)) {
+ return element
+ }
+
+ function isNotMapCountFlag(c: Comment): boolean {
+ return !isUtopiaPropOrCommentFlag(c, 'map-count')
+ }
+
+ const leadingComments = [...element.comments.leadingComments.filter(isNotMapCountFlag)]
+ if (action.value != null) {
+ leadingComments.push(makeUtopiaFlagComment({ type: 'map-count', value: action.value }))
+ }
+
+ return {
+ ...element,
+ comments: {
+ leadingComments: leadingComments,
+ trailingComments: element.comments.trailingComments.filter(isNotMapCountFlag),
+ questionTokenComments: element.comments.questionTokenComments,
+ },
+ }
+ },
+ editor,
+ )
+ },
+ UPDATE_CONDITIONAL_EXPRESSION: (
+ action: UpdateConditionalExpression,
+ editor: EditorModel,
+ ): EditorModel => {
+ return modifyOpenJsxElementOrConditionalAtPath(
+ action.target,
+ (element) => {
+ if (!isJSXConditionalExpression(element)) {
+ return element
+ }
+
+ // Use the values from the previous version in an attempt to stop a brief error screen
+ // from flashing between committing this change and re-parsing the file
+ const oldDefinedElsewhere =
+ element.condition.type === 'ATTRIBUTE_OTHER_JAVASCRIPT'
+ ? element.condition.definedElsewhere
+ : []
+ const oldElementsWithin =
+ element.condition.type === 'ATTRIBUTE_OTHER_JAVASCRIPT'
+ ? element.condition.elementsWithin
+ : {}
+
+ return {
+ ...element,
+ condition: jsExpressionOtherJavaScript(
+ [],
+ action.expression,
+ action.expression,
+ action.expression,
+ oldDefinedElsewhere,
+ null,
+ oldElementsWithin,
+ emptyComments,
+ ),
+ originalConditionString: action.expression,
+ }
+ },
+ editor,
+ )
+ },
+ ADD_IMPORTS: (action: AddImports, editor: EditorModel): EditorModel => {
+ let duplicateNames = new Map()
+ return modifyUnderlyingTargetElement(
+ action.target,
+ editor,
+ (element) => renameJsxElementChild(element, duplicateNames),
+ (success, _, underlyingFilePath) => {
+ const { imports, duplicateNameMapping } = mergeImports(
+ underlyingFilePath,
+ getFilePathMappings(editor.projectContents),
+ success.imports,
+ action.importsToAdd,
+ )
+ duplicateNames = duplicateNameMapping
+ return {
+ ...success,
+ imports: imports,
+ }
+ },
+ )
+ },
+ SET_ASPECT_RATIO_LOCK: (action: SetAspectRatioLock, editor: EditorModel): EditorModel => {
+ return modifyOpenJsxElementAtPath(
+ action.target,
+ (element) => {
+ const path = PP.create(AspectRatioLockedProp)
+ const updatedProps = action.locked
+ ? eitherToMaybe(
+ setJSXValueAtPath(element.props, path, jsExpressionValue(true, emptyComments)),
+ )
+ : deleteJSXAttribute(element.props, AspectRatioLockedProp)
+ return {
+ ...element,
+ props: updatedProps ?? element.props,
+ }
+ },
+ editor,
+ )
+ },
+ SET_SAFE_MODE: (action: SetSafeMode, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ safeMode: action.value,
+ }
+ },
+ SET_SAVE_ERROR: (action: SetSaveError, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ saveError: action.value,
+ }
+ },
+ INSERT_DROPPED_IMAGE: (action: InsertDroppedImage, editor: EditorModel): EditorModel => {
+ const projectContent = action.image
+ const parent = arrayToMaybe(editor.highlightedViews)
+ const newUID = generateUidWithExistingComponents(editor.projectContents)
+ const imageAttribute = jsExpressionValue(imagePathURL(action.path), emptyComments)
+ const size: Size = {
+ width: projectContent.width ?? 100,
+ height: projectContent.height ?? 100,
+ }
+ const { frame } = getFrameAndMultiplier(action.position, action.path, size, null)
+ let parentShiftX: number = 0
+ let parentShiftY: number = 0
+ if (parent != null) {
+ const frameOfParent = MetadataUtils.getFrameInCanvasCoords(parent, editor.jsxMetadata)
+ if (frameOfParent != null && isFiniteRectangle(frameOfParent)) {
+ parentShiftX = -frameOfParent.x
+ parentShiftY = -frameOfParent.y
+ }
+ }
+ const imageElement = jsxElement(
+ jsxElementName('img', []),
+ newUID,
+ jsxAttributesFromMap({
+ alt: jsExpressionValue('', emptyComments),
+ src: imageAttribute,
+ style: jsExpressionValue(
+ {
+ position: 'absolute',
+ left: parentShiftX + frame.x,
+ top: parentShiftY + frame.y,
+ width: frame.width,
+ height: frame.height,
+ },
+ emptyComments,
+ ),
+ 'data-uid': jsExpressionValue(newUID, emptyComments),
+ [AspectRatioLockedProp]: jsExpressionValue(true, emptyComments),
+ }),
+ [],
+ )
+
+ const insertJSXElementAction = insertJSXElement(imageElement, parent, {})
+ return UPDATE_FNS.INSERT_JSX_ELEMENT(insertJSXElementAction, editor)
+ },
+ REMOVE_FROM_NODE_MODULES_CONTENTS: (
+ action: RemoveFromNodeModulesContents,
+ editor: EditorState,
+ dispatch: EditorDispatch,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorState => {
+ const updatedNodeModulesFiles = removeModulesFromNodeModules(
+ action.modulesToRemove,
+ editor.nodeModules.files,
+ )
+
+ return {
+ ...editor,
+ nodeModules: {
+ ...editor.nodeModules,
+ files: updatedNodeModulesFiles,
+ },
+ codeResultCache: generateCodeResultCache(
+ editor.projectContents,
+ codeCacheToBuildResult(editor.codeResultCache.cache),
+ editor.codeResultCache.exportsInfo,
+ updatedNodeModulesFiles,
+ dispatch,
+ editor.codeResultCache.evaluationCache,
+ builtInDependencies,
+ ),
+ }
+ },
+ UPDATE_NODE_MODULES_CONTENTS: (
+ action: UpdateNodeModulesContents,
+ editor: EditorState,
+ dispatch: EditorDispatch,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorState => {
+ const updatedNodeModulesFiles = { ...editor.nodeModules.files, ...action.contentsToAdd }
+
+ return {
+ ...editor,
+ nodeModules: {
+ ...editor.nodeModules,
+ files: updatedNodeModulesFiles,
+ },
+ codeResultCache: generateCodeResultCache(
+ editor.projectContents,
+ codeCacheToBuildResult(editor.codeResultCache.cache),
+ editor.codeResultCache.exportsInfo,
+ updatedNodeModulesFiles,
+ dispatch,
+ editor.codeResultCache.evaluationCache,
+ builtInDependencies,
+ ),
+ }
+ },
+ UPDATE_PACKAGE_JSON: (action: UpdatePackageJson, editor: EditorState): EditorState => {
+ const dependencies = action.dependencies.reduce(
+ (acc: Array, curr: RequestedNpmDependency) => {
+ return {
+ ...acc,
+ [curr.name]: curr.version,
+ }
+ },
+ [] as Array,
+ )
+ return updateDependenciesInEditorState(editor, dependencies)
+ },
+ START_CHECKPOINT_TIMER: (
+ action: StartCheckpointTimer,
+ editor: EditorState,
+ dispatch: EditorDispatch,
+ ): EditorState => {
+ // Side effects.
+ clearTimeout(checkpointTimeoutId)
+ checkpointTimeoutId = window.setTimeout(() => {
+ dispatch([finishCheckpointTimer()], 'everyone')
+ }, 1000)
+ // No need to actually change the editor state.
+ return editor
+ },
+ FINISH_CHECKPOINT_TIMER: (action: FinishCheckpointTimer, editor: EditorState): EditorState => {
+ // Side effects.
+ checkpointTimeoutId = undefined
+ // No need to actually change the editor state.
+ return editor
+ },
+ ADD_MISSING_DIMENSIONS: (action: AddMissingDimensions, editor: EditorState): EditorState => {
+ const ArbitrarySize = 10
+ const frameWithExtendedDimensions = canvasRectangle({
+ x: action.existingSize.x,
+ y: action.existingSize.y,
+ width: action.existingSize.width === 0 ? ArbitrarySize : action.existingSize.width,
+ height: action.existingSize.height === 0 ? ArbitrarySize : action.existingSize.height,
+ })
+ const frameAndTarget: PinOrFlexFrameChange = pinSizeChange(
+ action.target,
+ frameWithExtendedDimensions,
+ null,
+ )
+ return setCanvasFramesInnerNew(editor, [frameAndTarget], null)
+ },
+ SET_PACKAGE_STATUS: (action: SetPackageStatus, editor: EditorState): EditorState => {
+ const packageName = action.packageName
+ return produce(editor, (draft) => {
+ draft.nodeModules.packageStatus[packageName] = { status: action.status }
+ })
+ },
+ SET_SHORTCUT: (action: SetShortcut, userState: UserState): UserState => {
+ let updatedShortcutConfig: ShortcutConfiguration = {}
+ if (userState.shortcutConfig != null) {
+ updatedShortcutConfig = {
+ ...userState.shortcutConfig,
+ }
+ }
+ updatedShortcutConfig[action.shortcutName] = [action.newKey]
+ const updatedUserConfiguration: UserConfiguration = {
+ shortcutConfig: updatedShortcutConfig,
+ themeConfig: userState.themeConfig,
+ }
+ // Side effect.
+ void saveUserConfiguration(updatedUserConfiguration)
+ return {
+ ...updatedUserConfiguration,
+ loginState: userState.loginState,
+ githubState: userState.githubState,
+ }
+ },
+ UPDATE_PROPERTY_CONTROLS_INFO: (
+ action: UpdatePropertyControlsInfo,
+ editor: EditorState,
+ ): EditorState => {
+ return {
+ ...editor,
+ propertyControlsInfo: action.propertyControlsInfo,
+ }
+ },
+ UPDATE_TEXT: (
+ action: UpdateText,
+ editorStore: EditorStoreUnpatched,
+ dispatch: EditorDispatch,
+ ): EditorStoreUnpatched => {
+ const { textProp } = action
+ // This flag is useful when editing conditional expressions:
+ // if the edited element is a js expression AND the content is still between curly brackets after editing,
+ // just save it as an expression, otherwise save it as text content
+ const isActionTextExpression =
+ (textProp === 'itself' || textProp === 'whenTrue' || textProp === 'whenFalse') &&
+ action.text.length > 1 &&
+ action.text[0] === '{' &&
+ action.text[action.text.length - 1] === '}'
+
+ const withUpdatedText = ((): EditorState => {
+ if (textProp === 'child') {
+ return modifyOpenJsxElementOrConditionalAtPath(
+ action.target,
+ (element): JSXElement | JSXFragment | JSXConditionalExpression => {
+ if (isJSXElement(element) || isJSXFragment(element)) {
+ if (action.text.trim() === '') {
+ return {
+ ...element,
+ children: [],
+ }
+ } else {
+ const result = {
+ ...element,
+ children: [jsxTextBlock(action.text)],
+ }
+ return result
+ }
+ } else {
+ throw new Error('Not an element with children.')
+ }
+ },
+ editorStore.unpatchedEditor,
+ )
+ } else if (textProp === 'itself') {
+ return modifyOpenJsxChildAtPath(
+ action.target,
+ (element): JSXElementChild => {
+ const comments = 'comments' in element ? element.comments : emptyComments
+ if (isJSExpression(element) && isActionTextExpression) {
+ const code = action.text.slice(1, -1)
+ return jsExpressionOtherJavaScript(
+ [],
+ code,
+ code,
+ code,
+ getDefinedElsewhereFromElementChild([], element),
+ null,
+ {},
+ comments,
+ element.uid,
+ )
+ }
+ if (action.text.trim() === '') {
+ return jsExpressionValue(null, comments, element.uid)
+ } else {
+ return jsExpressionValue(action.text, comments, element.uid)
+ }
+ },
+ editorStore.unpatchedEditor,
+ )
+ } else if (textProp === 'fullConditional') {
+ return modifyOpenJsxChildAtPath(
+ action.target,
+ (): JSXElementChild => {
+ // the whole expression will be reparsed again so we can just save it as a text block
+ return jsxTextBlock(action.text)
+ },
+ editorStore.unpatchedEditor,
+ )
+ } else if (textProp === 'whenFalse' || textProp === 'whenTrue') {
+ return modifyOpenJsxElementOrConditionalAtPath(
+ action.target,
+ (element): JSXElement | JSXFragment | JSXConditionalExpression => {
+ if (isJSXConditionalExpression(element)) {
+ return modify(
+ jsxConditionalExpressionConditionOptic(textProp),
+ (textElement): JSXElementChild => {
+ if (isJSExpression(textElement) && isActionTextExpression) {
+ const comments =
+ 'comments' in textElement ? textElement.comments : emptyComments
+ const code = action.text.slice(1, -1)
+ return jsExpressionOtherJavaScript(
+ [],
+ code,
+ code,
+ code,
+ getDefinedElsewhereFromElementChild([], textElement),
+ null,
+ {},
+ comments,
+ textElement.uid,
+ )
+ } else {
+ return jsExpressionValue(action.text, emptyComments, textElement.uid)
+ }
+ },
+ element,
+ )
+ }
+ return element
+ },
+ editorStore.unpatchedEditor,
+ )
+ } else {
+ assertNever(textProp)
+ }
+ })()
+ const withGroupTrueUpQueued: EditorState = addToTrueUpElements(
+ withUpdatedText,
+ trueUpGroupElementChanged(action.target),
+ )
+
+ const withCollapsedElements = collapseTextElements(action.target, withGroupTrueUpQueued)
+
+ let withFileChanges: EditorStoreUnpatched
+ if (withGroupTrueUpQueued === withCollapsedElements) {
+ withFileChanges = {
+ ...editorStore,
+ unpatchedEditor: withGroupTrueUpQueued,
+ }
+ } else {
+ withFileChanges = {
+ ...editorStore,
+ unpatchedEditor: withCollapsedElements,
+ history: History.add(
+ editorStore.history,
+ withGroupTrueUpQueued,
+ editorStore.unpatchedDerived,
+ [],
+ ),
+ }
+ }
+
+ // Find the text files that changed as a result of the update.
+ let changedTextFilenames: Array = []
+ zipContentsTree(
+ editorStore.unpatchedEditor.projectContents,
+ withFileChanges.unpatchedEditor.projectContents,
+ (fullPath, oldContents, newContents) => {
+ if (
+ isProjectContentFile(oldContents) &&
+ isProjectContentFile(newContents) &&
+ isTextFile(oldContents.content) &&
+ isTextFile(newContents.content)
+ ) {
+ const oldTextFile = oldContents.content
+ const newTextFile = newContents.content
+ if (oldTextFile !== newTextFile) {
+ changedTextFilenames.push(fullPath)
+ }
+ }
+
+ return true
+ },
+ )
+
+ // Accumulate the details of the data we need to update those files.
+ const filesToUpdateResult = getFilesToUpdate(
+ withFileChanges.unpatchedEditor.projectContents,
+ changedTextFilenames,
+ )
+
+ const filePathMappings = getFilePathMappings(withFileChanges.unpatchedEditor.projectContents)
+ // For those files that changed, do a print-parse against each file.
+ const workerUpdates = filesToUpdateResult.filesToUpdate.flatMap((fileToUpdate) => {
+ if (fileToUpdate.type === 'printandreparsefile') {
+ const printParsedContent = getPrintAndReparseCodeResult(
+ createPrintAndReparseFile(
+ fileToUpdate.filename,
+ fileToUpdate.parseSuccess,
+ fileToUpdate.stripUIDs,
+ fileToUpdate.versionNumber,
+ ),
+ createParseAndPrintOptions(
+ filePathMappings,
+ filesToUpdateResult.existingUIDs,
+ isSteganographyEnabled(),
+ getParseCacheOptions(),
+ ),
+ )
+ const updateAction = workerCodeAndParsedUpdate(
+ printParsedContent.filename,
+ printParsedContent.printResult,
+ printParsedContent.parseResult,
+ printParsedContent.versionNumber,
+ )
+ return [updateAction]
+ } else {
+ return []
+ }
+ })
+
+ const updatedEditorState = UPDATE_FNS.UPDATE_FROM_WORKER(
+ updateFromWorker(workerUpdates),
+ withFileChanges.unpatchedEditor,
+ withFileChanges.userState,
+ dispatch,
+ )
+ return {
+ ...withFileChanges,
+ unpatchedEditor: updatedEditorState,
+ }
+ },
+ TRUNCATE_HISTORY: (editorStore: EditorStoreUnpatched): EditorStoreUnpatched => {
+ return {
+ ...editorStore,
+ history: History.init(editorStore.unpatchedEditor, editorStore.unpatchedDerived),
+ }
+ },
+ MARK_VSCODE_BRIDGE_READY: (action: MarkVSCodeBridgeReady, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ vscodeBridgeReady: action.ready,
+ }
+ },
+ SELECT_FROM_FILE_AND_POSITION: (
+ action: SelectFromFileAndPosition,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ return updateSelectedComponentsFromEditorPosition(
+ editor,
+ dispatch,
+ action.filePath,
+ action.line,
+ )
+ },
+ SEND_CODE_EDITOR_INITIALISATION: (
+ action: SendCodeEditorInitialisation,
+ editor: EditorModel,
+ userState: UserState,
+ ): EditorModel => {
+ // Side effects.
+ sendCodeEditorDecorations(editor)
+ sendSelectedElement(editor)
+ sendSetVSCodeTheme(getCurrentTheme(userState))
+ return {
+ ...editor,
+ vscodeReady: true,
+ }
+ },
+ SET_FOCUSED_ELEMENT: (
+ action: SetFocusedElement,
+ editor: EditorModel,
+ derived: DerivedState,
+ ): EditorModel => {
+ let shouldApplyChange: boolean = false
+ if (action.focusedElementPath == null) {
+ shouldApplyChange = editor.focusedElementPath != null
+ } else if (
+ MetadataUtils.isManuallyFocusableComponent(
+ action.focusedElementPath,
+ editor.jsxMetadata,
+ derived.autoFocusedPaths,
+ derived.filePathMappings,
+ editor.propertyControlsInfo,
+ editor.projectContents,
+ )
+ ) {
+ shouldApplyChange = true
+ }
+ if (EP.pathsEqual(editor.focusedElementPath, action.focusedElementPath)) {
+ shouldApplyChange = false
+ }
+
+ if (shouldApplyChange) {
+ return {
+ ...editor,
+ focusedElementPath: action.focusedElementPath,
+ canvas: {
+ ...editor.canvas,
+ domWalkerInvalidateCount: editor.canvas.domWalkerInvalidateCount + 1,
+ },
+ }
+ } else {
+ return editor
+ }
+ },
+ SCROLL_TO_POSITION: (
+ action: ScrollToPosition,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ const isLeftMenuOpen = editor.leftMenu.visible
+ const containerRootDiv = document.getElementById('canvas-root')
+ const panelOffsets = canvasPanelOffsets()
+ const scale = 1 / editor.canvas.scale
+
+ // This returns the offset used as the fallback for the other behaviours when the container bounds are not defined.
+ // It will effectively scroll to the element by positioning it at the origin (TL) of the
+ // canvas, based on the BaseCanvasOffset value(s).
+ function canvasOffsetToOrigin(frame: CanvasRectangle): CanvasVector {
+ const baseCanvasOffset = isLeftMenuOpen ? BaseCanvasOffsetLeftPane : BaseCanvasOffset
+ const target = canvasPoint({
+ x: baseCanvasOffset.x * scale,
+ y: baseCanvasOffset.y * scale,
+ })
+ return Utils.pointDifference(frame, target)
+ }
+
+ function canvasOffsetToCenter(
+ frame: CanvasRectangle,
+ bounds: CanvasRectangle | null,
+ ): CanvasVector {
+ if (bounds == null) {
+ return canvasOffsetToOrigin(frame) // fallback default
+ }
+ const canvasCenter = getRectCenter(
+ canvasRectangle({
+ x: bounds.x,
+ y: bounds.y,
+ width: bounds.width * scale,
+ height: bounds.height * scale,
+ }),
+ )
+ const topLeftTarget = canvasPoint({
+ x:
+ canvasCenter.x -
+ frame.width / 2 -
+ bounds.x +
+ (panelOffsets.left / 2) * scale -
+ (panelOffsets.right / 2) * scale,
+ y: canvasCenter.y - frame.height / 2 - bounds.y,
+ })
+ return Utils.pointDifference(frame, topLeftTarget)
+ }
+
+ function canvasOffsetKeepScrollPositionIfVisible(
+ frame: CanvasRectangle,
+ bounds: CanvasRectangle | null,
+ ): CanvasVector | null {
+ if (bounds == null) {
+ return canvasOffsetToOrigin(frame) // fallback default
+ }
+ const containerRectangle = {
+ x: panelOffsets.left - editor.canvas.realCanvasOffset.x,
+ y: -editor.canvas.realCanvasOffset.y,
+ width: bounds.width,
+ height: bounds.height,
+ } as CanvasRectangle
+ const isVisible = rectangleIntersection(containerRectangle, frame) != null
+ return isVisible
+ ? null // when the element is on screen no scrolling is needed
+ : canvasOffsetToOrigin(frame) // fallback default
+ }
+
+ function getNewCanvasOffset(frame: CanvasRectangle): CanvasVector | null {
+ const containerDivBoundingRect = canvasRectangle(
+ containerRootDiv?.getBoundingClientRect() ?? null,
+ )
+ switch (action.behaviour) {
+ case 'keep-scroll-position-if-visible':
+ return canvasOffsetKeepScrollPositionIfVisible(frame, containerDivBoundingRect)
+ case 'to-center':
+ return canvasOffsetToCenter(frame, containerDivBoundingRect)
+ default:
+ assertNever(action.behaviour)
+ }
+ }
+
+ const newCanvasOffset = getNewCanvasOffset(action.target)
+ return newCanvasOffset == null
+ ? editor
+ : UPDATE_FNS.SET_SCROLL_ANIMATION(
+ setScrollAnimation(true),
+ {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ realCanvasOffset: newCanvasOffset,
+ roundedCanvasOffset: roundPointToNearestWhole(newCanvasOffset),
+ },
+ },
+ dispatch,
+ )
+ },
+ SCROLL_TO_ELEMENT: (
+ action: ScrollToElement,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ const targetElementCoords = MetadataUtils.getFrameInCanvasCoords(
+ action.target,
+ editor.jsxMetadata,
+ )
+ if (
+ targetElementCoords != null &&
+ isFiniteRectangle(targetElementCoords) &&
+ !isFollowMode(editor.mode)
+ ) {
+ return UPDATE_FNS.SCROLL_TO_POSITION(
+ scrollToPosition(targetElementCoords, action.behaviour),
+ editor,
+ dispatch,
+ )
+ } else {
+ return {
+ ...editor,
+ }
+ }
+ },
+ SET_SCROLL_ANIMATION: (
+ action: SetScrollAnimation,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ if (action.value) {
+ if (canvasScrollAnimationTimer != null) {
+ clearTimeout(canvasScrollAnimationTimer)
+ }
+ canvasScrollAnimationTimer = window.setTimeout(() => {
+ clearTimeout(canvasScrollAnimationTimer)
+ canvasScrollAnimationTimer = undefined
+ dispatch([setScrollAnimation(false)], 'everyone')
+ }, 500)
+ }
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ scrollAnimation: action.value,
+ },
+ }
+ },
+ SET_FOLLOW_SELECTION_ENABLED: (
+ action: SetFollowSelectionEnabled,
+ editor: EditorModel,
+ ): EditorModel => {
+ // Side effects
+ sendSetFollowSelectionEnabledMessage(action.value)
+ return {
+ ...editor,
+ config: {
+ ...editor.config,
+ followSelection: {
+ ...editor.config.followSelection,
+ enabled: action.value,
+ },
+ },
+ }
+ },
+ UPDATE_CONFIG_FROM_VSCODE: (action: UpdateConfigFromVSCode, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ config: action.config,
+ }
+ },
+ SET_LOGIN_STATE: (action: SetLoginState, userState: UserState): UserState => {
+ return {
+ ...userState,
+ loginState: action.loginState,
+ }
+ },
+ SET_GITHUB_STATE: (action: SetGithubState, userState: UserState): UserState => {
+ return {
+ ...userState,
+ githubState: {
+ ...userState.githubState,
+ ...action.githubState,
+ },
+ }
+ },
+ SET_USER_CONFIGURATION: (action: SetUserConfiguration, userState: UserState): UserState => {
+ // Side effect - update the theme setting in VS Code
+ sendSetVSCodeTheme(getCurrentTheme(action.userConfiguration))
+
+ return {
+ ...userState,
+ ...action.userConfiguration,
+ }
+ },
+ RESET_CANVAS: (action: ResetCanvas, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ mountCount: editor.canvas.mountCount + 1,
+ },
+ }
+ },
+ SET_FILEBROWSER_DROPTARGET: (
+ action: SetFilebrowserDropTarget,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ fileBrowser: {
+ ...editor.fileBrowser,
+ dropTarget: action.target,
+ },
+ }
+ },
+ SET_FORKED_FROM_PROJECT_ID: (
+ action: SetForkedFromProjectID,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ forkedFromProjectId: action.id,
+ }
+ },
+ SET_CURRENT_THEME: (action: SetCurrentTheme, userState: UserState): UserState => {
+ const updatedUserConfiguration: UserConfiguration = {
+ shortcutConfig: userState.shortcutConfig,
+ themeConfig: action.theme,
+ }
+
+ // Side effect - update the setting in VS Code
+ sendSetVSCodeTheme(getCurrentTheme(updatedUserConfiguration))
+
+ // Side effect - store the setting on the server
+ void saveUserConfiguration(updatedUserConfiguration)
+
+ return { ...userState, ...updatedUserConfiguration }
+ },
+ FOCUS_CLASS_NAME_INPUT: (editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ inspector: {
+ ...editor.inspector,
+ classnameFocusCounter: editor.inspector.classnameFocusCounter + 1,
+ },
+ }
+ },
+ FOCUS_FORMULA_BAR: (editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ topmenu: {
+ ...editor.topmenu,
+ formulaBarFocusCounter: editor.topmenu.formulaBarFocusCounter + 1,
+ },
+ }
+ },
+ UPDATE_FORMULA_BAR_MODE: (action: UpdateFormulaBarMode, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ topmenu: {
+ ...editor.topmenu,
+ formulaBarMode: action.value,
+ },
+ }
+ },
+ INSERT_INSERTABLE: (action: InsertInsertable, editor: EditorModel): EditorModel => {
+ const openFilename = editor.canvas.openFile?.filename
+ if (openFilename == null) {
+ return editor
+ } else {
+ let newSelectedViews: ElementPath[] = []
+ let detailsOfUpdate: string | null = null
+ let withInsertedElement: InsertChildAndDetails | null = null
+
+ if (action.insertionPath == null) {
+ return includeToast('Selected element does not support children', editor)
+ }
+
+ const { insertionPath } = action
+
+ function addNewSelectedView(newUID: string) {
+ const newPath = EP.appendToPath(insertionPath.intendedParentPath, newUID)
+ newSelectedViews.push(newPath)
+ }
+
+ const existingUids = new Set(
+ getAllUniqueUidsFromMapping(getUidMappings(editor.projectContents).filePathToUids),
+ )
+
+ const newUID = generateConsistentUID('new', existingUids)
+
+ const newPath = EP.appendToPath(action.insertionPath.intendedParentPath, newUID)
+
+ let element = action.toInsert.element()
+
+ const withNewElement = modifyUnderlyingTargetElement(
+ insertionPath.intendedParentPath,
+ editor,
+ identity,
+ (success, _, underlyingFilePath) => {
+ const utopiaComponents = getUtopiaJSXComponentsFromSuccess(success)
+
+ const updatedImports = mergeImports(
+ underlyingFilePath,
+ getFilePathMappings(editor.projectContents),
+ success.imports,
+ action.toInsert.importsToAdd,
+ )
+
+ const { imports, duplicateNameMapping } = updatedImports
+ element = renameJsxElementChildWithoutId(element, duplicateNameMapping)
+
+ if (element.type === 'JSX_ELEMENT') {
+ const propsWithUid = forceRight(
+ setJSXValueAtPath(
+ element.props,
+ PP.create(UTOPIA_UID_KEY),
+ jsExpressionValue(newUID, emptyComments),
+ ),
+ `Could not set data-uid on props of insertable element ${element.name}`,
+ )
+ // Potentially add in some default position and sizing.
+ let props = propsWithUid
+ if (action.styleProps === 'add-size') {
+ const sizesToSet: Array = [
+ { path: PP.create('style', 'width'), value: jsExpressionValue(100, emptyComments) },
+ {
+ path: PP.create('style', 'height'),
+ value: jsExpressionValue(100, emptyComments),
+ },
+ ]
+ const withSizeUpdates = setJSXValuesAtPaths(props, sizesToSet)
+ if (isRight(withSizeUpdates)) {
+ props = withSizeUpdates.value
+ } else {
+ console.error('Unable to set sizes on element.')
+ return success
+ }
+ }
+
+ const insertedElementName = element.name
+ let withMaybeUpdatedParent = utopiaComponents
+ let insertedElementChildren: JSXElementChildren = []
+
+ insertedElementChildren.push(...element.children)
+ const fixedElement = fixUtopiaElement(
+ jsxElement(insertedElementName, newUID, props, insertedElementChildren),
+ existingUids,
+ ).value
+
+ withInsertedElement = insertJSXElementChildren(
+ insertionPath,
+ [fixedElement],
+ withMaybeUpdatedParent,
+ action.indexPosition,
+ )
+ detailsOfUpdate = withInsertedElement.insertionDetails
+
+ addNewSelectedView(newUID)
+ } else if (element.type === 'JSX_CONDITIONAL_EXPRESSION') {
+ const fixedElement = fixUtopiaElement(
+ jsxConditionalExpression(
+ newUID,
+ element.condition,
+ element.originalConditionString,
+ element.whenTrue,
+ element.whenFalse,
+ element.comments,
+ ),
+ existingUids,
+ ).value
+
+ withInsertedElement = insertJSXElementChildren(
+ insertionPath,
+ [fixedElement],
+ utopiaComponents,
+ action.indexPosition,
+ )
+ detailsOfUpdate = withInsertedElement.insertionDetails
+ newSelectedViews.push(newPath)
+ } else if (element.type === 'JSX_FRAGMENT') {
+ const fixedElement = jsxFragment(newUID, element.children, element.longForm)
+
+ withInsertedElement = insertJSXElementChildren(
+ insertionPath,
+ [fixedElement],
+ utopiaComponents,
+ action.indexPosition,
+ )
+ detailsOfUpdate = withInsertedElement.insertionDetails
+
+ addNewSelectedView(newUID)
+ } else if (element.type === 'JSX_MAP_EXPRESSION') {
+ const fixedElement = fixUtopiaElement({ ...element, uid: newUID }, existingUids).value
+
+ withInsertedElement = insertJSXElementChildren(
+ insertionPath,
+ [fixedElement],
+ utopiaComponents,
+ action.indexPosition,
+ )
+ detailsOfUpdate = withInsertedElement.insertionDetails
+
+ addNewSelectedView(newUID)
+ } else {
+ assertNever(element)
+ }
+
+ const updatedTopLevelElements = applyUtopiaJSXComponentsChanges(
+ success.topLevelElements,
+ withInsertedElement.components,
+ )
+
+ return {
+ ...success,
+ topLevelElements: updatedTopLevelElements,
+ imports: imports,
+ }
+ },
+ )
+
+ let groupCommands: CanvasCommand[] = []
+ if (treatElementAsGroupLike(editor.jsxMetadata, action.insertionPath.intendedParentPath)) {
+ const group = MetadataUtils.findElementByElementPath(
+ editor.jsxMetadata,
+ action.insertionPath.intendedParentPath,
+ )
+ const localFrame = MetadataUtils.getLocalFrame(
+ action.insertionPath.intendedParentPath,
+ editor.jsxMetadata,
+ null,
+ )
+ if (group != null && localFrame != null) {
+ switch (element.type) {
+ case 'JSX_ELEMENT':
+ groupCommands.push(
+ ...createPinChangeCommandsForElementInsertedIntoGroup(
+ newPath,
+ right(element.props),
+ zeroRectIfNullOrInfinity(group.globalFrame),
+ zeroRectIfNullOrInfinity(localFrame),
+ ),
+ )
+ break
+ case 'JSX_CONDITIONAL_EXPRESSION':
+ if (element.whenTrue != null || element.whenFalse != null) {
+ // FIXME: This is a mid-step, as the conditional being inserted currently
+ // has nulls in both clauses, resulting in a zero-sized element.
+ groupCommands.push(
+ queueTrueUpElement([
+ trueUpChildrenOfGroupChanged(action.insertionPath.intendedParentPath),
+ ]),
+ )
+ }
+ break
+ case 'JSX_FRAGMENT':
+ if (element.children.length > 0) {
+ // this needs updating when we support inserting fragments with children
+ throw new Error('unhandled fragment insert into group')
+ }
+ break
+ default:
+ assertNever(action.toInsert.element as never)
+ }
+ }
+ }
+
+ const updatedEditorState: EditorModel = foldAndApplyCommandsSimple(
+ {
+ ...withNewElement,
+ selectedViews: newSelectedViews,
+ leftMenu: { visible: editor.leftMenu.visible, selectedTab: LeftMenuTab.Navigator },
+ trueUpElementsAfterDomWalkerRuns: [
+ ...editor.trueUpElementsAfterDomWalkerRuns,
+ trueUpGroupElementChanged(newPath),
+ ],
+ },
+ groupCommands,
+ )
+
+ // Add the toast for the update details if necessary.
+ return includeToast(detailsOfUpdate, updatedEditorState)
+ }
+ },
+ SET_PROP_TRANSIENT: (action: SetPropTransient, editor: EditorModel): EditorModel => {
+ const currentTransientProps =
+ editor.canvas.transientProperties != null
+ ? editor.canvas.transientProperties[EP.toString(action.target)]?.attributesToUpdate
+ : null
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ transientProperties: {
+ ...editor.canvas.transientProperties,
+ [EP.toString(action.target)]: {
+ elementPath: action.target,
+ attributesToUpdate: {
+ ...(currentTransientProps ?? {}),
+ [PP.toString(action.propertyPath)]: action.value,
+ },
+ },
+ },
+ },
+ }
+ },
+ CLEAR_TRANSIENT_PROPS: (action: ClearTransientProps, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ transientProperties: null,
+ },
+ }
+ },
+ ADD_TAILWIND_CONFIG: (
+ action: AddTailwindConfig,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorModel => {
+ const packageJsonFile = getProjectFileByFilePath(editor.projectContents, '/package.json')
+ const currentNpmDeps = dependenciesFromPackageJson(packageJsonFile, 'regular-only')
+
+ void findLatestVersion('tailwindcss').then((tailwindResult) => {
+ if (tailwindResult.type === 'VERSION_LOOKUP_SUCCESS') {
+ const tailwindVersion = tailwindResult.version
+ void findLatestVersion('postcss').then((postcssResult) => {
+ if (postcssResult.type === 'VERSION_LOOKUP_SUCCESS') {
+ const postcssVersion = postcssResult.version
+ const updatedNpmDeps = [
+ ...currentNpmDeps,
+ requestedNpmDependency('tailwindcss', tailwindVersion.version),
+ requestedNpmDependency('postcss', postcssVersion.version),
+ ]
+ void fetchNodeModules(dispatch, updatedNpmDeps, builtInDependencies).then(
+ (fetchNodeModulesResult) => {
+ const loadedPackagesStatus = createLoadedPackageStatusMapFromDependencies(
+ updatedNpmDeps,
+ fetchNodeModulesResult.dependenciesWithError,
+ fetchNodeModulesResult.dependenciesNotFound,
+ )
+ const packageErrorActions = Object.keys(loadedPackagesStatus).map(
+ (dependencyName) =>
+ setPackageStatus(dependencyName, loadedPackagesStatus[dependencyName].status),
+ )
+ dispatch([
+ ...packageErrorActions,
+ updateNodeModulesContents(fetchNodeModulesResult.nodeModules),
+ ])
+ },
+ )
+
+ const packageJsonUpdateAction = updatePackageJson(updatedNpmDeps)
+
+ dispatch([packageJsonUpdateAction], 'everyone')
+ }
+ })
+ }
+ })
+
+ let updatedProjectContents = editor.projectContents
+ if (getProjectFileByFilePath(editor.projectContents, TailwindConfigPath) == null) {
+ updatedProjectContents = addFileToProjectContents(
+ editor.projectContents,
+ TailwindConfigPath,
+ DefaultTailwindConfig(),
+ )
+ }
+
+ if (getProjectFileByFilePath(editor.projectContents, PostCSSPath) == null) {
+ updatedProjectContents = addFileToProjectContents(
+ updatedProjectContents,
+ PostCSSPath,
+ DefaultPostCSSConfig(),
+ )
+ }
+
+ return {
+ ...editor,
+ projectContents: updatedProjectContents,
+ nodeModules: {
+ ...editor.nodeModules,
+ packageStatus: {
+ ...editor.nodeModules.packageStatus,
+ ...{
+ postcss: { status: 'loading' },
+ tailwindcss: { status: 'loading' },
+ },
+ },
+ },
+ }
+ },
+ DECREMENT_RESIZE_OPTIONS_SELECTED_INDEX: (editor: EditorModel): EditorModel => {
+ const resizeOptions = editor.canvas.resizeOptions
+ const decrementedIndex = resizeOptions.propertyTargetSelectedIndex - 1
+ const newIndex =
+ decrementedIndex < 0 ? resizeOptions.propertyTargetOptions.length - 1 : decrementedIndex
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ resizeOptions: {
+ ...resizeOptions,
+ propertyTargetSelectedIndex: newIndex,
+ },
+ },
+ }
+ },
+ INCREMENT_RESIZE_OPTIONS_SELECTED_INDEX: (editor: EditorModel): EditorModel => {
+ const resizeOptions = editor.canvas.resizeOptions
+ const newIndex =
+ (resizeOptions.propertyTargetSelectedIndex + 1) % resizeOptions.propertyTargetOptions.length
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ resizeOptions: {
+ ...resizeOptions,
+ propertyTargetSelectedIndex: newIndex,
+ },
+ },
+ }
+ },
+ SET_RESIZE_OPTIONS_TARGET_OPTIONS: (
+ action: SetResizeOptionsTargetOptions,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ canvas: {
+ ...editor.canvas,
+ resizeOptions: {
+ propertyTargetOptions: action.propertyTargetOptions,
+ propertyTargetSelectedIndex: action.index ?? 0,
+ },
+ },
+ }
+ },
+ HIDE_VSCODE_LOADING_SCREEN: (
+ action: HideVSCodeLoadingScreen,
+ editor: EditorModel,
+ ): EditorModel => {
+ return { ...editor, vscodeLoadingScreenVisible: false }
+ },
+ SET_INDEXED_DB_FAILED: (action: SetIndexedDBFailed, editor: EditorModel): EditorModel => {
+ return { ...editor, indexedDBFailed: action.indexedDBFailed }
+ },
+ FORCE_PARSE_FILE: (action: ForceParseFile, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ forceParseFiles: editor.forceParseFiles.concat(action.filePath),
+ }
+ },
+ RUN_ESCAPE_HATCH: (
+ action: RunEscapeHatch,
+ editor: EditorModel,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorModel => {
+ const canvasState = pickCanvasStateFromEditorState(
+ editor.selectedViews,
+ editor,
+ builtInDependencies,
+ )
+ if (areAllSelectedElementsNonAbsolute(action.targets, editor.jsxMetadata)) {
+ const commands = getEscapeHatchCommands(
+ action.targets,
+ editor.jsxMetadata,
+ canvasState,
+ null,
+ action.setHuggingParentToFixed,
+ ).commands
+ return foldAndApplyCommandsSimple(editor, commands)
+ } else {
+ return editor
+ }
+ },
+ TOGGLE_SELECTION_LOCK: (action: ToggleSelectionLock, editor: EditorModel): EditorModel => {
+ const targets = action.targets
+ return targets.reduce((working, target) => {
+ switch (action.newValue) {
+ case 'locked':
+ return update(working, {
+ lockedElements: {
+ simpleLock: { $set: working.lockedElements.simpleLock.concat(target) },
+ hierarchyLock: {
+ $set: working.lockedElements.hierarchyLock.filter(
+ (element) => !EP.pathsEqual(element, target),
+ ),
+ },
+ },
+ })
+ case 'locked-hierarchy':
+ return update(working, {
+ lockedElements: {
+ simpleLock: {
+ $set: working.lockedElements.simpleLock.filter(
+ (element) => !EP.pathsEqual(element, target),
+ ),
+ },
+ hierarchyLock: { $set: working.lockedElements.hierarchyLock.concat(target) },
+ },
+ })
+ case 'selectable':
+ default:
+ return update(working, {
+ lockedElements: {
+ simpleLock: {
+ $set: working.lockedElements.simpleLock.filter(
+ (element) => !EP.pathsEqual(element, target),
+ ),
+ },
+ hierarchyLock: {
+ $set: working.lockedElements.hierarchyLock.filter(
+ (element) => !EP.pathsEqual(element, target),
+ ),
+ },
+ },
+ })
+ }
+ }, editor)
+ },
+ UPDATE_AGAINST_GITHUB: (action: UpdateAgainstGithub, editor: EditorModel): EditorModel => {
+ const githubSettings = editor.githubSettings
+ if (
+ githubSettings.targetRepository != null &&
+ githubSettings.branchName != null &&
+ githubSettings.originCommit != null
+ ) {
+ const mergeResults = mergeProjectContents(
+ editor.projectContents,
+ action.specificCommitContent,
+ action.branchLatestContent,
+ )
+ // If there are conflicts, then don't update the origin commit so we can try again.
+ const treeConflictsPresent = Object.keys(mergeResults.treeConflicts).length > 0
+ const newOriginCommit = treeConflictsPresent
+ ? githubSettings.originCommit
+ : action.latestCommit
+ const newPendingCommit = treeConflictsPresent ? action.latestCommit : null
+ return {
+ ...editor,
+ projectContents: mergeResults.value,
+ githubSettings: {
+ ...githubSettings,
+ originCommit: newOriginCommit,
+ pendingCommit: newPendingCommit,
+ },
+ githubData: {
+ ...editor.githubData,
+ treeConflicts: mergeResults.treeConflicts,
+ },
+ }
+ } else {
+ return editor
+ }
+ },
+ SET_FILE_BROWSER_DRAG_STATE: (
+ action: SetImageDragSessionState,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ imageDragSessionState: action.imageDragSessionState,
+ }
+ },
+ APPLY_COMMANDS: (action: ApplyCommandsAction, editor: EditorModel): EditorModel => {
+ return foldAndApplyCommandsSimple(editor, action.commands)
+ },
+ UPDATE_COLOR_SWATCHES: (action: UpdateColorSwatches, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ colorSwatches: action.colorSwatches,
+ }
+ },
+ SWITCH_CONDITIONAL_BRANCHES: (
+ action: SwitchConditionalBranches,
+ editor: EditorModel,
+ ): EditorModel => {
+ const openFile = editor.canvas.openFile?.filename
+ if (openFile == null) {
+ return editor
+ }
+
+ const updatedEditor = modifyUnderlyingTargetElement(
+ action.target,
+ editor,
+ (element) => {
+ if (!isJSXConditionalExpression(element)) {
+ return element
+ }
+ return jsxConditionalExpression(
+ element.uid,
+ element.condition,
+ element.originalConditionString,
+ element.whenFalse,
+ element.whenTrue,
+ element.comments,
+ )
+ },
+ (success) => success,
+ )
+
+ return updatedEditor
+ },
+ UPDATE_TOP_LEVEL_ELEMENTS_FROM_COLLABORATION_UPDATE: (
+ action: UpdateTopLevelElementsFromCollaborationUpdate,
+ editor: EditorModel,
+ ): EditorModel => {
+ let updatedEditor: EditorModel = editor
+ if (fileExists(editor.projectContents, action.fullPath)) {
+ updatedEditor = modifyParseSuccessAtPath(
+ action.fullPath,
+ editor,
+ (parsed) => {
+ const newTopLevelElementsDeepEquals = arrayDeepEquality(TopLevelElementKeepDeepEquality)(
+ parsed.topLevelElements,
+ action.topLevelElements,
+ )
+
+ if (newTopLevelElementsDeepEquals.areEqual) {
+ return parsed
+ } else {
+ const alreadyExistingUIDs = getAllUniqueUidsFromMapping(
+ getUidMappings(removeFromProjectContents(editor.projectContents, action.fullPath))
+ .filePathToUids,
+ )
+ const fixUIDsState: FixUIDsState = {
+ mutableAllNewUIDs: new Set(alreadyExistingUIDs),
+ uidsExpectedToBeSeen: new Set(),
+ mappings: [],
+ uidUpdateMethod: 'copy-uids-fix-duplicates',
+ }
+ const fixedUpTopLevelElements = fixTopLevelElementsUIDs(
+ parsed.topLevelElements,
+ newTopLevelElementsDeepEquals.value,
+ fixUIDsState,
+ )
+
+ return {
+ ...parsed,
+ topLevelElements: fixedUpTopLevelElements,
+ }
+ }
+ },
+ false,
+ )
+ } else {
+ const newParseSuccess = parseSuccess({}, action.topLevelElements, {}, null, null, [], {})
+ const newFile = textFile(
+ textFileContents('', newParseSuccess, RevisionsState.ParsedAhead),
+ null,
+ null,
+ 1,
+ )
+ const updatedProjectContents = addFileToProjectContents(
+ editor.projectContents,
+ action.fullPath,
+ newFile,
+ )
+ updatedEditor = {
+ ...editor,
+ projectContents: updatedProjectContents,
+ }
+ }
+
+ return {
+ ...updatedEditor,
+ filesModifiedByAnotherUser: updatedEditor.filesModifiedByAnotherUser.concat(action.fullPath),
+ }
+ },
+ UPDATE_EXPORTS_DETAIL_FROM_COLLABORATION_UPDATE: (
+ action: UpdateExportsDetailFromCollaborationUpdate,
+ editor: EditorModel,
+ ): EditorModel => {
+ let updatedEditor: EditorModel = editor
+ if (fileExists(editor.projectContents, action.fullPath)) {
+ updatedEditor = modifyParseSuccessAtPath(
+ action.fullPath,
+ editor,
+ (parsed) => {
+ const newExportsDetailsDeepEquals = arrayDeepEquality(ExportDetailKeepDeepEquality)(
+ parsed.exportsDetail,
+ action.exportsDetail,
+ )
+
+ if (newExportsDetailsDeepEquals.areEqual) {
+ return parsed
+ } else {
+ return {
+ ...parsed,
+ exportsDetail: newExportsDetailsDeepEquals.value,
+ }
+ }
+ },
+ false,
+ )
+ } else {
+ const newParseSuccess = parseSuccess({}, [], {}, null, null, action.exportsDetail, {})
+ const newFile = textFile(
+ textFileContents('', newParseSuccess, RevisionsState.ParsedAhead),
+ null,
+ null,
+ 1,
+ )
+ const updatedProjectContents = addFileToProjectContents(
+ editor.projectContents,
+ action.fullPath,
+ newFile,
+ )
+ updatedEditor = {
+ ...editor,
+ projectContents: updatedProjectContents,
+ }
+ }
+
+ return {
+ ...updatedEditor,
+ filesModifiedByAnotherUser: updatedEditor.filesModifiedByAnotherUser.concat(action.fullPath),
+ }
+ },
+ UPDATE_IMPORTS_FROM_COLLABORATION_UPDATE: (
+ action: UpdateImportsFromCollaborationUpdate,
+ editor: EditorModel,
+ ): EditorModel => {
+ let updatedEditor: EditorModel = editor
+ if (fileExists(editor.projectContents, action.fullPath)) {
+ updatedEditor = modifyParseSuccessAtPath(
+ action.fullPath,
+ editor,
+ (parsed) => {
+ const newImportsDeepEquals = objectDeepEquality(ImportDetailsKeepDeepEquality)(
+ parsed.imports,
+ action.imports,
+ )
+
+ if (newImportsDeepEquals.areEqual) {
+ return parsed
+ } else {
+ return {
+ ...parsed,
+ imports: newImportsDeepEquals.value,
+ }
+ }
+ },
+ false,
+ )
+ } else {
+ const newParseSuccess = parseSuccess(action.imports, [], {}, null, null, [], {})
+ const newFile = textFile(
+ textFileContents('', newParseSuccess, RevisionsState.ParsedAhead),
+ null,
+ null,
+ 1,
+ )
+ const updatedProjectContents = addFileToProjectContents(
+ editor.projectContents,
+ action.fullPath,
+ newFile,
+ )
+ updatedEditor = {
+ ...editor,
+ projectContents: updatedProjectContents,
+ }
+ }
+
+ return {
+ ...updatedEditor,
+ filesModifiedByAnotherUser: updatedEditor.filesModifiedByAnotherUser.concat(action.fullPath),
+ }
+ },
+ UPDATE_CODE_FROM_COLLABORATION_UPDATE: (
+ action: UpdateCodeFromCollaborationUpdate,
+ editor: EditorModel,
+ dispatch: EditorDispatch,
+ builtInDependencies: BuiltInDependencies,
+ ): EditorModel => {
+ const existing = getProjectFileByFilePath(editor.projectContents, action.fullPath)
+
+ let updatedFile: ProjectFile
+ if (existing == null || !isTextFile(existing)) {
+ const contents = textFileContents(action.code, unparsed, RevisionsState.CodeAhead)
+ updatedFile = textFile(contents, null, null, 0)
+ } else {
+ updatedFile = updateFileContents(action.code, existing, false)
+ }
+
+ const updateAction = updateFile(action.fullPath, updatedFile, true)
+ return UPDATE_FNS.UPDATE_FILE(updateAction, editor, dispatch, builtInDependencies)
+ },
+ SET_SHOW_RESOLVED_THREADS: (action: SetCommentFilterMode, editor: EditorModel): EditorModel => {
+ return { ...editor, commentFilterMode: action.commentFilterMode }
+ },
+ EXTRACT_PROPERTY_CONTROLS_FROM_DESCRIPTOR_FILES: (
+ action: ExtractPropertyControlsFromDescriptorFiles,
+ state: EditorState,
+ workers: UtopiaTsWorkers,
+ dispatch: EditorDispatch,
+ ): EditorModel => {
+ const evaluator = createModuleEvaluator(state)
+
+ const filesToUpdate: TextFileContentsWithPath[] = []
+ for (const filePath of action.paths) {
+ const file = getProjectFileByFilePath(state.projectContents, filePath)
+ if (file != null && file.type === 'TEXT_FILE') {
+ filesToUpdate.push({ path: filePath, file: file.fileContents })
+ }
+ }
+
+ void maybeUpdatePropertyControls(
+ state.propertyControlsInfo,
+ filesToUpdate,
+ workers,
+ dispatch,
+ evaluator,
+ )
+ return state
+ },
+ SET_SHARING_DIALOG_OPEN: (action: SetSharingDialogOpen, editor: EditorModel): EditorModel => {
+ return {
+ ...editor,
+ sharingDialogOpen: action.open,
+ }
+ },
+ SET_ERROR_BOUNDARY_HANDLING: (
+ action: SetErrorBoundaryHandling,
+ editor: EditorModel,
+ ): EditorModel => {
+ return {
+ ...editor,
+ editorRemixConfig: {
+ ...editor.editorRemixConfig,
+ errorBoundaryHandling: action.errorBoundaryHandling,
+ },
+ }
+ },
+}
+
+function copySelectionToClipboardMutating(
+ editor: EditorState,
+ builtInDependencies: BuiltInDependencies,
+): EditorState {
+ const copyData = createClipboardDataFromSelection(editor, builtInDependencies)
+ if (copyData != null) {
+ // side effect 😟
+ Clipboard.setClipboardData({
+ plainText: copyData.plaintext,
+ html: encodeUtopiaDataToHtml(copyData.data),
+ })
+ }
+
+ return {
+ ...editor,
+ pasteTargetsToIgnore: editor.selectedViews,
+ internalClipboard: {
+ styleClipboard: [],
+ elements: copyData?.data ?? [],
+ },
+ }
+}
+
+/** DO NOT USE outside of actions.ts, only exported for testing purposes */
+export function alignOrDistributeSelectedViews(
+ editor: EditorModel,
+ alignmentOrDistribution: Alignment | Distribution,
+): EditorModel {
+ const selectedViews = editor.selectedViews
+
+ let groupTrueUps: Array = [
+ ...editor.trueUpElementsAfterDomWalkerRuns,
+ ...selectedViews
+ .filter((path) => treatElementAsGroupLike(editor.jsxMetadata, EP.parentPath(path)))
+ .map(trueUpGroupElementChanged),
+ ]
+
+ let workingEditorState = { ...editor }
+
+ const targetAlignment = isAlignment(alignmentOrDistribution)
+ if (targetAlignment) {
+ workingEditorState = alignFlexOrGridChildren(
+ workingEditorState,
+ selectedViews.filter((v) =>
+ MetadataUtils.isFlexOrGridChild(workingEditorState.jsxMetadata, v),
+ ),
+ alignmentOrDistribution,
+ )
+ }
+
+ workingEditorState = alignFlowChildren(
+ workingEditorState,
+ selectedViews.filter(
+ (v) =>
+ !targetAlignment || !MetadataUtils.isFlexOrGridChild(workingEditorState.jsxMetadata, v),
+ ),
+ alignmentOrDistribution,
+ )
+
+ return {
+ ...workingEditorState,
+ trueUpElementsAfterDomWalkerRuns: groupTrueUps,
+ }
+}
+
+function alignOrDistributeCanvasRects(
+ componentMetadata: ElementInstanceMetadataMap,
+ targets: CanvasFrameAndTarget[],
+ source: CanvasRectangle,
+ alignmentOrDistribution: Alignment | Distribution,
+ sourceIsParent: boolean,
+): Array {
+ let results: Array = []
+
+ function addChange(target: ElementPath, frame: CanvasRectangle | null): void {
+ if (frame != null) {
+ const isParentFlex =
+ MetadataUtils.isParentYogaLayoutedContainerAndElementParticipatesInLayout(
+ target,
+ componentMetadata,
+ )
+ results.push(getFrameChange(target, frame, isParentFlex))
+ }
+ }
+
+ function addTransformedChanges(transform: (frame: CanvasRectangle) => CanvasRectangle): void {
+ Utils.fastForEach(targets, (target) => {
+ if (target.frame != null) {
+ addChange(target.target, transform(target.frame))
+ }
+ })
+ }
+
+ switch (alignmentOrDistribution) {
+ case 'left':
+ addTransformedChanges((frame) => Utils.setRectLeftX(frame, source.x))
+ break
+ case 'hcenter':
+ const centerX = Utils.getRectCenter(source).x
+ addTransformedChanges((frame) => Utils.setRectCenterX(frame, centerX))
+ break
+ case 'right':
+ addTransformedChanges((frame) => Utils.setRectRightX(frame, source.x + source.width))
+ break
+ case 'top':
+ addTransformedChanges((frame) => Utils.setRectTopY(frame, source.y))
+ break
+ case 'vcenter':
+ const centerY = Utils.getRectCenter(source).y
+ addTransformedChanges((frame) => Utils.setRectCenterY(frame, centerY))
+ break
+ case 'bottom':
+ const bottom = source.y + source.height
+ addTransformedChanges((frame) => Utils.setRectBottomY(frame, bottom))
+ break
+ case 'horizontal': {
+ let totalWidth: number = 0
+ let toOperateOn: Array<{ target: ElementPath; frame: CanvasRectangle }> = []
+ Utils.fastForEach(targets, (target) => {
+ if (target.frame != null) {
+ totalWidth += target.frame.width
+ toOperateOn.push({ target: target.target, frame: target.frame })
+ }
+ })
+ const totalHorizontalSpace = source.width - totalWidth
+ const numberOfSpaces = sourceIsParent ? toOperateOn.length + 1 : toOperateOn.length - 1
+ const horizontalSpacing = totalHorizontalSpace / numberOfSpaces
+
+ toOperateOn = toOperateOn.sort((l, r) => {
+ return l.frame.x - r.frame.x
+ })
+
+ let previous: CanvasRectangle | null = null
+ Utils.fastForEach(toOperateOn, (target) => {
+ const referencePoint = previous == null ? source.x : previous.x + previous.width
+ const shouldSpace = previous != null || sourceIsParent
+ const newX = referencePoint + (shouldSpace ? horizontalSpacing : 0)
+ const newRectangle = Utils.setRectLeftX(target.frame, newX)
+ previous = newRectangle
+ addChange(target.target, newRectangle)
+ })
+ break
+ }
+ case 'vertical': {
+ let totalHeight: number = 0
+ let toOperateOn: Array<{ target: ElementPath; frame: CanvasRectangle }> = []
+ Utils.fastForEach(targets, (target) => {
+ if (target.frame != null) {
+ totalHeight += target.frame.height
+ toOperateOn.push({ target: target.target, frame: target.frame })
+ }
+ })
+ const totalVerticalSpace = source.height - totalHeight
+ const numberOfSpaces = sourceIsParent ? toOperateOn.length + 1 : toOperateOn.length - 1
+ const verticalSpacing = totalVerticalSpace / numberOfSpaces
+
+ toOperateOn = toOperateOn.sort((l, r) => {
+ return l.frame.y - r.frame.y
+ })
+
+ let previous: CanvasRectangle | null = null
+ Utils.fastForEach(toOperateOn, (target) => {
+ const referencePoint = previous == null ? source.y : previous.y + previous.height
+ const shouldSpace = previous != null || sourceIsParent
+ const newY = referencePoint + (shouldSpace ? verticalSpacing : 0)
+ const newRectangle = Utils.setRectTopY(target.frame, newY)
+ previous = newRectangle
+ addChange(target.target, newRectangle)
+ })
+ break
+ }
+ default:
+ assertNever(alignmentOrDistribution)
+ }
+
+ return results
+}
+
+function setCanvasFramesInnerNew(
+ editor: EditorModel,
+ framesAndTargets: Array,
+ optionalParentFrame: CanvasRectangle | null,
+): EditorModel {
+ return updateFramesOfScenesAndComponents(editor, framesAndTargets, optionalParentFrame)
+}
+
+export async function load(
+ dispatch: EditorDispatch,
+ model: PersistentModel,
+ title: string,
+ projectId: string,
+ builtInDependencies: BuiltInDependencies,
+ retryFetchNodeModules: boolean = true,
+): Promise {
+ // this action is now async!
+ const migratedModel = applyMigrations(model)
+ const npmDependencies = dependenciesWithEditorRequirements(migratedModel.projectContents)
+ // side effect ☢️
+ notifyOperationStarted(dispatch, { type: 'refreshDependencies' })
+ const fetchNodeModulesResult = await fetchNodeModules(
+ dispatch,
+ npmDependencies,
+ builtInDependencies,
+ retryFetchNodeModules,
+ )
+
+ const nodeModules: NodeModules = fetchNodeModulesResult.nodeModules
+ const packageResult: PackageStatusMap = createLoadedPackageStatusMapFromDependencies(
+ npmDependencies,
+ fetchNodeModulesResult.dependenciesWithError,
+ fetchNodeModulesResult.dependenciesNotFound,
+ )
+
+ // side effect ☢️
+ notifyOperationFinished(
+ dispatch,
+ { type: 'refreshDependencies' },
+ getDependenciesStatus(packageResult),
+ )
+
+ const codeResultCache: CodeResultCache = generateCodeResultCache(
+ // TODO is this sufficient here?
+ migratedModel.projectContents,
+ {},
+ migratedModel.exportsInfo,
+ nodeModules,
+ dispatch,
+ {},
+ builtInDependencies,
+ )
+
+ const storedState = await loadStoredState(projectId)
+
+ const safeMode = await localforage.getItem(getProjectLockedKey(projectId))
+
+ dispatch(
+ [
+ {
+ action: 'LOAD',
+ model: migratedModel,
+ nodeModules: nodeModules,
+ packageResult: packageResult,
+ codeResultCache: codeResultCache,
+ title: title,
+ projectId: projectId,
+ storedState: storedState,
+ safeMode: safeMode,
+ },
+ ],
+ 'everyone',
+ )
+}
+
+function saveFileInProjectContents(
+ projectContents: ProjectContentTreeRoot,
+ filePath: string,
+): ProjectContentTreeRoot {
+ const file = getProjectFileByFilePath(projectContents, filePath)
+ if (file == null) {
+ return projectContents
+ } else {
+ return addFileToProjectContents(projectContents, filePath, saveFile(file))
+ }
+}
+
+function addTextFile(
+ editor: EditorState,
+ parentPath: string,
+ fileName: string,
+ newTextFile: TextFile,
+): { editorState: EditorState; newFileKey: string } {
+ const pathPrefix = parentPath == '' ? '' : parentPath + '/'
+ const newFileKey = uniqueProjectContentID(pathPrefix + fileName, editor.projectContents)
+
+ const updatedProjectContents = addFileToProjectContents(
+ editor.projectContents,
+ newFileKey,
+ newTextFile,
+ )
+
+ // Update the model.
+ return {
+ editorState: {
+ ...editor,
+ projectContents: updatedProjectContents,
+ },
+ newFileKey: newFileKey,
+ }
+}
+
+function updateFilePath(
+ editor: EditorModel,
+ userState: UserState,
+ params: {
+ oldPath: string
+ newPath: string
+ },
+): EditorState {
+ const replaceFilePathResults = replaceFilePath(
+ params.oldPath,
+ params.newPath,
+ editor.projectContents,
+ editor.codeResultCache.curriedRequireFn,
+ )
+ if (replaceFilePathResults.type === 'FAILURE') {
+ const toastAction = showToast(notice(replaceFilePathResults.errorMessage, 'ERROR', true))
+ return UPDATE_FNS.ADD_TOAST(toastAction, editor)
+ } else {
+ let currentDesignerFile = editor.canvas.openFile
+ const { projectContents, updatedFiles, renamedOptionalPrefix } = replaceFilePathResults
+ const mainUIFile = getMainUIFromModel(editor)
+ let updateUIFile: (e: EditorModel) => EditorModel = (e) => e
+ let updatePropertyControls: (e: EditorModel) => EditorModel = (e) => e
+ Utils.fastForEach(updatedFiles, (updatedFile) => {
+ const { oldPath, newPath } = updatedFile
+ // If the main UI file is what we have renamed, update that later.
+ if (oldPath === mainUIFile) {
+ updateUIFile = (e: EditorModel) => {
+ return updateMainUIInEditorState(e, newPath)
+ }
+ }
+ // update currently open file
+ if (currentDesignerFile != null && currentDesignerFile.filename === oldPath) {
+ currentDesignerFile = {
+ filename: newPath,
+ }
+ }
+ const oldContent = getProjectFileByFilePath(editor.projectContents, oldPath)
+ if (oldContent != null && (isImageFile(oldContent) || isAssetFile(oldContent))) {
+ // Update assets.
+ if (isLoggedIn(userState.loginState) && editor.id != null) {
+ void updateAssetFileName(editor.id, stripLeadingSlash(oldPath), newPath)
+ }
+ }
+ // when we rename a component descriptor file, we need to remove it from property controls
+ // if the new name is a component descriptor filename too, the controls will be readded
+ // if the new name is not a component descriptor filename, then we really have to remove the property controls
+ if (isComponentDescriptorFile(oldPath) && oldPath !== newPath) {
+ updatePropertyControls = (e: EditorModel) => ({
+ ...e,
+ propertyControlsInfo: updatePropertyControlsOnDescriptorFileDelete(
+ e.propertyControlsInfo,
+ oldPath,
+ ),
+ })
+ }
+ })
+
+ const withUpdatedPropertyControls = updatePropertyControls(
+ updateUIFile({
+ ...editor,
+ projectContents: projectContents,
+ codeEditorErrors: {
+ buildErrors: {},
+ lintErrors: {},
+ componentDescriptorErrors: {},
+ },
+ canvas: {
+ ...editor.canvas,
+ openFile: currentDesignerFile,
+ },
+ }),
+ )
+
+ return renamedOptionalPrefix
+ ? UPDATE_FNS.ADD_TOAST(
+ addToast(notice('Renamed Remix routes with optional prefixes.', 'NOTICE')),
+ withUpdatedPropertyControls,
+ )
+ : withUpdatedPropertyControls
+ }
+}
+
+function removeErrorMessagesForFile(editor: EditorState, filename: string): EditorState {
+ const noErrors = { [filename]: [] }
+
+ return UPDATE_FNS.SET_CODE_EDITOR_BUILD_ERRORS(
+ setCodeEditorBuildErrors(noErrors),
+ UPDATE_FNS.SET_CODE_EDITOR_LINT_ERRORS(
+ setCodeEditorLintErrors(noErrors),
+ UPDATE_FNS.SET_CODE_EDITOR_COMPONENT_DESCRIPTOR_ERRORS(
+ setCodeEditorComponentDescriptorErrors(noErrors),
+ editor,
+ ),
+ ),
+ )
+}
+
+function alignFlexOrGridChildren(editor: EditorState, views: ElementPath[], alignment: Alignment) {
+ let workingEditorState = { ...editor }
+ for (const view of views) {
+ // When updating alongside the given alignment, also update the opposite one so that it makes sense:
+ // For example, if alignment is 'alignSelf', delete the 'justifySelf' if currently set to stretch and, if so,
+ // set the explicit height of the element (and vice versa for 'justifySelf').
+ function updateOpposite(
+ editorState: EditorState,
+ frame: MaybeInfinityCanvasRectangle | null,
+ target: 'alignSelf' | 'justifySelf',
+ dimension: 'width' | 'height',
+ ) {
+ let working = { ...editorState }
+
+ working = deleteValuesAtPath(working, view, [styleP(target)]).editorStateWithChanges
+
+ working = applyValuesAtPath(working, view, [
+ {
+ path: styleP(dimension),
+ value: jsExpressionValue(zeroRectIfNullOrInfinity(frame)[dimension], emptyComments),
+ },
+ ]).editorStateWithChanges
+
+ return working
+ }
+
+ function apply(editorState: EditorState, prop: 'alignSelf' | 'justifySelf', value: string) {
+ const element = MetadataUtils.findElementByElementPath(editor.jsxMetadata, view)
+ if (element == null || isLeft(element.element) || !isJSXElement(element.element.value)) {
+ return workingEditorState
+ }
+
+ let working = editorState
+
+ working = applyValuesAtPath(working, view, [
+ { path: PP.create('style', prop), value: jsExpressionValue(value, emptyComments) },
+ ]).editorStateWithChanges
+
+ const alignSelfStretch =
+ getSimpleAttributeAtPath(right(element.element.value.props), styleP('alignSelf')).value ===
+ 'stretch'
+ const justifySelfStretch =
+ getSimpleAttributeAtPath(right(element.element.value.props), styleP('justifySelf'))
+ .value === 'stretch'
+
+ if (prop === 'alignSelf' && justifySelfStretch) {
+ working = updateOpposite(working, element.globalFrame, 'justifySelf', 'height')
+ } else if (prop === 'justifySelf' && alignSelfStretch) {
+ working = updateOpposite(working, element.globalFrame, 'alignSelf', 'width')
+ }
+ return working
+ }
+
+ const { align, justify } = MetadataUtils.getRelativeAlignJustify(
+ workingEditorState.jsxMetadata,
+ view,
+ )
+
+ switch (alignment) {
+ case 'bottom':
+ workingEditorState = apply(workingEditorState, align, 'end')
+ break
+ case 'top':
+ workingEditorState = apply(workingEditorState, align, 'start')
+ break
+ case 'vcenter':
+ workingEditorState = apply(workingEditorState, align, 'center')
+ break
+ case 'hcenter':
+ workingEditorState = apply(workingEditorState, justify, 'center')
+ break
+ case 'left':
+ workingEditorState = apply(workingEditorState, justify, 'start')
+ break
+ case 'right':
+ workingEditorState = apply(workingEditorState, justify, 'end')
+ break
+ default:
+ assertNever(alignment)
+ }
+ }
+
+ return workingEditorState
+}
+
+function alignFlowChildren(
+ editor: EditorState,
+ views: ElementPath[],
+ alignmentOrDistribution: Alignment | Distribution,
+) {
+ let workingEditorState = { ...editor }
+
+ if (views.length > 0) {
+ // this array of canvasFrames excludes the non-layoutables. it means in a multiselect, they will not be considered
+ const canvasFrames: Array<{
+ target: ElementPath
+ frame: CanvasRectangle
+ }> = Utils.stripNulls(
+ views.map((target) => {
+ const instanceGlobalFrame = MetadataUtils.getFrameInCanvasCoords(
+ target,
+ workingEditorState.jsxMetadata,
+ )
+ if (instanceGlobalFrame == null || isInfinityRectangle(instanceGlobalFrame)) {
+ return null
+ } else {
+ return {
+ target: target,
+ frame: instanceGlobalFrame,
+ }
+ }
+ }),
+ )
+
+ if (canvasFrames.length > 0) {
+ const parentPath = EP.parentPath(views[0])
+ const sourceIsParent = views.length === 1 && parentPath != null
+ let source: CanvasRectangle
+ if (sourceIsParent) {
+ const parentFrame = MetadataUtils.getFrameInCanvasCoords(
+ parentPath,
+ workingEditorState.jsxMetadata,
+ )
+
+ // if the parent frame is null, that means we probably ran into some error state,
+ // as it means the child's globalFrame should also be null, so we shouldn't be in this branch
+ const maybeSource = Utils.forceNotNull(
+ `found no parent global frame for ${EP.toComponentId(parentPath!)}`,
+ parentFrame,
+ )
+
+ // If the parent frame is infinite, fall back to using the selected element's frame
+ source = isInfinityRectangle(maybeSource) ? canvasFrames[0].frame : maybeSource
+ } else {
+ source = Utils.boundingRectangleArray(Utils.pluck(canvasFrames, 'frame'))! // I know this can't be null because we checked the canvasFrames array is non-empty
+ }
+ const updatedCanvasFrames = alignOrDistributeCanvasRects(
+ workingEditorState.jsxMetadata,
+ canvasFrames,
+ source,
+ alignmentOrDistribution,
+ sourceIsParent,
+ )
+ workingEditorState = {
+ ...setCanvasFramesInnerNew(workingEditorState, updatedCanvasFrames, null),
+ }
+ }
+ }
+ return workingEditorState
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/can-update-file.ts b/nexus-builder/packages/core/src/components/editor/actions/can-update-file.ts
new file mode 100644
index 000000000000..914aa9dcdc2b
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/can-update-file.ts
@@ -0,0 +1,42 @@
+import { isOlderThan, canUpdateRevisionsState } from '../../../core/model/project-file-utils'
+import type { ProjectFile } from '../../../core/shared/project-file-types'
+import { isTextFile, isImageFile, isAssetFile } from '../../../core/shared/project-file-types'
+import {
+ ImageFileKeepDeepEquality,
+ AssetFileKeepDeepEquality,
+} from '../store/store-deep-equality-instances'
+
+export function updateFileIfPossible(
+ updated: ProjectFile,
+ existing: ProjectFile | null,
+): ProjectFile | 'cant-update' {
+ if (existing == null || existing.type !== updated.type) {
+ return updated
+ }
+
+ if (
+ isTextFile(updated) &&
+ isTextFile(existing) &&
+ isOlderThan(existing, updated) &&
+ canUpdateRevisionsState(
+ updated.fileContents.revisionsState,
+ existing.fileContents.revisionsState,
+ )
+ ) {
+ return updated
+ }
+
+ if (isImageFile(updated) && existing != null && isImageFile(existing)) {
+ if (ImageFileKeepDeepEquality(existing, updated).areEqual) {
+ return 'cant-update'
+ }
+ }
+
+ if (isAssetFile(updated) && existing != null && isAssetFile(existing)) {
+ if (AssetFileKeepDeepEquality(existing, updated).areEqual) {
+ return 'cant-update'
+ }
+ }
+
+ return 'cant-update'
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/meta-actions.ts b/nexus-builder/packages/core/src/components/editor/actions/meta-actions.ts
new file mode 100644
index 000000000000..0930ef867020
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/meta-actions.ts
@@ -0,0 +1,49 @@
+import type { ElementPath } from '../../../core/shared/project-file-types'
+import { EditorModes } from '../editor-modes'
+import * as EditorActions from './action-creators'
+import CanvasActions from '../../canvas/canvas-actions'
+import { RightMenuTab } from '../store/editor-state'
+import type { EditorAction } from '../action-types'
+
+export type HandleInteractionSession =
+ | 'apply-changes'
+ | 'do-not-apply-changes'
+ | 'ignore-it-completely'
+
+export function cancelInsertModeActions(
+ handleInteractionSession: HandleInteractionSession,
+): Array {
+ let result: Array = []
+ // Determine if the interaction session is cleared or ignored.
+ switch (handleInteractionSession) {
+ case 'apply-changes':
+ result.push(CanvasActions.clearInteractionSession(true))
+ break
+ case 'do-not-apply-changes':
+ result.push(CanvasActions.clearInteractionSession(false))
+ break
+ case 'ignore-it-completely':
+ break
+ default:
+ const _exhaustiveCheck: never = handleInteractionSession
+ throw new Error(`Unhandled ${handleInteractionSession}.`)
+ }
+ // Common actions across all cases.
+ result.push(
+ EditorActions.switchEditorMode(EditorModes.selectMode(null, false, 'none'), 'textEdit'),
+ )
+ result.push(EditorActions.setRightMenuTab(RightMenuTab.Inspector))
+ result.push(EditorActions.clearHighlightedViews())
+
+ return result
+}
+
+export function selectComponents(
+ target: Array,
+ addToSelection: boolean,
+): Array {
+ return [
+ ...cancelInsertModeActions('apply-changes'),
+ EditorActions.selectComponents(target, addToSelection),
+ ]
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/migrations/migrations.ts b/nexus-builder/packages/core/src/components/editor/actions/migrations/migrations.ts
new file mode 100644
index 000000000000..e3a413de7c04
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/migrations/migrations.ts
@@ -0,0 +1,490 @@
+import type { PersistentModel } from '../../store/editor-state'
+import { StoryboardFilePath } from '../../store/editor-state'
+import { objectMap } from '../../../../core/shared/object-utils'
+import type {
+ ProjectFile,
+ SceneMetadata,
+ TextFile,
+} from '../../../../core/shared/project-file-types'
+import {
+ isParseSuccess,
+ isTextFile,
+ textFile,
+ textFileContents,
+ unparsed,
+ RevisionsState,
+} from '../../../../core/shared/project-file-types'
+import { isRight, right } from '../../../../core/shared/either'
+import {
+ BakedInStoryboardVariableName,
+ convertScenesToUtopiaCanvasComponent,
+} from '../../../../core/model/scene-utils'
+import type { ProjectContentFile, ProjectContentsTree } from '../../../assets'
+import {
+ addFileToProjectContents,
+ contentsToTree,
+ getProjectFileByFilePath,
+ projectContentFile,
+ removeFromProjectContents,
+ transformContentsTree,
+ walkContentsTree,
+} from '../../../assets'
+import { isUtopiaJSXComponent } from '../../../../core/shared/element-template'
+
+export const CURRENT_PROJECT_VERSION = 15
+
+export function applyMigrations(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: typeof CURRENT_PROJECT_VERSION } {
+ const version1 = migrateFromVersion0(persistentModel)
+ const version2 = migrateFromVersion1(version1)
+ const version3 = migrateFromVersion2(version2)
+ const version4 = migrateFromVersion3(version3)
+ const version5 = migrateFromVersion4(version4)
+ const version6 = migrateFromVersion5(version5)
+ const version7 = migrateFromVersion6(version6)
+ const version8 = migrateFromVersion7(version7)
+ const version9 = migrateFromVersion8(version8)
+ const version10 = migrateFromVersion9(version9)
+ const version11 = migrateFromVersion10(version10)
+ const version12 = migrateFromVersion11(version11)
+ const version13 = migrateFromVersion12(version12)
+ const version14 = migrateFromVersion13(version13)
+ const version15 = migrateFromVersion14(version14)
+ return version15
+}
+
+function migrateFromVersion0(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 1 } & { openFiles: any; selectedFile: any } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 0) {
+ return persistentModel as any
+ } else {
+ function updateOpenFilesEntry(openFile: string): any {
+ return {
+ type: 'OPEN_FILE_TAB',
+ filename: openFile,
+ }
+ }
+
+ const updatedOpenFiles = (persistentModel as any).openFiles.map((openFile: any) =>
+ updateOpenFilesEntry(openFile),
+ )
+ let updatedSelectedFile: any | null = null
+ const selectedFileAsString: string = (persistentModel as any).selectedFile as any
+ if (selectedFileAsString != '') {
+ updatedSelectedFile = updateOpenFilesEntry(selectedFileAsString)
+ }
+
+ return {
+ ...persistentModel,
+ openFiles: updatedOpenFiles,
+ selectedFile: updatedSelectedFile,
+ projectVersion: 1,
+ }
+ }
+}
+
+function migrateFromVersion1(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 2 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 1) {
+ return persistentModel as any
+ } else {
+ const updatedFiles = objectMap((file: ProjectFile, fileName) => {
+ if (
+ isTextFile(file) &&
+ isParseSuccess(file.fileContents as any) &&
+ isRight((file.fileContents as any).value.canvasMetadata)
+ ) {
+ const canvasMetadataParseSuccess = (file.fileContents as any).value.canvasMetadata.value
+ // this old canvas metadata might store an array of `scenes: Array`, whereas we expect a UtopiaJSXComponent here
+ if (
+ (canvasMetadataParseSuccess as any).utopiaCanvasJSXComponent == null &&
+ (canvasMetadataParseSuccess as any)['scenes'] != null
+ ) {
+ const scenes = (canvasMetadataParseSuccess as any)['scenes'] as Array
+ const utopiaCanvasComponent = convertScenesToUtopiaCanvasComponent(scenes)
+ const updatedCanvasMetadataParseSuccess: any = right({
+ utopiaCanvasJSXComponent: utopiaCanvasComponent,
+ })
+ return {
+ ...file,
+ fileContents: {
+ ...file.fileContents,
+ value: {
+ ...(file.fileContents as any).value,
+ canvasMetadata: updatedCanvasMetadataParseSuccess,
+ },
+ },
+ } as TextFile
+ } else {
+ return file
+ }
+ } else {
+ return file
+ }
+ }, persistentModel.projectContents as any)
+ return {
+ ...persistentModel,
+ projectContents: updatedFiles as any,
+ projectVersion: 2,
+ }
+ }
+}
+
+function migrateFromVersion2(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 3 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 2) {
+ return persistentModel as any
+ } else {
+ const updatedFiles = objectMap((file: ProjectFile, fileName) => {
+ if (isTextFile(file) && isParseSuccess(file.fileContents as any)) {
+ if (
+ isRight((file.fileContents as any).value.canvasMetadata) &&
+ // the parseSuccess contained a utopiaCanvasJSXComponent which we now merge to the array of topLevelElements
+ ((file.fileContents as any).value.canvasMetadata.value as any).utopiaCanvasJSXComponent !=
+ null
+ ) {
+ const utopiaCanvasJSXComponent = (
+ (file.fileContents as any).value.canvasMetadata.value as any
+ ).utopiaCanvasJSXComponent
+ const updatedTopLevelElements = [
+ ...(file.fileContents as any).value.topLevelElements,
+ utopiaCanvasJSXComponent,
+ ]
+ return {
+ ...file,
+ fileContents: {
+ ...file.fileContents,
+ value: {
+ ...(file.fileContents as any).value,
+ topLevelElements: updatedTopLevelElements,
+ canvasMetadata: right({}),
+ projectContainedOldSceneMetadata: true,
+ },
+ },
+ } as TextFile
+ } else {
+ return {
+ ...file,
+ fileContents: {
+ ...file.fileContents,
+ value: {
+ ...(file.fileContents as any).value,
+ projectContainedOldSceneMetadata: true,
+ },
+ },
+ }
+ }
+ } else {
+ return file
+ }
+ }, persistentModel.projectContents as any)
+ return {
+ ...persistentModel,
+ projectContents: updatedFiles as any,
+ projectVersion: 3,
+ }
+ }
+}
+
+const PackageJsonUrl = '/package.json'
+
+function migrateFromVersion3(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 4 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 3) {
+ return persistentModel as any
+ } else {
+ const packageJsonFile = (persistentModel.projectContents as any)[PackageJsonUrl]
+ if (packageJsonFile != null && isTextFile(packageJsonFile)) {
+ const parsedPackageJson = JSON.parse(packageJsonFile.fileContents as any)
+ const updatedPackageJson = {
+ ...parsedPackageJson,
+ utopia: {
+ ...parsedPackageJson.utopia,
+ html: `public/${parsedPackageJson.utopia.html}`,
+ js: `public/${parsedPackageJson.utopia.js}`,
+ },
+ }
+ const printedPackageJson = JSON.stringify(updatedPackageJson, null, 2)
+ const updatedPackageJsonFile = {
+ type: 'CODE_FILE',
+ fileContents: printedPackageJson,
+ lastSavedContents: null,
+ }
+
+ return {
+ ...persistentModel,
+ projectVersion: 4,
+ projectContents: {
+ ...persistentModel.projectContents,
+ [PackageJsonUrl]: updatedPackageJsonFile as any,
+ },
+ }
+ } else {
+ console.error('Error migrating project: package.json not found, skipping')
+ return { ...persistentModel, projectVersion: 4 }
+ }
+ }
+}
+
+function migrateFromVersion4(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 5 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 4) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 5,
+ projectContents: contentsToTree(persistentModel.projectContents as any),
+ }
+ }
+}
+
+function migrateFromVersion5(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 6 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 5) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 6,
+ projectContents: transformContentsTree(
+ persistentModel.projectContents,
+ (tree: ProjectContentsTree) => {
+ if (tree.type === 'PROJECT_CONTENT_FILE') {
+ const file: ProjectContentFile['content'] = tree.content
+ const fileType = file.type as string
+ if (fileType === 'CODE_FILE') {
+ const newFile = textFile(
+ textFileContents((file as any).fileContents, unparsed, RevisionsState.CodeAhead),
+ null,
+ null,
+ 0,
+ )
+ return projectContentFile(tree.fullPath, newFile)
+ } else if (fileType === 'UI_JS_FILE') {
+ const code = (file as any).fileContents.value.code
+ const newFile = textFile(
+ textFileContents(code, unparsed, RevisionsState.CodeAhead),
+ null,
+ null,
+ 0,
+ )
+ return projectContentFile(tree.fullPath, newFile)
+ } else {
+ return tree
+ }
+ } else {
+ return tree
+ }
+ },
+ ),
+ }
+ }
+}
+
+function migrateFromVersion6(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 7 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 6) {
+ return persistentModel as any
+ } else {
+ let storyboardTarget: string | null = null
+ walkContentsTree(persistentModel.projectContents, (fullPath: string, file: ProjectFile) => {
+ // Don't bother looking further if there's already something to work with.
+ if (storyboardTarget == null) {
+ if (isTextFile(file) && isParseSuccess(file.fileContents.parsed)) {
+ for (const topLevelElement of file.fileContents.parsed.topLevelElements) {
+ if (
+ isUtopiaJSXComponent(topLevelElement) &&
+ topLevelElement.name === BakedInStoryboardVariableName
+ ) {
+ storyboardTarget = fullPath
+ }
+ }
+ }
+ }
+ })
+
+ let updatedProjectContents = persistentModel.projectContents
+ if (storyboardTarget != null) {
+ const file = getProjectFileByFilePath(updatedProjectContents, storyboardTarget)
+ if (file == null) {
+ throw new Error(`Internal error in migration: Unable to find file ${storyboardTarget}.`)
+ } else {
+ // Move the file around.
+ updatedProjectContents = removeFromProjectContents(updatedProjectContents, storyboardTarget)
+ updatedProjectContents = addFileToProjectContents(
+ updatedProjectContents,
+ StoryboardFilePath,
+ file,
+ )
+ }
+ }
+ return {
+ ...persistentModel,
+ projectVersion: 7,
+ projectContents: updatedProjectContents,
+ }
+ }
+}
+
+function migrateFromVersion7(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 8 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 7) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 8,
+ githubSettings: {
+ targetRepository: null,
+ } as any,
+ }
+ }
+}
+
+function migrateFromVersion8(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 9 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 8) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 9,
+ githubSettings: {
+ ...persistentModel.githubSettings,
+ originCommit: null,
+ },
+ }
+ }
+}
+
+function migrateFromVersion9(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 10 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 9) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 10,
+ githubSettings: {
+ ...persistentModel.githubSettings,
+ originCommit: null,
+ branchName: null,
+ },
+ githubChecksums: null,
+ branchContents: null,
+ } as any
+ }
+}
+
+function migrateFromVersion10(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 11 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 10) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 11,
+ githubSettings: {
+ ...persistentModel.githubSettings,
+ originCommit: null,
+ branchName: null,
+ branchLoaded: false,
+ },
+ githubChecksums: null,
+ branchContents: null,
+ } as any
+ }
+}
+
+function migrateFromVersion11(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 12 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 11) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 12,
+ assetChecksums: {},
+ } as any
+ }
+}
+
+function migrateFromVersion12(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 13 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 12) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 13,
+ colorSwatches: [],
+ }
+ }
+}
+
+function migrateFromVersion13(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 14 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 13) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 14,
+ projectContents: transformContentsTree(
+ persistentModel.projectContents,
+ (tree: ProjectContentsTree) => {
+ if (tree.type === 'PROJECT_CONTENT_FILE') {
+ const file: ProjectContentFile['content'] = tree.content
+ if (file.type === 'TEXT_FILE') {
+ // We replaced lastRevisedTime (a timestamp) with versionNumber
+ const migratedFile = textFile(
+ file.fileContents,
+ file.lastSavedContents,
+ file.lastParseSuccess,
+ 0,
+ )
+ return projectContentFile(tree.fullPath, migratedFile)
+ } else {
+ return tree
+ }
+ } else {
+ return tree
+ }
+ },
+ ),
+ }
+ }
+}
+
+function migrateFromVersion14(
+ persistentModel: PersistentModel,
+): PersistentModel & { projectVersion: 15 } {
+ if (persistentModel.projectVersion != null && persistentModel.projectVersion !== 14) {
+ return persistentModel as any
+ } else {
+ return {
+ ...persistentModel,
+ projectVersion: 15,
+ codeEditorErrors: {
+ ...persistentModel.codeEditorErrors,
+ componentDescriptorErrors: persistentModel.codeEditorErrors.componentDescriptorErrors ?? {},
+ },
+ }
+ }
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/toast-helpers.ts b/nexus-builder/packages/core/src/components/editor/actions/toast-helpers.ts
new file mode 100644
index 000000000000..b539520fe591
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/toast-helpers.ts
@@ -0,0 +1,40 @@
+import type { Spec } from 'immutability-helper'
+import { uniqBy } from '../../../core/shared/array-utils'
+import type { Notice } from '../../common/notice'
+import { notice } from '../../common/notice'
+import type { EditorState } from '../store/editor-state'
+
+export function uniqToasts(notices: ReadonlyArray): ReadonlyArray {
+ return uniqBy(notices, (l, r) => l.id === r.id)
+}
+
+export function removeToastFromState(editorState: EditorState, noticeID: string): EditorState {
+ return {
+ ...editorState,
+ toasts: editorState.toasts.filter((toast) => toast.id !== noticeID),
+ }
+}
+
+export function addToastToState(editorState: EditorState, noticeToAdd: Notice): EditorState {
+ const withOldToastRemoved = removeToastFromState(editorState, noticeToAdd.id)
+ return {
+ ...withOldToastRemoved,
+ toasts: uniqToasts([...withOldToastRemoved.toasts, noticeToAdd]),
+ }
+}
+
+export function includeToast(toastText: string | null, editorState: EditorState): EditorState {
+ return toastText == null ? editorState : addToastToState(editorState, notice(toastText))
+}
+
+export function includeToastPatch(
+ toastText: string | null,
+ editorState: EditorState,
+): Spec {
+ const updatedEditorState = includeToast(toastText, editorState)
+ return {
+ toasts: {
+ $set: updatedEditorState.toasts,
+ },
+ }
+}
diff --git a/nexus-builder/packages/core/src/components/editor/actions/wrap-unwrap-helpers.tsx b/nexus-builder/packages/core/src/components/editor/actions/wrap-unwrap-helpers.tsx
new file mode 100644
index 000000000000..bfda9375c869
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/editor/actions/wrap-unwrap-helpers.tsx
@@ -0,0 +1,620 @@
+import {
+ findMaybeConditionalExpression,
+ getClauseOptic,
+ getConditionalActiveCase,
+ getConditionalBranch,
+} from '../../../core/model/conditionals'
+import {
+ MetadataUtils,
+ getSimpleAttributeAtPath,
+ propertyHasSimpleValue,
+} from '../../../core/model/element-metadata-utils'
+import {
+ generateUidWithExistingComponents,
+ insertJSXElementChildren,
+ renameJsxElementChild,
+ transformJSXComponentAtPath,
+} from '../../../core/model/element-template-utils'
+import {
+ applyUtopiaJSXComponentsChanges,
+ getFilePathMappings,
+ getUtopiaJSXComponentsFromSuccess,
+} from '../../../core/model/project-file-utils'
+import * as EP from '../../../core/shared/element-path'
+import * as PP from '../../../core/shared/property-path'
+import type {
+ ElementInstanceMetadataMap,
+ JSXConditionalExpression,
+ JSXElement,
+ JSXFragment,
+ JSXMapExpression,
+} from '../../../core/shared/element-template'
+import {
+ JSXElementChild,
+ emptyComments,
+ isJSXAttributeValue,
+ isJSXConditionalExpression,
+ isJSXElement,
+ isJSXElementLike,
+ jsExpressionValue,
+ jsxFragment,
+ jsxTextBlock,
+} from '../../../core/shared/element-template'
+import { modify, toFirst } from '../../../core/shared/optics/optic-utilities'
+import { forceNotNull, optionalMap } from '../../../core/shared/optional-utils'
+import type { ElementPath, Imports } from '../../../core/shared/project-file-types'
+import type { IndexPosition } from '../../../utils/utils'
+import { absolute } from '../../../utils/utils'
+import type { EditorDispatch } from '../action-types'
+import type { DerivedState, EditorState } from '../store/editor-state'
+import {
+ modifyUnderlyingTargetElement,
+ withUnderlyingTargetFromEditorState,
+} from '../store/editor-state'
+import type { ConditionalClauseInsertionPath, InsertionPath } from '../store/insertion-path'
+import {
+ childInsertionPath,
+ getElementPathFromInsertionPath,
+ getInsertionPath,
+ isChildInsertionPath,
+} from '../store/insertion-path'
+import { deleteView } from './action-creators'
+import { UPDATE_FNS } from './actions'
+import { foldAndApplyCommandsSimple } from '../../canvas/commands/commands'
+import { addElement } from '../../canvas/commands/add-element-command'
+import { mergeImports } from '../../../core/workers/common/project-file-utils'
+import type { ElementPathTrees } from '../../../core/shared/element-path-tree'
+import { fixUtopiaElementGeneric } from '../../../core/shared/uid-utils'
+import { getUidMappings, getAllUniqueUidsFromMapping } from '../../../core/model/get-uid-mappings'
+import { foldEither, right } from '../../../core/shared/either'
+import { editorStateToElementChildOptic } from '../../../core/model/common-optics'
+import { fromField, fromTypeGuard } from '../../../core/shared/optics/optic-creators'
+import { setJSXValueAtPath } from '../../../core/shared/jsx-attribute-utils'
+import { addToastToState } from './toast-helpers'
+import { notice } from '../../../components/common/notice'
+
+export function unwrapConditionalClause(
+ editor: EditorState,
+ target: ElementPath,
+ parentPath: ConditionalClauseInsertionPath,
+): EditorState {
+ let newSelection: Array = []
+ const withElementMoved = modifyUnderlyingTargetElement(
+ parentPath.intendedParentPath,
+ editor,
+ (element) => element,
+ (success) => {
+ const components = getUtopiaJSXComponentsFromSuccess(success)
+ const updatedComponents = transformJSXComponentAtPath(
+ components,
+ EP.dynamicPathToStaticPath(parentPath.intendedParentPath),
+ (elem) => {
+ if (isJSXConditionalExpression(elem)) {
+ const clauseOptic = getClauseOptic(parentPath.clause)
+ return modify(
+ clauseOptic,
+ (clauseElement) => {
+ if (isJSXElement(clauseElement)) {
+ if (clauseElement.children.length === 0) {
+ return jsExpressionValue(null, emptyComments)
+ } else if (clauseElement.children.length === 1) {
+ const childElement = clauseElement.children[0]
+ newSelection.push(
+ EP.appendToPath(parentPath.intendedParentPath, childElement.uid),
+ )
+ return childElement
+ } else {
+ const newUID = generateUidWithExistingComponents(editor.projectContents)
+ newSelection.push(EP.appendToPath(parentPath.intendedParentPath, newUID))
+ return jsxFragment(newUID, clauseElement.children, false)
+ }
+ } else if (isJSXConditionalExpression(clauseElement)) {
+ const activeCase = getConditionalActiveCase(
+ target,
+ clauseElement,
+ editor.spyMetadata,
+ )
+ if (activeCase != null) {
+ return activeCase === 'true-case'
+ ? clauseElement.whenTrue
+ : clauseElement.whenFalse
+ }
+ }
+ return clauseElement
+ },
+ elem,
+ )
+ } else {
+ return elem
+ }
+ },
+ )
+
+ const updatedTopLevelElements = applyUtopiaJSXComponentsChanges(
+ success.topLevelElements,
+ updatedComponents,
+ )
+
+ return {
+ ...success,
+ topLevelElements: updatedTopLevelElements,
+ }
+ },
+ )
+
+ return { ...withElementMoved, selectedViews: newSelection }
+}
+export function unwrapTextContainingConditional(
+ editor: EditorState,
+ target: ElementPath,
+): EditorState {
+ const conditional = findMaybeConditionalExpression(target, editor.jsxMetadata)
+ if (conditional == null) {
+ return editor
+ }
+ const activeCase = getConditionalActiveCase(target, conditional, editor.spyMetadata)
+ if (activeCase == null) {
+ return editor
+ }
+ const branch = getConditionalBranch(conditional, activeCase)
+ const isTextBranch = isJSXAttributeValue(branch) && typeof branch.value === 'string'
+ const elementToInsert = isTextBranch ? jsxTextBlock(branch.value) : branch
+
+ const targetParent = EP.parentPath(target)
+ const originalIndexPosition = MetadataUtils.getIndexInParent(
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ target,
+ )
+
+ const withParentUpdated = modifyUnderlyingTargetElement(
+ targetParent,
+ editor,
+ (element) => element,
+ (success) => {
+ const components = getUtopiaJSXComponentsFromSuccess(success)
+
+ const wrapperUID = generateUidWithExistingComponents(editor.projectContents)
+ const insertionPath = getInsertionPath(
+ targetParent,
+ editor.projectContents,
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ wrapperUID,
+ 1,
+ editor.propertyControlsInfo,
+ )
+ if (insertionPath == null) {
+ throw new Error('Invalid unwrap insertion path')
+ }
+
+ const updatedComponents = insertJSXElementChildren(
+ insertionPath,
+ [elementToInsert],
+ components,
+ absolute(originalIndexPosition),
+ )
+
+ const updatedTopLevelElements = applyUtopiaJSXComponentsChanges(
+ success.topLevelElements,
+ updatedComponents.components,
+ )
+
+ return {
+ ...success,
+ topLevelElements: updatedTopLevelElements,
+ }
+ },
+ )
+
+ return UPDATE_FNS.DELETE_VIEW(deleteView(target), withParentUpdated)
+}
+
+export function isTextContainingConditional(
+ target: ElementPath,
+ metadata: ElementInstanceMetadataMap,
+): boolean {
+ const element = MetadataUtils.findElementByElementPath(metadata, target)
+ const conditional = findMaybeConditionalExpression(target, metadata)
+ if (
+ conditional != null &&
+ element != null &&
+ MetadataUtils.isConditionalFromMetadata(element) &&
+ element.conditionValue !== 'not-a-conditional'
+ ) {
+ const currentValue = element.conditionValue.active
+ if (currentValue === true) {
+ return !isJSXElementLike(conditional.whenTrue)
+ } else if (currentValue === false) {
+ return !isJSXElementLike(conditional.whenFalse)
+ }
+ }
+ return false
+}
+
+function elementHasPositionOf(targetElement: JSXElement, positionValue: string): boolean {
+ return propertyHasSimpleValue(
+ right(targetElement.props),
+ PP.create('style', 'position'),
+ positionValue,
+ )
+}
+
+function elementHasContainLayout(targetElement: JSXElement): boolean {
+ return propertyHasSimpleValue(right(targetElement.props), PP.create('style', 'contain'), 'layout')
+}
+
+export function fixParentContainingBlockSettings(
+ editorState: EditorState,
+ targetPath: ElementPath,
+): EditorState {
+ // Determine if the current status of this element means that we potentially need to look
+ // into its ancestors.
+ const possibleTargetElement = toFirst(
+ editorStateToElementChildOptic(targetPath).compose(fromTypeGuard(isJSXElement)),
+ editorState,
+ )
+ const targetNecessitatesAncestorChecks: boolean = foldEither(
+ () => {
+ return false
+ },
+ (targetElement) => {
+ // Currently any element with `position: 'absolute'` is a likely candidate.
+ const targetHasPositionAbsolute = elementHasPositionOf(targetElement, 'absolute')
+ return targetHasPositionAbsolute
+ },
+ possibleTargetElement,
+ )
+
+ let shouldUpdateParent: boolean = false
+ const parentPath = EP.parentPath(targetPath)
+ const parentOptic = editorStateToElementChildOptic(parentPath).compose(
+ fromTypeGuard(isJSXElement),
+ )
+ // Check the status of the parent to see if that needs to be updated.
+ // Do not go outside of the current component, at least for now.
+ if (targetNecessitatesAncestorChecks && EP.isFromSameInstanceAs(targetPath, parentPath)) {
+ // Determine if the parent needs to be fixed up.
+ const possibleParentElement = toFirst(parentOptic, editorState)
+ shouldUpdateParent = foldEither(
+ () => {
+ return false
+ },
+ (parentElement) => {
+ // If the parent does not specify `position: 'absolute'`, `position: 'relative'`
+ // or it doesn't already have `contain: 'layout'`, then it needs updating.
+ const parentDefinesContainingBlock =
+ elementHasPositionOf(parentElement, 'absolute') ||
+ elementHasPositionOf(parentElement, 'relative') ||
+ elementHasContainLayout(parentElement)
+ return !parentDefinesContainingBlock
+ },
+ possibleParentElement,
+ )
+ }
+
+ // Update the parent if necessary.
+ if (shouldUpdateParent) {
+ let attributesUpdated: boolean = false
+ const updatedEditorState = modify(
+ parentOptic.compose(fromField('props')),
+ (attributes) => {
+ // Try to update the attributes, which may not be possible if they're
+ // defined by an expression.
+ const maybeUpdatedAttributes = setJSXValueAtPath(
+ attributes,
+ PP.create('style', 'contain'),
+ jsExpressionValue('layout', emptyComments),
+ )
+ return foldEither(
+ () => {
+ return attributes
+ },
+ (updatedAttributes) => {
+ attributesUpdated = true
+ return updatedAttributes
+ },
+ maybeUpdatedAttributes,
+ )
+ },
+ editorState,
+ )
+ if (attributesUpdated) {
+ // Add a toast indicating that `contain: 'layout'` has been added.
+ const toast = notice(
+ "Added `contain: 'layout'` to the parent of the newly added element.",
+ 'INFO',
+ )
+ return addToastToState(updatedEditorState, toast)
+ }
+ }
+
+ return editorState
+}
+
+export function wrapElementInsertions(
+ editor: EditorState,
+ targets: Array,
+ parentPath: InsertionPath,
+ rawElementToInsert: JSXElement | JSXFragment | JSXConditionalExpression | JSXMapExpression,
+ importsToAdd: Imports,
+ anyTargetIsARootElement: boolean,
+ targetThatIsRootElementOfCommonParent: ElementPath | undefined,
+): { updatedEditor: EditorState; newPath: ElementPath | null } {
+ // TODO this entire targetThatIsRootElementOfCommonParent could be simplified by introducing a "rootElementInsertionPath" to InsertionPath
+ const staticTarget =
+ optionalMap(childInsertionPath, targetThatIsRootElementOfCommonParent) ?? parentPath
+
+ const existingIDsMutable = new Set(
+ getAllUniqueUidsFromMapping(getUidMappings(editor.projectContents).filePathToUids),
+ )
+ const elementToInsert = fixUtopiaElementGeneric(
+ rawElementToInsert,
+ existingIDsMutable,
+ ).value
+
+ const newPath = anyTargetIsARootElement
+ ? EP.appendNewElementPath(getElementPathFromInsertionPath(parentPath), elementToInsert.uid)
+ : EP.appendToPath(getElementPathFromInsertionPath(parentPath), elementToInsert.uid)
+
+ const indexPosition = findIndexPositionInParent(
+ targets,
+ parentPath,
+ editor.jsxMetadata,
+ editor.elementPathTree,
+ )
+
+ switch (elementToInsert.type) {
+ case 'JSX_FRAGMENT': {
+ function pathsToBeWrappedInFragment(): ElementPath[] {
+ const elements: ElementPath[] = targets.filter((path) => {
+ return !targets
+ .filter((otherPath) => !EP.pathsEqual(otherPath, path))
+ .some((otherPath) => EP.isDescendantOf(path, otherPath))
+ })
+ const parents = new Set()
+ elements.forEach((e) => parents.add(EP.parentPath(e)))
+ if (parents.size !== 1) {
+ return []
+ }
+ return elements
+ }
+
+ const children = pathsToBeWrappedInFragment()
+ if (children.length === 0) {
+ // nothing to do
+ return { updatedEditor: editor, newPath: null }
+ }
+
+ switch (staticTarget.type) {
+ case 'CHILD_INSERTION':
+ return {
+ updatedEditor: foldAndApplyCommandsSimple(editor, [
+ addElement('always', staticTarget, elementToInsert, { importsToAdd, indexPosition }),
+ ]),
+ newPath: newPath,
+ }
+ case 'CONDITIONAL_CLAUSE_INSERTION':
+ const withTargetAdded = insertElementIntoJSXConditional(
+ editor,
+ staticTarget,
+ elementToInsert,
+ importsToAdd,
+ )
+ return { updatedEditor: withTargetAdded, newPath: newPath }
+ default:
+ const _exhaustiveCheck: never = staticTarget
+ return { updatedEditor: editor, newPath: null }
+ }
+ }
+ case 'JSX_ELEMENT': {
+ switch (staticTarget.type) {
+ case 'CHILD_INSERTION':
+ return {
+ updatedEditor: foldAndApplyCommandsSimple(editor, [
+ addElement('always', staticTarget, elementToInsert, { importsToAdd, indexPosition }),
+ ]),
+ newPath: newPath,
+ }
+ case 'CONDITIONAL_CLAUSE_INSERTION':
+ const withTargetAdded = insertElementIntoJSXConditional(
+ editor,
+ staticTarget,
+ elementToInsert,
+ importsToAdd,
+ )
+ return { updatedEditor: withTargetAdded, newPath: newPath }
+ default:
+ const _exhaustiveCheck: never = staticTarget
+ return { updatedEditor: editor, newPath: null }
+ }
+ }
+ case 'JSX_CONDITIONAL_EXPRESSION': {
+ switch (staticTarget.type) {
+ case 'CHILD_INSERTION':
+ return {
+ updatedEditor: foldAndApplyCommandsSimple(editor, [
+ addElement('always', staticTarget, elementToInsert, { importsToAdd, indexPosition }),
+ ]),
+ newPath: newPath,
+ }
+ case 'CONDITIONAL_CLAUSE_INSERTION':
+ const withTargetAdded = insertConditionalIntoConditionalClause(
+ editor,
+ staticTarget,
+ elementToInsert,
+ importsToAdd,
+ )
+ return { updatedEditor: withTargetAdded, newPath: newPath }
+ default:
+ const _exhaustiveCheck: never = staticTarget
+ return { updatedEditor: editor, newPath: null }
+ }
+ }
+ case 'JSX_MAP_EXPRESSION': {
+ switch (staticTarget.type) {
+ case 'CHILD_INSERTION':
+ return {
+ updatedEditor: foldAndApplyCommandsSimple(editor, [
+ addElement('always', staticTarget, elementToInsert, { importsToAdd, indexPosition }),
+ ]),
+ newPath: newPath,
+ }
+ case 'CONDITIONAL_CLAUSE_INSERTION':
+ const withTargetAdded = insertElementIntoJSXConditional(
+ editor,
+ staticTarget,
+ jsxFragment(elementToInsert.uid, [elementToInsert], false),
+ importsToAdd,
+ )
+ return { updatedEditor: withTargetAdded, newPath: newPath }
+ default:
+ // const _exhaustiveCheck: never = staticTarget
+ return { updatedEditor: editor, newPath: null }
+ }
+ }
+ default:
+ const _exhaustiveCheck: never = elementToInsert
+ return { updatedEditor: editor, newPath: null }
+ }
+}
+
+function findIndexPositionInParent(
+ targets: Array,
+ parentPath: InsertionPath,
+ metadata: ElementInstanceMetadataMap,
+ elementPathTree: ElementPathTrees,
+): IndexPosition | undefined {
+ let indexInParent: number | null = null
+ if (parentPath != null && isChildInsertionPath(parentPath)) {
+ indexInParent = optionalMap(
+ (firstPathMatchingCommonParent) =>
+ MetadataUtils.getIndexInParent(metadata, elementPathTree, firstPathMatchingCommonParent),
+ targets.find((target) => EP.pathsEqual(EP.parentPath(target), parentPath.intendedParentPath)),
+ )
+ }
+ return (
+ optionalMap(
+ (index) =>
+ ({
+ type: 'before',
+ index: index,
+ } as IndexPosition),
+ indexInParent,
+ ) ?? undefined
+ )
+}
+
+export function insertElementIntoJSXConditional(
+ editor: EditorState,
+ staticTarget: ConditionalClauseInsertionPath,
+ elementToInsert: JSXElement | JSXFragment,
+ importsToAdd: Imports,
+): EditorState {
+ return modifyUnderlyingTargetElement(
+ staticTarget.intendedParentPath,
+ editor,
+ (element) => element,
+ (success, _, underlyingFilePath) => {
+ const components = getUtopiaJSXComponentsFromSuccess(success)
+
+ const { imports, duplicateNameMapping } = mergeImports(
+ underlyingFilePath,
+ getFilePathMappings(editor.projectContents),
+ success.imports,
+ importsToAdd,
+ )
+
+ let elementToInsertRenamed = renameJsxElementChild(elementToInsert, duplicateNameMapping)
+
+ const updatedComponents = transformJSXComponentAtPath(
+ components,
+ getElementPathFromInsertionPath(staticTarget),
+ (oldRoot) => {
+ if (isJSXConditionalExpression(oldRoot)) {
+ const clauseOptic = getClauseOptic(staticTarget.clause)
+ return modify(
+ clauseOptic,
+ (clauseElement) => {
+ return {
+ ...elementToInsertRenamed,
+ children: [...elementToInsertRenamed.children, clauseElement],
+ }
+ },
+ oldRoot,
+ )
+ } else {
+ return {
+ ...elementToInsertRenamed,
+ children: [...elementToInsertRenamed.children, oldRoot],
+ }
+ }
+ },
+ )
+
+ const updatedTopLevelElements = applyUtopiaJSXComponentsChanges(
+ success.topLevelElements,
+ updatedComponents,
+ )
+
+ return {
+ ...success,
+ topLevelElements: updatedTopLevelElements,
+ imports: imports,
+ }
+ },
+ )
+}
+function insertConditionalIntoConditionalClause(
+ editor: EditorState,
+ staticTarget: ConditionalClauseInsertionPath,
+ elementToInsert: JSXConditionalExpression,
+ importsToAdd: Imports,
+): EditorState {
+ return modifyUnderlyingTargetElement(
+ staticTarget.intendedParentPath,
+ editor,
+ (element) => element,
+ (success, _, underlyingFilePath) => {
+ const { imports, duplicateNameMapping } = mergeImports(
+ underlyingFilePath,
+ getFilePathMappings(editor.projectContents),
+ success.imports,
+ importsToAdd,
+ )
+
+ let elementToInsertRenamed = renameJsxElementChild(elementToInsert, duplicateNameMapping)
+
+ const components = getUtopiaJSXComponentsFromSuccess(success)
+ const updatedComponents = transformJSXComponentAtPath(
+ components,
+ getElementPathFromInsertionPath(staticTarget),
+ (oldRoot) => {
+ if (isJSXConditionalExpression(oldRoot)) {
+ const clauseOptic = getClauseOptic(staticTarget.clause)
+ return modify(
+ clauseOptic,
+ (clauseElement) => {
+ return { ...elementToInsertRenamed, whenTrue: clauseElement }
+ },
+ oldRoot,
+ )
+ } else {
+ return { ...oldRoot }
+ }
+ },
+ )
+
+ const updatedTopLevelElements = applyUtopiaJSXComponentsChanges(
+ success.topLevelElements,
+ updatedComponents,
+ )
+
+ return {
+ ...success,
+ topLevelElements: updatedTopLevelElements,
+ imports: imports,
+ }
+ },
+ )
+}
diff --git a/nexus-builder/packages/core/src/components/index.ts b/nexus-builder/packages/core/src/components/index.ts
new file mode 100644
index 000000000000..fe3766370b27
--- /dev/null
+++ b/nexus-builder/packages/core/src/components/index.ts
@@ -0,0 +1,2 @@
+export * from './common/actions';
+export * from './editor/actions/action-creators';
diff --git a/nexus-builder/packages/core/src/index.ts b/nexus-builder/packages/core/src/index.ts
new file mode 100644
index 000000000000..872acc368e85
--- /dev/null
+++ b/nexus-builder/packages/core/src/index.ts
@@ -0,0 +1,8 @@
+export * from './shared/array-utils';
+export * from './shared/element-path';
+export * from './templates/editor-navigator';
+export * from './utils/react-performance';
+
+import Utils from './utils/utils';
+export default Utils;
+export * from './components';
diff --git a/nexus-builder/packages/core/src/shared/array-utils.ts b/nexus-builder/packages/core/src/shared/array-utils.ts
new file mode 100644
index 000000000000..d56455fcdae3
--- /dev/null
+++ b/nexus-builder/packages/core/src/shared/array-utils.ts
@@ -0,0 +1,605 @@
+import type { MapLike } from 'typescript'
+import { is, shallowEqual } from './equality-utils'
+import { clamp } from './math-utils'
+import { fastForEach } from './utils'
+
+export function stripNulls(array: Array): Array {
+ var workingArray: Array = []
+ for (const value of array) {
+ if (value !== null && value !== undefined) {
+ workingArray.push(value)
+ }
+ }
+ return workingArray
+}
+
+export function filterDuplicates(array: Array): Array {
+ var workingArray: Array = []
+ for (const value of array) {
+ if (workingArray.indexOf(value) < 0) {
+ workingArray.push(value)
+ }
+ }
+ return workingArray
+}
+
+export function flatMapArray(
+ fn: (t: T, index: number) => Array,
+ arr: ReadonlyArray,
+): Array {
+ var workingArray: Array = []
+ fastForEach(arr, (arrayEntry, index) => {
+ workingArray.push(...fn(arrayEntry, index))
+ })
+ return workingArray
+}
+
+export function mapDropNulls(
+ fn: (t: T, i: number) => U | null | undefined,
+ a: ReadonlyArray,
+): Array {
+ let result: Array = []
+ fastForEach(a, (t, i) => {
+ const u = fn(t, i)
+ if (u != null) {
+ result.push(u)
+ }
+ })
+ return result
+}
+
+export function dropNulls(a: ReadonlyArray): Array {
+ return mapDropNulls((t) => t, a)
+}
+
+export function mapAndFilter(
+ mapFn: (t: T, i: number) => U,
+ filter: (u: U) => boolean,
+ a: ReadonlyArray,
+): Array {
+ return mapDropNulls((t: T, i: number) => {
+ const mapResult = mapFn(t, i)
+ if (filter(mapResult)) {
+ return mapResult
+ } else {
+ return null
+ }
+ }, a)
+}
+
+export function mapFirstApplicable(
+ array: Iterable,
+ mapFn: (t: T, i: number) => U | null,
+): U | null {
+ for (const value of array) {
+ const mapped = mapFn(value, 0)
+ if (mapped != null) {
+ return mapped
+ }
+ }
+ return null
+}
+
+// Dumb version of:
+// traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)
+export function traverseArray(
+ fn: (t: T, index: number) => U | null,
+ arr: Array,
+): Array | null {
+ var workingArray: Array = []
+ var arrayIndex = 0
+ for (const arrayEntry of arr) {
+ const result = fn(arrayEntry, arrayIndex)
+ if (result == null) {
+ return null
+ } else {
+ workingArray.push(result)
+ }
+ arrayIndex += 1
+ }
+ return workingArray
+}
+
+export function pluck(list: T[], key: K): T[K][] {
+ return list.map((listItem) => listItem[key])
+}
+
+export function arrayToObject(
+ arr: Array,
+ keyFn: (t: T) => string,
+): {
+ [key: string]: T
+} {
+ const result: {
+ [key: string]: T
+ } = {}
+ for (const t of arr) {
+ result[keyFn(t)] = t
+ }
+ return result
+}
+
+export function mapArrayToDictionary(
+ arr: ReadonlyArray,
+ keyFn: (t: From, index: number) => Keys,
+ mapFn: (t: From, index: number) => Values,
+): {
+ [key in Keys]: Values
+} {
+ return arr.reduce(
+ (working, next, index) => {
+ const key = keyFn(next, index)
+ working[key] = mapFn(next, index)
+ return working
+ },
+ {} as {
+ [key in Keys]: Values
+ },
+ )
+}
+// taken from Ramda.move: https://github.com/ramda/ramda/blob/v0.26.1/source/move.js
+
+export function move(from: number, to: number, list: Array): Array {
+ var length = list.length
+ var result = [...list]
+ var positiveFrom = from < 0 ? length + from : from
+ var positiveTo = to < 0 ? length + to : to
+ var item = result.splice(positiveFrom, 1)
+ if (
+ positiveFrom < 0 ||
+ positiveFrom >= list.length ||
+ positiveTo < 0 ||
+ positiveTo >= list.length
+ ) {
+ return list
+ } else {
+ return result.slice(0, positiveTo).concat(item).concat(result.slice(positiveTo, list.length))
+ }
+}
+
+export function uniq(array: Array): Array {
+ // this is ~2x faster than R.uniq
+ // https://runkit.com/bayjorix/5aec102a814a000012474a6e
+ return Array.from(new Set(array))
+}
+
+export function uniqBy(array: ReadonlyArray, eq: (l: T, r: T) => boolean): Array {
+ let result: Array = []
+ fastForEach(array, (elem) => {
+ if (result.find((e) => eq(e, elem)) == null) {
+ result.push(elem)
+ }
+ })
+ return result
+}
+
+export function sortBy(array: ReadonlyArray, compare: (l: T, r: T) => number): Array {
+ let result = [...array]
+ result.sort(compare)
+ return result
+}
+
+export function drop(n: number, array: Array): Array {
+ return array.slice(n)
+}
+
+export function dropLast(array: Array): Array {
+ return dropLastN(1, array)
+}
+
+export function dropLastN(n: number, array: Array): Array {
+ return array.slice(0, -n)
+}
+
+export function take(n: number, array: Array): Array {
+ return array.slice(0, n)
+}
+
+export function last(array: Array): T | undefined {
+ return array[array.length - 1]
+}
+
+export function lastOfNonEmptyArray(array: NonEmptyArray): T {
+ return array[array.length - 1]
+}
+
+export function splitAt(n: number, array: Array): [Array, Array] {
+ return [take(n, array), drop(n, array)]
+}
+
+export function removeIndexFromArray(index: number, arr: Array): Array {
+ let result: Array = [...arr]
+ result.splice(index, 1)
+ return result
+}
+
+export function flattenArray(array: Array>): Array {
+ let result: Array = []
+ fastForEach(array, (elem) => result.push(...elem))
+ return result
+}
+
+export function addToMapOfArraysUnique>>(
+ map: M,
+ key: string,
+ value: V,
+): M {
+ if (key in map) {
+ const existing: Array = map[key]
+ if (existing.includes(value)) {
+ return map
+ } else {
+ return {
+ ...map,
+ [key]: [...existing, value],
+ }
+ }
+ } else {
+ return {
+ ...map,
+ [key]: [value],
+ }
+ }
+}
+
+export function groupBy(
+ toString: (value: T) => string,
+ array: Array,
+): { [key: string]: Array } {
+ let result: { [key: string]: Array } = {}
+
+ for (const value of array) {
+ const key = toString(value)
+ if (key in result) {
+ let existing: Array = result[key]
+ existing.push(value)
+ } else {
+ result[key] = [value]
+ }
+ }
+
+ return result
+}
+
+export function addUniquely(
+ array: Array,
+ value: T,
+): Array {
+ if (array.includes(value)) {
+ return array
+ } else {
+ return [...array, value]
+ }
+}
+
+export function pushUniquelyBy(array: Array, value: T, eq: (l: T, r: T) => boolean): void {
+ if (array.findIndex((a) => eq(a, value)) === -1) {
+ array.push(value)
+ }
+}
+
+export function addAllUniquely(
+ array: Array,
+ values: Array,
+): Array {
+ return values.reduce(addUniquely, array)
+}
+
+export function addAllUniquelyBy(
+ array: Array,
+ values: Array,
+ eq: (l: T, r: T) => boolean,
+): Array {
+ let workingArray = [...array]
+ fastForEach(values, (value) => {
+ if (!workingArray.some((a) => eq(a, value))) {
+ workingArray.push(value)
+ }
+ })
+ return workingArray
+}
+
+export function findLastIndex(predicate: (t: T) => boolean, array: ReadonlyArray): number {
+ // Assumes non-sparse arrays starting at zero.
+ for (let index: number = array.length - 1; index >= 0; index--) {
+ const elem = array[index]
+ if (elem != null && predicate(elem)) {
+ return index
+ }
+ }
+ return -1
+}
+
+// For those times when you want to join but use a different separator for the last value
+export function joinSpecial(
+ arr: Array,
+ normalSeparator: string,
+ lastSeparator: string,
+): string {
+ let result: string = ''
+ const length = arr.length
+ fastForEach(arr, (next, index) => {
+ if (index === 0) {
+ result = next
+ } else if (index === length - 1) {
+ result = `${result}${lastSeparator}${next}`
+ } else {
+ result = `${result}${normalSeparator}${next}`
+ }
+ })
+ return result
+}
+
+export function removeAll(
+ target: ReadonlyArray,
+ toRemove: ReadonlyArray,
+ eqFn: (l: T, R: T) => boolean = is,
+): Array {
+ let result: Array = []
+ fastForEach(target, (nextA) => {
+ if (toRemove.findIndex((nextB) => eqFn(nextA, nextB)) < 0) {
+ result.push(nextA)
+ }
+ })
+ return result
+}
+
+export function immutablyUpdateArrayIndex(
+ oldValue: Array,
+ newValue: T,
+ indexToReplace: number,
+): Array {
+ let working = [...oldValue]
+ working[indexToReplace] = newValue
+ return working
+}
+
+export function safeIndex(array: ReadonlyArray