· client · 5 min 阅读
在 tiptap 中实现斜杠功能
通过自定义方法,在 tiptap 中实现斜杠功能
我觉得 tiptap 是目前的最佳编辑器,高度可自定义,UI 都由你自己决定,这是基于 react 的一个官方 demo,可以去体验一下。
下面进入正题,官方文档中只是简单实现,但我想实现下面的这种。
以下教程基于 Vue3
+ tailwindcss
首先创建 4 个自定义文件
-
slash.js
import { Extension } from '@tiptap/core' import suggestion from '@tiptap/suggestion' export default Extension.create({ name: 'slash', addOptions() { return { suggestion: { char: '/', command: ({ editor, range, props }) => { props.command({ editor, range }) } } } }, addProseMirrorPlugins() { return [ suggestion({ editor: this.editor, ...this.options.suggestion, }) ] } })
-
suggestion.js
import { VueRenderer } from '@tiptap/vue-3' import tippy from 'tippy.js' import CommandsList from './CommandsList.vue' export default function set(items) { return { items: () => { return items ? items : [ { title: '一级标题', icon: 'mdi:format-header-1', command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run() } } ] }, render: () => { let component let popup return { onStart: props => { component = new VueRenderer(CommandsList, { props, editor: props.editor }) const { $cursor } = props.editor.view.state.selection const selection = props.editor.state.selection // 获取选区的上下文 const context = selection.$from.depth ? selection.$from.node(selection.$from.depth - 1) : null const listItem = context && context.type.name === 'listItem' const isCursorInParagraph = $cursor && $cursor.parent.type.name === 'paragraph' if (!props.clientRect || !isCursorInParagraph || listItem) { return } popup = tippy('body', { getReferenceClientRect: props.clientRect, appendTo: () => document.body, content: component.element, showOnCreate: true, interactive: true, trigger: 'manual', placement: 'bottom-start' }) }, onUpdate(props) { component.updateProps(props) if (!props.clientRect || props.text !== '/') { popup[0].hide() return } if (props.text === '/') { popup[0].show() } popup[0].setProps({ getReferenceClientRect: props.clientRect }) }, onKeyDown(props) { if (props.event.key === 'Escape') { popup[0].hide() return true } return component.ref?.onKeyDown(props) }, onExit() { popup[0].destroy() component.destroy() } } } } }
-
CommandsList.vue
<template> <div class="w-max flex shadow rounded py-1"> <div id="scrollingDiv" class="flex flex-col items-center gap-y-1 max-h-52 h-max overflow-y-auto dark:bg-zinc-900 dark:text-zinc-300" > <button class="tippy-item flex items-center p-1 pr-12 text-xs hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors rounded cursor-pointer w-full group" :class="{ 'bg-zinc-200 dark:bg-zinc-700': index === selectedIndex }" v-for="(item, index) in items" :key="index" @click="selectItem(index)" > <div class="flex p-1 mr-2 bg-zinc-200 dark:bg-zinc-500 group-hover:bg-zinc-50 dark:group-hover:bg-zinc-600 transition-colors rounded-sm" :class="{ 'bg-zinc-50 dark:bg-zinc-600': index === selectedIndex }" > <Icon :icon="item.icon" /> </div> {{ item.title }} </button> </div> </div> </template> <script> import Icon from './Icon.vue' export default { components: { Icon }, props: { items: { type: Array, required: true }, command: { type: Function, required: true } }, data() { return { selectedIndex: 0, scrollingDiv: null } }, mounted() { this.scrollingDiv = document.getElementById('scrollingDiv') }, watch: { items() { this.selectedIndex = 0 } }, methods: { onKeyDown({ event }) { if (event.key === 'ArrowUp') { this.upHandler() return true } if (event.key === 'ArrowDown') { this.downHandler() return true } if (event.key === 'Enter') { this.enterHandler() return true } return false }, upHandler() { this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length this.scrollDiv() }, downHandler() { this.selectedIndex = (this.selectedIndex + 1) % this.items.length this.scrollDiv() }, scrollDiv() { let buttons = this.scrollingDiv.querySelectorAll('button') let button = buttons[this.selectedIndex] button.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }) }, enterHandler() { this.selectItem(this.selectedIndex) }, selectItem(index) { const item = this.items[index] if (item) { this.command(item) } } } } </script>
-
Icon.vue
<script setup> import { Icon } from '@iconify/vue' defineProps(['icon', 'height', 'inline', 'color']) </script> <template> <div> <Icon :icon="icon" :height="height ? height : '18'" :inline="inline ? true : false" :color="color ? color : ''" /> </div> </template>
然后我们在主文件 index.vue
中引入自定义拓展
<script setup>
import { useEditor, EditorContent } from '@tiptap/vue-3'
import Slash from './slash.js'
import suggestion from './suggestion.js'
const props = defineProps(['content', 'slash'])
const editor = useEditor({
editable: true,
content: props.content,
extensions: [
Slash.configure({
suggestion: suggestion(props.slash)
})
],
})
</script>
<template>
<div class="dark:bg-zinc-800 dark:text-zinc-300 min-h-screen flex flex-col">
<editor-content class="w-full" :editor="editor" />
</div>
</template>
props.slash
数据有以下自定义数组组成
const slash = [
{
title: '普通文本',
icon: 'mdi:format-paragraph',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode('paragraph').run()
}
},
{
title: '一级标题',
icon: 'mdi:format-header-1',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run()
}
},
{
title: '二级标题',
icon: 'mdi:format-header-2',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run()
}
},
{
title: '三级标题',
icon: 'mdi:format-header-3',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run()
}
},
{
title: '代码块',
icon: 'mdi:code-braces-box',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode('codeBlock').run()
}
},
{
title: '无序列表',
icon: 'mdi:format-list-bulleted',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run()
}
},
{
title: '有序列表',
icon: 'mdi:format-list-numbered',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run()
}
}
]
至此,自定义 /
命令就可以正常工作了,注意别忘记安装使用到的依赖