Integrate Faraday layout system (#16)

Co-authored-by: Mike Sawka <sawka@users.noreply.github.com>
Co-authored-by: sawka <mike.sawka@gmail.com>
This commit is contained in:
Evan Simkowitz 2024-06-04 13:05:44 -07:00 committed by GitHub
parent 15527397de
commit c3e71c5c7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2469 additions and 369 deletions

View File

@ -1,23 +1,33 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../lib/**/*.mdx", "../lib/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
stories: ["../frontend/**/*.mdx", "../frontend/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
core: {
builder: "@storybook/builder-vite",
},
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
docs: {},
managerHead: (head) => `
${head}
<meta name="robots" content="noindex" />
`,
typescript: {
reactDocgen: "react-docgen-typescript",
},
};
export default config;

18
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start Storybook",
"type": "shell",
"command": "yarn storybook",
"presentation": {
"reveal": "silent",
"panel": "shared"
},
"runOptions": {
"instanceLimit": 1,
"runOn": "folderOpen"
}
}
]
}

View File

@ -6,6 +6,8 @@ import { atoms, globalStore } from "@/store/global";
import * as jotai from "jotai";
import { Provider } from "jotai";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import "../../public/style.less";
import { CenteredDiv } from "./element/quickelems";
@ -30,8 +32,10 @@ const AppInner = () => {
}
return (
<div className="mainapp">
<div className="titlebar"></div>
<Workspace />
<DndProvider backend={HTML5Backend}>
<div className="titlebar"></div>
<Workspace />
</DndProvider>
</div>
);
};

View File

@ -12,28 +12,17 @@ import * as React from "react";
import "./block.less";
const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
interface BlockProps {
blockId: string;
onClose: () => void;
}
const Block = ({ blockId, onClose }: BlockProps) => {
const blockRef = React.useRef<HTMLDivElement>(null);
const [dims, setDims] = React.useState({ width: 0, height: 0 });
function handleClose() {
WOS.DeleteBlock(blockId);
}
React.useEffect(() => {
if (!blockRef.current) {
return;
}
const rect = blockRef.current.getBoundingClientRect();
const newWidth = Math.floor(rect.width);
const newHeight = Math.floor(rect.height);
if (newWidth !== dims.width || newHeight !== dims.height) {
setDims({ width: newWidth, height: newHeight });
}
}, [blockRef.current]);
let blockElem: JSX.Element = null;
const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
if (!blockId || !blockData) return null;
console.log("blockData: ", blockData);
if (blockDataLoading) {
blockElem = <CenteredDiv>Loading...</CenteredDiv>;
@ -49,11 +38,9 @@ const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
return (
<div className="block" ref={blockRef}>
<div key="header" className="block-header">
<div className="block-header-text text-fixed">
Block [{blockId.substring(0, 8)}] {dims.width}x{dims.height}
</div>
<div className="block-header-text text-fixed">Block [{blockId.substring(0, 8)}]</div>
<div className="flex-spacer" />
<div className="close-button" onClick={() => handleClose()}>
<div className="close-button" onClick={onClose}>
<i className="fa fa-solid fa-xmark-large" />
</div>
</div>

View File

@ -3,6 +3,7 @@
// WaveObjectStore
import { LayoutNode } from "@/faraday/index";
import { Call as $Call, Events } from "@wailsio/runtime";
import * as jotai from "jotai";
import * as React from "react";
@ -131,19 +132,21 @@ function useWaveObjectValueWithSuspense<T>(oref: string): T {
return dataValue.value;
}
function getWaveObjectAtom<T extends WaveObj>(oref: string): jotai.Atom<T> {
function getWaveObjectAtom<T extends WaveObj>(oref: string): jotai.WritableAtom<T, [value: T], void> {
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
wov = createWaveValueObject(oref, true);
waveObjectValueCache.set(oref, wov);
}
return jotai.atom((get) => {
let dataValue = get(wov.dataAtom);
if (dataValue.loading) {
return null;
return jotai.atom(
(get) => {
let dataValue = get(wov.dataAtom);
return dataValue.value;
},
(_get, set, value: T) => {
setObjectValue(value, set, true);
}
return dataValue.value;
});
);
}
function getWaveObjectLoadingAtom<T extends WaveObj>(oref: string): jotai.Atom<boolean> {
@ -177,7 +180,7 @@ function useWaveObjectValue<T>(oref: string): [T, boolean] {
return [atomVal.value, atomVal.loading];
}
function useWaveObject<T extends WaveObj>(oref: string): [T, boolean, (T) => void] {
function useWaveObject<T extends WaveObj>(oref: string): [T, boolean, (val: T) => void] {
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
wov = createWaveValueObject(oref, true);
@ -278,11 +281,13 @@ function wrapObjectServiceCall<T>(fnName: string, ...args: any[]): Promise<T> {
// should provide getFn if it is available (e.g. inside of a jotai atom)
// otherwise it will use the globalStore.get function
function getObjectValue<T>(oref: string, getFn?: jotai.Getter): T {
console.log("getObjectValue", oref);
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
return null;
}
if (getFn == null) {
console.log("getObjectValue", "getFn is null, using globalStore.get");
getFn = globalStore.get;
}
const atomVal = getFn(wov.dataAtom);
@ -299,8 +304,10 @@ function setObjectValue<T>(value: WaveObj, setFn?: jotai.Setter, pushToServer?:
return;
}
if (setFn == null) {
console.log("setter null");
setFn = globalStore.set;
}
console.log("Setting", oref, "to", value);
setFn(wov.dataAtom, { value: value, loading: false });
if (pushToServer) {
UpdateObject(value, false);
@ -319,8 +326,8 @@ export function CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise<{
return wrapObjectServiceCall("CreateBlock", blockDef, rtOpts);
}
export function DeleteBlock(blockId: string): Promise<void> {
return wrapObjectServiceCall("DeleteBlock", blockId);
export function DeleteBlock(blockId: string, newLayout?: LayoutNode<any>): Promise<void> {
return wrapObjectServiceCall("DeleteBlock", blockId, newLayout);
}
export function CloseTab(tabId: string): Promise<void> {
@ -349,4 +356,5 @@ export {
useWaveObject,
useWaveObjectValue,
useWaveObjectValueWithSuspense,
waveObjectValueCache,
};

View File

@ -4,14 +4,41 @@
import { Block } from "@/app/block/block";
import * as WOS from "@/store/wos";
import { TileLayout } from "@/faraday/index";
import { getLayoutStateAtomForTab } from "@/faraday/lib/layoutAtom";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { CenteredDiv, CenteredLoadingDiv } from "../element/quickelems";
import "./tab.less";
const TabContent = ({ tabId }: { tabId: string }) => {
const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
const oref = useMemo(() => WOS.makeORef("tab", tabId), [tabId]);
const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom<Tab>(oref), [oref]);
const tabLoading = useAtomValue(loadingAtom);
const tabAtom = useMemo(() => WOS.getWaveObjectAtom<Tab>(oref), [oref]);
const layoutStateAtom = useMemo(() => getLayoutStateAtomForTab(tabId, tabAtom), [tabAtom, tabId]);
const tabData = useAtomValue(tabAtom);
const renderBlock = useCallback((tabData: TabLayoutData, onClose: () => void) => {
// console.log("renderBlock", tabData);
if (!tabData.blockId) {
return null;
}
return <Block blockId={tabData.blockId} onClose={onClose} />;
}, []);
const onNodeDelete = useCallback(
(data: TabLayoutData) => {
console.log("onNodeDelete", data, tabData);
WOS.DeleteBlock(data.blockId, tabData.layout);
},
[tabData]
);
if (tabLoading) {
return <CenteredLoadingDiv />;
}
if (!tabData) {
return (
<div className="tabcontent">
@ -19,15 +46,15 @@ const TabContent = ({ tabId }: { tabId: string }) => {
</div>
);
}
return (
<div className="tabcontent">
{tabData.blockids.map((blockId: string) => {
return (
<div key={blockId} className="block-container">
<Block key={blockId} tabId={tabId} blockId={blockId} />
</div>
);
})}
<TileLayout
key={tabId}
renderContent={renderBlock}
layoutTreeStateAtom={layoutStateAtom}
onNodeDelete={onNodeDelete}
/>
</div>
);
};

View File

@ -8,6 +8,13 @@ import { clsx } from "clsx";
import * as jotai from "jotai";
import { CenteredDiv } from "../element/quickelems";
import { LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode } from "@/faraday/index";
import {
deleteLayoutStateAtomForTab,
getLayoutStateAtomForTab,
useLayoutTreeStateReducerAtom,
} from "@/faraday/lib/layoutAtom";
import { useCallback, useMemo } from "react";
import "./workspace.less";
function Tab({ tabId }: { tabId: string }) {
@ -18,6 +25,7 @@ function Tab({ tabId }: { tabId: string }) {
}
function handleCloseTab() {
WOS.CloseTab(tabId);
deleteLayoutStateAtomForTab(tabId);
}
return (
<div
@ -54,11 +62,28 @@ function TabBar({ workspace }: { workspace: Workspace }) {
function Widgets() {
const windowData = jotai.useAtomValue(atoms.waveWindow);
const activeTabId = windowData.activetabid;
const activeTabAtom = useMemo(() => {
return WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", windowData.activetabid));
}, [windowData.activetabid]);
const [, dispatchLayoutStateAction] = useLayoutTreeStateReducerAtom(
getLayoutStateAtomForTab(windowData.activetabid, activeTabAtom)
);
const addBlockToTab = useCallback(
(blockId: string) => {
const insertNodeAction: LayoutTreeInsertNodeAction<TabLayoutData> = {
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode<TabLayoutData>(undefined, undefined, undefined, { blockId }),
};
dispatchLayoutStateAction(insertNodeAction);
},
[activeTabAtom]
);
async function createBlock(blockDef: BlockDef) {
const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
await WOS.CreateBlock(blockDef, rtOpts);
const { blockId } = await WOS.CreateBlock(blockDef, rtOpts);
addBlockToTab(blockId);
}
async function clickTerminal() {

38
frontend/faraday/index.ts Normal file
View File

@ -0,0 +1,38 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { TileLayout } from "./lib/TileLayout.jsx";
import { newLayoutTreeStateAtom, useLayoutTreeStateReducerAtom, withLayoutTreeState } from "./lib/layoutAtom.js";
import { newLayoutNode } from "./lib/layoutNode.js";
import type {
LayoutNode,
LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeMoveNodeAction,
LayoutTreeState,
WritableLayoutNodeAtom,
WritableLayoutTreeStateAtom,
} from "./lib/model.js";
import { LayoutTreeActionType } from "./lib/model.js";
export {
LayoutTreeActionType,
TileLayout,
newLayoutNode,
newLayoutTreeStateAtom,
useLayoutTreeStateReducerAtom,
withLayoutTreeState,
};
export type {
LayoutNode,
LayoutTreeCommitPendingAction,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeMoveNodeAction,
LayoutTreeState,
WritableLayoutNodeAtom,
WritableLayoutTreeStateAtom,
};

View File

@ -0,0 +1,111 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import type { Meta, StoryObj } from "@storybook/react";
import { TileLayout } from "./TileLayout.jsx";
import { useState } from "react";
import { newLayoutTreeStateAtom, useLayoutTreeStateReducerAtom } from "./layoutAtom.js";
import { newLayoutNode } from "./layoutNode.js";
import { LayoutTreeActionType, LayoutTreeInsertNodeAction } from "./model.js";
import "./tilelayout.stories.less";
import { FlexDirection } from "./utils.js";
interface TestData {
name: string;
}
const renderTestData = (data: TestData) => <div>{data.name}</div>;
const meta = {
title: "TileLayout",
args: {
layoutTreeStateAtom: newLayoutTreeStateAtom<TestData>(
newLayoutNode(FlexDirection.Row, undefined, undefined, {
name: "Hello world!",
})
),
renderContent: renderTestData,
},
component: TileLayout<TestData>,
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ["autodocs"],
} satisfies Meta<typeof TileLayout<TestData>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
args: {
layoutTreeStateAtom: newLayoutTreeStateAtom<TestData>(
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "Hello world!" })
),
},
};
export const More: Story = {
args: {
layoutTreeStateAtom: newLayoutTreeStateAtom<TestData>(
newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world1!" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world2!" }),
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world3!" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world4!" }),
]),
])
),
},
};
const evenMoreRootNode = newLayoutNode<TestData>(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world1!" }),
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world2!" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world3!" }),
]),
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world4!" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world5!" }),
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world6!" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world7!" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "Hello world8!" }),
]),
]),
]);
export const EvenMore: Story = {
args: {
layoutTreeStateAtom: newLayoutTreeStateAtom<TestData>(evenMoreRootNode),
},
};
const addNodeAtom = newLayoutTreeStateAtom(evenMoreRootNode);
export const AddNode: Story = {
render: () => {
const [, dispatch] = useLayoutTreeStateReducerAtom(addNodeAtom);
const [numAddedNodes, setNumAddedNodes] = useState(0);
const dispatchAddNode = () => {
const newNode = newLayoutNode(FlexDirection.Column, undefined, undefined, {
name: "New Node" + numAddedNodes,
});
const insertNodeAction: LayoutTreeInsertNodeAction<TestData> = {
type: LayoutTreeActionType.InsertNode,
node: newNode,
};
dispatch(insertNodeAction);
setNumAddedNodes(numAddedNodes + 1);
};
return (
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%" }}>
<div>
<button onClick={dispatchAddNode}>Add node</button>
</div>
<TileLayout layoutTreeStateAtom={addNodeAtom} renderContent={renderTestData} />
</div>
);
},
};

