Ahmad Kholid 3 년 전
부모
커밋
5d34258cef
100개의 변경된 파일3364개의 추가작업 그리고 920개의 파일을 삭제
  1. 18 3
      README.md
  2. 12 4
      package.json
  3. 1 1
      src/assets/css/tailwind.css
  4. 28 34
      src/background/index.js
  5. 6 0
      src/background/workflowEngine/blocksHandler/handlerCloseTab.js
  6. 49 16
      src/background/workflowEngine/blocksHandler/handlerConditions.js
  7. 4 3
      src/background/workflowEngine/blocksHandler/handlerDeleteData.js
  8. 55 7
      src/background/workflowEngine/blocksHandler/handlerGoogleSheets.js
  9. 49 22
      src/background/workflowEngine/blocksHandler/handlerHandleDialog.js
  10. 3 3
      src/background/workflowEngine/blocksHandler/handlerHandleDownload.js
  11. 6 0
      src/background/workflowEngine/blocksHandler/handlerHoverElement.js
  12. 1 3
      src/background/workflowEngine/blocksHandler/handlerInteractionBlock.js
  13. 5 0
      src/background/workflowEngine/blocksHandler/handlerLoopData.js
  14. 34 22
      src/background/workflowEngine/blocksHandler/handlerNewTab.js
  15. 3 2
      src/background/workflowEngine/blocksHandler/handlerProxy.js
  16. 17 7
      src/background/workflowEngine/blocksHandler/handlerTakeScreenshot.js
  17. 13 3
      src/background/workflowEngine/engine.js
  18. 34 3
      src/background/workflowEngine/helper.js
  19. 18 4
      src/background/workflowEngine/worker.js
  20. 5 0
      src/components/block/BlockBase.vue
  21. 2 1
      src/components/block/BlockBasic.vue
  22. 14 16
      src/components/block/BlockConditions.vue
  23. 22 1
      src/components/block/BlockDelay.vue
  24. 1 0
      src/components/block/BlockElementExists.vue
  25. 1 2
      src/components/block/BlockGroup.vue
  26. 1 0
      src/components/block/BlockLoopBreakpoint.vue
  27. 1 0
      src/components/block/BlockRepeatTask.vue
  28. 5 5
      src/components/content/selector/SelectorBlocks.vue
  29. 0 0
      src/components/content/selector/SelectorElementList.vue
  30. 111 0
      src/components/content/selector/SelectorElementsDetail.vue
  31. 2 2
      src/components/content/selector/SelectorQuery.vue
  32. 95 0
      src/components/content/shared/SharedElementHighlighter.vue
  33. 2 1
      src/components/newtab/app/AppSidebar.vue
  34. 7 1
      src/components/newtab/shared/SharedCodemirror.vue
  35. 58 21
      src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue
  36. 2 0
      src/components/newtab/shared/SharedConditionBuilder/index.vue
  37. 2 1
      src/components/newtab/shared/SharedLogsTable.vue
  38. 5 14
      src/components/newtab/workflow/WorkflowBuilder.vue
  39. 32 27
      src/components/newtab/workflow/WorkflowDataTable.vue
  40. 3 2
      src/components/newtab/workflow/WorkflowEditBlock.vue
  41. 54 113
      src/components/newtab/workflow/WorkflowSettings.vue
  42. 5 0
      src/components/newtab/workflow/edit/EditAutocomplete.vue
  43. 55 7
      src/components/newtab/workflow/edit/EditConditions.vue
  44. 24 0
      src/components/newtab/workflow/edit/EditDelay.vue
  45. 81 23
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  46. 3 5
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  47. 1 1
      src/components/newtab/workflow/edit/EditLoopData.vue
  48. 25 14
      src/components/newtab/workflow/edit/EditNewTab.vue
  49. 120 0
      src/components/newtab/workflow/edit/EditPressKey.vue
  50. 1 1
      src/components/newtab/workflow/edit/EditProxy.vue
  51. 1 1
      src/components/newtab/workflow/edit/EditTrigger.vue
  52. 21 4
      src/components/newtab/workflow/edit/EditUploadFile.vue
  53. 1 1
      src/components/newtab/workflow/edit/OnBlockError.vue
  54. 51 0
      src/components/newtab/workflow/settings/SettingsBlocks.vue
  55. 145 0
      src/components/newtab/workflow/settings/SettingsGeneral.vue
  56. 45 0
      src/components/newtab/workflow/settings/SettingsTable.vue
  57. 161 0
      src/components/popup/home/HomeSelectBlock.vue
  58. 107 0
      src/components/popup/home/HomeStartRecording.vue
  59. 63 0
      src/components/transitions/TransitionSlide.vue
  60. 24 13
      src/components/ui/UiAutocomplete.vue
  61. 5 0
      src/components/ui/UiTab.vue
  62. 24 9
      src/components/ui/UiTabPanel.vue
  63. 4 0
      src/components/ui/UiTabPanels.vue
  64. 2 1
      src/composable/editorBlock.js
  65. 4 3
      src/content/blocksHandler/handlerEventClick.js
  66. 6 5
      src/content/blocksHandler/handlerHoverElement.js
  67. 8 35
      src/content/blocksHandler/handlerJavascriptCode.js
  68. 143 0
      src/content/blocksHandler/handlerPressKey.js
  69. 0 0
      src/content/blocksHandler/handlerSaveAssets.js
  70. 16 7
      src/content/blocksHandler/handlerTriggerEvent.js
  71. 131 172
      src/content/elementSelector/App.vue
  72. 0 46
      src/content/elementSelector/AppElementHighlighter.vue
  73. 2 36
      src/content/elementSelector/index.js
  74. 49 60
      src/content/handleSelector.js
  75. 97 0
      src/content/handleTestCondition.js
  76. 37 38
      src/content/index.js
  77. 40 0
      src/content/injectAppStyles.js
  78. 596 0
      src/content/services/recordWorkflow/App.vue
  79. 15 0
      src/content/services/recordWorkflow/addBlock.js
  80. 8 0
      src/content/services/recordWorkflow/icons.js
  81. 23 0
      src/content/services/recordWorkflow/index.js
  82. 34 0
      src/content/services/recordWorkflow/main.js
  83. 114 76
      src/content/services/recordWorkflow/recordEvents.js
  84. 1 1
      src/content/services/webService.js
  85. 93 0
      src/content/utils.js
  86. 12 0
      src/lib/drawflow.js
  87. 17 1
      src/locales/en/blocks.json
  88. 2 1
      src/locales/en/common.json
  89. 13 2
      src/locales/en/newtab.json
  90. 7 1
      src/locales/en/popup.json
  91. 35 3
      src/locales/zh/blocks.json
  92. 1 0
      src/locales/zh/common.json
  93. 23 3
      src/locales/zh/newtab.json
  94. 7 1
      src/locales/zh/popup.json
  95. 0 0
      src/manifest.chrome.json
  96. 72 0
      src/manifest.firefox.json
  97. 2 0
      src/models/workflow.js
  98. 3 3
      src/newtab/App.vue
  99. 2 1
      src/newtab/pages/settings/SettingsAbout.vue
  100. 1 1
      src/newtab/pages/settings/SettingsShortcuts.vue

+ 18 - 3
README.md

@@ -36,22 +36,37 @@ Auto-fill forms, do a repetitive task, take a screenshot, or scrape website data
 Browse the Automa marketplace where you can share and download workflows with others. [Go to the marketplace »](https://automa.vercel.app/workflows)
 
 ## Project setup
+Before running the `yarn dev` or `yarn build` script, you need to create the `getPassKey.js` file in the `src/utils` directory.  Inside the file write
+
+```js
+export default function() {
+  return 'anything-you-want';
+}
+```
+
 ```bash
 # Install dependencies
 yarn install
 
-# Compiles and hot-reloads for development
+# Compiles and hot-reloads for development for the chrome browser
 yarn dev
 
-# Compiles and minifies for production
+# Compiles and minifies for production for the chrome browser
 yarn build
 
-# Create a zip file from the build folder
+# Create a zip file from the build folder for the chrome browser
 yarn build:zip
 
+# Compiles and hot-reloads for development for the firefox browser
+yarn dev:firefox
+
+# Compiles and minifies for production for the firefox browser
+yarn build:firefox
+
 # Lints and fixes files
 yarn lint
 ```
+
 ## Contributors
 Thanks to everyone who has submitted issues, made suggestions, and generally helped make this a better project.
 

+ 12 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.9.4",
+  "version": "1.10.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -9,12 +9,19 @@
   },
   "scripts": {
     "build": "node utils/build.js",
+    "build:firefox": "cross-env BROWSER=firefox yarn build",
     "build:zip": "node utils/build-zip.js",
-    "build:prod": "yarn build && yarn build:zip",
+    "build:prod": "yarn build:prod-chrome && yarn build:prod-firefox",
+    "build:prod-chrome": "yarn build && yarn build:zip",
+    "build:prod-firefox": "yarn build:firefox && cross-env BROWSER=firefox yarn build:zip",
     "dev": "node utils/webserver.js",
+    "dev:firefox": "cross-env BROWSER=firefox yarn dev",
     "prettier": "prettier --write '**/*.{js,jsx,css,html}'",
     "lint": "eslint --ext .js,.vue --ignore-path .gitignore ."
   },
+  "engines": {
+    "node": ">=14.18.1"
+  },
   "simple-git-hooks": {
     "pre-commit": "npx lint-staged"
   },
@@ -41,7 +48,7 @@
     "css-selector-generator": "^3.6.0",
     "dayjs": "^1.10.7",
     "defu": "^6.0.0",
-    "drawflow": "^0.0.51",
+    "drawflow": "^0.0.58",
     "idb": "^7.0.0",
     "lodash.clonedeep": "^4.5.0",
     "mitt": "^3.0.0",
@@ -57,7 +64,7 @@
     "vue-toastification": "^2.0.0-rc.5",
     "vuedraggable": "^4.1.0",
     "vuex": "^4.0.2",
-    "webextension-polyfill": "^0.8.0"
+    "webextension-polyfill": "^0.9.0"
   },
   "devDependencies": {
     "@babel/core": "7.15.5",
@@ -72,6 +79,7 @@
     "clean-webpack-plugin": "4.0.0",
     "copy-webpack-plugin": "9.0.1",
     "core-js": "3",
+    "cross-env": "^7.0.3",
     "css-loader": "5.2.7",
     "eslint": "7.32.0",
     "eslint-config-airbnb-base": "^14.2.1",

+ 1 - 1
src/assets/css/tailwind.css

@@ -32,7 +32,7 @@
   @apply dark:border-gray-700;
 }
 
