# ai-sparkwrite-editor > AI-first rich-text editor SDK on Tiptap for React and Vue: the model writes into the document as real nodes. `RichTextKit` is the whole editor as one extension; every feature is also importable on its own. AI goes through one backend URL (`endpoint`); uploads are app-provided callbacks returning durable URLs. Docs: https://ludejun.github.io/ai-sparkwrite-editor/ · Repository: https://github.com/ludejun/ai-sparkwrite-editor · npm: ai-sparkwrite-editor 1.1.0 # Getting Started `ai-sparkwrite-editor` is Tiptap extensions plus ready-made controls, from **one import per framework**: `ai-sparkwrite-editor` for React, `ai-sparkwrite-editor/vue` for Vue. The fastest start is the kit — `RichTextKit` registers every feature, `RichTextKitToolbar` and `RichTextKitMenus` render the UI — and you can just as well pick features one by one from the same import. Bundlers that tree-shake ES modules only ship what you reference, so the single import costs nothing (see [Bundle size](/guide/bundle-size)). Keep every `@tiptap/*` package on one compatible version. This repository uses `^3.29.2`; `@tiptap/vue-3` has to match `@tiptap/core` exactly. ## React ### 1. Install ::: code-group ```sh [pnpm] pnpm add ai-sparkwrite-editor @tiptap/react@^3.29.2 @tiptap/pm@^3.29.2 @tiptap/extension-document@^3.29.2 @tiptap/extension-paragraph@^3.29.2 @tiptap/extension-text@^3.29.2 ``` ```sh [npm] npm install ai-sparkwrite-editor @tiptap/react@^3.29.2 @tiptap/pm@^3.29.2 @tiptap/extension-document@^3.29.2 @tiptap/extension-paragraph@^3.29.2 @tiptap/extension-text@^3.29.2 ``` ```sh [bun] bun add ai-sparkwrite-editor @tiptap/react@^3.29.2 @tiptap/pm@^3.29.2 @tiptap/extension-document@^3.29.2 @tiptap/extension-paragraph@^3.29.2 @tiptap/extension-text@^3.29.2 ``` ```sh [yarn] yarn add ai-sparkwrite-editor @tiptap/react@^3.29.2 @tiptap/pm@^3.29.2 @tiptap/extension-document@^3.29.2 @tiptap/extension-paragraph@^3.29.2 @tiptap/extension-text@^3.29.2 ``` ::: The `@tiptap/extension-*` packages are only needed when you assemble the extensions yourself (next section but one); the kit brings its own. ### 2. The whole editor in ten lines `RichTextKit` is every feature as one extension, like Tiptap's StarterKit (every option on its [own page](/guide/kit)); `RichTextKitToolbar` and `RichTextKitMenus` render a toolbar, the AI composer dock, the bubble menus, the drag handle and the slash menu for whatever is registered. ```tsx 'use client'; import { EditorContent, useEditor } from '@tiptap/react'; import { RichTextKit, RichTextKitMenus, RichTextKitToolbar, RichTextProvider, } from 'ai-sparkwrite-editor'; import 'ai-sparkwrite-editor/style.css'; export default function Editor() { const editor = useEditor({ extensions: [RichTextKit.configure({ ai: { endpoint: '/api/ai' } })], content: '
Hello
', immediatelyRender: false, }); if (!editor) return null; return (Select some text, or press the AI button.
', immediatelyRender: false, }); if (!editor) return null; return (Select a few words to format them.
', immediatelyRender: false, }); if (!editor) return null; return (— Ada, ' + new Date().toLocaleDateString() + '
') .run(), }; }, }); ``` ## Custom rendering Two levels, depending on how far you need to go. **Change how a built-in node looks.** Most nodes carry a `class` or `data-*` hook you can style, and several take render options: `Divider.configure({ renderDivider })` decides the saved HTML, `Image.configure({ HTMLAttributes })` adds attributes, code blocks follow the `CODE_THEME` palette. Styles live behind one root class, `.sparkwrite`, so overriding them needs one more selector than the library uses. **Add a block of your own.** Any Tiptap node works, and a node view gives it an interactive editing state (React below; in Vue, `VueNodeViewRenderer` on the same node — see [Frameworks](/guide/frameworks)). The `Divider` extension is a compact example of the whole pattern — attributes, `parseHTML`/`renderHTML` for the saved form, a node view with an input, a plugin that keeps derived attributes in sync — and `Callout` a simpler one: ```tsx import { Node, mergeAttributes } from '@tiptap/core'; import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; const RatingView = ({ node, updateAttributes }) => (` for `en`, `zh_CN`, `hi`, `es`, `fr`, `bn`, `pt_BR`, `ru`, `id`, `de`, `ja`, `tr`, `vi`, `ko`, `it`, `hu_HU`, `fi`. See [Internationalization](/guide/internationalization).
## Theme
`ai-sparkwrite-editor/theme` exports `themeActions` (`setTheme`, `setBorderRadius`, accent colour) and `CODE_THEME`. Styles come from `ai-sparkwrite-editor/style.css`; the root element carries the `ai-sparkwrite-editor` class.
---
# Frameworks
The editor is Tiptap underneath, and Tiptap is framework-agnostic: the same extensions run in React, Vue, Svelte or a plain page through their respective bindings. What is React-specific here is the **UI** — toolbar controls, bubble menus, dialogs, and the node views that make blocks like the divider or the code block interactive inside the document.
The package is therefore split in two layers:
| Layer | Import | Depends on React | Contents |
| ----- | --------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Core | `ai-sparkwrite-editor/core` | No | Every extension without its node view (marks, headings, lists, tables, links, alignment, indent, font, colour, divider, code block, callout, details, video, iframe, images and GIFs, Katex, Mermaid, attachment, table of contents…; columns, Excalidraw, drawer, Twitter and the suggestion popups — mention, short message, emoji, slash commands — stay in the React layer), paste rules, search & replace, recorder, the AI transport and markdown rendering, image bookkeeping, translations |
| React | `ai-sparkwrite-editor` (everything; `/` and `/bubble/*` subpaths remain) | Yes | Everything above plus controls, bubble menus, dialogs and node views |
A build check (`tests/core-headless.test.mjs`) walks the chunk graph of the core bundle and fails if anything reachable from it imports `react`, `@tiptap/react`, Radix or lucide.
## Vue
One import: `ai-sparkwrite-editor/vue` carries the extensions (the framework-free ones re-exported from `core`, plus the blocks with a Vue node view), the UI and `RichTextKit`. The Vue layer ships a provider, composables, toolbar primitives, ready-made controls for the core extensions, and Vue node views for the blocks (divider, code block, callout, image, GIF, iframe, Katex, Mermaid, attachment, table of contents). It depends only on `vue`, `@tiptap/vue-3` and `lucide-vue-next`, and shares the stylesheet with the React controls, so both toolbars look the same.
```bash
pnpm add ai-sparkwrite-editor @tiptap/vue-3 @tiptap/pm @tiptap/extension-document @tiptap/extension-paragraph @tiptap/extension-text lucide-vue-next
```
```vue
```
`RichTextProvider` renders the root element with the `ai-sparkwrite-editor` class and hands the editor to every control below it. The example under `examples/vue` in the repository is this page with every control on it (`pnpm --dir examples/vue dev`).
### What the Vue layer contains
| Kind | Exports |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Provider and composables | `RichTextProvider`, `useEditorInstance()`, `useEditorState(selector, fallback)`, `useLocale()` |
| Toolbar primitives | `RichTextToolbar`, `RichTextToolbarDivider`, `RichTextToolbarButton`, `RichTextDropdown`, `RichTextToolbarMore`, `RichTextToolbarMoreGroup`, `RichTextToolbarMoreRow` |
| Controls | Undo/redo, bold, italic, underline, strike, code, clear, heading, bullet/ordered/task list, blockquote, text align, link (popover), table, divider, color, highlight, font size, line height, indent/outdent, code block, callout, details, table of contents, image (upload or URL), video, attachment, iframe, Katex and Mermaid (dialogs with live preview and one-line AI generation) — the same names as the React controls |
| Node views | `Divider` (style picker, editable caption), `CodeBlock` (language picker, copy, delete), `Callout`, `Image` + `ImageBlock` (resize handles, caption, flip and rotation), `ImageGif`, `Iframe` (URL prompt, resize, edit link), `Katex` (renders with the extension's `loadKatex` or `import('katex')`), `Mermaid`, `Attachment` (file picker, upload state, file card), `TableOfContents` + `TableOfContentsNode` (live heading list). Each is `Core.extend({ addNodeView })` with the same DOM and CSS as the React one; the components are exported as `NodeView`, and `useImageResize` is the shared corner-handle composable |
Your own control is a `RichTextToolbarButton` with an `onClick` that runs a command, or a `RichTextDropdown` with items; `useEditorState` gives it reactive `isActive`/`can()` state.
### Not in Vue yet
Excalidraw and the drawer (they wrap React-only libraries), the Twitter embed (`react-tweet`), columns, the search-and-replace panel, and the suggestion popups — emoji, mention, short message and the `/` slash menu — still render with React. Their extensions work wherever they are framework-free; only those affordances are missing in Vue. Details and video have no node view in either layer: they render through `renderHTML` and live in `ai-sparkwrite-editor/core`.
## Plain JavaScript
```ts
import { Editor } from '@tiptap/core';
import { Bold, Heading, Table, RichPaste } from 'ai-sparkwrite-editor/core';
const editor = new Editor({
element: document.querySelector('#editor')!,
extensions: [/* Document, Paragraph, Text, */ Bold, Heading, Table, RichPaste],
});
```
## What stays React-only today
- Node views that wrap React libraries: Excalidraw, drawer, Twitter.
- The suggestion popups (emoji, mention, short message, slash menu), columns, and the search-and-replace panel.
Each is a thin layer over a command or an attribute the core already exposes, so a Vue port is UI work, not editor work; the slash menu is the one most worth doing next.
## Adding a framework-free extension
Keep the extension module (`src/extensions//.ts`) free of component imports and re-export components from the folder's `index.ts` only; the build makes the React entry from `index.ts` and the core entry from the extension module, so the two never share a React-bearing chunk. Then export it from `src/core.ts` and run the headless check.
An extension with a node view is split in three: `.ts` exports `Core` (no node view), `React.ts` exports `` = `Core.extend({ addNodeView: ReactNodeViewRenderer(...) })` and is what `index.ts` re-exports, and `src/vue/nodeviews/.ts` exports `` = `Core.extend({ addNodeView: VueNodeViewRenderer(...) })`. Helpers both node views need (caption detection, language lists, file icons, the heading list) live in framework-free files next to the extension, never in a `.tsx`.
## AI, bubble menus and dialogs in Vue
Everything below comes from `ai-sparkwrite-editor/vue` and shares its stylesheet, class names and prompts with the React layer.
**AI.** Register `AI` (the core extension with the Vue panel mounted through `VueRenderer`) and, optionally, `AIAutocomplete` for ghost-text suggestions. Its options are the core `AIOptions` plus `renderResult(context)` to draw the answer yourself and `components.Panel` to replace the whole dialog. Then place the components:
- `RichTextAI` — the toolbar button (Sparkles + "AI", `aria-pressed` while the dock is open); toggles the composer, as `Mod-J` does.
- `RichTextAIComposer` — the dock under `EditorContent`: quick-action chips from `AI_COMPOSER_ACTIONS`, a prompt with a target select (selection, cursor, top, end, whole document), streaming straight into the document through `writeWithAI`, then Keep / Undo / Retry and refinement with the conversation history. Props: `actions`, `defaultOpen`.
- `RichTextAIImprove` — the selection menu of the text bubble: edit (improve, grammar, shorter, longer, simplify), change tone, generate (summarize, explain, table, list), translate to the browser language (or the configured `translateLanguages`), ask anything, open the composer. Each entry opens the AI panel on the selection captured when the menu opened.
- `AIPanel` — the panel component itself, for a custom `mountPanel`.
The framework-free pieces are re-exported so one import covers a Vue app: `AICore`, `AIAutocomplete`, `aiPluginKey`, `aiAutocompleteKey`, `writeWithAI`, `aiOptionsOf`, `resolveWriteTarget`, `documentContext`, `generateAIText`, `AI_COMPOSER_ACTIONS`, `composerPrompt`, `browserLanguage`, `markdownToHTML`, `markdownToFragment`, `markdownToSlice`, `markdownToPreviewHTML`, `DEFAULT_AI_SYSTEM_PROMPT`, and the `AI*` types.
```vue
```
**Bubble menus.** Built on `BubbleMenu` from `@tiptap/vue-3/menus`; place them anywhere inside `RichTextProvider`.
- `RichTextBubbleText` — over a text selection (not in code blocks, hidden while an AI panel is open): `RichTextAIImprove`, the paragraph/heading dropdown, bold, italic, underline, strike, code, link, colour, highlight, alignment. Put your own buttons in the default slot to replace them.
- `RichTextBubbleTable` — a right click inside a table opens a context menu: insert/delete rows and columns, merge/split cells, "Paragraph after table" with its shortcut, delete table. `hiddenActions` leaves entries out.
- `RichTextBubbleLink` — while the caret is in a link: the address, open, edit (text, address, new tab), unlink.
- `RichTextBubbleImage` — when an image is selected: align left/centre/right, S/M/L sizes, remove.
`BUBBLE_CLASS`, `BUBBLE_OPTIONS` and `useBubbleEditor()` are exported for a bubble menu of your own.
**Dialogs and controls.** `RichTextLink` opens a popover (text, address, open in new tab; unlink when in a link) and `RichTextLinkForm` is that form on its own. `RichTextImage` and `RichTextVideo` open a dialog with a drop zone (the extension's `upload`, `acceptMimes`, `maxSize`, `multiple`, `onError` are honoured; uploads are remembered for `getImageChanges`) and an address field; `RichTextKatex` and `RichTextMermaid` open a dialog with the source, a live preview and — when the AI extension is registered — a one-line "describe it" prompt (`RichTextAIGenerateField`). `RichTextIframe` and `RichTextCallout` are popovers; `RichTextAttachment` picks a file and uploads it through the extension's `upload`; `RichTextCodeBlock`, `RichTextDetails`, `RichTextTableOfContents`, `RichTextIndent` and `RichTextOutdent` run their command; `RichTextColor` and `RichTextHighlight` open a palette (the extension's `colors`, else the default list, plus a native picker); `RichTextFontSize` and `RichTextLineHeight` are dropdowns over the configured lists.
The primitives behind them are exported too: `RichTextPopover` (a toolbar button with a panel; the slot receives `{ close }`), `RichTextDialog` (`open` / `update:open`, `title`, `footer` slot) and `useDismiss(open, root, close)` for outside-click and Escape handling. Search and replace has no Vue control yet.
---
# Migrating from reactjs-tiptap-editor
`ai-sparkwrite-editor` descends from [reactjs-tiptap-editor](https://github.com/hunghg255/reactjs-tiptap-editor). Use this guide when moving an app off that package, or off its legacy `RichTextEditor` / `BaseKit` API, to the composable API used here. For a new project, follow [Getting Started](/guide/getting-started).
Two mechanical changes first: every import path moves from `reactjs-tiptap-editor/` to `ai-sparkwrite-editor/` (subpaths keep their names), and the root CSS class is `.sparkwrite`. Then the API differences below apply.
Keep a sample of your existing saved content and test it with the new extension configuration before switching users over.
## What changes
| Legacy API | Composable API |
| ---------------------------------- | ----------------------------------------------------------------------------- |
| Default `RichTextEditor` component | Named `RichTextProvider` with Tiptap's `EditorContent`. |
| `extensions` on the component | `extensions` in `useEditor`. |
| `content` and `output` props | `content` in `useEditor`; read `getHTML()` or `getJSON()`. |
| `onChangeContent` | `onUpdate: ({ editor }) => ...` in `useEditor`. |
| `BaseKit.configure(...)` | Register the individual base extensions and configure them directly. |
| Automatically assembled toolbar | Render `RichText*` controls explicitly inside the provider. |
| Bubble menu render configuration | Mount individual `RichTextBubble*` components. |
| `disabled` | `editable` in `useEditor` or `editor.setEditable(...)`. |
| `dark` | `themeActions.setTheme('light' or 'dark')`. |
| Legacy locale API | `localeActions` and `useLocale` from `/locale`; register needed dictionaries. |
| `/multicolumn` imports | `/column` with `Column`, `ColumnNode`, `MultipleColumnNode`. |
## Replace the editor component
Install the packages listed in Getting Started. This component receives initial HTML and reports edits to your existing save handler:
```tsx
'use client';
import { useEffect } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import {
RichTextProvider,
Bold,
RichTextBold,
Italic,
RichTextItalic,
History,
RichTextUndo,
RichTextRedo,
RichTextBubbleText,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Bold, Italic, History];
type MigratedEditorProps = {
initialContent: string;
onChangeContent: (html: string) => void;
disabled?: boolean;
};
export default function MigratedEditor({
initialContent,
onChangeContent,
disabled = false,
}: MigratedEditorProps) {
const editor = useEditor({
extensions,
content: initialContent,
editable: !disabled,
immediatelyRender: false,
onUpdate: ({ editor }) => onChangeContent(editor.getHTML()),
});
useEffect(() => {
editor?.setEditable(!disabled);
}, [editor, disabled]);
if (!editor) return null;
return (
>
}
/>
);
}
```
`initialContent` initializes the document. When opening another document, either remount with a document-specific React `key` or call `setContent` after loading it. Do not reset the document whenever your save handler updates parent state. See [loading content](/guide/getting-started#_5-load-or-replace-content).
For JSON storage, change the callback to `editor.getJSON()` and type your value as Tiptap's `JSONContent`, imported from `@tiptap/core`.
## Replace BaseKit deliberately
The minimal schema needs `Document`, `Paragraph`, and `Text`. Add the rest according to your existing features:
| Requirement | Extension to add |
| -------------------------- | ---------------------------------------------------------------------------------- |
| Line breaks | `HardBreak` from `@tiptap/extension-hard-break`. |
| Placeholder | `Placeholder` from `@tiptap/extensions`. |
| Drag/drop cursor | `Dropcursor` from `@tiptap/extensions`. |
| Cursor between blocks | `Gapcursor` from `@tiptap/extensions`. |
| Final trailing paragraph | `TrailingNode` from `@tiptap/extensions`. |
| Character limit | `CharacterCount` from `@tiptap/extensions`. |
| Bullet/numbered list items | `ListItem` from `@tiptap/extension-list`, alongside the list extension. |
| Color, fonts, line height | `TextStyle` from `@tiptap/extension-text-style`, alongside the feature extensions. |
| Undo/redo | Library `History` extension. |
For example, preserve a placeholder and character limit by adding these configured extensions to your array:
```ts
import { CharacterCount, Placeholder } from '@tiptap/extensions';
const extraExtensions = [
Placeholder.configure({ placeholder: 'Start writing…', showOnlyCurrent: true }),
CharacterCount.configure({ limit: 50_000 }),
];
```
Spread `extraExtensions` into your existing `extensions` array. A placeholder mentioning `/` does not enable slash commands; those require their own extension and list component.
If you already use `StarterKit`, disable any overlapping extensions before registering the library versions. Keep Tiptap packages on compatible versions; the current source uses Tiptap 3.
## Restore toolbar and bubble features
For each previously enabled feature:
1. Import its extension from the documented package subpath.
2. Add it and any companion extensions to `useEditor({ extensions })`.
3. Import and mount its `RichText*` toolbar control.
4. Mount a matching bubble component if contextual editing is needed.
5. Restore feature-specific configuration such as upload callbacks and CSS imports.
See [Toolbar](/guide/toolbar) and [Bubble Menu](/guide/bubble-menu) for component mappings and custom controls.
## Restore theme and locale
Use actions during client initialization or in preference-change handlers:
```ts
import { themeActions } from 'ai-sparkwrite-editor/theme';
import { localeActions } from 'ai-sparkwrite-editor/locale';
import vi from 'ai-sparkwrite-editor/locales/vi';
themeActions.setTheme('dark');
localeActions.setMessage('vi', vi);
localeActions.setLang('vi');
```
These settings are shared across editor instances. The provider's current `dark` prop is not applied by its implementation. See [Custom Theme](/guide/custom-theme) and [Internationalization](/guide/internationalization).
## Restore columns and slash commands
Column layouts need all three exports from `/column` and the document setup shown on the [Column page](/extensions/Column/). Do not register the original and extended Document together.
For slash commands, register `SlashCommand` and mount ` ` under `RichTextProvider`. For a custom menu, use its `commandList` prop; see [Slash Command](/extensions/SlashCommand/).
## Verify the migration
- Load representative saved HTML/JSON and confirm formatting, lists, tables, links, and custom nodes survive a save/reload cycle.
- Test undo/redo, selection controls, keyboard shortcuts, and changes to read-only state.
- Verify upload handlers return persistent URLs and failed uploads are visible to users.
- Check theme, translations, feature stylesheets, and client initialization in your framework.
- Test Word/PDF export with the actual node types your app uses; these formats do not guarantee every editor feature will be preserved.
---
# Internationalization
The library includes translations for its controls and dialogs. English (`en`) is the default. Changing the locale changes interface text; it does not translate document content.
## Choose a language
The lightweight `/locale` entry includes English only. Import and register each additional dictionary before selecting its language. Use `localeActions.setLang` during application initialization or in an event handler:
```tsx
import { localeActions, useLocale } from 'ai-sparkwrite-editor/locale';
import vi from 'ai-sparkwrite-editor/locales/vi';
import ja from 'ai-sparkwrite-editor/locales/ja';
localeActions.setMessage('vi', vi);
localeActions.setMessage('ja', ja);
export function LanguagePicker() {
const { lang } = useLocale();
return (
);
}
```
`useLocale()` returns `lang` (the language code) and `t` (a translation function). Call the hook inside a React component; `ai-sparkwrite-editor/vue` exports a `useLocale()` composable with the same shape. Use `localeActions.setLang` to change the language by code.
Locale state is shared across editor instances in the application. The library does not automatically persist a language choice across reloads; restore your application's preference when initializing the client.
## Included languages
Ordered by the number of speakers worldwide.
| Language | Code | Dictionary subpath |
| -------------------- | ------- | ------------------ |
| English | `en` | `/locales/en` |
| Simplified Chinese | `zh_CN` | `/locales/zh-cn` |
| Hindi | `hi` | `/locales/hi` |
| Spanish | `es` | `/locales/es` |
| French | `fr` | `/locales/fr` |
| Bengali | `bn` | `/locales/bn` |
| Brazilian Portuguese | `pt_BR` | `/locales/pt-br` |
| Russian | `ru` | `/locales/ru` |
| Indonesian | `id` | `/locales/id` |
| German | `de` | `/locales/de` |
| Japanese | `ja` | `/locales/ja` |
| Turkish | `tr` | `/locales/tr` |
| Vietnamese | `vi` | `/locales/vi` |
| Korean | `ko` | `/locales/ko` |
| Italian | `it` | `/locales/it` |
| Hungarian | `hu_HU` | `/locales/hu` |
| Finnish | `fi` | `/locales/fi` |
Use the exact code, including underscores and capitalization: three codes differ from their file name (`zh_CN` → `/locales/zh-cn`, `pt_BR` → `/locales/pt-br`, `hu_HU` → `/locales/hu`). Each dictionary has a default export. For example, register `/locales/pt-br` under the language code `pt_BR`.
No right-to-left language ships yet: the editor's own chrome (toolbar, dialogs, bubble menus) is laid out left-to-right, so an RTL dictionary alone would leave the interface mirrored incorrectly. Right-to-left _document content_ is supported through the Text Direction control regardless of the interface language.
### Load languages on demand
Importing all sixteen non-English dictionaries costs about 61 kB gzipped.
Loading one only when the reader picks it keeps the initial bundle at English
alone; each dictionary is its own chunk of roughly 3.5 to 4.5 kB.
The loader has to be written out per language. A bundler cannot follow a
computed specifier, so ``import(`ai-sparkwrite-editor/locales/${code}`)`` either
fails to build or quietly pulls in all of them — the map below keeps every
specifier a literal:
```tsx
import { useState } from 'react';
import { localeActions, useLocale } from 'ai-sparkwrite-editor/locale';
type Loader = () => Promise<{ default: Record }>;
/** English needs no loader: the `/locale` entry already registers it. */
const LANGUAGES: { code: string; label: string; load?: Loader }[] = [
{ code: 'en', label: 'English' },
{ code: 'zh_CN', label: '中文', load: () => import('ai-sparkwrite-editor/locales/zh-cn') },
{ code: 'hi', label: 'हिन्दी', load: () => import('ai-sparkwrite-editor/locales/hi') },
{ code: 'es', label: 'Español', load: () => import('ai-sparkwrite-editor/locales/es') },
{ code: 'fr', label: 'Français', load: () => import('ai-sparkwrite-editor/locales/fr') },
{ code: 'bn', label: 'বাংলা', load: () => import('ai-sparkwrite-editor/locales/bn') },
{ code: 'pt_BR', label: 'Português', load: () => import('ai-sparkwrite-editor/locales/pt-br') },
{ code: 'ru', label: 'Русский', load: () => import('ai-sparkwrite-editor/locales/ru') },
{ code: 'id', label: 'Bahasa Indonesia', load: () => import('ai-sparkwrite-editor/locales/id') },
{ code: 'de', label: 'Deutsch', load: () => import('ai-sparkwrite-editor/locales/de') },
{ code: 'ja', label: '日本語', load: () => import('ai-sparkwrite-editor/locales/ja') },
{ code: 'tr', label: 'Türkçe', load: () => import('ai-sparkwrite-editor/locales/tr') },
{ code: 'vi', label: 'Tiếng Việt', load: () => import('ai-sparkwrite-editor/locales/vi') },
{ code: 'ko', label: '한국어', load: () => import('ai-sparkwrite-editor/locales/ko') },
{ code: 'it', label: 'Italiano', load: () => import('ai-sparkwrite-editor/locales/it') },
{ code: 'hu_HU', label: 'Magyar', load: () => import('ai-sparkwrite-editor/locales/hu') },
{ code: 'fi', label: 'Suomi', load: () => import('ai-sparkwrite-editor/locales/fi') },
];
/** Locale state is global, so the cache of loaded dictionaries can be too. */
const registered = new Set(['en']);
export async function selectLanguage(code: string) {
const entry = LANGUAGES.find((language) => language.code === code);
if (entry?.load && !registered.has(code)) {
const { default: messages } = await entry.load();
localeActions.setMessage(code, messages);
registered.add(code);
}
// Register before selecting, or the interface flashes English first.
localeActions.setLang(code);
}
export function LanguagePicker() {
const { lang } = useLocale();
const [pending, setPending] = useState(null);
async function onSelect(code: string) {
setPending(code);
try {
await selectLanguage(code);
} catch {
// A failed chunk leaves the current language in place rather than
// dropping the reader back to English.
} finally {
setPending(null);
}
}
return (
);
}
```
### Match the browser language
`navigator.language` is a BCP 47 tag (`pt-BR`, `de-AT`, `ja`), which does not
line up with the codes above: three of them use an underscore. Match the exact
tag first, then fall back to the base language:
```ts
/** Reuses LANGUAGES and selectLanguage from the previous example. */
function matchBrowserLanguage(tag: string) {
const wanted = tag.toLowerCase().replace('_', '-');
const base = wanted.split('-')[0];
const normalize = (code: string) => code.toLowerCase().replace('_', '-');
return (
LANGUAGES.find((language) => normalize(language.code) === wanted) ??
LANGUAGES.find((language) => normalize(language.code).split('-')[0] === base)
);
}
// In a client-only effect, or wherever you initialize the editor:
const match = matchBrowserLanguage(navigator.language);
if (match) {
await selectLanguage(match.code);
}
```
Base matching is approximate where one base language ships in a single variant:
`zh-TW` falls back to `zh_CN`, so a reader in Taiwan sees Simplified Chinese.
Map those cases explicitly if it matters to your users.
### Compatibility entry
Existing imports from `ai-sparkwrite-editor/locale-bundle` still work and register all included languages automatically. Use that entry when you need all languages; use `/locale` and individual dictionaries to avoid loading unused translations. Both entries share the same locale state.
## Override existing messages
`setMessage` merges the supplied keys into the language's current messages. You can override a single label without copying the entire dictionary:
```ts
import { localeActions } from 'ai-sparkwrite-editor/locale';
localeActions.setMessage('en', {
'editor.remove': 'Delete',
});
```
## Add a language
Start from the exported English dictionary, override the keys you have translated, then select the new language:
```ts
import { en, localeActions } from 'ai-sparkwrite-editor/locale';
localeActions.setMessage('fr', {
...en,
'editor.remove': 'Supprimer',
});
localeActions.setLang('fr');
```
The English spread is optional: missing translations fall back to the current English messages, then to the message key if English also has no value. Register messages before selecting a new code to avoid showing English while its dictionary loads.
## Use translations in custom controls
```tsx
import { useLocale } from 'ai-sparkwrite-editor/locale';
export function RemoveLabel() {
const { t } = useLocale();
return {t('editor.remove')};
}
```
For messages containing placeholders such as `{count}`, pass a values object as the second argument to `t`. Preserve placeholder names when translating. The exported `en` object and TypeScript completion provide the available message keys.
---
# Toolbar
A toolbar is a layout you compose from `RichText*` controls. Its button order follows your JSX. Each control reads the editor from `RichTextProvider`, so you do not pass an `editor` prop to individual buttons.
Start with the working editor in [Getting Started](/guide/getting-started). To add headings and lists, install `@tiptap/extension-list` at the same version as your other Tiptap packages, then add these imports:
```tsx
import { ListItem } from '@tiptap/extension-list';
import { Heading, RichTextHeading, BulletList, RichTextBulletList } from 'ai-sparkwrite-editor';
```
Extend the existing `extensions` array with `Heading.configure({ levels: [1, 2, 3] })`, `ListItem`, and `BulletList`. Register each extension only once. Then replace the toolbar JSX with:
```tsx
```
The other controls in this example come from Getting Started. Select text to apply inline formatting; place the cursor in a paragraph to turn it into a heading or list.
## Keep the top row short
Every control is a separate component, so nothing stops you rendering all of
them — and nothing stops the row wrapping into four lines of identical grey
icons either. Mature editors all resolve this the same way: a single row of the
controls used constantly, and an overflow button for the rest. Google Docs has
"More", Word has the ribbon overflow, TinyMCE a chevron.
A workable split is undo/redo, block type, font family, the inline marks,
lists and alignment, then link, image, table and code block — roughly eighteen
controls. Everything else goes behind one button, grouped under small labels:
text options, blocks, embeds, import and export, tools. Separate the groups in
the main row with a thin rule so the eye can find them.
Two things are worth knowing before you build the panel behind that button.
Name the controls rather than relying on tooltips — the point of the panel is
that these are the things nobody recognises by icon. And give the control a
fixed-width slot before the name: the controls are not one width (`RichTextFontSize`
is a text trigger, `RichTextIndent` is a pair of buttons, several carry a
chevron), so a plain `icon + label` row leaves the column of names visibly
ragged. Put a control too wide for the slot on a full-width row with the
control at the far end, keeping the name at the same indent.
Also avoid two text triggers side by side in the main row: font family and font
size both read "Default" until they are used, and next to each other they are
indistinguishable.
The playground's `RichTextToolbar` implements all of this and is a reasonable
starting point to copy.
## Extension options and button placement
Configure behavior in the extension array, for example `Heading.configure({ levels: [1, 2, 3] })`. Render its control as ` ` inside the provider. Removing that control only removes the toolbar entry; the registered extension still parses content and exposes commands.
Some controls need companion extensions:
| Control | Register |
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `RichTextBulletList`, `RichTextOrderedList` | Corresponding list extension plus `ListItem`. |
| `RichTextColor`, `RichTextFontFamily`, `RichTextFontSize`, `RichTextLineHeight` | Corresponding extension plus `TextStyle`. |
| `RichTextTaskList` | `TaskList`; it includes `TaskItem`. |
| `RichTextTable` | `Table`; it includes row, cell, and header extensions. |
| `RichTextColumn` | `Column`, `ColumnNode`, `MultipleColumnNode`, and the document schema described on the [Column page](/extensions/Column/). |
## Build a custom control
You can call editor commands from an ordinary React button. This example reads the provider's context and subscribes to the state it displays (in Vue, `RichTextToolbarButton`, `useEditorInstance` and `useEditorState` from `ai-sparkwrite-editor/vue` do the same):
```tsx
import { useCurrentEditor, useEditorState } from '@tiptap/react';
export function CustomBoldButton() {
const { editor } = useCurrentEditor();
const state = useEditorState({
editor,
selector: ({ editor }) => ({
active: editor?.isActive('bold') ?? false,
enabled: Boolean(editor?.isEditable && editor.can().toggleBold()),
}),
});
return (
);
}
```
Register `Bold` and render ` ` inside `RichTextProvider`. `focus()` returns focus to the document before formatting. `type="button"` prevents accidental form submission.
## Keyboard shortcuts
A documented `shortcutKeys` option supplies shortcut labels to controls. Changing those labels does not generally register a new key binding. To change behavior, extend the extension's `addKeyboardShortcuts` method:
```tsx
import { Bold } from 'ai-sparkwrite-editor';
const CustomBold = Bold.extend({
addKeyboardShortcuts() {
return {
...this.parent?.(),
'Mod-Shift-b': () => this.editor.commands.toggleBold(),
};
},
}).configure({ shortcutKeys: ['mod', 'shift', 'B'] });
```
Use `CustomBold` in place of `Bold`. This example preserves inherited shortcuts and adds another one. `Mod` represents Command on macOS and Control on Windows/Linux.
For controls that appear only around a selection, see [Bubble Menu](/guide/bubble-menu).
---
# AI
The model writes **into the document**, not into a chat window. Answers stream in as Markdown and are rendered through the editor's own schema, so a `|` table becomes the editor's table, a fenced block a code block, `- [ ]` a task list — real nodes you can keep editing, never pasted text.
There are five ways in, and they share one extension:
| Entry point | What happens |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Select text → Improve** | The selection menu rewrites, shortens, translates, explains, turns the text into a table or a list; preview, then Apply. |
| **Space on an empty line** | Opens Ask AI at the caret (Notion's gesture). `spaceTrigger: false` turns it off. |
| **`/ai`, `/continue`** | Slash entries: Ask AI, Continue writing, open the composer. |
| **The AI toolbar button, `⌘/Ctrl+J`** | Opens the **composer dock** under the editor: type what you want, the answer streams into the page above. Keep, undo, refine. |
| **Ghost text while typing** (`AIAutocomplete`) | After a pause at the end of a block the next few words appear in grey; Tab keeps them, Escape or typing dismisses. |
## Setup
```tsx
import {
AI,
AIAutocomplete,
RichTextAI,
RichTextAIComposer,
SlashCommand,
SlashCommandList,
RichTextBubbleText,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [
// Document, Paragraph, Text, History, …
AI.configure({ endpoint: '/api/ai' }), // your backend; which provider and model answer is its business
AIAutocomplete, // optional: ghost-text suggestions
SlashCommand,
];
// Inside :
// …
//
// ← the dock, opened by RichTextAI / ⌘J / "/ai"
// ← includes the Improve menu
//
```
`endpoint` is all the frontend needs: the editor POSTs the conversation as JSON to that URL and reads the answer back, streamed or not — see [Your endpoint](#your-endpoint) for the contract. No protocol, model or key is configured in the browser. A direct call to OpenAI or Anthropic and a fully custom `generate` remain available for experiments and special transports.
## The composer dock
`RichTextAIComposer` sits under the editor and writes straight into it. It opens from the `RichTextAI` toolbar button, `⌘/Ctrl+J`, the `/ai` slash entry, the Improve menu ("Open AI composer") or `editor.commands.toggleAIComposer(true)`.
- **Chips** run the document-level presets: Continue writing, Summarize, Outline, Suggest a title, Action items (a task list of every decision and open question), Fix grammar everywhere, Translate document. Each knows where its answer goes (end, top, caret, or the whole document).
- **The prompt** takes anything; a target selector chooses _Replace selection_ / _At cursor_ / _Top_ / _End_ / _Whole document_. Images and text files go with it — the paperclip, a drop onto the box, or a paste — under the same `enableImageInput` / `enableFileInput` / `maxAttachmentSize` rules as the panel.
- **The chips stay on one line.** Whatever does not fit sits behind a `+N` button. Hovering a chip shows the exact prompt it sends; the prompts are the `prompt` fields of `AI_COMPOSER_ACTIONS`.
- **While the answer streams** the span it is filling is tinted and follows edits made elsewhere; Stop keeps what has arrived.
- **Afterwards**: Keep, Undo (puts the original back), Retry, or type a follow-up — the same span is rewritten with the conversation so far. The finished answer is a single undo step.
The presets are data, so you can change them:
```tsx
import { AI_COMPOSER_ACTIONS, RichTextAIComposer } from 'ai-sparkwrite-editor';
;
```
### Turning the composer off, or restyling it
`AI.configure({ composer: false })` removes the dock **and every way in**: the `RichTextAI` toolbar button renders nothing, `⌘/Ctrl+J` and `/ai composer` disappear, the Improve menu loses "Open AI Composer", and `toggleAIComposer()` is a no-op. The selection menu, Space-to-ask and autocomplete are unaffected.
The dock itself takes props (same names in Vue):
| Prop | Default | Effect |
| -------------------- | --------------------- | ----------------------------------------------------------- |
| `actions` | `AI_COMPOSER_ACTIONS` | The chips; `[]` hides the row |
| `showTarget` | `true` | The "where the text goes" selector |
| `hint` | `true` | Footer line; `false` hides it, a string replaces it |
| `placeholder` | locale string | Prompt box placeholder |
| `rows` | `2` | Visible lines of the prompt box |
| `gradient` | `true` | Gradient border and background wash; `false` is a flat dock |
| `accent` | `#804dff` | Accent colour; sets the `--ai-accent` variable |
| `className`, `style` | — | Passed to the root |
For finer control the stylesheet exposes `--ai-accent`, `--ai-accent-2`, `--ai-accent-3` on `.richtext-ai-composer`, and the parts are plain classes: `richtext-ai-composer-head` (chips or the Keep/Undo/Retry verdict, plus the close button), `-chipline`, `-chips`, `-more`, `-menu`, `-box` (the prompt), `-input`, `-bar` (target selector and send), `-send`, `-close`, `-foot`, `-hint`.
### Writing into the document from your own UI
The engine behind the dock is exported and framework-free:
```ts
import { writeWithAI } from 'ai-sparkwrite-editor';
// or 'ai-sparkwrite-editor/core'
const result = await writeWithAI(editor, {
prompt: 'Turn the meeting notes into a table of decisions.',
target: 'selection', // 'selection' | 'cursor' | 'start' | 'end' | 'document' | { from, to }
signal: controller.signal,
onProgress: (markdown) => console.log(markdown.length),
});
result.keep(); // or result.discard() to put the original back
// result.messages is the conversation; pass it as `history` to refine.
```
Document-level targets (`cursor`, `start`, `end`) send the document as Markdown context, trimmed to `documentContext` characters (default 12 000; `0` sends none). `selection` and `document` send the affected text itself — as Markdown when it spans more than one block, so structure survives a rewrite.
## Ghost-text autocomplete
`AIAutocomplete` is a separate extension so it costs nothing unless registered. Options: `enabled` (start on/off; `toggleAIAutocomplete()` flips it), `delay` (900 ms of quiet), `minChars` (24 characters in the block), `contextChars` (1 500 sent), `maxTokens` (48), `prompt`. It uses the `AI` extension's transport, asks only when the caret is at the end of a non-code block and the editor is focused, and drops stale answers. `acceptAISuggestion()` and `dismissAISuggestion()` are commands; Tab and Escape are bound.
## Improve selected text
`RichTextBubbleText` includes **Improve** when AI is registered: _Edit selection_ (improve writing, fix spelling & grammar, shorter, longer, simplify, change tone) and _Generate_ (summarize, explain, **turn into table**, **turn into list**, translate). Rewrites keep the original language, so translation is one entry that targets the reader's browser language; set `translateLanguages: ['English', 'Deutsch']` for a fixed submenu. **Ask AI anything** opens an empty prompt. It works after Select All too.
The panel opens in the document flow under the selection, streams the answer with the document's own styles, and offers Retry, Discard and Apply. Enter sends, Shift+Enter is a newline, Escape closes. Programmatically: `editor.commands.openAI('Make this more concise.')`.
Presets are plain prompts; a custom `buttonBubble` can place `RichTextAIImprove` (from `ai-sparkwrite-editor/bubble/ai`) anywhere.
## Your endpoint
The frontend never knows which provider or model answers. For every request it sends:
```http
POST /api/ai
Content-Type: application/json
{
"messages": [{ "role": "user", "content": "Summarize:\n\n…", "attachments": [] }],
"systemPrompt": "You are a writing assistant…",
"stream": true,
"maxTokens": 2048
}
```
`messages` is the conversation so far (`user` / `assistant` turns; text files the user attached are already inlined into `content`, images travel on `attachments` as data URLs). `stream` is `true` whenever the UI can render deltas as they arrive.
Answer with either:
- **JSON** — `{ "text": "…markdown…" }` (`content` or `markdown` are accepted too), or
- **Server-sent events** (`Content-Type: text/event-stream`) — one `data: {"text":"…"}` event per delta, `data: [DONE]` at the end.
A plain-text body, or an OpenAI Chat Completions / Anthropic Messages response or stream piped through unchanged, is read as well. So the simplest server is a proxy that adds the key and forwards the provider's stream:
```ts
// Node / Express with the OpenAI SDK — any provider or agent framework works the same way.
app.post('/api/ai', async (req, res) => {
const { messages, systemPrompt, stream, maxTokens } = req.body;
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
max_completion_tokens: maxTokens,
messages: [
{ role: 'system', content: systemPrompt },
...messages.map(({ role, content }) => ({ role, content })),
],
stream,
});
if (!stream) return res.json({ text: completion.choices[0].message.content });
res.setHeader('Content-Type', 'text/event-stream');
for await (const chunk of completion) {
const text = chunk.choices[0]?.delta?.content;
if (text) res.write(`data: ${JSON.stringify({ text })}\n\n`);
}
res.write('data: [DONE]\n\n');
res.end();
});
```
`headers` adds request headers (a CSRF token, a tenant id); cookies travel with same-origin requests as usual. A failed request is shown as a generic message with the status code — the response body is never displayed, so a proxy cannot leak credentials through it. `stream: false` on the extension always asks for a JSON answer.
## Direct provider calls and custom transports
Without `endpoint` the extension speaks [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat) or [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create) itself: `protocol`, `model`, optional `baseURL` (API root including `/v1`) and `apiKey` (a string or an async getter). A key in a browser bundle is visible to the browser, so keep this for local experiments or a same-origin proxy that injects the key (`baseURL: '/api/openai'`, no `apiKey`).
`generate(request, onChunk)` replaces the transport entirely — for an SDK client, a WebSocket, or an agent framework:
```tsx
AI.configure({
generate: async ({ messages, systemPrompt, signal }, onChunk) => {
const response = await fetch('/api/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, systemPrompt }),
signal,
});
if (!response.ok) throw new Error('Unable to generate text.');
let text = '';
for await (const chunk of readLines(response.body)) {
text += chunk;
onChunk?.(chunk); // streams into the panel / document
}
return text; // the full answer
},
});
```
`generate` receives attachments on `message.attachments`; call `onChunk` with each piece of text to stream and resolve with the full text. Error messages you throw are shown in the UI.
## Options
| Option | Default | Purpose |
| ---------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `endpoint` | `''` | **The recommended setup.** Your backend URL: receives `{ messages, systemPrompt, stream, maxTokens }` as JSON, answers `{ text }` or a `text/event-stream` of `{ text }` deltas |
| `generate` | `null` | Custom transport `(request, onChunk?) => Promise`; replaces `endpoint` and the provider calls |
| `protocol` | `'openai'` | Direct provider calls only: OpenAI Chat Completions or Anthropic Messages |
| `model` | `''` | Direct provider calls only: model ID |
| `apiKey` | `''` | Direct provider calls only: key or async key getter |
| `baseURL` | provider `/v1` root | Direct provider calls only: API root including `/v1` |
| `maxTokens` | `2048` | Maximum generated tokens |
| `headers` | `{}` | Extra or overridden request headers |
| `systemPrompt` | writing-assistant prompt | Asks for the user's language and Markdown-only output |
| `stream` | `true` | Ask for server-sent events; `false` waits for a JSON answer |
| `spaceTrigger` | `true` | Space on an empty line opens Ask AI |
| `documentContext` | `12000` | Characters of the document sent with document-level prompts |
| `serializeDocument` | the editor's Markdown export | `(editor, range) => string`: how a span is turned into text for the model. Multi-block spans and the whole document go as Markdown so tables, lists and code keep their shape; override to redact or reformat |
| `translateLanguages` | `[]` | Fixed Translate targets; empty means the browser language |
| `enableImageInput` | `true` | Attach images (the model has to accept them) |
| `enableFileInput` | `true` | Attach text files, inlined into the prompt |
| `imageMimes`, `fileMimes`, `maxAttachmentSize` | see source | Accepted attachment types and size (4 MB) |
| `renderResult` | — | React: replace how the panel shows the answer (`{ markdown, html, streaming }`) |
| `components.Panel` | — | React: replace the whole panel |
| `mountPanel` | React/Vue renderer | Framework hook: `(mount, props) => unmount`; the core entry ships it as `null` |
Only the selected text (or the document context you allow), your prompt and successful turns are sent. Document edits during a panel session — including collaborative ones — close the session and abort its request. Closing, stopping or destroying the editor aborts requests.
## Streaming and rich answers
With `endpoint` the deltas are the `data: {"text"}` events your server sends; with direct provider calls the provider is asked for server-sent events (OpenAI `stream: true`, Anthropic `content_block_delta`). Each delta is shown as it arrives; `stream: false` waits for the whole answer. The answer is Markdown rendered **through the editor's own schema**: the preview is the exact HTML the editor would save, with the document's styles, and Apply inserts real nodes. A single-paragraph answer merges into the paragraph being edited; anything with block structure replaces whole blocks. Unknown tags, scripts and attributes are dropped on the way in.
## Custom rendering (React)
- `renderResult({ markdown, html, streaming })` replaces only how the answer is shown — your own Markdown component, a word count, a diff against the selection.
- `components.Panel` replaces the whole dialog. It receives `editor`, `options`, `selectedText`, `initialPrompt`, `apply(markdown)` and `close()`; call `generateAIText(options, request, onChunk)` for the transport.
The helpers are exported: `markdownToHTML`, `markdownToFragment(editor, md)`, `markdownToSlice(editor, md)`, `markdownToPreviewHTML(editor, md)`.
## Core and Vue
`ai-sparkwrite-editor/core` exports the same extension as `AI` (headless: commands, decorations, `writeWithAI`, `AIAutocomplete`, `AI_COMPOSER_ACTIONS`, `generateAIText`, the Markdown helpers) with `mountPanel: null` — supply your own to mount a panel in any framework. `ai-sparkwrite-editor/vue` exports `AI` with the Vue panel plus `RichTextAI`, `RichTextAIComposer`, `RichTextAIImprove` and `RichTextBubbleText`; see [Frameworks](/guide/frameworks).
All strings go through the locale system (`editor.ai.*`, `editor.ai.compose.*`); the playground answers itself without a key so the whole flow can be tried (`VITE_AI_MODEL` etc. switch to a real model, see `playground/.env.example`).
---
# Attachment
Insert a downloadable file card with an application-provided upload handler.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Attachment, RichTextAttachment } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
async function uploadAttachment(file: File): Promise {
const body = new FormData();
body.append('file', file);
const response = await fetch('/api/attachments', { method: 'POST', body });
if (!response.ok) throw new Error('Attachment upload failed');
const data = await response.json();
if (typeof data.url !== 'string' || !data.url) {
throw new Error('Upload response must contain a URL');
}
return data.url;
}
const extensions = [Document, Paragraph, Text, Attachment.configure({ upload: uploadAttachment })];
export default function AttachmentExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Click the attachment button to add a placeholder, then choose a file in that card. The upload callback receives one `File` and must resolve with its download URL. The endpoint in this example is yours to implement; it must return `{ "url": "https://..." }`. Save the document after uploading completes.
---
# Blockquote
Wrap paragraphs in a blockquote to distinguish quoted material.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Blockquote, RichTextBlockquote } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Blockquote];
export default function BlockquoteExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Place the cursor in a paragraph or select several paragraphs, then click the blockquote button. Click it again to lift the content out of the quote. The command is `editor.chain().focus().toggleBlockquote().run()`.
---
# Bold
Apply bold emphasis to selected text, or enable bold before typing.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Bold, RichTextBold } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Bold];
export default function BoldExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Select a word and click **Bold**. Click again to remove the mark. Use `editor.chain().focus().toggleBold().run()` to trigger it from your own control.
## Options
### shortcutKeys
Type: `string[]`\
Default: `['mod', 'B']`
Shortcut labels shown by the controls. See [keyboard shortcut configuration](/guide/toolbar#keyboard-shortcuts) to change actual key bindings.
---
# BulletList
Organize paragraphs into an unordered list.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). Install `@tiptap/extension-list` at the same version as your other Tiptap packages. The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, BulletList, RichTextBulletList } from 'ai-sparkwrite-editor';
import { ListItem } from '@tiptap/extension-list';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, ListItem, BulletList];
export default function BulletListExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Register `ListItem` alongside `BulletList`; it defines the content of each list entry. Place the cursor in a paragraph and click the list button. Use `editor.chain().focus().toggleBulletList().run()` from a custom control.
## Options
### shortcutKeys
Type: `string[]`\
Default: `['shift', 'mod', '8']`
Shortcut labels shown by the controls. See [keyboard shortcut configuration](/guide/toolbar#keyboard-shortcuts) to change actual key bindings.
---
# Callout
Group text in a visually distinct callout box.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import {
RichTextProvider,
Callout,
RichTextCallout,
RichTextBubbleCallout,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Callout];
export default function CalloutExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
::: tip Vue
Not in the Vue layer yet: `RichTextBubbleCallout` — run the corresponding command from your own control, or see [Frameworks](/guide/frameworks).
:::
## How to use
Open the toolbar dialog, choose a callout type, enter its title and body, and apply it. Mount `RichTextBubbleCallout` for contextual editing. The callout is an atomic node with `type`, `title`, and `body` attributes, rather than a container of nested editor blocks.
## Insert from code
With a non-null editor, you can insert a callout directly:
```ts
editor
.chain()
.focus()
.setCallout({
type: 'tip',
title: 'Save your work',
body: 'Use the Save button before leaving this page.',
})
.run();
```
The built-in dialog offers `note`, `tip`, `important`, `warning`, and `caution` types.
---
# Clear
Remove text marks and reset block formatting in the current selection.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Clear, RichTextClear } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Clear];
export default function ClearExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Select the content to reset and click the eraser. This runs `editor.chain().focus().clearNodes().unsetAllMarks().run()`: it clears formatting, not the document’s text. To intentionally empty the document, use Tiptap’s `editor.commands.clearContent()`.
---
# Code
Format a short piece of text as inline code.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Code, RichTextCode } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Code];
export default function CodeExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Select text such as a variable name and click the code button. For multiple lines with a language selector, use [Code Block](/extensions/CodeBlock/) instead. The inline command is `editor.chain().focus().toggleCode().run()`.
## Options
### shortcutKeys
Type: `string[]`\
Default: `['mod', 'E']`
Shortcut labels shown by the controls. See [keyboard shortcut configuration](/guide/toolbar#keyboard-shortcuts) to change actual key bindings.
---
# CodeBlock
Insert a multi-line code block with syntax highlighting and language selection.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, CodeBlock, RichTextCodeBlock } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, CodeBlock];
export default function CodeBlockExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Click the toolbar button to insert a plain-text code block. Each block renders its own toolbar in the top-right corner — language picker, copy and delete — revealed on hover, so there is nothing extra to mount. Register this extension in place of any other `codeBlock` extension to avoid duplicate node names.
---
# CodeView
Toggle between rich text and editable HTML source in the document area.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, CodeView, RichTextCodeView } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, CodeView];
export default function CodeViewExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
::: tip Vue
Not in the Vue layer yet: `RichTextCodeView` — run the corresponding command from your own control, or see [Frameworks](/guide/frameworks).
:::
## How to use
Click the toolbar button to show serialized HTML as text in the editor. Edit it, then click again to parse it back into rich text. Both transitions replace editor content and emit updates. Return to rich-text mode before saving; saving while source mode is active would store the source-text document. Unsupported markup may be removed by the active schema. This is an HTML source view, not the [Code Block](/extensions/CodeBlock/) feature for displaying code in a document.
---
# Color
Apply a text color to the current selection.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). Install `@tiptap/extension-text-style` at the same version as your other Tiptap packages. The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Color, RichTextColor } from 'ai-sparkwrite-editor';
import { TextStyle } from '@tiptap/extension-text-style';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, TextStyle, Color];
export default function ColorExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Register `TextStyle` because color is stored as a text-style attribute. Select text and use the color picker. The commands are `editor.chain().focus().setColor("#2563eb").run()` and `editor.chain().focus().unsetColor().run()`.
## Configure the palette
Use this configuration in place of `Color` in your extension array:
```ts
import { Color } from 'ai-sparkwrite-editor';
Color.configure({
colors: ['#dc2626', '#16a34a', '#2563eb', '#262626'],
defaultColor: '#2563eb',
});
```
| Option | Purpose | Default |
| -------------- | ---------------------------------------- | ------------------------------ |
| `colors` | Palette entries shown in the picker. | Built-in palette when omitted. |
| `defaultColor` | Initial color for the keyboard action. | None. |
| `shortcutKeys` | Shortcut label displayed by the control. | `['⇧', 'alt', 'C']`. |
The actual keyboard binding is **Alt-Shift-C**. It applies the last chosen color, or removes it if the whole selection already has that color. Without a chosen/default color, it removes an existing text color or leaves uncolored text unchanged.
Changing `shortcutKeys` changes the label, not the binding. See [custom keyboard shortcuts](/guide/toolbar#keyboard-shortcuts).
## Programmatic formatting
With a registered Color extension and a non-null editor:
```ts
editor.chain().focus().setColor('#2563eb').run();
editor.chain().focus().unsetColor().run();
const currentColor = editor.getAttributes('textStyle').color;
```
Color changes text foreground. For a colored background behind text, use [Highlight](/extensions/Highlight/).
---
# Column
Arrange document blocks in a multi-column layout.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import {
RichTextProvider,
Column,
ColumnNode,
MultipleColumnNode,
RichTextColumn,
RichTextBubbleMenuDragHandle,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const DocumentColumn = Document.extend({ content: '(block|columns)+' });
const extensions = [DocumentColumn, Paragraph, Text, Column, ColumnNode, MultipleColumnNode];
export default function ColumnExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
:::
::: warning React only
This feature's interactive UI depends on React libraries; the Vue layer does not include it yet. See [Frameworks](/guide/frameworks) for what the Vue entry covers.
:::
## How to use
Register all three exports: `Column` supplies behavior, while `ColumnNode` and `MultipleColumnNode` define the layout nodes. Replace the base Document with `DocumentColumn` as shown below; do not register both. Column controls (insert column before/after, delete column) live in the block menu of `RichTextBubbleMenuDragHandle`: hover any block inside a column and open the menu.
---
# Details
Collapsible toggle blocks with a summary line and hidden content, similar to Notion toggles.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Details, RichTextDetails } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Details];
export default function DetailsExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
`Details` registers three nodes at once: `details`, `detailsSummary` and `detailsContent`, so you only add the single extension. Press the toolbar button (or `Mod-Alt-D`) to wrap the current block in a toggle, and press it again inside a toggle to unwrap it. Type `/toggle` to insert one from the slash menu.
Click the chevron to open or close the block. The open state is stored in the document (`persist: true`), so it survives save and reload as ``.
Keyboard behaviour inside the toggle:
- `Enter` in the summary creates a paragraph inside the content when the block is open, or after the block when it is closed.
- `Backspace` at the start of an empty summary unwraps the toggle.
- `Enter` on the last empty paragraph of the content exits the toggle.
## Insert from code
With a non-null editor, you can wrap the current selection or unwrap it:
```ts
editor.chain().focus().setDetails().run();
editor.chain().focus().unsetDetails().run();
```
## Options
```ts
Details.configure({
// keep the open state in the document (default: true)
persist: true,
// class added to the wrapper while open (default: 'is-open')
openClassName: 'is-open',
HTMLAttributes: { class: 'details' },
// forwarded to the nested nodes
summary: { HTMLAttributes: { class: 'details-summary' } },
content: { HTMLAttributes: { class: 'details-content' } },
});
```
---
# Divider
One block, many looks: a plain rule, dashed, dotted, double, a short centred line, three dots, stars, a rule with text in the middle, or a numbered one. Replaces `HorizontalRule`; register one or the other, not both.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Divider, RichTextDivider } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Divider];
export default function DividerExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
The toolbar button opens a menu of styles with a preview of each. `/divider` (also `/hr`) and ⌘/Ctrl ⌥/Alt S insert the `defaultVariant`. Click a divider to select it: a small picker appears above it to switch styles in place.
Two variants are editable:
- **Text** shows an input in the middle of the rule. Type a caption ("Chapter 2", "Part II"); Enter leaves the input and continues below.
- **Numbered** shows its ordinal among the numbered dividers in the document. Moving or deleting one renumbers the rest; the number is stored in `label` so saved HTML and exports carry it.
Commands:
```ts
editor.chain().focus().setDivider({ variant: 'dashed' }).run();
editor.chain().focus().setDivider({ variant: 'text', label: 'Chapter 2' }).run();
editor.commands.updateDivider({ variant: 'stars' }); // acts on the selected divider
```
## Saved HTML
```html
Chapter 2
```
The `
` stays inside so the rule still shows where the stylesheet is not loaded, in Word exports and in feeds that strip classes. Markdown export writes `---`; captions have no markdown form. `
` and the old `` markup are read back as a `line` divider, so existing documents open unchanged.
## Options
### variants
Type: `{ value: string; label?: string; editable?: boolean }[]`\
Default: the nine built-in variants, `text` editable
The styles offered, in menu order. Remove entries to offer fewer; add your own `value` and style `.divider--` in your CSS. `editable` shows the label input. `label` overrides the menu text (built-in values are translated).
```ts
Divider.configure({
variants: [
{ value: 'line' },
{ value: 'text', editable: true },
{ value: 'wave', label: 'Wave' }, // styled by your CSS
],
});
```
### defaultVariant
Type: `string`\
Default: `'line'`
Inserted by the toolbar button, the slash command and the shortcut.
### renderDivider
Type: `(attrs: { variant: string; label: string | null }) => DOMOutputSpec`\
Default: the markup above
Replaces the saved HTML. Pair it with `parseRules` so the same markup reads back:
```ts
Divider.configure({
renderDivider: ({ variant, label }) => [
'hr',
{ class: `sep sep-${variant}`, 'data-label': label ?? '' },
],
parseRules: [
{
tag: 'hr.sep',
getAttrs: (el) => ({
variant: (el as HTMLElement).className.replace(/.*sep-(\S+).*/, '$1'),
label: (el as HTMLElement).getAttribute('data-label') || null,
}),
},
],
});
```
### parseRules
Type: `ParseRule[]`\
Default: `[]`
Extra rules tried before the built-in ones.
### shortcutKeys
Type: `string[]`\
Default: `['mod', 'alt', 'S']`
Shortcut labels shown by the controls. See [keyboard shortcut configuration](/guide/toolbar#keyboard-shortcuts) to change actual key bindings.
---
# Drawer
Create freehand drawings and insert them into the document.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import {
RichTextProvider,
Drawer,
RichTextDrawer,
RichTextBubbleDrawer,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
import 'easydrawer/styles.css';
const extensions = [Document, Paragraph, Text, Drawer];
export default function DrawerExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
:::
::: warning React only
This feature's interactive UI depends on React libraries; the Vue layer does not include it yet. See [Frameworks](/guide/frameworks) for what the Vue entry covers.
:::
## How to use
Load `easydrawer/styles.css` alongside the editor stylesheet. Open the drawing dialog, draw, and apply the result. You can provide an `upload` callback resolving to a durable URL to store the generated SVG remotely. Install `easydrawer` directly if needed to resolve its CSS import.
## Loading behavior
The drawing canvas loads when a create or edit dialog opens. The first open may show a loading placeholder, and a failed module load offers a retry action. Keep the stylesheet import above so the canvas is styled when it becomes available.
---
# Emoji
Insert emoji using a picker or suggestions.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Emoji, RichTextEmoji } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Emoji];
export default function EmojiExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
:::
::: warning React only
This feature's interactive UI depends on React libraries; the Vue layer does not include it yet. See [Frameworks](/guide/frameworks) for what the Vue entry covers.
:::
## How to use
Open the toolbar picker and choose an emoji. The extension supplies its emoji data and suggestion UI; no upload endpoint or separate toolbar provider is needed.
- Copy Emoji List here: https://github.com/ludejun/ai-sparkwrite-editor-demo/blob/master/src/components/Editor/emojis.ts
## Loading behavior
The toolbar picker UI loads when the popover first opens. The extension still includes its full emoji dictionary for schema behavior, shortcodes, and document round trips; deferring the picker does not remove that dictionary. If you provide a separate suggestion dataset, you can dynamically import it from an asynchronous `suggestion.items` callback.
---
# Excalidraw
Create and insert Excalidraw drawings.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import {
RichTextProvider,
Excalidraw,
RichTextExcalidraw,
RichTextBubbleExcalidraw,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
import '@excalidraw/excalidraw/index.css';
const extensions = [Document, Paragraph, Text, Excalidraw];
export default function ExcalidrawExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
:::
::: warning React only
This feature's interactive UI depends on React libraries; the Vue layer does not include it yet. See [Frameworks](/guide/frameworks) for what the Vue entry covers.
:::
## How to use
Load `@excalidraw/excalidraw/index.css` alongside the editor stylesheet. Open the toolbar dialog, create a drawing, and apply it. Mount `RichTextBubbleExcalidraw` for contextual actions. Install `@excalidraw/excalidraw` directly if needed to resolve its CSS import.
---
# Export Markdown
Download the current document as a `.md` file, or get the markdown string to send to your backend.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import {
RichTextProvider,
Bold,
Heading,
ExportMarkdown,
RichTextExportMarkdown,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Bold, Heading, ExportMarkdown];
export default function ExportMarkdownExample() {
const editor = useEditor({
extensions,
content: 'Title
Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
::: tip Vue
Not in the Vue layer yet: `RichTextExportMarkdown` — run the corresponding command from your own control, or see [Frameworks](/guide/frameworks).
:::
## How to use
Press the toolbar button to download the document as markdown. The serializer (`@tiptap/markdown`) is loaded on demand the first time it is used, so it does not affect the initial bundle.
Every node is serialized:
| Content | Output |
| ------------------------------------------------------------------------- | ------------------------------------------ |
| Headings, paragraphs, bold, italic, strike, code, links, images, lists | Standard / GFM markdown |
| Task lists, tables, blockquotes, code blocks, horizontal rules, highlight | GFM markdown |
| Details (toggle) | `…
…` |
| Callout | GitHub alert (`> [!NOTE]`, `> [!TIP]`, …) |
| Table of contents block | `[TOC]` |
| Katex | `$formula$` |
| Attachment | `[file name](url)` |
| Twitter | `[url](url)` |
| Columns | Column contents one after another |
| Subscript / superscript | `` / `` |
| Video, iframe, mermaid, excalidraw, drawer and other custom nodes | Rendered as HTML so nothing is lost |
Text styles such as color, font size, font family, alignment and line height are dropped, as markdown has no equivalent.
## Use from code
```ts
import { getMarkdown } from 'ai-sparkwrite-editor';
// download
editor.chain().focus().exportToMarkdown({ fileName: 'notes.md' }).run();
// get the string (e.g. to save on your server)
const markdown = await getMarkdown(editor);
```
## Options
```ts
ExportMarkdown.configure({
// name of the downloaded file
fileName: 'richtext-export-document.md',
// indentation for nested lists
indentation: { style: 'space', size: 2 },
});
```
---
# Export PDF
Open the browser’s print flow for the editor content.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, ExportPdf, RichTextExportPdf } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, ExportPdf];
export default function ExportPdfExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
::: tip Vue
Not in the Vue layer yet: `RichTextExportPdf` — run the corresponding command from your own control, or see [Frameworks](/guide/frameworks).
:::
## How to use
Click the toolbar button, then choose the browser’s PDF destination if available. This uses browser printing rather than returning a PDF Blob. Configure paper size and margins below, and review the print preview because the browser controls the final output.
---
## Options
### paperSize
Type: `PaperSize`
Default: `'Letter'`
Specifies the size of the paper used when exporting to PDF.
Supported values:
```ts
type PaperSize = 'Legal' | 'Letter' | 'Tabloid' | 'A0' | 'A1' | 'A2' | 'A3' | 'A4' | 'A5';
```
### margins
Type:
```ts
{
top: PageMargin;
right: PageMargin;
bottom: PageMargin;
left: PageMargin;
}
```
Default:
```ts
{
top: '0.4in',
right: '0.4in',
bottom: '0.4in',
left: '0.4in'
}
```
Controls the page margins on all four sides. Values can be provided in inches (`in`), centimeters (`cm`), millimeters (`mm`), or points (`pt`).
Supported values:
```ts
type PageMargin =
// Inches
| '0in'
| '0.25in'
| '0.4in'
| '0.5in'
| '0.75in'
| '1in'
| '1.25in'
| '1.5in'
| '1.75in'
| '2in'
// Centimeters
| '0cm'
| '0.5cm'
| '1cm'
| '1.5cm'
| '2cm'
| '2.5cm'
| '3cm'
| '4cm'
| '5cm'
// Millimeters
| '0mm'
| '5mm'
| '10mm'
| '15mm'
| '20mm'
| '25mm'
| '30mm'
| '40mm'
| '50mm'
// Points
| '0pt'
| '18pt'
| '36pt'
| '54pt'
| '72pt'
| '90pt'
| '108pt'
| '144pt';
```
Example usage:
```ts
import { ExportPdf } from 'ai-sparkwrite-editor';
ExportPdf.configure({
paperSize: 'A4',
margins: {
top: '1in',
right: '0.4in',
bottom: '1in',
left: '0.4in',
},
});
```
---
# Export Word
Download the current document as a `.docx` file.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, ExportWord, RichTextExportWord } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, ExportWord];
export default function ExportWordExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
::: tip Vue
Not in the Vue layer yet: `RichTextExportWord` — run the corresponding command from your own control, or see [Frameworks](/guide/frameworks).
:::
## How to use
Click the toolbar button or call `editor.commands.exportToWord(editor.state.doc)`. The download uses `richtext-export-document.docx`. The current serializer excludes images and does not define mappings for every custom node or mark; test your document’s feature set before relying on Word export.
## Loading behavior
The Word serializer loads when export is requested. `exportToWord` returns a Tiptap command boolean immediately; it does not return a promise indicating that the download has finished. Serialization and download happen asynchronously, and failures are logged to the console. `editor.can().exportToWord(editor.state.doc)` does not load the serializer or start a download.
---
# Font Family
Choose the font family used by selected text.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). Install `@tiptap/extension-text-style` at the same version as your other Tiptap packages. The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, FontFamily, RichTextFontFamily } from 'ai-sparkwrite-editor';
import { TextStyle } from '@tiptap/extension-text-style';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, TextStyle, FontFamily];
export default function FontFamilyExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
::: tip Vue
Not in the Vue layer yet: `RichTextFontFamily` — run the corresponding command from your own control, or see [Frameworks](/guide/frameworks).
:::
## How to use
Register `TextStyle`. `fontFamilyList` controls the choices in the dropdown, but does not download fonts. Load web fonts in your application CSS, or choose fonts available on the reader’s device.
A plain string is used as both the label and the CSS value. A `{ name, value }`
entry separates them, which is what a cross-platform stack needs: the menu can
stay readable while the value lists one face per platform.
## Non-Latin scripts
Arial, Georgia, Times and the rest of the Latin defaults carry no Han,
Devanagari or Bengali glyphs, so applying one to Chinese or Hindi text changes
nothing visible — the browser substitutes a default face. The default list
therefore ends with the entries in `SCRIPT_FONT_FAMILY_LIST`:
| Script | Entries |
| ---------- | -------------------------------- |
| Chinese | 微软雅黑, 苹方, 黑体, 宋体, 楷体 |
| Japanese | ゴシック体, 明朝体 |
| Korean | 맑은 고딕 |
| Devanagari | देवनागरी |
| Bengali | বাংলা |
Each is named after the font readers know and resolves to a stack, so the
choice lands on that face where it exists and on the closest equivalent
elsewhere — 微软雅黑 gives Microsoft YaHei on Windows and PingFang SC on macOS,
the substitution every word processor has always done.
They are all system fonts by design: a webfont covering Han runs to several
megabytes even subset. To ship one anyway, load it in your CSS and add it to
`fontFamilyList` yourself.
### When they appear
Listing every script at all times buries the Latin fonts for readers who will
never use them. The picker shows a script's entries when **either** the
interface language uses that script **or** the document already contains it —
the document is sampled when the menu opens, so pasting Chinese into an
English-language editor brings the Chinese fonts back straight away. Entries
you add through `fontFamilyList` are never filtered.
The editor's own body text is unaffected by this list — it inherits the font of
the page it is embedded in. `system-ui` already falls back to a sensible CJK
face on every platform, so only an explicit choice needs these entries.
## Configuration
```ts
import { FontFamily } from 'ai-sparkwrite-editor';
FontFamily.configure({
fontFamilyList: ['Arial', 'Georgia', { name: 'Monospace', value: 'monospace' }],
});
```
Use this configured extension in place of the unconfigured one in `extensions`.
---
# Font Size
Choose the font size used by selected text.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). Install `@tiptap/extension-text-style` at the same version as your other Tiptap packages. The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, FontSize, RichTextFontSize } from 'ai-sparkwrite-editor';
import { TextStyle } from '@tiptap/extension-text-style';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, TextStyle, FontSize];
export default function FontSizeExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Register `TextStyle`. Configure `fontSizes` with CSS sizes such as `14px` or objects such as `{ name: "Large", value: "24px" }`. Use `editor.chain().focus().setFontSize("18px").run()` or `unsetFontSize()` from your own controls.
## Compact variant
` ` renders an icon button instead of a trigger
showing the current size. The wide trigger earns its space in a main toolbar,
where reading the size at a glance is the point; in a menu of named rows it is
the one control that will not line up, so the compact form matches the shape of
`RichTextLineHeight`.
## Configuration
```ts
import { FontSize } from 'ai-sparkwrite-editor';
FontSize.configure({
fontSizes: ['Default', '14px', '18px', { name: 'Large', value: '24px' }],
});
```
Use this configured extension in place of the unconfigured one in `extensions`.
---
# Format Painter
Copy inline formatting from one selection to another.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import {
RichTextProvider,
FormatPainter,
RichTextFormatPainter,
Bold,
RichTextBold,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Bold, FormatPainter];
export default function FormatPainterExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
:::
::: warning React only
This feature's interactive UI depends on React libraries; the Vue layer does not include it yet. See [Frameworks](/guide/frameworks) for what the Vue entry covers.
:::
## How to use
Register the mark extensions you want to copy, such as Bold, Italic, Color, or FontSize. The painter copies existing marks; it does not add those features by itself. The example includes Bold so you can format a source selection before copying it.
## Behavior
1. Select text that already has the formatting you want to copy.
2. Click the format painter button.
3. Select the target text.
4. The copied marks are applied to the target selection and the format painter turns off automatically.
Press `Escape` or click the button again to cancel the format painter state.
## Commands
### setPainter
Copies the current selection marks and enables format painter mode.
```ts
editor.commands.setPainter();
```
### unsetPainter
Cancels format painter mode.
```ts
editor.commands.unsetPainter();
```
---
# Heading
Turn a paragraph into a heading with a chosen level.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Heading, RichTextHeading } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Heading.configure({ levels: [1, 2, 3] })];
export default function HeadingExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Place the cursor in a paragraph and choose a level from the toolbar. `levels` controls the available heading levels; for example, `Heading.configure({ levels: [1, 2, 3] })`. Use `editor.chain().focus().toggleHeading({ level: 2 }).run()` for a custom action.
---
# Highlight
Apply a background highlight to selected text.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, Highlight, RichTextHighlight } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Highlight];
export default function HighlightExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Choose a color and select text to highlight. This extension enables multiple highlight colors by default. Set `Highlight.configure({ defaultColor: "#fef08a" })` to give the highlight shortcut an initial color. The commands are `setHighlight({ color: "#fef08a" })` and `unsetHighlight()`.
## Features
- 🎨 **Multiple Colors**: Support for multiple highlight colors
- ⌨️ **Keyboard Shortcuts**: Quick highlighting with `Mod-Shift-H`
- 🔄 **Smart Toggle**: Intelligent highlight toggling and replacement
- 🎯 **Synchronized Selection**: Color picker syncs between toolbar and bubble menu
- 🎨 **Custom Colors**: Add custom highlight colors via color picker
- 💾 **Recent Colors**: Automatically tracks recently used colors
- ❌ **No Fill Option**: Option to remove highlight
## Options
### defaultColor
Type: `string`\
Default: `undefined`
The default highlight color to use when the extension is initialized. This color will be used when applying highlight via keyboard shortcut for the first time.
```js
Highlight.configure({
defaultColor: '#ffff00', // Yellow
// or
defaultColor: '#ffc078', // Orange
});
```
### shortcutKeys
Type: `string[]`\
Default: `['⇧', 'mod', 'H']`
Shortcut label displayed by the control. The actual binding is `Mod-Shift-H` (Ctrl-Shift-H on Windows/Linux, Cmd-Shift-H on macOS). Changing this option does not rebind it; see [keyboard shortcuts](/guide/toolbar#keyboard-shortcuts).
```js
Highlight.configure({
shortcutKeys: ['⇧', 'mod', 'H'],
});
```
## Keyboard Shortcut Behavior
The `Mod-Shift-H` keyboard shortcut has intelligent toggle behavior:
1. **No highlight applied**: Applies the currently selected highlight color
2. **Same color already applied**: Removes the highlight (toggle off)
3. **Different color applied**: Replaces with the currently selected highlight color
4. **"No Fill" selected**: Does nothing (prevents applying undefined highlight)
## Color Selection Synchronization
The extension maintains a shared highlight color state across all instances:
- Selecting a color in the toolbar updates the bubble menu
- Selecting a color in the bubble menu updates the toolbar
- Keyboard shortcut uses the last selected color
- All color pickers show the same selected color
- Selecting "No Fill" clears the stored color
## Examples
### Basic Usage
```tsx
import { Highlight } from 'ai-sparkwrite-editor';
const extensions = [Highlight];
```
### With Default Color
```tsx
import { Highlight } from 'ai-sparkwrite-editor';
const extensions = [
Highlight.configure({
defaultColor: '#ffc078', // Orange highlight
}),
];
```
### Programmatic Usage
```tsx
// Apply highlight with color
editor.chain().focus().setHighlight({ color: '#ffff00' }).run();
// Remove highlight
editor.chain().focus().unsetHighlight().run();
// Toggle highlight (removes if same color, applies if different or none)
editor.chain().focus().toggleHighlight({ color: '#ffff00' }).run();
// Check if highlight is active
const isHighlightActive = editor.isActive('highlight');
// Check if specific color is active
const isYellowActive = editor.isActive('highlight', { color: '#ffff00' });
// Get current highlight color
const { color } = editor.getAttributes('highlight');
```
## Color Picker
The highlight color picker includes:
- **No Fill**: Remove highlight from text
- **Color Palette**: Predefined colors for quick selection
- **Recent Colors**: Last 10 used colors
- **Custom Color**: Pick any color using the color picker
## Differences from Color Extension
| Feature | Highlight | Color |
| ---------------- | ----------------------- | ------------------ |
| Purpose | Background highlighting | Text color |
| Default Shortcut | `Mod-Shift-H` | `Alt-Shift-C` |
| No Fill Behavior | Removes highlight | Removes text color |
| Visual Style | Background color | Foreground color |
---
# History
Undo and redo editing transactions.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, History, RichTextUndo, RichTextRedo } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, History];
export default function HistoryExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
## How to use
Register `History` once. It extends Tiptap 3’s `UndoRedo` extension, so do not also register `UndoRedo` or StarterKit’s undo history. The buttons become available when there is a change to undo or redo. Defaults are `depth: 100` and `newGroupDelay: 500` (milliseconds).
## Options
### shortcutKeys
Type: `string[][]`\
Default: `[['mod', 'Z'], ['shift', 'mod', 'Z']]`
Shortcut labels shown by the controls. See [keyboard shortcut configuration](/guide/toolbar#keyboard-shortcuts) to change actual key bindings.
---
# Horizontal Rule
Insert a horizontal separator between blocks.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { RichTextProvider, HorizontalRule, RichTextHorizontalRule } from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, HorizontalRule];
export default function HorizontalRuleExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
:::
::: warning React only
This feature's interactive UI depends on React libraries; the Vue layer does not include it yet. See [Frameworks](/guide/frameworks) for what the Vue entry covers.
:::
## How to use
Place the cursor where the separator should appear, then click the toolbar button. The command is `editor.chain().focus().setHorizontalRule().run()`. Add Tiptap’s `TrailingNode` if you want a paragraph automatically created after a trailing non-paragraph block.
## Options
### shortcutKeys
Type: `string[]`\
Default: `['mod', 'alt', 'S']`
Shortcut labels shown by the controls. See [keyboard shortcut configuration](/guide/toolbar#keyboard-shortcuts) to change actual key bindings.
---
# Embed
The `Iframe` extension (`ai-sparkwrite-editor/iframe`), listed here under the name users see in the toolbar. Embed a YouTube video, a Figma file, a Google Sheet, a map, a CodePen — 35 services recognised from their share links — or any web page.
## Setup
Start with the packages in [Getting Started](/guide/getting-started). The complete example below registers the feature and renders its UI — pick the React or the Vue tab. In an existing editor, merge the imports and extension entries into your setup, and place the controls inside your existing `RichTextProvider`.
::: code-group
```tsx [React]
'use client';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import {
RichTextProvider,
Iframe,
RichTextIframe,
RichTextBubbleIframe,
} from 'ai-sparkwrite-editor';
import 'ai-sparkwrite-editor/style.css';
const extensions = [Document, Paragraph, Text, Iframe];
export default function IframeExample() {
const editor = useEditor({
extensions,
content: 'Try this feature here.
',
immediatelyRender: false,
});
if (!editor) return null;
return (
);
}
```
```vue [Vue]
```
:::
::: tip Vue
Not in the Vue layer yet: `RichTextBubbleIframe` — run the corresponding command from your own control, or see [Frameworks](/guide/frameworks).
:::
## How to use
Click **Embed** in the toolbar (or type `/embed`, `/youtube`, `/figma`…), then paste a **share link** — the kind you copy from the address bar — or the whole `