diff --git a/packages/toolpad-app/src/appDom/index.ts b/packages/toolpad-app/src/appDom/index.ts index 1b1ae5f5374..b8ef1723031 100644 --- a/packages/toolpad-app/src/appDom/index.ts +++ b/packages/toolpad-app/src/appDom/index.ts @@ -367,7 +367,6 @@ export function getApp(dom: AppDom): AppNode { export type NodeChildren = ChildNodesOf; -// TODO: memoize the result of this function per dom in a WeakMap const childrenMemo = new WeakMap>>(); export function getChildNodes(dom: AppDom, parent: N): NodeChildren { let domChildrenMemo = childrenMemo.get(dom); diff --git a/packages/toolpad-app/src/components/EditableText.tsx b/packages/toolpad-app/src/components/EditableText.tsx index 0c1bfa34c29..7a39ea9e281 100644 --- a/packages/toolpad-app/src/components/EditableText.tsx +++ b/packages/toolpad-app/src/components/EditableText.tsx @@ -18,6 +18,7 @@ interface EditableTextProps { onChange?: (newValue: string) => void; onSave?: (newValue: string) => void; onClose?: () => void; + onDoubleClick?: () => void; size?: 'small' | 'medium'; sx?: SxProps; value?: string; @@ -34,6 +35,7 @@ const EditableText = React.forwardRef( error, onChange, onClose, + onDoubleClick, onSave, size, sx, @@ -113,9 +115,10 @@ const EditableText = React.forwardRef( error={error} helperText={helperText} ref={ref} + onDoubleClick={onDoubleClick} inputRef={appTitleInput} inputProps={{ - tabIndex: editable ? 0 : -1, + // tabIndex: editable ? 0 : -1, 'aria-readonly': !editable, sx: (theme: Theme) => ({ // Handle overflow @@ -123,7 +126,8 @@ const EditableText = React.forwardRef( overflow: 'hidden', whiteSpace: 'nowrap', fontSize: theme.typography[typographyVariant].fontSize, - height: `1.1em`, + height: theme.typography.pxToRem(8), + cursor: 'pointer', }), }} onKeyUp={handleInput} diff --git a/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx index 8bd2e741678..22398e29fab 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx @@ -2,8 +2,8 @@ import { TreeView } from '@mui/lab'; import { Typography, styled, Box, IconButton } from '@mui/material'; import * as React from 'react'; import TreeItem, { treeItemClasses, TreeItemProps } from '@mui/lab/TreeItem'; -import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; -import ArrowRightIcon from '@mui/icons-material/ArrowRight'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import AddIcon from '@mui/icons-material/Add'; import { NodeId } from '@mui/toolpad-core'; @@ -68,14 +68,14 @@ function HierarchyTreeItem(props: StyledTreeItemProps) { return ( + {labelIcon} {labelText} {onCreate ? ( - - + + ) : null} {toolpadNodeId ? ( @@ -86,9 +86,10 @@ function HierarchyTreeItem(props: StyledTreeItemProps) { [classes.treeItemMenuOpen]: menuProps.open, })} aria-label="Open hierarchy menu" + size="small" {...buttonProps} > - + )} nodeId={toolpadNodeId} @@ -234,8 +235,8 @@ export default function HierarchyExplorer({ className }: HierarchyExplorerProps) expanded={expanded} onNodeToggle={handleToggle} multiSelect - defaultCollapseIcon={} - defaultExpandIcon={} + defaultCollapseIcon={} + defaultExpandIcon={} > >([ ['Autocomplete', ManageSearchIcon], @@ -59,6 +61,8 @@ const iconMap = new Map>([ ['Drawer', ViewSidebarIcon], ['Icon', MoodIcon], ['Html', HtmlIcon], + ['PageRow', TableRowsIcon], + ['PageColumn', ViewColumnIcon], ['Metric', TagIcon], ]); @@ -67,11 +71,12 @@ type ComponentItemKind = 'future' | 'builtIn' | 'create' | 'custom'; interface ComponentIconProps { id: string; kind?: ComponentItemKind; + sx?: SxProps; } -function ComponentIcon({ id: componentId, kind }: ComponentIconProps) { +export function ComponentIcon({ id: componentId, kind, sx }: ComponentIconProps) { const Icon = iconMap.get(kind === 'custom' ? 'CodeComponent' : componentId); - return Icon ? : null; + return Icon ? : null; } interface ComponentCatalogItemProps { diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx index 658a882ce2b..317b37aa3ec 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx @@ -26,6 +26,7 @@ function stopPropagationHandler(event: React.SyntheticEvent) { const nodeHudClasses = { selected: 'NodeHud_Selected', + hovered: 'NodeHud_Hovered', selectionHint: 'NodeHud_SelectionHint', }; @@ -41,7 +42,7 @@ const NodeHudWrapper = styled('div', { userSelect: 'none', outline: `1px dotted ${isOutlineVisible ? theme.palette.primary[500] : 'transparent'}`, zIndex: 80, - '&:hover': { + [`&:hover, &.${nodeHudClasses.hovered}`]: { outline: `2px dashed ${isHoverable ? theme.palette.primary[500] : 'transparent'}`, }, [`.${nodeHudClasses.selected}`]: { @@ -150,6 +151,7 @@ interface NodeHudProps { onDuplicate?: (event: React.MouseEvent) => void; isOutlineVisible?: boolean; isHoverable?: boolean; + isHovered?: boolean; } export default function NodeHud({ @@ -167,6 +169,7 @@ export default function NodeHud({ onDuplicate, isOutlineVisible = false, isHoverable = true, + isHovered = false, }: NodeHudProps) { const hintPosition = rect.y > HUD_HEIGHT ? HINT_POSITION_TOP : HINT_POSITION_BOTTOM; @@ -205,6 +208,7 @@ export default function NodeHud({ } : {}), }} + className={isHovered ? nodeHudClasses.hovered : ''} isOutlineVisible={isOutlineVisible} isHoverable={isHoverable} > diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx index 08cd3bfb805..96c1996d7fb 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx @@ -276,6 +276,7 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const { dom } = useDom(); const { currentView } = useAppState(); const selectedNodeId = currentView.kind === 'page' ? currentView.selectedNodeId : null; + const hoveredNodeId = currentView.kind === 'page' ? currentView.hoveredNodeId : null; const domApi = useDomApi(); const appStateApi = useAppStateApi(); @@ -1586,6 +1587,7 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const isPageColumnChild = parent ? appDom.isElement(parent) && isPageColumn(parent) : false; const isSelected = selectedNode && !newNode ? selectedNode.id === node.id : false; + const isHovered = hoveredNodeId === node.id; const isHorizontallyResizable = isSelected && (isPageRowChild || isPageColumnChild); const isVerticallyResizable = @@ -1625,6 +1627,7 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { onDelete={handleNodeDelete(node.id)} isResizing={isResizingNode} resizePreviewElementRef={resizePreviewElementRef} + isHovered={isHovered} isHoverable={!isResizing && !isDraggingOver} isOutlineVisible={isDraggingOver} /> diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PagePanel.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PagePanel.tsx index 6b65cd54ae8..bf937b500c8 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PagePanel.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PagePanel.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { styled, SxProps, Box, Divider, Typography } from '@mui/material'; -import HierarchyExplorer from './HierarchyExplorer'; +import PagesHierarchyExplorer from './HierarchyExplorer'; +import PageStructureExplorer from './StructureExplorer'; +import SplitPane from '../../components/SplitPane'; import { useDom } from '../AppState'; import AppOptions from '../AppOptions'; import config from '../../config'; @@ -36,7 +38,10 @@ export default function PagePanel({ className, sx }: ComponentPanelProps) { - + + + + ); } diff --git a/packages/toolpad-app/src/toolpad/AppEditor/StructureExplorer/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/StructureExplorer/index.tsx new file mode 100644 index 00000000000..d4f27c4db65 --- /dev/null +++ b/packages/toolpad-app/src/toolpad/AppEditor/StructureExplorer/index.tsx @@ -0,0 +1,297 @@ +import * as React from 'react'; +import { NodeId } from '@mui/toolpad-core'; +import { Box, Typography } from '@mui/material'; +import TreeView from '@mui/lab/TreeView'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import TreeItem, { TreeItemProps } from '@mui/lab/TreeItem'; +import * as appDom from '../../../appDom'; +import { useDom, useDomApi, useAppState, useAppStateApi } from '../../AppState'; +import EditableText from '../../../components/EditableText'; +import { ComponentIcon } from '../PageEditor/ComponentCatalog/ComponentCatalogItem'; +import { useNodeNameValidation } from '../HierarchyExplorer/validation'; +import { + PAGE_ROW_COMPONENT_ID, + PAGE_COLUMN_COMPONENT_ID, +} from '../../../runtime/toolpadComponents'; +import { DomView } from '../../../utils/domView'; + +function CustomTreeItem( + props: TreeItemProps & { + node: appDom.ElementNode; + onHover?: (event: React.MouseEvent, nodeId: NodeId) => void; + onMouseLeave?: (event: React.MouseEvent) => void; + }, +) { + const domApi = useDomApi(); + const { dom } = useDom(); + + const [domNodeEditable, setDomNodeEditable] = React.useState(false); + const { label, node, onHover, onMouseLeave, ...other } = props; + + const [nodeNameInput, setNodeNameInput] = React.useState(node.name); + const handleNodeNameChange = React.useCallback( + (newValue: string) => setNodeNameInput(newValue), + [], + ); + const handleStopEditing = React.useCallback(() => { + setNodeNameInput(node.name); + setDomNodeEditable(false); + }, [node.name]); + + const existingNames = React.useMemo(() => appDom.getExistingNamesForNode(dom, node), [dom, node]); + const nodeNameError = useNodeNameValidation(nodeNameInput, existingNames, node.type); + const isNameValid = !nodeNameError; + + const handleNameSave = React.useCallback(() => { + if (isNameValid) { + setNodeNameInput(nodeNameInput); + domApi.setNodeName(node.id, nodeNameInput); + } else { + setNodeNameInput(node.name); + } + }, [isNameValid, domApi, node.id, node.name, nodeNameInput]); + + return ( + ) => { + onHover?.(event, node.id); + }} + onMouseLeave={onMouseLeave} + label={ + + + setDomNodeEditable(true)} + onChange={handleNodeNameChange} + onClose={handleStopEditing} + onSave={handleNameSave} + error={!isNameValid} + helperText={nodeNameError} + sx={{ flexGrow: 1 }} + /> + + } + {...other} + /> + ); +} + +function RecursiveSubTree({ + dom, + root, + onHover, + onMouseLeave, +}: { + dom: appDom.AppDom; + root: appDom.ElementNode; + onHover?: (event: React.MouseEvent, nodeId: NodeId) => void; + onMouseLeave?: (event: React.MouseEvent) => void; +}) { + const { children = [], renderItem = [] } = React.useMemo( + () => appDom.getChildNodes(dom, root), + [dom, root], + ); + + if ( + (root.attributes.component === PAGE_ROW_COMPONENT_ID || + root.attributes.component === PAGE_COLUMN_COMPONENT_ID) && + children.length === 1 + ) { + return children.map((childNode) => ( + + )); + } + + if (children.length) { + return ( + + {children.map((childNode) => ( + + ))} + + ); + } + if (renderItem.length) { + return ( + {root.name}}> + renderItem} + > + {renderItem.map((childNode) => ( + + ))} + + + ); + } + + return ( + + ); +} + +export default function PageStructureExplorer() { + const { dom } = useDom(); + const { currentView } = useAppState(); + const appStateApi = useAppStateApi(); + const [expandedDomNodeIds, setExpandedDomNodeIds] = React.useState([]); + + const currentPageId = currentView?.nodeId; + const currentPageNode = currentPageId ? appDom.getNode(dom, currentPageId, 'page') : null; + const selectedDomNodeId = (currentView as Extract)?.selectedNodeId; + + const selectedNodeAncestorIds = React.useMemo(() => { + if (!selectedDomNodeId) { + return []; + } + const selectedNode = appDom.getMaybeNode(dom, selectedDomNodeId); + if (selectedNode) { + return appDom.getAncestors(dom, selectedNode).map((node) => node.id); + } + return []; + }, [dom, selectedDomNodeId]); + + const { children: rootChildren = [] } = React.useMemo(() => { + if (!currentPageNode) { + return { children: [] }; + } + return appDom.getChildNodes(dom, currentPageNode); + }, [dom, currentPageNode]); + + const handleNodeSelect = React.useCallback( + (event: React.SyntheticEvent, nodeId: string) => { + appStateApi.selectNode(nodeId as NodeId); + }, + [appStateApi], + ); + + const handleNodeFocus = React.useCallback( + (event: React.SyntheticEvent, nodeId: string) => { + appStateApi.hoverNode(nodeId as NodeId); + }, + [appStateApi], + ); + + const handleNodeHover = React.useCallback( + (event: React.MouseEvent, nodeId: NodeId) => { + appStateApi.hoverNode(nodeId as NodeId); + }, + [appStateApi], + ); + + const handleNodeBlur = React.useCallback(() => { + appStateApi.blurHoverNode(); + }, [appStateApi]); + + const handleNodeToggle = React.useCallback( + (event: React.SyntheticEvent, nodeIds: string[]) => { + setExpandedDomNodeIds(nodeIds); + }, + [setExpandedDomNodeIds], + ); + + const deleteNode = React.useCallback(() => { + if (!selectedDomNodeId) { + return; + } + appStateApi.update( + (draft) => { + const toRemove = appDom.getMaybeNode(dom, selectedDomNodeId); + if (toRemove && appDom.isElement(toRemove)) { + draft = appDom.removeNode(draft, toRemove.id); + } + return draft; + }, + { + ...(currentView as Extract), + selectedNodeId: null, + }, + ); + }, [appStateApi, dom, selectedDomNodeId, currentView]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + // delete selected node if event.key is Backspace + if (event.key === 'Backspace') { + deleteNode(); + } + }, + [deleteNode], + ); + + const expandedDomNodeIdSet = React.useMemo(() => { + return new Set([...selectedNodeAncestorIds, ...expandedDomNodeIds]); + }, [selectedNodeAncestorIds, expandedDomNodeIds]); + + return ( + + ({ + flexGrow: 1, + fontWeight: theme.typography.fontWeightLight, + mx: 1, + my: 0.5, + })} + > + Components + + } + defaultExpandIcon={} + expanded={Array.from(expandedDomNodeIdSet)} + selected={selectedDomNodeId as string} + onNodeSelect={handleNodeSelect} + onNodeFocus={handleNodeFocus} + onNodeToggle={handleNodeToggle} + onKeyDown={handleKeyDown} + sx={{ + flexGrow: 1, + maxHeight: 450, + maxWidth: 400, + overflowY: 'auto', + scrollbarGutter: 'stable', + }} + > + {rootChildren.map((childNode) => ( + + ))} + + + ); +} diff --git a/packages/toolpad-app/src/toolpad/AppState.tsx b/packages/toolpad-app/src/toolpad/AppState.tsx index 48ebe300fb4..a7d0b5b734d 100644 --- a/packages/toolpad-app/src/toolpad/AppState.tsx +++ b/packages/toolpad-app/src/toolpad/AppState.tsx @@ -74,6 +74,13 @@ export type AppStateAction = | { type: 'DESELECT_NODE'; } + | { + type: 'HOVER_NODE'; + nodeId: NodeId; + } + | { + type: 'BLUR_HOVER_NODE'; + } | { type: 'SET_HAS_UNSAVED_CHANGES'; hasUnsavedChanges: boolean; @@ -245,6 +252,22 @@ export function appStateReducer(state: AppState, action: AppStateAction): AppSta } return state; } + case 'HOVER_NODE': { + if (state.currentView.kind === 'page') { + return update(state, { + currentView: { ...state.currentView, hoveredNodeId: action.nodeId }, + }); + } + return state; + } + case 'BLUR_HOVER_NODE': { + if (state.currentView.kind === 'page') { + return update(state, { + currentView: { ...state.currentView, hoveredNodeId: null }, + }); + } + return state; + } case 'SET_VIEW': case 'UPDATE': { if (!action.view) { @@ -360,6 +383,17 @@ function createAppStateApi( nodeId, }); }, + hoverNode(nodeId: NodeId) { + dispatch({ + type: 'HOVER_NODE', + nodeId, + }); + }, + blurHoverNode() { + dispatch({ + type: 'BLUR_HOVER_NODE', + }); + }, deselectNode() { dispatch({ type: 'DESELECT_NODE', diff --git a/packages/toolpad-app/src/utils/domView.ts b/packages/toolpad-app/src/utils/domView.ts index 4d70052a03c..30140351766 100644 --- a/packages/toolpad-app/src/utils/domView.ts +++ b/packages/toolpad-app/src/utils/domView.ts @@ -12,6 +12,7 @@ export type DomView = nodeId?: NodeId; view?: PageView; selectedNodeId?: NodeId | null; + hoveredNodeId?: NodeId | null; tab?: PageViewTab; } | { kind: 'connection'; nodeId: NodeId }