diff --git a/.storybook/main.ts b/.storybook/main.ts
index 0ae6c082d..68debcee3 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -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}
`,
+
+ typescript: {
+ reactDocgen: "react-docgen-typescript",
+ },
};
export default config;
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 000000000..03571dc88
--- /dev/null
+++ b/.vscode/tasks.json
@@ -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"
+ }
+ }
+ ]
+}
diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx
index 04870961a..290bab856 100644
--- a/frontend/app/app.tsx
+++ b/frontend/app/app.tsx
@@ -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 (
);
};
diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx
index a3cc83ae0..cfcce9e08 100644
--- a/frontend/app/block/block.tsx
+++ b/frontend/app/block/block.tsx
@@ -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(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(WOS.makeORef("block", blockId));
+ if (!blockId || !blockData) return null;
console.log("blockData: ", blockData);
if (blockDataLoading) {
blockElem = Loading...;
@@ -49,11 +38,9 @@ const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
return (
-
- Block [{blockId.substring(0, 8)}] {dims.width}x{dims.height}
-
+
Block [{blockId.substring(0, 8)}]
-
diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts
index f43117630..5ab78133d 100644
--- a/frontend/app/store/wos.ts
+++ b/frontend/app/store/wos.ts
@@ -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
(oref: string): T {
return dataValue.value;
}
-function getWaveObjectAtom(oref: string): jotai.Atom {
+function getWaveObjectAtom(oref: string): jotai.WritableAtom {
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(oref: string): jotai.Atom {
@@ -177,7 +180,7 @@ function useWaveObjectValue(oref: string): [T, boolean] {
return [atomVal.value, atomVal.loading];
}
-function useWaveObject(oref: string): [T, boolean, (T) => void] {
+function useWaveObject(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(fnName: string, ...args: any[]): Promise {
// should provide getFn if it is available (e.g. inside of a jotai atom)
// otherwise it will use the globalStore.get function
function getObjectValue(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(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 {
- return wrapObjectServiceCall("DeleteBlock", blockId);
+export function DeleteBlock(blockId: string, newLayout?: LayoutNode): Promise {
+ return wrapObjectServiceCall("DeleteBlock", blockId, newLayout);
}
export function CloseTab(tabId: string): Promise {
@@ -349,4 +356,5 @@ export {
useWaveObject,
useWaveObjectValue,
useWaveObjectValueWithSuspense,
+ waveObjectValueCache,
};
diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx
index d9b1f5db6..0333bba2c 100644
--- a/frontend/app/tab/tab.tsx
+++ b/frontend/app/tab/tab.tsx
@@ -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(WOS.makeORef("tab", tabId));
+ const oref = useMemo(() => WOS.makeORef("tab", tabId), [tabId]);
+ const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]);
+ const tabLoading = useAtomValue(loadingAtom);
+ const tabAtom = useMemo(() => WOS.getWaveObjectAtom(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 ;
+ }, []);
+
+ const onNodeDelete = useCallback(
+ (data: TabLayoutData) => {
+ console.log("onNodeDelete", data, tabData);
+ WOS.DeleteBlock(data.blockId, tabData.layout);
+ },
+ [tabData]
+ );
+
if (tabLoading) {
return ;
}
+
if (!tabData) {
return (
@@ -19,15 +46,15 @@ const TabContent = ({ tabId }: { tabId: string }) => {
);
}
+
return (
- {tabData.blockids.map((blockId: string) => {
- return (
-
-
-
- );
- })}
+
);
};
diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx
index 325c4e0f6..87d794e11 100644
--- a/frontend/app/workspace/workspace.tsx
+++ b/frontend/app/workspace/workspace.tsx
@@ -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 (
{
+ return WOS.getWaveObjectAtom
(WOS.makeORef("tab", windowData.activetabid));
+ }, [windowData.activetabid]);
+ const [, dispatchLayoutStateAction] = useLayoutTreeStateReducerAtom(
+ getLayoutStateAtomForTab(windowData.activetabid, activeTabAtom)
+ );
+
+ const addBlockToTab = useCallback(
+ (blockId: string) => {
+ const insertNodeAction: LayoutTreeInsertNodeAction = {
+ type: LayoutTreeActionType.InsertNode,
+ node: newLayoutNode(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() {
diff --git a/frontend/faraday/index.ts b/frontend/faraday/index.ts
new file mode 100644
index 000000000..4986378f4
--- /dev/null
+++ b/frontend/faraday/index.ts
@@ -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,
+};
diff --git a/frontend/faraday/lib/TileLayout.stories.tsx b/frontend/faraday/lib/TileLayout.stories.tsx
new file mode 100644
index 000000000..e7f9476e3
--- /dev/null
+++ b/frontend/faraday/lib/TileLayout.stories.tsx
@@ -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) => {data.name}
;
+
+const meta = {
+ title: "TileLayout",
+ args: {
+ layoutTreeStateAtom: newLayoutTreeStateAtom(
+ newLayoutNode(FlexDirection.Row, undefined, undefined, {
+ name: "Hello world!",
+ })
+ ),
+ renderContent: renderTestData,
+ },
+ component: TileLayout,
+ // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
+ tags: ["autodocs"],
+} satisfies Meta>;
+
+export default meta;
+type Story = StoryObj;
+
+export const Basic: Story = {
+ args: {
+ layoutTreeStateAtom: newLayoutTreeStateAtom(
+ newLayoutNode(FlexDirection.Row, undefined, undefined, { name: "Hello world!" })
+ ),
+ },
+};
+
+export const More: Story = {
+ args: {
+ layoutTreeStateAtom: newLayoutTreeStateAtom(
+ 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(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(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 = {
+ type: LayoutTreeActionType.InsertNode,
+ node: newNode,
+ };
+ dispatch(insertNodeAction);
+ setNumAddedNodes(numAddedNodes + 1);
+ };
+ return (
+
+ );
+ },
+};
diff --git a/frontend/faraday/lib/TileLayout.tsx b/frontend/faraday/lib/TileLayout.tsx
new file mode 100644
index 000000000..9f7e901ff
--- /dev/null
+++ b/frontend/faraday/lib/TileLayout.tsx
@@ -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 {
+ layoutTreeStateAtom: WritableLayoutTreeStateAtom;
+ renderContent: ContentRenderer;
+ onNodeDelete?: (data: T) => void;
+ className?: string;
+}
+
+export const TileLayout = ({ layoutTreeStateAtom, className, renderContent, onNodeDelete }: TileLayoutProps) => {
+ const overlayContainerRef = useRef(null);
+ const displayContainerRef = useRef(null);
+
+ const [layoutTreeState, dispatch] = useLayoutTreeStateReducerAtom(layoutTreeStateAtom);
+ const [nodeRefs, setNodeRefs] = useState