Browse Source

feat: add editor context menu

Ahmad Kholid 3 years ago
parent
commit
e3f4e624d4

+ 142 - 0
src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue

@@ -0,0 +1,142 @@
+<template>
+  <ui-popover
+    v-model="state.show"
+    :options="state.position"
+    padding="p-3"
+    @close="clearContextMenu"
+  >
+    <ui-list class="space-y-1 w-52">
+      <ui-list-item
+        v-for="item in state.items"
+        :key="item.id"
+        v-close-popover
+        class="cursor-pointer justify-between"
+        @click="item.event"
+      >
+        <span>
+          {{ item.name }}
+        </span>
+        <span
+          v-if="item.shortcut"
+          class="text-sm capitalize text-gray-600 dark:text-gray-200"
+        >
+          {{ item.shortcut }}
+        </span>
+      </ui-list-item>
+    </ui-list>
+  </ui-popover>
+</template>
+<script setup>
+import { onMounted, reactive, markRaw } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useStore } from '@/stores/main';
+import { getReadableShortcut, getShortcut } from '@/composable/shortcut';
+
+const props = defineProps({
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['copy', 'paste', 'duplicate']);
+
+const { t } = useI18n();
+const store = useStore();
+const state = reactive({
+  show: false,
+  items: [],
+  position: {},
+});
+
+let ctxData = null;
+const menuItems = {
+  paste: {
+    id: 'paste',
+    name: t('workflow.editor.paste'),
+    icon: 'riFileCopyLine',
+    shortcut: getReadableShortcut('mod+v'),
+    event: () => emit('paste', ctxData?.position),
+  },
+  delete: {
+    id: 'delete',
+    name: t('common.delete'),
+    icon: 'riDeleteBin7Line',
+    shortcut: 'Del',
+    event: () => {
+      props.editor.removeEdges(ctxData.edges);
+      props.editor.removeNodes(ctxData.nodes);
+    },
+  },
+  copy: {
+    id: 'copy',
+    name: t('workflow.editor.copy'),
+    icon: 'riFileCopyLine',
+    event: () => emit('copy', ctxData),
+    shortcut: getReadableShortcut('mod+c'),
+  },
+  duplicate: {
+    id: 'duplicate',
+    name: t('workflow.editor.duplicate'),
+    icon: 'riFileCopyLine',
+    event: () => emit('duplicate', ctxData),
+    shortcut: getShortcut('editor:duplicate-block').readable,
+  },
+};
+
+/* eslint-disable-next-line */
+function showCtxMenu(items = [], event) {
+  event.preventDefault();
+  const { clientX, clientY } = event;
+
+  state.items = items.map((key) => markRaw(menuItems[key]));
+
+  if (store.copiedEls.nodes.length > 0) {
+    state.items.unshift(markRaw(menuItems.paste));
+  }
+
+  state.position = {
+    getReferenceClientRect: () => ({
+      width: 0,
+      height: 0,
+      top: clientY,
+      right: clientX,
+      bottom: clientY,
+      left: clientX,
+    }),
+  };
+  state.show = true;
+}
+function clearContextMenu() {
+  state.show = false;
+  state.items = [];
+  state.position = {};
+}
+
+onMounted(() => {
+  props.editor.onNodeContextMenu(({ event, node }) => {
+    showCtxMenu(['copy', 'duplicate', 'delete'], event);
+    ctxData = { nodes: [node], edges: [] };
+  });
+  props.editor.onEdgeContextMenu(({ event, edge }) => {
+    showCtxMenu(['delete'], event);
+    ctxData = { nodes: [], edges: [edge] };
+  });
+  props.editor.onPaneContextMenu((event) => {
+    if (store.copiedEls.nodes.length === 0) return;
+
+    showCtxMenu([], event);
+    ctxData = {
+      nodes: [],
+      edges: [],
+      position: { clientX: event.clientX, clientY: event.clientY },
+    };
+  });
+  props.editor.onSelectionContextMenu(({ event }) => {
+    showCtxMenu(['copy', 'duplicate', 'delete'], event);
+    ctxData = {
+      nodes: props.editor.getSelectedNodes.value,
+      edges: props.editor.getSelectedEdges.value,
+    };
+  });
+});
+</script>

+ 74 - 26
src/newtab/pages/workflows/[id].vue

@@ -80,6 +80,13 @@
             @update:node="state.dataChanged = true"
             @delete:node="state.dataChanged = true"
           />
+          <editor-local-ctx-menu
+            v-if="editor"
+            :editor="editor"
+            @copy="copySelectedElements"
+            @paste="pasteCopiedElements"
+            @duplicate="duplicateElements"
+          />
         </ui-tab-panel>
         <ui-tab-panel value="logs" class="mt-24">
           <editor-logs
@@ -144,6 +151,7 @@ import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vu
 import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
 import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
 import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
+import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
 import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
 
 const nanoid = customAlphabet('1234567890abcdef', 7);
@@ -447,30 +455,51 @@ function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
 
   state.dataChanged = true;
 }
