Browse Source

feat: add share workflow

Ahmad Kholid 3 năm trước cách đây
mục cha
commit
4d748a197f
36 tập tin đã thay đổi với 1890 bổ sung316 xóa
  1. 9 3
      package.json
  2. 15 15
      src/assets/css/tailwind.css
  3. 1 1
      src/components/block/BlockDelay.vue
  4. 21 0
      src/components/newtab/app/AppSidebar.vue
  5. 1 1
      src/components/newtab/logs/LogsDataViewer.vue
  6. 6 3
      src/components/newtab/shared/SharedCard.vue
  7. 96 0
      src/components/newtab/shared/SharedWysiwyg.vue
  8. 20 2
      src/components/newtab/workflow/WorkflowActions.vue
  9. 22 29
      src/components/newtab/workflow/WorkflowBuilder.vue
  10. 17 2
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  11. 303 0
      src/components/newtab/workflow/WorkflowShare.vue
  12. 148 0
      src/components/newtab/workflow/WorkflowSharedActions.vue
  13. 2 2
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  14. 5 1
      src/components/ui/UiButton.vue
  15. 44 39
      src/components/ui/UiDialog.vue
  16. 1 1
      src/components/ui/UiModal.vue
  17. 5 0
      src/components/ui/UiPopover.vue
  18. 31 6
      src/components/ui/UiTab.vue
  19. 50 36
      src/components/ui/UiTabs.vue
  20. 12 5
      src/composable/dialog.js
  21. 12 6
      src/composable/groupTooltip.js
  22. 1 1
      src/composable/shortcut.js
  23. 3 0
      src/content/services/web-service.js
  24. 22 0
      src/lib/v-remixicon.js
  25. 4 2
      src/locales/en/common.json
  26. 25 0
      src/locales/en/newtab.json
  27. 58 5
      src/newtab/App.vue
  28. 123 84
      src/newtab/pages/Workflows.vue
  29. 346 38
      src/newtab/pages/workflows/[id].vue
  30. 21 1
      src/store/index.js
  31. 37 0
      src/utils/api.js
  32. 2 2
      src/utils/reference-data/mustache-replacer.js
  33. 6 0
      src/utils/shared.js
  34. 11 2
      src/utils/workflow-data.js
  35. 3 1
      tailwind.config.js
  36. 407 28
      yarn.lock

+ 9 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.17.4",
+  "version": "0.17.5",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -27,6 +27,12 @@
     "@codemirror/lang-json": "^0.19.1",
     "@codemirror/theme-one-dark": "^0.19.1",
     "@medv/finder": "^2.1.0",
+    "@tiptap/extension-character-count": "^2.0.0-beta.24",
+    "@tiptap/extension-image": "^2.0.0-beta.25",
+    "@tiptap/extension-link": "^2.0.0-beta.36",
+    "@tiptap/extension-placeholder": "^2.0.0-beta.48",
+    "@tiptap/starter-kit": "^2.0.0-beta.181",
+    "@tiptap/vue-3": "^2.0.0-beta.90",
     "@vuex-orm/core": "^0.36.4",
     "compare-versions": "^4.1.2",
     "crypto-js": "^4.1.1",
@@ -37,7 +43,7 @@
     "mitt": "^3.0.0",
     "mousetrap": "^1.6.5",
     "nanoid": "^3.2.0",
-    "object-path-immutable": "^4.1.2",
+    "object-path": "^0.11.8",
     "papaparse": "^5.3.1",
     "tippy.js": "^6.3.1",
     "v-remixicon": "^0.1.1",
@@ -54,6 +60,7 @@
     "@babel/eslint-parser": "7.15.7",
     "@babel/preset-env": "7.15.6",
     "@intlify/vue-i18n-loader": "^4.0.1",
+    "@tailwindcss/typography": "^0.5.1",
     "@vue/compiler-sfc": "3.2.19",
     "archiver": "^5.3.0",
     "autoprefixer": "10.3.6",
@@ -67,7 +74,6 @@
     "eslint-config-prettier": "^8.3.0",
     "eslint-friendly-formatter": "^4.0.1",
     "eslint-import-resolver-webpack": "^0.13.2",
-    "eslint-plugin-flowtype": "6.1.0",
     "eslint-plugin-import": "^2.24.2",
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-vue": "7.18.0",

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

@@ -2,6 +2,21 @@
 @tailwind components;
 @tailwind utilities;
 
