diff --git a/client/packages/lowcoder-design/src/components/option.tsx b/client/packages/lowcoder-design/src/components/option.tsx index 4e62301f8..d35ee0be1 100644 --- a/client/packages/lowcoder-design/src/components/option.tsx +++ b/client/packages/lowcoder-design/src/components/option.tsx @@ -9,7 +9,7 @@ import { CSS } from "@dnd-kit/utilities"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { ConstructorToComp, MultiCompConstructor } from "lowcoder-core"; import { ReactComponent as WarnIcon } from "icons/v1/icon-warning-white.svg"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { ActiveTextColor, GreyTextColor } from "constants/style"; import { trans } from "i18n/design"; @@ -225,12 +225,12 @@ function Option>(props: { } return -1; }; - const handleDragEnd = (e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = (e: DragEndEvent) => { if (!e.over) { return; } - const fromIndex = findIndex(e.active.id); - const toIndex = findIndex(e.over.id); + const fromIndex = findIndex(String(e.active.id)); + const toIndex = findIndex(String(e.over.id)); if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { return; } diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index dd5f62eed..b23a7ff82 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -16,10 +16,10 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.5.4", "@codemirror/search": "^6.5.5", - "@dnd-kit/core": "^5.0.1", + "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^7.0.0", - "@dnd-kit/sortable": "^6.0.0", - "@dnd-kit/utilities": "^3.1.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1", @@ -48,6 +48,7 @@ "copy-to-clipboard": "^3.3.3", "core-js": "^3.25.2", "dayjs": "^1.11.13", + "dnd-kit-sortable-tree": "^0.1.73", "echarts": "^5.4.3", "echarts-for-react": "^3.0.2", "echarts-wordcloud": "^2.1.0", diff --git a/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx b/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx index 6d4f2fc9a..46ca12d17 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx @@ -28,7 +28,7 @@ import log from "loglevel"; import { Datasource } from "@lowcoder-ee/constants/datasourceConstants"; import DataSourceIcon from "components/DataSourceIcon"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -599,7 +599,7 @@ const CreateFormBody = (props: { onCreate: CreateHandler }) => { setItems(initItems); }, [dataSourceTypeConfig, tableStructure, form]); - const handleDragEnd = useCallback((e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = useCallback((e: DragEndEvent) => { if (!e.over) { return; } diff --git a/client/packages/lowcoder/src/comps/comps/layout/layoutMenuItemComp.tsx b/client/packages/lowcoder/src/comps/comps/layout/layoutMenuItemComp.tsx index 0999a4012..dc8bee225 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/layoutMenuItemComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/layoutMenuItemComp.tsx @@ -1,6 +1,7 @@ import { MultiBaseComp } from "lowcoder-core"; import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; -import { valueComp } from "comps/generators"; +import { BoolControl } from "comps/controls/boolControl"; +import { valueComp, withPropertyViewFn } from "comps/generators"; import { list } from "comps/generators/list"; import { parseChildrenFromValueAndChildrenMap, @@ -16,16 +17,21 @@ import { genRandomKey } from "comps/utils/idGenerator"; import { LayoutActionComp } from "comps/comps/layout/layoutActionComp"; import { migrateOldData } from "comps/generators/simpleGenerators"; +// BoolControl without property view (internal state only) +const CollapsedControl = withPropertyViewFn(BoolControl, () => null); + const childrenMap = { label: StringControl, hidden: BoolCodeControl, action: LayoutActionComp, + collapsed: CollapsedControl, // tree editor collapsed state itemKey: valueComp(""), icon: IconControl, }; type ChildrenType = ToInstanceType & { items: InstanceType; + collapsed: InstanceType; }; /** @@ -73,6 +79,14 @@ export class LayoutMenuItemComp extends MultiBaseComp { getItemKey() { return this.children.itemKey.getView(); } + + getCollapsed(): boolean { + return this.children.collapsed.getView(); + } + + setCollapsed(collapsed: boolean) { + this.children.collapsed.dispatchChangeValueAction(collapsed); + } } const LayoutMenuItemCompMigrate = migrateOldData(LayoutMenuItemComp, (oldData: any) => { diff --git a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx index 4f0879879..4a7e2b355 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx @@ -247,6 +247,12 @@ let NavTmpLayout = (function () { { label: trans("menuItem") + " 1", itemKey: genRandomKey(), + items: [ + { + label: trans("subMenuItem") + " 1", + itemKey: genRandomKey(), + }, + ], }, ]), jsonItems: jsonControl(convertTreeData, jsonMenuItems), diff --git a/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx b/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx index 44ff6753d..849bb4322 100644 --- a/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx +++ b/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx @@ -22,7 +22,7 @@ import { useMergeCompStyles } from "@lowcoder-ee/util/hooks"; import { childrenToProps } from "@lowcoder-ee/comps/generators/multi"; import { AnimationStyleType } from "@lowcoder-ee/comps/controls/styleControlConstants"; import { getBackgroundStyle } from "@lowcoder-ee/util/styleUtils"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { JSONObject } from "@lowcoder-ee/util/jsonTypes"; @@ -354,7 +354,7 @@ export function ListView(props: Props) { useMergeCompStyles(childrenProps, comp.dispatch); - const handleDragEnd = (e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = (e: DragEndEvent) => { if (!e.over) { return; } diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx deleted file mode 100644 index 7a4c6ba1b..000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { DragIcon } from "lowcoder-design"; -import React, { Ref } from "react"; -import { HTMLAttributes, ReactNode } from "react"; -import styled from "styled-components"; - -const Wrapper = styled.div<{ $dragging: boolean; $isOver: boolean; $dropInAsSub: boolean }>` - position: relative; - width: 100%; - height: 30px; - border: 1px solid #d7d9e0; - border-radius: 4px; - margin-bottom: 4px; - display: flex; - padding: 0 8px; - background-color: #ffffff; - align-items: center; - opacity: ${(props) => (props.$dragging ? "0.5" : 1)}; - - &::after { - content: ""; - display: ${(props) => (props.$isOver ? "block" : "none")}; - height: 4px; - border-radius: 4px; - position: absolute; - left: ${(props) => (props.$dropInAsSub ? "15px" : "-1px")}; - right: 0; - background-color: #315efb; - bottom: -5px; - } - - .draggable-handle-icon { - &:hover, - &:focus { - cursor: grab; - } - - &, - & > svg { - width: 16px; - height: 16px; - } - } - - .draggable-text { - color: #333; - font-size: 13px; - margin-left: 4px; - height: 100%; - display: flex; - align-items: center; - flex: 1; - overflow: hidden; - cursor: pointer; - - & > div { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - display: inline-block; - height: 28px; - line-height: 28px; - } - } - - .draggable-extra-icon { - cursor: pointer; - - &, - & > svg { - width: 16px; - height: 16px; - } - } -`; - -interface IProps extends HTMLAttributes { - dragContent: ReactNode; - isOver?: boolean; - extra?: ReactNode; - dragging?: boolean; - dropInAsSub?: boolean; - dragListeners?: Record; -} - -function DraggableItem(props: IProps, ref: Ref) { - const { - dragContent: text, - extra, - dragging = false, - dropInAsSub = true, - isOver = false, - dragListeners, - ...divProps - } = props; - return ( - -
- -
-
{text}
-
{extra}
-
- ); -} - -export default React.forwardRef(DraggableItem); diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx deleted file mode 100644 index c4f22191a..000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useDraggable, useDroppable } from "@dnd-kit/core"; -import { trans } from "i18n"; -import { Fragment, useEffect } from "react"; -import styled from "styled-components"; -import DroppablePlaceholder from "./DroppablePlaceHolder"; -import MenuItem, { ICommonItemProps } from "./MenuItem"; -import { IDragData, IDropData } from "./types"; -import { LayoutMenuItemComp } from "comps/comps/layout/layoutMenuItemComp"; -import { genRandomKey } from "comps/utils/idGenerator"; - -const DraggableMenuItemWrapper = styled.div` - position: relative; -`; - -interface IDraggableMenuItemProps extends ICommonItemProps { - level: number; - active?: boolean; - disabled?: boolean; - disableDropIn?: boolean; - parentDragging?: boolean; -} - -export default function DraggableMenuItem(props: IDraggableMenuItemProps) { - const { - item, - path, - active, - disabled, - parentDragging, - disableDropIn, - dropInAsSub = true, - onAddSubMenu, - onDelete, - } = props; - - const id = path.join("_"); - const items = item.getView().items; - - const handleAddSubMenu = (path: number[]) => { - onAddSubMenu?.(path, { - label: trans("droppadbleMenuItem.subMenu", { number: items.length + 1 }), - }); - }; - - const dragData: IDragData = { - path, - item, - }; - const { - listeners: dragListeners, - setNodeRef: setDragNodeRef, - isDragging, - } = useDraggable({ - id, - data: dragData, - }); - - const dropData: IDropData = { - targetListSize: items.length, - targetPath: dropInAsSub ? [...path, 0] : [...path.slice(0, -1), path[path.length - 1] + 1], - dropInAsSub, - }; - const { setNodeRef: setDropNodeRef, isOver } = useDroppable({ - id, - disabled: isDragging || disabled || disableDropIn, - data: dropData, - }); - - // TODO: Remove this later. - // Set ItemKey for previously added sub-menus - useEffect(() => { - if(!items.length) return; - if(!(items[0] instanceof LayoutMenuItemComp)) return; - - return items.forEach(item => { - const subItem = item as LayoutMenuItemComp; - const itemKey = subItem.children.itemKey.getView(); - if(itemKey === '') { - subItem.children.itemKey.dispatchChangeValueAction(genRandomKey()) - } - }) - }, [items]) - - return ( - <> - - {active && ( - - )} - { - setDragNodeRef(node); - setDropNodeRef(node); - }} - isOver={isOver} - dropInAsSub={dropInAsSub} - dragging={isDragging || parentDragging} - dragListeners={{ ...dragListeners }} - onAddSubMenu={onAddSubMenu && handleAddSubMenu} - onDelete={onDelete} - /> - - {items.length > 0 && ( -
- {items.map((subItem, i) => ( - - - - ))} -
- )} - - ); -} diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx deleted file mode 100644 index 72c15cf85..000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useDroppable } from "@dnd-kit/core"; -import styled from "styled-components"; -import { IDropData } from "./types"; - -interface IDroppablePlaceholderProps { - path: number[]; - disabled?: boolean; - targetListSize: number; -} - -const PlaceHolderWrapper = styled.div<{ $active: boolean }>` - position: absolute; - width: 100%; - top: -4px; - height: 25px; - z-index: 10; - /* background-color: rgba(0, 0, 0, 0.2); */ - .position-line { - height: 4px; - border-radius: 4px; - background-color: ${(props) => (props.$active ? "#315efb" : "transparent")}; - width: 100%; - } -`; - -export default function DroppablePlaceholder(props: IDroppablePlaceholderProps) { - const { path, disabled, targetListSize } = props; - const data: IDropData = { - targetPath: path, - targetListSize, - dropInAsSub: false, - }; - const { setNodeRef: setDropNodeRef, isOver } = useDroppable({ - id: `p_${path.join("_")}`, - disabled, - data, - }); - return ( - -
-
- ); -} diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx index b328c30b0..37cdfc168 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx @@ -1,24 +1,15 @@ import { ActiveTextColor, GreyTextColor } from "constants/style"; import { EditPopover, SimplePopover } from "lowcoder-design"; import { PointIcon } from "lowcoder-design"; -import React, { HTMLAttributes, useState } from "react"; +import React, { useState } from "react"; import styled from "styled-components"; -import DraggableItem from "./DraggableItem"; import { NavCompType } from "comps/comps/navComp/components/types"; import { trans } from "i18n"; -export interface ICommonItemProps { - path: number[]; +export interface IMenuItemProps { item: NavCompType; - dropInAsSub?: boolean; - onDelete?: (path: number[]) => void; - onAddSubMenu?: (path: number[], value?: any) => void; -} - -interface IMenuItemProps extends ICommonItemProps, Omit, "id"> { - isOver?: boolean; - dragging?: boolean; - dragListeners?: Record; + onDelete?: () => void; + onAddSubMenu?: () => void; } const MenuItemWrapper = styled.div` @@ -29,6 +20,13 @@ const MenuItemWrapper = styled.div` const MenuItemContent = styled.div` width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + flex: 1; + color: #333; + font-size: 13px; `; const StyledPointIcon = styled(PointIcon)` @@ -39,61 +37,50 @@ const StyledPointIcon = styled(PointIcon)` } `; -const MenuItem = React.forwardRef((props: IMenuItemProps, ref: React.Ref) => { +const MenuItem: React.FC = (props) => { const { - path, item, - isOver, - dragging, - dragListeners, - dropInAsSub = true, onDelete, onAddSubMenu, - ...divProps } = props; const [isConfigPopShow, showConfigPop] = useState(false); const handleDel = () => { - onDelete?.(path); + onDelete?.(); }; const handleAddSubMenu = () => { - onAddSubMenu?.(path); + onAddSubMenu?.(); }; const content = {item.getPropertyView()}; return ( - - {item.children.label.getView()} - - } - extra={ - + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} > - - - } - /> + {item.children.label.getView() || trans("untitled")} + + + + + + ); -}); +}; export default MenuItem; diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 4c9d0de1e..f5025ed6b 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -1,135 +1,183 @@ -import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core"; +import { SortableTree, TreeItems, TreeItemComponentProps, SimpleTreeItemWrapper } from "dnd-kit-sortable-tree"; import LinkPlusButton from "components/LinkPlusButton"; -import { BluePlusIcon, controlItem } from "lowcoder-design"; +import { BluePlusIcon, controlItem, ScrollBar } from "lowcoder-design"; import { trans } from "i18n"; -import _ from "lodash"; -import { useState } from "react"; +import React, { useMemo, useCallback, createContext, useContext } from "react"; import styled from "styled-components"; -import DraggableMenuItem from "./DroppableMenuItem"; -import DroppablePlaceholder from "./DroppablePlaceHolder"; +import { NavCompType, NavListCompType, NavTreeItemData } from "./types"; import MenuItem from "./MenuItem"; -import { IDragData, IDropData, NavCompType, NavListCompType } from "./types"; - const Wrapper = styled.div` .menu-title { display: flex; flex-direction: row; justify-content: space-between; align-items: center; + margin-bottom: 8px; } .menu-list { - margin-top: 8px; position: relative; } +`; - .sub-menu-list { - padding-left: 16px; +const StyledTreeItem = styled.div` + .dnd-sortable-tree_simple_tree-item { + padding: 5px; + border-radius: 4px; + &:hover { + background-color: #f5f5f6; + } } `; +const TreeItemContent = styled.div` + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; +`; + +// Context for passing handlers to tree items +interface MenuItemHandlers { + onDeleteItem: (path: number[]) => void; + onAddSubItem: (path: number[], value?: any) => void; +} + +const MenuItemHandlersContext = createContext(null); + +// Tree item component +const NavTreeItemComponent = React.forwardRef< + HTMLDivElement, + TreeItemComponentProps +>((props, ref) => { + const { item, depth, collapsed, ...rest } = props; + const { comp, path } = item; + + const handlers = useContext(MenuItemHandlersContext); + + const handleDelete = () => { + handlers?.onDeleteItem(path); + }; + + const handleAddSubMenu = () => { + handlers?.onAddSubItem(path, { + label: `Sub Menu ${(item.children?.length || 0) + 1}`, + }); + }; + + return ( + + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + + + + ); +}); + +NavTreeItemComponent.displayName = "NavTreeItemComponent"; + interface IMenuItemListProps { items: NavCompType[]; onAddItem: (path: number[], value?: any) => number; onDeleteItem: (path: number[]) => void; onAddSubItem: (path: number[], value: any, unshift?: boolean) => number; - onMoveItem: (path: number[], from: number, to: number) => void; + onReorderItems: (newOrder: TreeItems) => void; } const menuItemLabel = trans("navigation.itemsDesc"); -function MenuItemList(props: IMenuItemListProps) { - const { items, onAddItem, onDeleteItem, onMoveItem, onAddSubItem } = props; +// Convert NavCompType[] to TreeItems format for dnd-kit-sortable-tree +function convertToTreeItems( + items: NavCompType[], + basePath: number[] = [] +): TreeItems { + return items.map((item, index) => { + const path = [...basePath, index]; + const subItems = item.getView().items || []; + // Read collapsed state from the item itself + const collapsed = item.getCollapsed?.() ?? false; + + return { + id: path.join("_"), + collapsed, + comp: item, + path: path, + children: subItems.length > 0 + ? convertToTreeItems(subItems, path) + : [], + }; + }); +} - const [active, setActive] = useState(null); - const isDraggingWithSub = active && active.item.children.items.getView().length > 0; +function MenuItemList(props: IMenuItemListProps) { + const { items, onAddItem, onDeleteItem, onAddSubItem, onReorderItems } = props; - function handleDragStart(event: DragStartEvent) { - setActive(event.active.data.current as IDragData); - } + // Convert items to tree format + const treeItems = useMemo(() => convertToTreeItems(items), [items]); - function handleDragEnd(e: DragEndEvent) { - const activeData = e.active.data.current as IDragData; - const overData = e.over?.data.current as IDropData; - - if (overData) { - const sourcePath = activeData.path; - const targetPath = overData.targetPath; - - if ( - sourcePath.length === targetPath.length && - _.isEqual(sourcePath.slice(0, -1), targetPath.slice(0, -1)) - ) { - // same level move - const from = sourcePath[sourcePath.length - 1]; - let to = targetPath[targetPath.length - 1]; - if (from < to) { - to -= 1; - } - onMoveItem(targetPath, from, to); - } else { - // cross level move - let targetIndex = targetPath[targetPath.length - 1]; - let targetListPath = targetPath; - let size = 0; - - onDeleteItem(sourcePath); - - if (overData.dropInAsSub) { - targetListPath = targetListPath.slice(0, -1); - size = onAddSubItem(targetListPath, activeData.item.toJsonValue()); - } else { - size = onAddItem(targetListPath, activeData.item.toJsonValue()); - } - - if (overData.targetListSize !== -1) { - onMoveItem(targetListPath, size, targetIndex); - } - } - } + // Handle all tree changes (drag/drop, collapse/expand) + const handleItemsChanged = useCallback( + (newItems: TreeItems) => { + onReorderItems(newItems); + }, + [onReorderItems] + ); - setActive(null); - } + // Handlers context value + const handlers = useMemo( + () => ({ + onDeleteItem, + onAddSubItem, + }), + [onDeleteItem, onAddSubItem] + ); return ( - -
-
{menuItemLabel}
- onAddItem([0])} icon={}> - {trans("newItem")} - -
-
- {items.map((i, idx) => { - return ( - - ); - })} -
- {active && } -
-
- - {active && } - -
+
+
{menuItemLabel}
+ onAddItem([0])} icon={}> + {trans("newItem")} + +
+
+ + + false }} + /> + + +
); } export function menuPropertyView(itemsComp: NavListCompType) { const items = itemsComp.getView(); + const getItemByPath = (path: number[], scope?: NavCompType[]): NavCompType => { if (!scope) { scope = items; @@ -150,6 +198,25 @@ export function menuPropertyView(itemsComp: NavListCompType) { return getItemListByPath(path.slice(1), root.getView()[path[0]].children.items); }; + // Convert tree structure back to nested comp structure + const handleReorderItems = (newItems: TreeItems) => { + const buildJsonFromTree = (treeItems: TreeItems): any[] => { + return treeItems.map((item) => { + const jsonValue = item.comp.toJsonValue() as Record; + return { + ...jsonValue, + collapsed: item.collapsed ?? false, // sync collapsed from tree item + items: item.children && item.children.length > 0 + ? buildJsonFromTree(item.children) + : [], + }; + }); + }; + + const newJson = buildJsonFromTree(newItems); + itemsComp.dispatch(itemsComp.setChildrensAction(newJson)); + }; + return controlItem( { filterText: menuItemLabel }, { - getItemListByPath(path).moveItem(from, to); - }} + onReorderItems={handleReorderItems} /> ); } diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts b/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts index 09640aac3..86e45194c 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts @@ -1,5 +1,6 @@ import { NavItemComp, navListComp } from "../navItemComp"; import { LayoutMenuItemComp, LayoutMenuItemListComp } from "comps/comps/layout/layoutMenuItemComp"; +import { TreeItem } from "dnd-kit-sortable-tree"; export type NavCompType = NavItemComp | LayoutMenuItemComp; @@ -15,13 +16,12 @@ export interface NavCompItemType { onEvent: (name: string) => void; } -export interface IDropData { - targetListSize: number; - targetPath: number[]; - dropInAsSub: boolean; -} - -export interface IDragData { - item: NavCompType; +// Tree item data for dnd-kit-sortable-tree +export interface NavTreeItemData { + comp: NavCompType; path: number[]; + collapsed?: boolean; } + +// Full tree item type for the sortable tree +export type NavTreeItem = TreeItem; diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx index f852fe694..846cc8c1e 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx @@ -66,7 +66,6 @@ ${props=>props.$animationStyle} const DEFAULT_SIZE = 378; -// If it is a number, use the px unit by default function transToPxSize(size: string | number) { return isNumeric(size) ? size + "px" : (size as string); } @@ -114,6 +113,7 @@ const Item = styled.div<{ $border?: string; $hoverBorder?: string; $activeBorder?: string; + $borderWidth?: string; $radius?: string; $disabled?: boolean; }>` @@ -121,9 +121,13 @@ const Item = styled.div<{ padding: ${(props) => props.$padding || '0 16px'}; color: ${(props) => props.$disabled ? `${props.$color}80` : (props.$active ? props.$activeColor : props.$color)}; background-color: ${(props) => (props.$active ? (props.$activeBg || 'transparent') : (props.$bg || 'transparent'))}; - border: ${(props) => props.$active - ? (props.$activeBorder ? `1px solid ${props.$activeBorder}` : (props.$border ? `1px solid ${props.$border}` : '1px solid transparent')) - : (props.$border ? `1px solid ${props.$border}` : '1px solid transparent')}; + border: ${(props) => { + const width = props.$borderWidth || '1px'; + if (props.$active) { + return props.$activeBorder ? `${width} solid ${props.$activeBorder}` : (props.$border ? `${width} solid ${props.$border}` : `${width} solid transparent`); + } + return props.$border ? `${width} solid ${props.$border}` : `${width} solid transparent`; + }}; border-radius: ${(props) => props.$radius || '0px'}; font-weight: ${(props) => props.$active ? (props.$activeTextWeight || props.$textWeight || 500) @@ -145,7 +149,13 @@ const Item = styled.div<{ &:hover { color: ${(props) => props.$disabled ? (props.$active ? props.$activeColor : props.$color) : (props.$hoverColor || props.$activeColor)}; background-color: ${(props) => props.$disabled ? (props.$active ? (props.$activeBg || 'transparent') : (props.$bg || 'transparent')) : (props.$hoverBg || props.$activeBg || props.$bg || 'transparent')}; - border: ${(props) => props.$hoverBorder ? `1px solid ${props.$hoverBorder}` : (props.$activeBorder ? `1px solid ${props.$activeBorder}` : (props.$border ? `1px solid ${props.$border}` : '1px solid transparent'))}; + border: ${(props) => { + const width = props.$borderWidth || '1px'; + if (props.$hoverBorder) return `${width} solid ${props.$hoverBorder}`; + if (props.$activeBorder) return `${width} solid ${props.$activeBorder}`; + if (props.$border) return `${width} solid ${props.$border}`; + return `${width} solid transparent`; + }}; cursor: ${(props) => props.$disabled ? 'not-allowed' : 'pointer'}; font-weight: ${(props) => props.$disabled ? undefined : (props.$hoverTextWeight || props.$textWeight || 500)}; font-family: ${(props) => props.$disabled ? undefined : (props.$hoverFontFamily || props.$fontFamily || 'sans-serif')}; @@ -213,7 +223,6 @@ const StyledMenu = styled(Menu) < color: ${(p) => p.$color}; background-color: ${(p) => p.$bg || "transparent"}; border-radius: ${(p) => p.$radius || "0px"}; - border: ${(p) => p.$border ? `1px solid ${p.$border}` : "1px solid transparent"}; font-weight: ${(p) => p.$textWeight || 500}; font-family: ${(p) => p.$fontFamily || "sans-serif"}; font-style: ${(p) => p.$fontStyle || "normal"}; @@ -227,7 +236,6 @@ const StyledMenu = styled(Menu) < .ant-dropdown-menu-item:hover { color: ${(p) => p.$hoverColor || p.$color}; background-color: ${(p) => p.$hoverBg || p.$bg || "transparent"} !important; - border: ${(p) => p.$hoverBorder ? `1px solid ${p.$hoverBorder}` : (p.$border ? `1px solid ${p.$border}` : "1px solid transparent")}; font-weight: ${(p) => p.$hoverTextWeight || p.$textWeight || 500}; font-family: ${(p) => p.$hoverFontFamily || p.$fontFamily || "sans-serif"}; font-style: ${(p) => p.$hoverFontStyle || p.$fontStyle || "normal"}; @@ -240,7 +248,6 @@ const StyledMenu = styled(Menu) < .ant-menu-item-selected { color: ${(p) => p.$activeColor}; background-color: ${(p) => p.$activeBg || p.$bg || "transparent"}; - border: ${(p) => p.$activeBorder ? `1px solid ${p.$activeBorder}` : (p.$border ? `1px solid ${p.$border}` : "1px solid transparent")}; font-weight: ${(p) => p.$activeTextWeight || p.$textWeight || 500}; font-family: ${(p) => p.$activeFontFamily || p.$fontFamily || "sans-serif"}; font-style: ${(p) => p.$activeFontStyle || p.$fontStyle || "normal"}; @@ -508,6 +515,25 @@ const childrenMap = { manual: [ { label: trans("menuItem") + " 1", + items: [ + { + label: trans("subMenuItem") + " 1", + items: [ + { + label: trans("subMenuItem") + " 1-1", + }, + { + label: trans("subMenuItem") + " 1-2", + }, + ], + }, + { + label: trans("subMenuItem") + " 2", + }, + { + label: trans("subMenuItem") + " 3", + }, + ], }, ], }), @@ -530,32 +556,34 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { return null; } - const label = view?.label; + const label = view?.label || trans("untitled"); const icon = hasIcon(view?.icon) ? view.icon : undefined; const active = !!view?.active; const onEvent = view?.onEvent; const disabled = !!view?.disabled; const subItems = isCompItem ? view?.items : []; - const subMenuItems: Array<{ key: string; label: any; icon?: any; disabled?: boolean }> = []; const subMenuSelectedKeys: Array = []; - - if (Array.isArray(subItems)) { - subItems.forEach((subItem: any, originalIndex: number) => { - if (subItem.children.hidden.getView()) { - return; - } - const key = originalIndex + ""; - subItem.children.active.getView() && subMenuSelectedKeys.push(key); - const subIcon = hasIcon(subItem.children.icon?.getView?.()) ? subItem.children.icon.getView() : undefined; - subMenuItems.push({ - key: key, - label: subItem.children.label.getView(), - icon: subIcon, - disabled: !!subItem.children.disabled.getView(), - }); - }); - } + const buildSubMenuItems = (list: any[], prefix = ""): Array => { + if (!Array.isArray(list)) return []; + return list + .map((subItem: any, originalIndex: number) => { + if (subItem.children.hidden.getView()) return null; + const key = prefix ? `${prefix}-${originalIndex}` : `${originalIndex}`; + subItem.children.active.getView() && subMenuSelectedKeys.push(key); + const subIcon = hasIcon(subItem.children.icon?.getView?.()) ? subItem.children.icon.getView() : undefined; + const children = buildSubMenuItems(subItem.getView()?.items, key); + return { + key, + label: subItem.children.label.getView() || trans("untitled"), + icon: subIcon, + disabled: !!subItem.children.disabled.getView(), + ...(children.length > 0 ? { children } : {}), + }; + }) + .filter(Boolean); + }; + const subMenuItems: Array = buildSubMenuItems(subItems); const item = ( { $hoverBorder={props.navItemHoverStyle?.border} $activeBorder={props.navItemActiveStyle?.border} $radius={props.navItemStyle?.radius} + $borderWidth={props.navItemStyle?.borderWidth} $disabled={disabled} onClick={() => { if (!disabled && onEvent) onEvent("click"); }} > @@ -598,14 +627,21 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { ); if (subMenuItems.length > 0) { const subMenu = ( - + { if (disabled) return; - const subItem = subItems[Number(e.key)]; - const isSubDisabled = !!subItem?.children?.disabled?.getView?.(); + const parts = String(e.key).split("-").filter(Boolean); + let currentList: any[] = subItems; + let current: any = null; + for (const part of parts) { + current = currentList?.[Number(part)]; + if (!current) return; + currentList = current.getView()?.items || []; + } + const isSubDisabled = !!current?.children?.disabled?.getView?.(); if (isSubDisabled) return; - const onSubEvent = subItem?.getView()?.onEvent; + const onSubEvent = current?.getView?.()?.onEvent; onSubEvent && onSubEvent("click"); }} selectedKeys={subMenuSelectedKeys} diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx index 6b6458094..1bd02bed9 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx @@ -1,8 +1,10 @@ import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolControl } from "comps/controls/boolControl"; import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { withPropertyViewFn } from "comps/generators"; import { list } from "comps/generators/list"; import { parseChildrenFromValueAndChildrenMap, ToViewReturn } from "comps/generators/multi"; -import { withDefault } from "comps/generators/simpleGenerators"; +import { migrateOldData, withDefault } from "comps/generators/simpleGenerators"; import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; import { trans } from "i18n"; import _ from "lodash"; @@ -12,12 +14,16 @@ import { IconControl } from "comps/controls/iconControl"; const events = [clickEvent]; +// BoolControl without property view (internal state only) +const CollapsedControl = withPropertyViewFn(BoolControl, () => null); + const childrenMap = { label: StringControl, icon: IconControl, hidden: BoolCodeControl, disabled: BoolCodeControl, active: BoolCodeControl, + collapsed: CollapsedControl, // tree editor collapsed state onEvent: withDefault(eventHandlerControl(events), [ { // name: "click", @@ -35,6 +41,7 @@ type ChildrenType = { hidden: InstanceType; disabled: InstanceType; active: InstanceType; + collapsed: InstanceType; onEvent: InstanceType>; items: InstanceType>; }; @@ -72,6 +79,14 @@ export class NavItemComp extends MultiBaseComp { this.children.items.addItem(value); } + getCollapsed(): boolean { + return this.children.collapsed.getView(); + } + + setCollapsed(collapsed: boolean) { + this.children.collapsed.dispatchChangeValueAction(collapsed); + } + exposingNode(): RecordNode { return fromRecord({ label: this.children.label.exposingNode(), @@ -93,17 +108,31 @@ type NavItemExposing = { items: Node[]>; }; +// Migrate old nav items to strip out deprecated itemKey field +function migrateNavItemData(oldData: any): any { + if (!oldData) return oldData; + + const { itemKey, ...rest } = oldData; + + // Also migrate nested items recursively + if (Array.isArray(rest.items)) { + rest.items = rest.items.map((item: any) => migrateNavItemData(item)); + } + + return rest; +} + +const NavItemCompMigrated = migrateOldData(NavItemComp, migrateNavItemData); + export function navListComp() { - const NavItemListCompBase = list(NavItemComp); + const NavItemListCompBase = list(NavItemCompMigrated); return class NavItemListComp extends NavItemListCompBase { addItem(value?: any) { const data = this.getView(); this.dispatch( this.pushAction( - value || { - label: trans("menuItem") + " " + (data.length + 1), - } + value || { label: trans("menuItem") + " " + (data.length + 1) } ) ); } diff --git a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx index 834b74548..01587643d 100644 --- a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx +++ b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx @@ -2362,6 +2362,7 @@ export const NavLayoutItemStyle = [ getBackground("primarySurface"), getStaticBorder("transparent"), RADIUS, + BORDER_WIDTH, { name: "text", label: trans("text"), @@ -2412,60 +2413,6 @@ export const NavLayoutItemActiveStyle = [ TEXT_DECORATION, ] as const; -// Submenu item styles (normal/hover/active), similar to top-level menu items -export const NavSubMenuItemStyle = [ - getBackground("primarySurface"), - getStaticBorder("transparent"), - RADIUS, - { - name: "text", - label: trans("text"), - depName: "background", - depType: DEP_TYPE.CONTRAST_TEXT, - transformer: contrastText, - }, - TEXT_SIZE, - TEXT_WEIGHT, - FONT_FAMILY, - FONT_STYLE, - TEXT_DECORATION, - MARGIN, - PADDING, -] as const; - -export const NavSubMenuItemHoverStyle = [ - getBackground("canvas"), - getStaticBorder("transparent"), - { - name: "text", - label: trans("text"), - depName: "background", - depType: DEP_TYPE.CONTRAST_TEXT, - transformer: contrastText, - }, - TEXT_SIZE, - TEXT_WEIGHT, - FONT_FAMILY, - FONT_STYLE, - TEXT_DECORATION, -] as const; - -export const NavSubMenuItemActiveStyle = [ - getBackground("primary"), - getStaticBorder("transparent"), - { - name: "text", - label: trans("text"), - depName: "background", - depType: DEP_TYPE.CONTRAST_TEXT, - transformer: contrastText, - }, - TEXT_SIZE, - TEXT_WEIGHT, - FONT_FAMILY, - FONT_STYLE, - TEXT_DECORATION, -] as const; export const CarouselStyle = [getBackground("canvas")] as const; @@ -2606,9 +2553,6 @@ export type NavLayoutItemHoverStyleType = StyleConfigType< export type NavLayoutItemActiveStyleType = StyleConfigType< typeof NavLayoutItemActiveStyle >; -export type NavSubMenuItemStyleType = StyleConfigType; -export type NavSubMenuItemHoverStyleType = StyleConfigType; -export type NavSubMenuItemActiveStyleType = StyleConfigType; export function widthCalculator(margin: string) { const marginArr = margin?.trim().replace(/\s+/g, " ").split(" ") || ""; diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index a9a7686b8..8cbc26404 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -33,6 +33,8 @@ export const en = { "form": "Form", "menu": "Menu", "menuItem": "Menu Item", + "subMenuItem": "Sub Menu", + "untitled": "Untitled", "ok": "OK", "cancel": "Cancel", "finish": "Finish", diff --git a/client/yarn.lock b/client/yarn.lock index 97711b88b..334a05b0f 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1877,7 +1877,7 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/accessibility@npm:^3.0.0": +"@dnd-kit/accessibility@npm:^3.1.1": version: 3.1.1 resolution: "@dnd-kit/accessibility@npm:3.1.1" dependencies: @@ -1888,17 +1888,17 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/core@npm:^5.0.1": - version: 5.0.3 - resolution: "@dnd-kit/core@npm:5.0.3" +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" dependencies: - "@dnd-kit/accessibility": ^3.0.0 - "@dnd-kit/utilities": ^3.1.0 + "@dnd-kit/accessibility": ^3.1.1 + "@dnd-kit/utilities": ^3.2.2 tslib: ^2.0.0 peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 4ace7c45057ed0a7257ab16b8b0ebf76b135e8d5675d6dd285138b99a17b0edf7f57e02f251b1b17efb055bad32d7c90b96616b6c77b4e775afbfbaddea401c5 + checksum: abe5ca5c63af2652b50df2636111a8eecb1560338f3b57e27af0d4eac31f89a278347049dbd59897aeec262477ef88d7a906a79254360c40480e490ee910947c languageName: node linkType: hard @@ -1915,20 +1915,20 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/sortable@npm:^6.0.0": - version: 6.0.1 - resolution: "@dnd-kit/sortable@npm:6.0.1" +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" dependencies: - "@dnd-kit/utilities": ^3.1.0 + "@dnd-kit/utilities": ^3.2.2 tslib: ^2.0.0 peerDependencies: - "@dnd-kit/core": ^5.0.2 + "@dnd-kit/core": ^6.3.0 react: ">=16.8.0" - checksum: beb80a229a50885a654ff15ee98af3b34b02826cadd6bc2f94b79dd103a140f70c35d0a3bf422adf87327573ff15dc3e26e9e5769e0f67b68943d8eaa9560183 + checksum: c853cb65d2ffb3d58d400d9f1c993b00413932acf5cf5b780c76acf3b1057aa88e7866021c6b178c4b33fc17db7fe7640584dba4449772e02edcb72cc797eeb0 languageName: node linkType: hard -"@dnd-kit/utilities@npm:^3.1.0, @dnd-kit/utilities@npm:^3.2.2": +"@dnd-kit/utilities@npm:^3.2.2": version: 3.2.2 resolution: "@dnd-kit/utilities@npm:3.2.2" dependencies: @@ -7347,7 +7347,7 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.0.4, clsx@npm:^1.1.1": +"clsx@npm:^1.0.4, clsx@npm:^1.1.1, clsx@npm:^1.2.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 @@ -8906,6 +8906,22 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"dnd-kit-sortable-tree@npm:^0.1.73": + version: 0.1.73 + resolution: "dnd-kit-sortable-tree@npm:0.1.73" + dependencies: + clsx: ^1.2.1 + react-merge-refs: ^2.0.1 + peerDependencies: + "@dnd-kit/core": ">=6.0.5" + "@dnd-kit/sortable": ">=7.0.1" + "@dnd-kit/utilities": ">=3.2.0" + react: ">=16" + react-dom: ">=16" + checksum: 86bec921ebb4484f03848fccac21654b9a98ef590978815c45b908a297d28faf88094093545e74387315a70a8f661c497d32987f34573e6cd2bd44aed0314cad + languageName: node + linkType: hard + "dns-packet@npm:^5.2.2": version: 5.6.1 resolution: "dns-packet@npm:5.6.1" @@ -14133,10 +14149,10 @@ coolshapes-react@lowcoder-org/coolshapes-react: "@codemirror/lang-json": ^6.0.1 "@codemirror/lang-sql": ^6.5.4 "@codemirror/search": ^6.5.5 - "@dnd-kit/core": ^5.0.1 + "@dnd-kit/core": ^6.3.1 "@dnd-kit/modifiers": ^7.0.0 - "@dnd-kit/sortable": ^6.0.0 - "@dnd-kit/utilities": ^3.1.0 + "@dnd-kit/sortable": ^10.0.0 + "@dnd-kit/utilities": ^3.2.2 "@fortawesome/fontawesome-svg-core": ^6.5.1 "@fortawesome/free-brands-svg-icons": ^6.5.1 "@fortawesome/free-regular-svg-icons": ^6.5.1 @@ -14174,6 +14190,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: copy-to-clipboard: ^3.3.3 core-js: ^3.25.2 dayjs: ^1.11.13 + dnd-kit-sortable-tree: ^0.1.73 dotenv: ^16.0.3 echarts: ^5.4.3 echarts-for-react: ^3.0.2 @@ -17975,6 +17992,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"react-merge-refs@npm:^2.0.1": + version: 2.1.1 + resolution: "react-merge-refs@npm:2.1.1" + checksum: 40564bc4c16520ef830d4fe7a2bd298c23a42d644a8fcb2353cdc6cf16aa82eac681c554df4c849397b25af9dbe728086566e1a01f65f95d2301dc8fc8f6809f + languageName: node + linkType: hard + "react-player@npm:^2.11.0": version: 2.16.0 resolution: "react-player@npm:2.16.0"