Skip to content

Commit

Permalink
Add boundary highlighting
Browse files Browse the repository at this point in the history
  • Loading branch information
apedroferreira committed May 27, 2022
1 parent 63ee6e9 commit 5bf728a
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { LiveBindings } from '@mui/toolpad-core';
import * as React from 'react';
import * as appDom from '../../../appDom';
import { NodeId, PageViewState } from '../../../types';
import { RectBoundary } from '../../../utils/geometry';
import { update } from '../../../utils/immutability';

export type ComponentPanelTab = 'component' | 'theme';
Expand All @@ -15,6 +16,7 @@ export interface PageEditorState {
readonly newNode: appDom.ElementNode | null;
readonly highlightLayout: boolean;
readonly highlightedNodeId: NodeId | null;
readonly highlightedNodeBoundary: RectBoundary | null;
readonly viewState: PageViewState;
readonly pageState: Record<string, unknown>;
readonly bindings: LiveBindings;
Expand Down Expand Up @@ -42,7 +44,10 @@ export type PageEditorAction =
}
| {
type: 'PAGE_NODE_DRAG_OVER';
nodeId: NodeId | null;
highlightState: {
nodeId: NodeId | null;
boundary: RectBoundary | null;
};
}
| {
type: 'PAGE_NODE_DRAG_END';
Expand Down Expand Up @@ -70,6 +75,7 @@ export function createPageEditorState(appId: string, nodeId: NodeId): PageEditor
newNode: null,
highlightLayout: false,
highlightedNodeId: null,
highlightedNodeBoundary: null,
viewState: { nodes: {} },
pageState: {},
bindings: {},
Expand Down Expand Up @@ -114,9 +120,12 @@ export function pageEditorReducer(
highlightedNodeId: null,
});
case 'PAGE_NODE_DRAG_OVER': {
const { nodeId, boundary } = action.highlightState;

return update(state, {
highlightLayout: true,
highlightedNodeId: action.nodeId,
highlightedNodeId: nodeId,
highlightedNodeBoundary: boundary,
});
}
case 'PAGE_VIEW_STATE_UPDATE': {
Expand Down Expand Up @@ -156,10 +165,10 @@ function createPageEditorApi(dispatch: React.Dispatch<PageEditorAction>) {
nodeDragEnd() {
dispatch({ type: 'PAGE_NODE_DRAG_END' });
},
nodeDragOver(nodeId: NodeId | null) {
nodeDragOver({ nodeId, boundary }: { nodeId: NodeId | null; boundary: RectBoundary | null }) {
dispatch({
type: 'PAGE_NODE_DRAG_OVER',
nodeId,
highlightState: { nodeId, boundary },
});
},
pageViewStateUpdate(viewState: PageViewState) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import { setEventHandler } from '@mui/toolpad-core/runtime';
import { NodeId, NodesInfo } from '../../../types';
import * as appDom from '../../../appDom';
import EditorCanvasHost from './EditorCanvasHost';
import { absolutePositionCss, Rectangle, rectContainsPoint } from '../../../utils/geometry';
import {
absolutePositionCss,
getRectPointBoundary,
Rectangle,
RectBoundary,
rectContainsPoint,
} from '../../../utils/geometry';
import { PinholeOverlay } from '../../../PinholeOverlay';
import { getPageViewState } from '../../../pageViewState';
import { useDom, useDomApi } from '../../DomLoader';
Expand Down Expand Up @@ -40,7 +46,10 @@ const RenderPanelRoot = styled('div')({
const overlayClasses = {
hud: 'Toolpad_Hud',
nodeHud: 'Toolpad_NodeHud',
highlighted: 'Toolpad_Highlighted',
highlightedTop: 'Toolpad_HighlightedTop',
highlightedRight: 'Toolpad_HighlightedRight',
highlightedBottom: 'Toolpad_HighlightedBottom',
highlightedLeft: 'Toolpad_HighlightedLeft',
selected: 'Toolpad_Selected',
allowNodeInteraction: 'Toolpad_AllowNodeInteraction',
layout: 'Toolpad_Layout',
Expand All @@ -51,6 +60,23 @@ const overlayClasses = {
hudOverlay: 'Toolpad_HudOverlay',
};

export const getHighlightedBoundaryOverlayClass = (
highlightedBoundary: RectBoundary,
): typeof overlayClasses[keyof typeof overlayClasses] | null => {
switch (highlightedBoundary) {
case RectBoundary.TOP:
return overlayClasses.highlightedTop;
case RectBoundary.RIGHT:
return overlayClasses.highlightedRight;
case RectBoundary.BOTTOM:
return overlayClasses.highlightedBottom;
case RectBoundary.LEFT:
return overlayClasses.highlightedLeft;
default:
return null;
}
};

const OverlayRoot = styled('div')({
pointerEvents: 'none',
width: '100%',
Expand Down Expand Up @@ -87,8 +113,17 @@ const OverlayRoot = styled('div')({
[`&.${overlayClasses.layout}`]: {
borderColor: 'rgba(255,0,0,.125)',
},
[`&.${overlayClasses.highlighted}`]: {
border: '2px solid green',
[`&.${overlayClasses.highlightedTop}`]: {
borderTop: '2px solid green',
},
[`&.${overlayClasses.highlightedRight}`]: {
borderRight: '2px solid green',
},
[`&.${overlayClasses.highlightedBottom}`]: {
borderBottom: '2px solid green',
},
[`&.${overlayClasses.highlightedLeft}`]: {
borderLeft: '2px solid green',
},
[`&.${overlayClasses.selected}`]: {
border: '1px solid red',
Expand Down Expand Up @@ -128,7 +163,7 @@ function findNodeAt(
interface SelectionHudProps {
node: appDom.ElementNode;
rect: Rectangle;
isHighlighted?: boolean;
highlightedBoundary?: RectBoundary | null;
selected?: boolean;
allowInteraction?: boolean;
onDragStart?: React.DragEventHandler<HTMLElement>;
Expand All @@ -137,7 +172,7 @@ interface SelectionHudProps {

function NodeHud({
node,
isHighlighted,
highlightedBoundary,
selected,
allowInteraction,
rect,
Expand All @@ -152,6 +187,9 @@ function NodeHud({
const isLayoutComponent =
componentId === PAGE_ROW_COMPONENT_ID || componentId === PAGE_COLUMN_COMPONENT_ID;

const highlightedBoundaryOverlayClass =
highlightedBoundary && getHighlightedBoundaryOverlayClass(highlightedBoundary);

return (
<div
draggable
Expand All @@ -160,7 +198,7 @@ function NodeHud({
style={absolutePositionCss(rect)}
className={clsx(overlayClasses.nodeHud, {
[overlayClasses.layout]: isLayoutComponent,
[overlayClasses.highlighted]: isHighlighted,
...(highlightedBoundaryOverlayClass ? { [highlightedBoundaryOverlayClass]: true } : {}),
[overlayClasses.selected]: selected,
[overlayClasses.allowNodeInteraction]: allowInteraction,
})}
Expand Down Expand Up @@ -192,6 +230,7 @@ export default function RenderPanel({ className }: RenderPanelProps) {
nodeId: pageNodeId,
highlightLayout,
highlightedNodeId,
highlightedNodeBoundary,
} = usePageEditorState();

const { nodes: nodesInfo } = viewState;
Expand Down Expand Up @@ -273,7 +312,7 @@ export default function RenderPanel({ className }: RenderPanelProps) {
return;
}

const newActiveDropNodeId = findNodeAt(
const activeDropNodeId = findNodeAt(
availableDropTargets,
nodesInfo,
cursorPos.x,
Expand All @@ -282,27 +321,54 @@ export default function RenderPanel({ className }: RenderPanelProps) {

event.preventDefault();

const activeDropNodeInfo = activeDropNodeId && nodesInfo[activeDropNodeId];

let activeDropBoundary = null;
if (activeDropNodeInfo) {
const activeDropNodeRect = activeDropNodeInfo.rect;

if (activeDropNodeRect) {
activeDropBoundary = getRectPointBoundary(
activeDropNodeRect,
cursorPos.x - activeDropNodeRect.x,
cursorPos.y - activeDropNodeRect.y,
{
ignoreCenterAreaXFraction: 0.25,
ignoreCenterAreaYFraction: 0.25,
},
);
}
}

const hasChangedHighlightedArea =
activeDropNodeId !== highlightedNodeId || activeDropBoundary !== highlightedNodeBoundary;

if (
newActiveDropNodeId &&
newActiveDropNodeId !== highlightedNodeId &&
availableDropTargetIds.has(newActiveDropNodeId)
activeDropNodeId &&
activeDropBoundary &&
hasChangedHighlightedArea &&
availableDropTargetIds.has(activeDropNodeId)
) {
api.nodeDragOver(newActiveDropNodeId);
} else if (!newActiveDropNodeId && highlightedNodeId) {
api.nodeDragOver(null);
api.nodeDragOver({ nodeId: activeDropNodeId, boundary: activeDropBoundary });
} else if (highlightedNodeId && (!activeDropNodeId || !activeDropBoundary)) {
api.nodeDragOver({ nodeId: null, boundary: null });
}
},
[
api,
availableDropTargetIds,
availableDropTargets,
getViewCoordinates,
highlightedNodeBoundary,
highlightedNodeId,
nodesInfo,
],
);

const handleDragLeave = React.useCallback(() => api.nodeDragOver(null), [api]);
const handleDragLeave = React.useCallback(
() => api.nodeDragOver({ nodeId: null, boundary: null }),
[api],
);

const handleDrop = React.useCallback(() => {}, []);

Expand Down Expand Up @@ -527,7 +593,7 @@ export default function RenderPanel({ className }: RenderPanelProps) {
key={node.id}
node={node}
rect={nodeLayout.rect}
isHighlighted={isHighlighted}
highlightedBoundary={isHighlighted ? highlightedNodeBoundary : null}
selected={selectedNode?.id === node.id}
allowInteraction={nodesWithInteraction.has(node.id)}
onDragStart={handleDragStart}
Expand Down
50 changes: 50 additions & 0 deletions packages/toolpad-app/src/utils/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,53 @@ export function getRelativeOuterRect(containerElm: Element, childElm: Element):
export function rectContainsPoint(rect: Rectangle, x: number, y: number): boolean {
return rect.x <= x && rect.x + rect.width >= x && rect.y <= y && rect.y + rect.height >= y;
}

export enum RectBoundary {
TOP = 'TOP',
RIGHT = 'RIGHT',
BOTTOM = 'BOTTOM',
LEFT = 'LEFT',
}

interface GetRectPointBoundaryOptions {
ignoreCenterAreaXFraction?: number // 0-1
ignoreCenterAreaYFraction?: number // 0-1
}

export function getRectPointBoundary(rect: Rectangle, x: number, y: number, options: GetRectPointBoundaryOptions = {}): RectBoundary | null {
const { height: rectHeight, width: rectWidth } = rect
const { ignoreCenterAreaXFraction = 0, ignoreCenterAreaYFraction = 0 } = options

// Out of bounds
if (x < 0 || x > rectWidth || y < 0 || y > rectHeight) {
return null;
}

// Ignored center area fractions
const fractionalX = x / rectWidth
const fractionalY = y / rectHeight
const includedCenterAreaXFractionHalf = (1 - ignoreCenterAreaXFraction) / 2
const includedCenterAreaYFractionHalf = (1 - ignoreCenterAreaYFraction) / 2
if (
fractionalX > includedCenterAreaXFractionHalf &&
fractionalX < 1 - includedCenterAreaXFractionHalf &&
fractionalY > includedCenterAreaYFractionHalf &&
fractionalY < 1 - includedCenterAreaYFractionHalf
) {
return null
}

const isOverFirstDiagonal = y < (rectHeight / rectWidth) * x;
const isOverSecondDiagonal = y < -1 * (rectHeight / rectWidth) * x + rectHeight;

if (isOverFirstDiagonal && isOverSecondDiagonal) {
return RectBoundary.TOP;
}
if (isOverFirstDiagonal) {
return RectBoundary.RIGHT;
}
if (isOverSecondDiagonal) {
return RectBoundary.LEFT;
}
return RectBoundary.BOTTOM;
}
60 changes: 30 additions & 30 deletions packages/toolpad-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,25 +84,25 @@ export interface FunctionValueType extends ValueTypeBase {

export interface ArgControlSpec {
type:
| 'boolean' // checkbox
| 'number' // number input
| 'range' // slider
| 'object' // json editor
| 'radio' // radio buttons
| 'buttons' // button group
| 'select' // select control
| 'string' // text input
| 'color' // color picker
| 'slot' // slot in canvas
| 'slots' // slots in canvas
| 'multiSelect' // multi select ({ type: 'array', items: { type: 'enum', values: ['1', '2', '3'] } })
| 'date' // date picker
| 'json' // JSON editor
| 'GridColumns' // GridColumns specialized editor
| 'HorizontalAlign'
| 'VerticalAlign'
| 'function'
| 'RowIdFieldSelect'; // Row id field specialized select
| 'boolean' // checkbox
| 'number' // number input
| 'range' // slider
| 'object' // json editor
| 'radio' // radio buttons
| 'buttons' // button group
| 'select' // select control
| 'string' // text input
| 'color' // color picker
| 'slot' // slot in canvas
| 'slots' // slots in canvas
| 'multiSelect' // multi select ({ type: 'array', items: { type: 'enum', values: ['1', '2', '3'] } })
| 'date' // date picker
| 'json' // JSON editor
| 'GridColumns' // GridColumns specialized editor
| 'HorizontalAlign'
| 'VerticalAlign'
| 'function'
| 'RowIdFieldSelect'; // Row id field specialized select
}

type PrimitiveValueType =
Expand Down Expand Up @@ -172,19 +172,19 @@ export interface LiveBinding {

export type RuntimeEvent =
| {
type: 'propUpdated';
nodeId: string;
prop: string;
value: React.SetStateAction<unknown>;
}
type: 'propUpdated';
nodeId: string;
prop: string;
value: React.SetStateAction<unknown>;
}
| {
type: 'pageStateUpdated';
pageState: Record<string, unknown>;
}
type: 'pageStateUpdated';
pageState: Record<string, unknown>;
}
| {
type: 'pageBindingsUpdated';
bindings: LiveBindings;
};
type: 'pageBindingsUpdated';
bindings: LiveBindings;
};

export interface ComponentConfig<P> {
/**
Expand Down

0 comments on commit 5bf728a

Please sign in to comment.