Markdown, HTML 및 JSON 콘텐츠 유형을 지원하는 TipTap 기반의 리치 텍스트 편집기 구성 요소입니다.A rich text editor component based on TipTap with support for markdown, HTML, and JSON content types.

Usage

Editor 구성 요소는 [TipTap](https://tiptap.dev/xph04x에 구축된 강력한 풍부한 텍스트 편집 환경을 제공합니다. 다양한 컨텐츠 형식(JSON, HTML, Markdown), 사용자 정의 도구 모음, 드래그 앤 드롭 블록 순서 변경, 언급, 이모티콘 선택기 및 사용자 정의 기능을 추가하기 위한 확장 가능한 아키텍처를 지원합니다.

이 예제에서는 프로덕션 준비 편집기 구성 요소를 보여 줍니다.GitHub에서 소스 코드를 확인하십시오.This example demonstrates a production-ready Editor component.Check out the source code on GitHub.
Editor 구성 요소 또는 확장 프로그램을 사용할 때 Adding different instances of a keyed plugin와 같은 prosemirror 관련 오류가 발생하면 prosemirror 패키지를 nuxt.config.ts 파일의 vite.optimizeDeps.include 목록에 추가해야 할 수도 있습니다. 이렇게 하면 Vite는 이러한 종속성을 사전 번들로 묶어 여러 인스턴스를 로드하지 않습니다.
nuxt.config.ts
export default defineNuxtConfig({
  vite: {
    optimizeDeps: {
      include: [
        '@nuxt/ui > prosemirror-state',
        '@nuxt/ui > prosemirror-transform',
        '@nuxt/ui > prosemirror-model',
        '@nuxt/ui > prosemirror-view',
        '@nuxt/ui > prosemirror-gapcursor'
      ]
    }
  }
})

Content

v-model 지시문을 사용하여 Editor 값을 제어합니다.

<script setup lang="ts">
const value = ref({
  type: 'doc',
  content: [
    {
      type: 'heading',
      attrs: {
        level: 1
      },
      content: [
        {
          type: 'text',
          text: 'Hello World'
        }
      ]
    },
    {
      type: 'paragraph',
      content: [
        {
          type: 'text',
          text: 'This is a '
        },
        {
          type: 'text',
          marks: [
            {
              type: 'bold'
            }
          ],
          text: 'rich text'
        },
        {
          type: 'text',
          text: ' editor.'
        }
      ]
    }
  ]
})
</script>

<template>
  <UEditor v-model="value" class="w-full min-h-21" />
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref({
  type: 'doc',
  content: [
    {
      type: 'heading',
      attrs: {
        level: 1
      },
      content: [
        {
          type: 'text',
          text: 'Hello World'
        }
      ]
    },
    {
      type: 'paragraph',
      content: [
        {
          type: 'text',
          text: 'This is a '
        },
        {
          type: 'text',
          marks: [
            {
              type: 'bold'
            }
          ],
          text: 'rich text'
        },
        {
          type: 'text',
          text: ' editor.'
        }
      ]
    }
  ]
})
</script>

<template>
  <UEditor v-model="value" class="w-full min-h-21" />
</template>

Content 형식

편집기는 v-model 유형에 따라 컨텐츠 형식을 자동으로 감지합니다. 문자열은 html로 처리되고 객체는 json로 처리됩니다.

content-type prop을 사용하여 형식을 명시적으로 설정할 수 있습니다: json, html 또는 markdown.

<script setup lang="ts">
const value = ref('<h1>Hello World</h1>\n<p>This is a <strong>rich text</strong> editor.</p>\n')
</script>

<template>
  <UEditor v-model="value" content-type="html" class="w-full min-h-21" />
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref('<h1>Hello World</h1>\n<p>This is a <strong>rich text</strong> editor.</p>\n')
</script>

<template>
  <UEditor v-model="value" content-type="html" class="w-full min-h-21" />
</template>

확장

편집기에는 기본적으로 다음과 같은 확장이 포함됩니다.The Editor includes the following extensions by default:

  • StarterKit - 코어 편집 기능(굵게, 기울임꼴, 머리글, 목록 등)
  • Placeholder - 자리 표시자 텍스트 표시(자리 표시자 Prop 제공 시)
  • Image - 이미지 삽입 및 표시
  • Mention - @mention 지원 추가
  • Markdown - Markdown 구문 분석 및 직렬화 (콘텐츠 유형이 markdown 인 경우)
각 내장 확장은 해당 prop (starter-kit, placeholder, image, mention, markdown)를 사용하여 TipTap 옵션으로 동작을 사용자 정의 할 수 있습니다.

extensions prop을 사용하여 TipTap 확장을 추가하여 에디터의 기능을 향상시킬 수 있습니다:

<script setup lang="ts">
import { Emoji } from '@tiptap/extension-emoji'
import { TextAlign } from '@tiptap/extension-text-align'

const value = ref('<h1>Hello World</h1>\n')
</script>

<template>
  <UEditor
    v-model="value"
    :extensions="[
      Emoji,
      TextAlign.configure({
        types: ['heading', 'paragraph']
      })
    ]"
  />
</template>
사용자 지정 TipTap 확장을 만들기 위한 이미지 업로드 예제를 확인하십시오.

placeholder 위치 표시자

placeholder 소품을 사용하여 빈 단락에 표시되는 자리 표시자 텍스트를 설정합니다.

<script setup lang="ts">
const value = ref('')
</script>

<template>
  <UEditor v-model="value" placeholder="Start writing..." class="w-full min-h-7" />
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref('')
</script>

<template>
  <UEditor v-model="value" placeholder="Start writing..." class="w-full min-h-7" />
</template>
placeholder prop은 PlaceholderOptions 및 추가 mode 속성이 있는 문자열 또는 객체를 허용합니다.
  • everyLine: 포커스가 설정되면 빈 줄마다 자리 표시자를 표시합니다(기본값).
  • firstLine : 편집기가 비어 있을 때 첫 줄에만 자리 표시자를 표시합니다.
<template>
  <UEditor :placeholder="{ placeholder: 'Start writing...', mode: 'firstLine' }" />
</template>
기본적으로 자리 표시자는 최상위 빈 노드에만 표시됩니다. 목록 항목과 같은 중첩된 요소에 자리 표시자를 표시하려면 includeChildren를 true로 설정합니다.
<template>
  <UEditor :placeholder="{ placeholder: 'Start writing...', includeChildren: true }" />
</template>
TipTap 설명서 에서 자리 표시 자 확장 에 대해 자세히 알아보 십시오 .

starter 키트

starter-kit prop 을 사용 하 여 굵 게 , 기울임 꼴 , 제목 , 목록 , 블록 따옴표 , 코드 블록 등 과 같 은 일반 적 인 편집기 기능 을 포함 하 는 내장 TipTap StarterKit 확장 을 구성 합니다 . Use the starter-kit prop to configure the built - in TipTap StarterKit extension which includes common editor features like bold , italic , headers , lists , blockquotes , code blocks , and more .

<script setup lang="ts">
const value = ref('<h1>Hello World</h1>\n')
</script>

<template>
  <UEditor
    v-model="value"
    :starter-kit="{
      blockquote: false,
      headings: {
        levels: [1, 2, 3, 4]
      },
      dropcursor: {
        color: 'var(--ui-primary)',
        width: 2
      },
      link: {
        openOnClick: false
      }
    }"
  />
</template>
일반 텍스트 편집기 의 경우 starter-kit 를 false 로 설정 합니다 . 기본 노드 (단락 , 텍스트 , 히스토리) 를 유지 하 고 굵 게 , 기울임 꼴 , 머리글 , 목록 , 코드 , 블록 따옴표 , 링크 및 가로 규칙 과 같 은 모든 서식 기능 을 비 활성 화 합니다 .
StarterKit 확장 에 대한 자세 한 내용 은 TipTap 설명서 에서 확인 하 십시오 .

