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, LayoutTreeDeleteNodeAction,
LayoutTreeMoveNodeAction, LayoutTreeMoveNodeAction,
LayoutTreeState, LayoutTreeState,
LayoutTreeSwapNodeAction,
PreviewRenderer, PreviewRenderer,
WritableLayoutTreeStateAtom, WritableLayoutTreeStateAtom,
} from "./model"; } from "./model";
@ -518,7 +519,9 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
useEffect(() => { useEffect(() => {
let newPlaceholderOverlay: ReactNode; let newPlaceholderOverlay: ReactNode;
if (layoutTreeState?.pendingAction?.type === LayoutTreeActionType.Move && overlayContainerRef?.current) { if (overlayContainerRef?.current) {
switch (layoutTreeState?.pendingAction?.type) {
case LayoutTreeActionType.Move: {
const action = layoutTreeState.pendingAction as LayoutTreeMoveNodeAction<T>; const action = layoutTreeState.pendingAction as LayoutTreeMoveNodeAction<T>;
let parentId: string; let parentId: string;
if (action.insertAtRoot) { if (action.insertAtRoot) {
@ -564,7 +567,8 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
if (action.index >= (parentNode.children?.length ?? 1)) { 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). // 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 += placeholderDimensions.top +=
parentNode.flexDirection === FlexDirection.Column && targetBoundingRect.height / 2; parentNode.flexDirection === FlexDirection.Column &&
targetBoundingRect.height / 2;
placeholderDimensions.left += placeholderDimensions.left +=
parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2; parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2;
} else { } else {
@ -573,14 +577,41 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
parentNode.flexDirection === FlexDirection.Column && parentNode.flexDirection === FlexDirection.Column &&
(3 * targetBoundingRect.height) / 4; (3 * targetBoundingRect.height) / 4;
placeholderDimensions.left += placeholderDimensions.left +=
parentNode.flexDirection === FlexDirection.Row && (3 * targetBoundingRect.width) / 4; parentNode.flexDirection === FlexDirection.Row &&
(3 * targetBoundingRect.width) / 4;
} }
} }
const placeholderTransform = createTransform(placeholderDimensions);
const placeholderTransform = createTransform(placeholderDimensions);
newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />; 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); setPlaceholderOverlay(newPlaceholderOverlay);
}, [layoutTreeState, nodeRefs, overlayContainerRef]); }, [layoutTreeState, nodeRefs, overlayContainerRef]);

View File

@ -99,12 +99,13 @@ export function addIntermediateNode<T>(node: LayoutNode<T>): LayoutNode<T> {
* Attempts to remove the specified node from its parent. * Attempts to remove the specified node from its parent.
* @param parent The parent node. * @param parent The parent node.
* @param childToRemove The node to remove. * @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. * @template T The type of data associated with the node.
* @returns The updated parent node, or undefined if the node was not found. * @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; if (!parent.children) return;
const idx = parent.children.indexOf(childToRemove); const idx = parent.children.indexOf(childToRemove, startingIndex);
if (idx === -1) return; if (idx === -1) return;
parent.children?.splice(idx, 1); parent.children?.splice(idx, 1);
} }

View File

