Ahmad Kholid 3 年之前
父節點
當前提交
5ee0a3e285
共有 100 個文件被更改,包括 3330 次插入932 次删除
  1. 1 0
      .eslintrc.js
  2. 9 6
      package.json
  3. 2 3
      src/assets/css/drawflow.css
  4. 31 16
      src/assets/css/tailwind.css
  5. 二進制
      src/assets/images/tile-white.png
  6. 192 69
      src/background/index.js
  7. 41 0
      src/background/workflow-engine/blocks-handler/handler-clipboard.js
  8. 4 2
      src/background/workflow-engine/blocks-handler/handler-delay.js
  9. 13 1
      src/background/workflow-engine/blocks-handler/handler-element-exists.js
  10. 2 1
      src/background/workflow-engine/blocks-handler/handler-execute-workflow.js
  11. 1 1
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  12. 5 8
      src/background/workflow-engine/blocks-handler/handler-google-sheets.js
  13. 24 0
      src/background/workflow-engine/blocks-handler/handler-insert-data.js
  14. 47 24
      src/background/workflow-engine/blocks-handler/handler-interaction-block.js
  15. 39 31
      src/background/workflow-engine/blocks-handler/handler-loop-data.js
  16. 7 5
      src/background/workflow-engine/blocks-handler/handler-new-tab.js
  17. 44 0
      src/background/workflow-engine/blocks-handler/handler-switch-tab.js
  18. 21 27
      src/background/workflow-engine/blocks-handler/handler-take-screenshot.js
  19. 46 2
      src/background/workflow-engine/blocks-handler/handler-webhook.js
  20. 33 9
      src/background/workflow-engine/engine.js
  21. 5 2
      src/components/block/BlockBase.vue
  22. 25 23
      src/components/block/BlockBasic.vue
  23. 2 2
      src/components/block/BlockConditions.vue
  24. 1 1
      src/components/block/BlockDelay.vue
  25. 2 2
      src/components/block/BlockElementExists.vue
  26. 1 1
      src/components/block/BlockGroup.vue
  27. 1 1
      src/components/block/BlockLoopBreakpoint.vue
  28. 5 5
      src/components/block/BlockRepeatTask.vue
  29. 38 0
      src/components/block/BlockWebhook.vue
  30. 23 2
      src/components/newtab/app/AppSidebar.vue
  31. 37 13
      src/components/newtab/logs/LogsDataViewer.vue
  32. 4 2
      src/components/newtab/logs/LogsFilters.vue
  33. 424 0
      src/components/newtab/settings/SettingsCloudBackup.vue
  34. 29 8
      src/components/newtab/shared/SharedCard.vue
  35. 1 1
      src/components/newtab/shared/SharedCodemirror.vue
  36. 7 7
      src/components/newtab/shared/SharedLogsTable.vue
  37. 1 1
      src/components/newtab/shared/SharedWorkflowState.vue
  38. 96 0
      src/components/newtab/shared/SharedWysiwyg.vue
  39. 79 17
      src/components/newtab/workflow/WorkflowActions.vue
  40. 100 27
      src/components/newtab/workflow/WorkflowBuilder.vue
  41. 5 5
      src/components/newtab/workflow/WorkflowDataTable.vue
  42. 21 4
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  43. 7 2
      src/components/newtab/workflow/WorkflowEditBlock.vue
  44. 1 1
      src/components/newtab/workflow/WorkflowRunning.vue
  45. 3 2
      src/components/newtab/workflow/WorkflowSettings.vue
  46. 325 0
      src/components/newtab/workflow/WorkflowShare.vue
  47. 148 0
      src/components/newtab/workflow/WorkflowSharedActions.vue
  48. 52 45
      src/components/newtab/workflow/edit/EditAttributeValue.vue
  49. 1 1
      src/components/newtab/workflow/edit/EditBrowserEvent.vue
  50. 103 0
      src/components/newtab/workflow/edit/EditClipboard.vue
  51. 19 8
      src/components/newtab/workflow/edit/EditCloseTab.vue
  52. 3 3
      src/components/newtab/workflow/edit/EditConditions.vue
  53. 10 1
      src/components/newtab/workflow/edit/EditElementExists.vue
  54. 15 5
      src/components/newtab/workflow/edit/EditExecuteWorkflow.vue
  55. 4 4
      src/components/newtab/workflow/edit/EditExportData.vue
  56. 37 29
      src/components/newtab/workflow/edit/EditForms.vue
  57. 72 65
      src/components/newtab/workflow/edit/EditGetText.vue
  58. 2 3
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  59. 116 0
      src/components/newtab/workflow/edit/EditInsertData.vue
  60. 42 16
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  61. 36 10
      src/components/newtab/workflow/edit/EditJavascriptCode.vue
  62. 40 16
      src/components/newtab/workflow/edit/EditLoopData.vue
  63. 5 13
      src/components/newtab/workflow/edit/EditNewTab.vue
  64. 14 8
      src/components/newtab/workflow/edit/EditNewWindow.vue
  65. 1 1
      src/components/newtab/workflow/edit/EditProxy.vue
  66. 59 0
      src/components/newtab/workflow/edit/EditSwitchTab.vue
  67. 46 39
      src/components/newtab/workflow/edit/EditTakeScreenshot.vue
  68. 39 54
      src/components/newtab/workflow/edit/EditTrigger.vue
  69. 3 2
      src/components/newtab/workflow/edit/EditTriggerEvent.vue
  70. 96 10
      src/components/newtab/workflow/edit/EditWebhook.vue
  71. 1 1
      src/components/newtab/workflow/edit/TriggerEventKeyboard.vue
  72. 7 3
      src/components/ui/UiButton.vue
  73. 10 3
      src/components/ui/UiCheckbox.vue
  74. 93 60
      src/components/ui/UiDialog.vue
  75. 6 1
      src/components/ui/UiExpand.vue
  76. 6 4
      src/components/ui/UiInput.vue
  77. 2 2
      src/components/ui/UiModal.vue
  78. 6 1
      src/components/ui/UiPopover.vue
  79. 1 0
      src/components/ui/UiSwitch.vue
  80. 31 6
      src/components/ui/UiTab.vue
  81. 58 36
      src/components/ui/UiTabs.vue
  82. 17 15
      src/components/ui/UiTextarea.vue
  83. 12 5
      src/composable/dialog.js
  84. 12 6
      src/composable/groupTooltip.js
  85. 7 3
      src/composable/shortcut.js
  86. 53 0
      src/composable/theme.js
  87. 9 6
      src/content/blocks-handler/handler-attribute-value.js
  88. 10 7
      src/content/blocks-handler/handler-element-exists.js
  89. 2 2
      src/content/blocks-handler/handler-element-scroll.js
  90. 2 2
      src/content/blocks-handler/handler-event-click.js
  91. 27 36
      src/content/blocks-handler/handler-forms.js
  92. 2 2
      src/content/blocks-handler/handler-get-text.js
  93. 29 9
      src/content/blocks-handler/handler-javascript-code.js
  94. 10 13
      src/content/blocks-handler/handler-link.js
  95. 2 2
      src/content/blocks-handler/handler-switch-to.js
  96. 2 2
      src/content/blocks-handler/handler-trigger-event.js
  97. 2 2
      src/content/blocks-handler/handler-upload-file.js
  98. 60 16
      src/content/element-selector/App.vue
  99. 37 19
      src/content/element-selector/AppSelector.vue
  100. 46 1
      src/content/handle-selector.js

+ 1 - 0
.eslintrc.js

