From e29bcaca8116f07aa602da8db40e9cb26307a14f Mon Sep 17 00:00:00 2001 From: Liad Yosef Date: Tue, 28 May 2024 14:35:10 +0300 Subject: [PATCH] Feat: Wrap multiple elements using the new picker (#5758) **Problem:** Our current picker does not support multiple selections, we need this functionality for wrapping multiple elements **Fix:** Make the new picker accept multiple selections. Important note: this PR adds the functionality of wrapping multiple elements, but for replacment and inserts the target remains only the first selection (as it was before). We need to decide later on what to do about insertion/replacements **Manual Tests:** I hereby swear that: - [X] I opened a hydrogen project and it loaded - [X] I could navigate to various routes in Preview mode Fixes #5696 --- editor/src/components/context-menu-items.ts | 10 +- .../component-picker-context-menu.tsx | 103 ++++++++++-------- editor/src/core/shared/element-path.ts | 4 + 3 files changed, 64 insertions(+), 53 deletions(-) diff --git a/editor/src/components/context-menu-items.ts b/editor/src/components/context-menu-items.ts index 4213453c76ab..bd4f68fd3ee2 100644 --- a/editor/src/components/context-menu-items.ts +++ b/editor/src/components/context-menu-items.ts @@ -491,15 +491,7 @@ export const wrapInPicker: ContextMenuItem = { shortcut: 'W', enabled: true, action: (data, dispatch, _coord, event) => { - // for multiple selection, we open the old picker - if (data.selectedViews.length > 1) { - requireDispatch(dispatch)( - [setFocus('canvas'), EditorActions.openFloatingInsertMenu({ insertMenuMode: 'wrap' })], - 'everyone', - ) - } else { - showWrapComponentPicker(data.selectedViews, data.jsxMetadata, data.showComponentPicker)(event) - } + showWrapComponentPicker(data.selectedViews, data.jsxMetadata, data.showComponentPicker)(event) }, } diff --git a/editor/src/components/navigator/navigator-item/component-picker-context-menu.tsx b/editor/src/components/navigator/navigator-item/component-picker-context-menu.tsx index 65909d4e2381..809ab19b431c 100644 --- a/editor/src/components/navigator/navigator-item/component-picker-context-menu.tsx +++ b/editor/src/components/navigator/navigator-item/component-picker-context-menu.tsx @@ -82,6 +82,7 @@ import { generateUidWithExistingComponents } from '../../../core/model/element-t import { emptyComments } from 'utopia-shared/src/types' import { intrinsicHTMLElementNamesThatSupportChildren } from '../../../core/shared/dom-utils' import { emptyImports } from '../../../core/workers/common/project-file-utils' +import { forceNotNull } from '../../../core/shared/optional-utils' type RenderPropTarget = { type: 'render-prop'; prop: string } type ConditionalTarget = { type: 'conditional'; conditionalCase: ConditionalCase } @@ -139,12 +140,12 @@ export function isConditionalTarget( } interface ComponentPickerContextMenuAtomData { - target: ElementPath + targets: ElementPath[] insertionTarget: InsertionTarget } const ComponentPickerContextMenuAtom = atom({ - target: EP.emptyElementPath, + targets: [EP.emptyElementPath], insertionTarget: insertAsChildTarget(), }) @@ -333,15 +334,22 @@ export const useCreateCallbackToShowComponentPicker = event.preventDefault() let pickerType: 'preferred' | 'full' + let targets = selectedViews - const target = selectedViews[0] - - if (overridePickerType == null) { + 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 = EP.getFirstPath(targets) const targetParent = isReplaceTarget(insertionTarget) || isReplaceKeepChildrenAndStyleTarget(insertionTarget) - ? EP.parentPath(target) - : target + ? EP.parentPath(firstTarget) + : firstTarget const targetElement = MetadataUtils.findElementByElementPath( editorRef.current.jsxMetadata, targetParent, @@ -358,11 +366,9 @@ export const useCreateCallbackToShowComponentPicker = ) pickerType = preferredChildren.length > 0 ? 'preferred' : 'full' - } else { - pickerType = overridePickerType } - setContextMenuProps({ target: target, insertionTarget: insertionTarget }) + setContextMenuProps({ targets: selectedViews, insertionTarget: insertionTarget }) const show = pickerType === 'preferred' ? showPreferred : showFull show(event, params) }, @@ -461,7 +467,7 @@ function moreItem( function insertComponentPickerItem( toInsert: InsertableComponent, - target: ElementPath, + targets: ElementPath[], projectContents: ProjectContentTreeRoot, metadata: ElementInstanceMetadataMap, pathTrees: ElementPathTrees, @@ -471,6 +477,8 @@ function insertComponentPickerItem( const uniqueIds = new Set(getAllUniqueUids(projectContents).uniqueIDs) const uid = generateConsistentUID('prop', uniqueIds) const elementWithoutUID = toInsert.element() + // TODO: for most of the operations we still only support one target + const firstTarget = EP.getFirstPath(targets) const actions = ((): Array => { if (elementWithoutUID.type === 'JSX_ELEMENT') { @@ -485,7 +493,7 @@ function insertComponentPickerItem( if (isRenderPropTarget(insertionTarget)) { return [ setProp_UNSAFE( - target, + firstTarget, PP.create(insertionTarget.prop), fixedElement, toInsert.importsToAdd ?? undefined, @@ -496,9 +504,9 @@ function insertComponentPickerItem( // Replacing a mapped element requires a different function if ( isReplaceTarget(insertionTarget) && - MetadataUtils.isJSXMapExpression(EP.parentPath(target), metadata) + MetadataUtils.isJSXMapExpression(EP.parentPath(firstTarget), metadata) ) { - return [replaceMappedElement(fixedElement, target, toInsert.importsToAdd)] + return [replaceMappedElement(fixedElement, firstTarget, toInsert.importsToAdd)] } if (isWrapTarget(insertionTarget)) { @@ -515,7 +523,7 @@ function insertComponentPickerItem( element.children, ) return [ - wrapInElement([target], { + wrapInElement(targets, { element: newElement, importsToAdd: toInsert.importsToAdd, }), @@ -526,23 +534,25 @@ function insertComponentPickerItem( isReplaceTarget(insertionTarget) || isReplaceKeepChildrenAndStyleTarget(insertionTarget) ) { - return [replaceJSXElement(fixedElement, target, toInsert.importsToAdd, insertionTarget)] + return [ + replaceJSXElement(fixedElement, firstTarget, toInsert.importsToAdd, insertionTarget), + ] } if (!isConditionalTarget(insertionTarget)) { - return [insertJSXElement(fixedElement, target, toInsert.importsToAdd ?? undefined)] + return [insertJSXElement(fixedElement, firstTarget, toInsert.importsToAdd ?? undefined)] } } if (isInsertAsChildTarget(insertionTarget)) { - return [insertInsertable(childInsertionPath(target), toInsert, 'do-not-add', null)] + return [insertInsertable(childInsertionPath(firstTarget), toInsert, 'do-not-add', null)] } if (isConditionalTarget(insertionTarget)) { return [ insertInsertable( conditionalClauseInsertionPath( - target, + firstTarget, insertionTarget.conditionalCase, replaceWithSingleElement(), ), @@ -557,7 +567,7 @@ function insertComponentPickerItem( const elementToInsert = toInsert.element() if ( elementToInsert.type === 'JSX_MAP_EXPRESSION' && - !MetadataUtils.isJSXElement(target, metadata) + !targets.every((target) => MetadataUtils.isJSXElement(target, metadata)) ) { return [ showToast( @@ -571,7 +581,7 @@ function insertComponentPickerItem( ] } return [ - wrapInElement([target], { + wrapInElement(targets, { element: { ...elementToInsert, uid: generateUidWithExistingComponents(projectContents), @@ -583,7 +593,7 @@ function insertComponentPickerItem( if (isReplaceTarget(insertionTarget)) { if ( - MetadataUtils.isJSXMapExpression(EP.parentPath(target), metadata) && + MetadataUtils.isJSXMapExpression(EP.parentPath(firstTarget), metadata) && elementWithoutUID.type !== 'JSX_ELEMENT' ) { return [ @@ -597,11 +607,11 @@ function insertComponentPickerItem( ), ] } - const index = MetadataUtils.getIndexInParent(metadata, pathTrees, target) + const index = MetadataUtils.getIndexInParent(metadata, pathTrees, firstTarget) return [ - deleteView(target), + deleteView(firstTarget), insertInsertable( - childInsertionPath(EP.parentPath(target)), + childInsertionPath(EP.parentPath(firstTarget)), toInsert, 'do-not-add', absolute(index), @@ -645,7 +655,7 @@ function toastMessage(insertionTarget: InsertionTarget, toInsert: InsertableComp function insertPreferredChild( preferredChildToInsert: ElementToInsert, - target: ElementPath, + targets: ElementPath[], projectContents: ProjectContentTreeRoot, metadata: ElementInstanceMetadataMap, pathTrees: ElementPathTrees, @@ -665,7 +675,7 @@ function insertPreferredChild( insertComponentPickerItem( toInsert, - target, + targets, projectContents, metadata, pathTrees, @@ -675,7 +685,7 @@ function insertPreferredChild( } interface ComponentPickerContextMenuProps { - target: ElementPath + targets: ElementPath[] insertionTarget: InsertionTarget } @@ -730,10 +740,12 @@ function contextMenuItemsFromVariants( } const ComponentPickerContextMenuSimple = React.memo( - ({ target, insertionTarget }) => { - const showFullMenu = useCreateCallbackToShowComponentPicker()([target], insertionTarget, 'full') + ({ targets, insertionTarget }) => { + const showFullMenu = useCreateCallbackToShowComponentPicker()(targets, insertionTarget, 'full') - const preferredChildren = usePreferredChildrenForTarget(target, insertionTarget) + // for insertion we currently only support one target + const firstTarget = EP.getFirstPath(targets) + const preferredChildren = usePreferredChildrenForTarget(firstTarget, insertionTarget) const dispatch = useDispatch() @@ -745,14 +757,14 @@ const ComponentPickerContextMenuSimple = React.memo insertPreferredChild( preferredChildToInsert, - target, + targets, projectContentsRef.current, metadataRef.current, elementPathTreesRef.current, dispatch, insertionTarget, ), - [target, projectContentsRef, metadataRef, elementPathTreesRef, dispatch, insertionTarget], + [targets, projectContentsRef, metadataRef, elementPathTreesRef, dispatch, insertionTarget], ) const wrapperRef = React.useRef(null) @@ -789,17 +801,20 @@ const ComponentPickerContextMenuSimple = React.memo( - ({ target, insertionTarget }) => { + ({ targets, insertionTarget }) => { + // for insertion we currently only support one target + const firstTarget = EP.getFirstPath(targets) const targetChildren = useEditorState( Substores.metadata, - (store) => MetadataUtils.getChildrenUnordered(store.editor.jsxMetadata, target), + (store) => MetadataUtils.getChildrenUnordered(store.editor.jsxMetadata, firstTarget), 'usePreferredChildrenForTarget targetChildren', ) - const isJsxElement = useEditorState( + const areAllJsxElements = useEditorState( Substores.metadata, - (store) => MetadataUtils.isJSXElement(target, store.editor.jsxMetadata), - 'isJsxElement targetElement', + (store) => + targets.every((target) => MetadataUtils.isJSXElement(target, store.editor.jsxMetadata)), + 'areAllJsxElements targetElement', ) const allInsertableComponents = useGetInsertableComponents('insert').flatMap((group) => { @@ -827,7 +842,7 @@ const ComponentPickerContextMenuFull = React.memo { - const [{ target, insertionTarget }] = useAtom(ComponentPickerContextMenuAtom) + const [{ targets, insertionTarget }] = useAtom(ComponentPickerContextMenuAtom) return ( - - + + ) }) diff --git a/editor/src/core/shared/element-path.ts b/editor/src/core/shared/element-path.ts index 6488e73c0d2c..a10c66447e0e 100644 --- a/editor/src/core/shared/element-path.ts +++ b/editor/src/core/shared/element-path.ts @@ -1236,3 +1236,7 @@ export function multiplePathsAllWithTheSameUID(paths: Array): boole 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)) +}