Browse Source

feat: add "press key" block

Ahmad Kholid 3 years ago
parent
commit
31df5e86cb

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

@@ -22,7 +22,7 @@ export function waitTabLoaded(tabId, ms = 10000) {
     const timeout = null;
     let isResolved = false;
     const onErrorOccurred = (details) => {
-      if (details.tabId !== tabId || detail.error.includes('ERR_ABORTED'))
+      if (details.tabId !== tabId || details.error.includes('ERR_ABORTED'))
         return;
 
       isResolved = true;

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

@@ -41,7 +41,7 @@
             :rotate="show ? 270 : 180"
             class="mr-1 transition-transform -ml-1"
           />
-          Selector options
+          {{ t('workflow.blocks.base.selectorOptions') }}
         </template>
         <div class="mt-1">
           <ui-checkbox

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

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

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

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

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

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

+ 10 - 10
src/content/handleSelector.js

@@ -9,6 +9,16 @@ export function markElement(el, { id, data }) {
   }
 }
 
+export function getDocumentCtx(frameSelector) {
+  let documentCtx = document;
+
+  if (frameSelector) {
+    documentCtx = document.querySelector(frameSelector)?.contentDocument;
+  }
+
+  return documentCtx;
+}
+
 export function queryElements(data, documentCtx = document) {
   return new Promise((resolve) => {
     let timeout = null;
@@ -40,16 +50,6 @@ export function queryElements(data, documentCtx = document) {
   });
 }
 
-export function getDocumentCtx(frameSelector) {
-  let documentCtx = document;
-
-  if (frameSelector) {
-    documentCtx = document.querySelector(frameSelector)?.contentDocument;
-  }
-
-  return documentCtx;
-}
-
 export default async function (
   { data, id, frameSelector, debugMode },
   { onSelected, onError, onSuccess }

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

@@ -13,6 +13,7 @@
         "title": "Blocks",
         "moveToGroup": "Move block to blocks group",
         "selector": "Element selector",
+        "selectorOptions": "Selector options",
         "timeout": "Timeout (milliseconds)",
         "toggle": {
           "enable": "Enable block",
@@ -92,6 +93,13 @@
         "name": "Reload tab",
         "description": "Reload the active tab"
       },
+      "press-key": {
+        "name": "Press key",
+        "description": "Press a key or a combination",
+        "target": "Target element (optional)",
+        "key": "Key",
+        "detect": "Detect key"
+      },
       "save-assets": {
         "name": "Save assets",
         "description": "Save assets (image, video, audio, or file) from an element or URL",

+ 10 - 3
src/utils/handleFormElement.js

@@ -32,9 +32,12 @@ function formEvent(element, data) {
     bubbles: true,
     cancelable: true,
   });
-  element.dispatchEvent(
-    new Event('change', { bubbles: true, cancelable: true })
-  );
+
+  if (data.type !== 'text-field') {
+    element.dispatchEvent(
+      new Event('change', { bubbles: true, cancelable: true })
+    );
+  }
 }
 async function inputText({ data, element, isEditable }) {
   const elementKey = isEditable ? 'textContent' : 'value';
@@ -60,6 +63,10 @@ async function inputText({ data, element, isEditable }) {
       value: data.value[0] ?? '',
     });
   }
+
+  element.dispatchEvent(
+    new Event('change', { bubbles: true, cancelable: true })
+  );
 }
 
 export default async function (element, data) {

+ 4 - 2
src/utils/helper.js

@@ -167,9 +167,11 @@ export function countDuration(started, ended) {
   return `${getText(minutes, 'm')} ${seconds}s`;
 }
 
-export function toCamelCase(str) {
+export function toCamelCase(str, capitalize = false) {
   const result = str.replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => {
-    return index === 0 ? letter.toLowerCase() : letter.toUpperCase();
+    return index === 0 && !capitalize
+      ? letter.toLowerCase()
+      : letter.toUpperCase();
   });
 
   return result.replace(/\s+|[-]/g, '');

+ 19 - 0
src/utils/shared.js

@@ -839,6 +839,25 @@ export const tasks = {
       onConflict: 'uniquify',
     },
   },
+  'press-key': {
+    name: 'Press key',
+    description: 'Press a key or a combination',
+    icon: 'riKeyboardLine',
+    component: 'BlockBasic',
+    editComponent: 'EditPressKey',
+    category: 'interaction',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    refDataKeys: ['selector', 'keys'],
+    data: {
+      disableBlock: false,
+      keys: '',
+      selector: '',
+      description: '',
+    },
+  },
   'handle-dialog': {
     name: 'Handle dialog',
     description:

+ 2 - 2
src/utils/simulateEvent/index.js

@@ -32,9 +32,9 @@ export function getEventObj(name, params) {
 
 export default function (element, name, params) {
   const event = getEventObj(name, params);
-  const useNativeEvents = ['focus', 'submit', 'blur'];
+  const useNativeMethods = ['focus', 'submit', 'blur'];
 
-  if (useNativeEvents.includes(name) && element[name]) {
+  if (useNativeMethods.includes(name) && element[name]) {
     element[name]();
   } else {
     element.dispatchEvent(event);