mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
Add resize handles to the layout system (#66)
Adds resizability to the layout system. Hovering in the margins of a block will highlight the available resize handle and show a cursor indicating its resize direction. Dragging will cause the resizing nodes to blur out and be replaced by an outline. Releasing the handle will commit the new resize operation and cause the underlying nodes to update to their new sizes. We'll want to refactor this in the future to move all layout and resize logic into a shared model that the TileLayout code can talk to, but that's a future improvement. For now, this makes some compromises, mainly that the logic is kind of distributed around. --------- Co-authored-by: sawka <mike.sawka@gmail.com>
This commit is contained in:
parent
b2cdd441d1
commit
75c9e211d9
@ -161,7 +161,7 @@ function switchBlock(tabId: string, offsetX: number, offsetY: number) {
|
||||
return;
|
||||
}
|
||||
const layoutTreeState = globalStore.get(getLayoutStateAtomForTab(tabId, tabAtom));
|
||||
const curBlockId = globalStore.get(atoms.waveWindow).activeblockid;
|
||||
const curBlockId = globalStore.get(atoms.waveWindow)?.activeblockid;
|
||||
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutTreeState, curBlockId);
|
||||
if (curBlockLeafId == null) {
|
||||
return;
|
||||
|
@ -55,11 +55,11 @@
|
||||
|
||||
&.block-frame-tech {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin: 10px 2px 2px 2px;
|
||||
border-radius: 7px;
|
||||
margin: 8px 0 0 0;
|
||||
padding: 10px 2px 2px 2px;
|
||||
height: calc(100% - 12px);
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 8px);
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
|
||||
&.block-preview {
|
||||
@ -87,7 +87,6 @@
|
||||
text-overflow: ellipsis;
|
||||
top: -11px;
|
||||
padding: 4px 6px 4px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--main-bg-color);
|
||||
font: var(--fixed-font);
|
||||
color: var(--secondary-text-color);
|
||||
@ -100,10 +99,7 @@
|
||||
top: 0;
|
||||
right: -2px;
|
||||
padding: 0 0 1px 1px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--panel-bg-color);
|
||||
cursor: pointer;
|
||||
color: var(--grey-text-color);
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
|
@ -224,7 +224,7 @@ const BlockFrame_Tech_Component = ({
|
||||
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
|
||||
return jotai.atom((get) => {
|
||||
const winData = get(atoms.waveWindow);
|
||||
return winData.activeblockid === blockId;
|
||||
return winData?.activeblockid === blockId;
|
||||
});
|
||||
});
|
||||
let isFocused = jotai.useAtomValue(isFocusedAtom);
|
||||
|
@ -341,6 +341,9 @@ async function fetchWaveFile(
|
||||
|
||||
function setBlockFocus(blockId: string) {
|
||||
let winData = globalStore.get(atoms.waveWindow);
|
||||
if (winData == null) {
|
||||
return;
|
||||
}
|
||||
if (winData.activeblockid === blockId) {
|
||||
return;
|
||||
}
|
||||
|
@ -11,6 +11,7 @@
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: 3px;
|
||||
|
||||
.block-container {
|
||||
display: flex;
|
||||
|
@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Block, BlockFrame } from "@/app/block/block";
|
||||
import { getApi } from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import * as WOS from "@/store/wos";
|
||||
import * as React from "react";
|
||||
@ -11,7 +12,6 @@ import { TileLayout } from "@/faraday/index";
|
||||
import { getLayoutStateAtomForTab } from "@/faraday/lib/layoutAtom";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { getApi } from "../store/global";
|
||||
import "./tabcontent.less";
|
||||
|
||||
const TabContent = React.memo(({ tabId }: { tabId: string }) => {
|
||||
|
@ -142,6 +142,9 @@ const IJSONConst = {
|
||||
|
||||
function setBlockFocus(blockId: string) {
|
||||
let winData = globalStore.get(atoms.waveWindow);
|
||||
if (winData == null) {
|
||||
return;
|
||||
}
|
||||
winData = produce(winData, (draft) => {
|
||||
draft.activeblockid = blockId;
|
||||
});
|
||||
@ -160,7 +163,7 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
|
||||
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
|
||||
return jotai.atom((get) => {
|
||||
const winData = get(atoms.waveWindow);
|
||||
return winData.activeblockid === blockId;
|
||||
return winData?.activeblockid === blockId;
|
||||
});
|
||||
});
|
||||
const termSettingsAtom = useSettingsAtom<TerminalConfigType>("term", (settings: SettingsConfigType) => {
|
||||
@ -240,6 +243,7 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
|
||||
termMode = "term";
|
||||
}
|
||||
|
||||
// set initial focus
|
||||
React.useEffect(() => {
|
||||
if (isFocused && termMode == "term") {
|
||||
termRef.current?.terminal.focus();
|
||||
@ -247,8 +251,9 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
|
||||
if (isFocused && termMode == "html") {
|
||||
htmlElemFocusRef.current?.focus();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// set intitial controller status, and then subscribe for updates
|
||||
React.useEffect(() => {
|
||||
function updateShellProcStatus(status: string) {
|
||||
if (status == null) {
|
||||
@ -268,12 +273,12 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
|
||||
updateShellProcStatus(rts?.shellprocstatus);
|
||||
});
|
||||
const bcSubject = getEventORefSubject("blockcontroller:status", WOS.makeORef("block", blockId));
|
||||
bcSubject.subscribe((data: WSEventType) => {
|
||||
const sub = bcSubject.subscribe((data: WSEventType) => {
|
||||
let bcRTS: BlockControllerRuntimeStatus = data.data;
|
||||
updateShellProcStatus(bcRTS?.shellprocstatus);
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, []);
|
||||
|
||||
let stickerConfig = {
|
||||
charWidth: 8,
|
||||
|
@ -10,7 +10,6 @@ import { newLayoutTreeStateAtom, useLayoutTreeStateReducerAtom } from "./layoutA
|
||||
import { newLayoutNode } from "./layoutNode.js";
|
||||
import { LayoutTreeActionType, LayoutTreeInsertNodeAction, WritableLayoutTreeStateAtom } from "./model.js";
|
||||
import "./tilelayout.stories.less";
|
||||
import { FlexDirection } from "./utils.js";
|
||||
|
||||
interface TestData {
|
||||
name: string;
|
||||
@ -22,7 +21,7 @@ const meta = {
|
||||
title: "TileLayout",
|
||||
args: {
|
||||
layoutTreeStateAtom: newLayoutTreeStateAtom<TestData>(
|
||||
newLayoutNode(FlexDirection.Row, undefined, undefined, {
|
||||
newLayoutNode(undefined, undefined, undefined, {
|
||||
name: "Hello world!",
|
||||
})
|
||||
),
|
||||
@ -52,7 +51,7 @@ type Story = StoryObj<typeof meta>;
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
layoutTreeStateAtom: newLayoutTreeStateAtom<TestData>(
|
||||
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "Hello world!" })
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world!" })
|
||||
),
|
||||
},
|
||||
};
|
||||
@ -60,31 +59,31 @@ export const Basic: Story = {
|
||||
export const More: Story = {
|
||||
args: {
|
||||
layoutTreeStateAtom: newLayoutTreeStateAtom<TestData>(
|
||||
newLayoutNode(FlexDirection.Row, undefined, [
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world1!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world2!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, [
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world3!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world4!" }),
|
||||
newLayoutNode(undefined, undefined, [
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world1!" }),
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world2!" }),
|
||||
newLayoutNode(undefined, undefined, [
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world3!" }),
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world4!" }),
|
||||
]),
|
||||
])
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const evenMoreRootNode = newLayoutNode<TestData>(FlexDirection.Row, undefined, [
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world1!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, [
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world2!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world3!" }),
|
||||
const evenMoreRootNode = newLayoutNode<TestData>(undefined, undefined, [
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world1!" }),
|
||||
newLayoutNode(undefined, undefined, [
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world2!" }),
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world3!" }),
|
||||
]),
|
||||
newLayoutNode(FlexDirection.Column, undefined, [
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world4!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world5!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, [
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world6!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world7!" }),
|
||||
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world8!" }),
|
||||
newLayoutNode(undefined, undefined, [
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world4!" }),
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world5!" }),
|
||||
newLayoutNode(undefined, undefined, [
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world6!" }),
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world7!" }),
|
||||
newLayoutNode(undefined, undefined, undefined, { name: "Hello world8!" }),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@ -102,7 +101,7 @@ export const AddNode: Story = {
|
||||
const [, dispatch] = useLayoutTreeStateReducerAtom(addNodeAtom);
|
||||
const [numAddedNodes, setNumAddedNodes] = useState(0);
|
||||
const dispatchAddNode = () => {
|
||||
const newNode = newLayoutNode(FlexDirection.Column, undefined, undefined, {
|
||||
const newNode = newLayoutNode(undefined, undefined, undefined, {
|
||||
name: "New Node" + numAddedNodes,
|
||||
});
|
||||
const insertNodeAction: LayoutTreeInsertNodeAction<TestData> = {
|
||||
|
@ -4,11 +4,12 @@
|
||||
import useResizeObserver from "@react-hook/resize-observer";
|
||||
import clsx from "clsx";
|
||||
import { toPng } from "html-to-image";
|
||||
import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import React, {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
Suspense,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@ -19,8 +20,9 @@ import React, {
|
||||
import { DropTargetMonitor, useDrag, useDragLayer, useDrop } from "react-dnd";
|
||||
import { debounce, throttle } from "throttle-debounce";
|
||||
import { useDevicePixelRatio } from "use-device-pixel-ratio";
|
||||
import { globalLayoutTransformsMap, useLayoutTreeStateReducerAtom } from "./layoutAtom";
|
||||
import { globalLayoutTransformsMap } from "./layoutAtom";
|
||||
import { findNode } from "./layoutNode";
|
||||
import { layoutTreeStateReducer } from "./layoutState";
|
||||
import {
|
||||
ContentRenderer,
|
||||
LayoutNode,
|
||||
@ -29,11 +31,14 @@ import {
|
||||
LayoutTreeComputeMoveNodeAction,
|
||||
LayoutTreeDeleteNodeAction,
|
||||
LayoutTreeMoveNodeAction,
|
||||
LayoutTreeResizeNodeAction,
|
||||
LayoutTreeSetPendingAction,
|
||||
LayoutTreeState,
|
||||
LayoutTreeSwapNodeAction,
|
||||
PreviewRenderer,
|
||||
WritableLayoutTreeStateAtom,
|
||||
} from "./model";
|
||||
import { NodeRefMap } from "./nodeRefMap";
|
||||
import "./tilelayout.less";
|
||||
import { Dimensions, FlexDirection, setTransform as createTransform, determineDropDirection } from "./utils";
|
||||
|
||||
@ -60,6 +65,12 @@ export interface TileLayoutContents<T> {
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* tabId this TileLayout is associated with
|
||||
*/
|
||||
@ -90,42 +101,24 @@ const DragPreviewHeight = 300;
|
||||
function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint }: 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());
|
||||
const [nodeRefsGen, setNodeRefsGen] = useState<number>(0);
|
||||
|
||||
// useEffect(() => {
|
||||
// console.log("layoutTreeState changed", layoutTreeState);
|
||||
// }, [layoutTreeState]);
|
||||
|
||||
const setRef = useCallback(
|
||||
(id: string, ref: RefObject<HTMLDivElement>) => {
|
||||
setNodeRefs((prev) => {
|
||||
// console.log("setRef", id, ref);
|
||||
prev.set(id, ref);
|
||||
return prev;
|
||||
});
|
||||
setNodeRefsGen((prev) => prev + 1);
|
||||
const jotaiStore = useStore();
|
||||
const layoutTreeState = useAtomValue(layoutTreeStateAtom);
|
||||
const [nodeRefsAtom] = useState<PrimitiveAtom<NodeRefMap>>(atom(new NodeRefMap()));
|
||||
const nodeRefs = useAtomValue(nodeRefsAtom);
|
||||
const dispatch = useCallback(
|
||||
(action: LayoutTreeAction) => {
|
||||
const currentState = jotaiStore.get(layoutTreeStateAtom);
|
||||
jotaiStore.set(layoutTreeStateAtom, layoutTreeStateReducer(currentState, action));
|
||||
},
|
||||
[setNodeRefs]
|
||||
[layoutTreeStateAtom, jotaiStore]
|
||||
);
|
||||
const [showOverlayAtom] = useState<PrimitiveAtom<boolean>>(atom(false));
|
||||
const [showOverlay, setShowOverlay] = useAtom(showOverlayAtom);
|
||||
|
||||
const deleteRef = useCallback(
|
||||
(id: string) => {
|
||||
// console.log("deleteRef", id);
|
||||
if (nodeRefs.has(id)) {
|
||||
setNodeRefs((prev) => {
|
||||
prev.delete(id);
|
||||
return prev;
|
||||
});
|
||||
setNodeRefsGen((prev) => prev + 1);
|
||||
} else {
|
||||
console.log("deleteRef id not found", id);
|
||||
function onPointerOver() {
|
||||
setShowOverlay(true);
|
||||
}
|
||||
},
|
||||
[nodeRefs, setNodeRefs]
|
||||
);
|
||||
|
||||
const [overlayTransform, setOverlayTransform] = useState<CSSProperties>();
|
||||
const [layoutLeafTransforms, setLayoutLeafTransformsRaw] = useState<Record<string, CSSProperties>>({});
|
||||
|
||||
@ -166,6 +159,8 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
|
||||
*/
|
||||
const updateTransforms = useCallback(
|
||||
debounce(30, () => {
|
||||
// TODO: janky way of preventing updates while a node resize is underway
|
||||
if (layoutTreeState.pendingAction?.type === LayoutTreeActionType.ResizeNode) return;
|
||||
if (overlayContainerRef.current && displayContainerRef.current) {
|
||||
const displayBoundingRect = displayContainerRef.current.getBoundingClientRect();
|
||||
// console.log("displayBoundingRect", displayBoundingRect);
|
||||
@ -206,7 +201,7 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
|
||||
setOverlayTransform(
|
||||
createTransform(
|
||||
{
|
||||
top: activeDrag ? 0 : newOverlayOffset,
|
||||
top: activeDrag || showOverlay ? 0 : newOverlayOffset,
|
||||
left: 0,
|
||||
width: overlayBoundingRect.width,
|
||||
height: overlayBoundingRect.height,
|
||||
@ -216,7 +211,7 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
|
||||
);
|
||||
}
|
||||
}),
|
||||
[activeDrag, overlayContainerRef, displayContainerRef, layoutTreeState.leafs, nodeRefsGen]
|
||||
[activeDrag, showOverlay, overlayContainerRef, displayContainerRef, layoutTreeState.leafs, nodeRefs.generation]
|
||||
);
|
||||
|
||||
// Update the transforms whenever we drag something and whenever the layout updates.
|
||||
@ -226,12 +221,6 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
|
||||
|
||||
useResizeObserver(overlayContainerRef, () => updateTransforms());
|
||||
|
||||
const onPointerLeave = useCallback(() => {
|
||||
if (activeDrag) {
|
||||
dispatch({ type: LayoutTreeActionType.ClearPendingAction });
|
||||
}
|
||||
}, [activeDrag, dispatch]);
|
||||
|
||||
// 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);
|
||||
@ -259,7 +248,7 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<div className={clsx("tile-layout", contents.className, { animate })} onPointerOut={onPointerLeave}>
|
||||
<div className={clsx("tile-layout", contents.className, { animate })} onPointerOver={onPointerOver}>
|
||||
<div key="display" ref={displayContainerRef} className="display-container">
|
||||
<DisplayNodesWrapper
|
||||
contents={contents}
|
||||
@ -273,7 +262,7 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
|
||||
key="placeholder"
|
||||
layoutTreeState={layoutTreeState}
|
||||
overlayContainerRef={overlayContainerRef}
|
||||
nodeRefs={nodeRefs}
|
||||
nodeRefsAtom={nodeRefsAtom}
|
||||
style={{ top: 10000, ...overlayTransform }}
|
||||
/>
|
||||
<div
|
||||
@ -286,8 +275,9 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
|
||||
layoutNode={layoutTreeState.rootNode}
|
||||
layoutTreeState={layoutTreeState}
|
||||
dispatch={dispatch}
|
||||
setRef={setRef}
|
||||
deleteRef={deleteRef}
|
||||
nodeRefsAtom={nodeRefsAtom}
|
||||
showOverlayAtom={showOverlayAtom}
|
||||
siblingSize={layoutTreeState.rootNode?.size}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -295,7 +285,7 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
|
||||
);
|
||||
}
|
||||
|
||||
export const TileLayout = React.memo(TileLayoutComponent) as typeof TileLayoutComponent;
|
||||
export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent;
|
||||
|
||||
interface DisplayNodesWrapperProps<T> {
|
||||
/**
|
||||
@ -321,7 +311,7 @@ interface DisplayNodesWrapperProps<T> {
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const DisplayNodesWrapper = React.memo(
|
||||
const DisplayNodesWrapper = memo(
|
||||
<T,>({ layoutTreeState, contents, onLeafClose, layoutLeafTransforms, ready }: DisplayNodesWrapperProps<T>) => {
|
||||
if (!layoutLeafTransforms) {
|
||||
return null;
|
||||
@ -372,7 +362,7 @@ const dragItemType = "TILE_ITEM";
|
||||
/**
|
||||
* The draggable and displayable portion of a leaf node in a layout tree.
|
||||
*/
|
||||
const DisplayNode = React.memo(<T,>({ layoutNode, contents, transform, onLeafClose, ready }: DisplayNodeProps<T>) => {
|
||||
const DisplayNode = memo(<T,>({ layoutNode, contents, transform, onLeafClose, ready }: DisplayNodeProps<T>) => {
|
||||
const tileNodeRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLDivElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
@ -460,11 +450,10 @@ const DisplayNode = React.memo(<T,>({ layoutNode, contents, transform, onLeafClo
|
||||
ref={tileNodeRef}
|
||||
id={layoutNode.id}
|
||||
style={{
|
||||
flexDirection: layoutNode.flexDirection,
|
||||
flexBasis: layoutNode.size,
|
||||
...transform,
|
||||
}}
|
||||
onPointerEnter={generatePreviewImage}
|
||||
onPointerOver={(event) => event.stopPropagation()}
|
||||
>
|
||||
{leafContent}
|
||||
{previewElement}
|
||||
@ -486,27 +475,36 @@ interface OverlayNodeProps<T> {
|
||||
* @param action The action to perform.
|
||||
*/
|
||||
dispatch: (action: LayoutTreeAction) => void;
|
||||
/**
|
||||
* A callback to update the RefObject mapping corresponding to the layout node. Used to inform the TileLayout of changes to the OverlayNode's position and size.
|
||||
* @param id The id of the layout node being mounted.
|
||||
* @param ref The reference to the mounted overlay node.
|
||||
*/
|
||||
setRef: (id: string, ref: RefObject<HTMLDivElement>) => void;
|
||||
/**
|
||||
* A callback to remove the RefObject mapping corresponding to the layout node when it gets unmounted.
|
||||
* @param id The id of the layout node being unmounted.
|
||||
*/
|
||||
deleteRef: (id: string) => void;
|
||||
|
||||
nodeRefsAtom: PrimitiveAtom<NodeRefMap>;
|
||||
|
||||
showOverlayAtom: PrimitiveAtom<boolean>;
|
||||
|
||||
showResizeOverlay?: boolean;
|
||||
|
||||
siblingSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = <T,>({ layoutNode, layoutTreeState, dispatch, setRef, deleteRef }: OverlayNodeProps<T>) => {
|
||||
const OverlayNode = <T,>({
|
||||
layoutNode,
|
||||
layoutTreeState,
|
||||
dispatch,
|
||||
nodeRefsAtom,
|
||||
showOverlayAtom,
|
||||
showResizeOverlay,
|
||||
siblingSize,
|
||||
}: OverlayNodeProps<T>) => {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const leafRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setShowOverlay = useSetAtom(showOverlayAtom);
|
||||
|
||||
const setNodeRefs = useSetAtom(nodeRefsAtom);
|
||||
|
||||
const [, drop] = useDrop(
|
||||
() => ({
|
||||
accept: dragItemType,
|
||||
@ -555,30 +553,79 @@ const OverlayNode = <T,>({ layoutNode, layoutTreeState, dispatch, setRef, delete
|
||||
const layoutNodeId = layoutNode?.id;
|
||||
if (overlayRef?.current) {
|
||||
drop(overlayRef);
|
||||
setRef(layoutNodeId, overlayRef);
|
||||
setNodeRefs((nodeRefs) => {
|
||||
nodeRefs.set(layoutNodeId, overlayRef);
|
||||
return nodeRefs;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
deleteRef(layoutNodeId);
|
||||
setNodeRefs((nodeRefs) => {
|
||||
nodeRefs.delete(layoutNodeId);
|
||||
return nodeRefs;
|
||||
});
|
||||
};
|
||||
}, [overlayRef]);
|
||||
|
||||
function onPointerOverLeaf(event: React.PointerEvent<HTMLDivElement>) {
|
||||
event.stopPropagation();
|
||||
setShowOverlay(false);
|
||||
}
|
||||
|
||||
const [resizeOnCurrentNode, setResizeOnCurrentNode] = useState(false);
|
||||
const [pendingSize, setPendingSize] = useState<number>(undefined);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (showResizeOverlay) {
|
||||
setResizeOnCurrentNode(false);
|
||||
setPendingSize(undefined);
|
||||
return;
|
||||
}
|
||||
if (layoutTreeState.pendingAction?.type === LayoutTreeActionType.ResizeNode) {
|
||||
const resizeAction = layoutTreeState.pendingAction as LayoutTreeResizeNodeAction;
|
||||
const resizeOperation = resizeAction?.resizeOperations?.find(
|
||||
(operation) => operation.nodeId === layoutNode.id
|
||||
);
|
||||
if (resizeOperation) {
|
||||
setResizeOnCurrentNode(true);
|
||||
setPendingSize(resizeOperation.size);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setResizeOnCurrentNode(false);
|
||||
setPendingSize(undefined);
|
||||
}, [showResizeOverlay, layoutTreeState.pendingAction]);
|
||||
|
||||
const generateChildren = () => {
|
||||
if (Array.isArray(layoutNode.children)) {
|
||||
return layoutNode.children.map((childItem) => {
|
||||
return (
|
||||
const totalSize = layoutNode.children.reduce((partialSum, child) => partialSum + child.size, 0);
|
||||
return layoutNode.children
|
||||
.map((childItem, i) => {
|
||||
return [
|
||||
<OverlayNode
|
||||
key={childItem.id}
|
||||
layoutNode={childItem}
|
||||
layoutTreeState={layoutTreeState}
|
||||
dispatch={dispatch}
|
||||
setRef={setRef}
|
||||
deleteRef={deleteRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
nodeRefsAtom={nodeRefsAtom}
|
||||
showOverlayAtom={showOverlayAtom}
|
||||
showResizeOverlay={resizeOnCurrentNode || showResizeOverlay}
|
||||
siblingSize={totalSize}
|
||||
/>,
|
||||
<ResizeHandle
|
||||
key={`resize-${layoutNode.id}-${i}`}
|
||||
parentNode={layoutNode}
|
||||
index={i}
|
||||
dispatch={dispatch}
|
||||
nodeRefsAtom={nodeRefsAtom}
|
||||
siblingSize={totalSize}
|
||||
/>,
|
||||
];
|
||||
})
|
||||
.flat()
|
||||
.slice(0, -1);
|
||||
} else {
|
||||
return [<div ref={leafRef} key="leaf" className="overlay-leaf"></div>];
|
||||
return [<div ref={leafRef} key="leaf" className="overlay-leaf" onPointerOver={onPointerOverLeaf}></div>];
|
||||
}
|
||||
};
|
||||
|
||||
@ -586,13 +633,15 @@ const OverlayNode = <T,>({ layoutNode, layoutTreeState, dispatch, setRef, delete
|
||||
return null;
|
||||
}
|
||||
|
||||
const sizePercentage = ((pendingSize ?? layoutNode.size) / siblingSize) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="overlay-node"
|
||||
className={clsx("overlay-node", { resizing: resizeOnCurrentNode || showResizeOverlay })}
|
||||
id={layoutNode.id}
|
||||
style={{
|
||||
flexBasis: layoutNode.size,
|
||||
flexBasis: `${sizePercentage.toPrecision(5)}%`,
|
||||
flexDirection: layoutNode.flexDirection,
|
||||
}}
|
||||
>
|
||||
@ -601,6 +650,159 @@ const OverlayNode = <T,>({ layoutNode, layoutTreeState, dispatch, setRef, delete
|
||||
);
|
||||
};
|
||||
|
||||
interface ResizeHandleProps<T> {
|
||||
parentNode: LayoutNode<T>;
|
||||
index: number;
|
||||
dispatch: (action: LayoutTreeAction) => void;
|
||||
nodeRefsAtom: PrimitiveAtom<NodeRefMap>;
|
||||
siblingSize: number;
|
||||
}
|
||||
|
||||
const ResizeHandle = <T,>({ parentNode, index, dispatch, nodeRefsAtom, siblingSize }: ResizeHandleProps<T>) => {
|
||||
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The pointer currently captured, or undefined.
|
||||
const [trackingPointer, setTrackingPointer] = useState<number>(undefined);
|
||||
const nodeRefs = useAtomValue(nodeRefsAtom);
|
||||
|
||||
// Cached values set in startResize
|
||||
const [parentRect, setParentRect] = useState<Dimensions>();
|
||||
const [combinedNodesRect, setCombinedNodesRect] = useState<Dimensions>();
|
||||
const [gapSize, setGapSize] = useState(0);
|
||||
const [pixelToSizeRatio, setPixelToSizeRatio] = useState(0);
|
||||
|
||||
// Precompute some values that will be needed by the handlePointerMove function
|
||||
const startResize = useCallback(
|
||||
throttle(30, () => {
|
||||
const parentRef = nodeRefs.get(parentNode.id);
|
||||
const node1Ref = nodeRefs.get(parentNode.children![index].id);
|
||||
const node2Ref = nodeRefs.get(parentNode.children![index + 1].id);
|
||||
if (parentRef?.current && node1Ref?.current && node2Ref?.current) {
|
||||
const parentIsRow = parentNode.flexDirection === FlexDirection.Row;
|
||||
const parentRectNew = parentRef.current.getBoundingClientRect();
|
||||
setParentRect(parentRectNew);
|
||||
const node1Rect = node1Ref.current.getBoundingClientRect();
|
||||
const node2Rect = node2Ref.current.getBoundingClientRect();
|
||||
const gapSize = parentIsRow
|
||||
? node2Rect.left - (node1Rect.left + node1Rect.width)
|
||||
: node2Rect.top - (node1Rect.top + node1Rect.height);
|
||||
setGapSize(gapSize);
|
||||
const parentPixelsMinusGap =
|
||||
(parentIsRow ? parentRectNew.width : parentRectNew.height) -
|
||||
(gapSize * parentNode.children!.length - 1);
|
||||
const newPixelToSizeRatio = siblingSize / parentPixelsMinusGap;
|
||||
// console.log("newPixelToSizeRatio", newPixelToSizeRatio, siblingSize, parentPixelsMinusGap);
|
||||
setPixelToSizeRatio(newPixelToSizeRatio);
|
||||
const newCombinedNodesRect: Dimensions = {
|
||||
top: node1Rect.top,
|
||||
left: node1Rect.left,
|
||||
height: parentIsRow ? node1Rect.height : node1Rect.height + node2Rect.height + gapSize,
|
||||
width: parentIsRow ? node1Rect.width + node2Rect.width + gapSize : node1Rect.width,
|
||||
};
|
||||
setCombinedNodesRect(newCombinedNodesRect);
|
||||
// console.log(
|
||||
// "startResize",
|
||||
// parentNode,
|
||||
// index,
|
||||
// parentIsRow,
|
||||
// gapSize,
|
||||
// parentRectNew,
|
||||
// node1Rect,
|
||||
// node2Rect,
|
||||
// newCombinedNodesRect
|
||||
// );
|
||||
}
|
||||
}),
|
||||
[parentNode, nodeRefs]
|
||||
);
|
||||
|
||||
// Calculates the new size of the two nodes on either side of the handle, based on the position of the cursor
|
||||
const handlePointerMove = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const parentIsRow = parentNode.flexDirection === FlexDirection.Row;
|
||||
const combinedStart = parentIsRow ? combinedNodesRect.left : combinedNodesRect.top;
|
||||
const combinedEnd = parentIsRow
|
||||
? combinedNodesRect.left + combinedNodesRect.width
|
||||
: combinedNodesRect.top + combinedNodesRect.height;
|
||||
const clientPoint = parentIsRow ? clientX : clientY;
|
||||
// console.log("handlePointerMove", parentNode, index, clientX, clientY, parentRect, combinedNodesRect);
|
||||
if (clientPoint > combinedStart + 10 && clientPoint < combinedEnd - 10) {
|
||||
const halfGap = gapSize / 2;
|
||||
const sizeNode1 = clientPoint - combinedStart - halfGap;
|
||||
const sizeNode2 = combinedEnd - clientPoint + halfGap;
|
||||
const resizeAction: LayoutTreeResizeNodeAction = {
|
||||
type: LayoutTreeActionType.ResizeNode,
|
||||
resizeOperations: [
|
||||
{
|
||||
nodeId: parentNode.children![index].id,
|
||||
size: parseFloat((sizeNode1 * pixelToSizeRatio).toPrecision(5)),
|
||||
},
|
||||
{
|
||||
nodeId: parentNode.children![index + 1].id,
|
||||
size: parseFloat((sizeNode2 * pixelToSizeRatio).toPrecision(5)),
|
||||
},
|
||||
],
|
||||
};
|
||||
const setPendingAction: LayoutTreeSetPendingAction = {
|
||||
type: LayoutTreeActionType.SetPendingAction,
|
||||
action: resizeAction,
|
||||
};
|
||||
|
||||
dispatch(setPendingAction);
|
||||
}
|
||||
},
|
||||
[dispatch, parentNode, parentRect, combinedNodesRect, pixelToSizeRatio, gapSize]
|
||||
);
|
||||
|
||||
// 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);
|
||||
dispatch({ type: LayoutTreeActionType.CommitPendingAction });
|
||||
}),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Only track pointer moves if we have a captured pointer.
|
||||
const onPointerMove = useCallback(
|
||||
throttle(10, (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (trackingPointer === event.pointerId) {
|
||||
handlePointerMove(event.clientX, event.clientY);
|
||||
}
|
||||
}),
|
||||
[trackingPointer, handlePointerMove]
|
||||
);
|
||||
|
||||
// Don't render if we are dealing with the last child of a parent
|
||||
if (index + 1 >= parentNode.children!.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
className={clsx("resize-handle", `flex-${parentNode.flexDirection}`)}
|
||||
onPointerDown={onPointerDown}
|
||||
onGotPointerCapture={onPointerCapture}
|
||||
onLostPointerCapture={onPointerRelease}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerEnter={startResize}
|
||||
>
|
||||
<div className="line" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlaceholderProps<T> {
|
||||
/**
|
||||
* The layout tree state.
|
||||
@ -613,7 +815,7 @@ interface PlaceholderProps<T> {
|
||||
/**
|
||||
* The mapping of all layout nodes to their corresponding mounted overlay node.
|
||||
*/
|
||||
nodeRefs: Map<string, React.RefObject<HTMLElement>>;
|
||||
nodeRefsAtom: PrimitiveAtom<NodeRefMap>;
|
||||
/**
|
||||
* Any styling to apply to the placeholder container div.
|
||||
*/
|
||||
@ -623,9 +825,11 @@ interface PlaceholderProps<T> {
|
||||
/**
|
||||
* An overlay to preview pending actions on the layout tree.
|
||||
*/
|
||||
const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style }: PlaceholderProps<T>) => {
|
||||
const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefsAtom, style }: PlaceholderProps<T>) => {
|
||||
const [placeholderOverlay, setPlaceholderOverlay] = useState<ReactNode>(null);
|
||||
|
||||
const nodeRefs = useAtomValue(nodeRefsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
let newPlaceholderOverlay: ReactNode;
|
||||
if (overlayContainerRef?.current) {
|
||||
@ -698,10 +902,10 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
|
||||
break;
|
||||
}
|
||||
case LayoutTreeActionType.Swap: {
|
||||
const action = layoutTreeState.pendingAction as LayoutTreeSwapNodeAction<T>;
|
||||
const action = layoutTreeState.pendingAction as LayoutTreeSwapNodeAction;
|
||||
// console.log("placeholder for swap", action);
|
||||
const targetNode = action.node1;
|
||||
const targetRef = nodeRefs.get(targetNode?.id);
|
||||
const targetNodeId = action.node1Id;
|
||||
const targetRef = nodeRefs.get(targetNodeId);
|
||||
if (targetRef?.current) {
|
||||
const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
|
||||
const targetBoundingRect = targetRef.current.getBoundingClientRect();
|
||||
@ -723,7 +927,7 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
|
||||
}
|
||||
}
|
||||
setPlaceholderOverlay(newPlaceholderOverlay);
|
||||
}, [layoutTreeState, nodeRefs, overlayContainerRef]);
|
||||
}, [layoutTreeState.pendingAction, nodeRefs, overlayContainerRef]);
|
||||
|
||||
return (
|
||||
<div className="placeholder-container" style={style}>
|
||||
|
@ -1,7 +1,7 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { LayoutNode } from "./model";
|
||||
import { DefaultNodeSize, LayoutNode } from "./model";
|
||||
import { FlexDirection, getCrypto, reverseFlexDirection } from "./utils";
|
||||
|
||||
const crypto = getCrypto();
|
||||
@ -24,7 +24,7 @@ export function newLayoutNode<T>(
|
||||
const newNode: LayoutNode<T> = {
|
||||
id: crypto.randomUUID(),
|
||||
flexDirection: flexDirection ?? FlexDirection.Row,
|
||||
size,
|
||||
size: size ?? DefaultNodeSize,
|
||||
children,
|
||||
data,
|
||||
};
|
||||
@ -182,14 +182,14 @@ function balanceNodeHelper<T>(node: LayoutNode<T>, leafs: LayoutNode<T>[]): Layo
|
||||
leafs.push(node);
|
||||
return node;
|
||||
}
|
||||
if (node.children.length === 0) return;
|
||||
if (node.children.length == 0) return;
|
||||
if (!validateNode(node)) throw new Error("Invalid node");
|
||||
node.children = node.children
|
||||
.flatMap((child) => {
|
||||
if (child.flexDirection === node.flexDirection) {
|
||||
child.flexDirection = reverseFlexDirection(node.flexDirection);
|
||||
}
|
||||
if (child.children?.length === 1 && child.children[0].children) {
|
||||
if (child.children?.length == 1 && child.children[0].children) {
|
||||
return child.children[0].children;
|
||||
}
|
||||
return child;
|
||||
@ -198,7 +198,7 @@ function balanceNodeHelper<T>(node: LayoutNode<T>, leafs: LayoutNode<T>[]): Layo
|
||||
return balanceNodeHelper(child, leafs);
|
||||
})
|
||||
.filter((v) => v);
|
||||
if (node.children.length === 1 && !node.children[0].children) {
|
||||
if (node.children.length == 1 && !node.children[0].children) {
|
||||
node.data = node.children[0].data;
|
||||
node.id = node.children[0].id;
|
||||
node.children = undefined;
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
removeChild,
|
||||
} from "./layoutNode";
|
||||
import {
|
||||
DefaultNodeSize,
|
||||
LayoutNode,
|
||||
LayoutTreeAction,
|
||||
LayoutTreeActionType,
|
||||
@ -18,6 +19,8 @@ import {
|
||||
LayoutTreeDeleteNodeAction,
|
||||
LayoutTreeInsertNodeAction,
|
||||
LayoutTreeMoveNodeAction,
|
||||
LayoutTreeResizeNodeAction,
|
||||
LayoutTreeSetPendingAction,
|
||||
LayoutTreeState,
|
||||
LayoutTreeSwapNodeAction,
|
||||
MoveOperation,
|
||||
@ -70,8 +73,11 @@ function layoutTreeStateReducerInner<T>(layoutTreeState: LayoutTreeState<T>, act
|
||||
case LayoutTreeActionType.ComputeMove:
|
||||
computeMoveNode(layoutTreeState, action as LayoutTreeComputeMoveNodeAction<T>);
|
||||
break;
|
||||
case LayoutTreeActionType.SetPendingAction:
|
||||
setPendingAction(layoutTreeState, action as LayoutTreeSetPendingAction);
|
||||
break;
|
||||
case LayoutTreeActionType.ClearPendingAction:
|
||||
clearPendingAction(layoutTreeState);
|
||||
layoutTreeState.pendingAction = undefined;
|
||||
break;
|
||||
case LayoutTreeActionType.CommitPendingAction:
|
||||
if (!layoutTreeState?.pendingAction) {
|
||||
@ -79,6 +85,7 @@ function layoutTreeStateReducerInner<T>(layoutTreeState: LayoutTreeState<T>, act
|
||||
break;
|
||||
}
|
||||
layoutTreeStateReducerInner(layoutTreeState, layoutTreeState.pendingAction);
|
||||
layoutTreeState.pendingAction = undefined;
|
||||
break;
|
||||
case LayoutTreeActionType.Move:
|
||||
moveNode(layoutTreeState, action as LayoutTreeMoveNodeAction<T>);
|
||||
@ -93,7 +100,11 @@ function layoutTreeStateReducerInner<T>(layoutTreeState: LayoutTreeState<T>, act
|
||||
layoutTreeState.generation++;
|
||||
break;
|
||||
case LayoutTreeActionType.Swap:
|
||||
swapNode(layoutTreeState, action as LayoutTreeSwapNodeAction<T>);
|
||||
swapNode(layoutTreeState, action as LayoutTreeSwapNodeAction);
|
||||
layoutTreeState.generation++;
|
||||
break;
|
||||
case LayoutTreeActionType.ResizeNode:
|
||||
resizeNode(layoutTreeState, action as LayoutTreeResizeNodeAction);
|
||||
layoutTreeState.generation++;
|
||||
break;
|
||||
default: {
|
||||
@ -260,10 +271,10 @@ function computeMoveNode<T>(
|
||||
case DropDirection.Center:
|
||||
// console.log("center drop", rootNode, node, nodeToMove);
|
||||
if (node.id !== rootNode.id && nodeToMove.id !== rootNode.id) {
|
||||
const swapAction: LayoutTreeSwapNodeAction<T> = {
|
||||
const swapAction: LayoutTreeSwapNodeAction = {
|
||||
type: LayoutTreeActionType.Swap,
|
||||
node1: node,
|
||||
node2: nodeToMove,
|
||||
node1Id: node.id,
|
||||
node2Id: nodeToMove.id,
|
||||
};
|
||||
// console.log("swapAction", swapAction);
|
||||
layoutTreeState.pendingAction = swapAction;
|
||||
@ -287,8 +298,12 @@ function computeMoveNode<T>(
|
||||
} as LayoutTreeMoveNodeAction<T>;
|
||||
}
|
||||
|
||||
function clearPendingAction(layoutTreeState: LayoutTreeState<any>) {
|
||||
layoutTreeState.pendingAction = undefined;
|
||||
function setPendingAction(layoutTreeState: LayoutTreeState<any>, action: LayoutTreeSetPendingAction) {
|
||||
if (action.action === undefined) {
|
||||
console.error("setPendingAction: invalid pending action passed to function");
|
||||
return;
|
||||
}
|
||||
layoutTreeState.pendingAction = action.action;
|
||||
}
|
||||
|
||||
function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMoveNodeAction<T>) {
|
||||
@ -313,11 +328,16 @@ function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMove
|
||||
|
||||
// If moving under the same parent, we need to make sure that we are removing the child from its old position, not its new one.
|
||||
// If the new index is before the old index, we need to start our search for the node to delete after the new index position.
|
||||
if (oldParent && parent && oldParent.id === parent.id) {
|
||||
// If a node is being moved under the same parent, it can keep its size. Otherwise, it should get reset.
|
||||
if (oldParent && parent) {
|
||||
if (oldParent.id === parent.id) {
|
||||
const curIndexInParent = parent.children!.indexOf(node);
|
||||
if (curIndexInParent >= action.index) {
|
||||
startingIndex = action.index + 1;
|
||||
}
|
||||
} else {
|
||||
node.size = DefaultNodeSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (!parent && action.insertAtRoot) {
|
||||
@ -360,28 +380,37 @@ function insertNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeIn
|
||||
layoutTreeState.leafs = leafs;
|
||||
}
|
||||
|
||||
function swapNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeSwapNodeAction<T>) {
|
||||
function swapNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeSwapNodeAction) {
|
||||
console.log("swapNode", layoutTreeState, action);
|
||||
if (!action.node1 || !action.node2) {
|
||||
|
||||
if (!action.node1Id || !action.node2Id) {
|
||||
console.error("invalid swapNode action, both node1 and node2 must be defined");
|
||||
return;
|
||||
}
|
||||
if (action.node1.id === layoutTreeState.rootNode.id || action.node2.id === layoutTreeState.rootNode.id) {
|
||||
|
||||
if (action.node1Id === layoutTreeState.rootNode.id || action.node2Id === layoutTreeState.rootNode.id) {
|
||||
console.error("invalid swapNode action, the root node cannot be swapped");
|
||||
return;
|
||||
}
|
||||
if (action.node1.id === action.node2.id) {
|
||||
if (action.node1Id === action.node2Id) {
|
||||
console.error("invalid swapNode action, node1 and node2 are equal");
|
||||
return;
|
||||
}
|
||||
|
||||
const parentNode1 = findParent(layoutTreeState.rootNode, action.node1.id);
|
||||
const parentNode2 = findParent(layoutTreeState.rootNode, action.node2.id);
|
||||
const parentNode1Index = parentNode1.children!.findIndex((child) => child.id === action.node1.id);
|
||||
const parentNode2Index = parentNode2.children!.findIndex((child) => child.id === action.node2.id);
|
||||
const parentNode1 = findParent(layoutTreeState.rootNode, action.node1Id);
|
||||
const parentNode2 = findParent(layoutTreeState.rootNode, action.node2Id);
|
||||
const parentNode1Index = parentNode1.children!.findIndex((child) => child.id === action.node1Id);
|
||||
const parentNode2Index = parentNode2.children!.findIndex((child) => child.id === action.node2Id);
|
||||
|
||||
parentNode1.children[parentNode1Index] = action.node2;
|
||||
parentNode2.children[parentNode2Index] = action.node1;
|
||||
const node1 = parentNode1.children![parentNode1Index];
|
||||
const node2 = parentNode2.children![parentNode2Index];
|
||||
|
||||
const node1Size = node1.size;
|
||||
node1.size = node2.size;
|
||||
node2.size = node1Size;
|
||||
|
||||
parentNode1.children[parentNode1Index] = node2;
|
||||
parentNode2.children[parentNode2Index] = node1;
|
||||
|
||||
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
|
||||
layoutTreeState.rootNode = newRootNode;
|
||||
@ -415,3 +444,18 @@ function deleteNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeDe
|
||||
layoutTreeState.rootNode = newRootNode;
|
||||
layoutTreeState.leafs = leafs;
|
||||
}
|
||||
|
||||
function resizeNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeResizeNodeAction) {
|
||||
console.log("resizeNode", layoutTreeState, action);
|
||||
if (!action.resizeOperations) {
|
||||
console.error("invalid resizeNode operation. nodeSizes array must be defined.");
|
||||
}
|
||||
for (const resize of action.resizeOperations) {
|
||||
if (!resize.nodeId || resize.size < 0 || resize.size > 100) {
|
||||
console.error("invalid resizeNode operation. nodeId must be defined and size must be between 0 and 100");
|
||||
return;
|
||||
}
|
||||
const node = findNode(layoutTreeState.rootNode, resize.nodeId);
|
||||
node.size = resize.size;
|
||||
}
|
||||
}
|
||||
|
@ -33,11 +33,12 @@ export type MoveOperation<T> = {
|
||||
* Types of actions that modify the layout tree.
|
||||
*/
|
||||
export enum LayoutTreeActionType {
|
||||
ComputeMove = "compute",
|
||||
ComputeMove = "computemove",
|
||||
Move = "move",
|
||||
Swap = "swap",
|
||||
CommitPendingAction = "commit",
|
||||
ClearPendingAction = "clear",
|
||||
SetPendingAction = "setpending",
|
||||
CommitPendingAction = "commitpending",
|
||||
ClearPendingAction = "clearpending",
|
||||
ResizeNode = "resize",
|
||||
InsertNode = "insert",
|
||||
DeleteNode = "delete",
|
||||
@ -79,24 +80,17 @@ export interface LayoutTreeMoveNodeAction<T> extends LayoutTreeAction, MoveOpera
|
||||
*
|
||||
* @template T The type of data associated with the nodes of the tree.
|
||||
*/
|
||||
export interface LayoutTreeSwapNodeAction<T> extends LayoutTreeAction {
|
||||
export interface LayoutTreeSwapNodeAction extends LayoutTreeAction {
|
||||
type: LayoutTreeActionType.Swap;
|
||||
|
||||
/**
|
||||
* The node that node2 will replace.
|
||||
*/
|
||||
node1: LayoutNode<T>;
|
||||
node1Id: string;
|
||||
/**
|
||||
* The node that node1 will replace.
|
||||
*/
|
||||
node2: LayoutNode<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for committing a pending action to the layout tree.
|
||||
*/
|
||||
export interface LayoutTreeCommitPendingAction extends LayoutTreeAction {
|
||||
type: LayoutTreeActionType.CommitPendingAction;
|
||||
node2Id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -117,6 +111,25 @@ export interface LayoutTreeDeleteNodeAction extends LayoutTreeAction {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for setting the pendingAction field of the layout tree state.
|
||||
*/
|
||||
export interface LayoutTreeSetPendingAction extends LayoutTreeAction {
|
||||
type: LayoutTreeActionType.SetPendingAction;
|
||||
|
||||
/**
|
||||
* The new value for the pending action field.
|
||||
*/
|
||||
action: LayoutTreeAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for committing the action in the pendingAction field of the layout tree state.
|
||||
*/
|
||||
export interface LayoutTreeCommitPendingAction extends LayoutTreeAction {
|
||||
type: LayoutTreeActionType.CommitPendingAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for clearing the pendingAction field from the layout tree state.
|
||||
*/
|
||||
@ -124,6 +137,32 @@ export interface LayoutTreeClearPendingAction extends LayoutTreeAction {
|
||||
type: LayoutTreeActionType.ClearPendingAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* An operation to resize a node.
|
||||
*/
|
||||
export interface ResizeNodeOperation {
|
||||
/**
|
||||
* The id of the node to resize.
|
||||
*/
|
||||
nodeId: string;
|
||||
/**
|
||||
* The new size for the node.
|
||||
*/
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for resizing a node from the layout tree.
|
||||
*/
|
||||
export interface LayoutTreeResizeNodeAction extends LayoutTreeAction {
|
||||
type: LayoutTreeActionType.ResizeNode;
|
||||
|
||||
/**
|
||||
* A list of node ids to update and their respective new sizes.
|
||||
*/
|
||||
resizeOperations: ResizeNodeOperation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the state of a layout tree.
|
||||
*
|
||||
@ -145,7 +184,7 @@ export interface LayoutNode<T> {
|
||||
data?: T;
|
||||
children?: LayoutNode<T>[];
|
||||
flexDirection: FlexDirection;
|
||||
size?: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -170,3 +209,5 @@ export type PreviewRenderer<T> = (data: T) => React.ReactElement;
|
||||
export interface LayoutNodeWaveObj<T> extends WaveObj {
|
||||
node: LayoutNode<T>;
|
||||
}
|
||||
|
||||
export const DefaultNodeSize = 10;
|
||||
|
22
frontend/faraday/lib/nodeRefMap.ts
Normal file
22
frontend/faraday/lib/nodeRefMap.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export class NodeRefMap {
|
||||
private map: Map<string, React.RefObject<HTMLDivElement>> = new Map();
|
||||
generation: number = 0;
|
||||
|
||||
set(id: string, ref: React.RefObject<HTMLDivElement>) {
|
||||
this.map.set(id, ref);
|
||||
this.generation++;
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
if (this.map.has(id)) {
|
||||
this.map.delete(id);
|
||||
this.generation++;
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): React.RefObject<HTMLDivElement> {
|
||||
if (this.map.has(id)) {
|
||||
return this.map.get(id);
|
||||
}
|
||||
}
|
||||
}
|
@ -16,6 +16,8 @@
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.placeholder-container {
|
||||
@ -29,14 +31,38 @@
|
||||
.tile-node,
|
||||
.overlay-node {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
&.is-dragging {
|
||||
background-color: rgba(0, 255, 0, 0.3);
|
||||
.overlay-node {
|
||||
.resize-handle {
|
||||
&.flex-row {
|
||||
cursor: ew-resize;
|
||||
.line {
|
||||
height: 100%;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
&.flex-column {
|
||||
cursor: ns-resize;
|
||||
.line {
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
flex: 0 0 5px;
|
||||
&:hover {
|
||||
&.flex-row .line {
|
||||
border-left: 1px solid var(--accent-color);
|
||||
}
|
||||
&.flex-column .line {
|
||||
border-bottom: 1px solid var(--accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.resizing {
|
||||
border: 1px solid var(--accent-color);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,19 +96,16 @@
|
||||
|
||||
.tile-leaf,
|
||||
.overlay-leaf {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tile-leaf {
|
||||
border: 1px solid black;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
background-color: aqua;
|
||||
background-color: var(--accent-color);
|
||||
opacity: 0.5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
2
frontend/types/custom.d.ts
vendored
2
frontend/types/custom.d.ts
vendored
@ -1,6 +1,8 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type * as rxjs from "rxjs";
|
||||
|
||||
declare global {
|
||||
type TabLayoutData = {
|
||||
blockId: string;
|
||||
|
Loading…
Reference in New Issue
Block a user