@@ -30,6 +30,7 @@ module.exports = {
   // add your custom rules here
   rules: {
     'no-undef': 'off',
+    'no-await-in-loop': 'off',
     'no-console': ['warn', { allow: ['warn', 'error'] }],
     'no-underscore-dangle': 'off',
     'func-names': 'off',

+ 9 - 6
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.17.4",
+  "version": "1.0.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -27,6 +27,12 @@
     "@codemirror/lang-json": "^0.19.1",
     "@codemirror/theme-one-dark": "^0.19.1",
     "@medv/finder": "^2.1.0",
+    "@tiptap/extension-character-count": "^2.0.0-beta.24",
+    "@tiptap/extension-image": "^2.0.0-beta.25",
+    "@tiptap/extension-link": "^2.0.0-beta.36",
+    "@tiptap/extension-placeholder": "^2.0.0-beta.48",
+    "@tiptap/starter-kit": "^2.0.0-beta.181",
+    "@tiptap/vue-3": "^2.0.0-beta.90",
     "@vuex-orm/core": "^0.36.4",
     "compare-versions": "^4.1.2",
     "crypto-js": "^4.1.1",
@@ -37,7 +43,7 @@
     "mitt": "^3.0.0",
     "mousetrap": "^1.6.5",
     "nanoid": "^3.2.0",
-    "object-path-immutable": "^4.1.2",
+    "object-path": "^0.11.8",
     "papaparse": "^5.3.1",
     "tippy.js": "^6.3.1",
     "v-remixicon": "^0.1.1",
@@ -52,13 +58,12 @@
   "devDependencies": {
     "@babel/core": "7.15.5",
     "@babel/eslint-parser": "7.15.7",
-    "@babel/plugin-proposal-class-properties": "7.14.5",
     "@babel/preset-env": "7.15.6",
     "@intlify/vue-i18n-loader": "^4.0.1",
+    "@tailwindcss/typography": "^0.5.1",
     "@vue/compiler-sfc": "3.2.19",
     "archiver": "^5.3.0",
     "autoprefixer": "10.3.6",
-    "babel-eslint": "^10.1.0",
     "babel-loader": "^8.2.2",
     "clean-webpack-plugin": "4.0.0",
     "copy-webpack-plugin": "9.0.1",
@@ -69,7 +74,6 @@
     "eslint-config-prettier": "^8.3.0",
     "eslint-friendly-formatter": "^4.0.1",
     "eslint-import-resolver-webpack": "^0.13.2",
-    "eslint-plugin-flowtype": "6.1.0",
     "eslint-plugin-import": "^2.24.2",
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-vue": "7.18.0",
@@ -84,7 +88,6 @@
     "prettier": "^2.4.1",
     "simple-git-hooks": "^2.6.1",
     "source-map-loader": "3.0.0",
-    "style-loader": "3.3.0",
     "tailwindcss": "^3.0.7",
     "terser-webpack-plugin": "5.2.4",
     "vue-loader": "16.8.1",

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

@@ -63,17 +63,16 @@
   position: relative;
   width: 18px;
   height: 18px;
-  background: #fff;
   border-radius: 50%;
   cursor: crosshair;
   z-index: 1;
   margin-bottom: 5px;
-  border-color: theme('colors.accent');
   border-width: 3px;
+  @apply border-accent bg-white dark:bg-gray-900;
 }
 
 .drawflow .drawflow-node .input {
-  @apply bg-accent;
+  @apply bg-accent !important;
 }
 
 .drawflow .icon-ui {

+ 31 - 16
src/assets/css/tailwind.css

@@ -2,6 +2,36 @@
 @tailwind components;
 @tailwind utilities;
 
+@layer utilities {
+  .hoverable {
+    @apply hover:bg-gray-800 hover:bg-opacity-5 dark:hover:bg-gray-200 dark:hover:bg-opacity-5;
+  }
+  .bg-input {
+    @apply bg-black bg-opacity-5 hover:bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-5 dark:hover:bg-opacity-10;
+  }
+  .bg-box-transparent {
+    @apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
+  }
+  .bg-box-transparent-2 {
+    @apply bg-black bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-10;
+  }
+}
+
+:host, :root {
+  --color-primary: 59 130 246;
+  --color-secondary: 96 165 250;
+  --color-accent: 24 24 27;
+}
+.dark {
+  --color-primary: 96 165 250;
+  --color-secondary: 59 130 246;
+  --color-accent: 244 244 245;
+}
+
+* {
+  @apply dark:border-gray-700;
+}
+
 :host, body {
   font-family: 'Inter var';
   font-size: 16px;
@@ -48,7 +78,7 @@ pre {
   background: transparent;
 }
 .tippy-box[data-theme~='tooltip-theme'] {
-  @apply px-2 py-1 bg-gray-900 text-sm text-gray-200 rounded-md;
+  @apply px-2 py-1 bg-gray-900 dark:bg-gray-200 dark:text-black text-sm text-gray-200 rounded-md;
 }
 .Vue-Toastification__toast {
   font-family: inherit !important;
@@ -63,18 +93,3 @@ pre {
 .ProseMirror img.ProseMirror-selectednode {
   outline: 3px solid #68CEF8;
 }
-
-@layer utilities {
-  .hoverable {
-    @apply hover:bg-gray-800 hover:bg-opacity-5 dark:hover:bg-gray-200 dark:hover:bg-opacity-5;
-  }
-  .bg-input {
-    @apply bg-black bg-opacity-5 hover:bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-5 dark:hover:bg-opacity-10;
-  }
-  .bg-box-transparent {
-    @apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
-  }
-  .bg-box-transparent-2 {
-    @apply bg-black bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-10;
-  }
-}

二進制
src/assets/images/tile-white.png


+ 192 - 69
src/background/index.js

@@ -1,7 +1,8 @@
 import browser from 'webextension-polyfill';
 import { MessageListener } from '@/utils/message';
 import { registerSpecificDay } from '../utils/workflow-trigger';
-import { parseJSON } from '@/utils/helper';
+import { parseJSON, findTriggerBlock } from '@/utils/helper';
+import getFile from '@/utils/get-file';
 import WorkflowState from './workflow-state';
 import CollectionEngine from './collection-engine';
 import WorkflowEngine from './workflow-engine/engine';
@@ -9,6 +10,7 @@ import blocksHandler from './workflow-engine/blocks-handler';
 import WorkflowLogger from './workflow-logger';
 import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
 
+const validateUrl = (str) => str?.startsWith('http');
 const storage = {
   async get(key) {
     try {
@@ -32,12 +34,25 @@ const workflow = {
   states: new WorkflowState({ storage }),
   logger: new WorkflowLogger({ storage }),
   async get(workflowId) {
-    const { workflows } = await browser.storage.local.get('workflows');
-    const findWorkflow = workflows.find(({ id }) => id === workflowId);
+    const { workflows, workflowHosts } = await browser.storage.local.get([
+      'workflows',
+      'workflowHosts',
+    ]);
+    let findWorkflow = workflows.find(({ id }) => id === workflowId);
+
+    if (!findWorkflow) {
+      findWorkflow = Object.values(workflowHosts || {}).find(
+        ({ hostId }) => hostId === workflowId
+      );
+
+      if (findWorkflow) findWorkflow.id = findWorkflow.hostId;
+    }
 
     return findWorkflow;
   },
   execute(workflowData, options) {
+    if (workflowData.isDisabled) return null;
+
     if (workflowData.isProtected) {
       const flow = parseJSON(workflowData.drawflow, null);
 
@@ -65,6 +80,44 @@ const workflow = {
   },
 };
 
+async function updateRecording(callback) {
+  const { isRecording, recording } = await browser.storage.local.get([
+    'isRecording',
+    'recording',
+  ]);
+
+  if (!isRecording || !recording) return;
+
+  callback(recording);
+
+  await browser.storage.local.set({ recording });
+}
+async function openDashboard(url) {
+  const tabOptions = {
+    active: true,
+    url: browser.runtime.getURL(
+      `/newtab.html#${typeof url === 'string' ? url : ''}`
+    ),
+  };
+
+  try {
+    const [tab] = await browser.tabs.query({
+      url: browser.runtime.getURL('/newtab.html'),
+    });
+
+    if (tab) {
+      await browser.tabs.update(tab.id, tabOptions);
+
+      if (tab.url.includes('workflows/')) {
+        await browser.tabs.reload(tab.id);
+      }
+    } else {
+      browser.tabs.create(tabOptions);
+    }
+  } catch (error) {
+    console.error(error);
+  }
+}
 async function checkWorkflowStates() {
   const states = await workflow.states.get();
   // const sessionStates = parseJSON(sessionStorage.getItem('workflowState'), {});
@@ -89,7 +142,9 @@ async function checkWorkflowStates() {
   await storage.set('workflowState', states);
 }
 checkWorkflowStates();
-async function checkVisitWebTriggers(states, tab) {
+async function checkVisitWebTriggers(changeInfo, tab) {
+  if (!changeInfo.status || changeInfo.status !== 'complete') return;
+
   const visitWebTriggers = await storage.get('visitWebTriggers');
   const triggeredWorkflow = visitWebTriggers.find(({ url, isRegex }) => {
     if (url.trim() === '') return false;
@@ -103,25 +158,143 @@ async function checkVisitWebTriggers(states, tab) {
     if (workflowData) workflow.execute(workflowData);
   }
 }
+async function checkRecordingWorkflow({ status }, { url, id }) {
+  if (status === 'complete' && validateUrl(url)) {
+    const { isRecording } = await browser.storage.local.get('isRecording');
+
+    if (!isRecording) return;
+
+    await browser.tabs.executeScript(id, {
+      file: 'recordWorkflow.bundle.js',
+    });
+  }
+}
 browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
-  if (changeInfo.status === 'complete') {
-    await checkVisitWebTriggers(null, tab);
+  checkRecordingWorkflow(changeInfo, tab);
+  checkVisitWebTriggers(changeInfo, tab);
+});
+browser.commands.onCommand.addListener((name) => {
+  if (name === 'open-dashboard') openDashboard();
+});
+browser.webNavigation.onCommitted.addListener(
+  ({ frameId, tabId, url, transitionType }) => {
+    const allowedType = ['link', 'typed', 'form_submit'];
+
+    if (frameId !== 0 || !allowedType.includes(transitionType)) return;
+
+    updateRecording((recording) => {
+      if (tabId !== recording.activeTab.id) return;
+
+      const lastFlow = recording.flows[recording.flows.length - 1];
+      const isClickSubmit =
+        lastFlow.id === 'event-click' && transitionType === 'form_submit';
+
+      if (isClickSubmit) return;
+
+      const isInvalidNewtabFlow =
+        lastFlow &&
+        lastFlow.id === 'new-tab' &&
+        !validateUrl(lastFlow.data.url);
+
+      if (isInvalidNewtabFlow) {
+        lastFlow.data.url = url;
+        lastFlow.description = url;
+      } else if (validateUrl(url)) {
+        if (lastFlow?.id !== 'link' || !lastFlow.isClickLink) {
+          recording.flows.push({
+            id: 'new-tab',
+            description: url,
+            data: {
+              url,
+              updatePrevTab: recording.activeTab.id === tabId,
+            },
+          });
+        }
+
+        recording.activeTab.id = tabId;
+        recording.activeTab.url = url;
+      }
+    });
   }
+);
+browser.tabs.onActivated.addListener(async ({ tabId }) => {
+  const { url, id, title } = await browser.tabs.get(tabId);
+
+  if (!validateUrl(url)) return;
+
+  updateRecording((recording) => {
+    recording.activeTab = { id, url };
+    recording.flows.push({
+      id: 'switch-tab',
+      data: {
+        url,
+        matchPattern: url,
+        createIfNoMatch: true,
+        description: title || url,
+      },
+    });
+  });
 });
-browser.alarms.onAlarm.addListener(({ name }) => {
-  workflow.get(name).then((currentWorkflow) => {
-    if (!currentWorkflow) return;
+browser.tabs.onCreated.addListener(async (tab) => {
+  const { isRecording, recording } = await browser.storage.local.get([
+    'isRecording',
+    'recording',
+  ]);
+
+  if (!isRecording || !recording) return;
+
+  const url = tab.url || tab.pendingUrl;
+  const lastFlow = recording.flows[recording.flows.length - 1];
+  const invalidPrevFlow =
+    lastFlow && lastFlow.id === 'new-tab' && !validateUrl(lastFlow.data.url);
+
+  if (!invalidPrevFlow) {
+    const validUrl = validateUrl(url) ? url : '';
+
+    recording.flows.push({
+      id: 'new-tab',
+      data: {
+        url: validUrl,
+        description: tab.title || validUrl,
+      },
+    });
+  }
 
-    workflow.execute(currentWorkflow);
+  recording.activeTab = {
+    url,
+    id: tab.id,
+  };
 
-    const triggerBlock = Object.values(
-      JSON.parse(currentWorkflow.drawflow).drawflow.Home.data
-    ).find((block) => block.name === 'trigger');
+  await browser.storage.local.set({ recording });
+});
+browser.alarms.onAlarm.addListener(async ({ name }) => {
+  const currentWorkflow = await workflow.get(name);
+  if (!currentWorkflow) return;
+
+  const { data } = findTriggerBlock(JSON.parse(currentWorkflow.drawflow)) || {};
+  if (data && data.type === 'interval' && data.fixedDelay) {
+    const workflowState = await workflow.states.get(
+      ({ workflowId }) => name === workflowId
+    );
+
+    if (workflowState) {
+      let { workflowQueue } = await browser.storage.local.get('workflowQueue');
+      workflowQueue = workflowQueue || [];
+
+      if (!workflowQueue.includes(name)) {
+        (workflowQueue = workflowQueue || []).push(name);
+        await browser.storage.local.set({ workflowQueue });
+      }
 
-    if (triggerBlock?.data.type === 'specific-day') {
-      registerSpecificDay(currentWorkflow.id, triggerBlock.data);
+      return;
     }
-  });
+  }
+
+  workflow.execute(currentWorkflow);
+
+  if (data && data.type === 'specific-day') {
+    registerSpecificDay(currentWorkflow.id, triggerBlock.data);
+  }
 });
 
 chrome.runtime.onInstalled.addListener((details) => {
@@ -172,27 +345,9 @@ message.on('fetch:text', (url) => {
   return fetch(url).then((response) => response.text());
 });
 message.on('open:dashboard', async (url) => {
-  const tabOptions = {
-    active: true,
-    url: browser.runtime.getURL(
-      `/newtab.html#${typeof url === 'string' ? url : ''}`
-    ),
-  };
+  await openDashboard(url);
 
-  try {
-    const [tab] = await browser.tabs.query({
-      url: browser.runtime.getURL('/newtab.html'),
-    });
-
-    if (tab) {
-      await browser.tabs.update(tab.id, tabOptions);
-      await browser.tabs.reload(tab.id);
-    } else {
-      browser.tabs.create(tabOptions);
-    }
-  } catch (error) {
-    console.error(error);
-  }
+  return Promise.resolve(true);
 });
 message.on('set:active-tab', (tabId) => {
   return browser.tabs.update(tabId, { active: true });
@@ -204,39 +359,7 @@ message.on('get:sender', (_, sender) => {
 message.on('get:tab-screenshot', (options) => {
   return browser.tabs.captureVisibleTab(options);
 });
-message.on('get:file', (path) => {
-  return new Promise((resolve, reject) => {
-    const isFile = /\.(.*)/.test(path);
-
-    if (!isFile) {
-      reject(new Error(`"${path}" is invalid file path.`));
-      return;
-    }
-
-    const fileUrl = path.startsWith('file://') ? path : `file://${path}`;
-
-    const xhr = new XMLHttpRequest();
-    xhr.responseType = 'blob';
-    xhr.onreadystatechange = () => {
-      if (xhr.readyState === XMLHttpRequest.DONE) {
-        if (xhr.status === 0 || xhr.status === 200) {
-          const objUrl = URL.createObjectURL(xhr.response);
-
-          resolve({ path, objUrl, type: xhr.response.type });
-        } else {
-          reject(new Error(xhr.statusText));
-        }
-      }
-    };
-    xhr.onerror = function () {
-      reject(
-        new Error(xhr.statusText || `Can't find a file with "${path}" path`)
-      );
-    };
-    xhr.open('GET', fileUrl);
-    xhr.send();
-  });
-});
+message.on('get:file', (path) => getFile(path));
 
 message.on('collection:execute', (collection) => {
   const engine = new CollectionEngine(collection, {

+ 41 - 0
src/background/workflow-engine/blocks-handler/handler-clipboard.js

@@ -0,0 +1,41 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+export default async function ({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  try {
+    const hasPermission = await browser.permissions.contains({
+      permissions: ['clipboardRead'],
+    });
+
+    if (!hasPermission) {
+      throw new Error('no-clipboard-acces');
+    }
+
+    const textarea = document.createElement('textarea');
+    document.body.appendChild(textarea);
+    textarea.focus();
+    document.execCommand('paste');
+
+    const copiedText = textarea.value;
+
+    if (data.assignVariable) {
+      this.referenceData.variables[data.variableName] = copiedText;
+    }
+    if (data.saveData) {
+      this.addDataToColumn(data.dataColumn, copiedText);
+    }
+
+    document.body.removeChild(textarea);
+
+    return {
+      nextBlockId,
+      data: copiedText,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}

+ 4 - 2
src/background/workflow-engine/blocks-handler/handler-delay.js

@@ -2,12 +2,14 @@ import { getBlockConnection } from '../helper';
 
 function delay(block) {
   return new Promise((resolve) => {
+    const delayTime = +block.data.time || 500;
+
     setTimeout(() => {
       resolve({
-        nextBlockId: getBlockConnection(block),
         data: '',
+        nextBlockId: getBlockConnection(block),
       });
-    }, block.data.time);
+    }, delayTime);
   });
 }
 

+ 13 - 1
src/background/workflow-engine/blocks-handler/handler-element-exists.js

@@ -4,9 +4,21 @@ function elementExists(block) {
   return new Promise((resolve, reject) => {
     this._sendMessageToTab(block)
       .then((data) => {
+        const nextBlockId = getBlockConnection(block, data ? 1 : 2);
+
+        if (!data && block.data.throwError) {
+          const error = new Error('element-not-found');
+          error.nextBlockId = nextBlockId;
+          error.data = { selector: block.data.selector };
+
+          reject(error);
+
+          return;
+        }
+
         resolve({
           data,
-          nextBlockId: getBlockConnection(block, data ? 1 : 2),
+          nextBlockId,
         });
       })
       .catch((error) => {

+ 2 - 1
src/background/workflow-engine/blocks-handler/handler-execute-workflow.js

@@ -62,13 +62,14 @@ async function executeWorkflow({ outputs, data }) {
         },
         onDestroyed: (engine) => {
           if (data.executeId) {
-            const { dataColumns, globalData, googleSheets } =
+            const { dataColumns, globalData, googleSheets, table } =
               engine.referenceData;
 
             this.referenceData.workflow[data.executeId] = {
               globalData,
               dataColumns,
               googleSheets,
+              table: table || dataColumns,
             };
           }
         },

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

@@ -4,7 +4,7 @@ import dataExporter from '@/utils/data-exporter';
 function exportData({ data, outputs }) {
   return new Promise((resolve) => {
     const dataToExport = data.dataToExport || 'data-columns';
-    let payload = this.referenceData.dataColumns;
+    let payload = this.referenceData.table;
 
     if (dataToExport === 'google-sheets') {
       payload = this.referenceData.googleSheets[data.refKey] || [];

+ 5 - 8
src/background/workflow-engine/blocks-handler/handler-google-sheets.js

@@ -28,15 +28,15 @@ async function updateSpreadsheetValues(
     dataFrom,
     customData,
   },
-  dataColumns
+  columns
 ) {
   let values = [];
 
-  if (dataFrom === 'data-columns') {
+  if (['data-columns', 'table'].includes(dataFrom)) {
     if (keysAsFirstRow) {
-      values = convertArrObjTo2DArr(dataColumns);
+      values = convertArrObjTo2DArr(columns);
     } else {
-      values = dataColumns.map(Object.values);
+      values = columns.map(Object.values);
     }
   } else if (dataFrom === 'custom') {
     values = parseJSON(customData, customData);
@@ -75,10 +75,7 @@ export default async function ({ data, outputs }) {
         this.referenceData.googleSheets[data.refKey] = spreadsheetValues;
       }
     } else if (data.type === 'update') {
-      result = await updateSpreadsheetValues(
-        data,
-        this.referenceData.dataColumns
-      );
+      result = await updateSpreadsheetValues(data, this.referenceData.table);
     }
 
     return {

+ 24 - 0
src/background/workflow-engine/blocks-handler/handler-insert-data.js

@@ -0,0 +1,24 @@
+import { getBlockConnection } from '../helper';
+import { parseJSON } from '@/utils/helper';
+import mustacheReplacer from '@/utils/reference-data/mustache-replacer';
+
+function delay({ outputs, data }, { refData }) {
+  return new Promise((resolve) => {
+    data.dataList.forEach(({ name, value, type }) => {
+      const replacedValue = mustacheReplacer(value, refData);
+      const realValue = parseJSON(replacedValue, replacedValue);
+
+      if (type === 'table') {
+        this.addDataToColumn(name, realValue);
+      } else {
+        this.referenceData.variables[name] = realValue;
+      }
+    });
+
+    resolve({
+      nextBlockId: getBlockConnection({ outputs }),
+    });
+  });
+}
+
+export default delay;

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

@@ -1,3 +1,4 @@
+import browser from 'webextension-polyfill';
 import { objectHasKey } from '@/utils/helper';
 import { getBlockConnection } from '../helper';
 
@@ -10,6 +11,14 @@ async function checkAccess(blockName) {
     if (hasFileAccess) return true;
 
     throw new Error('no-file-access');
+  } else if (blockName === 'clipboard') {
+    const hasPermission = await browser.permissions.contains({
+      permissions: ['clipboardRead'],
+    });
+
+    if (!hasPermission) {
+      throw new Error('no-clipboard-acces');
+    }
   }
 
   return true;
@@ -34,48 +43,62 @@ async function interactionHandler(block, { refData }) {
       frameId: this.activeTab.frameId || 0,
     });
 
-    if (block.name === 'link')
-      await new Promise((resolve) => setTimeout(resolve, 5000));
-
     if (objectHasKey(block.data, 'dataColumn')) {
       const dontSaveData =
         (block.name === 'forms' && !block.data.getValue) ||
         !block.data.saveData;
 
-      if (dontSaveData)
+      if (dontSaveData) {
         return {
           data,
           nextBlockId,
         };
+      }
 
       const currentColumnType =
         this.columns[block.data.dataColumn]?.type || 'any';
+      const insertDataToColumn = (value) => {
+        this.addDataToColumn(block.data.dataColumn, value);
+
+        const addExtraRow =
+          objectHasKey(block.data, 'extraRowDataColumn') &&
+          block.data.addExtraRow;
+        if (addExtraRow) {
+          this.addDataToColumn(
+            block.data.extraRowDataColumn,
+            block.data.extraRowValue
+          );
+        }
+      };
 
       if (Array.isArray(data) && currentColumnType !== 'array') {
-        data.forEach((item) => {
-          this.addDataToColumn(block.data.dataColumn, item);
-          if (objectHasKey(block.data, 'extraRowDataColumn')) {
-            if (block.data.addExtraRow)
-              this.addDataToColumn(
-                block.data.extraRowDataColumn,
-                block.data.extraRowValue
-              );
-          }
+        data.forEach((value) => {
+          insertDataToColumn(value);
         });
       } else {
-        this.addDataToColumn(block.data.dataColumn, data);
-        if (objectHasKey(block.data, 'extraRowDataColumn')) {
-          if (block.data.addExtraRow)
-            this.addDataToColumn(
-              block.data.extraRowDataColumn,
-              block.data.extraRowValue
-            );
-        }
+        insertDataToColumn(data);
       }
-    } else if (block.name === 'javascript-code') {
-      const arrData = Array.isArray(data) ? data : [data];
+    }
 
-      this.addDataToColumn(arrData);
+    const isJavascriptBlock = block.name === 'javascript-code';
+
+    if (block.data.assignVariable && !isJavascriptBlock) {
+      this.referenceData.variables[block.data.variableName] = data;
+    }
+
+    if (isJavascriptBlock) {
+      if (data?.variables) {
+        Object.keys(data.variables).forEach((varName) => {
+          this.referenceData.variables[varName] = data.variables[varName];
+        });
+      }
+
+      if (data?.columns.insert) {
+        const arrData = Array.isArray(data.columns.data)
+          ? data.columns.data
+          : [data.columns.data];
+        this.addDataToColumn(arrData);
+      }
     }
 
     return {

+ 39 - 31
src/background/workflow-engine/blocks-handler/handler-loop-data.js

@@ -1,10 +1,10 @@
 import { getBlockConnection } from '../helper';
+import { parseJSON } from '@/utils/helper';
 
-function loopData(block) {
-  return new Promise((resolve, reject) => {
-    const { data } = block;
-    const nextBlockId = getBlockConnection(block);
+async function loopData({ data, id, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
 
+  try {
     if (this.loopList[data.loopId]) {
       const index = this.loopList[data.loopId].index + 1;
 
@@ -13,7 +13,7 @@ function loopData(block) {
       let currentLoopData;
 
       if (data.loopThrough === 'numbers') {
-        currentLoopData = this.referenceData.loopData[data.loopId] + 1;
+        currentLoopData = this.referenceData.loopData[data.loopId].data + 1;
       } else {
         currentLoopData = this.loopList[data.loopId].data[index];
       }
@@ -23,37 +23,41 @@ function loopData(block) {
         $index: index,
       };
     } else {
-      let currLoopData;
+      const getLoopData = {
+        numbers: () => data.fromNumber,
+        table: () => this.referenceData.table,
+        'custom-data': () => JSON.parse(data.loopData),
+        'data-columns': () => this.referenceData.table,
+        'google-sheets': () =>
+          this.referenceData.googleSheets[data.referenceKey],
+        variable: () => {
+          const variableVal = this.referenceData.variables[data.variableName];
 
-      switch (data.loopThrough) {
-        case 'numbers':
-          currLoopData = data.fromNumber;
-          break;
-        case 'data-columns':
-          currLoopData = this.referenceData.dataColumns;
-          break;
-        case 'google-sheets':
-          currLoopData = this.referenceData.googleSheets[data.referenceKey];
-          break;
-        case 'custom-data':
-          currLoopData = JSON.parse(data.loopData);
-          break;
-        default:
-      }
+          return parseJSON(variableVal, variableVal);
+        },
+        elements: async () => {
+          const elements = await this._sendMessageToTab({
+            isBlock: false,
+            max: data.maxLoop,
+            type: 'loop-elements',
+            selector: data.elementSelector,
+          });
 
-      if (data.loopThrough !== 'numbers' && !Array.isArray(currLoopData)) {
-        const error = new Error('invalid-loop-data');
-        error.nextBlockId = nextBlockId;
+          return elements;
+        },
+      };
+
+      const currLoopData = await getLoopData[data.loopThrough]();
 
-        reject(error);
-        return;
+      if (data.loopThrough !== 'numbers' && !Array.isArray(currLoopData)) {
+        throw new Error('invalid-loop-data');
       }
 
       this.loopList[data.loopId] = {
         index: 0,
-        data: currLoopData,
+        blockId: id,
         id: data.loopId,
-        blockId: block.id,
+        data: currLoopData,
         type: data.loopThrough,
         maxLoop:
           data.loopThrough === 'numbers'
@@ -68,11 +72,15 @@ function loopData(block) {
       };
     }
 
-    resolve({
+    return {
       nextBlockId,
       data: this.referenceData.loopData[data.loopId],
-    });
-  });
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
 }
 
 export default loopData;

+ 7 - 5
src/background/workflow-engine/blocks-handler/handler-new-tab.js

@@ -26,17 +26,21 @@ async function newTab(block) {
       throw error;
     }
 
+    let tab = null;
+
     if (updatePrevTab && this.activeTab.id) {
-      await browser.tabs.update(this.activeTab.id, { url, active });
+      tab = await browser.tabs.update(this.activeTab.id, { url, active });
     } else {
-      const tab = await browser.tabs.create({
+      tab = await browser.tabs.create({
         url,
         active,
         windowId: this.windowId,
       });
+    }
 
+    this.activeTab.url = url;
+    if (tab) {
       this.activeTab.id = tab.id;
-      this.activeTab.url = url;
       this.windowId = tab.windowId;
     }
 
@@ -64,8 +68,6 @@ async function newTab(block) {
       nextBlockId,
     };
   } catch (error) {
-    console.error(error);
-    console.dir(error);
     error.nextBlockId = nextBlockId;
 
     throw error;

+ 44 - 0
src/background/workflow-engine/blocks-handler/handler-switch-tab.js

@@ -0,0 +1,44 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+export default async function ({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+  const generateError = (message, errorData) => {
+    const error = new Error(message);
+    error.nextBlockId = nextBlockId;
+
+    if (errorData) error.data = errorData;
+
+    return error;
+  };
+  this.windowId = null;
+  let [tab] = await browser.tabs.query({ url: data.matchPattern });
+
+  if (!tab) {
+    if (data.createIfNoMatch) {
+      if (!data.url.startsWith('http')) {
+        throw generateError('invalid-active-tab', { url: data.url });
+      }
+
+      tab = await browser.tabs.create({
+        active: true,
+        url: data.url,
+        windowId: this.windowId,
+      });
+    } else {
+      throw generateError('no-match-tab', { pattern: data.matchPattern });
+    }
+  } else {
+    await browser.tabs.update(tab.id, { active: true });
+  }
+
+  this.activeTab.id = tab.id;
+  this.activeTab.frameId = 0;
+  this.activeTab.url = tab.url;
+  this.windowId = tab.windowId;
+
+  return {
+    nextBlockId,
+    data: tab.url,
+  };
+}

+ 21 - 27
src/background/workflow-engine/blocks-handler/handler-take-screenshot.js

@@ -20,30 +20,26 @@ function saveImage({ fileName, uri, ext }) {
   image.src = uri;
 }
 
-async function takeScreenshot(block) {
-  const nextBlockId = getBlockConnection(block);
-  const {
-    ext,
-    quality,
-    captureActiveTab,
-    fileName,
-    saveToColumn,
-    dataColumn,
-    fullPage,
-  } = block.data;
-
+async function takeScreenshot({ data, outputs, name }) {
+  const nextBlockId = getBlockConnection({ outputs });
   const saveToComputer =
-    typeof block.data.saveToComputer === 'undefined'
-      ? true
-      : block.data.saveToComputer;
+    typeof data.saveToComputer === 'undefined' || data.saveToComputer;
 
   try {
+    let screenshot = null;
     const options = {
-      quality,
-      format: ext || 'png',
+      quality: data.quality,
+      format: data.ext || 'png',
+    };
+    const saveScreenshot = (dataUrl) => {
+      if (data.saveToColumn) this.addDataToColumn(data.dataColumn, dataUrl);
+      if (saveToComputer)
+        saveImage({ fileName: data.fileName, uri: dataUrl, ext: data.ext });
+      if (data.assignVariable)
+        this.referenceData.variables[data.variableName] = dataUrl;
     };
 
-    if (captureActiveTab) {
+    if (data.captureActiveTab) {
       if (!this.activeTab.id) {
         throw new Error('no-tab');
       }
@@ -58,11 +54,11 @@ async function takeScreenshot(block) {
 
       await new Promise((resolve) => setTimeout(resolve, 500));
 
-      const uri = await (fullPage
+      screenshot = await (data.fullPage
         ? this._sendMessageToTab({
-            tabId: this.activeTab.id,
+            name,
             options,
-            name: block.name,
+            tabId: this.activeTab.id,
           })
         : browser.tabs.captureVisibleTab(options));
 
@@ -71,16 +67,14 @@ async function takeScreenshot(block) {
         await browser.tabs.update(tab.id, { active: true });
       }
 
-      if (saveToColumn) this.addDataToColumn(dataColumn, uri);
-      if (saveToComputer) saveImage({ fileName, uri, ext });
+      saveScreenshot(screenshot);
     } else {
-      const uri = await browser.tabs.captureVisibleTab(options);
+      screenshot = await browser.tabs.captureVisibleTab(options);
 
-      if (saveToColumn) this.addDataToColumn(dataColumn, uri);
-      if (saveToComputer) saveImage({ fileName, uri, ext });
+      saveScreenshot(screenshot);
     }
 
-    return { data: '', nextBlockId };
+    return { data: screenshot, nextBlockId };
   } catch (error) {
     error.nextBlockId = nextBlockId;
 

+ 46 - 2
src/background/workflow-engine/blocks-handler/handler-webhook.js

@@ -1,21 +1,65 @@
+import objectPath from 'object-path';
 import { getBlockConnection } from '../helper';
 import { isWhitespace } from '@/utils/helper';
 import { executeWebhook } from '@/utils/webhookUtil';
 
 export async function webhook({ data, outputs }) {
   const nextBlockId = getBlockConnection({ outputs });
+  const fallbackOutput = getBlockConnection({ outputs }, 2);
 
   try {
     if (isWhitespace(data.url)) throw new Error('url-empty');
     if (!data.url.startsWith('http')) throw new Error('invalid-url');
 
-    await executeWebhook(data);
+    const response = await executeWebhook(data);
+
+    if (!response.ok) {
+      if (fallbackOutput) {
+        return {
+          data: '',
+          nextBlockId: fallbackOutput,
+        };
+      }
+
+      throw new Error(`(${response.status}) ${response.statusText}`);
+    }
+
+    if (!data.assignVariable && !data.saveData) {
+      return {
+        data: '',
+        nextBlockId,
+      };
+    }
+
+    let returnData = '';
+
+    if (data.responseType === 'json') {
+      const jsonRes = await response.json();
+
+      returnData = objectPath.get(jsonRes, data.dataPath);
+    } else {
+      returnData = await response.text();
+    }
+
+    if (data.assignVariable) {
+      this.referenceData.variables[data.variableName] = returnData;
+    }
+    if (data.saveData) {
+      this.addDataToColumn(data.dataColumn, returnData);
+    }
 
     return {
-      data: '',
       nextBlockId,
+      data: returnData,
     };
   } catch (error) {
+    if (fallbackOutput && error.message === 'Failed to fetch') {
+      return {
+        data: '',
+        nextBlockId: fallbackOutput,
+      };
+    }
+
     error.nextBlockId = nextBlockId;
 
     throw error;

+ 33 - 9
src/background/workflow-engine/engine.js

@@ -44,9 +44,10 @@ class WorkflowEngine {
       groupId: null,
     };
     this.referenceData = {
+      table: [],
       loopData: {},
       workflow: {},
-      dataColumns: [],
+      variables: {},
       googleSheets: {},
       globalData: parseJSON(globalDataValue, globalDataValue),
     };
@@ -84,9 +85,10 @@ class WorkflowEngine {
       return;
     }
 
-    const dataColumns = Array.isArray(this.workflow.dataColumns)
-      ? this.workflow.dataColumns
-      : Object.values(this.workflow.dataColumns);
+    const workflowTable = this.workflow.table || this.workflow.dataColumns;
+    const dataColumns = Array.isArray(workflowTable)
+      ? workflowTable
+      : Object.values(workflowTable);
 
     dataColumns.forEach(({ name, type }) => {
       this.columns[name] = { index: 0, type };
@@ -94,7 +96,7 @@ class WorkflowEngine {
 
     this.blocks = blocks;
     this.startedTimestamp = Date.now();
-    this.workflow.dataColumns = dataColumns;
+    this.workflow.table = dataColumns;
     this.currentBlock = currentBlock || triggerBlock;
 
     this.states.on('stop', this.onWorkflowStopped);
@@ -148,11 +150,11 @@ class WorkflowEngine {
     const currentColumn = this.columns[columnName];
     const convertedValue = convertData(value, currentColumn.type);
 
-    if (objectHasKey(this.referenceData.dataColumns, currentColumn.index)) {
-      this.referenceData.dataColumns[currentColumn.index][columnName] =
+    if (objectHasKey(this.referenceData.table, currentColumn.index)) {
+      this.referenceData.table[currentColumn.index][columnName] =
         convertedValue;
     } else {
-      this.referenceData.dataColumns.push({ [columnName]: convertedValue });
+      this.referenceData.table.push({ [columnName]: convertedValue });
     }
 
     currentColumn.index += 1;
@@ -170,12 +172,31 @@ class WorkflowEngine {
     }
   }
 
+  async executeQueue() {
+    const { workflowQueue } = await browser.storage.local.get('workflowQueue');
+    const queueIndex = (workflowQueue || []).indexOf(this.workflow.id);
+
+    if (!workflowQueue || queueIndex === -1) return;
+
+    const engine = new WorkflowEngine(this.workflow, {
+      logger: this.logger,
+      states: this.states,
+      blocksHandler: this.blocksHandler,
+    });
+    engine.init();
+
+    workflowQueue.splice(queueIndex, 1);
+
+    await browser.storage.local.set({ workflowQueue });
+  }
+
   async destroy(status, message) {
     try {
       if (this.isDestroyed) return;
       if (this.isUsingProxy) chrome.proxy.settings.clear({});
 
       const endedTimestamp = Date.now();
+      this.executeQueue();
 
       if (!this.workflow.isTesting && this.saveLog) {
         const { name, id } = this.workflow;
@@ -190,7 +211,10 @@ class WorkflowEngine {
           endedAt: endedTimestamp,
           parentLog: this.parentWorkflow,
           startedAt: this.startedTimestamp,
-          data: this.referenceData.dataColumns,
+          data: {
+            table: this.referenceData.table,
+            variables: this.referenceData.variables,
+          },
         });
       }
 

+ 5 - 2
src/components/block/BlockBase.vue

@@ -3,14 +3,17 @@
     <slot name="prepend" />
     <div
       :class="contentClass"
-      class="z-10 bg-white relative rounded-lg overflow-hidden w-full p-4"
+      class="z-10 bg-white dark:bg-gray-800 relative rounded-lg overflow-hidden w-full p-4 block-base__content"
     >
       <slot></slot>
     </div>
+    <slot name="append" />
     <div
       class="absolute bottom-1 transition-transform duration-300 pt-4 ml-1 menu"
     >
-      <div class="bg-accent px-3 py-2 text-white rounded-lg flex items-center">
+      <div
+        class="bg-accent dark:bg-gray-100 dark:text-black px-3 py-2 text-white rounded-lg flex items-center"
+      >
         <button v-if="!hideEdit" @click="$emit('edit')">
           <v-remixicon size="20" name="riPencilLine" />
         </button>

+ 25 - 23
src/components/block/BlockBasic.vue

@@ -3,40 +3,42 @@
     :id="componentId"
     :hide-edit="block.details.disableEdit"
     :hide-delete="block.details.disableDelete"
-    content-class="flex items-center"
     class="block-basic"
     @edit="editBlock"
     @delete="editor.removeNodeId(`node-${block.id}`)"
   >
-    <span
-      :class="block.category.color"
-      class="inline-block p-2 mr-2 rounded-lg bg-green-200"
-    >
-      <v-remixicon :name="block.details.icon || 'riGlobalLine'" />
-    </span>
-    <div style="max-width: 200px">
-      <p
-        v-if="block.details.id"
-        class="font-semibold leading-none whitespace-nowrap"
+    <div class="flex items-center">
+      <span
+        :class="block.category.color"
+        class="inline-block p-2 mr-2 rounded-lg dark:text-black"
       >
-        {{ t(`workflow.blocks.${block.details.id}.name`) }}
-      </p>
-      <p class="text-gray-600 text-overflow leading-tight">
-        {{ block.data.description }}
-      </p>
-      <input
-        type="text"
-        class="hidden trigger"
-        disabled="true"
-        @change="handleDataChange"
-      />
+        <v-remixicon :name="block.details.icon || 'riGlobalLine'" />
+      </span>
+      <div style="max-width: 200px">
+        <p
+          v-if="block.details.id"
+          class="font-semibold leading-none whitespace-nowrap"
+        >
+          {{ t(`workflow.blocks.${block.details.id}.name`) }}
+        </p>
+        <p class="text-gray-600 dark:text-gray-200 text-overflow leading-tight">
+          {{ block.data.description }}
+        </p>
+        <input
+          type="text"
+          class="hidden trigger"
+          disabled="true"
+          @change="handleDataChange"
+        />
+      </div>
     </div>
+    <slot></slot>
     <template #prepend>
       <div
         v-if="block.details.id !== 'trigger'"
         :title="t('workflow.blocks.base.moveToGroup')"
         draggable="true"
-        class="bg-white invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
+        class="bg-white dark:bg-gray-700 invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
         @dragstart="handleStartDrag"
         @mousedown.stop
       >

+ 2 - 2
src/components/block/BlockConditions.vue

@@ -3,7 +3,7 @@
     <div class="flex items-center">
       <div
         :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg"
+        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riAB" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.conditions.name') }}</span>
@@ -45,7 +45,7 @@
       </div>
       <p
         v-if="block.data.conditions && block.data.conditions.length !== 0"
-        class="text-right text-gray-600"
+        class="text-right text-gray-600 dark:text-gray-200"
       >
         <span :title="t('workflow.blocks.conditions.fallbackTitle')">
           &#9432;

+ 1 - 1
src/components/block/BlockDelay.vue

@@ -3,7 +3,7 @@
     <div class="flex items-center mb-2">
       <div
         :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg"
+        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riTimerLine" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.delay.name') }}</span>

+ 2 - 2
src/components/block/BlockElementExists.vue

@@ -7,7 +7,7 @@
   >
     <div
       :class="block.category.color"
-      class="inline-block text-sm mb-2 p-2 rounded-lg"
+      class="inline-block text-sm mb-2 p-2 rounded-lg dark:text-black"
     >
       <v-remixicon name="riFocus3Line" size="20" class="inline-block mr-1" />
       <span>{{ t('workflow.blocks.element-exists.name') }}</span>
@@ -19,7 +19,7 @@
     >
       {{ block.data.selector || t('workflow.blocks.element-exists.selector') }}
     </p>
-    <p class="text-right text-gray-600">
+    <p class="text-right text-gray-600 dark:text-gray-200">
       <span :title="t('workflow.blocks.element-exists.fallbackTitle')">
         &#9432;
       </span>

+ 1 - 1
src/components/block/BlockGroup.vue

@@ -4,7 +4,7 @@
       <div class="flex items-center mb-2">
         <div
           :class="block.category.color"
-          class="inline-flex items-center text-sm mr-4 p-2 rounded-lg"
+          class="inline-flex items-center text-sm mr-4 p-2 rounded-lg dark:text-black"
         >
           <v-remixicon
             :name="block.details.icon || 'riFolderZipLine'"

+ 1 - 1
src/components/block/BlockLoopBreakpoint.vue

@@ -3,7 +3,7 @@
     <div class="flex items-center mb-2">
       <div
         :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg"
+        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riStopLine" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.loop-breakpoint.name') }}</span>

+ 5 - 5
src/components/block/BlockRepeatTask.vue

@@ -3,7 +3,7 @@
     <div class="flex items-center mb-2">
       <div
         :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg"
+        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riRepeat2Line" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.repeat-task.name') }}</span>
@@ -26,11 +26,11 @@
         required
         @input="handleInput"
       />
-      <span class="text-gray-600">{{
-        t('workflow.blocks.repeat-task.times')
-      }}</span>
+      <span class="text-gray-600 dark:text-gray-200">
+        {{ t('workflow.blocks.repeat-task.times') }}
+      </span>
     </label>
-    <p class="text-right text-gray-600">
+    <p class="text-right text-gray-600 dark:text-gray-200">
       {{ t('workflow.blocks.repeat-task.repeatFrom') }}
     </p>
   </div>

+ 38 - 0
src/components/block/BlockWebhook.vue

@@ -0,0 +1,38 @@
+<template>
+  <block-basic :editor="editor" class="block-webhook">
+    <div class="fallback flex items-center pb-2 justify-end">
+      <v-remixicon
+        :title="t('workflow.blocks.webhook.fallback')"
+        name="riInformationLine"
+        size="18"
+      />
+      <span class="ml-1">
+        {{ t('common.fallback') }}
+      </span>
+    </div>
+  </block-basic>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import BlockBasic from './BlockBasic.vue';
+
+const { t } = useI18n();
+
+defineProps({
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+</script>
+<style>
+.block-webhook .block-base__content {
+  padding-bottom: 0;
+}
+.drawflow-node.webhook .outputs {
+  top: 64%;
+}
+.drawflow-node.webhook .outputs .output_1 {
+  margin-bottom: 14px;
+}
+</style>

+ 23 - 2
src/components/newtab/app/AppSidebar.vue

@@ -1,6 +1,6 @@
 <template>
   <aside
-    class="fixed flex flex-col items-center h-screen left-0 top-0 w-16 py-6 bg-white z-50"
+    class="fixed flex flex-col items-center h-screen left-0 top-0 w-16 py-6 bg-white dark:bg-gray-800 z-50"
   >
     <img
       :title="`v${extensionVersion}`"
@@ -41,6 +41,24 @@
       </router-link>
     </div>
     <div class="flex-grow"></div>
+    <ui-popover
+      v-if="store.state.user"
+      trigger="mouseenter"
+      placement="right"
+      class="mb-4"
+    >
+      <template #trigger>
+        <span class="inline-block p-1 bg-box-transparent rounded-full">
+          <img
+            :src="store.state.user.avatar_url"
+            height="32"
+            width="32"
+            class="rounded-full"
+          />
+        </span>
+      </template>
+      {{ store.state.user.username }}
+    </ui-popover>
     <router-link v-tooltip:right.group="t('settings.menu.about')" to="/about">
       <v-remixicon class="cursor-pointer" name="riInformationLine" />
     </router-link>
@@ -48,13 +66,16 @@
 </template>
 <script setup>
 import { ref } from 'vue';
+import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 
 useGroupTooltip();
+
 const { t } = useI18n();
+const store = useStore();
 const router = useRouter();
 
 const extensionVersion = chrome.runtime.getManifest().version;
@@ -115,6 +136,6 @@ function hoverHandler({ target }) {
   top: 0;
   height: 100%;
   width: 4px;
-  @apply bg-accent;
+  @apply bg-accent dark:bg-gray-100;
 }
 </style>

+ 37 - 13
src/components/newtab/logs/LogsDataViewer.vue

@@ -1,12 +1,12 @@
 <template>
-  <div class="flex items-center">
+  <div class="flex items-center mb-2">
     <ui-input
-      v-model="fileName"
+      v-model="state.fileName"
       :placeholder="t('common.fileName')"
       :title="t('common.fileName')"
     />
     <div class="flex-grow"></div>
-    <ui-popover>
+    <ui-popover trigger-width>
       <template #trigger>
         <ui-button variant="accent">
           <span>{{ t('log.exportData.title') }}</span>
@@ -26,19 +26,28 @@
       </ui-list>
     </ui-popover>
   </div>
+  <ui-tabs v-if="objectHasKey(log.data, 'table')" v-model="state.activeTab">
+    <ui-tab value="table">
+      {{ t('workflow.table.title') }}
+    </ui-tab>
+    <ui-tab value="variables">
+      {{ t('workflow.variables.title', 2) }}
+    </ui-tab>
+  </ui-tabs>
   <shared-codemirror
     :model-value="dataStr"
     :class="editorClass"
+    class="rounded-t-none"
     lang="json"
     readonly
-    class="mt-4"
   />
 </template>
 <script setup>
-import { ref, computed, defineAsyncComponent } from 'vue';
+import { shallowReactive, computed, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { dataExportTypes } from '@/utils/shared';
-import dataExporter, { generateJSON } from '@/utils/data-exporter';
+import { objectHasKey } from '@/utils/helper';
+import dataExporter from '@/utils/data-exporter';
 
 const SharedCodemirror = defineAsyncComponent(() =>
   import('@/components/newtab/shared/SharedCodemirror.vue')
@@ -57,17 +66,32 @@ const props = defineProps({
 
 const { t } = useI18n();
 
+const state = shallowReactive({
+  activeTab: 'table',
+  fileName: props.log.name,
+});
+const cache = {
+  table: '',
+  variables: '',
+};
+
 const dataStr = computed(() => {
-  const data = Array.isArray(props.log.data)
-    ? props.log.data
-    : generateJSON(Object.keys(props.log.data), props.log.data);
+  if (cache[state.activeTab]) return cache[state.activeTab];
 
-  return JSON.stringify(data, null, 2);
-});
+  let { data } = props.log;
 
-const fileName = ref(props.log.name);
+  if (objectHasKey(props.log.data, 'table')) {
+    data = props.log.data[state.activeTab];
+  }
+
+  data = JSON.stringify(data, null, 2);
+  /* eslint-disable-next-line */
+  cache[state.activeTab] = data;
+
+  return data;
+});
 
 function exportData(type) {
-  dataExporter(data, { name: fileName.value, type }, true);
+  dataExporter(props.log.data, { name: state.fileName, type }, true);
 }
 </script>

+ 4 - 2
src/components/newtab/logs/LogsFilters.vue

@@ -39,7 +39,9 @@
       </template>
       <div class="w-48">
         <p class="flex-1 mb-2 font-semibold">{{ t('log.filter.title') }}</p>
-        <p class="mb-2 text-sm text-gray-600">{{ t('log.filter.byStatus') }}</p>
+        <p class="mb-2 text-sm text-gray-600 dark:text-gray-200">
+          {{ t('log.filter.byStatus') }}
+        </p>
         <div class="grid grid-cols-2 gap-2">
           <ui-radio
             v-for="status in filterByStatus"
@@ -52,7 +54,7 @@
             {{ status.name }}
           </ui-radio>
         </div>
-        <p class="mb-1 text-sm text-gray-600 mt-3">
+        <p class="mb-1 text-sm text-gray-600 dark:text-gray-200 mt-3">
           {{ t('log.filter.byDate.title') }}
         </p>
         <ui-select

+ 424 - 0
src/components/newtab/settings/SettingsCloudBackup.vue

@@ -0,0 +1,424 @@
+<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>
+      <ui-button
+        v-if="state.activeTab === 'local'"
+        :loading="state.isBackingUp"
+        variant="accent"
+        class="ml-2"
+        @click="backupWorkflowsToCloud"
+      >
+        {{ t('settings.backupWorkflows.backup.button') }}
+      </ui-button>
+      <ui-button
+        v-else
+        :disabled="state.deleteIds.length <= 0"
+        :loading="state.isDeletingBackup"
+        class="ml-2"
+        variant="danger"
+        @click="deleteBackup(null)"
+      >
+        {{ 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>
+    <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)"
+        >
+          <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">{{ 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"
+          >
+            <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>
+    </div>
+  </div>
+</template>
+<script setup>
+import { computed, reactive, watch } from 'vue';
+import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import browser from 'webextension-polyfill';
+import { fetchApi, cacheApi } from '@/utils/api';
+import { convertWorkflow } from '@/utils/workflow-data';
+import { parseJSON } from '@/utils/helper';
+import dayjs from '@/lib/dayjs';
+import Workflow from '@/models/workflow';
+
+defineEmits(['close']);
+
+const { t } = useI18n();
+const store = useStore();
+const toast = useToast();
+
+const state = reactive({
+  query: '',
+  deleteIds: [],
+  backupIds: [],
+  activeTab: 'local',
+  cloudWorkflows: [],
+  isBackingUp: false,
+  loadingBackup: false,
+  backupRetrieved: false,
+  isDeletingBackup: false,
+});
+
+const workflows = computed(() =>
+  Workflow.query()
+    .where(({ name }) =>
+      name.toLocaleLowerCase().includes(state.query.toLowerCase())
+    )
+    .orderBy('createdAt', 'desc')
+    .get()
+);
+const backupWorkflows = computed(() =>
+  state.cloudWorkflows.filter(({ name }) =>
+    name.toLocaleLowerCase().includes(state.query.toLowerCase())
+  )
+);
+
+function formatDate(workflow, format) {
+  return dayjs(workflow.updatedAt || Date.now()).format(format);
+}
+function toggleDeleteWorkflow(value, workflowId) {
+  if (value) {
+    state.deleteIds.push(workflowId);
+  } else {
+    const index = state.deleteIds.indexOf(workflowId);
+
+    if (index !== -1) state.deleteIds.splice(index, 1);
+  }
+}
+async function deleteBackup(workflowId) {
+  try {
+    state.isDeletingBackup = true;
+
+    const ids = workflowId ? [workflowId] : state.deleteIds;
+    const response = await fetchApi(
+      `/me/workflows?id=${ids.join(',')}&type=backup`,
+      {
+        method: 'DELETE',
+      }
+    );
+
+    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);
+    });
+
+    await browser.storage.local.set({ backupIds });
+
+    state.backupIds = backupIds;
+    state.isDeletingBackup = false;
+    sessionStorage.removeItem('backup-workflows');
+  } catch (error) {
+    console.error(error);
+    state.isDeletingBackup = false;
+    toast.error(t('message.somethingWrong'));
+    state.isBackingUp = false;
+  }
+}
+async function onTabChange(value) {
+  if (value !== 'cloud' || state.backupRetrieved || state.loadingBackup) return;
+
+  state.deleteIds = [];
+
+  try {
+    state.loadingBackup = true;
+    const data = await cacheApi('backup-workflows', async () => {
+      const response = await fetchApi('/me/workflows?type=backup');
+
+      if (!response.ok) throw new Error(response.statusText);
+
+      const result = await response.json();
+
+      return result;
+    });
+
+    state.cloudWorkflows = data;
+    state.loadingBackup = false;
+  } 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;
+  }
+
+  try {
+    state.isBackingUp = true;
+
+    const workflowsPayload = state.backupIds.reduce((acc, id) => {
+      const findWorkflow = Workflow.find(id);
+
+      if (!findWorkflow) return acc;
+
+      const workflow = convertWorkflow(findWorkflow, [
+        'dataColumns',
+        'id',
+        '__id',
+      ]);
+      delete workflow.extVersion;
+
+      acc.push(workflow);
+
+      return acc;
+    }, []);
+
+    const response = await fetchApi('/me/workflows?type=backup', {
+      method: 'POST',
+      body: JSON.stringify({ workflows: workflowsPayload }),
+    });
+
+    if (!response.ok) {
+      throw new Error(response.statusText);
+    }
+
+    const { lastBackup, data, ids } = await response.json();
+
+    state.isBackingUp = false;
+    state.lastBackup = lastBackup;
+    state.lastSync = lastBackup;
+
+    const userWorkflows = parseJSON('user-workflows', {
+      backup: [],
+      hosted: {},
+    });
+    userWorkflows.backup = data;
+    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,
+    });
+
+    sessionStorage.removeItem('backup-workflows');
+    sessionStorage.removeItem('user-workflows');
+    sessionStorage.removeItem('cache-time:backup-workflows');
+  } catch (error) {
+    console.error(error);
+    toast.error(t('message.somethingWrong'));
+    state.isBackingUp = false;
+  }
+}
+
+watch(
+  () => store.state.userDataRetrieved,
+  async () => {
+    const { backupIds } = await browser.storage.local.get('backupIds');
+
+    state.backupIds = backupIds || [];
+  },
+  { immediate: true }
+);
+</script>
+<style scoped>
+.select-workflow.is-selected .select-icon {
+  display: block;
+}
+</style>

+ 29 - 8
src/components/newtab/shared/SharedCard.vue

@@ -1,5 +1,7 @@
 <template>
-  <ui-card class="hover:ring-2 flex flex-col group hover:ring-accent">
+  <ui-card
+    class="hover:ring-2 flex flex-col group hover:ring-accent dark:hover:ring-gray-200"
+  >
     <slot name="header">
       <div class="flex items-center mb-4">
         <ui-img
@@ -51,11 +53,24 @@
         {{ data.description }}
       </p>
     </div>
-    <p class="text-gray-600 dark:text-gray-200">{{ formatDate() }}</p>
+    <div class="flex items-center text-gray-600 dark:text-gray-200">
+      <p class="flex-1">{{ state.date }}</p>
+      <slot name="footer-content" />
+      <v-remixicon
+        v-if="state.triggerText"
+        v-tooltip="state.triggerText"
+        :class="{ 'ml-2': $slots['footer-content'] }"
+        name="riFlashlightLine"
+        size="20"
+      />
+    </div>
   </ui-card>
 </template>
 <script setup>
+import { onMounted, shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
 import dayjs from '@/lib/dayjs';
+import triggerText from '@/utils/trigger-text';
 
 const props = defineProps({
   data: {
@@ -78,12 +93,18 @@ const props = defineProps({
 
 defineEmits(['execute', 'click', 'menuSelected']);
 
-let formattedDate = null;
-const formatDate = () => {
-  if (formattedDate) return formattedDate;
+const { t } = useI18n();
+
+const state = shallowReactive({
+  triggerText: null,
+  date: dayjs(props.data.createdAt).fromNow(),
+});
 
-  formattedDate = dayjs(props.data.createdAt).fromNow();
+onMounted(async () => {
+  const { trigger, id } = props.data;
 
-  return formattedDate;
-};
+  if (!trigger) return;
+
+  state.triggerText = await triggerText(trigger, t, id);
+});
 </script>

+ 1 - 1
src/components/newtab/shared/SharedCodemirror.vue

@@ -2,7 +2,7 @@
   <div
     ref="containerEl"
     :class="{ 'hide-gutters': !lineNumbers }"
-    class="codemirror relative overflow-auto"
+    class="codemirror relative overflow-auto rounded-lg"
   ></div>
 </template>
 <script setup>

+ 7 - 7
src/components/newtab/shared/SharedLogsTable.vue

@@ -1,6 +1,6 @@
 <template>
   <table>
-    <tbody class="divide-y">
+    <tbody class="divide-y dark:divide-gray-800">
       <tr v-for="log in logs" :key="log.id" class="hoverable">
         <slot name="item-prepend" :log="log" />
         <td class="text-overflow" style="min-width: 140px; max-width: 330px">
@@ -11,7 +11,7 @@
             {{ log.name }}
           </router-link>
         </td>
-        <td class="log-time">
+        <td class="log-time dark:text-gray-200">
           <v-remixicon
             :title="t('log.startedDate')"
             name="riCalendarLine"
@@ -21,7 +21,7 @@
             {{ formatDate(log.startedAt, 'relative') }}
           </span>
         </td>
-        <td class="log-time" :title="t('log.duration')">
+        <td class="log-time dark:text-gray-200" :title="t('log.duration')">
           <v-remixicon name="riTimerLine"></v-remixicon>
           <span>{{ countDuration(log.startedAt, log.endedAt) }}</span>
         </td>
@@ -29,7 +29,7 @@
           <span
             :class="statusColors[log.status]"
             :title="log.status === 'error' ? getErrorMessage(log) : null"
-            class="inline-block py-1 w-16 text-center text-sm rounded-lg"
+            class="inline-block py-1 w-16 text-center text-sm rounded-md dark:text-black"
           >
             {{ t(`logStatus.${log.status}`) }}
           </span>
@@ -54,9 +54,9 @@ defineProps({
 const { t, te } = useI18n();
 
 const statusColors = {
-  error: 'bg-red-200',
-  success: 'bg-green-200',
-  stopped: 'bg-yellow-200',
+  error: 'bg-red-200 dark:bg-red-300',
+  success: 'bg-green-200 dark:bg-green-300',
+  stopped: 'bg-yellow-200 dark:bg-yellow-300',
 };
 
 function formatDate(date, format) {

+ 1 - 1
src/components/newtab/shared/SharedWorkflowState.vue

@@ -4,7 +4,7 @@
       <div class="flex-1 text-overflow mr-4">
         <p class="w-full mr-2 text-overflow">{{ data.state.name }}</p>
         <p
-          class="w-full mr-2 text-gray-600 leading-tight text-overflow"
+          class="w-full mr-2 text-gray-600 dark:text-gray-200 leading-tight text-overflow"
           :title="`Started at: ${formatDate(
             data.state.startedTimestamp,
             'DD MMM, hh:mm A'

+ 96 - 0
src/components/newtab/shared/SharedWysiwyg.vue

@@ -0,0 +1,96 @@
+<template>
+  <div class="wysiwyg-editor">
+    <slot v-if="editor" name="prepend" :editor="editor" />
+    <editor-content :editor="editor" />
+    <slot name="append" />
+  </div>
+</template>
+<script setup>
+import { shallowRef, onMounted, onBeforeUnmount, watch } from 'vue';
+import { Editor, EditorContent } from '@tiptap/vue-3';
+import StarterKit from '@tiptap/starter-kit';
+import Link from '@tiptap/extension-link';
+import Image from '@tiptap/extension-image';
+import Placeholder from '@tiptap/extension-placeholder';
+import CharacterCount from '@tiptap/extension-character-count';
+
+const props = defineProps({
+  modelValue: {
+    type: [String, Object],
+    default: null,
+  },
+  placeholder: {
+    type: String,
+    default: '',
+  },
+  limit: {
+    type: Number,
+    default: Infinity,
+  },
+  options: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:modelValue', 'count', 'change']);
+
+const editor = shallowRef(null);
+
+watch(
+  () => props.modelValue,
+  (value) => {
+    const isSame =
+      JSON.stringify(editor.value.getJSON()) === JSON.stringify(value);
+
+    if (isSame) return;
+
+    editor.value.commands.setContent(value, false);
+  }
+);
+
+onMounted(() => {
+  editor.value = new Editor({
+    content: props.modelValue,
+    onUpdate: () => {
+      const editorValue = editor.value.getJSON();
+
+      emit('count', editor.value.storage.characterCount.characters());
+      emit('change', editorValue);
+      emit('update:modelValue', editorValue);
+    },
+    extensions: [
+      Link,
+      Image,
+      StarterKit,
+      Placeholder.configure({
+        placeholder: props.placeholder,
+      }),
+      CharacterCount.configure({
+        limit: props.limit,
+      }),
+    ],
+    ...props.options,
+  });
+
+  emit('count', editor.value.storage.characterCount.characters());
+});
+onBeforeUnmount(() => {
+  editor.value?.destroy();
+});
+</script>
+<style>
+.ProseMirror pre,
+.ProseMirror code {
+  font-family: 'JetBrains Mono', monospace;
+}
+.ProseMirror:focus {
+  outline: none;
+}
+.ProseMirror p.is-editor-empty:first-child::before {
+  @apply text-gray-400;
+  content: attr(data-placeholder);
+  float: left;
+  pointer-events: none;
+  height: 0;
+}
+</style>

+ 79 - 17
src/components/newtab/workflow/WorkflowActions.vue

@@ -1,5 +1,64 @@
 <template>
-  <ui-card padding="p-1">
+  <ui-card v-if="!workflow.isProtected" padding="p-1 flex items-center">
+    <ui-popover>
+      <template #trigger>
+        <button
+          v-tooltip.group="t('workflow.host.title')"
+          class="hoverable p-2 rounded-lg"
+        >
+          <v-remixicon
+            :class="{ 'text-primary': data.isHost }"
+            name="riBaseStationLine"
+          />
+        </button>
+      </template>
+      <div :class="{ 'text-center': data.loadingHost }" class="w-64">
+        <div class="flex items-center text-gray-600 dark:text-gray-200">
+          <p>
+            {{ t('workflow.host.set') }}
+          </p>
+          <a
+            :title="t('common.docs')"
+            href="https://docs.automa.site/guide/host-workflow.html"
+            target="_blank"
+            class="ml-1"
+          >
+            <v-remixicon name="riInformationLine" size="20" />
+          </a>
+          <div class="flex-grow"></div>
+          <template v-if="$store.state.user">
+            <ui-spinner v-if="data.loadingHost" color="text-accent" />
+            <ui-switch
+              v-else
+              :model-value="data.isHost"
+              @change="$emit('host', $event)"
+            />
+          </template>
+          <ui-switch v-else v-close-popover @click="$emit('host', 'auth')" />
+        </div>
+        <transition-expand>
+          <ui-input
+            v-if="data.isHost"
+            v-tooltip:bottom="t('workflow.host.id')"
+            :model-value="host.hostId"
+            prepend-icon="riLinkM"
+            readonly
+            class="mt-4 block w-full"
+            @click="$event.target.select()"
+          />
+        </transition-expand>
+      </div>
+    </ui-popover>
+    <button
+      v-tooltip.group="t('workflow.share.title')"
+      :class="{ 'text-primary': data.hasShared }"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('share')"
+    >
+      <v-remixicon name="riShareLine" />
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 ml-4">
     <button
       v-for="item in modalActions"
       :key="item.id"
@@ -10,21 +69,14 @@
       <v-remixicon :name="item.icon" />
     </button>
   </ui-card>
-  <ui-card padding="p-1 ml-4">
+  <ui-card padding="p-1 ml-4 flex items-center">
     <button
+      v-if="!workflow.isDisabled"
       v-tooltip.group="
-        t(`workflow.protect.${workflow.isProtected ? 'remove' : 'title'}`)
+        `${t('common.execute')} (${
+          shortcuts['editor:execute-workflow'].readable
+        })`
       "
-      :class="{ 'text-green-600': workflow.isProtected }"
-      class="hoverable p-2 rounded-lg"
-      @click="$emit('protect')"
-    >
-      <v-remixicon name="riShieldKeyholeLine" />
-    </button>
-    <button
-      v-if="!workflow.isDisabled"
-      v-tooltip.group="t('common.execute')"
-      :title="shortcuts['editor:execute-workflow'].readable"
       class="hoverable p-2 rounded-lg"
       @click="$emit('execute')"
     >
@@ -102,6 +154,14 @@ defineProps({
     type: Object,
     default: () => ({}),
   },
+  host: {
+    type: Object,
+    default: () => ({}),
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
 });
 const emit = defineEmits([
   'showModal',
@@ -109,12 +169,14 @@ const emit = defineEmits([
   'rename',
   'delete',
   'save',
-  'protect',
   'export',
   'update',
+  'share',
+  'host',
 ]);
 
 useGroupTooltip();
+
 const { t } = useI18n();
 const shortcuts = useShortcut(
   [
@@ -128,9 +190,9 @@ const shortcuts = useShortcut(
 
 const modalActions = [
   {
-    id: 'data-columns',
-    name: t('workflow.dataColumns.title'),
-    icon: 'riKey2Line',
+    id: 'table',
+    name: t('workflow.table.title'),
+    icon: 'riTable2',
   },
   {
     id: 'global-data',

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

@@ -5,32 +5,36 @@
     @drop="dropHandler"
     @dragover.prevent="handleDragOver"
   >
-    <slot></slot>
-    <div class="absolute z-10 p-4 bottom-0 left-0">
-      <button
-        v-tooltip.group="t('workflow.editor.resetZoom')"
-        class="p-2 rounded-lg bg-white mr-2"
-        @click="editor.zoom_reset()"
-      >
-        <v-remixicon name="riFullscreenLine" />
-      </button>
-      <div class="rounded-lg bg-white inline-block">
-        <button
-          v-tooltip.group="t('workflow.editor.zoomOut')"
-          class="p-2 rounded-lg relative z-10"
-          @click="editor.zoom_out()"
-        >
-          <v-remixicon name="riSubtractLine" />
-        </button>
-        <hr class="h-6 border-r inline-block" />
+    <div
+      class="flex items-end absolute w-full p-4 left-0 bottom-0 justify-between z-10"
+    >
+      <div id="zoom">
         <button
-          v-tooltip.group="t('workflow.editor.zoomIn')"
-          class="p-2 rounded-lg"
-          @click="editor.zoom_in()"
+          v-tooltip.group="t('workflow.editor.resetZoom')"
+          class="p-2 rounded-lg bg-white dark:bg-gray-800 mr-2"
+          @click="editor.zoom_reset()"
         >
-          <v-remixicon name="riAddLine" />
+          <v-remixicon name="riFullscreenLine" />
         </button>
+        <div class="rounded-lg bg-white dark:bg-gray-800 inline-block">
+          <button
+            v-tooltip.group="t('workflow.editor.zoomOut')"
+            class="p-2 rounded-lg relative z-10"
+            @click="editor.zoom_out()"
+          >
+            <v-remixicon name="riSubtractLine" />
+          </button>
+          <hr class="h-6 border-r inline-block" />
+          <button
+            v-tooltip.group="t('workflow.editor.zoomIn')"
+            class="p-2 rounded-lg"
+            @click="editor.zoom_in()"
+          >
+            <v-remixicon name="riAddLine" />
+          </button>
+        </div>
       </div>
+      <slot v-bind="{ editor }"></slot>
     </div>
     <ui-popover
       v-model="contextMenu.show"
@@ -61,7 +65,15 @@
 </template>
 <script>
 /* eslint-disable camelcase */
-import { onMounted, shallowRef, reactive, getCurrentInstance } from 'vue';
+import {
+  onMounted,
+  shallowRef,
+  reactive,
+  getCurrentInstance,
+  watch,
+  onBeforeUnmount,
+} from 'vue';
+import { useRoute } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
 import defu from 'defu';
@@ -78,15 +90,25 @@ export default {
       type: [Object, String],
       default: null,
     },
+    isShared: {
+      type: Boolean,
+      default: false,
+    },
     version: {
-      type: String,
+      type: [String, Boolean],
       default: '',
     },
+    mode: {
+      type: String,
+      default: 'edit',
+    },
   },
   emits: ['load', 'deleteBlock', 'update', 'save'],
   setup(props, { emit }) {
     useGroupTooltip();
+
     const { t } = useI18n();
+    const route = useRoute();
 
     const contextMenuItems = {
       block: [
@@ -115,6 +137,8 @@ export default {
       position: {},
     });
 
+    const workflowId = route.params.id;
+
     const prevSelectedEl = {
       output: null,
       connection: null,
@@ -301,6 +325,31 @@ export default {
         'vue'
       );
     }
+    function checkWorkflowData() {
+      if (!editor.value) return;
+
+      editor.value.editor_mode = props.isShared ? 'fixed' : 'edit';
+      editor.value.container.classList.toggle('is-shared', props.isShared);
+    }
+    function refreshConnection() {
+      const nodes = document.querySelectorAll('#drawflow .drawflow-node');
+      nodes.forEach((node) => {
+        if (!node.id) return;
+
+        editor.value.updateConnectionNodes(node.id);
+      });
+    }
+    function saveEditorState() {
+      const editorStates =
+        parseJSON(localStorage.getItem('editor-states'), {}) || {};
+      editorStates[workflowId] = {
+        zoom: editor.value.zoom,
+        canvas_x: editor.value.canvas_x,
+        canvas_y: editor.value.canvas_y,
+      };
+
+      localStorage.setItem('editor-states', JSON.stringify(editorStates));
+    }
 
     useShortcut('editor:duplicate-block', () => {
       const selectedElement = document.querySelector('.drawflow-node.selected');
@@ -310,11 +359,24 @@ export default {
       duplicateBlock(selectedElement.id.substr(5));
     });
 
+    watch(() => props.isShared, checkWorkflowData);
+
     onMounted(() => {
       const context = getCurrentInstance().appContext.app._context;
       const element = document.querySelector('#drawflow');
 
       editor.value = drawflow(element, { context, options: { reroute: true } });
+
+      const editorStates =
+        parseJSON(localStorage.getItem('editor-states'), {}) || {};
+      const editorState = editorStates[workflowId];
+
+      if (editorState) {
+        editor.value.zoom = editorState.zoom;
+        editor.value.canvas_x = editorState.canvas_x;
+        editor.value.canvas_y = editorState.canvas_y;
+      }
+
       editor.value.start();
 
       emit('load', editor.value);
@@ -325,7 +387,7 @@ export default {
             ? parseJSON(props.data, null)
             : props.data;
 
-        if (!data) return;
+        if (!data || !data?.drawflow?.Home) return;
 
         const currentExtVersion = chrome.runtime.getManifest().version;
         const isOldWorkflow = compare(
@@ -334,7 +396,7 @@ export default {
           '>'
         );
 
-        if (isOldWorkflow) {
+        if (isOldWorkflow && typeof props.version !== 'boolean') {
           const newDrawflowData = Object.entries(
             data.drawflow.Home.data
           ).reduce((obj, [key, value]) => {
@@ -361,7 +423,7 @@ export default {
             emit('save');
           }, 200);
         }
-      } else {
+      } else if (!props.isShared) {
         editor.value.addNode(
           'trigger',
           0,
@@ -403,6 +465,7 @@ export default {
       editor.value.on('connectionRemoved', () => {
         emitter.emit('editor:data-changed');
       });
+      editor.value.on('export', saveEditorState);
       editor.value.on('contextmenu', ({ clientY, clientX, target }) => {
         const isBlock = target.closest('.drawflow .drawflow-node');
 
@@ -425,10 +488,14 @@ export default {
         }
       });
 
+      checkWorkflowData();
+
       setTimeout(() => {
         editor.value.zoom_refresh();
+        refreshConnection();
       }, 500);
     });
+    onBeforeUnmount(saveEditorState);
 
     return {
       t,
@@ -449,4 +516,10 @@ export default {
   background-image: url('@/assets/images/tile.png');
   background-size: 35px;
 }
+.dark #drawflow {
+  background-image: url('@/assets/images/tile-white.png');
+}
+.drawflow .drawflow-node {
+  @apply dark:bg-gray-800;
+}
 </style>

+ 5 - 5
src/components/newtab/workflow/WorkflowDataColumns.vue → src/components/newtab/workflow/WorkflowDataTable.vue

@@ -3,7 +3,7 @@
     <ui-input
       v-model.lowercase="state.query"
       autofocus
-      :placeholder="t('workflow.dataColumns.placeholder')"
+      :placeholder="t('workflow.table.placeholder')"
       class="mr-2 flex-1"
       @keyup.enter="addColumn"
       @keyup.esc="$emit('close')"
@@ -25,12 +25,12 @@
         :model-value="columns[index].name"
         disabled
         class="flex-1"
-        :placeholder="t('workflow.dataColumns.column.name')"
+        :placeholder="t('workflow.table.column.name')"
       />
       <ui-select
         v-model="columns[index].type"
         class="flex-1"
-        :placeholder="t('workflow.dataColumns.column.type')"
+        :placeholder="t('workflow.table.column.type')"
       >
         <option v-for="type in dataTypes" :key="type.id" :value="type.id">
           {{ type.name }}
@@ -84,13 +84,13 @@ function addColumn() {
 watch(
   () => state.columns,
   debounce((newValue) => {
-    emit('update', { dataColumns: newValue });
+    emit('update', { table: newValue });
   }, 250),
   { deep: true }
 );
 
 onMounted(() => {
-  const tempColumns = props.workflow.dataColumns;
+  const tempColumns = props.workflow.table;
 
   state.columns = Array.isArray(tempColumns)
     ? tempColumns

+ 21 - 4
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="px-4 flex items-start mb-2 mt-1">
-    <ui-popover class="mr-2 h-8">
+    <ui-popover :disabled="data.active === 'shared'" class="mr-2 h-8">
       <template #trigger>
         <span
           :title="t('workflow.sidebar.workflowIcon')"
@@ -47,20 +47,33 @@
   <ui-input
     id="search-input"
     v-model="query"
+    :disabled="data.active === 'shared'"
     :placeholder="`${t('common.search')}... (${
       shortcut['action:search'].readable
     })`"
     prepend-icon="riSearch2Line"
     class="px-4 mt-4 mb-2"
   />
-  <div class="scroll bg-scroll overflow-auto px-4 flex-1 overflow-auto">
+  <div
+    :class="[data.active === 'shared' ? 'overflow-hidden' : 'overflow-auto']"
+    class="scroll bg-scroll px-4 flex-1 relative"
+  >
+    <div
+      v-show="data.active === 'shared'"
+      class="absolute h-full w-full bg-white dark:bg-black bg-opacity-10 dark:bg-opacity-10 backdrop-blur rounded-lg z-10 flex items-center justify-center"
+      style="top: 0; left: 50%; transform: translateX(-50%); width: 95%"
+    >
+      <p>{{ t('workflow.cantEdit') }}</p>
+    </div>
     <template v-for="(items, catId) in blocks" :key="catId">
       <div class="flex items-center top-0 space-x-2 mb-2">
         <span
           :class="categories[catId].color"
           class="h-3 w-3 rounded-full"
         ></span>
-        <p class="capitalize text-gray-600">{{ categories[catId].name }}</p>
+        <p class="capitalize text-gray-600 dark:text-gray-200">
+          {{ categories[catId].name }}
+        </p>
       </div>
       <div class="grid grid-cols-2 gap-2 mb-4">
         <div
@@ -84,7 +97,7 @@
             :title="t('common.docs')"
             target="_blank"
             rel="noopener"
-            class="absolute top-px right-2 top-2 text-gray-600 invisible group-hover:visible"
+            class="absolute top-px right-2 top-2 text-gray-600 dark:text-gray-300 invisible group-hover:visible"
           >
             <v-remixicon name="riInformationLine" size="18" />
           </a>
@@ -108,6 +121,10 @@ defineProps({
     type: Object,
     default: () => ({}),
   },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
   dataChanged: {
     type: Boolean,
     default: false,

+ 7 - 2
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -1,7 +1,7 @@
 <template>
-  <div class="px-4 overflow-auto scroll pb-1">
+  <div id="workflow-edit-block" class="px-4 overflow-auto scroll pb-1">
     <div
-      class="sticky top-0 z-20 bg-white border-b border-gray-100 pb-4 mb-4 flex items-center"
+      class="sticky top-0 z-20 bg-white dark:bg-gray-800 pb-4 mb-2 flex items-center"
     >
       <button class="mr-2" @click="$emit('close')">
         <v-remixicon name="riArrowLeftLine" />
@@ -75,3 +75,8 @@ export default {
   },
 };
 </script>
+<style>
+#workflow-edit-block hr {
+  @apply dark:border-gray-700 dark:border-opacity-40 my-4;
+}
+</style>

+ 1 - 1
src/components/newtab/workflow/WorkflowRunning.vue

@@ -5,7 +5,7 @@
         <div class="flex-1 text-overflow mr-4">
           <p class="w-full mr-2 text-overflow">{{ item.state.name }}</p>
           <p
-            class="w-full mr-2 text-gray-600 leading-tight text-overflow"
+            class="w-full mr-2 text-gray-600 dark:text-gray-200 leading-tight text-overflow"
             :title="`Started at: ${formatDate(
               item.state.startedTimestamp,
               'DD MMM, hh:mm A'

+ 3 - 2
src/components/newtab/workflow/WorkflowSettings.vue

@@ -52,6 +52,7 @@
 <script setup>
 import { onMounted, reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { debounce } from '@/utils/helper';
 
 const props = defineProps({
   workflow: {
@@ -78,11 +79,11 @@ const settings = reactive({});
 
 watch(
   settings,
-  (newSettings) => {
+  debounce((newSettings) => {
     emit('update', {
       settings: newSettings,
     });
-  },
+  }, 500),
   { deep: true }
 );
 

+ 325 - 0
src/components/newtab/workflow/WorkflowShare.vue

@@ -0,0 +1,325 @@
+<template>
+  <ui-card class="w-full max-w-2xl share-workflow overflow-auto scroll">
+    <template v-if="!store.state.user?.username">
+      <div class="flex items-center mb-12">
+        <p>{{ t('workflow.share.title') }}</p>
+        <div class="flex-grow"></div>
+        <button @click="$emit('close')">
+          <v-remixicon name="riCloseLine" />
+        </button>
+      </div>
+      <p class="text-center">
+        {{ t('auth.username') }}.
+        <a
+          class="underline"
+          href="https://automa.site/profile?username=true"
+          target="_blank"
+        >
+          {{ t('auth.clickHere') }}
+        </a>
+      </p>
+    </template>
+    <template v-else>
+      <div v-if="!isUpdate" class="flex items-center mb-4">
+        <p>{{ t('workflow.share.title') }}</p>
+        <div class="flex-grow"></div>
+        <ui-button class="mr-2" @click="$emit('close')">
+          {{ t('common.cancel') }}
+        </ui-button>
+        <ui-button
+          :loading="state.isPublishing"
+          variant="accent"
+          @click="publishWorkflow"
+        >
+          {{ t('workflow.share.publish') }}
+        </ui-button>
+      </div>
+      <slot name="prepend"></slot>
+      <div class="flex mb-4">
+        <input
+          v-model="state.workflow.name"
+          :placeholder="t('workflow.name')"
+          type="text"
+          name="workflow name"
+          class="font-semibold leading-none text-2xl focus:ring-0 block w-full bg-transparent mr-4 flex-1"
+        />
+        <ui-select v-model="state.workflow.category">
+          <option value="">{{ t('common.category') }} (none)</option>
+          <option
+            v-for="(category, id) in workflowCategories"
+            :key="id"
+            :value="id"
+          >
+            {{ category }}
+          </option>
+        </ui-select>
+      </div>
+      <div class="relative mb-2">
+        <ui-textarea
+          v-model="state.workflow.description"
+          :max="300"
+          placeholder="Short description"
+          class="w-full h-32 scroll resize-none"
+        />
+        <p
+          class="text-sm text-gray-600 dark:text-gray-200 absolute bottom-2 right-2"
+        >
+          {{ state.workflow.description.length }}/300
+        </p>
+      </div>
+      <shared-wysiwyg
+        v-model="state.workflow.content"
+        :placeholder="t('common.description')"
+        :limit="5000"
+        class="prose prose-zinc dark:prose-invert max-w-none content-editor p-4 bg-box-transparent rounded-lg relative"
+        @count="state.contentLength = $event"
+      >
+        <template #prepend="{ editor }">
+          <div
+            class="p-2 rounded-lg backdrop-blur flex items-center sticky top-0 z-50 bg-box-transparent space-x-1 mb-2"
+          >
+            <button
+              :class="{
+                'bg-box-transparent text-primary': editor.isActive('heading', {
+                  level: 1,
+                }),
+              }"
+              title="Heading 1"
+              class="editor-menu-btn hoverable"
+              @click="editor.commands.toggleHeading({ level: 1 })"
+            >
+              <v-remixicon name="riH1" />
+            </button>
+            <button
+              :class="{
+                'bg-box-transparent text-primary': editor.isActive('heading', {
+                  level: 2,
+                }),
+              }"
+              title="Heading 2"
+              class="editor-menu-btn hoverable"
+              @click="editor.commands.toggleHeading({ level: 2 })"
+            >
+              <v-remixicon name="riH2" />
+            </button>
+            <span
+              class="w-px h-5 bg-gray-300 dark:bg-gray-600"
+              style="margin: 0 12px"
+            ></span>
+            <button
+              v-for="item in menuItems"
+              :key="item.id"
+              :title="item.name"
+              :class="{
+                'bg-box-transparent text-primary': editor.isActive(item.id),
+              }"
+              class="editor-menu-btn hoverable"
+              @click="editor.chain().focus()[item.action]().run()"
+            >
+              <v-remixicon :name="item.icon" />
+            </button>
+            <span
+              class="w-px h-5 bg-gray-300 dark:bg-gray-600"
+              style="margin: 0 12px"
+            ></span>
+            <button
+              :class="{
+                'bg-box-transparent text-primary':
+                  editor.isActive('blockquote'),
+              }"
+              title="Blockquote"
+              class="editor-menu-btn hoverable"
+              @click="editor.commands.toggleBlockquote()"
+            >
+              <v-remixicon name="riDoubleQuotesL" />
+            </button>
+            <button
+              title="Insert image"
+              class="editor-menu-btn hoverable"
+              @click="insertImage(editor)"
+            >
+              <v-remixicon name="riImageLine" />
+            </button>
+            <button
+              :class="{
+                'bg-box-transparent text-primary': editor.isActive('link'),
+              }"
+              title="Link"
+              class="editor-menu-btn hoverable"
+              @click="setLink(editor)"
+            >
+              <v-remixicon name="riLinkM" />
+            </button>
+            <button
+              v-show="editor.isActive('link')"
+              title="Remove link"
+              class="editor-menu-btn hoverable"
+              @click="editor.commands.unsetLink()"
+            >
+              <v-remixicon name="riLinkUnlinkM" />
+            </button>
+          </div>
+        </template>
+        <template #append>
+          <p
+            class="text-sm text-gray-600 dark:text-gray-200 absolute bottom-2 right-2"
+          >
+            {{ state.contentLength }}/5000
+          </p>
+        </template>
+      </shared-wysiwyg>
+    </template>
+  </ui-card>
+</template>
+<script setup>
+import { reactive, watch } from 'vue';
+import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import { fetchApi } from '@/utils/api';
+import { workflowCategories } from '@/utils/shared';
+import { parseJSON, debounce } from '@/utils/helper';
+import { convertWorkflow } from '@/utils/workflow-data';
+import SharedWysiwyg from '@/components/newtab/shared/SharedWysiwyg.vue';
+
+const props = defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  isUpdate: Boolean,
+});
+const emit = defineEmits(['close', 'publish', 'change']);
+
+const { t } = useI18n();
+const toast = useToast();
+const store = useStore();
+
+const menuItems = [
+  { id: 'bold', name: 'Bold', icon: 'riBold', action: 'toggleBold' },
+  { id: 'italic', name: 'Italic', icon: 'riItalic', action: 'toggleItalic' },
+  {
+    id: 'strike',
+    name: 'Strikethrough',
+    icon: 'riStrikethrough2',
+    action: 'toggleStrike',
+  },
+];
+
+const state = reactive({
+  contentLength: 0,
+  isPublishing: false,
+  workflow: JSON.parse(JSON.stringify(props.workflow)),
+});
+
+async function publishWorkflow() {
+  try {
+    state.isPublishing = true;
+
+    const workflow = convertWorkflow(state.workflow, ['id', 'category']);
+    workflow.name = workflow.name || 'unnamed';
+    workflow.content = state.workflow.content || null;
+    workflow.drawflow = parseJSON(workflow.drawflow, {});
+    workflow.description = state.workflow.description.slice(0, 300);
+
+    delete workflow.extVersion;
+
+    const nodes = workflow.drawflow?.drawflow.Home.data;
+    Object.keys(nodes).forEach((nodeId) => {
+      if (nodes[nodeId].name !== 'loop-data') return;
+
+      nodes[nodeId].data.loopData = '';
+    });
+
+    const response = await fetchApi('/me/workflows/shared', {
+      method: 'POST',
+      body: JSON.stringify({ workflow }),
+    });
+    const result = await response.json();
+
+    if (response.status !== 200) {
+      const error = new Error(response.statusText);
+      error.data = result.data;
+
+      throw error;
+    }
+
+    workflow.drawflow = props.workflow.drawflow;
+
+    store.commit('updateStateNested', {
+      path: `sharedWorkflows.${workflow.id}`,
+      value: workflow,
+    });
+    sessionStorage.setItem(
+      'shared-workflows',
+      JSON.stringify(store.state.sharedWorkflows)
+    );
+
+    state.isPublishing = false;
+
+    emit('publish');
+  } catch (error) {
+    let errorMessage = t('message.somethingWrong');
+
+    if (error.data && error.data.show) {
+      errorMessage = error.message;
+    }
+
+    toast.error(errorMessage);
+    console.error(error);
+
+    state.isPublishing = false;
+  }
+}
+function setLink(editor) {
+  const previousUrl = editor.getAttributes('link').href;
+  const url = window.prompt('URL', previousUrl);
+
+  if (url === null) return;
+
+  if (url === '') {
+    editor.chain().focus().extendMarkRange('link').unsetLink().run();
+
+    return;
+  }
+
+  editor
+    .chain()
+    .focus()
+    .extendMarkRange('link')
+    .setLink({ href: url, target: '_blank' })
+    .run();
+}
+function insertImage(editor) {
+  const url = window.prompt('URL');
+
+  if (url) {
+    editor.chain().focus().setImage({ src: url }).run();
+  }
+}
+
+watch(
+  () => state.workflow,
+  debounce(() => {
+    emit('change', state.workflow);
+  }, 200),
+  { deep: true }
+);
+</script>
+<style scoped>
+.share-workflow {
+  min-height: 500px;
+  max-height: calc(100vh - 4rem);
+}
+.editor-menu-btn {
+  @apply p-1 rounded-md transition;
+}
+</style>
+<style>
+.content-editor .ProseMirror {
+  min-height: 200px;
+}
+.content-editor .ProseMirror :first-child {
+  margin-top: 0 !important;
+}
+</style>

+ 148 - 0
src/components/newtab/workflow/WorkflowSharedActions.vue

@@ -0,0 +1,148 @@
+<template>
+  <ui-card padding="p-1">
+    <ui-input
+      v-tooltip="t('workflow.share.url')"
+      prepend-icon="riLinkM"
+      :model-value="`https://automa.site/workflow/${workflow.id}`"
+      readonly
+      @click="$event.target.select()"
+    />
+  </ui-card>
+  <ui-card padding="p-1 ml-4">
+    <button
+      v-if="data.hasLocal"
+      v-tooltip.group="t('workflow.share.fetchLocal')"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('fetchLocal')"
+    >
+      <v-remixicon name="riRefreshLine" />
+    </button>
+    <button
+      v-if="!data.hasLocal"
+      v-tooltip.group="t('workflow.share.download')"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('insertLocal')"
+    >
+      <v-remixicon name="riDownloadLine" />
+    </button>
+    <button
+      v-tooltip.group="t('workflow.share.edit')"
+      class="hoverable p-2 rounded-lg"
+      @click="state.showModal = true"
+    >
+      <v-remixicon name="riFileEditLine" />
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 flex ml-4">
+    <button
+      v-tooltip.group="t('workflow.share.unpublish')"
+      class="hoverable p-2 mr-2 rounded-lg relative"
+      @click="$emit('unpublish')"
+    >
+      <ui-spinner
+        v-if="data.isUnpublishing"
+        color="text-accent"
+        class="absolute top-2 left-2"
+      />
+      <v-remixicon
+        name="riLock2Line"
+        :class="{ 'opacity-75': data.isUnpublishing }"
+      />
+    </button>
+    <ui-button
+      :loading="data.isUpdating"
+      :disabled="data.isUnpublishing"
+      variant="accent"
+      @click="$emit('save')"
+    >
+      <span
+        v-if="data.isChanged"
+        class="flex h-3 w-3 absolute top-0 left-0 -ml-1 -mt-1"
+      >
+        <span
+          class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
+        ></span>
+        <span
+          class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
+        ></span>
+      </span>
+      {{ t('workflow.share.update') }}
+    </ui-button>
+  </ui-card>
+  <ui-modal v-model="state.showModal" custom-content @close="updateDescription">
+    <workflow-share
+      :workflow="workflow"
+      is-update
+      @change="onDescriptionUpdated"
+    >
+      <template #prepend>
+        <div class="flex justify-between mb-6">
+          <p>{{ t('workflow.share.edit') }}</p>
+          <v-remixicon
+            name="riCloseLine"
+            class="cursor-pointer"
+            @click="
+              state.showModal = false;
+              updateDescription();
+            "
+          />
+        </div>
+      </template>
+    </workflow-share>
+  </ui-modal>
+</template>
+<script setup>
+import { shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
+
+const props = defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits([
+  'insertLocal',
+  'fetchLocal',
+  'update',
+  'save',
+  'unpublish',
+]);
+
+useGroupTooltip();
+const { t } = useI18n();
+
+const state = shallowReactive({
+  showModal: false,
+  isChanged: false,
+  name: props.workflow.name,
+  content: props.workflow.content,
+  category: props.workflow.category,
+  description: props.workflow.description,
+});
+
+function onDescriptionUpdated({ name, description, content, category }) {
+  state.isChanged = true;
+
+  state.name = name;
+  state.content = content;
+  state.category = category;
+  state.description = description;
+}
+function updateDescription() {
+  if (!state.isChanged) return;
+
+  emit('update', {
+    name: state.name,
+    content: state.content,
+    description: state.description,
+  });
+  state.isChanged = false;
+}
+</script>

+ 52 - 45
src/components/newtab/workflow/edit/EditAttributeValue.vue

@@ -1,40 +1,55 @@
 <template>
   <edit-interaction-base v-bind="{ data }" @change="updateData">
+    <hr />
     <ui-input
       :model-value="data.attributeName"
-      :placeholder="t('workflow.blocks.attribute-value.forms.name')"
-      class="mt-3 w-full"
+      :label="t('workflow.blocks.attribute-value.forms.name')"
+      placeholder="name"
+      class="w-full"
       @change="updateData({ attributeName: $event })"
     />
+    <ui-checkbox
+      :model-value="data.assignVariable"
+      block
+      class="mt-4"
+      @change="updateData({ assignVariable: $event })"
+    >
+      {{ t('workflow.variables.assign') }}
+    </ui-checkbox>
+    <ui-input
+      v-if="data.assignVariable"
+      :model-value="data.variableName"
+      :placeholder="t('workflow.variables.name')"
+      :title="t('workflow.variables.name')"
+      class="mt-2 w-full"
+      @change="updateData({ variableName: $event })"
+    />
     <ui-checkbox
       :model-value="data.saveData"
-      class="mt-3"
+      class="mt-4"
       @change="updateData({ saveData: $event })"
     >
       {{ t('workflow.blocks.attribute-value.forms.checkbox') }}
     </ui-checkbox>
-    <div v-if="data.saveData" class="flex items-center mt-1">
-      <ui-select
-        :model-value="data.dataColumn"
-        :placeholder="t('workflow.blocks.attribute-value.forms.column')"
-        class="mr-2 flex-1"
-        @change="updateData({ dataColumn: $event })"
+    <ui-select
+      v-if="data.saveData"
+      :model-value="data.dataColumn"
+      :placeholder="t('workflow.blocks.attribute-value.forms.column')"
+      class="w-full mt-2"
+      @change="updateData({ dataColumn: $event })"
+    >
+      <option
+        v-for="column in workflow.data.value.table"
+        :key="column.name"
+        :value="column.name"
       >
-        <option
-          v-for="column in workflow.data.value.dataColumns"
-          :key="column.name"
-          :value="column.name"
-        >
-          {{ column.name }}
-        </option>
-      </ui-select>
-      <ui-button icon @click="workflow.showDataColumnsModal(true)">
-        <v-remixicon name="riKey2Line" />
-      </ui-button>
-    </div>
+        {{ column.name }}
+      </option>
+    </ui-select>
     <ui-checkbox
       :model-value="data.addExtraRow"
-      class="mt-3"
+      class="mt-4"
+      block
       @change="updateData({ addExtraRow: $event })"
     >
       {{ t('workflow.blocks.attribute-value.forms.extraRow.checkbox') }}
@@ -46,32 +61,24 @@
       :placeholder="
         t('workflow.blocks.attribute-value.forms.extraRow.placeholder')
       "
-      class="w-full mt-3 mb-2"
+      class="w-full mt-2 mb-2"
       @change="updateData({ extraRowValue: $event })"
     />
-    <div v-if="data.addExtraRow" class="flex items-center mt-1">
-      <ui-select
-        :model-value="data.extraRowDataColumn"
-        placeholder="Data column"
-        class="mr-2 flex-1"
-        @change="updateData({ extraRowDataColumn: $event })"
-      >
-        <option
-          v-for="column in workflow.data.value.dataColumns"
-          :key="column.name"
-          :value="column.name"
-        >
-          {{ column.name }}
-        </option>
-      </ui-select>
-      <ui-button
-        icon
-        title="Data columns"
-        @click="workflow.showDataColumnsModal(true)"
+    <ui-select
+      v-if="data.addExtraRow"
+      :model-value="data.extraRowDataColumn"
+      placeholder="Select column"
+      class="mt-1 w-full"
+      @change="updateData({ extraRowDataColumn: $event })"
+    >
+      <option
+        v-for="column in workflow.data.value.table"
+        :key="column.name"
+        :value="column.name"
       >
-        <v-remixicon name="riKey2Line" />
-      </ui-button>
-    </div>
+        {{ column.name }}
+      </option>
+    </ui-select>
   </edit-interaction-base>
 </template>
 <script setup>

+ 1 - 1
src/components/newtab/workflow/edit/EditBrowserEvent.vue

@@ -35,7 +35,7 @@
         :model-value="data.tabLoadedUrl"
         type="url"
         class="w-full mt-1"
-        placeholder="*://*.example.org/*"
+        placeholder="https://example.org/*"
         @change="updateData({ tabLoadedUrl: $event })"
       >
         <template #label>

+ 103 - 0
src/components/newtab/workflow/edit/EditClipboard.vue

@@ -0,0 +1,103 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full"
+      :placeholder="t('common.description')"
+      @change="updateData({ description: $event })"
+    />
+    <template v-if="hasPermission">
+      <p class="mt-4">
+        {{ t('workflow.blocks.clipboard.data') }}
+      </p>
+      <ui-input
+        v-if="data.responseType === 'json'"
+        :model-value="data.dataPath"
+        placeholder="path.to.data"
+        label="Data path"
+        class="w-full mt-2"
+        @change="updateData({ dataPath: $event })"
+      />
+      <ui-checkbox
+        :model-value="data.assignVariable"
+        block
+        class="mt-4"
+        @change="updateData({ assignVariable: $event })"
+      >
+        {{ t('workflow.variables.assign') }}
+      </ui-checkbox>
+      <ui-input
+        v-if="data.assignVariable"
+        :model-value="data.variableName"
+        :placeholder="t('workflow.variables.name')"
+        :title="t('workflow.variables.name')"
+        class="mt-2 w-full"
+        @change="updateData({ variableName: $event })"
+      />
+      <ui-checkbox
+        :model-value="data.saveData"
+        block
+        class="mt-4"
+        @change="updateData({ saveData: $event })"
+      >
+        {{ t('workflow.blocks.get-text.checkbox') }}
+      </ui-checkbox>
+      <ui-select
+        v-if="data.saveData"
+        :model-value="data.dataColumn"
+        placeholder="Select column"
+        class="mt-2 w-full"
+        @change="updateData({ dataColumn: $event })"
+      >
+        <option
+          v-for="column in workflow.data.value.table"
+          :key="column.name"
+          :value="column.name"
+        >
+          {{ column.name }}
+        </option>
+      </ui-select>
+    </template>
+    <template v-else>
+      <p class="mt-4">
+        {{ t('workflow.blocks.clipboard.noPermission') }}
+      </p>
+      <ui-button variant="accent" class="mt-2" @click="requestPermission">
+        {{ t('workflow.blocks.clipboard.grantPermission') }}
+      </ui-button>
+    </template>
+  </div>
+</template>
+<script setup>
+import { ref, inject, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import browser from 'webextension-polyfill';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const permission = { permissions: ['clipboardRead'] };
+const { t } = useI18n();
+
+const workflow = inject('workflow');
+const hasPermission = ref(false);
+
+function handlePermission(status) {
+  hasPermission.value = status;
+}
+function requestPermission() {
+  browser.permissions.request(permission).then(handlePermission);
+}
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+
+onMounted(() => {
+  browser.permissions.contains(permission).then(handlePermission);
+});
+</script>

+ 19 - 8
src/components/newtab/workflow/edit/EditCloseTab.vue

@@ -1,9 +1,15 @@
 <template>
   <div class="mb-2 mt-4">
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
     <ui-select
       :model-value="data.closeType"
-      placeholder="Close"
-      class="w-full mb-4"
+      :placeholder="Close"
+      class="w-full mt-2"
       @change="updateData({ closeType: $event })"
     >
       <option
@@ -16,7 +22,7 @@
       </option>
     </ui-select>
     <template v-if="data.closeType === 'tab'">
-      <div class="mb-2">
+      <div class="mt-1">
         <ui-checkbox
           :model-value="data.activeTab"
           @change="updateData({ activeTab: $event })"
@@ -27,25 +33,30 @@
       <ui-input
         v-if="!data.activeTab"
         :model-value="data.url"
-        class="w-full"
+        class="w-full mt-1"
         placeholder="http://example.com/*"
         @change="updateData({ url: $event })"
       >
         <template #label>
           {{ t('workflow.blocks.close-tab.url') }}
           <a
-            href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns"
-            target="_blank"
+            :title="t('common.example', 2)"
             rel="noopener"
-            title="More info"
+            target="_blank"
+            href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#examples"
           >
-            &#9432;
+            <v-remixicon
+              name="riInformationLine"
+              size="18"
+              class="inline-block"
+            />
           </a>
         </template>
       </ui-input>
     </template>
     <ui-checkbox
       v-else
+      class="mt-1"
       :model-value="data.allWindows"
       @change="updateData({ allWindows: $event })"
     >

+ 3 - 3
src/components/newtab/workflow/edit/EditConditions.vue

@@ -21,7 +21,7 @@
           class="py-2 px-4 w-full transition rounded-lg bg-transparent"
         />
         <button
-          class="bg-white absolute top-1/2 right-4 p-2 rounded-lg -translate-y-1/2 group-hover:right-14"
+          class="bg-white dark:bg-gray-700 absolute top-1/2 right-4 p-2 rounded-lg -translate-y-1/2 group-hover:right-14"
           @click="deleteCondition(index)"
         >
           <v-remixicon size="20" name="riDeleteBin7Line" />
@@ -29,7 +29,7 @@
         <select
           v-model="condition.type"
           :title="getTitle(index)"
-          class="bg-white absolute right-4 font-mono z-10 p-2 top-1/2 leading-tight -translate-y-1/2 text-center transition rounded-lg appearance-none"
+          class="bg-white dark:bg-gray-700 absolute right-4 font-mono z-10 p-2 top-1/2 leading-tight -translate-y-1/2 text-center transition rounded-lg appearance-none"
         >
           <option
             v-for="(name, type) in conditionTypes"
@@ -40,7 +40,7 @@
           </option>
         </select>
         <div
-          class="w-full bg-gray-300 h-px mx-auto"
+          class="w-full bg-gray-300 dark:bg-gray-700 h-px mx-auto"
           style="max-width: 89%"
         ></div>
         <input

+ 10 - 1
src/components/newtab/workflow/edit/EditElementExists.vue

@@ -3,7 +3,7 @@
     <ui-select
       :model-value="data.findBy || 'cssSelector'"
       :placeholder="t('workflow.blocks.base.findElement.placeholder')"
-      class="w-full mb-2"
+      class="w-full mb-1"
       @change="updateData({ findBy: $event })"
     >
       <option v-for="type in selectorTypes" :key="type" :value="type">
@@ -13,6 +13,7 @@
     <ui-input
       :model-value="data.selector"
       :label="t('workflow.blocks.element-exists.selector')"
+      placeholder=".element"
       class="mb-1 w-full"
       @change="updateData({ selector: $event })"
     />
@@ -34,6 +35,14 @@
       min="200"
       @change="updateData({ timeout: +$event })"
     />
+    <label class="flex items-center mt-4">
+      <ui-switch
+        :model-value="data.throwError"
+        class="mr-2"
+        @change="updateData({ throwError: $event })"
+      />
+      <span>{{ t('workflow.blocks.element-exists.throwError') }}</span>
+    </label>
   </div>
 </template>
 <script setup>

+ 15 - 5
src/components/newtab/workflow/edit/EditExecuteWorkflow.vue

@@ -1,9 +1,16 @@
 <template>
   <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      autoresize
+      class="w-full mb-2"
+      @change="updateData({ description: $event })"
+    />
     <ui-select
       :model-value="data.workflowId"
       :placeholder="t('workflow.blocks.execute-workflow.select')"
-      class="w-full mb-4"
+      class="w-full mb-2"
       @change="updateData({ workflowId: $event })"
     >
       <option
@@ -16,17 +23,20 @@
     </ui-select>
     <ui-input
       :model-value="data.executeId"
-      :placeholder="t('workflow.blocks.execute-workflow.executeId')"
+      :label="t('workflow.blocks.execute-workflow.executeId')"
       :title="t('workflow.blocks.execute-workflow.executeId')"
-      class="mb-2 w-full"
+      placeholder="abc123"
+      class="mb-3 w-full"
       @change="updateData({ executeId: $event })"
     />
-    <p>{{ t('common.globalData') }}</p>
+    <p class="text-sm text-gray-600 dark:text-gray-200 ml-1 mb-1">
+      {{ t('common.globalData') }}
+    </p>
     <pre
       v-if="!state.showGlobalData"
       class="rounded-lg text-gray-200 p-4 max-h-80 bg-gray-900 overflow-auto"
       @click="state.showGlobalData = true"
-      v-text="data.globalData"
+      v-text="data.globalData || '____'"
     />
     <ui-modal
       v-model="state.showGlobalData"

+ 4 - 4
src/components/newtab/workflow/edit/EditExportData.vue

@@ -8,7 +8,7 @@
     />
     <ui-select
       :model-value="data.dataToExport"
-      :placeholder="t('workflow.blocks.export-data.dataToExport.placeholder')"
+      :label="t('workflow.blocks.export-data.dataToExport.placeholder')"
       class="w-full mt-2"
       @change="updateData({ dataToExport: $event })"
     >
@@ -18,9 +18,9 @@
     </ui-select>
     <ui-input
       :model-value="data.name"
+      label="File name"
       class="w-full mt-2"
-      title="File name"
-      placeholder="File name"
+      placeholder="unnamed"
       @change="updateData({ name: $event })"
     />
     <ui-input
@@ -33,7 +33,7 @@
     />
     <ui-select
       :model-value="data.type"
-      :placeholder="t('workflow.blocks.export-data.exportAs')"
+      :label="t('workflow.blocks.export-data.exportAs')"
       class="w-full mt-2"
       @change="updateData({ type: $event })"
     >

+ 37 - 29
src/components/newtab/workflow/edit/EditForms.vue

@@ -1,8 +1,8 @@
 <template>
   <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
+    <hr />
     <ui-checkbox
       :model-value="data.getValue"
-      class="my-2"
       @change="updateData({ getValue: $event })"
     >
       {{ t('workflow.blocks.forms.getValue') }}
@@ -10,39 +10,48 @@
     <template v-if="data.getValue && !hideBase">
       <ui-checkbox
         :model-value="data.saveData"
-        class="mb-2 ml-2"
+        block
+        class="mt-4"
         @change="updateData({ saveData: $event })"
       >
-        Save data
+        Insert to table
       </ui-checkbox>
-      <div class="flex items-center">
-        <ui-select
-          :model-value="data.dataColumn"
-          placeholder="Data column"
-          class="mr-2 flex-1"
-          @change="updateData({ dataColumn: $event })"
-        >
-          <option
-            v-for="column in workflow.data.value.dataColumns"
-            :key="column.name"
-            :value="column.name"
-          >
-            {{ column.name }}
-          </option>
-        </ui-select>
-        <ui-button
-          icon
-          title="Data columns"
-          @click="workflow.showDataColumnsModal(true)"
+      <ui-select
+        v-if="data.saveData"
+        :model-value="data.dataColumn"
+        placeholder="Select column"
+        class="w-full mt-2"
+        @change="updateData({ dataColumn: $event })"
+      >
+        <option
+          v-for="column in workflow.data.value.table"
+          :key="column.name"
+          :value="column.name"
         >
-          <v-remixicon name="riKey2Line" />
-        </ui-button>
-      </div>
+          {{ column.name }}
+        </option>
+      </ui-select>
+      <ui-checkbox
+        :model-value="data.assignVariable"
+        block
+        class="mt-4"
+        @change="updateData({ assignVariable: $event })"
+      >
+        {{ t('workflow.variables.assign') }}
+      </ui-checkbox>
+      <ui-input
+        v-if="data.assignVariable"
+        :model-value="data.variableName"
+        :placeholder="t('workflow.variables.name')"
+        :title="t('workflow.variables.name')"
+        class="mt-2 w-full"
+        @change="updateData({ variableName: $event })"
+      />
     </template>
     <template v-else>
       <ui-select
         :model-value="data.type"
-        class="block w-full mb-3"
+        class="block w-full mb-2 mt-4"
         :placeholder="t('workflow.blocks.forms.type')"
         @change="updateData({ type: $event })"
       >
@@ -61,12 +70,11 @@
         <ui-textarea
           :model-value="data.value"
           :placeholder="t('workflow.blocks.forms.text-field.value')"
-          class="w-full"
+          class="w-full mb-1"
           @change="updateData({ value: $event })"
         />
         <ui-checkbox
           :model-value="data.clearValue"
-          class="mb-1 ml-1"
           @change="updateData({ clearValue: $event })"
         >
           {{ t('workflow.blocks.forms.text-field.clearValue') }}
@@ -77,7 +85,7 @@
         :model-value="data.delay"
         :label="t('workflow.blocks.forms.text-field.delay.label')"
         :placeholder="t('workflow.blocks.forms.text-field.delay.placeholder')"
-        class="w-full"
+        class="w-full mt-1"
         min="0"
         type="number"
         @change="updateData({ delay: +$event })"

+ 72 - 65
src/components/newtab/workflow/edit/EditGetText.vue

@@ -1,6 +1,7 @@
 <template>
   <edit-interaction-base v-bind="{ data }" @change="updateData">
-    <div class="flex rounded-lg bg-input px-4 items-center transition mt-3">
+    <hr />
+    <div class="flex rounded-lg bg-input px-4 items-center transition">
       <span>/</span>
       <input
         :value="data.regex"
@@ -25,95 +26,101 @@
         </div>
       </ui-popover>
     </div>
+    <div class="mt-2 flex space-x-2">
+      <ui-input
+        :model-value="data.prefixText"
+        :title="t('workflow.blocks.get-text.prefixText.title')"
+        :label="t('workflow.blocks.get-text.prefixText.placeholder')"
+        placeholder="Text"
+        class="w-full"
+        @change="updateData({ prefixText: $event })"
+      />
+      <ui-input
+        :model-value="data.suffixText"
+        :title="t('workflow.blocks.get-text.suffixText.title')"
+        :label="t('workflow.blocks.get-text.suffixText.placeholder')"
+        placeholder="Text"
+        class="w-full"
+        @change="updateData({ suffixText: $event })"
+      />
+    </div>
     <ui-checkbox
-      :model-value="data.saveData"
-      class="mt-3"
-      @change="updateData({ saveData: $event })"
+      :model-value="data.includeTags"
+      class="mt-4"
+      @change="updateData({ includeTags: $event })"
     >
-      {{ t('workflow.blocks.get-text.checkbox') }}
+      {{ t('workflow.blocks.get-text.includeTags') }}
+    </ui-checkbox>
+    <hr />
+    <ui-checkbox
+      :model-value="data.assignVariable"
+      block
+      @change="updateData({ assignVariable: $event })"
+    >
+      {{ t('workflow.variables.assign') }}
     </ui-checkbox>
-    <div v-if="data.saveData" class="flex items-center mt-1">
-      <ui-select
-        :model-value="data.dataColumn"
-        placeholder="Data column"
-        class="mr-2 flex-1"
-        @change="updateData({ dataColumn: $event })"
-      >
-        <option
-          v-for="column in workflow.data.value.dataColumns"
-          :key="column.name"
-          :value="column.name"
-        >
-          {{ column.name }}
-        </option>
-      </ui-select>
-      <ui-button
-        icon
-        title="Data columns"
-        @click="workflow.showDataColumnsModal(true)"
-      >
-        <v-remixicon name="riKey2Line" />
-      </ui-button>
-    </div>
-    <ui-input
-      :model-value="data.prefixText"
-      :title="t('workflow.blocks.get-text.prefixText.title')"
-      :placeholder="t('workflow.blocks.get-text.prefixText.placeholder')"
-      class="w-full mt-3 mb-2"
-      @change="updateData({ prefixText: $event })"
-    />
     <ui-input
-      :model-value="data.suffixText"
-      :title="t('workflow.blocks.get-text.suffixText.title')"
-      :placeholder="t('workflow.blocks.get-text.suffixText.placeholder')"
-      class="w-full"
-      @change="updateData({ suffixText: $event })"
+      v-if="data.assignVariable"
+      :model-value="data.variableName"
+      :placeholder="t('workflow.variables.name')"
+      :title="t('workflow.variables.name')"
+      class="mt-2 w-full"
+      @change="updateData({ variableName: $event })"
     />
     <ui-checkbox
-      :model-value="data.includeTags"
-      class="mt-3"
-      @change="updateData({ includeTags: $event })"
+      :model-value="data.saveData"
+      block
+      class="mt-4"
+      @change="updateData({ saveData: $event })"
     >
-      {{ t('workflow.blocks.get-text.includeTags') }}
+      {{ t('workflow.blocks.get-text.checkbox') }}
     </ui-checkbox>
+    <ui-select
+      v-if="data.saveData"
+      :model-value="data.dataColumn"
+      placeholder="Select column"
+      class="w-full mt-2 mb-4"
+      @change="updateData({ dataColumn: $event })"
+    >
+      <option
+        v-for="column in workflow.data.value.table"
+        :key="column.name"
+        :value="column.name"
+      >
+        {{ column.name }}
+      </option>
+    </ui-select>
     <ui-checkbox
       :model-value="data.addExtraRow"
-      class="mt-3"
+      block
+      class="mt-4"
       @change="updateData({ addExtraRow: $event })"
     >
       {{ t('workflow.blocks.get-text.extraRow.checkbox') }}
     </ui-checkbox>
-    <ui-input
-      v-if="data.addExtraRow"
-      :model-value="data.extraRowValue"
-      :title="t('workflow.blocks.get-text.extraRow.title')"
-      :placeholder="t('workflow.blocks.get-text.extraRow.placeholder')"
-      class="w-full mt-3 mb-2"
-      @change="updateData({ extraRowValue: $event })"
-    />
-    <div v-if="data.addExtraRow" class="flex items-center mt-1">
+    <template v-if="data.addExtraRow">
+      <ui-input
+        :model-value="data.extraRowValue"
+        :title="t('workflow.blocks.get-text.extraRow.title')"
+        :placeholder="t('workflow.blocks.get-text.extraRow.placeholder')"
+        class="w-full my-2"
+        @change="updateData({ extraRowValue: $event })"
+      />
       <ui-select
         :model-value="data.extraRowDataColumn"
-        placeholder="Data column"
-        class="mr-2 flex-1"
+        placeholder="Select column"
+        class="w-full"
         @change="updateData({ extraRowDataColumn: $event })"
       >
         <option
-          v-for="column in workflow.data.value.dataColumns"
+          v-for="column in workflow.data.value.table"
           :key="column.name"
           :value="column.name"
         >
           {{ column.name }}
         </option>
       </ui-select>
-      <ui-button
-        icon
-        title="Data columns"
-        @click="workflow.showDataColumnsModal(true)"
-      >
-        <v-remixicon name="riKey2Line" />
-      </ui-button>
-    </div>
+    </template>
   </edit-interaction-base>
 </template>
 <script setup>

+ 2 - 3
src/components/newtab/workflow/edit/EditGoogleSheets.vue

@@ -25,7 +25,7 @@
       @change="updateData({ spreadsheetId: $event })"
     >
       <template #label>
-        {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}
+        {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}*
         <a
           href="https://docs.automa.site/blocks/google-sheets.html#spreadsheet-id"
           target="_blank"
@@ -43,7 +43,7 @@
       @change="updateData({ range: $event })"
     >
       <template #label>
-        {{ t('workflow.blocks.google-sheets.range.label') }}
+        {{ t('workflow.blocks.google-sheets.range.label') }}*
         <a
           href="https://docs.automa.site/blocks/google-sheets.html#range"
           target="_blank"
@@ -211,7 +211,6 @@ async function previewData() {
 
     previewDataState.status = 'idle';
   } catch (error) {
-    console.dir(error);
     previewDataState.data = '';
     previewDataState.status = 'error';
     previewDataState.errorMessage = error.message;

+ 116 - 0
src/components/newtab/workflow/edit/EditInsertData.vue

@@ -0,0 +1,116 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ul v-show="dataList.length > 0" class="mt-4 data-list">
+      <li
+        v-for="(item, index) in dataList"
+        :key="index"
+        class="mb-4 pb-4 border-b"
+      >
+        <div class="flex mb-2">
+          <ui-select
+            :model-value="item.type"
+            class="mr-2 flex-shrink-0"
+            @change="changeItemType(index, $event)"
+          >
+            <option value="table">
+              {{ t('workflow.table.title') }}
+            </option>
+            <option value="variable">
+              {{ t('workflow.variables.title') }}
+            </option>
+          </ui-select>
+          <ui-input
+            v-if="item.type === 'variable'"
+            v-model="item.name"
+            :placeholder="t('workflow.variables.name')"
+            :title="t('workflow.variables.name')"
+            class="flex-1"
+          />
+          <ui-select
+            v-else
+            v-model="item.name"
+            :placeholder="t('workflow.table.select')"
+          >
+            <option
+              v-for="column in workflow.data.value.table"
+              :key="column.name"
+              :value="column.name"
+            >
+              {{ column.name }}
+            </option>
+          </ui-select>
+        </div>
+        <div class="flex items-center">
+          <ui-input
+            v-model="item.value"
+            placeholder="value"
+            title="value"
+            class="flex-1 mr-2"
+          />
+          <ui-button icon @click="dataList.splice(index, 1)">
+            <v-remixicon name="riDeleteBin7Line" />
+          </ui-button>
+        </div>
+      </li>
+    </ul>
+    <ui-button class="mt-4" variant="accent" @click="addItem">
+      {{ t('common.add') }}
+    </ui-button>
+  </div>
+</template>
+<script setup>
+import { ref, watch, inject } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+const workflow = inject('workflow');
+const dataList = ref(JSON.parse(JSON.stringify(props.data.dataList)));
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function addItem() {
+  dataList.value.push({
+    type: 'table',
+    name: '',
+    value: '',
+  });
+}
+function changeItemType(index, type) {
+  dataList.value[index] = {
+    ...dataList.value[index],
+    type,
+    name: '',
+  };
+}
+
+watch(
+  dataList,
+  (value) => {
+    updateData({ dataList: value });
+  },
+  { deep: true }
+);
+</script>
+<style scoped>
+.data-list li:last-child {
+  padding-bottom: 0;
+  margin-bottom: 0;
+  border-bottom: 0;
+}
+</style>

+ 42 - 16
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -5,7 +5,6 @@
       <ui-textarea
         :model-value="data.description"
         :placeholder="t('common.description')"
-        autoresize
         class="w-full mb-2"
         @change="updateData({ description: $event })"
       />
@@ -26,26 +25,53 @@
         class="mb-1 w-full"
         @change="updateData({ selector: $event })"
       />
-      <template
+      <ui-expand
         v-if="!hideSelector && (data.findBy || 'cssSelector') === 'cssSelector'"
+        hide-header-icon
+        header-class="flex items-center w-full focus:ring-0"
       >
+        <template #header="{ show }">
+          <v-remixicon
+            name="riArrowLeftSLine"
+            :rotate="show ? 270 : 180"
+            class="mr-1 transition-transform -ml-1"
+          />
+          Selector options
+        </template>
+        <div class="mt-1">
+          <ui-checkbox
+            v-if="!data.disableMultiple && !hideMultiple"
+            :title="t('workflow.blocks.base.multiple.title')"
+            :model-value="data.multiple"
+            class="mr-6"
+            @change="updateData({ multiple: $event })"
+          >
+            {{ t('workflow.blocks.base.multiple.text') }}
+          </ui-checkbox>
+          <ui-checkbox
+            :model-value="data.markEl"
+            :title="t('workflow.blocks.base.markElement.title')"
+            @change="updateData({ markEl: $event })"
+          >
+            {{ t('workflow.blocks.base.markElement.text') }}
+          </ui-checkbox>
+        </div>
         <ui-checkbox
-          v-if="!data.disableMultiple && !hideMultiple"
-          :title="t('workflow.blocks.base.multiple.title')"
-          :model-value="data.multiple"
-          class="mr-6"
-          @change="updateData({ multiple: $event })"
+          :model-value="data.waitForSelector"
+          block
+          class="mt-1"
+          @change="updateData({ waitForSelector: $event })"
         >
-          {{ t('workflow.blocks.base.multiple.text') }}
+          {{ t('workflow.blocks.base.waitSelector.title') }}
         </ui-checkbox>
-        <ui-checkbox
-          :model-value="data.markEl"
-          :title="t('workflow.blocks.base.markElement.title')"
-          @change="updateData({ markEl: $event })"
-        >
-          {{ t('workflow.blocks.base.markElement.text') }}
-        </ui-checkbox>
-      </template>
+        <ui-input
+          v-if="data.waitForSelector"
+          :model-value="data.waitSelectorTimeout"
+          :label="t('workflow.blocks.base.waitSelector.timeout')"
+          class="mt-1 w-full"
+          @change="updateData({ waitSelectorTimeout: +$event })"
+        />
+      </ui-expand>
     </template>
     <slot></slot>
   </div>

+ 36 - 10
src/components/newtab/workflow/edit/EditJavascriptCode.vue

@@ -4,17 +4,20 @@
       :model-value="data.description"
       autoresize
       :placeholder="t('common.description')"
-      class="w-full mb-2"
+      class="w-full mb-1"
       @change="updateData({ description: $event })"
     />
     <ui-input
-      type="number"
       :model-value="data.timeout"
-      class="mb-2 w-full"
-      :placeholder="t('workflow.blocks.javascript-code.timeout.placeholder')"
+      :label="t('workflow.blocks.javascript-code.timeout.placeholder')"
       :title="t('workflow.blocks.javascript-code.timeout.title')"
+      type="number"
+      class="mb-2 w-full"
       @change="updateData({ timeout: +$event })"
     />
+    <p class="text-sm ml-1 text-gray-600 dark:text-gray-200">
+      {{ t('workflow.blocks.javascript-code.name') }}
+    </p>
     <pre
       v-if="!state.showCodeModal"
       class="rounded-lg overflow-auto text-gray-200 p-4 max-h-80 bg-gray-900"
@@ -44,10 +47,12 @@
             class="overflow-auto"
             style="height: 87%"
           />
-          <p class="mt-1">
+          <p class="mt-1 text-sm">
             {{ t('workflow.blocks.javascript-code.availabeFuncs') }}
           </p>
-          <p class="space-x-1">
+          <p
+            class="space-x-1 whitespace-nowrap overflow-x-auto overflow-y-hidden pb-1 scroll"
+          >
             <a
               v-for="func in availableFuncs"
               :key="func.id"
@@ -111,9 +116,13 @@ const emit = defineEmits(['update:data']);
 const { t } = useI18n();
 
 const availableFuncs = [
-  { name: 'automaNextBlock(data)', id: 'automanextblock-data' },
-  { name: 'automaRefData(keyword, path)', id: 'automarefdata-keyword-path' },
-  { name: 'automaResetTimeout', id: 'automaresettimeout' },
+  { name: 'automaNextBlock(data, insert?)', id: 'automanextblock-data' },
+  { name: 'automaRefData(keyword, path?)', id: 'automarefdata-keyword-path' },
+  {
+    name: 'automaSetVariable(name, value)',
+    id: 'automasetvariable-name-value',
+  },
+  { name: 'automaResetTimeout()', id: 'automaresettimeout' },
 ];
 
 const state = reactive({
@@ -159,7 +168,7 @@ function automaFuncsCompletion(context) {
           const container = document.createElement('div');
 
           container.innerHTML = `
-            <code>automaNextBlock(<i>data</i>)</code>
+            <code>automaNextBlock(<i>data</i>, <i>insert?</i>)</code>
             <p class="mt-2">
               Execute the next block
               <a href="https://docs.automa.site/blocks/javascript-code.html#automanextblock-data" target="_blank" class="underline">
@@ -171,6 +180,23 @@ function automaFuncsCompletion(context) {
           return container;
         },
       },
+      {
+        label: 'automaSetVariable',
+        type: 'function',
+        apply: snippet('automaSetVariable(${name}, ${value})'),
+        info: () => {
+          const container = document.createElement('div');
+
+          container.innerHTML = `
+            <code>automaRefData(<i>name</i>, <i>value</i>)</code>
+            <p class="mt-2">
+              Set the value of a variable
+            </p>
+          `;
+
+          return container;
+        },
+      },
       {
         label: 'automaRefData',
         type: 'function',

+ 40 - 16
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -3,20 +3,20 @@
     <ui-textarea
       :model-value="data.description"
       :placeholder="t('common.description')"
-      class="w-full"
+      class="w-full mb-1"
       @change="updateData({ description: $event })"
     />
     <ui-input
       :model-value="data.loopId"
-      class="w-full mb-3"
+      class="w-full mb-2"
       :label="t('workflow.blocks.loop-data.loopId')"
       :placeholder="t('workflow.blocks.loop-data.loopId')"
       @change="updateLoopID"
     />
     <ui-select
       :model-value="data.loopThrough"
-      :placeholder="t('workflow.blocks.loop-data.loopThrough.placeholder')"
-      class="w-full mb-2"
+      :label="t('workflow.blocks.loop-data.loopThrough.placeholder')"
+      class="w-full"
       @change="
         updateData({
           loopThrough: $event,
@@ -28,34 +28,51 @@
         {{ t(`workflow.blocks.loop-data.loopThrough.options.${type}`) }}
       </option>
     </ui-select>
+    <ui-input
+      v-if="data.loopThrough === 'google-sheets'"
+      :model-value="data.referenceKey"
+      :label="t('workflow.blocks.loop-data.refKey')"
+      placeholder="abc123"
+      class="w-full mt-2"
+      @change="updateData({ referenceKey: $event })"
+    />
+    <ui-input
+      v-else-if="data.loopThrough === 'variable'"
+      :model-value="data.variableName"
+      :label="t('workflow.variables.name')"
+      placeholder="abc123"
+      class="w-full mt-2"
+      @change="updateData({ variableName: $event })"
+    />
+    <ui-input
+      v-else-if="data.loopThrough === 'elements'"
+      :model-value="data.elementSelector"
+      :label="t('workflow.blocks.base.selector')"
+      placeholder=".selector"
+      class="mt-2 w-full"
+      @change="updateData({ elementSelector: $event })"
+    />
     <ui-input
       v-if="data.loopThrough !== 'numbers'"
       :model-value="data.maxLoop"
       :label="t('workflow.blocks.loop-data.maxLoop.label')"
       :title="t('workflow.blocks.loop-data.maxLoop.title')"
-      class="w-full mb-4"
+      class="w-full mt-2"
       min="0"
       type="number"
       @change="updateData({ maxLoop: +$event || 0 })"
     />
     <ui-button
-      v-if="data.loopThrough === 'custom-data'"
-      class="w-full"
+      v-else-if="data.loopThrough === 'custom-data'"
+      class="w-full mt-4"
       variant="accent"
       @click="state.showDataModal = true"
     >
       {{ t('workflow.blocks.loop-data.buttons.insert') }}
     </ui-button>
-    <ui-input
-      v-else-if="data.loopThrough === 'google-sheets'"
-      :model-value="data.referenceKey"
-      :label="t('workflow.blocks.loop-data.refKey')"
-      class="w-full"
-      @change="updateData({ referenceKey: $event })"
-    />
     <div
       v-else-if="data.loopThrough === 'numbers'"
-      class="flex items-center space-x-2"
+      class="flex items-center space-x-2 mt-2"
     >
       <ui-input
         :model-value="data.fromNumber"
@@ -146,7 +163,14 @@ const { t } = useI18n();
 const toast = useToast();
 
 const maxFileSize = 1024 * 1024;
-const loopTypes = ['data-columns', 'numbers', 'google-sheets', 'custom-data'];
+const loopTypes = [
+  'data-columns',
+  'numbers',
+  'google-sheets',
+  'variable',
+  'custom-data',
+  'elements',
+];
 
 const state = shallowReactive({
   showOptions: false,

+ 5 - 13
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="mb-2 mt-4 space-y-2">
+  <div>
     <ui-textarea
       :model-value="data.description"
       :placeholder="t('common.description')"
@@ -9,23 +9,14 @@
     <ui-input
       v-if="!data.activeTab"
       :model-value="data.url"
-      title="URL"
-      class="w-full"
+      :label="t('workflow.blocks.new-tab.url')"
+      class="w-full mt-2"
       placeholder="http://example.com/"
       @change="updateData({ url: $event })"
     />
-    <a
-      href="https://docs.automa.site/api-reference/reference-data.html"
-      rel="noopener"
-      class="text-primary inline-block text-sm"
-      target="_blank"
-      style="margin-top: 0"
-    >
-      {{ t('message.useDynamicData') }}
-    </a>
     <ui-checkbox
       :model-value="data.updatePrevTab"
-      class="leading-tight"
+      class="leading-tight mt-2"
       :title="t('workflow.blocks.new-tab.updatePrevTab.title')"
       @change="updateData({ updatePrevTab: $event })"
     >
@@ -33,6 +24,7 @@
     </ui-checkbox>
     <ui-checkbox
       :model-value="data.active"
+      class="my-2"
       @change="updateData({ active: $event })"
     >
       {{ t('workflow.blocks.new-tab.activeTab') }}

+ 14 - 8
src/components/newtab/workflow/edit/EditNewWindow.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="mb-2 mt-4 space-y-2">
+  <div class="mb-2 mt-4">
     <ui-textarea
       :model-value="data.description"
       class="w-full"
@@ -8,7 +8,7 @@
     />
     <ui-select
       :model-value="data.windowState"
-      class="w-full"
+      class="w-full mt-4"
       :placeholder="t('workflow.blocks.new-window.windowState.placeholder')"
       @change="updateData({ windowState: $event })"
     >
@@ -19,7 +19,7 @@
     <ui-checkbox
       :model-value="data.incognito"
       :disabled="!allowInIncognito"
-      class="mb-4"
+      class="mt-1"
       @change="updateData({ incognito: $event })"
     >
       {{ t('workflow.blocks.new-window.incognito.text') }}
@@ -27,8 +27,11 @@
         &#128712;
       </span>
     </ui-checkbox>
-    <template v-if="data.windowState === 'normal'">
-      <div class="flex items-center space-x-2">
+    <div v-if="data.windowState === 'normal'" class="mt-2">
+      <div
+        :title="t('workflow.blocks.new-window.position')"
+        class="flex items-center space-x-2 mb-1"
+      >
         <ui-input
           :model-value="data.top"
           :label="t('workflow.blocks.new-window.top')"
@@ -40,7 +43,10 @@
           @change="updateData({ left: +$event })"
         />
       </div>
-      <div class="flex items-center space-x-2">
+      <div
+        :title="t('workflow.blocks.new-window.size')"
+        class="flex items-center space-x-2"
+      >
         <ui-input
           :model-value="data.height"
           :label="t('workflow.blocks.new-window.height')"
@@ -52,10 +58,10 @@
           @change="updateData({ width: +$event })"
         />
       </div>
-      <p class="mt-4 text-gray-600">
+      <p class="mt-2 text-gray-600 dark:text-gray-200">
         {{ t('workflow.blocks.new-window.note') }}
       </p>
-    </template>
+    </div>
   </div>
 </template>
 <script setup>

+ 1 - 1
src/components/newtab/workflow/edit/EditProxy.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <div class="flex items-center mb-1">
+    <div class="flex items-center mb-2">
       <ui-select
         :model-value="data.scheme"
         label="Scheme"

+ 59 - 0
src/components/newtab/workflow/edit/EditSwitchTab.vue

@@ -0,0 +1,59 @@
+<template>
+  <div>
+    <ui-input
+      :model-value="data.matchPattern"
+      placeholder="https://example.com/*"
+      class="w-full"
+      @change="updateData({ matchPattern: $event })"
+    >
+      <template #label>
+        {{ t('workflow.blocks.switch-tab.matchPattern') }}
+        <a
+          :title="t('common.example', 2)"
+          href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#examples"
+          target="_blank"
+          rel="noopener"
+          class="inline-block ml-1"
+        >
+          <v-remixicon
+            name="riInformationLine"
+            class="inline-block"
+            size="18"
+          />
+        </a>
+      </template>
+    </ui-input>
+    <ui-checkbox
+      :model-value="data.createIfNoMatch"
+      class="mt-1"
+      @change="updateData({ createIfNoMatch: $event })"
+    >
+      {{ t('workflow.blocks.switch-tab.createIfNoMatch') }}
+    </ui-checkbox>
+    <ui-input
+      v-if="data.createIfNoMatch"
+      :model-value="data.url"
+      :label="t('workflow.blocks.switch-tab.url')"
+      placeholder="https://example.com"
+      class="w-full mt-2"
+      @change="updateData({ url: $event })"
+    />
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 46 - 39
src/components/newtab/workflow/edit/EditTakeScreenshot.vue

@@ -1,20 +1,32 @@
 <template>
+  <p class="text-sm text-gray-600 dark:text-gray-200 ml-2">Image quality</p>
+  <div class="bg-box-transparent px-4 mb-4 py-2 rounded-lg flex items-center">
+    <input
+      :value="data.quality"
+      :title="t('workflow.blocks.take-screenshot.imageQuality')"
+      class="focus:outline-none flex-1"
+      type="range"
+      min="0"
+      max="100"
+      @change="updateQuality"
+    />
+    <span class="w-12 text-right">{{ data.quality }}%</span>
+  </div>
   <div class="take-screenshot">
     <ui-checkbox
       :model-value="data.fullPage"
-      class="mb-2"
       @change="updateData({ fullPage: $event })"
     >
       {{ t('workflow.blocks.take-screenshot.fullPage') }}
     </ui-checkbox>
     <ui-checkbox
       :model-value="data.saveToComputer"
-      class="mb-2"
+      class="mt-4"
       @change="updateData({ saveToComputer: $event })"
     >
       {{ t('workflow.blocks.take-screenshot.saveToComputer') }}
     </ui-checkbox>
-    <div v-if="data.saveToComputer" class="flex items-center my-2">
+    <div v-if="data.saveToComputer" class="flex items-center mt-1">
       <ui-input
         :model-value="data.fileName"
         :placeholder="t('common.fileName')"
@@ -31,49 +43,44 @@
         <option value="jpeg">JPEG</option>
       </ui-select>
     </div>
-    <p class="text-sm text-gray-600 ml-2">Image quality:</p>
-    <div class="bg-box-transparent px-4 mb-2 py-2 rounded-lg flex items-center">
-      <input
-        :value="data.quality"
-        :title="t('workflow.blocks.take-screenshot.imageQuality')"
-        class="focus:outline-none flex-1"
-        type="range"
-        min="0"
-        max="100"
-        @change="updateQuality"
-      />
-      <span class="w-12 text-right">{{ data.quality }}%</span>
-    </div>
     <ui-checkbox
       :model-value="data.saveToColumn"
-      class="mt-3"
+      class="mt-4"
       @change="updateData({ saveToColumn: $event })"
     >
       {{ t('workflow.blocks.take-screenshot.saveToColumn') }}
     </ui-checkbox>
-    <div v-if="data.saveToColumn" class="flex items-center mt-1">
-      <ui-select
-        :model-value="data.dataColumn"
-        placeholder="Data column"
-        class="mr-2 flex-1"
-        @change="updateData({ dataColumn: $event })"
-      >
-        <option
-          v-for="column in workflow.data.value.dataColumns"
-          :key="column.name"
-          :value="column.name"
-        >
-          {{ column.name }}
-        </option>
-      </ui-select>
-      <ui-button
-        icon
-        title="Data columns"
-        @click="workflow.showDataColumnsModal(true)"
+    <ui-select
+      v-if="data.saveToColumn"
+      :model-value="data.dataColumn"
+      placeholder="Select column"
+      class="w-full mt-1"
+      @change="updateData({ dataColumn: $event })"
+    >
+      <option
+        v-for="column in workflow.data.value.table"
+        :key="column.name"
+        :value="column.name"
       >
-        <v-remixicon name="riKey2Line" />
-      </ui-button>
-    </div>
+        {{ column.name }}
+      </option>
+    </ui-select>
+    <ui-checkbox
+      :model-value="data.assignVariable"
+      block
+      class="mt-4"
+      @change="updateData({ assignVariable: $event })"
+    >
+      {{ t('workflow.variables.assign') }}
+    </ui-checkbox>
+    <ui-input
+      v-if="data.assignVariable"
+      :model-value="data.variableName"
+      :placeholder="t('workflow.variables.name')"
+      :title="t('workflow.variables.name')"
+      class="mt-1 w-full"
+      @change="updateData({ variableName: $event })"
+    />
   </div>
 </template>
 <script setup>

+ 39 - 54
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -18,31 +18,42 @@
       </option>
     </ui-select>
     <transition-expand mode="out-in">
-      <div v-if="data.type === 'interval'" class="flex items-center mt-1">
-        <ui-input
-          :model-value="data.interval"
-          :label="t('workflow.blocks.trigger.forms.interval')"
-          type="number"
-          class="w-full mr-2"
-          placeholder="1-120"
-          min="1"
-          max="120"
-          @change="
-            updateIntervalInput($event, { key: 'interval', min: 1, max: 120 })
-          "
-        />
-        <ui-input
-          :model-value="data.delay"
-          type="number"
-          class="w-full"
-          :label="t('workflow.blocks.trigger.forms.delay')"
-          min="0"
-          max="20"
-          placeholder="0-20"
-          @change="
-            updateIntervalInput($event, { key: 'delay', min: 0, max: 20 })
-          "
-        />
+      <div v-if="data.type === 'interval'">
+        <div class="flex items-center mt-1">
+          <ui-input
+            :model-value="data.interval"
+            :label="t('workflow.blocks.trigger.forms.interval')"
+            type="number"
+            class="w-full"
+            placeholder="1-120"
+            min="1"
+            max="120"
+            @change="
+              updateIntervalInput($event, { key: 'interval', min: 1, max: 120 })
+            "
+          />
+          <ui-input
+            v-if="!data.fixedDelay"
+            :model-value="data.delay"
+            type="number"
+            class="w-full ml-2"
+            :label="t('workflow.blocks.trigger.forms.delay')"
+            min="0"
+            max="20"
+            placeholder="0-20"
+            @change="
+              updateIntervalInput($event, { key: 'delay', min: 0, max: 20 })
+            "
+          />
+        </div>
+        <ui-checkbox
+          :model-value="data.fixedDelay"
+          block
+          class="mt-2"
+          @change="updateData({ fixedDelay: $event })"
+        >
+          {{ t('workflow.blocks.trigger.fixedDelay') }}
+        </ui-checkbox>
       </div>
       <div v-else-if="data.type === 'date'" class="mt-2">
         <ui-input
@@ -202,6 +213,7 @@ import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import dayjs from 'dayjs';
 import { isObject } from '@/utils/helper';
+import recordShortcut from '@/utils/record-shortcut';
 
 const props = defineProps({
   data: {
@@ -234,17 +246,6 @@ const days = {
 };
 const maxDate = dayjs().add(30, 'day').format('YYYY-MM-DD');
 const minDate = dayjs().format('YYYY-MM-DD');
-const allowedKeys = {
-  '+': 'plus',
-  Delete: 'del',
-  Insert: 'ins',
-  ArrowDown: 'down',
-  ArrowLeft: 'left',
-  ArrowUp: 'up',
-  ArrowRight: 'right',
-  Escape: 'escape',
-  Enter: 'enter',
-};
 
 const recordKeys = reactive({
   isRecording: false,
@@ -312,26 +313,10 @@ function addTime() {
   });
 }
 function handleKeydownEvent(event) {
-  event.preventDefault();
-  event.stopPropagation();
-
-  if (event.repeat) return;
-
-  const keys = [];
-  const { ctrlKey, altKey, metaKey, shiftKey, key } = event;
-
-  if (ctrlKey || metaKey) keys.push('mod');
-  if (altKey) keys.push('option');
-  if (shiftKey) keys.push('shift');
-
-  const isValidKey = !!allowedKeys[key] || /^[a-z0-9,./;'[\]\-=`]$/i.test(key);
-
-  if (isValidKey) {
-    keys.push(allowedKeys[key] || key.toLowerCase());
-
+  recordShortcut(event, (keys) => {
     recordKeys.keys = keys.join('+');
     updateData({ shortcut: recordKeys.keys });
-  }
+  });
 }
 function toggleRecordKeys() {
   if (recordKeys.isRecording) {

+ 3 - 2
src/components/newtab/workflow/edit/EditTriggerEvent.vue

@@ -3,7 +3,7 @@
     <ui-select
       :model-value="data.eventName"
       :placeholder="t('workflow.blocks.trigger-event.selectEvent')"
-      class="w-full mt-2"
+      class="w-full mt-4"
       @change="handleSelectChange"
     >
       <option v-for="event in eventList" :key="event.id" :value="event.id">
@@ -11,7 +11,7 @@
       </option>
     </ui-select>
     <button
-      class="mb-2 block flex items-center w-full text-left mt-4 focus:ring-0"
+      class="mb-2 block flex items-center w-full text-left mt-2 focus:ring-0"
       @click="showOptions = !showOptions"
     >
       <v-remixicon
@@ -21,6 +21,7 @@
       />
       <span class="flex-1">{{ t('common.options') }}</span>
       <a
+        v-if="data.eventName"
         :href="getEventDetailsUrl()"
         rel="noopener"
         target="_blank"

+ 96 - 10
src/components/newtab/workflow/edit/EditWebhook.vue

@@ -6,18 +6,28 @@
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
+    <ui-select
+      :model-value="data.method || 'POST'"
+      :label="t('workflow.blocks.webhook.method')"
+      class="mb-2 w-full"
+      @change="updateMethod"
+    >
+      <option v-for="method in methods" :key="method" :value="method">
+        {{ method }}
+      </option>
+    </ui-select>
     <ui-input
       :model-value="data.url"
+      :label="`${t('workflow.blocks.webhook.url')}*`"
+      placeholder="http://api.example.com"
       class="mb-2 w-full"
-      placeholder="https://example.com/postreceive"
       required
-      :title="t('workflow.blocks.webhook.url')"
       type="url"
       @change="updateData({ url: $event })"
     />
     <ui-select
       :model-value="data.contentType"
-      :placeholder="t('workflow.blocks.webhook.contentType')"
+      :label="t('workflow.blocks.webhook.contentType')"
       class="mb-2 w-full"
       @change="updateData({ contentType: $event })"
     >
@@ -31,32 +41,39 @@
     </ui-select>
     <ui-input
       :model-value="data.timeout"
-      :placeholder="t('workflow.blocks.webhook.timeout.placeholder')"
+      :label="t('workflow.blocks.webhook.timeout.placeholder')"
       :title="t('workflow.blocks.webhook.timeout.title')"
       class="mb-2 w-full"
       type="number"
       @change="updateData({ timeout: +$event })"
     />
-    <ui-tabs v-model="activeTab" fill class="mb-4">
+    <ui-tabs v-model="activeTab" fill>
       <ui-tab value="headers">
         {{ t('workflow.blocks.webhook.tabs.headers') }}
       </ui-tab>
-      <ui-tab value="body">{{ t('workflow.blocks.webhook.tabs.body') }}</ui-tab>
+      <ui-tab v-if="!notHaveBody.includes(data.method)" value="body">
+        {{ t('workflow.blocks.webhook.tabs.body') }}
+      </ui-tab>
+      <ui-tab value="response">
+        {{ t('workflow.blocks.webhook.tabs.response') }}
+      </ui-tab>
     </ui-tabs>
-    <ui-tab-panels :model-value="activeTab">
+    <ui-tab-panels v-model="activeTab">
       <ui-tab-panel
         value="headers"
-        class="grid grid-cols-7 justify-items-center gap-2"
+        class="grid grid-cols-7 justify-items-center mt-4 gap-2"
       >
         <template v-for="(items, index) in headers" :key="index">
           <ui-input
             v-model="items.name"
+            :title="items.name"
             :placeholder="`Header ${index + 1}`"
             type="text"
             class="col-span-3"
           />
           <ui-input
             v-model="items.value"
+            :title="items.value"
             placeholder="Value"
             type="text"
             class="col-span-3"
@@ -73,7 +90,7 @@
           <span> {{ t('workflow.blocks.webhook.buttons.header') }} </span>
         </ui-button>
       </ui-tab-panel>
-      <ui-tab-panel value="body">
+      <ui-tab-panel value="body" class="mt-4">
         <pre
           v-if="!showBodyModal"
           class="rounded-lg text-gray-200 p-4 max-h-80 bg-gray-900 overflow-auto"
@@ -81,6 +98,64 @@
           v-text="data.body"
         />
       </ui-tab-panel>
+      <ui-tab-panel value="response" class="mt-2">
+        <ui-select
+          :model-value="data.responseType"
+          label="Response type"
+          class="w-full"
+          @change="updateData({ responseType: $event })"
+        >
+          <option value="json">JSON</option>
+          <option value="text">Text</option>
+        </ui-select>
+        <ui-input
+          v-if="data.responseType === 'json'"
+          :model-value="data.dataPath"
+          placeholder="path.to.data"
+          label="Data path"
+          class="w-full mt-2"
+          @change="updateData({ dataPath: $event })"
+        />
+        <ui-checkbox
+          :model-value="data.assignVariable"
+          block
+          class="mt-2"
+          @change="updateData({ assignVariable: $event })"
+        >
+          {{ t('workflow.variables.assign') }}
+        </ui-checkbox>
+        <ui-input
+          v-if="data.assignVariable"
+          :model-value="data.variableName"
+          :placeholder="t('workflow.variables.name')"
+          :title="t('workflow.variables.name')"
+          class="mt-2 w-full mb-2"
+          @change="updateData({ variableName: $event })"
+        />
+        <ui-checkbox
+          :model-value="data.saveData"
+          block
+          class="mt-2"
+          @change="updateData({ saveData: $event })"
+        >
+          {{ t('workflow.blocks.get-text.checkbox') }}
+        </ui-checkbox>
+        <ui-select
+          v-if="data.saveData"
+          :model-value="data.dataColumn"
+          placeholder="Select column"
+          class="mt-2 w-full"
+          @change="updateData({ dataColumn: $event })"
+        >
+          <option
+            v-for="column in workflow.data.value.table"
+            :key="column.name"
+            :value="column.name"
+          >
+            {{ column.name }}
+          </option>
+        </ui-select>
+      </ui-tab-panel>
     </ui-tab-panels>
     <ui-modal
       v-model="showBodyModal"
@@ -107,7 +182,7 @@
   </div>
 </template>
 <script setup>
-import { ref, watch, defineAsyncComponent } from 'vue';
+import { ref, watch, defineAsyncComponent, inject } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { contentTypes } from '@/utils/shared';
 
@@ -125,6 +200,10 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
+const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
+const notHaveBody = ['GET', 'DELETE'];
+
+const workflow = inject('workflow');
 const activeTab = ref('headers');
 const showBodyModal = ref(false);
 const headers = ref(JSON.parse(JSON.stringify(props.data.headers)));
@@ -138,6 +217,13 @@ function removeHeader(index) {
 function addHeader() {
   headers.value.push({ name: '', value: '' });
 }
+function updateMethod(method) {
+  if (notHaveBody.includes(method) && activeTab.value === 'body') {
+    activeTab.value = 'headers';
+  }
+
+  updateData({ method });
+}
 
 watch(
   headers,

+ 1 - 1
src/components/newtab/workflow/edit/TriggerEventKeyboard.vue

@@ -42,7 +42,7 @@ const defaultParams = shallowReactive({
   code: '',
   key: '',
   keyCode: 0,
-  repat: false,
+  repeat: false,
 });
 
 watch(

+ 7 - 3
src/components/ui/UiButton.vue

@@ -22,7 +22,11 @@
     </span>
     <div v-if="loading" class="button-loading">
       <ui-spinner
-        :color="variant === 'default' ? 'text-primary' : 'text-white'"
+        :color="
+          variant === 'default'
+            ? 'text-primary'
+            : 'text-white dark:text-gray-900'
+        "
       ></ui-spinner>
     </div>
   </component>
@@ -54,11 +58,11 @@ export default {
     const variants = {
       default: 'bg-input',
       accent:
-        'bg-accent hover:bg-gray-700 dark:bg-gray-200 dark:text-gray-900 text-white',
+        'bg-accent hover:bg-gray-700 dark:bg-gray-100 dark:hover:bg-gray-200 dark:text-black text-white',
       primary:
         'bg-primary text-white dark:bg-secondary dark:hover:bg-primary hover:bg-secondary',
       danger:
-        'bg-red-500 text-white dark:bg-red-600 dark:hover:bg-red-500 hover:bg-red-400',
+        'bg-red-400 text-white dark:bg-red-500 dark:hover:bg-red-500 hover:bg-red-400',
     };
 
     return {

+ 10 - 3
src/components/ui/UiCheckbox.vue

@@ -1,5 +1,8 @@
 <template>
-  <label class="checkbox-ui inline-flex items-center">
+  <label
+    class="checkbox-ui items-center"
+    :class="[block ? 'flex' : 'inline-flex']"
+  >
     <div
       :class="{ 'pointer-events-none opacity-75': disabled }"
       class="relative h-5 w-5 inline-block focus-within:ring-2 focus-within:ring-accent rounded"
@@ -12,12 +15,12 @@
         @change="changeHandler"
       />
       <div
-        class="border rounded absolute top-0 left-0 bg-input checkbox-ui__mark cursor-pointer"
+        class="border dark:border-gray-700 rounded absolute top-0 left-0 bg-input checkbox-ui__mark cursor-pointer"
       >
         <v-remixicon
           name="riCheckLine"
           size="20"
-          class="text-white"
+          class="text-white dark:text-black"
         ></v-remixicon>
       </div>
     </div>
@@ -37,6 +40,10 @@ export default {
       type: Boolean,
       default: null,
     },
+    block: {
+      type: Boolean,
+      default: null,
+    },
   },
   emits: ['update:modelValue', 'change'],
   setup(props, { emit }) {

+ 93 - 60
src/components/ui/UiDialog.vue

@@ -7,47 +7,58 @@
     <template #header>
       <h3 class="font-semibold">{{ state.options.title }}</h3>
     </template>
-    <p class="text-gray-600 dark:text-gray-200 leading-tight">
-      {{ state.options.body }}
-    </p>
-    <ui-input
-      v-if="state.type === 'prompt'"
-      v-model="state.input"
-      autofocus
-      :placeholder="state.options.placeholder"
-      :label="state.options.label"
-      :type="
-        state.options.inputType === 'password' && state.showPassword
-          ? 'text'
-          : state.options.inputType
-      "
-      class="w-full"
-    >
-      <template v-if="state.options.inputType === 'password'" #append>
-        <v-remixicon
-          :name="state.showPassword ? 'riEyeOffLine' : 'riEyeLine'"
-          class="absolute right-2"
-          @click="state.showPassword = !state.showPassword"
-        />
-      </template>
-    </ui-input>
-    <div class="mt-8 flex space-x-2">
-      <ui-button class="w-6/12" @click="fireCallback('onCancel')">
-        {{ state.options.cancelText }}
-      </ui-button>
-      <ui-button
-        class="w-6/12"
-        :variant="state.options.okVariant"
-        @click="fireCallback('onConfirm')"
+    <slot
+      v-if="state.options.custom"
+      v-bind="{ options: state.options }"
+      :name="state.type"
+    />
+    <template v-else>
+      <p class="text-gray-600 dark:text-gray-200 leading-tight">
+        {{ state.options.body }}
+      </p>
+      <ui-input
+        v-if="state.type === 'prompt'"
+        v-model="state.input"
+        autofocus
+        :disabled="state.loading"
+        :placeholder="state.options.placeholder"
+        :label="state.options.label"
+        :type="
+          state.options.inputType === 'password' && state.showPassword
+            ? 'text'
+            : state.options.inputType
+        "
+        class="w-full"
       >
-        {{ state.options.okText }}
-      </ui-button>
-    </div>
+        <template v-if="state.options.inputType === 'password'" #append>
+          <v-remixicon
+            :name="state.showPassword ? 'riEyeOffLine' : 'riEyeLine'"
+            class="absolute right-2"
+            @click="state.showPassword = !state.showPassword"
+          />
+        </template>
+      </ui-input>
+      <div class="mt-8 flex space-x-2">
+        <ui-button class="w-6/12" @click="fireCallback('onCancel')">
+          {{ state.options.cancelText }}
+        </ui-button>
+        <ui-button
+          class="w-6/12"
+          :loading="state.loading"
+          :variant="state.options.okVariant"
+          @click="fireCallback('onConfirm')"
+        >
+          {{ state.options.okText }}
+        </ui-button>
+      </div>
+    </template>
   </ui-modal>
 </template>
 <script>
-import { reactive, watch } from 'vue';
+import { reactive, watch, onUnmounted } from 'vue';
 import { useI18n } from 'vue-i18n';
+import defu from 'defu';
+import { throttle } from '@/utils/helper';
 import emitter from '@/lib/mitt';
 
 export default {
@@ -55,55 +66,71 @@ export default {
     const { t } = useI18n();
 
     const defaultOptions = {
-      html: false,
       body: '',
       title: '',
-      placeholder: '',
       label: '',
+      html: false,
+      onCancel: null,
+      onConfirm: null,
+      placeholder: '',
       inputType: 'text',
-      okText: t('common.confirm'),
+      showLoading: false,
       okVariant: 'accent',
+      okText: t('common.confirm'),
       cancelText: t('common.cancel'),
-      onConfirm: null,
-      onCancel: null,
     };
     const state = reactive({
-      show: false,
       type: '',
       input: '',
+      show: false,
+      loading: false,
       showPassword: false,
       options: defaultOptions,
     });
 
-    emitter.on('show-dialog', ({ type, options }) => {
+    function handleShowDialog({ type, options }) {
       state.type = type;
       state.input = options?.inputValue ?? '';
-      state.options = {
-        ...defaultOptions,
-        ...options,
-      };
+      state.options = defu(options, defaultOptions);
 
       state.show = true;
-    });
-
-    function fireCallback(type) {
+    }
+    function destroy() {
+      state.input = '';
+      state.show = false;
+      state.showPassword = false;
+      state.options = defaultOptions;
+    }
+    const fireCallback = throttle((type) => {
       const callback = state.options[type];
       const param = state.type === 'prompt' ? state.input : true;
-      let hide = true;
 
       if (callback) {
-        const cbReturn = callback(param);
+        const isAsync = state.options.async;
+        if (isAsync) state.loading = true;
 
-        if (typeof cbReturn === 'boolean') hide = cbReturn;
-      }
+        const cbReturn = callback(param) ?? true;
+
+        if (typeof cbReturn === 'boolean') {
+          if (cbReturn) destroy();
+          state.loading = false;
+
+          return;
+        }
+        if (isAsync && cbReturn?.then) {
+          cbReturn.then((value) => {
+            if (value) destroy();
+            state.loading = false;
+          });
 
-      if (hide) {
-        state.options = defaultOptions;
-        state.showPassword = false;
-        state.show = false;
-        state.input = '';
+          return;
+        }
+
+        destroy();
+      } else {
+        destroy();
       }
-    }
+    }, 200);
     function keyupHandler({ code }) {
       if (code === 'Enter') {
         fireCallback('onConfirm');
@@ -123,6 +150,12 @@ export default {
       }
     );
 
+    emitter.on('show-dialog', handleShowDialog);
+
+    onUnmounted(() => {
+      emitter.off('show-dialog', handleShowDialog);
+    });
+
     return {
       state,
       fireCallback,

+ 6 - 1
src/components/ui/UiExpand.vue

@@ -2,11 +2,12 @@
   <div :aria-expanded="show" class="ui-expand">
     <button :class="headerClass" @click="toggleExpand">
       <v-remixicon
+        v-if="!hideHeaderIcon"
         :rotate="show ? 90 : -90"
         name="riArrowLeftSLine"
         class="mr-2 transition-transform -ml-1"
       />
-      <slot name="header" />
+      <slot v-bind="{ show }" name="header" />
     </button>
     <transition-expand>
       <div v-if="show" :class="panelClass" class="ui-expand__panel">
@@ -31,6 +32,10 @@ const props = defineProps({
     type: String,
     default: 'px-4 py-2 w-full flex items-center h-full',
   },
+  hideHeaderIcon: {
+    type: Boolean,
+    default: false,
+  },
 });
 const emit = defineEmits(['update:modelValue']);
 

+ 6 - 4
src/components/ui/UiInput.vue

@@ -1,9 +1,11 @@
 <template>
   <div class="inline-block input-ui">
-    <label v-if="label || $slots.label" :for="componentId">
-      <span class="text-sm dark:text-gray-200 text-gray-600 mb-1 ml-1">
-        <slot name="label">{{ label }}</slot>
-      </span>
+    <label
+      v-if="label || $slots.label"
+      :for="componentId"
+      class="text-sm dark:text-gray-200 text-gray-600 ml-1 inline-block leading-none"
+    >
+      <slot name="label">{{ label }}</slot>
     </label>
     <div class="flex items-center relative w-full">
       <slot name="prepend">

+ 2 - 2
src/components/ui/UiModal.vue

@@ -7,7 +7,7 @@
       <transition name="modal" mode="out-in">
         <div
           v-if="show"
-          class="bg-black p-5 overflow-y-auto bg-opacity-20 modal-ui__content-container z-50 flex justify-center items-end md:items-center"
+          class="bg-black p-5 overflow-y-auto bg-opacity-20 dark:bg-opacity-60 modal-ui__content-container z-50 flex justify-center items-end md:items-center"
           :style="{ 'backdrop-filter': blur && 'blur(2px)' }"
           @click.self="closeModal"
         >
@@ -24,7 +24,7 @@
                 </span>
                 <v-remixicon
                   v-show="!persist"
-                  class="text-gray-600 cursor-pointer"
+                  class="text-gray-600 dark:text-gray-300 cursor-pointer"
                   name="riCloseLine"
                   size="20"
                   @click="closeModal"

+ 6 - 1
src/components/ui/UiPopover.vue

@@ -9,7 +9,7 @@
     </div>
     <div
       ref="content"
-      class="ui-popover__content bg-white dark:bg-gray-800 rounded-lg shadow-xl border"
+      class="ui-popover__content bg-white dark:bg-gray-800 rounded-lg shadow-xl border dark:border-gray-700"
       :class="[padding]"
     >
       <slot v-bind="{ isShow }"></slot>
@@ -130,6 +130,11 @@ export default {
         onTrigger: () => emit('trigger'),
         ...props.options,
       });
+
+      if (props.disabled) {
+        instance.value.hide();
+        instance.value.disable();
+      }
     });
     onUnmounted(() => {
       instance.value.destroy();

+ 1 - 0
src/components/ui/UiSwitch.vue

@@ -71,6 +71,7 @@ export default {
 }
 
 .ui-switch input:checked ~ .ui-switch__ball {
+  @apply dark:bg-gray-900;
   background-color: white;
   left: calc(100% - 21px);
 }

+ 31 - 6
src/components/ui/UiTab.vue

@@ -2,15 +2,16 @@
   <button
     :aria-selected="uiTabs.modelValue.value === value"
     :class="[
-      uiTabs.small.value ? 'p-2' : 'py-3 px-2',
-      uiTabs.modelValue.value === value
-        ? 'border-accent text-gray-800 dark:text-white'
-        : 'border-transparent',
-      { 'flex-1': uiTabs.fill.value },
+      uiTabs.type.value,
+      {
+        small: uiTabs.small.value,
+        'flex-1': uiTabs.fill.value,
+        'is-active': uiTabs.modelValue.value === value,
+      },
     ]"
     :tabIndex="uiTabs.modelValue.value === value ? 0 : -1"
     aria-role="tab"
-    class="border-b-2 transition-colors z-[1] ui-tab focus:ring-0"
+    class="transition-colors z-[1] ui-tab focus:ring-0 ui-tab"
     @mouseenter="uiTabs.hoverHandler"
     @click="uiTabs.updateActive(value)"
   >
@@ -30,3 +31,27 @@ const props = defineProps({
 
 const uiTabs = inject('ui-tabs', {});
 </script>
+<style scoped>
+.ui-tab {
+  z-index: 1;
+  @apply py-3 px-2 border-b-2 border-transparent;
+}
+.ui-tab.small {
+  @apply p-2;
+}
+.ui-tab.fill {
+  @apply rounded-lg border-b-0 px-4 py-2;
+}
+.ui-tab.fill.small {
+  @apply p-2;
+}
+.ui-tab.is-active {
+  @apply border-accent dark:border-gray-100 text-gray-800 dark:text-white;
+}
+.ui-tab.is-active.fill {
+  @apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
+}
+.ui-tab.is-active {
+  @apply border-accent dark:border-gray-100 text-gray-800 dark:text-white;
+}
+</style>

+ 58 - 36
src/components/ui/UiTabs.vue

@@ -1,58 +1,80 @@
 <template>
   <div
+    :class="[
+      tabTypes[type] || tabTypes['default'],
+      { [color]: type === 'fill' },
+    ]"
     aria-role="tablist"
-    class="ui-tabs text-gray-600 dark:text-gray-200 border-b flex space-x-1 items-center relative"
+    class="ui-tabs text-gray-600 dark:text-gray-200 flex space-x-1 items-center relative"
     @mouseleave="showHoverIndicator = false"
   >
     <div
       v-show="showHoverIndicator"
       ref="hoverIndicator"
-      class="ui-tabs__indicator z-0 top-[5px] absolute left-0 rounded-lg bg-box-transparent"
+      class="ui-tabs__indicator z-0 absolute left-0 rounded-lg bg-box-transparent"
+      style="top: 50%; transform: translate(0, -50%)"
     ></div>
     <slot></slot>
   </div>
 </template>
-<script>
+<script setup>
 import { provide, toRefs, ref } from 'vue';
 
-export default {
-  props: {
-    modelValue: {
-      type: [String, Number],
-      default: '',
-    },
-    small: Boolean,
-    fill: Boolean,
+const props = defineProps({
+  modelValue: {
+    type: [String, Number],
+    default: '',
   },
-  emits: ['update:modelValue'],
-  setup(props, { emit }) {
-    const hoverIndicator = ref(null);
-    const showHoverIndicator = ref(false);
+  type: {
+    type: String,
+    default: 'default',
+    validator: (value) => ['default', 'fill'].includes(value),
+  },
+  color: {
+    type: String,
+    default: 'bg-box-transparent',
+  },
+  small: Boolean,
+  fill: Boolean,
+});
+const emit = defineEmits(['update:modelValue', 'change']);
 
-    function updateActive(id) {
-      emit('update:modelValue', id);
-    }
-    function hoverHandler({ target }) {
-      const { height, width } = target.getBoundingClientRect();
+const tabTypes = {
+  default: 'border-b',
+  fill: 'p-2 rounded-lg',
+};
 
-      showHoverIndicator.value = true;
-      hoverIndicator.value.style.width = `${width}px`;
-      hoverIndicator.value.style.height = `${height - 11}px`;
-      hoverIndicator.value.style.transform = `translateX(${target.offsetLeft}px)`;
-    }
+const hoverIndicator = ref(null);
+const showHoverIndicator = ref(false);
 
-    provide('ui-tabs', {
-      updateActive,
-      hoverHandler,
-      ...toRefs(props),
-    });
+function updateActive(id) {
+  emit('change', id);
+  emit('update:modelValue', id);
+}
+function hoverHandler({ target }) {
+  const isFill = props.type === 'fill';
 
-    return {
-      hoverIndicator,
-      showHoverIndicator,
-    };
-  },
-};
+  if (target.classList.contains('is-active') && isFill) {
+    hoverIndicator.value.style.display = 'none';
+
+    return;
+  }
+
+  const { height, width } = target.getBoundingClientRect();
+  const elHeight = isFill ? height + 3 : height - 11;
+
+  showHoverIndicator.value = true;
+  hoverIndicator.value.style.width = `${width}px`;
+  hoverIndicator.value.style.height = `${elHeight}px`;
+  hoverIndicator.value.style.display = 'inline-block';
+  hoverIndicator.value.style.transform = `translate(${target.offsetLeft}px, -50%)`;
+}
+
+provide('ui-tabs', {
+  updateActive,
+  hoverHandler,
+  ...toRefs(props),
+});
 </script>
 <style>
 .ui-tabs__indicator {

+ 17 - 15
src/components/ui/UiTextarea.vue

@@ -1,20 +1,17 @@
 <template>
-  <label :class="[block ? 'block' : 'inline-block']">
-    <span v-if="label" class="text-gray-500 text-sm ml-2 block">{{
-      label
-    }}</span>
-    <textarea
-      ref="textarea"
-      v-bind="{ value: modelValue, placeholder, maxlength: max }"
-      class="ui-textarea w-full ui-input rounded-lg px-4 py-2 transition bg-input"
-      :class="{ 'overflow-hidden resize-none': autoresize }"
-      :style="{ height }"
-      @input="emitValue"
-    ></textarea>
-  </label>
+  <textarea
+    v-bind="{ value: modelValue, placeholder, maxlength: max }"
+    :id="textareaId"
+    ref="textarea"
+    class="ui-textarea w-full ui-input rounded-lg px-4 py-2 transition bg-input"
+    :class="{ 'overflow-hidden resize-none': autoresize }"
+    :style="{ height }"
+    @input="emitValue"
+  ></textarea>
 </template>
 <script>
 import { ref, onMounted } from 'vue';
+import { useComponentId } from '@/composable/componentId';
 
 export default {
   props: {
@@ -38,11 +35,15 @@ export default {
       type: [Number, String],
       default: '',
     },
-    max: [Number, String],
+    max: {
+      type: [Number, String],
+      default: null,
+    },
     block: Boolean,
   },
   emits: ['update:modelValue', 'change'],
   setup(props, { emit }) {
+    const textareaId = useComponentId('textarea');
     const textarea = ref(null);
 
     function calcHeight() {
@@ -53,7 +54,7 @@ export default {
     }
     function emitValue(event) {
       let { value } = event.target;
-      const maxLength = Math.abs(props.max);
+      const maxLength = Math.abs(props.max) || Infinity;
 
       if (value.length > maxLength) {
         value = value.slice(0, maxLength);
@@ -69,6 +70,7 @@ export default {
     return {
       textarea,
       emitValue,
+      textareaId,
     };
   },
 };

+ 12 - 5
src/composable/dialog.js

@@ -1,15 +1,22 @@
 import emitter from '@/lib/mitt';
 
 export function useDialog() {
-  function confirm(options) {
-    emitter.emit('show-dialog', { type: 'confirm', options });
-  }
+  const emitDialog = (type, options = {}) => {
+    emitter.emit('show-dialog', { type, options });
+  };
 
-  function prompt(options) {
-    emitter.emit('show-dialog', { type: 'prompt', options });
+  function confirm(options = {}) {
+    emitDialog('confirm', options);
+  }
+  function prompt(options = {}) {
+    emitDialog('prompt', options);
+  }
+  function custom(type, options = {}) {
+    emitDialog(type, { ...options, custom: true });
   }
 
   return {
+    custom,
     prompt,
     confirm,
   };

+ 12 - 6
src/composable/groupTooltip.js

@@ -1,20 +1,19 @@
-import { getCurrentInstance, onMounted, shallowRef } from 'vue';
+import { getCurrentInstance, shallowRef, nextTick, onUnmounted } from 'vue';
 import { createSingleton } from 'tippy.js';
 import createTippy, { defaultOptions } from '@/lib/tippy';
 
 export function useGroupTooltip(elements, options = {}) {
   const singleton = shallowRef(null);
+  const instance = getCurrentInstance();
+  const context = instance && instance.ctx;
 
-  onMounted(() => {
+  nextTick(() => {
     let tippyInstances = [];
 
     if (Array.isArray(elements)) {
       tippyInstances = elements.map((el) => el._tippy || createTippy(el));
     } else {
-      const instance = getCurrentInstance();
-      const ctx = instance && instance.ctx;
-
-      tippyInstances = ctx._tooltipGroup || [];
+      tippyInstances = context._tooltipGroup || [];
     }
 
     singleton.value = createSingleton(tippyInstances, {
@@ -25,6 +24,13 @@ export function useGroupTooltip(elements, options = {}) {
       moveTransition: 'transform 0.2s ease-out',
       overrides: ['placement', 'theme'],
     });
+
+    if (!elements) {
+      context.__tpSingleton = singleton.value;
+    }
+  });
+  onUnmounted(() => {
+    singleton.value?.destroy();
   });
 
   return singleton;

+ 7 - 3
src/composable/shortcut.js

@@ -1,8 +1,9 @@
 import { onUnmounted, onMounted } from 'vue';
+import defu from 'defu';
 import Mousetrap from 'mousetrap';
-import { isObject } from '@/utils/helper';
+import { isObject, parseJSON } from '@/utils/helper';
 
-export const mapShortcuts = {
+const defaultShortcut = {
   'page:dashboard': {
     id: 'page:dashboard',
     combo: 'option+1',
@@ -48,6 +49,9 @@ export const mapShortcuts = {
     combo: 'mod+[',
   },
 };
+const customShortcut = parseJSON(localStorage.getItem('shortcuts', {})) || {};
+
+export const mapShortcuts = defu(customShortcut, defaultShortcut);
 
 const os = navigator.appVersion.indexOf('Win') !== -1 ? 'win' : 'mac';
 export function getReadableShortcut(str) {
@@ -61,7 +65,7 @@ export function getReadableShortcut(str) {
       mac: '⌘',
     },
   };
-  const regex = new RegExp(Object.keys(list).join('|'), 'g');
+  const regex = new RegExp('option|mod', 'g');
   const replacedStr = str.replace(regex, (match) => {
     return list[match][os];
   });

+ 53 - 0
src/composable/theme.js

@@ -0,0 +1,53 @@
+import { ref, onMounted } from 'vue';
+import browser from 'webextension-polyfill';
+
+const themes = [
+  { name: 'Light', id: 'light' },
+  { name: 'Dark', id: 'dark' },
+  { name: 'System', id: 'system' },
+];
+const isPreferDark = () =>
+  window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+export function useTheme() {
+  const activeTheme = ref('system');
+
+  async function setTheme(theme) {
+    const isValidTheme = themes.some(({ id }) => id === theme);
+
+    if (!isValidTheme) return;
+
+    let isDarkTheme = theme === 'dark';
+
+    if (theme === 'system') isDarkTheme = isPreferDark();
+
+    document.documentElement.classList.toggle('dark', isDarkTheme);
+    activeTheme.value = theme;
+
+    await browser.storage.local.set({ theme });
+  }
+  async function getTheme() {
+    let { theme } = await browser.storage.local.get('theme');
+
+    if (!theme) theme = 'system';
+
+    return theme;
+  }
+  async function init() {
+    const theme = await getTheme();
+
+    await setTheme(theme);
+  }
+
+  onMounted(async () => {
+    activeTheme.value = await getTheme();
+  });
+
+  return {
+    init,
+    themes,
+    activeTheme,
+    set: setTheme,
+    get: getTheme,
+  };
+}

+ 9 - 6
src/content/blocks-handler/handler-attribute-value.js

@@ -1,4 +1,4 @@
-import { handleElement } from '../helper';
+import handleSelector from '../handle-selector';
 
 function attributeValue(block) {
   return new Promise((resolve, reject) => {
@@ -10,12 +10,15 @@ function attributeValue(block) {
       return ['checkbox', 'radio'].includes(element.getAttribute('type'));
     };
 
-    handleElement(block, {
+    handleSelector(block, {
       onSelected(element) {
-        const value =
-          attributeName === 'checked' && isCheckboxOrRadio(element)
-            ? element.checked
-            : element.getAttribute(attributeName);
+        let value = element.getAttribute(attributeName);
+
+        if (attributeName === 'checked' && isCheckboxOrRadio(element)) {
+          value = element.checked;
+        } else if (attributeName === 'href' && element.tagName === 'A') {
+          value = element.href;
+        }
 
         if (multiple) result.push(value);
         else result = value;

+ 10 - 7
src/content/blocks-handler/handler-element-exists.js

@@ -1,27 +1,30 @@
-import { handleElement } from '../helper';
+import handleSelector from '../handle-selector';
 
 function elementExists(block) {
   return new Promise((resolve) => {
     let trying = 0;
 
-    const isExists = () => {
+    const isExists = async () => {
       try {
-        const element = handleElement(block, { returnElement: true });
+        const element = await handleSelector(block, { returnElement: true });
 
-        return !!element;
+        if (!element) throw new Error('element-not-found');
+
+        return true;
       } catch (error) {
-        console.error(error);
         return false;
       }
     };
 
-    function checkElement() {
+    async function checkElement() {
       if (trying > (block.data.tryCount || 1)) {
         resolve(false);
         return;
       }
 
-      if (isExists()) {
+      const isElementExist = await isExists();
+
+      if (isElementExist) {
         resolve(true);
       } else {
         trying += 1;

+ 2 - 2
src/content/blocks-handler/handler-element-scroll.js

@@ -1,4 +1,4 @@
-import { handleElement } from '../helper';
+import handleSelector from '../handle-selector';
 
 function elementScroll(block) {
   function incScrollPos(element, data, vertical = true) {
@@ -17,7 +17,7 @@ function elementScroll(block) {
     const { data } = block;
     const behavior = data.smooth ? 'smooth' : 'auto';
 
-    handleElement(block, {
+    handleSelector(block, {
       onSelected(element) {
         if (data.scrollIntoView) {
           element.scrollIntoView({ behavior, block: 'center' });

+ 2 - 2
src/content/blocks-handler/handler-event-click.js

@@ -1,8 +1,8 @@
-import { handleElement } from '../helper';
+import handleSelector from '../handle-selector';
 
 function eventClick(block) {
   return new Promise((resolve, reject) => {
-    handleElement(block, {
+    handleSelector(block, {
       onSelected(element) {
         if (element.click) {
           element.click();

+ 27 - 36
src/content/blocks-handler/handler-forms.js

@@ -1,48 +1,39 @@
-import { handleElement, markElement } from '../helper';
+import handleSelector, { markElement } from '../handle-selector';
 import handleFormElement from '@/utils/handle-form-element';
 
-function forms(block) {
-  return new Promise((resolve, reject) => {
-    const { data } = block;
-    const elements = handleElement(block, { returnElement: true });
+async function forms(block) {
+  const { data } = block;
+  const elements = await handleSelector(block, { returnElement: true });
 
-    if (!elements) {
-      reject(new Error('element-not-found'));
+  if (!elements) {
+    throw new Error('element-not-found');
+  }
 
-      return;
+  if (data.getValue) {
+    let result = '';
+
+    if (data.multiple) {
+      result = elements.map((element) => element.value || '');
+    } else {
+      result = elements.value || '';
     }
 
-    if (data.getValue) {
-      let result = '';
+    return result;
+  }
 
-      if (data.multiple) {
-        result = elements.map((element) => element.value || '');
-      } else {
-        result = elements.value || '';
-      }
+  if (data.multiple) {
+    const promises = Array.from(elements).map(async (element) => {
+      markElement(element, block);
+      await handleFormElement(element, data, eventResolve);
+    });
 
-      resolve(result);
-      return;
-    }
+    await Promise.allSettled(promises);
+  } else {
+    markElement(elements, block);
+    await handleFormElement(elements, data);
+  }
 
-    if (data.multiple) {
-      const promises = Array.from(elements).map((element) => {
-        return new Promise((eventResolve) => {
-          markElement(element, block);
-          handleFormElement(element, data, eventResolve);
-        });
-      });
-
-      Promise.allSettled(promises).then(() => {
-        resolve('');
-      });
-    } else if (elements) {
-      markElement(elements, block);
-      handleFormElement(elements, data, resolve);
-    } else {
-      resolve('');
-    }
-  });
+  return null;
 }
 
 export default forms;

+ 2 - 2
src/content/blocks-handler/handler-get-text.js

@@ -1,4 +1,4 @@
-import { handleElement } from '../helper';
+import handleSelector from '../handle-selector';
 
 function getText(block) {
   return new Promise((resolve, reject) => {
@@ -17,7 +17,7 @@ function getText(block) {
       regex = new RegExp(regexData, regexExp.join(''));
     }
 
-    handleElement(block, {
+    handleSelector(block, {
       onSelected(element) {
         let text = includeTags ? element.outerHTML : element.innerText;
 

+ 29 - 9
src/content/blocks-handler/handler-javascript-code.js

@@ -1,9 +1,25 @@
 import { sendMessage } from '@/utils/message';
 
+/*
+setVariable(name, value);
+
+init => set variables to sessionStorage
+invoked => update the variable in the sessionStorage
+nextBlock => include the variables in payload
+*/
+
 function getAutomaScript(blockId) {
   return `
-function automaNextBlock(data) {
-  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: data }));
+function automaSetVariable(name, value) {
+  const data = JSON.parse(sessionStorage.getItem('automa--${blockId}')) || null;
+
+  if (data === null) return null;
+
+  data.variables[name] = value;
+  sessionStorage.setItem('automa--${blockId}', JSON.stringify(data));
+}
+function automaNextBlock(data, insert = true) {
+  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: { data, insert } }));
 }
 function automaResetTimeout() {
  window.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
@@ -14,17 +30,17 @@ function findData(obj, path) {
 
   if (paths.length === 0 || isWhitespace) return obj;
 
-  let current = obj;
+  let result = obj;
 
   for (let i = 0; i < paths.length; i++) {
-    if (current[paths[i]] == undefined) {
+    if (result[paths[i]] == undefined) {
       return undefined;
     } else {
-      current = current[paths[i]];
+      result = result[paths[i]];
     }
   }
 
-  return current;
+  return result;
 }
 function automaRefData(keyword, path = '') {
   const data = JSON.parse(sessionStorage.getItem('automa--${blockId}')) || null;
@@ -108,13 +124,17 @@ function javascriptCode(block) {
       script.id = 'automa-custom-js';
       script.innerHTML = `(() => {\n${automaScript} ${block.data.code}\n})()`;
 
-      const cleanUp = (data = '') => {
+      const cleanUp = (columns = '') => {
+        const storageKey = `automa--${block.id}`;
+        const storageRefData = JSON.parse(sessionStorage.getItem(storageKey));
+
         script.remove();
         preloadScripts.forEach((item) => {
           if (item.removeAfterExec) item.script.remove();
         });
-        sessionStorage.removeItem(`automa--${block.id}`);
-        resolve(data);
+        sessionStorage.removeItem(storageKey);
+
+        resolve({ columns, variables: storageRefData?.variables });
       };
 
       window.addEventListener('__automa-next-block__', ({ detail }) => {

+ 10 - 13
src/content/blocks-handler/handler-link.js

@@ -1,22 +1,19 @@
-import { handleElement, markElement } from '../helper';
+import handleSelector, { markElement } from '../handle-selector';
 
-function link(block) {
-  return new Promise((resolve, reject) => {
-    const element = handleElement(block, { returnElement: true });
+async function link(block) {
+  const element = await handleSelector(block, { returnElement: true });
 
-    if (!element) {
-      reject(new Error('element-not-found'));
-      return;
-    }
+  if (!element) {
+    throw new Error('element-not-found');
+  }
 
-    markElement(element, block);
+  markElement(element, block);
 
-    const url = element.href;
+  const url = element.href;
 
-    if (url) window.location.href = url;
+  if (url) window.location.href = url;
 
-    resolve(url);
-  });
+  return url;
 }
 
 export default link;

+ 2 - 2
src/content/blocks-handler/handler-switch-to.js

@@ -1,8 +1,8 @@
-import { handleElement } from '../helper';
+import handleSelector from '../handle-selector';
 
 function switchTo(block) {
   return new Promise((resolve, reject) => {
-    handleElement(block, {
+    handleSelector(block, {
       onSelected(element) {
         if (element.tagName !== 'IFRAME') {
           reject(new Error('not-iframe'));

+ 2 - 2
src/content/blocks-handler/handler-trigger-event.js

@@ -1,11 +1,11 @@
-import { handleElement } from '../helper';
+import handleSelector from '../handle-selector';
 import simulateEvent from '@/utils/simulate-event';
 
 function triggerEvent(block) {
   return new Promise((resolve, reject) => {
     const { data } = block;
 
-    handleElement(block, {
+    handleSelector(block, {
       onSelected(element) {
         simulateEvent(element, data.eventName, data.eventParams);
       },

+ 2 - 2
src/content/blocks-handler/handler-upload-file.js

@@ -1,5 +1,5 @@
 import { sendMessage } from '@/utils/message';
-import { handleElement } from '../helper';
+import handleSelector from '../handle-selector';
 
 function injectFiles(element, files) {
   const notFileTypeAttr = element.getAttribute('type') !== 'file';
@@ -12,7 +12,7 @@ function injectFiles(element, files) {
 }
 
 export default async function (block) {
-  const elements = handleElement(block, { returnElement: true });
+  const elements = await handleSelector(block, { returnElement: true });
 
   if (!elements) throw new Error('element-not-found');
 

+ 60 - 16
src/content/element-selector/App.vue

@@ -4,7 +4,7 @@
       'select-none': state.isDragging,
       'bg-black bg-opacity-30': !state.hide,
     }"
-    class="root fixed h-full w-full pointer-events-none top-0 text-gray-900 left-0"
+    class="root fixed h-full w-full pointer-events-none top-0 text-black left-0"
     style="z-index: 9999999999; font-family: Inter, sans-serif; font-size: 16px"
   >
     <div
@@ -35,6 +35,8 @@
       <app-selector
         :selector="state.elSelector"
         :selected-count="state.selectedElements.length"
+        :selector-type="state.selectorType"
+        @selector="state.selectorType = $event"
         @child="selectChildElement"
         @parent="selectParentElement"
         @change="updateSelectedElements"
@@ -50,7 +52,7 @@
         <ui-tab-panels
           v-model="state.activeTab"
           class="overflow-y-auto scroll"
-          style="max-height: calc(100vh - 15rem)"
+          style="max-height: calc(100vh - 17rem)"
         >
           <ui-tab-panel value="attributes">
             <app-element-list
@@ -69,9 +71,12 @@
                   >
                     {{ attribute.name }}
                   </p>
-                  <p title="Attribute value" class="text-overflow">
-                    {{ attribute.value }}
-                  </p>
+                  <input
+                    :value="attribute.value"
+                    readonly
+                    title="Attribute value"
+                    class="bg-transparent w-full"
+                  />
                 </div>
               </template>
             </app-element-list>
@@ -151,6 +156,7 @@ import { debounce } from '@/utils/helper';
 import AppBlocks from './AppBlocks.vue';
 import AppSelector from './AppSelector.vue';
 import AppElementList from './AppElementList.vue';
+import findElement from '@/utils/find-element';
 
 const selectedElement = {
   path: [],
@@ -168,6 +174,7 @@ const state = reactive({
   isDragging: false,
   isExecuting: false,
   selectElements: [],
+  selectorType: 'css',
   selectedElements: [],
   hide: window.self !== window.top,
 });
@@ -184,6 +191,34 @@ const cardRect = reactive({
   width: 0,
 });
 
+/* eslint-disable  no-use-before-define */
+const getElementSelector = (element) =>
+  state.selectorType === 'css' ? finder(element) : generateXPath(element);
+
+function generateXPath(element) {
+  if (!element) return null;
+  if (element.id !== '') return `id("${element.id}")`;
+  if (element === document.body) return `//${element.tagName}`;
+
+  let ix = 0;
+  const siblings = element.parentNode.childNodes;
+
+  for (let index = 0; index < siblings.length; index += 1) {
+    const sibling = siblings[index];
+
+    if (sibling === element) {
+      return `${generateXPath(element.parentNode)}/${element.tagName}[${
+        ix + 1
+      }]`;
+    }
+
+    if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
+      ix += 1;
+    }
+  }
+
+  return null;
+}
 function toggleHighlightElement({ index, highlight }) {
   state.selectedElements[index].highlight = highlight;
 }
@@ -203,9 +238,14 @@ function updateSelectedElements(selector) {
   state.elSelector = selector;
 
   try {
-    const elements = document.querySelectorAll(selector);
+    const selectorType = state.selectorType === 'css' ? 'cssSelector' : 'xpath';
+    let elements = findElement[selectorType]({ selector, multiple: true });
     const selectElements = [];
 
+    if (selectorType === 'xpath') {
+      elements = elements ? [elements] : [];
+    }
+
     state.selectedElements = Array.from(elements).map((element, index) => {
       const attributes = Array.from(element.attributes).map(
         ({ name, value }) => ({ name, value })
@@ -259,26 +299,30 @@ function handleMouseMove({ clientX, clientY, target }) {
   Object.assign(hoverElementRect, getElementRect(target));
 }
 function handleClick(event) {
-  if (event.target === rootElement || state.hide || state.isExecuting) return;
+  const { target, path } = event;
+
+  if (target === rootElement || state.hide || state.isExecuting) return;
 
   event.preventDefault();
   event.stopPropagation();
 
-  const attributes = Array.from(event.target.attributes).map(
-    ({ name, value }) => ({ name, value })
-  );
+  const attributes = Array.from(target.attributes).map(({ name, value }) => ({
+    name,
+    value,
+  }));
   state.selectedElements = [
     {
-      ...getElementRect(event.target),
+      ...getElementRect(target),
       attributes,
-      element: event.target,
+      element: target,
       highlight: false,
     },
   ];
-  state.elSelector = finder(event.target);
+
+  state.elSelector = getElementSelector(target);
 
   selectedElement.index = 0;
-  selectedElement.path = event.path;
+  selectedElement.path = path;
 }
 function selectChildElement() {
   if (selectedElement.path.length === 0 || state.hide) return;
@@ -300,7 +344,7 @@ function selectChildElement() {
     childElement = selectedElement.path[selectedElement.pathIndex];
   }
 
-  updateSelectedElements(finder(childElement));
+  updateSelectedElements(getElementSelector(childElement));
 }
 function selectParentElement() {
   if (selectedElement.path.length === 0 || state.hide) return;
@@ -311,7 +355,7 @@ function selectParentElement() {
 
   selectedElement.pathIndex += 1;
 
-  updateSelectedElements(finder(parentElement));
+  updateSelectedElements(getElementSelector(parentElement));
 }
 function handleMouseUp() {
   if (state.isDragging) state.isDragging = false;

+ 37 - 19
src/content/element-selector/AppSelector.vue

@@ -1,25 +1,39 @@
 <template>
-  <div class="mt-4 flex items-center">
-    <ui-input
-      :model-value="selector"
-      placeholder="Element selector"
-      class="leading-normal flex-1 h-full element-selector"
-      @change="updateSelector"
+  <div class="mt-4">
+    <ui-select
+      :model-value="selectorType"
+      class="w-full"
+      @change="$emit('selector', $event)"
     >
-      <template #prepend>
-        <button class="absolute ml-2 left-0" @click="copySelector">
-          <v-remixicon name="riFileCopyLine" />
+      <option value="css">CSS Selector</option>
+      <option value="xpath">XPath</option>
+    </ui-select>
+    <div class="mt-2 flex items-center">
+      <ui-input
+        :model-value="selector"
+        placeholder="Element selector"
+        class="leading-normal flex-1 h-full element-selector"
+        @change="updateSelector"
+      >
+        <template #prepend>
+          <button class="absolute ml-2 left-0" @click="copySelector">
+            <v-remixicon name="riFileCopyLine" />
+          </button>
+        </template>
+      </ui-input>
+      <template v-if="selectedCount === 1">
+        <button
+          class="mr-2 ml-4"
+          title="Parent element"
+          @click="$emit('parent')"
+        >
+          <v-remixicon rotate="90" name="riArrowLeftLine" />
+        </button>
+        <button title="Child element" @click="$emit('child')">
+          <v-remixicon rotate="-90" name="riArrowLeftLine" />
         </button>
       </template>
-    </ui-input>
-    <template v-if="selectedCount === 1">
-      <button class="mr-2 ml-4" title="Parent element" @click="$emit('parent')">
-        <v-remixicon rotate="90" name="riArrowLeftLine" />
-      </button>
-      <button title="Child element" @click="$emit('child')">
-        <v-remixicon rotate="-90" name="riArrowLeftLine" />
-      </button>
-    </template>
+    </div>
   </div>
 </template>
 <script setup>
@@ -36,8 +50,12 @@ const props = defineProps({
     type: Number,
     default: 0,
   },
+  selectorType: {
+    type: String,
+    default: '',
+  },
 });
-const emit = defineEmits(['change', 'parent', 'child']);
+const emit = defineEmits(['change', 'parent', 'child', 'selector']);
 
 const rootElement = inject('rootElement');
 

+ 46 - 1
src/content/helper.js → src/content/handle-selector.js

@@ -8,7 +8,35 @@ export function markElement(el, { id, data }) {
   }
 }
 
-export function handleElement(
+export function waitForSelector({
+  timeout,
+  selector,
+  documentCtx = document,
+} = {}) {
+  return new Promise((resolve) => {
+    let isTimeout = false;
+    const findSelector = () => {
+      if (isTimeout) return;
+
+      const element = documentCtx.querySelector(selector);
+
+      if (!element) {
+        setTimeout(findSelector, 200);
+      } else {
+        resolve(element);
+      }
+    };
+
+    findSelector();
+
+    setTimeout(() => {
+      isTimeout = true;
+      resolve(null);
+    }, timeout);
+  });
+}
+
+export default async function (
   { data, id, frameSelector },
   { onSelected, onError, onSuccess, returnElement }
 ) {
@@ -31,6 +59,23 @@ export function handleElement(
     documentCtx = iframeCtx;
   }
 
+  if (data.waitForSelector && data.findBy === 'cssSelector') {
+    const element = await waitForSelector({
+      documentCtx,
+      selector: data.selector,
+      timeout: data.waitSelectorTimeout,
+    });
+
+    if (!element) {
+      if (returnElement) return element;
+
+      if (onError) {
+        onError(new Error('element-not-found'));
+        return;
+      }
+    }
+  }
+
   try {
     data.blockIdAttr = `block--${id}`;
 

部分文件因文件數量過多而無法顯示