From 476fb0e1b00e320553e6d917b0a8beb6f9c70d74 Mon Sep 17 00:00:00 2001 From: newcat Date: Tue, 2 Jan 2024 04:15:56 +0100 Subject: [PATCH] Bring back the context menu (#240) --- packages/renderer-vue/playground/App.vue | 6 + .../src/components/ContextMenu.vue | 230 ++++++++---------- packages/renderer-vue/src/contextMenu.ts | 94 +++++++ packages/renderer-vue/src/editor/Editor.vue | 17 +- .../src/nodepalette/NodePalette.vue | 132 +++------- .../src/nodepalette/checkRecursion.ts | 23 -- packages/renderer-vue/src/settings.ts | 78 ++++++ packages/renderer-vue/src/utility/index.ts | 3 +- .../src/utility/useNodeCategories.ts | 80 ++++++ packages/renderer-vue/src/viewModel.ts | 56 +---- 10 files changed, 418 insertions(+), 301 deletions(-) create mode 100644 packages/renderer-vue/src/contextMenu.ts delete mode 100644 packages/renderer-vue/src/nodepalette/checkRecursion.ts create mode 100644 packages/renderer-vue/src/settings.ts create mode 100644 packages/renderer-vue/src/utility/useNodeCategories.ts diff --git a/packages/renderer-vue/playground/App.vue b/packages/renderer-vue/playground/App.vue index 89f09953..07e5cc2a 100644 --- a/packages/renderer-vue/playground/App.vue +++ b/packages/renderer-vue/playground/App.vue @@ -53,6 +53,12 @@ baklavaView.settings.enableMinimap = true; baklavaView.settings.sidebar.resizable = false; baklavaView.settings.displayValueOnHover = true; baklavaView.settings.nodes.resizable = true; +baklavaView.settings.contextMenu.additionalItems = [ + { isDivider: true }, + { label: "Copy", command: Commands.COPY_COMMAND }, + { label: "Paste", command: Commands.PASTE_COMMAND }, +]; + const engine = new DependencyEngine(editor); engine.events.afterRun.subscribe(token, (r) => { engine.pause(); diff --git a/packages/renderer-vue/src/components/ContextMenu.vue b/packages/renderer-vue/src/components/ContextMenu.vue index 87301b67..b0390064 100644 --- a/packages/renderer-vue/src/components/ContextMenu.vue +++ b/packages/renderer-vue/src/components/ContextMenu.vue @@ -25,9 +25,9 @@ /> - - diff --git a/packages/renderer-vue/src/contextMenu.ts b/packages/renderer-vue/src/contextMenu.ts new file mode 100644 index 00000000..08edb38e --- /dev/null +++ b/packages/renderer-vue/src/contextMenu.ts @@ -0,0 +1,94 @@ +import { Ref, computed, ref, reactive } from "vue"; +import { AbstractNode } from "@baklavajs/core"; +import { IMenuItem } from "./components/ContextMenu.vue"; +import { IBaklavaViewModel } from "./viewModel"; +import { useNodeCategories, useTransform } from "./utility"; + +export function useContextMenu(viewModel: Ref) { + const show = ref(false); + const x = ref(0); + const y = ref(0); + const categories = useNodeCategories(viewModel); + const { transform } = useTransform(); + + const nodeItems = computed(() => { + let defaultNodes: IMenuItem[] = []; + const categoryItems: Record = {}; + + for (const category of categories.value) { + const mappedNodes = Object.entries(category.nodeTypes).map(([nodeType, info]) => ({ + label: info.title, + value: "addNode:" + nodeType, + })); + if (category.name === "default") { + defaultNodes = mappedNodes; + } else { + categoryItems[category.name] = mappedNodes; + } + } + + const menuItems: IMenuItem[] = [ + ...Object.entries(categoryItems).map(([category, items]) => ({ + label: category, + submenu: items, + })), + ]; + if (menuItems.length > 0 && defaultNodes.length > 0) { + menuItems.push({ isDivider: true }); + } + menuItems.push(...defaultNodes); + + return menuItems; + }); + + const items = computed(() => { + if (viewModel.value.settings.contextMenu.additionalItems.length === 0) { + return nodeItems.value; + } else { + return [ + { label: "Add node", submenu: nodeItems.value }, + ...viewModel.value.settings.contextMenu.additionalItems.map((item) => { + if ("isDivider" in item || "submenu" in item) { + return item; + } else { + return { + label: item.label, + value: "command:" + item.command, + disabled: !viewModel.value.commandHandler.canExecuteCommand(item.command), + }; + } + }), + ]; + } + }); + + function open(ev: MouseEvent) { + show.value = true; + x.value = ev.offsetX; + y.value = ev.offsetY; + } + + function onClick(value: string) { + if (value.startsWith("addNode:")) { + // get node type + const nodeType = value.substring("addNode:".length); + const nodeInformation = viewModel.value.editor.nodeTypes.get(nodeType); + if (!nodeInformation) { + return; + } + + const instance = reactive(new nodeInformation.type()) as AbstractNode; + viewModel.value.displayedGraph.addNode(instance); + const [transformedX, transformedY] = transform(x.value, y.value); + instance.position.x = transformedX; + instance.position.y = transformedY; + } else if (value.startsWith("command:")) { + const command = value.substring("command:".length); + if (viewModel.value.commandHandler.canExecuteCommand(command)) { + viewModel.value.commandHandler.executeCommand(command); + } + } + } + + return { show, x, y, items, open, onClick }; +} diff --git a/packages/renderer-vue/src/editor/Editor.vue b/packages/renderer-vue/src/editor/Editor.vue index 5d82194d..ab5b08fb 100644 --- a/packages/renderer-vue/src/editor/Editor.vue +++ b/packages/renderer-vue/src/editor/Editor.vue @@ -13,6 +13,7 @@ @wheel.self="panZoom.onMouseWheel" @keydown="keyDown" @keyup="keyUp" + @contextmenu.self.prevent="contextMenu.open" > @@ -70,6 +71,17 @@ + + + + @@ -80,6 +92,7 @@ import { AbstractNode } from "@baklavajs/core"; import { IBaklavaViewModel } from "../viewModel"; import { usePanZoom } from "./panZoom"; import { useTemporaryConnection } from "./temporaryConnection"; +import { useContextMenu } from "../contextMenu"; import { providePlugin, useDragMove } from "../utility"; import Background from "./Background.vue"; @@ -90,6 +103,7 @@ import Sidebar from "../sidebar/Sidebar.vue"; import Minimap from "../components/Minimap.vue"; import NodePalette from "../nodepalette/NodePalette.vue"; import Toolbar from "../toolbar/Toolbar.vue"; +import ContextMenu from "../components/ContextMenu.vue"; const props = defineProps<{ viewModel: IBaklavaViewModel }>(); @@ -108,6 +122,7 @@ const selectedNodes = computed(() => props.viewModel.displayedGraph.selectedNode const panZoom = usePanZoom(); const temporaryConnection = useTemporaryConnection(); +const contextMenu = useContextMenu(viewModelRef); const nodeContainerStyle = computed(() => ({ ...panZoom.styles.value, @@ -152,7 +167,7 @@ const keyUp = (ev: KeyboardEvent) => { }; const selectNode = (node: AbstractNode) => { - if (!["Control", "Shift"].some(k => props.viewModel.commandHandler.pressedKeys.includes(k))) { + if (!["Control", "Shift"].some((k) => props.viewModel.commandHandler.pressedKeys.includes(k))) { unselectAllNodes(); } props.viewModel.displayedGraph.selectedNodes.push(node); diff --git a/packages/renderer-vue/src/nodepalette/NodePalette.vue b/packages/renderer-vue/src/nodepalette/NodePalette.vue index 3d498993..8d8daa57 100644 --- a/packages/renderer-vue/src/nodepalette/NodePalette.vue +++ b/packages/renderer-vue/src/nodepalette/NodePalette.vue @@ -20,110 +20,56 @@ - diff --git a/packages/renderer-vue/src/nodepalette/checkRecursion.ts b/packages/renderer-vue/src/nodepalette/checkRecursion.ts deleted file mode 100644 index 8b473789..00000000 --- a/packages/renderer-vue/src/nodepalette/checkRecursion.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Editor, getGraphNodeTypeString, Graph, GRAPH_NODE_TYPE_PREFIX } from "@baklavajs/core"; - -/** This function checks, whether the given GraphNode would cause a recursion if placed in the specified current graph */ -export function checkRecursion(editor: Editor, currentGraph: Graph, graphNodeType: string): boolean { - if (!currentGraph.template) { - // we are in the root graph, no recursion can happen here - return false; - } - - if (getGraphNodeTypeString(currentGraph.template) === graphNodeType) { - return true; - } - - // find the template of the specified graph node - const template = editor.graphTemplates.find((t) => getGraphNodeTypeString(t) === graphNodeType); - if (!template) { - return false; - } - - // find all the graph nodes contained in the templates and check them - const containedGraphNodes = template.nodes.filter((n) => n.type.startsWith(GRAPH_NODE_TYPE_PREFIX)); - return containedGraphNodes.some((n) => checkRecursion(editor, currentGraph, n.type)); -} diff --git a/packages/renderer-vue/src/settings.ts b/packages/renderer-vue/src/settings.ts new file mode 100644 index 00000000..89488814 --- /dev/null +++ b/packages/renderer-vue/src/settings.ts @@ -0,0 +1,78 @@ +interface SimpleContextMenuItem { + label: string; + command: string; +} + +interface DividerContextMenuItem { + isDivider: true; +} + +interface SubmenuContextMenuItem { + label: string; + submenu: ContextMenuItem[]; +} + +export type ContextMenuItem = SimpleContextMenuItem | DividerContextMenuItem | SubmenuContextMenuItem; + +export interface IViewSettings { + /** Use straight connections instead of bezier curves */ + useStraightConnections: boolean; + /** Show a minimap */ + enableMinimap: boolean; + /** Background settings */ + background: { + gridSize: number; + gridDivision: number; + subGridVisibleThreshold: number; + }; + /** Sidebar settings */ + sidebar: { + /** Width of the sidebar in pixels */ + width: number; + /** Whether users should be able to resize the sidebar */ + resizable: boolean; + }; + /** Show interface value on port hover */ + displayValueOnHover: boolean; + /** Node settings */ + nodes: { + /** Minimum width of a node */ + minWidth: number; + /** Maximum width of a node */ + maxWidth: number; + /** Default width of a node */ + defaultWidth: number; + /** Whether users should be able to resize nodes */ + resizable: boolean; + }; + contextMenu: { + /** Whether the context menu should be enabled */ + enabled: boolean; + additionalItems: ContextMenuItem[]; + }; +} + +export const DEFAULT_SETTINGS: () => IViewSettings = () => ({ + useStraightConnections: false, + enableMinimap: false, + background: { + gridSize: 100, + gridDivision: 5, + subGridVisibleThreshold: 0.6, + }, + sidebar: { + width: 300, + resizable: true, + }, + displayValueOnHover: false, + nodes: { + defaultWidth: 200, + maxWidth: 320, + minWidth: 150, + resizable: false, + }, + contextMenu: { + enabled: true, + additionalItems: [], + }, +}); diff --git a/packages/renderer-vue/src/utility/index.ts b/packages/renderer-vue/src/utility/index.ts index 2ce6e0e5..c83a2bce 100644 --- a/packages/renderer-vue/src/utility/index.ts +++ b/packages/renderer-vue/src/utility/index.ts @@ -1,5 +1,6 @@ export * from "./nodePosition"; export * from "./useDragMove"; export * from "./useGraph"; -export * from "./useViewModel"; +export * from "./useNodeCategories"; export * from "./useTransform"; +export * from "./useViewModel"; diff --git a/packages/renderer-vue/src/utility/useNodeCategories.ts b/packages/renderer-vue/src/utility/useNodeCategories.ts new file mode 100644 index 00000000..6fbc7309 --- /dev/null +++ b/packages/renderer-vue/src/utility/useNodeCategories.ts @@ -0,0 +1,80 @@ +import { Ref, computed } from "vue"; +import { + type Editor, + type Graph, + type INodeTypeInformation, + GRAPH_NODE_TYPE_PREFIX, + GRAPH_INPUT_NODE_TYPE, + GRAPH_OUTPUT_NODE_TYPE, + getGraphNodeTypeString, +} from "@baklavajs/core"; +import { IBaklavaViewModel } from "../viewModel"; + +/** This function checks, whether the given GraphNode would cause a recursion if placed in the specified current graph */ +function checkRecursion(editor: Editor, currentGraph: Graph, graphNodeType: string): boolean { + if (!currentGraph.template) { + // we are in the root graph, no recursion can happen here + return false; + } + + if (getGraphNodeTypeString(currentGraph.template) === graphNodeType) { + return true; + } + + // find the template of the specified graph node + const template = editor.graphTemplates.find((t) => getGraphNodeTypeString(t) === graphNodeType); + if (!template) { + return false; + } + + // find all the graph nodes contained in the templates and check them + const containedGraphNodes = template.nodes.filter((n) => n.type.startsWith(GRAPH_NODE_TYPE_PREFIX)); + return containedGraphNodes.some((n) => checkRecursion(editor, currentGraph, n.type)); +} + +type NodeTypeInformations = Record; + +export function useNodeCategories(viewModel: Ref) { + return computed>(() => { + const nodeTypeEntries = Array.from(viewModel.value.editor.nodeTypes.entries()); + + const categoryNames = new Set(nodeTypeEntries.map(([, ni]) => ni.category)); + + const categories: Array<{ name: string; nodeTypes: NodeTypeInformations }> = []; + for (const c of categoryNames.values()) { + let nodeTypesInCategory = nodeTypeEntries.filter(([, ni]) => ni.category === c); + + if (viewModel.value.displayedGraph.template) { + // don't show the graph nodes that directly or indirectly contain the current subgraph to prevent recursion + nodeTypesInCategory = nodeTypesInCategory.filter( + ([nt]) => !checkRecursion(viewModel.value.editor, viewModel.value.displayedGraph, nt), + ); + } else { + // if we are not in a subgraph, don't show subgraph input & output nodes + nodeTypesInCategory = nodeTypesInCategory.filter( + ([nt]) => ![GRAPH_INPUT_NODE_TYPE, GRAPH_OUTPUT_NODE_TYPE].includes(nt), + ); + } + + if (nodeTypesInCategory.length > 0) { + categories.push({ + name: c, + nodeTypes: Object.fromEntries(nodeTypesInCategory), + }); + } + } + + // sort, so the default category is always first and all others are sorted alphabetically + categories.sort((a, b) => { + if (a.name === "default") { + return -1; + } else if (b.name === "default") { + return 1; + } else { + return a.name > b.name ? 1 : -1; + } + }); + + return categories; + }); +} diff --git a/packages/renderer-vue/src/viewModel.ts b/packages/renderer-vue/src/viewModel.ts index bc1df656..ed683164 100644 --- a/packages/renderer-vue/src/viewModel.ts +++ b/packages/renderer-vue/src/viewModel.ts @@ -10,61 +10,7 @@ import { useSwitchGraph } from "./graph/switchGraph"; import { IViewNodeState, setViewNodeProperties } from "./node/viewNode"; import { SubgraphInputNode, SubgraphOutputNode } from "./graph/subgraphInterfaceNodes"; import { registerSidebarCommands } from "./sidebar"; - -export interface IViewSettings { - /** Use straight connections instead of bezier curves */ - useStraightConnections: boolean; - /** Show a minimap */ - enableMinimap: boolean; - /** Background settings */ - background: { - gridSize: number; - gridDivision: number; - subGridVisibleThreshold: number; - }; - /** Sidebar settings */ - sidebar: { - /** Width of the sidebar in pixels */ - width: number; - /** Whether users should be able to resize the sidebar */ - resizable: boolean; - }; - /** Show interface value on port hover */ - displayValueOnHover: boolean; - /** Node settings */ - nodes: { - /** Minimum width of a node */ - minWidth: number; - /** Maximum width of a node */ - maxWidth: number; - /** Default width of a node */ - defaultWidth: number; - /** Whether users should be able to resize nodes */ - resizable: boolean; - }; -} - -const DEFAULT_SETTINGS: () => IViewSettings = () => ({ - useStraightConnections: false, - enableMinimap: false, - background: { - gridSize: 100, - gridDivision: 5, - subGridVisibleThreshold: 0.6, - }, - sidebar: { - width: 300, - resizable: true, - }, - displayValueOnHover: false, - nodes: { - defaultWidth: 200, - maxWidth: 320, - minWidth: 150, - resizable: false, - }, -}); - +import { DEFAULT_SETTINGS, IViewSettings } from "./settings"; export interface IBaklavaViewModel extends IBaklavaTapable { editor: Editor; /** Currently displayed graph */