Appearance
TipTap — live demo
This is the real @rozie-ui/tiptap-vue package running on this page (VitePress is itself a Vue app). Type in the editor, select text and drive the formatting buttons, or hit Clear — then watch the live HTML readout and word count update. The same TipTap component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit.
The document is two-way bound with v-model:html — the readout above updates live as you type, and the buttons drive the imperative handle (toggleBold, toggleItalic, toggleHeading, toggleBulletList, undo, redo, focusEditor, clearContent). The component bundles its own toolbar (Bold / Italic / H1 / H2 / Bullet list, with live active-state highlighting); the buttons here are a second, external toolbar driving the same $expose handle. See the full API for the complete prop/event/handle surface — including the toolbar / bubbleMenu / floatingMenu portal slots and the reactive nodeView slot.
What ships for each framework
You author the component once as a .rozie file:
html
<!--
TipTap.rozie — data-bound port of TipTap (ProseMirror-based rich-text editor).
TipTap's value isn't the editor logic — that's ProseMirror, framework-
agnostic. The value-add of the official wrappers (@tiptap/react,
@tiptap/vue-3, svelte-tiptap, ngx-tiptap, solid-tiptap …) is just gluing
onUpdate to component state, forwarding extensions, and bridging node
views. Six maintenance burdens, ONE Rozie source covers six frameworks —
and crucially gives Lit (no wrapper exists) and Solid (thin, no node
views) React/Vue-grade ergonomics from the same definition.
What the official wrappers DON'T give you that this does:
- True two-way content binding. Neither @tiptap/react nor @tiptap/vue-3
ships a controlled `value`/v-model contract — every consumer hand-rolls
the content/onUpdate/setContent sync loop. Here: <TipTap r-model:html="…" />
- A batteries-included toolbar with live active-state, OR bring-your-own
via the `toolbar` portal slot (receives the live editor).
- Selection-anchored `bubbleMenu` / `floatingMenu` portal slots over the
Floating-UI menu extensions (receive the live editor).
- A uniform imperative command handle across all six targets ($expose).
Surface (Phase 32, feature-rich expansion from the original 4 props;
character/word count added quick 260720-tzw; link editor added quick
260721-liz; setLink/unsetLink added quick 260809-6zp):
- props (14): html[model] / editable / placeholder / autofocus /
editorClass / ariaLabel / editorProps / extensions /
starterKit / nodeSpecs / uploadImage / maxLength /
enforceMaxLength / bubbleMenuShouldShow
- events (4): update / selectionUpdate / focus / blur
- $expose (25): getEditor, focusEditor, blurEditor, getHTML, getJSON,
getText, setContent, clearContent, toggleBold, toggleItalic,
toggleHeading, toggleBulletList, toggleUnderline, toggleOrderedList,
undo, redo, chain, isActive, can, isEmpty, getCharacterCount,
getWordCount, openLinkEditor, setLink, unsetLink
- slots (6): 3 mount-once portal slots — `toolbar` (consumer toolbar bound
to the editor), `bubbleMenu` + `floatingMenu` (selection-anchored
menus over @tiptap/extension-bubble-menu / -floating-menu, each
handed the live editor) — plus 2 REACTIVE portal slots,
`linkEditor` (consumer override for the built-in link-editing
surface; scope includes setLink/unsetLink/close) and `nodeView`
(consumer fragment rendered as a custom ProseMirror node, driven
by the `nodeSpecs` prop — a general custom-node registration
facility, not a hardcoded pair) — plus `count`, a plain reactive
scoped slot for the character/word counter
The `editorProps` (ProseMirror) and `extensions` (extra TipTap extensions
merged onto StarterKit) props are the consumer-extensibility passthroughs —
the analog of CodeMirror's `:extensions` and Chart.js's `:plugins`. The
`starterKit` prop is a StarterKit config passthrough (ask A) with
collision-aware auto-disable so a same-named consumer `extensions` entry
genuinely wins over StarterKit's internal copy.
Node-view portal slots (Phase 33) render a framework component as a custom
ProseMirror node — the marquee TipTap differentiator. The `nodeView` slot is
the FIRST shipped REACTIVE portal slot: it re-renders the consumer fragment in
place on every transaction. The primitive is GENERAL (ask B, Phase
260719-d9e): the consumer registers any number of custom nodes via the
`nodeSpecs` prop (name/tag/group/inline/atom/content/attrs) and a SINGLE
nodeView fragment that dispatches on `scope.node.type.name`. The two node
views that originally proved the primitive — a non-editable @mention atom
chip (Spike 009 / REQ-26) and an editable contentDOM callout whose
[data-rozie-hole] placeholder is grafted by the per-target bridge (Spike 008
/ REQ-23) — now ship as `nodeSpecs` recipes in
`examples/demos/TipTapNodeView*.rozie` rather than being built into the
component. See docs/guide/tiptap-comparison.md.
-->
<rozie name="TipTap" inherit-attrs="false" inherit-listeners="false" adopt-document-styles>
<props>
{
html: {
type: String,
default: '<p>Start writing…</p>',
model: true,
docs: {
description:
"The editor's document content as an HTML string — the sole `model: true` prop (two-way `r-model`). Typing writes the new HTML back through the model path (TipTap's `onUpdate`); a consumer write reflects into the live document, echo-guarded so a programmatic set does not reset the selection or re-emit `update`.",
example: '<TipTap r-model:html="content" placeholder="Start writing…" />',
},
},
editable: {
type: Boolean,
default: true,
docs: {
description:
"Whether the document is editable. Toggling it calls TipTap's `setEditable` with `emitUpdate: false` (no spurious `update`). When `false`, the internal toolbar is hidden and the wrapper gets an `is-readonly` class.",
},
},
placeholder: {
type: String,
default: '',
docs: {
description:
'Placeholder text, forwarded to the editor host as `data-placeholder` + `aria-placeholder` and painted as ghost text on the first empty node via the bundled Placeholder extension. An empty string adds no placeholder.',
},
},
autofocus: {
type: Boolean,
default: false,
docs: {
description:
"Whether to place the caret in the document on mount (TipTap's `autofocus` option).",
},
},
editorClass: {
type: String,
default: '',
docs: {
description:
'A CSS class applied to the contenteditable element (`editorProps.attributes.class`).',
},
},
ariaLabel: {
type: String,
default: 'Rich text editor',
docs: {
description:
'The accessible name (`aria-label`) applied to the contenteditable element.',
},
},
editorProps: {
type: Object,
default: () => ({}),
docs: {
description:
'ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper\'s attribute defaults.',
},
},
extensions: {
type: Array,
default: () => [],
docs: {
description:
'Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Consumer extensions genuinely win for a StarterKit-bundled node or mark: a same-named custom extension (e.g. a custom `Link`) auto-disables the corresponding StarterKit key (unless explicitly configured via `starterKit`), and the final extension array is name-deduped keeping the last (consumer) occurrence — so a custom Link/Underline/OrderedList replaces StarterKit\'s without a "Duplicate extension names" warning.',
},
},
starterKit: {
type: Object,
default: () => ({}),
docs: {
description:
"StarterKit config passthrough — spread into `StarterKit.configure(...)`. Accepts per-extension option objects or `false` to disable an extension, e.g. `{ heading:false }`, `{ heading:{ levels:[1,2] } }`, `{ link:false }`. A StarterKit-bundled node/mark is auto-disabled when a same-named custom extension is supplied via `extensions`; an explicitly-set key here is always respected and never overridden by that auto-disable scan.",
},
},
nodeSpecs: {
type: Array,
default: () => [],
docs: {
description:
"Custom ProseMirror node registration for the reactive `nodeView` portal slot — general facility, read ONCE at mount (setup-once construction, not reactive). Each entry: `{ name, tag, group, inline, atom, content, selectable, defining, attrs }` — `name` (required, unique node name), `tag` (required, parseHTML selector string | string[]), `group` (default `'block'`), `inline` (default `false`), `atom` (default `false` — no contentDOM), `content` (e.g. `'inline*'`; presence ⇒ the node gets an editable contentDOM), `selectable` (default `true`), `defining` (default `false`), `attrs` (`{ key: { default } }`, ProseMirror `addAttributes` shape). One `Node.create` is built per entry; all render through the SAME `nodeView` fragment, which dispatches on `scope.node.type.name`. An empty array (default) registers no custom nodes — zero overhead.",
example:
"<TipTap :node-specs=\"[{ name: 'mention', tag: 'span[data-mention]', group: 'inline', inline: true, atom: true, attrs: { id: { default: null } } }]\"><template #nodeView=\"{ node }\">…</template></TipTap>",
},
},
uploadImage: {
type: Function,
default: null,
docs: {
description:
"An async image-upload hook, signature `(file: File) => Promise<string>` resolving to a URL. When provided, the (otherwise-absent) Image extension is registered AND pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When `null` (default), the Image extension is absent and paste/drop are unchanged — zero overhead. The wrapper's paste/drop handling is a fallback: a consumer-supplied `editorProps.handlePaste` / `handleDrop` still wins.",
example: '<TipTap :upload-image="uploadFn" />',
},
},
maxLength: {
type: Number,
default: null,
docs: {
description:
'A soft character-count threshold. `null` (default) registers NO CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (gated — see `enforceMaxLength`) and renders a live `characters / maxLength` counter (overridable via the `#count` slot); once `$data.count.characters` exceeds it, the counter gets the `over` state. Overflow is still ALLOWED unless `enforceMaxLength` is also set.',
example: '<TipTap :max-length="500" />',
},
},
enforceMaxLength: {
type: Boolean,
default: false,
docs: {
description:
'Opts into a HARD cap at `maxLength` (negative-opt-out — `false` by default, soft mode). When `true` AND `maxLength` is set, CharacterCount is configured with `{ limit: maxLength }`, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When `false` (default), the counter still tracks and surfaces the `over` state past `maxLength`, but typing/pasting is never blocked. Has no effect when `maxLength` is `null`.',
},
},
bubbleMenuShouldShow: {
type: Function,
default: null,
docs: {
description:
"A custom `shouldShow` predicate for the GENERAL `bubbleMenu` slot — the TipTap signature `({ editor, view, state, oldState, from, to }) => boolean`. When provided, it REPLACES the general bubbleMenu's default predicate (show on a non-empty text selection), turning the `bubbleMenu` slot into a fully consumer-controllable selection-tooling surface (e.g. show only inside a table, or only for a specific mark). When `null` (default), the default non-empty-selection behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. NOTE: as a Function prop it lowers to a loosely-typed callable on some targets (React `any` / Angular `unknown`) — pass a correctly-typed predicate; the wrapper forwards it verbatim to `BubbleMenu.configure({ shouldShow })`.",
example:
'<TipTap :bubble-menu-should-show="({ editor }) => editor.isActive(\'table\')"><template #bubbleMenu="{ editor }">…</template></TipTap>',
},
},
}
</props>
<data>
{
active: {
bold: false,
italic: false,
h1: false,
h2: false,
bulletList: false,
underline: false,
orderedList: false,
link: false,
},
count: {
characters: 0,
words: 0,
},
// Named `linkState` (not `link`) — a `$data.link` key would make React
// auto-generate a `setLink` state setter that collides with the new
// `setLink` $expose verb (ROZ524, the $data-key==$expose-verb collision
// class; the setContent/setHtml precedent, this time on a nested $data key
// rather than the model prop).
linkState: {
href: '',
attrs: {},
},
}
</data>
<script>
import { Editor, Node } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Placeholder } from '@tiptap/extensions'
// Selection-anchored menu extensions (G2). SEPARATE packages (NOT in
// @tiptap/extensions), version-pinned in lockstep with @tiptap/core (3.23.5).
// Both export their extension as a NAMED export (`BubbleMenu` / `FloatingMenu`)
// — verified against the installed dist .d.ts — and are `.configure({ element })`
// Extensions that own Floating-UI positioning and append the host element to the
// editor's parent automatically (no manual document insertion needed).
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { FloatingMenu } from '@tiptap/extension-floating-menu'
// Image node extension (ask D). Not part of StarterKit. Version-pinned in
// lockstep with @tiptap/core (3.23.5). Named export `Image` — verified against
// the installed dist `.d.ts` (also carries a default export; we use the named
// form to match the BubbleMenu/FloatingMenu import style). Gated on
// $props.uploadImage — an absent hook registers NO Image extension.
import { Image } from '@tiptap/extension-image'
// Character/word count storage extension (D-01/D-02). SEPARATE package, not part
// of StarterKit, version-pinned in lockstep with core (3.23.5). Named export
// `CharacterCount` — verified against the installed dist `.d.ts` (re-exported
// from `@tiptap/extensions`, matching Placeholder's home package; also carries a
// default export, but the named form matches this file's import style). Gated on
// $props.maxLength / the `count` slot — an unfilled gate registers NO extension.
import { CharacterCount } from '@tiptap/extension-character-count'
// The live editor instance — null before mount / after destroy. Named `editor`
// (distinct from any template `ref="X"` name) so no capture-var-vs-ref double
// declaration trap (the Chart.js canvasEl/canvasNode lesson).
let editor = null
// The raw HTML string the editor currently reflects. Compared against in the
// $props.html reconciler so the watcher's mount-time fire is a no-op: the
// editor is created with `content: $props.html`, so right after mount the bound
// model already matches and setContent must NOT re-run (re-running it replaces
// the whole ProseMirror document and resets the selection — the official
// @tiptap/* wrappers guard the same way against the *raw* value, never against
// the normalized `editor.getHTML()`). This is the CodeMirror suppress-echo
// guard in HTML-string form (flatpickr lineage).
let lastHtml = null
// The `toolbar` portal slot's dispose handle. COMPONENT-scope (top-level let),
// NOT a $onMount-local — the Solid emitter hoists the $onMount-returned cleanup
// into a sibling onCleanup() OUTSIDE the mount-body IIFE, so a mount-local would
// lose scope there (the Chart.js tooltipEl/tooltipDispose hoist lesson).
let toolbarDispose = null
// The `bubbleMenu` / `floatingMenu` portal-slot dispose handles + the imperatively
// created menu host elements. COMPONENT-scope for the same hoist reason as
// toolbarDispose — and the host els must be reachable from BOTH the pre-`new
// Editor` extension build (the menu extension needs its `element` at construction)
// AND the post-construction portal mount, so they live here too (not $onMount
// locals). Each stays null when its slot is unfilled (zero overhead, no $portals
// reference fired — the nodeView discipline).
let bubbleMenuEl = null
let bubbleMenuDispose = null
let floatingMenuEl = null
let floatingMenuDispose = null
// ── Link editor (#2) surface. Its OWN dedicated bubble-menu instance (distinct
// `pluginKey: 'rozieLinkEditor'`) with a link-aware trigger, orthogonal to the
// general `bubbleMenu` slot. `linkEditorEl` is the imperatively-created host handed
// to that BubbleMenu extension (the bubbleMenuEl discipline — engine owns
// positioning). COMPONENT-scope for the same hoist reason as the menu els.
// - When the consumer fills the `#linkEditor` slot → `linkEditorHandle` is the
// REACTIVE portal handle ({ update, dispose }); refreshLink() re-renders it in
// place (Spike 016 proved a reactive portal survives the bubble-menu
// extension's element.remove()/appendChild detach-reattach cycles).
// - Otherwise → the component builds its OWN default form imperatively into
// `linkEditorEl` (`linkInputEl` = its URL <input>); refreshLink() imperatively
// refreshes the input value. Pure-script ⇒ byte-identical across all 6 targets,
// no framework-reconciliation risk, and no portal default-content (the emitter
// renders none for an unfilled portal slot).
// `openFlag` = the toolbar Link button's create-mode trigger (set true on click,
// cleared on Apply/Remove/Cancel/blur); the link-aware shouldShow shows the editor
// when `editor.isActive('link')` (edit mode) OR `openFlag` (create mode).
let linkEditorEl = null
let linkEditorHandle = null
let linkInputEl = null
let openFlag = false
// Last link state refreshLink() reflected, as a compare key — lets refreshLink
// early-return when the link mark is unchanged (a keystroke fires BOTH onUpdate
// and onSelectionUpdate, so refreshLink would otherwise run — and re-render the
// #linkEditor fragment — twice per keystroke).
let lastLinkKey = null
// Recompute the internal toolbar's active-mark booleans from the live editor.
const refreshActive = () => {
if (!editor) return
$data.active = {
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
h1: editor.isActive('heading', { level: 1 }),
h2: editor.isActive('heading', { level: 2 }),
bulletList: editor.isActive('bulletList'),
underline: editor.isActive('underline'),
orderedList: editor.isActive('orderedList'),
link: editor.isActive('link'),
}
}
// ── Link editor (#2) command helpers + reactive refresh. TOP-LEVEL const arrows
// (siblings of refreshActive/refreshCount) so every `editor` read sits at the same
// shallow, proven-safe depth — never nested inside an object-literal method (the
// redirectNestedThis gap [[project_emitter_redirect_nested_this_gap]]). The link
// scope's setLink/unsetLink/close are these top-level fns, referenced by identity
// from buildLinkScope so the consumer fragment (and the built-in form) call the
// SAME verbs. `extendMarkRange('link')` widens the selection to the whole link so
// an edit/removal applies to the entire mark, not just the caret word.
//
// DECLARATION ORDER IS LOAD-BEARING (topological, leaves first): apply/remove/close
// → buildLinkScope → refreshLink → openLinkEditor. The React/Solid/Lit emitters lift
// reactive closures into useCallback/memo with eager dependency ARRAYS, so a forward
// reference to a later-declared reactive const is a hard TS2448 (use-before-decl) —
// unlike a deferred function BODY, which is fine. apply/removeLink therefore do NOT
// call refreshLink (which would make them depend on it and re-introduce a cycle):
// the setLink/unsetLink chain dispatches a transaction that fires onSelectionUpdate +
// onUpdate, both of which already call refreshLink. Only openLinkEditor (safely last)
// calls it, for immediate prefill on the create affordance.
const applyLink = (attrs) => {
// A link mark requires a non-empty href — ignore an empty Apply (built-in form)
// or a hrefless consumer setLink rather than writing a degenerate `<a href="">`.
if (!attrs || typeof attrs.href !== 'string' || !attrs.href.trim()) return
editor?.chain().focus().extendMarkRange('link').setLink(attrs).run()
openFlag = false
}
const removeLink = () => {
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
openFlag = false
}
// Force the link-editor BubbleMenu to open/close right now, matching openFlag
// (bug fix — quick 260809-6zp). `editor?.commands.focus()` alone is NOT
// sufficient: TipTap's `focus` command early-returns with NO dispatch whenever
// `view.hasFocus() && position === null` (the whole document is already
// focused — the COMMON case once the create/close affordance is invoked from
// a `@mousedown.prevent`-guarded control, which deliberately never blurs the
// editor). And even a dispatched but otherwise-INERT transaction (no doc/
// selection change) is not enough either: @tiptap/extension-bubble-menu's own
// `update()` short-circuits with `isSame = !selectionChanged && !docChanged`
// BEFORE it ever re-runs `shouldShow` — so a no-op dispatch is silently
// swallowed by the extension's OWN guard, not just TipTap's `focus` command.
// The extension's `transactionHandler` (its own doc comment: "This allows
// external code to trigger ... via `editor.view.dispatch(editor.state.tr
// .setMeta(pluginKey, 'updatePosition'))`") is the official escape hatch: a
// transaction tagged with THIS surface's own `pluginKey` ('rozieLinkEditor')
// calls `show()`/`hide()` directly, bypassing both guards. This bit both
// `openLinkEditor` (create-mode toolbar button) and `closeLink` (built-in
// Cancel AND any consumer `close()`), on every target, whenever the editor
// was already focused. `editor` is the raw TipTap `Editor` instance on all 6.
const forceMenuRecheck = () => {
if (!editor) return
const visible = editor.isEditable && (editor.isActive('link') || openFlag)
editor.view.dispatch(editor.state.tr.setMeta('rozieLinkEditor', visible ? 'show' : 'hide'))
}
const closeLink = () => {
openFlag = false
// "Cancel" = discard the unsaved edit: revert the built-in form's input to the
// current link href. The surface itself is link-anchored (like Google Docs) — it
// stays while the caret is on a link and hides once openFlag is clear and the
// caret is off any link (or the doc is not editable).
if (linkInputEl) linkInputEl.value = $data.linkState.href
editor?.commands.focus()
forceMenuRecheck()
}
// The reactive `#linkEditor` slot scope — keys EXACTLY { editor, href, attrs,
// setLink, unsetLink, close } (spec §5.3). `attrs` is the raw link mark attrs
// object so a consumer can read custom attrs (e.g. data-course-link); setLink
// forwards whatever attrs object it is handed VERBATIM (REQ-42 — persistence of a
// custom attr is the consumer's Link.extend concern, not this wrapper's).
//
// Takes `href`/`attrs` as PARAMETERS rather than reading `$data.linkState` —
// every caller has just computed (or is about to compute) these values
// directly from the live editor, and reading them back off `$data`
// immediately after a same-tick write hits the React setState-is-async
// stale-read trap (the D-04 prefill fix's own class of bug, here on the
// ONGOING reactive-refresh path rather than the one-time mount path). Passing
// them straight through keeps every target reading the value that was ACTUALLY
// just computed, not a framework-buffered echo of it.
const buildLinkScope = (href, attrs) => ({
editor,
href,
attrs,
setLink: applyLink,
unsetLink: removeLink,
close: closeLink,
})
// Recompute link state from the live editor + drive the surface. Called from
// onSelectionUpdate + onUpdate (and after content sets). When the consumer slot is
// filled, re-render the reactive portal in place; otherwise refresh the built-in
// form's input value — but NOT while the user is typing in it (don't stomp mid-edit).
const refreshLink = () => {
if (!editor) return
const a = editor.getAttributes('link')
const href = a.href || ''
// Early-return when the link mark is unchanged — collapses the twice-per-keystroke
// onUpdate+onSelectionUpdate double-fire to one effective refresh (no redundant
// reactive-portal re-render of the #linkEditor fragment on caret moves that don't
// change the link).
const key = href + '