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{}), } }