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,