-:host, body {
+body {
   font-family: 'Inter var';
   font-size: 16px;
   font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';

+ 28 - 34
src/background/index.js

@@ -145,61 +145,55 @@ async function checkWorkflowStates() {
   await storage.set('workflowState', states);
 }
 checkWorkflowStates();
-async function checkVisitWebTriggers(changeInfo, tab) {
-  if (!changeInfo.status || changeInfo.status !== 'complete') return;
-
-  const tabIsUsed = await workflow.states.get(({ state }) =>
-    state.tabIds.includes(tab.id)
+async function checkVisitWebTriggers(tabId, tabUrl) {
+  const workflowState = await workflow.states.get(({ state }) =>
+    state.tabIds.includes(tabId)
   );
-
-  if (tabIsUsed) return;
-
   const visitWebTriggers = await storage.get('visitWebTriggers');
-  const triggeredWorkflow = visitWebTriggers.find(({ url, isRegex }) => {
+  const triggeredWorkflow = visitWebTriggers.find(({ url, isRegex, id }) => {
     if (url.trim() === '') return false;
 
-    return tab.url.match(isRegex ? new RegExp(url, 'g') : url);
+    const matchUrl = tabUrl.match(isRegex ? new RegExp(url, 'g') : url);
+
+    return matchUrl && id !== workflowState.workflowId;
   });
 
   if (triggeredWorkflow) {
     const workflowData = await workflow.get(triggeredWorkflow.id);
 
-    if (workflowData) workflow.execute(workflowData, { tabId: tab.id });
+    if (workflowData) workflow.execute(workflowData, { tabId });
   }
 }
-async function checkRecordingWorkflow({ status }, { url, id }) {
-  if (status === 'complete' && validateUrl(url)) {
-    const { isRecording } = await browser.storage.local.get('isRecording');
+async function checkRecordingWorkflow(tabId, tabUrl) {
+  if (!validateUrl(tabUrl)) return;
 
-    if (!isRecording) return;
+  const isRecording = await storage.get('isRecording');
+  if (!isRecording) return;
 
-    await browser.tabs.executeScript(id, {
-      file: 'recordWorkflow.bundle.js',
-    });
-  }
+  await browser.tabs.executeScript(tabId, {
+    file: 'recordWorkflow.bundle.js',
+  });
 }
-browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
-  checkRecordingWorkflow(changeInfo, tab);
-  checkVisitWebTriggers(changeInfo, tab);
-});
+browser.webNavigation.onCompleted.addListener(
+  async ({ tabId, url, frameId }) => {
+    if (frameId > 0) return;
+
+    checkRecordingWorkflow(tabId, url);
+    checkVisitWebTriggers(tabId, url);
+  }
+);
 browser.commands.onCommand.addListener((name) => {
   if (name === 'open-dashboard') openDashboard();
 });
 browser.webNavigation.onCommitted.addListener(
   ({ frameId, tabId, url, transitionType }) => {
-    const allowedType = ['link', 'typed', 'form_submit'];
-
+    const allowedType = ['link', 'typed'];
     if (frameId !== 0 || !allowedType.includes(transitionType)) return;
 
     updateRecording((recording) => {
       if (tabId !== recording.activeTab.id) return;
 
-      const lastFlow = recording.flows[recording.flows.length - 1];
-      const isClickSubmit =
-        lastFlow.id === 'event-click' && transitionType === 'form_submit';
-
-      if (isClickSubmit) return;
-
+      const lastFlow = recording.flows.at(-1) ?? {};
       const isInvalidNewtabFlow =
         lastFlow &&
         lastFlow.id === 'new-tab' &&
@@ -235,11 +229,11 @@ browser.tabs.onActivated.addListener(async ({ tabId }) => {
     recording.activeTab = { id, url };
     recording.flows.push({
       id: 'switch-tab',
+      description: title,
       data: {
         url,
         matchPattern: url,
         createIfNoMatch: true,
-        description: title || url,
       },
     });
   });
@@ -306,7 +300,7 @@ browser.alarms.onAlarm.addListener(async ({ name }) => {
   }
 });
 
-chrome.runtime.onInstalled.addListener(async ({ reason }) => {
+browser.runtime.onInstalled.addListener(async ({ reason }) => {
   try {
     if (reason === 'install') {
       await browser.storage.local.set({
@@ -347,7 +341,7 @@ chrome.runtime.onInstalled.addListener(async ({ reason }) => {
     console.error(error);
   }
 });
-chrome.runtime.onStartup.addListener(async () => {
+browser.runtime.onStartup.addListener(async () => {
   const { onStartupTriggers, workflows } = await browser.storage.local.get([
     'onStartupTriggers',
     'workflows',

+ 6 - 0
src/background/workflowEngine/blocksHandler/handlerCloseTab.js

@@ -43,8 +43,14 @@ export default async function ({ data, outputs }) {
   try {
     if (data.closeType === 'window') {
       await closeWindow(data, this.windowId);
+
+      this.windowId = null;
     } else {
       await closeTab(data, this.activeTab.id);
+
+      if (data.activeTab) {
+        this.activeTab.id = null;
+      }
     }
 
     return {

+ 49 - 16
src/background/workflowEngine/blocksHandler/handlerConditions.js

@@ -3,13 +3,52 @@ import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
 import testConditions from '@/utils/testConditions';
 import { getBlockConnection } from '../helper';
 
+function checkConditions(data, conditionOptions) {
+  return new Promise((resolve, reject) => {
+    let retryCount = 1;
+    const replacedValue = {};
+
+    const testAllConditions = async () => {
+      try {
+        for (let index = 0; index < data.conditions.length; index += 1) {
+          const result = await testConditions(
+            data.conditions[index].conditions,
+            conditionOptions
+          );
+
+          Object.assign(replacedValue, result?.replacedValue || {});
+
+          if (result.isMatch) {
+            resolve({ match: true, index, replacedValue });
+            return;
+          }
+        }
+
+        if (data.retryConditions && retryCount <= data.retryCount) {
+          retryCount += 1;
+
+          setTimeout(() => {
+            testAllConditions();
+          }, data.retryTimeout);
+        } else {
+          resolve({ match: false, replacedValue });
+        }
+      } catch (error) {
+        reject(error);
+      }
+    };
+
+    testAllConditions();
+  });
+}
+
 async function conditions({ data, outputs }, { prevBlockData, refData }) {
   if (data.conditions.length === 0) {
     throw new Error('conditions-empty');
   }
 
   let resultData = '';
-  let isConditionMatch = false;
+  let isConditionMet = false;
   let outputIndex = data.conditions.length + 1;
 
   const replacedValue = {};
@@ -26,24 +65,18 @@ async function conditions({ data, outputs }, { prevBlockData, refData }) {
         this._sendMessageToTab({ ...payload, isBlock: false }),
     };
 
-    for (let index = 0; index < data.conditions.length; index += 1) {
-      const result = await testConditions(
-        data.conditions[index].conditions,
-        conditionPayload
-      );
-
-      Object.assign(replacedValue, result?.replacedValue || {});
+    const conditionsResult = await checkConditions(data, conditionPayload);
 
-      if (result.isMatch) {
-        isConditionMatch = true;
-        outputIndex = index + 1;
-
-        break;
-      }
+    if (conditionsResult.replacedValue) {
+      Object.assign(replacedValue, conditionsResult.replacedValue);
+    }
+    if (conditionsResult.match) {
+      isConditionMet = true;
+      outputIndex = conditionsResult.index + 1;
     }
   } else {
     data.conditions.forEach(({ type, value, compareValue }, index) => {
-      if (isConditionMatch) return;
+      if (isConditionMet) return;
 
       const firstValue = mustacheReplacer(compareValue ?? prevData, refData);
       const secondValue = mustacheReplacer(value, refData);
@@ -59,7 +92,7 @@ async function conditions({ data, outputs }, { prevBlockData, refData }) {
       if (isMatch) {
         resultData = value;
         outputIndex = index + 1;
-        isConditionMatch = true;
+        isConditionMet = true;
       }
     });
   }

+ 4 - 3
src/background/workflowEngine/blocksHandler/handlerDeleteData.js

@@ -6,9 +6,10 @@ function deleteData({ data, outputs }) {
       if (item.type === 'table') {
         if (item.columnId === '[all]') {
           this.engine.referenceData.table = [];
-          this.engine.columns = {
-            column: { index: 0, name: 'column', type: 'any' },
-          };
+
+          Object.keys(this.engine.columns).forEach((key) => {
+            this.engine.columns[key].index = 0;
+          });
         } else {
           const columnName = this.engine.columns[item.columnId].name;
 

+ 55 - 7
src/background/workflowEngine/blocksHandler/handlerGoogleSheets.js

@@ -21,14 +21,31 @@ async function getSpreadsheetValues({ spreadsheetId, range, firstRowAsKey }) {
 
   return sheetsData;
 }
+async function getSpreadsheetRange({ spreadsheetId, range }) {
+  const response = await googleSheets.getRange({ spreadsheetId, range });
+  const result = await response.json();
+
+  if (!response.ok) {
+    throw new Error(result.statusMessage);
+  }
+
+  const data = {
+    tableRange: result.tableRange || null,
+    lastRange: result.updates.updatedRange,
+  };
+
+  return data;
+}
 async function updateSpreadsheetValues(
   {
-    spreadsheetId,
     range,
-    valueInputOption,
-    keysAsFirstRow,
+    append,
     dataFrom,
     customData,
+    spreadsheetId,
+    keysAsFirstRow,
+    insertDataOption,
+    valueInputOption,
   },
   columns
 ) {
@@ -38,17 +55,33 @@ async function updateSpreadsheetValues(
     if (keysAsFirstRow) {
       values = convertArrObjTo2DArr(columns);
     } else {
-      values = columns.map(Object.values);
+      values = columns.map((item) =>
+        Object.values(item).map((value) =>
+          typeof value === 'object' ? JSON.stringify(value) : value
+        )
+      );
     }
   } else if (dataFrom === 'custom') {
     values = parseJSON(customData, customData);
   }
 
+  const queries = {
+    valueInputOption: valueInputOption || 'RAW',
+  };
+
+  if (append) {
+    Object.assign(queries, {
+      includeValuesInResponse: false,
+      insertDataOption: insertDataOption || 'INSERT_ROWS',
+    });
+  }
+
   const response = await googleSheets.updateValues({
     range,
+    append,
     spreadsheetId,
-    valueInputOption,
     options: {
+      queries,
       body: JSON.stringify({ values }),
     },
   });
@@ -78,8 +111,23 @@ export default async function ({ data, outputs }, { refData }) {
       if (data.refKey && !isWhitespace(data.refKey)) {
         refData.googleSheets[data.refKey] = spreadsheetValues;
       }
-    } else if (data.type === 'update') {
-      result = await updateSpreadsheetValues(data, refData.table);
+    } else if (data.type === 'getRange') {
+      result = await getSpreadsheetRange(data);
+
+      if (data.assignVariable) {
+        this.setVariable(data.variableName, result);
+      }
+      if (data.saveData) {
+        this.addDataToColumn(data.dataColumn, result);
+      }
+    } else if (['update', 'append'].includes(data.type)) {
+      result = await updateSpreadsheetValues(
+        {
+          ...data,
+          append: data.type === 'append',
+        },
+        refData.table
+      );
     }
 
     return {

+ 49 - 22
src/background/workflowEngine/blocksHandler/handlerHandleDialog.js

@@ -1,31 +1,58 @@
 import { getBlockConnection, sendDebugCommand } from '../helper';
 
-function handleDialog({ data, outputs }) {
+const overwriteDialog = (accept, promptText) => `
+  const realConfirm = window.confirm;
+  window.confirm = function() {
+    return ${accept};
+  };
+
+  const realAlert = window.alert;
+  window.alert = function() {
+    return ${accept};
+  };
+
+  const realPrompt = window.prompt;
+  window.prompt = function() {
+    return ${accept} ? "${promptText}" : null;
+  }
+`;
+
+function handleDialog({ data, outputs, id: blockId }) {
   const nextBlockId = getBlockConnection({ outputs });
 
-  return new Promise((resolve, reject) => {
-    if (!this.settings.debugMode) {
-      const error = new Error('not-debug-mode');
-      error.nextBlockId = nextBlockId;
+  return new Promise((resolve) => {
+    if (!this.settings.debugMode || BROWSER_TYPE !== 'chrome') {
+      const isScriptExist = this.preloadScripts.find(
+        ({ id }) => id === blockId
+      );
 
-      reject(error);
-      return;
-    }
+      if (!isScriptExist) {
+        this.preloadScripts.push({
+          id: blockId,
+          isBlock: true,
+          name: 'javascript-code',
+          data: {
+            everyNewTab: true,
+            code: overwriteDialog(data.accept, data.promptText),
+          },
+        });
+      }
+    } else {
+      this.dialogParams = {
+        accept: data.accept,
+        promptText: data.promptText,
+      };
 
-    this.dialogParams = {
-      accept: data.accept,
-      promptText: data.promptText,
-    };
-
-    const methodName = 'Page.javascriptDialogOpening';
-    if (!this.engine.eventListeners[methodName]) {
-      this.engine.on(methodName, () => {
-        sendDebugCommand(
-          this.activeTab.id,
-          'Page.handleJavaScriptDialog',
-          this.dialogParams
-        );
-      });
+      const methodName = 'Page.javascriptDialogOpening';
+      if (!this.engine.eventListeners[methodName]) {
+        this.engine.on(methodName, () => {
+          sendDebugCommand(
+            this.activeTab.id,
+            'Page.handleJavaScriptDialog',
+            this.dialogParams
+          );
+        });
+      }
     }
 
     resolve({

+ 3 - 3
src/background/workflowEngine/blocksHandler/handlerHandleDownload.js

@@ -36,9 +36,9 @@ function handleDownload({ data, outputs }) {
   return new Promise((resolve) => {
     if (!this.activeTab.id) throw new Error('no-tab');
 
-    const hasListener = chrome.downloads.onDeterminingFilename.hasListeners(
-      () => {}
-    );
+    const hasListener =
+      BROWSER_TYPE === 'chrome' &&
+      chrome.downloads.onDeterminingFilename.hasListeners(() => {});
     if (!hasListener) {
       chrome.downloads.onDeterminingFilename.addListener(
         determineFilenameListener

+ 6 - 0
src/background/workflowEngine/blocksHandler/handlerHoverElement.js

@@ -5,6 +5,12 @@ export async function hoverElement(block) {
 
   try {
     if (!this.activeTab.id) throw new Error('no-tab');
+    if (BROWSER_TYPE !== 'chrome') {
+      const error = new Error('browser-not-supported');
+      error.data = { browser: BROWSER_TYPE };
+
+      throw error;
+    }
 
     const { debugMode, executedBlockOnWeb } = this.settings;
 

+ 1 - 3
src/background/workflowEngine/blocksHandler/handlerInteractionBlock.js

@@ -4,9 +4,7 @@ import { getBlockConnection } from '../helper';
 
 async function checkAccess(blockName) {
   if (blockName === 'upload-file') {
-    const hasFileAccess = await new Promise((resolve) =>
-      chrome.extension.isAllowedFileSchemeAccess(resolve)
-    );
+    const hasFileAccess = await browser.extension.isAllowedFileSchemeAccess();
 
     if (hasFileAccess) return true;
 

+ 5 - 0
src/background/workflowEngine/blocksHandler/handlerLoopData.js

@@ -41,6 +41,7 @@ async function loopData({ data, id, outputs }, { refData }) {
             max: data.maxLoop,
             type: 'loop-elements',
             selector: data.elementSelector,
+            frameSelector: this.frameSelector,
           });
 
           return elements;
@@ -92,6 +93,10 @@ async function loopData({ data, id, outputs }, { refData }) {
   } catch (error) {
     error.nextBlockId = nextBlockId;
 
+    if (data.loopThrough === 'elements') {
+      error.data = { selector: data.elementSelector };
+    }
+
     throw error;
   }
 }

+ 34 - 22
src/background/workflowEngine/blocksHandler/handlerNewTab.js

@@ -1,12 +1,13 @@
 import browser from 'webextension-polyfill';
 import { isWhitespace, sleep } from '@/utils/helper';
 import {
-  getBlockConnection,
+  waitTabLoaded,
   attachDebugger,
   sendDebugCommand,
+  getBlockConnection,
 } from '../helper';
 
-async function newTab(block) {
+async function newTab({ outputs, data }) {
   if (this.windowId) {
     try {
       await browser.windows.get(this.windowId);
@@ -15,43 +16,45 @@ async function newTab(block) {
     }
   }
 
-  const nextBlockId = getBlockConnection(block);
+  const nextBlockId = getBlockConnection({ outputs });
 
   try {
-    const { updatePrevTab, url, active, inGroup, customUserAgent, userAgent } =
-      block.data;
-    const isInvalidUrl = !/^https?/.test(url);
+    const isInvalidUrl = !/^https?/.test(data.url);
 
     if (isInvalidUrl) {
       const error = new Error(
-        isWhitespace(url) ? 'url-empty' : 'invalid-active-tab'
+        isWhitespace(data.url) ? 'url-empty' : 'invalid-active-tab'
       );
-      error.data = { url };
+      error.data = { url: data.url };
 
       throw error;
     }
 
     let tab = null;
+    const isChrome = BROWSER_TYPE === 'chrome';
 
-    if (updatePrevTab && this.activeTab.id) {
-      tab = await browser.tabs.update(this.activeTab.id, { url, active });
+    if (data.updatePrevTab && this.activeTab.id) {
+      tab = await browser.tabs.update(this.activeTab.id, {
+        url: data.url,
+        active: data.active,
+      });
     } else {
       tab = await browser.tabs.create({
-        url,
-        active,
+        url: data.url,
+        active: data.active,
         windowId: this.windowId,
       });
     }
 
-    this.activeTab.url = url;
+    this.activeTab.url = data.url;
     if (tab) {
-      if (this.settings.debugMode || customUserAgent) {
+      if (this.settings.debugMode || data.customUserAgent) {
         await attachDebugger(tab.id, this.activeTab.id);
         this.debugAttached = true;
 
-        if (customUserAgent) {
+        if (data.customUserAgent && isChrome) {
           await sendDebugCommand(tab.id, 'Network.setUserAgentOverride', {
-            userAgent,
+            userAgent: data.userAgent,
           });
           await browser.tabs.reload(tab.id);
           await sleep(1000);
@@ -62,7 +65,7 @@ async function newTab(block) {
       this.windowId = tab.windowId;
     }
 
-    if (inGroup && !updatePrevTab) {
+    if (data.inGroup && !data.updatePrevTab) {
       const options = {
         groupId: this.activeTab.groupId,
         tabIds: this.activeTab.id,
@@ -74,14 +77,16 @@ async function newTab(block) {
         };
       }
 
-      chrome.tabs.group(options, (tabGroupId) => {
-        this.activeTab.groupId = tabGroupId;
-      });
+      if (isChrome) {
+        chrome.tabs.group(options, (tabGroupId) => {
+          this.activeTab.groupId = tabGroupId;
+        });
+      }
     }
 
     this.activeTab.frameId = 0;
 
-    if (!this.settings.debugMode && customUserAgent) {
+    if (isChrome && !this.settings.debugMode && data.customUserAgent) {
       chrome.debugger.detach({ tabId: tab.id });
     }
 
@@ -92,9 +97,16 @@ async function newTab(block) {
       await Promise.allSettled(preloadScripts);
     }
 
+    if (data.waitTabLoaded) {
+      await waitTabLoaded(
+        this.activeTab.id,
+        this.settings?.tabLoadTimeout ?? 30000
+      );
+    }
+
     return {
-      data: url,
       nextBlockId,
+      data: data.url,
     };
   } catch (error) {
     error.nextBlockId = nextBlockId;

+ 3 - 2
src/background/workflowEngine/blocksHandler/handlerProxy.js

@@ -1,3 +1,4 @@
+import browser from 'webextension-polyfill';
 import { isWhitespace } from '@/utils/helper';
 import { getBlockConnection } from '../helper';
 
@@ -6,7 +7,7 @@ function setProxy({ data, outputs }) {
 
   return new Promise((resolve, reject) => {
     if (data.clearProxy) {
-      chrome.proxy.settings.clear({});
+      browser.proxy.settings.clear({});
     }
 
     const config = {
@@ -46,7 +47,7 @@ function setProxy({ data, outputs }) {
       config.rules.singleProxy.port = data.port;
     }
 
-    chrome.proxy.settings.set({ value: config, scope: 'regular' }, () => {
+    browser.proxy.settings.set({ value: config, scope: 'regular' }).then(() => {
       this.engine.isUsingProxy = true;
 
       resolve({

+ 17 - 7
src/background/workflowEngine/blocksHandler/handlerTakeScreenshot.js

@@ -60,13 +60,23 @@ async function takeScreenshot({ data, outputs, name }) {
         throw new Error('no-tab');
       }
 
-      const [tab] = await browser.tabs.query({
-        active: true,
-        currentWindow: true,
-      });
+      let tab = null;
+      const isChrome = BROWSER_TYPE === 'chrome';
+      const captureTab = () => {
+        if (isChrome) return browser.tabs.captureVisibleTab(options);
+
+        return browser.tabs.captureTab(this.activeTab.id, options);
+      };
+
+      if (isChrome) {
+        [tab] = await browser.tabs.query({
+          active: true,
+          currentWindow: true,
+        });
 
-      if (this.windowId) {
-        await browser.windows.update(this.windowId, { focused: true });
+        if (this.windowId) {
+          await browser.windows.update(this.windowId, { focused: true });
+        }
       }
 
       await browser.tabs.update(this.activeTab.id, { active: true });
@@ -81,7 +91,7 @@ async function takeScreenshot({ data, outputs, name }) {
             selector: data.selector,
             tabId: this.activeTab.id,
           })
-        : browser.tabs.captureVisibleTab(options));
+        : captureTab());
 
       if (tab) {
         await browser.windows.update(tab.windowId, { focused: true });

+ 13 - 3
src/background/workflowEngine/engine.js

@@ -29,7 +29,14 @@ class WorkflowEngine {
     this.historyCtxData = {};
     this.eventListeners = {};
     this.preloadScripts = [];
-    this.columns = { column: { index: 0, name: 'column', type: 'any' } };
+
+    this.columns = {
+      column: {
+        index: 0,
+        type: 'any',
+        name: this.workflow.settings?.defaultColumnName || 'column',
+      },
+    };
 
     let variables = {};
     let { globalData } = workflow;
@@ -111,6 +118,9 @@ class WorkflowEngine {
       this.columns[columnId] = { index: 0, name, type };
     });
 
+    if (BROWSER_TYPE !== 'chrome') {
+      this.workflow.settings.debugMode = false;
+    }
     if (this.workflow.settings.debugMode) {
       chrome.debugger.onEvent.addListener(this.onDebugEvent);
     }
@@ -240,8 +250,8 @@ class WorkflowEngine {
   async destroy(status, message) {
     try {
       if (this.isDestroyed) return;
-      if (this.isUsingProxy) chrome.proxy.settings.clear({});
-      if (this.workflow.settings.debugMode) {
+      if (this.isUsingProxy) browser.proxy.settings.clear({});
+      if (this.workflow.settings.debugMode && BROWSER_TYPE === 'chrome') {
         chrome.debugger.onEvent.removeListener(this.onDebugEvent);
 
         await sleep(1000);

+ 34 - 3
src/background/workflowEngine/helper.js

@@ -1,3 +1,5 @@
+import browser from 'webextension-polyfill';
+
 export function sendDebugCommand(tabId, method, params = {}) {
   return new Promise((resolve) => {
     chrome.debugger.sendCommand({ tabId }, method, params, resolve);
@@ -15,10 +17,33 @@ export function attachDebugger(tabId, prevTab) {
   });
 }
 
-export function waitTabLoaded(tabId) {
+export function waitTabLoaded(tabId, ms = 10000) {
   return new Promise((resolve, reject) => {
+    const timeout = null;
+    let isResolved = false;
+    const onErrorOccurred = (details) => {
+      if (details.tabId !== tabId || details.error.includes('ERR_ABORTED'))
+        return;
+
+      isResolved = true;
+      browser.webNavigation.onErrorOccurred.removeListener(onErrorOccurred);
+      reject(new Error(details.error));
+    };
+
+    if (ms > 0) {
+      setTimeout(() => {
+        isResolved = true;
+        browser.webNavigation.onErrorOccurred.removeListener(onErrorOccurred);
+        reject(new Error('Timeout'));
+      }, ms);
+    }
+
+    browser.webNavigation.onErrorOccurred.addListener(onErrorOccurred);
+
     const activeTabStatus = () => {
-      chrome.tabs.get(tabId, (tab) => {
+      if (isResolved) return;
+
+      browser.tabs.get(tabId).then((tab) => {
         if (!tab) {
           reject(new Error('no-tab'));
           return;
@@ -27,10 +52,13 @@ export function waitTabLoaded(tabId) {
         if (tab.status === 'loading') {
           setTimeout(() => {
             activeTabStatus();
-          }, 500);
+          }, 1000);
           return;
         }
 
+        clearTimeout(timeout);
+
+        browser.webNavigation.onErrorOccurred.removeListener(onErrorOccurred);
         resolve();
       });
     };
@@ -54,6 +82,9 @@ export function convertData(data, type) {
     case 'array':
       result = Array.from(data);
       break;
+    case 'string':
+      result = String(data);
+      break;
     default:
   }
 

+ 18 - 4
src/background/workflowEngine/worker.js

@@ -56,8 +56,12 @@ class Worker {
       return;
     }
 
+    const insertDefault = this.settings.insertDefaultColumn ?? true;
     const columnId =
       (this.engine.columns[key] ? key : this.engine.columnsId[key]) || 'column';
+
+    if (columnId === 'column' && !insertDefault) return;
+
     const currentColumn = this.engine.columns[columnId];
     const columnName = currentColumn.name || 'column';
     const convertedValue = convertData(value, currentColumn.type);
@@ -199,6 +203,7 @@ class Worker {
         this.engine.destroyWorker(this.id);
       }
     } catch (error) {
+      console.error(error);
       const { onError: blockOnError } = replacedBlock.data;
       if (blockOnError && blockOnError.enable) {
         if (blockOnError.retry && blockOnError.retryTimes) {
@@ -213,7 +218,7 @@ class Worker {
           block,
           blockOnError.toDo === 'continue' ? 1 : 2
         );
-        if (blockOnError.toDo !== 'error' && nextBlocks.connections) {
+        if (blockOnError.toDo !== 'error' && nextBlocks?.connections) {
           addBlockLog('error', {
             message: error.message,
             ...(error.data || {}),
@@ -231,7 +236,7 @@ class Worker {
       });
 
       const { onError } = this.settings;
-      const nodeConnections = error.nextBlockId.connections;
+      const nodeConnections = error.nextBlockId?.connections;
 
       if (onError === 'keep-running' && nodeConnections) {
         setTimeout(() => {
@@ -272,7 +277,13 @@ class Worker {
 
     this.engine.history = [];
     this.engine.preloadScripts = [];
-    this.engine.columns = { column: { index: 0, name: 'column', type: 'any' } };
+    this.engine.columns = {
+      column: {
+        index: 0,
+        type: 'any',
+        name: this.settings?.defaultColumnName || 'column',
+      },
+    };
 
     this.activeTab = {
       url: '',
@@ -300,7 +311,10 @@ class Worker {
         throw error;
       }
 
-      await waitTabLoaded(this.activeTab.id);
+      await waitTabLoaded(
+        this.activeTab.id,
+        this.settings?.tabLoadTimeout ?? 30000
+      );
       await executeContentScript(
         this.activeTab.id,
         this.activeTab.frameId || 0

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

@@ -9,6 +9,7 @@
     </div>
     <slot name="append" />
     <div
+      v-if="!minimap"
       class="absolute bottom-1 transition-transform duration-300 pt-4 ml-1 menu"
     >
       <div
@@ -35,6 +36,10 @@ defineProps({
     type: Boolean,
     default: false,
   },
+  minimap: {
+    type: Boolean,
+    default: false,
+  },
   hideEdit: {
     type: Boolean,
     default: false,

+ 2 - 1
src/components/block/BlockBasic.vue

@@ -3,6 +3,7 @@
     :id="componentId"
     :hide-edit="block.details.disableEdit"
     :hide-delete="block.details.disableDelete"
+    :minimap="editor.minimap"
     class="block-basic"
     @edit="editBlock"
     @delete="editor.removeNodeId(`node-${block.id}`)"
@@ -53,7 +54,7 @@
     <slot :block="block"></slot>
     <template #prepend>
       <div
-        v-if="block.details.id !== 'trigger'"
+        v-if="!editor.minimap && block.details.id !== 'trigger'"
         :title="t('workflow.blocks.base.moveToGroup')"
         draggable="true"
         class="bg-white dark:bg-gray-700 invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"

+ 14 - 16
src/components/block/BlockConditions.vue

@@ -11,16 +11,18 @@
         <span>{{ t('workflow.blocks.conditions.name') }}</span>
       </div>
       <div class="flex-grow"></div>
-      <v-remixicon
-        name="riDeleteBin7Line"
-        class="cursor-pointer mr-2"
-        @click="editor.removeNodeId(`node-${block.id}`)"
-      />
-      <v-remixicon
-        name="riPencilLine"
-        class="inline-block cursor-pointer"
-        @click="editBlock"
-      />
+      <template v-if="!editor.minimap">
+        <v-remixicon
+          name="riDeleteBin7Line"
+          class="cursor-pointer mr-2"
+          @click="editor.removeNodeId(`node-${block.id}`)"
+        />
+        <v-remixicon
+          name="riPencilLine"
+          class="inline-block cursor-pointer"
+          @click="editBlock"
+        />
+      </template>
     </div>
     <ul
       v-if="block.data.conditions && block.data.conditions.length !== 0"
@@ -62,7 +64,7 @@
   </div>
 </template>
 <script setup>
-import { watch, toRaw, onBeforeUnmount } from 'vue';
+import { watch, onBeforeUnmount } from 'vue';
 import { useI18n } from 'vue-i18n';
 import emitter from '@/lib/mitt';
 import { debounce } from '@/utils/helper';
@@ -99,7 +101,7 @@ function addConditionEmit({ id }) {
 
   const { length } = block.data.conditions;
 
-  if (length >= 10) return;
+  if (length >= 20) return;
   if (length === 0) props.editor.addNodeOutput(block.id);
 
   props.editor.addNodeOutput(block.id);
@@ -133,10 +135,6 @@ function refreshConnections({ id }) {
 watch(
   () => block.data.conditions,
   debounce((newValue, oldValue) => {
-    props.editor.updateNodeDataFromId(block.id, {
-      conditions: toRaw(newValue),
-    });
-
     props.editor.updateConnectionNodes(`node-${block.id}`);
 
     if (!oldValue) return;

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

@@ -1,5 +1,5 @@
 <template>
-  <div :id="componentId" class="p-4">
+  <div :id="componentId" class="p-4 block-basic">
     <div class="flex items-center mb-2">
       <div
         :class="block.category.color"
@@ -10,6 +10,7 @@
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
+        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         class="cursor-pointer"
         @click="editor.removeNodeId(`node-${block.id}`)"
@@ -25,6 +26,16 @@
       required
       @input="handleInput"
     />
+    <div
+      v-if="!editor.minimap && block.details.id !== 'trigger'"
+      :title="t('workflow.blocks.base.moveToGroup')"
+      draggable="true"
+      class="bg-white dark:bg-gray-700 invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
+      @dragstart="handleStartDrag"
+      @mousedown.stop
+    >
+      <v-remixicon name="riDragDropLine" size="20" />
+    </div>
   </div>
 </template>
 <script setup>
@@ -48,4 +59,14 @@ function handleInput({ target }) {
   props.editor.updateNodeDataFromId(block.id, { time: target.value });
   emitter.emit('editor:data-changed', block.id);
 }
+function handleStartDrag(event) {
+  const payload = {
+    data: block.data,
+    id: block.details.id,
+    blockId: block.id,
+    fromBlockBasic: true,
+  };
+
+  event.dataTransfer.setData('block', JSON.stringify(payload));
+}
 </script>

+ 1 - 0
src/components/block/BlockElementExists.vue

@@ -1,6 +1,7 @@
 <template>
   <block-base
     :id="componentId"
+    :minimap="editor.minimap"
     class="element-exists"
     @edit="editBlock"
     @delete="editor.removeNodeId(`node-${block.id}`)"

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

@@ -58,7 +58,7 @@
               {{ element.data.description }}
             </p>
           </div>
-          <div class="invisible group-hover:visible">
+          <div v-if="!editor.minimap" class="invisible group-hover:visible">
             <v-remixicon
               name="riPencilLine"
               size="20"
@@ -116,7 +116,6 @@ const excludeBlocks = [
   'blocks-group',
   'conditions',
   'element-exists',
-  'delay',
 ];
 
 function onDragStart(item, event) {

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

@@ -10,6 +10,7 @@
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
+        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         class="cursor-pointer"
         @click="editor.removeNodeId(`node-${block.id}`)"

+ 1 - 0
src/components/block/BlockRepeatTask.vue

@@ -10,6 +10,7 @@
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
+        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         class="cursor-pointer"
         @click="editor.removeNodeId(`node-${block.id}`)"

+ 5 - 5
src/content/elementSelector/AppBlocks.vue → src/components/content/selector/SelectorBlocks.vue

@@ -39,11 +39,11 @@ import { tasks } from '@/utils/shared';
 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';
-import handleForms from '../blocksHandler/handlerForms';
-import handleGetText from '../blocksHandler/handlerGetText';
-import handleEventClick from '../blocksHandler/handlerEventClick';
-import handelTriggerEvent from '../blocksHandler/handlerTriggerEvent';
-import handleElementScroll from '../blocksHandler/handlerElementScroll';
+import handleForms from '@/content/blocksHandler/handlerForms';
+import handleGetText from '@/content/blocksHandler/handlerGetText';
+import handleEventClick from '@/content/blocksHandler/handlerEventClick';
+import handelTriggerEvent from '@/content/blocksHandler/handlerTriggerEvent';
+import handleElementScroll from '@/content/blocksHandler/handlerElementScroll';
 
 const props = defineProps({
   selector: {

+ 0 - 0
src/content/elementSelector/AppElementList.vue → src/components/content/selector/SelectorElementList.vue


+ 111 - 0
src/components/content/selector/SelectorElementsDetail.vue

@@ -0,0 +1,111 @@
+<template>
+  <ui-tabs
+    :model-value="activeTab"
+    class="mt-2"
+    fill
+    @change="$emit('update:activeTab', $event)"
+  >
+    <ui-tab value="attributes"> Attributes </ui-tab>
+    <ui-tab v-if="selectElements.length > 0" value="options"> Options </ui-tab>
+    <ui-tab value="blocks"> Blocks </ui-tab>
+  </ui-tabs>
+  <ui-tab-panels
+    :model-value="activeTab"
+    class="overflow-y-auto scroll"
+    style="max-height: calc(100vh - 17rem)"
+  >
+    <ui-tab-panel value="attributes">
+      <selector-element-list
+        :elements="selectedElements"
+        @highlight="$emit('highlight', $event)"
+      >
+        <template #item="{ element }">
+          <div
+            v-for="(value, name) in element.attributes"
+            :key="name"
+            class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
+          >
+            <p
+              class="text-sm text-overflow leading-tight text-gray-600"
+              title="Attribute name"
+            >
+              {{ name }}
+            </p>
+            <input
+              :value="value"
+              readonly
+              title="Attribute value"
+              class="bg-transparent w-full"
+            />
+          </div>
+        </template>
+      </selector-element-list>
+    </ui-tab-panel>
+    <ui-tab-panel value="options">
+      <selector-element-list
+        :elements="selectElements"
+        element-name="Select element options"
+        @highlight="
+          $emit('highlight', {
+            index: $event.element.index,
+            highlight: $event.highlight,
+          })
+        "
+      >
+        <template #item="{ element }">
+          <div
+            v-for="option in element.options"
+            :key="option.name"
+            class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
+          >
+            <p
+              class="text-sm text-overflow leading-tight text-gray-600"
+              title="Option name"
+            >
+              {{ option.name }}
+            </p>
+            <input
+              :value="option.value"
+              title="Option value"
+              class="text-overflow focus:ring-0 w-full bg-transparent"
+              readonly
+              @click="$event.target.select()"
+            />
+          </div>
+        </template>
+      </selector-element-list>
+    </ui-tab-panel>
+    <ui-tab-panel value="blocks">
+      <selector-blocks
+        :elements="selectedElements"
+        :selector="elSelector"
+        @execute="$emit('execute', $event)"
+        @update="$emit('update')"
+      />
+    </ui-tab-panel>
+  </ui-tab-panels>
+</template>
+<script setup>
+import SelectorBlocks from './SelectorBlocks.vue';
+import SelectorElementList from './SelectorElementList.vue';
+
+defineProps({
+  activeTab: {
+    type: String,
+    default: '',
+  },
+  selectElements: {
+    type: Array,
+    default: () => [],
+  },
+  selectedElements: {
+    type: Array,
+    default: () => [],
+  },
+  elSelector: {
+    type: String,
+    default: '',
+  },
+});
+defineEmits(['update:activeTab', 'execute', 'highlight', 'update']);
+</script>

+ 2 - 2
src/content/elementSelector/AppSelector.vue → src/components/content/selector/SelectorQuery.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="mt-4">
+  <div>
     <div class="flex items-center">
       <ui-select
         :model-value="selectorType"
@@ -36,7 +36,7 @@
       </ui-input>
       <template v-if="selectedCount === 1">
         <button
-          class="mr-2 ml-4"
+          class="mr-1 ml-2"
           title="Parent element"
           @click="$emit('parent')"
         >

+ 95 - 0
src/components/content/shared/SharedElementHighlighter.vue

@@ -0,0 +1,95 @@
+<template>
+  <svg
+    class="automa-element-highlighter"
+    style="
+      height: 100%;
+      width: 100%;
+      top: 0;
+      left: 0;
+      pointer-events: none;
+      position: fixed;
+      z-index: 10;
+    "
+  >
+    <g v-for="(colors, key) in data" :key="key">
+      <rect
+        v-for="(item, index) in items[key]"
+        v-bind="{
+          x: item.x,
+          y: item.y,
+          width: item.width,
+          height: item.height,
+          'stroke-dasharray': item.outline ? '5,5' : null,
+          fill: getFillColor(item, colors),
+          stroke: getStrokeColor(item, colors),
+        }"
+        :key="key + index"
+        stroke-width="2"
+      ></rect>
+    </g>
+  </svg>
+</template>
+<script setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import { debounce } from '@/utils/helper';
+
+const props = defineProps({
+  disabled: {
+    type: Boolean,
+    default: false,
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  items: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+let lastScrollPosY = window.scrollY;
+let lastScrollPosX = window.scrollX;
+
+const handleScroll = debounce(() => {
+  if (props.hide) return;
+
+  const yPos = window.scrollY - lastScrollPosY;
+  const xPos = window.scrollX - lastScrollPosX;
+
+  const updatePositions = (key) =>
+    props.items[key]?.map((item) => {
+      const copyItem = { ...item };
+
+      copyItem.x -= xPos;
+      copyItem.y -= yPos;
+
+      return copyItem;
+    }) || [];
+
+  Object.keys(props.data).forEach((key) => {
+    const newPositions = updatePositions(key);
+    emit('update', { key, items: newPositions });
+  });
+
+  lastScrollPosX = window.scrollX;
+  lastScrollPosY = window.scrollY;
+}, 100);
+
+function getFillColor(item, colors) {
+  if (item.outline) return null;
+
+  return item.highlight ? colors.fill : colors.activeFill || colors.fill;
+}
+function getStrokeColor(item, colors) {
+  return item.highlight ? colors.stroke : colors.activeStroke || colors.stroke;
+}
+
+onMounted(() => {
+  window.addEventListener('scroll', handleScroll);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener('scroll', handleScroll);
+});
+</script>

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

@@ -84,6 +84,7 @@ import { ref } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
+import browser from 'webextension-polyfill';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { communities } from '@/utils/shared';
@@ -94,7 +95,7 @@ const { t } = useI18n();
 const store = useStore();
 const router = useRouter();
 
-const extensionVersion = chrome.runtime.getManifest().version;
+const extensionVersion = browser.runtime.getManifest().version;
 const tabs = [
   {
     id: 'dashboard',

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

@@ -3,7 +3,13 @@
     ref="containerEl"
     :class="{ 'hide-gutters': !lineNumbers }"
     class="codemirror relative overflow-auto rounded-lg"
-  ></div>
+  >
+    <span
+      class="text-sm text-gray-300 absolute bottom-2 right-2 z-10 pointer-events-none z-10"
+    >
+      {{ lang }}
+    </span>
+  </div>
 </template>
 <script setup>
 import { onMounted, ref, onBeforeUnmount, watch } from 'vue';

+ 58 - 21
src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue

@@ -2,7 +2,7 @@
   <div
     v-for="(item, index) in inputsData"
     :key="item.id"
-    class="condition-input"
+    class="condition-input scroll"
   >
     <div
       v-if="item.category === 'value'"
@@ -10,6 +10,7 @@
     >
       <ui-select
         :model-value="item.type"
+        class="flex-shrink-0"
         @change="updateValueType($event, index)"
       >
         <optgroup
@@ -22,41 +23,63 @@
           </option>
         </optgroup>
       </ui-select>
-      <edit-autocomplete
-        v-for="(_, name) in item.data"
-        :key="item.id + name + index"
-        class="flex-1"
-      >
-        <ui-input
-          v-model="inputsData[index].data[name]"
-          :title="conditionBuilder.inputTypes[name].label"
-          :placeholder="conditionBuilder.inputTypes[name].label"
-          autocomplete="off"
-          class="w-full"
+      <template v-for="(_, name) in item.data" :key="item.id + name + index">
+        <v-remixicon
+          v-if="name === 'code'"
+          :title="t('workflow.conditionBuilder.topAwait')"
+          name="riInformationLine"
         />
-      </edit-autocomplete>
+        <edit-autocomplete
+          :disabled="name === 'code'"
+          :class="[name === 'code' ? 'w-full' : 'flex-1']"
+          :style="{ marginLeft: name === 'code' ? 0 : null }"
+        >
+          <shared-codemirror
+            v-if="name === 'code'"
+            v-model="inputsData[index].data[name]"
+            class="code-condition mt-2"
+            style="margin-left: 0"
+          />
+          <ui-input
+            v-else
+            v-model="inputsData[index].data[name]"
+            :title="conditionBuilder.inputTypes[name].label"
+            :placeholder="conditionBuilder.inputTypes[name].label"
+            autocomplete="off"
+            class="w-full"
+          />
+        </edit-autocomplete>
+      </template>
     </div>
     <ui-select
       v-else-if="item.category === 'compare'"
       :model-value="inputsData[index].type"
       @change="updateCompareType($event, index)"
     >
-      <option
-        v-for="type in conditionBuilder.compareTypes"
-        :key="type.id"
-        :value="type.id"
+      <optgroup
+        v-for="(types, category) in conditionOperators"
+        :key="category"
+        :label="category"
       >
-        {{ type.name }}
-      </option>
+        <option v-for="type in types" :key="type.id" :value="type.id">
+          {{ type.name }}
+        </option>
+      </optgroup>
     </ui-select>
   </div>
 </template>
 <script setup>
-import { ref, watch } from 'vue';
+import { ref, watch, defineAsyncComponent } from 'vue';
 import { nanoid } from 'nanoid';
+import { useI18n } from 'vue-i18n';
+import cloneDeep from 'lodash.clonedeep';
 import { conditionBuilder } from '@/utils/shared';
 import EditAutocomplete from '../../workflow/edit/EditAutocomplete.vue';
 
+const SharedCodemirror = defineAsyncComponent(() =>
+  import('../SharedCodemirror.vue')
+);
+
 const props = defineProps({
   data: {
     type: Array,
@@ -69,7 +92,16 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
-const inputsData = ref(JSON.parse(JSON.stringify(props.data)));
+const conditionOperators = conditionBuilder.compareTypes.reduce((acc, type) => {
+  if (!acc[type.category]) acc[type.category] = [];
+
+  acc[type.category].push(type);
+
+  return acc;
+}, {});
+
+const { t } = useI18n();
+const inputsData = ref(cloneDeep(props.data));
 
 function getDefaultValues(items) {
   const defaultValues = {
@@ -131,3 +163,8 @@ watch(
   { deep: true }
 );
 </script>
+<style>
+.code-condition .cm-content {
+  white-space: pre-wrap;
+}
+</style>

+ 2 - 0
src/components/newtab/shared/SharedConditionBuilder/index.vue

@@ -132,6 +132,8 @@ function getConditionText({ category, type, data }) {
 
   if (type === 'value') {
     text = data.value || 'Empty';
+  } else if (type.startsWith('code')) {
+    text = 'JS Code';
   } else if (type.startsWith('element')) {
     text = type;
 

+ 2 - 1
src/components/newtab/shared/SharedLogsTable.vue

@@ -6,7 +6,8 @@
         <td class="text-overflow" style="min-width: 140px; max-width: 330px">
           <router-link
             :to="`/logs/${log.id}`"
-            class="inline-block text-overflow w-full align-middle"
+            class="inline-block text-overflow w-full align-middle min-h"
+            style="min-height: 28px"
           >
             {{ log.name }}
           </router-link>

+ 5 - 14
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -80,6 +80,7 @@ import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
 import defu from 'defu';
 import SelectionArea from '@viselect/vanilla';
+import browser from 'webextension-polyfill';
 import emitter from '@/lib/mitt';
 import {
   useShortcut,
@@ -296,19 +297,6 @@ export default {
           .closest('.drawflow-node')
           .id.replace(/node-/, '');
         const outputClass = target.classList[1];
-        const blockData = editor.value.getNodeFromId(targetBlockId);
-        const { connections } = blockData.outputs[outputClass];
-
-        if (connections[0]) {
-          const { output, node } = connections[0];
-
-          editor.value.removeSingleConnection(
-            targetBlockId,
-            node,
-            outputClass,
-            output
-          );
-        }
 
         editor.value.addConnection(
           targetBlockId,
@@ -668,7 +656,7 @@ export default {
 
         if (!data || !data?.drawflow?.Home) return;
 
-        const currentExtVersion = chrome.runtime.getManifest().version;
+        const currentExtVersion = browser.runtime.getManifest().version;
         const isOldWorkflow = compare(
           currentExtVersion,
           props.version || '0.0.0',
@@ -770,6 +758,9 @@ export default {
       });
       editor.value.on('export', saveEditorState);
       editor.value.on('contextmenu', ({ clientY, clientX, target }) => {
+        if (target.tagName === 'path' && target.classList.contains('main-path'))
+          return;
+
         const isBlock = target.closest('.drawflow .drawflow-node');
         const virtualEl = {
           getReferenceClientRect: () => ({

+ 32 - 27
src/components/newtab/workflow/WorkflowDataTable.vue

@@ -12,35 +12,40 @@
       {{ t('common.add') }}
     </ui-button>
   </div>
-  <ul
-    class="space-y-2 overflow-y-auto scroll py-1"
-    style="max-height: calc(100vh - 11rem)"
+  <div
+    class="overflow-y-auto scroll"
+    style="max-height: 600px; height: calc(100vh - 13rem)"
   >
-    <li
-      v-for="(column, index) in columns"
-      :key="column.id"
-      class="flex items-center space-x-2"
-    >
-      <ui-input
-        :model-value="columns[index].name"
-        :placeholder="t('workflow.table.column.name')"
-        class="flex-1"
-        @blur="updateColumnName(index, $event.target)"
-      />
-      <ui-select
-        v-model="columns[index].type"
-        class="flex-1"
-        :placeholder="t('workflow.table.column.type')"
+    <p v-if="columns.length === 0" class="text-center mt-4">
+      {{ t('message.noData') }}
+    </p>
+    <ul v-else class="space-y-2 py-1">
+      <li
+        v-for="(column, index) in columns"
+        :key="column.id"
+        class="flex items-center space-x-2"
       >
-        <option v-for="type in dataTypes" :key="type.id" :value="type.id">
-          {{ type.name }}
-        </option>
-      </ui-select>
-      <button @click="state.columns.splice(index, 1)">
-        <v-remixicon name="riDeleteBin7Line" />
-      </button>
-    </li>
-  </ul>
+        <ui-input
+          :model-value="columns[index].name"
+          :placeholder="t('workflow.table.column.name')"
+          class="flex-1"
+          @blur="updateColumnName(index, $event.target)"
+        />
+        <ui-select
+          v-model="columns[index].type"
+          class="flex-1"
+          :placeholder="t('workflow.table.column.type')"
+        >
+          <option v-for="type in dataTypes" :key="type.id" :value="type.id">
+            {{ type.name }}
+          </option>
+        </ui-select>
+        <button @click="state.columns.splice(index, 1)">
+          <v-remixicon name="riDeleteBin7Line" />
+        </button>
+      </li>
+    </ul>
+  </div>
 </template>
 <script setup>
 import { computed, onMounted, watch, reactive } from 'vue';

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

@@ -98,11 +98,12 @@ export default {
   emits: ['close', 'update', 'update:autocomplete'],
   setup(props, { emit }) {
     const excludeOnError = [
+      'delay',
       'webhook',
+      'trigger',
       'while-loop',
-      'element-exists',
       'conditions',
-      'trigger',
+      'element-exists',
     ];
 
     const { t } = useI18n();

+ 54 - 113
src/components/newtab/workflow/WorkflowSettings.vue

@@ -1,82 +1,44 @@
 <template>
-  <div
-    class="workflow-settings space-y-4 divide-y dark:divide-gray-700 divide-gray-100"
-  >
-    <div class="flex items-center">
-      <div class="mr-4 flex-1">
-        <p>
-          {{ t('workflow.settings.onError.title') }}
-        </p>
-        <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
-          {{ t('workflow.settings.onError.description') }}
-        </p>
-      </div>
-      <ui-select v-model="settings.onError">
-        <option v-for="item in onError" :key="item.id" :value="item.id">
-          {{ t(`workflow.settings.onError.items.${item.name}`) }}
-        </option>
-      </ui-select>
-      <div
-        v-if="settings.onError === 'restart-workflow'"
-        :title="t('workflow.settings.restartWorkflow.description')"
-        class="flex items-center bg-input transition-colors rounded-lg ml-4"
-      >
-        <input
-          v-model.number="settings.restartTimes"
-          type="number"
-          class="py-2 pl-2 text-right appearance-none w-12 rounded-lg bg-transparent"
-        />
-        <span class="px-2 text-sm">
-          {{ t('workflow.settings.restartWorkflow.times') }}
-        </span>
-      </div>
+  <ui-card padding="p-0" class="workflow-settings w-full max-w-2xl">
+    <div class="flex items-center px-4 pt-4">
+      <p class="flex-1">
+        {{ t('common.settings') }}
+      </p>
+      <v-remixicon
+        name="riCloseLine"
+        class="cursor-pointer"
+        @click="$emit('close')"
+      />
     </div>
-    <div class="flex items-center pt-4">
-      <div class="mr-4 flex-1">
-        <p>
-          {{ t('workflow.settings.blockDelay.title') }}
-        </p>
-        <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
-          {{ t('workflow.settings.blockDelay.description') }}
-        </p>
-      </div>
-      <ui-input v-model.number="settings.blockDelay" type="number" />
+    <div class="space-x-2 px-4 pt-2">
+      <ui-tabs v-model="activeTab" class="space-x-2">
+        <ui-tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
+          {{ tab.name }}
+        </ui-tab>
+      </ui-tabs>
     </div>
-    <div
-      v-for="item in settingItems"
-      :key="item.id"
-      class="flex items-center pt-4"
+    <ui-tab-panels
+      v-model="activeTab"
+      class="overflow-auto scroll pt-4 px-4 pb-4 settings-content"
+      style="height: calc(100vh - 10rem); max-height: 600px"
     >
-      <div class="mr-4 flex-1">
-        <p>
-          {{ item.name }}
-        </p>
-        <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
-          {{ item.description }}
-        </p>
-      </div>
-      <ui-switch v-model="settings[item.id]" class="mr-4" />
-    </div>
-    <div class="flex items-center pt-4">
-      <div class="mr-4 flex-1">
-        <p>
-          {{ t('workflow.settings.clearCache.title') }}
-        </p>
-        <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
-          {{ t('workflow.settings.clearCache.description') }}
-        </p>
-      </div>
-      <ui-button @click="onClearCacheClick">
-        {{ t('workflow.settings.clearCache.btn') }}
-      </ui-button>
-    </div>
-  </div>
+      <ui-tab-panel v-for="tab in tabs" :key="tab.value" :value="tab.value">
+        <component
+          :is="tab.component"
+          :settings="settings"
+          @update="settings[$event.key] = $event.value"
+        />
+      </ui-tab-panel>
+    </ui-tab-panels>
+  </ui-card>
 </template>
 <script setup>
-import { onMounted, reactive, watch } from 'vue';
+import { onMounted, ref, reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { useToast } from 'vue-toastification';
-import { clearCache, debounce } from '@/utils/helper';
+import { debounce } from '@/utils/helper';
+import SettingsTable from './settings/SettingsTable.vue';
+import SettingsBlocks from './settings/SettingsBlocks.vue';
+import SettingsGeneral from './settings/SettingsGeneral.vue';
 
 const props = defineProps({
   workflow: {
@@ -84,63 +46,37 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-const emit = defineEmits(['update']);
+const emit = defineEmits(['update', 'close']);
 
 const { t } = useI18n();
-const toast = useToast();
 
-const onError = [
-  {
-    id: 'keep-running',
-    name: 'keepRunning',
-  },
-  {
-    id: 'stop-workflow',
-    name: 'stopWorkflow',
-  },
-  {
-    id: 'restart-workflow',
-    name: 'restartWorkflow',
-  },
-];
-const settingItems = [
+const tabs = [
   {
-    id: 'debugMode',
-    name: t('workflow.settings.debugMode.title'),
-    description: t('workflow.settings.debugMode.description'),
+    value: 'general',
+    component: SettingsGeneral,
+    name: t('settings.menu.general'),
   },
   {
-    id: 'inputAutocomplete',
-    name: t('workflow.settings.autocomplete.title'),
-    description: t('workflow.settings.autocomplete.description'),
+    value: 'table',
+    component: SettingsTable,
+    name: t('workflow.table.title'),
   },
   {
-    id: 'reuseLastState',
-    name: t('workflow.settings.reuseLastState.title'),
-    description: t('workflow.settings.reuseLastState.description'),
-  },
-  {
-    id: 'saveLog',
-    name: t('workflow.settings.saveLog'),
-    description: '',
-  },
-  {
-    id: 'executedBlockOnWeb',
-    name: t('workflow.settings.executedBlockOnWeb'),
-    description: '',
+    value: 'blocks',
+    component: SettingsBlocks,
+    name: t('workflow.blocks.base.title'),
   },
 ];
 
+const activeTab = ref('general');
 const settings = reactive({
   restartTimes: 3,
+  tabLoadTimeout: 30000,
   inputAutocomplete: true,
+  insertDefaultColumn: true,
+  defaultColumnName: 'column',
 });
 
-async function onClearCacheClick() {
-  const cacheCleared = await clearCache(props.workflow);
-  if (cacheCleared) toast(t('workflow.settings.clearCache.info'));
-}
-
 watch(
   settings,
   debounce((newSettings) => {
@@ -155,3 +91,8 @@ onMounted(() => {
   Object.assign(settings, props.workflow.settings);
 });
 </script>
+<style>
+.settings-content .ui-tab-panel {
+  @apply space-y-4 space-y-4 divide-y dark:divide-gray-700 divide-gray-100;
+}
+</style>

+ 5 - 0
src/components/newtab/workflow/edit/EditAutocomplete.vue

@@ -1,5 +1,6 @@
 <template>
   <ui-autocomplete
+    :disabled="disabled"
     :items="autocompleteList"
     :trigger-char="['{{', '}}']"
     :custom-filter="autocompleteFilter"
@@ -14,6 +15,10 @@
 import { inject, shallowReactive, computed } from 'vue';
 import objectPath from 'object-path';
 
+defineProps({
+  disabled: Boolean,
+});
+
 const autocompleteData = inject('autocompleteData', {});
 const state = shallowReactive({
   path: '',

+ 55 - 7
src/components/newtab/workflow/edit/EditConditions.vue

@@ -1,14 +1,19 @@
 <template>
   <div>
-    <div class="mb-4 flex items-center justify-between">
+    <div class="mb-4 flex items-center space-x-2">
+      <p v-if="state.showSettings" class="font-semibold">
+        {{ t('common.settings') }}
+      </p>
       <ui-button
-        :disabled="conditions.length >= 10"
+        v-else
+        :disabled="conditions.length >= 20"
         variant="accent"
         class="mr-2"
         @click="addCondition"
       >
         {{ t('workflow.blocks.conditions.add') }}
       </ui-button>
+      <div class="flex-grow"></div>
       <ui-button
         v-tooltip:bottom="t('workflow.blocks.conditions.refresh')"
         icon
@@ -16,8 +21,49 @@
       >
         <v-remixicon name="riRefreshLine" />
       </ui-button>
+      <ui-button
+        v-tooltip:bottom="t('common.settings')"
+        icon
+        @click="state.showSettings = !state.showSettings"
+      >
+        <v-remixicon
+          :name="state.showSettings ? 'riCloseLine' : 'riSettings3Line'"
+        />
+      </ui-button>
     </div>
+    <template v-if="state.showSettings">
+      <label class="flex items-center mt-6">
+        <ui-switch
+          :model-value="data.retryConditions"
+          @change="updateData({ retryConditions: $event })"
+        />
+        <span class="ml-2 leading-tight">
+          {{ t('workflow.blocks.conditions.retryConditions') }}
+        </span>
+      </label>
+      <div v-if="data.retryConditions" class="mt-2">
+        <ui-input
+          :model-value="data.retryCount"
+          :title="t('workflow.blocks.element-exists.tryFor.title')"
+          :label="t('workflow.blocks.element-exists.tryFor.label')"
+          class="w-full mb-1"
+          type="number"
+          min="1"
+          @change="updateData({ retryCount: +$event })"
+        />
+        <ui-input
+          :model-value="data.retryTimeout"
+          :label="t('workflow.blocks.element-exists.timeout.label')"
+          :title="t('workflow.blocks.element-exists.timeout.title')"
+          class="w-full"
+          type="number"
+          min="200"
+          @change="updateData({ retryTimeout: +$event })"
+        />
+      </div>
+    </template>
     <draggable
+      v-else
       v-model="conditions"
       item-key="id"
       tag="ui-list"
@@ -57,7 +103,7 @@
           />
         </div>
         <div
-          class="overflow-auto p-4 mt-4 scroll"
+          class="overflow-auto p-4 scroll"
           style="height: calc(100vh - 8rem)"
         >
           <input
@@ -108,6 +154,7 @@ const conditions = ref(props.data.conditions);
 const state = shallowReactive({
   showModal: false,
   conditionsIndex: 0,
+  showSettings: false,
 });
 
 function editCondition(index) {
@@ -115,7 +162,7 @@ function editCondition(index) {
   state.showModal = true;
 }
 function addCondition() {
-  if (conditions.value.length >= 10) return;
+  if (conditions.value.length >= 20) return;
 
   emitter.emit('conditions-block:add', {
     id: props.blockId,
@@ -140,13 +187,14 @@ function refreshConnections() {
     id: props.blockId,
   });
 }
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
 
 watch(
   conditions,
   () => {
-    emit('update:data', {
-      conditions: conditions.value,
-    });
+    updateData({ conditions: conditions.value });
   },
   { deep: true }
 );

+ 24 - 0
src/components/newtab/workflow/edit/EditDelay.vue

@@ -0,0 +1,24 @@
+<template>
+  <div class="space-y-2">
+    <ui-input
+      :model-value="data.time"
+      label="Delay time (millisecond)"
+      class="w-full"
+      type="text"
+      @change="updateData({ time: $event })"
+    />
+  </div>
+</template>
+<script setup>
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 81 - 23
src/components/newtab/workflow/edit/EditGoogleSheets.vue

@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div class="mb-10">
     <ui-textarea
       :model-value="data.description"
       class="w-full mb-2"
@@ -9,13 +9,10 @@
     <ui-select
       :model-value="data.type"
       class="w-full mb-2"
-      @change="updateData({ type: $event })"
+      @change="onActionChange"
     >
-      <option value="get">
-        {{ t('workflow.blocks.google-sheets.select.get') }}
-      </option>
-      <option value="update">
-        {{ t('workflow.blocks.google-sheets.select.update') }}
+      <option v-for="action in actions" :key="action" :value="action">
+        {{ t(`workflow.blocks.google-sheets.select.${action}`) }}
       </option>
     </ui-select>
     <edit-autocomplete>
@@ -84,15 +81,19 @@
       <p v-if="previewDataState.status === 'error'" class="text-red-500">
         {{ previewDataState.errorMessage }}
       </p>
-      <shared-codemirror
-        v-if="previewDataState.data && previewDataState.status !== 'error'"
-        :model-value="previewDataState.data"
-        :line-numbers="false"
-        readonly
-        class="mt-4 max-h-96 scroll"
-      />
     </template>
-    <template v-else-if="data.type === 'update'">
+    <template v-else-if="data.type === 'getRange'">
+      <insert-workflow-data :data="data" variables @update="updateData" />
+      <ui-button
+        :loading="previewDataState.status === 'loading'"
+        variant="accent"
+        class="mt-4"
+        @click="previewData"
+      >
+        {{ t('workflow.blocks.google-sheets.previewData') }}
+      </ui-button>
+    </template>
+    <template v-else-if="['update', 'append'].includes(data.type)">
       <ui-select
         :model-value="data.valueInputOption"
         class="w-full mt-2"
@@ -116,6 +117,29 @@
           {{ option }}
         </option>
       </ui-select>
+      <ui-select
+        :model-value="data.insertDataOption || 'INSERT_ROWS'"
+        class="w-full mt-2"
+        @change="updateData({ insertDataOption: $event })"
+      >
+        <template #label>
+          {{ t('workflow.blocks.google-sheets.insertDataOption') }}
+          <a
+            href="https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption"
+            target="_blank"
+            rel="noopener"
+          >
+            <v-remixicon name="riInformationLine" size="18" class="inline" />
+          </a>
+        </template>
+        <option
+          v-for="option in insertDataOptions"
+          :key="option"
+          :value="option"
+        >
+          {{ option }}
+        </option>
+      </ui-select>
       <ui-select
         :model-value="data.dataFrom"
         :label="t('workflow.blocks.google-sheets.dataFrom.label')"
@@ -143,6 +167,17 @@
         {{ t('workflow.blocks.google-sheets.insertData') }}
       </ui-button>
     </template>
+    <shared-codemirror
+      v-if="
+        previewDataState.data &&
+        previewDataState.status !== 'error' &&
+        data.type !== 'update'
+      "
+      :model-value="previewDataState.data"
+      :line-numbers="false"
+      readonly
+      class="mt-4 max-h-96 scroll"
+    />
     <ui-modal
       v-model="customDataState.showModal"
       title="Custom data"
@@ -163,6 +198,7 @@ import { useI18n } from 'vue-i18n';
 import { googleSheets } from '@/utils/api';
 import { convert2DArrayToArrayObj } from '@/utils/helper';
 import EditAutocomplete from './EditAutocomplete.vue';
+import InsertWorkflowData from './InsertWorkflowData.vue';
 
 const SharedCodemirror = defineAsyncComponent(() =>
   import('@/components/newtab/shared/SharedCodemirror.vue')
@@ -178,8 +214,10 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
+const actions = ['get', 'getRange', 'update', 'append'];
 const dataFrom = ['data-columns', 'custom'];
 const valueInputOptions = ['RAW', 'USER_ENTERED'];
+const insertDataOptions = ['OVERWRITE', 'INSERT_ROWS'];
 
 const previewDataState = shallowReactive({
   data: '',
@@ -197,10 +235,15 @@ function updateData(value) {
 async function previewData() {
   try {
     previewDataState.status = 'loading';
-    const response = await googleSheets.getValues({
-      spreadsheetId: props.data.spreadsheetId,
+
+    const isGetValues = props.data.type === 'get';
+    const params = {
       range: props.data.range,
-    });
+      spreadsheetId: props.data.spreadsheetId,
+    };
+    const response = await (isGetValues
+      ? googleSheets.getValues(params)
+      : googleSheets.getRange(params));
 
     if (!response.ok) {
       const error = await response.json();
@@ -208,18 +251,33 @@ async function previewData() {
       throw new Error(error.statusMessage || response.statusText);
     }
 
-    const { values } = await response.json();
-    const sheetsData = props.data.firstRowAsKey
-      ? convert2DArrayToArrayObj(values)
-      : values;
+    let result = await response.json();
 
-    previewDataState.data = JSON.stringify(sheetsData, null, 2);
+    if (isGetValues) {
+      result = props.data.firstRowAsKey
+        ? convert2DArrayToArrayObj(result.values)
+        : result.values;
+    } else {
+      result = {
+        tableRange: result.tableRange || null,
+        lastRange: result.updates.updatedRange,
+      };
+    }
 
+    previewDataState.data = JSON.stringify(result, null, 2);
     previewDataState.status = 'idle';
   } catch (error) {
+    console.error(error);
     previewDataState.data = '';
     previewDataState.status = 'error';
     previewDataState.errorMessage = error.message;
   }
 }
+function onActionChange(value) {
+  updateData({ type: value });
+
+  previewDataState.data = '';
+  previewDataState.status = '';
+  previewDataState.errorMessage = '';
+}
 </script>

+ 3 - 5
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -41,12 +41,9 @@
             :rotate="show ? 270 : 180"
             class="mr-1 transition-transform -ml-1"
           />
-          Selector options
+          {{ t('workflow.blocks.base.selectorOptions') }}
         </template>
-        <div
-          v-if="(data.findBy || 'cssSelector') === 'cssSelector'"
-          class="mt-1"
-        >
+        <div class="mt-1">
           <ui-checkbox
             v-if="!data.disableMultiple && !hideMultiple"
             :title="t('workflow.blocks.base.multiple.title')"
@@ -57,6 +54,7 @@
             {{ t('workflow.blocks.base.multiple.text') }}
           </ui-checkbox>
           <ui-checkbox
+            v-if="(data.findBy || 'cssSelector') === 'cssSelector'"
             :model-value="data.markEl"
             :title="t('workflow.blocks.base.markElement.title')"
             @change="updateData({ markEl: $event })"

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

@@ -49,7 +49,7 @@
         :model-value="data.elementSelector"
         :label="t('workflow.blocks.base.selector')"
         autocomplete="off"
-        placeholder=".selector"
+        placeholder="CSS Selector or XPath"
         class="w-full"
         @change="updateData({ elementSelector: $event })"
       />

+ 25 - 14
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -24,6 +24,14 @@
     >
       {{ t('workflow.blocks.new-tab.updatePrevTab.text') }}
     </ui-checkbox>
+    <ui-checkbox
+      :model-value="data.waitTabLoaded"
+      class="leading-tight mt-2"
+      :title="t('workflow.blocks.new-tab.waitTabLoaded')"
+      @change="updateData({ waitTabLoaded: $event })"
+    >
+      {{ t('workflow.blocks.new-tab.waitTabLoaded') }}
+    </ui-checkbox>
     <ui-checkbox
       :model-value="data.active"
       class="my-2"
@@ -31,20 +39,22 @@
     >
       {{ t('workflow.blocks.new-tab.activeTab') }}
     </ui-checkbox>
-    <ui-checkbox
-      :model-value="data.inGroup"
-      @change="updateData({ inGroup: $event })"
-    >
-      {{ t('workflow.blocks.new-tab.tabToGroup') }}
-    </ui-checkbox>
-    <ui-checkbox
-      :model-value="data.customUserAgent"
-      block
-      class="mt-2"
-      @change="updateData({ customUserAgent: $event })"
-    >
-      {{ t('workflow.blocks.new-tab.customUserAgent') }}
-    </ui-checkbox>
+    <template v-if="browserType === 'chrome'">
+      <ui-checkbox
+        :model-value="data.inGroup"
+        @change="updateData({ inGroup: $event })"
+      >
+        {{ t('workflow.blocks.new-tab.tabToGroup') }}
+      </ui-checkbox>
+      <ui-checkbox
+        :model-value="data.customUserAgent"
+        block
+        class="mt-2"
+        @change="updateData({ customUserAgent: $event })"
+      >
+        {{ t('workflow.blocks.new-tab.customUserAgent') }}
+      </ui-checkbox>
+    </template>
     <ui-input
       v-if="data.customUserAgent"
       :model-value="data.userAgent"
@@ -67,6 +77,7 @@ const props = defineProps({
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
+const browserType = BROWSER_TYPE;
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });

+ 120 - 0
src/components/newtab/workflow/edit/EditPressKey.vue

@@ -0,0 +1,120 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full"
+      :placeholder="t('common.description')"
+      @change="updateData({ description: $event })"
+    />
+    <edit-autocomplete class="mt-2">
+      <ui-input
+        :model-value="data.selector"
+        class="w-full"
+        autocomplete="off"
+        label="Target element (Optional)"
+        placeholder="CSS Selector or XPath"
+        @change="updateData({ selector: $event })"
+      />
+    </edit-autocomplete>
+    <div class="flex items-end">
+      <ui-autocomplete
+        :items="keysList"
+        :model-value="dataKeys"
+        hide-empty
+        block
+        class="mt-2"
+      >
+        <ui-input
+          :label="t('workflow.blocks.press-key.key')"
+          :model-value="dataKeys"
+          :disabled="isRecordingKey"
+          placeholder="(Enter, Esc, a, b, ...)"
+          autocomplete="off"
+          class="w-full"
+          @change="updateKeys"
+        />
+      </ui-autocomplete>
+      <ui-button
+        v-tooltip="
+          isRecordingKey
+            ? t('common.cancel')
+            : t('workflow.blocks.press-key.detect')
+        "
+        icon
+        class="ml-2"
+        @click="toggleRecordKeys"
+      >
+        <v-remixicon :name="isRecordingKey ? 'riCloseLine' : 'riFocus3Line'" />
+      </ui-button>
+    </div>
+  </div>
+</template>
+<script setup>
+import { ref, onBeforeUnmount } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { keyDefinitions } from '@/utils/USKeyboardLayout';
+import { recordPressedKey } from '@/utils/recordKeys';
+import EditAutocomplete from './EditAutocomplete.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const includedKeys = ['Enter', 'Control', 'Meta', 'Shift', 'Alt', 'Space'];
+const filteredDefinitions = Object.keys(keyDefinitions).filter(
+  (key) => key.trim().length <= 1 || key.startsWith('Arrow')
+);
+const keysList = filteredDefinitions.concat(includedKeys);
+
+const { t } = useI18n();
+
+const isRecordingKey = ref(false);
+const dataKeys = ref(`${props.data.keys}`);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function updateKeys(value) {
+  dataKeys.value = value;
+  updateData({ keys: value });
+}
+function onKeydown(event) {
+  event.preventDefault();
+  event.stopPropagation();
+
+  recordPressedKey(event, (keys) => {
+    updateKeys(keys.join('+'));
+  });
+}
+function onKeyup() {
+  isRecordingKey.value = false;
+
+  /* eslint-disable-next-line */
+  detachKeyEvents();
+}
+function attachKeyEvents() {
+  window.addEventListener('keyup', onKeyup);
+  window.addEventListener('keydown', onKeydown);
+}
+function detachKeyEvents() {
+  window.removeEventListener('keyup', onKeyup);
+  window.removeEventListener('keydown', onKeydown);
+}
+function toggleRecordKeys() {
+  isRecordingKey.value = !isRecordingKey.value;
+
+  if (isRecordingKey.value) {
+    attachKeyEvents();
+  } else {
+    detachKeyEvents();
+  }
+}
+
+onBeforeUnmount(() => {
+  detachKeyEvents();
+});
+</script>

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

@@ -69,7 +69,7 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
-const schemes = Object.values(chrome.proxy.Scheme);
+const schemes = ['http', 'https', 'socks4', 'socks5'];
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });

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

@@ -213,7 +213,7 @@ import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import dayjs from 'dayjs';
 import { isObject } from '@/utils/helper';
-import recordShortcut from '@/utils/recordShortcut';
+import { recordShortcut } from '@/utils/recordKeys';
 
 const props = defineProps({
   data: {

+ 21 - 4
src/components/newtab/workflow/edit/EditUploadFile.vue

@@ -1,6 +1,19 @@
 <template>
-  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
-    <template v-if="hasFileAccess">
+  <edit-interaction-base
+    class="mb-8"
+    v-bind="{ data, hide: hideBase }"
+    @change="updateData"
+  >
+    <template v-if="hasFileAccess || browserType === 'firefox'">
+      <div
+        v-if="browserType === 'firefox'"
+        class="mt-4 p-2 rounded-lg bg-primary mt-4 flex text-white items-start"
+      >
+        <v-remixicon name="riErrorWarningLine" size="20" />
+        <div class="ml-2 flex-1 leading-tight text-sm">
+          <p>{{ t('workflow.blocks.upload-file.onlyURL') }}</p>
+        </div>
+      </div>
       <div class="mt-4 space-y-2">
         <div
           v-for="(path, index) in filePaths"
@@ -27,7 +40,9 @@
       </ui-button>
     </template>
     <template v-else>
-      <div class="mt-4 p-2 rounded-lg bg-red-200 flex items-start">
+      <div
+        class="mt-4 p-2 rounded-lg bg-red-200 dark:bg-red-400 flex items-start"
+      >
         <v-remixicon name="riErrorWarningLine" />
         <div class="ml-2 flex-1 leading-tight">
           <p>{{ t('workflow.blocks.upload-file.noFileAccess') }}</p>
@@ -47,6 +62,7 @@
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { ref, watch } from 'vue';
+import browser from 'webextension-polyfill';
 import EditInteractionBase from './EditInteractionBase.vue';
 import EditAutocomplete from './EditAutocomplete.vue';
 
@@ -63,6 +79,7 @@ const props = defineProps({
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
+const browserType = BROWSER_TYPE;
 
 const filePaths = ref([...props.data.filePaths]);
 const hasFileAccess = ref(true);
@@ -71,7 +88,7 @@ function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
 
-chrome.extension.isAllowedFileSchemeAccess((value) => {
+browser.extension.isAllowedFileSchemeAccess().then((value) => {
   hasFileAccess.value = value;
 });
 

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

@@ -15,7 +15,7 @@
         class="p-4 rounded-lg bg-green-200 dark:bg-green-300 flex items-start text-black"
       >
         <v-remixicon name="riInformationLine" />
-        <p class="flex-1 ml-4 text-gray-100 dark:text-black">
+        <p class="flex-1 ml-4">
           {{ t('workflow.blocks.base.onError.info') }}
         </p>
       </div>

+ 51 - 0
src/components/newtab/workflow/settings/SettingsBlocks.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="flex items-center">
+    <div class="mr-4 flex-1">
+      <p>
+        {{ t('workflow.settings.blockDelay.title') }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+        {{ t('workflow.settings.blockDelay.description') }}
+      </p>
+    </div>
+    <ui-input
+      :model-value="settings.blockDelay"
+      type="number"
+      @change="updateSetting('blockDelay', +$event)"
+    />
+  </div>
+  <div class="flex items-center pt-4">
+    <div class="mr-4 flex-1">
+      <p>
+        {{ t('workflow.settings.tabLoadTimeout.title') }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+        {{ t('workflow.settings.tabLoadTimeout.description') }}
+      </p>
+    </div>
+    <ui-input
+      :model-value="settings.tabLoadTimeout"
+      type="number"
+      min="0"
+      max="60000"
+      @change="updateSetting('tabLoadTimeout', +$event)"
+    />
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+defineProps({
+  settings: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+
+function updateSetting(key, value) {
+  emit('update', { key, value });
+}
+</script>

+ 145 - 0
src/components/newtab/workflow/settings/SettingsGeneral.vue

@@ -0,0 +1,145 @@
+<template>
+  <div class="flex items-center">
+    <div class="mr-4 flex-1">
+      <p>
+        {{ t('workflow.settings.onError.title') }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+        {{ t('workflow.settings.onError.description') }}
+      </p>
+    </div>
+    <ui-select
+      :model-value="settings.onError"
+      @change="updateSetting('onError', $event)"
+    >
+      <option v-for="item in onError" :key="item.id" :value="item.id">
+        {{ t(`workflow.settings.onError.items.${item.name}`) }}
+      </option>
+    </ui-select>
+    <div
+      v-if="settings.onError === 'restart-workflow'"
+      :title="t('workflow.settings.restartWorkflow.description')"
+      class="flex items-center bg-input transition-colors rounded-lg ml-4"
+    >
+      <input
+        :value="settings.restartTimes ?? 3"
+        type="number"
+        class="py-2 pl-2 text-right appearance-none w-12 rounded-lg bg-transparent"
+        @input="updateSetting('restartTimes', +($event.target.value ?? 3))"
+      />
+      <span class="px-2 text-sm">
+        {{ t('workflow.settings.restartWorkflow.times') }}
+      </span>
+    </div>
+  </div>
+  <div
+    v-for="item in settingItems"
+    :key="item.id"
+    class="flex items-center pt-4"
+  >
+    <div class="mr-4 flex-1">
+      <p>
+        {{ item.name }}
+      </p>
+      <p
+        v-if="item.notSupport?.includes(browserType)"
+        class="text-sm leading-tight text-red-400 dark:text-red-300"
+      >
+        {{
+          t('log.messages.browser-not-supported', {
+            browser: browserType,
+          })
+        }}
+      </p>
+      <p v-else class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+        {{ item.description }}
+      </p>
+    </div>
+    <ui-switch
+      v-if="!item.notSupport?.includes(browserType)"
+      :model-value="settings[item.id]"
+      @change="updateSetting(item.id, $event)"
+    />
+  </div>
+  <div class="flex items-center pt-4">
+    <div class="mr-4 flex-1">
+      <p>
+        {{ t('workflow.settings.clearCache.title') }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+        {{ t('workflow.settings.clearCache.description') }}
+      </p>
+    </div>
+    <ui-button @click="onClearCacheClick">
+      {{ t('workflow.settings.clearCache.btn') }}
+    </ui-button>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import { clearCache } from '@/utils/helper';
+
+defineProps({
+  settings: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+const toast = useToast();
+
+const browserType = BROWSER_TYPE;
+const onError = [
+  {
+    id: 'keep-running',
+    name: 'keepRunning',
+  },
+  {
+    id: 'stop-workflow',
+    name: 'stopWorkflow',
+  },
+  {
+    id: 'restart-workflow',
+    name: 'restartWorkflow',
+  },
+];
+const settingItems = [
+  {
+    id: 'debugMode',
+    notSupport: ['firefox'],
+    name: t('workflow.settings.debugMode.title'),
+    description: t('workflow.settings.debugMode.description'),
+  },
+  {
+    id: 'inputAutocomplete',
+    name: t('workflow.settings.autocomplete.title'),
+    description: t('workflow.settings.autocomplete.description'),
+  },
+  {
+    id: 'reuseLastState',
+    name: t('workflow.settings.reuseLastState.title'),
+    description: t('workflow.settings.reuseLastState.description'),
+  },
+  {
+    id: 'saveLog',
+    name: t('workflow.settings.saveLog'),
+    description: '',
+  },
+  {
+    id: 'executedBlockOnWeb',
+    name: t('workflow.settings.executedBlockOnWeb'),
+    description: '',
+  },
+];
+
+async function onClearCacheClick() {
+  const cacheCleared = await clearCache(props.workflow);
+  if (cacheCleared) toast(t('workflow.settings.clearCache.info'));
+}
+function updateSetting(key, value) {
+  emit('update', { key, value });
+}
+</script>

+ 45 - 0
src/components/newtab/workflow/settings/SettingsTable.vue

@@ -0,0 +1,45 @@
+<template>
+  <div class="flex items-center">
+    <div class="flex-grow">
+      <p>
+        {{ t('workflow.settings.defaultColumn.title') }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+        {{ t('workflow.settings.defaultColumn.description') }}
+      </p>
+    </div>
+    <ui-switch
+      :model-value="settings.insertDefaultColumn"
+      @change="updateSetting('insertDefaultColumn', $event)"
+    />
+  </div>
+  <transition-expand>
+    <div v-if="settings.insertDefaultColumn" class="flex pt-4 items-center">
+      <p class="flex-1">
+        {{ t('workflow.settings.defaultColumn.name') }}
+      </p>
+      <ui-input
+        :model-value="settings.defaultColumnName"
+        :title="t('workflow.settings.defaultColumn.name')"
+        @change="updateSetting('defaultColumnName', $event)"
+      />
+    </div>
+  </transition-expand>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+defineProps({
+  settings: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+
+function updateSetting(key, value) {
+  emit('update', { key, value });
+}
+</script>

+ 161 - 0
src/components/popup/home/HomeSelectBlock.vue

@@ -0,0 +1,161 @@
+<template>
+  <div class="px-4 pb-4">
+    <div class="flex items-center mt-4">
+      <button @click="$emit('goBack')">
+        <v-remixicon
+          name="riArrowLeftLine"
+          class="-ml-1 mr-1 align-bottom inline-block"
+        />
+      </button>
+      <p class="font-semibold flex-1 text-overflow">
+        {{ workflow.name }}
+      </p>
+    </div>
+    <p class="mt-2">
+      {{ t('home.record.selectBlock') }}
+    </p>
+    <div
+      ref="editorContainer"
+      class="parent-drawflow h-56 min-h w-full rounded-lg bg-box-transparent"
+    ></div>
+    <ui-button
+      :disabled="!state.activeBlock"
+      variant="accent"
+      class="mt-6 w-full"
+      @click="startRecording"
+    >
+      {{ t('home.record.button') }}
+    </ui-button>
+  </div>
+</template>
+<script setup>
+import {
+  shallowReactive,
+  ref,
+  getCurrentInstance,
+  shallowRef,
+  onMounted,
+} from 'vue';
+import { useI18n } from 'vue-i18n';
+import { findTriggerBlock } from '@/utils/helper';
+import drawflow from '@/lib/drawflow';
+
+const props = defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['goBack', 'record']);
+
+const { t } = useI18n();
+const context = getCurrentInstance().appContext.app._context;
+
+const editor = shallowRef(null);
+const editorContainer = ref(null);
+const state = shallowReactive({
+  activeBlock: null,
+  blockOutput: 'output_1',
+});
+
+function onEditorClick(event) {
+  const [target] = event.composedPath();
+  const nodeEl = target.closest('.drawflow-node');
+
+  if (nodeEl) {
+    const prevActiveEl = editorContainer.value.querySelector(
+      '.drawflow-node.selected'
+    );
+    if (prevActiveEl) {
+      prevActiveEl.classList.remove('selected');
+
+      const outputEl = prevActiveEl.querySelector('.output.active');
+      outputEl.classList.remove('active');
+    }
+
+    const nodeId = nodeEl.id.slice(5);
+    const node = editor.value.getNodeFromId(nodeId);
+    const outputs = Object.keys(node.outputs);
+
+    if (outputs.length === 0) {
+      alert(t('home.record.anotherBlock'));
+      state.activeBlock = null;
+      state.blockOutput = null;
+      return;
+    }
+
+    let outputEl = target.closest('.output');
+
+    if (outputEl) {
+      /* eslint-disable-next-line */
+      state.blockOutput = outputEl.classList[1];
+      outputEl.classList.add('active');
+    } else {
+      const firstOutput = outputs[0];
+
+      state.blockOutput = firstOutput || '';
+      outputEl = nodeEl.querySelector(`.${firstOutput}`);
+    }
+
+    if (outputEl) outputEl.classList.add('active');
+
+    nodeEl.classList.add('selected');
+    state.activeBlock = node;
+  }
+}
+function startRecording() {
+  const options = {
+    name: props.workflow.name,
+    workflowId: props.workflow.id,
+    connectFrom: {
+      id: state.activeBlock.id,
+      output: state.blockOutput,
+    },
+  };
+
+  emit('record', options);
+}
+
+onMounted(() => {
+  const flowData = props.workflow.drawflow;
+  const flow = typeof flowData === 'string' ? JSON.parse(flowData) : flowData;
+  const triggerBlock = findTriggerBlock(flow);
+
+  const editorInstance = drawflow(editorContainer.value, {
+    context,
+    options: {
+      zoom: 0.5,
+      zoom_min: 0.1,
+      zoom_max: 0.8,
+      minimap: true,
+      editor_mode: 'fixed',
+    },
+  });
+
+  editorInstance.start();
+  editorInstance.import(flow);
+
+  if (triggerBlock) {
+    const getCoordinate = (pos) => {
+      const num = Math.abs(pos);
+
+      if (pos > 0) return -num;
+
+      return num;
+    };
+
+    editorInstance.translate_to(
+      getCoordinate(triggerBlock.pos_x),
+      getCoordinate(triggerBlock.pos_y)
+    );
+  }
+
+  editor.value = editorInstance;
+  editorContainer.value.addEventListener('click', onEditorClick);
+});
+</script>
+<style>
+.output.active {
+  @apply ring-4;
+}
+</style>

+ 107 - 0
src/components/popup/home/HomeStartRecording.vue

@@ -0,0 +1,107 @@
+<template>
+  <ui-tabs
+    v-model="state.activeTab"
+    fill
+    class="mx-4"
+    @change="$emit('update', $event)"
+  >
+    <ui-tab v-for="tab in tabs" :key="tab" :value="tab">
+      {{ t(`home.record.tabs.${tab}`) }}
+    </ui-tab>
+  </ui-tabs>
+  <ui-tab-panels :model-value="state.activeTab">
+    <ui-tab-panel value="new" class="px-4 mt-3">
+      <form @submit.prevent="$emit('record', { name: state.workflowName })">
+        <ui-input
+          v-model="state.workflowName"
+          :label="t('home.record.name')"
+          :placeholder="t('common.name')"
+          autofocus
+          class="w-full"
+        />
+        <ui-button class="w-full mt-6" variant="accent" type="submit">
+          {{ t('home.record.button') }}
+        </ui-button>
+      </form>
+    </ui-tab-panel>
+    <ui-tab-panel cache value="existing">
+      <home-select-block
+        v-if="activeWorkflow"
+        :workflow="activeWorkflow"
+        @record="$emit('record', $event)"
+        @goBack="state.activeWorkflow = ''"
+      />
+      <template v-else>
+        <div class="px-4 mt-4">
+          <ui-input
+            v-model="state.query"
+            class="w-full"
+            prepend-icon="riSearch2Line"
+            :placeholder="t('common.search')"
+          />
+        </div>
+        <ui-list class="overflow-y-auto scroll px-4 mt-2 mb-4 h-72">
+          <ui-list-item
+            v-for="workflow in workflows"
+            :key="workflow.id"
+            small
+            class="cursor-pointer"
+            @click="state.activeWorkflow = workflow.id"
+          >
+            <img
+              v-if="workflow.icon?.startsWith('http')"
+              :src="workflow.icon"
+              class="overflow-hidden rounded-lg"
+              style="height: 32px; width: 32px"
+              alt="Can not display"
+            />
+            <span v-else class="p-2 rounded-lg bg-box-transparent">
+              <v-remixicon :name="workflow.icon" size="20" />
+            </span>
+            <div class="ml-2 overflow-hidden flex-1">
+              <p :title="workflow.name" class="text-overflow leading-tight">
+                {{ workflow.name }}
+              </p>
+              <p
+                :title="workflow.description"
+                class="text-overflow text-gray-600 leading-tight text-sm"
+              >
+                {{ workflow.description }}
+              </p>
+            </div>
+          </ui-list-item>
+        </ui-list>
+      </template>
+    </ui-tab-panel>
+  </ui-tab-panels>
+</template>
+<script setup>
+import { reactive, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Workflow from '@/models/workflow';
+import HomeSelectBlock from './HomeSelectBlock.vue';
+
+const emit = defineEmits(['update', 'close', 'record']);
+
+emit('update', 'new');
+
+const tabs = ['new', 'existing'];
+const { t } = useI18n();
+
+const state = reactive({
+  query: '',
+  workflowName: '',
+  activeTab: 'new',
+  activeWorkflow: '',
+});
+
+const activeWorkflow = computed(() => Workflow.find(state.activeWorkflow));
+const workflows = computed(() =>
+  Workflow.query()
+    .where(({ name }) =>
+      name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
+    )
+    .orderBy('createdAt', 'desc')
+    .get()
+);
+</script>

+ 63 - 0
src/components/transitions/TransitionSlide.vue

@@ -0,0 +1,63 @@
+<script>
+import { h, Transition, TransitionGroup } from 'vue';
+
+export default {
+  props: {
+    group: Boolean,
+    direction: {
+      type: String,
+      default: 'left',
+      validator: (value) => ['top', 'left', 'right', 'bottom'].includes(value),
+    },
+  },
+  setup(props, { slots, attrs }) {
+    const translateValues = {
+      0: '-100%',
+      1: '100%',
+    };
+    const directionsKey = {
+      top: 0,
+      left: 0,
+      right: 1,
+      bottom: 1,
+    };
+
+    function getTranslateStyle(key = 0) {
+      const isHorizontal = ['left', 'right'].includes(props.direction);
+      const value = translateValues[directionsKey[props.direction] + key];
+
+      if (isHorizontal) return `translateX(${value})`;
+
+      return `translateY(${value})`;
+    }
+    function enter(element) {
+      element.style.transform = getTranslateStyle();
+    }
+    function leave(element) {
+      element.style.transform = getTranslateStyle(1);
+    }
+    function afterEnter(element) {
+      element.style.transform = 'translate(0, 0)';
+    }
+
+    return () =>
+      h(
+        props.group ? TransitionGroup : Transition,
+        {
+          ...attrs,
+          name: 'slide',
+          onEnter: enter,
+          onAfterEnter: afterEnter,
+          onLeave: leave,
+        },
+        slots.default
+      );
+  },
+};
+</script>
+<style>
+.slide-enter-active,
+.slide-leave-active {
+  transition: transform 0.25s ease-out;
+}
+</style>

+ 24 - 13
src/components/ui/UiAutocomplete.vue

@@ -11,7 +11,9 @@
     <template #trigger>
       <slot />
     </template>
-    <p v-if="filteredItems.length === 0" class="text-center">No data to show</p>
+    <p v-if="filteredItems.length === 0" class="text-center">
+      {{ t('message.noData') }}
+    </p>
     <ui-list v-else class="space-y-1">
       <ui-list-item
         v-for="(item, index) in filteredItems"
@@ -37,6 +39,7 @@ import {
   shallowReactive,
   watch,
 } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { useComponentId } from '@/composable/componentId';
 import { debounce } from '@/utils/helper';
 
@@ -57,14 +60,6 @@ const props = defineProps({
     type: Array,
     default: () => [],
   },
-  block: {
-    type: Boolean,
-    default: false,
-  },
-  hideEmpty: {
-    type: Boolean,
-    default: false,
-  },
   customFilter: {
     type: Function,
     default: null,
@@ -73,10 +68,14 @@ const props = defineProps({
     type: [String, Array],
     default: null,
   },
+  block: Boolean,
+  disabled: Boolean,
+  hideEmpty: Boolean,
 });
 const emit = defineEmits(['update:modelValue', 'change', 'search']);
 
 let input = null;
+const { t } = useI18n();
 const componentId = useComponentId('autocomplete');
 
 const state = shallowReactive({
@@ -134,6 +133,8 @@ function getSearchText(caretIndex, charIndex) {
   return null;
 }
 function showPopover() {
+  if (props.disabled) return;
+
   if (props.triggerChar.length < 1) {
     state.showPopover = true;
     return;
@@ -264,10 +265,14 @@ function handleFocus() {
 
   showPopover();
 }
+function handleInput() {
+  state.inputChanged = true;
+}
 function attachEvents() {
   if (!input) return;
 
   input.addEventListener('blur', handleBlur);
+  input.addEventListener('input', handleInput);
   input.addEventListener('focus', handleFocus);
   input.addEventListener('input', showPopover);
   input.addEventListener('keydown', handleKeydown);
@@ -276,6 +281,7 @@ function detachEvents() {
   if (!input) return;
 
   input.removeEventListener('blur', handleBlur);
+  input.removeEventListener('input', handleInput);
   input.removeEventListener('focus', handleFocus);
   input.removeEventListener('input', showPopover);
   input.removeEventListener('keydown', handleKeydown);
@@ -295,14 +301,19 @@ watch(
     }
   }, 100)
 );
+watch(
+  () => filteredItems,
+  () => {
+    if (filteredItems.value.length === 0 && props.hideEmpty) {
+      state.showPopover = false;
+    }
+  },
+  { deep: true }
+);
 watch(
   () => state.showPopover,
   (value) => {
     if (!value) state.inputChanged = false;
-
-    if (props.hideEmpty && filteredItems.value.length === 0) {
-      state.showPopover = false;
-    }
   }
 );
 

+ 5 - 0
src/components/ui/UiTab.vue

@@ -4,6 +4,7 @@
     :class="[
       uiTabs.type.value,
       {
+        'pointer-events-none opacity-75': disabled,
         small: uiTabs.small.value,
         'flex-1': uiTabs.fill.value,
         'is-active': uiTabs.modelValue.value === value,
@@ -23,6 +24,10 @@ import { inject } from 'vue';
 
 /* eslint-disable-next-line */
 const props = defineProps({
+  disabled: {
+    type: Boolean,
+    default: false,
+  },
   value: {
     type: [String, Number],
     default: '',

+ 24 - 9
src/components/ui/UiTabPanel.vue

@@ -1,17 +1,11 @@
 <template>
-  <div
-    v-if="value === uiTabPanels.modelValue.value"
-    class="ui-tab-panel"
-    :class="activeClass"
-  >
-    <slot></slot>
-  </div>
+  <render />
 </template>
 <script setup>
-import { inject } from 'vue';
+import { inject, h, useSlots } from 'vue';
 
 /* eslint-disable-next-line */
-defineProps({
+const props = defineProps({
   value: {
     type: [String, Number],
     default: '',
@@ -20,7 +14,28 @@ defineProps({
     type: String,
     default: 'ui-tab-panel--active',
   },
+  cache: Boolean,
 });
 
+const slots = useSlots();
 const uiTabPanels = inject('ui-tab-panels', {});
+
+const render = () => {
+  const isActive = props.value === uiTabPanels.modelValue.value;
+  const cache = props.cache || uiTabPanels.cache.value;
+  const component = h(
+    'div',
+    {
+      class: [props.activeClass, 'ui-tab-panel'],
+      style: {
+        display: cache && !isActive ? 'none' : null,
+      },
+    },
+    slots
+  );
+
+  if (props.cache || isActive) return component;
+
+  return null;
+};
 </script>

+ 4 - 0
src/components/ui/UiTabPanels.vue

@@ -12,6 +12,10 @@ const props = defineProps({
     type: [String, Number],
     default: '',
   },
+  cache: {
+    type: Boolean,
+    default: false,
+  },
 });
 
 provide('ui-tab-panels', toRefs(props));

+ 2 - 1
src/composable/editorBlock.js

@@ -12,7 +12,8 @@ export function useEditorBlock(selector, editor) {
   });
 
   nextTick(() => {
-    const element = document.querySelector(selector);
+    const rootElement = editor.rootElement || document;
+    const element = rootElement.querySelector(selector);
 
     if (block.id || !element) return;
 

+ 4 - 3
src/content/blocksHandler/handlerEventClick.js

@@ -1,4 +1,5 @@
 import { sendMessage } from '@/utils/message';
+import { getElementPosition } from '../utils';
 import handleSelector from '../handleSelector';
 
 function eventClick(block) {
@@ -6,13 +7,13 @@ function eventClick(block) {
     handleSelector(block, {
       async onSelected(element) {
         if (block.debugMode) {
-          const { width, height, x, y } = element.getBoundingClientRect();
+          const { x, y } = await getElementPosition(element);
           const payload = {
             tabId: block.activeTabId,
             method: 'Input.dispatchMouseEvent',
             params: {
-              x: x + width / 2,
-              y: y + height / 2,
+              x,
+              y,
               button: 'left',
             },
           };

+ 6 - 5
src/content/blocksHandler/handlerHoverElement.js

@@ -1,20 +1,21 @@
 import { sendMessage } from '@/utils/message';
+import { getElementPosition } from '../utils';
 import handleSelector from '../handleSelector';
 
 function eventClick(block) {
   return new Promise((resolve, reject) => {
     handleSelector(block, {
       async onSelected(element) {
-        const { width, height, x, y } = element.getBoundingClientRect();
+        const { x, y } = await getElementPosition(element);
         const payload = {
           tabId: block.activeTabId,
           method: 'Input.dispatchMouseEvent',
           params: {
-            type: 'mousePressed',
-            x: x + width / 2,
-            y: y + height / 2,
-            button: 'left',
+            x,
+            y,
             clickCount: 1,
+            button: 'left',
+            type: 'mousePressed',
           },
         };
 

+ 8 - 35
src/content/blocksHandler/handlerJavascriptCode.js

@@ -1,7 +1,8 @@
 import { sendMessage } from '@/utils/message';
+import { automaRefDataStr } from '../utils';
 
 function getAutomaScript(blockId, everyNewTab) {
-  const str = `
+  let str = `
 function automaSetVariable(name, value) {
   const data = JSON.parse(sessionStorage.getItem('automa--${blockId}')) || null;
 
@@ -16,49 +17,22 @@ function automaNextBlock(data, insert = true) {
 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 (path.startsWith('$last') && Array.isArray(obj)) {
-    paths[0] = obj.length - 1;
-  }
-
-  if (paths.length === 0 || isWhitespace) return obj;
-  else if (paths.length === 1) return obj[paths[0]];
-
-  let result = obj;
-
-  for (let i = 0; i < paths.length; i++) {
-    if (result[paths[i]] == undefined) {
-      return undefined;
-    } else {
-      result = result[paths[i]];
-    }
-  }
-
-  return result;
-}
-function automaRefData(keyword, path = '') {
-  const data = JSON.parse(sessionStorage.getItem('automa--${blockId}')) || null;
-
-  if (data === null) return null;
-
-  return findData(data[keyword], path);
-}
+${automaRefDataStr(blockId)}
   `;
 
-  if (everyNewTab) return '';
+  if (everyNewTab) str = automaRefDataStr(blockId);
 
   return str;
 }
 
 function javascriptCode(block) {
-  if (!block.data.everyNewTab)
+  if (!block.data.everyNewTab) {
     sessionStorage.setItem(
       `automa--${block.id}`,
       JSON.stringify(block.refData)
     );
+  }
+
   const automaScript = getAutomaScript(block.id, block.data.everyNewTab);
 
   return new Promise((resolve, reject) => {
@@ -88,7 +62,7 @@ function javascriptCode(block) {
     }
 
     const promisePreloadScripts =
-      block.data?.preloadScripts.map(async (item) => {
+      block.data?.preloadScripts?.map(async (item) => {
         try {
           const { protocol, pathname } = new URL(item.src);
           const isValidUrl = /https?/.test(protocol) && /\.js$/.test(pathname);
@@ -167,7 +141,6 @@ function javascriptCode(block) {
       } else {
         resolve();
       }
-
       documentCtx.body.appendChild(script);
     });
   });

+ 143 - 0
src/content/blocksHandler/handlerPressKey.js

@@ -0,0 +1,143 @@
+import { sendMessage } from '@/utils/message';
+import { keyDefinitions } from '@/utils/USKeyboardLayout';
+import { queryElements } from '../handleSelector';
+
+const textFieldTags = ['INPUT', 'TEXTAREA'];
+const modifierKeys = [
+  { name: 'Alt', id: 1 },
+  { name: 'Meta', id: 4 },
+  { name: 'Shift', id: 8 },
+  { name: 'Control', id: 2 },
+];
+
+function pressKeyWithJs(element, keys) {
+  const details = {
+    key: '',
+    code: '',
+    keyCode: '',
+    bubbles: true,
+    altKey: false,
+    metaKey: false,
+    ctrlKey: false,
+    shiftKey: false,
+    cancelable: true,
+  };
+
+  ['keydown', 'keyup'].forEach((event) => {
+    keys.forEach((key) => {
+      const isLetter = /^[a-zA-Z]$/.test(key);
+
+      const isModKey = modifierKeys.some(({ name }) => name === key);
+      const dispatchEvent = () => {
+        const keyDefinition = keyDefinitions[key] || {
+          key,
+          keyCode: 0,
+          code: isLetter ? `Key${key}` : key,
+        };
+        const keyboardEvent = new KeyboardEvent(event, {
+          ...details,
+          ...keyDefinition,
+        });
+
+        element.dispatchEvent(keyboardEvent);
+      };
+
+      if (isModKey) {
+        const modKey = key.charAt(0).toLowerCase() + key.slice(1);
+        details[modKey] = true;
+
+        dispatchEvent();
+
+        return;
+      }
+
+      dispatchEvent();
+
+      if (event !== 'keydown') return;
+
+      const isEditable = element.isContentEditable;
+      const isTextField = textFieldTags.includes(element.tagName);
+
+      if (isEditable || isTextField) {
+        const isDigit = /^[0-9]$/.test(key);
+
+        if (isLetter || isDigit) {
+          const contentKey = isEditable ? 'textContent' : 'value';
+          element[contentKey] += key;
+
+          return;
+        }
+
+        if (key === 'Enter') {
+          const isSubmitForm =
+            element.tagName === 'INPUT' &&
+            element.form &&
+            !details.ctrlKey &&
+            !details.altKey;
+
+          if (isSubmitForm) {
+            element.form.submit();
+            return;
+          }
+
+          element[contentKey] += '\r\n';
+        }
+      }
+    });
+  });
+}
+async function pressKeyWithCommand(_, keys, activeTabId) {
+  for (const event of ['keyDown', 'keyUp']) {
+    let modifierKey = 0;
+
+    for (const key of keys) {
+      const command = {
+        tabId: activeTabId,
+        method: 'Input.dispatchKeyEvent',
+        params: {
+          key,
+          code: '',
+          type: event,
+          modifiers: 0,
+          windowsVirtualKeyCode: 0,
+        },
+      };
+      const definition = keyDefinitions[key];
+
+      if (definition) {
+        Object.assign(command.params, definition);
+
+        command.params.windowsVirtualKeyCode = definition.keyCode;
+        command.params.nativeVirtualKeyCode = definition.keyCode;
+
+        const isModKey = modifierKeys.find(({ name }) => name === key);
+        if (isModKey) modifierKey = isModKey.id;
+        else command.params.modifiers = modifierKey;
+      }
+
+      await sendMessage('debugger:send-command', command, 'background');
+    }
+  }
+}
+
+async function pressKey({ data, debugMode, activeTabId }) {
+  let element = document.activeElement;
+
+  if (data.selector) {
+    const customElement = await queryElements({
+      selector: data.selector,
+      findBy: data.selector.startsWith('/') ? 'xpath' : 'cssSelector',
+    });
+
+    element = customElement || element;
+  }
+
+  const keys = data.keys.split('+');
+  const pressKeyFunction = debugMode ? pressKeyWithCommand : pressKeyWithJs;
+
+  await pressKeyFunction(element, keys, activeTabId);
+
+  return '';
+}
+
+export default pressKey;

+ 0 - 0
src/content/blocksHandler/handlerSaveSssets.js → src/content/blocksHandler/handlerSaveAssets.js


+ 16 - 7
src/content/blocksHandler/handlerTriggerEvent.js

@@ -1,6 +1,8 @@
 import { sendMessage } from '@/utils/message';
 import simulateEvent from '@/utils/simulateEvent';
 import simulateMouseEvent from '@/utils/simulateEvent/mouseEvent';
+import { keyDefinitions } from '@/utils/USKeyboardLayout';
+import { getElementPosition } from '../utils';
 import handleSelector from '../handleSelector';
 
 const modifiers = {
@@ -38,6 +40,8 @@ const eventHandlers = {
     await mouseEvents[eventName]();
   },
   'keyboard-event': async ({ name, params, sendCommand }) => {
+    const definition = keyDefinitions[params?.key];
+
     const commandParams = {
       key: params.key ?? '',
       code: params.code ?? '',
@@ -46,6 +50,10 @@ const eventHandlers = {
       type: name === 'keyup' ? 'keyUp' : 'keyDown',
     };
 
+    if (definition.text || params.key.length === 1) {
+      commandParams.text = definition.text || params.key;
+    }
+
     Object.keys(modifiers).forEach((key) => {
       if (commandParams.modifiers) return;
       if (params[key]) commandParams.modifiers = modifiers[key];
@@ -64,17 +72,18 @@ function triggerEvent({ data, id, frameSelector, debugMode, activeTabId }) {
           const eventHandler = eventHandlers[data.eventType];
 
           if (debugMode && eventHandler) {
-            const { x, y, width, height } = element.getBoundingClientRect();
-            const elCoordinate = {
-              x: x + width / 2,
-              y: y + height / 2,
-            };
+            let elCoordinate = {};
+
+            if (data.eventType === 'mouse-event') {
+              const { x, y } = await getElementPosition(element);
+              elCoordinate = { x, y };
+            }
+
             const sendCommand = (method, params = {}) => {
               const payload = {
                 method,
                 params: {
-                  x: elCoordinate.x,
-                  y: elCoordinate.y,
+                  ...elCoordinate,
                   ...params,
                 },
                 tabId: activeTabId,

+ 131 - 172
src/content/elementSelector/App.vue

@@ -5,16 +5,16 @@
       'bg-black bg-opacity-30': !state.hide,
     }"
     class="root fixed h-full w-full pointer-events-none top-0 text-black left-0"
-    style="z-index: 9999999999; font-family: Inter, sans-serif; font-size: 16px"
+    style="z-index: 99999999"
   >
     <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"
+      class="relative root-card bg-white shadow-xl z-50 pointer-events-auto rounded-lg"
     >
       <div
-        class="absolute p-2 drag-button shadow-xl bg-white p-1 cursor-move rounded-lg"
+        class="absolute p-2 drag-button z-50 shadow-xl bg-white p-1 cursor-move rounded-lg"
         style="top: -15px; left: -15px"
       >
         <v-remixicon
@@ -22,154 +22,99 @@
           @mousedown="state.isDragging = true"
         />
       </div>
-      <div class="flex items-center">
-        <p class="ml-1 text-lg font-semibold">Automa</p>
+      <div class="flex px-4 pt-4 items-center">
+        <ui-tabs
+          v-if="false"
+          v-model="mainActiveTab"
+          type="fill"
+          class="main-tab"
+        >
+          <ui-tab value="selector"> Selector </ui-tab>
+          <ui-tab value="workflow"> Workflow </ui-tab>
+        </ui-tabs>
+        <p class="text-lg font-semibold">Automa</p>
         <div class="flex-grow"></div>
-        <ui-button icon class="mr-2" @click="state.hide = !state.hide">
+        <button
+          class="mr-2 hoverable p-1 rounded-md transition"
+          @click="state.hide = !state.hide"
+        >
           <v-remixicon :name="state.hide ? 'riEyeOffLine' : 'riEyeLine'" />
-        </ui-button>
-        <ui-button icon @click="destroy">
+        </button>
+        <button class="hoverable p-1 rounded-md transition" @click="destroy">
           <v-remixicon name="riCloseLine" />
-        </ui-button>
+        </button>
+      </div>
+      <div class="p-4">
+        <selector-query
+          v-model:selectorType="state.selectorType"
+          v-model:selectList="state.selectList"
+          :selector="state.elSelector"
+          :selected-count="state.selectedElements.length"
+          @child="selectChildElement"
+          @parent="selectParentElement"
+          @change="updateSelectedElements"
+        />
+        <selector-elements-detail
+          v-if="!state.hide && state.selectedElements.length > 0"
+          v-model:active-tab="state.activeTab"
+          v-bind="{
+            elSelector: state.elSelector,
+            selectElements: state.selectElements,
+            selectedElements: state.selectedElements,
+          }"
+          @highlight="toggleHighlightElement"
+          @execute="state.isExecuting = $event"
+        />
       </div>
-      <app-selector
-        v-model:selectorType="state.selectorType"
-        v-model:selectList="state.selectList"
-        :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 v-if="state.selectElements.length > 0" value="options">
-            Options
-          </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 - 17rem)"
-        >
-          <ui-tab-panel value="attributes">
-            <app-element-list
-              :elements="state.selectedElements"
-              @highlight="toggleHighlightElement"
-            >
-              <template #item="{ element }">
-                <div
-                  v-for="(value, name) in element.attributes"
-                  :key="name"
-                  class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
-                >
-                  <p
-                    class="text-sm text-overflow leading-tight text-gray-600"
-                    title="Attribute name"
-                  >
-                    {{ name }}
-                  </p>
-                  <input
-                    :value="value"
-                    readonly
-                    title="Attribute value"
-                    class="bg-transparent w-full"
-                  />
-                </div>
-              </template>
-            </app-element-list>
-          </ui-tab-panel>
-          <ui-tab-panel value="options">
-            <app-element-list
-              :elements="state.selectElements"
-              element-name="Select element options"
-              @highlight="
-                toggleHighlightElement({
-                  index: $event.element.index,
-                  highlight: $event.highlight,
-                })
-              "
-            >
-              <template #item="{ element }">
-                <div
-                  v-for="option in element.options"
-                  :key="option.name"
-                  class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
-                >
-                  <p
-                    class="text-sm text-overflow leading-tight text-gray-600"
-                    title="Option name"
-                  >
-                    {{ option.name }}
-                  </p>
-                  <input
-                    :value="option.value"
-                    title="Option value"
-                    class="text-overflow focus:ring-0 w-full bg-transparent"
-                    readonly
-                    @click="$event.target.select()"
-                  />
-                </div>
-              </template>
-            </app-element-list>
-          </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
+    <shared-element-highlighter
       v-if="!state.hide"
-      class="h-full w-full absolute top-0 pointer-events-none left-0 z-10"
-    >
-      <app-element-highlighter
-        :items="state.hoveredElements"
-        stroke="#fbbf24"
-        fill="rgba(251, 191, 36, 0.1)"
-      />
-      <app-element-highlighter
-        :items="state.selectedElements"
-        stroke="#2563EB"
-        active-stroke="#f87171"
-        fill="rgba(37, 99, 235, 0.1)"
-        active-fill="rgba(248, 113, 113, 0.1)"
-      />
-    </svg>
+      :disabled="state.hide"
+      :data="elementsHighlightData"
+      :items="{
+        hoveredElements: state.hoveredElements,
+        selectedElements: state.selectedElements,
+      }"
+      @update="state[$event.key] = $event.items"
+    />
   </div>
+  <teleport to="body">
+    <div
+      v-if="!state.hide"
+      style="
+        z-index: 9999999;
+        position: fixed;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+      "
+    ></div>
+  </teleport>
 </template>
 <script setup>
-import { reactive, ref, watch, inject, nextTick } from 'vue';
+import { reactive, ref, watch, inject, onMounted, onBeforeUnmount } from 'vue';
 import { getCssSelector } from 'css-selector-generator';
-import { debounce } from '@/utils/helper';
 import { finder } from '@medv/finder';
+import { elementsHighlightData } from '@/utils/shared';
 import findElement from '@/utils/FindElement';
-import AppBlocks from './AppBlocks.vue';
-import AppSelector from './AppSelector.vue';
-import AppElementList from './AppElementList.vue';
-import AppElementHighlighter from './AppElementHighlighter.vue';
+import SelectorQuery from '@/components/content/selector/SelectorQuery.vue';
+import SelectorElementsDetail from '@/components/content/selector/SelectorElementsDetail.vue';
+import SharedElementHighlighter from '@/components/content/shared/SharedElementHighlighter.vue';
 import findElementList from './listSelector';
 
 const selectedElement = {
   path: [],
   pathIndex: 0,
 };
-let lastScrollPosY = window.scrollY;
-let lastScrollPosX = window.scrollX;
+
 const originalFontSize = document.documentElement.style.fontSize;
 
 const rootElement = inject('rootElement');
 
 const cardEl = ref('cardEl');
+const mainActiveTab = ref('selector');
 const state = reactive({
-  activeTab: '',
   elSelector: '',
   listSelector: '',
   isDragging: false,
@@ -179,6 +124,7 @@ const state = reactive({
   hoveredElements: [],
   selectorType: 'css',
   selectedElements: [],
+  activeTab: 'attributes',
   hide: window.self !== window.top,
 });
 const cardRect = reactive({
@@ -188,6 +134,13 @@ const cardRect = reactive({
   width: 0,
 });
 
+const cardElementObserver = new ResizeObserver(([entry]) => {
+  const { height, width } = entry.contentRect;
+
+  cardRect.width = width;
+  cardRect.height = height;
+});
+
 /* eslint-disable  no-use-before-define */
 const getElementSelector = (element, options = {}) => {
   if (state.selectorType === 'css') {
@@ -330,19 +283,15 @@ function getElementList(target) {
 }
 let prevHoverElement = null;
 function handleMouseMove({ clientX, clientY, target }) {
-  if (prevHoverElement === target) return;
-
-  prevHoverElement = target;
-
   if (state.isDragging) {
     const height = window.innerHeight;
     const width = document.documentElement.clientWidth;
 
-    if (clientY < 10) clientY = 0;
+    if (clientY < 10) clientY = 10;
     else if (cardRect.height + clientY > height)
       clientY = height - cardRect.height;
 
-    if (clientX < 10) clientX = 0;
+    if (clientX < 10) clientX = 10;
     else if (cardRect.width + clientX > width) clientX = width - cardRect.width;
 
     cardRect.x = clientX;
@@ -351,27 +300,34 @@ function handleMouseMove({ clientX, clientY, target }) {
     return;
   }
 
+  const { 1: realTarget } = document.elementsFromPoint(clientX, clientY);
+
+  if (prevHoverElement === realTarget) return;
+  prevHoverElement = realTarget;
+
   if (state.hide || rootElement === target) return;
 
   let elementsRect = [];
 
   if (state.selectList) {
-    const elements = getElementList(target) || [];
+    const elements = getElementList(realTarget) || [];
 
     elementsRect = elements.map((el) => getElementRect(el, true));
   } else {
-    elementsRect = [getElementRect(target)];
+    elementsRect = [getElementRect(realTarget)];
   }
 
   state.hoveredElements = elementsRect;
 }
 function handleClick(event) {
-  const { target, path, ctrlKey } = event;
+  const { target: eventTarget, path, ctrlKey, clientY, clientX } = event;
 
-  if (target === rootElement || state.hide || state.isExecuting) return;
+  if (eventTarget === rootElement || state.hide || state.isExecuting) return;
   event.stopPropagation();
   event.preventDefault();
 
+  const { 1: target } = document.elementsFromPoint(clientX, clientY);
+
   if (state.selectList) {
     const firstElement = state.hoveredElements[0].element;
 
@@ -397,9 +353,7 @@ function handleClick(event) {
       element.setAttribute('automa-el-list', '');
     });
 
-    const parentSelector = getCssSelector(firstElement.parentElement, {
-      includeTag: true,
-    });
+    const parentSelector = finder(firstElement.parentElement);
     const elementSelector = `${parentSelector} > ${firstElement.tagName.toLowerCase()}`;
 
     state.listSelector = elementSelector;
@@ -471,6 +425,7 @@ function selectChildElement() {
 
     childElement = childEl;
     selectedElement.path.unshift(childEl);
+    selectedElement.pathIndex = 0;
   } else {
     selectedElement.pathIndex -= 1;
     childElement = selectedElement.path[selectedElement.pathIndex];
@@ -492,29 +447,6 @@ function selectParentElement() {
 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;
-  const updateState = (key) => {
-    state[key].forEach((_, index) => {
-      state[key][index].x -= xPos;
-      state[key][index].y -= yPos;
-    });
-  };
-
-  updateState('hoveredElements');
-  updateState('selectedElements');
-
-  lastScrollPosX = window.scrollX;
-  lastScrollPosY = window.scrollY;
-}, 100);
 function destroy() {
   rootElement.style.display = 'none';
 
@@ -535,11 +467,20 @@ function destroy() {
 
   document.documentElement.style.fontSize = originalFontSize;
 }
+function attachListeners() {
+  cardElementObserver.observe(cardEl.value);
 
-window.addEventListener('scroll', handleScroll);
-window.addEventListener('mouseup', handleMouseUp);
-window.addEventListener('mousemove', handleMouseMove);
-document.addEventListener('click', handleClick, true);
+  window.addEventListener('mouseup', handleMouseUp);
+  window.addEventListener('mousemove', handleMouseMove);
+  document.addEventListener('click', handleClick, true);
+}
+function detachListeners() {
+  cardElementObserver.disconnect();
+
+  window.removeEventListener('mouseup', handleMouseUp);
+  window.removeEventListener('mousemove', handleMouseMove);
+  document.removeEventListener('click', handleClick, true);
+}
 
 watch(
   () => state.isDragging,
@@ -547,7 +488,6 @@ watch(
     document.body.toggleAttribute('automa-isDragging', value);
   }
 );
-watch(() => [state.elSelector, state.activeTab, state.hide], updateCardSize);
 watch(
   () => state.selectList,
   (value) => {
@@ -562,7 +502,7 @@ watch(
   }
 );
 
-nextTick(() => {
+onMounted(() => {
   setTimeout(() => {
     const { height, width } = cardEl.value.getBoundingClientRect();
 
@@ -576,15 +516,34 @@ nextTick(() => {
       '16px',
       'important'
     );
-  }, 250);
+  }, 500);
+
+  attachListeners();
+});
+onBeforeUnmount(() => {
+  detachListeners();
 });
 </script>
 <style>
+.root {
+  font-size: 16px;
+  z-index: 99999;
+  line-height: 1.5 !important;
+  font-family: 'Inter var', sans-serif;
+  font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
+}
+.root-card:hover .drag-button {
+  transform: scale(1);
+}
 .drag-button {
   transform: scale(0);
   transition: transform 200ms ease-in-out;
 }
-.root-card:hover .drag-button {
-  transform: scale(1);
+.main-tab {
+  background-color: transparent !important;
+  padding: 0 !important;
+}
+.main-tab .ui-tab.is-active.fill {
+  @apply bg-accent text-white !important;
 }
 </style>

+ 0 - 46
src/content/elementSelector/AppElementHighlighter.vue

@@ -1,46 +0,0 @@
-<template>
-  <rect
-    v-for="(item, index) in items"
-    v-bind="{
-      ...item,
-      'stroke-dasharray': item.outline ? '5,5' : null,
-      fill: getFillColor(item),
-      stroke: getStrokeColor(item),
-    }"
-    :key="index"
-    stroke-width="2"
-  ></rect>
-</template>
-<script setup>
-const props = defineProps({
-  items: {
-    type: Object,
-    default: () => ({}),
-  },
-  stroke: {
-    type: String,
-    default: null,
-  },
-  activeStroke: {
-    type: String,
-    default: null,
-  },
-  fill: {
-    type: String,
-    default: null,
-  },
-  activeFill: {
-    type: String,
-    default: null,
-  },
-});
-
-function getFillColor(item) {
-  if (item.outline) return null;
-
-  return item.highlight ? props.fill : props.activeFill || props.fill;
-}
-function getStrokeColor(item) {
-  return item.highlight ? props.stroke : props.activeStroke || props.stroke;
-}
-</script>

+ 2 - 36
src/content/elementSelector/index.js

@@ -1,31 +1,6 @@
 import browser from 'webextension-polyfill';
 import initElementSelector from './main';
-
-async function getStyles() {
-  try {
-    const response = await fetch(chrome.runtime.getURL('/elementSelector.css'));
-    const mainCSS = await response.text();
-
-    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");
-      }
-    `;
-
-    return `${mainCSS}\n${fontCSS}`;
-  } catch (error) {
-    console.error(error);
-    return '';
-  }
-}
+import injectAppStyles from '../injectAppStyles';
 
 function elementSelectorInstance() {
   const rootElementExist = document.querySelector(
@@ -62,19 +37,10 @@ function elementSelectorInstance() {
     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; direction: ltr } \n [automa-isDragging] { user-select: none } \n [automa-el-list] {outline: 2px dashed #6366f1;}`;
-
     initElementSelector(rootElement);
-
-    const appStyle = document.createElement('style');
-    appStyle.innerHTML = await getStyles();
-
-    rootElement.shadowRoot.appendChild(appStyle);
+    await injectAppStyles(rootElement.shadowRoot);
 
     document.documentElement.appendChild(rootElement);
-    document.documentElement.appendChild(automaStyle);
   } catch (error) {
     console.error(error);
   }

+ 49 - 60
src/content/handleSelector.js

@@ -1,5 +1,5 @@
 import FindElement from '@/utils/FindElement';
-import { scrollIfNeeded } from '@/utils/helper';
+import { visibleInViewport } from '@/utils/helper';
 
 /* eslint-disable consistent-return */
 
@@ -9,103 +9,92 @@ export function markElement(el, { id, data }) {
   }
 }
 
-export function waitForSelector({
-  timeout,
-  selector,
-  documentCtx = document,
-} = {}) {
+export function getDocumentCtx(frameSelector) {
+  let documentCtx = document;
+
+  if (frameSelector) {
+    documentCtx = document.querySelector(frameSelector)?.contentDocument;
+  }
+
+  return documentCtx;
+}
+
+export function queryElements(data, documentCtx = document) {
   return new Promise((resolve) => {
+    let timeout = null;
     let isTimeout = false;
+
     const findSelector = () => {
       if (isTimeout) return;
 
-      const element = documentCtx.querySelector(selector);
+      const selectorType = data.findBy || 'cssSelector';
+      const elements = FindElement[selectorType](data, documentCtx);
+      const isElNotFound = !elements || elements.length === 0;
 
-      if (!element) {
+      if (isElNotFound && data.waitForSelector) {
         setTimeout(findSelector, 200);
       } else {
-        resolve(element);
+        clearTimeout(timeout);
+        resolve(elements);
       }
     };
 
     findSelector();
 
-    setTimeout(() => {
-      isTimeout = true;
-      resolve(null);
-    }, timeout);
+    if (data.waitForSelector) {
+      timeout = setTimeout(() => {
+        isTimeout = true;
+        resolve(null);
+      }, data.waitSelectorTimeout);
+    }
   });
 }
 
 export default async function (
   { data, id, frameSelector, debugMode },
-  { onSelected, onError, onSuccess, returnElement }
+  { onSelected, onError, onSuccess }
 ) {
   if (!data || !data.selector) {
     if (onError) onError(new Error('selector-empty'));
     return null;
   }
 
-  let documentCtx = document;
-
-  if (frameSelector) {
-    const iframeCtx = document.querySelector(frameSelector)?.contentDocument;
-
-    if (!iframeCtx && returnElement) return null;
-    if (!iframeCtx && onError) {
-      onError(new Error('iframe-not-found'));
-      return;
-    }
+  const documentCtx = getDocumentCtx(frameSelector);
+  if (!documentCtx) {
+    if (onError) onError(new Error('iframe-not-found'));
 
-    documentCtx = iframeCtx;
-  }
-
-  if (data.waitForSelector && data.findBy === 'cssSelector') {
-    const element = await waitForSelector({
-      documentCtx,
-      selector: data.selector,
-      timeout: data.waitSelectorTimeout,
-    });
-
-    if (!element) {
-      if (returnElement) return element;
-
-      if (onError) {
-        onError(new Error('element-not-found'));
-        return;
-      }
-    }
+    return null;
   }
 
   try {
     data.blockIdAttr = `block--${id}`;
 
-    const selectorType = data.findBy || 'cssSelector';
-    const element = FindElement[selectorType](data, documentCtx);
+    const elements = await queryElements(data, documentCtx);
 
-    if (returnElement) return element;
-
-    if (!element) {
+    if (!elements || elements.length === 0) {
       if (onError) onError(new Error('element-not-found'));
 
       return null;
     }
 
-    if (data.multiple && selectorType === 'cssSelector') {
-      await Promise.allSettled(
-        Array.from(element).map((el) => {
-          markElement(el, { id, data });
-          if (debugMode) scrollIfNeeded(el);
-          return onSelected(el);
-        })
-      );
-    } else if (element) {
-      markElement(element, { id, data });
-      if (debugMode) scrollIfNeeded(element);
-      await onSelected(element);
-    }
+    const elementsArr = data.multiple ? Array.from(elements) : [elements];
+
+    await Promise.allSettled(
+      elementsArr.map(async (el) => {
+        markElement(el, { id, data });
+
+        if (debugMode) {
+          const isInViewport = visibleInViewport(el);
+          if (!isInViewport) el.scrollIntoView();
+        }
+
+        if (onSelected) await onSelected(el);
+      })
+    );
 
     if (onSuccess) onSuccess();
+
+    return elements;
   } catch (error) {
     console.error(error);
   }

+ 97 - 0
src/content/handleTestCondition.js

@@ -0,0 +1,97 @@
+import { nanoid } from 'nanoid';
+import { visibleInViewport } from '@/utils/helper';
+import FindElement from '@/utils/FindElement';
+import { automaRefDataStr } from './utils';
+
+function handleConditionElement({ data, type }) {
+  const selectorType = data.selector.startsWith('/') ? 'xpath' : 'cssSelector';
+
+  const element = FindElement[selectorType](data);
+  const { 1: actionType } = type.split('#');
+
+  const elementActions = {
+    exists: () => Boolean(element),
+    notExists: () => !element,
+    text: () => element?.innerText ?? null,
+    visibleScreen: () => visibleInViewport(element),
+    visible: () => {
+      if (!element) return false;
+
+      const { visibility, display } = getComputedStyle(element);
+
+      return visibility !== 'hidden' && display !== 'none';
+    },
+    invisible: () => {
+      if (!element) return false;
+
+      const { visibility, display } = getComputedStyle(element);
+      const styleHidden = visibility === 'hidden' || display === 'none';
+
+      return styleHidden || !visibleInViewport(element);
+    },
+    attribute: ({ attrName }) => {
+      if (!element || !element.hasAttribute(attrName)) return null;
+
+      return element.getAttribute(attrName);
+    },
+  };
+
+  return elementActions[actionType](data);
+}
+function injectJsCode({ data, refData }) {
+  return new Promise((resolve, reject) => {
+    const stateId = nanoid();
+
+    sessionStorage.setItem(`automa--${stateId}`, JSON.stringify(refData));
+
+    const scriptEl = document.createElement('script');
+    scriptEl.textContent = `
+      (async () => {
+        ${automaRefDataStr(stateId)}
+        try {
+          ${data.code}
+        } catch (error) {
+          return {
+            $isError: true,
+            message: error.message,
+          }
+        }
+      })()
+        .then((detail) => {
+          window.dispatchEvent(new CustomEvent('__automa-condition-code__', { detail }));
+        });
+    `;
+
+    document.body.appendChild(scriptEl);
+
+    const handleAutomaEvent = ({ detail }) => {
+      scriptEl.remove();
+      window.removeEventListener(
+        '__automa-condition-code__',
+        handleAutomaEvent
+      );
+
+      if (detail.$isError) {
+        reject(new Error(detail.message));
+        return;
+      }
+
+      resolve(detail);
+    };
+
+    window.addEventListener('__automa-condition-code__', handleAutomaEvent);
+  });
+}
+
+export default async function (data) {
+  let result = null;
+
+  if (data.type.startsWith('element')) {
+    result = await handleConditionElement(data);
+  }
+  if (data.type.startsWith('code')) {
+    result = await injectJsCode(data);
+  }
+
+  return result;
+}

+ 37 - 38
src/content/index.js

@@ -2,50 +2,39 @@ import browser from 'webextension-polyfill';
 import { nanoid } from 'nanoid';
 import { toCamelCase } from '@/utils/helper';
 import FindElement from '@/utils/FindElement';
+import { getDocumentCtx } from './handleSelector';
 import executedBlock from './executedBlock';
 import blocksHandler from './blocksHandler';
+import handleTestCondition from './handleTestCondition';
 
-function handleConditionBuilder({ data, type }) {
-  if (!type.startsWith('element')) return null;
+function messageListener({ data, source }) {
+  if (data !== 'automa:get-frame') return;
 
-  const selectorType = data.selector.startsWith('/') ? 'xpath' : 'cssSelector';
+  let frameRect = { x: 0, y: 0 };
 
-  const element = FindElement[selectorType](data);
-  const { 1: actionType } = type.split('#');
+  document.querySelectorAll('iframe').forEach((iframe) => {
+    if (iframe.contentWindow !== source) return;
 
-  if (!element) {
-    if (actionType === 'visible' || actionType === 'invisible') return false;
-
-    return null;
-  }
-
-  const elementActions = {
-    text: () => element.innerText,
-    visible: () => {
-      const { visibility, display } = getComputedStyle(element);
-
-      return visibility !== 'hidden' && display !== 'none';
-    },
-    invisible: () => {
-      const { visibility, display } = getComputedStyle(element);
-
-      return visibility === 'hidden' || display === 'none';
-    },
-    attribute: ({ attrName }) => {
-      if (!element.hasAttribute(attrName)) return null;
+    frameRect = iframe.getBoundingClientRect();
+  });
 
-      return element.getAttribute(attrName);
+  source.postMessage(
+    {
+      frameRect,
+      type: 'automa:the-frame-rect',
     },
-  };
-
-  return elementActions[actionType](data);
+    '*'
+  );
 }
 
 (() => {
   if (window.isAutomaInjected) return;
-
   window.isAutomaInjected = true;
 
+  if (window.self === window.top) {
+    window.addEventListener('message', messageListener);
+  }
+
   browser.runtime.onMessage.addListener((data) => {
     return new Promise((resolve, reject) => {
       if (data.isBlock) {
@@ -74,21 +63,31 @@ function handleConditionBuilder({ data, type }) {
 
       switch (data.type) {
         case 'condition-builder':
-          resolve(handleConditionBuilder(data.data));
+          handleTestCondition(data.data)
+            .then((result) => resolve(result))
+            .catch((error) => reject(error));
           break;
         case 'content-script-exists':
           resolve(true);
           break;
-        case 'give-me-the-frame-id':
-          browser.runtime.sendMessage({
-            type: 'this-is-the-frame-id',
-          });
-          resolve();
-          break;
         case 'loop-elements': {
           const selectors = [];
           const attrId = nanoid(5);
-          const elements = document.body.querySelectorAll(data.selector);
+
+          const documentCtx = getDocumentCtx(data.frameSelector);
+          const selectorType = data.selector.startsWith('/')
+            ? 'xpath'
+            : 'cssSelector';
+          const elements = FindElement[selectorType](
+            { selector: data.selector, multiple: true },
+            documentCtx
+          );
+
+          if (!elements || elements?.length === 0) {
+            reject(new Error('element-not-found'));
+
+            return;
+          }
 
           elements.forEach((el, index) => {
             if (data.max > 0 && selectors.length - 1 > data.max) return;

+ 40 - 0
src/content/injectAppStyles.js

@@ -0,0 +1,40 @@
+import browser from 'webextension-polyfill';
+
+export function generateStyleEl(css, classes = true) {
+  const style = document.createElement('style');
+  style.textContent = css;
+
+  if (classes) {
+    style.classList.add('automa-element-selector');
+  }
+
+  return style;
+}
+
+export default async function (appRoot, customCss = '') {
+  try {
+    const response = await fetch(
+      browser.runtime.getURL('/elementSelector.css')
+    );
+    const mainCSS = await response.text();
+    const appStyleEl = generateStyleEl(mainCSS + customCss, false);
+    appRoot.appendChild(appStyleEl);
+
+    const fontStyleExists = document.head.querySelector(
+      '.automa-element-selector'
+    );
+
+    if (!fontStyleExists) {
+      const commonCSS =
+        '\n.automa-element-selector { direction: ltr } \n [automa-isDragging] { user-select: none } \n [automa-el-list] {outline: 2px dashed #6366f1;}';
+
+      const fontURL = browser.runtime.getURL('/Inter-roman-latin.var.woff2');
+      const fontCSS = `@font-face { font-family: "Inter var"; font-weight: 100 900; font-display: swap; font-style: normal; font-named-instance: "Regular"; src: url("${fontURL}") format("woff2") }`;
+      const fontStyleEl = generateStyleEl(fontCSS + commonCSS);
+
+      document.head.appendChild(fontStyleEl);
+    }
+  } catch (error) {
+    console.error(error);
+  }
+}

+ 596 - 0
src/content/services/recordWorkflow/App.vue

@@ -0,0 +1,596 @@
+<template>
+  <div
+    ref="rootEl"
+    class="content rounded-lg bg-white shadow-xl fixed overflow-hidden text-black top-0 left-0"
+    style="z-index: 99999999; font-size: 16px"
+    :style="{
+      transform: `translate(${draggingState.xPos}px, ${draggingState.yPos}px)`,
+    }"
+  >
+    <div
+      class="px-4 py-2 hoverable flex items-center transition select-none"
+      :class="[draggingState.dragging ? 'cursor-grabbing' : 'cursor-grab']"
+      @mouseup="toggleDragging(false, $event)"
+      @mousedown="toggleDragging(true, $event)"
+    >
+      <span
+        class="relative rounded-full bg-red-400 flex items-center justify-center"
+        title="Recording workflow"
+        style="height: 24px; width: 24px"
+      >
+        <v-remixicon
+          name="riRecordCircleLine"
+          class="relative z-10"
+          size="20"
+        />
+        <span
+          class="absolute animate-ping bg-red-400 rounded-full"
+          style="height: 80%; width: 80%; animation-duration: 1.3s"
+        ></span>
+      </span>
+      <p class="font-semibold ml-2">Automa</p>
+      <div class="flex-grow"></div>
+      <v-remixicon name="mdiDragHorizontal" />
+    </div>
+    <div class="p-4">
+      <template v-if="selectState.status === 'idle'">
+        <button
+          class="px-4 py-2 rounded-lg bg-input transition w-full"
+          @click="startSelecting()"
+        >
+          Select element
+        </button>
+        <button
+          class="px-4 py-2 rounded-lg bg-input transition w-full mt-2"
+          @click="startSelecting(true)"
+        >
+          Select list element
+        </button>
+      </template>
+      <div v-else-if="selectState.status === 'selecting'" class="leading-tight">
+        <p v-if="selectState.selectedElements.length === 0">
+          Select an element by clicking on it
+        </p>
+        <template v-else>
+          <template v-if="selectState.list && !selectState.listId">
+            <label for="list-id" class="ml-1" style="font-size: 14px">
+              Element list id
+            </label>
+            <input
+              id="list-id"
+              v-model="tempListId"
+              placeholder="listId"
+              class="px-4 py-2 rounded-lg bg-input w-full"
+              @keyup.enter="saveElementListId"
+            />
+            <button
+              :class="{ 'opacity-75 pointer-events-none': !tempListId }"
+              class="px-4 py-2 w-full bg-accent rounded-lg mt-2 text-white"
+              @click="saveElementListId"
+            >
+              Save
+            </button>
+          </template>
+          <template v-else>
+            <div class="flex items-center space-x-2 w-full">
+              <input
+                :value="selectState.childSelector"
+                class="px-4 py-2 rounded-lg bg-input w-full"
+                readonly
+              />
+              <template v-if="!selectState.list">
+                <button @click="selectElementPath('up')">
+                  <v-remixicon name="riArrowLeftLine" rotate="90" />
+                </button>
+                <button @click="selectElementPath('down')">
+                  <v-remixicon name="riArrowLeftLine" rotate="-90" />
+                </button>
+              </template>
+            </div>
+            <select
+              v-model="addBlockState.activeBlock"
+              class="px-4 py-2 rounded-lg bg-input w-full mt-2"
+            >
+              <option value="" disabled selected>Select what to do</option>
+              <option
+                v-for="block in addBlockState.blocks"
+                :key="block"
+                :value="block"
+              >
+                {{ tasks[block].name }}
+              </option>
+            </select>
+            <template
+              v-if="
+                ['get-text', 'attribute-value'].includes(
+                  addBlockState.activeBlock
+                )
+              "
+            >
+              <select
+                v-if="addBlockState.activeBlock === 'attribute-value'"
+                v-model="addBlockState.activeAttr"
+                class="px-4 py-2 rounded-lg bg-input mt-2 block w-full"
+              >
+                <option value="" selected disabled>Select attribute</option>
+                <option
+                  v-for="attr in addBlockState.attributes"
+                  :key="attr.id"
+                  :value="attr.id"
+                >
+                  {{ attr.name }}
+                </option>
+              </select>
+              <label
+                for="variable-name"
+                class="text-sm ml-2 text-gray-600 mt-2"
+              >
+                Assign to variable
+              </label>
+              <input
+                id="variable-name"
+                v-model="addBlockState.varName"
+                placeholder="Variable name"
+                class="px-4 py-2 w-full rounded-lg bg-input"
+              />
+              <label
+                for="select-column"
+                class="text-sm ml-2 text-gray-600 mt-2"
+              >
+                Insert to table
+              </label>
+              <select
+                id="select-column"
+                v-model="addBlockState.column"
+                class="block w-full rounded-lg px-4 py-2 bg-input"
+              >
+                <option value="" selected>Select column [none]</option>
+                <option
+                  v-for="column in selectState.workflowColumns"
+                  :key="column.id"
+                  :value="column.id"
+                >
+                  {{ column.name }}
+                </option>
+              </select>
+            </template>
+            <button
+              v-if="addBlockState.activeBlock"
+              :class="{
+                'pointer-events-none opacity-75':
+                  addBlockState.activeBlock === 'attribute-value' &&
+                  !addBlockState.activeAttr,
+              }"
+              class="px-4 py-2 rounded-lg block w-full bg-accent text-white mt-4"
+              @click="addFlowItem"
+            >
+              Save
+            </button>
+          </template>
+        </template>
+        <p class="mt-4" style="font-size: 14px">
+          Press <kbd class="p-1 rounded-md bg-box-transparent">Esc</kbd> to
+          cancel
+        </p>
+      </div>
+    </div>
+  </div>
+  <shared-element-highlighter
+    v-if="selectState.status === 'selecting'"
+    :data="elementsHighlightData"
+    :items="{
+      hoveredElements: selectState.hoveredElements,
+      selectedElements: selectState.selectedElements,
+    }"
+    style="z-index: 9999999"
+    @update="selectState[$event.key] = $event.items"
+  />
+  <teleport to="body">
+    <div
+      v-if="selectState.status === 'selecting'"
+      style="
+        z-index: 999999;
+        position: fixed;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+        background-color: rgba(0, 0, 0, 0.3);
+      "
+    ></div>
+  </teleport>
+</template>
+<script setup>
+import { ref, reactive, watch, onMounted, onBeforeUnmount } from 'vue';
+import { finder } from '@medv/finder';
+import browser from 'webextension-polyfill';
+import { toCamelCase } from '@/utils/helper';
+import { elementsHighlightData, tasks } from '@/utils/shared';
+import SharedElementHighlighter from '@/components/content/shared/SharedElementHighlighter.vue';
+import findElementList from '../../elementSelector/listSelector';
+import addBlock from './addBlock';
+
+let prevHoverElement = null;
+const mouseRelativePos = { x: 0, y: 0 };
+const elementsPath = {
+  path: [],
+  cache: new WeakMap(),
+};
+
+const rootEl = ref(null);
+const tempListId = ref('');
+
+const selectState = reactive({
+  listId: '',
+  list: false,
+  pathIndex: 0,
+  status: 'idle',
+  isInList: false,
+  listSelector: '',
+  childSelector: '',
+  hoveredElements: [],
+  selectedElements: [],
+});
+const draggingState = reactive({
+  yPos: 20,
+  dragging: false,
+  xPos: window.innerWidth - 300,
+});
+const addBlockState = reactive({
+  blocks: [],
+  column: '',
+  varName: '',
+  attributes: [],
+  activeAttr: '',
+  activeBlock: '',
+  workflowColumns: [],
+});
+
+const blocksList = {
+  IMG: ['save-assets', 'attribute-value'],
+  VIDEO: ['save-assets', 'attribute-value'],
+  AUDIO: ['save-assets', 'attribute-value'],
+  default: ['get-text', 'attribute-value'],
+};
+
+function getElementRect(target, withElement = false) {
+  if (!target) return {};
+
+  const { x, y, height, width } = target.getBoundingClientRect();
+  const result = {
+    width: width + 4,
+    height: height + 4,
+    x: x - 2,
+    y: y - 2,
+  };
+
+  if (withElement) result.element = target;
+
+  return result;
+}
+function addFlowItem() {
+  const saveData = Boolean(addBlockState.column);
+  const assignVariable = Boolean(addBlockState.varName);
+  const block = {
+    id: addBlockState.activeBlock,
+    data: {
+      saveData,
+      assignVariable,
+      waitForSelector: true,
+      column: addBlockState.column,
+      variableName: addBlockState.varName,
+      selector: selectState.list
+        ? selectState.listSelector
+        : selectState.childSelector,
+    },
+  };
+
+  if (selectState.isInList || selectState.listId) {
+    const childSelector = selectState.isInList ? selectState.childSelector : '';
+
+    block.data.selector = `{{loopData@${selectState.listId}}} ${childSelector}`;
+  } else if (selectState.list) {
+    block.data.multiple = true;
+  }
+
+  if (addBlockState.activeBlock === 'attribute-value') {
+    block.data.attributeName = addBlockState.activeAttr;
+  }
+
+  addBlock(block).then(() => {
+    addBlockState.column = '';
+    addBlockState.varName = '';
+    addBlockState.activeAttr = '';
+  });
+}
+function selectElementPath(type) {
+  let pathIndex =
+    type === 'up' ? selectState.pathIndex + 1 : selectState.pathIndex - 1;
+  let element = elementsPath.path[pathIndex];
+
+  if ((type === 'up' && !element) || element?.tagName === 'BODY') return;
+
+  if (type === 'down' && !element) {
+    const previousElement = elementsPath.path[selectState.pathIndex];
+    const childEl = Array.from(previousElement.children).find(
+      (el) => !['STYLE', 'SCRIPT'].includes(el.tagName)
+    );
+
+    if (!childEl) return;
+
+    element = childEl;
+    elementsPath.path.unshift(childEl);
+    pathIndex = 0;
+  }
+
+  selectState.pathIndex = pathIndex;
+  selectState.selectedElements = [getElementRect(element)];
+  selectState.childSelector = elementsPath.cache.has(element)
+    ? elementsPath.cache.get(element)
+    : finder(element);
+}
+function clearSelectState() {
+  if (selectState.list && selectState.listId) {
+    addBlock({
+      id: 'loop-breakpoint',
+      description: selectState.listId,
+      data: {
+        loopId: selectState.listId,
+      },
+    });
+  }
+
+  selectState.listId = '';
+  selectState.list = false;
+  selectState.status = 'idle';
+  selectState.isSelecting = false;
+  selectState.hoveredElements = [];
+  selectState.selectedElements = [];
+
+  const selectedList = document.querySelectorAll('[automa-el-list]');
+  selectedList.forEach((element) => {
+    element.removeAttribute('automa-el-list');
+  });
+
+  document.body.removeAttribute('automa-selecting');
+}
+function saveElementListId() {
+  if (!tempListId.value) return;
+
+  selectState.listId = toCamelCase(tempListId.value);
+  tempListId.value = '';
+
+  addBlock({
+    id: 'loop-data',
+    description: selectState.listId,
+    data: {
+      loopThrough: 'elements',
+      loopId: selectState.listId,
+      elementSelector: selectState.listSelector,
+    },
+  });
+}
+function getElementListChild(target, root) {
+  const result = {
+    elements: [],
+    childSelector: null,
+  };
+
+  if (!target.hasAttribute('automa-el-list')) {
+    result.childSelector = finder(target, {
+      root,
+      idName: () => false,
+    });
+
+    const selector = `${selectState.listSelector} ${result.childSelector}`;
+
+    result.elements = Array.from(document.querySelectorAll(selector));
+  }
+
+  return result;
+}
+function getElementList(target, forceList = false) {
+  const automaListEl = target.closest('[automa-el-list]');
+
+  if (automaListEl) {
+    return getElementListChild(target, automaListEl).elements;
+  }
+  if (forceList) {
+    return [];
+  }
+
+  return findElementList(target) || [target];
+}
+function toggleDragging(value, event) {
+  if (value) {
+    const bounds = rootEl.value.getBoundingClientRect();
+    const y = event.clientY - bounds.top;
+    const x = event.clientX - bounds.left;
+
+    mouseRelativePos.x = x;
+    mouseRelativePos.y = y;
+  } else {
+    mouseRelativePos.x = 0;
+    mouseRelativePos.y = 0;
+  }
+
+  draggingState.dragging = value;
+}
+function onKeyup({ key }) {
+  if (key !== 'Escape') return;
+
+  clearSelectState();
+
+  window.removeEventListener('keyup', onKeyup);
+}
+function startSelecting(list = false) {
+  selectState.list = list;
+  selectState.isSelecting = true;
+  selectState.status = 'selecting';
+
+  document.body.setAttribute('automa-selecting', '');
+
+  window.addEventListener('keyup', onKeyup);
+}
+function onMousemove({ clientX, clientY, target: eventTarget }) {
+  if (draggingState.dragging) {
+    draggingState.xPos = clientX - mouseRelativePos.x;
+    draggingState.yPos = clientY - mouseRelativePos.y;
+
+    return;
+  }
+
+  if (!selectState.isSelecting) return;
+
+  const elementSelected = selectState.selectedElements.length > 0;
+  const disable = selectState.list && !selectState.listId && elementSelected;
+  if (disable) return;
+
+  if (
+    selectState.status === 'selecting' &&
+    eventTarget.id !== 'automa-recording'
+  ) {
+    const { 1: target } = document.elementsFromPoint(clientX, clientY);
+
+    if (prevHoverElement === target) return;
+
+    prevHoverElement = target;
+    let elementsRect = [];
+
+    if (selectState.list) {
+      const elements = getElementList(target, elementSelected) || [];
+      elementsRect = elements.map((el) => getElementRect(el, true));
+    } else {
+      elementsRect = [getElementRect(target, true)];
+    }
+
+    selectState.hoveredElements = elementsRect;
+  }
+}
+function getElementPath(el, root = document.documentElement) {
+  const path = [el];
+
+  /* eslint-disable-next-line */
+  while ((el = el.parentNode) && !el.isEqualNode(root)) {
+    path.push(el);
+  }
+
+  return path;
+}
+function onClick(event) {
+  if (!selectState.isSelecting) return;
+
+  const { target: eventTarget, clientY, clientX } = event;
+
+  if (eventTarget.id === 'automa-recording') return;
+
+  const disable =
+    selectState.list &&
+    !selectState.listId &&
+    selectState.selectedElements.length > 0;
+  if (disable) return;
+
+  const { 1: target } = document.elementsFromPoint(clientX, clientY);
+  const isInList = target.closest('[automa-el-list]');
+  const getElementBlocks = (element) => {
+    const elTag = element.tagName;
+    const blocks = [...(blocksList[elTag] || blocksList.default)];
+    const attrBlockIndex = blocks.indexOf('attribute-value');
+
+    if (attrBlockIndex !== -1) {
+      const attributes = Array.from(element.attributes).reduce(
+        (acc, { name, value }) => {
+          if (name === 'automa-el-list') return acc;
+
+          acc.push({ id: name, name: `${name} (${value})`, value });
+
+          return acc;
+        },
+        []
+      );
+
+      if (attributes.length === 0) blocks.splice(attrBlockIndex, 1);
+
+      addBlockState.attributes = attributes;
+    }
+
+    addBlockState.blocks = blocks;
+  };
+
+  if (isInList) {
+    const { elements, childSelector } = getElementListChild(target, isInList);
+
+    getElementBlocks(elements[0]);
+
+    selectState.isInList = true;
+    selectState.childSelector = childSelector;
+    selectState.selectedElements = elements.map((element) =>
+      getElementRect(element)
+    );
+
+    return;
+  }
+
+  const prevSelectedList = document.querySelectorAll('[automa-el-list]');
+  prevSelectedList.forEach((element) => {
+    element.removeAttribute('automa-el-list');
+  });
+
+  const firstElement = selectState.hoveredElements[0].element;
+  if (!firstElement) return;
+
+  elementsPath.path = [];
+
+  if (selectState.list) {
+    selectState.hoveredElements.forEach(({ element }) => {
+      element.setAttribute('automa-el-list', '');
+    });
+
+    const parentSelector = finder(firstElement.parentElement);
+    const childSelector = firstElement.tagName.toLowerCase();
+    const elementSelector = `${parentSelector} > ${childSelector}`;
+
+    selectState.listSelector = elementSelector;
+    selectState.childSelector = childSelector;
+  } else {
+    selectState.childSelector = finder(firstElement);
+    elementsPath.path = getElementPath(firstElement);
+  }
+
+  selectState.isInList = false;
+  selectState.selectedElements = selectState.hoveredElements;
+
+  getElementBlocks(firstElement);
+}
+function attachListeners() {
+  window.addEventListener('click', onClick);
+  window.addEventListener('mousemove', onMousemove);
+}
+function detachListeners() {
+  window.removeEventListener('keyup', onKeyup);
+  window.removeEventListener('click', onClick);
+  window.removeEventListener('mousemove', onMousemove);
+}
+
+watch(
+  () => selectState.selectedElements,
+  () => {
+    addBlockState.column = '';
+    addBlockState.varName = '';
+    addBlockState.activeBlock = '';
+  }
+);
+
+onMounted(() => {
+  attachListeners();
+
+  browser.storage.local
+    .get(['recording', 'workflows'])
+    .then(({ recording, workflows }) => {
+      const workflow = workflows.find(({ id }) => recording.workflowId === id);
+
+      addBlockState.workflowColumns = workflow?.table || [];
+    });
+});
+onBeforeUnmount(detachListeners);
+</script>

+ 15 - 0
src/content/services/recordWorkflow/addBlock.js

@@ -0,0 +1,15 @@
+import browser from 'webextension-polyfill';
+
+export default async function (detail) {
+  const { isRecording, recording } = await browser.storage.local.get([
+    'isRecording',
+    'recording',
+  ]);
+
+  if (!isRecording || !recording) return;
+
+  if (typeof detail === 'function') detail(recording);
+  else recording.flows.push(detail);
+
+  await browser.storage.local.set({ recording });
+}

+ 8 - 0
src/content/services/recordWorkflow/icons.js

@@ -0,0 +1,8 @@
+import { riRecordCircleLine, riArrowLeftLine } from 'v-remixicon/icons';
+
+export default {
+  riRecordCircleLine,
+  riArrowLeftLine,
+  mdiDragHorizontal:
+    'M3,15V13H5V15H3M3,11V9H5V11H3M7,15V13H9V15H7M7,11V9H9V11H7M11,15V13H13V15H11M11,11V9H13V11H11M15,15V13H17V15H15M15,11V9H17V11H15M19,15V13H21V15H19M19,11V9H21V11H19Z',
+};

+ 23 - 0
src/content/services/recordWorkflow/index.js

@@ -0,0 +1,23 @@
+import browser from 'webextension-polyfill';
+import initElementSelector from './main';
+import initRecordEvents from './recordEvents';
+
+(async () => {
+  try {
+    const element = document.querySelector('#automa-recording');
+    if (element) return;
+
+    const destroyRecordEvents = await initRecordEvents();
+    const elementSelectorInstance = await initElementSelector();
+
+    browser.runtime.onMessage.addListener(function messageListener({ type }) {
+      if (type === 'recording:stop') {
+        destroyRecordEvents();
+        elementSelectorInstance.unmount();
+        browser.runtime.onMessage.removeListener(messageListener);
+      }
+    });
+  } catch (error) {
+    console.error(error);
+  }
+})();

+ 34 - 0
src/content/services/recordWorkflow/main.js

@@ -0,0 +1,34 @@
+import { createApp } from 'vue';
+import vRemixicon from 'v-remixicon';
+import App from './App.vue';
+import icons from './icons';
+import injectAppStyles from '../../injectAppStyles';
+
+const customCSS = `
+  #app {
+    font-family: 'Inter var';
+    line-height: 1.5;
+  }
+  .content {
+    width: 250px;
+  }
+`;
+
+export default function () {
+  const rootElement = document.createElement('div');
+  rootElement.attachShadow({ mode: 'open' });
+  rootElement.setAttribute('id', 'automa-recording');
+  rootElement.classList.add('automa-element-selector');
+  document.body.appendChild(rootElement);
+
+  return injectAppStyles(rootElement.shadowRoot, customCSS).then(() => {
+    const appRoot = document.createElement('div');
+    appRoot.setAttribute('id', 'app');
+    rootElement.shadowRoot.appendChild(appRoot);
+
+    const app = createApp(App).use(vRemixicon, icons);
+    app.mount(appRoot);
+
+    return app;
+  });
+}

+ 114 - 76
src/content/services/recordWorkflow.js → src/content/services/recordWorkflow/recordEvents.js

@@ -1,25 +1,26 @@
-import { getCssSelector } from 'css-selector-generator';
+import { finder } from '@medv/finder';
+import { nanoid } from 'nanoid';
 import browser from 'webextension-polyfill';
 import { debounce } from '@/utils/helper';
+import { recordPressedKey } from '@/utils/recordKeys';
+import addBlock from './addBlock';
 
+const isAutomaInstance = (target) =>
+  target.id === 'automa-recording' ||
+  document.body.hasAttribute('automa-selecting');
 const textFieldEl = (el) =>
   ['INPUT', 'TEXTAREA'].includes(el.tagName) || el.isContentEditable;
 
-async function addBlock(detail) {
-  const { isRecording, recording } = await browser.storage.local.get([
-    'isRecording',
-    'recording',
-  ]);
-
-  if (!isRecording || !recording) return;
-
-  if (typeof detail === 'function') detail(recording);
-  else recording.flows.push(detail);
-
-  await browser.storage.local.set({ recording });
+function findSelector(element) {
+  return finder(element, {
+    tagName: () => true,
+    attr: (name, value) => name === 'id' || (name.startsWith('aria') && value),
+  });
 }
 
 function changeListener({ target }) {
+  if (isAutomaInstance(target)) return;
+
   const isInputEl = target.tagName === 'INPUT';
   const inputType = target.getAttribute('type');
   const execludeInput = isInputEl && ['checkbox', 'radio'].includes(inputType);
@@ -27,14 +28,14 @@ function changeListener({ target }) {
   if (execludeInput) return;
 
   let block = null;
-  const selector = getCssSelector(target);
+  const selector = findSelector(target);
   const isSelectEl = target.tagName === 'SELECT';
   const elementName = target.ariaLabel || target.name;
 
   if (isInputEl && inputType === 'file') {
     block = {
       id: 'upload-file',
-      description: elementName || selector,
+      description: elementName,
       data: {
         selector,
         waitForSelector: true,
@@ -43,16 +44,22 @@ function changeListener({ target }) {
       },
     };
   } else if (textFieldEl(target) || isSelectEl) {
+    let description = '';
+
+    if (elementName && elementName.length < 12) {
+      description = `${isSelectEl ? 'Select' : 'Text field'} (${elementName})`;
+    }
+
     block = {
       id: 'forms',
       data: {
         selector,
         delay: 100,
+        description,
         clearValue: true,
         value: target.value,
         waitForSelector: true,
         type: isSelectEl ? 'select' : 'text-field',
-        description: `${isSelectEl ? 'Select' : 'Text field'} (${elementName})`,
       },
     };
   } else {
@@ -60,11 +67,11 @@ function changeListener({ target }) {
       id: 'trigger-event',
       data: {
         selector,
+        description,
         eventName: 'change',
         eventType: 'event',
         waitForSelector: true,
         eventParams: { bubbles: true },
-        description: `Change event (${selector})`,
       },
     };
   }
@@ -75,57 +82,89 @@ function changeListener({ target }) {
       recording.flows.pop();
     }
 
+    if (
+      block.data.type === 'text-field' &&
+      block.data.selector === lastFlow?.data?.selector
+    )
+      return;
+
     recording.flows.push(block);
   });
 }
-function keyEventListener({
-  target,
-  code,
-  key,
-  keyCode,
-  altKey,
-  ctrlKey,
-  metaKey,
-  shiftKey,
-  type,
-  repeat,
-}) {
-  const isTextField = textFieldEl(target);
+async function keyEventListener(event) {
+  if (isAutomaInstance(event.target) || event.repeat) return;
 
-  if (isTextField) return;
+  const isTextField = textFieldEl(event.target);
+  const enterKey = event.key === 'Enter';
+  let isSubmitting = false;
 
-  const selector = getCssSelector(target);
+  if (isTextField) {
+    const inputInForm = event.target.form && event.target.tagName === 'INPUT';
 
-  addBlock({
-    id: 'trigger-event',
-    data: {
-      selector,
-      eventName: type,
-      eventType: 'keyboard-event',
-      eventParams: {
-        key,
-        code,
-        repeat,
-        altKey,
-        ctrlKey,
-        metaKey,
-        keyCode,
-        shiftKey,
-      },
-      description: `${type}(${key === ' ' ? 'Space' : key}): ${selector}`,
-    },
+    if (enterKey && inputInForm) {
+      event.preventDefault();
+
+      await addBlock({
+        id: 'forms',
+        data: {
+          delay: 100,
+          clearValue: true,
+          type: 'text-field',
+          waitForSelector: true,
+          value: event.target.value,
+          selector: findSelector(event.target),
+        },
+      });
+
+      isSubmitting = true;
+    } else {
+      return;
+    }
+  }
+
+  recordPressedKey(event, (keysArr) => {
+    const selector = isTextField && enterKey ? findSelector(event.target) : '';
+    const keys = keysArr.join('+');
+
+    addBlock((recording) => {
+      const block = {
+        id: 'press-key',
+        description: `Press: ${keys}`,
+        data: {
+          keys,
+          selector,
+        },
+      };
+
+      const lastFlow = recording.flows.at(-1);
+      if (lastFlow.id === 'press-key') {
+        if (!lastFlow.groupId) lastFlow.groupId = nanoid();
+        block.groupId = lastFlow.groupId;
+      }
+
+      recording.flows.push(block);
+
+      if (isSubmitting) {
+        setTimeout(() => {
+          event.target.form.submit();
+        }, 500);
+      }
+    });
   });
 }
 function clickListener(event) {
   const { target } = event;
-  let isClickLink = true;
+
+  if (isAutomaInstance(target)) return;
+
   const isTextField =
     (target.tagName === 'INPUT' && target.getAttribute('type') === 'text') ||
     ['SELECT', 'TEXTAREA'].includes(target.tagName);
 
   if (isTextField) return;
 
-  const selector = getCssSelector(target);
+  let isClickLink = false;
+  const selector = findSelector(target);
 
   if (target.tagName === 'A') {
     if (event.ctrlKey || event.metaKey) return;
@@ -136,11 +175,14 @@ function clickListener(event) {
     if (openInNewTab) {
       event.preventDefault();
 
+      const description = (target.innerText || target.href).slice(0, 24);
+
       addBlock({
         id: 'link',
+        description,
         data: {
           selector,
-          description: (target.innerText || target.href).slice(0, 64),
+          description,
         },
       });
 
@@ -150,24 +192,29 @@ function clickListener(event) {
     }
   }
 
-  const elText = target.innerText || target.ariaLabel || target.title;
+  const elText = (target.innerText || target.ariaLabel || target.title).slice(
+    0,
+    24
+  );
 
   addBlock({
     isClickLink,
     id: 'event-click',
-    description: elText.slice(0, 64) || selector,
+    description: elText,
     data: {
       selector,
+      description: elText,
       waitForSelector: true,
-      description: elText.slice(0, 64),
     },
   });
 }
 
 const scrollListener = debounce(({ target }) => {
+  if (isAutomaInstance(target)) return;
+
   const isDocument = target === document;
   const element = isDocument ? document.documentElement : target;
-  const selector = isDocument ? 'html' : getCssSelector(target);
+  const selector = isDocument ? 'html' : findSelector(target);
 
   addBlock((recording) => {
     const lastFlow = recording.flows[recording.flows.length - 1];
@@ -183,7 +230,6 @@ const scrollListener = debounce(({ target }) => {
 
     recording.flows.push({
       id: 'element-scroll',
-      description: selector,
       data: {
         selector,
         smooth: true,
@@ -194,30 +240,22 @@ const scrollListener = debounce(({ target }) => {
   });
 }, 500);
 
-function cleanUp() {
+export function cleanUp() {
   document.removeEventListener('click', clickListener, true);
   document.removeEventListener('change', changeListener, true);
   document.removeEventListener('scroll', scrollListener, true);
-  document.removeEventListener('keyup', keyEventListener, true);
   document.removeEventListener('keydown', keyEventListener, true);
 }
-function messageListener({ type }) {
-  if (type === 'recording:stop') {
-    cleanUp();
-    browser.runtime.onMessage.removeListener(messageListener);
-  }
-}
 
-(async () => {
+export default async function () {
   const { isRecording } = await browser.storage.local.get('isRecording');
 
-  if (!isRecording) return;
-
-  document.addEventListener('click', clickListener, true);
-  document.addEventListener('scroll', scrollListener, true);
-  document.addEventListener('change', changeListener, true);
-  document.addEventListener('keyup', keyEventListener, true);
-  document.addEventListener('keydown', keyEventListener, true);
+  if (isRecording) {
+    document.addEventListener('click', clickListener, true);
+    document.addEventListener('scroll', scrollListener, true);
+    document.addEventListener('change', changeListener, true);
+    document.addEventListener('keydown', keyEventListener, true);
+  }
 
-  browser.runtime.onMessage.addListener(messageListener);
-})();
+  return cleanUp;
+}

+ 1 - 1
src/content/services/webService.js

@@ -51,7 +51,7 @@ function initWebListener() {
         );
 
         workflow.table = workflow.table || workflow.dataColumns;
-        delete workflow.dataColumns;
+        workflow.dataColumns = [];
 
         workflowsStorage.push({
           ...workflow,

+ 93 - 0
src/content/utils.js

@@ -0,0 +1,93 @@
+export function automaRefDataStr(stateId) {
+  return `
+function findData(obj, path) {
+  const paths = path.split('.');
+  const isWhitespace = paths.length === 1 && !/\\S/.test(paths[0]);
+
+  if (path.startsWith('$last') && Array.isArray(obj)) {
+    paths[0] = obj.length - 1;
+  }
+
+  if (paths.length === 0 || isWhitespace) return obj;
+  else if (paths.length === 1) return obj[paths[0]];
+
+  let result = obj;
+
+  for (let i = 0; i < paths.length; i++) {
+    if (result[paths[i]] == undefined) {
+      return undefined;
+    } else {
+      result = result[paths[i]];
+    }
+  }
+
+  return result;
+}
+function automaRefData(keyword, path = '') {
+  const data = JSON.parse(sessionStorage.getItem('automa--${stateId}')) || null;
+
+  if (data === null) return null;
+
+  return findData(data[keyword], path);
+}
+  `;
+}
+
+function messageTopFrame(windowCtx) {
+  return new Promise((resolve) => {
+    let timeout = null;
+    let isResolved = false;
+
+    const messageListener = ({ data }) => {
+      if (data.type !== 'automa:the-frame-rect' || isResolved) return;
+
+      clearTimeout(timeout);
+      isResolved = true;
+      windowCtx.removeEventListener('message', messageListener);
+      resolve(data.frameRect);
+    };
+
+    timeout = setTimeout(() => {
+      if (isResolved) return;
+
+      isResolved = true;
+      windowCtx.removeEventListener('message', messageListener);
+      resolve(null);
+    }, 5000);
+
+    windowCtx.addEventListener('message', messageListener);
+    windowCtx.top.postMessage('automa:get-frame', '*');
+  });
+}
+export async function getElementPosition(element) {
+  const elWindow = element.ownerDocument.defaultView;
+  const isInFrame = elWindow !== window.top;
+  const { width, height, x, y } = element.getBoundingClientRect();
+  const position = {
+    x: x + width / 2,
+    y: y + height / 2,
+  };
+
+  if (!isInFrame) return position;
+
+  try {
+    const frameEl = elWindow.frameElement;
+    let frameRect = null;
+
+    if (frameEl) {
+      frameRect = frameEl.getBoundingClientRect();
+    } else {
+      frameRect = await messageTopFrame(elWindow);
+
+      if (!frameRect) throw new Error('Iframe not found');
+    }
+
+    position.x += frameRect.x;
+    position.y += frameRect.y;
+
+    return position;
+  } catch (error) {
+    console.error(error);
+    return position;
+  }
+}

+ 12 - 0
src/lib/drawflow.js

@@ -8,6 +8,18 @@ export default function (element, { context, options = {} }) {
   const editor = new Drawflow(element, { render, version: 3, h }, context);
 
   editor.useuuid = true;
+  editor.translate_to = function (x, y) {
+    this.canvas_x = x;
+    this.canvas_y = y;
+
+    const storedZoom = this.zoom;
+
+    this.zoom = 1;
+    this.precanvas.style.transform = `translate("${this.canvas_x}"px, "${this.canvas_y}"px) scale("${this.zoom}")`;
+    this.zoom = storedZoom;
+    this.zoom_last_value = 1;
+    this.zoom_refresh();
+  };
   editor.createCurvature = (
     startPosX,
     startPosY,

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

@@ -10,8 +10,10 @@
   "workflow": {
     "blocks": {
       "base": {
+        "title": "Blocks",
         "moveToGroup": "Move block to blocks group",
         "selector": "Element selector",
+        "selectorOptions": "Selector options",
         "timeout": "Timeout (milliseconds)",
         "toggle": {
           "enable": "Enable block",
@@ -91,6 +93,13 @@
         "name": "Reload tab",
         "description": "Reload the active tab"
       },
+      "press-key": {
+        "name": "Press key",
+        "description": "Press a key or a combination",
+        "target": "Target element (optional)",
+        "key": "Key",
+        "detect": "Detect key"
+      },
       "save-assets": {
         "name": "Save assets",
         "description": "Save assets (image, video, audio, or file) from an element or URL",
@@ -138,6 +147,7 @@
         "description": "Upload file into <input type=\"file\"> element",
         "filePath": "URL or File path",
         "addFile": "Add file",
+        "onlyURL": "Only support upload files from an URL in the Firefox browser",
         "requirement": "See the requirement before using this block",
         "noFileAccess": "Automa doesn't have file access"
       },
@@ -214,6 +224,8 @@
         "keysAsFirstRow": "Use keys as the first row",
         "insertData": "Insert data",
         "valueInputOption": "Value input option",
+        "insertDataOption": "Insert data option",
+        "rangeToSearch": "Range to start the search",
         "dataFrom": {
           "label": "Data from",
           "options": {
@@ -235,7 +247,9 @@
         },
         "select": {
           "get": "Get spreadsheet cell values",
-          "update": "Update spreadsheet cell values"
+          "getRange": "Get spreadsheet range",
+          "update": "Update spreadsheet cell values",
+          "append": "Append spreadsheet cell values"
         }
       },
       "active-tab": {
@@ -355,6 +369,7 @@
         "customUserAgent": "Use custom User-Agent",
         "activeTab": "Set as active tab",
         "tabToGroup": "Add tab to group",
+        "waitTabLoaded": "Wait till tab loaded",
         "updatePrevTab": {
           "title": "Use the previously opened new tab instead of creating a new one",
           "text": "Update previously opened tab"
@@ -434,6 +449,7 @@
       "conditions": {
         "name": "Conditions",
         "add": "Add condition",
+        "retryConditions": "Retry if all conditions are not met",
         "description": "Conditional block",
         "refresh": "Refresh conditions connections",
         "fallbackTitle": "Execute when all comparisons don't meet the requirement",

+ 2 - 1
src/locales/en/common.json

@@ -33,6 +33,7 @@
     "enable": "Enable",
     "fallback": "Fallback",
     "update": "Update",
+    "feature": "Feature",
     "duplicate": "Duplicate",
     "password": "Password",
     "category": "Category",
@@ -59,5 +60,5 @@
     "stopped": "stopped",
     "error": "error",
     "success": "success"
-  },
+  }
 }

+ 13 - 2
src/locales/en/newtab.json

@@ -109,7 +109,8 @@
       "title": "Condition builder",
       "add": "Add condition",
       "and": "AND",
-      "or": "OR"
+      "or": "OR",
+      "topAwait": "Support top-level await and \"automaRefData\" function"
     },
     "host": {
       "title": "Host workflow",
@@ -191,6 +192,11 @@
     "settings": {
       "saveLog": "Save workflow log",
       "executedBlockOnWeb": "Show executed block on web page",
+      "defaultColumn": {
+        "title": "Insert into the default column",
+        "description": "Insert data to the default column if there's no column selected in the block",
+        "name": "Default column name"
+      },
       "autocomplete": {
         "title": "Autocomplete",
         "description": "Enable autocomplete in the input block (disable if it makes Automa unstable)"
@@ -229,7 +235,11 @@
       "blockDelay": {
         "title": "Block delay (milliseconds)",
         "description": "Add delay before executing each of the blocks"
-      }
+      },
+      "tabLoadTimeout": {
+        "title": "Tab load timeout",
+        "description": "Maximum time to load tab in milliseconds, pass 0 to disable the timeout."
+      },
     }
   },
   "collection": {
@@ -287,6 +297,7 @@
       "no-workflow": "Can't find workflow with \"{workflowId}\" ID",
       "no-match-tab": "Can't find a tab with \"{pattern}\" patterns",
       "no-clipboard-acces": "Don't have permission to access clipboard",
+      "browser-not-supported": "This feature not supported in {browser} browser",
       "element-not-found": "Can't find an element with \"{selector}\" selector.",
       "not-iframe": "Element with \"{selector}\" selector is not an Iframe element",
       "iframe-not-found": "Can't find an Iframe element with \"{selector}\" selector.",

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

@@ -7,7 +7,13 @@
     "record": {
       "title": "Record workflow",
       "button": "Record",
-      "name": "Workflow name"
+      "name": "Workflow name",
+      "selectBlock": "Select a block to start from",
+      "anotherBlock": "Can't start from this block",
+      "tabs": {
+        "new": "New workflow",
+        "existing": "Existing workflow"
+      }
     },
     "elementSelector": {
       "name": "Element selector",

+ 35 - 3
src/locales/zh/blocks.json

@@ -10,14 +10,20 @@
   "workflow": {
     "blocks": {
       "base": {
+        "title": "模块",
         "moveToGroup": "移动模块到模块组",
         "selector": "元素选择器",
+        "timeout": "超时 (毫秒)",
+        "toggle": {
+          "enable": "启用模块",
+          "disable": "禁用模块",
+        },
         "onError": {
           "info": "这些规则适用于模块发生错误时",
           "button": "出错时",
-          "title": "发生错误时",
+          "title": "错误发生时",
           "retry": "重试操作",
-          "fallbackTitle": "当模块发生错误时执行",
+          "fallbackTitle": "当模块发生错误时执行",
           "times": {
             "name": "次数",
             "description": "重试操作的次数",
@@ -70,6 +76,22 @@
           }
         }
       },
+      "wait-connections": {
+        "name": "等待连接",
+        "description": "在继续下一个模块之前等待所有连接",
+        "specificFlow": "只继续一个特定的流程",
+        "selectFlow": "选择流程"
+      },
+      "delete-data": {
+        "name": "删除数据",
+        "description": "删除表格或变量",
+        "from": "数据来自",
+        "allColumns": "[全部列]"
+      },
+      "reload-tab": {
+        "name": "重载标签页",
+        "description": "重新加载激活的标签页"
+      },
       "save-assets": {
         "name": "保存资源",
         "description": "保存资源 (图像, 视频, 音频, 或文件) 从一个元素或 URL",
@@ -117,6 +139,7 @@
         "description": "上传文件到 <input type=\"file\"> 元素",
         "filePath": "URL 或 文件路径",
         "addFile": "添加文件",
+        "onlyURL": "仅支持从 Firefox 浏览器中的 URL 上传文件",
         "requirement": "使用本模块前,请参看要求",
         "noFileAccess": "Automa 没有文件访问权限"
       },
@@ -193,6 +216,7 @@
         "keysAsFirstRow": "使用主键作为第一行",
         "insertData": "插入数据",
         "valueInputOption": "输入选项值",
+        "rangeToSearch": "开始搜索的范围",
         "dataFrom": {
           "label": "数据来自",
           "options": {
@@ -214,6 +238,7 @@
         },
         "select": {
           "get": "获取电子表格单元格值",
+          "getRange": "获取电子表格范围",
           "update": "更新电子表格单元格值"
         }
       },
@@ -414,6 +439,7 @@
         "name": "条件",
         "add": "添加条件",
         "description": "条件模块",
+        "refresh": "刷新条件连接",
         "fallbackTitle": "当所有比较不满足要求时执行",
         "equals": "等于",
         "gt": "大于",
@@ -513,7 +539,13 @@
         "description": "截取当前活动标签页的屏幕截图",
         "imageQuality": "图像质量",
         "saveToColumn": "将屏幕截图插入表格",
-        "saveToComputer": "将屏幕截图保存到计算机"
+        "saveToComputer": "将屏幕截图保存到计算机",
+        "types": {
+          "title": "截图",
+          "page": "页面",
+          "fullpage": "完整页面",
+          "element": "一个元素"
+        }
       },
       "switch-to": {
         "name": "切换框架",

+ 1 - 0
src/locales/zh/common.json

@@ -33,6 +33,7 @@
     "enable": "启用",
     "fallback": "反馈",
     "update": "更新",
+    "feature": "特点",
     "duplicate": "副本",
     "password": "密码",
     "category": "分类",

+ 23 - 3
src/locales/zh/newtab.json

@@ -37,6 +37,14 @@
         "description": "添加箭头到线的尾端"
       }
     },
+    "deleteLog": {
+      "title": "自动删除工作流日志",
+      "after": "删除间隔",
+      "deleteAfter": {
+        "never": "从不",
+        "days": "{day} 天"
+      }
+    },
     "language": {
       "label": "语言",
       "helpTranslate": "找不到您的语言? 帮助翻译.",
@@ -54,6 +62,7 @@
       "invalidPassword": "无效密码",
       "workflowsAdded": "{count} 个工作流已被添加",
       "name": "备份工作流",
+      "needSignin": "首先你需要登录账户",
       "backup": {
         "button": "备份",
         "encrypt": "用密码加密"
@@ -68,6 +77,7 @@
           "local": "本地",
           "cloud": "云端"
         },
+        "location": "位置",
         "delete": "删除备份",
         "title": "云端备份",
         "sync": "同步",
@@ -90,6 +100,7 @@
     "browse": "浏览工作流",
     "name": "工作流名称",
     "rename": "重命名工作流",
+    "backupCloud": "备份工作流到云端",
     "add": "添加工作流",
     "clickToEnable": "单击启用",
     "toggleSidebar": "切换侧栏",
@@ -112,12 +123,12 @@
       "messages": {
         "hostExist": "您已添加此主机",
         "notFound": "找不到托管工作流 \"{id}\" id"
-      },
+      }
     },
     "type": {
       "local": "本地",
       "shared": "共享",
-      "host": "主机",
+      "host": "主机"
     },
     "unpublish": {
       "title": "取消发布工作流",
@@ -173,11 +184,18 @@
       "zoomIn": "放大",
       "zoomOut": "缩小",
       "resetZoom": "重置缩放",
-      "duplicate": "副本"
+      "duplicate": "副本",
+      "copy": "复制",
+      "paste": "粘贴"
     },
     "settings": {
       "saveLog": "保存工作流日志",
       "executedBlockOnWeb": "在网页上显示已执行的模块",
+      "defaultColumn": {
+        "title": "插入信息到默认列",
+        "description": "如果模块中没有选择列,则将数据插入默认列",
+        "name": "默认列名称"
+      },
       "autocomplete": {
         "title": "自动完成",
         "description": "在输入块中启用自动完成(如果造成 Automa 不稳定请禁用)"
@@ -238,6 +256,7 @@
     }
   },
   "log": {
+    "flowId": "流程 Id",
     "goBack": "返回到 \"{name}\" 日志",
     "goWorkflow": "转到工作流",
     "startedDate": "启动日期",
@@ -273,6 +292,7 @@
       "no-workflow": "找不到含 \"{workflowId}\" ID 的工作流",
       "no-match-tab": "找不到含 \"{pattern}\" 参数的标签页",
       "no-clipboard-acces": "无权访问剪贴板",
+      "browser-not-supported": "{browser} 浏览器不支持此功能",
       "element-not-found": "找不到含 \"{selector}\" 选择器的元素.",
       "not-iframe": "含 \"{selector}\" 选择器的元素并不是一个 Iframe 元素",
       "iframe-not-found": "找不到含 \"{selector}\" 选择器的 Iframe 元素.",

+ 7 - 1
src/locales/zh/popup.json

@@ -7,7 +7,13 @@
     "record": {
       "title": "录制工作流",
       "button": "录制",
-      "name": "工作流名称"
+      "name": "工作流名称",
+      "selectBlock": "选择一个启动模块",
+      "anotherBlock": "无法从此模块启动",
+      "tabs": {
+        "new": "新建模块",
+        "existing": "已存在工作流"
+	    }
     },
     "elementSelector": {
       "name": "元素选择器",

+ 0 - 0
src/manifest.json → src/manifest.chrome.json


+ 72 - 0
src/manifest.firefox.json

@@ -0,0 +1,72 @@
+{
+  "manifest_version": 2,
+  "name": "Automa",
+  "browser_specific_settings": {
+    "gecko": {
+      "strict_min_version": "91.1.0"
+    }
+  },
+  "background": {
+    "scripts": [
+      "background.bundle.js"
+    ],
+    "persistent": false
+  },
+  "browser_action": {
+    "default_popup": "popup.html",
+    "default_icon": "icon-128.png"
+  },
+  "icons": {
+    "128": "icon-128.png"
+  },
+  "commands": {
+    "open-dashboard": {
+      "suggested_key": {
+        "default": "Ctrl+Shift+A",
+        "mac": "MacCtrl+Shift+A"
+      },
+      "description": "Open the Automa dashboard"
+    }
+  },
+  "content_scripts": [
+    {
+      "matches": [
+        "<all_urls>"
+      ],
+      "js": [
+        "shortcutListener.bundle.js"
+      ],
+      "run_at": "document_end",
+      "all_frames": false
+    },
+    {
+      "matches": [
+        "*://*.automa.site/*",
+        "*://automa.vercel.app/*"
+      ],
+      "js": [
+        "webService.bundle.js"
+      ],
+      "all_frames": false
+    }
+  ],
+  "optional_permissions": [
+    "clipboardRead",
+    "downloads"
+  ],
+  "permissions": [
+    "tabs",
+    "proxy",
+    "alarms",
+    "storage",
+    "webNavigation",
+    "unlimitedStorage",
+    "<all_urls>"
+  ],
+  "web_accessible_resources": [
+    "/elementSelector.css",
+    "/Inter-roman-latin.var.woff2",
+    "/locales/*",
+    "elementSelector.bundle.js"
+  ]
+}

+ 2 - 0
src/models/workflow.js

@@ -39,6 +39,8 @@ class Workflow extends Model {
         inputAutocomplete: true,
         onError: 'stop-workflow',
         executedBlockOnWeb: false,
+        insertDefaultColumn: true,
+        defaultColumnName: 'column',
       }),
       logs: this.hasMany(Log, 'workflowId'),
       globalData: this.string('[{ "key": "value" }]'),

+ 3 - 3
src/newtab/App.vue

@@ -23,7 +23,7 @@
       </template>
     </ui-dialog>
     <div
-      v-if="false"
+      v-if="isUpdated"
       class="p-4 shadow-2xl z-50 fixed bottom-8 left-1/2 -translate-x-1/2 rounded-lg bg-accent text-white dark:text-gray-900 flex items-center"
     >
       <v-remixicon name="riInformationLine" class="mr-3" />
@@ -214,10 +214,10 @@ function handleStorageChanged(change) {
   }
 }
 
-browser.storage.local.onChanged.addListener(handleStorageChanged);
+browser.storage.onChanged.addListener(handleStorageChanged);
 
 window.addEventListener('beforeunload', () => {
-  browser.storage.local.onChanged.removeListener(handleStorageChanged);
+  browser.storage.onChanged.removeListener(handleStorageChanged);
 });
 
 (async () => {

+ 2 - 1
src/newtab/pages/settings/SettingsAbout.vue

@@ -50,13 +50,14 @@
 /* eslint-disable camelcase */
 import { onMounted } from 'vue';
 import { useStore } from 'vuex';
+import browser from 'webextension-polyfill';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { communities } from '@/utils/shared';
 
 useGroupTooltip();
 const store = useStore();
 
-const extensionVersion = chrome.runtime.getManifest().version;
+const extensionVersion = browser.runtime.getManifest().version;
 const links = [
   ...communities,
   { name: 'Website', icon: 'riGlobalLine', url: 'https://www.automa.site' },

+ 1 - 1
src/newtab/pages/settings/SettingsShortcuts.vue

@@ -56,7 +56,7 @@ import { ref, reactive, computed, onBeforeUnmount } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import { mapShortcuts, getReadableShortcut } from '@/composable/shortcut';
-import recordShortcut from '@/utils/recordShortcut';
+import { recordShortcut } from '@/utils/recordKeys';
 
 const { t } = useI18n();
 const toast = useToast();

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.