waveterm/frontend/layout/lib/TileLayout.tsx

509 lines
18 KiB
TypeScript
Raw Normal View History

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { getSettingsKeyAtom } from "@/app/store/global";
import clsx from "clsx";
import { toPng } from "html-to-image";
import { Atom, useAtomValue, useSetAtom } from "jotai";
2024-06-12 02:42:10 +02:00
import React, {
CSSProperties,
ReactNode,
Implement outer drop direction, add rudimentary drag preview image rendering (#29) This PR adds support for Outer variants of each DropDirection. When calculating the drop direction, the cursor position is calculated relevant to the box over which it is hovering. The following diagram shows how drop directions are calculated. The colored in center is currently not supported, it is assigned to the top, bottom, left, right direction for now, though it will ultimately be its own distinct direction. ![IMG_3505](https://github.com/wavetermdev/thenextwave/assets/16651283/a7ea7387-b95d-4831-9e29-d3225b824c97) When an outer drop direction is provided for a move operation, if the reference node flexes in the same axis as the drop direction, the new node will be inserted at the same level as the parent of the reference node. If the reference node flexes in a different direction or the reference node does not have a grandparent, the operation will fall back to its non-Outer variant. This also removes some chatty debug statements, adds a blur to the currently-dragging node to indicate that it cannot be dropped onto, and simplifies the deriving of the layout state atom from the tab atom so there's no longer another intermediate derived atom for the layout node. This also adds rudimentary support for rendering custom preview images for any tile being dragged. Right now, this is a simple block containing the block ID, but this can be anything. This resolves an issue where letting React-DnD generate its own previews could take up to a half second, and would block dragging until complete. For Monaco, this was outright failing. It also fixes an issue where the tile layout could animate on first paint. Now, I use React Suspense to prevent the layout from displaying until all the children have loaded.
2024-06-11 22:03:41 +02:00
Suspense,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { DropTargetMonitor, XYCoord, useDrag, useDragLayer, useDrop } from "react-dnd";
import { debounce, throttle } from "throttle-debounce";
import { useDevicePixelRatio } from "use-device-pixel-ratio";
2024-08-15 04:43:25 +02:00
import { LayoutModel } from "./layoutModel";
import { useNodeModel, useTileLayout } from "./layoutModelHooks";
import "./tilelayout.scss";
2024-08-15 04:43:25 +02:00
import {
LayoutNode,
LayoutTreeActionType,
LayoutTreeComputeMoveNodeAction,
ResizeHandleProps,
TileLayoutContents,
} from "./types";
import { determineDropDirection } from "./utils";
export interface TileLayoutProps {
2024-06-26 18:31:43 +02:00
/**
* The atom containing the layout tree state.
*/
tabAtom: Atom<Tab>;
2024-06-26 18:31:43 +02:00
/**
* 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({ 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);
const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({
activeDrag: monitor.isDragging(),
dragClientOffset: monitor.getClientOffset(),
}));
useEffect(() => {
setActiveDrag(activeDrag);
}, [setActiveDrag, activeDrag]);
const checkForCursorBounds = useCallback(
debounce(100, (dragClientOffset: XYCoord) => {
const cursorPoint = dragClientOffset ?? getCursorPoint?.();
if (cursorPoint && layoutModel.displayContainerRef?.current) {
const displayContainerRect = layoutModel.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
) {
layoutModel.treeReducer({ type: LayoutTreeActionType.ClearPendingAction });
}
}
}),
[getCursorPoint]
);
// 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]);
// 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);
setReady(true);
}, 50);
}, []);
const gapSizePx = useAtomValue(layoutModel.gapSizePx);
const animationTimeS = useAtomValue(layoutModel.animationTimeS);
const tileStyle = useMemo(
() =>
({
"--gap-size-px": `${gapSizePx}px`,
"--animation-time-s": `${animationTimeS}s`,
}) as CSSProperties,
[gapSizePx, animationTimeS]
);
return (
Implement outer drop direction, add rudimentary drag preview image rendering (#29) This PR adds support for Outer variants of each DropDirection. When calculating the drop direction, the cursor position is calculated relevant to the box over which it is hovering. The following diagram shows how drop directions are calculated. The colored in center is currently not supported, it is assigned to the top, bottom, left, right direction for now, though it will ultimately be its own distinct direction. ![IMG_3505](https://github.com/wavetermdev/thenextwave/assets/16651283/a7ea7387-b95d-4831-9e29-d3225b824c97) When an outer drop direction is provided for a move operation, if the reference node flexes in the same axis as the drop direction, the new node will be inserted at the same level as the parent of the reference node. If the reference node flexes in a different direction or the reference node does not have a grandparent, the operation will fall back to its non-Outer variant. This also removes some chatty debug statements, adds a blur to the currently-dragging node to indicate that it cannot be dropped onto, and simplifies the deriving of the layout state atom from the tab atom so there's no longer another intermediate derived atom for the layout node. This also adds rudimentary support for rendering custom preview images for any tile being dragged. Right now, this is a simple block containing the block ID, but this can be anything. This resolves an issue where letting React-DnD generate its own previews could take up to a half second, and would block dragging until complete. For Monaco, this was outright failing. It also fixes an issue where the tile layout could animate on first paint. Now, I use React Suspense to prevent the layout from displaying until all the children have loaded.
2024-06-11 22:03:41 +02:00
<Suspense>
<div
className={clsx("tile-layout", contents.className, { animate: animate && !isResizing })}
style={tileStyle}
>
<div key="display" ref={layoutModel.displayContainerRef} className="display-container">
<ResizeHandleWrapper layoutModel={layoutModel} />
<DisplayNodesWrapper layoutModel={layoutModel} />
<NodeBackdrops layoutModel={layoutModel} />
Implement outer drop direction, add rudimentary drag preview image rendering (#29) This PR adds support for Outer variants of each DropDirection. When calculating the drop direction, the cursor position is calculated relevant to the box over which it is hovering. The following diagram shows how drop directions are calculated. The colored in center is currently not supported, it is assigned to the top, bottom, left, right direction for now, though it will ultimately be its own distinct direction. ![IMG_3505](https://github.com/wavetermdev/thenextwave/assets/16651283/a7ea7387-b95d-4831-9e29-d3225b824c97) When an outer drop direction is provided for a move operation, if the reference node flexes in the same axis as the drop direction, the new node will be inserted at the same level as the parent of the reference node. If the reference node flexes in a different direction or the reference node does not have a grandparent, the operation will fall back to its non-Outer variant. This also removes some chatty debug statements, adds a blur to the currently-dragging node to indicate that it cannot be dropped onto, and simplifies the deriving of the layout state atom from the tab atom so there's no longer another intermediate derived atom for the layout node. This also adds rudimentary support for rendering custom preview images for any tile being dragged. Right now, this is a simple block containing the block ID, but this can be anything. This resolves an issue where letting React-DnD generate its own previews could take up to a half second, and would block dragging until complete. For Monaco, this was outright failing. It also fixes an issue where the tile layout could animate on first paint. Now, I use React Suspense to prevent the layout from displaying until all the children have loaded.
2024-06-11 22:03:41 +02:00
</div>
<Placeholder key="placeholder" layoutModel={layoutModel} style={{ top: 10000, ...overlayTransform }} />
<OverlayNodeWrapper layoutModel={layoutModel} />
</div>
Implement outer drop direction, add rudimentary drag preview image rendering (#29) This PR adds support for Outer variants of each DropDirection. When calculating the drop direction, the cursor position is calculated relevant to the box over which it is hovering. The following diagram shows how drop directions are calculated. The colored in center is currently not supported, it is assigned to the top, bottom, left, right direction for now, though it will ultimately be its own distinct direction. ![IMG_3505](https://github.com/wavetermdev/thenextwave/assets/16651283/a7ea7387-b95d-4831-9e29-d3225b824c97) When an outer drop direction is provided for a move operation, if the reference node flexes in the same axis as the drop direction, the new node will be inserted at the same level as the parent of the reference node. If the reference node flexes in a different direction or the reference node does not have a grandparent, the operation will fall back to its non-Outer variant. This also removes some chatty debug statements, adds a blur to the currently-dragging node to indicate that it cannot be dropped onto, and simplifies the deriving of the layout state atom from the tab atom so there's no longer another intermediate derived atom for the layout node. This also adds rudimentary support for rendering custom preview images for any tile being dragged. Right now, this is a simple block containing the block ID, but this can be anything. This resolves an issue where letting React-DnD generate its own previews could take up to a half second, and would block dragging until complete. For Monaco, this was outright failing. It also fixes an issue where the tile layout could animate on first paint. Now, I use React Suspense to prevent the layout from displaying until all the children have loaded.
2024-06-11 22:03:41 +02:00
</Suspense>
);
}
export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent;
2024-06-26 18:31:43 +02:00
function NodeBackdrops({ layoutModel }: { layoutModel: LayoutModel }) {
const [blockBlurAtom] = useState(() => getSettingsKeyAtom("window:magnifiedblockblursecondarypx"));
const blockBlur = useAtomValue(blockBlurAtom);
const ephemeralNode = useAtomValue(layoutModel.ephemeralNode);
const magnifiedNodeId = useAtomValue(layoutModel.treeStateAtom).magnifiedNodeId;
const [showMagnifiedBackdrop, setShowMagnifiedBackdrop] = useState(!!ephemeralNode);
const [showEphemeralBackdrop, setShowEphemeralBackdrop] = useState(!!magnifiedNodeId);
const debouncedSetMagnifyBackdrop = useCallback(
debounce(100, () => setShowMagnifiedBackdrop(true)),
[]
);
useEffect(() => {
if (magnifiedNodeId && !showMagnifiedBackdrop) {
debouncedSetMagnifyBackdrop();
}
if (!magnifiedNodeId) {
setShowMagnifiedBackdrop(false);
}
if (ephemeralNode && !showEphemeralBackdrop) {
setShowEphemeralBackdrop(true);
}
if (!ephemeralNode) {
setShowEphemeralBackdrop(false);
}
}, [ephemeralNode, magnifiedNodeId]);
const blockBlurStr = `${blockBlur}px`;
return (
<>
{showMagnifiedBackdrop && (
<div
className="magnified-node-backdrop"
onClick={() => {
layoutModel.magnifyNodeToggle(magnifiedNodeId);
}}
style={{ "--block-blur": blockBlurStr } as CSSProperties}
/>
)}
{showEphemeralBackdrop && (
<div
className="ephemeral-node-backdrop"
onClick={() => {
layoutModel.closeNode(ephemeralNode?.id);
}}
style={{ "--block-blur": blockBlurStr } as CSSProperties}
/>
)}
</>
);
}
interface DisplayNodesWrapperProps {
2024-06-26 18:31:43 +02:00
/**
* The layout tree state.
*/
layoutModel: LayoutModel;
2024-06-26 18:31:43 +02:00
}
const DisplayNodesWrapper = ({ layoutModel }: DisplayNodesWrapperProps) => {
const leafs = useAtomValue(layoutModel.leafs);
return useMemo(
() =>
leafs.map((node) => {
return <DisplayNode key={node.id} layoutModel={layoutModel} node={node} />;
}),
[leafs]
);
};
interface DisplayNodeProps {
layoutModel: LayoutModel;
/**
* The leaf node object, containing the data needed to display the leaf contents to the user.
*/
node: LayoutNode;
}
const dragItemType = "TILE_ITEM";
/**
2024-06-14 04:36:06 +02:00
* The draggable and displayable portion of a leaf node in a layout tree.
*/
const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => {
const nodeModel = useNodeModel(layoutModel, node);
const tileNodeRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const addlProps = useAtomValue(nodeModel.additionalProps);
const devicePixelRatio = useDevicePixelRatio();
const isEphemeral = useAtomValue(nodeModel.isEphemeral);
const isMagnified = useAtomValue(nodeModel.isMagnified);
const [{ isDragging }, drag, dragPreview] = useDrag(
() => ({
type: dragItemType,
canDrag: () => !(isEphemeral || isMagnified),
item: () => node,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[node, addlProps, isEphemeral, isMagnified]
);
const [previewElementGeneration, setPreviewElementGeneration] = useState(0);
const previewElement = useMemo(() => {
setPreviewElementGeneration(previewElementGeneration + 1);
return (
<div key="preview" className="tile-preview-container">
<div
className="tile-preview"
ref={previewRef}
style={{
width: DragPreviewWidth,
height: DragPreviewHeight,
transform: `scale(${1 / devicePixelRatio})`,
}}
>
{layoutModel.renderPreview?.(nodeModel)}
</div>
</div>
);
}, [devicePixelRatio, nodeModel]);
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 (
<div key="leaf" className="tile-leaf">
{layoutModel.renderContent(nodeModel)}
</div>
);
}, [nodeModel]);
// Register the display node as a draggable item
useEffect(() => {
drag(nodeModel.dragHandleRef);
}, [drag, nodeModel.dragHandleRef.current]);
return (
<div
className={clsx("tile-node", {
dragging: isDragging,
})}
2024-10-17 23:34:02 +02:00
key={node.id}
ref={tileNodeRef}
id={node.id}
style={addlProps?.transform}
onPointerEnter={generatePreviewImage}
onPointerOver={(event) => event.stopPropagation()}
>
{leafContent}
{previewElement}
</div>
);
};
interface OverlayNodeWrapperProps {
layoutModel: LayoutModel;
}
const OverlayNodeWrapper = memo(({ layoutModel }: OverlayNodeWrapperProps) => {
const leafs = useAtomValue(layoutModel.leafs);
const overlayTransform = useAtomValue(layoutModel.overlayTransform);
const overlayNodes = useMemo(
() =>
leafs.map((node) => {
return <OverlayNode key={node.id} layoutModel={layoutModel} node={node} />;
}),
[leafs]
);
return (
<div key="overlay" className="overlay-container" style={{ top: 10000, ...overlayTransform }}>
{overlayNodes}
</div>
);
});
interface OverlayNodeProps {
/**
* The layout tree state.
*/
layoutModel: LayoutModel;
node: LayoutNode;
}
/**
* 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-14 04:36:06 +02:00
* dimensions of the corresponding DisplayNode for each LayoutTreeState leaf.
*/
const OverlayNode = memo(({ node, layoutModel }: OverlayNodeProps) => {
const nodeModel = useNodeModel(layoutModel, node);
const additionalProps = useAtomValue(nodeModel.additionalProps);
const overlayRef = useRef<HTMLDivElement>(null);
const [, drop] = useDrop(
() => ({
accept: dragItemType,
canDrop: (_, monitor) => {
2024-09-11 07:50:28 +02:00
const dragItem = monitor.getItem<LayoutNode>();
if (monitor.isOver({ shallow: true }) && dragItem.id !== node.id) {
return true;
}
return false;
},
drop: (_, monitor) => {
if (!monitor.didDrop()) {
layoutModel.onDrop();
}
},
hover: throttle(50, (_, monitor: DropTargetMonitor<unknown, unknown>) => {
if (monitor.isOver({ shallow: true })) {
if (monitor.canDrop() && layoutModel.displayContainerRef?.current && additionalProps?.rect) {
const dragItem = monitor.getItem<LayoutNode>();
// console.log("computing operation", layoutNode, dragItem, additionalProps.rect);
const offset = monitor.getClientOffset();
const containerRect = layoutModel.displayContainerRef.current.getBoundingClientRect();
offset.x -= containerRect.x;
offset.y -= containerRect.y;
layoutModel.treeReducer({
type: LayoutTreeActionType.ComputeMove,
nodeId: node.id,
nodeToMoveId: dragItem.id,
direction: determineDropDirection(additionalProps.rect, offset),
} as LayoutTreeComputeMoveNodeAction);
} else {
layoutModel.treeReducer({
type: LayoutTreeActionType.ClearPendingAction,
});
}
}
}),
}),
[node.id, additionalProps?.rect, layoutModel.displayContainerRef, layoutModel.onDrop, layoutModel.treeReducer]
);
// Register the overlay node as a drop target
useEffect(() => {
drop(overlayRef);
}, []);
return <div ref={overlayRef} className="overlay-node" id={node.id} style={additionalProps?.transform} />;
});
interface ResizeHandleWrapperProps {
layoutModel: LayoutModel;
}
const ResizeHandleWrapper = memo(({ layoutModel }: ResizeHandleWrapperProps) => {
const resizeHandles = useAtomValue(layoutModel.resizeHandles) as Atom<ResizeHandleProps>[];
return resizeHandles.map((resizeHandleAtom, i) => (
<ResizeHandle key={`resize-handle-${i}`} layoutModel={layoutModel} resizeHandleAtom={resizeHandleAtom} />
));
});
interface ResizeHandleComponentProps {
resizeHandleAtom: Atom<ResizeHandleProps>;
layoutModel: LayoutModel;
}
const ResizeHandle = memo(({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => {
const resizeHandleProps = useAtomValue(resizeHandleAtom);
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(
throttle(10, (event: React.PointerEvent<HTMLDivElement>) => {
if (trackingPointer === event.pointerId) {
const { clientX, clientY } = event;
layoutModel.onResizeMove(resizeHandleProps, clientX, clientY);
}
}),
[trackingPointer, layoutModel.onResizeMove, resizeHandleProps]
);
// 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);
layoutModel.onResizeEnd();
}),
[layoutModel]
);
return (
<div
ref={resizeHandleRef}
className={clsx("resize-handle", `flex-${resizeHandleProps.flexDirection}`)}
onPointerDown={onPointerDown}
onGotPointerCapture={onPointerCapture}
onLostPointerCapture={onPointerRelease}
style={resizeHandleProps.transform}
onPointerMove={handlePointerMove}
>
<div className="line" />
</div>
);
});
interface PlaceholderProps {
/**
* The layout tree state.
*/
layoutModel: LayoutModel;
/**
* Any styling to apply to the placeholder container div.
*/
style: React.CSSProperties;
}
/**
* An overlay to preview pending actions on the layout tree.
*/
const Placeholder = memo(({ layoutModel, style }: PlaceholderProps) => {
const [placeholderOverlay, setPlaceholderOverlay] = useState<ReactNode>(null);
const placeholderTransform = useAtomValue(layoutModel.placeholderTransform);
useEffect(() => {
if (placeholderTransform) {
setPlaceholderOverlay(<div className="placeholder" style={placeholderTransform} />);
} else {
setPlaceholderOverlay(null);
}
}, [placeholderTransform]);
return (
<div className="placeholder-container" style={style}>
{placeholderOverlay}
</div>
);
});