Editor
A rich-text WYSIWYG editor built on Tiptap v3 + ProseMirror. Configurable toolbar, optional bubble menu, image upload, resizable tables, @-mentions, /-commands, YouTube embeds, drag handle for block reordering, HTML / JSON / Markdown output, character counter, sticky toolbar - and a full imperative api via bind:api.
onImageUpload now covers paste and drop as well as the toolbar picker, and the new imageCrop prop opens a cropper before the upload, so the server receives the framed image rather than the
original. The three URL dialogs became real forms, the link dialog gained an optional Display text field, and none of them show a validation error until the field is actually
left.Installation
Editor's Tiptap stack is grouped by feature so you can see which package backs which Editor option. All groups are required at install time - Editor loads every extension at module mount, so a missing peer fails at build/dev. The grouping is for understanding; a single combined command (further down) is fine.
1. Core
Required every time you import sv5ui/editor.
# Core - required every time you import `sv5ui/editor`
npm install \
sv5ui \
@tiptap/core \
@tiptap/pm \
@tiptap/starter-kit2. Built-in UX extensions
Powers placeholder, showCount / maxLength, bubbleMenu, and the text-alignment toolbar buttons (always loaded).
# Always-on Editor features: placeholder, char/word count, bubble menu,
# text alignment toolbar buttons, and smart-typography substitutions.
npm install \
@tiptap/extension-placeholder \
@tiptap/extension-character-count \
@tiptap/extension-bubble-menu \
@tiptap/extension-text-align \
@tiptap/extension-typography3. Image upload
Required when you use the image prop (paste / drop / onImageUpload).
# Image upload (`image` prop, paste/drop, onImageUpload callback)
npm install @tiptap/extension-image4. Tables
Required when you use the tables prop. Tiptap splits the feature across 4 packages - install all four.
# Tables (`tables` prop + the 8×8 dimension picker)
npm install \
@tiptap/extension-table \
@tiptap/extension-table-row \
@tiptap/extension-table-cell \
@tiptap/extension-table-header5. YouTube embeds
Required when you use the youtube prop.
# YouTube embeds (`youtube` prop)
npm install @tiptap/extension-youtube6. Mentions & slash commands
Required for onMention and the slash palette. Both share @tiptap/suggestion.
# @-mentions (`onMention`) and slash commands (`slash` prop).
# Both features share @tiptap/suggestion.
npm install @tiptap/extension-mention @tiptap/suggestion7. Drag handle
Required when you use the dragHandle prop.
# Block reordering via drag handle (`dragHandle` prop)
npm install @tiptap/extension-drag-handle8. Markdown output
Required when you set output="markdown" or enable markdownAllowHtml.
# Markdown output (`output="markdown"`, `markdownAllowHtml`)
npm install tiptap-markdownOr - all in one
Same packages, single command. Copy-paste convenience.
# All 18 packages in one shot (copy-paste convenience)
npm install \
sv5ui \
@tiptap/core \
@tiptap/pm \
@tiptap/starter-kit \
@tiptap/extension-placeholder \
@tiptap/extension-character-count \
@tiptap/extension-bubble-menu \
@tiptap/extension-text-align \
@tiptap/extension-typography \
@tiptap/extension-image \
@tiptap/extension-table \
@tiptap/extension-table-row \
@tiptap/extension-table-cell \
@tiptap/extension-table-header \
@tiptap/extension-youtube \
@tiptap/extension-mention \
@tiptap/suggestion \
@tiptap/extension-drag-handle \
tiptap-markdownBasic Usage
Import from sv5ui/editor and bind value. The default toolbar is shown automatically.
<script lang="ts">
// Note the sub-export: 'sv5ui/editor', not 'sv5ui'.
import { Editor } from 'sv5ui/editor';
let value = $state('<p>Start writing…</p>');
</script>
<Editor bind:value placeholder="Write something amazing…" />Output Formats
Switch the serialization format with output. The bound value type follows: string for HTML and Markdown, structured EditorJSON for JSON.
Set output once on mount. Changing it at runtime is now ignored to avoid content corruption - call getValue(format) for a one-off conversion, or remount (e.g. with {#key output}) to switch formats.
HTML
JSON
Markdown
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import type { EditorJSON } from 'sv5ui/editor';
// HTML (default) - value is a string
let htmlValue = $state('<p>Hello <strong>world</strong></p>');
// JSON - value is a Tiptap document
let jsonValue = $state<string | EditorJSON>({ type: 'doc', content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Structured!' }] }
]});
// Markdown - string of Markdown (uses tiptap-markdown)
let mdValue = $state('# Heading\n\nA **Markdown** paragraph.');
</script>
<Editor bind:value={htmlValue} output="html" />
<Editor bind:value={jsonValue} output="json" />
<Editor bind:value={mdValue} output="markdown" />Toolbar
The toolbar prop accepts true (default), false (hide), or an explicit array of action ids and '|' separators.
Default
Custom subset
Sticky toolbar
<!-- Default toolbar - every common action -->
<Editor toolbar />
<!-- Custom subset - order matters, '|' inserts a vertical separator -->
<Editor toolbar={['bold', 'italic', 'underline', '|', 'h1', 'h2', '|', 'link']} />
<!-- Hide the toolbar (pair with bubbleMenu for floating controls) -->
<Editor toolbar={false} bubbleMenu />
<!-- Stick the toolbar to the top of the page on scroll -->
<Editor stickyToolbar />Bubble Menu
Hide the toolbar and surface a floating mini-menu on text selection.
<!-- Floating mini-toolbar that appears on text selection. -->
<Editor toolbar={false} bubbleMenu />Placeholder, Counter & Max Length
Combine placeholder, showCount, and maxLength for textarea-style inputs.
<!-- Combine placeholder with a character counter + hard cap. -->
<Editor
placeholder="Tell us about your weekend…"
showCount
maxLength={280}
/>Image Upload
Enable image and provide an async onImageUpload(file). One handler covers every route that produces a file: the toolbar picker, a paste and a drop. To show the picker button on the toolbar, include 'image' in the toolbar array.
When the upload rejects, pass onImageUploadError(error) to surface the failure to the user. If omitted, the error is only logged to the console. Either way the document is left as it was.
<script lang="ts">
import { Editor } from 'sv5ui/editor';
async function uploadImage(file: File): Promise<string> {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: fd });
const { url } = await res.json();
return url;
}
</script>
<!-- One handler covers all three routes that produce a file: the toolbar
picker, a paste and a drop. Paste several images at once and they are
uploaded one after another and inserted in order, rather than racing
each other. A dropped image lands where it was dropped, not where the
caret happened to be. -->
<Editor
image
onImageUpload={uploadImage}
onImageUploadError={(error) => toast.add({ title: 'Upload failed', color: 'error' })}
toolbar={['bold', 'italic', '|', 'link', 'image', '|', 'undo', 'redo']}
/>
<!-- Without onImageUpload nothing is intercepted: a pasted image falls
through to the editor's own handling, and the toolbar button opens the
URL dialog instead of a file picker. -->Paste and Drop
Since v2.7.0 all of these routes go through onImageUpload.
| Action | What happens |
|---|---|
Paste one image | Uploaded, then inserted at the caret |
Paste several at once | Uploaded one after another and inserted in that order, rather than racing |
Drop an image | Inserted at the drop position, which is not necessarily where the caret was |
Paste plain text | Handed back to the editor and handled as usual |
Paste with no onImageUpload | Nothing is intercepted, because there is nowhere to send the file |
Upload rejects | Reported through onImageUploadError, and the document is left untouched |
Handler returns an unsafe URL | Blocked with a console warning, and the image is not inserted |
Crop Before Upload
imageCrop opens an ImageCropper in a modal before onImageUpload runs, so what leaves the browser is the framed image rather than whatever came off the camera. Pass true for the defaults, or an object to shape both the dialog and the export.
Pick an image from the toolbar, or paste or drop one into the editor. This demo has no server: the handler turns the cropped file into a data URL, so what you see inserted is exactly what a real handler would have received.
<script lang="ts">
import { Editor } from 'sv5ui/editor';
</script>
<!-- imageCrop opens an ImageCropper in a modal before onImageUpload runs,
so what leaves the browser is the cropped file rather than the original.
true uses the defaults. -->
<Editor image imageCrop onImageUpload={uploadImage} />
<!-- An object configures both the dialog and the export. maxWidth is the
one to reach for first: it caps what the server receives without asking
the writer to think about pixels. -->
<Editor
image
imageCrop={{
aspect: 16 / 9,
mode: 'fixed',
title: 'Crop before upload',
description: 'Drag to reposition, scroll to zoom.',
confirmLabel: 'Insert image',
output: { maxWidth: 1200, quality: 0.85 }
}}
onImageUpload={uploadImage}
/>
<!-- Applies to the toolbar picker, a paste and a drop alike. Inserting an
image by URL never goes through the cropper, since reading pixels back
out of a remote image needs CORS headers the host may not send.
The dialog opens with the crop frame on the whole image, so pressing
Insert without dragging keeps the original rather than quietly cropping
it. That is ImageCropper's initialFrame="full", set by the dialog. -->imageCrop Options
Pass true to accept every default.
| Field | Type | Default |
|---|---|---|
aspect | number | 'free' | 'free' |
shape | 'rect' | 'circle' | 'rect' |
mode | 'fixed' | 'box' | 'box' |
title | string | 'Crop image' |
description | string | 'Drag to reposition, and drag a handle to resize.' |
confirmLabel | string | 'Insert' |
output.type | 'image/png' | 'image/jpeg' | 'image/webp' | source format |
output.quality | number | - |
output.maxWidth | number | - |
output.maxHeight | number | - |
Crop Behavior
| Situation | Result |
|---|---|
Toolbar, paste or drop | All three open the cropper before the upload handler is called |
Cancel in the dialog | Nothing is uploaded and the document does not change |
Insert without dragging anything | The whole image is kept. The dialog opens with the frame on the entire image (initialFrame="full"), so it never crops silently |
Insert an image by URL | Never goes through the cropper |
imageCrop not set | The upload flow is exactly what it was before |
Several images pasted at once | The cropper opens for each in turn, one upload at a time |
Esc or an outside click | Ignored: only Cancel closes the dialog, so a slip cannot lose the image |
URL Dialogs
The three dialogs that ask for a URL, insert a link, insert an image by URL and embed a YouTube video, are proper forms: a labelled input, a required marker, an icon and help text. The URL field takes focus as the dialog opens, and no error appears until the field is actually left, so an empty dialog never opens complaining.
The link dialog also has an optional Display text field, prefilled with whatever is selected in the document. Try the link button below with a selection and without one.
<!-- The three URL dialogs are real forms: a labelled input, a required
marker, an icon and help text. The link dialog has an optional Display
text field, prefilled with whatever is selected in the document.
Insert link Link URL Include the protocol, for example https://
Edit link Link URL same, with an Update button
Insert image (URL) Image URL
Embed YouTube Video URL
The URL input takes focus when the dialog opens, and no error is shown
until the field is actually left, so an empty dialog never greets the
writer with a complaint. -->
<Editor
image
youtube
toolbar={['bold', 'italic', '|', 'link', 'image', 'youtube', '|', 'undo', 'redo']}
/>What the Link Dialog Inserts
The selection and the Display text field together decide the result.
| Situation | Result |
|---|---|
Text selected, Display text empty | The link is applied to the selection as it stands |
Text selected, Display text filled | The selection is replaced by the typed text, carrying the link |
Nothing selected, Display text empty | The URL is inserted as its own label. Previously the document was left unchanged |
Nothing selected, Display text filled | The typed text is inserted, carrying the link |
Caret inside an existing link | The dialog switches to Edit link, and the change covers the whole link |
Tables
Enable tables for the extension, then include 'table' in the toolbar array to show the button that opens an 8×8 dimension picker.
<!-- Enable the extension AND include 'table' in the toolbar array. -->
<Editor
tables
toolbar={['bold', 'italic', '|', 'h2', 'h3', '|', 'bulletList', 'orderedList', '|', 'link', 'table', '|', 'undo', 'redo']}
/>YouTube Embeds
Enable youtube for the extension, then include 'youtube' in the toolbar array to show the URL-prompt button.
<!-- Enable the extension AND include 'youtube' in the toolbar array. -->
<Editor
youtube
toolbar={['bold', 'italic', '|', 'h2', 'h3', '|', 'bulletList', 'orderedList', '|', 'link', 'youtube', '|', 'undo', 'redo']}
/>@-Mentions
Provide onMention(query). Typing @ opens a floating popup; results show avatar + label, the selected id is what gets stored.
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import type { MentionItem } from 'sv5ui/editor';
const users: MentionItem[] = [
{ id: 'alice', label: 'Alice Martin', avatar: 'https://i.pravatar.cc/40?u=alice' },
{ id: 'bob', label: 'Bob Wilson', avatar: 'https://i.pravatar.cc/40?u=bob' },
{ id: 'carol', label: 'Carol Lee', avatar: 'https://i.pravatar.cc/40?u=carol' }
];
async function lookup(query: string): Promise<MentionItem[]> {
const q = query.toLowerCase();
return users.filter((u) =>
u.label.toLowerCase().includes(q) || u.id.toLowerCase().includes(q)
);
}
</script>
<Editor onMention={lookup} placeholder="Type @ to mention someone…" />Slash Commands
Type / on a new line to open a fuzzy-searchable command palette. Use buildDefaultSlashCommands(opts) to extend the built-ins.
<script lang="ts">
import { Editor, buildDefaultSlashCommands } from 'sv5ui/editor';
import type { SlashCommand } from 'sv5ui/editor';
// Extend the defaults with one custom command.
const commands: SlashCommand[] = [
...buildDefaultSlashCommands({ image: true, tables: true, youtube: true }),
{
id: 'todo',
label: 'TODO note',
description: 'Insert a yellow TODO callout',
icon: 'lucide:list-todo',
keywords: ['todo', 'task'],
run: ({ editor }) => {
editor.chain().focus()
.insertContent('<p><mark>TODO: </mark></p>')
.run();
}
}
];
</script>
<Editor slash slashCommands={commands} image tables youtube />Drag Handle
Set dragHandle to reveal a handle on the left of any block on hover. Drag to reorder paragraphs, headings, lists, blockquotes, etc.
<!-- Hover any block to reveal a drag handle on the left. Reorder
paragraphs, headings, lists, tables, etc. -->
<Editor dragHandle />Inside FormField
Wrap Editor in <FormField> - error state propagates and the FormField label targets the editor's contenteditable.
Body is required
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import { FormField } from 'sv5ui';
let value = $state('');
const error = $derived(value.replace(/<[^>]*>/g, '').trim().length === 0
? 'Body is required'
: '');
</script>
<FormField label="Body" required help="Markdown / rich text" {error}>
<Editor bind:value placeholder="Write a description…" />
</FormField>Imperative API
Bind api to focus the editor, run any toolbar action by id, read reactive state, or insert content programmatically.
Chars: 0 · Words: 0 · Active: none
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import type { EditorApi } from 'sv5ui/editor';
import { Button } from 'sv5ui';
let value = $state('<p>Edit me…</p>');
let api = $state<EditorApi>();
</script>
<Editor bind:value bind:api />
<div class="flex flex-wrap gap-2">
<Button label="Bold" onclick={() => api?.run('bold')} />
<Button label="H1" onclick={() => api?.run('h1')} />
<Button label="Clear" color="error" variant="outline" onclick={() => api?.clear()} />
<Button label="Focus end" onclick={() => api?.focus('end')} />
<Button label="Insert text" onclick={() => api?.insert('Inserted! ')} />
</div>
<!-- Reactive editor state -->
<p>
Chars: {api?.state.charCount ?? 0} ·
Words: {api?.state.wordCount ?? 0} ·
{api?.state.active.bold ? 'bold ' : ''}
{api?.state.active.italic ? 'italic ' : ''}
</p>Custom Toolbar
The toolbarSlot snippet replaces the entire toolbar - it receives reactive state and the same api as bind:api.
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import { Button } from 'sv5ui';
</script>
<!-- Fully custom toolbar - the snippet receives reactive `state` and `api`. -->
<Editor>
{#snippet toolbarSlot({ state, api })}
<div class="bg-surface-container/30 flex items-center gap-1 border-b border-on-surface/15 p-1">
<Button
size="xs" variant={state.active.bold ? 'solid' : 'ghost'}
icon="lucide:bold" onclick={() => api.run('bold')}
aria-pressed={state.active.bold} aria-label="Bold"
/>
<Button
size="xs" variant={state.active.italic ? 'solid' : 'ghost'}
icon="lucide:italic" onclick={() => api.run('italic')}
aria-pressed={state.active.italic} aria-label="Italic"
/>
<span class="text-on-surface/40 ml-auto px-2 text-xs">
{state.wordCount} words
</span>
</div>
{/snippet}
</Editor>EditorApi
Exposed via `bind:api` for imperative control.
| Member | Type |
|---|---|
editor | TiptapEditor | null |
state | EditorReactiveState |
focus(position?) | (position?: 'start' | 'end' | number) => void |
run(action) | (action: ToolbarAction) => void |
getValue(format?) | (format?: EditorOutput) => string | EditorJSON |
setValue(value) | (value: string | EditorJSON) => void |
clear() | () => void |
insert(content) | (content: string | EditorJSON) => void |
Toolbar Actions
All built-in action ids accepted by the `toolbar` prop (or `api.run(action)`).
| Action(s) | Description |
|---|---|
bold / italic / underline / strike / code | Inline formatting marks |
h1 / h2 / h3 | Heading levels (config-driven via `headingLevels`) |
paragraph | Normalize block to paragraph |
bulletList / orderedList | List blocks |
blockquote / codeBlock / horizontalRule | Other block types |
link / unlink | Manage hyperlinks |
alignLeft / alignCenter / alignRight / alignJustify | Text alignment |
image | Insert image. NOT in the default toolbar - include `'image'` in your `toolbar` array AND set the `image` prop on Editor |
table | Insert a table. NOT in the default toolbar - include `'table'` in your `toolbar` array AND set the `tables` prop on Editor |
youtube | Insert a YouTube embed. NOT in the default toolbar - include `'youtube'` in your `toolbar` array AND set the `youtube` prop on Editor |
undo / redo | History controls |
clearFormatting | Strip marks from the current selection |
UI Slots
Use the `ui` prop to override classes on internal elements.
| Slot | Description |
|---|---|
root | Outermost wrapper around toolbar + content + counter |
toolbar | Toolbar bar |
toolbarButton | Per-action button |
toolbarSeparator | Vertical divider between groups |
content | Editable content surface (the `prose` area) |
bubbleMenu | Floating bubble menu container |
footer | Bottom strip (counter or custom footer) |
count | Character/word count text |
Snippets
| Snippet | Description |
|---|---|
toolbarSlot | Replace the entire toolbar. Receives `{ state, api }` |
bubbleMenuSlot | Replace bubble menu content. Receives `{ state, api }` |
header | Custom content between the toolbar and the content area |
footer | Custom content below the content area (overrides the count footer) |
Props
| Prop | Type | Default |
|---|---|---|
value | string | EditorJSON | '' |
output | 'html' | 'json' | 'markdown' | 'html' |
placeholder | string | - |
onValueChange | (value) => void | - |
onFocus / onBlur | () => void | - |
readonly | boolean | false |
disabled | boolean | false |
autofocus | boolean | 'start' | 'end' | number | false |
maxLength | number | - |
showCount | boolean | false |
toolbar | boolean | (ToolbarAction | '|')[] | true |
stickyToolbar | boolean | false |
bubbleMenu | boolean | false |
headingLevels | (1|2|3|4|5|6)[] | [1, 2, 3] |
autolink | boolean | true |
linkOpenInNewTab | boolean | true |
image | boolean | false |
onImageUpload | (file: File) => Promise<string> | - |
onImageUploadError | (error: unknown) => void | - |
tables | boolean | false |
youtube | boolean | false |
dragHandle | boolean | false |
slash | boolean | false |
slashCommands | SlashCommand[] | - |
slashTrigger | string | '/' |
onMention | (query) => Promise<MentionItem[]> | - |
mentionTrigger | string | '@' |
markdownAllowHtml | boolean | false |
extensions | AnyExtension[] | - |
extensionsOverride | AnyExtension[] | - |
size | 'sm' | 'md' | 'lg' | 'md' |
color | ColorType | 'primary' |
api | EditorApi | - |
id | string | - |
name | string | - |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |