From 2daecfacdf8369241a91546b1108f372e92b5aa2 Mon Sep 17 00:00:00 2001 From: Berci Kormendy Date: Mon, 15 Jul 2024 13:52:01 +0200 Subject: [PATCH] reinstate the context menu --- .../components/editor/editor-component.tsx | 19 +- .../component-picker-context-menu.tsx | 370 +++++++++++++++++- 2 files changed, 381 insertions(+), 8 deletions(-) diff --git a/editor/src/components/editor/editor-component.tsx b/editor/src/components/editor/editor-component.tsx index 6eda1bcb16e9..4484d9c0772d 100644 --- a/editor/src/components/editor/editor-component.tsx +++ b/editor/src/components/editor/editor-component.tsx @@ -3,11 +3,16 @@ /** @jsxFrag React.Fragment */ import { css, jsx, keyframes } from '@emotion/react' import { chrome as isChrome } from 'platform-detect' -import React from 'react' +import React, { useEffect } from 'react' +import ReactDOM from 'react-dom' import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' import { IS_TEST_ENVIRONMENT } from '../../common/env-vars' -import { assertNever, projectURLForProject } from '../../core/shared/utils' +import { + CanvasContextMenuPortalTargetID, + assertNever, + projectURLForProject, +} from '../../core/shared/utils' import Keyboard from '../../utils/keyboard' import { Modifier } from '../../utils/modifiers' import { @@ -81,7 +86,10 @@ import { useUpdateActiveRemixSceneOnSelectionChange, } from '../canvas/remix/utopia-remix-root-component' import { useDefaultCollapsedViews } from './use-default-collapsed-views' -import { useCreateCallbackToShowComponentPicker } from '../navigator/navigator-item/component-picker-context-menu' +import { + ComponentPickerContextMenu, + useCreateCallbackToShowComponentPicker, +} from '../navigator/navigator-item/component-picker-context-menu' import { useGithubPolling } from '../../core/shared/github/helpers' import { useAtom } from 'jotai' import { clearOpenMenuIds } from '../../core/shared/menu-state' @@ -453,6 +461,8 @@ export const EditorComponentInner = React.memo((props: EditorProps) => { useClearSelectionOnNavigation() + const portalTarget = document.getElementById(CanvasContextMenuPortalTargetID) + return ( <> @@ -549,6 +559,9 @@ export const EditorComponentInner = React.memo((props: EditorProps) => { + {portalTarget != null + ? ReactDOM.createPortal(, portalTarget) + : null} 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[], @@ -649,6 +750,11 @@ function insertPreferredChild( ) } +interface ComponentPickerContextMenuProps { + targets: ElementPath[] + insertionTarget: InsertionTarget +} + export function iconPropsForIcon(icon: Icon, inverted: boolean = false): IcnProps { return { category: 'navigator-element', @@ -900,3 +1006,257 @@ export const ComponentPickerDropDown = React.memo( return }) + +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 ComponentPickerContextMenuFull = React.memo( + ({ targets, insertionTarget }) => { + // for insertion we currently only support one target + const firstTarget = targets[0] + const targetChildren = useEditorState( + Substores.metadata, + (store) => MetadataUtils.getChildrenUnordered(store.editor.jsxMetadata, firstTarget), + 'usePreferredChildrenForTarget targetChildren', + ) + + const areAllJsxElements = useEditorState( + Substores.metadata, + (store) => + targets.every((target) => MetadataUtils.isJSXElement(target, store.editor.jsxMetadata)), + 'areAllJsxElements targetElement', + ) + + 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 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 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, + ], + ) + + const squashEvents = React.useCallback((e: React.UIEvent) => { + e.stopPropagation() + }, []) + + const onVisibilityChange = React.useCallback((isVisible: boolean) => { + if (isVisible) { + document.body.classList.add(BodyMenuOpenClass) + } else { + document.body.classList.remove(BodyMenuOpenClass) + } + }, []) + + return ( + + + + ) + }, +) + +export const ComponentPickerContextMenu = React.memo(() => { + const [{ targets, insertionTarget }] = useAtom(ComponentPickerContextMenuAtom) + + return ( + + + + + ) +})