Handler (### Handler) 파일

핸들러 는 TipTap 의 기본 제공 명령 을 래핑 하 여 편집기 작업 을 위한 통합 인터페이스 를 제공 합니다 . EditorToolbar 또는 [EditorSugestionMenu](xph 21 x) 항목 에 kind 등록 정보 를 추가 하 면 해당 핸들러 는 TipTap 명령 을 관리 하 고 활성 상태 (비활성 화) 를 실행 합니다 .

기본 핸들러

Editor 구성 요소 는 kind 속성 을 사용 하 여 도구 모음 또는 제안 메뉴 항목 에서 참조 할 수 있 는 이러 한 기본 처리 기 를 제공 합니다 .

처리 기 (Handler)설명 (Description)사용 방법
mark텍스트 표시 (굵 게 , 기울임 꼴 , 스트라이크 , 코드 , 밑줄) 전환항목 에 mark 속성 필요
textAlign텍스트 정렬 설정 (왼쪽 , 가운데 , 오른쪽 , 자리 맞추 기)항목 에 align 속성 필요
heading제목 레벨 전환 (1 - 6)항목 에 level 속성 필요
link링크 추가 , 편집 또는 제거URL 이 제공 되 지 않 은 경우 프롬프트
image이미지 삽입URL 이 제공 되 지 않 은 경우 프롬프트
blockquote블록 따옴표 토글
bulletList글 머리 기호 목록 전환리스트 변환 처리
orderedList순서 가 지정 된 목록 전환리스트 변환 처리
taskList작업 목록 토글리스트 변환 처리
codeBlock코드 블록 전환
horizontalRule수평 규칙 삽입
paragraph단락 형식 설정
undo마지막 변경 명령 취소
redo마지막 으로 실행 취소 한 변경 내용 다시 실행
clearFormatting 파일모든 서식 제거선택 또는 위치 와 함께 작업
duplicate노드 복제항목에 pos 속성 필요
delete노드 삭제항목에 pos 속성 필요
moveUp노드를 위로 이동항목에 pos 속성 필요
moveDown노드 아래로 이동항목에 pos 속성 필요
suggestion제안 메뉴 트리거/ 문자 삽입
mention트리거 언급 메뉴@ 문자 삽입
emoji 파일이모티콘 선택기 트리거: 문자 삽입
taskList 및 textAlign 처리기는 해당 확장이 설치된 경우에만 작동하며, 기본적으로 편집기에 포함되지 않습니다.

다음은 도구 모음 또는 제안 메뉴 항목에서 기본 처리기를 사용하는 방법입니다.

<script setup lang="ts">
import type { EditorToolbarItem } from '@nuxt/ui'

const value = ref('<h1>Hello World</h1>\n')

const items: EditorToolbarItem[] = [
  { kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
  { kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
  { kind: 'heading', level: 1, icon: 'i-lucide-heading-1' },
  { kind: 'heading', level: 2, icon: 'i-lucide-heading-2' },
  { kind: 'textAlign', align: 'left', icon: 'i-lucide-align-left' },
  { kind: 'textAlign', align: 'center', icon: 'i-lucide-align-center' },
  { kind: 'bulletList', icon: 'i-lucide-list' },
  { kind: 'orderedList', icon: 'i-lucide-list-ordered' },
  { kind: 'blockquote', icon: 'i-lucide-quote' },
  { kind: 'link', icon: 'i-lucide-link' }
]
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value">
    <UEditorToolbar :editor="editor" :items="items" />
  </UEditor>
</template>

사용자 정의 처리기

handlers 소품을 사용하여 기본 처리기를 확장하거나 재정의합니다. 사용자 정의 처리기는 기본 처리기와 병합되므로 새 작업을 추가하거나 기존 동작을 수정할 수 있습니다.

각 핸들러는 EditorHandler 인터페이스를 구현합니다.

interface EditorHandler {
  /* Checks if the command can be executed in the current editor state */
  canExecute: (editor: Editor, item?: any) => boolean
  /* Executes the command and returns a Tiptap chain */
  execute: (editor: Editor, item?: any) => any
  /* Determines if the item should appear active (used for toggle states) */
  isActive: (editor: Editor, item?: any) => boolean
  /* Optional additional check to disable the item (combined with `canExecute`) */
  isDisabled?: (editor: Editor, item?: any) => boolean
}

다음은 사용자 지정 처리기를 만드는 예입니다.

<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3'
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'

const value = ref('<h1>Hello World</h1>\n')

const customHandlers = {
  highlight: {
    canExecute: (editor: Editor) => editor.can().toggleHighlight(),
    execute: (editor: Editor) => editor.chain().focus().toggleHighlight(),
    isActive: (editor: Editor) => editor.isActive('highlight'),
    isDisabled: (editor: Editor) => !editor.isEditable
  }
} satisfies EditorCustomHandlers

const items = [
  // Built-in handler
  { kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
  // Custom handler
  { kind: 'highlight', icon: 'i-lucide-highlighter' }
] satisfies EditorToolbarItem<typeof customHandlers>[]
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" :handlers="customHandlers">
    <UEditorToolbar :editor="editor" :items="items" />
  </UEditor>
</template>
사용자 지정 처리기를 사용하여 전체 구현을 보려면 이미지 업로드 예제를 확인하십시오.

예제

GitHub에서 Editor template의 소스 코드를 확인하여 실제 예제를 보십시오.

With 도구 모음

EditorToolbar 구성 요소를 사용하여 일반적인 서식 작업을 사용하여 fixed, bubble 또는 floating 도구 모음을 편집기에 추가할 수 있습니다.

<script setup lang="ts">
import type { EditorToolbarItem } from '@nuxt/ui'

const value = ref(`# Toolbar

Select some text to see the formatting toolbar appear above your selection.`)

const items: EditorToolbarItem[][] = [
  [
    {
      icon: 'i-lucide-heading',
      tooltip: { text: 'Headings' },
      content: {
        align: 'start'
      },
      items: [
        {
          kind: 'heading',
          level: 1,
          icon: 'i-lucide-heading-1',
          label: 'Heading 1'
        },
        {
          kind: 'heading',
          level: 2,
          icon: 'i-lucide-heading-2',
          label: 'Heading 2'
        },
        {
          kind: 'heading',
          level: 3,
          icon: 'i-lucide-heading-3',
          label: 'Heading 3'
        },
        {
          kind: 'heading',
          level: 4,
          icon: 'i-lucide-heading-4',
          label: 'Heading 4'
        }
      ]
    }
  ],
  [
    {
      kind: 'mark',
      mark: 'bold',
      icon: 'i-lucide-bold',
      tooltip: { text: 'Bold' }
    },
    {
      kind: 'mark',
      mark: 'italic',
      icon: 'i-lucide-italic',
      tooltip: { text: 'Italic' }
    },
    {
      kind: 'mark',
      mark: 'underline',
      icon: 'i-lucide-underline',
      tooltip: { text: 'Underline' }
    },
    {
      kind: 'mark',
      mark: 'strike',
      icon: 'i-lucide-strikethrough',
      tooltip: { text: 'Strikethrough' }
    },
    {
      kind: 'mark',
      mark: 'code',
      icon: 'i-lucide-code',
      tooltip: { text: 'Code' }
    }
  ]
]
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="w-full min-h-21">
    <UEditorToolbar :editor="editor" :items="items" layout="bubble" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'

const value = ref(`# Toolbar

Select some text to see the formatting toolbar appear above your selection.`)

const items: EditorToolbarItem[][] = [
  [
    {
      icon: 'i-lucide-heading',
      tooltip: { text: 'Headings' },
      content: {
        align: 'start'
      },
      items: [
        {
          kind: 'heading',
          level: 1,
          icon: 'i-lucide-heading-1',
          label: 'Heading 1'
        },
        {
          kind: 'heading',
          level: 2,
          icon: 'i-lucide-heading-2',
          label: 'Heading 2'
        },
        {
          kind: 'heading',
          level: 3,
          icon: 'i-lucide-heading-3',
          label: 'Heading 3'
        },
        {
          kind: 'heading',
          level: 4,
          icon: 'i-lucide-heading-4',
          label: 'Heading 4'
        }
      ]
    }
  ],
  [
    {
      kind: 'mark',
      mark: 'bold',
      icon: 'i-lucide-bold',
      tooltip: { text: 'Bold' }
    },
    {
      kind: 'mark',
      mark: 'italic',
      icon: 'i-lucide-italic',
      tooltip: { text: 'Italic' }
    },
    {
      kind: 'mark',
      mark: 'underline',
      icon: 'i-lucide-underline',
      tooltip: { text: 'Underline' }
    },
    {
      kind: 'mark',
      mark: 'strike',
      icon: 'i-lucide-strikethrough',
      tooltip: { text: 'Strikethrough' }
    },
    {
      kind: 'mark',
      mark: 'code',
      icon: 'i-lucide-code',
      tooltip: { text: 'Code' }
    }
  ]
]
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="w-full min-h-21">
    <UEditorToolbar :editor="editor" :items="items" layout="bubble" />
  </UEditor>
</template>

드래그 핸들 사용

EditorDragHandle 구성요소를 사용하여 블록 순서를 조정하기 위한 끌기 가능한 핸들을 추가할 수 있습니다.

<script setup lang="ts">
const value = ref(`# Drag Handle

Hover over the left side of this block to see the drag handle appear and reorder blocks.`)
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="w-full min-h-21">
    <UEditorDragHandle :editor="editor" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref(`# Drag Handle

Hover over the left side of this block to see the drag handle appear and reorder blocks.`)
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="w-full min-h-21">
    <UEditorDragHandle :editor="editor" />
  </UEditor>
</template>

With 권장 메뉴

EditorSugestionMenu 구성 요소를 사용하여 슬래시 명령을 추가하여 형식 지정 및 삽입을 신속하게 수행할 수 있습니다.

<script setup lang="ts">
import type { EditorSuggestionMenuItem } from '@nuxt/ui'

const value = ref(`# Suggestion Menu

Type / to open the suggestion menu and browse available formatting commands.`)

const items: EditorSuggestionMenuItem[][] = [
  [
    {
      type: 'label',
      label: 'Text'
    },
    {
      kind: 'paragraph',
      label: 'Paragraph',
      icon: 'i-lucide-type'
    },
    {
      kind: 'heading',
      level: 1,
      label: 'Heading 1',
      icon: 'i-lucide-heading-1'
    },
    {
      kind: 'heading',
      level: 2,
      label: 'Heading 2',
      icon: 'i-lucide-heading-2'
    },
    {
      kind: 'heading',
      level: 3,
      label: 'Heading 3',
      icon: 'i-lucide-heading-3'
    }
  ],
  [
    {
      type: 'label',
      label: 'Lists'
    },
    {
      kind: 'bulletList',
      label: 'Bullet List',
      icon: 'i-lucide-list'
    },
    {
      kind: 'orderedList',
      label: 'Numbered List',
      icon: 'i-lucide-list-ordered'
    }
  ],
  [
    {
      type: 'label',
      label: 'Insert'
    },
    {
      kind: 'blockquote',
      label: 'Blockquote',
      icon: 'i-lucide-text-quote'
    },
    {
      kind: 'codeBlock',
      label: 'Code Block',
      icon: 'i-lucide-square-code'
    },
    {
      kind: 'horizontalRule',
      label: 'Divider',
      icon: 'i-lucide-separator-horizontal'
    }
  ]
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type / for commands..."
    class="w-full min-h-21"
  >
    <UEditorSuggestionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorSuggestionMenuItem } from '@nuxt/ui'

const value = ref(`# Suggestion Menu

Type / to open the suggestion menu and browse available formatting commands.`)

const items: EditorSuggestionMenuItem[][] = [
  [
    {
      type: 'label',
      label: 'Text'
    },
    {
      kind: 'paragraph',
      label: 'Paragraph',
      icon: 'i-lucide-type'
    },
    {
      kind: 'heading',
      level: 1,
      label: 'Heading 1',
      icon: 'i-lucide-heading-1'
    },
    {
      kind: 'heading',
      level: 2,
      label: 'Heading 2',
      icon: 'i-lucide-heading-2'
    },
    {
      kind: 'heading',
      level: 3,
      label: 'Heading 3',
      icon: 'i-lucide-heading-3'
    }
  ],
  [
    {
      type: 'label',
      label: 'Lists'
    },
    {
      kind: 'bulletList',
      label: 'Bullet List',
      icon: 'i-lucide-list'
    },
    {
      kind: 'orderedList',
      label: 'Numbered List',
      icon: 'i-lucide-list-ordered'
    }
  ],
  [
    {
      type: 'label',
      label: 'Insert'
    },
    {
      kind: 'blockquote',
      label: 'Blockquote',
      icon: 'i-lucide-text-quote'
    },
    {
      kind: 'codeBlock',
      label: 'Code Block',
      icon: 'i-lucide-square-code'
    },
    {
      kind: 'horizontalRule',
      label: 'Divider',
      icon: 'i-lucide-separator-horizontal'
    }
  ]
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type / for commands..."
    class="w-full min-h-21"
  >
    <UEditorSuggestionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>

Mention 메뉴

EditorMentionMenu 구성 요소를 사용하여 사용자 또는 엔티티에 태그를 지정하기 위한 @mentions를 추가할 수 있습니다.

<script setup lang="ts">
import type { EditorMentionMenuItem } from '@nuxt/ui'

const value = ref(`# Mention Menu

Type @ to mention someone and select from the list of available users.`)

const items: EditorMentionMenuItem[] = [
  {
    label: 'benjamincanac',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/739984?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'atinux',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/904724?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'danielroe',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/28706372?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'pi0',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/5158436?v=4',
      loading: 'lazy' as const
    }
  }
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention someone..."
    class="w-full min-h-21"
  >
    <UEditorMentionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorMentionMenuItem } from '@nuxt/ui'

const value = ref(`# Mention Menu

Type @ to mention someone and select from the list of available users.`)

const items: EditorMentionMenuItem[] = [
  {
    label: 'benjamincanac',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/739984?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'atinux',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/904724?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'danielroe',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/28706372?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'pi0',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/5158436?v=4',
      loading: 'lazy' as const
    }
  }
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention someone..."
    class="w-full min-h-21"
  >
    <UEditorMentionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>

Emoji 메뉴 포함

EditorEmojMenu 구성 요소를 사용하여 이모티콘 선택기 지원을 추가할 수 있습니다.

<script setup lang="ts">
import type { EditorEmojiMenuItem } from '@nuxt/ui'
import { Emoji, gitHubEmojis } from '@tiptap/extension-emoji'

const value = ref(`# Emoji Menu

Type : to insert emojis and select from the list of available emojis.`)

const items: EditorEmojiMenuItem[] = gitHubEmojis.filter(
  (emoji) => !emoji.name.startsWith('regional_indicator_')
)

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    :extensions="[Emoji]"
    content-type="markdown"
    placeholder="Type : to add emojis..."
    class="w-full min-h-21"
  >
    <UEditorEmojiMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorEmojiMenuItem } from '@nuxt/ui'
import { Emoji, gitHubEmojis } from '@tiptap/extension-emoji'

const value = ref(`# Emoji Menu

Type : to insert emojis and select from the list of available emojis.`)

const items: EditorEmojiMenuItem[] = gitHubEmojis.filter(
  (emoji) => !emoji.name.startsWith('regional_indicator_')
)

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    :extensions="[Emoji]"
    content-type="markdown"
    placeholder="Type : to add emojis..."
    class="w-full min-h-21"
  >
    <UEditorEmojiMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>

With 이미지 업로드

이 예에서는 extensions prop을 사용하여 이미지 업로드 기능을 만들어 사용자 정의 TipTap 노드와 handlers prop을 등록하여 도구 모음 버튼이 업로드 흐름을 트리거하는 방법을 정의하는 방법을 보여 줍니다.

  1. xFileUpload 구성 요소를 사용하여 Vue 구성 요소를 만듭니다.
EditorImageUploadNode.vue
<script setup lang="ts">
import type { NodeViewProps } from '@tiptap/vue-3'
import { NodeViewWrapper } from '@tiptap/vue-3'

const props = defineProps<NodeViewProps>()

const file = ref<File | null>(null)
const loading = ref(false)

watch(file, async (newFile) => {
  if (!newFile) return

  loading.value = true

  const reader = new FileReader()
  reader.onload = async (e) => {
    const dataUrl = e.target?.result as string
    if (!dataUrl) {
      loading.value = false
      return
    }

    // Simulate upload delay
    await new Promise(resolve => setTimeout(resolve, 1000))

    const pos = props.getPos()
    if (typeof pos !== 'number') {
      loading.value = false
      return
    }

    props.editor
      .chain()
      .focus()
      .deleteRange({ from: pos, to: pos + 1 })
      .setImage({ src: dataUrl })
      .run()

    loading.value = false
  }
  reader.readAsDataURL(newFile)
})
</script>

<template>
  <NodeViewWrapper>
    <UFileUpload
      v-model="file"
      accept="image/*"
      label="Upload an image"
      description="SVG, PNG, JPG or GIF (max. 2MB)"
      :preview="false"
      class="min-h-48"
    >
      <template #leading>
        <UAvatar
          :icon="loading ? 'i-lucide-loader-circle' : 'i-lucide-image'"
          size="xl"
          :ui="{ icon: [loading && 'animate-spin'] }"
        />
      </template>
    </UFileUpload>
  </NodeViewWrapper>
</template>
EditorImageUploadNode.vue
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { NodeViewProps } from '@tiptap/vue-3'
import { NodeViewWrapper } from '@tiptap/vue-3'

const props = defineProps<NodeViewProps>()

const file = ref<File | null>(null)
const loading = ref(false)

watch(file, async (newFile) => {
  if (!newFile) return

  loading.value = true

  const reader = new FileReader()
  reader.onload = async (e) => {
    const dataUrl = e.target?.result as string
    if (!dataUrl) {
      loading.value = false
      return
    }

    // Simulate upload delay
    await new Promise(resolve => setTimeout(resolve, 1000))

    const pos = props.getPos()
    if (typeof pos !== 'number') {
      loading.value = false
      return
    }

    props.editor
      .chain()
      .focus()
      .deleteRange({ from: pos, to: pos + 1 })
      .setImage({ src: dataUrl })
      .run()

    loading.value = false
  }
  reader.readAsDataURL(newFile)
})
</script>

<template>
  <NodeViewWrapper>
    <UFileUpload
      v-model="file"
      accept="image/*"
      label="Upload an image"
      description="SVG, PNG, JPG or GIF (max. 2MB)"
      :preview="false"
      class="min-h-48"
    >
      <template #leading>
        <UAvatar
          :icon="loading ? 'i-lucide-loader-circle' : 'i-lucide-image'"
          size="xl"
          :ui="{ icon: [loading && 'animate-spin'] }"
        />
      </template>
    </UFileUpload>
  </NodeViewWrapper>
</template>
  1. 사용자 정의 TipTap 확장을 만들어 노드를 등록합니다.
EditorImageUploadExtension.ts
import { Node, mergeAttributes } from '@tiptap/core'
import type { CommandProps, NodeViewRenderer } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import ImageUploadNodeComponent from './EditorImageUploadNode.vue'

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    imageUpload: {
      insertImageUpload: () => ReturnType
    }
  }
}

export const ImageUpload = Node.create({
  name: 'imageUpload',
  group: 'block',
  atom: true,
  draggable: true,
  addAttributes() {
    return {}
  },
  parseHTML() {
    return [{
      tag: 'div[data-type="image-upload"]'
    }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-upload' })]
  },
  addNodeView(): NodeViewRenderer {
    return VueNodeViewRenderer(ImageUploadNodeComponent)
  },
  addCommands() {
    return {
      insertImageUpload: () => ({ commands }: CommandProps) => {
        return commands.insertContent({ type: this.name })
      }
    }
  }
})

export default ImageUpload
  1. 편집기에서 사용자 지정 확장을 사용합니다.
<script setup lang="ts">
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/vue-3'
import { ImageUpload } from './EditorImageUploadExtension'

const value = ref(`# Image Upload

This editor demonstrates how to create a custom TipTap extension with handlers. Click the image button in the toolbar to upload a file — it will show a custom [FileUpload](/docs/components/file-upload) interface before inserting the image.

Try uploading an image below:

`)

const customHandlers = {
  imageUpload: {
    canExecute: (editor: Editor) => editor.can().insertContent({ type: 'imageUpload' }),
    execute: (editor: Editor) => editor.chain().focus().insertContent({ type: 'imageUpload' }),
    isActive: (editor: Editor) => editor.isActive('imageUpload'),
    isDisabled: undefined
  }
} satisfies EditorCustomHandlers

const items = [
  [
    {
      kind: 'imageUpload',
      icon: 'i-lucide-image',
      label: 'Add image',
      variant: 'soft'
    }
  ],
  [
    {
      icon: 'i-lucide-heading',
      content: {
        align: 'start'
      },
      items: [
        {
          kind: 'heading',
          level: 1,
          icon: 'i-lucide-heading-1',
          label: 'Heading 1'
        },
        {
          kind: 'heading',
          level: 2,
          icon: 'i-lucide-heading-2',
          label: 'Heading 2'
        },
        {
          kind: 'heading',
          level: 3,
          icon: 'i-lucide-heading-3',
          label: 'Heading 3'
        },
        {
          kind: 'heading',
          level: 4,
          icon: 'i-lucide-heading-4',
          label: 'Heading 4'
        }
      ]
    }
  ],
  [
    {
      kind: 'mark',
      mark: 'bold',
      icon: 'i-lucide-bold'
    },
    {
      kind: 'mark',
      mark: 'italic',
      icon: 'i-lucide-italic'
    },
    {
      kind: 'mark',
      mark: 'underline',
      icon: 'i-lucide-underline'
    },
    {
      kind: 'mark',
      mark: 'strike',
      icon: 'i-lucide-strikethrough'
    },
    {
      kind: 'mark',
      mark: 'code',
      icon: 'i-lucide-code'
    }
  ]
] satisfies EditorToolbarItem<typeof customHandlers>[][]
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    :extensions="[ImageUpload]"
    :handlers="customHandlers"
    content-type="markdown"
    :ui="{ base: 'p-8 sm:px-16' }"
    class="w-full min-h-74"
  >
    <UEditorToolbar
      :editor="editor"
      :items="items"
      class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto"
    />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/vue-3'
import { ImageUpload } from './EditorImageUploadExtension'

const value = ref(`# Image Upload

This editor demonstrates how to create a custom TipTap extension with handlers. Click the image button in the toolbar to upload a file — it will show a custom [FileUpload](/docs/components/file-upload) interface before inserting the image.

Try uploading an image below:

`)

const customHandlers = {
  imageUpload: {
    canExecute: (editor: Editor) => editor.can().insertContent({ type: 'imageUpload' }),
    execute: (editor: Editor) => editor.chain().focus().insertContent({ type: 'imageUpload' }),
    isActive: (editor: Editor) => editor.isActive('imageUpload'),
    isDisabled: undefined
  }
} satisfies EditorCustomHandlers

const items = [
  [
    {
      kind: 'imageUpload',
      icon: 'i-lucide-image',
      label: 'Add image',
      variant: 'soft'
    }
  ],
  [
    {
      icon: 'i-lucide-heading',
      content: {
        align: 'start'
      },
      items: [
        {
          kind: 'heading',
          level: 1,
          icon: 'i-lucide-heading-1',
          label: 'Heading 1'
        },
        {
          kind: 'heading',
          level: 2,
          icon: 'i-lucide-heading-2',
          label: 'Heading 2'
        },
        {
          kind: 'heading',
          level: 3,
          icon: 'i-lucide-heading-3',
          label: 'Heading 3'
        },
        {
          kind: 'heading',
          level: 4,
          icon: 'i-lucide-heading-4',
          label: 'Heading 4'
        }
      ]
    }
  ],
  [
    {
      kind: 'mark',
      mark: 'bold',
      icon: 'i-lucide-bold'
    },
    {
      kind: 'mark',
      mark: 'italic',
      icon: 'i-lucide-italic'
    },
    {
      kind: 'mark',
      mark: 'underline',
      icon: 'i-lucide-underline'
    },
    {
      kind: 'mark',
      mark: 'strike',
      icon: 'i-lucide-strikethrough'
    },
    {
      kind: 'mark',
      mark: 'code',
      icon: 'i-lucide-code'
    }
  ]
] satisfies EditorToolbarItem<typeof customHandlers>[][]
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    :extensions="[ImageUpload]"
    :handlers="customHandlers"
    content-type="markdown"
    :ui="{ base: 'p-8 sm:px-16' }"
    class="w-full min-h-74"
  >
    <UEditorToolbar
      :editor="editor"
      :items="items"
      class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto"
    />
  </UEditor>
</template>
TipTap 설명서에서 사용자 지정 확장 만들기에 대해 자세히 알아보십시오.

AI 완료

이 예에서는 Vercel AI SDK, 특히 스트리밍 텍스트 완성을 위해 ](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-completion) 컴포지션을 사용하여 편집기에 AI 기반 기능을 추가하는 방법을 보여 줍니다. Vercel AI Gateway와 결합하여 중앙 집중식 엔드포인트를 통해 AI 모델에 액세스할 수 있습니다. 고스트 텍스트 자동 완성 및 텍스트 변환이 포함됩니다. 동작 (수정 문법, 확장, 축소, 단순화, 번역 등).

이 예제를 사용하려면 먼저 이러한 종속성을 설치해야 합니다.You need to install these dependencies first to use this example:
pnpm add ai @ai-sdk/gateway @ai-sdk/vue
  1. 인라인 고스트 텍스트 제안을 처리하는 사용자 지정 TipTap 확장을 만듭니다.
EditorCompletionExtension.ts
import { Extension } from '@tiptap/core'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/vue-3'
import { useDebounceFn } from '@vueuse/core'

export interface CompletionOptions {
  /**
   * Debounce delay in ms before triggering completion
   * @defaultValue 250
   */
  debounce?: number
  /**
   * Whether to automatically trigger completion while typing
   * @defaultValue false
   */
  autoTrigger?: boolean
  /**
   * Characters that should prevent completion from triggering
   * @defaultValue ['/', ':', '@']
   */
  triggerCharacters?: string[]
  /**
   * Called when completion should be triggered, receives the editor instance
   */
  onTrigger?: (editor: Editor) => void
  /**
   * Called when suggestion is accepted
   */
  onAccept?: () => void
  /**
   * Called when suggestion is dismissed
   */
  onDismiss?: () => void
}

export interface CompletionStorage {
  suggestion: string
  position: number | undefined
  visible: boolean
  debouncedTrigger: ((editor: Editor) => void) | null
  setSuggestion: (text: string) => void
  clearSuggestion: () => void
}

export const completionPluginKey = new PluginKey('completion')

export const Completion = Extension.create<CompletionOptions, CompletionStorage>({
  name: 'completion',

  addOptions() {
    return {
      debounce: 250,
      autoTrigger: false,
      triggerCharacters: ['/', ':', '@'],
      onTrigger: undefined,
      onAccept: undefined,
      onDismiss: undefined
    }
  },

  addStorage() {
    return {
      suggestion: '',
      position: undefined as number | undefined,
      visible: false,
      debouncedTrigger: null as ((editor: Editor) => void) | null,
      setSuggestion(text: string) {
        this.suggestion = text
      },
      clearSuggestion() {
        this.suggestion = ''
        this.position = undefined
        this.visible = false
      }
    }
  },

  addProseMirrorPlugins() {
    const storage = this.storage

    return [
      new Plugin({
        key: completionPluginKey,
        props: {
          decorations(state) {
            if (!storage.visible || !storage.suggestion || storage.position === undefined) {
              return DecorationSet.empty
            }

            const widget = Decoration.widget(storage.position, () => {
              const span = document.createElement('span')
              span.className = 'completion-suggestion'
              span.textContent = storage.suggestion
              span.style.cssText = 'color: var(--ui-text-muted); opacity: 0.6; pointer-events: none;'
              return span
            }, { side: 1 })

            return DecorationSet.create(state.doc, [widget])
          }
        }
      })
    ]
  },

  addKeyboardShortcuts() {
    return {
      'Mod-j': ({ editor }) => {
        // Clear any existing suggestion first to avoid flickering
        if (this.storage.visible) {
          this.storage.clearSuggestion()
          this.options.onDismiss?.()
        }
        // Manually trigger completion
        this.storage.debouncedTrigger?.(editor as Editor)
        return true
      },
      'Tab': ({ editor }) => {
        if (!this.storage.visible || !this.storage.suggestion || this.storage.position === undefined) {
          return false
        }

        // Store values before clearing
        const suggestion = this.storage.suggestion
        const position = this.storage.position

        // Clear suggestion first
        this.storage.clearSuggestion()

        // Force decoration update
        editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))

        // Insert the suggestion text
        editor.chain().focus().insertContentAt(position, suggestion).run()

        this.options.onAccept?.()
        return true
      },
      'Escape': ({ editor }) => {
        if (this.storage.visible) {
          this.storage.clearSuggestion()
          // Force decoration update
          editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
          this.options.onDismiss?.()
          return true
        }
        return false
      }
    }
  },

  onUpdate({ editor }) {
    // Clear suggestion on any edit
    if (this.storage.visible) {
      this.storage.clearSuggestion()
      // Force decoration update
      editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
      this.options.onDismiss?.()
    }

    // Debounced trigger check (only if autoTrigger is enabled)
    if (this.options.autoTrigger) {
      this.storage.debouncedTrigger?.(editor as Editor)
    }
  },

  onSelectionUpdate({ editor }) {
    if (this.storage.visible) {
      this.storage.clearSuggestion()
      // Force decoration update
      editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
      this.options.onDismiss?.()
    }
  },

  onCreate() {
    const storage = this.storage
    const options = this.options

    // Create debounced trigger function for this instance
    this.storage.debouncedTrigger = useDebounceFn((editor: Editor) => {
      if (!options.onTrigger) return

      const { state } = editor
      const { selection } = state
      const { $from } = selection

      // Only suggest at end of block with content
      const isAtEndOfBlock = $from.parentOffset === $from.parent.content.size
      const hasContent = $from.parent.textContent.trim().length > 0
      const textContent = $from.parent.textContent

      // Don't trigger if sentence is complete (ends with punctuation)
      const endsWithPunctuation = /[.!?]\s*$/.test(textContent)

      // Don't trigger if text ends with trigger characters
      const triggerChars = options.triggerCharacters || []
      const endsWithTrigger = triggerChars.some(char => textContent.endsWith(char))

      if (!isAtEndOfBlock || !hasContent || endsWithPunctuation || endsWithTrigger) {
        return
      }

      // Set position and mark as visible
      storage.position = selection.from
      storage.visible = true

      // Pass editor to let the handler extract content (e.g., as markdown)
      options.onTrigger(editor)
    }, options.debounce || 250)
  },

  onDestroy() {
    this.storage.debouncedTrigger = null
  }
})