View File

@ -0,0 +1,369 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import clsx from "clsx";
import { CSSProperties, RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useDrag, useDragLayer, useDrop } from "react-dnd";
import { useLayoutTreeStateReducerAtom } from "./layoutAtom.js";
import {
ContentRenderer,
LayoutNode,
LayoutTreeAction,
LayoutTreeActionType,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeState,
WritableLayoutTreeStateAtom,
} from "./model.js";
import "./tilelayout.less";
import { setTransform as createTransform, debounce, determineDropDirection } from "./utils.js";
export interface TileLayoutProps<T> {
layoutTreeStateAtom: WritableLayoutTreeStateAtom<T>;
renderContent: ContentRenderer<T>;
onNodeDelete?: (data: T) => void;
className?: string;
}
export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent, onNodeDelete }: TileLayoutProps<T>) => {
const overlayContainerRef = useRef<HTMLDivElement>(null);
const displayContainerRef = useRef<HTMLDivElement>(null);
const [layoutTreeState, dispatch] = useLayoutTreeStateReducerAtom(layoutTreeStateAtom);
const [nodeRefs, setNodeRefs] = useState<Map<string, RefObject<HTMLDivElement>>>(new Map());
useEffect(() => {
console.log("layoutTreeState changed", layoutTreeState);
}, [layoutTreeState]);
const setRef = useCallback(
(id: string, ref: RefObject<HTMLDivElement>) => {
setNodeRefs((prev) => {
prev.set(id, ref);
console.log("setRef", id, ref, prev);
return prev;
});
},
[setNodeRefs]
);
const deleteRef = useCallback(
(id: string) => {
if (nodeRefs.has(id)) {
setNodeRefs((prev) => {
prev.delete(id);
console.log("deleteRef", id, prev);
return prev;
});
} else {
console.log("deleteRef id not found", id);
}
},
[nodeRefs, setNodeRefs]
);
const [overlayTransform, setOverlayTransform] = useState<CSSProperties>();
const [layoutLeafTransforms, setLayoutLeafTransforms] = useState<Record<string, CSSProperties>>({});
const activeDrag = useDragLayer((monitor) => monitor.isDragging());
/**
* Callback to update the transforms on the displayed leafs and move the overlay over the display layer when dragging.
*/
const updateTransforms = useCallback(
debounce(() => {
if (overlayContainerRef.current && displayContainerRef.current) {
const displayBoundingRect = displayContainerRef.current.getBoundingClientRect();
console.log("displayBoundingRect", displayBoundingRect);
const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
const newLayoutLeafTransforms: Record<string, CSSProperties> = {};
console.log(
"nodeRefs",
nodeRefs,
"layoutLeafs",
layoutTreeState.leafs,
"layoutTreeState",
layoutTreeState
);
for (const leaf of layoutTreeState.leafs) {
const leafRef = nodeRefs.get(leaf.id);
if (leafRef?.current) {
const leafBounding = leafRef.current.getBoundingClientRect();
const transform = createTransform({
top: leafBounding.top - overlayBoundingRect.top,
left: leafBounding.left - overlayBoundingRect.left,
width: leafBounding.width,
height: leafBounding.height,
});
newLayoutLeafTransforms[leafRef.current.id] = transform;
} else {
console.warn("missing leaf", leaf.id);
}
}
setLayoutLeafTransforms(newLayoutLeafTransforms);
const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height;
console.log("overlayOffset", newOverlayOffset);
setOverlayTransform(
createTransform(
{
top: activeDrag ? 0 : newOverlayOffset,
left: 0,
width: overlayBoundingRect.width,
height: overlayBoundingRect.height,
},
false
)
);
}
}, 30),
[activeDrag, overlayContainerRef, displayContainerRef, layoutTreeState, nodeRefs]
);
// Update the transforms whenever we drag something and whenever the layout updates.
useLayoutEffect(() => {
updateTransforms();
}, [activeDrag, layoutTreeState]);
// Update the transforms on first render and again whenever the window resizes. I had to do a slightly hacky thing
// because I noticed that the window handler wasn't updating when the callback changed so I remove it each time and
// reattach the new callback.
const [prevUpdateTransforms, setPrevUpdateTransforms] = useState<() => void>(undefined);
useEffect(() => {
if (prevUpdateTransforms) window.removeEventListener("resize", prevUpdateTransforms);
window.addEventListener("resize", updateTransforms);
setPrevUpdateTransforms(updateTransforms);
return () => {
window.removeEventListener("resize", updateTransforms);
};
}, [updateTransforms]);
// Ensure that we don't see any jostling in the layout when we're rendering it the first time.
// `animate` will be disabled until after the transforms have all applied the first time.
// `overlayVisible` will be disabled until after the overlay has been pushed out of view.
const [animate, setAnimate] = useState(false);
const [overlayVisible, setOverlayVisible] = useState(false);
useEffect(() => {
setTimeout(() => {
setAnimate(true);
}, 50);
setTimeout(() => {
setOverlayVisible(true);
}, 30);
}, []);
const onLeafClose = useCallback(
(node: LayoutNode<T>) => {
console.log("onLeafClose", node);
const deleteAction: LayoutTreeDeleteNodeAction = {
type: LayoutTreeActionType.DeleteNode,
nodeId: node.id,
};
console.log("calling dispatch", deleteAction);
dispatch(deleteAction);
console.log("calling onNodeDelete", node);
onNodeDelete?.(node.data);
console.log("node deleted");
},
[onNodeDelete, dispatch]
);
return (
<div className={clsx("tile-layout", className, { animate, overlayVisible })}>
<div key="display" ref={displayContainerRef} className="display-container">
{layoutLeafTransforms &&
layoutTreeState.leafs.map((leaf) => {
return (
<TileNode
key={leaf.id}
layoutNode={leaf}
renderContent={renderContent}
transform={layoutLeafTransforms[leaf.id]}
onLeafClose={onLeafClose}
/>
);
})}
</div>
<div
key="overlay"
ref={overlayContainerRef}
className="overlay-container"
style={{ top: 10000, ...overlayTransform }}
>
<OverlayNode
layoutNode={layoutTreeState.rootNode}
layoutTreeState={layoutTreeState}
dispatch={dispatch}
setRef={setRef}
deleteRef={deleteRef}
/>
</div>
</div>
);
};
interface TileNodeProps<T> {
layoutNode: LayoutNode<T>;
renderContent: ContentRenderer<T>;
onLeafClose: (node: LayoutNode<T>) => void;
transform: CSSProperties;
}
const dragItemType = "TILE_ITEM";
const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose }: TileNodeProps<T>) => {
const tileNodeRef = useRef<HTMLDivElement>(null);
const [{ isDragging, dragItem }, drag, dragPreview] = useDrag(
() => ({
type: dragItemType,
item: () => layoutNode,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
dragItem: monitor.getItem<LayoutNode<T>>(),
}),
}),
[layoutNode]
);
useEffect(() => {
if (isDragging) {
console.log("drag start", layoutNode.id, layoutNode, dragItem);
}
}, [isDragging]);
// Register the tile item as a draggable component
useEffect(() => {
console.log("set drag", layoutNode);
drag(tileNodeRef);
dragPreview(tileNodeRef);
}, [tileNodeRef]);
const onClose = useCallback(() => {
onLeafClose(layoutNode);
}, [layoutNode, onLeafClose]);
return (
<div
className="tile-node"
ref={tileNodeRef}
id={layoutNode.id}
style={{
flexDirection: layoutNode.flexDirection,
flexBasis: layoutNode.size,
...transform,
}}
>
{layoutNode.data && (
<div key="leaf" className="tile-leaf">
{renderContent(layoutNode.data, onClose)}
</div>
)}
</div>
);
};
interface OverlayNodeProps<T> {
layoutNode: LayoutNode<T>;
layoutTreeState: LayoutTreeState<T>;
dispatch: (action: LayoutTreeAction) => void;
setRef: (id: string, ref: RefObject<HTMLDivElement>) => void;
deleteRef: (id: string) => void;
}
const OverlayNode = <T,>({ layoutNode, layoutTreeState, dispatch, setRef, deleteRef }: OverlayNodeProps<T>) => {
const overlayRef = useRef<HTMLDivElement>(null);
const leafRef = useRef<HTMLDivElement>(null);
const [, drop] = useDrop(
() => ({
accept: dragItemType,
canDrop: (_, monitor) => {
const dragItem = monitor.getItem<LayoutNode<T>>();
if (monitor.isOver({ shallow: true }) && dragItem?.id !== layoutNode.id) {
return true;
}
return false;
},
drop: (_, monitor) => {
console.log("drop start", layoutNode.id, layoutTreeState.pendingAction);
if (!monitor.didDrop() && layoutTreeState.pendingAction) {
dispatch({
type: LayoutTreeActionType.CommitPendingAction,
});
}
},
hover: (_, monitor) => {
if (monitor.isOver({ shallow: true }) && monitor.canDrop()) {
const dragItem = monitor.getItem<LayoutNode<T>>();
console.log("computing operation", layoutNode, dragItem, layoutTreeState.pendingAction);
dispatch({
type: LayoutTreeActionType.ComputeMove,
node: layoutNode,
nodeToMove: dragItem,
direction: determineDropDirection(
overlayRef.current?.getBoundingClientRect(),
monitor.getClientOffset()
),
} as LayoutTreeComputeMoveNodeAction<T>);
}
},
}),
[overlayRef.current, layoutNode, layoutTreeState, dispatch]
);
// Register the tile item as a draggable component
useEffect(() => {
const layoutNodeId = layoutNode?.id;
if (overlayRef?.current) {
drop(overlayRef);
setRef(layoutNodeId, overlayRef);
}
return () => {
deleteRef(layoutNodeId);
};
}, [overlayRef]);
const generateChildren = () => {
if (Array.isArray(layoutNode.children)) {
return layoutNode.children.map((childItem) => {
return (
<OverlayNode
key={childItem.id}
layoutNode={childItem}
layoutTreeState={layoutTreeState}
dispatch={dispatch}
setRef={setRef}
deleteRef={deleteRef}
/>
);
});
} else {
return [<div ref={leafRef} key="leaf" className="overlay-leaf"></div>];
}
};
if (!layoutNode) {
return null;
}
return (
<div
ref={overlayRef}
className="overlay-node"
id={layoutNode.id}
style={{
flexBasis: layoutNode.size,
flexDirection: layoutNode.flexDirection,
}}
>
{generateChildren()}
</div>
);
};

