# 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 ( ); } ``` Every key of `RichTextKit.configure({ … })` is a feature: `false` leaves it out (and its button and menus disappear with it), an object configures it — `image: { upload }`, `codeBlock: { defaultLanguage: 'ts' }`, `ai: { endpoint }`. Features that need a key or a callback are opt-in and appear only when given an object: `imageGif: { GIPHY_API_KEY }`, `mention: { suggestion }`, `emoji: {}`, `excalidraw: {}`, `drawer: {}`, `twitter: {}`, `shortMessage: { messages }`, `recorder: {}`, `placeholder: { placeholder: 'Write…' }`, `horizontalRule: {}`. `` drops the "More tools" panel; its children are placed as extra controls. `` trims the floating UI. ### 3. Or pick the features yourself The same import gives you every extension and control; register the extensions you want and place their controls: ```tsx '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, RichTextToolbar, RichTextToolbarDivider, AI, AIAutocomplete, RichTextAI, RichTextAIComposer, Bold, RichTextBold, Italic, RichTextItalic, History, RichTextUndo, RichTextRedo, RichTextBubbleText, } from 'ai-sparkwrite-editor'; import 'ai-sparkwrite-editor/style.css'; const extensions = [ Document, Paragraph, Text, History, Bold, Italic, // One URL on your backend; which provider and model answer is its business. AI.configure({ endpoint: '/api/ai' }), AIAutocomplete, ]; export default function TextEditor() { const editor = useEditor({ extensions, content: '

Select some text, or press the AI button.

