mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
7a61f25331
I found a lot of places where asyncs weren't being properly wrapped or awaited
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
// 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";
|
|
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.scss";
|
|
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 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 (
|
|
<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} />
|
|
</div>
|
|
<Placeholder key="placeholder" layoutModel={layoutModel} style={{ top: 10000, ...overlayTransform }} />
|
|
<OverlayNodeWrapper layoutModel={layoutModel} />
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent;
|
|
|
|
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 {
|
|
/**
|
|
* 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 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,
|
|
})}
|
|
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
|
|
* 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,
|
|
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>
|
|
);
|
|
});
|