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(`
+
+ Utopia logo + Utopia logo + Utopia logo +
+
`), + '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(`
+
+ Utopia logo + Utopia logo + Utopia logo +
+
+`), + ) + }) + + 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(` +
+
+
+ + Hello World + +
+
+
+ `), + ) + }) +}) + +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: ` +
+
foo
+
bar
+
+ `, + elements: (renderResult) => { + const path = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb']) + return [ + { + element: getElementFromRenderResult(renderResult, path), + originalElementPath: path, + importsToAdd: {}, + }, + ] + }, + pasteInto: childInsertionPath(EP.appendNewElementPath(TestScenePath, ['aaa'])), + want: ` +
+
foo
+
bar
+
foo
+
+ `, + }, + { + name: 'multiple elements', + startingCode: ` +
+
foo
+
bar
+
baz
+
+ `, + 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: ` +
+
+
foo
+
bar
+
+ +
hello
+
there
+
+
+ `, + 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: ` +
+
+
foo
+
bar
+
+ +
+ `, + 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
+ +
+ +
+ `, + }, + { + name: 'a conditional', + startingCode: ` +
+
+
foo
+
bar
+
+ { + // @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: ` +
+ +
foo
+
+
bar
+
+ `, + 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: ` +
+ +
foo
+
+
bar
+
baz
+
+ `, + 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 ? ( +
+
foo
+
+ ) : 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: `
+
Hello!
+
+
`, + targets: [makeTargetPath('root/bbb')], + result: `
+
Hello!
+
+
Hello!
+
+
`, + }, + { + name: `paste an absolute element with % values into a flex layout`, + input: `
+
Hello!
+
+
`, + targets: [makeTargetPath('root/bbb')], + result: `
+
Hello!
+
+
Hello!
+
+
`, + }, + { + name: `paste a flex child with px size into a flex layout`, + input: `
+
+
Hello!
+
+
+
`, + targets: [makeTargetPath('root/bbb/ddd')], + result: `
+
+
Hello!
+
+
+
Hello!
+
+
`, + }, + { + 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: `
+
+
hi
+
+
+
hello
+
`, + targets: [makeTargetPath('root/bbb')], + result: `
+
+
hi
+
+
hello
+
+
hello
+
`, + }, + { + name: 'trying to paste a div into a span is not allowed', + input: `
+ hi +
+
`, + targets: [makeTargetPath('root/bbb')], + result: `
+ hi +
+
+
`, + }, + { + name: 'it is possible to paste a h1 element into a span', + input: `
+ hi +

hello

+
`, + targets: [makeTargetPath('root/bbb')], + result: `
+ + hi

hello

+
+

hello

+
`, + }, + { + name: 'paste 2 absolute elements - elements will keep their position to each other', + input: `
+
+
hi
+
+
hello
+
bello
+
`, + targets: [makeTargetPath('root/hello'), makeTargetPath('root/bello')], + result: `
+
+
hi
+
hello
+
bello
+
+
hello
+
bello
+
`, + }, + ] + + 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: `
+
Hello!
+
+
`, + targets: [makeTargetPath('root/bbb')], + result: `
+
+
Hello!
+
+
`, + }, + { + 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: `
+
Hello!
+
`, + 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: `
+
hello
+
bello
+
`, + 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: `
+
+ Hello! +
+
+
Hi!
+
+
`, + copyTargets: [makeTargetPath('root/bbb')], + pasteTargets: [makeTargetPath('root/ddd')], + expectedSelectedViews: [makeTargetPath('root/aai')], + result: `
+
+ Hello! +
+
+ Hello! +
+
`, + }, + { + name: `paste to replace a flex child`, + input: `
+
+ Hello! +
+
+
+
+
Hi!
+
+
+
+
`, + copyTargets: [makeTargetPath('root/bbb')], + pasteTargets: [makeTargetPath('root/ddd/fff')], + expectedSelectedViews: [makeTargetPath('root/ddd/aak')], + result: `
+
+ Hello! +
+
+
+
+ Hello! +
+
+
+
`, + }, + { + name: `paste to replace an absolute element with multiselection`, + input: `
+
+ Hello! +
+
+
Hi!
+
+
+
+ second element +
+
+
`, + copyTargets: [makeTargetPath('root/bbb'), makeTargetPath('root/fff/ggg')], + pasteTargets: [makeTargetPath('root/ddd')], + expectedSelectedViews: [makeTargetPath('root/aak'), makeTargetPath('root/aaz')], + result: `
+
+ Hello! +
+
+ Hello! +
+
+ second element +
+
+
+ second element +
+
+
`, + }, + { + name: `paste to replace multiselected absolute elements with multiselected absolute elements`, + input: `
+
+ Hello! +
+
+
Hi!
+
+
+ second element +
+
+
Hi!
+
+
`, + 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 + : ( +
+
Hi!
+
+ ) + } +
`, + 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 + : ( +
+
Hi!
+
+ ) + } +
+
+ second element +
+
+
`, + 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 +
+
+ ) + } +
+
+ 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) => ` +
+
+
+
+
${expected} +
+ ` + 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 = ` +
+ Utopia logo +
+ ` + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(testCode), + 'await-first-dom-report', + ) + await renderResult.dispatch([unwrapElements([makeTargetPath('aaa/bbb')])], true) + + expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual( + makeTestProjectCodeWithSnippet( + `
+ Utopia logo +
`, + ), + ) + }) + 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(` +
+
foo
+
+ `), + ) + }) + 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(` +
+
bar
+
+ `), + ) + }) + 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(` +
+
bar
+
+ `), + ) + }) + 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, index: number): T | undefined { + if (index in array) { + return array[index] + } else { + return undefined + } +} + +export function intersection( + first: Array, + second: Array, + eqFn: (l: T, r: T) => boolean = is, +): Array { + let result: Array = [] + for (const valueFromFirst of first) { + let foundIntersection: boolean = false + for (const valueFromSecond of second) { + foundIntersection = eqFn(valueFromFirst, valueFromSecond) + if (foundIntersection) { + let shouldAdd: boolean = true + for (const valueFromResult of result) { + if (eqFn(valueFromResult, valueFromFirst)) { + shouldAdd = false + break + } + } + if (shouldAdd) { + result.push(valueFromFirst) + } + } + } + } + + return result +} + +export function difference( + first: Array, + second: Array, + eqFn: (l: T, r: T) => boolean = is, +): Array { + let result: Array = [] + for (const valueFromFirst of first) { + let foundInSecondArray: boolean = false + for (const valueFromSecond of second) { + foundInSecondArray = eqFn(valueFromFirst, valueFromSecond) + if (foundInSecondArray) { + break + } + } + if (!foundInSecondArray) { + result.push(valueFromFirst) + } + } + + return result +} + +export function insert(index: number, element: T, array: ReadonlyArray): Array { + const clampedIndex = clamp(0, array.length, index) + return [...array.slice(0, clampedIndex), element, ...array.slice(clampedIndex, array.length)] +} + +export function insertMultiple(index: number, element: Array, array: Array): Array { + const clampedIndex = clamp(0, array.length, index) + return [...array.slice(0, clampedIndex), ...element, ...array.slice(clampedIndex, array.length)] +} + +export function reverse(array: Array): Array { + let result = [...array] + result.reverse() + return result +} + +export function aperture(n: number, array: Array): Array> { + if (n > 0) { + if (n > array.length) { + return [array] + } else { + let result: Array> = [] + for (let arrayIndex: number = 0; arrayIndex < array.length - n + 1; arrayIndex++) { + result.push(array.slice(arrayIndex, arrayIndex + n)) + } + return result + } + } else { + return [] + } +} + +export function cartesianProduct(one: ReadonlyArray, other: ReadonlyArray): [T, U][] { + return one.flatMap((x) => other.map((y): [T, U] => [x, y])) +} + +export function allElemsEqual( + ts: T[], + areEqual: (a: T, b: T) => boolean = shallowEqual, +): boolean { + if (ts.length === 0) { + return false + } + + return ts.slice(1).every((obj) => areEqual(ts[0], obj)) +} + +export function strictEvery( + ts: T[], + predicate: (t: T, index: number, array: T[]) => boolean, +): boolean { + return ts.length > 0 && ts.every(predicate) +} + +export function arrayAccumulate(callback: (acc: Array) => void): ReadonlyArray { + const accumulator: Array = [] + callback(accumulator) + return accumulator +} + +export function accumulate(accumulator: T, callback: (acc: T) => void): Readonly { + callback(accumulator) + return accumulator +} + +export function zip(one: A[], other: B[], make: (a: A, b: B) => C): C[] { + const doZip = (oneInner: A[], otherInner: B[]) => + oneInner.map((elem, idx) => make(elem, otherInner[idx])) + + return one.length < other.length ? doZip(one, other) : doZip(one.slice(0, other.length), other) +} + +// https://matiashernandez.dev/blog/post/typescript-how-to-create-a-non-empty-array-type +export type NonEmptyArray = [T, ...T[]] +export function isNonEmptyArray(array: T[]): array is NonEmptyArray { + let [first] = array + return first != null +} + +export function isEmptyArray(array: T[]): array is [] { + return array.length === 0 +} + +export function possiblyUniqueInArray( + array: number[], + existing: (number | null)[], + start: number, +): number { + let index = start + while (existing.includes(index)) { + index++ + if (index >= array.length) { + index = 0 + } + if (index === start) { + return start + } + } + return index +} + +export function isPrefixOf( + possiblePrefix: Array, + checkAgainst: Array, + equals: (first: T, second: T) => boolean = (first, second) => first === second, +): boolean { + if (possiblePrefix.length <= checkAgainst.length) { + return possiblePrefix.every((prefixValue, prefixIndex) => { + return equals(prefixValue, checkAgainst[prefixIndex]) + }) + } else { + // Prefix is too long to be a prefix. + return false + } +} + +export function valueOrArrayToArray(ts: T | T[]): T[] { + return Array.isArray(ts) ? ts : [ts] +} + +export function createArrayWithLength(length: number, value: (index: number) => T): T[] { + return Array.from({ length }, (_, index) => { + // see issue https://github.com/microsoft/TypeScript/issues/37750 + return value instanceof Function ? value(index) : value + }) +} + +export function matrixGetter(array: T[], width: number): (row: number, column: number) => T { + return (row, column) => { + return array[row * width + column] + } +} + +export function range(start: number, end: number): Array { + let result: Array = [] + for (let i = start; i < end; i++) { + result.push(i) + } + return result +} + +export function chunkArrayEqually( + sortedArray: T[], + numberOfChunks: number, + valueFn: (t: T) => number, +): T[][] { + const chunks: T[][] = Array.from({ length: numberOfChunks }, () => []) + const chunkSums: number[] = Array(numberOfChunks).fill(0) + for (const data of sortedArray) { + let minIndex = 0 + for (let i = 1; i < numberOfChunks; i++) { + if (chunkSums[i] < chunkSums[minIndex]) { + minIndex = i + } + } + chunks[minIndex].push(data) + chunkSums[minIndex] += valueFn(data) + } + return chunks.filter((chunk) => chunk.length > 0) +} + +export function sortArrayByAndReturnPermutation( + array: T[], + sortFn: (t: T) => number, + ascending: boolean = true, +): { sortedArray: T[]; permutation: number[] } { + const permutation = array.map((_, index) => index) + permutation.sort((a, b) => { + const sortResult = sortFn(array[a]) - sortFn(array[b]) + return ascending ? sortResult : -sortResult + }) + const sortedArray = permutation.map((index) => array[index]) + return { sortedArray, permutation } +} + +export function revertArrayOrder(array: T[], permutation: number[]): T[] { + return array.map((_, index) => array[permutation.indexOf(index)]) +} + +// From https://stackoverflow.com/a/31879739 +export function interleaveArray(array: T[], elem: T): T[] { + const newArray = [] + let i = 0 + if (i < array.length) { + newArray.push(array[i++]) + } + while (i < array.length) { + newArray.push(elem, array[i++]) + } + return newArray +} diff --git a/nexus-builder/packages/core/src/shared/element-path.ts b/nexus-builder/packages/core/src/shared/element-path.ts new file mode 100644 index 000000000000..a63ee8c85a88 --- /dev/null +++ b/nexus-builder/packages/core/src/shared/element-path.ts @@ -0,0 +1,1274 @@ +import type { + id, + ElementPath, + ElementPathPart, + StaticElementPathPart, + StaticElementPath, +} from './project-file-types' +import { + arrayEqualsByReference, + longestCommonArray, + identity, + fastForEach, + arrayEqualsByValue, +} from './utils' +import { replaceAll } from './string-utils' +import type { NonEmptyArray } from './array-utils' +import { last, dropLastN, drop, dropLast } from './array-utils' +import { forceNotNull, optionalMap } from './optional-utils' +import invariant from '../../third-party/remix/invariant' + +// KILLME, except in 28 places +export const toComponentId = toString + +// Probably KILLME too +export function toVarSafeComponentId(path: ElementPath): string { + const asStr = toString(path) + return replaceAll(asStr, '-', '_') +} + +interface ElementPathCache { + cached: ElementPath | null + childCaches: { [key: string]: ElementPathCache } + rootElementCaches: { [key: string]: ElementPathCache } +} + +function emptyElementPathCache(): ElementPathCache { + return { + cached: null, + childCaches: {}, + rootElementCaches: {}, + } +} + +let pathToStringCache: WeakMap = new WeakMap() +let dynamicToStaticPathCache: Map = new Map() +let dynamicToStaticLastElementPathPartCache: Map = new Map() +let dynamicElementPathToStaticElementPathCache: Map = + new Map() + +let globalPathStringToPathCache: { [key: string]: ElementPath } = {} +let globalElementPathCache: ElementPathCache = emptyElementPathCache() + +type RemovedPaths = Set + +function removePathsFromElementPathCacheWithDeadUIDs(existingUIDs: Set): RemovedPaths { + let removedPaths: Set = new Set() + + function pushRemovedValues(cacheToRemove: ElementPathCache) { + if (cacheToRemove.cached != null) { + removedPaths.add(cacheToRemove.cached) + } + } + + function traverseAndCull( + currentCache: ElementPathCache, + testBeforeCull: 'test-before-cull' | 'cull-all', + ) { + fastForEach(Object.entries(currentCache.childCaches), ([key, value]) => { + if (testBeforeCull === 'test-before-cull' && existingUIDs.has(key)) { + traverseAndCull(value, 'test-before-cull') + } else { + pushRemovedValues(value) + traverseAndCull(value, 'cull-all') + delete currentCache.childCaches[key] + } + }) + + fastForEach(Object.entries(currentCache.rootElementCaches), ([key, value]) => { + if (testBeforeCull === 'test-before-cull' && existingUIDs.has(key)) { + traverseAndCull(value, 'test-before-cull') + } else { + pushRemovedValues(value) + traverseAndCull(value, 'cull-all') + delete currentCache.rootElementCaches[key] + } + }) + } + + traverseAndCull(globalElementPathCache, 'test-before-cull') + + return removedPaths +} + +export function removePathsWithDeadUIDs(existingUIDs: Set): void { + const removedPaths = removePathsFromElementPathCacheWithDeadUIDs(existingUIDs) + + fastForEach(Object.keys(globalPathStringToPathCache), (stringifiedPath) => { + const pathUIDs = stringifiedPath.split(/[:/]/) + if (pathUIDs.some((uid) => !existingUIDs.has(uid))) { + delete globalPathStringToPathCache[stringifiedPath] + } + }) + + dynamicToStaticPathCache.forEach((value, key) => { + if (removedPaths.has(value)) { + dynamicToStaticPathCache.delete(key) + } + }) + + dynamicElementPathToStaticElementPathCache.forEach((value, key) => { + if (value.some((uid) => !existingUIDs.has(uid))) { + dynamicElementPathToStaticElementPathCache.delete(key) + } + }) +} + +function getElementPathCache(fullElementPath: ElementPathPart[]): ElementPathCache { + let workingPathCache: ElementPathCache = globalElementPathCache + + function shiftWorkingCacheRoot(pathPart: string) { + const innerCache = workingPathCache.rootElementCaches + const pathPartCache = innerCache[pathPart] + if (pathPartCache == null) { + const newCache = emptyElementPathCache() + innerCache[pathPart] = newCache + workingPathCache = newCache + } else { + workingPathCache = pathPartCache + } + } + + function shiftWorkingCacheChild(pathPart: string) { + const innerCache = workingPathCache.childCaches + const pathPartCache = innerCache[pathPart] + if (pathPartCache == null) { + const newCache = emptyElementPathCache() + innerCache[pathPart] = newCache + workingPathCache = newCache + } else { + workingPathCache = pathPartCache + } + } + + for (const elementPathPart of fullElementPath) { + if (elementPathPart.length === 0) { + // Special cased handling for when the path part is empty + shiftWorkingCacheRoot('empty-path') + } else { + let first: boolean = true + for (const pathPart of elementPathPart) { + if (first) { + shiftWorkingCacheRoot(pathPart) + } else { + shiftWorkingCacheChild(pathPart) + } + first = false + } + } + } + + return workingPathCache +} + +export const SceneSeparator = ':' +export const ElementSeparator = '/' + +function getComponentPathStringForPathString(path: string): string | null { + const indexOfLastSceneSeparator = path.lastIndexOf(SceneSeparator) + const indexOfLastElementSeparator = path.lastIndexOf(ElementSeparator) + if (indexOfLastSceneSeparator > indexOfLastElementSeparator) { + return path.slice(0, indexOfLastSceneSeparator) + } else { + return null + } +} + +export function getAllElementPathStringsForPathString(path: string): Array { + let allElementPathStrings: Array = [] + let workingPath: string | null = path + + while (workingPath != null) { + allElementPathStrings.push(workingPath) + workingPath = getComponentPathStringForPathString(workingPath) + } + + return allElementPathStrings +} + +export function elementPathPartToString(path: ElementPathPart): string { + let result: string = '' + const elementsLength = path.length + fastForEach(path, (elem, index) => { + result += elem + if (index < elementsLength - 1) { + result += ElementSeparator + } + }) + return result +} + +export function toString(target: ElementPath): string { + const cachedToString = pathToStringCache.get(target) + if (cachedToString == null) { + const stringifiedPath = target.parts.map(elementPathPartToString).join(SceneSeparator) + pathToStringCache.set(target, stringifiedPath) + return stringifiedPath + } else { + return cachedToString + } +} + +export const emptyElementPathPart: StaticElementPathPart = staticElementPath([]) +export const emptyElementPath: StaticElementPath = newElementPath([]) + +export function staticElementPath(elements: string[]): StaticElementPathPart { + return elements as StaticElementPathPart +} + +function isEmptyElementPathsArray(elementPathParts: ElementPathPart[]): boolean { + return ( + elementPathParts.length === 0 || + (elementPathParts.length === 1 && elementPathParts[0].length === 0) + ) +} + +export function isEmptyPath(path: ElementPath): boolean { + return isEmptyElementPathsArray(path.parts) +} + +function newElementPath(fullElementPath: StaticElementPathPart[]): StaticElementPath +function newElementPath(fullElementPath: ElementPathPart[]): ElementPath +function newElementPath(fullElementPath: ElementPathPart[]): ElementPath { + return { + type: 'elementpath', + // This makes `newElementPath` defensive against potentially mutated arrays + // being passed in, for this small cost that should only be incurred once for + // each path. + parts: [...fullElementPath.map((pathPart) => [...pathPart])], + } +} + +export function elementPath(fullElementPath: StaticElementPathPart[]): StaticElementPath +export function elementPath(fullElementPath: ElementPathPart[]): ElementPath +export function elementPath(fullElementPath: ElementPathPart[]): ElementPath { + if (isEmptyElementPathsArray(fullElementPath)) { + return emptyElementPath + } + + const pathCache = getElementPathCache(fullElementPath) + if (pathCache.cached == null) { + pathCache.cached = newElementPath(fullElementPath) + } + + return pathCache.cached +} + +export function asStatic(path: ElementPath): StaticElementPath { + return path as StaticElementPath +} + +export function isElementPath(path: unknown): path is ElementPath { + return (path as any)?.type === 'elementpath' +} + +export function isRootElementOfInstance(path: ElementPath): boolean { + return path.parts.length > 1 && last(path.parts)!.length === 1 +} + +export function isStoryboardPath(path: ElementPath): boolean { + return path.parts.length === 1 && path.parts[0].length === 1 +} + +export function isStoryboardDescendant(path: ElementPath): boolean { + return path.parts.length === 1 && path.parts[0].length > 1 +} + +export function isStoryboardChild(path: ElementPath): boolean { + return path.parts.length === 1 && path.parts[0].length === 2 +} + +export function lastElementPathForPath(path: StaticElementPath): StaticElementPathPart | null +export function lastElementPathForPath(path: ElementPath): ElementPathPart | null +export function lastElementPathForPath(path: ElementPath): ElementPathPart | null { + return last(path.parts) ?? null +} + +function fromStringUncached(path: string): ElementPath { + const elementPathPartStrings = path.split(SceneSeparator) + const fullElementPath = elementPathPartStrings.map((pathString) => + pathString.split(ElementSeparator).filter((str) => str.trim().length > 0), + ) + return elementPath(fullElementPath) +} + +export function fromString(path: string): ElementPath { + let fromPathStringCache = globalPathStringToPathCache[path] + if (fromPathStringCache == null) { + const result = fromStringUncached(path) + globalPathStringToPathCache[path] = result + return result + } else { + return fromPathStringCache + } +} + +export function fromStringStatic(pathString: string): StaticElementPath { + const path = fromString(pathString) + return dynamicPathToStaticPath(path) +} + +function allElementPathsForPart(path: ElementPathPart): Array { + let paths: Array = [] + for (var size = 1; size <= path.length; size++) { + paths.push(path.slice(0, size)) + } + return paths +} + +/** + * @deprecated use EP.allPathsInsideComponent instead! Reason: this includes the ElementPath of the containing components instance, which is probably not what you want + */ +export function allPathsForLastPart(path: ElementPath | null): Array { + if (path == null || isEmptyPath(path)) { + return [] + } else { + const prefix = dropLast(path.parts) + const lastPart = last(path.parts)! + const toElementPath = (elementPathPart: ElementPathPart) => + elementPath([...prefix, elementPathPart]) + return [elementPath(prefix), ...allElementPathsForPart(lastPart).map(toElementPath)] + } +} + +export function allPathsInsideComponent(path: ElementPath | null): Array { + if (path == null || isEmptyPath(path)) { + return [] + } else { + const prefix = dropLast(path.parts) + const lastPart = last(path.parts)! + const toElementPath = (elementPathPart: ElementPathPart) => + elementPath([...prefix, elementPathPart]) + return allElementPathsForPart(lastPart).map(toElementPath).reverse() + } +} + +export function depth(path: ElementPath): number { + return 1 + path.parts.length +} + +export function fullDepth(path: ElementPath): number { + return path.parts.reduce((working, part) => working + part.length, 0) +} + +function fullElementPathParent(path: StaticElementPathPart[]): StaticElementPathPart[] +function fullElementPathParent(path: ElementPathPart[]): ElementPathPart[] +function fullElementPathParent(path: ElementPathPart[]): ElementPathPart[] { + const prefix = dropLast(path) + const lastPart = last(path) + if (lastPart != null && lastPart.length > 1) { + return [...prefix, elementPathPartParent(lastPart)] + } else { + return prefix + } +} + +export function elementPathPartParent(path: StaticElementPathPart): StaticElementPathPart +export function elementPathPartParent(path: ElementPathPart): ElementPathPart +export function elementPathPartParent(path: ElementPathPart): ElementPathPart { + return path.slice(0, path.length - 1) +} + +let parentPathCache: Map = new Map() + +export function parentPath(path: StaticElementPath): StaticElementPath +export function parentPath(path: ElementPath): ElementPath +export function parentPath(path: ElementPath): ElementPath { + const existing = parentPathCache.get(path) + if (existing == null) { + const parentFullElementPath = fullElementPathParent(path.parts) + const result = elementPath(parentFullElementPath) + parentPathCache.set(path, result) + return result + } else { + return existing + } +} + +export function nthParentPath(path: StaticElementPath, n: number): StaticElementPath +export function nthParentPath(path: ElementPath, n: number): ElementPath +export function nthParentPath(path: ElementPath, n: number): ElementPath { + let working = path + for (let i = 0; i < n; i++) { + working = parentPath(working) + } + return working +} + +export function isParentComponentOf(maybeParent: ElementPath, maybeChild: ElementPath): boolean { + return pathsEqual(maybeParent, dropLastPathPart(maybeChild)) +} + +export function isParentOf(maybeParent: ElementPath, maybeChild: ElementPath): boolean { + const childLength = maybeChild.parts.length + const parentLength = maybeParent.parts.length + if (childLength === parentLength + 1) { + const lastChildPart = last(maybeChild.parts) + if (lastChildPart != null && lastChildPart.length === 1) { + for (let index = 0; index < parentLength; index++) { + const childParts = maybeChild.parts[index] + const parentParts = maybeParent.parts[index] + if (!elementPathPartsEqual(childParts, parentParts)) { + return false + } + } + + // Otherwise this is a parent. + return true + } else { + return false + } + } else if (childLength === parentLength) { + const lastChildPart = last(maybeChild.parts) + const lastParentPart = last(maybeParent.parts) + if ( + lastChildPart != null && + lastParentPart != null && + lastChildPart.length === lastParentPart.length + 1 + ) { + // Check the main bulk of the parts to ensure those are the same. + for (let index = 0; index < parentLength - 1; index++) { + const childParts = maybeChild.parts[index] + const parentParts = maybeParent.parts[index] + if (!elementPathPartsEqual(childParts, parentParts)) { + return false + } + } + + // Check the last array part. + for (let index = 0; index < lastChildPart.length - 1; index++) { + if (lastChildPart[index] !== lastParentPart[index]) { + return false + } + } + + // Otherwise this is a parent. + return true + } else { + return false + } + } else { + return false + } +} + +export function elementPathPartToUID(path: ElementPathPart): id { + return forceNotNull('Attempting to get the UID of an empty ElementPath', last(path)) +} + +function lastElementPathPart(path: ElementPath): ElementPathPart { + return last(path.parts) ?? emptyElementPathPart +} + +export function toUid(path: ElementPath): id { + const elementPathPartToUse = lastElementPathPart(path) + return elementPathPartToUID(elementPathPartToUse) +} + +export function toStaticUid(path: ElementPath): id { + return extractOriginalUidFromIndexedUid(toUid(path)) +} + +export function appendArrayToElementPath( + path: StaticElementPathPart, + next: Array, +): StaticElementPathPart +export function appendArrayToElementPath(path: ElementPathPart, next: Array): ElementPathPart +export function appendArrayToElementPath(path: ElementPathPart, next: Array): ElementPathPart { + return [...path, ...next] +} + +export function appendToElementPath(path: StaticElementPathPart, next: id): StaticElementPathPart +export function appendToElementPath(path: ElementPathPart, next: id): ElementPathPart +export function appendToElementPath(path: ElementPathPart, next: id): ElementPathPart { + return [...path, next] +} + +function appendToElementPathArray(pathArray: ElementPathPart[], next: id): ElementPathPart[] { + if (isEmptyElementPathsArray(pathArray)) { + return [[next]] + } else { + let result: Array = [] + const lengthMinusOne = pathArray.length - 1 + for (let index = 0; index < lengthMinusOne; index++) { + result.push(pathArray[index]) + } + result.push(appendToElementPath(pathArray[lengthMinusOne], next)) + return result + } +} + +function appendPartToElementPathArray( + pathArray: ElementPathPart[], + next: ElementPathPart, +): ElementPathPart[] { + if (isEmptyElementPathsArray(pathArray)) { + return [next] + } else { + let result: Array = [] + const lengthMinusOne = pathArray.length - 1 + for (let index = 0; index < lengthMinusOne; index++) { + result.push(pathArray[index]) + } + result.push(appendArrayToElementPath(pathArray[lengthMinusOne], next)) + return result + } +} + +export function appendNewElementPath( + path: ElementPath, + next: StaticElementPathPart, +): StaticElementPath +export function appendNewElementPath(path: ElementPath, next: id | ElementPathPart): ElementPath +export function appendNewElementPath(path: ElementPath, next: id | ElementPathPart): ElementPath { + const toAppend = Array.isArray(next) ? next : [next] + return elementPath([...path.parts, toAppend]) +} + +export function appendToPath(path: StaticElementPath, next: id): StaticElementPath +export function appendToPath(path: ElementPath, next: id): ElementPath +export function appendToPath(path: ElementPath, next: id): ElementPath { + const updatedPathParts = appendToElementPathArray(path.parts, next) + return elementPath(updatedPathParts) +} + +export function appendPartToPath( + path: StaticElementPath, + next: StaticElementPathPart, +): StaticElementPath +export function appendPartToPath(path: ElementPath, next: ElementPathPart): ElementPath +export function appendPartToPath(path: ElementPath, next: ElementPathPart): ElementPath { + const updatedPathParts = appendPartToElementPathArray(path.parts, next) + return elementPath(updatedPathParts) +} + +function elementPathPartsEqual(l: ElementPathPart, r: ElementPathPart): boolean { + return arrayEqualsByReference(l, r) +} + +function stringifiedPathsEqual(l: ElementPath, r: ElementPath): boolean { + return toString(l) === toString(r) +} + +export function pathsEqual(l: ElementPath | null, r: ElementPath | null): boolean { + if (l == null) { + return r == null + } else if (r == null) { + return false + } else if (l === r) { + return true + } else { + return stringifiedPathsEqual(l, r) + } +} + +export function arrayOfPathsEqual(l: Array, r: Array): boolean { + return arrayEqualsByValue(l, r, pathsEqual) +} + +export function containsPath(path: ElementPath, paths: Array): boolean { + for (const toCheck of paths) { + if (pathsEqual(toCheck, path)) { + return true + } + } + return false +} + +export function filterPaths(paths: ElementPath[], pathsToFilter: ElementPath[]): ElementPath[] { + return paths.filter((path) => !containsPath(path, pathsToFilter)) +} + +export function addPathIfMissing(path: ElementPath, paths: Array): Array { + if (containsPath(path, paths)) { + return paths + } else { + return paths.concat(path) + } +} + +export function addPathsIfMissing( + existingPaths: Array, + pathsToAdd: Array, +): Array { + if (pathsToAdd.length === 0) { + return existingPaths + } else if (existingPaths.length === 0) { + return pathsToAdd + } else { + return existingPaths.concat(filterPaths(pathsToAdd, existingPaths)) + } +} + +export function isChildOf(path: ElementPath | null, parent: ElementPath | null): boolean { + if (path == null || parent == null) { + return false + } else { + return isParentOf(parent, path) + } +} + +export function isRootElementOf(path: ElementPath | null, parent: ElementPath | null): boolean { + return path != null && isChildOf(path, parent) && isRootElementOfInstance(path) +} + +export function isSiblingOf(l: ElementPath | null, r: ElementPath | null): boolean { + return l != null && r != null && pathsEqual(parentPath(l), parentPath(r)) +} + +export function areSiblings(paths: Array): boolean { + return paths.every((p) => isSiblingOf(paths[0], p)) +} + +export function isDescendantOfOrEqualTo( + target: ElementPath, + maybeAncestorOrEqual: ElementPath, +): boolean { + return pathsEqual(target, maybeAncestorOrEqual) || isDescendantOf(target, maybeAncestorOrEqual) +} + +export function findAmongAncestorsOfPath( + path: ElementPath, + fn: (ancestor: ElementPath) => T | null, +): T | null { + const ancestors = getAncestors(path) + for (const ancestor of ancestors) { + const maybeFound = fn(ancestor) + if (maybeFound != null) { + return maybeFound + } + } + return null +} + +export function isDescendantOf(target: ElementPath, maybeAncestor: ElementPath): boolean { + // If the target has less parts than the potential ancestor, it's by default + // higher up the hierarchy. + if (target.parts.length < maybeAncestor.parts.length) { + return false + } + // Check if any of the shared (by index) parts are different, if any are that + // means these two are on different branches of the hierarchy. + for (let index = 0; index < maybeAncestor.parts.length - 1; index++) { + const maybeAncestorPart = maybeAncestor.parts[index] + const targetPart = target.parts[index] + if (!elementPathPartsEqual(maybeAncestorPart, targetPart)) { + return false + } + } + // As the rest of the parts are the same, we can check the last part to see + // if the target has less parts as that puts it higher up the hierarchy than the + // potential ancestor. + const lastTargetPart = target.parts[maybeAncestor.parts.length - 1] + const lastAncestorPart = maybeAncestor.parts[maybeAncestor.parts.length - 1] + if (lastTargetPart.length < lastAncestorPart.length) { + return false + } + // Check the elements of the last part to see if there are any differences + // as that means they're on a different branch. Limiting it to the length of + // the potential ancestor. + for (let partIndex = 0; partIndex < lastAncestorPart.length; partIndex++) { + const part = lastAncestorPart[partIndex] + if (lastTargetPart[partIndex] !== part) { + return false + } + } + // If the lengths of the parts and the lengths of the last parts are the same, + // since so far we've determined those to all match, then these are identical paths. + if ( + target.parts.length === maybeAncestor.parts.length && + lastTargetPart.length === lastAncestorPart.length + ) { + return false + } + + // Fallback meaning this is a descendant. + return true +} + +export function getAncestorsForLastPart(path: ElementPath): ElementPath[] { + return allPathsForLastPart(path).slice(0, -1) +} + +export function getAncestors(path: ElementPath): ElementPath[] { + let workingPath = parentPath(path) + let ancestors = [workingPath] + + while (!isEmptyPath(workingPath)) { + workingPath = parentPath(workingPath) + ancestors.push(workingPath) + } + + return ancestors +} + +function dropFromElementPaths(elementPathParts: ElementPathPart[], n: number): ElementPathPart[] { + const prefix = dropLast(elementPathParts) + const lastPart = last(elementPathParts) + if (lastPart == null) { + return [] + } else { + const remaining = lastPart.length - n + if (remaining > 0) { + return dropFromElementPaths(prefix, n) + } else { + return [...prefix, dropLastN(n, lastPart)] + } + } +} + +function dropFromPath(path: ElementPath, n: number): ElementPath { + const updatedPathParts = dropFromElementPaths(path.parts, n) + return updatedPathParts.length > 0 ? elementPath(updatedPathParts) : emptyElementPath +} + +export const getNthParent = dropFromPath + +export function dropNPathParts(path: ElementPath, n: number): ElementPath { + if (n <= 0) { + return path + } + return dropNPathParts(parentPath(path), n - 1) +} + +export function replaceIfAncestor( + path: ElementPath, + replaceSearch: ElementPath, + replaceWith: ElementPath | null, +): ElementPath | null { + // A/B/C -> A/B -> G/H -> G/H/C + if (isDescendantOf(path, replaceSearch) || pathsEqual(path, replaceSearch)) { + const oldParts = path.parts + const suffix = drop(replaceSearch.parts.length, oldParts) + const lastReplaceSearchPartLength = last(replaceSearch.parts)?.length ?? 0 + const overlappingPart = oldParts[replaceSearch.parts.length - 1] + const dropEntireLastPart = overlappingPart.length === lastReplaceSearchPartLength + const trimmedOverlappingPart = + overlappingPart == null || dropEntireLastPart + ? null + : drop(lastReplaceSearchPartLength, overlappingPart) + + let updatedPathParts: ElementPathPart[] = [] + if (trimmedOverlappingPart == null) { + if (replaceWith != null) { + updatedPathParts.push(...replaceWith.parts) + } + } else if (replaceWith == null) { + updatedPathParts.push(trimmedOverlappingPart) + } else { + updatedPathParts.push( + ...appendPartToElementPathArray(replaceWith.parts, trimmedOverlappingPart), + ) + } + + updatedPathParts.push(...suffix) + return elementPath(updatedPathParts) + } else { + return null + } +} + +export function replaceOrDefault( + path: ElementPath, + replaceSearch: ElementPath, + replaceWith: ElementPath | null, +): ElementPath { + return replaceIfAncestor(path, replaceSearch, replaceWith) ?? path +} + +// TODO: remove null +export function closestSharedAncestor( + l: ElementPath | null, + r: ElementPath | null, + includePathsEqual: boolean, +): ElementPath | null { + const toTargetPath: (p: ElementPath) => ElementPath | null = includePathsEqual + ? identity + : parentPath + + const lTarget = l == null ? null : toTargetPath(l) + const rTarget = r == null ? null : toTargetPath(r) + + if (l === null || r === null || lTarget == null || rTarget == null) { + return null + } else if (l === r) { + return toTargetPath(l) + } else { + const fullyMatchedElementPathParts = longestCommonArray( + lTarget.parts, + rTarget.parts, + elementPathPartsEqual, + ) + const nextLPart = lTarget.parts[fullyMatchedElementPathParts.length] + const nextRPart = rTarget.parts[fullyMatchedElementPathParts.length] + const nextMatchedElementPath = longestCommonArray(nextLPart ?? [], nextRPart ?? []) + const totalMatchedParts = + nextMatchedElementPath.length > 0 + ? [...fullyMatchedElementPathParts, nextMatchedElementPath] + : fullyMatchedElementPathParts + + return totalMatchedParts.length > 0 ? elementPath(totalMatchedParts) : null + } +} + +export function getCommonParent( + paths: Array, + includeSelf: boolean = false, +): ElementPath | null { + if (paths.length === 0) { + return null + } else { + const parents = includeSelf ? paths : paths.map(parentPath) + return parents.reduce( + (l, r) => closestSharedAncestor(l, r, true), + parents[0], + ) + } +} + +export function getCommonParentOfNonemptyPathArray( + paths: NonEmptyArray, + includeSelf: boolean = false, +): ElementPath { + const parents = includeSelf ? paths : paths.map(parentPath) + return parents.reduce( + (l, r) => closestSharedAncestor(l, r, true) ?? emptyElementPath, + parents[0], + ) +} + +export interface ElementsTransformResult { + elements: Array + transformedElement: T | null +} + +export function findAndTransformAtPath( + elements: Array, + path: ElementPathPart, + getChildren: (t: T) => Array | null, + getElementID: (element: T) => string, + transform: (t: T) => T, +): ElementsTransformResult { + let transformedElement: T | null = null + + function findAndTransformAtPathInner(element: T, workingPath: string[]): T { + if (transformedElement != null) { + return element + } + + const [firstUIDOrIndex, ...tailPath] = workingPath + if (getElementID(element) === firstUIDOrIndex) { + // transform + if (tailPath.length === 0) { + // this is the element we want to transform + transformedElement = transform(element) + return transformedElement + } else { + // we will want to transform one of our children + let childrenChanged: boolean = false + let transformedChildren: Array = [] + const children = getChildren(element) + if (children != null) { + fastForEach(children, (child) => { + const transformedChild = findAndTransformAtPathInner(child, tailPath) + if (transformedChild == child) { + transformedChildren.push(child) + } else { + childrenChanged = true + transformedChildren.push(transformedChild) + } + }) + } + if (childrenChanged && children != null) { + return { + ...element, + children: transformedChildren, + } + } else { + // we had a NO_OP transform result, let's keep reference equality + return element + } + } + } else { + // this is not the branch we are looking for + return element + } + } + + const transformedElements = elements.map((element) => { + if (transformedElement == null) { + return findAndTransformAtPathInner(element, path) + } else { + return element + } + }) + + return { + elements: transformedElements, + transformedElement: transformedElement, + } +} + +export function transformAtPath( + elements: Array, + path: ElementPathPart, + getChildren: (t: T) => Array | null, + getElementID: (element: T) => string, + transform: (t: T) => T, +): Array { + const transformResult = findAndTransformAtPath( + elements, + path, + getChildren, + getElementID, + transform, + ) + if (transformResult.transformedElement == null) { + throw new Error(`Did not find element to transform ${elementPathPartToString(path)}`) + } else { + return transformResult.elements + } +} + +export function findAtElementPath( + elements: Array, + path: ElementPathPart, + getChildren: (t: T) => Array | null, + getElementID: (element: T) => string, +): T | null { + let searchFailed: boolean = false + let foundElement: T | null = null + const shouldStop = () => searchFailed || foundElement != null + + function findAtPathInner(element: T, workingPath: string[]) { + if (shouldStop()) { + return + } + + const firstUIDOrIndex = workingPath[0] + if (getElementID(element) === firstUIDOrIndex) { + // we've found the right path + if (workingPath.length === 1) { + foundElement = element + return + } else { + const children = getChildren(element) + if (children != null) { + const tailPath = workingPath.slice(1) + fastForEach(children, (child) => { + if (shouldStop()) { + return + } + + findAtPathInner(child, tailPath) + }) + } + + if (foundElement == null) { + searchFailed = true + } + } + } else { + // this is not the branch we are looking for + return + } + } + + fastForEach(elements, (element) => findAtPathInner(element, path)) + + return foundElement +} + +function dropLastPathPart(path: ElementPath): ElementPath { + return elementPath(dropLast(path.parts)) +} + +export function areAllElementsInSameInstance(paths: ElementPath[]): boolean { + if (paths.length === 0) { + return true + } else { + const firstPathWithoutLastPart = dropLastPathPart(paths[0]) + return paths.every((p) => pathsEqual(firstPathWithoutLastPart, dropLastPathPart(p))) + } +} + +export function isFromSameInstanceAs(a: ElementPath, b: ElementPath): boolean { + return pathsEqual(dropLastPathPart(a), dropLastPathPart(b)) +} + +function dynamicElementPathToStaticElementPath(element: ElementPathPart): StaticElementPathPart { + const existing = dynamicElementPathToStaticElementPathCache.get(element) + if (existing == null) { + const result = element.map(extractOriginalUidFromIndexedUid) as StaticElementPathPart + dynamicElementPathToStaticElementPathCache.set(element, result) + return result + } else { + return existing + } +} + +export function dynamicPathToStaticPath(path: ElementPath): StaticElementPath { + const existing = dynamicToStaticPathCache.get(path) + if (existing == null) { + const result = elementPath(path.parts.map(dynamicElementPathToStaticElementPath)) + dynamicToStaticPathCache.set(path, result) + return result + } else { + return existing + } +} + +export function isDynamicPath(path: ElementPath): boolean { + return !pathsEqual(path, dynamicPathToStaticPath(path)) +} + +export function makeLastPartOfPathStatic(path: ElementPath): ElementPath { + const existing = dynamicToStaticLastElementPathPartCache.get(path) + if (existing == null) { + const dynamicLastPart = last(path.parts) + let result: ElementPath + if (dynamicLastPart == null) { + result = path + } else { + const staticLastPart = dynamicElementPathToStaticElementPath(dynamicLastPart) + result = elementPath([...dropLast(path.parts), staticLastPart]) + } + dynamicToStaticLastElementPathPartCache.set(path, result) + return result + } else { + return existing + } +} + +export function pathUpToElementPath( + fullPath: ElementPath, + elementPathPart: ElementPathPart, + convertToStatic: 'dynamic-path' | 'static-path', +): ElementPath | null { + const fullElementPath = fullPath.parts + const pathToUse = + convertToStatic === 'static-path' + ? fullElementPath.map(dynamicElementPathToStaticElementPath) + : fullElementPath + const foundIndex = pathToUse.findIndex((pathPart) => { + return elementPathPartsEqual(pathPart, elementPathPart) + }) + return foundIndex === -1 ? null : elementPath(fullElementPath.slice(0, foundIndex + 1)) +} + +export interface DropFirstPathElementResultType { + newPath: ElementPath | null + droppedPathElements: ElementPathPart | null +} + +export function dropFirstPathElement(path: ElementPath | null): DropFirstPathElementResultType { + if (path == null) { + return { + newPath: null, + droppedPathElements: null, + } + } else { + const fullElementPath = path.parts + if (fullElementPath.length > 1) { + return { + newPath: elementPath(drop(1, fullElementPath)), + droppedPathElements: fullElementPath[0], + } + } else { + return { + newPath: path, + droppedPathElements: null, + } + } + } +} + +export function takeLastPartOfPath(path: ElementPath): ElementPath { + if (path?.parts.length > 1) { + const lastPart = last(path.parts)! + return elementPath([lastPart]) + } + + return path +} + +export function createBackwardsCompatibleScenePath(path: ElementPath): ElementPath { + const firstPart = path.parts[0] ?? emptyElementPathPart + return elementPath([firstPart.slice(0, 2)]) // we only return the FIRST TWO elements (storyboard, scene), this is a horrible, horrible hack +} + +export function isFocused(focusedElementPath: ElementPath | null, path: ElementPath): boolean { + const lastPart = lastElementPathForPath(path) + if (focusedElementPath == null || lastPart == null) { + return false + } else { + // FIXME this is incorrect, as it will be marking other instances as focused + return pathUpToElementPath(focusedElementPath, lastPart, 'dynamic-path') != null + } +} + +export function isExplicitlyFocused( + focusedElementPath: ElementPath | null, + autoFocusedPaths: Array, + path: ElementPath, +): boolean { + if (pathsEqual(focusedElementPath, path)) { + return true + } + + const isNotAutoFocused = + path.parts.length > 1 || + !autoFocusedPaths.some((autoFocusedPath) => isDescendantOfOrEqualTo(path, autoFocusedPath)) + return isNotAutoFocused && isFocused(focusedElementPath, path) +} + +export function isInExplicitlyFocusedSubtree( + focusedElementPath: ElementPath | null, + autoFocusedPaths: Array, + path: ElementPath, +): boolean { + // we exclude children of a potentially focused component as children are not part of the focus system + const componentToCheck = dropLastPathPart(path) + // the focusedElementPath can contain multiple focused components along its path parts + // if the path to check is a descendant of any of the focused components, it is considered focused + let focusedAncestor = focusedElementPath + while ( + focusedAncestor != null && + !isEmptyPath(focusedAncestor) && + !isStoryboardDescendant(focusedAncestor) && + // since there is a single focusedElementPath by default we considered all ancestors as focused + // but if an ancestor is also autofocused, we should not consider it as explicitly focused + !autoFocusedPaths.some((autoFocusedPath) => pathsEqual(autoFocusedPath, focusedAncestor)) + ) { + if (isDescendantOfOrEqualTo(componentToCheck, focusedAncestor)) { + return true + } + focusedAncestor = getContainingComponent(focusedAncestor) + } + return false +} + +export function getOrderedPathsByDepth(elementPaths: Array): Array { + return elementPaths.slice().sort((a, b) => { + if (depth(b) === depth(a)) { + const aInnerDepth = last(a.parts)?.length ?? 0 + const bInnerDepth = last(b.parts)?.length ?? 0 + return bInnerDepth - aInnerDepth + } + + return depth(b) - depth(a) + }) +} + +export function emptyStaticElementPathPart(): StaticElementPathPart { + return [] as unknown as StaticElementPathPart +} + +export function getContainingComponent(path: ElementPath): ElementPath { + return dropLastPathPart(path) +} + +export function getPathOfComponentRoot(path: ElementPath): ElementPath { + if (isRootElementOfInstance(path)) { + return path + } + const lastPart = last(path.parts) + invariant( + lastPart != null, + 'internal error: Last part cannot be null because of the assertion isRootElementOfInstance', + ) + return elementPath([...dropLast(path.parts), [lastPart[0]]]) +} + +export function uniqueElementPaths(paths: Array): Array { + const pathSet = new Set(paths.map(toString)) + return Array.from(pathSet).map(fromString) +} + +export const GeneratedUIDSeparator = `~~~` +export function createIndexedUid(originalUid: string, index: string | number): string { + return `${originalUid}${GeneratedUIDSeparator}${index}` +} + +export function isIndexedUid(uid: string): boolean { + return uid.includes(GeneratedUIDSeparator) +} + +export function extractOriginalUidFromIndexedUid(uid: string): string { + const separatorIndex = uid.indexOf(GeneratedUIDSeparator) + if (separatorIndex >= 0) { + return uid.substr(0, separatorIndex) + } else { + return uid + } +} + +export function extractIndexFromIndexedUid(originaUid: string): string | null { + const separatorIndex = originaUid.indexOf(GeneratedUIDSeparator) + if (separatorIndex >= 0) { + return originaUid.slice(separatorIndex + GeneratedUIDSeparator.length) + } else { + return null + } +} + +export function getStoryboardPathFromPath(path: ElementPath): ElementPath | null { + if (isEmptyPath(path)) { + return null + } + return fromString(path.parts[0][0]) +} + +export function appendTwoPaths(base: ElementPath, other: ElementPath): ElementPath { + return elementPath([...base.parts, ...other.parts]) +} + +export function multiplePathsAllWithTheSameUID(paths: Array): boolean { + if (paths.length <= 1) { + return false + } + + const firstUid = toUid(paths[0]) + if (paths.every((v) => toUid(v) === firstUid)) { + return true + } + return false +} + +export function isIndexedElement(path: ElementPath): boolean { + return isIndexedUid(toUid(path)) +} + +export function getFirstPath(pathArray: Array): ElementPath { + return forceNotNull('Element path array is empty.', pathArray.at(0)) +} + +export function humanReadableDebugPath(path: ElementPath): string { + const stringifiedPath = path.parts + .map((part, index) => + elementPathPartToHumanReadableDebugString(part, index === path.parts.length - 1), + ) + .join(SceneSeparator) + return stringifiedPath +} + +function elementPathPartToHumanReadableDebugString( + path: ElementPathPart, + lastPart: boolean, +): string { + let result: string = '' + const elementsLength = path.length + fastForEach(path, (elem, index) => { + const lastElem = index === elementsLength - 1 + if (elem.length < 30 || (lastPart && lastElem)) { + result += elem + } else { + result += elem.slice(0, 4) + result += optionalMap((s) => `~~~${s}`, extractIndexFromIndexedUid(elem)) ?? '' + } + if (index < elementsLength - 1) { + result += ElementSeparator + } + }) + return result +} diff --git a/nexus-builder/packages/core/src/templates/editor-navigator.ts b/nexus-builder/packages/core/src/templates/editor-navigator.ts new file mode 100644 index 000000000000..108e8278ef1f --- /dev/null +++ b/nexus-builder/packages/core/src/templates/editor-navigator.ts @@ -0,0 +1,50 @@ +import type { ElementPath } from '../core/shared/project-file-types' +import * as EP from '../core/shared/element-path' +import type { + DerivedState, + EditorState, + NavigatorEntry, +} from '../components/editor/store/editor-state' +import type { LocalNavigatorAction } from '../components/navigator/actions' +import { NavigatorStateKeepDeepEquality } from '../components/editor/store/store-deep-equality-instances' + +export function getSelectedNavigatorEntries( + selectedViews: Array, + navigatorEntries: Array, +): Array { + return navigatorEntries.filter((v) => + selectedViews.some((selectedView) => EP.pathsEqual(selectedView, v.elementPath)), + ) +} + +export const runLocalNavigatorAction = function ( + model: EditorState, + derivedState: DerivedState, + action: LocalNavigatorAction, +): EditorState { + switch (action.action) { + case 'SHOW_DROP_TARGET_HINT': + return { + ...model, + navigator: NavigatorStateKeepDeepEquality(model.navigator, { + ...model.navigator, + dropTargetHint: { + type: action.type, + displayAtEntry: action.displayAtEntry, + targetParent: action.targetParentEntry, + targetIndexPosition: action.indexInTargetParent, + }, + }).value, + } + case 'HIDE_DROP_TARGET_HINT': + return { + ...model, + navigator: NavigatorStateKeepDeepEquality(model.navigator, { + ...model.navigator, + dropTargetHint: null, + }).value, + } + default: + return model + } +} diff --git a/nexus-builder/packages/core/src/utils/react-performance.ts b/nexus-builder/packages/core/src/utils/react-performance.ts new file mode 100644 index 000000000000..ab596f337b5b --- /dev/null +++ b/nexus-builder/packages/core/src/utils/react-performance.ts @@ -0,0 +1,478 @@ +import React from 'react' +import fastDeepEqual from 'fast-deep-equal' +import { PRODUCTION_ENV } from '../common/env-vars' +import type { KeepDeepEqualityCall, KeepDeepEqualityResult } from './deep-equality' +import { keepDeepEqualityResult } from './deep-equality' +import { shallowEqual } from '../core/shared/equality-utils' + +export function useHookUpdateAnalysisStrictEquals

(name: string, newValue: P) { + const previousValue = React.useRef(newValue) + if (previousValue.current !== newValue) { + console.warn( + `Previous value for ${name} -- ${JSON.stringify( + previousValue.current, + )} is not the same as next props ${JSON.stringify(newValue)}.`, + ) + } + previousValue.current = newValue +} + +export function useHookUpdateAnalysis

(newValue: P) { + const previousValue = React.useRef(newValue) + shouldComponentUpdateAnalysis(previousValue.current, newValue, {}, {}) + previousValue.current = newValue + return newValue +} + +export function shouldComponentUpdateAnalysis( + previousProps: P, + nextProps: P, + previousState: S, + nextState: S, +): boolean { + if (typeof previousProps === 'object') { + if (previousProps === null) { + if (nextProps !== null) { + console.warn('Previous props was null, but next props is not.') + return true + } + } else { + if (nextProps === null) { + console.warn('Previous props was not null, but next props is.') + return true + } else { + let differingPropKeys: Array = [] + for (const propKey of Object.keys(previousProps)) { + const previousPropValue = (previousProps as any)[propKey] + const nextPropValue = (nextProps as any)[propKey] + if (previousPropValue !== nextPropValue) { + differingPropKeys.push(propKey) + } + } + if (differingPropKeys.length > 0) { + console.warn(`Prop keys ${JSON.stringify(differingPropKeys)} have changed.`) + return true + } + } + } + } else { + // Not an object just check referential equality and roll the dice. + if (previousProps !== nextProps) { + console.warn( + `Previous props ${JSON.stringify( + previousProps, + )} is not the same as next props ${JSON.stringify(nextProps)}.`, + ) + return true + } + } + + if (typeof previousState === 'object') { + if (previousState === null) { + if (nextState !== null) { + console.warn('Previous state was null, but next state is not.') + return true + } + } else { + if (nextState === null) { + console.warn('Previous state was not null, but next state is.') + return true + } else { + let differingStateKeys: Array = [] + for (const stateKey of Object.keys(previousState)) { + const previousStateValue = (previousState as any)[stateKey] + const nextStateValue = (nextState as any)[stateKey] + if (previousStateValue !== nextStateValue) { + differingStateKeys.push(stateKey) + } + } + if (differingStateKeys.length > 0) { + console.warn(`State keys ${JSON.stringify(differingStateKeys)} have changed.`) + return true + } + } + } + } else { + // Not an object just check referential equality and roll the dice. + if (previousState !== nextState) { + console.warn( + `Previous state ${JSON.stringify( + previousState, + )} is not the same as next state ${JSON.stringify(nextState)}.`, + ) + return true + } + } + + console.warn('Nothing has changed.') + return false +} + +export function memoEqualityCheckAnalysis

(previousProps: P, nextProps: P): boolean { + if (typeof previousProps === 'object' && previousProps != null) { + let differingPropKeys: Array = [] + for (const propKey of Object.keys(previousProps)) { + const previousPropValue = (previousProps as any)[propKey] + const nextPropValue = (nextProps as any)[propKey] + if (previousPropValue !== nextPropValue) { + differingPropKeys.push(propKey) + } + } + if (differingPropKeys.length > 0) { + console.warn(`Prop keys ${JSON.stringify(differingPropKeys)} have changed.`) + return false + } + } else { + // Not an object just check referential equality and roll the dice. + if (previousProps !== nextProps) { + console.warn( + `Previous props ${JSON.stringify( + previousProps, + )} is not the same as next props ${JSON.stringify(nextProps)}.`, + ) + return false + } + } + + return true +} + +export function failSafeReactMemo

>( + displayName: string, + severity: 'strict' | 'gentle', + Component: React.FunctionComponent>, +): React.NamedExoticComponent

+export function failSafeReactMemo>>( + displayName: string, + severity: 'strict' | 'gentle', + Component: T, +): React.MemoExoticComponent +export function failSafeReactMemo>>( + displayName: string, + severity: 'strict' | 'gentle', + Component: T, +): React.MemoExoticComponent { + const memoizedComponent = React.memo( + Component, + failSafeMemoEqualityFunction(displayName, severity), + ) + memoizedComponent.displayName = displayName + return memoizedComponent +} + +function failSafeMemoEqualityFunction(componentDisplayName: string, severity: 'strict' | 'gentle') { + return function

(previousProps: P, nextProps: P): boolean { + if (typeof previousProps === 'object' && previousProps != null) { + let differingPropKeys: Array = [] + let propKeysThatAreDifferentYetDeeplyEqual: Array = [] + for (const propKey of Object.keys(previousProps)) { + const previousPropValue = (previousProps as any)[propKey] + const nextPropValue = (nextProps as any)[propKey] + if (previousPropValue !== nextPropValue) { + if (severity === 'strict') { + if (!PRODUCTION_ENV) { + if (fastDeepEqual(previousPropValue, nextPropValue)) { + // set the editor on fire + throw new Error( + `MEMOIZATION ERROR in ${componentDisplayName}: the prop ${propKey} lost referential equality, but it has the same value`, + ) + } + } + } else { + // in gentle mode, we fall back to fastDeepEqual as our equality function, + // and print a console warning if we found a referentially unstable prop + if (fastDeepEqual(previousPropValue, nextPropValue)) { + // the two values are actually deep equal, so let's show an error message but otherwise continue the comparison + propKeysThatAreDifferentYetDeeplyEqual.push(propKey) + } else { + // the two values really are different, push them to differingPropKeys + differingPropKeys.push(propKey) + } + } + } + } + if (propKeysThatAreDifferentYetDeeplyEqual.length > 0 && !PRODUCTION_ENV) { + console.warn( + `MEMOIZATION ERROR: ${componentDisplayName} Component received props which are deep equal but referentially unstable: ${JSON.stringify( + propKeysThatAreDifferentYetDeeplyEqual, + )}`, + ) + } + if (differingPropKeys.length > 0) { + return false + } + } else { + // Not an object just check referential equality and roll the dice. + if (previousProps !== nextProps) { + return false + } + } + + return true + } +} + +// this function has been adopted from https://github.com/epoberezkin/fast-deep-equal/tree/a33d49ab5cc659e331ff445109f35dd323230d41 +function keepDeepReferenceEqualityInner( + oldValue: any, + possibleNewValue: any, + stackSizeInner: number, + valueStackSoFar: Set, +): any { + if (oldValue === possibleNewValue) return oldValue + + if (stackSizeInner > 100) { + return possibleNewValue + } + + // We appear to have looped back on ourselves, + // escape by just returning the value. + if (valueStackSoFar.has(possibleNewValue)) { + return possibleNewValue + } + // mutation + valueStackSoFar.add(possibleNewValue) + + if ( + oldValue != null && + possibleNewValue != null && + typeof oldValue == 'object' && + typeof possibleNewValue == 'object' + ) { + if (oldValue.constructor !== possibleNewValue.constructor) return possibleNewValue + + var length, i, entry, keys + if (Array.isArray(oldValue)) { + let newArrayToReturn: any[] = [] + let canSaveOldArray = true + + length = possibleNewValue.length + if (length != oldValue.length) { + canSaveOldArray = false + } + for (i = length; i-- !== 0; ) { + // try to recurse into the array item here and save it if possible + newArrayToReturn[i] = keepDeepReferenceEqualityInner( + oldValue[i], + possibleNewValue[i], + stackSizeInner + 1, + valueStackSoFar, + ) + if (oldValue[i] !== newArrayToReturn[i]) { + canSaveOldArray = false + } + } + if (canSaveOldArray) { + return oldValue + } else { + return newArrayToReturn + } + } + + if (oldValue instanceof Map && possibleNewValue instanceof Map) { + let canSaveOldMap = true + let newMapToReturn: Map = new Map() + + if (oldValue.size !== possibleNewValue.size) { + canSaveOldMap = false + } + for (entry of possibleNewValue.entries()) { + const oldMapValue = oldValue.get(entry[0]) + const newMapValue = keepDeepReferenceEqualityInner( + oldMapValue, + entry[1], + stackSizeInner + 1, + valueStackSoFar, + ) + newMapToReturn.set(entry[0], newMapValue) + if (newMapValue !== oldMapValue) { + canSaveOldMap = false + } + } + if (canSaveOldMap) { + return oldValue + } else { + return newMapToReturn + } + } + + if (oldValue instanceof Set && possibleNewValue instanceof Set) { + /** + * Sets use reference equality to determine if something is in the set or not, + * which makes salvaging sub-values very hard. We don't attempt to do that here + * */ + + if (oldValue.size !== possibleNewValue.size) { + return possibleNewValue + } + for (entry of oldValue.entries()) { + if (!possibleNewValue.has(entry[0])) { + return possibleNewValue + } + } + return oldValue + } + + if (ArrayBuffer.isView(oldValue) && ArrayBuffer.isView(possibleNewValue)) { + /** + * we don't use ArrayBufferViews in Utopia, so I'm not going to attempt salvaging the subvalues + * typescript had the issues with length and index signature, I'm blindly trusting the original author here + */ + length = (oldValue as any).length + if (length != (possibleNewValue as any).length) return possibleNewValue + for (i = length; i-- !== 0; ) + if ((oldValue as any)[i] !== (possibleNewValue as any)[i]) return possibleNewValue + return oldValue + } + + if (oldValue.constructor === RegExp) { + if ( + oldValue.source === possibleNewValue.source && + oldValue.flags === possibleNewValue.flags + ) { + return oldValue + } else { + return possibleNewValue + } + } + if ( + oldValue.valueOf !== Object.prototype.valueOf && + typeof oldValue.valueOf === 'function' && + typeof possibleNewValue.valueOf === 'function' + ) { + if (oldValue.valueOf() === possibleNewValue.valueOf()) { + return oldValue + } else { + return possibleNewValue + } + } + if ( + oldValue.toString !== Object.prototype.toString && + typeof oldValue.toString === 'function' && + typeof possibleNewValue.toString === 'function' + ) { + if (oldValue.toString() === possibleNewValue.toString()) { + return oldValue + } else { + return possibleNewValue + } + } + + keys = Object.keys(possibleNewValue) + const oldKeys = Object.keys(oldValue) + length = keys.length + + let newObjectToReturn: any = {} + let canSaveOldObject = true + + if (length !== Object.keys(oldValue).length) { + canSaveOldObject = false + } + + for (i = 0; i < length; i++) { + var key = keys[i] + if (key === undefined) { + continue + } + + if (key !== oldKeys[i]) { + canSaveOldObject = false + } + + if (key === '_owner' && oldValue.$$typeof != null) { + // React-specific: avoid traversing React elements' _owner. + // _owner contains circular references + // and is not needed when comparing the actual elements (and not their owners) + newObjectToReturn[key] = possibleNewValue[key] + continue + } + + newObjectToReturn[key] = keepDeepReferenceEqualityInner( + oldValue[key], + possibleNewValue[key], + stackSizeInner + 1, + valueStackSoFar, + ) + if (oldValue[key] !== newObjectToReturn[key]) { + canSaveOldObject = false + } + } + + if (canSaveOldObject) { + return oldValue + } else { + return newObjectToReturn + } + } + + // true if both NaN, false otherwise + if (oldValue !== oldValue && possibleNewValue !== possibleNewValue) { + return oldValue + } else { + return possibleNewValue + } +} + +export function keepDeepReferenceEqualityIfPossible( + oldValue: T | null | undefined, + possibleNewValue: T, + stackSize?: number, +): T +export function keepDeepReferenceEqualityIfPossible( + oldValue: any, + possibleNewValue: any, + stackSize: number = 0, +) { + return keepDeepReferenceEqualityInner(oldValue, possibleNewValue, stackSize, new Set()) +} + +/** + * Flasher hook. + */ +export function useFlasher() { + const ref = React.useRef(null) + React.useEffect(() => { + if (ref.current != null) { + ref.current.setAttribute( + 'style', + `box-shadow: 0 0 8px 1px #FF00FF; + background-color: #FF00FFBB; + transition: box-shadow 50ms ease-out;`, + ) + setTimeout(() => ref.current!.setAttribute('style', ''), 100) + } + }) + return ref +} + +export function getIntrospectiveKeepDeepResult( + oldValue: T, + newValue: T, +): KeepDeepEqualityResult { + const value = keepDeepReferenceEqualityIfPossible(oldValue, newValue) + return keepDeepEqualityResult(value, value === oldValue) +} + +export function createCallFromIntrospectiveKeepDeep(): KeepDeepEqualityCall { + return getIntrospectiveKeepDeepResult +} + +export function useKeepShallowReferenceEquality(possibleNewValue: T, measure = false): T { + const oldValue = React.useRef(possibleNewValue) + if (!shallowEqual(oldValue.current, possibleNewValue, measure)) { + oldValue.current = possibleNewValue + } + return oldValue.current +} + +export function useKeepReferenceEqualityIfPossible(possibleNewValue: T, measure = false): T { + const oldValue = React.useRef(null) + oldValue.current = keepDeepReferenceEqualityIfPossible(oldValue.current, possibleNewValue) + return oldValue.current! +} + +export function useKeepDeepEqualityCall(newValue: T, keepDeepCall: KeepDeepEqualityCall): T { + const oldValue = React.useRef(newValue) + oldValue.current = keepDeepCall(oldValue.current, newValue).value + return oldValue.current +} diff --git a/nexus-builder/packages/core/src/utils/utils.ts b/nexus-builder/packages/core/src/utils/utils.ts new file mode 100644 index 000000000000..054269fce51b --- /dev/null +++ b/nexus-builder/packages/core/src/utils/utils.ts @@ -0,0 +1,1092 @@ +import chroma from 'chroma-js' +import { v4 as UUID } from 'uuid' +import type { PackageType } from '../core/shared/project-file-types' +import type { AnyJson, JsonMap } from '../missing-types/json' +import type { JsonSchema, PropSchema } from '../missing-types/json-schema' +import type { ControlStyles } from '../components/inspector/common/control-styles' +import type { NormalisedFrame } from 'utopia-api/core' +import { fastForEach, NO_OP } from '../core/shared/utils' +import type { + CanvasRectangle, + SimpleRectangle, + CoordinateMarker, + Rectangle, + LocalRectangle, +} from '../core/shared/math-utils' +import { + roundTo, + normalizeDegrees, + degreesToRadians, + radiansToDegrees, + scalePoint, + scaleVector, + rotateVector, + zeroPoint, + zeroSize, + zeroRectangle, + zeroRectangleAtPoint, + shiftToOrigin, + rectOrigin, + rectContainsPoint, + circleContainsPoint, + ellipseContainsPoint, + negate, + magnitude, + product, + distance, + vectorFromPoints, + interpolateAt, + offsetPoint, + normalizeRect, + getLocalRectangleInNewParentContext, + getLocalPointInNewParentContext, + getCanvasRectangleWithCanvasOffset, + getCanvasPointWithCanvasOffset, + getCanvasVectorFromWindowVector, + asLocal, + asGlobal, + rectangleDifference, + rectangleIntersection, + pointDifference, + vectorDifference, + offsetRect, + combineRectangles, + stretchRect, + scaleSize, + scaleRect, + getRectCenter, + setRectLeftX, + setRectCenterX, + setRectRightX, + setRectTopY, + setRectCenterY, + setRectBottomY, + setRectWidth, + setRectHeight, + rectFromPointVector, + rectSizeToVector, + transformFrameUsingBoundingBox, + closestPointOnLine, + lineIntersection, + percentToNumber, + numberToPercent, + rectangleToPoints, + boundingRectangle, + boundingRectangleArray, + angleOfPointFromVertical, + pointsEqual, + rectanglesEqual, + proportion, + rect, + point, + sizesEqual, + forceNotNaN, + nanToZero, + safeParseInt, +} from '../core/shared/math-utils' +import { + optionalMap, + optionalFlatMap, + maybeToArray, + arrayToMaybe, + defaultIfNull, + defaultIfNullLazy, + forceNotNull, +} from '../core/shared/optional-utils' +import { + propOrNull, + objectMap, + mapValues, + get, + propOr, + copyObjectWithoutFunctions, + getAllObjectPaths, + modifyKey, + mergeObjects, + objectValues, + isEmptyObject, + objectFlattenKeys, +} from '../core/shared/object-utils' +import { + stripNulls, + filterDuplicates, + flatMapArray, + pluck, + traverseArray, + arrayToObject, + mapArrayToDictionary, + uniq, + dropLast, + last, + removeIndexFromArray, + flattenArray, + addToMapOfArraysUnique, + addUniquely, + addAllUniquely, + findLastIndex, + drop, + insert, + insertMultiple, +} from '../core/shared/array-utils' +import { + shallowEqual, + oneLevelNestedEquals, + keepReferenceIfShallowEqual, +} from '../core/shared/equality-utils' +import { + safeFunction, + SafeFunctionCurriedErrorHandler, + processErrorWithSourceMap, +} from '../core/shared/code-exec-utils' +import type { ValueType, OptionsType, OptionTypeBase } from 'react-select' +import { emptySet } from '../core/shared/set-utils' +import * as ObjectPath from 'object-path' +// TODO Remove re-exported functions + +export type FilteredFields = { + [K in keyof Base]: Base[K] extends T ? K : never +}[keyof Base] + +export interface Front { + type: 'front' +} + +export function front(): Front { + return { + type: 'front', + } +} + +export interface Back { + type: 'back' +} + +export function back(): Back { + return { + type: 'back', + } +} + +export interface Absolute { + type: 'absolute' + index: number +} + +export function absolute(index: number): Absolute { + return { + type: 'absolute', + index: index, + } +} + +export interface After { + type: 'after' + index: number +} + +export function after(index: number): After { + return { + type: 'after', + index: index, + } +} + +export interface Before { + type: 'before' + index: number +} + +export function before(index: number): Before { + return { + type: 'before', + index: index, + } +} + +export type IndexPosition = Front | Back | Absolute | After | Before + +export function shiftIndexPositionForRemovedElement( + indexPosition: IndexPosition, + removedElementIndex: number, +): IndexPosition { + switch (indexPosition.type) { + case 'front': + case 'back': + case 'absolute': + return indexPosition + case 'before': + if (removedElementIndex < indexPosition.index) { + return before(indexPosition.index - 1) + } else { + return indexPosition + } + case 'after': + if (removedElementIndex < indexPosition.index) { + return after(indexPosition.index - 1) + } else { + return indexPosition + } + default: + const _exhaustiveCheck: never = indexPosition + throw new Error(`Unhandled index position ${JSON.stringify(indexPosition)}`) + } +} + +export type Axis = 'x' | 'y' + +export type DiagonalAxis = 'TopLeftToBottomRight' | 'BottomLeftToTopRight' + +export interface HSLA { + h: number + s: number + l: number + a: number +} + +export type Color = { red: number; green: number; blue: number; alpha: number } + +export type ColorProperty = { + enabled: boolean + color: Color +} + +export function normalisedFrameToCanvasFrame(frame: NormalisedFrame): CanvasRectangle { + const { left: x, top: y, width, height } = frame + return { x, y, width, height } as CanvasRectangle +} + +export type ObtainChildren = (elem: T, parents: Array) => Array + +export type GetFrame = (elem: T, parents: Array) => SimpleRectangle + +export type HitTester = { + elem: T + parents: Array + frame: SimpleRectangle +} + +export type Clock = { + // returns the current timestamp + now: () => number +} + +export const getChainSegmentEdge = (controlStyles: ControlStyles) => ({ + top: `0 1px ${controlStyles.borderColor} inset`, + bottom: `0 -1px ${controlStyles.borderColor} inset`, + left: `1px 0 ${controlStyles.borderColor} inset`, + right: `-1px 0 ${controlStyles.borderColor} inset`, +}) + +export function generateUUID(): string { + return UUID().replace(/-/g, '_') +} + +function isColor(color: any): color is Color { + if (color == null) { + return false + } else { + return ( + (color as Color).red !== undefined && + (color as Color).blue !== undefined && + (color as Color).green !== undefined && + (color as Color).alpha !== undefined + ) + } +} + +function safeColor(color: Color): Color { + return { + red: propOrNull('red', color) == null ? 0 : color.red, + green: propOrNull('green', color) == null ? 0 : color.green, + blue: propOrNull('blue', color) == null ? 0 : color.blue, + alpha: propOrNull('alpha', color) == null ? 1 : color.alpha, + } +} + +function colorToRGBA(unsafe: Color): string { + const color = safeColor(unsafe) + return ( + 'rgba(' + + Math.round(color.red) + + ',' + + Math.round(color.green) + + ',' + + Math.round(color.blue) + + ',' + + color.alpha + + ')' + ) +} + +function colorToHex(unsafe: Color): string { + const color = safeColor(unsafe) + return chroma(color.red, color.green, color.blue).hex().toUpperCase() +} + +function colorToRGBAWithoutOpacity(unsafe: Color): string { + const color = safeColor(unsafe) + return ( + 'rgba(' + + Math.round(color.red) + + ',' + + Math.round(color.green) + + ',' + + Math.round(color.blue) + + ',1)' + ) +} + +function colorToReactNativeColor(color: Color | null, defaultToBlack: boolean = false): string { + if (color == null || (color.red == null && color.blue == null && color.green == null)) { + return defaultToBlack ? 'rgba(0,0,0,1)' : 'transparent' + } + + return colorToRGBA(color) +} + +function nullIfTransparent(color: Color | null): Color | null { + if (color == null) { + return null + } + if (color.alpha == 0) { + return null + } + return color +} + +const TRANSPARENT_IMAGE_SRC = + 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' + +function isJsonStringValid(jsonString: string): boolean { + try { + JSON.parse(jsonString) + } catch (e) { + return false + } + return true +} + +function jsonParseOrNull(jsonString: string): any | null { + try { + return JSON.parse(jsonString) + } catch (e) { + return null + } +} + +function safeCompare(left: T | null | undefined, right: T, fn: (l: T, r: T) => T): T { + if (left === null || left === undefined) { + return right + } else { + return fn(left, right) + } +} + +function removeFirst(array: Array): Array { + return drop(1, array) +} + +function resolveRef($ref: string, completeSchema: JsonSchema): JsonSchema { + if ($ref.startsWith('main#/definitions/')) { + // this ONLY works with top level definitions + // TODO make it work with deep refs + const definitionName = $ref.slice(18) + return completeSchema.definitions![definitionName]! + } else { + throw new Error( + 'using a $ref which does not point to main#/definitions/ is not yet allowed: ' + $ref, + ) + } +} + +function traverseJsonSchema( + schemaPath: Array, + schema: JsonSchema, + completeSchema?: JsonSchema, +): JsonSchema | null { + const schemaToUse = completeSchema ?? schema + + const firstSchemaPath = schemaPath[0] + if (firstSchemaPath === undefined) { + return schema + } + if (schema.type === 'array' && schema.items != null) { + return traverseJsonSchema(removeFirst(schemaPath), schema.items, schemaToUse) + } + if (schema.properties != null) { + const prop = schema.properties[firstSchemaPath] + if (prop == null) { + return null + } else { + return traverseJsonSchema(removeFirst(schemaPath), prop, schemaToUse) + } + } + if (schema.$ref != null) { + return traverseJsonSchema(schemaPath, resolveRef(schema.$ref, schemaToUse), schemaToUse) + } + + return null +} + +function extractDefaultForType(schema: JsonSchema, type: PackageType): AnyJson | undefined { + if (type === 'svg' && schema.defaultSpecialForSvg != null) { + return schema.defaultSpecialForSvg + } else { + return schema.default + } +} + +function compileDefaultForSchema( + schema: JsonSchema, + type: PackageType, + debugKey: string | number, + preferLeafDefault: boolean, + completeSchema: JsonSchema, +): AnyJson { + const defaultForNode = extractDefaultForType(schema, type) + if (!preferLeafDefault && defaultForNode !== undefined) { + return defaultForNode + } + if (schema.type === 'array' && schema.items != null) { + /** + * The requested property path points to an array, + * which in our implementation is an object with numbers as keys. + * So an array of one element is of the shape { 0: ... } + */ + return { + 0: compileDefaultForSchema(schema.items, type, debugKey, preferLeafDefault, completeSchema), + } + } + + if (schema.allOf != null) { + let result: JsonMap = {} + fastForEach(schema.allOf, (elementOfAll) => { + const elementSchema = compileDefaultForSchema( + elementOfAll, + type, + debugKey, + preferLeafDefault, + completeSchema, + ) + if ( + typeof elementSchema === 'object' && + elementSchema != null && + !Array.isArray(elementSchema) + ) { + result = { + ...result, + ...elementSchema, + } + } else { + throw new Error(`Impossible to merge schema element ${elementSchema} into object.`) + } + }) + return result + } + + if (schema.properties != null) { + let result: { [property: string]: AnyJson } = {} + const properties = schema.properties + for (const [key, property] of Object.entries(properties)) { + result[key] = compileDefaultForSchema( + property, + type, + `${debugKey}.${key}`, + preferLeafDefault, + completeSchema, + ) + } + return result + } + + if (schema.$ref != null) { + return compileDefaultForSchema( + resolveRef(schema.$ref, completeSchema), + type, + debugKey, + preferLeafDefault, + completeSchema, + ) + } + + if (preferLeafDefault && defaultForNode !== undefined) { + return defaultForNode + } + + // oh no, we don't have a default for this! + throw new Error(`Schema Error: couldn't find default value for type '${debugKey}'`) +} + +function compileDefaultForPropSchema(propSchema: PropSchema): any { + return objectMap((schema, key) => { + try { + return compileDefaultForSchema(schema, 'base', key, false, schema) + } catch (e) { + // we couldn't find a default + return null + } + }, propSchema) +} + +export function eventTargetIsTextArea(event: KeyboardEvent): boolean { + return /textarea/i.test((event.target as any).tagName) +} +export function eventTargetIsTextAreaOrInput(event: KeyboardEvent) { + return /input|textarea/i.test((event.target as any).tagName) +} + +function getBaseAndIndex(name: string, insertSpace: boolean): { base: string; index: number } { + let tokens = name.split(' ') + let lastToken: string = '' + let baseName = name + if (insertSpace) { + lastToken = forceNotNull('Token should exist.', tokens[tokens.length - 1]) + baseName = dropLast(tokens).join(' ') + } else { + const regexMatch = name.match(/\d+$/) + if (regexMatch != null) { + lastToken = forceNotNull('First regex match should exist.', regexMatch[0]) + baseName = name.slice(0, regexMatch.index) + tokens = [baseName, lastToken] + } + } + + const indexToken = parseInt(lastToken) + + if (tokens.length == 0 || isNaN(indexToken)) { + return { + base: name, + index: 1, + } + } else { + return { + base: baseName, + index: indexToken, + } + } +} + +export function nextName( + candidate: string, + namesTaken: Array, + insertSpace: boolean = true, +): string { + const { base } = getBaseAndIndex(candidate, insertSpace) + const maxIndex = namesTaken + .filter((name) => { + return name.indexOf(base) >= 0 + }) + .reduce((acc, name) => { + const { index } = getBaseAndIndex(name, insertSpace) + return index > acc ? index : acc + }, 0) + return `${base}${insertSpace ? ' ' : ''}${maxIndex + 1}` +} + +// Treats "front" as if everything in the array was in a stack from back to front. +function indexToInsertAt(array: Array, indexPosition: IndexPosition): number { + switch (indexPosition.type) { + case 'front': + return array.length + case 'back': + return 0 + case 'absolute': + return indexPosition.index + case 'after': + return Math.min(indexPosition.index + 1, array.length) + case 'before': + return Math.max(indexPosition.index, 0) + } +} + +function addToArrayWithFill( + element: T, + array: Array, + atPosition: IndexPosition, + fillValue: () => T, +): Array { + const index = indexToInsertAt(array, atPosition) + let midResult: Array = [...array] + for (let fillIndex = array.length; fillIndex < index; fillIndex++) { + midResult.push(fillValue()) + } + return insert(index, element, midResult) +} + +export function addToArrayAtIndexPosition( + element: T, + array: Array, + atPosition: IndexPosition, +): Array { + const index = indexToInsertAt(array, atPosition) + return insert(index, element, array) +} + +export function addElementsToArrayAtIndexPosition( + elements: Array, + array: Array, + atPosition: IndexPosition, +): Array { + const index = indexToInsertAt(array, atPosition) + return insertMultiple(index, elements, array) +} + +function assert(errorMessage: string, predicate: boolean | (() => boolean)): void { + if (typeof predicate === 'function' && predicate()) { + return + } + if (typeof predicate === 'boolean' && predicate) { + return + } + throw new Error(`Assert failed: ${errorMessage}`) +} + +export interface Annotations { + _signature: string + _description: string + _properties: { [key: string]: any & Annotations } +} + +function withDoc( + value: T, + signature: string, + doc: string, + properties: { [key: string]: any & Annotations } = {}, +): T & Annotations { + var annotated: any = value + annotated._signature = signature + annotated._description = doc + annotated._properties = properties + return annotated +} + +function shallowClone(value: any): any { + switch (typeof value) { + case 'object': + if (Array.isArray(value)) { + return [...value] + } else { + return { ...value } + } + default: + return value + } +} + +function proxyValue( + valueToProxy: any, + recordAssignment: (p: Array, value: any) => any, +): any { + function wrapInProxy(toWrap: any, objPath: Array): any { + if (typeof toWrap == 'object' && toWrap != null) { + let withWrappedChildren: any + switch (typeof toWrap) { + case 'object': + if (Array.isArray(toWrap)) { + withWrappedChildren = toWrap.map((element: any, index: number) => + wrapInProxy(element, objPath.concat([`${index}`])), + ) + } else { + withWrappedChildren = mapValues( + (element: any, key: string) => wrapInProxy(element, objPath.concat([key])), + toWrap, + ) + } + break + default: + withWrappedChildren = toWrap + } + + return new Proxy(withWrappedChildren, { + set: function (target: any, property: any, value: any, receiver: any): boolean { + if (typeof value === 'symbol') { + target[property] = value + } else { + const innerPath = objPath.concat([property]) + const innerValue = wrapInProxy(value, innerPath) + target[property] = innerValue + recordAssignment(innerPath, value) + } + return true + }, + }) + } else { + return toWrap + } + } + + return wrapInProxy(valueToProxy, []) +} + +const createSimpleClock = function (): Clock { + return { + now: () => { + return Date.now() + }, + } +} + +function stepInArray( + eq: (first: T, second: T) => boolean, + step: number, + array: Array, + stepFrom: T, +): T | null { + let workingIndex = 0 + let foundIndex: number | null = null + for (const element of array) { + // Check if this is the element we're looking for. + if (eq(element, stepFrom)) { + foundIndex = workingIndex + // Might as well bail out early. + break + } + workingIndex++ + } + if (foundIndex === null) { + return null + } else { + let index: number = foundIndex + step + function tryNextStep(): void { + if (index < 0 || index >= array.length) { + index = step >= 0 ? index - array.length : index + array.length + tryNextStep() + } + } + tryNextStep() + return forceNotNull(`No element at index ${index}`, array[index]) + } +} + +function increaseScale(scale: number): number { + return scale * 2 +} + +function decreaseScale(scale: number): number { + return scale / 2 +} + +function createThrottler( + timeout: number, +): (functionToCall: (...args: any[]) => void, ...args: any[]) => void { + let canCallFunction = true + return (functionToCall: (...args: any[]) => void, ...args: any[]) => { + if (canCallFunction) { + // eslint-disable-next-line prefer-spread + functionToCall.apply(null, args) + canCallFunction = false + setTimeout(() => { + canCallFunction = true + }, timeout) + } + } +} + +function path( + objPath: Array, + obj: Record | undefined | null, +): T | undefined { + if (obj == null) { + return undefined + } else { + return ObjectPath.get(obj, objPath) + } +} + +// eslint-disable-next-line @typescript-eslint/ban-types +function pathOr(defaultValue: T, objPath: Array, obj: {}): T | U { + return ObjectPath.get(obj, objPath, defaultValue) +} + +function immutableUpdateField(valueToUpdate: any, field: string | number, valueToSet: any): any { + const fieldParsedAsNumber: number = typeof field === 'number' ? field : parseInt(field) + if (isNaN(fieldParsedAsNumber)) { + // Object field update. + return { + ...defaultIfNull({}, valueToUpdate), + [field]: valueToSet, + } + } else { + let result: Array = valueToUpdate == null ? [] : [...valueToUpdate] + result[fieldParsedAsNumber] = valueToSet + return result + } +} + +function immutableUpdate( + valueToUpdate: any, + objPath: Array, + valueToSet: any, +): any { + const first = objPath[0] + if (first === undefined) { + // No path, so we're just replacing the whole value at this point. + return valueToSet + } else if (objPath.length === 1) { + // Last part of the path, setting the `valueToSet` where the final part specifies. + return immutableUpdateField(valueToUpdate, first, valueToSet) + } else { + // 2 or more path elements, need to step down path part to recursively invoke this on the remainder. + const remainder = objPath.slice(1) + const fieldParsedAsNumber: number = typeof first === 'number' ? first : parseInt(first) + const isArrayUpdate = typeof first === 'number' || !isNaN(fieldParsedAsNumber) + if (isArrayUpdate) { + // Arrays. + const defaultedArray: Array = defaultIfNull([], valueToUpdate) + let result: Array = [...defaultedArray] + result[fieldParsedAsNumber] = immutableUpdate( + defaultedArray[fieldParsedAsNumber], + remainder, + valueToSet, + ) + return result + } else { + // Objects. + const defaultedObject: { [key: string]: any } = defaultIfNull({}, valueToUpdate) + return { + ...defaultedObject, + [first]: immutableUpdate(defaultedObject[first], remainder, valueToSet), + } + } + } +} + +function isLocalRectangle(rectangle: any): rectangle is LocalRectangle { + if (typeof rectangle === 'object') { + return ( + rectangle.x != null && + typeof rectangle.x === 'number' && + rectangle.y != null && + typeof rectangle.y === 'number' && + rectangle.width != null && + typeof rectangle.width === 'number' && + rectangle.height != null && + typeof rectangle.height === 'number' + ) + } else { + return false + } +} + +function update(index: number, newValue: T, array: Array): Array { + let result = [...array] + result.splice(index, 1, newValue) + return result +} + +export type Defer = Promise & { + resolve: (value?: T) => void + reject: (reason?: any) => void +} + +export function defer(): Defer { + var res, rej + + var promise = new Promise((resolve, reject) => { + res = resolve + rej = reject + }) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Object.defineProperty(promise, 'resolve', { value: res }) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Object.defineProperty(promise, 'reject', { value: rej }) + + return promise as any +} + +function timeLimitPromise(promise: Promise, limitms: number, message: string): Promise { + const timeoutPromise: Promise = new Promise((resolve, reject) => { + const timeoutID = setTimeout(() => { + clearTimeout(timeoutID) + reject(message) + }, limitms) + }) + return Promise.race([promise, timeoutPromise]) +} + +/** + * A type guard for React Select's onChange values, which can either be a value + * or an array of values. + */ +export function isOptionType(value: ValueType): value is T { + return value != null && !Array.isArray(value) +} + +/** + * A type guard for React Select's onChange values, which can either be a value + * or an array of values. + */ +export function isOptionsType( + value: ValueType, +): value is OptionsType { + return Array.isArray(value) +} + +function deduplicateBy(key: (t: T) => string, ts: Array): Array { + const seen = new Set() + const results: Array = [] + for (const t of ts) { + const k = key(t) + if (!seen.has(k)) { + results.push(t) + seen.add(k) + } + } + return results +} + +export default { + generateUUID: generateUUID, + assert: assert, + roundTo: roundTo, + normalizeDegrees: normalizeDegrees, + degreesToRadians: degreesToRadians, + radiansToDegrees: radiansToDegrees, + scalePoint: scalePoint, + scaleVector: scaleVector, + rotateVector: rotateVector, + zeroPoint: zeroPoint, + zeroSize: zeroSize, + zeroRectangle: zeroRectangle, + zeroRectangleAtPoint: zeroRectangleAtPoint, + shiftToOrigin: shiftToOrigin, + rectOrigin: rectOrigin, + rectContainsPoint: rectContainsPoint, + circleContainsPoint: circleContainsPoint, + ellipseContainsPoint: ellipseContainsPoint, + negate: negate, + magnitude: magnitude, + product: product, + distance: distance, + vectorFromPoints: vectorFromPoints, + interpolateAt: interpolateAt, + offsetPoint: offsetPoint, + addVectors: offsetPoint, + normalizeRect: normalizeRect, + getLocalRectangleInNewParentContext: getLocalRectangleInNewParentContext, + getLocalPointInNewParentContext: getLocalPointInNewParentContext, + getCanvasRectangleWithCanvasOffset: getCanvasRectangleWithCanvasOffset, + getCanvasPointWithCanvasOffset: getCanvasPointWithCanvasOffset, + getCanvasVectorFromWindowVector: getCanvasVectorFromWindowVector, + asLocal: asLocal, + asGlobal: asGlobal, + rectangleDifference: rectangleDifference, + rectangleIntersection: rectangleIntersection, + pointDifference: pointDifference, + vectorDifference: vectorDifference, + offsetRect: offsetRect, + combineRectangles: combineRectangles, + stretchRect: stretchRect, + scaleSize: scaleSize, + scaleRect: scaleRect, + getRectCenter: getRectCenter, + setRectLeftX: setRectLeftX, + setRectCenterX: setRectCenterX, + setRectRightX: setRectRightX, + setRectTopY: setRectTopY, + setRectCenterY: setRectCenterY, + setRectBottomY: setRectBottomY, + setRectWidth: setRectWidth, + setRectHeight: setRectHeight, + rectFromPointVector: rectFromPointVector, + rectSizeToVector: rectSizeToVector, + transformFrameUsingBoundingBox: transformFrameUsingBoundingBox, + closestPointOnLine: closestPointOnLine, + lineIntersection: lineIntersection, + isColor: isColor, + safeColor: safeColor, + colorToRGBA: colorToRGBA, + colorToHex: colorToHex, + colorToRGBAWithoutOpacity: colorToRGBAWithoutOpacity, + colorToReactNativeColor: colorToReactNativeColor, + nullIfTransparent: nullIfTransparent, + SafeFunction: safeFunction, + SafeFunctionCurriedErrorHandler: SafeFunctionCurriedErrorHandler, + TRANSPARENT_IMAGE_SRC: TRANSPARENT_IMAGE_SRC, + get: get, + isJsonStringValid: isJsonStringValid, + safeCompare: safeCompare, + optionalMap: optionalMap, + optionalFlatMap: optionalFlatMap, + stripNulls: stripNulls, + filterDuplicates: filterDuplicates, + propOr: propOr, + propOrNull: propOrNull, + objectMap: objectMap, + removeFirst: removeFirst, + resolveRef: resolveRef, + traverseJsonSchema: traverseJsonSchema, + compileDefaultForSchema: compileDefaultForSchema, + compileDefaultForPropSchema: compileDefaultForPropSchema, + eventTargetIsTextAreaOrInput: eventTargetIsTextAreaOrInput, + copyObjectWithoutFunctions: copyObjectWithoutFunctions, + forceNotNull: forceNotNull, + eventTargetIsTextArea: eventTargetIsTextArea, + nextName: nextName, + indexToInsertAt: indexToInsertAt, + addToArrayWithFill: addToArrayWithFill, + percentToNumber: percentToNumber, + numberToPercent: numberToPercent, + getAllObjectPaths: getAllObjectPaths, + mapValues: mapValues, + withDoc: withDoc, + shallowClone: shallowClone, + proxyValue: proxyValue, + createSimpleClock: createSimpleClock, + shallowEqual: shallowEqual, + oneLevelNestedEquals: oneLevelNestedEquals, + keepReferenceIfShallowEqual: keepReferenceIfShallowEqual, + maybeToArray: maybeToArray, + arrayToMaybe: arrayToMaybe, + rectangleToPoints: rectangleToPoints, + flatMapArray: flatMapArray, + boundingRectangle: boundingRectangle, + boundingRectangleArray: boundingRectangleArray, + pluck: pluck, + traverseArray: traverseArray, + NO_OP: NO_OP, + stepInArray: stepInArray, + arrayToObject: arrayToObject, + angleOfPointFromVertical: angleOfPointFromVertical, + pointsEqual: pointsEqual, + rectanglesEqual: rectanglesEqual, + proportion: proportion, + increaseScale: increaseScale, + decreaseScale: decreaseScale, + createThrottler: createThrottler, + mapArrayToDictionary: mapArrayToDictionary, + modifyKey: modifyKey, + uniq: uniq, + fastForEach: fastForEach, + forceNotNaN: forceNotNaN, + nanToZero: nanToZero, + safeParseInt: safeParseInt, + defaultIfNull: defaultIfNull, + defaultIfNullLazy: defaultIfNullLazy, + emptySet: emptySet, + path: path, + pathOr: pathOr, + mergeObjects: mergeObjects, + objectValues: objectValues, + rect: rect, + point: point, + dropLast: dropLast, + last: last, + immutableUpdate: immutableUpdate, + isEmptyObject: isEmptyObject, + sizesEqual: sizesEqual, + objectFlattenKeys: objectFlattenKeys, + jsonParseOrNull: jsonParseOrNull, + isLocalRectangle: isLocalRectangle, + removeIndexFromArray: removeIndexFromArray, + flattenArray: flattenArray, + update: update, + addToMapOfArraysUnique: addToMapOfArraysUnique, + addUniquely: addUniquely, + addAllUniquely: addAllUniquely, + defer: defer, + processErrorWithSourceMap: processErrorWithSourceMap, + findLastIndex: findLastIndex, + timeLimitPromise: timeLimitPromise, + deduplicateBy: deduplicateBy, +} diff --git a/nexus-builder/packages/core/tsconfig.json b/nexus-builder/packages/core/tsconfig.json new file mode 100644 index 000000000000..41806822b623 --- /dev/null +++ b/nexus-builder/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/nexus-builder/packages/navigator/package.json b/nexus-builder/packages/navigator/package.json new file mode 100644 index 000000000000..166b4182f9b3 --- /dev/null +++ b/nexus-builder/packages/navigator/package.json @@ -0,0 +1,25 @@ +{ + "name": "@builder/navigator", + "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", + "@emotion/react": "11.1.2", + "react-virtualized-auto-sizer": "1.0.2", + "react-window": "1.8.7", + "reselect": "4.1.7", + "re-reselect": "4.0.1" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "eslint": "^9.39.1", + "typescript": "^5.9.3" + } +} diff --git a/nexus-builder/packages/navigator/src/index.ts b/nexus-builder/packages/navigator/src/index.ts new file mode 100644 index 000000000000..1de3238e10ae --- /dev/null +++ b/nexus-builder/packages/navigator/src/index.ts @@ -0,0 +1,2 @@ +// Entry point for @builder/navigator +export {}; diff --git a/nexus-builder/packages/navigator/src/navigator/actions/index.ts b/nexus-builder/packages/navigator/src/navigator/actions/index.ts new file mode 100644 index 000000000000..76a559d86476 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/actions/index.ts @@ -0,0 +1,47 @@ +import type { ElementPath } from '../../../core/shared/project-file-types' +import type { IndexPosition } from '../../../utils/utils' +import type { RenameComponent } from '../../editor/action-types' +import type { DropTargetType, NavigatorEntry } from '../../editor/store/editor-state' + +export function renameComponent(target: ElementPath, newName: string | null): RenameComponent { + return { + action: 'RENAME_COMPONENT', + target: target, + name: newName, + } +} + +export type LocalNavigatorAction = ShowNavigatorDropTargetHint | HideNavigatorDropTargetHint + +export interface ShowNavigatorDropTargetHint { + action: 'SHOW_DROP_TARGET_HINT' + type: DropTargetType + targetParentEntry: NavigatorEntry + displayAtEntry: NavigatorEntry + indexInTargetParent: IndexPosition +} + +export function showNavigatorDropTargetHint( + type: DropTargetType, + moveToElementPath: NavigatorEntry, + displayAtElementPath: NavigatorEntry, + indexPosition: IndexPosition, +): ShowNavigatorDropTargetHint { + return { + action: 'SHOW_DROP_TARGET_HINT', + type: type, + targetParentEntry: moveToElementPath, + displayAtEntry: displayAtElementPath, + indexInTargetParent: indexPosition, + } +} + +export interface HideNavigatorDropTargetHint { + action: 'HIDE_DROP_TARGET_HINT' +} + +export function hideNavigatorDropTargetHint(): HideNavigatorDropTargetHint { + return { + action: 'HIDE_DROP_TARGET_HINT', + } +} diff --git a/nexus-builder/packages/navigator/src/navigator/dependecy-version-status-indicator.tsx b/nexus-builder/packages/navigator/src/navigator/dependecy-version-status-indicator.tsx new file mode 100644 index 000000000000..f7de65c826aa --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/dependecy-version-status-indicator.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import type { PackageStatus } from '../../core/shared/npm-dependency-types' + +export const NpmDependencyVersionAndStatusIndicator = React.memo<{ + status: PackageStatus + version: string | null +}>((props) => { + switch (props.status) { + case 'loaded': + case 'default-package': + return {props.version} + case 'loading': + return ( + + + loading… + + ) + case 'updating': + return ( + + + updating... + + ) + case 'version-lookup': + return ( + + + preparing... + + ) + case 'error': + return failed to load + case 'not-found': + return not found + default: + const _exhaustiveCheck: never = props.status + throw new Error(`Unhandled PackageStatus ${JSON.stringify(props.status)}.`) + } +}) diff --git a/nexus-builder/packages/navigator/src/navigator/dependency-list-input-field.tsx b/nexus-builder/packages/navigator/src/navigator/dependency-list-input-field.tsx new file mode 100644 index 000000000000..60e53d45bc0b --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/dependency-list-input-field.tsx @@ -0,0 +1,174 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import type CreatableSelect from 'react-select/creatable' +import Utils from '../../utils/utils' +import type { SelectOption } from '../inspector/controls/select-control' +import { SelectControl } from '../inspector/controls/select-control' +import { top1000NPMPackagesOptions } from './top1000NPMPackagesOptions' +import { getControlStyles } from '../inspector/common/control-styles' +import { Tooltip, StringInput, OnClickOutsideHOC, UtopiaTheme, useColorTheme } from '../../uuiui' + +interface DependencySearchSelectProps { + addDependency: (packageName: string | null, packageVersion: string | null) => void +} + +export const DependencySearchSelect = React.forwardRef( + ( + { addDependency }: DependencySearchSelectProps, + dependencyVersionInputRef: React.Ref, + ) => { + const dependencyNameSelectRef = React.useRef>(null) + const [stateEditedPackageName, setStateEditedPackageName] = React.useState('') + + const onSubmitValue = React.useCallback( + (newValue: string) => { + setStateEditedPackageName(newValue) + addDependency(newValue, null) + setStateEditedPackageName('') + }, + [setStateEditedPackageName, addDependency], + ) + + return ( +

+ ('', stateEditedPackageName)} + options={top1000NPMPackagesOptions} + id='edit-package-name' + key='edit-package-name' + testId='edit-package-name' + onSubmitValue={onSubmitValue} + controlStatus='simple' + controlStyles={getControlStyles('simple')} + style={{ + flexGrow: 1, + }} + DEPRECATED_controlOptions={{ + placeholder: 'Search for npm modules', + focusOnMount: true, + creatable: true, + selectCreatableRef: dependencyNameSelectRef, + onKeyDown: (e) => { + const select = dependencyNameSelectRef.current + if (e.key === 'Enter' && select != null) { + if ((select.state as any).menuIsOpen as boolean) { + e.stopPropagation() + } else { + addDependency(stateEditedPackageName, null) + } + } + }, + }} + /> +
+ ) + }, +) + +interface DependencyListItemEditingProps { + addDependency: (packageName: string | null, packageVersion: string | null) => void + handleAbandonEdit: () => void + editedPackageName: string | null + editedPackageVersion: string | null +} + +export const DependencyListItemEditing = React.forwardRef( + ( + { + addDependency, + handleAbandonEdit, + editedPackageName, + editedPackageVersion, + }: DependencyListItemEditingProps, + dependencyVersionInputRef: React.Ref, + ) => { + const colorTheme = useColorTheme() + const [stateEditedPackageName, setStateEditedPackageName] = React.useState( + editedPackageName ?? '', + ) + const [stateEditedPackageVersion, setStateEditedPackageVersion] = React.useState( + editedPackageVersion ?? '', + ) + + const onDependencyVersionChange = React.useCallback( + (e: React.ChangeEvent) => { + setStateEditedPackageVersion(e.target.value) + }, + [], + ) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + addDependency(stateEditedPackageName, stateEditedPackageVersion) + setStateEditedPackageName('') + } else if (e.key === 'Escape') { + e.preventDefault() + handleAbandonEdit() + } + }, + [addDependency, handleAbandonEdit, stateEditedPackageName, stateEditedPackageVersion], + ) + + return ( + +
+
+ {Utils.defaultIfNull('', stateEditedPackageName)} +
+ + + Version Number (e.g. 1.0.0) + + } + > + ('', stateEditedPackageVersion)} + id='add-package-version' + testId='' + key='add-package-version' + onChange={onDependencyVersionChange} + placeholder='0.0.0' + ref={dependencyVersionInputRef} + style={{ + flexGrow: 0, + flexShrink: 0, + flexBasis: 48, + }} + onKeyDown={onKeyDown} + /> + +
+
+ ) + }, +) diff --git a/nexus-builder/packages/navigator/src/navigator/dependency-list-item.tsx b/nexus-builder/packages/navigator/src/navigator/dependency-list-item.tsx new file mode 100644 index 000000000000..1edfe9dca098 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/dependency-list-item.tsx @@ -0,0 +1,249 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx, keyframes } from '@emotion/react' +import React from 'react' +import { NpmDependencyVersionAndStatusIndicator } from './dependecy-version-status-indicator' +import type { ContextMenuItem } from '../context-menu-items' +import { NO_OP } from '../../core/shared/utils' +import { + useColorTheme, + FlexRow, + UtopiaTheme, + Tooltip, + Icons, + AlternateColorThemeComponent, +} from '../../uuiui' +import { MenuProvider, ContextMenu } from '../../uuiui-deps' +import type { DependencyPackageDetails } from '../editor/store/editor-state' +import { unless } from '../../utils/react-conditionals' + +interface DependencyListItemProps { + packageDetails: DependencyPackageDetails + editingLocked: boolean + isNewlyLoaded: boolean + openDependencyEditField: (dependencyName: string, openVersionInput?: boolean) => void + updateDependencyToLatestVersion: (dependencyName: string) => void + removeDependency: (key: string) => void +} + +export const DependencyListItem: React.FunctionComponent< + React.PropsWithChildren +> = ({ + packageDetails, + editingLocked, + isNewlyLoaded, + openDependencyEditField, + updateDependencyToLatestVersion, + removeDependency, +}) => { + const colorTheme = useColorTheme() + const ref = React.useRef(null) + + const LoadingTextColor = colorTheme.subduedForeground.value + const DefaultTextColor = colorTheme.neutralForeground.value + + const FlashAnimation = React.useMemo( + () => + keyframes({ + from: { + backgroundColor: colorTheme.listNewItemFlashBackground.value, + color: colorTheme.subduedForeground.value, + }, + to: { + backgroundColor: colorTheme.listNewItemFlashBackground0.value, + color: colorTheme.neutralForeground.value, + }, + }), + [colorTheme], + ) + + const isLoading = + packageDetails.status === 'loading' || packageDetails.status === 'version-lookup' + const isDefault = packageDetails.status === 'default-package' + const hasError = packageDetails.status === 'error' + const name = packageDetails.name + + const versionFieldNode: React.ReactNode = ( + + ) + let textStyle: Pick + + if (isLoading) { + textStyle = { color: LoadingTextColor, fontStyle: 'italic' } + } else if (hasError) { + textStyle = { color: colorTheme.errorForeground.value, fontStyle: 'italic' } + } else { + textStyle = { + color: DefaultTextColor, + } + } + + const onVersionDoubleClick = React.useCallback( + (e: React.MouseEvent) => { + if (!isDefault) { + e.stopPropagation() + openDependencyEditField(name, true) + } + }, + [openDependencyEditField, name, isDefault], + ) + + // list items listen to keyboard events as they have tabIndex={0} + const onKeyUp = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key == 'Delete') { + removeDependency(name) + e.stopPropagation() + } else if (e.key == 'Enter') { + openDependencyEditField(name) + e.stopPropagation() + } + }, + [name, openDependencyEditField, removeDependency], + ) + + const menuId = `dependency-list-item-contextmenu-${packageDetails.name}` + const menuItems: Array> = isDefault + ? [] + : [ + { + name: 'Edit', + enabled: true, + action: () => { + openDependencyEditField(name) + }, + }, + { + name: 'Update to latest version', + enabled: true, + action: () => { + updateDependencyToLatestVersion(name) + }, + }, + { + name: 'Remove', + enabled: true, + action: () => { + removeDependency(name) + }, + }, + ] + + return ( + + + + + {name} + + {isDefault ? ( + + + default + + + ) : null} + + + + + + {unless( + isDefault, + removeDependency(name)} + > + + , + )} + {versionFieldNode} + + + + + ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/dependency-list-items.tsx b/nexus-builder/packages/navigator/src/navigator/dependency-list-items.tsx new file mode 100644 index 000000000000..23f76131397c --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/dependency-list-items.tsx @@ -0,0 +1,68 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import { FlexRow, UtopiaTheme } from '../../uuiui' +import type { DependencyPackageDetails } from '../editor/store/editor-state' +import { DependencyListItemEditing, DependencySearchSelect } from './dependency-list-input-field' +import { DependencyListItem } from './dependency-list-item' + +interface DependencyListItemsProps { + packages: Array + editingLocked: boolean + newlyLoadedItems: Array + dependencyBeingEdited: string | null + openDependencyEditField: (dependencyName: string, openVersionInput?: boolean) => void + updateDependencyToLatestVersion: (dependencyName: string) => void + removeDependency: (key: string) => void + addDependency: (packageName: string | null, packageVersion: string | null) => void + handleAbandonEdit: () => void +} + +export const DependencyListItems: React.FunctionComponent< + React.PropsWithChildren +> = ({ + packages, + editingLocked, + newlyLoadedItems, + dependencyBeingEdited, + openDependencyEditField, + updateDependencyToLatestVersion, + removeDependency, + addDependency, + handleAbandonEdit, +}) => { + return ( + + + + + {[ + ...packages.map((packageDetails) => { + const isNewlyLoaded = newlyLoadedItems.indexOf(packageDetails.name) >= 0 + return dependencyBeingEdited === packageDetails.name ? ( + + ) : ( + + ) + }), + ]} + + ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/dependency-list.tsx b/nexus-builder/packages/navigator/src/navigator/dependency-list.tsx new file mode 100644 index 000000000000..0460ec7a355c --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/dependency-list.tsx @@ -0,0 +1,518 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import type { + RequestedNpmDependency, + PackageStatusMap, + PackageStatus, +} from '../../core/shared/npm-dependency-types' +import { requestedNpmDependency } from '../../core/shared/npm-dependency-types' +import type { ProjectFile } from '../../core/shared/project-file-types' +import Utils from '../../utils/utils' +import type { EditorPanel } from '../common/actions' +import { setFocus } from '../common/actions' +import type { EditorDispatch } from '../editor/action-types' +import * as EditorActions from '../editor/actions/action-creators' +import { clearSelection, addToast } from '../editor/actions/action-creators' +import type { VersionLookupResult } from '../editor/npm-dependency/npm-dependency' +import { + dependenciesFromPackageJson, + findLatestVersion, + checkPackageVersionExists, +} from '../editor/npm-dependency/npm-dependency' +import type { DependencyPackageDetails } from '../editor/store/editor-state' +import { DefaultPackagesList } from '../editor/store/editor-state' +import { Substores, useEditorState } from '../editor/store/store-hook' +import { DependencyListItems } from './dependency-list-items' +import { fetchNodeModules } from '../../core/es-modules/package-manager/fetch-packages' +import { + SectionTitleRow, + FlexRow, + Title, + SquareButton, + FunctionIcons, + SectionBodyArea, + Section, + FlexColumn, + Button, + colorTheme, +} from '../../uuiui' +import { notice } from '../common/notice' +import { isFeatureEnabled } from '../../utils/feature-switches' +import type { BuiltInDependencies } from '../../core/es-modules/package-manager/built-in-dependencies-list' +import { useDispatch } from '../editor/store/dispatch-context' +import { packageJsonFileFromProjectContents } from '../assets' + +type DependencyListProps = { + editorDispatch: EditorDispatch + minimised: boolean + toggleMinimised?: () => void + focusedPanel: EditorPanel | null + packageJsonFile: ProjectFile | null + packageStatus: PackageStatusMap + builtInDependencies: BuiltInDependencies +} + +function packageDetails( + name: string, + version: string | null, + status: PackageStatus, +): DependencyPackageDetails { + return { + name: name, + version: version, + status: status, + } +} + +export type DependencyLoadingStatus = 'not-loading' | 'adding' | 'removing' + +type DependencyListState = { + dependencyBeingEdited: string | null + newlyLoadedItems: Array + dependencyLoadingStatus: DependencyLoadingStatus +} + +function packageDetailsFromDependencies( + npmDependencies: Array, + packageStatus: PackageStatusMap, +): Array { + const userAddedPackages: Array = [] + Utils.fastForEach(npmDependencies, (dep) => { + const foundDefaultDependency = DefaultPackagesList.find((p) => p.name === dep.name) + const status = + foundDefaultDependency != null && foundDefaultDependency.version === dep.version + ? 'default-package' + : packageStatus[dep.name]?.status ?? 'loaded' + userAddedPackages.push(packageDetails(dep.name, dep.version, status)) + }) + + return userAddedPackages +} + +export const DependencyList = React.memo(() => { + const props = useEditorState( + Substores.restOfEditor, + (store) => { + return { + minimised: store.editor.dependencyList.minimised, + focusedPanel: store.editor.focusedPanel, + packageStatus: store.editor.nodeModules.packageStatus, + } + }, + 'DependencyList', + ) + + const builtInDependencies = useEditorState( + Substores.builtInDependencies, + (store) => store.builtInDependencies, + 'DependencyList builtInDependencies', + ) + + const packageJsonFile = useEditorState( + Substores.projectContents, + (store) => packageJsonFileFromProjectContents(store.editor.projectContents), + 'DependencyList packageJsonFile', + ) + + const dispatch = useDispatch() + + const toggleMinimised = React.useCallback(() => { + dispatch([EditorActions.togglePanel('dependencylist')], 'leftpane') + }, [dispatch]) + + const dependencyProps = { ...props, toggleMinimised: toggleMinimised } + + return ( + + ) +}) + +function unwrapLookupResult(lookupResult: VersionLookupResult): string | null { + switch (lookupResult.type) { + case 'PACKAGE_NOT_FOUND': + return null + case 'VERSION_LOOKUP_SUCCESS': + return lookupResult.version.version + default: + const _exhaustiveCheck: never = lookupResult + throw new Error(`Unhandled version lookup type ${JSON.stringify(lookupResult)}`) + } +} + +class DependencyListInner extends React.PureComponent { + DependencyListContainerId = 'dependencyList' + constructor(props: DependencyListProps) { + super(props) + this.state = { + dependencyLoadingStatus: 'not-loading', + dependencyBeingEdited: null, + newlyLoadedItems: [], + } + } + + // TODO: this is a very very naughty lifecycle event + UNSAFE_componentWillReceiveProps(newProps: DependencyListProps): void { + if ( + newProps.packageJsonFile === this.props.packageJsonFile || + Utils.shallowEqual(newProps.packageJsonFile, this.props.packageJsonFile) + ) { + return + } else { + this.setState((state) => { + return { + dependencyLoadingStatus: 'not-loading', + dependencyBeingEdited: null, + newlyLoadedItems: state.newlyLoadedItems, + } + }) + } + } + + handleAbandonEdit = () => + this.setState({ + dependencyBeingEdited: null, + }) + + openDependencyEditField = (dependencyName: string) => { + this.setState({ + dependencyBeingEdited: dependencyName, + }) + } + + removeDependency = (key: string) => { + let npmDependencies = dependenciesFromPackageJson(this.props.packageJsonFile, 'regular-only') + // If we can't get the dependencies that implies something is broken, so avoid changing it. + if (npmDependencies != null) { + npmDependencies = npmDependencies.filter((dep) => dep.name != key) + + this.props.editorDispatch([EditorActions.updatePackageJson(npmDependencies)]) + + void fetchNodeModules( + this.props.editorDispatch, + npmDependencies, + this.props.builtInDependencies, + ).then((fetchNodeModulesResult) => { + if (fetchNodeModulesResult.dependenciesWithError.length > 0) { + this.packagesUpdateFailed( + `Failed to download the following dependencies: ${JSON.stringify( + fetchNodeModulesResult.dependenciesWithError.map((d) => d.name), + )}`, + fetchNodeModulesResult.dependenciesWithError[0]?.name, + ) + } + this.setState({ dependencyLoadingStatus: 'not-loading' }) + this.props.editorDispatch([ + EditorActions.updateNodeModulesContents(fetchNodeModulesResult.nodeModules), + ]) + }) + + this.setState({ dependencyLoadingStatus: 'removing' }) + } + } + + packagesUpdateSuccess = (packageName: string) => { + this.props.editorDispatch([EditorActions.setPackageStatus(packageName, 'loaded')]) + this.setState((prevState) => { + const newlyLoadedItems = [...prevState.newlyLoadedItems, packageName] + return { + newlyLoadedItems, + dependencyLoadingStatus: 'not-loading', + } + }) + } + + packagesUpdateFailed = (e: any, packageName: string) => { + // TODO make this a packageNames Array + console.error(e) + this.props.editorDispatch( + [ + EditorActions.setPackageStatus(packageName, 'error'), + addToast( + notice(`${packageName} couldn't be added. Check the console for details.`, 'ERROR', true), + ), + ], + 'leftpane', + ) + this.setState((prevState) => { + return { + dependencyLoadingStatus: 'not-loading', + } + }) + } + + packagesUpdateNotFound = (packageName: string) => { + this.props.editorDispatch( + [EditorActions.setPackageStatus(packageName, 'not-found')], + 'leftpane', + ) + this.setState((prevState) => { + return { + dependencyLoadingStatus: 'not-loading', + } + }) + } + + packageVersionLookup = ( + packageName: string, + version: string | undefined, + ): Promise => { + if (version == null || version === '') { + return this.latestPackageVersionLookup(packageName).then(unwrapLookupResult) + } else { + return checkPackageVersionExists(packageName, version).then((exists) => { + return exists ? version : null + }) + } + } + + latestPackageVersionLookup = (packageName: string): Promise => { + this.props.editorDispatch( + [EditorActions.setPackageStatus(packageName, 'version-lookup')], + 'leftpane', + ) + this.setState((prevState) => { + return { + dependencyLoadingStatus: 'adding', + } + }) + return findLatestVersion(packageName) + } + + addDependency = (packageName: string | null, packageVersion: string | null) => { + this.setState({ + dependencyBeingEdited: null, + }) + + if (packageName !== null) { + this.props.editorDispatch( + [addToast(notice(`Adding ${packageName} to your project.`, 'INFO'))], + 'leftpane', + ) + } + + if (DefaultPackagesList.some((pkg) => pkg.name === packageName)) { + this.props.editorDispatch( + [ + addToast( + notice( + `${packageName} is already available as a default package, no need to add it again :)`, + 'SUCCESS', + ), + ), + ], + 'leftpane', + ) + } else if (packageName !== '' && packageName !== null) { + const lowerCasePackageName = packageName.toLowerCase() + + const trimmedPackageVersion = packageVersion?.trim() + const dependencyBeingEdited = this.state.dependencyBeingEdited + const editedPackageName = lowerCasePackageName + + const packageAlreadyExists = this.props.packageStatus[editedPackageName] != null + const loadingOrUpdating = packageAlreadyExists ? 'updating' : 'loading' + + const packageNameAndVersion = + trimmedPackageVersion == null || trimmedPackageVersion === '' + ? lowerCasePackageName + : `${lowerCasePackageName}@${trimmedPackageVersion}` + + const editedPackageVersionPromise = this.packageVersionLookup( + lowerCasePackageName, + trimmedPackageVersion, + ) + editedPackageVersionPromise + .then((editedPackageVersion) => { + if (editedPackageVersion == null) { + this.props.editorDispatch( + [ + addToast( + notice(`No npm registry entry found for ${packageNameAndVersion}`, 'ERROR'), + ), + ], + 'leftpane', + ) + + this.packagesUpdateNotFound(editedPackageName) + } else { + this.setState((prevState) => { + const currentNpmDeps = dependenciesFromPackageJson( + this.props.packageJsonFile, + 'regular-only', + ) + const npmDepsWithoutCurrentDep = currentNpmDeps.filter( + (p) => p.name !== editedPackageName && p.name !== dependencyBeingEdited, + ) + const updatedNpmDeps = [ + ...npmDepsWithoutCurrentDep, + requestedNpmDependency(editedPackageName, editedPackageVersion!), + ] + + this.props.editorDispatch([ + EditorActions.setPackageStatus(editedPackageName, loadingOrUpdating), + EditorActions.updatePackageJson(updatedNpmDeps), + ]) + fetchNodeModules( + this.props.editorDispatch, + [requestedNpmDependency(editedPackageName, editedPackageVersion!)], + this.props.builtInDependencies, + ) + .then((fetchNodeModulesResult) => { + if (fetchNodeModulesResult.dependenciesWithError.length > 0) { + this.packagesUpdateFailed( + `Failed to download the following dependencies: ${JSON.stringify( + fetchNodeModulesResult.dependenciesWithError.map((d) => d.name), + )}`, + editedPackageName, + ) + } else { + this.packagesUpdateSuccess(editedPackageName) + this.props.editorDispatch([ + EditorActions.updateNodeModulesContents(fetchNodeModulesResult.nodeModules), + ]) + } + }) + .catch((e) => this.packagesUpdateFailed(e, editedPackageName)) + + return { + dependencyLoadingStatus: 'adding', + } + }) + } + }) + .catch((reason) => { + this.props.editorDispatch( + [addToast(notice(`Couldn't fetch metadata for ${packageNameAndVersion}`, 'ERROR'))], + 'leftpane', + ) + console.error('Reason for failing to locate the latest version.', reason) + this.packagesUpdateFailed(reason, editedPackageName) + }) + } + this.setState({ + dependencyBeingEdited: null, + }) + } + + onFocus = (e: React.FocusEvent) => { + if (this.props.focusedPanel !== 'dependencylist') { + this.props.editorDispatch([setFocus('dependencylist')], 'everyone') + } + if ((e.target as any).id === this.DependencyListContainerId) { + this.props.editorDispatch([clearSelection()], 'everyone') + } + } + + updateDependencyToLatestVersion = (dependencyName: string) => { + this.addDependency(dependencyName, null) + } + + render() { + const packagesWithStatus: Array = packageDetailsFromDependencies( + dependenciesFromPackageJson(this.props.packageJsonFile, 'regular-only'), + this.props.packageStatus, + ) + + const loadingPackages = + packagesWithStatus.filter((dependency) => dependency.status === 'loading') ?? [] + let statusNode: React.ReactNode + if (this.state.dependencyLoadingStatus === 'adding' && loadingPackages.length > 0) { + statusNode = ( + + {`(loading ${loadingPackages.length} new…)`} + + ) + } else if (this.state.dependencyLoadingStatus === 'removing') { + statusNode = ( + + {`(removing…)`} + + ) + } + + return ( +
+ + + + Dependencies + {statusNode != null ? '\u00A0' : null} + {statusNode} + + + + + {!this.props.minimised ? ( + + + + + ) : null} + +
+ ) + } +} + +interface AddTailwindButtonProps { + packagesWithStatus: DependencyPackageDetails[] +} + +const AddTailwindButton = (props: AddTailwindButtonProps) => { + const dispatch = useDispatch() + const onButtonClicked = React.useCallback(() => { + dispatch([EditorActions.addTailwindConfig()]) + }, [dispatch]) + + const tailwindAlreadyAdded = + props.packagesWithStatus.some((p) => p.name === 'tailwindcss') && + props.packagesWithStatus.some((p) => p.name === 'postcss') + if (tailwindAlreadyAdded) { + return null + } + return ( + + ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/font-family-item.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/font-family-item.tsx new file mode 100644 index 000000000000..845d526e92cb --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/font-family-item.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { FlexRow, Icons } from '../../../uuiui' +import type { FontFamilyData } from './google-fonts-utils' + +interface FontFamilyItem { + style: React.CSSProperties + data: FontFamilyData + isOpen: boolean + toggle: () => void +} + +export const FontFamilyItem: React.FunctionComponent> = ({ + style, + data, + isOpen, + toggle, +}) => { + return ( + +
+ {isOpen ? : } +
+
{data.familyName}
+
+ ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/font-variant-item.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/font-variant-item.tsx new file mode 100644 index 000000000000..756629389a96 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/font-variant-item.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { FlexRow, Icons } from '../../../uuiui' +import type { FontVariantData } from './google-fonts-utils' +import { prettyNameForFontVariant } from './google-fonts-utils' +import type { + PushNewFontFamilyVariant, + RemoveFontFamilyVariant, +} from './google-fonts-resources-list-search' + +interface FontVariantItemProps { + style: React.CSSProperties + data: FontVariantData & { + pushNewFontFamilyVariant: PushNewFontFamilyVariant + removeFontFamilyVariant: RemoveFontFamilyVariant + } +} + +export const FontVariantItem: React.FunctionComponent< + React.PropsWithChildren +> = ({ style, data }) => { + const [hovered, setHovered] = React.useState(false) + const onMouseEnter = React.useCallback(() => setHovered(true), []) + const onMouseLeave = React.useCallback(() => setHovered(false), []) + + const { pushNewFontFamilyVariant, removeFontFamilyVariant, variant } = data + + const onDownloadedClick = React.useCallback( + () => removeFontFamilyVariant(variant), + [removeFontFamilyVariant, variant], + ) + const onDownloadClick = React.useCallback( + () => pushNewFontFamilyVariant(variant), + [pushNewFontFamilyVariant, variant], + ) + return ( + +
+ {prettyNameForFontVariant(data.variant.fontVariant)} +
+ {data.isDownloaded ? ( + hovered ? ( + + ) : ( + + ) + ) : ( + + )} +
+ ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-input.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-input.tsx new file mode 100644 index 000000000000..2307bb5e24f0 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-input.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { FlexRow, FunctionIcons, StringInput } from '../../../uuiui' +import { UIGridRow } from '../../inspector/widgets/ui-grid-row' +import { ResourcesListGridRowConfig } from './generic-external-resources-list' + +interface MultiStringControlProps { + hrefValueToEdit?: string + relValueToEdit?: string + closeField: () => void + onSubmitValues: (values: { hrefValue: string; relValue: string }) => void +} + +export const GenericExternalResourcesInput = React.memo( + ({ hrefValueToEdit, relValueToEdit, onSubmitValues, closeField }: MultiStringControlProps) => { + const [hrefValue, setHrefValue] = React.useState(hrefValueToEdit ?? '') + const [relValue, setRelValue] = React.useState(relValueToEdit ?? '') + + const onHrefValueChange = React.useCallback((e: React.ChangeEvent) => { + setHrefValue(e.target.value) + }, []) + const onRelValueChange = React.useCallback((e: React.ChangeEvent) => { + setRelValue(e.target.value) + }, []) + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + onSubmitValues({ hrefValue, relValue }) + closeField() + } else if (e.key === 'Escape') { + e.preventDefault() + closeField() + } + } + + function onConfirmClick() { + onSubmitValues({ hrefValue, relValue }) + closeField() + } + + const hrefInputRef = React.useRef(null) + React.useEffect(() => { + if (hrefInputRef.current != null) { + hrefInputRef.current.focus() + } + }, []) + + return ( + + + + + + + + + ) + }, +) diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-list-item.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-list-item.tsx new file mode 100644 index 000000000000..7e38901f94e3 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-list-item.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import type { + GenericExternalResource, + ExternalResources, +} from '../../../printer-parsers/html/external-resources-parser' +import { useExternalResources } from '../../../printer-parsers/html/external-resources-parser' +import { MenuProvider, ContextMenu } from '../../../uuiui-deps' +import { UIGridRow } from '../../inspector/widgets/ui-grid-row' +import { ResourcesListGridRowConfig } from './generic-external-resources-list' +import type { ContextMenuItem } from '../../context-menu-items' +import { NO_OP } from '../../../core/shared/utils' + +interface GenericExternalResourcesListItemProps { + value: GenericExternalResource + index: number + setEditingIndex: React.Dispatch +} + +function indexedUpdateDeleteGenericResource( + index: number, + oldValue: ExternalResources, +): ExternalResources { + const workingResources = [...oldValue.genericExternalResources] + workingResources.splice(index, 1) + return { + ...oldValue, + genericExternalResources: workingResources, + } +} + +export const GenericExternalResourcesListItem = React.memo( + ({ value, index, setEditingIndex }) => { + const { useSubmitValueFactory } = useExternalResources() + const [deleteResource] = useSubmitValueFactory(indexedUpdateDeleteGenericResource) + + const onDoubleClick = React.useCallback(() => { + setEditingIndex(index) + }, [index, setEditingIndex]) + + const menuId = `generic-external-resources-list-item-contextmenu-${index}` + const menuItems: Array> = [ + { + name: 'Delete', + enabled: true, + action: () => { + deleteResource(index) + }, + }, + { + name: 'Rename', + enabled: true, + action: () => { + setEditingIndex(index) + }, + }, + ] + + return ( + + +
+ {value.href} +
+
+ {value.rel} +
+ +
+
+ ) + }, +) diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-list.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-list.tsx new file mode 100644 index 000000000000..33c52b65d231 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/generic-external-resources-list.tsx @@ -0,0 +1,152 @@ +import React from 'react' +import { setFocus } from '../../../components/common/actions' +import { isRight } from '../../../core/shared/either' +import type { ExternalResources } from '../../../printer-parsers/html/external-resources-parser' +import { + genericExternalResource, + useExternalResources, +} from '../../../printer-parsers/html/external-resources-parser' +import { + SectionTitleRow, + FlexRow, + Title, + SquareButton, + FunctionIcons, + SectionBodyArea, +} from '../../../uuiui' +import { clearSelection, togglePanel } from '../../editor/actions/action-creators' +import { useDispatch } from '../../editor/store/dispatch-context' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import type { GridRowProps } from '../../inspector/widgets/ui-grid-row' +import { GenericExternalResourcesInput } from './generic-external-resources-input' +import { GenericExternalResourcesListItem } from './generic-external-resources-list-item' + +export const ResourcesListGridRowConfig: GridRowProps = { + padded: false, + variant: '<-------auto------->|---60px---|', +} + +function updatePushNewGenericResource( + { hrefValue, relValue }: { hrefValue: string; relValue: string }, + oldValue: ExternalResources, +): ExternalResources { + const working = { ...oldValue } + working.genericExternalResources = [ + ...oldValue.genericExternalResources, + genericExternalResource(hrefValue, relValue), + ] + return working +} + +function getIndexedUpdateGenericResource(index: number) { + return function ( + { hrefValue, relValue }: { hrefValue: string; relValue: string }, + oldValue: ExternalResources, + ): ExternalResources { + const working = { ...oldValue } + working.genericExternalResources[index] = genericExternalResource(hrefValue, relValue) + return working + } +} + +export const GenericExternalResourcesList = React.memo(() => { + const { values, useSubmitValueFactory } = useExternalResources() + + const [editingIndexOrInserting, setEditingIndexOrInserting] = React.useState< + null | number | 'insert-new' + >(null) + + const closeInsertAndEditingFields = React.useCallback(() => { + setEditingIndexOrInserting(null) + }, []) + + const dispatch = useDispatch() + + const { minimised, focusedPanel } = useEditorState( + Substores.restOfEditor, + (store) => { + return { + minimised: store.editor.genericExternalResources.minimised, + focusedPanel: store.editor.focusedPanel, + } + }, + 'GenericExternalResourcesList', + ) + + const toggleMinimised = React.useCallback(() => { + dispatch([togglePanel('genericExternalResources')], 'leftpane') + }, [dispatch]) + + const onFocus = React.useCallback( + (e: React.FocusEvent) => { + dispatch([clearSelection()], 'everyone') + if (focusedPanel !== 'dependencylist') { + dispatch([setFocus('dependencylist')], 'everyone') + } + }, + [focusedPanel, dispatch], + ) + + const toggleOpenAddInsertField = React.useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setEditingIndexOrInserting((value) => (value === 'insert-new' ? null : 'insert-new')) + }, []) + + const [pushNewGenericResource] = useSubmitValueFactory(updatePushNewGenericResource) + const [indexedUpdateGenericResource] = useSubmitValueFactory( + getIndexedUpdateGenericResource( + typeof editingIndexOrInserting === 'number' ? editingIndexOrInserting : 0, + ), + ) + + return ( +
+ + + External Resources + + {minimised ? null : ( + + + + )} + + {minimised ? null : ( + + {isRight(values) ? ( + values.value.genericExternalResources.map((value, i) => + editingIndexOrInserting === i ? ( + + ) : ( + + ), + ) + ) : ( +
{values.value.description}
+ )} + {editingIndexOrInserting === 'insert-new' ? ( + + ) : null} +
+ )} +
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list-item.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list-item.tsx new file mode 100644 index 000000000000..cc9f07c1580e --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list-item.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import type { GoogleFontsResource } from '../../../printer-parsers/html/external-resources-parser' +import { UIGridRow } from '../../inspector/widgets/ui-grid-row' + +interface GoogleFontsResourcesListItemProps { + value: GoogleFontsResource +} + +export const GoogleFontsResourcesListItem = React.memo( + ({ value }) => { + return ( + +
+ {value.fontFamily} +
+
+ 400 +
+
+ ) + }, +) diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list-search.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list-search.tsx new file mode 100644 index 000000000000..3a4084c89e24 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list-search.tsx @@ -0,0 +1,228 @@ +import React from 'react' +import { FixedSizeTree } from 'react-vtree' +import type { TreeWalker } from 'react-vtree/dist/es/Tree' +import { googleFontsList } from '../../../../assets/google-fonts-list' +import type { + ExternalResources, + GoogleFontsResource, +} from '../../../printer-parsers/html/external-resources-parser' +import { googleFontsResource } from '../../../printer-parsers/html/external-resources-parser' +import { StringInput } from '../../../uuiui' +import { Utils } from '../../../uuiui-deps' +import type { UseSubmitValueFactory } from '../../inspector/common/property-path-hooks' +import type { + FontFamilyData, + FontNode, + FontsRoot, + FontVariantData, + WebFontFamilyVariant, +} from './google-fonts-utils' +import { fontFamilyData, fontVariantData, webFontFamilyVariant } from './google-fonts-utils' +import { GoogleFontsListItem } from './google-fonts-variant-list-item' + +interface GoogleFontsResourcesListSearchProps { + linkedResources: Array + useSubmitValueFactory: UseSubmitValueFactory +} + +export type PushNewFontFamilyVariant = (newValue: WebFontFamilyVariant) => void +export type RemoveFontFamilyVariant = (valueToDelete: WebFontFamilyVariant) => void + +export function updatePushNewFontFamilyVariant( + newValue: WebFontFamilyVariant, + oldValue: ExternalResources, +): ExternalResources { + return { + ...oldValue, + googleFontsResources: (() => { + let workingGoogleFontsResources = [...oldValue.googleFontsResources] + const existingFamilyResourceIndex = workingGoogleFontsResources.findIndex( + (resource) => resource.fontFamily === newValue.familyName, + ) + if (existingFamilyResourceIndex > -1) { + const variantIsAlreadyAdded = + oldValue.googleFontsResources[existingFamilyResourceIndex].variants.findIndex( + (value) => + value.webFontStyle === newValue.fontVariant.webFontStyle && + value.webFontWeight === newValue.fontVariant.webFontWeight, + ) > -1 + if (!variantIsAlreadyAdded) + workingGoogleFontsResources[existingFamilyResourceIndex] = { + ...oldValue.googleFontsResources[existingFamilyResourceIndex], + variants: (() => { + let workingVariants = [ + ...oldValue.googleFontsResources[existingFamilyResourceIndex].variants, + ] + workingVariants.push(newValue.fontVariant) + return workingVariants + })(), + } + } else { + workingGoogleFontsResources.push( + googleFontsResource(newValue.familyName, [newValue.fontVariant]), + ) + } + return workingGoogleFontsResources + })(), + } +} + +export function updateRemoveFontFamilyVariant( + valueToDelete: WebFontFamilyVariant, + oldValue: ExternalResources, +): ExternalResources { + return { + ...oldValue, + googleFontsResources: (() => { + let workingGoogleFontsResources = [...oldValue.googleFontsResources] + const familyIndex = workingGoogleFontsResources.findIndex( + (resource) => resource.fontFamily === valueToDelete.familyName, + ) + if (familyIndex > -1) { + let workingGoogleFontsResource = workingGoogleFontsResources[familyIndex] + let workingVariants = [...workingGoogleFontsResource.variants] + const variantIndex = workingVariants.findIndex( + (variant) => + variant.webFontStyle === valueToDelete.fontVariant.webFontStyle && + variant.webFontWeight === valueToDelete.fontVariant.webFontWeight, + ) + if (variantIndex > -1) { + if (workingVariants.length > 1) { + workingVariants.splice(variantIndex, 1) + workingGoogleFontsResource.variants = workingVariants + workingGoogleFontsResources[familyIndex] = workingGoogleFontsResource + } else { + workingGoogleFontsResources.splice(familyIndex, 1) + } + } + } + return workingGoogleFontsResources + })(), + } +} + +export const GoogleFontsResourcesListItemHeight = 26 + +export const GoogleFontsResourcesListSearch = React.memo( + ({ linkedResources, useSubmitValueFactory }) => { + const [searchTerm, setSearchTerm] = React.useState('') + const lowerCaseSearchTerm = searchTerm.toLowerCase() + const [pushNewFontFamilyVariant] = useSubmitValueFactory(updatePushNewFontFamilyVariant) + const [removeFontFamilyVariant] = useSubmitValueFactory(updateRemoveFontFamilyVariant) + + const tree = React.useMemo( + () => ({ + id: 'root', + type: 'root', + isOpenByDefault: true, + children: Utils.stripNulls( + googleFontsList.map((fontDatum) => { + const linkedVariants = linkedResources.find( + (resource) => resource.fontFamily === fontDatum.name, + ) + return fontFamilyData( + fontDatum.name, + fontDatum.variants.map((variant) => { + const isDownloaded = + (linkedVariants?.variants.findIndex( + (linkedVariant) => + linkedVariant.webFontStyle === variant.webFontStyle && + linkedVariant.webFontWeight === variant.webFontWeight, + ) ?? -1) >= 0 + return fontVariantData( + webFontFamilyVariant(fontDatum.name, variant), + isDownloaded, + pushNewFontFamilyVariant, + removeFontFamilyVariant, + ) + }), + ) + }), + ), + }), + [linkedResources, pushNewFontFamilyVariant, removeFontFamilyVariant], + ) + + const treeWalker: TreeWalker = React.useCallback( + function* (refresh: boolean) { + const stack = [] + // Remember all the necessary data of the first node in the stack. + stack.push({ + nestingLevel: 0, + node: tree, + }) + + // Walk through the tree until we have no nodes available. + while (stack.length !== 0) { + const { node, nestingLevel } = stack.pop() as any + const { id, type, familyName, variant, isOpenByDefault, isDownloaded } = node + let children: Array = node.children ?? [] + if (type === 'root') { + children = (children as Array).filter((child) => + child.familyName.toLowerCase().includes(lowerCaseSearchTerm), + ) + } + + // Here we are sending the information about the node to the Tree component + // and receive an information about the openness state from it. The + // `refresh` parameter tells us if the full update of the tree is requested; + // basing on it we decide to return the full node data or only the node + // id to update the nodes order. + const isOpened = yield refresh + ? { + id, + children, + isLeaf: children.length === 0, + isOpenByDefault, + nestingLevel, + type, + familyName, + variant, + isDownloaded, + pushNewFontFamilyVariant, + removeFontFamilyVariant, + } + : id + + // Basing on the node openness state we are deciding if we need to render + // the child nodes (if they exist). + if (children.length !== 0 && isOpened) { + // Since it is a stack structure, we need to put nodes we want to render + // first to the end of the stack. + for (let i = children.length - 1; i >= 0; i--) { + stack.push({ + nestingLevel: nestingLevel + 1, + node: children[i], + }) + } + } + } + }, + [pushNewFontFamilyVariant, removeFontFamilyVariant, tree, lowerCaseSearchTerm], + ) + + const onChange = React.useCallback( + (e: React.ChangeEvent) => setSearchTerm(e.target.value), + [], + ) + + return ( +
+ + + {GoogleFontsListItem} + +
+ ) + }, +) diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list.tsx new file mode 100644 index 000000000000..9aaf2bdc0e39 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-resources-list.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { setFocus } from '../../../components/common/actions' +import { isRight } from '../../../core/shared/either' +import { useExternalResources } from '../../../printer-parsers/html/external-resources-parser' +import { SectionTitleRow, FlexRow, Title, SectionBodyArea } from '../../../uuiui' +import { clearSelection, togglePanel } from '../../editor/actions/action-creators' +import { useDispatch } from '../../editor/store/dispatch-context' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { GoogleFontsResourcesListSearch } from './google-fonts-resources-list-search' + +export const GoogleFontsResourcesList = React.memo(() => { + const { values, useSubmitValueFactory } = useExternalResources() + const dispatch = useDispatch() + const { minimised, focusedPanel } = useEditorState( + Substores.restOfEditor, + (store) => { + return { + minimised: store.editor.googleFontsResources.minimised, + focusedPanel: store.editor.focusedPanel, + } + }, + 'GoogleFontsResourcesList', + ) + + const toggleMinimised = React.useCallback(() => { + dispatch([togglePanel('googleFontsResources')], 'leftpane') + }, [dispatch]) + + const onFocus = React.useCallback( + (e: React.FocusEvent) => { + dispatch([clearSelection()], 'everyone') + if (focusedPanel !== 'googleFontsResources') { + dispatch([setFocus('googleFontsResources')], 'leftpane') + } + }, + [focusedPanel, dispatch], + ) + + return ( +
+ + + Google Fonts + + + {minimised ? null : ( + + + + )} +
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-utils.ts b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-utils.ts new file mode 100644 index 000000000000..d57c5f28f11f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-utils.ts @@ -0,0 +1,239 @@ +import type { NodeData } from 'react-vtree/dist/es/Tree' +import { GOOGLE_WEB_FONTS_KEY } from '../../../common/env-vars' +import type { Either } from '../../../core/shared/either' +import { left, right } from '../../../core/shared/either' +import type { CSSFontStyle, CSSFontWeight } from '../../inspector/common/css-utils' +import type { + PushNewFontFamilyVariant, + RemoveFontFamilyVariant, +} from './google-fonts-resources-list-search' + +export const GoogleWebFontsURL = `https://www.googleapis.com/webfonts/v1/webfonts?key=${GOOGLE_WEB_FONTS_KEY}` + +export type WebFontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 + +export function cssFontWeightToWebFontWeight(value: CSSFontWeight): Either { + switch (value) { + case 'normal': { + return right(400) + } + case 'bold': { + return right(600) + } + case 100: + case 200: + case 300: + case 400: + case 500: + case 600: + case 700: + case 800: + case 900: { + return right(value) + } + default: { + return left('Variable width webfonts from Google are not supported yet.') + } + } +} + +export function cssFontStyleToWebFontStyle(value: CSSFontStyle): Either { + switch (value) { + case 'normal': + case 'italic': { + return right(value) + } + case 'oblique': { + return right('normal') + } + default: { + return left('Variable italic webfonts from Google are not supported yet.') + } + } +} + +type WebFontStyle = 'normal' | 'italic' +export function isFontVariantWeight(value: number): value is WebFontWeight { + return weightAxisPrettyNames.hasOwnProperty(value) +} +const weightAxisPrettyNames: { [key in WebFontWeight]: string } = { + 100: 'Thin', + 200: 'Extra-light', + 300: 'Light', + 400: 'Regular', + 500: 'Medium', + 600: 'Semi-bold', + 700: 'Bold', + 800: 'Extra-bold', + 900: 'Black', +} + +export const defaultWebFontWeightsAndStyles: Array = [ + webFontVariant(100, 'normal'), + webFontVariant(100, 'italic'), + webFontVariant(200, 'normal'), + webFontVariant(200, 'italic'), + webFontVariant(300, 'normal'), + webFontVariant(300, 'italic'), + webFontVariant(400, 'normal'), + webFontVariant(400, 'italic'), + webFontVariant(500, 'normal'), + webFontVariant(500, 'italic'), + webFontVariant(600, 'normal'), + webFontVariant(600, 'italic'), + webFontVariant(700, 'normal'), + webFontVariant(700, 'italic'), + webFontVariant(800, 'normal'), + webFontVariant(800, 'italic'), + webFontVariant(900, 'normal'), + webFontVariant(900, 'italic'), +] + +export interface WebFontFamily { + type: 'web-font-family' + familyName: string + variants: Array +} + +export function webFontFamily(familyName: string, variants: Array): WebFontFamily { + return { + type: 'web-font-family', + familyName, + variants, + } +} + +export interface WebFontFamilyVariant { + type: 'web-font-family-variant' + familyName: string + fontVariant: WebFontVariant +} + +export function webFontFamilyVariant( + familyName: string, + fontVariant: WebFontVariant, +): WebFontFamilyVariant { + return { + type: 'web-font-family-variant', + familyName, + fontVariant, + } +} + +export interface WebFontVariant { + type: 'web-font-variant' + webFontWeight: WebFontWeight + webFontStyle: WebFontStyle +} + +export function webFontVariant( + webFontWeight: WebFontWeight, + webFontStyle: WebFontStyle, +): WebFontVariant { + return { + type: 'web-font-variant', + webFontWeight, + webFontStyle, + } +} + +export function prettyNameForFontVariant(value: WebFontVariant): string { + const prettyWeightName = weightAxisPrettyNames[value.webFontWeight] + const italicKeyword = value.webFontStyle === 'italic' ? ' italic' : '' + return prettyWeightName + italicKeyword +} + +export interface GoogleFontFamilyOption { + label: string + variants: Array +} + +export function googleFontFamilyOption( + label: string, + variants: Array, +): GoogleFontFamilyOption { + return { + label, + variants, + } +} + +export interface FontsRoot extends NodeData { + type: 'root' + children: Array +} + +export interface FontFamilyData extends NodeData { + type: 'font-family' + familyName: string + children: Array +} + +export function fontFamilyData( + familyName: string, + children: Array, +): FontFamilyData { + return { + type: 'font-family', + id: familyName, + familyName, + children, + isOpenByDefault: false, + } +} + +export interface FontVariantData extends NodeData { + type: 'font-variant' + variant: WebFontFamilyVariant + isDownloaded: boolean + pushNewFontFamilyVariant: PushNewFontFamilyVariant + removeFontFamilyVariant: RemoveFontFamilyVariant +} +export function fontVariantData( + variant: WebFontFamilyVariant, + isDownloaded: boolean, + pushNewFontFamilyVariant: PushNewFontFamilyVariant, + removeFontFamilyVariant: RemoveFontFamilyVariant, +): FontVariantData { + return { + type: 'font-variant', + id: fontVariantID(variant), + variant, + isOpenByDefault: false, + isDownloaded, + pushNewFontFamilyVariant, + removeFontFamilyVariant, + } +} + +export function fontVariantID(variant: WebFontFamilyVariant): string { + return `${variant.familyName} ${prettyNameForFontVariant(variant.fontVariant)}` +} + +export type FontNode = FontsRoot | FontFamilyData | FontVariantData + +export interface SystemDefaultTypeface { + type: 'system-default-typeface' + name: 'San Francisco, SF UI, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"' +} +export const systemDefaultTypeface: SystemDefaultTypeface = { + type: 'system-default-typeface', + name: 'San Francisco, SF UI, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', +} + +export interface GoogleFontsTypeface { + type: 'google-fonts-typeface' + name: string + variants: Array +} + +export function googleFontsTypeface( + name: string, + variants: Array, +): GoogleFontsTypeface { + return { + type: 'google-fonts-typeface', + name, + variants, + } +} diff --git a/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-variant-list-item.tsx b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-variant-list-item.tsx new file mode 100644 index 000000000000..34598c34b660 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/external-resources/google-fonts-variant-list-item.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import type { NodeComponentProps } from 'react-vtree/dist/es/Tree' +import { FontFamilyItem } from './font-family-item' +import { FontVariantItem } from './font-variant-item' +import { GoogleFontsResourcesListItemHeight } from './google-fonts-resources-list-search' +import type { FontNode } from './google-fonts-utils' + +interface GoogleFontsListItemProps extends NodeComponentProps {} + +export const GoogleFontsListItem: React.FunctionComponent< + React.PropsWithChildren +> = (props) => { + if (props.data.type === 'font-family') { + return ( + + ) + } else if (props.data.type === 'font-variant') { + return ( + + ) + } else { + return null + } +} diff --git a/nexus-builder/packages/navigator/src/navigator/layout-element-icons.ts b/nexus-builder/packages/navigator/src/navigator/layout-element-icons.ts new file mode 100644 index 000000000000..a2b033958f13 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/layout-element-icons.ts @@ -0,0 +1,585 @@ +import { MetadataUtils } from '../../core/model/element-metadata-utils' +import { + type FilePathMappings, + isAnimatedElement, + isImg, + isImportedComponent, +} from '../../core/model/project-file-utils' +import type { + ElementInstanceMetadataMap, + ElementInstanceMetadata, + JSXElementChild, +} from '../../core/shared/element-template' +import { isJSXElement, isJSXAttributeValue } from '../../core/shared/element-template' +import * as EP from '../../core/shared/element-path' +import type { ElementPath } from '../../core/shared/project-file-types' +import { Substores, useEditorState, useRefEditorState } from '../editor/store/store-hook' +import { isLeft, isRight, maybeEitherToMaybe } from '../../core/shared/either' +import type { IcnPropsBase } from '../../uuiui' +import { shallowEqual } from '../../core/shared/equality-utils' +import type { AllElementProps, NavigatorEntry } from '../editor/store/editor-state' +import { isRegularNavigatorEntry, isSyntheticNavigatorEntry } from '../editor/store/editor-state' +import { getElementFragmentLikeType } from '../canvas/canvas-strategies/strategies/fragment-like-helpers' +import { findMaybeConditionalExpression } from '../../core/model/conditionals' +import type { ElementPathTrees } from '../../core/shared/element-path-tree' +import { treatElementAsGroupLike } from '../canvas/canvas-strategies/strategies/group-helpers' +import type { PropertyControlsInfo } from '../custom-code/code-file' +import type { ProjectContentTreeRoot } from '../assets' + +interface LayoutIconResult { + iconProps: IcnPropsBase + isPositionAbsolute: boolean +} + +export function useLayoutOrElementIcon(navigatorEntry: NavigatorEntry): LayoutIconResult { + return useEditorState( + Substores.metadata, + (store) => { + const metadata = store.editor.jsxMetadata + const pathTrees = store.editor.elementPathTree + return createElementIconPropsFromMetadata( + navigatorEntry.elementPath, + metadata, + pathTrees, + navigatorEntry, + store.editor.allElementProps, + ) + }, + 'useLayoutOrElementIcon', + (oldResult: LayoutIconResult, newResult: LayoutIconResult) => { + return ( + oldResult.isPositionAbsolute === newResult.isPositionAbsolute && + shallowEqual(oldResult.iconProps, newResult.iconProps) + ) + }, + ) +} + +export function useComponentIcon(navigatorEntry: NavigatorEntry): IcnPropsBase | null { + const autoFocusedPaths = useEditorState( + Substores.derived, + (store) => store.derived.autoFocusedPaths, + 'useComponentIcon autoFocusedPaths', + ) + const filePathMappings = useEditorState( + Substores.derived, + (store) => store.derived.filePathMappings, + 'useComponentIcon filePathMappings', + ) + + return useEditorState( + Substores.fullStore, + (store) => { + const metadata = store.editor.jsxMetadata + return createComponentIconProps( + navigatorEntry.elementPath, + metadata, + autoFocusedPaths, + filePathMappings, + store.editor.propertyControlsInfo, + store.editor.projectContents, + ) + }, + 'useComponentIcon', + ) // TODO Memoize Icon Result +} + +export function createComponentOrElementIconProps( + elementPath: ElementPath, + metadata: ElementInstanceMetadataMap, + pathTrees: ElementPathTrees, + autoFocusedPaths: Array, + navigatorEntry: NavigatorEntry | null, + allElementProps: AllElementProps, + filePathMappings: FilePathMappings, + propertyControlsInfo: PropertyControlsInfo, + projectContents: ProjectContentTreeRoot, +): IcnPropsBase { + return ( + createComponentIconProps( + elementPath, + metadata, + autoFocusedPaths, + filePathMappings, + propertyControlsInfo, + projectContents, + ) ?? + createElementIconPropsFromMetadata( + elementPath, + metadata, + pathTrees, + navigatorEntry, + allElementProps, + ).iconProps + ) +} + +function isConditionalBranchText( + navigatorEntry: NavigatorEntry | null, + metadata: ElementInstanceMetadataMap, +): boolean { + function getNavigatorEntryConditionalElementOrNull(): JSXElementChild | null { + if (navigatorEntry == null) { + return null + } + + if (isSyntheticNavigatorEntry(navigatorEntry)) { + return navigatorEntry.childOrAttribute + } + + if (isRegularNavigatorEntry(navigatorEntry)) { + const parent = EP.parentPath(navigatorEntry.elementPath) + const conditional = findMaybeConditionalExpression(parent, metadata) + if (conditional == null) { + return null + } + const original = MetadataUtils.findElementByElementPath(metadata, navigatorEntry.elementPath) + if (original == null || isLeft(original.element)) { + return null + } + return original.element.value + } + + return null + } + + const element = getNavigatorEntryConditionalElementOrNull() + return element != null && isJSXAttributeValue(element) && typeof element.value === 'string' +} + +export function createElementIconPropsFromMetadata( + elementPath: ElementPath, + metadata: ElementInstanceMetadataMap, + pathTrees: ElementPathTrees, + navigatorEntry: NavigatorEntry | null, + allElementProps: AllElementProps, +): LayoutIconResult { + let isPositionAbsolute: boolean = false + const element = MetadataUtils.findElementByElementPath(metadata, elementPath) + const elementProps = allElementProps[EP.toString(elementPath)] + + if (element != null && elementProps != null && elementProps.style != null) { + isPositionAbsolute = elementProps.style['position'] === 'absolute' + } + + if (treatElementAsGroupLike(metadata, elementPath)) { + return { + iconProps: { + category: 'element', + type: 'group-closed', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + if (MetadataUtils.isConditionalFromMetadata(element)) { + return { + iconProps: { + category: 'element', + type: 'conditional', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isExpressionOtherJavascript = MetadataUtils.isExpressionOtherJavascriptFromMetadata(element) + if (isExpressionOtherJavascript) { + return { + iconProps: { + category: 'element', + type: 'genericcode', + width: 18, + height: 18, + }, + isPositionAbsolute: false, + } + } + + const isJSXMapExpression = MetadataUtils.isJSXMapExpressionFromMetadata(element) + if (isJSXMapExpression) { + return { + iconProps: { + category: 'element', + type: 'lists', + width: 18, + height: 18, + }, + isPositionAbsolute: false, + } + } + + const fragmentLikeType = getElementFragmentLikeType( + metadata, + allElementProps, + pathTrees, + elementPath, + ) + + if (fragmentLikeType === 'fragment') { + return { + iconProps: { + category: 'element', + type: 'fragment', + width: 18, + height: 18, + }, + + isPositionAbsolute: false, + } + } + + if (fragmentLikeType !== null) { + return { + iconProps: { + category: 'element', + type: 'group-open', + width: 18, + height: 18, + }, + + isPositionAbsolute: false, + } + } + + if ( + MetadataUtils.isProbablyScene(metadata, elementPath) || + MetadataUtils.isProbablyRemixScene(metadata, elementPath) + ) { + return { + iconProps: { + category: 'component', + type: 'scene', + width: 18, + height: 18, + }, + + isPositionAbsolute: false, + } + } + + if (MetadataUtils.isProbablyRemixOutletFromMetadata(element)) { + return { + iconProps: { + category: 'component', + type: 'remix-outlet', + width: 18, + height: 18, + }, + + isPositionAbsolute: false, + } + } + + if (MetadataUtils.isProbablyRemixLinkFromMetadata(element)) { + return { + iconProps: { + category: 'component', + type: 'remix-link', + width: 18, + height: 18, + }, + + isPositionAbsolute: false, + } + } + + if (MetadataUtils.isReactSuspense(element)) { + return { + iconProps: { + category: 'element', + type: 'div', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isHTMLElement = element != null && MetadataUtils.isHTML(element) + if (isHTMLElement) { + return { + iconProps: { + category: 'element', + type: 'html', + width: 12, + height: 12, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isBodyElement = element != null && MetadataUtils.isBody(element) + if (isBodyElement) { + return { + iconProps: { + category: 'element', + type: 'body', + width: 12, + height: 12, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isHead = element != null && MetadataUtils.isHead(element) + if (isHead) { + return { + iconProps: { + category: 'element', + type: 'folder', + width: 12, + height: 12, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isHeading = element != null && MetadataUtils.isHeading(element) + if (isHeading) { + return { + iconProps: { + category: 'element', + type: 'headline', + width: 12, + height: 12, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isButton = MetadataUtils.isButtonFromMetadata(element) + if (isButton) { + return { + iconProps: { + category: 'element', + type: 'clickable', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isInput = element != null && MetadataUtils.isInput(element) + if (isInput) { + return { + iconProps: { + category: 'element', + type: 'input', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isAnchorLink = element != null && MetadataUtils.isAnchorLink(element) + if (isAnchorLink) { + return { + iconProps: { + category: 'element', + type: 'link', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isParagraph = element != null && MetadataUtils.isParagraph(element) + if (isParagraph) { + return { + iconProps: { + category: 'element', + type: 'paragraph', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isForm = element != null && MetadataUtils.isForm(element) + if (isForm) { + return { + iconProps: { + category: 'element', + type: 'form', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isText = + MetadataUtils.isTextFromMetadata(element) || isConditionalBranchText(navigatorEntry, metadata) + if (isText) { + return { + iconProps: { + category: 'element', + type: 'pure-text', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + const elementName = MetadataUtils.getJSXElementName(maybeEitherToMaybe(element?.element)) + if (elementName != null && isImg(elementName)) { + return { + iconProps: { + category: 'element', + type: 'image', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + const isDisplayInline = element?.specialSizeMeasurements.display.includes('inline') + if ( + isDisplayInline && + element != null && + isRight(element.element) && + isJSXElement(element.element.value) + ) { + const hasTextChild = element.element.value.children.some( + (child) => child.type === 'JSX_TEXT_BLOCK', + ) + if (hasTextChild) { + return { + iconProps: { + category: 'element', + type: 'pure-text', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + } + + if (MetadataUtils.isFlexLayoutedContainer(element)) { + const flexDirection = MetadataUtils.getFlexDirection(element) + if (flexDirection === 'row' || flexDirection === 'row-reverse') { + return { + iconProps: { + category: 'layout/systems', + type: 'flex-row', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } else { + return { + iconProps: { + category: 'layout/systems', + type: 'flex-column', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + } + + if (MetadataUtils.isGridLayoutedContainer(element)) { + return { + iconProps: { + category: 'layout/systems', + type: 'grid', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } + } + + return { + iconProps: { + category: 'element', + type: 'div', + width: 18, + height: 18, + }, + isPositionAbsolute: isPositionAbsolute, + } +} + +function createComponentIconProps( + path: ElementPath, + metadata: ElementInstanceMetadataMap, + autoFocusedPaths: Array, + filePathMappings: FilePathMappings, + propertyControlsInfo: PropertyControlsInfo, + projectContents: ProjectContentTreeRoot, +): IcnPropsBase | null { + const element = MetadataUtils.findElementByElementPath(metadata, path) + if (MetadataUtils.isProbablySceneFromMetadata(element)) { + return null + } + if (element?.isEmotionOrStyledComponent) { + // todo make 12x12 version if we still support this + return { + category: 'component', + type: 'styled', + width: 18, + height: 18, + } + } + const isAnimatedComponent = isAnimatedElement(element) + if (isAnimatedComponent) { + // todo make 12x12 version if we still support this + return { + category: 'component', + type: 'animated', + width: 18, + height: 18, + } + } + const isRemixComponent = + MetadataUtils.isImportedComponentFromMetadata(element, '@remix-run/react', null) || + MetadataUtils.isProbablyRemixSceneFromMetadata(element) + if (isRemixComponent) { + return { + category: 'navigator-element', + type: 'remix', + width: 12, + height: 12, + } + } + const isImported = isImportedComponent(element, filePathMappings) + if (isImported) { + return { + category: 'component', + type: 'npm', + width: 18, + height: 18, + } + } + const isComponent = MetadataUtils.isAutomaticOrManuallyFocusableComponent( + path, + metadata, + autoFocusedPaths, + filePathMappings, + propertyControlsInfo, + projectContents, + ) + if (isComponent) { + return { + category: 'navigator-element', + type: 'component-solid', + width: 12, + height: 12, + } + } + + return null +} diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/contents-pane.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/contents-pane.tsx new file mode 100644 index 000000000000..2cc6ad8c8a73 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/contents-pane.tsx @@ -0,0 +1,36 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React from 'react' +import { FlexColumn } from '../../../uuiui' +import { FileBrowser } from '../../filebrowser/filebrowser' +import { DependencyList } from '../dependency-list' +import { GenericExternalResourcesList } from '../external-resources/generic-external-resources-list' +import { GoogleFontsResourcesList } from '../external-resources/google-fonts-resources-list' + +export const ContentsPane = React.memo(() => { + return ( +
+ + + + + + +
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/forks-given.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/forks-given.tsx new file mode 100644 index 000000000000..7c832f790152 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/forks-given.tsx @@ -0,0 +1,88 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React from 'react' +import { projectEditorURL } from '../../../common/server' +import { Avatar, Section, SectionBodyArea, Subdued, useColorTheme } from '../../../uuiui' +import { User } from '../../../uuiui-deps' +import { Link } from '../../../uuiui/link' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { UIGridRow } from '../../inspector/widgets/ui-grid-row' + +export const ForksGiven = React.memo(() => { + const colorTheme = useColorTheme() + + const isLoggedIn = useEditorState( + Substores.restOfStore, + (store) => User.isLoggedIn(store.userState.loginState), + 'ForksGiven isLoggedIn', + ) + + const { id, forkedFrom } = useEditorState( + Substores.restOfEditor, + (store) => { + return { + id: store.editor.id, + forkedFrom: store.editor.forkedFromProjectId, + } + }, + 'ForkPanel', + ) + + const projectOwnerMetadata = useEditorState( + Substores.projectServerState, + (store) => store.projectServerState.projectData, + 'ForksGiven projectOwnerMetadata', + ) + + const forkedFromMetadata = useEditorState( + Substores.projectServerState, + (store) => store.projectServerState.forkedFromProjectData, + 'ForksGiven forkedFromProjectData', + ) + + const forkedFromText = + forkedFrom == null ? null : ( + + Forked from{' '} + {forkedFromMetadata?.title ?? forkedFrom} + + ) + + return ( +
+ + +
+ +
+ +
+ Created by {projectOwnerMetadata?.ownerName ?? ''} +
+ {forkedFromText} +
+ {id} +
+
+
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/block.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/block.tsx new file mode 100644 index 000000000000..0d1cda800c1f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/block.tsx @@ -0,0 +1,183 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React from 'react' +import { when } from '../../../../utils/react-conditionals' +import { colorTheme, FlexColumn, FlexRow, UtopiaTheme } from '../../../../uuiui' +import { UIGridRow } from '../../../inspector/widgets/ui-grid-row' + +export const IndicatorLight = React.memo((props: { status: BlockStatus }) => ( +
+)) + +function getIndicatorColor(status: BlockStatus): string { + switch (status) { + case 'incomplete': + return colorTheme.githubIndicatorIncomplete.value + case 'successful': + return colorTheme.githubIndicatorSuccessful.value + case 'failed': + return colorTheme.githubIndicatorFailed.value + case 'pending': + return 'transparent' // TODO 'conic-gradient(from 180deg at 50% 50%, #2D2E33 0deg, #FFFFFF 181.87deg, #FFFFFF 360deg)' + default: + const _exhaustiveCheck: never = status + throw new Error(`invalid state ${status}`) + } +} + +export type BlockStatus = 'incomplete' | 'successful' | 'failed' | 'pending' + +export type BlockProps = { + title: string + subtitle?: string | JSX.Element + status: BlockStatus + first?: boolean + expanded: boolean + last?: boolean + children: any + onClick?: (e: React.MouseEvent) => void +} + +export const Block = React.memo((props: BlockProps) => { + const preventExpand = React.useCallback((e: React.MouseEvent) => { + e.stopPropagation() + }, []) + return ( + <> + +
+
+ +
+
+ + +
{props.title}
+
+ {props.subtitle} +
+
+
+ + {when( + props.expanded, + +
+
+
+ + + {props.children} + + + , + )} + + ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/github-file-changes-list.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/github-file-changes-list.tsx new file mode 100644 index 000000000000..3755752224b2 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/github-file-changes-list.tsx @@ -0,0 +1,313 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React from 'react' +import { WarningIcon } from '../../../../uuiui/warning-icon' +import type { + Conflict, + GithubFileChanges, + GithubFileChangesListItem, +} from '../../../../core/shared/github/helpers' +import { + getGithubFileChangesCount, + githubFileChangesToList, +} from '../../../../core/shared/github/helpers' +import { Button, colorTheme, FlexColumn, FlexRow } from '../../../../uuiui' +import * as EditorActions from '../../../editor/actions/action-creators' +import { Substores, useEditorState } from '../../../editor/store/store-hook' +import { GithubFileStatusLetter } from '../../../filebrowser/fileitem' +import { when } from '../../../../utils/react-conditionals' +import { MenuProvider, ContextMenu } from '../../../../components/context-menu-wrapper' +import { NO_OP } from '../../../../core/shared/utils' +import { useContextMenu } from 'react-contexify' +import { getConflictMenuItems } from '../../../../core/shared/github-ui' +import { UIGridRow } from '../../../../components/inspector/widgets/ui-grid-row' +import { + isGithubCommitting, + isGithubLoadingAnyBranch, +} from '../../../../components/editor/store/editor-state' +import { useDispatch } from '../../../editor/store/dispatch-context' + +export const Ellipsis: React.FC<{ + children: any + title?: string + style?: React.CSSProperties +}> = ({ children, title, style }) => { + return ( +
+ {children} +
+ ) +} + +const RevertButton = ({ + disabled, + text, + onMouseUp, +}: { + disabled: boolean + text?: string + onMouseUp: (e: React.MouseEvent) => void +}) => { + return ( + + ) +} + +const RevertIcon = () => { + return ( + + + + ) +} + +interface ConflictButtonProps { + fullPath: string + conflict: Conflict + disabled: boolean +} + +const ConflictButton = React.memo((props: ConflictButtonProps) => { + const menuId = `conflict-context-menu-${props.fullPath}` + const dispatch = useDispatch() + const githubRepo = useEditorState( + Substores.github, + (store) => { + return store.editor.githubSettings.targetRepository + }, + 'ConflictButton githubRepo', + ) + const projectID = useEditorState( + Substores.restOfEditor, + (store) => { + return store.editor.id + }, + 'ConflictButton projectID', + ) + const githubUserDetails = useEditorState( + Substores.github, + (store) => store.editor.githubData.githubUserDetails, + 'ConflictButton githubUserDetails', + ) + + const menuItems = React.useMemo(() => { + if (githubRepo != null && projectID != null && githubUserDetails != null) { + return getConflictMenuItems( + githubRepo, + projectID, + dispatch, + props.fullPath, + props.conflict, + undefined, + ) + } else { + return [] + } + }, [props.fullPath, props.conflict, dispatch, githubRepo, projectID, githubUserDetails]) + const { show } = useContextMenu({ + id: menuId, + }) + const openContextMenu = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + show({ event }) + }, + [show], + ) + return ( + + + + + ) +}) + +export const GithubFileChangesList: React.FC<{ + changes: GithubFileChanges | null + revertable: boolean + clickable: boolean + showHeader: boolean + conflicts?: string[] +}> = ({ changes, revertable, showHeader, conflicts, clickable }) => { + const count = React.useMemo(() => getGithubFileChangesCount(changes), [changes]) + const dispatch = useDispatch() + const list = React.useMemo(() => githubFileChangesToList(changes), [changes]) + const treeConflicts = useEditorState( + Substores.github, + (store) => store.editor.githubData.treeConflicts, + 'GithubFileChangesList treeConflicts', + ) + + const handleClickRevertAllFiles = React.useCallback( + (e: React.MouseEvent) => { + if (!revertable) { + return + } + e.preventDefault() + dispatch([EditorActions.showModal({ type: 'file-revert-all' })], 'everyone') + }, + [dispatch, revertable], + ) + + const handleClickRevertFile = React.useCallback( + (item: GithubFileChangesListItem) => (e: React.MouseEvent) => { + if (!revertable) { + return + } + e.preventDefault() + dispatch( + [ + EditorActions.showModal({ + type: 'file-revert', + filePath: item.filename, + status: item.status, + }), + ], + 'everyone', + ) + }, + [dispatch, revertable], + ) + + const openFile = React.useCallback( + (filename: string) => () => { + dispatch([EditorActions.openCodeEditorFile(filename, true)], 'everyone') + }, + [dispatch], + ) + + const githubOperations = useEditorState( + Substores.github, + (store) => store.editor.githubOperations, + 'Github operations', + ) + + const disableButtons = React.useMemo(() => { + return isGithubLoadingAnyBranch(githubOperations) || isGithubCommitting(githubOperations) + }, [githubOperations]) + + if (count === 0) { + return null + } + + return ( + + {when( + showHeader, +
, + )} + + {list.map((i) => { + const conflicting = conflicts?.includes(i.filename) ?? false + const isTreeConflict = i.filename in treeConflicts + return ( + + + + + {i.filename} + + + {when(conflicting, )} + {when( + revertable && !isTreeConflict, + , + )} + {when( + isTreeConflict, + , + )} + + ) + })} + + + ) +} + +const Header: React.FC<{ + count: number + revertable: boolean + disabled: boolean + onClickRevertAll: (e: React.MouseEvent) => void +}> = ({ count, revertable, disabled, onClickRevertAll }) => { + return ( + +
+ {count} file{count !== 1 ? 's' : ''} changed +
+ {when( + revertable, + , + )} +
+ ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/github-spinner.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/github-spinner.tsx new file mode 100644 index 000000000000..72a2096f2fd4 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/github-spinner.tsx @@ -0,0 +1,45 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { css, jsx, keyframes } from '@emotion/react' +import React from 'react' +import { FlexColumn } from '../../../../uuiui' + +export const GithubSpinner: React.FC<{ stroke?: string }> = ({ stroke }) => { + const anim = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + ` + + return ( + + + + + + + + + + + + + ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/helpers.ts b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/helpers.ts new file mode 100644 index 000000000000..2dce6205fd47 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/helpers.ts @@ -0,0 +1,9 @@ +export function cleanupBranchName(branchName: string): string { + return branchName + .split(/[^0-9a-z\/_.]+/i) + .join('-') + .replace(/[-.\/]+$/, '') // does not end with a dash, a slash, or a dot + .replace(/^[-.\/]+/, '') // does not start with a dash, a slash, or a dot + .replace(/\.lock$/i, '') // does not end with .lock + .replace(/\.{2,}/, '-') // does not contain two or more consecutive dots +} diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/index.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/index.tsx new file mode 100644 index 000000000000..14e09ea9f02f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/index.tsx @@ -0,0 +1,1318 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React, { useEffect } from 'react' +import TimeAgo from 'react-timeago' +import { forceNotNull } from '../../../../core/shared/optional-utils' +import { projectDependenciesSelector } from '../../../../core/shared/dependencies' +import type { GithubBranch } from '../../../../core/shared/github/helpers' +import { + dispatchPromiseActions, + getGithubFileChangesCount, + githubFileChangesToList, + refreshGithubData, + useGithubFileChanges, +} from '../../../../core/shared/github/helpers' +import { unless, when } from '../../../../utils/react-conditionals' +import { + Button, + colorTheme, + FlexColumn, + FlexRow, + Icons, + MenuIcons, + Section, + SectionTitleRow, + StringInput, + UtopiaTheme, +} from '../../../../uuiui' +import { User } from '../../../../uuiui-deps' +import * as EditorActions from '../../../editor/actions/action-creators' +import { useDispatch } from '../../../editor/store/dispatch-context' +import { + fileChecksumsWithFileToFileChecksums, + githubRepoFullName, + githubRepoOwnerName, + githubRepositoryName, + isGithubCommitting, + isGithubListingBranches, + isGithubLoadingAnyBranch, + isGithubLoadingBranch, + isGithubUpdating, + persistentModelFromEditorModel, +} from '../../../editor/store/editor-state' +import { Substores, useEditorState, useRefEditorState } from '../../../editor/store/store-hook' +import { UIGridRow } from '../../../inspector/widgets/ui-grid-row' +import { Block } from './block' +import { Ellipsis, GithubFileChangesList } from './github-file-changes-list' +import { GithubSpinner } from './github-spinner' +import { cleanupBranchName } from './helpers' +import { PullRequestPane } from './pull-request-pane' +import { RefreshIcon } from './refresh-icon' +import { RepositoryListing } from './repository-listing' +import { GithubOperations } from '../../../../core/shared/github/operations' +import { useOnClickAuthenticateWithGithub } from '../../../../utils/github-auth-hooks' +import { setFocus } from '../../../common/actions' +import { OperationContext } from '../../../../core/shared/github/operations/github-operation-context' + +const compactTimeagoFormatter = (value: number, unit: string) => { + return `${value}${unit.charAt(0)}` +} + +type IndicatorState = 'incomplete' | 'failed' | 'successful' | 'pending' + +export const AuthenticateWithGithubButton = () => { + const triggerAuthentication = useOnClickAuthenticateWithGithub() + return ( + + ) +} + +const AccountBlock = () => { + const authenticated = useEditorState( + Substores.restOfStore, + (store) => store.userState.githubState.authenticated, + 'Github authenticated', + ) + const state = React.useMemo(() => (authenticated ? 'successful' : 'incomplete'), [authenticated]) + + if (authenticated) { + return null + } + + return ( + + + + ) +} + +const RepositoryBlock = () => { + const repo = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.targetRepository, + 'RepositoryBlock repo', + ) + const githubAuthenticated = useEditorState( + Substores.restOfStore, + (store) => store.userState.githubState.authenticated, + 'RepositoryBlock authenticated', + ) + const repoName = React.useMemo(() => githubRepoFullName(repo) ?? undefined, [repo]) + const repoOwner = React.useMemo(() => githubRepoOwnerName(repo), [repo]) + const repositoryName = React.useMemo(() => githubRepositoryName(repo), [repo]) + + const hasRepo = React.useMemo(() => repo != null, [repo]) + const [expanded, setExpanded] = React.useState(false) + React.useEffect(() => { + setExpanded(repo == null) + }, [repo]) + + const toggleExpanded = React.useCallback(() => { + if (!hasRepo) { + return + } + setExpanded(!expanded) + }, [expanded, hasRepo]) + + if (!githubAuthenticated) { + return null + } + + return ( + + {repoOwner} +
+ {repositoryName} + + } + status={hasRepo ? 'successful' : 'pending'} + first={true} + last={!hasRepo} + expanded={expanded} + onClick={toggleExpanded} + > + + +
Check your project sharing settings when importing from private repositories
+
+ +
+
+ ) +} + +const BranchBlock = () => { + const dispatch = useDispatch() + const { currentBranch, githubOperations, targetRepository, branchesForRepository } = + useEditorState( + Substores.github, + (store) => ({ + currentBranch: store.editor.githubSettings.branchName, + githubOperations: store.editor.githubOperations, + targetRepository: store.editor.githubSettings.targetRepository, + branchesForRepository: store.editor.githubData.branches, + }), + 'Github branch', + ) + const repositoryData = useEditorState( + Substores.github, + (store) => + [ + ...store.editor.githubData.userRepositories, + ...store.editor.githubData.publicRepositories, + ].find( + (r) => r.fullName === githubRepoFullName(store.editor.githubSettings.targetRepository), + ) ?? null, + 'BranchBlock Repository data', + ) + + const isListingBranches = React.useMemo( + () => isGithubListingBranches(githubOperations), + [githubOperations], + ) + const githubUserDetails = useEditorState( + Substores.github, + (store) => store.editor.githubData.githubUserDetails, + 'BranchBlock userDetails', + ) + + const refreshBranchesOnMouseUp = React.useCallback(() => { + if (targetRepository != null && githubUserDetails != null) { + void dispatchPromiseActions( + dispatch, + GithubOperations.getBranchesForGithubRepository( + dispatch, + targetRepository, + 'user-initiated', + ), + ) + } + }, [dispatch, targetRepository, githubUserDetails]) + + React.useEffect(() => { + if (targetRepository != null && githubUserDetails != null) { + void dispatchPromiseActions( + dispatch, + GithubOperations.getBranchesForGithubRepository(dispatch, targetRepository, 'polling'), + ) + } + }, [dispatch, targetRepository, githubUserDetails]) + + const [expandedFlag, setExpandedFlag] = React.useState(false) + + const expanded = React.useMemo(() => { + return expandedFlag && branchesForRepository != null + }, [expandedFlag, branchesForRepository]) + + React.useEffect(() => { + setExpandedFlag(currentBranch == null) + }, [currentBranch]) + + const toggleExpanded = React.useCallback(() => { + if (currentBranch == null) { + return + } + setExpandedFlag(!expanded) + }, [expanded, currentBranch]) + + const [branchFilter, setBranchFilter] = React.useState('') + + const updateBranchFilter = React.useCallback( + (event: React.ChangeEvent) => { + setBranchFilter(event.currentTarget.value) + }, + [setBranchFilter], + ) + + const [branchesWereLoaded, setBranchesWereLoaded] = React.useState(false) + React.useEffect(() => { + setBranchesWereLoaded(false) + }, [currentBranch, targetRepository]) + + const filteredBranches = React.useMemo(() => { + if (isListingBranches && !branchesWereLoaded) { + return [] + } + if (branchesForRepository == null || repositoryData == null) { + return [] + } + + setBranchesWereLoaded(true) + + let filtered = branchesForRepository.filter( + (b) => branchFilter.length === 0 || b.name.includes(branchFilter), + ) + + if (branchesForRepository.length === 0) { + filtered.push({ + name: repositoryData.defaultBranch, + new: true, + }) + } else { + const newBranchName = cleanupBranchName(branchFilter) + if (newBranchName.length > 1 && !filtered.some((b) => b.name === newBranchName)) { + filtered.push({ + name: newBranchName, + new: true, + }) + } + } + + return filtered + }, [branchesForRepository, branchesWereLoaded, repositoryData, branchFilter, isListingBranches]) + + const clearBranch = React.useCallback(() => { + dispatch( + [EditorActions.updateGithubSettings({ branchName: null, branchLoaded: false })], + 'everyone', + ) + }, [dispatch]) + + const selectBranch = React.useCallback( + (branch: GithubBranch) => () => { + if (isListingBranches) { + return + } + dispatch( + [ + EditorActions.updateGithubSettings({ + branchName: branch.name, + branchLoaded: false, + }), + ], + 'everyone', + ) + }, + [dispatch, isListingBranches], + ) + + const listBranchesUI = React.useMemo(() => { + return ( + + + + + + Or type in a new branch name. + + + {filteredBranches.map((branch, index) => { + const loadingThisBranch = isGithubLoadingBranch( + githubOperations, + branch.name, + targetRepository, + ) + const isCurrent = currentBranch === branch.name + return ( + + + {when(isCurrent, )} + {branch.name} + {when( + repositoryData?.defaultBranch === branch.name, + (default), + )} + + {when(branch.new === true, Create new)} + {when(loadingThisBranch, )} + + ) + })} + + + + + {when( + currentBranch != null, + + + , + )} + + ) + }, [ + branchFilter, + updateBranchFilter, + filteredBranches, + refreshBranchesOnMouseUp, + isListingBranches, + currentBranch, + clearBranch, + githubOperations, + targetRepository, + repositoryData?.defaultBranch, + selectBranch, + ]) + + const githubAuthenticated = useEditorState( + Substores.restOfStore, + (store) => store.userState.githubState.authenticated, + 'Github authenticated', + ) + + if (!githubAuthenticated) { + return null + } + + if (targetRepository == null) { + return null + } + + return ( + + {listBranchesUI} + + ) +} + +const RemoteChangesBlock = () => { + const workersRef = useRefEditorState((store) => { + return store.workers + }) + const projectIDRef = useRefEditorState((store) => { + return store.editor.id + }) + const currentContentsRef = useRefEditorState((store) => { + return store.editor.projectContents + }) + const upstreamChanges = useEditorState( + Substores.github, + (store) => store.editor.githubData.upstreamChanges, + 'Upstream changes', + ) + const hasUpstreamChanges = React.useMemo( + () => getGithubFileChangesCount(upstreamChanges) > 0, + [upstreamChanges], + ) + const githubFileChanges = useGithubFileChanges() + const bothModified = React.useMemo(() => { + const upstreamList = githubFileChangesToList(upstreamChanges) + const localList = githubFileChangesToList(githubFileChanges) + const intersection = upstreamList + .filter((upstream) => localList.some((local) => local.filename === upstream.filename)) + .map((change) => change.filename) + return intersection + }, [upstreamChanges, githubFileChanges]) + + const state = React.useMemo( + (): IndicatorState => + hasUpstreamChanges ? (bothModified.length > 0 ? 'failed' : 'pending') : 'successful', + [hasUpstreamChanges, bothModified], + ) + const githubOperations = useEditorState( + Substores.github, + (store) => store.editor.githubOperations, + 'Github operations', + ) + const githubLastUpdatedAt = useEditorState( + Substores.github, + (store) => store.editor.githubData.lastUpdatedAt, + 'Github last updated', + ) + const repo = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.targetRepository, + 'Github repo', + ) + const dispatch = useDispatch() + const branch = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.branchName, + 'Github branch', + ) + const branchLoaded = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.branchLoaded, + 'Github branchLoaded', + ) + const commit = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.originCommit, + 'Github commit', + ) + const githubUserDetails = useEditorState( + Substores.github, + (store) => store.editor.githubData.githubUserDetails, + 'Github githubUserDetails', + ) + const triggerUpdateAgainstGithub = React.useCallback(() => { + if (repo != null && branch != null && commit != null && githubUserDetails != null) { + void GithubOperations.updateProjectAgainstGithub( + workersRef.current, + dispatch, + repo, + branch, + commit, + forceNotNull('Should have a project ID by now.', projectIDRef.current), + currentContentsRef.current, + 'user-initiated', + ) + } + }, [ + workersRef, + dispatch, + repo, + branch, + commit, + projectIDRef, + currentContentsRef, + githubUserDetails, + ]) + const githubAuthenticated = useEditorState( + Substores.restOfStore, + (store) => store.userState.githubState.authenticated, + 'Github authenticated', + ) + if (!githubAuthenticated || branch == null || !branchLoaded) { + return null + } + return ( + + } + status={state} + > + {when( + hasUpstreamChanges, + + + + , + )} + + ) +} + +const LocalChangesBlock = () => { + const githubFileChanges = useGithubFileChanges() + const changesCount = React.useMemo( + () => getGithubFileChangesCount(githubFileChanges), + [githubFileChanges], + ) + const hasLocalChanges = React.useMemo(() => changesCount > 0, [changesCount]) + const state = React.useMemo( + (): IndicatorState => (hasLocalChanges ? 'incomplete' : 'successful'), + [hasLocalChanges], + ) + const githubOperations = useEditorState( + Substores.github, + (store) => store.editor.githubOperations, + 'Github operations', + ) + const dispatch = useDispatch() + const repo = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.targetRepository, + 'Github repo', + ) + const githubUserDetails = useEditorState( + Substores.github, + (store) => store.editor.githubData.githubUserDetails, + 'Github githubUserDetails', + ) + + const branch = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.branchName, + 'Github branch', + ) + + const [pushToNewBranch, setPushToNewBranch] = React.useState(false) + React.useEffect(() => { + setPushToNewBranch(false) + }, [branch]) + + const togglePushToNewBranch = React.useCallback(() => { + setPushToNewBranch(!pushToNewBranch) + }, [pushToNewBranch]) + + React.useEffect(() => { + setRawCommitBranchName(pushToNewBranch ? null : branch) + }, [pushToNewBranch, branch]) + + const [rawCommitBranchName, setRawCommitBranchName] = React.useState(null) + const updateCommitBranchName = React.useCallback( + (e: React.ChangeEvent) => setRawCommitBranchName(e.target.value), + [], + ) + const cleanedCommitBranchName = React.useMemo(() => { + if (rawCommitBranchName == null) { + return null + } + return cleanupBranchName(rawCommitBranchName) + }, [rawCommitBranchName]) + + const originCommit = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.originCommit, + 'Github origin commit', + ) + const [commitMessage, setCommitMessage] = React.useState(null) + const updateCommitMessage = React.useCallback( + (e: React.ChangeEvent) => setCommitMessage(e.target.value), + [], + ) + + React.useEffect(() => { + setCommitMessage(null) + }, [branch, originCommit]) + + const projectId = useEditorState(Substores.restOfEditor, (store) => store.editor.id, 'project id') + + const branchOriginContentsChecksums = useEditorState( + Substores.derived, + (store) => store.derived.branchOriginContentsChecksums, + 'Github branchOriginContentsChecksums', + ) + + const editorStateRef = useRefEditorState((store) => store.editor) + + const triggerSaveToGithub = React.useCallback(() => { + if (repo == null) { + console.warn('missing repo') + return + } + if (cleanedCommitBranchName == null) { + console.warn('missing branch name') + return + } + if (commitMessage == null) { + console.warn('missing commit message') + return + } + if (projectId == null) { + console.warn('project id is not set') + return + } + if (githubUserDetails == null) { + console.warn('github user is null') + return + } + void GithubOperations.saveProjectToGithub( + projectId, + repo, + persistentModelFromEditorModel(editorStateRef.current), + dispatch, + { + branchName: cleanedCommitBranchName, + commitMessage: commitMessage, + branchOriginChecksums: + branchOriginContentsChecksums == null + ? {} + : fileChecksumsWithFileToFileChecksums(branchOriginContentsChecksums), + }, + 'user-initiated', + ) + }, [ + githubUserDetails, + repo, + cleanedCommitBranchName, + commitMessage, + projectId, + editorStateRef, + dispatch, + branchOriginContentsChecksums, + ]) + + const githubAuthenticated = useEditorState( + Substores.restOfStore, + (store) => store.userState.githubState.authenticated, + 'Github authenticated', + ) + const pullRequests = useEditorState( + Substores.github, + (store) => store.editor.githubData.currentBranchPullRequests, + 'Branch PRs', + ) + + const branchLoaded = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.branchLoaded, + 'Github branchLoaded', + ) + + if (!githubAuthenticated || branch == null || !branchLoaded) { + return null + } + + return ( + + {when( + hasLocalChanges, + + +
Any unsaved files will be saved.
+ + {when( + pushToNewBranch, +
+ + {when( + rawCommitBranchName !== cleanedCommitBranchName, +
+ {cleanedCommitBranchName} +
, + )} +
, + )} + +
or
+ +
, + )} +
+ ) +} + +const PullRequestButton = () => { + const branch = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.branchName, + 'PullRequestButton branch', + ) + const repo = useEditorState( + Substores.github, + (store) => + store.editor.githubData.userRepositories.find( + (r) => r.fullName === githubRepoFullName(store.editor.githubSettings.targetRepository), + ) ?? null, + 'PullRequestButton repository', + ) + + const githubFileChanges = useGithubFileChanges() + const changesCount = React.useMemo( + () => getGithubFileChangesCount(githubFileChanges), + [githubFileChanges], + ) + const hasLocalChanges = React.useMemo(() => changesCount > 0, [changesCount]) + + const openPR = React.useCallback(() => { + if (repo != null && branch != null) { + window.open(`https://github.com/${repo.fullName}/compare/${branch}?expand=1`, '_blank') + } + }, [repo, branch]) + + const branchLoaded = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.branchLoaded, + 'Github branch loaded', + ) + + if (repo == null || branch == null || !branchLoaded) { + return null + } + if (hasLocalChanges || repo.defaultBranch === branch) { + return null + } + return ( + + + + ) +} + +const BranchNotLoadedBlock = () => { + const workersRef = useRefEditorState((store) => { + return store.workers + }) + const projectContentsRef = useRefEditorState((store) => { + return store.editor.projectContents + }) + const dispatch = useDispatch() + const { branchName, branches, branchLoaded, githubOperations, githubRepo } = useEditorState( + Substores.github, + (store) => ({ + branchName: store.editor.githubSettings.branchName, + branchLoaded: store.editor.githubSettings.branchLoaded, + branches: store.editor.githubData.branches, + githubOperations: store.editor.githubOperations, + githubRepo: store.editor.githubSettings.targetRepository, + }), + 'BranchNotLoadedBlock data', + ) + + const builtInDependencies = useEditorState( + Substores.restOfStore, + (store) => store.builtInDependencies, + 'Built-in dependencies', + ) + + const projectID = useEditorState(Substores.restOfEditor, (store) => store.editor.id, 'Project ID') + + const currentDependencies = useEditorState( + Substores.fullStore, + projectDependenciesSelector, + 'Project dependencies', + ) + const githubUserDetails = useEditorState( + Substores.github, + (store) => store.editor.githubData.githubUserDetails, + 'Project githubUserDetails', + ) + + const loadFromBranch = React.useCallback(() => { + if (githubRepo != null && branchName != null && githubUserDetails != null) { + void GithubOperations.updateProjectWithBranchContent( + workersRef.current, + dispatch, + forceNotNull('Should have a project ID by now.', projectID), + githubRepo, + branchName, + false, + currentDependencies, + builtInDependencies, + projectContentsRef.current, + 'user-initiated', + ) + } + }, [ + githubUserDetails, + workersRef, + dispatch, + githubRepo, + branchName, + currentDependencies, + builtInDependencies, + projectID, + projectContentsRef, + ]) + + const isANewBranch = React.useMemo(() => { + if (branches == null) { + return true + } + return !branches.some((b) => b.name === branchName) + }, [branches, branchName]) + + const [commitMessage, setCommitMessage] = React.useState(null) + React.useEffect(() => { + setCommitMessage(null) + }, [branchName]) + const updateCommitMessage = React.useCallback( + (e: React.ChangeEvent) => setCommitMessage(e.target.value), + [], + ) + + const projectId = useEditorState(Substores.restOfEditor, (store) => store.editor.id, 'project id') + + const branchOriginContentsChecksums = useEditorState( + Substores.derived, + (store) => store.derived.branchOriginContentsChecksums, + 'Github branchOriginContentsChecksums', + ) + + const editorStateRef = useRefEditorState((store) => store.editor) + + const pushToBranch = React.useCallback(() => { + if ( + githubRepo == null || + branchName == null || + projectId == null || + githubUserDetails == null + ) { + return + } + void GithubOperations.saveProjectToGithub( + projectId, + githubRepo, + persistentModelFromEditorModel(editorStateRef.current), + dispatch, + { + branchOriginChecksums: + branchOriginContentsChecksums == null + ? {} + : fileChecksumsWithFileToFileChecksums(branchOriginContentsChecksums), + branchName: branchName, + commitMessage: commitMessage ?? 'Committed automatically', + }, + 'user-initiated', + ) + }, [ + githubUserDetails, + githubRepo, + branchName, + projectId, + editorStateRef, + dispatch, + branchOriginContentsChecksums, + commitMessage, + ]) + + type LoadFlow = 'loadFromBranch' | 'pushToBranch' | 'createBranch' + + const [flow, setFlow] = React.useState(null) + const updateFlow = React.useCallback( + (f: LoadFlow | null) => () => { + setFlow(f) + }, + [], + ) + useEffect(() => { + setFlow(isANewBranch ? 'createBranch' : null) + }, [branchName, isANewBranch]) + + if (branchName == null || branchName === '' || branchLoaded) { + return null + } + return ( + + + {flow == null ? ( + <> + +
or
+ + + + + ) : ( + <> + {when( + flow === 'loadFromBranch', + + Loading from branch will replace your current project contents with the ones on + Github. + + , + )} + {when( + flow === 'pushToBranch', + + Pushing to the branch will store your project on Github, replacing the current + branch contents. + + + , + )} + {when( + flow === 'createBranch', + + + + , + )} + {when( + flow !== 'createBranch', + , + )} + + )} +
+
+ ) +} + +const PullRequestBlock = () => { + const pullRequests = useEditorState( + Substores.github, + (store) => store.editor.githubData.currentBranchPullRequests, + 'Branch PRs', + ) + if (pullRequests == null || pullRequests.length === 0) { + return null + } + return ( + + + + ) +} + +export const GithubPane = React.memo(() => { + const dispatch = useDispatch() + + const onFocus = React.useCallback( + (_: React.FocusEvent) => { + dispatch([setFocus('githuboptions')]) + }, + [dispatch], + ) + + const githubUser = useEditorState( + Substores.github, + (store) => store.editor.githubData.githubUserDetails, + 'GithubPane githubUser', + ) + const isLoggedIn = useEditorState( + Substores.restOfStore, + (store) => { + return User.isLoggedIn(store.userState.loginState) + }, + 'GithubPane isLoggedIn', + ) + + const openGithubProfile = React.useCallback(() => { + if (githubUser != null) { + window.open(githubUser.htmlURL, '_blank') + } + }, [githubUser]) + + const githubData = useEditorState( + Substores.github, + (store) => ({ + githubUserDetails: store.editor.githubData.githubUserDetails, + lastRefreshedCommit: store.editor.githubData.lastRefreshedCommit, + targetRepository: store.editor.githubSettings.targetRepository, + branchName: store.editor.githubSettings.branchName, + originCommit: store.editor.githubSettings.originCommit, + }), + 'GithubPane githubData', + ) + const branchOriginContentsChecksums = useEditorState( + Substores.derived, + (store) => store.derived.branchOriginContentsChecksums, + 'GithubPane branchOriginContentsChecksums', + ) + + const [refreshingGithubData, setRefreshingGithubData] = React.useState(false) + + const projectId = useEditorState( + Substores.restOfEditor, + (store) => store.editor.id, + 'GithubPane projectId', + ) + + const onClickRefresh = React.useCallback(() => { + if (projectId == null) { + return + } + setRefreshingGithubData(true) + void refreshGithubData( + dispatch, + projectId, + githubData.targetRepository, + githubData.branchName, + branchOriginContentsChecksums, + githubData.lastRefreshedCommit, + githubData.originCommit, + OperationContext, + 'user-initiated', + ).finally(() => { + setRefreshingGithubData(false) + }) + }, [dispatch, githubData, branchOriginContentsChecksums, projectId]) + + return ( +
+
+ + + {githubUser != null ? ( + + + + + ) : ( + + + + + )} + + + {unless( + isLoggedIn, + +

You need to be signed into Utopia to use the Github integration

+
, + )} +
+ {when( + isLoggedIn && githubUser != null, +
+ + + + + + + + +
, + )} +
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/pull-request-pane.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/pull-request-pane.tsx new file mode 100644 index 000000000000..c52be8ab842a --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/pull-request-pane.tsx @@ -0,0 +1,62 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React from 'react' +import { Substores, useEditorState } from '../../../../components/editor/store/store-hook' +import { UIGridRow } from '../../../../components/inspector/widgets/ui-grid-row' +import { FlexColumn } from '../../../../uuiui' + +export const PullRequestPane = React.memo(() => { + const pullRequests = useEditorState( + Substores.github, + (store) => { + return store.editor.githubData.currentBranchPullRequests + }, + 'PullRequestPane pullRequest', + ) + + const openBlank = React.useCallback( + (url: string) => () => { + window.open(url, '_blank') + }, + [], + ) + + if (pullRequests == null) { + return null + } + + return ( + + + {pullRequests.map((pr, index) => { + return ( + + #{pr.number} + {pr.title} + + ) + })} + + + ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/refresh-icon.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/refresh-icon.tsx new file mode 100644 index 000000000000..095caa0b960f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/refresh-icon.tsx @@ -0,0 +1,28 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React from 'react' +import { colorTheme, FlexColumn } from '../../../../uuiui' + +export const RefreshIcon: React.FC = () => { + return ( + + + + + + + + ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/repository-listing.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/repository-listing.tsx new file mode 100644 index 000000000000..f21ce5fc1f8f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/github-pane/repository-listing.tsx @@ -0,0 +1,461 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React, { useMemo } from 'react' +import TimeAgo from 'react-timeago' +import { notice } from '../../../../components/common/notice' +import { + showToast, + updateGithubSettings, +} from '../../../../components/editor/actions/action-creators' +import type { GithubRepo } from '../../../../components/editor/store/editor-state' +import { + emptyGithubSettings, + githubRepoEquals, + githubRepoFullName, + isGithubLoadingBranch, + isGithubLoadingRepositories, +} from '../../../../components/editor/store/editor-state' +import { UIGridRow } from '../../../../components/inspector/widgets/ui-grid-row' +import type { RepositoryEntry } from '../../../../core/shared/github/helpers' +import { connectRepo, parseGithubProjectString } from '../../../../core/shared/github/helpers' +import { unless, when } from '../../../../utils/react-conditionals' +import { Button, colorTheme, FlexColumn, FlexRow, StringInput } from '../../../../uuiui' +import { useDispatch } from '../../../editor/store/dispatch-context' +import { Substores, useEditorState } from '../../../editor/store/store-hook' +import { Ellipsis } from './github-file-changes-list' +import { GithubSpinner } from './github-spinner' +import { RefreshIcon } from './refresh-icon' +import { GithubOperations } from '../../../../core/shared/github/operations' +import type { EditorDispatch } from '../../../editor/action-types' +import { useGridPanelState } from '../../../canvas/grid-panels-state' +import { GridMenuMinWidth } from '../../../canvas/stored-layout' + +interface RepositoryRowProps extends RepositoryEntry { + importPermitted: boolean + searchable: boolean +} + +const RepositoryRow = (props: RepositoryRowProps) => { + const dispatch = useDispatch() + + const [importing, setImporting] = React.useState(false) + + const githubOperations = useEditorState( + Substores.github, + (store) => store.editor.githubOperations, + 'RepositoryRow githubOperations', + ) + + const currentRepo = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.targetRepository, + 'RepositoryRow currentRepo', + ) + + const loadingRepos = useMemo(() => { + return isGithubLoadingRepositories(githubOperations) + }, [githubOperations]) + + const importingThisBranch = useMemo(() => { + if (props.defaultBranch == null) { + return false + } else { + return isGithubLoadingBranch(githubOperations, props.defaultBranch, currentRepo) + } + }, [props.defaultBranch, githubOperations, currentRepo]) + + const [previousImportingThisBranch, setPreviousImportingThisBranch] = + React.useState(importingThisBranch) + + // Should reset the spinner which is tied to a specific branch and repository. + if (importingThisBranch !== previousImportingThisBranch) { + setPreviousImportingThisBranch(importingThisBranch) + if (!importingThisBranch) { + setImporting(false) + } + } + + const importRepository = React.useCallback(() => { + if (loadingRepos) { + return + } + const parsedTargetRepository = parseGithubProjectString(props.fullName) + if (parsedTargetRepository == null || props.defaultBranch == null) { + dispatch( + [ + showToast( + notice( + `Error when attempting to import a repository with repo: ${props.fullName}`, + 'ERROR', + ), + ), + ], + 'everyone', + ) + } else { + const isAnotherRepo = !githubRepoEquals(parsedTargetRepository, currentRepo) + dispatch(connectRepo(isAnotherRepo, parsedTargetRepository, null, null, isAnotherRepo)) + } + }, [dispatch, props.fullName, props.defaultBranch, loadingRepos, currentRepo]) + + const githubUserDetails = useEditorState( + Substores.github, + (store) => store.editor.githubData.githubUserDetails, + 'RepositoryRow githubUserDetails', + ) + + const searchPublicRepo = React.useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (githubUserDetails != null) { + void searchPublicRepoFromString(dispatch, githubUserDetails.login, props.fullName) + } + }, + [dispatch, githubUserDetails, props], + ) + + return ( + + {/* this helps text not get truncated */} +
+ {props.fullName} + + {unless( + props.searchable, + + {props.isPrivate ? 'private' : 'public'} + {props.updatedAt == null ? null : ( + <> + {' '} + · + + )} + , + )} + +
+ {when( + props.searchable, + , + )} + {when(importing, )} +
+ ) +} + +interface RepositoryListingProps { + githubAuthenticated: boolean + storedTargetGithubRepo: GithubRepo | null +} + +export const RepositoryListing = React.memo( + ({ githubAuthenticated, storedTargetGithubRepo }: RepositoryListingProps) => { + const storedTargetGithubRepoAsText = React.useMemo(() => { + if (storedTargetGithubRepo == null) { + return undefined + } else { + return `${storedTargetGithubRepo.owner}/${storedTargetGithubRepo.repository}` + } + }, [storedTargetGithubRepo]) + const [previousStoredTarget, setPreviousStoredTarget] = React.useState( + undefined, + ) + const [targetRepository, setTargetRepository] = React.useState( + storedTargetGithubRepoAsText, + ) + if (storedTargetGithubRepoAsText !== previousStoredTarget) { + // Since the storedTargetGithubRepoAsText value changed, update targetRepository. + setTargetRepository(storedTargetGithubRepoAsText) + setPreviousStoredTarget(storedTargetGithubRepoAsText) + } + + const onInputChangeTargetRepository = React.useCallback( + (event: React.ChangeEvent) => { + setTargetRepository(event.currentTarget.value) + }, + [setTargetRepository], + ) + + const githubUserDetails = useEditorState( + Substores.github, + (store) => store.editor.githubData.githubUserDetails, + 'RepositoryListing githubUserDetails', + ) + + const repositories = useEditorState( + Substores.github, + (store) => [ + ...store.editor.githubData.userRepositories, + ...store.editor.githubData.publicRepositories, + ], + 'Github repositories', + ) + + const filteredRepositories = React.useMemo(() => { + let filteredResult: Array = [] + for (const repository of repositories) { + // Only include a repository if the user can pull it. + if (repository.permissions.pull) { + // TODO make sure to disable commit/push blocks if the repo does not have push permissions + filteredResult.push({ + ...repository, + importPermitted: true, + searchable: false, + }) + } + } + if (targetRepository != null) { + filteredResult = filteredResult.filter((repository) => { + return repository.fullName.includes(targetRepository) + }) + } + return filteredResult + }, [repositories, targetRepository]) + + const filteredRepositoriesWithSpecialCases = React.useMemo(() => { + if (filteredRepositories == null) { + return null + } else { + const parsedRepo = + targetRepository == null ? null : parseGithubProjectString(targetRepository) + if (parsedRepo == null) { + return filteredRepositories + } else { + const ownerRepo = githubRepoFullName(parsedRepo) + const alreadyIncludesEntry = + filteredRepositories?.some((repo) => repo.fullName === ownerRepo) ?? false + if (alreadyIncludesEntry) { + return filteredRepositories + } else { + const fullName = `${parsedRepo.owner}/${parsedRepo.repository}` + const isSearchable = + githubUserDetails != null && + isSearchableRepository( + lookupSearchableRepositoryDetails(githubUserDetails.login, fullName), + ) + + const additionalEntry: RepositoryRowProps = { + fullName: fullName, + name: parsedRepo.repository, + avatarUrl: null, + isPrivate: true, + description: null, + updatedAt: null, + defaultBranch: 'main', + importPermitted: false, + permissions: { + admin: false, + push: false, + pull: false, + }, + searchable: isSearchable, + } + return [...filteredRepositories, additionalEntry] + } + } + } + }, [filteredRepositories, targetRepository, githubUserDetails]) + + const githubOperations = useEditorState( + Substores.github, + (store) => store.editor.githubOperations, + 'Github operations', + ) + const isLoadingRepositories = React.useMemo( + () => githubOperations.some((op) => op.name === 'loadRepositories'), + [githubOperations], + ) + + const currentRepo = useEditorState( + Substores.github, + (store) => store.editor.githubSettings.targetRepository, + 'Github currentRepo', + ) + + const dispatch = useDispatch() + + const refreshReposOnClick = React.useCallback(() => { + void GithubOperations.getUsersPublicGithubRepositories( + dispatch, + 'user-initiated', + currentRepo, + ).then((actions) => { + dispatch(actions, 'everyone') + }) + }, [dispatch, currentRepo]) + + const clearRepository = React.useCallback(() => { + dispatch([updateGithubSettings(emptyGithubSettings())], 'everyone') + }, [dispatch]) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && targetRepository != null && githubUserDetails != null) { + void searchPublicRepoFromString(dispatch, githubUserDetails.login, targetRepository) + return + } + }, + [targetRepository, dispatch, githubUserDetails], + ) + + if (!githubAuthenticated) { + return null + } + + return ( + + + + + + {filteredRepositoriesWithSpecialCases == null ? ( +
+
+ +
+
+ ) : ( + filteredRepositoriesWithSpecialCases.map((repository, index) => { + return + }) + )} +
+ + + + Create new repository on Github. + + + {when( + targetRepository != null, + , + )} +
+ ) + }, +) + +type NotSearchableRepository = { type: 'not-searchable' } +type SearchableRepository = { type: 'searchable'; owner: string; repo: string } + +type SearchableRepositoryDetails = NotSearchableRepository | SearchableRepository + +function isSearchableRepository( + details: SearchableRepositoryDetails, +): details is SearchableRepository { + return details.type === 'searchable' +} + +function lookupSearchableRepositoryDetails( + login: string, + fullName: string, +): SearchableRepositoryDetails { + const parts = fullName.trim().toLowerCase().split('/') + if (parts.length !== 2) { + return { type: 'not-searchable' } + } + const [owner, repo] = parts + if (owner === login) { + return { type: 'not-searchable' } + } + if (owner.length < 1 || repo.length < 1) { + return { type: 'not-searchable' } + } + + return { type: 'searchable', owner: owner, repo: repo } +} + +async function searchPublicRepoFromString( + dispatch: EditorDispatch, + login: string, + fullName: string, +) { + const details = lookupSearchableRepositoryDetails(login, fullName) + if (!isSearchableRepository(details)) { + return + } + + const actions = await GithubOperations.searchPublicGithubRepository(dispatch, 'user-initiated', { + owner: details.owner, + repo: details.repo, + }) + + dispatch(actions, 'everyone') +} diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/index.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/index.tsx new file mode 100644 index 000000000000..8babed005899 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/index.tsx @@ -0,0 +1,157 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React from 'react' +import { FlexColumn, FlexRow, UtopiaStyles, UtopiaTheme, colorTheme } from '../../../uuiui' +import { MenuTab } from '../../../uuiui/menu-tab' +import type { EditorAction, EditorDispatch, LoginState } from '../../editor/action-types' +import { setLeftMenuTab } from '../../editor/actions/action-creators' +import { useDispatch } from '../../editor/store/dispatch-context' +import type { DerivedState, EditorState } from '../../editor/store/editor-state' +import { LeftMenuTab } from '../../editor/store/editor-state' +import { LowPriorityStoreProvider } from '../../editor/store/store-context-providers' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { TitleBarProjectTitle } from '../../titlebar/title-bar' +import { NavigatorComponent } from '../navigator' +import { ContentsPane } from './contents-pane' +import { GithubPane } from './github-pane' +import type { StoredPanel } from '../../canvas/stored-layout' +import { useIsMyProject } from '../../editor/store/collaborative-editing' +import { when } from '../../../utils/react-conditionals' +import { PagesPane } from './pages-pane' +import { getRemixRootFile } from '../../canvas/remix/remix-utils' +import { getRemixRootDir } from '../../editor/store/remix-derived-data' +import { foldEither } from '../../../core/shared/either' + +export interface LeftPaneProps { + editorState: EditorState + derivedState: DerivedState + editorDispatch: EditorDispatch + loginState: LoginState +} + +export const LeftPaneComponentId = 'left-pane' + +export const LeftPaneContentId = 'left-pane-content' + +interface LeftPaneComponentProps { + panelData: StoredPanel +} + +export const LeftPaneComponent = React.memo((props) => { + const selectedTab = useEditorState( + Substores.restOfEditor, + (store) => store.editor.leftMenu.selectedTab, + 'LeftPaneComponent selectedTab', + ) + + const dispatch = useDispatch() + + const onMouseDownTab = React.useCallback( + (menuTab: LeftMenuTab) => { + let actions: Array = [] + actions.push(setLeftMenuTab(menuTab)) + dispatch(actions) + }, + [dispatch], + ) + + const onMouseDownPagesTab = React.useCallback(() => { + onMouseDownTab(LeftMenuTab.Pages) + }, [onMouseDownTab]) + + const onMouseDownProjectTab = React.useCallback(() => { + onMouseDownTab(LeftMenuTab.Project) + }, [onMouseDownTab]) + + const onMouseDownNavigatorTab = React.useCallback(() => { + onMouseDownTab(LeftMenuTab.Navigator) + }, [onMouseDownTab]) + + const onMouseDownGithubTab = React.useCallback(() => { + onMouseDownTab(LeftMenuTab.Github) + }, [onMouseDownTab]) + + const isMyProject = useIsMyProject() + + const curriedRequireFn = useEditorState( + Substores.restOfEditor, + (store) => store.editor.codeResultCache.curriedRequireFn, + 'LeftPaneComponent curriedRequireFn', + ) + + const isRemixProject = useEditorState( + Substores.projectContents, + (store) => { + const rootDir = getRemixRootDir(store.editor.projectContents, curriedRequireFn) + return foldEither( + () => false, + (dir) => getRemixRootFile(dir, store.editor.projectContents) != null, + rootDir, + ) + }, + 'LeftPaneComponent isRemixProject', + ) + return ( + + + +
+ {when( + isMyProject, + + {when( + isRemixProject, + , + )} + + + + , + )} + {when(isRemixProject && selectedTab === LeftMenuTab.Pages, )} + {when(selectedTab === LeftMenuTab.Navigator, )} + {when(isMyProject && selectedTab === LeftMenuTab.Project, )} + {when(isMyProject && selectedTab === LeftMenuTab.Github, )} +
+
+
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/left-pane-utils.ts b/nexus-builder/packages/navigator/src/navigator/left-pane/left-pane-utils.ts new file mode 100644 index 000000000000..e87d9647c3ff --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/left-pane-utils.ts @@ -0,0 +1,17 @@ +import type { ElementPath } from '../../../core/shared/project-file-types' +import { LeftMenuTab } from '../../editor/store/editor-state' + +export function nextSelectedTab( + currentLeftMenuTab: LeftMenuTab, + newSelectedPaths: ElementPath[], +): LeftMenuTab { + if (newSelectedPaths.length === 0) { + return currentLeftMenuTab + } + + if (currentLeftMenuTab === LeftMenuTab.Pages) { + return LeftMenuTab.Pages + } + + return LeftMenuTab.Navigator +} diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/logged-out-pane.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/logged-out-pane.tsx new file mode 100644 index 000000000000..108ce087d8bf --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/logged-out-pane.tsx @@ -0,0 +1,66 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React from 'react' +import { + Button, + FlexRow, + Icons, + Section, + SectionBodyArea, + SectionTitleRow, + Subdued, + Title, + colorTheme, +} from '../../../uuiui' +import { UIGridRow } from '../../inspector/widgets/ui-grid-row' +import { FlexCol } from 'utopia-api' + +export const LoggedOutPane = React.memo(() => { + return ( +
+ + + Sign In To: +
    +
  • Design and code from anywhere
  • +
  • Save and preview your projects
  • +
  • Use custom assets, fonts, and more
  • +
  • Load and save projects on Github
  • +
+
+ + + Free and Open Source + +
+
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/pages-pane.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/pages-pane.tsx new file mode 100644 index 000000000000..41b40b3ad197 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/pages-pane.tsx @@ -0,0 +1,759 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' + +import { useAtom } from 'jotai' +import React from 'react' +import { matchPath, matchRoutes } from 'react-router' +import { mapFirstApplicable } from '../../../core/shared/array-utils' +import * as EP from '../../../core/shared/element-path' +import { NO_OP, arrayEqualsByReference, assertNever } from '../../../core/shared/utils' +import { + FlexColumn, + FlexRow, + FunctionIcons, + Icn, + Icons, + InspectorSectionHeader, + SquareButton, + StringInput, + Tooltip, + UtopiaTheme, + colorTheme, +} from '../../../uuiui' +import type { PageTemplate } from '../../canvas/remix/remix-utils' +import { RemixIndexPathLabel, getRemixUrlFromLocation } from '../../canvas/remix/remix-utils' +import { + ActiveRemixSceneAtom, + RemixNavigationAtom, +} from '../../canvas/remix/utopia-remix-root-component' +import { Substores, useEditorState, useRefEditorState } from '../../editor/store/store-hook' +import { ExpandableIndicator } from '../navigator-item/expandable-indicator' +import { + getFeaturedRoutesFromPackageJSON, + getPageTemplatesFromPackageJSON, +} from '../../../printer-parsers/html/external-resources-parser' +import { defaultEither, foldEither } from '../../../core/shared/either' +import { unless, when } from '../../../utils/react-conditionals' +import { useDispatch } from '../../editor/store/dispatch-context' +import { + addNewPage, + showToast, + updateRemixRoute, + scrollToPosition, +} from '../../editor/actions/action-creators' +import { createNewPageName } from '../../editor/store/editor-state' +import urljoin from 'url-join' +import { notice } from '../../common/notice' +import type { EditorAction, EditorDispatch } from '../../editor/action-types' +import { StarUnstarIcon } from '../../canvas/star-unstar-icon' +import { canvasRectangle, isFiniteRectangle } from '../../../core/shared/math-utils' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import type { ElementInstanceMetadataMap } from '../../../core/shared/element-template' +import type { ElementPath } from 'utopia-shared/src/types' +import type { DropdownMenuItem } from '../../../uuiui/radix-components' +import { DropdownMenu, regularDropdownMenuItem } from '../../../uuiui/radix-components' + +type RouteMatch = { + path: string + resolvedPath: string | null + matchesRealRoute: boolean +} + +type RouteMatches = { + [path: string]: RouteMatch +} + +type NavigateTo = { + resolvedPath: string + routePath: string + mode: NavigateMode +} + +type NavigateMode = + | 'only-active-scene' // apply navigation only on the active scene + | 'all-scenes' // apply navigation to all scenes + +export const PagesPane = React.memo((props) => { + const [navigationControls] = useAtom(RemixNavigationAtom) + const [activeRemixScene] = useAtom(ActiveRemixSceneAtom) + + const activeLocation = navigationControls[EP.toString(activeRemixScene)]?.location + const pathname = activeLocation?.pathname ?? '' + const activeRoute = getRemixUrlFromLocation(activeLocation) ?? '' + + const featuredRoutes = useEditorState( + Substores.projectContents, + (store) => { + return defaultEither([], getFeaturedRoutesFromPackageJSON(store.editor.projectContents)) + }, + 'PagesPane featuredRoutes', + ) + + const remixRoutesObject: RouteMatches = useEditorState( + Substores.derived, + (store) => { + function processRoutes(routes: Array, prefix: string): RouteMatches { + let result: RouteMatches = {} + for (const route of routes) { + const delimiter = prefix == '' ? '/' : '' + const path = urljoin(prefix, delimiter, route.path ?? '') + const firstMatchingFavorite = mapFirstApplicable( + [...featuredRoutes, activeRoute], + (favorite) => matchPath(path, favorite)?.pathname ?? null, + ) + + if (path !== '') { + result[path] = { + path: path, + resolvedPath: firstMatchingFavorite, + matchesRealRoute: true, + } + } + if (route.children != null) { + result = { ...result, ...processRoutes(route.children, path) } + } + } + return result + } + const routes = processRoutes( + foldEither( + () => [], + (remixData) => remixData?.routes ?? [], + store.derived.remixData, + ), + '', + ) + return routes + }, + 'PagesPane remixRoutes', + ) + + // all remix routes in the project, ordered alphabetically + const remixRoutes = fillInGapsInRoutes(remixRoutesObject) + + const pageTemplates = useEditorState( + Substores.projectContents, + (store) => { + return defaultEither([], getPageTemplatesFromPackageJSON(store.editor.projectContents)) + }, + 'PagesPane pageTemplates', + ) + + const canAddPage = React.useMemo(() => { + return pageTemplates.length > 0 // TODO more constraints + }, [pageTemplates]) + + const dispatch = useDispatch() + + const [navigateTo, setNavigateTo] = React.useState(null) + + const onAfterPageAdd = React.useCallback((pageName: string) => { + const resolvedPath = urljoin('/', pageName) + setNavigateTo({ + resolvedPath: resolvedPath, + routePath: resolvedPath, // it's a straight path anyways + mode: 'only-active-scene', + }) + }, []) + + const onAfterRouteRenamed = React.useCallback((routePath: string, resolvedPath: string) => { + setNavigateTo({ + routePath: urljoin('/', routePath), + resolvedPath: urljoin('/', resolvedPath), + mode: 'all-scenes', + }) + }, []) + + useNavigateToRouteWhenAvailable(remixRoutes, navigateTo, () => { + setNavigateTo(null) + }) + + const metadataRef = useRefEditorState((store) => store.editor.jsxMetadata) + + const addPageAction = React.useCallback( + (template: PageTemplate) => () => { + const newPageName = createNewPageName() + dispatch([ + addNewPage('/app/routes', template, newPageName), + ...resetCanvasForNewPage(metadataRef.current, activeRemixScene), + ]) + onAfterPageAdd(newPageName) + }, + [dispatch, metadataRef, activeRemixScene, onAfterPageAdd], + ) + + const addPageDropdownItems: DropdownMenuItem[] = React.useMemo( + () => + pageTemplates.map((t) => + regularDropdownMenuItem({ + id: t.label, + label: t.label, + onSelect: addPageAction(t), + }), + ), + [addPageAction, pageTemplates], + ) + + const addPageOpenButton = React.useCallback( + () => ( + + + + ), + [], + ) + + const activeRouteDoesntMatchAnyFavorites = !featuredRoutes.includes(activeRoute!) + const activeRouteTemplatePath = matchRoutes(remixRoutes, pathname)?.[0].route.path + + return ( + + + Favorites + + + {featuredRoutes.map((favorite: string) => { + const pathMatchesActivePath = activeRoute === favorite + + return ( + + ) + })} + {activeRouteDoesntMatchAnyFavorites ? ( + + ) : ( +
+ )} + + + Routes + {when( + canAddPage, + + + , + )} + + + {remixRoutes.map((route: RouteMatch, index) => { + const { path, resolvedPath } = route + const pathMatchesActivePath = path === activeRouteTemplatePath + const pathToDisplay = path ?? RemixIndexPathLabel + + return ( + + ) + })} + + ) +}) + +interface PageRouteEntryProps { + routePath: string + resolvedPath: string | null + active: boolean + matchesRealRoute: boolean + navigateTo: NavigateTo | null + onAfterRouteRenamed: (routePath: string, resolvedPath: string) => void +} +const PageRouteEntry = React.memo((props) => { + const ref = React.useRef(null) + + const [navigationControls] = useAtom(RemixNavigationAtom) + const [activeRemixScene] = useAtom(ActiveRemixSceneAtom) + + const pathCanBeClickedToNavigate = + props.matchesRealRoute && !(props.resolvedPath == null && props.routePath.includes(':')) + + const onClick = React.useCallback(() => { + if (!pathCanBeClickedToNavigate) { + return + } + void navigationControls[EP.toString(activeRemixScene)]?.navigate( + props.resolvedPath ?? props.routePath, + ) + }, [ + navigationControls, + activeRemixScene, + pathCanBeClickedToNavigate, + props.resolvedPath, + props.routePath, + ]) + + const resolvedPathWithoutQueryOrHash = props.resolvedPath?.split('?')[0].split('#')[0] + + const resolvedRouteSegments = resolvedPathWithoutQueryOrHash?.split('/') + const templateRouteSegments = props.routePath.split('/') + + const lastResolvedSegment = resolvedRouteSegments?.[resolvedRouteSegments.length - 1] + const lastTemplateSegment = templateRouteSegments[templateRouteSegments.length - 1] + + const indentation = templateRouteSegments.length - 2 + + const isDynamicPathSegment = lastTemplateSegment.startsWith(':') + + const renaming = useRenaming(props) + + return ( + + + + {when(renaming.isRenaming, renaming.InputField)} + {unless( + renaming.isRenaming, + + + {props.routePath} + + &': { + display: 'inline-block', + }, + }} + > + {lastResolvedSegment} + + , + )} + + ) +}) + +interface FavoriteEntryProps { + favorite: string + active: boolean + addedToFavorites: boolean +} + +const FavoriteEntry = React.memo(({ favorite, active, addedToFavorites }: FavoriteEntryProps) => { + const url = favorite + const routeWithoutQueryOrHash = url.split('?')[0].split('#')[0] + // We insert a line break before every param + const queryAndHashWithLineBreaks = url.slice(routeWithoutQueryOrHash.length).replace(/&/g, '\n&') + + const [navigationControls] = useAtom(RemixNavigationAtom) + const [activeRemixScene] = useAtom(ActiveRemixSceneAtom) + + const onClick = React.useCallback(() => { + void navigationControls[EP.toString(activeRemixScene)]?.navigate(favorite) + }, [navigationControls, activeRemixScene, favorite]) + + return ( + + + + {routeWithoutQueryOrHash} + {when( + queryAndHashWithLineBreaks.length > 0, + + {queryAndHashWithLineBreaks} + + } + > + + … + + , + )} + + + + ) +}) + +function fillInGapsInRoutes(routes: RouteMatches): Array { + // if we find a route /collections, and a route /collections/hats/beanies, we should create an entry for /collections/hats, so there are no gaps in the tree structure + let result: RouteMatches = {} + for (const route of Object.values(routes)) { + const parts = route.path.split('/') + for (let i = 1; i < parts.length - 1; i++) { + const parentRoute = parts.slice(0, i + 1).join('/') + if (!(parentRoute in result)) { + result[parentRoute] = { + path: parentRoute, + resolvedPath: null, + matchesRealRoute: false, + } + } + } + result[route.path] = route + } + + return Object.values(result).sort((a, b) => a.path.localeCompare(b.path)) +} + +function resetCanvasForNewPage( + metadata: ElementInstanceMetadataMap, + activeScenePath: ElementPath, +): EditorAction[] { + const sceneFrame = MetadataUtils.getFrameInCanvasCoords(activeScenePath, metadata) + if (sceneFrame != null && isFiniteRectangle(sceneFrame)) { + const target = canvasRectangle({ + x: sceneFrame.x, + y: sceneFrame.y, + width: 0, + height: 0, + }) + return [scrollToPosition(target, 'keep-scroll-position-if-visible')] + } + return [] +} + +function useNavigateToRouteWhenAvailable( + remixRoutes: RouteMatch[], + navigateTo: NavigateTo | null, + onNavigate: () => void, +) { + const [navigationControls] = useAtom(RemixNavigationAtom) + const [activeRemixScene] = useAtom(ActiveRemixSceneAtom) + + const scenesToNavigate: string[] = React.useMemo(() => { + if (navigateTo == null) { + return [] + } + switch (navigateTo.mode) { + case 'all-scenes': + return Object.keys(navigationControls) + case 'only-active-scene': + return [EP.toString(activeRemixScene)] + default: + assertNever(navigateTo.mode) + } + }, [navigationControls, activeRemixScene, navigateTo]) + + React.useEffect(() => { + if (navigateTo == null) { + return + } + + // if the target is a specific route, go to it + const navigateToMatchesRealRoute = + matchRoutes(remixRoutes, navigateTo.resolvedPath)?.[0].route.matchesRealRoute ?? false + + // otherwise if the target is not a specific route, go to the first valid match for its prefix + const navigationTarget = navigateToMatchesRealRoute + ? navigateTo.resolvedPath + : remixRoutes.find((r) => r.matchesRealRoute && r.path.startsWith(navigateTo.routePath)) + ?.resolvedPath ?? null + + if (navigationTarget != null) { + scenesToNavigate.forEach((path) => { + void navigationControls[path]?.navigate(navigationTarget) + }) + onNavigate() + } + }, [navigateTo, remixRoutes, navigationControls, activeRemixScene, onNavigate, scenesToNavigate]) +} + +function isReplacementToken(token: string): boolean { + return token.startsWith(':') +} + +function slashPathToRemixPath(path: string): string { + return path + .replace(/\//g, '.') // slashes to dots + .replace(/:/g, '$') // colons to dollars + .replace(/^\./, '') // chomp first dot + .trim() +} + +// keeping this as a hook so we can reuse it if, for example, we want to add renaming abilities to the favorites section +function useRenaming(props: PageRouteEntryProps) { + const dispatch = useDispatch() + + const [isRenaming, setIsRenaming] = React.useState(false) + + const startRenaming = React.useCallback(() => { + const canRename = + props.routePath !== '/' && (props.resolvedPath != null || !props.routePath.includes(':')) + if (canRename) { + setIsRenaming(true) + } + }, [props.routePath, props.resolvedPath]) + + const onDoneRenaming = React.useCallback( + (newPath: string | null) => { + setIsRenaming(false) + const pathIsDynamicUnresolved = props.resolvedPath == null && props.routePath.includes(':') + if (pathIsDynamicUnresolved) { + return + } + const newResolvedPath = runRenameRemixRoute( + dispatch, + newPath, + props.routePath, + props.resolvedPath ?? props.routePath, + ) + if (newPath != null && newResolvedPath != null) { + props.onAfterRouteRenamed(newPath, newResolvedPath) + } + }, + [props, dispatch], + ) + + return { + isRenaming: isRenaming, + InputField: , + startRenaming: startRenaming, + } +} + +/** + * Rename a Remix route, update the related featured entry in the package.json and return the new resolved path. + */ +function runRenameRemixRoute( + dispatch: EditorDispatch, + newPath: string | null, + routePath: string, + resolvedPath: string, +): string | null { + if (newPath == null) { + return null + } + + // split route and resolved path by `/` so we get every path token + const routeTokens = routePath.split('/') + const resolvedTokens = resolvedPath.split('/') + + // keep track of the replacement tokens (:token) and their resolved values + let replacements: { [key: string]: string } = {} + for (let i = 0; i < routeTokens.length; i++) { + const token = routeTokens[i] + if (isReplacementToken(token)) { + replacements[token] = resolvedTokens[i] + } + } + + // if the renamed value does not match the original replacement tokens, stop here + const newResolvedPathReplacementTokens = newPath.split('/').filter(isReplacementToken) + const replacementTokens = routeTokens.filter(isReplacementToken) + if (!arrayEqualsByReference(replacementTokens, newResolvedPathReplacementTokens)) { + dispatch([ + showToast( + notice( + `Invalid tokens ${Array.from(newResolvedPathReplacementTokens).join(', ')}`, + 'ERROR', + false, + ), + ), + ]) + return null + } + + // build the new route path by applying the relevant replacements + let newResolvedPath = newPath + for (const [key, value] of Object.entries(replacements)) { + newResolvedPath = newResolvedPath.replace(key, value) + } + + // update the remix route + dispatch( + [ + updateRemixRoute( + urljoin('/app/routes', slashPathToRemixPath(routePath)), + urljoin('/app/routes', slashPathToRemixPath(newPath)), + resolvedPath, + urljoin('/', newResolvedPath), + ), + ], + 'everyone', + ) + + return newResolvedPath +} + +type RenameInputFieldProps = { + doneRenaming: (newPath: string | null) => void + routePath: string +} + +const RenameInputField = React.memo((props: RenameInputFieldProps) => { + const [value, setValue] = React.useState(props.routePath) + + const onBlur = React.useCallback(() => { + props.doneRenaming(null) + }, [props]) + + const onFocus = React.useCallback((e: React.FocusEvent) => { + e.target.select() + }, []) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'Escape': + props.doneRenaming(null) + break + case 'Enter': + props.doneRenaming(value) + break + } + }, + [props, value], + ) + + const onChange = React.useCallback((e: React.ChangeEvent) => { + setValue(e.target.value) + }, []) + + return ( + + ) +}) +RenameInputField.displayName = 'RenameInputField' diff --git a/nexus-builder/packages/navigator/src/navigator/left-pane/settings-pane.tsx b/nexus-builder/packages/navigator/src/navigator/left-pane/settings-pane.tsx new file mode 100644 index 000000000000..76741a0993c3 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/left-pane/settings-pane.tsx @@ -0,0 +1,394 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { jsx } from '@emotion/react' +import React, { useState } from 'react' +import { + Button, + CheckboxInput, + colorTheme, + FlexColumn, + FlexRow, + H2, + HeadlessStringInput, + Icn, + PopupList, + Section, + StringInput, + UtopiaTheme, +} from '../../../uuiui' +import type { SelectOption } from '../../../uuiui-deps' +import * as EditorActions from '../../editor/actions/action-creators' +import { setProjectDescription, setProjectName } from '../../editor/actions/action-creators' +import { useDispatch } from '../../editor/store/dispatch-context' +import { Substores, useEditorState, useRefEditorState } from '../../editor/store/store-hook' +import { UIGridRow } from '../../inspector/widgets/ui-grid-row' +import type { FeatureName } from '../../../utils/feature-switches' +import { + toggleFeatureEnabled, + isFeatureEnabled, + getFeaturesToDisplay, +} from '../../../utils/feature-switches' +import json5 from 'json5' +import { load } from '../../../components/editor/actions/actions' +import { when } from '../../../utils/react-conditionals' +import { useTriggerForkProject } from '../../editor/persistence-hooks' +import { saveUserPreferencesDefaultLayout } from '../../common/user-preferences' +import { useGridPanelState } from '../../canvas/grid-panels-state' +import { notice } from '../../common/notice' +import { gridMenuDefaultPanels } from '../../canvas/stored-layout' +import { usePermissions } from '../../editor/store/permissions' +import { DisableControlsInSubtree } from '../../../uuiui/utilities/disable-subtree' +import { useIsMyProject } from '../../editor/store/collaborative-editing' + +const themeOptions = [ + { + label: 'System', + value: 'system', + }, + { + label: 'Dark', + value: 'dark', + }, + { + label: 'Light', + value: 'light', + }, +] + +const defaultTheme = themeOptions[0] + +export const FeatureSwitchesSection = React.memo(() => { + const [changeCount, setChangeCount] = React.useState(0) + // this replaces the 'forceRender' in the original implementation + const onFeatureChange = React.useCallback(() => setChangeCount(changeCount + 1), [changeCount]) + // eslint-disable-next-line react-hooks/exhaustive-deps + const featuresToDisplay = React.useMemo(() => getFeaturesToDisplay(), [changeCount]) + if (featuresToDisplay.length > 0) { + return ( +
+ +

Experimental Toggle Features

+
+ {featuresToDisplay.map((name) => ( + + ))} +
+ ) + } else { + return null + } +}) + +const FeatureSwitchRow = React.memo((props: { name: FeatureName; onFeatureChange: () => void }) => { + const name = props.name + const onFeatureChange = props.onFeatureChange + const id = `toggle-${name}` + const onChange = React.useCallback(() => { + toggleFeatureEnabled(name) + onFeatureChange() + }, [name, onFeatureChange]) + return ( + + + + + ) +}) + +export const SettingsPane = React.memo(() => { + const dispatch = useDispatch() + const { projectName, projectDescription } = useEditorState( + Substores.restOfEditor, + (store) => { + return { + projectName: store.editor.projectName, + projectDescription: store.editor.projectDescription, + } + }, + 'SettingsPane', + ) + + const userState = useEditorState( + Substores.userState, + (store) => store.userState, + 'SettingsPane userState', + ) + const themeConfig = userState.themeConfig + + const isMyProject = useIsMyProject() + + const [theme, setTheme] = React.useState( + themeOptions.find((option) => option.value === themeConfig) ?? defaultTheme, + ) + + const handleSubmitValueTheme = React.useCallback( + (option: SelectOption) => { + setTheme(option) + dispatch([EditorActions.setCurrentTheme(option.value)]) + }, + [dispatch], + ) + + const onChangeProjectName = React.useCallback((event: React.ChangeEvent) => { + changeProjectName(event.target.value) + }, []) + + const onChangeProjectDescription = React.useCallback( + (event: React.ChangeEvent) => { + changeProjectDescription(event.target.value) + }, + [], + ) + + const updateProjectName = React.useCallback( + (newProjectName: string) => { + dispatch([setProjectName(newProjectName)]) + }, + [dispatch], + ) + + const updateProjectDescription = React.useCallback( + (newProjectDescription: string) => { + dispatch([setProjectDescription(newProjectDescription)]) + }, + [dispatch], + ) + + const handleBlurProjectName = React.useCallback( + (e: React.ChangeEvent) => { + updateProjectName(e.target.value) + }, + [updateProjectName], + ) + + const handleBlurProjectDescription = React.useCallback( + (e: React.ChangeEvent) => { + updateProjectDescription(e.target.value) + }, + [updateProjectDescription], + ) + + const handleKeyPress = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + let target = e.target as HTMLInputElement + target.blur() + } + }, []) + + const [name, changeProjectName] = useState(projectName), + [description, changeProjectDescription] = useState(projectDescription) + + const entireStateRef = useRefEditorState((store) => store) + + const loadProjectContentJson = React.useCallback( + (value: string) => { + const confirmed = window.confirm( + 'If you press OK, the inserted code will override the current project. Are you sure?', + ) + if (confirmed) { + const persistentModel = json5.parse(value) + console.info('attempting to load new Project Contents JSON', persistentModel) + void load( + dispatch, + persistentModel, + entireStateRef.current.editor.projectName, + entireStateRef.current.editor.id!, + entireStateRef.current.builtInDependencies, + ) + } + }, + [dispatch, entireStateRef], + ) + + const onForkProjectClicked = useTriggerForkProject() + + const [panelState, setPanelState] = useGridPanelState() + + const onSavePanelsDefaultLayout = React.useCallback(() => { + void saveUserPreferencesDefaultLayout(panelState) + dispatch([ + EditorActions.addToast( + notice('Saved current panels layout as default for new projects.', 'SUCCESS'), + ), + ]) + }, [panelState, dispatch]) + + const onResetPanelsLayout = React.useCallback(() => { + setPanelState(gridMenuDefaultPanels()) + dispatch([EditorActions.addToast(notice('Restored project panels layout.', 'SUCCESS'))]) + }, [dispatch, setPanelState]) + + const onResetPanelsDefaultLayout = React.useCallback(async () => { + await saveUserPreferencesDefaultLayout(gridMenuDefaultPanels()) + setPanelState(gridMenuDefaultPanels()) + dispatch([EditorActions.addToast(notice('Restored default panels layout.', 'SUCCESS'))]) + }, [dispatch, setPanelState]) + + const canEditProject = usePermissions().edit + + const projectOwnerMetadata = useEditorState( + Substores.projectServerState, + (store) => store.projectServerState.projectData, + 'ForksGiven projectOwnerMetadata', + ) + + return ( + +
+ +

Project

+
+ + + Name + + + + Description + + + + Owner + + {projectOwnerMetadata?.ownerName} {isMyProject ? (you) : ''} + + + + {when( + userState.loginState.type === 'LOGGED_IN', + + + , + )} + + + + + +
+ +
+ +

Application

+
+ + Theme + + + +
Panels
+
+ + + +
+
+ +
Contents
+ +
+
+ +
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-drag-layer.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-drag-layer.tsx new file mode 100644 index 000000000000..37c960006da0 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-drag-layer.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { useDragLayer } from 'react-dnd' +import { regularNavigatorEntry } from '../editor/store/editor-state' +import { + NavigatorItemDragType, + type NavigatorItemDragAndDropWrapperProps, +} from './navigator-item/navigator-item-dnd-container' +import type { WindowPoint } from '../../core/shared/math-utils' +import { windowPoint, zeroPoint } from '../../core/shared/math-utils' +import { ItemLabel } from './navigator-item/item-label' +import { NO_OP } from '../../core/shared/utils' +import { colorTheme, FlexRow, Icn } from '../../uuiui' +import { useLayoutOrElementIcon } from './layout-element-icons' +import { emptyElementPath } from '../../core/shared/element-path' +import { getElementPadding } from './navigator-item/navigator-item' + +export const NavigatorDragLayer = React.memo(() => { + const { item, initialOffset, difference } = useDragLayer((monitor) => ({ + item: monitor.getItem() as NavigatorItemDragAndDropWrapperProps | null, + initialOffset: (monitor.getInitialSourceClientOffset() as WindowPoint) ?? zeroPoint, + difference: (monitor.getDifferenceFromInitialOffset() as WindowPoint) ?? zeroPoint, + })) + + const containerRef = React.useRef(null) + + const navigatorEntry = React.useMemo( + () => regularNavigatorEntry(item?.elementPath ?? emptyElementPath), + [item?.elementPath], + ) + + const draggedItemIsNavigatorItem = item != null && item.type === NavigatorItemDragType + const hidden = !draggedItemIsNavigatorItem + + const offset = windowPoint({ + x: initialOffset.x + difference.x - (containerRef.current?.getBoundingClientRect()?.x ?? 0), + y: initialOffset.y + difference.y - (containerRef.current?.getBoundingClientRect()?.y ?? 0), + }) + + const icon = useLayoutOrElementIcon(navigatorEntry)?.iconProps ?? {} + const label = item?.label ?? '' + const selected = item?.selected ?? false + + return hidden ? null : ( +
+ + + + + + +
+ ) +}) +NavigatorDragLayer.displayName = 'NavigatorDragLayer' diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/component-picker-context-menu.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/component-picker-context-menu.tsx new file mode 100644 index 000000000000..486739eda009 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/component-picker-context-menu.tsx @@ -0,0 +1,1041 @@ +import React from 'react' +import { + useContextMenu, + Menu, + type ShowContextMenuParams, + contextMenu, + type TriggerEvent, +} from 'react-contexify' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import type { ElementInstanceMetadataMap } from '../../../core/shared/element-template' +import { + type ElementInstanceMetadata, + getJSXElementNameAsString, + jsxAttributesFromMap, + jsxElement, + jsxElementFromJSXElementWithoutUID, + jsxElementNameFromString, + getJSXElementNameLastPart, + isIntrinsicHTMLElement, +} from '../../../core/shared/element-template' +import type { ElementPath, Imports } from '../../../core/shared/project-file-types' +import { useDispatch } from '../../editor/store/dispatch-context' +import { Substores, useEditorState, useRefEditorState } from '../../editor/store/store-hook' +import { + applyCommandsAction, + deleteView, + insertAsChildTarget, + insertInsertable, + insertJSXElement, + replaceJSXElement, + replaceMappedElement, + selectComponents, + setProp_UNSAFE, + showToast, + switchEditorMode, + wrapInElement, +} from '../../editor/actions/action-creators' +import * as EP from '../../../core/shared/element-path' +import * as PP from '../../../core/shared/property-path' +import { + ComponentPicker, + elementToInsertToInsertableComponent, + type ElementToInsert, +} from './component-picker' +import type { PreferredChildComponentDescriptor } from '../../custom-code/internal-property-controls' +import { fixUtopiaElement, generateConsistentUID } from '../../../core/shared/uid-utils' +import { getUidMappings, getAllUniqueUidsFromMapping } from '../../../core/model/get-uid-mappings' +import { elementFromInsertMenuItem } from '../../editor/insert-callbacks' +import { ContextMenuWrapper_DEPRECATED } from '../../context-menu-wrapper' +import { BodyMenuOpenClass, assertNever } from '../../../core/shared/utils' +import { type ContextMenuItem } from '../../context-menu-items' +import { FlexRow, Icn, type IcnProps } from '../../../uuiui' +import type { + EditorAction, + EditorDispatch, + InsertAsChildTarget, + ReplaceKeepChildrenAndStyleTarget, + ReplaceTarget, + WrapTarget, +} from '../../editor/action-types' +import { type ProjectContentTreeRoot } from '../../assets' +import { + type PropertyControlsInfo, + type ComponentInfo, + componentElementToInsertHasChildren, +} from '../../custom-code/code-file' +import { type Icon } from 'utopia-api' +import { getRegisteredComponent } from '../../../core/property-controls/property-controls-utils' +import { defaultImportsForComponentModule } from '../../../core/property-controls/property-controls-local' +import { useGetInsertableComponents } from '../../canvas/ui/floating-insert-menu' +import { atom, useAtom, useSetAtom } from 'jotai' +import { + childInsertionPath, + conditionalClauseInsertionPath, + replaceWithSingleElement, +} from '../../editor/store/insertion-path' +import { mapComponentInfo, type InsertableComponent } from '../../shared/project-components' +import { + getConditionalClausePathFromMetadata, + type ConditionalCase, +} from '../../../core/model/conditionals' +import type { ElementPathTrees } from '../../../core/shared/element-path-tree' +import { absolute } from '../../../utils/utils' +import { notice } from '../../common/notice' +import { generateUidWithExistingComponents } from '../../../core/model/element-template-utils' +import { intrinsicHTMLElementNamesThatSupportChildren } from '../../../core/shared/dom-utils' +import { commandsForFirstApplicableStrategy } from '../../../components/inspector/inspector-strategies/inspector-strategy' +import { wrapInDivStrategy } from '../../../components/editor/wrap-in-callbacks' +import type { AllElementProps } from '../../../components/editor/store/editor-state' +import { EditorModes } from '../../editor/editor-modes' +import { + conditionalOverrideUpdateForPath, + getConditionalOverrideActions, +} from './navigator-item-clickable-wrapper' + +type RenderPropTarget = { type: 'render-prop'; prop: string } +type ConditionalTarget = { type: 'conditional'; conditionalCase: ConditionalCase } + +export type InsertionTarget = + | RenderPropTarget + | ReplaceTarget + | ReplaceKeepChildrenAndStyleTarget + | InsertAsChildTarget + | ConditionalTarget + | WrapTarget + +export function renderPropTarget(prop: string): RenderPropTarget { + return { + type: 'render-prop', + prop: prop, + } +} +export function conditionalTarget(conditionalCase: ConditionalCase): ConditionalTarget { + return { type: 'conditional', conditionalCase: conditionalCase } +} + +export function isReplaceTarget( + insertionTarget: InsertionTarget, +): insertionTarget is ReplaceTarget { + return insertionTarget.type === 'replace-target' +} + +export function isWrapTarget(insertionTarget: InsertionTarget): insertionTarget is WrapTarget { + return insertionTarget.type === 'wrap-target' +} + +export function isReplaceKeepChildrenAndStyleTarget( + insertionTarget: InsertionTarget, +): insertionTarget is ReplaceKeepChildrenAndStyleTarget { + return insertionTarget.type === 'replace-target-keep-children-and-style' +} + +export function isInsertAsChildTarget( + insertionTarget: InsertionTarget, +): insertionTarget is InsertAsChildTarget { + return insertionTarget.type === 'insert-as-child' +} + +export function isRenderPropTarget( + insertionTarget: InsertionTarget, +): insertionTarget is RenderPropTarget { + return insertionTarget.type === 'render-prop' +} + +export function isConditionalTarget( + insertionTarget: InsertionTarget, +): insertionTarget is ConditionalTarget { + return insertionTarget.type === 'conditional' +} + +interface ComponentPickerContextMenuAtomData { + targets: ElementPath[] + insertionTarget: InsertionTarget +} + +const ComponentPickerContextMenuAtom = atom({ + targets: [EP.emptyElementPath], + insertionTarget: insertAsChildTarget(), +}) + +function getIconForComponent( + targetName: string, + moduleName: string | null, + propertyControlsInfo: PropertyControlsInfo, +): Icon { + if (moduleName == null) { + return 'component' + } + + const registeredComponent = getRegisteredComponent(targetName, moduleName, propertyControlsInfo) + + return registeredComponent?.icon ?? 'component' +} + +interface PreferredChildComponentDescriptorWithIcon extends PreferredChildComponentDescriptor { + icon: Icon +} + +export function preferredChildrenForTarget( + targetElement: ElementInstanceMetadata | null, + insertionTarget: InsertionTarget, + propertyControlsInfo: PropertyControlsInfo, + elementChildren: Array, +): Array { + const targetJSXElement = MetadataUtils.getJSXElementFromElementInstanceMetadata(targetElement) + const elementImportInfo = targetElement?.importInfo + if (elementImportInfo == null || targetJSXElement == null) { + return [] + } + + const targetName = getJSXElementNameAsString(targetJSXElement.name) + const registeredComponent = getRegisteredComponent( + targetName, + elementImportInfo.filePath, + propertyControlsInfo, + ) + + // TODO: we don't deal with components registered with the same name in multiple files + if (registeredComponent != null) { + if ( + isInsertAsChildTarget(insertionTarget) || + isReplaceTarget(insertionTarget) || + isReplaceKeepChildrenAndStyleTarget(insertionTarget) + ) { + // If we want to keep the children of this element when it has some, don't include replacements that have children. + const includeComponentsWithChildren = + !isReplaceKeepChildrenAndStyleTarget(insertionTarget) || elementChildren.length === 0 + + return registeredComponent.preferredChildComponents.map((childComponent) => { + return { + ...childComponent, + variants: childComponent.variants.filter((variant) => { + // Includes everything if components with children are permitted, otherwise only includes components without children. + return ( + includeComponentsWithChildren || + !componentElementToInsertHasChildren(variant.elementToInsert()) + ) + }), + icon: getIconForComponent( + childComponent.name, + childComponent.moduleName, + propertyControlsInfo, + ), + } + }) + } else if (isRenderPropTarget(insertionTarget)) { + for (const [registeredPropName, registeredPropValue] of Object.entries( + registeredComponent.properties, + )) { + if ( + registeredPropName === insertionTarget.prop && + registeredPropValue.control === 'jsx' && + registeredPropValue.preferredChildComponents != null + ) { + return registeredPropValue.preferredChildComponents.map((v) => ({ + ...v, + icon: getIconForComponent(v.name, v.moduleName, propertyControlsInfo), + })) + } + } + } + } + + return [] +} + +function augmentPreferredChildren( + preferredChildren: PreferredChildComponentDescriptorWithIcon[], + insertionTarget: InsertionTarget, +): PreferredChildComponentDescriptorWithIcon[] { + if (insertionTarget.type === 'insert-as-child') { + return [ + ...preferredChildren, + { + name: 'List', + moduleName: null, + variants: [mapComponentInfo], + icon: 'code', + }, + ] + } + + return preferredChildren +} + +function getTargetParentFromInsertionTarget( + target: ElementPath, + insertionTarget: InsertionTarget, +): ElementPath { + return isReplaceTarget(insertionTarget) || isReplaceKeepChildrenAndStyleTarget(insertionTarget) + ? EP.parentPath(target) + : target +} + +const usePreferredChildrenForTarget = ( + target: ElementPath, + insertionTarget: InsertionTarget, +): Array => { + const targetParent = getTargetParentFromInsertionTarget(target, insertionTarget) + + const targetElement = useEditorState( + Substores.metadata, + (store) => MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, targetParent), + 'usePreferredChildrenForTarget targetElement', + ) + const targetChildren = useEditorState( + Substores.metadata, + (store) => MetadataUtils.getChildrenUnordered(store.editor.jsxMetadata, target), + 'usePreferredChildrenForTarget targetChildren', + ) + + const preferredChildren = useEditorState( + Substores.restOfEditor, + (store) => { + return preferredChildrenForTarget( + targetElement, + insertionTarget, + store.editor.propertyControlsInfo, + targetChildren, + ) + }, + 'usePreferredChildrenForSelectedElement propertyControlsInfo', + ) + + return augmentPreferredChildren(preferredChildren, insertionTarget) +} + +export type ShowComponentPickerContextMenuCallback = ( + selectedViews: ElementPath[], + insertionTarget: InsertionTarget, + pickerType?: 'preferred' | 'full', +) => ShowComponentPickerContextMenu + +export type ShowComponentPickerContextMenu = ( + event: TriggerEvent, + params?: Pick | undefined, +) => void + +const PreferredMenuId = 'component-picker-context-menu' +const FullMenuId = 'component-picker-context-menu-full' + +export const useCreateCallbackToShowComponentPicker = + (): ShowComponentPickerContextMenuCallback => { + const { show: showPreferred } = useContextMenu({ id: PreferredMenuId }) + const { show: showFull } = useContextMenu({ id: FullMenuId }) + const setContextMenuProps = useSetAtom(ComponentPickerContextMenuAtom) + const editorRef = useRefEditorState((store) => ({ + jsxMetadata: store.editor.jsxMetadata, + propertyControlsInfo: store.editor.propertyControlsInfo, + })) + + const dispatch = useDispatch() + + return React.useCallback( + ( + selectedViews: ElementPath[], + insertionTarget: InsertionTarget, + overridePickerType?: 'preferred' | 'full', + ) => + ( + event: TriggerEvent, + params?: Pick | undefined, + ) => { + event.stopPropagation() + event.preventDefault() + + let pickerType: 'preferred' | 'full' + let targets = selectedViews + + if (overridePickerType != null) { + pickerType = overridePickerType + } else if (isWrapTarget(insertionTarget)) { + pickerType = 'full' + } else { + // for insertion and replacement we still don't support multiple selection + // so we pick the first one + targets = selectedViews.slice(0, 1) + const firstTarget = targets[0] + const targetParent = + isReplaceTarget(insertionTarget) || + isReplaceKeepChildrenAndStyleTarget(insertionTarget) + ? EP.parentPath(firstTarget) + : firstTarget + const targetElement = MetadataUtils.findElementByElementPath( + editorRef.current.jsxMetadata, + targetParent, + ) + const targetChildren = MetadataUtils.getChildrenUnordered( + editorRef.current.jsxMetadata, + targetParent, + ) + const preferredChildren = preferredChildrenForTarget( + targetElement, + insertionTarget, + editorRef.current.propertyControlsInfo, + targetChildren, + ) + + pickerType = preferredChildren.length > 0 ? 'preferred' : 'full' + } + + setContextMenuProps({ targets: selectedViews, insertionTarget: insertionTarget }) + const show = pickerType === 'preferred' ? showPreferred : showFull + show({ ...params, event }) + + // conditional slots should get selected as a result, since this action would supersede + // the navigator's selection handling. + if (isConditionalTarget(insertionTarget)) { + let elementsToSelect: ElementPath[] = [] + let overrideActions: EditorAction[] = [] + for (const view of selectedViews) { + const clause = getConditionalClausePathFromMetadata( + view, + editorRef.current.jsxMetadata, + insertionTarget.conditionalCase, + ) + if (clause != null) { + elementsToSelect.push(clause) + overrideActions.push( + ...getConditionalOverrideActions( + view, + conditionalOverrideUpdateForPath(clause, editorRef.current.jsxMetadata), + ), + ) + } + } + dispatch([...overrideActions, selectComponents(elementsToSelect, false)]) + } + }, + [editorRef, showPreferred, showFull, setContextMenuProps, dispatch], + ) + } + +function defaultVariantItem( + elementName: string, + label: string | React.ReactNode, + imports: Imports, + submenuName: string | React.ReactNode | null, + onItemClick: (preferredChildToInsert: ElementToInsert) => void, +): ContextMenuItem { + return { + name: label, + submenuName: submenuName, + enabled: true, + action: () => + onItemClick({ + name: elementName, + elementToInsert: (uid: string) => + jsxElement(elementName, uid, jsxAttributesFromMap({}), []), + additionalImports: imports, + }), + } +} + +function singletonItem( + label: string | React.ReactNode, + variant: ComponentInfo, + onItemClick: (preferredChildToInsert: ElementToInsert) => void, +): ContextMenuItem { + return { + name: label, + submenuName: null, + enabled: true, + action: () => + onItemClick({ + name: variant.insertMenuLabel, + elementToInsert: (uid: string) => elementFromInsertMenuItem(variant.elementToInsert(), uid), + additionalImports: variant.importsToAdd, + }), + } +} + +function variantItem( + variant: ComponentInfo, + submenuName: string | React.ReactNode | null, + onItemClick: (preferredChildToInsert: ElementToInsert) => void, +): ContextMenuItem { + return { + name: variant.insertMenuLabel, + submenuName: submenuName, + enabled: true, + action: () => + onItemClick({ + name: variant.insertMenuLabel, + elementToInsert: (uid: string) => elementFromInsertMenuItem(variant.elementToInsert(), uid), + additionalImports: variant.importsToAdd, + }), + } +} + +const separatorItem: ContextMenuItem = { + name:
, + enabled: false, + isSeparator: true, + action: () => null, +} + +function moreItem( + menuWrapperRef: React.RefObject, + showComponentPickerContextMenu: ShowComponentPickerContextMenu, +): ContextMenuItem { + return { + name: More…, + enabled: true, + action: (_data, _dispatch, _rightClickCoordinate, e) => { + // FIXME Yeah this is horrific + const currentMenu = (menuWrapperRef.current?.childNodes[1] as HTMLDivElement) ?? null + const position = + currentMenu == null + ? undefined + : { + x: currentMenu.offsetLeft, + y: currentMenu.offsetTop, + } + + showComponentPickerContextMenu(e as React.MouseEvent, { + position: position, + }) + }, + } +} + +export function insertComponentPickerItem( + toInsert: InsertableComponent, + targets: ElementPath[], + projectContents: ProjectContentTreeRoot, + allElementProps: AllElementProps, + propertyControlsInfo: PropertyControlsInfo, + metadata: ElementInstanceMetadataMap, + pathTrees: ElementPathTrees, + dispatch: EditorDispatch, + insertionTarget: InsertionTarget, +) { + const uniqueIds = new Set( + getAllUniqueUidsFromMapping(getUidMappings(projectContents).filePathToUids), + ) + const elementWithoutUID = toInsert.element() + // TODO: for most of the operations we still only support one target + const firstTarget = targets[0] + + const actions = ((): Array => { + if (elementWithoutUID.type === 'JSX_ELEMENT') { + if (isWrapTarget(insertionTarget) && elementWithoutUID?.name?.baseVariable === 'div') { + const commands = commandsForFirstApplicableStrategy([ + wrapInDivStrategy( + metadata, + targets, + pathTrees, + allElementProps, + projectContents, + propertyControlsInfo, + ), + ]) + + if (commands != null) { + return [applyCommandsAction(commands)] + } + } + + const uid = generateConsistentUID('prop', uniqueIds) + const element = jsxElementFromJSXElementWithoutUID(elementWithoutUID, uid) + const fixedElement = fixUtopiaElement(element, uniqueIds).value + + if (fixedElement.type !== 'JSX_ELEMENT') { + throw new Error('JSXElementWithoutUid is not converted to JSXElement') + } + + // if we are inserting into a render prop + if (isRenderPropTarget(insertionTarget)) { + return [ + setProp_UNSAFE( + firstTarget, + PP.create(insertionTarget.prop), + fixedElement, + toInsert.importsToAdd ?? undefined, + ), + ] + } + + // Replacing a mapped element requires a different function + if ( + isReplaceTarget(insertionTarget) && + MetadataUtils.isJSXMapExpression(EP.parentPath(firstTarget), metadata) + ) { + return [replaceMappedElement(fixedElement, firstTarget, toInsert.importsToAdd)] + } + + if ( + isReplaceTarget(insertionTarget) || + isReplaceKeepChildrenAndStyleTarget(insertionTarget) + ) { + return [ + replaceJSXElement(fixedElement, firstTarget, toInsert.importsToAdd, insertionTarget), + ] + } + + if (isWrapTarget(insertionTarget)) { + return [ + wrapInElement(targets, { + element: fixedElement, + importsToAdd: toInsert.importsToAdd, + }), + ] + } + + if (!isConditionalTarget(insertionTarget)) { + return [ + insertJSXElement( + fixedElement, + firstTarget, + toInsert.importsToAdd ?? undefined, + insertionTarget.indexPosition, + ), + ] + } + } + + if (isInsertAsChildTarget(insertionTarget)) { + return [ + insertInsertable( + childInsertionPath(firstTarget), + toInsert, + 'do-not-add', + insertionTarget.indexPosition ?? null, + ), + ] + } + + if (isConditionalTarget(insertionTarget)) { + return [ + insertInsertable( + conditionalClauseInsertionPath( + firstTarget, + insertionTarget.conditionalCase, + replaceWithSingleElement(), + ), + toInsert, + 'do-not-add', + null, + ), + ] + } + + if (isWrapTarget(insertionTarget)) { + const elementToInsert = toInsert.element() + if ( + elementToInsert.type === 'JSX_MAP_EXPRESSION' && + !targets.every((target) => MetadataUtils.isJSXElement(target, metadata)) + ) { + return [ + showToast( + notice( + 'We are working on support to insert Lists, Conditionals and Fragments into Lists', + 'INFO', + false, + 'wrap-component-picker-item-nested-map', + ), + ), + ] + } + return [ + wrapInElement(targets, { + element: { + ...elementToInsert, + uid: generateUidWithExistingComponents(projectContents), + }, + importsToAdd: toInsert.importsToAdd, + }), + ] + } + + if (isReplaceTarget(insertionTarget)) { + if ( + MetadataUtils.isJSXMapExpression(EP.parentPath(firstTarget), metadata) && + elementWithoutUID.type !== 'JSX_ELEMENT' + ) { + return [ + showToast( + notice( + 'We are working on support to insert Lists, Conditionals and Fragments into Lists', + 'INFO', + false, + 'insert-component-picker-item-nested-map', + ), + ), + ] + } + const index = MetadataUtils.getIndexInParent(metadata, pathTrees, firstTarget) + return [ + deleteView(firstTarget), + insertInsertable( + childInsertionPath(EP.parentPath(firstTarget)), + toInsert, + 'do-not-add', + absolute(index), + ), + ] + } + + return [ + showToast( + notice( + toastMessage(insertionTarget, toInsert), + 'INFO', + false, + 'insert-component-picker-item-nested-map', + ), + ), + ] + })() + + dispatch(actions.concat(switchEditorMode(EditorModes.selectMode(null, false, 'none')))) +} + +function toastMessage(insertionTarget: InsertionTarget, toInsert: InsertableComponent) { + switch (insertionTarget.type) { + case 'replace-target': + return `Swapping to ${toInsert.name} isn't supported yet` + case 'replace-target-keep-children-and-style': + return `Replacing with ${toInsert.name} isn't supported yet` + case 'insert-as-child': + return `Inserting ${toInsert.name} as child isn't supported yet` + case 'conditional': + return `Inserting ${toInsert.name} into conditional ${insertionTarget.type} isn't supported yet` + case 'render-prop': + return `Inserting ${toInsert.name} into render prop ${insertionTarget.prop} isn't supported yet` + case 'wrap-target': + return `Wrapping with ${toInsert.name} isn't supported yet` + default: + assertNever(insertionTarget) + } +} + +function insertPreferredChild( + preferredChildToInsert: ElementToInsert, + targets: ElementPath[], + projectContents: ProjectContentTreeRoot, + allElementProps: AllElementProps, + propertyControlsInfo: PropertyControlsInfo, + metadata: ElementInstanceMetadataMap, + pathTrees: ElementPathTrees, + dispatch: EditorDispatch, + insertionTarget: InsertionTarget, +) { + const uniqueIds = new Set( + getAllUniqueUidsFromMapping(getUidMappings(projectContents).filePathToUids), + ) + const uid = generateConsistentUID('prop', uniqueIds) + const toInsert = elementToInsertToInsertableComponent( + preferredChildToInsert, + uid, + ['do-not-add'], + null, + { type: 'file-root' }, + null, + ) + + insertComponentPickerItem( + toInsert, + targets, + projectContents, + allElementProps, + propertyControlsInfo, + metadata, + pathTrees, + dispatch, + insertionTarget, + ) +} + +interface ComponentPickerContextMenuProps { + targets: ElementPath[] + insertionTarget: InsertionTarget +} + +export function iconPropsForIcon(icon: Icon, inverted: boolean = false): IcnProps { + return { + category: 'navigator-element', + type: icon, + color: inverted ? 'black' : 'white', + } +} + +export function labelTestIdForComponentIcon( + componentName: string, + moduleName: string, + icon: Icon, +): string { + return `variant-label-${componentName}-${moduleName}-${icon}` +} + +function contextMenuItemsFromVariants( + preferredChildComponentDescriptor: PreferredChildComponentDescriptorWithIcon, + submenuLabel: React.ReactElement, + defaultVariantImports: Imports, + onItemClick: (_: ElementToInsert) => void, +): ContextMenuItem[] { + const allJSXElements = preferredChildComponentDescriptor.variants.every( + (v) => v.elementToInsert().type === 'JSX_ELEMENT', + ) + + if (allJSXElements) { + return [ + defaultVariantItem( + preferredChildComponentDescriptor.name, + '(empty)', + defaultVariantImports, + submenuLabel, + onItemClick, + ), + ...preferredChildComponentDescriptor.variants.map((variant) => { + return variantItem(variant, submenuLabel, onItemClick) + }), + ] + } + + if (preferredChildComponentDescriptor.variants.length === 1) { + return [singletonItem(submenuLabel, preferredChildComponentDescriptor.variants[0], onItemClick)] + } + + return preferredChildComponentDescriptor.variants.map((variant) => { + return variantItem(variant, submenuLabel, onItemClick) + }) +} + +const ComponentPickerContextMenuSimple = React.memo( + ({ targets, insertionTarget }) => { + const showFullMenu = useCreateCallbackToShowComponentPicker()(targets, insertionTarget, 'full') + + // for insertion we currently only support one target + const firstTarget = targets[0] + const preferredChildren = usePreferredChildrenForTarget(firstTarget, insertionTarget) + + const dispatch = useDispatch() + + const projectContentsRef = useRefEditorState((state) => state.editor.projectContents) + const allElementPropsRef = useRefEditorState((state) => state.editor.allElementProps) + const propertyControlsInfoRef = useRefEditorState((state) => state.editor.propertyControlsInfo) + const metadataRef = useRefEditorState((state) => state.editor.jsxMetadata) + const elementPathTreesRef = useRefEditorState((state) => state.editor.elementPathTree) + + const onItemClick = React.useCallback( + (preferredChildToInsert: ElementToInsert) => + insertPreferredChild( + preferredChildToInsert, + targets, + projectContentsRef.current, + allElementPropsRef.current, + propertyControlsInfoRef.current, + metadataRef.current, + elementPathTreesRef.current, + dispatch, + insertionTarget, + ), + [ + targets, + projectContentsRef, + allElementPropsRef, + propertyControlsInfoRef, + metadataRef, + elementPathTreesRef, + dispatch, + insertionTarget, + ], + ) + const wrapperRef = React.useRef(null) + + const items: Array> = preferredChildren + .flatMap>((data) => { + const iconProps = iconPropsForIcon(data.icon) + + const submenuLabel = ( + + + {data.name} + + ) + + const defaultVariantImports = defaultImportsForComponentModule(data.name, data.moduleName) + + const jsxName = jsxElementNameFromString(data.name) + const name = getJSXElementNameLastPart(jsxName) + if (data.variants == null || data.variants.length === 0) { + return [defaultVariantItem(name, submenuLabel, defaultVariantImports, null, onItemClick)] + } + + return contextMenuItemsFromVariants(data, submenuLabel, defaultVariantImports, onItemClick) + }) + .concat([separatorItem, moreItem(wrapperRef, showFullMenu)]) + + return ( + + ) + }, +) + +const ComponentPickerWrapper = React.memo( + ({ targets, insertionTarget }) => { + // for insertion we currently only support one target + const firstTarget = targets.at(0) + const targetChildren = useEditorState( + Substores.metadata, + (store) => { + return firstTarget == null + ? [] + : MetadataUtils.getChildrenUnordered(store.editor.jsxMetadata, firstTarget) + }, + 'ComponentPickerWrapper targetChildren', + ) + + const areAllJsxElements = useEditorState( + Substores.metadata, + (store) => + targets.every((target) => MetadataUtils.isJSXElement(target, store.editor.jsxMetadata)), + 'ComponentPickerWrapper areAllJsxElements', + ) + + const dispatch = useDispatch() + + const projectContentsRef = useRefEditorState((state) => state.editor.projectContents) + const allElementPropsRef = useRefEditorState((state) => state.editor.allElementProps) + const propertyControlsInfoRef = useRefEditorState((state) => state.editor.propertyControlsInfo) + const metadataRef = useRefEditorState((state) => state.editor.jsxMetadata) + const elementPathTreesRef = useRefEditorState((state) => state.editor.elementPathTree) + + const hideAllContextMenus = React.useCallback(() => { + contextMenu.hideAll() + }, []) + + const mode = insertionTarget.type === 'wrap-target' ? 'wrap' : 'insert' + + const allInsertableComponents = useGetInsertableComponents(mode).flatMap((group) => { + return { + label: group.label, + options: group.options.filter((option) => { + const element = option.value.element() + if ( + isInsertAsChildTarget(insertionTarget) || + isConditionalTarget(insertionTarget) || + isReplaceTarget(insertionTarget) + ) { + return true + } + if (isReplaceKeepChildrenAndStyleTarget(insertionTarget)) { + // If we want to keep the children of this element when it has some, don't include replacements that have children. + return targetChildren.length === 0 || !componentElementToInsertHasChildren(element) + } + if (isWrapTarget(insertionTarget)) { + if (element.type === 'JSX_ELEMENT' && isIntrinsicHTMLElement(element.name)) { + // when it is an intrinsic html element, we check if it supports children from our list + return intrinsicHTMLElementNamesThatSupportChildren.includes( + element.name.baseVariable, + ) + } + if (element.type === 'JSX_MAP_EXPRESSION') { + // we cannot currently wrap in List a conditional, fragment or map expression + return areAllJsxElements + } + return true + } + // Right now we only support inserting JSX elements when we insert into a render prop or when replacing elements + return element.type === 'JSX_ELEMENT' + }), + } + }) + + const onItemClick = React.useCallback( + (preferredChildToInsert: InsertableComponent) => (e: React.UIEvent) => { + e.stopPropagation() + e.preventDefault() + + insertComponentPickerItem( + preferredChildToInsert, + targets, + projectContentsRef.current, + allElementPropsRef.current, + propertyControlsInfoRef.current, + metadataRef.current, + elementPathTreesRef.current, + dispatch, + insertionTarget, + ) + + hideAllContextMenus() + }, + [ + targets, + projectContentsRef, + allElementPropsRef, + propertyControlsInfoRef, + metadataRef, + elementPathTreesRef, + dispatch, + insertionTarget, + hideAllContextMenus, + ], + ) + + return ( + + ) + }, +) + +const ComponentPickerContextMenuFull = React.memo( + ({ targets, insertionTarget }) => { + const squashEvents = React.useCallback((e: React.UIEvent) => { + e.stopPropagation() + }, []) + + const [pickerIsVisible, setPickerIsVisible] = React.useState(false) + + const onVisibilityChange = React.useCallback((isVisible: boolean) => { + setPickerIsVisible(isVisible) + if (isVisible) { + document.body.classList.add(BodyMenuOpenClass) + } else { + document.body.classList.remove(BodyMenuOpenClass) + } + }, []) + + return ( + + {pickerIsVisible ? ( + + ) : null} + + ) + }, +) + +export const ComponentPickerContextMenu = React.memo(() => { + const [{ targets, insertionTarget }] = useAtom(ComponentPickerContextMenuAtom) + + return ( + + + + + ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/component-picker.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/component-picker.tsx new file mode 100644 index 000000000000..c6b73371b5a1 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/component-picker.tsx @@ -0,0 +1,516 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React, { useCallback, useMemo } from 'react' +import debounce from 'lodash.debounce' +import { colorTheme, Icn, type IcnProps } from '../../../uuiui' +import { dark } from '../../../uuiui/styles/theme/dark' +import type { JSXElementChild } from '../../../core/shared/element-template' +import type { ElementPath, Imports } from '../../../core/shared/project-file-types' +import { type ComponentElementToInsert } from '../../custom-code/code-file' +import type { + InsertMenuItem, + InsertMenuItemGroup, + InsertMenuItemValue, +} from '../../canvas/ui/floating-insert-menu' +import { UIGridRow } from '../../../components/inspector/widgets/ui-grid-row' +import { FlexRow, type Icon } from 'utopia-api' +import { insertableComponent } from '../../shared/project-components' +import type { StylePropOption, InsertableComponent } from '../../shared/project-components' +import type { Size } from '../../../core/shared/math-utils' +import { dataPasteHandler } from '../../../utils/paste-handler' +import { sortBy } from '../../../core/shared/array-utils' +import { iconPropsForIcon } from './component-picker-context-menu' +import type { FileRootPath } from '../../canvas/ui-jsx-canvas' + +const FILTER_CATEGORIES: Array = ['Everything'] + +interface Category { + label: string + items: Array +} + +export interface ComponentPickerProps { + allComponents: Array + onItemClick: (elementToInsert: InsertableComponent) => React.UIEventHandler + closePicker: () => void + shownInToolbar: boolean + insertionActive: boolean +} + +export interface ElementToInsert { + name: string + elementToInsert: (uid: string) => JSXElementChild + additionalImports: Imports +} + +export function elementToInsertToInsertableComponent( + elementToInsert: ElementToInsert, + uid: string, + stylePropOptions: Array, + defaultSize: Size | null, + insertionCeiling: ElementPath | FileRootPath, + icon: Icon | null, +): InsertableComponent { + const element = elementToInsert.elementToInsert(uid) + return insertableComponent( + elementToInsert.additionalImports, + () => element as ComponentElementToInsert, + elementToInsert.name, + stylePropOptions, + defaultSize, + insertionCeiling, + icon, + ) +} + +export const ComponentPickerTestId = 'component-picker-full' + +export function componentPickerTestIdForProp(prop: string): string { + return `component-picker-${prop}` +} + +export const componentPickerCloseButtonTestId = `component-picker-close-button` +export const componentPickerFilterInputTestId = `component-picker-filter-input` + +export function componentPickerOptionTestId(componentName: string, variant?: string): string { + const variantSuffix = variant == null ? '' : `-${variant}` + return `component-picker-option-${componentName}${variantSuffix}` +} + +export const ComponentPicker = React.memo((props: ComponentPickerProps) => { + const { onItemClick, closePicker, shownInToolbar, insertionActive } = props + const [selectedComponentKey, setSelectedComponentKey] = React.useState(null) + const [filter, setFilter] = React.useState('') + const menuRef = React.useRef(null) + + const flatComponentsToShowUnsorted = useMemo(() => { + return props.allComponents + .flatMap((c) => c.options) + .filter((v) => v.label.toLocaleLowerCase().includes(filter.toLocaleLowerCase().trim())) + }, [props.allComponents, filter]) + + const flatComponentsToShow = useMemo( + () => + sortBy(flatComponentsToShowUnsorted, (a, b) => + a.label.toLocaleLowerCase().trim().localeCompare(b.label.toLocaleLowerCase().trim()), + ), + [flatComponentsToShowUnsorted], + ) + + const highlightedComponentKey = useMemo(() => { + const firstOptionKey = + flatComponentsToShow.length > 0 ? flatComponentsToShow[0].value.key : null + if (selectedComponentKey == null) { + return firstOptionKey + } + // check if selectedComponentKey is still in the list + const found = flatComponentsToShow.some((c) => c.value.key === selectedComponentKey) + return found ? selectedComponentKey : firstOptionKey + }, [flatComponentsToShow, selectedComponentKey]) + + const onItemHover = useCallback( + (elementToInsert: InsertMenuItemValue) => { + return () => { + setSelectedComponentKey(elementToInsert.key) + } + }, + [setSelectedComponentKey], + ) + + const selectIndex = useCallback( + (index: number) => { + const newKey = flatComponentsToShow[index].value.key + setSelectedComponentKey(newKey) + const selectedComponent = menuRef.current?.querySelector(`[data-key="${newKey}"]`) + if (selectedComponent != null) { + // scroll into view + selectedComponent.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } + }, + [flatComponentsToShow], + ) + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + const currentIndex = flatComponentsToShow.findIndex( + (c) => c.value.key === highlightedComponentKey, + ) + if (currentIndex >= 0 && currentIndex < flatComponentsToShow.length - 1) { + selectIndex(currentIndex + 1) + } + } else if (e.key === 'ArrowUp') { + const currentIndex = flatComponentsToShow.findIndex( + (c) => c.value.key === highlightedComponentKey, + ) + if (currentIndex > 0) { + selectIndex(currentIndex - 1) + } + } else if (e.key === 'Enter') { + const selectedComponent = flatComponentsToShow.find( + (c) => c.value.key === highlightedComponentKey, + ) + if (selectedComponent != null) { + onItemClick(selectedComponent.value)(e) + } + } else if (e.key === 'Escape') { + closePicker() + } else { + // we don't want to prevent default for other keys + return + } + e.preventDefault() + e.stopPropagation() + }, + [flatComponentsToShow, highlightedComponentKey, onItemClick, selectIndex, closePicker], + ) + + const categorizedComponents = [ + { + label: 'Everything', + items: flatComponentsToShow, + }, + ] + + return ( +
+ + {filter.length > 0 || !insertionActive ? ( + + ) : null} +
+ ) +}) + +interface ComponentPickerTopSectionProps { + components: Array + onFilterChange: (filter: string) => void + onKeyDown: (e: React.KeyboardEvent) => void + shownInToolbar: boolean +} + +const ComponentPickerTopSection = React.memo((props: ComponentPickerTopSectionProps) => { + const { components, shownInToolbar, onFilterChange, onKeyDown } = props + + return ( +
+ {components.length > 1 && } + +
+ ) +}) + +interface FilterBarProps { + shownInToolbar: boolean + onFilterChange: (filter: string) => void + onKeyDown?: (e: React.KeyboardEvent) => void +} + +const FilterBar = React.memo((props: FilterBarProps) => { + const { shownInToolbar, onFilterChange, onKeyDown } = props + + const [filter, setFilterState] = React.useState('') + const setFilter = React.useCallback( + (s: string) => { + setFilterState(s) + onFilterChange(s) + }, + [onFilterChange], + ) + + const handleFilterKeydown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (onKeyDown != null) { + onKeyDown(e) + } + } else if (e.key === 'Escape' && filter !== '') { + // clear filter only if there is text, + // otherwise it will close the picker + setFilter('') + } else if (onKeyDown != null) { + onKeyDown(e) + } + e.stopPropagation() + }, + [setFilter, onKeyDown, filter], + ) + const handleFilterChange = React.useCallback( + (e: React.ChangeEvent) => { + setFilter(e.target.value) + }, + [setFilter], + ) + + return ( + doesn't work because uses the css var + border: `1px solid #888`, + color: shownInToolbar ? undefined : `#888`, + borderRadius: 4, + width: '100%', + '&:focus': { + color: shownInToolbar ? undefined : '#ccc', + borderColor: '#ccc', + }, + }} + placeholder='Filter...' + autoComplete='off' + autoFocus={true} + spellCheck={false} + onKeyDown={handleFilterKeydown} + onChange={handleFilterChange} + value={filter} + data-testid={componentPickerFilterInputTestId} + {...dataPasteHandler(true)} + /> + ) +}) + +interface FilterButtonsProps { + components: Array +} + +const FilterButtons = React.memo((props: FilterButtonsProps) => { + const { components } = props + + const [focusedIndex, setFocusedIndex] = React.useState(0) + + const setActiveIndexAll = React.useCallback(() => setFocusedIndex(0), [setFocusedIndex]) + + return ( +
{ + if (event.key === 'ArrowRight') { + setFocusedIndex((prev) => Math.min(prev + 1, FILTER_CATEGORIES.length)) + } else if (event.key === 'ArrowLeft') { + setFocusedIndex((prev) => Math.max(prev - 1, 0)) + } else if (event.key === 'Enter') { + document + .getElementById(FILTER_CATEGORIES[Math.max(focusedIndex - 1, 0)]) + ?.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } else { + return + } + event.stopPropagation() + event.preventDefault() + }} + > +
+ +
+
    + {components.map(({ label }, index) => ( +
  • + +
  • + ))} +
+
+ ) +}) + +interface FilterButtonProps { + highlighted: boolean + index: number + label: string + setActiveFocus: (index: number) => void +} + +const FilterButton = React.memo((props: FilterButtonProps) => { + const { highlighted, index, label, setActiveFocus } = props + + const ref = React.useRef(null) + + React.useEffect(() => { + if (highlighted && ref.current !== null) { + ref.current.scrollIntoView({ + block: 'start', + behavior: 'instant', + }) + } + }, [highlighted]) + + return ( + + ) +}) + +interface ComponentPickerComponentSectionProps { + components: Array + onItemClick: (elementToInsert: InsertableComponent) => React.MouseEventHandler + onItemHover: (elementToInsert: InsertMenuItemValue) => React.MouseEventHandler + currentlySelectedKey: string | null + shownInToolbar: boolean +} + +export function componentPickerComponentTestId(key: string): string { + return `component-picker-item-${key}` +} + +const ComponentPickerComponentSection = React.memo( + (props: ComponentPickerComponentSectionProps) => { + const { components, onItemClick, onItemHover, currentlySelectedKey, shownInToolbar } = props + const [isScrolling, setIsScrolling] = React.useState(false) + const debouncedSetIsScrolling = React.useRef(debounce(() => setIsScrolling(false), 100)) + const onScroll = React.useCallback(() => { + setIsScrolling(true) + debouncedSetIsScrolling.current() + }, []) + + return ( +
+ {components.flatMap((category) => + category.items.map((component, index) => { + const isSelected = component.value.key === currentlySelectedKey + return ( + + + + {component.label} + + + ) + }), + )} +
+ ) + }, +) diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/component-preview.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/component-preview.tsx new file mode 100644 index 000000000000..412703a0d96f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/component-preview.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import type { IcnProps } from '../../../uuiui' +import { Icn } from '../../../uuiui' +import { useComponentIcon } from '../layout-element-icons' +import type { NavigatorEntry } from '../../../components/editor/store/editor-state' + +interface ComponentPreviewProps { + navigatorEntry: NavigatorEntry + color: IcnProps['color'] +} + +export const ComponentPreview: React.FunctionComponent< + React.PropsWithChildren +> = React.memo((props) => { + const iconProps = useComponentIcon(props.navigatorEntry) + + if (iconProps == null) { + return null + } else { + return ( +
+ +
+ ) + } +}) diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/expandable-indicator.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/expandable-indicator.tsx new file mode 100644 index 000000000000..a68189e34c20 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/expandable-indicator.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import type { IcnProps } from '../../../uuiui' +import { Icn } from '../../../uuiui' + +export const ExpansionArrowWidth = 8 +export const ExpansionArrowHeight = 8 + +interface ExpandableIndicatorProps { + visible: boolean + collapsed: boolean + selected: boolean + onMouseDown?: (e: any) => void + onClick?: (e: any) => void + testId?: string + style?: React.CSSProperties + iconColor?: IcnProps['color'] +} + +export const ExpandableIndicator: React.FunctionComponent< + React.PropsWithChildren +> = React.memo((props) => { + const color = props.iconColor + + return ( +
+ +
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/item-label.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/item-label.tsx new file mode 100644 index 000000000000..8e9c11941f3a --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/item-label.tsx @@ -0,0 +1,297 @@ +import type { CSSProperties } from 'react' +import React from 'react' +import type { NavigatorEntry } from '../../../components/editor/store/editor-state' +import { + isConditionalClauseNavigatorEntry, + isInvalidOverrideNavigatorEntry, + isRegularNavigatorEntry, + varSafeNavigatorEntryToKey, +} from '../../../components/editor/store/editor-state' +import { + findMaybeConditionalExpression, + getConditionalActiveCase, + getConditionalFlag, +} from '../../../core/model/conditionals' +import { colorTheme, flexRowStyle, StringInput } from '../../../uuiui' +import type { EditorDispatch, ElementPaste } from '../../editor/action-types' +import * as EditorActions from '../../editor/actions/action-creators' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { renameComponent } from '../actions' +import type { RemixItemType } from './navigator-item' +import { NavigatorItemTestId } from './navigator-item' +import { useAtom } from 'jotai' +import type { RemixNavigationAtomData } from '../../canvas/remix/utopia-remix-root-component' +import { RemixNavigationAtom } from '../../canvas/remix/utopia-remix-root-component' +import * as EP from '../../../core/shared/element-path' +import type { ElementPath } from '../../../core/shared/project-file-types' +import type { Location } from 'react-router' +import { RemixIndexPathLabel, getRemixLocationLabel } from '../../canvas/remix/remix-utils' + +export function itemLabelTestIdForEntry(navigatorEntry: NavigatorEntry): string { + return `${NavigatorItemTestId(varSafeNavigatorEntryToKey(navigatorEntry))}-label` +} + +interface ItemLabelProps { + testId: string + dispatch: EditorDispatch + target: NavigatorEntry + isDynamic: boolean + selected: boolean + name: string + suffix?: string + inputVisible: boolean + style?: CSSProperties + remixItemType: RemixItemType +} + +export const ItemLabel = React.memo((props: ItemLabelProps) => { + const { + name: propsName, + testId, + dispatch, + target, + isDynamic, + suffix, + inputVisible, + style, + } = props + + const elementRef = React.useRef(null) + + const [name, setName] = React.useState(propsName) + + const isConditionalClause = React.useMemo(() => { + return isConditionalClauseNavigatorEntry(target) + }, [target]) + + const isActiveConditionalClause = useEditorState( + Substores.metadata, + (store) => { + if (!isConditionalClauseNavigatorEntry(target)) { + return false + } + const parent = findMaybeConditionalExpression(target.elementPath, store.editor.jsxMetadata) + if (parent == null) { + return false + } + const activeCase = getConditionalActiveCase( + target.elementPath, + parent, + store.editor.spyMetadata, + ) + if (activeCase == null) { + return false + } + return activeCase === target.clause + }, + 'NavigatorItemLabel isActiveBranchOfOverriddenConditional', + ) + + const isInvalidOverride = isInvalidOverrideNavigatorEntry(target) + + React.useEffect(() => { + if (inputVisible && elementRef.current != null) { + elementRef.current.focus() + elementRef.current.select() + } + }, [inputVisible, testId]) + + const maybeLinkTarget = useEditorState( + Substores.metadata, + (store) => { + if (props.remixItemType !== 'link') { + return null + } + return store.editor.allElementProps[EP.toString(props.target.elementPath)]?.['to'] ?? null + }, + 'ItemLabel maybeLinkTarget', + ) + + const [remixNavigationData] = useAtom(RemixNavigationAtom) + + const maybePathForOutlet = React.useMemo(() => { + if (props.remixItemType !== 'outlet') { + return null + } + return remixSceneLocationFromOutletPath(props.target.elementPath, remixNavigationData)?.pathname + }, [props.remixItemType, props.target.elementPath, remixNavigationData]) + + const label = React.useMemo(() => { + if (maybeLinkTarget != null) { + return maybeLinkTarget + } + if (maybePathForOutlet != null) { + return `Outlet: ${getRemixLocationLabel(maybePathForOutlet)}` + } + return suffix == null ? name : `Outlet: ${name} ${suffix}` + }, [maybeLinkTarget, maybePathForOutlet, suffix, name]) + + const cancelRename = React.useCallback(() => { + setName(name) + dispatch([EditorActions.setNavigatorRenamingTarget(null)], 'leftpane') + }, [dispatch, name]) + + const triggerRenameComponent = React.useCallback(() => { + if (isRegularNavigatorEntry(target)) { + // if the name would be the same, or if the new name would be empty, just cancel + if (propsName === name) { + cancelRename() + } else { + const nameIsBlank = name.trim().length === 0 + const action = renameComponent(target.elementPath, nameIsBlank ? null : name) + dispatch([action, EditorActions.setNavigatorRenamingTarget(null)], 'leftpane') + } + } else { + cancelRename() + } + }, [cancelRename, target, propsName, dispatch, name]) + + const onInputLabelKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key == 'Enter') { + triggerRenameComponent() + } + if (event.key == 'Escape') { + cancelRename() + } + }, + [cancelRename, triggerRenameComponent], + ) + + const onInputLabelBlur = React.useCallback(() => { + triggerRenameComponent() + }, [triggerRenameComponent]) + + const onInputLabelChange = React.useCallback((event: React.ChangeEvent) => { + setName(event.target.value) + }, []) + + const isActiveBranchOfOverriddenConditional = useEditorState( + Substores.metadata, + (store) => { + if (!isConditionalClauseNavigatorEntry(target)) { + return false + } + + const conditional = findMaybeConditionalExpression( + target.elementPath, + store.editor.jsxMetadata, + ) + if (conditional == null) { + return false + } + + switch (getConditionalFlag(conditional)) { + case true: + return target.clause === 'true-case' + case false: + return target.clause === 'false-case' + default: + return false + } + }, + 'NavigatorItemLabel isActiveBranchOfOverriddenConditional', + ) + + const color = (() => { + if (isActiveBranchOfOverriddenConditional) { + return colorTheme.brandNeonPink.value + } + if (isActiveConditionalClause) { + return colorTheme.dynamicBlue.value + } + if (isConditionalClause) { + return colorTheme.fg7.value + } + if (isInvalidOverride) { + return colorTheme.brandNeonPink.value + } + return style?.color + })() + + return ( +
+ {isConditionalClause && ( +
+ ✓ +
+ )} + {inputVisible ? ( +
+ +
+ ) : ( +
{ + if (!isDynamic && event.altKey && isRegularNavigatorEntry(target)) { + dispatch([EditorActions.setNavigatorRenamingTarget(target.elementPath)], 'leftpane') + } + }} + > + {label} +
+ )} +
+ ) +}) + +// here +function remixSceneLocationFromOutletPath( + outletPath: ElementPath, + remixNavigationData: RemixNavigationAtomData, +): Location | null { + return EP.findAmongAncestorsOfPath( + outletPath, + (p) => remixNavigationData[EP.toString(p)]?.location ?? null, + ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/layout-icon.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/layout-icon.tsx new file mode 100644 index 000000000000..d8395148718c --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/layout-icon.tsx @@ -0,0 +1,286 @@ +import React from 'react' +import type { ElementWarnings, NavigatorEntry } from '../../../components/editor/store/editor-state' +import { + isInvalidOverrideNavigatorEntry, + navigatorEntryToKey, +} from '../../../components/editor/store/editor-state' +import type { IcnProps } from '../../../uuiui' +import { Icn, Icons } from '../../../uuiui' +import { invalidGroupStateToString } from '../../canvas/canvas-strategies/strategies/group-helpers' +import { ChildWithPercentageSize } from '../../common/size-warnings' +import { useLayoutOrElementIcon } from '../layout-element-icons' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import type { ElementPath } from '../../../core/shared/project-file-types' +import type { ElementInstanceMetadataMap } from '../../../core/shared/element-template' +import { isInfinityRectangle } from '../../../core/shared/math-utils' +import { isZeroSizedElement } from '../../canvas/controls/outline-utils' +import { optionalMap } from '../../../core/shared/optional-utils' +import createCachedSelector from 're-reselect' +import { metadataSelector } from '../../inspector/inpector-selectors' +import type { MetadataSubstate } from '../../editor/store/store-hook-substore-types' +import * as EP from '../../../core/shared/element-path' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import type { Icon } from 'utopia-api' +import { when } from '../../../utils/react-conditionals' + +interface LayoutIconProps { + navigatorEntry: NavigatorEntry + override?: Icon | null + color: IcnProps['color'] + warningText?: string | null + elementWarnings?: ElementWarnings | null +} + +export function layoutIconTestIdForEntry(navigatorEntry: NavigatorEntry): string { + return `layout-icn-${navigatorEntryToKey(navigatorEntry)}` +} + +export function isZeroSizedDiv(elementPath: ElementPath, metadata: ElementInstanceMetadataMap) { + const bounds = MetadataUtils.getFrameInCanvasCoords(elementPath, metadata) + if (bounds == null || isInfinityRectangle(bounds)) { + return false + } + + const isElementDiv = + optionalMap( + (i) => MetadataUtils.isDiv(i), + MetadataUtils.findElementByElementPath(metadata, elementPath), + ) ?? false + + return isZeroSizedElement(bounds) && isElementDiv +} + +const isZeroSizedDivSelector = createCachedSelector( + metadataSelector, + (_: MetadataSubstate, x: ElementPath) => x, + (metadata, elementPath) => { + return isZeroSizedDiv(elementPath, metadata) + }, +)((_, x) => EP.toString(x)) + +export const LayoutIcon: React.FunctionComponent> = + React.memo((props) => { + const { + elementWarnings, + color: baseColor, + warningText: propsWarningText, + navigatorEntry, + } = props + const { iconProps, isPositionAbsolute } = useLayoutOrElementIcon(navigatorEntry) + + const addAbsoluteMarkerToIcon = isPositionAbsolute + + const isZeroSized = useEditorState( + Substores.metadata, + (store) => isZeroSizedDivSelector(store, props.navigatorEntry.elementPath), + 'LayoutIcon isZeroSized', + ) + + const warningText = React.useMemo(() => { + if (elementWarnings == null) { + return propsWarningText ?? null + } + if (elementWarnings.dynamicSceneChildWidthHeightPercentage) { + return ChildWithPercentageSize + } else if (elementWarnings.widthOrHeightZero) { + return 'Missing width or height' + } else if (elementWarnings.absoluteWithUnpositionedParent) { + return 'Element is trying to be positioned absolutely with an unconfigured parent. Add absolute or relative position to the parent.' + } else if (elementWarnings.invalidGroup != null) { + return invalidGroupStateToString(elementWarnings.invalidGroup) + } else if (elementWarnings.invalidGroupChild != null) { + return invalidGroupStateToString(elementWarnings.invalidGroupChild) + } else { + return propsWarningText ?? null + } + }, [elementWarnings, propsWarningText]) + + const isErroredGroup = React.useMemo( + () => elementWarnings?.invalidGroup != null, + [elementWarnings], + ) + const isErroredGroupChild = React.useMemo( + () => elementWarnings?.invalidGroupChild != null, + [elementWarnings], + ) + + const iconTestId = React.useMemo( + () => layoutIconTestIdForEntry(navigatorEntry), + [navigatorEntry], + ) + + const { color, iconType, transform } = React.useMemo(() => { + let colorToReturn = baseColor + let iconTypeToReturn = iconProps.type + let transformToReturn: string | undefined = undefined + if (props.override != null) { + iconTypeToReturn = props.override + if (baseColor === 'white') { + colorToReturn = baseColor + } else if ( + props.override === 'row' || + props.override === 'column' || + props.override === 'layout' || + props.override === 'grid' + ) { + colorToReturn = 'primary' + } else { + colorToReturn = baseColor + } + } else if (isZeroSized) { + iconTypeToReturn = 'zerosized-div' + colorToReturn = baseColor + } else if (isInvalidOverrideNavigatorEntry(navigatorEntry)) { + iconTypeToReturn = 'warningtriangle' + colorToReturn = baseColor + } else if (warningText == null) { + transformToReturn = addAbsoluteMarkerToIcon ? 'scale(.8)' : undefined + if (baseColor === 'white') { + colorToReturn = 'white' + } else if (baseColor === 'component') { + colorToReturn = 'component' + } else if ( + iconProps.type === 'row' || + iconProps.type === 'column' || + iconProps.type === 'flex-column' || + iconProps.type === 'flex-row' || + iconProps.type === 'grid' + ) { + colorToReturn = 'primary' + } else { + colorToReturn = baseColor + } + } else if (isErroredGroup) { + iconTypeToReturn = 'group-problematic' + } else if (isErroredGroupChild) { + iconTypeToReturn = iconProps.type + } else { + iconTypeToReturn = 'warningtriangle' + } + return { color: colorToReturn, iconType: iconTypeToReturn, transform: transformToReturn } + }, [ + addAbsoluteMarkerToIcon, + baseColor, + iconProps.type, + isErroredGroup, + isErroredGroupChild, + isZeroSized, + navigatorEntry, + props.override, + warningText, + ]) + + const icon = React.useMemo(() => { + return ( +
+ +
+ ) + }, [transform, iconType, warningText, iconTestId, color]) + + const marker = React.useMemo(() => { + if (warningText != null && isErroredGroupChild) { + return ( + + ) + } else if (addAbsoluteMarkerToIcon) { + return ( + + ) + } else { + return null + } + }, [addAbsoluteMarkerToIcon, color, warningText, isErroredGroupChild, iconTestId]) + + const listIndex = useEditorState( + Substores.metadata, + (store) => { + const generatedIndex = EP.extractIndexFromIndexedUid( + EP.toUid(props.navigatorEntry.elementPath), + ) + if (generatedIndex == null) { + return null + } + const parent = EP.parentPath(props.navigatorEntry.elementPath) + return MetadataUtils.isJSXMapExpression(parent, store.editor.jsxMetadata) + ? generatedIndex + : null + }, + 'NavigatorRowLabel listIndex', + ) + + return ( +
+ {when( + marker != null, +
+ {marker} +
, + )} + {when( + listIndex != null, +
+ {listIndex} +
, + )} + {icon} +
+ ) + }) diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/map-counter.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/map-counter.tsx new file mode 100644 index 000000000000..0f95675780a0 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/map-counter.tsx @@ -0,0 +1,202 @@ +import React from 'react' +import type { CSSProperties } from 'react' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import * as EP from '../../../core/shared/element-path' +import type { ElementPath } from '../../../core/shared/project-file-types' +import { + findUtopiaCommentFlag, + isUtopiaPropOrCommentFlagMapCount, +} from '../../../core/shared/utopia-flags' +import { isRight } from '../../../core/shared/either' +import { isJSXMapExpression } from '../../../core/shared/element-template' +import { assertNever } from '../../../core/shared/utils' +import { setMapCountOverride } from '../../editor/actions/action-creators' +import { useDispatch } from '../../editor/store/dispatch-context' +import { colorTheme } from '../../../uuiui' + +export const MapCounterTestIdPrefix = 'map-counter-' + +export function getMapCounterTestId( + path: ElementPath, + source: 'navigator' | 'inspector' | 'data-picker', +): string { + return `${MapCounterTestIdPrefix}${EP.toString(path)}-${source}` +} + +type OverrideStatus = 'no-override' | 'overridden' | 'override-failed' +type SelectedStatus = true | false + +interface MapCounterProps { + elementPath: ElementPath + selected: boolean + source: 'navigator' | 'inspector' | 'data-picker' +} + +export const MapCounter = React.memo((props: MapCounterProps) => { + const dispatch = useDispatch() + + const counter = useEditorState( + Substores.metadata, + (store) => { + const elementMetadata = MetadataUtils.findElementByElementPath( + store.editor.jsxMetadata, + props.elementPath, + ) + + const element = + elementMetadata != null && isRight(elementMetadata.element) + ? elementMetadata.element.value + : null + if (element == null || !isJSXMapExpression(element)) { + return null + } + + const commentFlag = findUtopiaCommentFlag(element.comments, 'map-count') + const mapCountOverride = isUtopiaPropOrCommentFlagMapCount(commentFlag) + ? commentFlag.value + : null + return { + nrChildren: MetadataUtils.getChildrenOrdered( + store.editor.jsxMetadata, + store.editor.elementPathTree, + props.elementPath, + ).length, + countOverride: mapCountOverride, + } + }, + 'MapCounter counterValue', + ) + + const nrChildren = React.useMemo(() => { + return counter?.nrChildren ?? null + }, [counter]) + + const countOverride = React.useMemo(() => { + return counter?.countOverride ?? null + }, [counter]) + + const isOverridden = nrChildren != null && countOverride != null + const shownCounterValue = countOverride ?? nrChildren + const overrideFailed = isOverridden && countOverride > nrChildren + const overrideStatus: OverrideStatus = (() => { + if (isOverridden) { + if (overrideFailed) { + return 'override-failed' + } + return 'overridden' + } + return 'no-override' + })() + + const onClick = React.useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (nrChildren == null) { + return + } + const nextValue = getNextOverrideValue(overrideStatus, countOverride, nrChildren) + if (nextValue !== countOverride) { + dispatch([setMapCountOverride(props.elementPath, nextValue)]) + } + }, + [props.elementPath, dispatch, overrideStatus, countOverride, nrChildren], + ) + + const selectedStatus = props.selected + + return ( + + ) +}) + +function getMapCounterStyleProps( + overrideStatus: OverrideStatus, + selectedStatus: SelectedStatus, +): CSSProperties { + const stylePropsBase: CSSProperties = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: 15, + width: 'max-content', + minWidth: 15, + borderRadius: 4, + padding: 4, + fontWeight: 400, + marginRight: 2, + } + + switch (overrideStatus) { + case 'no-override': + return { + ...stylePropsBase, + backgroundColor: selectedStatus ? colorTheme.whiteOpacity30.value : colorTheme.bg1.value, + color: selectedStatus ? colorTheme.white.value : undefined, + } + case 'overridden': + return { + ...stylePropsBase, + backgroundColor: colorTheme.brandNeonPink60.value, + } + case 'override-failed': + return { + ...stylePropsBase, + background: `linear-gradient(to left bottom, ${colorTheme.brandNeonPink60.value} 47%, ${colorTheme.brandNeonPink.value} 48%, ${colorTheme.brandNeonPink.value} 52%, ${colorTheme.brandNeonPink60.value} 53%)`, + boxSizing: 'border-box', + border: `1px solid ${colorTheme.brandNeonPink60.value}`, + } + default: + assertNever(overrideStatus) + } +} +interface MapCounterUIProps { + 'data-testid'?: string + counterValue: number | null + overrideStatus: OverrideStatus + selectedStatus: SelectedStatus + onClick?: React.MouseEventHandler +} +export const MapCounterUi = React.memo((props: MapCounterUIProps) => { + const { counterValue, overrideStatus, selectedStatus, onClick } = props + + return ( +
+ {counterValue} +
+ ) +}) + +function getNextOverrideValue( + overrideStatus: OverrideStatus, + countOverride: number | null, + nrChildren: number, +): number | null { + const maxOverride = Math.min(2, nrChildren) + switch (overrideStatus) { + case 'no-override': + return maxOverride + case 'override-failed': + return null + case 'overridden': + if (countOverride === null || countOverride > 2) { + return maxOverride + } + if (countOverride === 2 || countOverride === 1) { + return Math.min(countOverride - 1, nrChildren) + } + return null + default: + assertNever(overrideStatus) + } +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-condensed-entry.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-condensed-entry.tsx new file mode 100644 index 000000000000..6f208b183679 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-condensed-entry.tsx @@ -0,0 +1,585 @@ +import React from 'react' +import * as EP from '../../../core/shared/element-path' +import { Icons, Tooltip, useColorTheme } from '../../../uuiui' +import { useDispatch } from '../../editor/store/dispatch-context' +import type { DataReferenceNavigatorEntry, NavigatorEntry } from '../../editor/store/editor-state' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { condensedNavigatorRow, type CondensedNavigatorRow } from '../navigator-row' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import { getNavigatorEntryLabel, labelSelector } from './navigator-item-wrapper' +import { + BasePaddingUnit, + NavigatorRowBorderRadius, + elementWarningsSelector, +} from './navigator-item' +import { setHighlightedViews, toggleCollapse } from '../../editor/actions/action-creators' +import type { ElementPath } from 'utopia-shared/src/types' +import { unless, when } from '../../../utils/react-conditionals' +import { ExpandableIndicator } from './expandable-indicator' +import { LayoutIcon } from './layout-icon' +import { DataReferenceCartoucheControl } from '../../inspector/sections/component-section/data-reference-cartouche' +import { + NavigatorRowClickableWrapper, + useGetNavigatorMouseDownActions, +} from './navigator-item-clickable-wrapper' +import type { ThemeObject } from '../../../uuiui/styles/theme/theme-helpers' +import { useNavigatorSelectionBoundsForEntry } from './use-navigator-selection-bounds-for-entry' + +function useEntryLabel(entry: NavigatorEntry) { + const labelForTheElement = useEditorState( + Substores.projectContentsAndMetadata, + (store) => labelSelector(store, entry), + 'CondensedEntry labelSelector', + ) + + const entryLabel = React.useMemo(() => { + return getNavigatorEntryLabel(entry, labelForTheElement) + }, [entry, labelForTheElement]) + + return entryLabel +} + +function getSelectionColor(colorTheme: ThemeObject, isComponent: boolean) { + return isComponent + ? { main: colorTheme.selectionPurple.value, child: colorTheme.childSelectionPurple.value } + : { main: colorTheme.selectionBlue.value, child: colorTheme.childSelectionBlue.value } +} + +export const CondensedEntryItemWrapper = React.memo( + (props: { windowStyle: React.CSSProperties; navigatorRow: CondensedNavigatorRow }) => { + const colorTheme = useColorTheme() + + const selectedViews = useEditorState( + Substores.selectedViews, + (store) => store.editor.selectedViews, + 'CondensedEntryItemWrapper selectedViews', + ) + + const hasSelection = React.useMemo(() => { + return selectedViews.some((path) => + props.navigatorRow.entries.some( + (entry) => entry.type !== 'DATA_REFERENCE' && EP.pathsEqual(path, entry.elementPath), + ), + ) + }, [selectedViews, props.navigatorRow]) + + const wholeRowInsideSelection = React.useMemo(() => { + return selectedViews.some((path) => { + return props.navigatorRow.entries.every((entry) => { + return EP.isDescendantOfOrEqualTo(entry.elementPath, path) + }) + }) + }, [selectedViews, props.navigatorRow]) + + const isCollapsed = useEditorState( + Substores.navigator, + (store) => + store.editor.navigator.collapsedViews.some((path) => + props.navigatorRow.entries.some((entry) => EP.pathsEqual(path, entry.elementPath)), + ), + 'CondensedEntryItemWrapper isCollapsed', + ) + + const autoFocusedPaths = useEditorState( + Substores.derived, + (store) => store.derived.autoFocusedPaths, + 'CondensedEntryItemWrapper autoFocusedPaths', + ) + + const isComponentOrInsideComponent = useEditorState( + Substores.focusedElement, + (store) => + props.navigatorRow.entries.some( + (entry) => + EP.isInExplicitlyFocusedSubtree( + store.editor.focusedElementPath, + autoFocusedPaths, + entry.elementPath, + ) || + EP.isExplicitlyFocused( + store.editor.focusedElementPath, + autoFocusedPaths, + entry.elementPath, + ), + ), + 'CondensedEntryItemWrapper isInFocusedComponentSubtree', + ) + + const isDataReferenceRow = React.useMemo(() => { + return props.navigatorRow.entries.every( + (entry, idx) => + (idx === 0 && entry.type === 'REGULAR') || (idx > 0 && entry.type === 'DATA_REFERENCE'), + ) + }, [props.navigatorRow]) + + const rowContainsSelection = React.useMemo(() => { + return props.navigatorRow.entries.some((entry) => + selectedViews.some((view) => EP.pathsEqual(view, entry.elementPath)), + ) + }, [selectedViews, props.navigatorRow]) + + const rowRootSelected = React.useMemo(() => { + return selectedViews.some((view) => + EP.pathsEqual(view, props.navigatorRow.entries[0].elementPath), + ) + }, [selectedViews, props.navigatorRow]) + + function getBackgroundColor() { + if (rowRootSelected) { + return getSelectionColor(colorTheme, isComponentOrInsideComponent).main + } else if (hasSelection || wholeRowInsideSelection) { + return getSelectionColor(colorTheme, isComponentOrInsideComponent).child + } else { + return 'transparent' + } + } + const { isTopOfSelection, isBottomOfSelection } = useNavigatorSelectionBoundsForEntry( + props.navigatorRow.entries[0], + rowRootSelected, + 0, + ) + + return ( +
+ + {props.navigatorRow.entries.map((entry, idx) => { + const showSeparator = + props.navigatorRow.variant === 'trunk' && idx < props.navigatorRow.entries.length - 1 + + return ( + + ) + })} + +
+ ) + }, +) +CondensedEntryItemWrapper.displayName = 'CondensedEntryItemWrapper' + +const CondensedEntryItem = React.memo( + (props: { + entry: NavigatorEntry + navigatorRow: CondensedNavigatorRow + isDataReferenceRow: boolean + rowContainsSelection: boolean + rowRootSelected: boolean + wholeRowInsideSelection: boolean + showSeparator: boolean + showExpandableIndicator: boolean + isComponentOrInsideComponent: boolean + }) => { + const colorTheme = useColorTheme() + + const selectedViews = useEditorState( + Substores.selectedViews, + (store) => store.editor.selectedViews, + 'CondensedEntryItem selectedViews', + ) + + const rowEntriesBeforeThisOne = React.useMemo(() => { + let entries: NavigatorEntry[] = [] + for (const entry of props.navigatorRow.entries) { + if (EP.pathsEqual(entry.elementPath, props.entry.elementPath)) { + break + } + entries.push(entry) + } + return entries + }, [props.entry, props.navigatorRow]) + + const isChildOfSelected = React.useMemo(() => { + return selectedViews.some((path) => { + return rowEntriesBeforeThisOne.some((other) => EP.pathsEqual(other.elementPath, path)) + }) + }, [selectedViews, rowEntriesBeforeThisOne]) + + const selectionIsDataReference = React.useMemo(() => { + const entries = props.navigatorRow.entries.filter((entry) => + selectedViews.some((path) => EP.pathsEqual(path, entry.elementPath)), + ) + return entries.some((entry) => entry.type === 'DATA_REFERENCE') + }, [props.navigatorRow, selectedViews]) + + const isSelected = React.useMemo(() => { + return selectedViews.some((path) => EP.pathsEqual(path, props.entry.elementPath)) + }, [selectedViews, props.entry]) + + const indentation = React.useMemo(() => { + if (!props.showExpandableIndicator) { + return 0 + } + return BasePaddingUnit * props.navigatorRow.indentation + }, [props.showExpandableIndicator, props.navigatorRow.indentation]) + + const backgroundColor = React.useMemo((): string => { + if (props.wholeRowInsideSelection) { + return 'transparent' + } else if (!selectionIsDataReference && (isSelected || isChildOfSelected)) { + return getSelectionColor(colorTheme, props.isComponentOrInsideComponent).child + } else { + return colorTheme.bg1.value + } + }, [ + props.wholeRowInsideSelection, + colorTheme, + selectionIsDataReference, + isSelected, + isChildOfSelected, + props.isComponentOrInsideComponent, + ]) + + return ( + + + {when( + props.showSeparator, + , + )} + + ) + }, +) +CondensedEntryItem.displayName = 'CondensedEntryItem' + +const CondensedEntryItemContent = React.memo( + (props: { + entry: NavigatorEntry + wholeRowInsideSelection: boolean + isChildOfSelected: boolean + selected: boolean + rowRootSelected: boolean + showExpandableIndicator: boolean + isDataReferenceRow: boolean + indentation: number + isComponentOrInsideComponent: boolean + }) => { + const dispatch = useDispatch() + const colorTheme = useColorTheme() + + const highlightedViews = useEditorState( + Substores.highlightedHoveredViews, + (store) => store.editor.highlightedViews, + 'CondensedEntryItemContent highlightedViews', + ) + + const isCollapsed = useEditorState( + Substores.navigator, + (store) => + store.editor.navigator.collapsedViews.some((path) => + EP.pathsEqual(path, props.entry.elementPath), + ), + 'CondensedEntryItemContent isCollapsed', + ) + + const showLabel = useEditorState( + Substores.metadata, + (store) => { + return ( + MetadataUtils.isProbablyScene(store.editor.jsxMetadata, props.entry.elementPath) || + MetadataUtils.isProbablyRemixScene(store.editor.jsxMetadata, props.entry.elementPath) + ) + }, + 'CondensedEntryItemContent isScene', + ) + + const isDataReference = React.useMemo(() => { + return props.entry.type === 'DATA_REFERENCE' + }, [props.entry]) + + const onMouseOver = React.useCallback(() => { + dispatch([setHighlightedViews([props.entry.elementPath])]) + }, [props.entry, dispatch]) + + const onMouseOut = React.useCallback(() => { + dispatch([ + setHighlightedViews( + highlightedViews.filter((path) => !EP.pathsEqual(path, props.entry.elementPath)), + ), + ]) + }, [props.entry, dispatch, highlightedViews]) + + const getMouseDownActions = useGetNavigatorMouseDownActions( + props.entry.elementPath, + props.selected, + condensedNavigatorRow([props.entry], 'leaf', props.indentation), + ) + + const onMouseDown = React.useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + dispatch(getMouseDownActions(e)) + }, + [dispatch, getMouseDownActions], + ) + + return ( +
+
+
+ {when( + props.showExpandableIndicator, + , + )} + {unless( + isDataReference, + , + )} +
+ {when(showLabel, )} + {when( + isDataReference, + , + )} +
+
+ ) + }, +) +CondensedEntryItemContent.displayName = 'CondensedEntryItemContent' + +type CondensedEntryTrunkSeparatorProps = { + backgroundColor: string + selected: boolean +} + +const CondensedEntryTrunkSeparator = React.memo((props: CondensedEntryTrunkSeparatorProps) => { + const colorTheme = useColorTheme() + + return ( +
+
+ +
+
+ ) +}) + +CondensedEntryTrunkSeparator.displayName = 'CondensedEntryTrunkSeparator' + +const WrappedExpandableIndicator = React.memo( + (props: { + elementPath: ElementPath + selected: boolean + collapsed: boolean + disabled: boolean + invisible: boolean + }) => { + const dispatch = useDispatch() + + const onClickCollapse = React.useCallback( + (elementPath: ElementPath) => (e: React.MouseEvent) => { + if (props.disabled) { + return + } + e.stopPropagation() + dispatch([toggleCollapse(elementPath)], 'leftpane') + }, + [dispatch, props.disabled], + ) + + return ( +
+ {when(props.invisible,
)} + {unless( + props.disabled || props.invisible, + , + )} +
+ ) + }, +) +WrappedExpandableIndicator.displayName = 'WrappedExpandableIndicator' + +const WrappedLayoutIcon = React.memo( + (props: { entry: NavigatorEntry; hideTooltip: boolean; selected: boolean }) => { + const entryLabel = useEntryLabel(props.entry) + + const iconOverride = useEditorState( + Substores.propertyControlsInfo, + (store) => + MetadataUtils.getIconOfComponent( + props.entry.elementPath, + store.editor.propertyControlsInfo, + store.editor.projectContents, + ), + 'WrappedLayoutIcon iconOverride', + ) + + const elementWarnings = useEditorState( + Substores.derived, + (store) => elementWarningsSelector(store, props.entry), + 'WrappedLayoutIcon elementWarningsSelector', + ) + + return ( + +
+ +
+
+ ) + }, +) +WrappedLayoutIcon.displayName = 'WrappedLayoutIcon' + +const WrappedLabel = React.memo((props: { selected: boolean; entry: NavigatorEntry }) => { + const entryLabel = useEntryLabel(props.entry) + + return ( + + {entryLabel} + + ) +}) +WrappedLabel.displayName = 'WrappedLabel' diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-clickable-wrapper.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-clickable-wrapper.tsx new file mode 100644 index 000000000000..856a766f22f8 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-clickable-wrapper.tsx @@ -0,0 +1,323 @@ +import React from 'react' +import type { ConditionalCase } from '../../../core/model/conditionals' +import { + getConditionalCaseCorrespondingToBranchPath, + isActiveBranchOfConditional, + isDefaultBranchOfConditional, + isOverriddenConditional, +} from '../../../core/model/conditionals' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import * as EP from '../../../core/shared/element-path' +import type { EditorAction } from '../../editor/action-types' +import * as EditorActions from '../../editor/actions/action-creators' +import type { NavigatorEntry } from '../../editor/store/editor-state' +import { + getElementFromProjectContents, + isConditionalClauseNavigatorEntry, +} from '../../editor/store/editor-state' +import { Substores, useEditorState, useRefEditorState } from '../../editor/store/store-hook' +import { getHighlightBoundsForProject } from '../../../core/model/project-file-utils' +import { + selectedElementChangedMessageFromHighlightBounds, + sendMessage, +} from '../../../core/vscode/vscode-bridge' +import { isRegulaNavigatorRow, type NavigatorRow } from '../navigator-row' +import { useDispatch } from '../../editor/store/dispatch-context' +import { isRight } from '../../../core/shared/either' +import type { ElementPath, ProjectContentTreeRoot } from 'utopia-shared/src/types' +import { assertNever } from '../../../core/shared/utils' +import type { + ElementInstanceMetadata, + ElementInstanceMetadataMap, +} from '../../../core/shared/element-template' +import { navigatorTargetsSelector } from '../navigator-utils' + +export const NavigatorRowClickableWrapper = React.memo( + (props: { row: NavigatorRow; children: React.ReactNode }) => { + const dispatch = useDispatch() + + const selectedViews = useRefEditorState((store) => store.editor.selectedViews) + + const targetPath = React.useMemo(() => { + return getRowPath(props.row) + }, [props.row]) + + const selected = React.useMemo(() => { + return selectedViews.current.some((view) => EP.pathsEqual(targetPath, view)) + }, [selectedViews, targetPath]) + + const getMouseDownActions = useGetNavigatorMouseDownActions(targetPath, selected, props.row) + + const onMouseDown = React.useCallback( + (e: React.MouseEvent) => { + const actions = getMouseDownActions(e) + dispatch(actions) + }, + [dispatch, getMouseDownActions], + ) + + return ( +
+ {props.children} +
+ ) + }, +) +NavigatorRowClickableWrapper.displayName = 'NavigatorRowClickableWrapper' + +export function useGetNavigatorMouseDownActions( + targetPath: ElementPath, + selected: boolean, + row: NavigatorRow, +): (event: React.MouseEvent) => Array { + const navigatorTargets = useRefEditorState(navigatorTargetsSelector) + const selectedViews = useRefEditorState((store) => store.editor.selectedViews) + const collapsedViews = useRefEditorState((store) => store.editor.navigator.collapsedViews) + const projectContents = useRefEditorState((store) => store.editor.projectContents) + const jsxMetadata = useRefEditorState((store) => store.editor.jsxMetadata) + + const highlightBounds = useHighlightBounds(targetPath) + const conditionalOverrideUpdate = useConditionalOverrideUpdate(row) + + return React.useCallback( + (e: React.MouseEvent) => { + if (e.metaKey && !e.shiftKey) { + return actionsForAddToSelection(targetPath) + } else if (e.shiftKey) { + return actionsForRangeSelection( + targetPath, + navigatorTargets.current.navigatorTargets, + selectedViews.current, + collapsedViews.current, + projectContents.current, + jsxMetadata.current, + ) + } else { + // when we click on an already selected item we should force vscode to navigate there + if (selected && highlightBounds != null) { + sendMessage( + selectedElementChangedMessageFromHighlightBounds(highlightBounds, 'force-navigation'), + ) + } + return actionsForSingleSelection(targetPath, row, conditionalOverrideUpdate) + } + }, + [ + row, + selectedViews, + projectContents, + conditionalOverrideUpdate, + highlightBounds, + jsxMetadata, + selected, + targetPath, + collapsedViews, + navigatorTargets, + ], + ) +} + +function actionsForAddToSelection(targetPath: ElementPath): EditorAction[] { + return [EditorActions.selectComponents([targetPath], true)] +} + +export function actionsForRangeSelection( + targetPath: ElementPath, + navigatorTargets: NavigatorEntry[], + selectedViews: ElementPath[], + collapsedViews: ElementPath[], + projectContents: ProjectContentTreeRoot, + jsxMetadata: ElementInstanceMetadataMap, +): EditorAction[] { + const selectableTargets = navigatorTargets + .filter((target) => target.type !== 'SYNTHETIC') + .map((target) => target.elementPath) + + // boundaries of the current selection + let selectionTop: number = Infinity + let selectionBottom: number = -Infinity + + // index of the row being clicked + let targetIndex: number = Infinity + + // populate the indexes by matching rows, selected views, and the target path + for (let i = 0; i < selectableTargets.length; i++) { + const target = selectableTargets[i] + if ( + selectedViews.some((path) => { + if (EP.pathsEqual(target, path)) { + return true + } + + const element = getElementFromProjectContents(path, projectContents) + if (MetadataUtils.isElementDataReference(element)) { + return EP.isParentOf(target, path) + } + + return false + }) + ) { + selectionTop = Math.min(selectionTop, i) + selectionBottom = Math.max(selectionBottom, i) + } + if (EP.pathsEqual(target, targetPath)) { + targetIndex = Math.min(targetIndex, i) + } + } + + // derive the slice indexes + const sliceFrom = Math.min(selectionTop, targetIndex) + const sliceTo = Math.max(selectionBottom, targetIndex) + + const selectionPaths = selectableTargets + // get the targets inside the new selection bounds + .slice(sliceFrom, sliceTo + 1) + // get their paths + .flatMap((target) => { + let paths = [target] + // if a collapsed view is included in the range, get its children paths + if (collapsedViews.some((view) => EP.pathsEqual(view, target))) { + const children = MetadataUtils.getChildrenUnordered(jsxMetadata, target) + paths.push(...children.map((c) => c.elementPath)) + } + return paths + }) + + const selection = selectionPaths + // filter out conditional branches rows + .filter((path) => { + const parent = MetadataUtils.findElementByElementPath(jsxMetadata, EP.parentPath(path)) + const isConditionalExpression = + parent != null && + isRight(parent.element) && + parent.element.value.type === 'JSX_CONDITIONAL_EXPRESSION' + + return !isConditionalExpression + }) + + return [EditorActions.selectComponents(selection, false)] +} + +function actionsForSingleSelection( + targetPath: ElementPath, + row: NavigatorRow, + conditionalOverrideUpdate: ConditionalOverrideUpdate, +): EditorAction[] { + let actions: EditorAction[] = [] + + if (isRegulaNavigatorRow(row)) { + const conditionalOverrideActions = isConditionalClauseNavigatorEntry(row.entry) + ? getConditionalOverrideActions(targetPath, conditionalOverrideUpdate) + : getConditionalOverrideActions(EP.parentPath(targetPath), conditionalOverrideUpdate) + actions.push(...conditionalOverrideActions) + } + + const isNotSelectable = isRegulaNavigatorRow(row) && row.entry.type === 'CONDITIONAL_CLAUSE' + if (!isNotSelectable) { + actions.push(EditorActions.selectComponents([targetPath], false)) + } + + return actions +} + +function getRowPath(row: NavigatorRow): ElementPath { + return isRegulaNavigatorRow(row) ? row.entry.elementPath : row.entries[0].elementPath +} + +type ConditionalOverrideUpdate = ConditionalCase | 'clear-override' | 'no-update' + +export function getConditionalOverrideActions( + targetPath: ElementPath, + conditionalOverrideUpdate: ConditionalOverrideUpdate, +): Array { + switch (conditionalOverrideUpdate) { + case 'no-update': + return [] + case 'clear-override': + return [EditorActions.setConditionalOverriddenCondition(targetPath, null)] + case 'true-case': + return [EditorActions.setConditionalOverriddenCondition(targetPath, true)] + case 'false-case': + return [EditorActions.setConditionalOverriddenCondition(targetPath, false)] + default: + assertNever(conditionalOverrideUpdate) + } +} + +function useConditionalOverrideUpdate(row: NavigatorRow) { + return useEditorState( + Substores.metadata, + (store): ConditionalOverrideUpdate => { + if (!isRegulaNavigatorRow(row)) { + return 'no-update' + } + const navigatorEntry = row.entry + const path = navigatorEntry.elementPath + const metadata = store.editor.jsxMetadata + const elementMetadata = MetadataUtils.findElementByElementPath( + store.editor.jsxMetadata, + navigatorEntry.elementPath, + ) + if (isConditionalClauseNavigatorEntry(navigatorEntry)) { + return conditionalOverrideUpdateForClause(navigatorEntry.clause, elementMetadata) + } else { + return conditionalOverrideUpdateForPath(path, metadata) + } + }, + 'useConditionalOverrideUpdate conditionalOverrideUpdate', + ) +} + +function conditionalOverrideUpdateForClause( + clause: ConditionalCase, + elementMetadata: ElementInstanceMetadata | null, +) { + if (isActiveBranchOfConditional(clause, elementMetadata)) { + if (isOverriddenConditional(elementMetadata)) { + return 'clear-override' + } else { + return clause + } + } else { + return clause + } +} + +export function conditionalOverrideUpdateForPath( + path: ElementPath, + metadata: ElementInstanceMetadataMap, +) { + const conditionalCase = getConditionalCaseCorrespondingToBranchPath(path, metadata) + if (conditionalCase != null) { + const parentPath = EP.parentPath(path) + const parentMetadata = MetadataUtils.findElementByElementPath(metadata, parentPath) + if (isActiveBranchOfConditional(conditionalCase, parentMetadata)) { + return 'no-update' + } else if (isDefaultBranchOfConditional(conditionalCase, parentMetadata)) { + return 'clear-override' + } else { + return conditionalCase + } + } + + return 'no-update' +} + +function useHighlightBounds(path: ElementPath) { + return useEditorState( + Substores.projectContents, + (store) => { + const staticPath = EP.dynamicPathToStaticPath(path) + if (staticPath != null) { + const bounds = getHighlightBoundsForProject(store.editor.projectContents) + if (bounds != null) { + const highlightedUID = EP.toUid(staticPath) + return bounds[highlightedUID] + } + } + + return null + }, + 'useHighlightBounds highlightBounds', + ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-components.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-components.tsx new file mode 100644 index 000000000000..1686b791019d --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-components.tsx @@ -0,0 +1,574 @@ +import React from 'react' +import { type ElementPath } from '../../../core/shared/project-file-types' +import type { EditorDispatch } from '../../editor/action-types' +import * as EditorActions from '../../editor/actions/action-creators' +import * as EP from '../../../core/shared/element-path' +import type { IcnProps } from '../../../uuiui' +import { useColorTheme, Button, FlexRow, Icn } from '../../../uuiui' +import { stopPropagation } from '../../inspector/common/inspector-utils' +import { unless, when } from '../../../utils/react-conditionals' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import type { NavigatorEntry } from '../../editor/store/editor-state' +import { + isConditionalClauseNavigatorEntry, + isRegularNavigatorEntry, + navigatorEntryToKey, + varSafeNavigatorEntryToKey, +} from '../../editor/store/editor-state' +import type { SelectionLocked } from '../../canvas/canvas-types' +import type { InsertionTarget } from './component-picker-context-menu' +import { + conditionalTarget, + renderPropTarget, + useCreateCallbackToShowComponentPicker, +} from './component-picker-context-menu' +import type { ConditionalCase } from '../../../core/model/conditionals' +import { useConditionalCaseCorrespondingToBranchPath } from '../../../core/model/conditionals' +import { + getJSXElementNameAsString, + isIntrinsicHTMLElement, +} from '../../../core/shared/element-template' +import { getRegisteredComponent } from '../../../core/property-controls/property-controls-utils' +import { intrinsicHTMLElementNamesThatSupportChildren } from '../../../core/shared/dom-utils' +import { ExpandableIndicator } from './expandable-indicator' +import { elementSupportsChildrenFromPropertyControls } from '../../editor/element-children' + +export const NavigatorHintCircleDiameter = 8 + +const outletAwareBackgroundColor = ( + colorTheme: ReturnType, + isOutlet: boolean, +) => colorTheme.navigatorResizeHintBorder.value + +interface NavigatorHintProps { + testId: string + shouldBeShown: boolean + shouldAcceptMouseEvents: boolean + margin: number + isOutletOrDescendantOfOutlet: boolean +} + +export const NavigatorHintTop = React.forwardRef( + (props, ref) => { + const colorTheme = useColorTheme() + return ( +
+
+
+
+
+
+
+
+ ) + }, +) + +export const NavigatorHintBottom = React.forwardRef( + (props, ref) => { + const colorTheme = useColorTheme() + return ( +
+
+
+
+
+
+
+
+ ) + }, +) + +interface VisiblityIndicatorProps { + shouldShow: boolean + visibilityEnabled: boolean + selected: boolean + iconColor: IcnProps['color'] + onMouseDown: () => void +} + +export const VisibilityIndicator: React.FunctionComponent< + React.PropsWithChildren +> = React.memo((props) => { + const color = props.iconColor + + return ( + + ) +}) + +interface AddChildButtonProps { + target: ElementPath + iconColor: IcnProps['color'] +} + +export function addChildButtonTestId(target: ElementPath): string { + return `add-child-button-${EP.toString(target)}` +} + +const AddChildButton = React.memo((props: AddChildButtonProps) => { + const { target, iconColor } = props + const supportsChildren = useEditorState( + Substores.metadataAndPropertyControlsInfo, + (store) => + elementSupportsChildrenFromPropertyControls( + store.editor.jsxMetadata, + store.editor.propertyControlsInfo, + target, + ), + 'AddChildButton supportsChildren', + ) + + const onMouseDown = useCreateCallbackToShowComponentPicker()( + [target], + EditorActions.insertAsChildTarget(), + ) + + if (!supportsChildren) { + return null + } + + return ( + + ) +}) + +export const ReplaceElementButtonTestId = (path: ElementPath, prop: string | null) => + `replace-element-button-${EP.toString(path)}${prop == null ? '' : `-${prop}`}` + +interface ReplaceElementButtonProps { + target: ElementPath + iconColor: IcnProps['color'] + prop: string | null + conditionalCase: ConditionalCase | null +} + +const ReplaceElementButton = React.memo((props: ReplaceElementButtonProps) => { + const { target, prop, iconColor, conditionalCase } = props + + const { target: realTarget, insertionTarget } = ((): { + target: ElementPath + insertionTarget: InsertionTarget + } => { + if (prop != null) { + return { + target: EP.parentPath(target), + insertionTarget: renderPropTarget(prop), + } + } + if (conditionalCase != null) { + return { + target: EP.parentPath(target), + insertionTarget: conditionalTarget(conditionalCase), + } + } + return { + target: target, + insertionTarget: EditorActions.replaceTarget, + } + })() + + const onMouseDown = useCreateCallbackToShowComponentPicker()([realTarget], insertionTarget) + + return ( + + ) +}) + +interface SelectionLockedIndicatorProps { + shouldShow: boolean + value: SelectionLocked + selected: boolean + iconColor: IcnProps['color'] + isDescendantOfLocked: boolean + onMouseDown: (value: SelectionLocked) => void +} + +export const SelectionLockedIndicator: React.FunctionComponent< + React.PropsWithChildren +> = React.memo((props) => { + const { shouldShow, value, iconColor, isDescendantOfLocked, onMouseDown } = props + const color = iconColor + + const handleMouseDown = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + switch (value) { + case 'selectable': + onMouseDown('locked-hierarchy') + break + case 'locked-hierarchy': + onMouseDown('locked') + break + case 'locked': + default: + onMouseDown('selectable') + break + } + }, + [onMouseDown, value], + ) + return ( + + ) +}) + +interface OriginalComponentNameLabelProps { + selected: boolean + instanceOriginalComponentName: string | null +} + +export const OriginalComponentNameLabel: React.FunctionComponent< + React.PropsWithChildren +> = React.memo((props) => { + const colorTheme = useColorTheme() + return ( +
+ {props.instanceOriginalComponentName} +
+ ) +}) + +interface NavigatorItemActionSheetProps { + selected: boolean + highlighted: boolean + navigatorEntry: NavigatorEntry + isVisibleOnCanvas: boolean // TODO FIXME bad name, also, use state + instanceOriginalComponentName: string | null + isSlot: boolean + isScene: boolean + collapsed: boolean + iconColor: IcnProps['color'] + background?: string | any + dispatch: EditorDispatch +} + +export const NavigatorItemActionSheet: React.FunctionComponent< + React.PropsWithChildren +> = React.memo((props) => { + const { navigatorEntry, dispatch } = props + + const toggleHidden = React.useCallback(() => { + if (isRegularNavigatorEntry(navigatorEntry)) { + dispatch([EditorActions.toggleHidden([navigatorEntry.elementPath])], 'everyone') + } + }, [dispatch, navigatorEntry]) + + const toggleSelectable = React.useCallback( + (newValue: SelectionLocked) => { + if (isRegularNavigatorEntry(navigatorEntry)) { + dispatch( + [EditorActions.toggleSelectionLock([navigatorEntry.elementPath], newValue)], + 'everyone', + ) + } + }, + [dispatch, navigatorEntry], + ) + const isLockedElement = useEditorState( + Substores.restOfEditor, + (store) => { + return store.editor.lockedElements.simpleLock.some((path) => + EP.pathsEqual(navigatorEntry.elementPath, path), + ) + }, + 'NavigatorItemActionSheet isLockedElement', + ) + const isLockedHierarchy = useEditorState( + Substores.restOfEditor, + (store) => { + return store.editor.lockedElements.hierarchyLock.some((path) => + EP.pathsEqual(navigatorEntry.elementPath, path), + ) + }, + 'NavigatorItemActionSheet isLockedHierarchy', + ) + + const isDescendantOfLocked = useEditorState( + Substores.restOfEditor, + (store) => { + return MetadataUtils.isDescendantOfHierarchyLockedElement( + navigatorEntry.elementPath, + store.editor.lockedElements, + ) + }, + 'NavigatorItemActionSheet descendant of locked', + ) + + const isConditionalClauseTitle = isConditionalClauseNavigatorEntry(props.navigatorEntry) + + const conditionalCase = useConditionalCaseCorrespondingToBranchPath( + props.navigatorEntry.elementPath, + ) + + const toggleCollapse = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + dispatch([EditorActions.toggleCollapse(navigatorEntry.elementPath)]) + }, + [dispatch, navigatorEntry.elementPath], + ) + + return ( + + {unless( + props.isScene, + <> + + {when( + (navigatorEntry.type === 'REGULAR' || navigatorEntry.type === 'RENDER_PROP_VALUE') && + (props.highlighted || props.selected), + <> + + + , + )} + + + , + )} + {when( + props.isScene, + , + )} + + ) +}) diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-dnd-container.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-dnd-container.tsx new file mode 100644 index 000000000000..6861ae661783 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-dnd-container.tsx @@ -0,0 +1,1234 @@ +import React from 'react' +import type { DropTargetMonitor } from 'react-dnd' +import { useDrag, useDrop } from 'react-dnd' +import type { ElementPath } from '../../../core/shared/project-file-types' +import type { EditorAction, EditorDispatch } from '../../editor/action-types' +import * as EditorActions from '../../editor/actions/action-creators' +import * as MetaActions from '../../editor/actions/meta-actions' +import * as EP from '../../../core/shared/element-path' +import { hideNavigatorDropTargetHint, showNavigatorDropTargetHint } from '../actions' +import { ExpansionArrowWidth } from './expandable-indicator' +import type { ParentOutline } from './navigator-item' +import { BasePaddingUnit, NavigatorItem } from './navigator-item' +import { + NavigatorHintBottom, + NavigatorHintCircleDiameter, + NavigatorHintTop, +} from './navigator-item-components' +import type { + AllElementProps, + ConditionalClauseNavigatorEntry, + DropTargetHint, + DropTargetType, + EditorState, + InvalidOverrideNavigatorEntry, + NavigatorEntry, +} from '../../editor/store/editor-state' +import { + CanvasSizeAtom, + navigatorEntriesEqual, + regularNavigatorEntry, + renderPropNavigatorEntry, + slotNavigatorEntry, + varSafeNavigatorEntryToKey, +} from '../../editor/store/editor-state' +import { + Substores, + useEditorState, + useRefEditorState, +} from '../../../components/editor/store/store-hook' +import { + isElementRenderedBySameComponent, + isAllowedToNavigatorReparent, +} from '../../canvas/canvas-strategies/strategies/reparent-helpers/reparent-helpers' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import { getEmptyImage } from 'react-dnd-html5-backend' +import { when } from '../../../utils/react-conditionals' +import { metadataSelector } from '../../inspector/inpector-selectors' +import { + baseNavigatorDepth, + navigatorTargetsSelector, + useGetNavigatorTargets, +} from '../navigator-utils' +import type { + ElementInstanceMetadataMap, + JSXElementChild, +} from '../../../core/shared/element-template' +import { + findMaybeConditionalExpression, + getConditionalActiveCase, + getConditionalCaseCorrespondingToBranchPath, + isEmptyConditionalBranch, + isNonEmptyConditionalBranch, +} from '../../../core/model/conditionals' +import type { IndexPosition } from '../../../utils/utils' +import { after, before, front } from '../../../utils/utils' +import { assertNever } from '../../../core/shared/utils' +import type { ElementPathTrees } from '../../../core/shared/element-path-tree' +import { useAtom, atom } from 'jotai' +import { AlwaysFalse, usePubSubAtomReadOnly } from '../../../core/shared/atom-with-pub-sub' +import type { CanvasPoint } from '../../../core/shared/math-utils' +import { zeroCanvasPoint } from '../../../core/shared/math-utils' +import { createNavigatorReparentPostActionActions } from '../../canvas/canvas-strategies/post-action-options/post-action-options' +import createCachedSelector from 're-reselect' +import type { MetadataSubstate } from '../../editor/store/store-hook-substore-types' +import { getCanvasViewportCenter } from '../../../templates/paste-helpers' +import { isRegulaNavigatorRow } from '../navigator-row' + +export const WiggleUnit = BasePaddingUnit * 1.5 + +const DragSessionInProgressAtom = atom(false) + +export const TopDropTargetLineTestId = (safeComponentId: string): string => + `navigator-item-drop-before-${safeComponentId}` + +export const BottomDropTargetLineTestId = (safeComponentId: string): string => + `navigator-item-drop-after-${safeComponentId}` + +export const ReparentDropTargetTestId = (safeComponentId: string): string => + `navigator-item-${safeComponentId}` + +export const DragItemTestId = (safeComponentId: string): string => + `navigator-item-drag-${safeComponentId}` + +const BaseRowHeight = 35 +const PreviewIconSize = BaseRowHeight + +export interface DragSelection { + elementPath: ElementPath + index: number +} + +export const NavigatorItemDragType = 'navigator-item-drag-item' as const + +export interface NavigatorItemDragAndDropWrapperPropsBase { + type: typeof NavigatorItemDragType + index: number + indentation: number + appropriateDropTargetHint: DropTargetHint | null + editorDispatch: EditorDispatch + selected: boolean + highlighted: boolean // TODO are we sure about this? + collapsed: boolean // TODO are we sure about this? + getCurrentlySelectedEntries: () => Array + getSelectedViewsInRange: (index: number) => Array // TODO remove me + canReparentInto: boolean + noOfChildren: number + label: string + isElementVisible: boolean + renamingTarget: ElementPath | null + windowStyle: React.CSSProperties + visibleNavigatorTargets: Array + navigatorEntry: NavigatorEntry +} + +export interface NavigatorItemDragAndDropWrapperProps + extends NavigatorItemDragAndDropWrapperPropsBase { + elementPath: ElementPath +} + +export interface SyntheticNavigatorItemContainerProps + extends NavigatorItemDragAndDropWrapperPropsBase { + isOutletOrDescendantOfOutlet: boolean + elementPath: ElementPath + childOrAttribute: JSXElementChild +} + +export interface RenderPropNavigatorItemContainerProps + extends NavigatorItemDragAndDropWrapperPropsBase { + isOutletOrDescendantOfOutlet: boolean + elementPath: ElementPath + propName: string + childPath: ElementPath | null +} + +export interface SlotNavigatorItemContainerProps extends NavigatorItemDragAndDropWrapperPropsBase { + parentElementPath: ElementPath + renderProp: string +} + +export interface ConditionalClauseNavigatorItemContainerProps + extends NavigatorItemDragAndDropWrapperPropsBase { + isOutletOrDescendantOfOutlet: boolean + navigatorEntry: ConditionalClauseNavigatorEntry +} + +export interface ErrorNavigatorItemContainerProps extends NavigatorItemDragAndDropWrapperPropsBase { + isOutletOrDescendantOfOutlet: boolean + navigatorEntry: InvalidOverrideNavigatorEntry +} + +function isDroppingToOriginalPosition( + metadata: ElementInstanceMetadataMap, + elementPathTree: ElementPathTrees, + dropTargetHint: DropTargetHint, + dropTargetElementPath: ElementPath, + draggedElementPath: ElementPath, +): boolean { + const parentMatches = EP.pathsEqual( + EP.parentPath(draggedElementPath), + dropTargetHint.targetParent.elementPath, + ) + if (!parentMatches) { + // bail out if not dropping into the same parent + return false + } + + if (EP.pathsEqual(dropTargetElementPath, draggedElementPath)) { + return true + } + + // dropping the element before itself + const index = MetadataUtils.getIndexInParent(metadata, elementPathTree, draggedElementPath) + if ( + dropTargetHint.targetIndexPosition.type === 'before' && + dropTargetHint.targetIndexPosition.index === index + ) { + return true + } + + // dropping the element after the preceding entry + if ( + dropTargetHint.targetIndexPosition.type === 'after' && + dropTargetHint.targetIndexPosition.index === index - 1 + ) { + return true + } + + // dropping to front and back + const siblings = MetadataUtils.getSiblingsOrdered(metadata, elementPathTree, draggedElementPath) + if ( + (dropTargetHint.targetIndexPosition.type === 'back' && + EP.pathsEqual(siblings.at(0)?.elementPath ?? null, draggedElementPath)) || + (dropTargetHint.targetIndexPosition.type === 'front' && + EP.pathsEqual(siblings.at(-1)?.elementPath ?? null, draggedElementPath)) + ) { + return true + } + + // absolute, for the sake of completeness + if ( + dropTargetHint.targetIndexPosition.type === 'absolute' && + dropTargetHint.targetIndexPosition.index === index + ) { + return true + } + + return false +} + +function notDescendant(draggedOntoPath: ElementPath, draggedItemPath: ElementPath): boolean { + return !EP.isDescendantOf(draggedOntoPath, draggedItemPath) +} + +function safeIndexInParent( + metadata: ElementInstanceMetadataMap, + elementPathTree: ElementPathTrees, + elementPath: ElementPath, +): number | null { + const index = MetadataUtils.getIndexInParent(metadata, elementPathTree, elementPath) + return index < 0 ? null : index +} + +function depthOfCommonAncestor( + navigatorEntries: Array, + hoveredNavigatorEntry: NavigatorEntry, +): number { + const index = navigatorEntries.findIndex((e) => navigatorEntriesEqual(e, hoveredNavigatorEntry)) + if (index === navigatorEntries.length - 1) { + return 0 + } + + const next = navigatorEntries[index + 1] + const closestSharedAncestor = EP.closestSharedAncestor( + hoveredNavigatorEntry.elementPath, + next.elementPath, + true, + ) + + if (closestSharedAncestor == null) { + return 0 + } + + return baseNavigatorDepth(closestSharedAncestor) +} + +function notDroppingIntoOwnDefinition( + selectedViews: Array, + targetParent: ElementPath, + metadata: ElementInstanceMetadataMap, +) { + return selectedViews.every((selection) => { + const jsxElement = MetadataUtils.getJSXElementFromMetadata(metadata, selection) + return ( + jsxElement == null || !isElementRenderedBySameComponent(metadata, targetParent, jsxElement) + ) + }) +} + +function canDropInto(editorState: EditorState, moveToEntry: ElementPath): boolean { + const notSelectedItem = editorState.selectedViews.every((selection) => { + return !EP.isDescendantOfOrEqualTo(moveToEntry, selection) + }) + + const targetSupportsChildren = MetadataUtils.targetSupportsChildren( + editorState.projectContents, + editorState.jsxMetadata, + moveToEntry, + editorState.elementPathTree, + editorState.propertyControlsInfo, + ) + + return ( + targetSupportsChildren && + notSelectedItem && + notDroppingIntoOwnDefinition(editorState.selectedViews, moveToEntry, editorState.jsxMetadata) + ) +} + +function canDropNextTo(editorState: EditorState, moveToEntry: ElementPath): boolean { + const notSelectedItem = editorState.selectedViews.every((selection) => { + return notDescendant(moveToEntry, selection) + }) + + const targetNotRootOfInstance = !EP.isRootElementOfInstance(moveToEntry) + + return notSelectedItem && targetNotRootOfInstance +} + +function onDrop( + propsOfDraggedItem: NavigatorItemDragAndDropWrapperProps, + propsOfDropTargetItem: NavigatorItemDragAndDropWrapperProps, + targetParent: ElementPath, + indexPosition: IndexPosition, + canvasViewportCenter: CanvasPoint, + jsxMetadata: ElementInstanceMetadataMap, + allElementProps: AllElementProps, +): Array { + const dragSelections = propsOfDraggedItem.getCurrentlySelectedEntries() + const filteredSelections = dragSelections.filter((selection) => + notDescendant(propsOfDropTargetItem.elementPath, selection.elementPath), + ) + const draggedElements = filteredSelections.map((selection) => selection.elementPath) + + const reparentActions = createNavigatorReparentPostActionActions( + draggedElements, + targetParent, + indexPosition, + canvasViewportCenter, + jsxMetadata, + allElementProps, + ) + + return [...reparentActions, hideNavigatorDropTargetHint()] +} + +function getHintPaddingForDepth(depth: number): number { + return ( + depth * BasePaddingUnit + + ExpansionArrowWidth + + PreviewIconSize / 2 - + NavigatorHintCircleDiameter + ) +} + +const isDescendantOfOutletOrOutletSelector = createCachedSelector( + metadataSelector, + (_: MetadataSubstate, x: ElementPath) => x, + (metadata, path) => { + return isDescendantOfOutletOrOutlet(path, metadata) + }, +)((_, x) => EP.toString(x)) + +function onHoverDropTargetLine( + propsOfDraggedItem: NavigatorItemDragAndDropWrapperProps, + propsOfDropTargetItem: NavigatorItemDragAndDropWrapperProps, + monitor: DropTargetMonitor | null, + position: 'before' | 'after', + metadata: ElementInstanceMetadataMap, + navigatorEntries: Array, + elementPathTree: ElementPathTrees, + isLastSibling: boolean, +): void { + if ( + monitor == null || + !propsOfDraggedItem + .getCurrentlySelectedEntries() + .every((selection) => + notDescendant(propsOfDropTargetItem.elementPath, selection.elementPath), + ) || + isHintDisallowed(propsOfDropTargetItem.elementPath, metadata) + ) { + return propsOfDraggedItem.editorDispatch([hideNavigatorDropTargetHint()], 'leftpane') + } + + const cursor = monitor.getClientOffset() + const cursorDelta = monitor.getDifferenceFromInitialOffset() + const targetAction = propsOfDraggedItem.highlighted + ? [] + : [EditorActions.setHighlightedView(propsOfDraggedItem.elementPath)] + + if (cursor == null || cursorDelta == null) { + return propsOfDraggedItem.editorDispatch([hideNavigatorDropTargetHint()], 'leftpane') + } + + const targetEntryWithReparentWiggle: { + type: DropTargetType + targetParent: NavigatorEntry + indexPosition: IndexPosition + } | null = (() => { + if (cursorDelta.x >= -WiggleUnit || !isLastSibling || position === 'before') { + return null + } + + const commonAncestorDepth = depthOfCommonAncestor( + navigatorEntries, + regularNavigatorEntry(propsOfDropTargetItem.elementPath), + ) + + const maximumTargetDepth = baseNavigatorDepth(propsOfDropTargetItem.elementPath) // this differs from the `indentation` prop as it needs to be calculated on the actual path length + const cursorTargetDepth = 1 + Math.floor(Math.abs(cursorDelta.x) / WiggleUnit) + + const nPathPartsToDrop = Math.min( + Math.min(cursorTargetDepth, maximumTargetDepth - commonAncestorDepth), + maximumTargetDepth, + ) + const targetParentPath = EP.dropNPathParts(propsOfDropTargetItem.elementPath, nPathPartsToDrop) + const targetPathWithinParent = EP.dropNPathParts( + propsOfDropTargetItem.elementPath, + nPathPartsToDrop - 1, + ) + + const indexPositionFn = + position === 'after' ? after : position === 'before' ? before : assertNever(position) + + const index = MetadataUtils.getIndexInParent(metadata, elementPathTree, targetPathWithinParent) + + if (index == null) { + return null + } + + return { + type: 'after', + targetParent: regularNavigatorEntry(targetParentPath), + indexPosition: indexPositionFn(index), + } + })() + + if (targetEntryWithReparentWiggle != null) { + return propsOfDraggedItem.editorDispatch([ + ...targetAction, + showNavigatorDropTargetHint( + targetEntryWithReparentWiggle.type, + targetEntryWithReparentWiggle.targetParent, + regularNavigatorEntry(propsOfDropTargetItem.elementPath), + targetEntryWithReparentWiggle.indexPosition, + ), + ]) + } + + if ( + propsOfDraggedItem.appropriateDropTargetHint?.type !== position || + !navigatorEntriesEqual( + propsOfDraggedItem.appropriateDropTargetHint?.displayAtEntry, + regularNavigatorEntry(propsOfDropTargetItem.elementPath), + ) + ) { + const indexPositionFn = + position === 'after' ? after : position === 'before' ? before : assertNever(position) + + const index = + safeIndexInParent(metadata, elementPathTree, propsOfDropTargetItem.elementPath) ?? 0 + + return propsOfDraggedItem.editorDispatch( + [ + ...targetAction, + showNavigatorDropTargetHint( + position, + regularNavigatorEntry(EP.parentPath(propsOfDropTargetItem.elementPath)), + regularNavigatorEntry(propsOfDropTargetItem.elementPath), + indexPositionFn(index), + ), + ], + 'leftpane', + ) + } + + return propsOfDraggedItem.editorDispatch([hideNavigatorDropTargetHint()], 'leftpane') +} + +function onHoverParentOutline( + propsOfDraggedItem: NavigatorItemDragAndDropWrapperProps, + propsOfDropTargetItem: NavigatorItemDragAndDropWrapperProps, + monitor: DropTargetMonitor | null, +): void { + if ( + monitor == null || + !propsOfDraggedItem + .getCurrentlySelectedEntries() + .every((selection) => notDescendant(propsOfDropTargetItem.elementPath, selection.elementPath)) + ) { + return propsOfDraggedItem.editorDispatch([hideNavigatorDropTargetHint()], 'leftpane') + } + + const cursor = monitor.getClientOffset() + const cursorDelta = monitor.getDifferenceFromInitialOffset() + const targetAction = propsOfDraggedItem.highlighted + ? [] + : [EditorActions.setHighlightedView(propsOfDraggedItem.elementPath)] + + if (cursor == null || cursorDelta == null) { + return propsOfDraggedItem.editorDispatch([hideNavigatorDropTargetHint()], 'leftpane') + } + + const { canReparentInto } = propsOfDropTargetItem + + if (canReparentInto) { + return propsOfDraggedItem.editorDispatch([ + ...targetAction, + showNavigatorDropTargetHint( + 'reparent', + regularNavigatorEntry(propsOfDropTargetItem.elementPath), + regularNavigatorEntry(propsOfDropTargetItem.elementPath), + front(), + ), + ]) + } + + return propsOfDraggedItem.editorDispatch([hideNavigatorDropTargetHint()], 'leftpane') +} + +function beginDrag( + props: NavigatorItemDragAndDropWrapperProps, +): NavigatorItemDragAndDropWrapperProps { + if (!props.selected) { + props.editorDispatch(MetaActions.selectComponents([props.elementPath], false), 'leftpane') + } + return props +} + +interface DropCollectedProps { + isOver: boolean + canDrop: boolean +} + +function isInsideConditional(elementPath: ElementPath, jsxMetadata: ElementInstanceMetadataMap) { + return findMaybeConditionalExpression(EP.parentPath(elementPath), jsxMetadata) != null +} + +function isHintDisallowed(elementPath: ElementPath | null, metadata: ElementInstanceMetadataMap) { + return elementPath == null || isInsideConditional(elementPath, metadata) // don't show top / bottom hints on elements that are the root of a conditional branch +} + +export const NavigatorItemContainer = React.memo((props: NavigatorItemDragAndDropWrapperProps) => { + const editorStateRef = useRefEditorState((store) => store.editor) + const canvasSize = usePubSubAtomReadOnly(CanvasSizeAtom, AlwaysFalse) + const canvasRef = useRefEditorState((store) => store.editor.canvas) + + const [isDragSessionInProgress, updateDragSessionInProgress] = useAtom(DragSessionInProgressAtom) + + const [, drag, preview] = useDrag( + () => ({ + type: 'NAVIGATOR_ITEM', + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + item: () => { + updateDragSessionInProgress(true) + return beginDrag(props) + }, + end: () => updateDragSessionInProgress(false), + canDrag: () => { + const editorState = editorStateRef.current + return ( + isAllowedToNavigatorReparent(editorState.jsxMetadata, props.elementPath) && + !EP.isRootElementOfInstance(props.elementPath) + ) + }, + }), + [props, updateDragSessionInProgress], + ) + + const dropTargetHint = useEditorState( + Substores.navigator, + (store) => store.editor.navigator.dropTargetHint, + 'NavigatorItemDndWrapper dropTargetHint', + ) + + const navigatorTargets = useGetNavigatorTargets().navigatorTargets + + const isFirstSibling = React.useMemo(() => { + const siblings = MetadataUtils.getSiblingsOrdered( + editorStateRef.current.jsxMetadata, + editorStateRef.current.elementPathTree, + props.elementPath, + ) + const firstSibling = siblings.at(0) + if (firstSibling == null) { + return false + } + + return EP.pathsEqual(firstSibling.elementPath, props.elementPath) + }, [editorStateRef, props.elementPath]) + + // Note for future selves: watch out! This works because changing siblings triggers a re-render of the navigator, + // but if that were not to happen anymore, the references used here by the editorStateRef would not guarantee + // updated hook values. (https://github.com/concrete-utopia/utopia/pull/4055#discussion_r1285777910) + const isLastSibling = React.useMemo(() => { + const siblings = MetadataUtils.getSiblingsOrdered( + editorStateRef.current.jsxMetadata, + editorStateRef.current.elementPathTree, + props.elementPath, + ) + const lastSibling = siblings.at(-1) + if (lastSibling == null) { + return false + } + + return EP.pathsEqual(lastSibling.elementPath, props.elementPath) + }, [editorStateRef, props.elementPath]) + + const [{ isOver: isOverBottomHint, canDrop: canDropOnBottomHint }, bottomDropRef] = useDrop< + NavigatorItemDragAndDropWrapperProps, + unknown, + DropCollectedProps + >( + () => ({ + accept: 'NAVIGATOR_ITEM', + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + hover: (item: NavigatorItemDragAndDropWrapperProps, monitor) => { + onHoverDropTargetLine( + item, + props, + monitor, + 'after', + editorStateRef.current.jsxMetadata, + navigatorTargets, + editorStateRef.current.elementPathTree, + isLastSibling, + ) + }, + drop: (item: NavigatorItemDragAndDropWrapperProps, monitor) => { + let actions: Array = [hideNavigatorDropTargetHint()] + if ( + dropTargetHint != null && + !isDroppingToOriginalPosition( + editorStateRef.current.jsxMetadata, + editorStateRef.current.elementPathTree, + dropTargetHint, + props.elementPath, + item.elementPath, + ) && + notDroppingIntoOwnDefinition( + editorStateRef.current.selectedViews, + dropTargetHint.targetParent.elementPath, + editorStateRef.current.jsxMetadata, + ) + ) { + actions.push( + ...onDrop( + item, + props, + dropTargetHint.targetParent.elementPath, + dropTargetHint.targetIndexPosition, + getCanvasViewportCenter( + canvasRef.current.roundedCanvasOffset, + canvasRef.current.scale, + canvasSize, + ), + editorStateRef.current.jsxMetadata, + editorStateRef.current.allElementProps, + ), + ) + } + props.editorDispatch(actions) + }, + canDrop: (item: NavigatorItemDragAndDropWrapperProps) => { + const target = props.elementPath + return canDropNextTo(editorStateRef.current, target) + }, + }), + [props, editorStateRef, dropTargetHint, isLastSibling], + ) + + const [{ isOver: isOverTopHint, canDrop: canDropOnTopHint }, topDropRef] = useDrop< + NavigatorItemDragAndDropWrapperProps, + unknown, + DropCollectedProps + >( + () => ({ + accept: 'NAVIGATOR_ITEM', + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + hover: (item: NavigatorItemDragAndDropWrapperProps, monitor) => { + onHoverDropTargetLine( + item, + props, + monitor, + 'before', + editorStateRef.current.jsxMetadata, + navigatorTargets, + editorStateRef.current.elementPathTree, + isLastSibling, + ) + }, + drop: (item: NavigatorItemDragAndDropWrapperProps, monitor) => { + let actions: Array = [hideNavigatorDropTargetHint()] + if ( + dropTargetHint != null && + !isDroppingToOriginalPosition( + editorStateRef.current.jsxMetadata, + editorStateRef.current.elementPathTree, + dropTargetHint, + props.elementPath, + item.elementPath, + ) && + notDroppingIntoOwnDefinition( + editorStateRef.current.selectedViews, + dropTargetHint.targetParent.elementPath, + editorStateRef.current.jsxMetadata, + ) + ) { + actions.push( + ...onDrop( + item, + props, + dropTargetHint.targetParent.elementPath, + dropTargetHint.targetIndexPosition, + getCanvasViewportCenter( + canvasRef.current.roundedCanvasOffset, + canvasRef.current.scale, + canvasSize, + ), + editorStateRef.current.jsxMetadata, + editorStateRef.current.allElementProps, + ), + ) + } + props.editorDispatch(actions) + }, + canDrop: (item: NavigatorItemDragAndDropWrapperProps) => { + const target = props.elementPath + return canDropNextTo(editorStateRef.current, target) + }, + }), + [props, editorStateRef, dropTargetHint, isLastSibling], + ) + + const [{ canDrop: canDropParentOutline }, reparentDropRef] = useDrop< + NavigatorItemDragAndDropWrapperProps, + unknown, + DropCollectedProps + >( + () => ({ + accept: 'NAVIGATOR_ITEM', + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + hover: (item: NavigatorItemDragAndDropWrapperProps, monitor) => { + if (monitor.canDrop()) { + onHoverParentOutline(item, props, monitor) + } + }, + drop: (item: NavigatorItemDragAndDropWrapperProps, monitor) => { + let actions: Array = [hideNavigatorDropTargetHint()] + if (monitor.canDrop() && dropTargetHint != null) { + actions.push( + ...onDrop( + item, + props, + dropTargetHint.targetParent.elementPath, + dropTargetHint.targetIndexPosition, + getCanvasViewportCenter( + canvasRef.current.roundedCanvasOffset, + canvasRef.current.scale, + canvasSize, + ), + editorStateRef.current.jsxMetadata, + editorStateRef.current.allElementProps, + ), + ) + } + props.editorDispatch(actions) + }, + canDrop: (item: NavigatorItemDragAndDropWrapperProps) => { + return canDropInto(editorStateRef.current, props.elementPath) + }, + }), + [props, dropTargetHint], + ) + + const safeComponentId = varSafeNavigatorEntryToKey(regularNavigatorEntry(props.elementPath)) + + React.useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }) + }) + + const shouldShowTopHint = + isOverTopHint && + canDropOnTopHint && + !isHintDisallowed( + dropTargetHint?.displayAtEntry?.elementPath ?? null, + editorStateRef.current.jsxMetadata, + ) + + const isConditionalEntry = MetadataUtils.isConditionalFromMetadata( + MetadataUtils.findElementByElementPath(editorStateRef.current.jsxMetadata, props.elementPath), + ) + + const isCollapsedCondtionalEntry = isConditionalEntry ? props.collapsed : true + + const shouldShowBottomHint = + isOverBottomHint && + canDropOnBottomHint && + !isHintDisallowed(props.elementPath, editorStateRef.current.jsxMetadata) && + isCollapsedCondtionalEntry + + const navigatorRows = useRefEditorState(navigatorTargetsSelector) + + const margin = (() => { + if (dropTargetHint == null) { + return 0 + } + + const targetRow = navigatorRows.current.navigatorRows.find((row) => + isRegulaNavigatorRow(row) + ? EP.pathsEqual(row.entry.elementPath, dropTargetHint.targetParent.elementPath) + : row.entries.some((entry) => + EP.pathsEqual(dropTargetHint.targetParent.elementPath, entry.elementPath), + ), + ) + if (targetRow != null) { + return getHintPaddingForDepth(targetRow.indentation) + } + return props.indentation + })() + + const parentOutline = React.useMemo((): ParentOutline => { + if (dropTargetHint == null || !canDropParentOutline) { + return 'none' + } + + const { targetParent } = dropTargetHint + + if (navigatorEntriesEqual(regularNavigatorEntry(props.elementPath), targetParent)) { + return 'solid' + } + + return 'none' + }, [dropTargetHint, canDropParentOutline, props.elementPath]) + + // Drop target lines should only intercept mouse events if a drag session is in progress + const shouldTopDropLineInterceptMouseEvents = isDragSessionInProgress + + // in addition, if this entry is a conditional, the bottom drop target line should only be active when + // the it's toggled closed (since otherwise the drop line would show up between the entry and the TRUE + // entry underneath) + const shouldBottomDropLineInterceptMouseEvents = + isDragSessionInProgress && isCollapsedCondtionalEntry + + const isOutletOrDescendantOfOutlet = useEditorState( + Substores.metadata, + (store) => isDescendantOfOutletOrOutletSelector(store, props.elementPath), + 'NavigatorItemContainer isOutlet', + ) + + const mouseEventStopPropagation = React.useCallback((event: React.MouseEvent) => { + // Prevent mouse events from passing through this element so that the same event + // on a containing element will only trigger de-selection if the event doesn't hit + // any entries in the navigator. + event.stopPropagation() + }, []) + + return ( +
+ {when( + isFirstSibling, + , + )} +
+ +
+ +
+ ) +}) + +function maybeSetConditionalOverrideOnDrop( + elementPath: ElementPath, + jsxMetadata: ElementInstanceMetadataMap, + spyMetadata: ElementInstanceMetadataMap, +): Array { + if (!isEmptyConditionalBranch(elementPath, jsxMetadata)) { + return [] + } + + const conditionalPath = EP.parentPath(elementPath) + + const conditional = findMaybeConditionalExpression(conditionalPath, jsxMetadata) + if (conditional == null) { + return [] + } + + const clause = getConditionalCaseCorrespondingToBranchPath(elementPath, jsxMetadata) + + const activeCase = getConditionalActiveCase(conditionalPath, conditional, spyMetadata) + if (activeCase === clause) { + return [] + } + + return [ + EditorActions.setConditionalOverriddenCondition( + conditionalPath, + clause === 'true-case' ? true : false, + ), + ] +} + +export const SyntheticNavigatorItemContainer = React.memo( + (props: SyntheticNavigatorItemContainerProps) => { + const editorStateRef = useRefEditorState((store) => store.editor) + + const [, updateDragSessionInProgress] = useAtom(DragSessionInProgressAtom) + + const navigatorEntry = props.navigatorEntry + + const [{ isOver }, reparentDropRef] = useDrop< + NavigatorItemDragAndDropWrapperProps, + unknown, + DropCollectedProps + >( + () => ({ + accept: 'NAVIGATOR_ITEM', + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + hover: (item: NavigatorItemDragAndDropWrapperProps, monitor) => { + onHoverParentOutline(item, props, monitor) + }, + drop: (item: NavigatorItemDragAndDropWrapperProps): void => { + const { jsxMetadata, spyMetadata, allElementProps } = editorStateRef.current + props.editorDispatch([ + ...onDrop( + item, + props, + props.elementPath, + front(), + zeroCanvasPoint, + jsxMetadata, + allElementProps, + ), + ...maybeSetConditionalOverrideOnDrop(props.elementPath, jsxMetadata, spyMetadata), + hideNavigatorDropTargetHint(), + ]) + }, + canDrop: () => { + const metadata = editorStateRef.current.jsxMetadata + return isEmptyConditionalBranch(props.elementPath, metadata) + }, + }), + [props, navigatorEntry], + ) + + const [, drag, preview] = useDrag( + () => ({ + type: 'NAVIGATOR_ITEM', + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + item: () => { + updateDragSessionInProgress(true) + return beginDrag(props) + }, + end: () => updateDragSessionInProgress(false), + canDrag: () => + isNonEmptyConditionalBranch(props.elementPath, editorStateRef.current.jsxMetadata), + }), + [props], + ) + + React.useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }) + }) + + const mouseEventStopPropagation = React.useCallback((event: React.MouseEvent) => { + // Prevent mouse events from passing through this element so that the same event + // on a containing element will only trigger de-selection if the event doesn't hit + // any entries in the navigator. + event.stopPropagation() + }, []) + + const parentOutline = isOver ? 'child' : 'none' + const safeComponentId = varSafeNavigatorEntryToKey(navigatorEntry) + return ( +
+
+ +
+
+ ) + }, +) + +export const RenderPropNavigatorItemContainer = React.memo( + (props: RenderPropNavigatorItemContainerProps) => { + const navigatorEntry = React.useMemo( + () => renderPropNavigatorEntry(props.elementPath, props.propName, props.childPath), + [props.propName, props.elementPath, props.childPath], + ) + + const safeComponentId = varSafeNavigatorEntryToKey(navigatorEntry) + return ( +
+
+ +
+
+ ) + }, +) + +export const SlotNavigatorItemContainer = React.memo((props: SlotNavigatorItemContainerProps) => { + const navigatorEntry = React.useMemo( + () => slotNavigatorEntry(props.parentElementPath, props.renderProp), + [props.parentElementPath, props.renderProp], + ) + + const safeComponentId = varSafeNavigatorEntryToKey(navigatorEntry) + return ( +
+
+ +
+
+ ) +}) + +export const ConditionalClauseNavigatorItemContainer = React.memo( + (props: ConditionalClauseNavigatorItemContainerProps) => { + const safeComponentId = varSafeNavigatorEntryToKey(props.navigatorEntry) + + const mouseEventStopPropagation = React.useCallback((event: React.MouseEvent) => { + // Prevent mouse events from passing through this element so that the same event + // on a containing element will only trigger de-selection if the event doesn't hit + // any entries in the navigator. + event.stopPropagation() + }, []) + + return ( +
+
+ +
+
+ ) + }, +) + +export const ErrorNavigatorItemContainer = React.memo((props: ErrorNavigatorItemContainerProps) => { + const safeComponentId = varSafeNavigatorEntryToKey(props.navigatorEntry) + const mouseEventStopPropagation = React.useCallback((event: React.MouseEvent) => { + // Prevent mouse events from passing through this element so that the same event + // on a containing element will only trigger de-selection if the event doesn't hit + // any entries in the navigator. + event.stopPropagation() + }, []) + + return ( +
+
+ +
+
+ ) +}) + +function isDescendantOfOutletOrOutlet( + path: ElementPath, + metadata: ElementInstanceMetadataMap, +): boolean { + return ( + EP.findAmongAncestorsOfPath(path, (p) => + MetadataUtils.isProbablyRemixOutlet(metadata, p) ? true : null, + ) === true + ) +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-wrapper.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-wrapper.tsx new file mode 100644 index 000000000000..7d3ae201df8f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item-wrapper.tsx @@ -0,0 +1,465 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import { createCachedSelector } from 're-reselect' +import React from 'react' +import { maybeConditionalExpression } from '../../../core/model/conditionals' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import * as EP from '../../../core/shared/element-path' +import type { + ElementInstanceMetadata, + JSXConditionalExpression, +} from '../../../core/shared/element-template' +import { + getJSXElementNameLastPart, + isNullJSXAttributeValue, +} from '../../../core/shared/element-template' +import type { ElementPath } from '../../../core/shared/project-file-types' +import { assertNever } from '../../../core/shared/utils' +import { getRouteComponentNameForOutlet } from '../../canvas/remix/remix-utils' +import { useDispatch } from '../../editor/store/dispatch-context' +import type { + DropTargetHint, + EditorStorePatched, + NavigatorEntry, +} from '../../editor/store/editor-state' +import { + isConditionalClauseNavigatorEntry, + isRegularNavigatorEntry, + navigatorEntriesEqual, + navigatorEntryToKey, +} from '../../editor/store/editor-state' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import type { + DerivedSubstate, + MetadataSubstate, + NavigatorTargetsSubstate, + ProjectContentAndMetadataSubstate, + PropertyControlsInfoSubstate, +} from '../../editor/store/store-hook-substore-types' +import { isRegulaNavigatorRow, type NavigatorRow } from '../navigator-row' +import type { + ConditionalClauseNavigatorItemContainerProps, + ErrorNavigatorItemContainerProps, + NavigatorItemDragAndDropWrapperProps, + NavigatorItemDragAndDropWrapperPropsBase, + RenderPropNavigatorItemContainerProps, + SlotNavigatorItemContainerProps, + SyntheticNavigatorItemContainerProps, +} from './navigator-item-dnd-container' +import { + ConditionalClauseNavigatorItemContainer, + ErrorNavigatorItemContainer, + NavigatorItemContainer, + NavigatorItemDragType, + RenderPropNavigatorItemContainer, + SlotNavigatorItemContainer, + SyntheticNavigatorItemContainer, +} from './navigator-item-dnd-container' +import { CondensedEntryItemWrapper } from './navigator-condensed-entry' +import { + navigatorTargetsSelector, + navigatorTargetsSelectorNavigatorTargets, +} from '../navigator-utils' + +interface NavigatorItemWrapperProps { + index: number + targetComponentKey: string + navigatorRow: NavigatorRow + getCurrentlySelectedEntries: () => Array + getSelectedViewsInRange: (index: number) => Array + windowStyle: React.CSSProperties +} + +const targetElementMetadataSelector = createCachedSelector( + (store: MetadataSubstate) => store.editor.jsxMetadata, + (store: MetadataSubstate, target: NavigatorEntry) => target, + (metadata, target): ElementInstanceMetadata | null => { + return MetadataUtils.findElementByElementPath(metadata, target.elementPath) + }, +)((_, navigatorEntry) => navigatorEntryToKey(navigatorEntry)) + +const targetInNavigatorItemsSelector = createCachedSelector( + navigatorTargetsSelector, + (store: EditorStorePatched, target: NavigatorEntry) => target, + (navigatorTargets, target) => { + return navigatorTargets.navigatorTargets.some((navigatorTarget) => { + return navigatorEntriesEqual(target, navigatorTarget) + }) + }, +)((_, navigatorEntry) => navigatorEntryToKey(navigatorEntry)) + +const elementSupportsChildrenSelector = createCachedSelector( + (store: EditorStorePatched) => store.editor.projectContents, + (store: MetadataSubstate) => store.editor.jsxMetadata, + targetElementMetadataSelector, + (store: MetadataSubstate) => store.editor.elementPathTree, + targetInNavigatorItemsSelector, + (store: PropertyControlsInfoSubstate) => store.editor.propertyControlsInfo, + ( + projectContents, + metadata, + elementMetadata, + pathTrees, + elementInNavigatorTargets, + propertyControlsInfo, + ) => { + if (!elementInNavigatorTargets || elementMetadata == null) { + return false + } + return MetadataUtils.targetElementSupportsChildren( + projectContents, + elementMetadata.elementPath, + metadata, + pathTrees, + propertyControlsInfo, + ) + }, +)((_, navigatorEntry) => navigatorEntryToKey(navigatorEntry)) + +export const labelSelector = createCachedSelector( + (store: ProjectContentAndMetadataSubstate) => store.editor.jsxMetadata, + targetElementMetadataSelector, + (store: ProjectContentAndMetadataSubstate) => store.editor.allElementProps, + (store: ProjectContentAndMetadataSubstate) => store.editor.elementPathTree, + (store: ProjectContentAndMetadataSubstate) => store.editor.projectContents, + (metadata, elementMetadata, allElementProps, pathTrees, projectContents) => { + if (elementMetadata == null) { + // "Element" with ghost emoji. + return 'Element 👻' + } + const label = MetadataUtils.getElementLabelFromMetadata( + metadata, + allElementProps, + pathTrees, + elementMetadata, + ) + + const routeComponentName = getRouteComponentNameForOutlet( + elementMetadata.elementPath, + metadata, + projectContents, + pathTrees, + ) + + return routeComponentName == null ? label : `${label}: ${routeComponentName}` + }, +)((_, navigatorEntry) => navigatorEntryToKey(navigatorEntry)) + +const noOfChildrenSelector = createCachedSelector( + navigatorTargetsSelector, + (_: NavigatorTargetsSubstate, navigatorEntry: NavigatorEntry) => navigatorEntry, + (navigatorTargets, navigatorEntry) => { + let result = 0 + for (const nt of navigatorTargets.navigatorTargets) { + if ( + isRegularNavigatorEntry(navigatorEntry) && + EP.isChildOf(nt.elementPath, navigatorEntry.elementPath) + ) { + result += 1 + } + } + return result + }, +)((_, navigatorEntry) => navigatorEntryToKey(navigatorEntry)) + +export function getNavigatorEntryLabel( + navigatorEntry: NavigatorEntry, + labelForTheElement: string, +): string { + switch (navigatorEntry.type) { + case 'REGULAR': + return labelForTheElement + case 'CONDITIONAL_CLAUSE': + switch (navigatorEntry.clause) { + case 'true-case': + return 'TRUE' + case 'false-case': + return 'FALSE' + default: + throw assertNever(navigatorEntry.clause) + } + case 'DATA_REFERENCE': + case 'SYNTHETIC': { + switch (navigatorEntry.childOrAttribute.type) { + case 'JSX_ELEMENT': + return getJSXElementNameLastPart(navigatorEntry.childOrAttribute.name) + case 'JSX_MAP_EXPRESSION': + case 'ATTRIBUTE_OTHER_JAVASCRIPT': + return '(code)' + case 'JSX_TEXT_BLOCK': + return navigatorEntry.childOrAttribute.text + case 'JSX_FRAGMENT': + return 'Fragment' + case 'JSX_CONDITIONAL_EXPRESSION': + return 'Conditional' + case 'ATTRIBUTE_VALUE': + return `${navigatorEntry.childOrAttribute.value}` + case 'ATTRIBUTE_NESTED_ARRAY': + return '(code)' + case 'ATTRIBUTE_NESTED_OBJECT': + return '(code)' + case 'ATTRIBUTE_FUNCTION_CALL': + return '(code)' + case 'JS_IDENTIFIER': + return '(code)' + case 'JS_ELEMENT_ACCESS': + return '(code)' + case 'JS_PROPERTY_ACCESS': + return '(code)' + default: + throw assertNever(navigatorEntry.childOrAttribute) + } + } + case 'RENDER_PROP': + return navigatorEntry.propName + case 'RENDER_PROP_VALUE': + return labelForTheElement + case 'INVALID_OVERRIDE': + return navigatorEntry.message + case 'SLOT': + return '(empty)' + default: + assertNever(navigatorEntry) + } +} + +export const NavigatorItemWrapper: React.FunctionComponent = React.memo( + (props) => { + if (isRegulaNavigatorRow(props.navigatorRow)) { + const navigatorEntry = props.navigatorRow.entry + return ( + + ) + } + return ( + + ) + }, +) + +type SingleEntryNavigatorItemWrapperProps = NavigatorItemWrapperProps & { + indentation: number + navigatorEntry: NavigatorEntry +} + +const SingleEntryNavigatorItemWrapper: React.FunctionComponent< + React.PropsWithChildren +> = React.memo((props) => { + const isSelected = useEditorState( + Substores.selectedViews, + (store) => + !isConditionalClauseNavigatorEntry(props.navigatorEntry) && + EP.containsPath(props.navigatorEntry.elementPath, store.editor.selectedViews), + 'NavigatorItemWrapper isSelected', + ) + const isHighlighted = useEditorState( + Substores.highlightedHoveredViews, + (store) => + isRegularNavigatorEntry(props.navigatorEntry) && + EP.containsPath(props.navigatorEntry.elementPath, store.editor.highlightedViews), + 'NavigatorItemWrapper isHighlighted', + ) + + const noOfChildren = useEditorState( + Substores.navigatorTargetsSubstate, // TODO do not use fullStore here + (store) => { + return noOfChildrenSelector(store, props.navigatorEntry) + }, + 'NavigatorItemWrapper noOfChildren', + ) + + const elementSupportsChildren = useEditorState( + Substores.fullStore, + // this is not good + (store) => elementSupportsChildrenSelector(store, props.navigatorEntry), + 'NavigatorItemWrapper elementSupportsChildren', + ) + + const maybeParentConditional = useEditorState( + Substores.metadata, + (store) => + maybeConditionalExpression( + MetadataUtils.findElementByElementPath( + store.editor.jsxMetadata, + EP.parentPath(props.navigatorEntry.elementPath), + ), + ), + 'NavigatorItemWrapper maybeParentConditional', + ) + + function isNullConditionalBranch( + entry: NavigatorEntry, + maybeConditional: JSXConditionalExpression | null, + ) { + if (maybeConditional == null) { + return false + } + const truePath = EP.appendToPath( + EP.parentPath(entry.elementPath), + maybeConditional.whenTrue.uid, + ) + const branch = EP.pathsEqual(entry.elementPath, truePath) + ? maybeConditional.whenTrue + : maybeConditional.whenFalse + return isNullJSXAttributeValue(branch) + } + + const canReparentInto = + elementSupportsChildren || + isConditionalClauseNavigatorEntry(props.navigatorEntry) || + isNullConditionalBranch(props.navigatorEntry, maybeParentConditional) + + const labelForTheElement = useEditorState( + Substores.projectContentsAndMetadata, + (store) => labelSelector(store, props.navigatorEntry), + 'NavigatorItemWrapper labelSelector', + ) + const label = getNavigatorEntryLabel(props.navigatorEntry, labelForTheElement) + + const visibleNavigatorTargets = useEditorState( + Substores.navigatorTargetsSubstate, + navigatorTargetsSelectorNavigatorTargets, + 'NavigatorItemWrapper navigatorTargets', + ) + const dispatch = useDispatch() + const { isElementVisible, renamingTarget, appropriateDropTargetHint, isCollapsed } = + useEditorState( + Substores.restOfEditor, + (store) => { + // Only capture this if it relates to the current navigator item, as it may change while + // dragging around the navigator but we don't want the entire navigator to re-render each time. + let possiblyAppropriateDropTargetHint: DropTargetHint | null = null + if ( + (isRegularNavigatorEntry(props.navigatorEntry) || + isConditionalClauseNavigatorEntry(props.navigatorEntry)) && + store.editor.navigator.dropTargetHint?.displayAtEntry != null && + navigatorEntriesEqual( + store.editor.navigator.dropTargetHint.displayAtEntry, + props.navigatorEntry, + ) + ) { + possiblyAppropriateDropTargetHint = store.editor.navigator.dropTargetHint + } + + const elementIsCollapsed = EP.containsPath( + props.navigatorEntry.elementPath, + store.editor.navigator.collapsedViews, + ) + return { + appropriateDropTargetHint: possiblyAppropriateDropTargetHint, + renamingTarget: store.editor.navigator.renamingTarget, + isElementVisible: !EP.containsPath( + props.navigatorEntry.elementPath, + store.editor.hiddenInstances, + ), + isCollapsed: elementIsCollapsed, + } + }, + 'NavigatorItemWrapper', + ) + + const navigatorItemProps: NavigatorItemDragAndDropWrapperPropsBase = { + type: NavigatorItemDragType, + index: props.index, + indentation: props.indentation, + editorDispatch: dispatch, + selected: isSelected, + highlighted: isHighlighted, + collapsed: isCollapsed, + getCurrentlySelectedEntries: props.getCurrentlySelectedEntries, + getSelectedViewsInRange: props.getSelectedViewsInRange, + appropriateDropTargetHint: appropriateDropTargetHint, + canReparentInto: canReparentInto, + noOfChildren: noOfChildren, + label: label, + isElementVisible: isElementVisible, + renamingTarget: renamingTarget, + windowStyle: props.windowStyle, + visibleNavigatorTargets: visibleNavigatorTargets, + navigatorEntry: props.navigatorEntry, + } + + if (props.navigatorEntry.type === 'REGULAR') { + const entryProps: NavigatorItemDragAndDropWrapperProps = { + ...navigatorItemProps, + elementPath: props.navigatorEntry.elementPath, + } + return + } + + if ( + props.navigatorEntry.type === 'SYNTHETIC' || + props.navigatorEntry.type === 'DATA_REFERENCE' // TODO remove this once we have a proper navigator item container for data references + ) { + const entryProps: SyntheticNavigatorItemContainerProps = { + ...navigatorItemProps, + childOrAttribute: props.navigatorEntry.childOrAttribute, + elementPath: props.navigatorEntry.elementPath, + isOutletOrDescendantOfOutlet: false, + } + return + } + + if (props.navigatorEntry.type === 'CONDITIONAL_CLAUSE') { + const entryProps: ConditionalClauseNavigatorItemContainerProps = { + ...navigatorItemProps, + navigatorEntry: props.navigatorEntry, + isOutletOrDescendantOfOutlet: false, + } + return + } + + if (props.navigatorEntry.type === 'INVALID_OVERRIDE') { + const entryProps: ErrorNavigatorItemContainerProps = { + ...navigatorItemProps, + navigatorEntry: props.navigatorEntry, + isOutletOrDescendantOfOutlet: false, + } + + return + } + + if (props.navigatorEntry.type === 'RENDER_PROP') { + const entryProps: RenderPropNavigatorItemContainerProps = { + ...navigatorItemProps, + propName: props.navigatorEntry.propName, + elementPath: props.navigatorEntry.elementPath, + isOutletOrDescendantOfOutlet: false, + childPath: props.navigatorEntry.childPath, + } + return + } + + if (props.navigatorEntry.type === 'RENDER_PROP_VALUE') { + const entryProps: NavigatorItemDragAndDropWrapperProps = { + ...navigatorItemProps, + elementPath: props.navigatorEntry.elementPath, + } + return + } + + if (props.navigatorEntry.type === 'SLOT') { + const entryProps: SlotNavigatorItemContainerProps = { + ...navigatorItemProps, + renderProp: props.navigatorEntry.prop, + parentElementPath: props.navigatorEntry.elementPath, + } + return + } + + assertNever(props.navigatorEntry) +}) +NavigatorItemWrapper.displayName = 'NavigatorItemWrapper' diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item.tsx new file mode 100644 index 000000000000..a39e4af8276f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/navigator-item.tsx @@ -0,0 +1,1123 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import createCachedSelector from 're-reselect' +import type { CSSProperties } from 'react' +import React from 'react' +import type { ConditionalCase } from '../../../core/model/conditionals' +import { + findMaybeConditionalExpression, + getConditionalActiveCase, + getConditionalCaseCorrespondingToBranchPath, + getConditionalClausePath, + getConditionalFlag, + maybeConditionalActiveBranch, + maybeConditionalExpression, + useConditionalCaseCorrespondingToBranchPath, +} from '../../../core/model/conditionals' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import * as EP from '../../../core/shared/element-path' +import type { + ElementInstanceMetadata, + ElementInstanceMetadataMap, +} from '../../../core/shared/element-template' +import { hasElementsWithin } from '../../../core/shared/element-template' +import type { ElementPath } from '../../../core/shared/project-file-types' +import { unless } from '../../../utils/react-conditionals' +import { useKeepReferenceEqualityIfPossible } from '../../../utils/react-performance' +import type { IcnColor, IcnProps } from '../../../uuiui' +import { FlexRow, useColorTheme, UtopiaTheme } from '../../../uuiui' +import type { ThemeObject } from '../../../uuiui/styles/theme/theme-helpers' +import { isEntryAPlaceholder } from '../../canvas/canvas-utils' +import type { EditorDispatch } from '../../editor/action-types' +import * as EditorActions from '../../editor/actions/action-creators' +import type { + ElementWarnings, + NavigatorEntry, + SlotNavigatorEntry, +} from '../../editor/store/editor-state' +import { + defaultElementWarnings, + isConditionalClauseNavigatorEntry, + isDataReferenceNavigatorEntry, + isRegularNavigatorEntry, + isRenderPropNavigatorEntry, + isSyntheticNavigatorEntry, + navigatorEntryToKey, + varSafeNavigatorEntryToKey, +} from '../../editor/store/editor-state' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import type { + DerivedSubstate, + MetadataSubstate, +} from '../../editor/store/store-hook-substore-types' +import { ComponentPreview } from './component-preview' +import { ExpandableIndicator } from './expandable-indicator' +import { ItemLabel } from './item-label' +import { LayoutIcon } from './layout-icon' +import { NavigatorItemActionSheet } from './navigator-item-components' +import type { ElementPathTrees } from '../../../core/shared/element-path-tree' +import { + conditionalTarget, + renderPropTarget, + useCreateCallbackToShowComponentPicker, +} from './component-picker-context-menu' +import type { Emphasis, Icon } from 'utopia-api' +import { contextMenu } from 'react-contexify' +import { DataReferenceCartoucheControl } from '../../inspector/sections/component-section/data-reference-cartouche' +import { MapListSourceCartoucheNavigator } from '../../inspector/sections/layout-section/list-source-cartouche' +import { regularNavigatorRow } from '../navigator-row' +import { NavigatorRowClickableWrapper } from './navigator-item-clickable-wrapper' +import { useNavigatorSelectionBoundsForEntry } from './use-navigator-selection-bounds-for-entry' + +export function getItemHeight(navigatorEntry: NavigatorEntry): number { + if (isConditionalClauseNavigatorEntry(navigatorEntry)) { + return UtopiaTheme.layout.rowHeight.smallest + } else { + // Default size for everything else. + return UtopiaTheme.layout.rowHeight.smaller + } +} + +export const NavigatorItemTestId = (pathString: string): string => + `NavigatorItemTestId-${pathString}` + +interface ComputedLook { + style: React.CSSProperties & { '--iconOpacity'?: number } + iconColor: IcnProps['color'] +} + +export const BasePaddingUnit = 17 + +export function getElementPadding(withNavigatorDepth: number): number { + const paddingOffset = withNavigatorDepth + return paddingOffset * BasePaddingUnit +} + +export type ParentOutline = 'solid' | 'child' | 'none' + +const highlightItem = ( + dispatch: EditorDispatch, + navigatorEntry: NavigatorEntry, + selected: boolean, + highlighted: boolean, +) => { + const elementPath = getSelectionTargetForNavigatorEntry(navigatorEntry) + if (!highlighted) { + if (selected) { + dispatch([EditorActions.clearHighlightedViews()], 'leftpane') + } else { + dispatch([EditorActions.setHighlightedView(elementPath)], 'leftpane') + } + } +} + +const collapseItem = ( + dispatch: EditorDispatch, + elementPath: ElementPath, + e: React.MouseEvent, +) => { + e.stopPropagation() + dispatch([EditorActions.toggleCollapse(elementPath)], 'leftpane') +} + +type StyleType = + | 'default' + | 'dynamic' + | 'component' + | 'componentInstance' + | 'erroredGroup' + | 'lowEmphasis' + | 'selected' +type SelectedType = + | 'unselected' + | 'selected' + | 'descendantOfSelected' + | 'selectedFocusedComponent' + | 'descendantOfSelectedFocusedComponent' + +const styleTypeColors: Record = { + default: { color: 'fg0', iconColor: 'main' }, + dynamic: { color: 'dynamicBlue', iconColor: 'dynamic' }, + component: { color: 'componentPurple', iconColor: 'component' }, + componentInstance: { color: 'fg0', iconColor: 'main' }, + erroredGroup: { color: 'error', iconColor: 'error' }, + lowEmphasis: { color: 'fg5', iconColor: 'subdued' }, + selected: { color: 'white', iconColor: 'white' }, +} + +const selectedTypeBackground: Record = { + unselected: 'bg1', + selected: 'selectionBlue', + descendantOfSelected: 'childSelectionBlue', + selectedFocusedComponent: 'selectionPurple', + descendantOfSelectedFocusedComponent: 'childSelectionPurple', +} + +const getColors = ( + styleType: StyleType, + selectedType: SelectedType, + colorTheme: ThemeObject, +): ComputedLook => { + const { color: colorKey, iconColor } = styleTypeColors[styleType] + const color = colorTheme[colorKey].value + const background = colorTheme[selectedTypeBackground[selectedType]].value + + return { + style: { background, color }, + iconColor: iconColor, + } +} + +export const NavigatorRowBorderRadius = 5 + +const computeResultingStyle = (params: { + selected: boolean + isTopOfSelection: boolean + isBottomOfSelection: boolean + emphasis: Emphasis + isInsideComponent: boolean + isProbablyScene: boolean + fullyVisible: boolean + isFocusedComponent: boolean + isFocusableComponent: boolean + isHighlightedForInteraction: boolean + isDescendantOfSelected: boolean + isErroredGroup: boolean + colorTheme: ThemeObject +}) => { + const { + selected, + isTopOfSelection, + isBottomOfSelection, + emphasis, + isInsideComponent, + isProbablyScene, + fullyVisible, + isFocusedComponent, + isFocusableComponent, + isHighlightedForInteraction, + isDescendantOfSelected, + isErroredGroup, + colorTheme, + } = params + let styleType: StyleType = 'default' + let selectedType: SelectedType = 'unselected' + + if (selected) { + styleType = 'selected' + } else if (isErroredGroup) { + styleType = 'erroredGroup' + } else if ( + (!isProbablyScene && isFocusedComponent) || + (isDescendantOfSelected && isInsideComponent) + ) { + styleType = 'component' + } else if (emphasis === 'subdued') { + styleType = 'lowEmphasis' + } else if (isFocusableComponent) { + styleType = 'componentInstance' + } else if (isHighlightedForInteraction) { + styleType = 'default' + } + const isProbablyParentOfSelected = (isProbablyScene || fullyVisible) && !selected + + if ( + selected && + (isFocusedComponent || isInsideComponent) && + !isProbablyScene && + !isProbablyParentOfSelected + ) { + selectedType = 'selectedFocusedComponent' + } else if (selected) { + selectedType = 'selected' + } else if (isDescendantOfSelected && isInsideComponent) { + selectedType = 'descendantOfSelectedFocusedComponent' + } else if (isDescendantOfSelected) { + selectedType = 'descendantOfSelected' + } else { + selectedType = 'unselected' + } + + let result = getColors(styleType, selectedType, colorTheme) + + const borderRadiusTop = isTopOfSelection ? NavigatorRowBorderRadius : 0 + const borderRadiusBottom = isBottomOfSelection ? NavigatorRowBorderRadius : 0 + + result.style = { + ...result.style, + fontWeight: isProbablyParentOfSelected || isProbablyScene ? 600 : 'inherit', + borderTopLeftRadius: borderRadiusTop, + borderTopRightRadius: borderRadiusTop, + borderBottomLeftRadius: borderRadiusBottom, + borderBottomRightRadius: borderRadiusBottom, + } + + return result +} + +function useStyleFullyVisible( + navigatorEntry: NavigatorEntry, + autoFocusedPaths: Array, +): boolean { + return useEditorState( + Substores.metadata, + (store) => { + if (isRegularNavigatorEntry(navigatorEntry)) { + const path = navigatorEntry.elementPath + const metadata = store.editor.jsxMetadata + const selectedViews = store.editor.selectedViews + const isSelected = selectedViews.some((selected) => EP.pathsEqual(path, selected)) + const isParentOfSelected = selectedViews.some((selected) => EP.isParentOf(path, selected)) + + const isStoryboardChild = EP.isStoryboardChild(path) + + const isContainingBlockAncestor = selectedViews.some((selected) => { + return EP.pathsEqual(MetadataUtils.findContainingBlock(metadata, selected), path) + }) + + const isFlexAncestorDirectionChange = selectedViews.some((selected) => { + const selectedSizeMeasurements = MetadataUtils.findElementByElementPath( + metadata, + selected, + )?.specialSizeMeasurements + const parentPath = EP.parentPath(selected) + if ( + selectedSizeMeasurements?.parentLayoutSystem === 'flex' && + !isParentOfSelected && + EP.isDescendantOfOrEqualTo(selected, path) && + parentPath != null + ) { + const flexDirectionChange = MetadataUtils.findNearestAncestorFlexDirectionChange( + metadata, + parentPath, + ) + return EP.pathsEqual(flexDirectionChange, path) + } else { + return false + } + }) + + const isInsideFocusedComponent = EP.isInExplicitlyFocusedSubtree( + store.editor.focusedElementPath, + autoFocusedPaths, + navigatorEntry.elementPath, + ) + + return ( + isStoryboardChild || + isSelected || + isParentOfSelected || + isContainingBlockAncestor || + isFlexAncestorDirectionChange || + isInsideFocusedComponent + ) + } else { + return false + } + }, + 'NavigatorItem useStyleFullyVisible', + ) +} + +function useIsProbablyScene(navigatorEntry: NavigatorEntry): boolean { + return useEditorState( + Substores.metadata, + (store) => + isRegularNavigatorEntry(navigatorEntry) && + MetadataUtils.isProbablyScene(store.editor.jsxMetadata, navigatorEntry.elementPath), + 'NavigatorItem useIsProbablyScene', + ) +} + +const isHiddenConditionalBranchSelector = createCachedSelector( + (store: MetadataSubstate) => store.editor.jsxMetadata, + (store: MetadataSubstate, _elementPath: ElementPath, parentPath: ElementPath) => + MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, parentPath), + (_store: MetadataSubstate, elementPath: ElementPath, _parentPath: ElementPath) => elementPath, + (_store: MetadataSubstate, _elementPath: ElementPath, parentPath: ElementPath) => parentPath, + ( + metadata: ElementInstanceMetadataMap, + parent: ElementInstanceMetadata | null, + elementPath: ElementPath, + parentPath: ElementPath, + ): boolean => { + if (parent == null) { + return false + } + const originalConditionValue = parent.conditionValue + if (originalConditionValue === 'not-a-conditional') { + return false + } + + const conditional = maybeConditionalExpression(parent) + if (conditional == null) { + return false + } + + const flag = getConditionalFlag(conditional) + + // the final condition value, either from the original or from the override + const overriddenConditionValue: boolean = flag ?? originalConditionValue.active + + const branch = maybeConditionalActiveBranch(parentPath, metadata) + + if ( + EP.isDynamicPath(elementPath) && + hasElementsWithin(branch) && + Object.values(branch.elementsWithin) + .map((e) => EP.appendToPath(parentPath, e.uid)) + .find((e) => EP.pathsEqual(e, EP.dynamicPathToStaticPath(elementPath))) != null + ) { + // the branch is active _and_ it's dynamic + return false + } + + // when the condition is true, then the 'then' branch is not hidden + if (overriddenConditionValue) { + const trueClausePath = getConditionalClausePath(parentPath, conditional.whenTrue) + return !EP.pathsEqual(elementPath, trueClausePath) + } + // when the condition is false, then the 'else' branch is not hidden + const falseClausePath = getConditionalClausePath(parentPath, conditional.whenFalse) + return !EP.pathsEqual(elementPath, falseClausePath) + }, +)((_, elementPath, parentPath) => `${EP.toString(elementPath)}_${EP.toString(parentPath)}`) + +export const elementWarningsSelector = createCachedSelector( + (store: DerivedSubstate) => store.derived.elementWarnings, + (_: DerivedSubstate, navigatorEntry: NavigatorEntry) => navigatorEntry, + (elementWarnings, navigatorEntry) => { + if (isRegularNavigatorEntry(navigatorEntry)) { + return elementWarnings[EP.toString(navigatorEntry.elementPath)] ?? defaultElementWarnings + } else { + return defaultElementWarnings + } + }, +)((_, navigatorEntry) => navigatorEntryToKey(navigatorEntry)) + +type CodeItemType = 'conditional' | 'map' | 'code' | 'none' | 'remix' +export type RemixItemType = 'scene' | 'outlet' | 'link' | 'none' + +export interface NavigatorItemInnerProps { + navigatorEntry: NavigatorEntry + indentation: number + getSelectedViewsInRange: (i: number) => Array // TODO KILLME + noOfChildren: number + label: string + dispatch: EditorDispatch + isHighlighted: boolean + collapsed: boolean + isElementVisible: boolean + renamingTarget: ElementPath | null + selected: boolean + parentOutline: ParentOutline + isOutletOrDescendantOfOutlet: boolean + visibleNavigatorTargets: Array +} + +export const NavigatorItem: React.FunctionComponent< + React.PropsWithChildren +> = React.memo((props) => { + const { dispatch, isHighlighted, isElementVisible, selected, collapsed, navigatorEntry } = props + + const colorTheme = useColorTheme() + + const autoFocusedPaths = useEditorState( + Substores.derived, + (store) => store.derived.autoFocusedPaths, + 'NavigatorItem autoFocusedPaths', + ) + + const isFocusedComponent = useEditorState( + Substores.focusedElement, + (store) => + isRegularNavigatorEntry(navigatorEntry) && + EP.isExplicitlyFocused( + store.editor.focusedElementPath, + autoFocusedPaths, + navigatorEntry.elementPath, + ), + 'NavigatorItem isFocusedComponent', + ) + + const isInFocusedComponentSubtree = useEditorState( + Substores.focusedElement, + (store) => + EP.isInExplicitlyFocusedSubtree( + store.editor.focusedElementPath, + autoFocusedPaths, + navigatorEntry.elementPath, + ), + 'NavigatorItem isInFocusedComponentSubtree', + ) + + const elementWarnings = useEditorState( + Substores.derived, + (store) => elementWarningsSelector(store, props.navigatorEntry), + 'NavigatorItem elementWarningsSelector', + ) + + const filePathMappings = useEditorState( + Substores.derived, + (store) => store.derived.filePathMappings, + 'NavigatorItem filePathMappings', + ) + + const isManuallyFocusableComponent = useEditorState( + Substores.fullStore, + (store) => { + return ( + isRegularNavigatorEntry(navigatorEntry) && + MetadataUtils.isManuallyFocusableComponent( + navigatorEntry.elementPath, + store.editor.jsxMetadata, + autoFocusedPaths, + filePathMappings, + store.editor.propertyControlsInfo, + store.editor.projectContents, + ) + ) + }, + 'NavigatorItem isManuallyFocusableComponent', + ) + + const entryNavigatorDepth = props.indentation + + const containsExpressions: boolean = useEditorState( + Substores.metadata, + (store) => { + return elementContainsExpressions( + navigatorEntry.elementPath, + store.editor.jsxMetadata, + store.editor.elementPathTree, + ) + }, + 'NavigatorItem containsExpressions', + ) + + const isConditionalDynamicBranch = useEditorState( + Substores.metadata, + (store) => { + if ( + !isSyntheticNavigatorEntry(navigatorEntry) || + !hasElementsWithin(navigatorEntry.childOrAttribute) + ) { + return false + } + + const parentPath = EP.parentPath(navigatorEntry.elementPath) + const conditionalParent = findMaybeConditionalExpression(parentPath, store.editor.jsxMetadata) + if (conditionalParent == null) { + return false + } + + const clause = getConditionalCaseCorrespondingToBranchPath( + getConditionalClausePath(parentPath, navigatorEntry.childOrAttribute), + store.editor.jsxMetadata, + ) + if (clause == null) { + return false + } + + return ( + getConditionalActiveCase(parentPath, conditionalParent, store.editor.spyMetadata) === clause + ) + }, + 'NavigatorItem isConditionalDynamicBranch', + ) + + const childComponentCount = props.noOfChildren + + const isGenerated = MetadataUtils.isElementOrAncestorGenerated(navigatorEntry.elementPath) + const isDynamic = isGenerated || containsExpressions || isConditionalDynamicBranch + + const codeItemType: CodeItemType = useEditorState( + Substores.metadata, + (store) => { + if (!isRegularNavigatorEntry(props.navigatorEntry)) { + return 'none' + } + const elementMetadata = MetadataUtils.findElementByElementPath( + store.editor.jsxMetadata, + props.navigatorEntry.elementPath, + ) + if (MetadataUtils.isConditionalFromMetadata(elementMetadata)) { + return 'conditional' + } + if (MetadataUtils.isJSXMapExpressionFromMetadata(elementMetadata)) { + return 'map' + } + if (MetadataUtils.isExpressionOtherJavascriptFromMetadata(elementMetadata)) { + return 'code' + } + if ( + MetadataUtils.isProbablyRemixOutletFromMetadata(elementMetadata) || + MetadataUtils.isProbablyRemixSceneFromMetadata(elementMetadata) || + MetadataUtils.isProbablyRemixLinkFromMetadata(elementMetadata) + ) { + return 'remix' + } + return 'none' + }, + 'NavigatorItem codeItemType', + ) + + const remixItemType: RemixItemType = useEditorState( + Substores.metadata, + (store) => { + const elementMetadata = MetadataUtils.findElementByElementPath( + store.editor.jsxMetadata, + props.navigatorEntry.elementPath, + ) + if (MetadataUtils.isProbablyRemixSceneFromMetadata(elementMetadata)) { + return 'scene' + } + if (MetadataUtils.isProbablyRemixOutletFromMetadata(elementMetadata)) { + return 'outlet' + } + if (MetadataUtils.isProbablyRemixLinkFromMetadata(elementMetadata)) { + return 'link' + } + return 'none' + }, + 'NavigatorItem remixItemType', + ) + + const isConditional = codeItemType === 'conditional' + + const metadata = useEditorState( + Substores.metadata, + (store): ElementInstanceMetadataMap => { + return store.editor.jsxMetadata + }, + 'NavigatorItem metadata', + ) + + const emphasis = useEditorState( + Substores.propertyControlsInfo, + (store): Emphasis => { + return MetadataUtils.getEmphasisOfComponent( + navigatorEntry.elementPath, + metadata, + store.editor.propertyControlsInfo, + store.editor.projectContents, + ) + }, + 'NavigatorItem emphasis', + ) + + const iconOverride = useEditorState( + Substores.propertyControlsInfo, + (store) => + MetadataUtils.getIconOfComponent( + navigatorEntry.elementPath, + store.editor.propertyControlsInfo, + store.editor.projectContents, + ), + 'NavigatorItem iconOverride', + ) + + const isInsideComponent = isInFocusedComponentSubtree + const fullyVisible = useStyleFullyVisible(navigatorEntry, autoFocusedPaths) + const isProbablyScene = useIsProbablyScene(navigatorEntry) + + const elementIsData = navigatorEntry.type === 'DATA_REFERENCE' + + const isHighlightedForInteraction = useEditorState( + Substores.restOfEditor, + (store) => { + return ( + isRegularNavigatorEntry(props.navigatorEntry) && + store.editor.navigator.highlightedTargets.some((target) => + EP.pathsEqual(target, props.navigatorEntry.elementPath), + ) + ) + }, + 'isreallyhighlighted', + ) + + const isDescendantOfSelected = useEditorState( + Substores.selectedViews, + (store) => + store.editor.selectedViews.some((path) => + EP.isDescendantOfOrEqualTo(navigatorEntry.elementPath, path), + ), + 'navigator item isDescendantOfSelected', + ) + + const isErroredGroup = React.useMemo(() => { + return elementWarnings.invalidGroup != null || elementWarnings.invalidGroupChild != null + }, [elementWarnings]) + + const toggleCollapse = React.useCallback( + (event: React.MouseEvent) => { + collapseItem(dispatch, navigatorEntry.elementPath, event) + event.stopPropagation() + }, + [dispatch, navigatorEntry.elementPath], + ) + + const highlight = React.useCallback( + () => highlightItem(dispatch, navigatorEntry, selected, isHighlighted), + [dispatch, navigatorEntry, selected, isHighlighted], + ) + + const focusComponent = React.useCallback( + (event: React.MouseEvent) => { + if (isManuallyFocusableComponent && !event.altKey) { + const elementPath = getSelectionTargetForNavigatorEntry(navigatorEntry) + dispatch([EditorActions.setFocusedElement(elementPath)]) + } + }, + [dispatch, navigatorEntry, isManuallyFocusableComponent], + ) + + const isHiddenConditionalBranch = useEditorState( + Substores.metadata, + (store) => + isHiddenConditionalBranchSelector( + store, + props.navigatorEntry.elementPath, + EP.parentPath(props.navigatorEntry.elementPath), + ), + 'NavigatorItem isHiddenConditionalBranch', + ) + + const conditionalCase = useConditionalCaseCorrespondingToBranchPath( + props.navigatorEntry.elementPath, + ) + + const isPlaceholder = isEntryAPlaceholder(props.navigatorEntry) + + const isRenderProp = isRenderPropNavigatorEntry(navigatorEntry) + + const containerStyle: React.CSSProperties = React.useMemo(() => { + return { + opacity: isElementVisible && (!isHiddenConditionalBranch || isPlaceholder) ? undefined : 0.4, + overflow: 'hidden', + flexGrow: 1, + flexShrink: 0, + } + }, [isElementVisible, isHiddenConditionalBranch, isPlaceholder]) + + const cursorStyle = isConditionalClauseNavigatorEntry(navigatorEntry) ? { cursor: 'pointer' } : {} + + const canBeExpanded = React.useMemo(() => { + return ( + isConditional || // if it is a conditional, so it could have no children if both branches are null + childComponentCount > 0 || + isFocusedComponent + ) + }, [childComponentCount, isFocusedComponent, isConditional]) + + const { isTopOfSelection, isBottomOfSelection } = useNavigatorSelectionBoundsForEntry( + navigatorEntry, + selected, + childComponentCount, + ) + + const resultingStyle = computeResultingStyle({ + selected: elementIsData ? false : selected, + isTopOfSelection: isTopOfSelection, + isBottomOfSelection: isBottomOfSelection, + emphasis: emphasis, + isInsideComponent: isInsideComponent, + isProbablyScene: isProbablyScene, + fullyVisible: fullyVisible, + isFocusedComponent: isFocusedComponent, + isFocusableComponent: isManuallyFocusableComponent, + isHighlightedForInteraction: isHighlightedForInteraction, + isDescendantOfSelected: elementIsData && selected ? false : isDescendantOfSelected, + isErroredGroup: isErroredGroup, + colorTheme: colorTheme, + }) + + const rowStyle = useKeepReferenceEqualityIfPossible({ + paddingLeft: getElementPadding(entryNavigatorDepth), + height: getItemHeight(navigatorEntry), + ...resultingStyle.style, + ...cursorStyle, + }) + + const iconColor = resultingStyle.iconColor + + const currentlyRenaming = EP.pathsEqual(props.renamingTarget, props.navigatorEntry.elementPath) + + const onMouseDown = React.useCallback( + (e: React.MouseEvent) => { + // Only do this for a left mouse down. + if (e.button === 0) { + if (isRenderPropNavigatorEntry(navigatorEntry)) { + e.stopPropagation() + } + + contextMenu.hideAll() + } + }, + [navigatorEntry], + ) + + const isScene = React.useMemo(() => { + return ( + MetadataUtils.isProbablyScene(metadata, props.navigatorEntry.elementPath) || + MetadataUtils.isProbablyRemixScene(metadata, props.navigatorEntry.elementPath) + ) + }, [props.navigatorEntry, metadata]) + + const hideExpandableIndicator = React.useMemo(() => { + return props.navigatorEntry.type === 'CONDITIONAL_CLAUSE' || isScene + }, [props.navigatorEntry, isScene]) + + return ( + +
+ + {isPlaceholder ? ( + navigatorEntry.type === 'SLOT' ? ( + + ) : conditionalCase !== null ? ( + + ) : ( + + ) + ) : isRenderProp ? ( +
+ {props.label} +
+ ) : elementIsData ? ( +
+ +
+ ) : ( + + + {unless( + hideExpandableIndicator, + , + )} + + + {unless( + currentlyRenaming || props.navigatorEntry.type === 'CONDITIONAL_CLAUSE', + , + )} + + )} +
+
+
+ ) +}) +NavigatorItem.displayName = 'NavigatorItem' + +interface RenderPropSlotProps { + label: string + parentOutline: ParentOutline + navigatorEntry: SlotNavigatorEntry +} + +const RenderPropSlot = React.memo((props: RenderPropSlotProps) => { + const { label, parentOutline, navigatorEntry } = props + const target = EP.parentPath(navigatorEntry.elementPath) + const insertionTarget = renderPropTarget(navigatorEntry.prop) + + const showComponentPickerContextMenu = useCreateCallbackToShowComponentPicker()( + [target], + insertionTarget, + ) as React.MouseEventHandler + + return ( + + ) +}) + +interface ConditionalBranchSlotProps { + label: string + parentOutline: ParentOutline + navigatorEntry: NavigatorEntry + conditionalCase: ConditionalCase +} + +const ConditionalBranchSlot = React.memo((props: ConditionalBranchSlotProps) => { + const { label, parentOutline, navigatorEntry, conditionalCase } = props + const target = EP.parentPath(navigatorEntry.elementPath) + + const showComponentPickerContextMenu = useCreateCallbackToShowComponentPicker()( + [target], + conditionalTarget(conditionalCase), + ) as React.MouseEventHandler + + return ( + + ) +}) + +interface PlaceholderSlotProps { + label: string + parentOutline: ParentOutline + cursor?: 'pointer' | 'inherit' + testId?: string + onMouseDown?: React.MouseEventHandler +} + +const PlaceholderSlot = React.memo((props: PlaceholderSlotProps) => { + const { label, parentOutline, cursor, testId, onMouseDown } = props + const colorTheme = useColorTheme() + + return ( +
+ Empty +
+ ) +}) +interface NavigatorRowLabelProps { + navigatorEntry: NavigatorEntry + iconColor: IcnProps['color'] + iconOverride: Icon | null + elementWarnings: ElementWarnings | null + label: string + isDynamic: boolean + renamingTarget: ElementPath | null + selected: boolean + codeItemType: CodeItemType + remixItemType: RemixItemType + emphasis: Emphasis + shouldShowParentOutline: boolean + childComponentCount: number + dispatch: EditorDispatch + insideFocusedComponent: boolean + style?: CSSProperties +} + +export const NavigatorRowLabel = React.memo((props: NavigatorRowLabelProps) => { + const isCodeItem = props.codeItemType !== 'none' && props.codeItemType !== 'remix' + + return ( +
+ {unless( + props.navigatorEntry.type === 'CONDITIONAL_CLAUSE' || + props.navigatorEntry.type === 'RENDER_PROP', + , + )} + +
+ +
+ + +
+ ) +}) + +function elementContainsExpressions( + path: ElementPath, + metadata: ElementInstanceMetadataMap, + pathTrees: ElementPathTrees, +): boolean { + return MetadataUtils.isGeneratedTextFromMetadata(path, pathTrees, metadata) +} + +function getSelectionTargetForNavigatorEntry(navigatorEntry: NavigatorEntry): ElementPath { + if (isDataReferenceNavigatorEntry(navigatorEntry)) { + return EP.parentPath(navigatorEntry.elementPath) + } + if (isRenderPropNavigatorEntry(navigatorEntry) && navigatorEntry.childPath != null) { + return navigatorEntry.childPath + } + + return navigatorEntry.elementPath +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/scroll-to-element-if-selected-hook.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/scroll-to-element-if-selected-hook.tsx new file mode 100644 index 000000000000..1ed626bc4961 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/scroll-to-element-if-selected-hook.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { cancelIdleCallback, requestIdleCallback } from '../../../utils/request-idle-callback-shim' + +// Modified from this: https://stackoverflow.com/a/15203639/305009 +function isElementVisible(element: HTMLElement) { + const rect = element.getBoundingClientRect() + const vWidth = window.innerWidth ?? document.documentElement!.clientWidth + const vHeight = window.innerHeight ?? document.documentElement!.clientHeight + const elementFromPoint = function (x: number, y: number) { + return document.elementFromPoint(x, y) + } + + if (process.env.JEST_WORKER_ID != null) { + // Jest mitigation, jsdom doesn't have document.elementFromPoint or scrollIntoView + return true + } + + if (rect.right < 0 || rect.bottom < 0 || rect.left > vWidth || rect.top > vHeight) { + // Return false if it's not in the viewport + return false + } else { + // Return true if any of its four corners are visible + return ( + element.contains(elementFromPoint(rect.left, rect.top)) || + element.contains(elementFromPoint(rect.right, rect.top)) || + element.contains(elementFromPoint(rect.right, rect.bottom)) || + element.contains(elementFromPoint(rect.left, rect.bottom)) + ) + } +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-item/use-navigator-selection-bounds-for-entry.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-item/use-navigator-selection-bounds-for-entry.tsx new file mode 100644 index 000000000000..e0f4af4fe7d6 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-item/use-navigator-selection-bounds-for-entry.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import * as EP from '../../../core/shared/element-path' +import type { NavigatorEntry } from '../../editor/store/editor-state' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { isRegulaNavigatorRow } from '../navigator-row' +import { createSelector } from 'reselect' +import { navigatorTargetsSelector } from '../navigator-utils' + +const navigatorRowsPathsSelector = createSelector(navigatorTargetsSelector, (targets) => { + return targets.navigatorRows.map((row) => + isRegulaNavigatorRow(row) ? row.entry.elementPath : row.entries[0].elementPath, + ) +}) + +export function useNavigatorSelectionBoundsForEntry( + navigatorEntry: NavigatorEntry, + selected: boolean, + childComponentCount: number, +): { + isTopOfSelection: boolean + isBottomOfSelection: boolean +} { + const selectedViews = useEditorState( + Substores.selectedViews, + (store) => store.editor.selectedViews, + 'useNavigatorSelectionBoundsCheck selectedViews', + ) + + const navigatorRowsPaths = useEditorState( + Substores.navigatorTargetsSubstate, + navigatorRowsPathsSelector, + 'useNavigatorSelectionBoundsCheck navigatorRowsPaths', + ) + + const collapsedViews = useEditorState( + Substores.navigator, + (store) => { + return store.editor.navigator.collapsedViews + }, + 'useNavigatorSelectionBoundsCheck collapsedViews', + ) + + return React.useMemo(() => { + const index = navigatorRowsPaths.findIndex((view) => + EP.pathsEqual(view, navigatorEntry.elementPath), + ) + + const previous = index > 0 ? navigatorRowsPaths.at(index - 1) : null + const next = index < navigatorRowsPaths.length - 1 ? navigatorRowsPaths.at(index + 1) : null + + const isDangling = + childComponentCount === 0 || collapsedViews.includes(navigatorEntry.elementPath) + + const isTopOfSelection = + previous == null || + (!selectedViews.some( + (view) => + EP.pathsEqual(view, previous) || + EP.isDescendantOf(EP.parentPath(navigatorEntry.elementPath), view) || + EP.isDescendantOf(previous, view), + ) && + selected) + + const isBottomOfSelection = + next == null || + ((!selected || isDangling) && + !selectedViews.some((view) => EP.pathsEqual(view, next) || EP.isDescendantOf(next, view))) + + return { + isTopOfSelection: isTopOfSelection, + isBottomOfSelection: isBottomOfSelection, + } + }, [ + navigatorRowsPaths, + navigatorEntry, + selectedViews, + selected, + childComponentCount, + collapsedViews, + ]) +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-row.tsx b/nexus-builder/packages/navigator/src/navigator/navigator-row.tsx new file mode 100644 index 000000000000..029ca5779f85 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-row.tsx @@ -0,0 +1,58 @@ +import type { NavigatorEntry } from '../editor/store/editor-state' + +export type NavigatorRow = RegularNavigatorRow | CondensedNavigatorRow + +export interface RegularNavigatorRow { + type: 'regular-row' + indentation: number + entry: NavigatorEntry +} + +export function regularNavigatorRow( + entry: NavigatorEntry, + indentation: number, +): RegularNavigatorRow { + return { + type: 'regular-row', + entry: entry, + indentation: indentation, + } +} + +export function isRegulaNavigatorRow(row: NavigatorRow): row is RegularNavigatorRow { + return row.type === 'regular-row' +} + +export interface CondensedNavigatorRow { + type: 'condensed-row' + indentation: number + variant: CondensedNavigatorRowVariant + entries: Array +} + +export type CondensedNavigatorRowVariant = 'trunk' | 'leaf' + +export function condensedNavigatorRow( + entries: Array, + variant: 'trunk' | 'leaf', + indentation: number, +): CondensedNavigatorRow { + return { + type: 'condensed-row', + variant: variant, + entries: entries, + indentation: indentation, + } +} + +export function isCondensedNavigatorRow(row: NavigatorRow): row is CondensedNavigatorRow { + return row.type === 'condensed-row' +} + +export function getEntriesForRow(row: NavigatorRow): Array { + if (isRegulaNavigatorRow(row)) { + return [row.entry] + } else { + return row.entries + } +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator-utils.ts b/nexus-builder/packages/navigator/src/navigator/navigator-utils.ts new file mode 100644 index 000000000000..d84324561244 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator-utils.ts @@ -0,0 +1,925 @@ +import type { ElementPath } from '../../core/shared/project-file-types' +import * as EP from '../../core/shared/element-path' +import type { + ElementInstanceMetadata, + ElementInstanceMetadataMap, + JSXConditionalExpression, + JSXElementChild, + JSXMapExpression, +} from '../../core/shared/element-template' +import { + getJSXAttribute, + hasElementsWithin, + isJSExpressionValue, + isJSXConditionalExpression, + isJSXElement, + isJSXMapExpression, +} from '../../core/shared/element-template' +import { MetadataUtils } from '../../core/model/element-metadata-utils' +import { isLeft } from '../../core/shared/either' +import type { + ConditionalClauseNavigatorEntry, + EditorState, + EditorStorePatched, + NavigatorEntry, +} from '../editor/store/editor-state' +import { + conditionalClauseNavigatorEntry, + dataReferenceNavigatorEntry, + getElementFromProjectContents, + invalidOverrideNavigatorEntry, + regularNavigatorEntry, + renderPropNavigatorEntry, + renderPropValueNavigatorEntry, + renderedAtChildNode, + renderedAtPropertyPath, + slotNavigatorEntry, + syntheticNavigatorEntry, +} from '../editor/store/editor-state' +import type { ElementPathTree, ElementPathTrees } from '../../core/shared/element-path-tree' +import { + getCanvasRoots, + getElementPathTreeChildren, + getSubTree, +} from '../../core/shared/element-path-tree' +import { assertNever } from '../../core/shared/utils' +import type { ConditionalCase } from '../../core/model/conditionals' +import { getConditionalClausePath } from '../../core/model/conditionals' +import { + findUtopiaCommentFlag, + isUtopiaPropOrCommentFlagMapCount, +} from '../../core/shared/utopia-flags' +import { getPropertyControlsForTarget } from '../../core/property-controls/property-controls-utils' +import type { PropertyControlsInfo } from '../custom-code/code-file' +import type { ProjectContentTreeRoot } from '../assets' +import type { PropertyControls } from '../custom-code/internal-property-controls' +import { isFeatureEnabled } from '../../utils/feature-switches' +import * as PP from '../../core/shared/property-path' +import * as EPP from '../template-property-path' +import { + condensedNavigatorRow, + getEntriesForRow, + regularNavigatorRow, + type NavigatorRow, + type RegularNavigatorRow, +} from './navigator-row' +import { dropNulls, mapDropNulls } from '../../core/shared/array-utils' +import { getUtopiaID } from '../../core/shared/uid-utils' +import { emptySet } from '../../core/shared/set-utils' +import { objectMap } from '../../core/shared/object-utils' +import { createSelector } from 'reselect' +import { Substores, useEditorState } from '../editor/store/store-hook' +import type { + MetadataSubstate, + NavigatorSubstate, + NavigatorTargetsSubstate, + ProjectContentSubstate, + PropertyControlsInfoSubstate, +} from '../editor/store/store-hook-substore-types' +import { dataCanCondenseFromMetadata } from '../../utils/can-condense' + +export function baseNavigatorDepth(path: ElementPath): number { + // The storyboard means that this starts at -1, + // so that the scenes are the left most entity. + return EP.fullDepth(path) - 1 +} + +type RegularNavigatorTree = { + type: 'regular-entry' + elementHidden: boolean + subtreeHidden: boolean + navigatorEntry: NavigatorEntry + renderProps: { [propName: string]: NavigatorTree } + children: Array +} + +// maybe the leaf is not actually useful and we can remove it +type LeafNavigatorTree = { + type: 'leaf-entry' + elementHidden: boolean + navigatorEntry: NavigatorEntry +} + +type MapNavigatorTree = { + type: 'map-entry' + elementHidden: boolean + subtreeHidden: boolean + navigatorEntry: NavigatorEntry + mappedEntries: Array +} + +type ConditionalNavigatorTree = { + type: 'conditional-entry' + elementHidden: boolean + subtreeHidden: boolean + navigatorEntry: NavigatorEntry + trueCase: Array + falseCase: Array +} + +type CondensedTrunkNavigatorTree = { + type: 'condensed-trunk' + elementHidden: boolean + subtreeHidden: boolean + navigatorEntry: NavigatorEntry + child: NavigatorTree +} + +type CondensedLeafNavigatorTree = { + type: 'condensed-leaf' + navigatorEntry: NavigatorEntry + children: Array +} + +export type NavigatorTree = + | RegularNavigatorTree + | LeafNavigatorTree + | MapNavigatorTree + | ConditionalNavigatorTree + | CondensedTrunkNavigatorTree + | CondensedLeafNavigatorTree + +function isSubtreeHidden(navigatorTree: NavigatorTree): boolean { + return 'subtreeHidden' in navigatorTree && navigatorTree.subtreeHidden +} + +function isElementHidden(navigatorTree: NavigatorTree): boolean { + return 'elementHidden' in navigatorTree && navigatorTree.elementHidden +} + +interface GetNavigatorTargetsResults { + navigatorRows: Array + navigatorTargets: Array + visibleNavigatorTargets: Array +} + +export function getNavigatorTrees( + metadata: ElementInstanceMetadataMap, + elementPathTree: ElementPathTrees, + collapsedViews: Array, + hiddenInNavigator: Array, + propertyControlsInfo: PropertyControlsInfo, + projectContents: ProjectContentTreeRoot, +): Array { + const canvasRoots = getCanvasRoots(elementPathTree) + const navigatorTrees: Array = mapDropNulls((canvasRoot) => { + const subTree = getSubTree(elementPathTree, canvasRoot.path) + if (subTree == null) { + return null + } + return createNavigatorSubtree( + metadata, + elementPathTree, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + subTree, + ) + }, canvasRoots) + + return navigatorTrees +} + +function createNavigatorSubtree( + metadata: ElementInstanceMetadataMap, + elementPathTrees: ElementPathTrees, + projectContents: ProjectContentTreeRoot, + propertyControlsInfo: PropertyControlsInfo, + collapsedViews: Array, // TODO turn this into a single array!! + hiddenInNavigator: Array, + subTree: ElementPathTree, +): NavigatorTree | null { + const elementPath = subTree.path + const jsxElementChild = getElementFromProjectContents(elementPath, projectContents) + if (jsxElementChild == null) { + return { + type: 'leaf-entry', + elementHidden: false, + navigatorEntry: invalidOverrideNavigatorEntry( + subTree.path, + 'Element was not found in project contents.', + ), + } + } + + const elementIsDataReferenceFromProjectContents = + MetadataUtils.isElementDataReference(jsxElementChild) + + const isCollapsed = EP.containsPath(elementPath, collapsedViews) + const isHiddenInNavigator = EP.containsPath(elementPath, hiddenInNavigator) + + const elementTypeHidden = MetadataUtils.isElementTypeHiddenInNavigator( + elementPath, + metadata, + elementPathTrees, + ) + + const subtreeHidden = isCollapsed + const elementHidden = isHiddenInNavigator || elementTypeHidden + + if ( + elementIsDataReferenceFromProjectContents && + isFeatureEnabled('Condensed Navigator Entries') + ) { + // add synthetic entry + const dataRefEntry = dataReferenceNavigatorEntry( + elementPath, + renderedAtChildNode(EP.parentPath(elementPath), EP.toUid(elementPath)), + EP.parentPath(elementPath), + jsxElementChild, + ) + return { type: 'leaf-entry', elementHidden: elementHidden, navigatorEntry: dataRefEntry } + } + + if (isJSXConditionalExpression(jsxElementChild)) { + return walkConditionalNavigatorEntry( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + subTree, + jsxElementChild, + elementPath, + elementHidden, + subtreeHidden, + ) + } + + if (isJSXMapExpression(jsxElementChild)) { + return walkMapExpression( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + subTree, + jsxElementChild, + elementHidden, + subtreeHidden, + ) + } + + return walkRegularNavigatorEntry( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + subTree, + jsxElementChild, + getPropertyControlsForTarget(elementPath, propertyControlsInfo, projectContents), + elementPath, + elementHidden, + subtreeHidden, + ) +} + +function walkRegularNavigatorEntry( + metadata: ElementInstanceMetadataMap, + elementPathTrees: ElementPathTrees, + projectContents: ProjectContentTreeRoot, + propertyControlsInfo: PropertyControlsInfo, + collapsedViews: Array, + hiddenInNavigator: Array, + subTree: ElementPathTree, + jsxElement: JSXElementChild, + propControls: PropertyControls | null, // TODO this is redundant, we should be able to get this from propertyControlsInfo: PropertyControlsInfo, + elementPath: ElementPath, + elementHidden: boolean, + subtreeHidden: boolean, +): NavigatorTree | null { + let renderPropChildrenAccumulator: { [propName: string]: NavigatorTree } = {} + let processedAccumulator: Set = emptySet() + + const elementMetadata = MetadataUtils.findElementByElementPath(metadata, elementPath) + // If there was an early return, then we should stop walking the tree here. + if (elementMetadata != null && elementMetadata.earlyReturn != null) { + return null + } + + if (isJSXElement(jsxElement)) { + Object.entries(propControls ?? {}).forEach(([prop, control]) => { + if (control.control !== 'jsx' || prop === 'children') { + return + } + const propValue = getJSXAttribute(jsxElement.props, prop) + const fakeRenderPropPath = EP.appendToPath(elementPath, renderPropId(prop)) + + if (propValue == null || (isJSExpressionValue(propValue) && propValue.value == null)) { + renderPropChildrenAccumulator[prop] = { + type: 'leaf-entry', + elementHidden: false, + navigatorEntry: slotNavigatorEntry( + fakeRenderPropPath, // TODO fakeRenderPropPath must be deleted + prop, + ), + } + return + } + + const childPath = EP.appendToPath(elementPath, getUtopiaID(propValue)) + + const subTreeChild = getElementPathTreeChildren(subTree).find((child) => + EP.pathsEqual(child.path, childPath), + ) + if (subTreeChild != null) { + const childTreeEntry = createNavigatorSubtree( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + subTreeChild, + ) + if (childTreeEntry != null) { + const childTree: NavigatorTree = { + ...childTreeEntry, + navigatorEntry: renderPropValueNavigatorEntry(childPath, prop), + } + processedAccumulator.add(EP.toString(subTreeChild.path)) + renderPropChildrenAccumulator[prop] = childTree + } + } else { + const synthEntry = isFeatureEnabled('Condensed Navigator Entries') + ? dataReferenceNavigatorEntry( + childPath, + renderedAtPropertyPath(EPP.create(elementPath, PP.create(prop))), + elementPath, + propValue, + ) + : syntheticNavigatorEntry(childPath, propValue) + + processedAccumulator.add(EP.toString(childPath)) + renderPropChildrenAccumulator[prop] = { + type: 'leaf-entry', + elementHidden: false, + navigatorEntry: synthEntry, + } + } + }) + } + + const childrenPaths = getElementPathTreeChildren(subTree).filter( + (child) => !processedAccumulator.has(EP.toString(child.path)), + ) + const children: Array = mapDropNulls((child) => { + return createNavigatorSubtree( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + child, + ) + }, childrenPaths) + + return { + type: 'regular-entry', + navigatorEntry: regularNavigatorEntry(elementPath), + renderProps: renderPropChildrenAccumulator, + children: children, + elementHidden: elementHidden, + subtreeHidden: subtreeHidden, + } +} + +function walkConditionalNavigatorEntry( + metadata: ElementInstanceMetadataMap, + elementPathTrees: ElementPathTrees, + projectContents: ProjectContentTreeRoot, + propertyControlsInfo: PropertyControlsInfo, + collapsedViews: Array, + hiddenInNavigator: Array, + subTree: ElementPathTree, + jsxElement: JSXConditionalExpression, + elementPath: ElementPath, + elementHidden: boolean, + subtreeHidden: boolean, +): NavigatorTree { + const trueCase = walkConditionalClause( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + subTree, + jsxElement, + 'true-case', + ) + const falseCase = walkConditionalClause( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + subTree, + jsxElement, + 'false-case', + ) + + return { + type: 'conditional-entry', + navigatorEntry: regularNavigatorEntry(elementPath), + trueCase: trueCase, + falseCase: falseCase, + elementHidden: elementHidden, + subtreeHidden: subtreeHidden, + } +} + +function walkConditionalClause( + metadata: ElementInstanceMetadataMap, + elementPathTrees: ElementPathTrees, + projectContents: ProjectContentTreeRoot, + propertyControlsInfo: PropertyControlsInfo, + collapsedViews: Array, + hiddenInNavigator: Array, + conditionalSubTree: ElementPathTree, + conditional: JSXConditionalExpression, + conditionalCase: ConditionalCase, +): Array { + const isDynamic = (elementPath: ElementPath) => { + return ( + MetadataUtils.isElementOrAncestorGenerated(elementPath) || + MetadataUtils.isGeneratedTextFromMetadata(elementPath, elementPathTrees, metadata) + ) + } + + const path = conditionalSubTree.path + const clauseValue = conditionalCase === 'true-case' ? conditional.whenTrue : conditional.whenFalse + + // Get the clause path. + const clausePath = getConditionalClausePath(path, clauseValue) + + const branch = conditionalCase === 'true-case' ? conditional.whenTrue : conditional.whenFalse + + // Walk the clause of the conditional. + const clausePathTrees = getElementPathTreeChildren(conditionalSubTree).filter((childPath) => { + if (isDynamic(childPath.path) && hasElementsWithin(branch)) { + for (const element of Object.values(branch.elementsWithin)) { + const firstChildPath = EP.appendToPath(EP.parentPath(clausePath), element.uid) + const containedElement = Object.values(metadata).find(({ elementPath }) => { + return EP.pathsEqual(EP.dynamicPathToStaticPath(elementPath), firstChildPath) + }) + if (containedElement != null) { + return true + } + } + } + return EP.pathsEqual(childPath.path, clausePath) + }) + + // if we find regular tree entries for the clause, it means the branch has proper JSXElements, so we recurse into the tree building + if (clausePathTrees.length > 0) { + const children = mapDropNulls((child) => { + return createNavigatorSubtree( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + child, + ) + }, clausePathTrees) + return children + } + + // No children were found in the ElementPathTrees, so we create a synthetic entry for the value of the clause. + return [ + { + type: 'leaf-entry', + elementHidden: false, + navigatorEntry: syntheticNavigatorEntry(clausePath, clauseValue), + }, + ] +} + +function walkMapExpression( + metadata: ElementInstanceMetadataMap, + elementPathTrees: ElementPathTrees, + projectContents: ProjectContentTreeRoot, + propertyControlsInfo: PropertyControlsInfo, + collapsedViews: Array, + hiddenInNavigator: Array, + subTree: ElementPathTree, + element: JSXMapExpression, + elementHidden: boolean, + subtreeHidden: boolean, +): NavigatorTree { + const commentFlag = findUtopiaCommentFlag(element.comments, 'map-count') + + const mapCountOverride = isUtopiaPropOrCommentFlagMapCount(commentFlag) ? commentFlag.value : null + const mappedChildren = getElementPathTreeChildren(subTree).map((child) => + createNavigatorSubtree( + metadata, + elementPathTrees, + projectContents, + propertyControlsInfo, + collapsedViews, + hiddenInNavigator, + child, + ), + ) + + const invaldiOverrideEntries = (() => { + if (mapCountOverride == null) { + return [] + } + + let invalidEntries: Array = [] + for (let i = getElementPathTreeChildren(subTree).length; i < mapCountOverride; i++) { + const entry = invalidOverrideNavigatorEntry( + EP.appendToPath(subTree.path, `invalid-override-${i + 1}`), + 'data source not found', + ) + invalidEntries.push({ type: 'leaf-entry', elementHidden: false, navigatorEntry: entry }) + } + return invalidEntries + })() + + return { + type: 'map-entry', + navigatorEntry: regularNavigatorEntry(subTree.path), + mappedEntries: dropNulls([...mappedChildren, ...invaldiOverrideEntries]), + elementHidden: elementHidden, + subtreeHidden: subtreeHidden, + } +} + +function isCondensableLeafEntry(entry: NavigatorTree): boolean { + // for now filter only for Data Entries + return ( + entry.navigatorEntry.type === 'DATA_REFERENCE' && + // the entry is either a dedicated leaf entry + (entry.type === 'leaf-entry' || + // or regular entry but has no children and no render props + (entry.type === 'regular-entry' && + entry.children.length === 0 && + Object.values(entry.renderProps).length === 0)) + ) +} + +function condenseNavigatorTree( + metadata: ElementInstanceMetadataMap, + navigatorTree: Array, +): Array { + if (!isFeatureEnabled('Condensed Navigator Entries')) { + return navigatorTree + } + + function walkSubtreeMaybeCondense(entry: NavigatorTree): NavigatorTree { + // if the entry only has leaf children, we can turn it into a condensed leaf + if ( + entry.type === 'regular-entry' && + entry.children.length > 0 && + entry.children.every(isCondensableLeafEntry) + ) { + return { + type: 'condensed-leaf', + navigatorEntry: entry.navigatorEntry, + children: entry.children.map((c) => c.navigatorEntry), + } + } + + // if the entry only has a single child, we can condense it + if ( + entry.type === 'regular-entry' && + entry.children.length === 1 && + dataCanCondenseFromMetadata(metadata, entry.navigatorEntry.elementPath) && + !MetadataUtils.isProbablyScene(metadata, entry.navigatorEntry.elementPath) && + !MetadataUtils.isProbablyRemixScene(metadata, entry.navigatorEntry.elementPath) + ) { + return { + type: 'condensed-trunk', + navigatorEntry: entry.navigatorEntry, + elementHidden: entry.elementHidden, + subtreeHidden: entry.subtreeHidden, + child: walkSubtreeMaybeCondense(entry.children[0]), + } + } + + // we need to recurse into the subtrees here! + switch (entry.type) { + case 'regular-entry': { + return { + type: 'regular-entry', + navigatorEntry: entry.navigatorEntry, + renderProps: objectMap( + (renderPropChild) => walkSubtreeMaybeCondense(renderPropChild), + entry.renderProps, + ), + children: entry.children.map(walkSubtreeMaybeCondense), + elementHidden: entry.elementHidden, + subtreeHidden: entry.subtreeHidden, + } + } + case 'leaf-entry': + return entry + case 'map-entry': { + return { + type: 'map-entry', + navigatorEntry: entry.navigatorEntry, + mappedEntries: entry.mappedEntries.map(walkSubtreeMaybeCondense), + elementHidden: entry.elementHidden, + subtreeHidden: entry.subtreeHidden, + } + } + case 'conditional-entry': { + return { + type: 'conditional-entry', + navigatorEntry: entry.navigatorEntry, + trueCase: entry.trueCase.map(walkSubtreeMaybeCondense), + falseCase: entry.falseCase.map(walkSubtreeMaybeCondense), + elementHidden: entry.elementHidden, + subtreeHidden: entry.subtreeHidden, + } + } + case 'condensed-trunk': { + return { + type: 'condensed-trunk', + elementHidden: entry.elementHidden, + subtreeHidden: entry.subtreeHidden, + navigatorEntry: entry.navigatorEntry, + child: walkSubtreeMaybeCondense(entry.child), + } + } + case 'condensed-leaf': + return entry + default: + assertNever(entry) + } + } + + return navigatorTree.map(walkSubtreeMaybeCondense) +} + +function flattenCondensedTrunk(entry: CondensedTrunkNavigatorTree): { + singleRow: Array + child: NavigatorTree +} { + const singleRow = [entry.navigatorEntry] + let currentChild: NavigatorTree = entry.child + while (currentChild.type === 'condensed-trunk') { + singleRow.push(currentChild.navigatorEntry) + currentChild = currentChild.child + } + return { singleRow: singleRow, child: currentChild } +} + +function getNavigatorRowsForTree( + metadata: ElementInstanceMetadataMap, + navigatorTree: Array, + filterVisible: 'all-navigator-targets' | 'visible-navigator-targets', +): Array { + const condensedTree = condenseNavigatorTree(metadata, navigatorTree) + + function walkTree(entry: NavigatorTree, indentation: number): Array { + function walkIfSubtreeVisible(e: NavigatorTree, i: number): Array { + if (isSubtreeHidden(entry) && filterVisible === 'visible-navigator-targets') { + return [] + } + return walkTree(e, i) + } + + function renderPropRows( + tree: RegularNavigatorTree, + path: ElementPath, + nextIndentation: number, + ): NavigatorRow[] { + return Object.entries(tree.renderProps).flatMap(([propName, renderPropChild]) => { + const fakeRenderPropPath = EP.appendToPath(path, renderPropId(propName)) + let rows: NavigatorRow[] = [] + if (!isSubtreeHidden(tree)) { + rows.push( + regularNavigatorRow( + renderPropNavigatorEntry( + fakeRenderPropPath, + propName, + renderPropChild.navigatorEntry.elementPath, + ), + nextIndentation, + ), + ) + } + rows.push(...walkIfSubtreeVisible(renderPropChild, nextIndentation + 1)) + return rows + }) + } + + function shouldShowChildrenLabel(tree: RegularNavigatorTree): boolean { + return ( + Object.values(tree.renderProps).length > 0 && + tree.children.length > 0 && + !isSubtreeHidden(tree) + ) + } + + function childrenLabelRow(tree: RegularNavigatorTree, path: ElementPath): RegularNavigatorRow { + // we only show the children label if there are render props + return regularNavigatorRow( + renderPropNavigatorEntry( + EP.appendToPath(path, renderPropId('children')), + 'children', + tree.children[0].navigatorEntry.elementPath, // pick the first child path + ), + nextIndentation, + ) + } + + if (isElementHidden(entry) && filterVisible === 'visible-navigator-targets') { + return [] + } + + const nextIndentation = + MetadataUtils.isProbablyScene(metadata, entry.navigatorEntry.elementPath) || + MetadataUtils.isProbablyRemixScene(metadata, entry.navigatorEntry.elementPath) + ? indentation // scenes live on a dedicated row, on the same indent as their children + : indentation + 1 // for everything else, go one level deeper + + switch (entry.type) { + case 'condensed-trunk': { + const { singleRow, child } = flattenCondensedTrunk(entry) + if (singleRow.length === 1) { + return [ + regularNavigatorRow(singleRow[0], indentation), + ...walkIfSubtreeVisible(child, nextIndentation), + ] + } + return [ + condensedNavigatorRow(singleRow, 'trunk', indentation), + ...walkIfSubtreeVisible(child, nextIndentation), + ] + } + case 'condensed-leaf': + return [ + condensedNavigatorRow( + [ + entry.navigatorEntry, // maybe we want to separate this to two rows, one for the label and one for the children + ...entry.children, + ], + 'leaf', + indentation, + ), + ] + case 'regular-entry': { + const path = entry.navigatorEntry.elementPath + const showChildrenLabel = shouldShowChildrenLabel(entry) + return [ + regularNavigatorRow(entry.navigatorEntry, indentation), + ...renderPropRows(entry, path, nextIndentation), + ...(showChildrenLabel ? [childrenLabelRow(entry, path)] : []), + ...entry.children.flatMap((t) => + walkIfSubtreeVisible(t, showChildrenLabel ? nextIndentation + 1 : nextIndentation), + ), + ] + } + case 'leaf-entry': + return [regularNavigatorRow(entry.navigatorEntry, indentation)] + case 'map-entry': + return [ + regularNavigatorRow(entry.navigatorEntry, indentation), + ...entry.mappedEntries.flatMap((t) => walkIfSubtreeVisible(t, nextIndentation)), + ] + case 'conditional-entry': + return dropNulls([ + regularNavigatorRow(entry.navigatorEntry, indentation), + entry.subtreeHidden + ? null + : regularNavigatorRow( + conditionalClauseNavigatorEntry(entry.navigatorEntry.elementPath, 'true-case'), + nextIndentation, + ), + ...entry.trueCase.flatMap((t) => walkIfSubtreeVisible(t, nextIndentation + 1)), + entry.subtreeHidden + ? null + : regularNavigatorRow( + conditionalClauseNavigatorEntry(entry.navigatorEntry.elementPath, 'false-case'), + nextIndentation, + ), + ...entry.falseCase.flatMap((t) => walkIfSubtreeVisible(t, nextIndentation + 1)), + ]) + default: + assertNever(entry) + } + } + + return condensedTree.flatMap((t) => walkTree(t, 0)) +} + +export function getNavigatorTargetsFromEditorState( + editorState: EditorState, +): GetNavigatorTargetsResults { + return navigatorTargetsSelector({ editor: editorState }) +} + +export function useGetNavigatorTargets(): GetNavigatorTargetsResults { + return useEditorState( + Substores.navigatorTargetsSubstate, + navigatorTargetsSelector, + 'useGetNavigatorTargets', + ) +} + +export const navigatorTargetsSelector = createSelector( + (state: NavigatorTargetsSubstate) => state.editor.jsxMetadata, + (state: NavigatorTargetsSubstate) => state.editor.elementPathTree, + (state: NavigatorTargetsSubstate) => state.editor.navigator.collapsedViews, + (state: NavigatorTargetsSubstate) => state.editor.navigator.hiddenInNavigator, + (state: NavigatorTargetsSubstate) => state.editor.propertyControlsInfo, + (state: NavigatorTargetsSubstate) => state.editor.projectContents, + ( + jsxMetadata, + elementPathTree, + collapsedViews, + hiddenInNavigator, + propertyControlsInfo, + projectContents, + ) => + getNavigatorTargets( + jsxMetadata, + elementPathTree, + collapsedViews, + hiddenInNavigator, + propertyControlsInfo, + projectContents, + ), +) + +export const navigatorTargetsSelectorNavigatorTargets = createSelector( + navigatorTargetsSelector, + (results) => results.navigatorTargets, +) + +export const navigatorTargetsSelectorVisibleNavigatorTargets = createSelector( + navigatorTargetsSelector, + (results) => results.visibleNavigatorTargets, +) + +function getNavigatorTargets( + metadata: ElementInstanceMetadataMap, + elementPathTree: ElementPathTrees, + collapsedViews: Array, + hiddenInNavigator: Array, + propertyControlsInfo: PropertyControlsInfo, + projectContents: ProjectContentTreeRoot, +): GetNavigatorTargetsResults { + const navigatorTrees = getNavigatorTrees( + metadata, + elementPathTree, + collapsedViews, + hiddenInNavigator, + propertyControlsInfo, + projectContents, + ) + + const navigatorRows = getNavigatorRowsForTree(metadata, navigatorTrees, 'all-navigator-targets') + const navigatorTargets = navigatorRows.flatMap(getEntriesForRow) + + const visibleNavigatorRows = getNavigatorRowsForTree( + metadata, + navigatorTrees, + 'visible-navigator-targets', + ) + const filteredVisibleNavigatorRows = visibleNavigatorRows + const visibleNavigatorTargets = filteredVisibleNavigatorRows.flatMap(getEntriesForRow) + + return { + navigatorRows: filteredVisibleNavigatorRows, + navigatorTargets: navigatorTargets, + visibleNavigatorTargets: visibleNavigatorTargets, + } +} + +export function getConditionalClausePathForNavigatorEntry( + navigatorEntry: ConditionalClauseNavigatorEntry, + elementMetadata: ElementInstanceMetadata, +): ElementPath | null { + if (elementMetadata == null) { + return null + } + const element = elementMetadata.element + if (isLeft(element)) { + return null + } + const jsxElement = element.value + if (!isJSXConditionalExpression(jsxElement)) { + return null + } + const clauseElement = + navigatorEntry.clause === 'true-case' ? jsxElement.whenTrue : jsxElement.whenFalse + return getConditionalClausePath(navigatorEntry.elementPath, clauseElement) +} + +function renderPropId(propName: string): string { + return `prop-label-${propName}` +} diff --git a/nexus-builder/packages/navigator/src/navigator/navigator.stories.tsx b/nexus-builder/packages/navigator/src/navigator/navigator.stories.tsx new file mode 100644 index 000000000000..def5206b903f --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { NavigatorComponent } from './navigator'; // Assuming this is the main component + +const meta = { + title: 'Builder/Navigator', + component: NavigatorComponent, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + // We will need to mock the props here + }, +}; diff --git a/nexus-builder/packages/navigator/src/navigator/navigator.tsx b/nexus-builder/packages/navigator/src/navigator/navigator.tsx new file mode 100644 index 000000000000..a26bf6753597 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/navigator.tsx @@ -0,0 +1,361 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import type { Size } from 'react-virtualized-auto-sizer' +import AutoSizer from 'react-virtualized-auto-sizer' +import type { ListChildComponentProps } from 'react-window' +import { VariableSizeList } from 'react-window' +import { last, safeIndex } from '@builder/core' +import * as EP from '@builder/core' +import type { ElementPath } from '../../core/shared/project-file-types' +import { getSelectedNavigatorEntries } from '@builder/core' +import { useKeepReferenceEqualityIfPossible } from '@builder/core' +import Utils from '@builder/core' +import { FlexColumn, Section, SectionBodyArea, UtopiaTheme } from '../uuiui' +import { setFocus } from '@builder/core' +import { + clearHighlightedViews, + clearSelection, + showContextMenu, +} from '../editor/actions/action-creators' +import { useDispatch } from '../editor/store/dispatch-context' +import type { EditorStorePatched, NavigatorEntry } from '../editor/store/editor-state' +import { + isRegularNavigatorEntry, + navigatorEntryToKey, + navigatorEntriesEqual, + navigatorRowToKey, +} from '../editor/store/editor-state' +import { Substores, useEditorState, useRefEditorState } from '../editor/store/store-hook' +import { ElementContextMenu } from '../element-context-menu' +import { getItemHeight } from './navigator-item/navigator-item' +import { NavigatorDragLayer } from './navigator-drag-layer' +import { NavigatorItemWrapper } from './navigator-item/navigator-item-wrapper' +import type { CondensedNavigatorRow, NavigatorRow, RegularNavigatorRow } from './navigator-row' +import { getEntriesForRow } from './navigator-row' +import { assertNever } from '../../core/shared/utils' +import { + navigatorTargetsSelector, + navigatorTargetsSelectorVisibleNavigatorTargets, +} from './navigator-utils' +import { createSelector } from 'reselect' +import createCachedSelector from 're-reselect' +import type { + NavigatorTargetsSubstate, + SelectedViewsSubstate, +} from '../editor/store/store-hook-substore-types' + +interface ItemProps extends ListChildComponentProps {} + +const currentlySelectedNavigatorEntriesSelector = createSelector( + navigatorTargetsSelector, + (store: EditorStorePatched) => store.editor.selectedViews, + (navigatorTargets, selectedViews) => { + return getSelectedNavigatorEntries(selectedViews, navigatorTargets.navigatorTargets) + }, +) + +const targetEntrySelector = createCachedSelector( + navigatorTargetsSelector, + (_: NavigatorTargetsSubstate, index: number) => index, + (navigatorTargets, index) => navigatorTargets.navigatorRows[index], +)((_, index) => index) + +const Item = React.memo(({ index, style }: ItemProps) => { + const targetEntry = useEditorState( + Substores.navigatorTargetsSubstate, + (store) => targetEntrySelector(store, index), + 'Item navigatorRows', + ) + + const editorSliceRef = useRefEditorState((store) => { + return { + selectedViews: store.editor.selectedViews, + } + }) + + const navigatorTargetsRef = useRefEditorState(navigatorTargetsSelector) + const currentlySelectedNavigatorEntriesRef = useRefEditorState( + currentlySelectedNavigatorEntriesSelector, + ) + + const getCurrentlySelectedNavigatorEntries = React.useCallback((): Array => { + return currentlySelectedNavigatorEntriesRef.current + }, [currentlySelectedNavigatorEntriesRef]) + + const visibleTargetIndexToRegularIndex = React.useCallback( + (visibleTargetIndex: number) => { + const visibleNavigatorEntry = + navigatorTargetsRef.current.visibleNavigatorTargets[visibleTargetIndex] + if (visibleNavigatorEntry == null) { + return null + } else { + const targetIndex = navigatorTargetsRef.current.navigatorTargets.findIndex((target) => + navigatorEntriesEqual(target, visibleNavigatorEntry), + ) + if (targetIndex >= 0) { + return targetIndex + } else { + return null + } + } + }, + [navigatorTargetsRef], + ) + + // Used to determine the views that will be selected by starting with the last selected item + // and selecting everything from there to `targetIndex`. + const getSelectedViewsInRange = React.useCallback( + (visibleTargetIndex: number): Array => { + const selectedItemIndexes = editorSliceRef.current.selectedViews + .map((selection) => + navigatorTargetsRef.current.navigatorTargets.findIndex( + (entry) => + isRegularNavigatorEntry(entry) && EP.pathsEqual(entry.elementPath, selection), + ), + ) + .sort((a, b) => a - b) + // As we're primarily operating on visible navigator targets, we need to convert the index. + const targetIndex = visibleTargetIndexToRegularIndex(visibleTargetIndex) + if (targetIndex == null) { + return [] + } + const lastSelectedItemIndex = last(selectedItemIndexes) + if (lastSelectedItemIndex == null) { + const lastSelectedItem = navigatorTargetsRef.current.navigatorTargets[targetIndex] + if (isRegularNavigatorEntry(lastSelectedItem)) { + return [lastSelectedItem.elementPath] + } else { + return [] + } + } else { + let start = 0 + let end = 0 + if (targetIndex > lastSelectedItemIndex) { + start = selectedItemIndexes[0] + end = targetIndex + } else if (targetIndex < lastSelectedItemIndex && targetIndex > selectedItemIndexes[0]) { + start = selectedItemIndexes[0] + end = targetIndex + } else { + start = targetIndex + end = lastSelectedItemIndex + } + let selectedViewTargets: Array = editorSliceRef.current.selectedViews + Utils.fastForEach(navigatorTargetsRef.current.navigatorTargets, (item, itemIndex) => { + if (itemIndex >= start && itemIndex <= end && isRegularNavigatorEntry(item)) { + selectedViewTargets = EP.addPathIfMissing(item.elementPath, selectedViewTargets) + } + }) + return selectedViewTargets + } + }, + [editorSliceRef, navigatorTargetsRef, visibleTargetIndexToRegularIndex], + ) + + const componentKey = navigatorRowToKey(targetEntry) + const deepKeptStyle = useKeepReferenceEqualityIfPossible(style) + + return ( + + ) +}) + +export const NavigatorContainerId = 'navigator' + +const selectionIndexSelector = createSelector( + navigatorTargetsSelector, + (store: SelectedViewsSubstate) => store.editor.selectedViews, + (navigatorTargets, selectedViews) => { + const selectionIndex = + selectedViews == null + ? -1 + : navigatorTargets.navigatorRows.findIndex((entry) => { + return getEntriesForRow(entry).some( + (e) => isRegularNavigatorEntry(e) && EP.pathsEqual(e.elementPath, selectedViews[0]), + ) + }) + return selectionIndex + }, +) + +export const NavigatorComponent = React.memo(() => { + const dispatch = useDispatch() + const minimised = useEditorState( + Substores.navigator, + (store) => { + return store.editor.navigator.minimised + }, + 'NavigatorComponent navigator.minimised', + ) + const selectionIndex = useEditorState( + Substores.fullStore, + selectionIndexSelector, + 'NavigatorComponent selectionIndexSelector', + ) + + const { navigatorRows } = useEditorState( + Substores.navigatorTargetsSubstate, + navigatorTargetsSelector, + 'NavigatorComponent navigatorTargetsSelector', + ) + + const itemListRef = React.createRef() + + React.useEffect(() => { + if (selectionIndex >= 0) { + itemListRef.current?.scrollToItem(selectionIndex, 'smart') + } + }, [selectionIndex, itemListRef]) + + React.useEffect(() => { + /** + * VariableSizeList caches the item sizes returned by itemSize={getItemSize} + * When a reorder happens, the items are offset, and the cached sizes are not applied to the right items anymore + * resetAfterIndex(0, false) clears the cached size of all items, and false means it does not force a re-render + * + * as a first approximation, this useEffect runs on any change to navigatorRows + */ + itemListRef.current?.resetAfterIndex(0, false) + }, [navigatorRows, itemListRef]) + + const onFocus = React.useCallback( + (e: React.FocusEvent) => { + dispatch([setFocus('navigator')]) + }, + [dispatch], + ) + + const onMouseLeave = React.useCallback( + (e: React.MouseEvent) => { + dispatch([clearHighlightedViews()], 'everyone') + }, + [dispatch], + ) + + const onContextMenu = React.useCallback( + (event: React.MouseEvent) => { + dispatch([showContextMenu('context-menu-navigator', event.nativeEvent)], 'everyone') + }, + [dispatch], + ) + + const getItemSize = React.useCallback( + (entryIndex: number) => { + const navigatorRow = safeIndex(navigatorRows, entryIndex) + if (navigatorRow == null) { + throw new Error(`Could not find navigator entry at index ${entryIndex}`) + } + if (navigatorRow.type === 'condensed-row') { + return UtopiaTheme.layout.rowHeight.smaller + } + if (navigatorRow.type === 'regular-row') { + return getItemHeight(navigatorRow.entry) + } + assertNever(navigatorRow) + }, + [navigatorRows], + ) + + const ItemList = (size: Size) => { + if (size.height == null) { + return null + } else { + return ( + + {Item} + + ) + } + } + + const onMouseDown = React.useCallback( + (mouseEvent: React.MouseEvent) => { + mouseEvent.stopPropagation() + // Ensure this is a left click. + if (mouseEvent.button === 0) { + dispatch([clearSelection()]) + } + }, + [dispatch], + ) + + return ( +
+ + + + + + {ItemList} + + + +
+ ) +}) +NavigatorComponent.displayName = 'NavigatorComponent' diff --git a/nexus-builder/packages/navigator/src/navigator/top1000NPMPackagesOptions.ts b/nexus-builder/packages/navigator/src/navigator/top1000NPMPackagesOptions.ts new file mode 100644 index 000000000000..3db5f4ddc475 --- /dev/null +++ b/nexus-builder/packages/navigator/src/navigator/top1000NPMPackagesOptions.ts @@ -0,0 +1,1401 @@ +const designRelevantPackages = [ + { + value: 'react-spring', + label: 'react-spring', + contents: [ + 'useSpring', + 'useSprings', + 'animated', + 'interpolate', + 'useTrail', + 'useTransition', + 'useChain', + ], + }, + { value: 'react-use-gesture', label: 'react-use-gesture', contents: ['useGesture'] }, + { + value: '@emotion/react', + label: '@emotion/react', + contents: ['css', 'jsx', 'Global', 'Classnames'], + }, + { value: '@emotion/styled', label: '@emotion/styled', contents: ['styled'] }, + { value: '@emotion/css', label: '@emotion/css', contents: ['css'] }, + { value: 'styled-components', label: 'styled-components', contents: ['styled', 'css'] }, + { value: 'leva', label: 'leva', contents: ['useControls'] }, + { value: '@react-three/fiber', label: '@react-three/fiber' }, + { value: '@react-three/drei', label: '@react-three/drei' }, + { + value: '@react-three/cannon', + label: '@react-three/cannon', + contents: [ + 'Physics', + 'useBox', + 'usePlane', + 'useCylinder', + 'useHeightfield', + 'useParticle', + 'useSphere', + 'useTrimesh', + 'useConvexPolyhedron', + 'useCompoundBody', + 'usePointToPointConstraint', + 'useConeTwistConstraint', + 'useSpring', + ], + }, + { value: 'react-use-measure', label: 'react-use-measure', contents: ['useMeasure'] }, + { value: 'react-laag', label: 'react-laag', contents: ['ToggleLayer'] }, + { value: 'react-router', label: 'react-router' }, + { value: 'tinycolor2', label: 'tinycolor2' }, + { value: 'chroma-js', label: 'chroma-js' }, + { value: 'colors', label: 'colors' }, + { value: 'color', label: 'color' }, + { value: 'd3', label: 'd3' }, + { value: 'font-awesome', label: 'font-awesome' }, +] + +export const stateManagementPackages = [ + { value: 'zustand', label: 'zustand', contents: ['create'] }, + { + value: 'immer', + label: 'immer', + contents: ['RecoilRoot', 'atom', 'selector', 'useRecoilState', 'useRecoilValue'], + }, + { + value: 'recoil', + label: 'recoil (experimental)', + contents: ['RecoilRoot', 'atom', 'selector', 'useRecoilState', 'useRecoilValue'], + }, + { + value: 'valtio', + label: 'valtio', + contents: [ + 'proxy', + 'useSnapshot', + 'subscribe', + 'ref', + 'snapshot', + 'addComputed', + 'proxyWithComputed', + ], + }, + { value: 'redux', label: 'redux' }, + { value: 'react-redux', label: 'react-redux' }, + { value: 'reselect', label: 'reselect' }, + { value: 'rxjs', label: 'rxjs' }, + { value: 'mobx', label: 'mobx' }, + { value: 'react-router-redux', label: 'react-router-redux' }, + { value: 'redux-saga', label: 'redux-saga' }, + { value: 'flux', label: 'flux' }, +] + +export const componentLibraryPackages = [ + { value: 'antd', label: 'antd', website: 'https://ant.design/', contents: ['DatePicker'] }, + { + value: 'semantic-ui', + label: 'semantic-ui', + website: 'https://react.semantic-ui.com/usage', + }, + { + value: 'fluentui', + label: 'Fluent UI', + website: 'https://developer.microsoft.com/en-us/fluentui#/', + github: 'https://github.com/microsoft/fluentui', + }, + + { + value: 'blueprintjs', + label: 'blueprintjs', + website: 'https://blueprintjs.com/', + github: 'https://github.com/palantir/blueprint', + }, + { + value: 'evergreen', + label: 'evergreen', + github: 'https://github.com/segmentio/evergreen', + }, + { + value: 'chakra-ui', + label: 'chakra-ui', + website: 'https://chakra-ui.com/', + github: 'https://github.com/chakra-ui/chakra-ui/', + }, + { + value: 'rebass', + label: 'rebass', + github: 'https://github.com/rebassjs/rebass', + website: 'https://rebassjs.org/', + }, + { + value: 'grommet', + label: 'grommet', + website: 'https://grommet.io/', + github: 'https://github.com/grommet/grommet', + }, + { + value: 'rsuite', + label: 'rsuite', + website: 'https://rsuitejs.com/', + github: 'https://github.com/rsuite/rsuite', + }, + + { + value: 'material-kit-react', + label: 'material-kit-react', + website: 'https://github.com/creativetimofficial/material-kit-react/', + github: 'https://github.com/creativetimofficial/material-kit-react', + }, + { + value: 'react-admin', + label: 'react-admin', + website: 'https://marmelab.com/react-admin/', + github: 'https://github.com/marmelab/react-admin', + }, + { + value: 'material-ui', + label: 'material-ui', + website: 'https://material-ui.com/', + github: 'https://github.com/mui-org/material-ui/', + }, + { + value: 'react-bootstrap', + label: 'react-bootstrap', + website: 'https://react-bootstrap.github.io/getting-started/introduction', + github: 'http://github.com/mui-org/material-ui', + }, + { + value: 'react-toolbox', + label: 'react-toolbox', + github: 'https://github.com/react-toolbox/react-toolbox', + }, + { + value: 'react-onsenui', + label: 'react-onsenui', + website: 'https://onsen.io/v2/guide/react/', + }, +] + +// First 11 pages of +// https://www.npmjs.com/search?q=react&ranking=optimal&page=1&perPage=20 +export const commonReactPackages = [ + { value: 'react-select', label: 'react-select' }, + { value: 'react-draggable', label: 'react-draggable' }, + { value: 'react-popper', label: 'react-popper' }, + { value: 'react-color', label: 'react-color' }, + { value: 'react-table', label: 'react-table' }, + { value: 'react-transition-group', label: 'react-transition-group' }, + { value: 'react-toastify', label: 'react-toastify' }, + { value: 'react-ga', label: 'react-ga' }, + { value: 'rc-util', label: 'rc-util' }, + { value: 'react-router', label: 'react-router' }, + { value: 'react-is', label: 'react-is' }, + { value: 'react-tabs', label: 'react-tabs' }, + { value: 'react-chartjs', label: 'react-chartjs' }, + { value: 'react-hook-form', label: 'react-hook-form' }, + { value: 'downshift', label: 'downshift' }, + { value: 'react-textarea-autosize', label: 'react-textarea-autosize' }, + { value: 'react-measure', label: 'react-measure' }, + { value: 'react-paginate', label: 'react-paginate' }, + { value: 'react-scroll', label: 'react-scroll' }, + { value: 'react-number-format', label: 'react-number-format' }, + { value: 'react-modal', label: 'react-modal' }, + { value: 'react-moment', label: 'react-moment' }, + { value: 'react-motion', label: 'react-motion' }, + { value: 'react-final-form', label: 'react-final-form' }, + { value: 'react-datetime', label: 'react-datetime' }, + { value: 'framer-motion', label: 'framer-motion' }, + { value: 'react-resizable', label: 'react-resizable' }, + { value: 'react-datepicker', label: 'react-datepicker' }, + { value: 'react-tooltip', label: 'react-tooltip' }, + { value: 'react-intl', label: 'react-intl' }, + { value: 'react-i18next', label: 'react-i18next' }, + { value: 'react-icons', label: 'react-icons' }, + { value: 'react-sizeme', label: 'react-sizeme' }, + { value: 'react-image-crop', label: 'react-image-crop' }, + { value: 'react-content-loader', label: 'react-content-loader' }, + { value: 'react-phone-number-input', label: 'react-phone-number-input' }, + { value: 'react-pdf', label: 'react-pdf' }, + { value: 'react-beautiful-dnd', label: 'react-beautiful-dnd' }, + { value: 'react-stripe-elements', label: 'react-stripe-elements' }, + { value: '@stripe/react-stripe-js', label: '@stripe/react-stripe-js' }, + { value: 'react-rnd', label: 'react-rnd' }, + { value: 'react-copy-to-clipboard', label: 'react-copy-to-clipboard' }, + { value: 'react-navigation', label: 'react-navigation' }, + { value: '@testing-library/react-hooks', label: '@testing-library/react-hooks' }, + { value: 'react-dates', label: 'react-dates' }, + { value: 'react-fast-compare', label: 'react-fast-compare' }, + { value: 'rc-slider', label: 'rc-slider' }, + { value: 'react-slick', label: 'react-slick' }, + { value: 're-resizable', label: 're-resizable' }, + { value: 'polished', label: 'polished' }, + { value: 'rc-tooltip', label: 'rc-tooltip' }, + { value: 'react-input-autosize', label: 'react-input-autosize' }, + { value: 'react-cropper', label: 'react-cropper' }, + { value: 'react-autosuggest', label: 'react-autosuggest' }, + { value: 'formik', label: 'formik' }, + { value: 'react-player', label: 'react-player' }, + { value: 'react-parallax', label: 'react-parallax' }, + { value: 'recharts', label: 'recharts' }, + { value: '@fortawesome/react-fontawesome', label: '@fortawesome/react-fontawesome' }, + { value: 'react-timeago', label: 'react-timeago' }, + { value: 'react-slider', label: 'react-slider' }, + { value: 'react-side-effect', label: 'react-side-effect' }, + { value: 'react-calendar', label: 'react-calendar' }, + { value: 'downshift', label: 'downshift' }, + { value: 'react-scroll-up', label: 'react-scroll-up' }, + { value: 'react-window', label: 'react-window' }, + { value: 'react-swipeable', label: 'react-swipeable' }, + { value: 'react-google-login', label: 'react-google-login' }, + { value: 'react-day-picker', label: 'react-day-picker' }, + { + value: 'react-use', + label: 'react-use', + website: 'https://streamich.github.io/react-use/', + github: 'https://github.com/streamich/react-use/', + }, + { value: 'sortablejs', label: 'sortablejs' }, + { value: 'react-collapsible', label: 'react-collapsible' }, + { value: 'office-ui-fabric-react', label: 'office-ui-fabric-react' }, + { value: 'react-outside-click-handler', label: 'react-outside-click-handler' }, + { value: 'react-date-picker', label: 'react-date-picker' }, + { value: 'rc-tabs', label: 'rc-tabs' }, + { value: 'react-contenteditable', label: 'react-contenteditable' }, + { value: 'react-with-direction', label: 'react-with-direction' }, + { value: 'react-highlight', label: 'react-highlight' }, + { value: 'react-popper-tooltip', label: 'react-popper-tooltip' }, + { value: 'react-aria-menubutton', label: 'react-aria-menubutton' }, + { value: 'react-grid-layout', label: 'react-grid-layout' }, + { value: 'react-mapbox-gl', label: 'react-mapbox-gl' }, + { value: 'react-dnd', label: 'react-dnd' }, + { value: 'react-date-range', label: 'react-date-range' }, + { value: '@react-google-maps/api', label: '@react-google-maps/api' }, + { value: 'react-lazy-load-image-component', label: 'react-lazy-load-image-component' }, + { value: 'react-resize-detector', label: 'react-resize-detector' }, + { value: 'react-simple-animate', label: 'react-simple-animate' }, + { value: 'rc-tree', label: 'rc-tree' }, + { value: 'google-map-react', label: 'google-map-react' }, + { value: 'react-custom-scroll', label: 'react-custom-scroll' }, + { value: 'rc-menu', label: 'rc-menu' }, + { value: 'rc-dialog', label: 'rc-dialog' }, + { value: 'rc-select', label: 'rc-select' }, + { value: 'react-share', label: 'react-share' }, + { value: '@coreui/react', label: '@coreui/react' }, + { value: 'react-overlays', label: 'react-overlays' }, + { + value: 'ahooks', + label: 'ahooks', + website: 'https://ahooks.js.org/', + }, +] + +export const commonUtils = [ + { value: 'lodash', label: 'lodash' }, + { value: 'immutable', label: 'immutable' }, + { value: 'underscore', label: 'underscore' }, + { value: 'ramda', label: 'ramda' }, + { value: 'moment', label: 'moment' }, + { value: 'json5', label: 'json5' }, + { + value: 'react-virtualized', + label: 'React Virtualized', + website: 'https://bvaughn.github.io/react-virtualized/', + github: 'https://github.com/bvaughn/react-virtualized', + }, + { value: 'uuid', label: 'uuid' }, + { value: 'url-join', label: 'url-join' }, + { value: 'numeral', label: 'numeral' }, + { value: 'randomstring', label: 'randomstring' }, + { value: 'debounce', label: 'debounce' }, +] + +export const top1000NPMPackagesOptions = [ + ...designRelevantPackages, + ...componentLibraryPackages, + ...commonReactPackages, + ...stateManagementPackages, + ...commonUtils, + { value: 'request', label: 'request' }, + { value: 'chalk', label: 'chalk' }, + { value: 'commander', label: 'commander' }, + { value: 'async', label: 'async' }, + { value: 'react', label: 'react' }, + { value: 'debug', label: 'debug' }, + { value: 'bluebird', label: 'bluebird' }, + { value: 'fs-extra', label: 'fs-extra' }, + { value: 'react-dom', label: 'react-dom' }, + { value: 'mkdirp', label: 'mkdirp' }, + { value: 'prop-types', label: 'prop-types' }, + { value: 'glob', label: 'glob' }, + { value: 'body-parser', label: 'body-parser' }, + { value: 'yargs', label: 'yargs' }, + { value: 'minimist', label: 'minimist' }, + { value: 'yeoman-generator', label: 'yeoman-generator' }, + { value: 'through2', label: 'through2' }, + { value: 'jquery', label: 'jquery' }, + { value: 'babel-runtime', label: 'babel-runtime' }, + { value: 'vue', label: 'vue' }, + { value: 'axios', label: 'axios' }, + { value: 'inquirer', label: 'inquirer' }, + { value: 'babel-core', label: 'babel-core' }, + { value: 'q', label: 'q' }, + { value: 'cheerio', label: 'cheerio' }, + { value: 'classnames', label: 'classnames' }, + { value: 'gulp-util', label: 'gulp-util' }, + { value: 'tslib', label: 'tslib' }, + { value: 'semver', label: 'semver' }, + { value: 'babel-loader', label: 'babel-loader' }, + { value: 'rimraf', label: 'rimraf' }, + { value: 'shelljs', label: 'shelljs' }, + { value: '@angular/core', label: '@angular/core' }, + { value: 'object-assign', label: 'object-assign' }, + { value: 'gulp', label: 'gulp' }, + { value: 'core-js', label: 'core-js' }, + { value: 'zone.js', label: 'zone.js' }, + { value: '@angular/common', label: '@angular/common' }, + { value: 'winston', label: 'winston' }, + { value: 'yosay', label: 'yosay' }, + { value: 'js-yaml', label: 'js-yaml' }, + { value: 'coffee-script', label: 'coffee-script' }, + { value: 'css-loader', label: 'css-loader' }, + { value: 'babel-preset-es2015', label: 'babel-preset-es2015' }, + { value: 'eslint', label: 'eslint' }, + { value: '@angular/platform-browser', label: '@angular/platform-browser' }, + { value: 'socket.io', label: 'socket.io' }, + { value: 'ember-cli-babel', label: 'ember-cli-babel' }, + { value: '@angular/compiler', label: '@angular/compiler' }, + { value: 'handlebars', label: 'handlebars' }, + { value: 'style-loader', label: 'style-loader' }, + { value: 'mocha', label: 'mocha' }, + { value: '@angular/http', label: '@angular/http' }, + { value: '@angular/forms', label: '@angular/forms' }, + { + value: '@angular/platform-browser-dynamic', + label: '@angular/platform-browser-dynamic', + }, + { value: 'babel-polyfill', label: 'babel-polyfill' }, + { value: 'superagent', label: 'superagent' }, + { value: 'ejs', label: 'ejs' }, + { value: 'dotenv', label: 'dotenv' }, + { value: 'optimist', label: 'optimist' }, + { value: '@angular/router', label: '@angular/router' }, + { value: 'mongodb', label: 'mongodb' }, + { value: 'xml2js', label: 'xml2js' }, + { value: 'co', label: 'co' }, + { value: 'aws-sdk', label: 'aws-sdk' }, + { value: 'file-loader', label: 'file-loader' }, + { value: 'ws', label: 'ws' }, + { value: 'babel-eslint', label: 'babel-eslint' }, + { value: 'chai', label: 'chai' }, + { value: 'redis', label: 'redis' }, + { value: 'mongoose', label: 'mongoose' }, + { value: 'typescript', label: 'typescript' }, + { value: 'request-promise', label: 'request-promise' }, + { value: 'path', label: 'path' }, + + { value: 'morgan', label: 'morgan' }, + { value: 'promise', label: 'promise' }, + { value: 'ora', label: 'ora' }, + + { value: 'node-fetch', label: 'node-fetch' }, + { value: 'autoprefixer', label: 'autoprefixer' }, + { value: 'bootstrap', label: 'bootstrap' }, + { value: 'url-loader', label: 'url-loader' }, + { value: 'webpack-dev-server', label: 'webpack-dev-server' }, + { value: 'node-sass', label: 'node-sass' }, + { value: 'cookie-parser', label: 'cookie-parser' }, + { value: 'eslint-plugin-react', label: 'eslint-plugin-react' }, + { value: 'extend', label: 'extend' }, + { value: 'eslint-plugin-import', label: 'eslint-plugin-import' }, + { value: 'babel-preset-react', label: 'babel-preset-react' }, + { value: 'mime', label: 'mime' }, + { value: 'node-uuid', label: 'node-uuid' }, + { value: 'html-webpack-plugin', label: 'html-webpack-plugin' }, + { value: 'chokidar', label: 'chokidar' }, + { value: 'marked', label: 'marked' }, + { value: 'postcss', label: 'postcss' }, + { + value: 'extract-text-webpack-plugin', + label: 'extract-text-webpack-plugin', + }, + { value: 'less', label: 'less' }, + { value: 'uglify-js', label: 'uglify-js' }, + { value: 'meow', label: 'meow' }, + { value: 'postcss-loader', label: 'postcss-loader' }, + { value: '@types/node', label: '@types/node' }, + { value: '@angular/animations', label: '@angular/animations' }, + { value: 'jade', label: 'jade' }, + { value: 'mysql', label: 'mysql' }, + { value: 'loader-utils', label: 'loader-utils' }, + { value: 'fs', label: 'fs' }, + + { value: 'babel-preset-env', label: 'babel-preset-env' }, + { value: 'minimatch', label: 'minimatch' }, + { value: 'es6-promise', label: 'es6-promise' }, + { value: 'qs', label: 'qs' }, + { value: 'whatwg-fetch', label: 'whatwg-fetch' }, + { value: 'joi', label: 'joi' }, + { value: 'underscore.string', label: 'underscore.string' }, + { value: 'babel-cli', label: 'babel-cli' }, + { value: 'prompt', label: 'prompt' }, + + { value: 'npm', label: 'npm' }, + { value: 'angular', label: 'angular' }, + { value: 'vue-router', label: 'vue-router' }, + { value: 'socket.io-client', label: 'socket.io-client' }, + { value: 'browserify', label: 'browserify' }, + { value: 'jsonwebtoken', label: 'jsonwebtoken' }, + { value: 'gulp-rename', label: 'gulp-rename' }, + { value: 'eslint-plugin-jsx-a11y', label: 'eslint-plugin-jsx-a11y' }, + { value: 'jest', label: 'jest' }, + { value: 'reflect-metadata', label: 'reflect-metadata' }, + { value: 'del', label: 'del' }, + { value: 'sass-loader', label: 'sass-loader' }, + { value: 'update-notifier', label: 'update-notifier' }, + { value: 'resolve', label: 'resolve' }, + { value: 'nan', label: 'nan' }, + { value: 'xtend', label: 'xtend' }, + { value: 'compression', label: 'compression' }, + { value: 'cross-spawn', label: 'cross-spawn' }, + { value: 'isomorphic-fetch', label: 'isomorphic-fetch' }, + { value: 'koa', label: 'koa' }, + { value: 'grunt', label: 'grunt' }, + { value: 'cors', label: 'cors' }, + { value: 'jsdom', label: 'jsdom' }, + { value: 'ember-cli-htmlbars', label: 'ember-cli-htmlbars' }, + { value: 'react-router-dom', label: 'react-router-dom' }, + { value: 'babel-preset-stage-0', label: 'babel-preset-stage-0' }, + { value: 'express-session', label: 'express-session' }, + { value: 'through', label: 'through' }, + { + value: 'babel-plugin-transform-runtime', + label: 'babel-plugin-transform-runtime', + }, + { value: 'validator', label: 'validator' }, + { value: 'opn', label: 'opn' }, + { value: 'nodemailer', label: 'nodemailer' }, + { value: 'connect', label: 'connect' }, + { value: 'eslint-loader', label: 'eslint-loader' }, + { value: 'babel-jest', label: 'babel-jest' }, + + { value: 'inherits', label: 'inherits' }, + { value: 'passport', label: 'passport' }, + { value: 'react-dev-utils', label: 'react-dev-utils' }, + { value: 'redux-thunk', label: 'redux-thunk' }, + { value: 'pg', label: 'pg' }, + { value: 'gulp-uglify', label: 'gulp-uglify' }, + { value: 'eslint-plugin-flowtype', label: 'eslint-plugin-flowtype' }, + { value: 'invariant', label: 'invariant' }, + { value: 'deepmerge', label: 'deepmerge' }, + { + value: 'case-sensitive-paths-webpack-plugin', + label: 'case-sensitive-paths-webpack-plugin', + }, + { value: 'serve-favicon', label: 'serve-favicon' }, + { value: 'readable-stream', label: 'readable-stream' }, + { value: 'open', label: 'open' }, + { value: 'iconv-lite', label: 'iconv-lite' }, + + { value: 'got', label: 'got' }, + { value: 'progress', label: 'progress' }, + + { value: 'mustache', label: 'mustache' }, + { value: 'bunyan', label: 'bunyan' }, + { value: 'md5', label: 'md5' }, + { value: 'tmp', label: 'tmp' }, + { value: 'webpack-manifest-plugin', label: 'webpack-manifest-plugin' }, + { value: 'babel-register', label: 'babel-register' }, + { value: 'highlight.js', label: 'highlight.js' }, + { value: 'source-map-support', label: 'source-map-support' }, + { value: 'log4js', label: 'log4js' }, + { value: 'cli-table', label: 'cli-table' }, + { value: 'ncp', label: 'ncp' }, + { value: '@babel/runtime', label: '@babel/runtime' }, + { value: 'jsonfile', label: 'jsonfile' }, + { value: 'esprima', label: 'esprima' }, + { value: 'concat-stream', label: 'concat-stream' }, + { value: 'globby', label: 'globby' }, + { value: 'less-loader', label: 'less-loader' }, + { value: 'path-to-regexp', label: 'path-to-regexp' }, + { value: 'stylus', label: 'stylus' }, + { value: 'event-stream', label: 'event-stream' }, + { value: 'babel-preset-react-app', label: 'babel-preset-react-app' }, + { value: 'raf', label: 'raf' }, + { value: 'lru-cache', label: 'lru-cache' }, + { value: '@babel/core', label: '@babel/core' }, + { value: 'postcss-flexbugs-fixes', label: 'postcss-flexbugs-fixes' }, + { value: 'clone', label: 'clone' }, + { value: 'serve-static', label: 'serve-static' }, + { value: 'gulp-concat', label: 'gulp-concat' }, + { value: 'query-string', label: 'query-string' }, + { + value: 'babel-plugin-transform-object-rest-spread', + label: 'babel-plugin-transform-object-rest-spread', + }, + { value: 'graceful-fs', label: 'graceful-fs' }, + { value: 'koa-router', label: 'koa-router' }, + { value: 'crypto', label: 'crypto' }, + { value: 'archiver', label: 'archiver' }, + { value: 'url', label: 'url' }, + { value: 'bindings', label: 'bindings' }, + { value: 'eslint-config-react-app', label: 'eslint-config-react-app' }, + { value: 'cli-color', label: 'cli-color' }, + { value: 'history', label: 'history' }, + { value: 'lodash.merge', label: 'lodash.merge' }, + { value: 'babel', label: 'babel' }, + { value: 'crypto-js', label: 'crypto-js' }, + { value: 'querystring', label: 'querystring' }, + { value: 'http-proxy', label: 'http-proxy' }, + { value: 'config', label: 'config' }, + { value: 'moment-timezone', label: 'moment-timezone' }, + { value: 'pug', label: 'pug' }, + { value: 'json-loader', label: 'json-loader' }, + { value: 'merge', label: 'merge' }, + { value: 'graphql', label: 'graphql' }, + { value: 'ajv', label: 'ajv' }, + { value: 'source-map', label: 'source-map' }, + { value: 'readline-sync', label: 'readline-sync' }, + { value: 'execa', label: 'execa' }, + { value: 'gulp-sourcemaps', label: 'gulp-sourcemaps' }, + { value: 'download-git-repo', label: 'download-git-repo' }, + { value: 'pluralize', label: 'pluralize' }, + { value: 'shortid', label: 'shortid' }, + { value: 'sequelize', label: 'sequelize' }, + { value: 'request-promise-native', label: 'request-promise-native' }, + { value: 'vuex', label: 'vuex' }, + { value: 'browser-sync', label: 'browser-sync' }, + { value: 'when', label: 'when' }, + { value: 'dateformat', label: 'dateformat' }, + { value: 'gulp-sass', label: 'gulp-sass' }, + { + value: 'babel-plugin-transform-class-properties', + label: 'babel-plugin-transform-class-properties', + }, + { value: 'events', label: 'events' }, + { value: 'sqlite3', label: 'sqlite3' }, + { value: 'mime-types', label: 'mime-types' }, + { value: 'js-beautify', label: 'js-beautify' }, + { value: 'nconf', label: 'nconf' }, + { value: 'sw-precache-webpack-plugin', label: 'sw-precache-webpack-plugin' }, + { value: 'sinon', label: 'sinon' }, + { value: 'multer', label: 'multer' }, + { value: 'hoek', label: 'hoek' }, + { value: 'which', label: 'which' }, + { value: 'boom', label: 'boom' }, + { value: 'eslint-config-airbnb', label: 'eslint-config-airbnb' }, + { value: 'figlet', label: 'figlet' }, + { value: 'deep-equal', label: 'deep-equal' }, + { value: 'vinyl', label: 'vinyl' }, + { value: 'ip', label: 'ip' }, + { value: 'fsevents', label: 'fsevents' }, + { value: 'clean-css', label: 'clean-css' }, + { + value: 'babel-plugin-transform-decorators-legacy', + label: 'babel-plugin-transform-decorators-legacy', + }, + { value: 'webpack-dev-middleware', label: 'webpack-dev-middleware' }, + { value: 'temp', label: 'temp' }, + { value: 'util', label: 'util' }, + { value: 'xmldom', label: 'xmldom' }, + { value: 'normalize.css', label: 'normalize.css' }, + { value: 'html-minifier', label: 'html-minifier' }, + { value: 'pify', label: 'pify' }, + { value: 'react-native', label: 'react-native' }, + { value: 'backbone', label: 'backbone' }, + { value: 'tar', label: 'tar' }, + { value: 'eventemitter3', label: 'eventemitter3' }, + { value: 'prettier', label: 'prettier' }, + { value: 'nopt', label: 'nopt' }, + { value: 'warning', label: 'warning' }, + { value: 'webpack-merge', label: 'webpack-merge' }, + { value: 'ini', label: 'ini' }, + { value: 'form-data', label: 'form-data' }, + { value: 'mz', label: 'mz' }, + { value: '@types/lodash', label: '@types/lodash' }, + { value: 'react-hot-loader', label: 'react-hot-loader' }, + { value: 'escodegen', label: 'escodegen' }, + { value: 'webpack-hot-middleware', label: 'webpack-hot-middleware' }, + + { value: 'strip-ansi', label: 'strip-ansi' }, + { value: 'markdown-it', label: 'markdown-it' }, + { value: 'react-scripts', label: 'react-scripts' }, + { value: 'run-sequence', label: 'run-sequence' }, + { value: 'command-line-args', label: 'command-line-args' }, + { value: 'unique-random-array', label: 'unique-random-array' }, + { value: 'nunjucks', label: 'nunjucks' }, + { value: 'cross-env', label: 'cross-env' }, + { value: 'passport-local', label: 'passport-local' }, + { value: 'babelify', label: 'babelify' }, + { value: 'puppeteer', label: 'puppeteer' }, + { value: 'bcrypt', label: 'bcrypt' }, + { value: 'hoist-non-react-statics', label: 'hoist-non-react-statics' }, + { value: 'lodash.get', label: 'lodash.get' }, + { value: 'gulp-if', label: 'gulp-if' }, + { value: 'cookie', label: 'cookie' }, + { value: '@babel/preset-env', label: '@babel/preset-env' }, + { value: 'yamljs', label: 'yamljs' }, + { value: 'uglifyjs-webpack-plugin', label: 'uglifyjs-webpack-plugin' }, + { value: 'pkginfo', label: 'pkginfo' }, + { value: 'vinyl-fs', label: 'vinyl-fs' }, + { value: 'should', label: 'should' }, + { value: 'nodemon', label: 'nodemon' }, + { value: 'babel-preset-stage-2', label: 'babel-preset-stage-2' }, + { value: 'recursive-readdir', label: 'recursive-readdir' }, + { value: 'eventemitter2', label: 'eventemitter2' }, + { value: 'bower', label: 'bower' }, + { value: 'vue-template-compiler', label: 'vue-template-compiler' }, + { value: 'dotenv-expand', label: 'dotenv-expand' }, + { value: 'tslint', label: 'tslint' }, + { value: 'koa-static', label: 'koa-static' }, + { value: 'ms', label: 'ms' }, + { value: 'bignumber.js', label: 'bignumber.js' }, + { value: 'react-bootstrap', label: 'react-bootstrap' }, + { value: 'gulp-babel', label: 'gulp-babel' }, + { value: 'htmlparser2', label: 'htmlparser2' }, + { value: 'web3', label: 'web3' }, + { value: 'consolidate', label: 'consolidate' }, + { value: 'gm', label: 'gm' }, + { value: 'gulp-plumber', label: 'gulp-plumber' }, + { value: 'serialport', label: 'serialport' }, + { value: 'assert', label: 'assert' }, + { value: 'github', label: 'github' }, + { value: 'vue-loader', label: 'vue-loader' }, + { value: 'JSONStream', label: 'JSONStream' }, + { value: 'mqtt', label: 'mqtt' }, + { value: 'log-symbols', label: 'log-symbols' }, + { value: 'string', label: 'string' }, + { value: 'method-override', label: 'method-override' }, + { value: 'ioredis', label: 'ioredis' }, + { value: 'hapi', label: 'hapi' }, + { value: '@types/react', label: '@types/react' }, + { value: 'amqplib', label: 'amqplib' }, + { value: 'istanbul', label: 'istanbul' }, + { value: 'copy-webpack-plugin', label: 'copy-webpack-plugin' }, + { value: 'once', label: 'once' }, + { value: 'rsvp', label: 'rsvp' }, + { value: 'lodash.assign', label: 'lodash.assign' }, + { value: 'acorn', label: 'acorn' }, + { value: 'firebase', label: 'firebase' }, + { value: 'lodash.debounce', label: 'lodash.debounce' }, + { value: 'http', label: 'http' }, + { value: 'knex', label: 'knex' }, + { value: 'popper.js', label: 'popper.js' }, + { value: 'babylon', label: 'babylon' }, + { value: 'simple-git', label: 'simple-git' }, + { value: 'systemjs', label: 'systemjs' }, + { value: 'adm-zip', label: 'adm-zip' }, + { value: 'require-dir', label: 'require-dir' }, + { value: 'gulp-autoprefixer', label: 'gulp-autoprefixer' }, + { value: 'tape', label: 'tape' }, + { value: 'ts-loader', label: 'ts-loader' }, + { value: 'date-fns', label: 'date-fns' }, + { value: 'redux-logger', label: 'redux-logger' }, + { value: 'rc', label: 'rc' }, + { value: 'rollup', label: 'rollup' }, + { value: 'node-notifier', label: 'node-notifier' }, + { value: 'html-loader', label: 'html-loader' }, + { value: 'formidable', label: 'formidable' }, + { value: 'deep-extend', label: 'deep-extend' }, + { value: 'recompose', label: 'recompose' }, + { value: 'pump', label: 'pump' }, + { value: 'split', label: 'split' }, + { value: 'wrench', label: 'wrench' }, + { value: 'restify', label: 'restify' }, + { value: 'react-transition-group', label: 'react-transition-group' }, + { value: 'reselect', label: 'reselect' }, + { value: 'walk', label: 'walk' }, + { value: 'global', label: 'global' }, + { value: 'camelcase', label: 'camelcase' }, + { value: 'karma', label: 'karma' }, + { value: 'koa-bodyparser', label: 'koa-bodyparser' }, + { value: 'escape-string-regexp', label: 'escape-string-regexp' }, + { + value: 'babel-plugin-add-module-exports', + label: 'babel-plugin-add-module-exports', + }, + { value: 'helmet', label: 'helmet' }, + { value: 'requirejs', label: 'requirejs' }, + { value: 'http-proxy-middleware', label: 'http-proxy-middleware' }, + { value: 'css', label: 'css' }, + { value: 'element-ui', label: 'element-ui' }, + { value: 'raw-loader', label: 'raw-loader' }, + { value: 'lodash.isequal', label: 'lodash.isequal' }, + { value: 'hammerjs', label: 'hammerjs' }, + { value: 'traverse', label: 'traverse' }, + { value: 'unzip', label: 'unzip' }, + { value: 'app-root-path', label: 'app-root-path' }, + { value: 'download', label: 'download' }, + { value: 'npmlog', label: 'npmlog' }, + { value: 'elasticsearch', label: 'elasticsearch' }, + { value: 'jszip', label: 'jszip' }, + + { value: 'rx', label: 'rx' }, + { value: 'sprintf-js', label: 'sprintf-js' }, + { value: 'eslint-plugin-promise', label: 'eslint-plugin-promise' }, + { value: 'library.min.js', label: 'library.min.js' }, + { value: 'broccoli-merge-trees', label: 'broccoli-merge-trees' }, + { value: 'jshint', label: 'jshint' }, + { value: 'create-react-class', label: 'create-react-class' }, + { value: 'argparse', label: 'argparse' }, + { value: 'buffer', label: 'buffer' }, + { value: 'change-case', label: 'change-case' }, + { value: 'lodash.throttle', label: 'lodash.throttle' }, + { value: 'diff', label: 'diff' }, + { value: 'bn.js', label: 'bn.js' }, + { value: 'lodash.clonedeep', label: 'lodash.clonedeep' }, + { value: 'es6-shim', label: 'es6-shim' }, + { value: 'websocket', label: 'websocket' }, + { value: 'cssnano', label: 'cssnano' }, + { value: 'filesize', label: 'filesize' }, + { value: 'gulp-less', label: 'gulp-less' }, + { value: 'ssh2', label: 'ssh2' }, + + { value: 'inflection', label: 'inflection' }, + { value: 'co-prompt', label: 'co-prompt' }, + { value: 'swig', label: 'swig' }, + + { value: 'mini-css-extract-plugin', label: 'mini-css-extract-plugin' }, + { value: 'babel-types', label: 'babel-types' }, + { value: 'gulp-template', label: 'gulp-template' }, + { value: 'xmlbuilder', label: 'xmlbuilder' }, + { value: 'bcryptjs', label: 'bcryptjs' }, + { value: 'vinyl-source-stream', label: 'vinyl-source-stream' }, + { value: 'cron', label: 'cron' }, + { value: 'plugin-error', label: 'plugin-error' }, + { value: 'react-select', label: 'react-select' }, + { value: '@angular/material', label: '@angular/material' }, + { value: 'js-cookie', label: 'js-cookie' }, + { value: 'escape-html', label: 'escape-html' }, + { value: 'async-validator', label: 'async-validator' }, + { value: 'watch', label: 'watch' }, + { + value: 'connect-history-api-fallback', + label: 'connect-history-api-fallback', + }, + { value: 'gulp-watch', label: 'gulp-watch' }, + { value: 'json-stringify-safe', label: 'json-stringify-safe' }, + { value: 'xmlhttprequest', label: 'xmlhttprequest' }, + { value: 'webpack-cli', label: 'webpack-cli' }, + { value: 'path-exists', label: 'path-exists' }, + { value: 'googleapis', label: 'googleapis' }, + { value: 'send', label: 'send' }, + { value: 'broccoli-funnel', label: 'broccoli-funnel' }, + { value: 'clear', label: 'clear' }, + { value: 'json-stable-stringify', label: 'json-stable-stringify' }, + { value: 'sax', label: 'sax' }, + { value: 'gulp-install', label: 'gulp-install' }, + { value: 'user-home', label: 'user-home' }, + { value: 'nomnom', label: 'nomnom' }, + { + value: 'optimize-css-assets-webpack-plugin', + label: 'optimize-css-assets-webpack-plugin', + }, + { value: 'validate-npm-package-name', label: 'validate-npm-package-name' }, + { value: 'canvas', label: 'canvas' }, + { value: 'get-stdin', label: 'get-stdin' }, + { value: 'jsonschema', label: 'jsonschema' }, + { value: 'metalsmith', label: 'metalsmith' }, + { value: 'oauth', label: 'oauth' }, + { value: 'prettyjson', label: 'prettyjson' }, + { value: 'pull-stream', label: 'pull-stream' }, + { value: 'fs-promise', label: 'fs-promise' }, + { value: 'methods', label: 'methods' }, + { value: 'watchify', label: 'watchify' }, + { value: 'grunt-contrib-clean', label: 'grunt-contrib-clean' }, + { value: 'url-parse', label: 'url-parse' }, + { value: 'postcss-import', label: 'postcss-import' }, + { value: 'errorhandler', label: 'errorhandler' }, + { value: 'grunt-contrib-uglify', label: 'grunt-contrib-uglify' }, + { value: 'ts-node', label: 'ts-node' }, + { value: 'eslint-plugin-standard', label: 'eslint-plugin-standard' }, + { value: 'gulp-replace', label: 'gulp-replace' }, + { value: 'image-size', label: 'image-size' }, + { value: 'configstore', label: 'configstore' }, + { value: 'http-errors', label: 'http-errors' }, + { value: '@angular/cdk', label: '@angular/cdk' }, + { value: 'codemirror', label: 'codemirror' }, + { value: '@babel/polyfill', label: '@babel/polyfill' }, + { value: 'eslint-config-airbnb-base', label: 'eslint-config-airbnb-base' }, + { value: 'estraverse', label: 'estraverse' }, + { value: 'mobx', label: 'mobx' }, + { value: 'liftoff', label: 'liftoff' }, + { value: 'eslint-config-standard', label: 'eslint-config-standard' }, + { value: 'babel-preset-stage-1', label: 'babel-preset-stage-1' }, + { value: 'xlsx', label: 'xlsx' }, + { value: 'safe-buffer', label: 'safe-buffer' }, + { value: 'protobufjs', label: 'protobufjs' }, + { value: 'blessed', label: 'blessed' }, + { value: 'grunt-contrib-watch', label: 'grunt-contrib-watch' }, + { value: 'gulp-conflict', label: 'gulp-conflict' }, + { + value: 'babel-plugin-transform-es2015-modules-commonjs', + label: 'babel-plugin-transform-es2015-modules-commonjs', + }, + { value: 'cli-spinner', label: 'cli-spinner' }, + { value: 'nedb', label: 'nedb' }, + + { value: 'cli', label: 'cli' }, + { value: 'leaflet', label: 'leaflet' }, + { value: 'lodash.defaults', label: 'lodash.defaults' }, + { value: 'bs58', label: 'bs58' }, + { value: 'deasync', label: 'deasync' }, + { value: 'clean-webpack-plugin', label: 'clean-webpack-plugin' }, + { value: 'jimp', label: 'jimp' }, + { value: 'three', label: 'three' }, + { value: 'gulp-load-plugins', label: 'gulp-load-plugins' }, + { value: 'eslint-plugin-node', label: 'eslint-plugin-node' }, + { value: 'redux-actions', label: 'redux-actions' }, + { value: 'eslint-config-prettier', label: 'eslint-config-prettier' }, + { value: 'read', label: 'read' }, + { value: 'faker', label: 'faker' }, + { value: 'gulp-imagemin', label: 'gulp-imagemin' }, + { value: 'map-stream', label: 'map-stream' }, + { value: 'portfinder', label: 'portfinder' }, + { value: 'phantomjs', label: 'phantomjs' }, + { value: 'fbjs', label: 'fbjs' }, + { + value: 'react-addons-css-transition-group', + label: 'react-addons-css-transition-group', + }, + { value: '@angular/compiler-cli', label: '@angular/compiler-cli' }, + { value: 'parse5', label: 'parse5' }, + { value: '@types/react-dom', label: '@types/react-dom' }, + { value: 'csv', label: 'csv' }, + { value: 'child_process', label: 'child_process' }, + { value: 'text-table', label: 'text-table' }, + { value: '@babel/preset-react', label: '@babel/preset-react' }, + { value: 'elliptic', label: 'elliptic' }, + { value: 'prismjs', label: 'prismjs' }, + + { value: 'level', label: 'level' }, + { value: 'gulp-jshint', label: 'gulp-jshint' }, + { value: 'bytes', label: 'bytes' }, + { value: '@types/express', label: '@types/express' }, + { value: 'phantomjs-prebuilt', label: 'phantomjs-prebuilt' }, + { value: 'object-path', label: 'object-path' }, + { value: 'strip-json-comments', label: 'strip-json-comments' }, + { value: 'node.extend', label: 'node.extend' }, + { value: 'sharp', label: 'sharp' }, + { value: 'node-schedule', label: 'node-schedule' }, + { value: 'graphql-tag', label: 'graphql-tag' }, + { value: 'plist', label: 'plist' }, + { value: 'parseurl', label: 'parseurl' }, + { value: 'electron', label: 'electron' }, + { value: 'react-tap-event-plugin', label: 'react-tap-event-plugin' }, + { value: 'stylelint', label: 'stylelint' }, + { value: 'webpack-bundle-analyzer', label: 'webpack-bundle-analyzer' }, + { value: 'levelup', label: 'levelup' }, + { + value: '@babel/plugin-proposal-class-properties', + label: '@babel/plugin-proposal-class-properties', + }, + { value: 'grunt-contrib-copy', label: 'grunt-contrib-copy' }, + { value: 'grunt-contrib-jshint', label: 'grunt-contrib-jshint' }, + { value: 'gulp-notify', label: 'gulp-notify' }, + { value: 'merge-stream', label: 'merge-stream' }, + { value: 'node-gyp', label: 'node-gyp' }, + { value: 'hiredis', label: 'hiredis' }, + { value: 'brfs', label: 'brfs' }, + { value: 'pm2', label: 'pm2' }, + { value: 'koa-compose', label: 'koa-compose' }, + { value: 'flat', label: 'flat' }, + { value: 'slug', label: 'slug' }, + { value: 'fstream', label: 'fstream' }, + { value: 'gulp-minify-css', label: 'gulp-minify-css' }, + { value: 'mysql2', label: 'mysql2' }, + { value: 'webpack-node-externals', label: 'webpack-node-externals' }, + { value: 'bcrypt-nodejs', label: 'bcrypt-nodejs' }, + + { value: 'rollup-plugin-node-resolve', label: 'rollup-plugin-node-resolve' }, + { value: 'sync-request', label: 'sync-request' }, + { value: 'on-finished', label: 'on-finished' }, + { value: 'lodash.isplainobject', label: 'lodash.isplainobject' }, + { value: 'slash', label: 'slash' }, + { value: 'user', label: 'user' }, + { value: 'discord.js', label: 'discord.js' }, + { value: 'vinyl-buffer', label: 'vinyl-buffer' }, + { value: 'babel-traverse', label: 'babel-traverse' }, + { value: 'csv-parse', label: 'csv-parse' }, + { value: 'html-entities', label: 'html-entities' }, + { value: 'lodash.omit', label: 'lodash.omit' }, + { value: 'verror', label: 'verror' }, + { + value: 'babel-plugin-transform-react-jsx', + label: 'babel-plugin-transform-react-jsx', + }, + { value: 'es6-promisify', label: 'es6-promisify' }, + { value: 'micromatch', label: 'micromatch' }, + { value: 'markdown', label: 'markdown' }, + { value: 'clui', label: 'clui' }, + { value: 'connect-redis', label: 'connect-redis' }, + { value: 'throttle-debounce', label: 'throttle-debounce' }, + { value: 'tough-cookie', label: 'tough-cookie' }, + { value: 'find-up', label: 'find-up' }, + { value: 'basic-auth', label: 'basic-auth' }, + { value: 'chart.js', label: 'chart.js' }, + { value: 'unist-util-visit', label: 'unist-util-visit' }, + { value: 'passport-oauth', label: 'passport-oauth' }, + { value: 'karma-chrome-launcher', label: 'karma-chrome-launcher' }, + { value: 'lodash.pick', label: 'lodash.pick' }, + { value: 'webpack-sources', label: 'webpack-sources' }, + { value: 'postcss-cssnext', label: 'postcss-cssnext' }, + { value: 'multimatch', label: 'multimatch' }, + { value: 'detect-port', label: 'detect-port' }, + { value: 'needle', label: 'needle' }, + { value: 'react-helmet', label: 'react-helmet' }, + { value: 'restler', label: 'restler' }, + { value: '@angular/platform-server', label: '@angular/platform-server' }, + { value: 'gaze', label: 'gaze' }, + { value: 'node-emoji', label: 'node-emoji' }, + { value: 'object-hash', label: 'object-hash' }, + { value: 'eslint-plugin-babel', label: 'eslint-plugin-babel' }, + { value: 'osenv', label: 'osenv' }, + { value: 'raw-body', label: 'raw-body' }, + { value: 'extend-shallow', label: 'extend-shallow' }, + + { value: 'rollup-plugin-commonjs', label: 'rollup-plugin-commonjs' }, + { value: 'touch', label: 'touch' }, + { value: 'figures', label: 'figures' }, + { value: 'pretty-bytes', label: 'pretty-bytes' }, + { value: 'gulp-filter', label: 'gulp-filter' }, + { value: 'enzyme', label: 'enzyme' }, + { value: 'https', label: 'https' }, + { value: 'valid-url', label: 'valid-url' }, + { value: 'highland', label: 'highland' }, + { + value: 'babel-plugin-syntax-dynamic-import', + label: 'babel-plugin-syntax-dynamic-import', + }, + { value: 'tar-fs', label: 'tar-fs' }, + { value: 'log-update', label: 'log-update' }, + { value: 'supertest', label: 'supertest' }, + { value: 'convert-source-map', label: 'convert-source-map' }, + { value: 'selenium-webdriver', label: 'selenium-webdriver' }, + { value: 'command-line-usage', label: 'command-line-usage' }, + { value: 'shallowequal', label: 'shallowequal' }, + { value: 'eslint-plugin-prettier', label: 'eslint-plugin-prettier' }, + { value: 'requireindex', label: 'requireindex' }, + { value: 'read-pkg-up', label: 'read-pkg-up' }, + { value: 'node-watch', label: 'node-watch' }, + { value: 'finalhandler', label: 'finalhandler' }, + { value: 'path-is-absolute', label: 'path-is-absolute' }, + { value: 'he', label: 'he' }, + { + value: 'babel-helper-vue-jsx-merge-props', + label: 'babel-helper-vue-jsx-merge-props', + }, + { value: 'react-dnd', label: 'react-dnd' }, + { value: 'mobx-react', label: 'mobx-react' }, + { value: 'supports-color', label: 'supports-color' }, + { value: 'node-forge', label: 'node-forge' }, + { value: 'child-process-promise', label: 'child-process-promise' }, + { value: 'readline', label: 'readline' }, + { value: 'draft-js', label: 'draft-js' }, + { value: 'connect-flash', label: 'connect-flash' }, + + { value: 'opener', label: 'opener' }, + { value: 'keypress', label: 'keypress' }, + { value: 'mathjs', label: 'mathjs' }, + { value: 'grunt-contrib-concat', label: 'grunt-contrib-concat' }, + { value: 'node-static', label: 'node-static' }, + { value: 'is-plain-object', label: 'is-plain-object' }, + { value: 'grunt-cli', label: 'grunt-cli' }, + { value: 'hogan.js', label: 'hogan.js' }, + { value: 'argv', label: 'argv' }, + { value: 'generic-pool', label: 'generic-pool' }, + { value: 'findup-sync', label: 'findup-sync' }, + { value: 'd3-scale', label: 'd3-scale' }, + { value: 'regenerator-runtime', label: 'regenerator-runtime' }, + { value: 'hyperquest', label: 'hyperquest' }, + { value: 'babel-template', label: 'babel-template' }, + { value: 'gulp-clean-css', label: 'gulp-clean-css' }, + { value: 'lodash.isfunction', label: 'lodash.isfunction' }, + { value: 'assert-plus', label: 'assert-plus' }, + { value: 'cuid', label: 'cuid' }, + { value: 'sprintf', label: 'sprintf' }, + { value: 'install', label: 'install' }, + { value: 'sha1', label: 'sha1' }, + { value: 'passport-oauth2', label: 'passport-oauth2' }, + { value: 'urijs', label: 'urijs' }, + { value: 'urllib', label: 'urllib' }, + { value: 'stylus-loader', label: 'stylus-loader' }, + { value: 'ethereumjs-util', label: 'ethereumjs-util' }, + { + value: 'babel-plugin-transform-async-to-generator', + label: 'babel-plugin-transform-async-to-generator', + }, + { value: 'gulp-postcss', label: 'gulp-postcss' }, + { value: 'bl', label: 'bl' }, + { value: 'lodash-es', label: 'lodash-es' }, + { value: 'babel-generator', label: 'babel-generator' }, + { value: 'fancy-log', label: 'fancy-log' }, + { value: 'component-emitter', label: 'component-emitter' }, + { + value: 'friendly-errors-webpack-plugin', + label: 'friendly-errors-webpack-plugin', + }, + { value: 'stack-trace', label: 'stack-trace' }, + { value: 'identity-obj-proxy', label: 'identity-obj-proxy' }, + { value: 'file-type', label: 'file-type' }, + { value: 'http-server', label: 'http-server' }, + { value: 'utils-merge', label: 'utils-merge' }, + { value: 'crc', label: 'crc' }, + { value: 'node-cache', label: 'node-cache' }, + { value: 'react-intl', label: 'react-intl' }, + { value: 'graphql-tools', label: 'graphql-tools' }, + { value: 'svgo', label: 'svgo' }, + { value: 'chai-as-promised', label: 'chai-as-promised' }, + { value: 'babel-plugin-syntax-jsx', label: 'babel-plugin-syntax-jsx' }, + { value: 'lodash.set', label: 'lodash.set' }, + { value: 'babel-plugin-import', label: 'babel-plugin-import' }, + { value: 'iniparser', label: 'iniparser' }, + { value: 'nodegit', label: 'nodegit' }, + { value: 'require-all', label: 'require-all' }, + { value: 'leveldown', label: 'leveldown' }, + { value: 'lodash.uniq', label: 'lodash.uniq' }, + { value: 'passport-strategy', label: 'passport-strategy' }, + { value: 'jasmine', label: 'jasmine' }, + { value: 'redux-form', label: 'redux-form' }, + { value: 'vorpal', label: 'vorpal' }, + { value: 'nib', label: 'nib' }, + { value: 'node-dir', label: 'node-dir' }, + { value: 'chance', label: 'chance' }, + { value: 'memory-fs', label: 'memory-fs' }, + { value: 'vue-style-loader', label: 'vue-style-loader' }, + { value: 'loglevel', label: 'loglevel' }, + { + value: 'babel-plugin-transform-regenerator', + label: 'babel-plugin-transform-regenerator', + }, + { value: 'recast', label: 'recast' }, + { value: 'gzip-size', label: 'gzip-size' }, + { value: 'babel-preset-stage-3', label: 'babel-preset-stage-3' }, + { value: 'karma-phantomjs-launcher', label: 'karma-phantomjs-launcher' }, + { value: 'depd', label: 'depd' }, + { value: 'xml2json', label: 'xml2json' }, + { value: 'serve-index', label: 'serve-index' }, + { value: 'btoa', label: 'btoa' }, + { value: 'vue-hot-reload-api', label: 'vue-hot-reload-api' }, + { value: 'cli-table2', label: 'cli-table2' }, + { value: '@angular/upgrade', label: '@angular/upgrade' }, + { value: 'lodash.isstring', label: 'lodash.isstring' }, + + { value: 'react-dnd-html5-backend', label: 'react-dnd-html5-backend' }, + { value: 'lodash.foreach', label: 'lodash.foreach' }, + { value: 'coveralls', label: 'coveralls' }, + { value: 'type-is', label: 'type-is' }, + { value: 'lodash.template', label: 'lodash.template' }, + { value: 'koa-logger', label: 'koa-logger' }, + { value: 'tildify', label: 'tildify' }, + { + value: '@babel/plugin-syntax-dynamic-import', + label: '@babel/plugin-syntax-dynamic-import', + }, + { value: 'gulp-eslint', label: 'gulp-eslint' }, + { value: 'file-saver', label: 'file-saver' }, + { value: 'sanitize-filename', label: 'sanitize-filename' }, + { value: 'connect-mongo', label: 'connect-mongo' }, + { value: 'accepts', label: 'accepts' }, + { value: 'phantom', label: 'phantom' }, + { value: 'deep-diff', label: 'deep-diff' }, + { value: 'base-64', label: 'base-64' }, + + { value: '@material-ui/core', label: '@material-ui/core' }, + { value: 'imagemin', label: 'imagemin' }, + { value: 'etag', label: 'etag' }, + { value: 'npm-run-all', label: 'npm-run-all' }, + { value: 'xregexp', label: 'xregexp' }, + { + value: '@babel/plugin-transform-runtime', + label: '@babel/plugin-transform-runtime', + }, + { value: 'content-type', label: 'content-type' }, + { value: 'mssql', label: 'mssql' }, + { value: 'rollup-plugin-babel', label: 'rollup-plugin-babel' }, + { value: 'copy-paste', label: 'copy-paste' }, + { value: 'react-native-vector-icons', label: 'react-native-vector-icons' }, + { value: 'is-url', label: 'is-url' }, + { value: 'left-pad', label: 'left-pad' }, + { value: 'ts-helpers', label: 'ts-helpers' }, + { value: 'js-base64', label: 'js-base64' }, + { value: 'fluent-ffmpeg', label: 'fluent-ffmpeg' }, + { value: 'jwt-simple', label: 'jwt-simple' }, + { value: 'lodash.camelcase', label: 'lodash.camelcase' }, + { value: 'get-port', label: 'get-port' }, + { value: 'mockjs', label: 'mockjs' }, + { value: 'front-matter', label: 'front-matter' }, + { value: 'promise-polyfill', label: 'promise-polyfill' }, + { value: 'gulp-typescript', label: 'gulp-typescript' }, + { value: 'thunkify', label: 'thunkify' }, + { value: 'karma-jasmine', label: 'karma-jasmine' }, + { value: 'dockerode', label: 'dockerode' }, + { value: 'd3-selection', label: 'd3-selection' }, + { value: 'source-map-loader', label: 'source-map-loader' }, + { value: 'argx', label: 'argx' }, + { value: 'angular-animate', label: 'angular-animate' }, + { value: 'node-pre-gyp', label: 'node-pre-gyp' }, + { value: 'tv4', label: 'tv4' }, + { value: 'lodash.isempty', label: 'lodash.isempty' }, + { value: 'react-dropzone', label: 'react-dropzone' }, + { value: 'long', label: 'long' }, + { value: 'koa-body', label: 'koa-body' }, + { value: 'inert', label: 'inert' }, + { value: 'lodash.map', label: 'lodash.map' }, + { value: 'dot', label: 'dot' }, + { value: 'gulp-shell', label: 'gulp-shell' }, + { value: 'jasmine-core', label: 'jasmine-core' }, + { value: 'pouchdb', label: 'pouchdb' }, + { value: 'forever', label: 'forever' }, + { value: 'wiredep', label: 'wiredep' }, + { value: 'decompress', label: 'decompress' }, + { value: 'serialize-javascript', label: 'serialize-javascript' }, + { value: 'https-proxy-agent', label: 'https-proxy-agent' }, + { value: 'pinkie-promise', label: 'pinkie-promise' }, + { value: 'create-hash', label: 'create-hash' }, + { value: 'soap', label: 'soap' }, + { value: 'columnify', label: 'columnify' }, + { + value: '@babel/plugin-proposal-object-rest-spread', + label: '@babel/plugin-proposal-object-rest-spread', + }, + { value: 'bson', label: 'bson' }, + { + value: 'babel-plugin-transform-flow-strip-types', + label: 'babel-plugin-transform-flow-strip-types', + }, + { value: 'lodash.isobject', label: 'lodash.isobject' }, + { value: 'mout', label: 'mout' }, + { value: 'xhr', label: 'xhr' }, + { value: 'karma-mocha', label: 'karma-mocha' }, + + { value: 'arrify', label: 'arrify' }, + { value: 'gulp-clean', label: 'gulp-clean' }, + { value: 'pretty-error', label: 'pretty-error' }, + { value: 'knox', label: 'knox' }, + { + value: 'react-addons-shallow-compare', + label: 'react-addons-shallow-compare', + }, + { value: 'defaults', label: 'defaults' }, + { value: 'is-promise', label: 'is-promise' }, + { value: 'unirest', label: 'unirest' }, + { value: 'uglify-es', label: 'uglify-es' }, + { value: 'resize-observer-polyfill', label: 'resize-observer-polyfill' }, + { value: 'keycode', label: 'keycode' }, + { value: 'react-test-renderer', label: 'react-test-renderer' }, + { value: 'showdown', label: 'showdown' }, + { value: 'd3-array', label: 'd3-array' }, + { value: 'MD5', label: 'MD5' }, + { value: 'lodash.flatten', label: 'lodash.flatten' }, + { value: 'command-exists', label: 'command-exists' }, + { value: 'rollup-pluginutils', label: 'rollup-pluginutils' }, + { value: 'babel-preset-latest', label: 'babel-preset-latest' }, + { value: 'rc-util', label: 'rc-util' }, + { value: 'tracer', label: 'tracer' }, + { value: 'jsdoc', label: 'jsdoc' }, + { value: 'virtual-dom', label: 'virtual-dom' }, + { value: 'byline', label: 'byline' }, + { value: 'lowdb', label: 'lowdb' }, + { value: 'bootstrap-sass', label: 'bootstrap-sass' }, + { value: 'slush', label: 'slush' }, + { value: 'lazy-cache', label: 'lazy-cache' }, + { value: 'callsite', label: 'callsite' }, + { value: 'tweetnacl', label: 'tweetnacl' }, + { + value: 'babel-plugin-transform-react-remove-prop-types', + label: 'babel-plugin-transform-react-remove-prop-types', + }, + { value: 'fastclick', label: 'fastclick' }, + { value: 'imagemin-pngquant', label: 'imagemin-pngquant' }, + { value: 'busboy', label: 'busboy' }, + { value: 'pngjs', label: 'pngjs' }, + { value: 'gulp-mocha', label: 'gulp-mocha' }, + { value: 'listr', label: 'listr' }, + { + value: 'babel-plugin-transform-es2015-destructuring', + label: 'babel-plugin-transform-es2015-destructuring', + }, + { value: 'grpc', label: 'grpc' }, + { value: 'isobject', label: 'isobject' }, + { value: 'radium', label: 'radium' }, + { value: 'normalize-url', label: 'normalize-url' }, + + { value: 'shell-quote', label: 'shell-quote' }, + { value: 'follow-redirects', label: 'follow-redirects' }, + { value: 'deep-assign', label: 'deep-assign' }, + { value: 'nock', label: 'nock' }, + { value: 'jquery-ui', label: 'jquery-ui' }, + { value: 'jwt-decode', label: 'jwt-decode' }, + { value: 'localforage', label: 'localforage' }, + { value: 'nyc', label: 'nyc' }, + { value: 'bufferutil', label: 'bufferutil' }, + { value: 'pegjs', label: 'pegjs' }, + { value: 'process', label: 'process' }, + { value: 'es6-error', label: 'es6-error' }, + { value: 'mv', label: 'mv' }, + { value: 'gulp-changed', label: 'gulp-changed' }, + { value: 'standard', label: 'standard' }, + { value: 'split2', label: 'split2' }, + { value: 'preact', label: 'preact' }, + { value: 'jshint-stylish', label: 'jshint-stylish' }, + { value: 'vue-resource', label: 'vue-resource' }, + + { value: 'stylelint-config-standard', label: 'stylelint-config-standard' }, + { value: 'passport-facebook', label: 'passport-facebook' }, + { value: 'immutability-helper', label: 'immutability-helper' }, + { value: 'xpath', label: 'xpath' }, + { value: 'react-error-overlay', label: 'react-error-overlay' }, + { value: 'angular-ui-router', label: 'angular-ui-router' }, + { value: 'react-addons-test-utils', label: 'react-addons-test-utils' }, + { value: 'inversify', label: 'inversify' }, + { value: 'duplexify', label: 'duplexify' }, + { value: 'extract-zip', label: 'extract-zip' }, + { value: 'wordwrap', label: 'wordwrap' }, + { value: '@polymer/polymer', label: '@polymer/polymer' }, + { value: 'is', label: 'is' }, + { value: 'cookies', label: 'cookies' }, + { value: 'sugar', label: 'sugar' }, + { value: 'angular-sanitize', label: 'angular-sanitize' }, + { + value: 'babel-plugin-transform-object-assign', + label: 'babel-plugin-transform-object-assign', + }, + { value: 'multiparty', label: 'multiparty' }, + { value: 'md5-file', label: 'md5-file' }, + { value: 'nightmare', label: 'nightmare' }, + { value: 'gulp-htmlmin', label: 'gulp-htmlmin' }, + { value: 'koa-mount', label: 'koa-mount' }, + { value: 'tslint-react', label: 'tslint-react' }, + { value: 'twit', label: 'twit' }, + { value: 'zmq', label: 'zmq' }, + { value: 'fresh', label: 'fresh' }, + { value: 'husky', label: 'husky' }, + { + value: 'babel-plugin-transform-es2015-parameters', + label: 'babel-plugin-transform-es2015-parameters', + }, + { value: 'isarray', label: 'isarray' }, + { value: 'karma-coverage', label: 'karma-coverage' }, + { value: 'log', label: 'log' }, + { value: 'natural', label: 'natural' }, + { value: 'pino', label: 'pino' }, + { value: 'pretty-hrtime', label: 'pretty-hrtime' }, + { value: 'os', label: 'os' }, + + { value: 'keymirror', label: 'keymirror' }, + { value: 'ansi-styles', label: 'ansi-styles' }, + { value: 'end-of-stream', label: 'end-of-stream' }, + { value: 'rxjs-compat', label: 'rxjs-compat' }, + { value: 'amqp', label: 'amqp' }, + { value: 'decamelize', label: 'decamelize' }, + { value: 'utf-8-validate', label: 'utf-8-validate' }, + { value: 'findit', label: 'findit' }, + { value: 'ts-jest', label: 'ts-jest' }, + { value: 'sinon-chai', label: 'sinon-chai' }, + { value: 'gray-matter', label: 'gray-matter' }, + { value: 'content-disposition', label: 'content-disposition' }, + { value: 'imports-loader', label: 'imports-loader' }, + { value: 'pako', label: 'pako' }, + { value: 'hexlet-pairs', label: 'hexlet-pairs' }, + { value: 'secp256k1', label: 'secp256k1' }, + { value: 'defined', label: 'defined' }, + { value: 'bytebuffer', label: 'bytebuffer' }, + { value: 'amdefine', label: 'amdefine' }, + { value: 'replace', label: 'replace' }, + + { value: 'memory-cache', label: 'memory-cache' }, + { value: 'ndarray', label: 'ndarray' }, + { value: 'ua-parser-js', label: 'ua-parser-js' }, + { value: 'awesome-typescript-loader', label: 'awesome-typescript-loader' }, + { value: 'ftp', label: 'ftp' }, + { value: 'object.assign', label: 'object.assign' }, + { value: 'dom-helpers', label: 'dom-helpers' }, + { value: 'retry', label: 'retry' }, + { value: 'raven', label: 'raven' }, + { value: 'winston-daily-rotate-file', label: 'winston-daily-rotate-file' }, + { value: 'strip-bom', label: 'strip-bom' }, + { value: 'sanitize-html', label: 'sanitize-html' }, + { value: 'on-headers', label: 'on-headers' }, + { value: 'node-cmd', label: 'node-cmd' }, + { value: 'i', label: 'i' }, + { value: 'tiny-lr', label: 'tiny-lr' }, + { value: 'require-directory', label: 'require-directory' }, + { value: 'intl', label: 'intl' }, + { value: 'ecstatic', label: 'ecstatic' }, + { value: 'clipboardy', label: 'clipboardy' }, + { value: 'striptags', label: 'striptags' }, + { value: 'archy', label: 'archy' }, + { value: 'postcss-preset-env', label: 'postcss-preset-env' }, + { value: 'lodash.isarray', label: 'lodash.isarray' }, + { value: 'hash-sum', label: 'hash-sum' }, + { value: 'blueimp-md5', label: 'blueimp-md5' }, + { value: 'event-emitter', label: 'event-emitter' }, + { value: 'gh-pages', label: 'gh-pages' }, + { value: 'range-parser', label: 'range-parser' }, + { value: 'base64-js', label: 'base64-js' }, + { value: 'cookie-signature', label: 'cookie-signature' }, + { value: 'yeoman-environment', label: 'yeoman-environment' }, + { value: 'setimmediate', label: 'setimmediate' }, + { value: 'big.js', label: 'big.js' }, + { value: 'merge-descriptors', label: 'merge-descriptors' }, + { value: 'karma-webpack', label: 'karma-webpack' }, + { value: 'grunt-contrib-cssmin', label: 'grunt-contrib-cssmin' }, + { value: 'toml', label: 'toml' }, + { value: 'gulp-rev', label: 'gulp-rev' }, + { value: 'step', label: 'step' }, + { value: '@types/request', label: '@types/request' }, + { value: 'node-rest-client', label: 'node-rest-client' }, + { value: 'falafel', label: 'falafel' }, + { value: 'docopt', label: 'docopt' }, + { value: 'bigi', label: 'bigi' }, + { value: 'envify', label: 'envify' }, + { value: 'find-root', label: 'find-root' }, + { value: 'hbs', label: 'hbs' }, + { value: 'fibers', label: 'fibers' }, + { value: 'noble', label: 'noble' }, + { value: 'common-tags', label: 'common-tags' }, + { value: 'bitcoinjs-lib', label: 'bitcoinjs-lib' }, + { value: 'gulp-git', label: 'gulp-git' }, + { value: 'animate.css', label: 'animate.css' }, + { value: 'flow-bin', label: 'flow-bin' }, + { value: 'xml', label: 'xml' }, + { value: 'ffi', label: 'ffi' }, + { value: '@types/bluebird', label: '@types/bluebird' }, + { value: 'wreck', label: 'wreck' }, + { value: 'ignore', label: 'ignore' }, + { value: 'camel-case', label: 'camel-case' }, + { + value: 'progress-bar-webpack-plugin', + label: 'progress-bar-webpack-plugin', + }, + { value: 'twilio', label: 'twilio' }, + { + value: 'babel-plugin-module-resolver', + label: 'babel-plugin-module-resolver', + }, + { value: 'big-integer', label: 'big-integer' }, + + { value: 'koa-send', label: 'koa-send' }, + { + value: 'babel-plugin-named-asset-import', + label: 'babel-plugin-named-asset-import', + }, + { value: 'git-clone', label: 'git-clone' }, + { value: 'express-jwt', label: 'express-jwt' }, + { value: 'make-dir', label: 'make-dir' }, + { value: 'express-handlebars', label: 'express-handlebars' }, + { value: 'd3-shape', label: 'd3-shape' }, + { value: 'word-wrap', label: 'word-wrap' }, + { value: 'randombytes', label: 'randombytes' }, + { value: 'ansi-escapes', label: 'ansi-escapes' }, + { value: 'is-stream', label: 'is-stream' }, + { value: 'boxen', label: 'boxen' }, + { value: 'react-timer-mixin', label: 'react-timer-mixin' }, + + { value: '@oclif/config', label: '@oclif/config' }, + { value: 'webpack-stream', label: 'webpack-stream' }, + { value: 'qiniu', label: 'qiniu' }, + + // these are added in for fun: + { value: '@types/chroma-js', label: '@types/chroma-js' }, + { value: 'react-spring', label: 'react-spring' }, +] diff --git a/nexus-builder/packages/navigator/src/uuiui/button.tsx b/nexus-builder/packages/navigator/src/uuiui/button.tsx new file mode 100644 index 000000000000..7ba85643e5ce --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/button.tsx @@ -0,0 +1,150 @@ +import styled from '@emotion/styled' +import type { Property } from 'csstype' +//TODO: refactor components to functional components and use 'useColorTheme()': +import { colorTheme, UtopiaTheme } from './styles/theme' + +export interface ButtonProps { + hidden?: boolean + highlight?: boolean + spotlight?: boolean + outline?: boolean + disabled?: boolean + primary?: boolean + danger?: boolean + overriddenBackground?: Property.Background +} + +/** + * Button + * Inline button for UI elements, including small icon buttons on rows + * @param hidden: invisible + * @param highlight: should highlight on hover + * @param spotlight: slightly visible background + * @param outline: draws borders + * @param disabled: no interactions, style opacity. Also prevents pointer events + * @param primary: uses primary color scheme + + */ + +export const Button = styled.div((props: ButtonProps) => { + let background: Property.Background | undefined = undefined + if (props.overriddenBackground != null) { + background = props.overriddenBackground + } else if (props.primary) { + background = colorTheme.primary.value + } else if (props.spotlight) { + background = colorTheme.buttonBackground.value + } + + let hoverBackground: Property.Background | undefined = 'transparent' + if (props.overriddenBackground != null) { + hoverBackground = props.overriddenBackground + } else if (props.primary && props.highlight) { + hoverBackground = colorTheme.primary.value + } else if (props.highlight) { + hoverBackground = colorTheme.buttonHoverBackground.value + } + + return { + label: 'button', + display: props.hidden ? 'none' : 'flex', + flexGrow: 0, + flexShrink: 0, + border: 'none', + boxSixing: 'border-box', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + outline: 'none', + borderRadius: UtopiaTheme.inputBorderRadius, + padding: 0, + height: UtopiaTheme.layout.inputHeight.default, + opacity: props.disabled ? 0.5 : 1, + pointerEvents: props.disabled ? 'none' : 'initial', + boxShadow: props.outline ? `inset 0px 0px 0px 1px ${colorTheme.buttonShadow.value}` : undefined, + color: props.primary ? 'white' : 'inherit', + background: background, + '&:hover': { + background: hoverBackground, + }, + } +}) + +export const SquareButton = styled(Button)({ + label: 'SquareButton', + width: UtopiaTheme.layout.inputHeight.default, + height: UtopiaTheme.layout.inputHeight.default, +}) + +export const ToggleButton = styled(SquareButton)<{ + value: boolean +}>((props) => ({ + background: props.value ? colorTheme.buttonBackground.value : 'transparent', + '&:hover': { + background: colorTheme.buttonHoverBackground.value, + }, +})) + +export const FormButton = styled.button((props: ButtonProps) => ({ + justifyContent: 'center', + fontSize: '11px', + paddingLeft: 12, + paddingRight: 12, + minWidth: '90px', + height: UtopiaTheme.layout.rowHeight.normal, + display: props.hidden ? 'none' : 'flex', + boxSixing: 'border-box', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 5, + outline: 'none', + opacity: props.disabled ? 0.5 : 1, + pointerEvents: props.disabled ? 'none' : 'initial', + cursor: 'pointer', + + // slightly subdued colors in default state + backgroundColor: props.primary + ? props.danger + ? colorTheme.errorForeground.value + : colorTheme.primary.value + : 'transparent', + + color: props.primary ? 'white' : props.danger ? colorTheme.errorForeground.value : 'inherit', + + border: `1px solid ${ + props.danger + ? colorTheme.errorForeground.value + : props.primary + ? colorTheme.denimBlue.value + : 'transparent' + }`, + transition: 'all .10s ease-in-out', + // regular background in hover state + '&:hover': { + backgroundColor: props.primary + ? props.danger + ? colorTheme.errorForeground.value + : colorTheme.denimBlue.value + : colorTheme.dialogBackground2.value, + }, + '&:focus': { + outline: 'none', + // solid subdued outline in focused state + boxShadow: `0px 0px 0px 2px ${ + props.danger + ? colorTheme.errorForeground20.value + : props.primary + ? colorTheme.primary30.value + : colorTheme.subduedBorder80.value + }`, + }, + '&:active': { + outline: 'none', + // slightly brighter backgrounds while pressed + backgroundColor: props.primary + ? props.danger + ? colorTheme.errorForegroundEmphasized.value + : colorTheme.primary.value + : colorTheme.bg2.value, + }, +})) diff --git a/nexus-builder/packages/navigator/src/uuiui/dialog.tsx b/nexus-builder/packages/navigator/src/uuiui/dialog.tsx new file mode 100644 index 000000000000..d59f13a3974c --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/dialog.tsx @@ -0,0 +1,116 @@ +import styled from '@emotion/styled' +import React from 'react' +import { Isolator } from './isolator' +import { FlexColumn } from './widgets/layout/flex-column' +import { FlexRow } from './widgets/layout/flex-row' +import { UtopiaStyles, UtopiaTheme, useColorTheme } from './styles/theme' +import { color } from './styles/utopitrons' + +interface DialogProps { + title: string + content: React.ReactElement + defaultButton: React.ReactElement + secondaryButton?: React.ReactElement + subduedButton?: React.ReactElement + closeCallback: () => void +} + +export const Dialog = (props: DialogProps) => { + const colorTheme = useColorTheme() + return ( + + +
e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + style={{ + width: '450px', + height: '220px', + transform: 'translateY(-150px)', + backgroundColor: colorTheme.bg2.value, + color: colorTheme.fg1.value, + overflow: 'hidden', + borderRadius: UtopiaTheme.panelStyles.panelBorderRadius, + boxShadow: UtopiaStyles.shadowStyles.highest.boxShadow, + }} + > + + + + {props.title} + + +
+
+ {props.content} +
+
+ + {props.subduedButton} + {props.secondaryButton} + {props.defaultButton} + +
+
+
+
+ ) +} + +const ScreenCenter = styled.div({ + position: 'fixed', + left: '0px', + top: '0px', + right: '0px', + bottom: '0px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}) diff --git a/nexus-builder/packages/navigator/src/uuiui/icn.tsx b/nexus-builder/packages/navigator/src/uuiui/icn.tsx new file mode 100644 index 000000000000..08cb8a04c55c --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/icn.tsx @@ -0,0 +1,250 @@ +import type { Placement } from 'tippy.js' +import React from 'react' +import { getPossiblyHashedURL } from '../utils/hashed-assets' +import { Tooltip } from './tooltip' +import { Substores, useEditorState } from '../components/editor/store/store-hook' +import { getCurrentTheme } from '../components/editor/store/editor-state' +import type { Theme } from './styles/theme' + +export type IcnColor = + | 'main' + | 'secondary' + | 'subdued' + | 'primary' + | 'warning' + | 'error' + | 'component' + | 'component-orange' + | 'on-highlight-main' + | 'on-highlight-secondary' + | 'on-light-main' + | 'darkgray' + | 'black' + | 'white' + | 'overridden' + | 'dynamic' + | 'remix' + | 'green' + | 'lightgreen' + +export type IcnResultingColor = + | 'white' + | 'gray' + | 'darkgray' + | 'lightgray' + | 'black' + | 'blue' + | 'purple' + | 'red' + | 'orange' + | 'colourful' + | 'pink' + | 'lightorange' + | 'lightpurple' + | 'lightblue' + | 'lightpink' + | 'aqua' + | 'lightaqua' + | 'green' + | 'lightgreen' + +function useIconColor(intent: IcnColor): IcnResultingColor { + const currentTheme: Theme = useEditorState( + Substores.theme, + (store) => getCurrentTheme(store.userState), + 'currentTheme', + ) + if (currentTheme === 'light') { + switch (intent) { + case 'main': + return 'black' + case 'secondary': + return 'gray' + case 'subdued': + return 'gray' + case 'primary': + return 'blue' + case 'warning': + return 'orange' + case 'component-orange': + return 'orange' + case 'dynamic': + return 'blue' + case 'error': + return 'red' + case 'overridden': + return 'pink' + case 'component': + return 'purple' + case 'on-highlight-main': + return 'white' + case 'on-highlight-secondary': + return 'lightgray' + case 'on-light-main': + return 'white' + case 'black': + return 'black' + case 'remix': + return 'aqua' + case 'green': + return 'green' + case 'white': + return 'white' + default: + return 'white' + } + } else if (currentTheme === 'dark') { + switch (intent) { + case 'main': + return 'white' + case 'secondary': + return 'lightgray' + case 'subdued': + return 'gray' + case 'primary': + return 'blue' + case 'component': + return 'lightpurple' + case 'error': + return 'red' + case 'overridden': + return 'lightpink' + case 'warning': + return 'orange' + case 'component-orange': + return 'lightorange' + case 'dynamic': + return 'lightblue' + case 'on-highlight-main': + return 'white' + case 'on-highlight-secondary': + return 'darkgray' + case 'on-light-main': + return 'black' + case 'black': + return 'black' + case 'remix': + return 'lightaqua' + case 'green': + return 'lightgreen' + case 'white': + return 'white' + default: + return 'white' + } + } + return 'black' +} + +export interface IcnPropsBase { + category?: string + type: string + width?: number + height?: number +} + +export interface IcnProps extends IcnPropsBase { + color?: IcnColor + style?: React.CSSProperties + className?: string + isDisabled?: boolean + tooltipText?: string + tooltipPlacement?: Placement + onMouseDown?: (event: React.MouseEvent) => void + onClick?: (event: React.MouseEvent) => void + onDoubleClick?: (event: React.MouseEvent) => void + onMouseUp?: (event: React.MouseEvent) => void + onMouseOver?: (event: React.MouseEvent) => void + onMouseLeave?: (event: React.MouseEvent) => void + testId?: string +} + +const defaultIcnWidth = 16 +const defaultIcnHeight = 16 + +/** + * This should only be used by the below component and where + * absolutely needed. Namely: logic that is better handled with + * CSS backgroundImage. Otherwise, use ``. + */ +export function UNSAFE_getIconURL( + type: string, + // TODO colortheme (icons) + color: IcnResultingColor = 'black', + category = 'semantic', + width: number = 16, + height: number = 16, +): string { + const theme = 'light' + return getPossiblyHashedURL( + `/editor/icons/${theme}/${category}/${type}-${color}-${width}x${height}@2x.png`, + ) +} + +export const Icn = React.memo( + ({ + width = defaultIcnWidth, + height = defaultIcnHeight, + isDisabled = false, + ...props + }: IcnProps) => { + const disabledStyle = isDisabled ? { opacity: 0.5 } : undefined + + const iconColor = useIconColor(props.color ?? 'main') + + const { onMouseDown: propsOnMouseDown, onClick } = props + const onMouseDown = React.useCallback( + (e: React.MouseEvent) => { + if (propsOnMouseDown != null) { + propsOnMouseDown(e) + } + if (onClick != null) { + e.stopPropagation() + } + }, + [propsOnMouseDown, onClick], + ) + + const imageElement = ( + + ) + if (props.tooltipText == null) { + return imageElement + } else { + return ( + + {imageElement} + + ) + } + }, +) +Icn.displayName = 'Icon' + +export const IcnSpacer = React.memo( + ({ width = 16, height = 16 }: { width?: number | string; height?: number | string }) => { + return
+ }, +) diff --git a/nexus-builder/packages/navigator/src/uuiui/icon-toggle-button.tsx b/nexus-builder/packages/navigator/src/uuiui/icon-toggle-button.tsx new file mode 100644 index 000000000000..93a4ffffd615 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/icon-toggle-button.tsx @@ -0,0 +1,51 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import { useColorTheme } from './styles/theme' + +interface IconToggleButtonProps { + value: boolean + srcOn: string + srcOff: string + onToggle: (event: React.MouseEvent) => void + className?: string +} + +export const IconToggleButton = React.forwardRef( + (props, ref) => { + const colorTheme = useColorTheme() + const { value, onToggle, srcOn, srcOff, className } = props + return ( +
+ ) + }, +) diff --git a/nexus-builder/packages/navigator/src/uuiui/icons.tsx b/nexus-builder/packages/navigator/src/uuiui/icons.tsx new file mode 100644 index 000000000000..b8594d71a477 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/icons.tsx @@ -0,0 +1,1019 @@ +import React from 'react' +import type { IcnProps } from './icn' +import { Icn } from './icn' +import type { RegularControlType } from 'utopia-api/core' +import { memoize } from '../core/shared/memoize' +import { assertNever } from '../core/shared/utils' + +const makeIcon = ( + appliedProps: IcnProps, +): React.FunctionComponent>> => + React.memo((props) => ) + +/** + * Provides a set of Icon components with overrideable props + * The main props (from ) are + * @param color: white, blue, purple, darkgray, gray, red, others + * @param size: number, typically 18 or 16. For irregular shapes, use width and height instead + * @param disabled: boolean + * @param tooltipText: string. Add to wrap the icon in a tooltip + * as well as all standard React component props, and width and height + */ + +export const LargerIcons = { + MagnifyingGlass: makeIcon({ + type: 'magnifyingglass-larger', + color: 'main', + width: 18, + height: 18, + }), + MagnifyingGlassPlus: makeIcon({ + type: 'magnifyingglass-plus-larger', + color: 'main', + width: 18, + height: 18, + }), + MagnifyingGlassMinus: makeIcon({ + type: 'magnifyingglass-minus-larger', + color: 'main', + width: 18, + height: 18, + }), + Code: makeIcon({ type: 'cody', color: 'main', width: 22, height: 22 }), + Node: makeIcon({ type: 'nodymcnodeface-larger', color: 'main', width: 18, height: 18 }), + DesignTool: makeIcon({ type: 'designtool-larger', color: 'main', width: 18, height: 18 }), + PlayButton: makeIcon({ type: 'playbutton-larger', color: 'main', width: 18, height: 18 }), + PlusButton: makeIcon({ type: 'plusbutton-larger', color: 'main', width: 18, height: 18 }), + Hamburgermenu: makeIcon({ type: 'hamburgermenu-larger', color: 'main', width: 18, height: 18 }), + HamburgermenuRotated: makeIcon({ + type: 'hamburgermenu-rotated-larger', + color: 'main', + width: 18, + height: 18, + }), + Canvas: makeIcon({ type: 'canvas-larger', color: 'main', width: 18, height: 18 }), + Inspector: makeIcon({ type: 'inspector', color: 'main', width: 22, height: 22 }), + Navigator: makeIcon({ type: 'navigator', color: 'main', width: 22, height: 22 }), + StopButton: makeIcon({ type: 'stopbutton', color: 'main', width: 18, height: 18 }), + Refresh: makeIcon({ type: 'refresh-larger', color: 'main', width: 18, height: 18 }), + Mobilephone: makeIcon({ type: 'mobilephone', color: 'main', width: 18, height: 18 }), + ExternalLink: makeIcon({ type: 'externallink', color: 'main', width: 18, height: 18 }), + Divider: makeIcon({ type: 'divider', color: 'subdued', width: 5, height: 18 }), + PreviewPane: makeIcon({ type: 'previewpane', color: 'main', width: 22, height: 18 }), + PixelatedPalm: makeIcon({ + category: 'special', + type: 'palm', + color: 'main', + width: 21, + height: 21, + }), + NpmLogo: makeIcon({ + category: 'special', + type: 'npm', + color: 'main', + width: 28, + height: 11, + }), +} + +export const InspectorSectionIcons = { + Layout: makeIcon({ + category: 'inspector', + type: 'layout', + color: 'main', + width: 16, + height: 16, + }), + LayoutSystem: makeIcon({ + category: 'inspector', + type: 'layout-system', + color: 'main', + width: 16, + height: 16, + }), + Code: makeIcon({ + category: 'element', + type: 'lists', + color: 'main', + width: 18, + height: 18, + }), + Conditionals: makeIcon({ + category: 'element', + type: 'conditional', + color: 'main', + width: 18, + height: 18, + }), + Layer: makeIcon({ + category: 'inspector', + type: 'layer', + color: 'main', + width: 16, + height: 16, + }), + Background: makeIcon({ + category: 'inspector', + type: 'background', + color: 'main', + width: 16, + height: 16, + }), + Border: makeIcon({ + category: 'inspector', + type: 'border', + color: 'main', + width: 16, + height: 16, + }), + Shadow: makeIcon({ + category: 'inspector', + type: 'shadow', + color: 'main', + width: 16, + height: 16, + }), + Typography: makeIcon({ + category: 'inspector', + type: 'typography', + color: 'main', + width: 16, + height: 16, + }), + Transforms: makeIcon({ + category: 'inspector', + type: 'transform', + color: 'main', + width: 16, + height: 16, + }), + TextShadow: makeIcon({ + category: 'inspector', + type: 'text-shadow', + color: 'main', + width: 16, + height: 16, + }), + Image: makeIcon({ + category: 'inspector', + type: 'image', + color: 'main', + width: 16, + height: 16, + }), + Interactions: makeIcon({ + category: 'inspector', + type: 'interactions', + color: 'main', + width: 16, + height: 16, + }), + SplitFull: makeIcon({ + category: 'inspector', + type: 'split-full', + color: 'main', + width: 13, + height: 13, + }), + SplitHalf: makeIcon({ + category: 'inspector', + type: 'split-half', + color: 'main', + width: 13, + height: 13, + }), + SplitQuarter: makeIcon({ + category: 'inspector', + type: 'split-quarter', + color: 'main', + width: 13, + height: 13, + }), +} + +export const SmallerIcons = { + ExpansionArrowDown: makeIcon({ + category: 'controls/input', + type: 'down', + color: 'subdued', + width: 11, + height: 11, + }), + ExpansionArrowRight: makeIcon({ + category: 'semantic', + type: 'expansionarrow-small-right', + color: 'main', + width: 12, + height: 12, + }), +} + +export const Icons = { + Bin: makeIcon({ type: 'bin', color: 'main' }), + BracketedPointer: makeIcon({ type: 'bracketed-pointer', color: 'main' }), + Cross: makeIcon({ type: 'cross-medium', color: 'main' }), + Cube: makeIcon({ type: 'd', color: 'main' }), + Checkmark: makeIcon({ type: 'checkmark', color: 'main' }), + DragHandle: makeIcon({ type: 'draghandle', color: 'main' }), + Degree: makeIcon({ category: 'inspector-element', type: 'degree', color: 'main' }), + Code: makeIcon({ type: 'codymccodeface-larger', color: 'main' }), + EditPencil: makeIcon({ type: 'editpencil', color: 'main' }), + ExpansionArrow: makeIcon({ type: 'expansionarrow-down', color: 'main' }), + ExpansionArrowControlled: makeIcon({ type: 'expansionarrow-down', color: 'primary' }), + ExpansionArrowDown: makeIcon({ type: 'expansionarrow-down', color: 'main' }), + ExpansionArrowRight: makeIcon({ type: 'expansionarrow-right', color: 'main' }), + ExpansionArrowRightWhite: makeIcon({ type: 'expansionarrow-right', color: 'white' }), + NarrowExpansionArrowRight: makeIcon({ + category: 'navigator-element', + type: 'right-facing-caret', + color: 'main', + width: 12, + height: 12, + }), + ExternalLink: makeIcon({ type: 'externallink', color: 'main' }), + ExternalLinkSmaller: makeIcon({ type: 'externallink-smaller', color: 'main' }), + EyeStrikethrough: makeIcon({ type: 'eye-strikethrough', color: 'main' }), + EyeOpen: makeIcon({ type: 'eyeopen', color: 'main' }), + Flip: makeIcon({ type: 'flip', color: 'main' }), + FourDots: makeIcon({ type: 'fourdots', color: 'main' }), + FlexRow: makeIcon({ + type: 'flexDirection-row-regular-nowrap', + color: 'main', + category: 'layout/flex', + }), + FlexColumn: makeIcon({ + type: 'flexDirection-column-regular-nowrap', + color: 'main', + category: 'layout/flex', + }), + Downloaded: makeIcon({ type: 'downloaded', color: 'main', width: 18, height: 18 }), + Gear: makeIcon({ type: 'gear', color: 'main' }), + GroupClosed: makeIcon({ + type: 'group-closed', + category: 'element', + color: 'main', + width: 18, + height: 18, + }), + FolderClosed: makeIcon({ + type: 'folder-closed', + category: 'filetype', + color: 'main', + width: 12, + height: 12, + }), + Threedots: makeIcon({ type: 'threedots', color: 'main' }), + LinkClosed: makeIcon({ type: 'link-closed', color: 'main' }), + LinkStrikethrough: makeIcon({ type: 'link-strikethrough', color: 'main' }), + LockClosed: makeIcon({ type: 'lockclosed', color: 'main' }), + LockClosedDot: makeIcon({ type: 'lockcloseddot', color: 'main' }), + LockOpen: makeIcon({ type: 'lockopen', color: 'main' }), + Dot: makeIcon({ type: 'dot', color: 'main' }), + PinFilled: makeIcon({ type: 'pinfilled', color: 'main' }), + PinLeftFilled: makeIcon({ type: 'pinleftfilled', color: 'main' }), + PinRightFilled: makeIcon({ type: 'pinrightfilled', color: 'main' }), + PinOutline: makeIcon({ type: 'pinoutline', color: 'main' }), + PinLeftOutline: makeIcon({ type: 'pinleftoutline', color: 'main' }), + PinRightOutline: makeIcon({ type: 'pinrightoutline', color: 'main' }), + Pipette: makeIcon({ type: 'pipette', color: 'main' }), + SmallPipette: makeIcon({ + type: 'pipette', + color: 'main', + category: 'inspector-element', + width: 16, + height: 16, + }), + Minus: makeIcon({ type: 'minus', color: 'main' }), + SmallMinus: makeIcon({ type: 'minus', color: 'main', width: 12, height: 12 }), + Plus: makeIcon({ type: 'plus', color: 'main' }), + SmallPlus: makeIcon({ type: 'plus', color: 'main', width: 12, height: 12 }), + Play: makeIcon({ type: 'play', color: 'main' }), + React: makeIcon({ type: 'react', color: 'primary' }), + Refresh: makeIcon({ type: 'refresh', color: 'main' }), + SmallCross: makeIcon({ type: 'cross-small', color: 'main' }), + Smiangle: makeIcon({ type: 'smiangle', color: 'primary' }), + WarningTriangle: makeIcon({ type: 'warningtriangle', color: 'main' }), + DotDotDot: makeIcon({ type: 'dotdotdot', color: 'main' }), + ConvertObject: makeIcon({ type: 'convertobject', color: 'main' }), + NewTextFile: makeIcon({ + category: 'filetype', + type: 'other', + width: 12, + height: 12, + }), + NewFolder: makeIcon({ + category: 'filetype', + type: 'folder-small', + width: 18, + height: 18, + }), + NewUIFile: makeIcon({ + category: 'filetype', + type: 'ui', + width: 18, + height: 18, + }), + NewUIJSFile: makeIcon({ + category: 'filetype', + type: 'ui', + width: 18, + height: 18, + }), + NewImageAsset: makeIcon({ + category: 'filetype', + type: 'img', + width: 18, + height: 18, + }), + Component: makeIcon({ + category: 'element', + type: 'component', + width: 18, + height: 18, + }), + ComponentInstance: makeIcon({ + category: 'element', + type: 'componentinstance', + width: 18, + height: 18, + }), + CircleSmall: makeIcon({ type: 'circle-small', color: 'secondary' }), + CrossSmall: makeIcon({ type: 'cross-small', color: 'secondary' }), + CrossInTranslucentCircle: makeIcon({ type: 'cross-in-translucent-circle', color: 'main' }), + Download: makeIcon({ + category: 'semantic', + type: 'download', + width: 18, + height: 18, + color: 'main', + }), + Upload: makeIcon({ + category: 'semantic', + type: 'upload', + width: 18, + height: 18, + color: 'main', + }), + Branch: makeIcon({ + category: 'semantic', + type: 'branch', + width: 18, + height: 18, + color: 'main', + }), + GroupDed: makeIcon({ + type: 'group-ded', + color: 'main', + category: 'element', + width: 18, + height: 18, + }), + GroupProblematic: makeIcon({ + type: 'group-problematic', + color: 'main', + category: 'element', + width: 18, + height: 18, + }), + ExclamationMark: makeIcon({ + type: 'exclamationmark', + color: 'main', + category: 'semantic', + width: 3, + height: 8, + }), + StringInputControl: makeIcon({ + category: 'controltype', + type: 'string-input', + color: 'main', + width: 18, + height: 18, + }), + NavigatorText: makeIcon({ + category: 'navigator-element', + type: 'text', + color: 'main', + width: 12, + height: 12, + }), + NavigatorData: makeIcon({ + category: 'navigator-element', + type: 'data', + color: 'main', + width: 12, + height: 12, + }), + Italic: makeIcon({ + category: 'inspector-element', + type: 'italic', + color: 'main', + width: 16, + height: 16, + }), + Underline: makeIcon({ + category: 'inspector-element', + type: 'underline', + color: 'main', + width: 16, + height: 16, + }), + Strikethrough: makeIcon({ + category: 'inspector-element', + type: 'strikethrough', + color: 'main', + width: 16, + height: 16, + }), + FontStyleSerif: makeIcon({ + category: 'inspector-element', + type: 'fontStyle-serif', + color: 'main', + width: 16, + height: 16, + }), + FontStyleScript: makeIcon({ + category: 'inspector-element', + type: 'fontStyle-script', + color: 'main', + width: 16, + height: 16, + }), + FontStyleMonospace: makeIcon({ + category: 'inspector-element', + type: 'fontStyle-monospace', + color: 'main', + width: 16, + height: 16, + }), + FontStyleSansSerif: makeIcon({ + category: 'inspector-element', + type: 'fontStyle-sansSerif', + color: 'main', + width: 16, + height: 16, + }), + TextAlignLeft: makeIcon({ + category: 'inspector-element', + type: 'textAlign-left', + color: 'main', + width: 16, + height: 16, + }), + TextAlignCenter: makeIcon({ + category: 'inspector-element', + type: 'textAlign-center', + color: 'main', + width: 16, + height: 16, + }), + TextAlignRight: makeIcon({ + category: 'inspector-element', + type: 'textAlign-right', + color: 'main', + width: 16, + height: 16, + }), + FlexDirectionRow: makeIcon({ + category: 'inspector-element', + type: 'arrowRight', + color: 'main', + width: 16, + height: 16, + }), + FlexDirectionRowReverse: makeIcon({ + category: 'inspector-element', + type: 'arrowLeft', + color: 'main', + width: 16, + height: 16, + }), + FlexDirectionColumn: makeIcon({ + category: 'inspector-element', + type: 'arrowDown', + color: 'main', + width: 16, + height: 16, + }), + FlexDirectionColumnReverse: makeIcon({ + category: 'inspector-element', + type: 'arrowUp', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsRowCenter: makeIcon({ + category: 'inspector-element', + type: 'alignItems-row-center', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsRowFlexStart: makeIcon({ + category: 'inspector-element', + type: 'alignItems-row-flexStart', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsRowFlexEnd: makeIcon({ + category: 'inspector-element', + type: 'alignItems-row-flexEnd', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsRowStretch: makeIcon({ + category: 'inspector-element', + type: 'alignItems-row-stretch', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsRowBaseline: makeIcon({ + category: 'inspector-element', + type: 'alignItems-row-baseline', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsColumnCenter: makeIcon({ + category: 'inspector-element', + type: 'alignItems-column-center', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsColumnFlexStart: makeIcon({ + category: 'inspector-element', + type: 'alignItems-column-flexStart', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsColumnFlexEnd: makeIcon({ + category: 'inspector-element', + type: 'alignItems-column-flexEnd', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsColumnStretch: makeIcon({ + category: 'inspector-element', + type: 'alignItems-column-stretch', + color: 'main', + width: 16, + height: 16, + }), + AlignItemsColumnBaseline: makeIcon({ + category: 'inspector-element', + type: 'alignItems-column-baseline', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentRowCenter: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-row-center', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentRowFlexStart: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-row-flexstart', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentRowFlexEnd: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-row-flexend', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentRowSpaceBetween: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-row-spaceBetween', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentRowSpaceAround: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-row-spaceAround', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentRowSpaceEvenly: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-row-spaceEvenly', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentColumnCenter: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-column-center', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentColumnFlexStart: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-column-flexstart', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentColumnFlexEnd: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-column-flexend', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentColumnSpaceBetween: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-column-spaceBetween', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentColumnSpaceAround: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-column-spaceAround', + color: 'main', + width: 16, + height: 16, + }), + JustifyContentColumnSpaceEvenly: makeIcon({ + category: 'inspector-element', + type: 'justifyContent-column-spaceEvenly', + color: 'main', + width: 16, + height: 16, + }), + BorderRadius: makeIcon({ + category: 'inspector-element', + type: 'borderRadius', + color: 'main', + width: 16, + height: 16, + }), + BorderRadiusBottomLeft: makeIcon({ + category: 'inspector-element', + type: 'borderRadius-bottomLeft', + color: 'main', + width: 16, + height: 16, + }), + BorderRadiusBottomRight: makeIcon({ + category: 'inspector-element', + type: 'borderRadius-bottomRight', + color: 'main', + width: 16, + height: 16, + }), + BorderRadiusTopLeft: makeIcon({ + category: 'inspector-element', + type: 'borderRadius-topLeft', + color: 'main', + width: 16, + height: 16, + }), + BorderRadiusTopRight: makeIcon({ + category: 'inspector-element', + type: 'borderRadius-topRight', + color: 'main', + width: 16, + height: 16, + }), + Padding: makeIcon({ + category: 'inspector-element', + type: 'padding', + color: 'main', + width: 16, + height: 16, + }), + PaddingVertical: makeIcon({ + category: 'inspector-element', + type: 'paddingVertical', + color: 'main', + width: 16, + height: 16, + }), + PaddingHorizontal: makeIcon({ + category: 'inspector-element', + type: 'paddingHorizontal', + color: 'main', + width: 16, + height: 16, + }), + PaddingTop: makeIcon({ + category: 'inspector-element', + type: 'paddingTop', + color: 'main', + width: 16, + height: 16, + }), + PaddingBottom: makeIcon({ + category: 'inspector-element', + type: 'paddingBottom', + color: 'main', + width: 16, + height: 16, + }), + PaddingLeft: makeIcon({ + category: 'inspector-element', + type: 'paddingLeft', + color: 'main', + width: 16, + height: 16, + }), + PaddingRight: makeIcon({ + category: 'inspector-element', + type: 'paddingRight', + color: 'main', + width: 16, + height: 16, + }), + GapVertical: makeIcon({ + category: 'inspector-element', + type: 'gapVertical', + color: 'main', + width: 16, + height: 16, + }), + GapHorizontal: makeIcon({ + category: 'inspector-element', + type: 'gapHorizontal', + color: 'main', + width: 16, + height: 16, + }), + WrapRow: makeIcon({ + category: 'inspector-element', + type: 'wrapRow', + color: 'main', + width: 16, + height: 16, + }), + WrapColumn: makeIcon({ + category: 'inspector-element', + type: 'wrapColumn', + color: 'main', + width: 16, + height: 16, + }), + LetterSpacing: makeIcon({ + category: 'inspector-element', + type: 'letterSpacing', + color: 'main', + width: 16, + height: 16, + }), + LineHeight: makeIcon({ + category: 'inspector-element', + type: 'lineHeight', + color: 'main', + width: 16, + height: 16, + }), + StrikeThrough: makeIcon({ + category: 'inspector-element', + type: 'strikethrough', + color: 'main', + width: 16, + height: 16, + }), + RowSpan: makeIcon({ + category: 'inspector-element', + type: 'rowSpan', + width: 16, + height: 16, + color: 'main', + }), + ColumnSpan: makeIcon({ + category: 'inspector-element', + type: 'columnSpan', + width: 16, + height: 16, + color: 'main', + }), +} as const + +export const FunctionIcons = { + Add: Icons.Plus, + Remove: Icons.Minus, + Delete: Icons.SmallCross, + Confirm: Icons.Checkmark, + Close: Icons.SmallCross, + Drag: Icons.DragHandle, + Edit: Icons.EditPencil, + Expand: Icons.ExpansionArrowRight, + Expanded: Icons.ExpansionArrowDown, + Refresh: Icons.Refresh, + Reset: Icons.Refresh, + RefreshingAnimated: Icons.Refresh, +} + +export const ModalityIcons = { + MoveAbsolute: makeIcon({ + category: 'modalities', + type: 'moveabs-large', + color: 'main', + width: 18, + height: 18, + }), + Reorder: makeIcon({ + category: 'modalities', + type: 'reorder-large', + color: 'main', + width: 18, + height: 18, + }), + Reparent: makeIcon({ + category: 'modalities', + type: 'reparent-large', + color: 'main', + width: 18, + height: 18, + }), + Replant: makeIcon({ + category: 'modalities', + type: 'replant-large', + color: 'main', + width: 18, + height: 18, + }), + Magic: makeIcon({ + category: 'modalities', + type: 'magic-large', + color: 'main', + width: 18, + height: 18, + }), +} + +export const MenuIcons = { + Menu: makeIcon({ + category: 'semantic', + type: 'hamburgermenu', + width: 24, + height: 24, + color: 'main', + }), + Smiangle: makeIcon({ + category: 'semantic', + type: 'smiangle', + width: 24, + height: 24, + color: 'main', + }), + Octocat: makeIcon({ + category: 'semantic', + type: 'octocat', + width: 24, + height: 24, + color: 'main', + }), + TwoGhosts: makeIcon({ + category: 'semantic', + type: 'twoghosts', + width: 24, + height: 24, + color: 'main', + }), + FileSkewed: makeIcon({ + category: 'semantic', + type: 'file-skewed', + width: 24, + height: 24, + color: 'main', + }), + CodeSkewed: makeIcon({ + category: 'semantic', + type: 'code-skewed', + width: 24, + height: 24, + color: 'main', + }), + Pyramid: makeIcon({ + category: 'semantic', + type: 'pyramid', + width: 24, + height: 24, + color: 'main', + }), + Insert: makeIcon({ + category: 'semantic', + type: 'pluscube', + width: 24, + height: 24, + color: 'main', + }), + ExternalLink: makeIcon({ + category: 'semantic', + type: 'externallink-large', + width: 24, + height: 24, + }), + Project: makeIcon({ + category: 'semantic', + type: 'closedcube', + width: 24, + height: 24, + color: 'main', + }), + Folder: makeIcon({ + category: 'semantic', + type: 'openfolder', + width: 24, + height: 24, + color: 'main', + }), + Filestack: makeIcon({ + category: 'semantic', + type: 'filestack', + width: 24, + height: 24, + color: 'main', + }), + Settings: makeIcon({ + category: 'semantic', + type: 'gear', + width: 24, + height: 24, + color: 'main', + }), + Columns: makeIcon({ + category: 'semantic', + type: 'columnmenu', + width: 24, + height: 24, + color: 'main', + }), + Play: makeIcon({ + category: 'semantic', + type: 'playbutton', + width: 24, + height: 24, + color: 'main', + }), + Navigator: makeIcon({ + category: 'semantic', + type: 'navigator', + width: 16, + height: 16, + color: 'main', + }), +} + +export const iconForControlType = memoize( + (controlType: RegularControlType): ReturnType => { + switch (controlType) { + case 'color': + case 'expression-input': + case 'expression-popuplist': + case 'html-input': + case 'jsx': + case 'none': + case 'number-input': + case 'string-input': + case 'style-controls': + return makeIcon({ + category: 'controltype', + type: controlType, + width: 18, + height: 18, + color: 'on-highlight-main', + }) + case 'array': + case 'checkbox': + case 'euler': + case 'matrix3': + case 'matrix4': + case 'object': + case 'popuplist': + case 'radio': + case 'tuple': + case 'union': + case 'vector2': + case 'vector3': + case 'vector4': + // fallback icon for the missing cases + return makeIcon({ + category: 'controltype', + type: 'expression-input', + width: 18, + height: 18, + color: 'on-highlight-main', + }) + default: + // If we add a new control type, make sure it has an icon! + assertNever(controlType) + } + }, +) diff --git a/nexus-builder/packages/navigator/src/uuiui/index.tsx b/nexus-builder/packages/navigator/src/uuiui/index.tsx new file mode 100644 index 000000000000..f54530e7fc60 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/index.tsx @@ -0,0 +1,30 @@ +// put everything here that you want to export as in the root uuiui module +export * from './styles/theme' +export * from './styles/css-vars' +export * from './styles/layout-styles' +export * from './styles/utopi-color-helpers' +export * from './styles/utopitrons' +export * from './button' +export * from './dialog' +export * from './icn' +export * from './icons' +export * from './isolator' +export * from './tab' +export * from './tooltip' +export * from './widgets/actionsheet/actionsheet' +export * from './widgets/avatar/avatar' +export * from './widgets/controlled-textarea' +export * from './widgets/headings/headings' +export * from './widgets/layout/common-layout-shorthands' +export * from './widgets/layout/flex-column' +export * from './widgets/layout/flex-row' +export * from './widgets/layout/tile' +export * from './widgets/layout/resizable-flex-components' +export * from './widgets/popup-list/popup-list' +export * from './widgets/section/section' +export * from './widgets/utopia-list-select/utopia-list-select' +export * from './inputs/checkbox-input' +export * from './inputs/number-input' +export * from './inputs/string-input' +export * from './utilities/on-click-outside-hoc' +export * from './widgets/layout/ui-row' diff --git a/nexus-builder/packages/navigator/src/uuiui/inline-button.tsx b/nexus-builder/packages/navigator/src/uuiui/inline-button.tsx new file mode 100644 index 000000000000..8ce328b26052 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inline-button.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import styled from '@emotion/styled' +//TODO: refactor components to functional components and use 'useColorTheme()': +import { colorTheme, UtopiaTheme } from './styles/theme' + +export const InlineLink = styled.a({ + color: colorTheme.primary.value, + textDecoration: 'none', + cursor: 'pointer', + padding: '0 2px', + '&:hover': { + textDecoration: 'underline', + }, + '&:visited': { + color: colorTheme.primary.value, + }, + '&:focus': { + textDecoration: 'underline', + }, +}) + +export const InlineButton = styled.button({ + fontSize: 11, + fontFamily: 'utopian-inter', + color: colorTheme.primary.value, + background: 'transparent', + border: 'none', + outline: 'none', + cursor: 'pointer', + paddingLeft: 2, + paddingRight: 2, + borderRadius: UtopiaTheme.inputBorderRadius, + position: 'relative', + '&:hover': { + borderRadius: 1, + background: colorTheme.canvasElementBackground.value, + }, + '&:focus': { + outline: 'none', + }, + '&:active': { + background: colorTheme.primary.value, + color: colorTheme.neutralInvertedForeground.value, + outline: 'none', + }, +}) + +export const InlineIndicator = styled.div<{ + shouldIndicate: boolean +}>((props) => ({ + fontSize: 11, + fontFamily: 'utopian-inter', + background: 'transparent', + border: 'none', + outline: 'none', + paddingLeft: 2, + paddingRight: 2, + color: props.shouldIndicate + ? colorTheme.primary.value + : colorTheme.canvasControlsInlineIndicatorInactive.value, +})) + +export const InlineToggleButton = styled(InlineButton)<{ + toggleValue: boolean +}>((props) => ({ + color: props.toggleValue + ? colorTheme.primary.value + : colorTheme.canvasControlsInlineToggleUnsetText.value, + '&:hover': { + background: colorTheme.canvasControlsInlineToggleHoverBackground.value, + color: colorTheme.canvasControlsInlineToggleHoverText.value, + }, + '&:active': { + background: colorTheme.canvasControlsInlineToggleActiveBackground.value, + }, +})) diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/base-input.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/base-input.tsx new file mode 100644 index 000000000000..250e2d57574f --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/base-input.tsx @@ -0,0 +1,185 @@ +import type { CSSObject } from '@emotion/styled' +import styled from '@emotion/styled' +import { getChainSegmentEdge } from '../../utils/utils' +import type { ControlStyles, ControlStatus } from '../../uuiui-deps' +import { colorTheme, UtopiaTheme } from '../styles/theme' +import React from 'react' +import { dataPasteHandler } from '../../utils/paste-handler' + +export type ChainedType = 'not-chained' | 'first' | 'last' | 'middle' + +export type BoxCorners = + | 'right' + | 'left' + | 'top' + | 'bottom' + | 'topLeft' + | 'topRight' + | 'bottomRight' + | 'bottomLeft' + | 'none' + | 'all' + +function getChainedBoxShadow( + controlStyles: ControlStyles, + chained: ChainedType, + focused: boolean, + hovered: boolean, +) { + const controlStatusEdges = getChainSegmentEdge(controlStyles) + const hoveredBoxShadow = `0 0 0 1px ${colorTheme.inspectorHoverColor.value} inset` + const focusedBoxShadow = `0 0 0 1px ${colorTheme.inspectorFocusedColor.value} inset` + + const standardBoxShadow = { + boxShadow: focused + ? focusedBoxShadow + : hovered + ? hoveredBoxShadow + : `0 0 0 1px ${controlStyles.borderColor} inset`, + } + if (controlStyles.interactive) { + switch (chained) { + case 'not-chained': { + return standardBoxShadow + } + case 'first': { + return { + boxShadow: focused + ? focusedBoxShadow + : hovered + ? hoveredBoxShadow + : `${controlStatusEdges.top}, ${controlStatusEdges.bottom}, ${controlStatusEdges.left}`, + } + } + case 'middle': { + return { + boxShadow: focused + ? focusedBoxShadow + : hovered + ? hoveredBoxShadow + : `${controlStatusEdges.top}, ${controlStatusEdges.bottom}`, + } + } + case 'last': { + return { + boxShadow: focused + ? focusedBoxShadow + : hovered + ? hoveredBoxShadow + : `${controlStatusEdges.top}, ${controlStatusEdges.bottom}, ${controlStatusEdges.right}`, + } + } + default: { + const _exhaustiveCheck: never = chained + return standardBoxShadow + } + } + } else { + return standardBoxShadow + } +} + +export function getBorderRadiusStyles(chained: ChainedType, rc: BoxCorners) { + return { + borderRadius: chained !== 'not-chained' || rc != null ? 0 : UtopiaTheme.inputBorderRadius, + borderTopRightRadius: + rc === 'all' || rc === 'right' || rc === 'topRight' || rc === 'top' + ? UtopiaTheme.inputBorderRadius + : undefined, + borderBottomRightRadius: + rc === 'all' || rc === 'right' || rc === 'bottomRight' || rc === 'bottom' + ? UtopiaTheme.inputBorderRadius + : undefined, + borderTopLeftRadius: + rc === 'all' || rc === 'left' || rc === 'topLeft' || rc === 'top' + ? UtopiaTheme.inputBorderRadius + : undefined, + borderBottomLeftRadius: + rc === 'all' || rc === 'left' || rc === 'bottomLeft' || rc === 'bottom' + ? UtopiaTheme.inputBorderRadius + : undefined, + } +} + +export interface BaseInputProps { + focusOnMount?: boolean + inputProps?: React.InputHTMLAttributes + testId: string +} + +interface InspectorInputProps extends React.InputHTMLAttributes { + testId: string + chained?: ChainedType + controlStyles: ControlStyles + controlStatus: ControlStatus + focused: boolean + hasLabel: boolean + roundCorners?: BoxCorners + mixed?: boolean + value?: string | readonly string[] | number + pasteHandler?: boolean +} + +type InspectorInputEmotionStyleProps = { + chained?: ChainedType + controlStyles: ControlStyles + hasLabel: boolean + roundCorners?: BoxCorners +} + +export const InspectorInputEmotionStyle = ({ + chained = 'not-chained', + controlStyles, + roundCorners = 'all', +}: InspectorInputEmotionStyleProps): CSSObject => ({ + outline: 'none', + padding: '2px 4px', + background: 'transparent', + fontStyle: controlStyles.fontStyle, + fontWeight: controlStyles.fontWeight, + border: 0, + height: UtopiaTheme.layout.inputHeight.default, + width: '100%', + marginBottom: 0, + // ...getChainedBoxShadow(controlStyles, chained, focused, false), + ...getBorderRadiusStyles(chained, roundCorners), + disabled: !controlStyles.interactive, + '&:hover': { + // ...getChainedBoxShadow(controlStyles, chained, focused, true), + }, + '&:focus': {}, +}) + +const StyledInput = styled.input(InspectorInputEmotionStyle) + +export const InspectorInput = React.memo( + React.forwardRef((props, ref) => { + return ( + + ) + }), +) + +export const MixedPlaceholder = 'Mixed' +export const UnknownPlaceholder = 'Unknown' +export const InvalidPlaceholder = 'Invalid' + +export function getControlStylesAwarePlaceholder(controlStyles: ControlStyles): string | undefined { + if (controlStyles.unknown) { + return UnknownPlaceholder + } + if (controlStyles.mixed) { + return MixedPlaceholder + } + if (controlStyles.invalid) { + return InvalidPlaceholder + } + return undefined +} diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/checkbox-input.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/checkbox-input.tsx new file mode 100644 index 000000000000..55938b1762f6 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/checkbox-input.tsx @@ -0,0 +1,93 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import composeRefs from '@seznam/compose-react-refs' +import React from 'react' +import { jsx } from '@emotion/react' +import type { ControlStatus } from '../../components/inspector/common/control-status' +import type { ControlStyles } from '../../components/inspector/common/control-styles' +import { getControlStyles } from '../../components/inspector/common/control-styles' +import { useColorTheme, UtopiaTheme } from '../styles/theme' +import { useControlsDisabledInSubtree } from '../utilities/disable-subtree' + +export interface CheckboxInputProps extends React.InputHTMLAttributes { + controlStatus?: ControlStatus + focusOnMount?: boolean +} + +// TODO FIX MY REFs +export const CheckboxInput = React.memo( + React.forwardRef( + ({ controlStatus = 'simple', focusOnMount = false, style, ...props }, propsRef) => { + const ref = React.useRef(null) + + const controlStyles: ControlStyles = getControlStyles(controlStatus) + + const controlsDisabled = useControlsDisabledInSubtree() + const disabled = !controlStyles.interactive || controlsDisabled + + React.useEffect(() => { + if (ref.current != null) { + ref.current.indeterminate = controlStyles.mixed + } + }) + + React.useEffect(() => { + if (focusOnMount && ref.current != null) { + ref.current.focus() + } + }, [focusOnMount, ref]) + + const colorTheme = useColorTheme() + const checked = + props.checked != null && (controlStyles.interactive || controlStyles.showContent) + ? props.checked + : false + + return ( + + ) + }, + ), +) diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/grid-expression-input.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/grid-expression-input.tsx new file mode 100644 index 000000000000..12a8cbaddcb1 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/grid-expression-input.tsx @@ -0,0 +1,258 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import type { CSSProperties } from 'react' +import React from 'react' +import type { CSSNumberUnit } from '../../components/inspector/common/css-utils' +import { + cssKeyword, + gridDimensionsAreEqual, + isFR, + isGridCSSNumber, + isValidGridDimensionKeyword, + parseCSSNumber, + parseGridCSSMinmaxOrRepeat, + stringifyGridDimension, + type CSSKeyword, + type CSSNumber, + type GridDimension, + type ValidGridDimensionKeyword, +} from '../../components/inspector/common/css-utils' +import { isRight } from '../../core/shared/either' +import { StringInput } from './string-input' +import type { DropdownMenuItem } from '../radix-components' +import { + DropdownMenu, + regularDropdownMenuItem, + separatorDropdownMenuItem, +} from '../radix-components' +import { Icons, SmallerIcons } from '../icons' +import { NO_OP } from '../../core/shared/utils' +import { unless } from '../../utils/react-conditionals' +import { useColorTheme, UtopiaTheme } from '../styles/theme' +import type { Optic } from '../../core/shared/optics/optics' +import { fromField, fromTypeGuard, notNull } from '../../core/shared/optics/optic-creators' +import { exists, modify } from '../../core/shared/optics/optic-utilities' + +interface GridExpressionInputProps { + testId: string + value: GridDimension + onUpdateNumberOrKeyword: (v: CSSNumber | CSSKeyword) => void + onUpdateDimension: (v: GridDimension) => void + onFocus: () => void + onBlur: () => void + keywords: Array<{ label: string; value: CSSKeyword }> + style?: CSSProperties + defaultValue: GridDimension +} + +const DropdownWidth = 25 + +const ArrowKeyFractionalIncrement = 0.1 + +export const GridExpressionInput = React.memo( + ({ + testId, + value, + onUpdateNumberOrKeyword, + onUpdateDimension, + onFocus, + onBlur, + keywords, + style = {}, + defaultValue, + }: GridExpressionInputProps) => { + const colorTheme = useColorTheme() + + const [printValue, setPrintValue] = React.useState(stringifyGridDimension(value)) + React.useEffect(() => setPrintValue(stringifyGridDimension(value)), [value]) + + const onChange = React.useCallback((e: React.ChangeEvent) => { + setPrintValue(e.target.value) + }, []) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'Enter': + if (isValidGridDimensionKeyword(printValue)) { + return onUpdateNumberOrKeyword(cssKeyword(printValue)) + } + + const defaultUnit = isGridCSSNumber(value) ? value.value.unit : 'px' + const maybeNumber = parseCSSNumber(printValue, 'AnyValid', defaultUnit) + if (isRight(maybeNumber)) { + return onUpdateNumberOrKeyword(maybeNumber.value) + } + + const maybeMinmax = parseGridCSSMinmaxOrRepeat(printValue) + if (maybeMinmax != null) { + return onUpdateDimension({ + ...maybeMinmax, + lineName: value.lineName, + } as GridDimension) + } + + if (printValue === '') { + return onUpdateNumberOrKeyword(cssKeyword('auto')) + } + + setPrintValue(stringifyGridDimension(value)) + break + case 'ArrowUp': + case 'ArrowDown': + e.preventDefault() + const gridNumberValueOptic: Optic = fromTypeGuard( + isGridCSSNumber, + ).compose(fromField('value')) + const valueUnitOptic: Optic = gridNumberValueOptic + .compose(fromField('unit')) + .compose(notNull()) + .compose(fromTypeGuard(isFR)) + const gridNumberNumberOptic: Optic = + gridNumberValueOptic.compose(fromField('value')) + if (exists(valueUnitOptic, value)) { + function updateFractional(fractionalValue: number): number { + return ( + fractionalValue + + (e.key === 'ArrowUp' ? ArrowKeyFractionalIncrement : -ArrowKeyFractionalIncrement) + ) + } + const updatedDimension = modify(gridNumberNumberOptic, updateFractional, value) + onUpdateDimension(updatedDimension) + } + break + } + }, + [printValue, onUpdateNumberOrKeyword, onUpdateDimension, value], + ) + + const [hover, setHover] = React.useState(false) + const [dropdownOpen, setDropdownOpen] = React.useState(false) + const onMouseOver = React.useCallback(() => { + setHover(true) + }, []) + const onMouseOut = React.useCallback(() => { + setHover(false) + }, []) + + const dropdownButtonId = `${testId}-dropdown` + + const dropdownButton = React.useCallback( + () => ( + + ), + [dropdownButtonId, hover, dropdownOpen], + ) + + const dropdownItems = React.useMemo((): DropdownMenuItem[] => { + let items: DropdownMenuItem[] = [] + items.push( + regularDropdownMenuItem({ + id: 'dropdown-input-value', + icon: , + label: printValue, + disabled: true, + onSelect: NO_OP, + }), + ) + if (keywords.length > 0) { + items.push(separatorDropdownMenuItem('dropdown-separator')) + } + items.push( + ...keywords.map((keyword, idx): DropdownMenuItem => { + return regularDropdownMenuItem({ + id: `dropdown-label-${keyword.value.value}`, + icon:
, + label: keyword.label, + onSelect: () => onUpdateNumberOrKeyword(keyword.value), + }) + }), + ) + return items + }, [keywords, printValue, onUpdateNumberOrKeyword]) + + const [inputFocused, setInputFocused] = React.useState(false) + + const inputOnFocus = React.useCallback(() => { + setInputFocused(true) + onFocus() + }, [onFocus]) + + const inputOnBlur = React.useCallback(() => { + setInputFocused(false) + onBlur() + }, [onBlur]) + + const isDefault = React.useMemo(() => { + return gridDimensionsAreEqual(value, defaultValue) + }, [value, defaultValue]) + + const highlightBorder = dropdownOpen || inputFocused + + return ( +
+ + {unless( + inputFocused, +
+ +
, + )} +
+ ) + }, +) +GridExpressionInput.displayName = 'GridExpressionInput' diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/new-number-or-keyword-input.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/new-number-or-keyword-input.tsx new file mode 100644 index 000000000000..e25d48400a60 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/new-number-or-keyword-input.tsx @@ -0,0 +1,112 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import type { + CSSKeyword, + CSSNumber, + UnknownOrEmptyInput, +} from '../../components/inspector/common/css-utils' +import { + emptyInputValue, + isCSSNumber, + isEmptyInputValue, + isUnknownInputValue, + parseCSSNumber, + unknownInputValue, +} from '../../components/inspector/common/css-utils' +import type { + InspectorControlProps, + OnSubmitValue, + OnSubmitValueOrUnknownOrEmpty, +} from '../../components/inspector/controls/control' +import { isRight } from '../../core/shared/either' +import { StringInput } from './string-input' +import { Icn, type IcnProps } from '../icn' + +export interface NumberOrKeywordInputProps extends InspectorControlProps { + value: CSSNumber | CSSKeyword + onSubmitValue: OnSubmitValueOrUnknownOrEmpty> + validKeywords: Array> + incrementalControls?: boolean + showBorder?: boolean + labelInner?: string | IcnProps +} + +export function NumberOrKeywordInput(props: NumberOrKeywordInputProps) { + const { onSubmitValue: propsOnSubmitValue } = props + + const [stringValue, setStringValue] = React.useState(toStringValue(props.value)) + React.useEffect(() => setStringValue(toStringValue(props.value)), [props.value]) + + const onSubmitValue: OnSubmitValue> = React.useCallback( + (value) => { + const parsedNewValue = parseInput(value, props.validKeywords) + if (isUnknownInputValue(parsedNewValue)) { + setStringValue(toStringValue(props.value)) + propsOnSubmitValue(parsedNewValue.value === '' ? emptyInputValue() : parsedNewValue) + } else { + propsOnSubmitValue(parsedNewValue) + } + }, + [props.value, props.validKeywords, propsOnSubmitValue], + ) + + const onChange = React.useCallback( + (e: React.ChangeEvent) => setStringValue(e.target.value), + [], + ) + + return ( +
+
+ {typeof props.labelInner === 'object' && 'type' in props.labelInner ? ( + + ) : ( + props.labelInner + )} +
+ +
+ ) +} + +function parseInput( + value: UnknownOrEmptyInput, + validKeywords: Array>, +): UnknownOrEmptyInput> { + if (!isUnknownInputValue(value) && !isEmptyInputValue(value)) { + const parsedNumber = parseCSSNumber(value, 'AnyValid', null) + if (isRight(parsedNumber)) { + return parsedNumber.value + } else { + return validKeywords.find((k) => k.value === value) ?? unknownInputValue(value) + } + } + return value +} + +function toStringValue(v: CSSNumber | CSSKeyword) { + return isCSSNumber(v) ? `${v.value}${v.unit ?? ''}` : v.value +} diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.spec.browser2.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.spec.browser2.tsx new file mode 100644 index 000000000000..c7c7f565e1f6 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.spec.browser2.tsx @@ -0,0 +1,82 @@ +/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "checkPlaceholder"] }] */ +import type { RenderResult } from '@testing-library/react' +import { act, fireEvent, render } from '@testing-library/react' +import React from 'react' +import { + getStoreHook, + TestInspectorContextProvider, +} from '../../components/inspector/common/inspector.test-utils' +import { NumberInput } from './number-input' +import type { CSSNumber, UnknownOrEmptyInput } from '../../components/inspector/common/css-utils' +import { + cssNumber, + isEmptyInputValue, + isUnknownInputValue, +} from '../../components/inspector/common/css-utils' +import keycode from 'keycode' +import { wait } from '../../utils/utils.test-utils' + +describe('NumberInput', () => { + function checkPlaceholder(renderResult: RenderResult, expectedPlaceholder: string | null): void { + const inputElement = renderResult.queryByTestId('placeholdertest') + if (inputElement == null) { + throw new Error('Could not find input element.') + } else { + expect(inputElement.getAttribute('placeholder')).toEqual(expectedPlaceholder) + } + } + it('ensures that no placeholder property is in the input field by default', () => { + const storeHookForTest = getStoreHook() + const result = render( + + + , + ) + checkPlaceholder(result, null) + }) + it('ensures that the unknown control styles property shows in the input field', () => { + const storeHookForTest = getStoreHook() + const result = render( + + + , + ) + checkPlaceholder(result, 'Unknown') + }) + it('ensures that the mixed control styles property shows in the input field', () => { + const storeHookForTest = getStoreHook() + const result = render( + + + , + ) + checkPlaceholder(result, 'Mixed') + }) +}) diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.spec.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.spec.tsx new file mode 100644 index 000000000000..7ba590f42fef --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.spec.tsx @@ -0,0 +1,160 @@ +import React from 'react' +import type { CSSNumber, UnknownOrEmptyInput } from '../../components/inspector/common/css-utils' +import { + cssNumber, + isEmptyInputValue, + isUnknownInputValue, +} from '../../components/inspector/common/css-utils' +import { act, fireEvent, render } from '@testing-library/react' +import { + getStoreHook, + TestInspectorContextProvider, +} from '../../components/inspector/common/inspector.test-utils' +import { calculateDragDirectionDelta, NumberInput } from './number-input' +import keycode from 'keycode' + +jest.useFakeTimers() + +describe('NumberInput', () => { + it('calculateDragDirectionDelta should be able to invert its results', () => { + const testCases = [ + [5, 2], + [2, 5], + [-5.5, 2], + [-2, 5], + [0, 4], + [3.2, 1], + [-6, 3], + [-3, 6], + ] + + testCases.forEach(([delta, scaling]) => { + const { result, inverse } = calculateDragDirectionDelta(delta, scaling) + expect(inverse(result)).toEqual(delta) + }) + }) + it('triggers multiple transient updates and a single non-transient one when holding a key down and eventually releasing it', async () => { + const storeHookForTest = getStoreHook() + let value = cssNumber(100) + let transientUpdates: Array> = [] + + function updateValue(number: UnknownOrEmptyInput): void { + if (isUnknownInputValue(number) || isEmptyInputValue(number)) { + throw new Error(`Unexpected value: ${number}`) + } else { + value = number + } + reRender() + } + + function onTransientSubmitValue(number: UnknownOrEmptyInput): void { + transientUpdates.push(number) + updateValue(number) + } + let nonTransientUpdates: Array> = [] + function onSubmitValue(number: UnknownOrEmptyInput): void { + nonTransientUpdates.push(number) + updateValue(number) + } + + function reRender() { + return render( + + + , + ) + } + + const result = reRender() + const inputElement = result.getByTestId('keydowntest') + for (let count = 0; count < 10; count++) { + act(() => { + fireEvent( + inputElement, + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowUp', + keyCode: keycode('ArrowUp'), + }), + ) + fireEvent( + inputElement, + new KeyboardEvent('keyup', { + bubbles: true, + cancelable: true, + key: 'ArrowUp', + keyCode: keycode('ArrowUp'), + }), + ) + }) + } + // Need this for the delay that is in the `NumberInput`. + jest.runAllTimers() + expect(transientUpdates).toMatchInlineSnapshot(` + Array [ + Object { + "unit": null, + "value": 101, + }, + Object { + "unit": null, + "value": 102, + }, + Object { + "unit": null, + "value": 103, + }, + Object { + "unit": null, + "value": 104, + }, + Object { + "unit": null, + "value": 105, + }, + Object { + "unit": null, + "value": 106, + }, + Object { + "unit": null, + "value": 107, + }, + Object { + "unit": null, + "value": 108, + }, + Object { + "unit": null, + "value": 109, + }, + Object { + "unit": null, + "value": 110, + }, + ] + `) + expect(nonTransientUpdates).toMatchInlineSnapshot(` + Array [ + Object { + "unit": null, + "value": 110, + }, + ] + `) + }) +}) diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.tsx new file mode 100644 index 000000000000..d9b4796bac67 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/number-input.tsx @@ -0,0 +1,1102 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import type { Interpolation, Theme } from '@emotion/react' +import { jsx } from '@emotion/react' +import type { MouseEventHandler } from 'react' +import React from 'react' +import type { + CSSNumber, + CSSNumberType, + CSSNumberUnit, + UnknownOrEmptyInput, +} from '../../components/inspector/common/css-utils' +import { + cssNumber, + cssNumberToString, + emptyInputValue, + getCSSNumberUnit, + isCSSNumber, + isEmptyInputValue, + isUnknownInputValue, + parseCSSNumber, + setCSSNumberValue, + unknownInputValue, +} from '../../components/inspector/common/css-utils' +import type { + OnUnsetValues, + SubmitValueFactoryReturn, +} from '../../components/inspector/common/property-path-hooks' +import type { + InspectorControlProps, + OnSubmitValue, + OnSubmitValueOrEmpty, + OnSubmitValueOrUnknownOrEmpty, + OnSubmitValueOrUnknownOrEmptyMaybeTransient, +} from '../../components/inspector/controls/control' +import type { Either } from '../../core/shared/either' +import { isLeft, mapEither } from '../../core/shared/either' +import { clampValue } from '../../core/shared/math-utils' +import { memoize } from '../../core/shared/memoize' +import type { ControlStyles } from '../../uuiui-deps' +import { getControlStyles, CSSCursor } from '../../uuiui-deps' +import { Icn } from '../icn' +import { useColorTheme, UtopiaTheme } from '../styles/theme' +import { FlexRow } from '../widgets/layout/flex-row' +import type { BaseInputProps, BoxCorners, ChainedType } from './base-input' +import { + getBorderRadiusStyles, + getControlStylesAwarePlaceholder, + InspectorInput, +} from './base-input' +import { usePropControlledStateV2 } from '../../components/inspector/common/inspector-utils' +import { useControlsDisabledInSubtree } from '../utilities/disable-subtree' +import { getPossiblyHashedURL } from '../../utils/hashed-assets' + +function getDisplayValueNotMemoized( + value: CSSNumber | null, + defaultUnitToHide: CSSNumberUnit | null, + mixed: boolean, + showContent: boolean, +): string { + if (!mixed && value != null && showContent) { + const unit = getCSSNumberUnit(value) + const showUnit = unit !== defaultUnitToHide + return cssNumberToString(value, showUnit) + } else { + return '–' + } +} + +const getDisplayValue = memoize(getDisplayValueNotMemoized, { maxSize: 1000 }) + +function parseDisplayValueNotMemoized( + input: string, + numberType: CSSNumberType, + defaultUnit: CSSNumberUnit | null, +): Either { + const parsedInput = parseCSSNumber(input, numberType) + return mapEither((value: CSSNumber) => { + if (value.unit == null) { + return cssNumber(value.value, defaultUnit) + } else { + return value + } + }, parsedInput) +} + +const parseDisplayValue = memoize(parseDisplayValueNotMemoized, { maxSize: 1000 }) + +function dragDeltaSign(delta: number): 1 | -1 { + return delta >= 0 ? 1 : -1 +} + +export function calculateDragDirectionDelta( + delta: number, + scalingFactor: number, +): { + result: number + inverse: (value: number) => number +} { + const sign = dragDeltaSign(delta) + const rawAbsDelta = Math.abs(delta) + // Floor the value and then restore its sign so that it is rounded towards zero. + const scaledAbsDelta = Math.floor(rawAbsDelta / scalingFactor) + // save the diff for inverse calculation + const diff = rawAbsDelta - scaledAbsDelta * scalingFactor + return { + result: sign * scaledAbsDelta, + inverse: (value: number) => { + return sign * (Math.abs(value) * scalingFactor + diff) + }, + } +} + +function calculateDragDelta( + delta: number, + scalingFactor: number = 2, +): { + result: number + inverse: (value: number) => number +} { + return calculateDragDirectionDelta(delta, scalingFactor) +} + +let incrementTimeout: number | undefined = undefined +let incrementAnimationFrame: number | undefined = undefined +const repeatThreshold: number = 600 + +export interface NumberInputOptions { + innerLabel?: React.ReactChild + minimum?: number + maximum?: number + stepSize?: number + incrementControls?: boolean + chained?: ChainedType + height?: number + roundCorners?: BoxCorners + numberType: CSSNumberType + defaultUnitToHide: CSSNumberUnit | null + pasteHandler?: boolean + descriptionLabel?: string + disableScrubbing?: boolean + clampOnSubmitValue?: boolean +} + +export interface AbstractNumberInputProps + extends NumberInputOptions, + BaseInputProps, + InspectorControlProps { + value: T | null | undefined + invalid?: boolean +} + +export interface NumberInputProps extends AbstractNumberInputProps { + onSubmitValue?: OnSubmitValueOrUnknownOrEmptyMaybeTransient + onTransientSubmitValue?: OnSubmitValueOrUnknownOrEmptyMaybeTransient + onForcedSubmitValue?: OnSubmitValueOrUnknownOrEmptyMaybeTransient + setGlobalCursor?: (cursor: CSSCursor | null) => void + onMouseEnter?: MouseEventHandler + onMouseLeave?: MouseEventHandler +} + +const ScrubThreshold = 3 + +export const NumberInput = React.memo( + ({ + value: propsValue, + style, + testId, + inputProps = {}, + id, + innerLabel, + minimum: unscaledMinimum = -Infinity, + maximum: unscaledMaximum = Infinity, + stepSize: unscaledStepSize, + incrementControls = false, + chained = 'not-chained', + height = UtopiaTheme.layout.inputHeight.default, + roundCorners = 'all', + onSubmitValue, + onTransientSubmitValue, + onForcedSubmitValue, + controlStatus = 'simple', + focusOnMount = false, + numberType, + defaultUnitToHide, + setGlobalCursor, + onMouseEnter, + onMouseLeave, + invalid, + pasteHandler, + disableScrubbing = false, + clampOnSubmitValue, + }) => { + const ref = React.useRef(null) + const colorTheme = useColorTheme() + + const controlStyles = React.useMemo((): ControlStyles => { + return { + ...getControlStyles(controlStatus), + invalid: invalid ?? false, + } + }, [controlStatus, invalid]) + + const controlsDisabledInSubtree = useControlsDisabledInSubtree() + + const disabled = !controlStyles.interactive || controlsDisabledInSubtree + + const { mixed, showContent } = React.useMemo( + () => ({ + mixed: controlStyles.mixed, + showContent: controlStyles.showContent && !controlStyles.invalid, + }), + [controlStyles], + ) + + const [value, setValue] = usePropControlledStateV2(propsValue ?? null) + const [displayValue, setDisplayValue] = usePropControlledStateV2( + getDisplayValue(value, defaultUnitToHide, mixed, showContent), + ) + React.useEffect(() => { + if (mixed) { + setDisplayValue('') + } + }, [mixed, setDisplayValue]) + + const valueUnit = React.useMemo(() => value?.unit ?? null, [value]) + + const [isActuallyFocused, setIsActuallyFocused] = React.useState(false) + const [isFauxcused, setIsFauxcused] = React.useState(false) + const isFocused = isActuallyFocused || isFauxcused + + const [, setValueAtDragOriginState] = React.useState(0) + const valueAtDragOrigin = React.useRef(null) + const setValueAtDragOrigin = (n: number) => { + valueAtDragOrigin.current = n + setValueAtDragOriginState(n) + } + + const [dragOriginX, setDragOriginX] = React.useState(null) + const [dragOriginY, setDragOriginY] = React.useState(null) + + const [, setScrubThresholdPassedState] = React.useState(false) + const scrubThresholdPassed = React.useRef(false) + const setScrubThresholdPassed = (b: boolean) => { + scrubThresholdPassed.current = b + setScrubThresholdPassedState(b) + } + + const simulatedPointerRef = React.useRef(null) + const pointerOriginRef = React.useRef(null) + + const accumulatedMouseDeltaX = React.useRef(0) + const clampedAccumulatedDelta = React.useRef(0) + // This is here to alleviate a circular reference issue that I stumbled into with the callbacks, + // it means that the cleanup callback isn't dependent on the event listeners, which result in + // a break in the circle. + const scrubbingCleanupCallbacks = React.useRef void>>([]) + + const [valueChangedSinceFocus, setValueChangedSinceFocus] = React.useState(false) + + const scaleFactor = valueUnit === '%' ? 100 : 1 + const minimum = scaleFactor * unscaledMinimum + const maximum = scaleFactor * unscaledMaximum + const stepSize = unscaledStepSize == null ? 1 : unscaledStepSize * scaleFactor + + const repeatedValueRef = React.useRef(value ?? null) + + const updateValue = React.useCallback( + (newValue: CSSNumber | null) => { + setValue(newValue) + setDisplayValue(getDisplayValue(newValue, defaultUnitToHide, mixed, showContent)) + }, + [setValue, setDisplayValue, defaultUnitToHide, mixed, showContent], + ) + + const incrementBy = React.useCallback( + (incrementStepSize: number, shiftKey: boolean, transient: boolean) => { + if (value == null) { + return cssNumber(0) + } + const delta = incrementStepSize * (shiftKey ? 10 : 1) + const newNumericValue = clampValue(value.value + delta, minimum, maximum) + const newValue = setCSSNumberValue(value, newNumericValue) + if (transient) { + if (onTransientSubmitValue != null) { + onTransientSubmitValue(newValue, transient) + } else if (onSubmitValue != null) { + onSubmitValue(newValue, transient) + } + } else { + if (onForcedSubmitValue != null) { + onForcedSubmitValue(newValue, transient) + } else if (onSubmitValue != null) { + onSubmitValue(newValue, transient) + } + } + repeatedValueRef.current = newValue + setValueChangedSinceFocus(true) + updateValue(newValue) + return newValue + }, + [ + maximum, + minimum, + onForcedSubmitValue, + onSubmitValue, + onTransientSubmitValue, + value, + updateValue, + ], + ) + + const repeatIncrement = React.useCallback( + ( + currentValue: CSSNumber, + incrementStepSize: number, + shiftKey: boolean, + transient: boolean, + ) => { + const newValue = incrementBy(incrementStepSize, shiftKey, transient) + incrementAnimationFrame = window.requestAnimationFrame(() => + repeatIncrement(newValue, incrementStepSize, shiftKey, transient), + ) + }, + [incrementBy], + ) + + const setScrubValue = React.useCallback( + (transient: boolean) => { + if (valueAtDragOrigin.current != null) { + const { result: dragDelta, inverse } = calculateDragDelta(clampedAccumulatedDelta.current) + const totalClampedValue = clampValue( + valueAtDragOrigin.current + stepSize * dragDelta, + minimum, + maximum, + ) + const clampedDelta = (totalClampedValue - valueAtDragOrigin.current) / stepSize + clampedAccumulatedDelta.current = inverse(clampedDelta) + const newValue = cssNumber(totalClampedValue, valueUnit) + + if (transient) { + if (onTransientSubmitValue != null) { + onTransientSubmitValue(newValue, transient) + } else if (onSubmitValue != null) { + onSubmitValue(newValue, transient) + } + } else { + if (onForcedSubmitValue != null) { + onForcedSubmitValue(newValue, transient) + } else if (onSubmitValue != null) { + onSubmitValue(newValue, transient) + } + } + updateValue(newValue) + } + }, + [ + stepSize, + minimum, + maximum, + valueUnit, + updateValue, + onTransientSubmitValue, + onSubmitValue, + onForcedSubmitValue, + ], + ) + + React.useEffect(() => { + if (focusOnMount && ref.current != null) { + ref.current.focus() + } + }, [focusOnMount, ref]) + + const onThresholdPassed = (e: MouseEvent, fn: () => void) => { + const thresholdPassed = + scrubThresholdPassed.current || Math.abs(accumulatedMouseDeltaX.current) >= ScrubThreshold + if (thresholdPassed) { + fn() + } + } + + const cancelPointerLock = React.useCallback( + (revertChanges: 'revert-nothing' | 'revert-changes') => { + document.exitPointerLock() + if ( + revertChanges === 'revert-changes' && + onSubmitValue != null && + valueAtDragOrigin.current != null + ) { + const oldValue = cssNumber(valueAtDragOrigin.current, valueUnit) + onSubmitValue(oldValue, false) + } + + setIsFauxcused(false) + ref.current?.focus() + + setScrubThresholdPassed(false) + setGlobalCursor?.(null) + }, + [onSubmitValue, setGlobalCursor, valueUnit], + ) + + const checkPointerLockChange = React.useCallback(() => { + if (document.pointerLockElement !== pointerOriginRef.current) { + cancelPointerLock('revert-changes') + scrubbingCleanupCallbacks.current.forEach((fn) => fn()) + } + }, [cancelPointerLock]) + + const scrubOnMouseUp = React.useCallback( + (e: MouseEvent) => { + scrubbingCleanupCallbacks.current.forEach((fn) => fn()) + onThresholdPassed(e, () => { + setScrubValue(false) + }) + cancelPointerLock('revert-nothing') + }, + [cancelPointerLock, setScrubValue], + ) + + const scrubOnMouseMove = React.useCallback( + (e: MouseEvent) => { + // Apply the movement to the accumulated delta, as the movement is + // relative to the last event. + accumulatedMouseDeltaX.current += e.movementX + clampedAccumulatedDelta.current += e.movementX + + onThresholdPassed(e, () => { + if (!scrubThresholdPassed.current) { + setScrubThresholdPassed(true) + if (pointerOriginRef.current != null) { + pointerOriginRef.current.requestPointerLock() + scrubbingCleanupCallbacks.current.push(() => { + window.removeEventListener('mouseup', scrubOnMouseUp) + }) + scrubbingCleanupCallbacks.current.push(() => { + window.removeEventListener('mousemove', scrubOnMouseMove) + }) + scrubbingCleanupCallbacks.current.push(() => { + document.removeEventListener('pointerlockchange', checkPointerLockChange, true) + }) + } + } + setScrubValue(true) + }) + }, + [checkPointerLockChange, scrubOnMouseUp, setScrubValue], + ) + + const rc = roundCorners == null ? 'all' : roundCorners + const borderRadiusStyles = getBorderRadiusStyles(chained, rc) + + const onFocus = React.useCallback( + (e: React.FocusEvent) => { + setIsActuallyFocused(true) + e.target.select() + if (inputProps.onFocus != null) { + inputProps.onFocus(e) + } + }, + [inputProps], + ) + + const clearIncrementTimeouts = React.useCallback(() => { + if (incrementTimeout != null) { + window.clearTimeout(incrementTimeout) + incrementTimeout = undefined + } + if (incrementAnimationFrame != null) { + window.cancelAnimationFrame(incrementAnimationFrame ?? 0) + incrementAnimationFrame = undefined + } + }, []) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + const shiftKey = e.shiftKey + const changeBy = e.key === 'ArrowUp' ? stepSize : -stepSize + const newValue = incrementBy(changeBy, shiftKey, true) + clearIncrementTimeouts() + incrementTimeout = window.setTimeout(() => { + if (onSubmitValue != null) { + onSubmitValue(newValue, false) + } else if (onForcedSubmitValue != null) { + onForcedSubmitValue(newValue, false) + } + }, repeatThreshold) + } else if (e.key === 'Enter' || e.key === 'Escape') { + e.nativeEvent.stopImmediatePropagation() + e.preventDefault() + ref.current?.blur() + } + }, + [stepSize, incrementBy, clearIncrementTimeouts, onSubmitValue, onForcedSubmitValue], + ) + + const onBlur = React.useCallback( + (e: React.FocusEvent) => { + setIsActuallyFocused(false) + + function getNewValue() { + if (displayValue == '') { + return emptyInputValue() + } + const parsed = parseDisplayValue(displayValue, numberType, defaultUnitToHide) + if (isLeft(parsed)) { + return unknownInputValue(displayValue) + } + return clampOnSubmitValue + ? { + ...parsed.value, + value: clampValue(parsed.value.value, minimum, maximum), + } + : parsed.value + } + + const newValue = getNewValue() + if (isUnknownInputValue(newValue)) { + updateValue(value) + return + } + updateValue(isEmptyInputValue(newValue) ? cssNumber(0) : newValue) + + if (inputProps.onBlur != null) { + inputProps.onBlur(e) + } + if (valueChangedSinceFocus) { + setValueChangedSinceFocus(false) + if (onSubmitValue != null) { + onSubmitValue(newValue, false) + } + } + }, + [ + inputProps, + onSubmitValue, + valueChangedSinceFocus, + defaultUnitToHide, + numberType, + updateValue, + value, + displayValue, + minimum, + maximum, + clampOnSubmitValue, + ], + ) + + const onChange = React.useCallback( + (e: React.ChangeEvent) => { + if (inputProps.onChange != null) { + inputProps.onChange(e) + } + setValueChangedSinceFocus(true) + setDisplayValue(e.target.value) + }, + [inputProps, setDisplayValue], + ) + + const onIncrementMouseUp = React.useCallback(() => { + window.removeEventListener('mouseup', onIncrementMouseUp) + setIsFauxcused(false) + ref.current?.focus() + if (incrementTimeout != null) { + window.clearTimeout(incrementTimeout) + incrementTimeout = undefined + } + if (incrementAnimationFrame != null) { + window.cancelAnimationFrame(incrementAnimationFrame ?? 0) + incrementAnimationFrame = undefined + } + + // Clear transient state by setting the final value + if (onSubmitValue != null) { + onSubmitValue( + repeatedValueRef.current != null + ? repeatedValueRef.current + : unknownInputValue(displayValue), + false, + ) + } + + if (repeatedValueRef.current != null) { + updateValue(repeatedValueRef.current) + } + }, [ref, onSubmitValue, updateValue, displayValue]) + + const onIncrementMouseDown = React.useCallback( + (e: React.MouseEvent) => { + if (disabled) { + return + } + if (e.button === 0) { + e.stopPropagation() + setIsFauxcused(true) + window.addEventListener('mouseup', onIncrementMouseUp) + const shiftKey = e.shiftKey + const newValue = incrementBy(stepSize, shiftKey, false) + incrementTimeout = window.setTimeout(() => { + repeatIncrement(newValue, stepSize, shiftKey, true) + }, repeatThreshold) + } + }, + [incrementBy, stepSize, repeatIncrement, onIncrementMouseUp, disabled], + ) + + const onDecrementMouseUp = React.useCallback(() => { + window.removeEventListener('mouseup', onDecrementMouseUp) + setIsFauxcused(false) + ref.current?.focus() + if (incrementTimeout != null) { + window.clearTimeout(incrementTimeout) + incrementTimeout = undefined + } + if (incrementAnimationFrame != null) { + window.cancelAnimationFrame(incrementAnimationFrame ?? 0) + incrementAnimationFrame = undefined + } + + // Clear transient state by setting the final value + if (onSubmitValue != null) { + onSubmitValue( + repeatedValueRef.current != null + ? repeatedValueRef.current + : unknownInputValue(displayValue), + false, + ) + } + + if (repeatedValueRef.current != null) { + updateValue(repeatedValueRef.current) + } + }, [ref, onSubmitValue, displayValue, updateValue]) + + const onDecrementMouseDown = React.useCallback( + (e: React.MouseEvent) => { + if (disabled) { + return + } + if (e.button === 0) { + e.stopPropagation() + setIsFauxcused(true) + window.addEventListener('mouseup', onDecrementMouseUp) + const shiftKey = e.shiftKey + const newValue = incrementBy(-stepSize, shiftKey, false) + incrementTimeout = window.setTimeout(() => { + repeatIncrement(newValue, -stepSize, shiftKey, true) + }, repeatThreshold) + } + }, + [incrementBy, stepSize, repeatIncrement, onDecrementMouseUp, disabled], + ) + + const onLabelMouseDown = React.useCallback( + (e: React.MouseEvent) => { + if (disabled || disableScrubbing) { + return + } + if (e.button === 0) { + e.stopPropagation() + setIsFauxcused(true) + window.addEventListener('mousemove', scrubOnMouseMove) + window.addEventListener('mouseup', scrubOnMouseUp) + document.addEventListener('pointerlockchange', checkPointerLockChange, true) + setValueAtDragOrigin(value?.value ?? 0) + setDragOriginX(e.pageX) + setDragOriginY(e.pageY) + setGlobalCursor?.(CSSCursor.ResizeEW) + accumulatedMouseDeltaX.current = 0 + clampedAccumulatedDelta.current = 0 + } + }, + [ + disabled, + disableScrubbing, + scrubOnMouseMove, + scrubOnMouseUp, + checkPointerLockChange, + value?.value, + setGlobalCursor, + ], + ) + + const placeholder = getControlStylesAwarePlaceholder(controlStyles) + + const chainedStyles: Interpolation | undefined = + (chained === 'first' || chained === 'middle') && !isFocused + ? { + '&:not(:hover)::after': { + content: '""', + width: 1, + height: UtopiaTheme.layout.inputHeight.default / 2, + backgroundColor: controlStyles.borderColor, + zIndex: 2, + position: 'absolute', + top: UtopiaTheme.layout.inputHeight.default / 2, + right: 0, + transform: 'translateX(0.5px)', + }, + } + : undefined + + let simulatedPointerTransformX: number | undefined = undefined + if (pointerOriginRef.current != null && scrubThresholdPassed.current && dragOriginX != null) { + const pointerOriginRect = pointerOriginRef.current.getBoundingClientRect() + const intendedPointerX = + (pointerOriginRect.left + accumulatedMouseDeltaX.current) % window.screen.width + simulatedPointerTransformX = intendedPointerX - pointerOriginRect.left + } + + return ( +
+
+ +
+
+ } + > + {incrementControls && !disabled ? ( +
+
+ +
+
+ +
+
+ ) : null} + {innerLabel == null && controlStatus != 'off' ? null : ( +
+ {innerLabel} +
+ )} + +
+
+ ) + }, +) +NumberInput.displayName = 'NumberInput' + +interface SimpleNumberInputProps extends Omit, 'numberType'> { + onSubmitValue: OnSubmitValueOrEmpty + onTransientSubmitValue: OnSubmitValueOrEmpty + onForcedSubmitValue: OnSubmitValueOrEmpty + setGlobalCursor?: (cursor: CSSCursor | null) => void +} + +interface SimpleCSSNumberInputProps extends AbstractNumberInputProps { + onSubmitValue: OnSubmitValueOrEmpty + onTransientSubmitValue: OnSubmitValueOrEmpty + onForcedSubmitValue: OnSubmitValueOrEmpty + setGlobalCursor?: (cursor: CSSCursor | null) => void +} + +function wrappedSimpleOnSubmitValue( + onSubmitValue: OnSubmitValueOrEmpty, +): OnSubmitValueOrUnknownOrEmpty { + return (value) => { + if (isCSSNumber(value)) { + onSubmitValue(value.value) + } + } +} + +export const SimpleNumberInput = React.memo( + ({ + value, + onSubmitValue, + onTransientSubmitValue, + onForcedSubmitValue, + ...sharedProps + }: SimpleNumberInputProps) => { + const wrappedProps: NumberInputProps = { + ...sharedProps, + value: value == null ? null : cssNumber(value), + onSubmitValue: wrappedSimpleOnSubmitValue(onSubmitValue), + onTransientSubmitValue: wrappedSimpleOnSubmitValue(onTransientSubmitValue), + onForcedSubmitValue: wrappedSimpleOnSubmitValue(onForcedSubmitValue), + numberType: 'Unitless', + } + return + }, +) + +export const SimpleCSSNumberInput = React.memo( + ({ + value, + onSubmitValue, + onTransientSubmitValue, + onForcedSubmitValue, + numberType, + ...sharedProps + }: SimpleCSSNumberInputProps) => { + const wrappedProps: NumberInputProps = { + ...sharedProps, + value: value, + onSubmitValue: wrappedSimpleOnSubmitValue(onSubmitValue), + onTransientSubmitValue: wrappedSimpleOnSubmitValue(onTransientSubmitValue), + onForcedSubmitValue: wrappedSimpleOnSubmitValue(onForcedSubmitValue), + numberType: 'AnyValid', + } + return + }, +) + +interface SimplePercentInputProps extends Omit, 'numberType'> { + onSubmitValue: OnSubmitValueOrEmpty + onTransientSubmitValue: OnSubmitValueOrEmpty + onForcedSubmitValue: OnSubmitValueOrEmpty +} + +function wrappedPercentOnSubmitValue( + onSubmitValue: OnSubmitValueOrEmpty, +): OnSubmitValueOrUnknownOrEmpty { + return (value) => { + if (isCSSNumber(value)) { + onSubmitValue(value.value / 100) + } + } +} + +export const SimplePercentInput = React.memo( + ({ + value, + onSubmitValue, + onTransientSubmitValue, + onForcedSubmitValue, + ...sharedProps + }: SimplePercentInputProps) => { + const wrappedProps: NumberInputProps = { + ...sharedProps, + value: value == null ? null : cssNumber(value * 100, '%'), + onSubmitValue: wrappedPercentOnSubmitValue(onSubmitValue), + onTransientSubmitValue: wrappedPercentOnSubmitValue(onTransientSubmitValue), + onForcedSubmitValue: wrappedPercentOnSubmitValue(onForcedSubmitValue), + numberType: 'Percent', + } + return + }, +) + +interface ChainedNumberControlProps { + propsArray: Array> + idPrefix: string + style?: React.CSSProperties + setGlobalCursor?: (cursor: CSSCursor | null) => void + wrap?: boolean +} + +export const ChainedNumberInput: React.FunctionComponent< + React.PropsWithChildren +> = React.memo(({ propsArray, idPrefix, style, setGlobalCursor, wrap }) => { + return ( + + {propsArray.map((props, i) => { + switch (i) { + case 0: { + return ( + + ) + } + case propsArray.length - 1: { + return ( + + ) + } + default: { + return ( + + ) + } + } + })} + + ) +}) + +export function wrappedEmptyOrUnknownOnSubmitValue( + onSubmitValue: OnSubmitValue, + onUnsetValue?: OnUnsetValues, +): OnSubmitValueOrUnknownOrEmpty { + return (value) => { + if (isEmptyInputValue(value)) { + if (onUnsetValue != null) { + onUnsetValue() + } + } else if (!isUnknownInputValue(value)) { + onSubmitValue(value) + } + } +} + +export function useWrappedEmptyOrUnknownOnSubmitValue( + onSubmitValue: OnSubmitValue, + onUnsetValue?: OnUnsetValues, +): OnSubmitValueOrUnknownOrEmpty { + return React.useCallback( + (value) => wrappedEmptyOrUnknownOnSubmitValue(onSubmitValue, onUnsetValue)(value), + [onSubmitValue, onUnsetValue], + ) +} + +/** + * An easy wrapper for the submitValueFactory that applies useWrappedEmptyOrUnknownOnSubmitValue + * to each submitValue type. + */ +export function useWrappedSubmitFactoryEmptyOrUnknownOnSubmitValue( + submitValueReturn: SubmitValueFactoryReturn, + onUnsetValue?: OnUnsetValues, +): SubmitValueFactoryReturn> { + const index0 = useWrappedEmptyOrUnknownOnSubmitValue(submitValueReturn[0], onUnsetValue) + const index1 = useWrappedEmptyOrUnknownOnSubmitValue(submitValueReturn[1], onUnsetValue) + return [index0, index1] +} diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/number-or-keyword-control.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/number-or-keyword-control.tsx new file mode 100644 index 000000000000..0d1f3c95a93f --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/number-or-keyword-control.tsx @@ -0,0 +1,152 @@ +import type { CSSProperties } from 'react' +import React from 'react' +import { Icons, SmallerIcons } from '../icons' +import { + DropdownMenu, + regularDropdownMenuItem, + separatorDropdownMenuItem, + type DropdownMenuItem, +} from '../radix-components' +import { NumberOrKeywordInput } from './new-number-or-keyword-input' +import type { + CSSKeyword, + CSSNumber, + UnknownOrEmptyInput, +} from '../../components/inspector/common/css-utils' +import { isCSSNumber } from '../../components/inspector/common/css-utils' +import { NO_OP } from '../../core/shared/utils' +import type { ControlStatus } from '../../uuiui-deps' +import type { IcnProps } from '../icn' +import { mapDropNulls } from '../../core/shared/array-utils' + +export type KeywordForControl = + | { label: string; value: CSSKeyword } + | 'separator' + +type NumberOrKeywordControlProps = { + testId: string + style?: CSSProperties + onSubmitValue: (value: UnknownOrEmptyInput>) => void + value: CSSNumber | CSSKeyword + valueAlias?: string + keywords: Array> + keywordTypeCheck: (keyword: unknown) => keyword is T + controlStatus?: ControlStatus + labelInner?: string | IcnProps +} + +export function NumberOrKeywordControl(props: NumberOrKeywordControlProps) { + const { onSubmitValue: propsOnSubmitValue } = props + + const [hover, setHover] = React.useState(false) + const [dropdownOpen, setDropdownOpen] = React.useState(false) + + const onSubmitValue = React.useCallback( + (value: UnknownOrEmptyInput>) => { + propsOnSubmitValue(value) + setHover(false) + }, + [propsOnSubmitValue], + ) + + const dropdownButtonId = `${props.testId}-dropdown` + + const dropdownButton = React.useCallback( + () => ( + + ), + [dropdownButtonId, hover, dropdownOpen], + ) + + const dropdownItems = React.useMemo((): DropdownMenuItem[] => { + let items: DropdownMenuItem[] = [] + if (props.controlStatus !== 'off') { + items.push( + regularDropdownMenuItem({ + id: 'dropdown-input-value', + icon: , + label: `${props.value.value}${isCSSNumber(props.value) ? props.value.unit ?? '' : ''}${ + props.valueAlias != null ? ` (${props.valueAlias})` : '' + }`, + disabled: true, + onSelect: NO_OP, + }), + ) + } + if (props.keywords.length > 0) { + items.push(separatorDropdownMenuItem('dropdown-separator')) + } + items.push( + ...props.keywords.map((keyword, idx): DropdownMenuItem => { + if (keyword === 'separator') { + return separatorDropdownMenuItem(`keyword-separator-${idx}`) + } + return regularDropdownMenuItem({ + id: `dropdown-label-${keyword.value.value}`, + icon:
, + label: keyword.label, + onSelect: () => onSubmitValue(keyword.value), + }) + }), + ) + return items + }, [props.value, props.keywords, onSubmitValue, props.controlStatus, props.valueAlias]) + + const keywordsWithoutSeparators = React.useMemo(() => { + return mapDropNulls((k) => (k !== 'separator' ? k : null), props.keywords).map((k) => k.value) + }, [props.keywords]) + + return ( +
setHover(true)} + onMouseOut={() => setHover(false)} + > + +
+ +
+
+ ) +} diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/string-input.spec.browser2.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/string-input.spec.browser2.tsx new file mode 100644 index 000000000000..862b30a9f3ff --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/string-input.spec.browser2.tsx @@ -0,0 +1,80 @@ +/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "checkPlaceholder"] }] */ +import type { RenderResult } from '@testing-library/react' +import { render } from '@testing-library/react' +import React from 'react' +import { StringInput } from './string-input' +import { + TestInspectorContextProvider, + getStoreHook, +} from '../../components/inspector/common/inspector.test-utils' + +describe('StringInput', () => { + function checkPlaceholder(renderResult: RenderResult, expectedPlaceholder: string | null): void { + const inputElement = renderResult.queryByTestId('placeholdertest') + if (inputElement == null) { + throw new Error('Could not find input element.') + } else { + expect(inputElement.getAttribute('placeholder')).toEqual(expectedPlaceholder) + } + } + it('ensures that no placeholder property shows in the input field by default', () => { + const storeHookForTest = getStoreHook() + const result = render( + + + , + ) + checkPlaceholder(result, null) + }) + it('ensures that the placeholder property shows in the input field', () => { + const storeHookForTest = getStoreHook() + const result = render( + + + , + ) + checkPlaceholder(result, 'this is a placeholder') + }) + it('ensures that the unknown control styles property shows in the input field', () => { + const storeHookForTest = getStoreHook() + const result = render( + + + , + ) + checkPlaceholder(result, 'Unknown') + }) + it('ensures that the mixed control styles property shows in the input field', () => { + const storeHookForTest = getStoreHook() + const result = render( + + + , + ) + checkPlaceholder(result, 'Mixed') + }) +}) diff --git a/nexus-builder/packages/navigator/src/uuiui/inputs/string-input.tsx b/nexus-builder/packages/navigator/src/uuiui/inputs/string-input.tsx new file mode 100644 index 000000000000..5cddfd0d280e --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/inputs/string-input.tsx @@ -0,0 +1,291 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import styled from '@emotion/styled' +import composeRefs from '@seznam/compose-react-refs' +import type { CSSProperties } from 'react' +import React from 'react' +import type { ControlStatus } from '../../components/inspector/common/control-status' +import type { ControlStyles } from '../../components/inspector/common/control-styles' +import { getControlStyles } from '../../components/inspector/common/control-styles' +import { preventDefault, stopPropagation } from '../../components/inspector/common/inspector-utils' +import { UtopiaTheme, useColorTheme } from '../styles/theme' +import { InspectorInputEmotionStyle, getControlStylesAwarePlaceholder } from './base-input' +import { useControlsDisabledInSubtree } from '../utilities/disable-subtree' +import { dataPasteHandler } from '../../utils/paste-handler' + +interface StringInputOptions { + focusOnMount?: boolean +} + +export interface StringInputProps + extends StringInputOptions, + React.InputHTMLAttributes { + testId: string + placeholder?: string + style?: React.CSSProperties + id?: string + className?: string + DEPRECATED_labelBelow?: React.ReactChild + controlStatus?: ControlStatus + growInputAutomatically?: boolean + includeBoxShadow?: boolean + onSubmitValue?: (value: string) => void + onEscape?: () => void + pasteHandler?: boolean + showBorder?: boolean + innerStyle?: React.CSSProperties + ellipsize?: boolean +} + +export const StringInput = React.memo( + React.forwardRef( + ( + { + controlStatus = 'simple', + style, + innerStyle, + focusOnMount = false, + includeBoxShadow = true, + placeholder: initialPlaceHolder, + DEPRECATED_labelBelow: labelBelow, + testId, + showBorder, + ellipsize, + ...inputProps + }, + propsRef, + ) => { + const colorTheme = useColorTheme() + const ref = React.useRef(null) + + React.useEffect(() => { + if (focusOnMount && typeof ref !== 'function' && ref.current != null) { + ref.current.focus() + } + }, [focusOnMount, ref]) + + const controlStyles: ControlStyles = getControlStyles(controlStatus) + const controlsDisabled = useControlsDisabledInSubtree() + const disabled = !controlStyles.interactive || controlsDisabled + + const inputPropsKeyDown = inputProps.onKeyDown + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (inputPropsKeyDown != null) { + inputPropsKeyDown(e) + } + if ( + e.key === 'ArrowRight' || + e.key === 'ArrowLeft' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' + ) { + // handle navigation events without & with modifiers + e.stopPropagation() + } + }, + [inputPropsKeyDown], + ) + + const placeholder = getControlStylesAwarePlaceholder(controlStyles) ?? initialPlaceHolder + + let inputStyle: CSSProperties = {} + if (ellipsize) { + inputStyle.textOverflow = 'ellipsis' + inputStyle.whiteSpace = 'nowrap' + inputStyle.overflow = 'hidden' + } + + return ( +
+
+ + {labelBelow == null ? null : ( + + {labelBelow} + + )} +
+
+ ) + }, + ), +) + +const LabelBelow = styled.label({ + fontSize: 9, + paddingTop: 2, + textAlign: 'center', + display: 'block', +}) + +export type HeadlessStringInputProps = React.InputHTMLAttributes & { + onSubmitValue?: (value: string) => void + onEscape?: () => void + growInputAutomatically?: boolean + testId: string + pasteHandler?: boolean +} + +export const HeadlessStringInput = React.forwardRef( + (props, propsRef) => { + const { + onSubmitValue, + onEscape, + onChange, + growInputAutomatically = false, + style = {}, + value, + testId, + pasteHandler, + ...otherProps + } = props + const { disabled, onKeyDown, onFocus, onBlur } = otherProps + + const ref = React.useRef(null) + + const spanRef = React.useRef(null) + + const handleOnKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (onKeyDown != null) { + onKeyDown(e) + } + if (e.key === 'Escape' || e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + // eslint-disable-next-line no-unused-expressions + ref.current?.blur() + if (e.key === 'Enter') { + // eslint-disable-next-line no-unused-expressions + onSubmitValue?.(e.currentTarget.value) + } + if (e.key === 'Escape') { + // eslint-disable-next-line no-unused-expressions + onEscape?.() + } + } + }, + [onKeyDown, ref, onSubmitValue, onEscape], + ) + + const handleOnFocus = React.useCallback( + (e: React.FocusEvent) => { + // TODO do we actually need to manage disabled? + if (disabled) { + e.preventDefault() + e.target.blur() + } else { + if (onFocus != null) { + onFocus(e) + } + e.target.select() + } + }, + [disabled, onFocus], + ) + + const inputComponent = ( + + ) + + if (growInputAutomatically) { + return ( +
+ {inputComponent} + + {value ?? ''} + +
+ ) + } else { + return inputComponent + } + }, +) diff --git a/nexus-builder/packages/navigator/src/uuiui/isolator.tsx b/nexus-builder/packages/navigator/src/uuiui/isolator.tsx new file mode 100644 index 000000000000..b88edf738971 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/isolator.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { colorTheme } from './styles/theme' + +type IsolatorProps = { + onAbandonIntent: () => void +} + +export const Isolator: React.FunctionComponent> = ( + props, +) => { + const onAbandonIntent = props.onAbandonIntent + React.useEffect(() => { + const handleKeyPressed = (e: any) => { + if (e.key === 'Escape') { + onAbandonIntent() + } + } + /* add listener when component mounts */ + document.addEventListener('keydown', handleKeyPressed) + + return () => { + /* remove listener on unmount */ + document.removeEventListener('keydown', handleKeyPressed) + } + }, [onAbandonIntent]) + + return ( +
props.onAbandonIntent()} + > + {props.children} +
+ ) +} diff --git a/nexus-builder/packages/navigator/src/uuiui/link.tsx b/nexus-builder/packages/navigator/src/uuiui/link.tsx new file mode 100644 index 000000000000..c81905b59fb3 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/link.tsx @@ -0,0 +1,18 @@ +import styled from '@emotion/styled' +import React from 'react' +//TODO: refactor components to functional components and use 'useColorTheme()': +import { colorTheme } from './styles/theme' + +export const Link = styled.a({ + color: colorTheme.inlineButtonColor.value, + textDecoration: 'none', + cursor: 'pointer', + paddingLeft: 2, + paddingRight: 2, + '&:hover': { + textDecoration: 'underline', + }, + '&:visited': { + color: colorTheme.inlineButtonColor.value, + }, +}) diff --git a/nexus-builder/packages/navigator/src/uuiui/loader.js b/nexus-builder/packages/navigator/src/uuiui/loader.js new file mode 100644 index 000000000000..fa4754532db6 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/loader.js @@ -0,0 +1,126 @@ +const fs = require('fs') + +function flatten(arr) { + return Array.prototype.concat.apply([], arr) +} + +// https://github.com/tc39/proposal-object-from-entries/blob/master/polyfill.js +function ObjectFromEntries(iter) { + const obj = {} + + for (const pair of iter) { + if (Object(pair) !== pair) { + throw new TypeError('iterable for fromEntries should yield objects') + } + + // Consistency with Map: contract is that entry has "0" and "1" keys, not + // that it is an array or iterable. + + const { 0: key, 1: val } = pair + + Object.defineProperty(obj, key, { + configurable: true, + enumerable: true, + writable: true, + value: val, + }) + } + + return obj +} + +function directory() { + return { + type: 'DIRECTORY', + } +} + +function codeFile(fileContents) { + return { + type: 'CODE_FILE', + fileContents: fileContents, + lastSavedContents: null, + } +} + +/** + * @param {string} filePath + * @return {Promise} + */ +function readFileAsync(filePath) { + return new Promise((resolve, reject) => { + fs.readFile(filePath, (err, data) => { + if (err != null) { + reject(err) + } else { + resolve(data.toString('base64')) + } + }) + }) +} + +/** + * + * @param {string} filePath + * @return {Array} + */ +function readDirAsync(filePath) { + return new Promise((resolve, reject) => { + fs.readdir(filePath, { withFileTypes: true }, (err, files) => { + if (err != null) { + reject(err) + } else { + resolve(files) + } + }) + }) +} + +function filterFileByName(name) { + return ['.DS_Store', 'loader.js', '.spec.ts', '.snapshot.'].every( + (word) => name.indexOf(word) < 0, + ) +} + +/** + * + * @param {string} realPath + * @param {string} utopiaPath + * @returns {Promise<[string, any]>} + */ +async function turnDirectoryIntoProjectContents(realPath, utopiaPath) { + const directoryEntries = await readDirAsync(realPath) + const keyValueResults = [ + [utopiaPath, directory()], + ...flatten( + await Promise.all( + directoryEntries.map(async (dirEnt) => { + const filePath = realPath + '/' + dirEnt.name + const utopiaFilePath = utopiaPath + '/' + dirEnt.name + if (dirEnt.isFile() && filterFileByName(dirEnt.name)) { + const fileString = await readFileAsync(filePath) + return [[utopiaFilePath, codeFile(fileString)]] + } else if (dirEnt.isDirectory()) { + return turnDirectoryIntoProjectContents(filePath, utopiaFilePath) + } else { + return null + } + }), + ), + ).filter((e) => e != null), + ] + + return keyValueResults +} + +module.exports = async (options, loaderContext) => { + const result = await turnDirectoryIntoProjectContents(loaderContext.context, '/uuiui') + const resultObject = ObjectFromEntries(result) + const printedSanitizedResult = JSON.stringify(resultObject, null, 2).replace(/\\/g, '\\\\') + + return new Promise((resolve, reject) => { + setTimeout(function () { + resolve({ code: `module.exports = JSON.parse(\`${printedSanitizedResult}\`)` }) + }, 1000) + }) +} diff --git a/nexus-builder/packages/navigator/src/uuiui/menu-tab.tsx b/nexus-builder/packages/navigator/src/uuiui/menu-tab.tsx new file mode 100644 index 000000000000..447df5c9ad02 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/menu-tab.tsx @@ -0,0 +1,69 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import styled from '@emotion/styled' +import { FlexRow, SimpleFlexRow } from './widgets/layout/flex-row' +import { Button } from './button' +import { Icons } from './icons' + +import { useColorTheme, UtopiaTheme } from './styles/theme' +import { CSSObject } from '@emotion/serialize' +import { defaultIfNull } from '../core/shared/optional-utils' +import { NO_OP } from '../core/shared/utils' +import { UIGridRow } from '../components/inspector/widgets/ui-grid-row' + +interface MenuTabProps { + selected?: boolean + hasErrorMessages?: boolean + label: React.ReactElement | string + onClose?: () => void + onClick?: () => void + onMouseDown?: () => void + className?: string + testId?: string +} + +export const MenuTab: React.FunctionComponent> = React.memo( + (props) => { + const colorTheme = useColorTheme() + + const selected = defaultIfNull(false, props.selected) + const hasErrorMessages = defaultIfNull(false, props.hasErrorMessages) + const label = defaultIfNull('', props.label) + + const baseStyle = { + padding: '4px 8px', + transition: 'all .05s ease-in-out', + cursor: 'pointer', + + justifyContent: 'center', + fontWeight: 500, + '&:hover': { + opacity: 1, + }, + } + + const selectionHandlingStyle = { + color: hasErrorMessages + ? colorTheme.errorForeground.value + : colorTheme.tabSelectedForeground.value, + opacity: selected ? 1 : 0.4, + } + return ( + +
{label}
+
+ ) + }, +) diff --git a/nexus-builder/packages/navigator/src/uuiui/radix-components.tsx b/nexus-builder/packages/navigator/src/uuiui/radix-components.tsx new file mode 100644 index 000000000000..db93aa8df623 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/radix-components.tsx @@ -0,0 +1,478 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu' +import * as Select from '@radix-ui/react-select' +import { styled } from '@stitches/react' +import type { CSSProperties } from 'react' +import React from 'react' +import { colorTheme, UtopiaStyles, UtopiaTheme } from './styles/theme' +import { Icons, SmallerIcons } from './icons' +import { when } from '../utils/react-conditionals' +import { Icn, type IcnProps } from './icn' +import { forceNotNull } from '../core/shared/optional-utils' +import { usePropControlledStateV2 } from '../components/inspector/common/inspector-utils' +import { assertNever } from '../core/shared/utils' + +// Keep this in sync with the radix-components-portal div in index.html. +export const RadixComponentsPortalId = 'radix-components-portal' + +const RadixItemContainer = styled(RadixDropdownMenu.Item, { + minWidth: 128, + padding: '4px 6px', + display: 'flex', + gap: 12, + justifyContent: 'space-between', + alignItems: 'center', + cursor: 'pointer', + color: colorTheme.contextMenuForeground.value, + '&[data-highlighted]': { + backgroundColor: colorTheme.contextMenuHighlightBackground.value, + color: colorTheme.contextMenuForeground.value, + borderRadius: 6, + }, +}) + +const RadixDropdownContent = styled(RadixDropdownMenu.Content, { + padding: 4, + flexDirection: 'column', + backgroundColor: colorTheme.contextMenuBackground.value, + borderRadius: 6, + display: 'grid', + gridTemplateRows: '1fr auto', +}) + +type BaseDropdownMenuItem = { + id: string +} + +type RegularDropdownMenuItem = BaseDropdownMenuItem & { + type: 'LABELED' + shortcut?: string + icon?: React.ReactNode + checked?: boolean + danger?: boolean + label: React.ReactNode + onSelect: () => void + disabled?: boolean + badge?: string | null +} + +export function regularDropdownMenuItem( + props: Omit, +): RegularDropdownMenuItem { + return { + type: 'LABELED', + ...props, + } +} + +export function separatorDropdownMenuItem(id: string): SeparatorDropdownMenuItem { + return { + id: id, + type: 'SEPARATOR', + } +} + +type SeparatorDropdownMenuItem = BaseDropdownMenuItem & { + type: 'SEPARATOR' +} + +function isSeparatorDropdownMenuItem(item: DropdownMenuItem): item is SeparatorDropdownMenuItem { + return item.type === 'SEPARATOR' +} + +export type DropdownMenuItem = RegularDropdownMenuItem | SeparatorDropdownMenuItem + +export interface DropdownMenuProps { + opener: (open: boolean) => React.ReactNode + items: DropdownMenuItem[] + align?: RadixDropdownMenu.DropdownMenuContentProps['align'] + sideOffset?: number + alignOffset?: number + onOpenChange?: (open: boolean) => void + style?: CSSProperties + forceOpen?: boolean +} + +export const ItemContainerTestId = (id: string) => `item-container-${id}` + +export const DropdownMenu = React.memo((props) => { + const stopPropagation = React.useCallback((e: React.KeyboardEvent) => { + const hasModifiers = e.altKey || e.metaKey || e.shiftKey || e.ctrlKey + if (!hasModifiers) { + e.stopPropagation() + } + }, []) + const onEscapeKeyDown = React.useCallback((e: KeyboardEvent) => e.stopPropagation(), []) + + const [open, setOpen] = usePropControlledStateV2(props.forceOpen || false) + + const shouldShowCheckboxes = props.items.some( + (i) => !isSeparatorDropdownMenuItem(i) && i.checked != null, + ) + const shouldShowIcons = props.items.some((i) => !isSeparatorDropdownMenuItem(i) && i.icon != null) + + const onOpenChange = React.useCallback( + (isOpen: boolean) => { + setOpen(isOpen) + props.onOpenChange?.(isOpen) + }, + [props, setOpen], + ) + + const radixPortalContainer = forceNotNull( + 'Should be able to find the radix portal.', + document.getElementById(RadixComponentsPortalId), + ) + + return ( + + + {props.opener(open)} + + + + {props.items.map((item) => { + if (isSeparatorDropdownMenuItem(item)) { + return + } + return ( + +
+ {when( + shouldShowCheckboxes, +
+ +
, + )} + {when( + shouldShowIcons, +
{item.icon}
, + )} + {item.label} + {when( + item.badge != null, + {item.badge}, + )} +
+ {when(item.shortcut != null, {item.shortcut})} +
+ ) + })} +
+
+
+ ) +}) + +const Separator = React.memo(() => { + return ( + +
+ + ) +}) +Separator.displayName = 'Separator' + +type RegularRadixSelectOption = { + type: 'REGULAR' + value: string + label: string | ((isOpen: boolean, currentValue: string | null) => string) + icon?: IcnProps + placeholder?: boolean +} + +export function regularRadixSelectOption( + params: Omit, +): RegularRadixSelectOption { + return { + type: 'REGULAR', + ...params, + } +} + +type Separator = { + type: 'SEPARATOR' +} + +export function separatorRadixSelectOption(): Separator { + return { + type: 'SEPARATOR', + } +} + +export type RadixSelectOption = RegularRadixSelectOption | Separator + +function equalRadixSelectOptions( + a: RadixSelectOption | null, + b: RadixSelectOption | null, +): boolean { + if (a == null && b == null) { + return true + } + if (a == null || b == null) { + return false + } + switch (a.type) { + case 'REGULAR': + if (b.type !== 'REGULAR') { + return false + } + return a.value === b.value && a.label === b.label + case 'SEPARATOR': + return b.type === 'SEPARATOR' + default: + assertNever(a) + } +} + +function optionLabelToString( + option: RegularRadixSelectOption | null, + isOpen: boolean, + currentValue: string | null, +): string | null { + if (option == null) { + return null + } + + const label = typeof option.label === 'string' ? option.label : option.label(isOpen, currentValue) + + return `${label.charAt(0).toUpperCase()}${label.slice(1)}` +} + +export const RadixSelect = React.memo( + (props: { + id: string + value: RegularRadixSelectOption | null + options: RadixSelectOption[] + style?: CSSProperties + contentStyle?: CSSProperties + onValueChange?: (value: string) => void + contentClassName?: string + onOpenChange?: (open: boolean) => void + allowedValues?: string[] + }) => { + const stopPropagation = React.useCallback((e: React.KeyboardEvent) => { + e.stopPropagation() + }, []) + + const { onOpenChange: propsOnOpenChange } = props + + const [isOpen, setIsOpen] = React.useState(false) + const onOpenChange = React.useCallback( + (open: boolean) => { + setIsOpen(open) + propsOnOpenChange?.(open) + }, + [propsOnOpenChange], + ) + + const valueLabel = React.useMemo(() => { + return optionLabelToString(props.value ?? null, isOpen, props.value?.value ?? null) + }, [props.value, isOpen]) + + const options = React.useMemo(() => { + let fullOptions = [...props.options] + + if ( + // the value is not null + props.value != null && + // the value is allowed for this dropdown + props.allowedValues?.some((allowed) => allowed === props.value?.value) && + // the options don't contain the value already + !fullOptions.some((opt) => equalRadixSelectOptions(opt, props.value)) + ) { + // add the given option + separator at the top of the options + fullOptions.unshift(...[props.value, separatorDropdownMenuItem('unknown-dropdown-value')]) + } + + return fullOptions + }, [props.options, props.value, props.allowedValues]) + + const onMouseDownOutside = React.useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + onOpenChange(false) + }, + [onOpenChange], + ) + + return ( + + + + + + + + + +
+ + + + + {options.map((option, index) => { + if (option.type === 'SEPARATOR') { + return ( + + ) + } + + const label = optionLabelToString(option, isOpen, props.value?.value ?? null) + return ( + + {props.value?.value === option.value ? ( + + ) : ( +
+ )} + {option.icon != null ? ( + + ) : null} + {label} + + ) + })} + + + + + + + + ) + }, +) +RadixSelect.displayName = 'RadixSelect' diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/css-vars.tsx b/nexus-builder/packages/navigator/src/uuiui/styles/css-vars.tsx new file mode 100644 index 000000000000..d77302f63c03 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/css-vars.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react' +import { getCurrentTheme } from '../../components/editor/store/editor-state' +import { Substores, useEditorState } from '../../components/editor/store/store-hook' +import { sendSetVSCodeTheme } from '../../core/vscode/vscode-bridge' +import type { Theme } from './theme' +import { getPreferredColorScheme } from './theme' +import { colorThemeCssVariables, darkColorThemeCssVariables } from './theme/utopia-theme' + +export const ColorThemeComponent = React.memo(() => { + const themeSetting = useEditorState( + Substores.theme, + (store) => store.userState.themeConfig, + 'currentTheme', + ) + const currentTheme: Theme = useEditorState( + Substores.theme, + (store) => getCurrentTheme(store.userState), + 'currentTheme', + ) + const colorTheme = currentTheme === 'dark' ? darkColorThemeCssVariables : colorThemeCssVariables + + // a dummy state used to force a re-render when the system preferred color scheme + // change event fires + const [theme, setTheme] = useState('') + + useEffect(() => { + const handlePreferredColorSchemeChange = () => { + const preferredColorScheme = getPreferredColorScheme() + sendSetVSCodeTheme(preferredColorScheme) + setTheme(preferredColorScheme) + } + + const colorSchemeQuery = window?.matchMedia?.('(prefers-color-scheme: dark)') + + if (themeSetting === 'system') { + colorSchemeQuery?.addEventListener('change', handlePreferredColorSchemeChange) + } + + return function cleanup() { + colorSchemeQuery?.removeEventListener('change', handlePreferredColorSchemeChange) + } + }, [themeSetting, theme]) + + return ( + + ) +}) + +interface AlternateThemeProps {} + +// TODO: Extend this to work with multiple alternate "themes" +export const AlternateColorThemeComponent = React.memo( + (props: React.PropsWithChildren) => { + return
{props.children}
+ }, +) diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/layout-styles.ts b/nexus-builder/packages/navigator/src/uuiui/styles/layout-styles.ts new file mode 100644 index 000000000000..76fc8caef5e3 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/layout-styles.ts @@ -0,0 +1,30 @@ +import type React from 'react' + +export const flexRowStyle: Pick< + React.CSSProperties, + 'display' | 'flexDirection' | 'alignItems' | 'whiteSpace' +> = { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + whiteSpace: 'nowrap', +} as const + +export const flexColumnStyle: Pick< + React.CSSProperties, + 'display' | 'flexDirection' | 'whiteSpace' +> = { + display: 'flex', + flexDirection: 'column', + whiteSpace: 'nowrap', +} as const + +export const tileStyle: Pick< + React.CSSProperties, + 'display' | 'flexDirection' | 'justifyContent' | 'alignItems' +> = { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', +} as const diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/theme/dark.ts b/nexus-builder/packages/navigator/src/uuiui/styles/theme/dark.ts new file mode 100644 index 000000000000..3ce901a5a19a --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/theme/dark.ts @@ -0,0 +1,279 @@ +import { createUtopiColor, enforceUtopiColorTheme } from '../utopi-color-helpers' +import type { light } from './light' + +const darkBase = { + primary: createUtopiColor('oklch(59% 0.25 254)'), + primarySubdued: createUtopiColor('oklch(43% 0.15 254)'), + primary10solid: createUtopiColor('oklch(0.98 0.01 253.75)'), + primary10: createUtopiColor('oklch(59% 0.25 254 / 10%)'), + primary25: createUtopiColor('oklch(59% 0.25 254 / 25%)'), + primary30: createUtopiColor('oklch(59% 0.25 254 / 30%)'), + primary50: createUtopiColor('oklch(59% 0.25 254 / 50%)'), + component: createUtopiColor('oklch(53% 0.31 290)'), + componentChild: createUtopiColor('oklch(83.6% 0.198 81.5)'), + componentChild20: createUtopiColor('oklch(83.6% 0.198 81.5 / 20%)'), + css: createUtopiColor('oklch(86.6% 0.27 158.6)'), + white: createUtopiColor('oklch(100% 0 0)'), + black: createUtopiColor('oklch(0% 0 0)'), + brandPurple: createUtopiColor('oklch(53% 0.31 290)'), + brandPurple70: createUtopiColor('oklch(53% 0.31 290 / 70%)'), + brandNeonPink: createUtopiColor('oklch(78.64% 0.237 327.81)'), + brandNeonPink10: createUtopiColor('oklch(78.64% 0.237 327.81 / 10%)'), + brandNeonPink60: createUtopiColor('oklch(78.64% 0.237 327.81 / 50%)'), + brandNeonGreen: createUtopiColor('oklch(86.6% 0.27 158.6)'), + brandNeonOrange: createUtopiColor('oklch(79% 0.19 70)'), + green: createUtopiColor('oklch(88% 0.2535 150)'), + green10: createUtopiColor('oklch(88% 0.2535 150 / 10%)'), + green20: createUtopiColor('oklch(88% 0.2535 150 / 20%)'), + pinkSubdued: createUtopiColor('oklch(33% 0.07 327)'), + secondaryBlue: createUtopiColor('oklch(75.44% 0.138 251.22)'), + secondaryOrange: createUtopiColor('oklch(81.8% 0.141 47)'), + denimBlue: createUtopiColor('oklch(33.65% 0.09 255)'), + lightDenimBlue: createUtopiColor('oklch(25% 0.07 255)'), + selectionBlue: createUtopiColor('oklch(66.9% 0.18 248.8)'), + selectionBlue10: createUtopiColor('oklch(66.9% 0.18 248.8 / 10%)'), + selectionBlue20: createUtopiColor('oklch(66.9% 0.18 248.8 / 20%)'), + childSelectionBlue: createUtopiColor('oklch(35.15% 0.11 243)'), + selectionPurple: createUtopiColor('oklch(53.22% 0.28 289.7)'), + childSelectionPurple: createUtopiColor('oklch(32.25% 0.13 293.16)'), + transparent: createUtopiColor('oklch(0% 0 0 / 0%)'), + error: createUtopiColor('oklch(67.99% 0.261 22.81)'), + componentOrange: createUtopiColor('oklch(80.6% 0.15 50)'), + componentPurple: createUtopiColor('oklch(76% 0.155 300)'), + componentPurple05: createUtopiColor('oklch(76% 0.155 300 / 5%)'), + componentPurple05solid: createUtopiColor('oklch(0.4 0.04 284.66)'), + dynamicBlue: createUtopiColor('oklch(81% 0.11 241)'), + dynamicBlue10: createUtopiColor('oklch(81% 0.11 241 / 10%)'), + dynamicBlue30: createUtopiColor('oklch(81% 0.11 241 / 30%)'), + unavailable: createUtopiColor('oklch(0% 0 0 / 5%)'), + unavailableGrey: createUtopiColor('oklch(100% 0 0 / 22%)'), + unavailableGrey10: createUtopiColor('oklch(100% 0 0 / 10%)'), + aqua: createUtopiColor('oklch(86% 0.135 208.71)'), + aqua10: createUtopiColor('oklch(86% 0.135 208.71 / 10%)'), + aqua05solid: createUtopiColor('oklch(0.41 0.03 238.48)'), + bg510solid: createUtopiColor('oklch(0.41 0.02 269.74)'), + bg0: createUtopiColor('#000000'), + bg1: createUtopiColor('#181C20'), + bg1subdued: createUtopiColor('#1e2226'), + bg2: createUtopiColor('#232630'), + bg3: createUtopiColor('#393d49'), + bg4: createUtopiColor('#55575f'), + bg5: createUtopiColor('#848998'), + bg6: createUtopiColor('#1a1a1a'), + fg0: createUtopiColor('#ffffff'), + fg1: createUtopiColor('#D9DCE3'), + fg2: createUtopiColor('#c9cCc3'), + fg3: createUtopiColor('#b9bCb3'), + fg4: createUtopiColor('#a9aCa3'), + fg5: createUtopiColor('#8B91A0'), + fg6: createUtopiColor('#6F778B'), + fg7: createUtopiColor('#545454'), + fg8: createUtopiColor('#2F374A'), + fg9: createUtopiColor('#151A27'), + border0: createUtopiColor('#181C20'), + border1: createUtopiColor('#181C20'), + border2: createUtopiColor('#181C20'), + border3: createUtopiColor('#181C20'), + bg1transparentgradient: createUtopiColor('radial-gradient(circle, #181C20 15%, #181C2000 80%)'), + gridControlsPink: createUtopiColor('oklch(72.2% 0.36 331.7)'), // copy of light theme's brandNeonPink +} + +const darkPrimitives = { + // backgrounds + emphasizedBackground: darkBase.bg0, + emphasizedBackgroundPop: createUtopiColor('rgba(0,0,0,1)'), + emphasizedBackgroundReduced: createUtopiColor('rgba(5,5,5,1)'), + neutralBackground: darkBase.bg1, + secondaryBackground: darkBase.bg2, + subtleBackground: darkBase.bg2, + neutralInvertedBackground: darkBase.fg1, + dialogBackground: darkBase.bg2, + dialogBackground2: darkBase.bg3, + popupBorder: darkBase.bg0, + + emphasizedForeground: darkBase.fg0, + neutralForeground: darkBase.fg1, + subduedForeground: darkBase.fg5, + verySubduedForeground: darkBase.fg8, + neutralInvertedForeground: darkBase.bg0, + + neutralBorder: darkBase.border1, + secondaryBorder: darkBase.border2, + subduedBorder: darkBase.border3, + + checkerboardLight: createUtopiColor('rgb(67,67,67)'), + checkerboardDark: createUtopiColor('rgb(44, 45, 48)'), +} + +const darkErrorStates = { + errorForeground: darkBase.error, + // TODO vv only used by button, refactor button and remove + errorForegroundEmphasized: createUtopiColor('rgba(245,0,57,1)'), + warningForeground: darkBase.componentChild, + // TODO vv only used by image-thumbnail-control, consider removing + warningBgTranslucent: createUtopiColor('rgba(250, 94, 0, 0.2)'), + warningBgSolid: createUtopiColor('rgba(252,142,77,1)'), + warningOrange: createUtopiColor('#FFB751'), +} + +// TEMP colors with preset opacity pulled from within the app +const colorsWithOpacity = { + isolator: createUtopiColor('#00000080'), + shadow90: createUtopiColor('#00000090'), + shadow85: createUtopiColor('#00000085'), + shadow80: createUtopiColor('#00000080'), + shadow75: createUtopiColor('#00000075'), + shadow70: createUtopiColor('#00000070'), + shadow65: createUtopiColor('#00000065'), + shadow60: createUtopiColor('#00000060'), + shadow55: createUtopiColor('#00000055'), + shadow50: createUtopiColor('#00000050'), + shadow45: createUtopiColor('#00000045'), + shadow40: createUtopiColor('#00000040'), + shadow35: createUtopiColor('#00000035'), + shadow30: createUtopiColor('#00000030'), + fg0Opacity10: createUtopiColor('hsla(0,100%,100%,0.1)'), + fg0Opacity20: createUtopiColor('hsla(0,100%,100%,0.2)'), + fg6Opacity50: createUtopiColor('rgba(111, 119, 139, 0.5)'), + whiteOpacity20: createUtopiColor('oklch(100% 0 0 /20%)'), + whiteOpacity30: createUtopiColor('oklch(100% 0 0 /30%)'), + whiteOpacity35: createUtopiColor('oklch(100% 0 0 /35%)'), + grey65: createUtopiColor('oklch(65% 0 0)'), + blackOpacity35: createUtopiColor('oklch(0% 0 0 / 35%)'), + canvasControlsSizeBoxShadowColor20: createUtopiColor('rgba(0,0,0,0.20)'), + canvasControlsSizeBoxShadowColor50: createUtopiColor('rgba(0,0,0,0.5)'), + neutralInvertedBackground10: createUtopiColor('rgba(217, 220, 227, 0.1)'), + neutralInvertedBackground20: createUtopiColor('rgba(217, 220, 227, 0.2)'), + neutralInvertedBackground30: createUtopiColor('rgba(217, 220, 227, 0.3)'), + listNewItemFlashBackground0: createUtopiColor('rgba(211, 254, 162, 0)'), + + // TODO vv only used by button, refactor & remove + errorForeground20: createUtopiColor('rgba(253, 0, 59, 0.2)'), + subduedBorder80: createUtopiColor('rgba(24, 28, 32, 0.8)'), + + cartoucheLiteralHighlightDefault: createUtopiColor('rgba(255, 255, 255, 0.1)'), + cartoucheLiteralHighlightHovered: createUtopiColor('rgba(255, 255, 255, 0.2)'), + cartoucheLiteralHighlightSelected: createUtopiColor('rgba(255, 255, 255, 0.4)'), +} + +const darkTheme: typeof light = { + ...colorsWithOpacity, + ...darkBase, + ...darkPrimitives, + ...darkErrorStates, + + textColor: darkBase.white, + panelShadowColor: createUtopiColor('rgba(0,0,0, .3)'), + seperator: createUtopiColor('#282B35'), + + // big sections + leftMenuBackground: darkPrimitives.neutralBackground, + leftPaneBackground: darkPrimitives.neutralBackground, + inspectorBackground: darkPrimitives.neutralBackground, + canvasBackground: darkBase.bg3, + canvasLiveBackground: createUtopiColor('rgba(195,197,201,1)'), + canvasLiveBorder: darkBase.primary, + + // tabs. Nb: active tab matches canvasBackground + tabSelectedForeground: darkPrimitives.emphasizedForeground, + tabHoveredBackground: darkPrimitives.secondaryBackground, + + // lists + listNewItemFlashBackground: createUtopiColor('rgb(211, 254, 162)'), + + // canvas controls + canvasControlsSizeBoxBackground: createUtopiColor('white'), + canvasControlsSizeBoxShadowColor: createUtopiColor('black'), + canvasControlsSizeBoxBorder: createUtopiColor('hsl(0,0%,15%)'), + canvasControlReorderSliderBoxShadowPrimary: createUtopiColor('rgba(52,52,52,0.35)'), + canvasControlReorderSliderBoxShadowSecondary: createUtopiColor('rgba(166,166,166,0.82)'), + canvasControlsCoordinateSystemMarks: darkBase.brandNeonPink, + canvasControlsImmediateParentMarks: createUtopiColor('rgba(0,0,0,0.25)'), + // TODO vv refactor - only used by self-layout-subsection indirection + canvasControlsInlineIndicatorInactive: createUtopiColor('rgba(179,215,255,1)'), + // TODO vv refactor - following four *only* used by inline button + canvasControlsInlineToggleUnsetText: createUtopiColor('rgba(179,215,255,1)'), + canvasControlsInlineToggleHoverBackground: createUtopiColor('rgba(242,248,255,1)'), + canvasControlsInlineToggleHoverText: createUtopiColor('rgba(26,135,255,1)'), + canvasControlsInlineToggleActiveBackground: createUtopiColor('rgba(230,242,255,1)'), + + canvasControlsCornerOutline: createUtopiColor('rgba(103, 142, 255, 1)'), + canvasControlsDimensionableControlShadow: createUtopiColor('rgba(140,140,140,.9)'), + + canvasSelectionPrimaryOutline: darkBase.primary, + canvasSelectionInstanceOutline: darkBase.brandPurple, + canvasSelectionSceneOutline: darkBase.brandPurple, + canvasSelectionRandomDOMElementInstanceOutline: createUtopiColor('oklch(59.82% 0 0)'), + canvasSelectionSecondaryOutline: createUtopiColor('rgba(217, 220, 227, 0.5)'), // fg1 + canvasSelectionNotFocusable: createUtopiColor('oklch(59.82% 0 0)'), + + canvasSelectionFocusable: darkBase.brandPurple, + canvasSelectionIsolatedComponent: darkBase.brandPurple, + //Children of isolated component + canvasSelectionNotFocusableChild: createUtopiColor('oklch(63% 0.22 41)'), + canvasSelectionFocusableChild: darkBase.brandPurple, + + canvasLayoutStroke: darkBase.brandNeonPink, + + paddingForeground: darkBase.brandNeonGreen, + paddingFillTranslucent: createUtopiColor('rgba(230,248,230,0.9)'), + + canvasElementBackground: createUtopiColor('rgba(230,242,255,1)'), + canvasComponentButtonFocusable: createUtopiColor('rgba(238,237,252,1)'), + canvasComponentButtonFocused: createUtopiColor('rgba(255,239,230,1)'), + inspectorControlledBackground: createUtopiColor('rgba(242,248,255,1)'), + + textEditableOutline: darkBase.primary, + + // interface elements: buttons, segment controls, checkboxes etc + + inlineButtonColor: darkBase.primary, + buttonBackground: darkBase.bg2, + buttonHoverBackground: darkBase.bg3, + buttonShadow: darkBase.fg9, + buttonShadowActive: darkBase.fg8, + + // application utilities: + navigatorResizeHintBorder: darkBase.primary, + navigatorComponentName: darkBase.primary, + navigatorComponentSelected: darkBase.componentChild20, + navigatorComponentIconBorder: darkBase.componentChild, + + contextMenuBackground: darkBase.bg0, + contextMenuForeground: darkBase.white, + contextMenuHighlightForeground: darkBase.white, + contextMenuHighlightBackground: darkBase.primary, + contextMenuSeparator: createUtopiColor('rgba(255,255,255,0.35)'), + + inspectorHoverColor: darkBase.fg8, + inspectorFocusedColor: darkBase.dynamicBlue, + inspectorSetBorderColor: darkPrimitives.neutralBorder, + flasherHookColor: darkBase.brandNeonPink, + + // Github pane + githubBoxesBorder: createUtopiColor('#282a2d'), + gitubIndicatorConnectorLine: createUtopiColor('#686a6d'), + githubIndicatorSuccessful: createUtopiColor('#1FCCB7'), + githubIndicatorFailed: createUtopiColor('#FF7759'), + githubIndicatorIncomplete: createUtopiColor('#FFFFFF00'), + githubMUDUntracked: createUtopiColor('#09f'), + githubMUDModified: createUtopiColor('#f90'), + githubMUDDeleted: createUtopiColor('#f22'), + githubMUDDefault: createUtopiColor('#ccc'), + + // Code editor loading screen + codeEditorShimmerPrimary: darkBase.bg4, + codeEditorShimmerSecondary: darkBase.bg5, + codeEditorTabRowBg: darkBase.bg2, + codeEditorTabSelectedBG: darkBase.bg1, + codeEditorTabSelectedFG: darkBase.fg0, + codeEditorTabSelectedBorder: darkBase.bg2, + codeEditorBreadcrumbs: darkBase.fg5, + codeEditorTabRowFg: darkBase.fg5, + codeEditorGrid: createUtopiColor('#6d705b'), + + // Gap controls + gapControlsBg: darkBase.brandNeonOrange, +} + +export const dark = enforceUtopiColorTheme(darkTheme) diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/theme/index.ts b/nexus-builder/packages/navigator/src/uuiui/styles/theme/index.ts new file mode 100644 index 000000000000..49fc17ce4eb4 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/theme/index.ts @@ -0,0 +1,18 @@ +import type { ThemeObject } from './theme-helpers' +import { colorTheme } from './utopia-theme' + +export type Theme = 'light' | 'dark' + +export const useColorTheme = (): ThemeObject => { + return colorTheme +} + +export function getPreferredColorScheme(): Theme { + if (window?.matchMedia?.('(prefers-color-scheme: dark)').matches) { + return 'dark' + } else { + return 'light' + } +} + +export { colorTheme, UtopiaStyles, UtopiaTheme } from './utopia-theme' diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/theme/light.ts b/nexus-builder/packages/navigator/src/uuiui/styles/theme/light.ts new file mode 100644 index 000000000000..e520b41c10b4 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/theme/light.ts @@ -0,0 +1,280 @@ +import { UtopiColor, createUtopiColor, enforceUtopiColorTheme } from '../utopi-color-helpers' +import type { dark } from './dark' + +const lightBase = { + primary: createUtopiColor('oklch(59% 0.25 254)'), + primarySubdued: createUtopiColor('oklch(75% 0.15 254)'), + primary10solid: createUtopiColor('oklch(0.98 0.01 253.75)'), + primary10: createUtopiColor('oklch(59% 0.25 254 / 10%)'), + primary25: createUtopiColor('oklch(59% 0.25 254 / 25%)'), + primary30: createUtopiColor('oklch(59% 0.25 254 / 30%)'), + primary50: createUtopiColor('oklch(59% 0.25 254 / 50%)'), + component: createUtopiColor('oklch(53% 0.31 290)'), + componentChild: createUtopiColor('oklch(83.6% 0.198 81.5)'), + componentChild20: createUtopiColor('oklch(83.6% 0.198 81.5 / 20%)'), + css: createUtopiColor('oklch(69% 0.18 166.76)'), + white: createUtopiColor('oklch(100% 0 0)'), + black: createUtopiColor('oklch(0% 0 0)'), + brandPurple: createUtopiColor('oklch(53% 0.31 290)'), + brandPurple70: createUtopiColor('oklch(53% 0.31 290 / 70%)'), + brandNeonPink: createUtopiColor('oklch(72.2% 0.36 331.7)'), + brandNeonPink10: createUtopiColor('oklch(72.53% 0.353 331.69 / 10%)'), + brandNeonPink60: createUtopiColor('oklch(72.53% 0.353 331.69 / 30%)'), + brandNeonGreen: createUtopiColor('oklch(86.6% 0.27 158.6)'), + brandNeonOrange: createUtopiColor('oklch(79% 0.19 70)'), + green: createUtopiColor('oklch(64.6% 0.17 150.6)'), + green10: createUtopiColor('oklch(64.6% 0.17 150.6 / 10%)'), + green20: createUtopiColor('oklch(64.6% 0.17 150.6 / 20%)'), + pinkSubdued: createUtopiColor('oklch(92% 0.076 326)'), + secondaryBlue: createUtopiColor('oklch(74.5% 0.14 241.9)'), + secondaryOrange: createUtopiColor('oklch(78.97% 0.192 70)'), + denimBlue: createUtopiColor('oklch(91.3% 0.04 252)'), + lightDenimBlue: createUtopiColor('oklch(97% 0.02 254)'), + selectionBlue: createUtopiColor('oklch(66.9% 0.18 248.8)'), + selectionBlue10: createUtopiColor('oklch(66.9% 0.18 248.8 / 10%)'), + selectionBlue20: createUtopiColor('oklch(66.9% 0.18 248.8 / 20%)'), + childSelectionBlue: createUtopiColor('oklch(92.39% 0.05 236.2)'), + selectionPurple: createUtopiColor('oklch(53.22% 0.28 289.7)'), + childSelectionPurple: createUtopiColor('oklch(89.45% 0.06 302.7)'), + transparent: createUtopiColor('oklch(0% 0 0 / 0%)'), + error: createUtopiColor('oklch(66% 0.3 11.65)'), + componentOrange: createUtopiColor('oklch(68% 0.2 42)'), + componentPurple: createUtopiColor('oklch(53% 0.31 290)'), + componentPurple05: createUtopiColor('oklch(53.19% 0.307 289.7 / 5%)'), + componentPurple05solid: createUtopiColor('oklch(91.14% 0.018 303.4)'), + dynamicBlue: createUtopiColor('oklch(59% 0.25 254)'), + dynamicBlue10: createUtopiColor('oklch(58.98% 0.246 254.39 / 10%)'), + dynamicBlue30: createUtopiColor('oklch(58.98% 0.246 254.39 / 30%)'), + unavailable: createUtopiColor('oklch(54.52% 0 0 / 5%)'), + unavailableGrey: createUtopiColor('oklch(0% 0 0 / 22%)'), + unavailableGrey10: createUtopiColor('oklch(0% 0 0 / 10%)'), + aqua: createUtopiColor('oklch(51.89% 0.09 211.12)'), + aqua10: createUtopiColor('oklch(51.89% 0.09 211.12 / 10%)'), + aqua05solid: createUtopiColor('oklch(91.86% 0.016 216.68)'), + bg510solid: createUtopiColor('oklch(90.97% 0 0)'), + bg0: createUtopiColor('hsl(0,0%,100%)'), + bg1: createUtopiColor('lch(99.5 0.01 0)'), + bg1subdued: createUtopiColor('lch(98 0.01 0)'), + bg2: createUtopiColor('lch(96.0 0.01 0)'), + bg3: createUtopiColor('hsl(0,0%,94%)'), + bg4: createUtopiColor('hsl(0,0%,92%)'), + bg5: createUtopiColor('hsl(0,0%,90%)'), + bg6: createUtopiColor('#ffffff'), + fg0: createUtopiColor('hsl(0,0%,0%)'), + fg1: createUtopiColor('hsl(0,0%,10%)'), + fg2: createUtopiColor('hsl(0,0%,20%)'), + fg3: createUtopiColor('hsl(0,0%,30%)'), + fg4: createUtopiColor('hsl(0,0%,40%)'), + fg5: createUtopiColor('hsl(0,0%,50%)'), + fg6: createUtopiColor('hsl(0,0%,60%)'), + fg7: createUtopiColor('hsl(0,0%,70%)'), + fg8: createUtopiColor('hsl(0,0%,80%)'), + fg9: createUtopiColor('hsl(0,0%,90%)'), + border0: createUtopiColor('hsl(0,0%,93%)'), + border1: createUtopiColor('hsl(0,0%,91%)'), + border2: createUtopiColor('hsl(0,0%,86%)'), + border3: createUtopiColor('hsl(0,0%,83%)'), + bg1transparentgradient: createUtopiColor('radial-gradient(circle, #ffffff 15%, #ffffff00 80%)'), + gridControlsPink: createUtopiColor('oklch(72.2% 0.36 331.7)'), // copy of brandNeonPink +} + +const lightPrimitives = { + // backgrounds + emphasizedBackground: lightBase.bg0, + emphasizedBackgroundPop: lightBase.bg1, + emphasizedBackgroundReduced: lightBase.white, + neutralBackground: lightBase.bg1, + secondaryBackground: lightBase.bg2, + subtleBackground: lightBase.bg3, + neutralInvertedBackground: lightBase.fg1, + dialogBackground: lightBase.bg1, + dialogBackground2: lightBase.bg4, + popupBorder: lightBase.bg5, + + emphasizedForeground: lightBase.fg0, + neutralForeground: lightBase.fg1, + subduedForeground: lightBase.fg5, + verySubduedForeground: lightBase.fg8, + neutralInvertedForeground: lightBase.bg0, + + neutralBorder: lightBase.border3, + secondaryBorder: lightBase.border2, + subduedBorder: lightBase.border1, + + checkerboardLight: createUtopiColor('oklch(100% 0 0)'), + checkerboardDark: createUtopiColor('rgb(247 247 247)'), +} + +const lightErrorStates = { + errorForeground: createUtopiColor('oklch(66% 0.3 11.65)'), + errorForegroundEmphasized: createUtopiColor('rgba(245,0,57,1)'), + // TODO vv only used by button, refactor button and remove + warningForeground: createUtopiColor('oklch(83.6% 0.198 81.5)'), + // TODO vv only used by image-thumbnail-control, consider removing + warningBgTranslucent: createUtopiColor('rgba(250, 94, 0, 0.2)'), + warningBgSolid: createUtopiColor('rgba(252,142,77,1)'), + warningOrange: createUtopiColor('#FF8A44'), +} + +// TEMP colors with preset opacity pulled from within the app +const colorsWithOpacity = { + isolator: createUtopiColor('#00000025'), + shadow90: createUtopiColor('#00000065'), + shadow85: createUtopiColor('#00000055'), + shadow80: createUtopiColor('#00000045'), + shadow75: createUtopiColor('#00000040'), + shadow70: createUtopiColor('#00000040'), + shadow65: createUtopiColor('#00000035'), + shadow60: createUtopiColor('#00000035'), + shadow55: createUtopiColor('#00000025'), + shadow50: createUtopiColor('#00000020'), + shadow45: createUtopiColor('#00000015'), + shadow40: createUtopiColor('#00000010'), + shadow35: createUtopiColor('#00000005'), + shadow30: createUtopiColor('#00000005'), + + fg0Opacity10: createUtopiColor('hsla(0,0%,0%,0.1)'), + fg0Opacity20: createUtopiColor('hsla(0,0%,0%,0.2)'), + fg6Opacity50: createUtopiColor('hsla(0,0%,0%,0.5)'), + whiteOpacity20: createUtopiColor('oklch(100% 0 0 /20%)'), + whiteOpacity30: createUtopiColor('oklch(100% 0 0 /30%)'), + whiteOpacity35: createUtopiColor('oklch(100% 0 0 /35%)'), + blackOpacity35: createUtopiColor('oklch(0% 0 0 / 35%)'), + grey65: createUtopiColor('oklch(65% 0 0)'), + canvasControlsSizeBoxShadowColor20: createUtopiColor('rgba(0,0,0,0.20)'), + canvasControlsSizeBoxShadowColor50: createUtopiColor('rgba(0,0,0,0.5)'), + neutralInvertedBackground10: createUtopiColor('hsla(0,0%,0%,0.1)'), + neutralInvertedBackground20: createUtopiColor('hsla(0,0%,0%,0.2)'), + neutralInvertedBackground30: createUtopiColor('hsla(0,0%,0%,0.3)'), + // the following is used with an animation to zero opacity but same colour value + listNewItemFlashBackground0: createUtopiColor('rgba(211, 254, 162, 0)'), + // TODO vv only used by button, refactor & remove + errorForeground20: createUtopiColor('rgba(253, 0, 59, 0.2)'), + subduedBorder80: createUtopiColor('hsla(0, 0%, 91%, 0.8)'), + + cartoucheLiteralHighlightDefault: createUtopiColor('rgba(43, 43, 43, 0.1)'), + cartoucheLiteralHighlightHovered: createUtopiColor('rgba(43, 43, 43, 0.2)'), + cartoucheLiteralHighlightSelected: createUtopiColor('rgba(43, 43, 43, 0.5)'), +} + +const lightTheme = { + ...colorsWithOpacity, + ...lightBase, + ...lightPrimitives, + ...lightErrorStates, + + textColor: createUtopiColor('oklch(21.56% 0 0)'), + + panelShadowColor: createUtopiColor('rgba(0,0,0, .3)'), + seperator: createUtopiColor('hsl(0,0%,92%)'), + + // big sections + leftMenuBackground: lightPrimitives.neutralBackground, + leftPaneBackground: lightPrimitives.neutralBackground, + inspectorBackground: lightPrimitives.neutralBackground, + canvasBackground: lightBase.bg4, + canvasLiveBackground: createUtopiColor('rgba(252,252,252,1)'), + canvasLiveBorder: lightBase.primary, + + // tabs. Nb: active tab matches canvasBackground + tabSelectedForeground: lightPrimitives.emphasizedForeground, + tabHoveredBackground: lightPrimitives.secondaryBackground, + + // lists + listNewItemFlashBackground: createUtopiColor('rgba(211, 254, 162, 1)'), + + // canvas controls + canvasControlsSizeBoxBackground: createUtopiColor('white'), + canvasControlsSizeBoxShadowColor: createUtopiColor('black'), + canvasControlsSizeBoxBorder: createUtopiColor('hsl(0,0%,15%)'), + canvasControlReorderSliderBoxShadowPrimary: createUtopiColor('rgba(52,52,52,0.35)'), + canvasControlReorderSliderBoxShadowSecondary: createUtopiColor('hsl(0,0%,0%,0.5)'), + canvasControlsCoordinateSystemMarks: lightBase.brandNeonPink, + canvasControlsImmediateParentMarks: createUtopiColor('rgba(0, 0, 0, 0.25)'), + canvasControlsInlineIndicatorInactive: createUtopiColor('rgba(179,215,255,1)'), + canvasControlsInlineToggleUnsetText: createUtopiColor('rgba(179,215,255,1)'), + canvasControlsInlineToggleHoverBackground: createUtopiColor('rgba(242,248,255,1)'), + canvasControlsInlineToggleHoverText: createUtopiColor('rgba(26,135,255,1)'), + canvasControlsInlineToggleActiveBackground: createUtopiColor('rgba(230,242,255,1)'), + + canvasControlsCornerOutline: createUtopiColor('rgba(103, 142, 255, 1)'), + canvasControlsDimensionableControlShadow: createUtopiColor('rgba(140,140,140,.9)'), + + canvasSelectionPrimaryOutline: lightBase.primary, + canvasSelectionInstanceOutline: lightBase.brandPurple, + canvasSelectionSceneOutline: lightBase.brandPurple, + canvasSelectionRandomDOMElementInstanceOutline: createUtopiColor('oklch(59.82% 0 0)'), + canvasSelectionSecondaryOutline: createUtopiColor('hsla(0,0%,10%,0.5)'), + canvasSelectionNotFocusable: createUtopiColor('oklch(59.82% 0 0)'), + + canvasSelectionFocusable: lightBase.brandPurple, + canvasSelectionIsolatedComponent: lightBase.brandPurple, + //Children of isolated component + canvasSelectionNotFocusableChild: createUtopiColor('oklch(63% 0.22 41)'), + canvasSelectionFocusableChild: lightBase.brandPurple, + + canvasLayoutStroke: lightBase.brandNeonPink, + + paddingForeground: lightBase.brandNeonGreen, + paddingFillTranslucent: createUtopiColor('rgba(230,248,230,0.7)'), + + canvasElementBackground: createUtopiColor('rgba(230,242,255,1)'), + canvasComponentButtonFocusable: createUtopiColor('rgba(238,237,252,1)'), + canvasComponentButtonFocused: createUtopiColor('rgba(255,239,230,1)'), + inspectorControlledBackground: createUtopiColor('rgba(242,248,255,1)'), + + textEditableOutline: lightBase.primary, + + // interface elements: buttons, segment controls, checkboxes etc + + inlineButtonColor: lightBase.primary, + buttonBackground: lightBase.bg2, + buttonHoverBackground: lightBase.bg3, + buttonShadow: lightBase.fg9, + buttonShadowActive: lightBase.fg8, + + // application utilities: + navigatorResizeHintBorder: lightBase.primary, + navigatorComponentName: lightBase.primary, + navigatorComponentSelected: lightBase.componentChild20, + navigatorComponentIconBorder: lightBase.componentChild, + + contextMenuBackground: createUtopiColor('#181C20'), + contextMenuForeground: lightBase.white, + contextMenuHighlightForeground: lightBase.white, + contextMenuHighlightBackground: lightBase.primary, + contextMenuSeparator: createUtopiColor('rgba(255,255,255,0.35)'), + + inspectorHoverColor: lightBase.fg8, + inspectorFocusedColor: lightBase.primary, + inspectorSetBorderColor: lightPrimitives.neutralBorder, + flasherHookColor: lightBase.brandNeonPink, + + // Github pane + githubBoxesBorder: createUtopiColor('#2D2E33'), + gitubIndicatorConnectorLine: lightBase.black, + githubIndicatorSuccessful: createUtopiColor('#1FCCB7'), + githubIndicatorFailed: createUtopiColor('#FF7759'), + githubIndicatorIncomplete: createUtopiColor('#FFFFFF00'), + githubMUDUntracked: createUtopiColor('#09f'), + githubMUDModified: createUtopiColor('#f90'), + githubMUDDeleted: createUtopiColor('#f22'), + githubMUDDefault: createUtopiColor('#ccc'), + + // Code editor loading screen + codeEditorShimmerPrimary: lightBase.bg4, + codeEditorShimmerSecondary: createUtopiColor('#D6D6D6'), + codeEditorTabRowBg: lightBase.bg2, + codeEditorTabSelectedBG: lightBase.bg1, + codeEditorTabSelectedFG: lightBase.fg0, + codeEditorTabSelectedBorder: lightBase.bg2, + codeEditorBreadcrumbs: lightBase.fg5, + codeEditorTabRowFg: lightBase.fg5, + codeEditorGrid: createUtopiColor('#6d705b'), + + // Gap controls + gapControlsBg: lightBase.brandNeonOrange, +} + +// all values in light must be of the type UtopiColor! This will break if you made a mistake. +export const light = enforceUtopiColorTheme(lightTheme) diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/theme/theme-helpers.ts b/nexus-builder/packages/navigator/src/uuiui/styles/theme/theme-helpers.ts new file mode 100644 index 000000000000..fa8699f5c29b --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/theme/theme-helpers.ts @@ -0,0 +1,25 @@ +import { createUtopiColor } from '../utopi-color-helpers' +import type { light } from './light' + +export type ThemeObject = typeof light + +export type ThemeVariableObject = Record + +export function generateCssVariablesFromThemeObject( + themeObject: ThemeObject, + pathModifier: string = '', +): [ThemeObject, ThemeVariableObject] { + const valuesObject = { ...themeObject } + const variablesObject: ThemeVariableObject = {} + + Object.entries(themeObject).forEach((entry) => { + const [key, value] = entry + const finalPath = `--utopitheme${pathModifier}-${key}` + + const newValue = createUtopiColor(value.cssValue, finalPath) + valuesObject[key as keyof typeof light] = newValue + variablesObject[finalPath] = value.cssValue + }) + + return [valuesObject, variablesObject] +} diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/theme/utopia-theme.ts b/nexus-builder/packages/navigator/src/uuiui/styles/theme/utopia-theme.ts new file mode 100644 index 000000000000..e89bafd1aad6 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/theme/utopia-theme.ts @@ -0,0 +1,205 @@ +import { color } from '../utopitrons' +import { dark } from './dark' +import { light } from './light' +import type { ThemeObject } from './theme-helpers' +import { generateCssVariablesFromThemeObject } from './theme-helpers' + +const inspectorXPadding = 8 +const canvasMenuWidth = 38 +const inspectorSmallWidth = 255 +const inspectorLargeWidth = 300 +const inspectorSmallPaddedWidth = inspectorSmallWidth - inspectorXPadding * 2 + +const [lightTheme, lightThemeCssVariables] = generateCssVariablesFromThemeObject(light) +const [, darkThemeCssVariables] = generateCssVariablesFromThemeObject(dark) + +export const colorTheme: ThemeObject = { + ...lightTheme, +} + +export const colorThemeCssVariables = { + ...lightThemeCssVariables, +} + +export const darkColorThemeCssVariables = { + ...darkThemeCssVariables, +} + +export const UtopiaTheme = { + layout: { + rowHorizontalPadding: 8, + rowButtonSpacing: 4, + rowHeight: { + smallest: 21, + smaller: 29, + normal: 34, + large: 42, + max: 47, + }, + inputHeight: { + small: 18, + default: 22, + tall: 26, + }, + inspectorXPadding, + inspectorSmallPaddedWidth, + inspectorSmallWidth: inspectorSmallWidth, + inspectorLargeWidth: inspectorLargeWidth, + canvasMenuWidth, + inspectorModalBaseOffset: inspectorXPadding + canvasMenuWidth, + }, + inputBorderRadius: 3, + styles: { + inspectorSetSelectedOpacity: 1, + inspectorUnsetSelectedOpacity: 0.3, + inspectorSetUnselectedOpacity: 0.5, + inspectorUnsetUnselectedOpacity: 0.3, + }, + panelStyles: { + panelBorderRadius: 10, + }, +} as const + +const shadowStyles = { + //scenes directly on the canvas + grounded: { + boxShadow: `0px 1px 2px 0px ${colorTheme.shadow90.value}, 0px 2px 4px -1px ${colorTheme.shadow50.value}`, + }, + low: { + boxShadow: `0px 2px 4px -2px ${colorTheme.shadow80.value}, 0px 4px 8px -2px ${colorTheme.shadow45.value}`, + }, + mid: { + boxShadow: `0px 3px 6px -2px ${colorTheme.shadow70.value}, 0px 4px 8px -2px ${colorTheme.shadow40.value}`, + }, + high: { + boxShadow: `0px 4px 8px -2px ${colorTheme.shadow60.value}, 0px 6px 12px -2px ${colorTheme.shadow35.value}`, + }, + highest: { + boxShadow: `0px 6px 12px -2px ${colorTheme.shadow50.value}, 0px 8px 16px -2px ${colorTheme.shadow30.value}`, + }, +} + +const flexRow: React.CSSProperties = { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + whiteSpace: 'nowrap', +} +const flexColumn: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + whiteSpace: 'nowrap', +} +const flexCenter: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', +} + +// uses borders, since outlines get hidden by other elements, +// and inset box shadows get covered by scenes +// unaffected by zoom/scale/offsets, since it applies to outer canvas only + +const canvas = { + live: { + border: `1px solid ${colorTheme.canvasLiveBorder.value}`, + }, + editing: { + border: '1px solid transparent', + }, +} + +// see type AlertLevel in editor-state.ts + +const noticeStyles: { [styleName: string]: React.CSSProperties } = { + info: { + backgroundColor: colorTheme.fg1.value, + color: colorTheme.white.value, + }, + warning: { + backgroundColor: colorTheme.fg1.value, + color: colorTheme.white.value, + }, + notice: { + backgroundColor: colorTheme.primary.value, + color: colorTheme.white.value, + }, + success: { + backgroundColor: colorTheme.primary.value, + color: colorTheme.white.value, + }, + primary: { + backgroundColor: colorTheme.primary.value, + color: colorTheme.white.value, + }, + error: { + backgroundColor: colorTheme.error.value, + color: colorTheme.white.value, + }, + disconnected: { + backgroundColor: colorTheme.fg1.value, + color: colorTheme.white.value, + }, +} + +const textNoticeStyles = { + info: {}, + success: { color: colorTheme.brandNeonGreen.value }, + primary: { color: colorTheme.primary.value }, + notice: { color: colorTheme.fg5.value }, + warning: { color: colorTheme.error.value }, + error: { color: colorTheme.error.value }, + disconnected: { background: colorTheme.black.value, color: colorTheme.white.value }, +} + +const fontStyles = { + monospaced: { + fontFamily: 'Consolas, Menlo, monospace', + }, +} + +const popup: React.CSSProperties = { + background: colorTheme.neutralBackground.value, + boxShadow: shadowStyles.high.boxShadow, + padding: '4px 0', + borderRadius: 4, +} + +const checkerboardBackground: Pick< + React.CSSProperties, + 'backgroundImage' | 'backgroundSize' | 'backgroundPosition' +> = { + backgroundImage: `conic-gradient( + ${colorTheme.checkerboardLight.value} 0.25turn, + ${colorTheme.checkerboardDark.value} 0.25turn 0.5turn, + ${colorTheme.checkerboardLight.value} 0.5turn 0.75turn, + ${colorTheme.checkerboardDark.value} 0.75turn + )`, + backgroundSize: '12px 12px, 12px 12px, 12px 12px, 12px 12px', + backgroundPosition: '-9px 0px, -3px -6px, 3px 6px, -3px 0', +} + +const stripedBackground = ( + stripeColor: string, + scale: number, +): { backgroundImage: string; backgroundSize: string } => ({ + backgroundImage: `linear-gradient(135deg, ${stripeColor} 24.5%, ${colorTheme.transparent.value} 24.5%, ${colorTheme.transparent.value} 50%, ${stripeColor} 50%, ${stripeColor} 74%, ${colorTheme.transparent.value} 74%, ${colorTheme.transparent.value} 100%)`, + backgroundSize: `${4 / scale}px ${4 / scale}px`, +}) + +export const UtopiaStyles = { + backgrounds: { + checkerboardBackground, + stripedBackground, + }, + noticeStyles, + textNoticeStyles, + shadowStyles, + popup, + flexRow, + flexColumn, + flexCenter, + canvas, + fontStyles, +} as const diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/utopi-color-helpers.ts b/nexus-builder/packages/navigator/src/uuiui/styles/utopi-color-helpers.ts new file mode 100644 index 000000000000..279339320583 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/utopi-color-helpers.ts @@ -0,0 +1,15 @@ +export interface UtopiColor { + value: string + cssValue: string +} + +export function createUtopiColor( + baseColor: string, + path: string = '--utopitheme-not-set', +): UtopiColor { + return { value: `var(${path})`, cssValue: baseColor } +} + +export function enforceUtopiColorTheme(theme: T): T { + return theme +} diff --git a/nexus-builder/packages/navigator/src/uuiui/styles/utopitrons.ts b/nexus-builder/packages/navigator/src/uuiui/styles/utopitrons.ts new file mode 100644 index 000000000000..51f1e7c2f93f --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/styles/utopitrons.ts @@ -0,0 +1,284 @@ +import { css } from '@emotion/react' +import type { CSSPropertiesWithMultiValues } from '@emotion/serialize' +import { FunctionInterpolation } from '@emotion/serialize' +import type { UtopiColor } from './utopi-color-helpers' +import type { Property } from 'csstype' + +type CSSObject = CSSPropertiesWithMultiValues + +export function fill(utopiColor: UtopiColor): CSSObject { + return { + fill: utopiColor.value, + } +} + +export function stroke(utopiColor: UtopiColor): CSSObject { + return { + stroke: utopiColor.value, + } +} + +/** + * CSS background-color property + * @param utopiColor the UtopiColor of the background + * @param color2 (optional) if present, the background will be a gradient + */ +export function background( + utopiColor: UtopiColor, + color2?: UtopiColor, + deg: number = 0, +): CSSObject { + if (color2 != null) { + return { + backgroundImage: `linear-gradient(${deg}deg, ${utopiColor.value} 0%, ${color2.value} 100%)`, + } + } else { + return { + backgroundColor: utopiColor.value, + } + } +} + +export function width(value: CSSObject['width']): CSSObject { + return { + width: value, + } +} + +export function height(value: CSSObject['height']): CSSObject { + return { + height: value, + } +} + +/* Padding */ + +export function padding(value: number): CSSObject { + return { + paddingTop: value, + } +} + +export function paddingTop(value: number): CSSObject { + return { + paddingTop: value, + } +} + +export function paddingBottom(value: number): CSSObject { + return { + paddingBottom: value, + } +} + +export function paddingLeft(value: number): CSSObject { + return { + paddingLeft: value, + } +} + +export function paddingRight(value: number): CSSObject { + return { + paddingRight: value, + } +} + +export function paddingVertical(value: number): CSSObject { + return { + paddingTop: value, + paddingBottom: value, + } +} + +export function paddingHorizontal(value: number): CSSObject { + return { + paddingLeft: value, + paddingRight: value, + } +} + +/* Margin */ + +export function margin(value: number): CSSObject { + return { + marginTop: value, + } +} + +export function marginTop(value: number): CSSObject { + return { + marginTop: value, + } +} + +export function marginBottom(value: number): CSSObject { + return { + marginBottom: value, + } +} + +export function marginLeft(value: number): CSSObject { + return { + marginLeft: value, + } +} + +export function marginRight(value: number): CSSObject { + return { + marginRight: value, + } +} + +export function marginVertical(value: number): CSSObject { + return { + marginTop: value, + marginBottom: value, + } +} + +export function marginHorizontal(value: number): CSSObject { + return { + marginLeft: value, + marginRight: value, + } +} + +export function borderRadius(value: number): CSSObject { + return { + borderRadius: value, + } +} + +/* Border */ + +export function border(utopiColor: UtopiColor, lineWidth: number = 1): CSSObject { + return { + borderColor: utopiColor.value, + borderWidth: lineWidth, + borderStyle: 'solid', + } +} + +export function borderLeft(utopiColor: UtopiColor, lineWidth: number = 1): CSSObject { + return { + borderLeftColor: utopiColor.value, + borderLeftWidth: lineWidth, + borderLeftStyle: 'solid', + } +} + +export function borderRight(utopiColor: UtopiColor, lineWidth: number = 1): CSSObject { + return { + borderRightColor: utopiColor.value, + borderRightWidth: lineWidth, + borderRightStyle: 'solid', + } +} + +export function borderBottom(utopiColor: UtopiColor, lineWidth: number = 1): CSSObject { + return { + borderBottomColor: utopiColor.value, + borderBottomWidth: lineWidth, + borderBottomStyle: 'solid', + } +} + +export function borderTop(utopiColor: UtopiColor, lineWidth: number = 1): CSSObject { + return { + borderTopColor: utopiColor.value, + borderTopWidth: lineWidth, + borderTopStyle: 'solid', + } +} + +export function color(value: UtopiColor): CSSObject { + return { + color: value.value, + } +} + +/* Font Weight */ +export function fontWeight(value: number | 'normal' | 'bold'): CSSObject { + return { + fontWeight: value, + } +} +export const normal = fontWeight('normal') +export const bold = fontWeight('bold') + +/* Font Size */ +export function fontSize(value: number): CSSObject { + return { + fontSize: value, + } +} + +export function opacity(value: number): CSSObject { + return { + opacity: value, + } +} + +export function display(value: CSSObject['display']): CSSObject { + return { + display: value, + } +} + +export function items( + value: 'start' | 'flex-start' | 'end' | 'flex-end' | 'center' | 'baseline' | 'stretch', +): CSSObject { + let valueToUse = value + if (value === 'start') { + valueToUse = 'flex-start' + } + + if (value === 'end') { + valueToUse = 'flex-end' + } + + return { + alignItems: valueToUse, + } +} + +export function justifyContent(value: Property.JustifyContent): CSSObject { + return { + justifyContent: value, + } +} + +export function textDecorationLine( + value: 'none' | 'underline' | 'overline' | 'line-through' | 'blink', +): CSSObject { + return { + textDecoration: value, + } +} + +export const flexColumn: CSSObject = { + flexDirection: 'column', +} + +export const focusOutline = (utopiColor: UtopiColor) => + css({ + outline: 'none', + '&:focus': { + outline: `1px solid ${utopiColor.value}`, + }, + }) + +interface DisabledOpacityProps { + /** + * set to true if you want H1 to be in disabled style. + * shorthand syntax: + * `

...

` + */ + disabled?: boolean +} + +export const disabledOpacityStyle = (props: DisabledOpacityProps) => + css({ + opacity: props.disabled === true ? 0.5 : 1, + pointerEvents: props.disabled === true ? 'none' : 'initial', + }) diff --git a/nexus-builder/packages/navigator/src/uuiui/tab.tsx b/nexus-builder/packages/navigator/src/uuiui/tab.tsx new file mode 100644 index 000000000000..c92a0b37a318 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/tab.tsx @@ -0,0 +1,124 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import styled from '@emotion/styled' +import { FlexRow, SimpleFlexRow } from './widgets/layout/flex-row' +import { Button } from './button' +import { Icons } from './icons' + +import { useColorTheme, UtopiaTheme } from './styles/theme' +import { CSSObject } from '@emotion/serialize' +import { defaultIfNull } from '../core/shared/optional-utils' +import { NO_OP } from '../core/shared/utils' + +interface TabComponentProps { + modified?: boolean + showModifiedIndicator?: boolean + showCloseIndicator?: boolean + selected?: boolean + hasErrorMessages?: boolean + icon?: React.ReactElement | string + label: React.ReactElement | string + onClose?: () => void + onClick?: () => void + onDoubleClick?: () => void + onMouseDown?: () => void + className?: string +} + +export const TabComponent: React.FunctionComponent> = + React.memo((props) => { + const colorTheme = useColorTheme() + const [tabIsHovered, setTabIsHovered] = React.useState(false) + const [indicatorIsHovered, setIndicatorIsHovered] = React.useState(false) + + const modified = defaultIfNull(false, props.showModifiedIndicator) + const showModifiedIndicator = defaultIfNull(true, props.showModifiedIndicator) + const showCloseIndicator = defaultIfNull(true, props.showCloseIndicator) + const selected = defaultIfNull(false, props.selected) + const hasErrorMessages = defaultIfNull(false, props.hasErrorMessages) + const label = defaultIfNull('', props.label) + const icon = defaultIfNull('', props.icon) + const onClose = defaultIfNull(NO_OP, props.onClose) + + const baseStyle = { + paddingLeft: 4, + paddingRight: 4, + transition: 'all .05s ease-in-out', + '&:hover': { + backgroundColor: colorTheme.tabHoveredBackground.value, + }, + cursor: 'pointer', + } + + const selectionHandlingStyle = { + color: hasErrorMessages + ? colorTheme.errorForeground.value + : colorTheme.tabSelectedForeground.value, + + fontWeight: selected ? 500 : undefined, + } + + const modifiedIndicator = showModifiedIndicator ? : null + const closeIndicator = showCloseIndicator ? : null + const closeIndicatorHovered = showCloseIndicator ? : null + + const tabUnhoveredIndicator = modified ? modifiedIndicator : selected ? closeIndicator : null + const tabHoveredIndicator = indicatorIsHovered ? closeIndicatorHovered : closeIndicator + + const setTabHoveredTrue = React.useCallback(() => setTabIsHovered(true), [setTabIsHovered]) + const setTabHoveredFalse = React.useCallback(() => setTabIsHovered(false), [setTabIsHovered]) + + const setIndicatorHoveredTrue = React.useCallback( + () => setIndicatorIsHovered(true), + [setIndicatorIsHovered], + ) + const setIndicatorHoveredFalse = React.useCallback( + () => setIndicatorIsHovered(false), + [setIndicatorIsHovered], + ) + + const close: React.MouseEventHandler = React.useCallback( + (e) => { + onClose() + e.stopPropagation() + }, + [onClose], + ) + + return ( + + + {icon} + {label} + + + + ) + }) diff --git a/nexus-builder/packages/navigator/src/uuiui/tooltip.tsx b/nexus-builder/packages/navigator/src/uuiui/tooltip.tsx new file mode 100644 index 000000000000..477c9f383391 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/tooltip.tsx @@ -0,0 +1,67 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import Tippy from '@tippyjs/react' +import type { Placement } from 'tippy.js' +import 'tippy.js/dist/tippy.css' +import React from 'react' +//TODO: switch to functional component and make use of 'useColorTheme': +import { colorTheme } from './styles/theme' + +export interface TooltipProps { + children?: React.ReactElement + title: React.ReactElement | string + placement?: Placement + disabled?: boolean + backgroundColor?: string + textColor?: string +} + +export const Tooltip: React.FunctionComponent> = (props) => { + const backgroundColor = props.backgroundColor ?? colorTheme.neutralInvertedBackground.value + const textColor = props.textColor ?? colorTheme.neutralInvertedForeground.value + + const css = React.useMemo( + () => + ({ + fontWeight: 400, + fontSize: 11, + textAlign: 'center', + fontFamily: + "utopian-inter, -apple-system, BlinkMacSystemFont, Helvetica, 'Segoe UI', Roboto, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + backgroundColor: `${backgroundColor} !important`, + color: `${textColor} !important`, + '& .tippy-content': { + padding: '4px 8px !important', + }, + '&[data-placement^=top] .tippy-arrow::before': { + borderTopColor: `${backgroundColor} !important`, + }, + '&[data-placement^=right] .tippy-arrow::before': { + borderRightColor: `${backgroundColor} !important`, + }, + '&[data-placement^=bottom] .tippy-arrow::before': { + borderBottomColor: `${backgroundColor} !important`, + }, + '&[data-placement^=left] .tippy-arrow::before': { + borderLeftColor: `${backgroundColor} !important`, + }, + } as const), + [backgroundColor, textColor], + ) + + return ( + + {props.children} + + ) +} diff --git a/nexus-builder/packages/navigator/src/uuiui/utilities/disable-subtree.tsx b/nexus-builder/packages/navigator/src/uuiui/utilities/disable-subtree.tsx new file mode 100644 index 000000000000..98d6d7976810 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/utilities/disable-subtree.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +const ControlsDisabledContext = React.createContext(false) + +export function useControlsDisabledInSubtree() { + return React.useContext(ControlsDisabledContext) +} + +export const DisableControlsInSubtree = (props: React.PropsWithChildren<{ disable: boolean }>) => { + const { disable } = props + return ( + + {props.children} + + ) +} diff --git a/nexus-builder/packages/navigator/src/uuiui/utilities/on-click-outside-hoc.tsx b/nexus-builder/packages/navigator/src/uuiui/utilities/on-click-outside-hoc.tsx new file mode 100644 index 000000000000..3e74cd698e2f --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/utilities/on-click-outside-hoc.tsx @@ -0,0 +1,26 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import React from 'react' +import onClickOutside from 'react-onclickoutside' + +export interface OnClickOutsideHOCProps { + onClickOutside?: (e: MouseEvent) => void + outsideClickIgnoreClass?: string +} + +class OnClickOutsideHOCUnenhanced extends React.Component< + React.PropsWithChildren +> { + handleClickOutside(event: MouseEvent) { + if (this.props.onClickOutside != null) { + this.props.onClickOutside(event) + } + } + + render() { + return {this.props.children} + } +} + +export const OnClickOutsideHOC = onClickOutside(OnClickOutsideHOCUnenhanced) diff --git a/nexus-builder/packages/navigator/src/uuiui/warning-icon.tsx b/nexus-builder/packages/navigator/src/uuiui/warning-icon.tsx new file mode 100644 index 000000000000..061b65cc3d2b --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/warning-icon.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import type { IcnColor, IcnProps } from './icn' +import { Icons } from './icons' + +interface WarningIconProps { + color?: IcnColor + tooltipText?: string + style?: React.CSSProperties + testId?: string +} + +export const WarningIcon = React.memo((props: WarningIconProps) => { + const icnProps = getWarningIconProps(props.tooltipText, props.color, props.style) + return +}) + +export function getWarningIconProps( + tooltipText: string | undefined, + color: IcnColor = 'warning', + style: React.CSSProperties | undefined = undefined, +): IcnProps { + const tooltipPlacement = tooltipText == null ? undefined : 'right' + return { + type: 'warningtriangle', + color: color, + tooltipText: tooltipText, + tooltipPlacement: tooltipPlacement, + style: style, + } +} diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/actionsheet/actionsheet.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/actionsheet/actionsheet.tsx new file mode 100644 index 000000000000..4e21c5bc960b --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/actionsheet/actionsheet.tsx @@ -0,0 +1,33 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import { FlexRow } from '../layout/flex-row' +import React from 'react' + +export const ActionSheet = (props: any) => { + const stopProp = React.useCallback((e: React.SyntheticEvent) => { + e.stopPropagation() + }, []) + + return ( + div': { + marginRight: '4px', + }, + }} + className='actionsheet' + {...props} + onMouseDown={stopProp} + onMouseUp={stopProp} + onClick={stopProp} + /> + ) +} diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/avatar/avatar.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/avatar/avatar.tsx new file mode 100644 index 000000000000..80f02b4c4e38 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/avatar/avatar.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import type { CSSProperties } from 'react' +import { useColorTheme } from '../../styles/theme' + +interface AvatarProps { + userPicture: string | null + isLoggedIn: boolean + size?: number + style?: CSSProperties +} + +export const Avatar = React.memo((props: AvatarProps) => { + const colorTheme = useColorTheme() + + /* Make the user wish they'd never logged in with an avatar-providing service. */ + /* Change these in avatars.sketch and export from there */ + const utopinoIndex = Math.round(Math.random() * 13) + const anonyminoIndex = Math.round(Math.random() * 10) + + const fallbackLoggedOutImageURL = + 'url(/editor/avatars/anonymino' + anonyminoIndex.toString() + '.png)' + const fallbackLoggedInImageURL = 'url(/editor/avatars/utopino' + utopinoIndex.toString() + '.png)' + + const imageURL = + props.userPicture != null + ? `url(${props.userPicture})` + : props.isLoggedIn + ? fallbackLoggedInImageURL + : fallbackLoggedOutImageURL + + const backgroundStyle = { + backgroundImage: imageURL, + backgroundPosition: 'center', + backgroundSize: 'cover', + backgroundRepeat: 'no-repeat', + } + + return ( +
+ ) +}) diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/controlled-textarea.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/controlled-textarea.tsx new file mode 100644 index 000000000000..4e7f6635f97c --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/controlled-textarea.tsx @@ -0,0 +1,107 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import type { Interpolation } from '@emotion/react' +import { jsx } from '@emotion/react' +import React from 'react' + +interface ControlledTextAreaProps { + value: string + onSubmitValue: (value: string) => void + className?: string + inputType: 'textarea' | 'input' + onClickSelectsAll: boolean + onBlurOnCmdReturn?: boolean + disabled: boolean + css?: Interpolation +} + +interface ControlledTextAreaState { + value: string + propsValue: string +} + +export class ControlledTextArea extends React.Component< + ControlledTextAreaProps, + ControlledTextAreaState +> { + input: null | HTMLTextAreaElement | HTMLInputElement = null + + constructor(props: ControlledTextAreaProps) { + super(props) + this.state = { + value: props.value, + propsValue: props.value, + } + } + + static getDerivedStateFromProps( + props: ControlledTextAreaProps, + state: ControlledTextAreaState, + ): ControlledTextAreaState | null { + if (props.value === state.propsValue) { + return null + } else { + return { + value: props.value, + propsValue: props.value, + } + } + } + + triggerSelect = () => { + this.props.onClickSelectsAll && this.input != null ? this.input.select() : null + } + + setRef = (input: null | HTMLTextAreaElement | HTMLInputElement) => { + this.input = input + } + + tagOnBlur = () => { + if (this.props.value !== this.state.value) { + this.props.onSubmitValue(this.state.value) + } + } + + tagOnChange = ( + event: React.ChangeEvent | React.ChangeEvent, + ) => { + this.setState({ + value: event.target.value, + }) + } + + tagOnKeyDown = ( + event: React.KeyboardEvent | React.KeyboardEvent, + ) => { + if ( + event.key === 'Enter' && + this.input != null && + (this.props.onBlurOnCmdReturn !== true || + (this.props.onBlurOnCmdReturn && event.metaKey === true)) + ) { + this.input.blur() + } + event.stopPropagation() + } + + render() { + const TagToRender = this.props.inputType + return ( + + ) + } +} diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/headings/headings.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/headings/headings.tsx new file mode 100644 index 000000000000..ed7dd8c2a354 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/headings/headings.tsx @@ -0,0 +1,71 @@ +import styled from '@emotion/styled' +import { disabledOpacityStyle } from '../../styles/utopitrons' +//TODO: refactor styled components to functional components and use 'useColorTheme': +import { colorTheme, UtopiaTheme } from '../../styles/theme' +import { FlexRow } from '../layout/flex-row' + +export const Title = styled(FlexRow)({ + fontSize: 11, + fontWeight: 600, + letterSpacing: '0.1px', +}) + +export const H1 = styled(FlexRow)([ + disabledOpacityStyle, + { + fontSize: '10px', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.2px', + }, +]) + +export const H2 = styled(FlexRow)([ + disabledOpacityStyle, + { + fontSize: '11px', + fontWeight: 600, + letterSpacing: '0.2px', + }, +]) + +export const H3 = styled(FlexRow)([ + disabledOpacityStyle, + { + fontSize: '10px', + fontWeight: 500, + letterSpacing: '0.1px', + margin: '0px', + }, +]) + +export const Subdued = styled.span([ + disabledOpacityStyle, + { + color: colorTheme.subduedForeground.value, + letterSpacing: '0.1px', + lineHeight: '17px', + }, +]) +export const VerySubdued = styled(Subdued)({ + color: colorTheme.verySubduedForeground.value, +}) + +export const InspectorSectionHeader = styled(H1)({ + label: 'section-header', + flexShrink: 0, + height: UtopiaTheme.layout.rowHeight.large, + borderTop: `1px solid ${colorTheme.seperator.value}`, + padding: `6px ${UtopiaTheme.layout.inspectorXPadding}px 6px ${UtopiaTheme.layout.inspectorXPadding}px`, +}) +InspectorSectionHeader.displayName = 'InspectorSectionHeader' + +export const InspectorSubsectionHeader = styled(H2)({ + height: UtopiaTheme.layout.rowHeight.large, + label: 'subsection-header', + borderTop: `1px solid ${colorTheme.seperator.value}`, + // no margin here so that subsections stack nicely + // margin bottom needs to go into the subsection body + padding: `0 ${UtopiaTheme.layout.inspectorXPadding}px`, +}) +InspectorSubsectionHeader.displayName = 'InspectorSubsectionHeader' diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/layout/common-layout-shorthands.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/common-layout-shorthands.tsx new file mode 100644 index 000000000000..a514c25ec0ed --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/common-layout-shorthands.tsx @@ -0,0 +1,11 @@ +import { css } from '@emotion/react' +import type { CSSProperties, CSSInterpolation } from '@emotion/serialize' + +interface CommonSenseUtopiaProps { + flexGrow?: CSSProperties['flexGrow'] +} + +export const commonSenseUtopiaLayoutShorthands = (props: CommonSenseUtopiaProps) => + css({ + flexGrow: props.flexGrow, + } as CSSInterpolation) diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/layout/flex-column.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/flex-column.tsx new file mode 100644 index 000000000000..a9aefbe06ef2 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/flex-column.tsx @@ -0,0 +1,28 @@ +import styled from '@emotion/styled' +import { commonSenseUtopiaLayoutShorthands } from './common-layout-shorthands' +import { flexColumnStyle } from '../../styles/layout-styles' +import React from 'react' + +/** + * **Flexbox Column, with horizontally centered content** + * - Uses alignItems: 'center' to center things vertically, override if req'd + * - justifyContent (main axis, across): flex-start, center, flex-end, space-between, space-around, space-evenly + * - align-items: (cross axis, down) flex-start | flex-end | center | baseline | stretch + */ + +export const FlexColumn = styled.div([commonSenseUtopiaLayoutShorthands, { ...flexColumnStyle }]) +FlexColumn.displayName = 'FlexColumn' + +export const SimpleFlexColumn = (props: JSX.IntrinsicElements['div']) => { + const mergedProps = React.useMemo(() => { + return { + ...props, + style: { + ...flexColumnStyle, + ...props.style, + }, + } + }, [props]) + return
+} +SimpleFlexColumn.displayName = 'SimpleFlexColumn' diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/layout/flex-row.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/flex-row.tsx new file mode 100644 index 000000000000..64c90cdb5693 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/flex-row.tsx @@ -0,0 +1,30 @@ +import styled from '@emotion/styled' +import { commonSenseUtopiaLayoutShorthands } from './common-layout-shorthands' +import { flexRowStyle } from '../../styles/layout-styles' +import React from 'react' + +/** + * **Flexbox Row, with vertically centered content** + * - Uses alignItems: 'center' to center things vertically, override if req'd + * - justifyContent (main axis, across): flex-start, center, flex-end, space-between, space-around, space-evenly + * - align-items: (cross axis, down) flex-start | flex-end | center | baseline | stretch + */ + +export const FlexRow = styled.div([commonSenseUtopiaLayoutShorthands, { ...flexRowStyle }]) +FlexRow.displayName = 'FlexRow' + +export const SimpleFlexRow = React.forwardRef( + (props, ref) => { + const mergedProps = React.useMemo(() => { + return { + ...props, + style: { + ...flexRowStyle, + ...props.style, + }, + } + }, [props]) + return
+ }, +) +SimpleFlexRow.displayName = 'SimpleFlexRow' diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/layout/resizable-flex-components.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/resizable-flex-components.tsx new file mode 100644 index 000000000000..06c6213bc2ab --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/resizable-flex-components.tsx @@ -0,0 +1,30 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' + +import React from 'react' +import type { ResizableProps } from '../../../uuiui-deps' +import { Resizable } from '../../../uuiui-deps' + +import { UtopiaStyles } from '../../styles/theme' + +export const ResizableFlexColumn: React.FunctionComponent< + React.PropsWithChildren +> = (props) => ( + +) +ResizableFlexColumn.displayName = 'Resizable Flex Column' diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/layout/tile.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/tile.tsx new file mode 100644 index 000000000000..0e1be65db335 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/tile.tsx @@ -0,0 +1,25 @@ +import styled from '@emotion/styled' +import { commonSenseUtopiaLayoutShorthands } from './common-layout-shorthands' +import { tileStyle } from '../../styles/layout-styles' +import React from 'react' + +/** + * **Tile, Flex container centering content horizontally and vertically** + */ + +export const Tile = styled.div([commonSenseUtopiaLayoutShorthands, { ...tileStyle }]) +Tile.displayName = 'Tile' + +export const SimpleTile = (props: JSX.IntrinsicElements['div']) => { + const mergedProps = React.useMemo(() => { + return { + ...props, + style: { + ...tileStyle, + ...props.style, + }, + } + }, [props]) + return
+} +SimpleTile.displayName = 'SimpleTile' diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/layout/ui-row.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/ui-row.tsx new file mode 100644 index 000000000000..a0c410e462ea --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/layout/ui-row.tsx @@ -0,0 +1,21 @@ +import styled from '@emotion/styled' +import { commonSenseUtopiaLayoutShorthands } from './common-layout-shorthands' +import { flexRowStyle } from '../../styles/layout-styles' +import type React from 'react' +import { UtopiaTheme } from '../../styles/theme' + +export interface UIRowProp extends React.InputHTMLAttributes { + padded?: boolean + rowHeight?: keyof typeof UtopiaTheme.layout.rowHeight +} + +export const UIRow = styled.div((props) => ({ + ...commonSenseUtopiaLayoutShorthands, + ...flexRowStyle, + padding: props.padded === true ? UtopiaTheme.layout.rowHorizontalPadding : undefined, + height: + props.rowHeight == null + ? UtopiaTheme.layout.rowHeight.normal + : UtopiaTheme.layout.rowHeight[props.rowHeight], +})) +UIRow.displayName = 'UIRow' diff --git a/nexus-builder/packages/navigator/src/uuiui/widgets/popup-list/popup-list.tsx b/nexus-builder/packages/navigator/src/uuiui/widgets/popup-list/popup-list.tsx new file mode 100644 index 000000000000..251bbdef0ad1 --- /dev/null +++ b/nexus-builder/packages/navigator/src/uuiui/widgets/popup-list/popup-list.tsx @@ -0,0 +1,778 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import styled from '@emotion/styled' +import useInterval from '@use-it/interval' +import React from 'react' +import * as ReactDOM from 'react-dom' +import type { + InputProps, + KeyboardEventHandler, + MenuListComponentProps, + OptionProps, + OptionsType, + SingleValueProps, + ValueType, +} from 'react-select' +import Select, { components, createFilter } from 'react-select' +import type { IndicatorProps } from 'react-select/src/components/indicators' +import type { MenuPortalProps } from 'react-select/src/components/Menu' +import type { styleFn } from 'react-select/src/styles' +import type { IcnProps } from '../../icn' +import { Icn } from '../../icn' +import { colorTheme, UtopiaStyles, UtopiaTheme } from '../../styles/theme' +import { FlexRow } from '../layout/flex-row' +import { isOptionType } from '../../../utils/utils' +import type { ControlStyles, SelectOption } from '../../../uuiui-deps' +import { CommonUtils, getControlStyles } from '../../../uuiui-deps' +import { SmallerIcons } from '../../../uuiui/icons' +import { Tooltip } from '../../tooltip' +import { useIsMyProject } from '../../../components/editor/store/collaborative-editing' +import { useControlsDisabledInSubtree } from '../../utilities/disable-subtree' + +type ContainerMode = 'default' | 'showBorderOnHover' | 'noBorder' + +interface PopupListProps { + id?: string + options: OptionsType + value: SelectOption | undefined + onSubmitValue: (option: SelectOption) => void + style?: React.CSSProperties + containerMode?: ContainerMode + controlStyles?: ControlStyles + autoFocus?: boolean + disabled?: boolean + icon?: IcnProps +} + +const WindowEdgePadding = 4 +const OptionHeight = UtopiaTheme.layout.inputHeight.default +const CheckboxPadding = 4 +const CheckboxWidth = 16 +const CheckboxInset = CheckboxPadding + CheckboxWidth +const ValueContainerLeftPadding = 4 +const menuVerticalPadding = 4 + +const getValueOfValueType = (value: ValueType): SelectOption['value'] => { + if (Array.isArray(value)) { + if (value.length > 0) { + return value[0].value + } else { + return undefined + } + } else { + return (value as unknown as SelectOption).value + } +} + +const getIndexOfValue = ( + value: ValueType, + options: OptionsType, +): number => { + const firstValue = getValueOfValueType(value) + + let allOptions: SelectOption[] = [] + options.forEach((option) => { + allOptions.push(option) + if (option.options != null) { + allOptions.push(...option.options) + } + }) + + const index = allOptions.findIndex((option) => option.value === firstValue) + return Math.max(0, index) +} + +const Option = (props: OptionProps) => { + const selectOption = props.selectOption + const data: SelectOption = props.data + + const onMouseUp = React.useCallback(() => { + selectOption(data) + }, [data, selectOption]) + + const iconShown = props.data.icon != null + + return ( + + + + {props.isSelected ? '✓' : ''} + + {props.data.icon == null ? null : ( + + )} + {props.children} + + + ) +} + +const calculateMenuScrollPosition = (index: number, menuHeight: number) => { + return index * OptionHeight - menuHeight / 2 + OptionHeight / 2 +} + +const calculateOptionsToCutOff = ( + optionsLength: number, + windowHeightAboveOrBelowReference: number, + index: number = 0, +) => { + return Math.min( + optionsLength, + Math.max( + 0, + Math.round(optionsLength - index - windowHeightAboveOrBelowReference / OptionHeight), + ), + ) +} + +const getPortalPosition = ( + referenceTop: number, + options: OptionsType, + value: ValueType, + scrollIndexOffset: number, +): { + menuTop: number + menuHeight: number + scrollTop: number + croppedTop: boolean + croppedBottom: boolean +} => { + const optionsLength = options.reduce((working, o) => { + if (o.options == null) { + return working + 1 + } else { + return working + 1 + o.options.length + } + }, 0) + const windowHeight = window.innerHeight + const indexOfValue = getIndexOfValue(value, options) + const centredIndex = indexOfValue + scrollIndexOffset + + const windowHeightAboveReference = referenceTop - WindowEdgePadding + const windowHeightBelowReference = + windowHeight - (windowHeightAboveReference + OptionHeight) - WindowEdgePadding + + const optionPaddingElements = 1 + const optionPaddingAboveSelected = OptionHeight * Math.min(optionPaddingElements, optionsLength) + const optionPaddingBelowSelected = + OptionHeight * Math.min(optionPaddingElements, optionsLength - centredIndex) + + if ( + windowHeightAboveReference > optionPaddingAboveSelected && + windowHeightBelowReference > optionPaddingBelowSelected + ) { + const numberCroppedTop = calculateOptionsToCutOff( + optionsLength, + windowHeightAboveReference, + optionsLength - centredIndex - 1, + ) + const howManyElementsToShowAboveSelected = centredIndex - numberCroppedTop + const numberCroppedBottom = calculateOptionsToCutOff( + optionsLength, + windowHeightBelowReference, + centredIndex, + ) + const howManyElementsToShowBelowSelected = + optionsLength - 1 - centredIndex - numberCroppedBottom + const croppedMenuHeight = + (howManyElementsToShowAboveSelected + howManyElementsToShowBelowSelected + 1) * OptionHeight + return { + menuTop: + referenceTop - 2 * menuVerticalPadding - howManyElementsToShowAboveSelected * OptionHeight, + menuHeight: croppedMenuHeight, + scrollTop: numberCroppedTop * OptionHeight, + croppedTop: numberCroppedTop > 0, + croppedBottom: numberCroppedBottom > 0, + } + } else { + if (windowHeightAboveReference > windowHeightBelowReference) { + const numberCroppedTop = calculateOptionsToCutOff(optionsLength, windowHeightAboveReference) + const numberCroppedBottom = calculateOptionsToCutOff( + optionsLength, + windowHeightBelowReference, + ) + const menuHeight = Math.min( + optionsLength * OptionHeight, + windowHeightAboveReference - numberCroppedTop * OptionHeight, + ) + return { + menuTop: referenceTop - 2 * menuVerticalPadding - menuHeight, + menuHeight: menuHeight, + scrollTop: 0, + croppedTop: numberCroppedTop > 0, + croppedBottom: numberCroppedBottom > 0, + } + } else { + const numberCroppedTop = calculateOptionsToCutOff(optionsLength, windowHeightAboveReference) + const numberCroppedBottom = calculateOptionsToCutOff( + optionsLength, + windowHeightBelowReference, + ) + const menuHeight = Math.min( + optionsLength * OptionHeight, + windowHeightBelowReference - numberCroppedBottom * OptionHeight, + ) + return { + menuTop: referenceTop - 2 * menuVerticalPadding + OptionHeight, + menuHeight: menuHeight, + scrollTop: 0, + croppedTop: numberCroppedTop > 0, + croppedBottom: numberCroppedBottom > 0, + } + } + } +} + +const MenuPortal = (props: MenuPortalProps) => { + const ref = React.useRef(null) + const [popupHeight, setPopupHeight] = React.useState(0) + const [popupTop, setPopupTop] = React.useState(0) + const [popupLeft, setPopupLeft] = React.useState(0) + const [alignRight, setAlignRight] = React.useState(false) + const [deltaSinceMouseDown, setDeltaSinceMouseDown] = React.useState(0) + const [croppedTop, setCroppedTop] = React.useState(false) + const [croppedBottom, setCroppedBottom] = React.useState(false) + const [scrollIndexOffset, setScrollIndexOffset] = React.useState(0) + const [mouseInCropTopArea, setMouseInCropTopArea] = React.useState(false) + const [mouseInCropBottomArea, setMouseInCropBottomArea] = React.useState(false) + + useInterval( + () => { + setScrollIndexOffset((value) => { + return value - 1 + }) + }, + mouseInCropTopArea && croppedTop ? 50 : null, + ) + + useInterval( + () => { + setScrollIndexOffset((value) => { + return value + 1 + }) + }, + mouseInCropBottomArea && croppedBottom ? 50 : null, + ) + + const onCroppedTopMouseOver = React.useCallback(() => { + setMouseInCropTopArea(true) + }, []) + + const onCroppedTopMouseOut = React.useCallback(() => { + setMouseInCropTopArea(false) + }, []) + + const onCroppedBottomMouseOver = React.useCallback(() => { + setMouseInCropBottomArea(true) + }, []) + + const onCroppedBottomMouseOut = React.useCallback(() => { + setMouseInCropBottomArea(false) + }, []) + + const onMouseMove = React.useCallback(() => { + setDeltaSinceMouseDown((value) => value + 1) + }, [setDeltaSinceMouseDown]) + + const onMouseUp = React.useCallback( + (e: React.MouseEvent) => { + if (deltaSinceMouseDown < 3) { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + } + }, + [deltaSinceMouseDown], + ) + + const propsOptions = props.options + const propsGetValue = props.getValue + const refCurrent = ref.current + const referenceElement = props.controlElement + const updateLayout = React.useCallback(() => { + if (referenceElement != null) { + const referenceRect = referenceElement.getBoundingClientRect() + const { + menuTop, + menuHeight, + scrollTop, + croppedTop: isCroppedTop, + croppedBottom: isCroppedBottom, + } = getPortalPosition(referenceRect.top, propsOptions, propsGetValue(), scrollIndexOffset) + if (refCurrent != null) { + refCurrent.scrollTo({ + top: scrollTop, + }) + } + const popupRect = refCurrent?.getBoundingClientRect() + if (popupRect != null && popupRect.width + popupRect.left > window.innerWidth) { + setAlignRight(true) + } else { + setPopupLeft(referenceRect.left) + setAlignRight(false) + } + setPopupHeight(menuHeight + 8) + setPopupTop(menuTop) + setCroppedTop(isCroppedTop) + setCroppedBottom(isCroppedBottom) + } + }, [propsOptions, propsGetValue, refCurrent, referenceElement, scrollIndexOffset]) + + React.useLayoutEffect(updateLayout, [updateLayout]) + + React.useEffect(() => { + window.addEventListener('resize', updateLayout) + return () => { + window.removeEventListener('resize', updateLayout) + } + }, [updateLayout]) + + if (props.selectProps.menuPortalTarget != null) { + return ReactDOM.createPortal( +
+ +
, + props.selectProps.menuPortalTarget, + ) + } else { + return null + } +} +export const MenuListTestID = 'react-select-inspector-menu-list' +const MenuList = (props: MenuListComponentProps) => { + const ref = React.useRef(null) + const refCurrent = ref.current + const propsValue = props.getValue() + React.useEffect(() => { + if (ref.current != null) { + ref.current.setAttribute('data-testid', MenuListTestID) + } + if (refCurrent != null) { + const index = getIndexOfValue(propsValue, []) + refCurrent.scrollTo({ + top: calculateMenuScrollPosition(index, refCurrent.clientHeight), + }) + } + }, [refCurrent, propsValue]) + + return +} + +const DropdownIndicator: React.FunctionComponent< + React.PropsWithChildren> +> = (indicatorProps) => { + return components.DropdownIndicator == null ? null : ( + + + + ) +} + +const SingleValue = (props: SingleValueProps) => { + const iconShown = props.data.icon != null + + return ( + { + return { ...props.getStyles(name, p), margin: iconShown ? -4 : 0 } + }} + > + {props.data.icon == null ? null : ( +
+ +
+ )} + + {props.children} + +
+ ) +} +SingleValue.displayName = 'SingleValue' + +const displayNone: styleFn = () => ({ + display: 'none', +}) + +const getDefaultContainer = + (controlStyles: ControlStyles, propsStyle?: React.CSSProperties): styleFn => + () => ({ + width: '100%', + height: OptionHeight, + borderRadius: UtopiaTheme.inputBorderRadius, + boxShadow: `inset 0 0 0 1px ${controlStyles.borderColor}`, + backgroundColor: controlStyles.backgroundColor, + color: controlStyles.mainColor, + // textTransform: 'capitalize', // BB I am disabling this to be able to write "Left and Width" as an option + ...propsStyle, + }) + +const getShowBorderOnHoverContainer = + (controlStyles: ControlStyles, propsStyle?: React.CSSProperties): styleFn => + () => { + return { + width: '100%', + height: OptionHeight, + borderRadius: UtopiaTheme.inputBorderRadius, + color: controlStyles.mainColor, + textTransform: 'capitalize', + '&:hover': { + boxShadow: `inset 0 0 0 1px ${controlStyles.borderColor}`, + backgroundColor: controlStyles.backgroundColor, + }, + ...propsStyle, + } as unknown as React.CSSProperties // incorrect react-select type. it actually accepts an emotion style object + } + +const getNoBorderContainer = + (controlStyles: ControlStyles, propsStyle?: React.CSSProperties): styleFn => + () => ({ + width: '100%', + height: OptionHeight, + color: controlStyles.mainColor, + textTransform: 'capitalize', + ...propsStyle, + }) + +const getContainer = ( + containerMode: ContainerMode, + controlStyles: ControlStyles, + style: React.CSSProperties | undefined, +): styleFn => { + switch (containerMode) { + case 'default': { + return getDefaultContainer(controlStyles, style) + } + case 'showBorderOnHover': { + return getShowBorderOnHoverContainer(controlStyles, style) + } + case 'noBorder': { + return getNoBorderContainer(controlStyles, style) + } + } +} + +const Input = (props: InputProps) => { + const inputStyle = React.useMemo(() => { + return { + label: 'input', + background: 0, + border: 0, + fontSize: 'inherit', + opacity: props.isHidden ? 0 : 1, + outline: 0, + padding: 0, + color: 'inherit', + } + }, [props.isHidden]) + + let strippedProps: any = { ...props } + delete strippedProps['getStyles'] + delete strippedProps['innerRef'] + delete strippedProps['isHidden'] + delete strippedProps['isDisabled'] + delete strippedProps['cx'] + delete strippedProps['selectProps'] + + return ( +
+ +
+ ) +} + +export const PopupList = React.memo( + React.forwardRef( + ( + { + id, + options, + value, + onSubmitValue, + style, + containerMode = 'default', + controlStyles = getControlStyles('simple'), + disabled: initialDisabled, + }, + ref, + ) => { + const controlsDisabled = useControlsDisabledInSubtree() + const disabled = initialDisabled || !controlStyles.interactive || controlsDisabled + + const selectOnSubmitValue = React.useCallback( + (newValue: ValueType) => { + if (isOptionType(newValue)) { + onSubmitValue(newValue) + } + }, + [onSubmitValue], + ) + + const container: styleFn = getContainer(containerMode, controlStyles, style) + + const isOptionDisabled = React.useCallback((option: SelectOption) => { + return option.disabled === true + }, []) + + const stopPropagation: KeyboardEventHandler = React.useCallback((event) => { + if (event.key.includes('Arrow')) { + // if the user is using the ArrowUp or ArrowDown to navigate the react select, don't trigger keyboard moves on the Canvas + event.stopPropagation() + } + }, []) + + return ( +