From c4dda31bb9ace1b3de42332ba84a0a8a89e549d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bertalan=20K=C3=B6rmendy?= Date: Thu, 14 Mar 2024 12:56:34 +0100 Subject: [PATCH] Render prop picker (#5035) * Click to insert on empty render prop * fix(editor) enable selection and editing of render props * Nested navigator view * wip - popup * wip - select prop from variants * max width on popup * Quick fix for SpyWrapper issue * Fix non-rendered elements * Turn on FS * don't consider elements within generated * add important todo * remove element from elementsWithin * Only allow insert on empty render prop * whoops * revert deleting generated elements * Added label for children * Updated design * Empty render prop slot * use context menu styles in render prop picker * Fix children label when there are only empty props * Better fake uids * Remove deletion stuff * Remove childOrAttribute * Updated design * Make expression check safer * Cleanup * Adapt jsx elements in props to render prop navigator * Fix children label * Fix children label * remove `isRendered` * wip - test * test with render prop filled out * test with empty render prop * cleanup * remove unused import * add early return * `notRenderPropChildren` * `isEntryAPlaceholder` * remove `SyntheticNavigatorEntry.renderProp` * remove renderprop too * show slot for null value * slot navigator entry * popup * move render prop picker to its own file --------- Co-authored-by: Balint Gabor <127662+gbalint@users.noreply.github.com> Co-authored-by: RheeseyB <1044774+Rheeseyb@users.noreply.github.com> --- .../components/editor/store/editor-state.ts | 4 +- .../store/store-deep-equality-instances.ts | 6 +- .../navigator-item-dnd-container.tsx | 5 +- .../navigator-item/navigator-item-wrapper.tsx | 1 + .../navigator-item/navigator-item.tsx | 29 ++++- .../render-prop-selector-popup.tsx | 121 ++++++++++++++++++ .../components/navigator/navigator-utils.ts | 2 +- 7 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 editor/src/components/navigator/navigator-item/render-prop-selector-popup.tsx diff --git a/editor/src/components/editor/store/editor-state.ts b/editor/src/components/editor/store/editor-state.ts index e10070d78f23..00468bb17776 100644 --- a/editor/src/components/editor/store/editor-state.ts +++ b/editor/src/components/editor/store/editor-state.ts @@ -2120,12 +2120,14 @@ export function syntheticNavigatorEntry( export interface SlotNavigatorEntry { type: 'SLOT' elementPath: ElementPath + prop: string } -export function slotNavigatorEntry(elementPath: ElementPath): SlotNavigatorEntry { +export function slotNavigatorEntry(elementPath: ElementPath, prop: string): SlotNavigatorEntry { return { type: 'SLOT', elementPath: elementPath, + prop: prop, } } diff --git a/editor/src/components/editor/store/store-deep-equality-instances.ts b/editor/src/components/editor/store/store-deep-equality-instances.ts index 73bf5fc263bf..6c9d256278f7 100644 --- a/editor/src/components/editor/store/store-deep-equality-instances.ts +++ b/editor/src/components/editor/store/store-deep-equality-instances.ts @@ -671,10 +671,12 @@ export const InvalidOverrideNavigatorEntryKeepDeepEquality: KeepDeepEqualityCall ) export const SlotNavigatorEntryKeepDeepEquality: KeepDeepEqualityCall = - combine1EqualityCall( + combine2EqualityCalls( (entry) => entry.elementPath, ElementPathKeepDeepEquality, - (path) => ({ type: 'SLOT', elementPath: path }), + (entry) => entry.prop, + StringKeepDeepEquality, + (elementPath, prop) => ({ type: 'SLOT', elementPath: elementPath, prop: prop }), ) export const NavigatorEntryKeepDeepEquality: KeepDeepEqualityCall = ( diff --git a/editor/src/components/navigator/navigator-item/navigator-item-dnd-container.tsx b/editor/src/components/navigator/navigator-item/navigator-item-dnd-container.tsx index 449e905f4c10..7e61df1ced06 100644 --- a/editor/src/components/navigator/navigator-item/navigator-item-dnd-container.tsx +++ b/editor/src/components/navigator/navigator-item/navigator-item-dnd-container.tsx @@ -139,6 +139,7 @@ export interface RenderPropNavigatorItemContainerProps export interface SlotNavigatorItemContainerProps extends NavigatorItemDragAndDropWrapperPropsBase { parentElementPath: ElementPath + renderProp: string } export interface ConditionalClauseNavigatorItemContainerProps @@ -1091,8 +1092,8 @@ export const RenderPropNavigatorItemContainer = React.memo( export const SlotNavigatorItemContainer = React.memo((props: SlotNavigatorItemContainerProps) => { const navigatorEntry = React.useMemo( - () => slotNavigatorEntry(props.parentElementPath), - [props.parentElementPath], + () => slotNavigatorEntry(props.parentElementPath, props.renderProp), + [props.parentElementPath, props.renderProp], ) const safeComponentId = varSafeNavigatorEntryToKey(navigatorEntry) diff --git a/editor/src/components/navigator/navigator-item/navigator-item-wrapper.tsx b/editor/src/components/navigator/navigator-item/navigator-item-wrapper.tsx index a98fab65d18f..b34e3659c1b5 100644 --- a/editor/src/components/navigator/navigator-item/navigator-item-wrapper.tsx +++ b/editor/src/components/navigator/navigator-item/navigator-item-wrapper.tsx @@ -399,6 +399,7 @@ export const NavigatorItemWrapper: React.FunctionComponent< if (props.navigatorEntry.type === 'SLOT') { const entryProps: SlotNavigatorItemContainerProps = { ...navigatorItemProps, + renderProp: props.navigatorEntry.prop, parentElementPath: props.navigatorEntry.elementPath, } return diff --git a/editor/src/components/navigator/navigator-item/navigator-item.tsx b/editor/src/components/navigator/navigator-item/navigator-item.tsx index 11c92a78732b..686be2d80e64 100644 --- a/editor/src/components/navigator/navigator-item/navigator-item.tsx +++ b/editor/src/components/navigator/navigator-item/navigator-item.tsx @@ -24,7 +24,7 @@ import type { } from '../../../core/shared/element-template' import { hasElementsWithin } from '../../../core/shared/element-template' import type { ElementPath } from '../../../core/shared/project-file-types' -import { unless, when } from '../../../utils/react-conditionals' +import { unless } from '../../../utils/react-conditionals' import { useKeepReferenceEqualityIfPossible } from '../../../utils/react-performance' import type { IcnColor, IcnProps } from '../../../uuiui' import { FlexRow, useColorTheme, UtopiaTheme } from '../../../uuiui' @@ -55,10 +55,11 @@ import { ExpandableIndicator } from './expandable-indicator' import { ItemLabel } from './item-label' import { LayoutIcon } from './layout-icon' import { NavigatorItemActionSheet } from './navigator-item-components' -import { assertNever } from '../../../core/shared/utils' +import { CanvasContextMenuPortalTargetID, assertNever } from '../../../core/shared/utils' import type { ElementPathTrees } from '../../../core/shared/element-path-tree' import { MapCounter } from './map-counter' -import { Outlet } from 'react-router' +import ReactDOM from 'react-dom' +import { RenderPropPicker, useShowRenderPropPicker } from './render-prop-selector-popup' export function getItemHeight(navigatorEntry: NavigatorEntry): number { if (isConditionalClauseNavigatorEntry(navigatorEntry)) { @@ -205,7 +206,7 @@ const getColors = ( return { style: { background, color }, - iconColor, + iconColor: iconColor, } } @@ -760,6 +761,12 @@ export const NavigatorItem: React.FunctionComponent< ) }, [childComponentCount, isFocusedComponent, isConditional]) + const renderPropPickerId = varSafeNavigatorEntryToKey(navigatorEntry) + const { showRenderPropPicker: showContextMenu, hideRenderPropPicker: hideContextMenu } = + useShowRenderPropPicker(renderPropPickerId) + + const portalTarget = document.getElementById(CanvasContextMenuPortalTargetID) + const iconColor = isRemixItem ? 'remix' : isCodeItem @@ -770,6 +777,7 @@ export const NavigatorItem: React.FunctionComponent< return (
+ {portalTarget == null || navigatorEntry.type !== 'SLOT' + ? null + : ReactDOM.createPortal( + , + portalTarget, + )} { const isComponentScene = useIsProbablyScene(props.navigatorEntry) && props.childComponentCount === 1 - const isRemixScene = props.remixItemType === 'scene' const isOutlet = props.remixItemType === 'outlet' - const isLink = props.remixItemType === 'link' const backgroundLozengeColor = isCodeItem && !props.selected diff --git a/editor/src/components/navigator/navigator-item/render-prop-selector-popup.tsx b/editor/src/components/navigator/navigator-item/render-prop-selector-popup.tsx new file mode 100644 index 000000000000..0f53b5af75b7 --- /dev/null +++ b/editor/src/components/navigator/navigator-item/render-prop-selector-popup.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import { useContextMenu, Menu } from 'react-contexify' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import { + getJSXElementNameAsString, + jsExpressionOtherJavaScriptSimple, +} from '../../../core/shared/element-template' +import type { ElementPath } from '../../../core/shared/project-file-types' +import { useDispatch } from '../../editor/store/dispatch-context' +import { Substores, useEditorState } from '../../editor/store/store-hook' +import { setProp_UNSAFE } from '../../editor/actions/action-creators' +import * as EP from '../../../core/shared/element-path' +import * as PP from '../../../core/shared/property-path' + +const usePreferredChildrenForTargetProp = (target: ElementPath, prop: string) => { + const selectedJSXElement = useEditorState( + Substores.metadata, + (store) => MetadataUtils.getJSXElementFromMetadata(store.editor.jsxMetadata, target), + 'usePreferredChildrenForSelectedElement selectedJSXElement', + ) + + const preferredChildrenForTargetProp = useEditorState( + Substores.restOfEditor, + (store) => { + if (selectedJSXElement == null) { + return null + } + + const targetName = getJSXElementNameAsString(selectedJSXElement.name) + // TODO: we don't deal with components registered with the same name in multiple files + for (const file of Object.values(store.editor.propertyControlsInfo)) { + for (const [name, value] of Object.entries(file)) { + if (name === targetName) { + for (const [registeredPropName, registeredPropValue] of Object.entries( + value.properties, + )) { + if ( + registeredPropName === prop && + registeredPropValue.control === 'jsx' && + registeredPropValue.preferredChildComponents != null + ) { + return registeredPropValue.preferredChildComponents + } + } + } + } + } + + return null + }, + 'usePreferredChildrenForSelectedElement propertyControlsInfo', + ) + + if (selectedJSXElement == null || preferredChildrenForTargetProp == null) { + return null + } + + return preferredChildrenForTargetProp +} + +export const useShowRenderPropPicker = (id: string) => { + const { show, hideAll } = useContextMenu({ id }) + const onClick = React.useCallback( + (event: React.MouseEvent) => { + show(event) + }, + [show], + ) + + return { showRenderPropPicker: onClick, hideRenderPropPicker: hideAll } +} + +interface RenderPropPickerProps { + target: ElementPath + prop: string + key: string + id: string +} + +export const RenderPropPicker = React.memo(({ key, id, target, prop }) => { + const preferredChildrenForTargetProp = usePreferredChildrenForTargetProp( + EP.parentPath(target), + prop, + ) + + const dispatch = useDispatch() + + const onItemClick = React.useCallback( + (rawJSCodeForRenderProp: string) => (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + + dispatch([ + setProp_UNSAFE( + EP.parentPath(target), + PP.create(prop), + jsExpressionOtherJavaScriptSimple(rawJSCodeForRenderProp, []), + ), + ]) + }, + [dispatch, prop, target], + ) + + if (preferredChildrenForTargetProp == null) { + return null + } + + const rawJSToInsert: Array = (preferredChildrenForTargetProp ?? []).flatMap((data) => + data.variants == null ? `<${data.name} />` : data.variants.map((v) => v.code), + ) + + return ( + + {rawJSToInsert.map((option, idx) => ( +
+ {option} +
+ ))} +
+ ) +}) diff --git a/editor/src/components/navigator/navigator-utils.ts b/editor/src/components/navigator/navigator-utils.ts index c22359bce4a7..cc0b1bab9658 100644 --- a/editor/src/components/navigator/navigator-utils.ts +++ b/editor/src/components/navigator/navigator-utils.ts @@ -140,7 +140,7 @@ export function getNavigatorTargets( if (propValue == null || (isJSExpressionValue(propValue) && propValue.value == null)) { const entries = [ renderPropNavigatorEntry(fakeRenderPropPath, prop), - slotNavigatorEntry(fakeRenderPropPath), + slotNavigatorEntry(fakeRenderPropPath, prop), ] navigatorTargets.push(...entries) visibleNavigatorTargets.push(...entries)