Skip to content

Sender 输入框

基于 tiptap 的富文本输入框组件,支持多种扩展和自定义布局。

基础用法

扩展提醒

如果你想扩展更多html类型,可以查看源码方式,自行传入extensions进行扩展使用

主题设置

@成员 扩展用法

扩展提醒

使用tiptap提供的Mention扩展能力。

./metion/suggestions 文件
ts
import { computePosition, flip, shift } from '@floating-ui/dom'
import type { VirtualElement } from '@floating-ui/dom'
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
import { Editor } from '@tiptap/core'
import type { SuggestionProps } from '@tiptap/suggestion'
import MentionList from './MentionList.vue'

const updatePosition = (editor: Editor, element: HTMLElement) => {
  const virtualElement: VirtualElement = {
    getBoundingClientRect: () =>
      posToDOMRect(
        editor.view,
        editor.state.selection.from,
        editor.state.selection.to
      ),
  }

  computePosition(virtualElement, element, {
    placement: 'bottom-start',
    strategy: 'absolute',
    middleware: [shift(), flip()],
  }).then(({ x, y, strategy }) => {
    element.style.width = 'max-content'
    element.style.position = strategy
    element.style.left = `${x}px`
    element.style.top = `${y}px`
  })
}
const suggestions: any[] = [
  {
    char: '@',
    items: ({ query }: { query: string }) => {
      return [
        'Lea Thompson',
        'Cyndi Lauper',
        'Tom Cruise',
        'Madonna',
        'Jerry Hall',
        'Joan Collins',
        'Winona Ryder',
        'Christina Applegate',
        'Alyssa Milano',
        'Molly Ringwald',
        'Ally Sheedy',
        'Debbie Harry',
        'Olivia Newton-John',
        'Elton John',
        'Michael J. Fox',
        'Axl Rose',
        'Emilio Estevez',
        'Ralph Macchio',
        'Rob Lowe',
        'Jennifer Grey',
        'Mickey Rourke',
        'John Cusack',
        'Matthew Broderick',
        'Justine Bateman',
        'Lisa Bonet',
      ]
        .filter((item) => item.toLowerCase().startsWith(query.toLowerCase()))
        .slice(0, 5)
    },

    render: () => {
      let component: VueRenderer

      return {
        onStart: (props: SuggestionProps) => {
          component = new VueRenderer(MentionList, {
            // using vue 2:
            // parent: this,
            // propsData: props,
            // using vue 3:
            props,
            editor: props.editor,
          })

          if (!props.clientRect) {
            return
          }

          ;(component.element as HTMLElement).style.position = 'absolute'

          document.body.appendChild(component.element as HTMLElement)

          updatePosition(props.editor, component.element as HTMLElement)
        },

        onUpdate(props: SuggestionProps) {
          component.updateProps(props)

          if (!props.clientRect) {
            return
          }

          updatePosition(props.editor, component.element as HTMLElement)
        },

        onKeyDown(props: SuggestionProps) {
          // @ts-ignore
          if (props.event.key === 'Escape') {
            component.destroy()

            return true
          }

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

        onExit() {
          component.destroy()
        },
      }
    },
  },
  {
    char: '#',
    items: ({ query }: { query: string }) => {
      return ['Dirty Dancing', 'Pirates of the Caribbean', 'The Matrix']
        .filter((item) => item.toLowerCase().startsWith(query.toLowerCase()))
        .slice(0, 5)
    },

    render: () => {
      let component: VueRenderer

      return {
        onStart: (props: SuggestionProps) => {
          component = new VueRenderer(MentionList, {
            // using vue 2:
            // parent: this,
            // propsData: props,
            // using vue 3:
            props,
            editor: props.editor,
          })

          if (!props.clientRect) {
            return
          }

          ;(component.element as HTMLElement).style.position = 'absolute'

          document.body.appendChild(component.element as HTMLElement)

          updatePosition(props.editor, component.element as HTMLElement)
        },

        onUpdate(props: SuggestionProps) {
          component.updateProps(props)

          if (!props.clientRect) {
            return
          }

          updatePosition(props.editor, component.element as HTMLElement)
        },

        onKeyDown(props: SuggestionProps) {
          // @ts-ignore
          if (props.event.key === 'Escape') {
            component.destroy()

            return true
          }

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

        onExit() {
          component.destroy()
        },
      }
    },
  },
]

export default suggestions
vue
<template>
  <div class="dropdown-menu">
    <template v-if="items.length">
      <button
        :class="{ 'is-selected': index === selectedIndex }"
        v-for="(item, index) in items"
        :key="index"
        @click="selectItem(index)"
      >
        {{ item }}
      </button>
    </template>
    <div class="item" v-else>No result</div>
  </div>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true,
    },

    command: {
      type: Function,
      required: true,
    },
  },

  data() {
    return {
      selectedIndex: 0,
    }
  },

  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
    },

    downHandler() {
      this.selectedIndex = (this.selectedIndex + 1) % this.items.length
    },

    enterHandler() {
      this.selectItem(this.selectedIndex)
    },

    selectItem(index) {
      const item = this.items[index]

      if (item) {
        this.command({ id: item })
      }
    },
  },
}
</script>