export default Completion
  1. AI 완료 상태 및 처리기를 관리하는 컴포지블을 만듭니다.
useEditorCompletion.ts
import { useCompletion } from '@ai-sdk/vue'
import type { Editor } from '@tiptap/vue-3'
import { Completion } from './EditorCompletionExtension'
import type { CompletionStorage } from './EditorCompletionExtension'

type CompletionMode = 'continue' | 'fix' | 'extend' | 'reduce' | 'simplify' | 'summarize' | 'translate'

export interface UseEditorCompletionOptions {
  api?: string
}

export function useEditorCompletion(editorRef: Ref<{ editor: Editor | undefined } | null | undefined>, options: UseEditorCompletionOptions = {}) {
  // State for direct insertion/transform mode
  const insertState = ref<{
    pos: number
    deleteRange?: { from: number, to: number }
  }>()
  const mode = ref<CompletionMode>('continue')
  const language = ref<string>()

  // Helper to get completion storage
  function getCompletionStorage() {
    const storage = editorRef.value?.editor?.storage as Record<string, CompletionStorage> | undefined
    return storage?.completion
  }

  const { completion, complete, isLoading, stop, setCompletion } = useCompletion({
    api: options.api || '/api/completion',
    streamProtocol: 'text',
    body: computed(() => ({
      mode: mode.value,
      language: language.value
    })),
    onFinish: (_prompt, completionText) => {
      // For inline suggestion mode, don't clear - let user accept with Tab
      const storage = getCompletionStorage()
      if (mode.value === 'continue' && storage?.visible) {
        return
      }

      // For transform modes, insert the full completion with markdown parsing
      const transformModes = ['fix', 'extend', 'reduce', 'simplify', 'summarize', 'translate']
      if (transformModes.includes(mode.value) && insertState.value && completionText) {
        const editor = editorRef.value?.editor
        if (editor) {
          // Delete the original selection if not already done
          if (insertState.value.deleteRange) {
            editor.chain()
              .focus()
              .deleteRange(insertState.value.deleteRange)
              .run()
          }

          // Insert with markdown parsing
          editor.chain()
            .focus()
            .insertContentAt(insertState.value.pos, completionText, { contentType: 'markdown' })
            .run()
        }
      }

      insertState.value = undefined
    },
    onError: (error) => {
      console.error('AI completion error:', error)
      insertState.value = undefined
      getCompletionStorage()?.clearSuggestion()
    }
  })

  // Watch completion for inline suggestion updates
  watch(completion, (newCompletion, oldCompletion) => {
    const editor = editorRef.value?.editor
    if (!editor || !newCompletion) return

    const storage = getCompletionStorage()
    if (storage?.visible) {
      // Update inline suggestion
      // Add space prefix if needed (so preview matches what will be inserted)
      let suggestionText = newCompletion
      if (storage.position !== undefined) {
        const textBefore = editor.state.doc.textBetween(Math.max(0, storage.position - 1), storage.position)
        if (textBefore && !/\s/.test(textBefore) && !suggestionText.startsWith(' ')) {
          suggestionText = ' ' + suggestionText
        }
      }
      storage.setSuggestion(suggestionText)
      editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
    } else if (insertState.value) {
      // Direct insertion/transform mode (from toolbar actions)

      // Transform modes use markdown insertion - wait for full completion
      const transformModes = ['fix', 'extend', 'reduce', 'simplify', 'summarize', 'translate']
      if (transformModes.includes(mode.value)) {
        // Don't stream - will be handled in onFinish
        return
      }

      // If this is the first chunk and we have a selection to replace, delete it first
      if (insertState.value.deleteRange && !oldCompletion) {
        editor.chain()
          .focus()
          .deleteRange(insertState.value.deleteRange)
          .run()
        insertState.value.deleteRange = undefined
      }

      let delta = newCompletion.slice(oldCompletion?.length || 0)
      if (delta) {
        // For single-paragraph transforms, replace all line breaks with spaces
        if (['fix', 'simplify', 'translate'].includes(mode.value)) {
          delta = delta.replace(/[\r\n]+/g, ' ').replace(/\s{2,}/g, ' ')
        }

        // For "continue" mode, add a space before if needed (first chunk only)
        if (mode.value === 'continue' && !oldCompletion) {
          const textBefore = editor.state.doc.textBetween(Math.max(0, insertState.value.pos - 1), insertState.value.pos)
          if (textBefore && !/\s/.test(textBefore)) {
            delta = ' ' + delta
          }
        }

        editor.chain().focus().command(({ tr }) => {
          tr.insertText(delta, insertState.value!.pos)
          return true
        }).run()
        insertState.value.pos += delta.length
      }
    }
  })

  function triggerTransform(editor: Editor, transformMode: Exclude<CompletionMode, 'continue'>, lang?: string) {
    if (isLoading.value) return

    getCompletionStorage()?.clearSuggestion()

    const { state } = editor
    const { selection } = state

    if (selection.empty) return

    mode.value = transformMode
    language.value = lang
    const selectedText = state.doc.textBetween(selection.from, selection.to)

    // Replace the selected text with the transformed version
    insertState.value = { pos: selection.from, deleteRange: { from: selection.from, to: selection.to } }

    complete(selectedText)
  }

  function getMarkdownBefore(editor: Editor, pos: number): string {
    const { state } = editor
    const serializer = (editor.storage.markdown as { serializer?: { serialize: (content: unknown) => string } })?.serializer
    if (serializer) {
      const slice = state.doc.slice(0, pos)
      return serializer.serialize(slice.content)
    }
    // Fallback to plain text
    return state.doc.textBetween(0, pos, '\n')
  }

  function triggerContinue(editor: Editor) {
    if (isLoading.value) return

    mode.value = 'continue'
    getCompletionStorage()?.clearSuggestion()
    const { state } = editor
    const { selection } = state

    if (selection.empty) {
      // No selection: continue from cursor position
      const textBefore = getMarkdownBefore(editor, selection.from)
      insertState.value = { pos: selection.from }
      complete(textBefore)
    } else {
      // Text selected: append completion after the selection
      const textBefore = getMarkdownBefore(editor, selection.to)
      insertState.value = { pos: selection.to }
      complete(textBefore)
    }
  }

  // Configure Completion extension
  const extension = Completion.configure({
    onTrigger: (editor) => {
      if (isLoading.value) return
      mode.value = 'continue'
      const textBefore = getMarkdownBefore(editor, editor.state.selection.from)
      complete(textBefore)
    },
    onAccept: () => {
      setCompletion('')
    },
    onDismiss: () => {
      stop()
      setCompletion('')
    }
  })

  // Create handlers for toolbar
  const handlers = {
    aiContinue: {
      canExecute: () => !isLoading.value,
      execute: (editor: Editor) => {
        triggerContinue(editor)
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'continue'),
      isDisabled: () => !!isLoading.value
    },
    aiFix: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'fix')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'fix'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiExtend: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'extend')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'extend'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiReduce: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'reduce')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'reduce'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiSimplify: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'simplify')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'simplify'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiSummarize: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'summarize')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'summarize'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiTranslate: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor, cmd: { language?: string } | undefined) => {
        triggerTransform(editor, 'translate', cmd?.language)
        return editor.chain()
      },
      isActive: (_editor: Editor, cmd: { language?: string } | undefined) => !!(isLoading.value && mode.value === 'translate' && language.value === cmd?.language),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    }
  }

  return {
    extension,
    handlers,
    isLoading,
    mode
  }
}
  1. xstreamText를 사용하여 완료 요청을 처리하는 서버 API 엔드포인트를 만듭니다.
