tiptap一次封装自定义UI

效果图

(参考element-tiptap):

image.png

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>

愚者
12 声望3 粉丝