浏览代码

feat: save

gemercheung 7 月之前
父节点
当前提交
43aeb4dc92
共有 100 个文件被更改,包括 5271 次插入110 次删除
  1. 2 10
      packages/backend/src/modules/article/article.entity.ts
  2. 1 1
      packages/backend/src/modules/article/article.service.ts
  3. 3 2
      packages/backend/src/modules/article/dto.ts
  4. 1 1
      packages/frontend/package.json
  5. 75 8
      packages/frontend/src/views/article/add.vue
  6. 31 75
      packages/frontend/src/views/article/index.vue
  7. 16 12
      packages/frontend/src/views/category/index.vue
  8. 1 1
      packages/frontend/src/views/pms/resource/components/MenuTree.vue
  9. 14 0
      packages/vivid/README.md
  10. 103 0
      packages/vivid/package.json
  11. 81 0
      packages/vivid/src/core/components/VividColorPicker.vue
  12. 190 0
      packages/vivid/src/core/components/VividImage.vue
  13. 411 0
      packages/vivid/src/core/components/VividImagePreview.vue
  14. 74 0
      packages/vivid/src/core/components/VividMenuItem.vue
  15. 117 0
      packages/vivid/src/core/components/VividSimpleUpload.vue
  16. 269 0
      packages/vivid/src/core/extension/ai/Ai.vue
  17. 11 0
      packages/vivid/src/core/extension/ai/index.ts
  18. 31 0
      packages/vivid/src/core/extension/blockquote/BlockQuote.vue
  19. 8 0
      packages/vivid/src/core/extension/blockquote/index.ts
  20. 31 0
      packages/vivid/src/core/extension/bold/Bold.vue
  21. 8 0
      packages/vivid/src/core/extension/bold/index.ts
  22. 31 0
      packages/vivid/src/core/extension/bullet-list/BulletList.vue
  23. 8 0
      packages/vivid/src/core/extension/bullet-list/index.ts
  24. 16 0
      packages/vivid/src/core/extension/character-count/CharacterCount.vue
  25. 8 0
      packages/vivid/src/core/extension/character-count/index.ts
  26. 32 0
      packages/vivid/src/core/extension/code-block/CodeBlock.vue
  27. 73 0
      packages/vivid/src/core/extension/code-block/CodeBlockView.vue
  28. 92 0
      packages/vivid/src/core/extension/code-block/code-block.ts
  29. 4 0
      packages/vivid/src/core/extension/code-block/index.ts
  30. 31 0
      packages/vivid/src/core/extension/code/Code.vue
  31. 8 0
      packages/vivid/src/core/extension/code/index.ts
  32. 50 0
      packages/vivid/src/core/extension/color/Color.vue
  33. 8 0
      packages/vivid/src/core/extension/color/index.ts
  34. 11 0
      packages/vivid/src/core/extension/copypaste/CopyPasteExt.vue
  35. 90 0
      packages/vivid/src/core/extension/copypaste/copypaste.ts
  36. 4 0
      packages/vivid/src/core/extension/copypaste/index.ts
  37. 19 0
      packages/vivid/src/core/extension/divider/Divider.vue
  38. 3 0
      packages/vivid/src/core/extension/divider/index.ts
  39. 9 0
      packages/vivid/src/core/extension/document/Document.vue
  40. 8 0
      packages/vivid/src/core/extension/document/index.ts
  41. 370 0
      packages/vivid/src/core/extension/drag-handle/DragHandle.vue
  42. 234 0
      packages/vivid/src/core/extension/drag-handle/drag-handle.ts
  43. 3 0
      packages/vivid/src/core/extension/drag-handle/index.ts
  44. 17 0
      packages/vivid/src/core/extension/dropcursor/Dropcursor.vue
  45. 8 0
      packages/vivid/src/core/extension/dropcursor/index.ts
  46. 17 0
      packages/vivid/src/core/extension/focus/Focus.vue
  47. 8 0
      packages/vivid/src/core/extension/focus/index.ts
  48. 20 0
      packages/vivid/src/core/extension/format-clear/FormatClear.vue
  49. 3 0
      packages/vivid/src/core/extension/format-clear/index.ts
  50. 24 0
      packages/vivid/src/core/extension/fullscreen/Fullscreen.vue
  51. 3 0
      packages/vivid/src/core/extension/fullscreen/index.ts
  52. 9 0
      packages/vivid/src/core/extension/gapcursor/Gapcursor.vue
  53. 8 0
      packages/vivid/src/core/extension/gapcursor/index.ts
  54. 17 0
      packages/vivid/src/core/extension/hard-break/HardBreak.vue
  55. 8 0
      packages/vivid/src/core/extension/hard-break/index.ts
  56. 64 0
      packages/vivid/src/core/extension/heading/Heading.vue
  57. 8 0
      packages/vivid/src/core/extension/heading/index.ts
  58. 54 0
      packages/vivid/src/core/extension/highlight/HighlightExt.vue
  59. 36 0
      packages/vivid/src/core/extension/highlight/highlight.ts
  60. 4 0
      packages/vivid/src/core/extension/highlight/index.ts
  61. 17 0
      packages/vivid/src/core/extension/history/History.vue
  62. 8 0
      packages/vivid/src/core/extension/history/index.ts
  63. 32 0
      packages/vivid/src/core/extension/hocuspocus/HocuspocusExt.vue
  64. 31 0
      packages/vivid/src/core/extension/hocuspocus/hocuspocus.ts
  65. 4 0
      packages/vivid/src/core/extension/hocuspocus/index.ts
  66. 30 0
      packages/vivid/src/core/extension/horizontal-rule/HorizontalRule.vue
  67. 8 0
      packages/vivid/src/core/extension/horizontal-rule/index.ts
  68. 86 0
      packages/vivid/src/core/extension/image/ImageBubbleMenu.vue
  69. 56 0
      packages/vivid/src/core/extension/image/ImageExt.vue
  70. 186 0
      packages/vivid/src/core/extension/image/ImageView.vue
  71. 148 0
      packages/vivid/src/core/extension/image/VividImageModal.vue
  72. 105 0
      packages/vivid/src/core/extension/image/image.ts
  73. 5 0
      packages/vivid/src/core/extension/image/index.ts
  74. 17 0
      packages/vivid/src/core/extension/indent/IndentExt.vue
  75. 156 0
      packages/vivid/src/core/extension/indent/indent.ts
  76. 4 0
      packages/vivid/src/core/extension/indent/index.ts
  77. 50 0
      packages/vivid/src/core/extension/index.ts
  78. 31 0
      packages/vivid/src/core/extension/italic/Italic.vue
  79. 8 0
      packages/vivid/src/core/extension/italic/index.ts
  80. 59 0
      packages/vivid/src/core/extension/line-height/LineHeight.vue
  81. 69 0
      packages/vivid/src/core/extension/line-height/index.ts
  82. 134 0
      packages/vivid/src/core/extension/line-height/utils.ts
  83. 268 0
      packages/vivid/src/core/extension/link/LinkExt.vue
  84. 85 0
      packages/vivid/src/core/extension/link/VividLinkModal.vue
  85. 29 0
      packages/vivid/src/core/extension/link/helpers/clickHandler.ts
  86. 4 0
      packages/vivid/src/core/extension/link/index.ts
  87. 31 0
      packages/vivid/src/core/extension/link/link.ts
  88. 18 0
      packages/vivid/src/core/extension/list-item/ListItem.vue
  89. 8 0
      packages/vivid/src/core/extension/list-item/index.ts
  90. 59 0
      packages/vivid/src/core/extension/math/MathBubbleMenu.vue
  91. 47 0
      packages/vivid/src/core/extension/math/MathExt.vue
  92. 49 0
      packages/vivid/src/core/extension/math/MathView.vue
  93. 214 0
      packages/vivid/src/core/extension/math/VividMathContent.vue
  94. 43 0
      packages/vivid/src/core/extension/math/VividMathModal.vue
  95. 5 0
      packages/vivid/src/core/extension/math/index.ts
  96. 72 0
      packages/vivid/src/core/extension/math/math.ts
  97. 31 0
      packages/vivid/src/core/extension/ordered-list/OrderedList.vue
  98. 8 0
      packages/vivid/src/core/extension/ordered-list/index.ts
  99. 158 0
      packages/vivid/src/core/extension/outline/OutlineExt.vue
  100. 0 0
      packages/vivid/src/core/extension/outline/index.ts

+ 2 - 10
packages/backend/src/modules/article/article.entity.ts

@@ -52,14 +52,6 @@ export class Article {
   @UpdateDateColumn()
   updateTime: Date;
 
-  // @ManyToMany(() => User, (user) => user.roles, {
-  //   createForeignKeyConstraints: false,
-  // })
-  // users: User[];
-
-  // @ManyToMany(() => Permission, (permission) => permission.roles, {
-  //   createForeignKeyConstraints: false,
-  // })
-  // @JoinTable()
-  // permissions: Permission[];
+  @Column({ nullable: true })
+  categoryId: number;
 }

+ 1 - 1
packages/backend/src/modules/article/article.service.ts

@@ -49,7 +49,7 @@ export class ArticleService {
       relations: { user: true, category: true },
       order: {
         // title: 'ASC',
-        updateTime: 'ASC',
+        createTime: 'DESC',
       },
       take: pageSize,
       skip: (pageNo - 1) * pageSize,

+ 3 - 2
packages/backend/src/modules/article/dto.ts

@@ -32,8 +32,9 @@ export class CreateArticleDto {
   @IsNumber()
   categoryId: number;
 
-  @ApiProperty({ required: false })
+  @ApiProperty({ required: false, default: true })
   @IsBoolean()
+  @IsOptional()
   isShow?: boolean;
 
   @ApiProperty({ required: false })
@@ -60,7 +61,7 @@ export class GetArticleDto {
   enable?: boolean;
 }
 
-export class QueryArticleDto extends GetArticleDto {}
+export class QueryArticleDto extends GetArticleDto { }
 
 export class UpdateArticleDto {
   @ApiProperty()

+ 1 - 1
packages/frontend/package.json

@@ -12,8 +12,8 @@
     "up": "taze major -I"
   },
   "dependencies": {
+    "@4dkankan/vivid": "workspace:*",
     "@arco-design/color": "^0.4.0",
-    "@codecoderun/vivid": "^0.0.23",
     "@vueuse/core": "^12.0.0",
     "axios": "^1.7.9",
     "cherry-markdown": "^0.8.57",

+ 75 - 8
packages/frontend/src/views/article/add.vue

@@ -6,24 +6,91 @@
       </NButton>
     </template>
 
-    <!-- <n-space size="large"> -->
     <div class="editor-wrap">
-      <VividEditor v-model:value="data" :dark="true" />
-    </div>
+      <n-form
+        ref="modalFormRef" class="form wh-full" label-placement="left" label-align="left" :label-width="80"
+        :model="modalForm"
+      >
+        <n-form-item
+          label="文章名称" path="title" :rule="{
+            required: true,
+            message: '请输入文章名称',
+            trigger: ['input', 'blur'],
+          }"
+        >
+          <n-input v-model:value="modalForm.title" />
+        </n-form-item>
+
+        <n-form-item
+          label="文章分类" path="categoryId" :rule="{
+            required: true,
+            type: 'number',
+            trigger: ['change', 'blur'],
+            message: '请输入文章分类',
+          }"
+        >
+          <n-select
+            v-model:value="modalForm.categoryId" :options="allCategory" clearable filterable tag
+            style="max-width: 300px;"
+          />
+        </n-form-item>
 
-    <!-- </n-space> -->
+        <VividEditor v-model="modalForm.content" :dark="isDark">
+          <SlashCommand />
+          <DragHandle />
+        </VividEditor>
+      </n-form>
+    </div>
   </CommonPage>
 </template>
 
 <script setup>
-import { VividEditor } from '@codecoderun/vivid'
+import { DragHandle, SlashCommand, VividEditor } from '@4dkankan/vivid'
+import { useUserStore } from '@/store/index.js'
+import { useDark } from '@vueuse/core'
+import { NButton, useThemeVars } from 'naive-ui'
 import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import categoryApi from '../category/api'
+import articleApi from './api'
+import '@4dkankan/vivid/dist/style.css'
+
+const isDark = useDark()
+const vars = useThemeVars()
+const modalFormRef = ref('')
+const { userId } = useUserStore()
+const router = useRouter()
+const modalForm = ref({
+  title: '',
+  categoryId: null,
+  content: '',
+  userId,
+})
 
-const articleValue = ref('')
-const data = ref('')
 onMounted(() => {
   console.log('VividEditor', VividEditor)
 })
+const allCategory = ref([])
+categoryApi.getAll().then(({ data = [] }) => (allCategory.value = data.map(item => ({ label: item.title, value: item.id }))))
 
-function handleAdd() { }
+function handleAdd() {
+  modalFormRef.value?.validate((errors) => {
+    if (!errors) {
+      articleApi.create(modalForm.value)
+      $message.success('保存成功!')
+      router.push('/article')
+    }
+    else {
+      $message.error('请填写对应项!')
+      console.log('errors', errors)
+    }
+  })
+}
 </script>
+
+<style>
+.editor-wrap {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 31 - 75
packages/frontend/src/views/article/index.vue

@@ -7,91 +7,26 @@
       </NButton>
     </template>
 
-    <MeCrud
-      ref="$table"
-      v-model:query-items="queryItems"
-      :scroll-x="1200"
-      :columns="columns"
-      :get-data="api.read"
-    >
+    <MeCrud ref="$table" v-model:query-items="queryItems" :scroll-x="1200" :columns="columns" :get-data="api.read">
       <MeQueryItem label="标题" :label-width="50">
         <n-input v-model:value="queryItems.title" type="text" placeholder="请输入标题名" clearable>
           <template #password-visible-icon />
         </n-input>
       </MeQueryItem>
       <MeQueryItem label="状态" :label-width="50">
-        <n-select
-          v-model:value="queryItems.enable"
-          clearable
-          :options="[
-            { label: '启用', value: 1 },
-            { label: '停用', value: 0 },
-          ]"
-        />
+        <n-select v-model:value="queryItems.enable" clearable :options="[
+          { label: '启用', value: 1 },
+          { label: '停用', value: 0 },
+        ]" />
       </MeQueryItem>
     </MeCrud>
-
-    <MeModal ref="modalRef" width="520px">
-      <n-form
-        ref="modalFormRef"
-        label-placement="left"
-        label-align="left"
-        :label-width="80"
-        :model="modalForm"
-      >
-        <n-form-item
-          label="角色名"
-          path="name"
-          :rule="{
-            required: true,
-            message: '请输入角色名',
-            trigger: ['input', 'blur'],
-          }"
-        >
-          <n-input v-model:value="modalForm.name" />
-        </n-form-item>
-        <n-form-item
-          label="角色编码"
-          path="code"
-          :rule="{
-            required: true,
-            message: '请输入角色编码',
-            trigger: ['input', 'blur'],
-          }"
-        >
-          <n-input v-model:value="modalForm.code" :disabled="modalAction !== 'add'" />
-        </n-form-item>
-        <n-form-item label="权限" path="permissionIds">
-          <n-tree
-            key-field="id"
-            label-field="name"
-            :selectable="false"
-            :data="permissionTree"
-            :checked-keys="modalForm.permissionIds"
-            :on-update:checked-keys="(keys) => (modalForm.permissionIds = keys)"
-
-            checkable check-on-click default-expand-all
-            class="cus-scroll max-h-200 w-full"
-          />
-        </n-form-item>
-        <n-form-item label="状态" path="enable">
-          <NSwitch v-model:value="modalForm.enable">
-            <template #checked>
-              启用
-            </template>
-            <template #unchecked>
-              停用
-            </template>
-          </NSwitch>
-        </n-form-item>
-      </n-form>
-    </MeModal>
   </CommonPage>
 </template>
 
 <script setup>
-import { MeCrud, MeModal, MeQueryItem } from '@/components'
+import { MeCrud, MeQueryItem } from '@/components'
 import { useCrud } from '@/composables'
+import { formatDateTime } from '@/utils'
 import { NButton, NSwitch } from 'naive-ui'
 import api from './api'
 
@@ -107,7 +42,7 @@ onMounted(() => {
   $table.value?.handleSearch()
 })
 
-const { modalRef, modalFormRef, modalAction, modalForm, handleAdd, handleDelete, handleEdit }
+const { handleDelete }
   = useCrud({
     name: '文章',
     doCreate: api.create,
@@ -118,9 +53,19 @@ const { modalRef, modalFormRef, modalAction, modalForm, handleAdd, handleDelete,
   })
 
 const columns = [
-  { title: '标题名', key: 'title' },
+  { title: '标题名', key: 'title', width: '200' },
   { title: '分类', key: 'category.title' },
-  { title: '内容', key: 'content' },
+  {
+    title: '内容',
+    key: 'content',
+    width: '400',
+    render: row => h('div', htmlspecialchars(row.content)),
+  },
+  {
+    title: '创建时间',
+    key: 'createTime',
+    render: row => h('span', formatDateTime(row.createTime)),
+  },
   {
     title: '状态',
     key: 'enable',
@@ -196,6 +141,17 @@ async function handleEnable(row) {
     row.enableLoading = false
   }
 }
+function htmlspecialchars(str) {
+  const div = document.createElement('div')
+  div.innerHTML = str
+  const text = div.textContent || ''
+  return text.length > 150 ? `${text.substring(0, 150)}...` : text
+}
+
+function handleEdit() {
+  console.log('222')
+  // router.push('')
+}
 
 const permissionTree = ref([])
 api.getAllPermissionTree().then(({ data = [] }) => (permissionTree.value = data))

+ 16 - 12
packages/frontend/src/views/category/index.vue

@@ -14,20 +14,24 @@
         </n-input>
       </MeQueryItem>
       <MeQueryItem label="状态" :label-width="50">
-        <n-select v-model:value="queryItems.enable" clearable :options="[
-          { label: '启用', value: 1 },
-          { label: '停用', value: 0 },
-        ]" />
+        <n-select
+          v-model:value="queryItems.enable" clearable :options="[
+            { label: '启用', value: 1 },
+            { label: '停用', value: 0 },
+          ]"
+        />
       </MeQueryItem>
     </MeCrud>
 
     <MeModal ref="modalRef" width="520px">
       <n-form ref="modalFormRef" label-placement="left" label-align="left" :label-width="80" :model="modalForm">
-        <n-form-item label="分类名" path="title" :rule="{
-          required: true,
-          message: '请输入分类名',
-          trigger: ['input', 'blur'],
-        }">
+        <n-form-item
+          label="分类名" path="title" :rule="{
+            required: true,
+            message: '请输入分类名',
+            trigger: ['input', 'blur'],
+          }"
+        >
           <n-input v-model:value="modalForm.title" />
         </n-form-item>
         <n-form-item label="上层分类" path="parentId">
@@ -165,13 +169,13 @@ async function handleEnable(row) {
     row.enableLoading = false
     $message.success('操作成功')
     $table.value?.handleSearch()
-  } catch (error) {
+  }
+  catch (error) {
     console.error(error)
     row.enableLoading = false
   }
 }
 
 const allCategory = ref([])
-api.getAll().then(({ data = [] }) => (allCategory.value = data.map(item => ({ label: item.title, value: item.id }))))
-
+api.getAll().then(({ data = [] }) => (allCategory.value = data.map(item => ({ label: item.title, value: item.id }))))
 </script>

+ 1 - 1
packages/frontend/src/views/pms/resource/components/MenuTree.vue

@@ -29,7 +29,7 @@
         key-field="code"
         label-field="name"
 
-        default-expand-all block-line
+        block-line default-expand-all
       />
     </n-space>
 

+ 14 - 0
packages/vivid/README.md

@@ -0,0 +1,14 @@
+# VividEditor
+
+旨在基于tiptap实现一个现代的编辑器
+
+## todo
+1. 工具条可以配置
+2. 悬浮气泡工具条可以配置开启和关闭
+3. 工具条可以扩展
+4. 页面模式:固定宽度,仿照word,
+5. 内容样式可以修改
+6. ai支持
+7. 协作支持
+8. SlashCommand 
+9. 行高

+ 103 - 0
packages/vivid/package.json

