mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-02-21 02:33:34 +01:00
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:
parent
15527397de
commit
c3e71c5c7d
@ -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
18
.vscode/tasks.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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
38
frontend/faraday/index.ts
Normal 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,
|
||||
};
|
111
frontend/faraday/lib/TileLayout.stories.tsx
Normal file
111
frontend/faraday/lib/TileLayout.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
369
frontend/faraday/lib/TileLayout.tsx
Normal file
369
frontend/faraday/lib/TileLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
95
frontend/faraday/lib/layoutAtom.ts
Normal file
95
frontend/faraday/lib/layoutAtom.ts
Normal 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);
|
||||
}
|
||||
}
|
245
frontend/faraday/lib/layoutNode.ts
Normal file
245
frontend/faraday/lib/layoutNode.ts
Normal 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];
|
||||
}
|
273
frontend/faraday/lib/layoutState.ts
Normal file
273
frontend/faraday/lib/layoutState.ts
Normal 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;
|
||||
}
|
133
frontend/faraday/lib/model.ts
Normal file
133
frontend/faraday/lib/model.ts
Normal 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;
|
93
frontend/faraday/lib/tilelayout.less
Normal file
93
frontend/faraday/lib/tilelayout.less
Normal 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;
|
||||
}
|
||||
}
|
9
frontend/faraday/lib/tilelayout.stories.less
Normal file
9
frontend/faraday/lib/tilelayout.stories.less
Normal 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;
|
||||
}
|
||||
}
|
116
frontend/faraday/lib/utils.ts
Normal file
116
frontend/faraday/lib/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
304
frontend/faraday/tests/layoutNode.test.ts
Normal file
304
frontend/faraday/tests/layoutNode.test.ts
Normal 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");
|
||||
});
|
57
frontend/faraday/tests/layoutState.test.ts
Normal file
57
frontend/faraday/tests/layoutState.test.ts
Normal 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");
|
||||
});
|
6
frontend/faraday/tests/model.ts
Normal file
6
frontend/faraday/tests/model.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export type TestData = {
|
||||
name: string;
|
||||
};
|
65
frontend/faraday/tests/utils.test.ts
Normal file
65
frontend/faraday/tests/utils.test.ts
Normal 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);
|
||||
});
|
11
frontend/types/custom.d.ts
vendored
11
frontend/types/custom.d.ts
vendored
@ -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 {};
|
||||
|
@ -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";
|
||||
|
27
package.json
27
package.json
@ -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",
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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/*"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user