Browse Source

feat: add element selector when record workflow

Ahmad Kholid 3 years ago
parent
commit
7957883677
30 changed files with 1338 additions and 531 deletions
  1. 1 1
      src/assets/css/tailwind.css
  2. 3 2
      src/background/index.js
  3. 5 5
      src/components/content/selector/SelectorBlocks.vue
  4. 0 0
      src/components/content/selector/SelectorElementList.vue
  5. 7 7
      src/components/content/selector/SelectorElementsDetail.vue
  6. 0 0
      src/components/content/selector/SelectorQuery.vue
  7. 95 0
      src/components/content/shared/SharedElementHighlighter.vue
  8. 56 44
      src/components/popup/home/HomeSelectBlock.vue
  9. 106 0
      src/components/popup/home/HomeStartRecording.vue
  10. 63 0
      src/components/transitions/TransitionSlide.vue
  11. 68 86
      src/content/elementSelector/App.vue
  12. 0 46
      src/content/elementSelector/AppElementHighlighter.vue
  13. 0 4
      src/content/elementSelector/compsUi.js
  14. 0 98
      src/content/elementSelector/icons.js
  15. 2 33
      src/content/elementSelector/index.js
  16. 0 3
      src/content/elementSelector/workflow/WorkflowAddBlock.vue
  17. 0 69
      src/content/elementSelector/workflow/WorkflowList.vue
  18. 40 0
      src/content/injectAppStyles.js
  19. 585 0
      src/content/services/recordWorkflow/App.vue
  20. 15 0
      src/content/services/recordWorkflow/addBlock.js
  21. 8 0
      src/content/services/recordWorkflow/icons.js
  22. 23 0
      src/content/services/recordWorkflow/index.js
  23. 34 0
      src/content/services/recordWorkflow/main.js
  24. 79 62
      src/content/services/recordWorkflow/recordEvents.js
  25. 7 1
      src/locales/en/popup.json
  26. 86 51
      src/popup/pages/Home.vue
  27. 40 15
      src/popup/pages/Recording.vue
  28. 0 3
      src/popup/pages/workflow/Edit.vue
  29. 13 0
      src/utils/shared.js
  30. 2 1
      webpack.config.js

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

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

+ 3 - 2
src/background/index.js

