Skip to content

Commit

Permalink
Feat: Wrap multiple elements using the new picker (#5758)
Browse files Browse the repository at this point in the history
**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

<video src="https://github.com/concrete-utopia/utopia/assets/7003853/1825e5a1-b695-4c87-8bb1-89516728436c"></video>


**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
  • Loading branch information
liady authored May 28, 2024
1 parent 246140a commit e29bcac
Show file tree
Hide file tree
Showing 3 changed files with 64 additions and 53 deletions.
10 changes: 1 addition & 9 deletions editor/src/components/context-menu-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,15 +491,7 @@ export const wrapInPicker: ContextMenuItem<CanvasData> = {
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)
},
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -139,12 +140,12 @@ export function isConditionalTarget(
}

interface ComponentPickerContextMenuAtomData {
target: ElementPath
targets: ElementPath[]
insertionTarget: InsertionTarget
}

const ComponentPickerContextMenuAtom = atom<ComponentPickerContextMenuAtomData>({
target: EP.emptyElementPath,
targets: [EP.emptyElementPath],
insertionTarget: insertAsChildTarget(),
})

Expand Down Expand Up @@ -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,
Expand All @@ -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)
},
Expand Down Expand Up @@ -461,7 +467,7 @@ function moreItem(

function insertComponentPickerItem(
toInsert: InsertableComponent,
target: ElementPath,
targets: ElementPath[],
projectContents: ProjectContentTreeRoot,
metadata: ElementInstanceMetadataMap,
pathTrees: ElementPathTrees,
Expand All @@ -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<EditorAction> => {
if (elementWithoutUID.type === 'JSX_ELEMENT') {
Expand All @@ -485,7 +493,7 @@ function insertComponentPickerItem(
if (isRenderPropTarget(insertionTarget)) {
return [
setProp_UNSAFE(
target,
firstTarget,
PP.create(insertionTarget.prop),
fixedElement,
toInsert.importsToAdd ?? undefined,
Expand All @@ -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)) {
Expand All @@ -515,7 +523,7 @@ function insertComponentPickerItem(
element.children,
)
return [
wrapInElement([target], {
wrapInElement(targets, {
element: newElement,
importsToAdd: toInsert.importsToAdd,
}),
Expand All @@ -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(),
),
Expand All @@ -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(
Expand All @@ -571,7 +581,7 @@ function insertComponentPickerItem(
]
}
return [
wrapInElement([target], {
wrapInElement(targets, {
element: {
...elementToInsert,
uid: generateUidWithExistingComponents(projectContents),
Expand All @@ -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 [
Expand All @@ -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),
Expand Down Expand Up @@ -645,7 +655,7 @@ function toastMessage(insertionTarget: InsertionTarget, toInsert: InsertableComp

function insertPreferredChild(
preferredChildToInsert: ElementToInsert,
target: ElementPath,
targets: ElementPath[],
projectContents: ProjectContentTreeRoot,
metadata: ElementInstanceMetadataMap,
pathTrees: ElementPathTrees,
Expand All @@ -665,7 +675,7 @@ function insertPreferredChild(

insertComponentPickerItem(
toInsert,
target,
targets,
projectContents,
metadata,
pathTrees,
Expand All @@ -675,7 +685,7 @@ function insertPreferredChild(
}

interface ComponentPickerContextMenuProps {
target: ElementPath
targets: ElementPath[]
insertionTarget: InsertionTarget
}

Expand Down Expand Up @@ -730,10 +740,12 @@ function contextMenuItemsFromVariants(
}

const ComponentPickerContextMenuSimple = React.memo<ComponentPickerContextMenuProps>(
({ 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()

Expand All @@ -745,14 +757,14 @@ const ComponentPickerContextMenuSimple = React.memo<ComponentPickerContextMenuPr
(preferredChildToInsert: ElementToInsert) =>
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<HTMLDivElement>(null)

Expand Down Expand Up @@ -789,17 +801,20 @@ const ComponentPickerContextMenuSimple = React.memo<ComponentPickerContextMenuPr
)

const ComponentPickerContextMenuFull = React.memo<ComponentPickerContextMenuProps>(
({ 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) => {
Expand Down Expand Up @@ -827,7 +842,7 @@ const ComponentPickerContextMenuFull = React.memo<ComponentPickerContextMenuProp
}
if (element.type === 'JSX_MAP_EXPRESSION') {
// we cannot currently wrap in List a conditional, fragment or map expression
return isJsxElement
return areAllJsxElements
}
return true
}
Expand All @@ -854,7 +869,7 @@ const ComponentPickerContextMenuFull = React.memo<ComponentPickerContextMenuProp

insertComponentPickerItem(
preferredChildToInsert,
target,
targets,
projectContentsRef.current,
metadataRef.current,
elementPathTreesRef.current,
Expand All @@ -865,7 +880,7 @@ const ComponentPickerContextMenuFull = React.memo<ComponentPickerContextMenuProp
hideAllContextMenus()
},
[
target,
targets,
projectContentsRef,
metadataRef,
elementPathTreesRef,
Expand Down Expand Up @@ -907,12 +922,12 @@ const ComponentPickerContextMenuFull = React.memo<ComponentPickerContextMenuProp
)

export const ComponentPickerContextMenu = React.memo(() => {
const [{ target, insertionTarget }] = useAtom(ComponentPickerContextMenuAtom)
const [{ targets, insertionTarget }] = useAtom(ComponentPickerContextMenuAtom)

return (
<React.Fragment>
<ComponentPickerContextMenuSimple target={target} insertionTarget={insertionTarget} />
<ComponentPickerContextMenuFull target={target} insertionTarget={insertionTarget} />
<ComponentPickerContextMenuSimple targets={targets} insertionTarget={insertionTarget} />
<ComponentPickerContextMenuFull targets={targets} insertionTarget={insertionTarget} />
</React.Fragment>
)
})
4 changes: 4 additions & 0 deletions editor/src/core/shared/element-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,3 +1236,7 @@ export function multiplePathsAllWithTheSameUID(paths: Array<ElementPath>): boole
export function isIndexedElement(path: ElementPath): boolean {
return isIndexedUid(toUid(path))
}

export function getFirstPath(pathArray: Array<ElementPath>): ElementPath {
return forceNotNull('Element path array is empty.', pathArray.at(0))
}

0 comments on commit e29bcac

Please sign in to comment.