WorkflowBuilder.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. <template>
  2. <div
  3. id="drawflow"
  4. class="parent-drawflow relative"
  5. @drop="dropHandler"
  6. @dragover.prevent="handleDragOver"
  7. >
  8. <slot v-bind="{ editor }"></slot>
  9. <ui-popover
  10. v-model="contextMenu.show"
  11. :options="contextMenu.position"
  12. padding="p-3"
  13. >
  14. <ui-list class="space-y-1 w-52">
  15. <ui-list-item
  16. v-for="item in contextMenu.items"
  17. :key="item.id"
  18. v-close-popover
  19. class="cursor-pointer justify-between"
  20. @click="contextMenuHandler[item.event]"
  21. >
  22. <span>
  23. {{ item.name }}
  24. </span>
  25. <span
  26. v-if="item.shortcut"
  27. class="text-sm capitalize text-gray-600 dark:text-gray-200"
  28. >
  29. {{ item.shortcut }}
  30. </span>
  31. </ui-list-item>
  32. </ui-list>
  33. </ui-popover>
  34. </div>
  35. </template>
  36. <script>
  37. /* eslint-disable camelcase */
  38. import {
  39. onMounted,
  40. shallowRef,
  41. reactive,
  42. getCurrentInstance,
  43. watch,
  44. onBeforeUnmount,
  45. } from 'vue';
  46. import { useRoute } from 'vue-router';
  47. import { useI18n } from 'vue-i18n';
  48. import { compare } from 'compare-versions';
  49. import defu from 'defu';
  50. import emitter from '@/lib/mitt';
  51. import { useShortcut, getShortcut } from '@/composable/shortcut';
  52. import { tasks } from '@/utils/shared';
  53. import { parseJSON } from '@/utils/helper';
  54. import { useGroupTooltip } from '@/composable/groupTooltip';
  55. import drawflow from '@/lib/drawflow';
  56. export default {
  57. props: {
  58. data: {
  59. type: [Object, String],
  60. default: null,
  61. },
  62. isShared: {
  63. type: Boolean,
  64. default: false,
  65. },
  66. version: {
  67. type: String,
  68. default: '',
  69. },
  70. },
  71. emits: ['load', 'deleteBlock', 'update', 'save'],
  72. setup(props, { emit }) {
  73. useGroupTooltip();
  74. const { t } = useI18n();
  75. const route = useRoute();
  76. const contextMenuItems = {
  77. block: [
  78. {
  79. id: 'duplicate',
  80. name: t('workflow.editor.duplicate'),
  81. icon: 'riFileCopyLine',
  82. event: 'duplicateBlock',
  83. shortcut: getShortcut('editor:duplicate-block').readable,
  84. },
  85. {
  86. id: 'delete',
  87. name: t('common.delete'),
  88. icon: 'riDeleteBin7Line',
  89. event: 'deleteBlock',
  90. shortcut: 'Del',
  91. },
  92. ],
  93. };
  94. const editor = shallowRef(null);
  95. const contextMenu = reactive({
  96. items: [],
  97. data: null,
  98. show: false,
  99. position: {},
  100. });
  101. const workflowId = route.params.id;
  102. const prevSelectedEl = {
  103. output: null,
  104. connection: null,
  105. };
  106. const isOutputEl = (el) => el.classList.contains('output');
  107. const isConnectionEl = (el) =>
  108. el.matches('path.main-path') ||
  109. el.parentElement.classList.contains('connection');
  110. function toggleHoverClass({ target, name, active, classes }) {
  111. const prev = prevSelectedEl[name];
  112. if (active) {
  113. if (prev === target) return;
  114. target.classList.toggle(classes, true);
  115. } else if (prev) {
  116. prev.classList.toggle(classes, false);
  117. }
  118. prevSelectedEl[name] = target;
  119. }
  120. function handleDragOver({ target }) {
  121. toggleHoverClass({
  122. target,
  123. name: 'connection',
  124. classes: 'selected',
  125. active: isConnectionEl(target),
  126. });
  127. toggleHoverClass({
  128. target,
  129. name: 'output',
  130. classes: 'ring-4',
  131. active: isOutputEl(target),
  132. });
  133. }
  134. function dropHandler({ dataTransfer, clientX, clientY, target }) {
  135. const block = JSON.parse(dataTransfer.getData('block') || null);
  136. if (!block || block.fromBlockBasic) return;
  137. const isTriggerExists =
  138. block.id === 'trigger' &&
  139. editor.value.getNodesFromName('trigger').length !== 0;
  140. if (isTriggerExists) return;
  141. const xPosition =
  142. clientX *
  143. (editor.value.precanvas.clientWidth /
  144. (editor.value.precanvas.clientWidth * editor.value.zoom)) -
  145. editor.value.precanvas.getBoundingClientRect().x *
  146. (editor.value.precanvas.clientWidth /
  147. (editor.value.precanvas.clientWidth * editor.value.zoom));
  148. const yPosition =
  149. clientY *
  150. (editor.value.precanvas.clientHeight /
  151. (editor.value.precanvas.clientHeight * editor.value.zoom)) -
  152. editor.value.precanvas.getBoundingClientRect().y *
  153. (editor.value.precanvas.clientHeight /
  154. (editor.value.precanvas.clientHeight * editor.value.zoom));
  155. const blockId = editor.value.addNode(
  156. block.id,
  157. block.inputs,
  158. block.outputs,
  159. xPosition + 25,
  160. yPosition - 25,
  161. block.id,
  162. block.data,
  163. block.component,
  164. 'vue'
  165. );
  166. if (block.fromGroup) {
  167. const blockEl = document.getElementById(`node-${blockId}`);
  168. blockEl.setAttribute('group-item-id', block.itemId);
  169. }
  170. if (isConnectionEl(target)) {
  171. target.classList.remove('selected');
  172. const classes = target.parentElement.classList.toString();
  173. const result = {};
  174. const items = [
  175. { str: 'node_in_', key: 'inputId' },
  176. { str: 'input_', key: 'inputClass' },
  177. { str: 'node_out_', key: 'outputId' },
  178. { str: 'output_', key: 'outputClass' },
  179. ];
  180. items.forEach(({ key, str }) => {
  181. result[key] = classes
  182. .match(new RegExp(`${str}[^\\s]*`))[0]
  183. ?.replace(/node_in_node-|node_out_node-/, '');
  184. });
  185. try {
  186. editor.value.removeSingleConnection(
  187. result.outputId,
  188. result.inputId,
  189. result.outputClass,
  190. result.inputClass
  191. );
  192. editor.value.addConnection(
  193. result.outputId,
  194. blockId,
  195. result.outputClass,
  196. 'input_1'
  197. );
  198. editor.value.addConnection(
  199. blockId,
  200. result.inputId,
  201. 'output_1',
  202. result.inputClass
  203. );
  204. } catch (error) {
  205. // Do nothing
  206. }
  207. } else if (isOutputEl(target)) {
  208. prevSelectedEl.output?.classList.remove('ring-4');
  209. const targetBlockId = target
  210. .closest('.drawflow-node')
  211. .id.replace(/node-/, '');
  212. const outputClass = target.classList[1];
  213. const blockData = editor.value.getNodeFromId(targetBlockId);
  214. const { connections } = blockData.outputs[outputClass];
  215. if (connections[0]) {
  216. const { output, node } = connections[0];
  217. editor.value.removeSingleConnection(
  218. targetBlockId,
  219. node,
  220. outputClass,
  221. output
  222. );
  223. }
  224. editor.value.addConnection(
  225. targetBlockId,
  226. blockId,
  227. outputClass,
  228. 'input_1'
  229. );
  230. }
  231. emitter.emit('editor:data-changed');
  232. }
  233. function isInputAllowed(allowedInputs, input) {
  234. if (typeof allowedInputs === 'boolean') return allowedInputs;
  235. return allowedInputs.some((item) => {
  236. if (item.startsWith('#')) {
  237. return tasks[input].category === item.substr(1);
  238. }
  239. return item === input;
  240. });
  241. }
  242. function deleteBlock() {
  243. editor.value.removeNodeId(contextMenu.data);
  244. }
  245. function duplicateBlock(id) {
  246. const { name, pos_x, pos_y, data, html } = editor.value.getNodeFromId(
  247. id || contextMenu.data.substr(5)
  248. );
  249. if (name === 'trigger') return;
  250. const { outputs, inputs } = tasks[name];
  251. editor.value.addNode(
  252. name,
  253. inputs,
  254. outputs,
  255. pos_x + 50,
  256. pos_y + 100,
  257. name,
  258. data,
  259. html,
  260. 'vue'
  261. );
  262. }
  263. function checkWorkflowData() {
  264. if (!editor.value) return;
  265. editor.value.editor_mode = props.isShared ? 'fixed' : 'edit';
  266. editor.value.container.classList.toggle('is-shared', props.isShared);
  267. }
  268. function refreshConnection() {
  269. const nodes = document.querySelectorAll('#drawflow .drawflow-node');
  270. nodes.forEach((node) => {
  271. if (!node.id) return;
  272. editor.value.updateConnectionNodes(node.id);
  273. });
  274. }
  275. useShortcut('editor:duplicate-block', () => {
  276. const selectedElement = document.querySelector('.drawflow-node.selected');
  277. if (!selectedElement) return;
  278. duplicateBlock(selectedElement.id.substr(5));
  279. });
  280. watch(() => props.isShared, checkWorkflowData);
  281. onMounted(() => {
  282. const context = getCurrentInstance().appContext.app._context;
  283. const element = document.querySelector('#drawflow');
  284. editor.value = drawflow(element, { context, options: { reroute: true } });
  285. const editorStates =
  286. parseJSON(localStorage.getItem('editor-states'), {}) || {};
  287. const editorState = editorStates[workflowId];
  288. if (editorState) {
  289. editor.value.zoom = editorState.zoom;
  290. editor.value.canvas_x = editorState.canvas_x;
  291. editor.value.canvas_y = editorState.canvas_y;
  292. }
  293. editor.value.start();
  294. emit('load', editor.value);
  295. if (props.data) {
  296. let data =
  297. typeof props.data === 'string'
  298. ? parseJSON(props.data, null)
  299. : props.data;
  300. if (!data) return;
  301. const currentExtVersion = chrome.runtime.getManifest().version;
  302. const isOldWorkflow = compare(
  303. currentExtVersion,
  304. props.version || '0.0.0',
  305. '>'
  306. );
  307. if (isOldWorkflow) {
  308. const newDrawflowData = Object.entries(
  309. data.drawflow.Home.data
  310. ).reduce((obj, [key, value]) => {
  311. obj[key] = {
  312. ...value,
  313. html: tasks[value.name].component,
  314. data: defu({}, value.data, tasks[value.name].data),
  315. };
  316. return obj;
  317. }, {});
  318. data = {
  319. drawflow: { Home: { data: newDrawflowData } },
  320. };
  321. emit('update', { version: currentExtVersion });
  322. }
  323. editor.value.import(data);
  324. if (isOldWorkflow) {
  325. setTimeout(() => {
  326. emit('save');
  327. }, 200);
  328. }
  329. } else if (!props.isShared) {
  330. editor.value.addNode(
  331. 'trigger',
  332. 0,
  333. 1,
  334. 50,
  335. 300,
  336. 'trigger',
  337. tasks.trigger.data,
  338. 'BlockBasic',
  339. 'vue'
  340. );
  341. }
  342. editor.value.on('nodeRemoved', (id) => {
  343. emit('deleteBlock', id);
  344. });
  345. editor.value.on(
  346. 'connectionCreated',
  347. ({ output_id, input_id, output_class, input_class }) => {
  348. const { outputs } = editor.value.getNodeFromId(output_id);
  349. const { name: inputName } = editor.value.getNodeFromId(input_id);
  350. const { allowedInputs, maxConnection } = tasks[inputName];
  351. const isAllowed = isInputAllowed(allowedInputs, inputName);
  352. const isMaxConnections =
  353. outputs[output_class]?.connections.length > maxConnection;
  354. if (!isAllowed || isMaxConnections) {
  355. editor.value.removeSingleConnection(
  356. output_id,
  357. input_id,
  358. output_class,
  359. input_class
  360. );
  361. }
  362. emitter.emit('editor:data-changed');
  363. }
  364. );
  365. editor.value.on('connectionRemoved', () => {
  366. emitter.emit('editor:data-changed');
  367. });
  368. editor.value.on('contextmenu', ({ clientY, clientX, target }) => {
  369. const isBlock = target.closest('.drawflow .drawflow-node');
  370. if (isBlock) {
  371. const virtualEl = {
  372. getReferenceClientRect: () => ({
  373. width: 0,
  374. height: 0,
  375. top: clientY,
  376. right: clientX,
  377. bottom: clientY,
  378. left: clientX,
  379. }),
  380. };
  381. contextMenu.data = isBlock.id;
  382. contextMenu.position = virtualEl;
  383. contextMenu.items = contextMenuItems.block;
  384. contextMenu.show = true;
  385. }
  386. });
  387. checkWorkflowData();
  388. setTimeout(() => {
  389. editor.value.zoom_refresh();
  390. refreshConnection();
  391. }, 500);
  392. });
  393. onBeforeUnmount(() => {
  394. const editorStates =
  395. parseJSON(localStorage.getItem('editor-states'), {}) || {};
  396. editorStates[workflowId] = {
  397. zoom: editor.value.zoom,
  398. canvas_x: editor.value.canvas_x,
  399. canvas_y: editor.value.canvas_y,
  400. };
  401. localStorage.setItem('editor-states', JSON.stringify(editorStates));
  402. });
  403. return {
  404. t,
  405. editor,
  406. contextMenu,
  407. dropHandler,
  408. handleDragOver,
  409. contextMenuHandler: {
  410. deleteBlock,
  411. duplicateBlock: () => duplicateBlock(),
  412. },
  413. };
  414. },
  415. };
  416. </script>
  417. <style>
  418. #drawflow {
  419. background-image: url('@/assets/images/tile.png');
  420. background-size: 35px;
  421. }
  422. .dark #drawflow {
  423. background-image: url('@/assets/images/tile-white.png');
  424. }
  425. .drawflow .drawflow-node {
  426. @apply dark:bg-gray-800;
  427. }
  428. </style>