View File

@ -0,0 +1,95 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { PrimitiveAtom, WritableAtom, atom, useAtom } from "jotai";
import { useCallback } from "react";
import { layoutTreeStateReducer, newLayoutTreeState } from "./layoutState.js";
import {
LayoutNode,
LayoutTreeAction,
LayoutTreeState,
WritableLayoutNodeAtom,
WritableLayoutTreeStateAtom,
} from "./model.js";
/**
* Creates a new layout tree state wrapped as an atom.
* @param rootNode The root node for the tree.
* @returns The state wrapped as an atom.
*
* @template T The type of data associated with the nodes of the tree.
*/
export function newLayoutTreeStateAtom<T>(rootNode: LayoutNode<T>): PrimitiveAtom<LayoutTreeState<T>> {
return atom(newLayoutTreeState(rootNode)) as PrimitiveAtom<LayoutTreeState<T>>;
}
/**
* Derives a WritableLayoutTreeStateAtom from a WritableLayoutNodeAtom, initializing the tree state.
* @param layoutNodeAtom The atom containing the root node for the LayoutTreeState.
* @returns The derived WritableLayoutTreeStateAtom.
*/
export function withLayoutTreeState<T>(layoutNodeAtom: WritableLayoutNodeAtom<T>): WritableLayoutTreeStateAtom<T> {
return atom(
(get) => newLayoutTreeState(get(layoutNodeAtom)),
(_get, set, value) => set(layoutNodeAtom, value.rootNode)
);
}
export function withLayoutStateFromTab(
tabAtom: WritableAtom<Tab, [value: Tab], void>
): WritableLayoutTreeStateAtom<TabLayoutData> {
return atom(
(get) => {
const tabData = get(tabAtom);
console.log("get layout state from tab", tabData);
return newLayoutTreeState(tabData?.layout);
},
(get, set, value) => {
const tabValue = get(tabAtom);
const newTabValue = { ...tabValue };
newTabValue.layout = value.rootNode;
console.log("set tab", tabValue, value);
set(tabAtom, newTabValue);
}
);
}
/**
* Hook to subscribe to the tree state and dispatch actions to its reducer functon.
* @param layoutTreeStateAtom The atom holding the layout tree state.
* @returns The current state of the tree and the dispatch function.
*/
export function useLayoutTreeStateReducerAtom<T>(
layoutTreeStateAtom: WritableLayoutTreeStateAtom<T>
): readonly [LayoutTreeState<T>, (action: LayoutTreeAction) => void] {
const [state, setState] = useAtom(layoutTreeStateAtom);
const dispatch = useCallback(
(action: LayoutTreeAction) => setState(layoutTreeStateReducer(state, action)),
[state, setState]
);
return [state, dispatch];
}
const tabLayoutAtomCache = new Map<string, WritableLayoutTreeStateAtom<TabLayoutData>>();
export function getLayoutStateAtomForTab(
tabId: string,
tabAtom: WritableAtom<Tab, [value: Tab], void>
): WritableLayoutTreeStateAtom<TabLayoutData> {
let atom = tabLayoutAtomCache.get(tabId);
if (atom) {
console.log("Reusing atom for tab", tabId);
return atom;
}
console.log("Creating new atom for tab", tabId);
atom = withLayoutStateFromTab(tabAtom);
tabLayoutAtomCache.set(tabId, atom);
return atom;
}
export function deleteLayoutStateAtomForTab(tabId: string) {
const atom = tabLayoutAtomCache.get(tabId);
if (atom) {
tabLayoutAtomCache.delete(tabId);
}
}

