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.
This commit is contained in:
Evan Simkowitz 2024-06-17 14:14:09 -07:00 committed by GitHub
parent b71ae8e6e8
commit 48d4611a05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 205 additions and 96 deletions

View File

@ -29,6 +29,7 @@ import {
LayoutTreeDeleteNodeAction,
LayoutTreeMoveNodeAction,
LayoutTreeState,
LayoutTreeSwapNodeAction,
PreviewRenderer,
WritableLayoutTreeStateAtom,
} from "./model";
@ -518,68 +519,98 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
useEffect(() => {
let newPlaceholderOverlay: ReactNode;
if (layoutTreeState?.pendingAction?.type === LayoutTreeActionType.Move && overlayContainerRef?.current) {
const action = layoutTreeState.pendingAction as LayoutTreeMoveNodeAction<T>;
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<T>;
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<HTMLElement>;
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<HTMLElement>;
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 = <div className="placeholder" style={{ ...placeholderTransform }} />;
}
}
const placeholderTransform = createTransform(placeholderDimensions);
newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />;
break;
}
case LayoutTreeActionType.Swap: {
const action = layoutTreeState.pendingAction as LayoutTreeSwapNodeAction<T>;
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 = <div className="placeholder" style={{ ...placeholderTransform }} />;
}
break;
}
default:
// No-op
break;
}
}
setPlaceholderOverlay(newPlaceholderOverlay);

View File

@ -99,12 +99,13 @@ export function addIntermediateNode<T>(node: LayoutNode<T>): LayoutNode<T> {
* 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<T>(parent: LayoutNode<T>, childToRemove: LayoutNode<T>) {
export function removeChild<T>(parent: LayoutNode<T>, childToRemove: LayoutNode<T>, 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);
}

View File

@ -19,6 +19,7 @@ import {
LayoutTreeInsertNodeAction,
LayoutTreeMoveNodeAction,
LayoutTreeState,
LayoutTreeSwapNodeAction,
MoveOperation,
} from "./model";
import { DropDirection, FlexDirection, lazy } from "./utils";
@ -88,6 +89,10 @@ function layoutTreeStateReducerInner<T>(layoutTreeState: LayoutTreeState<T>, act
deleteNode(layoutTreeState, action as LayoutTreeDeleteNodeAction);
layoutTreeState.generation++;
break;
case LayoutTreeActionType.Swap:
swapNode(layoutTreeState, action as LayoutTreeSwapNodeAction<T>);
layoutTreeState.generation++;
break;
default: {
console.error("Invalid reducer action", layoutTreeState, action);
}
@ -114,7 +119,12 @@ function computeMoveNode<T>(
return;
}
let newOperation: MoveOperation<T>;
if (node.id === nodeToMove.id) {
console.warn("Cannot compute move node action since both nodes are equal");
return;
}
let newMoveOperation: MoveOperation<T>;
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<T>(
if (grandparentNode) {
console.log("has grandparent", grandparentNode);
const index = indexInGrandparent();
newOperation = {
newMoveOperation = {
parentId: grandparentNode.id,
node: nodeToMove,
index,
@ -139,10 +149,10 @@ function computeMoveNode<T>(
}
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<T>(
const parentNode = parent();
if (parentNode)
newOperation = {
newMoveOperation = {
parentId: parentNode.id,
index: indexInParent() ?? 0,
node: nodeToMove,
@ -164,7 +174,7 @@ function computeMoveNode<T>(
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<T>(
}
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<T>(
const parentNode = parent();
if (parentNode)
newOperation = {
newMoveOperation = {
parentId: parentNode.id,
index: indexInParent() + 1,
node: nodeToMove,
@ -199,7 +209,7 @@ function computeMoveNode<T>(
if (grandparentNode) {
console.log("has grandparent", grandparentNode);
const index = indexInGrandparent();
newOperation = {
newMoveOperation = {
parentId: grandparentNode.id,
node: nodeToMove,
index,
@ -209,11 +219,11 @@ function computeMoveNode<T>(
}
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<T>(
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<T>(
}
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<T>(
}
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<T> = {
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<T>;
}
function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMoveNodeAction<T>) {
@ -271,12 +295,23 @@ function moveNode<T>(layoutTreeState: LayoutTreeState<T>, 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<T>(layoutTreeState: LayoutTreeState<T>, 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<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeIn
layoutTreeState.leafs = leafs;
}
function swapNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeSwapNodeAction<T>) {
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<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeDeleteNodeAction) {
console.log("deleteNode", layoutTreeState, action);
if (!action?.nodeId) {

View File

@ -35,6 +35,7 @@ export type MoveOperation<T> = {
export enum LayoutTreeActionType {
ComputeMove = "computeMove",
Move = "move",
Swap = "swap",
CommitPendingAction = "commit",
ResizeNode = "resize",
InsertNode = "insert",
@ -72,6 +73,13 @@ export interface LayoutTreeMoveNodeAction<T> extends LayoutTreeAction, MoveOpera
type: LayoutTreeActionType.Move;
}
export interface LayoutTreeSwapNodeAction<T> extends LayoutTreeAction {
type: LayoutTreeActionType.Swap;
node1: LayoutNode<T>;
node2: LayoutNode<T>;
}
/**
* Action for committing a pending action to the layout tree.
*/

View File

@ -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;

View File

@ -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, {