Skip to content

Sender Input

A rich text input component based on tiptap, supporting various extensions and custom layouts.

Basic Usage

Extension Reminder

If you want to extend more HTML types, please refer to the source code and pass in extensions for customization.

Theme Settings

@Member Extension Usage

Extension Reminder

Uses the Mention extension provided by Tiptap.

./metion/suggestions file
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>

Advanced Chat Input

  • Set the maximum height of the input box via CSS.
  • Combine with ElADragUpload, useFileOperation, and ElAFilesUpload to implement drag-and-drop, Ctrl+V pasting, and click-to-upload.
  • Use ElAFilesCard to display uploaded files.

props

AttributeDescriptionTypeDefault
v-modelHTML content of the input boxstring''
v-model:show-input-tag-prefixWhether to show the input box prefix tagbooleanfalse
v-model:loadingSending status; style can be customized via slotbooleanfalse
themeTheme'light' | 'dark''light'
placeholderPlaceholder textstring''
disabledWhether disabledbooleanfalse
extensionstiptap extension configurationArray<Extensions>[]
inputTagPrefixValueContent of the input box prefix tagstring''
enterBreakWhether Enter key inserts a line break. If false, Enter triggers the enterPressed event.booleanfalse
onHandleKeyDownCustom keyboard event handling(view: EditorView, event: KeyboardEvent) => void-
variantLayout variant'default' | 'updown''default'

slots

Slot NameDescriptionScoped Arguments
prefixPrefix content slot-
input-tag-prefixCustom content for input box prefix tag-
action-listAction bar list slot-
send-btnSend button slot{ disabled: boolean }
send-btn-loadingDisplayed when loading is true-
select-slot-contentPopup slot for select-slot clicks{ options: SenderSelectOption[] }

events

Event NameDescriptionCallback Arguments
enterPressedTriggered when Enter key is pressed (when enterBreak is false)-
pasteTriggered on paste(event: ClipboardEvent)
pasteFileTriggered on file paste(files: File[])
blurTriggered on blur-
focusTriggered on focus-
sendTriggered when clicking the send button or pressing Enter to send(content: string)

exposes

NameDescriptionType
editortiptap editor instance() => Editor
focusAuto focusvoid