ソースを参照

feat: add `element change` trigger

Ahmad Kholid 3 年 前
コミット
7b602b9855

+ 7 - 1
src/background/index.js

@@ -459,7 +459,13 @@ message.on('collection:execute', (collection) => {
   engine.init();
 });
 
-message.on('workflow:execute', (workflowData) => {
+message.on('workflow:execute', (workflowData, sender) => {
+  if (workflowData.includeTabId) {
+    if (!workflowData.options) workflowData.options = {};
+
+    workflowData.options.tabId = sender.tab.id;
+  }
+
   workflow.execute(workflowData, workflowData?.options || {});
 });
 message.on('workflow:stop', (id) => workflow.states.stop(id));

+ 2 - 0
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -35,6 +35,7 @@ import TriggerInterval from './Trigger/TriggerInterval.vue';
 import TriggerVisitWeb from './Trigger/TriggerVisitWeb.vue';
 import TriggerContextMenu from './Trigger/TriggerContextMenu.vue';
 import TriggerSpecificDay from './Trigger/TriggerSpecificDay.vue';
+import TriggerElementChange from './Trigger/TriggerElementChange.vue';
 import TriggerKeyboardShortcut from './Trigger/TriggerKeyboardShortcut.vue';
 
 const props = defineProps({
@@ -51,6 +52,7 @@ const triggers = {
   manual: null,
   interval: TriggerInterval,
   'context-menu': TriggerContextMenu,
+  'element-change': TriggerElementChange,
   date: TriggerDate,
   'specific-day': TriggerSpecificDay,
   'on-startup': null,

+ 130 - 0
src/components/newtab/workflow/edit/Trigger/TriggerElementChange.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="mt-4">
+    <ui-input
+      v-model="observeDetail.matchPattern"
+      :label="t('workflow.blocks.trigger.element-change.target')"
+      class="w-full"
+      placeholder="https://web.telegram.org/*"
+    >
+      <template #label>
+        {{ t('workflow.blocks.switch-tab.matchPattern') }}
+        <a
+          :title="t('workflow.blocks.trigger.element-change.targetWebsite')"
+          href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#examples"
+          target="_blank"
+          rel="noopener"
+          class="inline-block"
+        >
+          <v-remixicon name="riInformationLine" class="info-icon" size="18" />
+        </a>
+      </template>
+    </ui-input>
+    <ui-input
+      v-model="observeDetail.baseSelector"
+      class="w-full mt-4"
+      placeholder="CSS Selector or XPath"
+    >
+      <template #label>
+        <span>
+          {{ t('workflow.blocks.trigger.element-change.baseEl.title') }}
+        </span>
+        <v-remixicon
+          :title="
+            t('workflow.blocks.trigger.element-change.baseEl.description')
+          "
+          name="riInformationLine"
+          class="info-icon"
+          size="18"
+        />
+      </template>
+    </ui-input>
+    <ui-expand header-class="w-full group flex items-center focus:ring-0">
+      <template #header>
+        {{ t('common.options') }}
+      </template>
+      <trigger-element-options v-model="observeDetail.baseElOptions" />
+    </ui-expand>
+    <ui-input
+      v-model="observeDetail.selector"
+      :label="t('workflow.blocks.trigger.element-change.target')"
+      class="w-full mt-4"
+      placeholder="CSS Selector or XPath"
+    />
+    <ui-expand header-class="w-full flex items-center focus:ring-0 group">
+      <template #header>
+        {{ t('common.options') }}
+        <v-remixicon
+          :title="t('workflow.blocks.trigger.element-change.optionsInfo')"
+          class="info-icon invisible group-hover:visible"
+          size="18"
+          name="riInformationLine"
+        />
+      </template>
+      <trigger-element-options
+        v-model="observeDetail.targetOptions"
+        show-desc
+      />
+    </ui-expand>
+  </div>
+</template>
+<script setup>
+import { onMounted, reactive, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import cloneDeep from 'lodash.clonedeep';
+import TriggerElementOptions from './TriggerElementOptions.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+
+const state = reactive({
+  retrieved: false,
+});
+const observeDetail = reactive({
+  selector: '',
+  baseSelector: '',
+  matchPattern: '',
+  targetOptions: {
+    subtree: false,
+    childList: true,
+    attributes: false,
+    attributeFilter: [],
+    characterData: false,
+  },
+  baseElOptions: {
+    subtree: false,
+    childList: true,
+    attributes: false,
+    attributeFilter: [],
+    characterData: false,
+  },
+});
+
+watch(
+  observeDetail,
+  (value) => {
+    if (!state.retrieved) return;
+
+    emit('update', { observeElement: value });
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  Object.assign(observeDetail, cloneDeep(props.data.observeElement));
+  setTimeout(() => {
+    state.retrieved = true;
+  }, 100);
+});
+</script>
+<style>
+.info-icon {
+  @apply text-gray-600 dark:text-gray-300 inline-block;
+}
+</style>

+ 76 - 0
src/components/newtab/workflow/edit/Trigger/TriggerElementOptions.vue

@@ -0,0 +1,76 @@
+<template>
+  <ul class="space-y-2 mt-1">
+    <li v-for="option in types" :key="option" class="group">
+      <ui-checkbox
+        :model-value="modelValue[option]"
+        @change="
+          $emit('update:modelValue', { ...modelValue, [option]: $event })
+        "
+      >
+        {{ t(`workflow.blocks.trigger.element-change.${option}.title`) }}
+        <v-remixicon
+          :title="
+            t(`workflow.blocks.trigger.element-change.${option}.description`)
+          "
+          class="info-icon invisible group-hover:visible"
+          size="18"
+          name="riInformationLine"
+        />
+      </ui-checkbox>
+      <template v-if="option === 'attributes' && modelValue.attributes">
+        <ui-input
+          :model-value="modelValue.attributeFilter.join(',')"
+          :label="
+            t('workflow.blocks.trigger.element-change.subtree.description')
+          "
+          class="w-full block"
+          placeholder="id,label,class"
+          @change="onAttrFilterChange"
+        >
+          <template #label>
+            {{
+              t('workflow.blocks.trigger.element-change.attributeFilter.title')
+            }}
+            <v-remixicon
+              :title="
+                t(
+                  'workflow.blocks.trigger.element-change.attributeFilter.description'
+                )
+              "
+              class="info-icon"
+              size="18"
+              name="riInformationLine"
+            />
+          </template>
+        </ui-input>
+        <span class="text-sm text-gray-600 dark:text-gray-200">
+          {{
+            t('workflow.blocks.trigger.element-change.attributeFilter.separate')
+          }}
+        </span>
+      </template>
+    </li>
+  </ul>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { debounce } from '@/utils/helper';
+
+const props = defineProps({
+  modelValue: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:modelValue']);
+
+const types = ['subtree', 'childList', 'attributes', 'characterData'];
+const { t } = useI18n();
+
+const onAttrFilterChange = debounce((value) => {
+  emit('update:modelValue', {
+    ...props.modelValue,
+    attributeFilter: value.split(','),
+  });
+}, 200);
+</script>

+ 111 - 0
src/content/elementObserver.js

@@ -0,0 +1,111 @@
+import browser from 'webextension-polyfill';
+import { isXPath, debounce } from '@/utils/helper';
+import { sendMessage } from '@/utils/message';
+import FindElement from '@/utils/FindElement';
+
+const observeElements = {};
+
+const targetMutationCallback = debounce(([{ target }]) => {
+  let workflowId = target.getAttribute('automa-id');
+
+  if (!workflowId) {
+    const element = target.closest('[automa-id]');
+    if (!element) return;
+    workflowId = element.getAttribute('automa-id');
+  }
+  if (!observeElements[workflowId]) return;
+
+  const { workflow } = observeElements[workflowId];
+  workflow.includeTabId = true;
+
+  sendMessage('workflow:execute', workflow, 'background');
+}, 250);
+const targetObserver = new MutationObserver(targetMutationCallback);
+
+const baseMutationCallback = debounce(() => {
+  targetObserver.disconnect();
+  Object.values(observeElements).forEach((detail) => {
+    /* eslint-disable-next-line */
+    tryObserve({ ...detail, observer: targetObserver });
+  });
+}, 250);
+const baseObserver = new MutationObserver(baseMutationCallback);
+
+export function matchPatternToRegex(str) {
+  const regexStr = str.replace(/[*?^$]/g, (char) => {
+    if (char === '*') return '[a-zA-Z0-9]*';
+
+    return `\\${char}`;
+  });
+  const regex = new RegExp(regexStr);
+
+  return regex;
+}
+function tryObserve({ selector, observer, options, id }) {
+  let tryCount = 0;
+
+  const findElement = () => {
+    if (tryCount > 10) return;
+
+    const selectorType = isXPath(selector) ? 'xpath' : 'cssSelector';
+    const element = FindElement[selectorType]({ selector });
+
+    if (!element) {
+      tryCount += 1;
+      setTimeout(findElement, 1000);
+      return;
+    }
+
+    if (id) element.setAttribute('automa-id', id);
+
+    if (!options.attributes || options.attributeFilter.length === 0)
+      delete options.attributeFilter;
+    observer.observe(element, options);
+  };
+
+  findElement();
+}
+
+export default async function () {
+  const { workflows } = await browser.storage.local.get('workflows');
+  workflows.forEach(({ trigger, id, ...workflowDetail }) => {
+    if (
+      !trigger ||
+      trigger.type !== 'element-change' ||
+      !trigger.observeElement?.selector ||
+      !trigger.observeElement?.matchPattern
+    )
+      return;
+
+    const {
+      baseSelector,
+      baseElOptions,
+      selector,
+      targetOptions,
+      matchPattern,
+    } = trigger.observeElement;
+
+    const regex = matchPatternToRegex(matchPattern);
+    if (!regex.test(window.location.href)) return;
+
+    if (baseSelector)
+      tryObserve({
+        selector: baseSelector,
+        options: baseElOptions,
+        observer: baseObserver,
+      });
+
+    observeElements[id] = {
+      id,
+      selector,
+      options: targetOptions,
+      workflow: { id, trigger, ...workflowDetail },
+    };
+    tryObserve({
+      selector,
+      options: targetOptions,
+      observer: targetObserver,
+      id,
+    });
+  });
+}

+ 2 - 0
src/content/index.js

@@ -5,6 +5,7 @@ import blocksHandler from './blocksHandler';
 import showExecutedBlock from './showExecutedBlock';
 import handleTestCondition from './handleTestCondition';
 import shortcutListener from './services/shortcutListener';
+import elementObserver from './elementObserver';
 import { elementSelectorInstance } from './utils';
 
 const isMainFrame = window.self === window.top;
@@ -141,6 +142,7 @@ function messageListener({ data, source }) {
     window.addEventListener('contextmenu', ({ target }) => {
       contextElement = target;
     });
+    window.addEventListener('load', elementObserver);
   }
 
   browser.runtime.onMessage.addListener((data) => {

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

@@ -205,11 +205,42 @@
           "url": "URL or Regex",
           "shortcut": "Shortcut"
         },
+        "element-change": {
+          "target": "Target element to observe",
+          "optionsInfo": "Which element mutation will trigger the workflow",
+          "targetWebsite": "The Match Pattern of the website where the target element is (click to see more Match Pattern examples)",
+          "baseEl": {
+            "title": "Base element (optional)",
+            "description": "Automa will restart observing the target element when this element changed"
+          },
+          "subtree": {
+            "title": "Include subtree",
+            "description": "Extend monitoring to the entire subtree of the target element"
+          },
+          "childList": {
+            "title": "Child list",
+            "description": "Monitor for the addition of new child elements or removal of existing child elements"
+          },
+          "attributes": {
+            "title": "Attributes",
+            "description": "Watch for changes to the value of attributes on the target element"
+          },
+          "attributeFilter": {
+            "title": "Attribute filter",
+            "separate": "Use commas(,) to separate attribute name",
+            "description": "Only monitor specific attributes (leave blank to monitor all)"
+          },
+          "characterData": {
+            "title": "Character data",
+            "description": "Monitor changes to the character data/text within the target element"
+          },
+        },
         "items": {
           "manual": "Manually",
           "interval": "Interval",
           "date": "On a specific date",
           "context-menu": "Context menu",
+          "element-change": "When element change",
           "specific-day": "On a specific day",
           "visit-web": "When visit a website",
           "on-startup": "On browser startup",
@@ -220,7 +251,7 @@
         "name": "Execute workflow",
         "overwriteNote": "This will overwrite the global data of the selected workflow",
         "select": "Select workflow",
-        "executeId": "Execute Id",
+        "executeId": "Execute Id (optional)",
         "description": ""
       },
       "google-sheets": {

+ 19 - 0
src/utils/shared.js

@@ -28,6 +28,25 @@ export const tasks = {
       days: [],
       contextMenuName: '',
       contextTypes: [],
+      observeElement: {
+        selector: '',
+        baseSelector: '',
+        matchPattern: '',
+        targetOptions: {
+          subtree: false,
+          childList: true,
+          attributes: false,
+          attributeFilter: [],
+          characterData: false,
+        },
+        baseElOptions: {
+          subtree: false,
+          childList: true,
+          attributes: false,
+          attributeFilter: [],
+          characterData: false,
+        },
+      },
     },
   },
   'execute-workflow': {

+ 11 - 3
src/utils/workflowTrigger.js

@@ -98,9 +98,17 @@ export async function cleanWorkflowTriggers(workflowId) {
       onStartupTriggers: startupTriggers,
     });
 
-    (BROWSER_TYPE === 'firefox' ? browser.menus : browser.contextMenus)?.remove(
-      workflowId
-    );
+    const removeFromContextMenu = async () => {
+      try {
+        await (BROWSER_TYPE === 'firefox'
+          ? browser.menus
+          : browser.contextMenus
+        )?.remove(workflowId);
+      } catch (error) {
+        // Do nothing
+      }
+    };
+    await removeFromContextMenu();
   } catch (error) {
     console.error(error);
   }