server/api/completion.post.ts
import { streamText, createTextStreamResponse } from 'ai'
import { gateway } from '@ai-sdk/gateway'

export default defineEventHandler(async (event) => {
  const { prompt, mode, language } = await readBody(event)
  if (!prompt) {
    throw createError({ statusCode: 400, message: 'Prompt is required' })
  }

  let instructions: string
  let maxOutputTokens: number

  const preserveMarkdown = 'IMPORTANT: Preserve all markdown formatting (bold, italic, links, etc.) exactly as in the original.'

  switch (mode) {
    case 'fix':
      instructions = `You are a writing assistant. Fix all spelling and grammar errors in the given text. ${preserveMarkdown} Only output the corrected text, nothing else.`
      maxOutputTokens = 500
      break
    case 'extend':
      instructions = `You are a writing assistant. Extend the given text with more details, examples, and explanations while maintaining the same style. ${preserveMarkdown} Only output the extended text, nothing else.`
      maxOutputTokens = 500
      break
    case 'reduce':
      instructions = `You are a writing assistant. Make the given text more concise by removing unnecessary words while keeping the meaning. ${preserveMarkdown} Only output the reduced text, nothing else.`
      maxOutputTokens = 300
      break
    case 'simplify':
      instructions = `You are a writing assistant. Simplify the given text to make it easier to understand, using simpler words and shorter sentences. ${preserveMarkdown} Only output the simplified text, nothing else.`
      maxOutputTokens = 400
      break
    case 'summarize':
      instructions = 'You are a writing assistant. Summarize the given text concisely while keeping the key points. Only output the summary, nothing else.'
      maxOutputTokens = 200
      break
    case 'translate':
      instructions = `You are a writing assistant. Translate the given text to ${language || 'English'}. ${preserveMarkdown} Only output the translated text, nothing else.`
      maxOutputTokens = 500
      break
    case 'continue':
    default:
      instructions = `You are a writing assistant providing inline autocompletions.
CRITICAL RULES:
- Output ONLY the NEW text that comes AFTER the user's input
- NEVER repeat any words from the end of the user's text
- Keep completions short (1 sentence max)
- Match the tone and style of the existing text
- ${preserveMarkdown}`
      maxOutputTokens = 25
      break
  }

  const result = streamText({
    model: gateway('anthropic/claude-haiku-4.5'),
    instructions,
    prompt,
    maxOutputTokens
  })

  return createTextStreamResponse({ stream: result.textStream })
})
  1. Editor에서 작성 가능한 항목을 사용합니다.
