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.
This commit is contained in:
Evan Simkowitz 2024-08-14 18:40:41 -07:00 committed by GitHub
parent 2684a74db2
commit e85b0d205e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1480 additions and 1462 deletions

View File

@ -1,33 +1,31 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { useWaveObjectValue } from "@/app/store/wos";
import { Workspace } from "@/app/workspace/workspace";
import {
LayoutTreeActionType,
LayoutTreeDeleteNodeAction,
deleteLayoutModelForTab,
getLayoutModelForTab,
} from "@/layout/index";
import { ContextMenuModel } from "@/store/contextmenu";
import { PLATFORM, WOS, atoms, globalStore, setBlockFocus } from "@/store/global";
import * as services from "@/store/services";
import { getWebServerEndpoint } from "@/util/endpoints";
import * as keyutil from "@/util/keyutil";
import * as layoututil from "@/util/layoututil";
import * as util from "@/util/util";
import clsx from "clsx";
import Color from "color";
import * as csstree from "css-tree";
import {
deleteLayoutStateAtomForTab,
getLayoutStateAtomForTab,
globalLayoutTransformsMap,
} from "frontend/layout/lib/layoutAtom";
import * as jotai from "jotai";
import "overlayscrollbars/overlayscrollbars.css";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { CenteredDiv } from "./element/quickelems";
import { useWaveObjectValue } from "@/app/store/wos";
import { LayoutTreeActionType, LayoutTreeDeleteNodeAction } from "@/layout/index";
import { layoutTreeStateReducer } from "@/layout/lib/layoutState";
import { getWebServerEndpoint } from "@/util/endpoints";
import clsx from "clsx";
import Color from "color";
import "overlayscrollbars/overlayscrollbars.css";
import "./app.less";
import { CenteredDiv } from "./element/quickelems";
const App = () => {
let Provider = jotai.Provider;
@ -173,15 +171,15 @@ function findBlockAtPoint(m: Map<string, Bounds>, p: Point): string {
function switchBlockIdx(index: number) {
const tabId = globalStore.get(atoms.activeTabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutTreeState = globalStore.get(getLayoutStateAtomForTab(tabId, tabAtom));
if (layoutTreeState?.leafs == null) {
const layoutModel = getLayoutModelForTab(tabAtom);
if (layoutModel?.leafs == null) {
return;
}
const newLeafIdx = index - 1;
if (newLeafIdx < 0 || newLeafIdx >= layoutTreeState.leafs.length) {
if (newLeafIdx < 0 || newLeafIdx >= layoutModel.leafs.length) {
return;
}
const leaf = layoutTreeState.leafs[newLeafIdx];
const leaf = layoutModel.leafs[newLeafIdx];
if (leaf?.data?.blockId == null) {
return;
}
@ -194,26 +192,22 @@ function switchBlock(tabId: string, offsetX: number, offsetY: number) {
return;
}
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const transforms = globalLayoutTransformsMap.get(tabId);
if (transforms == null) {
return;
}
const layoutTreeState = globalStore.get(getLayoutStateAtomForTab(tabId, tabAtom));
const layoutModel = getLayoutModelForTab(tabAtom);
const curBlockId = globalStore.get(atoms.waveWindow)?.activeblockid;
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutTreeState, curBlockId);
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutModel, curBlockId);
if (curBlockLeafId == null) {
return;
}
const blockPos = readBoundsFromTransform(transforms[curBlockLeafId]);
const blockPos = readBoundsFromTransform(layoutModel.getNodeTransformById(curBlockLeafId));
if (blockPos == null) {
return;
}
var blockPositions: Map<string, Bounds> = new Map();
for (let leaf of layoutTreeState.leafs) {
for (const leaf of layoutModel.leafs) {
if (leaf.id == curBlockLeafId) {
continue;
}
const pos = readBoundsFromTransform(transforms[leaf.id]);
const pos = readBoundsFromTransform(layoutModel.getNodeTransform(leaf));
if (pos != null) {
blockPositions.set(leaf.data.blockId, pos);
}
@ -341,7 +335,7 @@ function genericClose(tabId: string) {
if (tabData.blockids == null || tabData.blockids.length == 0) {
// close tab
services.WindowService.CloseTab(tabId);
deleteLayoutStateAtomForTab(tabId);
deleteLayoutModelForTab(tabId);
return;
}
// close block
@ -349,14 +343,13 @@ function genericClose(tabId: string) {
if (activeBlockId == null) {
return;
}
const layoutStateAtom = getLayoutStateAtomForTab(tabId, tabAtom);
const layoutTreeState = globalStore.get(layoutStateAtom);
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutTreeState, activeBlockId);
const layoutModel = getLayoutModelForTab(tabAtom);
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutModel, activeBlockId);
const deleteAction: LayoutTreeDeleteNodeAction = {
type: LayoutTreeActionType.DeleteNode,
nodeId: curBlockLeafId,
};
globalStore.set(layoutStateAtom, layoutTreeStateReducer(layoutTreeState, deleteAction));
layoutModel.treeReducer(deleteAction);
services.ObjectService.DeleteBlock(activeBlockId);
}

View File

@ -7,10 +7,8 @@ import { ContextMenuModel } from "@/app/store/contextmenu";
import { atoms, globalStore, useBlockAtom, WOS } from "@/app/store/global";
import * as services from "@/app/store/services";
import { MagnifyIcon } from "@/element/magnify";
import { LayoutTreeState } from "@/layout/index";
import { getLayoutStateAtomForTab } from "@/layout/lib/layoutAtom";
import { useLayoutModel } from "@/layout/index";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { isBlockMagnified } from "@/util/layoututil";
import * as util from "@/util/util";
import clsx from "clsx";
import * as jotai from "jotai";
@ -73,21 +71,15 @@ function getViewIconElem(viewIconUnion: string | HeaderIconButton, blockData: Bl
}
}
const OptMagnifyButton = React.memo(
({ blockData, layoutModel }: { blockData: Block; layoutModel: LayoutComponentModel }) => {
const tabId = globalStore.get(atoms.activeTabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutTreeState = util.useAtomValueSafe(getLayoutStateAtomForTab(tabId, tabAtom));
const isMagnified = isBlockMagnified(layoutTreeState, blockData.oid);
const magnifyDecl: HeaderIconButton = {
elemtype: "iconbutton",
icon: <MagnifyIcon enabled={isMagnified} />,
title: isMagnified ? "Minimize" : "Magnify",
click: layoutModel?.onMagnifyToggle,
};
return <IconButton key="magnify" decl={magnifyDecl} className="block-frame-magnify" />;
}
);
const OptMagnifyButton = React.memo(({ layoutCompModel }: { layoutCompModel: LayoutComponentModel }) => {
const magnifyDecl: HeaderIconButton = {
elemtype: "iconbutton",
icon: <MagnifyIcon enabled={layoutCompModel?.isMagnified} />,
title: layoutCompModel?.isMagnified ? "Minimize" : "Magnify",
click: layoutCompModel?.onMagnifyToggle,
};
return <IconButton key="magnify" decl={magnifyDecl} className="block-frame-magnify" />;
});
function computeEndIcons(blockData: Block, viewModel: ViewModel, layoutModel: LayoutComponentModel): JSX.Element[] {
const endIconsElem: JSX.Element[] = [];
@ -104,7 +96,7 @@ function computeEndIcons(blockData: Block, viewModel: ViewModel, layoutModel: La
handleHeaderContextMenu(e, blockData, viewModel, layoutModel?.onMagnifyToggle, layoutModel?.onClose),
};
endIconsElem.push(<IconButton key="settings" decl={settingsDecl} className="block-frame-settings" />);
endIconsElem.push(<OptMagnifyButton key="unmagnify" blockData={blockData} layoutModel={layoutModel} />);
endIconsElem.push(<OptMagnifyButton key="unmagnify" layoutCompModel={layoutModel} />);
const closeDecl: HeaderIconButton = {
elemtype: "iconbutton",
icon: "xmark-large",
@ -214,12 +206,9 @@ function renderHeaderElements(headerTextUnion: HeaderElem[]): JSX.Element[] {
function BlockNum({ blockId }: { blockId: string }) {
const tabId = jotai.useAtomValue(atoms.activeTabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutTreeState: LayoutTreeState<TabLayoutData> = globalStore.get(getLayoutStateAtomForTab(tabId, tabAtom));
if (!layoutTreeState || !layoutTreeState.leafs) {
return null;
}
for (let idx = 0; idx < layoutTreeState.leafs.length; idx++) {
const leaf = layoutTreeState.leafs[idx];
const layoutModel = useLayoutModel(tabAtom);
for (let idx = 0; idx < layoutModel.leafs.length; idx++) {
const leaf = layoutModel.leafs[idx];
if (leaf?.data?.blockId == blockId) {
return String(idx + 1);
}

View File

@ -5,6 +5,7 @@ export interface LayoutComponentModel {
disablePointerEvents: boolean;
onClose?: () => void;
onMagnifyToggle?: () => void;
isMagnified: boolean;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}

View File

@ -1,18 +1,15 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { handleIncomingRpcMessage } from "@/app/store/wshrpc";
import {
LayoutTreeAction,
getLayoutModelForTabById,
LayoutTreeActionType,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
newLayoutNode,
} from "frontend/layout/index";
import { getLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom";
import { layoutTreeStateReducer } from "frontend/layout/lib/layoutState";
import { handleIncomingRpcMessage } from "@/app/store/wshrpc";
import { LayoutTreeInsertNodeAtIndexAction } from "@/layout/lib/model";
import { getWSServerEndpoint, getWebServerEndpoint } from "@/util/endpoints";
} from "@/layout/index";
import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints";
import * as layoututil from "@/util/layoututil";
import { produce } from "immer";
import * as jotai from "jotai";
@ -261,29 +258,26 @@ function handleWSEventMessage(msg: WSEventType) {
}
if (msg.eventtype == "layoutaction") {
const layoutAction: WSLayoutActionData = msg.data;
const tabId = layoutAction.tabid;
const layoutModel = getLayoutModelForTabById(tabId);
switch (layoutAction.actiontype) {
case LayoutTreeActionType.InsertNode: {
const insertNodeAction: LayoutTreeInsertNodeAction<TabLayoutData> = {
const insertNodeAction: LayoutTreeInsertNodeAction = {
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode<TabLayoutData>(undefined, undefined, undefined, {
node: newLayoutNode(undefined, undefined, undefined, {
blockId: layoutAction.blockid,
}),
};
runLayoutAction(layoutAction.tabid, insertNodeAction);
layoutModel.treeReducer(insertNodeAction);
break;
}
case LayoutTreeActionType.DeleteNode: {
const layoutStateAtom = getLayoutStateAtomForTab(
layoutAction.tabid,
WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", layoutAction.tabid))
);
const curState = globalStore.get(layoutStateAtom);
const leafId = layoututil.findLeafIdFromBlockId(curState, layoutAction.blockid);
const leafId = layoututil.findLeafIdFromBlockId(layoutModel, layoutAction.blockid);
const deleteNodeAction = {
type: LayoutTreeActionType.DeleteNode,
nodeId: leafId,
};
runLayoutAction(layoutAction.tabid, deleteNodeAction);
layoutModel.treeReducer(deleteNodeAction);
break;
}
case LayoutTreeActionType.InsertNodeAtIndex: {
@ -291,14 +285,14 @@ function handleWSEventMessage(msg: WSEventType) {
console.error("Cannot apply eventbus layout action InsertNodeAtIndex, indexarr field is missing.");
break;
}
const insertAction: LayoutTreeInsertNodeAtIndexAction<TabLayoutData> = {
const insertAction: LayoutTreeInsertNodeAtIndexAction = {
type: LayoutTreeActionType.InsertNodeAtIndex,
node: newLayoutNode<TabLayoutData>(undefined, layoutAction.nodesize, undefined, {
node: newLayoutNode(undefined, layoutAction.nodesize, undefined, {
blockId: layoutAction.blockid,
}),
indexArr: layoutAction.indexarr,
};
runLayoutAction(layoutAction.tabid, insertAction);
layoutModel.treeReducer(insertAction);
break;
}
default:
@ -355,21 +349,16 @@ function getApi(): ElectronApi {
return (window as any).api;
}
function runLayoutAction(tabId: string, action: LayoutTreeAction) {
const layoutStateAtom = getLayoutStateAtomForTab(tabId, WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId)));
const curState = globalStore.get(layoutStateAtom);
globalStore.set(layoutStateAtom, layoutTreeStateReducer(curState, action));
}
async function createBlock(blockDef: BlockDef) {
const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
const blockId = await services.ObjectService.CreateBlock(blockDef, rtOpts);
const insertNodeAction: LayoutTreeInsertNodeAction<TabLayoutData> = {
const insertNodeAction: LayoutTreeInsertNodeAction = {
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode<TabLayoutData>(undefined, undefined, undefined, { blockId }),
node: newLayoutNode(undefined, undefined, undefined, { blockId }),
};
const activeTabId = globalStore.get(atoms.uiContext).activetabid;
runLayoutAction(activeTabId, insertNodeAction);
const layoutModel = getLayoutModelForTabById(activeTabId);
layoutModel.treeReducer(insertNodeAction);
}
// when file is not found, returns {data: null, fileInfo: null}
@ -450,8 +439,6 @@ async function openLink(uri: string) {
}
export {
PLATFORM,
WOS,
atoms,
createBlock,
fetchWaveFile,
@ -466,10 +453,12 @@ export {
initWS,
isDev,
openLink,
PLATFORM,
sendWSCommand,
setBlockFocus,
setPlatform,
useBlockAtom,
useBlockCache,
useSettingsAtom,
WOS,
};

View File

@ -24,8 +24,6 @@ type WaveObjectValue<T extends WaveObj> = {
holdTime: number;
};
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
function splitORef(oref: string): [string, string] {
const parts = oref.split(":");
if (parts.length != 2) {

View File

@ -2,9 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
import { WindowDrag } from "@/element/windowdrag";
import { deleteLayoutModelForTab } from "@/layout/index";
import { atoms, getApi, isDev } from "@/store/global";
import * as services from "@/store/services";
import { deleteLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom";
import { useAtomValue } from "jotai";
import { OverlayScrollbars } from "overlayscrollbars";
import React, { createRef, useCallback, useEffect, useRef, useState } from "react";
@ -140,8 +140,6 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
let newTabWidth = tabWidth;
let newScrollable = scrollable;
console.log("spaceForTabs", spaceForTabs, minTotalTabWidth);
if (spaceForTabs < totalDefaultTabWidth && spaceForTabs > minTotalTabWidth) {
newTabWidth = TAB_MIN_WIDTH;
} else if (minTotalTabWidth > spaceForTabs) {
@ -467,7 +465,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
event?.stopPropagation();
services.WindowService.CloseTab(tabId);
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutStateAtomForTab(tabId);
deleteLayoutModelForTab(tabId);
};
const handleTabLoaded = useCallback((tabId) => {

View File

@ -3,16 +3,13 @@
import { Block } from "@/app/block/block";
import { LayoutComponentModel } from "@/app/block/blocktypes";
import { CenteredDiv } from "@/element/quickelems";
import { ContentRenderer, TileLayout } from "@/layout/index";
import { getApi } from "@/store/global";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
import * as React from "react";
import { CenteredDiv } from "@/element/quickelems";
import { ContentRenderer } from "@/layout/lib/model";
import { TileLayout } from "frontend/layout/index";
import { getLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom";
import { useAtomValue } from "jotai";
import * as React from "react";
import { useMemo } from "react";
import "./tabcontent.less";
@ -21,19 +18,19 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]);
const tabLoading = useAtomValue(loadingAtom);
const tabAtom = useMemo(() => WOS.getWaveObjectAtom<Tab>(oref), [oref]);
const layoutStateAtom = useMemo(() => getLayoutStateAtomForTab(tabId, tabAtom), [tabAtom, tabId]);
const tabData = useAtomValue(tabAtom);
const tileLayoutContents = useMemo(() => {
const renderBlock: ContentRenderer<TabLayoutData> = (
tabData: TabLayoutData,
const renderBlock: ContentRenderer = (
blockData: TabLayoutData,
ready: boolean,
isMagnified: boolean,
disablePointerEvents: boolean,
onMagnifyToggle: () => void,
onClose: () => void,
dragHandleRef: React.RefObject<HTMLDivElement>
) => {
if (!tabData.blockId || !ready) {
if (!blockData.blockId || !ready) {
return null;
}
const layoutModel: LayoutComponentModel = {
@ -41,11 +38,15 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
onClose,
onMagnifyToggle,
dragHandleRef,
isMagnified,
};
return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={layoutModel} preview={false} />;
return (
<Block key={blockData.blockId} blockId={blockData.blockId} layoutModel={layoutModel} preview={false} />
);
};
function renderPreview(tabData: TabLayoutData) {
if (!tabData) return;
return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={null} preview={true} />;
}
@ -59,7 +60,7 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
tabId: tabId,
onNodeDelete: onNodeDelete,
};
}, []);
}, [tabId]);
if (tabLoading) {
return (
@ -86,7 +87,7 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
<TileLayout
key={tabId}
contents={tileLayoutContents}
layoutTreeStateAtom={layoutStateAtom}
tabAtom={tabAtom}
getCursorPoint={getApi().getCursorPoint}
/>
</div>

View File

@ -45,8 +45,10 @@
--zindex-elem-modal: 100;
--zindex-window-drag: 100;
--zindex-tab-name: 3;
--zindex-layout-placeholder-container: 1;
--zindex-layout-overlay-container: 2;
--zindex-layout-display-container: 0;
--zindex-layout-resize-handle: 1;
--zindex-layout-placeholder-container: 2;
--zindex-layout-overlay-container: 3;
--zindex-block-mask-inner: 10;
// z-indexes in xterm.css

View File

@ -2,38 +2,59 @@
// SPDX-License-Identifier: Apache-2.0
import { TileLayout } from "./lib/TileLayout";
import { newLayoutTreeStateAtom, useLayoutTreeStateReducerAtom, withLayoutTreeState } from "./lib/layoutAtom";
import { LayoutModel } from "./lib/layoutModel";
import {
deleteLayoutModelForTab,
getLayoutModelForTab,
getLayoutModelForTabById,
useLayoutModel,
useLayoutNode,
} from "./lib/layoutModelHooks";
import { newLayoutNode } from "./lib/layoutNode";
import type {
LayoutNode,
LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeMoveNodeAction,
LayoutTreeState,
WritableLayoutNodeAtom,
WritableLayoutTreeStateAtom,
} from "./lib/model";
import { LayoutTreeAction, LayoutTreeActionType } from "./lib/model";
export {
LayoutTreeActionType,
TileLayout,
newLayoutNode,
newLayoutTreeStateAtom,
useLayoutTreeStateReducerAtom,
withLayoutTreeState,
};
export type {
ContentRenderer,
LayoutNode,
LayoutTreeAction,
LayoutTreeClearPendingAction,
LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
LayoutTreeMoveNodeAction,
LayoutTreeState,
WritableLayoutNodeAtom,
WritableLayoutTreeStateAtom,
LayoutTreeResizeNodeAction,
LayoutTreeSetPendingAction,
LayoutTreeStateSetter,
LayoutTreeSwapNodeAction,
} from "./lib/types";
import { LayoutTreeActionType } from "./lib/types";
export {
deleteLayoutModelForTab,
getLayoutModelForTab,
getLayoutModelForTabById,
LayoutModel,
LayoutTreeActionType,
newLayoutNode,
TileLayout,
useLayoutModel,
useLayoutNode,
};
export type {
ContentRenderer,
LayoutNode,
LayoutTreeAction,
LayoutTreeClearPendingAction,
LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
LayoutTreeMoveNodeAction,
LayoutTreeResizeNodeAction,
LayoutTreeSetPendingAction,
LayoutTreeStateSetter,
LayoutTreeSwapNodeAction,
};

View File

@ -8,8 +8,8 @@ import { TileLayout } from "./TileLayout.jsx";
import { useState } from "react";
import { newLayoutTreeStateAtom, useLayoutTreeStateReducerAtom } from "./layoutAtom.js";
import { newLayoutNode } from "./layoutNode.js";
import { LayoutTreeActionType, LayoutTreeInsertNodeAction, WritableLayoutTreeStateAtom } from "./model.js";
import "./tilelayout.stories.less";
import { LayoutTreeActionType, LayoutTreeInsertNodeAction, WritableLayoutTreeStateAtom } from "./types.js";
interface TestData {
name: string;

File diff suppressed because it is too large Load Diff

View File

@ -1,140 +1,52 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { WOS } from "@/app/store/global";
import { Atom, Getter, PrimitiveAtom, WritableAtom, atom, useAtom } from "jotai";
import { useCallback } from "react";
import { layoutTreeStateReducer, newLayoutTreeState } from "./layoutState.js";
import {
LayoutNode,
LayoutNodeWaveObj,
LayoutTreeAction,
LayoutTreeState,
WritableLayoutNodeAtom,
WritableLayoutTreeStateAtom,
} from "./model.js";
import { Atom, atom, Getter } from "jotai";
import { LayoutTreeState, WritableLayoutTreeStateAtom } from "./types";
// map from tabId => layout transforms (sizes and positions of the nodes)
let globalLayoutTransformsMap = new Map<string, Record<string, React.CSSProperties>>();
const layoutStateAtomMap: WeakMap<Atom<Tab>, WritableLayoutTreeStateAtom> = new WeakMap();
// const layoutStateLoadingAtomMap: WeakMap<Atom<Tab>, Atom<boolean>> = new WeakMap();
// const layoutStateAtomMap
/**
* Creates a new layout tree state wrapped as an atom.
* @param rootNode The root node for the tree.
* @returns The state wrapped as an atom.
*
* @template T The type of data associated with the nodes of the tree.
*/
export function newLayoutTreeStateAtom<T>(rootNode: LayoutNode<T>): PrimitiveAtom<LayoutTreeState<T>> {
return atom(newLayoutTreeState(rootNode)) as PrimitiveAtom<LayoutTreeState<T>>;
function getLayoutStateAtomFromTab(tabAtom: Atom<Tab>, get: Getter): WritableWaveObjectAtom<LayoutState> {
const tabData = get(tabAtom);
if (!tabData) return;
const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate);
const layoutStateAtom = WOS.getWaveObjectAtom<LayoutState>(layoutStateOref);
return layoutStateAtom;
}
/**
* Derives a WritableLayoutTreeStateAtom from a WritableLayoutNodeAtom, initializing the tree state.
* @param layoutNodeAtom The atom containing the root node for the LayoutTreeState.
* @returns The derived WritableLayoutTreeStateAtom.
*/
export function withLayoutTreeState<T>(layoutNodeAtom: WritableLayoutNodeAtom<T>): WritableLayoutTreeStateAtom<T> {
const pendingActionAtom = atom<LayoutTreeAction>(null) as PrimitiveAtom<LayoutTreeAction>;
const generationAtom = atom(0) as PrimitiveAtom<number>;
return atom(
export function withLayoutTreeStateAtomFromTab(tabAtom: Atom<Tab>): WritableLayoutTreeStateAtom {
if (layoutStateAtomMap.has(tabAtom)) {
console.log("found atom");
return layoutStateAtomMap.get(tabAtom);
}
const generationAtom = atom(1);
const treeStateAtom: WritableLayoutTreeStateAtom = atom(
(get) => {
const layoutState = newLayoutTreeState(get(layoutNodeAtom));
layoutState.pendingAction = get(pendingActionAtom);
layoutState.generation = get(generationAtom);
return layoutState;
const stateAtom = getLayoutStateAtomFromTab(tabAtom, get);
if (!stateAtom) return;
const layoutStateData = get(stateAtom);
console.log("layoutStateData", layoutStateData);
const layoutTreeState: LayoutTreeState = {
rootNode: layoutStateData?.rootnode,
magnifiedNodeId: layoutStateData?.magnifiednodeid,
generation: get(generationAtom),
};
return layoutTreeState;
},
(get, set, value) => {
set(pendingActionAtom, value.pendingAction);
if (get(generationAtom) !== value.generation) {
if (get(generationAtom) < value.generation) {
const stateAtom = getLayoutStateAtomFromTab(tabAtom, get);
console.log("setting new atom val", value);
if (!stateAtom) return;
const waveObjVal = get(stateAtom);
console.log("waveObjVal", waveObjVal);
waveObjVal.rootnode = value.rootNode;
waveObjVal.magnifiednodeid = value.magnifiedNodeId;
set(generationAtom, value.generation);
set(layoutNodeAtom, value.rootNode);
set(stateAtom, waveObjVal);
}
}
);
layoutStateAtomMap.set(tabAtom, treeStateAtom);
return treeStateAtom;
}
/**
* Hook to subscribe to the tree state and dispatch actions to its reducer functon.
* @param layoutTreeStateAtom The atom holding the layout tree state.
* @returns The current state of the tree and the dispatch function.
*/
export function useLayoutTreeStateReducerAtom<T>(
layoutTreeStateAtom: WritableLayoutTreeStateAtom<T>
): readonly [LayoutTreeState<T>, (action: LayoutTreeAction) => void] {
const [state, setState] = useAtom(layoutTreeStateAtom);
const dispatch = useCallback(
(action: LayoutTreeAction) => setState(layoutTreeStateReducer(state, action)),
[state, setState]
);
return [state, dispatch];
}
const tabLayoutAtomCache = new Map<string, WritableLayoutTreeStateAtom<TabLayoutData>>();
function getLayoutNodeWaveObjAtomFromTab<T>(
tabAtom: Atom<Tab>,
get: Getter
): WritableAtom<LayoutNodeWaveObj<T>, [value: LayoutNodeWaveObj<T>], void> {
const tabValue = get(tabAtom);
// console.log("getLayoutNodeWaveObjAtomFromTab tabValue", tabValue);
if (!tabValue) return;
const layoutNodeOref = WOS.makeORef("layout", tabValue.layoutnode);
// console.log("getLayoutNodeWaveObjAtomFromTab oref", layoutNodeOref);
return WOS.getWaveObjectAtom<LayoutNodeWaveObj<T>>(layoutNodeOref);
}
export function withLayoutStateAtomFromTab<T>(tabAtom: Atom<Tab>): WritableLayoutTreeStateAtom<T> {
const pendingActionAtom = atom<LayoutTreeAction>(null) as PrimitiveAtom<LayoutTreeAction>;
const generationAtom = atom(0) as PrimitiveAtom<number>;
return atom(
(get) => {
const waveObjAtom = getLayoutNodeWaveObjAtomFromTab<T>(tabAtom, get);
if (!waveObjAtom) return null;
const waveObj = get(waveObjAtom);
const layoutState = newLayoutTreeState(waveObj?.node);
layoutState.pendingAction = get(pendingActionAtom);
layoutState.generation = get(generationAtom);
layoutState.magnifiedNodeId = waveObj?.magnifiednodeid;
return layoutState;
},
(get, set, value) => {
set(pendingActionAtom, value.pendingAction);
if (get(generationAtom) !== value.generation) {
const waveObjAtom = getLayoutNodeWaveObjAtomFromTab<T>(tabAtom, get);
if (!waveObjAtom) return;
const newWaveObj = {
...get(waveObjAtom),
node: value.rootNode,
magnifiednodeid: value.magnifiedNodeId,
};
set(generationAtom, value.generation);
set(waveObjAtom, newWaveObj);
}
}
);
}
export function getLayoutStateAtomForTab(
tabId: string,
tabAtom: WritableAtom<Tab, [value: Tab], void>
): WritableLayoutTreeStateAtom<TabLayoutData> {
let atom = tabLayoutAtomCache.get(tabId);
if (atom) {
// console.log("Reusing atom for tab", tabId);
return atom;
}
// console.log("Creating new atom for tab", tabId);
atom = withLayoutStateAtomFromTab<TabLayoutData>(tabAtom);
tabLayoutAtomCache.set(tabId, atom);
return atom;
}
export function deleteLayoutStateAtomForTab(tabId: string) {
const atom = tabLayoutAtomCache.get(tabId);
if (atom) {
tabLayoutAtomCache.delete(tabId);
}
}
export { globalLayoutTransformsMap };

View File

@ -0,0 +1,589 @@
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 });
}
}

View File

@ -0,0 +1,54 @@
import { globalStore, WOS } from "@/app/store/global";
import useResizeObserver from "@react-hook/resize-observer";
import { atom, Atom, useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { withLayoutTreeStateAtomFromTab } from "./layoutAtom";
import { LayoutModel, LayoutNodeAdditionalProps } from "./layoutModel";
import { LayoutNode, TileLayoutContents } from "./types";
const layoutModelMap: Map<string, LayoutModel> = new Map();
export function getLayoutModelForTab(tabAtom: Atom<Tab>): LayoutModel {
const tabData = globalStore.get(tabAtom);
if (!tabData) return;
const tabId = tabData.oid;
if (layoutModelMap.has(tabId)) {
const layoutModel = layoutModelMap.get(tabData.oid);
if (layoutModel) {
if (!layoutModel.generationAtom) layoutModel.generationAtom = atom(0);
return layoutModel;
}
}
const layoutTreeStateAtom = withLayoutTreeStateAtomFromTab(tabAtom);
const layoutModel = new LayoutModel(layoutTreeStateAtom, globalStore.get, globalStore.set);
globalStore.sub(layoutTreeStateAtom, () => layoutModel.updateTreeState());
layoutModelMap.set(tabId, layoutModel);
return layoutModel;
}
export function getLayoutModelForTabById(tabId: string) {
const tabOref = WOS.makeORef("tab", tabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(tabOref);
return getLayoutModelForTab(tabAtom);
}
export function deleteLayoutModelForTab(tabId: string) {
if (layoutModelMap.has(tabId)) layoutModelMap.delete(tabId);
}
export function useLayoutModel(tabAtom: Atom<Tab>): LayoutModel {
return getLayoutModelForTab(tabAtom);
}
export function useTileLayout(tabAtom: Atom<Tab>, tileContent: TileLayoutContents): LayoutModel {
const layoutModel = useLayoutModel(tabAtom);
useResizeObserver(layoutModel?.displayContainerRef, layoutModel?.onContainerResize);
useEffect(() => layoutModel.registerTileLayout(tileContent), [tileContent]);
return layoutModel;
}
export function useLayoutNode(layoutModel: LayoutModel, layoutNode: LayoutNode): LayoutNodeAdditionalProps {
const [addlPropsAtom] = useState(layoutModel.getNodeAdditionalPropertiesAtom(layoutNode.id));
const addlProps = useAtomValue(addlPropsAtom);
return addlProps;
}

View File

@ -1,7 +1,7 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { DefaultNodeSize, LayoutNode } from "./model";
import { DefaultNodeSize, LayoutNode } from "./types";
import { FlexDirection, reverseFlexDirection } from "./utils";
/**
@ -10,16 +10,15 @@ import { FlexDirection, reverseFlexDirection } from "./utils";
* @param size The size for the new node.
* @param children The children for the new node.
* @param data The data for the new node.
* @template T The type of data associated with the node.
* @returns The new node.
*/
export function newLayoutNode<T>(
export function newLayoutNode(
flexDirection?: FlexDirection,
size?: number,
children?: LayoutNode<T>[],
data?: T
): LayoutNode<T> {
const newNode: LayoutNode<T> = {
children?: LayoutNode[],
data?: TabLayoutData
): LayoutNode {
const newNode: LayoutNode = {
id: crypto.randomUUID(),
flexDirection: flexDirection ?? FlexDirection.Row,
size: size ?? DefaultNodeSize,
@ -38,10 +37,9 @@ export function newLayoutNode<T>(
* @param node The parent node.
* @param idx The index to insert at.
* @param children The nodes to insert.
* @template T The type of data associated with the node.
* @returns The updated parent node.
*/
export function addChildAt<T>(node: LayoutNode<T>, idx: number, ...children: LayoutNode<T>[]) {
export function addChildAt(node: LayoutNode, idx: number, ...children: LayoutNode[]) {
console.log("adding", children, "to", node, "at index", idx);
if (children.length === 0) return;
@ -71,11 +69,10 @@ export function addChildAt<T>(node: LayoutNode<T>, idx: number, ...children: Lay
*
* If the node contains children, they are moved two levels deeper to preserve their flex direction. If the node only has data, it is moved one level deeper.
* @param node The node to add the intermediate node to.
* @template T The type of data associated with the node.
* @returns The updated node and the node that was added.
*/
export function addIntermediateNode<T>(node: LayoutNode<T>): LayoutNode<T> {
let intermediateNode: LayoutNode<T>;
export function addIntermediateNode(node: LayoutNode): LayoutNode {
let intermediateNode: LayoutNode;
console.log(node);
if (node.data) {
@ -98,10 +95,9 @@ export function addIntermediateNode<T>(node: LayoutNode<T>): LayoutNode<T> {
* @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>, startingIndex: number = 0) {
export function removeChild(parent: LayoutNode, childToRemove: LayoutNode, startingIndex: number = 0) {
if (!parent.children) return;
const idx = parent.children.indexOf(childToRemove, startingIndex);
if (idx === -1) return;
@ -112,10 +108,9 @@ export function removeChild<T>(parent: LayoutNode<T>, childToRemove: LayoutNode<
* Finds the node with the given id.
* @param node The node to search in.
* @param id The id to search for.
* @template T The type of data associated with the node.
* @returns The node with the given id or undefined if no node with the given id was found.
*/
export function findNode<T>(node: LayoutNode<T>, id: string): LayoutNode<T> | undefined {
export function findNode(node: LayoutNode, id: string): LayoutNode | undefined {
if (node.id === id) return node;
if (!node.children) return;
for (const child of node.children) {
@ -129,10 +124,9 @@ export function findNode<T>(node: LayoutNode<T>, id: string): LayoutNode<T> | un
* Finds the node whose children contains the node with the given id.
* @param node The node to start the search from.
* @param id The id to search for.
* @template T The type of data associated with the node.
* @returns The parent node, or undefined if no node with the given id was found.
*/
export function findParent<T>(node: LayoutNode<T>, id: string): LayoutNode<T> | undefined {
export function findParent(node: LayoutNode, id: string): LayoutNode | undefined {
if (node.id === id || !node.children) return;
for (const child of node.children) {
if (child.id === id) return node;
@ -145,10 +139,9 @@ export function findParent<T>(node: LayoutNode<T>, id: string): LayoutNode<T> |
/**
* Determines whether a node is valid.
* @param node The node to validate.
* @template T The type of data associated with the node.
* @returns True if the node is valid, false otherwise.
*/
export function validateNode<T>(node: LayoutNode<T>): boolean {
export function validateNode(node: LayoutNode): boolean {
if (!node.children == !node.data) {
console.error("Either children or data must be defined for node, not both");
return false;
@ -162,45 +155,60 @@ export function validateNode<T>(node: LayoutNode<T>): boolean {
}
/**
* Recursively corrects the tree to minimize nested single-child nodes, remove invalid nodes, and correct invalid flex direction order.
* Also finds all leaf nodes under the specified node.
* @param node The node to start the balancing from.
* @template T The type of data associated with the node.
* @returns The corrected node and an array of leaf nodes.
* Recursively walk the layout tree starting at the specified node. Run the specified callbacks, if any.
* @param node The node from which to start the walk.
* @param beforeWalkCallback An optional callback to run before walking a node's children.
* @param afterWalkCallback An optional callback to run after walking a node's children.
*/
export function balanceNode<T>(node: LayoutNode<T>): { node: LayoutNode<T>; leafs: LayoutNode<T>[] } | undefined {
const leafs: LayoutNode<T>[] = [];
const newNode = balanceNodeHelper(node, leafs);
return { node: newNode, leafs };
export function walkNodes(
node: LayoutNode,
beforeWalkCallback?: (node: LayoutNode) => void,
afterWalkCallback?: (node: LayoutNode) => void
) {
if (!node) return;
beforeWalkCallback?.(node);
node.children?.forEach((child) => walkNodes(child, beforeWalkCallback, afterWalkCallback));
afterWalkCallback?.(node);
}
function balanceNodeHelper<T>(node: LayoutNode<T>, leafs: LayoutNode<T>[]): LayoutNode<T> {
if (!node) return;
if (!node.children) {
leafs.push(node);
return node;
}
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);
/**
* Recursively corrects the tree to minimize nested single-child nodes, remove invalid nodes, and correct invalid flex direction order.
* @param node The node to start the balancing from.
* @param beforeWalkCallback Any optional callback to run before walking a node's children.
* @param afterWalkCallback An optional callback to run after walking a node's children.
* @returns The corrected node.
*/
export function balanceNode(
node: LayoutNode,
beforeWalkCallback?: (node: LayoutNode) => void,
afterWalkCallback?: (node: LayoutNode) => void
): LayoutNode {
walkNodes(
node,
(node) => {
beforeWalkCallback?.(node);
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) {
return child.children[0].children;
}
if (child.children?.length === 0) return;
return child;
});
},
(node) => {
node.children = node.children?.filter((v) => v);
if (node.children?.length === 1 && !node.children[0].children) {
node.data = node.children[0].data;
node.id = node.children[0].id;
node.children = undefined;
}
if (child.children?.length == 1 && child.children[0].children) {
return child.children[0].children;
}
return child;
})
.map((child) => {
return balanceNodeHelper(child, leafs);
})
.filter((v) => v);
if (node.children.length == 1 && !node.children[0].children) {
node.data = node.children[0].data;
node.id = node.children[0].id;
node.children = undefined;
}
afterWalkCallback?.(node);
}
);
return node;
}
@ -215,10 +223,7 @@ function balanceNodeHelper<T>(node: LayoutNode<T>, leafs: LayoutNode<T>[]): Layo
* @param maxChildren The maximum number of children a node can have.
* @returns The node to insert into and the index at which to insert.
*/
export function findNextInsertLocation<T>(
node: LayoutNode<T>,
maxChildren: number
): { node: LayoutNode<T>; index: number } {
export function findNextInsertLocation(node: LayoutNode, maxChildren: number): { node: LayoutNode; index: number } {
const insertLoc = findNextInsertLocationHelper(node, maxChildren, 1);
return { node: insertLoc?.node, index: insertLoc?.index };
}
@ -229,10 +234,10 @@ export function findNextInsertLocation<T>(
* @param indexArr The array of indices to aid in the traversal.
* @returns The node to insert into and the index at which to insert.
*/
export function findInsertLocationFromIndexArr<T>(
node: LayoutNode<T>,
export function findInsertLocationFromIndexArr(
node: LayoutNode,
indexArr: number[]
): { node: LayoutNode<T>; index: number } {
): { node: LayoutNode; index: number } {
function normalizeIndex(index: number) {
const childrenLength = node.children?.length ?? 1;
const lastChildIndex = childrenLength - 1;
@ -248,17 +253,17 @@ export function findInsertLocationFromIndexArr<T>(
if (indexArr.length == 0 || !node.children) {
return { node, index: nextIndex };
}
return findInsertLocationFromIndexArr<T>(node.children[nextIndex], indexArr);
return findInsertLocationFromIndexArr(node.children[nextIndex], indexArr);
}
function findNextInsertLocationHelper<T>(
node: LayoutNode<T>,
function findNextInsertLocationHelper(
node: LayoutNode,
maxChildren: number,
curDepth: number = 1
): { node: LayoutNode<T>; index: number; depth: number } {
): { node: LayoutNode; index: number; depth: number } {
if (!node) return;
if (!node.children) return { node, index: 1, depth: curDepth };
let insertLocs: { node: LayoutNode<T>; index: number; depth: number }[] = [];
let insertLocs: { node: LayoutNode; index: number; depth: number }[] = [];
if (node.children.length < maxChildren) {
insertLocs.push({ node, index: node.children.length, depth: curDepth });
}
@ -271,6 +276,6 @@ function findNextInsertLocationHelper<T>(
return insertLocs[0];
}
export function totalChildrenSize(node: LayoutNode<any>): number {
return parseFloat(node.children?.reduce((partialSum, child) => partialSum + child.size, 0).toPrecision(5));
export function totalChildrenSize(node: LayoutNode): number {
return node.children?.reduce((partialSum, child) => partialSum + child.size, 0);
}

View File

@ -14,7 +14,6 @@ import {
} from "./layoutNode";
import {
DefaultNodeSize,
LayoutNode,
LayoutTreeAction,
LayoutTreeActionType,
LayoutTreeComputeMoveNodeAction,
@ -24,103 +23,59 @@ import {
LayoutTreeMagnifyNodeToggleAction,
LayoutTreeMoveNodeAction,
LayoutTreeResizeNodeAction,
LayoutTreeSetPendingAction,
LayoutTreeState,
LayoutTreeSwapNodeAction,
MoveOperation,
} from "./model";
} from "./types";
import { DropDirection, FlexDirection } from "./utils";
/**
* Initializes a layout tree state.
* @param rootNode The root node for the tree.
* @returns The state of the tree.
*t
* @template T The type of data associated with the nodes of the tree.
*/
export function newLayoutTreeState<T>(rootNode: LayoutNode<T>): LayoutTreeState<T> {
const { node: balancedRootNode, leafs } = balanceNode(rootNode);
return {
rootNode: balancedRootNode,
leafs,
pendingAction: undefined,
generation: 0,
};
}
/**
* Performs a specified action on the layout tree state. Uses Immer Produce internally to resolve deep changes to the tree.
*
* @param layoutTreeState The state of the tree.
* @param layoutState The state of the tree.
* @param action The action to perform.
*
* @template T The type of data associated with the nodes of the tree.
* @returns The new state of the tree.
*/
export function layoutTreeStateReducer<T>(
layoutTreeState: LayoutTreeState<T>,
action: LayoutTreeAction
): LayoutTreeState<T> {
layoutTreeStateReducerInner(layoutTreeState, action);
return layoutTreeState;
export function layoutStateReducer(layoutState: LayoutTreeState, action: LayoutTreeAction): LayoutTreeState {
layoutStateReducerInner(layoutState, action);
return layoutState;
}
/**
* Helper function for layoutTreeStateReducer.
* @param layoutTreeState The state of the tree.
* Helper function for layoutStateReducer.
* @param layoutState The state of the tree.
* @param action The action to perform.
* @see layoutTreeStateReducer
* @template T The type of data associated with the nodes of the tree.
* @see layoutStateReducer
*/
function layoutTreeStateReducerInner<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeAction) {
function layoutStateReducerInner(layoutState: LayoutTreeState, action: LayoutTreeAction) {
switch (action.type) {
case LayoutTreeActionType.ComputeMove:
computeMoveNode(layoutTreeState, action as LayoutTreeComputeMoveNodeAction<T>);
break;
case LayoutTreeActionType.SetPendingAction:
setPendingAction(layoutTreeState, action as LayoutTreeSetPendingAction);
break;
case LayoutTreeActionType.ClearPendingAction:
layoutTreeState.pendingAction = undefined;
break;
case LayoutTreeActionType.CommitPendingAction:
if (!layoutTreeState?.pendingAction) {
console.error("unable to commit pending action, does not exist");
break;
}
layoutTreeStateReducerInner(layoutTreeState, layoutTreeState.pendingAction);
layoutTreeState.pendingAction = undefined;
computeMoveNode(layoutState, action as LayoutTreeComputeMoveNodeAction);
break;
case LayoutTreeActionType.Move:
moveNode(layoutTreeState, action as LayoutTreeMoveNodeAction<T>);
layoutTreeState.generation++;
moveNode(layoutState, action as LayoutTreeMoveNodeAction);
break;
case LayoutTreeActionType.InsertNode:
insertNode(layoutTreeState, action as LayoutTreeInsertNodeAction<T>);
layoutTreeState.generation++;
insertNode(layoutState, action as LayoutTreeInsertNodeAction);
break;
case LayoutTreeActionType.InsertNodeAtIndex:
insertNodeAtIndex(layoutTreeState, action as LayoutTreeInsertNodeAtIndexAction<T>);
layoutTreeState.generation++;
insertNodeAtIndex(layoutState, action as LayoutTreeInsertNodeAtIndexAction);
break;
case LayoutTreeActionType.DeleteNode:
deleteNode(layoutTreeState, action as LayoutTreeDeleteNodeAction);
layoutTreeState.generation++;
deleteNode(layoutState, action as LayoutTreeDeleteNodeAction);
break;
case LayoutTreeActionType.Swap:
swapNode(layoutTreeState, action as LayoutTreeSwapNodeAction);
layoutTreeState.generation++;
swapNode(layoutState, action as LayoutTreeSwapNodeAction);
break;
case LayoutTreeActionType.ResizeNode:
resizeNode(layoutTreeState, action as LayoutTreeResizeNodeAction);
layoutTreeState.generation++;
resizeNode(layoutState, action as LayoutTreeResizeNodeAction);
break;
case LayoutTreeActionType.MagnifyNodeToggle:
magnifyNodeToggle(layoutTreeState, action as LayoutTreeMagnifyNodeToggleAction);
layoutTreeState.generation++;
magnifyNodeToggle(layoutState, action as LayoutTreeMagnifyNodeToggleAction);
break;
default: {
console.error("Invalid reducer action", layoutTreeState, action);
console.error("Invalid reducer action", layoutState, action);
}
}
}
@ -128,18 +83,13 @@ function layoutTreeStateReducerInner<T>(layoutTreeState: LayoutTreeState<T>, act
/**
* Computes an operation for inserting a new node into the tree in the given direction relative to the specified node.
*
* @param layoutTreeState The state of the tree.
* @param layoutState The state of the tree.
* @param computeInsertAction The operation to compute.
*
* @template T The type of data associated with the nodes of the tree.
*/
function computeMoveNode<T>(
layoutTreeState: LayoutTreeState<T>,
computeInsertAction: LayoutTreeComputeMoveNodeAction<T>
) {
const rootNode = layoutTreeState.rootNode;
export function computeMoveNode(layoutState: LayoutTreeState, computeInsertAction: LayoutTreeComputeMoveNodeAction) {
const rootNode = layoutState.rootNode;
const { node, nodeToMove, direction } = computeInsertAction;
// console.log("computeInsertOperation start", layoutTreeState.rootNode, node, nodeToMove, direction);
// console.log("computeInsertOperation start", layoutState.rootNode, node, nodeToMove, direction);
if (direction === undefined) {
console.warn("No direction provided for insertItemInDirection");
return;
@ -150,7 +100,7 @@ function computeMoveNode<T>(
return;
}
let newMoveOperation: MoveOperation<T>;
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));
@ -289,8 +239,7 @@ function computeMoveNode<T>(
node2Id: nodeToMove.id,
};
// console.log("swapAction", swapAction);
layoutTreeState.pendingAction = swapAction;
return;
return swapAction;
} else {
console.warn("cannot swap");
}
@ -304,23 +253,15 @@ function computeMoveNode<T>(
(newMoveOperation.index !== nodeToMoveIndexInParent() &&
newMoveOperation.index !== nodeToMoveIndexInParent() + 1)
)
layoutTreeState.pendingAction = {
return {
type: LayoutTreeActionType.Move,
...newMoveOperation,
} as LayoutTreeMoveNodeAction<T>;
} as LayoutTreeMoveNodeAction;
}
function setPendingAction(layoutTreeState: LayoutTreeState<any>, action: LayoutTreeSetPendingAction) {
if (action.action === undefined) {
console.error("setPendingAction: invalid pending action passed to function");
return;
}
layoutTreeState.pendingAction = action.action;
}
function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMoveNodeAction<T>) {
const rootNode = layoutTreeState.rootNode;
// console.log("moveNode", action, layoutTreeState.rootNode);
export function moveNode(layoutState: LayoutTreeState, action: LayoutTreeMoveNodeAction) {
const rootNode = layoutState.rootNode;
// console.log("moveNode", action, layoutState.rootNode);
if (!action) {
console.error("no move node action provided");
return;
@ -368,61 +309,50 @@ function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMove
removeChild(oldParent, node, startingIndex);
}
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
layoutTreeState.rootNode = newRootNode;
layoutTreeState.leafs = leafs;
layoutTreeState.pendingAction = undefined;
layoutState.rootNode = balanceNode(layoutState.rootNode);
}
function insertNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeInsertNodeAction<T>) {
export function insertNode(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAction) {
if (!action?.node) {
console.error("insertNode cannot run, no insert node action provided");
return;
}
if (!layoutTreeState.rootNode) {
const { node: balancedNode, leafs } = balanceNode(action.node);
layoutTreeState.rootNode = balancedNode;
layoutTreeState.leafs = leafs;
if (!layoutState.rootNode) {
layoutState.rootNode = balanceNode(action.node);
return;
}
const insertLoc = findNextInsertLocation(layoutTreeState.rootNode, 5);
const insertLoc = findNextInsertLocation(layoutState.rootNode, 5);
addChildAt(insertLoc.node, insertLoc.index, action.node);
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
layoutTreeState.rootNode = newRootNode;
layoutTreeState.leafs = leafs;
layoutState.rootNode = balanceNode(layoutState.rootNode);
}
function insertNodeAtIndex<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeInsertNodeAtIndexAction<T>) {
export function insertNodeAtIndex(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAtIndexAction) {
if (!action?.node || !action?.indexArr) {
console.error("insertNodeAtIndex cannot run, either node or indexArr field is missing");
return;
}
if (!layoutTreeState.rootNode) {
const { node: balancedNode, leafs } = balanceNode(action.node);
layoutTreeState.rootNode = balancedNode;
layoutTreeState.leafs = leafs;
if (!layoutState.rootNode) {
layoutState.rootNode = balanceNode(action.node);
return;
}
const insertLoc = findInsertLocationFromIndexArr(layoutTreeState.rootNode, action.indexArr);
const insertLoc = findInsertLocationFromIndexArr(layoutState.rootNode, action.indexArr);
if (!insertLoc) {
console.error("insertNodeAtIndex unable to find insert location");
return;
}
addChildAt(insertLoc.node, insertLoc.index + 1, action.node);
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
layoutTreeState.rootNode = newRootNode;
layoutTreeState.leafs = leafs;
layoutState.rootNode = balanceNode(layoutState.rootNode);
}
function swapNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeSwapNodeAction) {
console.log("swapNode", layoutTreeState, action);
export function swapNode(layoutState: LayoutTreeState, action: LayoutTreeSwapNodeAction) {
console.log("swapNode", layoutState, action);
if (!action.node1Id || !action.node2Id) {
console.error("invalid swapNode action, both node1 and node2 must be defined");
return;
}
if (action.node1Id === layoutTreeState.rootNode.id || action.node2Id === layoutTreeState.rootNode.id) {
if (action.node1Id === layoutState.rootNode.id || action.node2Id === layoutState.rootNode.id) {
console.error("invalid swapNode action, the root node cannot be swapped");
return;
}
@ -431,8 +361,8 @@ function swapNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeSwap
return;
}
const parentNode1 = findParent(layoutTreeState.rootNode, action.node1Id);
const parentNode2 = findParent(layoutTreeState.rootNode, action.node2Id);
const parentNode1 = findParent(layoutState.rootNode, action.node1Id);
const parentNode2 = findParent(layoutState.rootNode, action.node2Id);
const parentNode1Index = parentNode1.children!.findIndex((child) => child.id === action.node1Id);
const parentNode2Index = parentNode2.children!.findIndex((child) => child.id === action.node2Id);
@ -446,27 +376,24 @@ function swapNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeSwap
parentNode1.children[parentNode1Index] = node2;
parentNode2.children[parentNode2Index] = node1;
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
layoutTreeState.rootNode = newRootNode;
layoutTreeState.leafs = leafs;
layoutState.rootNode = balanceNode(layoutState.rootNode);
}
function deleteNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeDeleteNodeAction) {
// console.log("deleteNode", layoutTreeState, action);
export function deleteNode(layoutState: LayoutTreeState, action: LayoutTreeDeleteNodeAction) {
// console.log("deleteNode", layoutState, action);
if (!action?.nodeId) {
console.error("no delete node action provided");
return;
}
if (!layoutTreeState.rootNode) {
if (!layoutState.rootNode) {
console.error("no root node");
return;
}
if (layoutTreeState.rootNode.id === action.nodeId) {
layoutTreeState.rootNode = undefined;
layoutTreeState.leafs = undefined;
if (layoutState.rootNode.id === action.nodeId) {
layoutState.rootNode = undefined;
return;
}
const parent = findParent(layoutTreeState.rootNode, action.nodeId);
const parent = findParent(layoutState.rootNode, action.nodeId);
if (parent) {
const node = parent.children.find((child) => child.id === action.nodeId);
removeChild(parent, node);
@ -474,13 +401,11 @@ function deleteNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeDe
} else {
console.error("unable to delete node, not found in tree");
}
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
layoutTreeState.rootNode = newRootNode;
layoutTreeState.leafs = leafs;
layoutState.rootNode = balanceNode(layoutState.rootNode);
}
function resizeNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeResizeNodeAction) {
console.log("resizeNode", layoutTreeState, action);
export function resizeNode(layoutState: LayoutTreeState, action: LayoutTreeResizeNodeAction) {
console.log("resizeNode", layoutState, action);
if (!action.resizeOperations) {
console.error("invalid resizeNode operation. nodeSizes array must be defined.");
}
@ -489,24 +414,24 @@ function resizeNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeRe
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);
const node = findNode(layoutState.rootNode, resize.nodeId);
node.size = resize.size;
}
}
function magnifyNodeToggle<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMagnifyNodeToggleAction) {
console.log("magnifyNodeToggle", layoutTreeState, action);
export function magnifyNodeToggle(layoutState: LayoutTreeState, action: LayoutTreeMagnifyNodeToggleAction) {
console.log("magnifyNodeToggle", layoutState, action);
if (!action.nodeId) {
console.error("invalid magnifyNodeToggle operation. nodeId must be defined.");
return;
}
if (layoutTreeState.rootNode.id === action.nodeId) {
if (layoutState.rootNode.id === action.nodeId) {
console.warn(`cannot toggle magnification of node ${action.nodeId} because it is the root node.`);
return;
}
if (layoutTreeState.magnifiedNodeId === action.nodeId) {
layoutTreeState.magnifiedNodeId = undefined;
if (layoutState.magnifiedNodeId === action.nodeId) {
layoutState.magnifiedNodeId = undefined;
} else {
layoutTreeState.magnifiedNodeId = action.nodeId;
layoutState.magnifiedNodeId = action.nodeId;
}
}

View File

@ -7,6 +7,8 @@
width: 100%;
overflow: hidden;
--gap-size-px: 5px;
.overlay-container,
.display-container,
.placeholder-container {
@ -20,6 +22,10 @@
min-width: 4rem;
}
.display-container {
z-index: var(--zindex-layout-display-container);
}
.placeholder-container {
z-index: var(--zindex-layout-placeholder-container);
}
@ -33,41 +39,37 @@
flex: 0 1 auto;
}
.overlay-node {
.resize-handle {
&.flex-row {
cursor: ew-resize;
.line {
height: 100%;
margin-left: 2px;
}
}
&.flex-column {
cursor: ns-resize;
.line {
margin-top: 2px;
}
}
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);
}
.resize-handle {
z-index: var(--zindex-layout-resize-handle);
&.flex-row {
cursor: ew-resize;
.line {
height: 100%;
margin-left: calc(var(--gap-size-px) - 1px);
}
}
&.resizing {
border: 1px solid var(--accent-color);
backdrop-filter: blur(8px);
&.flex-column {
cursor: ns-resize;
.line {
margin-top: calc(var(--gap-size-px) - 1px);
}
}
&:hover {
&.flex-row .line {
border-left: 2px solid var(--accent-color);
}
&.flex-column .line {
border-bottom: 2px solid var(--accent-color);
}
}
}
.tile-node {
border-radius: calc(var(--block-border-radius) + 2px);
overflow: hidden;
width: 100%;
height: 100%;
z-index: inherit;
&.dragging {
filter: blur(8px);
@ -90,6 +92,11 @@
height: 100%;
}
}
&.resizing {
border: 1px solid var(--accent-color);
backdrop-filter: blur(8px);
}
}
&.animate {

View File

@ -7,7 +7,7 @@ import { DropDirection, FlexDirection } from "./utils.js";
/**
* Represents an operation to insert a node into a tree.
*/
export type MoveOperation<T> = {
export type MoveOperation = {
/**
* The index at which the node will be inserted in the parent.
*/
@ -26,7 +26,7 @@ export type MoveOperation<T> = {
/**
* The node to insert.
*/
node: LayoutNode<T>;
node: LayoutNode;
};
/**
@ -56,31 +56,28 @@ export interface LayoutTreeAction {
/**
* Action for computing a move operation and saving it as a pending action in the tree state.
*
* @template T The type of data associated with the nodes of the tree.
* @see MoveOperation
* @see LayoutTreeMoveNodeAction
*/
export interface LayoutTreeComputeMoveNodeAction<T> extends LayoutTreeAction {
export interface LayoutTreeComputeMoveNodeAction extends LayoutTreeAction {
type: LayoutTreeActionType.ComputeMove;
node: LayoutNode<T>;
nodeToMove: LayoutNode<T>;
node: LayoutNode;
nodeToMove: LayoutNode;
direction: DropDirection;
}
/**
* Action for moving a node within the layout tree.
*
* @template T The type of data associated with the nodes of the tree.
* @see MoveOperation
*/
export interface LayoutTreeMoveNodeAction<T> extends LayoutTreeAction, MoveOperation<T> {
export interface LayoutTreeMoveNodeAction extends LayoutTreeAction, MoveOperation {
type: LayoutTreeActionType.Move;
}
/**
* Action for swapping two nodes within the layout tree.
*
* @template T The type of data associated with the nodes of the tree.
*/
export interface LayoutTreeSwapNodeAction extends LayoutTreeAction {
type: LayoutTreeActionType.Swap;
@ -98,22 +95,21 @@ export interface LayoutTreeSwapNodeAction extends LayoutTreeAction {
/**
* Action for inserting a new node to the layout tree.
*
* @template T The type of data associated with the nodes of the tree.
*/
export interface LayoutTreeInsertNodeAction<T> extends LayoutTreeAction {
export interface LayoutTreeInsertNodeAction extends LayoutTreeAction {
type: LayoutTreeActionType.InsertNode;
node: LayoutNode<T>;
node: LayoutNode;
}
/**
* Action for inserting a node into the layout tree at the specified index.
*/
export interface LayoutTreeInsertNodeAtIndexAction<T> extends LayoutTreeAction {
export interface LayoutTreeInsertNodeAtIndexAction extends LayoutTreeAction {
type: LayoutTreeActionType.InsertNodeAtIndex;
/**
* The node to insert.
*/
node: LayoutNode<T>;
node: LayoutNode;
/**
* The array of indices to traverse when inserting the node.
* The last index is the index within the parent node where the node should be inserted.
@ -193,55 +189,72 @@ export interface LayoutTreeMagnifyNodeToggleAction extends LayoutTreeAction {
nodeId: string;
}
/**
* Represents the state of a layout tree.
*
* @template T The type of data associated with the nodes of the tree.
*/
export type LayoutTreeState<T> = {
rootNode: LayoutNode<T>;
leafs: LayoutNode<T>[];
pendingAction: LayoutTreeAction;
generation: number;
magnifiedNodeId?: string;
};
/**
* Represents a single node in the layout tree.
* @template T The type of data associated with the node.
*/
export interface LayoutNode<T> {
export interface LayoutNode {
id: string;
data?: T;
children?: LayoutNode<T>[];
data?: TabLayoutData;
children?: LayoutNode[];
flexDirection: FlexDirection;
size: number;
}
/**
* An abstraction of the type definition for a writable layout node atom.
*/
export type WritableLayoutNodeAtom<T> = WritableAtom<LayoutNode<T>, [value: LayoutNode<T>], void>;
export type LayoutTreeStateSetter = (value: LayoutState) => void;
/**
* An abstraction of the type definition for a writable layout tree state atom.
*/
export type WritableLayoutTreeStateAtom<T> = WritableAtom<LayoutTreeState<T>, [value: LayoutTreeState<T>], void>;
export type LayoutTreeState = {
rootNode: LayoutNode;
magnifiedNodeId?: string;
generation: number;
};
export type ContentRenderer<T> = (
data: T,
export type WritableLayoutTreeStateAtom = WritableAtom<LayoutTreeState, [value: LayoutTreeState], void>;
export type ContentRenderer = (
data: TabLayoutData,
ready: boolean,
isMagnified: boolean,
disablePointerEvents: boolean,
onMagnifyToggle: () => void,
onClose: () => void,
dragHandleRef: React.RefObject<HTMLDivElement>
) => React.ReactNode;
export type PreviewRenderer<T> = (data: T) => React.ReactElement;
export interface LayoutNodeWaveObj<T> extends WaveObj {
node: LayoutNode<T>;
magnifiednodeid: string;
}
export type PreviewRenderer = (data: TabLayoutData) => React.ReactElement;
export const DefaultNodeSize = 10;
/**
* contains callbacks and information about the contents (or styling) of of the TileLayout
* nothing in here is specific to the TileLayout itself
*/
export interface TileLayoutContents {
/**
* A callback that accepts the data from the leaf node and displays the leaf contents to the user.
*/
renderContent: ContentRenderer;
/**
* A callback that accepts the data from the leaf node and returns a preview that can be shown when the user drags a node.
*/
renderPreview?: PreviewRenderer;
/**
* A callback that is called when a node gets deleted from the LayoutTreeState.
* @param data The contents of the node that was deleted.
*/
onNodeDelete?: (data: TabLayoutData) => Promise<void>;
/**
* The class name to use for the top-level div of the tile layout.
*/
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
*/
tabId?: string;
}

View File

@ -81,12 +81,16 @@ export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord
return code;
}
export function setTransform({ top, left, width, height }: Dimensions, setSize: boolean = true): CSSProperties {
export function setTransform(
{ top, left, width, height }: Dimensions,
setSize = true,
roundVals = true
): CSSProperties {
// Replace unitless items with px
const topRounded = Math.floor(top);
const leftRounded = Math.floor(left);
const widthRounded = Math.ceil(width);
const heightRounded = Math.ceil(height);
const topRounded = roundVals ? Math.floor(top) : top;
const leftRounded = roundVals ? Math.floor(left) : left;
const widthRounded = roundVals ? Math.ceil(width) : width;
const heightRounded = roundVals ? Math.ceil(height) : height;
const translate = `translate3d(${leftRounded}px,${topRounded}px, 0)`;
return {
top: 0,

View File

@ -9,38 +9,37 @@ import {
findNextInsertLocation,
newLayoutNode,
} from "../lib/layoutNode.js";
import { LayoutNode } from "../lib/model.js";
import { LayoutNode } from "../lib/types.js";
import { FlexDirection } from "../lib/utils.js";
import { TestData } from "./model.js";
test("newLayoutNode", () => {
assert.throws(
() => newLayoutNode<TestData>(FlexDirection.Column),
() => newLayoutNode(FlexDirection.Column),
"Invalid node",
undefined,
"calls to the constructor without data or children should fail"
);
assert.throws(
() => newLayoutNode<TestData>(FlexDirection.Column, undefined, [], { name: "hello" }),
() => newLayoutNode(FlexDirection.Column, undefined, [], { blockId: "hello" }),
"Invalid node",
undefined,
"calls to the constructor with both data and children should fail"
);
assert.doesNotThrow(
() => newLayoutNode<TestData>(FlexDirection.Column, undefined, undefined, { name: "hello" }),
() => newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "hello" }),
"Invalid node",
undefined,
"calls to the constructor with only data defined should succeed"
);
assert.throws(() => newLayoutNode<TestData>(FlexDirection.Column, undefined, [], undefined)),
assert.throws(() => newLayoutNode(FlexDirection.Column, undefined, [], undefined)),
"Invalid node",
undefined,
"calls to the constructor with empty children array should fail";
assert.doesNotThrow(() =>
newLayoutNode<TestData>(
newLayoutNode(
FlexDirection.Column,
undefined,
[newLayoutNode<TestData>(FlexDirection.Column, undefined, undefined, { name: "hello" })],
[newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "hello" })],
undefined
)
),
@ -50,10 +49,10 @@ test("newLayoutNode", () => {
});
test("addIntermediateNode", () => {
let node1: LayoutNode<TestData> = newLayoutNode<TestData>(FlexDirection.Column, undefined, [
newLayoutNode<TestData>(FlexDirection.Row, undefined, undefined, { name: "hello" }),
let node1: LayoutNode = newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "hello" }),
]);
assert(node1.children![0].data!.name === "hello", "node1 should have one child which should have data");
assert(node1.children![0].data!.blockId === "hello", "node1 should have one child which should have data");
const intermediateNode1 = addIntermediateNode(node1);
assert(
node1.children !== undefined && node1.children.length === 1 && node1.children?.includes(intermediateNode1),
@ -61,12 +60,12 @@ test("addIntermediateNode", () => {
);
assert(intermediateNode1.flexDirection === FlexDirection.Row, "intermediateNode1 should have flexDirection Row");
assert(
intermediateNode1.children![0].children![0].data!.name === "hello" &&
intermediateNode1.children![0].children![0].data!.blockId === "hello" &&
intermediateNode1.children![0].children![0].flexDirection === FlexDirection.Row,
"intermediateNode1 should have a nested child which should have data and flexDirection Row"
);
let node2: LayoutNode<TestData> = newLayoutNode<TestData>(FlexDirection.Column, undefined, undefined, {
name: "hello",
let node2: LayoutNode = newLayoutNode(FlexDirection.Column, undefined, undefined, {
blockId: "hello",
});
const intermediateNode2 = addIntermediateNode(node2);
assert(
@ -77,43 +76,43 @@ test("addIntermediateNode", () => {
"node2 should have no data and a single child intermediateNode2"
);
assert(
intermediateNode2.data.name === "hello" && intermediateNode2.children === undefined,
intermediateNode2.data.blockId === "hello" && intermediateNode2.children === undefined,
"intermediateNode2 should have no children and should have data matching the old value of node2"
);
});
test("addChildAt - same flexDirection, no children", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" });
let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node2" });
let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" });
let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" });
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data");
assert(node1.children![1].id === node2.id, "node1's second child should be node2");
assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should now have flexDirection Column");
});
test("addChildAt - different flexDirection, no children", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" });
let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2" });
let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" });
let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" });
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![0].data!.name === "node1", "node1's first child should have flexDirection Column");
assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data");
assert(node1.children![0].data!.blockId === "node1", "node1's first child should have flexDirection Column");
assert(node1.children![1].id === node2.id, "node1's second child should be node2");
assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should have flexDirection Row");
});
test("addChildAt - same flexDirection, first node has children, second doesn't", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }),
]);
let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2" });
let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" });
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data");
assert(
node1.children![0].flexDirection === FlexDirection.Column,
"node1's first child should have flexDirection Column"
@ -124,28 +123,28 @@ test("addChildAt - same flexDirection, first node has children, second doesn't",
test("addChildAt - different flexDirection, first node has children, second doesn't", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }),
]);
let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node2" });
let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" });
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data");
assert(node1.children![1].id === node2.id, "node1's second child should be node2");
assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should now have flexDirection Column");
});
test("addChildAt - same flexDirection, first node has children, second has children", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }),
]);
let node2 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" }),
]);
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data");
assert(
node1.children![0].flexDirection === FlexDirection.Column,
"node1's first child should have flexDirection Column"
@ -159,15 +158,15 @@ test("addChildAt - same flexDirection, first node has children, second has child
test("addChildAt - different flexDirection, first node has children, second has children", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }),
]);
let node2 = newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node2" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" }),
]);
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data");
assert(
node1.children![0].flexDirection === FlexDirection.Column,
"node1's first child should have flexDirection Column"
@ -181,10 +180,10 @@ test("addChildAt - different flexDirection, first node has children, second has
test("balanceNode - corrects flex directions", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1Inner1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1Inner2" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1Inner1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1Inner2" }),
]);
const newNode1 = balanceNode(node1).node;
const newNode1 = balanceNode(node1);
assert(newNode1 !== undefined, "newNode1 should not be undefined");
node1 = newNode1;
assert(node1.data === undefined, "node1 should have no data");
@ -194,48 +193,48 @@ test("balanceNode - corrects flex directions", () => {
test("balanceNode - collapses nodes with single grandchild 1", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
]),
]);
const newNode1 = balanceNode(node1).node;
const newNode1 = balanceNode(node1);
assert(newNode1 !== undefined, "newNode1 should not be undefined");
node1 = newNode1;
assert(node1.children === undefined, "node1 should have no children");
assert(node1.data!.name === "node1", "node1 should have data 'node1'");
assert(node1.data!.blockId === "node1", "node1 should have data 'node1'");
});
test("balanceNode - collapses nodes with single grandchild 2", () => {
let node2 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2Inner1" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2Inner2" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2Inner1" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2Inner2" }),
]),
]),
]);
const { node: newNode2, leafs } = balanceNode(node2);
const newNode2 = balanceNode(node2);
assert(newNode2 !== undefined, "newNode2 should not be undefined");
node2 = newNode2;
assert(node2.children!.length === 2, "node2 should have two children");
assert(node2.children[0].data!.name === "node2Inner1", "node2's first child should have data 'node2Inner1'");
assert(leafs.length === 2, "leafs should have two leafs");
assert(leafs[0].data!.name === "node2Inner1", "leafs[0] should have data 'node2Inner1'");
assert(leafs[1].data!.name === "node2Inner2", "leafs[1] should have data 'node2Inner2'");
assert(node2.children[0].data!.blockId === "node2Inner1", "node2's first child should have data 'node2Inner1'");
// assert(leafs.length === 2, "leafs should have two leafs");
// assert(leafs[0].data!.blockId === "node2Inner1", "leafs[0] should have data 'node2Inner1'");
// assert(leafs[1].data!.blockId === "node2Inner2", "leafs[1] should have data 'node2Inner2'");
});
test("balanceNode - collapses nodes with single grandchild 3", () => {
let node3 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node3" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node3" }),
]),
]),
]);
const newNode3 = balanceNode(node3).node;
const newNode3 = balanceNode(node3);
assert(newNode3 !== undefined, "newNode3 should not be undefined");
node3 = newNode3;
assert(node3.children === undefined, "node3 should have no children");
assert(node3.data!.name === "node3", "node3 should have data 'node3'");
assert(node3.data!.blockId === "node3", "node3 should have data 'node3'");
});
test("balanceNode - collapses nodes with single grandchild 4", () => {
@ -243,41 +242,41 @@ test("balanceNode - collapses nodes with single grandchild 4", () => {
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node4Inner1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node4Inner2" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node4Inner1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node4Inner2" }),
]),
]),
]),
]);
const newNode4 = balanceNode(node4);
assert(newNode4 !== undefined, "newNode4 should not be undefined");
node4 = newNode4.node;
node4 = newNode4;
assert(node4.children!.length === 1, "node4 should have one child");
assert(node4.children![0].children!.length === 2, "node4 should have two grandchildren");
assert(
node4.children[0].children![0].data!.name === "node4Inner1",
node4.children[0].children![0].data!.blockId === "node4Inner1",
"node4's first child should have data 'node4Inner1'"
);
});
test("findNextInsertLocation", () => {
const node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
]);
const insertLoc1 = findNextInsertLocation(node1, 5);
assert(insertLoc1.node.id === node1.id, "should insert into node1");
assert(insertLoc1.index === 4, "should insert into index 4 of node1");
const node2Inner5 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node2Inner5" });
const node2Inner5 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2Inner5" });
const node2 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
node2Inner5,
]);
@ -286,14 +285,14 @@ test("findNextInsertLocation", () => {
assert(insertLoc2.index === 1, "should insert into index 1 of node2Inner1");
const node3Inner5 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
]);
const node3Inner4 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node3Inner4" });
const node3Inner4 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node3Inner4" });
const node3 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }),
node3Inner4,
node3Inner5,
]);

View File

@ -3,8 +3,8 @@
import { assert, test } from "vitest";
import { newLayoutNode } from "../lib/layoutNode.js";
import { layoutTreeStateReducer, newLayoutTreeState } from "../lib/layoutState.js";
import { LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, LayoutTreeMoveNodeAction } from "../lib/model.js";
import { layoutStateReducer, newLayoutTreeState } from "../lib/layoutTree.js";
import { LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, LayoutTreeMoveNodeAction } from "../lib/types.js";
import { DropDirection } from "../lib/utils.js";
import { TestData } from "./model.js";
@ -12,7 +12,7 @@ test("layoutTreeStateReducer - compute move", () => {
let treeState = newLayoutTreeState<TestData>(newLayoutNode(undefined, undefined, undefined, { name: "root" }));
assert(treeState.rootNode.data!.name === "root", "root should have no children and should have data");
let node1 = newLayoutNode(undefined, undefined, undefined, { name: "node1" });
treeState = layoutTreeStateReducer(treeState, {
treeState = layoutStateReducer(treeState, {
type: LayoutTreeActionType.ComputeMove,
node: treeState.rootNode,
nodeToMove: node1,
@ -23,7 +23,7 @@ test("layoutTreeStateReducer - compute move", () => {
assert(!insertOperation.parentId, "insert operation parent should not be defined");
assert(insertOperation.index === 1, "insert operation index should equal 1");
assert(insertOperation.insertAtRoot, "insert operation insertAtRoot should be true");
treeState = layoutTreeStateReducer(treeState, {
treeState = layoutStateReducer(treeState, {
type: LayoutTreeActionType.CommitPendingAction,
});
assert(
@ -33,7 +33,7 @@ test("layoutTreeStateReducer - compute move", () => {
assert(treeState.rootNode.children![1].data!.name === "node1", "root's second child should be node1");
let node2 = newLayoutNode(undefined, undefined, undefined, { name: "node2" });
treeState = layoutTreeStateReducer(treeState, {
treeState = layoutStateReducer(treeState, {
type: LayoutTreeActionType.ComputeMove,
node: node1,
nodeToMove: node2,
@ -44,7 +44,7 @@ test("layoutTreeStateReducer - compute move", () => {
assert(insertOperation2.parentId === node1.id, "insert operation parent id should be node1 id");
assert(insertOperation2.index === 1, "insert operation index should equal 1");
assert(!insertOperation2.insertAtRoot, "insert operation insertAtRoot should be false");
treeState = layoutTreeStateReducer(treeState, {
treeState = layoutStateReducer(treeState, {
type: LayoutTreeActionType.CommitPendingAction,
});
assert(
@ -68,7 +68,7 @@ test("computeMove - noop action", () => {
nodeToMove,
direction: DropDirection.Left,
};
treeState = layoutTreeStateReducer(treeState, moveAction);
treeState = layoutStateReducer(treeState, moveAction);
assert(
treeState.pendingAction === undefined,
"inserting a node to the left of itself should not produce a pendingAction"
@ -81,7 +81,7 @@ test("computeMove - noop action", () => {
direction: DropDirection.Right,
};
treeState = layoutTreeStateReducer(treeState, moveAction);
treeState = layoutStateReducer(treeState, moveAction);
assert(
treeState.pendingAction === undefined,
"inserting a node to the right of itself should not produce a pendingAction"

View File

@ -20,6 +20,25 @@ declare global {
updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>;
};
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
type ThrottledValueAtom<T> = jotai.WritableAtom<T, [update: jotai.SetStateAction<T>], void>;
type AtomWithThrottle<T> = {
currentValueAtom: jotai.Atom<T>;
throttledValueAtom: ThrottledValueAtom<T>;
};
type DebouncedValueAtom<T> = jotai.WritableAtom<T, [update: jotai.SetStateAction<T>], void>;
type AtomWithDebounce<T> = {
currentValueAtom: jotai.Atom<T>;
debouncedValueAtom: DebouncedValueAtom<T>;
};
type SplitAtom<Item> = Atom<Atom<Item>[]>;
type WritableSplitAtom<Item> = WritableAtom<PrimitiveAtom<Item>[], [SplitAtomAction<Item>], void>;
type TabLayoutData = {
blockId: string;
};

View File

@ -200,9 +200,9 @@ declare global {
data64: string;
};
// wstore.LayoutNode
type LayoutNode = WaveObj & {
node?: any;
// wstore.LayoutState
type LayoutState = WaveObj & {
rootnode?: any;
magnifiednodeid?: string;
};
@ -389,7 +389,7 @@ declare global {
// wstore.Tab
type Tab = WaveObj & {
name: string;
layoutnode: string;
layoutstate: string;
blockids: string[];
};

View File

@ -1,13 +1,13 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { LayoutTreeState } from "frontend/layout/index";
import { LayoutModel } from "@/layout/index";
function findLeafIdFromBlockId(layoutTree: LayoutTreeState<TabLayoutData>, blockId: string): string {
if (layoutTree?.leafs == null) {
function findLeafIdFromBlockId(layoutModel: LayoutModel, blockId: string): string {
if (layoutModel?.leafs == null) {
return null;
}
for (let leaf of layoutTree.leafs) {
for (const leaf of layoutModel.leafs) {
if (leaf.data.blockId == blockId) {
return leaf.id;
}
@ -15,13 +15,13 @@ function findLeafIdFromBlockId(layoutTree: LayoutTreeState<TabLayoutData>, block
return null;
}
function isBlockMagnified(layoutTree: LayoutTreeState<TabLayoutData>, blockId: string): boolean {
if (layoutTree?.leafs == null || layoutTree.magnifiedNodeId == null) {
function isBlockMagnified(layoutModel: LayoutModel, blockId: string): boolean {
if (layoutModel?.leafs == null || layoutModel.treeState.magnifiedNodeId == null) {
return false;
}
for (let leaf of layoutTree.leafs) {
for (const leaf of layoutModel.leafs) {
if (leaf.data.blockId == blockId) {
return layoutTree.magnifiedNodeId == leaf.id;
return layoutModel.treeState.magnifiedNodeId == leaf.id;
}
}
return false;

View File

@ -3,7 +3,8 @@
import base64 from "base64-js";
import clsx from "clsx";
import * as jotai from "jotai";
import { Atom, atom, Getter, SetStateAction, Setter, useAtomValue } from "jotai";
import { debounce, throttle } from "throttle-debounce";
function isBlank(str: string): boolean {
return str == null || str == "";
@ -160,13 +161,13 @@ function jotaiLoadableValue<T>(value: Loadable<T>, def: T): T {
return def;
}
const NullAtom = jotai.atom(null);
const NullAtom = atom(null);
function useAtomValueSafe<T>(atom: jotai.Atom<T>): T {
function useAtomValueSafe<T>(atom: Atom<T>): T {
if (atom == null) {
return jotai.useAtomValue(NullAtom) as T;
return useAtomValue(NullAtom) as T;
}
return jotai.useAtomValue(atom);
return useAtomValue(atom);
}
/**
@ -195,7 +196,55 @@ function makeExternLink(url: string): string {
return "https://extern?" + encodeURIComponent(url);
}
function atomWithThrottle<T>(initialValue: T, delayMilliseconds = 500): AtomWithThrottle<T> {
// DO NOT EXPORT currentValueAtom as using this atom to set state can cause
// inconsistent state between currentValueAtom and throttledValueAtom
const _currentValueAtom = atom(initialValue);
const throttledValueAtom = atom(initialValue, (get, set, update: SetStateAction<T>) => {
const prevValue = get(_currentValueAtom);
const nextValue = typeof update === "function" ? (update as (prev: T) => T)(prevValue) : update;
set(_currentValueAtom, nextValue);
throttleUpdate(get, set);
});
const throttleUpdate = throttle(delayMilliseconds, (get: Getter, set: Setter) => {
const curVal = get(_currentValueAtom);
set(throttledValueAtom, curVal);
});
return {
currentValueAtom: atom((get) => get(_currentValueAtom)),
throttledValueAtom,
};
}
function atomWithDebounce<T>(initialValue: T, delayMilliseconds = 500): AtomWithDebounce<T> {
// DO NOT EXPORT currentValueAtom as using this atom to set state can cause
// inconsistent state between currentValueAtom and debouncedValueAtom
const _currentValueAtom = atom(initialValue);
const debouncedValueAtom = atom(initialValue, (get, set, update: SetStateAction<T>) => {
const prevValue = get(_currentValueAtom);
const nextValue = typeof update === "function" ? (update as (prev: T) => T)(prevValue) : update;
set(_currentValueAtom, nextValue);
debounceUpdate(get, set);
});
const debounceUpdate = debounce(delayMilliseconds, (get: Getter, set: Setter) => {
const curVal = get(_currentValueAtom);
set(debouncedValueAtom, curVal);
});
return {
currentValueAtom: atom((get) => get(_currentValueAtom)),
debouncedValueAtom,
};
}
export {
atomWithDebounce,
atomWithThrottle,
base64ToArray,
base64ToString,
boundNumber,

View File

@ -40,7 +40,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const waveWindow = await WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", windowId));
await WOS.loadAndPinWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
const initialTab = await WOS.loadAndPinWaveObject<Tab>(WOS.makeORef("tab", waveWindow.activetabid));
WOS.loadAndPinWaveObject<LayoutNode>(WOS.makeORef("layout", initialTab.layoutnode));
await WOS.loadAndPinWaveObject<LayoutState>(WOS.makeORef("layout", initialTab.layoutstate));
initWS();
const settings = await services.FileService.GetSettingsConfig();
console.log("settings", settings);

View File

@ -155,19 +155,19 @@ func CreateTab(ctx context.Context, workspaceId string, name string) (*Tab, erro
if ws == nil {
return nil, fmt.Errorf("workspace not found: %q", workspaceId)
}
layoutNodeId := uuid.NewString()
layoutStateId := uuid.NewString()
tab := &Tab{
OID: uuid.NewString(),
Name: name,
BlockIds: []string{},
LayoutNode: layoutNodeId,
OID: uuid.NewString(),
Name: name,
BlockIds: []string{},
LayoutState: layoutStateId,
}
layoutNode := &LayoutNode{
OID: layoutNodeId,
layoutState := &LayoutState{
OID: layoutStateId,
}
ws.TabIds = append(ws.TabIds, tab.OID)
DBInsert(tx.Context(), tab)
DBInsert(tx.Context(), layoutNode)
DBInsert(tx.Context(), layoutState)
DBUpdate(tx.Context(), ws)
return tab, nil
})
@ -289,7 +289,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string) error {
ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
DBUpdate(tx.Context(), ws)
DBDelete(tx.Context(), OType_Tab, tabId)
DBDelete(tx.Context(), OType_LayoutNode, tab.LayoutNode)
DBDelete(tx.Context(), OType_LayoutState, tab.LayoutState)
for _, blockId := range tab.BlockIds {
DBDelete(tx.Context(), OType_Block, blockId)
}

View File

@ -23,12 +23,12 @@ const (
)
const (
OType_Client = "client"
OType_Window = "window"
OType_Workspace = "workspace"
OType_Tab = "tab"
OType_LayoutNode = "layout"
OType_Block = "block"
OType_Client = "client"
OType_Window = "window"
OType_Workspace = "workspace"
OType_Tab = "tab"
OType_LayoutState = "layout"
OType_Block = "block"
)
type WaveObjUpdate struct {
@ -156,12 +156,12 @@ func (*Workspace) GetOType() string {
}
type Tab struct {
OID string `json:"oid"`
Version int `json:"version"`
Name string `json:"name"`
LayoutNode string `json:"layoutnode"`
BlockIds []string `json:"blockids"`
Meta MetaMapType `json:"meta"`
OID string `json:"oid"`
Version int `json:"version"`
Name string `json:"name"`
LayoutState string `json:"layoutstate"`
BlockIds []string `json:"blockids"`
Meta MetaMapType `json:"meta"`
}
func (*Tab) GetOType() string {
@ -176,16 +176,16 @@ func (t *Tab) GetBlockORefs() []waveobj.ORef {
return rtn
}
type LayoutNode struct {
type LayoutState struct {
OID string `json:"oid"`
Version int `json:"version"`
Node any `json:"node,omitempty"`
RootNode any `json:"rootnode,omitempty"`
MagnifiedNodeId string `json:"magnifiednodeid,omitempty"`
Meta MetaMapType `json:"meta,omitempty"`
}
func (*LayoutNode) GetOType() string {
return OType_LayoutNode
func (*LayoutState) GetOType() string {
return OType_LayoutState
}
type FileDef struct {
@ -254,6 +254,6 @@ func AllWaveObjTypes() []reflect.Type {
reflect.TypeOf(&Workspace{}),
reflect.TypeOf(&Tab{}),
reflect.TypeOf(&Block{}),
reflect.TypeOf(&LayoutNode{}),
reflect.TypeOf(&LayoutState{}),
}
}