', immediatelyRender: false, }); if (!editor) return null; return ( ); } ``` The stylesheet supplies the controls and the content styles; the consuming app does not need Tailwind. Leave the AI pieces out if you do not want them — every feature is opt-in. The per-feature subpaths (`ai-sparkwrite-editor/bold`, `/ai`, `/bubble/text`…) still exist for bundlers that do not tree-shake. ### Next.js and server rendering Keep the editor in a client component (`'use client'`) with `immediatelyRender: false`, handle the initial `null` editor before rendering the provider, and import the stylesheet where your framework allows global CSS. See the [Tiptap React integration](https://tiptap.dev/docs/editor/getting-started/install/react). ## Vue ### 1. Install ::: code-group ```sh [pnpm] pnpm add ai-sparkwrite-editor @tiptap/vue-3@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 lucide-vue-next ``` ```sh [npm] npm install ai-sparkwrite-editor @tiptap/vue-3@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 lucide-vue-next ``` ::: `ai-sparkwrite-editor/vue` depends only on `vue`, `@tiptap/vue-3` and `lucide-vue-next`; nothing from React is loaded. It exports the extensions too (the framework-free ones plus the blocks with a Vue node view), so a Vue app needs this one import. ### 2. The whole editor in ten lines ```vue ``` The Vue kit takes the same options as the React one, minus the React-only features (Excalidraw, the drawer, emoji, mentions, the Twitter embed, the slash menu); `column` and `imageGif` are opt-in. ### 3. Or pick the features yourself ```vue ``` Everything — extensions, node views and controls — comes from `ai-sparkwrite-editor/vue`; the framework-free `ai-sparkwrite-editor/core` entry remains for headless or non-Vue setups. The full list is in [Frameworks](/guide/frameworks). Only English is bundled; register other languages with `localeActions.setMessage` (see [Internationalization](/guide/internationalization)). ## The pieces | Piece | Responsibility | | ------------------------------------------ | ---------------------------------------------------------------------------------------------- | | `useEditor` | Creates the Tiptap instance and configures content, extensions, and callbacks. | | `RichTextKit` | Every feature as one extension; `.configure({ bold: false, ai: { endpoint } })` shapes it. | | `RichTextKitToolbar`, `RichTextKitMenus` | A toolbar and the floating UI for whatever is registered. | | `Document`, `Paragraph`, `Text` | Define the minimal document structure. Register each once. | | `Bold`, `Image`, `AI`, etc. | Add nodes, marks, commands, or behaviour to `extensions`. | | `RichTextProvider` | Makes the editor available to the controls and carries the root class the stylesheet keys off. | | `RichTextBold`, `RichTextAI`, etc. | Controls for registered extensions. Place them in the toolbar yourself. | | `RichTextAIComposer`, `RichTextBubbleText` | The dock under the editor and the selection menu. Place them after `EditorContent`. | | `EditorContent` | Renders the editable document. It does not add a toolbar. | For each feature, register the extension **and** render its control if you want a button. Importing a control alone does not enable the feature; an extension can also be driven through commands without a button. Avoid registering both a library extension and a Tiptap extension with the same name — if you use `StarterKit`, disable overlapping features there first. ## Save, load, read-only Read the document in `onUpdate` and debounce network saves: ```ts const editor = useEditor({ extensions, onUpdate: ({ editor }) => { const nextDocument = editor.getJSON(); // or editor.getHTML() save(nextDocument); }, }); ``` Pass saved HTML or Tiptap JSON as `content` when creating the editor. For a document loaded later, call `editor.commands.setContent(html, { emitUpdate: false })` once, not on every `onUpdate`. Keep the extensions that stored content needs registered when loading it; a node the schema does not know cannot be represented. Read-only: `editable: false` in `useEditor`, or `editor.setEditable(false)` later. The AI dock, menus and Space/Tab entry points hide themselves while the editor is not editable. ## Troubleshooting | Symptom | What to check | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | A control is missing | Register its extension and render the control under `RichTextProvider`. | | Unknown node or missing command | Check the feature's page for companion extensions (lists need `ListItem`, colours need `TextStyle`). | | Duplicate extension warning | Remove overlapping registrations, including those inside `StarterKit`. | | UI has no styling | Import `ai-sparkwrite-editor/style.css` and any feature-specific stylesheet. | | Two copies of `@tiptap/core` | Pin `@tiptap/vue-3` (and every `@tiptap/*`) to the same version; a mismatch breaks the schema. | | Content does not change after fetching | Use `setContent` after loading; `content` only initialises the document. | | A slash placeholder appears but no menu opens | Register `SlashCommand` and mount `SlashCommandList` (React); a placeholder is only text. | | The AI button does nothing | Register the `AI` extension with an `endpoint` (or a model / `generate`); otherwise the panel shows a configuration error. | | Upload does not persist | Supply an upload callback that resolves to a durable URL. | ## Where next [AI](/extensions/AI/) for the composer, autocomplete and providers · [Toolbar](/guide/toolbar) and [Bubble Menu](/guide/bubble-menu) for composing the UI · [Features](/guide/features) for every extension and its options · [Frameworks](/guide/frameworks) for the core/React/Vue split · [Internationalization](/guide/internationalization) · [Custom Theme](/guide/custom-theme) · [Bundle size](/guide/bundle-size) · [AI Coding Agents](/guide/ai-agents) to hand the whole thing to Claude Code or Cursor. --- # RichTextKit `RichTextKit` is every feature of the editor as **one Tiptap extension**, the way `StarterKit` bundles Tiptap's basics. `RichTextKitToolbar` renders a complete toolbar and `RichTextKitMenus` the floating UI — the AI composer dock, the text bubble with the Improve menu, the table, link, media and block bubbles, the drag handle and the slash menu — for whatever the kit registered. Three imports, one working editor; switch a feature off and its button and menus go with it. ::: code-group ```tsx [React] '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' }, // one URL on your backend image: { upload: (file) => uploadToYourStorage(file) }, placeholder: { placeholder: 'Type / for blocks, Space on an empty line for AI…' }, twitter: false, }), ], immediatelyRender: false, }); if (!editor) return null; return ( ); } ``` ```vue [Vue] ``` ::: The kit is a convenience, not a lock-in: the same import exports every extension and control separately, and the `RichTextKit*` components work with an editor you assembled yourself — they only look at which extensions are registered. Because each feature keeps its own chunk, a kit with features switched off is not bigger than the same features imported one by one (see [Bundle size](/guide/bundle-size)). ## Options One key per feature. Three values: - **omitted** — the default: included, except for the opt-in features below; - **`false`** — left out, together with its toolbar button and menus; - **an object** — included and passed to that extension's `.configure()`; the object type is the extension's own options (`ai: { endpoint }`, `codeBlock: { defaultLanguage: 'ts' }`, `heading: { levels: [1, 2, 3] }`). For an opt-in feature the object switches it on — `{}` is enough when it needs nothing. | Group | Keys (default on) | Opt-in keys (`{ … }` switches on) | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Document | `document`, `paragraph`, `text`, `hardBreak`, `dropcursor`, `gapcursor`, `history` | `placeholder` (`{ placeholder: '…' }`) | | Text style | `bold`, `italic`, `underline`, `strike`, `code`, `moreMark` (super/subscript), `textStyle` (the mark colours, fonts and sizes hang on), `color`, `highlight`, `fontFamily`, `fontSize`, `lineHeight`, `textAlign`, `textDirection`, `indent`, `clear`, `formatPainter` (React), `link` | — | | Blocks | `heading`, `bulletList`, `orderedList`, `taskList`, `blockquote`, `table`, `divider`, `details`, `codeBlock`, `callout`, `notice`, `tableOfContents`, `column` (React; opt-in in Vue) | `horizontalRule` (the divider replaces it) | | Media and embeds | `image`, `video`, `iframe`, `attachment`, `katex`, `mermaid` | `imageGif` (`{ GIPHY_API_KEY }`), `emoji`, `excalidraw`, `drawer`, `twitter`, `mention` (`{ suggestion }`), `shortMessage` (`{ messages }`) — React only, except `imageGif` | | Behaviour | `slashCommand` (React), `searchAndReplace`, `richPaste`, `markdownPaste`, `exportMarkdown`, `exportWord`, `importWord`, `exportPdf`, `codeView` | `recorder` | | AI | `ai` ([options](/extensions/AI/#options); set `endpoint`), `aiAutocomplete` | — | Lists bring `ListItem` along, `column` widens the document schema to `(block|columns)+`, and every extension is registered once even when two keys share a companion. `RichTextKitOptions` is exported for typing a config you keep elsewhere. ## The toolbar `` lays out: AI · undo, redo · heading · bold, italic, underline, strike, colour, highlight, clear · bullet, ordered and task lists, alignment · link, image, table, code block · a **More tools** panel with the rest (font family, font size, line height, super/subscript, indent, format painter; blockquote, inline code, divider, columns, callout, details, table of contents, emoji, video, GIF, attachment, iframe, Katex, Excalidraw, Mermaid, drawer, Twitter; import Word, export PDF / Word / Markdown; search & replace, text direction, source view). Every control appears only when its extension is registered. The panel lays its rows out three per line, related ones side by side (font size · line height · format painter; indent · outdent…). **Drag a row onto the toolbar to pin it there** — it leaves the panel and gets a permanent button. To remove it, hover the button and click its × badge, use the "On the Toolbar" group at the bottom of the panel, or drag it back onto the panel. Pins are remembered per browser in `localStorage`. | Prop | Default | Purpose | | ------------- | --------------------------------------- | --------------------------------------------------------------------------------- | | `more` | `true` | The "More tools" panel | | `pinnable` | `true` | Drag rows out of the panel onto the toolbar, and back | | `defaultPins` | `[]` | Panel keys pinned until the user changes them, e.g. `['fontSize', 'katex']` | | `storageKey` | `ai-sparkwrite-editor:kit-toolbar-pins` | Where the pins are stored | | `children` | — | Extra controls, placed after the built-in groups (React); the default slot in Vue | | `className` | — | React: extra classes on the toolbar | For a different order or a hand-picked set, compose your own from the controls — see [Toolbar](/guide/toolbar). ## The menus `` mounts, for the registered extensions: `RichTextAIComposer`, `RichTextBubbleText` (with the Improve menu), the table, link, image, video, GIF, callout, iframe, Katex, Mermaid, Excalidraw, drawer and Twitter bubbles, `RichTextBubbleMenuDragHandle` and `SlashCommandList` (React). In Vue: the composer and the text, table, link and image bubbles. | Prop | Default | Purpose | | ------------ | ------- | ----------------------------------------------------------------------------------------------------- | | `composer` | `true` | The AI composer dock; `false` hides it, an object passes its props (`{ defaultOpen: true, rows: 3 }`) | | `dragHandle` | `true` | The block drag handle (React) | ## Playground The [playground](https://ludejun.github.io/ai-sparkwrite-editor/playground/) runs on the kit by default; its **Setup** switch shows the same editor _Assembled_ control by control — in React and in Vue. --- # AI Coding Agents Most editors are now wired up by an AI assistant rather than by hand. An assistant that only knows Tiptap will guess at import paths, put an API key in the browser, or upload images to a blob URL. This page lists what to hand it so it does not have to guess. ## 1. The skill The repository ships an [agent skill](https://github.com/ludejun/ai-sparkwrite-editor/tree/main/skills/ai-sparkwrite-editor) — a `SKILL.md` with the integration rules and five reference files: install and the kit options, every export and entry point, the **AI backend contract** (`endpoint` request/response, streaming, server samples), the upload and image-deletion recipes, and a debugging checklist. It is written for the installed version and tells the agent to trust `lib/*.d.ts` over its own text. Install it into your project with the [skills CLI](https://github.com/vercel-labs/skills) (works for Claude Code, Cursor, Codex, Copilot, Windsurf and others): ```bash npx skills add ludejun/ai-sparkwrite-editor ``` Or point the agent at the copy inside the package — it ships with every release: ``` node_modules/ai-sparkwrite-editor/skills/ai-sparkwrite-editor/SKILL.md ``` A one-line instruction in your `CLAUDE.md` / `AGENTS.md` / `.cursorrules` is enough: ```md Before touching the editor, read node_modules/ai-sparkwrite-editor/skills/ai-sparkwrite-editor/SKILL.md and the reference it points to. ``` ## 2. The documentation, in one file The docs site publishes the two files the [llms.txt convention](https://llmstxt.org) defines: - **[llms.txt](https://ludejun.github.io/ai-sparkwrite-editor/llms.txt)** — an index of every page with its description. - **[llms-full.txt](https://ludejun.github.io/ai-sparkwrite-editor/llms-full.txt)** — every English page concatenated, for an agent that can fetch a URL. The pages themselves are Markdown in the repository under [`docs/`](https://github.com/ludejun/ai-sparkwrite-editor/tree/main/docs), so an agent with GitHub access can read a single page, for example [`docs/extensions/AI/index.md`](https://github.com/ludejun/ai-sparkwrite-editor/blob/main/docs/extensions/AI/index.md). ## 3. The type declarations The most reliable source for the installed version is the package itself. Every option carries a JSDoc comment: | What | Where | | ----------------------------------------------------------------------- | ------------------------------------------------------------- | | Every React export, kit options | `node_modules/ai-sparkwrite-editor/lib/index.d.ts` | | Vue exports | `node_modules/ai-sparkwrite-editor/lib/vue.d.ts` | | Framework-free core | `node_modules/ai-sparkwrite-editor/lib/core.d.ts` | | One extension's options (`AIOptions`, `IImageOptions`, `VideoOptions`…) | `node_modules/ai-sparkwrite-editor/lib/extensions//` | | Public entry points | `exports` in `node_modules/ai-sparkwrite-editor/package.json` | ## 4. What to tell it about your app The skill asks the agent to find these in your code and to ask you only when it cannot: - **The AI backend** — the URL the editor should POST to (`ai: { endpoint: '/api/ai' }`) and where to implement it. The contract is in [AI › Your endpoint](/extensions/AI/#your-endpoint); the skill carries Express and Next.js samples for OpenAI and Anthropic. Keys stay on the server. - **Uploads** — the endpoint and response shape for images, videos and attachments. The callback must resolve to a URL the saved document can reopen later. - **Persistence** — HTML or JSON, when to save, and whether deleted images should be removed on the server at save time (`getImageChanges` / `markImagesSaved`, see [Image](/extensions/Image/#uploads-and-deleted-images)). - **Framework and route** — React or Vue, the kit or a hand-assembled toolbar, SSR or not. ## 5. A prompt that works ```text Add ai-sparkwrite-editor to the article form. Use RichTextKit with our /api/ai endpoint (implement it as a Next.js route handler proxying to Anthropic), image uploads through /api/uploads (returns { url }), save HTML on change with an 800 ms debounce, and delete orphaned images on save. Read node_modules/ai-sparkwrite-editor/skills/ai-sparkwrite-editor/SKILL.md first. ``` --- # Bubble Menu Bubble menus provide actions near selected text or a selected node. They are separate components — React here, and the same names from `ai-sparkwrite-editor/vue` for Vue: register the corresponding extensions, then mount the menus inside the same `RichTextProvider` as the document. Importing a menu does not mount it, and mounting a menu does not register its extension. ## Add a text selection menu This example uses the packages from [Getting Started](/guide/getting-started). Select a word in the editor to show the menu: ```tsx '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, Italic, RichTextItalic, RichTextBubbleText, } from 'ai-sparkwrite-editor'; import 'ai-sparkwrite-editor/style.css'; const extensions = [Document, Paragraph, Text, Bold, Italic]; export default function BubbleMenuExample() { const editor = useEditor({ extensions, content: '

