2024-06-04 13:05:44 -07:00
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import clsx from "clsx" ;
2024-06-18 23:44:53 -07:00
import { toPng } from "html-to-image" ;
2024-08-14 18:40:41 -07:00
import { Atom , useAtomValue , useSetAtom } from "jotai" ;
2024-06-11 17:42:10 -07:00
import React , {
2024-06-06 17:58:37 -07:00
CSSProperties ,
ReactNode ,
2024-06-11 13:03:41 -07:00
Suspense ,
2024-07-03 14:31:02 -07:00
memo ,
2024-06-06 17:58:37 -07:00
useCallback ,
useEffect ,
useMemo ,
useRef ,
useState ,
} from "react" ;
2024-07-31 12:49:38 -07:00
import { DropTargetMonitor , XYCoord , useDrag , useDragLayer , useDrop } from "react-dnd" ;
2024-06-19 11:15:14 -07:00
import { debounce , throttle } from "throttle-debounce" ;
2024-06-21 10:18:35 -07:00
import { useDevicePixelRatio } from "use-device-pixel-ratio" ;
2024-08-14 19:43:25 -07:00
import { LayoutModel } from "./layoutModel" ;
2024-08-26 11:56:00 -07:00
import { useNodeModel , useTileLayout } from "./layoutModelHooks" ;
2024-06-04 13:05:44 -07:00
import "./tilelayout.less" ;
2024-08-14 19:43:25 -07:00
import {
LayoutNode ,
LayoutTreeActionType ,
LayoutTreeComputeMoveNodeAction ,
ResizeHandleProps ,
TileLayoutContents ,
} from "./types" ;
2024-08-14 18:40:41 -07:00
import { determineDropDirection } from "./utils" ;
2024-06-04 13:05:44 -07:00
2024-08-14 18:40:41 -07:00
export interface TileLayoutProps {
2024-06-26 09:31:43 -07:00
/**
* The atom containing the layout tree state.
*/
2024-08-14 18:40:41 -07:00
tabAtom : Atom < Tab >;
2024-06-26 09:31:43 -07:00
/**
* callbacks and information about the contents (or styling) of the TileLayout or contents
*/
2024-08-14 18:40:41 -07:00
contents : TileLayoutContents ;
2024-06-21 12:32:38 -07:00
2024-06-21 10:18:35 -07:00
/**
* 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 ;
2024-06-04 13:05:44 -07:00
}
2024-06-18 23:44:53 -07:00
const DragPreviewWidth = 300 ;
const DragPreviewHeight = 300 ;
2024-08-14 18:40:41 -07:00
function TileLayoutComponent ({ tabAtom , contents , getCursorPoint } : TileLayoutProps ) {
const layoutModel = useTileLayout ( tabAtom , contents );
const overlayTransform = useAtomValue ( layoutModel . overlayTransform );
const setActiveDrag = useSetAtom ( layoutModel . activeDrag );
const setReady = useSetAtom ( layoutModel . ready );
const isResizing = useAtomValue ( layoutModel . isResizing );
2024-06-04 13:05:44 -07:00
2024-06-19 11:15:14 -07:00
const { activeDrag , dragClientOffset } = useDragLayer (( monitor ) => ({
activeDrag : monitor.isDragging (),
dragClientOffset : monitor.getClientOffset (),
}));
2024-08-14 18:40:41 -07:00
useEffect (() => {
setActiveDrag ( activeDrag );
}, [ setActiveDrag , activeDrag ]);
2024-07-31 12:49:38 -07:00
const checkForCursorBounds = useCallback (
debounce ( 100 , ( dragClientOffset : XYCoord ) => {
const cursorPoint = dragClientOffset ?? getCursorPoint ? .();
2024-08-14 18:40:41 -07:00
if ( cursorPoint && layoutModel . displayContainerRef ? . current ) {
const displayContainerRect = layoutModel . displayContainerRef . current . getBoundingClientRect ();
2024-06-19 11:15:14 -07:00
const normalizedX = cursorPoint . x - displayContainerRect . x ;
const normalizedY = cursorPoint . y - displayContainerRect . y ;
if (
normalizedX <= 0 ||
normalizedX >= displayContainerRect . width ||
normalizedY <= 0 ||
normalizedY >= displayContainerRect . height
) {
2024-08-14 18:40:41 -07:00
layoutModel . treeReducer ({ type : LayoutTreeActionType . ClearPendingAction });
2024-06-19 11:15:14 -07:00
}
}
}),
2024-08-26 11:56:00 -07:00
[ getCursorPoint ]
2024-06-19 11:15:14 -07:00
);
2024-06-04 13:05:44 -07:00
2024-07-31 12:49:38 -07:00
// 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 (() => checkForCursorBounds ( dragClientOffset ), [ dragClientOffset ]);
2024-06-04 13:05:44 -07:00
// 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 );
2024-08-14 18:40:41 -07:00
setReady ( true );
2024-06-04 13:05:44 -07:00
}, 50 );
}, []);
2024-09-04 22:07:47 -07:00
const gapSizePx = useAtomValue ( layoutModel . gapSizePx );
const animationTimeS = useAtomValue ( layoutModel . animationTimeS );
2024-08-14 18:40:41 -07:00
const tileStyle = useMemo (
2024-08-27 13:41:36 -07:00
() =>
({
2024-09-04 22:07:47 -07:00
"--gap-size-px" : ` ${ gapSizePx } px` ,
"--animation-time-s" : ` ${ animationTimeS } s` ,
2024-08-27 13:41:36 -07:00
}) as CSSProperties ,
2024-09-04 22:07:47 -07:00
[ gapSizePx , animationTimeS ]
2024-07-30 10:59:53 -07:00
);
2024-06-04 13:05:44 -07:00
return (
2024-06-11 13:03:41 -07:00
< Suspense >
2024-08-14 18:40:41 -07:00
< div
className = { clsx ( "tile-layout" , contents . className , { animate : animate && ! isResizing })}
style = { tileStyle }
>
< div key = "display" ref = { layoutModel . displayContainerRef } className = "display-container" >
< ResizeHandleWrapper layoutModel = { layoutModel } />
2024-08-26 11:56:00 -07:00
< DisplayNodesWrapper layoutModel = { layoutModel } />
2024-06-11 13:03:41 -07:00
</ div >
2024-08-14 18:40:41 -07:00
< Placeholder key = "placeholder" layoutModel = { layoutModel } style = {{ top : 10000 , ... overlayTransform }} />
< OverlayNodeWrapper layoutModel = { layoutModel } />
2024-06-04 13:05:44 -07:00
</ div >
2024-06-11 13:03:41 -07:00
</ Suspense >
2024-06-04 13:05:44 -07:00
);
2024-06-26 12:22:27 -07:00
}
2024-07-03 14:31:02 -07:00
export const TileLayout = memo ( TileLayoutComponent ) as typeof TileLayoutComponent ;
2024-06-26 09:31:43 -07:00
2024-08-14 18:40:41 -07:00
interface DisplayNodesWrapperProps {
2024-06-26 09:31:43 -07:00
/**
* The layout tree state.
*/
2024-08-14 18:40:41 -07:00
layoutModel : LayoutModel ;
2024-06-26 09:31:43 -07:00
}
2024-08-26 11:56:00 -07:00
const DisplayNodesWrapper = ({ layoutModel } : DisplayNodesWrapperProps ) => {
2024-08-21 17:43:11 -07:00
const leafs = useAtomValue ( layoutModel . leafs );
2024-06-04 13:05:44 -07:00
2024-08-14 18:40:41 -07:00
return useMemo (
() =>
2024-08-26 11:56:00 -07:00
leafs . map (( node ) => {
return < DisplayNode key = { node . id } layoutModel = { layoutModel } node = { node } />;
2024-08-14 18:40:41 -07:00
}),
2024-08-21 17:43:11 -07:00
[ leafs ]
2024-08-14 18:40:41 -07:00
);
};
interface DisplayNodeProps {
layoutModel : LayoutModel ;
2024-06-13 19:33:06 -07:00
/**
* The leaf node object, containing the data needed to display the leaf contents to the user.
*/
2024-08-26 11:56:00 -07:00
node : LayoutNode ;
2024-06-04 13:05:44 -07:00
}
const dragItemType = "TILE_ITEM" ;
2024-06-13 19:33:06 -07:00
/**
2024-06-13 19:36:06 -07:00
* The draggable and displayable portion of a leaf node in a layout tree.
2024-06-13 19:33:06 -07:00
*/
2024-08-26 11:56:00 -07:00
const DisplayNode = ({ layoutModel , node } : DisplayNodeProps ) => {
const nodeModel = useNodeModel ( layoutModel , node );
2024-08-14 18:40:41 -07:00
const tileNodeRef = useRef < HTMLDivElement >( null );
const previewRef = useRef < HTMLDivElement >( null );
2024-08-26 11:56:00 -07:00
const addlProps = useAtomValue ( nodeModel . additionalProps );
2024-08-14 18:40:41 -07:00
const devicePixelRatio = useDevicePixelRatio ();
2024-06-21 10:18:35 -07:00
2024-08-14 18:40:41 -07:00
const [{ isDragging }, drag , dragPreview ] = useDrag (
() => ({
type : dragItemType ,
2024-08-26 11:56:00 -07:00
item : () => node ,
2024-08-20 20:14:14 -07:00
canDrag : () => ! addlProps ? . isMagnifiedNode ,
2024-08-14 18:40:41 -07:00
collect : ( monitor ) => ({
isDragging : monitor.isDragging (),
2024-06-04 13:05:44 -07:00
}),
2024-08-14 18:40:41 -07:00
}),
2024-08-26 11:56:00 -07:00
[ node , addlProps ]
2024-08-14 18:40:41 -07:00
);
2024-07-30 10:59:53 -07:00
2024-08-14 18:40:41 -07:00
const [ previewElementGeneration , setPreviewElementGeneration ] = useState ( 0 );
const previewElement = useMemo (() => {
setPreviewElementGeneration ( previewElementGeneration + 1 );
2024-07-30 10:59:53 -07:00
return (
2024-08-14 18:40:41 -07:00
< div key = "preview" className = "tile-preview-container" >
< div
className = "tile-preview"
ref = { previewRef }
style = {{
width : DragPreviewWidth ,
height : DragPreviewHeight ,
transform : `scale( ${ 1 / devicePixelRatio } )` ,
}}
>
2024-08-26 11:56:00 -07:00
{ layoutModel . renderPreview ? .( nodeModel )}
2024-08-14 18:40:41 -07:00
</ div >
2024-06-21 10:18:35 -07:00
</ div >
);
2024-08-26 11:56:00 -07:00
}, [ devicePixelRatio , nodeModel ]);
2024-06-04 13:05:44 -07:00
2024-08-14 18:40:41 -07:00
const [ previewImage , setPreviewImage ] = useState < HTMLImageElement >( 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 ,
]);
const leafContent = useMemo (() => {
return (
2024-08-26 11:56:00 -07:00
< div key = "leaf" className = "tile-leaf" >
{ layoutModel . renderContent ( nodeModel )}
</ div >
2024-08-14 18:40:41 -07:00
);
2024-08-26 11:56:00 -07:00
}, [ nodeModel ]);
// Register the display node as a draggable item
useEffect (() => {
drag ( nodeModel . dragHandleRef );
}, [ drag , nodeModel . dragHandleRef . current ]);
2024-08-14 18:40:41 -07:00
return (
< div
2024-08-15 12:24:06 -07:00
className = { clsx ( "tile-node" , {
dragging : isDragging ,
2024-08-20 20:14:14 -07:00
magnified : addlProps?.isMagnifiedNode ,
2024-08-15 13:45:45 -07:00
"last-magnified" : addlProps ? . isLastMagnifiedNode ,
2024-08-15 12:24:06 -07:00
})}
2024-08-14 18:40:41 -07:00
ref = { tileNodeRef }
2024-08-26 11:56:00 -07:00
id = { node . id }
2024-08-14 18:40:41 -07:00
style = { addlProps ? . transform }
onPointerEnter = { generatePreviewImage }
onPointerOver = {( event ) => event . stopPropagation ()}
>
{ leafContent }
{ previewElement }
</ div >
);
};
interface OverlayNodeWrapperProps {
layoutModel : LayoutModel ;
}
2024-08-26 11:56:00 -07:00
const OverlayNodeWrapper = memo (({ layoutModel } : OverlayNodeWrapperProps ) => {
2024-08-21 17:43:11 -07:00
const leafs = useAtomValue ( layoutModel . leafs );
2024-08-14 18:40:41 -07:00
const overlayTransform = useAtomValue ( layoutModel . overlayTransform );
const overlayNodes = useMemo (
() =>
2024-08-26 11:56:00 -07:00
leafs . map (( node ) => {
return < OverlayNode key = { node . id } layoutModel = { layoutModel } node = { node } />;
2024-08-14 18:40:41 -07:00
}),
2024-08-21 17:43:11 -07:00
[ leafs ]
2024-08-14 18:40:41 -07:00
);
return (
< div key = "overlay" className = "overlay-container" style = {{ top : 10000 , ... overlayTransform }}>
{ overlayNodes }
</ div >
);
2024-08-26 11:56:00 -07:00
});
2024-08-14 18:40:41 -07:00
interface OverlayNodeProps {
2024-06-13 19:33:06 -07:00
/**
* The layout tree state.
*/
2024-08-14 18:40:41 -07:00
layoutModel : LayoutModel ;
2024-08-26 11:56:00 -07:00
node : LayoutNode ;
2024-06-04 13:05:44 -07:00
}
2024-06-13 19:33:06 -07:00
/**
* 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
2024-06-13 19:36:06 -07:00
* dimensions of the corresponding DisplayNode for each LayoutTreeState leaf.
2024-06-13 19:33:06 -07:00
*/
2024-08-26 11:56:00 -07:00
const OverlayNode = memo (({ node , layoutModel } : OverlayNodeProps ) => {
const nodeModel = useNodeModel ( layoutModel , node );
const additionalProps = useAtomValue ( nodeModel . additionalProps );
2024-06-04 13:05:44 -07:00
const overlayRef = useRef < HTMLDivElement >( null );
2024-07-03 14:31:02 -07:00
2024-06-04 13:05:44 -07:00
const [, drop ] = useDrop (
() => ({
accept : dragItemType ,
canDrop : ( _ , monitor ) => {
2024-09-10 22:50:28 -07:00
const dragItem = monitor . getItem < LayoutNode >();
if ( monitor . isOver ({ shallow : true }) && dragItem . id !== node . id ) {
2024-06-04 13:05:44 -07:00
return true ;
}
return false ;
},
drop : ( _ , monitor ) => {
2024-08-15 14:53:13 -07:00
if ( ! monitor . didDrop ()) {
layoutModel . onDrop ();
2024-06-04 13:05:44 -07:00
}
},
2024-08-15 14:53:13 -07:00
hover : throttle ( 50 , ( _ , monitor : DropTargetMonitor < unknown , unknown >) => {
2024-06-18 16:03:00 -07:00
if ( monitor . isOver ({ shallow : true })) {
2024-08-15 14:53:13 -07:00
if ( monitor . canDrop () && layoutModel . displayContainerRef ? . current && additionalProps ? . rect ) {
2024-08-14 18:40:41 -07:00
const dragItem = monitor . getItem < LayoutNode >();
// console.log("computing operation", layoutNode, dragItem, additionalProps.rect);
const offset = monitor . getClientOffset ();
2024-08-15 14:53:13 -07:00
const containerRect = layoutModel . displayContainerRef . current . getBoundingClientRect ();
offset . x -= containerRect . x ;
offset . y -= containerRect . y ;
2024-08-14 18:40:41 -07:00
layoutModel . treeReducer ({
2024-06-18 16:03:00 -07:00
type : LayoutTreeActionType . ComputeMove ,
2024-09-10 17:23:28 -07:00
nodeId : node.id ,
nodeToMoveId : dragItem.id ,
2024-08-15 14:53:13 -07:00
direction : determineDropDirection ( additionalProps . rect , offset ),
2024-08-14 18:40:41 -07:00
} as LayoutTreeComputeMoveNodeAction );
2024-06-18 16:03:00 -07:00
} else {
2024-08-14 18:40:41 -07:00
layoutModel . treeReducer ({
2024-06-18 16:03:00 -07:00
type : LayoutTreeActionType . ClearPendingAction ,
});
}
2024-06-04 13:05:44 -07:00
}
2024-06-19 11:15:14 -07:00
}),
2024-06-04 13:05:44 -07:00
}),
2024-09-10 17:23:28 -07:00
[ node . id , additionalProps ? . rect , layoutModel . displayContainerRef , layoutModel . onDrop , layoutModel . treeReducer ]
2024-06-04 13:05:44 -07:00
);
2024-08-15 14:53:13 -07:00
// Register the overlay node as a drop target
2024-06-04 13:05:44 -07:00
useEffect (() => {
2024-08-14 18:40:41 -07:00
drop ( overlayRef );
}, []);
2024-06-04 13:05:44 -07:00
2024-08-26 11:56:00 -07:00
return < div ref = { overlayRef } className = "overlay-node" id = { node . id } style = { additionalProps ? . transform } />;
});
2024-06-06 17:58:37 -07:00
2024-08-14 18:40:41 -07:00
interface ResizeHandleWrapperProps {
layoutModel : LayoutModel ;
2024-07-03 14:31:02 -07:00
}
2024-08-26 11:56:00 -07:00
const ResizeHandleWrapper = memo (({ layoutModel } : ResizeHandleWrapperProps ) => {
2024-08-14 18:40:41 -07:00
const resizeHandles = useAtomValue ( layoutModel . resizeHandles ) as Atom < ResizeHandleProps >[];
return resizeHandles . map (( resizeHandleAtom , i ) => (
< ResizeHandle key = { `resize-handle- ${ i } ` } layoutModel = { layoutModel } resizeHandleAtom = { resizeHandleAtom } />
));
2024-08-26 11:56:00 -07:00
});
2024-08-14 18:40:41 -07:00
interface ResizeHandleComponentProps {
resizeHandleAtom : Atom < ResizeHandleProps >;
layoutModel : LayoutModel ;
}
2024-08-26 11:56:00 -07:00
const ResizeHandle = memo (({ resizeHandleAtom , layoutModel } : ResizeHandleComponentProps ) => {
2024-08-14 18:40:41 -07:00
const resizeHandleProps = useAtomValue ( resizeHandleAtom );
2024-07-03 14:31:02 -07:00
const resizeHandleRef = useRef < HTMLDivElement >( null );
// The pointer currently captured, or undefined.
const [ trackingPointer , setTrackingPointer ] = useState < number >( undefined );
// Calculates the new size of the two nodes on either side of the handle, based on the position of the cursor
const handlePointerMove = useCallback (
2024-07-03 15:31:39 -07:00
throttle ( 10 , ( event : React.PointerEvent < HTMLDivElement >) => {
if ( trackingPointer === event . pointerId ) {
const { clientX , clientY } = event ;
2024-08-14 18:40:41 -07:00
layoutModel . onResizeMove ( resizeHandleProps , clientX , clientY );
2024-07-03 14:31:02 -07:00
}
2024-07-03 15:31:39 -07:00
}),
2024-08-14 18:40:41 -07:00
[ trackingPointer , layoutModel . onResizeMove , resizeHandleProps ]
2024-07-03 14:31:02 -07:00
);
// 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 < HTMLDivElement >) {
resizeHandleRef . current ? . setPointerCapture ( event . pointerId );
}
// This indicates that we're ready to start tracking the resize operation via the pointer
function onPointerCapture ( event : React.PointerEvent < HTMLDivElement >) {
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 < HTMLDivElement >) => {
setTrackingPointer ( undefined );
2024-08-14 18:40:41 -07:00
layoutModel . onResizeEnd ();
2024-07-03 14:31:02 -07:00
}),
2024-08-14 18:40:41 -07:00
[ layoutModel ]
2024-07-03 14:31:02 -07:00
);
return (
< div
ref = { resizeHandleRef }
2024-08-14 18:40:41 -07:00
className = { clsx ( "resize-handle" , `flex- ${ resizeHandleProps . flexDirection } ` )}
2024-07-03 14:31:02 -07:00
onPointerDown = { onPointerDown }
onGotPointerCapture = { onPointerCapture }
onLostPointerCapture = { onPointerRelease }
2024-08-14 18:40:41 -07:00
style = { resizeHandleProps . transform }
2024-07-03 15:31:39 -07:00
onPointerMove = { handlePointerMove }
2024-07-03 14:31:02 -07:00
>
< div className = "line" />
</ div >
);
2024-08-26 11:56:00 -07:00
});
2024-07-03 14:31:02 -07:00
2024-08-14 18:40:41 -07:00
interface PlaceholderProps {
2024-06-13 19:33:06 -07:00
/**
* The layout tree state.
*/
2024-08-14 18:40:41 -07:00
layoutModel : LayoutModel ;
2024-06-13 19:33:06 -07:00
/**
* Any styling to apply to the placeholder container div.
*/
2024-06-06 17:58:37 -07:00
style : React.CSSProperties ;
}
2024-06-13 19:33:06 -07:00
/**
* An overlay to preview pending actions on the layout tree.
*/
2024-08-14 18:40:41 -07:00
const Placeholder = memo (({ layoutModel , style } : PlaceholderProps ) => {
2024-06-06 17:58:37 -07:00
const [ placeholderOverlay , setPlaceholderOverlay ] = useState < ReactNode >( null );
2024-08-14 18:40:41 -07:00
const placeholderTransform = useAtomValue ( layoutModel . placeholderTransform );
2024-07-31 12:49:38 -07:00
useEffect (() => {
2024-08-14 18:40:41 -07:00
if ( placeholderTransform ) {
setPlaceholderOverlay (< div className = "placeholder" style = { placeholderTransform } />);
} else {
setPlaceholderOverlay ( null );
}
}, [ placeholderTransform ]);
2024-06-06 17:58:37 -07:00
return (
< div className = "placeholder-container" style = { style }>
{ placeholderOverlay }
</ div >
);
2024-07-31 12:49:38 -07:00
});