关于vue3整合tiptap的slash菜单的ts支持

首先声明,这不是一篇技术文章

最近在捣鼓tiptap编辑器,tiptap构建的风格还是比较现代的,而且api简单,官方文档清晰明了,这就是我选择它的原因

注意: tiptap并不支持markdown,而且三方插件的表现也差强人意,但是对于普通的用户(区别于程序员),我觉得markdown并不是特别重要

整合tiptap的slash菜单,官方是给了vue的案例的,但是,官方给的案例是js的方式,而且vue是v2的写法,项目是v3+ts的,所以遇到一些问题,这就是这篇文章的主要目的,直接上代码

ts 复制代码
// 这个文件是创建一个插件,通过扩展Suggestion的插件而来
// commands.ts
import { Extension } from '@tiptap/core'
import Suggestion from '@tiptap/suggestion'
import { Editor, Range } from '@tiptap/vue-3'

export default Extension.create({
    name: 'slashMenu',

    addOptions() {
        return {
            suggestion: {
                char: '/',
                command: ({ editor, range, props }: { editor: Editor, range: Range, props: any }) => {
                    props.command({ editor, range })
                },
            },
        }
    },

    addProseMirrorPlugins() {
        return [
            Suggestion({
                editor: this.editor,
                ...this.options.suggestion,
            }),
        ]
    },
})
ts 复制代码
// 这个文件是主要的实现方法,其中遇到一些问题,根据源码的ts类型实现有些不知名的问题,直接使用any类型了
// suggestion.ts

import { Editor, Range, VueRenderer } from '@tiptap/vue-3'
import tippy from 'tippy.js'

import CommandsList from './CommandsList.vue'

export default {
    items: ({ query }: { query: string }) => {
        return [
            {
                title: 'Heading 1',
                command: ({ editor, range }: { editor: Editor, range: Range, props: any }) => {
                    editor
                        .chain()
                        .focus()
                        .deleteRange(range)
                        .setNode('heading', { level: 1 })
                        .run()
                },
            },
            {
                title: 'Heading 2',
                command: ({ editor, range }: { editor: Editor, range: Range, props: any }) => {
                    editor
                        .chain()
                        .focus()
                        .deleteRange(range)
                        .setNode('heading', { level: 2 })
                        .run()
                },
            },
            {
                title: 'Bold',
                command: ({ editor, range }: { editor: Editor, range: Range, props: any }) => {
                    editor
                        .chain()
                        .focus()
                        .deleteRange(range)
                        .setMark('bold')
                        .run()
                },
            },
            {
                title: 'Italic',
                command: ({ editor, range }: { editor: Editor, range: Range, props: any }) => {
                    editor
                        .chain()
                        .focus()
                        .deleteRange(range)
                        .setMark('italic')
                        .run()
                },
            },
        ].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10)
    },

    render: () => {
        let component: any
        let popup: any

        return {
            onStart: (props: any) => {
                component = new VueRenderer(CommandsList, {
                    props,
                    editor: props.editor,
                })

                if (!props.clientRect) {
                    return
                }

                const { $anchor } = 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 = $anchor && $anchor.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: any) {
                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: any) {
                if (props.event.key === 'Escape') {
                    popup[0].hide()

                    return true
                }

                return component.ref?.onKeyDown(props.event)
            },

            onExit() {
                popup[0].destroy()
                component.destroy()
            },
        }
    },
}
vue 复制代码
// 这就是最主要的vue文件,显示菜单与键盘导航
// CommandsList.vue

<template>
    <div class="w-max flex shadow rounded py-1">
        <div
            ref="scrollingDiv"
            class="flex flex-col items-center max-h-52 h-max overflow-y-auto bg-background"
        >
            <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)"
                >
                {{ item.title }}
            </button>
        </div>
    </div>
</template>

<script setup lang="ts">
import { Editor, Range } from '@tiptap/vue-3';
import { ref } from 'vue'

const webProps = defineProps<{
    items: {
        title: string,
        command: { editor: Editor, range: Range, props: any }
    }[],
    command: Function
}>()

const selectedIndex = ref(0)
const scrollingDiv = ref<HTMLElement>()

const onKeyDown = (e: any) => {
    if (e.key === 'ArrowUp') {
        upHandler()
        return true
    }

    if (e.key === 'ArrowDown') {
        downHandler()
        return true
    }

    if (e.key === 'Enter') {
        enterHandler()
        return true
    }

    return false
}

const upHandler = () => {
    selectedIndex.value = ((selectedIndex.value + webProps.items.length) - 1) % webProps.items.length
    scrollDiv()
}

const downHandler = () => {
    selectedIndex.value = (selectedIndex.value + 1) % webProps.items.length
    scrollDiv()
}

const scrollDiv = () => {
    let buttons = scrollingDiv.value?.querySelectorAll('button');
    let button = buttons![selectedIndex.value];
    button.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
}

const enterHandler = () => {
    selectItem(selectedIndex.value)
}

const selectItem = (index: number) => {
    const item = webProps.items[index]

    if (item) {
        webProps.command(item)
    }
}

defineExpose({ onKeyDown })
</script>

<style lang="scss">
/* Dropdown menu */
.dropdown-menu {
    overflow: auto;
    position: relative;

    button {
        align-items: center;
        background-color: transparent;
        display: flex;
        gap: 0.25rem;
        text-align: left;
        width: 100%;

        &:hover,
        &:hover.is-selected {
            @apply bg-accent;
        }

        &.is-selected {
            @apply bg-accent;
        }
    }
}
</style>
相关推荐
jjw_zyfx1 小时前
成熟的前端vue vite websocket,Django后端实现方案包含主动断开websocket连接的实现
前端·vue.js·websocket
乌夷3 小时前
使用spring boot vue 上传mp4转码为dash并播放
vue.js·spring boot·dash
苹果酱05675 小时前
2020-06-23 暑期学习日更计划(机器学习入门之路(资源汇总)+概率论)
java·vue.js·spring boot·mysql·课程设计
Deepsleep.5 小时前
react和vue的区别之一
javascript·vue.js·react.js
zqlcoding5 小时前
使用el-table表格动态渲染表头数据之后,导致设置fixed的列渲染出现问题
前端·javascript·vue.js
爱吃的强哥5 小时前
vue3 使用 vite 管理多个项目,实现各子项目独立运行,独立打包
前端·javascript·vue.js
涵信5 小时前
第十节:性能优化高频题-虚拟DOM与Diff算法优化
javascript·vue.js·性能优化
拖孩6 小时前
【Nova UI】十一、组件库中 Icon 组件的测试、使用与全局注册全攻略
前端·javascript·vue.js·ui·sass
凉豆菌7 小时前
在html中如何创建vue自定义组件(以自定义文件上传组件为例,vue2+elementUI)
vue.js·elementui·html
广西千灵通网络科技有限公司7 小时前
基于 springboot+vue+elementui 的办公自动化系统设计(
vue.js·spring boot·elementui