<style scoped lang="scss">
/* Dropdown menu */
.dropdown-menu {
  background: #fff;
  border: 1px solid rgba(61, 37, 20, 0.05);
  border-radius: 0.7rem;
  box-shadow:
    0px 12px 33px 0px rgba(0, 0, 0, 0.06),
    0px 3.618px 9.949px 0px rgba(0, 0, 0, 0.04);
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  overflow: auto;
  padding: 0.4rem;
  position: relative;

  button {
    align-items: center;
    background-color: transparent;
    display: flex;
    gap: 0.25rem;
    text-align: left;
    width: 100%;
    padding: 0 5px;
    border-radius: 4px;
    color: black;

    &:hover,
    &:hover.is-selected {
      background-color: rgba(61, 37, 20, 0.12);
    }

    &.is-selected {
      background-color: rgba(61, 37, 20, 0.08);
    }
  }
}
</style>

高级聊天输入框

  • 通过 CSS 设置输入框最大高度。
  • 搭配 ElADragUploaduseFileOperationElAFilesUpload 实现拖拽、Ctrl+V 粘贴、点击上传文件。
  • 使用 ElAFilesCard 实现文件上传回显。

props

属性名说明类型默认值
v-model输入框的htmlstring''
v-model:show-input-tag-prefix是否显示输入框前置标签booleanfalse
v-model:loading发送中、可以通过slot自定义样式booleanfalse
theme主题'light' | 'dark''light'
placeholder占位文本string''
disabled是否禁用booleanfalse
extensionstiptap 扩展配置Array<Extensions>[]
inputTagPrefixValue输入框前置标签内容string''
enterBreak回车是否换行,为 false 时回车触发 enterPressed 事件booleanfalse
onHandleKeyDown自定义键盘事件处理(view: EditorView, event: KeyboardEvent) => void-
variant布局变体'default' | 'updown''default'

slots

插槽名说明作用域参数
prefix前置内容插槽-
input-tag-prefix输入框前置标签自定义内容-
action-list操作栏列表插槽-
send-btn发送按钮插槽{ disabled: boolean }
send-btn-loadingloading为true的时候,显示loading按钮-
select-slot-contentselect-slot 点击弹窗插槽{ options: SenderSelectOption[] }

events

事件名说明回调参数
enterPressed回车键按下时触发(当 enterBreakfalse-
paste粘贴时触发(event: ClipboardEvent)
pasteFile粘贴文件时触发(files: File[])
blur失去焦点时触发-
focus获得焦点时触发-
send点击发送按钮或回车发送时触发(content: string)

exposes

名称说明类型
editortiptap editor 实例() => Editor
focus自动聚焦void