waveterm/frontend/layout/lib/layoutModel.ts
Evan Simkowitz e85b0d205e
New layout model (#210)
This PR is a large refactoring of the layout code to move as much of the
layout state logic as possible into a unified model class, with atoms
and derived atoms to notify the display logic of changes. It also fixes
some latent bugs in the node resize code, significantly speeds up
response times for resizing and dragging, and sets us up to fully
replace the React-DnD library in the future.
2024-08-14 18:40:41 -07:00

590 lines
24 KiB
TypeScript

import { atomWithThrottle, boundNumber } from "@/util/util";
import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai";
import { splitAtom } from "jotai/utils";
import { createRef, CSSProperties } from "react";
import { debounce } from "throttle-debounce";
import { balanceNode, findNode, walkNodes } from "./layoutNode";
import {
computeMoveNode,
deleteNode,
insertNode,
insertNodeAtIndex,
magnifyNodeToggle,
moveNode,
resizeNode,
swapNode,
} from "./layoutTree";
import {
ContentRenderer,
LayoutNode,
LayoutTreeAction,
LayoutTreeActionType,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
LayoutTreeMoveNodeAction,
LayoutTreeResizeNodeAction,
LayoutTreeSetPendingAction,
LayoutTreeState,
LayoutTreeSwapNodeAction,
PreviewRenderer,
TileLayoutContents,
WritableLayoutTreeStateAtom,
} from "./types";
import { Dimensions, FlexDirection, setTransform } from "./utils";
export interface ResizeHandleProps {
id: string;
parentNodeId: string;
parentIndex: number;
centerPx: number;
transform: CSSProperties;
flexDirection: FlexDirection;
}
export interface LayoutNodeAdditionalProps {
transform?: CSSProperties;
rect?: Dimensions;
pixelToSizeRatio?: number;
resizeHandles?: ResizeHandleProps[];
}
interface ResizeContext {
handleId: string;
pixelToSizeRatio: number;
resizeHandleStartPx: number;
beforeNodeStartSize: number;
afterNodeStartSize: number;
}
const DefaultGapSizePx = 5;
export class LayoutModel {
treeStateAtom: WritableLayoutTreeStateAtom;
getter: Getter;
setter: Setter;
renderContent?: ContentRenderer;
renderPreview?: PreviewRenderer;
onNodeDelete?: (data: TabLayoutData) => Promise<void>;
gapSizePx: number;
treeState: LayoutTreeState;
leafs: LayoutNode[];
resizeHandles: SplitAtom<ResizeHandleProps>;
additionalProps: PrimitiveAtom<Record<string, LayoutNodeAdditionalProps>>;
pendingAction: AtomWithThrottle<LayoutTreeAction>;
activeDrag: PrimitiveAtom<boolean>;
showOverlay: PrimitiveAtom<boolean>;
ready: PrimitiveAtom<boolean>;
displayContainerRef: React.RefObject<HTMLDivElement>;
placeholderTransform: Atom<CSSProperties>;
overlayTransform: Atom<CSSProperties>;
private resizeContext?: ResizeContext;
isResizing: Atom<boolean>;
private isContainerResizing: PrimitiveAtom<boolean>;
generationAtom: PrimitiveAtom<number>;
constructor(
treeStateAtom: WritableLayoutTreeStateAtom,
getter: Getter,
setter: Setter,
renderContent?: ContentRenderer,
renderPreview?: PreviewRenderer,
onNodeDelete?: (data: TabLayoutData) => Promise<void>,
gapSizePx?: number
) {
console.log("ctor");
this.treeStateAtom = treeStateAtom;
this.getter = getter;
this.setter = setter;
this.renderContent = renderContent;
this.renderPreview = renderPreview;
this.onNodeDelete = onNodeDelete;
this.gapSizePx = gapSizePx ?? DefaultGapSizePx;
this.leafs = [];
this.additionalProps = atom({});
const resizeHandleListAtom = atom((get) => {
const addlProps = get(this.additionalProps);
return Object.values(addlProps)
.flatMap((props) => props.resizeHandles)
.filter((v) => v);
});
this.resizeHandles = splitAtom(resizeHandleListAtom);
this.isContainerResizing = atom(false);
this.isResizing = atom((get) => {
const pendingAction = get(this.pendingAction.throttledValueAtom);
const isWindowResizing = get(this.isContainerResizing);
return isWindowResizing || pendingAction?.type === LayoutTreeActionType.ResizeNode;
});
this.displayContainerRef = createRef();
this.activeDrag = atom(false);
this.showOverlay = atom(false);
this.ready = atom(false);
this.overlayTransform = atom<CSSProperties>((get) => {
const activeDrag = get(this.activeDrag);
const showOverlay = get(this.showOverlay);
if (this.displayContainerRef.current) {
const displayBoundingRect = this.displayContainerRef.current.getBoundingClientRect();
const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height;
const newTransform = setTransform(
{
top: activeDrag || showOverlay ? 0 : newOverlayOffset,
left: 0,
width: displayBoundingRect.width,
height: displayBoundingRect.height,
},
false
);
return newTransform;
}
});
this.pendingAction = atomWithThrottle<LayoutTreeAction>(null, 10);
this.placeholderTransform = atom<CSSProperties>((get: Getter) => {
const pendingAction = get(this.pendingAction.throttledValueAtom);
console.log("update to pending action", pendingAction);
return this.getPlaceholderTransform(pendingAction);
});
this.generationAtom = atom(0);
this.updateTreeState(true);
}
registerTileLayout(contents: TileLayoutContents) {
this.renderContent = contents.renderContent;
this.renderPreview = contents.renderPreview;
this.onNodeDelete = contents.onNodeDelete;
}
treeReducer(action: LayoutTreeAction) {
console.log("treeReducer", action, this);
let stateChanged = false;
switch (action.type) {
case LayoutTreeActionType.ComputeMove:
this.setter(
this.pendingAction.throttledValueAtom,
computeMoveNode(this.treeState, action as LayoutTreeComputeMoveNodeAction)
);
break;
case LayoutTreeActionType.Move:
moveNode(this.treeState, action as LayoutTreeMoveNodeAction);
stateChanged = true;
break;
case LayoutTreeActionType.InsertNode:
insertNode(this.treeState, action as LayoutTreeInsertNodeAction);
stateChanged = true;
break;
case LayoutTreeActionType.InsertNodeAtIndex:
insertNodeAtIndex(this.treeState, action as LayoutTreeInsertNodeAtIndexAction);
stateChanged = true;
break;
case LayoutTreeActionType.DeleteNode:
deleteNode(this.treeState, action as LayoutTreeDeleteNodeAction);
stateChanged = true;
break;
case LayoutTreeActionType.Swap:
swapNode(this.treeState, action as LayoutTreeSwapNodeAction);
stateChanged = true;
break;
case LayoutTreeActionType.ResizeNode:
resizeNode(this.treeState, action as LayoutTreeResizeNodeAction);
stateChanged = true;
break;
case LayoutTreeActionType.SetPendingAction: {
const pendingAction = (action as LayoutTreeSetPendingAction).action;
if (pendingAction) {
this.setter(this.pendingAction.throttledValueAtom, pendingAction);
} else {
console.warn("No new pending action provided");
}
break;
}
case LayoutTreeActionType.ClearPendingAction:
this.setter(this.pendingAction.throttledValueAtom, undefined);
break;
case LayoutTreeActionType.CommitPendingAction: {
const pendingAction = this.getter(this.pendingAction.currentValueAtom);
if (!pendingAction) {
console.error("unable to commit pending action, does not exist");
break;
}
this.treeReducer(pendingAction);
this.setter(this.pendingAction.throttledValueAtom, undefined);
break;
}
case LayoutTreeActionType.MagnifyNodeToggle:
magnifyNodeToggle(this.treeState, action as LayoutTreeMagnifyNodeToggleAction);
stateChanged = true;
break;
default:
console.error("Invalid reducer action", this.treeState, action);
}
if (stateChanged) {
console.log("state changed", this.treeState);
this.updateTree();
this.treeState.generation++;
this.setter(this.treeStateAtom, this.treeState);
}
}
updateTreeState(force = false) {
const treeState = this.getter(this.treeStateAtom);
console.log("updateTreeState", this.treeState, treeState);
if (
force ||
!this.treeState?.rootNode ||
!this.treeState?.generation ||
treeState?.generation > this.treeState.generation
) {
console.log("newTreeState", treeState);
this.treeState = treeState;
this.updateTree();
}
}
private bumpGeneration() {
console.log("bumpGeneration");
this.setter(this.generationAtom, this.getter(this.generationAtom) + 1);
}
getNodeAdditionalPropertiesAtom(nodeId: string): Atom<LayoutNodeAdditionalProps> {
return atom((get) => {
const addlProps = get(this.additionalProps);
console.log(
"updated addlProps",
nodeId,
addlProps?.[nodeId]?.transform,
addlProps?.[nodeId]?.rect,
addlProps?.[nodeId]?.pixelToSizeRatio
);
if (addlProps.hasOwnProperty(nodeId)) return addlProps[nodeId];
});
}
getNodeAdditionalPropertiesById(nodeId: string): LayoutNodeAdditionalProps {
const addlProps = this.getter(this.additionalProps);
if (addlProps.hasOwnProperty(nodeId)) return addlProps[nodeId];
}
getNodeAdditionalProperties(node: LayoutNode): LayoutNodeAdditionalProps {
return this.getNodeAdditionalPropertiesById(node.id);
}
getNodeTransform(node: LayoutNode): CSSProperties {
return this.getNodeTransformById(node.id);
}
getNodeTransformById(nodeId: string): CSSProperties {
return this.getNodeAdditionalPropertiesById(nodeId)?.transform;
}
getNodeRect(node: LayoutNode): Dimensions {
return this.getNodeRectById(node.id);
}
getNodeRectById(nodeId: string): Dimensions {
return this.getNodeAdditionalPropertiesById(nodeId)?.rect;
}
updateTree = (balanceTree: boolean = true) => {
console.log("updateTree");
if (this.displayContainerRef.current) {
console.log("updateTree 1");
const newLeafs: LayoutNode[] = [];
const newAdditionalProps = {};
const pendingAction = this.getter(this.pendingAction.currentValueAtom);
const resizeAction =
pendingAction?.type === LayoutTreeActionType.ResizeNode
? (pendingAction as LayoutTreeResizeNodeAction)
: null;
const callback = (node: LayoutNode) =>
this.updateTreeHelper(node, newAdditionalProps, newLeafs, resizeAction);
if (balanceTree) this.treeState.rootNode = balanceNode(this.treeState.rootNode, callback);
else walkNodes(this.treeState.rootNode, callback);
this.setter(this.additionalProps, newAdditionalProps);
this.leafs = newLeafs.sort((a, b) => a.id.localeCompare(b.id));
this.bumpGeneration();
}
};
private getBoundingRect(): Dimensions {
const boundingRect = this.displayContainerRef.current.getBoundingClientRect();
return { top: 0, left: 0, width: boundingRect.width, height: boundingRect.height };
}
private updateTreeHelper(
node: LayoutNode,
additionalPropsMap: Record<string, LayoutNodeAdditionalProps>,
leafs: LayoutNode[],
resizeAction?: LayoutTreeResizeNodeAction
) {
if (!node.children?.length) {
console.log("adding node to leafs", node);
leafs.push(node);
if (this.treeState.magnifiedNodeId === node.id) {
const boundingRect = this.getBoundingRect();
const transform = setTransform(
{
top: boundingRect.height * 0.05,
left: boundingRect.width * 0.05,
width: boundingRect.width * 0.9,
height: boundingRect.height * 0.9,
},
true
);
additionalPropsMap[node.id].transform = transform;
}
return;
}
function getNodeSize(node: LayoutNode) {
return resizeAction?.resizeOperations.find((op) => op.nodeId === node.id)?.size ?? node.size;
}
const additionalProps: LayoutNodeAdditionalProps = additionalPropsMap.hasOwnProperty(node.id)
? additionalPropsMap[node.id]
: {};
const nodeRect: Dimensions =
node.id === this.treeState.rootNode.id ? this.getBoundingRect() : additionalProps.rect;
const nodeIsRow = node.flexDirection === FlexDirection.Row;
const nodePixelsMinusGap =
(nodeIsRow ? nodeRect.width : nodeRect.height) - this.gapSizePx * (node.children.length - 1);
const totalChildrenSize = node.children.reduce((acc, child) => acc + getNodeSize(child), 0);
const pixelToSizeRatio = totalChildrenSize / nodePixelsMinusGap;
let lastChildRect: Dimensions;
const resizeHandles: ResizeHandleProps[] = [];
node.children.forEach((child, i) => {
const childSize = getNodeSize(child);
const rect: Dimensions = {
top:
!nodeIsRow && lastChildRect
? lastChildRect.top + lastChildRect.height + this.gapSizePx
: nodeRect.top,
left:
nodeIsRow && lastChildRect
? lastChildRect.left + lastChildRect.width + this.gapSizePx
: nodeRect.left,
width: nodeIsRow ? childSize / pixelToSizeRatio : nodeRect.width,
height: nodeIsRow ? nodeRect.height : childSize / pixelToSizeRatio,
};
const transform = setTransform(rect);
additionalPropsMap[child.id] = {
rect,
transform,
};
const resizeHandleDimensions: Dimensions = {
top: nodeIsRow ? rect.top : rect.top + rect.height - 0.5 * this.gapSizePx,
left: nodeIsRow ? rect.left + rect.width - 0.5 * this.gapSizePx : rect.left,
width: nodeIsRow ? 2 * this.gapSizePx : rect.width,
height: nodeIsRow ? rect.height : 2 * this.gapSizePx,
};
resizeHandles.push({
id: `${node.id}-${i}`,
parentNodeId: node.id,
parentIndex: i,
transform: setTransform(resizeHandleDimensions, true, false),
flexDirection: node.flexDirection,
centerPx: (nodeIsRow ? resizeHandleDimensions.left : resizeHandleDimensions.top) + this.gapSizePx,
});
lastChildRect = rect;
});
resizeHandles.pop();
additionalPropsMap[node.id] = {
...additionalProps,
pixelToSizeRatio,
resizeHandles,
};
}
private getPlaceholderTransform(pendingAction: LayoutTreeAction): CSSProperties {
if (pendingAction) {
console.log("pendingAction", pendingAction, this);
switch (pendingAction.type) {
case LayoutTreeActionType.Move: {
// console.log("doing move overlay");
const action = pendingAction as LayoutTreeMoveNodeAction;
let parentId: string;
if (action.insertAtRoot) {
parentId = this.treeState.rootNode.id;
} else {
parentId = action.parentId;
}
const parentNode = findNode(this.treeState.rootNode, parentId);
if (action.index !== undefined && parentNode) {
const targetIndex = boundNumber(
action.index - 1,
0,
parentNode.children ? parentNode.children.length - 1 : 0
);
const targetNode = parentNode?.children?.at(targetIndex) ?? parentNode;
if (targetNode) {
const targetBoundingRect = this.getNodeRect(targetNode);
// 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,
left: targetBoundingRect.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;
}
}
return setTransform(placeholderDimensions);
}
}
break;
}
case LayoutTreeActionType.Swap: {
// console.log("doing swap overlay");
const action = pendingAction as LayoutTreeSwapNodeAction;
const targetNodeId = action.node1Id;
const targetBoundingRect = this.getNodeRectById(targetNodeId);
const placeholderDimensions: Dimensions = {
top: targetBoundingRect.top,
left: targetBoundingRect.left,
height: targetBoundingRect.height,
width: targetBoundingRect.width,
};
return setTransform(placeholderDimensions);
}
default:
// No-op
break;
}
}
return;
}
magnifyNode(node: LayoutNode) {
const action = {
type: LayoutTreeActionType.MagnifyNodeToggle,
nodeId: node.id,
};
this.treeReducer(action);
}
async closeNode(node: LayoutNode) {
const deleteAction: LayoutTreeDeleteNodeAction = {
type: LayoutTreeActionType.DeleteNode,
nodeId: node.id,
};
this.treeReducer(deleteAction);
await this.onNodeDelete?.(node.data);
}
onContainerResize = () => {
this.updateTree();
this.setter(this.isContainerResizing, true);
this.stopContainerResizing();
};
stopContainerResizing = debounce(30, () => {
this.setter(this.isContainerResizing, false);
});
onResizeMove(resizeHandle: ResizeHandleProps, clientX: number, clientY: number) {
console.log("onResizeMove", resizeHandle, clientX, clientY, this.resizeContext);
const parentIsRow = resizeHandle.flexDirection === FlexDirection.Row;
const parentNode = findNode(this.treeState.rootNode, resizeHandle.parentNodeId);
const beforeNode = parentNode.children![resizeHandle.parentIndex];
const afterNode = parentNode.children![resizeHandle.parentIndex + 1];
// If the resize context is out of date, update it and save it for future events.
if (this.resizeContext?.handleId !== resizeHandle.id) {
const addlProps = this.getter(this.additionalProps);
const pixelToSizeRatio = addlProps[resizeHandle.parentNodeId]?.pixelToSizeRatio;
if (beforeNode && afterNode && pixelToSizeRatio) {
this.resizeContext = {
handleId: resizeHandle.id,
resizeHandleStartPx: resizeHandle.centerPx,
beforeNodeStartSize: beforeNode.size,
afterNodeStartSize: afterNode.size,
pixelToSizeRatio,
};
} else {
console.error(
"Invalid resize handle, cannot get the additional properties for the nodes in the resize handle properties."
);
return;
}
}
const boundingRect = this.displayContainerRef.current?.getBoundingClientRect();
clientX -= boundingRect?.top;
clientY -= boundingRect?.left;
const clientPoint = parentIsRow ? clientX : clientY;
const clientDiff = (this.resizeContext.resizeHandleStartPx - clientPoint) * this.resizeContext.pixelToSizeRatio;
const sizeNode1 = this.resizeContext.beforeNodeStartSize - clientDiff;
const sizeNode2 = this.resizeContext.afterNodeStartSize + clientDiff;
const resizeAction: LayoutTreeResizeNodeAction = {
type: LayoutTreeActionType.ResizeNode,
resizeOperations: [
{
nodeId: beforeNode.id,
size: sizeNode1,
},
{
nodeId: afterNode.id,
size: sizeNode2,
},
],
};
const setPendingAction: LayoutTreeSetPendingAction = {
type: LayoutTreeActionType.SetPendingAction,
action: resizeAction,
};
this.treeReducer(setPendingAction);
this.updateTree(false);
}
onResizeEnd() {
this.resizeContext = undefined;
this.treeReducer({ type: LayoutTreeActionType.CommitPendingAction });
}
}