waveterm/frontend/faraday/lib/TileLayout.tsx
Evan Simkowitz f12e246c15
Break layout node into its own Wave Object (#21)
I am updating the layout node setup to write to its own wave object. 

The existing setup requires me to plumb the layout updates through every
time the tab gets updated, which produces a lot of annoying and
unintuitive design patterns. With this new setup, the tab object doesn't
get written to when the layout changes, only the layout object will get
written to. This prevents collisions when both the tab object and the
layout node object are getting updated, such as when a new block is
added or deleted.
2024-06-05 17:21:40 -07:00

376 lines
13 KiB
TypeScript

// 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<T> {
layoutTreeStateAtom: WritableLayoutTreeStateAtom<T>;
renderContent: ContentRenderer<T>;
onNodeDelete?: (data: T) => Promise<void>;
className?: string;
}
export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent, onNodeDelete }: TileLayoutProps<T>) => {
const overlayContainerRef = useRef<HTMLDivElement>(null);
const displayContainerRef = useRef<HTMLDivElement>(null);
const [layoutTreeState, dispatch] = useLayoutTreeStateReducerAtom(layoutTreeStateAtom);
const [nodeRefs, setNodeRefs] = useState<Map<string, RefObject<HTMLDivElement>>>(new Map());
useEffect(() => {
console.log("layoutTreeState changed", layoutTreeState);
}, [layoutTreeState]);
const setRef = useCallback(
(id: string, ref: RefObject<HTMLDivElement>) => {
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<CSSProperties>();
const [layoutLeafTransforms, setLayoutLeafTransforms] = useState<Record<string, CSSProperties>>({});
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<string, CSSProperties> = {};
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 [prevUpdateTransforms, setPrevUpdateTransforms] = useState<() => void>(undefined);
useEffect(() => {
console.log("replace resize listener");
if (prevUpdateTransforms) window.removeEventListener("resize", prevUpdateTransforms);
window.addEventListener("resize", updateTransforms);
setPrevUpdateTransforms(updateTransforms);
return () => {
window.removeEventListener("resize", updateTransforms);
};
}, [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.
// `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<T>) => {
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 (
<div className={clsx("tile-layout", className, { animate, overlayVisible })}>
<div key="display" ref={displayContainerRef} className="display-container">
{layoutLeafTransforms &&
layoutTreeState.leafs.map((leaf) => {
return (
<TileNode
key={leaf.id}
layoutNode={leaf}
renderContent={renderContent}
transform={layoutLeafTransforms[leaf.id]}
onLeafClose={onLeafClose}
ready={animate}
/>
);
})}
</div>
<div
key="overlay"
ref={overlayContainerRef}
className="overlay-container"
style={{ top: 10000, ...overlayTransform }}
>
<OverlayNode
layoutNode={layoutTreeState.rootNode}
layoutTreeState={layoutTreeState}
dispatch={dispatch}
setRef={setRef}
deleteRef={deleteRef}
/>
</div>
</div>
);
};
interface TileNodeProps<T> {
layoutNode: LayoutNode<T>;
renderContent: ContentRenderer<T>;
onLeafClose: (node: LayoutNode<T>) => void;
ready: boolean;
transform: CSSProperties;
}
const dragItemType = "TILE_ITEM";
const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose, ready }: TileNodeProps<T>) => {
const tileNodeRef = useRef<HTMLDivElement>(null);
const [{ isDragging, dragItem }, drag, dragPreview] = useDrag(
() => ({
type: dragItemType,
item: () => layoutNode,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
dragItem: monitor.getItem<LayoutNode<T>>(),
}),
}),
[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 && (
<div key="leaf" className="tile-leaf">
{renderContent(layoutNode.data, ready, onClose)}
</div>
)
);
}, [, layoutNode.data, ready, onClose]);
return (
<div
className="tile-node"
ref={tileNodeRef}
id={layoutNode.id}
style={{
flexDirection: layoutNode.flexDirection,
flexBasis: layoutNode.size,
...transform,
}}
>
{leafContent}
</div>
);
};
interface OverlayNodeProps<T> {
layoutNode: LayoutNode<T>;
layoutTreeState: LayoutTreeState<T>;
dispatch: (action: LayoutTreeAction) => void;
setRef: (id: string, ref: RefObject<HTMLDivElement>) => void;
deleteRef: (id: string) => void;
}
const OverlayNode = <T,>({ layoutNode, layoutTreeState, dispatch, setRef, deleteRef }: OverlayNodeProps<T>) => {
const overlayRef = useRef<HTMLDivElement>(null);
const leafRef = useRef<HTMLDivElement>(null);
const [, drop] = useDrop(
() => ({
accept: dragItemType,
canDrop: (_, monitor) => {
const dragItem = monitor.getItem<LayoutNode<T>>();
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<LayoutNode<T>>();
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<T>);
}
},
}),
[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 (
<OverlayNode
key={childItem.id}
layoutNode={childItem}
layoutTreeState={layoutTreeState}
dispatch={dispatch}
setRef={setRef}
deleteRef={deleteRef}
/>
);
});
} else {
return [<div ref={leafRef} key="leaf" className="overlay-leaf"></div>];
}
};
if (!layoutNode) {
return null;
}
return (
<div
ref={overlayRef}
className="overlay-node"
id={layoutNode.id}
style={{
flexBasis: layoutNode.size,
flexDirection: layoutNode.flexDirection,
}}
>
{generateChildren()}
</div>
);
};