New layout model (#210)

This PR is a large refactoring of the layout code to move as much of the
layout state logic as possible into a unified model class, with atoms
and derived atoms to notify the display logic of changes. It also fixes
some latent bugs in the node resize code, significantly speeds up
response times for resizing and dragging, and sets us up to fully
replace the React-DnD library in the future.
This commit is contained in:
Evan Simkowitz 2024-08-14 18:40:41 -07:00 committed by GitHub
parent 2684a74db2
commit e85b0d205e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1480 additions and 1462 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,38 +2,59 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { TileLayout } from "./lib/TileLayout"; 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 { newLayoutNode } from "./lib/layoutNode";
import type { import type {
LayoutNode, ContentRenderer,
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 {
LayoutNode, LayoutNode,
LayoutTreeAction, LayoutTreeAction,
LayoutTreeClearPendingAction,
LayoutTreeCommitPendingAction, LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction, LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction, LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction, LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
LayoutTreeMoveNodeAction, LayoutTreeMoveNodeAction,
LayoutTreeState, LayoutTreeResizeNodeAction,
WritableLayoutNodeAtom, LayoutTreeSetPendingAction,
WritableLayoutTreeStateAtom, LayoutTreeStateSetter,
LayoutTreeSwapNodeAction,
} from "./lib/types";
import { LayoutTreeActionType } from "./lib/types";
export {
deleteLayoutModelForTab,
getLayoutModelForTab,
getLayoutModelForTabById,
LayoutModel,
LayoutTreeActionType,
newLayoutNode,
TileLayout,
useLayoutModel,
useLayoutNode,
};
export type {
ContentRenderer,
LayoutNode,
LayoutTreeAction,
LayoutTreeClearPendingAction,
LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
LayoutTreeMoveNodeAction,
LayoutTreeResizeNodeAction,
LayoutTreeSetPendingAction,
LayoutTreeStateSetter,
LayoutTreeSwapNodeAction,
}; };

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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