<script setup lang="ts">
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
import { useEditorCompletion } from './EditorUseCompletion'

const editorRef = useTemplateRef('editorRef')

const value = ref(`# AI Completion

This editor demonstrates how to add AI-powered features using the [Vercel AI SDK](https://ai-sdk.dev/). It includes ghost text autocompletion that appears as you type (press Tab to accept) and text transformation actions.

Try selecting some text and using the AI dropdown to fix grammar, extend, or simplify it.`)

const {
  extension: completionExtension,
  handlers: aiHandlers,
  isLoading: aiLoading
} = useEditorCompletion(editorRef)

const customHandlers = {
  ...aiHandlers
} satisfies EditorCustomHandlers

const items = computed(
  () =>
    [
      [
        {
          icon: 'i-lucide-sparkles',
          label: 'Improve',
          variant: 'soft',
          loading: aiLoading.value,
          content: {
            align: 'start'
          },
          items: [
            {
              kind: 'aiFix',
              icon: 'i-lucide-spell-check',
              label: 'Fix spelling & grammar'
            },
            {
              kind: 'aiExtend',
              icon: 'i-lucide-unfold-vertical',
              label: 'Extend text'
            },
            {
              kind: 'aiReduce',
              icon: 'i-lucide-fold-vertical',
              label: 'Reduce text'
            },
            {
              kind: 'aiSimplify',
              icon: 'i-lucide-lightbulb',
              label: 'Simplify text'
            },
            {
              kind: 'aiContinue',
              icon: 'i-lucide-text',
              label: 'Continue sentence'
            },
            {
              kind: 'aiSummarize',
              icon: 'i-lucide-list',
              label: 'Summarize'
            },
            {
              icon: 'i-lucide-languages',
              label: 'Translate',
              children: [
                {
                  kind: 'aiTranslate',
                  language: 'English',
                  label: 'English'
                },
                {
                  kind: 'aiTranslate',
                  language: 'French',
                  label: 'French'
                },
                {
                  kind: 'aiTranslate',
                  language: 'Spanish',
                  label: 'Spanish'
                },
                {
                  kind: 'aiTranslate',
                  language: 'German',
                  label: 'German'
                }
              ]
            }
          ]
        }
      ],
      [
        {
          icon: 'i-lucide-heading',
          content: {
            align: 'start'
          },
          items: [
            {
              kind: 'heading',
              level: 1,
              icon: 'i-lucide-heading-1',
              label: 'Heading 1'
            },
            {
              kind: 'heading',
              level: 2,
              icon: 'i-lucide-heading-2',
              label: 'Heading 2'
            },
            {
              kind: 'heading',
              level: 3,
              icon: 'i-lucide-heading-3',
              label: 'Heading 3'
            }
          ]
        }
      ],
      [
        {
          kind: 'mark',
          mark: 'bold',
          icon: 'i-lucide-bold'
        },
        {
          kind: 'mark',
          mark: 'italic',
          icon: 'i-lucide-italic'
        },
        {
          kind: 'mark',
          mark: 'underline',
          icon: 'i-lucide-underline'
        }
      ]
    ] satisfies EditorToolbarItem<typeof customHandlers>[][]
)
</script>

