diff --git a/packages/toolpad-studio/src/canvas/BridgeContext.ts b/packages/toolpad-studio/src/canvas/BridgeContext.ts deleted file mode 100644 index 922d044f29b..00000000000 --- a/packages/toolpad-studio/src/canvas/BridgeContext.ts +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react'; -import type { ToolpadBridge } from './ToolpadBridge'; - -export const BridgeContext = React.createContext(null); diff --git a/packages/toolpad-studio/src/canvas/ToolpadBridge.tsx b/packages/toolpad-studio/src/canvas/ToolpadBridge.tsx deleted file mode 100644 index 955fe6c44e1..00000000000 --- a/packages/toolpad-studio/src/canvas/ToolpadBridge.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Emitter } from '@toolpad/utils/events'; -import type { RuntimeEvents } from '@toolpad/studio-runtime'; -import type { PageViewState } from '../types'; - -const COMMAND_HANDLERS = Symbol('hidden property to hold the command handlers'); - -type Commands> = T & { - [COMMAND_HANDLERS]: Partial; -}; - -export function createCommands>( - initial: Partial = {}, -): Commands { - return new Proxy( - { - [COMMAND_HANDLERS]: initial, - }, - { - get(target, prop, receiver) { - if (typeof prop !== 'string') { - return Reflect.get(target, prop, receiver); - } - - return (...args: any[]): any => { - const handler = target[COMMAND_HANDLERS][prop]; - if (typeof handler !== 'function') { - throw new Error(`Command "${prop}" not recognized.`); - } - return handler(...args); - }; - }, - }, - ) as Commands; -} - -export function setCommandHandler, K extends keyof T & string>( - commands: Commands, - name: K, - handler: T[K], -) { - commands[COMMAND_HANDLERS][name] = handler; -} - -// Interface to communicate between editor and canvas -export interface ToolpadBridge { - // Events fired in the editor, listened in the canvas - editorEvents: Emitter<{}>; - // Commands executed from the canvas, ran in the editor - editorCommands: Commands<{}>; - // Events fired in the canvas, listened in the editor - canvasEvents: Emitter; - // Commands executed from the editor, ran in the canvas - canvasCommands: Commands<{ - getViewCoordinates(clientX: number, clientY: number): { x: number; y: number } | null; - getPageViewState(): PageViewState; - scrollComponent(nodeId: string): void; - isReady(): boolean; - invalidateQueries(): void; - }>; -} -const isRenderedInCanvas = - typeof window !== 'undefined' && - (window.frameElement as HTMLIFrameElement | null)?.dataset.toolpadCanvas; - -let canvasIsReady = false; - -const bridge: ToolpadBridge | null = isRenderedInCanvas - ? ({ - editorEvents: new Emitter(), - editorCommands: createCommands(), - canvasEvents: new Emitter(), - canvasCommands: createCommands({ - isReady: () => canvasIsReady, - getPageViewState: () => { - throw new Error('Not implemented'); - }, - scrollComponent: () => { - throw new Error('Not Implemented'); - }, - getViewCoordinates: () => { - throw new Error('Not implemented'); - }, - invalidateQueries: () => { - throw new Error('Not implemented'); - }, - update: () => { - throw new Error('Not implemented'); - }, - }), - } satisfies ToolpadBridge) - : null; - -bridge?.canvasEvents.on('ready', () => { - canvasIsReady = true; -}); - -export { bridge }; diff --git a/packages/toolpad-studio/src/canvas/index.tsx b/packages/toolpad-studio/src/canvas/index.tsx deleted file mode 100644 index d1de1ca27dc..00000000000 --- a/packages/toolpad-studio/src/canvas/index.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import * as React from 'react'; -import invariant from 'invariant'; -import { throttle } from 'lodash-es'; -import { - queryClient, - FlowDirection, - SlotType, - useAppHost, - CanvasEventsContext, -} from '@toolpad/studio-runtime'; -import { update } from '@toolpad/utils/immutability'; -import { AppCanvasState, NodeInfo, PageViewState, SlotsState } from '../types'; -import { - getRelativeBoundingRect, - getRelativeOuterRect, - rectContainsPoint, -} from '../utils/geometry'; -import { ToolpadBridge, bridge, setCommandHandler } from './ToolpadBridge'; -import { ToolpadApp, CanvasHooks, CanvasHooksContext } from '../runtime'; - -const handleScreenUpdate = throttle( - () => { - bridge?.canvasEvents.emit('screenUpdate', {}); - }, - 50, - { trailing: true }, -); - -export function updateNodeInfo(nodeInfo: NodeInfo, rootElm: Element): NodeInfo { - const nodeElm = rootElm.querySelector(`[data-toolpad-node-id="${nodeInfo.nodeId}"]`); - - if (!nodeElm) { - return nodeInfo; - } - - const rect = getRelativeOuterRect(rootElm, nodeElm); - - const slotElms = rootElm.querySelectorAll(`[data-toolpad-slot-parent="${nodeInfo.nodeId}"]`); - - const slots: SlotsState = {}; - - for (const slotElm of slotElms) { - const slotName = slotElm.getAttribute('data-toolpad-slot-name'); - const slotType = slotElm.getAttribute('data-toolpad-slot-type'); - - invariant(slotName, 'Slot name not found'); - invariant(slotType, 'Slot type not found'); - - if (slots[slotName]) { - continue; - } - - const slotRect = - slotType === 'single' - ? getRelativeBoundingRect(rootElm, slotElm) - : getRelativeBoundingRect(rootElm, slotElm); - - const display = window.getComputedStyle(slotElm).display; - let flowDirection: FlowDirection = 'row'; - if (slotType === 'layout') { - flowDirection = 'column'; - } else if (display === 'grid') { - const gridAutoFlow = window.getComputedStyle(slotElm).gridAutoFlow; - flowDirection = gridAutoFlow === 'row' ? 'column' : 'row'; - } else if (display === 'flex') { - flowDirection = window.getComputedStyle(slotElm).flexDirection as FlowDirection; - } - - slots[slotName] = { - type: slotType as SlotType, - rect: slotRect, - flowDirection, - }; - } - - return { ...nodeInfo, rect, slots }; -} - -export interface AppCanvasProps { - state: AppCanvasState; - basename: string; -} - -export default function AppCanvas({ basename, state }: AppCanvasProps) { - const [readyBridge, setReadyBridge] = React.useState(); - - const appRootRef = React.useRef(); - const appRootCleanupRef = React.useRef<() => void>(); - const onAppRoot = React.useCallback((appRoot: HTMLDivElement) => { - appRootCleanupRef.current?.(); - appRootCleanupRef.current = undefined; - - if (!appRoot) { - return; - } - - appRootRef.current = appRoot; - - const mutationObserver = new MutationObserver(handleScreenUpdate); - - mutationObserver.observe(appRoot, { - attributes: true, - childList: true, - subtree: true, - characterData: true, - }); - - const resizeObserver = new ResizeObserver(handleScreenUpdate); - - resizeObserver.observe(appRoot); - appRoot.querySelectorAll('*').forEach((elm) => resizeObserver.observe(elm)); - - appRootCleanupRef.current = () => { - handleScreenUpdate.cancel(); - mutationObserver.disconnect(); - resizeObserver.disconnect(); - }; - }, []); - - React.useEffect( - () => () => { - appRootCleanupRef.current?.(); - appRootCleanupRef.current = undefined; - }, - [], - ); - - // Notify host after every render - React.useEffect(() => { - if (appRootRef.current) { - // Only notify screen updates if the approot is rendered - handleScreenUpdate(); - } - }); - - const viewState = React.useRef({ nodes: {} }); - - React.useEffect(() => { - if (!bridge) { - return; - } - - setCommandHandler(bridge.canvasCommands, 'getPageViewState', () => { - invariant(appRootRef.current, 'App root not found'); - let nodes = viewState.current.nodes; - - for (const [nodeId, nodeInfo] of Object.entries(nodes)) { - nodes = update(nodes, { - [nodeId]: updateNodeInfo(nodeInfo, appRootRef.current), - }); - } - - return { nodes }; - }); - - setCommandHandler(bridge.canvasCommands, 'scrollComponent', (nodeId) => { - if (!nodeId) { - return; - } - invariant(appRootRef.current, 'App root not found'); - const canvasNode = appRootRef.current.querySelector(`[data-node-id='${nodeId}']`); - canvasNode?.scrollIntoView({ behavior: 'instant', block: 'end', inline: 'end' }); - }); - - setCommandHandler(bridge.canvasCommands, 'getViewCoordinates', (clientX, clientY) => { - if (!appRootRef.current) { - return null; - } - const rect = appRootRef.current.getBoundingClientRect(); - if (rectContainsPoint(rect, clientX, clientY)) { - return { x: clientX - rect.x, y: clientY - rect.y }; - } - return null; - }); - - setCommandHandler(bridge.canvasCommands, 'invalidateQueries', () => { - queryClient.invalidateQueries(); - }); - - bridge.canvasEvents.emit('ready', {}); - setReadyBridge(bridge); - }, []); - - const savedNodes = state?.savedNodes; - const editorHooks: CanvasHooks = React.useMemo(() => { - return { - savedNodes, - registerNode: (node, props, componentConfig) => { - viewState.current.nodes[node.id] = { - nodeId: node.id, - props, - componentConfig, - }; - - return () => { - delete viewState.current.nodes[node.id]; - }; - }, - }; - }, [savedNodes]); - - const appHost = useAppHost(); - - if (appHost.isCanvas) { - return readyBridge ? ( - - - - - - ) : null; - } - - return ; -} diff --git a/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx b/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx index 46b7806d5db..fcd6acac9cc 100644 --- a/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx +++ b/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx @@ -8,6 +8,8 @@ import { useAppHost, queryClient, NodeId, + FlowDirection, + SlotType, } from '@toolpad/studio-runtime'; import createCache from '@emotion/cache'; import { CacheProvider } from '@emotion/react'; @@ -17,16 +19,82 @@ import { update } from '@toolpad/utils/immutability'; import { throttle } from 'lodash-es'; import invariant from 'invariant'; import * as appDom from '@toolpad/studio-runtime/appDom'; -import { createCommands, type ToolpadBridge } from '../../../canvas/ToolpadBridge'; import { useProject } from '../../../project'; import { RuntimeState } from '../../../runtime'; import { RenderedPage, ToolpadAppProvider } from '../../../runtime/ToolpadApp'; import { CanvasHooks, CanvasHooksContext } from '../../../runtime/CanvasHooksContext'; -import { rectContainsPoint } from '../../../utils/geometry'; -import { PageViewState } from '../../../types'; -import { updateNodeInfo } from '../../../canvas'; +import { + rectContainsPoint, + getRelativeBoundingRect, + getRelativeOuterRect, +} from '../../../utils/geometry'; +import { PageViewState, NodeInfo, SlotsState } from '../../../types'; import { useAppStateApi } from '../../AppState'; +// Interface to communicate between editor and canvas +export interface ToolpadBridge { + // Events fired in the canvas, listened in the editor + canvasEvents: Emitter; + // Commands executed from the editor, ran in the canvas + canvasCommands: { + getViewCoordinates(clientX: number, clientY: number): { x: number; y: number } | null; + getPageViewState(): PageViewState; + scrollComponent(nodeId: string): void; + isReady(): boolean; + invalidateQueries(): void; + }; +} + +export function updateNodeInfo(nodeInfo: NodeInfo, rootElm: Element): NodeInfo { + const nodeElm = rootElm.querySelector(`[data-toolpad-node-id="${nodeInfo.nodeId}"]`); + + if (!nodeElm) { + return nodeInfo; + } + + const rect = getRelativeOuterRect(rootElm, nodeElm); + + const slotElms = rootElm.querySelectorAll(`[data-toolpad-slot-parent="${nodeInfo.nodeId}"]`); + + const slots: SlotsState = {}; + + for (const slotElm of slotElms) { + const slotName = slotElm.getAttribute('data-toolpad-slot-name'); + const slotType = slotElm.getAttribute('data-toolpad-slot-type'); + + invariant(slotName, 'Slot name not found'); + invariant(slotType, 'Slot type not found'); + + if (slots[slotName]) { + continue; + } + + const slotRect = + slotType === 'single' + ? getRelativeBoundingRect(rootElm, slotElm) + : getRelativeBoundingRect(rootElm, slotElm); + + const display = window.getComputedStyle(slotElm).display; + let flowDirection: FlowDirection = 'row'; + if (slotType === 'layout') { + flowDirection = 'column'; + } else if (display === 'grid') { + const gridAutoFlow = window.getComputedStyle(slotElm).gridAutoFlow; + flowDirection = gridAutoFlow === 'row' ? 'column' : 'row'; + } else if (display === 'flex') { + flowDirection = window.getComputedStyle(slotElm).flexDirection as FlowDirection; + } + + slots[slotName] = { + type: slotType as SlotType, + rect: slotRect, + flowDirection, + }; + } + + return { ...nodeInfo, rect, slots }; +} + interface OverlayProps { children?: React.ReactNode; container?: HTMLElement; @@ -156,10 +224,8 @@ export default function EditorCanvasHost({ } const bridge: ToolpadBridge = { - editorEvents: new Emitter(), - editorCommands: createCommands(), canvasEvents: new Emitter(), - canvasCommands: createCommands({ + canvasCommands: { isReady: () => true, getPageViewState: () => { let nodes = viewState.current.nodes; @@ -182,7 +248,6 @@ export default function EditorCanvasHost({ invalidateQueries: () => { queryClient.invalidateQueries(); }, - update: () => {}, scrollComponent: (nodeId: NodeId) => { if (!appRoot) { return; @@ -190,8 +255,8 @@ export default function EditorCanvasHost({ const node = appRoot.querySelector(`[data-node-id='${nodeId}']`); node?.scrollIntoView({ behavior: 'instant', block: 'end', inline: 'end' }); }, - }), - } satisfies ToolpadBridge; + }, + }; const handleScreenUpdate = throttle( () => { diff --git a/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx b/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx index a97c838d457..adfb32ee308 100644 --- a/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx +++ b/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx @@ -43,13 +43,13 @@ import NodeHud from './NodeHud'; import { OverlayGrid, OverlayGridHandle } from './OverlayGrid'; import { NodeInfo } from '../../../../types'; import NodeDropArea from './NodeDropArea'; -import type { ToolpadBridge } from '../../../../canvas/ToolpadBridge'; import { PinholeOverlay } from '../../../../PinholeOverlay'; import { deleteOrphanedLayoutNodes, normalizePageRowColumnSizes, removePageLayoutNode, } from '../../pageLayout'; +import { ToolpadBridge } from '../EditorCanvasHost'; const VERTICAL_RESIZE_SNAP_UNITS = 4; // px diff --git a/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderPanel.tsx b/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderPanel.tsx index f1027c133f7..8025f004451 100644 --- a/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderPanel.tsx +++ b/packages/toolpad-studio/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderPanel.tsx @@ -3,11 +3,10 @@ import { styled } from '@mui/material'; import { NodeHashes, NodeId } from '@toolpad/studio-runtime'; import useEventCallback from '@mui/utils/useEventCallback'; import * as appDom from '@toolpad/studio-runtime/appDom'; -import EditorCanvasHost from '../EditorCanvasHost'; +import EditorCanvasHost, { ToolpadBridge } from '../EditorCanvasHost'; import { getNodeHashes, useAppState, useAppStateApi, useDomApi } from '../../../AppState'; import { usePageEditorApi, usePageEditorState } from '../PageEditorProvider'; import RenderOverlay from './RenderOverlay'; -import type { ToolpadBridge } from '../../../../canvas/ToolpadBridge'; import { getBindingType } from '../../../../runtime/bindings'; import createRuntimeState from '../../../../runtime/createRuntimeState'; import { RuntimeState } from '../../../../runtime';