+@layer utilities {
+  .hoverable {
+    @apply hover:bg-gray-800 hover:bg-opacity-5 dark:hover:bg-gray-200 dark:hover:bg-opacity-5;
+  }
+  .bg-input {
+    @apply bg-black bg-opacity-5 hover:bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-5 dark:hover:bg-opacity-10;
+  }
+  .bg-box-transparent {
+    @apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
+  }
+  .bg-box-transparent-2 {
+    @apply bg-black bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-10;
+  }
+}
+
 :host, :root {
   --color-primary: 59 130 246;
   --color-secondary: 96 165 250;
@@ -78,18 +93,3 @@ pre {
 .ProseMirror img.ProseMirror-selectednode {
   outline: 3px solid #68CEF8;
 }
-
-@layer utilities {
-  .hoverable {
-    @apply hover:bg-gray-800 hover:bg-opacity-5 dark:hover:bg-gray-200 dark:hover:bg-opacity-5;
-  }
-  .bg-input {
-    @apply bg-black bg-opacity-5 hover:bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-5 dark:hover:bg-opacity-10;
-  }
-  .bg-box-transparent {
-    @apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
-  }
-  .bg-box-transparent-2 {
-    @apply bg-black bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-10;
-  }
-}

+ 1 - 1
src/components/block/BlockDelay.vue

@@ -3,7 +3,7 @@
     <div class="flex items-center mb-2">
       <div
         :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg"
+        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riTimerLine" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.delay.name') }}</span>

+ 21 - 0
src/components/newtab/app/AppSidebar.vue

@@ -41,6 +41,24 @@
       </router-link>
     </div>
     <div class="flex-grow"></div>
+    <ui-popover
+      v-if="store.state.user"
+      trigger="mouseenter"
+      placement="right"
+      class="mb-4"
+    >
+      <template #trigger>
+        <span class="inline-block p-1 bg-box-transparent rounded-full">
+          <img
+            :src="store.state.user.avatar_url"
+            height="32"
+            width="32"
+            class="rounded-full"
+          />
+        </span>
+      </template>
+      {{ store.state.user.username }}
+    </ui-popover>
     <router-link v-tooltip:right.group="t('settings.menu.about')" to="/about">
       <v-remixicon class="cursor-pointer" name="riInformationLine" />
     </router-link>
@@ -48,13 +66,16 @@
 </template>
 <script setup>
 import { ref } from 'vue';
+import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 
 useGroupTooltip();
+
 const { t } = useI18n();
+const store = useStore();
 const router = useRouter();
 
 const extensionVersion = chrome.runtime.getManifest().version;

+ 1 - 1
src/components/newtab/logs/LogsDataViewer.vue

@@ -6,7 +6,7 @@
       :title="t('common.fileName')"
     />
     <div class="flex-grow"></div>
-    <ui-popover>
+    <ui-popover trigger-width>
       <template #trigger>
         <ui-button variant="accent">
           <span>{{ t('log.exportData.title') }}</span>

+ 6 - 3
src/components/newtab/shared/SharedCard.vue

@@ -93,7 +93,7 @@ defineEmits(['execute', 'click', 'menuSelected']);
 
 const { t } = useI18n();
 
-const excludeTrigger = ['manual', 'on-startup'];
+const excludeTrigger = ['manual'];
 const state = shallowReactive({
   triggerText: null,
   date: dayjs(props.data.createdAt).fromNow(),
@@ -112,12 +112,15 @@ onMounted(async () => {
     text = trigger.shortcut;
   } else if (trigger.type === 'visit-web') {
     text = trigger.url;
-  } else {
+  } else if (['specific-day', 'date'].includes(trigger.type)) {
     const triggerTime = (await browser.alarms.get(id))?.scheduledTime;
 
     text = dayjs(triggerTime || Date.now()).format('DD-MMM-YYYY, hh:mm A');
   }
 
-  state.triggerText = `Trigger (${triggerName}): \n ${text}`;
+  text = text && `: \n ${text}`;
+  state.triggerText = `${t(
+    'workflow.blocks.trigger.name'
+  )} (${triggerName})${text}`;
 });
 </script>

+ 96 - 0
src/components/newtab/shared/SharedWysiwyg.vue

@@ -0,0 +1,96 @@
+<template>
+  <div class="wysiwyg-editor">
+    <slot v-if="editor" name="prepend" :editor="editor" />
+    <editor-content :editor="editor" />
+    <slot name="append" />
+  </div>
+</template>
+<script setup>
+import { shallowRef, onMounted, onBeforeUnmount, watch } from 'vue';
+import { Editor, EditorContent } from '@tiptap/vue-3';
+import StarterKit from '@tiptap/starter-kit';
+import Link from '@tiptap/extension-link';
+import Image from '@tiptap/extension-image';
+import Placeholder from '@tiptap/extension-placeholder';
+import CharacterCount from '@tiptap/extension-character-count';
+
+const props = defineProps({
+  modelValue: {
+    type: [String, Object],
+    default: null,
+  },
+  placeholder: {
+    type: String,
+    default: '',
+  },
+  limit: {
+    type: Number,
+    default: Infinity,
+  },
+  options: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:modelValue', 'count', 'change']);
+
+const editor = shallowRef(null);
+
+watch(
+  () => props.modelValue,
+  (value) => {
+    const isSame =
+      JSON.stringify(editor.value.getJSON()) === JSON.stringify(value);
+
+    if (isSame) return;
+
+    editor.value.commands.setContent(value, false);
+  }
+);
+
+onMounted(() => {
+  editor.value = new Editor({
+    content: props.modelValue,
+    onUpdate: () => {
+      const editorValue = editor.value.getJSON();
+
+      emit('count', editor.value.storage.characterCount.characters());
+      emit('change', editorValue);
+      emit('update:modelValue', editorValue);
+    },
+    extensions: [
+      Link,
+      Image,
+      StarterKit,
+      Placeholder.configure({
+        placeholder: props.placeholder,
+      }),
+      CharacterCount.configure({
+        limit: props.limit,
+      }),
+    ],
+    ...props.options,
+  });
+
+  emit('count', editor.value.storage.characterCount.characters());
+});
+onBeforeUnmount(() => {
+  editor.value?.destroy();
+});
+</script>
+<style>
+.ProseMirror pre,
+.ProseMirror code {
+  font-family: 'JetBrains Mono', monospace;
+}
+.ProseMirror:focus {
+  outline: none;
+}
+.ProseMirror p.is-editor-empty:first-child::before {
+  @apply text-gray-400;
+  content: attr(data-placeholder);
+  float: left;
+  pointer-events: none;
+  height: 0;
+}
+</style>

+ 20 - 2
src/components/newtab/workflow/WorkflowActions.vue

@@ -11,6 +11,15 @@
     </button>
   </ui-card>
   <ui-card padding="p-1 ml-4 flex items-center">
+    <button
+      v-if="!workflow.isProtected"
+      v-tooltip.group="t('workflow.share.title')"
+      :class="{ 'text-primary': data.hasShared }"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('share')"
+    >
+      <v-remixicon name="riShareLine" />
+    </button>
     <button
       v-tooltip.group="
         t(`workflow.protect.${workflow.isProtected ? 'remove' : 'title'}`)
@@ -23,8 +32,11 @@
     </button>
     <button
       v-if="!workflow.isDisabled"
-      v-tooltip.group="t('common.execute')"
-      :title="shortcuts['editor:execute-workflow'].readable"
+      v-tooltip.group="
+        `${t('common.execute')} (${
+          shortcuts['editor:execute-workflow'].readable
+        })`
+      "
       class="hoverable p-2 rounded-lg"
       @click="$emit('execute')"
     >
@@ -102,6 +114,10 @@ defineProps({
     type: Object,
     default: () => ({}),
   },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
 });
 const emit = defineEmits([
   'showModal',
@@ -112,9 +128,11 @@ const emit = defineEmits([
   'protect',
   'export',
   'update',
+  'share',
 ]);
 
 useGroupTooltip();
+
 const { t } = useI18n();
 const shortcuts = useShortcut(
   [

+ 22 - 29
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -5,33 +5,7 @@
     @drop="dropHandler"
     @dragover.prevent="handleDragOver"
   >
-    <slot></slot>
-    <div class="absolute z-10 p-4 bottom-0 left-0">
-      <button
-        v-tooltip.group="t('workflow.editor.resetZoom')"
-        class="p-2 rounded-lg bg-white dark:bg-gray-800 mr-2"
-        @click="editor.zoom_reset()"
-      >
-        <v-remixicon name="riFullscreenLine" />
-      </button>
-      <div class="rounded-lg bg-white dark:bg-gray-800 inline-block">
-        <button
-          v-tooltip.group="t('workflow.editor.zoomOut')"
-          class="p-2 rounded-lg relative z-10"
-          @click="editor.zoom_out()"
-        >
-          <v-remixicon name="riSubtractLine" />
-        </button>
-        <hr class="h-6 border-r inline-block" />
-        <button
-          v-tooltip.group="t('workflow.editor.zoomIn')"
-          class="p-2 rounded-lg"
-          @click="editor.zoom_in()"
-        >
-          <v-remixicon name="riAddLine" />
-        </button>
-      </div>
-    </div>
+    <slot v-bind="{ editor }"></slot>
     <ui-popover
       v-model="contextMenu.show"
       :options="contextMenu.position"
@@ -61,7 +35,13 @@
 </template>
 <script>
 /* eslint-disable camelcase */
-import { onMounted, shallowRef, reactive, getCurrentInstance } from 'vue';
+import {
+  onMounted,
+  shallowRef,
+  reactive,
+  getCurrentInstance,
+  watch,
+} from 'vue';
 import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
 import defu from 'defu';
@@ -78,6 +58,10 @@ export default {
       type: [Object, String],
       default: null,
     },
+    isShared: {
+      type: Boolean,
+      default: false,
+    },
     version: {
       type: String,
       default: '',
@@ -301,6 +285,12 @@ export default {
         'vue'
       );
     }
+    function checkWorkflowData() {
+      if (!editor.value) return;
+
+      editor.value.editor_mode = props.isShared ? 'fixed' : 'edit';
+      editor.value.container.classList.toggle('is-shared', props.isShared);
+    }
 
     useShortcut('editor:duplicate-block', () => {
       const selectedElement = document.querySelector('.drawflow-node.selected');
@@ -310,6 +300,8 @@ export default {
       duplicateBlock(selectedElement.id.substr(5));
     });
 
+    watch(() => props.isShared, checkWorkflowData);
+
     onMounted(() => {
       const context = getCurrentInstance().appContext.app._context;
       const element = document.querySelector('#drawflow');
@@ -361,7 +353,7 @@ export default {
             emit('save');
           }, 200);
         }
-      } else {
+      } else if (!props.isShared) {
         editor.value.addNode(
           'trigger',
           0,
@@ -425,6 +417,7 @@ export default {
         }
       });
 
+      checkWorkflowData();
       setTimeout(() => {
         editor.value.zoom_refresh();
       }, 500);

+ 17 - 2
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="px-4 flex items-start mb-2 mt-1">
-    <ui-popover class="mr-2 h-8">
+    <ui-popover :disabled="data.active === 'shared'" class="mr-2 h-8">
       <template #trigger>
         <span
           :title="t('workflow.sidebar.workflowIcon')"
@@ -47,13 +47,24 @@
   <ui-input
     id="search-input"
     v-model="query"
+    :disabled="data.active === 'shared'"
     :placeholder="`${t('common.search')}... (${
       shortcut['action:search'].readable
     })`"
     prepend-icon="riSearch2Line"
     class="px-4 mt-4 mb-2"
   />
-  <div class="scroll bg-scroll overflow-auto px-4 flex-1 overflow-auto">
+  <div
+    :class="[data.active === 'shared' ? 'overflow-hidden' : 'overflow-auto']"
+    class="scroll bg-scroll px-4 flex-1 relative"
+  >
+    <div
+      v-show="data.active === 'shared'"
+      class="absolute h-full w-full bg-white dark:bg-black bg-opacity-10 dark:bg-opacity-10 backdrop-blur rounded-lg z-10 flex items-center justify-center"
+      style="top: 0; left: 50%; transform: translateX(-50%); width: 95%"
+    >
+      <p>{{ t('workflow.cantEdit') }}</p>
+    </div>
     <template v-for="(items, catId) in blocks" :key="catId">
       <div class="flex items-center top-0 space-x-2 mb-2">
         <span
@@ -110,6 +121,10 @@ defineProps({
     type: Object,
     default: () => ({}),
   },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
   dataChanged: {
     type: Boolean,
     default: false,

+ 303 - 0
src/components/newtab/workflow/WorkflowShare.vue

@@ -0,0 +1,303 @@
+<template>
+  <ui-card class="w-full max-w-2xl share-workflow overflow-auto scroll">
+    <div v-if="!isUpdate" class="flex items-center mb-4">
+      <p>{{ t('workflow.share.title') }}</p>
+      <div class="flex-grow"></div>
+      <ui-button class="mr-2" @click="$emit('close')">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button
+        :loading="state.isPublishing"
+        variant="accent"
+        @click="publishWorkflow"
+      >
+        {{ t('workflow.share.publish') }}
+      </ui-button>
+    </div>
+    <slot name="prepend"></slot>
+    <div class="flex mb-4">
+      <input
+        v-model="state.workflow.name"
+        :placeholder="t('workflow.name')"
+        type="text"
+        name="workflow name"
+        class="font-semibold leading-none text-2xl focus:ring-0 block w-full bg-transparent mr-4 flex-1"
+      />
+      <ui-select v-model="state.workflow.category">
+        <option value="">{{ t('common.category') }} (none)</option>
+        <option
+          v-for="(category, id) in workflowCategories"
+          :key="id"
+          :value="id"
+        >
+          {{ category }}
+        </option>
+      </ui-select>
+    </div>
+    <div class="relative mb-2">
+      <ui-textarea
+        v-model="state.workflow.description"
+        :max="300"
+        placeholder="Short description"
+        class="w-full h-32 scroll resize-none"
+      />
+      <p
+        class="text-sm text-gray-600 dark:text-gray-200 absolute bottom-2 right-2"
+      >
+        {{ state.workflow.description.length }}/300
+      </p>
+    </div>
+    <shared-wysiwyg
+      v-model="state.workflow.content"
+      :placeholder="t('common.description')"
+      :limit="5000"
+      class="prose prose-zinc dark:prose-invert max-w-none content-editor p-4 bg-box-transparent rounded-lg relative"
+      @count="state.contentLength = $event"
+    >
+      <template #prepend="{ editor }">
+        <div
+          class="p-2 rounded-lg backdrop-blur flex items-center sticky top-0 z-50 bg-box-transparent space-x-1 mb-2"
+        >
+          <button
+            :class="{
+              'bg-box-transparent text-primary': editor.isActive('heading', {
+                level: 1,
+              }),
+            }"
+            title="Heading 1"
+            class="editor-menu-btn hoverable"
+            @click="editor.commands.toggleHeading({ level: 1 })"
+          >
+            <v-remixicon name="riH1" />
+          </button>
+          <button
+            :class="{
+              'bg-box-transparent text-primary': editor.isActive('heading', {
+                level: 2,
+              }),
+            }"
+            title="Heading 2"
+            class="editor-menu-btn hoverable"
+            @click="editor.commands.toggleHeading({ level: 2 })"
+          >
+            <v-remixicon name="riH2" />
+          </button>
+          <span
+            class="w-px h-5 bg-gray-300 dark:bg-gray-600"
+            style="margin: 0 12px"
+          ></span>
+          <button
+            v-for="item in menuItems"
+            :key="item.id"
+            :title="item.name"
+            :class="{
+              'bg-box-transparent text-primary': editor.isActive(item.id),
+            }"
+            class="editor-menu-btn hoverable"
+            @click="editor.chain().focus()[item.action]().run()"
+          >
+            <v-remixicon :name="item.icon" />
+          </button>
+          <span
+            class="w-px h-5 bg-gray-300 dark:bg-gray-600"
+            style="margin: 0 12px"
+          ></span>
+          <button
+            :class="{
+              'bg-box-transparent text-primary': editor.isActive('blockquote'),
+            }"
+            title="Blockquote"
+            class="editor-menu-btn hoverable"
+            @click="editor.commands.toggleBlockquote()"
+          >
+            <v-remixicon name="riDoubleQuotesL" />
+          </button>
+          <button
+            title="Insert image"
+            class="editor-menu-btn hoverable"
+            @click="insertImage(editor)"
+          >
+            <v-remixicon name="riImageLine" />
+          </button>
+          <button
+            :class="{
+              'bg-box-transparent text-primary': editor.isActive('link'),
+            }"
+            title="Link"
+            class="editor-menu-btn hoverable"
+            @click="setLink(editor)"
+          >
+            <v-remixicon name="riLinkM" />
+          </button>
+          <button
+            v-show="editor.isActive('link')"
+            title="Remove link"
+            class="editor-menu-btn hoverable"
+            @click="editor.commands.unsetLink()"
+          >
+            <v-remixicon name="riLinkUnlinkM" />
+          </button>
+        </div>
+      </template>
+      <template #append>
+        <p
+          class="text-sm text-gray-600 dark:text-gray-200 absolute bottom-2 right-2"
+        >
+          {{ state.contentLength }}/5000
+        </p>
+      </template>
+    </shared-wysiwyg>
+  </ui-card>
+</template>
+<script setup>
+import { reactive, watch } from 'vue';
+import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import { fetchApi } from '@/utils/api';
+import { workflowCategories } from '@/utils/shared';
+import { parseJSON, debounce } from '@/utils/helper';
+import { convertWorkflow } from '@/utils/workflow-data';
+import SharedWysiwyg from '@/components/newtab/shared/SharedWysiwyg.vue';
+
+const props = defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  isUpdate: Boolean,
+});
+const emit = defineEmits(['close', 'publish', 'change']);
+
+const { t } = useI18n();
+const toast = useToast();
+const store = useStore();
+
+const menuItems = [
+  { id: 'bold', name: 'Bold', icon: 'riBold', action: 'toggleBold' },
+  { id: 'italic', name: 'Italic', icon: 'riItalic', action: 'toggleItalic' },
+  {
+    id: 'strike',
+    name: 'Strikethrough',
+    icon: 'riStrikethrough2',
+    action: 'toggleStrike',
+  },
+];
+
+const state = reactive({
+  contentLength: 0,
+  isPublishing: false,
+  workflow: JSON.parse(JSON.stringify(props.workflow)),
+});
+
+async function publishWorkflow() {
+  try {
+    state.isPublishing = true;
+
+    const workflow = convertWorkflow(state.workflow, ['id', 'category']);
+    workflow.name = workflow.name || 'unnamed';
+    workflow.content = state.workflow.content || null;
+    workflow.drawflow = parseJSON(workflow.drawflow, {});
+    workflow.description = state.workflow.description.slice(0, 300);
+
+    delete workflow.extVersion;
+
+    const nodes = workflow.drawflow?.drawflow.Home.data;
+    Object.keys(nodes).forEach((nodeId) => {
+      if (nodes[nodeId].name !== 'loop-data') return;
+
+      nodes[nodeId].data.loopData = '';
+    });
+
+    const response = await fetchApi('/workflow/publish', {
+      method: 'POST',
+      body: JSON.stringify({ workflow }),
+    });
+    const result = await response.json();
+
+    if (response.status !== 200) {
+      const error = new Error(response.statusText);
+      error.data = result.data;
+
+      throw error;
+    }
+
+    workflow.drawflow = props.workflow.drawflow;
+
+    store.commit('updateStateNested', {
+      path: `sharedWorkflows.${workflow.id}`,
+      value: workflow,
+    });
+    sessionStorage.setItem(
+      'shared-workflows',
+      JSON.stringify(store.state.sharedWorkflows)
+    );
+
+    state.isPublishing = false;
+
+    emit('publish');
+  } catch (error) {
+    let errorMessage = t('message.somethingWrong');
+
+    if (error.data && error.data.show) {
+      errorMessage = error.message;
+    }
+
+    toast.error(errorMessage);
+    console.error(error);
+
+    state.isPublishing = false;
+  }
+}
+function setLink(editor) {
+  const previousUrl = editor.getAttributes('link').href;
+  const url = window.prompt('URL', previousUrl);
+
+  if (url === null) return;
+
+  if (url === '') {
+    editor.chain().focus().extendMarkRange('link').unsetLink().run();
+
+    return;
+  }
+
+  editor
+    .chain()
+    .focus()
+    .extendMarkRange('link')
+    .setLink({ href: url, target: '_blank' })
+    .run();
+}
+function insertImage(editor) {
+  const url = window.prompt('URL');
+
+  if (url) {
+    editor.chain().focus().setImage({ src: url }).run();
+  }
+}
+
+watch(
+  () => state.workflow,
+  debounce(() => {
+    emit('change', state.workflow);
+  }, 200),
+  { deep: true }
+);
+</script>
+<style scoped>
+.share-workflow {
+  min-height: 500px;
+  max-height: calc(100vh - 4rem);
+}
+.editor-menu-btn {
+  @apply p-1 rounded-md transition;
+}
+</style>
+<style>
+.content-editor .ProseMirror {
+  min-height: 200px;
+}
+.content-editor .ProseMirror :first-child {
+  margin-top: 0 !important;
+}
+</style>

+ 148 - 0
src/components/newtab/workflow/WorkflowSharedActions.vue

@@ -0,0 +1,148 @@
+<template>
+  <ui-card padding="p-1">
+    <ui-input
+      v-tooltip="t('workflow.share.url')"
+      prepend-icon="riLinkM"
+      :model-value="`https://automa.site/workflow/${workflow.id}`"
+      readonly
+      @click="$event.target.select()"
+    />
+  </ui-card>
+  <ui-card padding="p-1 ml-4">
+    <button
+      v-if="data.hasLocal"
+      v-tooltip.group="t('workflow.share.fetchLocal')"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('fetchLocal')"
+    >
+      <v-remixicon name="riRefreshLine" />
+    </button>
+    <button
+      v-if="!data.hasLocal"
+      v-tooltip.group="t('workflow.share.download')"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('insertLocal')"
+    >
+      <v-remixicon name="riDownloadLine" />
+    </button>
+    <button
+      v-tooltip.group="t('workflow.share.edit')"
+      class="hoverable p-2 rounded-lg"
+      @click="state.showModal = true"
+    >
+      <v-remixicon name="riFileEditLine" />
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 flex ml-4">
+    <button
+      v-tooltip.group="t('workflow.share.unpublish')"
+      class="hoverable p-2 mr-2 rounded-lg relative"
+      @click="$emit('unpublish')"
+    >
+      <ui-spinner
+        v-if="data.isUnpublishing"
+        color="text-accent"
+        class="absolute top-2 left-2"
+      />
+      <v-remixicon
+        name="riLock2Line"
+        :class="{ 'opacity-75': data.isUnpublishing }"
+      />
+    </button>
+    <ui-button
+      :loading="data.isUpdating"
+      :disabled="data.isUnpublishing"
+      variant="accent"
+      @click="$emit('save')"
+    >
+      <span
+        v-if="data.isChanged"
+        class="flex h-3 w-3 absolute top-0 left-0 -ml-1 -mt-1"
+      >
+        <span
+          class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
+        ></span>
+        <span
+          class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
+        ></span>
+      </span>
+      {{ t('workflow.share.update') }}
+    </ui-button>
+  </ui-card>
+  <ui-modal v-model="state.showModal" custom-content @close="updateDescription">
+    <workflow-share
+      :workflow="workflow"
+      is-update
+      @change="onDescriptionUpdated"
+    >
+      <template #prepend>
+        <div class="flex justify-between mb-6">
+          <p>{{ t('workflow.share.edit') }}</p>
+          <v-remixicon
+            name="riCloseLine"
+            class="cursor-pointer"
+            @click="
+              state.showModal = false;
+              updateDescription();
+            "
+          />
+        </div>
+      </template>
+    </workflow-share>
+  </ui-modal>
+</template>
+<script setup>
+import { shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
+
+const props = defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits([
+  'insertLocal',
+  'fetchLocal',
+  'update',
+  'save',
+  'unpublish',
+]);
+
+useGroupTooltip();
+const { t } = useI18n();
+
+const state = shallowReactive({
+  showModal: false,
+  isChanged: false,
+  name: props.workflow.name,
+  content: props.workflow.content,
+  category: props.workflow.category,
+  description: props.workflow.description,
+});
+
+function onDescriptionUpdated({ name, description, content, category }) {
+  state.isChanged = true;
+
+  state.name = name;
+  state.content = content;
+  state.category = category;
+  state.description = description;
+}
+function updateDescription() {
+  if (!state.isChanged) return;
+
+  emit('update', {
+    name: state.name,
+    content: state.content,
+    description: state.description,
+  });
+  state.isChanged = false;
+}
+</script>

+ 2 - 2
src/components/newtab/workflow/edit/EditGoogleSheets.vue

@@ -25,7 +25,7 @@
       @change="updateData({ spreadsheetId: $event })"
     >
       <template #label>
-        {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}
+        {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}*
         <a
           href="https://docs.automa.site/blocks/google-sheets.html#spreadsheet-id"
           target="_blank"
@@ -43,7 +43,7 @@
       @change="updateData({ range: $event })"
     >
       <template #label>
-        {{ t('workflow.blocks.google-sheets.range.label') }}
+        {{ t('workflow.blocks.google-sheets.range.label') }}*
         <a
           href="https://docs.automa.site/blocks/google-sheets.html#range"
           target="_blank"

+ 5 - 1
src/components/ui/UiButton.vue

@@ -22,7 +22,11 @@
     </span>
     <div v-if="loading" class="button-loading">
       <ui-spinner
-        :color="variant === 'default' ? 'text-primary' : 'text-white'"
+        :color="
+          variant === 'default'
+            ? 'text-primary'
+            : 'text-white dark:text-gray-900'
+        "
       ></ui-spinner>
     </div>
   </component>

+ 44 - 39
src/components/ui/UiDialog.vue

@@ -7,47 +7,55 @@
     <template #header>
       <h3 class="font-semibold">{{ state.options.title }}</h3>
     </template>
-    <p class="text-gray-600 dark:text-gray-200 leading-tight">
-      {{ state.options.body }}
-    </p>
-    <ui-input
-      v-if="state.type === 'prompt'"
-      v-model="state.input"
-      autofocus
-      :placeholder="state.options.placeholder"
-      :label="state.options.label"
-      :type="
-        state.options.inputType === 'password' && state.showPassword
-          ? 'text'
-          : state.options.inputType
-      "
-      class="w-full"
-    >
-      <template v-if="state.options.inputType === 'password'" #append>
-        <v-remixicon
-          :name="state.showPassword ? 'riEyeOffLine' : 'riEyeLine'"
-          class="absolute right-2"
-          @click="state.showPassword = !state.showPassword"
-        />
-      </template>
-    </ui-input>
-    <div class="mt-8 flex space-x-2">
-      <ui-button class="w-6/12" @click="fireCallback('onCancel')">
-        {{ state.options.cancelText }}
-      </ui-button>
-      <ui-button
-        class="w-6/12"
-        :variant="state.options.okVariant"
-        @click="fireCallback('onConfirm')"
+    <slot
+      v-if="state.options.custom"
+      v-bind="{ options: state.options }"
+      :name="state.type"
+    />
+    <template v-else>
+      <p class="text-gray-600 dark:text-gray-200 leading-tight">
+        {{ state.options.body }}
+      </p>
+      <ui-input
+        v-if="state.type === 'prompt'"
+        v-model="state.input"
+        autofocus
+        :placeholder="state.options.placeholder"
+        :label="state.options.label"
+        :type="
+          state.options.inputType === 'password' && state.showPassword
+            ? 'text'
+            : state.options.inputType
+        "
+        class="w-full"
       >
-        {{ state.options.okText }}
-      </ui-button>
-    </div>
+        <template v-if="state.options.inputType === 'password'" #append>
+          <v-remixicon
+            :name="state.showPassword ? 'riEyeOffLine' : 'riEyeLine'"
+            class="absolute right-2"
+            @click="state.showPassword = !state.showPassword"
+          />
+        </template>
+      </ui-input>
+      <div class="mt-8 flex space-x-2">
+        <ui-button class="w-6/12" @click="fireCallback('onCancel')">
+          {{ state.options.cancelText }}
+        </ui-button>
+        <ui-button
+          class="w-6/12"
+          :variant="state.options.okVariant"
+          @click="fireCallback('onConfirm')"
+        >
+          {{ state.options.okText }}
+        </ui-button>
+      </div>
+    </template>
   </ui-modal>
 </template>
 <script>
 import { reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import defu from 'defu';
 import emitter from '@/lib/mitt';
 
 export default {
@@ -78,10 +86,7 @@ export default {
     emitter.on('show-dialog', ({ type, options }) => {
       state.type = type;
       state.input = options?.inputValue ?? '';
-      state.options = {
-        ...defaultOptions,
-        ...options,
-      };
+      state.options = defu(options, defaultOptions);
 
       state.show = true;
     });

+ 1 - 1
src/components/ui/UiModal.vue

@@ -24,7 +24,7 @@
                 </span>
                 <v-remixicon
                   v-show="!persist"
-                  class="text-gray-600 cursor-pointer"
+                  class="text-gray-600 dark:text-gray-300 cursor-pointer"
                   name="riCloseLine"
                   size="20"
                   @click="closeModal"

+ 5 - 0
src/components/ui/UiPopover.vue

@@ -130,6 +130,11 @@ export default {
         onTrigger: () => emit('trigger'),
         ...props.options,
       });
+
+      if (props.disabled) {
+        instance.value.hide();
+        instance.value.disable();
+      }
     });
     onUnmounted(() => {
       instance.value.destroy();

+ 31 - 6
src/components/ui/UiTab.vue

@@ -2,15 +2,16 @@
   <button
     :aria-selected="uiTabs.modelValue.value === value"
     :class="[
-      uiTabs.small.value ? 'p-2' : 'py-3 px-2',
-      uiTabs.modelValue.value === value
-        ? 'border-accent dark:border-gray-100 text-gray-800 dark:text-white'
-        : '!border-transparent',
-      { 'flex-1': uiTabs.fill.value },
+      uiTabs.type.value,
+      {
+        small: uiTabs.small.value,
+        'flex-1': uiTabs.fill.value,
+        'is-active': uiTabs.modelValue.value === value,
+      },
     ]"
     :tabIndex="uiTabs.modelValue.value === value ? 0 : -1"
     aria-role="tab"
-    class="border-b-2 transition-colors z-[1] ui-tab focus:ring-0"
+    class="transition-colors z-[1] ui-tab focus:ring-0 ui-tab"
     @mouseenter="uiTabs.hoverHandler"
     @click="uiTabs.updateActive(value)"
   >
@@ -30,3 +31,27 @@ const props = defineProps({
 
 const uiTabs = inject('ui-tabs', {});
 </script>
+<style scoped>
+.ui-tab {
+  z-index: 1;
+  @apply py-3 px-2 border-b-2 border-transparent;
+}
+.ui-tab.small {
+  @apply p-2;
+}
+.ui-tab.fill {
+  @apply rounded-lg border-b-0 px-4 py-2;
+}
+.ui-tab.fill.small {
+  @apply p-2;
+}
+.ui-tab.is-active {
+  @apply border-accent dark:border-gray-100 text-gray-800 dark:text-white;
+}
+.ui-tab.is-active.fill {
+  @apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
+}
+.ui-tab.is-active {
+  @apply border-accent dark:border-gray-100 text-gray-800 dark:text-white;
+}
+</style>

+ 50 - 36
src/components/ui/UiTabs.vue

@@ -1,58 +1,72 @@
 <template>
   <div
+    :class="tabTypes[type] || tabTypes['default']"
     aria-role="tablist"
-    class="ui-tabs text-gray-600 dark:text-gray-200 border-b flex space-x-1 items-center relative"
+    class="ui-tabs text-gray-600 dark:text-gray-200 flex space-x-1 items-center relative"
     @mouseleave="showHoverIndicator = false"
   >
     <div
       v-show="showHoverIndicator"
       ref="hoverIndicator"
-      class="ui-tabs__indicator z-0 top-[5px] absolute left-0 rounded-lg bg-box-transparent"
+      class="ui-tabs__indicator z-0 absolute left-0 rounded-lg bg-box-transparent"
+      style="top: 50%; transform: translate(0, -50%)"
     ></div>
     <slot></slot>
   </div>
 </template>
-<script>
+<script setup>
 import { provide, toRefs, ref } from 'vue';
 
-export default {
-  props: {
-    modelValue: {
-      type: [String, Number],
-      default: '',
-    },
-    small: Boolean,
-    fill: Boolean,
+const props = defineProps({
+  modelValue: {
+    type: [String, Number],
+    default: '',
   },
-  emits: ['update:modelValue'],
-  setup(props, { emit }) {
-    const hoverIndicator = ref(null);
-    const showHoverIndicator = ref(false);
+  type: {
+    type: String,
+    default: 'default',
+    validator: (value) => ['default', 'fill'].includes(value),
+  },
+  small: Boolean,
+  fill: Boolean,
+});
+const emit = defineEmits(['update:modelValue']);
 
-    function updateActive(id) {
-      emit('update:modelValue', id);
-    }
-    function hoverHandler({ target }) {
-      const { height, width } = target.getBoundingClientRect();
+const tabTypes = {
+  default: 'border-b',
+  fill: 'p-2 rounded-lg bg-box-transparent',
+};
 
-      showHoverIndicator.value = true;
-      hoverIndicator.value.style.width = `${width}px`;
-      hoverIndicator.value.style.height = `${height - 11}px`;
-      hoverIndicator.value.style.transform = `translateX(${target.offsetLeft}px)`;
-    }
+const hoverIndicator = ref(null);
+const showHoverIndicator = ref(false);
 
-    provide('ui-tabs', {
-      updateActive,
-      hoverHandler,
-      ...toRefs(props),
-    });
+function updateActive(id) {
+  emit('update:modelValue', id);
+}
+function hoverHandler({ target }) {
+  const isFill = props.type === 'fill';
 
-    return {
-      hoverIndicator,
-      showHoverIndicator,
-    };
-  },
-};
+  if (target.classList.contains('is-active') && isFill) {
+    hoverIndicator.value.style.display = 'none';
+
+    return;
+  }
+
+  const { height, width } = target.getBoundingClientRect();
+  const elHeight = isFill ? height + 3 : height - 11;
+
+  showHoverIndicator.value = true;
+  hoverIndicator.value.style.width = `${width}px`;
+  hoverIndicator.value.style.height = `${elHeight}px`;
+  hoverIndicator.value.style.display = 'inline-block';
+  hoverIndicator.value.style.transform = `translate(${target.offsetLeft}px, -50%)`;
+}
+
+provide('ui-tabs', {
+  updateActive,
+  hoverHandler,
+  ...toRefs(props),
+});
 </script>
 <style>
 .ui-tabs__indicator {

+ 12 - 5
src/composable/dialog.js

@@ -1,15 +1,22 @@
 import emitter from '@/lib/mitt';
 
 export function useDialog() {
-  function confirm(options) {
-    emitter.emit('show-dialog', { type: 'confirm', options });
-  }
+  const emitDialog = (type, options = {}) => {
+    emitter.emit('show-dialog', { type, options });
+  };
 
-  function prompt(options) {
-    emitter.emit('show-dialog', { type: 'prompt', options });
+  function confirm(options = {}) {
+    emitDialog('confirm', options);
+  }
+  function prompt(options = {}) {
+    emitDialog('prompt', options);
+  }
+  function custom(type, options = {}) {
+    emitDialog(type, { ...options, custom: true });
   }
 
   return {
+    custom,
     prompt,
     confirm,
   };

+ 12 - 6
src/composable/groupTooltip.js

@@ -1,20 +1,19 @@
-import { getCurrentInstance, onMounted, shallowRef } from 'vue';
+import { getCurrentInstance, shallowRef, nextTick, onUnmounted } from 'vue';
 import { createSingleton } from 'tippy.js';
 import createTippy, { defaultOptions } from '@/lib/tippy';
 
 export function useGroupTooltip(elements, options = {}) {
   const singleton = shallowRef(null);
+  const instance = getCurrentInstance();
+  const context = instance && instance.ctx;
 
-  onMounted(() => {
+  nextTick(() => {
     let tippyInstances = [];
 
     if (Array.isArray(elements)) {
       tippyInstances = elements.map((el) => el._tippy || createTippy(el));
     } else {
-      const instance = getCurrentInstance();
-      const ctx = instance && instance.ctx;
-
-      tippyInstances = ctx._tooltipGroup || [];
+      tippyInstances = context._tooltipGroup || [];
     }
 
     singleton.value = createSingleton(tippyInstances, {
@@ -25,6 +24,13 @@ export function useGroupTooltip(elements, options = {}) {
       moveTransition: 'transform 0.2s ease-out',
       overrides: ['placement', 'theme'],
     });
+
+    if (!elements) {
+      context.__tpSingleton = singleton.value;
+    }
+  });
+  onUnmounted(() => {
+    singleton.value?.destroy();
   });
 
   return singleton;

+ 1 - 1
src/composable/shortcut.js

@@ -61,7 +61,7 @@ export function getReadableShortcut(str) {
       mac: '⌘',
     },
   };
-  const regex = new RegExp(Object.keys(list).join('|'), 'g');
+  const regex = new RegExp('option|mod', 'g');
   const replacedStr = str.replace(regex, (match) => {
     return list[match][os];
   });

+ 3 - 0
src/content/services/web-service.js

@@ -50,6 +50,9 @@ function initWebListener() {
           'workflows'
         );
 
+        workflow.table = workflow.table || workflow.dataColumns;
+        delete workflow.dataColumns;
+
         workflowsStorage.push({
           ...workflow,
           id: nanoid(),

+ 22 - 0
src/lib/v-remixicon.js

@@ -1,6 +1,17 @@
 import vRemixicon from 'v-remixicon';
 import {
+  riH1,
+  riH2,
+  riLinkM,
+  riLock2Line,
+  riLinkUnlinkM,
+  riFileEditLine,
+  riBold,
+  riItalic,
+  riStrikethrough2,
+  riDoubleQuotesL,
   riHome5Line,
+  riShareLine,
   riTable2,
   riArrowLeftRightLine,
   riFileUploadLine,
@@ -85,7 +96,18 @@ import {
 } from 'v-remixicon/icons';
 
 export const icons = {
+  riH1,
+  riH2,
+  riLinkM,
+  riLock2Line,
+  riLinkUnlinkM,
+  riFileEditLine,
+  riBold,
+  riItalic,
+  riStrikethrough2,
+  riDoubleQuotesL,
   riHome5Line,
+  riShareLine,
   riTable2,
   riArrowLeftRightLine,
   riFileUploadLine,

+ 4 - 2
src/locales/en/common.json

@@ -34,7 +34,8 @@
     "fallback": "Fallback",
     "update": "Update",
     "duplicate": "Duplicate",
-    "password": "Password"
+    "password": "Password",
+    "category": "Category"
   },
   "message": {
     "noBlock": "No block",
@@ -43,8 +44,9 @@
     "useDynamicData": "Learn how to add dynamic data",
     "delete": "Are you sure want to delete \"{name}\"?",
     "empty": "Oppss... It's looks like you don't have any items",
-    "notSaved": "Do you really want to leave? you have unsaved changes!",
     "maxSizeExceeded": "The file size is the exceeded maximum allowed",
+    "notSaved": "Do you really want to leave? you have unsaved changes!",
+    "somethingWrong": "Something went wrong",
   },
   "sort": {
     "sortBy": "Sort by",

+ 25 - 0
src/locales/en/newtab.json

@@ -11,6 +11,11 @@
     "text1": "Automa has been updated to v{version},",
     "text2": "see what's new."
   },
+  "auth": {
+    "title": "Auth",
+    "signIn": "Sign in",
+    "text": "You need to be signed in before you can do that"
+  },
   "settings": {
     "theme": "Theme",
     "language": {
@@ -47,6 +52,26 @@
     "add": "Add workflow",
     "clickToEnable": "Click to enable",
     "toggleSidebar": "Toggle sidebar",
+    "cantEdit": "Can't edit shared workflow",
+    "type": {
+      "local": "Local",
+      "shared": "Shared"
+    },
+    "unpublish": {
+      "title": "Unpublish workflow",
+      "button": "Unpublish",
+      "body": "Are you sure want to unpublish \"{name}\" workflow?"
+    },
+    "share": {
+      "url": "Share URL",
+      "publish": "Publish",
+      "title": "Share workflow",
+      "download": "Add workflow to local",
+      "edit": "Edit description",
+      "fetchLocal": "Fetch local workflow",
+      "update": "Update",
+      "unpublish": "Unpublish"
+    },
     "variables": {
       "title": "Variables",
       "name": "Variable name",

+ 58 - 5
src/newtab/App.vue

@@ -4,10 +4,27 @@
     <main class="pl-16">
       <router-view />
     </main>
-    <ui-dialog />
+    <ui-dialog>
+      <template #auth>
+        <div class="text-center">
+          <p class="font-semibold text-xl">Oops!! 😬</p>
+          <p class="mt-2 text-gray-600 dark:text-gray-200">
+            {{ t('auth.text') }}
+          </p>
+          <ui-button
+            tag="a"
+            href="https://www.automa.site/auth"
+            class="mt-6 w-full block"
+            variant="accent"
+          >
+            {{ t('auth.signIn') }}
+          </ui-button>
+        </div>
+      </template>
+    </ui-dialog>
     <div
       v-if="isUpdated"
-      class="p-4 shadow-2xl z-50 fixed bottom-8 left-1/2 -translate-x-1/2 rounded-lg bg-accent text-white flex items-center"
+      class="p-4 shadow-2xl z-50 fixed bottom-8 left-1/2 -translate-x-1/2 rounded-lg bg-accent text-white dark:text-gray-900 flex items-center"
     >
       <v-remixicon name="riInformationLine" class="mr-3" />
       <p>
@@ -21,11 +38,17 @@
       >
         {{ t('updateMessage.text2') }}
       </a>
-      <button class="ml-6 text-gray-300" @click="isUpdated = false">
+      <button
+        class="ml-6 text-gray-200 dark:text-gray-600"
+        @click="isUpdated = false"
+      >
         <v-remixicon size="20" name="riCloseLine" />
       </button>
     </div>
   </template>
+  <div v-else class="py-8 text-center">
+    <ui-spinner color="text-accent" size="28" />
+  </div>
 </template>
 <script setup>
 import { ref, onMounted } from 'vue';
@@ -34,6 +57,7 @@ import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
 import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
+import { fetchApi, getSharedWorkflows } from '@/utils/api';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 
@@ -49,6 +73,32 @@ const currentVersion = browser.runtime.getManifest().version;
 const prevVersion = localStorage.getItem('ext-version') || '0.0.0';
 const isUpdated = ref(false);
 
+async function fetchUserData() {
+  try {
+    const response = await fetchApi('/me');
+    const user = await response.json();
+
+    if (response.status !== 200 || !user) {
+      if (!user) sessionStorage.removeItem('shared-workflows');
+
+      return;
+    }
+
+    store.commit('updateState', {
+      key: 'user',
+      value: user,
+    });
+
+    const sharedWorkflows = await getSharedWorkflows();
+
+    store.commit('updateState', {
+      key: 'sharedWorkflows',
+      value: sharedWorkflows,
+    });
+  } catch (error) {
+    console.error(error);
+  }
+}
 function handleStorageChanged(change) {
   if (change.logs) {
     store.dispatch('entities/create', {
@@ -80,8 +130,11 @@ onMounted(async () => {
 
     isUpdated.value = !isFirstTime && compare(currentVersion, prevVersion, '>');
 
-    await store.dispatch('retrieve', ['workflows', 'logs', 'collections']);
-    await store.dispatch('retrieveWorkflowState');
+    await Promise.allSettled([
+      store.dispatch('retrieve', ['workflows', 'logs', 'collections']),
+      store.dispatch('retrieveWorkflowState'),
+      fetchUserData(),
+    ]);
 
     await loadLocaleMessages(store.state.settings.locale, 'newtab');
     await setI18nLanguage(store.state.settings.locale);

+ 123 - 84
src/newtab/pages/Workflows.vue

@@ -3,7 +3,7 @@
     <h1 class="text-2xl font-semibold mb-6 capitalize">
       {{ t('common.workflow', 2) }}
     </h1>
-    <div class="flex items-center mb-6 space-x-4">
+    <div class="flex items-center space-x-4">
       <ui-input
         id="search-input"
         v-model="state.query"
@@ -62,90 +62,125 @@
         {{ t('workflow.new') }}
       </ui-button>
     </div>
-    <div v-if="Workflow.all().length === 0" class="py-12 flex items-center">
-      <img src="@/assets/svg/alien.svg" class="w-96" />
-      <div class="ml-4">
-        <h1 class="text-2xl font-semibold max-w-md mb-6">
-          {{ t('message.empty') }}
-        </h1>
-        <ui-button variant="accent" @click="newWorkflow">
-          {{ t('workflow.new') }}
-        </ui-button>
-      </div>
-    </div>
-    <div v-else class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
-      <shared-card
-        v-for="workflow in workflows"
-        :key="workflow.id"
-        :data="workflow"
-        @click="$router.push(`/workflows/${$event.id}`)"
-      >
-        <template #header>
-          <div class="flex items-center mb-4">
-            <template v-if="!workflow.isDisabled">
-              <ui-img
-                v-if="workflow.icon.startsWith('http')"
-                :src="workflow.icon"
-                class="rounded-lg overflow-hidden"
-                style="height: 40px; width: 40px"
-                alt="Can not display"
-              />
-              <span v-else class="p-2 rounded-lg bg-box-transparent">
-                <v-remixicon :name="workflow.icon" />
-              </span>
-            </template>
-            <p v-else class="py-2">{{ t('common.disabled') }}</p>
-            <div class="flex-grow"></div>
-            <button
-              v-if="!workflow.isDisabled"
-              class="invisible group-hover:visible"
-              @click="executeWorkflow(workflow)"
-            >
-              <v-remixicon name="riPlayLine" />
-            </button>
-            <v-remixicon
-              v-if="workflow.isProtected"
-              name="riShieldKeyholeLine"
-              class="text-green-600 dark:text-green-400 ml-2"
-            />
-            <ui-popover v-if="!workflow.isProtected" class="h-6 ml-2">
-              <template #trigger>
-                <button>
-                  <v-remixicon name="riMoreLine" />
-                </button>
-              </template>
-              <ui-list class="space-y-1" style="min-width: 150px">
-                <ui-list-item
-                  class="cursor-pointer"
-                  @click="
-                    updateWorkflow(workflow.id, {
-                      isDisabled: !workflow.isDisabled,
-                    })
-                  "
-                >
-                  <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
-                  <span class="capitalize">
-                    {{
-                      t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`)
-                    }}
+    <ui-tabs
+      v-if="store.state.user"
+      v-model="state.activeTab"
+      class="mt-4 space-x-2"
+      type="fill"
+      style="display: inline-flex; background-color: transparent; padding: 0"
+    >
+      <ui-tab value="local">
+        {{ t('workflow.type.local') }}
+      </ui-tab>
+      <ui-tab value="shared">
+        {{ t('workflow.type.shared') }}
+      </ui-tab>
+    </ui-tabs>
+    <ui-tab-panels v-model="state.activeTab" class="mt-6">
+      <ui-tab-panel value="shared">
+        <div v-if="state.loadingShared" class="text-center">
+          <ui-spinner color="text-accent" />
+        </div>
+        <div v-else class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
+          <shared-card
+            v-for="workflow in store.state.sharedWorkflows"
+            :key="workflow.id"
+            :data="workflow"
+            @click="$router.push(`/workflows/${$event.id}?shared=true`)"
+          />
+        </div>
+      </ui-tab-panel>
+      <ui-tab-panel value="local">
+        <div v-if="Workflow.all().length === 0" class="py-12 flex items-center">
+          <img src="@/assets/svg/alien.svg" class="w-96" />
+          <div class="ml-4">
+            <h1 class="text-2xl font-semibold max-w-md mb-6">
+              {{ t('message.empty') }}
+            </h1>
+            <ui-button variant="accent" @click="newWorkflow">
+              {{ t('workflow.new') }}
+            </ui-button>
+          </div>
+        </div>
+        <div v-else class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
+          <shared-card
+            v-for="workflow in workflows"
+            :key="workflow.id"
+            :data="workflow"
+            @click="$router.push(`/workflows/${$event.id}`)"
+          >
+            <template #header>
+              <div class="flex items-center mb-4">
+                <template v-if="!workflow.isDisabled">
+                  <ui-img
+                    v-if="workflow.icon.startsWith('http')"
+                    :src="workflow.icon"
+                    class="rounded-lg overflow-hidden"
+                    style="height: 40px; width: 40px"
+                    alt="Can not display"
+                  />
+                  <span v-else class="p-2 rounded-lg bg-box-transparent">
+                    <v-remixicon :name="workflow.icon" />
                   </span>
-                </ui-list-item>
-                <ui-list-item
-                  v-for="item in menu"
-                  :key="item.id"
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="menuHandlers[item.id](workflow)"
+                </template>
+                <p v-else class="py-2">{{ t('common.disabled') }}</p>
+                <div class="flex-grow"></div>
+                <button
+                  v-if="!workflow.isDisabled"
+                  class="invisible group-hover:visible"
+                  @click="executeWorkflow(workflow)"
                 >
-                  <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-                  <span class="capitalize">{{ item.name }}</span>
-                </ui-list-item>
-              </ui-list>
-            </ui-popover>
-          </div>
-        </template>
-      </shared-card>
-    </div>
+                  <v-remixicon name="riPlayLine" />
+                </button>
+                <v-remixicon
+                  v-if="workflow.isProtected"
+                  name="riShieldKeyholeLine"
+                  class="text-green-600 dark:text-green-400 ml-2"
+                />
+                <ui-popover v-if="!workflow.isProtected" class="h-6 ml-2">
+                  <template #trigger>
+                    <button>
+                      <v-remixicon name="riMoreLine" />
+                    </button>
+                  </template>
+                  <ui-list class="space-y-1" style="min-width: 150px">
+                    <ui-list-item
+                      class="cursor-pointer"
+                      @click="
+                        updateWorkflow(workflow.id, {
+                          isDisabled: !workflow.isDisabled,
+                        })
+                      "
+                    >
+                      <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+                      <span class="capitalize">
+                        {{
+                          t(
+                            `common.${
+                              workflow.isDisabled ? 'enable' : 'disable'
+                            }`
+                          )
+                        }}
+                      </span>
+                    </ui-list-item>
+                    <ui-list-item
+                      v-for="item in menu"
+                      :key="item.id"
+                      v-close-popover
+                      class="cursor-pointer"
+                      @click="menuHandlers[item.id](workflow)"
+                    >
+                      <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+                      <span class="capitalize">{{ item.name }}</span>
+                    </ui-list-item>
+                  </ui-list>
+                </ui-popover>
+              </div>
+            </template>
+          </shared-card>
+        </div>
+      </ui-tab-panel>
+    </ui-tab-panels>
     <ui-modal v-model="workflowModal.show" title="Workflow">
       <ui-input
         v-model="workflowModal.name"
@@ -181,6 +216,7 @@
 </template>
 <script setup>
 import { computed, shallowReactive, watch } from 'vue';
+import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
@@ -189,8 +225,9 @@ import { exportWorkflow, importWorkflow } from '@/utils/workflow-data';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
 
-const dialog = useDialog();
 const { t } = useI18n();
+const store = useStore();
+const dialog = useDialog();
 
 const sorts = ['name', 'createdAt'];
 const menu = [
@@ -203,6 +240,8 @@ const menu = [
 const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
 const state = shallowReactive({
   query: '',
+  activeTab: 'local',
+  loadingShared: false,
   sortBy: savedSorts.sortBy || 'createdAt',
   sortOrder: savedSorts.sortOrder || 'desc',
   highlightBrowse: !localStorage.getItem('first-time-browse'),

+ 346 - 38
src/newtab/pages/workflows/[id].vue

@@ -37,13 +37,13 @@
       {{ t(`workflow.locked.messages.${protectionState.message}`) }}
     </p>
   </div>
-  <div v-else class="flex h-screen">
+  <div v-else-if="workflow" class="flex h-screen">
     <div
       v-if="state.showSidebar"
       class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
     >
       <workflow-edit-block
-        v-if="state.isEditBlock"
+        v-if="state.isEditBlock && workflowData.active !== 'shared'"
         :data="state.blockData"
         @update="updateBlockData"
         @close="(state.isEditBlock = false), (state.blockData = {})"
@@ -51,6 +51,7 @@
       <workflow-details-card
         v-else
         :workflow="workflow"
+        :data="workflowData"
         @update="updateWorkflow"
       />
     </div>
@@ -87,30 +88,93 @@
           </ui-tab>
         </ui-tabs>
         <div class="flex-grow"></div>
+        <workflow-shared-actions
+          v-if="workflowData.active === 'shared'"
+          :data="workflowData"
+          :workflow="workflow"
+          @insertLocal="insertToLocal"
+          @update="updateSharedWorkflow"
+          @fetchLocal="fetchLocalWorkflow"
+          @save="saveUpdatedSharedWorkflow"
+          @unpublish="unpublishSharedWorkflow"
+        />
         <workflow-actions
+          v-else
+          :data="workflowData"
           :workflow="workflow"
           :is-data-changed="state.isDataChanged"
-          @showModal="(state.modalName = $event), (state.showModal = true)"
           @save="saveWorkflow"
-          @export="workflowExporter"
-          @execute="executeWorkflow"
+          @share="shareWorkflow"
           @rename="renameWorkflow"
           @update="updateWorkflow"
           @delete="deleteWorkflow"
+          @execute="executeWorkflow"
+          @export="workflowExporter"
           @protect="toggleProtection"
+          @showModal="(state.modalName = $event), (state.showModal = true)"
         />
       </div>
       <keep-alive>
         <workflow-builder
           v-if="activeTab === 'editor' && state.drawflow !== null"
+          v-slot="{ editor: currEditor }"
           class="h-full w-full"
+          :is-shared="workflowData.active === 'shared'"
           :data="state.drawflow"
           :version="workflow.version"
           @save="saveWorkflow"
           @update="updateWorkflow"
           @load="editor = $event"
           @deleteBlock="deleteBlock"
-        />
+        >
+          <div
+            class="flex items-end absolute w-full p-4 left-0 bottom-0 justify-between"
+          >
+            <div>
+              <button
+                v-tooltip.group="t('workflow.editor.resetZoom')"
+                class="p-2 rounded-lg bg-white dark:bg-gray-800 mr-2"
+                @click="currEditor.zoom_reset()"
+              >
+                <v-remixicon name="riFullscreenLine" />
+              </button>
+              <div class="rounded-lg bg-white dark:bg-gray-800 inline-block">
+                <button
+                  v-tooltip.group="t('workflow.editor.zoomOut')"
+                  class="p-2 rounded-lg relative z-10"
+                  @click="currEditor.zoom_out()"
+                >
+                  <v-remixicon name="riSubtractLine" />
+                </button>
+                <hr class="h-6 border-r inline-block" />
+                <button
+                  v-tooltip.group="t('workflow.editor.zoomIn')"
+                  class="p-2 rounded-lg"
+                  @click="currEditor.zoom_in()"
+                >
+                  <v-remixicon name="riAddLine" />
+                </button>
+              </div>
+            </div>
+            <ui-tabs
+              v-if="
+                workflowData.hasLocal &&
+                workflowData.hasShared &&
+                !state.isDataChanged
+              "
+              v-model="workflowData.active"
+              class="bg-white dark:bg-gray-800 text-sm"
+              type="fill"
+            >
+              <ui-tab value="local">
+                {{ t('workflow.type.local') }}
+              </ui-tab>
+              <ui-tab value="shared">
+                {{ t('workflow.type.shared') }}
+              </ui-tab>
+            </ui-tabs>
+          </div>
+        </workflow-builder>
         <div v-else class="container pb-4 mt-24 px-4">
           <template v-if="activeTab === 'logs'">
             <div v-if="logs.length === 0" class="text-center">
@@ -152,11 +216,18 @@
       </keep-alive>
     </div>
   </div>
-  <ui-modal v-model="state.showModal" content-class="max-w-xl">
-    <template #header>{{ workflowModals[state.modalName].title }}</template>
+  <ui-modal
+    v-model="state.showModal"
+    :content-class="workflowModal?.width || 'max-w-xl'"
+    v-bind="workflowModal.attrs || {}"
+  >
+    <template v-if="workflowModal.title" #header>
+      {{ workflowModal.title }}
+    </template>
     <component
-      :is="workflowModals[state.modalName].component"
+      :is="workflowModal.component"
       v-bind="{ workflow }"
+      v-on="workflowModal?.events || {}"
       @update="updateWorkflow"
       @close="state.showModal = false"
     />
@@ -172,7 +243,7 @@
       v-model="renameModal.description"
       :placeholder="t('common.description')"
       height="165px"
-      class="w-full dark:text-gray-200 text-right"
+      class="w-full dark:text-gray-200"
       max="300"
     />
     <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
@@ -198,6 +269,7 @@ import {
   onMounted,
   onUnmounted,
   toRaw,
+  watch,
 } from 'vue';
 import { useStore } from 'vuex';
 import { useToast } from 'vue-toastification';
@@ -209,21 +281,24 @@ import emitter from '@/lib/mitt';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { sendMessage } from '@/utils/message';
-import { debounce, isObject } from '@/utils/helper';
 import { exportWorkflow } from '@/utils/workflow-data';
 import { tasks } from '@/utils/shared';
+import { fetchApi } from '@/utils/api';
+import { debounce, isObject, objectHasKey, parseJSON } from '@/utils/helper';
 import Log from '@/models/log';
 import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
 import Workflow from '@/models/workflow';
 import workflowTrigger from '@/utils/workflow-trigger';
+import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
 import WorkflowActions from '@/components/newtab/workflow/WorkflowActions.vue';
 import WorkflowBuilder from '@/components/newtab/workflow/WorkflowBuilder.vue';
 import WorkflowProtect from '@/components/newtab/workflow/WorkflowProtect.vue';
 import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
 import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
-import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
-import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
 import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
+import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
+import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
+import WorkflowSharedActions from '@/components/newtab/workflow/WorkflowSharedActions.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
 import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
 
@@ -235,6 +310,39 @@ const router = useRouter();
 const dialog = useDialog();
 const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
 
+const editor = shallowRef(null);
+const activeTab = shallowRef('editor');
+const state = reactive({
+  blockData: {},
+  modalName: '',
+  drawflow: null,
+  showModal: false,
+  showSidebar: true,
+  isEditBlock: false,
+  isLoadingFlow: false,
+  isDataChanged: false,
+});
+const workflowData = reactive({
+  hasLocal: true,
+  hasShared: false,
+  isChanged: false,
+  isUpdating: false,
+  isUnpublishing: false,
+  changingKeys: new Set(),
+  active: route.query.shared ? 'shared' : 'local',
+});
+const renameModal = reactive({
+  show: false,
+  name: '',
+  description: '',
+});
+const protectionState = reactive({
+  message: '',
+  password: '',
+  needed: false,
+  showPassword: false,
+});
+
 const workflowId = route.params.id;
 const workflowModals = {
   table: {
@@ -242,12 +350,35 @@ const workflowModals = {
     component: WorkflowDataTable,
     title: t('workflow.table.title'),
   },
+  'workflow-share': {
+    icon: 'riShareLine',
+    component: WorkflowShare,
+    attrs: {
+      blur: true,
+      persist: true,
+      customContent: true,
+    },
+    events: {
+      close() {
+        state.showModal = false;
+        state.modalName = '';
+      },
+      publish() {
+        workflowData.hasShared = true;
+
+        state.showModal = false;
+        state.modalName = '';
+      },
+    },
+  },
   'global-data': {
+    width: 'max-w-2xl',
     icon: 'riDatabase2Line',
     component: WorkflowGlobalData,
     title: t('common.globalData'),
   },
   'protect-workflow': {
+    width: 'max-w-lg',
     icon: 'riShieldKeyholeLine',
     component: WorkflowProtect,
     title: t('workflow.protect.title'),
@@ -259,33 +390,15 @@ const workflowModals = {
   },
 };
 
-const editor = shallowRef(null);
-const activeTab = shallowRef('editor');
-const state = reactive({
-  blockData: {},
-  modalName: '',
-  drawflow: null,
-  showModal: false,
-  showSidebar: true,
-  isEditBlock: false,
-  isDataChanged: false,
-});
-const renameModal = reactive({
-  show: false,
-  name: '',
-  description: '',
-});
-const protectionState = reactive({
-  message: '',
-  password: '',
-  needed: false,
-  showPassword: false,
-});
-
+const sharedWorkflow = computed(() => store.state.sharedWorkflows[workflowId]);
+const localWorkflow = computed(() => Workflow.find(workflowId));
+const workflow = computed(() =>
+  workflowData.active === 'local' ? localWorkflow.value : sharedWorkflow.value
+);
+const workflowModal = computed(() => workflowModals[state.modalName] || {});
 const workflowState = computed(() =>
   store.getters.getWorkflowState(workflowId)
 );
-const workflow = computed(() => Workflow.find(workflowId) || {});
 const logs = computed(() =>
   Log.query()
     .where(
@@ -296,6 +409,7 @@ const logs = computed(() =>
     .orderBy('startedAt', 'desc')
     .get()
 );
+
 const updateBlockData = debounce((data) => {
   let payload = data;
 
@@ -317,6 +431,155 @@ const updateBlockData = debounce((data) => {
       new CustomEvent('change', { detail: toRaw(payload) })
     );
 }, 250);
+
+function unpublishSharedWorkflow() {
+  dialog.confirm({
+    title: t('workflow.unpublish.title'),
+    body: t('workflow.unpublish.body', { name: workflow.value.name }),
+    okVariant: 'danger',
+    okText: t('workflow.unpublish.button'),
+    async onConfirm() {
+      try {
+        workflowData.isUnpublishing = true;
+
+        const response = await fetchApi(
+          `/me/workflows/shared?workflowId=${workflowId}`,
+          {
+            method: 'DELETE',
+          }
+        );
+
+        if (response.status !== 200) {
+          throw new Error(response.statusText);
+        }
+
+        store.commit('deleteStateNested', `sharedWorkflows.${workflowId}`);
+        sessionStorage.setItem(
+          'shared-workflows',
+          JSON.stringify(store.state.sharedWorkflows)
+        );
+
+        if (workflowData.hasLocal) {
+          workflowData.active = 'local';
+          workflowData.hasShared = false;
+        } else {
+          router.push('/');
+        }
+
+        workflowData.isUnpublishing = false;
+      } catch (error) {
+        console.error(error);
+        workflowData.isUnpublishing = false;
+        toast.error(t('message.somethingWrong'));
+      }
+    },
+  });
+}
+async function saveUpdatedSharedWorkflow() {
+  try {
+    workflowData.isUpdating = true;
+
+    const payload = {};
+    workflowData.changingKeys.forEach((key) => {
+      if (key === 'drawflow') {
+        payload.drawflow = JSON.parse(workflow.value.drawflow);
+      } else {
+        payload[key] = workflow.value[key];
+      }
+    });
+
+    const url = `/me/workflows/shared?workflowId=${workflowId}`;
+    const response = await fetchApi(url, {
+      method: 'PUT',
+      body: JSON.stringify(payload),
+    });
+
+    if (response.status !== 200) {
+      toast.error(t('message.somethingWrong'));
+      throw new Error(response.statusText);
+    }
+
+    workflowData.isChanged = false;
+    workflowData.changingKeys.clear();
+    sessionStorage.setItem(
+      'shared-workflows',
+      JSON.stringify(store.state.sharedWorkflows)
+    );
+
+    workflowData.isUpdating = false;
+  } catch (error) {
+    console.error(error);
+    workflowData.isUpdating = false;
+  }
+}
+function updateSharedWorkflow(data = {}) {
+  Object.keys(data).forEach((key) => {
+    workflowData.changingKeys.add(key);
+  });
+
+  store.commit('updateStateNested', {
+    path: `sharedWorkflows.${workflowId}`,
+    value: {
+      ...workflow.value,
+      ...data,
+    },
+  });
+  workflowData.isChanged = true;
+}
+function fetchLocalWorkflow() {
+  const localData = {};
+  const keys = [
+    'drawflow',
+    'name',
+    'description',
+    'icon',
+    'globalData',
+    'dataColumns',
+    'table',
+    'settings',
+  ];
+
+  keys.forEach((key) => {
+    if (localWorkflow.value.isProtected && key === 'drawflow') return;
+
+    localData[key] = localWorkflow.value[key];
+  });
+
+  if (localData.drawflow) {
+    editor.value.import(JSON.parse(localData.drawflow), false);
+  }
+
+  updateSharedWorkflow(localData);
+}
+function insertToLocal() {
+  const copy = {
+    ...props.workflow,
+    createdAt: Date.now(),
+    version: chrome.runtime.getManifest().version,
+  };
+
+  Workflow.insert({
+    data: copy,
+  }).then(() => {
+    workflowData.hasLocal = true;
+  });
+}
+function shareWorkflow() {
+  if (workflowData.hasShared) {
+    workflowData.active = 'shared';
+
+    return;
+  }
+
+  if (store.state.user) {
+    state.modalName = 'workflow-share';
+    state.showModal = true;
+  } else {
+    dialog.custom('auth', {
+      title: t('auth.title'),
+    });
+  }
+}
 function deleteLog(logId) {
   Log.delete(logId).then(() => {
     store.dispatch('saveToStorage', 'logs');
@@ -385,6 +648,8 @@ function deleteBlock(id) {
   state.isDataChanged = true;
 }
 function updateWorkflow(data) {
+  if (workflowData.active === 'shared') return;
+
   return Workflow.update({
     where: workflowId,
     data,
@@ -403,6 +668,8 @@ function updateNameAndDesc() {
   });
 }
 async function saveWorkflow() {
+  if (workflowData.active === 'shared') return;
+
   try {
     let flow = JSON.stringify(editor.value.export());
     const [triggerBlockId] = editor.value.getNodesFromName('trigger');
@@ -424,6 +691,8 @@ async function saveWorkflow() {
   }
 }
 function editBlock(data) {
+  if (workflowData.active === 'shared') return;
+
   state.isEditBlock = true;
   state.blockData = defu(data, tasks[data.id] || {});
 }
@@ -474,6 +743,27 @@ provide('workflow', {
   },
 });
 
+watch(
+  () => workflowData.active,
+  (value) => {
+    if (value === 'shared') {
+      state.isEditBlock = false;
+      state.blockData = {};
+    } else if (workflow.value.isProtected) {
+      protectionState.needed = true;
+      return;
+    }
+
+    let drawflow = parseJSON(workflow.value.drawflow, null);
+
+    if (!drawflow?.drawflow?.Home) {
+      drawflow = { drawflow: { Home: { data: {} } } };
+    }
+
+    editor.value.import(drawflow, false);
+  }
+);
+
 onBeforeRouteLeave(() => {
   if (!state.isDataChanged) return;
 
@@ -485,7 +775,17 @@ onBeforeRouteLeave(() => {
 onMounted(() => {
   const isWorkflowExists = Workflow.query().where('id', workflowId).exists();
 
-  if (!isWorkflowExists) {
+  workflowData.hasLocal = isWorkflowExists;
+  workflowData.hasShared = objectHasKey(
+    store.state.sharedWorkflows,
+    workflowId
+  );
+
+  const dontHaveLocal = !isWorkflowExists && workflowData.active === 'local';
+  const dontHaveShared =
+    !workflowData.hasShared && workflowData.active === 'shared';
+
+  if (dontHaveLocal || dontHaveShared) {
     router.push('/workflows');
     return;
   }
@@ -524,4 +824,12 @@ onUnmounted(() => {
 .ghost-task:not(.workflow-task) * {
   display: none;
 }
+
+.parent-drawflow.is-shared .drawflow-node * {
+  pointer-events: none;
+}
+.parent-drawflow.is-shared .drawflow-node .move-to-group,
+.parent-drawflow.is-shared .drawflow-node .menu {
+  display: none;
+}
 </style>

+ 21 - 1
src/store/index.js

@@ -1,4 +1,5 @@
 import { createStore } from 'vuex';
+import objectPath from 'object-path';
 import browser from 'webextension-polyfill';
 import vuexORM from '@/lib/vuex-orm';
 import * as models from '@/models';
@@ -7,8 +8,21 @@ import { firstWorkflows } from '@/utils/shared';
 const store = createStore({
   plugins: [vuexORM(models)],
   state: () => ({
-    contributors: null,
+    user: null,
     workflowState: [],
+    contributors: null,
+    sharedWorkflows: {
+      'Tely-7beu0zHiJrBhzrC4': {
+        id: 'Tely-7beu0zHiJrBhzrC4',
+        name: 'data columns',
+        description: 'Halo perkenalkan nama saya adalah anu 123',
+        icon: 'riGlobalLine',
+        createdAt: '2021-12-20T01:30:08.508289+00:00',
+      },
+    },
+    retrievedData: {
+      sharedWorkflows: false,
+    },
     settings: {
       locale: 'en',
     },
@@ -17,6 +31,12 @@ const store = createStore({
     updateState(state, { key, value }) {
       state[key] = value;
     },
+    updateStateNested(state, { path, value }) {
+      objectPath.set(state, path, value);
+    },
+    deleteStateNested(state, path) {
+      objectPath.del(state, path);
+    },
   },
   getters: {
     getWorkflowState: (state) => (id) =>

+ 37 - 0
src/utils/api.js

@@ -1,4 +1,5 @@
 import secrets from 'secrets';
+import { parseJSON } from './helper';
 
 export function fetchApi(path, options) {
   const urlPath = path.startsWith('/') ? path : `/${path}`;
@@ -26,3 +27,39 @@ export const googleSheets = {
     });
   },
 };
+
+export async function getSharedWorkflows(useCache = true) {
+  try {
+    const sharedWorkflowsStorage = parseJSON(
+      sessionStorage.getItem('shared-workflows'),
+      null
+    );
+
+    if (sharedWorkflowsStorage && useCache) {
+      return sharedWorkflowsStorage;
+    }
+
+    const response = await fetchApi('/me/workflows/shared?data=all');
+
+    if (response.status !== 200) throw new Error(response.statusText);
+
+    const result = await response.json();
+    const sharedWorkflows = result.reduce((acc, item) => {
+      item.drawflow = JSON.stringify(item.drawflow);
+      item.table = item.table || item.dataColumns || [];
+      item.createdAt = new Date(item.createdAt || Date.now()).getTime();
+
+      acc[item.id] = item;
+
+      return acc;
+    }, {});
+
+    sessionStorage.setItem('shared-workflows', JSON.stringify(sharedWorkflows));
+
+    return sharedWorkflows;
+  } catch (error) {
+    console.error(error);
+
+    return {};
+  }
+}

+ 2 - 2
src/utils/reference-data/mustache-replacer.js

@@ -1,4 +1,4 @@
-import { get as getObjectPath } from 'object-path-immutable';
+import objectPath from 'object-path';
 import keyParser from './key-parser';
 
 export function extractStrFunction(str) {
@@ -35,7 +35,7 @@ export default function (str, data) {
       );
     } else {
       const { dataKey, path } = keyParser(key);
-      result = getObjectPath(data[dataKey], path) ?? match;
+      result = objectPath.get(data[dataKey], path) ?? match;
     }
 
     return typeof result === 'string' ? result : JSON.stringify(result);

+ 6 - 0
src/utils/shared.js

@@ -735,6 +735,12 @@ export const firstWorkflows = [
   },
 ];
 
+export const workflowCategories = {
+  scrape: 'Scraping',
+  automation: 'Automation',
+  productivity: 'Productivity',
+};
+
 export const contentTypes = [
   { name: 'application/json', value: 'json' },
   { name: 'application/x-www-form-urlencoded', value: 'form' },

+ 11 - 2
src/utils/workflow-data.js

@@ -22,9 +22,14 @@ export function importWorkflow() {
 
             if (isWorkflowExists) return;
 
+            const currentWorkflow = workflow.includedWorkflows[workflowId];
+            currentWorkflow.table =
+              currentWorkflow.table || currentWorkflow.dataColumns;
+            delete currentWorkflow.dataColumns;
+
             Workflow.insert({
               data: {
-                ...workflow.includedWorkflows[workflowId],
+                ...currentWorkflow,
                 drawflow: getDrawflow(workflow.includedWorkflows[workflowId]),
                 id: workflowId,
                 createdAt: Date.now(),
@@ -35,6 +40,9 @@ export function importWorkflow() {
           delete workflow.includedWorkflows;
         }
 
+        workflow.table = workflow.table || workflow.dataColumns;
+        delete workflow.dataColumns;
+
         Workflow.insert({
           data: {
             ...workflow,
@@ -51,7 +59,7 @@ export function importWorkflow() {
     });
 }
 
-function convertWorkflow(workflow) {
+export function convertWorkflow(workflow, additionalKeys = []) {
   if (!workflow) return null;
 
   const keys = [
@@ -63,6 +71,7 @@ function convertWorkflow(workflow) {
     'settings',
     'globalData',
     'description',
+    ...additionalKeys,
   ];
   const content = {
     extVersion: chrome.runtime.getManifest().version,

+ 3 - 1
tailwind.config.js

@@ -1,3 +1,5 @@
+/* eslint-disable global-require */
+
 const colors = require('tailwindcss/colors');
 
 function withOpacityValue(variable) {
@@ -37,5 +39,5 @@ module.exports = {
   variants: {
     extend: {},
   },
-  plugins: [],
+  plugins: [require('@tailwindcss/typography')],
 };

+ 407 - 28
yarn.lock

@@ -1336,6 +1336,219 @@
   resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.0.tgz#6734f8ebc106a0860dff7f92bf90df193f0935d7"
   integrity sha512-zrsUxjLOKAzdewIDRWy9nsV1GQsKBCWaGwsZQlCgr6/q+vjyZhFgqedLfFBuI9anTPEUT4APq9Mu0SZBTzIcGQ==
 
+"@tailwindcss/typography@^0.5.1":
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.1.tgz#486248a9426501f11a9b0295f7cfc0eb29659c46"
+  integrity sha512-AmSzZSgLhHKlILKduU+PKBTHL6c+al82syZlRid1xgmlWwXagLigO+O++B4C0scpMfzW//f/3YCRcwwEHWoU3w==
+  dependencies:
+    lodash.castarray "^4.4.0"
+    lodash.isplainobject "^4.0.6"
+    lodash.merge "^4.6.2"
+
+"@tiptap/core@^2.0.0-beta.172":
+  version "2.0.0-beta.172"
+  resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.172.tgz#56528f6fc2532d2794ae7c26ffdd0d9a9e0c470e"
+  integrity sha512-+AUA81WdWRLqjCjSWJlVLkyjMmH3E2xbdOF8WSzrv/LXF2VuuZjlB0T/75ivMkNQUQcP+h6B89PN9ppLNU9GtQ==
+  dependencies:
+    "@types/prosemirror-commands" "^1.0.4"
+    "@types/prosemirror-keymap" "^1.0.4"
+    "@types/prosemirror-model" "^1.16.0"
+    "@types/prosemirror-schema-list" "^1.0.3"
+    "@types/prosemirror-state" "^1.2.8"
+    "@types/prosemirror-transform" "^1.1.5"
+    "@types/prosemirror-view" "^1.23.1"
+    prosemirror-commands "^1.2.1"
+    prosemirror-keymap "^1.1.5"
+    prosemirror-model "^1.16.1"
+    prosemirror-schema-list "^1.1.6"
+    prosemirror-state "^1.3.4"
+    prosemirror-transform "^1.3.3"
+    prosemirror-view "^1.23.6"
+
+"@tiptap/extension-blockquote@^2.0.0-beta.26":
+  version "2.0.0-beta.26"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.0.0-beta.26.tgz#e5ae4b7bd9376db37407a23e22080c7b11287f3b"
+  integrity sha512-A6yjcYovONJfOjQFk6vDYXswaCdCtCwjL7w9VTB0R2DLTuJvvRt9DWN0IDcMrj5G+aMgDq4GUUTitv+2Y8krDg==
+
+"@tiptap/extension-bold@^2.0.0-beta.26":
+  version "2.0.0-beta.26"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.0.0-beta.26.tgz#aa1c7850df28cec8e0614fde437183bd4ae3e66b"
+  integrity sha512-pnO0I5sEQM3pmowjMGQ74adLzvc6HqGyLyqMizaGMicPu9uTYlSdId+qckYEEgPwPMaEShtv2Vg+ZHs7KVqfcg==
+
+"@tiptap/extension-bubble-menu@^2.0.0-beta.55":
+  version "2.0.0-beta.55"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.0.0-beta.55.tgz#a26ad892cea6af9eeada22235701b06d0921af48"
+  integrity sha512-v32/QnwwRbepdbrho8mTYru1/XNW/rJi3Mjrgo3rrIs67R86aEPmhmdzD3QEQUJhAJkduuwdw8zElmVWqIJQ9w==
+  dependencies:
+    prosemirror-state "^1.3.4"
+    prosemirror-view "^1.23.6"
+    tippy.js "^6.3.7"
+
+"@tiptap/extension-bullet-list@^2.0.0-beta.26":
+  version "2.0.0-beta.26"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.0.0-beta.26.tgz#b42126d2d984c04041b14037e8d3ec1bcf16e7ec"
+  integrity sha512-1n5HV8gY1tLjPk4x48nva6SZlFHoPlRfF6pqSu9JcJxPO7FUSPxUokuz4swYNe0LRrtykfyNz44dUcxKVhoFow==
+
+"@tiptap/extension-character-count@^2.0.0-beta.24":
+  version "2.0.0-beta.24"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-character-count/-/extension-character-count-2.0.0-beta.24.tgz#8b5dba59be75343b0d660c59656acbb0a0eb4c4b"
+  integrity sha512-zMe+iNmHypvGQop5yV6xLetXvgEx7oMXJUvX+WwvtjZwx+/jJKLOzsR5EVt0vY/T5P5VCC8hkTseQhgrv4p72w==
+
+"@tiptap/extension-code-block@^2.0.0-beta.37":
+  version "2.0.0-beta.37"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.0.0-beta.37.tgz#c07c007248a21d9e0434458fd05c363b7078227f"
+  integrity sha512-mJAM+PHaNoKRYwM3D36lZ51/aoPxxvZNQn3UBnZ6G7l0ZJSgB3JvBEzqK6S8nNFeYIIxGwv4QF6vXe4MG9ie2g==
+  dependencies:
+    prosemirror-state "^1.3.4"
+
+"@tiptap/extension-code@^2.0.0-beta.26":
+  version "2.0.0-beta.26"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.0.0-beta.26.tgz#bbfa600a252ee2cded6947b56b6c4c33d998e53a"
+  integrity sha512-QcFWdEFfbJ1n5UFFBD17QPPAJ3J5p/b7XV484u0shCzywO7aNPV32QeHy1z0eMoyZtCbOWf6hg/a7Ugv8IwpHw==
+
+"@tiptap/extension-document@^2.0.0-beta.15":
+  version "2.0.0-beta.15"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.0.0-beta.15.tgz#5d17a0289244a913ab2ef08e8495a1e46950711e"
+  integrity sha512-ypENC+xUYD5m2t+KOKNYqyXnanXd5fxyIyhR1qeEEwwQwMXGNrO3kCH6O4mIDCpy+/WqHvVay2tV5dVsXnvY8w==
+
+"@tiptap/extension-dropcursor@^2.0.0-beta.25":
+  version "2.0.0-beta.25"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.0.0-beta.25.tgz#962f290a200259533a26194daca5a4b4a53e72d3"
+  integrity sha512-GYf5s6dkZtsDy+TEkrQK6kLbfbitG4qnk02D+FlhlJMI/Nnx8rYCRJbwEHDdqrfX7XwZzULMqqqHvzxZYrEeNg==
+  dependencies:
+    "@types/prosemirror-dropcursor" "^1.0.3"
+    prosemirror-dropcursor "^1.4.0"
+
+"@tiptap/extension-floating-menu@^2.0.0-beta.50":
+  version "2.0.0-beta.50"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.0.0-beta.50.tgz#e8785d5f051a848ae053ce139581dce96b951a35"
+  integrity sha512-aQu1HtthMIYEPylr6kzioLxMiObLbcgwx9xZzF03KwNnkjQLbjZOeJX2RwSYVpiVgtfPBGOm3N/br6NSYec4yQ==
+  dependencies:
+    prosemirror-state "^1.3.4"
+    prosemirror-view "^1.23.6"
+    tippy.js "^6.3.7"
+
+"@tiptap/extension-gapcursor@^2.0.0-beta.34":
+  version "2.0.0-beta.34"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.0-beta.34.tgz#0e4971affb1621934422dd5fc4bf2dd7a84f70f7"
+  integrity sha512-Vm8vMWWQ2kJcUOLfB5CEo5pYgyudI7JeeiZvX9ScPmUmgKVYhEpt3EAICY9pUYJ41aAVH35gZLXkUtsz2f9GHw==
+  dependencies:
+    "@types/prosemirror-gapcursor" "^1.0.4"
+    prosemirror-gapcursor "^1.2.1"
+
+"@tiptap/extension-hard-break@^2.0.0-beta.30":
+  version "2.0.0-beta.30"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.0.0-beta.30.tgz#165494f1194a7bad08907e6d64d349dd15851b72"
+  integrity sha512-X9xj/S+CikrbIE7ccUFVwit5QHEbflnKVxod+4zPwr1cxogFbE9AyLZE2MpYdx3z9LcnTYYi9leBqFrP4T/Olw==
+
+"@tiptap/extension-heading@^2.0.0-beta.26":
+  version "2.0.0-beta.26"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.0.0-beta.26.tgz#112b14b4d488772bda36abbf7cb2bc8aba7c42f5"
+  integrity sha512-nR6W/3rjnZH1Swo7tGBoYsmO6xMvu9MGq6jlm3WVHCB7B3CsrRvCkTwGjVIbKTaZC4bQfx5gvAUpQFvwuU+M5w==
+
+"@tiptap/extension-history@^2.0.0-beta.21":
+  version "2.0.0-beta.21"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.0.0-beta.21.tgz#5d96a17a83a7130744f0757a3275dd5b11eb1bf7"
+  integrity sha512-0v8Cl30V4dsabdpspLdk+f+lMoIvLFlJN5WRxtc7RRZ5gfJVxPHwooIKdvC51brfh/oJtWFCNMRjhoz0fRaF9A==
+  dependencies:
+    "@types/prosemirror-history" "^1.0.3"
+    prosemirror-history "^1.2.0"
+
+"@tiptap/extension-horizontal-rule@^2.0.0-beta.31":
+  version "2.0.0-beta.31"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.0.0-beta.31.tgz#efb383a6cedbbf4f2175d7d207eaeeba626faab0"
+  integrity sha512-MNc4retfjRgkv3qxqGya0+/BEd1Kmn+oMsCRvE+8x3sXyKIse+vdqMuG5qUcA6np0ZD/9hh1riiQ1GQdgc23Ng==
+  dependencies:
+    prosemirror-state "^1.3.4"
+
+"@tiptap/extension-image@^2.0.0-beta.25":
+  version "2.0.0-beta.25"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-2.0.0-beta.25.tgz#7fb001a6449a9a841ae4f42c258ad6a06022b523"
+  integrity sha512-RgW5jFVS2QNDvFhBOz7H1hY6LjYcbVAa/mE4F4c3RPg3o7GJZXNoL9s+k0QkEM2GXAvY6fX+OICMBn8TSENXKA==
+
+"@tiptap/extension-italic@^2.0.0-beta.26":
+  version "2.0.0-beta.26"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.0.0-beta.26.tgz#b00c9e32b81b1bd94eaed24bb2a22e44d5dc54a3"
+  integrity sha512-vejGe2ra4K5ipFOn1U9viqF9X9nPTX8WSJpSOux+9UbKjHpANy7bz69tp66OIi/Wh5L/MMDc+luH/04qfVnpZw==
+
+"@tiptap/extension-link@^2.0.0-beta.36":
+  version "2.0.0-beta.36"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.0.0-beta.36.tgz#184bac20f3226b8945e400ebfdce2feabb4f5a3c"
+  integrity sha512-jV0EBM/QPfR4e5FG5OPHZARnYS+CL8yhCzHO4J1Nb1i/+vRY9QpPVBruZABBwt+J+PMdq6t/6vvIXejCR3wyAg==
+  dependencies:
+    linkifyjs "^3.0.5"
+    prosemirror-model "^1.16.1"
+    prosemirror-state "^1.3.4"
+
+"@tiptap/extension-list-item@^2.0.0-beta.20":
+  version "2.0.0-beta.20"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.0.0-beta.20.tgz#7169528b226dee4590e013bdf6e5fc6d83729b0f"
+  integrity sha512-5IPEspJt38t9ROj4xLUesOVEYlTT/R9Skd9meHRxJQZX1qrzBICs5PC/WRIsnexrvTBhdxpYgCYjpvpsJBlKuQ==
+
+"@tiptap/extension-ordered-list@^2.0.0-beta.27":
+  version "2.0.0-beta.27"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.0.0-beta.27.tgz#ed48a53a9b012d578613b68375db31e8664bfdc9"
+  integrity sha512-apFDeignxdZb3cA3p1HJu0zw1JgJdBYUBz1r7f99qdNybYuk3I/1MPUvlOuOgvIrBB/wydoyVDP+v9F7QN3tfQ==
+
+"@tiptap/extension-paragraph@^2.0.0-beta.23":
+  version "2.0.0-beta.23"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.0.0-beta.23.tgz#2ab77308519494994d7a9e5a4acd14042f45f28c"
+  integrity sha512-VWAxyzecErYWk97Kv/Gkghh97zAQTcaVOisEnYYArZAlyYDaYM48qVssAC/vnRRynP2eQxb1EkppbAxE+bMHAA==
+
+"@tiptap/extension-placeholder@^2.0.0-beta.48":
+  version "2.0.0-beta.48"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.0.0-beta.48.tgz#aff02fbdcd27772ff503b5f84a2f1d83da846006"
+  integrity sha512-TZNGAHocPoV5DtB8Q5BwQU2uf5vDiwLxbgVHRAIme9P4VsVqa/U1i1TkyN5A5BVdfOzc+E4EOU7cKuyjy7DNyA==
+  dependencies:
+    prosemirror-model "^1.16.1"
+    prosemirror-state "^1.3.4"
+    prosemirror-view "^1.23.6"
+
+"@tiptap/extension-strike@^2.0.0-beta.27":
+  version "2.0.0-beta.27"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.0-beta.27.tgz#c5187bf3c28837f95a5c0c0617d0dd31c318353d"
+  integrity sha512-2dmCgtesuDdivM/54Q+Y6Tc3JbGz1SkHP6c62piuqBiYLWg3xa16zChZOhfN8szbbQlBgLT6XRTDt3c2Ux+Dug==
+
+"@tiptap/extension-text@^2.0.0-beta.15":
+  version "2.0.0-beta.15"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.0.0-beta.15.tgz#f08cff1b78f1c6996464dfba1fef8ec1e107617f"
+  integrity sha512-S3j2+HyV2gsXZP8Wg/HA+YVXQsZ3nrXgBM9HmGAxB0ESOO50l7LWfip0f3qcw1oRlh5H3iLPkA6/f7clD2/TFA==
+
+"@tiptap/starter-kit@^2.0.0-beta.181":
+  version "2.0.0-beta.181"
+  resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.181.tgz#b49eb534f5f0b7fbb2d3dcf20a7b13c1c9245853"
+  integrity sha512-PCAKThzFDg32frxvpb6Q6yhA1F1ZK+Kv0PJv5KQgw/AI451cu09nj4BcnNWgjeJ2+o36iWPp7ddZTS9Q05JlDQ==
+  dependencies:
+    "@tiptap/core" "^2.0.0-beta.172"
+    "@tiptap/extension-blockquote" "^2.0.0-beta.26"
+    "@tiptap/extension-bold" "^2.0.0-beta.26"
+    "@tiptap/extension-bullet-list" "^2.0.0-beta.26"
+    "@tiptap/extension-code" "^2.0.0-beta.26"
+    "@tiptap/extension-code-block" "^2.0.0-beta.37"
+    "@tiptap/extension-document" "^2.0.0-beta.15"
+    "@tiptap/extension-dropcursor" "^2.0.0-beta.25"
+    "@tiptap/extension-gapcursor" "^2.0.0-beta.34"
+    "@tiptap/extension-hard-break" "^2.0.0-beta.30"
+    "@tiptap/extension-heading" "^2.0.0-beta.26"
+    "@tiptap/extension-history" "^2.0.0-beta.21"
+    "@tiptap/extension-horizontal-rule" "^2.0.0-beta.31"
+    "@tiptap/extension-italic" "^2.0.0-beta.26"
+    "@tiptap/extension-list-item" "^2.0.0-beta.20"
+    "@tiptap/extension-ordered-list" "^2.0.0-beta.27"
+    "@tiptap/extension-paragraph" "^2.0.0-beta.23"
+    "@tiptap/extension-strike" "^2.0.0-beta.27"
+    "@tiptap/extension-text" "^2.0.0-beta.15"
+
+"@tiptap/vue-3@^2.0.0-beta.90":
+  version "2.0.0-beta.90"
+  resolved "https://registry.yarnpkg.com/@tiptap/vue-3/-/vue-3-2.0.0-beta.90.tgz#139bfc26ce95a47fae88f9d076876e6c3ecbd878"
+  integrity sha512-5QwYpwqomqD1DmEL9DVS0wXuVouzp+2CThXl9QzPxXXsE+pm03yjjk+VhNMiFkA+DaY0hhnrvtWU7n0o00ExRQ==
+  dependencies:
+    "@tiptap/extension-bubble-menu" "^2.0.0-beta.55"
+    "@tiptap/extension-floating-menu" "^2.0.0-beta.50"
+    prosemirror-state "^1.3.4"
+    prosemirror-view "^1.23.6"
+
 "@types/eslint-scope@^3.7.0":
   version "3.7.1"
   resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e"
@@ -1390,11 +1603,99 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
   integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==
 
+"@types/orderedmap@*":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/orderedmap/-/orderedmap-1.0.0.tgz#807455a192bba52cbbb4517044bc82bdbfa8c596"
+  integrity sha512-dxKo80TqYx3YtBipHwA/SdFmMMyLCnP+5mkEqN0eMjcTBzHkiiX0ES118DsjDBjvD+zeSsSU9jULTZ+frog+Gw==
+
 "@types/parse-json@^4.0.0":
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
   integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
 
+"@types/prosemirror-commands@*", "@types/prosemirror-commands@^1.0.4":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-commands/-/prosemirror-commands-1.0.4.tgz#d08551415127d93ae62e7239d30db0b5e7208e22"
+  integrity sha512-utDNYB3EXLjAfYIcRWJe6pn3kcQ5kG4RijbT/0Y/TFOm6yhvYS/D9eJVnijdg9LDjykapcezchxGRqFD5LcyaQ==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+    "@types/prosemirror-view" "*"
+
+"@types/prosemirror-dropcursor@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-dropcursor/-/prosemirror-dropcursor-1.0.3.tgz#49250849b8a0b86e8c29eb1ba70a463e53e46947"
+  integrity sha512-b0/8njnJ4lwyHKcGuCMf3x7r1KjxyugB1R/c2iMCjplsJHSC7UY9+OysqgJR5uUXRekUSGniiLgBtac/lvH6wg==
+  dependencies:
+    "@types/prosemirror-state" "*"
+
+"@types/prosemirror-gapcursor@^1.0.4":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-gapcursor/-/prosemirror-gapcursor-1.0.4.tgz#7df7d373edb33ea8da12084bfd462cf84cd69761"
+  integrity sha512-9xKjFIG5947dzerFvkLWp6F53JwrUYoYwh3SgcTFEp8SbSfNNrez/PFYVZKPnoqPoaK5WtTdQTaMwpCV9rXQIg==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+
+"@types/prosemirror-history@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-history/-/prosemirror-history-1.0.3.tgz#f1110efbe758129b5475e466ff077f0a8d9b964f"
+  integrity sha512-5TloMDRavgLjOAKXp1Li8u0xcsspzbT1Cm9F2pwHOkgvQOz1jWQb2VIXO7RVNsFjLBZdIXlyfSLivro3DuMWXg==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+
+"@types/prosemirror-keymap@^1.0.4":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-keymap/-/prosemirror-keymap-1.0.4.tgz#f73c79810e8d0e0a20d153d84f998f02e5afbc0c"
+  integrity sha512-ycevwkqUh+jEQtPwqO7sWGcm+Sybmhu8MpBsM8DlO3+YTKnXbKA6SDz/+q14q1wK3UA8lHJyfR+v+GPxfUSemg==
+  dependencies:
+    "@types/prosemirror-commands" "*"
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+    "@types/prosemirror-view" "*"
+
+"@types/prosemirror-model@*", "@types/prosemirror-model@^1.16.0":
+  version "1.16.1"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-model/-/prosemirror-model-1.16.1.tgz#0ce6c80cd81b398b8a11b1bf7cf695bff3160c9a"
+  integrity sha512-SrrCe2cHlYrQ9o55e2i/c3wt1yRajTTpRLvzfmB+2DWjWEbBLTByVWyjrdpKtQTxAaTeU2aeDGo1iuwl/jF27w==
+  dependencies:
+    "@types/orderedmap" "*"
+
+"@types/prosemirror-schema-list@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-schema-list/-/prosemirror-schema-list-1.0.3.tgz#bdf1893a7915fbdc5c49b3cac9368e96213d70de"
+  integrity sha512-uWybOf+M2Ea7rlbs0yLsS4YJYNGXYtn4N+w8HCw3Vvfl6wBAROzlMt0gV/D/VW/7J/LlAjwMezuGe8xi24HzXA==
+  dependencies:
+    "@types/orderedmap" "*"
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+
+"@types/prosemirror-state@*", "@types/prosemirror-state@^1.2.8":
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-state/-/prosemirror-state-1.2.8.tgz#65080eeec52f63c50bf7034377f07773b4f6b2ac"
+  integrity sha512-mq9uyQWcpu8jeamO6Callrdvf/e1H/aRLR2kZWSpZrPHctEsxWHBbluD/wqVjXBRIOoMHLf6ZvOkrkmGLoCHVA==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-transform" "*"
+    "@types/prosemirror-view" "*"
+
+"@types/prosemirror-transform@*", "@types/prosemirror-transform@^1.1.5":
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-transform/-/prosemirror-transform-1.1.5.tgz#e6949398c64a5d3ca53e6081352751aa9e9ce76e"
+  integrity sha512-Wr2HXaEF4JPklWpC17RTxE6PxyU54Taqk5FMhK1ojgcN93J+GpkYW8s0mD3rl7KfTmlhVwZPCHE9o0cYf2Go5A==
+  dependencies:
+    "@types/prosemirror-model" "*"
+
+"@types/prosemirror-view@*", "@types/prosemirror-view@^1.23.1":
+  version "1.23.1"
+  resolved "https://registry.yarnpkg.com/@types/prosemirror-view/-/prosemirror-view-1.23.1.tgz#a9a926bb6b6e6873e3a9d8caa61c32f3402629eb"
+  integrity sha512-6e1B2oKUnhmZPUrsVvYjDqeVjE6jGezygjtoHsAK4ZENAxHzHqy5NT4jUvdPTWjCYeH0t2Y7pSfRPNrPIyQX4A==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+    "@types/prosemirror-transform" "*"
+
 "@vue/compiler-core@3.2.19":
   version "3.2.19"
   resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.19.tgz#b537dd377ce51fdb64e9b30ebfbff7cd70a64cb9"
@@ -3137,14 +3438,6 @@ eslint-module-utils@^2.7.1:
     find-up "^2.1.0"
     pkg-dir "^2.0.0"
 
-eslint-plugin-flowtype@6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-6.1.0.tgz#626f44d9adbdb681644accd5fa29dffcb0d6d531"
-  integrity sha512-md72y02Gq/1mmLkW31wPpUmjT0CSYHbFA1IVfkQ1kViaFrKYGR1yCWKS1THqz0hmoIUlx8Jm7NHa4B6lDvJj3g==
-  dependencies:
-    lodash "^4.17.21"
-    string-natural-compare "^3.0.1"
-
 eslint-plugin-import@^2.24.2:
   version "2.25.3"
   resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.25.3.tgz#a554b5f66e08fb4f6dc99221866e57cfff824766"
@@ -4384,11 +4677,6 @@ is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   dependencies:
     isobject "^3.0.1"
 
-is-plain-object@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
-  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
-
 is-regex@^1.0.4, is-regex@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
@@ -4632,6 +4920,11 @@ lines-and-columns@^1.1.6:
   resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
   integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
 
+linkifyjs@^3.0.5:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-3.0.5.tgz#99e51a3a0c0e232fcb63ebb89eea3ff923378f34"
+  integrity sha512-1Y9XQH65eQKA9p2xtk+zxvnTeQBG7rdAXSkUG97DmuI/Xhji9uaUzaWxRj6rf9YC0v8KKHkxav7tnLX82Sz5Fg==
+
 lint-staged@^11.1.2:
   version "11.2.6"
   resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-11.2.6.tgz#f477b1af0294db054e5937f171679df63baa4c43"
@@ -4712,6 +5005,11 @@ locate-path@^5.0.0:
   dependencies:
     p-locate "^4.1.0"
 
+lodash.castarray@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
+  integrity sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU=
+
 lodash.clonedeep@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
@@ -5161,14 +5459,6 @@ object-keys@^1.0.12, object-keys@^1.1.1:
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
-object-path-immutable@^4.1.2:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/object-path-immutable/-/object-path-immutable-4.1.2.tgz#d78e3587f03c9a41f83dd6465cfef5a9eb390bb4"
-  integrity sha512-Bfrox46OegMkQXL872EzEjofMyBxk/2hgiy99NkCkYFegn6Dm9FvV2jY2Tnp9qLj2QL0TLii12CuPpzonkjJrA==
-  dependencies:
-    is-plain-object "^5.0.0"
-    object-path "^0.11.8"
-
 object-path@^0.11.8:
   version "0.11.8"
   resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.8.tgz#ed002c02bbdd0070b78a27455e8ae01fc14d4742"
@@ -5266,6 +5556,11 @@ optionator@^0.9.1:
     type-check "^0.4.0"
     word-wrap "^1.2.3"
 
+orderedmap@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-1.1.1.tgz#c618e77611b3b21d0fe3edc92586265e0059c789"
+  integrity sha512-3Ux8um0zXbVacKUkcytc0u3HgC0b0bBLT+I60r2J/En72cI0nZffqrA7Xtf2Hqs27j1g82llR5Mhbd0Z1XW4AQ==
+
 original@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f"
@@ -5664,6 +5959,90 @@ progress@^2.0.0:
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
   integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
 
+prosemirror-commands@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.2.1.tgz#eae0cb714df695260659b78ff5d201d3a037e50d"
+  integrity sha512-S/IkpXfpuLFsRynC2HQ5iYROUPiZskKS1+ClcWycGJvj4HMb/mVfeEkQrixYxgTl96EAh+RZQNWPC06GZXk5tQ==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-dropcursor@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.4.0.tgz#91a859d4ee79c99b1c0ba6ee61c093b195c0d9f0"
+  integrity sha512-6+YwTjmqDwlA/Dm+5wK67ezgqgjA/MhSDgaNxKUzH97SmeuWFXyLeDRxxOPZeSo7yTxcDGUCWTEjmQZsVBuMrQ==
+  dependencies:
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.1.0"
+    prosemirror-view "^1.1.0"
+
+prosemirror-gapcursor@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.2.1.tgz#02365e1bcc1ad25d390b0fb7f0e94a7fc173ad75"
+  integrity sha512-PHa9lj27iM/g4C46gxVzsefuXVfy/LrGQH4QjMRht7VDBgw77iWYWn8ZHMWSFkwtr9jQEuxI5gccHHHwWG80nw==
+  dependencies:
+    prosemirror-keymap "^1.0.0"
+    prosemirror-model "^1.0.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-view "^1.0.0"
+
+prosemirror-history@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.2.0.tgz#04cc4df8d2f7b2a46651a2780de191ada6d465ea"
+  integrity sha512-B9v9xtf4fYbKxQwIr+3wtTDNLDZcmMMmGiI3TAPShnUzvo+Rmv1GiUrsQChY1meetHl7rhML2cppF3FTs7f7UQ==
+  dependencies:
+    prosemirror-state "^1.2.2"
+    prosemirror-transform "^1.0.0"
+    rope-sequence "^1.3.0"
+
+prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.1.5.tgz#b5984c7d30f5c75956c853126c54e9e624c0327b"
+  integrity sha512-8SZgPH3K+GLsHL2wKuwBD9rxhsbnVBTwpHCO4VUO5GmqUQlxd/2GtBVWTsyLq4Dp3N9nGgPd3+lZFKUDuVp+Vw==
+  dependencies:
+    prosemirror-state "^1.0.0"
+    w3c-keyname "^2.2.0"
+
+prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.16.1:
+  version "1.16.1"
+  resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.16.1.tgz#fb388270bc9609b66298d6a7e15d0cc1d6c61253"
+  integrity sha512-r1/w0HDU40TtkXp0DyKBnFPYwd8FSlUSJmGCGFv4DeynfeSlyQF2FD0RQbVEMOe6P3PpUSXM6LZBV7W/YNZ4mA==
+  dependencies:
+    orderedmap "^1.1.0"
+
+prosemirror-schema-list@^1.1.6:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.1.6.tgz#c3e13fe2f74750e4a53ff88d798dc0c4ccca6707"
+  integrity sha512-aFGEdaCWmJzouZ8DwedmvSsL50JpRkqhQ6tcpThwJONVVmCgI36LJHtoQ4VGZbusMavaBhXXr33zyD2IVsTlkw==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.3.4:
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.3.4.tgz#4c6b52628216e753fc901c6d2bfd84ce109e8952"
+  integrity sha512-Xkkrpd1y/TQ6HKzN3agsQIGRcLckUMA9u3j207L04mt8ToRgpGeyhbVv0HI7omDORIBHjR29b7AwlATFFf2GLA==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.3.3:
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.3.4.tgz#1d1997009b7b145c2aa2773f7f670c8a3d4cb46f"
+  integrity sha512-gTsg3UIeaFuEY6+YmNPMgTpEkCKPedkFIUnsPpOMbclU701fEVI/e4VOXACXh3BO5rZJaBbEBwrnzB0mLp6eBA==
+  dependencies:
+    prosemirror-model "^1.0.0"
+
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.23.6:
+  version "1.23.6"
+  resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.23.6.tgz#f514b3166942cb70aac4ac24d0a28c21c3897608"
+  integrity sha512-B4DAzriNpI/AVoW0Lu6SVfX00jZZQxOVwdBQEjWlRbCdT9V0pvk4GQJ3JTFaib+b6BcPdRZ3MjWXz2xvV1rblA==
+  dependencies:
+    prosemirror-model "^1.16.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.1.0"
+
 proxy-addr@~2.0.5:
   version "2.0.7"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -6007,6 +6386,11 @@ rimraf@^3.0.2:
   dependencies:
     glob "^7.1.3"
 
+rope-sequence@^1.3.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.2.tgz#a19e02d72991ca71feb6b5f8a91154e48e3c098b"
+  integrity sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg==
+
 run-parallel@^1.1.9:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -6438,11 +6822,6 @@ string-argv@0.3.1:
   resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
   integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
 
-string-natural-compare@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
-  integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
-
 string-width@^3.0.0, string-width@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
@@ -6690,7 +7069,7 @@ thunky@^1.0.2:
   resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
   integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
 
-tippy.js@^6.3.1:
+tippy.js@^6.3.1, tippy.js@^6.3.7:
   version "6.3.7"
   resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
   integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
@@ -6982,7 +7361,7 @@ vuex@^4.0.2:
   dependencies:
     "@vue/devtools-api" "^6.0.0-beta.11"
 
-w3c-keyname@^2.2.4:
+w3c-keyname@^2.2.0, w3c-keyname@^2.2.4:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.4.tgz#4ade6916f6290224cdbd1db8ac49eab03d0eef6b"
   integrity sha512-tOhfEwEzFLJzf6d1ZPkYfGj+FWhIpBux9ppoP3rlclw3Z0BZv3N7b7030Z1kYth+6rDuAsXUFr+d0VE6Ed1ikw==