// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import useResizeObserver from "@react-hook/resize-observer"; import clsx from "clsx"; import { toPng } from "html-to-image"; import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import React, { CSSProperties, ReactNode, Suspense, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { DropTargetMonitor, useDrag, useDragLayer, useDrop } from "react-dnd"; import { debounce, throttle } from "throttle-debounce"; import { useDevicePixelRatio } from "use-device-pixel-ratio"; import { globalLayoutTransformsMap } from "./layoutAtom"; import { findNode, totalChildrenSize } from "./layoutNode"; import { layoutTreeStateReducer } from "./layoutState"; import { ContentRenderer, LayoutNode, LayoutTreeAction, LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, LayoutTreeDeleteNodeAction, LayoutTreeMoveNodeAction, LayoutTreeResizeNodeAction, LayoutTreeSetPendingAction, LayoutTreeState, LayoutTreeSwapNodeAction, PreviewRenderer, WritableLayoutTreeStateAtom, } from "./model"; import { NodeRefMap } from "./nodeRefMap"; import "./tilelayout.less"; import { Dimensions, FlexDirection, determineDropDirection, setTransform } from "./utils"; /** * contains callbacks and information about the contents (or styling) of of the TileLayout * nothing in here is specific to the TileLayout itself */ export interface TileLayoutContents { /** * A callback that accepts the data from the leaf node and displays the leaf contents to the user. */ renderContent: ContentRenderer; /** * A callback that accepts the data from the leaf node and returns a preview that can be shown when the user drags a node. */ renderPreview?: PreviewRenderer; /** * A callback that is called when a node gets deleted from the LayoutTreeState. * @param data The contents of the node that was deleted. */ onNodeDelete?: (data: T) => Promise; /** * The class name to use for the top-level div of the tile layout. */ className?: string; /** * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook. * @returns The cursor position relative to the current window. */ getCursorPoint?: () => Point; /** * tabId this TileLayout is associated with */ tabId?: string; } export interface TileLayoutProps { /** * The atom containing the layout tree state. */ layoutTreeStateAtom: WritableLayoutTreeStateAtom; /** * callbacks and information about the contents (or styling) of the TileLayout or contents */ contents: TileLayoutContents; /** * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook. * @returns The cursor position relative to the current window. */ getCursorPoint?: () => Point; } const DragPreviewWidth = 300; const DragPreviewHeight = 300; function TileLayoutComponent({ layoutTreeStateAtom, contents, getCursorPoint }: TileLayoutProps) { const overlayContainerRef = useRef(null); const displayContainerRef = useRef(null); const jotaiStore = useStore(); const layoutTreeState = useAtomValue(layoutTreeStateAtom); const [nodeRefsAtom] = useState>(atom(new NodeRefMap())); const nodeRefs = useAtomValue(nodeRefsAtom); const dispatch = useCallback( (action: LayoutTreeAction) => { const currentState = jotaiStore.get(layoutTreeStateAtom); jotaiStore.set(layoutTreeStateAtom, layoutTreeStateReducer(currentState, action)); }, [layoutTreeStateAtom, jotaiStore] ); const [showOverlayAtom] = useState>(atom(false)); const [showOverlay, setShowOverlay] = useAtom(showOverlayAtom); function onPointerOver() { setShowOverlay(true); } const [overlayTransform, setOverlayTransform] = useState(); const [layoutLeafTransforms, setLayoutLeafTransformsRaw] = useState>({}); const setLayoutLeafTransforms = (transforms: Record) => { globalLayoutTransformsMap.set(contents.tabId, transforms); setLayoutLeafTransformsRaw(transforms); }; const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({ activeDrag: monitor.isDragging(), dragClientOffset: monitor.getClientOffset(), })); // Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture // because that conflicts with the DnD layer. useEffect( debounce(100, () => { const cursorPoint = getCursorPoint?.() ?? dragClientOffset; if (cursorPoint && displayContainerRef.current) { const displayContainerRect = displayContainerRef.current.getBoundingClientRect(); const normalizedX = cursorPoint.x - displayContainerRect.x; const normalizedY = cursorPoint.y - displayContainerRect.y; if ( normalizedX <= 0 || normalizedX >= displayContainerRect.width || normalizedY <= 0 || normalizedY >= displayContainerRect.height ) { dispatch({ type: LayoutTreeActionType.ClearPendingAction }); } } }), [dragClientOffset] ); /** * Callback to update the transforms on the displayed leafs and move the overlay over the display layer when dragging. */ const updateTransforms = useCallback( debounce(30, () => { // TODO: janky way of preventing updates while a node resize is underway if (layoutTreeState.pendingAction?.type === LayoutTreeActionType.ResizeNode) return; if (overlayContainerRef.current && displayContainerRef.current) { const displayBoundingRect = displayContainerRef.current.getBoundingClientRect(); // console.log("displayBoundingRect", displayBoundingRect); const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); const newLayoutLeafTransforms: Record = {}; // console.log( // "nodeRefs", // nodeRefs, // "layoutLeafs", // layoutTreeState.leafs, // "layoutTreeState", // layoutTreeState // ); for (const leaf of layoutTreeState.leafs) { const leafRef = nodeRefs.get(leaf.id); // console.log("current leafRef", leafRef.current); if (leafRef?.current) { if (leaf.id === layoutTreeState.magnifiedNodeId) { const transform = setTransform( { top: displayBoundingRect.height * 0.05, left: displayBoundingRect.width * 0.05, width: displayBoundingRect.width * 0.9, height: displayBoundingRect.height * 0.9, }, true ); newLayoutLeafTransforms[leaf.id] = transform; continue; } const leafBounding = leafRef.current.getBoundingClientRect(); const transform = setTransform({ top: leafBounding.top - overlayBoundingRect.top, left: leafBounding.left - overlayBoundingRect.left, width: leafBounding.width, height: leafBounding.height, }); newLayoutLeafTransforms[leaf.id] = transform; } else { console.warn("missing leaf", leaf.id); } } setLayoutLeafTransforms(newLayoutLeafTransforms); const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height; // console.log("overlayOffset", newOverlayOffset); setOverlayTransform( setTransform( { top: activeDrag || showOverlay ? 0 : newOverlayOffset, left: 0, width: overlayBoundingRect.width, height: overlayBoundingRect.height, }, false ) ); } }), [activeDrag, showOverlay, overlayContainerRef, displayContainerRef, layoutTreeState.leafs, nodeRefs.generation] ); // Update the transforms whenever we drag something and whenever the layout updates. useLayoutEffect(() => { updateTransforms(); }, [updateTransforms]); useResizeObserver(overlayContainerRef, () => updateTransforms()); // Ensure that we don't see any jostling in the layout when we're rendering it the first time. // `animate` will be disabled until after the transforms have all applied the first time. const [animate, setAnimate] = useState(false); useEffect(() => { setTimeout(() => { setAnimate(true); }, 50); }, []); const onLeafClose = useCallback( async (node: LayoutNode) => { // console.log("onLeafClose", node); const deleteAction: LayoutTreeDeleteNodeAction = { type: LayoutTreeActionType.DeleteNode, nodeId: node.id, }; // console.log("calling dispatch", deleteAction); dispatch(deleteAction); // console.log("calling onNodeDelete", node); await contents.onNodeDelete?.(node.data); // console.log("node deleted"); }, [contents.onNodeDelete, dispatch] ); const onLeafMagnifyToggle = useCallback( (node: LayoutNode) => { const action = { type: LayoutTreeActionType.MagnifyNodeToggle, nodeId: node.id, }; dispatch(action); }, [dispatch] ); return (
{!layoutTreeState?.rootNode ? null : ( )}
); } export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; interface DisplayNodesWrapperProps { /** * The layout tree state. */ layoutTreeState: LayoutTreeState; /** * contains callbacks and information about the contents (or styling) of of the TileLayout */ contents: TileLayoutContents; /** * A callback that is called when a leaf node gets closed. * @param node The node that is closed. */ onLeafClose: (node: LayoutNode) => void; /** * A callback that is called when a leaf's magnification is being toggled. * @param node The node that is being magnified or un-magnified. */ onLeafMagnifyToggle: (node: LayoutNode) => void; /** * A series of CSS properties used to display a leaf node with the correct dimensions and position, as determined from its corresponding OverlayNode. */ layoutLeafTransforms: Record; /** * Determines whether the leaf nodes are ready to be displayed to the user. */ ready: boolean; } const DisplayNodesWrapper = memo( ({ layoutTreeState, contents, onLeafClose, onLeafMagnifyToggle, layoutLeafTransforms, ready, }: DisplayNodesWrapperProps) => { if (!layoutLeafTransforms) { return null; } return layoutTreeState.leafs.map((leaf) => { return ( ); }); } ); interface DisplayNodeProps { /** * The leaf node object, containing the data needed to display the leaf contents to the user. */ layoutNode: LayoutNode; /** * contains callbacks and information about the contents (or styling) of of the TileLayout */ contents: TileLayoutContents; /** * A callback that is called when a leaf's magnification is being toggled. * @param node The node that is being magnified or unmagnified. */ onLeafMagnifyToggle: (node: LayoutNode) => void; /** * A callback that is called when a leaf node gets closed. * @param node The node that is closed. */ onLeafClose: (node: LayoutNode) => void; /** * Determines whether a leaf's contents should be displayed to the user. */ ready: boolean; /** * Any class names to add to the component. */ className?: string; /** * A series of CSS properties used to display a leaf node with the correct dimensions and position, as determined from its corresponding OverlayNode. */ transform: CSSProperties; } const dragItemType = "TILE_ITEM"; /** * The draggable and displayable portion of a leaf node in a layout tree. */ const DisplayNode = memo( ({ layoutNode, contents, transform, onLeafMagnifyToggle, onLeafClose, ready, className, }: DisplayNodeProps) => { const tileNodeRef = useRef(null); const dragHandleRef = useRef(null); const previewRef = useRef(null); const devicePixelRatio = useDevicePixelRatio(); const [{ isDragging }, drag, dragPreview] = useDrag( () => ({ type: dragItemType, item: () => layoutNode, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }), [layoutNode] ); const [previewElementGeneration, setPreviewElementGeneration] = useState(0); const previewElement = useMemo(() => { setPreviewElementGeneration(previewElementGeneration + 1); return (
{contents.renderPreview?.(layoutNode.data)}
); }, [contents.renderPreview, devicePixelRatio]); const [previewImage, setPreviewImage] = useState(null); const [previewImageGeneration, setPreviewImageGeneration] = useState(0); const generatePreviewImage = useCallback(() => { const offsetX = (DragPreviewWidth * devicePixelRatio - DragPreviewWidth) / 2 + 10; const offsetY = (DragPreviewHeight * devicePixelRatio - DragPreviewHeight) / 2 + 10; if (previewImage !== null && previewElementGeneration === previewImageGeneration) { dragPreview(previewImage, { offsetY, offsetX }); } else if (previewRef.current) { setPreviewImageGeneration(previewElementGeneration); toPng(previewRef.current).then((url) => { const img = new Image(); img.src = url; setPreviewImage(img); dragPreview(img, { offsetY, offsetX }); }); } }, [ dragPreview, previewRef.current, previewElementGeneration, previewImageGeneration, previewImage, devicePixelRatio, ]); // Register the tile item as a draggable component useEffect(() => { drag(dragHandleRef); }, [drag, dragHandleRef.current]); const onClose = useCallback(() => { onLeafClose(layoutNode); }, [layoutNode, onLeafClose]); const onMagnifyToggle = useCallback(() => { onLeafMagnifyToggle(layoutNode); }, [layoutNode, onLeafMagnifyToggle]); const leafContent = useMemo(() => { return ( layoutNode.data && (
{contents.renderContent(layoutNode.data, ready, onMagnifyToggle, onClose, dragHandleRef)}
) ); }, [layoutNode.data, ready, onClose]); return (
event.stopPropagation()} > {leafContent} {previewElement}
); } ); interface OverlayNodeProps { /** * The layout node object corresponding to the OverlayNode. */ layoutNode: LayoutNode; /** * The layout tree state. */ layoutTreeState: LayoutTreeState; /** * The reducer function for mutating the layout tree state. * @param action The action to perform. */ dispatch: (action: LayoutTreeAction) => void; nodeRefsAtom: PrimitiveAtom; showOverlayAtom: PrimitiveAtom; showResizeOverlay?: boolean; siblingSize: number; } /** * An overlay representing the true flexbox layout of the LayoutTreeState. This holds the drop targets for moving around nodes and is used to calculate the * dimensions of the corresponding DisplayNode for each LayoutTreeState leaf. */ const OverlayNode = ({ layoutNode, layoutTreeState, dispatch, nodeRefsAtom, showOverlayAtom, showResizeOverlay, siblingSize, }: OverlayNodeProps) => { const overlayRef = useRef(null); const leafRef = useRef(null); const setShowOverlay = useSetAtom(showOverlayAtom); const setNodeRefs = useSetAtom(nodeRefsAtom); const [, drop] = useDrop( () => ({ accept: dragItemType, canDrop: (_, monitor) => { const dragItem = monitor.getItem>(); if (monitor.isOver({ shallow: true }) && dragItem?.id !== layoutNode.id) { return true; } return false; }, drop: (_, monitor) => { // console.log("drop start", layoutNode.id, layoutTreeState.pendingAction); if (!monitor.didDrop() && layoutTreeState.pendingAction) { dispatch({ type: LayoutTreeActionType.CommitPendingAction, }); } }, hover: throttle(30, (_, monitor: DropTargetMonitor) => { if (monitor.isOver({ shallow: true })) { if (monitor.canDrop()) { const dragItem = monitor.getItem>(); // console.log("computing operation", layoutNode, dragItem, layoutTreeState.pendingAction); dispatch({ type: LayoutTreeActionType.ComputeMove, node: layoutNode, nodeToMove: dragItem, direction: determineDropDirection( overlayRef.current?.getBoundingClientRect(), monitor.getClientOffset() ), } as LayoutTreeComputeMoveNodeAction); } else { dispatch({ type: LayoutTreeActionType.ClearPendingAction, }); } } }), }), [overlayRef.current, layoutNode, layoutTreeState, dispatch] ); // Register the tile item as a draggable component useEffect(() => { const layoutNodeId = layoutNode?.id; if (overlayRef?.current) { drop(overlayRef); setNodeRefs((nodeRefs) => { nodeRefs.set(layoutNodeId, overlayRef); return nodeRefs; }); } return () => { setNodeRefs((nodeRefs) => { nodeRefs.delete(layoutNodeId); return nodeRefs; }); }; }, [overlayRef, layoutNode?.id]); function onPointerOverLeaf(event: React.PointerEvent) { event.stopPropagation(); setShowOverlay(false); } const [resizeOnCurrentNode, setResizeOnCurrentNode] = useState(false); const [pendingSize, setPendingSize] = useState(undefined); useLayoutEffect(() => { if (showResizeOverlay) { setResizeOnCurrentNode(false); setPendingSize(undefined); return; } if (layoutTreeState.pendingAction?.type === LayoutTreeActionType.ResizeNode) { const resizeAction = layoutTreeState.pendingAction as LayoutTreeResizeNodeAction; const resizeOperation = resizeAction?.resizeOperations?.find( (operation) => operation.nodeId === layoutNode.id ); if (resizeOperation) { setResizeOnCurrentNode(true); setPendingSize(resizeOperation.size); return; } } setResizeOnCurrentNode(false); setPendingSize(undefined); }, [showResizeOverlay, layoutTreeState.pendingAction]); const generateChildren = () => { if (Array.isArray(layoutNode.children)) { const totalSize = totalChildrenSize(layoutNode); return layoutNode.children .map((childItem, i) => { return [ , , ]; }) .flat() .slice(0, -1); } else { return [
]; } }; if (!layoutNode) { return null; } const sizePercentage = ((pendingSize ?? layoutNode.size) / siblingSize) * 100; return (
{generateChildren()}
); }; interface ResizeHandleProps { parentNode: LayoutNode; index: number; dispatch: (action: LayoutTreeAction) => void; nodeRefsAtom: PrimitiveAtom; } const ResizeHandle = ({ parentNode, index, dispatch, nodeRefsAtom }: ResizeHandleProps) => { const resizeHandleRef = useRef(null); // The pointer currently captured, or undefined. const [trackingPointer, setTrackingPointer] = useState(undefined); const nodeRefs = useAtomValue(nodeRefsAtom); // Cached values set in startResize const [combinedNodesRect, setCombinedNodesRect] = useState(); const [gapSize, setGapSize] = useState(0); const [pixelToSizeRatio, setPixelToSizeRatio] = useState(0); // Precompute some values that will be needed by the handlePointerMove function const startResize = () => { const parentRef = nodeRefs.get(parentNode.id); const node1Ref = nodeRefs.get(parentNode.children![index].id); const node2Ref = nodeRefs.get(parentNode.children![index + 1].id); if (parentRef?.current && node1Ref?.current && node2Ref?.current) { const parentIsRow = parentNode.flexDirection === FlexDirection.Row; const parentRectNew = parentRef.current.getBoundingClientRect(); const node1Rect = node1Ref.current.getBoundingClientRect(); const node2Rect = node2Ref.current.getBoundingClientRect(); const totalSiblingSize = totalChildrenSize(parentNode); const gapSize = parentIsRow ? node2Rect.left - (node1Rect.left + node1Rect.width) : node2Rect.top - (node1Rect.top + node1Rect.height); setGapSize(gapSize); const parentPixelsMinusGap = (parentIsRow ? parentRectNew.width : parentRectNew.height) - (gapSize * parentNode.children!.length - 1); const newPixelToSizeRatio = totalSiblingSize / parentPixelsMinusGap; // console.log("newPixelToSizeRatio", newPixelToSizeRatio, siblingSize, parentPixelsMinusGap); setPixelToSizeRatio(newPixelToSizeRatio); const newCombinedNodesRect: Dimensions = { top: node1Rect.top, left: node1Rect.left, height: parentIsRow ? node1Rect.height : node1Rect.height + node2Rect.height + gapSize, width: parentIsRow ? node1Rect.width + node2Rect.width + gapSize : node1Rect.width, }; setCombinedNodesRect(newCombinedNodesRect); // console.log( // "startResize", // parentNode, // index, // parentIsRow, // gapSize, // parentRectNew, // node1Rect, // node2Rect, // newCombinedNodesRect, // newPixelToSizeRatio // ); } }; // Calculates the new size of the two nodes on either side of the handle, based on the position of the cursor const handlePointerMove = useCallback( throttle(10, (event: React.PointerEvent) => { if (trackingPointer === event.pointerId) { const { clientX, clientY } = event; // console.log("handlePointerMove", [clientX, clientY], combinedNodesRect, pixelToSizeRatio); const parentIsRow = parentNode.flexDirection === FlexDirection.Row; const combinedStart = parentIsRow ? combinedNodesRect.left : combinedNodesRect.top; const combinedEnd = parentIsRow ? combinedNodesRect.left + combinedNodesRect.width : combinedNodesRect.top + combinedNodesRect.height; const clientPoint = parentIsRow ? clientX : clientY; // console.log("handlePointerMove", parentNode, index, clientX, clientY, parentRect, combinedNodesRect); if (clientPoint > combinedStart + 10 && clientPoint < combinedEnd - 10) { const halfGap = gapSize / 2; const sizeNode1 = clientPoint - combinedStart - halfGap; const sizeNode2 = combinedEnd - clientPoint + halfGap; const resizeAction: LayoutTreeResizeNodeAction = { type: LayoutTreeActionType.ResizeNode, resizeOperations: [ { nodeId: parentNode.children![index].id, size: parseFloat((sizeNode1 * pixelToSizeRatio).toPrecision(5)), }, { nodeId: parentNode.children![index + 1].id, size: parseFloat((sizeNode2 * pixelToSizeRatio).toPrecision(5)), }, ], }; const setPendingAction: LayoutTreeSetPendingAction = { type: LayoutTreeActionType.SetPendingAction, action: resizeAction, }; dispatch(setPendingAction); } } }), [parentNode, trackingPointer, dispatch, gapSize, combinedNodesRect, pixelToSizeRatio, index] ); // We want to use pointer capture so the operation continues even if the pointer leaves the bounds of the handle function onPointerDown(event: React.PointerEvent) { resizeHandleRef.current?.setPointerCapture(event.pointerId); startResize(); } // This indicates that we're ready to start tracking the resize operation via the pointer function onPointerCapture(event: React.PointerEvent) { setTrackingPointer(event.pointerId); } // We want to wait a bit before committing the pending resize operation in case some events haven't arrived yet. const onPointerRelease = useCallback( debounce(30, (event: React.PointerEvent) => { setTrackingPointer(undefined); dispatch({ type: LayoutTreeActionType.CommitPendingAction }); }), [dispatch] ); // Don't render if we are dealing with the last child of a parent if (index + 1 >= parentNode.children!.length) { return; } return (
); }; interface PlaceholderProps { /** * The layout tree state. */ layoutTreeState: LayoutTreeState; /** * A reference to the div containing the overlay nodes. Used to normalize the position of the target node as the overlay container is moved in and out of view. */ overlayContainerRef: React.RefObject; /** * The mapping of all layout nodes to their corresponding mounted overlay node. */ nodeRefsAtom: PrimitiveAtom; /** * Any styling to apply to the placeholder container div. */ style: React.CSSProperties; } /** * An overlay to preview pending actions on the layout tree. */ const Placeholder = ({ layoutTreeState, overlayContainerRef, nodeRefsAtom, style }: PlaceholderProps) => { const [placeholderOverlay, setPlaceholderOverlay] = useState(null); const nodeRefs = useAtomValue(nodeRefsAtom); useEffect(() => { let newPlaceholderOverlay: ReactNode; if (overlayContainerRef?.current) { switch (layoutTreeState?.pendingAction?.type) { case LayoutTreeActionType.Move: { const action = layoutTreeState.pendingAction as LayoutTreeMoveNodeAction; let parentId: string; if (action.insertAtRoot) { parentId = layoutTreeState.rootNode.id; } else { parentId = action.parentId; } const parentNode = findNode(layoutTreeState.rootNode, parentId); if (action.index !== undefined && parentNode) { const targetIndex = Math.min( parentNode.children ? parentNode.children.length - 1 : 0, Math.max(0, action.index - 1) ); let targetNode = parentNode?.children?.at(targetIndex); let targetRef: React.RefObject; if (targetNode) { targetRef = nodeRefs.get(targetNode.id); } else { targetRef = nodeRefs.get(parentNode.id); targetNode = parentNode; } if (targetRef?.current) { const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); const targetBoundingRect = targetRef.current.getBoundingClientRect(); // Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent. // Default to placing the placeholder in the first half of the target node. const placeholderDimensions: Dimensions = { height: parentNode.flexDirection === FlexDirection.Column ? targetBoundingRect.height / 2 : targetBoundingRect.height, width: parentNode.flexDirection === FlexDirection.Row ? targetBoundingRect.width / 2 : targetBoundingRect.width, top: targetBoundingRect.top - overlayBoundingRect.top, left: targetBoundingRect.left - overlayBoundingRect.left, }; if (action.index > targetIndex) { if (action.index >= (parentNode.children?.length ?? 1)) { // If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom). placeholderDimensions.top += parentNode.flexDirection === FlexDirection.Column && targetBoundingRect.height / 2; placeholderDimensions.left += parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2; } else { // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node placeholderDimensions.top += parentNode.flexDirection === FlexDirection.Column && (3 * targetBoundingRect.height) / 4; placeholderDimensions.left += parentNode.flexDirection === FlexDirection.Row && (3 * targetBoundingRect.width) / 4; } } const placeholderTransform = setTransform(placeholderDimensions); newPlaceholderOverlay =
; } } break; } case LayoutTreeActionType.Swap: { const action = layoutTreeState.pendingAction as LayoutTreeSwapNodeAction; // console.log("placeholder for swap", action); const targetNodeId = action.node1Id; const targetRef = nodeRefs.get(targetNodeId); if (targetRef?.current) { const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); const targetBoundingRect = targetRef.current.getBoundingClientRect(); const placeholderDimensions: Dimensions = { top: targetBoundingRect.top - overlayBoundingRect.top, left: targetBoundingRect.left - overlayBoundingRect.left, height: targetBoundingRect.height, width: targetBoundingRect.width, }; const placeholderTransform = setTransform(placeholderDimensions); newPlaceholderOverlay =
; } break; } default: // No-op break; } } setPlaceholderOverlay(newPlaceholderOverlay); }, [layoutTreeState.pendingAction, nodeRefs, overlayContainerRef]); return (
{placeholderOverlay}
); };