SwEditor
SwEditor is a rich text editor built on Tiptap (ProseMirror under the hood), aimed at compose scenarios (comments / posts / chat): two-way v-model, @ user mention, # topic, a built-in emoji panel, image / video upload, and an à-la-carte toolbar — and it is SSR-safe: drop it into any SSR / ISR page (the editor itself mounts client-only, so it neither breaks SSR nor forces the host page to ssr: false).
Styling follows "minimal necessary baseline" — the editing box / toolbar / dropdowns / emoji panel are all legible and decent by default; override colors / radius / padding via --sw-editor-* tokens or your own CSS.
Scope: the current implementation is the
composemode (text + mention + emoji + upload buttons). Long-form WYSIWYG (heading / table / code-block toolbar) is thedocumentmode — themodeprop is reserved, to be extended on demand.
Basic usage
v-model gives you the HTML string directly, and v-model:json keeps the Tiptap JSON structure in sync (see the two output panes below the demo); submit-on-enter submits on Enter (Shift / Ctrl / Meta + Enter inserts a line break).
Basic (v-model / v-model:json + submit on Enter)
<script setup lang="ts">
import { ref } from 'vue'
const content = ref('<p>Hello</p>')
function onSubmit() {
console.log(content.value) // get HTML on submit
}
</script>
<template>
<SwEditor v-model="content" placeholder="Say something…" submit-on-enter @submit="onSubmit" />
</template>
You can also bind
v-model:jsonfor the Tiptap JSON structure (kept in sync with HTML), or callgetHTML()/getText()/getJSON()viaref.
@ mention & # topic
Pass the :mention options object to inject search callbacks (decoupled from your API): typing @ triggers user search, # triggers topic search. With customTopic: true, an empty topic search falls back to the query word as a custom topic. Clicking an inserted mention fires @mention-click (navigation is up to you — the framework does not couple routing).
@ mention + # topic (with custom topic)
<script setup lang="ts">
import { ref } from 'vue'
const content = ref('')
// return { id, label }[] (extra fields like avatar allowed)
async function fetchUsers(q: string) {
return await request('/user/search', { body: { q } })
}
async function fetchTags(q: string) {
return await request('/tag/search', { body: { q } })
}
function onMentionClick(m: { id: string; type: string; label: string }) {
// type is '@' (user) or '#' (topic) — decide navigation yourself
navigateTo(m.type === '@' ? `/u/${m.id}` : `/topic/${m.label}`)
}
</script>
<template>
<SwEditor
v-model="content"
:mention="{ fetchUsers, fetchTags, customTopic: true }"
placeholder="Type @ or #"
@mention-click="onMentionClick"
/>
</template>
On submit, use getByChar(json, char) to extract the mention list from JSON:
const json = editorRef.value!.getJSON()
const atUsers = editorRef.value!.getByChar(json, '@') // [{ id, label, char }]
const hashTags = editorRef.value!.getByChar(json, '#')
Built-in toolbar + emoji panel
:tools renders built-in buttons à la carte (mention / topic / emoji / submit, plus image / video for upload); the #toolbar scoped slot holds business buttons (e.g. a tip button), placed between the tools and the send button. The emoji panel ships a curated native-unicode set (zero image assets, lazy-loaded with the editor core); replace the whole panel via the #emoji slot for a different style / image pack.
Built-in toolbar + emoji + business button
<template>
<SwEditor
v-model="content"
:mention="{ fetchUsers }"
:tools="['mention', 'topic', 'emoji', 'submit']"
@submit="onSend"
>
<!-- business button: receives editor instance + insert command -->
<template #toolbar="{ insert }">
<button type="button" @click="insert('🎁')">Tip</button>
</template>
</SwEditor>
</template>
Image / video upload
The image / video toolbar buttons wrap SwUploadImage / SwUploadVideo; inject your upload / query functions via :upload. On success it emits upload({ type, result }) for you to handle (send as a standalone message, or insert it yourself) — the framework does not insert media into the editor content for you (matching the "image / video as standalone message" pattern on social sites).
<template>
<SwEditor
v-model="content"
:tools="['image', 'video', 'submit']"
:upload="{
image: (file, thumbnail) => uploadImage(file, thumbnail),
video: { upload: chunkUpload, query: queryVideo },
}"
@upload="(p) => sendMedia(p.type, p.result)"
@upload-error="(e) => toast.error(e.message)"
/>
</template>
Character limit & read-only
limit is a CharacterCount hard cap — input beyond it is simply blocked, not just warned about; editable toggles read-only (reuse the same rendering for view/detail states). autofocus focuses on mount — fine for a single-editor page, avoid on multi-editor/document pages (it steals focus).
Hard character cap (limit) + read-only toggle (editable)
Thin-shell extensions (extensions)
extensions injects extra Tiptap extensions from the app side (thin-shell extension — no framework source changes; StarterKit / Placeholder / CharacterCount / Mention are already built in, don't inject them again). Extension packages are new dependencies — get approval per the dependency-control rule before installing:
<script setup lang="ts">
import Highlight from '@tiptap/extension-highlight'
const extensions = [Highlight.configure({ multicolor: true })]
</script>
<template>
<SwEditor v-model="content" :extensions="extensions" />
</template>
Styling tokens
Brand styling goes through --sw-editor-* tokens (the fallback is the default look) — no :deep piercing needed:
| Token | Default | Applies to |
|---|---|---|
--sw-editor-bg | var(--background) | Editing area background |
--sw-editor-border | var(--input) | Outer border color (1px width is always present; focus only changes color — zero reflow) |
--sw-editor-radius | var(--radius) | Outer border radius |
--sw-editor-padding | 0.625rem 0.75rem | Editing area padding |
--sw-editor-min-height | 4rem | Editing area min height (the SSR placeholder shell matches it, preventing hydration jump / CLS) |
--sw-editor-mention-color | var(--primary) | Text color of @mention / #topic |
/* Editor inside a dark card: dim the surface, brand-accent the mentions */
.comment-card .sw-editor {
--sw-editor-bg: var(--card);
--sw-editor-border: var(--border);
--sw-editor-min-height: 6rem;
--sw-editor-mention-color: var(--brand-gold);
}
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
modelValue | string | '' | v-model, editor content HTML | |
json (v-model:json) | object | null | null | editor content as Tiptap JSON | |
placeholder | string | '' | placeholder for empty state | |
limit | number | — | max characters (CharacterCount hard limit, preserves formatting) | |
submitOnEnter | boolean | false | Enter fires submit (Shift/Ctrl/Meta+Enter = line break) | |
editable | boolean | true | editable (false = read-only) | |
autofocus | boolean | false | focus on mount | |
mode | 'compose' | 'document' | 'compose' | mode tier (compose implemented; document reserved) | |
mention | { fetchUsers?, fetchTags?, customTopic? } | — | mention config; omit to disable @ / # | |
tools | EditorTool[] | — | built-in toolbar buttons: 'mention'|'topic'|'emoji'|'image'|'video'|'submit'; omit to hide the toolbar | |
upload | EditorUploadConfig | — | upload functions for image / video tools; the button renders only when its config is present | |
styles | Record<string, string> | {} | inline styles applied to the editing area (e.g. minHeight) | |
extensions | unknown[] | — | extra Tiptap extensions (thin-shell extension, no source fork) |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | content changed (HTML) |
update:json | object | content changed (Tiptap JSON) |
focus / blur | — | focus / blur |
submit | — | fired on Enter (when submitOnEnter) or by clicking the built-in toolbar send button (:tools includes 'submit') |
ready | Editor | editor instance ready |
mention-click | { id, type, label } | an inserted mention clicked (type is '@' / '#') |
upload | { type, result } | image / video uploaded (type is 'image' / 'video') |
upload-error | Error | upload failed |
Slots
| Slot | Scope | Description |
|---|---|---|
toolbar | { editor, insert } | render your own toolbar / business buttons (coexists with :tools) |
emoji | { insert } | replace the whole built-in emoji panel (default: built-in native-unicode panel) |
Expose (ref methods)
| Method | Returns | Description |
|---|---|---|
getHTML() | string | content HTML |
getText() | string | plain text |
getJSON() | object | Tiptap JSON |
getByChar(json, char) | { id, label, char }[] | mentions for a trigger char ('@' / '#') from JSON |
setContent(html) | void | set content (no update event) |
clear() | void | clear content |
focus() / blur() | void | focus / blur |
insertContent(content) | void | insert at cursor and focus |
getEditor() | Editor | null | the underlying Tiptap instance |
Notes
- SSR-safe: the editor itself mounts client-only (internal client-only island + placeholder shell), so it can go into SSR / ISR pages without breaking SSR or forcing
ssr: false. - v-model: bind content via
v-model(HTML) orv-model:json(JSON);refaccess works too. - XSS: sanitize the output HTML before rendering (server-side filtering or client-side DOMPurify); never
v-htmlunfiltered content. - Upload not embedded: the
uploadevent hands the result to you; the framework does not insert media into the content (in compose scenarios, image / video are usually sent as standalone messages). - Emoji: built-in curated native-unicode set (cross-platform look follows the system emoji font); use the
#emojislot for a consistent look / custom pack. - Icons: the framework depends on no icon library; toolbar icons are inline SVG.