App.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. <template>
  2. <div
  3. :class="{
  4. 'select-none': state.isDragging,
  5. 'bg-black bg-opacity-30': !state.hide,
  6. }"
  7. class="root fixed h-full w-full pointer-events-none top-0 text-black left-0"
  8. style="z-index: 9999999999; font-family: Inter, sans-serif; font-size: 16px"
  9. >
  10. <div
  11. ref="cardEl"
  12. :style="{ transform: `translate(${cardRect.x}px, ${cardRect.y}px)` }"
  13. style="width: 320px"
  14. class="absolute root-card bg-white shadow-xl z-50 p-4 pointer-events-auto rounded-lg"
  15. >
  16. <div
  17. class="absolute p-2 drag-button shadow-xl bg-white p-1 cursor-move rounded-lg"
  18. style="top: -15px; left: -15px"
  19. >
  20. <v-remixicon
  21. name="riDragMoveLine"
  22. @mousedown="state.isDragging = true"
  23. />
  24. </div>
  25. <div class="flex items-center">
  26. <p class="ml-1 text-lg font-semibold">Automa</p>
  27. <div class="flex-grow"></div>
  28. <ui-button icon class="mr-2" @click="state.hide = !state.hide">
  29. <v-remixicon :name="state.hide ? 'riEyeOffLine' : 'riEyeLine'" />
  30. </ui-button>
  31. <ui-button icon @click="destroy">
  32. <v-remixicon name="riCloseLine" />
  33. </ui-button>
  34. </div>
  35. <app-selector
  36. :selector="state.elSelector"
  37. :selected-count="state.selectedElements.length"
  38. :selector-type="state.selectorType"
  39. @selector="state.selectorType = $event"
  40. @child="selectChildElement"
  41. @parent="selectParentElement"
  42. @change="updateSelectedElements"
  43. />
  44. <template v-if="!state.hide && state.selectedElements.length > 0">
  45. <ui-tabs v-model="state.activeTab" class="mt-2" fill>
  46. <ui-tab value="attributes"> Attributes </ui-tab>
  47. <ui-tab v-if="state.selectElements.length > 0" value="options">
  48. Options
  49. </ui-tab>
  50. <ui-tab value="blocks"> Blocks </ui-tab>
  51. </ui-tabs>
  52. <ui-tab-panels
  53. v-model="state.activeTab"
  54. class="overflow-y-auto scroll"
  55. style="max-height: calc(100vh - 17rem)"
  56. >
  57. <ui-tab-panel value="attributes">
  58. <app-element-list
  59. :elements="state.selectedElements"
  60. @highlight="toggleHighlightElement"
  61. >
  62. <template #item="{ element }">
  63. <div
  64. v-for="attribute in element.attributes"
  65. :key="attribute.name"
  66. class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
  67. >
  68. <p
  69. class="text-sm text-overflow leading-tight text-gray-600"
  70. title="Attribute name"
  71. >
  72. {{ attribute.name }}
  73. </p>
  74. <input
  75. :value="attribute.value"
  76. readonly
  77. title="Attribute value"
  78. class="bg-transparent w-full"
  79. />
  80. </div>
  81. </template>
  82. </app-element-list>
  83. </ui-tab-panel>
  84. <ui-tab-panel value="options">
  85. <app-element-list
  86. :elements="state.selectElements"
  87. element-name="Select element options"
  88. @highlight="
  89. toggleHighlightElement({
  90. index: $event.element.index,
  91. highlight: $event.highlight,
  92. })
  93. "
  94. >
  95. <template #item="{ element }">
  96. <div
  97. v-for="option in element.options"
  98. :key="option.name"
  99. class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
  100. >
  101. <p
  102. class="text-sm text-overflow leading-tight text-gray-600"
  103. title="Option name"
  104. >
  105. {{ option.name }}
  106. </p>
  107. <input
  108. :value="option.value"
  109. title="Option value"
  110. class="text-overflow focus:ring-0 w-full bg-transparent"
  111. readonly
  112. @click="$event.target.select()"
  113. />
  114. </div>
  115. </template>
  116. </app-element-list>
  117. </ui-tab-panel>
  118. <ui-tab-panel value="blocks">
  119. <app-blocks
  120. :elements="state.selectedElements"
  121. :selector="state.elSelector"
  122. @execute="state.isExecuting = $event"
  123. @update="updateCardSize"
  124. />
  125. </ui-tab-panel>
  126. </ui-tab-panels>
  127. </template>
  128. </div>
  129. <svg
  130. v-if="!state.hide"
  131. class="h-full w-full absolute top-0 pointer-events-none left-0 z-10"
  132. >
  133. <rect
  134. v-bind="hoverElementRect"
  135. stroke-width="2"
  136. stroke="#fbbf24"
  137. fill="rgba(251, 191, 36, 0.2)"
  138. ></rect>
  139. <rect
  140. v-for="(item, index) in state.selectedElements"
  141. v-bind="item"
  142. :key="index"
  143. :stroke="item.highlight ? '#2563EB' : '#f87171'"
  144. :fill="
  145. item.highlight ? 'rgb(37, 99, 235, 0.2)' : 'rgba(248, 113, 113, 0.2)'
  146. "
  147. stroke-width="2"
  148. ></rect>
  149. </svg>
  150. </div>
  151. </template>
  152. <script setup>
  153. import { reactive, ref, watch, inject, nextTick } from 'vue';
  154. import { getCssSelector } from 'css-selector-generator';
  155. import { debounce } from '@/utils/helper';
  156. import findElement from '@/utils/find-element';
  157. import AppBlocks from './AppBlocks.vue';
  158. import AppSelector from './AppSelector.vue';
  159. import AppElementList from './AppElementList.vue';
  160. const selectedElement = {
  161. path: [],
  162. pathIndex: 0,
  163. };
  164. let lastScrollPosY = window.scrollY;
  165. let lastScrollPosX = window.scrollX;
  166. const rootElement = inject('rootElement');
  167. const cardEl = ref('cardEl');
  168. const state = reactive({
  169. activeTab: '',
  170. elSelector: '',
  171. isDragging: false,
  172. isExecuting: false,
  173. selectElements: [],
  174. selectorType: 'css',
  175. selectedElements: [],
  176. hide: window.self !== window.top,
  177. });
  178. const hoverElementRect = reactive({
  179. x: 0,
  180. y: 0,
  181. height: 0,
  182. width: 0,
  183. });
  184. const cardRect = reactive({
  185. x: 0,
  186. y: 0,
  187. height: 0,
  188. width: 0,
  189. });
  190. /* eslint-disable no-use-before-define */
  191. const getElementSelector = (element) =>
  192. state.selectorType === 'css'
  193. ? getCssSelector(element, {
  194. includeTag: true,
  195. blacklist: ['[focused]', /focus/],
  196. })
  197. : generateXPath(element);
  198. function generateXPath(element) {
  199. if (!element) return null;
  200. if (element.id !== '') return `id("${element.id}")`;
  201. if (element === document.body) return `//${element.tagName}`;
  202. let ix = 0;
  203. const siblings = element.parentNode.childNodes;
  204. for (let index = 0; index < siblings.length; index += 1) {
  205. const sibling = siblings[index];
  206. if (sibling === element) {
  207. return `${generateXPath(element.parentNode)}/${element.tagName}[${
  208. ix + 1
  209. }]`;
  210. }
  211. if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
  212. ix += 1;
  213. }
  214. }
  215. return null;
  216. }
  217. function toggleHighlightElement({ index, highlight }) {
  218. state.selectedElements[index].highlight = highlight;
  219. }
  220. function getElementRect(target) {
  221. if (!target) return {};
  222. const { x, y, height, width } = target.getBoundingClientRect();
  223. return {
  224. width: width + 4,
  225. height: height + 4,
  226. x: x - 2,
  227. y: y - 2,
  228. };
  229. }
  230. function updateSelectedElements(selector) {
  231. state.elSelector = selector;
  232. try {
  233. const selectorType = state.selectorType === 'css' ? 'cssSelector' : 'xpath';
  234. let elements = findElement[selectorType]({ selector, multiple: true });
  235. const selectElements = [];
  236. if (selectorType === 'xpath') {
  237. elements = elements ? [elements] : [];
  238. }
  239. state.selectedElements = Array.from(elements).map((element, index) => {
  240. const attributes = Array.from(element.attributes).map(
  241. ({ name, value }) => ({ name, value })
  242. );
  243. const elementProps = {
  244. element,
  245. attributes,
  246. highlight: false,
  247. ...getElementRect(element),
  248. };
  249. if (element.tagName === 'SELECT') {
  250. const options = Array.from(element.querySelectorAll('option')).map(
  251. (option) => ({
  252. name: option.innerText,
  253. value: option.value,
  254. })
  255. );
  256. selectElements.push({ ...elementProps, options, index });
  257. }
  258. return elementProps;
  259. });
  260. state.selectElements = selectElements;
  261. } catch (error) {
  262. state.selectElements = [];
  263. state.selectedElements = [];
  264. }
  265. }
  266. function handleMouseMove({ clientX, clientY, target }) {
  267. if (state.isDragging) {
  268. const height = window.innerHeight;
  269. const width = document.documentElement.clientWidth;
  270. if (clientY < 10) clientY = 0;
  271. else if (cardRect.height + clientY > height)
  272. clientY = height - cardRect.height;
  273. if (clientX < 10) clientX = 0;
  274. else if (cardRect.width + clientX > width) clientX = width - cardRect.width;
  275. cardRect.x = clientX;
  276. cardRect.y = clientY;
  277. return;
  278. }
  279. if (state.hide || rootElement === target) return;
  280. Object.assign(hoverElementRect, getElementRect(target));
  281. }
  282. function handleClick(event) {
  283. const { target, path, ctrlKey, shiftKey } = event;
  284. if (target === rootElement || state.hide || state.isExecuting) return;
  285. event.stopPropagation();
  286. event.preventDefault();
  287. const attributes = Array.from(target.attributes).map(({ name, value }) => ({
  288. name,
  289. value,
  290. }));
  291. let targetElement = target;
  292. const targetElementDetail = {
  293. ...getElementRect(target),
  294. attributes,
  295. element: target,
  296. highlight: false,
  297. };
  298. if ((state.selectorType === 'css' && ctrlKey) || shiftKey) {
  299. let elementIndex = -1;
  300. const elements = state.selectedElements.map(({ element }, index) => {
  301. if (element === targetElement) {
  302. elementIndex = index;
  303. }
  304. return element;
  305. });
  306. if (elementIndex === -1) {
  307. targetElement = [...elements, target];
  308. state.selectedElements.push(targetElementDetail);
  309. } else {
  310. targetElement = elements.splice(elementIndex, 1);
  311. state.selectedElements.splice(elementIndex, 1);
  312. }
  313. } else {
  314. state.selectedElements = [targetElementDetail];
  315. }
  316. state.elSelector = getElementSelector(targetElement);
  317. selectedElement.index = 0;
  318. selectedElement.path = path;
  319. }
  320. function selectChildElement() {
  321. if (selectedElement.path.length === 0 || state.hide) return;
  322. const currentEl = selectedElement.path[selectedElement.pathIndex];
  323. let childElement = currentEl;
  324. if (selectedElement.pathIndex <= 0) {
  325. const childEl = Array.from(currentEl.children).find(
  326. (el) => !['STYLE', 'SCRIPT'].includes(el.tagName)
  327. );
  328. if (currentEl.childElementCount === 0 || currentEl === childEl) return;
  329. childElement = childEl;
  330. selectedElement.path.unshift(childEl);
  331. } else {
  332. selectedElement.pathIndex -= 1;
  333. childElement = selectedElement.path[selectedElement.pathIndex];
  334. }
  335. updateSelectedElements(getElementSelector(childElement));
  336. }
  337. function selectParentElement() {
  338. if (selectedElement.path.length === 0 || state.hide) return;
  339. const parentElement = selectedElement.path[selectedElement.pathIndex];
  340. if (parentElement.tagName === 'HTML') return;
  341. selectedElement.pathIndex += 1;
  342. updateSelectedElements(getElementSelector(parentElement));
  343. }
  344. function handleMouseUp() {
  345. if (state.isDragging) state.isDragging = false;
  346. }
  347. function updateCardSize() {
  348. setTimeout(() => {
  349. cardRect.height = cardEl.value.getBoundingClientRect().height;
  350. }, 250);
  351. }
  352. const handleScroll = debounce(() => {
  353. if (state.hide) return;
  354. const yPos = window.scrollY - lastScrollPosY;
  355. const xPos = window.scrollX - lastScrollPosX;
  356. state.selectedElements.forEach((_, index) => {
  357. state.selectedElements[index].x -= xPos;
  358. state.selectedElements[index].y -= yPos;
  359. });
  360. hoverElementRect.x -= xPos;
  361. hoverElementRect.y -= yPos;
  362. lastScrollPosX = window.scrollX;
  363. lastScrollPosY = window.scrollY;
  364. }, 100);
  365. function destroy() {
  366. rootElement.style.display = 'none';
  367. Object.assign(state, {
  368. hide: true,
  369. activeTab: '',
  370. elSelector: '',
  371. isDragging: false,
  372. isExecuting: false,
  373. selectedElements: [],
  374. });
  375. Object.assign(hoverElementRect, {
  376. x: 0,
  377. y: 0,
  378. height: 0,
  379. width: 0,
  380. });
  381. }
  382. window.addEventListener('scroll', handleScroll);
  383. window.addEventListener('mouseup', handleMouseUp);
  384. window.addEventListener('mousemove', handleMouseMove);
  385. document.addEventListener('click', handleClick, true);
  386. watch(
  387. () => state.isDragging,
  388. (value) => {
  389. document.body.toggleAttribute('automa-isDragging', value);
  390. }
  391. );
  392. watch(() => [state.elSelector, state.activeTab, state.hide], updateCardSize);
  393. nextTick(() => {
  394. setTimeout(() => {
  395. const { height, width } = cardEl.value.getBoundingClientRect();
  396. cardRect.x = window.innerWidth - (width + 35);
  397. cardRect.y = 20;
  398. cardRect.width = width;
  399. cardRect.height = height;
  400. }, 250);
  401. });
  402. </script>
  403. <style>
  404. .drag-button {
  405. transform: scale(0);
  406. transition: transform 200ms ease-in-out;
  407. }
  408. .root-card:hover .drag-button {
  409. transform: scale(1);
  410. }
  411. </style>