Unified node model to pass data from layout to blocks (#259)

This adds a new NodeModel, which can be passed from the TileLayout to
contained blocks. It contains all the layout data that the block should
care about, including focus status, whether a drag operation is
underway, whether the node is magnified, etc.

This also adds a focus stack for the layout, which will let the focus
switch to the last-focused node when the currently-focused one is
closed.

This also addresses a regression in the resize handles that caused them
to be offset from the cursor when dragged.

---------

Co-authored-by: sawka <mike.sawka@gmail.com>
This commit is contained in:
Evan Simkowitz 2024-08-26 11:56:00 -07:00 committed by GitHub
parent 8e5a4a457c
commit 164afeeb66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 596 additions and 573 deletions

View File

@ -1,14 +1,19 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { atoms, createBlock, getViewModel, globalStore, setBlockFocus, WOS } from "@/app/store/global";
import { deleteLayoutModelForTab, getLayoutModelForTab } from "@/layout/index";
import { atoms, createBlock, globalStore, WOS } from "@/app/store/global";
import {
deleteLayoutModelForTab,
getLayoutModelForActiveTab,
getLayoutModelForTab,
getLayoutModelForTabById,
NavigateDirection,
} from "@/layout/index";
import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil";
import * as jotai from "jotai";
const simpleControlShiftAtom = jotai.atom(false);
const transformRegexp = /translate3d\(\s*([0-9.]+)px\s*,\s*([0-9.]+)px,\s*0\)/;
function setControlShift() {
globalStore.set(simpleControlShiftAtom, true);
@ -38,99 +43,21 @@ function genericClose(tabId: string) {
deleteLayoutModelForTab(tabId);
return;
}
// close block
const activeBlockId = globalStore.get(atoms.waveWindow)?.activeblockid;
if (activeBlockId == null) {
return;
}
const layoutModel = getLayoutModelForTab(tabAtom);
const curBlockLeafId = layoutModel.getNodeByBlockId(activeBlockId)?.id;
layoutModel.closeNodeById(curBlockLeafId);
layoutModel.closeFocusedNode();
}
function switchBlockIdx(index: number) {
const tabId = globalStore.get(atoms.activeTabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutModel = getLayoutModelForTab(tabAtom);
function switchBlockByBlockNum(index: number) {
const layoutModel = getLayoutModelForActiveTab();
if (!layoutModel) {
return;
}
const leafsOrdered = globalStore.get(layoutModel.leafsOrdered);
const newLeafIdx = index - 1;
if (newLeafIdx < 0 || newLeafIdx >= leafsOrdered.length) {
return;
}
const leaf = leafsOrdered[newLeafIdx];
if (leaf?.data?.blockId == null) {
return;
}
setBlockFocus(leaf.data.blockId);
layoutModel.switchNodeFocusByBlockNum(index);
}
function getCenter(dimensions: Dimensions): Point {
return {
x: dimensions.left + dimensions.width / 2,
y: dimensions.top + dimensions.height / 2,
};
}
function findBlockAtPoint(m: Map<string, Dimensions>, p: Point): string {
for (const [blockId, dimension] of m.entries()) {
if (
p.x >= dimension.left &&
p.x <= dimension.left + dimension.width &&
p.y >= dimension.top &&
p.y <= dimension.top + dimension.height
) {
return blockId;
}
}
return null;
}
function switchBlock(tabId: string, offsetX: number, offsetY: number) {
console.log("switch block", offsetX, offsetY);
if (offsetY == 0 && offsetX == 0) {
return;
}
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutModel = getLayoutModelForTab(tabAtom);
const curBlockId = globalStore.get(atoms.waveWindow)?.activeblockid;
const addlProps = globalStore.get(layoutModel.additionalProps);
const blockPositions: Map<string, Dimensions> = new Map();
const leafsOrdered = globalStore.get(layoutModel.leafsOrdered);
for (const leaf of leafsOrdered) {
const pos = addlProps[leaf.id]?.rect;
if (pos) {
blockPositions.set(leaf.data.blockId, pos);
}
}
const curBlockPos = blockPositions.get(curBlockId);
if (!curBlockPos) {
return;
}
blockPositions.delete(curBlockId);
const boundingRect = layoutModel.displayContainerRef?.current.getBoundingClientRect();
if (!boundingRect) {
return;
}
const maxX = boundingRect.left + boundingRect.width;
const maxY = boundingRect.top + boundingRect.height;
const moveAmount = 10;
const curPoint = getCenter(curBlockPos);
while (true) {
console.log("nextPoint", curPoint, curBlockPos);
curPoint.x += offsetX * moveAmount;
curPoint.y += offsetY * moveAmount;
if (curPoint.x < 0 || curPoint.x > maxX || curPoint.y < 0 || curPoint.y > maxY) {
return;
}
const blockId = findBlockAtPoint(blockPositions, curPoint);
if (blockId != null) {
setBlockFocus(blockId);
return;
}
}
function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
const layoutModel = getLayoutModelForTabById(tabId);
layoutModel.switchNodeFocusInDirection(direction);
}
function switchTabAbs(index: number) {
@ -158,7 +85,6 @@ function switchTab(offset: number) {
}
const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length;
const newActiveTabId = ws.tabids[newTabIdx];
console.log("switching tabs", tabIdx, newTabIdx, activeTabId, newActiveTabId, ws.tabids);
services.ObjectService.SetActiveTab(newActiveTabId);
}
@ -174,17 +100,17 @@ function appHandleKeyUp(event: KeyboardEvent) {
}
}
async function handleCmdT() {
async function handleCmdN() {
const termBlockDef: BlockDef = {
meta: {
view: "term",
controller: "shell",
},
};
const tabId = globalStore.get(atoms.activeTabId);
const win = globalStore.get(atoms.waveWindow);
if (win?.activeblockid != null) {
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", win.activeblockid));
const layoutModel = getLayoutModelForActiveTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode != null) {
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId));
const blockData = globalStore.get(blockAtom);
if (blockData?.meta?.view == "term") {
if (blockData?.meta?.["cmd:cwd"] != null) {
@ -195,27 +121,7 @@ async function handleCmdT() {
termBlockDef.meta.connection = blockData.meta.connection;
}
}
const newBlockId = await createBlock(termBlockDef);
setBlockFocus(newBlockId);
}
function handleCmdI() {
const waveWindow = globalStore.get(atoms.waveWindow);
if (waveWindow == null) {
return;
}
let activeBlockId = waveWindow.activeblockid;
if (activeBlockId == null) {
// get the first block
const tabData = globalStore.get(atoms.tabAtom);
const firstBlockId = tabData.blockids?.length == 0 ? null : tabData.blockids[0];
if (firstBlockId == null) {
return;
}
activeBlockId = firstBlockId;
}
const viewModel = getViewModel(activeBlockId);
viewModel?.giveFocus?.();
await createBlock(termBlockDef);
}
function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
@ -241,11 +147,7 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Cmd:n")) {
handleCmdT();
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Cmd:i")) {
handleCmdI();
handleCmdN();
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Cmd:t")) {
@ -265,24 +167,24 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
keyutil.checkKeyPressed(waveEvent, `Ctrl:Shift:c{Digit${idx}}`) ||
keyutil.checkKeyPressed(waveEvent, `Ctrl:Shift:c{Numpad${idx}}`)
) {
switchBlockIdx(idx);
switchBlockByBlockNum(idx);
return true;
}
}
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:ArrowUp")) {
switchBlock(tabId, 0, -1);
switchBlockInDirection(tabId, NavigateDirection.Up);
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:ArrowDown")) {
switchBlock(tabId, 0, 1);
switchBlockInDirection(tabId, NavigateDirection.Down);
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:ArrowLeft")) {
switchBlock(tabId, -1, 0);
switchBlockInDirection(tabId, NavigateDirection.Left);
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:ArrowRight")) {
switchBlock(tabId, 1, 0);
switchBlockInDirection(tabId, NavigateDirection.Right);
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Cmd:w")) {

View File

@ -1,20 +1,13 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { BlockComponentModel, BlockProps, LayoutComponentModel } from "@/app/block/blocktypes";
import { BlockComponentModel, BlockProps } from "@/app/block/blocktypes";
import { PlotView } from "@/app/view/plotview/plotview";
import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview";
import { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems";
import {
atoms,
counterInc,
getViewModel,
registerViewModel,
setBlockFocus,
unregisterViewModel,
useBlockAtom,
} from "@/store/global";
import { NodeModel } from "@/layout/index";
import { counterInc, getViewModel, registerViewModel, unregisterViewModel } from "@/store/global";
import * as WOS from "@/store/wos";
import * as util from "@/util/util";
import { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel } from "@/view/cpuplot/cpuplot";
@ -24,15 +17,13 @@ import { WaveAi, WaveAiModel, makeWaveAiViewModel } from "@/view/waveai/waveai";
import { WebView, WebViewModel, makeWebViewModel } from "@/view/webview/webview";
import * as jotai from "jotai";
import * as React from "react";
import "./block.less";
import { BlockFrame } from "./blockframe";
import { blockViewToIcon, blockViewToName } from "./blockutil";
import "./block.less";
type FullBlockProps = {
blockId: string;
preview: boolean;
layoutModel: LayoutComponentModel;
nodeModel: NodeModel;
viewModel: ViewModel;
};
@ -105,16 +96,15 @@ function makeDefaultViewModel(blockId: string, viewType: string): ViewModel {
return viewModel;
}
const BlockPreview = React.memo(({ blockId, layoutModel, viewModel }: FullBlockProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const BlockPreview = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId));
if (!blockData) {
return null;
}
return (
<BlockFrame
key={blockId}
blockId={blockId}
layoutModel={layoutModel}
key={nodeModel.blockId}
nodeModel={nodeModel}
preview={true}
blockModel={null}
viewModel={viewModel}
@ -122,20 +112,16 @@ const BlockPreview = React.memo(({ blockId, layoutModel, viewModel }: FullBlockP
);
});
const BlockFull = React.memo(({ blockId, layoutModel, viewModel }: FullBlockProps) => {
const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
counterInc("render-BlockFull");
const focusElemRef = React.useRef<HTMLInputElement>(null);
const blockRef = React.useRef<HTMLDivElement>(null);
const [blockClicked, setBlockClicked] = React.useState(false);
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId));
const [focusedChild, setFocusedChild] = React.useState(null);
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
return jotai.atom((get) => {
const winData = get(atoms.waveWindow);
return winData.activeblockid === blockId;
});
});
const isFocused = jotai.useAtomValue(isFocusedAtom);
const isFocused = jotai.useAtomValue(nodeModel.isFocused);
const disablePointerEvents = jotai.useAtomValue(nodeModel.disablePointerEvents);
const addlProps = jotai.useAtomValue(nodeModel.additionalProps);
React.useLayoutEffect(() => {
setBlockClicked(isFocused);
@ -150,24 +136,24 @@ const BlockFull = React.memo(({ blockId, layoutModel, viewModel }: FullBlockProp
if (!focusWithin) {
setFocusTarget();
}
setBlockFocus(blockId);
nodeModel.focusNode();
}, [blockClicked]);
React.useLayoutEffect(() => {
if (focusedChild == null) {
return;
}
setBlockFocus(blockId);
}, [focusedChild, blockId]);
nodeModel.focusNode();
}, [focusedChild]);
// treat the block as clicked on creation
const setBlockClickedTrue = React.useCallback(() => {
setBlockClicked(true);
}, []);
let viewElem = React.useMemo(
() => getViewElem(blockId, blockData?.meta?.view, viewModel),
[blockId, blockData?.meta?.view, viewModel]
const viewElem = React.useMemo(
() => getViewElem(nodeModel.blockId, blockData?.meta?.view, viewModel),
[nodeModel.blockId, blockData?.meta?.view, viewModel]
);
const determineFocusedChild = React.useCallback(
@ -193,20 +179,29 @@ const BlockFull = React.memo(({ blockId, layoutModel, viewModel }: FullBlockProp
return (
<BlockFrame
key={blockId}
blockId={blockId}
layoutModel={layoutModel}
key={nodeModel.blockId}
nodeModel={nodeModel}
preview={false}
blockModel={blockModel}
viewModel={viewModel}
>
<div key="focuselem" className="block-focuselem">
<input type="text" value="" ref={focusElemRef} id={`${blockId}-dummy-focus`} onChange={() => {}} />
<input
type="text"
value=""
ref={focusElemRef}
id={`${nodeModel.blockId}-dummy-focus`}
onChange={() => {}}
/>
</div>
<div
key="content"
className="block-content"
style={{ pointerEvents: layoutModel?.disablePointerEvents ? "none" : undefined }}
style={{
pointerEvents: disablePointerEvents ? "none" : undefined,
width: addlProps?.transform?.width,
height: addlProps?.transform?.height,
}}
>
<ErrorBoundary>
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</React.Suspense>
@ -218,19 +213,19 @@ const BlockFull = React.memo(({ blockId, layoutModel, viewModel }: FullBlockProp
const Block = React.memo((props: BlockProps) => {
counterInc("render-Block");
counterInc("render-Block-" + props.blockId.substring(0, 8));
const [blockData, loading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", props.blockId));
let viewModel = getViewModel(props.blockId);
counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8));
const [blockData, loading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", props.nodeModel.blockId));
let viewModel = getViewModel(props.nodeModel.blockId);
if (viewModel == null || viewModel.viewType != blockData?.meta?.view) {
viewModel = makeViewModel(props.blockId, blockData?.meta?.view);
registerViewModel(props.blockId, viewModel);
viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view);
registerViewModel(props.nodeModel.blockId, viewModel);
}
React.useEffect(() => {
return () => {
unregisterViewModel(props.blockId);
unregisterViewModel(props.nodeModel.blockId);
};
}, []);
if (loading || util.isBlank(props.blockId) || blockData == null) {
if (loading || util.isBlank(props.nodeModel.blockId) || blockData == null) {
return null;
}
if (props.preview) {

View File

@ -11,21 +11,22 @@ import {
} from "@/app/block/blockutil";
import { Button } from "@/app/element/button";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { atoms, globalStore, useBlockAtom, WOS } from "@/app/store/global";
import { atoms, globalStore, WOS } from "@/app/store/global";
import * as services from "@/app/store/services";
import { MagnifyIcon } from "@/element/magnify";
import { useLayoutModel } from "@/layout/index";
import { NodeModel } from "@/layout/index";
import { checkKeyPressed, keydownWrapper } from "@/util/keyutil";
import * as util from "@/util/util";
import clsx from "clsx";
import * as jotai from "jotai";
import * as React from "react";
import { BlockFrameProps, LayoutComponentModel } from "./blocktypes";
import { BlockFrameProps } from "./blocktypes";
function handleHeaderContextMenu(
e: React.MouseEvent<HTMLDivElement>,
blockData: Block,
viewModel: ViewModel,
magnified: boolean,
onMagnifyToggle: () => void,
onClose: () => void
) {
@ -33,7 +34,7 @@ function handleHeaderContextMenu(
e.stopPropagation();
let menu: ContextMenuItem[] = [
{
label: "Magnify Block",
label: magnified ? "Un-magnify Block" : "Magnify Block",
click: () => {
onMagnifyToggle();
},
@ -78,20 +79,27 @@ function getViewIconElem(viewIconUnion: string | HeaderIconButton, blockData: Bl
}
}
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" />;
});
const OptMagnifyButton = React.memo(
({ magnified, toggleMagnify }: { magnified: boolean; toggleMagnify: () => void }) => {
const magnifyDecl: HeaderIconButton = {
elemtype: "iconbutton",
icon: <MagnifyIcon enabled={magnified} />,
title: magnified ? "Minimize" : "Magnify",
click: toggleMagnify,
};
return <IconButton key="magnify" decl={magnifyDecl} className="block-frame-magnify" />;
}
);
function computeEndIcons(blockData: Block, viewModel: ViewModel, layoutModel: LayoutComponentModel): JSX.Element[] {
function computeEndIcons(
viewModel: ViewModel,
magnified: boolean,
toggleMagnify: () => void,
onClose: () => void,
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void
): JSX.Element[] {
const endIconsElem: JSX.Element[] = [];
const endIconButtons = util.useAtomValueSafe(viewModel.endIconButtons);
if (endIconButtons && endIconButtons.length > 0) {
endIconsElem.push(...endIconButtons.map((button, idx) => <IconButton key={idx} decl={button} />));
}
@ -99,30 +107,44 @@ function computeEndIcons(blockData: Block, viewModel: ViewModel, layoutModel: La
elemtype: "iconbutton",
icon: "cog",
title: "Settings",
click: (e) =>
handleHeaderContextMenu(e, blockData, viewModel, layoutModel?.onMagnifyToggle, layoutModel?.onClose),
click: onContextMenu,
};
endIconsElem.push(<IconButton key="settings" decl={settingsDecl} className="block-frame-settings" />);
endIconsElem.push(<OptMagnifyButton key="unmagnify" layoutCompModel={layoutModel} />);
endIconsElem.push(<OptMagnifyButton key="unmagnify" magnified={magnified} toggleMagnify={toggleMagnify} />);
const closeDecl: HeaderIconButton = {
elemtype: "iconbutton",
icon: "xmark-large",
title: "Close",
click: layoutModel?.onClose,
click: onClose,
};
endIconsElem.push(<IconButton key="close" decl={closeDecl} className="block-frame-default-close" />);
return endIconsElem;
}
const BlockFrame_Header = ({ blockId, layoutModel, viewModel }: BlockFrameProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const BlockFrame_Header = ({ nodeModel, viewModel, preview }: BlockFrameProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId));
const viewName = util.useAtomValueSafe(viewModel.viewName) ?? blockViewToName(blockData?.meta?.view);
const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom);
const viewIconUnion = util.useAtomValueSafe(viewModel.viewIcon) ?? blockViewToIcon(blockData?.meta?.view);
const preIconButton = util.useAtomValueSafe(viewModel.preIconButton);
const headerTextUnion = util.useAtomValueSafe(viewModel.viewText);
const magnified = jotai.useAtomValue(nodeModel.isMagnified);
const dragHandleRef = preview ? null : nodeModel.dragHandleRef;
const endIconsElem = computeEndIcons(blockData, viewModel, layoutModel);
const onContextMenu = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify, nodeModel.onClose);
},
[magnified]
);
const endIconsElem = computeEndIcons(
viewModel,
magnified,
nodeModel.toggleMagnify,
nodeModel.onClose,
onContextMenu
);
const viewIconElem = getViewIconElem(viewIconUnion, blockData);
let preIconButtonElem: JSX.Element = null;
if (preIconButton) {
@ -139,23 +161,17 @@ const BlockFrame_Header = ({ blockId, layoutModel, viewModel }: BlockFrameProps)
);
}
} else if (Array.isArray(headerTextUnion)) {
headerTextElems.push(...renderHeaderElements(headerTextUnion));
headerTextElems.push(...renderHeaderElements(headerTextUnion, preview));
}
return (
<div
className="block-frame-default-header"
ref={layoutModel?.dragHandleRef}
onContextMenu={(e) =>
handleHeaderContextMenu(e, blockData, viewModel, layoutModel?.onMagnifyToggle, layoutModel?.onClose)
}
>
<div className="block-frame-default-header" ref={dragHandleRef} onContextMenu={onContextMenu}>
{preIconButtonElem}
<div className="block-frame-default-header-iconview">
{viewIconElem}
<div className="block-frame-view-type">{viewName}</div>
{settingsConfig?.blockheader?.showblockids && (
<div className="block-frame-blockid">[{blockId.substring(0, 8)}]</div>
<div className="block-frame-blockid">[{nodeModel.blockId.substring(0, 8)}]</div>
)}
</div>
<div className="block-frame-textelems-wrapper">{headerTextElems}</div>
@ -164,11 +180,11 @@ const BlockFrame_Header = ({ blockId, layoutModel, viewModel }: BlockFrameProps)
);
};
const HeaderTextElem = React.memo(({ elem }: { elem: HeaderElem }) => {
const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => {
if (elem.elemtype == "iconbutton") {
return <IconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />;
} else if (elem.elemtype == "input") {
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} />;
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />;
} else if (elem.elemtype == "text") {
return <div className="block-frame-text">{elem.text}</div>;
} else if (elem.elemtype == "textbutton") {
@ -187,7 +203,7 @@ const HeaderTextElem = React.memo(({ elem }: { elem: HeaderElem }) => {
onMouseOut={elem.onMouseOut}
>
{elem.children.map((child, childIdx) => (
<HeaderTextElem elem={child} key={childIdx} />
<HeaderTextElem elem={child} key={childIdx} preview={preview} />
))}
</div>
);
@ -195,11 +211,11 @@ const HeaderTextElem = React.memo(({ elem }: { elem: HeaderElem }) => {
return null;
});
function renderHeaderElements(headerTextUnion: HeaderElem[]): JSX.Element[] {
function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): JSX.Element[] {
const headerTextElems: JSX.Element[] = [];
for (let idx = 0; idx < headerTextUnion.length; idx++) {
const elem = headerTextUnion[idx];
const renderedElement = <HeaderTextElem elem={elem} key={idx} />;
const renderedElement = <HeaderTextElem elem={elem} key={idx} preview={preview} />;
if (renderedElement) {
headerTextElems.push(renderedElement);
}
@ -207,18 +223,11 @@ function renderHeaderElements(headerTextUnion: HeaderElem[]): JSX.Element[] {
return headerTextElems;
}
function BlockNum({ blockId }: { blockId: string }) {
const tabId = jotai.useAtomValue(atoms.activeTabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
const layoutModel = useLayoutModel(tabAtom);
const leafsOrdered = jotai.useAtomValue(layoutModel.leafsOrdered);
const index = React.useMemo(() => leafsOrdered.findIndex((leaf) => leaf.data?.blockId == blockId), [leafsOrdered]);
return index !== -1 ? index + 1 : null;
}
const BlockMask = ({ blockId, preview, isFocused }: { blockId: string; preview: boolean; isFocused: boolean }) => {
const BlockMask = ({ nodeModel }: { nodeModel: NodeModel }) => {
const isFocused = jotai.useAtomValue(nodeModel.isFocused);
const blockNum = jotai.useAtomValue(nodeModel.blockNum);
const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom);
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId));
const style: React.CSSProperties = {};
if (!isFocused && blockData?.meta?.["frame:bordercolor"]) {
@ -231,9 +240,7 @@ const BlockMask = ({ blockId, preview, isFocused }: { blockId: string; preview:
if (isLayoutMode) {
innerElem = (
<div className="block-mask-inner">
<div className="bignum">
<BlockNum blockId={blockId} />
</div>
<div className="bignum">{blockNum}</div>
</div>
);
}
@ -245,27 +252,17 @@ const BlockMask = ({ blockId, preview, isFocused }: { blockId: string; preview:
};
const BlockFrame_Default_Component = (props: BlockFrameProps) => {
const { blockId, layoutModel, viewModel, blockModel, preview, numBlocksInTab, children } = props;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
return jotai.atom((get) => {
const winData = get(atoms.waveWindow);
return winData?.activeblockid === blockId;
});
});
const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId));
const isFocused = jotai.useAtomValue(nodeModel.isFocused);
const viewIconUnion = util.useAtomValueSafe(viewModel.viewIcon) ?? blockViewToIcon(blockData?.meta?.view);
const customBg = util.useAtomValueSafe(viewModel.blockBg);
let isFocused = jotai.useAtomValue(isFocusedAtom);
if (preview) {
isFocused = true;
}
const viewIconElem = getViewIconElem(viewIconUnion, blockData);
function handleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
if (checkKeyPressed(waveEvent, "Cmd:m")) {
layoutModel?.onMagnifyToggle();
nodeModel.toggleMagnify();
return true;
}
if (viewModel?.keyDownHandler) {
@ -286,20 +283,17 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
const previewElem = <div className="block-frame-preview">{viewIconElem}</div>;
return (
<div
className={clsx(
"block",
"block-frame-default",
isFocused ? "block-focused" : null,
preview ? "block-preview" : null,
numBlocksInTab == 1 ? "block-no-highlight" : null,
"block-" + blockId
)}
className={clsx("block", "block-frame-default", "block-" + nodeModel.blockId, {
"block-focused": isFocused || preview,
"block-preview": preview,
"block-no-highlight": numBlocksInTab === 1,
})}
onClick={blockModel?.onClick}
onFocusCapture={blockModel?.onFocusCapture}
ref={blockModel?.blockRef}
onKeyDown={keydownWrapper(handleKeyDown)}
>
<BlockMask blockId={blockId} preview={preview} isFocused={isFocused} />
<BlockMask nodeModel={nodeModel} />
<div className="block-frame-default-inner" style={innerStyle}>
<BlockFrame_Header {...props} />
{preview ? previewElem : children}
@ -311,7 +305,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component;
const BlockFrame = React.memo((props: BlockFrameProps) => {
const blockId = props.blockId;
const blockId = props.nodeModel.blockId;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const tabData = jotai.useAtomValue(atoms.tabAtom);

View File

@ -1,18 +1,10 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
export interface LayoutComponentModel {
disablePointerEvents: boolean;
onClose?: () => void;
onMagnifyToggle?: () => void;
isMagnified: boolean;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
import { NodeModel } from "@/layout/index";
export interface BlockProps {
blockId: string;
preview: boolean;
layoutModel: LayoutComponentModel;
nodeModel: NodeModel;
}
export interface BlockComponentModel {
@ -22,9 +14,8 @@ export interface BlockComponentModel {
}
export interface BlockFrameProps {
blockId: string;
blockModel?: BlockComponentModel;
layoutModel?: LayoutComponentModel;
nodeModel?: NodeModel;
viewModel?: ViewModel;
preview: boolean;
numBlocksInTab?: number;

View File

@ -196,20 +196,26 @@ export const ConnectionButton = React.memo(({ decl }: { decl: ConnectionButton }
);
});
export const Input = React.memo(({ decl, className }: { decl: HeaderInput; className: string }) => {
const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl;
return (
<div className="input-wrapper">
<input
ref={ref}
disabled={isDisabled}
className={className}
value={value}
onChange={(e) => onChange(e)}
onKeyDown={(e) => onKeyDown(e)}
onFocus={(e) => onFocus(e)}
onBlur={(e) => onBlur(e)}
/>
</div>
);
});
export const Input = React.memo(
({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => {
const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl;
return (
<div className="input-wrapper">
<input
ref={
!preview
? ref
: undefined /* don't wire up the input field if the preview block is being rendered */
}
disabled={isDisabled}
className={className}
value={value}
onChange={(e) => onChange(e)}
onKeyDown={(e) => onKeyDown(e)}
onFocus={(e) => onFocus(e)}
onBlur={(e) => onBlur(e)}
/>
</div>
);
}
);

View File

@ -3,6 +3,7 @@
import { handleIncomingRpcMessage, sendRawRpcMessage } from "@/app/store/wshrpc";
import {
getLayoutModelForActiveTab,
getLayoutModelForTabById,
LayoutTreeActionType,
LayoutTreeInsertNodeAction,
@ -12,7 +13,7 @@ import {
import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil";
import * as util from "@/util/util";
import { produce } from "immer";
import { fireAndForget } from "@/util/util";
import * as jotai from "jotai";
import * as rxjs from "rxjs";
import { modalsModel } from "./modalmodel";
@ -60,7 +61,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
const isFullScreenAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;
try {
getApi().onFullScreenChange((isFullScreen) => {
console.log("fullscreen change", isFullScreen);
globalStore.set(isFullScreenAtom, isFullScreen);
});
} catch (_) {
@ -118,7 +118,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
try {
globalStore.set(updaterStatusAtom, getApi().getUpdaterStatus());
getApi().onUpdaterStatusChange((status) => {
console.log("updater status change", status);
globalStore.set(updaterStatusAtom, status);
});
} catch (_) {
@ -336,7 +335,7 @@ function handleWaveEvent(event: WaveEvent) {
function handleWSEventMessage(msg: WSEventType) {
if (msg.eventtype == null) {
console.log("unsupported event", msg);
console.warn("unsupported WSEvent", msg);
return;
}
if (msg.eventtype == "config") {
@ -380,7 +379,7 @@ function handleWSEventMessage(msg: WSEventType) {
case LayoutTreeActionType.DeleteNode: {
const leaf = layoutModel?.getNodeByBlockId(layoutAction.blockid);
if (leaf) {
layoutModel.closeNode(leaf);
fireAndForget(() => layoutModel.closeNode(leaf.id));
} else {
console.error(
"Cannot apply eventbus layout action DeleteNode, could not find leaf node with blockId",
@ -406,7 +405,7 @@ function handleWSEventMessage(msg: WSEventType) {
break;
}
default:
console.log("unsupported layout action", layoutAction);
console.warn("unsupported layout action", layoutAction);
break;
}
return;
@ -459,12 +458,13 @@ function getApi(): ElectronApi {
return (window as any).api;
}
async function createBlock(blockDef: BlockDef): Promise<string> {
async function createBlock(blockDef: BlockDef, magnified = false): Promise<string> {
const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
const blockId = await services.ObjectService.CreateBlock(blockDef, rtOpts);
const insertNodeAction: LayoutTreeInsertNodeAction = {
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode(undefined, undefined, undefined, { blockId }),
magnified,
};
const activeTabId = globalStore.get(atoms.uiContext).activetabid;
const layoutModel = getLayoutModelForTabById(activeTabId);
@ -503,18 +503,9 @@ async function fetchWaveFile(
return { data: new Uint8Array(data), fileInfo };
}
function setBlockFocus(blockId: string) {
let winData = globalStore.get(atoms.waveWindow);
if (winData == null) {
return;
}
if (winData.activeblockid === blockId) {
return;
}
winData = produce(winData, (draft) => {
draft.activeblockid = blockId;
});
WOS.setObjectValue(winData, globalStore.set, true);
function setNodeFocus(nodeId: string) {
const layoutModel = getLayoutModelForActiveTab();
layoutModel.focusNode(nodeId);
}
const objectIdWeakMap = new WeakMap();
@ -635,7 +626,7 @@ export {
PLATFORM,
registerViewModel,
sendWSCommand,
setBlockFocus,
setNodeFocus,
setPlatform,
subscribeToConnEvents,
unregisterViewModel,

View File

@ -2,9 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
import { Block } from "@/app/block/block";
import { LayoutComponentModel } from "@/app/block/blocktypes";
import { CenteredDiv } from "@/element/quickelems";
import { ContentRenderer, TileLayout } from "@/layout/index";
import { ContentRenderer, NodeModel, PreviewRenderer, TileLayout } from "@/layout/index";
import { getApi } from "@/store/global";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
@ -21,34 +20,13 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
const tabData = useAtomValue(tabAtom);
const tileLayoutContents = useMemo(() => {
const renderBlock: ContentRenderer = (
blockData: TabLayoutData,
ready: boolean,
isMagnified: boolean,
disablePointerEvents: boolean,
onMagnifyToggle: () => void,
onClose: () => void,
dragHandleRef: React.RefObject<HTMLDivElement>
) => {
if (!blockData.blockId || !ready) {
return null;
}
const layoutModel: LayoutComponentModel = {
disablePointerEvents,
onClose,
onMagnifyToggle,
dragHandleRef,
isMagnified,
};
return (
<Block key={blockData.blockId} blockId={blockData.blockId} layoutModel={layoutModel} preview={false} />
);
const renderBlock: ContentRenderer = (nodeModel: NodeModel) => {
return <Block key={nodeModel.blockId} nodeModel={nodeModel} preview={false} />;
};
function renderPreview(tabData: TabLayoutData) {
if (!tabData) return;
return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={null} preview={true} />;
}
const renderPreview: PreviewRenderer = (nodeModel: NodeModel) => {
return <Block key={nodeModel.blockId} nodeModel={nodeModel} preview={true} />;
};
function onNodeDelete(data: TabLayoutData) {
return services.ObjectService.DeleteBlock(data.blockId);

View File

@ -17,7 +17,6 @@ import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil";
import * as util from "@/util/util";
import clsx from "clsx";
import { produce } from "immer";
import * as jotai from "jotai";
import "public/xterm.css";
import * as React from "react";
@ -105,17 +104,6 @@ const testVDom: VDomElem = {
],
};
function setBlockFocus(blockId: string) {
let winData = globalStore.get(atoms.waveWindow);
if (winData == null) {
return;
}
winData = produce(winData, (draft) => {
draft.activeblockid = blockId;
});
WOS.setObjectValue(winData, globalStore.set, true);
}
class TermViewModel {
viewType: string;
connected: boolean;
@ -256,17 +244,10 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null);
model.htmlElemFocusRef = htmlElemFocusRef;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
return jotai.atom((get) => {
const winData = get(atoms.waveWindow);
return winData?.activeblockid === blockId;
});
});
const termSettingsAtom = useSettingsAtom<TerminalConfigType>("term", (settings: SettingsConfigType) => {
return settings?.term;
});
const termSettings = jotai.useAtomValue(termSettingsAtom);
const isFocused = jotai.useAtomValue(isFocusedAtom);
React.useEffect(() => {
function handleTerminalKeydown(event: KeyboardEvent): boolean {
@ -323,9 +304,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
);
(window as any).term = termWrap;
termRef.current = termWrap;
termWrap.addFocusListener(() => {
setBlockFocus(blockId);
});
const rszObs = new ResizeObserver(() => {
termWrap.handleResize_debounced();
});
@ -358,16 +336,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
termMode = "term";
}
// set initial focus
React.useEffect(() => {
if (isFocused && termMode == "term") {
termRef.current?.terminal.focus();
}
if (isFocused && termMode == "html") {
htmlElemFocusRef.current?.focus();
}
}, []);
// set intitial controller status, and then subscribe for updates
React.useEffect(() => {
function updateShellProcStatus(status: string) {
@ -455,11 +423,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
);
return (
<div
className={clsx("view-term", "term-mode-" + termMode, isFocused ? "is-focused" : null)}
onKeyDown={handleKeyDown}
ref={viewRef}
>
<div className={clsx("view-term", "term-mode-" + termMode)} onKeyDown={handleKeyDown} ref={viewRef}>
{typeAhead[blockId] && (
<TypeAheadModal
anchor={viewRef}
@ -483,7 +447,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
if (htmlElemFocusRef.current != null) {
htmlElemFocusRef.current.focus();
}
setBlockFocus(blockId);
}}
>
<div key="htmlElemFocus" className="term-htmlelem-focus">

View File

@ -5,10 +5,10 @@ import { TileLayout } from "./lib/TileLayout";
import { LayoutModel } from "./lib/layoutModel";
import {
deleteLayoutModelForTab,
getLayoutModelForActiveTab,
getLayoutModelForTab,
getLayoutModelForTabById,
useLayoutModel,
useLayoutNode,
} from "./lib/layoutModelHooks";
import { newLayoutNode } from "./lib/layoutNode";
import type {
@ -19,6 +19,7 @@ import type {
LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeFocusNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
@ -27,12 +28,15 @@ import type {
LayoutTreeSetPendingAction,
LayoutTreeStateSetter,
LayoutTreeSwapNodeAction,
NodeModel,
PreviewRenderer,
} from "./lib/types";
import { DropDirection, LayoutTreeActionType, NavigateDirection } from "./lib/types";
export {
deleteLayoutModelForTab,
DropDirection,
getLayoutModelForActiveTab,
getLayoutModelForTab,
getLayoutModelForTabById,
LayoutModel,
@ -41,7 +45,6 @@ export {
newLayoutNode,
TileLayout,
useLayoutModel,
useLayoutNode,
};
export type {
ContentRenderer,
@ -51,6 +54,7 @@ export type {
LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeFocusNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
@ -59,4 +63,6 @@ export type {
LayoutTreeSetPendingAction,
LayoutTreeStateSetter,
LayoutTreeSwapNodeAction,
NodeModel,
PreviewRenderer,
};

View File

@ -19,7 +19,7 @@ import { DropTargetMonitor, XYCoord, useDrag, useDragLayer, useDrop } from "reac
import { debounce, throttle } from "throttle-debounce";
import { useDevicePixelRatio } from "use-device-pixel-ratio";
import { LayoutModel } from "./layoutModel";
import { useLayoutNode, useTileLayout } from "./layoutModelHooks";
import { useNodeModel, useTileLayout } from "./layoutModelHooks";
import "./tilelayout.less";
import {
LayoutNode,
@ -53,7 +53,6 @@ const DragPreviewHeight = 300;
function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutProps) {
const layoutModel = useTileLayout(tabAtom, contents);
const generation = useAtomValue(layoutModel.generationAtom);
const overlayTransform = useAtomValue(layoutModel.overlayTransform);
const setActiveDrag = useSetAtom(layoutModel.activeDrag);
const setReady = useSetAtom(layoutModel.ready);
@ -85,7 +84,7 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
}
}
}),
[getCursorPoint, generation]
[getCursorPoint]
);
// Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture
@ -115,7 +114,7 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
>
<div key="display" ref={layoutModel.displayContainerRef} className="display-container">
<ResizeHandleWrapper layoutModel={layoutModel} />
<DisplayNodesWrapper contents={contents} layoutModel={layoutModel} />
<DisplayNodesWrapper layoutModel={layoutModel} />
</div>
<Placeholder key="placeholder" layoutModel={layoutModel} style={{ top: 10000, ...overlayTransform }} />
<OverlayNodeWrapper layoutModel={layoutModel} />
@ -131,19 +130,15 @@ interface DisplayNodesWrapperProps {
* The layout tree state.
*/
layoutModel: LayoutModel;
/**
* contains callbacks and information about the contents (or styling) of of the TileLayout
*/
contents: TileLayoutContents;
}
const DisplayNodesWrapper = ({ layoutModel, contents }: DisplayNodesWrapperProps) => {
const DisplayNodesWrapper = ({ layoutModel }: DisplayNodesWrapperProps) => {
const leafs = useAtomValue(layoutModel.leafs);
return useMemo(
() =>
leafs.map((leaf) => {
return <DisplayNode key={leaf.id} layoutModel={layoutModel} layoutNode={leaf} contents={contents} />;
leafs.map((node) => {
return <DisplayNode key={node.id} layoutModel={layoutModel} node={node} />;
}),
[leafs]
);
@ -154,12 +149,7 @@ interface DisplayNodeProps {
/**
* The leaf node object, containing the data needed to display the leaf contents to the user.
*/
layoutNode: LayoutNode;
/**
* contains callbacks and information about the contents (or styling) of of the TileLayout
*/
contents: TileLayoutContents;
node: LayoutNode;
}
const dragItemType = "TILE_ITEM";
@ -167,26 +157,23 @@ const dragItemType = "TILE_ITEM";
/**
* The draggable and displayable portion of a leaf node in a layout tree.
*/
const DisplayNode = ({ layoutModel, layoutNode, contents }: DisplayNodeProps) => {
const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => {
const nodeModel = useNodeModel(layoutModel, node);
const tileNodeRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const addlProps = useLayoutNode(layoutModel, layoutNode);
const activeDrag = useAtomValue(layoutModel.activeDrag);
const globalReady = useAtomValue(layoutModel.ready);
const addlProps = useAtomValue(nodeModel.additionalProps);
const devicePixelRatio = useDevicePixelRatio();
const [{ isDragging }, drag, dragPreview] = useDrag(
() => ({
type: dragItemType,
item: () => layoutNode,
item: () => node,
canDrag: () => !addlProps?.isMagnifiedNode,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[layoutNode, addlProps]
[node, addlProps]
);
const [previewElementGeneration, setPreviewElementGeneration] = useState(0);
@ -203,11 +190,11 @@ const DisplayNode = ({ layoutModel, layoutNode, contents }: DisplayNodeProps) =>
transform: `scale(${1 / devicePixelRatio})`,
}}
>
{contents.renderPreview?.(layoutNode.data)}
{layoutModel.renderPreview?.(nodeModel)}
</div>
</div>
);
}, [contents.renderPreview, devicePixelRatio, layoutNode.data]);
}, [devicePixelRatio, nodeModel]);
const [previewImage, setPreviewImage] = useState<HTMLImageElement>(null);
const [previewImageGeneration, setPreviewImageGeneration] = useState(0);
@ -232,31 +219,20 @@ const DisplayNode = ({ layoutModel, layoutNode, contents }: DisplayNodeProps) =>
previewImageGeneration,
previewImage,
devicePixelRatio,
layoutNode.data,
]);
// Register the display node as a draggable item
useEffect(() => {
drag(dragHandleRef);
}, [drag, dragHandleRef.current]);
const leafContent = useMemo(() => {
return (
layoutNode.data && (
<div key="leaf" className="tile-leaf">
{contents.renderContent(
layoutNode.data,
globalReady,
addlProps?.isMagnifiedNode ?? false,
activeDrag,
() => layoutModel.magnifyNodeToggle(layoutNode),
() => layoutModel.closeNode(layoutNode),
dragHandleRef
)}
</div>
)
<div key="leaf" className="tile-leaf">
{layoutModel.renderContent(nodeModel)}
</div>
);
}, [layoutNode, globalReady, activeDrag, addlProps]);
}, [nodeModel]);
// Register the display node as a draggable item
useEffect(() => {
drag(nodeModel.dragHandleRef);
}, [drag, nodeModel.dragHandleRef.current]);
return (
<div
@ -266,7 +242,7 @@ const DisplayNode = ({ layoutModel, layoutNode, contents }: DisplayNodeProps) =>
"last-magnified": addlProps?.isLastMagnifiedNode,
})}
ref={tileNodeRef}
id={layoutNode.id}
id={node.id}
style={addlProps?.transform}
onPointerEnter={generatePreviewImage}
onPointerOver={(event) => event.stopPropagation()}
@ -281,14 +257,14 @@ interface OverlayNodeWrapperProps {
layoutModel: LayoutModel;
}
const OverlayNodeWrapper = ({ layoutModel }: OverlayNodeWrapperProps) => {
const OverlayNodeWrapper = memo(({ layoutModel }: OverlayNodeWrapperProps) => {
const leafs = useAtomValue(layoutModel.leafs);
const overlayTransform = useAtomValue(layoutModel.overlayTransform);
const overlayNodes = useMemo(
() =>
leafs.map((leaf) => {
return <OverlayNode key={leaf.id} layoutModel={layoutModel} layoutNode={leaf} />;
leafs.map((node) => {
return <OverlayNode key={node.id} layoutModel={layoutModel} node={node} />;
}),
[leafs]
);
@ -298,34 +274,31 @@ const OverlayNodeWrapper = ({ layoutModel }: OverlayNodeWrapperProps) => {
{overlayNodes}
</div>
);
};
});
interface OverlayNodeProps {
/**
* The layout node object corresponding to the OverlayNode.
*/
layoutNode: LayoutNode;
/**
* The layout tree state.
*/
layoutModel: LayoutModel;
node: LayoutNode;
}
/**
* An overlay representing the true flexbox layout of the LayoutTreeState. This holds the drop targets for moving around nodes and is used to calculate the
* dimensions of the corresponding DisplayNode for each LayoutTreeState leaf.
*/
const OverlayNode = ({ layoutNode, layoutModel }: OverlayNodeProps) => {
const additionalProps = useLayoutNode(layoutModel, layoutNode);
const OverlayNode = memo(({ node, layoutModel }: OverlayNodeProps) => {
const nodeModel = useNodeModel(layoutModel, node);
const additionalProps = useAtomValue(nodeModel.additionalProps);
const overlayRef = useRef<HTMLDivElement>(null);
const generation = useAtomValue(layoutModel.generationAtom);
const [, drop] = useDrop(
() => ({
accept: dragItemType,
canDrop: (_, monitor) => {
const dragItem = monitor.getItem<LayoutNode>();
if (monitor.isOver({ shallow: true }) && dragItem?.id !== layoutNode.id) {
if (monitor.isOver({ shallow: true }) && dragItem?.id !== node.id) {
return true;
}
return false;
@ -346,7 +319,7 @@ const OverlayNode = ({ layoutNode, layoutModel }: OverlayNodeProps) => {
offset.y -= containerRect.y;
layoutModel.treeReducer({
type: LayoutTreeActionType.ComputeMove,
node: layoutNode,
node: node,
nodeToMove: dragItem,
direction: determineDropDirection(additionalProps.rect, offset),
} as LayoutTreeComputeMoveNodeAction);
@ -358,7 +331,7 @@ const OverlayNode = ({ layoutNode, layoutModel }: OverlayNodeProps) => {
}
}),
}),
[layoutNode, generation, additionalProps, layoutModel.displayContainerRef]
[node, additionalProps, layoutModel.displayContainerRef]
);
// Register the overlay node as a drop target
@ -366,27 +339,27 @@ const OverlayNode = ({ layoutNode, layoutModel }: OverlayNodeProps) => {
drop(overlayRef);
}, []);
return <div ref={overlayRef} className="overlay-node" id={layoutNode.id} style={additionalProps?.transform} />;
};
return <div ref={overlayRef} className="overlay-node" id={node.id} style={additionalProps?.transform} />;
});
interface ResizeHandleWrapperProps {
layoutModel: LayoutModel;
}
const ResizeHandleWrapper = ({ layoutModel }: ResizeHandleWrapperProps) => {
const ResizeHandleWrapper = memo(({ layoutModel }: ResizeHandleWrapperProps) => {
const resizeHandles = useAtomValue(layoutModel.resizeHandles) as Atom<ResizeHandleProps>[];
return resizeHandles.map((resizeHandleAtom, i) => (
<ResizeHandle key={`resize-handle-${i}`} layoutModel={layoutModel} resizeHandleAtom={resizeHandleAtom} />
));
};
});
interface ResizeHandleComponentProps {
resizeHandleAtom: Atom<ResizeHandleProps>;
layoutModel: LayoutModel;
}
const ResizeHandle = ({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => {
const ResizeHandle = memo(({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => {
const resizeHandleProps = useAtomValue(resizeHandleAtom);
const resizeHandleRef = useRef<HTMLDivElement>(null);
@ -436,7 +409,7 @@ const ResizeHandle = ({ resizeHandleAtom, layoutModel }: ResizeHandleComponentPr
<div className="line" />
</div>
);
};
});
interface PlaceholderProps {
/**

View File

@ -17,7 +17,6 @@ function getLayoutStateAtomFromTab(tabAtom: Atom<Tab>, get: Getter): WritableWav
export function withLayoutTreeStateAtomFromTab(tabAtom: Atom<Tab>): WritableLayoutTreeStateAtom {
if (layoutStateAtomMap.has(tabAtom)) {
// console.log("found atom");
return layoutStateAtomMap.get(tabAtom);
}
const generationAtom = atom(1);
@ -26,9 +25,9 @@ export function withLayoutTreeStateAtomFromTab(tabAtom: Atom<Tab>): WritableLayo
const stateAtom = getLayoutStateAtomFromTab(tabAtom, get);
if (!stateAtom) return;
const layoutStateData = get(stateAtom);
// console.log("layoutStateData", layoutStateData);
const layoutTreeState: LayoutTreeState = {
rootNode: layoutStateData?.rootnode,
focusedNodeId: layoutStateData?.focusednodeid,
magnifiedNodeId: layoutStateData?.magnifiednodeid,
generation: get(generationAtom),
};
@ -37,12 +36,11 @@ export function withLayoutTreeStateAtomFromTab(tabAtom: Atom<Tab>): WritableLayo
(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;
waveObjVal.focusednodeid = value.focusedNodeId;
set(generationAtom, value.generation);
set(stateAtom, waveObjVal);
}

View File

@ -10,6 +10,7 @@ import { balanceNode, findNode, walkNodes } from "./layoutNode";
import {
computeMoveNode,
deleteNode,
focusNode,
insertNode,
insertNodeAtIndex,
magnifyNodeToggle,
@ -26,6 +27,7 @@ import {
LayoutTreeActionType,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeFocusNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
@ -34,12 +36,14 @@ import {
LayoutTreeSetPendingAction,
LayoutTreeState,
LayoutTreeSwapNodeAction,
NavigateDirection,
NodeModel,
PreviewRenderer,
ResizeHandleProps,
TileLayoutContents,
WritableLayoutTreeStateAtom,
} from "./types";
import { setTransform } from "./utils";
import { getCenter, navigateDirectionToOffset, setTransform } from "./utils";
interface ResizeContext {
handleId: string;
@ -61,6 +65,10 @@ export class LayoutModel {
* The tree state as it is persisted on the backend.
*/
treeState: LayoutTreeState;
/**
* The last-recorded tree state generation.
*/
lastTreeStateGeneration: number;
/**
* The jotai getter that is used to read atom values.
*/
@ -91,9 +99,13 @@ export class LayoutModel {
*/
leafs: PrimitiveAtom<LayoutNode[]>;
/**
* List of nodes that are leafs, ordered sequentially by placement in the tree.
* An ordered list of node ids starting from the top left corner to the bottom right corner.
*/
leafsOrdered: Atom<LayoutNode[]>;
leafOrder: Atom<string[]>;
/**
* A map of node models for currently-active leafs.
*/
private nodeModels: Map<string, NodeModel>;
/**
* Split atom containing the properties of all of the resize handles that should be placed in the layout.
@ -136,6 +148,14 @@ export class LayoutModel {
*/
overlayTransform: Atom<CSSProperties>;
/**
* The currently focused node.
*/
private focusedNodeIdStack: string[];
/**
* Atom pointing to the currently focused node.
*/
focusedNode: Atom<LayoutNode>;
/**
* The currently magnified node.
*/
@ -170,11 +190,6 @@ export class LayoutModel {
*/
private isContainerResizing: PrimitiveAtom<boolean>;
/**
* An arbitrary generation value that is incremented every time the updateTree function runs. Helps indicate to subscribers that they should update their memoized values.
*/
generationAtom: PrimitiveAtom<number>;
constructor(
treeStateAtom: WritableLayoutTreeStateAtom,
getter: Getter,
@ -184,7 +199,6 @@ export class LayoutModel {
onNodeDelete?: (data: TabLayoutData) => Promise<void>,
gapSizePx?: number
) {
console.log("ctor");
this.treeStateAtom = treeStateAtom;
this.getter = getter;
this.setter = setter;
@ -196,20 +210,22 @@ export class LayoutModel {
this.resizeHandleSizePx = 2 * this.halfResizeHandleSizePx;
this.leafs = atom([]);
this.additionalProps = atom({});
this.leafsOrdered = atom((get) => {
this.leafOrder = atom((get) => {
const leafs = get(this.leafs);
const additionalProps = get(this.additionalProps);
console.log("additionalProps", additionalProps);
const leafsOrdered = leafs.sort((a, b) => {
const treeKeyA = additionalProps[a.id].treeKey;
const treeKeyB = additionalProps[b.id].treeKey;
return treeKeyA.localeCompare(treeKeyB);
});
console.log("leafsOrdered", leafsOrdered);
return leafsOrdered;
return leafs
.map((node) => node.id)
.sort((a, b) => {
const treeKeyA = additionalProps[a]?.treeKey;
const treeKeyB = additionalProps[b]?.treeKey;
if (!treeKeyA || !treeKeyB) return;
return treeKeyA.localeCompare(treeKeyB);
});
});
this.nodeModels = new Map();
this.additionalProps = atom({});
const resizeHandleListAtom = atom((get) => {
const addlProps = get(this.additionalProps);
return Object.values(addlProps)
@ -247,17 +263,25 @@ export class LayoutModel {
}
});
this.focusedNode = atom((get) => {
const treeState = get(this.treeStateAtom);
return findNode(treeState.rootNode, treeState.focusedNodeId);
});
this.focusedNodeIdStack = [];
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);
}
get focusedNodeId(): string {
return this.focusedNodeIdStack[0];
}
/**
* Register TileLayout callbacks that should be called on various state changes.
* @param contents Contains callbacks provided by the TileLayout component.
@ -273,8 +297,6 @@ export class LayoutModel {
* @param action The action to perform.
*/
treeReducer(action: LayoutTreeAction) {
// console.log("treeReducer", action, this);
let stateChanged = false;
switch (action.type) {
case LayoutTreeActionType.ComputeMove:
this.setter(
@ -284,27 +306,21 @@ export class LayoutModel {
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;
@ -328,21 +344,22 @@ export class LayoutModel {
this.setter(this.pendingAction.throttledValueAtom, undefined);
break;
}
case LayoutTreeActionType.FocusNode:
focusNode(this.treeState, action as LayoutTreeFocusNodeAction);
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);
if (this.lastTreeStateGeneration !== this.treeState.generation) {
this.lastTreeStateGeneration = this.treeState.generation;
if (this.magnifiedNodeId !== this.treeState.magnifiedNodeId) {
this.lastMagnifiedNodeId = this.magnifiedNodeId;
this.magnifiedNodeId = this.treeState.magnifiedNodeId;
}
this.updateTree();
this.treeState.generation++;
this.setter(this.treeStateAtom, this.treeState);
}
}
@ -370,9 +387,7 @@ export class LayoutModel {
* @param balanceTree Whether the tree should also be balanced as it is walked. This should be done if the tree state has just been updated. Defaults to true.
*/
updateTree = (balanceTree: boolean = true) => {
// console.log("updateTree");
if (this.displayContainerRef.current) {
// console.log("updateTree 1");
const newLeafs: LayoutNode[] = [];
const newAdditionalProps = {};
@ -391,8 +406,8 @@ export class LayoutModel {
this.leafs,
newLeafs.sort((a, b) => a.id.localeCompare(b.id))
);
this.setter(this.generationAtom, this.getter(this.generationAtom) + 1);
this.validateFocusedNode(newLeafs);
this.cleanupNodeModels();
}
};
@ -419,7 +434,6 @@ export class LayoutModel {
};
if (!node.children?.length) {
// console.log("adding node to leafs", node);
leafs.push(node);
const addlProps = additionalPropsMap[node.id];
if (addlProps) {
@ -450,8 +464,6 @@ export class LayoutModel {
? additionalPropsMap[node.id]
: { treeKey: "0" };
console.log("layoutNode addlProps", node, additionalProps);
const nodeRect: Dimensions = node.id === this.treeState.rootNode.id ? getBoundingRect() : additionalProps.rect;
const nodeIsRow = node.flexDirection === FlexDirection.Row;
const nodePixels = nodeIsRow ? nodeRect.width : nodeRect.height;
@ -509,6 +521,29 @@ export class LayoutModel {
};
}
/**
* Checks whether the focused node id has changed and, if so, whether to update the focused node stack. If the focused node was deleted, will pop the latest value from the stack.
* @param newLeafs The new leafs array to use when searching for stale nodes in the stack.
*/
private validateFocusedNode(newLeafs: LayoutNode[]) {
if (this.treeState.focusedNodeId !== this.focusedNodeId) {
// Remove duplicates and stale entries from focus stack.
const leafIds = newLeafs.map((leaf) => leaf.id);
const newFocusedNodeIdStack: string[] = [];
for (const id of this.focusedNodeIdStack) {
if (leafIds.includes(id) && !newFocusedNodeIdStack.includes(id)) newFocusedNodeIdStack.push(id);
}
this.focusedNodeIdStack = newFocusedNodeIdStack;
// Update the focused node and stack based on the changes in the tree state.
if (this.treeState.focusedNodeId) {
this.focusedNodeIdStack.unshift(this.treeState.focusedNodeId);
} else {
this.treeState.focusedNodeId = this.focusedNodeIdStack.shift();
}
}
}
/**
* Helper function for the placeholderTransform atom, which computes the new transform value when the pending action changes.
* @param pendingAction The new pending action value.
@ -518,10 +553,8 @@ export class LayoutModel {
*/
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) {
@ -581,7 +614,6 @@ export class LayoutModel {
break;
}
case LayoutTreeActionType.Swap: {
// console.log("doing swap overlay");
const action = pendingAction as LayoutTreeSwapNodeAction;
const targetNodeId = action.node1Id;
const targetBoundingRect = this.getNodeRectById(targetNodeId);
@ -603,13 +635,150 @@ export class LayoutModel {
}
/**
* Toggle magnification of a given node.
* @param node The node that is being magnified.
* Gets the node model for the given node.
* @param node The node for which to retrieve the node model.
* @returns The node model for the given node.
*/
magnifyNodeToggle(node: LayoutNode) {
const action = {
getNodeModel(node: LayoutNode): NodeModel {
const nodeid = node.id;
const blockId = node.data.blockId;
if (!this.nodeModels.has(nodeid)) {
this.nodeModels.set(nodeid, {
additionalProps: this.getNodeAdditionalPropertiesAtom(nodeid),
nodeId: nodeid,
blockId,
blockNum: atom((get) => get(this.leafOrder).indexOf(nodeid) + 1),
isFocused: atom((get) => {
const treeState = get(this.treeStateAtom);
const isFocused = treeState.focusedNodeId === nodeid;
return isFocused;
}),
isMagnified: atom((get) => {
const treeState = get(this.treeStateAtom);
return treeState.magnifiedNodeId === nodeid;
}),
ready: this.ready,
disablePointerEvents: this.activeDrag,
onClose: async () => await this.closeNode(nodeid),
toggleMagnify: () => this.magnifyNodeToggle(nodeid),
focusNode: () => this.focusNode(nodeid),
dragHandleRef: createRef(),
});
}
const nodeModel = this.nodeModels.get(nodeid);
return nodeModel;
}
private cleanupNodeModels() {
const leafOrder = this.getter(this.leafOrder);
const orphanedNodeModels = [...this.nodeModels.keys()].filter((id) => !leafOrder.includes(id));
for (const id of orphanedNodeModels) {
this.nodeModels.delete(id);
}
}
/**
* Switch focus to the next node in the given direction in the layout.
* @param direction The direction in which to switch focus.
*/
switchNodeFocusInDirection(direction: NavigateDirection) {
const curNodeId = this.focusedNodeId;
// If no node is focused, set focus to the first leaf.
if (!curNodeId) {
this.focusNode(this.getter(this.leafOrder)[0]);
return;
}
const offset = navigateDirectionToOffset(direction);
const nodePositions: Map<string, Dimensions> = new Map();
const leafs = this.getter(this.leafs);
const addlProps = this.getter(this.additionalProps);
for (const leaf of leafs) {
const pos = addlProps[leaf.id]?.rect;
if (pos) {
nodePositions.set(leaf.id, pos);
}
}
const curNodePos = nodePositions.get(curNodeId);
if (!curNodePos) {
return;
}
nodePositions.delete(curNodeId);
const boundingRect = this.displayContainerRef?.current.getBoundingClientRect();
if (!boundingRect) {
return;
}
const maxX = boundingRect.left + boundingRect.width;
const maxY = boundingRect.top + boundingRect.height;
const moveAmount = 10;
const curPoint = getCenter(curNodePos);
function findNodeAtPoint(m: Map<string, Dimensions>, p: Point): string {
for (const [blockId, dimension] of m.entries()) {
if (
p.x >= dimension.left &&
p.x <= dimension.left + dimension.width &&
p.y >= dimension.top &&
p.y <= dimension.top + dimension.height
) {
return blockId;
}
}
return null;
}
while (true) {
curPoint.x += offset.x * moveAmount;
curPoint.y += offset.y * moveAmount;
if (curPoint.x < 0 || curPoint.x > maxX || curPoint.y < 0 || curPoint.y > maxY) {
return;
}
const nodeId = findNodeAtPoint(nodePositions, curPoint);
if (nodeId != null) {
this.focusNode(nodeId);
return;
}
}
}
/**
* Switch focus to a node using the given BlockNum
* @param newBlockNum The BlockNum of the node to which focus should switch.
* @see leafOrder - the indices in this array determine BlockNum
*/
switchNodeFocusByBlockNum(newBlockNum: number) {
const leafOrder = this.getter(this.leafOrder);
const newLeafIdx = newBlockNum - 1;
if (newLeafIdx < 0 || newLeafIdx >= leafOrder.length) {
return;
}
const leafId = leafOrder[newLeafIdx];
this.focusNode(leafId);
}
/**
* Set the layout to focus on the given node.
* @param nodeId The id of the node that is being focused.
*/
focusNode(nodeId: string) {
if (this.focusedNodeId === nodeId) return;
const action: LayoutTreeFocusNodeAction = {
type: LayoutTreeActionType.FocusNode,
nodeId: nodeId,
};
this.treeReducer(action);
}
/**
* Toggle magnification of a given node.
* @param nodeId The id of the node that is being magnified.
*/
magnifyNodeToggle(nodeId: string) {
const action: LayoutTreeMagnifyNodeToggleAction = {
type: LayoutTreeActionType.MagnifyNodeToggle,
nodeId: node.id,
nodeId: nodeId,
};
this.treeReducer(action);
@ -617,22 +786,32 @@ export class LayoutModel {
/**
* Close a given node and update the tree state.
* @param node The node that is being closed.
* @param nodeId The id of the node that is being closed.
*/
async closeNode(node: LayoutNode) {
async closeNode(nodeId: string) {
const nodeToDelete = findNode(this.treeState.rootNode, nodeId);
if (!nodeToDelete) {
console.error("unable to close node, cannot find it in tree", nodeId);
return;
}
const deleteAction: LayoutTreeDeleteNodeAction = {
type: LayoutTreeActionType.DeleteNode,
nodeId: node.id,
nodeId: nodeId,
};
this.treeReducer(deleteAction);
await this.onNodeDelete?.(node.data);
await this.onNodeDelete?.(nodeToDelete.data);
}
async closeNodeById(nodeId: string) {
const nodeToDelete = findNode(this.treeState.rootNode, nodeId);
await this.closeNode(nodeToDelete);
/**
* Shorthand function for closing the focused node in a layout.
*/
async closeFocusedNode() {
await this.closeNode(this.focusedNodeId);
}
/**
* Callback that is invoked when a drag operation completes and the pending action should be committed.
*/
onDrop() {
if (this.getter(this.pendingAction.currentValueAtom)) {
this.treeReducer({
@ -664,7 +843,6 @@ export class LayoutModel {
* @param y The Y coordinate of the pointer device, in CSS pixels.
*/
onResizeMove(resizeHandle: ResizeHandleProps, x: number, y: number) {
// console.log("onResizeMove", resizeHandle, x, y, this.resizeContext);
const parentIsRow = resizeHandle.flexDirection === FlexDirection.Row;
const parentNode = findNode(this.treeState.rootNode, resizeHandle.parentNodeId);
const beforeNode = parentNode.children![resizeHandle.parentIndex];
@ -690,10 +868,6 @@ export class LayoutModel {
}
}
const boundingRect = this.displayContainerRef.current?.getBoundingClientRect();
x -= boundingRect?.top + 10;
y -= boundingRect?.left - 10;
const clientPoint = parentIsRow ? x : y;
const clientDiff = (this.resizeContext.resizeHandleStartPx - clientPoint) * this.resizeContext.pixelToSizeRatio;
const minNodeSize = MinNodeSizePx * this.resizeContext.pixelToSizeRatio;
@ -737,7 +911,12 @@ export class LayoutModel {
}
}
getNodeByBlockId(blockId: string) {
/**
* Get the layout node matching the specified blockId.
* @param blockId The blockId that the returned node should contain.
* @returns The node containing the specified blockId, null if not found.
*/
getNodeByBlockId(blockId: string): LayoutNode {
for (const leaf of this.getter(this.leafs)) {
if (leaf.data.blockId === blockId) {
return leaf;
@ -754,13 +933,6 @@ export class LayoutModel {
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];
});
}

View File

@ -1,13 +1,13 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { globalStore, WOS } from "@/app/store/global";
import { atoms, globalStore, WOS } from "@/app/store/global";
import useResizeObserver from "@react-hook/resize-observer";
import { Atom, useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { withLayoutTreeStateAtomFromTab } from "./layoutAtom";
import { LayoutModel } from "./layoutModel";
import { LayoutNode, LayoutNodeAdditionalProps, TileLayoutContents } from "./types";
import { LayoutNode, NodeModel, TileLayoutContents } from "./types";
const layoutModelMap: Map<string, LayoutModel> = new Map();
@ -34,6 +34,11 @@ export function getLayoutModelForTabById(tabId: string) {
return getLayoutModelForTab(tabAtom);
}
export function getLayoutModelForActiveTab() {
const tabId = globalStore.get(atoms.activeTabId);
return getLayoutModelForTabById(tabId);
}
export function deleteLayoutModelForTab(tabId: string) {
if (layoutModelMap.has(tabId)) layoutModelMap.delete(tabId);
}
@ -51,8 +56,6 @@ export function useTileLayout(tabAtom: Atom<Tab>, tileContent: TileLayoutContent
return layoutModel;
}
export function useLayoutNode(layoutModel: LayoutModel, layoutNode: LayoutNode): LayoutNodeAdditionalProps {
const [addlPropsAtom] = useState(layoutModel.getNodeAdditionalPropertiesAtom(layoutNode.id));
const addlProps = useAtomValue(addlPropsAtom);
return addlProps;
export function useNodeModel(layoutModel: LayoutModel, layoutNode: LayoutNode): NodeModel {
return layoutModel.getNodeModel(layoutNode);
}

View File

@ -18,6 +18,7 @@ import {
LayoutTreeActionType,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeFocusNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeInsertNodeAtIndexAction,
LayoutTreeMagnifyNodeToggleAction,
@ -37,7 +38,6 @@ import {
export function computeMoveNode(layoutState: LayoutTreeState, computeInsertAction: LayoutTreeComputeMoveNodeAction) {
const rootNode = layoutState.rootNode;
const { node, nodeToMove, direction } = computeInsertAction;
// console.log("computeInsertOperation start", layoutState.rootNode, node, nodeToMove, direction);
if (direction === undefined) {
console.warn("No direction provided for insertItemInDirection");
return;
@ -179,14 +179,12 @@ export function computeMoveNode(layoutState: LayoutTreeState, computeInsertActio
}
break;
case DropDirection.Center:
// console.log("center drop", rootNode, node, nodeToMove);
if (node.id !== rootNode.id && nodeToMove.id !== rootNode.id) {
const swapAction: LayoutTreeSwapNodeAction = {
type: LayoutTreeActionType.Swap,
node1Id: node.id,
node2Id: nodeToMove.id,
};
// console.log("swapAction", swapAction);
return swapAction;
} else {
console.warn("cannot swap");
@ -209,7 +207,6 @@ export function computeMoveNode(layoutState: LayoutTreeState, computeInsertActio
export function moveNode(layoutState: LayoutTreeState, action: LayoutTreeMoveNodeAction) {
const rootNode = layoutState.rootNode;
// console.log("moveNode", action, layoutState.rootNode);
if (!action) {
console.error("no move node action provided");
return;
@ -223,8 +220,6 @@ export function moveNode(layoutState: LayoutTreeState, action: LayoutTreeMoveNod
const parent = findNode(rootNode, action.parentId);
const oldParent = findParent(rootNode, action.node.id);
// console.log(node, parent, oldParent);
let startingIndex = 0;
// If moving under the same parent, we need to make sure that we are removing the child from its old position, not its new one.
@ -256,6 +251,7 @@ export function moveNode(layoutState: LayoutTreeState, action: LayoutTreeMoveNod
if (oldParent) {
removeChild(oldParent, node, startingIndex);
}
layoutState.generation++;
}
export function insertNode(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAction) {
@ -265,13 +261,15 @@ export function insertNode(layoutState: LayoutTreeState, action: LayoutTreeInser
}
if (!layoutState.rootNode) {
layoutState.rootNode = action.node;
return;
}
const insertLoc = findNextInsertLocation(layoutState.rootNode, 5);
addChildAt(insertLoc.node, insertLoc.index, action.node);
if (action.magnified) {
layoutState.magnifiedNodeId = action.node.id;
} else {
const insertLoc = findNextInsertLocation(layoutState.rootNode, 5);
addChildAt(insertLoc.node, insertLoc.index, action.node);
if (action.magnified) {
layoutState.magnifiedNodeId = action.node.id;
}
layoutState.focusedNodeId = action.node.id;
}
layoutState.generation++;
}
export function insertNodeAtIndex(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAtIndexAction) {
@ -281,22 +279,22 @@ export function insertNodeAtIndex(layoutState: LayoutTreeState, action: LayoutTr
}
if (!layoutState.rootNode) {
layoutState.rootNode = action.node;
return;
}
const insertLoc = findInsertLocationFromIndexArr(layoutState.rootNode, action.indexArr);
if (!insertLoc) {
console.error("insertNodeAtIndex unable to find insert location");
return;
}
addChildAt(insertLoc.node, insertLoc.index + 1, action.node);
if (action.magnified) {
layoutState.magnifiedNodeId = action.node.id;
} else {
const insertLoc = findInsertLocationFromIndexArr(layoutState.rootNode, action.indexArr);
if (!insertLoc) {
console.error("insertNodeAtIndex unable to find insert location");
return;
}
addChildAt(insertLoc.node, insertLoc.index + 1, action.node);
if (action.magnified) {
layoutState.magnifiedNodeId = action.node.id;
}
layoutState.focusedNodeId = action.node.id;
}
layoutState.generation++;
}
export function swapNode(layoutState: LayoutTreeState, action: LayoutTreeSwapNodeAction) {
// console.log("swapNode", layoutState, action);
if (!action.node1Id || !action.node2Id) {
console.error("invalid swapNode action, both node1 and node2 must be defined");
return;
@ -325,10 +323,10 @@ export function swapNode(layoutState: LayoutTreeState, action: LayoutTreeSwapNod
parentNode1.children[parentNode1Index] = node2;
parentNode2.children[parentNode2Index] = node1;
layoutState.generation++;
}
export function deleteNode(layoutState: LayoutTreeState, action: LayoutTreeDeleteNodeAction) {
// console.log("deleteNode", layoutState, action);
if (!action?.nodeId) {
console.error("no delete node action provided");
return;
@ -339,20 +337,23 @@ export function deleteNode(layoutState: LayoutTreeState, action: LayoutTreeDelet
}
if (layoutState.rootNode.id === action.nodeId) {
layoutState.rootNode = undefined;
return;
}
const parent = findParent(layoutState.rootNode, action.nodeId);
if (parent) {
const node = parent.children.find((child) => child.id === action.nodeId);
removeChild(parent, node);
// console.log("node deleted", parent, node);
} else {
console.error("unable to delete node, not found in tree");
const parent = findParent(layoutState.rootNode, action.nodeId);
if (parent) {
const node = parent.children.find((child) => child.id === action.nodeId);
removeChild(parent, node);
if (layoutState.focusedNodeId === node.id) {
layoutState.focusedNodeId = undefined;
}
} else {
console.error("unable to delete node, not found in tree");
}
}
layoutState.generation++;
}
export function resizeNode(layoutState: LayoutTreeState, action: LayoutTreeResizeNodeAction) {
// console.log("resizeNode", layoutState, action);
if (!action.resizeOperations) {
console.error("invalid resizeNode operation. nodeSizes array must be defined.");
}
@ -364,10 +365,20 @@ export function resizeNode(layoutState: LayoutTreeState, action: LayoutTreeResiz
const node = findNode(layoutState.rootNode, resize.nodeId);
node.size = resize.size;
}
layoutState.generation++;
}
export function focusNode(layoutState: LayoutTreeState, action: LayoutTreeFocusNodeAction) {
if (!action.nodeId) {
console.error("invalid focusNode operation, nodeId must be defined.");
return;
}
layoutState.focusedNodeId = action.nodeId;
layoutState.generation++;
}
export function magnifyNodeToggle(layoutState: LayoutTreeState, action: LayoutTreeMagnifyNodeToggleAction) {
// console.log("magnifyNodeToggle", layoutState, action);
if (!action.nodeId) {
console.error("invalid magnifyNodeToggle operation. nodeId must be defined.");
return;
@ -380,5 +391,7 @@ export function magnifyNodeToggle(layoutState: LayoutTreeState, action: LayoutTr
layoutState.magnifiedNodeId = undefined;
} else {
layoutState.magnifiedNodeId = action.nodeId;
layoutState.focusedNodeId = action.nodeId;
}
layoutState.generation++;
}

View File

@ -1,13 +1,13 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { WritableAtom } from "jotai";
import { Atom, WritableAtom } from "jotai";
import { CSSProperties } from "react";
export enum NavigateDirection {
Top = 0,
Up = 0,
Right = 1,
Bottom = 2,
Down = 2,
Left = 3,
}
@ -67,6 +67,7 @@ export enum LayoutTreeActionType {
InsertNode = "insert",
InsertNodeAtIndex = "insertatindex",
DeleteNode = "delete",
FocusNode = "focus",
MagnifyNodeToggle = "magnify",
}
@ -207,6 +208,18 @@ export interface LayoutTreeResizeNodeAction extends LayoutTreeAction {
resizeOperations: ResizeNodeOperation[];
}
/**
* Action for focusing a node from the layout tree.
*/
export interface LayoutTreeFocusNodeAction extends LayoutTreeAction {
type: LayoutTreeActionType.FocusNode;
/**
* The id of the node to focus;
*/
nodeId: string;
}
/**
* Action for toggling magnification of a node from the layout tree.
*/
@ -234,23 +247,16 @@ export type LayoutTreeStateSetter = (value: LayoutState) => void;
export type LayoutTreeState = {
rootNode: LayoutNode;
focusedNodeId?: string;
magnifiedNodeId?: string;
generation: number;
};
export type WritableLayoutTreeStateAtom = WritableAtom<LayoutTreeState, [value: LayoutTreeState], void>;
export type ContentRenderer = (
data: TabLayoutData,
ready: boolean,
isMagnified: boolean,
disablePointerEvents: boolean,
onMagnifyToggle: () => void,
onClose: () => void,
dragHandleRef: React.RefObject<HTMLDivElement>
) => React.ReactNode;
export type ContentRenderer = (nodeModel: NodeModel) => React.ReactNode;
export type PreviewRenderer = (data: TabLayoutData) => React.ReactElement;
export type PreviewRenderer = (nodeModel: NodeModel) => React.ReactElement;
export const DefaultNodeSize = 10;
@ -307,3 +313,18 @@ export interface LayoutNodeAdditionalProps {
isMagnifiedNode?: boolean;
isLastMagnifiedNode?: boolean;
}
export interface NodeModel {
additionalProps: Atom<LayoutNodeAdditionalProps>;
blockNum: Atom<number>;
nodeId: string;
blockId: string;
isFocused: Atom<boolean>;
isMagnified: Atom<boolean>;
ready: Atom<boolean>;
disablePointerEvents: Atom<boolean>;
toggleMagnify: () => void;
focusNode: () => void;
onClose: () => void;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}

View File

@ -3,7 +3,7 @@
import { CSSProperties } from "react";
import { XYCoord } from "react-dnd";
import { DropDirection, FlexDirection } from "./types";
import { DropDirection, FlexDirection, NavigateDirection } from "./types";
export function reverseFlexDirection(flexDirection: FlexDirection): FlexDirection {
return flexDirection === FlexDirection.Row ? FlexDirection.Column : FlexDirection.Row;
@ -82,3 +82,23 @@ export function setTransform(
position: "absolute",
};
}
export function getCenter(dimensions: Dimensions): Point {
return {
x: dimensions.left + dimensions.width / 2,
y: dimensions.top + dimensions.height / 2,
};
}
export function navigateDirectionToOffset(direction: NavigateDirection): Point {
switch (direction) {
case NavigateDirection.Up:
return { x: 0, y: -1 };
case NavigateDirection.Down:
return { x: 0, y: 1 };
case NavigateDirection.Left:
return { x: -1, y: 0 };
case NavigateDirection.Right:
return { x: 1, y: 0 };
}
}

View File

@ -212,6 +212,7 @@ declare global {
type LayoutState = WaveObj & {
rootnode?: any;
magnifiednodeid?: string;
focusednodeid?: string;
};
// waveobj.MetaTSType
@ -585,8 +586,6 @@ declare global {
type WaveWindow = WaveObj & {
workspaceid: string;
activetabid: string;
activeblockid?: string;
activeblockmap: {[key: string]: string};
pos: Point;
winsize: WinSize;
lastfocusts: number;

View File

@ -126,16 +126,14 @@ func (*Client) GetOType() string {
// stores the ui-context of the window
// workspaceid, active tab, active block within each tab, window size, etc.
type Window struct {
OID string `json:"oid"`
Version int `json:"version"`
WorkspaceId string `json:"workspaceid"`
ActiveTabId string `json:"activetabid"`
ActiveBlockId string `json:"activeblockid,omitempty"`
ActiveBlockMap map[string]string `json:"activeblockmap"` // map from tabid to blockid
Pos Point `json:"pos"`
WinSize WinSize `json:"winsize"`
LastFocusTs int64 `json:"lastfocusts"`
Meta MetaMapType `json:"meta"`
OID string `json:"oid"`
Version int `json:"version"`
WorkspaceId string `json:"workspaceid"`
ActiveTabId string `json:"activetabid"`
Pos Point `json:"pos"`
WinSize WinSize `json:"winsize"`
LastFocusTs int64 `json:"lastfocusts"`
Meta MetaMapType `json:"meta"`
}
func (*Window) GetOType() string {
@ -180,6 +178,7 @@ type LayoutState struct {
Version int `json:"version"`
RootNode any `json:"rootnode,omitempty"`
MagnifiedNodeId string `json:"magnifiednodeid,omitempty"`
FocusedNodeId string `json:"focusednodeid,omitempty"`
Meta MetaMapType `json:"meta,omitempty"`
}

View File

@ -199,9 +199,8 @@ func CreateWindow(ctx context.Context, winSize *waveobj.WinSize) (*waveobj.Windo
}
}
window := &waveobj.Window{
OID: windowId,
WorkspaceId: workspaceId,
ActiveBlockMap: make(map[string]string),
OID: windowId,
WorkspaceId: workspaceId,
Pos: waveobj.Point{
X: 100,
Y: 100,