关于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>
相关推荐
苏打水com7 分钟前
第十二篇:Day34-36 前端工程化进阶——从“单人开发”到“团队协作”(对标职场“大型项目协作”需求)
前端·javascript·css·vue.js·html
钝挫力PROGRAMER29 分钟前
Vue中选项式和组合式API的学习
javascript·vue.js
3秒一个大33 分钟前
Vue 任务清单开发:数据驱动 vs 传统 DOM 操作
前端·javascript·vue.js
an869500133 分钟前
vue自定义组件this.$emit(“refresh“);
前端·javascript·vue.js
o__A_A34 分钟前
渲染可配置报告模板+自适应宽度(vue3)
前端·vue.js
鹏北海34 分钟前
Vue 组件解耦实践:用回调函数模式替代枚举类型传递
前端·vue.js
San3036 分钟前
拒绝做 DOM 的“搬运工”:从 Vanilla JS 到 Vue 3 响应式思维的进化
javascript·vue.js·响应式编程
qq_427506081 小时前
基于Vue 3和Element Plus利用h、render函数写一个简单的tooltip局部or全局指令
前端·javascript·vue.js
m0_471199632 小时前
【vue】收银界面离线可用,本地缓存订单,网络恢复后同步
网络·vue.js·缓存