@@ -0,0 +1,103 @@
+{
+	"name": "@4dkankan/vivid",
+	"description": "modern editor powered by tiptap & naive ui",
+	"version": "0.0.23",
+	"private": false,
+	"license": "MIT",
+	"type": "module",
+	"main": "dist/index.cjs",
+	"module": "dist/index.mjs",
+	"typings": "types/index.d.ts",
+	"files": [
+		"dist",
+		"src",
+		"types",
+		"README.md"
+	],
+	"scripts": {
+		"dev-server": "pnpm --parallel vite",
+		"dev-types": "vue-tsc  --noEmit -p tsconfig.app.json --watch",
+		"build": "pnpm build-lib && pnpm build-types",
+		"build-lib": "vite build",
+		"build-types": "vue-tsc --emitDeclarationOnly --declaration -p tsconfig.app.json",
+		"publish": "npm publish --access public",
+		"lint": "eslint 'src/**/*.{ts,vue}'",
+		"format": "prettier --write \"src/**/*.ts\" \"src/**/*.vue\""
+	},
+	"dependencies": {
+		"@hocuspocus/provider": "^2.8.1",
+		"@iconify/vue": "^4.1.2",
+		"@jamescoyle/vue-icon": "^0.1.2",
+		"@mdi/js": "^7.4.47",
+		"@tiptap/core": "^2.4.0",
+		"@tiptap/extension-blockquote": "^2.4.0",
+		"@tiptap/extension-bold": "^2.4.0",
+		"@tiptap/extension-bubble-menu": "^2.4.0",
+		"@tiptap/extension-bullet-list": "^2.4.0",
+		"@tiptap/extension-character-count": "^2.4.0",
+		"@tiptap/extension-code": "^2.4.0",
+		"@tiptap/extension-code-block": "^2.4.0",
+		"@tiptap/extension-code-block-lowlight": "^2.4.0",
+		"@tiptap/extension-collaboration": "^2.4.0",
+		"@tiptap/extension-collaboration-cursor": "^2.4.0",
+		"@tiptap/extension-color": "^2.4.0",
+		"@tiptap/extension-document": "^2.4.0",
+		"@tiptap/extension-dropcursor": "^2.4.0",
+		"@tiptap/extension-focus": "^2.3.2",
+		"@tiptap/extension-gapcursor": "^2.4.0",
+		"@tiptap/extension-hard-break": "^2.4.0",
+		"@tiptap/extension-heading": "^2.4.0",
+		"@tiptap/extension-highlight": "^2.4.0",
+		"@tiptap/extension-history": "^2.4.0",
+		"@tiptap/extension-horizontal-rule": "^2.4.0",
+		"@tiptap/extension-image": "^2.4.0",
+		"@tiptap/extension-italic": "^2.4.0",
+		"@tiptap/extension-link": "^2.4.0",
+		"@tiptap/extension-list-item": "^2.4.0",
+		"@tiptap/extension-ordered-list": "^2.4.0",
+		"@tiptap/extension-paragraph": "^2.4.0",
+		"@tiptap/extension-placeholder": "^2.4.0",
+		"@tiptap/extension-strike": "^2.4.0",
+		"@tiptap/extension-subscript": "^2.4.0",
+		"@tiptap/extension-superscript": "^2.4.0",
+		"@tiptap/extension-table": "^2.4.0",
+		"@tiptap/extension-table-cell": "^2.4.0",
+		"@tiptap/extension-table-header": "^2.4.0",
+		"@tiptap/extension-table-row": "^2.4.0",
+		"@tiptap/extension-task-item": "^2.4.0",
+		"@tiptap/extension-task-list": "^2.4.0",
+		"@tiptap/extension-text": "^2.4.0",
+		"@tiptap/extension-text-align": "^2.4.0",
+		"@tiptap/extension-text-style": "^2.4.0",
+		"@tiptap/extension-underline": "^2.4.0",
+		"@tiptap/pm": "^2.4.0",
+		"@tiptap/suggestion": "^2.4.0",
+		"@tiptap/vue-3": "^2.4.0",
+		"axios": "^1.6.8",
+		"linkifyjs": "^4.1.3",
+		"openai": "^4.47.1",
+		"prosemirror-commands": "^1.5.2",
+		"prosemirror-keymap": "^1.2.2",
+		"prosemirror-model": "^1.21.0",
+		"prosemirror-state": "^1.4.3",
+		"prosemirror-tables": "^1.3.7",
+		"prosemirror-transform": "^1.9.0",
+		"prosemirror-view": "^1.33.6",
+		"tippy.js": "^6.3.7",
+		"vue3-moveable": "^0.28.0"
+	},
+	"devDependencies": {
+		"@vueuse/core": "^8.0.1",
+		"animate.css": "^4.1.1",
+		"highlight.js": "^11.8.0",
+		"katex": "^0.16.4",
+		"lowlight": "^2.8.0",
+		"naive-ui": "^2.35.0",
+		"remixicon": "^3.7.0",
+		"sass": "^1.75.0",
+		"vfonts": "^0.0.3",
+		"vite": "^4.5.0",
+		"vitest": "^0.34.6",
+		"vue": "^3.0.4"
+	}
+}

+ 81 - 0
packages/vivid/src/core/components/VividColorPicker.vue

@@ -0,0 +1,81 @@
+<script setup>
+	import { NColorPicker } from "naive-ui";
+
+	defineProps({
+		defaultColor: {
+			type: String,
+			default: "#000",
+		},
+	});
+
+	const emit = defineEmits(["change"]);
+
+	function change(e) {
+		emit("change", e);
+	}
+</script>
+
+<template>
+	<button class="hb-tiptap-color-picker">
+		<n-color-picker
+			:render-label="() => null"
+			:default-value="defaultColor"
+			placement="bottom"
+			show-preview
+			:swatches="[
+				'#3366FF',
+				'#8ACE14',
+				'#33C4F4',
+				'#FFB949',
+				'#FF4235',
+				'#FC33FF',
+				'#FF6D83',
+				'#FFE605',
+			]"
+			@update:value="change"
+		/>
+	</button>
+</template>
+
+<style scoped>
+	.hb-tiptap-color-picker {
+		position: relative;
+		color: #0d0d0d;
+		border: none;
+		background-color: transparent;
+		border-radius: 3px;
+		padding: 5px;
+		transition: all 0.2s;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		overflow: hidden;
+	}
+
+	.icon-box {
+		position: absolute;
+		pointer-events: none;
+		inset: 0;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+	}
+
+	::v-deep(.n-color-picker) {
+		width: 18px;
+		height: 18px;
+		border: 2px solid white;
+		border-radius: 50%;
+		overflow: hidden;
+	}
+
+	::v-deep(.n-color-picker-trigger) {
+		border: none;
+	}
+
+	::v-deep(.n-color-picker-trigger .n-color-picker-trigger__fill) {
+		inset: 0;
+		border-radius: 2px;
+		overflow: hidden;
+	}
+</style>

+ 190 - 0
packages/vivid/src/core/components/VividImage.vue

@@ -0,0 +1,190 @@
+<script setup>
+import { NIcon, useThemeVars } from "naive-ui";
+import VividImagePreview from "./VividImagePreview.vue";
+
+/**
+ * 功能说明:
+ * 设置宽高后,图片将按比例缩放到完整显示较小的边,长的边会进行裁剪
+ * */
+
+import { nextTick, onMounted, ref } from "vue";
+const themeVars = useThemeVars();
+
+const props = defineProps({
+	src: {
+		type: String,
+		default: "",
+	},
+	prevSrc: {
+		type: String,
+		default: "",
+	},
+	height: {
+		type: Number,
+		default: null,
+	},
+	width: {
+		type: Number,
+		default: null,
+	},
+	radius: {
+		type: String,
+		default: "0px",
+	},
+	isPreview: {
+		type: Boolean,
+		default: false,
+	},
+	crossOrigin: {
+		type: Boolean,
+		default: false,
+	},
+});
+
+const HbImgWrap = ref();
+const HbImg = ref();
+let imgHeight = 0;
+let imgWidth = 0;
+
+function calcImg() {
+	// 图片宽高比
+	const WHPercent = imgWidth / imgHeight;
+	// 图片高宽比
+	const HWPercent = imgHeight / imgWidth;
+	// 容器高度
+	let divHeight = imgHeight;
+	if (props.height) {
+		divHeight = props.height;
+	}
+	// 容器宽度
+	let divWidth = imgWidth;
+	if (props.width) {
+		divWidth = props.width;
+	}
+	// 设置容器高度
+	HbImgWrap.value.style.height = divHeight + "px";
+	// 设置容器宽度
+	HbImgWrap.value.style.width = divWidth + "px";
+
+	// 容器宽高比
+	const divPercent = divWidth / divHeight;
+	if (imgWidth > divWidth && imgHeight < divHeight) {
+		// 图片宽比容器大 高比容器小
+		HbImg.value.style.setProperty("height", divHeight + "px", "important");
+		HbImg.value.style.setProperty("width", divHeight * WHPercent + "px", "important");
+	} else if (imgWidth < divWidth && imgHeight > divHeight) {
+		// 图片高比容器大 宽比容器小
+		HbImg.value.style.setProperty("height", divWidth * HWPercent + "px", "important");
+		HbImg.value.style.setProperty("width", divWidth + "px", "important");
+	} else {
+		// 图片宽高都大于容器  或  图片宽高都小于容器
+		if (WHPercent > 1) {
+			if (WHPercent > divPercent) {
+				HbImg.value.style.setProperty("height", divHeight + "px", "important");
+				HbImg.value.style.setProperty("width", divHeight * WHPercent + "px", "important");
+			} else {
+				HbImg.value.style.setProperty("height", divWidth * HWPercent + "px", "important");
+				HbImg.value.style.setProperty("width", divWidth + "px", "important");
+			}
+		} else if (WHPercent < 1) {
+			if (divPercent >= 1) {
+				HbImg.value.style.setProperty("height", divWidth * HWPercent + "px", "important");
+				HbImg.value.style.setProperty("width", divWidth + "px", "important");
+			} else {
+				HbImg.value.style.setProperty("height", divHeight + "px", "important");
+				HbImg.value.style.setProperty("width", divHeight * WHPercent + "px", "important");
+			}
+		} else {
+			if (divPercent >= 1) {
+				HbImg.value.style.setProperty("height", divWidth * HWPercent + "px", "important");
+				HbImg.value.style.setProperty("width", divWidth + "px", "important");
+			} else {
+				HbImg.value.style.setProperty("height", divHeight + "px", "important");
+				HbImg.value.style.setProperty("width", divHeight * WHPercent + "px", "important");
+			}
+		}
+	}
+}
+
+const isShow = ref(false);
+
+onMounted(() => {
+	const image = new Image();
+	if (props.crossOrigin) {
+		image.crossOrigin = "Anonymous";
+	}
+	image.src = props.src;
+	if (image.complete) {
+		imgHeight = HbImg.value.naturalHeight;
+		imgWidth = HbImg.value.naturalWidth;
+		calcImg();
+	} else {
+		nextTick(() => {
+			image.onload = () => {
+				HbImg.value.src = props.src;
+				imgHeight = HbImg.value.offsetHeight;
+				imgWidth = HbImg.value.offsetWidth;
+				calcImg();
+			};
+			image.onerror = () => {
+				imgHeight = HbImg.value.offsetHeight;
+				imgWidth = HbImg.value.offsetWidth;
+				calcImg();
+			};
+		});
+	}
+});
+</script>
+
+<template>
+	<div ref="HbImgWrap" class="hb-img-wrap" :style="`border-radius: ${props.radius};`">
+		<img ref="HbImg" class="hb-img" :src="src" />
+		<div v-show="props.isPreview" class="hb-img-prev">
+			<n-icon style="cursor: pointer" @click="isShow = true">
+				<i class="ri-eye-line" />
+			</n-icon>
+		</div>
+	</div>
+	<vivid-image-preview v-model:value="isShow" :list="[props.prevSrc || props.src]" />
+</template>
+
+<style scoped>
+.hb-img-wrap {
+	height: 100%;
+	width: 100%;
+	position: relative;
+	overflow: hidden;
+}
+
+.hb-img-wrap:hover .hb-img-prev {
+	display: flex;
+}
+
+.hb-img {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	padding: 0;
+	margin: 0;
+}
+
+.hb-img-prev {
+	position: absolute;
+	top: 0;
+	left: 0;
+	bottom: 0;
+	right: 0;
+	background-color: rgba(0, 0, 0, 0);
+	color: v-bind(themeVars.textColor3);
+	font-size: 28px;
+	display: none;
+	justify-content: center;
+	align-items: center;
+	transition: background-color 0.5s;
+}
+
+.hb-img-prev:hover {
+	background-color: rgba(0, 0, 0, 0.5);
+}
+</style>

+ 411 - 0
packages/vivid/src/core/components/VividImagePreview.vue

@@ -0,0 +1,411 @@
+<script setup>
+import { nextTick, onMounted, ref, toRefs, watch } from "vue";
+
+const props = defineProps({
+	value: {
+		type: Boolean,
+		default: false,
+	},
+	isThumb: {
+		type: Boolean,
+		default: false,
+	},
+	list: {
+		type: Array,
+		default: () => [],
+	},
+	initialIndex: {
+		type: Number,
+		default: 0,
+	},
+	clickHide: {
+		type: Boolean,
+		default: true,
+	},
+});
+
+const isShow = ref(false);
+const { value, initialIndex } = toRefs(props);
+isShow.value = value.value;
+const pIndex = ref(0);
+pIndex.value = initialIndex.value;
+let elWidth = 0; // 预览图片宽度
+let elHeight = 0; // 预览图片高度
+let elPosition = { x: 0, y: 0 }; // 预览图片当前的位置
+let elScale = { x: 1, y: 1 }; // 预览图片的缩放旋转
+let elRotate = 0;
+let screenWidth = 0; // 显示区域宽度
+let screenHeight = 0; // 显示区域高度
+const tempMove = { x: 0, y: 0 };
+let isDrag = false;
+
+watch(
+	() => props.value,
+	() => {
+		isShow.value = props.value;
+		if (isShow.value) {
+			nextTick(() => {
+				initialPreview();
+			});
+		}
+	},
+);
+
+const emit = defineEmits(["update:value"]);
+watch(isShow, () => {
+	emit("update:value", isShow.value);
+});
+
+watch(
+	() => props.initialIndex,
+	() => {
+		pIndex.value = props.initialIndex;
+	},
+);
+
+watch(pIndex, () => {
+	if (props.isThumb) {
+		nextTick(() => {
+			const prevThumbBox = HbPrevThumbBox.value;
+			if (prevThumbBox) {
+				prevThumbBox.scrollLeft = (pIndex.value - 1) * 100;
+			}
+		});
+	}
+});
+
+function getElementPos(el) {
+	const pos = [parseInt(el.style.left + 0), parseInt(el.style.top + 0)];
+	return { x: pos[0], y: pos[1] };
+}
+
+function initialImage(el, type = "fit") {
+	const image = new Image();
+	image.src = props.list[pIndex.value];
+	if (image.complete) {
+		setImage(image, el, type);
+	} else {
+		image.onload = () => {
+			setImage(image, el, type);
+		};
+	}
+}
+
+function setImage(image, el, type) {
+	const imgWidth = image.naturalWidth;
+	const imgHeight = image.naturalHeight;
+	const imgRatio = imgWidth / imgHeight;
+	if (type === "fit") {
+		elWidth = Math.ceil((screenHeight * imgRatio) / 2);
+		elHeight = Math.ceil(screenHeight / 2);
+	} else if (type === "original") {
+		elWidth = imgWidth;
+		elHeight = imgHeight;
+	}
+	// 设置预览图的初始宽高度
+	el.style.width = elWidth + "px";
+	el.style.height = elHeight + "px";
+	// 设置预览图的初始位置
+	el.style.left = Math.ceil((screenWidth - elWidth) / 2) + "px";
+	el.style.top = Math.ceil((screenHeight - elHeight) / 2) + "px";
+
+	elScale = { x: 1, y: 1 };
+	elRotate = 0;
+	el.style.transform = "scale(1) rotate(0deg)";
+}
+
+function initialPreview() {
+	if (props.list.length > 0) {
+		const prevImg = HbPrevImg.value;
+		if (prevImg) {
+			screenWidth = prevImg.parentNode.offsetWidth;
+			screenHeight = prevImg.parentNode.offsetHeight;
+			initialImage(prevImg);
+
+			if (props.isThumb) {
+				const prevThumb = HbPrevThumb.value;
+				prevThumb.style.width = props.list.length * 100 + "px";
+			}
+
+			prevImg.addEventListener("mousedown", (e) => {
+				e.preventDefault();
+				elPosition = getElementPos(prevImg);
+				tempMove.x = e.clientX;
+				tempMove.y = e.clientY;
+				isDrag = true;
+			});
+			prevImg.addEventListener("mousemove", (e) => {
+				e.preventDefault();
+				if (isDrag) {
+					document.body.style.cursor = "move";
+					const left = elPosition.x + e.clientX - tempMove.x;
+					const top = elPosition.y + e.clientY - tempMove.y;
+
+					prevImg.style.transition = "none";
+					prevImg.style.left = left + "px";
+					prevImg.style.top = top + "px";
+
+					tempMove.x = e.clientX;
+					tempMove.y = e.clientY;
+					elPosition = getElementPos(prevImg);
+				}
+			});
+			prevImg.addEventListener("mouseup", (e) => {
+				e.preventDefault();
+				document.body.style.cursor = "default";
+				isDrag = false;
+				prevImg.style.transition = "all .3s";
+			});
+			HbPrevWrap.value.addEventListener("wheel", (e) => {
+				// 前面是谷歌的,后面是火狐的
+				if (e.wheelDelta > 0 || e.detail > 0) {
+					if (elScale.x < 1.9) {
+						elScale.x += 0.1;
+						elScale.y += 0.1;
+					}
+				}
+				if (e.wheelDelta < 0 || e.detail < 0) {
+					if (elScale.x > 0.4) {
+						elScale.x -= 0.1;
+						elScale.y -= 0.1;
+					}
+				}
+
+				// 设置缩放
+				prevImg.style.transform = `scale(${elScale.x},${elScale.y}) rotate(${elRotate}deg)`;
+				elPosition = getElementPos(prevImg);
+			});
+		}
+	} else {
+		isShow.value = false;
+	}
+}
+
+function handlePrevIndex(index) {
+	const prevImg = HbPrevImg.value;
+	prevImg.style.transition = "none";
+	pIndex.value = index;
+	elScale = { x: 1, y: 1 };
+	elRotate = 0;
+	HbPrevImg.value.style.transform = "scale(1) rotate(0deg)";
+	setTimeout(() => {
+		prevImg.style.transition = "all .3s";
+	}, 30);
+}
+
+function prevLeft() {
+	if (pIndex.value > 0) {
+		pIndex.value--;
+	} else {
+		pIndex.value = props.list.length - 1;
+	}
+	const prevImg = HbPrevImg.value;
+
+	prevImg.style.transition = "none";
+	initialImage(prevImg);
+	setTimeout(() => {
+		prevImg.style.transition = "all .3s";
+	}, 30);
+}
+
+function prevRight() {
+	if (pIndex.value < props.list.length - 1) {
+		pIndex.value++;
+	} else {
+		pIndex.value = 0;
+	}
+	const prevImg = HbPrevImg.value;
+	prevImg.style.transition = "none";
+	initialImage(prevImg);
+	setTimeout(() => {
+		prevImg.style.transition = "all .3s";
+	}, 30);
+}
+
+function prevOriginal() {
+	const prevImg = HbPrevImg.value;
+	initialImage(prevImg, "original");
+	prevImg.style.transform = `scale(${elScale.x},${elScale.y}) rotate(${elRotate}deg)`;
+}
+
+function prevRotateLeft() {
+	const prevImg = HbPrevImg.value;
+	prevImg.style.transform = `scale(${elScale.x},${elScale.y}) rotate(${(elRotate -= 90)}deg)`;
+}
+
+function prevRotateRight() {
+	const prevImg = HbPrevImg.value;
+	prevImg.style.transform = `scale(${elScale.x},${elScale.y}) rotate(${(elRotate += 90)}deg)`;
+}
+
+function handleClose() {
+	if (props.clickHide) {
+		isShow.value = false;
+	}
+}
+
+onMounted(() => {
+	initialPreview();
+});
+
+const HbPrevWrap = ref();
+const HbPrevImg = ref();
+const HbPrevThumbBox = ref();
+const HbPrevThumb = ref();
+</script>
+<template>
+	<div v-if="isShow" ref="HbPrevWrap" class="hb-prev-wrap">
+		<div class="hb-prev-mask" @click="handleClose" />
+		<img ref="HbPrevImg" class="hb-prev-img" alt="预览图" :src="list[pIndex]" />
+		<div v-if="props.isThumb" ref="HbPrevThumbBox" class="hb-prev-thumb-box">
+			<div ref="HbPrevThumb" class="hb-prev-thumb-list">
+				<div v-for="(item, i) in props.list" :key="i" class="hb-prev-thumb-item"
+					:class="pIndex === i ? 'hb-prev-thumb-item-active' : ''" @click="handlePrevIndex(i)">
+					<img class="hb-prev-thumb-img" :src="item" />
+				</div>
+			</div>
+		</div>
+		<div class="hb-prev-bar">
+			<div class="hb-prev-btn" title="向左旋转" @click="prevRotateLeft">
+				<i class="ri-anticlockwise-line" />
+			</div>
+			<div class="hb-prev-btn" title="前一张" @click="prevLeft">
+				<i class="ri-arrow-left-s-line" />
+			</div>
+			<div class="hb-prev-btn" title="图片原始大小" @click="prevOriginal">
+				<i class="ri-aspect-ratio-line" />
+			</div>
+			<div class="hb-prev-btn" title="后一张" @click="prevRight">
+				<i class="ri-arrow-right-s-line" />
+			</div>
+			<div class="hb-prev-btn" title="向右旋转" @click="prevRotateRight">
+				<i class="ri-clockwise-line" />
+			</div>
+		</div>
+		<div class="hb-prev-close" @click="isShow = false">
+			<i class="ri-close-line" />
+		</div>
+	</div>
+</template>
+
+<style scoped>
+.hb-prev-wrap {
+	position: fixed;
+	top: 0;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	z-index: 99999;
+}
+
+.hb-prev-mask {
+	height: 100%;
+	width: 100%;
+	background-color: rgba(0, 0, 0, 0.5);
+}
+
+.hb-prev-img {
+	position: fixed;
+	transition: all 0.3s;
+}
+
+.hb-prev-bar {
+	position: fixed;
+	left: 50%;
+	bottom: 30px;
+	transform: translateX(-50%);
+	width: 250px;
+	height: 50px;
+	background-color: rgba(96, 98, 102, 0.71);
+	border-radius: 25px;
+	padding: 0 25px;
+	box-sizing: border-box;
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	user-select: none;
+}
+
+.hb-prev-btn {
+	width: 30px;
+	height: 30px;
+	border-radius: 60px;
+	background-color: #999999;
+	color: #ffffff;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	cursor: pointer;
+}
+
+.hb-prev-btn:hover {
+	background-color: #aaaaaa;
+}
+
+.hb-prev-close {
+	width: 50px;
+	height: 50px;
+	position: fixed;
+	top: 50px;
+	right: 50px;
+	font-size: 36px;
+	color: #f2f3f5;
+	cursor: pointer;
+	background-color: #999999;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	border-radius: 50px;
+	padding: 5px;
+}
+
+.hb-prev-close:hover {
+	background-color: #aaaaaa;
+}
+
+.hb-prev-thumb-box {
+	max-width: 50%;
+	overflow-x: scroll;
+	position: fixed;
+	left: 50%;
+	bottom: 90px;
+	transform: translateX(-50%);
+	padding: 5px;
+	box-sizing: border-box;
+	background-color: rgba(96, 98, 102, 0.71);
+}
+
+.hb-prev-thumb-box::-webkit-scrollbar {
+	display: none;
+}
+
+.hb-prev-thumb-list {
+	display: flex;
+}
+
+.hb-prev-thumb-item {
+	height: 100px;
+	width: 100px;
+	margin: 0 5px;
+	padding: 5px 10px;
+	box-sizing: border-box;
+	cursor: pointer;
+	transition: all 0.3s;
+}
+
+.hb-prev-thumb-item:hover {
+	background-color: #888888;
+}
+
+.hb-prev-thumb-item-active {
+	padding: 0;
+	background-color: #888888;
+	border: 1px solid #ffffff;
+}
+
+.hb-prev-thumb-img {
+	height: 100%;
+	width: 100%;
+	object-fit: scale-down;
+}
+</style>

