diff --git a/frontend/app/appkey.ts b/frontend/app/appkey.ts index 7b84a592b..afd92294e 100644 --- a/frontend/app/appkey.ts +++ b/frontend/app/appkey.ts @@ -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(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, 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(WOS.makeORef("tab", tabId)); - const layoutModel = getLayoutModelForTab(tabAtom); - const curBlockId = globalStore.get(atoms.waveWindow)?.activeblockid; - const addlProps = globalStore.get(layoutModel.additionalProps); - const blockPositions: Map = 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(WOS.makeORef("block", win.activeblockid)); + const layoutModel = getLayoutModelForActiveTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + if (focusedNode != null) { + const blockAtom = WOS.getWaveObjectAtom(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")) { diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 96f7d29df..07f06ce7a 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -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(WOS.makeORef("block", blockId)); +const BlockPreview = React.memo(({ nodeModel, viewModel }: FullBlockProps) => { + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); if (!blockData) { return null; } return ( { +const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => { counterInc("render-BlockFull"); const focusElemRef = React.useRef(null); const blockRef = React.useRef(null); const [blockClicked, setBlockClicked] = React.useState(false); - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); const [focusedChild, setFocusedChild] = React.useState(null); - const isFocusedAtom = useBlockAtom(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 (
- {}} /> + {}} + />
Loading...}>{viewElem} @@ -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(WOS.makeORef("block", props.blockId)); - let viewModel = getViewModel(props.blockId); + counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8)); + const [blockData, loading] = WOS.useWaveObjectValue(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) { diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 36e2e3735..3a0bd4de6 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -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, 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: , - title: layoutCompModel?.isMagnified ? "Minimize" : "Magnify", - click: layoutCompModel?.onMagnifyToggle, - }; - return ; -}); +const OptMagnifyButton = React.memo( + ({ magnified, toggleMagnify }: { magnified: boolean; toggleMagnify: () => void }) => { + const magnifyDecl: HeaderIconButton = { + elemtype: "iconbutton", + icon: , + title: magnified ? "Minimize" : "Magnify", + click: toggleMagnify, + }; + return ; + } +); -function computeEndIcons(blockData: Block, viewModel: ViewModel, layoutModel: LayoutComponentModel): JSX.Element[] { +function computeEndIcons( + viewModel: ViewModel, + magnified: boolean, + toggleMagnify: () => void, + onClose: () => void, + onContextMenu: (e: React.MouseEvent) => void +): JSX.Element[] { const endIconsElem: JSX.Element[] = []; const endIconButtons = util.useAtomValueSafe(viewModel.endIconButtons); - if (endIconButtons && endIconButtons.length > 0) { endIconsElem.push(...endIconButtons.map((button, idx) => )); } @@ -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(); - endIconsElem.push(); + endIconsElem.push(); const closeDecl: HeaderIconButton = { elemtype: "iconbutton", icon: "xmark-large", title: "Close", - click: layoutModel?.onClose, + click: onClose, }; endIconsElem.push(); return endIconsElem; } -const BlockFrame_Header = ({ blockId, layoutModel, viewModel }: BlockFrameProps) => { - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); +const BlockFrame_Header = ({ nodeModel, viewModel, preview }: BlockFrameProps) => { + const [blockData] = WOS.useWaveObjectValue(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) => { + 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 ( -
- handleHeaderContextMenu(e, blockData, viewModel, layoutModel?.onMagnifyToggle, layoutModel?.onClose) - } - > +
{preIconButtonElem}
{viewIconElem}
{viewName}
{settingsConfig?.blockheader?.showblockids && ( -
[{blockId.substring(0, 8)}]
+
[{nodeModel.blockId.substring(0, 8)}]
)}
{headerTextElems}
@@ -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 ; } else if (elem.elemtype == "input") { - return ; + return ; } else if (elem.elemtype == "text") { return
{elem.text}
; } else if (elem.elemtype == "textbutton") { @@ -187,7 +203,7 @@ const HeaderTextElem = React.memo(({ elem }: { elem: HeaderElem }) => { onMouseOut={elem.onMouseOut} > {elem.children.map((child, childIdx) => ( - + ))}
); @@ -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 = ; + const renderedElement = ; 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(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(WOS.makeORef("block", blockId)); + const [blockData] = WOS.useWaveObjectValue(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 = (
-
- -
+
{blockNum}
); } @@ -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(WOS.makeORef("block", blockId)); - const isFocusedAtom = useBlockAtom(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(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 =
{viewIconElem}
; return (
- +
{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(WOS.makeORef("block", blockId)); const tabData = jotai.useAtomValue(atoms.tabAtom); diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts index e305632ef..866127b42 100644 --- a/frontend/app/block/blocktypes.ts +++ b/frontend/app/block/blocktypes.ts @@ -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; -} - +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; diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 06c81a741..3c1bdb484 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -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 ( -
- onChange(e)} - onKeyDown={(e) => onKeyDown(e)} - onFocus={(e) => onFocus(e)} - onBlur={(e) => onBlur(e)} - /> -
- ); -}); +export const Input = React.memo( + ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => { + const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl; + return ( +
+ onChange(e)} + onKeyDown={(e) => onKeyDown(e)} + onFocus={(e) => onFocus(e)} + onBlur={(e) => onBlur(e)} + /> +
+ ); + } +); diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 49eb96fce..31cd223c4 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -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; 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 { +async function createBlock(blockDef: BlockDef, magnified = false): Promise { 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, diff --git a/frontend/app/tab/tabcontent.tsx b/frontend/app/tab/tabcontent.tsx index a1449dc5b..c6a3597ea 100644 --- a/frontend/app/tab/tabcontent.tsx +++ b/frontend/app/tab/tabcontent.tsx @@ -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 - ) => { - if (!blockData.blockId || !ready) { - return null; - } - const layoutModel: LayoutComponentModel = { - disablePointerEvents, - onClose, - onMagnifyToggle, - dragHandleRef, - isMagnified, - }; - return ( - - ); + const renderBlock: ContentRenderer = (nodeModel: NodeModel) => { + return ; }; - function renderPreview(tabData: TabLayoutData) { - if (!tabData) return; - return ; - } + const renderPreview: PreviewRenderer = (nodeModel: NodeModel) => { + return ; + }; function onNodeDelete(data: TabLayoutData) { return services.ObjectService.DeleteBlock(data.blockId); diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index bfd6ea22d..4a3117131 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -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(null); model.htmlElemFocusRef = htmlElemFocusRef; const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); - const isFocusedAtom = useBlockAtom(blockId, "isFocused", () => { - return jotai.atom((get) => { - const winData = get(atoms.waveWindow); - return winData?.activeblockid === blockId; - }); - }); const termSettingsAtom = useSettingsAtom("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 ( -
+
{typeAhead[blockId] && ( { if (htmlElemFocusRef.current != null) { htmlElemFocusRef.current.focus(); } - setBlockFocus(blockId); }} >
diff --git a/frontend/layout/index.ts b/frontend/layout/index.ts index e4f4ceb92..09035f1bc 100644 --- a/frontend/layout/index.ts +++ b/frontend/layout/index.ts @@ -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, }; diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index 1ef408e70..36d23e31d 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -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 >
- +
@@ -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 ; + leafs.map((node) => { + return ; }), [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(null); - const dragHandleRef = useRef(null); const previewRef = useRef(null); - const addlProps = useLayoutNode(layoutModel, layoutNode); - const activeDrag = useAtomValue(layoutModel.activeDrag); - const globalReady = useAtomValue(layoutModel.ready); - + const 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)}
); - }, [contents.renderPreview, devicePixelRatio, layoutNode.data]); + }, [devicePixelRatio, nodeModel]); const [previewImage, setPreviewImage] = useState(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 && ( -
- {contents.renderContent( - layoutNode.data, - globalReady, - addlProps?.isMagnifiedNode ?? false, - activeDrag, - () => layoutModel.magnifyNodeToggle(layoutNode), - () => layoutModel.closeNode(layoutNode), - dragHandleRef - )} -
- ) +
+ {layoutModel.renderContent(nodeModel)} +
); - }, [layoutNode, globalReady, activeDrag, addlProps]); + }, [nodeModel]); + + // Register the display node as a draggable item + useEffect(() => { + drag(nodeModel.dragHandleRef); + }, [drag, nodeModel.dragHandleRef.current]); return (
"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 ; + leafs.map((node) => { + return ; }), [leafs] ); @@ -298,34 +274,31 @@ const OverlayNodeWrapper = ({ layoutModel }: OverlayNodeWrapperProps) => { {overlayNodes}
); -}; +}); 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(null); - const generation = useAtomValue(layoutModel.generationAtom); const [, drop] = useDrop( () => ({ accept: dragItemType, canDrop: (_, monitor) => { const dragItem = monitor.getItem(); - 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
; -}; + return
; +}); interface ResizeHandleWrapperProps { layoutModel: LayoutModel; } -const ResizeHandleWrapper = ({ layoutModel }: ResizeHandleWrapperProps) => { +const ResizeHandleWrapper = memo(({ layoutModel }: ResizeHandleWrapperProps) => { const resizeHandles = useAtomValue(layoutModel.resizeHandles) as Atom[]; return resizeHandles.map((resizeHandleAtom, i) => ( )); -}; +}); interface ResizeHandleComponentProps { resizeHandleAtom: Atom; layoutModel: LayoutModel; } -const ResizeHandle = ({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => { +const ResizeHandle = memo(({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => { const resizeHandleProps = useAtomValue(resizeHandleAtom); const resizeHandleRef = useRef(null); @@ -436,7 +409,7 @@ const ResizeHandle = ({ resizeHandleAtom, layoutModel }: ResizeHandleComponentPr
); -}; +}); interface PlaceholderProps { /** diff --git a/frontend/layout/lib/layoutAtom.ts b/frontend/layout/lib/layoutAtom.ts index 5bca48168..a4bd463bf 100644 --- a/frontend/layout/lib/layoutAtom.ts +++ b/frontend/layout/lib/layoutAtom.ts @@ -17,7 +17,6 @@ function getLayoutStateAtomFromTab(tabAtom: Atom, get: Getter): WritableWav export function withLayoutTreeStateAtomFromTab(tabAtom: Atom): 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): 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): 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); } diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index 82a2461da..d491e6a63 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -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; /** - * 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; + leafOrder: Atom; + /** + * A map of node models for currently-active leafs. + */ + private nodeModels: Map; /** * 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; + /** + * The currently focused node. + */ + private focusedNodeIdStack: string[]; + /** + * Atom pointing to the currently focused node. + */ + focusedNode: Atom; /** * The currently magnified node. */ @@ -170,11 +190,6 @@ export class LayoutModel { */ private isContainerResizing: PrimitiveAtom; - /** - * 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; - constructor( treeStateAtom: WritableLayoutTreeStateAtom, getter: Getter, @@ -184,7 +199,6 @@ export class LayoutModel { onNodeDelete?: (data: TabLayoutData) => Promise, 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(null, 10); this.placeholderTransform = atom((get: Getter) => { const pendingAction = get(this.pendingAction.throttledValueAtom); - // console.log("update to pending action", pendingAction); return this.getPlaceholderTransform(pendingAction); }); - this.generationAtom = atom(0); this.updateTreeState(true); } + 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 = 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, 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 { 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]; }); } diff --git a/frontend/layout/lib/layoutModelHooks.ts b/frontend/layout/lib/layoutModelHooks.ts index bd2c236f5..2190ab2f4 100644 --- a/frontend/layout/lib/layoutModelHooks.ts +++ b/frontend/layout/lib/layoutModelHooks.ts @@ -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 = 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, 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); } diff --git a/frontend/layout/lib/layoutTree.ts b/frontend/layout/lib/layoutTree.ts index 5be6aa050..0a69c32ff 100644 --- a/frontend/layout/lib/layoutTree.ts +++ b/frontend/layout/lib/layoutTree.ts @@ -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++; } diff --git a/frontend/layout/lib/types.ts b/frontend/layout/lib/types.ts index 461cc8f4a..b649ddbec 100644 --- a/frontend/layout/lib/types.ts +++ b/frontend/layout/lib/types.ts @@ -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; -export type ContentRenderer = ( - data: TabLayoutData, - ready: boolean, - isMagnified: boolean, - disablePointerEvents: boolean, - onMagnifyToggle: () => void, - onClose: () => void, - dragHandleRef: React.RefObject -) => 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; + blockNum: Atom; + nodeId: string; + blockId: string; + isFocused: Atom; + isMagnified: Atom; + ready: Atom; + disablePointerEvents: Atom; + toggleMagnify: () => void; + focusNode: () => void; + onClose: () => void; + dragHandleRef?: React.RefObject; +} diff --git a/frontend/layout/lib/utils.ts b/frontend/layout/lib/utils.ts index 78b5b7e1e..aae7b6668 100644 --- a/frontend/layout/lib/utils.ts +++ b/frontend/layout/lib/utils.ts @@ -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 }; + } +} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index b5419adfe..3a8086fd0 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -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; diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 08de4dbde..181b971d8 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -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"` } diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index c6b9e0df9..514cec0a5 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -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,