瀏覽代碼

feat: add blocks folder

Ahmad Kholid 2 年之前
父節點
當前提交
f2a9936bee

+ 8 - 1
src/assets/css/flow.css

@@ -45,7 +45,14 @@
 	}
 }
 
+.dark .vue-flow__edge-path:hover {
+	stroke: theme('colors.yellow.400');
+}
 .vue-flow__edge-path {
   stroke: theme('colors.accent');
-  stroke-width: 3;
+  stroke-width: 4;
+  transition: stroke 100ms ease;
+  &:hover {
+		stroke: theme('colors.yellow.500');
+  }
 }

+ 4 - 3
src/components/newtab/workflow/WorkflowEditor.vue

@@ -13,9 +13,12 @@
     <MiniMap v-if="minimap" :node-class-name="minimapNodeClassName" />
     <div
       v-if="editorControls"
-      class="flex items-end absolute p-4 left-0 bottom-0 z-10"
+      class="flex items-end absolute w-full p-4 left-0 bottom-0 z-10 pr-60"
     >
       <slot name="controls-prepend" />
+      <editor-search-blocks :editor="editor" />
+      <slot name="controls-append" />
+      <div class="flex-grow pointer-events-none" />
       <button
         v-tooltip.group="t('workflow.editor.resetZoom')"
         class="control-button mr-2"
@@ -40,8 +43,6 @@
           <v-remixicon name="riAddLine" />
         </button>
       </div>
-      <editor-search-blocks :editor="editor" />
-      <slot name="controls-append" />
     </div>
     <template v-for="(node, name) in nodeTypes" :key="name" #[name]="nodeProps">
       <component

+ 2 - 1
src/components/newtab/workflow/edit/EditWorkflowParameters.vue

@@ -35,7 +35,7 @@
                 @change="updateParamType(index, $event)"
               >
                 <option
-                  v-for="type in paramTypes"
+                  v-for="type in paramTypesArr"
                   :key="type.id"
                   :value="type.id"
                 >
@@ -123,6 +123,7 @@ const paramTypes = {
   },
   ...workflowParameters,
 };
