Ahmad Kholid преди 3 години
родител
ревизия
cf2b636b85
променени са 89 файла, в които са добавени 3571 реда и са изтрити 1817 реда
  1. 1 1
      package.json
  2. 5 2
      src/assets/css/tailwind.css
  3. 2 2
      src/background/collection-engine/flow-handler.js
  4. 2 2
      src/background/collection-engine/index.js
  5. 2 2
      src/background/index.js
  6. 8 622
      src/background/workflow-engine/blocks-handler.js
  7. 43 0
      src/background/workflow-engine/blocks-handler/handler-active-tab.js
  8. 33 0
      src/background/workflow-engine/blocks-handler/handler-close-tab.js
  9. 33 0
      src/background/workflow-engine/blocks-handler/handler-condition.js
  10. 14 0
      src/background/workflow-engine/blocks-handler/handler-delay.js
  11. 20 0
      src/background/workflow-engine/blocks-handler/handler-element-exists.js
  12. 72 0
      src/background/workflow-engine/blocks-handler/handler-execute-workflow.js
  13. 15 0
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  14. 32 0
      src/background/workflow-engine/blocks-handler/handler-forward-page.js
  15. 32 0
      src/background/workflow-engine/blocks-handler/handler-go-back.js
  16. 92 0
      src/background/workflow-engine/blocks-handler/handler-interaction-block.js
  17. 33 0
      src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js
  18. 59 0
      src/background/workflow-engine/blocks-handler/handler-loop-data.js
  19. 79 0
      src/background/workflow-engine/blocks-handler/handler-new-tab.js
  20. 27 0
      src/background/workflow-engine/blocks-handler/handler-new-window.js
  21. 60 0
      src/background/workflow-engine/blocks-handler/handler-proxy.js
  22. 23 0
      src/background/workflow-engine/blocks-handler/handler-repeat-task.js
  23. 39 0
      src/background/workflow-engine/blocks-handler/handler-switch-to.js
  24. 70 0
      src/background/workflow-engine/blocks-handler/handler-take-screenshot.js
  25. 20 0
      src/background/workflow-engine/blocks-handler/handler-trigger.js
  26. 37 0
      src/background/workflow-engine/blocks-handler/handler-webhook.js
  27. 393 0
      src/background/workflow-engine/engine.js
  28. 0 0
      src/background/workflow-engine/execute-content-script.js
  29. 24 0
      src/background/workflow-engine/helper.js
  30. 5 355
      src/background/workflow-engine/index.js
  31. 22 12
      src/components/newtab/app/AppSidebar.vue
  32. 9 1
      src/components/newtab/shared/SharedCard.vue
  33. 11 1
      src/components/newtab/shared/SharedWorkflowState.vue
  34. 34 13
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  35. 89 0
      src/components/newtab/workflow/edit/EditExecuteWorkflow.vue
  36. 83 36
      src/components/newtab/workflow/edit/EditForms.vue
  37. 56 29
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  38. 1 1
      src/components/newtab/workflow/edit/EditJavascriptCode.vue
  39. 29 1
      src/components/newtab/workflow/edit/EditLoopData.vue
  40. 77 0
      src/components/newtab/workflow/edit/EditProxy.vue
  41. 5 1
      src/components/newtab/workflow/edit/EditScrollElement.vue
  42. 18 30
      src/components/newtab/workflow/edit/EditTriggerEvent.vue
  43. 0 0
      src/components/ui/UiCheckbox.vue
  44. 91 0
      src/components/ui/UiImg.vue
  45. 8 350
      src/content/blocks-handler.js
  46. 27 0
      src/content/blocks-handler/handler-attribute-value.js
  47. 26 0
      src/content/blocks-handler/handler-element-exists.js
  48. 38 0
      src/content/blocks-handler/handler-element-scroll.js
  49. 13 0
      src/content/blocks-handler/handler-event-click.js
  50. 42 0
      src/content/blocks-handler/handler-forms.js
  51. 27 0
      src/content/blocks-handler/handler-get-text.js
  52. 128 0
      src/content/blocks-handler/handler-javascript-code.js
  53. 22 0
      src/content/blocks-handler/handler-link.js
  54. 22 0
      src/content/blocks-handler/handler-switch-to.js
  55. 16 0
      src/content/blocks-handler/handler-trigger-event.js
  56. 343 0
      src/content/element-selector/App.vue
  57. 121 0
      src/content/element-selector/AppBlocks.vue
  58. 39 0
      src/content/element-selector/AppElementAttributes.vue
  59. 0 0
      src/content/element-selector/AppHeader.vue
  60. 57 0
      src/content/element-selector/AppSelector.vue
  61. 0 308
      src/content/element-selector/ElementSelector.ce.vue
  62. 26 0
      src/content/element-selector/comps-ui.js
  63. 25 0
      src/content/element-selector/icons.js
  64. 62 15
      src/content/element-selector/index.js
  65. 17 0
      src/content/element-selector/main.js
  66. 13 0
      src/content/element-selector/vue-i18n.js
  67. 34 0
      src/content/helper.js
  68. 1 1
      src/content/index.js
  69. 1 0
      src/lib/dayjs.js
  70. 6 0
      src/lib/v-remixicon.js
  71. 3 3
      src/lib/vue-i18n.js
  72. 26 0
      src/locales/en/blocks.json
  73. 15 8
      src/locales/en/newtab.json
  74. 285 0
      src/locales/zh-TW/blocks.json
  75. 54 0
      src/locales/zh-TW/common.json
  76. 128 0
      src/locales/zh-TW/newtab.json
  77. 13 0
      src/locales/zh-TW/popup.json
  78. 7 0
      src/manifest.json
  79. 1 0
      src/models/log.js
  80. 1 1
      src/newtab/pages/Home.vue
  81. 4 2
      src/newtab/pages/Logs.vue
  82. 12 6
      src/newtab/pages/Workflows.vue
  83. 2 2
      src/newtab/pages/collections/[id].vue
  84. 10 7
      src/newtab/pages/logs/[id].vue
  85. 5 1
      src/newtab/pages/workflows/[id].vue
  86. 23 0
      src/utils/find-element.js
  87. 4 0
      src/utils/helper.js
  88. 56 1
      src/utils/shared.js
  89. 8 1
      webpack.config.js

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.7.2",
+  "version": "0.8.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {

+ 5 - 2
src/assets/css/tailwind.css

@@ -2,11 +2,11 @@
 @tailwind components;
 @tailwind utilities;
 
-body {
+:host, body {
   font-family: 'Inter var';
   font-size: 16px;
   font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
-  @apply bg-gray-50 dark:bg-gray-900;
+  @apply bg-gray-50 dark:bg-gray-900 leading-normal;
 }
 table th,
 table td {
@@ -58,4 +58,7 @@ select:focus,
   .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;
+  }
 }

+ 2 - 2
src/background/collection-engine/flow-handler.js