<template>
  <UEditor
    ref="editorRef"
    v-slot="{ editor }"
    v-model="value"
    :extensions="[completionExtension]"
    :handlers="customHandlers"
    content-type="markdown"
    :ui="{ base: 'p-8 sm:px-16' }"
    class="w-full min-h-74"
  >
    <UEditorToolbar
      :editor="editor"
      :items="items"
      class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto"
    />
  </UEditor>
</template>
<script setup lang="ts">
import { ref, computed, useTemplateRef } from 'vue'
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
import { useEditorCompletion } from './EditorUseCompletion'

const editorRef = useTemplateRef('editorRef')

const value = ref(`# AI Completion

This editor demonstrates how to add AI-powered features using the [Vercel AI SDK](https://ai-sdk.dev/). It includes ghost text autocompletion that appears as you type (press Tab to accept) and text transformation actions.

Try selecting some text and using the AI dropdown to fix grammar, extend, or simplify it.`)

const {
  extension: completionExtension,
  handlers: aiHandlers,
  isLoading: aiLoading
} = useEditorCompletion(editorRef)

const customHandlers = {
  ...aiHandlers
} satisfies EditorCustomHandlers

const items = computed(
  () =>
    [
      [
        {
          icon: 'i-lucide-sparkles',
          label: 'Improve',
          variant: 'soft',
          loading: aiLoading.value,
          content: {
            align: 'start'
          },
          items: [
            {
              kind: 'aiFix',
              icon: 'i-lucide-spell-check',
              label: 'Fix spelling & grammar'
            },
            {
              kind: 'aiExtend',
              icon: 'i-lucide-unfold-vertical',
              label: 'Extend text'
            },
            {
              kind: 'aiReduce',
              icon: 'i-lucide-fold-vertical',
              label: 'Reduce text'
            },
            {
              kind: 'aiSimplify',
              icon: 'i-lucide-lightbulb',
              label: 'Simplify text'
            },
            {
              kind: 'aiContinue',
              icon: 'i-lucide-text',
              label: 'Continue sentence'
            },
            {
              kind: 'aiSummarize',
              icon: 'i-lucide-list',
              label: 'Summarize'
            },
            {
              icon: 'i-lucide-languages',
              label: 'Translate',
              children: [
                {
                  kind: 'aiTranslate',
                  language: 'English',
                  label: 'English'
                },
                {
                  kind: 'aiTranslate',
                  language: 'French',
                  label: 'French'
                },
                {
                  kind: 'aiTranslate',
                  language: 'Spanish',
                  label: 'Spanish'
                },
                {
                  kind: 'aiTranslate',
                  language: 'German',
                  label: 'German'
                }
              ]
            }
          ]
        }
      ],
      [
        {
          icon: 'i-lucide-heading',
          content: {
            align: 'start'
          },
          items: [
            {
              kind: 'heading',
              level: 1,
              icon: 'i-lucide-heading-1',
              label: 'Heading 1'
            },
            {
              kind: 'heading',
              level: 2,
              icon: 'i-lucide-heading-2',
              label: 'Heading 2'
            },
            {
              kind: 'heading',
              level: 3,
              icon: 'i-lucide-heading-3',
              label: 'Heading 3'
            }
          ]
        }
      ],
      [
        {
          kind: 'mark',
          mark: 'bold',
          icon: 'i-lucide-bold'
        },
        {
          kind: 'mark',
          mark: 'italic',
          icon: 'i-lucide-italic'
        },
        {
          kind: 'mark',
          mark: 'underline',
          icon: 'i-lucide-underline'
        }
      ]
    ] satisfies EditorToolbarItem<typeof customHandlers>[][]
)
</script>