+ 74 - 0
packages/vivid/src/core/components/VividMenuItem.vue

@@ -0,0 +1,74 @@
+<script setup>
+	import { ref } from "vue";
+	import { useThemeVars, NTooltip } from "naive-ui";
+	const vars = useThemeVars();
+	const props = defineProps({
+		icon: {
+			type: String,
+			required: false,
+		},
+		title: {
+			type: String,
+			required: true,
+		},
+		action: {
+			type: Function,
+			required: true,
+		},
+		isActive: {
+			type: Function,
+			default: null,
+		},
+	});
+
+	const iconUrl = ref("");
+</script>
+<template>
+	<div>
+		<n-tooltip trigger="hover" style="border-radius: 10px">
+			<template #trigger>
+				<button
+					class="menu-item"
+					:class="{ 'is-active': isActive ? isActive() : null }"
+					@click="action"
+				>
+					<slot>
+						<i :class="`ri-${icon}`"></i>
+					</slot>
+				</button>
+			</template>
+			<div class="menu-title">
+				{{ title }}
+			</div>
+		</n-tooltip>
+	</div>
+</template>
+
+<style scoped>
+	.menu-item {
+		width: 28px;
+		height: 28px;
+		color: v-bind(vars.textColorBase);
+		border: none;
+		background-color: transparent;
+		border-radius: 5px;
+		padding: 5px;
+		transition: all 0.2s;
+		margin-left: 2px;
+		margin-right: 2px;
+		font-size: 18px;
+		display: flex;
+		align-items: center;
+	}
+
+	.menu-item:hover,
+	.is-active {
+		color: v-bind(vars.baseColor);
+		background-color: v-bind(vars.textColorBase);
+	}
+
+	.menu-title {
+		font-size: 12px;
+		white-space: nowrap;
+	}
+</style>

+ 117 - 0
packages/vivid/src/core/components/VividSimpleUpload.vue

@@ -0,0 +1,117 @@
+<script setup>
+  import { computed, nextTick, ref } from "vue";
+  import { useThemeVars, NUploadDragger, NUpload } from "naive-ui";
+  // 启用中文
+  const vars = useThemeVars();
+  const props = defineProps({
+    type: {
+      type: String,
+      default: "image", // image 或  video  或  audio
+    },
+  });
+
+  const accept = computed(() => {
+    switch (props.type) {
+      case "image":
+        return "image/*";
+      case "video":
+        return "video/*";
+      case "audio":
+        return "audio/*";
+    }
+  });
+
+  const fileValue = ref(null);
+  const fileBlob = ref(null);
+  const emit = defineEmits(["change"]);
+
+  function handleSelect(event) {
+    fileValue.value = URL.createObjectURL(event.file.file);
+    fileBlob.value = event.file.file;
+    nextTick(() => {
+      emit("change", fileBlob.value);
+    });
+  }
+
+  function handleDel() {
+    fileValue.value = null;
+    fileBlob.value = null;
+    nextTick(() => {
+      emit("change", fileBlob.value);
+    });
+  }
+
+  function getFile() {
+    return fileBlob.value;
+  }
+
+  defineExpose({ getFile });
+</script>
+
+<template>
+  <div class="hb-su-wrap">
+    <n-upload v-if="fileValue === null" :show-file-list="false" :accept="accept" @change="handleSelect">
+      <n-upload-dragger>
+        <div class="upload-drag-placeholder" v-if="props.type === 'image'">
+          <i class="ri-image-add-line ri-2x"></i>
+          <div>拖拽图片到此框内,或点击上传</div>
+        </div>
+        <div class="upload-drag-placeholder" v-if="props.type === 'video'">
+          <i class="ri-video-upload-line ri-2x"></i>
+          <div>拖拽视频到此框内,或点击上传</div>
+        </div>
+      </n-upload-dragger>
+    </n-upload>
+    <div v-if="fileValue" class="hb-su-preview">
+      <div class="hb-su-close" @click="handleDel">
+        <i class="ri-close-line" />
+      </div>
+      <img v-if="props.type === 'image'" style="width: 100%" :src="fileValue" />
+      <video v-if="props.type === 'video'" style="width: 100%" :src="fileValue" />
+    </div>
+  </div>
+</template>
+
+<style scoped>
+  .hb-su-wrap {
+    height: 100%;
+    width: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .hb-su-preview {
+    width: 400px;
+    height: 100%;
+    position: relative;
+    box-sizing: border-box;
+  }
+
+  .hb-su-close {
+    font-size: 12px;
+    color: #fff;
+    padding: 0 4px;
+    border-radius: 50%;
+    background-color: rgba(0, 0, 0, 0.7);
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    z-index: 2;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    cursor: pointer;
+  }
+
+  .hb-su-close:hover {
+    background-color: rgba(0, 0, 0, 0.8);
+  }
+
+  .upload-drag-placeholder{
+    display: flex;
+    flex-direction: column;
+    gap:10px;
+
+  }
+</style>

+ 269 - 0
packages/vivid/src/core/extension/ai/Ai.vue

@@ -0,0 +1,269 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { h, ref, PropType } from "vue";
+	import {
+		NButton,
+		NDropdown,
+		NSpace,
+		NIcon,
+		NCard,
+		NInput,
+		NModal,
+		useMessage,
+		useDialog,
+		useThemeVars,
+	} from "naive-ui";
+	import { Icon } from "@iconify/vue";
+	import { AiOption } from "@lib/core/extension";
+	import { useEditorInstance } from "../utils/common";
+	import { Stream } from "openai/streaming";
+	import OpenAI from "openai/index";
+	import ChatCompletionChunk = OpenAI.ChatCompletionChunk;
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<AiOption>,
+			required: true,
+		},
+	});
+
+	const vars = useThemeVars();
+	const editorInstance = useEditorInstance();
+	const options: any[] = [
+		{
+			label: "AI续写",
+			icon: "mdi:magic",
+			key: "xuxie",
+		},
+		{
+			label: "AI润色",
+			icon: "mdi:magic-staff",
+			key: "runse",
+			children: [
+				{
+					label: "更加详细",
+					icon: "mdi:magic-staff",
+					key: "xiangxi",
+				},
+				{
+					icon: "mdi:magic-staff",
+					label: "更加精简",
+					key: "jingjian",
+				},
+				{
+					icon: "mdi:magic-staff",
+					label: "更加正式",
+					key: "zhengshi",
+				},
+				{
+					label: "更加连贯",
+					icon: "mdi:magic-staff",
+					key: "lianguan",
+				},
+				{
+					icon: "mdi:magic-staff",
+					label: "更加生动",
+					key: "shengdong",
+				},
+			],
+		},
+		{
+			label: "AI校阅",
+			key: "jiaoyue",
+			icon: "mdi:file-document-box-search-outline",
+		},
+		{
+			label: "AI翻译",
+			icon: "mdi:translate",
+			key: "fanyi",
+			children: [
+				{
+					label: "英语",
+					icon: "mdi:translate",
+					key: "english",
+				},
+				{
+					label: "中文",
+					icon: "mdi:translate",
+					key: "chinese",
+				},
+			],
+		},
+	];
+	const message = useMessage();
+	const dialog = useDialog();
+	const status = ref("idle");
+	const result = ref("");
+	const messageList = ref<string[]>([]);
+	const showModal = ref(false);
+	const storage = editorInstance.value.storage;
+	let selectionFrom: number;
+	let selectionTo: number;
+	let stream: Stream<ChatCompletionChunk>;
+
+	function renderDropdownIcon(item) {
+		return h(NIcon, { size: 18 }, { default: () => h(Icon, { icon: item.icon }) });
+	}
+
+	async function handleSelect(key: string) {
+		showModal.value = true;
+		const selection = editorInstance.value.state.selection;
+		const state = editorInstance.value.state;
+		selectionFrom = state.selection.from;
+		selectionTo = state.selection.to;
+		const selectionText = state.doc.textBetween(selectionFrom, selectionTo);
+		const selectionJSON =
+			selectionText.length === 0 ? null : state.selection.content().content.toJSON();
+		const prevText = state.doc.textBetween(Math.max(0, selectionTo - 5000), selectionTo, "\n");
+		status.value = "loading";
+		editorInstance.value.commands.focus();
+
+		stream = await props.options.completions(selectionText, key);
+		status.value = "generating";
+		for await (const chunk of stream) {
+			messageList.value.push(chunk.choices[0]?.delta?.content || "");
+			result.value += chunk.choices[0]?.delta?.content || "";
+		}
+		status.value = "completed";
+	}
+
+	function handleStop() {
+		status.value = "idle";
+	}
+
+	function handleReplace() {
+		const range = {
+			from: Number(selectionFrom),
+			to: Number(selectionTo),
+		};
+		editorInstance.value
+			.chain()
+			.setTextSelection(range)
+			.deleteSelection()
+			.insertContent(result.value)
+			.run();
+		reset();
+	}
+
+	function reset() {
+		result.value = "";
+		messageList.value = [];
+		status.value = "idle";
+		showModal.value = false;
+	}
+
+	function copy(text: string) {
+		navigator.clipboard.writeText(text).then(() => {
+			message.success("复制成功");
+		});
+	}
+
+	function handleCancel() {
+		stream && stream.controller.abort();
+		reset();
+	}
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<n-dropdown
+			:to="false"
+			:z-index="9999"
+			placement="bottom-start"
+			trigger="hover"
+			:options="options"
+			:render-icon="renderDropdownIcon"
+			@select="handleSelect"
+		>
+			<vivid-menu-item
+				icon="sparkling-line"
+				title="AI助手"
+				:action="() => {}"
+				:is-active="() => {}"
+			/>
+		</n-dropdown>
+		<n-modal @mask-click="handleCancel" style="width: 600px" v-model:show="showModal">
+			<n-card title="AI助手" size="small">
+				<div :class="{ box: status === 'generating' }">
+					<n-input
+						placeholder="生成内容"
+						:bordered="status !== 'generating'"
+						show-count
+						:autosize="{
+							minRows: 3,
+							maxRows: 10,
+						}"
+						type="textarea"
+						v-model:value="result"
+						:loading="status === 'generating'"
+						:readonly="status === 'generating'"
+						style="height: fit-content; z-index: 9"
+					></n-input>
+				</div>
+				<template #footer>
+					<n-space justify="end">
+						<n-button @click="handleCancel" size="small" secondary>取消</n-button>
+						<n-button @click="copy(result)" size="small" secondary v-if="status === 'completed'"
+							>复制</n-button
+						>
+						<n-button
+							@click="handleReplace"
+							size="small"
+							type="primary"
+							secondary
+							v-if="status === 'completed'"
+							>替换
+						</n-button>
+					</n-space>
+				</template>
+			</n-card>
+		</n-modal>
+	</div>
+</template>
+
+<style scoped>
+	.box {
+		position: relative;
+		padding: 2px;
+		box-sizing: border-box;
+	}
+
+	.box::before {
+		content: "";
+		position: absolute;
+		inset: 0;
+		z-index: 0;
+		border-radius: 3px;
+		background-image: linear-gradient(
+			135deg,
+			#ff0000,
+			#ff7f00,
+			#ffff00,
+			#00ff00,
+			#00ffff,
+			#0000ff,
+			#8b00ff
+		);
+		background-size: 400%; /* 背景放大,动画更明显 */
+		animation: animate_box 5s linear infinite;
+	}
+
+	.box::after {
+		content: "";
+		position: absolute;
+		inset: 2px;
+		z-index: 1;
+		border-radius: 3px;
+		background: var(--n-color-popover);
+	}
+
+	@keyframes animate_box {
+		0%,
+		100% {
+			background-position: 0%, 50%;
+		}
+		50% {
+			background-position: 100%, 50%;
+		}
+	}
+</style>

+ 11 - 0
packages/vivid/src/core/extension/ai/index.ts

@@ -0,0 +1,11 @@
+import AiExt from "./Ai.vue";
+import { APIPromise } from "openai/core";
+import { Stream } from "openai/streaming";
+import OpenAI from "openai/index";
+import ChatCompletionChunk = OpenAI.ChatCompletionChunk;
+
+export declare interface AiOption {
+	completions: (text: string, key: string) => APIPromise<Stream<ChatCompletionChunk>>;
+}
+
+export { AiExt };

