Ahmad Kholid %!s(int64=3) %!d(string=hai) anos
pai
achega
aea91bbf25
Modificáronse 45 ficheiros con 1706 adicións e 1163 borrados
  1. 1 1
      package.json
  2. 37 12
      src/background/index.js
  3. 9 7
      src/background/workflowEngine/blocksHandler/handlerLoopData.js
  4. 1 1
      src/background/workflowEngine/executeContentScript.js
  5. 8 2
      src/components/block/BlockElementExists.vue
  6. 1 9
      src/components/content/selector/SelectorElementList.vue
  7. 1 0
      src/components/content/selector/SelectorElementsDetail.vue
  8. 3 9
      src/components/content/selector/SelectorQuery.vue
  9. 41 79
      src/components/content/shared/SharedElementHighlighter.vue
  10. 280 0
      src/components/content/shared/SharedElementSelector.vue
  11. 58 35
      src/components/newtab/shared/SharedConditionBuilder/index.vue
  12. 69 5
      src/components/newtab/workflow/WorkflowBuilder.vue
  13. 7 1
      src/components/newtab/workflow/edit/EditElementExists.vue
  14. 1 1
      src/components/newtab/workflow/edit/EditJavascriptCode.vue
  15. 2 1
      src/components/newtab/workflow/edit/EditWebhook.vue
  16. 12 2
      src/components/popup/home/HomeWorkflowCard.vue
  17. 1 1
      src/components/ui/UiExpand.vue
  18. 24 23
      src/content/blocksHandler/handlerJavascriptCode.js
  19. 26 0
      src/content/blocksHandler/handlerLoopData.js
  20. 2 1
      src/content/blocksHandler/handlerPressKey.js
  21. 58 340
      src/content/elementSelector/App.vue
  22. 57 0
      src/content/elementSelector/generateElementsSelector.js
  23. 21 9
      src/content/elementSelector/index.js
  24. 32 3
      src/content/elementSelector/listSelector.js
  25. 87 0
      src/content/elementSelector/selectorFrameContext.js
  26. 1 1
      src/content/handleSelector.js
  27. 6 7
      src/content/handleTestCondition.js
  28. 118 73
      src/content/index.js
  29. 84 213
      src/content/services/recordWorkflow/App.vue
  30. 8 4
      src/content/services/recordWorkflow/addBlock.js
  31. 21 5
      src/content/services/recordWorkflow/index.js
  32. 83 20
      src/content/services/recordWorkflow/recordEvents.js
  33. 0 0
      src/content/showExecutedBlock.js
  34. 68 5
      src/content/utils.js
  35. 1 0
      src/locales/en/newtab.json
  36. 5 1
      src/locales/en/popup.json
  37. 79 2
      src/newtab/App.vue
  38. 300 264
      src/newtab/pages/Workflows.vue
  39. 8 1
      src/newtab/pages/settings/SettingsIndex.vue
  40. 58 6
      src/popup/pages/Home.vue
  41. 16 0
      src/utils/handleFormElement.js
  42. 6 0
      src/utils/helper.js
  43. 1 0
      src/utils/shared.js
  44. 2 10
      src/utils/webhookUtil.js
  45. 2 9
      src/utils/workflowTrigger.js

+ 1 - 1
package.json

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

+ 37 - 12
src/background/index.js

@@ -1,4 +1,5 @@
 import browser from 'webextension-polyfill';
+import dayjs from '@/lib/dayjs';
 import { MessageListener } from '@/utils/message';
 import { parseJSON, findTriggerBlock } from '@/utils/helper';
 import getFile from '@/utils/getFile';
@@ -171,6 +172,7 @@ async function checkRecordingWorkflow(tabId, tabUrl) {
   if (!isRecording) return;
 
   await browser.tabs.executeScript(tabId, {
+    allFrames: true,
     file: 'recordWorkflow.bundle.js',
   });
 }