+const paramTypesArr = Object.values(paramTypes).filter((item) => item.id);
 
 const state = reactive({
   parameters: cloneDeep(props.data || []),

+ 23 - 6
src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue

@@ -10,7 +10,7 @@
         v-for="item in state.items"
         :key="item.id"
         v-close-popover
-        class="cursor-pointer justify-between"
+        class="cursor-pointer text-sm justify-between"
         @click="item.event"
       >
         <span>
@@ -38,7 +38,14 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-const emit = defineEmits(['copy', 'paste', 'duplicate', 'group', 'ungroup']);
+const emit = defineEmits([
+  'copy',
+  'paste',
+  'duplicate',
+  'group',
+  'ungroup',
+  'saveBlock',
+]);
 
 const { t } = useI18n();
 const state = reactive({
@@ -66,6 +73,13 @@ const menuItems = {
       props.editor.removeNodes(ctxData.nodes);
     },
   },
+  saveToFolder: {
+    id: 'saveToFolder',
+    name: t('workflow.blocksFolder.save'),
+    event: () => {
+      emit('saveBlock', ctxData);
+    },
+  },
   copy: {
     id: 'copy',
     name: t('workflow.editor.copy'),
@@ -123,11 +137,11 @@ function clearContextMenu() {
 
 onMounted(() => {
   props.editor.onNodeContextMenu(({ event, node }) => {
-    const items = ['copy', 'duplicate', 'delete'];
+    const items = ['copy', 'duplicate', 'saveToFolder', 'delete'];
     if (node.label === 'blocks-group') {
-      items.splice(2, 0, 'ungroup');
+      items.splice(3, 0, 'ungroup');
     } else if (!excludeGroupBlocks.includes(node.label)) {
-      items.splice(2, 0, 'group');
+      items.splice(3, 0, 'group');
     }
 
     showCtxMenu(items, event);
@@ -150,7 +164,10 @@ onMounted(() => {
     };
   });
   props.editor.onSelectionContextMenu(({ event }) => {
-    showCtxMenu(['copy', 'duplicate', 'group', 'delete'], event);
+    showCtxMenu(
+      ['copy', 'duplicate', 'saveToFolder', 'group', 'delete'],
+      event
+    );
     ctxData = {
       nodes: props.editor.getSelectedNodes.value,
       edges: props.editor.getSelectedEdges.value,

+ 176 - 0
src/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="p-4 absolute w-full bottom-0 z-50">
+    <ui-card class="w-full h-full" padding="p-0">
+      <div class="flex items-center p-4">
+        <ui-input
+          v-model="state.query"
+          :placeholder="$t('common.search')"
+          autofocus
+          autocomplete="off"
+          prepend-icon="riSearch2Line"
+        />
+        <ui-button
+          v-tooltip="'Refresh data'"
+          icon
+          class="ml-4"
+          @click="loadData"
+        >
+          <v-remixicon name="riRefreshLine" />
+        </ui-button>
+        <div class="flex-grow" />
+        <ui-button icon @click="$emit('close')">
+          <v-remixicon name="riCloseLine" />
+        </ui-button>
+      </div>
+      <div
+        class="flex overflow-x-auto space-x-4 mx-4 pb-4 scroll"
+        style="min-height: 95px"
+      >
+        <p
+          v-if="state.savedBlocks.length === 0"
+          class="py-8 w-full text-center"
+        >
+          {{ t('message.noData') }}
+        </p>
+        <div
+          v-for="item in items"
+          :key="item.id"
+          draggable="true"
+          class="p-4 rounded-lg flex-shrink-0 border-2 cursor-move hoverable flex flex-col relative transition"
+          style="width: 220px"
+          @dragstart="
+            $event.dataTransfer.setData('savedBlocks', JSON.stringify(item))
+          "
+        >
+          <p class="font-semibold text-overflow leading-tight">
+            {{ item.name }}
+          </p>
+          <p
+            class="text-gray-600 dark:text-gray-200 line-clamp leading-tight flex-1"
+          >
+            {{ item.description }}
+          </p>
+          <div
+            class="space-x-3 mt-2 text-gray-600 dark:text-gray-200 flex justify-end"
+          >
+            <v-remixicon
+              name="riPencilLine"
+              size="18"
+              class="cursor-pointer"
+              @click="initEditState(item)"
+            />
+            <v-remixicon
+              name="riDeleteBin7Line"
+              size="18"
+              class="cursor-pointer"
+              @click="deleteItem(item)"
+            />
+          </div>
+        </div>
+      </div>
+    </ui-card>
+    <ui-modal v-model="editState.show" :title="t('common.message')">
+      <ui-input
+        v-model="editState.name"
+        :placeholder="t('common.name')"
+        autofocus
+        class="w-full"
+        @keyup.enter="saveEdit"
+      />
+      <ui-textarea
+        v-model="editState.description"
+        :label="t('common.description')"
+        placeholder="Description..."
+        class="w-full mt-4"
+      />
+      <div class="flex items-center justify-end space-x-4 mt-6">
+        <ui-button @click="clearEditState">
+          {{ t('common.cancel') }}
+        </ui-button>
+        <ui-button variant="accent" class="w-20" @click="saveEdit">
+          {{ t('common.save') }}
+        </ui-button>
+      </div>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { onMounted, reactive, computed, toRaw } from 'vue';
+import { useI18n } from 'vue-i18n';
+import browser from 'webextension-polyfill';
+
+defineEmits(['close']);
+
+const { t } = useI18n();
+
+const state = reactive({
+  query: '',
+  savedBlocks: [],
+});
+const editState = reactive({
+  id: '',
+  name: '',
+  show: false,
+  description: '',
+});
+
+const items = computed(() => {
+  const query = state.query.toLocaleLowerCase();
+
+  return state.savedBlocks.filter((item) =>
+    item.name.toLocaleLowerCase().includes(query)
+  );
+});
+
+function initEditState(item) {
+  Object.assign(editState, {
+    show: true,
+    id: item.id,
+    name: item.name,
+    description: item.description,
+  });
+}
+function clearEditState() {
+  Object.assign(editState, {
+    id: '',
+    name: '',
+    show: false,
+    description: '',
+  });
+}
+async function saveEdit() {
+  try {
+    const index = state.savedBlocks.findIndex(
+      (item) => item.id === editState.id
+    );
+    Object.assign(state.savedBlocks[index], {
+      name: editState.name,
+      description: editState.description,
+    });
+
+    browser.storage.local.set({
+      savedBlocks: toRaw(state.savedBlocks),
+    });
+
+    clearEditState();
+  } catch (error) {
+    console.error(error);
+  }
+}
+function loadData() {
+  browser.storage.local.get('savedBlocks').then((storage) => {
+    state.savedBlocks = storage.savedBlocks || [];
+  });
+}
+function deleteItem({ id }) {
+  const index = state.savedBlocks.findIndex((item) => item.id === id);
+  if (index === -1) return;
+
+  state.savedBlocks.splice(index, 1);
+  browser.storage.local.set({
+    savedBlocks: toRaw(state.savedBlocks),
+  });
+}
+
+onMounted(loadData);
+</script>

+ 5 - 0
src/locales/en/newtab.json

@@ -183,6 +183,11 @@
     "autoAlign": {
       "title": "Auto-align"
     },
+    "blocksFolder": {
+      "title": "Blocks folder",
+      "add": "Add blocks to folder",
+      "save": "Save to folder"
+    },
     "searchBlocks": {
       "title": "Search blocks in the editor"
     },

+ 125 - 10
src/newtab/pages/workflows/[id].vue

@@ -117,21 +117,17 @@
             :data="workflow.drawflow"
             :disabled="isTeamWorkflow && !haveEditAccess"
             :class="{ 'animate-blocks': state.animateBlocks }"
-            class="h-screen workflow-editor"
+            class="h-screen focus:outline-none workflow-editor"
             tabindex="0"
             @init="onEditorInit"
             @edit="initEditBlock"
             @update:node="state.dataChanged = true"
             @delete:node="state.dataChanged = true"
           >
-            <template v-if="!isTeamWorkflow || haveEditAccess" #controls-append>
-              <button
-                v-tooltip="t('workflow.autoAlign.title')"
-                class="control-button hoverable ml-2"
-                @click="autoAlign"
-              >
-                <v-remixicon name="riMagicLine" />
-              </button>
+            <template
+              v-if="!isTeamWorkflow || haveEditAccess"
+              #controls-prepend
+            >
               <ui-card padding="p-0 ml-2 undo-redo">
                 <button
                   v-tooltip.group="
@@ -156,8 +152,26 @@
                   <v-remixicon name="riArrowGoForwardLine" />
                 </button>
               </ui-card>
+              <button
+                v-tooltip="t('workflow.blocksFolder.title')"
+                class="control-button hoverable ml-2"
+                @click="blockFolderModal.showList = !blockFolderModal.showList"
+              >
+                <v-remixicon name="riFolderOpenLine" />
+              </button>
+              <button
+                v-tooltip="t('workflow.autoAlign.title')"
+                class="control-button hoverable ml-2"
+                @click="autoAlign"
+              >
+                <v-remixicon name="riMagicLine" />
+              </button>
             </template>
           </workflow-editor>
+          <editor-local-saved-blocks
+            v-if="blockFolderModal.showList"
+            @close="blockFolderModal.showList = false"
+          />
           <editor-local-ctx-menu
             v-if="editor"
             :editor="editor"
@@ -165,6 +179,7 @@
             @ungroup="ungroupBlocks"
             @copy="copySelectedElements"
             @paste="pasteCopiedElements"
+            @saveBlock="initBlockFolder"
             @duplicate="duplicateElements"
           />
         </ui-tab-panel>
@@ -207,6 +222,32 @@
     :permissions="permissionState.items"
     @granted="registerTrigger"
   />
+  <ui-modal
+    v-model="blockFolderModal.showModal"
+    :title="t('workflow.blocksFolder.add')"
+  >
+    <ui-input
+      v-model="blockFolderModal.name"
+      :placeholder="t('common.name')"
+      autofocus
+      class="w-full"
+      @keyup.enter="saveBlockToFolder"
+    />
+    <ui-textarea
+      v-model="blockFolderModal.description"
+      :label="t('common.description')"
+      placeholder="Description..."
+      class="w-full mt-4"
+    />
+    <div class="flex items-center justify-end space-x-4 mt-6">
+      <ui-button @click="clearBlockFolderModal">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button variant="accent" class="w-20" @click="saveBlockToFolder">
+        {{ t('common.add') }}
+      </ui-button>
+    </div>
+  </ui-modal>
 </template>
 <script setup>
 import {
@@ -261,6 +302,7 @@ import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissions
 import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
 import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
 import EditorUsedCredentials from '@/components/newtab/workflow/editor/EditorUsedCredentials.vue';
+import EditorLocalSavedBlocks from '@/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue';
 
 const blocks = { ...tasks, ...customBlocks };
 
@@ -293,6 +335,13 @@ const state = reactive({
   workflowConverted: false,
   activeTab: route.query.tab || 'editor',
 });
+const blockFolderModal = reactive({
+  name: '',
+  nodes: [],
+  description: '',
+  showList: false,
+  showModal: false,
+});
 const permissionState = reactive({
   permissions: [],
   showModal: false,
@@ -571,6 +620,57 @@ const onEdgesChange = debounce((changes) => {
   // if (command) commandManager.add(command);
 }, 250);
 
+function initBlockFolder({ nodes }) {
+  Object.assign(blockFolderModal, {
+    nodes,
+    showModal: true,
+  });
+}
+function clearBlockFolderModal() {
+  Object.assign(blockFolderModal, {
+    name: '',
+    nodes: [],
+    showModal: false,
+    description: '',
+  });
+}
+async function saveBlockToFolder() {
+  try {
+    let { savedBlocks } = await browser.storage.local.get('savedBlocks');
+    if (!savedBlocks) savedBlocks = [];
+
+    const seen = new Set();
+    const nodeList = [
+      ...editor.value.getSelectedNodes.value,
+      ...blockFolderModal.nodes,
+    ].reduce((acc, node) => {
+      if (seen.has(node.id)) return acc;
+
+      const { label, data, position, id } = node;
+      acc.push(cloneDeep({ label, data, position, id }));
+      seen.add(node.id);
+
+      return acc;
+    }, []);
+    const edges = editor.value.getSelectedEdges.value.map(
+      ({ source, target, targetHandle, sourceHandle, id }) =>
+        cloneDeep({ id, source, target, targetHandle, sourceHandle })
+    );
+
+    savedBlocks.push({
+      id: nanoid(5),
+      data: { nodes: nodeList, edges },
+      name: blockFolderModal.name || 'unnamed',
+      description: blockFolderModal.description,
+    });
+
+    await browser.storage.local.set({ savedBlocks });
+
+    clearBlockFolderModal();
+  } catch (error) {
+    console.error(error);
+  }
+}
 function groupBlocks({ position }) {
   const nodesToDelete = [];
   const nodes = editor.value.getSelectedNodes.value;
@@ -587,6 +687,8 @@ function groupBlocks({ position }) {
     return acc;
   }, []);
 
+  if (groupBlocksList.length === 0) return;
+
   editor.value.removeNodes(nodesToDelete);
 
   const { component, data } = blocks['blocks-group'];
@@ -972,6 +1074,19 @@ function onDragoverEditor({ target }) {
   }
 }
 function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
+  const savedBlocks = parseJSON(dataTransfer.getData('savedBlocks'), null);
+  if (savedBlocks) {
+    const { nodes, edges } = savedBlocks.data;
+    /* eslint-disable-next-line */
+    const newElements = copyElements(nodes, edges, { clientX, clientY });
+
+    editor.value.addNodes(newElements.nodes);
+    editor.value.addEdges(newElements.edges);
+
+    state.dataChanged = true;
+    return;
+  }
+
   const block = parseJSON(dataTransfer.getData('block'), null);
   if (!block) return;
 
@@ -1060,12 +1175,12 @@ function copyElements(nodes, edges, initialPos) {
     }
 
     const copyNode = cloneDeep({
-      type,
       data,
       label,
       id: newNodeId,
       selected: true,
       position: nodePos,
+      type: type || blocks[label].component,
     });
     copyNode.data = reactive(copyNode.data);