diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less
index f3e172912..967098c69 100644
--- a/frontend/app/block/block.less
+++ b/frontend/app/block/block.less
@@ -216,6 +216,12 @@
}
}
+ .menubutton {
+ .button {
+ font-size: 11px;
+ }
+ }
+
.block-frame-div-url,
.block-frame-div-search {
background: rgba(255, 255, 255, 0.1);
diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx
index 82a78574c..6c2f5fd52 100644
--- a/frontend/app/block/blockframe.tsx
+++ b/frontend/app/block/blockframe.tsx
@@ -32,6 +32,7 @@ import { WindowRpcClient } from "@/app/store/wshrpcutil";
import { ErrorBoundary } from "@/element/errorboundary";
import { IconButton } from "@/element/iconbutton";
import { MagnifyIcon } from "@/element/magnify";
+import { MenuButton } from "@/element/menubutton";
import { NodeModel } from "@/layout/index";
import * as keyutil from "@/util/keyutil";
import * as util from "@/util/util";
@@ -250,7 +251,7 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe
} else if (elem.elemtype == "text") {
return (
- elem?.onClick()}>
+ elem?.onClick(e)}>
{elem.text}
@@ -273,6 +274,8 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe
))}
);
+ } else if (elem.elemtype == "menubutton") {
+ return ;
}
return null;
});
diff --git a/frontend/app/element/menu.less b/frontend/app/element/menu.less
index f16d78478..60397f7f0 100644
--- a/frontend/app/element/menu.less
+++ b/frontend/app/element/menu.less
@@ -1,8 +1,8 @@
.menu {
position: absolute;
- z-index: 1000; // TODO: put this in theme.less
+ z-index: 1000;
display: flex;
- width: 125px; /* Ensure the menu has a fixed width */
+ max-width: 400px;
padding: 2px;
flex-direction: column;
justify-content: flex-end;
diff --git a/frontend/app/element/menu.stories.tsx b/frontend/app/element/menu.stories.tsx
index e471e4da1..222883d96 100644
--- a/frontend/app/element/menu.stories.tsx
+++ b/frontend/app/element/menu.stories.tsx
@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
-import { fn } from "@storybook/test";
-import { useEffect, useRef, useState } from "react";
+import { useRef, useState } from "react";
import { Button } from "./button";
import { Menu } from "./menu";
@@ -58,31 +57,18 @@ const meta = {
component: Menu,
args: {
items: [],
- anchorRef: undefined,
- scopeRef: undefined,
- initialPosition: undefined,
- className: "",
- setVisibility: fn(),
+ children: null,
},
argTypes: {
items: {
description: "Items of menu",
},
- anchorRef: {
- description: "Element to attach the menu",
- },
- initialPosition: {
- description: "Initial position of the menu",
- },
- setVisibility: {
- description: "Visibility event handler",
- },
- scopeRef: {
- description: "Component that defines the boundaries of the menu",
- },
className: {
description: "Custom className",
},
+ children: {
+ description: "The contents of the menu anchor element",
+ },
},
} satisfies Meta;
@@ -91,21 +77,12 @@ type Story = StoryObj;
export const DefaultRendererLeftPositioned: Story = {
render: (args) => {
- const anchorRef = useRef(null);
- const scopeRef = useRef(null);
- const [isMenuVisible, setIsMenuVisible] = useState(false);
-
- const handleAnchorClick = () => {
- setIsMenuVisible((prev) => !prev);
- };
-
const mapItemsWithClick = (items: any[]) => {
return items.map((item) => ({
...item,
onClick: () => {
if (item.onClick) {
item.onClick();
- setIsMenuVisible(false);
}
},
subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined,
@@ -119,29 +96,17 @@ export const DefaultRendererLeftPositioned: Story = {
return (
-
-
-
- {isMenuVisible && (
-
);
},
@@ -152,14 +117,8 @@ export const DefaultRendererLeftPositioned: Story = {
export const DefaultRendererRightPositioned: Story = {
render: (args) => {
- const anchorRef = useRef(null);
- const scopeRef = useRef(null);
const [isMenuVisible, setIsMenuVisible] = useState(false);
- const handleAnchorClick = () => {
- setIsMenuVisible((prev) => !prev);
- };
-
const mapItemsWithClick = (items: any[]) => {
return items.map((item) => ({
...item,
@@ -167,7 +126,6 @@ export const DefaultRendererRightPositioned: Story = {
if (item.onClick) {
item.onClick();
}
- setIsMenuVisible(false);
},
subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined,
}));
@@ -180,29 +138,17 @@ export const DefaultRendererRightPositioned: Story = {
return (
-
-
-
- {isMenuVisible && (
-
setIsMenuVisible(visible)}
- anchorRef={anchorRef}
- scopeRef={scopeRef}
- />
- )}
+
+
+
+
+
);
},
@@ -213,14 +159,6 @@ export const DefaultRendererRightPositioned: Story = {
export const DefaultRendererBottomRightPositioned: Story = {
render: (args) => {
- const anchorRef = useRef(null);
- const scopeRef = useRef(null);
- const [isMenuVisible, setIsMenuVisible] = useState(false);
-
- const handleAnchorClick = () => {
- setIsMenuVisible((prev) => !prev);
- };
-
const mapItemsWithClick = (items: any[]) => {
return items.map((item) => ({
...item,
@@ -228,7 +166,6 @@ export const DefaultRendererBottomRightPositioned: Story = {
if (item.onClick) {
item.onClick();
}
- setIsMenuVisible(false);
},
subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined,
}));
@@ -241,29 +178,17 @@ export const DefaultRendererBottomRightPositioned: Story = {
return (
-
-
-
- {isMenuVisible && (
-
setIsMenuVisible(visible)}
- anchorRef={anchorRef}
- scopeRef={scopeRef}
- />
- )}
+
+
+
+
+
);
},
@@ -276,11 +201,6 @@ export const DefaultRendererBottomLeftPositioned: Story = {
render: (args) => {
const anchorRef = useRef(null);
const scopeRef = useRef(null);
- const [isMenuVisible, setIsMenuVisible] = useState(false);
-
- const handleAnchorClick = () => {
- setIsMenuVisible((prev) => !prev);
- };
const mapItemsWithClick = (items: any[]) => {
return items.map((item) => ({
@@ -289,7 +209,6 @@ export const DefaultRendererBottomLeftPositioned: Story = {
if (item.onClick) {
item.onClick();
}
- setIsMenuVisible(false);
},
subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined,
}));
@@ -306,25 +225,17 @@ export const DefaultRendererBottomLeftPositioned: Story = {
className="boundary"
style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }}
>
-
-
-
- {isMenuVisible && (
- setIsMenuVisible(visible)}
- anchorRef={anchorRef}
- scopeRef={scopeRef}
- />
- )}
+
+
+
+
+
);
},
@@ -335,14 +246,6 @@ export const DefaultRendererBottomLeftPositioned: Story = {
export const CustomRenderer: Story = {
render: (args) => {
- const anchorRef = useRef(null);
- const scopeRef = useRef(null);
- const [isMenuVisible, setIsMenuVisible] = useState(false);
-
- const handleAnchorClick = () => {
- setIsMenuVisible((prev) => !prev);
- };
-
const mapItemsWithClick = (items: any[]) => {
return items.map((item) => ({
...item,
@@ -350,7 +253,6 @@ export const CustomRenderer: Story = {
if (item.onClick) {
item.onClick();
}
- setIsMenuVisible(false);
},
subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined,
}));
@@ -371,32 +273,15 @@ export const CustomRenderer: Story = {
};
return (
-
-
-
-
- {isMenuVisible && (
-
setIsMenuVisible(visible)}
- anchorRef={anchorRef}
- scopeRef={scopeRef}
- renderMenu={renderMenu}
- renderMenuItem={renderMenuItem}
- />
- )}
+
+
+
+
+
+
);
},
@@ -405,67 +290,59 @@ export const CustomRenderer: Story = {
},
};
-export const ContextMenu: Story = {
- render: (args) => {
- const scopeRef = useRef(null);
- const [isMenuVisible, setIsMenuVisible] = useState(false);
- const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
+// export const ContextMenu: Story = {
+// render: (args) => {
+// const scopeRef = useRef(null);
+// const [isMenuVisible, setIsMenuVisible] = useState(false);
+// const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
- const handleBlockRightClick = (e: MouseEvent) => {
- e.preventDefault(); // Prevent the default context menu
- setMenuPosition({ top: e.clientY, left: e.clientX });
- setIsMenuVisible(true);
- };
+// const handleBlockRightClick = (e: MouseEvent) => {
+// e.preventDefault(); // Prevent the default context menu
+// setMenuPosition({ top: e.clientY, left: e.clientX });
+// setIsMenuVisible(true);
+// };
- useEffect(() => {
- const blockElement = scopeRef.current;
- if (blockElement) {
- blockElement.addEventListener("contextmenu", handleBlockRightClick);
- }
+// useEffect(() => {
+// const blockElement = scopeRef.current;
+// if (blockElement) {
+// blockElement.addEventListener("contextmenu", handleBlockRightClick);
+// }
- return () => {
- if (blockElement) {
- blockElement.removeEventListener("contextmenu", handleBlockRightClick);
- }
- };
- }, []);
+// return () => {
+// if (blockElement) {
+// blockElement.removeEventListener("contextmenu", handleBlockRightClick);
+// }
+// };
+// }, []);
- const mapItemsWithClick = (items: any[]) => {
- return items.map((item) => ({
- ...item,
- onClick: () => {
- if (item.onClick) {
- item.onClick();
- setIsMenuVisible(false);
- }
- },
- subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined,
- }));
- };
+// const mapItemsWithClick = (items: any[]) => {
+// return items.map((item) => ({
+// ...item,
+// onClick: () => {
+// if (item.onClick) {
+// item.onClick();
+// }
+// },
+// subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined,
+// }));
+// };
- const modifiedArgs = {
- ...args,
- items: mapItemsWithClick(args.items),
- };
+// const modifiedArgs = {
+// ...args,
+// items: mapItemsWithClick(args.items),
+// };
- return (
-
- {isMenuVisible && (
-
setIsMenuVisible(visible)}
- initialPosition={menuPosition}
- scopeRef={scopeRef}
- />
- )}
-
- );
- },
- args: {
- items: items,
- },
-};
+// return (
+//
+// {isMenuVisible &&
}
+//
+// );
+// },
+// args: {
+// items: items,
+// },
+// };
diff --git a/frontend/app/element/menu.tsx b/frontend/app/element/menu.tsx
index db6e77e11..fb4a6df7a 100644
--- a/frontend/app/element/menu.tsx
+++ b/frontend/app/element/menu.tsx
@@ -1,19 +1,12 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
+import { useDismiss, useFloating, useInteractions } from "@floating-ui/react";
import clsx from "clsx";
-import React, { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { createRef, Fragment, memo, ReactNode, useRef, useState } from "react";
import ReactDOM from "react-dom";
-
-import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
import "./menu.less";
-type MenuItem = {
- label: string;
- subItems?: MenuItem[];
- onClick?: (e) => void;
-};
-
const SubMenu = memo(
({
subItems,
@@ -48,7 +41,7 @@ const SubMenu = memo(
subItems.forEach((_, idx) => {
const newKey = `${parentKey}-${idx}`;
if (!subMenuRefs.current[newKey]) {
- subMenuRefs.current[newKey] = React.createRef();
+ subMenuRefs.current[newKey] = createRef();
}
});
@@ -88,7 +81,7 @@ const SubMenu = memo(
);
return (
-
+
{renderedItem}
{visibleSubMenus[newKey]?.visible && item.subItems && (
)}
-
+
);
})}
@@ -117,20 +110,16 @@ const SubMenu = memo(
const Menu = memo(
({
items,
- anchorRef,
- scopeRef,
- initialPosition,
+ children,
className,
- setVisibility,
+ onOpenChange,
renderMenu,
renderMenuItem,
}: {
items: MenuItem[];
- anchorRef: React.RefObject;
- scopeRef?: React.RefObject;
- initialPosition?: { top: number; left: number };
className?: string;
- setVisibility: (_: boolean) => void;
+ onOpenChange?: (isOpen: boolean) => void;
+ children: ReactNode | ReactNode[];
renderMenu?: (subMenu: JSX.Element, props: any) => JSX.Element;
renderMenuItem?: (item: MenuItem, props: any) => JSX.Element;
}) => {
@@ -139,128 +128,29 @@ const Menu = memo(
const [subMenuPosition, setSubMenuPosition] = useState<{
[key: string]: { top: number; left: number; label: string };
}>({});
- const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const menuRef = useRef(null);
const subMenuRefs = useRef<{ [key: string]: React.RefObject }>({});
- const domRect = useDimensionsWithExistingRef(scopeRef, 30);
- const width = domRect?.width ?? 0;
- const height = domRect?.height ?? 0;
+
+ const [isOpen, setIsOpen] = useState(false);
+ const onOpenChangeMenu = (isOpen: boolean) => {
+ setIsOpen(isOpen);
+ onOpenChange?.(isOpen);
+ };
+ const { refs, floatingStyles, context } = useFloating({
+ placement: "bottom-start",
+ open: isOpen,
+ onOpenChange: onOpenChangeMenu,
+ });
+ const dismiss = useDismiss(context);
+ const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
items.forEach((_, idx) => {
const key = `${idx}`;
if (!subMenuRefs.current[key]) {
- subMenuRefs.current[key] = React.createRef();
+ subMenuRefs.current[key] = createRef();
}
});
- useLayoutEffect(() => {
- const shadowOffset = 10; // Adjust for box shadow space
-
- if (initialPosition) {
- // Adjust position if initialPosition is provided
- let { top, left } = initialPosition;
-
- const scrollTop = window.scrollY || document.documentElement.scrollTop;
- const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
-
- const menuWidth = menuRef.current?.offsetWidth || 0;
- const menuHeight = menuRef.current?.offsetHeight || 0;
-
- const boundaryTop = 0;
- const boundaryLeft = 0;
- const boundaryRight = window.innerWidth + scrollLeft;
- const boundaryBottom = window.innerHeight + scrollTop;
-
- // Adjust if the menu overflows the right boundary
- if (left + menuWidth > boundaryRight) {
- left = boundaryRight - menuWidth - shadowOffset; // Shift left more for shadow
- }
-
- // Adjust if the menu overflows the bottom boundary: move the menu upwards so its bottom edge aligns with the initial position
- if (top + menuHeight > boundaryBottom) {
- top = initialPosition.top - menuHeight - shadowOffset; // Shift up for shadow
- }
-
- // Adjust if the menu overflows the left boundary
- if (left < boundaryLeft) {
- left = boundaryLeft + shadowOffset; // Add shadow offset from the left edge
- }
-
- // Adjust if the menu overflows the top boundary
- if (top < boundaryTop) {
- top = boundaryTop + shadowOffset; // Add shadow offset from the top edge
- }
-
- setPosition({ top, left });
- } else if (anchorRef.current && menuRef.current) {
- // Calculate position based on anchorRef if it exists
- const anchorRect = anchorRef.current.getBoundingClientRect();
- const scrollTop = window.scrollY || document.documentElement.scrollTop;
- const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
-
- let top = anchorRect.bottom + scrollTop;
- let left = anchorRect.left + scrollLeft;
-
- const menuWidth = menuRef.current.offsetWidth;
- const menuHeight = menuRef.current.offsetHeight;
-
- const boundaryTop = 0;
- const boundaryLeft = 0;
- const boundaryRight = window.innerWidth + scrollLeft;
- const boundaryBottom = window.innerHeight + scrollTop;
-
- // Adjust if the menu overflows the right boundary
- if (left + menuWidth > boundaryRight) {
- left = boundaryRight - menuWidth;
- }
-
- // Adjust if the menu overflows the bottom boundary: move the menu upwards so its bottom edge aligns with the anchor top
- if (top + menuHeight > boundaryBottom) {
- top = anchorRect.top + scrollTop - menuHeight;
- }
-
- // Adjust if the menu overflows the left boundary
- if (left < boundaryLeft) {
- left = boundaryLeft;
- }
-
- // Adjust if the menu overflows the top boundary
- if (top < boundaryTop) {
- top = boundaryTop;
- }
-
- setPosition({ top, left });
- } else {
- console.warn("Neither initialPosition nor anchorRef provided. Defaulting to { top: 0, left: 0 }.");
- }
- }, [width, height, initialPosition]);
-
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- const isClickInsideDropdown = menuRef.current && menuRef.current.contains(event.target as Node);
-
- const isClickInsideAnchor = anchorRef?.current
- ? anchorRef.current.contains(event.target as Node)
- : false;
-
- const isClickInsideSubMenus = Object.keys(subMenuRefs.current).some(
- (key) =>
- subMenuRefs.current[key]?.current &&
- subMenuRefs.current[key]?.current.contains(event.target as Node)
- );
-
- if (!isClickInsideDropdown && !isClickInsideAnchor && !isClickInsideSubMenus) {
- setVisibility(false);
- }
- };
-
- scopeRef?.current?.addEventListener("mousedown", handleClickOutside);
-
- return () => {
- scopeRef?.current?.removeEventListener("mousedown", handleClickOutside);
- };
- }, []);
-
// Position submenus based on available space and scroll position
const handleSubMenuPosition = (
key: string,
@@ -345,92 +235,72 @@ const Menu = memo(
const handleOnClick = (e: React.MouseEvent, item: MenuItem) => {
e.stopPropagation();
- item.onClick && item.onClick(e);
+ onOpenChangeMenu(false);
+ item.onClick?.(e);
};
- // const handleKeyDown = useCallback(
- // (waveEvent: WaveKeyboardEvent): boolean => {
- // if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) {
- // setFocusedIndex((prev) => (prev + 1) % items.length); // Move down
- // return true;
- // }
- // if (keyutil.checkKeyPressed(waveEvent, "ArrowUp")) {
- // setFocusedIndex((prev) => (prev - 1 + items.length) % items.length); // Move up
- // return true;
- // }
- // if (keyutil.checkKeyPressed(waveEvent, "ArrowRight")) {
- // if (items[focusedIndex].subItems) {
- // setSubmenuOpen(focusedIndex); // Open the submenu
- // }
- // return true;
- // }
- // if (keyutil.checkKeyPressed(waveEvent, "ArrowLeft")) {
- // if (submenuOpen !== null) {
- // setSubmenuOpen(null); // Close the submenu
- // }
- // return true;
- // }
- // if (keyutil.checkKeyPressed(waveEvent, "Enter") || keyutil.checkKeyPressed(waveEvent, " ")) {
- // if (items[focusedIndex].onClick) {
- // items[focusedIndex].onClick(); // Trigger click
- // }
- // return true;
- // }
- // if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
- // setVisibility(false); // Close the menu
- // return true;
- // }
- // return false;
- // },
- // [focusedIndex, submenuOpen, items, setVisibility]
- // );
+ return (
+ <>
+ onOpenChangeMenu(!isOpen)}
+ >
+ {children}
+
- const menuMenu = (
-
- {items.map((item, index) => {
- const key = `${index}`;
- const isActive = hoveredItems.includes(key);
+ {isOpen && (
+
+ {items.map((item, index) => {
+ const key = `${index}`;
+ const isActive = hoveredItems.includes(key);
- const menuItemProps = {
- className: clsx("menu-item", { active: isActive }),
- onMouseEnter: (event: React.MouseEvent
) =>
- handleMouseEnterItem(event, null, index, item),
- onClick: (e: React.MouseEvent) => handleOnClick(e, item),
- };
+ const menuItemProps = {
+ className: clsx("menu-item", { active: isActive }),
+ onMouseEnter: (event: React.MouseEvent) =>
+ handleMouseEnterItem(event, null, index, item),
+ onClick: (e: React.MouseEvent) => handleOnClick(e, item),
+ };
- const renderedItem = renderMenuItem ? (
- renderMenuItem(item, menuItemProps)
- ) : (
-
- {item.label}
- {item.subItems && }
-
- );
+ const renderedItem = renderMenuItem ? (
+ renderMenuItem(item, menuItemProps)
+ ) : (
+
+ {item.label}
+ {item.subItems && }
+
+ );
- return (
-
- {renderedItem}
- {visibleSubMenus[key]?.visible && item.subItems && (
-
- )}
-
- );
- })}
-
+ return (
+
+ {renderedItem}
+ {visibleSubMenus[key]?.visible && item.subItems && (
+
+ )}
+
+ );
+ })}
+
+ )}
+ >
);
-
- return ReactDOM.createPortal(renderMenu ? renderMenu(menuMenu, { parentKey: null }) : menuMenu, document.body);
}
);
diff --git a/frontend/app/element/menubutton.tsx b/frontend/app/element/menubutton.tsx
new file mode 100644
index 000000000..f0cb5e383
--- /dev/null
+++ b/frontend/app/element/menubutton.tsx
@@ -0,0 +1,24 @@
+import clsx from "clsx";
+import { memo, useState } from "react";
+import { Button } from "./button";
+import { Menu } from "./menu";
+
+const MenuButtonComponent = ({ items, className, text, title }: MenuButtonProps) => {
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+
+
+
+
+
+ );
+};
+
+export const MenuButton = memo(MenuButtonComponent) as typeof MenuButtonComponent;
diff --git a/frontend/app/hook/useDimensions.tsx b/frontend/app/hook/useDimensions.tsx
index 4e0b066c3..2824661c8 100644
--- a/frontend/app/hook/useDimensions.tsx
+++ b/frontend/app/hook/useDimensions.tsx
@@ -12,10 +12,15 @@ export function useDimensionsWithCallbackRef(
const rszObjRef = React.useRef(null);
const oldHtmlElem = React.useRef(null);
const ref = React.useRef(null);
- const refCallback = useCallback((node: T) => {
- setHtmlElem(node);
- ref.current = node;
- }, []);
+ const refCallback = useCallback(
+ (node: T) => {
+ if (ref) {
+ setHtmlElem(node);
+ ref.current = node;
+ }
+ },
+ [ref]
+ );
const setDomRectDebounced = React.useCallback(debounceMs == null ? setDomRect : debounce(debounceMs, setDomRect), [
debounceMs,
setDomRect,
@@ -54,7 +59,7 @@ export function useDimensionsWithCallbackRef(
// will not react to ref changes
// pass debounceMs of null to not debounce
export function useDimensionsWithExistingRef(
- ref: React.RefObject,
+ ref?: React.RefObject,
debounceMs: number = null
): DOMRectReadOnly {
const [domRect, setDomRect] = useState(null);
@@ -76,7 +81,7 @@ export function useDimensionsWithExistingRef(
}
});
}
- if (ref.current) {
+ if (ref?.current) {
rszObjRef.current.observe(ref.current);
oldHtmlElem.current = ref.current;
}
@@ -86,13 +91,13 @@ export function useDimensionsWithExistingRef(
oldHtmlElem.current = null;
}
};
- }, [ref.current]);
+ }, [ref?.current]);
React.useEffect(() => {
return () => {
rszObjRef.current?.disconnect();
};
}, []);
- if (ref.current != null) {
+ if (ref?.current != null) {
return ref.current.getBoundingClientRect();
}
return null;
diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx
index f31e676b3..0c170d645 100644
--- a/frontend/app/view/waveai/waveai.tsx
+++ b/frontend/app/view/waveai/waveai.tsx
@@ -7,9 +7,9 @@ import { TypingIndicator } from "@/app/element/typingindicator";
import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil";
import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global";
-import { BlockService } from "@/store/services";
+import { BlockService, ObjectService } from "@/store/services";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
-import { isBlank, makeIconClass } from "@/util/util";
+import { fireAndForget, isBlank, makeIconClass } from "@/util/util";
import { atom, Atom, PrimitiveAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai";
import type { OverlayScrollbars } from "overlayscrollbars";
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
@@ -41,6 +41,9 @@ export class WaveAiModel implements ViewModel {
viewType: string;
blockId: string;
blockAtom: Atom;
+ presetKey: Atom;
+ presetMap: Atom<{ [k: string]: MetaType }>;
+ aiOpts: Atom;
viewIcon?: Atom;
viewName?: Atom;
viewText?: Atom;
@@ -61,11 +64,32 @@ export class WaveAiModel implements ViewModel {
this.viewType = "waveai";
this.blockId = blockId;
this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`);
- this.viewIcon = atom((get) => {
- return "sparkles"; // should not be hardcoded
- });
- this.viewName = atom("Wave Ai");
+ this.viewIcon = atom("sparkles");
+ this.viewName = atom("Wave AI");
this.messagesAtom = atom([]);
+ this.presetKey = atom((get) => {
+ const metaPresetKey = get(this.blockAtom).meta["ai:preset"];
+ const globalPresetKey = get(atoms.settingsAtom)["ai:preset"];
+ return metaPresetKey ?? globalPresetKey;
+ });
+ this.presetMap = atom((get) => {
+ const fullConfig = get(atoms.fullConfigAtom);
+ const presets = fullConfig.presets;
+ const settings = fullConfig.settings;
+ return Object.fromEntries(
+ Object.entries(presets)
+ .filter(([k]) => k.startsWith("ai@"))
+ .map(([k, v]) => {
+ const aiPresetKeys = Object.keys(v).filter((k) => k.startsWith("ai:"));
+ console.log(aiPresetKeys);
+ v["display:name"] =
+ aiPresetKeys.length == 1 && aiPresetKeys.includes("ai:*")
+ ? `${v["display:name"] ?? "Default"} (${settings["ai:model"]})`
+ : v["display:name"];
+ return [k, v];
+ })
+ );
+ });
this.addMessageAtom = atom(null, (get, set, message: ChatMessageType) => {
const messages = get(this.messagesAtom);
@@ -104,19 +128,34 @@ export class WaveAiModel implements ViewModel {
}
set(this.updateLastMessageAtom, "", false);
});
+
+ this.aiOpts = atom((get) => {
+ const meta = get(this.blockAtom).meta;
+ let settings = get(atoms.settingsAtom);
+ settings = {
+ ...settings,
+ ...meta,
+ };
+ const opts: OpenAIOptsType = {
+ model: settings["ai:model"] ?? null,
+ apitype: settings["ai:apitype"] ?? null,
+ orgid: settings["ai:orgid"] ?? null,
+ apitoken: settings["ai:apitoken"] ?? null,
+ apiversion: settings["ai:apiversion"] ?? null,
+ maxtokens: settings["ai:maxtokens"] ?? null,
+ timeoutms: settings["ai:timeoutms"] ?? 60000,
+ baseurl: settings["ai:baseurl"] ?? null,
+ };
+ return opts;
+ });
+
this.viewText = atom((get) => {
const viewTextChildren: HeaderElem[] = [];
- const aiOpts = this.getAiOpts();
- const aiName = this.getAiName();
+ const aiOpts = get(this.aiOpts);
+ const presets = get(this.presetMap);
+ const presetKey = get(this.presetKey);
+ const presetName = presets[presetKey]?.["display:name"] ?? "";
const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl);
- let modelText = "gpt-4o-mini";
- if (!isCloud && !isBlank(aiOpts.model)) {
- if (!isBlank(aiName)) {
- modelText = aiName;
- } else {
- modelText = aiOpts.model;
- }
- }
if (isCloud) {
viewTextChildren.push({
elemtype: "iconbutton",
@@ -143,9 +182,26 @@ export class WaveAiModel implements ViewModel {
});
}
}
+
viewTextChildren.push({
- elemtype: "text",
- text: modelText,
+ elemtype: "menubutton",
+ text: presetName,
+ title: "Select AI Configuration",
+ items: Object.entries(presets)
+ .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1))
+ .map(
+ (preset) =>
+ ({
+ label: preset[1]["display:name"],
+ onClick: () =>
+ fireAndForget(async () => {
+ await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), {
+ ...preset[1],
+ "ai:preset": preset[0],
+ });
+ }),
+ }) as MenuItem
+ ),
});
return viewTextChildren;
});
@@ -173,22 +229,6 @@ export class WaveAiModel implements ViewModel {
return false;
}
- getAiOpts(): OpenAIOptsType {
- const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {};
- const settings = globalStore.get(atoms.settingsAtom) ?? {};
- const opts: OpenAIOptsType = {
- model: blockMeta["ai:model"] ?? settings["ai:model"] ?? null,
- apitype: blockMeta["ai:apitype"] ?? settings["ai:apitype"] ?? null,
- orgid: blockMeta["ai:orgid"] ?? settings["ai:orgid"] ?? null,
- apitoken: blockMeta["ai:apitoken"] ?? settings["ai:apitoken"] ?? null,
- apiversion: blockMeta["ai:apiversion"] ?? settings["ai:apiversion"] ?? null,
- maxtokens: blockMeta["ai:maxtokens"] ?? settings["ai:maxtokens"] ?? null,
- timeoutms: blockMeta["ai:timeoutms"] ?? settings["ai:timeoutms"] ?? 60000,
- baseurl: blockMeta["ai:baseurl"] ?? settings["ai:baseurl"] ?? null,
- };
- return opts;
- }
-
getAiName(): string {
const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {};
const settings = globalStore.get(atoms.settingsAtom) ?? {};
@@ -199,7 +239,6 @@ export class WaveAiModel implements ViewModel {
useWaveAi() {
const messages = useAtomValue(this.messagesAtom);
const addMessage = useSetAtom(this.addMessageAtom);
- const simulateResponse = useSetAtom(this.simulateAssistantResponseAtom);
const clientId = useAtomValue(atoms.clientId);
const blockId = this.blockId;
const setLocked = useSetAtom(this.locked);
@@ -213,7 +252,7 @@ export class WaveAiModel implements ViewModel {
};
addMessage(newMessage);
// send message to backend and get response
- const opts = this.getAiOpts();
+ const opts = globalStore.get(this.aiOpts);
const newPrompt: OpenAIPromptMessageType = {
role: "user",
content: text,
diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts
index a032d1bf3..df29bc76e 100644
--- a/frontend/types/custom.d.ts
+++ b/frontend/types/custom.d.ts
@@ -161,7 +161,14 @@ declare global {
type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void };
- type HeaderElem = IconButtonDecl | HeaderText | HeaderInput | HeaderDiv | HeaderTextButton | ConnectionButton;
+ type HeaderElem =
+ | IconButtonDecl
+ | HeaderText
+ | HeaderInput
+ | HeaderDiv
+ | HeaderTextButton
+ | ConnectionButton
+ | MenuButton;
type IconButtonDecl = {
elemtype: "iconbutton";
@@ -186,7 +193,7 @@ declare global {
text: string;
ref?: React.MutableRefObject;
className?: string;
- onClick?: () => void;
+ onClick?: (e: React.MouseEvent) => void;
};
type HeaderInput = {
@@ -219,6 +226,23 @@ declare global {
connected: boolean;
};
+ type MenuItem = {
+ label: string;
+ subItems?: MenuItem[];
+ onClick?: (e: React.MouseEvent) => void;
+ };
+
+ type MenuButtonProps = {
+ items: MenuItem[];
+ className?: string;
+ text: string;
+ title?: string;
+ };
+
+ type MenuButton = {
+ elemtype: "menubutton";
+ } & MenuButtonProps;
+
interface ViewModel {
viewType: string;
viewIcon?: jotai.Atom;
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index 5d21cbb8d..c84901570 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -293,6 +293,7 @@ declare global {
"cmd:cwd"?: string;
"cmd:nowsh"?: boolean;
"ai:*"?: boolean;
+ "ai:preset"?: string;
"ai:apitype"?: string;
"ai:baseurl"?: string;
"ai:apitoken"?: string;
@@ -426,6 +427,7 @@ declare global {
// wconfig.SettingsType
type SettingsType = {
"ai:*"?: boolean;
+ "ai:preset"?: string;
"ai:apitype"?: string;
"ai:baseurl"?: string;
"ai:apitoken"?: string;
diff --git a/package.json b/package.json
index 219c8e4f5..b599ad217 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,7 @@
"vitest": "^2.1.2"
},
"dependencies": {
+ "@floating-ui/react": "^0.26.24",
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "^4.6.0",
"@observablehq/plot": "^0.6.16",
diff --git a/pkg/waveai/waveai.go b/pkg/waveai/waveai.go
index f22a48101..360167b8c 100644
--- a/pkg/waveai/waveai.go
+++ b/pkg/waveai/waveai.go
@@ -116,7 +116,7 @@ func RunAICommand(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan
log.Print("sending ai chat message to default waveterm cloud endpoint\n")
return RunCloudCompletionStream(ctx, request)
}
- log.Printf("sending ai chat message to user-configured endpoint %s\n", request.Opts.BaseURL)
+ log.Printf("sending ai chat message to user-configured endpoint %s using model %s\n", request.Opts.BaseURL, request.Opts.Model)
return RunLocalCompletionStream(ctx, request)
}
diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go
index e12e04d67..41fdce4dd 100644
--- a/pkg/waveobj/metaconsts.go
+++ b/pkg/waveobj/metaconsts.go
@@ -49,6 +49,7 @@ const (
MetaKey_CmdNoWsh = "cmd:nowsh"
MetaKey_AiClear = "ai:*"
+ MetaKey_AiPresetKey = "ai:preset"
MetaKey_AiApiType = "ai:apitype"
MetaKey_AiBaseURL = "ai:baseurl"
MetaKey_AiApiToken = "ai:apitoken"
diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go
index b8e9b6e98..2b52e897a 100644
--- a/pkg/waveobj/wtypemeta.go
+++ b/pkg/waveobj/wtypemeta.go
@@ -49,6 +49,7 @@ type MetaTSType struct {
// AI options match settings
AiClear bool `json:"ai:*,omitempty"`
+ AiPresetKey string `json:"ai:preset,omitempty"`
AiApiType string `json:"ai:apitype,omitempty"`
AiBaseURL string `json:"ai:baseurl,omitempty"`
AiApiToken string `json:"ai:apitoken,omitempty"`
diff --git a/pkg/wconfig/defaultconfig/presets.json b/pkg/wconfig/defaultconfig/presets.json
index 3f1a38135..78da237fe 100644
--- a/pkg/wconfig/defaultconfig/presets.json
+++ b/pkg/wconfig/defaultconfig/presets.json
@@ -94,5 +94,25 @@
"bg": "linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)",
"bg:blendmode": "overlay",
"bg:text": "rgb(200, 200, 200)"
+ },
+ "ai@global": {
+ "display:name": "Global default",
+ "display:order": -1,
+ "ai:*": true
+ },
+ "ai@wave": {
+ "display:name": "Wave Proxy - gpt-4o-mini",
+ "display:order": 0,
+ "ai:*": true,
+ "ai:model": "gpt-4o-mini",
+ "ai:maxtokens": 2048,
+ "ai:timeoutms": 60000
+ },
+ "ai@ollama-llama3.1": {
+ "display:name": "ollama - llama3.1",
+ "display:order": 0,
+ "ai:*": true,
+ "ai:baseurl": "http://localhost:11434/v1",
+ "ai:model": "llama3.1:latest"
}
}
diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json
index 791901ee8..49e886c7a 100644
--- a/pkg/wconfig/defaultconfig/settings.json
+++ b/pkg/wconfig/defaultconfig/settings.json
@@ -1,11 +1,12 @@
{
+ "ai:preset": "ai@global",
"ai:model": "gpt-4o-mini",
"ai:maxtokens": 2048,
"ai:timeoutms": 60000,
"autoupdate:enabled": true,
"autoupdate:installonquit": true,
"autoupdate:intervalms": 3600000,
- "conn:askbeforewshinstall": true,
+ "conn:askbeforewshinstall": true,
"editor:minimapenabled": true,
"web:defaulturl": "https://github.com/wavetermdev/waveterm",
"web:defaultsearch": "https://www.google.com/search?q={query}",
diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go
index 4a9b29a37..e7a2a2c5e 100644
--- a/pkg/wconfig/metaconsts.go
+++ b/pkg/wconfig/metaconsts.go
@@ -7,6 +7,7 @@ package wconfig
const (
ConfigKey_AiClear = "ai:*"
+ ConfigKey_AiPreset = "ai:preset"
ConfigKey_AiApiType = "ai:apitype"
ConfigKey_AiBaseURL = "ai:baseurl"
ConfigKey_AiApiToken = "ai:apitoken"
diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go
index e4ecf5bf8..cdd63d8c4 100644
--- a/pkg/wconfig/settingsconfig.go
+++ b/pkg/wconfig/settingsconfig.go
@@ -41,6 +41,7 @@ func (m MetaSettingsType) MarshalJSON() ([]byte, error) {
type SettingsType struct {
AiClear bool `json:"ai:*,omitempty"`
+ AiPreset string `json:"ai:preset,omitempty"`
AiApiType string `json:"ai:apitype,omitempty"`
AiBaseURL string `json:"ai:baseurl,omitempty"`
AiApiToken string `json:"ai:apitoken,omitempty"`
diff --git a/yarn.lock b/yarn.lock
index 559c7336c..b1091033e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -816,6 +816,58 @@ __metadata:
languageName: node
linkType: hard
+"@floating-ui/core@npm:^1.6.0":
+ version: 1.6.8
+ resolution: "@floating-ui/core@npm:1.6.8"
+ dependencies:
+ "@floating-ui/utils": "npm:^0.2.8"
+ checksum: 10c0/d6985462aeccae7b55a2d3f40571551c8c42bf820ae0a477fc40ef462e33edc4f3f5b7f11b100de77c9b58ecb581670c5c3f46d0af82b5e30aa185c735257eb9
+ languageName: node
+ linkType: hard
+
+"@floating-ui/dom@npm:^1.0.0":
+ version: 1.6.11
+ resolution: "@floating-ui/dom@npm:1.6.11"
+ dependencies:
+ "@floating-ui/core": "npm:^1.6.0"
+ "@floating-ui/utils": "npm:^0.2.8"
+ checksum: 10c0/02ef34a75a515543c772880338eea7b66724997bd5ec7cd58d26b50325709d46d480a306b84e7d5509d734434411a4bcf23af5680c2e461e6e6a8bf45d751df8
+ languageName: node
+ linkType: hard
+
+"@floating-ui/react-dom@npm:^2.1.2":
+ version: 2.1.2
+ resolution: "@floating-ui/react-dom@npm:2.1.2"
+ dependencies:
+ "@floating-ui/dom": "npm:^1.0.0"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 10c0/e855131c74e68cab505f7f44f92cd4e2efab1c125796db3116c54c0859323adae4bf697bf292ee83ac77b9335a41ad67852193d7aeace90aa2e1c4a640cafa60
+ languageName: node
+ linkType: hard
+
+"@floating-ui/react@npm:^0.26.24":
+ version: 0.26.24
+ resolution: "@floating-ui/react@npm:0.26.24"
+ dependencies:
+ "@floating-ui/react-dom": "npm:^2.1.2"
+ "@floating-ui/utils": "npm:^0.2.8"
+ tabbable: "npm:^6.0.0"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 10c0/c5c3ac265802087673a69b0e08b3bea1ee02de9da4cdbc40bb1c9e06823be72628a82f1655b40d56a4383715b4ab3b6deddff4e69146f513970ee592e1dd8f92
+ languageName: node
+ linkType: hard
+
+"@floating-ui/utils@npm:^0.2.8":
+ version: 0.2.8
+ resolution: "@floating-ui/utils@npm:0.2.8"
+ checksum: 10c0/a8cee5f17406c900e1c3ef63e3ca89b35e7a2ed645418459a73627b93b7377477fc888081011c6cd177cac45ec2b92a6cab018c14ea140519465498dddd2d3f9
+ languageName: node
+ linkType: hard
+
"@gar/promisify@npm:^1.1.3":
version: 1.1.3
resolution: "@gar/promisify@npm:1.1.3"
@@ -10567,6 +10619,13 @@ __metadata:
languageName: node
linkType: hard
+"tabbable@npm:^6.0.0":
+ version: 6.2.0
+ resolution: "tabbable@npm:6.2.0"
+ checksum: 10c0/ced8b38f05f2de62cd46836d77c2646c42b8c9713f5bd265daf0e78ff5ac73d3ba48a7ca45f348bafeef29b23da7187c72250742d37627883ef89cbd7fa76898
+ languageName: node
+ linkType: hard
+
"tar@npm:^6.0.5, tar@npm:^6.1.11, tar@npm:^6.1.12, tar@npm:^6.1.2, tar@npm:^6.2.1":
version: 6.2.1
resolution: "tar@npm:6.2.1"
@@ -11460,6 +11519,7 @@ __metadata:
dependencies:
"@chromatic-com/storybook": "npm:^2.0.2"
"@eslint/js": "npm:^9.12.0"
+ "@floating-ui/react": "npm:^0.26.24"
"@monaco-editor/loader": "npm:^1.4.0"
"@monaco-editor/react": "npm:^4.6.0"
"@observablehq/plot": "npm:^0.6.16"