diff --git a/docs/app/pages/blog/index.vue b/docs/app/pages/blog/index.vue index 1065568178..059869eaaa 100644 --- a/docs/app/pages/blog/index.vue +++ b/docs/app/pages/blog/index.vue @@ -7,7 +7,7 @@ if (!page.value) { } const { data: posts } = await useAsyncData('blog-posts', () => - queryCollection('posts').order('date', 'DESC').all() + queryCollection('posts').order('date', 'ASC').all() ) useSeoMeta({ diff --git a/docs/content/blog/how-to-build-an-ai-chat.md b/docs/content/blog/how-to-build-an-ai-chat.md index 95ed6b293b..dec3224fdc 100644 --- a/docs/content/blog/how-to-build-an-ai-chat.md +++ b/docs/content/blog/how-to-build-an-ai-chat.md @@ -1,5 +1,5 @@ --- -title: Build an AI Chatbot with Nuxt, Nuxt UI, and AI SDK +title: Build an AI Chatbot with Nuxt UI and AI SDK description: Learn how to build a full-featured AI chatbot with streaming responses, multiple models support, and a beautiful UI using Nuxt, Nuxt UI, and Vercel AI SDK. navigation: false image: /assets/blog/building-nuxt-ai-chatbot.png diff --git a/docs/content/blog/how-to-build-an-editor.md b/docs/content/blog/how-to-build-an-editor.md new file mode 100644 index 0000000000..82533617e5 --- /dev/null +++ b/docs/content/blog/how-to-build-an-editor.md @@ -0,0 +1,2707 @@ +--- +title: Build a Notion-like Editor with Nuxt UI and TipTap +description: Learn how to build a Notion-like WYSIWYG editor with AI-powered completions, slash commands, mentions, tables, task lists, drag-and-drop, and syntax-highlighted code blocks using Nuxt UI and TipTap. +navigation: false +image: /assets/blog/building-nuxt-editor.png +authors: + - name: Benjamin Canac + avatar: + src: https://github.com/benjamincanac.png + to: https://x.com/benjamincanac +date: 2025-01-08T10:00:00.000Z +category: Tutorial +--- + +Building a Notion-like editor has traditionally been one of the most complex frontend challenges. This guide walks through creating a full-featured WYSIWYG editor using Nuxt UI's purpose-built components powered by [TipTap](https://tiptap.dev). Each step is explained in detail so you understand how every piece works together. + +## What we're building + +By the end of this tutorial, you'll have a Notion-like editor with: + +- **Markdown support** for seamless content authoring +- **Fixed and bubble toolbars** for formatting actions +- **Image toolbar** that appears when selecting images +- **Table toolbar** with row/column controls +- **Slash commands** for quick block insertions (type `/`) +- **@ mentions** for tagging users +- **Emoji picker** with GitHub emoji support (type `:`) +- **Drag-and-drop** block reordering +- **Tables** with full spreadsheet-like functionality +- **Task lists** with interactive checkboxes +- **Syntax-highlighted code blocks** powered by Shiki +- **Image upload** with custom TipTap extension +- **AI completion** with ghost text suggestions and text transformations + +::callout{icon="i-simple-icons-github" to="https://github.com/nuxt-ui-templates/editor" target="_blank"} +Check out the complete **Editor template** on GitHub for a production-ready implementation with real-time collaboration. +:: + +## Prerequisites + +Before we start, make sure you have: + +- Node.js 20+ installed +- Basic familiarity with Vue and Nuxt + +## Project setup + +Start by creating a new Nuxt project: + +```bash +npx nuxi@latest init nuxt-editor +cd nuxt-editor +``` + +### Installing dependencies + +Install Nuxt UI: + +::code-group{sync="pm"} +```bash [pnpm] +pnpm add @nuxt/ui tailwindcss +``` + +```bash [yarn] +yarn add @nuxt/ui tailwindcss +``` + +```bash [npm] +npm install @nuxt/ui tailwindcss +``` + +```bash [bun] +bun add @nuxt/ui tailwindcss +``` +:: + +### Configuration + +Update your `nuxt.config.ts` to register the modules: + +::code-tree-intersection +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['@nuxt/ui'], + + css: ['~/assets/css/main.css'] +}) +``` +:: + +Create the main CSS file to import Tailwind CSS and Nuxt UI: + +::code-tree-intersection +```css [app/assets/css/main.css] +@import "tailwindcss"; +@import "@nuxt/ui"; +``` +:: + +### Setting up the app + +Nuxt UI requires wrapping your app with `UApp` for overlays to work properly: + +::code-tree-intersection +```vue [app/app.vue] + +``` +:: + +## Building the basic editor + +Let's start with a simple editor page that uses markdown content: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +The `UEditor` component provides a powerful editing experience out of the box. The `content-type` prop tells it to work with markdown, and the `v-model` handles two-way binding with your content. + +::tip +If you encounter ProseMirror-related errors such as `Adding different instances of a keyed plugin`, add the following to your `nuxt.config.ts`: + +```ts +export default defineNuxtConfig({ + vite: { + optimizeDeps: { + include: [ + 'prosemirror-state', + 'prosemirror-transform', + 'prosemirror-model', + 'prosemirror-view' + ] + } + } +}) +``` + +This ensures Vite pre-bundles ProseMirror dependencies to avoid loading multiple instances. +:: + +## Adding the fixed toolbar + +A toolbar provides quick access to formatting actions. Let's add a fixed toolbar at the top of the editor with common formatting options. + +First, create a composable to define the toolbar items: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useFixedToolbarItems.ts] +import type { EditorToolbarItem } from '@nuxt/ui' + +export function useFixedToolbarItems() { + const fixedToolbarItems: EditorToolbarItem[][] = [[{ + kind: 'undo', + icon: 'i-lucide-undo', + tooltip: { text: 'Undo' } + }, { + kind: 'redo', + icon: 'i-lucide-redo', + tooltip: { text: 'Redo' } + }], [{ + icon: 'i-lucide-heading', + tooltip: { text: 'Headings' }, + 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' + }] + }, { + icon: 'i-lucide-list', + tooltip: { text: 'Lists' }, + items: [{ + kind: 'bulletList', + icon: 'i-lucide-list', + label: 'Bullet List' + }, { + kind: 'orderedList', + icon: 'i-lucide-list-ordered', + label: 'Ordered List' + }] + }, { + kind: 'blockquote', + icon: 'i-lucide-text-quote', + tooltip: { text: 'Blockquote' } + }, { + kind: 'codeBlock', + icon: 'i-lucide-square-code', + tooltip: { text: 'Code Block' } + }], [{ + 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' } + }], [{ + slot: 'link', + icon: 'i-lucide-link' + }, { + kind: 'image', + icon: 'i-lucide-image', + tooltip: { text: 'Image' } + }]] + + return { fixedToolbarItems } +} +``` + +::: +:: + +Now update the page to use the composable: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +**Toolbar Item Structure** + +Each item can have a `kind` property that references a built-in handler (like `mark`, `heading`, `bulletList`), an `icon`, a `tooltip`, and optional nested `items` for dropdown menus. + +**Grouped Items** + +By passing an array of arrays, items are visually separated into groups. This helps organize related actions together. + +### Adding a link popover + +For a better link editing experience, let's create a custom popover component instead of using the browser's prompt: + +::code-tree-intersection +:::code-collapse + +```vue [app/components/EditorLinkPopover.vue] + + + +``` + +::: +:: + +Now update the page to use the custom link popover slot (we already defined `slot: 'link'` in the toolbar items): + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +## Adding the bubble toolbar + +A bubble toolbar appears when text is selected, providing quick access to formatting options without leaving the content area. + +Create a composable for the bubble toolbar items: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useBubbleToolbarItems.ts] +import type { EditorToolbarItem } from '@nuxt/ui' + +export function useBubbleToolbarItems() { + const bubbleToolbarItems: EditorToolbarItem[][] = [[{ + label: 'Turn into', + trailingIcon: 'i-lucide-chevron-down', + activeColor: 'neutral', + activeVariant: 'ghost', + tooltip: { text: 'Turn into' }, + content: { align: 'start' }, + ui: { label: 'text-xs' }, + items: [{ + type: 'label', + label: 'Turn into' + }, { + kind: 'paragraph', + label: 'Paragraph', + icon: 'i-lucide-type' + }, { + 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: 'bulletList', + icon: 'i-lucide-list', + label: 'Bullet List' + }, { + kind: 'orderedList', + icon: 'i-lucide-list-ordered', + label: 'Ordered List' + }, { + kind: 'blockquote', + icon: 'i-lucide-text-quote', + label: 'Blockquote' + }, { + kind: 'codeBlock', + icon: 'i-lucide-square-code', + label: 'Code Block' + }] + }], [{ + 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' } + }], [{ + slot: 'link', + icon: 'i-lucide-link' + }]] + + return { bubbleToolbarItems } +} +``` + +::: +:: + +Update the page to add the bubble toolbar: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +The `should-show` prop controls when the bubble toolbar appears. In this case, it shows when text is selected and the view has focus, but not when an image is selected. + +## Adding the image toolbar + +When working with images, you can add a context-specific bubble toolbar that appears only when an image is selected. + +Create a composable for the image toolbar items: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useImageToolbarItems.ts] +import type { EditorToolbarItem } from '@nuxt/ui' +import type { Editor } from '@tiptap/vue-3' + +export function useImageToolbarItems() { + const imageToolbarItems = (editor: Editor): EditorToolbarItem[][] => { + const node = editor.state.doc.nodeAt(editor.state.selection.from) + + return [[{ + icon: 'i-lucide-download', + to: node?.attrs?.src, + download: true, + tooltip: { text: 'Download' } + }, { + icon: 'i-lucide-refresh-cw', + tooltip: { text: 'Replace' }, + onClick: () => { + const { state } = editor + const pos = state.selection.from + const currentNode = state.doc.nodeAt(pos) + + if (currentNode && currentNode.type.name === 'image') { + editor.chain().focus().deleteRange({ from: pos, to: pos + currentNode.nodeSize }).insertContentAt(pos, { type: 'imageUpload' }).run() + } + } + }], [{ + icon: 'i-lucide-trash', + tooltip: { text: 'Delete' }, + onClick: () => { + const { state } = editor + const pos = state.selection.from + const currentNode = state.doc.nodeAt(pos) + + if (currentNode && currentNode.type.name === 'image') { + editor.chain().focus().deleteRange({ from: pos, to: pos + currentNode.nodeSize }).run() + } + } + }]] + } + + return { imageToolbarItems } +} +``` + +::: +:: + +Update the page to add the image toolbar: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +This creates a contextual toolbar that only appears when an image is selected, providing actions to download, replace, or delete the image. + +## Adding tables + +Tables bring spreadsheet-like functionality to your editor with row/column controls and cell selection. + +First, install the Table extension: + +::code-group{sync="pm"} +```bash [pnpm] +pnpm add @tiptap/extension-table +``` + +```bash [yarn] +yarn add @tiptap/extension-table +``` + +```bash [npm] +npm install @tiptap/extension-table +``` + +```bash [bun] +bun add @tiptap/extension-table +``` +:: + +Create a composable for the table toolbar items: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useTableToolbarItems.ts] +import type { EditorToolbarItem } from '@nuxt/ui' +import type { Editor } from '@tiptap/vue-3' + +export function useTableToolbarItems() { + const tableToolbarItems = (editor: Editor): EditorToolbarItem[][] => [[{ + icon: 'i-lucide-plus', + tooltip: { text: 'Add column after' }, + onClick: () => editor.chain().focus().addColumnAfter().run() + }, { + icon: 'i-lucide-minus', + tooltip: { text: 'Delete column' }, + onClick: () => editor.chain().focus().deleteColumn().run() + }], [{ + icon: 'i-lucide-plus', + tooltip: { text: 'Add row after' }, + onClick: () => editor.chain().focus().addRowAfter().run() + }, { + icon: 'i-lucide-minus', + tooltip: { text: 'Delete row' }, + onClick: () => editor.chain().focus().deleteRow().run() + }], [{ + icon: 'i-lucide-trash', + tooltip: { text: 'Delete table' }, + onClick: () => editor.chain().focus().deleteTable().run() + }]] + + return { tableToolbarItems } +} +``` + +::: +:: + +Update the page to add table support: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +You can also add a table insertion button to your fixed toolbar or slash commands: + +```ts +// Add to fixedToolbarItems or suggestionItems +{ + icon: 'i-lucide-table', + label: 'Table', + onClick: () => editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run() +} +``` + +## Adding task lists + +Task lists provide interactive checklists, perfect for to-do lists and project management. + +Install the TaskList and TaskItem extensions: + +::code-group{sync="pm"} +```bash [pnpm] +pnpm add @tiptap/extension-task-list @tiptap/extension-task-item +``` + +```bash [yarn] +yarn add @tiptap/extension-task-list @tiptap/extension-task-item +``` + +```bash [npm] +npm install @tiptap/extension-task-list @tiptap/extension-task-item +``` + +```bash [bun] +bun add @tiptap/extension-task-list @tiptap/extension-task-item +``` +:: + +Add the extensions and update your toolbar/suggestion items: + +```ts +import { TaskList } from '@tiptap/extension-task-list' +import { TaskItem } from '@tiptap/extension-task-item' + +// Add to extensions +const extensions = [ + TaskList, + TaskItem.configure({ nested: true }) +] + +// Add to toolbar or suggestion items +{ + kind: 'taskList', + icon: 'i-lucide-list-checks', + label: 'Task List' +} +``` + +## Adding syntax-highlighted code blocks + +For beautiful syntax highlighting in code blocks, use the CodeBlockShiki extension powered by [Shiki](https://shiki.style). + +Install the extension: + +::code-group{sync="pm"} +```bash [pnpm] +pnpm add tiptap-extension-code-block-shiki +``` + +```bash [yarn] +yarn add tiptap-extension-code-block-shiki +``` + +```bash [npm] +npm install tiptap-extension-code-block-shiki +``` + +```bash [bun] +bun add tiptap-extension-code-block-shiki +``` +:: + +Configure the extension with your preferred themes: + +::code-tree-intersection +```ts [app/pages/index.vue] +import { CodeBlockShiki } from 'tiptap-extension-code-block-shiki' + +// Add to extensions with theme configuration +const extensions = [ + CodeBlockShiki.configure({ + defaultTheme: 'github-dark', + themes: { + light: 'github-light', + dark: 'github-dark' + } + }) +] +``` +:: + +::tip +You can use any [Shiki theme](https://shiki.style/themes) for syntax highlighting. Popular choices include `github-dark`, `one-dark-pro`, `dracula`, and `nord`. +:: + +## Adding drag handle + +The drag handle allows users to reorder blocks by dragging them. It also provides a dropdown menu for additional actions. + +Create a composable for the drag handle items: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useHandleItems.ts] +import type { DropdownMenuItem } from '@nuxt/ui' +import type { Editor, JSONContent } from '@tiptap/vue-3' +import { upperFirst } from 'scule' +import { mapEditorItems } from '@nuxt/ui/utils/editor' + +export function useHandleItems() { + const selectedNode = ref<{ node: JSONContent, pos: number }>() + + const handleItems = (editor: Editor): DropdownMenuItem[][] => { + if (!selectedNode.value?.node?.type) { + return [] + } + + return mapEditorItems(editor, [[ + { + type: 'label', + label: upperFirst(selectedNode.value.node.type) + }, + { + label: 'Turn into', + icon: 'i-lucide-repeat-2', + children: [ + { 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' }, + { kind: 'bulletList', label: 'Bullet List', icon: 'i-lucide-list' }, + { kind: 'orderedList', label: 'Ordered List', icon: 'i-lucide-list-ordered' }, + { kind: 'blockquote', label: 'Blockquote', icon: 'i-lucide-text-quote' }, + { kind: 'codeBlock', label: 'Code Block', icon: 'i-lucide-square-code' } + ] + }, + { + kind: 'clearFormatting', + pos: selectedNode.value?.pos, + label: 'Reset formatting', + icon: 'i-lucide-rotate-ccw' + } + ], [ + { + kind: 'duplicate', + pos: selectedNode.value?.pos, + label: 'Duplicate', + icon: 'i-lucide-copy' + } + ], [ + { + kind: 'moveUp', + pos: selectedNode.value?.pos, + label: 'Move up', + icon: 'i-lucide-arrow-up' + }, + { + kind: 'moveDown', + pos: selectedNode.value?.pos, + label: 'Move down', + icon: 'i-lucide-arrow-down' + } + ], [ + { + kind: 'delete', + pos: selectedNode.value?.pos, + label: 'Delete', + icon: 'i-lucide-trash' + } + ]]) as DropdownMenuItem[][] + } + + return { selectedNode, handleItems } +} +``` + +::: +:: + +Update the page to add the drag handle: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +The drag handle provides two buttons: +- A **plus button** that opens the suggestion menu for inserting new blocks +- A **grip button** that shows a dropdown menu with actions like duplicate, move, and delete + +## Adding slash commands + +Slash commands provide a quick way to insert blocks and formatting by typing `/`. + +Create a composable for the suggestion items: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useSuggestionItems.ts] +import type { EditorSuggestionMenuItem } from '@nuxt/ui' + +export function useSuggestionItems() { + const suggestionItems: EditorSuggestionMenuItem[][] = [[{ + type: 'label', + label: 'Style' + }, { + 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' + }, { + kind: 'bulletList', + label: 'Bullet List', + icon: 'i-lucide-list' + }, { + kind: 'orderedList', + label: 'Numbered List', + icon: 'i-lucide-list-ordered' + }, { + kind: 'blockquote', + label: 'Blockquote', + icon: 'i-lucide-text-quote' + }, { + kind: 'codeBlock', + label: 'Code Block', + icon: 'i-lucide-square-code' + }], [{ + type: 'label', + label: 'Insert' + }, { + kind: 'mention', + label: 'Mention', + icon: 'i-lucide-at-sign' + }, { + kind: 'emoji', + label: 'Emoji', + icon: 'i-lucide-smile-plus' + }, { + kind: 'horizontalRule', + label: 'Horizontal Rule', + icon: 'i-lucide-separator-horizontal' + }]] + + return { suggestionItems } +} +``` + +::: +:: + +Update the page to add the suggestion menu: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +::tip +Type `/` anywhere in the editor to open the suggestion menu. You can filter items by continuing to type. +:: + +## Adding mentions and emojis + +Mentions allow users to tag people using `@`, while the emoji picker lets users insert emojis using `:`. + +First, install the emoji extension: + +::code-group{sync="pm"} +```bash [pnpm] +pnpm add @tiptap/extension-emoji +``` + +```bash [yarn] +yarn add @tiptap/extension-emoji +``` + +```bash [npm] +npm install @tiptap/extension-emoji +``` + +```bash [bun] +bun add @tiptap/extension-emoji +``` +:: + +Create composables for mentions and emojis: + +::code-tree-intersection +```ts [app/composables/useMentionItems.ts] +import type { EditorMentionMenuItem } from '@nuxt/ui' + +export function useMentionItems() { + const mentionItems: EditorMentionMenuItem[] = [{ + label: 'benjamincanac', + avatar: { src: 'https://avatars.githubusercontent.com/u/739984?v=4' } + }, { + label: 'HugoRCD', + avatar: { src: 'https://avatars.githubusercontent.com/u/71938701?v=4' } + }, { + label: 'romhml', + avatar: { src: 'https://avatars.githubusercontent.com/u/25613751?v=4' } + }, { + label: 'sandros94', + avatar: { src: 'https://avatars.githubusercontent.com/u/13056429?v=4' } + }, { + label: 'hywax', + avatar: { src: 'https://avatars.githubusercontent.com/u/149865959?v=4' } + }] + + return { mentionItems } +} +``` +:: + +::code-tree-intersection +```ts [app/composables/useEmojiItems.ts] +import type { EditorEmojiMenuItem } from '@nuxt/ui' +import { gitHubEmojis } from '@tiptap/extension-emoji' + +export function useEmojiItems() { + const emojiItems: EditorEmojiMenuItem[] = gitHubEmojis.filter(emoji => + !emoji.name.startsWith('regional_indicator_') + ) + + return { emojiItems } +} +``` +:: + +Update the page to add mentions and emoji menus: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +::note +The `@tiptap/extension-emoji` package includes `gitHubEmojis`, a comprehensive list of GitHub-style emojis that you can use directly. +:: + +## Custom image upload extension + +For a better image upload experience, let's create a custom TipTap extension that uses the `UFileUpload` component: + +### Creating the upload node component + +::code-tree-intersection +:::code-collapse + +```vue [app/components/EditorImageUploadNode.vue] + + + +``` + +::: +:: + +### Creating the TipTap extension + +::code-tree-intersection +:::code-collapse + +```ts [app/extensions/ImageUpload.ts] +import { Node, mergeAttributes } from '@tiptap/core' +import type { NodeViewRenderer } from '@tiptap/core' +import { VueNodeViewRenderer } from '@tiptap/vue-3' +import ImageUploadNodeComponent from '~/components/EditorImageUploadNode.vue' + +declare module '@tiptap/core' { + interface Commands { + 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 }) => { + return commands.insertContent({ type: this.name }) + } + } + } +}) + +export default ImageUpload +``` + +::: +:: + +### Adding the custom handler + +Create a composable for custom handlers: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useCustomHandlers.ts] +import type { EditorCustomHandlers } from '@nuxt/ui' +import type { Editor } from '@tiptap/vue-3' + +export function useCustomHandlers() { + 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 + + return { customHandlers } +} +``` + +::: +:: + +Update the fixed toolbar composable to use the custom handler: + +```ts +// In useFixedToolbarItems.ts, update the image item: +{ + kind: 'imageUpload', + icon: 'i-lucide-image', + tooltip: { text: 'Image' } +} +``` + +Update the page to use the extension and handlers: + +::code-tree-intersection +```vue [app/pages/index.vue] + + + +``` +:: + +::tip +In a real application, you would upload the image to a storage service (like S3, Cloudflare R2, or NuxtHub Blob) and use the returned URL instead of a data URL. +:: + +## AI completion + +For AI-powered features like ghost text suggestions and text transformations, you can integrate the Vercel AI SDK. This requires additional setup. + +### Installing AI dependencies + +::code-group{sync="pm"} +```bash [pnpm] +pnpm add ai @ai-sdk/gateway @ai-sdk/vue +``` + +```bash [yarn] +yarn add ai @ai-sdk/gateway @ai-sdk/vue +``` + +```bash [npm] +npm install ai @ai-sdk/gateway @ai-sdk/vue +``` + +```bash [bun] +bun add ai @ai-sdk/gateway @ai-sdk/vue +``` +:: + +### Creating the completion extension + +::code-tree-intersection +:::code-collapse + +```ts [app/extensions/Completion.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?: number + autoTrigger?: boolean + triggerCharacters?: string[] + onTrigger?: (editor: Editor) => void + onAccept?: () => void + 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({ + 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 }) => { + if (this.storage.visible) { + this.storage.clearSuggestion() + this.options.onDismiss?.() + } + this.storage.debouncedTrigger?.(editor as Editor) + return true + }, + 'Tab': ({ editor }) => { + if (!this.storage.visible || !this.storage.suggestion || this.storage.position === undefined) { + return false + } + + const suggestion = this.storage.suggestion + const position = this.storage.position + + this.storage.clearSuggestion() + editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true)) + editor.chain().focus().insertContentAt(position, suggestion).run() + + this.options.onAccept?.() + return true + }, + 'Escape': ({ editor }) => { + if (this.storage.visible) { + this.storage.clearSuggestion() + editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true)) + this.options.onDismiss?.() + return true + } + return false + } + } + }, + + onUpdate({ editor }) { + if (this.storage.visible) { + this.storage.clearSuggestion() + editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true)) + this.options.onDismiss?.() + } + + if (this.options.autoTrigger) { + this.storage.debouncedTrigger?.(editor as Editor) + } + }, + + onSelectionUpdate({ editor }) { + if (this.storage.visible) { + this.storage.clearSuggestion() + editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true)) + this.options.onDismiss?.() + } + }, + + onCreate() { + const storage = this.storage + const options = this.options + + this.storage.debouncedTrigger = useDebounceFn((editor: Editor) => { + if (!options.onTrigger) return + + const { state } = editor + const { selection } = state + const { $from } = selection + + const isAtEndOfBlock = $from.parentOffset === $from.parent.content.size + const hasContent = $from.parent.textContent.trim().length > 0 + const textContent = $from.parent.textContent + + const endsWithPunctuation = /[.!?]\s*$/.test(textContent) + const triggerChars = options.triggerCharacters || [] + const endsWithTrigger = triggerChars.some(char => textContent.endsWith(char)) + + if (!isAtEndOfBlock || !hasContent || endsWithPunctuation || endsWithTrigger) { + return + } + + storage.position = selection.from + storage.visible = true + + options.onTrigger(editor) + }, options.debounce || 250) + }, + + onDestroy() { + this.storage.debouncedTrigger = null + } +}) + +export default Completion +``` + +::: +:: + +### Creating the completion composable + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useEditorCompletion.ts] +import { useCompletion } from '@ai-sdk/vue' +import type { Editor } from '@tiptap/vue-3' +import { Completion } from '~/extensions/Completion' +import type { CompletionStorage } from '~/extensions/Completion' + +type CompletionMode = 'continue' | 'fix' | 'extend' | 'reduce' | 'simplify' | 'summarize' | 'translate' + +export function useEditorCompletion(editorRef: Ref<{ editor: Editor | undefined } | null | undefined>) { + const insertState = ref<{ + pos: number + deleteRange?: { from: number, to: number } + }>() + const mode = ref('continue') + const language = ref() + + function getCompletionStorage() { + const storage = editorRef.value?.editor?.storage as Record | undefined + return storage?.completion + } + + const { completion, complete, isLoading, stop, setCompletion } = useCompletion({ + api: '/api/completion', + streamProtocol: 'text', + body: computed(() => ({ + mode: mode.value, + language: language.value + })), + onFinish: (_prompt, completionText) => { + const storage = getCompletionStorage() + if (mode.value === 'continue' && storage?.visible) { + return + } + + const transformModes = ['fix', 'extend', 'reduce', 'simplify', 'summarize', 'translate'] + if (transformModes.includes(mode.value) && insertState.value && completionText) { + const editor = editorRef.value?.editor + if (editor) { + if (insertState.value.deleteRange) { + editor.chain().focus().deleteRange(insertState.value.deleteRange).run() + } + + 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, (newCompletion, oldCompletion) => { + const editor = editorRef.value?.editor + if (!editor || !newCompletion) return + + const storage = getCompletionStorage() + if (storage?.visible) { + 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)) + } + }) + + function triggerTransform(editor: Editor, transformMode: Exclude, 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) + + 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) + } + 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) { + const textBefore = getMarkdownBefore(editor, selection.from) + insertState.value = { pos: selection.from } + complete(textBefore) + } else { + const textBefore = getMarkdownBefore(editor, selection.to) + insertState.value = { pos: selection.to } + complete(textBefore) + } + } + + 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('') + } + }) + + 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 + }, + 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 + }, + 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 + } +} +``` + +::: +:: + +### Creating the server endpoint + +::code-tree-intersection +:::code-collapse + +```ts [server/api/completion.post.ts] +import { streamText } 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 system: string + let maxOutputTokens: number + + const preserveMarkdown = 'IMPORTANT: Preserve all markdown formatting (bold, italic, links, etc.) exactly as in the original.' + + switch (mode) { + case 'fix': + system = `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': + system = `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 'simplify': + system = `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 'translate': + system = `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: + system = `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 + } + + return streamText({ + model: gateway('openai/gpt-4o-mini'), + system, + prompt, + maxOutputTokens + }).toTextStreamResponse() +}) +``` + +::: +:: + +### Using AI in the bubble toolbar + +Add AI actions to your bubble toolbar composable. Update `useBubbleToolbarItems.ts` to include an AI dropdown: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useBubbleToolbarItems.ts] +import type { EditorToolbarItem } from '@nuxt/ui' + +export function useBubbleToolbarItems(aiLoading?: Ref) { + const bubbleToolbarItems = computed(() => [[{ + icon: 'i-lucide-sparkles', + label: 'Improve', + activeColor: 'neutral', + activeVariant: 'ghost', + 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: 'aiSimplify', + icon: 'i-lucide-lightbulb', + label: 'Simplify text' + }, { + 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' + }] + }] + }, { + label: 'Turn into', + trailingIcon: 'i-lucide-chevron-down', + // ... rest of the items + }], + // ... marks items + ]) + + return { bubbleToolbarItems } +} +``` + +::: +:: + +::note +The completion extension can be configured with `autoTrigger: true` to automatically suggest completions while typing (disabled by default). You can also manually trigger it with :kbd{value="meta"} :kbd{value="j" class="ms-px"}. +:: + +## Consolidating composables + +For a cleaner production setup, you can consolidate your composables following the pattern used in the [Editor template](https://github.com/nuxt-ui-templates/editor). Here's a recommended structure: + +::code-tree-intersection +:::code-collapse + +```ts [app/composables/useEditorToolbar.ts] +import type { EditorToolbarItem, EditorCustomHandlers } from '@nuxt/ui' +import type { Editor } from '@tiptap/vue-3' + +export function useEditorToolbar( + customHandlers: T, + options?: { aiLoading?: Ref } +) { + const toolbarItems: EditorToolbarItem[][] = [[{ + kind: 'undo', + icon: 'i-lucide-undo', + tooltip: { text: 'Undo' } + }, { + kind: 'redo', + icon: 'i-lucide-redo', + tooltip: { text: 'Redo' } + }], [{ + icon: 'i-lucide-heading', + tooltip: { text: 'Headings' }, + 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' } + ] + }, { + icon: 'i-lucide-list', + tooltip: { text: 'Lists' }, + items: [ + { kind: 'bulletList', icon: 'i-lucide-list', label: 'Bullet List' }, + { kind: 'orderedList', icon: 'i-lucide-list-ordered', label: 'Ordered List' }, + { kind: 'taskList', icon: 'i-lucide-list-checks', label: 'Task List' } + ] + }], [{ + 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: 'code', icon: 'i-lucide-code', tooltip: { text: 'Code' } + }], [{ + slot: 'link', icon: 'i-lucide-link' + }, { + kind: 'imageUpload', icon: 'i-lucide-image', tooltip: { text: 'Image' } + }, { + kind: 'table', icon: 'i-lucide-table', tooltip: { text: 'Table' } + }]] + + const bubbleToolbarItems = computed[][]>(() => [[{ + icon: 'i-lucide-sparkles', + label: 'Improve', + loading: options?.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: 'aiSimplify', icon: 'i-lucide-lightbulb', label: 'Simplify text' } + ] + }], [{ + 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: 'code', icon: 'i-lucide-code', tooltip: { text: 'Code' } + }], [{ + slot: 'link', icon: 'i-lucide-link' + }]]) + + const getImageToolbarItems = (editor: Editor): EditorToolbarItem[][] => { + const node = editor.state.doc.nodeAt(editor.state.selection.from) + return [[{ + icon: 'i-lucide-download', + to: node?.attrs?.src, + download: true, + tooltip: { text: 'Download' } + }], [{ + icon: 'i-lucide-trash', + tooltip: { text: 'Delete' }, + onClick: () => { + const pos = editor.state.selection.from + const currentNode = editor.state.doc.nodeAt(pos) + if (currentNode?.type.name === 'image') { + editor.chain().focus().deleteRange({ from: pos, to: pos + currentNode.nodeSize }).run() + } + } + }]] + } + + const getTableToolbarItems = (editor: Editor): EditorToolbarItem[][] => [[{ + icon: 'i-lucide-plus', + tooltip: { text: 'Add column' }, + onClick: () => editor.chain().focus().addColumnAfter().run() + }, { + icon: 'i-lucide-minus', + tooltip: { text: 'Delete column' }, + onClick: () => editor.chain().focus().deleteColumn().run() + }], [{ + icon: 'i-lucide-plus', + tooltip: { text: 'Add row' }, + onClick: () => editor.chain().focus().addRowAfter().run() + }, { + icon: 'i-lucide-minus', + tooltip: { text: 'Delete row' }, + onClick: () => editor.chain().focus().deleteRow().run() + }], [{ + icon: 'i-lucide-trash', + tooltip: { text: 'Delete table' }, + onClick: () => editor.chain().focus().deleteTable().run() + }]] + + return { toolbarItems, bubbleToolbarItems, getImageToolbarItems, getTableToolbarItems } +} +``` + +::: +:: + +This consolidated approach keeps your page component clean: + +::code-tree-intersection +:::code-collapse + +```vue [app/pages/index.vue] + + + +``` + +::: +:: + +This structure matches the [official Editor template](https://github.com/nuxt-ui-templates/editor/blob/main/app/pages/index.vue) and provides a clean separation of concerns. + +## Going further + +You now have a fully functional Notion-like editor! To take it further, consider adding: + +**Real-time Collaboration** + +Integrate [Y.js](https://yjs.dev/) with TipTap's collaboration extension for multiplayer editing. The [Editor template](https://github.com/nuxt-ui-templates/editor) includes optional [PartyKit](https://partykit.io/) integration for real-time collaboration with cursor presence. + +**Blob Storage** + +Use [NuxtHub Blob](https://hub.nuxt.com/docs/features/blob) for image uploads with support for [Vercel Blob](https://vercel.com/docs/vercel-blob), Cloudflare R2, or Amazon S3. + +**Export Options** + +Add buttons to export content as HTML, Markdown, or PDF using TipTap's built-in serializers. + +**Autosave** + +Implement debounced autosave to persist content to a database or local storage. + +::callout{icon="i-lucide-rocket" to="https://github.com/nuxt-ui-templates/editor" target="_blank"} +The official **Editor template** includes all features covered in this tutorial plus real-time collaboration. Get started instantly with `npx nuxi@latest init -t ui/editor my-editor-app`. +:: + +## Conclusion + +You've built a complete Notion-like editor with: + +- **Markdown support** for seamless content authoring +- **Multiple toolbars**: fixed, bubble, image, and table toolbars +- **Tables and task lists** for structured content +- **Syntax-highlighted code blocks** powered by Shiki +- **Slash commands** for quick insertions (type `/`) +- **Mentions and emojis** for rich interactions +- **Drag-and-drop** block reordering +- **Custom image upload** extension +- **AI-powered** text completion and transformation + +The combination of Nuxt UI's purpose-built editor components and TipTap's extensible architecture makes building sophisticated Notion-like editing experiences straightforward and enjoyable. + +**Resources:** + +- [Nuxt UI Editor Components](https://ui.nuxt.com/docs/components/editor) +- [TipTap Documentation](https://tiptap.dev/docs) +- [AI SDK Documentation](https://ai-sdk.dev) +- [Editor Template](https://github.com/nuxt-ui-templates/editor) + +We're excited to see what you'll build!