Select a few words to format them.

', immediatelyRender: false, }); if (!editor) return null; return ( } /> ); } ``` `buttonBubble` replaces the default text controls with your own React content. Each control still needs its corresponding extension. Omit the prop to use the library's default text menu; register the formatting and block features you intend to offer there. A regular toolbar and a bubble menu can coexist. Both operate on the same editor instance. ## Add a node menu For images, register `Image` in the existing extension array and mount `RichTextBubbleImage` under the provider. Select an inserted image to show its controls. Follow the same pattern for the other supported nodes: | Menu component | Required feature | Purpose | | -------------------------- | ------------------------------------------------------- | ------------------------------- | | `RichTextBubbleText` | Text and the formatting extensions used by its controls | Format selected text. | | `RichTextBubbleLink` | [Link](/extensions/Link/) | Edit a hovered link. | | `RichTextBubbleImage` | [Image](/extensions/Image/) | Edit a selected image. | | `RichTextBubbleVideo` | [Video](/extensions/Video/) | Edit a selected video. | | `RichTextBubbleTable` | [Table](/extensions/Table/) | Table actions, on right click. | | `RichTextBubbleIframe` | [Embed](/extensions/Iframe/) | Edit an embedded frame. | | `RichTextBubbleImageGif` | [ImageGif](/extensions/ImageGif/) | Edit a selected GIF. | | `RichTextBubbleDrawer` | [Drawer](/extensions/Drawer/) | Edit a drawing node. | | `RichTextBubbleExcalidraw` | [Excalidraw](/extensions/Excalidraw/) | Edit an Excalidraw node. | | `RichTextBubbleMermaid` | [Mermaid](/extensions/Mermaid/) | Edit a diagram node. | | `RichTextBubbleTwitter` | [Twitter](/extensions/Twitter/) | Manage a post embed. | | `RichTextBubbleCallout` | [Callout](/extensions/Callout/) | Edit a callout. | | `RichTextBubbleKatex` | [Katex](/extensions/Katex/) | Edit a mathematical expression. | All menu components in this table are exported from `ai-sparkwrite-editor/bubble`. Mount each menu once per editor and only include the menus your editor needs. `RichTextBubbleTable` is the odd one out: despite the name it mounts a context menu rather than a bubble, so the table actions appear where you right-click inside a table instead of hovering over the document while the caret is in a cell. Two blocks deliberately have no bubble menu: - **Code blocks** render their own toolbar (language, copy, delete) in the block's top-right corner, revealed on hover. It ships with the `CodeBlock` extension, so nothing needs mounting. - **Columns** expose their actions through `RichTextBubbleMenuDragHandle`, under the block menu of any block inside a column. Mount the drag handle to get them. ## Individual imports Use these public subpaths to make feature dependencies explicit. The existing `/bubble` entry remains supported. Import only the components you mount. | Component | Subpath after `ai-sparkwrite-editor` | | ------------------------------ | ------------------------------------ | | `RichTextBubbleText` | `/bubble/text` | | `RichTextBubbleMenuDragHandle` | `/bubble/drag-handle` | | `RichTextAIImprove` | `/bubble/ai` | | `RichTextBubbleCallout` | `/bubble/callout` | | `RichTextBubbleDrawer` | `/bubble/drawer` | | `RichTextBubbleExcalidraw` | `/bubble/excalidraw` | | `RichTextBubbleIframe` | `/bubble/iframe` | | `RichTextBubbleKatex` | `/bubble/katex` | | `RichTextBubbleLink` | `/bubble/link` | | `RichTextBubbleMermaid` | `/bubble/mermaid` | | `RichTextBubbleTable` | `/bubble/table` | | `RichTextBubbleTwitter` | `/bubble/twitter` | | `RichTextBubbleImage` | `/bubble/media` | | `RichTextBubbleVideo` | `/bubble/media` | | `RichTextBubbleImageGif` | `/bubble/media` | `/bubble/media` exports the Image, Video, and ImageGif menus together. `RichTextAIImprove` is an AI control; see [AI](/extensions/AI/). The text bubble does not require KaTeX or Yjs. The drag handle still brings collaboration-related dependencies through Tiptap, even in an editor without collaboration. ## Block drag handle `RichTextBubbleMenuDragHandle` provides a handle for moving document blocks and a block action menu. It does not move the bubble menu itself. ```tsx import { RichTextBubbleMenuDragHandle } from 'ai-sparkwrite-editor'; // Mount inside your existing RichTextProvider. ; ``` ## Slash commands `SlashCommandList` supplies the slash command list and is not a text-selection bubble menu. Mount it inside the provider and register `SlashCommand` to enable `/` commands. See [Slash Command](/extensions/SlashCommand/). ## Troubleshooting If a menu does not appear, confirm that the editor is editable, its matching extension is registered, and the appropriate content is selected. A collapsed text cursor does not show the text-selection menu. Check clipping or stacking styles in your host layout if a menu appears behind another element. --- # Bundle size Every feature is its own chunk and the main entry `ai-sparkwrite-editor` only re-exports them, so in a bundler that tree-shakes ES modules (Vite, Rollup, webpack, esbuild) importing `Bold` from the main entry produces byte-for-byte the same output as importing it from `ai-sparkwrite-editor/bold` (checked with a Vite build of both). The per-feature subpaths (`/bold`, `/table`, `/image`, `/bubble/table`…) remain for tools that do not tree-shake. A host pays for the features it references; registering everything through `RichTextKit` costs about 280 KB of library code gzipped, with Excalidraw, Mermaid, Katex and the Word import/export libraries loaded on first use. This page lists what an entry costs, what every entry shares, and the two rules that keep it that way. ## What a feature costs Bytes of the library's own JavaScript (minified, before gzip) reached from one entry, measured over the chunk graph in `lib/` with `pnpm measure:entries`. React, Tiptap, Radix and `lucide-react` are peer/runtime dependencies and are not counted; the locale table (`en`, 14 KB) is, since every control reads its tooltip from it. | Entry | Before | After | Lucide icons before → after | | --------------------------- | ----------------- | ---------------------- | --------------------------- | | `ai-sparkwrite-editor/bold` | 88 KB, 16 chunks | **30 KB**, 17 chunks | 87 → 1 | | `/heading` | 98 KB, 18 chunks | **39 KB**, 19 chunks | 90 → 3 | | `/table` | 97 KB, 18 chunks | **39 KB**, 19 chunks | 87 → 1 | | `/image` | 135 KB, 27 chunks | **77 KB**, 29 chunks | 88 → 6 | | `/ai` | 227 KB, 28 chunks | **169 KB**, 29 chunks | 95 → 15 | | root (provider + toolbar) | 175 KB, 33 chunks | **124 KB**, 36 chunks | 88 → 6 | | `/bubble` (every bubble) | 512 KB, 95 chunks | **465 KB**, 106 chunks | 109 → 92 | `core`/`vue` are not listed: their size is dominated by concurrent work on the framework-free layer, not by anything on this page. The `lucide` column counts the distinct Lucide icons a bundle imports. Before, every entry imported all ~90 of them; a bundler cannot drop an icon that is looked up by name at runtime from a map holding all of them. ## What every entry shares The floor under a single control is about 30 KB: - the `en` locale (14 KB) — the default tooltips, always present so a control renders without configuration; - `ActionButton` with the tooltip/toggle primitives (about 5 KB); - the locale and editable-state stores (about 4 KB, plain `useSyncExternalStore`); - the icon registry itself, holding only the shared dropdown chevron (about 1 KB). Things that used to sit in this floor and no longer do: - the `cn` package (31 KB): a compiled clsx + tailwind-merge. The library's classes are `richtext-` prefixed, which tailwind-merge never recognised, so it only ever joined strings. A 20-line local `cn` with clsx semantics does the same. - the icon map (24 KB of our code plus every Lucide icon): replaced by a registry that features fill in as they load — see below. - `reactjs-signal` / `alien-signals` (7 KB): the editable-state and slash-command stores now use `useSyncExternalStore` directly. ## How icons stay tree-shakable Icons are addressed by name (`icon: 'Table'` in a `button()` config, `iconName` in a slash command, ``) and resolved from a registry. The registry starts almost empty; each React control registers the icons it draws when its module loads: ```ts // src/extensions/Table/components/RichTextTable.tsx import { TableIcon } from 'lucide-react'; import { registerIcons } from '@/components/icons/icons'; registerIcons({ Table: TableIcon }); ``` So importing `ai-sparkwrite-editor/table` brings the Table icon and nothing else. The same holds for the editor's own SVG icons (Mermaid, Excalidraw, export/import glyphs…): they are registered by their feature, not shipped to everyone. Two consequences for a host: - A name of your own has to be registered before it renders — `registerIcons({ Save })` at module scope. See [Customization → Icons](/guide/customization#icons). - A component that renders another feature's buttons by name must import that feature's controls, or register the names itself. The bubble menus do this: `RichTextBubbleTable` registers the row/column icons it lists, so it works with the Table _extension_ alone. `tests/icon-registry.test.mjs` walks the source module graph from every public entry and fails if an icon name is used in a graph that never registers it. ## Tree-shaking notes - `package.json` declares `sideEffects` for CSS and the locale bundle only. Everything else is side-effect free _as a module_, so a bundler may skip an entry file whose exports you do not use. `registerIcons` calls live in the component modules whose exports you render, so they survive as long as the control does. - Heavy runtime dependencies (`katex`, `mermaid`, `@excalidraw/excalidraw`, `docx`, `mammoth`, `react-image-crop`…) are externals: they load through your bundler, once, and only for entries that use them. - `react-tweet` stays bundled on purpose: its ESM imports CSS modules, which only a bundler can resolve. As an external it would make the `twitter` entries fail outside one (SSR, tests). It lives in the Twitter node view's chunk; rolldown also parks its module-interop helper there, so an entry whose code needs that helper (`ai`, `core` at the time of writing) imports the chunk without using the tweet embed — a chunking artefact, not a dependency. - The root entry (`ai-sparkwrite-editor`) exports the provider and toolbar building blocks only; features come from their own entries, so importing the root does not pull every feature. ## Measuring ```sh pnpm build:lib pnpm measure:entries # the default set of entries pnpm measure:entries Bold.js # one entry, with its largest chunks ``` The script sums the `lib/` chunks reachable from an entry and lists the external packages it imports; it does not include those externals' own size. --- # Custom Theme Use the theme actions to control the editor's light/dark appearance, accent palette, and corner radius. Import the editor stylesheet once before applying your own layout styles. ## Set an initial theme Run these actions during client initialization, or from your application's theme-change handler: ```ts import { themeActions } from 'ai-sparkwrite-editor/theme'; // These settings apply to the library's editor UI and dialogs. themeActions.setTheme('dark'); themeActions.setColor('blue'); themeActions.setBorderRadius('0.5rem'); ``` | Setting | Supported values | Default | | ------------- | --------------------------------------------------------------------------------------- | ----------- | | Theme | `'light'`, `'dark'` | `'light'` | | Color | `'default'`, `'red'`, `'blue'`, `'green'`, `'orange'`, `'rose'`, `'violet'`, `'yellow'` | `'default'` | | Border radius | CSS length such as `'0px'` or `'0.5rem'` | `'0.65rem'` | The settings are shared by editor instances and their portaled dialogs. They are not per-editor props, and the library does not persist them across reloads. Restore preferences through your own application state if needed. ## Add a theme toggle ```tsx import { themeActions, useTheme } from 'ai-sparkwrite-editor/theme'; export function EditorThemeToggle() { const { theme } = useTheme(); return ( ); } ``` `useTheme()` also returns `color` and `borderRadius`. Call it inside a React component. In Vue pass `dark` to `RichTextProvider`; the palette variables live on `.sparkwrite` and can be overridden in CSS. To follow your host application's appearance, invoke `setTheme` when that application's theme changes. ::: tip Migrating from the old editor The current provider type still accepts `dark`, but the implementation does not apply it. Use `themeActions.setTheme` instead of ``. ::: ## Style the document area Add a class to `EditorContent` in your existing editor: ```tsx ``` Then define your layout in application CSS: ```css .article-editor .tiptap { min-height: 240px; padding: 1rem; } ``` Use editor-scoped selectors so these rules do not affect unrelated page content. Theme colors style the interface; inline text colors applied through the Color extension remain part of the document. Diagrams and drawing tools can require additional stylesheets. Follow the [KaTeX](/extensions/Katex/), [Drawer](/extensions/Drawer/), and [Excalidraw](/extensions/Excalidraw/) setup instructions when enabling those features. --- # Customization Four things every host ends up wanting: its own toolbar menus, its own block types, control over when and how the document is saved, and a way to play a session back. Each has a home in the library. ## Custom menus The toolbar is composed from components — React below, and the same building blocks from `ai-sparkwrite-editor/vue` for Vue — so a custom menu is just another component in the row. The library ships the same building blocks the playground uses: ```tsx import { RichTextToolbar, RichTextToolbarButton, RichTextToolbarDivider, RichTextToolbarMore, RichTextToolbarMoreGroup, RichTextToolbarMoreRow, registerIcons, RichTextBold, RichTextTable, } from 'ai-sparkwrite-editor'; import { Pencil, Save } from 'lucide-react'; // Icons are looked up by name; the built-in controls register theirs, you register yours. registerIcons({ Pencil, Save }); {/* Your own action, styled like the built-in controls */} save(editor)} /> {/* Everything else, with labels instead of tooltips */} ; ``` `RichTextToolbarMore` keeps a dropdown opened from inside it (font size, line height…) alive while the panel is up, and clicking a row's label triggers its control. Any `RichText*` control from an extension can sit in a row; so can anything of your own. See [Toolbar](/guide/toolbar) for the conventions on what belongs in the top row. ### Icons Every `icon` in the library is a name — `icon: 'Table'` in an extension's `button()` options, `iconName` in a slash command, ``. Names resolve through a registry that starts almost empty; each built-in control registers the icons it uses — [Lucide](https://lucide.dev) or the editor's own SVGs — when its module loads, so a bundle only carries the icons of the features it imports (see [Bundle size](/guide/bundle-size)). A name of your own has to be registered before it is rendered — at module scope, next to the component that uses it: ```ts import { registerIcons } from 'ai-sparkwrite-editor'; import { Save } from 'lucide-react'; registerIcons({ Save }); ``` `registerIcons` takes any component that accepts a `className` (a Lucide icon, your own SVG component), keyed by the name you use in configs. Registering a name again replaces the previous icon, which is also how you swap a built-in one: `registerIcons({ Bold: MyBoldIcon })` after importing `ai-sparkwrite-editor/bold` changes the Bold button everywhere. Reading a name back is `icons[name]` (`import { icons } from 'ai-sparkwrite-editor'`). A custom menu item usually calls a command. For an action the built-in extensions do not have, write a small extension: ```ts import { Extension } from '@tiptap/core'; export const Signature = Extension.create({ name: 'signature', addCommands() { return { insertSignature: () => ({ chain }) => chain() .insertContent('

