UIPackage

Rich Text Editor

Vue form
Edit on GitHub

WYSIWYG editor wrapped around TipTap — bold/italic/links/lists/headings/blockquote/code, plus a configurable toolbar. Drop into forms where Markdown is too low-level.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/rich-text-editor.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/rich-text-editor

Examples

Props

Name Type / Values Default Required
modelValue string '' optional
placeholder string 'Start writing...' optional
class string optional
editorClass string optional
minHeight string '120px' optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

ToolbarItem
interface ToolbarItem {
  type: 'button' | 'separator'
  icon?: any
  action?: () => void
  isActive?: () => boolean
  title?: string
}

Dependencies

Used by

Files (2)

  • app/components/ui/rich-text-editor/RichTextEditor.vue 12 kB
    <script setup lang="ts">
    import { computed, watch, onBeforeUnmount, ref } from 'vue'
    import { useEditor, EditorContent } from '@tiptap/vue-3'
    import StarterKit from '@tiptap/starter-kit'
    import Placeholder from '@tiptap/extension-placeholder'
    import Underline from '@tiptap/extension-underline'
    import Link from '@tiptap/extension-link'
    import TextAlign from '@tiptap/extension-text-align'
    import TaskList from '@tiptap/extension-task-list'
    import TaskItem from '@tiptap/extension-task-item'
    import {
      Bold,
      Italic,
      Underline as UnderlineIcon,
      Strikethrough,
      List,
      ListOrdered,
      ListChecks,
      AlignLeft,
      AlignCenter,
      AlignRight,
      Link as LinkIcon,
      Heading1,
      Heading2,
      Quote,
      Minus,
      Undo2,
      Redo2,
      Code,
      RemoveFormatting,
      ChevronDown,
    } from 'lucide-vue-next'
    import { Toggle } from '@/components/ui/toggle'
    import { Separator } from '@/components/ui/separator'
    import { cn } from '@/lib/utils'
    
    const props = withDefaults(
      defineProps<{
        modelValue?: string
        placeholder?: string
        class?: string
        editorClass?: string
        minHeight?: string
      }>(),
      {
        modelValue: '',
        placeholder: 'Start writing...',
        minHeight: '120px',
      },
    )
    
    const emit = defineEmits<{
      'update:modelValue': [value: string]
    }>()
    
    const editor = useEditor({
      content: props.modelValue,
      extensions: [
        StarterKit.configure({
          heading: { levels: [1, 2, 3] },
          // StarterKit ships its own link + underline since v3 — disable them so
          // we can register the standalone packages with our own configuration
          // without TipTap warning about duplicate extension names.
          link: false,
          underline: false,
        }),
        Placeholder.configure({ placeholder: props.placeholder }),
        Underline,
        Link.configure({
          openOnClick: false,
          HTMLAttributes: { class: 'text-primary underline cursor-pointer' },
        }),
        TextAlign.configure({ types: ['heading', 'paragraph'] }),
        TaskList,
        TaskItem.configure({ nested: true }),
      ],
      editorProps: {
        attributes: {
          class: 'prose prose-sm dark:prose-invert max-w-none focus:outline-none',
        },
      },
      onUpdate: ({ editor: e }) => {
        emit('update:modelValue', e.getHTML())
      },
    })
    
    watch(
      () => props.modelValue,
      (val) => {
        if (editor.value && editor.value.getHTML() !== val) {
          editor.value.commands.setContent(val || '', { emitUpdate: false })
        }
      },
    )
    
    onBeforeUnmount(() => {
      editor.value?.destroy()
    })
    
    function toggleLink() {
      if (!editor.value) return
      if (editor.value.isActive('link')) {
        editor.value.chain().focus().unsetLink().run()
      } else {
        const url = window.prompt('Enter URL')
        if (url) {
          editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
        }
      }
    }
    
    interface ToolbarItem {
      type: 'button' | 'separator'
      icon?: any
      action?: () => void
      isActive?: () => boolean
      title?: string
    }
    
    const showExtended = ref(false)
    
    const essentialItems = computed<ToolbarItem[]>(() => {
      if (!editor.value) return []
      const e = editor.value
      return [
        {
          type: 'button',
          icon: Bold,
          action: () => e.chain().focus().toggleBold().run(),
          isActive: () => e.isActive('bold'),
          title: 'Bold',
        },
        {
          type: 'button',
          icon: Italic,
          action: () => e.chain().focus().toggleItalic().run(),
          isActive: () => e.isActive('italic'),
          title: 'Italic',
        },
        {
          type: 'button',
          icon: UnderlineIcon,
          action: () => e.chain().focus().toggleUnderline().run(),
          isActive: () => e.isActive('underline'),
          title: 'Underline',
        },
        {
          type: 'button',
          icon: Strikethrough,
          action: () => e.chain().focus().toggleStrike().run(),
          isActive: () => e.isActive('strike'),
          title: 'Strikethrough',
        },
        { type: 'separator' },
        {
          type: 'button',
          icon: List,
          action: () => e.chain().focus().toggleBulletList().run(),
          isActive: () => e.isActive('bulletList'),
          title: 'Bullet list',
        },
        {
          type: 'button',
          icon: ListOrdered,
          action: () => e.chain().focus().toggleOrderedList().run(),
          isActive: () => e.isActive('orderedList'),
          title: 'Numbered list',
        },
        { type: 'separator' },
        { type: 'button', icon: LinkIcon, action: toggleLink, isActive: () => e.isActive('link'), title: 'Link' },
        { type: 'separator' },
        { type: 'button', icon: Undo2, action: () => e.chain().focus().undo().run(), isActive: () => false, title: 'Undo' },
        { type: 'button', icon: Redo2, action: () => e.chain().focus().redo().run(), isActive: () => false, title: 'Redo' },
      ]
    })
    
    const extendedItems = computed<ToolbarItem[]>(() => {
      if (!editor.value) return []
      const e = editor.value
      return [
        {
          type: 'button',
          icon: Heading1,
          action: () => e.chain().focus().toggleHeading({ level: 1 }).run(),
          isActive: () => e.isActive('heading', { level: 1 }),
          title: 'Heading 1',
        },
        {
          type: 'button',
          icon: Heading2,
          action: () => e.chain().focus().toggleHeading({ level: 2 }).run(),
          isActive: () => e.isActive('heading', { level: 2 }),
          title: 'Heading 2',
        },
        { type: 'separator' },
        {
          type: 'button',
          icon: Code,
          action: () => e.chain().focus().toggleCode().run(),
          isActive: () => e.isActive('code'),
          title: 'Inline code',
        },
        {
          type: 'button',
          icon: Quote,
          action: () => e.chain().focus().toggleBlockquote().run(),
          isActive: () => e.isActive('blockquote'),
          title: 'Blockquote',
        },
        {
          type: 'button',
          icon: Minus,
          action: () => e.chain().focus().setHorizontalRule().run(),
          isActive: () => false,
          title: 'Divider',
        },
        {
          type: 'button',
          icon: ListChecks,
          action: () => e.chain().focus().toggleTaskList().run(),
          isActive: () => e.isActive('taskList'),
          title: 'Task list',
        },
        { type: 'separator' },
        {
          type: 'button',
          icon: AlignLeft,
          action: () => e.chain().focus().setTextAlign('left').run(),
          isActive: () => e.isActive({ textAlign: 'left' }),
          title: 'Align left',
        },
        {
          type: 'button',
          icon: AlignCenter,
          action: () => e.chain().focus().setTextAlign('center').run(),
          isActive: () => e.isActive({ textAlign: 'center' }),
          title: 'Align center',
        },
        {
          type: 'button',
          icon: AlignRight,
          action: () => e.chain().focus().setTextAlign('right').run(),
          isActive: () => e.isActive({ textAlign: 'right' }),
          title: 'Align right',
        },
        { type: 'separator' },
        {
          type: 'button',
          icon: RemoveFormatting,
          action: () => e.chain().focus().clearNodes().unsetAllMarks().run(),
          isActive: () => false,
          title: 'Clear formatting',
        },
      ]
    })
    </script>
    
    <template>
      <div :class="cn('rich-text-editor rounded-lg border', props.class)">
        <!-- Toolbar -->
        <div v-if="editor" class="border-b">
          <!-- Essential row -->
          <div class="flex items-center gap-0.5 px-2 py-1.5">
            <template v-for="(item, i) in essentialItems" :key="'e' + i">
              <Separator v-if="item.type === 'separator'" orientation="vertical" class="mx-1 h-5" />
              <Toggle
                v-else
                size="sm"
                :pressed="item.isActive?.()"
                :title="item.title"
                class="focus-visible:ring-ring size-7 p-0 focus-visible:ring-2 focus-visible:outline-none"
                @click="item.action?.()"
              >
                <component :is="item.icon" class="size-3.5" />
              </Toggle>
            </template>
    
            <Separator orientation="vertical" class="mx-1 h-5" />
    
            <!-- Expand toggle -->
            <button
              type="button"
              :title="showExtended ? 'Hide more options' : 'Show more options'"
              :class="[
                'text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:ring-ring inline-flex size-7 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none',
                showExtended && 'bg-muted text-foreground',
              ]"
              @click="showExtended = !showExtended"
            >
              <ChevronDown :class="['size-3.5 transition-transform duration-200', showExtended && 'rotate-180']" />
            </button>
          </div>
    
          <!-- Extended row (collapsible) -->
          <div
            :class="[
              'grid transition-colors duration-200 ease-in-out',
              showExtended ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
            ]"
          >
            <div class="overflow-hidden">
              <div class="flex items-center gap-0.5 border-t px-2 py-1.5">
                <template v-for="(item, i) in extendedItems" :key="'x' + i">
                  <Separator v-if="item.type === 'separator'" orientation="vertical" class="mx-1 h-5" />
                  <Toggle
                    v-else
                    size="sm"
                    :pressed="item.isActive?.()"
                    :title="item.title"
                    class="focus-visible:ring-ring size-7 p-0 focus-visible:ring-2 focus-visible:outline-none"
                    @click="item.action?.()"
                  >
                    <component :is="item.icon" class="size-3.5" />
                  </Toggle>
                </template>
              </div>
            </div>
          </div>
        </div>
    
        <!-- Editor -->
        <EditorContent
          :editor="editor"
          :class="cn('rich-text-content overflow-y-auto px-3 py-2', props.editorClass)"
          :style="{ minHeight: props.minHeight }"
        />
      </div>
    </template>
    
    <style>
    /* Editor placeholder */
    .rich-text-editor .tiptap p.is-editor-empty:first-child::before {
      content: attr(data-placeholder);
      float: left;
      color: var(--muted-foreground);
      opacity: 0.5;
      pointer-events: none;
      height: 0;
    }
    
    /* Prose overrides for compact styling */
    .rich-text-content .tiptap {
      min-height: inherit;
    }
    
    .rich-text-content .tiptap > *:first-child {
      margin-top: 0;
    }
    
    .rich-text-content .tiptap > *:last-child {
      margin-bottom: 0;
    }
    
    .rich-text-content .tiptap h1 {
      font-size: 1.25rem;
      font-weight: 700;
      line-height: 1.3;
      margin-top: 1rem;
      margin-bottom: 0.5rem;
    }
    
    .rich-text-content .tiptap h2 {
      font-size: 1.1rem;
      font-weight: 600;
      line-height: 1.3;
      margin-top: 0.75rem;
      margin-bottom: 0.375rem;
    }
    
    .rich-text-content .tiptap p {
      font-size: 0.875rem;
      line-height: 1.6;
      margin-top: 0.25rem;
      margin-bottom: 0.25rem;
    }
    
    .rich-text-content .tiptap ul,
    .rich-text-content .tiptap ol {
      padding-left: 1.25rem;
      margin-top: 0.25rem;
      margin-bottom: 0.25rem;
    }
    
    .rich-text-content .tiptap li {
      font-size: 0.875rem;
      margin-top: 0.125rem;
      margin-bottom: 0.125rem;
    }
    
    .rich-text-content .tiptap blockquote {
      border-left: 3px solid var(--border);
      padding-left: 0.75rem;
      margin-top: 0.5rem;
      margin-bottom: 0.5rem;
      color: var(--muted-foreground);
      font-style: italic;
    }
    
    .rich-text-content .tiptap code {
      background: var(--muted);
      border-radius: 0.25rem;
      padding: 0.125rem 0.25rem;
      font-size: 0.8rem;
      font-family: ui-monospace, monospace;
    }
    
    .rich-text-content .tiptap pre {
      background: var(--muted);
      border-radius: 0.5rem;
      padding: 0.75rem 1rem;
      margin-top: 0.5rem;
      margin-bottom: 0.5rem;
    }
    
    .rich-text-content .tiptap pre code {
      background: none;
      padding: 0;
      font-size: 0.8rem;
    }
    
    .rich-text-content .tiptap hr {
      border-color: var(--border);
      margin-top: 0.75rem;
      margin-bottom: 0.75rem;
    }
    
    /* Task list styling */
    .rich-text-content .tiptap ul[data-type='taskList'] {
      list-style: none;
      padding-left: 0;
    }
    
    .rich-text-content .tiptap ul[data-type='taskList'] li {
      display: flex;
      align-items: flex-start;
      gap: 0.5rem;
    }
    
    .rich-text-content .tiptap ul[data-type='taskList'] li > label {
      flex-shrink: 0;
      margin-top: 0.125rem;
    }
    
    .rich-text-content .tiptap ul[data-type='taskList'] li > label input[type='checkbox'] {
      accent-color: var(--primary);
      width: 0.875rem;
      height: 0.875rem;
      cursor: pointer;
    }
    
    .rich-text-content .tiptap ul[data-type='taskList'] li > div {
      flex: 1;
    }
    
    /* Link styling */
    .rich-text-content .tiptap a {
      color: var(--primary);
      text-decoration: underline;
      cursor: pointer;
    }
    </style>
  • app/components/ui/rich-text-editor/index.ts 0.1 kB
    export { default as RichTextEditor } from './RichTextEditor.vue'

Raw manifest: https://uipkge.dev/r/vue/rich-text-editor.json