@ -19,6 +19,7 @@ import {
LayoutTreeInsertNodeAction, LayoutTreeInsertNodeAction,
LayoutTreeMoveNodeAction, LayoutTreeMoveNodeAction,
LayoutTreeState, LayoutTreeState,
LayoutTreeSwapNodeAction,
MoveOperation, MoveOperation,
} from "./model"; } from "./model";
import { DropDirection, FlexDirection, lazy } from "./utils"; import { DropDirection, FlexDirection, lazy } from "./utils";
@ -88,6 +89,10 @@ function layoutTreeStateReducerInner<T>(layoutTreeState: LayoutTreeState<T>, act
deleteNode(layoutTreeState, action as LayoutTreeDeleteNodeAction); deleteNode(layoutTreeState, action as LayoutTreeDeleteNodeAction);
layoutTreeState.generation++; layoutTreeState.generation++;
break; break;
case LayoutTreeActionType.Swap:
swapNode(layoutTreeState, action as LayoutTreeSwapNodeAction<T>);
layoutTreeState.generation++;
break;
default: { default: {
console.error("Invalid reducer action", layoutTreeState, action); console.error("Invalid reducer action", layoutTreeState, action);
} }
@ -114,7 +119,12 @@ function computeMoveNode<T>(
return; 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 parent = lazy(() => findParent(rootNode, node.id));
const grandparent = lazy(() => findParent(rootNode, parent().id)); const grandparent = lazy(() => findParent(rootNode, parent().id));
const indexInParent = lazy(() => parent()?.children.findIndex((child) => node.id === child.id)); const indexInParent = lazy(() => parent()?.children.findIndex((child) => node.id === child.id));
@ -129,7 +139,7 @@ function computeMoveNode<T>(
if (grandparentNode) { if (grandparentNode) {
console.log("has grandparent", grandparentNode); console.log("has grandparent", grandparentNode);
const index = indexInGrandparent(); const index = indexInGrandparent();
newOperation = { newMoveOperation = {
parentId: grandparentNode.id, parentId: grandparentNode.id,
node: nodeToMove, node: nodeToMove,
index, index,
@ -139,10 +149,10 @@ function computeMoveNode<T>(
} }
case DropDirection.Top: case DropDirection.Top:
if (node.flexDirection === FlexDirection.Column) { if (node.flexDirection === FlexDirection.Column) {
newOperation = { parentId: node.id, index: 0, node: nodeToMove }; newMoveOperation = { parentId: node.id, index: 0, node: nodeToMove };
} else { } else {
if (isRoot) if (isRoot)
newOperation = { newMoveOperation = {
node: nodeToMove, node: nodeToMove,
index: 0, index: 0,
insertAtRoot: true, insertAtRoot: true,
@ -150,7 +160,7 @@ function computeMoveNode<T>(
const parentNode = parent(); const parentNode = parent();
if (parentNode) if (parentNode)
newOperation = { newMoveOperation = {
parentId: parentNode.id, parentId: parentNode.id,
index: indexInParent() ?? 0, index: indexInParent() ?? 0,
node: nodeToMove, node: nodeToMove,
@ -164,7 +174,7 @@ function computeMoveNode<T>(
if (grandparentNode) { if (grandparentNode) {
console.log("has grandparent", grandparentNode); console.log("has grandparent", grandparentNode);
const index = indexInGrandparent() + 1; const index = indexInGrandparent() + 1;
newOperation = { newMoveOperation = {
parentId: grandparentNode.id, parentId: grandparentNode.id,
node: nodeToMove, node: nodeToMove,
index, index,
@ -174,10 +184,10 @@ function computeMoveNode<T>(
} }
case DropDirection.Bottom: case DropDirection.Bottom:
if (node.flexDirection === FlexDirection.Column) { if (node.flexDirection === FlexDirection.Column) {
newOperation = { parentId: node.id, index: 1, node: nodeToMove }; newMoveOperation = { parentId: node.id, index: 1, node: nodeToMove };
} else { } else {
if (isRoot) if (isRoot)
newOperation = { newMoveOperation = {
node: nodeToMove, node: nodeToMove,
index: 1, index: 1,
insertAtRoot: true, insertAtRoot: true,
@ -185,7 +195,7 @@ function computeMoveNode<T>(
const parentNode = parent(); const parentNode = parent();
if (parentNode) if (parentNode)
newOperation = { newMoveOperation = {
parentId: parentNode.id, parentId: parentNode.id,
index: indexInParent() + 1, index: indexInParent() + 1,
node: nodeToMove, node: nodeToMove,
@ -199,7 +209,7 @@ function computeMoveNode<T>(
if (grandparentNode) { if (grandparentNode) {
console.log("has grandparent", grandparentNode); console.log("has grandparent", grandparentNode);
const index = indexInGrandparent(); const index = indexInGrandparent();
newOperation = { newMoveOperation = {
parentId: grandparentNode.id, parentId: grandparentNode.id,
node: nodeToMove, node: nodeToMove,
index, index,
@ -209,11 +219,11 @@ function computeMoveNode<T>(
} }
case DropDirection.Left: case DropDirection.Left:
if (node.flexDirection === FlexDirection.Row) { if (node.flexDirection === FlexDirection.Row) {
newOperation = { parentId: node.id, index: 0, node: nodeToMove }; newMoveOperation = { parentId: node.id, index: 0, node: nodeToMove };
} else { } else {
const parentNode = parent(); const parentNode = parent();
if (parentNode) if (parentNode)
newOperation = { newMoveOperation = {
parentId: parentNode.id, parentId: parentNode.id,
index: indexInParent(), index: indexInParent(),
node: nodeToMove, node: nodeToMove,
@ -227,7 +237,7 @@ function computeMoveNode<T>(
if (grandparentNode) { if (grandparentNode) {
console.log("has grandparent", grandparentNode); console.log("has grandparent", grandparentNode);
const index = indexInGrandparent() + 1; const index = indexInGrandparent() + 1;
newOperation = { newMoveOperation = {
parentId: grandparentNode.id, parentId: grandparentNode.id,
node: nodeToMove, node: nodeToMove,
index, index,
@ -237,11 +247,11 @@ function computeMoveNode<T>(
} }
case DropDirection.Right: case DropDirection.Right:
if (node.flexDirection === FlexDirection.Row) { if (node.flexDirection === FlexDirection.Row) {
newOperation = { parentId: node.id, index: 1, node: nodeToMove }; newMoveOperation = { parentId: node.id, index: 1, node: nodeToMove };
} else { } else {
const parentNode = parent(); const parentNode = parent();
if (parentNode) if (parentNode)
newOperation = { newMoveOperation = {
parentId: parentNode.id, parentId: parentNode.id,
index: indexInParent() + 1, index: indexInParent() + 1,
node: nodeToMove, node: nodeToMove,
@ -249,14 +259,28 @@ function computeMoveNode<T>(
} }
break; break;
case DropDirection.Center: case DropDirection.Center:
// TODO: handle center drop console.log("center drop", rootNode, node, nodeToMove);
console.log("center drop"); 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; break;
default: default:
throw new Error(`Invalid direction: ${direction}`); 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>) { function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMoveNodeAction<T>) {
@ -271,12 +295,23 @@ function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMove
return; return;
} }
let node = findNode(rootNode, action.node.id) ?? action.node; const node = findNode(rootNode, action.node.id) ?? action.node;
let parent = findNode(rootNode, action.parentId); const parent = findNode(rootNode, action.parentId);
let oldParent = findParent(rootNode, action.node.id); const oldParent = findParent(rootNode, action.node.id);
console.log(node, parent, oldParent); 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 (!parent && action.insertAtRoot) {
if (!rootNode.children) { if (!rootNode.children) {
addIntermediateNode(rootNode); addIntermediateNode(rootNode);
@ -290,7 +325,7 @@ function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMove
// Remove nodeToInsert from its old parent // Remove nodeToInsert from its old parent
if (oldParent) { if (oldParent) {
removeChild(oldParent, node); removeChild(oldParent, node, startingIndex);
} }
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode); const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
@ -317,6 +352,34 @@ function insertNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeIn
layoutTreeState.leafs = leafs; 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) { function deleteNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeDeleteNodeAction) {
console.log("deleteNode", layoutTreeState, action); console.log("deleteNode", layoutTreeState, action);
if (!action?.nodeId) { if (!action?.nodeId) {

View File

@ -35,6 +35,7 @@ export type MoveOperation<T> = {
export enum LayoutTreeActionType { export enum LayoutTreeActionType {
ComputeMove = "computeMove", ComputeMove = "computeMove",
Move = "move", Move = "move",
Swap = "swap",
CommitPendingAction = "commit", CommitPendingAction = "commit",
ResizeNode = "resize", ResizeNode = "resize",
InsertNode = "insert", InsertNode = "insert",
@ -72,6 +73,13 @@ export interface LayoutTreeMoveNodeAction<T> extends LayoutTreeAction, MoveOpera
type: LayoutTreeActionType.Move; 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. * 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 // Lies outside of the box
if (y < 0 || y > height || x < 0 || x > width) return undefined; 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.
// // Determines if a drop point falls within the center fifth of the box, meaning we should return Center. const centerX1 = (2 * width) / 5;
// const centerX1 = (2 * width) / 5; const centerX2 = (3 * width) / 5;
// const centerX2 = (3 * width) / 5; const centerY1 = (2 * height) / 5;
// const centerY1 = (2 * height) / 5; const centerY2 = (3 * height) / 5;
// const centerY2 = (3 * width) / 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 diagonal1 = y * width - x * height;
const diagonal2 = y * width + x * height - height * width; const diagonal2 = y * width + x * height - height * width;

View File

@ -82,14 +82,21 @@ test("determineDropDirection", () => {
DropDirection.OuterLeft DropDirection.OuterLeft
); );
// TODO: uncomment once center direction is supported assert.equal(
// assert.equal( determineDropDirection(dimensions, {
// determineDropDirection(dimensions, { x: 2.5,
// x: 2.5, y: 2.5,
// y: 2.5, }),
// }), DropDirection.Center
// DropDirection.Center );
// );
assert.equal(
determineDropDirection(dimensions, {
x: 2.51,
y: 2.51,
}),
DropDirection.Center
);
assert.equal( assert.equal(
determineDropDirection(dimensions, { determineDropDirection(dimensions, {