From 2d06db6fd447c3399f6d2d90832a0ee8e0051e28 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Wed, 27 Jul 2022 16:19:37 +0100 Subject: [PATCH] Editor - Resizing elements inside page rows (#645) * Fixes and remove unneeded stylings in recent visual editor update * Enable dragging row child edges (WIP) (broken) * Small refactor * Use context instead of state * Attempt to resize columns in rows * Add logic for right edge dragging * Enable resizing in DOM * Normalize column sizes (will extract this logic later) * Remove unneeded stop propagation * Drag & drop improvements * Drag preview + fix resizing result * Fix resizing preview sizes + show 12-column grid while dragging * Disallow resizing from left of first and right of last element in a row * Add minimum resize width and snapping to grid * Fix resizing positions + show resize cursor while resizing * Fix some function names and types * Fix element selection after resize * Fix deleting elements by clicking * Fix resizing on add + delete + edge cases with automatic column behavior * Fix deleting with Backspace * Set row column sizes prop in runtime - remove prop control * Change OverlayGrid file location * Simplicy some logic + separate resizing logic in its own hook * Add box around Image component * Increase and move horizontal resize drag areas to make resizing more intuitive * Extract some components to their own file * Move pageViw utils to geometry utils Co-authored-by: Jan Potoms <2109932+Janpot@users.noreply.github.com> --- packages/toolpad-app/src/appDom.ts | 75 +- .../toolpad-app/src/runtime/ToolpadApp.tsx | 14 +- .../AppEditor/PageEditor/NodeDropArea.tsx | 198 +++++ .../toolpad/AppEditor/PageEditor/NodeHud.tsx | 177 ++++ .../AppEditor/PageEditor/OverlayGrid.tsx | 77 ++ .../PageEditor/PageEditorProvider.tsx | 58 +- .../AppEditor/PageEditor/RenderPanel.tsx | 753 ++++++++++-------- packages/toolpad-app/src/utils/geometry.ts | 20 +- packages/toolpad-components/src/Button.tsx | 2 +- packages/toolpad-components/src/Image.tsx | 72 +- packages/toolpad-components/src/PageRow.tsx | 11 +- packages/toolpad-components/src/Select.tsx | 2 +- packages/toolpad-components/src/TextField.tsx | 2 +- .../toolpad-components/src/Typography.tsx | 50 +- 14 files changed, 1085 insertions(+), 426 deletions(-) create mode 100644 packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeDropArea.tsx create mode 100644 packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeHud.tsx create mode 100644 packages/toolpad-app/src/toolpad/AppEditor/PageEditor/OverlayGrid.tsx diff --git a/packages/toolpad-app/src/appDom.ts b/packages/toolpad-app/src/appDom.ts index 1047c8ffa5d..9b275c69b0a 100644 --- a/packages/toolpad-app/src/appDom.ts +++ b/packages/toolpad-app/src/appDom.ts @@ -89,6 +89,9 @@ export interface ElementNode

extends AppDomNodeBase { readonly component: ConstantAttrValue; }; readonly props?: BindableAttrValues

; + readonly layout?: { + readonly columnSize?: ConstantAttrValue; + }; } export interface CodeComponentNode extends AppDomNodeBase { @@ -430,6 +433,7 @@ export function createElement

( dom: AppDom, component: string, props: Partial> = {}, + layout: Partial> = {}, name?: string, ): ElementNode { return createNode(dom, 'element', { @@ -438,6 +442,7 @@ export function createElement

( attributes: { component: createConst(component), }, + layout, }); } @@ -592,11 +597,11 @@ function setNodeParent( parentProp: string, parentIndex?: string, ) { - const parent = getNode(dom, parentId); - if (!parentIndex) { - const siblings: readonly AppDomNode[] = (getChildNodes(dom, parent) as any)[parentProp] ?? []; - const lastIndex = siblings.length > 0 ? siblings[siblings.length - 1].parentIndex : null; + const parent = getNode(dom, parentId); + + const children: readonly AppDomNode[] = (getChildNodes(dom, parent) as any)[parentProp] ?? []; + const lastIndex = children.length > 0 ? children[children.length - 1].parentIndex : null; parentIndex = createFractionalIndex(lastIndex, null); } @@ -698,6 +703,42 @@ export function getNodeIdByName(dom: AppDom, name: string): NodeId | null { return index.get(name) ?? null; } +export function getSiblingBeforeNode( + dom: AppDom, + node: ElementNode | PageNode, + parentProp: string, +) { + const parent = getParent(dom, node); + + invariant(parent, `Node: "${node.id}" has no parent`); + + const parentChildren = + ((isPage(parent) || isElement(parent)) && + (getChildNodes(dom, parent) as NodeChildren)[parentProp]) || + []; + + const nodeIndex = parentChildren.findIndex((child) => child.id === node.id); + const nodeBefore = nodeIndex > 0 ? parentChildren[nodeIndex - 1] : null; + + return nodeBefore; +} + +export function getSiblingAfterNode(dom: AppDom, node: ElementNode | PageNode, parentProp: string) { + const parent = getParent(dom, node); + + invariant(parent, `Node: "${node.id}" has no parent`); + + const parentChildren = + ((isPage(parent) || isElement(parent)) && + (getChildNodes(dom, parent) as NodeChildren)[parentProp]) || + []; + + const nodeIndex = parentChildren.findIndex((child) => child.id === node.id); + const nodeAfter = nodeIndex < parentChildren.length - 1 ? parentChildren[nodeIndex + 1] : null; + + return nodeAfter; +} + export function getNewFirstParentIndexInNode( dom: AppDom, node: ElementNode | PageNode, @@ -725,18 +766,7 @@ export function getNewParentIndexBeforeNode( node: ElementNode | PageNode, parentProp: string, ) { - const parent = getParent(dom, node); - - invariant(parent, `Node: "${node.id}" has no parent`); - - const parentChildren = - ((isPage(parent) || isElement(parent)) && - (getChildNodes(dom, parent) as NodeChildren)[parentProp]) || - []; - - const nodeIndex = parentChildren.findIndex((child) => child.id === node.id); - const nodeBefore = nodeIndex > 0 ? parentChildren[nodeIndex - 1] : null; - + const nodeBefore = getSiblingBeforeNode(dom, node, parentProp); return createFractionalIndex(nodeBefore?.parentIndex || null, node.parentIndex); } @@ -745,18 +775,7 @@ export function getNewParentIndexAfterNode( node: ElementNode | PageNode, parentProp: string, ) { - const parent = getParent(dom, node); - - invariant(parent, `Node: "${node.id}" has no parent`); - - const parentChildren = - ((isPage(parent) || isElement(parent)) && - (getChildNodes(dom, parent) as NodeChildren)[parentProp]) || - []; - - const nodeIndex = parentChildren.findIndex((child) => child.id === node.id); - const nodeAfter = nodeIndex < parentChildren.length - 1 ? parentChildren[nodeIndex + 1] : null; - + const nodeAfter = getSiblingAfterNode(dom, node, parentProp); return createFractionalIndex(node.parentIndex, nodeAfter?.parentIndex || null); } diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 59fe0ab0ea7..58085eca876 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -39,8 +39,8 @@ import { import * as appDom from '../appDom'; import { VersionOrPreview } from '../types'; import { createProvidedContext } from '../utils/react'; +import { getElementNodeComponentId, isPageRow, PAGE_ROW_COMPONENT_ID } from '../toolpadComponents'; import AppOverview from './AppOverview'; -import { getElementNodeComponentId, PAGE_ROW_COMPONENT_ID } from '../toolpadComponents'; import AppThemeProvider from './AppThemeProvider'; import evalJsBindings, { BindingEvaluationResult, @@ -252,14 +252,24 @@ function RenderedNodeContent({ node, childNodes, Component }: RenderedNodeConten : // `undefined` to ensure the defaultProps get picked up undefined; + const layoutProps = React.useMemo(() => { + if (appDom.isElement(node) && isPageRow(node)) { + return { + layoutColumnSizes: childNodes.map((childNode) => childNode.layout?.columnSize?.value), + }; + } + return {}; + }, [childNodes, node]); + const props: Record = React.useMemo(() => { return { ...boundProps, ...onChangeHandlers, ...eventHandlers, + ...layoutProps, children: reactChildren, }; - }, [boundProps, eventHandlers, onChangeHandlers, reactChildren]); + }, [boundProps, eventHandlers, layoutProps, onChangeHandlers, reactChildren]); // Wrap with slots for (const [propName, argType] of Object.entries(argTypes)) { diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeDropArea.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeDropArea.tsx new file mode 100644 index 00000000000..80f584e4a4c --- /dev/null +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeDropArea.tsx @@ -0,0 +1,198 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { styled } from '@mui/material'; +import { NodeInfo } from '../../../types'; +import * as appDom from '../../../appDom'; +import { + absolutePositionCss, + isHorizontalFlow, + isVerticalFlow, + Rectangle, +} from '../../../utils/geometry'; +import { + DropZone, + DROP_ZONE_BOTTOM, + DROP_ZONE_CENTER, + DROP_ZONE_LEFT, + DROP_ZONE_RIGHT, + DROP_ZONE_TOP, +} from './PageEditorProvider'; + +const dropAreaHighlightClasses = { + highlightedTop: 'DropArea_HighlightedTop', + highlightedRight: 'DropArea_HighlightedRight', + highlightedBottom: 'DropArea_HighlightedBottom', + highlightedLeft: 'DropArea_HighlightedLeft', + highlightedCenter: 'DropArea_HighlightedCenter', +}; + +function getHighlightedZoneOverlayClass( + highlightedZone: DropZone, +): typeof dropAreaHighlightClasses[keyof typeof dropAreaHighlightClasses] | null { + switch (highlightedZone) { + case DROP_ZONE_TOP: + return dropAreaHighlightClasses.highlightedTop; + case DROP_ZONE_RIGHT: + return dropAreaHighlightClasses.highlightedRight; + case DROP_ZONE_BOTTOM: + return dropAreaHighlightClasses.highlightedBottom; + case DROP_ZONE_LEFT: + return dropAreaHighlightClasses.highlightedLeft; + case DROP_ZONE_CENTER: + return dropAreaHighlightClasses.highlightedCenter; + default: + return null; + } +} + +const StyledNodeDropArea = styled('div', { + shouldForwardProp: (prop) => prop !== 'highlightRelativeRect', +})<{ + highlightRelativeRect?: Partial; +}>(({ highlightRelativeRect = {} }) => { + const { + x: highlightRelativeX = 0, + y: highlightRelativeY = 0, + width: highlightWidth = '100%', + height: highlightHeight = '100%', + } = highlightRelativeRect; + + return { + pointerEvents: 'none', + position: 'absolute', + [`&.${dropAreaHighlightClasses.highlightedTop}`]: { + '&:after': { + backgroundColor: '#44EB2D', + content: "''", + position: 'absolute', + height: 4, + width: highlightWidth, + top: -2, + left: highlightRelativeX, + }, + }, + [`&.${dropAreaHighlightClasses.highlightedRight}`]: { + '&:after': { + backgroundColor: '#44EB2D', + content: "''", + position: 'absolute', + height: highlightHeight, + width: 4, + top: highlightRelativeY, + right: -2, + }, + }, + [`&.${dropAreaHighlightClasses.highlightedBottom}`]: { + '&:after': { + backgroundColor: '#44EB2D', + content: "''", + position: 'absolute', + height: 4, + width: highlightWidth, + bottom: -2, + left: highlightRelativeX, + }, + }, + [`&.${dropAreaHighlightClasses.highlightedLeft}`]: { + '&:after': { + backgroundColor: '#44EB2D', + content: "''", + position: 'absolute', + height: highlightHeight, + width: 4, + left: -2, + top: highlightRelativeY, + }, + }, + [`&.${dropAreaHighlightClasses.highlightedCenter}`]: { + border: '4px solid #44EB2D', + }, + }; +}); + +const EmptySlot = styled('div')({ + alignItems: 'center', + border: '1px dashed green', + color: 'green', + display: 'flex', + fontSize: 20, + justifyContent: 'center', + position: 'absolute', + opacity: 0.75, +}); + +interface NodeDropAreaProps { + node: appDom.ElementNode | appDom.PageNode; + parentInfo: NodeInfo | null; + layoutRect: Rectangle; + dropAreaRect: Rectangle; + slotRect?: Rectangle; + highlightedZone?: DropZone | null; + isEmptySlot: boolean; + isPageChild: boolean; +} + +export default function NodeDropArea({ + node, + highlightedZone, + parentInfo, + layoutRect, + slotRect, + dropAreaRect, + isEmptySlot, + isPageChild, +}: NodeDropAreaProps) { + const highlightedZoneOverlayClass = + highlightedZone && getHighlightedZoneOverlayClass(highlightedZone); + + const nodeParentProp = node.parentProp; + + const parentSlots = parentInfo?.slots; + const parentSlot = parentSlots && nodeParentProp && parentSlots[nodeParentProp]; + + const parentRect = parentInfo?.rect; + + const isHorizontalContainerChild = parentSlot + ? isHorizontalFlow(parentSlot.flowDirection) + : false; + const isVerticalContainerChild = parentSlot ? isVerticalFlow(parentSlot.flowDirection) : false; + + const highlightHeight = + isHorizontalContainerChild && parentRect ? parentRect.height : layoutRect.height; + const highlightWidth = + !isPageChild && isVerticalContainerChild && parentRect ? parentRect.width : layoutRect.width; + + const highlightRelativeX = + (!isPageChild && isVerticalContainerChild && parentRect ? parentRect.x : layoutRect.x) - + dropAreaRect.x; + const highlightRelativeY = + (isHorizontalContainerChild && parentRect ? parentRect.y : layoutRect.y) - dropAreaRect.y; + + const isHighlightingCenter = highlightedZone === DROP_ZONE_CENTER; + + const highlightRect = isHighlightingCenter && isEmptySlot && slotRect ? slotRect : dropAreaRect; + + return ( + + + {isEmptySlot && slotRect ? ( + + + ) : null} + + ); +} diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeHud.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeHud.tsx new file mode 100644 index 00000000000..003cdb2256a --- /dev/null +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeHud.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { IconButton, styled } from '@mui/material'; +import * as appDom from '../../../appDom'; +import { + absolutePositionCss, + Rectangle, + RectangleEdge, + RECTANGLE_EDGE_LEFT, + RECTANGLE_EDGE_RIGHT, +} from '../../../utils/geometry'; +import { useDom } from '../../DomLoader'; +import { useToolpadComponent } from '../toolpadComponents'; +import { getElementNodeComponentId } from '../../../toolpadComponents'; + +const nodeHudClasses = { + allowNodeInteraction: 'NodeHud_AllowNodeInteraction', + selected: 'NodeHud_Selected', + selectionHint: 'NodeHud_SelectionHint', +}; + +const NodeHudWrapper = styled('div')({ + // capture mouse events + pointerEvents: 'initial', + position: 'absolute', + outline: '1px dotted rgba(255,0,0,.2)', + userSelect: 'none', + [`.${nodeHudClasses.selected}`]: { + position: 'absolute', + top: 0, + left: 0, + height: '100%', + width: '100%', + outline: '1px solid red', + }, + [`.${nodeHudClasses.selectionHint}`]: { + // capture mouse events + pointerEvents: 'initial', + cursor: 'grab', + display: 'flex', + position: 'absolute', + alignItems: 'center', + right: -1, + background: 'red', + color: 'white', + fontSize: 11, + padding: `0 0 0 8px`, + // TODO: figure out positioning of this selectionhint, perhaps it should + // - prefer top right, above the component + // - if that appears out of bound of the editor, show it bottom or left + zIndex: 1, + transform: `translate(0, -100%)`, + }, + [`&.${nodeHudClasses.allowNodeInteraction}`]: { + // block pointer-events so we can interact with the selection + pointerEvents: 'none', + }, +}); + +const DraggableEdge = styled('div', { + shouldForwardProp: (prop) => prop !== 'edge', +})<{ + edge: RectangleEdge; +}>(({ edge }) => { + let dynamicStyles = {}; + if (edge === RECTANGLE_EDGE_RIGHT) { + dynamicStyles = { + top: 0, + right: -2, + height: '100%', + width: 12, + }; + } + if (edge === RECTANGLE_EDGE_LEFT) { + dynamicStyles = { + top: 0, + left: -2, + height: '100%', + width: 12, + }; + } + + return { + ...dynamicStyles, + cursor: 'ew-resize', + position: 'absolute', + pointerEvents: 'initial', + zIndex: 1, + }; +}); + +const ResizePreview = styled('div')({ + backgroundColor: '#44EB2D', + opacity: 0.5, +}); + +interface NodeHudProps { + node: appDom.ElementNode | appDom.PageNode; + rect: Rectangle; + selected?: boolean; + allowInteraction?: boolean; + onNodeDragStart?: React.DragEventHandler; + draggableEdges?: RectangleEdge[]; + onEdgeDragStart?: ( + node: appDom.ElementNode, + edge: RectangleEdge, + ) => React.MouseEventHandler; + onDelete?: React.MouseEventHandler; + isResizing?: boolean; + resizePreviewElementRef: React.MutableRefObject; +} + +export default function NodeHud({ + node, + selected, + allowInteraction, + rect, + onNodeDragStart, + draggableEdges = [], + onEdgeDragStart, + onDelete, + isResizing = false, + resizePreviewElementRef, +}: NodeHudProps) { + const dom = useDom(); + + const componentId = appDom.isElement(node) ? getElementNodeComponentId(node) : ''; + const component = useToolpadComponent(dom, componentId); + + const handleDelete = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + + if (onDelete) { + onDelete(event); + } + }, + [onDelete], + ); + + return ( + + {selected ? ( + + +

+ {component?.displayName || ''} + + + + +
+ + ) : null} + {onEdgeDragStart + ? draggableEdges.map((edge) => ( + + )) + : null} + {isResizing ? ( + + ) : null} + + ); +} diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/OverlayGrid.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/OverlayGrid.tsx new file mode 100644 index 00000000000..bb1be6234e3 --- /dev/null +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/OverlayGrid.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { Grid, styled } from '@mui/material'; + +export interface OverlayGridHandle { + gridElement: HTMLDivElement | null; + getMinColumnWidth: () => number; + getLeftColumnEdges: () => number[]; + getRightColumnEdges: () => number[]; +} + +export const GRID_NUMBER_OF_COLUMNS = 12; +export const GRID_COLUMN_GAP = 1; + +const StyledGrid = styled(Grid)({ + height: '100vh', + pointerEvents: 'none', + position: 'absolute', + width: 'calc(100vw - 6px)', + zIndex: 1, + margin: '0 auto', + left: -1, +}); + +const StyledGridColumn = styled('div')({ + backgroundColor: 'pink', + height: '100%', + opacity: 0.2, + width: '100%', +}); + +export const OverlayGrid = React.forwardRef(function OverlayGrid( + props, + forwardedRef, +) { + const gridRef = React.useRef(null); + + React.useImperativeHandle( + forwardedRef, + () => { + let columnEdges: number[] = []; + if (gridRef.current) { + const gridColumnContainers = Array.from(gridRef.current.children); + const gridColumnEdges = gridColumnContainers.map((container: Element) => { + const containerRect = container.firstElementChild?.getBoundingClientRect(); + return containerRect + ? [Math.round(containerRect.x), Math.round(containerRect.x + containerRect.width)] + : []; + }); + columnEdges = gridColumnEdges.flat(); + } + + return { + gridElement: gridRef.current, + getMinColumnWidth() { + return columnEdges[1] - columnEdges[0]; + }, + getLeftColumnEdges() { + return columnEdges.filter((column, index) => index % 2 === 0); + }, + getRightColumnEdges() { + return columnEdges.filter((column, index) => index % 2 === 1); + }, + }; + }, + [], + ); + + return ( + + {[...Array(GRID_NUMBER_OF_COLUMNS)].map((column, index) => ( + + + + ))} + + ); +}); diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageEditorProvider.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageEditorProvider.tsx index 916405daf7a..c99df854a28 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageEditorProvider.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageEditorProvider.tsx @@ -2,6 +2,7 @@ import { NodeId, LiveBindings } from '@mui/toolpad-core'; import * as React from 'react'; import * as appDom from '../../../appDom'; import { PageViewState } from '../../../types'; +import { RectangleEdge } from '../../../utils/geometry'; import { update } from '../../../utils/immutability'; export type ComponentPanelTab = 'component' | 'theme'; @@ -25,10 +26,12 @@ export interface PageEditorState { readonly selection: NodeId | null; readonly componentPanelTab: ComponentPanelTab; readonly newNode: appDom.ElementNode | null; - readonly highlightLayout: boolean; + readonly draggedNodeId: NodeId | null; + readonly isDraggingOver: boolean; readonly dragOverNodeId: NodeId | null; readonly dragOverSlotParentProp: string | null; readonly dragOverZone: DropZone | null; + readonly draggedEdge: RectangleEdge | null; readonly viewState: PageViewState; readonly pageState: Record; readonly bindings: LiveBindings; @@ -54,6 +57,17 @@ export type PageEditorAction = type: 'PAGE_NEW_NODE_DRAG_START'; newNode: appDom.ElementNode; } + | { + type: 'PAGE_EXISTING_NODE_DRAG_START'; + node: appDom.ElementNode; + } + | { + type: 'PAGE_EDGE_DRAG_START'; + edgeDragState: { + nodeId: NodeId | null; + edge: RectangleEdge; + }; + } | { type: 'PAGE_NODE_DRAG_OVER'; dragOverState: { @@ -63,7 +77,7 @@ export type PageEditorAction = }; } | { - type: 'PAGE_NODE_DRAG_END'; + type: 'PAGE_DRAG_END'; } | { type: 'PAGE_STATE_UPDATE'; @@ -86,10 +100,12 @@ export function createPageEditorState(appId: string, nodeId: NodeId): PageEditor selection: null, componentPanelTab: 'component', newNode: null, - highlightLayout: false, + draggedNodeId: null, + isDraggingOver: false, dragOverNodeId: null, dragOverSlotParentProp: null, dragOverZone: null, + draggedEdge: null, viewState: { nodes: {} }, pageState: {}, bindings: {}, @@ -127,17 +143,34 @@ export function pageEditorReducer( newNode: action.newNode, }); } - case 'PAGE_NODE_DRAG_END': + case 'PAGE_EXISTING_NODE_DRAG_START': { + return update(state, { + draggedNodeId: action.node.id, + }); + } + case 'PAGE_EDGE_DRAG_START': { + const { nodeId, edge } = action.edgeDragState; + + return update(state, { + draggedNodeId: nodeId, + draggedEdge: edge, + }); + } + case 'PAGE_DRAG_END': return update(state, { newNode: null, - highlightLayout: false, + draggedNodeId: null, + isDraggingOver: false, dragOverNodeId: null, + dragOverSlotParentProp: null, + dragOverZone: null, + draggedEdge: null, }); case 'PAGE_NODE_DRAG_OVER': { const { nodeId, parentProp, zone } = action.dragOverState; return update(state, { - highlightLayout: true, + isDraggingOver: true, dragOverNodeId: nodeId, dragOverSlotParentProp: parentProp, dragOverZone: zone, @@ -177,8 +210,17 @@ function createPageEditorApi(dispatch: React.Dispatch) { newNodeDragStart(newNode: appDom.ElementNode) { dispatch({ type: 'PAGE_NEW_NODE_DRAG_START', newNode }); }, - nodeDragEnd() { - dispatch({ type: 'PAGE_NODE_DRAG_END' }); + existingNodeDragStart(node: appDom.ElementNode) { + dispatch({ type: 'PAGE_EXISTING_NODE_DRAG_START', node }); + }, + edgeDragStart({ nodeId, edge }: { nodeId: NodeId | null; edge: RectangleEdge }) { + dispatch({ + type: 'PAGE_EDGE_DRAG_START', + edgeDragState: { nodeId, edge }, + }); + }, + dragEnd() { + dispatch({ type: 'PAGE_DRAG_END' }); }, nodeDragOver({ nodeId, diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel.tsx index f4cfacb7846..1f769d22bb6 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel.tsx @@ -1,16 +1,16 @@ import * as React from 'react'; import clsx from 'clsx'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import DeleteIcon from '@mui/icons-material/Delete'; -import { IconButton, styled } from '@mui/material'; +import { styled } from '@mui/material'; import { RuntimeEvent, NodeId } from '@mui/toolpad-core'; import { useNavigate } from 'react-router-dom'; -import { FlowDirection, NodeInfo, SlotsState, SlotState } from '../../../types'; +import { FlowDirection, NodeInfo, SlotsState } from '../../../types'; import * as appDom from '../../../appDom'; import EditorCanvasHost, { EditorCanvasHostHandle } from './EditorCanvasHost'; import { - absolutePositionCss, - getRectanglePointEdge, + getRectanglePointActiveEdge, + isHorizontalFlow, + isReverseFlow, + isVerticalFlow, Rectangle, RectangleEdge, RECTANGLE_EDGE_BOTTOM, @@ -32,16 +32,20 @@ import { usePageEditorApi, usePageEditorState, } from './PageEditorProvider'; -import { useToolpadComponent } from '../toolpadComponents'; import { - getElementNodeComponentId, - isPageRow, PAGE_COLUMN_COMPONENT_ID, PAGE_ROW_COMPONENT_ID, isPageLayoutComponent, + isPageRow, isPageColumn, } from '../../../toolpadComponents'; import { ExactEntriesOf } from '../../../utils/types'; +import { OverlayGrid, OverlayGridHandle } from './OverlayGrid'; +import NodeHud from './NodeHud'; +import NodeDropArea from './NodeDropArea'; + +const RESIZE_SNAP_UNITS = 4; // px +const SNAP_TO_GRID_COLUMN_MARGIN = 10; // px const classes = { view: 'Toolpad_View', @@ -59,16 +63,9 @@ const RenderPanelRoot = styled('div')({ const overlayClasses = { hud: 'Toolpad_Hud', nodeHud: 'Toolpad_NodeHud', - highlightedTop: 'Toolpad_HighlightedTop', - highlightedRight: 'Toolpad_HighlightedRight', - highlightedBottom: 'Toolpad_HighlightedBottom', - highlightedLeft: 'Toolpad_HighlightedLeft', - highlightedCenter: 'Toolpad_HighlightedCenter', - selected: 'Toolpad_Selected', - allowNodeInteraction: 'Toolpad_AllowNodeInteraction', container: 'Toolpad_Container', componentDragging: 'Toolpad_ComponentDragging', - selectionHint: 'Toolpad_SelectionHint', + resize: 'Toolpad_Resize', hudOverlay: 'Toolpad_HudOverlay', }; @@ -82,23 +79,8 @@ const OverlayRoot = styled('div')({ [`&.${overlayClasses.componentDragging}`]: { cursor: 'copy', }, - [`.${overlayClasses.selectionHint}`]: { - // capture mouse events - pointerEvents: 'initial', - cursor: 'grab', - display: 'none', - position: 'absolute', - alignItems: 'center', - right: -1, - background: 'red', - color: 'white', - fontSize: 11, - padding: `0 0 0 8px`, - // TODO: figure out positioning of this selectionhint, perhaps it should - // - prefer top right, above the component - // - if that appears out of bound of the editor, show it bottom or left - zIndex: 1, - transform: `translate(0, -100%)`, + [`&.${overlayClasses.resize}`]: { + cursor: 'ew-resize', }, [`.${overlayClasses.hudOverlay}`]: { position: 'absolute', @@ -106,104 +88,6 @@ const OverlayRoot = styled('div')({ }, }); -const NodeHudWrapper = styled('div')({ - // capture mouse events - pointerEvents: 'initial', - position: 'absolute', - outline: '1px dotted rgba(255,0,0,.2)', - [`.${overlayClasses.selected}`]: { - position: 'absolute', - top: 0, - left: 0, - height: '100%', - width: '100%', - outline: '1px solid red', - }, - [`.${overlayClasses.selectionHint}`]: { - display: 'flex', - }, - [`&.${overlayClasses.allowNodeInteraction}`]: { - // block pointer-events so we can interact with the selection - pointerEvents: 'none', - }, -}); - -const StyledNodeDropArea = styled('div', { - shouldForwardProp: (prop) => prop !== 'highlightRelativeRect', -})<{ - highlightRelativeRect?: Partial; -}>(({ highlightRelativeRect = {} }) => { - const { - x: highlightRelativeX = 0, - y: highlightRelativeY = 0, - width: highlightWidth = '100%', - height: highlightHeight = '100%', - } = highlightRelativeRect; - - return { - pointerEvents: 'none', - position: 'absolute', - [`&.${overlayClasses.highlightedTop}`]: { - '&:after': { - backgroundColor: '#44EB2D', - content: "''", - position: 'absolute', - height: 4, - width: highlightWidth, - top: -2, - left: highlightRelativeX, - }, - }, - [`&.${overlayClasses.highlightedRight}`]: { - '&:after': { - backgroundColor: '#44EB2D', - content: "''", - position: 'absolute', - height: highlightHeight, - width: 4, - top: highlightRelativeY, - right: -2, - }, - }, - [`&.${overlayClasses.highlightedBottom}`]: { - '&:after': { - backgroundColor: '#44EB2D', - content: "''", - position: 'absolute', - height: 4, - width: highlightWidth, - bottom: -2, - left: highlightRelativeX, - }, - }, - [`&.${overlayClasses.highlightedLeft}`]: { - '&:after': { - backgroundColor: '#44EB2D', - content: "''", - position: 'absolute', - height: highlightHeight, - width: 4, - left: -2, - top: highlightRelativeY, - }, - }, - [`&.${overlayClasses.highlightedCenter}`]: { - border: '4px solid #44EB2D', - }, - }; -}); - -const EmptySlot = styled('div')({ - alignItems: 'center', - border: '1px dashed green', - color: 'green', - display: 'flex', - fontSize: 20, - justifyContent: 'center', - position: 'absolute', - opacity: 0.75, -}); - function hasFreeNodeSlots(nodeInfo: NodeInfo): boolean { return Object.keys(nodeInfo.slots || []).length > 0; } @@ -240,25 +124,6 @@ function getRectangleEdgeDropZone(edge: RectangleEdge | null): DropZone | null { } } -function getHighlightedZoneOverlayClass( - highlightedZone: DropZone, -): typeof overlayClasses[keyof typeof overlayClasses] | null { - switch (highlightedZone) { - case DROP_ZONE_TOP: - return overlayClasses.highlightedTop; - case DROP_ZONE_RIGHT: - return overlayClasses.highlightedRight; - case DROP_ZONE_BOTTOM: - return overlayClasses.highlightedBottom; - case DROP_ZONE_LEFT: - return overlayClasses.highlightedLeft; - case DROP_ZONE_CENTER: - return overlayClasses.highlightedCenter; - default: - return null; - } -} - function getChildNodeHighlightedZone(parentFlowDirection: FlowDirection): DropZone | null { switch (parentFlowDirection) { case 'row': @@ -274,18 +139,6 @@ function getChildNodeHighlightedZone(parentFlowDirection: FlowDirection): DropZo } } -function isHorizontalSlot(slot: SlotState): boolean { - return slot.flowDirection === 'row' || slot.flowDirection === 'row-reverse'; -} - -function isVerticalSlot(slot: SlotState): boolean { - return slot.flowDirection === 'column' || slot.flowDirection === 'column-reverse'; -} - -function isReverseSlot(slot: SlotState): boolean { - return slot.flowDirection === 'row-reverse' || slot.flowDirection === 'column-reverse'; -} - function getDropAreaId(nodeId: string, parentProp: string): string { return `${nodeId}:${parentProp}`; } @@ -298,132 +151,6 @@ function getDropAreaParentProp(dropAreaId: string): string | null { return dropAreaId.split(':')[1] || null; } -interface NodeHudProps { - node: appDom.ElementNode | appDom.PageNode; - rect: Rectangle; - selected?: boolean; - allowInteraction?: boolean; - onDragStart?: React.DragEventHandler; - onDelete?: React.MouseEventHandler; -} - -function NodeHud({ node, selected, allowInteraction, rect, onDragStart, onDelete }: NodeHudProps) { - const dom = useDom(); - - const componentId = appDom.isElement(node) ? getElementNodeComponentId(node) : ''; - const component = useToolpadComponent(dom, componentId); - - const handleDelete = React.useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - - if (onDelete) { - onDelete(event); - } - }, - [onDelete], - ); - - return ( - - {selected ? ( - - -
- {component?.displayName || ''} - - - - -
-
- ) : null} -
- ); -} - -interface NodeSlotProps { - node: appDom.ElementNode | appDom.PageNode; - parentInfo: NodeInfo | null; - layoutRect: Rectangle; - dropAreaRect: Rectangle; - slotRect?: Rectangle; - highlightedZone?: DropZone | null; - isEmptySlot: boolean; - isPageChild: boolean; -} - -function NodeDropArea({ - node, - highlightedZone, - parentInfo, - layoutRect, - slotRect, - dropAreaRect, - isEmptySlot, - isPageChild, -}: NodeSlotProps) { - const highlightedZoneOverlayClass = - highlightedZone && getHighlightedZoneOverlayClass(highlightedZone); - - const nodeParentProp = node.parentProp; - - const parentSlots = parentInfo?.slots; - const parentSlot = parentSlots && nodeParentProp && parentSlots[nodeParentProp]; - - const parentRect = parentInfo?.rect; - - const isHorizontalContainerChild = parentSlot ? isHorizontalSlot(parentSlot) : false; - const isVerticalContainerChild = parentSlot ? isVerticalSlot(parentSlot) : false; - - const highlightHeight = - isHorizontalContainerChild && parentRect ? parentRect.height : layoutRect.height; - const highlightWidth = - !isPageChild && isVerticalContainerChild && parentRect ? parentRect.width : layoutRect.width; - - const highlightRelativeX = - (!isPageChild && isVerticalContainerChild && parentRect ? parentRect.x : layoutRect.x) - - dropAreaRect.x; - const highlightRelativeY = - (isHorizontalContainerChild && parentRect ? parentRect.y : layoutRect.y) - dropAreaRect.y; - - const isHighlightingCenter = highlightedZone === DROP_ZONE_CENTER; - - const highlightRect = isHighlightingCenter && isEmptySlot && slotRect ? slotRect : dropAreaRect; - - return ( - - - {isEmptySlot && slotRect ? ( - + - ) : null} - - ); -} - export interface RenderPanelProps { className?: string; } @@ -438,10 +165,12 @@ export default function RenderPanel({ className }: RenderPanelProps) { newNode, viewState, nodeId: pageNodeId, - highlightLayout, + draggedNodeId, + isDraggingOver, dragOverNodeId, dragOverSlotParentProp, dragOverZone, + draggedEdge, } = usePageEditorState(); const { nodes: nodesInfo } = viewState; @@ -454,28 +183,33 @@ export default function RenderPanel({ className }: RenderPanelProps) { return [pageNode, ...appDom.getDescendants(dom, pageNode)]; }, [dom, pageNode]); + const overlayGridRef = React.useRef({ + gridElement: null, + getMinColumnWidth: () => 0, + getLeftColumnEdges: () => [], + getRightColumnEdges: () => [], + }); + const isEmptyPage = pageNodes.length <= 1; const selectedNode = selection && appDom.getNode(dom, selection); - const handleDragStart = React.useCallback( - (event: React.DragEvent) => { + const handleNodeDragStart = React.useCallback( + (node: appDom.ElementNode) => (event: React.DragEvent) => { event.stopPropagation(); - const nodeId = event.currentTarget.dataset.nodeId as NodeId | undefined; - - if (!nodeId) { - return; - } event.dataTransfer.dropEffect = 'move'; - api.select(nodeId); + api.select(node.id); + api.existingNodeDragStart(node); }, [api], ); - const getCurrentlyDraggedNode = React.useCallback((): appDom.ElementNode | null => { - return newNode || (selection && appDom.getNode(dom, selection, 'element')); - }, [dom, newNode, selection]); + const getCurrentlyDraggedNode = React.useCallback( + (): appDom.ElementNode | null => + newNode || (draggedNodeId && appDom.getNode(dom, draggedNodeId, 'element')), + [dom, draggedNodeId, newNode], + ); const availableDropTargets = React.useMemo((): appDom.AppDomNode[] => { const draggedNode = getCurrentlyDraggedNode(); @@ -530,8 +264,10 @@ export default function RenderPanel({ className }: RenderPanelProps) { const isDraggingPageRow = isPageRow(draggedNode); const isDraggingPageColumn = isPageColumn(draggedNode); - const isDraggingOverHorizontalContainer = dragOverSlot && isHorizontalSlot(dragOverSlot); - const isDraggingOverVerticalContainer = dragOverSlot && isVerticalSlot(dragOverSlot); + const isDraggingOverHorizontalContainer = + dragOverSlot && isHorizontalFlow(dragOverSlot.flowDirection); + const isDraggingOverVerticalContainer = + dragOverSlot && isVerticalFlow(dragOverSlot.flowDirection); const isDraggingOverPageRow = appDom.isElement(dragOverNode) && isPageRow(dragOverNode); @@ -657,10 +393,16 @@ export default function RenderPanel({ className }: RenderPanelProps) { const parentSlots = parentInfo?.slots; const parentSlot = (parentSlots && nodeParentProp && parentSlots[nodeParentProp]) || null; - const isParentVerticalContainer = parentSlot ? isVerticalSlot(parentSlot) : false; - const isParentHorizontalContainer = parentSlot ? isHorizontalSlot(parentSlot) : false; + const isParentVerticalContainer = parentSlot + ? isVerticalFlow(parentSlot.flowDirection) + : false; + const isParentHorizontalContainer = parentSlot + ? isHorizontalFlow(parentSlot.flowDirection) + : false; - const isParentReverseContainer = parentSlot ? isReverseSlot(parentSlot) : false; + const isParentReverseContainer = parentSlot + ? isReverseFlow(parentSlot.flowDirection) + : false; let parentGap = 0; if (nodesInfo && gapCount > 0) { @@ -736,16 +478,18 @@ export default function RenderPanel({ className }: RenderPanelProps) { return rects; }, [nodesInfo, pageNodes]); - const handleDragOver = React.useCallback( + const handleNodeDragOver = React.useCallback( (event: React.DragEvent) => { + event.preventDefault(); + const cursorPos = canvasHostRef.current?.getViewCoordinates(event.clientX, event.clientY); - if (!cursorPos) { + const draggedNode = getCurrentlyDraggedNode(); + + if (!cursorPos || !draggedNode) { return; } - event.preventDefault(); - const activeDropAreaId = findAreaAt(dropAreaRects, cursorPos.x, cursorPos.y); const activeDropNodeId: NodeId = @@ -800,7 +544,7 @@ export default function RenderPanel({ className }: RenderPanelProps) { activeDropZone = isDraggingOverEmptyContainer ? DROP_ZONE_CENTER : getRectangleEdgeDropZone( - getRectanglePointEdge(activeDropAreaRect, relativeX, relativeY), + getRectanglePointActiveEdge(activeDropAreaRect, relativeX, relativeY), ); if (isDraggingOverPage) { @@ -820,7 +564,7 @@ export default function RenderPanel({ className }: RenderPanelProps) { ? appDom.isPage(activeDropNodeParent) : false; - if (isHorizontalSlot(activeDropSlot)) { + if (isHorizontalFlow(activeDropSlot.flowDirection)) { if ( isDraggingOverPageChild && activeDropNodeRect && @@ -835,7 +579,7 @@ export default function RenderPanel({ className }: RenderPanelProps) { activeDropZone = DROP_ZONE_CENTER; } } - if (isVerticalSlot(activeDropSlot)) { + if (isVerticalFlow(activeDropSlot.flowDirection)) { if (relativeX <= edgeDetectionMargin) { activeDropZone = DROP_ZONE_LEFT; } else if (activeDropAreaRect.width - relativeX <= edgeDetectionMargin) { @@ -861,6 +605,7 @@ export default function RenderPanel({ className }: RenderPanelProps) { } }, [ + getCurrentlyDraggedNode, dropAreaRects, pageNode.id, dom, @@ -999,11 +744,6 @@ export default function RenderPanel({ className }: RenderPanelProps) { ], ); - const handleDragLeave = React.useCallback( - () => api.nodeDragOver({ nodeId: null, parentProp: null, zone: null }), - [api], - ); - const deleteOrphanedLayoutComponents = React.useCallback( (movedOrDeletedNode: appDom.ElementNode, moveTargetNodeId: NodeId | null = null) => { const movedOrDeletedNodeParentProp = movedOrDeletedNode.parentProp; @@ -1058,6 +798,16 @@ export default function RenderPanel({ className }: RenderPanelProps) { lastContainerChild.parentProp, parent.parentIndex, ); + + if (isPageColumn(parent)) { + domApi.setNodeNamespacedProp( + lastContainerChild, + 'layout', + 'columnSize', + parent.layout?.columnSize || appDom.createConst(1), + ); + } + domApi.removeNode(parent.id); } @@ -1072,6 +822,16 @@ export default function RenderPanel({ className }: RenderPanelProps) { parentParentParent, lastContainerChild.parentProp, ); + + if (isPageColumn(parentParent)) { + domApi.setNodeNamespacedProp( + lastContainerChild, + 'layout', + 'columnSize', + parentParent.layout?.columnSize || appDom.createConst(1), + ); + } + domApi.removeNode(parentParent.id); } } @@ -1089,7 +849,7 @@ export default function RenderPanel({ className }: RenderPanelProps) { [dom, domApi], ); - const handleDrop = React.useCallback( + const handleNodeDrop = React.useCallback( (event: React.DragEvent) => { const draggedNode = getCurrentlyDraggedNode(); const cursorPos = canvasHostRef.current?.getViewCoordinates(event.clientX, event.clientY); @@ -1167,16 +927,18 @@ export default function RenderPanel({ className }: RenderPanelProps) { const isDraggingOverRow = isDraggingOverElement && isPageRow(dragOverNode); const isDraggingOverHorizontalContainer = dragOverSlot - ? isHorizontalSlot(dragOverSlot) + ? isHorizontalFlow(dragOverSlot.flowDirection) + : false; + const isDraggingOverVerticalContainer = dragOverSlot + ? isVerticalFlow(dragOverSlot.flowDirection) : false; - const isDraggingOverVerticalContainer = dragOverSlot ? isVerticalSlot(dragOverSlot) : false; if (dragOverZone === DROP_ZONE_CENTER && dragOverSlotParentProp) { addOrMoveNode(draggedNode, dragOverNode, dragOverSlotParentProp); } const isOriginalParentHorizontalContainer = originalParentSlot - ? isHorizontalSlot(originalParentSlot) + ? isHorizontalFlow(originalParentSlot.flowDirection) : false; if ([DROP_ZONE_TOP, DROP_ZONE_BOTTOM].includes(dragOverZone)) { @@ -1199,7 +961,22 @@ export default function RenderPanel({ className }: RenderPanelProps) { } if (isOriginalParentHorizontalContainer) { - const columnContainer = appDom.createElement(dom, PAGE_COLUMN_COMPONENT_ID, {}); + const columnContainer = appDom.createElement( + dom, + PAGE_COLUMN_COMPONENT_ID, + {}, + { + columnSize: dragOverNode.layout?.columnSize || appDom.createConst(1), + }, + ); + + domApi.setNodeNamespacedProp( + dragOverNode, + 'layout', + 'columnSize', + appDom.createConst(1), + ); + domApi.addNode( columnContainer, parent, @@ -1242,7 +1019,9 @@ export default function RenderPanel({ className }: RenderPanelProps) { } const isOriginalParentNonPageVerticalContainer = - !isOriginalParentPage && originalParentSlot ? isVerticalSlot(originalParentSlot) : false; + !isOriginalParentPage && originalParentSlot + ? isVerticalFlow(originalParentSlot.flowDirection) + : false; if ([DROP_ZONE_RIGHT, DROP_ZONE_LEFT].includes(dragOverZone)) { if (!isDraggingOverHorizontalContainer) { @@ -1289,9 +1068,18 @@ export default function RenderPanel({ className }: RenderPanelProps) { addOrMoveNode(draggedNode, dragOverNode, dragOverSlotParentProp, newParentIndex); } } + + const draggedNodeParent = appDom.getParent(dom, draggedNode); + if ( + draggedNode.layout?.columnSize && + draggedNodeParent && + draggedNodeParent.id !== parent.id + ) { + domApi.setNodeNamespacedProp(draggedNode, 'layout', 'columnSize', appDom.createConst(1)); + } } - api.nodeDragEnd(); + api.dragEnd(); if (selection) { deleteOrphanedLayoutComponents(draggedNode, dragOverNodeId); @@ -1317,32 +1105,32 @@ export default function RenderPanel({ className }: RenderPanelProps) { ], ); - const handleDragEnd = React.useCallback( + const handleNodeDragEnd = React.useCallback( (event: DragEvent | React.DragEvent) => { event.preventDefault(); - api.nodeDragEnd(); + api.dragEnd(); }, [api], ); React.useEffect(() => { - const handleDragOverDefault = (event: DragEvent) => { + const handleNodeDragOverDefault = (event: DragEvent) => { // Make the whole window a drop target to prevent the return animation happening on dragend event.preventDefault(); }; - window.addEventListener('dragover', handleDragOverDefault); - window.addEventListener('dragend', handleDragEnd); + window.addEventListener('dragover', handleNodeDragOverDefault); + window.addEventListener('dragend', handleNodeDragEnd); return () => { - window.removeEventListener('dragover', handleDragOverDefault); - window.removeEventListener('dragend', handleDragEnd); + window.removeEventListener('dragover', handleNodeDragOverDefault); + window.removeEventListener('dragend', handleNodeDragEnd); }; - }, [handleDragEnd]); + }, [handleNodeDragEnd]); - const handleClick = React.useCallback( + const handleNodeMouseUp = React.useCallback( (event: React.MouseEvent) => { const cursorPos = canvasHostRef.current?.getViewCoordinates(event.clientX, event.clientY); - if (!cursorPos) { + if (!cursorPos || draggedNodeId) { return; } @@ -1355,11 +1143,15 @@ export default function RenderPanel({ className }: RenderPanelProps) { api.select(null); } }, - [selectionRects, dom, api], + [draggedNodeId, selectionRects, dom, api], ); const handleDelete = React.useCallback( - (nodeId: NodeId) => { + (nodeId: NodeId) => (event?: React.MouseEvent) => { + if (event) { + event.stopPropagation(); + } + const toRemove = appDom.getNode(dom, nodeId); domApi.removeNode(toRemove.id); @@ -1370,17 +1162,18 @@ export default function RenderPanel({ className }: RenderPanelProps) { api.deselect(); }, - [dom, domApi, deleteOrphanedLayoutComponents, api], + [dom, domApi, api, deleteOrphanedLayoutComponents], ); const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (selection && event.key === 'Backspace') { - handleDelete(selection); + handleDelete(selection)(); } }, [selection, handleDelete], ); + const selectedRect = selectedNode ? nodesInfo[selectedNode.id]?.rect : null; const nodesWithInteraction = React.useMemo>(() => { @@ -1448,6 +1241,224 @@ export default function RenderPanel({ className }: RenderPanelProps) { [dom, domApi, api, navigate], ); + const normalizePageRowColumnSizes = React.useCallback( + (pageRowNode: appDom.ElementNode): number[] => { + const children = appDom.getChildNodes(dom, pageRowNode).children; + + const layoutColumnSizes = children.map((child) => child.layout?.columnSize?.value || 1); + const totalLayoutColumnSizes = layoutColumnSizes.reduce((acc, size) => acc + size, 0); + + const normalizedLayoutColumnSizes = layoutColumnSizes.map( + (size) => (size * children.length) / totalLayoutColumnSizes, + ); + + children.forEach((child, childIndex) => { + if (child.layout?.columnSize) { + domApi.setNodeNamespacedProp( + child, + 'layout', + 'columnSize', + appDom.createConst(normalizedLayoutColumnSizes[childIndex]), + ); + } + }); + + return normalizedLayoutColumnSizes; + }, + [dom, domApi], + ); + + const previousRowColumnCountsRef = React.useRef>({}); + + React.useEffect(() => { + pageNodes.forEach((node: appDom.AppDomNode) => { + if (appDom.isElement(node) && isPageRow(node)) { + const children = appDom.getChildNodes(dom, node).children; + const childrenCount = children.length; + + if (childrenCount < previousRowColumnCountsRef.current[node.id]) { + normalizePageRowColumnSizes(node); + } + + previousRowColumnCountsRef.current[node.id] = childrenCount; + } + }); + }, [dom, normalizePageRowColumnSizes, pageNodes]); + + const resizePreviewElementRef = React.useRef(null); + const resizePreviewElement = resizePreviewElementRef.current; + + const handleEdgeDragStart = React.useCallback( + (node: appDom.ElementNode, edge: RectangleEdge) => (event: React.MouseEvent) => { + event.stopPropagation(); + + api.edgeDragStart({ nodeId: node.id, edge }); + + api.select(node.id); + }, + [api], + ); + + const handleEdgeDragOver = React.useCallback( + (event: React.MouseEvent) => { + const draggedNode = getCurrentlyDraggedNode(); + + if (!draggedNode) { + return; + } + + const draggedNodeInfo = nodesInfo[draggedNode.id]; + const draggedNodeRect = draggedNodeInfo?.rect; + + const parent = draggedNode && appDom.getParent(dom, draggedNode); + + const parentInfo = parent ? nodesInfo[parent.id] : null; + const parentRect = parentInfo?.rect; + + const cursorPos = canvasHostRef.current?.getViewCoordinates(event.clientX, event.clientY); + + if (draggedNodeRect && parentRect && resizePreviewElement && cursorPos) { + let snappedToGridCursorPosX = + Math.round(cursorPos.x / RESIZE_SNAP_UNITS) * RESIZE_SNAP_UNITS; + + const activeSnapGridColumnEdges = + draggedEdge === RECTANGLE_EDGE_LEFT + ? overlayGridRef.current.getLeftColumnEdges() + : overlayGridRef.current.getRightColumnEdges(); + + for (const gridColumnEdge of activeSnapGridColumnEdges) { + if (Math.abs(gridColumnEdge - cursorPos.x) <= SNAP_TO_GRID_COLUMN_MARGIN) { + snappedToGridCursorPosX = gridColumnEdge; + } + } + + const minGridColumnWidth = overlayGridRef.current.getMinColumnWidth(); + + if ( + draggedEdge === RECTANGLE_EDGE_LEFT && + cursorPos.x > parentRect.x + minGridColumnWidth && + cursorPos.x < draggedNodeRect.x + draggedNodeRect.width - minGridColumnWidth + ) { + const updatedTransformScale = + 1 + (draggedNodeRect.x - snappedToGridCursorPosX) / draggedNodeRect.width; + + resizePreviewElement.style.transformOrigin = '100% 50%'; + resizePreviewElement.style.transform = `scale(${updatedTransformScale}, 1)`; + } + if ( + draggedEdge === RECTANGLE_EDGE_RIGHT && + cursorPos.x > draggedNodeRect.x + minGridColumnWidth && + cursorPos.x < parentRect.x + parentRect.width - minGridColumnWidth + ) { + const updatedTransformScale = + (snappedToGridCursorPosX - draggedNodeRect.x) / draggedNodeRect.width; + + resizePreviewElement.style.transformOrigin = '0 50%'; + resizePreviewElement.style.transform = `scale(${updatedTransformScale}, 1)`; + } + } + }, + [ + canvasHostRef, + dom, + draggedEdge, + getCurrentlyDraggedNode, + nodesInfo, + overlayGridRef, + resizePreviewElement, + ], + ); + + const handleEdgeDragEnd = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + + const draggedNode = getCurrentlyDraggedNode(); + + if (!draggedNode) { + return; + } + + const draggedNodeInfo = nodesInfo[draggedNode.id]; + const draggedNodeRect = draggedNodeInfo?.rect; + + const parent = appDom.getParent(dom, draggedNode); + + const parentChildren = parent ? appDom.getChildNodes(dom, parent).children : []; + const totalLayoutColumnSizes = parentChildren.reduce( + (acc, child) => acc + (nodesInfo[child.id]?.rect?.width || 0), + 0, + ); + + const resizePreviewRect = resizePreviewElement?.getBoundingClientRect(); + + if (draggedNodeRect && resizePreviewRect) { + const normalizeColumnSize = (size: number) => + Math.max(0, size * parentChildren.length) / totalLayoutColumnSizes; + + if (draggedEdge === RECTANGLE_EDGE_LEFT) { + const previousSibling = appDom.getSiblingBeforeNode(dom, draggedNode, 'children'); + + if (previousSibling) { + const previousSiblingInfo = nodesInfo[previousSibling.id]; + const previousSiblingRect = previousSiblingInfo?.rect; + + if (previousSiblingRect) { + const updatedDraggedNodeColumnSize = normalizeColumnSize(resizePreviewRect.width); + const updatedPreviousSiblingColumnSize = normalizeColumnSize( + previousSiblingRect.width - (resizePreviewRect.width - draggedNodeRect.width), + ); + + domApi.setNodeNamespacedProp( + draggedNode, + 'layout', + 'columnSize', + appDom.createConst(updatedDraggedNodeColumnSize), + ); + domApi.setNodeNamespacedProp( + previousSibling, + 'layout', + 'columnSize', + appDom.createConst(updatedPreviousSiblingColumnSize), + ); + } + } + } + if (draggedEdge === RECTANGLE_EDGE_RIGHT) { + const nextSibling = appDom.getSiblingAfterNode(dom, draggedNode, 'children'); + + if (nextSibling) { + const nextSiblingInfo = nodesInfo[nextSibling.id]; + const nextSiblingRect = nextSiblingInfo?.rect; + + if (nextSiblingRect) { + const updatedDraggedNodeColumnSize = normalizeColumnSize(resizePreviewRect.width); + const updatedNextSiblingColumnSize = normalizeColumnSize( + nextSiblingRect.width - (resizePreviewRect.width - draggedNodeRect.width), + ); + + domApi.setNodeNamespacedProp( + draggedNode, + 'layout', + 'columnSize', + appDom.createConst(updatedDraggedNodeColumnSize), + ); + domApi.setNodeNamespacedProp( + nextSibling, + 'layout', + 'columnSize', + appDom.createConst(updatedNextSiblingColumnSize), + ); + } + } + } + } + + api.dragEnd(); + }, + [api, dom, domApi, draggedEdge, getCurrentlyDraggedNode, nodesInfo, resizePreviewElement], + ); + return ( {pageNodes.map((node) => { const nodeInfo = nodesInfo[node.id]; @@ -1487,13 +1505,28 @@ export default function RenderPanel({ className }: RenderPanelProps) { const hasMultipleFreeSlots = freeSlotEntries.length > 1; const isPageNode = appDom.isPage(node); + const isPageChild = parent ? appDom.isPage(parent) : false; + const isPageRowChild = parent ? appDom.isElement(parent) && isPageRow(parent) : false; const childNodes = appDom.getChildNodes( dom, node, ) as appDom.NodeChildren; + const parentChildNodes = + parent && + (appDom.getChildNodes(dom, parent) as appDom.NodeChildren); + const parentSlotChildNodes = + parentChildNodes && node.parentProp && parentChildNodes[node.parentProp]; + + const isFirstChild = parentSlotChildNodes + ? parentSlotChildNodes[0].id === node.id + : false; + const isLastChild = parentSlotChildNodes + ? parentSlotChildNodes[parentSlotChildNodes.length - 1].id === node.id + : false; + const nodeRect = nodeInfo?.rect || null; const hasNodeOverlay = isPageNode || appDom.isElement(node); @@ -1508,9 +1541,20 @@ export default function RenderPanel({ className }: RenderPanelProps) { node={node} rect={nodeRect} selected={selectedNode?.id === node.id} - allowInteraction={nodesWithInteraction.has(node.id)} - onDragStart={handleDragStart} - onDelete={() => handleDelete(node.id)} + allowInteraction={nodesWithInteraction.has(node.id) && !draggedEdge} + onNodeDragStart={handleNodeDragStart(node)} + draggableEdges={ + isPageRowChild + ? [ + ...(isFirstChild ? [] : [RECTANGLE_EDGE_LEFT as RectangleEdge]), + ...(isLastChild ? [] : [RECTANGLE_EDGE_RIGHT as RectangleEdge]), + ] + : [] + } + onEdgeDragStart={isPageRowChild ? handleEdgeDragStart : undefined} + onDelete={handleDelete(node.id)} + isResizing={Boolean(draggedEdge) && node.id === draggedNodeId} + resizePreviewElementRef={resizePreviewElementRef} /> ) : null} {hasFreeSlots @@ -1558,11 +1602,12 @@ export default function RenderPanel({ className }: RenderPanelProps) { ); })} {/* - This overlay allows passing through pointer-events through a pinhole - This allows interactivity on the selected element only, while maintaining - a reliable click target for the rest of the page - */} + This overlay allows passing through pointer-events through a pinhole + This allows interactivity on the selected element only, while maintaining + a reliable click target for the rest of the page + */} + {draggedEdge ? : null} } /> diff --git a/packages/toolpad-app/src/utils/geometry.ts b/packages/toolpad-app/src/utils/geometry.ts index aa84ff07c63..3688050fd7b 100644 --- a/packages/toolpad-app/src/utils/geometry.ts +++ b/packages/toolpad-app/src/utils/geometry.ts @@ -1,3 +1,5 @@ +import { FlowDirection } from '@mui/toolpad-core'; + export interface Rectangle { x: number; y: number; @@ -87,6 +89,18 @@ export function absolutePositionCss({ x, y, width, height }: Rectangle): React.C return { left: x, top: y, width, height }; } +export function isHorizontalFlow(flowDirection: FlowDirection): boolean { + return flowDirection === 'row' || flowDirection === 'row-reverse'; +} + +export function isVerticalFlow(flowDirection: FlowDirection): boolean { + return flowDirection === 'column' || flowDirection === 'column-reverse'; +} + +export function isReverseFlow(flowDirection: FlowDirection): boolean { + return flowDirection === 'row-reverse' || flowDirection === 'column-reverse'; +} + // Returns the bounding client rect of an element against another element. export function getRelativeBoundingRect(containerElm: Element, childElm: Element): Rectangle { const containerRect = containerElm.getBoundingClientRect(); @@ -140,7 +154,11 @@ export type RectangleEdge = | typeof RECTANGLE_EDGE_LEFT | typeof RECTANGLE_EDGE_RIGHT; -export function getRectanglePointEdge(rect: Rectangle, x: number, y: number): RectangleEdge | null { +export function getRectanglePointActiveEdge( + rect: Rectangle, + x: number, + y: number, +): RectangleEdge | null { const { height: rectHeight, width: rectWidth } = rect; // Out of bounds diff --git a/packages/toolpad-components/src/Button.tsx b/packages/toolpad-components/src/Button.tsx index 8c7ab0295a0..86962c7eeef 100644 --- a/packages/toolpad-components/src/Button.tsx +++ b/packages/toolpad-components/src/Button.tsx @@ -55,7 +55,7 @@ export default createComponent(Button, { }, label: 'Horizontal alignment', control: { type: 'HorizontalAlign' }, - defaultValue: 'center', + defaultValue: 'start', }, fullWidth: { typeDef: { type: 'boolean' }, diff --git a/packages/toolpad-components/src/Image.tsx b/packages/toolpad-components/src/Image.tsx index 08273e43493..603bce53002 100644 --- a/packages/toolpad-components/src/Image.tsx +++ b/packages/toolpad-components/src/Image.tsx @@ -1,4 +1,4 @@ -import { Box, Skeleton, SxProps } from '@mui/material'; +import { Box, Skeleton, SxProps, BoxProps } from '@mui/material'; import * as React from 'react'; import { createComponent } from '@mui/toolpad-core'; @@ -10,9 +10,21 @@ export interface ImageProps { height: number; loading?: boolean; fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; + alignItems?: BoxProps['alignItems']; + justifyContent?: BoxProps['justifyContent']; } -function Image({ sx: sxProp, src, width, height, alt, loading: loadingProp, fit }: ImageProps) { +function Image({ + sx: sxProp, + src, + width, + height, + alt, + loading: loadingProp, + fit, + alignItems, + justifyContent, +}: ImageProps) { const sx: SxProps = React.useMemo( () => ({ ...sxProp, @@ -32,21 +44,29 @@ function Image({ sx: sxProp, src, width, height, alt, loading: loadingProp, fit const loading = loadingProp || imgLoading; return ( - - {loading ? : null} - + + + {loading ? : null} + + ); } @@ -78,6 +98,24 @@ export default createComponent(Image, { typeDef: { type: 'boolean' }, defaultValue: false, }, + alignItems: { + typeDef: { + type: 'string', + enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], + }, + label: 'Vertical alignment', + control: { type: 'VerticalAlign' }, + defaultValue: 'center', + }, + justifyContent: { + typeDef: { + type: 'string', + enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], + }, + label: 'Horizontal alignment', + control: { type: 'HorizontalAlign' }, + defaultValue: 'start', + }, sx: { typeDef: { type: 'object' }, defaultValue: { maxWidth: '100%' }, diff --git a/packages/toolpad-components/src/PageRow.tsx b/packages/toolpad-components/src/PageRow.tsx index 147d1592239..15a7a03e906 100644 --- a/packages/toolpad-components/src/PageRow.tsx +++ b/packages/toolpad-components/src/PageRow.tsx @@ -3,19 +3,24 @@ import { Box } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; export interface PageRowProps { + layoutColumnSizes: number[]; gap?: number; children?: React.ReactNode; } -function PageRow({ gap, children }: PageRowProps) { +function PageRow({ layoutColumnSizes = [], gap, children }: PageRowProps) { + const gridAutoColumns = layoutColumnSizes.reduce( + (acc, layoutColumnSize) => `${acc}${`${acc && ' '}minmax(0, ${layoutColumnSize || 1}fr)`}`, + '', + ); + return ( {children} diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index af6a98387f8..48f51907c69 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -62,7 +62,7 @@ export default createComponent(Select, { }, label: 'Horizontal alignment', control: { type: 'HorizontalAlign' }, - defaultValue: 'center', + defaultValue: 'start', }, fullWidth: { typeDef: { type: 'boolean' }, diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index a8867b81291..675b86a3298 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -48,7 +48,7 @@ export default createComponent(TextField, { }, label: 'Horizontal alignment', control: { type: 'HorizontalAlign' }, - defaultValue: 'center', + defaultValue: 'start', }, fullWidth: { typeDef: { type: 'boolean' }, diff --git a/packages/toolpad-components/src/Typography.tsx b/packages/toolpad-components/src/Typography.tsx index 912d6e4cb50..da2e44c3641 100644 --- a/packages/toolpad-components/src/Typography.tsx +++ b/packages/toolpad-components/src/Typography.tsx @@ -3,28 +3,40 @@ import { Skeleton, Typography as MuiTypography, TypographyProps as MuiTypographyProps, + Box, + BoxProps, } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; interface TypographyProps extends Omit { value: string; loading?: boolean; + alignItems?: BoxProps['alignItems']; + justifyContent?: BoxProps['justifyContent']; } -function Typography({ value, loading, sx, ...props }: TypographyProps) { +function Typography({ value, loading, alignItems, justifyContent, sx, ...props }: TypographyProps) { return ( - - {loading ? : value} - + + {loading ? : value} + + ); } @@ -42,6 +54,24 @@ export default createComponent(Typography, { enum: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'subtitle1', 'subtitle2', 'body1', 'body2'], }, }, + alignItems: { + typeDef: { + type: 'string', + enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], + }, + label: 'Vertical alignment', + control: { type: 'VerticalAlign' }, + defaultValue: 'center', + }, + justifyContent: { + typeDef: { + type: 'string', + enum: ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly'], + }, + label: 'Horizontal alignment', + control: { type: 'HorizontalAlign' }, + defaultValue: 'start', + }, loading: { typeDef: { type: 'boolean' }, defaultValue: false,