@@ -342,21 +344,44 @@ browser.runtime.onInstalled.addListener(async ({ reason }) => {
   }
 });
 browser.runtime.onStartup.addListener(async () => {
-  const { onStartupTriggers, workflows } = await browser.storage.local.get([
-    'onStartupTriggers',
-    'workflows',
-  ]);
+  const { workflows } = await browser.storage.local.get('workflows');
 
-  (onStartupTriggers || []).forEach((workflowId, index) => {
-    const findWorkflow = workflows.find(({ id }) => id === workflowId);
+  for (const currWorkflow of workflows) {
+    let triggerBlock = currWorkflow.trigger;
 
-    if (findWorkflow) {
-      workflow.execute(findWorkflow);
-    } else {
-      onStartupTriggers.splice(index, 1);
+    if (!triggerBlock) {
+      const flow =
+        typeof currWorkflow.drawflow === 'string'
+          ? parseJSON(currWorkflow.drawflow, {})
+          : currWorkflow.drawflow;
+
+      triggerBlock = findTriggerBlock(flow)?.data;
     }
-  });
-  await browser.storage.local.set({ onStartupTriggers });
+
+    if (triggerBlock) {
+      if (triggerBlock.type === 'specific-day') {
+        const alarm = await browser.alarms.get(currWorkflow.id);
+
+        if (!alarm) await registerSpecificDay(currWorkflow.id, triggerBlock);
+      } else if (triggerBlock.type === 'date' && triggerBlock.date) {
+        const [hour, minute] = triggerBlock.time.split(':');
+        const date = dayjs(triggerBlock.date)
+          .hour(hour)
+          .minute(minute)
+          .second(0);
+
+        const isBefore = dayjs().isBefore(date);
+
+        if (isBefore) {
+          await browser.alarms.create(currWorkflow.id, {
+            when: date.valueOf(),
+          });
+        }
+      } else if (triggerBlock.type === 'on-startup') {
+        workflow.execute(currWorkflow);
+      }
+    }
+  }
 });
 
 const message = new MessageListener('background');

+ 9 - 7
src/background/workflowEngine/blocksHandler/handlerLoopData.js

@@ -1,4 +1,4 @@
-import { parseJSON } from '@/utils/helper';
+import { parseJSON, isXPath } from '@/utils/helper';
 import { getBlockConnection } from '../helper';
 
 async function loopData({ data, id, outputs }, { refData }) {
@@ -36,12 +36,14 @@ async function loopData({ data, id, outputs }, { refData }) {
         },
         elements: async () => {
           const elements = await this._sendMessageToTab({
-            blockId: id,
-            isBlock: false,
-            max: data.maxLoop,
-            type: 'loop-elements',
-            selector: data.elementSelector,
-            frameSelector: this.frameSelector,
+            id,
+            name: 'loop-data',
+            data: {
+              multiple: true,
+              max: data.maxLoop,
+              selector: data.elementSelector,
+              findBy: isXPath(data.elementSelector) ? 'xpath' : 'cssSelector',
+            },
           });
 
           return elements;

+ 1 - 1
src/background/workflowEngine/executeContentScript.js

@@ -39,8 +39,8 @@ export default async function (tabId, frameId = 0) {
 
     if (!isScriptExists) {
       await browser.tabs.executeScript(tabId, {
+        allFrames: true,
         runAt: 'document_end',
-        frameId: currentFrameId,
         file: './contentScript.bundle.js',
       });
     }

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

@@ -3,6 +3,7 @@
     :id="componentId"
     :minimap="editor.minimap"
     class="element-exists"
+    style="width: 195px"
     @edit="editBlock"
     @delete="editor.removeNodeId(`node-${block.id}`)"
   >
@@ -17,10 +18,15 @@
     </div>
     <p
       :title="t('workflow.blocks.element-exists.selector')"
-      class="text-overflow p-2 rounded-lg bg-box-transparent text-sm font-mono text-right mb-2"
+      :class="{ 'font-mono': !block.data.description }"
+      class="text-overflow p-2 rounded-lg bg-box-transparent text-sm text-right mb-2"
       style="max-width: 200px"
     >
-      {{ block.data.selector || t('workflow.blocks.element-exists.selector') }}
+      {{
+        block.data.description ||
+        block.data.selector ||
+        t('workflow.blocks.element-exists.selector')
+      }}
     </p>
     <p class="text-right text-gray-600 dark:text-gray-200">
       <span :title="t('workflow.blocks.element-exists.fallbackTitle')">

+ 1 - 9
src/components/content/selector/SelectorElementList.vue

@@ -6,15 +6,7 @@
       @mouseenter="$emit('highlight', { highlight: true, index, element })"
       @mouseleave="$emit('highlight', { highlight: false, index, element })"
     >
-      <p
-        class="mb-1 cursor-pointer"
-        title="Scroll into view"
-        @click="
-          element.element.scrollIntoView({ block: 'center', inline: 'center' })
-        "
-      >
-        #{{ index + 1 }} {{ elementName }}
-      </p>
+      <p class="mb-1">#{{ index + 1 }} {{ elementName }}</p>
       <slot name="item" v-bind="{ element }" />
     </li>
   </ul>

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

@@ -33,6 +33,7 @@
             </p>
             <input
               :value="value"
+              :placeholder="!value ? 'EMPTY' : null"
               readonly
               title="Attribute value"
               class="bg-transparent w-full"

+ 3 - 9
src/components/content/selector/SelectorQuery.vue

@@ -24,9 +24,9 @@
     <div class="mt-2 flex items-center">
       <ui-input
         :model-value="selector"
+        readonly
         placeholder="Element selector"
         class="leading-normal flex-1 h-full element-selector"
-        @change="updateSelector"
       >
         <template #prepend>
           <button class="absolute ml-2 left-0" @click="copySelector">
@@ -34,7 +34,7 @@
           </button>
         </template>
       </ui-input>
-      <template v-if="selectedCount === 1">
+      <template v-if="selectedCount === 1 && !selector.includes('|>')">
         <button
           class="mr-1 ml-2"
           title="Parent element"
@@ -51,7 +51,6 @@
 </template>
 <script setup>
 import { inject } from 'vue';
-import { debounce } from '@/utils/helper';
 import UiInput from '@/components/ui/UiInput.vue';
 
 const props = defineProps({
@@ -72,7 +71,7 @@ const props = defineProps({
     default: false,
   },
 });
-const emit = defineEmits([
+defineEmits([
   'change',
   'list',
   'parent',
@@ -83,11 +82,6 @@ const emit = defineEmits([
 
 const rootElement = inject('rootElement');
 
-const updateSelector = debounce((value) => {
-  if (value === props.selector) return;
-
-  emit('change', value);
-}, 250);
 function copySelector() {
   rootElement.shadowRoot.querySelector('input')?.select();
 

+ 41 - 79
src/components/content/shared/SharedElementHighlighter.vue

@@ -1,95 +1,57 @@
 <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>
+  <rect
+    v-for="(item, index) in items"
+    v-bind="{
+      x: getNumber(item?.x),
+      y: getNumber(item?.y),
+      width: getNumber(item?.width),
+      height: getNumber(item?.height),
+      'stroke-dasharray': item?.outline ? '5,5' : null,
+      fill: getFillColor(item),
+      stroke: getStrokeColor(item),
+    }"
+    :key="index"
+    stroke-width="2"
+  ></rect>
 </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: () => ({}),
   },
+  stroke: {
+    type: String,
+    default: null,
+  },
+  activeStroke: {
+    type: String,
+    default: null,
+  },
+  fill: {
+    type: String,
+    default: null,
+  },
+  activeFill: {
+    type: String,
+    default: null,
+  },
 });
-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;
+function getNumber(num) {
+  if (Number.isNaN(num) || !num) return 0;
 
-  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) {
+  return num;
+}
+function getFillColor(item) {
+  if (!item) return null;
   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;
+  return item.highlight ? props.fill : props.activeFill || props.fill;
 }
+function getStrokeColor(item) {
+  if (!item) return null;
 
-onMounted(() => {
-  window.addEventListener('scroll', handleScroll);
-});
-onBeforeUnmount(() => {
-  window.removeEventListener('scroll', handleScroll);
-});
+  return item.highlight ? props.stroke : props.activeStroke || props.stroke;
+}
 </script>

+ 280 - 0
src/components/content/shared/SharedElementSelector.vue

@@ -0,0 +1,280 @@
+<template>
+  <svg
+    v-if="!disabled"
+    class="automa-element-highlighter"
+    style="
+      height: 100%;
+      width: 100%;
+      top: 0;
+      left: 0;
+      pointer-events: none;
+      position: fixed;
+      z-index: 999999;
+    "
+  >
+    <shared-element-highlighter
+      :items="elementsState.hovered"
+      stroke="#fbbf24"
+      fill="rgba(251, 191, 36, 0.1)"
+    />
+    <shared-element-highlighter
+      :items="elementsState.selected"
+      stroke="#2563EB"
+      active-stroke="#f87171"
+      fill="rgba(37, 99, 235, 0.1)"
+      active-fill="rgba(248, 113, 113, 0.1)"
+    />
+  </svg>
+  <teleport to="body">
+    <div
+      v-if="!disabled"
+      style="
+        z-index: 9999999;
+        position: fixed;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+      "
+    ></div>
+  </teleport>
+</template>
+<script setup>
+import { reactive, watch, onMounted, onBeforeUnmount } from 'vue';
+import { finder } from '@medv/finder';
+import { debounce } from '@/utils/helper';
+import { generateXPath, getElementPath, getElementRect } from '@/content/utils';
+import findElementList from '@/content/elementSelector/listSelector';
+import generateElementsSelector from '@/content/elementSelector/generateElementsSelector';
+import SharedElementHighlighter from './SharedElementHighlighter.vue';
+
+const props = defineProps({
+  selectorType: {
+    type: String,
+    default: 'css',
+  },
+  selectedEls: {
+    type: Array,
+    default: () => [],
+  },
+  list: Boolean,
+  pause: Boolean,
+  disabled: Boolean,
+  onlyInList: Boolean,
+  withAttributes: Boolean,
+});
+const emit = defineEmits(['selected']);
+
+let frameElement = null;
+let frameElementRect = null;
+let lastScrollPosY = window.scrollY;
+let lastScrollPosX = window.scrollX;
+
+let hoveredElements = [];
+const elementsState = reactive({
+  hovered: [],
+  selected: [],
+});
+
+const onScroll = debounce(() => {
+  if (props.disabled) return;
+
+  hoveredElements = [];
+  elementsState.hovered = [];
+
+  const yPos = window.scrollY - lastScrollPosY;
+  const xPos = window.scrollX - lastScrollPosX;
+
+  elementsState.selected.forEach((_, index) => {
+    elementsState.selected[index].x -= xPos;
+    elementsState.selected[index].y -= yPos;
+  });
+
+  lastScrollPosX = window.scrollX;
+  lastScrollPosY = window.scrollY;
+}, 100);
+
+function getElementRectWithOffset(element, withAttribute) {
+  const rect = getElementRect(element, withAttribute);
+
+  if (frameElementRect) {
+    rect.y += frameElementRect.top;
+    rect.x += frameElementRect.left;
+  }
+
+  return rect;
+}
+function removeElementsList() {
+  const prevSelectedList = document.querySelectorAll('[automa-el-list]');
+  prevSelectedList.forEach((el) => {
+    el.removeAttribute('automa-el-list');
+  });
+}
+function resetFramesElements(options = {}) {
+  const elements = document.querySelectorAll('iframe, frame');
+
+  elements.forEach((element) => {
+    element.contentWindow.postMessage(
+      {
+        ...options,
+        type: 'automa:reset-element-selector',
+      },
+      '*'
+    );
+  });
+}
+function retrieveElementsRect({ clientX, clientY, target: eventTarget }, type) {
+  const isAutomaContainer = eventTarget.classList.contains(
+    'automa-element-selector'
+  );
+  if (props.disabled || isAutomaContainer) return;
+
+  const isSelectList = props.list && props.selectorType === 'css';
+
+  let { 1: target } = document.elementsFromPoint(clientX, clientY);
+  if (!target) return;
+
+  const onlyInList = props.onlyInList && elementsState.selected.length > 0;
+
+  if (target.tagName === 'IFRAME' || target.tagName === 'FRAME') {
+    if (type === 'selected') removeElementsList();
+
+    if (target.contentDocument) {
+      frameElement = target;
+      frameElementRect = target.getBoundingClientRect();
+
+      const yPos = clientY - frameElementRect.top;
+      const xPos = clientX - frameElementRect.left;
+
+      target = target.contentDocument.elementFromPoint(xPos, yPos);
+    } else {
+      const { top, left } = target.getBoundingClientRect();
+      const payload = {
+        top,
+        left,
+        clientX,
+        clientY,
+        onlyInList,
+        list: isSelectList,
+        type: 'automa:get-element-rect',
+        withAttributes: props.withAttributes,
+      };
+
+      if (type === 'selected')
+        Object.assign(payload, {
+          click: true,
+          selectorType: props.selectorType,
+        });
+
+      target.contentWindow.postMessage(payload, '*');
+      frameElement = target;
+      frameElementRect = target.getBoundingClientRect();
+      return;
+    }
+  } else {
+    frameElement = null;
+    frameElementRect = null;
+  }
+
+  let elementsRect = [];
+  const withAttribute = props.withAttributes && type === 'selected';
+
+  if (isSelectList) {
+    const elements =
+      findElementList(target, {
+        onlyInList,
+        frameElement,
+      }) || [];
+
+    if (type === 'hovered') hoveredElements = elements;
+
+    elementsRect = elements.map((el) =>
+      getElementRectWithOffset(el, withAttribute)
+    );
+  } else {
+    if (type === 'hovered') hoveredElements = [target];
+
+    elementsRect = [getElementRectWithOffset(target, withAttribute)];
+  }
+
+  elementsState[type] = elementsRect;
+
+  if (type === 'selected') {
+    if (!frameElement) resetFramesElements();
+
+    let selector = generateElementsSelector({
+      target,
+      frameElement,
+      hoveredElements,
+      list: isSelectList,
+      selectorType: props.selectorType,
+    });
+
+    if (frameElement) {
+      const frameSelector = finder(frameElement);
+      selector = `${frameSelector} |> ${selector}`;
+    }
+
+    emit('selected', {
+      selector,
+      elements: elementsRect,
+      path: getElementPath(target),
+    });
+  }
+}
+function onMousemove(event) {
+  if (props.pause) return;
+
+  retrieveElementsRect(event, 'hovered');
+}
+function onClick(event) {
+  retrieveElementsRect(event, 'selected');
+}
+function onMessage({ data }) {
+  if (data.type !== 'automa:iframe-element-rect') return;
+
+  if (data.click) {
+    const frameSelector =
+      props.selectorType === 'css'
+        ? finder(frameElement, { tagName: () => true })
+        : generateXPath(frameElement);
+
+    emit('selected', {
+      elements: data.elements,
+      selector: `${frameSelector} |> ${data.selector}`,
+    });
+  }
+
+  const key = data.click ? 'selected' : 'hovered';
+  elementsState[key] = data.elements;
+}
+function attachListeners() {
+  window.addEventListener('scroll', onScroll);
+  document.addEventListener('click', onClick);
+  window.addEventListener('message', onMessage);
+  window.addEventListener('mousemove', onMousemove);
+}
+function detachListeners() {
+  window.removeEventListener('scroll', onScroll);
+  document.removeEventListener('click', onClick);
+  window.removeEventListener('message', onMessage);
+  window.removeEventListener('mousemove', onMousemove);
+}
+
+watch(
+  () => [props.list, props.disabled],
+  () => {
+    removeElementsList();
+    resetFramesElements({ clearCache: true });
+  }
+);
+watch(
+  () => props.selectedEls,
+  () => {
+    elementsState.selected = props.selectedEls;
+  }
+);
+
+onMounted(attachListeners);
+onBeforeUnmount(detachListeners);
+</script>

+ 58 - 35
src/components/newtab/shared/SharedConditionBuilder/index.vue

@@ -17,43 +17,54 @@
           </span>
         </div>
         <div class="flex-1 space-y-2">
-          <ui-expand
-            v-for="(inputs, inputsIndex) in item.conditions"
-            :key="inputs.id"
-            class="border rounded-lg w-full"
-            header-class="px-4 py-2 w-full flex items-center h-full rounded-lg overflow-hidden group focus:ring-0"
+          <draggable
+            v-model="conditions[index].conditions"
+            item-key="id"
+            group="conditions"
+            class="space-y-2"
+            @end="onDragEnd"
           >
-            <template #header>
-              <p class="text-overflow flex-1 text-left space-x-2 w-64">
-                <span
-                  v-for="input in inputs.items"
-                  :key="`text-${input.id}`"
-                  :class="[
-                    input.category === 'compare'
-                      ? 'font-semibold'
-                      : 'text-gray-600 dark:text-gray-200',
-                  ]"
+            <template #item="{ element: inputs, index: inputsIndex }">
+              <div class="condition-item">
+                <ui-expand
+                  class="border rounded-lg w-full"
+                  header-class="px-4 py-2 w-full flex items-center h-full rounded-lg overflow-hidden group focus:ring-0"
                 >
-                  {{ getConditionText(input) }}
-                </span>
-              </p>
-              <v-remixicon
-                name="riDeleteBin7Line"
-                class="ml-4 group-hover:visible invisible"
-                @click.stop="deleteCondition(index, inputsIndex)"
-              />
+                  <template #header>
+                    <p class="text-overflow flex-1 text-left space-x-2 w-64">
+                      <span
+                        v-for="input in inputs.items"
+                        :key="`text-${input.id}`"
+                        :class="[
+                          input.category === 'compare'
+                            ? 'font-semibold'
+                            : 'text-gray-600 dark:text-gray-200',
+                        ]"
+                      >
+                        {{ getConditionText(input) }}
+                      </span>
+                    </p>
+                    <v-remixicon
+                      name="riDeleteBin7Line"
+                      class="ml-4 group-hover:visible invisible"
+                      @click.stop="deleteCondition(index, inputsIndex)"
+                    />
+                    <v-remixicon name="mdiDrag" class="ml-2 cursor-move" />
+                  </template>
+                  <div class="space-y-2 px-4 py-2">
+                    <condition-builder-inputs
+                      :autocomplete="autocomplete"
+                      :data="inputs.items"
+                      @update="
+                        conditions[index].conditions[inputsIndex].items = $event
+                      "
+                    />
+                  </div>
+                </ui-expand>
+              </div>
             </template>
-            <div class="space-y-2 px-4 py-2">
-              <condition-builder-inputs
-                :autocomplete="autocomplete"
-                :data="inputs.items"
-                @update="
-                  conditions[index].conditions[inputsIndex].items = $event
-                "
-              />
-            </div>
-          </ui-expand>
-          <div class="space-x-2 text-sm">
+          </draggable>
+          <div class="space-x-2 text-sm mt-2 condition-action">
             <ui-button @click="addAndCondition(index)">
               <v-remixicon name="riAddLine" class="-ml-2 mr-1" size="20" />
               {{ t('workflow.conditionBuilder.and') }}
@@ -89,6 +100,8 @@
 import { ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid';
+import Draggable from 'vuedraggable';
+import cloneDeep from 'lodash.clonedeep';
 import { conditionBuilder } from '@/utils/shared';
 import ConditionBuilderInputs from './ConditionBuilderInputs.vue';
 
@@ -106,7 +119,7 @@ const emit = defineEmits(['update:modelValue', 'change']);
 
 const { t } = useI18n();
 
-const conditions = ref(JSON.parse(JSON.stringify(props.modelValue)));
+const conditions = ref(cloneDeep(props.modelValue));
 
 function getDefaultValues(items = ['value', 'compare', 'value']) {
   const defaultValues = {
@@ -167,6 +180,13 @@ function deleteCondition(index, itemIndex) {
 
   if (condition.length === 0) conditions.value.splice(index, 1);
 }
+function onDragEnd() {
+  conditions.value.forEach((item, index) => {
+    if (item.conditions.length > 0) return;
+
+    conditions.value.splice(index, 1);
+  });
+}
 
 watch(
   conditions,
@@ -187,4 +207,7 @@ watch(
   left: 50%;
   @apply dark:border-blue-400 border-blue-500 border-2 border-r-0 rounded-bl-lg rounded-tl-lg;
 }
+.ghost-condition .condition-action {
+  display: none;
+}
 </style>

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

@@ -173,6 +173,7 @@ export default {
     const prevSelectedEl = {
       output: null,
       connection: null,
+      nodeContent: null,
     };
     const isOutputEl = (el) => el.classList.contains('output');
     const isConnectionEl = (el) =>
@@ -205,18 +206,78 @@ export default {
         classes: 'ring-4',
         active: isOutputEl(target),
       });
+
+      const nodeContent = target.closest('.drawflow_content_node');
+      toggleHoverClass({
+        classes: 'ring-4',
+        target: nodeContent,
+        name: 'nodeContent',
+        active: nodeContent,
+      });
     }
     function dropHandler({ dataTransfer, clientX, clientY, target }) {
       const block = JSON.parse(dataTransfer.getData('block') || null);
 
-      if (!block || block.fromBlockBasic) return;
+      if (!block) return;
 
       const isTriggerExists =
         block.id === 'trigger' &&
         editor.value.getNodesFromName('trigger').length !== 0;
-
       if (isTriggerExists) return;
 
+      if (target.closest('.drawflow_content_node')) {
+        prevSelectedEl.nodeContent?.classList.remove('ring-4');
+
+        const targetNodeId = target
+          .closest('.drawflow-node')
+          .id.replace(/node-/, '');
+        const targetNode = editor.value.getNodeFromId(targetNodeId);
+        editor.value.removeNodeId(`node-${targetNodeId}`);
+
+        let targetBlock = block;
+        if (block.fromBlockBasic) {
+          targetBlock = { ...tasks[block.id], id: block.id };
+        }
+
+        const newNodeId = editor.value.addNode(
+          targetBlock.id,
+          targetBlock.inputs,
+          targetBlock.outputs,
+          targetNode.pos_x,
+          targetNode.pos_y,
+          targetBlock.id,
+          targetBlock.data,
+          targetBlock.component,
+          'vue'
+        );
+        const duplicateConnections = (nodeIO, type) => {
+          if (block[type] === 0) return;
+
+          Object.keys(nodeIO).forEach((name) => {
+            const { connections } = nodeIO[name];
+
+            connections.forEach(({ node, input, output }) => {
+              if (node === targetNodeId) return;
+
+              if (type === 'inputs') {
+                editor.value.addConnection(node, newNodeId, input, name);
+              } else if (type === 'outputs') {
+                editor.value.addConnection(newNodeId, node, name, output);
+              }
+            });
+          });
+        };
+
+        duplicateConnections(targetNode.inputs, 'inputs');
+        duplicateConnections(targetNode.outputs, 'outputs');
+
+        emitter.emit('editor:data-changed');
+
+        return;
+      }
+
+      if (block.fromBlockBasic) return;
+
       const xPosition =
         clientX *
           (editor.value.precanvas.clientWidth /
@@ -288,18 +349,18 @@ export default {
             result.inputClass
           );
         } catch (error) {
-          // Do nothing
+          console.error(error);
         }
       } else if (isOutputEl(target)) {
         prevSelectedEl.output?.classList.remove('ring-4');
 
-        const targetBlockId = target
+        const targetNodeId = target
           .closest('.drawflow-node')
           .id.replace(/node-/, '');
         const outputClass = target.classList[1];
 
         editor.value.addConnection(
-          targetBlockId,
+          targetNodeId,
           blockId,
           outputClass,
           'input_1'
@@ -862,4 +923,7 @@ export default {
   border: 2px solid rgba(98, 155, 255, 0.81);
   border-radius: 0.1em;
 }
+.drawflow_content_node {
+  @apply rounded-lg;
+}
 </style>

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

@@ -1,9 +1,15 @@
 <template>
   <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
     <ui-select
       :model-value="data.findBy || 'cssSelector'"
       :placeholder="t('workflow.blocks.base.findElement.placeholder')"
-      class="w-full mb-1"
+      class="w-full mb-1 mt-4"
       @change="updateData({ findBy: $event })"
     >
       <option v-for="type in selectorTypes" :key="type" :value="type">

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

@@ -196,7 +196,7 @@ function automaFuncsCompletion(context) {
       {
         label: 'automaSetVariable',
         type: 'function',
-        apply: snippet('automaSetVariable(${name}, ${value})'),
+        apply: snippet("automaSetVariable('${name}', ${value})"),
         info: () => {
           const container = document.createElement('div');
 

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

@@ -17,9 +17,10 @@
       </option>
     </ui-select>
     <edit-autocomplete class="mb-2">
-      <ui-input
+      <ui-textarea
         :model-value="data.url"
         :label="`${t('workflow.blocks.webhook.url')}*`"
+        rows="1"
         placeholder="http://api.example.com"
         class="w-full"
         autocomplete="off"

+ 12 - 2
src/components/popup/home/HomeWorkflowCard.vue

@@ -28,6 +28,7 @@
       </template>
       <ui-list class="w-40 space-y-1">
         <ui-list-item
+          v-if="tab === 'local'"
           class="capitalize cursor-pointer"
           @click="$emit('update', { isDisabled: !workflow.isDisabled })"
         >
@@ -37,7 +38,7 @@
           }}</span>
         </ui-list-item>
         <ui-list-item
-          v-for="item in menu"
+          v-for="item in filteredMenu"
           :key="item.name"
           v-close-popover
           class="capitalize cursor-pointer"
@@ -54,11 +55,15 @@
 import { useI18n } from 'vue-i18n';
 import dayjs from '@/lib/dayjs';
 
-defineProps({
+const props = defineProps({
   workflow: {
     type: Object,
     default: () => ({}),
   },
+  tab: {
+    type: String,
+    default: 'local',
+  },
 });
 defineEmits(['execute', 'rename', 'details', 'delete', 'update']);
 
@@ -68,4 +73,9 @@ const menu = [
   { name: 'rename', icon: 'riPencilLine' },
   { name: 'delete', icon: 'riDeleteBin7Line' },
 ];
+const filteredMenu = menu.filter(({ name }) => {
+  if (name === 'rename' && props.tab !== 'local') return false;
+
+  return true;
+});
 </script>

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

@@ -15,7 +15,7 @@
         v-if="appendIcon"
         :rotate="show ? 90 : -90"
         name="riArrowLeftSLine"
-        class="mr-2 transition-transform -ml-1"
+        class="transition-transform -mr-1 ml-2"
       />
     </button>
     <transition-expand>

+ 24 - 23
src/content/blocksHandler/handlerJavascriptCode.js

@@ -1,39 +1,31 @@
+import { nanoid } from 'nanoid/non-secure';
 import { sendMessage } from '@/utils/message';
 import { automaRefDataStr } from '../utils';
 
-function getAutomaScript(blockId, everyNewTab) {
+function getAutomaScript(refData, everyNewTab) {
+  const varName = `automa${nanoid(5)}`;
+
   let str = `
+const ${varName} = ${JSON.stringify(refData)};
+${automaRefDataStr(varName)}
 function automaSetVariable(name, value) {
-  const data = JSON.parse(sessionStorage.getItem('automa--${blockId}')) || null;
-
-  if (data === null) return null;
-
-  data.variables[name] = value;
-  sessionStorage.setItem('automa--${blockId}', JSON.stringify(data));
+  ${varName}.variables[name] = value;
 }
 function automaNextBlock(data, insert = true) {
-  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: { data, insert } }));
+  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: { data, insert, refData: ${varName} } }));
 }
 function automaResetTimeout() {
  window.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
 }
-${automaRefDataStr(blockId)}
   `;
 
-  if (everyNewTab) str = automaRefDataStr(blockId);
+  if (everyNewTab) str = automaRefDataStr(varName);
 
   return str;
 }
 
 function javascriptCode(block) {
-  if (!block.data.everyNewTab) {
-    sessionStorage.setItem(
-      `automa--${block.id}`,
-      JSON.stringify(block.refData)
-    );
-  }
-
-  const automaScript = getAutomaScript(block.id, block.data.everyNewTab);
+  const automaScript = getAutomaScript(block.refData, block.data.everyNewTab);
 
   return new Promise((resolve, reject) => {
     let documentCtx = document;
@@ -115,20 +107,29 @@ function javascriptCode(block) {
 
       if (!block.data.everyNewTab) {
         let timeout;
-        const cleanUp = (columns = {}) => {
-          const storageKey = `automa--${block.id}`;
-          const storageRefData = JSON.parse(sessionStorage.getItem(storageKey));
+        let isResolved = false;
+
+        const cleanUp = (detail = {}) => {
+          if (isResolved) return;
+          isResolved = true;
 
           script.remove();
           preloadScripts.forEach((item) => {
             if (item.removeAfterExec) item.script.remove();
           });
 
-          resolve({ columns, variables: storageRefData?.variables });
+          clearTimeout(timeout);
+
+          resolve({
+            columns: {
+              data: detail?.data,
+              insert: detail?.insert,
+            },
+            variables: detail?.refData?.variables,
+          });
         };
 
         window.addEventListener('__automa-next-block__', ({ detail }) => {
-          clearTimeout(timeout);
           cleanUp(detail || {});
         });
         window.addEventListener('__automa-reset-timeout__', () => {

+ 26 - 0
src/content/blocksHandler/handlerLoopData.js

@@ -0,0 +1,26 @@
+import { nanoid } from 'nanoid';
+import handleSelector from '../handleSelector';
+
+export default async function loopElements(block) {
+  const elements = await handleSelector(block);
+  const selectors = [];
+  const attrId = nanoid(5);
+
+  let frameSelector = '';
+
+  if (block.data.$frameSelector) {
+    frameSelector = `${block.data.$frameSelector} |> `;
+  }
+
+  elements.forEach((el, index) => {
+    if (block.data.max > 0 && selectors.length - 1 > block.data.max) return;
+
+    const attrName = 'automa-loop';
+    const attrValue = `${attrId}--${index}`;
+
+    el.setAttribute(attrName, attrValue);
+    selectors.push(`${frameSelector}[${attrName}="${attrValue}"]`);
+  });
+
+  return selectors;
+}

+ 2 - 1
src/content/blocksHandler/handlerPressKey.js

@@ -1,3 +1,4 @@
+import { isXPath } from '@/utils/helper';
 import { sendMessage } from '@/utils/message';
 import { keyDefinitions } from '@/utils/USKeyboardLayout';
 import { queryElements } from '../handleSelector';
@@ -126,7 +127,7 @@ async function pressKey({ data, debugMode, activeTabId }) {
   if (data.selector) {
     const customElement = await queryElements({
       selector: data.selector,
-      findBy: data.selector.startsWith('/') ? 'xpath' : 'cssSelector',
+      findBy: isXPath(data.selector) ? 'xpath' : 'cssSelector',
     });
 
     element = customElement || element;

+ 58 - 340
src/content/elementSelector/App.vue

@@ -50,9 +50,8 @@
           v-model:selectList="state.selectList"
           :selector="state.elSelector"
           :selected-count="state.selectedElements.length"
-          @child="selectChildElement"
-          @parent="selectParentElement"
-          @change="updateSelectedElements"
+          @parent="selectElementPath('up')"
+          @child="selectElementPath('down')"
         />
         <selector-elements-detail
           v-if="!state.hide && state.selectedElements.length > 0"
@@ -67,65 +66,45 @@
         />
       </div>
     </div>
-    <shared-element-highlighter
-      v-if="!state.hide"
-      :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>
+  <shared-element-selector
+    :hide="state.hide"
+    :disabled="state.hide"
+    :list="state.selectList"
+    :selector-type="state.selectorType"
+    :selected-els="state.selectedElements"
+    with-attributes
+    @selected="onElementsSelected"
+  />
 </template>
 <script setup>
 import { reactive, ref, watch, inject, onMounted, onBeforeUnmount } from 'vue';
-import { getCssSelector } from 'css-selector-generator';
 import { finder } from '@medv/finder';
-import { elementsHighlightData } from '@/utils/shared';
-import findElement from '@/utils/FindElement';
 import SelectorQuery from '@/components/content/selector/SelectorQuery.vue';
+import SharedElementSelector from '@/components/content/shared/SharedElementSelector.vue';
 import SelectorElementsDetail from '@/components/content/selector/SelectorElementsDetail.vue';
-import SharedElementHighlighter from '@/components/content/shared/SharedElementHighlighter.vue';
-import findElementList from './listSelector';
+import { getElementRect } from '../utils';
 
+const originalFontSize = document.documentElement.style.fontSize;
 const selectedElement = {
   path: [],
   pathIndex: 0,
+  cache: new WeakMap(),
 };
 
-const originalFontSize = document.documentElement.style.fontSize;
-
 const rootElement = inject('rootElement');
 
 const cardEl = ref('cardEl');
 const mainActiveTab = ref('selector');
 const state = reactive({
+  hide: false,
   elSelector: '',
-  listSelector: '',
   isDragging: false,
   selectList: false,
   isExecuting: false,
-  selectElements: [],
-  hoveredElements: [],
   selectorType: 'css',
   selectedElements: [],
   activeTab: 'attributes',
-  hide: window.self !== window.top,
 });
 const cardRect = reactive({
   x: 0,
@@ -141,310 +120,64 @@ const cardElementObserver = new ResizeObserver(([entry]) => {
   cardRect.height = height;
 });
 
-/* eslint-disable  no-use-before-define */
-const getElementSelector = (element, options = {}) => {
-  if (state.selectorType === 'css') {
-    if (Array.isArray(element)) {
-      return getCssSelector(element, {
-        root: document.body,
-        blacklist: [
-          '[focused]',
-          /focus/,
-          '[src=*]',
-          '[data-*]',
-          '[href=*]',
-          '[style=*]',
-          '[value=*]',
-          '[automa-*]',
-        ],
-        includeTag: true,
-        ...options,
-      });
-    }
-
-    return finder(element);
-  }
-
-  return generateXPath(element);
-};
-
-function generateXPath(element) {
-  if (!element) return null;
-  if (element.id !== '') return `id("${element.id}")`;
-  if (element === document.body) return `//${element.tagName}`;
-
-  let ix = 0;
-  const siblings = element.parentNode.childNodes;
-
-  for (let index = 0; index < siblings.length; index += 1) {
-    const sibling = siblings[index];
-
-    if (sibling === element) {
-      return `${generateXPath(element.parentNode)}/${element.tagName}[${
-        ix + 1
-      }]`;
-    }
-
-    if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
-      ix += 1;
-    }
-  }
-
-  return null;
-}
 function toggleHighlightElement({ index, highlight }) {
   state.selectedElements[index].highlight = highlight;
 }
-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 updateSelectedElements(selector) {
-  state.elSelector = selector;
-
-  try {
-    const selectorType = state.selectorType === 'css' ? 'cssSelector' : 'xpath';
-    let elements = findElement[selectorType]({ selector, multiple: true });
-    const selectElements = [];
-
-    if (selectorType === 'xpath') {
-      elements = elements ? [elements] : [];
-    }
-
-    const elementsDetail = Array.from(elements).map((element, index) => {
-      const attributes = Array.from(element.attributes).reduce(
-        (acc, { name, value }) => {
-          if (name === 'automa-el-list') return acc;
-
-          acc[name] = value;
-
-          return acc;
-        },
-        {}
-      );
-
-      const elementProps = {
-        element,
-        attributes,
-        highlight: false,
-        ...getElementRect(element),
-      };
-
-      if (element.tagName === 'SELECT') {
-        const options = Array.from(element.querySelectorAll('option')).map(
-          (option) => ({
-            name: option.innerText,
-            value: option.value,
-          })
-        );
-
-        selectElements.push({ ...elementProps, options, index });
-      }
-
-      return elementProps;
-    });
-
-    state.selectElements = selectElements;
-    state.selectedElements = elementsDetail;
-  } catch (error) {
-    state.selectElements = [];
-    state.selectedElements = [];
-  }
-}
-function getElementList(target) {
-  const automaListEl = target.closest('[automa-el-list]');
-
-  if (automaListEl) {
-    if (target.hasAttribute('automa-el-list')) return [];
-
-    const childSelector = finder(target, {
-      root: automaListEl,
-      idName: () => false,
-    });
-    const elements = document.querySelectorAll(
-      `${state.listSelector} ${childSelector}`
-    );
-
-    return Array.from(elements);
-  }
-
-  return findElementList(target) || [target];
-}
-let prevHoverElement = null;
-function handleMouseMove({ clientX, clientY, target }) {
-  if (state.isDragging) {
-    const height = window.innerHeight;
-    const width = document.documentElement.clientWidth;
-
-    if (clientY < 10) clientY = 10;
-    else if (cardRect.height + clientY > height)
-      clientY = height - cardRect.height;
-
-    if (clientX < 10) clientX = 10;
-    else if (cardRect.width + clientX > width) clientX = width - cardRect.width;
-
-    cardRect.x = clientX;
-    cardRect.y = clientY;
-
-    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(realTarget) || [];
-
-    elementsRect = elements.map((el) => getElementRect(el, true));
-  } else {
-    elementsRect = [getElementRect(realTarget)];
+function onElementsSelected({ selector, elements, path }) {
+  if (path) {
+    selectedElement.path = path;
+    selectedElement.pathIndex = 0;
   }
 
-  state.hoveredElements = elementsRect;
+  state.elSelector = selector;
+  state.selectedElements = elements || [];
 }
-function handleClick(event) {
-  const { target: eventTarget, path, ctrlKey, clientY, clientX } = event;
-
-  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;
-
-    if (!firstElement) return;
-
-    const isInList = target.closest('[automa-el-list]');
-    if (isInList) {
-      const childSelector = finder(target, {
-        root: isInList,
-        idName: () => false,
-      });
-      updateSelectedElements(`${state.listSelector} ${childSelector}`, true);
-
-      return;
-    }
-
-    const prevSelectedList = document.querySelectorAll('[automa-el-list]');
-    prevSelectedList.forEach((element) => {
-      element.removeAttribute('automa-el-list');
-    });
-
-    state.hoveredElements.forEach(({ element }) => {
-      element.setAttribute('automa-el-list', '');
-    });
-
-    const parentSelector = finder(firstElement.parentElement);
-    const elementSelector = `${parentSelector} > ${firstElement.tagName.toLowerCase()}`;
-
-    state.listSelector = elementSelector;
-    updateSelectedElements(elementSelector);
+function onMousemove({ clientX, clientY }) {
+  if (!state.isDragging) return;
 
-    return;
-  }
-
-  const getElementDetail = (element) => {
-    const attributes = {};
-
-    Array.from(element.attributes).forEach(({ name, value }) => {
-      if (name === 'automa-el-list') return;
-
-      attributes[name] = value;
-    });
-
-    return {
-      ...getElementRect(element),
-      element,
-      attributes,
-      highlight: false,
-      outline: state.selectList && state.selectedElements.length,
-    };
-  };
-
-  let targetElement = target;
-  const targetElementDetail = getElementDetail(target);
+  const height = window.innerHeight;
+  const width = document.documentElement.clientWidth;
 
-  if (state.selectorType === 'css' && ctrlKey) {
-    let elementIndex = -1;
+  if (clientY < 10) clientY = 10;
+  else if (cardRect.height + clientY > height)
+    clientY = height - cardRect.height;
 
-    const elements = state.selectedElements.map(({ element }, index) => {
-      if (element === targetElement) {
-        elementIndex = index;
-      }
+  if (clientX < 10) clientX = 10;
+  else if (cardRect.width + clientX > width) clientX = width - cardRect.width;
 
-      return element;
-    });
-
-    if (elementIndex === -1) {
-      targetElement = [...elements, target];
-      state.selectedElements.push(targetElementDetail);
-    } else {
-      targetElement = elements.splice(elementIndex, 1);
-      state.selectedElements.splice(elementIndex, 1);
-    }
-  } else {
-    state.selectedElements = [targetElementDetail];
-  }
-
-  state.elSelector = getElementSelector(targetElement);
-
-  selectedElement.index = 0;
-  selectedElement.path = path;
+  cardRect.x = clientX;
+  cardRect.y = clientY;
 }
-function selectChildElement() {
-  if (selectedElement.path.length === 0 || state.hide) return;
-
-  const currentEl = selectedElement.path[selectedElement.pathIndex];
-  let childElement = currentEl;
-
-  if (selectedElement.pathIndex <= 0) {
-    const childEl = Array.from(currentEl.children).find(
+function selectElementPath(type) {
+  let pathIndex =
+    type === 'up'
+      ? selectedElement.pathIndex + 1
+      : selectedElement.pathIndex - 1;
+  let element = selectedElement.path[pathIndex];
+
+  if ((type === 'up' && !element) || element?.tagName === 'BODY') return;
+
+  if (type === 'down' && !element) {
+    const previousElement = selectedElement.path[selectedElement.pathIndex];
+    const childEl = Array.from(previousElement.children).find(
       (el) => !['STYLE', 'SCRIPT'].includes(el.tagName)
     );
 
-    if (currentEl.childElementCount === 0 || currentEl === childEl) return;
+    if (!childEl) return;
 
-    childElement = childEl;
+    element = childEl;
     selectedElement.path.unshift(childEl);
-    selectedElement.pathIndex = 0;
-  } else {
-    selectedElement.pathIndex -= 1;
-    childElement = selectedElement.path[selectedElement.pathIndex];
+    pathIndex = 0;
   }
 
-  updateSelectedElements(getElementSelector(childElement));
-}
-function selectParentElement() {
-  if (selectedElement.path.length === 0 || state.hide) return;
-
-  const parentElement = selectedElement.path[selectedElement.pathIndex];
-
-  if (parentElement.tagName === 'HTML') return;
+  selectedElement.pathIndex = pathIndex;
 
-  selectedElement.pathIndex += 1;
-
-  updateSelectedElements(getElementSelector(parentElement));
+  state.selectedElements = [getElementRect(element, true)];
+  state.elSelector = selectedElement.cache.has(element)
+    ? selectedElement.cache.get(element)
+    : finder(element);
 }
-function handleMouseUp() {
+function onMouseup() {
   if (state.isDragging) state.isDragging = false;
 }
 function destroy() {
@@ -470,16 +203,14 @@ function destroy() {
 function attachListeners() {
   cardElementObserver.observe(cardEl.value);
 
-  window.addEventListener('mouseup', handleMouseUp);
-  window.addEventListener('mousemove', handleMouseMove);
-  document.addEventListener('click', handleClick, true);
+  window.addEventListener('mouseup', onMouseup);
+  window.addEventListener('mousemove', onMousemove);
 }
 function detachListeners() {
   cardElementObserver.disconnect();
 
-  window.removeEventListener('mouseup', handleMouseUp);
-  window.removeEventListener('mousemove', handleMouseMove);
-  document.removeEventListener('click', handleClick, true);
+  window.removeEventListener('mouseup', onMouseup);
+  window.removeEventListener('mousemove', onMousemove);
 }
 
 watch(
@@ -488,19 +219,6 @@ watch(
     document.body.toggleAttribute('automa-isDragging', value);
   }
 );
-watch(
-  () => state.selectList,
-  (value) => {
-    if (value) {
-      state.selectedElements = [];
-    } else {
-      const prevSelectedList = document.querySelectorAll('[automa-el-list]');
-      prevSelectedList.forEach((element) => {
-        element.removeAttribute('automa-el-list');
-      });
-    }
-  }
-);
 
 onMounted(() => {
   setTimeout(() => {

+ 57 - 0
src/content/elementSelector/generateElementsSelector.js

@@ -0,0 +1,57 @@
+import { finder } from '@medv/finder';
+import { generateXPath } from '../utils';
+
+export default function ({
+  list,
+  target,
+  selectorType,
+  hoveredElements,
+  frameElement,
+}) {
+  let selector = '';
+
+  const [selectedElement] = hoveredElements;
+  const finderOptions = {};
+  let documentCtx = document;
+
+  if (frameElement) {
+    documentCtx = frameElement.contentDocument.body;
+    finderOptions.root = documentCtx;
+  }
+
+  if (list) {
+    const isInList = target.closest('[automa-el-list]');
+
+    if (isInList) {
+      const childSelector = finder(target, {
+        root: isInList,
+        idName: () => false,
+      });
+      const listSelector = isInList.getAttribute('automa-el-list');
+
+      selector = `${listSelector} ${childSelector}`;
+    } else {
+      const parentSelector = finder(
+        selectedElement.parentElement,
+        finderOptions
+      );
+      selector = `${parentSelector} > ${selectedElement.tagName.toLowerCase()}`;
+
+      const prevSelectedList = documentCtx.querySelectorAll('[automa-el-list]');
+      prevSelectedList.forEach((el) => {
+        el.removeAttribute('automa-el-list');
+      });
+
+      hoveredElements.forEach((el) => {
+        el.setAttribute('automa-el-list', selector);
+      });
+    }
+  } else {
+    selector =
+      selectorType === 'css'
+        ? finder(selectedElement, finderOptions)
+        : generateXPath(selectedElement);
+  }
+
+  return selector;
+}

+ 21 - 9
src/content/elementSelector/index.js

@@ -1,6 +1,7 @@
 import browser from 'webextension-polyfill';
 import initElementSelector from './main';
 import injectAppStyles from '../injectAppStyles';
+import selectorFrameContext from './selectorFrameContext';
 
 function elementSelectorInstance() {
   const rootElementExist = document.querySelector(
@@ -28,19 +29,30 @@ function elementSelectorInstance() {
   });
 
   try {
-    const isAppExists = elementSelectorInstance();
+    const isMainFrame = window.self === window.top;
 
-    if (isAppExists) return;
+    if (isMainFrame) {
+      const isAppExists = elementSelectorInstance();
 
-    const rootElement = document.createElement('div');
-    rootElement.setAttribute('id', 'app-container');
-    rootElement.classList.add('automa-element-selector');
-    rootElement.attachShadow({ mode: 'open' });
+      if (isAppExists) return;
 
-    initElementSelector(rootElement);
-    await injectAppStyles(rootElement.shadowRoot);
+      const rootElement = document.createElement('div');
+      rootElement.setAttribute('id', 'app-container');
+      rootElement.classList.add('automa-element-selector');
+      rootElement.attachShadow({ mode: 'open' });
 
-    document.documentElement.appendChild(rootElement);
+      initElementSelector(rootElement);
+      await injectAppStyles(rootElement.shadowRoot);
+
+      document.documentElement.appendChild(rootElement);
+    } else {
+      const style = document.createElement('style');
+      style.textContent = '[automa-el-list] {outline: 2px dashed #6366f1;}';
+
+      document.body.appendChild(style);
+
+      selectorFrameContext();
+    }
   } catch (error) {
     console.error(error);
   }

+ 32 - 3
src/content/elementSelector/listSelector.js

@@ -1,3 +1,5 @@
+import { finder } from '@medv/finder';
+
 /* eslint-disable  no-cond-assign */
 export function getAllSiblings(el, selector) {
   const siblings = [el];
@@ -33,7 +35,7 @@ export function getAllSiblings(el, selector) {
 }
 
 export function getCssPath(el, root = document.body) {
-  if (!(el instanceof Element)) return null;
+  if (!el) return null;
 
   const path = [];
 
@@ -64,7 +66,7 @@ export function getCssPath(el, root = document.body) {
 }
 
 export function getElementList(el, maxDepth = 50, paths = []) {
-  if (maxDepth === 0 || el.tagName === 'BODY') return null;
+  if (maxDepth === 0 || !el || el.tagName === 'BODY') return null;
 
   let selector = el.tagName.toLowerCase();
   const { elements, index } = getAllSiblings(el, paths.join(' > '));
@@ -81,4 +83,31 @@ export function getElementList(el, maxDepth = 50, paths = []) {
   return siblings;
 }
 
-export default getElementList;
+export default function (target, { frameElement, onlyInList } = {}) {
+  if (!target) return [];
+
+  const automaListEl = target.closest('[automa-el-list]');
+  let documentCtx = document;
+
+  if (frameElement) {
+    documentCtx = frameElement.contentDocument;
+  }
+
+  if (automaListEl) {
+    if (target.hasAttribute('automa-el-list')) return [];
+
+    const childSelector = finder(target, {
+      root: automaListEl,
+      idName: () => false,
+    });
+    const elements = documentCtx.querySelectorAll(
+      `[automa-el-list] ${childSelector}`
+    );
+
+    return Array.from(elements);
+  }
+
+  if (onlyInList) return [];
+
+  return getElementList(target) || [target];
+}

+ 87 - 0
src/content/elementSelector/selectorFrameContext.js

@@ -0,0 +1,87 @@
+import { getElementRect } from '../utils';
+import findElementList from './listSelector';
+import generateElementsSelector from './generateElementsSelector';
+
+let hoveredElements = [];
+let prevSelectedElement = null;
+
+function getElementRectWithOffset(element, data) {
+  const withAttributes = data.withAttributes && data.click;
+  const elementRect = getElementRect(element, withAttributes);
+
+  elementRect.y += data.top;
+  elementRect.x += data.left;
+
+  return elementRect;
+}
+function getElementsRect(data) {
+  const [element] = document.elementsFromPoint(
+    data.clientX - data.left,
+    data.clientY - data.top
+  );
+  if ((!element || element === prevSelectedElement) && !data.click) return;
+
+  const payload = {
+    elements: [],
+    type: 'automa:iframe-element-rect',
+  };
+
+  if (data.click) {
+    if (hoveredElements.length === 0) return;
+
+    payload.click = true;
+
+    const [selectedElement] = hoveredElements;
+    const selector = generateElementsSelector({
+      hoveredElements,
+      list: data.list,
+      target: selectedElement,
+      selectorType: data.selectorType,
+    });
+
+    payload.selector = selector;
+    payload.elements = hoveredElements.map((el) =>
+      getElementRectWithOffset(el, data)
+    );
+  } else {
+    prevSelectedElement = element;
+    let elementsRect = [];
+
+    if (data.list) {
+      const elements =
+        findElementList(element, {
+          onlyInList: data.onlyInList,
+        }) || [];
+
+      hoveredElements = elements;
+      elementsRect = elements.map((el) => getElementRectWithOffset(el, data));
+    } else {
+      hoveredElements = [element];
+      elementsRect = [getElementRectWithOffset(element, data)];
+    }
+
+    payload.elements = elementsRect;
+  }
+
+  window.top.postMessage(payload, '*');
+}
+function resetElementSelector(data) {
+  const prevSelectedList = document.querySelectorAll('[automa-el-list]');
+  prevSelectedList.forEach((el) => {
+    el.removeAttribute('automa-el-list');
+  });
+
+  if (data.clearCache) {
+    hoveredElements = [];
+    prevSelectedElement = null;
+  }
+}
+function onMessage({ data }) {
+  if (data.type === 'automa:get-element-rect') getElementsRect(data);
+  else if (data.type === 'automa:reset-element-selector')
+    resetElementSelector(data);
+}
+
+export default function () {
+  window.addEventListener('message', onMessage);
+}

+ 1 - 1
src/content/handleSelector.js

@@ -52,7 +52,7 @@ export function queryElements(data, documentCtx = document) {
 
 export default async function (
   { data, id, frameSelector, debugMode },
-  { onSelected, onError, onSuccess }
+  { onSelected, onError, onSuccess } = {}
 ) {
   if (!data || !data.selector) {
     if (onError) onError(new Error('selector-empty'));

+ 6 - 7
src/content/handleTestCondition.js

@@ -1,10 +1,10 @@
-import { nanoid } from 'nanoid';
-import { visibleInViewport } from '@/utils/helper';
+import { nanoid } from 'nanoid/non-secure';
+import { visibleInViewport, isXPath } from '@/utils/helper';
 import FindElement from '@/utils/FindElement';
 import { automaRefDataStr } from './utils';
 
 function handleConditionElement({ data, type }) {
-  const selectorType = data.selector.startsWith('/') ? 'xpath' : 'cssSelector';
+  const selectorType = isXPath(data.selector) ? 'xpath' : 'cssSelector';
 
   const element = FindElement[selectorType](data);
   const { 1: actionType } = type.split('#');
@@ -44,14 +44,13 @@ function handleConditionElement({ data, type }) {
 }
 function injectJsCode({ data, refData }) {
   return new Promise((resolve, reject) => {
-    const stateId = nanoid();
-
-    sessionStorage.setItem(`automa--${stateId}`, JSON.stringify(refData));
+    const varName = `automa${nanoid(5)}`;
 
     const scriptEl = document.createElement('script');
     scriptEl.textContent = `
       (async () => {
-        ${automaRefDataStr(stateId)}
+        const ${varName} = ${JSON.stringify(refData)};
+        ${automaRefDataStr(varName)}
         try {
           ${data.code}
         } catch (error) {

+ 118 - 73
src/content/index.js

@@ -1,63 +1,140 @@
 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 showExecutedBlock from './showExecutedBlock';
 import handleTestCondition from './handleTestCondition';
 
-function messageListener({ data, source }) {
-  if (data !== 'automa:get-frame') return;
+const isMainFrame = window.self === window.top;
+
+function messageToFrame(frameElement, blockData) {
+  return new Promise((resolve, reject) => {
+    function onMessage({ data }) {
+      if (data.type !== 'automa:block-execute-result') return;
 
-  let frameRect = { x: 0, y: 0 };
+      if (data.result?.$isError) {
+        const error = new Error(data.result.message);
+        error.data = data.result.data;
 
-  document.querySelectorAll('iframe').forEach((iframe) => {
-    if (iframe.contentWindow !== source) return;
+        reject(error);
+      } else {
+        resolve(data.result);
+      }
 
-    frameRect = iframe.getBoundingClientRect();
+      window.removeEventListener('message', onMessage);
+    }
+    window.addEventListener('message', onMessage);
+
+    frameElement.contentWindow.postMessage(
+      {
+        type: 'automa:execute-block',
+        blockData: { ...blockData, frameSelector: '' },
+      },
+      '*'
+    );
   });
+}
+async function executeBlock(data) {
+  const removeExecutedBlock = showExecutedBlock(data, data.executedBlockOnWeb);
+
+  if (data.data.selector?.includes('|>') && isMainFrame) {
+    const [frameSelector, selector] = data.data.selector.split(/\|>(.+)/);
+    const frameElement = document.querySelector(frameSelector);
+    const frameError = (message) => {
+      const error = new Error(message);
+      error.data = { selector: frameSelector };
+
+      return error;
+    };
+
+    if (!frameElement) throw frameError('iframe-not-found');
+
+    const isFrameEelement = ['IFRAME', 'FRAME'].includes(frameElement.tagName);
+    if (!isFrameEelement) throw frameError('not-iframe');
+
+    data.data.selector = selector;
+    data.data.$frameSelector = frameSelector;
+
+    if (frameElement.contentDocument) {
+      data.frameSelector = frameSelector;
+    } else {
+      const result = await messageToFrame(frameElement, data);
+      return result;
+    }
+  }
 
-  source.postMessage(
-    {
-      frameRect,
-      type: 'automa:the-frame-rect',
-    },
-    '*'
-  );
+  const handler = blocksHandler[toCamelCase(data.name)];
+
+  if (handler) {
+    const result = await handler(data);
+    removeExecutedBlock();
+
+    return result;
+  }
+
+  const error = new Error(`"${data.name}" doesn't have a handler`);
+  console.error(error);
+
+  throw error;
 }
+function messageListener({ data, source }) {
+  if (data.type === 'automa:get-frame' && isMainFrame) {
+    let frameRect = { x: 0, y: 0 };
 
-(() => {
-  if (window.isAutomaInjected) return;
-  window.isAutomaInjected = true;
+    document.querySelectorAll('iframe').forEach((iframe) => {
+      if (iframe.contentWindow !== source) return;
+
+      frameRect = iframe.getBoundingClientRect();
+    });
 
-  if (window.self === window.top) {
-    window.addEventListener('message', messageListener);
+    source.postMessage(
+      {
+        frameRect,
+        type: 'automa:the-frame-rect',
+      },
+      '*'
+    );
+
+    return;
   }
 
-  browser.runtime.onMessage.addListener((data) => {
-    return new Promise((resolve, reject) => {
-      if (data.isBlock) {
-        const removeExecutedBlock = executedBlock(
-          data,
-          data.executedBlockOnWeb
+  if (data.type === 'automa:execute-block') {
+    executeBlock(data.blockData)
+      .then((result) => {
+        window.top.postMessage(
+          {
+            result,
+            type: 'automa:block-execute-result',
+          },
+          '*'
         );
+      })
+      .catch((error) => {
+        console.error(error);
+        window.top.postMessage(
+          {
+            result: {
+              $isError: true,
+              message: error.message,
+              data: error.data || {},
+            },
+            type: 'automa:block-execute-result',
+          },
+          '*'
+        );
+      });
+  }
+}
 
-        const handler = blocksHandler[toCamelCase(data.name)];
-
-        if (handler) {
-          handler(data)
-            .then((result) => {
-              removeExecutedBlock();
-              resolve(result);
-            })
-            .catch(reject);
+(() => {
+  if (window.isAutomaInjected) return;
 
-          return;
-        }
-        console.error(`"${data.name}" doesn't have a handler`);
+  window.isAutomaInjected = true;
+  window.addEventListener('message', messageListener);
 
-        resolve('');
+  browser.runtime.onMessage.addListener((data) => {
+    return new Promise((resolve, reject) => {
+      if (data.isBlock) {
+        executeBlock(data).then(resolve).catch(reject);
         return;
       }
 
@@ -70,38 +147,6 @@ function messageListener({ data, source }) {
         case 'content-script-exists':
           resolve(true);
           break;
-        case 'loop-elements': {
-          const selectors = [];
-          const attrId = nanoid(5);
-
-          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;
-
-            const attrName = 'automa-loop';
-            const attrValue = `${attrId}--${index}`;
-
-            el.setAttribute(attrName, attrValue);
-            selectors.push(`[${attrName}="${attrValue}"]`);
-          });
-
-          resolve(selectors);
-          break;
-        }
         default:
       }
     });

+ 84 - 213
src/content/services/recordWorkflow/App.vue

@@ -74,11 +74,15 @@
           <template v-else>
             <div class="flex items-center space-x-2 w-full">
               <input
-                :value="selectState.childSelector"
+                :value="selectState.childSelector || selectState.parentSelector"
                 class="px-4 py-2 rounded-lg bg-input w-full"
                 readonly
               />
-              <template v-if="!selectState.list">
+              <template
+                v-if="
+                  !selectState.list && !selectState.childSelector.includes('|>')
+                "
+              >
                 <button @click="selectElementPath('up')">
                   <v-remixicon name="riArrowLeftLine" rotate="90" />
                 </button>
@@ -114,11 +118,11 @@
               >
                 <option value="" selected disabled>Select attribute</option>
                 <option
-                  v-for="attr in addBlockState.attributes"
-                  :key="attr.id"
-                  :value="attr.id"
+                  v-for="(value, name) in addBlockState.attributes"
+                  :key="name"
+                  :value="name"
                 >
-                  {{ attr.name }}
+                  {{ name }}({{ value.slice(0, 64) }})
                 </option>
               </select>
               <label
@@ -175,42 +179,30 @@
       </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"
+  <shared-element-selector
+    v-if="selectState.isSelecting"
+    :selected-els="selectState.selectedElements"
+    with-attributes
+    only-in-list
+    :list="selectState.list"
+    :pause="
+      selectState.selectedElements.length > 0 &&
+      selectState.list &&
+      !selectState.listId
+    "
+    @selected="onElementsSelected"
   />
-  <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 { tasks } from '@/utils/shared';
+import SharedElementSelector from '@/components/content/shared/SharedElementSelector.vue';
+import { getElementRect } from '../../utils';
 import addBlock from './addBlock';
 
-let prevHoverElement = null;
 const mouseRelativePos = { x: 0, y: 0 };
 const elementsPath = {
   path: [],
@@ -228,7 +220,7 @@ const selectState = reactive({
   isInList: false,
   listSelector: '',
   childSelector: '',
-  hoveredElements: [],
+  isSelecting: false,
   selectedElements: [],
 });
 const draggingState = reactive({
@@ -253,20 +245,41 @@ const blocksList = {
   default: ['get-text', 'attribute-value'],
 };
 
-function getElementRect(target, withElement = false) {
-  if (!target) return {};
+function getElementBlocks(element) {
+  if (!element) return;
 
-  const { x, y, height, width } = target.getBoundingClientRect();
-  const result = {
-    width: width + 4,
-    height: height + 4,
-    x: x - 2,
-    y: y - 2,
-  };
+  const elTag = element.tagName;
+  const blocks = [...(blocksList[elTag] || blocksList.default)];
+  const attrBlockIndex = blocks.indexOf('attribute-value');
+
+  if (attrBlockIndex !== -1) {
+    addBlockState.attributes = element.attributes;
+  }
+
+  addBlockState.blocks = blocks;
+}
+function onElementsSelected({ selector, elements, path }) {
+  if (path) {
+    elementsPath.path = path;
+    selectState.pathIndex = 0;
+  }
+
+  getElementBlocks(elements[0]);
+  selectState.selectedElements = elements;
+
+  if (selectState.list) {
+    if (!selectState.listSelector) {
+      selectState.isInList = false;
+      selectState.listSelector = selector;
+      selectState.childSelector = selector;
+      return;
+    }
 
-  if (withElement) result.element = target;
+    selectState.isInList = true;
+    selector = selector.replace(selectState.listSelector, '');
+  }
 
-  return result;
+  selectState.childSelector = selector;
 }
 function addFlowItem() {
   const saveData = Boolean(addBlockState.column);
@@ -285,12 +298,15 @@ function addFlowItem() {
     },
   };
 
-  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 (selectState.list) {
+    if (selectState.isInList || selectState.listId) {
+      const childSelector = selectState.isInList
+        ? selectState.childSelector
+        : '';
+      block.data.selector = `{{loopData@${selectState.listId}}} ${childSelector}`;
+    } else {
+      block.data.multiple = true;
+    }
   }
 
   if (addBlockState.activeBlock === 'attribute-value') {
@@ -343,8 +359,10 @@ function clearSelectState() {
   selectState.listId = '';
   selectState.list = false;
   selectState.status = 'idle';
+  selectState.listSelector = '';
+  selectState.childSelector = '';
+  selectState.parentSelector = '';
   selectState.isSelecting = false;
-  selectState.hoveredElements = [];
   selectState.selectedElements = [];
 
   const selectedList = document.querySelectorAll('[automa-el-list]');
@@ -352,6 +370,16 @@ function clearSelectState() {
     element.removeAttribute('automa-el-list');
   });
 
+  const frameElements = document.querySelectorAll('iframe, frame');
+  frameElements.forEach((element) => {
+    element.contentWindow.postMessage(
+      {
+        type: 'automa:reset-element-selector',
+      },
+      '*'
+    );
+  });
+
   document.body.removeAttribute('automa-selecting');
 }
 function saveElementListId() {
@@ -370,37 +398,6 @@ function saveElementListId() {
     },
   });
 }
-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();
@@ -432,143 +429,17 @@ function startSelecting(list = false) {
 
   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;
+function onMousemove({ clientX, clientY }) {
+  if (!draggingState.dragging) return;
 
-  getElementBlocks(firstElement);
+  draggingState.xPos = clientX - mouseRelativePos.x;
+  draggingState.yPos = clientY - mouseRelativePos.y;
 }
 function attachListeners() {
-  window.addEventListener('click', onClick);
   window.addEventListener('mousemove', onMousemove);
 }
 function detachListeners() {
   window.removeEventListener('keyup', onKeyup);
-  window.removeEventListener('click', onClick);
   window.removeEventListener('mousemove', onMousemove);
 }
 

+ 8 - 4
src/content/services/recordWorkflow/addBlock.js

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

+ 21 - 5
src/content/services/recordWorkflow/index.js

@@ -1,19 +1,35 @@
 import browser from 'webextension-polyfill';
 import initElementSelector from './main';
 import initRecordEvents from './recordEvents';
+import selectorFrameContext from '../../elementSelector/selectorFrameContext';
 
 (async () => {
   try {
-    const element = document.querySelector('#automa-recording');
-    if (element) return;
+    let elementSelectorInstance = null;
+    const isMainFrame = window.self === window.top;
+    const destroyRecordEvents = await initRecordEvents(isMainFrame);
 
-    const destroyRecordEvents = await initRecordEvents();
-    const elementSelectorInstance = await initElementSelector();
+    if (isMainFrame) {
+      const element = document.querySelector('#automa-recording');
+      if (element) return;
+
+      elementSelectorInstance = await initElementSelector();
+    } else {
+      const style = document.createElement('style');
+      style.textContent = '[automa-el-list] {outline: 2px dashed #6366f1;}';
+
+      document.body.appendChild(style);
+
+      selectorFrameContext();
+    }
 
     browser.runtime.onMessage.addListener(function messageListener({ type }) {
       if (type === 'recording:stop') {
+        if (elementSelectorInstance) {
+          elementSelectorInstance.unmount();
+        }
+
         destroyRecordEvents();
-        elementSelectorInstance.unmount();
         browser.runtime.onMessage.removeListener(messageListener);
       }
     });

+ 83 - 20
src/content/services/recordWorkflow/recordEvents.js

@@ -3,7 +3,9 @@ import { nanoid } from 'nanoid';
 import browser from 'webextension-polyfill';
 import { debounce } from '@/utils/helper';
 import { recordPressedKey } from '@/utils/recordKeys';
-import addBlock from './addBlock';
+import addBlockToFlow from './addBlock';
+
+let isMainFrame = true;
 
 const isAutomaInstance = (target) =>
   target.id === 'automa-recording' ||
@@ -17,8 +19,34 @@ function findSelector(element) {
     attr: (name, value) => name === 'id' || (name.startsWith('aria') && value),
   });
 }
+async function addBlock(detail) {
+  try {
+    const data = await addBlockToFlow(detail, isMainFrame);
+
+    if (!isMainFrame || !data || !data.addedBlock) {
+      let frameSelector = null;
+
+      if (window.frameElement) {
+        frameSelector = finder(window.frameElement, {
+          root: window.frameElement.ownerDocument,
+        });
+      }
 
-function changeListener({ target }) {
+      window.top.postMessage(
+        {
+          frameSelector,
+          recording: data.recording,
+          type: 'automa:record-events',
+        },
+        '*'
+      );
+    }
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+function onChange({ target }) {
   if (isAutomaInstance(target)) return;
 
   const isInputEl = target.tagName === 'INPUT';
@@ -86,12 +114,14 @@ function changeListener({ target }) {
       block.data.type === 'text-field' &&
       block.data.selector === lastFlow?.data?.selector
     )
-      return;
+      return null;
 
     recording.flows.push(block);
+
+    return block;
   });
 }
-async function keyEventListener(event) {
+async function onKeydown(event) {
   if (isAutomaInstance(event.target) || event.repeat) return;
 
   const isTextField = textFieldEl(event.target);
@@ -149,10 +179,12 @@ async function keyEventListener(event) {
           event.target.form.submit();
         }, 500);
       }
+
+      return block;
     });
   });
 }
-function clickListener(event) {
+function onClick(event) {
   const { target } = event;
 
   if (isAutomaInstance(target)) return;
@@ -175,7 +207,7 @@ function clickListener(event) {
     if (openInNewTab) {
       event.preventDefault();
 
-      const description = (target.innerText || target.href).slice(0, 24);
+      const description = (target.innerText || target.href)?.slice(0, 24) || '';
 
       addBlock({
         id: 'link',
@@ -192,10 +224,8 @@ function clickListener(event) {
     }
   }
 
-  const elText = (target.innerText || target.ariaLabel || target.title).slice(
-    0,
-    24
-  );
+  const elText =
+    (target.innerText || target.ariaLabel || target.title)?.slice(0, 24) || '';
 
   addBlock({
     isClickLink,
@@ -209,7 +239,30 @@ function clickListener(event) {
   });
 }
 
-const scrollListener = debounce(({ target }) => {
+const onMessage = debounce(({ data, source }) => {
+  if (data.type !== 'automa:record-events') return;
+
+  let { frameSelector } = data;
+
+  if (!frameSelector) {
+    const frames = document.querySelectorAll('iframe, frame');
+
+    frames.forEach((frame) => {
+      if (frame.contentWindow !== source) return;
+
+      frameSelector = finder(frame);
+    });
+  }
+
+  const lastFlow = data.recording.flows.at(-1);
+  const lastIndex = data.recording.flows.length - 1;
+  data.recording.flows[
+    lastIndex
+  ].data.selector = `${frameSelector} |> ${lastFlow.data.selector}`;
+
+  browser.storage.local.set({ recording: data.recording });
+}, 100);
+const onScroll = debounce(({ target }) => {
   if (isAutomaInstance(target)) return;
 
   const isDocument = target === document;
@@ -241,20 +294,30 @@ const scrollListener = debounce(({ target }) => {
 }, 500);
 
 export function cleanUp() {
-  document.removeEventListener('click', clickListener, true);
-  document.removeEventListener('change', changeListener, true);
-  document.removeEventListener('scroll', scrollListener, true);
-  document.removeEventListener('keydown', keyEventListener, true);
+  if (isMainFrame) {
+    window.removeEventListener('message', onMessage);
+    document.removeEventListener('scroll', onScroll, true);
+  }
+
+  document.removeEventListener('click', onClick, true);
+  document.removeEventListener('change', onChange, true);
+  document.removeEventListener('keydown', onKeydown, true);
 }
 
-export default async function () {
+export default async function (mainFrame) {
   const { isRecording } = await browser.storage.local.get('isRecording');
 
+  isMainFrame = mainFrame;
+
   if (isRecording) {
-    document.addEventListener('click', clickListener, true);
-    document.addEventListener('scroll', scrollListener, true);
-    document.addEventListener('change', changeListener, true);
-    document.addEventListener('keydown', keyEventListener, true);
+    if (isMainFrame) {
+      window.addEventListener('message', onMessage);
+      document.addEventListener('scroll', onScroll, true);
+    }
+
+    document.addEventListener('click', onClick, true);
+    document.addEventListener('change', onChange, true);
+    document.addEventListener('keydown', onKeydown, true);
   }
 
   return cleanUp;

+ 0 - 0
src/content/executedBlock.js → src/content/showExecutedBlock.js


+ 68 - 5
src/content/utils.js

@@ -1,4 +1,67 @@
-export function automaRefDataStr(stateId) {
+export function getElementRect(target, withAttributes) {
+  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 (withAttributes) {
+    const attributes = {};
+
+    Array.from(target.attributes).forEach(({ name, value }) => {
+      if (name === 'automa-el-list') return;
+
+      attributes[name] = value;
+    });
+
+    result.attributes = attributes;
+    result.tagName = target.tagName;
+  }
+
+  return result;
+}
+
+export 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;
+}
+
+export function generateXPath(element, root = document.body) {
+  if (!element) return null;
+  if (element.id !== '') return `id("${element.id}")`;
+  if (element === root) return `//${element.tagName}`;
+
+  let ix = 0;
+  const siblings = element.parentNode.childNodes;
+
+  for (let index = 0; index < siblings.length; index += 1) {
+    const sibling = siblings[index];
+
+    if (sibling === element) {
+      return `${generateXPath(element.parentNode)}/${element.tagName}[${
+        ix + 1
+      }]`;
+    }
+
+    if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
+      ix += 1;
+    }
+  }
+
+  return null;
+}
+
+export function automaRefDataStr(varName) {
   return `
 function findData(obj, path) {
   const paths = path.split('.');
@@ -24,11 +87,11 @@ function findData(obj, path) {
   return result;
 }
 function automaRefData(keyword, path = '') {
-  const data = JSON.parse(sessionStorage.getItem('automa--${stateId}')) || null;
+  const data = ${varName}[keyword];
 
-  if (data === null) return null;
+  if (!data) return;
 
-  return findData(data[keyword], path);
+  return findData(data, path);
 }
   `;
 }
@@ -56,7 +119,7 @@ function messageTopFrame(windowCtx) {
     }, 5000);
 
     windowCtx.addEventListener('message', messageListener);
-    windowCtx.top.postMessage('automa:get-frame', '*');
+    windowCtx.top.postMessage({ type: 'automa:get-frame' }, '*');
   });
 }
 export async function getElementPosition(element) {

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

@@ -94,6 +94,7 @@
     }
   },
   "workflow": {
+    "my": "My workflows",
     "import": "Import workflow",
     "new": "New workflow",
     "delete": "Delete workflow",

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

@@ -22,7 +22,11 @@
     "workflow": {
       "new": "New workflow",
       "rename": "Rename workflow",
-      "delete": "Delete workflow"
+      "delete": "Delete workflow",
+      "type": {
+        "host": "Host",
+        "local": "Local",
+      }
     },
   }
 }

+ 79 - 2
src/newtab/App.vue

@@ -49,9 +49,38 @@
   <div v-else class="py-8 text-center">
     <ui-spinner color="text-accent" size="28" />
   </div>
+  <ui-card
+    v-if="modalState.show"
+    class="fixed bottom-8 right-8 shadow-2xl border-2 w-72 group"
+  >
+    <button
+      class="absolute bg-white shadow-md rounded-full -right-2 -top-2 transition scale-0 group-hover:scale-100"
+      @click="closeModal"
+    >
+      <v-remixicon class="text-gray-600" name="riCloseLine" />
+    </button>
+    <h2 class="font-semibold text-lg">
+      {{ activeModal.title }}
+    </h2>
+    <p class="mt-1 dark:text-gray-100 text-gray-700">
+      {{ activeModal.body }}
+    </p>
+    <div class="space-y-2 mt-4">
+      <ui-button
+        :href="activeModal.url"
+        tag="a"
+        target="_blank"
+        rel="noopener"
+        class="w-full block"
+        variant="accent"
+      >
+        {{ activeModal.button }}
+      </ui-button>
+    </div>
+  </ui-card>
 </template>
 <script setup>
-import { ref } from 'vue';
+import { ref, shallowReactive, computed } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
@@ -71,10 +100,30 @@ const theme = useTheme();
 theme.init();
 
 const retrieved = ref(false);
+const isUpdated = ref(false);
+const modalState = shallowReactive({
+  show: true,
+  type: 'survey',
+});
 
+const modalTypes = {
+  testimonial: {
+    title: 'Hi There 👋',
+    body: 'Thank you for using Automa, and if you have a great experience. Would you like to give us a testimonial?',
+    button: 'Give Testimonial',
+    url: 'https://testimonial.to/automa',
+  },
+  survey: {
+    title: "How do you think we're doing?",
+    body: 'To help us make Automa as best it can be, we need a few minutes of your time to get your feedback.',
+    button: 'Take Survey',
+    url: 'https://www.automa.site/survey',
+  },
+};
 const currentVersion = browser.runtime.getManifest().version;
 const prevVersion = localStorage.getItem('ext-version') || '0.0.0';
-const isUpdated = ref(false);
+
+const activeModal = computed(() => modalTypes[modalState.type]);
 
 async function syncHostWorkflow(hosts) {
   const hostIds = [];
@@ -213,6 +262,32 @@ function handleStorageChanged(change) {
     });
   }
 }
+function closeModal() {
+  let value = true;
+
+  if (modalState.type === 'survey') {
+    value = new Date().toString();
+  }
+
+  modalState.show = false;
+  localStorage.setItem(`has-${modalState.type}`, value);
+}
+function checkModal() {
+  const survey = localStorage.getItem('has-survey');
+
+  if (!survey) return;
+
+  const daysDiff = dayjs().diff(survey, 'day');
+  const showTestimonial =
+    daysDiff >= 2 && !localStorage.getItem('has-testimonial');
+
+  if (showTestimonial) {
+    modalState.show = true;
+    modalState.type = 'testimonial';
+  } else {
+    modalState.show = false;
+  }
+}
 
 browser.storage.onChanged.addListener(handleStorageChanged);
 
@@ -222,6 +297,8 @@ window.addEventListener('beforeunload', () => {
 
 (async () => {
   try {
+    checkModal();
+
     const { isFirstTime } = await browser.storage.local.get('isFirstTime');
     isUpdated.value = !isFirstTime && compare(currentVersion, prevVersion, '>');
 

+ 300 - 264
src/newtab/pages/Workflows.vue

@@ -1,277 +1,315 @@
 <template>
   <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold mb-6 capitalize">
+    <h1 class="text-2xl font-semibold mb-8 capitalize">
       {{ t('common.workflow', 2) }}
     </h1>
-    <div class="flex items-center space-x-4">
-      <ui-input
-        id="search-input"
-        v-model="state.query"
-        :placeholder="`${t(`common.search`)}... (${
-          shortcut['action:search'].readable
-        })`"
-        prepend-icon="riSearch2Line"
-        class="flex-1"
-      />
-      <div class="flex items-center workflow-sort">
-        <ui-button
-          icon
-          class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
-          @click="state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'"
-        >
-          <v-remixicon
-            :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
-          />
-        </ui-button>
-        <ui-select v-model="state.sortBy" :placeholder="t('sort.sortBy')">
-          <option v-for="sort in sorts" :key="sort" :value="sort">
-            {{ t(`sort.${sort}`) }}
-          </option>
-        </ui-select>
-      </div>
-      <span v-tooltip:bottom.group="t('workflow.browse')">
-        <ui-button
-          icon
-          tag="a"
-          href="https://automa.site/workflows"
-          target="_blank"
-          class="inline-block relative"
-          @click="browseWorkflow"
-        >
-          <span
-            v-if="state.highlightBrowse"
-            class="flex h-3 w-3 absolute top-0 right-0 -mr-1 -mt-1"
+    <div class="flex items-start">
+      <div class="w-60 sticky top-8">
+        <div class="flex w-full">
+          <ui-button
+            :title="shortcut['action:new'].readable"
+            variant="accent"
+            class="border-r rounded-r-none flex-1 font-semibold"
+            @click="newWorkflow"
           >
-            <span
-              class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
-            ></span>
-            <span
-              class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
-            ></span>
-          </span>
-          <v-remixicon name="riCompass3Line" />
-        </ui-button>
-      </span>
-      <span v-tooltip:bottom.group="t('workflow.backupCloud')">
-        <ui-button tag="router-link" to="/backup" class="inline-block" icon>
-          <v-remixicon name="riUploadCloud2Line" />
-        </ui-button>
-      </span>
-      <ui-button
-        v-tooltip:bottom.group="t('workflow.import')"
-        icon
-        @click="importWorkflow({ multiple: true })"
-      >
-        <v-remixicon name="riUploadLine" />
-      </ui-button>
-      <div class="flex">
-        <ui-button
-          :title="shortcut['action:new'].readable"
-          variant="accent"
-          class="border-r rounded-r-none"
-          @click="newWorkflow"
-        >
-          {{ t('workflow.new') }}
-        </ui-button>
-        <ui-popover>
-          <template #trigger>
-            <ui-button icon class="rounded-l-none" variant="accent">
-              <v-remixicon name="riArrowLeftSLine" rotate="-90" />
-            </ui-button>
-          </template>
-          <ui-list>
-            <ui-list-item
-              v-close-popover
-              class="cursor-pointer"
-              @click="addHostWorkflow"
-            >
-              {{ t('workflow.host.add') }}
-            </ui-list-item>
-          </ui-list>
-        </ui-popover>
-      </div>
-    </div>
-    <ui-tabs
-      v-model="state.activeTab"
-      class="mt-4 space-x-2"
-      type="fill"
-      style="display: inline-flex; background-color: transparent; padding: 0"
-    >
-      <ui-tab value="local">
-        {{ t('workflow.type.local') }}
-      </ui-tab>
-      <ui-tab v-if="store.state.user" value="shared">
-        {{ t('workflow.type.shared') }}
-      </ui-tab>
-      <ui-tab v-if="workflowHosts.length > 0" value="host">
-        {{ t('workflow.type.host') }}
-      </ui-tab>
-    </ui-tabs>
-    <ui-tab-panels v-model="state.activeTab" class="mt-6">
-      <ui-tab-panel value="shared">
-        <div class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
-          <shared-card
-            v-for="workflow in sharedWorkflows"
-            :key="workflow.id"
-            :data="workflow"
-            :show-details="false"
-            @click="$router.push(`/workflows/${$event.id}?shared=true`)"
-          />
+            {{ t('workflow.new') }}
+          </ui-button>
+          <ui-popover>
+            <template #trigger>
+              <ui-button icon class="rounded-l-none" variant="accent">
+                <v-remixicon name="riArrowLeftSLine" rotate="-90" />
+              </ui-button>
+            </template>
+            <ui-list class="space-y-1">
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="importWorkflow({ multiple: true })"
+              >
+                {{ t('workflow.import') }}
+              </ui-list-item>
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="addHostWorkflow"
+              >
+                {{ t('workflow.host.add') }}
+              </ui-list-item>
+            </ui-list>
+          </ui-popover>
         </div>
-      </ui-tab-panel>
-      <ui-tab-panel value="host">
-        <div class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
-          <shared-card
-            v-for="workflow in workflowHosts"
-            :key="workflow.hostId"
-            :data="workflow"
-            :menu="workflowHostMenu"
-            @click="$router.push(`/workflows/${$event.hostId}/host`)"
-            @menuSelected="deleteWorkflowHost(workflow)"
+        <ui-list class="mt-6 space-y-2">
+          <ui-list-item
+            tag="a"
+            href="https://www.automa.site/workflows"
+            target="_blank"
+          >
+            <v-remixicon name="riCompass3Line" />
+            <span class="ml-4 capitalize">
+              {{ t('workflow.browse') }}
+            </span>
+          </ui-list-item>
+          <ui-expand
+            :model-value="true"
+            append-icon
+            header-class="px-4 py-2 rounded-lg hoverable w-full flex items-center"
+          >
+            <template #header>
+              <v-remixicon name="riFlowChart" />
+              <span class="ml-4 capitalize flex-1 text-left">
+                {{ t('workflow.my') }}
+              </span>
+            </template>
+            <ui-list class="space-y-1 mt-1">
+              <ui-list-item
+                tag="button"
+                :active="state.activeTab === 'local'"
+                color="bg-box-transparent font-semibold"
+                class="pl-14"
+                @click="state.activeTab = 'local'"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.local') }}
+                </span>
+              </ui-list-item>
+              <ui-list-item
+                v-if="store.state.user"
+                :active="state.activeTab === 'shared'"
+                tag="button"
+                color="bg-box-transparent font-semibold"
+                class="pl-14"
+                @click="state.activeTab = 'shared'"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.shared') }}
+                </span>
+              </ui-list-item>
+              <ui-list-item
+                v-if="workflowHosts.length > 0"
+                :active="state.activeTab === 'host'"
+                color="bg-box-transparent font-semibold"
+                tag="button"
+                class="pl-14"
+                @click="state.activeTab = 'host'"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.host') }}
+                </span>
+              </ui-list-item>
+            </ui-list>
+          </ui-expand>
+        </ui-list>
+      </div>
+      <div class="flex-1 ml-8">
+        <div class="flex items-center">
+          <ui-input
+            id="search-input"
+            v-model="state.query"
+            :placeholder="`${t(`common.search`)}... (${
+              shortcut['action:search'].readable
+            })`"
+            prepend-icon="riSearch2Line"
           />
-        </div>
-      </ui-tab-panel>
-      <ui-tab-panel value="local">
-        <div v-if="Workflow.all().length === 0" class="py-12 flex items-center">
-          <img src="@/assets/svg/alien.svg" class="w-96" />
-          <div class="ml-4">
-            <h1 class="text-2xl font-semibold max-w-md mb-6">
-              {{ t('message.empty') }}
-            </h1>
-            <ui-button variant="accent" @click="newWorkflow">
-              {{ t('workflow.new') }}
+          <div class="flex-grow"></div>
+          <span v-tooltip:bottom.group="t('workflow.backupCloud')" class="mr-4">
+            <ui-button tag="router-link" to="/backup" class="inline-block" icon>
+              <v-remixicon name="riUploadCloud2Line" />
+            </ui-button>
+          </span>
+          <div class="flex items-center workflow-sort">
+            <ui-button
+              icon
+              class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
+              @click="
+                state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'
+              "
+            >
+              <v-remixicon
+                :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
+              />
             </ui-button>
+            <ui-select v-model="state.sortBy" :placeholder="t('sort.sortBy')">
+              <option v-for="sort in sorts" :key="sort" :value="sort">
+                {{ t(`sort.${sort}`) }}
+              </option>
+            </ui-select>
           </div>
         </div>
-        <template v-else>
-          <div class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
-            <shared-card
-              v-for="workflow in localWorkflows"
-              :key="workflow.id"
-              :data="workflow"
-              @click="$router.push(`/workflows/${$event.id}`)"
+        <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
+          <ui-tab-panel value="shared">
+            <div class="workflows-container">
+              <shared-card
+                v-for="workflow in sharedWorkflows"
+                :key="workflow.id"
+                :data="workflow"
+                :show-details="false"
+                @click="$router.push(`/workflows/${$event.id}?shared=true`)"
+              />
+            </div>
+          </ui-tab-panel>
+          <ui-tab-panel value="host">
+            <div class="workflows-container">
+              <shared-card
+                v-for="workflow in workflowHosts"
+                :key="workflow.hostId"
+                :data="workflow"
+                :menu="workflowHostMenu"
+                @click="$router.push(`/workflows/${$event.hostId}/host`)"
+                @menuSelected="deleteWorkflowHost(workflow)"
+              />
+            </div>
+          </ui-tab-panel>
+          <ui-tab-panel value="local">
+            <div
+              v-if="Workflow.all().length === 0"
+              class="py-12 flex items-center"
             >
-              <template #header>
-                <div class="flex items-center mb-4">
-                  <template v-if="!workflow.isDisabled">
-                    <ui-img
-                      v-if="workflow.icon.startsWith('http')"
-                      :src="workflow.icon"
-                      class="rounded-lg overflow-hidden"
-                      style="height: 40px; width: 40px"
-                      alt="Can not display"
+              <img src="@/assets/svg/alien.svg" class="w-96" />
+              <div class="ml-4">
+                <h1 class="text-2xl font-semibold max-w-md mb-6">
+                  {{ t('message.empty') }}
+                </h1>
+                <ui-button variant="accent" @click="newWorkflow">
+                  {{ t('workflow.new') }}
+                </ui-button>
+              </div>
+            </div>
+            <template v-else>
+              <div class="workflows-container">
+                <shared-card
+                  v-for="workflow in localWorkflows"
+                  :key="workflow.id"
+                  :data="workflow"
+                  @click="$router.push(`/workflows/${$event.id}`)"
+                >
+                  <template #header>
+                    <div class="flex items-center mb-4">
+                      <template v-if="!workflow.isDisabled">
+                        <ui-img
+                          v-if="workflow.icon.startsWith('http')"
+                          :src="workflow.icon"
+                          class="rounded-lg overflow-hidden"
+                          style="height: 40px; width: 40px"
+                          alt="Can not display"
+                        />
+                        <span v-else class="p-2 rounded-lg bg-box-transparent">
+                          <v-remixicon :name="workflow.icon" />
+                        </span>
+                      </template>
+                      <p v-else class="py-2">{{ t('common.disabled') }}</p>
+                      <div class="flex-grow"></div>
+                      <button
+                        v-if="!workflow.isDisabled"
+                        class="invisible group-hover:visible"
+                        @click="executeWorkflow(workflow)"
+                      >
+                        <v-remixicon name="riPlayLine" />
+                      </button>
+                      <v-remixicon
+                        v-if="workflow.isProtected"
+                        name="riShieldKeyholeLine"
+                        class="text-green-600 dark:text-green-400 ml-2"
+                      />
+                      <ui-popover v-if="!workflow.isProtected" class="h-6 ml-2">
+                        <template #trigger>
+                          <button>
+                            <v-remixicon name="riMoreLine" />
+                          </button>
+                        </template>
+                        <ui-list class="space-y-1" style="min-width: 150px">
+                          <ui-list-item
+                            class="cursor-pointer"
+                            @click="
+                              updateWorkflow(workflow.id, {
+                                isDisabled: !workflow.isDisabled,
+                              })
+                            "
+                          >
+                            <v-remixicon
+                              name="riToggleLine"
+                              class="mr-2 -ml-1"
+                            />
+                            <span class="capitalize">
+                              {{
+                                t(
+                                  `common.${
+                                    workflow.isDisabled ? 'enable' : 'disable'
+                                  }`
+                                )
+                              }}
+                            </span>
+                          </ui-list-item>
+                          <ui-list-item
+                            v-for="item in menu"
+                            :key="item.id"
+                            v-close-popover
+                            class="cursor-pointer"
+                            @click="menuHandlers[item.id](workflow)"
+                          >
+                            <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+                            <span class="capitalize">{{ item.name }}</span>
+                          </ui-list-item>
+                        </ui-list>
+                      </ui-popover>
+                    </div>
+                  </template>
+                  <template #footer-content>
+                    <v-remixicon
+                      v-if="sharedWorkflows[workflow.id]"
+                      v-tooltip:bottom.group="
+                        t('workflow.share.sharedAs', {
+                          name: sharedWorkflows[workflow.id]?.name.slice(0, 64),
+                        })
+                      "
+                      name="riShareLine"
+                      size="20"
+                      class="ml-2"
+                    />
+                    <v-remixicon
+                      v-if="hostWorkflows[workflow.id]"
+                      v-tooltip:bottom.group="t('workflow.host.title')"
+                      name="riBaseStationLine"
+                      size="20"
+                      class="ml-2"
                     />
-                    <span v-else class="p-2 rounded-lg bg-box-transparent">
-                      <v-remixicon :name="workflow.icon" />
-                    </span>
                   </template>
-                  <p v-else class="py-2">{{ t('common.disabled') }}</p>
-                  <div class="flex-grow"></div>
-                  <button
-                    v-if="!workflow.isDisabled"
-                    class="invisible group-hover:visible"
-                    @click="executeWorkflow(workflow)"
+                </shared-card>
+              </div>
+              <div
+                v-if="workflows.length > 18"
+                class="flex items-center justify-between mt-8"
+              >
+                <div>
+                  {{ t('components.pagination.text1') }}
+                  <select
+                    v-model="pagination.perPage"
+                    class="p-1 rounded-md bg-input"
                   >
-                    <v-remixicon name="riPlayLine" />
-                  </button>
-                  <v-remixicon
-                    v-if="workflow.isProtected"
-                    name="riShieldKeyholeLine"
-                    class="text-green-600 dark:text-green-400 ml-2"
-                  />
-                  <ui-popover v-if="!workflow.isProtected" class="h-6 ml-2">
-                    <template #trigger>
-                      <button>
-                        <v-remixicon name="riMoreLine" />
-                      </button>
-                    </template>
-                    <ui-list class="space-y-1" style="min-width: 150px">
-                      <ui-list-item
-                        class="cursor-pointer"
-                        @click="
-                          updateWorkflow(workflow.id, {
-                            isDisabled: !workflow.isDisabled,
-                          })
-                        "
-                      >
-                        <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
-                        <span class="capitalize">
-                          {{
-                            t(
-                              `common.${
-                                workflow.isDisabled ? 'enable' : 'disable'
-                              }`
-                            )
-                          }}
-                        </span>
-                      </ui-list-item>
-                      <ui-list-item
-                        v-for="item in menu"
-                        :key="item.id"
-                        v-close-popover
-                        class="cursor-pointer"
-                        @click="menuHandlers[item.id](workflow)"
-                      >
-                        <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-                        <span class="capitalize">{{ item.name }}</span>
-                      </ui-list-item>
-                    </ui-list>
-                  </ui-popover>
-                </div>
-              </template>
-              <template #footer-content>
-                <v-remixicon
-                  v-if="sharedWorkflows[workflow.id]"
-                  v-tooltip:bottom.group="
-                    t('workflow.share.sharedAs', {
-                      name: sharedWorkflows[workflow.id]?.name.slice(0, 64),
+                    <option
+                      v-for="num in [18, 32, 64, 128]"
+                      :key="num"
+                      :value="num"
+                    >
+                      {{ num }}
+                    </option>
+                  </select>
+                  {{
+                    t('components.pagination.text2', {
+                      count: workflows.length,
                     })
-                  "
-                  name="riShareLine"
-                  size="20"
-                  class="ml-2"
-                />
-                <v-remixicon
-                  v-if="hostWorkflows[workflow.id]"
-                  v-tooltip:bottom.group="t('workflow.host.title')"
-                  name="riBaseStationLine"
-                  size="20"
-                  class="ml-2"
+                  }}
+                </div>
+                <ui-pagination
+                  v-model="pagination.currentPage"
+                  :per-page="pagination.perPage"
+                  :records="workflows.length"
                 />
-              </template>
-            </shared-card>
-          </div>
-          <div
-            v-if="workflows.length > 16"
-            class="flex items-center justify-between mt-8"
-          >
-            <div>
-              {{ t('components.pagination.text1') }}
-              <select
-                v-model="pagination.perPage"
-                class="p-1 rounded-md bg-input"
-              >
-                <option
-                  v-for="num in [16, 32, 64, 128]"
-                  :key="num"
-                  :value="num"
-                >
-                  {{ num }}
-                </option>
-              </select>
-              {{
-                t('components.pagination.text2', { count: workflows.length })
-              }}
-            </div>
-            <ui-pagination
-              v-model="pagination.currentPage"
-              :per-page="pagination.perPage"
-              :records="workflows.length"
-            />
-          </div>
-        </template>
-      </ui-tab-panel>
-    </ui-tab-panels>
+              </div>
+            </template>
+          </ui-tab-panel>
+        </ui-tab-panels>
+      </div>
+    </div>
     <ui-modal v-model="workflowModal.show" title="Workflow">
       <ui-input
         v-model="workflowModal.name"
@@ -348,7 +386,6 @@ const state = shallowReactive({
   activeTab: 'local',
   sortBy: savedSorts.sortBy || 'createdAt',
   sortOrder: savedSorts.sortOrder || 'desc',
-  highlightBrowse: !localStorage.getItem('first-time-browse'),
 });
 const workflowModal = shallowReactive({
   name: '',
@@ -357,7 +394,7 @@ const workflowModal = shallowReactive({
 });
 const pagination = shallowReactive({
   currentPage: 1,
-  perPage: savedSorts.perPage || 16,
+  perPage: savedSorts.perPage || 18,
 });
 
 const hostWorkflows = computed(() => store.state.hostWorkflows || {});
@@ -485,10 +522,6 @@ function addHostWorkflow() {
     },
   });
 }
-function browseWorkflow() {
-  state.highlightBrowse = false;
-  localStorage.setItem('first-time-browse', false);
-}
 function executeWorkflow(workflow) {
   sendMessage('workflow:execute', workflow, 'background');
 }
@@ -594,4 +627,7 @@ watch(
 .workflow-sort select {
   @apply rounded-l-none !important;
 }
+.workflows-container {
+  @apply grid gap-4 grid-cols-3 2xl:grid-cols-4;
+}
 </style>

+ 8 - 1
src/newtab/pages/settings/SettingsIndex.vue

@@ -81,6 +81,7 @@
 import { computed, ref } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
+import cloneDeep from 'lodash.clonedeep';
 import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
 import { supportLocales } from '@/utils/shared';
@@ -100,7 +101,13 @@ function updateSetting(path, value) {
     path: `settings.${path}`,
   });
 
-  browser.storage.local.set({ settings: settings.value });
+  let userSettings = settings.value;
+
+  if (BROWSER_TYPE === 'firefox') {
+    userSettings = cloneDeep(userSettings);
+  }
+
+  browser.storage.local.set({ settings: userSettings });
 }
 function updateLanguage(value) {
   isLangChange.value = true;

+ 58 - 6
src/popup/pages/Home.vue

@@ -1,6 +1,12 @@
 <template>
-  <div class="bg-accent rounded-b-2xl absolute top-0 left-0 h-48 w-full"></div>
-  <div class="dark placeholder-black relative z-10 text-white px-5 pt-8 mb-6">
+  <div
+    :class="[workflowHostKeys.length === 0 ? 'h-48' : 'h-56']"
+    class="bg-accent rounded-b-2xl absolute top-0 left-0 w-full"
+  ></div>
+  <div
+    :class="[workflowHostKeys.length === 0 ? 'mb-6' : 'mb-2']"
+    class="dark placeholder-black relative z-10 text-white px-5 pt-8"
+  >
     <div class="flex items-center mb-4">
       <h1 class="text-xl font-semibold text-white">Automa</h1>
       <div class="flex-grow"></div>
@@ -39,6 +45,16 @@
         class="w-full search-input"
       />
     </div>
+    <ui-tabs
+      v-if="workflowHostKeys.length > 0"
+      v-model="state.activeTab"
+      fill
+      class="mt-1"
+    >
+      <ui-tab v-for="type in workflowTypes" :key="type" :value="type">
+        {{ t(`home.workflow.type.${type}`) }}
+      </ui-tab>
+    </ui-tabs>
   </div>
   <div class="px-5 pb-5 space-y-2">
     <ui-card v-if="Workflow.all().length === 0" class="text-center">
@@ -56,6 +72,7 @@
       v-for="workflow in workflows"
       :key="workflow.id"
       :workflow="workflow"
+      :tab="state.activeTab"
       @details="openDashboard(`/workflows/${$event.id}`)"
       @update="updateWorkflow(workflow.id, $event)"
       @execute="executeWorkflow"
@@ -91,6 +108,7 @@
 <script setup>
 import { computed, onMounted, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useStore } from 'vuex';
 import browser from 'webextension-polyfill';
 import { useDialog } from '@/composable/dialog';
 import { useGroupTooltip } from '@/composable/groupTooltip';
@@ -99,12 +117,14 @@ import Workflow from '@/models/workflow';
 import HomeWorkflowCard from '@/components/popup/home/HomeWorkflowCard.vue';
 import HomeStartRecording from '@/components/popup/home/HomeStartRecording.vue';
 
+const workflowTypes = ['local', 'host'];
 const recordingCardHeight = {
   new: 255,
   existing: 480,
 };
 
 const { t } = useI18n();
+const store = useStore();
 const dialog = useDialog();
 
 useGroupTooltip();
@@ -113,16 +133,37 @@ const state = shallowReactive({
   query: '',
   cardHeight: 255,
   haveAccess: true,
+  activeTab: 'local',
   newRecordingModal: false,
 });
 
-const workflows = computed(() =>
-  Workflow.query()
+const workflowHostKeys = computed(() => Object.keys(store.state.workflowHosts));
+const workflowHosts = computed(() => {
+  if (state.activeTab !== 'host') return [];
+
+  return workflowHostKeys.value.reduce((acc, key) => {
+    const workflow = store.state.workflowHosts[key];
+    const isMatch = workflow.name
+      .toLocaleLowerCase()
+      .includes(state.query.toLocaleLowerCase());
+
+    if (isMatch) acc.push({ ...workflow, id: key });
+
+    return acc;
+  }, []);
+});
+const localWorkflows = computed(() => {
+  if (state.activeTab !== 'local') return [];
+
+  return Workflow.query()
     .where(({ name }) =>
       name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
     )
     .orderBy('createdAt', 'desc')
-    .get()
+    .get();
+});
+const workflows = computed(() =>
+  state.activeTab === 'local' ? localWorkflows.value : workflowHosts.value
 );
 
 function executeWorkflow(workflow) {
@@ -151,7 +192,17 @@ function deleteWorkflow({ id, name }) {
     okVariant: 'danger',
     body: t('message.delete', { name }),
     onConfirm: () => {
-      Workflow.delete(id);
+      if (state.activeTab === 'local') {
+        Workflow.delete(id);
+      } else {
+        store.commit('deleteStateNested', `workflowHosts.${id}`);
+
+        if (workflowHostKeys.value.length === 0) {
+          state.activeTab = 'local';
+        }
+
+        browser.storage.local.set({ workflowHosts: store.state.workflowHosts });
+      }
     },
   });
 }
@@ -213,6 +264,7 @@ async function recordWorkflow(options = {}) {
     for (const tab of tabs) {
       if (tab.url.startsWith('http')) {
         await browser.tabs.executeScript(tab.id, {
+          allFrames: true,
           file: 'recordWorkflow.bundle.js',
         });
       }

+ 16 - 0
src/utils/handleFormElement.js

@@ -1,6 +1,16 @@
 import { keyDefinitions } from '@/utils/USKeyboardLayout';
 import simulateEvent from './simulateEvent';
 
+const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+  window.HTMLInputElement.prototype,
+  'value'
+).set;
+function reactJsEvent(element, value) {
+  if (!element._valueTracker) return;
+
+  nativeInputValueSetter.call(element, value);
+}
+
 function formEvent(element, data) {
   if (data.type === 'text-field') {
     const currentKey = /\s/.test(data.value) ? 'Space' : data.value;
@@ -47,6 +57,9 @@ async function inputText({ data, element, isEditable }) {
       const currentChar = data.value[index];
 
       element[elementKey] += currentChar;
+
+      if (elementKey === 'value') reactJsEvent(element, element.value);
+
       formEvent(element, {
         type: 'text-field',
         value: currentChar,
@@ -57,6 +70,9 @@ async function inputText({ data, element, isEditable }) {
     }
   } else {
     element[elementKey] += data.value;
+
+    if (elementKey === 'value') reactJsEvent(element, element.value);
+
     formEvent(element, {
       isEditable,
       type: 'text-field',

+ 6 - 0
src/utils/helper.js

@@ -1,5 +1,11 @@
 import browser from 'webextension-polyfill';
 
+export function isXPath(str) {
+  const regex = /^[(/@]/;
+
+  return regex.test(str);
+}
+
 export function visibleInViewport(element) {
   const { top, left, bottom, right, height, width } =
     element.getBoundingClientRect();

+ 1 - 0
src/utils/shared.js

@@ -592,6 +592,7 @@ export const tasks = {
     refDataKeys: ['selector'],
     data: {
       disableBlock: false,
+      description: '',
       findBy: 'cssSelector',
       selector: '',
       tryCount: 1,

+ 2 - 10
src/utils/webhookUtil.js

@@ -1,4 +1,4 @@
-import { isObject, parseJSON, isWhitespace } from './helper';
+import { parseJSON, isWhitespace } from './helper';
 
 const renderContent = (content, contentType) => {
   const renderedJson = parseJSON(content, new Error('invalid-body'));
@@ -6,15 +6,7 @@ const renderContent = (content, contentType) => {
   if (renderedJson instanceof Error) throw renderedJson;
 
   if (contentType === 'application/x-www-form-urlencoded') {
-    return Object.keys(renderedJson)
-      .map((key) => {
-        const value = isObject(renderedJson[key])
-          ? JSON.stringify(renderedJson[key])
-          : renderedJson[key];
-
-        return `${key}=${value}`;
-      })
-      .join('&');
+    return new URLSearchParams(renderedJson);
   }
 
   return JSON.stringify(renderedJson);

+ 2 - 9
src/utils/workflowTrigger.js

@@ -149,15 +149,8 @@ export async function registerKeyboardShortcut(workflowId, data) {
   }
 }
 
-export async function registerOnStartup(workflowId) {
-  const { onStartupTriggers } = await browser.storage.local.get(
-    'onStartupTriggers'
-  );
-  const startupTriggers = onStartupTriggers || [];
-
-  startupTriggers.push(workflowId);
-
-  await browser.storage.local.set({ onStartupTriggers: startupTriggers });
+export async function registerOnStartup() {
+  // Do nothing
 }
 
 export async function registerWorkflowTrigger(workflowId, { data }) {