mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
c892c39a73
Make the block content sizing update once when its node moves or becomes magnified. By manually updating this inner sizing rather than letting the block flow in the DOM, the animations of the block frames are much smoother. This also fixes an issue where two scrollbars were being rendered for the Directory Preview widget. This also sets zero padding on nodes when there's only a single node being rendered.
450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
// Copyright 2024, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import clsx from "clsx";
|
|
import { toPng } from "html-to-image";
|
|
import { Atom, useAtomValue, useSetAtom } from "jotai";
|
|
import React, {
|
|
CSSProperties,
|
|
ReactNode,
|
|
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";
|
|
import { LayoutModel } from "./layoutModel";
|
|
import { useNodeModel, useTileLayout } from "./layoutModelHooks";
|
|
import "./tilelayout.less";
|
|
import {
|
|
LayoutNode,
|
|
LayoutTreeActionType,
|
|
LayoutTreeComputeMoveNodeAction,
|
|
ResizeHandleProps,
|
|
TileLayoutContents,
|
|
} from "./types";
|
|
import { determineDropDirection } from "./utils";
|
|
|
|
export interface TileLayoutProps {
|
|
/**
|
|
* The atom containing the layout tree state.
|
|
*/
|
|
tabAtom: Atom<Tab>;
|
|
|
|
/**
|
|
* 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 tileStyle = useMemo(
|
|
() =>
|
|
({
|
|
"--gap-size-px": `${layoutModel.gapSizePx}px`,
|
|
"--animation-time-s": `${layoutModel.animationTimeS}s`,
|
|
}) as CSSProperties,
|
|
[layoutModel.gapSizePx, layoutModel.animationTimeS]
|
|
);
|
|
|
|
return (
|
|
<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} />
|
|
</div>
|
|
<Placeholder key="placeholder" layoutModel={layoutModel} style={{ top: 10000, ...overlayTransform }} />
|
|
<OverlayNodeWrapper layoutModel={layoutModel} />
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent;
|
|
|
|
interface DisplayNodesWrapperProps {
|
|
/**
|
|
* The layout tree state.
|
|
*/
|
|
layoutModel: LayoutModel;
|
|
}
|
|
|
|
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";
|
|
|
|
/**
|
|
* 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 [{ isDragging }, drag, dragPreview] = useDrag(
|
|
() => ({
|
|
type: dragItemType,
|
|
item: () => node,
|
|
canDrag: () => !addlProps?.isMagnifiedNode,
|
|
collect: (monitor) => ({
|
|
isDragging: monitor.isDragging(),
|
|
}),
|
|
}),
|
|
[node, addlProps]
|
|
);
|
|
|
|
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,
|
|
magnified: addlProps?.isMagnifiedNode,
|
|
"last-magnified": addlProps?.isLastMagnifiedNode,
|
|
})}
|
|
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
|
|
* 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) => {
|
|
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,
|
|
node: node,
|
|
nodeToMove: dragItem,
|
|
direction: determineDropDirection(additionalProps.rect, offset),
|
|
} as LayoutTreeComputeMoveNodeAction);
|
|
} else {
|
|
layoutModel.treeReducer({
|
|
type: LayoutTreeActionType.ClearPendingAction,
|
|
});
|
|
}
|
|
}
|
|
}),
|
|
}),
|
|
[node, additionalProps, layoutModel.displayContainerRef]
|
|
);
|
|
|
|
// 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>
|
|
);
|
|
});
|