View File

@ -0,0 +1,245 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { LayoutNode } from "./model.js";
import { FlexDirection, getCrypto, reverseFlexDirection } from "./utils.js";
const crypto = getCrypto();
/**
* Creates a new node.
* @param flexDirection The flex direction for the new node.
* @param size The size for the new node.
* @param children The children for the new node.
* @param data The data for the new node.
* @template T The type of data associated with the node.
* @returns The new node.
*/
export function newLayoutNode<T>(
flexDirection?: FlexDirection,
size?: number,
children?: LayoutNode<T>[],
data?: T
): LayoutNode<T> {
const newNode: LayoutNode<T> = {
id: crypto.randomUUID(),
flexDirection: flexDirection ?? FlexDirection.Column,
size,
children,
data,
};
if (!validateNode(newNode)) {
throw new Error("Invalid node");
}
return newNode;
}
/**
* Adds new nodes to the tree at the given index.
* @param node The parent node.
* @param idx The index to insert at.
* @param children The nodes to insert.
* @template T The type of data associated with the node.
* @returns The updated parent node.
*/
export function addChildAt<T>(node: LayoutNode<T>, idx: number, ...children: LayoutNode<T>[]) {
console.log("adding", children, "to", node, "at index", idx);
if (children.length === 0) return;
if (!node.children) {
addIntermediateNode(node);
}
const childrenToAdd = children.flatMap((v) => {
if (v.flexDirection !== node.flexDirection) {
return v;
} else if (v.children) {
return v.children;
} else {
v.flexDirection = reverseFlexDirection(node.flexDirection);
return v;
}
});
if (node.children.length <= idx) {
node.children.push(...childrenToAdd);
} else if (idx >= 0) {
node.children.splice(idx, 0, ...childrenToAdd);
}
}
/**
* Adds an intermediate node as a direct child of the given node, moving the given node's children or data into it.
*
* If the node contains children, they are moved two levels deeper to preserve their flex direction. If the node only has data, it is moved one level deeper.
* @param node The node to add the intermediate node to.
* @template T The type of data associated with the node.
* @returns The updated node and the node that was added.
*/
export function addIntermediateNode<T>(node: LayoutNode<T>): LayoutNode<T> {
let intermediateNode: LayoutNode<T>;
console.log(node);
if (node.data) {
intermediateNode = newLayoutNode(reverseFlexDirection(node.flexDirection), undefined, undefined, node.data);
node.children = [intermediateNode];
node.data = undefined;
} else {
const intermediateNodeInner = newLayoutNode(node.flexDirection, undefined, node.children);
intermediateNode = newLayoutNode(reverseFlexDirection(node.flexDirection), undefined, [intermediateNodeInner]);
node.children = [intermediateNode];
}
const intermediateNodeId = intermediateNode.id;
intermediateNode.id = node.id;
node.id = intermediateNodeId;
return intermediateNode;
}
/**
* Attempts to remove the specified node from its parent.
* @param parent The parent node.
* @param childToRemove The node to remove.
* @template T The type of data associated with the node.
* @returns The updated parent node, or undefined if the node was not found.
*/
export function removeChild<T>(parent: LayoutNode<T>, childToRemove: LayoutNode<T>) {
if (!parent.children) return;
const idx = parent.children.indexOf(childToRemove);
if (idx === -1) return;
parent.children?.splice(idx, 1);
}
/**
* Finds the node with the given id.
* @param node The node to search in.
* @param id The id to search for.
* @template T The type of data associated with the node.
* @returns The node with the given id or undefined if no node with the given id was found.
*/
export function findNode<T>(node: LayoutNode<T>, id: string): LayoutNode<T> | undefined {
if (node.id === id) return node;
if (!node.children) return;
for (const child of node.children) {
const result = findNode(child, id);
if (result) return result;
}
return;
}
/**
* Finds the node whose children contains the node with the given id.
* @param node The node to start the search from.
* @param id The id to search for.
* @template T The type of data associated with the node.
* @returns The parent node, or undefined if no node with the given id was found.
*/
export function findParent<T>(node: LayoutNode<T>, id: string): LayoutNode<T> | undefined {
if (node.id === id || !node.children) return;
for (const child of node.children) {
if (child.id === id) return node;
const retVal = findParent(child, id);
if (retVal) return retVal;
}
return;
}
/**
* Determines whether a node is valid.
* @param node The node to validate.
* @template T The type of data associated with the node.
* @returns True if the node is valid, false otherwise.
*/
export function validateNode<T>(node: LayoutNode<T>): boolean {
if (!node.children == !node.data) {
console.error("Either children or data must be defined for node, not both");
return false;
}
if (node.children?.length === 0) {
console.error("Node cannot define an empty array of children");
return false;
}
return true;
}
/**
* Recursively corrects the tree to minimize nested single-child nodes, remove invalid nodes, and correct invalid flex direction order.
* Also finds all leaf nodes under the specified node.
* @param node The node to start the balancing from.
* @template T The type of data associated with the node.
* @returns The corrected node and an array of leaf nodes.
*/
export function balanceNode<T>(node: LayoutNode<T>): { node: LayoutNode<T>; leafs: LayoutNode<T>[] } | undefined {
const leafs: LayoutNode<T>[] = [];
const newNode = balanceNodeHelper(node, leafs);
return { node: newNode, leafs };
}
function balanceNodeHelper<T>(node: LayoutNode<T>, leafs: LayoutNode<T>[]): LayoutNode<T> {
if (!node) return;
if (!node.children) {
leafs.push(node);
return node;
}
if (node.children.length === 0) return;
if (!validateNode(node)) throw new Error("Invalid node");
node.children = node.children
.flatMap((child) => {
if (child.flexDirection === node.flexDirection) {
child.flexDirection = reverseFlexDirection(node.flexDirection);
}
if (child.children?.length === 1 && child.children[0].children) {
return child.children[0].children;
}
return child;
})
.map((child) => {
return balanceNodeHelper(child, leafs);
})
.filter((v) => v);
if (node.children.length === 1 && !node.children[0].children) {
node.data = node.children[0].data;
node.id = node.children[0].id;
node.children = undefined;
}
return node;
}
/**
* Finds the first node in the tree where a new node can be inserted.
*
* This will attempt to fill each node until it has maxChildren children. If a node is full, it will move to its children and
* fill each of them until it has maxChildren children. It will ensure that each child fills evenly before moving to the next
* layer down.
*
* @param node The node to start the search from.
* @param maxChildren The maximum number of children a node can have.
* @returns The node to insert into and the index at which to insert.
*/
export function findNextInsertLocation<T>(
node: LayoutNode<T>,
maxChildren: number
): { node: LayoutNode<T>; index: number } {
const insertLoc = findNextInsertLocationHelper(node, maxChildren, 1);
return { node: insertLoc?.node, index: insertLoc?.index };
}
function findNextInsertLocationHelper<T>(
node: LayoutNode<T>,
maxChildren: number,
curDepth: number = 1
): { node: LayoutNode<T>; index: number; depth: number } {
if (!node) return;
if (!node.children) return { node, index: 1, depth: curDepth };
let insertLocs: { node: LayoutNode<T>; index: number; depth: number }[] = [];
if (node.children.length < maxChildren) {
insertLocs.push({ node, index: node.children.length, depth: curDepth });
}
for (const child of node.children.slice().reverse()) {
insertLocs.push(findNextInsertLocationHelper(child, maxChildren, curDepth + 1));
}
insertLocs = insertLocs
.filter((a) => a)
.sort((a, b) => Math.pow(a.depth, a.index + maxChildren) - Math.pow(b.depth, b.index + maxChildren));
return insertLocs[0];
}

