From 48d4611a05bba9bbb402572688b154527b2c4b58 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 17 Jun 2024 14:14:09 -0700 Subject: [PATCH] Add Swap Node functionality (#56) Adds the ability to swap nodes by dragging to the center of a tile. Also fixes a bug where moving a node to a new lesser index under the same parent would produce a no-op. --- frontend/faraday/lib/TileLayout.tsx | 145 ++++++++++++++++----------- frontend/faraday/lib/layoutNode.ts | 5 +- frontend/faraday/lib/layoutState.ts | 107 ++++++++++++++++---- frontend/faraday/lib/model.ts | 8 ++ frontend/faraday/lib/utils.ts | 13 ++- frontend/faraday/tests/utils.test.ts | 23 +++-- 6 files changed, 205 insertions(+), 96 deletions(-) diff --git a/frontend/faraday/lib/TileLayout.tsx b/frontend/faraday/lib/TileLayout.tsx index 1dac9ead3..1fe0e08fb 100644 --- a/frontend/faraday/lib/TileLayout.tsx +++ b/frontend/faraday/lib/TileLayout.tsx @@ -29,6 +29,7 @@ import { LayoutTreeDeleteNodeAction, LayoutTreeMoveNodeAction, LayoutTreeState, + LayoutTreeSwapNodeAction, PreviewRenderer, WritableLayoutTreeStateAtom, } from "./model"; @@ -518,68 +519,98 @@ const Placeholder = ({ layoutTreeState, overlayContainerRef, nodeRefs, style useEffect(() => { let newPlaceholderOverlay: ReactNode; - if (layoutTreeState?.pendingAction?.type === LayoutTreeActionType.Move && overlayContainerRef?.current) { - const action = layoutTreeState.pendingAction as LayoutTreeMoveNodeAction; - let parentId: string; - if (action.insertAtRoot) { - parentId = layoutTreeState.rootNode.id; - } else { - parentId = action.parentId; - } + if (overlayContainerRef?.current) { + switch (layoutTreeState?.pendingAction?.type) { + case LayoutTreeActionType.Move: { + const action = layoutTreeState.pendingAction as LayoutTreeMoveNodeAction; + let parentId: string; + if (action.insertAtRoot) { + parentId = layoutTreeState.rootNode.id; + } else { + parentId = action.parentId; + } - const parentNode = findNode(layoutTreeState.rootNode, parentId); - if (action.index !== undefined && parentNode) { - const targetIndex = Math.min( - parentNode.children ? parentNode.children.length - 1 : 0, - Math.max(0, action.index - 1) - ); - let targetNode = parentNode?.children?.at(targetIndex); - let targetRef: React.RefObject; - if (targetNode) { - targetRef = nodeRefs.get(targetNode.id); - } else { - targetRef = nodeRefs.get(parentNode.id); - targetNode = parentNode; - } - if (targetRef?.current) { - const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); - const targetBoundingRect = targetRef.current.getBoundingClientRect(); - - // Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent. - // Default to placing the placeholder in the first half of the target node. - const placeholderDimensions: Dimensions = { - height: - parentNode.flexDirection === FlexDirection.Column - ? targetBoundingRect.height / 2 - : targetBoundingRect.height, - width: - parentNode.flexDirection === FlexDirection.Row - ? targetBoundingRect.width / 2 - : targetBoundingRect.width, - top: targetBoundingRect.top - overlayBoundingRect.top, - left: targetBoundingRect.left - overlayBoundingRect.left, - }; - - if (action.index > targetIndex) { - if (action.index >= (parentNode.children?.length ?? 1)) { - // If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom). - placeholderDimensions.top += - parentNode.flexDirection === FlexDirection.Column && targetBoundingRect.height / 2; - placeholderDimensions.left += - parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2; + const parentNode = findNode(layoutTreeState.rootNode, parentId); + if (action.index !== undefined && parentNode) { + const targetIndex = Math.min( + parentNode.children ? parentNode.children.length - 1 : 0, + Math.max(0, action.index - 1) + ); + let targetNode = parentNode?.children?.at(targetIndex); + let targetRef: React.RefObject; + if (targetNode) { + targetRef = nodeRefs.get(targetNode.id); } else { - // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node - placeholderDimensions.top += - parentNode.flexDirection === FlexDirection.Column && - (3 * targetBoundingRect.height) / 4; - placeholderDimensions.left += - parentNode.flexDirection === FlexDirection.Row && (3 * targetBoundingRect.width) / 4; + targetRef = nodeRefs.get(parentNode.id); + targetNode = parentNode; + } + if (targetRef?.current) { + const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); + const targetBoundingRect = targetRef.current.getBoundingClientRect(); + + // Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent. + // Default to placing the placeholder in the first half of the target node. + const placeholderDimensions: Dimensions = { + height: + parentNode.flexDirection === FlexDirection.Column + ? targetBoundingRect.height / 2 + : targetBoundingRect.height, + width: + parentNode.flexDirection === FlexDirection.Row + ? targetBoundingRect.width / 2 + : targetBoundingRect.width, + top: targetBoundingRect.top - overlayBoundingRect.top, + left: targetBoundingRect.left - overlayBoundingRect.left, + }; + + if (action.index > targetIndex) { + if (action.index >= (parentNode.children?.length ?? 1)) { + // If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom). + placeholderDimensions.top += + parentNode.flexDirection === FlexDirection.Column && + targetBoundingRect.height / 2; + placeholderDimensions.left += + parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2; + } else { + // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node + placeholderDimensions.top += + parentNode.flexDirection === FlexDirection.Column && + (3 * targetBoundingRect.height) / 4; + placeholderDimensions.left += + parentNode.flexDirection === FlexDirection.Row && + (3 * targetBoundingRect.width) / 4; + } + } + + const placeholderTransform = createTransform(placeholderDimensions); + newPlaceholderOverlay =
; } } - const placeholderTransform = createTransform(placeholderDimensions); - - newPlaceholderOverlay =
; + break; } + case LayoutTreeActionType.Swap: { + const action = layoutTreeState.pendingAction as LayoutTreeSwapNodeAction; + console.log("placeholder for swap", action); + const targetNode = action.node1; + const targetRef = nodeRefs.get(targetNode?.id); + if (targetRef?.current) { + const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); + const targetBoundingRect = targetRef.current.getBoundingClientRect(); + const placeholderDimensions: Dimensions = { + top: targetBoundingRect.top - overlayBoundingRect.top, + left: targetBoundingRect.left - overlayBoundingRect.left, + height: targetBoundingRect.height, + width: targetBoundingRect.width, + }; + + const placeholderTransform = createTransform(placeholderDimensions); + newPlaceholderOverlay =
; + } + break; + } + default: + // No-op + break; } } setPlaceholderOverlay(newPlaceholderOverlay); diff --git a/frontend/faraday/lib/layoutNode.ts b/frontend/faraday/lib/layoutNode.ts index ef157b1b0..6ed7f9830 100644 --- a/frontend/faraday/lib/layoutNode.ts +++ b/frontend/faraday/lib/layoutNode.ts @@ -99,12 +99,13 @@ export function addIntermediateNode(node: LayoutNode): LayoutNode { * Attempts to remove the specified node from its parent. * @param parent The parent node. * @param childToRemove The node to remove. + * @param startingIndex The index in children to start the search from. * @template T The type of data associated with the node. * @returns The updated parent node, or undefined if the node was not found. */ -export function removeChild(parent: LayoutNode, childToRemove: LayoutNode) { +export function removeChild(parent: LayoutNode, childToRemove: LayoutNode, startingIndex: number = 0) { if (!parent.children) return; - const idx = parent.children.indexOf(childToRemove); + const idx = parent.children.indexOf(childToRemove, startingIndex); if (idx === -1) return; parent.children?.splice(idx, 1); } diff --git a/frontend/faraday/lib/layoutState.ts b/frontend/faraday/lib/layoutState.ts index 39d4d7d66..df318a207 100644 --- a/frontend/faraday/lib/layoutState.ts +++ b/frontend/faraday/lib/layoutState.ts @@ -19,6 +19,7 @@ import { LayoutTreeInsertNodeAction, LayoutTreeMoveNodeAction, LayoutTreeState, + LayoutTreeSwapNodeAction, MoveOperation, } from "./model"; import { DropDirection, FlexDirection, lazy } from "./utils"; @@ -88,6 +89,10 @@ function layoutTreeStateReducerInner(layoutTreeState: LayoutTreeState, act deleteNode(layoutTreeState, action as LayoutTreeDeleteNodeAction); layoutTreeState.generation++; break; + case LayoutTreeActionType.Swap: + swapNode(layoutTreeState, action as LayoutTreeSwapNodeAction); + layoutTreeState.generation++; + break; default: { console.error("Invalid reducer action", layoutTreeState, action); } @@ -114,7 +119,12 @@ function computeMoveNode( return; } - let newOperation: MoveOperation; + if (node.id === nodeToMove.id) { + console.warn("Cannot compute move node action since both nodes are equal"); + return; + } + + let newMoveOperation: MoveOperation; const parent = lazy(() => findParent(rootNode, node.id)); const grandparent = lazy(() => findParent(rootNode, parent().id)); const indexInParent = lazy(() => parent()?.children.findIndex((child) => node.id === child.id)); @@ -129,7 +139,7 @@ function computeMoveNode( if (grandparentNode) { console.log("has grandparent", grandparentNode); const index = indexInGrandparent(); - newOperation = { + newMoveOperation = { parentId: grandparentNode.id, node: nodeToMove, index, @@ -139,10 +149,10 @@ function computeMoveNode( } case DropDirection.Top: if (node.flexDirection === FlexDirection.Column) { - newOperation = { parentId: node.id, index: 0, node: nodeToMove }; + newMoveOperation = { parentId: node.id, index: 0, node: nodeToMove }; } else { if (isRoot) - newOperation = { + newMoveOperation = { node: nodeToMove, index: 0, insertAtRoot: true, @@ -150,7 +160,7 @@ function computeMoveNode( const parentNode = parent(); if (parentNode) - newOperation = { + newMoveOperation = { parentId: parentNode.id, index: indexInParent() ?? 0, node: nodeToMove, @@ -164,7 +174,7 @@ function computeMoveNode( if (grandparentNode) { console.log("has grandparent", grandparentNode); const index = indexInGrandparent() + 1; - newOperation = { + newMoveOperation = { parentId: grandparentNode.id, node: nodeToMove, index, @@ -174,10 +184,10 @@ function computeMoveNode( } case DropDirection.Bottom: if (node.flexDirection === FlexDirection.Column) { - newOperation = { parentId: node.id, index: 1, node: nodeToMove }; + newMoveOperation = { parentId: node.id, index: 1, node: nodeToMove }; } else { if (isRoot) - newOperation = { + newMoveOperation = { node: nodeToMove, index: 1, insertAtRoot: true, @@ -185,7 +195,7 @@ function computeMoveNode( const parentNode = parent(); if (parentNode) - newOperation = { + newMoveOperation = { parentId: parentNode.id, index: indexInParent() + 1, node: nodeToMove, @@ -199,7 +209,7 @@ function computeMoveNode( if (grandparentNode) { console.log("has grandparent", grandparentNode); const index = indexInGrandparent(); - newOperation = { + newMoveOperation = { parentId: grandparentNode.id, node: nodeToMove, index, @@ -209,11 +219,11 @@ function computeMoveNode( } case DropDirection.Left: if (node.flexDirection === FlexDirection.Row) { - newOperation = { parentId: node.id, index: 0, node: nodeToMove }; + newMoveOperation = { parentId: node.id, index: 0, node: nodeToMove }; } else { const parentNode = parent(); if (parentNode) - newOperation = { + newMoveOperation = { parentId: parentNode.id, index: indexInParent(), node: nodeToMove, @@ -227,7 +237,7 @@ function computeMoveNode( if (grandparentNode) { console.log("has grandparent", grandparentNode); const index = indexInGrandparent() + 1; - newOperation = { + newMoveOperation = { parentId: grandparentNode.id, node: nodeToMove, index, @@ -237,11 +247,11 @@ function computeMoveNode( } case DropDirection.Right: if (node.flexDirection === FlexDirection.Row) { - newOperation = { parentId: node.id, index: 1, node: nodeToMove }; + newMoveOperation = { parentId: node.id, index: 1, node: nodeToMove }; } else { const parentNode = parent(); if (parentNode) - newOperation = { + newMoveOperation = { parentId: parentNode.id, index: indexInParent() + 1, node: nodeToMove, @@ -249,14 +259,28 @@ function computeMoveNode( } break; case DropDirection.Center: - // TODO: handle center drop - console.log("center drop"); + console.log("center drop", rootNode, node, nodeToMove); + if (node.id !== rootNode.id && nodeToMove.id !== rootNode.id) { + const swapAction: LayoutTreeSwapNodeAction = { + type: LayoutTreeActionType.Swap, + node1: node, + node2: nodeToMove, + }; + console.log("swapAction", swapAction); + layoutTreeState.pendingAction = swapAction; + } else { + console.warn("cannot swap"); + } break; default: throw new Error(`Invalid direction: ${direction}`); } - if (newOperation) layoutTreeState.pendingAction = { type: LayoutTreeActionType.Move, ...newOperation }; + if (newMoveOperation) + layoutTreeState.pendingAction = { + type: LayoutTreeActionType.Move, + ...newMoveOperation, + } as LayoutTreeMoveNodeAction; } function moveNode(layoutTreeState: LayoutTreeState, action: LayoutTreeMoveNodeAction) { @@ -271,12 +295,23 @@ function moveNode(layoutTreeState: LayoutTreeState, action: LayoutTreeMove return; } - let node = findNode(rootNode, action.node.id) ?? action.node; - let parent = findNode(rootNode, action.parentId); - let oldParent = findParent(rootNode, action.node.id); + const node = findNode(rootNode, action.node.id) ?? action.node; + const parent = findNode(rootNode, action.parentId); + const oldParent = findParent(rootNode, action.node.id); console.log(node, parent, oldParent); + let startingIndex = 0; + + // 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?.id === parent.id) { + const curIndexInParent = parent.children!.indexOf(node); + if (curIndexInParent >= action.index) { + startingIndex = action.index + 1; + } + } + if (!parent && action.insertAtRoot) { if (!rootNode.children) { addIntermediateNode(rootNode); @@ -290,7 +325,7 @@ function moveNode(layoutTreeState: LayoutTreeState, action: LayoutTreeMove // Remove nodeToInsert from its old parent if (oldParent) { - removeChild(oldParent, node); + removeChild(oldParent, node, startingIndex); } const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode); @@ -317,6 +352,34 @@ function insertNode(layoutTreeState: LayoutTreeState, action: LayoutTreeIn layoutTreeState.leafs = leafs; } +function swapNode(layoutTreeState: LayoutTreeState, action: LayoutTreeSwapNodeAction) { + console.log("swapNode", layoutTreeState, action); + if (!action.node1 || !action.node2) { + 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) { + console.error("invalid swapNode action, the root node cannot be swapped"); + return; + } + if (action.node1.id === action.node2.id) { + 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); + + parentNode1.children[parentNode1Index] = action.node2; + parentNode2.children[parentNode2Index] = action.node1; + + const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode); + layoutTreeState.rootNode = newRootNode; + layoutTreeState.leafs = leafs; +} + function deleteNode(layoutTreeState: LayoutTreeState, action: LayoutTreeDeleteNodeAction) { console.log("deleteNode", layoutTreeState, action); if (!action?.nodeId) { diff --git a/frontend/faraday/lib/model.ts b/frontend/faraday/lib/model.ts index f804888af..049d2ca91 100644 --- a/frontend/faraday/lib/model.ts +++ b/frontend/faraday/lib/model.ts @@ -35,6 +35,7 @@ export type MoveOperation = { export enum LayoutTreeActionType { ComputeMove = "computeMove", Move = "move", + Swap = "swap", CommitPendingAction = "commit", ResizeNode = "resize", InsertNode = "insert", @@ -72,6 +73,13 @@ export interface LayoutTreeMoveNodeAction extends LayoutTreeAction, MoveOpera type: LayoutTreeActionType.Move; } +export interface LayoutTreeSwapNodeAction extends LayoutTreeAction { + type: LayoutTreeActionType.Swap; + + node1: LayoutNode; + node2: LayoutNode; +} + /** * Action for committing a pending action to the layout tree. */ diff --git a/frontend/faraday/lib/utils.ts b/frontend/faraday/lib/utils.ts index 191edef7c..35a5483b7 100644 --- a/frontend/faraday/lib/utils.ts +++ b/frontend/faraday/lib/utils.ts @@ -43,14 +43,13 @@ export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord // Lies outside of the box if (y < 0 || y > height || x < 0 || x > width) return undefined; - // TODO: uncomment once center drop is supported - // // Determines if a drop point falls within the center fifth of the box, meaning we should return Center. - // const centerX1 = (2 * width) / 5; - // const centerX2 = (3 * width) / 5; - // const centerY1 = (2 * height) / 5; - // const centerY2 = (3 * width) / 5; + // Determines if a drop point falls within the center fifth of the box, meaning we should return Center. + const centerX1 = (2 * width) / 5; + const centerX2 = (3 * width) / 5; + const centerY1 = (2 * height) / 5; + const centerY2 = (3 * height) / 5; - // if (x > centerX1 && x < centerX2 && y > centerY1 && y < centerY2) return DropDirection.Center; + if (x > centerX1 && x < centerX2 && y > centerY1 && y < centerY2) return DropDirection.Center; const diagonal1 = y * width - x * height; const diagonal2 = y * width + x * height - height * width; diff --git a/frontend/faraday/tests/utils.test.ts b/frontend/faraday/tests/utils.test.ts index 56c60b440..18e47ccdb 100644 --- a/frontend/faraday/tests/utils.test.ts +++ b/frontend/faraday/tests/utils.test.ts @@ -82,14 +82,21 @@ test("determineDropDirection", () => { DropDirection.OuterLeft ); - // TODO: uncomment once center direction is supported - // assert.equal( - // determineDropDirection(dimensions, { - // x: 2.5, - // y: 2.5, - // }), - // DropDirection.Center - // ); + assert.equal( + determineDropDirection(dimensions, { + x: 2.5, + y: 2.5, + }), + DropDirection.Center + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 2.51, + y: 2.51, + }), + DropDirection.Center + ); assert.equal( determineDropDirection(dimensions, {