@@ -1,4 +1,4 @@
-import WorkflowEngine from '../workflow-engine';
+import workflowEngine from '../workflow-engine';
 import dataExporter from '@/utils/data-exporter';
 
 export function workflow(flow) {
@@ -26,7 +26,7 @@ export function workflow(flow) {
     const { globalData } = this.collection;
     this.currentWorkflow = currentWorkflow;
 
-    const engine = new WorkflowEngine(currentWorkflow, {
+    const engine = workflowEngine(currentWorkflow, {
       isInCollection: true,
       collectionLogId: this.id,
       collectionId: this.collection.id,

+ 2 - 2
src/background/collection-engine/index.js

@@ -3,7 +3,7 @@ import browser from 'webextension-polyfill';
 import { toCamelCase } from '@/utils/helper';
 import * as flowHandler from './flow-handler';
 import workflowState from '../workflow-state';
-import WorkflowEngine from '../workflow-engine';
+import workflowEngine from '../workflow-engine';
 
 class CollectionEngine {
   constructor(collection) {
@@ -37,7 +37,7 @@ class CollectionEngine {
           const currentWorkflow = workflows.find(({ id }) => id === itemId);
 
           if (currentWorkflow) {
-            const engine = new WorkflowEngine(currentWorkflow, {});
+            const engine = workflowEngine(currentWorkflow, {});
 
             engine.init();
           }

+ 2 - 2
src/background/index.js

@@ -1,7 +1,7 @@
 import browser from 'webextension-polyfill';
 import { MessageListener } from '@/utils/message';
 import workflowState from './workflow-state';
-import WorkflowEngine from './workflow-engine';
+import workflowEngine from './workflow-engine';
 import CollectionEngine from './collection-engine';
 import { registerSpecificDay } from '../utils/workflow-trigger';
 
@@ -20,7 +20,7 @@ const runningCollections = {};
 
 async function executeWorkflow(workflow, tabId) {
   try {
-    const engine = new WorkflowEngine(workflow, { tabId });
+    const engine = workflowEngine(workflow, { tabId });
 
     runningWorkflows[engine.id] = engine;
 

+ 8 - 622
src/background/workflow-engine/blocks-handler.js

@@ -1,626 +1,12 @@
-/* eslint-disable no-underscore-dangle */
-import browser from 'webextension-polyfill';
-import { objectHasKey, fileSaver, isObject } from '@/utils/helper';
-import { executeWebhook } from '@/utils/webhookUtil';
-import executeContentScript from '@/utils/execute-content-script';
-import dataExporter, { generateJSON } from '@/utils/data-exporter';
-import compareBlockValue from '@/utils/compare-block-value';
+import { toCamelCase } from '@/utils/helper';
 
-function getBlockConnection(block, index = 1) {
-  const blockId = block.outputs[`output_${index}`]?.connections[0]?.node;
+const blocksHandler = require.context('./blocks-handler', false, /\.js$/);
+const handlers = blocksHandler.keys().reduce((acc, key) => {
+  const name = key.replace(/^\.\/handler-|\.js/g, '');
 
-  return blockId;
-}
-function convertData(data, type) {
-  let result = data;
+  acc[toCamelCase(name)] = blocksHandler(key).default;
 
-  switch (type) {
-    case 'integer':
-      result = +data.replace(/\D+/g, '');
-      break;
-    case 'boolean':
-      result = Boolean(data);
-      break;
-    case 'array':
-      result = Array.from(data);
-      break;
-    default:
-  }
+  return acc;
+}, {});
 
-  return result;
-}
-
-export async function closeTab(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    const { data } = block;
-    let tabIds;
-
-    if (data.activeTab && this.tabId) {
-      tabIds = this.tabId;
-    } else if (data.url) {
-      tabIds = (await browser.tabs.query({ url: data.url })).map(
-        (tab) => tab.id
-      );
-    }
-
-    if (tabIds) await browser.tabs.remove(tabIds);
-
-    return {
-      nextBlockId,
-      data: '',
-    };
-  } catch (error) {
-    const errorInstance = typeof error === 'string' ? new Error(error) : error;
-    errorInstance.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-export async function trigger(block) {
-  const nextBlockId = getBlockConnection(block);
-  try {
-    if (block.data.type === 'visit-web' && this.tabId) {
-      this.frames = await executeContentScript(this.tabId, 'trigger');
-    }
-
-    return { nextBlockId, data: '' };
-  } catch (error) {
-    const errorInstance = new Error(error);
-    errorInstance.nextBlockId = nextBlockId;
-
-    throw errorInstance;
-  }
-}
-
-export function loopBreakpoint(block, prevBlockData) {
-  return new Promise((resolve) => {
-    const currentLoop = this.loopList[block.data.loopId];
-
-    if (
-      currentLoop &&
-      currentLoop.index < currentLoop.maxLoop - 1 &&
-      currentLoop.index <= currentLoop.data.length - 1
-    ) {
-      resolve({
-        data: '',
-        nextBlockId: currentLoop.blockId,
-      });
-    } else {
-      resolve({
-        data: prevBlockData,
-        nextBlockId: getBlockConnection(block),
-      });
-    }
-  });
-}
-
-export function loopData(block) {
-  return new Promise((resolve) => {
-    const { data } = block;
-
-    if (this.loopList[data.loopId]) {
-      this.loopList[data.loopId].index += 1;
-      this.loopData[data.loopId] =
-        this.loopList[data.loopId].data[this.loopList[data.loopId].index];
-    } else {
-      const currLoopData =
-        data.loopThrough === 'data-columns'
-          ? generateJSON(Object.keys(this.data), this.data)
-          : JSON.parse(data.loopData);
-
-      this.loopList[data.loopId] = {
-        index: 0,
-        data: currLoopData,
-        id: data.loopId,
-        blockId: block.id,
-        maxLoop: data.maxLoop || currLoopData.length,
-      };
-      /* eslint-disable-next-line */
-      this.loopData[data.loopId] = currLoopData[0];
-    }
-
-    resolve({
-      data: this.loopData[data.loopId],
-      nextBlockId: getBlockConnection(block),
-    });
-  });
-}
-
-export function goBack(block) {
-  return new Promise((resolve, reject) => {
-    const nextBlockId = getBlockConnection(block);
-
-    if (!this.tabId) {
-      const error = new Error('no-tab');
-      error.nextBlockId = nextBlockId;
-
-      reject(error);
-
-      return;
-    }
-
-    browser.tabs
-      .goBack(this.tabId)
-      .then(() => {
-        resolve({
-          nextBlockId,
-          data: '',
-        });
-      })
-      .catch((error) => {
-        error.nextBlockId = nextBlockId;
-        reject(error);
-      });
-  });
-}
-
-export function forwardPage(block) {
-  return new Promise((resolve, reject) => {
-    const nextBlockId = getBlockConnection(block);
-
-    if (!this.tabId) {
-      const error = new Error('no-tab');
-      error.nextBlockId = nextBlockId;
-
-      reject(nextBlockId);
-
-      return;
-    }
-
-    browser.tabs
-      .goForward(this.tabId)
-      .then(() => {
-        resolve({
-          nextBlockId,
-          data: '',
-        });
-      })
-      .catch((error) => {
-        error.nextBlockId = nextBlockId;
-        reject(error);
-      });
-  });
-}
-
-export async function newWindow(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    const { incognito, windowState } = block.data;
-    const { id } = await browser.windows.create({
-      incognito,
-      state: windowState,
-    });
-
-    this.windowId = id;
-
-    return {
-      data: id,
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-
-function tabUpdatedListener(tab) {
-  return new Promise((resolve) => {
-    this._listener({
-      name: 'tab-updated',
-      id: tab.id,
-      callback: async (tabId, changeInfo, deleteListener) => {
-        if (changeInfo.status !== 'complete') return;
-
-        const frames = await executeContentScript(tabId, 'newtab');
-
-        deleteListener();
-
-        resolve(frames);
-      },
-    });
-  });
-}
-export async function newTab(block) {
-  if (this.windowId) {
-    try {
-      await browser.windows.get(this.windowId);
-    } catch (error) {
-      this.windowId = null;
-    }
-  }
-
-  try {
-    const { updatePrevTab, url, active, inGroup } = block.data;
-
-    if (updatePrevTab && this.tabId) {
-      await browser.tabs.update(this.tabId, { url, active });
-    } else {
-      const tab = await browser.tabs.create({
-        url,
-        active,
-        windowId: this.windowId,
-      });
-
-      this.tabId = tab.id;
-      this.activeTabUrl = url;
-      this.windowId = tab.windowId;
-    }
-
-    if (inGroup && !updatePrevTab) {
-      const options = {
-        groupId: this.tabGroupId,
-        tabIds: this.tabId,
-      };
-
-      if (!this.tabGroupId) {
-        options.createProperties = {
-          windowId: this.windowId,
-        };
-      }
-
-      chrome.tabs.group(options, (tabGroupId) => {
-        this.tabGroupId = tabGroupId;
-      });
-    }
-
-    this.frameId = 0;
-    this.frames = await tabUpdatedListener.call(this, { id: this.tabId });
-
-    return {
-      data: url,
-      nextBlockId: getBlockConnection(block),
-    };
-  } catch (error) {
-    console.error(error);
-    throw error;
-  }
-}
-
-export async function activeTab(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    const data = {
-      nextBlockId,
-      data: '',
-    };
-
-    if (this.tabId) {
-      await browser.tabs.update(this.tabId, { active: true });
-
-      return data;
-    }
-
-    const [tab] = await browser.tabs.query({
-      active: true,
-      currentWindow: true,
-    });
-
-    this.frames = await executeContentScript(tab.id, 'activetab');
-
-    this.frameId = 0;
-    this.tabId = tab.id;
-    this.activeTabUrl = tab.url;
-    this.windowId = tab.windowId;
-
-    return data;
-  } catch (error) {
-    console.error(error);
-    return {
-      data: '',
-      message: error.message || error,
-      nextBlockId,
-    };
-  }
-}
-
-export async function takeScreenshot(block) {
-  const nextBlockId = getBlockConnection(block);
-  const { ext, quality, captureActiveTab, fileName } = block.data;
-
-  function saveImage(uri) {
-    const image = new Image();
-
-    image.onload = () => {
-      const name = `${fileName || 'Screenshot'}.${ext || 'png'}`;
-      const canvas = document.createElement('canvas');
-      canvas.width = image.width;
-      canvas.height = image.height;
-
-      const context = canvas.getContext('2d');
-      context.drawImage(image, 0, 0);
-
-      fileSaver(name, canvas.toDataURL());
-    };
-
-    image.src = uri;
-  }
-
-  try {
-    const options = {
-      quality,
-      format: ext || 'png',
-    };
-
-    if (captureActiveTab) {
-      if (!this.tabId) {
-        throw new Error('no-tab');
-      }
-
-      const [tab] = await browser.tabs.query({
-        active: true,
-        currentWindow: true,
-      });
-
-      await browser.windows.update(this.windowId, { focused: true });
-      await browser.tabs.update(this.tabId, { active: true });
-
-      await new Promise((resolve) => setTimeout(resolve, 500));
-
-      const uri = await browser.tabs.captureVisibleTab(options);
-
-      if (tab) {
-        await browser.windows.update(tab.windowId, { focused: true });
-        await browser.tabs.update(tab.id, { active: true });
-      }
-
-      saveImage(uri);
-    } else {
-      const uri = await browser.tabs.captureVisibleTab(options);
-
-      saveImage(uri);
-    }
-
-    return { data: '', nextBlockId };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-
-export async function switchTo(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    if (block.data.windowType === 'main-window') {
-      this.frameId = 0;
-
-      return {
-        data: '',
-        nextBlockId,
-      };
-    }
-
-    const { url } = await this._sendMessageToTab(block, { frameId: 0 });
-
-    if (objectHasKey(this.frames, url)) {
-      this.frameId = this.frames[url];
-
-      return {
-        data: this.frameId,
-        nextBlockId,
-      };
-    }
-
-    const error = new Error('no-iframe-id');
-    error.data = { selector: block.selector };
-
-    throw error;
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-
-export async function interactionHandler(block, prevBlockData) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    const refData = {
-      prevBlockData,
-      dataColumns: this.data,
-      loopData: this.loopData,
-      globalData: this.globalData,
-      activeTabUrl: this.activeTabUrl,
-    };
-    const data = await this._sendMessageToTab(
-      { ...block, refData },
-      {
-        frameId: this.frameId || 0,
-      }
-    );
-
-    if (block.name === 'link')
-      await new Promise((resolve) => setTimeout(resolve, 5000));
-
-    if (data?.isError) {
-      const error = new Error(data.message);
-      error.nextBlockId = nextBlockId;
-
-      throw error;
-    }
-
-    const getColumn = (name) =>
-      this.workflow.dataColumns.find((item) => item.name === name) || {
-        name: 'column',
-        type: 'text',
-      };
-    const pushData = (column, value) => {
-      this.data[column.name]?.push(convertData(value, column.type));
-    };
-
-    if (objectHasKey(block.data, 'dataColumn')) {
-      const column = getColumn(block.data.dataColumn);
-
-      if (block.data.saveData) {
-        if (Array.isArray(data) && column.type !== 'array') {
-          data.forEach((item) => {
-            pushData(column, item);
-          });
-        } else {
-          pushData(column, data);
-        }
-      }
-    } else if (block.name === 'javascript-code') {
-      const memoColumn = {};
-      const pushObjectData = (obj) => {
-        Object.entries(obj).forEach(([key, value]) => {
-          let column;
-
-          if (memoColumn[key]) {
-            column = memoColumn[key];
-          } else {
-            const currentColumn = getColumn(key);
-
-            column = currentColumn;
-            memoColumn[key] = currentColumn;
-          }
-
-          pushData(column, value);
-        });
-      };
-
-      if (Array.isArray(data)) {
-        data.forEach((obj) => {
-          if (isObject(obj)) pushObjectData(obj);
-        });
-      } else if (isObject(data)) {
-        pushObjectData(data);
-      }
-    }
-
-    return {
-      data,
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-
-export function delay(block) {
-  return new Promise((resolve) => {
-    setTimeout(() => {
-      resolve({
-        nextBlockId: getBlockConnection(block),
-        data: '',
-      });
-    }, block.data.time);
-  });
-}
-
-export function exportData(block) {
-  return new Promise((resolve) => {
-    dataExporter(this.data, block.data);
-
-    resolve({
-      data: '',
-      nextBlockId: getBlockConnection(block),
-    });
-  });
-}
-
-export function elementExists(block) {
-  return new Promise((resolve, reject) => {
-    this._sendMessageToTab(block)
-      .then((data) => {
-        resolve({
-          data,
-          nextBlockId: getBlockConnection(block, data ? 1 : 2),
-        });
-      })
-      .catch((error) => {
-        error.nextBlockId = getBlockConnection(block);
-
-        reject(error);
-      });
-  });
-}
-
-export function conditions({ data, outputs }, prevBlockData) {
-  return new Promise((resolve, reject) => {
-    if (data.conditions.length === 0) {
-      reject(new Error('Conditions is empty'));
-      return;
-    }
-
-    let outputIndex = data.conditions.length + 1;
-    let resultData = '';
-    const prevData = Array.isArray(prevBlockData)
-      ? prevBlockData[0]
-      : prevBlockData;
-
-    data.conditions.forEach(({ type, value }, index) => {
-      const result = compareBlockValue(type, prevData, value);
-
-      if (result) {
-        resultData = value;
-        outputIndex = index + 1;
-      }
-    });
-
-    resolve({
-      data: resultData,
-      nextBlockId: getBlockConnection({ outputs }, outputIndex),
-    });
-  });
-}
-
-export function repeatTask({ data, id, outputs }) {
-  return new Promise((resolve) => {
-    if (this.repeatedTasks[id] >= data.repeatFor) {
-      resolve({
-        data: data.repeatFor,
-        nextBlockId: getBlockConnection({ outputs }),
-      });
-    } else {
-      this.repeatedTasks[id] = (this.repeatedTasks[id] || 1) + 1;
-
-      resolve({
-        data: data.repeatFor,
-        nextBlockId: getBlockConnection({ outputs }, 2),
-      });
-    }
-  });
-}
-
-export function webhook({ data, outputs }) {
-  return new Promise((resolve, reject) => {
-    const nextBlockId = getBlockConnection({ outputs });
-
-    if (!data.url) {
-      const error = new Error('URL is empty');
-      error.nextBlockId = nextBlockId;
-
-      reject(error);
-      return;
-    }
-
-    if (!data.url.startsWith('http')) {
-      const error = new Error('URL is not valid');
-      error.nextBlockId = nextBlockId;
-
-      reject(error);
-      return;
-    }
-
-    executeWebhook(data)
-      .then(() => {
-        resolve({
-          data: '',
-          nextBlockId: getBlockConnection({ outputs }),
-        });
-      })
-      .catch((error) => {
-        reject(error);
-      });
-  });
-}
+export default handlers;

+ 43 - 0
src/background/workflow-engine/blocks-handler/handler-active-tab.js

@@ -0,0 +1,43 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+import executeContentScript from '../execute-content-script';
+
+async function activeTab(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const data = {
+      nextBlockId,
+      data: '',
+    };
+
+    if (this.tabId) {
+      await browser.tabs.update(this.tabId, { active: true });
+
+      return data;
+    }
+
+    const [tab] = await browser.tabs.query({
+      active: true,
+      currentWindow: true,
+    });
+
+    this.frames = await executeContentScript(tab.id, 'activetab');
+
+    this.frameId = 0;
+    this.tabId = tab.id;
+    this.activeTabUrl = tab.url;
+    this.windowId = tab.windowId;
+
+    return data;
+  } catch (error) {
+    console.error(error);
+    return {
+      data: '',
+      message: error.message || error,
+      nextBlockId,
+    };
+  }
+}
+
+export default activeTab;

+ 33 - 0
src/background/workflow-engine/blocks-handler/handler-close-tab.js

@@ -0,0 +1,33 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+async function closeTab(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const { data } = block;
+    let tabIds;
+
+    if (data.activeTab && this.tabId) {
+      tabIds = this.tabId;
+    } else if (data.url) {
+      tabIds = (await browser.tabs.query({ url: data.url })).map(
+        (tab) => tab.id
+      );
+    }
+
+    if (tabIds) await browser.tabs.remove(tabIds);
+
+    return {
+      nextBlockId,
+      data: '',
+    };
+  } catch (error) {
+    const errorInstance = typeof error === 'string' ? new Error(error) : error;
+    errorInstance.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default closeTab;

+ 33 - 0
src/background/workflow-engine/blocks-handler/handler-condition.js

@@ -0,0 +1,33 @@
+import { getBlockConnection } from '../helper';
+import compareBlockValue from '@/utils/compare-block-value';
+
+function conditions({ data, outputs }, prevBlockData) {
+  return new Promise((resolve, reject) => {
+    if (data.conditions.length === 0) {
+      reject(new Error('Conditions is empty'));
+      return;
+    }
+
+    let outputIndex = data.conditions.length + 1;
+    let resultData = '';
+    const prevData = Array.isArray(prevBlockData)
+      ? prevBlockData[0]
+      : prevBlockData;
+
+    data.conditions.forEach(({ type, value }, index) => {
+      const result = compareBlockValue(type, prevData, value);
+
+      if (result) {
+        resultData = value;
+        outputIndex = index + 1;
+      }
+    });
+
+    resolve({
+      data: resultData,
+      nextBlockId: getBlockConnection({ outputs }, outputIndex),
+    });
+  });
+}
+
+export default conditions;

+ 14 - 0
src/background/workflow-engine/blocks-handler/handler-delay.js

@@ -0,0 +1,14 @@
+import { getBlockConnection } from '../helper';
+
+function delay(block) {
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      resolve({
+        nextBlockId: getBlockConnection(block),
+        data: '',
+      });
+    }, block.data.time);
+  });
+}
+
+export default delay;

+ 20 - 0
src/background/workflow-engine/blocks-handler/handler-element-exists.js

@@ -0,0 +1,20 @@
+import { getBlockConnection } from '../helper';
+
+function elementExists(block) {
+  return new Promise((resolve, reject) => {
+    this._sendMessageToTab(block)
+      .then((data) => {
+        resolve({
+          data,
+          nextBlockId: getBlockConnection(block, data ? 1 : 2),
+        });
+      })
+      .catch((error) => {
+        error.nextBlockId = getBlockConnection(block);
+
+        reject(error);
+      });
+  });
+}
+
+export default elementExists;

+ 72 - 0
src/background/workflow-engine/blocks-handler/handler-execute-workflow.js

@@ -0,0 +1,72 @@
+import browser from 'webextension-polyfill';
+import WorkflowEngine from '../index';
+import { getBlockConnection } from '../helper';
+
+function workflowListener(workflow, options) {
+  return new Promise((resolve, reject) => {
+    const engine = new WorkflowEngine(workflow, options);
+    engine.init();
+    engine.on('destroyed', ({ id, status, message, currentBlock }) => {
+      if (status === 'error') {
+        const error = new Error(message);
+        error.data = { logId: id, name: currentBlock.name };
+
+        reject(error);
+        return;
+      }
+
+      resolve({ id, status, message });
+    });
+
+    options.events.onInit(engine);
+  });
+}
+
+async function executeWorkflow(block) {
+  const nextBlockId = getBlockConnection(block);
+  const { data } = block;
+
+  try {
+    if (data.workflowId === '') throw new Error('empty-workflow');
+
+    const { workflows } = await browser.storage.local.get('workflows');
+    const workflow = workflows.find(({ id }) => id === data.workflowId);
+
+    if (!workflow) {
+      const errorInstance = new Error('no-workflow');
+      errorInstance.data = { workflowId: data.workflowId };
+
+      throw errorInstance;
+    }
+
+    const onInit = (engine) => {
+      this.childWorkflow = engine;
+    };
+    const options = {
+      events: { onInit },
+      isChildWorkflow: true,
+      collectionLogId: this.id,
+      collectionId: this.workflow.id,
+      parentWorkflow: { name: this.workflow.name },
+      globalData: !/\S/g.test(data.globalData) ? null : data.globalData,
+    };
+
+    if (workflow.drawflow.includes(this.workflow.id)) {
+      throw new Error('workflow-infinite-loop');
+    }
+
+    const result = await workflowListener(workflow, options);
+
+    return {
+      data: '',
+      logId: result.id,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default executeWorkflow;

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

@@ -0,0 +1,15 @@
+import { getBlockConnection } from '../helper';
+import dataExporter from '@/utils/data-exporter';
+
+function exportData(block) {
+  return new Promise((resolve) => {
+    dataExporter(this.data, block.data);
+
+    resolve({
+      data: '',
+      nextBlockId: getBlockConnection(block),
+    });
+  });
+}
+
+export default exportData;

+ 32 - 0
src/background/workflow-engine/blocks-handler/handler-forward-page.js

@@ -0,0 +1,32 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+function forwardPage(block) {
+  return new Promise((resolve, reject) => {
+    const nextBlockId = getBlockConnection(block);
+
+    if (!this.tabId) {
+      const error = new Error('no-tab');
+      error.nextBlockId = nextBlockId;
+
+      reject(nextBlockId);
+
+      return;
+    }
+
+    browser.tabs
+      .goForward(this.tabId)
+      .then(() => {
+        resolve({
+          nextBlockId,
+          data: '',
+        });
+      })
+      .catch((error) => {
+        error.nextBlockId = nextBlockId;
+        reject(error);
+      });
+  });
+}
+
+export default forwardPage;

+ 32 - 0
src/background/workflow-engine/blocks-handler/handler-go-back.js

@@ -0,0 +1,32 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+export function goBack(block) {
+  return new Promise((resolve, reject) => {
+    const nextBlockId = getBlockConnection(block);
+
+    if (!this.tabId) {
+      const error = new Error('no-tab');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+
+      return;
+    }
+
+    browser.tabs
+      .goBack(this.tabId)
+      .then(() => {
+        resolve({
+          nextBlockId,
+          data: '',
+        });
+      })
+      .catch((error) => {
+        error.nextBlockId = nextBlockId;
+        reject(error);
+      });
+  });
+}
+
+export default goBack;

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

@@ -0,0 +1,92 @@
+import { objectHasKey, isObject } from '@/utils/helper';
+import { getBlockConnection, convertData } from '../helper';
+
+async function interactionHandler(block, prevBlockData) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const refData = {
+      prevBlockData,
+      dataColumns: this.data,
+      loopData: this.loopData,
+      globalData: this.globalData,
+      activeTabUrl: this.activeTabUrl,
+    };
+    const data = await this._sendMessageToTab(
+      { ...block, refData },
+      {
+        frameId: this.frameId || 0,
+      }
+    );
+
+    if (block.name === 'link')
+      await new Promise((resolve) => setTimeout(resolve, 5000));
+
+    if (data?.isError) {
+      const error = new Error(data.message);
+      error.nextBlockId = nextBlockId;
+
+      throw error;
+    }
+
+    const getColumn = (name) =>
+      this.workflow.dataColumns.find((item) => item.name === name) || {
+        name: 'column',
+        type: 'text',
+      };
+    const pushData = (column, value) => {
+      this.data[column.name]?.push(convertData(value, column.type));
+    };
+
+    if (objectHasKey(block.data, 'dataColumn')) {
+      const column = getColumn(block.data.dataColumn);
+
+      if (block.data.saveData) {
+        if (Array.isArray(data) && column.type !== 'array') {
+          data.forEach((item) => {
+            pushData(column, item);
+          });
+        } else {
+          pushData(column, data);
+        }
+      }
+    } else if (block.name === 'javascript-code') {
+      const memoColumn = {};
+      const pushObjectData = (obj) => {
+        Object.entries(obj).forEach(([key, value]) => {
+          let column;
+
+          if (memoColumn[key]) {
+            column = memoColumn[key];
+          } else {
+            const currentColumn = getColumn(key);
+
+            column = currentColumn;
+            memoColumn[key] = currentColumn;
+          }
+
+          pushData(column, value);
+        });
+      };
+
+      if (Array.isArray(data)) {
+        data.forEach((obj) => {
+          if (isObject(obj)) pushObjectData(obj);
+        });
+      } else if (isObject(data)) {
+        pushObjectData(data);
+      }
+    }
+
+    return {
+      data,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default interactionHandler;

+ 33 - 0
src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js

@@ -0,0 +1,33 @@
+import { getBlockConnection } from '../helper';
+
+function loopBreakpoint(block, prevBlockData) {
+  const currentLoop = this.loopList[block.data.loopId];
+
+  return new Promise((resolve) => {
+    const validLoopData =
+      currentLoop.type === 'numbers'
+        ? true
+        : currentLoop.index <= currentLoop.data.length - 1;
+
+    if (
+      currentLoop &&
+      currentLoop.index < currentLoop.maxLoop - 1 &&
+      validLoopData
+    ) {
+      resolve({
+        data: '',
+        nextBlockId: currentLoop.blockId,
+      });
+    } else {
+      delete this.loopList[block.data.loopId];
+      delete this.loopData[block.data.loopId];
+
+      resolve({
+        data: prevBlockData,
+        nextBlockId: getBlockConnection(block),
+      });
+    }
+  });
+}
+
+export default loopBreakpoint;

+ 59 - 0
src/background/workflow-engine/blocks-handler/handler-loop-data.js

@@ -0,0 +1,59 @@
+import { generateJSON } from '@/utils/data-exporter';
+import { getBlockConnection } from '../helper';
+
+function loopData(block) {
+  return new Promise((resolve) => {
+    const { data } = block;
+
+    if (this.loopList[data.loopId]) {
+      this.loopList[data.loopId].index += 1;
+
+      let currentLoopData;
+
+      if (data.loopThrough === 'numbers') {
+        currentLoopData = this.loopData[data.loopId] + 1;
+      } else {
+        currentLoopData =
+          this.loopList[data.loopId].data[this.loopList[data.loopId].index];
+      }
+
+      this.loopData[data.loopId] = currentLoopData;
+    } else {
+      let currLoopData;
+
+      switch (data.loopThrough) {
+        case 'numbers':
+          currLoopData = data.fromNumber;
+          break;
+        case 'data-columns':
+          currLoopData = generateJSON(Object.keys(this.data), this.data);
+          break;
+        case 'custom-data':
+          currLoopData = JSON.parse(data.loopData);
+          break;
+        default:
+      }
+
+      this.loopList[data.loopId] = {
+        index: 0,
+        data: currLoopData,
+        id: data.loopId,
+        blockId: block.id,
+        type: data.loopThrough,
+        maxLoop:
+          data.loopThrough === 'numbers'
+            ? data.toNumber + 1 - data.fromNumber
+            : data.maxLoop || currLoopData.length,
+      };
+      /* eslint-disable-next-line */
+      this.loopData[data.loopId] = data.loopThrough === 'numbers' ? data.fromNumber : currLoopData[0];
+    }
+
+    resolve({
+      data: this.loopData[data.loopId],
+      nextBlockId: getBlockConnection(block),
+    });
+  });
+}
+
+export default loopData;

+ 79 - 0
src/background/workflow-engine/blocks-handler/handler-new-tab.js

@@ -0,0 +1,79 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+import executeContentScript from '../execute-content-script';
+
+function tabUpdatedListener(tab) {
+  return new Promise((resolve) => {
+    this._listener({
+      name: 'tab-updated',
+      id: tab.id,
+      callback: async (tabId, changeInfo, deleteListener) => {
+        if (changeInfo.status !== 'complete') return;
+
+        const frames = await executeContentScript(tabId, 'newtab');
+
+        deleteListener();
+
+        resolve(frames);
+      },
+    });
+  });
+}
+
+async function newTab(block) {
+  if (this.windowId) {
+    try {
+      await browser.windows.get(this.windowId);
+    } catch (error) {
+      this.windowId = null;
+    }
+  }
+
+  try {
+    const { updatePrevTab, url, active, inGroup } = block.data;
+
+    if (updatePrevTab && this.tabId) {
+      await browser.tabs.update(this.tabId, { url, active });
+    } else {
+      const tab = await browser.tabs.create({
+        url,
+        active,
+        windowId: this.windowId,
+      });
+
+      this.tabId = tab.id;
+      this.activeTabUrl = url;
+      this.windowId = tab.windowId;
+    }
+
+    if (inGroup && !updatePrevTab) {
+      const options = {
+        groupId: this.tabGroupId,
+        tabIds: this.tabId,
+      };
+
+      if (!this.tabGroupId) {
+        options.createProperties = {
+          windowId: this.windowId,
+        };
+      }
+
+      chrome.tabs.group(options, (tabGroupId) => {
+        this.tabGroupId = tabGroupId;
+      });
+    }
+
+    this.frameId = 0;
+    this.frames = await tabUpdatedListener.call(this, { id: this.tabId });
+
+    return {
+      data: url,
+      nextBlockId: getBlockConnection(block),
+    };
+  } catch (error) {
+    console.error(error);
+    throw error;
+  }
+}
+
+export default newTab;

+ 27 - 0
src/background/workflow-engine/blocks-handler/handler-new-window.js

@@ -0,0 +1,27 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+export async function newWindow(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const { incognito, windowState } = block.data;
+    const { id } = await browser.windows.create({
+      incognito,
+      state: windowState,
+    });
+
+    this.windowId = id;
+
+    return {
+      data: id,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default newWindow;

+ 60 - 0
src/background/workflow-engine/blocks-handler/handler-proxy.js

@@ -0,0 +1,60 @@
+import { isWhitespace } from '@/utils/helper';
+import { getBlockConnection } from '../helper';
+
+function setProxy({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  return new Promise((resolve, reject) => {
+    if (data.clearProxy) {
+      chrome.proxy.settings.clear({});
+    }
+
+    const config = {
+      mode: 'fixed_servers',
+      rules: {
+        singleProxy: {
+          scheme: data.scheme,
+        },
+        bypassList: isWhitespace(data.bypassList)
+          ? []
+          : data.bypassList.split(','),
+      },
+    };
+
+    if (!isWhitespace(data.host)) {
+      config.rules.singleProxy.host = data.host;
+    } else {
+      if (data.clearProxy) {
+        this.isUsingProxy = false;
+
+        resolve({
+          data: '',
+          nextBlockId,
+        });
+
+        return;
+      }
+
+      const error = new Error('invalid-proxy-host');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+      return;
+    }
+
+    if (data.port !== 0) {
+      config.rules.singleProxy.port = data.port;
+    }
+
+    chrome.proxy.settings.set({ value: config, scope: 'regular' }, () => {
+      this.isUsingProxy = true;
+
+      resolve({
+        data: data.host,
+        nextBlockId,
+      });
+    });
+  });
+}
+
+export default setProxy;

+ 23 - 0
src/background/workflow-engine/blocks-handler/handler-repeat-task.js

@@ -0,0 +1,23 @@
+import { getBlockConnection } from '../helper';
+
+function repeatTask({ data, id, outputs }) {
+  return new Promise((resolve) => {
+    if (this.repeatedTasks[id] >= data.repeatFor) {
+      delete this.repeatedTasks[id];
+
+      resolve({
+        data: data.repeatFor,
+        nextBlockId: getBlockConnection({ outputs }),
+      });
+    } else {
+      this.repeatedTasks[id] = (this.repeatedTasks[id] || 1) + 1;
+
+      resolve({
+        data: data.repeatFor,
+        nextBlockId: getBlockConnection({ outputs }, 2),
+      });
+    }
+  });
+}
+
+export default repeatTask;

+ 39 - 0
src/background/workflow-engine/blocks-handler/handler-switch-to.js

@@ -0,0 +1,39 @@
+import { objectHasKey } from '@/utils/helper';
+import { getBlockConnection } from '../helper';
+
+async function switchTo(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    if (block.data.windowType === 'main-window') {
+      this.frameId = 0;
+
+      return {
+        data: '',
+        nextBlockId,
+      };
+    }
+
+    const { url } = await this._sendMessageToTab(block, { frameId: 0 });
+
+    if (objectHasKey(this.frames, url)) {
+      this.frameId = this.frames[url];
+
+      return {
+        data: this.frameId,
+        nextBlockId,
+      };
+    }
+
+    const error = new Error('no-iframe-id');
+    error.data = { selector: block.selector };
+
+    throw error;
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default switchTo;

+ 70 - 0
src/background/workflow-engine/blocks-handler/handler-take-screenshot.js

@@ -0,0 +1,70 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+import { fileSaver } from '@/utils/helper';
+
+function saveImage({ fileName, uri, ext }) {
+  const image = new Image();
+
+  image.onload = () => {
+    const name = `${fileName || 'Screenshot'}.${ext || 'png'}`;
+    const canvas = document.createElement('canvas');
+    canvas.width = image.width;
+    canvas.height = image.height;
+
+    const context = canvas.getContext('2d');
+    context.drawImage(image, 0, 0);
+
+    fileSaver(name, canvas.toDataURL());
+  };
+
+  image.src = uri;
+}
+
+async function takeScreenshot(block) {
+  const nextBlockId = getBlockConnection(block);
+  const { ext, quality, captureActiveTab, fileName } = block.data;
+
+  try {
+    const options = {
+      quality,
+      format: ext || 'png',
+    };
+
+    if (captureActiveTab) {
+      if (!this.tabId) {
+        throw new Error('no-tab');
+      }
+
+      const [tab] = await browser.tabs.query({
+        active: true,
+        currentWindow: true,
+      });
+
+      await browser.windows.update(this.windowId, { focused: true });
+      await browser.tabs.update(this.tabId, { active: true });
+
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      const uri = await browser.tabs.captureVisibleTab(options);
+
+      if (tab) {
+        await browser.windows.update(tab.windowId, { focused: true });
+        await browser.tabs.update(tab.id, { active: true });
+      }
+
+      saveImage({ fileName, uri, ext });
+    } else {
+      const uri = await browser.tabs.captureVisibleTab(options);
+
+      saveImage({ fileName, uri, ext });
+    }
+
+    return { data: '', nextBlockId };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default takeScreenshot;

+ 20 - 0
src/background/workflow-engine/blocks-handler/handler-trigger.js

@@ -0,0 +1,20 @@
+import { getBlockConnection } from '../helper';
+
+async function trigger(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    if (block.data.type === 'visit-web' && this.tabId) {
+      this.frames = await executeContentScript(this.tabId, 'trigger');
+    }
+
+    return { nextBlockId, data: '' };
+  } catch (error) {
+    const errorInstance = new Error(error);
+    errorInstance.nextBlockId = nextBlockId;
+
+    throw errorInstance;
+  }
+}
+
+export default trigger;

+ 37 - 0
src/background/workflow-engine/blocks-handler/handler-webhook.js

@@ -0,0 +1,37 @@
+import { getBlockConnection } from '../helper';
+import { executeWebhook } from '@/utils/webhookUtil';
+
+function webhook({ data, outputs }) {
+  return new Promise((resolve, reject) => {
+    const nextBlockId = getBlockConnection({ outputs });
+
+    if (!data.url) {
+      const error = new Error('URL is empty');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+      return;
+    }
+
+    if (!data.url.startsWith('http')) {
+      const error = new Error('URL is not valid');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+      return;
+    }
+
+    executeWebhook(data)
+      .then(() => {
+        resolve({
+          data: '',
+          nextBlockId: getBlockConnection({ outputs }),
+        });
+      })
+      .catch((error) => {
+        reject(error);
+      });
+  });
+}
+
+export default webhook;

+ 393 - 0
src/background/workflow-engine/engine.js

@@ -0,0 +1,393 @@
+/* eslint-disable no-underscore-dangle */
+import browser from 'webextension-polyfill';
+import { nanoid } from 'nanoid';
+import { tasks } from '@/utils/shared';
+import { toCamelCase, parseJSON } from '@/utils/helper';
+import { generateJSON } from '@/utils/data-exporter';
+import errorMessage from './error-message';
+import referenceData from '@/utils/reference-data';
+import workflowState from '../workflow-state';
+import executeContentScript from './execute-content-script';
+
+let reloadTimeout;
+
+function tabRemovedHandler(tabId) {
+  if (tabId !== this.tabId) return;
+
+  delete this.tabId;
+
+  if (
+    this.currentBlock.name === 'new-tab' ||
+    tasks[this.currentBlock.name].category === 'interaction'
+  ) {
+    this.destroy('error', 'Current active tab is removed');
+  }
+
+  workflowState.update(this.id, this.state);
+}
+function tabUpdatedHandler(tabId, changeInfo, tab) {
+  const listener = this.tabUpdatedListeners[tabId];
+
+  if (listener) {
+    listener.callback(tabId, changeInfo, () => {
+      delete this.tabUpdatedListeners[tabId];
+    });
+  } else if (this.tabId === tabId) {
+    if (!reloadTimeout) {
+      reloadTimeout = setTimeout(() => {
+        this.isPaused = false;
+      }, 15000);
+    }
+
+    this.isPaused = true;
+
+    if (changeInfo.status === 'complete') {
+      clearTimeout(reloadTimeout);
+      reloadTimeout = null;
+
+      executeContentScript(tabId, 'update tab')
+        .then((frames) => {
+          this.tabId = tabId;
+          this.frames = frames;
+          this.isPaused = false;
+          this.activeTabUrl = tab?.url || '';
+        })
+        .catch((error) => {
+          console.error(error);
+          this.isPaused = false;
+        });
+    }
+  }
+}
+
+class WorkflowEngine {
+  constructor(
+    workflow,
+    {
+      globalData,
+      tabId = null,
+      isInCollection,
+      collectionLogId,
+      blocksHandler,
+      parentWorkflow,
+    }
+  ) {
+    const globalDataVal = globalData || workflow.globalData;
+
+    this.id = nanoid();
+    this.tabId = tabId;
+    this.workflow = workflow;
+    this.blocksHandler = blocksHandler;
+    this.isInCollection = isInCollection;
+    this.parentWorkflow = parentWorkflow;
+    this.collectionLogId = collectionLogId;
+    this.globalData = parseJSON(globalDataVal, globalDataVal);
+    this.activeTabUrl = '';
+    this.data = {};
+    this.logs = [];
+    this.blocks = {};
+    this.frames = {};
+    this.loopList = {};
+    this.loopData = {};
+    this.repeatedTasks = {};
+    this.eventListeners = {};
+    this.isPaused = false;
+    this.isDestroyed = false;
+    this.isUsingProxy = false;
+    this.frameId = null;
+    this.windowId = null;
+    this.tabGroupId = null;
+    this.currentBlock = null;
+    this.childWorkflow = null;
+    this.workflowTimeout = null;
+
+    this.tabUpdatedListeners = {};
+    this.tabUpdatedHandler = tabUpdatedHandler.bind(this);
+    this.tabRemovedHandler = tabRemovedHandler.bind(this);
+  }
+
+  init() {
+    if (this.workflow.isDisabled) return;
+
+    const drawflowData =
+      typeof this.workflow.drawflow === 'string'
+        ? JSON.parse(this.workflow.drawflow || '{}')
+        : this.workflow.drawflow;
+    const blocks = drawflowData?.drawflow.Home.data;
+
+    if (!blocks) {
+      console.error(errorMessage('no-block', this.workflow));
+      return;
+    }
+
+    const blocksArr = Object.values(blocks);
+    const triggerBlock = blocksArr.find(({ name }) => name === 'trigger');
+
+    if (!triggerBlock) {
+      console.error(errorMessage('no-trigger-block', this.workflow));
+      return;
+    }
+
+    browser.tabs.onUpdated.addListener(this.tabUpdatedHandler);
+    browser.tabs.onRemoved.addListener(this.tabRemovedHandler);
+
+    const dataColumns = Array.isArray(this.workflow.dataColumns)
+      ? this.workflow.dataColumns
+      : Object.values(this.workflow.dataColumns);
+
+    this.blocks = blocks;
+    this.startedTimestamp = Date.now();
+    this.workflow.dataColumns = dataColumns;
+    this.data = dataColumns.reduce(
+      (acc, column) => {
+        acc[column.name] = [];
+
+        return acc;
+      },
+      { column: [] }
+    );
+
+    workflowState
+      .add(this.id, {
+        state: this.state,
+        workflowId: this.workflow.id,
+        isInCollection: this.isInCollection,
+      })
+      .then(() => {
+        this._blockHandler(triggerBlock);
+      });
+  }
+
+  on(name, listener) {
+    (this.eventListeners[name] = this.eventListeners[name] || []).push(
+      listener
+    );
+  }
+
+  pause(pause = true) {
+    this.isPaused = pause;
+
+    workflowState.update(this.id, this.state);
+  }
+
+  async stop(message) {
+    try {
+      if (this.childWorkflow) {
+        await this.childWorkflow.stop();
+      }
+
+      this.logs.push({
+        message,
+        type: 'stop',
+        name: 'stop',
+      });
+
+      await this.destroy('stopped');
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  async destroy(status, message) {
+    try {
+      if (this.isUsingProxy) chrome.proxy.settings.clear({});
+
+      await browser.tabs.onRemoved.removeListener(this.tabRemovedHandler);
+      await browser.tabs.onUpdated.removeListener(this.tabUpdatedHandler);
+
+      await workflowState.delete(this.id);
+
+      clearTimeout(this.workflowTimeout);
+      this.isDestroyed = true;
+      this.endedTimestamp = Date.now();
+
+      if (!this.workflow.isTesting) {
+        const { logs } = await browser.storage.local.get('logs');
+        const { name, icon, id } = this.workflow;
+        const jsonData = generateJSON(Object.keys(this.data), this.data);
+
+        logs.push({
+          name,
+          icon,
+          status,
+          id: this.id,
+          workflowId: id,
+          data: jsonData,
+          history: this.logs,
+          endedAt: this.endedTimestamp,
+          startedAt: this.startedTimestamp,
+          isChildLog: !!this.parentWorkflow,
+          isInCollection: this.isInCollection,
+          collectionLogId: this.collectionLogId,
+        });
+
+        await browser.storage.local.set({ logs });
+      }
+
+      this.dispatchEvent('destroyed', {
+        id: this.id,
+        status,
+        message,
+        currentBlock: this.currentBlock,
+      });
+
+      this.eventListeners = {};
+      this.tabUpdatedListeners = {};
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  dispatchEvent(name, params) {
+    const listeners = this.eventListeners[name];
+
+    if (!listeners) return;
+
+    listeners.forEach((callback) => {
+      callback(params);
+    });
+  }
+
+  get state() {
+    const keys = [
+      'tabId',
+      'isPaused',
+      'isDestroyed',
+      'currentBlock',
+      'isInCollection',
+      'startedTimestamp',
+    ];
+    const state = keys.reduce((acc, key) => {
+      acc[key] = this[key];
+
+      return acc;
+    }, {});
+
+    state.name = this.workflow.name;
+    state.icon = this.workflow.icon;
+
+    if (this.parentWorkflow) state.parentState = this.parentWorkflow;
+
+    return state;
+  }
+
+  _blockHandler(block, prevBlockData) {
+    if (this.isDestroyed) return;
+    if (this.isPaused) {
+      setTimeout(() => {
+        this._blockHandler(block, prevBlockData);
+      }, 1000);
+
+      return;
+    }
+
+    const disableTimeoutKeys = ['delay', 'javascript-code'];
+
+    if (!disableTimeoutKeys.includes(block.name)) {
+      this.workflowTimeout = setTimeout(() => {
+        alert('timeout');
+        if (!this.isDestroyed) this.stop('stop-timeout');
+      }, this.workflow.settings.timeout || 120000);
+    }
+
+    this.currentBlock = block;
+
+    workflowState.update(this.id, this.state);
+    this.dispatchEvent('update', this.state);
+
+    const started = Date.now();
+    const blockHandler = this.blocksHandler[toCamelCase(block?.name)];
+    const handler =
+      !blockHandler && tasks[block.name].category === 'interaction'
+        ? this.blocksHandler.interactionBlock
+        : blockHandler;
+
+    if (handler) {
+      const replacedBlock = referenceData(block, {
+        prevBlockData,
+        data: this.data,
+        loopData: this.loopData,
+        globalData: this.globalData,
+        activeTabUrl: this.activeTabUrl,
+      });
+
+      handler
+        .call(this, replacedBlock, prevBlockData)
+        .then((result) => {
+          clearTimeout(this.workflowTimeout);
+          this.workflowTimeout = null;
+          this.logs.push({
+            type: 'success',
+            name: block.name,
+            logId: result.logId,
+            duration: Math.round(Date.now() - started),
+          });
+
+          if (result.nextBlockId) {
+            this._blockHandler(this.blocks[result.nextBlockId], result.data);
+          } else {
+            this.logs.push({
+              type: 'finish',
+              name: 'finish',
+            });
+            this.dispatchEvent('finish');
+            this.destroy('success');
+          }
+        })
+        .catch((error) => {
+          this.logs.push({
+            type: 'error',
+            message: error.message,
+            name: block.name,
+            ...(error.data || {}),
+          });
+
+          if (
+            this.workflow.settings.onError === 'keep-running' &&
+            error.nextBlockId
+          ) {
+            this._blockHandler(
+              this.blocks[error.nextBlockId],
+              error.data || ''
+            );
+          } else {
+            this.destroy('error', error.message);
+          }
+
+          clearTimeout(this.workflowTimeout);
+          this.workflowTimeout = null;
+
+          console.error(error);
+        });
+    } else {
+      console.error(`"${block.name}" block doesn't have a handler`);
+    }
+  }
+
+  _sendMessageToTab(payload, options = {}) {
+    return new Promise((resolve, reject) => {
+      if (!this.tabId) {
+        const error = new Error('no-tab');
+        error.workflowId = this.id;
+
+        reject(error);
+        return;
+      }
+
+      browser.tabs
+        .sendMessage(this.tabId, { isBlock: true, ...payload }, options)
+        .then(resolve)
+        .catch(reject);
+    });
+  }
+
+  _listener({ id, name, callback, once = true, ...options }) {
+    const listenerNames = {
+      event: 'eventListener',
+      'tab-updated': 'tabUpdatedListeners',
+    };
+    this[listenerNames[name]][id] = { callback, once, ...options };
+  }
+}
+
+export default WorkflowEngine;

+ 0 - 0
src/utils/execute-content-script.js → src/background/workflow-engine/execute-content-script.js


+ 24 - 0
src/background/workflow-engine/helper.js

@@ -0,0 +1,24 @@
+export function convertData(data, type) {
+  let result = data;
+
+  switch (type) {
+    case 'integer':
+      result = +data.replace(/\D+/g, '');
+      break;
+    case 'boolean':
+      result = Boolean(data);
+      break;
+    case 'array':
+      result = Array.from(data);
+      break;
+    default:
+  }
+
+  return result;
+}
+
+export function getBlockConnection(block, index = 1) {
+  const blockId = block.outputs[`output_${index}`]?.connections[0]?.node;
+
+  return blockId;
+}

+ 5 - 355
src/background/workflow-engine/index.js

@@ -1,358 +1,8 @@
-/* eslint-disable no-underscore-dangle */
-import browser from 'webextension-polyfill';
-import { nanoid } from 'nanoid';
-import { tasks } from '@/utils/shared';
-import { toCamelCase, parseJSON } from '@/utils/helper';
-import { generateJSON } from '@/utils/data-exporter';
-import errorMessage from './error-message';
-import referenceData from '@/utils/reference-data';
-import workflowState from '../workflow-state';
-import * as blocksHandler from './blocks-handler';
-import executeContentScript from '@/utils/execute-content-script';
+import Engine from './engine';
+import blocksHandler from './blocks-handler';
 
-let reloadTimeout;
+export default function (workflow, options = {}) {
+  const engine = new Engine(workflow, { ...options, blocksHandler });
 
-function tabRemovedHandler(tabId) {
-  if (tabId !== this.tabId) return;
-
-  delete this.tabId;
-
-  if (
-    this.currentBlock.name === 'new-tab' ||
-    tasks[this.currentBlock.name].category === 'interaction'
-  ) {
-    this.destroy('error', 'Current active tab is removed');
-  }
-
-  workflowState.update(this.id, this.state);
-}
-function tabUpdatedHandler(tabId, changeInfo, tab) {
-  const listener = this.tabUpdatedListeners[tabId];
-
-  if (listener) {
-    listener.callback(tabId, changeInfo, () => {
-      delete this.tabUpdatedListeners[tabId];
-    });
-  } else if (this.tabId === tabId) {
-    if (!reloadTimeout) {
-      reloadTimeout = setTimeout(() => {
-        this.isPaused = false;
-      }, 15000);
-    }
-
-    this.isPaused = true;
-
-    if (changeInfo.status === 'complete') {
-      clearTimeout(reloadTimeout);
-      reloadTimeout = null;
-
-      executeContentScript(tabId, 'update tab')
-        .then((frames) => {
-          this.tabId = tabId;
-          this.frames = frames;
-          this.isPaused = false;
-          this.activeTabUrl = tab?.url || '';
-        })
-        .catch((error) => {
-          console.error(error);
-          this.isPaused = false;
-        });
-    }
-  }
-}
-
-class WorkflowEngine {
-  constructor(
-    workflow,
-    { globalData, tabId = null, isInCollection, collectionLogId }
-  ) {
-    const globalDataVal = globalData || workflow.globalData;
-
-    this.id = nanoid();
-    this.tabId = tabId;
-    this.workflow = workflow;
-    this.isInCollection = isInCollection;
-    this.collectionLogId = collectionLogId;
-    this.globalData = parseJSON(globalDataVal, globalDataVal);
-    this.activeTabUrl = '';
-    this.data = {};
-    this.logs = [];
-    this.blocks = {};
-    this.frames = {};
-    this.loopList = {};
-    this.loopData = {};
-    this.repeatedTasks = {};
-    this.eventListeners = {};
-    this.isPaused = false;
-    this.isDestroyed = false;
-    this.frameId = null;
-    this.windowId = null;
-    this.tabGroupId = null;
-    this.currentBlock = null;
-    this.workflowTimeout = null;
-
-    this.tabUpdatedListeners = {};
-    this.tabUpdatedHandler = tabUpdatedHandler.bind(this);
-    this.tabRemovedHandler = tabRemovedHandler.bind(this);
-  }
-
-  init() {
-    if (this.workflow.isDisabled) return;
-
-    const drawflowData =
-      typeof this.workflow.drawflow === 'string'
-        ? JSON.parse(this.workflow.drawflow || '{}')
-        : this.workflow.drawflow;
-    const blocks = drawflowData?.drawflow.Home.data;
-
-    if (!blocks) {
-      console.error(errorMessage('no-block', this.workflow));
-      return;
-    }
-
-    const blocksArr = Object.values(blocks);
-    const triggerBlock = blocksArr.find(({ name }) => name === 'trigger');
-
-    if (!triggerBlock) {
-      console.error(errorMessage('no-trigger-block', this.workflow));
-      return;
-    }
-
-    browser.tabs.onUpdated.addListener(this.tabUpdatedHandler);
-    browser.tabs.onRemoved.addListener(this.tabRemovedHandler);
-
-    const dataColumns = Array.isArray(this.workflow.dataColumns)
-      ? this.workflow.dataColumns
-      : Object.values(this.workflow.dataColumns);
-
-    this.blocks = blocks;
-    this.startedTimestamp = Date.now();
-    this.workflow.dataColumns = dataColumns;
-    this.data = dataColumns.reduce(
-      (acc, column) => {
-        acc[column.name] = [];
-
-        return acc;
-      },
-      { column: [] }
-    );
-
-    workflowState
-      .add(this.id, {
-        state: this.state,
-        workflowId: this.workflow.id,
-        isInCollection: this.isInCollection,
-      })
-      .then(() => {
-        this._blockHandler(triggerBlock);
-      });
-  }
-
-  on(name, listener) {
-    (this.eventListeners[name] = this.eventListeners[name] || []).push(
-      listener
-    );
-  }
-
-  pause(pause = true) {
-    this.isPaused = pause;
-
-    workflowState.update(this.id, this.state);
-  }
-
-  stop(message) {
-    this.logs.push({
-      message,
-      type: 'stop',
-      name: 'stop',
-    });
-    this.destroy('stopped');
-  }
-
-  async destroy(status, message) {
-    try {
-      this.dispatchEvent('destroyed', { id: this.id, status, message });
-
-      this.eventListeners = {};
-      this.tabUpdatedListeners = {};
-
-      await browser.tabs.onRemoved.removeListener(this.tabRemovedHandler);
-      await browser.tabs.onUpdated.removeListener(this.tabUpdatedHandler);
-
-      await workflowState.delete(this.id);
-
-      clearTimeout(this.workflowTimeout);
-      this.isDestroyed = true;
-      this.endedTimestamp = Date.now();
-
-      if (!this.workflow.isTesting) {
-        const { logs } = await browser.storage.local.get('logs');
-        const { name, icon, id } = this.workflow;
-        const jsonData = generateJSON(Object.keys(this.data), this.data);
-
-        logs.push({
-          name,
-          icon,
-          status,
-          id: this.id,
-          workflowId: id,
-          data: jsonData,
-          history: this.logs,
-          endedAt: this.endedTimestamp,
-          startedAt: this.startedTimestamp,
-          isInCollection: this.isInCollection,
-          collectionLogId: this.collectionLogId,
-        });
-
-        await browser.storage.local.set({ logs });
-      }
-    } catch (error) {
-      console.error(error);
-    }
-  }
-
-  dispatchEvent(name, params) {
-    const listeners = this.eventListeners[name];
-
-    if (!listeners) return;
-
-    listeners.forEach((callback) => {
-      callback(params);
-    });
-  }
-
-  get state() {
-    const keys = [
-      'tabId',
-      'isPaused',
-      'isDestroyed',
-      'currentBlock',
-      'isInCollection',
-      'startedTimestamp',
-    ];
-    const state = keys.reduce((acc, key) => {
-      acc[key] = this[key];
-
-      return acc;
-    }, {});
-
-    state.name = this.workflow.name;
-    state.icon = this.workflow.icon;
-
-    return state;
-  }
-
-  _blockHandler(block, prevBlockData) {
-    if (this.isDestroyed) return;
-    if (this.isPaused) {
-      setTimeout(() => {
-        this._blockHandler(block, prevBlockData);
-      }, 1000);
-
-      return;
-    }
-
-    const disableTimeoutKeys = ['delay', 'javascript-code'];
-
-    if (!disableTimeoutKeys.includes(block.name)) {
-      this.workflowTimeout = setTimeout(() => {
-        if (!this.isDestroyed) this.stop('stop-timeout');
-      }, this.workflow.settings.timeout || 120000);
-    }
-
-    this.currentBlock = block;
-
-    workflowState.update(this.id, this.state);
-    this.dispatchEvent('update', this.state);
-
-    const started = Date.now();
-    const blockHandler = blocksHandler[toCamelCase(block?.name)];
-    const handler =
-      !blockHandler && tasks[block.name].category === 'interaction'
-        ? blocksHandler.interactionHandler
-        : blockHandler;
-
-    if (handler) {
-      const replacedBlock = referenceData(block, {
-        prevBlockData,
-        data: this.data,
-        loopData: this.loopData,
-        globalData: this.globalData,
-        activeTabUrl: this.activeTabUrl,
-      });
-
-      handler
-        .call(this, replacedBlock, prevBlockData)
-        .then((result) => {
-          clearTimeout(this.workflowTimeout);
-          this.workflowTimeout = null;
-          this.logs.push({
-            type: 'success',
-            name: block.name,
-            duration: Math.round(Date.now() - started),
-          });
-
-          if (result.nextBlockId) {
-            this._blockHandler(this.blocks[result.nextBlockId], result.data);
-          } else {
-            this.logs.push({
-              type: 'finish',
-              name: 'finish',
-            });
-            this.dispatchEvent('finish');
-            this.destroy('success');
-          }
-        })
-        .catch((error) => {
-          this.logs.push({
-            type: 'error',
-            message: error.message,
-            name: block.name,
-          });
-
-          if (
-            this.workflow.settings.onError === 'keep-running' &&
-            error.nextBlockId
-          ) {
-            this._blockHandler(
-              this.blocks[error.nextBlockId],
-              error.data || ''
-            );
-          } else {
-            this.destroy('error', error.message);
-          }
-
-          clearTimeout(this.workflowTimeout);
-          this.workflowTimeout = null;
-
-          console.error(error);
-        });
-    } else {
-      console.error(`"${block.name}" block doesn't have a handler`);
-    }
-  }
-
-  _sendMessageToTab(payload, options = {}) {
-    return new Promise((resolve, reject) => {
-      if (!this.tabId) {
-        reject(new Error('no-tab'));
-        return;
-      }
-
-      browser.tabs
-        .sendMessage(this.tabId, { isBlock: true, ...payload }, options)
-        .then(resolve)
-        .catch(reject);
-    });
-  }
-
-  _listener({ id, name, callback, once = true, ...options }) {
-    const listenerNames = {
-      event: 'eventListener',
-      'tab-updated': 'tabUpdatedListeners',
-    };
-    this[listenerNames[name]][id] = { callback, once, ...options };
-  }
+  return engine;
 }
-
-export default WorkflowEngine;

+ 22 - 12
src/components/newtab/app/AppSidebar.vue

@@ -70,22 +70,15 @@
       </template>
       <ui-list class="space-y-1">
         <ui-list-item
+          v-for="item in links"
+          :key="item.name"
+          :href="item.url"
           tag="a"
-          href="https://github.com/kholid060/automa/wiki"
           rel="noopener"
           target="_blank"
         >
-          <v-remixicon name="riBookOpenLine" class="-ml-1 mr-2" />
-          <span>{{ t('common.docs', 2) }}</span>
-        </ui-list-item>
-        <ui-list-item
-          tag="a"
-          href="https://github.com/kholid060/automa"
-          rel="noopener"
-          target="_blank"
-        >
-          <v-remixicon name="riGithubFill" class="-ml-1 mr-2" />
-          <span>GitHub</span>
+          <v-remixicon :name="item.icon" class="-ml-1 mr-2" />
+          <span>{{ item.name }}</span>
         </ui-list-item>
       </ui-list>
     </ui-popover>
@@ -99,6 +92,23 @@ import { useGroupTooltip } from '@/composable/groupTooltip';
 useGroupTooltip();
 const { t } = useI18n();
 
+const links = [
+  {
+    name: 'Donate',
+    icon: 'riHandHeartLine',
+    url: 'https://paypal.me/akholid060',
+  },
+  {
+    name: t('common.docs', 2),
+    icon: 'riBookOpenLine',
+    url: 'https://github.com/kholid060/automa/wiki',
+  },
+  {
+    name: 'GitHub',
+    icon: 'riGithubFill',
+    url: 'https://github.com/kholid060/automa',
+  },
+];
 const tabs = [
   {
     id: 'dashboard',

+ 9 - 1
src/components/newtab/shared/SharedCard.vue

@@ -2,7 +2,14 @@
   <ui-card class="hover:ring-2 group hover:ring-accent">
     <slot name="header">
       <div class="flex items-center mb-4">
-        <span class="p-2 rounded-lg bg-box-transparent">
+        <ui-img
+          v-if="data.icon.startsWith('http')"
+          :src="data.icon"
+          class="overflow-hidden rounded-lg"
+          style="height: 40px; width: 40px"
+          alt="Can not display"
+        />
+        <span v-else class="p-2 rounded-lg bg-box-transparent">
           <v-remixicon :name="data.icon || icon" />
         </span>
         <div class="flex-grow"></div>
@@ -62,6 +69,7 @@ const props = defineProps({
     default: () => [],
   },
 });
+
 defineEmits(['execute', 'click', 'menuSelected']);
 
 let formattedDate = null;

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

@@ -22,7 +22,11 @@
       >
         <v-remixicon name="riExternalLinkLine" />
       </ui-button>
-      <ui-button variant="accent" @click="stopWorkflow">
+      <ui-button
+        variant="accent"
+        :disabled="!!data.state.parentState"
+        @click="stopWorkflow"
+      >
         <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
         <span>{{ t('common.stop') }}</span>
       </ui-button>
@@ -38,6 +42,12 @@
         <ui-spinner color="text-accnet" size="20" />
       </div>
     </div>
+    <div
+      v-if="data.state.parentState"
+      class="py-2 px-4 bg-yellow-200 rounded-lg mt-2 text-sm"
+    >
+      {{ t('workflow.state.executeBy', { name: data.state.parentState.name }) }}
+    </div>
   </ui-card>
 </template>
 <script setup>

+ 34 - 13
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -1,24 +1,45 @@
 <template>
   <div class="px-4 flex items-center mb-2 mt-1">
-    <ui-popover class="mr-2 h-6">
+    <ui-popover class="mr-2 h-8">
       <template #trigger>
         <span
           :title="t('workflow.sidebar.workflowIcon')"
-          class="cursor-pointer"
+          class="cursor-pointer inline-block h-full"
         >
-          <v-remixicon :name="workflow.icon" size="26" />
+          <ui-img
+            v-if="workflow.icon.startsWith('http')"
+            :src="workflow.icon"
+            class="w-8 h-8"
+          />
+          <v-remixicon v-else :name="workflow.icon" size="26" class="mt-1" />
         </span>
       </template>
-      <p class="mb-2">{{ t('workflow.sidebar.workflowIcon') }}</p>
-      <div class="grid grid-cols-4 gap-1">
-        <span
-          v-for="icon in icons"
-          :key="icon"
-          class="cursor-pointer rounded-lg inline-block p-2 hoverable"
-          @click="$emit('update', { icon })"
-        >
-          <v-remixicon :name="icon" />
-        </span>
+      <div class="w-56">
+        <p class="mb-2">{{ t('workflow.sidebar.workflowIcon') }}</p>
+        <div class="grid grid-cols-5 mb-2 gap-1">
+          <span
+            v-for="icon in icons"
+            :key="icon"
+            class="
+              cursor-pointer
+              rounded-lg
+              inline-block
+              text-center
+              p-2
+              hoverable
+            "
+            @click="$emit('update', { icon })"
+          >
+            <v-remixicon :name="icon" />
+          </span>
+        </div>
+        <ui-input
+          :model-value="workflow.icon.startsWith('ri') ? '' : workflow.icon"
+          type="url"
+          placeholder="http://example.com/img.png"
+          label="Icon URL"
+          @change="$emit('update', { icon: $event })"
+        />
       </div>
     </ui-popover>
     <p class="font-semibold text-overflow inline-block text-lg flex-1 mr-4">

+ 89 - 0
src/components/newtab/workflow/edit/EditExecuteWorkflow.vue

@@ -0,0 +1,89 @@
+<template>
+  <div>
+    <ui-select
+      :model-value="data.workflowId"
+      :placeholder="t('workflow.blocks.execute-workflow.select')"
+      class="w-full mb-4"
+      @change="updateData({ workflowId: $event })"
+    >
+      <option
+        v-for="workflow in workflows"
+        :key="workflow.id"
+        :value="workflow.id"
+      >
+        {{ workflow.name }}
+      </option>
+    </ui-select>
+    <p>{{ t('common.globalData') }}</p>
+    <prism-editor
+      v-if="!state.showGlobalData"
+      :model-value="data.globalData"
+      :highlight="highlighter('json')"
+      readonly
+      class="p-4 max-h-80"
+      @click="state.showGlobalData = true"
+    />
+    <ui-modal
+      v-model="state.showGlobalData"
+      title="Global data"
+      content-class="max-w-xl"
+    >
+      <p>{{ t('workflow.blocks.execute-workflow.overwriteNote') }}</p>
+      <prism-editor
+        :model-value="state.globalData"
+        :highlight="highlighter('json')"
+        class="w-full scroll"
+        style="height: calc(100vh - 10rem)"
+        @input="updateGlobalData"
+      />
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { computed, shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRoute } from 'vue-router';
+import { PrismEditor } from 'vue-prism-editor';
+import { highlighter } from '@/lib/prism';
+import Workflow from '@/models/workflow';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const route = useRoute();
+
+const state = shallowReactive({
+  showGlobalData: false,
+  globalData: `${props.data.globalData}`,
+});
+
+const workflows = computed(() =>
+  Workflow.query()
+    .where(
+      ({ id, drawflow }) =>
+        id !== route.params.id && !drawflow.includes(route.params.id)
+    )
+    .orderBy('name', 'asc')
+    .get()
+);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function updateGlobalData(event) {
+  const { value } = event.target;
+
+  state.globalData = value;
+  updateData({ globalData: value });
+}
+</script>

+ 83 - 36
src/components/newtab/workflow/edit/EditForms.vue

@@ -1,50 +1,92 @@
 <template>
-  <edit-interaction-base v-bind="{ data }" @change="updateData">
-    <ui-select
-      :model-value="data.type"
-      class="block w-full mt-4 mb-3"
-      :placeholder="t('workflow.blocks.forms.type')"
-      @change="updateData({ type: $event })"
-    >
-      <option v-for="form in forms" :key="form" :value="form">
-        {{ t(`workflow.blocks.forms.${form}.name`) }}
-      </option>
-    </ui-select>
+  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
     <ui-checkbox
-      v-if="data.type === 'checkbox' || data.type === 'radio'"
-      :model-value="data.selected"
-      @change="updateData({ selected: $event })"
+      :model-value="data.getValue"
+      class="my-2"
+      @change="updateData({ getValue: $event })"
     >
-      {{ t('workflow.blocks.forms.selected') }}
+      {{ t('workflow.blocks.forms.getValue') }}
     </ui-checkbox>
-    <template v-if="data.type === 'text-field' || data.type === 'select'">
-      <ui-textarea
-        :model-value="data.value"
-        :placeholder="t('workflow.blocks.forms.text-field.value')"
-        class="w-full"
-        @change="updateData({ value: $event })"
-      />
+    <template v-if="data.getValue">
       <ui-checkbox
-        :model-value="data.clearValue"
-        class="mb-1 ml-1"
-        @change="updateData({ clearValue: $event })"
+        :model-value="data.saveData"
+        class="mb-2 ml-2"
+        @change="updateData({ saveData: $event })"
       >
-        {{ t('workflow.blocks.forms.text-field.clearValue') }}
+        Save data
       </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)"
+        >
+          <v-remixicon name="riKey2Line" />
+        </ui-button>
+      </div>
+    </template>
+    <template v-else>
+      <ui-select
+        :model-value="data.type"
+        class="block w-full mb-3"
+        :placeholder="t('workflow.blocks.forms.type')"
+        @change="updateData({ type: $event })"
+      >
+        <option v-for="form in forms" :key="form" :value="form">
+          {{ t(`workflow.blocks.forms.${form}.name`) }}
+        </option>
+      </ui-select>
+      <ui-checkbox
+        v-if="data.type === 'checkbox' || data.type === 'radio'"
+        :model-value="data.selected"
+        @change="updateData({ selected: $event })"
+      >
+        {{ t('workflow.blocks.forms.selected') }}
+      </ui-checkbox>
+      <template v-if="data.type === 'text-field' || data.type === 'select'">
+        <ui-textarea
+          :model-value="data.value"
+          :placeholder="t('workflow.blocks.forms.text-field.value')"
+          class="w-full"
+          @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') }}
+        </ui-checkbox>
+      </template>
+      <ui-input
+        v-if="data.type === 'text-field'"
+        :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"
+        min="0"
+        type="number"
+        @change="updateData({ delay: +$event })"
+      />
     </template>
-    <ui-input
-      v-if="data.type === 'text-field'"
-      :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"
-      min="0"
-      type="number"
-      @change="updateData({ delay: +$event })"
-    />
   </edit-interaction-base>
 </template>
 <script setup>
+import { inject } from 'vue';
 import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 
@@ -53,10 +95,15 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
 });
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
+const workflow = inject('workflow');
 
 const forms = ['text-field', 'select', 'checkbox', 'radio'];
 

+ 56 - 29
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -1,42 +1,57 @@
 <template>
   <div>
     <slot name="prepend" />
-    <ui-textarea
-      :model-value="data.description"
-      :placeholder="t('common.description')"
-      autoresize
-      class="w-full mb-2"
-      @change="updateData({ description: $event })"
-    />
-    <ui-input
-      v-if="!hideSelector"
-      :model-value="data.selector"
-      :placeholder="t('workflow.blocks.base.selector')"
-      class="mb-1 w-full"
-      @change="updateData({ selector: $event })"
-    />
-    <template v-if="!hideSelector">
-      <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 })"
+    <template v-if="!hide">
+      <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.findBy || 'cssSelector'"
+        :placeholder="t('workflow.blocks.base.findElement.placeholder')"
+        class="w-full mb-2"
+        @change="updateData({ findBy: $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 })"
+        <option v-for="type in selectorTypes" :key="type" :value="type">
+          {{ t(`workflow.blocks.base.findElement.options.${type}`) }}
+        </option>
+      </ui-select>
+      <ui-input
+        v-if="!hideSelector"
+        :model-value="data.selector"
+        :placeholder="t('workflow.blocks.base.selector')"
+        class="mb-1 w-full"
+        @change="updateData({ selector: $event })"
+      />
+      <template
+        v-if="!hideSelector && (data.findBy || 'cssSelector') === 'cssSelector'"
       >
-        {{ t('workflow.blocks.base.markElement.text') }}
-      </ui-checkbox>
+        <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>
+      </template>
     </template>
     <slot></slot>
   </div>
 </template>
 <script setup>
+import { onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
 
 const props = defineProps({
@@ -44,6 +59,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  hide: {
+    type: Boolean,
+    default: false,
+  },
   hideSelector: {
     type: Boolean,
     default: false,
@@ -57,10 +76,18 @@ const emit = defineEmits(['update:data', 'change']);
 
 const { t } = useI18n();
 
+const selectorTypes = ['cssSelector', 'xpath'];
+
 function updateData(value) {
   const payload = { ...props.data, ...value };
 
   emit('update:data', payload);
   emit('change', payload);
 }
+
+onMounted(() => {
+  if (!props.data.findBy) {
+    updateData({ findBy: 'cssSelector' });
+  }
+});
 </script>

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

@@ -95,8 +95,8 @@
 </template>
 <script setup>
 import { watch, reactive } from 'vue';
-import { PrismEditor } from 'vue-prism-editor';
 import { useI18n } from 'vue-i18n';
+import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 
 const props = defineProps({

+ 29 - 1
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -29,6 +29,7 @@
       </option>
     </ui-select>
     <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')"
@@ -45,6 +46,32 @@
     >
       {{ t('workflow.blocks.loop-data.buttons.insert') }}
     </ui-button>
+    <div
+      v-else-if="data.loopThrough === 'numbers'"
+      class="flex items-center space-x-2"
+    >
+      <ui-input
+        :model-value="data.fromNumber"
+        :label="t('workflow.blocks.loop-data.loopThrough.fromNumber')"
+        type="number"
+        @change="
+          updateData({
+            fromNumber: +$event >= data.toNumber ? data.toNumber - 1 : +$event,
+          })
+        "
+      />
+      <ui-input
+        :model-value="data.toNumber"
+        :label="t('workflow.blocks.loop-data.loopThrough.toNumber')"
+        type="number"
+        @change="
+          updateData({
+            toNumber:
+              +$event <= data.fromNumber ? data.fromNumber + 1 : +$event,
+          })
+        "
+      />
+    </div>
     <ui-modal
       v-model="state.showDataModal"
       title="Data"
@@ -94,6 +121,7 @@
   </div>
 </template>
 <script setup>
+/* eslint-disable no-alert */
 import { onMounted, shallowReactive } from 'vue';
 import { nanoid } from 'nanoid';
 import { PrismEditor } from 'vue-prism-editor';
@@ -118,7 +146,7 @@ const { t } = useI18n();
 
 const maxStrLength = 5e4;
 const maxFileSize = 1024 * 1024;
-const loopTypes = ['data-columns', 'custom-data'];
+const loopTypes = ['data-columns', 'numbers', 'custom-data'];
 const tempLoopData =
   props.data.loopData.length > maxStrLength
     ? props.data.loopData.slice(0, maxStrLength)

+ 77 - 0
src/components/newtab/workflow/edit/EditProxy.vue

@@ -0,0 +1,77 @@
+<template>
+  <div>
+    <div class="flex items-center mb-1">
+      <ui-select
+        :model-value="data.scheme"
+        label="Scheme"
+        class="mr-2"
+        @change="updateData({ scheme: $event })"
+      >
+        <option v-for="scheme in schemes" :key="scheme" :value="scheme">
+          {{ scheme.toUpperCase() }}
+        </option>
+      </ui-select>
+      <ui-input
+        :model-value="data.port"
+        label="Port"
+        placeholder="443"
+        class="flex-1"
+        type="number"
+        @change="updateData({ port: +$event })"
+      />
+    </div>
+    <ui-input
+      :model-value="data.host"
+      label="Host"
+      placeholder="1.2.3.4"
+      class="w-full mb-2"
+      @change="updateData({ host: $event })"
+    />
+    <ui-input
+      :model-value="data.bypassList"
+      placeholder="example1.com, example2.org"
+      class="w-full"
+      @change="updateData({ bypassList: $event })"
+    >
+      <template #label>
+        {{ t('workflow.blocks.proxy.bypass.label') }}
+        <a
+          href="https://developer.chrome.com/docs/extensions/reference/proxy/#bypass-list"
+          target="_blank"
+          rel="noopener"
+        >
+          &#128712;
+        </a>
+      </template>
+    </ui-input>
+    <p class="text-gray-600 dark:text-gray-200 text-sm">
+      {{ t('workflow.blocks.proxy.bypass.note') }}
+    </p>
+    <ui-checkbox
+      :model-value="data.clearProxy"
+      class="mt-4"
+      @change="updateData({ clearProxy: $event })"
+    >
+      {{ t('workflow.blocks.proxy.clear') }}
+    </ui-checkbox>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+const schemes = Object.values(chrome.proxy.Scheme);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 5 - 1
src/components/newtab/workflow/edit/EditScrollElement.vue

@@ -1,5 +1,5 @@
 <template>
-  <edit-interaction-base v-bind="{ data }" @change="updateData">
+  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
     <div v-if="!data.scrollIntoView" class="flex items-center mt-3 space-x-2">
       <ui-input
         :model-value="data.scrollX || 0"
@@ -54,6 +54,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 18 - 30
src/components/newtab/workflow/edit/EditTriggerEvent.vue

@@ -1,5 +1,5 @@
 <template>
-  <edit-interaction-base v-bind="{ data }" @change="updateData">
+  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
     <ui-select
       :model-value="data.eventName"
       :placeholder="t('workflow.blocks.trigger-event.selectEvent')"
@@ -46,8 +46,8 @@
           </ui-checkbox>
         </div>
         <component
-          :is="componentName"
-          v-if="componentName"
+          :is="eventComponents[data.eventType]"
+          v-if="eventComponents[data.eventType]"
           :params="params"
           @update="updateParams({ ...params, ...$event })"
         />
@@ -55,52 +55,42 @@
     </transition-expand>
   </edit-interaction-base>
 </template>
-<script>
-import TriggerEventMouse from './TriggerEventMouse.vue';
-import TriggerEventTouch from './TriggerEventTouch.vue';
-import TriggerEventWheel from './TriggerEventWheel.vue';
-import TriggerEventInput from './TriggerEventInput.vue';
-import TriggerEventKeyboard from './TriggerEventKeyboard.vue';
-
-export default {
-  components: {
-    TriggerEventMouse,
-    TriggerEventWheel,
-    TriggerEventTouch,
-    TriggerEventInput,
-    TriggerEventKeyboard,
-  },
-};
-</script>
 <script setup>
-/* eslint-disable */
 import { ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { eventList } from '@/utils/shared';
 import { toCamelCase } from '@/utils/helper';
 import EditInteractionBase from './EditInteractionBase.vue';
+import TriggerEventMouse from './TriggerEventMouse.vue';
+import TriggerEventTouch from './TriggerEventTouch.vue';
+import TriggerEventWheel from './TriggerEventWheel.vue';
+import TriggerEventInput from './TriggerEventInput.vue';
+import TriggerEventKeyboard from './TriggerEventKeyboard.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
 });
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
 const eventComponents = {
-  'mouse-event': 'TriggerEventMouse',
+  'mouse-event': TriggerEventMouse,
   'focus-event': '',
-  'event': '',
-  'touch-event': 'TriggerEventTouch',
-  'keyboard-event': 'TriggerEventKeyboard',
-  'wheel-event': 'TriggerEventWheel',
-  'input-event': 'TriggerEventInput',
+  event: '',
+  'touch-event': TriggerEventTouch,
+  'keyboard-event': TriggerEventKeyboard,
+  'wheel-event': TriggerEventWheel,
+  'input-event': TriggerEventInput,
 };
 
-const componentName = ref(eventComponents[props.data.eventType]);
 const params = ref(props.data.eventParams);
 const showOptions = ref(false);
 
@@ -120,8 +110,6 @@ function handleSelectChange(value) {
   const eventType = eventList.find(({ id }) => id === value).type;
   const payload = { eventName: value, eventType };
 
-  componentName.value = eventComponents[eventType];
-
   if (eventType !== props.eventType) {
     const defaultParams = { bubbles: true, cancelable: true };
 

+ 0 - 0
src/components/ui/uiCheckbox.vue → src/components/ui/UiCheckbox.vue


+ 91 - 0
src/components/ui/UiImg.vue

@@ -0,0 +1,91 @@
+<template>
+  <div ref="imageContainer" class="ui-image relative">
+    <div class="flex justify-center items-center">
+      <slot v-if="state.loading" name="loading">
+        <div
+          class="absolute h-full rounded-lg bg-input-dark w-full animate-pulse"
+        ></div>
+      </slot>
+      <slot v-else-if="state.error" name="error">
+        <p class="text-lighter text-center">Failed to load image</p>
+      </slot>
+      <div
+        v-else
+        :style="{
+          backgroundImage: `url(${src})`,
+          backgroundSize: contain ? 'contain' : 'cover',
+        }"
+        v-bind="{ role: alt ? 'img' : null, 'aria-label': alt }"
+        class="h-full absolute top-0 left-0 w-full bg-no-repeat bg-center"
+      >
+        <slot></slot>
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+import { ref, shallowReactive, onMounted } from 'vue';
+
+export default {
+  props: {
+    src: {
+      type: String,
+      default: '',
+    },
+    alt: {
+      type: String,
+      default: '',
+    },
+    lazy: Boolean,
+    contain: Boolean,
+  },
+  emits: ['error', 'load'],
+  setup(props, { emit }) {
+    const imageContainer = ref(null);
+    const state = shallowReactive({
+      loading: true,
+      error: false,
+    });
+
+    function handleImageLoad() {
+      state.loading = false;
+      state.error = false;
+
+      emit('load', true);
+    }
+    function handleImageError() {
+      state.loading = false;
+      state.error = true;
+
+      emit('error', true);
+    }
+    function loadImage() {
+      const image = new Image();
+
+      image.onload = () => handleImageLoad(image);
+      image.onerror = handleImageError;
+      image.src = props.src;
+    }
+
+    const observer = new IntersectionObserver((entries) => {
+      entries.forEach((entry) => {
+        if (entry.isIntersecting) {
+          const { target } = entry;
+          loadImage();
+          observer.unobserve(target);
+        }
+      });
+    });
+
+    onMounted(() => {
+      if (props.lazy) observer.observe(imageContainer.value);
+      else loadImage();
+    });
+
+    return {
+      state,
+      imageContainer,
+    };
+  },
+};
+</script>

+ 8 - 350
src/content/blocks-handler.js

@@ -1,354 +1,12 @@
-/* eslint-disable consistent-return, no-param-reassign */
-import simulateEvent from '@/utils/simulate-event';
-import handleFormElement from '@/utils/handle-form-element';
-import { generateJSON } from '@/utils/data-exporter';
-import { sendMessage } from '@/utils/message';
+import { toCamelCase } from '@/utils/helper';
 
-function markElement(el, { id, data }) {
-  if (data.markEl) {
-    el.setAttribute(`block--${id}`, '');
-  }
-}
-function handleElement({ data, id }, callback, errCallback) {
-  if (!data || !data.selector) return null;
+const blocksHandler = require.context('./blocks-handler', false, /\.js$/);
+const handlers = blocksHandler.keys().reduce((acc, key) => {
+  const name = key.replace(/^\.\/handler-|\.js/g, '');
 
-  try {
-    const blockIdAttr = `block--${id}`;
-    const selector = data.markEl
-      ? `${data.selector.trim()}:not([${blockIdAttr}])`
-      : data.selector;
+  acc[toCamelCase(name)] = blocksHandler(key).default;
 
-    const element = data.multiple
-      ? document.querySelectorAll(selector)
-      : document.querySelector(selector);
+  return acc;
+}, {});
 
-    if (typeof callback === 'boolean' && callback) return element;
-
-    if (data.multiple) {
-      element.forEach((el) => {
-        markElement(el, { id, data });
-        callback(el);
-      });
-    } else if (element) {
-      markElement(element, { id, data });
-      callback(element);
-    } else if (errCallback) {
-      errCallback();
-    }
-  } catch (error) {
-    console.error(error);
-  }
-}
-
-export function switchTo(block) {
-  return new Promise((resolve) => {
-    handleElement(
-      block,
-      (element) => {
-        if (element.tagName !== 'IFRAME') {
-          resolve('');
-          return;
-        }
-
-        resolve({ url: element.src });
-      },
-      () => {
-        resolve('');
-      }
-    );
-  });
-}
-
-export function eventClick(block) {
-  return new Promise((resolve) => {
-    handleElement(block, (element) => {
-      element.click();
-    });
-
-    resolve('');
-  });
-}
-
-export function getText(block) {
-  return new Promise((resolve) => {
-    let regex;
-    const { regex: regexData, regexExp, prefixText, suffixText } = block.data;
-    const textResult = [];
-
-    if (regexData) {
-      regex = new RegExp(regexData, regexExp.join(''));
-    }
-
-    handleElement(block, (element) => {
-      let text = element.innerText;
-
-      if (regex) text = text.match(regex).join(' ');
-
-      text = (prefixText || '') + text + (suffixText || '');
-
-      textResult.push(text);
-    });
-
-    resolve(textResult);
-  });
-}
-
-export function javascriptCode(block) {
-  block.refData.dataColumns = generateJSON(
-    Object.keys(block.refData.dataColumns),
-    block.refData.dataColumns
-  );
-
-  sessionStorage.setItem(`automa--${block.id}`, JSON.stringify(block.refData));
-  const automaScript = `
-function automaNextBlock(data) {
-  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: data }));
-}
-function automaResetTimeout() {
- window.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
-}
-function findData(obj, path) {
-  const paths = path.split('.');
-  const isWhitespace = paths.length === 1 && !/\\S/.test(paths[0]);
-
-  if (paths.length === 0 || isWhitespace) return obj;
-
-  let current = obj;
-
-  for (let i = 0; i < paths.length; i++) {
-    if (current[paths[i]] == undefined) {
-      return undefined;
-    } else {
-      current = current[paths[i]];
-    }
-  }
-
-  return current;
-}
-function automaRefData(keyword, path = '') {
-  const data = JSON.parse(sessionStorage.getItem('automa--${block.id}')) || null;
-
-  if (data === null) return null;
-
-  return findData(data[keyword], path);
-}
-`;
-
-  return new Promise((resolve) => {
-    const isScriptExists = document.getElementById('automa-custom-js');
-    const scriptAttr = `block--${block.id}`;
-
-    if (isScriptExists && isScriptExists.hasAttribute(scriptAttr)) {
-      resolve('');
-      return;
-    }
-
-    const promisePreloadScripts =
-      block.data?.preloadScripts.map(async (item) => {
-        try {
-          const { protocol, pathname } = new URL(item.src);
-          const isValidUrl = /https?/.test(protocol) && /\.js$/.test(pathname);
-
-          if (!isValidUrl) return null;
-
-          const script = await sendMessage(
-            'fetch:text',
-            item.src,
-            'background'
-          );
-          const scriptEl = document.createElement('script');
-
-          scriptEl.type = 'text/javascript';
-          scriptEl.innerHTML = script;
-
-          return {
-            ...item,
-            script: scriptEl,
-          };
-        } catch (error) {
-          return null;
-        }
-      }, []) || [];
-
-    Promise.allSettled(promisePreloadScripts).then((result) => {
-      const preloadScripts = result.reduce((acc, { status, value }) => {
-        if (status !== 'fulfilled' || !value) return acc;
-
-        acc.push(value);
-        document.body.appendChild(value.script);
-
-        return acc;
-      }, []);
-
-      const script = document.createElement('script');
-      let timeout;
-
-      script.setAttribute(scriptAttr, '');
-      script.id = 'automa-custom-js';
-      script.innerHTML = `(() => {\n${automaScript} ${block.data.code}\n})()`;
-
-      const cleanUp = (data = '') => {
-        script.remove();
-        preloadScripts.forEach((item) => {
-          if (item.removeAfterExec) item.script.remove();
-        });
-        sessionStorage.removeItem(`automa--${block.id}`);
-        resolve(data);
-      };
-
-      window.addEventListener('__automa-next-block__', ({ detail }) => {
-        clearTimeout(timeout);
-        cleanUp(detail || {});
-      });
-      window.addEventListener('__automa-reset-timeout__', () => {
-        clearTimeout(timeout);
-
-        timeout = setTimeout(cleanUp, block.data.timeout);
-      });
-
-      document.body.appendChild(script);
-
-      timeout = setTimeout(cleanUp, block.data.timeout);
-    });
-  });
-}
-
-export function elementScroll(block) {
-  function incScrollPos(element, data, vertical = true) {
-    let currentPos = vertical ? element.scrollTop : element.scrollLeft;
-
-    if (data.incY) {
-      currentPos += data.scrollY;
-    } else if (data.incX) {
-      currentPos += data.scrollX;
-    }
-
-    return currentPos;
-  }
-
-  return new Promise((resolve) => {
-    const { data } = block;
-    const behavior = data.smooth ? 'smooth' : 'auto';
-
-    handleElement(block, (element) => {
-      if (data.scrollIntoView) {
-        element.scrollIntoView({ behavior, block: 'center' });
-      } else {
-        element.scroll({
-          behavior,
-          top: data.incY ? incScrollPos(element, data) : data.scrollY,
-          left: data.incX ? incScrollPos(element, data, false) : data.scrollX,
-        });
-      }
-    });
-
-    window.dispatchEvent(new Event('scroll'));
-
-    resolve('');
-  });
-}
-
-export function attributeValue(block) {
-  return new Promise((resolve) => {
-    let result = [];
-    const { attributeName, multiple } = block.data;
-    const isCheckboxOrRadio = (element) => {
-      if (element.tagName !== 'INPUT') return false;
-
-      return ['checkbox', 'radio'].includes(element.getAttribute('type'));
-    };
-
-    handleElement(block, (element) => {
-      const value =
-        attributeName === 'checked' && isCheckboxOrRadio(element)
-          ? element.checked
-          : element.getAttribute(attributeName);
-
-      if (multiple) result.push(value);
-      else result = value;
-    });
-
-    resolve(result);
-  });
-}
-
-export function forms(block) {
-  return new Promise((resolve) => {
-    const { data } = block;
-    const elements = handleElement(block, true);
-
-    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('');
-    }
-  });
-}
-
-export function triggerEvent(block) {
-  return new Promise((resolve) => {
-    const { data } = block;
-
-    handleElement(block, (element) => {
-      simulateEvent(element, data.eventName, data.eventParams);
-    });
-
-    resolve(data.eventName);
-  });
-}
-
-export function link(block) {
-  return new Promise((resolve) => {
-    const element = document.querySelector(block.data.selector);
-
-    if (!element) {
-      resolve('');
-      return;
-    }
-
-    markElement(element, block);
-
-    const url = element.href;
-
-    if (url) window.location.href = url;
-
-    resolve(url);
-  });
-}
-
-export function elementExists({ data }) {
-  return new Promise((resolve) => {
-    let trying = 0;
-
-    function checkElement() {
-      if (trying >= (data.tryCount || 1)) {
-        resolve(false);
-        return;
-      }
-
-      const element = document.querySelector(data.selector);
-
-      if (element) {
-        resolve(true);
-      } else {
-        trying += 1;
-
-        setTimeout(checkElement, data.timeout || 500);
-      }
-    }
-
-    checkElement();
-  });
-}
+export default handlers;

+ 27 - 0
src/content/blocks-handler/handler-attribute-value.js

@@ -0,0 +1,27 @@
+import { handleElement } from '../helper';
+
+function attributeValue(block) {
+  return new Promise((resolve) => {
+    let result = [];
+    const { attributeName, multiple } = block.data;
+    const isCheckboxOrRadio = (element) => {
+      if (element.tagName !== 'INPUT') return false;
+
+      return ['checkbox', 'radio'].includes(element.getAttribute('type'));
+    };
+
+    handleElement(block, (element) => {
+      const value =
+        attributeName === 'checked' && isCheckboxOrRadio(element)
+          ? element.checked
+          : element.getAttribute(attributeName);
+
+      if (multiple) result.push(value);
+      else result = value;
+    });
+
+    resolve(result);
+  });
+}
+
+export default attributeValue;

+ 26 - 0
src/content/blocks-handler/handler-element-exists.js

@@ -0,0 +1,26 @@
+function elementExists({ data }) {
+  return new Promise((resolve) => {
+    let trying = 0;
+
+    function checkElement() {
+      if (trying >= (data.tryCount || 1)) {
+        resolve(false);
+        return;
+      }
+
+      const element = document.querySelector(data.selector);
+
+      if (element) {
+        resolve(true);
+      } else {
+        trying += 1;
+
+        setTimeout(checkElement, data.timeout || 500);
+      }
+    }
+
+    checkElement();
+  });
+}
+
+export default elementExists;

+ 38 - 0
src/content/blocks-handler/handler-element-scroll.js

@@ -0,0 +1,38 @@
+import { handleElement } from '../helper';
+
+function elementScroll(block) {
+  function incScrollPos(element, data, vertical = true) {
+    let currentPos = vertical ? element.scrollTop : element.scrollLeft;
+
+    if (data.incY) {
+      currentPos += data.scrollY;
+    } else if (data.incX) {
+      currentPos += data.scrollX;
+    }
+
+    return currentPos;
+  }
+
+  return new Promise((resolve) => {
+    const { data } = block;
+    const behavior = data.smooth ? 'smooth' : 'auto';
+
+    handleElement(block, (element) => {
+      if (data.scrollIntoView) {
+        element.scrollIntoView({ behavior, block: 'center' });
+      } else {
+        element.scroll({
+          behavior,
+          top: data.incY ? incScrollPos(element, data) : data.scrollY,
+          left: data.incX ? incScrollPos(element, data, false) : data.scrollX,
+        });
+      }
+    });
+
+    window.dispatchEvent(new Event('scroll'));
+
+    resolve('');
+  });
+}
+
+export default elementScroll;

+ 13 - 0
src/content/blocks-handler/handler-event-click.js

@@ -0,0 +1,13 @@
+import { handleElement } from '../helper';
+
+function eventClick(block) {
+  return new Promise((resolve) => {
+    handleElement(block, (element) => {
+      element.click();
+    });
+
+    resolve('');
+  });
+}
+
+export default eventClick;

+ 42 - 0
src/content/blocks-handler/handler-forms.js

@@ -0,0 +1,42 @@
+import { handleElement, markElement } from '../helper';
+import handleFormElement from '@/utils/handle-form-element';
+
+function forms(block) {
+  return new Promise((resolve) => {
+    const { data } = block;
+    const elements = handleElement(block, true);
+
+    if (data.getValue) {
+      let result = '';
+
+      if (data.multiple) {
+        result = elements.map((element) => element.value || '');
+      } else {
+        result = elements.value || '';
+      }
+
+      resolve(result);
+      return;
+    }
+
+    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('');
+    }
+  });
+}
+
+export default forms;

+ 27 - 0
src/content/blocks-handler/handler-get-text.js

@@ -0,0 +1,27 @@
+import { handleElement } from '../helper';
+
+function getText(block) {
+  return new Promise((resolve) => {
+    let regex;
+    const { regex: regexData, regexExp, prefixText, suffixText } = block.data;
+    const textResult = [];
+
+    if (regexData) {
+      regex = new RegExp(regexData, regexExp.join(''));
+    }
+
+    handleElement(block, (element) => {
+      let text = element.innerText;
+
+      if (regex) text = text.match(regex).join(' ');
+
+      text = (prefixText || '') + text + (suffixText || '');
+
+      textResult.push(text);
+    });
+
+    resolve(textResult);
+  });
+}
+
+export default getText;

+ 128 - 0
src/content/blocks-handler/handler-javascript-code.js

@@ -0,0 +1,128 @@
+import { sendMessage } from '@/utils/message';
+import { generateJSON } from '@/utils/data-exporter';
+
+function getAutomaScript(blockId) {
+  return `
+function automaNextBlock(data) {
+  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: data }));
+}
+function automaResetTimeout() {
+ window.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
+}
+function findData(obj, path) {
+  const paths = path.split('.');
+  const isWhitespace = paths.length === 1 && !/\\S/.test(paths[0]);
+
+  if (paths.length === 0 || isWhitespace) return obj;
+
+  let current = obj;
+
+  for (let i = 0; i < paths.length; i++) {
+    if (current[paths[i]] == undefined) {
+      return undefined;
+    } else {
+      current = current[paths[i]];
+    }
+  }
+
+  return current;
+}
+function automaRefData(keyword, path = '') {
+  const data = JSON.parse(sessionStorage.getItem('automa--${blockId}')) || null;
+
+  if (data === null) return null;
+
+  return findData(data[keyword], path);
+}
+  `;
+}
+
+function javascriptCode(block) {
+  block.refData.dataColumns = generateJSON(
+    Object.keys(block.refData.dataColumns),
+    block.refData.dataColumns
+  );
+
+  sessionStorage.setItem(`automa--${block.id}`, JSON.stringify(block.refData));
+  const automaScript = getAutomaScript(block.id);
+
+  return new Promise((resolve) => {
+    const isScriptExists = document.getElementById('automa-custom-js');
+    const scriptAttr = `block--${block.id}`;
+
+    if (isScriptExists && isScriptExists.hasAttribute(scriptAttr)) {
+      resolve('');
+      return;
+    }
+
+    const promisePreloadScripts =
+      block.data?.preloadScripts.map(async (item) => {
+        try {
+          const { protocol, pathname } = new URL(item.src);
+          const isValidUrl = /https?/.test(protocol) && /\.js$/.test(pathname);
+
+          if (!isValidUrl) return null;
+
+          const script = await sendMessage(
+            'fetch:text',
+            item.src,
+            'background'
+          );
+          const scriptEl = document.createElement('script');
+
+          scriptEl.type = 'text/javascript';
+          scriptEl.innerHTML = script;
+
+          return {
+            ...item,
+            script: scriptEl,
+          };
+        } catch (error) {
+          return null;
+        }
+      }, []) || [];
+
+    Promise.allSettled(promisePreloadScripts).then((result) => {
+      const preloadScripts = result.reduce((acc, { status, value }) => {
+        if (status !== 'fulfilled' || !value) return acc;
+
+        acc.push(value);
+        document.body.appendChild(value.script);
+
+        return acc;
+      }, []);
+
+      const script = document.createElement('script');
+      let timeout;
+
+      script.setAttribute(scriptAttr, '');
+      script.id = 'automa-custom-js';
+      script.innerHTML = `(() => {\n${automaScript} ${block.data.code}\n})()`;
+
+      const cleanUp = (data = '') => {
+        script.remove();
+        preloadScripts.forEach((item) => {
+          if (item.removeAfterExec) item.script.remove();
+        });
+        sessionStorage.removeItem(`automa--${block.id}`);
+        resolve(data);
+      };
+
+      window.addEventListener('__automa-next-block__', ({ detail }) => {
+        clearTimeout(timeout);
+        cleanUp(detail || {});
+      });
+      window.addEventListener('__automa-reset-timeout__', () => {
+        clearTimeout(timeout);
+
+        timeout = setTimeout(cleanUp, block.data.timeout);
+      });
+
+      document.body.appendChild(script);
+
+      timeout = setTimeout(cleanUp, block.data.timeout);
+    });
+  });
+}
+
+export default javascriptCode;

+ 22 - 0
src/content/blocks-handler/handler-link.js

@@ -0,0 +1,22 @@
+import { markElement } from '../helper';
+
+function link(block) {
+  return new Promise((resolve) => {
+    const element = document.querySelector(block.data.selector);
+
+    if (!element) {
+      resolve('');
+      return;
+    }
+
+    markElement(element, block);
+
+    const url = element.href;
+
+    if (url) window.location.href = url;
+
+    resolve(url);
+  });
+}
+
+export default link;

+ 22 - 0
src/content/blocks-handler/handler-switch-to.js

@@ -0,0 +1,22 @@
+import { handleElement } from '../helper';
+
+function switchTo(block) {
+  return new Promise((resolve) => {
+    handleElement(
+      block,
+      (element) => {
+        if (element.tagName !== 'IFRAME') {
+          resolve('');
+          return;
+        }
+
+        resolve({ url: element.src });
+      },
+      () => {
+        resolve('');
+      }
+    );
+  });
+}
+
+export default switchTo;

+ 16 - 0
src/content/blocks-handler/handler-trigger-event.js

@@ -0,0 +1,16 @@
+import { handleElement } from '../helper';
+import simulateEvent from '@/utils/simulate-event';
+
+function triggerEvent(block) {
+  return new Promise((resolve) => {
+    const { data } = block;
+
+    handleElement(block, (element) => {
+      simulateEvent(element, data.eventName, data.eventParams);
+    });
+
+    resolve(data.eventName);
+  });
+}
+
+export default triggerEvent;

+ 343 - 0
src/content/element-selector/App.vue

@@ -0,0 +1,343 @@
+<template>
+  <div
+    :class="{
+      '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
+    "
+    style="z-index: 9999999999; font-family: Inter, sans-serif; font-size: 16px"
+  >
+    <div
+      ref="cardEl"
+      :style="{ transform: `translate(${cardRect.x}px, ${cardRect.y}px)` }"
+      style="width: 320px"
+      class="
+        absolute
+        root-card
+        bg-white
+        shadow-xl
+        z-50
+        p-4
+        pointer-events-auto
+        rounded-lg
+      "
+    >
+      <div
+        class="
+          absolute
+          p-2
+          drag-button
+          shadow-xl
+          bg-white
+          p-1
+          cursor-move
+          rounded-lg
+        "
+        style="top: -15px; left: -15px"
+      >
+        <v-remixicon
+          name="riDragMoveLine"
+          @mousedown="state.isDragging = true"
+        />
+      </div>
+      <div class="flex items-center">
+        <p class="ml-1 text-lg font-semibold">Automa</p>
+        <div class="flex-grow"></div>
+        <ui-button icon class="mr-2" @click="state.hide = !state.hide">
+          <v-remixicon :name="state.hide ? 'riEyeOffLine' : 'riEyeLine'" />
+        </ui-button>
+        <ui-button icon @click="destroy">
+          <v-remixicon name="riCloseLine" />
+        </ui-button>
+      </div>
+      <app-selector
+        :selector="state.elSelector"
+        :selected-count="state.selectedElements.length"
+        @child="selectChildElement"
+        @parent="selectParentElement"
+        @change="updateSelectedElements"
+      />
+      <template v-if="!state.hide && state.selectedElements.length > 0">
+        <ui-tabs v-model="state.activeTab" class="mt-2" fill>
+          <ui-tab value="attributes"> Attributes </ui-tab>
+          <ui-tab value="blocks"> Blocks </ui-tab>
+        </ui-tabs>
+        <ui-tab-panels
+          v-model="state.activeTab"
+          class="overflow-y-auto scroll"
+          style="max-height: calc(100vh - 15rem)"
+        >
+          <ui-tab-panel value="attributes">
+            <app-element-attributes
+              :elements="state.selectedElements"
+              @highlight="
+                state.selectedElements[$event.index].highlight =
+                  $event.highlight
+              "
+            />
+          </ui-tab-panel>
+          <ui-tab-panel value="blocks">
+            <app-blocks
+              :elements="state.selectedElements"
+              :selector="state.elSelector"
+              @execute="state.isExecuting = $event"
+              @update="updateCardSize"
+            />
+          </ui-tab-panel>
+        </ui-tab-panels>
+      </template>
+    </div>
+    <svg
+      v-if="!state.hide"
+      class="h-full w-full absolute top-0 pointer-events-none left-0 z-10"
+    >
+      <rect
+        v-bind="hoverElementRect"
+        stroke-width="2"
+        stroke="#fbbf24"
+        fill="rgba(251, 191, 36, 0.2)"
+      ></rect>
+      <rect
+        v-for="(item, index) in state.selectedElements"
+        v-bind="item"
+        :key="index"
+        :stroke="item.highlight ? '#2563EB' : '#f87171'"
+        :fill="
+          item.highlight ? 'rgb(37, 99, 235, 0.2)' : 'rgba(248, 113, 113, 0.2)'
+        "
+        stroke-width="2"
+      ></rect>
+    </svg>
+  </div>
+</template>
+<script setup>
+import { reactive, ref, watch, inject, nextTick } from 'vue';
+import { finder } from '@medv/finder';
+import { debounce } from '@/utils/helper';
+import AppBlocks from './AppBlocks.vue';
+import AppSelector from './AppSelector.vue';
+import AppElementAttributes from './AppElementAttributes.vue';
+
+const selectedElement = {
+  path: [],
+  pathIndex: 0,
+};
+let lastScrollPosY = window.scrollY;
+let lastScrollPosX = window.scrollX;
+
+const rootElement = inject('rootElement');
+
+const cardEl = ref('cardEl');
+const state = reactive({
+  activeTab: '',
+  elSelector: '',
+  isDragging: false,
+  isExecuting: false,
+  selectedElements: [],
+  hide: window.self !== window.top,
+});
+const hoverElementRect = reactive({
+  x: 0,
+  y: 0,
+  height: 0,
+  width: 0,
+});
+const cardRect = reactive({
+  x: 0,
+  y: 0,
+  height: 0,
+  width: 0,
+});
+
+function getElementRect(target) {
+  if (!target) return {};
+
+  const { x, y, height, width } = target.getBoundingClientRect();
+
+  return {
+    width: width + 4,
+    height: height + 4,
+    x: x - 2,
+    y: y - 2,
+  };
+}
+function updateSelectedElements(selector) {
+  state.elSelector = selector;
+
+  try {
+    const elements = document.querySelectorAll(selector);
+
+    state.selectedElements = Array.from(elements).map((element) => {
+      const attributes = Array.from(element.attributes).map(
+        ({ name, value }) => ({ name, value })
+      );
+
+      return {
+        element,
+        attributes,
+        highlight: false,
+        ...getElementRect(element),
+      };
+    });
+  } catch (error) {
+    state.selectedElements = [];
+  }
+}
+function handleMouseMove({ clientX, clientY, target }) {
+  if (state.isDragging) {
+    const height = window.innerHeight;
+    const width = document.documentElement.clientWidth;
+
+    if (clientY < 10) clientY = 0;
+    else if (cardRect.height + clientY > height)
+      clientY = height - cardRect.height;
+
+    if (clientX < 10) clientX = 0;
+    else if (cardRect.width + clientX > width) clientX = width - cardRect.width;
+
+    cardRect.x = clientX;
+    cardRect.y = clientY;
+
+    return;
+  }
+
+  if (state.hide || rootElement === target) return;
+
+  Object.assign(hoverElementRect, getElementRect(target));
+}
+function handleClick(event) {
+  if (event.target === rootElement || state.hide || state.isExecuting) return;
+
+  event.preventDefault();
+  event.stopPropagation();
+
+  const attributes = Array.from(event.target.attributes).map(
+    ({ name, value }) => ({ name, value })
+  );
+  state.selectedElements = [
+    {
+      ...getElementRect(event.target),
+      attributes,
+      element: event.target,
+      highlight: false,
+    },
+  ];
+  state.elSelector = finder(event.target);
+
+  selectedElement.index = 0;
+  selectedElement.path = event.path;
+}
+function selectChildElement() {
+  if (selectedElement.path.length === 0 || state.hide) return;
+
+  const currentEl = selectedElement.path[selectedElement.pathIndex];
+  let childElement = currentEl;
+
+  if (selectedElement.pathIndex <= 0) {
+    const childEl = Array.from(currentEl.children).find(
+      (el) => !['STYLE', 'SCRIPT'].includes(el.tagName)
+    );
+
+    if (currentEl.childElementCount === 0 || currentEl === childEl) return;
+
+    childElement = childEl;
+    selectedElement.path.unshift(childEl);
+  } else {
+    selectedElement.pathIndex -= 1;
+    childElement = selectedElement.path[selectedElement.pathIndex];
+  }
+
+  updateSelectedElements(finder(childElement));
+}
+function selectParentElement() {
+  if (selectedElement.path.length === 0 || state.hide) return;
+
+  const parentElement = selectedElement.path[selectedElement.pathIndex];
+
+  if (parentElement.tagName === 'HTML') return;
+
+  selectedElement.pathIndex += 1;
+
+  updateSelectedElements(finder(parentElement));
+}
+function handleMouseUp() {
+  if (state.isDragging) state.isDragging = false;
+}
+function updateCardSize() {
+  setTimeout(() => {
+    cardRect.height = cardEl.value.getBoundingClientRect().height;
+  }, 250);
+}
+const handleScroll = debounce(() => {
+  if (state.hide) return;
+
+  const yPos = window.scrollY - lastScrollPosY;
+  const xPos = window.scrollX - lastScrollPosX;
+
+  state.selectedElements.forEach((_, index) => {
+    state.selectedElements[index].x -= xPos;
+    state.selectedElements[index].y -= yPos;
+  });
+
+  hoverElementRect.x -= xPos;
+  hoverElementRect.y -= yPos;
+
+  lastScrollPosX = window.scrollX;
+  lastScrollPosY = window.scrollY;
+}, 100);
+function destroy() {
+  window.removeEventListener('scroll', handleScroll);
+  window.removeEventListener('mouseup', handleMouseUp);
+  window.removeEventListener('mousemove', handleMouseMove);
+  document.removeEventListener('click', handleClick, true);
+
+  const automaElements = document.querySelectorAll('automa-element-selector');
+  automaElements.forEach((element) => {
+    element.remove();
+  });
+
+  rootElement.remove();
+}
+
+window.addEventListener('scroll', handleScroll);
+window.addEventListener('mouseup', handleMouseUp);
+window.addEventListener('mousemove', handleMouseMove);
+document.addEventListener('click', handleClick, true);
+
+watch(
+  () => state.isDragging,
+  (value) => {
+    document.body.toggleAttribute('automa-isDragging', value);
+  }
+);
+watch(() => [state.elSelector, state.activeTab, state.hide], updateCardSize);
+
+nextTick(() => {
+  setTimeout(() => {
+    const { height, width } = cardEl.value.getBoundingClientRect();
+
+    cardRect.x = window.innerWidth - (width + 35);
+    cardRect.y = 20;
+    cardRect.width = width;
+    cardRect.height = height;
+  }, 250);
+});
+</script>
+<style>
+.drag-button {
+  transform: scale(0);
+  transition: transform 200ms ease-in-out;
+}
+.root-card:hover .drag-button {
+  transform: scale(1);
+}
+</style>

+ 121 - 0
src/content/element-selector/AppBlocks.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="events mt-4">
+    <div class="flex items-center">
+      <ui-select
+        v-model="state.selectedBlock"
+        class="flex-1 mr-4"
+        placeholder="Select block"
+        @change="onSelectChanged"
+      >
+        <option v-for="(block, id) in blocks" :key="id" :value="id">
+          {{ block.name }}
+        </option>
+      </ui-select>
+      <ui-button
+        :disabled="!state.selectedBlock"
+        variant="accent"
+        @click="executeBlock"
+      >
+        Execute
+      </ui-button>
+    </div>
+    <component
+      :is="blocks[state.selectedBlock].component"
+      v-if="state.selectedBlock && blocks[state.selectedBlock].component"
+      :data="state.params"
+      :hide-base="true"
+      @update:data="updateParams"
+    />
+    <prism-editor
+      v-if="state.blockResult"
+      v-model="state.blockResult"
+      :highlight="highlighter('json')"
+      class="h-full scroll mt-2"
+    />
+  </div>
+</template>
+<script setup>
+import { shallowReactive } from 'vue';
+import { PrismEditor } from 'vue-prism-editor';
+import { tasks } from '@/utils/shared';
+import { highlighter } from '@/lib/prism';
+import handleForms from '../blocks-handler/handler-forms';
+import handleGetText from '../blocks-handler/handler-get-text';
+import handleEventClick from '../blocks-handler/handler-event-click';
+import handelTriggerEvent from '../blocks-handler/handler-trigger-event';
+import handleElementScroll from '../blocks-handler/handler-element-scroll';
+import EditForms from '@/components/newtab/workflow/edit/EditForms.vue';
+import EditTriggerEvent from '@/components/newtab/workflow/edit/EditTriggerEvent.vue';
+import EditScrollElement from '@/components/newtab/workflow/edit/EditScrollElement.vue';
+
+const props = defineProps({
+  selector: {
+    type: String,
+    default: '',
+  },
+  elements: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update', 'execute']);
+
+const blocks = {
+  forms: {
+    ...tasks.forms,
+    component: EditForms,
+    handler: handleForms,
+  },
+  'get-text': {
+    ...tasks['get-text'],
+    component: '',
+    handler: handleGetText,
+  },
+  'event-click': {
+    ...tasks['event-click'],
+    component: '',
+    handler: handleEventClick,
+  },
+  'trigger-event': {
+    ...tasks['trigger-event'],
+    component: EditTriggerEvent,
+    handler: handelTriggerEvent,
+  },
+  'element-scroll': {
+    ...tasks['element-scroll'],
+    component: EditScrollElement,
+    handler: handleElementScroll,
+  },
+};
+
+const state = shallowReactive({
+  params: {},
+  blockResult: '',
+  selectedBlock: '',
+});
+
+function updateParams(data = {}) {
+  state.params = data;
+  emit('update');
+}
+function onSelectChanged(value) {
+  state.params = tasks[value].data;
+  state.blockResult = '';
+  emit('update');
+}
+function executeBlock() {
+  const params = {
+    ...state.params,
+    selector: props.selector,
+    multiple: props.elements.length > 1,
+  };
+
+  emit('execute', true);
+
+  blocks[state.selectedBlock].handler({ data: params }).then((result) => {
+    state.blockResult = JSON.stringify(result, null, 2).trim();
+    emit('update');
+    emit('execute', false);
+  });
+}
+</script>

+ 39 - 0
src/content/element-selector/AppElementAttributes.vue

@@ -0,0 +1,39 @@
+<template>
+  <ul class="space-y-4 mt-2">
+    <li
+      v-for="(element, index) in elements"
+      :key="index"
+      @mouseenter="$emit('highlight', { highlight: true, index })"
+      @mouseleave="$emit('highlight', { highlight: false, index })"
+    >
+      <p
+        class="mb-1 cursor-pointer"
+        title="Scroll into view"
+        @click="
+          element.element.scrollIntoView({ block: 'center', inline: 'center' })
+        "
+      >
+        #{{ index + 1 }} Element
+      </p>
+      <div
+        v-for="attribute in element.attributes"
+        :key="attribute.name"
+        class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
+      >
+        <p class="text-sm text-overflow leading-tight text-gray-600">
+          {{ attribute.name }}
+        </p>
+        <p class="text-overflow">{{ attribute.value }}</p>
+      </div>
+    </li>
+  </ul>
+</template>
+<script setup>
+defineProps({
+  elements: {
+    type: Array,
+    default: () => [],
+  },
+});
+defineEmits(['highlight']);
+</script>

+ 0 - 0
src/content/element-selector/AppHeader.vue


+ 57 - 0
src/content/element-selector/AppSelector.vue

@@ -0,0 +1,57 @@
+<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"
+    >
+      <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" @click="$emit('parent')">
+        <v-remixicon rotate="90" name="riArrowLeftLine" />
+      </button>
+      <button @click="$emit('child')">
+        <v-remixicon rotate="-90" name="riArrowLeftLine" />
+      </button>
+    </template>
+  </div>
+</template>
+<script setup>
+import { inject } from 'vue';
+import { debounce } from '@/utils/helper';
+import UiInput from '@/components/ui/UiInput.vue';
+
+const props = defineProps({
+  selector: {
+    type: String,
+    default: '',
+  },
+  selectedCount: {
+    type: Number,
+    default: 0,
+  },
+});
+const emit = defineEmits(['change', 'parent', 'child']);
+
+const rootElement = inject('rootElement');
+
+const updateSelector = debounce((value) => {
+  if (value === props.selector) return;
+
+  emit('change', value);
+}, 250);
+function copySelector() {
+  rootElement.shadowRoot.querySelector('input')?.select();
+
+  navigator.clipboard.writeText(props.selector).catch((error) => {
+    document.execCommand('copy');
+    console.error(error);
+  });
+}
+</script>

+ 0 - 308
src/content/element-selector/ElementSelector.ce.vue

@@ -1,308 +0,0 @@
-<template>
-  <template v-if="!element.hide">
-    <div class="overlay"></div>
-    <div
-      :style="{
-        transform: `translate(${element.hovered.x}px, ${element.hovered.y}px)`,
-        height: element.hovered.height + 'px',
-        width: element.hovered.width + 'px',
-      }"
-      class="indicator pointer-events-auto"
-    ></div>
-    <div
-      v-if="element.selector"
-      :style="{
-        transform: `translate(${element.selected.x}px, ${element.selected.y}px)`,
-        height: element.selected.height + 'px',
-        width: element.selected.width + 'px',
-        zIndex: 99,
-      }"
-      class="indicator selected"
-    ></div>
-  </template>
-  <div class="card">
-    <button
-      title="Toggle hide"
-      class="mr-2"
-      @click="element.hide = !element.hide"
-    >
-      <v-remix-icon :path="element.hide ? riEyeLine : riEyeOffLine" />
-    </button>
-    <div class="selector">
-      <v-remix-icon
-        style="cursor: pointer"
-        title="Copy selector"
-        :path="riFileCopyLine"
-        @click="copySelector"
-      />
-      <input
-        type="text"
-        placeholder="Element selector"
-        title="Element selector"
-        readonly
-        :value="element.selector"
-      />
-    </div>
-    <template v-if="element.selector && !element.hide">
-      <button
-        title="Select parent element (press P)"
-        class="ml-2"
-        @click="selectParentElement"
-      >
-        <v-remix-icon :path="riArrowDownLine" rotate="180" />
-      </button>
-      <button
-        title="Select parent element (press C)"
-        class="ml-2"
-        @click="selectChildElement"
-      >
-        <v-remix-icon :path="riArrowDownLine" />
-      </button>
-    </template>
-    <button class="primary ml-2" @click="destroy">Close</button>
-  </div>
-</template>
-<script setup>
-import { reactive } from 'vue';
-import { finder } from '@medv/finder';
-import { VRemixIcon } from 'v-remixicon';
-import {
-  riFileCopyLine,
-  riArrowDownLine,
-  riEyeLine,
-  riEyeOffLine,
-} from 'v-remixicon/icons';
-
-/* to-do get list of attribute value, add test for each of the web interation block */
-
-const element = reactive({
-  hide: window.self !== window.top,
-  hovered: {},
-  selected: {},
-  selector: '',
-});
-
-let targetEl = null;
-let selectedEl = null;
-let pathIndex = 0;
-let selectedPath = [];
-const root = document.querySelector('element-selector');
-
-function getElementRect(target) {
-  if (!target) return {};
-
-  const { x, y, height, width } = target.getBoundingClientRect();
-
-  return {
-    width,
-    height,
-    x: x - 2,
-    y: y - 2,
-  };
-}
-function handleMouseMove({ target }) {
-  if (element.hide || targetEl === target || target === root) return;
-
-  targetEl = target;
-
-  element.hovered = getElementRect(target);
-}
-function copySelector() {
-  root.shadowRoot.querySelector('input')?.select();
-
-  navigator.clipboard.writeText(element.selector).catch((error) => {
-    document.execCommand('copy');
-    console.error(error);
-  });
-}
-function selectChildElement() {
-  if (selectedPath.length === 0 || element.hide) return;
-
-  const currentEl = selectedPath[pathIndex];
-  let activeEl = currentEl;
-
-  if (pathIndex <= 0) {
-    const childEl = Array.from(currentEl.children).find(
-      (el) => !['STYLE', 'SCRIPT'].includes(el.tagName)
-    );
-
-    if (currentEl.childElementCount === 0 || currentEl === childEl) return;
-
-    activeEl = childEl;
-    selectedPath.unshift(childEl);
-  } else {
-    pathIndex -= 1;
-    activeEl = selectedPath[pathIndex];
-  }
-
-  element.selected = getElementRect(activeEl);
-  element.selector = finder(activeEl);
-  selectedEl = activeEl;
-}
-function selectParentElement() {
-  if (
-    selectedEl.tagName === 'HTML' ||
-    selectedPath.length === 0 ||
-    element.hide
-  )
-    return;
-
-  pathIndex += 1;
-  const activeEl = selectedPath[pathIndex];
-
-  element.selected = getElementRect(activeEl);
-  element.selector = finder(activeEl);
-  selectedEl = activeEl;
-}
-function handleClick(event) {
-  if (event.target === root || element.hide) return;
-
-  if (!element.hide) {
-    event.preventDefault();
-    event.stopPropagation();
-  }
-
-  selectedPath = event.path;
-  element.selected = getElementRect(targetEl);
-  element.selector = finder(targetEl);
-
-  selectedEl = targetEl;
-}
-function handleKeyup({ code }) {
-  const shortcuts = {
-    /* eslint-disable-next-line */
-    Escape: destroy,
-    KeyC: selectChildElement,
-    KeyP: selectParentElement,
-  };
-
-  if (shortcuts[code]) shortcuts[code]();
-}
-function handleScroll() {
-  const { x: hoveredX, y: hoveredY } = getElementRect(targetEl);
-  const { x: selectedX, y: selectedY } = getElementRect(selectedEl);
-
-  element.hovered.x = hoveredX;
-  element.hovered.y = hoveredY;
-  element.selected.x = selectedX;
-  element.selected.y = selectedY;
-}
-function destroy() {
-  window.removeEventListener('keyup', handleKeyup);
-  window.removeEventListener('scroll', handleScroll);
-  document.removeEventListener('click', handleClick, true);
-  window.removeEventListener('mousemove', handleMouseMove);
-
-  root.remove();
-}
-
-window.addEventListener('keyup', handleKeyup);
-window.addEventListener('scroll', handleScroll);
-document.addEventListener('click', handleClick, true);
-window.addEventListener('mousemove', handleMouseMove);
-</script>
-<style>
-:host {
-  position: fixed;
-  height: 100%;
-  width: 100%;
-  top: 0;
-  left: 0;
-  pointer-events: none;
-  z-index: 999999999;
-  color: #18181b;
-  font-size: 16px;
-  box-sizing: border-box;
-  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
-    'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
-    'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
-}
-
-:host * {
-  font-size: 16px;
-}
-
-svg {
-  display: inline-block;
-}
-
-button {
-  border: none;
-  background-color: transparent;
-  color: inherit;
-  border-radius: 8px;
-  height: 38px;
-  padding: 0 10px;
-  background-color: #e4e4e7;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  cursor: pointer;
-}
-button.primary {
-  background-color: #18181b;
-  color: white;
-}
-
-.selector {
-  border-radius: 8px;
-  display: flex;
-  align-items: center;
-  padding-left: 12px;
-  background-color: #e4e4e7;
-}
-
-.ml-2 {
-  margin-left: 6px;
-}
-
-.mr-2 {
-  margin-right: 6px;
-}
-
-.overlay {
-  background-color: rgba(0, 0, 0, 0.2);
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  pointer-events: none;
-}
-
-input {
-  border: none;
-  color: inherit;
-  background-color: transparent;
-  padding: 10px 12px 10px 6px;
-  width: 150px;
-}
-input:focus {
-  outline: none;
-}
-
-.card {
-  position: absolute;
-  display: flex;
-  align-items: center;
-  bottom: 12px;
-  left: 12px;
-  background-color: white;
-  border-radius: 8px;
-  padding: 12px;
-  color: #1f2937;
-  border: 1px solid #e4e4e7;
-  pointer-events: all;
-  z-index: 999;
-}
-
-.indicator {
-  background-color: rgba(251, 191, 36, 0.2);
-  border: 2px solid #fbbf24;
-  position: absolute;
-}
-.indicator.selected {
-  background-color: rgba(248, 113, 113, 0.2);
-  border-color: #f87171;
-}
-</style>

+ 26 - 0
src/content/element-selector/comps-ui.js

@@ -0,0 +1,26 @@
+import VAutofocus from '@/directives/VAutofocus';
+import UiTab from '@/components/ui/UiTab.vue';
+import UiTabs from '@/components/ui/UiTabs.vue';
+import UiInput from '@/components/ui/UiInput.vue';
+import UiButton from '@/components/ui/UiButton.vue';
+import UiSelect from '@/components/ui/UiSelect.vue';
+import UiTextarea from '@/components/ui/UiTextarea.vue';
+import UiCheckbox from '@/components/ui/UiCheckbox.vue';
+import UiTabPanel from '@/components/ui/UiTabPanel.vue';
+import UiTabPanels from '@/components/ui/UiTabPanels.vue';
+import TransitionExpand from '@/components/transitions/TransitionExpand.vue';
+
+export default function (app) {
+  app.component('UiTab', UiTab);
+  app.component('UiTabs', UiTabs);
+  app.component('UiInput', UiInput);
+  app.component('UiButton', UiButton);
+  app.component('UiSelect', UiSelect);
+  app.component('UiTextarea', UiTextarea);
+  app.component('UiCheckbox', UiCheckbox);
+  app.component('UiTabPanel', UiTabPanel);
+  app.component('UiTabPanels', UiTabPanels);
+  app.component('TransitionExpand', TransitionExpand);
+
+  app.directive('autofocus', VAutofocus);
+}

+ 25 - 0
src/content/element-selector/icons.js

@@ -0,0 +1,25 @@
+import {
+  riEyeLine,
+  riCheckLine,
+  riCloseLine,
+  riEyeOffLine,
+  riFileCopyLine,
+  riDragMoveLine,
+  riArrowLeftLine,
+  riArrowLeftSLine,
+  riInformationLine,
+  riArrowDropDownLine,
+} from 'v-remixicon/icons';
+
+export default {
+  riEyeLine,
+  riCheckLine,
+  riCloseLine,
+  riEyeOffLine,
+  riFileCopyLine,
+  riDragMoveLine,
+  riArrowLeftLine,
+  riArrowLeftSLine,
+  riInformationLine,
+  riArrowDropDownLine,
+};

+ 62 - 15
src/content/element-selector/index.js

@@ -1,21 +1,68 @@
-import '@webcomponents/custom-elements';
-import { defineCustomElement } from 'vue';
-import ElementSelector from './ElementSelector.ce.vue';
+async function getStyles() {
+  try {
+    const response = await fetch(chrome.runtime.getURL('/elementSelector.css'));
+    const mainCSS = await response.text();
 
-/* to-do attribute list */
+    const fontCSS = `
+      :host { font-size: 16px }
+      @font-face {
+        font-family: Inter var;
+        font-weight: 100 900;
+        font-display: swap;
+        font-style: normal;
+        font-named-instance: "Regular";
+        src: url('${chrome.runtime.getURL(
+          '/Inter-roman-latin.var.woff2'
+        )}') format("woff2");
+      }
+    `;
 
-export default function () {
-  const isElementExists = document.querySelector('element-selector');
+    return `${mainCSS}\n${fontCSS}`;
+  } catch (error) {
+    console.error(error);
+    return '';
+  }
+}
+function getLocale() {
+  return new Promise((resolve) => {
+    chrome.storage.local.get('settings', ({ settings }) => {
+      resolve(settings?.locale || 'en');
+    });
+  });
+}
+
+export default async function () {
+  try {
+    const rootElement = document.createElement('div');
+    rootElement.classList.add('automa-element-selector');
+    rootElement.attachShadow({ mode: 'open' });
+
+    const automaStyle = document.createElement('style');
+    automaStyle.classList.add('automa-element-selector');
+    automaStyle.innerHTML = `.automa-element-selector { pointer-events: none } \n [automa-isDragging] { user-select: none }`;
 
-  if (isElementExists) return;
-  if (!customElements.get('element-selector')) {
-    window.customElements.define(
-      'element-selector',
-      defineCustomElement(ElementSelector)
+    const scriptEl = document.createElement('script');
+    scriptEl.setAttribute('type', 'module');
+    scriptEl.setAttribute(
+      'src',
+      chrome.runtime.getURL('/elementSelector.bundle.js')
     );
-  }
 
-  document.documentElement.appendChild(
-    document.createElement('element-selector')
-  );
+    const appContainer = document.createElement('div');
+    appContainer.setAttribute('data-id', chrome.runtime.id);
+    appContainer.setAttribute('data-locale', await getLocale());
+    appContainer.setAttribute('id', 'app');
+
+    const appStyle = document.createElement('style');
+    appStyle.innerHTML = await getStyles();
+
+    rootElement.shadowRoot.appendChild(appContainer);
+    rootElement.shadowRoot.appendChild(appStyle);
+    rootElement.shadowRoot.appendChild(scriptEl);
+
+    document.documentElement.appendChild(rootElement);
+    document.documentElement.appendChild(automaStyle);
+  } catch (error) {
+    console.error(error);
+  }
 }

+ 17 - 0
src/content/element-selector/main.js

@@ -0,0 +1,17 @@
+import { createApp } from 'vue';
+import vRemixicon from 'v-remixicon';
+import App from './App.vue';
+import compsUi from './comps-ui';
+import icons from './icons';
+import vueI18n from './vue-i18n';
+import '@/assets/css/tailwind.css';
+
+const rootElement = document.querySelector('div.automa-element-selector');
+const appRoot = rootElement.shadowRoot.querySelector('#app');
+
+createApp(App)
+  .provide('rootElement', rootElement)
+  .use(vueI18n)
+  .use(vRemixicon, icons)
+  .use(compsUi)
+  .mount(appRoot);

+ 13 - 0
src/content/element-selector/vue-i18n.js

@@ -0,0 +1,13 @@
+import { createI18n } from 'vue-i18n/dist/vue-i18n.esm-bundler';
+import enCommon from '@/locales/en/common.json';
+import enBlocks from '@/locales/en/blocks.json';
+
+const i18n = createI18n({
+  locale: 'en',
+  legacy: false,
+});
+
+i18n.global.mergeLocaleMessage('en', enCommon);
+i18n.global.mergeLocaleMessage('en', enBlocks);
+
+export default i18n;

+ 34 - 0
src/content/helper.js

@@ -0,0 +1,34 @@
+import FindElement from '@/utils/find-element';
+
+/* eslint-disable consistent-return */
+
+export function markElement(el, { id, data }) {
+  if (data.markEl) {
+    el.setAttribute(`block--${id}`, '');
+  }
+}
+
+export function handleElement({ data, id }, callback, errCallback) {
+  if (!data || !data.selector) return null;
+
+  try {
+    data.blockIdAttr = `block--${id}`;
+    const element = FindElement[data.findBy || 'cssSelector'](data);
+
+    if (typeof callback === 'boolean' && callback) return element;
+
+    if (data.multiple && (data.findBy || 'cssSelector') === 'cssSelector') {
+      element.forEach((el) => {
+        markElement(el, { id, data });
+        callback(el);
+      });
+    } else if (element) {
+      markElement(element, { id, data });
+      callback(element);
+    } else if (errCallback) {
+      errCallback();
+    }
+  } catch (error) {
+    console.error(error);
+  }
+}

+ 1 - 1
src/content/index.js

@@ -1,7 +1,7 @@
 import browser from 'webextension-polyfill';
 import { toCamelCase } from '@/utils/helper';
 import elementSelector from './element-selector';
-import * as blocksHandler from './blocks-handler';
+import blocksHandler from './blocks-handler';
 
 (() => {
   browser.runtime.onMessage.addListener((data) => {

+ 1 - 0
src/lib/dayjs.js

@@ -1,6 +1,7 @@
 import dayjs from 'dayjs';
 import relativeTime from 'dayjs/plugin/relativeTime';
 import 'dayjs/locale/zh';
+import 'dayjs/locale/zh-tw';
 
 dayjs.extend(relativeTime);
 

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

@@ -1,7 +1,9 @@
 import vRemixicon from 'v-remixicon';
 import {
   riHome5Line,
+  riHandHeartLine,
   riFileCopyLine,
+  riShieldKeyholeLine,
   riToggleLine,
   riFolderLine,
   riInformationLine,
@@ -13,6 +15,7 @@ import {
   riCodeSSlashLine,
   riRecordCircleLine,
   riErrorWarningLine,
+  riEyeLine,
   riCalendarLine,
   riFileTextLine,
   riFilter2Line,
@@ -74,7 +77,9 @@ import {
 
 export const icons = {
   riHome5Line,
+  riHandHeartLine,
   riFileCopyLine,
+  riShieldKeyholeLine,
   riToggleLine,
   riFolderLine,
   riInformationLine,
@@ -86,6 +91,7 @@ export const icons = {
   riCodeSSlashLine,
   riRecordCircleLine,
   riErrorWarningLine,
+  riEyeLine,
   riCalendarLine,
   riFileTextLine,
   riFilter2Line,

+ 3 - 3
src/lib/vue-i18n.js

@@ -26,7 +26,7 @@ export async function loadLocaleMessages(locale, location) {
   const importLocale = async (path, merge = false) => {
     try {
       const messages = await import(
-        /* webpackChunkName: "locale-[request]" */ `../locales/${locale}/${path}`
+        /* webpackChunkName: "locales/locale-[request]" */ `../locales/${locale}/${path}`
       );
 
       if (merge) {
@@ -39,12 +39,12 @@ export async function loadLocaleMessages(locale, location) {
     }
   };
 
-  dayjs.locale(locale);
-
   if (locale !== 'en' && !i18n.global.availableLocales.includes('en')) {
     await loadLocaleMessages('en', location);
   }
 
+  dayjs.locale(locale);
+
   await importLocale('common.json');
   await importLocale(`${location}.json`, true);
   await importLocale('blocks.json', true);

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

@@ -11,6 +11,13 @@
     "blocks": {
       "base": {
         "selector": "Element selector",
+        "findElement": {
+          "placeholder": "Find element by",
+          "options": {
+            "cssSelector": "CSS Selector",
+            "xpath": "XPath"
+          }
+        },
         "markElement": {
           "title": "An element will not be selected if have been selected before",
           "text": "Mark element"
@@ -57,10 +64,25 @@
           "keyboard-shortcut": "Keyboard shortcut"
         }
       },
+      "execute-workflow": {
+        "name": "Execute workflow",
+        "overwriteNote": "This will overwrite the global data of the selected workflow",
+        "select": "Select workflow",
+        "description": ""
+      },
       "active-tab": {
         "name": "Active tab",
         "description": "Set current tab that you're in as an active tab"
       },
+      "proxy": {
+        "name": "Proxy",
+        "description": "Set the proxy of the browser",
+        "clear": "Clear all proxy",
+        "bypass": {
+          "label": "Bypass list",
+          "note": "Use commas (,) to separate URL"
+        }
+      },
       "new-window": {
         "name": "New window",
         "description": "Create a new window",
@@ -158,6 +180,7 @@
         "description": "",
         "selected": "Selected",
         "type": "Form type",
+        "getValue": "Get form value",
         "text-field": {
           "name": "Text field",
           "value": "Value",
@@ -262,7 +285,10 @@
         },
         "loopThrough": {
           "placeholder": "Loop through",
+          "fromNumber": "From number",
+          "toNumber": "To number",
           "options": {
+            "numbers": "Numbers",
             "data-columns": "Data columns",
             "custom-data": "Custom data"
           }

+ 15 - 8
src/locales/en/newtab.json

@@ -7,7 +7,7 @@
       "label": "Language",
       "helpTranslate": "Can't find your language? Help translate.",
       "reloadPage": "Reload the page to take effect"
-    },
+    }
   },
   "workflow": {
     "import": "Import workflow",
@@ -17,6 +17,9 @@
     "rename": "Rename workflow",
     "add": "Add workflow",
     "clickToEnable": "Click to enable",
+    "state": {
+      "executeBy": "Executed by: \"{name}\""
+    },
     "dataColumns": {
       "title": "Data columns",
       "placeholder": "Search or add column",
@@ -79,8 +82,12 @@
     "messages": {
       "workflow-disabled": "Workflow is disabled",
       "stop-timeout": "Workflow is stopped because of timeout",
-      "no-iframe-id": "Can't find Frame ID for the frame element with \"{selector}\" selector",
-      "no-tab": "Can't connect to a tab, use \"New tab\" or \"Active tab\" block before using the \"{name}\" block."
+      "invalid-proxy-host": "Invalid proxy host",
+      "no-iframe-id": "Can't find Frame ID for the iframe element with \"{selector}\" selector",
+      "no-tab": "Can't connect to a tab, use \"New tab\" or \"Active tab\" block before using the \"{name}\" block.",
+      "empty-workflow": "You must select a workflow first",
+      "no-workflow": "Can't find workflow with \"{workflowId}\" ID",
+      "workflow-infinite-loop": "Can't execute the workflow to prevent an infinite loop"
     },
     "description": {
       "text": "{status} on {date} in {duration}",
@@ -100,7 +107,7 @@
         "json": "JSON",
         "csv": "CSV",
         "plain-text": "Plain text"
-      },
+      }
     },
     "filter": {
       "title": "Filter",
@@ -110,10 +117,10 @@
         "items": {
           "lastDay": "Last day",
           "last7Days": "Last seven days",
-          "last30Days": "Last thirty days",
+          "last30Days": "Last thirty days"
         }
-      },
-    },
+      }
+    }
   },
   "components": {
     "pagination": {
@@ -124,5 +131,5 @@
       "prevPage": "Previous page",
       "of": "of {page}"
     }
-  },
+  }
 }

+ 285 - 0
src/locales/zh-TW/blocks.json

@@ -0,0 +1,285 @@
+{
+  "collection": {
+    "blocks": {
+      "export-result": {
+        "name": "匯出結果",
+        "description": "以JSON格式匯出"
+      }
+    }
+  },
+  "workflow": {
+    "blocks": {
+      "base": {
+        "selector": "元素選擇器",
+        "markElement": {
+          "title": "An element will not be selected if have been selected before",
+          "text": "標記 HTML 元素"
+        },
+        "multiple": {
+          "title": "複選 HTML 元素",
+          "text": "複選 HTML 元素"
+        }
+      },
+      "trigger": {
+        "name": "觸發原點",
+        "description": "工作流程設置的起點",
+        "days": [
+          "星期日",
+          "星期一",
+          "星期二",
+          "星期三",
+          "星期四",
+          "星期五",
+          "星期六"
+        ],
+        "useRegex": "使用正則表達式",
+        "shortcut": {
+          "tootlip": "設定快捷鍵",
+          "checkboxTitle": "允許快捷鍵在文字輸入框內執行",
+          "checkbox": "啟用輸入框內執行快捷鍵",
+          "note": "Note: 鍵盤快捷鍵只在當前網頁有效"
+        },
+        "forms": {
+          "triggerWorkflow": "觸發工作流",
+          "interval": "間隔(分鐘)",
+          "delay": "延遲(分鐘)",
+          "date": "日期",
+          "time": "時間",
+          "url": "網址或正則表達式",
+          "shortcut": "快捷鍵"
+        },
+        "items": {
+          "manual": "手動",
+          "interval": "指定間隔",
+          "date": "指定日期",
+          "specific-day": "指定星期",
+          "visit-web": "指定網頁",
+          "keyboard-shortcut": "鍵盤快捷鍵"
+        }
+      },
+      "active-tab": {
+        "name": "授權分頁操作",
+        "description": "授予 Chrome 瀏覽器對分頁動作操作的權限"
+      },
+      "new-window": {
+        "name": "新視窗",
+        "description": "開啟新的瀏覽器視窗",
+        "windowState": {
+          "placeholder": "視窗大小",
+          "options": {
+            "normal": "一般",
+            "minimized": "視窗最小化",
+            "maximized": "視窗最大化",
+            "fullscreen": "全視窗"
+          }
+        },
+        "incognito": {
+          "text": "設為無痕視窗",
+          "note": "您必須在套件設定內啟用“允許在無痕模式中執行”才能使用該選項"
+        }
+      },
+      "go-back": {
+        "name": "返回",
+        "description": "回到上一個執行動作"
+      },
+      "forward-page": {
+        "name": "前進",
+        "description": "前往下一個執行動作"
+      },
+      "close-tab": {
+        "name": "關閉分頁",
+        "description": "關閉瀏覽器分頁",
+        "activeTab": "取消授權分頁操作",
+        "url": "URL or match pattern"
+      },
+      "event-click": {
+        "name": "點選 HTML元素",
+        "description": ""
+      },
+      "delay": {
+        "name": "等待時間",
+        "description": "設定執行下一個動作前的等待時間"
+      },
+      "get-text": {
+        "name": "擷取文字",
+        "description": "從HTML元素內擷取文字",
+        "prefixText": {
+          "placeholder": "文本前綴",
+          "title": "Add prefix to the text"
+        },
+        "suffixText": {
+          "placeholder": "文本後綴",
+          "title": "Add suffix to the text"
+        }
+      },
+      "export-data": {
+        "name": "匯出資料",
+        "description": "匯出工作流資料列表"
+      },
+      "element-scroll": {
+        "name": "捲軸(Scrollbar)",
+        "description": "",
+        "scrollY": "垂直捲動",
+        "scrollX": "水平捲動",
+        "intoView": "滚动到视图中",
+        "smooth": "平滑滚动",
+        "incScrollX": "遞增水平捲動",
+        "incScrollY": "遞增垂直捲動"
+      },
+      "new-tab": {
+        "name": "開啟新分頁",
+        "description": "開啟一個新瀏覽器的分頁",
+        "activeTab": "授權分頁操作",
+        "tabToGroup": "新增分頁到群組",
+        "updatePrevTab": {
+          "title": "沿用上一個空白頁",
+          "text": ""
+        }
+      },
+      "link": {
+        "name": "連結",
+        "description": "點開超連結元素"
+      },
+      "attribute-value": {
+        "name": "參數",
+        "description": "選取一個HTML元素的值",
+        "forms": {
+          "name": "參數名稱",
+          "checkbox": "儲存參數",
+          "column": "選項列表"
+        }
+      },
+      "forms": {
+        "name": "表單",
+        "description": "",
+        "selected": "已選擇",
+        "type": "表單類型",
+        "text-field": {
+          "name": "文字框",
+          "value": "值",
+          "clearValue": "清空表單內的參數",
+          "delay": {
+            "placeholder": "等待時間",
+            "label": "設定執行文字輸入前的等待時間; 單位: ms(0為停用)"
+          }
+        },
+        "select": { "name": "下拉式選單" },
+        "radio": { "name": "單選框" },
+        "checkbox": { "name": "複選框" }
+      },
+      "repeat-task": {
+        "name": "重新執行任務",
+        "description": "",
+        "times": "時間",
+        "repeatFrom": "重複自:"
+      },
+      "javascript-code": {
+        "name": "JavaScript Code",
+        "description": "在網頁中執行 JavaScript 程式",
+        "modal": {
+          "tabs": {
+            "code": "JavaScript Code",
+            "preloadScript": "預先載入 script"
+          }
+        },
+        "availabeFuncs": "可用的函式:",
+        "timeout": {
+          "placeholder": "逾時",
+          "title": "Javascript 執行逾時"
+        }
+      },
+      "trigger-event": {
+        "name": "觸發事件",
+        "description": "",
+        "selectEvent": "選擇事件"
+      },
+      "conditions": {
+        "name": "條件",
+        "description": "設定執行條件",
+        "fallbackTitle": "當條件均不符合時執行",
+        "equals": "等於",
+        "gt": "大於 > ",
+        "gte": "大於或等於 >= ",
+        "lt": "小於 < ",
+        "lte": "小於或等於 >= ",
+        "contains": "包含"
+      },
+      "element-exists": {
+        "name": "如果HTML元素存在",
+        "description": "檢查HTML元素是否存在",
+        "selector": "HTML元素選擇器",
+        "tryFor": {
+          "title": "檢查HTML元素是否存在",
+          "label": "檢查"
+        },
+        "timeout": {
+          "label": "逾時 (毫秒)",
+          "title": "每次嘗試HTML元素所逾時的時間"
+        }
+      },
+      "webhook": {
+        "name": "Webhook",
+        "description": "Webhook 允許外部服務通知",
+        "url": "接收POST請求的URL",
+        "contentType": "選擇 Content type",
+        "buttons": {
+          "header": "新增 Header"
+        },
+        "timeout": {
+          "placeholder": "逾時",
+          "title": "HTTP請求執行逾時 (ms)"
+        },
+        "tabs": {
+          "headers": "Headers",
+          "body": "Content body"
+        }
+      },
+      "loop-data": {
+        "name": "迴圈資料",
+        "description": "遍歷內部參數列表或自行定義的參數",
+        "loopId": "迴圈 ID",
+        "modal": {
+          "fileTooLarge": "檔案過大,無法編輯",
+          "maxFile": "檔案大小限制為 1MB",
+          "options": {
+            "firstRow": "使用第一個 row 作為 keys"
+          }
+        },
+        "buttons": {
+          "clear": "清空資料",
+          "insert": "插入資料",
+          "import": "匯出檔案"
+        },
+        "maxLoop": {
+          "title": "迴圈數最大值",
+          "label": "迴圈數最大值 (0為停用)"
+        },
+        "loopThrough": {
+          "placeholder": "Loop through",
+          "options": {
+            "data-columns": "參數列表",
+            "custom-data": "自定義參數"
+          }
+        }
+      },
+      "loop-breakpoint": {
+        "name": "中斷點",
+        "description": "設定迴圈的暫停節點"
+      },
+      "take-screenshot": {
+        "name": "截圖",
+        "description": "擷取當前分頁為圖片",
+        "imageQuality": "圖片品質"
+      },
+      "switch-to": {
+        "name": "切換到",
+        "description": "在主視窗和iframe切換",
+        "iframeSelector": "iframe元素選擇器",
+        "windowTypes": {
+          "main": "主視窗",
+          "iframe": "iframe"
+        }
+      }
+    }
+  }
+}

+ 54 - 0
src/locales/zh-TW/common.json

@@ -0,0 +1,54 @@
+{
+  "common": {
+    "dashboard": "主控版",
+    "workflow": "工作流 | 工作流",
+    "collection": "集合 | 集合",
+    "log": "工作日誌 | 工作日誌",
+    "block": "區塊 | 區塊",
+    "docs": "文件",
+    "search": "搜尋",
+    "import": "匯入",
+    "export": "匯出",
+    "rename": "重新命名",
+    "execute": "執行",
+    "delete": "刪除",
+    "cancel": "取消",
+    "settings": "設定",
+    "options": "選項",
+    "confirm": "確認",
+    "name": "名稱",
+    "all": "全部",
+    "add": "新增",
+    "save": "儲存",
+    "data": "資料",
+    "stop": "停止",
+    "editor": "編輯",
+    "running": "執行",
+    "globalData": "全域資料",
+    "fileName": "檔案名",
+    "description": "描述",
+    "disable": "停用",
+    "disabled": "已停用",
+    "enable": "啟用"
+  },
+  "message": {
+    "noBlock": "沒有功能區塊",
+    "noData": "沒有資料",
+    "noTriggerBlock": "沒有觸發原點",
+    "useDynamicData": "了解如何建立動態資料",
+    "delete": "確定要刪除\"{name}\"?",
+    "empty": "糟糕...看起來空蕩蕩的,要不要做一個玩玩?",
+    "notSaved": "喂喂喂~ 檔案還沒存就想跑啊?",
+    "maxSizeExceeded": "檔案大小超出允許上限"
+  },
+  "sort": {
+    "sortBy": "排序方式",
+    "name": "名稱",
+    "createdAt": "建立時間"
+  },
+  "logStatus": {
+    "stopped": "停止",
+    "error": "錯誤",
+    "success": "成功"
+  }
+}

+ 128 - 0
src/locales/zh-TW/newtab.json

@@ -0,0 +1,128 @@
+{
+  "home": {
+    "viewAll": "檢視全部"
+  },
+  "settings": {
+    "language": {
+      "label": "語言",
+      "helpTranslate": "找不到你的語言嗎? 請協助我們翻譯。",
+      "reloadPage": "重整頁面使其生效"
+    }
+  },
+  "workflow": {
+    "import": "匯入工作流",
+    "new": "新增工作流",
+    "delete": "刪除工作流",
+    "name": "工作流名稱",
+    "rename": "重新命名工作流",
+    "add": "建立工作流",
+    "clickToEnable": "點擊啟用",
+    "dataColumns": {
+      "title": "資料欄位",
+      "placeholder": "搜尋或建立欄位",
+      "column": {
+        "name": "欄位名稱",
+        "type": "資料型別"
+      }
+    },
+    "sidebar": {
+      "workflowIcon": "工作流圖示"
+    },
+    "editor": {
+      "zoomIn": "放大",
+      "zoomOut": "縮小",
+      "resetZoom": "重設縮放",
+      "duplicate": "複製"
+    },
+    "settings": {
+      "onError": {
+        "title": "工作流發生錯誤",
+        "items": {
+          "keepRunning": "繼續執行",
+          "stopWorkflow": "停止工作流"
+        }
+      },
+      "timeout": {
+        "title": "工作流超時 (毫秒)"
+      }
+    }
+  },
+  "collection": {
+    "description": "依序執行工作流",
+    "new": "新增集合",
+    "delete": "刪除集合",
+    "add": "建立集合",
+    "rename": "重新命名集合",
+    "flow": "工作流",
+    "dragDropText": "拖曳工作流或區塊至此",
+    "options": {
+      "atOnce": {
+        "title": "立即執行集合中的所有工作流",
+        "description": "使用此選項不會執行區塊"
+      }
+    },
+    "globalData": {
+      "note": "這將覆蓋工作流的全域資料"
+    }
+  },
+  "log": {
+    "goBack": "返回 \"{name}\" 紀錄",
+    "startedDate": "開始日期",
+    "duration": "期間",
+    "selectAll": "全選",
+    "deselectAll": "取消全選",
+    "deleteSelected": "刪除選取的紀錄",
+    "types": {
+      "stop": "工作流停止",
+      "finish": "工作流完成"
+    },
+    "messages": {
+      "workflow-disabled": "工作流已禁用",
+      "stop-timeout": "工作流因逾時而停止",
+      "no-iframe-id": "Can't find Frame ID for the iframe element with \"{selector}\" selector",
+      "no-tab": "Can't connect to a tab, use \"New tab\" or \"Active tab\" block before using the \"{name}\" block."
+    },
+    "description": {
+      "text": "{status} on {date} in {duration}",
+      "status": {
+        "success": "成功",
+        "error": "失敗",
+        "stopped": "停止"
+      }
+    },
+    "delete": {
+      "title": "刪除紀錄",
+      "description": "確定要刪除所有選取的紀錄嗎?"
+    },
+    "exportData": {
+      "title": "匯出資料",
+      "types": {
+        "json": "JSON",
+        "csv": "CSV",
+        "plain-text": "純文字"
+      }
+    },
+    "filter": {
+      "title": "篩選",
+      "byStatus": "依 status",
+      "byDate": {
+        "title": "依 date",
+        "items": {
+          "lastDay": "最後1天",
+          "last7Days": "最後7天",
+          "last30Days": "最後30天"
+        }
+      }
+    }
+  },
+  "components": {
+    "pagination": {
+      "text1": "顯示",
+      "text2": "項, 總共 {count} 項",
+      "nextPage": "下一頁",
+      "currentPage": "目前頁數",
+      "prevPage": "上一頁",
+      "of": "of {page}"
+    }
+  }
+}

+ 13 - 0
src/locales/zh-TW/popup.json

@@ -0,0 +1,13 @@
+{
+  "home": {
+    "elementSelector": {
+      "name": "元素選擇器",
+      "noAccess": "無權訪問這個網站"
+    },
+    "workflow": {
+      "new": "新增工作流",
+      "rename": "重新命名工作流",
+      "delete": "刪除工作流"
+    }
+  }
+}

+ 7 - 0
src/manifest.json

@@ -28,9 +28,16 @@
   ],
   "permissions": [
     "tabs",
+    "proxy",
     "alarms",
     "storage",
     "unlimitedStorage",
     "<all_urls>"
+  ],
+  "web_accessible_resources": [
+    "/elementSelector.css",
+    "/Inter-roman-latin.var.woff2",
+    "/locales/*",
+    "elementSelector.bundle.js"
   ]
 }

+ 1 - 0
src/models/log.js

@@ -15,6 +15,7 @@ class Log extends Model {
       workflowId: this.attr(null),
       collectionId: this.attr(null),
       status: this.string('success'),
+      isChildLog: this.boolean(false),
       collectionLogId: this.attr(null),
       icon: this.string('riGlobalLine'),
       isInCollection: this.boolean(false),

+ 1 - 1
src/newtab/pages/Home.vue

@@ -66,7 +66,7 @@ const workflows = computed(() =>
 );
 const logs = computed(() =>
   Log.query()
-    .where('isInCollection', false)
+    .where(({ isInCollection, isChildLog }) => !isInCollection && !isChildLog)
     .orderBy('startedAt', 'desc')
     .limit(10)
     .get()

+ 4 - 2
src/newtab/pages/Logs.vue

@@ -121,7 +121,9 @@ const exportDataModal = shallowReactive({
 
 const filteredLogs = computed(() =>
   Log.query()
-    .where(({ name, status, startedAt, isInCollection }) => {
+    .where(({ name, status, startedAt, isInCollection, isChildLog }) => {
+      if (isInCollection || isChildLog) return false;
+
       let statusFilter = true;
       let dateFilter = true;
       const searchFilter = name
@@ -138,7 +140,7 @@ const filteredLogs = computed(() =>
         dateFilter = date <= startedAt;
       }
 
-      return !isInCollection && searchFilter && statusFilter && dateFilter;
+      return searchFilter && statusFilter && dateFilter;
     })
     .orderBy(sortsBuilder.by, sortsBuilder.order)
     .get()

+ 12 - 6
src/newtab/pages/Workflows.vue

@@ -54,12 +54,18 @@
       >
         <template #header>
           <div class="flex items-center mb-4">
-            <span
-              v-if="!workflow.isDisabled"
-              class="p-2 rounded-lg bg-box-transparent"
-            >
-              <v-remixicon :name="workflow.icon || icon" />
-            </span>
+            <template v-if="!workflow.isDisabled">
+              <ui-img
+                v-if="workflow.icon.startsWith('http')"
+                :src="workflow.icon"
+                class="rounded-lg overflow-hidden"
+                style="height: 40px; width: 40px"
+                alt="Can not display"
+              />
+              <span v-else class="p-2 rounded-lg bg-box-transparent">
+                <v-remixicon :name="workflow.icon" />
+              </span>
+            </template>
             <p v-else class="py-2">{{ t('common.disabled') }}</p>
             <div class="flex-grow"></div>
             <button

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

@@ -285,8 +285,8 @@ const runningCollection = computed(() =>
 const logs = computed(() =>
   Log.query()
     .where(
-      ({ collectionId, isInCollection }) =>
-        collectionId === route.params.id && !isInCollection
+      ({ collectionId, isInCollection, isChildLog }) =>
+        collectionId === route.params.id && (!isInCollection || !isChildLog)
     )
     .orderBy('startedAt', 'desc')
     .limit(10)

+ 10 - 7
src/newtab/pages/logs/[id].vue

@@ -148,25 +148,28 @@ const pagination = shallowReactive({
 });
 
 function translateLog(log) {
-  const { name, message, type } = log;
+  const copyLog = { ...log };
   const getTranslatation = (path, def) => {
     const params = typeof path === 'string' ? { path } : path;
 
     return te(params.path) ? t(params.path, params.params) : def;
   };
 
-  if (['finish', 'stop'].includes(type)) {
-    log.name = t(`log.types.${type}`);
+  if (['finish', 'stop'].includes(log.type)) {
+    copyLog.name = t(`log.types.${log.type}`);
   } else {
-    log.name = getTranslatation(`workflow.blocks.${name}.name`, name);
+    copyLog.name = getTranslatation(
+      `workflow.blocks.${log.name}.name`,
+      log.name
+    );
   }
 
-  log.message = getTranslatation(
-    { path: `log.messages.${message}`, params: log },
+  copyLog.message = getTranslatation(
+    { path: `log.messages.${log.message}`, params: log },
     ''
   );
 
-  return log;
+  return copyLog;
 }
 
 const activeLog = computed(() => Log.find(route.params.id));

+ 5 - 1
src/newtab/pages/workflows/[id].vue

@@ -187,7 +187,11 @@ const workflowState = computed(() =>
 const workflow = computed(() => Workflow.find(workflowId) || {});
 const logs = computed(() =>
   Log.query()
-    .where((item) => item.workflowId === workflowId && !item.isInCollection)
+    .where(
+      (item) =>
+        item.workflowId === workflowId &&
+        (!item.isInCollection || !item.isChildLog)
+    )
     .orderBy('startedAt', 'desc')
     .get()
 );

+ 23 - 0
src/utils/find-element.js

@@ -0,0 +1,23 @@
+class FindElement {
+  static cssSelector(data) {
+    const selector = data.markEl
+      ? `${data.selector.trim()}:not([${data.blockIdAttr}])`
+      : data.selector;
+
+    return data.multiple
+      ? document.querySelectorAll(selector)
+      : document.querySelector(selector);
+  }
+
+  static xpath(data) {
+    return document.evaluate(
+      data.selector,
+      document,
+      null,
+      XPathResult.FIRST_ORDERED_NODE_TYPE,
+      null
+    ).singleNodeValue;
+  }
+}
+
+export default FindElement;

+ 4 - 0
src/utils/helper.js

@@ -1,3 +1,7 @@
+export function isWhitespace(str) {
+  return !/\S/g.test(str);
+}
+
 export function parseJSON(data, def) {
   try {
     const result = JSON.parse(data);

+ 56 - 1
src/utils/shared.js

@@ -27,6 +27,22 @@ export const tasks = {
       days: [],
     },
   },
+  'execute-workflow': {
+    name: 'Execute workflow',
+    description: '',
+    icon: 'riFlowChart',
+    component: 'BlockBasic',
+    category: 'general',
+    editComponent: 'EditExecuteWorkflow',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      workflowId: '',
+      globalData: '',
+    },
+  },
   'active-tab': {
     name: 'Active tab',
     description: "Set current tab that you're in as an active tab",
@@ -76,6 +92,25 @@ export const tasks = {
       windowState: 'normal',
     },
   },
+  proxy: {
+    name: 'Proxy',
+    description: 'Set the proxy of the browser',
+    icon: 'riShieldKeyholeLine',
+    component: 'BlockBasic',
+    category: 'browser',
+    editComponent: 'EditProxy',
+    inputs: 1,
+    outputs: 1,
+    maxConnection: 1,
+    allowedInputs: true,
+    data: {
+      scheme: 'https',
+      host: '',
+      port: 443,
+      bypassList: '',
+      clearProxy: false,
+    },
+  },
   'go-back': {
     name: 'Go back',
     description: 'Go back to the previous page',
@@ -147,6 +182,7 @@ export const tasks = {
     maxConnection: 1,
     data: {
       description: '',
+      findBy: 'cssSelector',
       selector: '',
       markEl: false,
       multiple: false,
@@ -180,6 +216,7 @@ export const tasks = {
     maxConnection: 1,
     data: {
       description: '',
+      findBy: 'cssSelector',
       selector: '',
       markEl: false,
       multiple: false,
@@ -218,6 +255,7 @@ export const tasks = {
     maxConnection: 1,
     data: {
       description: '',
+      findBy: 'cssSelector',
       selector: 'html',
       markEl: false,
       multiple: false,
@@ -242,6 +280,7 @@ export const tasks = {
     maxConnection: 1,
     data: {
       description: '',
+      findBy: 'cssSelector',
       selector: '',
       markEl: false,
       disableMultiple: true,
@@ -260,6 +299,7 @@ export const tasks = {
     maxConnection: 1,
     data: {
       description: '',
+      findBy: 'cssSelector',
       selector: '',
       markEl: false,
       multiple: false,
@@ -281,11 +321,15 @@ export const tasks = {
     maxConnection: 1,
     data: {
       description: '',
+      findBy: 'cssSelector',
       selector: '',
       markEl: false,
       multiple: false,
       selected: true,
       clearValue: true,
+      getValue: false,
+      saveData: true,
+      dataColumn: '',
       type: 'text-field',
       value: '',
       delay: 0,
@@ -332,7 +376,7 @@ export const tasks = {
     maxConnection: 1,
     data: {
       description: '',
-      timeout: 10000,
+      timeout: 20000,
       code: 'console.log("Hello world!")',
       preloadScripts: [],
     },
@@ -350,6 +394,7 @@ export const tasks = {
     maxConnection: 1,
     data: {
       description: '',
+      findBy: 'cssSelector',
       selector: '',
       markEl: false,
       multiple: false,
@@ -384,6 +429,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      findBy: 'cssSelector',
       selector: '',
       tryCount: 1,
       timeout: 500,
@@ -424,6 +470,8 @@ export const tasks = {
     data: {
       loopId: '',
       maxLoop: 0,
+      fromNumber: 1,
+      toNumber: 10,
       loopData: '[]',
       description: '',
       loopThrough: 'data-columns',
@@ -457,6 +505,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      findBy: 'cssSelector',
       selector: '',
       windowType: 'main-window',
     },
@@ -487,6 +536,11 @@ export const eventList = [
   { id: 'dblclick', name: 'Double Click', type: 'mouse-event' },
   { id: 'mouseup', name: 'Mouseup', type: 'mouse-event' },
   { id: 'mousedown', name: 'Mousedown', type: 'mouse-event' },
+  { id: 'mouseenter ', name: 'Mouseenter', type: 'mouse-event' },
+  { id: 'mouseleave ', name: 'Mouseleave', type: 'mouse-event' },
+  { id: 'mouseover ', name: 'Mouseover', type: 'mouse-event' },
+  { id: 'mouseout ', name: 'Mouseout', type: 'mouse-event' },
+  { id: 'mousemove ', name: 'Mousemove', type: 'mouse-event' },
   { id: 'focus', name: 'Focus', type: 'focus-event' },
   { id: 'blur', name: 'Blur', type: 'focus-event' },
   { id: 'input', name: 'Input', type: 'input-event' },
@@ -531,4 +585,5 @@ export const contentTypes = [
 export const supportLocales = [
   { id: 'en', name: 'English' },
   { id: 'zh', name: '简体中文' },
+  { id: 'zh-tw', name: '繁體中文' },
 ];

+ 8 - 1
webpack.config.js

@@ -43,9 +43,16 @@ const options = {
     background: path.join(__dirname, 'src', 'background', 'index.js'),
     contentScript: path.join(__dirname, 'src', 'content', 'index.js'),
     shortcut: path.join(__dirname, 'src', 'content', 'shortcut.js'),
+    elementSelector: path.join(
+      __dirname,
+      'src',
+      'content',
+      'element-selector',
+      'main.js'
+    ),
   },
   chromeExtensionBoilerplate: {
-    notHotReload: ['contentScript', 'shortcut'],
+    notHotReload: ['contentScript', 'shortcut', 'elementSelector'],
   },
   output: {
     path: path.resolve(__dirname, 'build'),