-function copySelectedElements() {
+function copyElements(nodes, edges, initialPos) {
   const newIds = new Map();
-
-  const nodes = editor.value.getSelectedNodes.value.map(
-    ({ id, label, position, data, type }) => {
-      const newNodeId = nanoid();
-
-      newIds.set(id, newNodeId);
-
-      return {
-        type,
-        data,
-        label,
-        position: {
-          z: position.z,
-          y: position.y + 50,
-          x: position.x + 50,
-        },
-        id: newNodeId,
-        selected: true,
-      };
+  let firstNodePos = null;
+
+  const newNodes = nodes.map(({ id, label, position, data, type }, index) => {
+    const newNodeId = nanoid();
+
+    const nodePos = {
+      z: position.z || 0,
+      y: position.y + 50,
+      x: position.x + 50,
+    };
+    newIds.set(id, newNodeId);
+
+    if (initialPos) {
+      if (index === 0) {
+        firstNodePos = {
+          x: nodePos.x,
+          y: nodePos.y,
+        };
+        initialPos = editor.value.project({
+          y: initialPos.clientY,
+          x: initialPos.clientX - 360,
+        });
+
+        Object.assign(nodePos, initialPos);
+      } else {
+        const xDistance = nodePos.x - firstNodePos.x;
+        const yDistance = nodePos.y - firstNodePos.y;
+
+        nodePos.x = initialPos.x + xDistance;
+        nodePos.y = initialPos.y + yDistance;
+      }
     }
-  );
-  const edges = editor.value.getSelectedEdges.value.reduce(
+
+    return {
+      type,
+      data,
+      label,
+      id: newNodeId,
+      selected: true,
+      position: nodePos,
+    };
+  });
+  const newEdges = edges.reduce(
     (acc, { target, targetHandle, source, sourceHandle }) => {
       const targetId = newIds.get(target);
       const sourceId = newIds.get(source);
@@ -491,14 +520,33 @@ function copySelectedElements() {
     []
   );
 
-  store.copiedEls.edges = edges;
-  store.copiedEls.nodes = nodes;
+  return {
+    nodes: newNodes,
+    edges: newEdges,
+  };
 }
-function pasteCopiedElements() {
+function duplicateElements({ nodes, edges }) {
   editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
   editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
 
-  const { nodes, edges } = store.copiedEls;
+  const { edges: newEdges, nodes: newNodes } = copyElements(nodes, edges);
+
+  editor.value.addNodes(newNodes);
+  editor.value.addEdges(newEdges);
+}
+function copySelectedElements(data = {}) {
+  store.copiedEls.nodes = data.nodes || editor.value.getSelectedNodes.value;
+  store.copiedEls.edges = data.edges || editor.value.getSelectedEdges.value;
+}
+function pasteCopiedElements(position) {
+  editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
+  editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
+
+  const { nodes, edges } = copyElements(
+    store.copiedEls.nodes,
+    store.copiedEls.edges,
+    position
+  );
   editor.value.addNodes(nodes);
   editor.value.addEdges(edges);
 }