+ 31 - 0
packages/vivid/src/core/extension/blockquote/BlockQuote.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import BlockQuote, { BlockquoteOptions } from "@tiptap/extension-blockquote";
+	import { useEditorInstance, injectExtension } from "../utils/common";
+	import { PropType } from "vue";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<BlockquoteOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(BlockQuote.configure(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="double-quotes-l"
+				title="引用"
+				:action="() => editorInstance.chain().focus().toggleBlockquote().run()"
+				:is-active="() => editorInstance.isActive('blockquote')"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/blockquote/index.ts

@@ -0,0 +1,8 @@
+import Blockquote, { BlockquoteOptions } from "@tiptap/extension-blockquote";
+import BlockQuoteExt from "./BlockQuote.vue";
+
+export function useBlockquote(options?: Partial<BlockquoteOptions>) {
+	return Blockquote.configure(options);
+}
+
+export { BlockQuoteExt };

+ 31 - 0
packages/vivid/src/core/extension/bold/Bold.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import Bold, { BoldOptions } from "@tiptap/extension-bold";
+	import { PropType } from "vue";
+	import { useEditorInstance, injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<BoldOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(Bold.configure(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="bold"
+				title="加粗"
+				:action="() => editorInstance.chain().focus().toggleBold().run()"
+				:is-active="() => editorInstance.isActive('bold')"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/bold/index.ts

@@ -0,0 +1,8 @@
+import Bold, { BoldOptions } from "@tiptap/extension-bold";
+import BoldExt from "./Bold.vue";
+
+export function useBold(options?: Partial<BoldOptions>) {
+	return Bold.configure(options);
+}
+
+export { BoldExt };

+ 31 - 0
packages/vivid/src/core/extension/bullet-list/BulletList.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import BulletList, { BulletListOptions } from "@tiptap/extension-bullet-list";
+	import { PropType } from "vue";
+	import { useEditorInstance, injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<BulletListOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(BulletList.configure(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="list-unordered"
+				title="无序列表"
+				:action="() => editorInstance.chain().focus().toggleBulletList().run()"
+				:is-active="() => editorInstance.isActive('bulletList')"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/bullet-list/index.ts

@@ -0,0 +1,8 @@
+import BulletList, { BulletListOptions } from "@tiptap/extension-bullet-list";
+import BulletListExt from "./BulletList.vue";
+
+export function useBulletList(options?: Partial<BulletListOptions>) {
+	return BulletList.configure(options);
+}
+
+export { BulletListExt };

+ 16 - 0
packages/vivid/src/core/extension/character-count/CharacterCount.vue

@@ -0,0 +1,16 @@
+<script setup lang="ts">
+	import CharacterCount, { CharacterCountOptions } from "@tiptap/extension-character-count";
+	import { PropType } from "vue";
+	import { injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<CharacterCountOptions>>,
+			required: false,
+		},
+	});
+	injectExtension(CharacterCount.configure(props.options));
+</script>
+
+<template></template>
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/character-count/index.ts

@@ -0,0 +1,8 @@
+import CharacterCount, { CharacterCountOptions } from "@tiptap/extension-character-count";
+import CharacterCountExt from "./CharacterCount.vue";
+
+export function useCharacterCount(options?: Partial<CharacterCountOptions>) {
+	return CharacterCount.configure(options);
+}
+
+export { CharacterCountExt };

+ 32 - 0
packages/vivid/src/core/extension/code-block/CodeBlock.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { PropType } from "vue";
+	import { CodeBlockLowlightOptions } from "@tiptap/extension-code-block-lowlight";
+	import { useCodeBlock } from "./code-block";
+	import { useEditorInstance, injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<CodeBlockLowlightOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(useCodeBlock(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="code-view"
+				title="代码块"
+				:action="() => editorInstance.chain().focus().toggleCodeBlock().run()"
+				:is-active="() => editorInstance.isActive('hb-code')"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 73 - 0
packages/vivid/src/core/extension/code-block/CodeBlockView.vue

@@ -0,0 +1,73 @@
+<script setup lang="ts">
+	import "highlight.js/styles/atom-one-light.css";
+	import { NodeViewContent, nodeViewProps, NodeViewWrapper } from "@tiptap/vue-3";
+	import { NPopselect, NButton } from "naive-ui";
+	import { onMounted, ref, watch } from "vue";
+
+	const props = defineProps(nodeViewProps);
+
+	const languages = props.extension.options.lowlight.listLanguages().map((e) => {
+		return {
+			label: e,
+			value: e,
+		};
+	});
+
+	languages.unshift({
+		label: "auto",
+		value: "auto",
+	});
+
+	const selectedLanguage = ref(props.node.attrs.language);
+
+	watch(selectedLanguage, () => {
+		if (selectedLanguage.value === "auto") {
+			props.updateAttributes({ language: null });
+		} else {
+			props.updateAttributes({ language: selectedLanguage.value });
+		}
+	});
+
+	watch(
+		() => props.node.attrs.language,
+		() => {
+			selectedLanguage.value = props.node.attrs.language;
+		},
+	);
+
+	onMounted(() => {
+		selectedLanguage.value = props.node.attrs.language;
+	});
+</script>
+<template>
+	<node-view-wrapper class="code-block" as="p">
+		<div class="lang-select" contenteditable="false">
+			<n-popselect
+				:disabled="!props.editor.isEditable"
+				v-model:value="selectedLanguage"
+				:options="languages"
+				trigger="click"
+				style="max-height: 300px; overflow: auto"
+			>
+				<n-button text type="info" size="small">
+					{{ selectedLanguage || "auto" }}
+				</n-button>
+			</n-popselect>
+		</div>
+		<pre><node-view-content /></pre>
+	</node-view-wrapper>
+</template>
+
+<style scoped>
+	.code-block {
+		position: relative;
+	}
+
+	.code-block .lang-select {
+		position: absolute;
+		top: 0;
+		right: 0;
+		border-radius: 4px;
+		padding: 0 10px;
+	}
+</style>

+ 92 - 0
packages/vivid/src/core/extension/code-block/code-block.ts

@@ -0,0 +1,92 @@
+import CodeBlockLowlight, { CodeBlockLowlightOptions } from "@tiptap/extension-code-block-lowlight";
+import { VueNodeViewRenderer } from "@tiptap/vue-3";
+import CodeBlockView from "./CodeBlockView.vue";
+
+// load all highlight.js languages
+import { lowlight } from "lowlight/lib/core";
+import arduino from "highlight.js/lib/languages/arduino";
+import bash from "highlight.js/lib/languages/bash";
+import c from "highlight.js/lib/languages/c";
+import cpp from "highlight.js/lib/languages/cpp";
+import csharp from "highlight.js/lib/languages/csharp";
+import css from "highlight.js/lib/languages/css";
+import diff from "highlight.js/lib/languages/diff";
+import go from "highlight.js/lib/languages/go";
+import graphql from "highlight.js/lib/languages/graphql";
+import ini from "highlight.js/lib/languages/ini";
+import java from "highlight.js/lib/languages/java";
+import javascript from "highlight.js/lib/languages/javascript";
+import json from "highlight.js/lib/languages/json";
+import kotlin from "highlight.js/lib/languages/kotlin";
+import less from "highlight.js/lib/languages/less";
+import lua from "highlight.js/lib/languages/lua";
+import makefile from "highlight.js/lib/languages/makefile";
+import markdown from "highlight.js/lib/languages/markdown";
+import objectivec from "highlight.js/lib/languages/objectivec";
+import perl from "highlight.js/lib/languages/perl";
+import php from "highlight.js/lib/languages/php";
+import phpTemplate from "highlight.js/lib/languages/php-template";
+import plaintext from "highlight.js/lib/languages/plaintext";
+import python from "highlight.js/lib/languages/python";
+import pythonRepl from "highlight.js/lib/languages/python-repl";
+import r from "highlight.js/lib/languages/r";
+import ruby from "highlight.js/lib/languages/ruby";
+import rust from "highlight.js/lib/languages/rust";
+import scss from "highlight.js/lib/languages/scss";
+import shell from "highlight.js/lib/languages/shell";
+import sql from "highlight.js/lib/languages/sql";
+import swift from "highlight.js/lib/languages/swift";
+import typescript from "highlight.js/lib/languages/typescript";
+import vbnet from "highlight.js/lib/languages/vbnet";
+import wasm from "highlight.js/lib/languages/wasm";
+import xml from "highlight.js/lib/languages/xml";
+import yaml from "highlight.js/lib/languages/yaml";
+
+lowlight.registerLanguage("arduino", arduino);
+lowlight.registerLanguage("bash", bash);
+lowlight.registerLanguage("c", c);
+lowlight.registerLanguage("cpp", cpp);
+lowlight.registerLanguage("csharp", csharp);
+lowlight.registerLanguage("css", css);
+lowlight.registerLanguage("diff", diff);
+lowlight.registerLanguage("go", go);
+lowlight.registerLanguage("graphql", graphql);
+lowlight.registerLanguage("ini", ini);
+lowlight.registerLanguage("java", java);
+lowlight.registerLanguage("javascript", javascript);
+lowlight.registerLanguage("json", json);
+lowlight.registerLanguage("kotlin", kotlin);
+lowlight.registerLanguage("less", less);
+lowlight.registerLanguage("lua", lua);
+lowlight.registerLanguage("makefile", makefile);
+lowlight.registerLanguage("markdown", markdown);
+lowlight.registerLanguage("objectivec", objectivec);
+lowlight.registerLanguage("perl", perl);
+lowlight.registerLanguage("php", php);
+lowlight.registerLanguage("php-template", phpTemplate);
+lowlight.registerLanguage("plaintext", plaintext);
+lowlight.registerLanguage("python", python);
+lowlight.registerLanguage("python-repl", pythonRepl);
+lowlight.registerLanguage("r", r);
+lowlight.registerLanguage("ruby", ruby);
+lowlight.registerLanguage("rust", rust);
+lowlight.registerLanguage("scss", scss);
+lowlight.registerLanguage("shell", shell);
+lowlight.registerLanguage("sql", sql);
+lowlight.registerLanguage("swift", swift);
+lowlight.registerLanguage("typescript", typescript);
+lowlight.registerLanguage("vbnet", vbnet);
+lowlight.registerLanguage("wasm", wasm);
+lowlight.registerLanguage("xml", xml);
+lowlight.registerLanguage("yaml", yaml);
+
+export function useCodeBlock(options?: Partial<CodeBlockLowlightOptions>) {
+	if (!options) {
+		options = { lowlight };
+	}
+	return CodeBlockLowlight.extend({
+		addNodeView() {
+			return VueNodeViewRenderer(CodeBlockView);
+		},
+	}).configure(options);
+}

+ 4 - 0
packages/vivid/src/core/extension/code-block/index.ts

@@ -0,0 +1,4 @@
+import { useCodeBlock } from "./code-block.js";
+import CodeBlockExt from "./CodeBlock.vue";
+
+export { useCodeBlock, CodeBlockExt };

+ 31 - 0
packages/vivid/src/core/extension/code/Code.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import Code, { CodeOptions } from "@tiptap/extension-code";
+	import { PropType } from "vue";
+	import { useEditorInstance, injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<CodeOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(Code.configure(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="brackets-line"
+				title="代码"
+				:action="() => editorInstance.chain().focus().toggleCode().run()"
+				:is-active="() => editorInstance.isActive('code')"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/code/index.ts

@@ -0,0 +1,8 @@
+import Code, { CodeOptions } from "@tiptap/extension-code";
+import CodeExt from "./Code.vue";
+
+export function useCode(options?: Partial<CodeOptions>) {
+	return Code.configure(options);
+}
+
+export { CodeExt };

+ 50 - 0
packages/vivid/src/core/extension/color/Color.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import VividColorPicker from "../../components/VividColorPicker.vue";
+	import Color, { ColorOptions } from "@tiptap/extension-color";
+	import { PropType, ref } from "vue";
+	import { useEditorInstance, injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<ColorOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(Color.configure(props.options));
+
+	const color = ref("#000000");
+
+	function setColor() {
+		editorInstance.value.chain().focus().setColor(color.value).run();
+	}
+
+	function updateColor(newColor: string) {
+		color.value = newColor;
+		setColor();
+	}
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<div class="color-box">
+				<vivid-menu-item
+					icon="font-color"
+					title="字体颜色"
+					:action="setColor"
+					:is-active="() => editorInstance.isActive('font-color')"
+				/>
+				<vivid-color-picker @change="updateColor" />
+			</div>
+		</slot>
+	</div>
+</template>
+
+<style scoped>
+	.color-box {
+		display: flex;
+	}
+</style>

+ 8 - 0
packages/vivid/src/core/extension/color/index.ts

@@ -0,0 +1,8 @@
+import Color, { ColorOptions } from "@tiptap/extension-color";
+import ColorExt from "./Color.vue";
+
+export function useColor(options?: Partial<ColorOptions>) {
+	return Color.configure(options);
+}
+
+export { ColorExt };

+ 11 - 0
packages/vivid/src/core/extension/copypaste/CopyPasteExt.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+	import { injectExtension, useEditorInstance } from "../utils/common";
+	import { useCopyPaste } from "@lib/core/extension/copypaste/copypaste";
+
+	const editor = useEditorInstance();
+	injectExtension(useCopyPaste());
+</script>
+
+<template></template>
+
+<style scoped></style>

+ 90 - 0
packages/vivid/src/core/extension/copypaste/copypaste.ts

@@ -0,0 +1,90 @@
+import { Extension, Range } from "@tiptap/core";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { DOMSerializer, Node } from "prosemirror-model";
+import { UploadInfo } from "@lib/core/extension/types";
+
+declare module "@tiptap/core" {
+	interface Commands<ReturnType> {
+		copyPaste: {
+			copyRange: (range: Range, node: Node) => ReturnType;
+		};
+	}
+}
+
+export function useCopyPaste() {
+	return Extension.create({
+		name: "handleCopyPaste",
+		addCommands() {
+			return {
+				copyRange: (range: Range, node: Node) => {
+					return ({ state, dispatch }) => {
+						this.editor.chain().focus().setNodeSelection(range.from).run();
+						const serializer = DOMSerializer.fromSchema(this.editor.schema);
+						if (node) {
+							const html = serializer.serializeNode(node);
+							const div = document.createElement("div");
+							div.append(html);
+							navigator.clipboard.writeText(div.innerHTML);
+							return true;
+						}
+						return false;
+					};
+				},
+			};
+		},
+		addStorage() {
+			return {};
+		},
+		addProseMirrorPlugins() {
+			const editor = this.editor;
+			const plugin = new Plugin({
+				key: new PluginKey("handleCopyPaste"),
+				props: {
+					handlePaste: (view, event, p) => {
+						function hasImageExt() {
+							return editor.extensionManager.extensions.find((e) => e.name === "image");
+						}
+
+						function hasUploadManagerExt() {
+							return editor.extensionManager.extensions.find((e) => e.name === "upload-manager");
+						}
+
+						const files = event.clipboardData!.files;
+						const imageExt = hasImageExt();
+						const uploadManagerExt = hasUploadManagerExt();
+						if (uploadManagerExt && imageExt && files && files.length) {
+							const fileList: UploadInfo[] = [];
+							for (let i = 0; i < files.length; i++) {
+								const file = files.item(i);
+								if (!file) {
+									continue;
+								}
+								if (!file.type.startsWith("image")) {
+									continue;
+								}
+								fileList.push({
+									file,
+									pos: view.state.selection.from,
+								});
+							}
+							editor.commands.upload(fileList);
+							return true;
+						}
+
+						const html = event.clipboardData!.getData("text/html");
+						if (html) {
+							return false;
+						}
+
+						const text = event.clipboardData!.getData("text");
+						if (!text) {
+							return false;
+						}
+						return editor.commands.insertContent(text);
+					},
+				},
+			});
+			return [plugin];
+		},
+	});
+}

+ 4 - 0
packages/vivid/src/core/extension/copypaste/index.ts

@@ -0,0 +1,4 @@
+import { useCopyPaste } from "./copypaste";
+import CopyPasteExt from "./CopyPasteExt.vue";
+
+export { CopyPasteExt, useCopyPaste };

+ 19 - 0
packages/vivid/src/core/extension/divider/Divider.vue

@@ -0,0 +1,19 @@
+<script setup lang="ts">
+	import { useThemeVars } from "naive-ui";
+
+	const vars = useThemeVars();
+</script>
+
+<template>
+	<div class="divider" />
+</template>
+
+<style scoped>
+	.divider {
+		width: 2px;
+		height: 20px;
+		background-color: v-bind(vars.borderColor);
+		margin-left: 2px;
+		margin-right: 2px;
+	}
+</style>

+ 3 - 0
packages/vivid/src/core/extension/divider/index.ts

@@ -0,0 +1,3 @@
+import DividerExt from "./Divider.vue";
+
+export { DividerExt };

+ 9 - 0
packages/vivid/src/core/extension/document/Document.vue

@@ -0,0 +1,9 @@
+<script setup>
+	import Document from "@tiptap/extension-document";
+	import { injectExtension } from "../utils/common";
+
+	injectExtension(Document);
+</script>
+
+<template></template>
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/document/index.ts

@@ -0,0 +1,8 @@
+import Document from "@tiptap/extension-document";
+import DocumentExt from "./Document.vue";
+
+export function useDocument() {
+	return Document;
+}
+
+export { DocumentExt };

+ 370 - 0
packages/vivid/src/core/extension/drag-handle/DragHandle.vue

@@ -0,0 +1,370 @@
+<script setup lang="ts">
+	import { NPopover, NElement, useThemeVars } from "naive-ui";
+	import {
+		lockDragHandle,
+		unlockDragHandle,
+		useDragHandle,
+		useDragHandleData,
+	} from "./drag-handle";
+	import { onMounted, ref, watch } from "vue";
+	import { Range } from "@tiptap/core";
+	import { injectExtension, useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const vars = useThemeVars();
+	const root = ref<any>();
+	const showSlash = ref(false);
+	const showPop = ref(false);
+
+	const editorInstance = useEditorInstance();
+	const data = useDragHandleData();
+	const container = document.createElement("div");
+	injectExtension(useDragHandle({ element: container }));
+
+	onMounted(() => {
+		container.append(root.value);
+	});
+
+	const items = ref([
+		{
+			name: "插入段落",
+			cmd: "/paragraph",
+			icon: "paragraph",
+			action: (range: Range) => {
+				if (!data.value.range) {
+					return;
+				}
+				editorInstance.value.chain().focus().insertContentAt(range.to, "<p></p>").run();
+			},
+		},
+		{
+			name: "插入链接",
+			cmd: "/link",
+			icon: "link",
+			action: (range: Range) => {
+				if (!data.value.range) {
+					return;
+				}
+				editorInstance.value.chain().focus().insertContentAt(range.to, "<p></p>").run();
+				editorInstance.value.storage.link.openLink();
+			},
+		},
+		{
+			name: "插入图片",
+			cmd: "/img",
+			icon: "image-line",
+			action: (range: Range) => {
+				editorInstance.value.chain().insertContentAt(range.to, "<p></p>").focus().run();
+				editorInstance.value.storage.image.openUploader();
+			},
+		},
+		{
+			name: "插入视频",
+			cmd: "/video",
+			icon: "video-line",
+			action: (range: Range) => {
+				editorInstance.value.chain().focus().insertContentAt(range.to, "<p></p>").run();
+				editorInstance.value.storage.video.openUploader();
+			},
+		},
+		{
+			name: "引用",
+			cmd: "/b",
+			icon: "double-quotes-l",
+			action: (range: Range) =>
+				editorInstance.value
+					.chain()
+					.focus()
+					.insertContentAt(range.to, "<p></p>")
+					.toggleBlockquote()
+					.run(),
+		},
+		{
+			name: "标题1",
+			cmd: "/h1",
+			icon: "h-1",
+			action: (range: Range) =>
+				editorInstance.value
+					.chain()
+					.focus()
+					.insertContentAt(range.to, "<p></p>")
+					.setHeading({ level: 1 })
+					.run(),
+		},
+		{
+			name: "标题2",
+			cmd: "/h2",
+			icon: "h-2",
+			action: (range: Range) =>
+				editorInstance.value
+					.chain()
+					.insertContentAt(range.to, "<p></p>")
+					.setHeading({ level: 2 })
+					.run(),
+		},
+		{
+			name: "标题3",
+			cmd: "/h3",
+			icon: "h-3",
+			action: (range: Range) =>
+				editorInstance.value
+					.chain()
+					.insertContentAt(range.to, "<p></p>")
+					.setHeading({ level: 3 })
+					.run(),
+		},
+		{
+			name: "列表",
+			cmd: "/list",
+			icon: "list-unordered",
+			action: (range: Range) =>
+				editorInstance.value
+					.chain()
+					.focus()
+					.insertContentAt(range.to, "<p></p>")
+					.toggleBulletList()
+					.run(),
+		},
+		{
+			name: "数学公式",
+			cmd: "/math",
+			icon: "functions",
+			action: (range: Range) => {
+				editorInstance.value.chain().focus().insertContentAt(range.to, "<p></p>").run();
+				editorInstance.value.storage["hb-math"].openEditor();
+			},
+		},
+		{
+			name: "代码",
+			cmd: "/code",
+			icon: "brackets-line",
+			action: (range: Range) =>
+				editorInstance.value
+					.chain()
+					.focus()
+					.insertContentAt(range.to, "<p></p>")
+					.toggleCode()
+					.run(),
+		},
+		{
+			name: "代码块",
+			cmd: "/codeblock",
+			icon: "code-view",
+			action: (range: Range) =>
+				editorInstance.value
+					.chain()
+					.focus()
+					.insertContentAt(range.to, "<p></p>")
+					.toggleCodeBlock()
+					.run(),
+		},
+	]);
+	const items2 = ref([
+		{
+			name: "上移一行",
+			icon: "arrow-up-s-line",
+			action: (range: Range) => {
+				if (!data.value.preRange) {
+					return;
+				}
+				const editor = editorInstance.value;
+				const state = editorInstance.value.state;
+				const tr = state.tr;
+				const range1 = { ...data.value.preRange }; // 第二个 range 的位置
+				const range2 = { ...range }; // 第一个 range 的位置
+				const fromNode = state.doc.cut(range1.from, range1.to);
+				const toNode = state.doc.cut(range2.from, range2.to);
+				tr.replaceRangeWith(range2.from, range2.to, fromNode);
+				tr.replaceRangeWith(range1.from, range1.to, toNode);
+				editor.view.dispatch(tr);
+			},
+		},
+		{
+			name: "删除本行",
+			icon: "close-line",
+			action: (range: Range) => {
+				const editor = editorInstance.value;
+				const state = editorInstance.value.state;
+				const tr = state.tr;
+				tr.delete(range.from, range.to).scrollIntoView();
+				editorInstance.value.view.dispatch(tr);
+			},
+		},
+		{
+			name: "复制本行",
+			icon: "file-copy-line",
+			action: (range: Range) => {
+				const editor = editorInstance.value;
+				if (data.value.node) {
+					editor.commands.copyRange(range, data.value.node);
+				}
+			},
+		},
+		{
+			name: "清除格式",
+			icon: "format-clear",
+			action: (range: Range) => {
+				const editor = editorInstance.value;
+				editor.chain().setNodeSelection(range.from).unsetAllMarks().run()
+			},
+		},
+		{
+			name: "下移一行",
+			icon: "arrow-down-s-line",
+			action: (range: Range) => {
+				if (!data.value.nextRange) {
+					return;
+				}
+				const editor = editorInstance.value;
+				const state = editorInstance.value.state;
+				const tr = state.tr;
+				const range1 = { ...range }; // 第一个 range 的位置
+				const range2 = { ...data.value.nextRange }; // 第二个 range 的位置
+				const fromNode = state.doc.cut(range1.from, range1.to);
+				const toNode = state.doc.cut(range2.from, range2.to);
+				tr.replaceRangeWith(range2.from, range2.to, fromNode);
+				tr.replaceRangeWith(range1.from, range1.to, toNode);
+				editor.view.dispatch(tr);
+			},
+		},
+	]);
+
+	function doAction(e: any) {
+		if (data.value.rect) {
+			e.action(data.value.range);
+			showSlash.value = false;
+			showPop.value = false;
+		}
+	}
+
+	watch([showSlash, showPop], () => {
+		if (showSlash.value || showPop.value) {
+			lockDragHandle();
+		} else {
+			unlockDragHandle();
+		}
+	});
+</script>
+
+<template>
+	<div style="display: none">
+		<div class="drag-handle" ref="root">
+			<n-popover
+				:z-index="99999"
+				style="padding: 0; border-radius: 10px"
+				v-model:show="showSlash"
+				trigger="click"
+				placement="bottom-start"
+				:show-arrow="false"
+			>
+				<template #trigger>
+					<n-element class="drag-button">
+						<i class="ri-add-fill"></i>
+					</n-element>
+				</template>
+				<slot name="drag-handle-slash">
+					<n-element class="slash-command">
+						<div class="slash-item" v-for="(e, i) in items" @click="doAction(e)" :key="e.cmd">
+							<div class="slash-name">
+								<div class="slash-icon">
+									<i :class="`ri-${e.icon}`"></i>
+								</div>
+								<span>{{ e.name }}</span>
+							</div>
+						</div>
+					</n-element>
+				</slot>
+			</n-popover>
+			<n-popover
+				:z-index="99999"
+				style="padding: 0; border-radius: 10px"
+				v-model:show="showPop"
+				trigger="click"
+				placement="bottom-start"
+				:show-arrow="false"
+			>
+				<template #trigger>
+					<n-element class="drag-button">
+						<i class="ri-draggable"></i>
+					</n-element>
+				</template>
+				<slot name="drag-handle-select">
+					<n-element class="slash-command">
+						<div class="slash-item" v-for="(e, i) in items2" @click="doAction(e)" :key="e.name">
+							<div class="slash-name">
+								<div class="slash-icon">
+									<i :class="`ri-${e.icon}`"></i>
+								</div>
+								<span>{{ e.name }}</span>
+							</div>
+						</div>
+					</n-element>
+				</slot>
+			</n-popover>
+		</div>
+	</div>
+</template>
+
+<style scoped>
+	.slash-command {
+		width: 160px;
+		box-sizing: border-box;
+		display: flex;
+		flex-direction: column;
+		outline: none;
+		border: none;
+		user-select: none;
+		border-radius: 10px;
+		overflow: hidden;
+	}
+
+	.slash-item {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		padding: 6px 10px;
+		transition: all 0.5s;
+	}
+
+	.slash-item:hover {
+		background: var(--hover-color);
+	}
+
+	.slash-name {
+		display: flex;
+		gap: 10px;
+		align-items: center;
+		font-size: 14px;
+	}
+
+	.slash-icon {
+		border: 1px solid var(--border-color);
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		width: 24px;
+		height: 24px;
+		border-radius: 5px;
+	}
+
+	.drag-handle {
+		align-items: center;
+		gap: 2px;
+		display: flex;
+	}
+
+	.drag-button {
+		width: 24px;
+		height: 24px;
+		border-radius: 5px;
+		box-sizing: border-box;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		color: var(--text-color3);
+	}
+
+	.drag-button:hover {
+		background: var(--hover-color);
+		border: 1px solid var(--border-color);
+	}
+</style>

+ 234 - 0
packages/vivid/src/core/extension/drag-handle/drag-handle.ts

@@ -0,0 +1,234 @@
+import { Editor, Extension, Range } from "@tiptap/core";
+import tippy, { Instance } from "tippy.js";
+import { Ref, ref } from "vue";
+import { Node } from "prosemirror-model";
+
+export interface DragHandleOptions {
+	element: HTMLElement;
+	debug?: boolean;
+}
+
+export type DragHandleData = {
+	editor?: Editor;
+	node: Node | null;
+	nodeDOM?: HTMLElement;
+	rect?: DOMRect;
+	preRange?: Range;
+	range?: Range;
+	nextRange?: Range;
+};
+
+let keepCurrentPos = false;
+const data: Ref<DragHandleData> = ref({
+	node: null,
+});
+
+export function lockDragHandle() {
+	keepCurrentPos = true;
+}
+
+export function unlockDragHandle() {
+	keepCurrentPos = false;
+}
+
+export function useDragHandleData() {
+	return data;
+}
+
+export function useDragHandle(options: DragHandleOptions) {
+	let mouseEvent: MouseEvent;
+	let tippyInstance: Instance;
+	let currentDom: Element;
+	let editor: Editor;
+
+	const box = document.createElement("div");
+	box.style.pointerEvents = "none";
+	document.body.append(box);
+
+	function drawBox(rect: any) {
+		if (!currentDom) {
+			return;
+		}
+		const div = document.createElement("div");
+		div.style.width = `${rect.width}px`;
+		div.style.height = `${rect.height}px`;
+		div.style.left = `${rect.left}px`;
+		div.style.top = `${rect.top}px`;
+		div.style.marginLeft = "0";
+		div.style.marginRight = "0";
+		div.style.position = "fixed";
+		div.style.border = "1px solid";
+		div.style.pointerEvents = "none";
+		box.append(div);
+	}
+
+	function getBoundingClientRect(dom: Element) {
+		const rect = dom.getBoundingClientRect();
+		// 获取元素的边距
+		const computedStyle = window.getComputedStyle(dom)
+		const marginLeft = parseFloat(computedStyle.marginLeft);
+		const marginTop = parseFloat(computedStyle.marginTop);
+		const marginBottom = parseFloat(computedStyle.marginBottom);
+		const marginRight = parseFloat(computedStyle.marginRight);
+		// 计算包含边距的宽度和高度
+		const width = rect.width + marginLeft + marginRight;
+		const height = rect.height + marginTop + marginBottom;
+
+		const isTable = dom.classList.contains("tableWrapper");
+
+		const res = {
+			top: rect.top - marginTop,
+			width: rect.width,
+			height: height,
+			left: rect.left,
+			x: rect.x,
+			y: rect.y - marginTop,
+			bottom: rect.bottom + marginBottom,
+			right: rect.right,
+		};
+
+		if (isTable) {
+			res.right += 10;
+			res.width += 10;
+		} else {
+			res.left -= 10;
+			res.right += 10;
+			res.width += 20;
+		}
+		return res;
+	}
+
+	function getHoverRect(dom: Element) {
+		box.innerHTML = "";
+		let index = 0;
+		const wrapRect = dom.getBoundingClientRect();
+
+		for (let child of dom.children) {
+			const rect = getBoundingClientRect(child);
+			const checkRect = {
+				...rect,
+			};
+			checkRect.left = wrapRect.left;
+			checkRect.right = wrapRect.right;
+			checkRect.width = wrapRect.width;
+			checkRect.top = rect.top;
+			checkRect.height = rect.height;
+
+			if (isMouseInsideElement(checkRect)) {
+				currentDom = child;
+				if (options.debug) {
+					drawBox(rect);
+				}
+				const range = getRange(child);
+				const preRange = getRange(dom.children[index - 1]);
+				const nextRange = getRange(dom.children[index + 1]);
+				return {
+					rect,
+					preRange,
+					range,
+					nextRange,
+				};
+			}
+			index += 1;
+		}
+	}
+
+	function isMouseInsideElement(rect: any) {
+		if (!mouseEvent) {
+			return false;
+		}
+		const mouseX = mouseEvent.clientX;
+		const mouseY = mouseEvent.clientY;
+		return (
+			mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom
+		);
+	}
+
+	function getRange(dom: Element): Range | undefined {
+		if (!dom) {
+			return undefined;
+		}
+
+		if (dom.tagName === "HR") {
+			const from = editor.view.posAtDOM(dom, 0);
+			const to = from + 1;
+			return {
+				from,
+				to,
+			};
+		}
+
+		const from = editor.view.posAtDOM(dom, 0) - 1;
+		const node = editor.state.doc.nodeAt(from)!;
+		const to = node.nodeSize + from;
+		return {
+			from,
+			to,
+		};
+	}
+
+	function render(dom: Element) {
+		if (!editor.isEditable) {
+			tippyInstance.hide();
+			return;
+		}
+		if (keepCurrentPos) {
+			return;
+		}
+		const pos = getHoverRect(dom);
+		if (pos) {
+			data.value.editor = editor;
+			data.value.nodeDOM = currentDom as HTMLElement;
+			data.value.rect = pos.rect as DOMRect;
+			data.value.range = pos.range;
+			data.value.preRange = pos.preRange;
+			data.value.nextRange = pos.nextRange;
+			if (pos.range) {
+				data.value.node = editor.state.doc.nodeAt(pos.range.from) as Node | null;
+			}
+			tippyInstance.setProps({
+				getReferenceClientRect: () => {
+					return pos.rect as DOMRect;
+				},
+			});
+			tippyInstance.show();
+		} else {
+			tippyInstance.hide();
+		}
+	}
+
+	return Extension.create({
+		name: "dragHandle",
+		onCreate() {
+			editor = this.editor;
+			const dom = this.editor.view.dom;
+			// 创建弹出层
+			// @ts-ignore
+			tippyInstance = tippy("body", {
+				duration: 0,
+				getReferenceClientRect: null,
+				content: options.element,
+				interactive: true,
+				trigger: "manual",
+				appendTo: dom.parentNode,
+				placement: "left-start",
+				hideOnClick: "toggle",
+			})[0];
+			dom.parentNode!.addEventListener("scroll", () => {
+				render(dom);
+			});
+			dom.addEventListener("mousemove", (e) => {
+				mouseEvent = e;
+				render(dom);
+			});
+			render(dom);
+		},
+		onUpdate() {
+			const dom = this.editor.view.dom;
+			render(dom);
+		},
+		onDestroy() {
+			tippyInstance?.destroy();
+		},
+	});
+}

+ 3 - 0
packages/vivid/src/core/extension/drag-handle/index.ts

@@ -0,0 +1,3 @@
+import DragHandle from "./DragHandle.vue";
+
+export { DragHandle };

+ 17 - 0
packages/vivid/src/core/extension/dropcursor/Dropcursor.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+	import Dropcursor, { DropcursorOptions } from "@tiptap/extension-dropcursor";
+	import { PropType } from "vue";
+	import { injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<DropcursorOptions>>,
+			required: false,
+		},
+	});
+
+	injectExtension(Dropcursor.configure(props.options));
+</script>
+
+<template></template>
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/dropcursor/index.ts

@@ -0,0 +1,8 @@
+import Dropcursor, { DropcursorOptions } from "@tiptap/extension-dropcursor";
+import DropcursorExt from "./Dropcursor.vue";
+
+export function useDropcursor(options?: Partial<DropcursorOptions>) {
+	return Dropcursor.configure(options);
+}
+
+export { DropcursorExt };

+ 17 - 0
packages/vivid/src/core/extension/focus/Focus.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+	import Focus, { FocusOptions } from "@tiptap/extension-focus";
+	import { PropType } from "vue";
+	import { injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<FocusOptions>>,
+			required: false,
+		},
+	});
+
+	injectExtension(Focus.configure(props.options));
+</script>
+
+<template></template>
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/focus/index.ts

@@ -0,0 +1,8 @@
+import Focus, { FocusOptions } from "@tiptap/extension-focus";
+import FocusExt from "./Focus.vue";
+
+export function useFocus(options?: Partial<FocusOptions>) {
+	return Focus.configure(options);
+}
+
+export { FocusExt };

+ 20 - 0
packages/vivid/src/core/extension/format-clear/FormatClear.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { useEditorInstance } from "../utils/common";
+
+	const editorInstance = useEditorInstance();
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="format-clear"
+				title="清除样式"
+				:action="() => editorInstance.chain().focus().clearNodes().unsetAllMarks().run()"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 3 - 0
packages/vivid/src/core/extension/format-clear/index.ts

@@ -0,0 +1,3 @@
+import FormatClearExt from "./FormatClear.vue";
+
+export { FormatClearExt };

+ 24 - 0
packages/vivid/src/core/extension/fullscreen/Fullscreen.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { useEditorInstance } from "../utils/common";
+
+	const editorInstance = useEditorInstance();
+
+	function toggleFullscreen() {
+		editorInstance.value.storage.fullscreen.value = !editorInstance.value.storage.fullscreen.value;
+	}
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				:icon="editorInstance.storage.fullscreen.value ? 'fullscreen-exit-line' : 'fullscreen-line'"
+				title="全屏"
+				:action="toggleFullscreen"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 3 - 0
packages/vivid/src/core/extension/fullscreen/index.ts

@@ -0,0 +1,3 @@
+import FullscreenExt from "./Fullscreen.vue";
+
+export { FullscreenExt };

+ 9 - 0
packages/vivid/src/core/extension/gapcursor/Gapcursor.vue

@@ -0,0 +1,9 @@
+<script setup lang="ts">
+	import Gapcursor from "@tiptap/extension-gapcursor";
+	import { injectExtension } from "../utils/common";
+
+	injectExtension(Gapcursor);
+</script>
+
+<template></template>
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/gapcursor/index.ts

@@ -0,0 +1,8 @@
+import Gapcursor from "@tiptap/extension-gapcursor";
+import GapcursorExt from "./Gapcursor.vue";
+
+export function useGapcursor() {
+	return Gapcursor;
+}
+
+export { GapcursorExt };

+ 17 - 0
packages/vivid/src/core/extension/hard-break/HardBreak.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+	import HardBreak, { HardBreakOptions } from "@tiptap/extension-hard-break";
+	import { PropType } from "vue";
+	import { injectExtension } from "../utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<HardBreakOptions>>,
+			required: false,
+		},
+	});
+
+	injectExtension(HardBreak.configure(props.options));
+</script>
+
+<template></template>
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/hard-break/index.ts

@@ -0,0 +1,8 @@
+import HardBreak, { HardBreakOptions } from "@tiptap/extension-hard-break";
+import HardBreakExt from "./HardBreak.vue";
+
+export function useHardBreak(options?: Partial<HardBreakOptions>) {
+	return HardBreak.configure(options);
+}
+
+export { HardBreakExt };

+ 64 - 0
packages/vivid/src/core/extension/heading/Heading.vue

@@ -0,0 +1,64 @@
+<script setup lang="ts">
+	import VividMenuItem from "@lib/core/components/VividMenuItem.vue";
+	import Heading, { HeadingOptions } from "@tiptap/extension-heading";
+	import { PropType } from "vue";
+	import { NSpace } from "naive-ui";
+	import { injectExtension, useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<HeadingOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(Heading.configure(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<n-space :size="2">
+				<vivid-menu-item
+					icon="h-1"
+					title="标题1"
+					:action="() => editorInstance.chain().focus().toggleHeading({ level: 1 }).run()"
+					:is-active="() => editorInstance.isActive('heading', { level: 1 })"
+				/>
+				<vivid-menu-item
+					icon="h-2"
+					title="标题2"
+					:action="() => editorInstance.chain().focus().toggleHeading({ level: 2 }).run()"
+					:is-active="() => editorInstance.isActive('heading', { level: 2 })"
+				/>
+				<vivid-menu-item
+					icon="h-3"
+					title="标题3"
+					:action="() => editorInstance.chain().focus().toggleHeading({ level: 3 }).run()"
+					:is-active="() => editorInstance.isActive('heading', { level: 3 })"
+				/>
+				<vivid-menu-item
+					icon="h-4"
+					title="标题4"
+					:action="() => editorInstance.chain().focus().toggleHeading({ level: 4 }).run()"
+					:is-active="() => editorInstance.isActive('heading', { level: 4 })"
+				/>
+				<vivid-menu-item
+					icon="h-5"
+					title="标题5"
+					:action="() => editorInstance.chain().focus().toggleHeading({ level: 5 }).run()"
+					:is-active="() => editorInstance.isActive('heading', { level: 5 })"
+				/>
+				<vivid-menu-item
+					icon="h-6"
+					title="标题6"
+					:action="() => editorInstance.chain().focus().toggleHeading({ level: 6 }).run()"
+					:is-active="() => editorInstance.isActive('heading', { level: 6 })"
+				/>
+			</n-space>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/heading/index.ts

@@ -0,0 +1,8 @@
+import Heading, { HeadingOptions } from "@tiptap/extension-heading";
+import HeadingExt from "./Heading.vue";
+
+export function useHeading(options?: Partial<HeadingOptions>) {
+	return Heading.configure(options);
+}
+
+export { HeadingExt };

+ 54 - 0
packages/vivid/src/core/extension/highlight/HighlightExt.vue

@@ -0,0 +1,54 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { ref, PropType } from "vue";
+	import { HighlightOptions } from "@tiptap/extension-highlight";
+	import VividColorPicker from "../../components/VividColorPicker.vue";
+	import { useHighlight } from "./highlight";
+	import { injectExtension, useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<HighlightOptions>>,
+			required: false,
+			default: () => {
+				return { multicolor: true };
+			},
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(useHighlight(props.options));
+
+	const colorHighlight = ref("#fec300");
+
+	function setHighlightColor() {
+		editorInstance.value.chain().focus().toggleHighlight({ color: colorHighlight.value }).run();
+	}
+
+	function updateHeightColor(newColor: string) {
+		colorHighlight.value = newColor;
+		editorInstance.value.chain().focus().setHighlight({ color: colorHighlight.value }).run();
+	}
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<div class="color-box">
+				<vivid-menu-item
+					icon="mark-pen-line"
+					title="高亮"
+					:action="setHighlightColor"
+					:is-active="() => editorInstance.isActive('highlight')"
+				/>
+				<vivid-color-picker :default-color="colorHighlight" @change="updateHeightColor" />
+			</div>
+		</slot>
+	</div>
+</template>
+
+<style scoped>
+	.color-box {
+		display: flex;
+	}
+</style>

+ 36 - 0
packages/vivid/src/core/extension/highlight/highlight.ts

@@ -0,0 +1,36 @@
+import Highlight, { HighlightOptions } from "@tiptap/extension-highlight";
+import { mergeAttributes } from "@tiptap/core";
+
+export function useHighlight(options?: Partial<HighlightOptions>) {
+	return Highlight.configure(options).extend({
+		addOptions() {
+			return {
+				...this.parent?.(),
+				...options,
+			};
+		},
+		renderHTML: function ({ HTMLAttributes }) {
+			const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes);
+			if (attrs.color) {
+				attrs.style = `background-color: ${attrs.color}`;
+			}
+			return ["mark", attrs, 0];
+		},
+		addAttributes: function () {
+			return {
+				...this.parent?.(),
+				// 添加新的属性
+				color: {
+					default: "none",
+					parseHTML: (element) => {
+						return element.getAttribute("data-color");
+					},
+					renderHTML: (attributes) => ({
+						"data-color": attributes.color,
+						style: `background-color: ${attributes.color}`,
+					}),
+				},
+			};
+		},
+	});
+}

+ 4 - 0
packages/vivid/src/core/extension/highlight/index.ts

@@ -0,0 +1,4 @@
+import HighlightExt from "./HighlightExt.vue";
+import { useHighlight } from "./highlight";
+
+export { useHighlight, HighlightExt };

+ 17 - 0
packages/vivid/src/core/extension/history/History.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+	import History, { HistoryOptions } from "@tiptap/extension-history";
+	import { PropType } from "vue";
+	import { injectExtension } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<HistoryOptions>>,
+			required: false,
+		},
+	});
+
+	injectExtension(History.configure(props.options));
+</script>
+
+<template></template>
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/history/index.ts

@@ -0,0 +1,8 @@
+import History, { HistoryOptions } from "@tiptap/extension-history";
+import HistoryExt from "./History.vue";
+
+export function useHistory(options?: Partial<HistoryOptions>) {
+	return History.configure(options);
+}
+
+export { HistoryExt };

+ 32 - 0
packages/vivid/src/core/extension/hocuspocus/HocuspocusExt.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts">
+	import { PropType } from "vue";
+	import { useHocuspocus, getRandomColor } from "./hocuspocus";
+	import { HocuspocusProviderConfiguration } from "@hocuspocus/provider";
+	import { injectExtension, uninjectExtension } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<HocuspocusProviderConfiguration>,
+			required: true,
+		},
+		user: {
+			type: Object as PropType<Record<string, any>>,
+			required: false,
+			default: () => {
+				return {
+					// avatar: '',
+					// name: '',
+					color: getRandomColor(),
+				};
+			},
+		},
+	});
+
+	uninjectExtension("history");
+	useHocuspocus(props.options, props.user).map((ext) => {
+		injectExtension(ext);
+	});
+</script>
+
+<template></template>
+<style scoped></style>

+ 31 - 0
packages/vivid/src/core/extension/hocuspocus/hocuspocus.ts

@@ -0,0 +1,31 @@
+import { HocuspocusProvider, HocuspocusProviderConfiguration } from "@hocuspocus/provider";
+import { Collaboration } from "@tiptap/extension-collaboration";
+import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
+import { onBeforeUnmount } from "vue";
+
+export function getRandomColor() {
+	const letters = "BCDEF".split("");
+	let color = "#";
+	for (let i = 0; i < 6; i++) {
+		color += letters[Math.floor(Math.random() * letters.length)];
+	}
+	return color;
+}
+
+export function useHocuspocus(options: HocuspocusProviderConfiguration, user: Record<string, any>) {
+	// Set up the Hocuspocus WebSocket provider
+	const provider = new HocuspocusProvider(options);
+	onBeforeUnmount(() => {
+		provider.disconnect();
+		provider.destroy();
+	});
+	return [
+		Collaboration.configure({
+			document: provider.document,
+		}),
+		CollaborationCursor.configure({
+			provider,
+			user,
+		}),
+	];
+}

+ 4 - 0
packages/vivid/src/core/extension/hocuspocus/index.ts

@@ -0,0 +1,4 @@
+import { useHocuspocus, getRandomColor } from "./hocuspocus.js";
+import HocuspocusExt from "./HocuspocusExt.vue";
+
+export { useHocuspocus, getRandomColor, HocuspocusExt };

+ 30 - 0
packages/vivid/src/core/extension/horizontal-rule/HorizontalRule.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import HorizontalRule, { HorizontalRuleOptions } from "@tiptap/extension-horizontal-rule";
+	import { PropType } from "vue";
+	import { injectExtension, useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<HorizontalRuleOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(HorizontalRule.configure(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="separator"
+				title="横线"
+				:action="() => editorInstance.chain().focus().setHorizontalRule().run()"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/horizontal-rule/index.ts

@@ -0,0 +1,8 @@
+import HorizontalRule, { HorizontalRuleOptions } from "@tiptap/extension-horizontal-rule";
+import HorizontalRuleExt from "./HorizontalRule.vue";
+
+export function useHorizontalRule(options?: Partial<HorizontalRuleOptions>) {
+	return HorizontalRule.configure(options);
+}
+
+export { HorizontalRuleExt };

+ 86 - 0
packages/vivid/src/core/extension/image/ImageBubbleMenu.vue

@@ -0,0 +1,86 @@
+<script setup lang="ts">
+	import { NSpace } from "naive-ui";
+	import VividMenuItem from "../../../core/components/VividMenuItem.vue";
+	import SvgIcon from "@jamescoyle/vue-icon/lib/svg-icon.vue";
+	import {
+		mdiFormatFloatLeft,
+		mdiFormatFloatRight,
+		mdiFormatFloatNone,
+		mdiSizeM,
+		mdiSizeL,
+		mdiSizeS,
+	} from "@mdi/js";
+	import { deleteSelection } from "prosemirror-commands";
+	import { useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const editorInstance = useEditorInstance();
+
+	function deleteImage() {
+		const { state, dispatch } = editorInstance.value.view;
+		deleteSelection(state, dispatch);
+	}
+
+	function toggleKeepRatio() {
+		if (editorInstance.value.isActive("image", { keepRatio: true })) {
+			editorInstance.value.chain().focus().updateImage({ keepRatio: false }).run();
+		} else {
+			editorInstance.value.chain().focus().updateImage({ keepRatio: true }).run();
+		}
+	}
+</script>
+
+<template>
+	<div>
+		<n-space :size="2">
+			<vivid-menu-item
+				title="左侧浮动"
+				:action="() => editorInstance.chain().focus().updateImage({ display: 'left' }).run()"
+				:isActive="() => editorInstance.isActive('image', { display: 'left' })"
+			>
+				<svg-icon type="mdi" :path="mdiFormatFloatLeft"></svg-icon>
+			</vivid-menu-item>
+			<vivid-menu-item
+				title="行内"
+				:action="() => editorInstance.chain().focus().updateImage({ display: 'inline' }).run()"
+				:isActive="() => editorInstance.isActive('image', { display: 'inline' })"
+			>
+				<svg-icon type="mdi" :path="mdiFormatFloatNone"></svg-icon>
+			</vivid-menu-item>
+			<vivid-menu-item
+				title="右侧浮动"
+				:action="() => editorInstance.chain().focus().updateImage({ display: 'right' }).run()"
+				:isActive="() => editorInstance.isActive('image', { display: 'right' })"
+			>
+				<svg-icon type="mdi" :path="mdiFormatFloatRight"></svg-icon>
+			</vivid-menu-item>
+			<vivid-menu-item
+				title="小型尺寸"
+				:action="() => editorInstance.chain().focus().updateImage({ width: 200 }).run()"
+				:isActive="() => editorInstance.isActive('image', { width: 200 })"
+			>
+				<svg-icon type="mdi" :path="mdiSizeS"></svg-icon>
+			</vivid-menu-item>
+			<vivid-menu-item
+				title="中型尺寸"
+				:action="() => editorInstance.chain().focus().updateImage({ width: 500 }).run()"
+				:isActive="() => editorInstance.isActive('image', { width: 500 })"
+			>
+				<svg-icon type="mdi" :path="mdiSizeM"></svg-icon>
+			</vivid-menu-item>
+			<vivid-menu-item
+				title="铺满"
+				:action="() => editorInstance.chain().focus().updateImage({ width: '100%' }).run()"
+				:isActive="() => editorInstance.isActive('image', { width: '100%' })"
+			>
+				<svg-icon type="mdi" :path="mdiSizeL"></svg-icon>
+			</vivid-menu-item>
+			<vivid-menu-item
+				icon="aspect-ratio-line"
+				title="锁定比例"
+				:action="toggleKeepRatio"
+				:isActive="() => editorInstance.isActive('image', { keepRatio: true })"
+			/>
+			<vivid-menu-item icon="delete-bin-line" title="删除" :action="deleteImage" />
+		</n-space>
+	</div>
+</template>

+ 56 - 0
packages/vivid/src/core/extension/image/ImageExt.vue

@@ -0,0 +1,56 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { ref, PropType } from "vue";
+	import { useImage } from "./image";
+	import VividImageModal from "./VividImageModal.vue";
+	import {
+		injectExtension,
+		onEditorCreated,
+		useEditorInstance,
+	} from "@lib/core/extension/utils/common";
+	import { UploadFunction } from "@lib/core/extension/types";
+
+	const props = defineProps({
+		handleUpload: {
+			type: Function as PropType<UploadFunction>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(useImage());
+
+	const HTI = ref<any>(null);
+
+	function handleOpenImage() {
+		HTI.value.open();
+	}
+
+	onEditorCreated(() => {
+		editorInstance.value.storage.image = {
+			openUploader: handleOpenImage,
+		};
+	});
+
+	function insertImage(url: string) {
+		if (url) {
+			editorInstance.value.chain().setImage({ src: url }).focus().run();
+		}
+	}
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="image-line"
+				title="插入图片"
+				:action="handleOpenImage"
+				:is-active="() => editorInstance.isActive('image')"
+			/>
+			<vivid-image-modal :handle-upload="handleUpload" ref="HTI" @ok="insertImage" />
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 186 - 0
packages/vivid/src/core/extension/image/ImageView.vue

@@ -0,0 +1,186 @@
+<script setup lang="ts">
+	import { isNumber, nodeViewProps, NodeViewWrapper } from "@tiptap/vue-3";
+	import Moveable from "vue3-moveable";
+	import { computed, ref, unref, watch } from "vue";
+
+	const props = defineProps({
+		...nodeViewProps,
+		selected: {
+			type: Boolean,
+			required: true,
+		},
+	});
+
+	const Wrap = ref<any>();
+	const targetRef = ref<any>();
+	const maxWidth = ref("auto");
+	const maxHeight = ref("auto");
+	const minWidth = ref("auto");
+	const minHeight = ref("auto");
+	const resizable = ref(true);
+	const rotatable = ref(false);
+	const keepRatio = ref(true);
+	const throttleResize = ref(1);
+	const renderDirections = ref(["nw", "n", "ne", "w", "e", "sw", "s", "se"]);
+
+	function updateAttr({ width, height, transform }: Record<string, any>) {
+		props.updateAttributes({
+			keepRatio: keepRatio.value,
+			width,
+			height,
+			transform,
+		});
+	}
+
+	const imgAttrs = computed(() => {
+		const { src, alt, width: w, height: h, keepRatio } = props.node.attrs;
+
+		const width = isNumber(w) ? w + "px" : w;
+		const height = keepRatio ? null : isNumber(h) ? h + "px" : h;
+
+		return {
+			src: src || undefined,
+			alt: alt || undefined,
+			style: {
+				width: width || undefined,
+				height: height || undefined,
+			},
+		};
+	});
+
+	const imageMaxStyle = computed(() => {
+		const {
+			style: { width },
+		} = unref(imgAttrs);
+		return { width: width === "100%" ? width : undefined };
+	});
+
+	const isResizing = ref(false);
+
+	const resizeState = ref({
+		width: undefined,
+		height: undefined,
+	});
+
+	function onResizeStart() {
+		isResizing.value = true;
+	}
+
+	const onResize = async (e) => {
+		isResizing.value = true;
+		e.width = Math.round(e.width);
+		e.height = Math.round(e.height);
+
+		const maxWidth = Math.round(Wrap.value.$el.parentNode.getBoundingClientRect().width);
+		if (keepRatio.value) {
+			if (e.width >= maxWidth) {
+				e.width = maxWidth;
+				e.height = e.width / e.startRatio;
+				updateAttr({
+					width: "100%",
+					height: e.height,
+				});
+				return;
+			}
+		}
+
+		updateAttr({
+			width: e.width,
+			height: e.height,
+		});
+
+		e.target.style.width = `${e.width}px`;
+		e.target.style.height = `${e.height}px`;
+	};
+
+	function onResizeEnd(e) {
+		isResizing.value = false;
+		resizeState.value.width = imgAttrs.value.style.width;
+		resizeState.value.height = imgAttrs.value.style.height;
+		selectImage();
+	}
+
+	watch(
+		() => props.node.attrs,
+		() => {
+			keepRatio.value = props.node.attrs.keepRatio;
+		},
+		{
+			immediate: true,
+			deep: true,
+		},
+	);
+
+	function selectImage() {
+		const { editor, getPos } = props;
+		editor.commands.setNodeSelection(getPos());
+	}
+</script>
+
+<template>
+	<node-view-wrapper
+		ref="Wrap"
+		class="vivid-image"
+		as="span"
+		:class="[props.node.attrs.display]"
+		:style="imageMaxStyle"
+	>
+		<div class="vivid-image-container" :style="imageMaxStyle">
+			<div
+				class="move-box"
+				:style="`max-width: ${maxWidth};max-height: ${maxHeight};min-width: ${minWidth};min-height: ${minHeight};width:${resizeState.width}px;height:${resizeState.height}px`"
+				ref="targetRef"
+			>
+				<img :src="imgAttrs.src" :alt="imgAttrs.alt" :style="imgAttrs.style" />
+			</div>
+			<Moveable
+				v-if="selected || isResizing"
+				:target="targetRef"
+				:resizable="resizable && editor.isEditable"
+				:rotatable="rotatable"
+				:keepRatio="keepRatio"
+				:throttleResize="throttleResize"
+				:renderDirections="renderDirections"
+				:useResizeObserver="true"
+				@resize="onResize"
+				@resizeEnd="onResizeEnd"
+			></Moveable>
+		</div>
+	</node-view-wrapper>
+</template>
+
+<style scoped>
+	.vivid-image {
+		line-height: 0;
+		display: inline-block;
+		vertical-align: baseline;
+		max-width: 100%;
+		user-select: auto;
+		position: relative;
+	}
+
+	.vivid-image.left {
+		float: left;
+	}
+
+	.vivid-image.right {
+		float: right;
+	}
+
+	.vivid-image.inline {
+		float: none;
+	}
+
+	.vivid-image-container {
+		position: relative;
+		display: inline-block;
+		vertical-align: baseline;
+		clear: both;
+		max-width: 100%;
+		z-index: 1;
+	}
+
+	.move-box {
+		max-width: -webkit-fill-available;
+	}
+</style>

+ 148 - 0
packages/vivid/src/core/extension/image/VividImageModal.vue

@@ -0,0 +1,148 @@
+<script setup lang="ts">
+	import VividSimpleUpload from "../../components/VividSimpleUpload.vue";
+	import {
+		NModal,
+		NProgress,
+		NInputGroup,
+		NInput,
+		NForm,
+		NFormItem,
+		NButton,
+		NSpace,
+		NTabs,
+		NTabPane,
+	} from "naive-ui";
+	import { ref, watch } from "vue";
+	import { PropType } from "vue";
+	import { ImageAttrsOptions } from "@lib/core/extension/image/image";
+	import { UploadFunction } from "@lib/core/extension/types";
+
+	const showModal = ref(false);
+
+	const tabName = ref("本地图片");
+
+	const props = defineProps({
+		handleUpload: {
+			type: Function as PropType<UploadFunction>,
+			required: false,
+		},
+	});
+
+	function changeTab(name: string) {
+		tabName.value = name;
+		if (name === "本地图片") {
+			readySave.value = false;
+		}
+		if (name === "网络图片") {
+			readySave.value = !!url.value;
+			readyFile.value = null;
+		}
+	}
+
+	const href = ref("");
+
+	const simpleUpload = ref<any>(null);
+
+	const emit = defineEmits(["ok"]);
+
+	function open(attrs?: ImageAttrsOptions) {
+		percent.value = 0;
+		readyFile.value = null;
+		readySave.value = false;
+		showModal.value = true;
+		tabName.value = "本地图片";
+		href.value = attrs?.src || "";
+	}
+
+	const readyFile = ref<File | null>(null);
+	const readySave = ref(false);
+  const loading = ref(false);
+	const url = ref("");
+
+	async function onOk() {
+		showModal.value = false;
+		emit("ok", url.value);
+	}
+
+	watch(href, () => {
+		url.value = href.value;
+		readySave.value = !!url.value;
+	});
+
+	const percent = ref(0);
+
+	function updateProgress(n: number) {
+		percent.value = n;
+	}
+
+	async function handleUpload() {
+    loading.value = true
+		if (!readyFile.value) {
+			return;
+		}
+		if (props.handleUpload) {
+			percent.value = 0;
+			const path = await props.handleUpload(readyFile.value, updateProgress);
+			url.value = path;
+			href.value = url.value;
+			readySave.value = true;
+		} else {
+			percent.value = 100;
+			url.value = URL.createObjectURL(readyFile.value);
+			href.value = url.value;
+			readySave.value = true;
+		}
+    loading.value = false
+	}
+
+	function onCancel() {
+		percent.value = 0;
+		showModal.value = false;
+		readySave.value = false;
+	}
+
+	function onChange(file: File) {
+		percent.value = 0;
+		readySave.value = false;
+		url.value = "";
+		readyFile.value = file;
+	}
+
+	defineExpose({ open });
+</script>
+
+<template>
+	<n-modal v-model:show="showModal" preset="card" style="width: 450px">
+		<template #header>
+			<div>插入图片</div>
+		</template>
+		<div>
+			<n-tabs type="line" animated :default-value="tabName" @update:value="changeTab">
+				<n-tab-pane name="网络图片">
+					<n-form label-placement="left" label-width="auto">
+						<n-form-item label="地址">
+							<n-input-group>
+								<n-input v-model:value="href" />
+							</n-input-group>
+						</n-form-item>
+					</n-form>
+				</n-tab-pane>
+				<n-tab-pane name="本地图片">
+					<div>
+						<vivid-simple-upload ref="simpleUpload" @change="onChange" />
+					</div>
+					<n-progress v-if="readyFile" :percentage="percent" />
+				</n-tab-pane>
+			</n-tabs>
+		</div>
+		<template #footer>
+			<n-space justify="end">
+				<n-button @click="onCancel"> 取消</n-button>
+				<n-button v-if="readyFile && !readySave" type="info" @click="handleUpload" :loading="loading"> 上传 </n-button>
+				<n-button v-if="readySave" type="success" @click="onOk"> 确定 </n-button>
+			</n-space>
+		</template>
+	</n-modal>
+</template>
+
+<style scoped></style>

+ 105 - 0
packages/vivid/src/core/extension/image/image.ts

@@ -0,0 +1,105 @@
+import { VueNodeViewRenderer } from "@tiptap/vue-3";
+import { Image as TiptapImage } from "@tiptap/extension-image";
+import ImageView from "./ImageView.vue";
+
+export type Display = "block" | "inline" | "left" | "right";
+
+export interface ImageAttrsOptions {
+	/** The source URL of the image. */
+	src?: string;
+	/** The alternative text for the image. */
+	alt?: string;
+	/** The title of the image. */
+	title?: string;
+	/** Indicates whether the aspect ratio of the image should be locked. */
+	keepRatio?: boolean;
+	/** The width of the image. */
+	width?: number | string | null;
+	/** The height of the image. */
+	height?: number | string | null;
+	/** The display style of the image. */
+	display?: Display;
+}
+
+export interface SetImageAttrsOptions extends ImageAttrsOptions {
+	/** The source URL of the image. */
+	src: string;
+}
+
+declare module "@tiptap/core" {
+	interface Commands<ReturnType> {
+		imageResize: {
+			/**
+			 * Add an image
+			 */
+			setImage: (options: Partial<SetImageAttrsOptions>) => ReturnType;
+			/**
+			 * Update an image
+			 */
+			updateImage: (options: Partial<SetImageAttrsOptions>) => ReturnType;
+		};
+	}
+}
+
+export function useImage() {
+	return TiptapImage.extend({
+		addOptions() {
+			return {
+				...this.parent?.(),
+				inline: true,
+				HTMLAttributes: {},
+			};
+		},
+		addNodeView() {
+			return VueNodeViewRenderer(ImageView);
+		},
+		addAttributes() {
+			return {
+				src: {
+					default: null,
+				},
+				alt: {
+					default: null,
+				},
+				keepRatio: {
+					default: true,
+				},
+				title: {
+					default: null,
+				},
+				width: {
+					default: "100%",
+				},
+				height: {
+					default: null,
+				},
+				display: {
+					default: "inline",
+					renderHTML: ({ display }) => {
+						if (!display) {
+							return {};
+						}
+						return {
+							"data-display": display,
+						};
+					},
+					parseHTML: (element) => {
+						const display = element.getAttribute("data-display");
+						return display || "inline";
+					},
+				},
+			};
+		},
+
+		addCommands() {
+			return {
+				...this.parent?.(),
+				updateImage:
+					(options) =>
+					({ commands }) => {
+						return commands.updateAttributes(this.name, options);
+					},
+			};
+		},
+	});
+}

+ 5 - 0
packages/vivid/src/core/extension/image/index.ts

@@ -0,0 +1,5 @@
+import { useImage } from "./image";
+import ImageExt from "./ImageExt.vue";
+import ImageBubbleMenu from "./ImageBubbleMenu.vue";
+
+export { useImage, ImageBubbleMenu, ImageExt };

+ 17 - 0
packages/vivid/src/core/extension/indent/IndentExt.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+	import { PropType } from "vue";
+	import { IndentOptions, useIndent } from "./indent";
+	import { injectExtension } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<IndentOptions>>,
+			required: false,
+		},
+	});
+
+	injectExtension(useIndent(props.options));
+</script>
+
+<template></template>
+<style scoped></style>

+ 156 - 0
packages/vivid/src/core/extension/indent/indent.ts

@@ -0,0 +1,156 @@
+import { Editor, Extension, isList } from "@tiptap/core";
+import { TextSelection, AllSelection, Transaction } from "@tiptap/pm/state";
+
+export interface IndentOptions {
+	types: string[];
+	minIndent: number;
+	maxIndent: number;
+}
+
+declare module "@tiptap/core" {
+	interface Commands<ReturnType> {
+		indent: {
+			/**
+			 * Set the indent attribute
+			 */
+			indent: () => ReturnType;
+			/**
+			 * Set the outdent attribute
+			 */
+			outdent: () => ReturnType;
+		};
+	}
+}
+
+function clamp(val: number, min: number, max: number): number {
+	if (val < min) {
+		return min;
+	}
+	if (val > max) {
+		return max;
+	}
+	return val;
+}
+
+function updateIndentLevel(tr: Transaction, delta: number, types: string[], editor: Editor) {
+	const { doc, selection } = tr;
+
+	if (!doc || !selection) return tr;
+
+	if (!(selection instanceof TextSelection || selection instanceof AllSelection)) {
+		return tr;
+	}
+
+	const { from, to } = selection;
+
+	doc.nodesBetween(from, to, (node, pos) => {
+		const nodeType = node.type;
+
+		if (types.includes(nodeType.name)) {
+			tr = setNodeIndentMarkup(tr, pos, delta);
+			return false;
+		} else if (isList(node.type.name, editor.extensionManager.extensions)) {
+			return false;
+		}
+		return true;
+	});
+
+	return tr;
+}
+
+function setNodeIndentMarkup(tr: Transaction, pos: number, delta: number) {
+	if (!tr.doc) return tr;
+
+	const node = tr.doc.nodeAt(pos);
+	if (!node) return tr;
+
+	const minIndent = 0;
+	const maxIndent = 7;
+
+	const indent = clamp((node.attrs.indent || 0) + delta, minIndent, maxIndent);
+
+	if (indent === node.attrs.indent) return tr;
+
+	const nodeAttrs = {
+		...node.attrs,
+		indent,
+	};
+
+	return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks);
+}
+
+function createIndentCommand({ delta, types }: { delta: number; types: string[] }) {
+	return ({ state, dispatch, editor }) => {
+		const { selection } = state;
+		let { tr } = state;
+		tr = tr.setSelection(selection);
+		tr = updateIndentLevel(tr, delta, types, editor);
+
+		if (tr.docChanged) {
+			dispatch && dispatch(tr);
+			return true;
+		}
+
+		return false;
+	};
+}
+
+export function useIndent(options?: Partial<IndentOptions>) {
+	return Extension.create({
+		name: "indent",
+		addOptions() {
+			return {
+				types: ["paragraph", "heading"],
+				minIndent: 0,
+				maxIndent: 7,
+				...options,
+			};
+		},
+
+		addGlobalAttributes() {
+			return [
+				{
+					types: this.options.types,
+					attributes: {
+						indent: {
+							default: 0,
+							parseHTML: (element) => {
+								const identAttr = element.getAttribute("data-indent");
+								return (identAttr ? parseInt(identAttr, 10) : 0) || 0;
+							},
+							renderHTML: (attributes) => {
+								if (!attributes.indent) {
+									return {};
+								}
+
+								return { "data-indent": attributes.indent };
+							},
+						},
+					},
+				},
+			];
+		},
+
+		addCommands() {
+			return {
+				indent: () =>
+					createIndentCommand({
+						delta: 1,
+						types: this.options.types,
+					}),
+				outdent: () =>
+					createIndentCommand({
+						delta: -1,
+						types: this.options.types,
+					}),
+			};
+		},
+
+		addKeyboardShortcuts() {
+			return {
+				Tab: () => this.editor.commands.indent(),
+				"Shift-Tab": () => this.editor.commands.outdent(),
+			};
+		},
+	});
+}

+ 4 - 0
packages/vivid/src/core/extension/indent/index.ts

@@ -0,0 +1,4 @@
+import { useIndent } from "./indent";
+import IndentExt from "./IndentExt.vue";
+
+export { useIndent, IndentExt };

+ 50 - 0
packages/vivid/src/core/extension/index.ts

@@ -0,0 +1,50 @@
+export * from "./blockquote";
+export * from "./bold";
+export * from "./bullet-list";
+export * from "./character-count";
+export * from "./code";
+export * from "./code-block";
+export * from "./color";
+export * from "./divider";
+export * from "./document";
+export * from "./dropcursor";
+export * from "./gapcursor";
+export * from "./format-clear";
+export * from "./fullscreen";
+export * from "./hard-break";
+export * from "./heading";
+export * from "./highlight";
+export * from "./history";
+export * from "./horizontal-rule";
+export * from "./image";
+export * from "./indent";
+export * from "./italic";
+export * from "./link";
+export * from "./list-item";
+export * from "./math";
+export * from "./ordered-list";
+export * from "./paragraph";
+export * from "./placeholder";
+export * from "./strike";
+export * from "./subscript";
+export * from "./superscript";
+export * from "./table";
+export * from "./task-list";
+export * from "./text";
+export * from "./text-align";
+export * from "./text-style";
+export * from "./underline";
+export * from "./video";
+export * from "./undo";
+export * from "./redo";
+export * from "./hocuspocus";
+export * from "./focus";
+export * from "./ai";
+export * from "./line-height";
+export * from "./slash-command";
+export * from "./trailing-node";
+export * from "./drag-handle";
+export * from "./section";
+export * from "./copypaste";
+export * from "./upload-manager";
+export * from "./outline";

+ 31 - 0
packages/vivid/src/core/extension/italic/Italic.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import Italic, { ItalicOptions } from "@tiptap/extension-italic";
+	import { PropType } from "vue";
+	import { injectExtension, useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<ItalicOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(Italic.configure(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="italic"
+				title="斜体"
+				:action="() => editorInstance.chain().focus().toggleItalic().run()"
+				:is-active="() => editorInstance.isActive('italic')"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/italic/index.ts

@@ -0,0 +1,8 @@
+import Italic, { ItalicOptions } from "@tiptap/extension-italic";
+import ItalicExt from "./Italic.vue";
+
+export function useItalic(options?: Partial<ItalicOptions>) {
+	return Italic.configure(options);
+}
+
+export { ItalicExt };

+ 59 - 0
packages/vivid/src/core/extension/line-height/LineHeight.vue

@@ -0,0 +1,59 @@
+<template>
+	<div v-if="editorInstance">
+		<n-popselect
+			v-model:value="value"
+			:options="LineHeights"
+			trigger="click"
+			:on-update:value="toggleLineHeight"
+		>
+			<slot>
+				<vivid-menu-item icon="line-height" title="行高" :action="() => {}" />
+			</slot>
+		</n-popselect>
+	</div>
+</template>
+
+<script setup lang="ts">
+	import { ref, computed } from "vue";
+	import { NPopselect } from "naive-ui";
+	import { useLineHeight } from "./index.js";
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { injectExtension, useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const editorInstance = useEditorInstance();
+	injectExtension(useLineHeight());
+
+	function percentageToDecimal(percentageString: string) {
+		// 去掉百分号并转换成数字
+		const percentage = parseFloat(percentageString.replace("%", ""));
+		// 将百分比转换成小数
+		return percentage / 100;
+	}
+
+	const LineHeights = computed(() => {
+		const lineHeightOptions = editorInstance.value.extensionManager.extensions.find(
+			(e) => e.name === "lineHeight",
+		)!.options;
+		const a = lineHeightOptions.lineHeights;
+		const b = a.map((item) => ({
+			label: percentageToDecimal(item),
+			value: item,
+		}));
+		b.unshift({
+			label: "默认",
+			value: "default",
+		});
+		return b;
+	});
+
+	const value = ref("default");
+
+	function toggleLineHeight(key: string) {
+		if (key === "default") {
+			editorInstance.value.commands.unsetLineHeight();
+		} else {
+			editorInstance.value.commands.setLineHeight(key);
+		}
+		value.value = key;
+	}
+</script>

+ 69 - 0
packages/vivid/src/core/extension/line-height/index.ts

@@ -0,0 +1,69 @@
+import { Extension } from "@tiptap/core";
+import { createLineHeightCommand } from "./utils";
+import LineHeightExt from "./LineHeight.vue";
+
+export interface LineHeightOptions {
+	types: string[];
+	lineHeights: string[];
+	defaultHeight: string;
+}
+
+declare module "@tiptap/core" {
+	interface Commands<ReturnType> {
+		lineHeight: {
+			setLineHeight: (lineHeight: string) => ReturnType;
+			unsetLineHeight: () => ReturnType;
+		};
+	}
+}
+
+export function useLineHeight() {
+	return Extension.create<LineHeightOptions>({
+		name: "lineHeight",
+		addOptions() {
+			return {
+				...this.parent?.(),
+				types: ["paragraph", "heading", "list_item", "todo_item"],
+				lineHeights: ["100%", "115%", "150%", "200%", "250%", "300%"],
+				defaultHeight: "100%",
+			};
+		},
+		addGlobalAttributes() {
+			return [
+				{
+					types: this.options.types,
+					attributes: {
+						lineHeight: {
+							default: null,
+							parseHTML: (element) => {
+								return element.style.lineHeight || this.options.defaultHeight;
+							},
+							renderHTML: (attributes) => {
+								if (
+									attributes.lineHeight === this.options.defaultHeight ||
+									!attributes.lineHeight
+								) {
+									return {};
+								}
+								return { style: `line-height: ${attributes.lineHeight}` };
+							},
+						},
+					},
+				},
+			];
+		},
+
+		addCommands() {
+			return {
+				setLineHeight: (lineHeight) => createLineHeightCommand(lineHeight),
+				unsetLineHeight:
+					() =>
+					({ commands }) => {
+						return this.options.types.every((type) => commands.resetAttributes(type, "lineHeight"));
+					},
+			};
+		},
+	});
+}
+
+export { LineHeightExt };

+ 134 - 0
packages/vivid/src/core/extension/line-height/utils.ts

@@ -0,0 +1,134 @@
+import { TextSelection, AllSelection, EditorState, Transaction } from "@tiptap/pm/state";
+import { Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model";
+import type { Command } from "@tiptap/core";
+
+export const LINE_HEIGHT_100 = 1.7;
+export const DEFAULT_LINE_HEIGHT = "100%";
+
+export const ALLOWED_NODE_TYPES = ["paragraph", "heading", "list_item", "todo_item"];
+
+const NUMBER_VALUE_PATTERN = /^\d+(.\d+)?$/;
+
+export function isLineHeightActive(state: EditorState, lineHeight: string): boolean {
+	const { selection, doc } = state;
+	const { from, to } = selection;
+
+	let keepLooking = true;
+	let active = false;
+
+	doc.nodesBetween(from, to, (node) => {
+		const nodeType = node.type;
+		const lineHeightValue = node.attrs.lineHeight || DEFAULT_LINE_HEIGHT;
+
+		if (ALLOWED_NODE_TYPES.includes(nodeType.name)) {
+			if (keepLooking && lineHeight === lineHeightValue) {
+				keepLooking = false;
+				active = true;
+
+				return false;
+			}
+			return nodeType.name !== "list_item" && nodeType.name !== "todo_item";
+		}
+		return keepLooking;
+	});
+
+	return active;
+}
+
+export function transformLineHeightToCSS(value: string | number): string {
+	if (!value) return "";
+
+	let strValue = String(value);
+
+	if (NUMBER_VALUE_PATTERN.test(strValue)) {
+		const numValue = parseFloat(strValue);
+		strValue = String(Math.round(numValue * 100)) + "%";
+	}
+
+	return parseFloat(strValue) * LINE_HEIGHT_100 + "%";
+}
+
+export function transformCSStoLineHeight(value: string): string {
+	if (!value) return "";
+	if (value === DEFAULT_LINE_HEIGHT) return "";
+
+	let strValue = value;
+
+	if (NUMBER_VALUE_PATTERN.test(value)) {
+		const numValue = parseFloat(value);
+		strValue = String(Math.round(numValue * 100)) + "%";
+		if (strValue === DEFAULT_LINE_HEIGHT) return "";
+	}
+
+	return parseFloat(strValue) / LINE_HEIGHT_100 + "%";
+}
+
+interface SetLineHeightTask {
+	node: ProsemirrorNode;
+	nodeType: NodeType;
+	pos: number;
+}
+
+export function setTextLineHeight(tr: Transaction, lineHeight: string | null): Transaction {
+	const { selection, doc } = tr;
+
+	if (!selection || !doc) return tr;
+
+	if (!(selection instanceof TextSelection || selection instanceof AllSelection)) {
+		return tr;
+	}
+
+	const { from, to } = selection;
+
+	const tasks: Array<SetLineHeightTask> = [];
+	const lineHeightValue = lineHeight && lineHeight !== DEFAULT_LINE_HEIGHT ? lineHeight : null;
+
+	doc.nodesBetween(from, to, (node, pos) => {
+		const nodeType = node.type;
+		if (ALLOWED_NODE_TYPES.includes(nodeType.name)) {
+			const lineHeight = node.attrs.lineHeight || null;
+			if (lineHeight !== lineHeightValue) {
+				tasks.push({
+					node,
+					pos,
+					nodeType,
+				});
+			}
+			return nodeType.name !== "list_item" && nodeType.name !== "todo_item";
+		}
+		return true;
+	});
+
+	if (!tasks.length) return tr;
+
+	tasks.forEach((task) => {
+		const { node, pos, nodeType } = task;
+		let { attrs } = node;
+
+		attrs = {
+			...attrs,
+			lineHeight: lineHeightValue,
+		};
+
+		tr = tr.setNodeMarkup(pos, nodeType, attrs, node.marks);
+	});
+
+	return tr;
+}
+
+export function createLineHeightCommand(lineHeight: string): Command {
+	return ({ state, dispatch }) => {
+		const { selection } = state;
+		let { tr } = state;
+		tr = tr.setSelection(selection);
+
+		tr = setTextLineHeight(tr, lineHeight);
+
+		if (tr.docChanged) {
+			dispatch && dispatch(tr);
+			return true;
+		}
+
+		return false;
+	};
+}

+ 268 - 0
packages/vivid/src/core/extension/link/LinkExt.vue

@@ -0,0 +1,268 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { PropType, ref } from "vue";
+	import VividLinkModal from "./VividLinkModal.vue";
+	import { useLink, VividLinkOptions } from "./link";
+	import { getAttributes, getMarkRange } from "@tiptap/core";
+	import {
+		NCard,
+		NInputGroup,
+		NInput,
+		NRadioGroup,
+		NRadio,
+		NForm,
+		NFormItem,
+		NButton,
+		NSpace,
+	} from "naive-ui";
+	import {
+		injectExtension,
+		onEditorCreated,
+		useEditorInstance,
+	} from "@lib/core/extension/utils/common";
+	import { EditorView } from "prosemirror-view";
+	import { MarkType } from "@tiptap/pm/model";
+	import tippy, { Instance } from "tippy.js";
+	import { TextSelection } from "@tiptap/pm/state";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<VividLinkOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+
+	const root = ref<any>();
+	const HTL = ref<any>(null);
+	const isEdit = ref(false);
+	const href = ref("");
+	const target = ref("_blank");
+	let tippyInstance: Instance;
+
+	function handleOpenLink() {
+		if (editorInstance.value.isActive("link")) {
+			editorInstance.value.chain().focus().unsetLink().run();
+		} else {
+			HTL.value!.open();
+		}
+	}
+
+	onEditorCreated(() => {
+		editorInstance.value.storage.link = {
+			openLink: handleOpenLink,
+		};
+	});
+
+	function setLink(text: string, href: string, target: string) {
+		console.log(text, href, target);
+		if (text) {
+			editorInstance.value
+				.chain()
+				// .extendMarkRange("link")
+				.insertContent({
+					type: "text",
+					text: text,
+					marks: [
+						{
+							type: "link",
+							attrs: {
+								href: href,
+								target: target,
+							},
+						},
+					],
+				})
+				.setLink({ href: href })
+				.focus()
+				.run();
+		} else {
+			editorInstance.value
+				.chain()
+				.setLink({ href: href, target: target })
+				.focus()
+				.run();
+		}
+
+	}
+
+	function handleLinkClick(view: EditorView, pos: number, event: MouseEvent, type: MarkType) {
+		if (!view.editable) {
+			return false;
+		}
+		if (event.button !== 0) {
+			return false;
+		}
+		let a = event.target as HTMLElement;
+		const els: HTMLElement[] = [];
+		while (a.nodeName !== "DIV") {
+			els.push(a);
+			a = a.parentNode as HTMLElement;
+		}
+		if (!els.find((value) => value.nodeName === "A")) {
+			return false;
+		}
+		const attrs = getAttributes(view.state, type.name);
+		const link = event.target as HTMLLinkElement;
+
+		const node = view.state.doc.nodeAt(pos);
+		if (node) {
+			const linkNode = node.marks.filter((e) => e.type.name === "link");
+			if (linkNode.length) {
+				const { schema, doc, tr } = view.state;
+				const range = getMarkRange(doc.resolve(pos), schema.marks.link);
+				if (!range) return false;
+				const $start = doc.resolve(range.from);
+				const $end = doc.resolve(range.to);
+				const transaction = tr.setSelection(new TextSelection($start, $end));
+				view.dispatch(transaction);
+				destroyTooltip();
+				createTooltip(link, attrs);
+
+				return true;
+			}
+		}
+		return false;
+	}
+
+	function createTooltip(linkElement: HTMLLinkElement, attrs: Record<string, any>) {
+		if (!root.value) {
+			return;
+		}
+		href.value = linkElement?.href ?? attrs.href;
+		target.value = linkElement?.target ?? attrs.target;
+
+		const container = document.createElement("div");
+		container.append(root.value);
+		tippyInstance = tippy("body", {
+			duration: 0,
+			getReferenceClientRect: () => linkElement.getBoundingClientRect(),
+			content: container,
+			interactive: true,
+			trigger: "manual",
+			placement: "bottom-start",
+		})[0];
+		tippyInstance.show();
+	}
+
+	function destroyTooltip() {
+		if (tippyInstance) {
+			tippyInstance.destroy();
+		}
+		isEdit.value = false;
+		return false;
+	}
+
+	injectExtension(
+		useLink({
+			handleClick: handleLinkClick,
+			handleKeyDown: destroyTooltip,
+			protocols: ["ftp", "mailto", "http", "https"],
+			autolink: false,
+		}),
+	);
+
+	function onCancel() {
+		destroyTooltip();
+	}
+
+	function unsetLink() {
+		editorInstance.value.chain().focus().unsetLink().run();
+		destroyTooltip();
+	}
+
+	function onOk() {
+		editorInstance.value
+			.chain()
+			.extendMarkRange("link")
+			.setLink({ href: href.value, target: target.value })
+			.focus()
+			.run();
+		destroyTooltip();
+	}
+
+	function openLink() {
+		window.open(href.value, target.value);
+	}
+</script>
+
+<template>
+	<div>
+		<slot>
+			<vivid-menu-item
+				icon="link"
+				title="超链接"
+				:action="handleOpenLink"
+				:is-active="() => editorInstance?.isActive('link')"
+			/>
+			<vivid-link-modal ref="HTL" @ok="setLink" />
+		</slot>
+		<div style="display: none">
+			<div ref="root">
+				<n-card size="small" class="link-card" v-if="!isEdit">
+					<div class="link-pop">
+						<div class="link-href" @click="openLink">
+							{{ href }}
+						</div>
+						<n-button text @click="isEdit = true">
+							<i class="ri-lg ri-edit-circle-line"></i>
+						</n-button>
+						<n-button text type="error"  @click="unsetLink">
+							<i class="ri-lg ri-delete-bin-5-line"></i>
+						</n-button>
+					</div>
+				</n-card>
+				<n-card size="small" class="link-card" v-else>
+					<n-form label-placement="left" label-width="auto">
+						<n-form-item label="链接地址" :show-feedback="false">
+							<n-input-group>
+								<n-input v-model:value="href" />
+							</n-input-group>
+						</n-form-item>
+						<n-form-item label="打开方式" :show-feedback="false">
+							<n-radio-group v-model:value="target">
+								<n-space>
+									<n-radio value="_self"> 当前窗口</n-radio>
+									<n-radio value="_blank"> 新窗口</n-radio>
+								</n-space>
+							</n-radio-group>
+						</n-form-item>
+					</n-form>
+					<template #footer>
+						<n-space justify="end">
+							<n-button @click="onCancel" size="small"> 取消</n-button>
+							<n-button type="info" @click="onOk" size="small"> 确定</n-button>
+						</n-space>
+					</template>
+				</n-card>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style scoped>
+	.link-card {
+		width: 400px;
+		box-shadow: 0 6px 16px -9px rgba(0, 0, 0, 0.08),
+		0 9px 28px 0 rgba(0, 0, 0, 0.05),
+		0 12px 48px 16px rgba(0, 0, 0, 0.03);
+		border-radius: 10px;
+	}
+
+	.link-pop {
+		display: flex;
+		align-items: center;
+		gap: 12px;
+		width: 100%;
+	}
+
+	.link-href {
+		flex: 1;
+		word-break: keep-all;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		text-decoration: underline;
+		cursor: pointer;
+	}
+</style>

+ 85 - 0
packages/vivid/src/core/extension/link/VividLinkModal.vue

@@ -0,0 +1,85 @@
+<script setup>
+	import {
+		NModal,
+		NInputGroup,
+		NSelect,
+		NInput,
+		NRadioGroup,
+		NRadio,
+		NForm,
+		NFormItem,
+		NButton,
+		NSpace,
+	} from "naive-ui";
+	import { ref } from "vue";
+	import { useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const showModal = ref(false);
+
+	const href = ref("");
+	const text = ref("");
+	const target = ref("_blank");
+	const isInsert = ref(false)
+
+	const emit = defineEmits(["ok"]);
+
+	const editor = useEditorInstance()
+
+	function open() {
+		showModal.value = true;
+		target.value = "_blank";
+		href.value = "";
+		text.value = "";
+		const selection = editor.value.state.selection
+		isInsert.value = selection.from === selection.to
+	}
+
+	function onOk() {
+		showModal.value = false;
+		emit("ok", text.value, href.value, target.value);
+	}
+
+	function onCancel() {
+		showModal.value = false;
+	}
+
+	defineExpose({ open });
+</script>
+
+<template>
+	<n-modal v-model:show="showModal" preset="card" style="width: 450px">
+		<template #header>
+			<div>插入超链接</div>
+		</template>
+		<div>
+			<n-form label-placement="left" label-width="auto">
+				<n-form-item label="文字" v-if="isInsert">
+					<n-input-group>
+						<n-input v-model:value="text" />
+					</n-input-group>
+				</n-form-item>
+				<n-form-item label="链接地址">
+					<n-input-group>
+						<n-input v-model:value="href"/>
+					</n-input-group>
+				</n-form-item>
+				<n-form-item label="打开方式">
+					<n-radio-group v-model:value="target">
+						<n-space>
+							<n-radio value="_self"> 当前窗口 </n-radio>
+							<n-radio value="_blank"> 新窗口 </n-radio>
+						</n-space>
+					</n-radio-group>
+				</n-form-item>
+			</n-form>
+		</div>
+		<template #footer>
+			<n-space justify="end">
+				<n-button @click="onCancel"> 取消 </n-button>
+				<n-button type="info" @click="onOk"> 确定 </n-button>
+			</n-space>
+		</template>
+	</n-modal>
+</template>
+
+<style scoped></style>

+ 29 - 0
packages/vivid/src/core/extension/link/helpers/clickHandler.ts

@@ -0,0 +1,29 @@
+import { MarkType } from "@tiptap/pm/model";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { EditorView } from "prosemirror-view";
+
+type ClickHandlerOptions = {
+	type: MarkType;
+	handleClick?: (view: EditorView, pos: number, event: MouseEvent, type: MarkType) => boolean;
+	handleKeyDown?: () => boolean;
+};
+
+export function clickHandler(options: ClickHandlerOptions) {
+	return new Plugin({
+		key: new PluginKey("handleClickLink"),
+		props: {
+			handleClick: (view, pos, event) => {
+				if (options.handleClick) {
+					return options.handleClick(view, pos, event, options.type);
+				}
+				return false;
+			},
+			handleKeyDown: () => {
+				if (options.handleKeyDown) {
+					return options.handleKeyDown();
+				}
+				return false;
+			},
+		},
+	});
+}

+ 4 - 0
packages/vivid/src/core/extension/link/index.ts

@@ -0,0 +1,4 @@
+import { useLink } from "./link";
+import LinkExt from "./LinkExt.vue";
+
+export { LinkExt, useLink };

+ 31 - 0
packages/vivid/src/core/extension/link/link.ts

@@ -0,0 +1,31 @@
+import Link, { LinkOptions } from "@tiptap/extension-link";
+import { clickHandler } from "./helpers/clickHandler";
+import { EditorView } from "prosemirror-view";
+import { MarkType } from "@tiptap/pm/model";
+
+export interface VividLinkOptions extends LinkOptions {
+	handleClick: (view: EditorView, pos: number, event: MouseEvent, type: MarkType) => boolean;
+	handleKeyDown?: () => boolean;
+}
+
+export function useLink(options: Partial<VividLinkOptions>) {
+	return Link.extend({
+		addProseMirrorPlugins() {
+			const parentPlugins = this.parent!().filter((e) => {
+				// @ts-ignore
+				return !e.spec.key.key.startsWith("handleClickLink");
+			});
+
+			if (this.options.openOnClick) {
+				parentPlugins.push(
+					clickHandler({
+						type: this.type,
+						handleClick: options.handleClick,
+						handleKeyDown: options.handleKeyDown,
+					}),
+				);
+			}
+			return parentPlugins;
+		},
+	}).configure(options);
+}

+ 18 - 0
packages/vivid/src/core/extension/list-item/ListItem.vue

@@ -0,0 +1,18 @@
+<script setup lang="ts">
+	import ListItem, { ListItemOptions } from "@tiptap/extension-list-item";
+	import { PropType } from "vue";
+	import { injectExtension } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<ListItemOptions>>,
+			required: false,
+		},
+	});
+
+	injectExtension(ListItem.configure(props.options));
+</script>
+
+<template></template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/list-item/index.ts

@@ -0,0 +1,8 @@
+import ListItem, { ListItemOptions } from "@tiptap/extension-list-item";
+import ListItemExt from "./ListItem.vue";
+
+export function useListItem(options?: Partial<ListItemOptions>) {
+	return ListItem.configure(options);
+}
+
+export { ListItemExt };

+ 59 - 0
packages/vivid/src/core/extension/math/MathBubbleMenu.vue

@@ -0,0 +1,59 @@
+<script setup lang="ts">
+	import { ref } from "vue";
+	import { NSpace } from "naive-ui";
+	import VividMenuItem from "../../../core/components/VividMenuItem.vue";
+	import VividMathModal from "./VividMathModal.vue";
+	import { useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const editorInstance = useEditorInstance();
+
+	const HTM = ref<any>(null);
+
+	function handleOpenMath() {
+		const selection = editorInstance.value.state.selection as any;
+		if (selection.node) {
+			HTM.value.open(selection.node.attrs.tex);
+		}
+	}
+
+	function deleteNode() {
+		editorInstance.value.commands.deleteSelection();
+	}
+
+	function onok(val: string) {
+		if (!val) {
+			editorInstance.value.commands.deleteSelection();
+			return;
+		}
+		const selection = editorInstance.value.state.selection;
+		const editor = editorInstance.value;
+
+		editor.commands.command(({ tr }) => {
+			const pos = selection.from;
+			tr.setNodeMarkup(pos, undefined, {
+				tex: val,
+			});
+			return true;
+		});
+	}
+</script>
+
+<template>
+	<div>
+		<n-space :size="2">
+			<vivid-menu-item
+				icon="edit-box-line"
+				title="修改公式"
+				:action="handleOpenMath"
+				:is-active="() => {}"
+			/>
+			<vivid-menu-item
+				icon="delete-bin-2-line"
+				title="删除"
+				:action="deleteNode"
+				:is-active="() => {}"
+			/>
+		</n-space>
+		<vivid-math-modal ref="HTM" @ok="onok" />
+	</div>
+</template>

+ 47 - 0
packages/vivid/src/core/extension/math/MathExt.vue

@@ -0,0 +1,47 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import { ref } from "vue";
+	import VividMathModal from "./VividMathModal.vue";
+	import { useMath } from "./math";
+	import {
+		injectExtension,
+		onEditorCreated,
+		useEditorInstance,
+	} from "@lib/core/extension/utils/common";
+
+	const editorInstance = useEditorInstance();
+	injectExtension(useMath());
+
+	const HTM = ref<any>(null);
+
+	function handleOpenMath() {
+		const val = "";
+		HTM.value.open(val);
+	}
+
+	onEditorCreated(() => {
+		editorInstance.value.storage["hb-math"] = {
+			openEditor: handleOpenMath,
+		};
+	});
+
+	function setMath(val: string) {
+		editorInstance.value.chain().focus().setHbMath({ tex: val }).run();
+	}
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="functions"
+				title="数学公式"
+				:action="handleOpenMath"
+				:is-active="() => editorInstance.isActive('hb-math')"
+			/>
+			<vivid-math-modal ref="HTM" @ok="setMath" />
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 49 - 0
packages/vivid/src/core/extension/math/MathView.vue

@@ -0,0 +1,49 @@
+<script setup>
+	import { NodeViewContent, nodeViewProps, NodeViewWrapper } from "@tiptap/vue-3";
+	import "katex/dist/katex.css";
+	import katex from "katex";
+
+	import { onMounted, ref, watch } from "vue";
+
+	const props = defineProps(nodeViewProps);
+
+	const box = ref(null);
+
+	function init() {
+		katex.render(props.node.attrs.tex, box.value, {
+			throwOnError: false,
+		});
+	}
+
+	watch(
+		() => props.node.attrs.tex,
+		() => {
+			init();
+		},
+	);
+
+	onMounted(() => {
+		init();
+	});
+</script>
+<template>
+	<node-view-wrapper class="math-block">
+		<p ref="box" :class="{ 'math-selected': props.selected }" />
+	</node-view-wrapper>
+</template>
+
+<style scoped>
+	.math-block {
+		position: relative;
+		max-width: 100%;
+		box-sizing: border-box;
+		display: inline-block;
+		line-height: 0;
+		float: none;
+		vertical-align: baseline;
+	}
+
+	.math-selected {
+		background: rgb(0 150 255 / 32%);
+	}
+</style>

+ 214 - 0
packages/vivid/src/core/extension/math/VividMathContent.vue

@@ -0,0 +1,214 @@
+<script setup>
+	import { useThemeVars, NButton, NPopover, NGrid, NGridItem } from "naive-ui";
+	import { ref, watch } from "vue";
+	import katex from "katex";
+
+	const themeVars = useThemeVars();
+
+	const tex = ref("");
+
+	const TEXAREA = ref(null);
+
+	function addTex(val) {
+		const indexStart = TEXAREA.value.selectionStart;
+		const indexEnd = TEXAREA.value.selectionEnd;
+		tex.value =
+			tex.value.substr(0, indexStart) + val + tex.value.substr(indexEnd, tex.value.length);
+	}
+
+	watch(tex, () => {
+		renderMath();
+	});
+
+	const box = ref(null);
+
+	function renderMath() {
+		if (!tex.value) {
+			box.value.innerHTML = "";
+			return;
+		}
+		katex.render(tex.value, box.value, {
+			throwOnError: false,
+		});
+	}
+
+	const letters = ref({
+		α: "\\alpha",
+		β: "\\beta",
+		γ: "\\gamma",
+		δ: "\\delta",
+		ϵ: "\\epsilon",
+		ζ: "\\zeta",
+		η: "\\eta",
+		θ: "\\theta",
+		ι: "\\iota",
+		κ: "\\kappa",
+		λ: "\\lambda",
+		μ: "\\mu",
+		ν: "\\nu",
+		ξ: "\\xi",
+		ο: "\\omicron",
+		π: "\\pi",
+		ρ: "\\rho",
+		σ: "\\sigma",
+		τ: "\\tau",
+		υ: "\\upsilon",
+		ϕ: "\\phi",
+		χ: "\\chi",
+		ψ: "\\psi",
+		ω: "\\omega",
+		ε: "\\varepsilon",
+		ϰ: "\\varkappa",
+		ϑ: "\\vartheta",
+		ϖ: "\\varpi",
+		ϱ: "\\varrho",
+		ς: "\\varsigma",
+		φ: "\\varphi",
+		ϝ: "\\digamma",
+	});
+	const logic = ref({
+		"×": "\\times",
+		"÷": "\\div",
+		"≈": "\\approx",
+		"≪": "\\ll",
+		"≫": "\\gg",
+		"≂": "\\eqsim",
+		"⪖": "\\eqslantgtr",
+		"⪕": "\\eqslantless",
+		"⊕": "\\oplus",
+		"±": "\\pm",
+		"∙": "\\bullet",
+		"∀": "\\forall",
+		"∁": "\\complement",
+		"∴": "\\therefore",
+		"∅": "\\varnothing",
+		"∃": "\\exists",
+		"⊂": "\\subset",
+		"∵": "\\because",
+		"⊃": "\\supset",
+		"↦": "\\mapsto",
+		"∄": "\\nexists",
+		"∣": "\\mid",
+		"→": "\\to",
+		"⟹": "\\implies",
+		"∈": "\\in",
+		"∧": "\\land",
+		"←": "\\gets",
+		"⟸": "\\impliedby",
+		"∨": "\\lor",
+		"↔": "\\leftrightarrow",
+		"⟺": "\\iff",
+		"∋": "\\ni",
+		"¬": "\\neg",
+	});
+
+	function setTex(val = "") {
+		tex.value = val;
+	}
+
+	function getTex() {
+		return tex.value;
+	}
+
+	defineExpose({ setTex, getTex });
+</script>
+
+<template>
+	<div style="margin-bottom: 10px">
+		<n-popover>
+			<template #trigger>
+				<n-button size="small" type="primary" secondary> 希腊字母 </n-button>
+			</template>
+			<n-grid :y-gap="5" :cols="12">
+				<n-grid-item v-for="(val, key, i) in letters" @click="addTex(val)">
+					<div class="item-hover">
+						{{ key }}
+					</div>
+				</n-grid-item>
+			</n-grid>
+		</n-popover>
+		<n-popover>
+			<template #trigger>
+				<n-button size="small" type="primary" secondary style="margin-left: 10px">
+					逻辑符号
+				</n-button>
+			</template>
+			<n-grid :y-gap="5" :cols="12">
+				<n-grid-item v-for="(val, key, i) in logic" @click="addTex(val)">
+					<div class="item-hover">
+						{{ key }}
+					</div>
+				</n-grid-item>
+			</n-grid>
+		</n-popover>
+	</div>
+	<div class="texbox">
+		<textarea ref="TEXAREA" v-model="tex" class="texarea" placeholder="输入TeX公式" />
+	</div>
+	<div class="preview-title">预览</div>
+	<div class="preview">
+		<p ref="box" />
+	</div>
+</template>
+
+<style scoped>
+	.item-hover {
+		display: flex;
+		justify-content: center;
+		align-items: center;
+		border-radius: 3px;
+		cursor: pointer;
+		font-size: 17px;
+		width: 30px;
+		height: 30px;
+		transition: all 0.2s;
+	}
+
+	.item-hover:hover {
+		background-color: rgba(170, 170, 170, 0.2);
+		transform: scale(1.1);
+	}
+
+	.texbox {
+		box-sizing: border-box;
+		border-radius: 3px;
+		height: 200px;
+	}
+
+	.texarea {
+		width: 100%;
+		height: 100%;
+		outline: none;
+		resize: none;
+		background: v-bind(themeVars.inputColor);
+		padding: 10px;
+		box-sizing: border-box;
+		color: v-bind(themeVars.textColor1);
+		border-color: v-bind(themeVars.borderColor);
+		border-radius: 3px;
+		transition-property: border-color, box-shadow;
+		transition-duration: 0.2s;
+	}
+	.texarea:hover {
+		border-color: v-bind(themeVars.primaryColorHover);
+	}
+	.texarea:focus {
+		border-color: v-bind(themeVars.primaryColor) !important;
+		box-shadow: 0 0 0 2px rgba(24, 160, 88, 0.2);
+	}
+
+	.preview-title {
+		font-size: 12px;
+		margin-top: 20px;
+		color: v-bind(themeVars.textColor3);
+	}
+
+	.preview {
+		padding: 10px;
+		box-sizing: border-box;
+		border: 1px dashed v-bind(themeVars.borderColor);
+		border-radius: 3px;
+		min-height: 80px;
+		font-size: 20px;
+	}
+</style>

+ 43 - 0
packages/vivid/src/core/extension/math/VividMathModal.vue

@@ -0,0 +1,43 @@
+<script setup>
+	import { NModal, NButton, NSpace } from "naive-ui";
+	import { nextTick, ref } from "vue";
+	import VividMathContent from "./VividMathContent.vue";
+
+	const HTMC = ref(null);
+
+	const showModal = ref(false);
+
+	function open(val = "") {
+		showModal.value = true;
+		nextTick(() => {
+			HTMC.value.setTex(val);
+		});
+	}
+
+	const emit = defineEmits(["ok"]);
+
+	function onOk() {
+		showModal.value = false;
+		emit("ok", HTMC.value.getTex());
+	}
+
+	function onCancel() {
+		showModal.value = false;
+	}
+
+	defineExpose({ open });
+</script>
+<template>
+	<n-modal v-model:show="showModal" preset="card" style="width: 500px">
+		<template #header>
+			<div>插入公式</div>
+		</template>
+		<vivid-math-content ref="HTMC" />
+		<template #footer>
+			<n-space justify="end">
+				<n-button @click="onCancel"> 取消 </n-button>
+				<n-button type="info" @click="onOk"> 确定 </n-button>
+			</n-space>
+		</template>
+	</n-modal>
+</template>

+ 5 - 0
packages/vivid/src/core/extension/math/index.ts

@@ -0,0 +1,5 @@
+import { useMath } from "./math";
+import MathExt from "./MathExt.vue";
+import MathBubbleMenu from "./MathBubbleMenu.vue";
+
+export { useMath, MathExt, MathBubbleMenu };

+ 72 - 0
packages/vivid/src/core/extension/math/math.ts

@@ -0,0 +1,72 @@
+import { mergeAttributes, Node } from "@tiptap/core";
+import { VueNodeViewRenderer } from "@tiptap/vue-3";
+import MathView from "./MathView.vue";
+
+interface SetMathProps {
+	tex: string;
+}
+
+declare module "@tiptap/core" {
+	interface Commands<ReturnType> {
+		math: {
+			setHbMath: (data: SetMathProps) => ReturnType;
+		};
+	}
+}
+
+export function useMath() {
+	const node = Node.create({
+		name: "hb-math",
+		addOptions() {
+			return {
+				inline: true,
+				HTMLAttributes: {},
+			};
+		},
+		inline() {
+			return this.options.inline;
+		},
+
+		group() {
+			return this.options.inline ? "inline" : "block";
+		},
+
+		draggable: false,
+
+		addAttributes() {
+			return {
+				tex: "",
+			};
+		},
+
+		parseHTML() {
+			return [
+				{
+					tag: "span[tex]",
+				},
+			];
+		},
+
+		renderHTML({ HTMLAttributes }) {
+			return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
+		},
+
+		addCommands() {
+			return {
+				setHbMath:
+					(options) =>
+					({ commands }) => {
+						return commands.insertContent({
+							type: this.name,
+							attrs: options,
+						});
+					},
+			};
+		},
+	});
+	return node.extend({
+		addNodeView() {
+			return VueNodeViewRenderer(MathView);
+		},
+	});
+}

+ 31 - 0
packages/vivid/src/core/extension/ordered-list/OrderedList.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+	import OrderedList, { OrderedListOptions } from "@tiptap/extension-ordered-list";
+	import { PropType } from "vue";
+	import { injectExtension, useEditorInstance } from "@lib/core/extension/utils/common";
+
+	const props = defineProps({
+		options: {
+			type: Object as PropType<Partial<OrderedListOptions>>,
+			required: false,
+		},
+	});
+
+	const editorInstance = useEditorInstance();
+	injectExtension(OrderedList.configure(props.options));
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="list-ordered"
+				title="有序列表"
+				:action="() => editorInstance.chain().focus().toggleOrderedList().run()"
+				:is-active="() => editorInstance.isActive('orderedList')"
+			/>
+		</slot>
+	</div>
+</template>
+
+<style scoped></style>

+ 8 - 0
packages/vivid/src/core/extension/ordered-list/index.ts

@@ -0,0 +1,8 @@
+import OrderedList, { OrderedListOptions } from "@tiptap/extension-ordered-list";
+import OrderedListExt from "./OrderedList.vue";
+
+export function useOrderedList(options?: Partial<OrderedListOptions>) {
+	return OrderedList.configure(options);
+}
+
+export { OrderedListExt };

+ 158 - 0
packages/vivid/src/core/extension/outline/OutlineExt.vue

@@ -0,0 +1,158 @@
+<script setup lang="ts">
+	import VividMenuItem from "../../components/VividMenuItem.vue";
+  import { onBeforeUnmount, ref, h, nextTick } from "vue";
+	import { NDrawer, NDrawerContent, NTree, NIcon, TreeOption } from "naive-ui";
+	import {
+		onEditorCreated,
+		useEditorInstance,
+	} from "@lib/core/extension/utils/common";
+  import { useDebounceFn, useThrottleFn } from "@vueuse/core";
+
+	const editorInstance = useEditorInstance();
+	const show = ref(false);
+
+	type HeadingItem = {
+		label: string; //标题
+		level: number; //标题大小(1-6)
+		key: string
+		children: HeadingItem[];
+		dom: HTMLElement
+	};
+	const getHeadingTree = (items: HeadingItem[]) => {
+		let result: HeadingItem[] = [];
+		let i = 0;
+		while (i < items.length) {
+			const item: HeadingItem = { ...items[i] };
+			const children: HeadingItem[] = [];
+			i++;
+			while (i < items.length && items[i].level > item.level) {
+				children.push(items[i]);
+				i++;
+			}
+			if (children.length > 0) {
+				const nextChildren = getHeadingTree(children);
+				if (nextChildren.length > 0) {
+					item.children = nextChildren;
+				}
+			}
+			result.push(item);
+		}
+		return result;
+	};
+
+	const target = ref<any>(null);
+	const treeData = ref<HeadingItem[]>([]);
+
+	function handleOpen() {
+		show.value = !show.value;
+	}
+
+	function renderPrefix(item: any) {
+		return h(NIcon, { class: `ri-h-${item.option.level}` });
+	}
+
+  function init(){
+    nextTick(()=>{
+      let dom = editorInstance.value.options.element as HTMLElement;
+      if (dom.classList.contains("editor-body-page")) {
+        dom = dom.parentElement as HTMLElement;
+      }
+
+      target.value = dom.parentElement as HTMLElement;
+    })
+  }
+
+  const update = useDebounceFn(onUpdate, 100)
+	function onUpdate() {
+    if (!editorInstance.value){
+      return
+    }
+		let dom = editorInstance.value.options.element as HTMLElement;
+		if (dom.classList.contains("editor-body-page")) {
+			dom = dom.parentElement as HTMLElement;
+		}
+
+		const list = [...dom.querySelectorAll("h1,h2,h3,h4")] as HTMLElement[];
+		const items: HeadingItem[] = [];
+		list.forEach((e, i) => {
+			items.push({
+				label: e.innerText,//标题
+				level: parseInt(e.nodeName.slice(1), 10), //标题大小(1-6)
+				key: i + "",
+				dom: e,
+				children: [],
+			});
+		});
+		treeData.value = getHeadingTree(items);
+	}
+
+	onEditorCreated(() => {
+		editorInstance.value.on("update", update);
+    init()
+	});
+
+	onBeforeUnmount(() => {
+		editorInstance.value.off("update", update);
+	});
+
+	const nodeProps = ({ option }: {
+		option: TreeOption
+	}) => {
+		return {
+			onClick() {
+				(option as HeadingItem).dom.scrollIntoView();
+			},
+		};
+	};
+</script>
+
+<template>
+	<div v-if="editorInstance">
+		<slot>
+			<vivid-menu-item
+				icon="node-tree"
+				title="文档大纲"
+				:action="handleOpen"
+				:is-active="()=>show"
+			/>
+		</slot>
+		<n-drawer
+			v-if="target"
+			:to="target"
+			:show-mask="false"
+			:close-on-esc="false"
+			:width="200" :show="show"
+			style="top: 100px;bottom:100px;border-radius: 10px"
+			:trap-focus="false"
+			:block-scroll="false"
+			:auto-focus="false">
+			<n-drawer-content>
+				<n-tree
+					style="height: 100%"
+					:data="treeData"
+					:default-expand-all="true"
+					:render-prefix="renderPrefix"
+					:node-props="nodeProps"
+				>
+					<template #empty>
+						<div class="empty-outline">
+							<i class="ri-2x ri-draft-line"></i>
+							<div>暂无大纲</div>
+						</div>
+					</template>
+				</n-tree>
+			</n-drawer-content>
+		</n-drawer>
+	</div>
+</template>
+
+<style scoped>
+	.empty-outline {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+		height: 100%;
+	}
+
+</style>

+ 0 - 0
packages/vivid/src/core/extension/outline/index.ts


部分文件因为文件数量过多而无法显示