// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import { CSSProperties, RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useDrag, useDragLayer, useDrop } from "react-dnd"; import { useLayoutTreeStateReducerAtom } from "./layoutAtom.js"; import { ContentRenderer, LayoutNode, LayoutTreeAction, LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, LayoutTreeDeleteNodeAction, LayoutTreeState, WritableLayoutTreeStateAtom, } from "./model.js"; import "./tilelayout.less"; import { setTransform as createTransform, debounce, determineDropDirection } from "./utils.js"; export interface TileLayoutProps { layoutTreeStateAtom: WritableLayoutTreeStateAtom; renderContent: ContentRenderer; onNodeDelete?: (data: T) => Promise; className?: string; } export const TileLayout = ({ layoutTreeStateAtom, className, renderContent, onNodeDelete }: TileLayoutProps) => { const overlayContainerRef = useRef(null); const displayContainerRef = useRef(null); const [layoutTreeState, dispatch] = useLayoutTreeStateReducerAtom(layoutTreeStateAtom); const [nodeRefs, setNodeRefs] = useState>>(new Map()); useEffect(() => { console.log("layoutTreeState changed", layoutTreeState); }, [layoutTreeState]); const setRef = useCallback( (id: string, ref: RefObject) => { setNodeRefs((prev) => { prev.set(id, ref); return prev; }); }, [setNodeRefs] ); const deleteRef = useCallback( (id: string) => { if (nodeRefs.has(id)) { setNodeRefs((prev) => { prev.delete(id); return prev; }); } else { console.log("deleteRef id not found", id); } }, [nodeRefs, setNodeRefs] ); const [overlayTransform, setOverlayTransform] = useState(); const [layoutLeafTransforms, setLayoutLeafTransforms] = useState>({}); const activeDrag = useDragLayer((monitor) => monitor.isDragging()); /** * Callback to update the transforms on the displayed leafs and move the overlay over the display layer when dragging. */ const updateTransforms = useCallback( debounce(() => { 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); if (leafRef?.current) { const leafBounding = leafRef.current.getBoundingClientRect(); const transform = createTransform({ top: leafBounding.top - overlayBoundingRect.top, left: leafBounding.left - overlayBoundingRect.left, width: leafBounding.width, height: leafBounding.height, }); newLayoutLeafTransforms[leafRef.current.id] = transform; } else { console.warn("missing leaf", leaf.id); } } setLayoutLeafTransforms(newLayoutLeafTransforms); const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height; console.log("overlayOffset", newOverlayOffset); setOverlayTransform( createTransform( { top: activeDrag ? 0 : newOverlayOffset, left: 0, width: overlayBoundingRect.width, height: overlayBoundingRect.height, }, false ) ); } }, 30), [activeDrag, overlayContainerRef, displayContainerRef, layoutTreeState.leafs, nodeRefs] ); // Update the transforms whenever we drag something and whenever the layout updates. useLayoutEffect(() => { updateTransforms(); }, [activeDrag, layoutTreeState]); // Update the transforms on first render and again whenever the window resizes. I had to do a slightly hacky thing // because I noticed that the window handler wasn't updating when the callback changed so I remove it each time and // reattach the new callback. const [resizeObserver, setResizeObserver] = useState(undefined); useEffect(() => { if (overlayContainerRef.current) { console.log("replace resize listener"); if (resizeObserver) resizeObserver.disconnect(); const newResizeObserver = new ResizeObserver(updateTransforms); newResizeObserver.observe(overlayContainerRef.current); setResizeObserver(newResizeObserver); return () => { newResizeObserver.disconnect(); }; } }, [updateTransforms, overlayContainerRef]); // 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. // `overlayVisible` will be disabled until after the overlay has been pushed out of view. const [animate, setAnimate] = useState(false); const [overlayVisible, setOverlayVisible] = useState(false); useEffect(() => { setTimeout(() => { setAnimate(true); }, 50); setTimeout(() => { setOverlayVisible(true); }, 30); }, []); 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 onNodeDelete?.(node.data); console.log("node deleted"); }, [onNodeDelete, dispatch] ); return (
{layoutLeafTransforms && layoutTreeState.leafs.map((leaf) => { return ( ); })}
); }; interface TileNodeProps { layoutNode: LayoutNode; renderContent: ContentRenderer; onLeafClose: (node: LayoutNode) => void; ready: boolean; transform: CSSProperties; } const dragItemType = "TILE_ITEM"; const TileNode = ({ layoutNode, renderContent, transform, onLeafClose, ready }: TileNodeProps) => { const tileNodeRef = useRef(null); const [{ isDragging, dragItem }, drag, dragPreview] = useDrag( () => ({ type: dragItemType, item: () => layoutNode, collect: (monitor) => ({ isDragging: monitor.isDragging(), dragItem: monitor.getItem>(), }), }), [layoutNode] ); useEffect(() => { if (isDragging) { console.log("drag start", layoutNode.id, layoutNode, dragItem); } }, [isDragging]); // Register the tile item as a draggable component useEffect(() => { drag(tileNodeRef); dragPreview(tileNodeRef); }, [tileNodeRef]); const onClose = useCallback(() => { onLeafClose(layoutNode); }, [layoutNode, onLeafClose]); const leafContent = useMemo(() => { return ( layoutNode.data && (
{renderContent(layoutNode.data, ready, onClose)}
) ); }, [, layoutNode.data, ready, onClose]); return (
{leafContent}
); }; interface OverlayNodeProps { layoutNode: LayoutNode; layoutTreeState: LayoutTreeState; dispatch: (action: LayoutTreeAction) => void; setRef: (id: string, ref: RefObject) => void; deleteRef: (id: string) => void; } const OverlayNode = ({ layoutNode, layoutTreeState, dispatch, setRef, deleteRef }: OverlayNodeProps) => { const overlayRef = useRef(null); const leafRef = useRef(null); 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: (_, monitor) => { if (monitor.isOver({ shallow: true }) && 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); } }, }), [overlayRef.current, layoutNode, layoutTreeState, dispatch] ); // Register the tile item as a draggable component useEffect(() => { const layoutNodeId = layoutNode?.id; if (overlayRef?.current) { drop(overlayRef); setRef(layoutNodeId, overlayRef); } return () => { deleteRef(layoutNodeId); }; }, [overlayRef]); const generateChildren = () => { if (Array.isArray(layoutNode.children)) { return layoutNode.children.map((childItem) => { return ( ); }); } else { return [
]; } }; if (!layoutNode) { return null; } return (
{generateChildren()}
); };