@@ -196,7 +196,8 @@ browser.webNavigation.onCommitted.addListener(
 
       const lastFlow = recording.flows[recording.flows.length - 1];
       const isClickSubmit =
-        lastFlow.id === 'event-click' && transitionType === 'form_submit';
+        lastFlow.id === 'event-click' &&
+        (transitionType === 'form_submit' || lastFlow.isClickLink);
 
       if (isClickSubmit) return;
 
@@ -235,11 +236,11 @@ browser.tabs.onActivated.addListener(async ({ tabId }) => {
     recording.activeTab = { id, url };
     recording.flows.push({
       id: 'switch-tab',
+      description: title,
       data: {
         url,
         matchPattern: url,
         createIfNoMatch: true,
-        description: title || url,
       },
     });
   });

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

@@ -39,11 +39,11 @@ import { tasks } from '@/utils/shared';
 import EditForms from '@/components/newtab/workflow/edit/EditForms.vue';
 import EditTriggerEvent from '@/components/newtab/workflow/edit/EditTriggerEvent.vue';
 import EditScrollElement from '@/components/newtab/workflow/edit/EditScrollElement.vue';
-import handleForms from '../blocksHandler/handlerForms';
-import handleGetText from '../blocksHandler/handlerGetText';
-import handleEventClick from '../blocksHandler/handlerEventClick';
-import handelTriggerEvent from '../blocksHandler/handlerTriggerEvent';
-import handleElementScroll from '../blocksHandler/handlerElementScroll';
+import handleForms from '@/content/blocksHandler/handlerForms';
+import handleGetText from '@/content/blocksHandler/handlerGetText';
+import handleEventClick from '@/content/blocksHandler/handlerEventClick';
+import handelTriggerEvent from '@/content/blocksHandler/handlerTriggerEvent';
+import handleElementScroll from '@/content/blocksHandler/handlerElementScroll';
 
 const props = defineProps({
   selector: {

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


+ 7 - 7
src/content/elementSelector/AppElementsDetail.vue → src/components/content/selector/SelectorElementsDetail.vue

@@ -15,7 +15,7 @@
     style="max-height: calc(100vh - 17rem)"
   >
     <ui-tab-panel value="attributes">
-      <app-element-list
+      <selector-element-list
         :elements="selectedElements"
         @highlight="$emit('highlight', $event)"
       >
@@ -39,10 +39,10 @@
             />
           </div>
         </template>
-      </app-element-list>
+      </selector-element-list>
     </ui-tab-panel>
     <ui-tab-panel value="options">
-      <app-element-list
+      <selector-element-list
         :elements="selectElements"
         element-name="Select element options"
         @highlight="
@@ -73,10 +73,10 @@
             />
           </div>
         </template>
-      </app-element-list>
+      </selector-element-list>
     </ui-tab-panel>
     <ui-tab-panel value="blocks">
-      <app-blocks
+      <selector-blocks
         :elements="selectedElements"
         :selector="elSelector"
         @execute="$emit('execute', $event)"
@@ -86,8 +86,8 @@
   </ui-tab-panels>
 </template>
 <script setup>
-import AppBlocks from './AppBlocks.vue';
-import AppElementList from './AppElementList.vue';
+import SelectorBlocks from './SelectorBlocks.vue';
+import SelectorElementList from './SelectorElementList.vue';
 
 defineProps({
   activeTab: {

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


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

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

+ 56 - 44
src/content/elementSelector/workflow/WorkflowEditor.vue → src/components/popup/home/HomeSelectBlock.vue

@@ -1,64 +1,63 @@
 <template>
-  <div class="flex items-center p-2 rounded-lg bg-accent">
-    <p class="flex-1">Connect a workflow</p>
-  </div>
-  <workflow-list
-    v-if="!activeWorkflow"
-    :workflows="state.workflows"
-    @select="state.activeWorkflow = $event"
-  />
-  <div v-else class="mt-4 px-4 pb-4">
-    <div class="flex items-center">
-      <button class="group" @click="state.activeWorkflow = ''">
+  <div class="px-4 pb-4">
+    <div class="flex items-center mt-4">
+      <button @click="$emit('goBack')">
         <v-remixicon
           name="riArrowLeftLine"
-          class="group-hover:-translate-x-1 -ml-1 transition-transform align-bottom inline-block"
+          class="-ml-1 mr-1 align-bottom inline-block"
         />
       </button>
-      <p class="flex-1 text-overflow font-semibold ml-1">
-        {{ activeWorkflow.name }}
+      <p class="font-semibold flex-1 text-overflow">
+        {{ workflow.name }}
       </p>
     </div>
-    <p class="mt-2">Select a block output to start</p>
+    <p class="mt-2">
+      {{ t('home.record.selectBlock') }}
+    </p>
     <div
       ref="editorContainer"
-      class="parent-drawflow h-40 min-h w-full rounded-lg bg-box-transparent"
+      class="parent-drawflow h-56 min-h w-full rounded-lg bg-box-transparent"
     ></div>
-    <workflow-add-block v-if="activeBlock" />
+    <ui-button
+      :disabled="!state.activeBlock"
+      variant="accent"
+      class="mt-6 w-full"
+      @click="startRecording"
+    >
+      {{ t('home.record.button') }}
+    </ui-button>
   </div>
 </template>
 <script setup>
 import {
   shallowReactive,
-  computed,
-  watch,
   ref,
   getCurrentInstance,
   shallowRef,
-  inject,
+  onMounted,
 } from 'vue';
-import browser from 'webextension-polyfill';
+import { useI18n } from 'vue-i18n';
 import { findTriggerBlock } from '@/utils/helper';
 import drawflow from '@/lib/drawflow';
-import WorkflowList from './WorkflowList.vue';
-import WorkflowAddBlock from './WorkflowAddBlock.vue';
 
-const rootElement = inject('rootElement');
+const props = defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['goBack', 'record']);
+
+const { t } = useI18n();
 const context = getCurrentInstance().appContext.app._context;
 
 const editor = shallowRef(null);
 const editorContainer = ref(null);
-const activeBlock = shallowRef(null);
 const state = shallowReactive({
-  workflows: [],
-  activeWorkflow: '',
+  activeBlock: null,
   blockOutput: 'output_1',
 });
 
-const activeWorkflow = computed(() =>
-  state.workflows.find(({ id }) => id === state.activeWorkflow)
-);
-
 function onEditorClick(event) {
   const [target] = event.composedPath();
   const nodeEl = target.closest('.drawflow-node');
@@ -76,6 +75,15 @@ function onEditorClick(event) {
 
     const nodeId = nodeEl.id.slice(5);
     const node = editor.value.getNodeFromId(nodeId);
+    const outputs = Object.keys(node.outputs);
+
+    if (outputs.length === 0) {
+      alert(t('home.record.anotherBlock'));
+      state.activeBlock = null;
+      state.blockOutput = null;
+      return;
+    }
+
     let outputEl = target.closest('.output');
 
     if (outputEl) {
@@ -83,7 +91,7 @@ function onEditorClick(event) {
       state.blockOutput = outputEl.classList[1];
       outputEl.classList.add('active');
     } else {
-      const firstOutput = Object.keys(node.outputs)[0];
+      const firstOutput = outputs[0];
 
       state.blockOutput = firstOutput || '';
       outputEl = nodeEl.querySelector(`.${firstOutput}`);
@@ -92,18 +100,28 @@ function onEditorClick(event) {
     if (outputEl) outputEl.classList.add('active');
 
     nodeEl.classList.add('selected');
-    activeBlock.value = node;
+    state.activeBlock = node;
   }
 }
+function startRecording() {
+  const options = {
+    name: props.workflow.name,
+    workflowId: props.workflow.id,
+    connectFrom: {
+      id: state.activeBlock.id,
+      output: state.blockOutput,
+    },
+  };
 
-watch(editorContainer, (element) => {
-  if (!activeWorkflow.value) return;
+  emit('record', options);
+}
 
-  const flowData = activeWorkflow.value.drawflow;
+onMounted(() => {
+  const flowData = props.workflow.drawflow;
   const flow = typeof flowData === 'string' ? JSON.parse(flowData) : flowData;
   const triggerBlock = findTriggerBlock(flow);
 
-  const editorInstance = drawflow(element, {
+  const editorInstance = drawflow(editorContainer.value, {
     context,
     options: {
       zoom: 0.5,
@@ -111,7 +129,6 @@ watch(editorContainer, (element) => {
       zoom_max: 0.8,
       minimap: true,
       editor_mode: 'fixed',
-      rootElement: rootElement.shadowRoot,
     },
   });
 
@@ -134,13 +151,8 @@ watch(editorContainer, (element) => {
   }
 
   editor.value = editorInstance;
-  element.addEventListener('click', onEditorClick);
+  editorContainer.value.addEventListener('click', onEditorClick);
 });
-
-(async () => {
-  const { workflows } = await browser.storage.local.get('workflows');
-  state.workflows = (workflows || []).reverse();
-})();
 </script>
 <style>
 .output.active {

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

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

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

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

+ 68 - 86
src/content/elementSelector/App.vue

@@ -5,15 +5,16 @@
       'bg-black bg-opacity-30': !state.hide,
     }"
     class="root fixed h-full w-full pointer-events-none top-0 text-black left-0"
+    style="z-index: 99999999"
   >
     <div
       ref="cardEl"
       :style="{ transform: `translate(${cardRect.x}px, ${cardRect.y}px)` }"
-      style="width: 320px; min-height: 175px"
-      class="absolute root-card bg-white shadow-xl z-50 pointer-events-auto rounded-lg"
+      style="width: 320px"
+      class="relative root-card bg-white shadow-xl z-50 pointer-events-auto rounded-lg"
     >
       <div
-        class="absolute p-2 drag-button shadow-xl bg-white p-1 cursor-move rounded-lg"
+        class="absolute p-2 drag-button z-50 shadow-xl bg-white p-1 cursor-move rounded-lg"
         style="top: -15px; left: -15px"
       >
         <v-remixicon
@@ -34,81 +35,79 @@
         <p class="text-lg font-semibold">Automa</p>
         <div class="flex-grow"></div>
         <button
-          class="mr-1 hoverable p-1 rounded-md transition"
-          size="20"
+          class="mr-2 hoverable p-1 rounded-md transition"
           @click="state.hide = !state.hide"
         >
           <v-remixicon :name="state.hide ? 'riEyeOffLine' : 'riEyeLine'" />
         </button>
-        <button
-          class="hoverable p-1 rounded-md transition"
-          size="20"
-          @click="destroy"
-        >
+        <button class="hoverable p-1 rounded-md transition" @click="destroy">
           <v-remixicon name="riCloseLine" />
         </button>
       </div>
-      <ui-tab-panels :model-value="mainActiveTab">
-        <ui-tab-panel value="selector" class="p-4">
-          <app-selector
-            v-model:selectorType="state.selectorType"
-            v-model:selectList="state.selectList"
-            :selector="state.elSelector"
-            :selected-count="state.selectedElements.length"
-            @child="selectChildElement"
-            @parent="selectParentElement"
-            @change="updateSelectedElements"
-          />
-          <app-elements-detail
-            v-if="!state.hide && state.selectedElements.length > 0"
-            v-model:active-tab="state.activeTab"
-            v-bind="{
-              elSelector: state.elSelector,
-              selectElements: state.selectElements,
-              selectedElements: state.selectedElements,
-            }"
-            @highlight="toggleHighlightElement"
-            @execute="state.isExecuting = $event"
-          />
-        </ui-tab-panel>
-      </ui-tab-panels>
+      <div class="p-4">
+        <selector-query
+          v-model:selectorType="state.selectorType"
+          v-model:selectList="state.selectList"
+          :selector="state.elSelector"
+          :selected-count="state.selectedElements.length"
+          @child="selectChildElement"
+          @parent="selectParentElement"
+          @change="updateSelectedElements"
+        />
+        <selector-elements-detail
+          v-if="!state.hide && state.selectedElements.length > 0"
+          v-model:active-tab="state.activeTab"
+          v-bind="{
+            elSelector: state.elSelector,
+            selectElements: state.selectElements,
+            selectedElements: state.selectedElements,
+          }"
+          @highlight="toggleHighlightElement"
+          @execute="state.isExecuting = $event"
+        />
+      </div>
     </div>
-    <svg
+    <shared-element-highlighter
       v-if="!state.hide"
-      class="h-full w-full absolute top-0 pointer-events-none left-0 z-10"
-    >
-      <app-element-highlighter
-        :items="state.hoveredElements"
-        stroke="#fbbf24"
-        fill="rgba(251, 191, 36, 0.1)"
-      />
-      <app-element-highlighter
-        :items="state.selectedElements"
-        stroke="#2563EB"
-        active-stroke="#f87171"
-        fill="rgba(37, 99, 235, 0.1)"
-        active-fill="rgba(248, 113, 113, 0.1)"
-      />
-    </svg>
+      :disabled="state.hide"
+      :data="elementsHighlightData"
+      :items="{
+        hoveredElements: state.hoveredElements,
+        selectedElements: state.selectedElements,
+      }"
+      @update="state[$event.key] = $event.items"
+    />
   </div>
+  <teleport to="body">
+    <div
+      v-if="!state.hide"
+      style="
+        z-index: 9999999;
+        position: fixed;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+      "
+    ></div>
+  </teleport>
 </template>
 <script setup>
 import { reactive, ref, watch, inject, onMounted, onBeforeUnmount } from 'vue';
 import { getCssSelector } from 'css-selector-generator';
-import { debounce } from '@/utils/helper';
 import { finder } from '@medv/finder';
+import { elementsHighlightData } from '@/utils/shared';
 import findElement from '@/utils/FindElement';
-import AppSelector from './AppSelector.vue';
-import AppElementsDetail from './AppElementsDetail.vue';
-import AppElementHighlighter from './AppElementHighlighter.vue';
+import SelectorQuery from '@/components/content/selector/SelectorQuery.vue';
+import SelectorElementsDetail from '@/components/content/selector/SelectorElementsDetail.vue';
+import SharedElementHighlighter from '@/components/content/shared/SharedElementHighlighter.vue';
 import findElementList from './listSelector';
 
 const selectedElement = {
   path: [],
   pathIndex: 0,
 };
-let lastScrollPosY = window.scrollY;
-let lastScrollPosX = window.scrollX;
+
 const originalFontSize = document.documentElement.style.fontSize;
 
 const rootElement = inject('rootElement');
@@ -301,30 +300,34 @@ function handleMouseMove({ clientX, clientY, target }) {
     return;
   }
 
-  if (prevHoverElement === target) return;
-  prevHoverElement = target;
+  const { 1: realTarget } = document.elementsFromPoint(clientX, clientY);
+
+  if (prevHoverElement === realTarget) return;
+  prevHoverElement = realTarget;
 
   if (state.hide || rootElement === target) return;
 
   let elementsRect = [];
 
   if (state.selectList) {
-    const elements = getElementList(target) || [];
+    const elements = getElementList(realTarget) || [];
 
     elementsRect = elements.map((el) => getElementRect(el, true));
   } else {
-    elementsRect = [getElementRect(target)];
+    elementsRect = [getElementRect(realTarget)];
   }
 
   state.hoveredElements = elementsRect;
 }
 function handleClick(event) {
-  const { target, path, ctrlKey } = event;
+  const { target: eventTarget, path, ctrlKey, clientY, clientX } = event;
 
-  if (target === rootElement || state.hide || state.isExecuting) return;
+  if (eventTarget === rootElement || state.hide || state.isExecuting) return;
   event.stopPropagation();
   event.preventDefault();
 
+  const { 1: target } = document.elementsFromPoint(clientX, clientY);
+
   if (state.selectList) {
     const firstElement = state.hoveredElements[0].element;
 
@@ -350,9 +353,7 @@ function handleClick(event) {
       element.setAttribute('automa-el-list', '');
     });
 
-    const parentSelector = getCssSelector(firstElement.parentElement, {
-      includeTag: true,
-    });
+    const parentSelector = finder(firstElement.parentElement);
     const elementSelector = `${parentSelector} > ${firstElement.tagName.toLowerCase()}`;
 
     state.listSelector = elementSelector;
@@ -424,6 +425,7 @@ function selectChildElement() {
 
     childElement = childEl;
     selectedElement.path.unshift(childEl);
+    selectedElement.pathIndex = 0;
   } else {
     selectedElement.pathIndex -= 1;
     childElement = selectedElement.path[selectedElement.pathIndex];
@@ -445,24 +447,6 @@ function selectParentElement() {
 function handleMouseUp() {
   if (state.isDragging) state.isDragging = false;
 }
-const handleScroll = debounce(() => {
-  if (state.hide) return;
-
-  const yPos = window.scrollY - lastScrollPosY;
-  const xPos = window.scrollX - lastScrollPosX;
-  const updateState = (key) => {
-    state[key].forEach((_, index) => {
-      state[key][index].x -= xPos;
-      state[key][index].y -= yPos;
-    });
-  };
-
-  updateState('hoveredElements');
-  updateState('selectedElements');
-
-  lastScrollPosX = window.scrollX;
-  lastScrollPosY = window.scrollY;
-}, 100);
 function destroy() {
   rootElement.style.display = 'none';
 
@@ -486,7 +470,6 @@ function destroy() {
 function attachListeners() {
   cardElementObserver.observe(cardEl.value);
 
-  window.addEventListener('scroll', handleScroll);
   window.addEventListener('mouseup', handleMouseUp);
   window.addEventListener('mousemove', handleMouseMove);
   document.addEventListener('click', handleClick, true);
@@ -494,7 +477,6 @@ function attachListeners() {
 function detachListeners() {
   cardElementObserver.disconnect();
 
-  window.removeEventListener('scroll', handleScroll);
   window.removeEventListener('mouseup', handleMouseUp);
   window.removeEventListener('mousemove', handleMouseMove);
   document.removeEventListener('click', handleClick, true);
@@ -545,7 +527,7 @@ onBeforeUnmount(() => {
 <style>
 .root {
   font-size: 16px;
-  z-index: 9999999999;
+  z-index: 99999;
   line-height: 1.5 !important;
   font-family: 'Inter var', sans-serif;
   font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';

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

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

+ 0 - 4
src/content/elementSelector/compsUi.js

@@ -1,12 +1,10 @@
 import VAutofocus from '@/directives/VAutofocus';
 import UiTab from '@/components/ui/UiTab.vue';
-import UiList from '@/components/ui/UiList.vue';
 import UiTabs from '@/components/ui/UiTabs.vue';
 import UiInput from '@/components/ui/UiInput.vue';
 import UiButton from '@/components/ui/UiButton.vue';
 import UiSelect from '@/components/ui/UiSelect.vue';
 import UiExpand from '@/components/ui/UiExpand.vue';
-import UiListItem from '@/components/ui/UiListItem.vue';
 import UiTextarea from '@/components/ui/UiTextarea.vue';
 import UiCheckbox from '@/components/ui/UiCheckbox.vue';
 import UiTabPanel from '@/components/ui/UiTabPanel.vue';
@@ -16,12 +14,10 @@ import TransitionExpand from '@/components/transitions/TransitionExpand.vue';
 export default function (app) {
   app.component('UiTab', UiTab);
   app.component('UiTabs', UiTabs);
-  app.component('UiList', UiList);
   app.component('UiInput', UiInput);
   app.component('UiButton', UiButton);
   app.component('UiSelect', UiSelect);
   app.component('UiExpand', UiExpand);
-  app.component('UiListItem', UiListItem);
   app.component('UiTextarea', UiTextarea);
   app.component('UiCheckbox', UiCheckbox);
   app.component('UiTabPanel', UiTabPanel);

+ 0 - 98
src/content/elementSelector/icons.js

@@ -1,125 +1,27 @@
 import {
   riEyeLine,
-  riAB,
-  riLink,
-  riStopLine,
-  riFlowChart,
-  riParagraph,
-  riMouseLine,
-  riEarthLine,
-  riImageLine,
-  riChat3Line,
   riCheckLine,
   riCloseLine,
-  riTimerLine,
-  riWindowLine,
-  riFocus3Line,
-  riGithubFill,
   riEyeOffLine,
-  riGlobalLine,
-  riCursorLine,
-  riWindow2Line,
-  riRepeat2Line,
-  riRefreshFill,
-  riRefreshLine,
-  riRestartLine,
-  riTwitterLine,
-  riDiscordLine,
-  riCommandLine,
-  riSearch2Line,
-  riBracketsLine,
   riFileCopyLine,
   riDragMoveLine,
-  riFileTextLine,
-  riCalendarLine,
-  riDownloadLine,
-  riLightbulbLine,
-  riFolderZipLine,
-  riClipboardLine,
-  riEqualizerLine,
-  riDatabase2Line,
   riListUnordered,
   riArrowLeftLine,
-  riCodeSSlashLine,
-  riFileUploadLine,
-  riDeleteBin7Line,
-  riTimerFlashLine,
-  riFlashlightLine,
   riArrowLeftSLine,
-  riArrowGoBackLine,
-  riCloseCircleLine,
-  riInputCursorMove,
-  riArrowUpDownLine,
   riInformationLine,
-  riFileDownloadLine,
-  riShieldKeyholeLine,
   riArrowDropDownLine,
-  riArrowLeftRightLine,
-  riArrowGoForwardLine,
-  riLightbulbFlashLine,
 } from 'v-remixicon/icons';
 
 export default {
-  riAB,
-  riLink,
   riEyeLine,
-  riStopLine,
-  riFlowChart,
-  riParagraph,
-  riMouseLine,
-  riEarthLine,
-  riImageLine,
-  riChat3Line,
   riCheckLine,
   riCloseLine,
-  riTimerLine,
-  riFocus3Line,
-  riGithubFill,
   riEyeOffLine,
-  riGlobalLine,
-  riWindowLine,
-  riCursorLine,
-  riWindow2Line,
-  riRepeat2Line,
-  riRefreshFill,
-  riRefreshLine,
-  riRestartLine,
-  riTwitterLine,
-  riDiscordLine,
-  riCommandLine,
-  riSearch2Line,
-  riBracketsLine,
   riFileCopyLine,
   riDragMoveLine,
-  riFileTextLine,
-  riCalendarLine,
-  riDownloadLine,
-  riLightbulbLine,
-  riFolderZipLine,
-  riClipboardLine,
-  riEqualizerLine,
-  riDatabase2Line,
   riListUnordered,
   riArrowLeftLine,
-  riCodeSSlashLine,
-  riFileUploadLine,
-  riDeleteBin7Line,
-  riTimerFlashLine,
-  riFlashlightLine,
   riArrowLeftSLine,
-  riArrowGoBackLine,
-  riCloseCircleLine,
-  riInputCursorMove,
-  riArrowUpDownLine,
   riInformationLine,
-  riFileDownloadLine,
-  riShieldKeyholeLine,
   riArrowDropDownLine,
-  riArrowLeftRightLine,
-  riArrowGoForwardLine,
-  riLightbulbFlashLine,
-  mdiGoogleSheet:
-    'M19,11V9H11V5H9V9H5V11H9V19H11V11H19M19,3C19.5,3 20,3.2 20.39,3.61C20.8,4 21,4.5 21,5V19C21,19.5 20.8,20 20.39,20.39C20,20.8 19.5,21 19,21H5C4.5,21 4,20.8 3.61,20.39C3.2,20 3,19.5 3,19V5C3,4.5 3.2,4 3.61,3.61C4,3.2 4.5,3 5,3H19Z',
-  mdiCursorDefaultClickOutline:
-    'M11.5,11L17.88,16.37L17,16.55L16.36,16.67C15.73,16.8 15.37,17.5 15.65,18.07L15.92,18.65L17.28,21.59L15.86,22.25L14.5,19.32L14.24,18.74C13.97,18.15 13.22,17.97 12.72,18.38L12.21,18.78L11.5,19.35V11M10.76,8.69A0.76,0.76 0 0,0 10,9.45V20.9C10,21.32 10.34,21.66 10.76,21.66C10.95,21.66 11.11,21.6 11.24,21.5L13.15,19.95L14.81,23.57C14.94,23.84 15.21,24 15.5,24C15.61,24 15.72,24 15.83,23.92L18.59,22.64C18.97,22.46 19.15,22 18.95,21.63L17.28,18L19.69,17.55C19.85,17.5 20,17.43 20.12,17.29C20.39,16.97 20.35,16.5 20,16.21L11.26,8.86L11.25,8.87C11.12,8.76 10.95,8.69 10.76,8.69M15,10V8H20V10H15M13.83,4.76L16.66,1.93L18.07,3.34L15.24,6.17L13.83,4.76M10,0H12V5H10V0M3.93,14.66L6.76,11.83L8.17,13.24L5.34,16.07L3.93,14.66M3.93,3.34L5.34,1.93L8.17,4.76L6.76,6.17L3.93,3.34M7,10H2V8H7V10',
 };

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

@@ -1,33 +1,6 @@
 import browser from 'webextension-polyfill';
 import initElementSelector from './main';
-
-function generateStyleEl(css, classes = true) {
-  const style = document.createElement('style');
-  style.textContent = css;
-
-  if (classes) {
-    style.classList.add('automa-element-selector');
-  }
-
-  return style;
-}
-async function injectAppStyles(appRoot) {
-  try {
-    const response = await fetch(
-      browser.runtime.getURL('/elementSelector.css')
-    );
-    const mainCSS = await response.text();
-    const appStyleEl = generateStyleEl(mainCSS, false);
-    appRoot.shadowRoot.appendChild(appStyleEl);
-
-    const fontURL = browser.runtime.getURL('/Inter-roman-latin.var.woff2');
-    const fontCSS = `@font-face { font-family: "Inter var"; font-weight: 100 900; font-display: swap; font-style: normal; font-named-instance: "Regular"; src: url("${fontURL}") format("woff2") }`;
-    const fontStyleEl = generateStyleEl(fontCSS);
-    document.head.appendChild(fontStyleEl);
-  } catch (error) {
-    console.error(error);
-  }
-}
+import injectAppStyles from '../injectAppStyles';
 
 function elementSelectorInstance() {
   const rootElementExist = document.querySelector(
@@ -64,14 +37,10 @@ function elementSelectorInstance() {
     rootElement.classList.add('automa-element-selector');
     rootElement.attachShadow({ mode: 'open' });
 
-    const automaCSS = `.automa-element-selector { pointer-events: none; direction: ltr } \n [automa-isDragging] { user-select: none } \n [automa-el-list] {outline: 2px dashed #6366f1;}`;
-    const automaStyleEl = generateStyleEl(automaCSS);
-
     initElementSelector(rootElement);
-    await injectAppStyles(rootElement);
+    await injectAppStyles(rootElement.shadowRoot);
 
     document.documentElement.appendChild(rootElement);
-    document.documentElement.appendChild(automaStyleEl);
   } catch (error) {
     console.error(error);
   }

+ 0 - 3
src/content/elementSelector/workflow/WorkflowAddBlock.vue

@@ -1,3 +0,0 @@
-<template>
-  <ui-select class="w-full" placeholder="Select a block to add"> </ui-select>
-</template>

+ 0 - 69
src/content/elementSelector/workflow/WorkflowList.vue

@@ -1,69 +0,0 @@
-<template>
-  <div class="pb-4 mt-4">
-    <div class="px-4">
-      <p>Select a workflow</p>
-      <ui-input
-        v-model="query"
-        prepend-icon="riSearch2Line"
-        class="w-full"
-        autocomplete="off"
-        placeholder="Search..."
-      />
-    </div>
-    <ui-list
-      class="overflow-y-auto scroll px-4 mt-4"
-      style="max-height: calc(100vh - 14rem)"
-    >
-      <ui-list-item
-        v-for="workflow in filteredWorkflows"
-        :key="workflow.id"
-        small
-        class="cursor-pointer"
-        @click="$emit('select', workflow.id)"
-      >
-        <img
-          v-if="workflow.icon?.startsWith('http')"
-          :src="workflow.icon"
-          class="overflow-hidden rounded-lg"
-          style="height: 32px; width: 32px"
-          alt="Can not display"
-        />
-        <span v-else class="p-2 rounded-lg bg-box-transparent">
-          <v-remixicon :name="workflow.icon" size="20" />
-        </span>
-        <div class="ml-2 overflow-hidden flex-1">
-          <p :title="workflow.name" class="text-overflow leading-tight">
-            {{ workflow.name }}
-          </p>
-          <p
-            :title="workflow.description"
-            class="text-overflow text-gray-600 leading-tight text-sm"
-          >
-            {{ workflow.description }}
-          </p>
-        </div>
-      </ui-list-item>
-    </ui-list>
-  </div>
-</template>
-<script setup>
-import { shallowRef, computed } from 'vue';
-
-const props = defineProps({
-  workflows: {
-    type: Array,
-    default: () => [],
-  },
-});
-defineEmits(['select']);
-
-const query = shallowRef('');
-
-const filteredWorkflows = computed(() => {
-  const search = query.value.toLocaleLowerCase();
-
-  return props.workflows.filter((workflow) =>
-    workflow.name.toLocaleLowerCase().includes(search)
-  );
-});
-</script>

+ 40 - 0
src/content/injectAppStyles.js

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

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

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

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

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

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

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

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

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

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

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

+ 79 - 62
src/content/services/recordWorkflow.js → src/content/services/recordWorkflow/recordEvents.js

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

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

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

+ 86 - 51
src/popup/pages/Home.vue

@@ -8,13 +8,13 @@
         v-tooltip.group="t('home.record.title')"
         icon
         class="mr-2"
-        @click="recordWorkflow"
+        @click="state.newRecordingModal = true"
       >
         <v-remixicon name="riRecordCircleLine" />
       </ui-button>
       <ui-button
         v-tooltip.group="
-          t(`home.elementSelector.${haveAccess ? 'name' : 'noAccess'}`)
+          t(`home.elementSelector.${state.haveAccess ? 'name' : 'noAccess'}`)
         "
         icon
         class="mr-2"
@@ -33,7 +33,7 @@
     </div>
     <div class="flex">
       <ui-input
-        v-model="query"
+        v-model="state.query"
         :placeholder="`${t('common.search')}...`"
         prepend-icon="riSearch2Line"
         class="w-full search-input"
@@ -63,9 +63,33 @@
       @delete="deleteWorkflow"
     />
   </div>
+  <ui-modal v-model="state.newRecordingModal" custom-content>
+    <ui-card
+      :style="{ height: `${state.cardHeight}px` }"
+      class="w-full recording-card overflow-hidden rounded-b-none"
+      padding="p-0"
+    >
+      <div class="flex items-center px-4 pt-4 pb-2">
+        <p class="flex-1 font-semibold">
+          {{ t('home.record.title') }}
+        </p>
+        <v-remixicon
+          class="text-gray-600 dark:text-gray-300 cursor-pointer"
+          name="riCloseLine"
+          size="20"
+          @click="state.newRecordingModal = false"
+        ></v-remixicon>
+      </div>
+      <home-start-recording
+        @record="recordWorkflow"
+        @close="state.newRecordingModal = false"
+        @update="state.cardHeight = recordingCardHeight[$event] || 255"
+      />
+    </ui-card>
+  </ui-modal>
 </template>
 <script setup>
-import { ref, computed, onMounted } from 'vue';
+import { computed, onMounted, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
 import browser from 'webextension-polyfill';
 import { useDialog } from '@/composable/dialog';
@@ -73,19 +97,29 @@ import { useGroupTooltip } from '@/composable/groupTooltip';
 import { sendMessage } from '@/utils/message';
 import Workflow from '@/models/workflow';
 import HomeWorkflowCard from '@/components/popup/home/HomeWorkflowCard.vue';
+import HomeStartRecording from '@/components/popup/home/HomeStartRecording.vue';
+
+const recordingCardHeight = {
+  new: 255,
+  existing: 480,
+};
 
 const { t } = useI18n();
 const dialog = useDialog();
 
 useGroupTooltip();
 
-const query = ref('');
-const haveAccess = ref(true);
+const state = shallowReactive({
+  query: '',
+  cardHeight: 255,
+  haveAccess: true,
+  newRecordingModal: false,
+});
 
 const workflows = computed(() =>
   Workflow.query()
     .where(({ name }) =>
-      name.toLocaleLowerCase().includes(query.value.toLocaleLowerCase())
+      name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
     )
     .orderBy('createdAt', 'desc')
     .get()
@@ -144,59 +178,60 @@ async function initElementSelector() {
     console.error(error);
   }
 }
-function recordWorkflow() {
-  dialog.prompt({
-    title: t('home.record.title'),
-    okText: t('home.record.button'),
-    placeholder: t('home.record.name'),
-    onConfirm: async (name) => {
-      const flows = [];
-      const [activeTab] = await browser.tabs.query({
-        active: true,
-        currentWindow: true,
-      });
+async function recordWorkflow(options = {}) {
+  try {
+    const flows = [];
+    const [activeTab] = await browser.tabs.query({
+      active: true,
+      currentWindow: true,
+    });
 
-      if (activeTab && activeTab.url.startsWith('http')) {
-        flows.push({
-          id: 'new-tab',
-          description: activeTab.url,
-          data: { url: activeTab.url },
-        });
-      }
+    if (activeTab && activeTab.url.startsWith('http')) {
+      flows.push({
+        id: 'new-tab',
+        description: activeTab.url,
+        data: { url: activeTab.url },
+      });
+    }
 
-      await browser.storage.local.set({
-        isRecording: true,
-        recording: {
-          flows,
-          activeTab: {
-            id: activeTab.id,
-            url: activeTab.url,
-          },
-          name: name || 'unnamed',
+    await browser.storage.local.set({
+      isRecording: true,
+      recording: {
+        flows,
+        name: 'unnamed',
+        activeTab: {
+          id: activeTab.id,
+          url: activeTab.url,
         },
-      });
-      await browser.browserAction.setBadgeBackgroundColor({ color: '#ef4444' });
-      await browser.browserAction.setBadgeText({ text: 'rec' });
+        ...options,
+      },
+    });
+    await browser.browserAction.setBadgeBackgroundColor({ color: '#ef4444' });
+    await browser.browserAction.setBadgeText({ text: 'rec' });
 
-      const tabs = (await browser.tabs.query({})).filter(({ url }) =>
-        url.startsWith('http')
-      );
-      await Promise.allSettled(
-        tabs.map(({ id }) =>
-          browser.tabs.executeScript(id, {
-            file: 'recordWorkflow.bundle.js',
-          })
-        )
-      );
+    const tabs = await browser.tabs.query({});
+    for (const tab of tabs) {
+      if (tab.url.startsWith('http')) {
+        await browser.tabs.executeScript(tab.id, {
+          file: 'recordWorkflow.bundle.js',
+        });
+      }
+    }
 
-      window.close();
-    },
-  });
+    window.close();
+  } catch (error) {
+    console.error(error);
+  }
 }
 
 onMounted(async () => {
   const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
 
-  haveAccess.value = /^(https?)/.test(tab.url);
+  state.haveAccess = /^(https?)/.test(tab.url);
 });
 </script>
+<style>
+.recording-card {
+  transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) !important;
+}
+</style>

+ 40 - 15
src/popup/pages/Recording.vue

@@ -69,10 +69,10 @@ const state = reactive({
   isGenerating: false,
 });
 
-function generateDrawflow() {
+function generateDrawflow(startBlock, startBlockData) {
   let nextNodeId = nanoid();
-  const triggerId = nanoid();
-  let prevNodeId = triggerId;
+  const triggerId = startBlock.id || nanoid();
+  let prevNodeId = startBlock.id || triggerId;
 
   const nodes = {
     [triggerId]: {
@@ -80,7 +80,7 @@ function generateDrawflow() {
       pos_y: 300,
       inputs: {},
       outputs: {
-        output_1: {
+        [startBlock ? startBlock.output : 'output_1']: {
           connections: [{ node: nextNodeId, output: 'input_1' }],
         },
       },
@@ -90,9 +90,14 @@ function generateDrawflow() {
       class: 'trigger',
       html: 'BlockBasic',
       data: tasks.trigger.data,
+      ...startBlockData,
     },
   };
-  const position = { x: 260, y: 300 };
+
+  const position = {
+    y: startBlockData ? startBlockData.pos_y + 50 : 300,
+    x: startBlockData ? startBlockData.pos_x + 120 : 260,
+  };
 
   state.flows.forEach((block, index) => {
     const node = {
@@ -110,7 +115,7 @@ function generateDrawflow() {
 
     node.inputs.input_1.connections.push({
       node: prevNodeId,
-      input: 'output_1',
+      input: index === 0 && startBlock ? startBlock.output : 'output_1',
     });
 
     const isLastIndex = index === state.flows.length - 1;
@@ -126,13 +131,15 @@ function generateDrawflow() {
     }
 
     const inNewRow = (index + 1) % 5 === 0;
-    const blockNameLen = tasks[block.id].name.length * 11 + 120;
+    const blockNameLen = tasks[block.id].name.length * 14 + 120;
     position.x = inNewRow ? 50 : position.x + blockNameLen;
     position.y = inNewRow ? position.y + 150 : position.y;
 
     nodes[node.id] = node;
   });
 
+  if (startBlock) return nodes;
+
   return { drawflow: { Home: { data: nodes } } };
 }
 async function stopRecording() {
@@ -145,14 +152,32 @@ async function stopRecording() {
   try {
     state.isGenerating = true;
 
-    const drawflow = generateDrawflow();
-
-    await Workflow.insert({
-      data: {
-        name: state.name,
-        drawflow: JSON.stringify(drawflow),
-      },
-    });
+    if (state.workflowId) {
+      const workflow = Workflow.find(state.workflowId);
+      const drawflow =
+        typeof workflow.drawflow === 'string'
+          ? JSON.parse(workflow.drawflow)
+          : workflow.drawflow;
+      const node = drawflow.drawflow.Home.data[state.connectFrom.id];
+      const updatedDrawflow = generateDrawflow(state.connectFrom, node);
+
+      Object.assign(drawflow.drawflow.Home.data, updatedDrawflow);
+
+      await Workflow.update({
+        where: state.workflowId,
+        data: {
+          drawflow: JSON.stringify(drawflow),
+        },
+      });
+    } else {
+      const drawflow = generateDrawflow();
+      await Workflow.insert({
+        data: {
+          name: state.name,
+          drawflow: JSON.stringify(drawflow),
+        },
+      });
+    }
 
     await browser.storage.local.remove(['isRecording', 'recording']);
     await browser.browserAction.setBadgeText({ text: '' });

+ 0 - 3
src/popup/pages/workflow/Edit.vue

@@ -1,3 +0,0 @@
-<template>
-  <p>edit workflow</p>
-</template>

+ 13 - 0
src/utils/shared.js

@@ -1043,6 +1043,19 @@ export const communities = [
   },
 ];
 
+export const elementsHighlightData = {
+  selectedElements: {
+    stroke: '#2563EB',
+    activeStroke: '#f87171',
+    fill: 'rgba(37, 99, 235, 0.1)',
+    activeFill: 'rgba(248, 113, 113, 0.1)',
+  },
+  hoveredElements: {
+    stroke: '#fbbf24',
+    fill: 'rgba(251, 191, 36, 0.1)',
+  },
+};
+
 export const conditionBuilder = {
   valueTypes: [
     {

+ 2 - 1
webpack.config.js

@@ -48,7 +48,8 @@ const options = {
       'src',
       'content',
       'services',
-      'recordWorkflow.js'
+      'recordWorkflow',
+      'index.js'
     ),
     shortcutListener: path.join(
       __dirname,