diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 6708ed85..d5cbe107 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -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; diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less index 368df48a..7ebf9ac8 100644 --- a/frontend/app/block/block.less +++ b/frontend/app/block/block.less @@ -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 { diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 31e5c135..902a3821 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -224,7 +224,7 @@ const BlockFrame_Tech_Component = ({ const isFocusedAtom = useBlockAtom(blockId, "isFocused", () => { return jotai.atom((get) => { const winData = get(atoms.waveWindow); - return winData.activeblockid === blockId; + return winData?.activeblockid === blockId; }); }); let isFocused = jotai.useAtomValue(isFocusedAtom); diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index ab457272..5ff18e06 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -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; } diff --git a/frontend/app/tab/tabcontent.less b/frontend/app/tab/tabcontent.less index 982788fc..cf4f5e5e 100644 --- a/frontend/app/tab/tabcontent.less +++ b/frontend/app/tab/tabcontent.less @@ -11,6 +11,7 @@ justify-content: center; overflow: hidden; position: relative; + margin: 3px; .block-container { display: flex; diff --git a/frontend/app/tab/tabcontent.tsx b/frontend/app/tab/tabcontent.tsx index 64452aca..87646da4 100644 --- a/frontend/app/tab/tabcontent.tsx +++ b/frontend/app/tab/tabcontent.tsx @@ -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 }) => { diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index d140eae2..66c0ba59 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -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(blockId, "isFocused", () => { return jotai.atom((get) => { const winData = get(atoms.waveWindow); - return winData.activeblockid === blockId; + return winData?.activeblockid === blockId; }); }); const termSettingsAtom = useSettingsAtom("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, diff --git a/frontend/faraday/lib/TileLayout.stories.tsx b/frontend/faraday/lib/TileLayout.stories.tsx index 33876b8b..5682aef1 100644 --- a/frontend/faraday/lib/TileLayout.stories.tsx +++ b/frontend/faraday/lib/TileLayout.stories.tsx @@ -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( - newLayoutNode(FlexDirection.Row, undefined, undefined, { + newLayoutNode(undefined, undefined, undefined, { name: "Hello world!", }) ), @@ -52,7 +51,7 @@ type Story = StoryObj; export const Basic: Story = { args: { layoutTreeStateAtom: newLayoutTreeStateAtom( - 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( - 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(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(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 = { diff --git a/frontend/faraday/lib/TileLayout.tsx b/frontend/faraday/lib/TileLayout.tsx index faf6b471..66b2507f 100644 --- a/frontend/faraday/lib/TileLayout.tsx +++ b/frontend/faraday/lib/TileLayout.tsx @@ -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 { */ 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({ layoutTreeStateAtom, contents, getCursorPoint }: TileLayoutProps) { const overlayContainerRef = useRef(null); const displayContainerRef = useRef(null); - - const [layoutTreeState, dispatch] = useLayoutTreeStateReducerAtom(layoutTreeStateAtom); - const [nodeRefs, setNodeRefs] = useState>>(new Map()); - const [nodeRefsGen, setNodeRefsGen] = useState(0); - - // useEffect(() => { - // console.log("layoutTreeState changed", layoutTreeState); - // }, [layoutTreeState]); - - const setRef = useCallback( - (id: string, ref: RefObject) => { - 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>(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>(atom(false)); + const [showOverlay, setShowOverlay] = useAtom(showOverlayAtom); + + function onPointerOver() { + setShowOverlay(true); + } - 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); - } - }, - [nodeRefs, setNodeRefs] - ); const [overlayTransform, setOverlayTransform] = useState(); const [layoutLeafTransforms, setLayoutLeafTransformsRaw] = useState>({}); @@ -166,6 +159,8 @@ function TileLayoutComponent({ 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({ 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({ 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({ 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({ layoutTreeStateAtom, contents, getCursorPoint return ( -
+
({ layoutTreeStateAtom, contents, getCursorPoint key="placeholder" layoutTreeState={layoutTreeState} overlayContainerRef={overlayContainerRef} - nodeRefs={nodeRefs} + nodeRefsAtom={nodeRefsAtom} style={{ top: 10000, ...overlayTransform }} />
({ layoutTreeStateAtom, contents, getCursorPoint layoutNode={layoutTreeState.rootNode} layoutTreeState={layoutTreeState} dispatch={dispatch} - setRef={setRef} - deleteRef={deleteRef} + nodeRefsAtom={nodeRefsAtom} + showOverlayAtom={showOverlayAtom} + siblingSize={layoutTreeState.rootNode?.size} />
@@ -295,7 +285,7 @@ function TileLayoutComponent({ layoutTreeStateAtom, contents, getCursorPoint ); } -export const TileLayout = React.memo(TileLayoutComponent) as typeof TileLayoutComponent; +export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; interface DisplayNodesWrapperProps { /** @@ -321,7 +311,7 @@ interface DisplayNodesWrapperProps { ready: boolean; } -const DisplayNodesWrapper = React.memo( +const DisplayNodesWrapper = memo( ({ layoutTreeState, contents, onLeafClose, layoutLeafTransforms, ready }: DisplayNodesWrapperProps) => { 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(({ layoutNode, contents, transform, onLeafClose, ready }: DisplayNodeProps) => { +const DisplayNode = memo(({ layoutNode, contents, transform, onLeafClose, ready }: DisplayNodeProps) => { const tileNodeRef = useRef(null); const dragHandleRef = useRef(null); const previewRef = useRef(null); @@ -460,11 +450,10 @@ const DisplayNode = React.memo(({ 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 { * @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) => 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; + + showOverlayAtom: PrimitiveAtom; + + 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 = ({ layoutNode, layoutTreeState, dispatch, setRef, deleteRef }: OverlayNodeProps) => { +const OverlayNode = ({ + layoutNode, + layoutTreeState, + dispatch, + nodeRefsAtom, + showOverlayAtom, + showResizeOverlay, + siblingSize, +}: OverlayNodeProps) => { const overlayRef = useRef(null); const leafRef = useRef(null); + const setShowOverlay = useSetAtom(showOverlayAtom); + + const setNodeRefs = useSetAtom(nodeRefsAtom); + const [, drop] = useDrop( () => ({ accept: dragItemType, @@ -555,30 +553,79 @@ const OverlayNode = ({ 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) { + event.stopPropagation(); + setShowOverlay(false); + } + + const [resizeOnCurrentNode, setResizeOnCurrentNode] = useState(false); + const [pendingSize, setPendingSize] = useState(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 [ + , + , + ]; + }) + .flat() + .slice(0, -1); } else { - return [
]; + return [
]; } }; @@ -586,13 +633,15 @@ const OverlayNode = ({ layoutNode, layoutTreeState, dispatch, setRef, delete return null; } + const sizePercentage = ((pendingSize ?? layoutNode.size) / siblingSize) * 100; + return (
@@ -601,6 +650,159 @@ const OverlayNode = ({ layoutNode, layoutTreeState, dispatch, setRef, delete ); }; +interface ResizeHandleProps { + parentNode: LayoutNode; + index: number; + dispatch: (action: LayoutTreeAction) => void; + nodeRefsAtom: PrimitiveAtom; + siblingSize: number; +} + +const ResizeHandle = ({ parentNode, index, dispatch, nodeRefsAtom, siblingSize }: ResizeHandleProps) => { + const resizeHandleRef = useRef(null); + + // The pointer currently captured, or undefined. + const [trackingPointer, setTrackingPointer] = useState(undefined); + const nodeRefs = useAtomValue(nodeRefsAtom); + + // Cached values set in startResize + const [parentRect, setParentRect] = useState(); + const [combinedNodesRect, setCombinedNodesRect] = useState(); + 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) { + 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) { + 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) => { + 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) => { + 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 ( +
+
+
+ ); +}; + interface PlaceholderProps { /** * The layout tree state. @@ -613,7 +815,7 @@ interface PlaceholderProps { /** * The mapping of all layout nodes to their corresponding mounted overlay node. */ - nodeRefs: Map>; + nodeRefsAtom: PrimitiveAtom; /** * Any styling to apply to the placeholder container div. */ @@ -623,9 +825,11 @@ interface PlaceholderProps { /** * An overlay to preview pending actions on the layout tree. */ -const Placeholder = ({ layoutTreeState, overlayContainerRef, nodeRefs, style }: PlaceholderProps) => { +const Placeholder = ({ layoutTreeState, overlayContainerRef, nodeRefsAtom, style }: PlaceholderProps) => { const [placeholderOverlay, setPlaceholderOverlay] = useState(null); + const nodeRefs = useAtomValue(nodeRefsAtom); + useEffect(() => { let newPlaceholderOverlay: ReactNode; if (overlayContainerRef?.current) { @@ -698,10 +902,10 @@ const Placeholder = ({ layoutTreeState, overlayContainerRef, nodeRefs, style break; } case LayoutTreeActionType.Swap: { - const action = layoutTreeState.pendingAction as LayoutTreeSwapNodeAction; + 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 = ({ layoutTreeState, overlayContainerRef, nodeRefs, style } } setPlaceholderOverlay(newPlaceholderOverlay); - }, [layoutTreeState, nodeRefs, overlayContainerRef]); + }, [layoutTreeState.pendingAction, nodeRefs, overlayContainerRef]); return (
diff --git a/frontend/faraday/lib/layoutNode.ts b/frontend/faraday/lib/layoutNode.ts index 6ed7f983..2ab593d1 100644 --- a/frontend/faraday/lib/layoutNode.ts +++ b/frontend/faraday/lib/layoutNode.ts @@ -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( const newNode: LayoutNode = { id: crypto.randomUUID(), flexDirection: flexDirection ?? FlexDirection.Row, - size, + size: size ?? DefaultNodeSize, children, data, }; @@ -182,14 +182,14 @@ function balanceNodeHelper(node: LayoutNode, leafs: LayoutNode[]): 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(node: LayoutNode, leafs: LayoutNode[]): 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; diff --git a/frontend/faraday/lib/layoutState.ts b/frontend/faraday/lib/layoutState.ts index 8a27de7a..c3482e5a 100644 --- a/frontend/faraday/lib/layoutState.ts +++ b/frontend/faraday/lib/layoutState.ts @@ -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(layoutTreeState: LayoutTreeState, act case LayoutTreeActionType.ComputeMove: computeMoveNode(layoutTreeState, action as LayoutTreeComputeMoveNodeAction); 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(layoutTreeState: LayoutTreeState, act break; } layoutTreeStateReducerInner(layoutTreeState, layoutTreeState.pendingAction); + layoutTreeState.pendingAction = undefined; break; case LayoutTreeActionType.Move: moveNode(layoutTreeState, action as LayoutTreeMoveNodeAction); @@ -93,7 +100,11 @@ function layoutTreeStateReducerInner(layoutTreeState: LayoutTreeState, act layoutTreeState.generation++; break; case LayoutTreeActionType.Swap: - swapNode(layoutTreeState, action as LayoutTreeSwapNodeAction); + 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( case DropDirection.Center: // console.log("center drop", rootNode, node, nodeToMove); if (node.id !== rootNode.id && nodeToMove.id !== rootNode.id) { - const swapAction: LayoutTreeSwapNodeAction = { + 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( } as LayoutTreeMoveNodeAction; } -function clearPendingAction(layoutTreeState: LayoutTreeState) { - layoutTreeState.pendingAction = undefined; +function setPendingAction(layoutTreeState: LayoutTreeState, action: LayoutTreeSetPendingAction) { + if (action.action === undefined) { + console.error("setPendingAction: invalid pending action passed to function"); + return; + } + layoutTreeState.pendingAction = action.action; } function moveNode(layoutTreeState: LayoutTreeState, action: LayoutTreeMoveNodeAction) { @@ -313,10 +328,15 @@ function moveNode(layoutTreeState: LayoutTreeState, 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) { - const curIndexInParent = parent.children!.indexOf(node); - if (curIndexInParent >= action.index) { - startingIndex = action.index + 1; + // 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; } } @@ -360,28 +380,37 @@ function insertNode(layoutTreeState: LayoutTreeState, action: LayoutTreeIn layoutTreeState.leafs = leafs; } -function swapNode(layoutTreeState: LayoutTreeState, action: LayoutTreeSwapNodeAction) { +function swapNode(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, action: LayoutTreeDe layoutTreeState.rootNode = newRootNode; layoutTreeState.leafs = leafs; } + +function resizeNode(layoutTreeState: LayoutTreeState, 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; + } +} diff --git a/frontend/faraday/lib/model.ts b/frontend/faraday/lib/model.ts index e5cdf63d..096e60f1 100644 --- a/frontend/faraday/lib/model.ts +++ b/frontend/faraday/lib/model.ts @@ -33,11 +33,12 @@ export type MoveOperation = { * 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 extends LayoutTreeAction, MoveOpera * * @template T The type of data associated with the nodes of the tree. */ -export interface LayoutTreeSwapNodeAction extends LayoutTreeAction { +export interface LayoutTreeSwapNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.Swap; /** * The node that node2 will replace. */ - node1: LayoutNode; + node1Id: string; /** * The node that node1 will replace. */ - node2: LayoutNode; -} - -/** - * 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 { data?: T; children?: LayoutNode[]; flexDirection: FlexDirection; - size?: number; + size: number; } /** @@ -170,3 +209,5 @@ export type PreviewRenderer = (data: T) => React.ReactElement; export interface LayoutNodeWaveObj extends WaveObj { node: LayoutNode; } + +export const DefaultNodeSize = 10; diff --git a/frontend/faraday/lib/nodeRefMap.ts b/frontend/faraday/lib/nodeRefMap.ts new file mode 100644 index 00000000..e6ec6cc3 --- /dev/null +++ b/frontend/faraday/lib/nodeRefMap.ts @@ -0,0 +1,22 @@ +export class NodeRefMap { + private map: Map> = new Map(); + generation: number = 0; + + set(id: string, ref: React.RefObject) { + 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 { + if (this.map.has(id)) { + return this.map.get(id); + } + } +} diff --git a/frontend/faraday/lib/tilelayout.less b/frontend/faraday/lib/tilelayout.less index 21bd249c..dc5c22b7 100644 --- a/frontend/faraday/lib/tilelayout.less +++ b/frontend/faraday/lib/tilelayout.less @@ -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; } diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 98ad76da..768b8c19 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -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;