View File

@ -0,0 +1,273 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import {
addChildAt,
addIntermediateNode,
balanceNode,
findNextInsertLocation,
findNode,
findParent,
removeChild,
} from "./layoutNode.js";
import {
LayoutNode,
LayoutTreeAction,
LayoutTreeActionType,
LayoutTreeComputeMoveNodeAction,
LayoutTreeDeleteNodeAction,
LayoutTreeInsertNodeAction,
LayoutTreeMoveNodeAction,
LayoutTreeState,
MoveOperation,
} from "./model.js";
import { DropDirection, FlexDirection, lazy } from "./utils.js";
/**
* Initializes a layout tree state.
* @param rootNode The root node for the tree.
* @returns The state of the tree.
*
* @template T The type of data associated with the nodes of the tree.
*/
export function newLayoutTreeState<T>(rootNode: LayoutNode<T>): LayoutTreeState<T> {
const { node: balancedRootNode, leafs } = balanceNode(rootNode);
return {
rootNode: balancedRootNode,
leafs,
pendingAction: undefined,
};
}
/**
* Performs a specified action on the layout tree state. Uses Immer Produce internally to resolve deep changes to the tree.
*
* @param layoutTreeState The state of the tree.
* @param action The action to perform.
*
* @template T The type of data associated with the nodes of the tree.
* @returns The new state of the tree.
*/
export function layoutTreeStateReducer<T>(
layoutTreeState: LayoutTreeState<T>,
action: LayoutTreeAction
): LayoutTreeState<T> {
layoutTreeStateReducerInner(layoutTreeState, action);
return layoutTreeState;
}
/**
* Helper function for layoutTreeStateReducer.
* @param layoutTreeState The state of the tree.
* @param action The action to perform.
* @see layoutTreeStateReducer
* @template T The type of data associated with the nodes of the tree.
*/
function layoutTreeStateReducerInner<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeAction) {
switch (action.type) {
case LayoutTreeActionType.ComputeMove:
computeMoveNode(layoutTreeState, action as LayoutTreeComputeMoveNodeAction<T>);
break;
case LayoutTreeActionType.CommitPendingAction:
if (!layoutTreeState?.pendingAction) {
console.error("unable to commit pending action, does not exist");
break;
}
layoutTreeStateReducerInner(layoutTreeState, layoutTreeState.pendingAction);
break;
case LayoutTreeActionType.Move:
moveNode(layoutTreeState, action as LayoutTreeMoveNodeAction<T>);
break;
case LayoutTreeActionType.InsertNode:
insertNode(layoutTreeState, action as LayoutTreeInsertNodeAction<T>);
break;
case LayoutTreeActionType.DeleteNode:
deleteNode(layoutTreeState, action as LayoutTreeDeleteNodeAction);
break;
default: {
console.error("Invalid reducer action", layoutTreeState, action);
}
}
}
/**
* Computes an operation for inserting a new node into the tree in the given direction relative to the specified node.
*
* @param layoutTreeState The state of the tree.
* @param computeInsertAction The operation to compute.
*
* @template T The type of data associated with the nodes of the tree.
*/
function computeMoveNode<T>(
layoutTreeState: LayoutTreeState<T>,
computeInsertAction: LayoutTreeComputeMoveNodeAction<T>
) {
const rootNode = layoutTreeState.rootNode;
const { node, nodeToMove, direction } = computeInsertAction;
console.log("computeInsertOperation start", layoutTreeState.rootNode, node, nodeToMove, direction);
if (direction === undefined) {
console.warn("No direction provided for insertItemInDirection");
return;
}
let newOperation: MoveOperation<T>;
const parent = lazy(() => findParent(rootNode, node.id));
const indexInParent = lazy(() => parent()?.children.findIndex((child) => node.id === child.id));
const isRoot = rootNode.id === node.id;
switch (direction) {
case DropDirection.Top:
if (node.flexDirection === FlexDirection.Column) {
newOperation = { parentId: node.id, index: 0, node: nodeToMove };
} else {
if (isRoot)
newOperation = {
node: nodeToMove,
index: 0,
insertAtRoot: true,
};
const parentNode = parent();
if (parentNode)
newOperation = {
parentId: parentNode.id,
index: indexInParent() ?? 0,
node: nodeToMove,
};
}
break;
case DropDirection.Bottom:
if (node.flexDirection === FlexDirection.Column) {
newOperation = { parentId: node.id, index: 1, node: nodeToMove };
} else {
if (isRoot)
newOperation = {
node: nodeToMove,
index: 1,
insertAtRoot: true,
};
const parentNode = parent();
if (parentNode)
newOperation = {
parentId: parentNode.id,
index: indexInParent() + 1,
node: nodeToMove,
};
}
break;
case DropDirection.Left:
if (node.flexDirection === FlexDirection.Row) {
newOperation = { parentId: node.id, index: 0, node: nodeToMove };
} else {
const parentNode = parent();
if (parentNode)
newOperation = {
parentId: parentNode.id,
index: indexInParent(),
node: nodeToMove,
};
}
break;
case DropDirection.Right:
if (node.flexDirection === FlexDirection.Row) {
newOperation = { parentId: node.id, index: 1, node: nodeToMove };
} else {
const parentNode = parent();
if (parentNode)
newOperation = {
parentId: parentNode.id,
index: indexInParent() + 1,
node: nodeToMove,
};
}
break;
default:
throw new Error("Invalid direction");
}
if (newOperation) layoutTreeState.pendingAction = { type: LayoutTreeActionType.Move, ...newOperation };
}
function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMoveNodeAction<T>) {
const rootNode = layoutTreeState.rootNode;
console.log("moveNode", action, layoutTreeState.rootNode);
if (!action) {
console.error("no move node action provided");
return;
}
if (action.parentId && action.insertAtRoot) {
console.error("parent and insertAtRoot cannot both be defined in a move node action");
return;
}
let node = findNode(rootNode, action.node.id) ?? action.node;
let parent = findNode(rootNode, action.parentId);
let oldParent = findParent(rootNode, action.node.id);
console.log(node, parent, oldParent);
// Remove nodeToInsert from its old parent
if (oldParent) {
removeChild(oldParent, node);
}
if (!parent && action.insertAtRoot) {
addIntermediateNode(rootNode);
addChildAt(rootNode, action.index, node);
} else if (parent) {
addChildAt(parent, action.index, node);
} else {
throw new Error("Invalid InsertOperation");
}
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
layoutTreeState.rootNode = newRootNode;
layoutTreeState.leafs = leafs;
layoutTreeState.pendingAction = undefined;
}
function insertNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeInsertNodeAction<T>) {
if (!action?.node) {
console.error("no insert node action provided");
return;
}
if (!layoutTreeState.rootNode) {
const { node: balancedNode, leafs } = balanceNode(action.node);
layoutTreeState.rootNode = balancedNode;
layoutTreeState.leafs = leafs;
return;
}
const insertLoc = findNextInsertLocation(layoutTreeState.rootNode, 5);
addChildAt(insertLoc.node, insertLoc.index, action.node);
const { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
layoutTreeState.rootNode = newRootNode;
layoutTreeState.leafs = leafs;
}
function deleteNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeDeleteNodeAction) {
console.log("deleteNode", layoutTreeState, action);
if (!action?.nodeId) {
console.error("no delete node action provided");
return;
}
if (!layoutTreeState.rootNode) {
console.error("no root node");
return;
}
if (layoutTreeState.rootNode.id === action.nodeId) {
layoutTreeState.rootNode = undefined;
layoutTreeState.leafs = undefined;
return;
}
const parent = findParent(layoutTreeState.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 { node: newRootNode, leafs } = balanceNode(layoutTreeState.rootNode);
layoutTreeState.rootNode = newRootNode;
layoutTreeState.leafs = leafs;
}

View File

@ -0,0 +1,133 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { WritableAtom } from "jotai";
import { DropDirection, FlexDirection } from "./utils.js";
/**
* Represents an operation to insert a node into a tree.
*/
export type MoveOperation<T> = {
/**
* The index at which the node will be inserted in the parent.
*/
index: number;
/**
* The parent node. Undefined if inserting at root.
*/
parentId?: string;
/**
* Whether the node will be inserted at the root of the tree.
*/
insertAtRoot?: boolean;
/**
* The node to insert.
*/
node: LayoutNode<T>;
};
/**
* Types of actions that modify the layout tree.
*/
export enum LayoutTreeActionType {
ComputeMove = "computeMove",
Move = "move",
CommitPendingAction = "commit",
ResizeNode = "resize",
InsertNode = "insert",
DeleteNode = "delete",
}
/**
* Base class for actions that modify the layout tree.
*/
export interface LayoutTreeAction {
type: LayoutTreeActionType;
}
/**
* Action for computing a move operation and saving it as a pending action in the tree state.
*
* @template T The type of data associated with the nodes of the tree.
* @see MoveOperation
* @see LayoutTreeMoveNodeAction
*/
export interface LayoutTreeComputeMoveNodeAction<T> extends LayoutTreeAction {
type: LayoutTreeActionType.ComputeMove;
node: LayoutNode<T>;
nodeToMove: LayoutNode<T>;
direction: DropDirection;
}
/**
* Action for moving a node within the layout tree.
*
* @template T The type of data associated with the nodes of the tree.
* @see MoveOperation
*/
export interface LayoutTreeMoveNodeAction<T> extends LayoutTreeAction, MoveOperation<T> {
type: LayoutTreeActionType.Move;
}
/**
* Action for committing a pending action to the layout tree.
*/
export interface LayoutTreeCommitPendingAction extends LayoutTreeAction {
type: LayoutTreeActionType.CommitPendingAction;
}
/**
* Action for inserting a new node to the layout tree.
*
* @template T The type of data associated with the nodes of the tree.
*/
export interface LayoutTreeInsertNodeAction<T> extends LayoutTreeAction {
type: LayoutTreeActionType.InsertNode;
node: LayoutNode<T>;
}
/**
* Action for deleting a node from the layout tree.
*/
export interface LayoutTreeDeleteNodeAction extends LayoutTreeAction {
type: LayoutTreeActionType.DeleteNode;
nodeId: string;
}
/**
* Represents the state of a layout tree.
*
* @template T The type of data associated with the nodes of the tree.
*/
export type LayoutTreeState<T> = {
rootNode: LayoutNode<T>;
leafs: LayoutNode<T>[];
pendingAction: LayoutTreeAction;
};
/**
* Represents a single node in the layout tree.
* @template T The type of data associated with the node.
*/
export interface LayoutNode<T> {
id: string;
data?: T;
children?: LayoutNode<T>[];
flexDirection: FlexDirection;
size?: number;
}
/**
* An abstraction of the type definition for a writable layout node atom.
*/
export type WritableLayoutNodeAtom<T> = WritableAtom<LayoutNode<T>, [value: LayoutNode<T>], void>;
/**
* An abstraction of the type definition for a writable layout tree state atom.
*/
export type WritableLayoutTreeStateAtom<T> = WritableAtom<LayoutTreeState<T>, [value: LayoutTreeState<T>], void>;
export type ContentRenderer<T> = (data: T, onClose?: () => void) => React.ReactNode;

View File

@ -0,0 +1,93 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.tile-layout {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
.overlay-container,
.display-container,
.placeholder-container {
position: absolute;
display: flex;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.placeholder-container {
z-index: 1;
}
.overlay-container {
z-index: 2;
background-color: coral;
opacity: 0.5;
}
.tile-node,
.overlay-node {
display: flex;
flex: 1 1 auto;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
&.is-dragging {
background-color: rgba(0, 255, 0, 0.3);
}
}
.tile-node {
visibility: hidden;
}
&.animate {
.tile-node {
transition-duration: 0.15s;
transition-timing-function: ease-in;
transition-property: transform, width, height;
}
}
&.overlayVisible {
.tile-node {
visibility: unset;
}
}
.tile-leaf,
.overlay-leaf {
display: flex;
flex: 1 1 auto;
max-height: 100%;
max-width: 100%;
margin: 5px;
}
.tile-leaf {
border: 1px solid black;
}
.overlay-leaf {
border: 10px solid red;
}
// .tile-leaf {
// border: 1px solid black;
// }
// .overlay-leaf {
// margin: 1px;
// }
.placeholder {
display: flex;
flex: 1 1 auto;
background-color: aqua;
}
}

View File

@ -0,0 +1,9 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.placeholder-visible {
.overlay-container,
.placeholder-container {
transform: unset !important;
}
}

View File

@ -0,0 +1,116 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { CSSProperties } from "react";
import { XYCoord } from "react-dnd";
export interface Dimensions {
width: number;
height: number;
left: number;
top: number;
}
export enum DropDirection {
Top = 0,
Right = 1,
Bottom = 2,
Left = 3,
}
export enum FlexDirection {
Row = "row",
Column = "column",
}
export function reverseFlexDirection(flexDirection: FlexDirection): FlexDirection {
return flexDirection === FlexDirection.Row ? FlexDirection.Column : FlexDirection.Row;
}
export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord | null): DropDirection | undefined {
console.log("determineDropDirection", dimensions, offset);
if (!offset || !dimensions) return undefined;
const { width, height, left, top } = dimensions;
let { x, y } = offset;
x -= left;
y -= top;
// Lies outside of the box
if (y < 0 || y > height || x < 0 || x > width) return undefined;
const diagonal1 = y * width - x * height;
const diagonal2 = y * width + x * height - height * width;
// Lies on diagonal
if (diagonal1 == 0 || diagonal2 == 0) return undefined;
let code = 0;
if (diagonal2 > 0) {
code += 1;
}
if (diagonal1 > 0) {
code += 2;
code = 5 - code;
}
return code;
}
export function setTransform({ top, left, width, height }: Dimensions, setSize: boolean = true): CSSProperties {
// Replace unitless items with px
const translate = `translate(${left}px,${top}px)`;
return {
top: 0,
left: 0,
transform: translate,
WebkitTransform: translate,
MozTransform: translate,
msTransform: translate,
OTransform: translate,
width: setSize ? `${width}px` : undefined,
height: setSize ? `${height}px` : undefined,
position: "absolute",
};
}
export const debounce = <T extends (...args: any[]) => any>(callback: T, waitFor: number) => {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>): ReturnType<T> => {
let result: any;
clearTimeout(timeout);
timeout = setTimeout(() => {
result = callback(...args);
}, waitFor);
return result;
};
};
/**
* Simple wrapper function that lazily evaluates the provided function and caches its result for future calls.
* @param callback The function to lazily run.
* @returns The result of the function.
*/
export const lazy = <T extends (...args: any[]) => any>(callback: T) => {
let res: ReturnType<T>;
let processed = false;
return (...args: Parameters<T>): ReturnType<T> => {
if (processed) return res;
res = callback(...args);
processed = true;
return res;
};
};
/**
* Workaround for NodeJS compatibility. Will attempt to resolve the Crypto API from the browser and fallback to NodeJS if it isn't present.
* @returns The Crypto API.
*/
export function getCrypto() {
try {
return window.crypto;
} catch {
return crypto;
}
}

