tiptap一次封装自定义UI
效果图
(参考element-tiptap):
demo如下
本次封装基于vue3
<template>
<div
v-if="editor"
:class="[
{
'el-tiptap-editor': true,
'el-tiptap-editor--fullscreen': isFullscreen,
},
]"
>
<menu-bubble :editor="editor" />
<menu-bar :editor="editor" style="user-select: none">
<template #extra><slot name="extra"></slot></template
></menu-bar>
<table-menu-bar :editor="editor" style="user-select: none" />
<slot name="inner-editor"></slot>
<editor-content
:editor="editor"
:class="[
{
'el-tiptap-editor__content': true,
'tiptap-editable-content': true,
},
]"
/>
</div>
</template>
<script lang="ts" setup>
import { EditorContent, type Extension, type Node, useEditor } from '@tiptap/vue-3'
import TiptapPlaceholder from '@tiptap/extension-placeholder'
import MenuBubble from './MenuBubble/index.vue'
import MenuBar from './MenuBar/index.vue'
import TableMenuBar from './TableMenuBar/index.vue'
import { uploadAttachment } from '../utils/upload-attachment'
const props = withDefaults(
defineProps<{
content?: string
extensions: (Extension | Node)[]
placeholder?: string
hasCollaboration?: boolean
autoFocus?: boolean
}>(),
{
content: '',
placeholder: '',
hasCollaboration: false,
autoFocus: true,
},
)
const isFullscreen = ref(false)
const extensions = props.extensions.concat([
TiptapPlaceholder.configure({
emptyEditorClass: 'el-tiptap-editor--empty',
emptyNodeClass: 'el-tiptap-editor__placeholder',
showOnlyCurrent: false,
placeholder: () => {
return props.placeholder
},
}),
])
const emits = defineEmits([
'update:content',
'onUpdate',
'onCreate',
'onTransaction',
'onFocus',
'onBlur',
'onDestroy',
])
const editor = useEditor({
content: props.hasCollaboration ? undefined : props.content,
extensions,
autofocus: props.autoFocus,
editorProps: {
attributes: {
spellcheck: 'false',
},
handleDrop(view, event, _slice, moved) {
if (moved) return
const fileList = event.dataTransfer?.files
if (!fileList?.length) return
const files: File[] = []
for (let i = 0; i < fileList.length; ++i) {
files.push(fileList.item(i)!)
}
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
})
if (!coordinates) return
uploadAttachment(files).then((response) => {
editor.value?.commands.setAttachmentsAt(coordinates.pos, response)
})
return true
},
},
onCreate: (options) => {
emits('onCreate', options)
},
onTransaction: (options) => {
emits('onTransaction', options)
},
onFocus: (options) => {
emits('onFocus', options)
},
onBlur: (options) => {
emits('onBlur', options)
},
onDestroy: (options) => {
emits('onDestroy', options)
},
onUpdate: ({ editor }) => {
const output = editor.getHTML()
emits('update:content', output)
emits('onUpdate', output, editor)
},
})
provide('isFullscreen', isFullscreen)
defineExpose({
editor,
})
</script>
<style lang="scss">
@import '../styles/command-button.scss';
</style>
组件再抽离封装
其中 menu-bubble
(菜单气泡),menu-bar
(菜单栏),table-menu-bar
(表格栏)均进行一次封装
菜单气泡
// menu-bubble
<template>
<bubble-menu v-if="editor" :editor="editor">
<link-bubble-menu v-if="activeMenu === 'link'" :editor="editor"></link-bubble-menu>
<attachment-bubble-menu
v-if="activeMenu === 'attachment'"
:editor="editor"
></attachment-bubble-menu>
</bubble-menu>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { Editor, BubbleMenu } from '@tiptap/vue-3'
import LinkBubbleMenu from './LinkBubbleMenu.vue'
import AttachmentBubbleMenu from './AttachmentBubbleMenu.vue'
const enum MenuType {
NONE = 'none',
LINK = 'link',
ATTACHMENT = 'attachment',
}
export default defineComponent({
name: 'MenuBubble',
components: {
BubbleMenu,
LinkBubbleMenu,
AttachmentBubbleMenu,
},
props: {
editor: {
type: Editor,
required: true,
},
menuBubbleOptions: {
type: Object,
default: () => ({}),
},
},
computed: {
activeMenu(): MenuType {
if (this.editor.isActive('link')) return MenuType.LINK
if (this.editor.isActive('attachment')) return MenuType.ATTACHMENT
return MenuType.NONE
},
},
})
</script>
菜单栏
<template>
<div class="el-tiptap-editor__menu-bar">
<component
:is="spec.component"
v-for="(spec, i) in generateCommandButtonComponentSpecs()"
:key="'command-button' + i"
v-bind="spec.componentProps"
v-on="spec.componentEvents || {}"
/>
<slot name="extra"></slot>
</div>
</template>
<script lang="ts" setup>
import { Editor } from '@tiptap/core'
const props = defineProps<{
editor: Editor
}>()
const generateCommandButtonComponentSpecs = () => {
const extensionManager = props.editor.extensionManager
// 把插入链接放到 textAlign 后面
let linkIndex = -1
let linkExt
for (let i = 0; i < extensionManager.extensions.length; i++) {
if (extensionManager.extensions[i].name === 'link') {
linkExt = extensionManager.extensions[i]
linkIndex = i
break
}
}
if (linkIndex !== -1) {
extensionManager.extensions.splice(linkIndex, 1)
}
for (let i = 0; i < extensionManager.extensions.length; i++) {
if (extensionManager.extensions[i].name === 'textAlign' && linkExt) {
extensionManager.extensions.splice(i + 1, 0, linkExt)
break
}
}
return extensionManager.extensions.reduce((acc: any, extension) => {
const { button } = extension.options
if (!button || typeof button !== 'function') return acc
const menuBtnComponentSpec = button({
editor: props.editor,
extension,
})
if (Array.isArray(menuBtnComponentSpec)) {
return [...acc, ...menuBtnComponentSpec]
}
return [...acc, menuBtnComponentSpec]
}, [])
}
</script>
<style lang="less" scoped>
.el-tiptap-editor__menu-bar {
border-bottom: none;
}
</style>
表格栏
<template>
<div class="tiptap-editor__menu-bar">
<component
:is="spec.component"
v-for="(spec, i) in tableRowFunc(editor)"
:key="'command-button' + i"
v-bind="spec.componentProps"
/>
<div class="span-line"></div>
<component
:is="spec.component"
v-for="(spec, i) in tableColumnFunc(editor)"
:key="'command-button' + i"
v-bind="spec.componentProps"
/>
<div class="span-line"></div>
<component
:is="spec.component"
v-for="(spec, i) in tableOperateFunc(editor)"
:key="'command-button' + i"
v-bind="spec.componentProps"
/>
<div class="color-btn">
<a-tooltip :placement="isFullscreen ? 'bottom' : 'top'">
<template #title>表格背景色填充</template>
<a-dropdown v-model:visible="showDropdown" :placement="'bottom'" :trigger="['click']">
<template #overlay>
<div class="color-dropdown">
<div
v-for="(item, index) in COLOR_SET"
:key="index"
class="color-item"
@click="handleSetColor(item)"
>
<a :style="`background-color: ${item}`"></a>
</div>
</div>
</template>
<a-button
style="
width: 38px;
border: none;
display: flex;
justify-content: center;
align-items: center;
"
>
<template #icon>
<span
:class="`img-btn ${commandButtonClass}`"
:style="`background: url(${imgUrl}) no-repeat center;color: rgb(58, 60, 63);`"
></span>
<i :class="`iconfont icon-nav_ic_down_line ${commandButtonClass}`"></i>
</template>
</a-button>
</a-dropdown>
</a-tooltip>
</div>
</div>
</template>
<script lang="ts" setup>
import { COLOR_SET } from '@/tiptap/utils/color'
import { Editor } from '@tiptap/core'
import { Ref } from 'vue'
import { tableRowFunc, tableColumnFunc, tableOperateFunc, isTableActive } from '../../utils/table'
const props = defineProps<{
editor: Editor
}>()
const { editor } = toRefs(props)
const imgUrl = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAElBMVEX///9xcXH////noZtxcXHQRDelTeAdAAAABnRSTlMAiP////8xi9whAAAAQ0lEQVR42oXOUQoAIAhEQVu3+1+5sMVAg96HyBCStVgyhuYQDA0BswAOAj4dwF7PC0j2LpBckNwbkryRPf/Rf1qybwsQEAHh66g3QgAAAABJRU5ErkJggg==`
const showDropdown = ref(false)
const isFullscreen: Ref<boolean> = inject('isFullscreen', ref(false))
const handleSetColor = (color: string) => {
if (color) {
props.editor.commands.setCellAttribute('backgroundColor', color)
} else {
props.editor.commands.setCellAttribute('backgroundColor', '#ffffff')
}
showDropdown.value = false
props.editor.commands.focus()
}
const commandButtonClass = computed(() => {
let str = ''
if (!isTableActive(editor.value.state)) str += 'tiptap-editor__command-button--disabled '
return str
})
</script>
<style lang="less" scoped>
.tiptap-editor__command-button--disabled {
cursor: not-allowed !important;
opacity: 0.5;
}
.img-btn {
width: 16px;
height: 16px;
line-height: 22px;
display: inline-block;
}
.color-dropdown {
overflow: auto;
padding: 6px;
width: 155px;
background-color: #ffffff;
.color-item {
display: inline;
float: left;
height: 16px;
line-height: 16px;
padding: 2px;
width: 16px;
a {
border: 1px solid #ddd;
border-radius: 2px;
display: block;
height: 14px;
line-height: 14px;
padding: 0px;
text-align: justify;
width: 14px;
}
}
}
.color-btn {
display: flex;
margin-left: 4px;
.icon-wrap {
display: flex;
flex-direction: column;
align-items: center;
.icon-hr {
position: absolute;
top: 12px;
}
}
.new-btn {
display: flex;
border-radius: 4px 0 0 4px;
padding: 4px 8px;
}
.dropdown-new {
width: 20px;
border-radius: 0 4px 4px 0px;
border-left-color: hsla(0, 0%, 100%, 0.2);
}
:deep(.ant-btn:hover, .ant-btn:focus) {
color: inherit;
border-color: #d9d9d9;
}
:deep(.ant-btn:focus) {
color: inherit;
border-color: #d9d9d9;
}
}
.tiptap-editor__menu-bar {
background-color: #fff;
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
padding: 5px;
position: relative;
border-left: 1px solid #ebeef5;
border-right: 1px solid #ebeef5;
border-bottom: 1px solid #ebeef5;
}
.span-line {
background: #e8e8e8;
margin-left: 8px;
margin-top: 2px;
width: 1px;
height: 28px;
margin-right: 4px;
}
</style>
tiptap二次封装
二次封装主要是可以自定义一些需要的插件(此处插件可以自己根据需要自定义封装,可参考https://github.com/Leecason/element-tiptap)及协同编辑
<template>
<div :style="`height:${editorHeight}`">
<tiptap-editor-content
ref="tiptapEditor"
v-model:content="content"
:placeholder="placeholder"
:extensions="extensions"
:auto-focus="autoFocus"
:has-collaboration="!!collaborationOptions"
@onCreate="onCreate"
@onUpdate="onUpdate"
>
<template #extra>
<slot name="draft" :editor="tiptapEditor?.editor"></slot>
<slot name="extra" :editor="tiptapEditor?.editor"></slot>
</template>
<template #inner-editor>
<slot name="inner-editor"></slot>
</template>
</tiptap-editor-content>
</div>
</template>
<script lang="ts" setup>
import TiptapEditorContent from '../../tiptap/components/TiptapEditorContent.vue'
import {
Text,
Paragraph,
Document,
Heading,
Bold,
Italic,
Underline,
Strike,
Table,
Image,
Color,
TaskList,
BulletList,
OrderedList,
Indent,
TextAlign,
Link,
Highlight,
CodeBlock,
Blockquote,
HorizontalRule,
FontSize,
LineHeight,
TextStyle,
HardBreak,
Fullscreen,
Gapcursor,
// Markdown,
FormatClear,
Attachment,
Audio,
Video,
} from '../../tiptap/extensions'
import Collaboration from '@tiptap/extension-collaboration'
import * as Y from 'yjs'
import { CollaborationOptions } from './editor'
import { HocuspocusProvider } from '@hocuspocus/provider'
import StarterKit from '@tiptap/starter-kit'
import { getLocalStorage } from '@/utils/browser'
import { DemandModuleKey } from '@/utils/constants'
import type { Editor, Extension, FocusPosition, Node } from '@tiptap/vue-3'
import { toLegacyEditorContent } from '@/utils/editor'
import { syncMark } from '../content-diff/sync-mark'
import { isNumber } from 'lodash-es'
const emits = defineEmits(['changeContents', 'onCreate', 'saveContent'])
const router = useRouter()
let ydoc: Y.Doc
let provider: HocuspocusProvider
let isEditorReady = false
const props = withDefaults(
defineProps<{
content?: string
placeholder?: string
height?: string | number
collaborationOptions?: CollaborationOptions
pageUnloadInterrupt?: boolean
autoFocus?: boolean
}>(),
{
content: '',
placeholder: '请输入...',
outputType: 'html',
height: '100%',
pageUnloadInterrupt: false,
autoFocus: true,
},
)
const { content, placeholder, height, collaborationOptions, pageUnloadInterrupt, autoFocus } =
toRefs(props)
const editorHeight = computed(() => {
return isNumber(height.value) ? `${height.value}px` : height.value
})
// 编辑器extensions 它们将会按照你声明的顺序被添加到菜单栏和气泡菜单中
const extensions: (Extension | Node)[] = [
StarterKit.configure({
codeBlock: false,
}),
Text,
Paragraph,
Document,
Heading.configure({ levels: [1, 2, 3, 4, 5] }),
Bold,
Italic,
Underline,
Strike,
Color,
Highlight,
FontSize,
LineHeight,
FormatClear,
BulletList,
OrderedList,
TaskList,
Indent,
TextAlign.configure({ alignments: ['left', 'center', 'right'] }),
Link.configure({ openOnClick: true }),
Image,
Attachment,
Table.configure({
HTMLAttributes: {
class: 'TableProseMirror',
},
resizable: true,
allowTableNodeSelection: true,
}),
CodeBlock,
Blockquote,
HorizontalRule,
TextStyle,
HardBreak,
Fullscreen,
Gapcursor,
// Markdown.configure({
// html: true, // Allow HTML input/output
// tightLists: true, // No <p> inside <li> in markdown output
// tightListClass: 'tight', // Add class to <ul> allowing you to remove <p> margins when tight
// bulletListMarker: '-', // <li> prefix in markdown output
// linkify: true, // Create links from "https://..." text
// breaks: false, // New lines (\n) in markdown input are converted to <br>
// transformPastedText: true, // Allow to paste markdown text in the editor
// transformCopiedText: false, // Copied text is transformed to markdown
// }),
Audio,
Video,
]
if (collaborationOptions?.value) {
// 必须立刻获取,否则数据可能被修改
const initialContent = collaborationOptions.value!.initialContent
ydoc = new Y.Doc()
let synced = false
provider = new HocuspocusProvider({
url: `wss://${import.meta.env.VITE_APP_EDITOR_SERVER}`,
name: collaborationOptions.value.roomName,
token: null,
document: ydoc,
onOpen() {
console.log('[collaboration] open', Date.now())
/**
* 分析:
* 用户A初始化或者刷新后,服务端没有立即记录当前clientID,大约要过15秒才会有记录。
* 这段时间内如果B进编辑器,会判定当前只有自己在编辑,于是立即覆盖内容导致A的内容丢失。
*
* 解决:
* 设置随意的字段以触发awareness在服务端产生记录
*/
provider.setAwarenessField('initial_time', new Date().toString())
},
onConnect() {
console.log('[collaboration] connect', Date.now())
},
onDisconnect(data) {
console.log('[collaboration] disconnect', Date.now(), data.event)
},
onAwarenessChange(data) {
console.log('[collaboration] client changed', Date.now(), data.states)
},
async onSynced() {
if (synced) {
console.log('[collaboration] reconnect, stop syncing')
return
}
synced = true
let clients = Array.from(provider.awareness.states.keys())
if (clients.length === 1) {
// 防止同步人数比同步内容慢
await new Promise((resolve) => setTimeout(resolve, 120))
clients = Array.from(provider.awareness.states.keys())
}
const clientIds = clients.sort((a, b) => a - b)
console.log('[collaboration] syncing with clients', clientIds, Date.now())
if (!initialContent) return
if (clientIds[0] !== ydoc.clientID) return
// 有多人时,如果协作草稿为空,则获得权限的client需要设置内容
if (tiptapEditor.value!.editor!.isEmpty) {
return insertContent(initialContent)
}
// 只有一个人的时候,需要让用户决定是否保留草稿
if (clientIds.length === 1) {
const draftContent = syncMark(initialContent, tiptapEditor.value!.editor!.getHTML())
insertContent(initialContent)
if (draftContent !== initialContent) {
// TODO: 需要先解决图片问题
// collaborationDraftData.value = {
// draftContent: draftContent,
// originContent: initialContent,
// }
}
}
},
})
extensions.push(Collaboration.configure({ document: ydoc }))
}
const tiptapEditor = ref<{ editor?: Editor }>()
if (pageUnloadInterrupt.value) {
const stopPageClose = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = '关闭提示'
}
onMounted(() => {
// https://developer.mozilla.org/zh-CN/docs/Web/API/Window/beforeunload_event
window.addEventListener('beforeunload', stopPageClose)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', stopPageClose)
})
}
onMounted(() => {
listenSave()
})
onBeforeUnmount(() => {
provider?.destroy()
removeListSave()
})
onActivated(() => {
listenSave()
})
onDeactivated(() => {
removeListSave()
})
const insertContent = (content: string) => {
clearContent()
tiptapEditor.value?.editor?.commands.insertContent(content)
}
const clearContent = () => {
tiptapEditor.value?.editor?.commands.clearContent()
}
const focusEditor = (position: FocusPosition = 'start') => {
tiptapEditor.value?.editor?.commands.focus(position)
}
const onCreate = () => {
isEditorReady = true
emits('onCreate')
// 弹窗可能造成焦点丢失,而且弹出动画时间比较长
setTimeout(() => {
if (autoFocus.value && !tiptapEditor.value?.editor?.isFocused) {
focusEditor()
}
}, 700)
}
const onUpdate = (output: string) => {
// 由于可能时序问题导致最开始会调用一次空的update,加一个 isCreated
if (isEditorReady) {
emits('changeContents', toLegacyEditorContent(output))
}
}
defineExpose({
insertContent,
clearContent,
focusEditor,
destroyWs: () => {
provider.destroy()
},
})
</script>
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。