Ahmad Kholid 3 年之前
父節點
當前提交
efa9184593
共有 32 個文件被更改,包括 1117 次插入526 次删除
  1. 3 2
      package.json
  2. 3 1
      src/assets/css/drawflow.css
  3. 二進制
      src/assets/images/curvature.png
  4. 二進制
      src/assets/images/no-curvature.png
  5. 9 0
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  6. 1 1
      src/background/workflow-engine/blocks-handler/handler-interaction-block.js
  7. 8 4
      src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js
  8. 104 0
      src/components/newtab/settings/SettingsBackupItems.vue
  9. 191 261
      src/components/newtab/settings/SettingsCloudBackup.vue
  10. 294 27
      src/components/newtab/workflow/WorkflowBuilder.vue
  11. 3 1
      src/components/newtab/workflow/WorkflowEditBlock.vue
  12. 28 24
      src/components/newtab/workflow/edit/EditConditions.vue
  13. 17 9
      src/components/newtab/workflow/edit/EditExportData.vue
  14. 13 4
      src/components/ui/UiCheckbox.vue
  15. 12 5
      src/content/element-selector/App.vue
  16. 80 4
      src/lib/drawflow.js
  17. 2 0
      src/lib/v-remixicon.js
  18. 1 0
      src/locales/en/blocks.json
  19. 7 1
      src/locales/en/newtab.json
  20. 1 0
      src/newtab/pages/Settings.vue
  21. 137 97
      src/newtab/pages/Workflows.vue
  22. 31 2
      src/newtab/pages/logs/[id].vue
  23. 33 39
      src/newtab/pages/settings/SettingsBackup.vue
  24. 103 0
      src/newtab/pages/settings/SettingsEditor.vue
  25. 0 31
      src/newtab/pages/settings/SettingsIndex.vue
  26. 7 4
      src/newtab/pages/workflows/[id].vue
  27. 2 0
      src/newtab/router.js
  28. 4 1
      src/store/index.js
  29. 8 2
      src/utils/data-exporter.js
  30. 4 1
      src/utils/reference-data/mustache-replacer.js
  31. 2 1
      src/utils/shared.js
  32. 9 4
      yarn.lock

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.7.2",
+  "version": "1.8.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -34,12 +34,13 @@
     "@tiptap/extension-placeholder": "^2.0.0-beta.48",
     "@tiptap/starter-kit": "^2.0.0-beta.181",
     "@tiptap/vue-3": "^2.0.0-beta.90",
+    "@viselect/vanilla": "^3.0.0-beta.13",
     "@vuex-orm/core": "^0.36.4",
     "compare-versions": "^4.1.2",
     "crypto-js": "^4.1.1",
     "css-selector-generator": "^3.6.0",
     "dayjs": "^1.10.7",
-    "defu": "^5.0.1",
+    "defu": "^6.0.0",
     "drawflow": "^0.0.51",
     "idb": "^7.0.0",
     "lodash.clonedeep": "^4.5.0",

+ 3 - 1
src/assets/css/drawflow.css

@@ -1,3 +1,4 @@
+.drawflow-node.selected-list .menu,
 .drawflow-node.selected .menu,
 .drawflow-node .block-base:hover .menu {
   @apply translate-y-11;
@@ -34,7 +35,8 @@
   @apply rounded-lg transition ring-2 ring-transparent duration-200 shadow-lg;
 }
 