View File

@ -0,0 +1,304 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { assert, test } from "vitest";
import {
addChildAt,
addIntermediateNode,
balanceNode,
findNextInsertLocation,
newLayoutNode,
} from "../lib/layoutNode.js";
import { LayoutNode } from "../lib/model.js";
import { FlexDirection } from "../lib/utils.js";
import { TestData } from "./model.js";
test("newLayoutNode", () => {
assert.throws(
() => newLayoutNode<TestData>(FlexDirection.Column),
"Invalid node",
undefined,
"calls to the constructor without data or children should fail"
);
assert.throws(
() => newLayoutNode<TestData>(FlexDirection.Column, undefined, [], { name: "hello" }),
"Invalid node",
undefined,
"calls to the constructor with both data and children should fail"
);
assert.doesNotThrow(
() => newLayoutNode<TestData>(FlexDirection.Column, undefined, undefined, { name: "hello" }),
"Invalid node",
undefined,
"calls to the constructor with only data defined should succeed"
);
assert.throws(() => newLayoutNode<TestData>(FlexDirection.Column, undefined, [], undefined)),
"Invalid node",
undefined,
"calls to the constructor with empty children array should fail";
assert.doesNotThrow(() =>
newLayoutNode<TestData>(
FlexDirection.Column,
undefined,
[newLayoutNode<TestData>(FlexDirection.Column, undefined, undefined, { name: "hello" })],
undefined
)
),
"Invalid node",
undefined,
"calls to the constructor with children array containing at least one child should succeed";
});
test("addIntermediateNode", () => {
let node1: LayoutNode<TestData> = newLayoutNode<TestData>(FlexDirection.Column, undefined, [
newLayoutNode<TestData>(FlexDirection.Row, undefined, undefined, { name: "hello" }),
]);
assert(node1.children![0].data!.name === "hello", "node1 should have one child which should have data");
const intermediateNode1 = addIntermediateNode(node1);
assert(
node1.children !== undefined && node1.children.length === 1 && node1.children?.includes(intermediateNode1),
"node1 should have a single child intermediateNode1"
);
assert(intermediateNode1.flexDirection === FlexDirection.Row, "intermediateNode1 should have flexDirection Row");
assert(
intermediateNode1.children![0].children![0].data!.name === "hello" &&
intermediateNode1.children![0].children![0].flexDirection === FlexDirection.Row,
"intermediateNode1 should have a nested child which should have data and flexDirection Row"
);
let node2: LayoutNode<TestData> = newLayoutNode<TestData>(FlexDirection.Column, undefined, undefined, {
name: "hello",
});
const intermediateNode2 = addIntermediateNode(node2);
assert(
node2.children !== undefined &&
node2.data === undefined &&
node2.children.length === 1 &&
node2.children.includes(intermediateNode2),
"node2 should have no data and a single child intermediateNode2"
);
assert(
intermediateNode2.data.name === "hello" && intermediateNode2.children === undefined,
"intermediateNode2 should have no children and should have data matching the old value of node2"
);
});
test("addChildAt - same flexDirection, no children", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" });
let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node2" });
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![1].id === node2.id, "node1's second child should be node2");
assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should now have flexDirection Column");
});
test("addChildAt - different flexDirection, no children", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" });
let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2" });
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![0].data!.name === "node1", "node1's first child should have flexDirection Column");
assert(node1.children![1].id === node2.id, "node1's second child should be node2");
assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should have flexDirection Row");
});
test("addChildAt - same flexDirection, first node has children, second doesn't", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" }),
]);
let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2" });
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(
node1.children![0].flexDirection === FlexDirection.Column,
"node1's first child should have flexDirection Column"
);
assert(node1.children![1].id === node2.id, "node1's second child should be node2");
assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should have flexDirection Column");
});
test("addChildAt - different flexDirection, first node has children, second doesn't", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" }),
]);
let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node2" });
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(node1.children![1].id === node2.id, "node1's second child should be node2");
assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should now have flexDirection Column");
});
test("addChildAt - same flexDirection, first node has children, second has children", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" }),
]);
let node2 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2" }),
]);
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(
node1.children![0].flexDirection === FlexDirection.Column,
"node1's first child should have flexDirection Column"
);
assert(node1.children![1].id === node2.children![0].id, "node1's second child should be node2's child");
assert(
node1.children![1].flexDirection === FlexDirection.Column,
"node1's second child should have flexDirection Column"
);
});
test("addChildAt - different flexDirection, first node has children, second has children", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" }),
]);
let node2 = newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node2" }),
]);
addChildAt(node1, 1, node2);
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children!.length === 2, "node1 should have two children");
assert(node1.children![0].data!.name === "node1", "node1's first child should have node1's data");
assert(
node1.children![0].flexDirection === FlexDirection.Column,
"node1's first child should have flexDirection Column"
);
assert(node1.children![1].id === node2.id, "node1's second child should be node2");
assert(
node1.children![1].flexDirection === FlexDirection.Column,
"node1's second child should have flexDirection Column"
);
});
test("balanceNode - corrects flex directions", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1Inner1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1Inner2" }),
]);
const newNode1 = balanceNode(node1).node;
assert(newNode1 !== undefined, "newNode1 should not be undefined");
node1 = newNode1;
assert(node1.data === undefined, "node1 should have no data");
assert(node1.children![0].flexDirection !== node1.flexDirection);
});
test("balanceNode - collapses nodes with single grandchild 1", () => {
let node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
]),
]);
const newNode1 = balanceNode(node1).node;
assert(newNode1 !== undefined, "newNode1 should not be undefined");
node1 = newNode1;
assert(node1.children === undefined, "node1 should have no children");
assert(node1.data!.name === "node1", "node1 should have data 'node1'");
});
test("balanceNode - collapses nodes with single grandchild 2", () => {
let node2 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2Inner1" }),
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2Inner2" }),
]),
]),
]);
const { node: newNode2, leafs } = balanceNode(node2);
assert(newNode2 !== undefined, "newNode2 should not be undefined");
node2 = newNode2;
assert(node2.children!.length === 2, "node2 should have two children");
assert(node2.children[0].data!.name === "node2Inner1", "node2's first child should have data 'node2Inner1'");
assert(leafs.length === 2, "leafs should have two leafs");
assert(leafs[0].data!.name === "node2Inner1", "leafs[0] should have data 'node2Inner1'");
assert(leafs[1].data!.name === "node2Inner2", "leafs[1] should have data 'node2Inner2'");
});
test("balanceNode - collapses nodes with single grandchild 3", () => {
let node3 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node3" }),
]),
]),
]);
const newNode3 = balanceNode(node3).node;
assert(newNode3 !== undefined, "newNode3 should not be undefined");
node3 = newNode3;
assert(node3.children === undefined, "node3 should have no children");
assert(node3.data!.name === "node3", "node3 should have data 'node3'");
});
test("balanceNode - collapses nodes with single grandchild 4", () => {
let node4 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Column, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node4Inner1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node4Inner2" }),
]),
]),
]),
]);
const newNode4 = balanceNode(node4);
assert(newNode4 !== undefined, "newNode4 should not be undefined");
node4 = newNode4.node;
assert(node4.children!.length === 1, "node4 should have one child");
assert(node4.children![0].children!.length === 2, "node4 should have two grandchildren");
assert(
node4.children[0].children![0].data!.name === "node4Inner1",
"node4's first child should have data 'node4Inner1'"
);
});
test("findNextInsertLocation", () => {
const node1 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
]);
const insertLoc1 = findNextInsertLocation(node1, 5);
assert(insertLoc1.node.id === node1.id, "should insert into node1");
assert(insertLoc1.index === 4, "should insert into index 4 of node1");
const node2Inner5 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node2Inner5" });
const node2 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
node2Inner5,
]);
const insertLoc2 = findNextInsertLocation(node2, 5);
assert(insertLoc2.node.id === node2Inner5.id, "should insert into node2Inner5");
assert(insertLoc2.index === 1, "should insert into index 1 of node2Inner1");
const node3Inner5 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
]);
const node3Inner4 = newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node3Inner4" });
const node3 = newLayoutNode(FlexDirection.Row, undefined, [
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "node1" }),
node3Inner4,
node3Inner5,
]);
const insertLoc3 = findNextInsertLocation(node3, 5);
assert(insertLoc3.node.id === node3Inner4.id, "should insert into node3Inner4");
assert(insertLoc3.index === 1, "should insert into index 1 of node3Inner4");
});

