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
28 changed files with 1480 additions and 1462 deletions
+25 -32
View File
@@ -1,33 +1,31 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { useWaveObjectValue } from "@/app/store/wos";
import { Workspace } from "@/app/workspace/workspace";
import {
LayoutTreeActionType,
LayoutTreeDeleteNodeAction,
deleteLayoutModelForTab,
getLayoutModelForTab,
} from "@/layout/index";
import { ContextMenuModel } from "@/store/contextmenu";
import { PLATFORM, WOS, atoms, globalStore, setBlockFocus } from "@/store/global";
import * as services from "@/store/services";
import { getWebServerEndpoint } from "@/util/endpoints";
import * as keyutil from "@/util/keyutil";
import * as layoututil from "@/util/layoututil";
import * as util from "@/util/util";
import clsx from "clsx";
import Color from "color";
import * as csstree from "css-tree";
import {
deleteLayoutStateAtomForTab,
getLayoutStateAtomForTab,
globalLayoutTransformsMap,
} from "frontend/layout/lib/layoutAtom";
import * as jotai from "jotai";
import "overlayscrollbars/overlayscrollbars.css";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { CenteredDiv } from "./element/quickelems";
import { useWaveObjectValue } from "@/app/store/wos";
import { LayoutTreeActionType, LayoutTreeDeleteNodeAction } from "@/layout/index";
import { layoutTreeStateReducer } from "@/layout/lib/layoutState";
import { getWebServerEndpoint } from "@/util/endpoints";
import clsx from "clsx";
import Color from "color";
import "overlayscrollbars/overlayscrollbars.css";
import "./app.less";
import { CenteredDiv } from "./element/quickelems";
const App = () => {
let Provider = jotai.Provider;
@@ -173,15 +171,15 @@ function findBlockAtPoint(m: Map<string, Bounds>, p: Point): string {
function switchBlockIdx(index: number) {
const tabId = globalStore.get(atoms.activeTabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutTreeState = globalStore.get(getLayoutStateAtomForTab(tabId, tabAtom));
if (layoutTreeState?.leafs == null) {
const layoutModel = getLayoutModelForTab(tabAtom);
if (layoutModel?.leafs == null) {
return;
}
const newLeafIdx = index - 1;
if (newLeafIdx < 0 || newLeafIdx >= layoutTreeState.leafs.length) {
if (newLeafIdx < 0 || newLeafIdx >= layoutModel.leafs.length) {
return;
}
const leaf = layoutTreeState.leafs[newLeafIdx];
const leaf = layoutModel.leafs[newLeafIdx];
if (leaf?.data?.blockId == null) {
return;
}
@@ -194,26 +192,22 @@ function switchBlock(tabId: string, offsetX: number, offsetY: number) {
return;
}
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const transforms = globalLayoutTransformsMap.get(tabId);
if (transforms == null) {
return;
}
const layoutTreeState = globalStore.get(getLayoutStateAtomForTab(tabId, tabAtom));
const layoutModel = getLayoutModelForTab(tabAtom);
const curBlockId = globalStore.get(atoms.waveWindow)?.activeblockid;
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutTreeState, curBlockId);
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutModel, curBlockId);
if (curBlockLeafId == null) {
return;
}
const blockPos = readBoundsFromTransform(transforms[curBlockLeafId]);
const blockPos = readBoundsFromTransform(layoutModel.getNodeTransformById(curBlockLeafId));
if (blockPos == null) {
return;
}
var blockPositions: Map<string, Bounds> = new Map();
for (let leaf of layoutTreeState.leafs) {
for (const leaf of layoutModel.leafs) {
if (leaf.id == curBlockLeafId) {
continue;
}
const pos = readBoundsFromTransform(transforms[leaf.id]);
const pos = readBoundsFromTransform(layoutModel.getNodeTransform(leaf));
if (pos != null) {
blockPositions.set(leaf.data.blockId, pos);
}
@@ -341,7 +335,7 @@ function genericClose(tabId: string) {
if (tabData.blockids == null || tabData.blockids.length == 0) {
// close tab
services.WindowService.CloseTab(tabId);
deleteLayoutStateAtomForTab(tabId);
deleteLayoutModelForTab(tabId);
return;
}
// close block
@@ -349,14 +343,13 @@ function genericClose(tabId: string) {
if (activeBlockId == null) {
return;
}
const layoutStateAtom = getLayoutStateAtomForTab(tabId, tabAtom);
const layoutTreeState = globalStore.get(layoutStateAtom);
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutTreeState, activeBlockId);
const layoutModel = getLayoutModelForTab(tabAtom);
const curBlockLeafId = layoututil.findLeafIdFromBlockId(layoutModel, activeBlockId);
const deleteAction: LayoutTreeDeleteNodeAction = {
type: LayoutTreeActionType.DeleteNode,
nodeId: curBlockLeafId,
};
globalStore.set(layoutStateAtom, layoutTreeStateReducer(layoutTreeState, deleteAction));
layoutModel.treeReducer(deleteAction);
services.ObjectService.DeleteBlock(activeBlockId);
}
+14 -25
View File
@@ -7,10 +7,8 @@ import { ContextMenuModel } from "@/app/store/contextmenu";
import { atoms, globalStore, useBlockAtom, WOS } from "@/app/store/global";
import * as services from "@/app/store/services";
import { MagnifyIcon } from "@/element/magnify";
import { LayoutTreeState } from "@/layout/index";
import { getLayoutStateAtomForTab } from "@/layout/lib/layoutAtom";
import { useLayoutModel } from "@/layout/index";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { isBlockMagnified } from "@/util/layoututil";
import * as util from "@/util/util";
import clsx from "clsx";
import * as jotai from "jotai";
@@ -73,21 +71,15 @@ function getViewIconElem(viewIconUnion: string | HeaderIconButton, blockData: Bl
}
}
const OptMagnifyButton = React.memo(
({ blockData, layoutModel }: { blockData: Block; layoutModel: LayoutComponentModel }) => {
const tabId = globalStore.get(atoms.activeTabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutTreeState = util.useAtomValueSafe(getLayoutStateAtomForTab(tabId, tabAtom));
const isMagnified = isBlockMagnified(layoutTreeState, blockData.oid);
const magnifyDecl: HeaderIconButton = {
elemtype: "iconbutton",
icon: <MagnifyIcon enabled={isMagnified} />,
title: isMagnified ? "Minimize" : "Magnify",
click: layoutModel?.onMagnifyToggle,
};
return <IconButton key="magnify" decl={magnifyDecl} className="block-frame-magnify" />;
}
);
const OptMagnifyButton = React.memo(({ layoutCompModel }: { layoutCompModel: LayoutComponentModel }) => {
const magnifyDecl: HeaderIconButton = {
elemtype: "iconbutton",
icon: <MagnifyIcon enabled={layoutCompModel?.isMagnified} />,
title: layoutCompModel?.isMagnified ? "Minimize" : "Magnify",
click: layoutCompModel?.onMagnifyToggle,
};
return <IconButton key="magnify" decl={magnifyDecl} className="block-frame-magnify" />;
});
function computeEndIcons(blockData: Block, viewModel: ViewModel, layoutModel: LayoutComponentModel): JSX.Element[] {
const endIconsElem: JSX.Element[] = [];
@@ -104,7 +96,7 @@ function computeEndIcons(blockData: Block, viewModel: ViewModel, layoutModel: La
handleHeaderContextMenu(e, blockData, viewModel, layoutModel?.onMagnifyToggle, layoutModel?.onClose),
};
endIconsElem.push(<IconButton key="settings" decl={settingsDecl} className="block-frame-settings" />);
endIconsElem.push(<OptMagnifyButton key="unmagnify" blockData={blockData} layoutModel={layoutModel} />);
endIconsElem.push(<OptMagnifyButton key="unmagnify" layoutCompModel={layoutModel} />);
const closeDecl: HeaderIconButton = {
elemtype: "iconbutton",
icon: "xmark-large",
@@ -214,12 +206,9 @@ function renderHeaderElements(headerTextUnion: HeaderElem[]): JSX.Element[] {
function BlockNum({ blockId }: { blockId: string }) {
const tabId = jotai.useAtomValue(atoms.activeTabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutTreeState: LayoutTreeState<TabLayoutData> = globalStore.get(getLayoutStateAtomForTab(tabId, tabAtom));
if (!layoutTreeState || !layoutTreeState.leafs) {
return null;
}
for (let idx = 0; idx < layoutTreeState.leafs.length; idx++) {
const leaf = layoutTreeState.leafs[idx];
const layoutModel = useLayoutModel(tabAtom);
for (let idx = 0; idx < layoutModel.leafs.length; idx++) {
const leaf = layoutModel.leafs[idx];
if (leaf?.data?.blockId == blockId) {
return String(idx + 1);
}
+1
View File
@@ -5,6 +5,7 @@ export interface LayoutComponentModel {
disablePointerEvents: boolean;
onClose?: () => void;
onMagnifyToggle?: () => void;
isMagnified: boolean;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
+21 -32
View File
@@ -1,18 +1,15 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { handleIncomingRpcMessage } from "@/app/store/wshrpc";
import {
LayoutTreeAction,
getLayoutModelForTabById,
LayoutTreeActionType,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
newLayoutNode,
} from "frontend/layout/index";
import { getLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom";
import { layoutTreeStateReducer } from "frontend/layout/lib/layoutState";
import { handleIncomingRpcMessage } from "@/app/store/wshrpc";
import { LayoutTreeInsertNodeAtIndexAction } from "@/layout/lib/model";
import { getWSServerEndpoint, getWebServerEndpoint } from "@/util/endpoints";
} from "@/layout/index";
import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints";
import * as layoututil from "@/util/layoututil";
import { produce } from "immer";
import * as jotai from "jotai";
@@ -261,29 +258,26 @@ function handleWSEventMessage(msg: WSEventType) {
}
if (msg.eventtype == "layoutaction") {
const layoutAction: WSLayoutActionData = msg.data;
const tabId = layoutAction.tabid;
const layoutModel = getLayoutModelForTabById(tabId);
switch (layoutAction.actiontype) {
case LayoutTreeActionType.InsertNode: {
const insertNodeAction: LayoutTreeInsertNodeAction<TabLayoutData> = {
const insertNodeAction: LayoutTreeInsertNodeAction = {
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode<TabLayoutData>(undefined, undefined, undefined, {
node: newLayoutNode(undefined, undefined, undefined, {
blockId: layoutAction.blockid,
}),
};
runLayoutAction(layoutAction.tabid, insertNodeAction);
layoutModel.treeReducer(insertNodeAction);
break;
}
case LayoutTreeActionType.DeleteNode: {
const layoutStateAtom = getLayoutStateAtomForTab(
layoutAction.tabid,
WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", layoutAction.tabid))
);
const curState = globalStore.get(layoutStateAtom);
const leafId = layoututil.findLeafIdFromBlockId(curState, layoutAction.blockid);
const leafId = layoututil.findLeafIdFromBlockId(layoutModel, layoutAction.blockid);
const deleteNodeAction = {
type: LayoutTreeActionType.DeleteNode,
nodeId: leafId,
};
runLayoutAction(layoutAction.tabid, deleteNodeAction);
layoutModel.treeReducer(deleteNodeAction);
break;
}
case LayoutTreeActionType.InsertNodeAtIndex: {
@@ -291,14 +285,14 @@ function handleWSEventMessage(msg: WSEventType) {
console.error("Cannot apply eventbus layout action InsertNodeAtIndex, indexarr field is missing.");
break;
}
const insertAction: LayoutTreeInsertNodeAtIndexAction<TabLayoutData> = {
const insertAction: LayoutTreeInsertNodeAtIndexAction = {
type: LayoutTreeActionType.InsertNodeAtIndex,
node: newLayoutNode<TabLayoutData>(undefined, layoutAction.nodesize, undefined, {
node: newLayoutNode(undefined, layoutAction.nodesize, undefined, {
blockId: layoutAction.blockid,
}),
indexArr: layoutAction.indexarr,
};
runLayoutAction(layoutAction.tabid, insertAction);
layoutModel.treeReducer(insertAction);
break;
}
default:
@@ -355,21 +349,16 @@ function getApi(): ElectronApi {
return (window as any).api;
}
function runLayoutAction(tabId: string, action: LayoutTreeAction) {
const layoutStateAtom = getLayoutStateAtomForTab(tabId, WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId)));
const curState = globalStore.get(layoutStateAtom);
globalStore.set(layoutStateAtom, layoutTreeStateReducer(curState, action));
}
async function createBlock(blockDef: BlockDef) {
const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
const blockId = await services.ObjectService.CreateBlock(blockDef, rtOpts);
const insertNodeAction: LayoutTreeInsertNodeAction<TabLayoutData> = {
const insertNodeAction: LayoutTreeInsertNodeAction = {
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode<TabLayoutData>(undefined, undefined, undefined, { blockId }),
node: newLayoutNode(undefined, undefined, undefined, { blockId }),
};
const activeTabId = globalStore.get(atoms.uiContext).activetabid;
runLayoutAction(activeTabId, insertNodeAction);
const layoutModel = getLayoutModelForTabById(activeTabId);
layoutModel.treeReducer(insertNodeAction);
}
// when file is not found, returns {data: null, fileInfo: null}
@@ -450,8 +439,6 @@ async function openLink(uri: string) {
}
export {
PLATFORM,
WOS,
atoms,
createBlock,
fetchWaveFile,
@@ -466,10 +453,12 @@ export {
initWS,
isDev,
openLink,
PLATFORM,
sendWSCommand,
setBlockFocus,
setPlatform,
useBlockAtom,
useBlockCache,
useSettingsAtom,
WOS,
};
-2
View File
@@ -24,8 +24,6 @@ type WaveObjectValue<T extends WaveObj> = {
holdTime: number;
};
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
function splitORef(oref: string): [string, string] {
const parts = oref.split(":");
if (parts.length != 2) {
+2 -4
View File
@@ -2,9 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
import { WindowDrag } from "@/element/windowdrag";
import { deleteLayoutModelForTab } from "@/layout/index";
import { atoms, getApi, isDev } from "@/store/global";
import * as services from "@/store/services";
import { deleteLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom";
import { useAtomValue } from "jotai";
import { OverlayScrollbars } from "overlayscrollbars";
import React, { createRef, useCallback, useEffect, useRef, useState } from "react";
@@ -140,8 +140,6 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
let newTabWidth = tabWidth;
let newScrollable = scrollable;
console.log("spaceForTabs", spaceForTabs, minTotalTabWidth);
if (spaceForTabs < totalDefaultTabWidth && spaceForTabs > minTotalTabWidth) {
newTabWidth = TAB_MIN_WIDTH;
} else if (minTotalTabWidth > spaceForTabs) {
@@ -467,7 +465,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
event?.stopPropagation();
services.WindowService.CloseTab(tabId);
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutStateAtomForTab(tabId);
deleteLayoutModelForTab(tabId);
};
const handleTabLoaded = useCallback((tabId) => {
+14 -13
View File
@@ -3,16 +3,13 @@
import { Block } from "@/app/block/block";
import { LayoutComponentModel } from "@/app/block/blocktypes";
import { CenteredDiv } from "@/element/quickelems";
import { ContentRenderer, TileLayout } from "@/layout/index";
import { getApi } from "@/store/global";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
import * as React from "react";
import { CenteredDiv } from "@/element/quickelems";
import { ContentRenderer } from "@/layout/lib/model";
import { TileLayout } from "frontend/layout/index";
import { getLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom";
import { useAtomValue } from "jotai";
import * as React from "react";
import { useMemo } from "react";
import "./tabcontent.less";
@@ -21,19 +18,19 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]);
const tabLoading = useAtomValue(loadingAtom);
const tabAtom = useMemo(() => WOS.getWaveObjectAtom<Tab>(oref), [oref]);
const layoutStateAtom = useMemo(() => getLayoutStateAtomForTab(tabId, tabAtom), [tabAtom, tabId]);
const tabData = useAtomValue(tabAtom);
const tileLayoutContents = useMemo(() => {
const renderBlock: ContentRenderer<TabLayoutData> = (
tabData: TabLayoutData,
const renderBlock: ContentRenderer = (
blockData: TabLayoutData,
ready: boolean,
isMagnified: boolean,
disablePointerEvents: boolean,
onMagnifyToggle: () => void,
onClose: () => void,
dragHandleRef: React.RefObject<HTMLDivElement>
) => {
if (!tabData.blockId || !ready) {
if (!blockData.blockId || !ready) {
return null;
}
const layoutModel: LayoutComponentModel = {
@@ -41,11 +38,15 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
onClose,
onMagnifyToggle,
dragHandleRef,
isMagnified,
};
return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={layoutModel} preview={false} />;
return (
<Block key={blockData.blockId} blockId={blockData.blockId} layoutModel={layoutModel} preview={false} />
);
};
function renderPreview(tabData: TabLayoutData) {
if (!tabData) return;
return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={null} preview={true} />;
}
@@ -59,7 +60,7 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
tabId: tabId,
onNodeDelete: onNodeDelete,
};
}, []);
}, [tabId]);
if (tabLoading) {
return (
@@ -86,7 +87,7 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
<TileLayout
key={tabId}
contents={tileLayoutContents}
layoutTreeStateAtom={layoutStateAtom}
tabAtom={tabAtom}
getCursorPoint={getApi().getCursorPoint}
/>
</div>
+4 -2
View File
@@ -45,8 +45,10 @@
--zindex-elem-modal: 100;
--zindex-window-drag: 100;
--zindex-tab-name: 3;
--zindex-layout-placeholder-container: 1;
--zindex-layout-overlay-container: 2;
--zindex-layout-display-container: 0;
--zindex-layout-resize-handle: 1;
--zindex-layout-placeholder-container: 2;
--zindex-layout-overlay-container: 3;
--zindex-block-mask-inner: 10;
// z-indexes in xterm.css