-.drawflow .drawflow-node.selected {
+.drawflow .drawflow-node.selected,
+.drawflow .drawflow-node.selected-list {
   @apply ring-accent;
 }
 

二進制
src/assets/images/curvature.png


二進制
src/assets/images/no-curvature.png


+ 9 - 0
src/background/workflow-engine/blocks-handler/handler-export-data.js

@@ -11,6 +11,15 @@ async function exportData({ data, outputs }) {
 
     if (dataToExport === 'google-sheets') {
       payload = this.referenceData.googleSheets[data.refKey] || [];
+    } else if (dataToExport === 'variable') {
+      payload = this.referenceData.variables[data.variableName] || [];
+
+      if (!Array.isArray(payload)) {
+        payload = [payload];
+
+        if (data.type === 'csv' && typeof payload[0] !== 'object')
+          payload = [payload];
+      }
     }
 
     const hasDownloadAccess = await browser.permissions.contains({

+ 1 - 1
src/background/workflow-engine/blocks-handler/handler-interaction-block.js

@@ -36,7 +36,7 @@ async function interactionHandler(block) {
 
     if (
       (block.data.saveData && block.name !== 'forms') ||
-      block.data.getValue
+      (block.data.getValue && block.data.saveData)
     ) {
       const currentColumnType =
         this.columns[block.data.dataColumn]?.type || 'any';

+ 8 - 4
src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js

@@ -4,10 +4,14 @@ function loopBreakpoint(block, { prevBlockData }) {
   const currentLoop = this.loopList[block.data.loopId];
 
   return new Promise((resolve) => {
-    const validLoopData =
-      currentLoop.type === 'numbers'
-        ? true
-        : currentLoop.index <= currentLoop.data.length - 1;
+    let validLoopData = false;
+
+    if (currentLoop) {
+      validLoopData =
+        currentLoop.type === 'numbers'
+          ? true
+          : currentLoop.index <= currentLoop.data.length - 1;
+    }
 
     if (
       currentLoop &&

+ 104 - 0
src/components/newtab/settings/SettingsBackupItems.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="overflow-auto scroll w-full content">
+    <div v-if="!query && workflows.length === 0" class="text-center">
+      <img src="@/assets/svg/files-and-folder.svg" class="mx-auto max-w-sm" />
+      <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
+    </div>
+    <ui-list class="space-y-1">
+      <ui-list-item
+        v-for="workflow in workflows"
+        :key="workflow.id"
+        :class="{ 'bg-box-transparent': isActive(workflow.id) }"
+        class="overflow-hidden group"
+      >
+        <ui-checkbox
+          :disabled="exceedLimit && !isActive(workflow.id)"
+          :model-value="isActive(workflow.id)"
+          class="mr-4"
+          @change="toggleDeleteWorkflow($event, workflow.id)"
+        />
+        <ui-img
+          v-if="workflow.icon?.startsWith('http')"
+          :src="workflow.icon"
+          style="height: 24px; width: 24px"
+          alt="Can not display"
+        />
+        <v-remixicon v-else :name="workflow.icon" />
+        <div class="flex-1 ml-2 overflow-hidden">
+          <p class="text-overflow flex-1">{{ workflow.name }}</p>
+          <p
+            class="text-gray-600 text-sm dark:text-gray-200 leading-tight text-overflow"
+          >
+            {{ workflow.description }}
+          </p>
+        </div>
+        <slot :workflow="workflow" />
+      </ui-list-item>
+    </ui-list>
+  </div>
+  <div class="flex items-center">
+    <ui-checkbox
+      :model-value="exceedLimit"
+      :indeterminate="modelValue.length > 0 && modelValue.length < limit"
+      class="mt-2 ml-4"
+      @change="$emit('select', $event)"
+    >
+      {{
+        t(
+          `settings.backupWorkflows.cloud.${
+            modelValue.length > 0 && modelValue.length >= limit
+              ? 'deselectAll'
+              : 'selectAll'
+          }`
+        )
+      }}
+    </ui-checkbox>
+    <div class="flex-grow"></div>
+    <span> {{ modelValue.length }}/{{ limit }} </span>
+  </div>
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  workflows: {
+    type: Array,
+    default: () => [],
+  },
+  modelValue: {
+    type: Array,
+    default: () => [],
+  },
+  limit: {
+    type: Number,
+    default: Infinity,
+  },
+  query: {
+    type: String,
+    default: '',
+  },
+});
+const emit = defineEmits(['update:modelValue', 'select']);
+
+const { t } = useI18n();
+
+const exceedLimit = computed(() => props.modelValue.length >= props.limit);
+
+function toggleDeleteWorkflow(selected, workflowId) {
+  const workflows = [...props.modelValue];
+
+  if (selected) {
+    workflows.push(workflowId);
+  } else {
+    const index = workflows.indexOf(workflowId);
+
+    if (index !== -1) workflows.splice(index, 1);
+  }
+
+  emit('update:modelValue', workflows);
+}
+function isActive(workflowId) {
+  return props.modelValue.includes(workflowId);
+}
+</script>

+ 191 - 261
src/components/newtab/settings/SettingsCloudBackup.vue

@@ -1,194 +1,124 @@
 <template>
-  <div class="bg-white dark:bg-gray-800 rounded-lg py-4 w-full max-w-3xl">
-    <div class="px-4 flex items-center">
-      <div class="flex-1 leading-tight">
-        <h1 class="text-xl font-semibold">
-          {{ t('settings.backupWorkflows.cloud.title') }}
-        </h1>
-        <p>
-          {{
-            t(
-              `settings.backupWorkflows.cloud.${
-                state.activeTab === 'local' ? 'selectText' : 'storedWorkflows'
-              }`
-            )
-          }}
-        </p>
-      </div>
-      <ui-button @click="$emit('close')">
-        {{ t('common.cancel') }}
-      </ui-button>
+  <div class="flex items-start mt-4 cloud-backup">
+    <div class="w-56">
+      <ui-input
+        v-model="state.query"
+        :placeholder="t('common.search')"
+        autocomplete="off"
+        prepend-icon="riSearch2Line"
+      />
+      <ui-list class="mt-4">
+        <p class="mb-1 text-sm text-gray-600 dark:text-gray-200">Location</p>
+        <ui-list-item
+          v-for="location in ['local', 'cloud']"
+          :key="location"
+          :active="location === state.activeTab"
+          :disabled="backupState.uploading || backupState.deleting"
+          color="bg-box-transparent"
+          class="mb-1 cursor-pointer"
+          @click="state.activeTab = location"
+        >
+          {{ t(`settings.backupWorkflows.cloud.buttons.${location}`) }}
+          <span
+            v-if="location === 'cloud'"
+            class="ml-2 text-sm rounded-full bg-accent dark:text-black text-gray-100 text-center"
+            style="height: 29px; width: 29px; line-height: 29px"
+          >
+            {{ state.cloudWorkflows.length }}
+          </span>
+        </ui-list-item>
+      </ui-list>
       <ui-button
-        v-if="state.activeTab === 'local'"
-        :loading="state.isBackingUp"
+        v-if="state.selectedWorkflows.length > 0 && state.activeTab === 'local'"
+        :loading="backupState.uploading"
         variant="accent"
-        class="ml-2"
-        @click="backupWorkflowsToCloud"
+        class="mt-4 w-8/12"
+        @click="backupWorkflowsToCloud()"
       >
         {{ t('settings.backupWorkflows.backup.button') }}
+        ({{ state.selectedWorkflows.length }})
       </ui-button>
       <ui-button
-        v-else
-        :disabled="state.deleteIds.length <= 0"
-        :loading="state.isDeletingBackup"
-        class="ml-2"
+        v-if="state.deleteIds.length > 0 && state.activeTab === 'cloud'"
+        :loading="backupState.deleting"
         variant="danger"
-        @click="deleteBackup(null)"
+        class="mt-4"
+        @click="deleteBackup()"
       >
         {{ t('settings.backupWorkflows.cloud.delete') }}
         ({{ state.deleteIds.length }})
       </ui-button>
     </div>
-    <div class="flex items-center px-4 mt-6">
-      <ui-tabs
-        v-model="state.activeTab"
-        type="fill"
-        style="background-color: transparent; padding: 0"
-        @change="onTabChange"
-      >
-        <ui-tab v-for="type in ['local', 'cloud']" :key="type" :value="type">
-          {{ t(`settings.backupWorkflows.cloud.buttons.${type}`) }}
-        </ui-tab>
-      </ui-tabs>
-      <div class="flex-grow"></div>
-      <ui-input
-        v-model="state.query"
-        :placeholder="t('common.search')"
-        prepend-icon="riSearch2Line"
-      />
+    <div v-if="!state.backupRetrieved" class="text-center block flex-1 content">
+      <ui-spinner color="text-accent" />
     </div>
-    <ui-tab-panels
-      v-model="state.activeTab"
-      class="overflow-auto scroll p-1 mt-2 px-4"
-      style="height: calc(100vh - 14rem)"
-    >
-      <ui-tab-panel value="local" class="grid grid-cols-2 gap-2">
-        <div
-          v-for="workflow in workflows"
-          :key="workflow.id"
-          :class="{
-            'is-selected bg-box-transparent': state.backupIds.includes(
-              workflow.id
-            ),
-          }"
-          class="border rounded-lg select-workflow p-4 cursor-pointer leading-tight hoverable flex items-start relative transition"
-          @click="toggleSelectWorkflow(workflow.id)"
+    <div v-else class="flex-1 ml-4 overflow-hidden">
+      <template v-if="state.activeTab === 'cloud'">
+        <settings-backup-items
+          v-slot="{ workflow }"
+          v-model="state.deleteIds"
+          :workflows="backupWorkflows"
+          :limit="state.cloudWorkflows.length"
+          :query="state.query"
+          @select="selectAllCloud"
         >
-          <ui-img
-            v-if="workflow.icon?.startsWith('http')"
-            :src="workflow.icon"
-            style="height: 24px; width: 24px"
-            alt="Can not display"
+          <p
+            :title="`Last updated: ${formatDate(
+              workflow,
+              'DD MMMM YYYY, hh:mm A'
+            )}`"
+            class="ml-4 mr-8"
+          >
+            {{ formatDate(workflow, 'DD MMM YYYY') }}
+          </p>
+          <ui-spinner
+            v-if="backupState.workflowId === workflow.id"
+            color="text-accent"
+            class="ml-4"
           />
-          <v-remixicon v-else :name="workflow.icon" />
-          <div class="flex-1 ml-2 overflow-hidden">
-            <p class="text-overflow">{{ workflow.name }}</p>
-            <p class="text-gray-600 dark:text-gray-200 text-overflow">
-              {{ workflow.description }}
-            </p>
-          </div>
-          <span
-            class="hidden select-icon p-1 rounded-full bg-accent dark:text-black text-gray-100"
+          <button
+            v-else-if="!backupState.deleting"
+            class="ml-4 invisible group-hover:visible"
+            :aria-label="t('settings.backupWorkflows.cloud.delete')"
+            @click="deleteBackup(workflow.id)"
           >
-            <v-remixicon name="riCheckboxCircleLine" size="20" />
-          </span>
-        </div>
-      </ui-tab-panel>
-      <ui-tab-panel value="cloud">
-        <div v-if="state.loadingBackup" class="text-center py-4 col-span-2">
-          <ui-spinner color="text-accent" />
-        </div>
-        <template v-else>
-          <ui-list class="space-y-1">
-            <ui-list-item
-              v-for="workflow in backupWorkflows"
-              :key="workflow.id"
-              :class="{
-                'bg-box-transparent': state.deleteIds.includes(workflow.id),
-              }"
-              class="overflow-hidden"
-            >
-              <ui-checkbox
-                :model-value="state.deleteIds.includes(workflow.id)"
-                class="mr-4"
-                @change="toggleDeleteWorkflow($event, workflow.id)"
-              />
-              <ui-img
-                v-if="workflow.icon?.startsWith('http')"
-                :src="workflow.icon"
-                style="height: 24px; width: 24px"
-                alt="Can not display"
-              />
-              <v-remixicon v-else :name="workflow.icon" />
-              <p class="text-overflow flex-1 ml-2">{{ workflow.name }}</p>
-              <p
-                :title="`Last updated: ${formatDate(
-                  workflow,
-                  'DD MMMM YYYY, hh:mm A'
-                )}`"
-                class="ml-4 mr-8"
-              >
-                {{ formatDate(workflow, 'DD MMM YYYY') }}
-              </p>
-              <button
-                v-if="!state.isDeletingBackup"
-                :aria-label="t('settings.backupWorkflows.cloud.delete')"
-                @click="deleteBackup(workflow.id)"
-              >
-                <v-remixicon name="riDeleteBin7Line" />
-              </button>
-            </ui-list-item>
-          </ui-list>
-        </template>
-      </ui-tab-panel>
-    </ui-tab-panels>
-    <div class="mt-2 flex items-center px-4">
-      <button
-        v-if="state.activeTab === 'local'"
-        class="mr-2 flex items-center"
-        @click="selectAll"
-      >
-        <v-remixicon name="riCheckboxCircleLine" />
-        <p class="ml-2">
-          {{
-            t(
-              `settings.backupWorkflows.cloud.${
-                state.backupIds.length >= 40 ? 'deselectAll' : 'selectAll'
-              }`
-            )
-          }}
-        </p>
-      </button>
-      <label v-else class="mr-2 flex items-center">
-        <ui-checkbox
-          :model-value="state.deleteIds.length >= 40"
-          @change="selectAllDelIds"
-        />
-        <p class="ml-2">
-          {{
-            t(
-              `settings.backupWorkflows.cloud.${
-                state.deleteIds.length >= 40 ? 'deselectAll' : 'selectAll'
-              }`
-            )
-          }}
-        </p>
-      </label>
-      <div class="flex-grow"></div>
-      <p>
-        {{
-          state.activeTab === 'local'
-            ? state.backupIds.length
-            : state.cloudWorkflows.length
-        }}/40 {{ t('common.workflow', 2) }}
-      </p>
+            <v-remixicon name="riDeleteBin7Line" />
+          </button>
+        </settings-backup-items>
+      </template>
+      <template v-else>
+        <settings-backup-items
+          v-slot="{ workflow }"
+          v-model="state.selectedWorkflows"
+          :workflows="workflows"
+          :limit="workflowLimit"
+          :query="state.query"
+          @select="selectAllLocal"
+        >
+          <ui-spinner
+            v-if="backupState.workflowId === workflow.id"
+            color="text-accent"
+            class="ml-4"
+          />
+          <button
+            v-else-if="
+              !backupState.uploading &&
+              state.selectedWorkflows.length <= workflowLimit
+            "
+            class="ml-4 invisible group-hover:visible"
+            @click="backupWorkflowsToCloud(workflow.id)"
+          >
+            <v-remixicon name="riUploadCloud2Line" />
+          </button>
+        </settings-backup-items>
+      </template>
     </div>
   </div>
 </template>
 <script setup>
-import { computed, reactive, watch } from 'vue';
-import { useStore } from 'vuex';
+import { computed, reactive, onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useStore } from 'vuex';
 import { useToast } from 'vue-toastification';
 import browser from 'webextension-polyfill';
 import { fetchApi, cacheApi } from '@/utils/api';
@@ -196,6 +126,7 @@ import { convertWorkflow } from '@/utils/workflow-data';
 import { parseJSON } from '@/utils/helper';
 import dayjs from '@/lib/dayjs';
 import Workflow from '@/models/workflow';
+import SettingsBackupItems from './SettingsBackupItems.vue';
 
 defineEmits(['close']);
 
@@ -206,20 +137,29 @@ const toast = useToast();
 const state = reactive({
   query: '',
   deleteIds: [],
-  backupIds: [],
   activeTab: 'local',
   cloudWorkflows: [],
-  isBackingUp: false,
-  loadingBackup: false,
+  selectedWorkflows: [],
   backupRetrieved: false,
-  isDeletingBackup: false,
+});
+const backupState = reactive({
+  workflowId: '',
+  deleting: false,
+  uploading: false,
 });
 
 const workflows = computed(() =>
   Workflow.query()
-    .where(({ name }) =>
-      name.toLocaleLowerCase().includes(state.query.toLowerCase())
-    )
+    .where(({ name, id }) => {
+      const isInCloud = state.cloudWorkflows.some(
+        (workflow) => workflow.id === id
+      );
+
+      return (
+        name.toLocaleLowerCase().includes(state.query.toLowerCase()) &&
+        !isInCloud
+      );
+    })
     .orderBy('createdAt', 'desc')
     .get()
 );
@@ -228,22 +168,44 @@ const backupWorkflows = computed(() =>
     name.toLocaleLowerCase().includes(state.query.toLowerCase())
   )
 );
+const workflowLimit = computed(() => {
+  const maxWorkflow = store.state.user.limit.backupWorkflow;
+
+  return maxWorkflow - state.cloudWorkflows.length;
+});
 
 function formatDate(workflow, format) {
   return dayjs(workflow.updatedAt || Date.now()).format(format);
 }
-function toggleDeleteWorkflow(value, workflowId) {
+function selectAllCloud(value) {
   if (value) {
-    state.deleteIds.push(workflowId);
+    state.deleteIds = state.cloudWorkflows.map(({ id }) => id);
   } else {
-    const index = state.deleteIds.indexOf(workflowId);
+    state.deleteIds = [];
+  }
+}
+function selectAllLocal() {
+  let limit = state.selectedWorkflows.length;
 
-    if (index !== -1) state.deleteIds.splice(index, 1);
+  if (limit >= workflowLimit.value) {
+    state.selectedWorkflows = [];
+    return;
   }
+
+  workflows.value.forEach(({ id }) => {
+    if (limit >= workflowLimit.value || state.selectedWorkflows.includes(id))
+      return;
+
+    state.selectedWorkflows.push(id);
+
+    limit += 1;
+  });
 }
 async function deleteBackup(workflowId) {
   try {
-    state.isDeletingBackup = true;
+    backupState.deleting = true;
+
+    if (workflowId) backupState.workflowId = workflowId;
 
     const ids = workflowId ? [workflowId] : state.deleteIds;
     const response = await fetchApi(
@@ -255,35 +217,34 @@ async function deleteBackup(workflowId) {
 
     if (!response.ok) throw new Error(response.statusText);
 
-    const { backupIds } = await browser.storage.local.get('backupIds');
-
     ids.forEach((id) => {
       const index = state.cloudWorkflows.findIndex((item) => item.id === id);
-      if (index !== -1) state.cloudWorkflows.splice(index, 1);
 
-      const backupIndex = backupIds.indexOf(id);
-      if (backupIndex !== -1) backupIds.splice(backupIndex, 1);
+      if (index !== -1) state.cloudWorkflows.splice(index, 1);
     });
 
-    await browser.storage.local.set({ backupIds });
+    await browser.storage.local.set({
+      backupIds: state.cloudWorkflows.map(({ id }) => id),
+    });
 
-    state.backupIds = backupIds;
-    state.isDeletingBackup = false;
+    state.deleteIds = [];
+    backupState.workflowId = '';
+    backupState.deleting = false;
     sessionStorage.removeItem('backup-workflows');
   } catch (error) {
     console.error(error);
-    state.isDeletingBackup = false;
+    backupState.workflowId = '';
+    backupState.deleting = false;
     toast.error(t('message.somethingWrong'));
-    state.isBackingUp = false;
+    backupState.uploading = false;
   }
 }
-async function onTabChange(value) {
-  if (value !== 'cloud' || state.backupRetrieved || state.loadingBackup) return;
+async function fetchCloudWorkflows() {
+  if (state.backupRetrieved) return;
 
   state.deleteIds = [];
 
   try {
-    state.loadingBackup = true;
     const data = await cacheApi('backup-workflows', async () => {
       const response = await fetchApi('/me/workflows?type=backup');
 
@@ -295,61 +256,22 @@ async function onTabChange(value) {
     });
 
     state.cloudWorkflows = data;
-    state.loadingBackup = false;
+    state.backupRetrieved = true;
   } catch (error) {
     console.error(error);
     state.loadingBackup = false;
   }
 }
-function toggleSelectWorkflow(workflowId) {
-  if (state.backupIds.length >= 40) return;
-
-  const index = state.backupIds.indexOf(workflowId);
-
-  if (index !== -1) state.backupIds.splice(index, 1);
-  else state.backupIds.push(workflowId);
-}
-function selectAllDelIds(value) {
-  if (value) {
-    state.deleteIds = state.cloudWorkflows.map(({ id }) => id);
-  } else {
-    state.deleteIds = [];
-  }
-}
-function selectAll() {
-  let limit = state.backupIds.length;
-
-  if (limit >= 40) {
-    state.backupIds = [];
-    return;
-  }
-
-  Workflow.query()
-    .orderBy('createdAt', 'desc')
-    .get()
-    .forEach(({ id }) => {
-      if (limit >= 40 || state.backupIds.includes(id)) return;
-
-      state.backupIds.push(id);
-
-      limit += 1;
-    });
-}
-async function backupWorkflowsToCloud() {
-  if (state.isBackingUp) return;
-
-  if (state.backupIds.length === 0) {
-    toast.error(t('settings.backupWorkflows.cloud.needSelectWorkflow'), {
-      timeout: 7000,
-    });
-
-    return;
-  }
+async function backupWorkflowsToCloud(workflowId) {
+  if (backupState.uploading) return;
 
   try {
-    state.isBackingUp = true;
+    backupState.uploading = true;
+
+    if (workflowId) backupState.workflowId = workflowId;
 
-    const workflowsPayload = state.backupIds.reduce((acc, id) => {
+    const workflowIds = workflowId ? [workflowId] : state.selectedWorkflows;
+    const workflowsPayload = workflowIds.reduce((acc, id) => {
       const findWorkflow = Workflow.find(id);
 
       if (!findWorkflow) return acc;
@@ -361,6 +283,8 @@ async function backupWorkflowsToCloud() {
       ]);
       delete workflow.extVersion;
 
+      if (!workflow.__id) delete workflow.__id;
+
       acc.push(workflow);
 
       return acc;
@@ -377,24 +301,34 @@ async function backupWorkflowsToCloud() {
 
     const { lastBackup, data, ids } = await response.json();
 
-    state.isBackingUp = false;
-    state.lastBackup = lastBackup;
+    backupState.uploading = false;
+    backupState.workflowId = '';
+
+    ids.forEach((id) => {
+      const isExists = state.cloudWorkflows.some(
+        (workflow) => workflow.id === id
+      );
+      if (isExists) return;
+
+      state.cloudWorkflows.push(Workflow.find(id));
+    });
+
     state.lastSync = lastBackup;
+    state.selectedWorkflows = [];
+    state.lastBackup = lastBackup;
 
     const userWorkflows = parseJSON('user-workflows', {
       backup: [],
       hosted: {},
     });
-    userWorkflows.backup = data;
+    userWorkflows.backup = state.cloudWorkflows;
     sessionStorage.setItem('user-workflows', JSON.stringify(userWorkflows));
 
-    state.cloudWorkflows = ids.map((id) => Workflow.find(id));
-
     await Workflow.insertOrUpdate({ data });
     await browser.storage.local.set({
       lastBackup,
-      lastSync: lastBackup,
       backupIds: ids,
+      lastSync: lastBackup,
     });
 
     sessionStorage.removeItem('backup-workflows');
@@ -402,23 +336,19 @@ async function backupWorkflowsToCloud() {
     sessionStorage.removeItem('cache-time:backup-workflows');
   } catch (error) {
     console.error(error);
-    toast.error(t('message.somethingWrong'));
-    state.isBackingUp = false;
+    toast.error(error.message);
+    backupState.workflowId = '';
+    backupState.uploading = false;
   }
 }
 
-watch(
-  () => store.state.userDataRetrieved,
-  async () => {
-    const { backupIds } = await browser.storage.local.get('backupIds');
-
-    state.backupIds = backupIds || [];
-  },
-  { immediate: true }
-);
+onMounted(async () => {
+  await fetchCloudWorkflows();
+});
 </script>
-<style scoped>
-.select-workflow.is-selected .select-icon {
-  display: block;
+<style>
+.cloud-backup .content {
+  height: calc(100vh - 10rem);
+  max-height: 1200px;
 }
 </style>

+ 294 - 27
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -1,6 +1,7 @@
 <template>
   <div
     id="drawflow"
+    :class="{ 'with-arrow': $store.state.settings.editor.arrow }"
     class="parent-drawflow relative"
     @drop="dropHandler"
     @dragover.prevent="handleDragOver"
@@ -78,6 +79,7 @@ import { useRoute } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
 import defu from 'defu';
+import SelectionArea from '@viselect/vanilla';
 import emitter from '@/lib/mitt';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { tasks } from '@/utils/shared';
@@ -131,6 +133,12 @@ export default {
       ],
     };
 
+    let activeNode = null;
+    let hasDragged = false;
+    let isDragging = false;
+    let selectedElements = [];
+
+    const selection = shallowRef(null);
     const editor = shallowRef(null);
     const contextMenu = reactive({
       items: [],
@@ -306,27 +314,87 @@ export default {
     function deleteBlock() {
       editor.value.removeNodeId(contextMenu.data);
     }
-    function duplicateBlock(id) {
-      const { name, pos_x, pos_y, data, html, inputs, outputs } =
-        editor.value.getNodeFromId(id || contextMenu.data.substr(5));
-
-      if (name === 'trigger') return;
-
-      const { outputs: defOutputs, inputs: defInputs } = tasks[name];
-      const blockInputs = Object.keys(inputs).length || defInputs;
-      const blockOutputs = Object.keys(outputs).length || defOutputs;
-
-      editor.value.addNode(
-        name,
-        blockInputs,
-        blockOutputs,
-        pos_x + 50,
-        pos_y + 100,
-        name,
-        data,
-        html,
-        'vue'
-      );
+    function clearSelectedElements() {
+      selection.value.clearSelection();
+      selectedElements.forEach(({ el }) => {
+        el.classList.remove('selected-list');
+      });
+      selectedElements = [];
+      activeNode = null;
+    }
+    function duplicateBlock(nodeId) {
+      const nodes = new Map();
+      const addNode = (id) => {
+        const node = editor.value.getNodeFromId(id);
+
+        if (node.name === 'trigger') return;
+
+        nodes.set(node.id, node);
+      };
+
+      if (nodeId) addNode(nodeId);
+      else if (activeNode) addNode(activeNode.id);
+
+      selectedElements.forEach((node) => {
+        if (activeNode?.id === node.id || nodeId === node.id) return;
+
+        addNode(node.id);
+      });
+
+      const nodesOutputs = [];
+
+      clearSelectedElements();
+
+      nodes.forEach((node) => {
+        const { outputs, inputs } = tasks[node.name];
+
+        const inputsLen = Object.keys(node.inputs).length;
+        const outputsLen = Object.keys(node.outputs).length;
+
+        const blockInputs = inputsLen || inputs;
+        const blockOutputs = outputsLen || outputs;
+
+        const newNodeId = editor.value.addNode(
+          node.name,
+          blockInputs,
+          blockOutputs,
+          node.pos_x + 25,
+          node.pos_y + 70,
+          node.name,
+          node.data,
+          node.html,
+          'vue'
+        );
+
+        nodes.set(node.id, { ...nodes.get(node.id), newId: newNodeId });
+
+        const nodeElement = document.querySelector(`#node-${newNodeId}`);
+        nodeElement.classList.add('selected-list');
+        selectedElements.push({
+          id: newNodeId,
+          el: nodeElement,
+          posY: parseInt(nodeElement.style.top, 10),
+          posX: parseInt(nodeElement.style.left, 10),
+        });
+
+        if (outputsLen > 0) {
+          nodesOutputs.push({ id: newNodeId, outputs: node.outputs });
+        }
+      });
+
+      if (nodesOutputs.length < 1) return;
+
+      nodesOutputs.forEach(({ id, outputs }) => {
+        Object.keys(outputs).forEach((key) => {
+          outputs[key].connections.forEach((connection) => {
+            const node = nodes.get(connection.node);
+
+            if (!node) return;
+
+            editor.value.addConnection(id, node.newId, key, 'input_1');
+          });
+        });
+      });
     }
     function checkWorkflowData() {
       if (!editor.value) return;
@@ -353,13 +421,154 @@ export default {
 
       localStorage.setItem('editor-states', JSON.stringify(editorStates));
     }
+    function initSelectArea() {
+      selection.value = new SelectionArea({
+        container: '#drawflow',
+        startareas: ['#drawflow'],
+        boundaries: ['#drawflow'],
+        selectables: ['.drawflow-node'],
+        features: {
+          singleTap: {
+            allow: false,
+          },
+        },
+      });
 
-    useShortcut('editor:duplicate-block', () => {
-      const selectedElement = document.querySelector('.drawflow-node.selected');
+      selection.value.on('beforestart', ({ event }) => {
+        if (!event.ctrlKey) return false;
 
-      if (!selectedElement) return;
+        editor.value.editor_mode = 'fixed';
+        editor.value.editor_selected = false;
+
+        return true;
+      });
+      selection.value.on('move', () => {
+        hasDragged = true;
+      });
+      selection.value.on('stop', (event) => {
+        event.store.selected.forEach((el) => {
+          const isExists = selectedElements.some((item) =>
+            item.el.isEqualNode(el)
+          );
+
+          if (isExists) return;
+
+          el.classList.toggle('selected-list', true);
+
+          selectedElements.push({
+            el,
+            id: el.id.slice(5),
+            posY: parseInt(el.style.top, 10),
+            posX: parseInt(el.style.left, 10),
+          });
+        });
+
+        setTimeout(() => {
+          hasDragged = false;
+        }, 500);
+      });
+    }
+    function onMouseup({ target }) {
+      editor.value.editor_mode = 'edit';
+
+      const isNodeEl = target.closest('.drawflow-node');
+      if (!isNodeEl) return;
+
+      const getPosition = (el) => {
+        return {
+          posY: parseInt(el.style.top, 10),
+          posX: parseInt(el.style.left, 10),
+        };
+      };
 
-      duplicateBlock(selectedElement.id.substr(5));
+      selectedElements.forEach(({ el }, index) => {
+        Object.assign(selectedElements[index], getPosition(el));
+      });
+
+      if (activeNode) Object.assign(activeNode, getPosition(activeNode.el));
+
+      isDragging = false;
+    }
+    function onMousedown({ target }) {
+      const nodeEl = target.closest('.drawflow-node');
+      if (!nodeEl) return;
+
+      if (nodeEl.classList.contains('selected-list')) {
+        activeNode = {
+          el: nodeEl,
+          id: nodeEl.id.slice(5),
+          posY: parseInt(nodeEl.style.top, 10),
+          posX: parseInt(nodeEl.style.left, 10),
+        };
+      }
+
+      isDragging = true;
+    }
+    function onClick({ ctrlKey, target }) {
+      const nodeEl = target.closest('.drawflow-node');
+      if (!nodeEl) {
+        if (!hasDragged) clearSelectedElements();
+        return;
+      }
+
+      const nodeProperties = {
+        el: nodeEl,
+        id: nodeEl.id.slice(5),
+        posY: parseInt(nodeEl.style.top, 10),
+        posX: parseInt(nodeEl.style.left, 10),
+      };
+
+      if (!ctrlKey && !hasDragged) {
+        clearSelectedElements();
+
+        activeNode = nodeProperties;
+        nodeEl.classList.add('selected-list');
+        selectedElements = [nodeProperties];
+        hasDragged = false;
+
+        return;
+      }
+      hasDragged = false;
+
+      if (!ctrlKey) return;
+
+      const nodeIndex = selectedElements.findIndex(({ el }) =>
+        nodeEl.isEqualNode(el)
+      );
+      if (nodeIndex !== -1) {
+        setTimeout(() => {
+          nodeEl.classList.remove('selected-list', 'selected');
+        }, 400);
+        selectedElements.splice(nodeIndex, 1);
+      } else {
+        nodeEl.classList.add('selected-list');
+        selectedElements.push(nodeProperties);
+      }
+    }
+    function onKeyup({ key, target }) {
+      const isAnInput =
+        ['INPUT', 'TEXTAREA'].includes(target.tagName) &&
+        target.isContentEditable;
+
+      if (key !== 'Delete' || isAnInput) return;
+
+      selectedElements.forEach(({ id }) => {
+        const nodeId = `node-${id}`;
+        const isNodeExists = document.querySelector(`#${nodeId}`);
+
+        if (!isNodeExists) return;
+
+        editor.value.removeNodeId(nodeId);
+      });
+
+      selectedElements = [];
+      activeNode = null;
+    }
+
+    useShortcut('editor:duplicate-block', () => {
+      if (!activeNode && selectedElements.length <= 0) return;
+
+      duplicateBlock();
     });
 
     watch(() => props.isShared, checkWorkflowData);
@@ -368,6 +577,11 @@ export default {
       const context = getCurrentInstance().appContext.app._context;
       const element = document.querySelector('#drawflow');
 
+      element.addEventListener('mousedown', onMousedown);
+      element.addEventListener('mouseup', onMouseup);
+      element.addEventListener('click', onClick);
+      element.addEventListener('keyup', onKeyup);
+
       editor.value = drawflow(element, {
         context,
         options: {
@@ -446,6 +660,33 @@ export default {
         );
       }
 
+      editor.value.on('mouseMove', () => {
+        if (!activeNode || !isDragging) return;
+
+        const xDistance =
+          parseInt(activeNode.el.style.left, 10) - activeNode.posX;
+        const yDistance =
+          parseInt(activeNode.el.style.top, 10) - activeNode.posY;
+
+        selectedElements.forEach(({ el, posX, posY }) => {
+          if (el.isEqualNode(activeNode.el)) return;
+
+          const nodeId = el.id.slice(5);
+          const node = editor.value.drawflow.drawflow.Home.data[nodeId];
+
+          const newPosX = posX + xDistance;
+          const newPosY = posY + yDistance;
+
+          node.pos_x = newPosX;
+          node.pos_y = newPosY;
+          el.style.top = `${newPosY}px`;
+          el.style.left = `${newPosX}px`;
+
+          editor.value.updateConnectionNodes(el.id);
+        });
+
+        hasDragged = true;
+      });
       editor.value.on('nodeRemoved', (id) => {
         emit('deleteBlock', id);
       });
@@ -498,13 +739,25 @@ export default {
       });
 
       checkWorkflowData();
+      initSelectArea();
 
       setTimeout(() => {
         editor.value.zoom_refresh();
         refreshConnection();
       }, 500);
     });
-    onBeforeUnmount(saveEditorState);
+    onBeforeUnmount(() => {
+      const element = document.querySelector('#drawflow');
+
+      if (element) {
+        element.removeEventListener('mousedown', onMousedown);
+        element.removeEventListener('mouseup', onMouseup);
+        element.removeEventListener('click', onClick);
+        element.removeEventListener('keyup', onKeyup);
+      }
+
+      saveEditorState();
+    });
 
     return {
       t,
@@ -514,7 +767,7 @@ export default {
       handleDragOver,
       contextMenuHandler: {
         deleteBlock,
-        duplicateBlock: () => duplicateBlock(),
+        duplicateBlock: () => duplicateBlock(contextMenu.data.substr(5)),
       },
     };
   },
@@ -524,6 +777,7 @@ export default {
 #drawflow {
   background-image: url('@/assets/images/tile.png');
   background-size: 35px;
+  user-select: none;
 }
 .dark #drawflow {
   background-image: url('@/assets/images/tile-white.png');
@@ -531,4 +785,17 @@ export default {
 .drawflow .drawflow-node {
   @apply dark:bg-gray-800;
 }
+#drawflow.with-arrow .drawflow-node .input {
+  background-color: transparent !important;
+  border-top: 10px solid transparent;
+  border-radius: 0;
+  border-left: 10px solid theme('colors.accent');
+  border-right: 10px solid transparent;
+  border-bottom: 10px solid transparent;
+}
+.selection-area {
+  background: rgba(46, 115, 252, 0.11);
+  border: 2px solid rgba(98, 155, 255, 0.81);
+  border-radius: 0.1em;
+}
 </style>

+ 3 - 1
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -128,7 +128,9 @@ export default {
       if (!tasks[name].autocomplete) return;
 
       tasks[name].autocomplete.forEach((key) => {
-        if (!data[key]) return;
+        const variableNotAssigned =
+          key === 'variableName' && !data.assignVariable;
+        if (!data[key] || variableNotAssigned) return;
 
         autocompleteData.value[id].add(`${dataKeywords[key]}@${data[key]}`);
       });

+ 28 - 24
src/components/newtab/workflow/edit/EditConditions.vue

@@ -8,30 +8,33 @@
     >
       {{ t('workflow.blocks.conditions.add') }}
     </ui-button>
-    <ui-list class="space-y-1">
-      <ui-list-item
-        v-for="(item, index) in conditions"
-        :key="item.id"
-        class="group"
-      >
-        <v-remixicon name="riGuideLine" size="20" class="mr-2 -ml-1" />
-        <p class="flex-1 text-overflow" :title="item.name">
-          {{ item.name }}
-        </p>
-        <v-remixicon
-          class="cursor-pointer group-hover:visible invisible"
-          name="riPencilLine"
-          size="20"
-          @click="editCondition(index)"
-        />
-        <v-remixicon
-          name="riDeleteBin7Line"
-          size="20"
-          class="ml-2 -mr-1 cursor-pointer"
-          @click="deleteCondition(index)"
-        />
-      </ui-list-item>
-    </ui-list>
+    <draggable
+      v-model="conditions"
+      item-key="id"
+      tag="ui-list"
+      class="space-y-1"
+    >
+      <template #item="{ element, index }">
+        <ui-list-item class="group cursor-move">
+          <v-remixicon name="riGuideLine" size="20" class="mr-2 -ml-1" />
+          <p class="flex-1 text-overflow" :title="element.name">
+            {{ element.name }}
+          </p>
+          <v-remixicon
+            class="cursor-pointer group-hover:visible invisible"
+            name="riPencilLine"
+            size="20"
+            @click="editCondition(index)"
+          />
+          <v-remixicon
+            name="riDeleteBin7Line"
+            size="20"
+            class="ml-2 -mr-1 cursor-pointer"
+            @click="deleteCondition(index)"
+          />
+        </ui-list-item>
+      </template>
+    </draggable>
     <ui-modal v-model="state.showModal" custom-content>
       <ui-card padding="p-0" class="w-full max-w-3xl">
         <div class="px-4 pt-4 flex items-center">
@@ -66,6 +69,7 @@
 import { ref, watch, onMounted, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid';
+import Draggable from 'vuedraggable';
 import emitter from '@/lib/mitt';
 import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
 

+ 17 - 9
src/components/newtab/workflow/edit/EditExportData.vue

@@ -16,6 +16,22 @@
         {{ t(`workflow.blocks.export-data.dataToExport.options.${option}`) }}
       </option>
     </ui-select>
+    <ui-input
+      v-if="data.dataToExport === 'google-sheets'"
+      :model-value="data.refKey"
+      :title="t('workflow.blocks.export-data.refKey')"
+      :placeholder="t('workflow.blocks.export-data.refKey')"
+      class="w-full mt-2"
+      @change="updateData({ refKey: $event })"
+    />
+    <ui-input
+      v-if="data.dataToExport === 'variable'"
+      :model-value="data.variableName"
+      :title="t('workflow.variables.name')"
+      :placeholder="t('workflow.variables.name')"
+      class="w-full mt-2"
+      @change="updateData({ variableName: $event })"
+    />
     <ui-autocomplete
       :items="autocomplete"
       :trigger-char="['{{', '}}']"
@@ -43,14 +59,6 @@
         {{ t(`workflow.blocks.base.downloads.onConflict.${item}`) }}
       </option>
     </ui-select>
-    <ui-input
-      v-if="data.dataToExport === 'google-sheets'"
-      :model-value="data.refKey"
-      :title="t('workflow.blocks.export-data.refKey')"
-      :placeholder="t('workflow.blocks.export-data.refKey')"
-      class="w-full mt-2"
-      @change="updateData({ refKey: $event })"
-    />
     <ui-select
       :model-value="data.type"
       :label="t('workflow.blocks.export-data.exportAs')"
@@ -88,7 +96,7 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
-const dataToExport = ['data-columns', 'google-sheets'];
+const dataToExport = ['data-columns', 'google-sheets', 'variable'];
 const onConflict = ['uniquify', 'overwrite', 'prompt'];
 
 const { t } = useI18n();

+ 13 - 4
src/components/ui/UiCheckbox.vue

@@ -4,10 +4,13 @@
     :class="[block ? 'flex' : 'inline-flex']"
   >
     <div
-      :class="{ 'pointer-events-none opacity-75': disabled }"
+      :class="{
+        'pointer-events-none opacity-75': disabled,
+      }"
       class="relative h-5 w-5 inline-block focus-within:ring-2 focus-within:ring-accent rounded"
     >
       <input
+        :class="{ indeterminate }"
         type="checkbox"
         class="opacity-0 checkbox-ui__input"
         :value="modelValue"
@@ -18,7 +21,7 @@
         class="border dark:border-gray-700 rounded absolute top-0 left-0 bg-input checkbox-ui__mark cursor-pointer"
       >
         <v-remixicon
-          name="riCheckLine"
+          :name="indeterminate ? 'riSubtractLine' : 'riCheckLine'"
           size="20"
           class="text-white dark:text-black"
         ></v-remixicon>
@@ -36,6 +39,10 @@ export default {
       type: Boolean,
       default: false,
     },
+    indeterminate: {
+      type: Boolean,
+      default: false,
+    },
     disabled: {
       type: Boolean,
       default: null,
@@ -59,13 +66,15 @@ export default {
 };
 </script>
 <style scoped>
-.checkbox-ui__input:checked ~ .checkbox-ui__mark .v-remixicon {
+.checkbox-ui__input:checked ~ .checkbox-ui__mark .v-remixicon,
+.checkbox-ui__input.indeterminate ~ .checkbox-ui__mark .v-remixicon {
   transform: scale(1) !important;
 }
 .checkbox-ui .v-remixicon {
   transform: scale(0);
 }
-.checkbox-ui__input:checked ~ .checkbox-ui__mark {
+.checkbox-ui__input:checked ~ .checkbox-ui__mark,
+.checkbox-ui__input.indeterminate ~ .checkbox-ui__mark {
   @apply bg-accent border-accent bg-opacity-100;
 }
 .checkbox-ui__mark {

+ 12 - 5
src/content/element-selector/App.vue

@@ -189,9 +189,10 @@ const cardRect = reactive({
 });
 
 /* eslint-disable  no-use-before-define */
-const getElementSelector = (element, options = {}) =>
-  state.selectorType === 'css'
-    ? getCssSelector(element, {
+const getElementSelector = (element, options = {}) => {
+  if (state.selectorType === 'css') {
+    if (Array.isArray(element)) {
+      return getCssSelector(element, {
         root: document.body,
         blacklist: [
           '[focused]',
@@ -205,8 +206,14 @@ const getElementSelector = (element, options = {}) =>
         ],
         includeTag: true,
         ...options,
-      })
-    : generateXPath(element);
+      });
+    }
+
+    return finder(element);
+  }
+
+  return generateXPath(element);
+};
 
 function generateXPath(element) {
   if (!element) return null;

+ 80 - 4
src/lib/drawflow.js

@@ -8,10 +8,86 @@ export default function (element, { context, options = {} }) {
   const editor = new Drawflow(element, { render, version: 3, h }, context);
 
   editor.useuuid = true;
-  editor.curvature = 0;
-  editor.reroute_curvature = 0;
-  editor.reroute_curvature_start_end = 0;
-  editor.reroute_fix_curvature = true;
+  editor.createCurvature = (
+    startPosX,
+    startPosY,
+    endPosX,
+    endPosY,
+    curvatureValue,
+    type
+  ) => {
+    const curvature = options.disableCurvature ? 0 : curvatureValue;
+    const generateCurvature = (start = false) => {
+      if (start) {
+        return startPosX + Math.abs(endPosX - startPosX) * curvature;
+      }
+
+      return endPosX - Math.abs(endPosX - startPosX) * curvature;
+    };
+
+    switch (type) {
+      case 'open': {
+        const hx1 = generateCurvature(true);
+        let hx2 = generateCurvature();
+
+        if (startPosX >= endPosX) {
+          hx2 = endPosX - Math.abs(endPosX - startPosX) * (curvature * -1);
+        }
+
+        return ` M ${startPosX} ${startPosY} C ${hx1} ${startPosY} ${hx2} ${endPosY} ${endPosX}  ${endPosY}`;
+      }
+      case 'close': {
+        let hx1 = generateCurvature(true);
+        const hx2 = generateCurvature();
+
+        if (startPosX >= endPosX) {
+          hx1 = startPosX + Math.abs(endPosX - startPosX) * (curvature * -1);
+        }
+
+        const posX = options.arrow ? endPosX - 10 : endPosX;
+
+        return ` M ${startPosX} ${startPosY} C ${hx1} ${startPosY} ${hx2} ${endPosY} ${posX} ${endPosY}`;
+      }
+      case 'other': {
+        let hx1 = generateCurvature(true);
+        let hx2 = generateCurvature();
+
+        if (startPosX >= endPosX) {
+          hx1 = startPosX + Math.abs(endPosX - startPosX) * (curvature * -1);
+          hx2 = endPosX - Math.abs(endPosX - startPosX) * (curvature * -1);
+        }
+
+        return ` M ${startPosX} ${startPosY} C ${hx1} ${startPosY} ${hx2} ${endPosY} ${endPosX} ${endPosY}`;
+      }
+      default: {
+        let line = '';
+        const posX = options.arrow ? endPosX - 10 : endPosX;
+
+        if (!options.disableCurvature) {
+          const hx1 = generateCurvature(true);
+          const hx2 = generateCurvature();
+
+          line = `M${startPosX} ${startPosY} C${hx1} ${startPosY} ${hx2} ${endPosY} ${posX} ${endPosY}`;
+        } else {
+          const centerX =
+            Math.abs(endPosX - startPosX) < 300
+              ? (endPosX - startPosX) / 2 + startPosX
+              : startPosX + 150;
+          let firstLine = `L${centerX} ${startPosY} L${centerX} ${endPosY}`;
+
+          if (startPosX >= endPosX) {
+            const centerY = (endPosY - startPosY) / 2 + startPosY;
+
+            firstLine = ` L${startPosX} ${startPosY} L${startPosX} ${centerY} L${posX} ${centerY}`;
+          }
+
+          line = `M ${startPosX} ${startPosY} ${firstLine} L${posX} ${endPosY}`;
+        }
+
+        return line;
+      }
+    }
+  };
 
   Object.entries(options).forEach(([key, value]) => {
     editor[key] = value;

+ 2 - 0
src/lib/v-remixicon.js

@@ -99,6 +99,7 @@ import {
   riRecordCircleLine,
   riErrorWarningLine,
   riExternalLinkLine,
+  riUploadCloud2Line,
   riFileDownloadLine,
   riShieldKeyholeLine,
   riArrowDropDownLine,
@@ -208,6 +209,7 @@ export const icons = {
   riRecordCircleLine,
   riErrorWarningLine,
   riExternalLinkLine,
+  riUploadCloud2Line,
   riFileDownloadLine,
   riShieldKeyholeLine,
   riArrowDropDownLine,

+ 1 - 0
src/locales/en/blocks.json

@@ -306,6 +306,7 @@
           "options": {
             "data-columns": "Table",
             "google-sheets": "Google sheets",
+            "variable": "Variable"
           },
         }
       },

+ 7 - 1
src/locales/en/newtab.json

@@ -25,12 +25,17 @@
       "duplicate": "Shortcut already use by \"{name}\""
     },
     "editor": {
+      "title": "Title",
       "curvature": {
-        "title": "Editor Line Curvature",
+        "title": "Line Curvature",
         "line": "Line",
         "reroute": "Reroute",
         "rerouteFirstLast": "Reroute first & last point"
       },
+      "arrow": {
+        "title": "Line arrow",
+        "description": "Add an arrow at the end of the line"
+      }
     },
     "language": {
       "label": "Language",
@@ -234,6 +239,7 @@
   },
   "log": {
     "goBack": "Go back to \"{name}\" log",
+    "goWorkflow": "Go to workflow",
     "startedDate": "Started date",
     "duration": "Duration",
     "selectAll": "Select all",

+ 1 - 0
src/newtab/pages/Settings.vue

@@ -39,6 +39,7 @@ const { t } = useI18n();
 const menus = [
   { id: 'general', path: '/settings', icon: 'riSettings3Line' },
   { id: 'backup', path: '/backup', icon: 'riDatabase2Line' },
+  { id: 'editor', path: '/editor', icon: 'riMindMap' },
   { id: 'shortcuts', path: '/shortcuts', icon: 'riKeyboardLine' },
   { id: 'about', path: '/about', icon: 'riInformationLine' },
 ];

+ 137 - 97
src/newtab/pages/Workflows.vue

@@ -133,103 +133,133 @@
             </ui-button>
           </div>
         </div>
-        <div v-else class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
-          <shared-card
-            v-for="workflow in workflows"
-            :key="workflow.id"
-            :data="workflow"
-            @click="$router.push(`/workflows/${$event.id}`)"
-          >
-            <template #header>
-              <div class="flex items-center mb-4">
-                <template v-if="!workflow.isDisabled">
-                  <ui-img
-                    v-if="workflow.icon.startsWith('http')"
-                    :src="workflow.icon"
-                    class="rounded-lg overflow-hidden"
-                    style="height: 40px; width: 40px"
-                    alt="Can not display"
+        <template v-else>
+          <div class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
+            <shared-card
+              v-for="workflow in localWorkflows"
+              :key="workflow.id"
+              :data="workflow"
+              @click="$router.push(`/workflows/${$event.id}`)"
+            >
+              <template #header>
+                <div class="flex items-center mb-4">
+                  <template v-if="!workflow.isDisabled">
+                    <ui-img
+                      v-if="workflow.icon.startsWith('http')"
+                      :src="workflow.icon"
+                      class="rounded-lg overflow-hidden"
+                      style="height: 40px; width: 40px"
+                      alt="Can not display"
+                    />
+                    <span v-else class="p-2 rounded-lg bg-box-transparent">
+                      <v-remixicon :name="workflow.icon" />
+                    </span>
+                  </template>
+                  <p v-else class="py-2">{{ t('common.disabled') }}</p>
+                  <div class="flex-grow"></div>
+                  <button
+                    v-if="!workflow.isDisabled"
+                    class="invisible group-hover:visible"
+                    @click="executeWorkflow(workflow)"
+                  >
+                    <v-remixicon name="riPlayLine" />
+                  </button>
+                  <v-remixicon
+                    v-if="workflow.isProtected"
+                    name="riShieldKeyholeLine"
+                    class="text-green-600 dark:text-green-400 ml-2"
                   />
-                  <span v-else class="p-2 rounded-lg bg-box-transparent">
-                    <v-remixicon :name="workflow.icon" />
-                  </span>
-                </template>
-                <p v-else class="py-2">{{ t('common.disabled') }}</p>
-                <div class="flex-grow"></div>
-                <button
-                  v-if="!workflow.isDisabled"
-                  class="invisible group-hover:visible"
-                  @click="executeWorkflow(workflow)"
-                >
-                  <v-remixicon name="riPlayLine" />
-                </button>
+                  <ui-popover v-if="!workflow.isProtected" class="h-6 ml-2">
+                    <template #trigger>
+                      <button>
+                        <v-remixicon name="riMoreLine" />
+                      </button>
+                    </template>
+                    <ui-list class="space-y-1" style="min-width: 150px">
+                      <ui-list-item
+                        class="cursor-pointer"
+                        @click="
+                          updateWorkflow(workflow.id, {
+                            isDisabled: !workflow.isDisabled,
+                          })
+                        "
+                      >
+                        <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+                        <span class="capitalize">
+                          {{
+                            t(
+                              `common.${
+                                workflow.isDisabled ? 'enable' : 'disable'
+                              }`
+                            )
+                          }}
+                        </span>
+                      </ui-list-item>
+                      <ui-list-item
+                        v-for="item in menu"
+                        :key="item.id"
+                        v-close-popover
+                        class="cursor-pointer"
+                        @click="menuHandlers[item.id](workflow)"
+                      >
+                        <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+                        <span class="capitalize">{{ item.name }}</span>
+                      </ui-list-item>
+                    </ui-list>
+                  </ui-popover>
+                </div>
+              </template>
+              <template #footer-content>
                 <v-remixicon
-                  v-if="workflow.isProtected"
-                  name="riShieldKeyholeLine"
-                  class="text-green-600 dark:text-green-400 ml-2"
+                  v-if="sharedWorkflows[workflow.id]"
+                  v-tooltip="
+                    t('workflow.share.sharedAs', {
+                      name: sharedWorkflows[workflow.id]?.name.slice(0, 64),
+                    })
+                  "
+                  name="riShareLine"
+                  size="20"
+                  class="ml-2"
                 />
-                <ui-popover v-if="!workflow.isProtected" class="h-6 ml-2">
-                  <template #trigger>
-                    <button>
-                      <v-remixicon name="riMoreLine" />
-                    </button>
-                  </template>
-                  <ui-list class="space-y-1" style="min-width: 150px">
-                    <ui-list-item
-                      class="cursor-pointer"
-                      @click="
-                        updateWorkflow(workflow.id, {
-                          isDisabled: !workflow.isDisabled,
-                        })
-                      "
-                    >
-                      <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
-                      <span class="capitalize">
-                        {{
-                          t(
-                            `common.${
-                              workflow.isDisabled ? 'enable' : 'disable'
-                            }`
-                          )
-                        }}
-                      </span>
-                    </ui-list-item>
-                    <ui-list-item
-                      v-for="item in menu"
-                      :key="item.id"
-                      v-close-popover
-                      class="cursor-pointer"
-                      @click="menuHandlers[item.id](workflow)"
-                    >
-                      <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-                      <span class="capitalize">{{ item.name }}</span>
-                    </ui-list-item>
-                  </ui-list>
-                </ui-popover>
-              </div>
-            </template>
-            <template #footer-content>
-              <v-remixicon
-                v-if="sharedWorkflows[workflow.id]"
-                v-tooltip="
-                  t('workflow.share.sharedAs', {
-                    name: sharedWorkflows[workflow.id]?.name.slice(0, 64),
-                  })
-                "
-                name="riShareLine"
-                size="20"
-                class="ml-2"
-              />
-              <v-remixicon
-                v-if="hostWorkflows[workflow.id]"
-                v-tooltip="t('workflow.host.title')"
-                name="riBaseStationLine"
-                size="20"
-                class="ml-2"
-              />
-            </template>
-          </shared-card>
-        </div>
+                <v-remixicon
+                  v-if="hostWorkflows[workflow.id]"
+                  v-tooltip="t('workflow.host.title')"
+                  name="riBaseStationLine"
+                  size="20"
+                  class="ml-2"
+                />
+              </template>
+            </shared-card>
+          </div>
+          <div
+            v-if="workflows.length > 16"
+            class="flex items-center justify-between mt-8"
+          >
+            <div>
+              {{ t('components.pagination.text1') }}
+              <select
+                v-model="pagination.perPage"
+                class="p-1 rounded-md bg-input"
+              >
+                <option
+                  v-for="num in [16, 32, 64, 128]"
+                  :key="num"
+                  :value="num"
+                >
+                  {{ num }}
+                </option>
+              </select>
+              {{
+                t('components.pagination.text2', { count: workflows.length })
+              }}
+            </div>
+            <ui-pagination
+              v-model="pagination.currentPage"
+              :per-page="pagination.perPage"
+              :records="workflows.length"
+            />
+          </div>
+        </template>
       </ui-tab-panel>
     </ui-tab-panels>
     <ui-modal v-model="workflowModal.show" title="Workflow">
@@ -313,6 +343,10 @@ const workflowModal = shallowReactive({
   type: 'update',
   description: '',
 });
+const pagination = shallowReactive({
+  currentPage: 1,
+  perPage: savedSorts.perPage || 16,
+});
 
 const hostWorkflows = computed(() => store.state.hostWorkflows || {});
 const workflowHosts = computed(() => Object.values(store.state.workflowHosts));
@@ -325,6 +359,12 @@ const workflows = computed(() =>
     .orderBy(state.sortBy, state.sortOrder)
     .get()
 );
+const localWorkflows = computed(() =>
+  workflows.value.slice(
+    (pagination.currentPage - 1) * pagination.perPage,
+    pagination.currentPage * pagination.perPage
+  )
+);
 
 async function deleteWorkflowHost(workflow) {
   dialog.confirm({
@@ -529,11 +569,11 @@ const menuHandlers = {
 };
 
 watch(
-  () => [state.sortOrder, state.sortBy],
-  ([sortOrder, sortBy]) => {
+  () => [state.sortOrder, state.sortBy, pagination.perPage],
+  ([sortOrder, sortBy, perPage]) => {
     localStorage.setItem(
       'workflow-sorts',
-      JSON.stringify({ sortOrder, sortBy })
+      JSON.stringify({ sortOrder, sortBy, perPage })
     );
   }
 );

+ 31 - 2
src/newtab/pages/logs/[id].vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="container pt-8 pb-4">
+  <div v-if="activeLog" class="container pt-8 pb-4">
     <div class="flex items-center mb-8">
       <div>
         <h1 class="text-2xl max-w-md text-overflow font-semibold">
@@ -16,6 +16,15 @@
         </p>
       </div>
       <div class="flex-grow"></div>
+      <ui-button
+        v-if="workflowExists"
+        v-tooltip="t('log.goWorkflow')"
+        icon
+        class="mr-4"
+        @click="goToWorkflow"
+      >
+        <v-remixicon name="riExternalLinkLine" />
+      </ui-button>
       <ui-button class="text-red-500 dark:text-red-400" @click="deleteLog">
         {{ t('common.delete') }}
       </ui-button>
@@ -133,6 +142,7 @@ import { useRoute, useRouter } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import browser from 'webextension-polyfill';
 import Log from '@/models/log';
+import Workflow from '@/models/workflow';
 import dayjs from '@/lib/dayjs';
 import { countDuration } from '@/utils/helper';
 import LogsDataViewer from '@/components/newtab/logs/LogsDataViewer.vue';
@@ -204,7 +214,6 @@ const history = computed(() =>
     )
     .map(translateLog)
 );
-
 const collectionLog = computed(() => {
   if (activeLog.value.parentLog) {
     return Log.find(activeLog.value.parentLog.id);
@@ -212,12 +221,32 @@ const collectionLog = computed(() => {
 
   return Log.find(activeLog.value.collectionLogId);
 });
+const workflowExists = computed(() =>
+  Workflow.find(activeLog.value.workflowId)
+);
 
 function deleteLog() {
   Log.delete(route.params.id).then(() => {
+    const backHistory = window.history.state.back;
+
+    if (backHistory.startsWith('/workflows')) {
+      router.replace(backHistory);
+      return;
+    }
+
     router.replace('/logs');
   });
 }
+function goToWorkflow() {
+  const backHistory = window.history.state.back;
+  let path = `/workflows/${activeLog.value.workflowId}`;
+
+  if (backHistory.startsWith(path)) {
+    path = backHistory;
+  }
+
+  router.push(path);
+}
 
 onMounted(async () => {
   if (!activeLog.value) router.replace('/logs');

+ 33 - 39
src/newtab/pages/settings/SettingsBackup.vue

@@ -4,44 +4,40 @@
       <h2 class="font-semibold mb-2">
         {{ t('settings.backupWorkflows.cloud.title') }}
       </h2>
-      <template v-if="$store.state.user.subscription !== 'free'">
-        <div
-          class="border dark:border-gray-700 p-4 rounded-lg flex items-center"
-        >
-          <span class="inline-block p-2 rounded-full bg-box-transparent">
-            <v-remixicon name="riUploadLine" />
-          </span>
-          <div class="flex-1 ml-4 leading-tight">
-            <p class="text-sm text-gray-600 dark:text-gray-200">
-              {{ t('settings.backupWorkflows.cloud.lastBackup') }}
-            </p>
-            <p>{{ formatDate(state.lastBackup) }}</p>
-          </div>
-          <ui-button
-            :loading="backupState.loading"
-            @click="backupState.modal = true"
-          >
-            {{ t('settings.backupWorkflows.backup.button') }}
-          </ui-button>
-        </div>
-        <div
-          class="border dark:border-gray-700 p-4 rounded-lg flex items-center mt-2"
-        >
-          <span class="inline-block p-2 rounded-full bg-box-transparent">
-            <v-remixicon name="riDownloadLine" />
-          </span>
-          <p class="flex-1 ml-4">
-            {{ t('settings.backupWorkflows.cloud.sync') }}
+      <div class="border dark:border-gray-700 p-4 rounded-lg flex items-center">
+        <span class="inline-block p-2 rounded-full bg-box-transparent">
+          <v-remixicon name="riUploadLine" />
+        </span>
+        <div class="flex-1 ml-4 leading-tight">
+          <p class="text-sm text-gray-600 dark:text-gray-200">
+            {{ t('settings.backupWorkflows.cloud.lastBackup') }}
           </p>
-          <ui-button
-            :loading="state.loadingSync"
-            class="ml-2"
-            @click="syncBackupWorkflows"
-          >
-            {{ t('settings.backupWorkflows.cloud.sync') }}
-          </ui-button>
+          <p>{{ formatDate(state.lastBackup) }}</p>
         </div>
-      </template>
+        <ui-button
+          :loading="backupState.loading"
+          @click="backupState.modal = true"
+        >
+          {{ t('settings.backupWorkflows.backup.button') }}
+        </ui-button>
+      </div>
+      <div
+        class="border dark:border-gray-700 p-4 rounded-lg flex items-center mt-2"
+      >
+        <span class="inline-block p-2 rounded-full bg-box-transparent">
+          <v-remixicon name="riDownloadLine" />
+        </span>
+        <p class="flex-1 ml-4">
+          {{ t('settings.backupWorkflows.cloud.sync') }}
+        </p>
+        <ui-button
+          :loading="state.loadingSync"
+          class="ml-2"
+          @click="syncBackupWorkflows"
+        >
+          {{ t('settings.backupWorkflows.cloud.sync') }}
+        </ui-button>
+      </div>
       <p v-if="false">
         Upgrade to the
         <a
@@ -88,10 +84,8 @@
   </div>
   <ui-modal
     v-model="backupState.modal"
+    :title="t('settings.backupWorkflows.cloud.title')"
     content-class="max-w-4xl"
-    persist
-    blur
-    custom-content
   >
     <settings-cloud-backup
       v-model:ids="backupState.ids"

+ 103 - 0
src/newtab/pages/settings/SettingsEditor.vue

@@ -0,0 +1,103 @@
+<template>
+  <div class="max-w-2xl">
+    <p class="font-semibold">
+      {{ t('settings.editor.curvature.title') }}
+    </p>
+    <div class="flex items-center space-x-4 mt-2">
+      <div
+        v-for="item in curvatureOptions"
+        :key="item.id"
+        class="cursor-pointer"
+        role="button"
+        @click="updateSetting('disableCurvature', item.value)"
+      >
+        <div
+          :class="{
+            'ring ring-accent': item.value === settings.editor.disableCurvature,
+          }"
+          class="p-0.5 rounded-lg"
+        >
+          <img
+            :src="require(`@/assets/images/${item.id}.png`).default"
+            width="140"
+            class="rounded-lg"
+          />
+        </div>
+        <span class="text-sm text-gray-600 dark:text-gray-200 ml-1">
+          {{ t(`common.${item.name}`) }}
+        </span>
+      </div>
+    </div>
+    <transition-expand>
+      <div
+        v-if="!settings.editor.disableCurvature"
+        class="flex space-x-2 items-end mt-1"
+      >
+        <ui-input
+          v-for="item in curvatureSettings"
+          :key="item.id"
+          :model-value="settings.editor[item.key]"
+          :label="t(`settings.editor.curvature.${item.id}`)"
+          type="number"
+          min="0"
+          max="1"
+          class="w-full"
+          placeholder="0.5"
+          @change="updateSetting(item.key, curvatureLimit($event))"
+        />
+      </div>
+    </transition-expand>
+    <ui-list class="mt-8">
+      <ui-list-item small>
+        <ui-switch
+          :model-value="settings.editor.arrow"
+          @change="updateSetting('arrow', $event)"
+        />
+        <div class="flex-1 ml-4">
+          <p class="leading-tight">
+            {{ t('settings.editor.arrow.title') }}
+          </p>
+          <p class="text-gray-600 text-sm leading-tight dark:text-gray-200">
+            {{ t('settings.editor.arrow.description') }}
+          </p>
+        </div>
+      </ui-list-item>
+    </ui-list>
+  </div>
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import browser from 'webextension-polyfill';
+
+const curvatureSettings = [
+  { id: 'line', key: 'curvature' },
+  { id: 'reroute', key: 'reroute_curvature' },
+  { id: 'rerouteFirstLast', key: 'reroute_curvature_start_end' },
+];
+const curvatureOptions = [
+  { id: 'curvature', value: false, name: 'enable' },
+  { id: 'no-curvature', value: true, name: 'disable' },
+];
+
+const { t } = useI18n();
+const store = useStore();
+
+const settings = computed(() => store.state.settings);
+
+function updateSetting(path, value) {
+  store.commit('updateStateNested', {
+    value,
+    path: `settings.editor.${path}`,
+  });
+
+  browser.storage.local.set({ settings: settings.value });
+}
+function curvatureLimit(value) {
+  if (value > 1) return 1;
+  if (value < 0) return 0;
+
+  return value;
+}
+</script>

+ 0 - 31
src/newtab/pages/settings/SettingsIndex.vue

@@ -54,25 +54,6 @@
       {{ t('settings.language.reloadPage') }}
     </p>
   </div>
-  <div class="mt-12 max-w-2xl">
-    <p class="font-semibold">
-      {{ t('settings.editor.curvature.title') }}
-    </p>
-    <div class="flex space-x-2 items-end">
-      <ui-input
-        v-for="item in curvatureSettings"
-        :key="item.id"
-        :model-value="settings.editor[item.key]"
-        :label="t(`settings.editor.curvature.${item.id}`)"
-        type="number"
-        min="0"
-        max="1"
-        class="w-full"
-        placeholder="0.5"
-        @change="updateSetting(`editor.${item.key}`, curvatureLimit($event))"
-      />
-    </div>
-  </div>
 </template>
 <script setup>
 import { computed, ref } from 'vue';
@@ -82,12 +63,6 @@ import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
 import { supportLocales } from '@/utils/shared';
 
-const curvatureSettings = [
-  { id: 'line', key: 'curvature' },
-  { id: 'reroute', key: 'reroute_curvature' },
-  { id: 'rerouteFirstLast', key: 'reroute_curvature_start_end' },
-];
-
 const { t } = useI18n();
 const store = useStore();
 const theme = useTheme();
@@ -95,12 +70,6 @@ const theme = useTheme();
 const isLangChange = ref(false);
 const settings = computed(() => store.state.settings);
 
-function curvatureLimit(value) {
-  if (value > 1) return 1;
-  if (value < 0) return 0;
-
-  return value;
-}
 function updateSetting(path, value) {
   store.commit('updateStateNested', {
     value,

+ 7 - 4
src/newtab/pages/workflows/[id].vue

@@ -262,8 +262,10 @@ const router = useRouter();
 const dialog = useDialog();
 const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
 
+const activeTabQuery = route.query.tab || 'editor';
+
 const editor = shallowRef(null);
-const activeTab = shallowRef('editor');
+const activeTab = shallowRef(activeTabQuery);
 
 const autocomplete = reactive({
   cache: null,
@@ -658,9 +660,7 @@ async function setAsHostWorkflow(isHost) {
   } catch (error) {
     console.error(error);
     workflowData.loadingHost = false;
-    toast.error(
-      error?.data?.show ? error.message : t('message.somethingWrong')
-    );
+    toast.error(error.message);
   }
 }
 function shareWorkflow() {
@@ -811,6 +811,9 @@ provide('workflow', {
   },
 });
 
+watch(activeTab, (value) => {
+  router.replace({ ...route, query: { tab: value } });
+});
 watch(() => workflowPayload.data, throttle(updateHostedWorkflow, 5000), {
   deep: true,
 });

+ 2 - 0
src/newtab/router.js

@@ -13,6 +13,7 @@ import SettingsIndex from './pages/settings/SettingsIndex.vue';
 import SettingsAbout from './pages/settings/SettingsAbout.vue';
 import SettingsShortcuts from './pages/settings/SettingsShortcuts.vue';
 import SettingsBackup from './pages/settings/SettingsBackup.vue';
+import SettingsEditor from './pages/settings/SettingsEditor.vue';
 
 const routes = [
   {
@@ -67,6 +68,7 @@ const routes = [
       { path: '', component: SettingsIndex },
       { path: '/about', component: SettingsAbout },
       { path: '/backup', component: SettingsBackup },
+      { path: '/editor', component: SettingsEditor },
       { path: '/shortcuts', component: SettingsShortcuts },
     ],
   },

+ 4 - 1
src/store/index.js

@@ -2,6 +2,7 @@ import { createStore } from 'vuex';
 import objectPath from 'object-path';
 import browser from 'webextension-polyfill';
 import vuexORM from '@/lib/vuex-orm';
+import defu from 'defu';
 import * as models from '@/models';
 import { firstWorkflows } from '@/utils/shared';
 import { fetchApi } from '@/utils/api';
@@ -21,6 +22,8 @@ const store = createStore({
     settings: {
       locale: 'en',
       editor: {
+        arrow: false,
+        disableCurvature: false,
         curvature: 0.5,
         reroute_curvature: 0.5,
         reroute_curvature_start_end: 0.5,
@@ -69,7 +72,7 @@ const store = createStore({
 
         commit('updateState', {
           key: 'settings',
-          value: { ...state.settings, ...(settings || {}) },
+          value: defu(settings || {}, state.settings),
         });
         commit('updateState', {
           key: 'workflowHosts',

+ 8 - 2
src/utils/data-exporter.js

@@ -51,10 +51,16 @@ export default function (
         ? Papa.unparse(jsonData)
         : JSON.stringify(jsonData, null, 2);
   } else if (type === 'plain-text') {
+    const extractObj = (obj) => {
+      if (typeof obj !== 'object') return [obj];
+
+      return Object.values(obj);
+    };
+
     result = (
       Array.isArray(data)
-        ? data.map((item) => Object.values(item)).flat()
-        : Object.values(data)
+        ? data.flatMap((item) => extractObj(item))
+        : extractObj(data)
     ).join(' ');
   }
 

+ 4 - 1
src/utils/reference-data/mustache-replacer.js

@@ -1,5 +1,6 @@
 import objectPath from 'object-path';
 import dayjs from '@/lib/dayjs';
+import { parseJSON } from '@/utils/helper';
 
 const refKeys = {
   table: 'table',
@@ -35,7 +36,9 @@ export const functions = {
     return Math.round(Math.random() * (+max - +min) + +min);
   },
   getLength(str) {
-    return str.length ?? str;
+    const value = parseJSON(str, str);
+
+    return value.length ?? value;
   },
 };
 

+ 2 - 1
src/utils/shared.js

@@ -321,6 +321,7 @@ export const tasks = {
       refKey: '',
       type: 'json',
       description: '',
+      variableName: '',
       addBOMHeader: false,
       onConflict: 'uniquify',
       dataToExport: 'data-columns',
@@ -430,7 +431,7 @@ export const tasks = {
       selected: true,
       clearValue: true,
       getValue: false,
-      saveData: true,
+      saveData: false,
       dataColumn: '',
       assignVariable: false,
       variableName: '',

+ 9 - 4
yarn.lock

@@ -1652,6 +1652,11 @@
     "@types/prosemirror-state" "*"
     "@types/prosemirror-transform" "*"
 
+"@viselect/vanilla@^3.0.0-beta.13":
+  version "3.0.0-beta.13"
+  resolved "https://registry.yarnpkg.com/@viselect/vanilla/-/vanilla-3.0.0-beta.13.tgz#cb2ac109701ba25923a885e2ba691fb82302e243"
+  integrity sha512-ML6uLrIpAgtFMRDXc5NfC2K7LiD+IzcsmUfYxrUVvvJgKfxm/eZjhHBvNVbJfto9SHsD9o+KjxJR/iexFyRLVg==
+
 "@vue/compiler-core@3.2.19":
   version "3.2.19"
   resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.19.tgz#b537dd377ce51fdb64e9b30ebfbff7cd70a64cb9"
@@ -3076,10 +3081,10 @@ defined@^1.0.0:
   resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
   integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=
 
-defu@^5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/defu/-/defu-5.0.1.tgz#a034278f9b032bf0845d261aa75e9ad98da878ac"
-  integrity sha512-EPS1carKg+dkEVy3qNTqIdp2qV7mUP08nIsupfwQpz++slCVRw7qbQyWvSTig+kFPwz2XXp5/kIIkH+CwrJKkQ==
+defu@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/defu/-/defu-6.0.0.tgz#b397a6709a2f3202747a3d9daf9446e41ad0c5fc"
+  integrity sha512-t2MZGLf1V2rV4VBZbWIaXKdX/mUcYW0n2znQZoADBkGGxYL8EWqCuCZBmJPJ/Yy9fofJkyuuSuo5GSwo0XdEgw==
 
 del@^4.1.1:
   version "4.1.1"