View File

@ -0,0 +1,57 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { assert, test } from "vitest";
import { newLayoutNode } from "../lib/layoutNode.js";
import { layoutTreeStateReducer, newLayoutTreeState } from "../lib/layoutState.js";
import { LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, LayoutTreeMoveNodeAction } from "../lib/model.js";
import { DropDirection, FlexDirection } from "../lib/utils.js";
import { TestData } from "./model.js";
test("layoutTreeStateReducer - compute move", () => {
let treeState = newLayoutTreeState<TestData>(
newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "root" })
);
assert(treeState.rootNode.data!.name === "root", "root should have no children and should have data");
let node1 = newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node1" });
treeState = layoutTreeStateReducer(treeState, {
type: LayoutTreeActionType.ComputeMove,
node: treeState.rootNode,
nodeToMove: node1,
direction: DropDirection.Bottom,
} as LayoutTreeComputeMoveNodeAction<TestData>);
const insertOperation = treeState.pendingAction as LayoutTreeMoveNodeAction<TestData>;
assert(insertOperation.node === node1, "insert operation node should equal node1");
assert(!insertOperation.parentId, "insert operation parent should not be defined");
assert(insertOperation.index === 1, "insert operation index should equal 1");
assert(insertOperation.insertAtRoot, "insert operation insertAtRoot should be true");
treeState = layoutTreeStateReducer(treeState, {
type: LayoutTreeActionType.CommitPendingAction,
});
assert(
treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 2,
"root node should now have no data and should have two children"
);
assert(treeState.rootNode.children![1].data!.name === "node1", "root's second child should be node1");
let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { name: "node2" });
treeState = layoutTreeStateReducer(treeState, {
type: LayoutTreeActionType.ComputeMove,
node: node1,
nodeToMove: node2,
direction: DropDirection.Bottom,
} as LayoutTreeComputeMoveNodeAction<TestData>);
const insertOperation2 = treeState.pendingAction as LayoutTreeMoveNodeAction<TestData>;
assert(insertOperation2.node === node2, "insert operation node should equal node2");
assert(insertOperation2.parentId === node1.id, "insert operation parent id should be node1 id");
assert(insertOperation2.index === 1, "insert operation index should equal 1");
assert(!insertOperation2.insertAtRoot, "insert operation insertAtRoot should be false");
treeState = layoutTreeStateReducer(treeState, {
type: LayoutTreeActionType.CommitPendingAction,
});
assert(
treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 2,
"root node should still have three children"
);
assert(treeState.rootNode.children![1].children!.length === 2, "root's second child should now have two children");
});

