From e85b0d205edb73d79740f263b8e0d9519f455d8b Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Wed, 14 Aug 2024 18:40:41 -0700 Subject: [PATCH] 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. --- frontend/app/app.tsx | 57 +- frontend/app/block/blockframe.tsx | 39 +- frontend/app/block/blocktypes.ts | 1 + frontend/app/store/global.ts | 53 +- frontend/app/store/wos.ts | 2 - frontend/app/tab/tabbar.tsx | 6 +- frontend/app/tab/tabcontent.tsx | 27 +- frontend/app/theme.less | 6 +- frontend/layout/index.ts | 71 +- frontend/layout/lib/TileLayout.stories.tsx | 2 +- frontend/layout/lib/TileLayout.tsx | 1036 ++++------------- frontend/layout/lib/layoutAtom.ts | 166 +-- frontend/layout/lib/layoutModel.ts | 589 ++++++++++ frontend/layout/lib/layoutModelHooks.ts | 54 + frontend/layout/lib/layoutNode.ts | 143 +-- .../lib/{layoutState.ts => layoutTree.ts} | 199 +--- frontend/layout/lib/tilelayout.less | 61 +- frontend/layout/lib/{model.ts => types.ts} | 107 +- frontend/layout/lib/utils.ts | 14 +- frontend/layout/tests/layoutNode.test.ts | 137 ++- frontend/layout/tests/layoutState.test.ts | 16 +- frontend/types/custom.d.ts | 19 + frontend/types/gotypes.d.ts | 8 +- frontend/util/layoututil.ts | 16 +- frontend/util/util.ts | 59 +- frontend/wave.ts | 2 +- pkg/wstore/wstore.go | 18 +- pkg/wstore/wstore_types.go | 34 +- 28 files changed, 1480 insertions(+), 1462 deletions(-) create mode 100644 frontend/layout/lib/layoutModel.ts create mode 100644 frontend/layout/lib/layoutModelHooks.ts rename frontend/layout/lib/{layoutState.ts => layoutTree.ts} (63%) rename frontend/layout/lib/{model.ts => types.ts} (67%) diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 343222c0..9625e860 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -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, p: Point): string { function switchBlockIdx(index: number) { const tabId = globalStore.get(atoms.activeTabId); const tabAtom = WOS.getWaveObjectAtom(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(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 = 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); } diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index aa5ea831..f68ec871 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -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(WOS.makeORef("tab", tabId)); - const layoutTreeState = util.useAtomValueSafe(getLayoutStateAtomForTab(tabId, tabAtom)); - const isMagnified = isBlockMagnified(layoutTreeState, blockData.oid); - const magnifyDecl: HeaderIconButton = { - elemtype: "iconbutton", - icon: , - title: isMagnified ? "Minimize" : "Magnify", - click: layoutModel?.onMagnifyToggle, - }; - return ; - } -); +const OptMagnifyButton = React.memo(({ layoutCompModel }: { layoutCompModel: LayoutComponentModel }) => { + const magnifyDecl: HeaderIconButton = { + elemtype: "iconbutton", + icon: , + title: layoutCompModel?.isMagnified ? "Minimize" : "Magnify", + click: layoutCompModel?.onMagnifyToggle, + }; + return ; +}); 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(); - endIconsElem.push(); + endIconsElem.push(); 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(WOS.makeORef("tab", tabId)); - const layoutTreeState: LayoutTreeState = 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); } diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts index e638462d..e305632e 100644 --- a/frontend/app/block/blocktypes.ts +++ b/frontend/app/block/blocktypes.ts @@ -5,6 +5,7 @@ export interface LayoutComponentModel { disablePointerEvents: boolean; onClose?: () => void; onMagnifyToggle?: () => void; + isMagnified: boolean; dragHandleRef?: React.RefObject; } diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 96b985df..e0e83b5c 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -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 = { + const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, - node: newLayoutNode(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(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 = { + const insertAction: LayoutTreeInsertNodeAtIndexAction = { type: LayoutTreeActionType.InsertNodeAtIndex, - node: newLayoutNode(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(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 = { + const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, - node: newLayoutNode(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, }; diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 8f1d560a..87826400 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -24,8 +24,6 @@ type WaveObjectValue = { holdTime: number; }; -type WritableWaveObjectAtom = jotai.WritableAtom; - function splitORef(oref: string): [string, string] { const parts = oref.split(":"); if (parts.length != 2) { diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index a1e3acab..9f1e92da 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -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) => { diff --git a/frontend/app/tab/tabcontent.tsx b/frontend/app/tab/tabcontent.tsx index e73093c9..fb2b38b1 100644 --- a/frontend/app/tab/tabcontent.tsx +++ b/frontend/app/tab/tabcontent.tsx @@ -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(oref), [oref]); - const layoutStateAtom = useMemo(() => getLayoutStateAtomForTab(tabId, tabAtom), [tabAtom, tabId]); const tabData = useAtomValue(tabAtom); const tileLayoutContents = useMemo(() => { - const renderBlock: ContentRenderer = ( - tabData: TabLayoutData, + const renderBlock: ContentRenderer = ( + blockData: TabLayoutData, ready: boolean, + isMagnified: boolean, disablePointerEvents: boolean, onMagnifyToggle: () => void, onClose: () => void, dragHandleRef: React.RefObject ) => { - 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 ; + return ( + + ); }; function renderPreview(tabData: TabLayoutData) { + if (!tabData) return; return ; } @@ -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 }) => { diff --git a/frontend/app/theme.less b/frontend/app/theme.less index 7d2633ae..6f056958 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -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 diff --git a/frontend/layout/index.ts b/frontend/layout/index.ts index b09734d0..1d214cf0 100644 --- a/frontend/layout/index.ts +++ b/frontend/layout/index.ts @@ -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, }; diff --git a/frontend/layout/lib/TileLayout.stories.tsx b/frontend/layout/lib/TileLayout.stories.tsx index 5682aef1..968596c2 100644 --- a/frontend/layout/lib/TileLayout.stories.tsx +++ b/frontend/layout/lib/TileLayout.stories.tsx @@ -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; diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index 49c2d1dd..4e47498d 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -1,10 +1,9 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import useResizeObserver from "@react-hook/resize-observer"; import clsx from "clsx"; import { toPng } from "html-to-image"; -import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; +import { Atom, useAtomValue, useSetAtom } from "jotai"; import React, { CSSProperties, ReactNode, @@ -12,7 +11,6 @@ import React, { memo, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -20,73 +18,22 @@ import React, { import { DropTargetMonitor, XYCoord, useDrag, useDragLayer, useDrop } from "react-dnd"; import { debounce, throttle } from "throttle-debounce"; import { useDevicePixelRatio } from "use-device-pixel-ratio"; -import { globalLayoutTransformsMap } from "./layoutAtom"; -import { findNode, totalChildrenSize } from "./layoutNode"; -import { layoutTreeStateReducer } from "./layoutState"; -import { - ContentRenderer, - LayoutNode, - LayoutTreeAction, - LayoutTreeActionType, - LayoutTreeComputeMoveNodeAction, - LayoutTreeDeleteNodeAction, - LayoutTreeMoveNodeAction, - LayoutTreeResizeNodeAction, - LayoutTreeSetPendingAction, - LayoutTreeState, - LayoutTreeSwapNodeAction, - PreviewRenderer, - WritableLayoutTreeStateAtom, -} from "./model"; -import { NodeRefMap } from "./nodeRefMap"; +import { LayoutModel, ResizeHandleProps } from "./layoutModel"; +import { useLayoutNode, useTileLayout } from "./layoutModelHooks"; import "./tilelayout.less"; -import { Dimensions, FlexDirection, determineDropDirection, setTransform } from "./utils"; +import { LayoutNode, LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, TileLayoutContents } from "./types"; +import { determineDropDirection } from "./utils"; -/** - * 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: T) => Promise; - /** - * 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; -} - -export interface TileLayoutProps { +export interface TileLayoutProps { /** * The atom containing the layout tree state. */ - layoutTreeStateAtom: WritableLayoutTreeStateAtom; + tabAtom: Atom; /** * callbacks and information about the contents (or styling) of the TileLayout or contents */ - contents: TileLayoutContents; + contents: TileLayoutContents; /** * 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. @@ -98,45 +45,29 @@ export interface TileLayoutProps { const DragPreviewWidth = 300; const DragPreviewHeight = 300; -function TileLayoutComponent({ layoutTreeStateAtom, contents, getCursorPoint }: TileLayoutProps) { - const overlayContainerRef = useRef(null); - const displayContainerRef = useRef(null); - const jotaiStore = useStore(); - const layoutTreeState = useAtomValue(layoutTreeStateAtom); - const [nodeRefsAtom] = useState>(atom(new NodeRefMap())); - const nodeRefs = useAtomValue(nodeRefsAtom); - const dispatch = useCallback( - (action: LayoutTreeAction) => { - const currentState = jotaiStore.get(layoutTreeStateAtom); - jotaiStore.set(layoutTreeStateAtom, layoutTreeStateReducer(currentState, action)); - }, - [layoutTreeStateAtom, jotaiStore] - ); - const [showOverlayAtom] = useState>(atom(false)); - const [showOverlay, setShowOverlay] = useAtom(showOverlayAtom); - - function onPointerOver() { - setShowOverlay(true); - } - - const [overlayTransform, setOverlayTransform] = useState(); - const [layoutLeafTransforms, setLayoutLeafTransformsRaw] = useState>({}); - - const setLayoutLeafTransforms = (transforms: Record) => { - globalLayoutTransformsMap.set(contents.tabId, transforms); - setLayoutLeafTransformsRaw(transforms); - }; +function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutProps) { + const layoutModel = useTileLayout(tabAtom, contents); + const generation = useAtomValue(layoutModel.generationAtom); + const overlayTransform = useAtomValue(layoutModel.overlayTransform); + const setActiveDrag = useSetAtom(layoutModel.activeDrag); + const setReady = useSetAtom(layoutModel.ready); + const isResizing = useAtomValue(layoutModel.isResizing); const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({ activeDrag: monitor.isDragging(), dragClientOffset: monitor.getClientOffset(), })); + useEffect(() => { + console.log("activeDrag", activeDrag); + setActiveDrag(activeDrag); + }, [setActiveDrag, activeDrag]); + const checkForCursorBounds = useCallback( debounce(100, (dragClientOffset: XYCoord) => { const cursorPoint = dragClientOffset ?? getCursorPoint?.(); - if (cursorPoint && displayContainerRef.current) { - const displayContainerRect = displayContainerRef.current.getBoundingClientRect(); + if (cursorPoint && layoutModel.displayContainerRef?.current) { + const displayContainerRect = layoutModel.displayContainerRef.current.getBoundingClientRect(); const normalizedX = cursorPoint.x - displayContainerRect.x; const normalizedY = cursorPoint.y - displayContainerRect.y; if ( @@ -145,173 +76,45 @@ function TileLayoutComponent({ layoutTreeStateAtom, contents, getCursorPoint normalizedY <= 0 || normalizedY >= displayContainerRect.height ) { - dispatch({ type: LayoutTreeActionType.ClearPendingAction }); + layoutModel.treeReducer({ type: LayoutTreeActionType.ClearPendingAction }); } } }), - [getCursorPoint, displayContainerRef, dispatch] + [getCursorPoint, generation] ); // Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture // because that conflicts with the DnD layer. useEffect(() => checkForCursorBounds(dragClientOffset), [dragClientOffset]); - /** - * Callback to update the transforms on the displayed leafs and move the overlay over the display layer when dragging. - */ - const updateTransforms = useCallback( - debounce(30, () => { - // TODO: janky way of preventing updates while a node resize is underway - if (layoutTreeState.pendingAction?.type === LayoutTreeActionType.ResizeNode) return; - if (overlayContainerRef.current && displayContainerRef.current) { - const displayBoundingRect = displayContainerRef.current.getBoundingClientRect(); - // console.log("displayBoundingRect", displayBoundingRect); - const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); - - const newLayoutLeafTransforms: Record = {}; - - // console.log( - // "nodeRefs", - // nodeRefs, - // "layoutLeafs", - // layoutTreeState.leafs, - // "layoutTreeState", - // layoutTreeState - // ); - - for (const leaf of layoutTreeState.leafs) { - const leafRef = nodeRefs.get(leaf.id); - // console.log("current leafRef", leafRef.current); - if (leafRef?.current) { - if (leaf.id === layoutTreeState.magnifiedNodeId) { - const transform = setTransform( - { - top: displayBoundingRect.height * 0.05, - left: displayBoundingRect.width * 0.05, - width: displayBoundingRect.width * 0.9, - height: displayBoundingRect.height * 0.9, - }, - true - ); - newLayoutLeafTransforms[leaf.id] = transform; - continue; - } - - const leafBounding = leafRef.current.getBoundingClientRect(); - const transform = setTransform({ - top: leafBounding.top - overlayBoundingRect.top, - left: leafBounding.left - overlayBoundingRect.left, - width: leafBounding.width, - height: leafBounding.height, - }); - newLayoutLeafTransforms[leaf.id] = transform; - } else { - console.warn("missing leaf", leaf.id); - } - } - - setLayoutLeafTransforms(newLayoutLeafTransforms); - - const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height; - // console.log("overlayOffset", newOverlayOffset); - setOverlayTransform( - setTransform( - { - top: activeDrag || showOverlay ? 0 : newOverlayOffset, - left: 0, - width: overlayBoundingRect.width, - height: overlayBoundingRect.height, - }, - false - ) - ); - } - }), - [activeDrag, showOverlay, overlayContainerRef, displayContainerRef, layoutTreeState.leafs, nodeRefs.generation] - ); - - // Update the transforms whenever we drag something and whenever the layout updates. - useLayoutEffect(() => { - updateTransforms(); - }, [updateTransforms]); - - useResizeObserver(overlayContainerRef, () => updateTransforms()); - // Ensure that we don't see any jostling in the layout when we're rendering it the first time. // `animate` will be disabled until after the transforms have all applied the first time. const [animate, setAnimate] = useState(false); useEffect(() => { + setReady(false); setTimeout(() => { setAnimate(true); + setReady(true); }, 50); }, []); - const onLeafClose = useCallback( - async (node: LayoutNode) => { - // console.log("onLeafClose", node); - const deleteAction: LayoutTreeDeleteNodeAction = { - type: LayoutTreeActionType.DeleteNode, - nodeId: node.id, - }; - // console.log("calling dispatch", deleteAction); - dispatch(deleteAction); - // console.log("calling onNodeDelete", node); - await contents.onNodeDelete?.(node.data); - // console.log("node deleted"); - }, - [contents.onNodeDelete, dispatch] - ); - - const onLeafMagnifyToggle = useCallback( - (node: LayoutNode) => { - const action = { - type: LayoutTreeActionType.MagnifyNodeToggle, - nodeId: node.id, - }; - - dispatch(action); - }, - [dispatch] + const tileStyle = useMemo( + () => ({ "--gap-size-px": `${layoutModel.gapSizePx}px` }) as CSSProperties, + [layoutModel.gapSizePx] ); return ( -
-
- -
- -
- {!layoutTreeState?.rootNode ? null : ( - - )} +
+
+ +
+ +
); @@ -319,115 +122,53 @@ function TileLayoutComponent({ layoutTreeStateAtom, contents, getCursorPoint export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; -interface DisplayNodesWrapperProps { +interface DisplayNodesWrapperProps { /** * The layout tree state. */ - layoutTreeState: LayoutTreeState; + layoutModel: LayoutModel; /** * contains callbacks and information about the contents (or styling) of of the TileLayout */ - contents: TileLayoutContents; - /** - * A callback that is called when a leaf node gets closed. - * @param node The node that is closed. - */ - onLeafClose: (node: LayoutNode) => void; - - /** - * A callback that is called when a leaf's magnification is being toggled. - * @param node The node that is being magnified or un-magnified. - */ - onLeafMagnifyToggle: (node: LayoutNode) => void; - - /** - * A series of CSS properties used to display a leaf node with the correct dimensions and position, as determined from its corresponding OverlayNode. - */ - layoutLeafTransforms: Record; - /** - * Determines whether the leaf nodes are ready to be displayed to the user. - */ - ready: boolean; - - /** - * Determines if a drag operation is in progress. - */ - activeDrag: boolean; + contents: TileLayoutContents; } -const DisplayNodesWrapper = memo( - ({ - layoutTreeState, - contents, - onLeafClose, - onLeafMagnifyToggle, - layoutLeafTransforms, - ready, - activeDrag, - }: DisplayNodesWrapperProps) => { - if (!layoutLeafTransforms) { - return null; - } - return layoutTreeState.leafs.map((leaf) => { - return ( - - ); - }); - } -); +const DisplayNodesWrapper = ({ layoutModel, contents }: DisplayNodesWrapperProps) => { + const generation = useAtomValue(layoutModel.generationAtom); -interface DisplayNodeProps { + return useMemo( + () => + layoutModel.leafs.map((leaf) => { + return ( + + ); + }), + [generation] + ); +}; + +interface DisplayNodeProps { + layoutModel: LayoutModel; /** * The leaf node object, containing the data needed to display the leaf contents to the user. */ - layoutNode: LayoutNode; + layoutNode: LayoutNode; /** * contains callbacks and information about the contents (or styling) of of the TileLayout */ - contents: TileLayoutContents; - - /** - * A callback that is called when a leaf's magnification is being toggled. - * @param node The node that is being magnified or unmagnified. - */ - onLeafMagnifyToggle: (node: LayoutNode) => void; - - /** - * A callback that is called when a leaf node gets closed. - * @param node The node that is closed. - */ - onLeafClose: (node: LayoutNode) => void; - - /** - * Determines whether a leaf's contents should be displayed to the user. - */ - ready: boolean; - - /** - * Determines if a drag operation is in progress. - */ - activeDrag: boolean; + contents: TileLayoutContents; /** * Any class names to add to the component. */ className?: string; - - /** - * A series of CSS properties used to display a leaf node with the correct dimensions and position, as determined from its corresponding OverlayNode. - */ - transform: CSSProperties; } const dragItemType = "TILE_ITEM"; @@ -435,176 +176,161 @@ const dragItemType = "TILE_ITEM"; /** * The draggable and displayable portion of a leaf node in a layout tree. */ -const DisplayNode = memo( - ({ - layoutNode, - contents, - transform, - onLeafMagnifyToggle, - onLeafClose, - ready, - activeDrag, - className, - }: DisplayNodeProps) => { - const tileNodeRef = useRef(null); - const dragHandleRef = useRef(null); - const previewRef = useRef(null); +const DisplayNode = ({ layoutModel, layoutNode, contents, className }: DisplayNodeProps) => { + const tileNodeRef = useRef(null); + const dragHandleRef = useRef(null); + const previewRef = useRef(null); + const addlProps = useLayoutNode(layoutModel, layoutNode); + const activeDrag = useAtomValue(layoutModel.activeDrag); + const globalReady = useAtomValue(layoutModel.ready); + const layoutGeneration = useAtomValue(layoutModel.generationAtom); - const devicePixelRatio = useDevicePixelRatio(); + const devicePixelRatio = useDevicePixelRatio(); - const [{ isDragging }, drag, dragPreview] = useDrag( - () => ({ - type: dragItemType, - item: () => layoutNode, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), + const [{ isDragging }, drag, dragPreview] = useDrag( + () => ({ + type: dragItemType, + item: () => layoutNode, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), }), - [layoutNode] - ); - - const [previewElementGeneration, setPreviewElementGeneration] = useState(0); - const previewElement = useMemo(() => { - setPreviewElementGeneration(previewElementGeneration + 1); - return ( -
-
- {contents.renderPreview?.(layoutNode.data)} -
-
- ); - }, [contents.renderPreview, devicePixelRatio]); - - const [previewImage, setPreviewImage] = useState(null); - const [previewImageGeneration, setPreviewImageGeneration] = useState(0); - const generatePreviewImage = useCallback(() => { - const offsetX = (DragPreviewWidth * devicePixelRatio - DragPreviewWidth) / 2 + 10; - const offsetY = (DragPreviewHeight * devicePixelRatio - DragPreviewHeight) / 2 + 10; - if (previewImage !== null && previewElementGeneration === previewImageGeneration) { - dragPreview(previewImage, { offsetY, offsetX }); - } else if (previewRef.current) { - setPreviewImageGeneration(previewElementGeneration); - toPng(previewRef.current).then((url) => { - const img = new Image(); - img.src = url; - setPreviewImage(img); - dragPreview(img, { offsetY, offsetX }); - }); - } - }, [ - dragPreview, - previewRef.current, - previewElementGeneration, - previewImageGeneration, - previewImage, - devicePixelRatio, - ]); - - // Register the tile item as a draggable component - useEffect(() => { - drag(dragHandleRef); - }, [drag, dragHandleRef.current]); - - const onClose = useCallback(() => { - onLeafClose(layoutNode); - }, [layoutNode, onLeafClose]); - - const onMagnifyToggle = useCallback(() => { - onLeafMagnifyToggle(layoutNode); - }, [layoutNode, onLeafMagnifyToggle]); - - const leafContent = useMemo(() => { - return ( - layoutNode.data && ( -
- {contents.renderContent( - layoutNode.data, - ready, - activeDrag, - onMagnifyToggle, - onClose, - dragHandleRef - )} -
- ) - ); - }, [layoutNode.data, ready, activeDrag, onClose]); + }), + [layoutNode] + ); + const [previewElementGeneration, setPreviewElementGeneration] = useState(0); + const previewElement = useMemo(() => { + setPreviewElementGeneration(previewElementGeneration + 1); return ( -
event.stopPropagation()} - > - {leafContent} - {previewElement} +
+
+ {contents.renderPreview?.(layoutNode.data)} +
); - } -); + }, [contents.renderPreview, devicePixelRatio, layoutNode.data]); -interface OverlayNodeProps { + const [previewImage, setPreviewImage] = useState(null); + const [previewImageGeneration, setPreviewImageGeneration] = useState(0); + const generatePreviewImage = useCallback(() => { + const offsetX = (DragPreviewWidth * devicePixelRatio - DragPreviewWidth) / 2 + 10; + const offsetY = (DragPreviewHeight * devicePixelRatio - DragPreviewHeight) / 2 + 10; + if (previewImage !== null && previewElementGeneration === previewImageGeneration) { + dragPreview(previewImage, { offsetY, offsetX }); + } else if (previewRef.current) { + setPreviewImageGeneration(previewElementGeneration); + toPng(previewRef.current).then((url) => { + const img = new Image(); + img.src = url; + setPreviewImage(img); + dragPreview(img, { offsetY, offsetX }); + }); + } + }, [ + dragPreview, + previewRef.current, + previewElementGeneration, + previewImageGeneration, + previewImage, + devicePixelRatio, + layoutNode.data, + ]); + + // Register the tile item as a draggable component + useEffect(() => { + drag(dragHandleRef); + }, [drag, dragHandleRef.current]); + + const leafContent = useMemo(() => { + return ( + layoutNode.data && ( +
+ {contents.renderContent( + layoutNode.data, + globalReady, + layoutNode.id === layoutModel.treeState.magnifiedNodeId, + activeDrag, + () => layoutModel.magnifyNode(layoutNode), + () => layoutModel.closeNode(layoutNode), + dragHandleRef + )} +
+ ) + ); + }, [layoutNode, globalReady, layoutGeneration, activeDrag, addlProps]); + + return ( +
event.stopPropagation()} + > + {leafContent} + {previewElement} +
+ ); +}; + +interface OverlayNodeWrapperProps { + layoutModel: LayoutModel; +} + +const OverlayNodeWrapper = ({ layoutModel }: OverlayNodeWrapperProps) => { + const generation = useAtomValue(layoutModel.generationAtom); + const overlayTransform = useAtomValue(layoutModel.overlayTransform); + + const overlayNodes = useMemo( + () => + layoutModel.leafs.map((leaf) => { + return ; + }), + [generation] + ); + + return ( +
+ {overlayNodes} +
+ ); +}; + +interface OverlayNodeProps { /** * The layout node object corresponding to the OverlayNode. */ - layoutNode: LayoutNode; + layoutNode: LayoutNode; /** * The layout tree state. */ - layoutTreeState: LayoutTreeState; - /** - * The reducer function for mutating the layout tree state. - * @param action The action to perform. - */ - dispatch: (action: LayoutTreeAction) => void; - - nodeRefsAtom: PrimitiveAtom; - - showOverlayAtom: PrimitiveAtom; - - showResizeOverlay?: boolean; - - siblingSize: number; + layoutModel: LayoutModel; } /** * An overlay representing the true flexbox layout of the LayoutTreeState. This holds the drop targets for moving around nodes and is used to calculate the * dimensions of the corresponding DisplayNode for each LayoutTreeState leaf. */ -const OverlayNode = ({ - layoutNode, - layoutTreeState, - dispatch, - nodeRefsAtom, - showOverlayAtom, - showResizeOverlay, - siblingSize, -}: OverlayNodeProps) => { +const OverlayNode = ({ layoutNode, layoutModel }: OverlayNodeProps) => { + const additionalProps = useLayoutNode(layoutModel, layoutNode); const overlayRef = useRef(null); - const leafRef = useRef(null); - - const setShowOverlay = useSetAtom(showOverlayAtom); - - const setNodeRefs = useSetAtom(nodeRefsAtom); + const generation = useAtomValue(layoutModel.generationAtom); + const pendingAction = useAtomValue(layoutModel.pendingAction.throttledValueAtom); const [, drop] = useDrop( () => ({ accept: dragItemType, canDrop: (_, monitor) => { - const dragItem = monitor.getItem>(); + const dragItem = monitor.getItem(); if (monitor.isOver({ shallow: true }) && dragItem?.id !== layoutNode.id) { return true; } @@ -612,246 +338,84 @@ const OverlayNode = ({ }, drop: (_, monitor) => { // console.log("drop start", layoutNode.id, layoutTreeState.pendingAction); - if (!monitor.didDrop() && layoutTreeState.pendingAction) { - dispatch({ + if (!monitor.didDrop() && pendingAction) { + layoutModel.treeReducer({ type: LayoutTreeActionType.CommitPendingAction, }); } }, hover: throttle(30, (_, monitor: DropTargetMonitor) => { if (monitor.isOver({ shallow: true })) { - if (monitor.canDrop()) { - const dragItem = monitor.getItem>(); - // console.log("computing operation", layoutNode, dragItem, layoutTreeState.pendingAction); - dispatch({ + if (monitor.canDrop() && layoutModel.displayContainerRef?.current) { + const dragItem = monitor.getItem(); + // console.log("computing operation", layoutNode, dragItem, additionalProps.rect); + const offset = monitor.getClientOffset(); + const containerRect = layoutModel.displayContainerRef.current?.getBoundingClientRect(); + offset.x -= containerRect?.x; + offset.y -= containerRect?.y; + layoutModel.treeReducer({ type: LayoutTreeActionType.ComputeMove, node: layoutNode, nodeToMove: dragItem, - direction: determineDropDirection( - overlayRef.current?.getBoundingClientRect(), - monitor.getClientOffset() - ), - } as LayoutTreeComputeMoveNodeAction); + direction: determineDropDirection(additionalProps?.rect, offset), + } as LayoutTreeComputeMoveNodeAction); } else { - dispatch({ + layoutModel.treeReducer({ type: LayoutTreeActionType.ClearPendingAction, }); } } }), }), - [overlayRef.current, layoutNode, layoutTreeState, dispatch] + [layoutNode, pendingAction, generation, additionalProps, layoutModel.displayContainerRef] ); // Register the tile item as a draggable component useEffect(() => { - const layoutNodeId = layoutNode?.id; - if (overlayRef?.current) { - drop(overlayRef); - setNodeRefs((nodeRefs) => { - nodeRefs.set(layoutNodeId, overlayRef); - return nodeRefs; - }); - } + drop(overlayRef); + }, []); - return () => { - setNodeRefs((nodeRefs) => { - nodeRefs.delete(layoutNodeId); - return nodeRefs; - }); - }; - }, [overlayRef, layoutNode?.id]); - - function onPointerOverLeaf(event: React.PointerEvent) { - event.stopPropagation(); - setShowOverlay(false); - } - - const [resizeOnCurrentNode, setResizeOnCurrentNode] = useState(false); - const [pendingSize, setPendingSize] = useState(undefined); - - useLayoutEffect(() => { - if (showResizeOverlay) { - setResizeOnCurrentNode(false); - setPendingSize(undefined); - return; - } - if (layoutTreeState.pendingAction?.type === LayoutTreeActionType.ResizeNode) { - const resizeAction = layoutTreeState.pendingAction as LayoutTreeResizeNodeAction; - const resizeOperation = resizeAction?.resizeOperations?.find( - (operation) => operation.nodeId === layoutNode.id - ); - if (resizeOperation) { - setResizeOnCurrentNode(true); - setPendingSize(resizeOperation.size); - return; - } - } - setResizeOnCurrentNode(false); - setPendingSize(undefined); - }, [showResizeOverlay, layoutTreeState.pendingAction]); - - const generateChildren = () => { - if (Array.isArray(layoutNode.children)) { - const totalSize = totalChildrenSize(layoutNode); - return layoutNode.children - .map((childItem, i) => { - return [ - , - , - ]; - }) - .flat() - .slice(0, -1); - } else { - return [
]; - } - }; - - if (!layoutNode) { - return null; - } - - const sizePercentage = ((pendingSize ?? layoutNode.size) / siblingSize) * 100; - - return ( -
- {generateChildren()} -
- ); + return
; }; -interface ResizeHandleProps { - parentNode: LayoutNode; - index: number; - dispatch: (action: LayoutTreeAction) => void; - nodeRefsAtom: PrimitiveAtom; +interface ResizeHandleWrapperProps { + layoutModel: LayoutModel; } -const ResizeHandle = ({ parentNode, index, dispatch, nodeRefsAtom }: ResizeHandleProps) => { +const ResizeHandleWrapper = ({ layoutModel }: ResizeHandleWrapperProps) => { + const resizeHandles = useAtomValue(layoutModel.resizeHandles) as Atom[]; + + return resizeHandles.map((resizeHandleAtom, i) => ( + + )); +}; + +interface ResizeHandleComponentProps { + resizeHandleAtom: Atom; + layoutModel: LayoutModel; +} + +const ResizeHandle = ({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => { + const resizeHandleProps = useAtomValue(resizeHandleAtom); const resizeHandleRef = useRef(null); // The pointer currently captured, or undefined. const [trackingPointer, setTrackingPointer] = useState(undefined); - const nodeRefs = useAtomValue(nodeRefsAtom); - - // Cached values set in startResize - const [combinedNodesRect, setCombinedNodesRect] = useState(); - const [gapSize, setGapSize] = useState(0); - const [pixelToSizeRatio, setPixelToSizeRatio] = useState(0); - - // Precompute some values that will be needed by the handlePointerMove function - const startResize = () => { - const parentRef = nodeRefs.get(parentNode.id); - const node1Ref = nodeRefs.get(parentNode.children![index].id); - const node2Ref = nodeRefs.get(parentNode.children![index + 1].id); - if (parentRef?.current && node1Ref?.current && node2Ref?.current) { - const parentIsRow = parentNode.flexDirection === FlexDirection.Row; - const parentRectNew = parentRef.current.getBoundingClientRect(); - const node1Rect = node1Ref.current.getBoundingClientRect(); - const node2Rect = node2Ref.current.getBoundingClientRect(); - const totalSiblingSize = totalChildrenSize(parentNode); - const gapSize = parentIsRow - ? node2Rect.left - (node1Rect.left + node1Rect.width) - : node2Rect.top - (node1Rect.top + node1Rect.height); - setGapSize(gapSize); - const parentPixelsMinusGap = - (parentIsRow ? parentRectNew.width : parentRectNew.height) - - (gapSize * parentNode.children!.length - 1); - const newPixelToSizeRatio = totalSiblingSize / parentPixelsMinusGap; - // console.log("newPixelToSizeRatio", newPixelToSizeRatio, siblingSize, parentPixelsMinusGap); - setPixelToSizeRatio(newPixelToSizeRatio); - const newCombinedNodesRect: Dimensions = { - top: node1Rect.top, - left: node1Rect.left, - height: parentIsRow ? node1Rect.height : node1Rect.height + node2Rect.height + gapSize, - width: parentIsRow ? node1Rect.width + node2Rect.width + gapSize : node1Rect.width, - }; - setCombinedNodesRect(newCombinedNodesRect); - // console.log( - // "startResize", - // parentNode, - // index, - // parentIsRow, - // gapSize, - // parentRectNew, - // node1Rect, - // node2Rect, - // newCombinedNodesRect, - // newPixelToSizeRatio - // ); - } - }; // Calculates the new size of the two nodes on either side of the handle, based on the position of the cursor const handlePointerMove = useCallback( throttle(10, (event: React.PointerEvent) => { if (trackingPointer === event.pointerId) { const { clientX, clientY } = event; - // console.log("handlePointerMove", [clientX, clientY], combinedNodesRect, pixelToSizeRatio); - const parentIsRow = parentNode.flexDirection === FlexDirection.Row; - const combinedStart = parentIsRow ? combinedNodesRect.left : combinedNodesRect.top; - const combinedEnd = parentIsRow - ? combinedNodesRect.left + combinedNodesRect.width - : combinedNodesRect.top + combinedNodesRect.height; - const clientPoint = parentIsRow ? clientX : clientY; - // console.log("handlePointerMove", parentNode, index, clientX, clientY, parentRect, combinedNodesRect); - if (clientPoint > combinedStart + 10 && clientPoint < combinedEnd - 10) { - const halfGap = gapSize / 2; - const sizeNode1 = clientPoint - combinedStart - halfGap; - const sizeNode2 = combinedEnd - clientPoint + halfGap; - const resizeAction: LayoutTreeResizeNodeAction = { - type: LayoutTreeActionType.ResizeNode, - resizeOperations: [ - { - nodeId: parentNode.children![index].id, - size: parseFloat((sizeNode1 * pixelToSizeRatio).toPrecision(5)), - }, - { - nodeId: parentNode.children![index + 1].id, - size: parseFloat((sizeNode2 * pixelToSizeRatio).toPrecision(5)), - }, - ], - }; - const setPendingAction: LayoutTreeSetPendingAction = { - type: LayoutTreeActionType.SetPendingAction, - action: resizeAction, - }; - - dispatch(setPendingAction); - } + layoutModel.onResizeMove(resizeHandleProps, clientX, clientY); } }), - [parentNode, trackingPointer, dispatch, gapSize, combinedNodesRect, pixelToSizeRatio, index] + [trackingPointer, layoutModel.onResizeMove, resizeHandleProps] ); // We want to use pointer capture so the operation continues even if the pointer leaves the bounds of the handle function onPointerDown(event: React.PointerEvent) { resizeHandleRef.current?.setPointerCapture(event.pointerId); - startResize(); } // This indicates that we're ready to start tracking the resize operation via the pointer @@ -863,23 +427,19 @@ const ResizeHandle = ({ parentNode, index, dispatch, nodeRefsAtom }: ResizeH const onPointerRelease = useCallback( debounce(30, (event: React.PointerEvent) => { setTrackingPointer(undefined); - dispatch({ type: LayoutTreeActionType.CommitPendingAction }); + layoutModel.onResizeEnd(); }), - [dispatch] + [layoutModel] ); - // Don't render if we are dealing with the last child of a parent - if (index + 1 >= parentNode.children!.length) { - return; - } - return (
@@ -887,19 +447,11 @@ const ResizeHandle = ({ parentNode, index, dispatch, nodeRefsAtom }: ResizeH ); }; -interface PlaceholderProps { +interface PlaceholderProps { /** * The layout tree state. */ - layoutTreeState: LayoutTreeState; - /** - * A reference to the div containing the overlay nodes. Used to normalize the position of the target node as the overlay container is moved in and out of view. - */ - overlayContainerRef: React.RefObject; - /** - * The mapping of all layout nodes to their corresponding mounted overlay node. - */ - nodeRefsAtom: PrimitiveAtom; + layoutModel: LayoutModel; /** * Any styling to apply to the placeholder container div. */ @@ -909,119 +461,17 @@ interface PlaceholderProps { /** * An overlay to preview pending actions on the layout tree. */ -const Placeholder = memo(({ layoutTreeState, overlayContainerRef, nodeRefsAtom, style }: PlaceholderProps) => { +const Placeholder = memo(({ layoutModel, style }: PlaceholderProps) => { const [placeholderOverlay, setPlaceholderOverlay] = useState(null); - - const nodeRefs = useAtomValue(nodeRefsAtom); - - const updatePlaceholder = useCallback( - throttle(10, (pendingAction: LayoutTreeAction) => { - let newPlaceholderOverlay: ReactNode; - if (overlayContainerRef?.current) { - switch (pendingAction?.type) { - case LayoutTreeActionType.Move: { - const action = pendingAction as LayoutTreeMoveNodeAction; - let parentId: string; - if (action.insertAtRoot) { - parentId = layoutTreeState.rootNode.id; - } else { - parentId = action.parentId; - } - - const parentNode = findNode(layoutTreeState.rootNode, parentId); - if (action.index !== undefined && parentNode) { - const targetIndex = Math.min( - parentNode.children ? parentNode.children.length - 1 : 0, - Math.max(0, action.index - 1) - ); - let targetNode = parentNode?.children?.at(targetIndex); - let targetRef: React.RefObject; - if (targetNode) { - targetRef = nodeRefs.get(targetNode.id); - } else { - targetRef = nodeRefs.get(parentNode.id); - targetNode = parentNode; - } - if (targetRef?.current) { - const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); - const targetBoundingRect = targetRef.current.getBoundingClientRect(); - - // Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent. - // Default to placing the placeholder in the first half of the target node. - const placeholderDimensions: Dimensions = { - height: - parentNode.flexDirection === FlexDirection.Column - ? targetBoundingRect.height / 2 - : targetBoundingRect.height, - width: - parentNode.flexDirection === FlexDirection.Row - ? targetBoundingRect.width / 2 - : targetBoundingRect.width, - top: targetBoundingRect.top - overlayBoundingRect.top, - left: targetBoundingRect.left - overlayBoundingRect.left, - }; - - if (action.index > targetIndex) { - if (action.index >= (parentNode.children?.length ?? 1)) { - // If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom). - placeholderDimensions.top += - parentNode.flexDirection === FlexDirection.Column && - targetBoundingRect.height / 2; - placeholderDimensions.left += - parentNode.flexDirection === FlexDirection.Row && - targetBoundingRect.width / 2; - } else { - // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node - placeholderDimensions.top += - parentNode.flexDirection === FlexDirection.Column && - (3 * targetBoundingRect.height) / 4; - placeholderDimensions.left += - parentNode.flexDirection === FlexDirection.Row && - (3 * targetBoundingRect.width) / 4; - } - } - - const placeholderTransform = setTransform(placeholderDimensions); - newPlaceholderOverlay = ( -
- ); - } - } - break; - } - case LayoutTreeActionType.Swap: { - const action = pendingAction as LayoutTreeSwapNodeAction; - // console.log("placeholder for swap", action); - const targetNodeId = action.node1Id; - const targetRef = nodeRefs.get(targetNodeId); - if (targetRef?.current) { - const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); - const targetBoundingRect = targetRef.current.getBoundingClientRect(); - const placeholderDimensions: Dimensions = { - top: targetBoundingRect.top - overlayBoundingRect.top, - left: targetBoundingRect.left - overlayBoundingRect.left, - height: targetBoundingRect.height, - width: targetBoundingRect.width, - }; - - const placeholderTransform = setTransform(placeholderDimensions); - newPlaceholderOverlay =
; - } - break; - } - default: - // No-op - break; - } - } - setPlaceholderOverlay(newPlaceholderOverlay); - }), - [nodeRefs, overlayContainerRef, layoutTreeState.rootNode] - ); + const placeholderTransform = useAtomValue(layoutModel.placeholderTransform); useEffect(() => { - updatePlaceholder(layoutTreeState.pendingAction); - }, [layoutTreeState.pendingAction, updatePlaceholder]); + if (placeholderTransform) { + setPlaceholderOverlay(
); + } else { + setPlaceholderOverlay(null); + } + }, [placeholderTransform]); return (
diff --git a/frontend/layout/lib/layoutAtom.ts b/frontend/layout/lib/layoutAtom.ts index 5e9030f4..39fc160a 100644 --- a/frontend/layout/lib/layoutAtom.ts +++ b/frontend/layout/lib/layoutAtom.ts @@ -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>(); +const layoutStateAtomMap: WeakMap, WritableLayoutTreeStateAtom> = new WeakMap(); +// const layoutStateLoadingAtomMap: WeakMap, Atom> = 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(rootNode: LayoutNode): PrimitiveAtom> { - return atom(newLayoutTreeState(rootNode)) as PrimitiveAtom>; +function getLayoutStateAtomFromTab(tabAtom: Atom, get: Getter): WritableWaveObjectAtom { + const tabData = get(tabAtom); + if (!tabData) return; + const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate); + const layoutStateAtom = WOS.getWaveObjectAtom(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(layoutNodeAtom: WritableLayoutNodeAtom): WritableLayoutTreeStateAtom { - const pendingActionAtom = atom(null) as PrimitiveAtom; - const generationAtom = atom(0) as PrimitiveAtom; - return atom( +export function withLayoutTreeStateAtomFromTab(tabAtom: Atom): 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( - layoutTreeStateAtom: WritableLayoutTreeStateAtom -): readonly [LayoutTreeState, (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>(); - -function getLayoutNodeWaveObjAtomFromTab( - tabAtom: Atom, - get: Getter -): WritableAtom, [value: LayoutNodeWaveObj], 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>(layoutNodeOref); -} - -export function withLayoutStateAtomFromTab(tabAtom: Atom): WritableLayoutTreeStateAtom { - const pendingActionAtom = atom(null) as PrimitiveAtom; - const generationAtom = atom(0) as PrimitiveAtom; - - return atom( - (get) => { - const waveObjAtom = getLayoutNodeWaveObjAtomFromTab(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(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 -): WritableLayoutTreeStateAtom { - 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(tabAtom); - tabLayoutAtomCache.set(tabId, atom); - return atom; -} - -export function deleteLayoutStateAtomForTab(tabId: string) { - const atom = tabLayoutAtomCache.get(tabId); - if (atom) { - tabLayoutAtomCache.delete(tabId); - } -} - -export { globalLayoutTransformsMap }; diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts new file mode 100644 index 00000000..c9c998d7 --- /dev/null +++ b/frontend/layout/lib/layoutModel.ts @@ -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; + gapSizePx: number; + + treeState: LayoutTreeState; + leafs: LayoutNode[]; + resizeHandles: SplitAtom; + additionalProps: PrimitiveAtom>; + pendingAction: AtomWithThrottle; + activeDrag: PrimitiveAtom; + showOverlay: PrimitiveAtom; + ready: PrimitiveAtom; + + displayContainerRef: React.RefObject; + placeholderTransform: Atom; + overlayTransform: Atom; + + private resizeContext?: ResizeContext; + isResizing: Atom; + private isContainerResizing: PrimitiveAtom; + generationAtom: PrimitiveAtom; + + constructor( + treeStateAtom: WritableLayoutTreeStateAtom, + getter: Getter, + setter: Setter, + renderContent?: ContentRenderer, + renderPreview?: PreviewRenderer, + onNodeDelete?: (data: TabLayoutData) => Promise, + 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((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(null, 10); + this.placeholderTransform = atom((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 { + 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, + 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 }); + } +} diff --git a/frontend/layout/lib/layoutModelHooks.ts b/frontend/layout/lib/layoutModelHooks.ts new file mode 100644 index 00000000..b9a60aa6 --- /dev/null +++ b/frontend/layout/lib/layoutModelHooks.ts @@ -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 = new Map(); + +export function getLayoutModelForTab(tabAtom: Atom): 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(tabOref); + return getLayoutModelForTab(tabAtom); +} + +export function deleteLayoutModelForTab(tabId: string) { + if (layoutModelMap.has(tabId)) layoutModelMap.delete(tabId); +} + +export function useLayoutModel(tabAtom: Atom): LayoutModel { + return getLayoutModelForTab(tabAtom); +} + +export function useTileLayout(tabAtom: Atom, 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; +} diff --git a/frontend/layout/lib/layoutNode.ts b/frontend/layout/lib/layoutNode.ts index 693e1652..d28480e7 100644 --- a/frontend/layout/lib/layoutNode.ts +++ b/frontend/layout/lib/layoutNode.ts @@ -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( +export function newLayoutNode( flexDirection?: FlexDirection, size?: number, - children?: LayoutNode[], - data?: T -): LayoutNode { - const newNode: LayoutNode = { + 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( * @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(node: LayoutNode, idx: number, ...children: LayoutNode[]) { +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(node: LayoutNode, 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(node: LayoutNode): LayoutNode { - let intermediateNode: LayoutNode; +export function addIntermediateNode(node: LayoutNode): LayoutNode { + let intermediateNode: LayoutNode; console.log(node); if (node.data) { @@ -98,10 +95,9 @@ export function addIntermediateNode(node: LayoutNode): LayoutNode { * @param parent The parent node. * @param childToRemove The node to remove. * @param startingIndex The index in children to start the search from. - * @template T The type of data associated with the node. * @returns The updated parent node, or undefined if the node was not found. */ -export function removeChild(parent: LayoutNode, childToRemove: LayoutNode, 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(parent: LayoutNode, 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(node: LayoutNode, id: string): LayoutNode | 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(node: LayoutNode, id: string): LayoutNode | 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(node: LayoutNode, id: string): LayoutNode | 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(node: LayoutNode, id: string): LayoutNode | /** * 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(node: LayoutNode): 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(node: LayoutNode): 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(node: LayoutNode): { node: LayoutNode; leafs: LayoutNode[] } | undefined { - const leafs: LayoutNode[] = []; - 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(node: LayoutNode, leafs: LayoutNode[]): LayoutNode { - 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(node: LayoutNode, leafs: LayoutNode[]): 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( - node: LayoutNode, - maxChildren: number -): { node: LayoutNode; 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( * @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( - node: LayoutNode, +export function findInsertLocationFromIndexArr( + node: LayoutNode, indexArr: number[] -): { node: LayoutNode; 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( if (indexArr.length == 0 || !node.children) { return { node, index: nextIndex }; } - return findInsertLocationFromIndexArr(node.children[nextIndex], indexArr); + return findInsertLocationFromIndexArr(node.children[nextIndex], indexArr); } -function findNextInsertLocationHelper( - node: LayoutNode, +function findNextInsertLocationHelper( + node: LayoutNode, maxChildren: number, curDepth: number = 1 -): { node: LayoutNode; 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; 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( return insertLocs[0]; } -export function totalChildrenSize(node: LayoutNode): 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); } diff --git a/frontend/layout/lib/layoutState.ts b/frontend/layout/lib/layoutTree.ts similarity index 63% rename from frontend/layout/lib/layoutState.ts rename to frontend/layout/lib/layoutTree.ts index a789ebfd..9d20948a 100644 --- a/frontend/layout/lib/layoutState.ts +++ b/frontend/layout/lib/layoutTree.ts @@ -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(rootNode: LayoutNode): LayoutTreeState { - 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( - layoutTreeState: LayoutTreeState, - action: LayoutTreeAction -): LayoutTreeState { - 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(layoutTreeState: LayoutTreeState, action: LayoutTreeAction) { +function layoutStateReducerInner(layoutState: LayoutTreeState, action: LayoutTreeAction) { switch (action.type) { case LayoutTreeActionType.ComputeMove: - computeMoveNode(layoutTreeState, action as LayoutTreeComputeMoveNodeAction); - 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); - layoutTreeState.generation++; + moveNode(layoutState, action as LayoutTreeMoveNodeAction); break; case LayoutTreeActionType.InsertNode: - insertNode(layoutTreeState, action as LayoutTreeInsertNodeAction); - layoutTreeState.generation++; + insertNode(layoutState, action as LayoutTreeInsertNodeAction); break; case LayoutTreeActionType.InsertNodeAtIndex: - insertNodeAtIndex(layoutTreeState, action as LayoutTreeInsertNodeAtIndexAction); - 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(layoutTreeState: LayoutTreeState, 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( - layoutTreeState: LayoutTreeState, - computeInsertAction: LayoutTreeComputeMoveNodeAction -) { - 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( return; } - let newMoveOperation: MoveOperation; + 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( node2Id: nodeToMove.id, }; // console.log("swapAction", swapAction); - layoutTreeState.pendingAction = swapAction; - return; + return swapAction; } else { console.warn("cannot swap"); } @@ -304,23 +253,15 @@ function computeMoveNode( (newMoveOperation.index !== nodeToMoveIndexInParent() && newMoveOperation.index !== nodeToMoveIndexInParent() + 1) ) - layoutTreeState.pendingAction = { + return { type: LayoutTreeActionType.Move, ...newMoveOperation, - } as LayoutTreeMoveNodeAction; + } as LayoutTreeMoveNodeAction; } -function setPendingAction(layoutTreeState: LayoutTreeState, action: LayoutTreeSetPendingAction) { - if (action.action === undefined) { - console.error("setPendingAction: invalid pending action passed to function"); - return; - } - layoutTreeState.pendingAction = action.action; -} - -function moveNode(layoutTreeState: LayoutTreeState, action: LayoutTreeMoveNodeAction) { - 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(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, action: LayoutTreeInsertNodeAction) { +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(layoutTreeState: LayoutTreeState, action: LayoutTreeInsertNodeAtIndexAction) { +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(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, 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(layoutTreeState: LayoutTreeState, 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; } } diff --git a/frontend/layout/lib/tilelayout.less b/frontend/layout/lib/tilelayout.less index 08a29903..81f70570 100644 --- a/frontend/layout/lib/tilelayout.less +++ b/frontend/layout/lib/tilelayout.less @@ -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 { diff --git a/frontend/layout/lib/model.ts b/frontend/layout/lib/types.ts similarity index 67% rename from frontend/layout/lib/model.ts rename to frontend/layout/lib/types.ts index fde0ccc1..a36ae6cc 100644 --- a/frontend/layout/lib/model.ts +++ b/frontend/layout/lib/types.ts @@ -7,7 +7,7 @@ import { DropDirection, FlexDirection } from "./utils.js"; /** * Represents an operation to insert a node into a tree. */ -export type MoveOperation = { +export type MoveOperation = { /** * The index at which the node will be inserted in the parent. */ @@ -26,7 +26,7 @@ export type MoveOperation = { /** * The node to insert. */ - node: LayoutNode; + 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 extends LayoutTreeAction { +export interface LayoutTreeComputeMoveNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.ComputeMove; - node: LayoutNode; - nodeToMove: LayoutNode; + 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 extends LayoutTreeAction, MoveOperation { +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 extends LayoutTreeAction { +export interface LayoutTreeInsertNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.InsertNode; - node: LayoutNode; + node: LayoutNode; } /** * Action for inserting a node into the layout tree at the specified index. */ -export interface LayoutTreeInsertNodeAtIndexAction extends LayoutTreeAction { +export interface LayoutTreeInsertNodeAtIndexAction extends LayoutTreeAction { type: LayoutTreeActionType.InsertNodeAtIndex; /** * The node to insert. */ - node: LayoutNode; + 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 = { - rootNode: LayoutNode; - leafs: LayoutNode[]; - 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 { +export interface LayoutNode { id: string; - data?: T; - children?: LayoutNode[]; + data?: TabLayoutData; + children?: LayoutNode[]; flexDirection: FlexDirection; size: number; } -/** - * An abstraction of the type definition for a writable layout node atom. - */ -export type WritableLayoutNodeAtom = WritableAtom, [value: LayoutNode], void>; +export type LayoutTreeStateSetter = (value: LayoutState) => void; -/** - * An abstraction of the type definition for a writable layout tree state atom. - */ -export type WritableLayoutTreeStateAtom = WritableAtom, [value: LayoutTreeState], void>; +export type LayoutTreeState = { + rootNode: LayoutNode; + magnifiedNodeId?: string; + generation: number; +}; -export type ContentRenderer = ( - data: T, +export type WritableLayoutTreeStateAtom = WritableAtom; + +export type ContentRenderer = ( + data: TabLayoutData, ready: boolean, + isMagnified: boolean, disablePointerEvents: boolean, onMagnifyToggle: () => void, onClose: () => void, dragHandleRef: React.RefObject ) => React.ReactNode; -export type PreviewRenderer = (data: T) => React.ReactElement; - -export interface LayoutNodeWaveObj extends WaveObj { - node: LayoutNode; - 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; + /** + * 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; +} diff --git a/frontend/layout/lib/utils.ts b/frontend/layout/lib/utils.ts index 855ff4f0..639fb705 100644 --- a/frontend/layout/lib/utils.ts +++ b/frontend/layout/lib/utils.ts @@ -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, diff --git a/frontend/layout/tests/layoutNode.test.ts b/frontend/layout/tests/layoutNode.test.ts index b134bd81..21dd5dfd 100644 --- a/frontend/layout/tests/layoutNode.test.ts +++ b/frontend/layout/tests/layoutNode.test.ts @@ -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(FlexDirection.Column), + () => newLayoutNode(FlexDirection.Column), "Invalid node", undefined, "calls to the constructor without data or children should fail" ); assert.throws( - () => newLayoutNode(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(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(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( + newLayoutNode( FlexDirection.Column, undefined, - [newLayoutNode(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 = newLayoutNode(FlexDirection.Column, undefined, [ - newLayoutNode(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 = newLayoutNode(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, ]); diff --git a/frontend/layout/tests/layoutState.test.ts b/frontend/layout/tests/layoutState.test.ts index 6d7b1fe0..54b00d86 100644 --- a/frontend/layout/tests/layoutState.test.ts +++ b/frontend/layout/tests/layoutState.test.ts @@ -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(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" diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 5beedde5..15bd45a8 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -20,6 +20,25 @@ declare global { updaterStatusAtom: jotai.PrimitiveAtom; }; + type WritableWaveObjectAtom = jotai.WritableAtom; + + type ThrottledValueAtom = jotai.WritableAtom], void>; + + type AtomWithThrottle = { + currentValueAtom: jotai.Atom; + throttledValueAtom: ThrottledValueAtom; + }; + + type DebouncedValueAtom = jotai.WritableAtom], void>; + + type AtomWithDebounce = { + currentValueAtom: jotai.Atom; + debouncedValueAtom: DebouncedValueAtom; + }; + + type SplitAtom = Atom[]>; + type WritableSplitAtom = WritableAtom[], [SplitAtomAction], void>; + type TabLayoutData = { blockId: string; }; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index f2cca14e..f57030be 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -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[]; }; diff --git a/frontend/util/layoututil.ts b/frontend/util/layoututil.ts index b5209e0c..868a461f 100644 --- a/frontend/util/layoututil.ts +++ b/frontend/util/layoututil.ts @@ -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, 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, block return null; } -function isBlockMagnified(layoutTree: LayoutTreeState, 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; diff --git a/frontend/util/util.ts b/frontend/util/util.ts index 3c267df2..a330ce54 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -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(value: Loadable, def: T): T { return def; } -const NullAtom = jotai.atom(null); +const NullAtom = atom(null); -function useAtomValueSafe(atom: jotai.Atom): T { +function useAtomValueSafe(atom: Atom): 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(initialValue: T, delayMilliseconds = 500): AtomWithThrottle { + // 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) => { + 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(initialValue: T, delayMilliseconds = 500): AtomWithDebounce { + // 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) => { + 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, diff --git a/frontend/wave.ts b/frontend/wave.ts index 1e66496a..1e926acc 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -40,7 +40,7 @@ document.addEventListener("DOMContentLoaded", async () => { const waveWindow = await WOS.loadAndPinWaveObject(WOS.makeORef("window", windowId)); await WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); const initialTab = await WOS.loadAndPinWaveObject(WOS.makeORef("tab", waveWindow.activetabid)); - WOS.loadAndPinWaveObject(WOS.makeORef("layout", initialTab.layoutnode)); + await WOS.loadAndPinWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); initWS(); const settings = await services.FileService.GetSettingsConfig(); console.log("settings", settings); diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 6efa8276..9ce6058c 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -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) } diff --git a/pkg/wstore/wstore_types.go b/pkg/wstore/wstore_types.go index 99e1c7c1..873b7111 100644 --- a/pkg/wstore/wstore_types.go +++ b/pkg/wstore/wstore_types.go @@ -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{}), } }