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:
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 });
<RichTextToolbar>
<RichTextBold />
<RichTextToolbarDivider />
<RichTextTable />
{/* Your own action, styled like the built-in controls */}
<RichTextToolbarButton
icon='Save'
tooltip='Save'
shortcutKeys={['mod', 'S']}
onClick={() => save(editor)}
/>
{/* Everything else, with labels instead of tooltips */}
<RichTextToolbarMore label={t('editor.more')}>
<RichTextToolbarMoreGroup label={t('editor.slash.insert')}>
<RichTextToolbarMoreRow label={t('editor.divider.tooltip')}>
<RichTextDivider />
</RichTextToolbarMoreRow>
<RichTextToolbarMoreRow label='Insert signature'>
<RichTextToolbarButton icon='Pencil' onClick={insertSignature} />
</RichTextToolbarMoreRow>
</RichTextToolbarMoreGroup>
</RichTextToolbarMore>
</RichTextToolbar>;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 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, <RichTextToolbarButton icon='Save'>. Names resolve through a registry that starts almost empty; each built-in control registers the icons it uses — Lucide 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).
A name of your own has to be registered before it is rendered — at module scope, next to the component that uses it:
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:
import { Extension } from '@tiptap/core';
export const Signature = Extension.create({
name: 'signature',
addCommands() {
return {
insertSignature:
() =>
({ chain }) =>
chain()
.insertContent('<p>— Ada, ' + new Date().toLocaleDateString() + '</p>')
.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). 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:
import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
const RatingView = ({ node, updateAttributes }) => (
<NodeViewWrapper className='rating'>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => updateAttributes({ value: n })}
aria-pressed={n <= node.attrs.value}
>
★
</button>
))}
</NodeViewWrapper>
);
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). 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) or Word (ExportWord). JSON round-trips exactly; HTML is what most backends store. Autosave is an onUpdate with a debounce:
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 andmarkImagesSaved(editor)records the baseline — see 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 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.