View File

@ -0,0 +1,6 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
export type TestData = {
name: string;
};

View File

@ -0,0 +1,65 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { assert, test } from "vitest";
import {
Dimensions,
DropDirection,
FlexDirection,
determineDropDirection,
reverseFlexDirection,
} from "../lib/utils.js";
test("determineDropDirection", () => {
const dimensions: Dimensions = {
top: 0,
left: 0,
height: 3,
width: 3,
};
assert.equal(
determineDropDirection(dimensions, {
x: 1.5,
y: 0.5,
}),
DropDirection.Top
);
assert.equal(
determineDropDirection(dimensions, {
x: 1.5,
y: 2.5,
}),
DropDirection.Bottom
);
assert.equal(
determineDropDirection(dimensions, {
x: 2.5,
y: 1.5,
}),
DropDirection.Right
);
assert.equal(
determineDropDirection(dimensions, {
x: 0.5,
y: 1.5,
}),
DropDirection.Left
);
assert.equal(
determineDropDirection(dimensions, {
x: 1.5,
y: 1.5,
}),
undefined
);
});
test("reverseFlexDirection", () => {
assert.equal(reverseFlexDirection(FlexDirection.Row), FlexDirection.Column);
assert.equal(reverseFlexDirection(FlexDirection.Column), FlexDirection.Row);
});

View File

@ -1,6 +1,8 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { LayoutNode } from "../faraday";
declare global {
type UIContext = {
windowid: string;
@ -14,11 +16,11 @@ declare global {
oid: string;
};
type WaveObj = {
interface WaveObj {
otype: string;
oid: string;
version: number;
};
}
type WaveObjUpdate = {
updatetype: "update" | "delete";
@ -68,6 +70,7 @@ declare global {
version: number;
name: string;
blockids: string[];
layout: LayoutNode<TabLayoutData>;
};
type Point = {
@ -104,6 +107,10 @@ declare global {
winsize: WinSize;
lastfocusts: number;
};
type TabLayoutData = {
blockId: string;
};
}
export {};

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { Client } from "@/gopkg/wstore";
import { globalStore } from "@/store/global";
import * as WOS from "@/store/wos";
import * as React from "react";
import { createRoot } from "react-dom/client";
@ -17,6 +18,7 @@ loadFonts();
console.log("Wave Starting");
(window as any).WOS = WOS;
(window as any).globalStore = globalStore;
function matchViewportSize() {
document.body.style.width = window.visualViewport.width + "px";

View File

@ -7,18 +7,23 @@
"dev": "vite",
"build": "vite build --minify false --mode development",
"build:prod": "vite build --mode production",
"preview": "vite preview"
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"coverage": "vitest run --coverage",
"test": "vitest"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.3.3",
"@chromatic-com/storybook": "^1.5.0",
"@eslint/js": "^9.2.0",
"@storybook/addon-essentials": "^8.0.10",
"@storybook/addon-interactions": "^8.0.10",
"@storybook/addon-links": "^8.0.10",
"@storybook/blocks": "^8.0.10",
"@storybook/react": "^8.0.10",
"@storybook/react-vite": "^8.0.10",
"@storybook/test": "^8.0.10",
"@storybook/addon-essentials": "^8.1.4",
"@storybook/addon-interactions": "^8.1.4",
"@storybook/addon-links": "^8.1.4",
"@storybook/blocks": "^8.1.4",
"@storybook/builder-vite": "^8.1.4",
"@storybook/react": "^8.1.4",
"@storybook/react-vite": "^8.1.4",
"@storybook/test": "^8.1.4",
"@types/node": "^20.12.12",
"@types/react": "^18.3.2",
"@types/uuid": "^9.0.8",
@ -31,7 +36,7 @@
"prettier": "^3.2.5",
"prettier-plugin-jsdoc": "^1.3.0",
"prettier-plugin-organize-imports": "^3.2.4",
"storybook": "^8.0.10",
"storybook": "^8.1.4",
"ts-node": "^10.9.2",
"tslib": "^2.6.2",
"typescript": "^5.4.5",
@ -54,6 +59,8 @@
"jotai": "^2.8.0",
"monaco-editor": "^0.49.0",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",

View File

@ -143,15 +143,15 @@ func (svc *ObjectService) CreateBlock(uiContext wstore.UIContext, blockDef *wsto
}
}
rtn := make(map[string]any)
rtn["blockid"] = blockData.OID
rtn["blockId"] = blockData.OID
return updatesRtn(ctx, rtn)
}
func (svc *ObjectService) DeleteBlock(uiContext wstore.UIContext, blockId string) (any, error) {
func (svc *ObjectService) DeleteBlock(uiContext wstore.UIContext, blockId string, newLayout any) (any, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
ctx = wstore.ContextWithUpdates(ctx)
err := wstore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId)
err := wstore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId, newLayout)
if err != nil {
return nil, fmt.Errorf("error deleting block: %w", err)
}

View File

@ -210,7 +210,7 @@ func findStringInSlice(slice []string, val string) int {
return -1
}
func DeleteBlock(ctx context.Context, tabId string, blockId string) error {
func DeleteBlock(ctx context.Context, tabId string, blockId string, newLayout any) error {
return WithTx(ctx, func(tx *TxWrap) error {
tab, _ := DBGet[*Tab](tx.Context(), tabId)
if tab == nil {
@ -221,11 +221,13 @@ func DeleteBlock(ctx context.Context, tabId string, blockId string) error {
return nil
}
tab.BlockIds = append(tab.BlockIds[:blockIdx], tab.BlockIds[blockIdx+1:]...)
if newLayout != nil {
tab.Layout = newLayout
}
DBUpdate(tx.Context(), tab)
DBDelete(tx.Context(), "block", blockId)
return nil
})
}
func CloseTab(ctx context.Context, workspaceId string, tabId string) error {

View File

@ -88,6 +88,7 @@ type Tab struct {
OID string `json:"oid"`
Version int `json:"version"`
Name string `json:"name"`
Layout any `json:"layout,omitempty"`
BlockIds []string `json:"blockids"`
Meta map[string]any `json:"meta"`
}

View File

@ -17,10 +17,11 @@
"paths": {
"@/app/*": ["frontend/app/*"],
"@/util/*": ["frontend/util/*"],
"@/faraday/*": ["frontend/faraday/*"],
"@/store/*": ["frontend/app/store/*"],
"@/element/*": ["frontend/app/element/*"],
"@/bindings/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/service/*"],
"@/gopkg/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/*"]
"@/gopkg/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/*"],
}
}
}

693
yarn.lock

File diff suppressed because it is too large Load Diff