— 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 }) => ( {[1, 2, 3, 4, 5].map((n) => ( ))} ); export const Rating = Node.create({ name: 'rating', group: 'block', atom: true, addAttributes: () => ({ value: { default: 0 } }), parseHTML: () => [{ tag: 'div[data-type="rating"]' }], renderHTML: ({ HTMLAttributes }) => [ 'div', mergeAttributes(HTMLAttributes, { 'data-type': 'rating' }), ], addNodeView: () => ReactNodeViewRenderer(RatingView), }); ``` Register it alongside the others, add a `RichTextToolbarButton` that runs `editor.commands.insertContent({ type: 'rating' })`, and a slash entry if you want one (see [SlashCommand](/extensions/SlashCommand/)). `renderHTML` is what `getHTML()` saves, so the saved form is entirely yours. ## Saving The document is available as HTML (`editor.getHTML()`), JSON (`editor.getJSON()`), plain text, Markdown ([ExportMarkdown](/extensions/ExportMarkdown/)) or Word ([ExportWord](/extensions/ExportWord/)). JSON round-trips exactly; HTML is what most backends store. Autosave is an `onUpdate` with a debounce: ```ts const editor = useEditor({ extensions, content, onUpdate: debounce(({ editor }) => { void api.save({ html: editor.getHTML(), json: editor.getJSON() }); }, 800), }); ``` Two things worth handling at save time: - **Images.** Uploads happen before the save, so deleted pictures leave files behind. `getImageChanges(editor)` lists what to delete and `markImagesSaved(editor)` records the baseline — see [Image › Uploads and deleted images](/extensions/Image/#uploads-and-deleted-images). - **Read-only.** `editor.setEditable(false)` during a long save or a replay keeps the document from changing under you. ## Replay [Recorder](/extensions/Recorder/) records every edit as timestamped ProseMirror steps. Save the recording with the document and `replayRecording(editor, recording, { speed: 4 })` plays the session back — how a text came to be, who changed what in a review, or the exact sequence that triggered a bug. --- # Features Everything comes from one import — `ai-sparkwrite-editor` (React) or `ai-sparkwrite-editor/vue` (Vue) — or all at once through `RichTextKit`. Register the extension in `useEditor`, place the control inside `RichTextProvider`. The `Import` column lists the per-feature subpath, an equivalent for bundlers that do not tree-shake. Options are passed with `.configure({ ... })`; every extension also accepts the shared `toolbar`, `divider`, `spacer` and `shortcutKeys` options. ## Core | Feature | Import | Control | Notable options | | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | [RichTextKit](/guide/kit) | `ai-sparkwrite-editor`, `ai-sparkwrite-editor/vue` | `RichTextKit`, `RichTextKitToolbar`, `RichTextKitMenus` | one key per feature: `false`, or the extension's options | | Provider, toolbar primitives | `ai-sparkwrite-editor` | `RichTextProvider`, `RichTextToolbar`, `RichTextToolbarMore`, `RichTextToolbarButton` … | see [Customization](/guide/customization) | | Framework-agnostic core | `ai-sparkwrite-editor/core` | — | extensions, paste rules, recorder, AI transport, translations; no React — see [Frameworks](/guide/frameworks) | | Vue 3 UI | `ai-sparkwrite-editor/vue` | `RichTextProvider`, toolbar primitives, controls, bubble menus, dialogs, AI panel and composer, node views | same stylesheet as the React controls; see [Frameworks](/guide/frameworks) | | [History](/extensions/History/) | `ai-sparkwrite-editor/history` | `RichTextHistory` (undo/redo) | `depth` | | [Heading](/extensions/Heading/) | `ai-sparkwrite-editor/heading` | `RichTextHeading` | `levels`; clicks in the gap above a block go to that block | | [Bold](/extensions/Bold/), [Italic](/extensions/Italic/), [Underline](/extensions/TextUnderline/), [Strike](/extensions/Strike/), [Code](/extensions/Code/) | `ai-sparkwrite-editor/bold` … | `RichTextBold` … | — | | [MoreMark](/extensions/MoreMark/) | `ai-sparkwrite-editor/moremark` | `RichTextMoreMark` (superscript, subscript) | — | | [Color](/extensions/Color/), [Highlight](/extensions/Highlight/) | `ai-sparkwrite-editor/color`, `ai-sparkwrite-editor/highlight` | `RichTextColor`, `RichTextHighlight` | `colors` | | [FontFamily](/extensions/FontFamily/) | `ai-sparkwrite-editor/fontfamily` | `RichTextFontFamily` | `fontFamilyList`; CJK, Devanagari, Bengali stacks shown by locale and document | | [FontSize](/extensions/FontSize/) | `ai-sparkwrite-editor/fontsize` | `RichTextFontSize` (`compact`) | `fontSizes` | | [LineHeight](/extensions/LineHeight/) | `ai-sparkwrite-editor/lineheight` | `RichTextLineHeight` | `lineHeights` | | [TextAlign](/extensions/TextAlign/) | `ai-sparkwrite-editor/textalign` | `RichTextTextAlign` | `alignments`, `types` | | [TextDirection](/extensions/TextDirection/) | `ai-sparkwrite-editor/textdirection` | `RichTextTextDirection` | `directions` | | [Indent](/extensions/Indent/) | `ai-sparkwrite-editor/indent` | `RichTextIndent` | `minIndent`, `maxIndent` | | [Clear](/extensions/Clear/) | `ai-sparkwrite-editor/clear` | `RichTextClear` (clear formatting) | — | | [FormatPainter](/extensions/FormatPainter/) | `ai-sparkwrite-editor/formatpainter` | `RichTextFormatPainter` | — | ## Blocks | Feature | Import | Control | Notable options | | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------- | | [BulletList](/extensions/BulletList/), [OrderedList](/extensions/OrderedList/), [TaskList](/extensions/TaskList/) | `ai-sparkwrite-editor/bulletlist` … | `RichTextBulletList` … | — | | [Blockquote](/extensions/Blockquote/) | `ai-sparkwrite-editor/blockquote` | `RichTextBlockquote` | — | | [CodeBlock](/extensions/CodeBlock/) | `ai-sparkwrite-editor/codeblock` | `RichTextCodeBlock` | language picker node view, `detectLanguageFn`, GitHub light/dark palette; exports `guessLanguage` | | [Table](/extensions/Table/) | `ai-sparkwrite-editor/table` | `RichTextTable`, `RichTextBubbleTable` | rounded corners, `insertParagraphAfterTable` (click beside, ⌘/Ctrl+Enter, context menu) | | [Divider](/extensions/Divider/) | `ai-sparkwrite-editor/divider` | `RichTextDivider` | `variants` (9 built in, editable text, numbered), `defaultVariant`, `renderDivider`, `parseRules` | | [Column](/extensions/Column/) | `ai-sparkwrite-editor/column` | `RichTextColumn` | `insertColumns({ cols })` | | [Callout](/extensions/Callout/) | `ai-sparkwrite-editor/callout` | `RichTextCallout`, `RichTextBubbleCallout` | note, tip, important, warning, caution | | [Notice](/extensions/Notice/) | `ai-sparkwrite-editor/notice` | `RichTextNotice`, `RichTextBubbleNotice` | info, success, warning, tip — a coloured box of editable blocks | | [Details](/extensions/Details/) | `ai-sparkwrite-editor/details` | `RichTextDetails` | collapsible block | | [TableOfContents](/extensions/TableOfContents/) | `ai-sparkwrite-editor/tableofcontents` | `RichTextTableOfContents` | live outline node | | [HorizontalRule](/extensions/HorizontalRule/) | `ai-sparkwrite-editor/horizontalrule` | `RichTextHorizontalRule` | superseded by Divider; register one or the other | ## Media and embeds | Feature | Import | Control | Notable options | | ------------------------------------- | --------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [Image](/extensions/Image/) | `ai-sparkwrite-editor/image` | `RichTextImage`, `RichTextBubbleImage` | `upload`, `acceptMimes`, `maxSize`, `resourceImage`; cropping, captions, rotate; `getImageChanges` / `markImagesSaved` for deleted uploads | | [ImageGif](/extensions/ImageGif/) | `ai-sparkwrite-editor/imagegif` | `RichTextImageGif` | `GIPHY_API_KEY` | | [Video](/extensions/Video/) | `ai-sparkwrite-editor/video` | `RichTextVideo`, `RichTextBubbleVideo` | `upload`, `resourceVideo` | | [Attachment](/extensions/Attachment/) | `ai-sparkwrite-editor/attachment` | `RichTextAttachment` | `upload` | | [Embed](/extensions/Iframe/) | `ai-sparkwrite-editor/iframe` | `RichTextIframe`, `RichTextBubbleIframe` | YouTube, Vimeo, Bilibili, Loom, Spotify, Google Maps, Figma, Canva, Miro, CodePen, Gist, Google Docs/Sheets/Slides/Forms, Airtable… 35 services, or any page | | [Twitter](/extensions/Twitter/) | `ai-sparkwrite-editor/twitter` | `RichTextTwitter` | x.com and twitter.com links | | [Katex](/extensions/Katex/) | `ai-sparkwrite-editor/katex` | `RichTextKatex`, `RichTextBubbleKatex` | `loadKatex` (lazy); AI "describe the formula" | | [Mermaid](/extensions/Mermaid/) | `ai-sparkwrite-editor/mermaid` | `RichTextMermaid`, `RichTextBubbleMermaid` | AI "describe the diagram" | | [Excalidraw](/extensions/Excalidraw/) | `ai-sparkwrite-editor/excalidraw` | `RichTextExcalidraw`, `RichTextBubbleExcalidraw` | — | | [Drawer](/extensions/Drawer/) | `ai-sparkwrite-editor/drawer` | `RichTextDrawer`, `RichTextBubbleDrawer` | freehand drawing | | [Emoji](/extensions/Emoji/) | `ai-sparkwrite-editor/emoji` | `RichTextEmoji` | lazy emoji data | | [Mention](/extensions/Mention/) | `ai-sparkwrite-editor/mention` | — | `suggestion` | | [Link](/extensions/Link/) | `ai-sparkwrite-editor/link` | `RichTextLink`, `RichTextBubbleLink` (hover) | `openOnClick`, `autolink` | ## Writing aids | Feature | Import | Control | Notable options | | ------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [AI](/extensions/AI/) | `ai-sparkwrite-editor/ai` | `RichTextAI` (toolbar button), `RichTextAIComposer` (dock under the editor), `RichTextAIImprove` (selection menu), `/ai` slash entries, Space on an empty line | `endpoint` (one URL on your backend), `generate(request, onChunk)`, `stream`, `spaceTrigger`, `documentContext`, `translateLanguages`, `renderResult`, `components.Panel`; `writeWithAI` streams Markdown into the document as real nodes | | AI autocomplete | `ai-sparkwrite-editor/ai` (`AIAutocomplete`) | ghost text after a pause; Tab accepts, Escape dismisses | `enabled`, `delay`, `minChars`, `contextChars`, `maxTokens`, `prompt`; `toggleAIAutocomplete()` | | [SlashCommand](/extensions/SlashCommand/) | `ai-sparkwrite-editor/slashcommand` | `SlashCommandList` | `commandList`, `hiddenUntilSearched` | | [RichPaste](/extensions/RichPaste/) | `ai-sparkwrite-editor/richpaste` | — | `wordLists`, `codeBlocks`, `detectLanguage` | | [MarkdownPaste](/extensions/MarkdownPaste/) | `ai-sparkwrite-editor/markdownpaste` | — | pasted markdown text becomes content | | [SearchAndReplace](/extensions/SearchAndReplace/) | `ai-sparkwrite-editor/searchandreplace` | `RichTextSearchAndReplace` | — | | [ShortMessage](/extensions/ShortMessage/) | `ai-sparkwrite-editor/shortmessage` | — | `shortcut`, `messages` (text expansion) | | [Recorder](/extensions/Recorder/) | `ai-sparkwrite-editor/recorder` | — | `autoStart`, `recordSelection`, `onEntry`; `replayRecording` | | [CodeView](/extensions/CodeView/) | `ai-sparkwrite-editor/codeview` | `RichTextCodeView` | edit the HTML source | ## Import and export | Feature | Import | Control | Notable options | | --------------------------------------------- | ------------------------------------- | ------------------------ | ----------------------------------------------- | | [ImportWord](/extensions/ImportWord/) | `ai-sparkwrite-editor/importword` | `RichTextImportWord` | `upload` for embedded images | | [ExportWord](/extensions/ExportWord/) | `ai-sparkwrite-editor/exportword` | `RichTextExportWord` | — | | [ExportPdf](/extensions/ExportPdf/) | `ai-sparkwrite-editor/exportpdf` | `RichTextExportPdf` | prints with the editor stylesheet | | [ExportMarkdown](/extensions/ExportMarkdown/) | `ai-sparkwrite-editor/exportmarkdown` | `RichTextExportMarkdown` | nodes without a markdown form fall back to HTML | ## Bubble menus Imported individually from `ai-sparkwrite-editor/bubble/`: `text`, `table`, `media` (image), `video`, `link`, `callout`, `notice`, `drawer`, `excalidraw`, `iframe`, `katex`, `mermaid`, `drag-handle`. See [Bubble menus](/guide/bubble-menu). ## Languages `ai-sparkwrite-editor/locales/` 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 ``` 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 `