关于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>
相关推荐
因吹斯汀1 小时前
一饭封神:当AI厨神遇上你的冰箱,八大菜系大师在线battle!
前端·vue.js·ai编程
崔璨1 小时前
详解Vue3的响应式系统
前端·vue.js
摸鱼的鱼lv1 小时前
🔥 Vue.js组件通信全攻略:从父子传值到全局状态管理,一篇搞定所有场景!🚀
前端·vue.js
lichong9512 小时前
【混合开发】vue+Android、iPhone、鸿蒙、win、macOS、Linux之dist打包发布在Android工程asserts里
android·vue.js·iphone
超人不会飛3 小时前
vue3 markdown组件|大模型应用专用
前端·vue.js·人工智能
VillenK3 小时前
用插件的方式注入Vue组件
前端·vue.js
等什么君!4 小时前
如何 正确使用 nrm 工具 管理镜像源
vue.js
warder4 小时前
字典状态管理:基于 Vue3 + Pinia 的工程化实践
vue.js
人工智能训练师4 小时前
在Ubuntu中如何使用PM2来运行一个编译好的Vue项目
linux·运维·服务器·vue.js·ubuntu·容器
浩星5 小时前
iframe引入界面有el-date-picker日期框,点击出现闪退问题处理
前端·vue.js·elementui