<template>
  <UEditor
    ref="editorRef"
    v-slot="{ editor }"
    v-model="value"
    :extensions="[completionExtension]"
    :handlers="customHandlers"
    content-type="markdown"
    :ui="{ base: 'p-8 sm:px-16' }"
    class="w-full min-h-74"
  >
    <UEditorToolbar
      :editor="editor"
      :items="items"
      class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto"
    />
  </UEditor>
</template>
완료 확장 기능은 autoTrigger: true를 사용하여 입력하는 동안 자동으로 완료를 제안하도록 구성할 수 있습니다(기본적으로 비활성화됨). kbd{value="meta"}:kbd{value="j" class="ms-px"}로 수동으로 트리거할 수도 있습니다.
Vercel AI SDK 및 사용 가능한 공급자에 대해 자세히 알아보세요.

API

Props (### Props)

Prop Default Type
as
'div'
any

The element or component this component should render as.

modelValue
null | string | JSONContent | JSONContent[]
contentType
"markdown" | "json" | "html"

The content type the content is provided as. When not specified, it's automatically inferred: strings are treated as 'html', objects as 'json'.

starterKit
true
boolean | Partial<StarterKitOptions>

The starter kit options to configure the editor. Set to false for a plain-text editor: keeps the essential nodes (paragraph, text, history) and disables all rich-text formatting.

placeholder
{ showOnlyWhenEditable: false, showOnlyCurrent: true, mode: 'everyLine' }
string | Partial<PlaceholderOptions> & { mode?: "firstLine" | "everyLine" | undefined; }

The placeholder text to show in empty paragraphs. Can be a string or PlaceholderOptions from @tiptap/extension-placeholder.

markdown
{ markedOptions: { gfm: true } }
Partial<MarkdownExtensionOptions>

The markdown extension options to configure markdown parsing and serialization.

image
true
boolean | Partial<ImageOptions>

The image extension options to configure image handling. Set to false to disable the extension.

mention
true
boolean | Partial<Omit<MentionOptions<any, MentionNodeAttrs>, "suggestion" | "suggestions">>

The mention extension options to configure mention handling. Set to false to disable the extension. The suggestion and suggestions options are omitted as they are managed by the EditorMentionMenu component.

handlers
H

Custom item handlers to override or extend the default handlers. These handlers are provided to all child components (toolbar, suggestion menu, etc.).

extensions
Extensions

The extensions to use

injectCSS
boolean

Whether to inject base CSS styles

injectNonce
string

A nonce to use for CSP while injecting styles

autofocus
null | number | false | true | "start" | "end" | "all"

The editor's initial focus position

editable
boolean

Whether the editor is editable

textDirection
"ltr" | "rtl" | "auto"

The default text direction for all content in the editor. When set to 'ltr' or 'rtl', all nodes will have the corresponding dir attribute. When set to 'auto', the dir attribute will be set based on content detection. When undefined, no dir attribute will be added.

editorProps
EditorProps<any>

The editor's props

parseOptions
ParseOptions
coreExtensionOptions
{ clipboardTextSerializer?: { blockSeparator?: string | undefined; } | undefined; tabindex?: { value?: string | undefined; } | undefined; delete?: { async?: boolean | undefined; filterTransaction?: ((transaction: Transaction) => boolean) | undefined; } | undefined; }

The editor's core extension options

enableInputRules
false | true | (string | AnyExtension)[]

Whether to enable input rules behavior

enablePasteRules
false | true | (string | AnyExtension)[]

Whether to enable paste rules behavior

enableCoreExtensions
boolean | Partial<Record<"editable" | "textDirection" | "clipboardTextSerializer" | "commands" | "focusEvents" | "keymap" | "tabindex" | "drop" | "paste" | "delete", false>>

Determines whether core extensions are enabled.

If set to false, all core extensions will be disabled. To disable specific core extensions, provide an object where the keys are the extension names and the values are false. Extensions not listed in the object will remain enabled.

enableContentCheck
boolean

If true, the editor will check the content for errors on initialization. Emitting the contentError event if the content is invalid. Which can be used to show a warning or error message to the user.

emitContentError
boolean

If true, the editor will emit the contentError event if invalid content is encountered but enableContentCheck is false. This lets you preserve the invalid editor content while still showing a warning or error message to the user.

onBeforeCreate
(props: { editor: Editor; }): void

Called before the editor is constructed.

onCreate
(props: { editor: Editor; }): void

Called after the editor is constructed.

onMount
(props: { editor: Editor; }): void

Called when the editor is mounted.

onUnmount
(props: { editor: Editor; }): void

Called when the editor is unmounted.

onContentError
(props: { editor: Editor; error: Error; disableCollaboration: () => void; }): void

Called when the editor encounters an error while parsing the content. Only enabled if enableContentCheck is true.

onUpdate
(props: { editor: Editor; transaction: Transaction; appendedTransactions: Transaction[]; }): void

Called when the editor's content is updated.

onSelectionUpdate
(props: { editor: Editor; transaction: Transaction; }): void

Called when the editor's selection is updated.

onTransaction
(props: { editor: Editor; transaction: Transaction; appendedTransactions: Transaction[]; }): void

Called after a transaction is applied to the editor.

onFocus
(props: { editor: Editor; event: FocusEvent; transaction: Transaction; }): void

Called on focus events.

onBlur
(props: { editor: Editor; event: FocusEvent; transaction: Transaction; }): void

Called on blur events.

onDestroy
(props: void): void

Called when the editor is destroyed.

onPaste
(e: ClipboardEvent, slice: Slice): void

Called when content is pasted into the editor.

onDrop
(e: DragEvent, slice: Slice, moved: boolean): void

Called when content is dropped into the editor.

onDelete
(props: { editor: Editor; deletedRange: Range; newRange: Range; transaction: Transaction; combinedTransform: Transform; partial: boolean; from: number; to: number; } & ({ ...; } | { ...; })): void

Called when content is deleted from the editor.

enableExtensionDispatchTransaction
boolean

Whether to enable extension-level dispatching of transactions. If false, extensions cannot define their own dispatchTransaction hook.

ui
{ root?: SlotClass; content?: SlotClass; base?: SlotClass; }

Slots 슬롯

Slot Type
default
{ editor: Editor; handlers: EditorHandlers<H>; }

Emits

Event Type
update:modelValue
[value: T]

노출

템플릿 참조를 통해 컴포넌트에 액세스하는 경우 다음을 사용할 수 있습니다.

이름 (Name)유형 (Type)
editor (editor)Ref<Editor | undefined> (Ref<Editor | undefined>)
노출된 편집기 인스턴스는 TipTap Editor API입니다. 사용 가능한 모든 메소드와 속성은 TipTap 설명서를 참조하십시오.

Theme 테마

app.config.ts
export default defineAppConfig({
  ui: {
    editor: {
      slots: {
        root: '',
        content: 'relative size-full flex-1',
        base: [
          'w-full outline-none *:my-5 *:first:mt-0 *:last:mb-0 sm:px-8 selection:bg-primary/20',
          '[&_p]:leading-7',
          '[&_a]:text-primary [&_a]:border-b [&_a]:border-transparent [&_a]:hover:border-primary [&_a]:font-medium',
          '[&_a]:transition-colors',
          '[&_a>code]:border-dashed [&_a:hover>code]:border-primary [&_a:hover>code]:text-primary',
          '[&_a>code]:transition-colors',
          '[&_.mention]:text-primary [&_.mention]:font-medium',
          '[&_:is(h1,h2,h3,h4,h5,h6)]:text-highlighted [&_:is(h1,h2,h3,h4,h5,h6)]:font-bold',
          '[&_h1]:text-3xl',
          '[&_h2]:text-2xl',
          '[&_h3]:text-xl',
          '[&_h4]:text-lg',
          '[&_h5]:text-base',
          '[&_h6]:text-base',
          '[&_:is(h1,h2,h3,h4,h5,h6)>code]:border-dashed [&_:is(h1,h2,h3,h4,h5,h6)>code]:font-bold',
          '[&_h2>code]:text-xl/6',
          '[&_h3>code]:text-lg/5',
          '[&_blockquote]:border-s-4 [&_blockquote]:border-accented [&_blockquote]:ps-4 [&_blockquote]:italic',
          '[&_[data-type=horizontalRule]]:my-8 [&_[data-type=horizontalRule]]:py-2',
          '[&_hr]:border-t [&_hr]:border-default',
          '[&_pre]:text-sm/6 [&_pre]:border [&_pre]:border-muted [&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:px-4 [&_pre]:py-3 [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:overflow-x-auto',
          '[&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:font-inherit [&_pre_code]:rounded-none [&_pre_code]:inline [&_pre_code]:border-0 [&_pre_code]:bg-transparent',
          '[&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-sm [&_code]:font-mono [&_code]:font-medium [&_code]:rounded-md [&_code]:inline-block [&_code]:border [&_code]:border-muted [&_code]:text-highlighted [&_code]:bg-muted',
          '[&_:is(ul,ol)]:ps-6',
          '[&_ul]:list-disc [&_ul]:marker:text-(--ui-border-accented)',
          '[&_ol]:list-decimal [&_ol]:marker:text-muted',
          '[&_li]:my-1.5 [&_li]:ps-1.5',
          '[&_img]:rounded-md [&_img]:block [&_img]:max-w-full [&_img.ProseMirror-selectednode]:outline-2 [&_img.ProseMirror-selectednode]:outline-primary',
          '[&_.ProseMirror-selectednode:not(img):not(pre):not([data-node-view-wrapper])]:bg-primary/20'
        ]
      },
      variants: {
        placeholderMode: {
          firstLine: {
            base: '[&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:text-dimmed [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:float-start [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:h-0 [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:pointer-events-none'
          },
          everyLine: {
            base: '[&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:text-dimmed [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:float-start [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:h-0 [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:pointer-events-none'
          }
        }
      },
      defaultVariants: {
        placeholderMode: 'everyLine'
      }
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        editor: {
          slots: {
            root: '',
            content: 'relative size-full flex-1',
            base: [
              'w-full outline-none *:my-5 *:first:mt-0 *:last:mb-0 sm:px-8 selection:bg-primary/20',
              '[&_p]:leading-7',
              '[&_a]:text-primary [&_a]:border-b [&_a]:border-transparent [&_a]:hover:border-primary [&_a]:font-medium',
              '[&_a]:transition-colors',
              '[&_a>code]:border-dashed [&_a:hover>code]:border-primary [&_a:hover>code]:text-primary',
              '[&_a>code]:transition-colors',
              '[&_.mention]:text-primary [&_.mention]:font-medium',
              '[&_:is(h1,h2,h3,h4,h5,h6)]:text-highlighted [&_:is(h1,h2,h3,h4,h5,h6)]:font-bold',
              '[&_h1]:text-3xl',
              '[&_h2]:text-2xl',
              '[&_h3]:text-xl',
              '[&_h4]:text-lg',
              '[&_h5]:text-base',
              '[&_h6]:text-base',
              '[&_:is(h1,h2,h3,h4,h5,h6)>code]:border-dashed [&_:is(h1,h2,h3,h4,h5,h6)>code]:font-bold',
              '[&_h2>code]:text-xl/6',
              '[&_h3>code]:text-lg/5',
              '[&_blockquote]:border-s-4 [&_blockquote]:border-accented [&_blockquote]:ps-4 [&_blockquote]:italic',
              '[&_[data-type=horizontalRule]]:my-8 [&_[data-type=horizontalRule]]:py-2',
              '[&_hr]:border-t [&_hr]:border-default',
              '[&_pre]:text-sm/6 [&_pre]:border [&_pre]:border-muted [&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:px-4 [&_pre]:py-3 [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:overflow-x-auto',
              '[&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:font-inherit [&_pre_code]:rounded-none [&_pre_code]:inline [&_pre_code]:border-0 [&_pre_code]:bg-transparent',
              '[&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-sm [&_code]:font-mono [&_code]:font-medium [&_code]:rounded-md [&_code]:inline-block [&_code]:border [&_code]:border-muted [&_code]:text-highlighted [&_code]:bg-muted',
              '[&_:is(ul,ol)]:ps-6',
              '[&_ul]:list-disc [&_ul]:marker:text-(--ui-border-accented)',
              '[&_ol]:list-decimal [&_ol]:marker:text-muted',
              '[&_li]:my-1.5 [&_li]:ps-1.5',
              '[&_img]:rounded-md [&_img]:block [&_img]:max-w-full [&_img.ProseMirror-selectednode]:outline-2 [&_img.ProseMirror-selectednode]:outline-primary',
              '[&_.ProseMirror-selectednode:not(img):not(pre):not([data-node-view-wrapper])]:bg-primary/20'
            ]
          },
          variants: {
            placeholderMode: {
              firstLine: {
                base: '[&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:text-dimmed [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:float-start [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:h-0 [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:pointer-events-none'
              },
              everyLine: {
                base: '[&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:text-dimmed [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:float-start [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:h-0 [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:pointer-events-none'
              }
            }
          },
          defaultVariants: {
            placeholderMode: 'everyLine'
          }
        }
      }
    })
  ]
})

변경 로그

No recent changes