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 compose mode (text + mention + emoji + upload buttons). Long-form WYSIWYG (heading / table / code-block toolbar) is the document mode — the mode prop 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:json for the Tiptap JSON structure (kept in sync with HTML), or call getHTML() / getText() / getJSON() via ref.

@ 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:

TokenDefaultApplies to
--sw-editor-bgvar(--background)Editing area background
--sw-editor-bordervar(--input)Outer border color (1px width is always present; focus only changes color — zero reflow)
--sw-editor-radiusvar(--radius)Outer border radius
--sw-editor-padding0.625rem 0.75remEditing area padding
--sw-editor-min-height4remEditing area min height (the SSR placeholder shell matches it, preventing hydration jump / CLS)
--sw-editor-mention-colorvar(--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

NameTypeRequiredDefaultDescription
modelValuestring''v-model, editor content HTML
json (v-model:json)object | nullnulleditor content as Tiptap JSON
placeholderstring''placeholder for empty state
limitnumber—max characters (CharacterCount hard limit, preserves formatting)
submitOnEnterbooleanfalseEnter fires submit (Shift/Ctrl/Meta+Enter = line break)
editablebooleantrueeditable (false = read-only)
autofocusbooleanfalsefocus on mount
mode'compose' | 'document''compose'mode tier (compose implemented; document reserved)
mention{ fetchUsers?, fetchTags?, customTopic? }—mention config; omit to disable @ / #
toolsEditorTool[]—built-in toolbar buttons: 'mention'|'topic'|'emoji'|'image'|'video'|'submit'; omit to hide the toolbar
uploadEditorUploadConfig—upload functions for image / video tools; the button renders only when its config is present
stylesRecord<string, string>{}inline styles applied to the editing area (e.g. minHeight)
extensionsunknown[]—extra Tiptap extensions (thin-shell extension, no source fork)

Events

EventPayloadDescription
update:modelValuestringcontent changed (HTML)
update:jsonobjectcontent 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')
readyEditoreditor instance ready
mention-click{ id, type, label }an inserted mention clicked (type is '@' / '#')
upload{ type, result }image / video uploaded (type is 'image' / 'video')
upload-errorErrorupload failed

Slots

SlotScopeDescription
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)

MethodReturnsDescription
getHTML()stringcontent HTML
getText()stringplain text
getJSON()objectTiptap JSON
getByChar(json, char){ id, label, char }[]mentions for a trigger char ('@' / '#') from JSON
setContent(html)voidset content (no update event)
clear()voidclear content
focus() / blur()voidfocus / blur
insertContent(content)voidinsert at cursor and focus
getEditor()Editor | nullthe 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) or v-model:json (JSON); ref access works too.
  • XSS: sanitize the output HTML before rendering (server-side filtering or client-side DOMPurify); never v-html unfiltered content.
  • Upload not embedded: the upload event 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 #emoji slot for a consistent look / custom pack.
  • Icons: the framework depends on no icon library; toolbar icons are inline SVG.