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. Everything below is driven by the same TipTap.rozie source that compiles to all six frameworks.
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.
One source, six outputs
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):
- props (8): html[model] / editable / placeholder / autofocus /
editorClass / ariaLabel / editorProps / extensions
- events (4): update / selectionUpdate / focus / blur
- $expose (18): getEditor, focusEditor, blurEditor, getHTML, getJSON,
getText, setContent, clearContent, toggleBold, toggleItalic,
toggleHeading, toggleBulletList, undo, redo, chain,
isActive, can, isEmpty
- slots (4): 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 `nodeView`, a REACTIVE portal slot
(consumer fragment rendered as a custom ProseMirror node — both a
non-editable @mention atom chip and an editable [data-rozie-hole]
contentDOM callout)
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`.
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. Two node views ship, proving both halves of 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). 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, …). Composed **last** so consumer extensions win for the same node or mark.',
},
},
}
</props>
<data>
{
active: {
bold: false,
italic: false,
h1: false,
h2: false,
bulletList: false,
},
}
</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'
// 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
// 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'),
}
}
// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator). When the consumer fills the
// `nodeView` slot, two custom ProseMirror nodes render the consumer fragment as
// a custom node *in-engine*, re-rendering it in place on every transaction via
// the reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). Both halves of the primitive are proven and shipped here:
//
// 1. `mention` — a NON-EDITABLE inline ATOM (selectable:true, no contentDOM,
// Spike 009 / REQ-26). selectNode/deselectNode/update(node) → handle.update
// so the chip re-renders in place (engine-driven; no Rozie reactive loop).
//
// 2. `callout` — an EDITABLE BLOCK (content:'inline*', so it HAS a contentDOM,
// Spike 008 / REQ-23). ProseMirror owns the editable hole; the consumer
// fragment renders chrome wrapping a [data-rozie-hole] placeholder and the
// per-target portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
const makeNodeView = (nv, editable) => (props) => {
const { node, getPos, editor: ed } = props
// engine-owned outer host the consumer fragment mounts into.
const dom = document.createElement(editable ? 'div' : 'span')
dom.className = editable ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline'
// EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
// into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
const contentDOM = editable ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null
if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content'
const updateAttributes = (attrs) => {
if (typeof getPos !== 'function') return
const pos = getPos()
if (pos == null) return
ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs }))
}
const buildScope = (n, selected) => ({
node: n,
selected,
updateAttributes,
getPos,
editor: ed,
...(contentDOM ? { contentDOM } : {}),
})
// Reactive handle — { update, dispose }. The fragment mounts ONCE; every
// engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
const handle = nv(dom, buildScope(node, false))
// contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
// consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
// ProseMirror manages `contentDOM` and renders the node's editable children
// INTO it, so `contentDOM` must live inside the visible hole. The fragment is
// rendered into `dom` by the per-target reactive portal — synchronously on
// React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
// Angular (REQ-23). A query-after-render graft (retried across a microtask +
// a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
// the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
// and the framework never reconciles it away (the hole carries no child binding).
const graftContentDOM = (attempt) => {
if (!contentDOM) return
const hole = dom.querySelector('[data-rozie-hole]')
if (hole) {
if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM)
return
}
if (attempt < 5) {
if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1))
else requestAnimationFrame(() => graftContentDOM(attempt + 1))
}
}
graftContentDOM(0)
// After a reactive re-render (chrome update), re-graft so a fragment that
// recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
// (REQ-24 — the editable subtree survives every chrome update).
const updateInPlace = (n, selected) => {
handle.update(buildScope(n, selected))
if (contentDOM) graftContentDOM(0)
}
return {
dom,
...(contentDOM ? { contentDOM } : {}),
// attr / content change for THIS node → re-render the fragment in place,
// keep the view (return true). The new node identity is forwarded so the
// fragment reads fresh node.attrs (REQ-26).
update(nextNode) {
if (nextNode.type !== node.type) return false
updateInPlace(nextNode, false)
return true
},
// NodeSelection enters/leaves the node → toggle `selected` in scope so the
// chip's selected styling is pure engine-driven reactive `update`.
selectNode() {
updateInPlace(node, true)
},
deselectNode() {
updateInPlace(node, false)
},
destroy() {
handle.dispose()
},
}
}
// Build the two custom Nodes bound to the reactive nodeView portal. Takes the
// per-target `$portals.nodeView` (captured here so the reference stays inside
// the mount lifecycle — never top-level, per the bundled-leaf typecheck rule).
const makeNodeViewExtensions = (nv) => {
// (1) NON-EDITABLE inline atom @mention chip (Spike 009 / REQ-26).
const Mention = Node.create({
name: 'rozieMention',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes: () => ({
id: { default: null },
label: { default: '' },
}),
parseHTML: () => [{ tag: 'span[data-rozie-mention]' }],
// ATOM nodes are leaf nodes — their renderHTML must NOT include a `0` content
// hole (ProseMirror's DOMSerializer throws "Content hole not allowed in a leaf
// node spec"). The chip's visible content is supplied by the node view; the
// serialized form is just the marker span carrying the attrs.
renderHTML: ({ HTMLAttributes }) => ['span', { 'data-rozie-mention': '', ...HTMLAttributes }],
addNodeView: () => makeNodeView(nv, false),
})
// (2) EDITABLE block callout with a contentDOM hole (Spike 008 / REQ-23).
const Callout = Node.create({
name: 'rozieCallout',
group: 'block',
content: 'inline*',
defining: true,
addAttributes: () => ({
tone: { default: 'info' },
}),
parseHTML: () => [{ tag: 'div[data-rozie-callout]' }],
renderHTML: ({ HTMLAttributes }) => ['div', { 'data-rozie-callout': '', ...HTMLAttributes }, 0],
addNodeView: () => makeNodeView(nv, true),
})
return [Mention, Callout]
}
$onMount(() => {
lastHtml = $props.html
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
const nodeViewExtensions = $slots.nodeView ? makeNodeViewExtensions($portals.nodeView) : []
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
const placeholderExtensions = $props.placeholder ? [Placeholder.configure({ placeholder: $props.placeholder })] : []
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
if ($slots.bubbleMenu) {
bubbleMenuEl = document.createElement('div')
bubbleMenuEl.className = 'rozie-tiptap-bubble-menu'
}
if ($slots.floatingMenu) {
floatingMenuEl = document.createElement('div')
floatingMenuEl.className = 'rozie-tiptap-floating-menu'
}
const menuExtensions = [
...(bubbleMenuEl ? [BubbleMenu.configure({ element: bubbleMenuEl })] : []),
...(floatingMenuEl ? [FloatingMenu.configure({ element: floatingMenuEl })] : []),
]
editor = new Editor({
element: $refs.editorEl,
content: $props.html,
editable: $props.editable,
autofocus: $props.autofocus,
// StarterKit first; the Placeholder ext next; the reactive node-view nodes
// next; consumer extensions LAST so they win (TipTap applies later-registered
// extensions over earlier ones for the same node/mark).
extensions: [StarterKit, ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...$props.extensions],
editorProps: {
attributes: {
'aria-label': $props.ariaLabel,
...($props.editorClass ? { class: $props.editorClass } : {}),
...($props.placeholder ? { 'data-placeholder': $props.placeholder, 'aria-placeholder': $props.placeholder } : {}),
},
// Consumer editorProps spread LAST — full ProseMirror editorProps control
// (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
...$props.editorProps,
},
onUpdate: ({ editor }) => {
const next = editor.getHTML()
lastHtml = next
// Round-trip guard — see CodeMirror/Flatpickr for the same shape.
if (next !== $props.html) $model.html = next
$emit('update', next)
},
onSelectionUpdate: () => {
refreshActive()
$emit('selectionUpdate')
},
onFocus: () => $emit('focus'),
onBlur: () => $emit('blur'),
})
refreshActive()
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
if ($slots.toolbar && $refs.toolbarEl) {
toolbarDispose = $portals.toolbar($refs.toolbarEl, { editor })
}
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
if (bubbleMenuEl) {
bubbleMenuDispose = $portals.bubbleMenu(bubbleMenuEl, { editor })
}
if (floatingMenuEl) {
floatingMenuDispose = $portals.floatingMenu(floatingMenuEl, { editor })
}
return () => {
toolbarDispose?.()
toolbarDispose = null
bubbleMenuDispose?.()
bubbleMenuDispose = null
floatingMenuDispose?.()
floatingMenuDispose = null
editor?.destroy()
}
})
// Reconcile EXTERNAL prop changes back into the editor without bouncing through
// onUpdate. The guard compares against `lastHtml` (the value the editor was last
// set with) — NOT `editor.getHTML()` (ProseMirror's normalized serialization,
// which never string-equals the author's raw HTML, so a getHTML() guard would
// re-run setContent on every mount and reset the selection). setContent's
// { emitUpdate: false } skips the change emission (TipTap v3 — the 2nd arg is an
// options object, not a bare boolean as in v2).
$watch(() => $props.html, (v) => {
if (!editor) return
if (v === lastHtml) return
lastHtml = v
editor.commands.setContent(v, { emitUpdate: false })
refreshActive()
})
// setEditable's 2nd arg is `emitUpdate` (defaults to true in TipTap v3). Pass
// `false` — toggling editability is not a content change and must NOT emit an
// `update`, which would round-trip ProseMirror's normalized HTML back into the
// bound model.
$watch(() => $props.editable, (v) => editor?.setEditable(v, false))
// ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
// the marquee surface: 14 verbs over the live Editor, uniform across all 6
// targets. Each guards the pre-mount / destroyed `editor = null`.
//
// Collision discipline:
// - The content setter is named `setContent`, NOT `setHtml` — an `html` model
// prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
// $expose verb would collide on the React target (ROZ524). (CodeMirror's
// setValue→replaceValue lesson, html edition.)
// - None of the 14 names collide with LitElement reserved lifecycle methods
// (update/render/firstUpdated/updated/willUpdate/requestUpdate).
// - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
// `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
// class-based targets (Angular) an output field and a method cannot share a
// name (ROZ121). The diagnostic's own guidance: rename the method, keep the
// event's public name. (The expose-verb-vs-event-name collision lesson.)
// - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
// ariaLabel/editorProps/extensions).
function getEditor() { return editor }
function focusEditor() { editor?.commands.focus() }
function blurEditor() { editor?.commands.blur() }
function getHTML() { return editor ? editor.getHTML() : '' }
function getJSON() { return editor ? editor.getJSON() : null }
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
function getText() { return editor ? editor.getText() : '' }
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
function setContent(next) {
if (!editor) return
const v = next ?? ''
if (v === lastHtml) return
lastHtml = v
editor.commands.setContent(v, { emitUpdate: false })
$model.html = v
refreshActive()
}
function clearContent() {
if (!editor) return
editor.commands.clearContent()
lastHtml = editor.getHTML()
$model.html = lastHtml
refreshActive()
}
function toggleBold() { editor?.chain().focus().toggleBold().run(); refreshActive() }
function toggleItalic() { editor?.chain().focus().toggleItalic().run(); refreshActive() }
function toggleHeading(level) {
editor?.chain().focus().toggleHeading({ level: level ?? 1 }).run()
refreshActive()
}
function toggleBulletList() { editor?.chain().focus().toggleBulletList().run(); refreshActive() }
function undo() { editor?.chain().focus().undo().run(); refreshActive() }
function redo() { editor?.chain().focus().redo().run(); refreshActive() }
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
function chain() { return editor ? editor.chain().focus() : null }
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
// - isActive(name, attrs?): is a mark/node active in the current selection
// (drive toolbar button active styling). False before mount.
// - can(): the command-availability chain (editor.can().chain()…run()) for
// enable/disable of toolbar buttons. null before mount (mirrors chain()).
// - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
function isActive(name, attrs) { return editor ? editor.isActive(name, attrs) : false }
function can() { return editor ? editor.can() : null }
function isEmpty() { return editor ? editor.isEmpty : true }
$expose({
getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent,
toggleBold, toggleItalic, toggleHeading, toggleBulletList, undo, redo, chain,
isActive, can, isEmpty,
})
</script>
<template>
<div class="rozie-tiptap" :class="{ 'is-readonly': !$props.editable }">
<!-- Internal batteries-included toolbar — rendered when editable AND the
consumer has NOT supplied a `toolbar` slot. Buttons drive the $expose
command verbs directly; active state tracks editor.isActive() live. -->
<div class="rozie-tiptap-toolbar" r-if="$props.editable && !$slots.toolbar">
<button type="button" :class="{ active: $data.active.bold }" @click="toggleBold" aria-label="Bold"><strong>B</strong></button>
<button type="button" :class="{ active: $data.active.italic }" @click="toggleItalic" aria-label="Italic"><em>I</em></button>
<span class="sep" />
<button type="button" :class="{ active: $data.active.h1 }" @click="toggleHeading(1)" aria-label="Heading 1">H1</button>
<button type="button" :class="{ active: $data.active.h2 }" @click="toggleHeading(2)" aria-label="Heading 2">H2</button>
<span class="sep" />
<button type="button" :class="{ active: $data.active.bulletList }" @click="toggleBulletList" aria-label="Bullet list">• List</button>
</div>
<!-- Consumer toolbar portal host — rendered when editable AND the `toolbar`
slot is filled. $portals.toolbar (in $onMount) mounts the consumer's
fragment here with the live editor in scope. -->
<div class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" ref="toolbarEl" r-if="$props.editable && $slots.toolbar"></div>
<div ref="editorEl" class="rozie-tiptap-content" :data-placeholder="$props.placeholder" />
</div>
<!--
Portal-slot primitive (Spike 003). The `toolbar` slot is declared but NOT
rendered inline — per-target template emitters skip it; it exists only to
declare the consumer-facing render-prop / scoped-slot / contentChild shape
(per target). The wrapper invokes it from script via
$portals.toolbar($refs.toolbarEl, { editor }) inside $onMount; the portal
helper mounts the consumer's fragment into the toolbar host node and returns a
dispose handle the wrapper calls on unmount. ROZ127-clean: `toolbar` ≠ any prop.
-->
<slot name="toolbar" portal :params="['editor']" />
<!--
Selection-anchored menu portal slots (G2). Same mount-once portal shape as
`toolbar` (NO `reactive`) — but with NO template host div: the host element is
created imperatively in $onMount and handed to the Floating-UI menu extension
(@tiptap/extension-bubble-menu / -floating-menu), which owns the element's
positioning and appends it to the editor's parent. The wrapper invokes
$portals.bubbleMenu / $portals.floatingMenu(menuEl, { editor }) inside $onMount,
mounting the consumer's menu fragment into that host node, and returns a dispose
handle called on unmount. The default `shouldShow` shows the bubble menu on a
non-empty text selection and the floating menu on an empty line.
ROZ127-clean: `bubbleMenu` / `floatingMenu` ≠ any prop name (html/editable/
placeholder/autofocus/editorClass/ariaLabel/editorProps/extensions).
-->
<slot name="bubbleMenu" portal :params="['editor']" />
<slot name="floatingMenu" portal :params="['editor']" />
<!--
Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive` portal
slot). Declared but NOT rendered inline; per-target template emitters skip it.
It exists to declare the consumer-facing render-prop / scoped-slot / contentChild
shape (per target) for a CUSTOM ProseMirror NODE. Invoked from the addNodeView
closures via $portals.nodeView(dom, scope) where scope carries the live node
state; the per-target portal bridge mounts the consumer fragment into the
engine-owned node DOM and returns a { update, dispose } handle (the reactive
variant — re-renders IN PLACE on every transaction, no remount).
For an EDITABLE node (the `callout`), the consumer fragment renders chrome
WRAPPING a `[data-rozie-hole]` placeholder element; the per-target bridge grafts
the engine-owned `contentDOM` (in scope) into that hole — native ref on
React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular (REQ-23).
ProseMirror then owns + manages the editable subtree; the framework must never
reconcile it away. For the NON-EDITABLE `mention` atom, `contentDOM` is absent
and the chip is purely a reactive read of `node.attrs` + `selected` (REQ-26).
ROZ127-clean: `nodeView` ≠ any prop name (html/editable/placeholder/autofocus/
editorClass/ariaLabel/editorProps/extensions).
-->
<slot name="nodeView" portal reactive :params="['node', 'selected', 'updateAttributes', 'getPos', 'editor', 'contentDOM']" />
</template>
<style>
.rozie-tiptap {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
overflow: hidden;
background: white;
}
.rozie-tiptap.is-readonly {
background: #fafafa;
}
.rozie-tiptap-toolbar {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #f5f5f7;
}
.rozie-tiptap-toolbar button {
padding: 0.25rem 0.5rem;
border: 1px solid transparent;
background: transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
min-width: 1.75rem;
color: rgba(0, 0, 0, 0.65);
}
.rozie-tiptap-toolbar button:hover {
background: rgba(0, 0, 0, 0.06);
}
.rozie-tiptap-toolbar button.active {
background: #1a1a1a;
color: white;
border-color: #1a1a1a;
}
.rozie-tiptap-toolbar .sep {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.25rem;
}
.rozie-tiptap-content {
padding: 0.625rem 0.875rem;
min-height: 6rem;
font: inherit;
outline: none;
}
.rozie-tiptap-content p { margin: 0 0 0.5rem; }
.rozie-tiptap-content p:last-child { margin-bottom: 0; }
.rozie-tiptap-content h1 { font-size: 1.5rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content h2 { font-size: 1.25rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content ul { margin: 0 0 0.5rem; padding-left: 1.5rem; }
/* Placeholder ghost-text (G3) via the :root { } engine-DOM escape hatch (Phase
34). The Placeholder extension adds `.is-editor-empty` + a `data-placeholder`
attribute to the first empty ProseMirror node — an engine-rendered node that
never carries Rozie's [data-rozie-s-*] scope attribute, so a plain scoped rule
would silently fail to match on React/Solid/Lit. The nested `:root { }` form
emits its children UNSCOPED/global on all six targets, reaching the engine node.
(Not `:global()` — that is a ROZ128 hard error; `:root { nested }` is canonical.) */
:root {
.rozie-tiptap-content .is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: rgba(0, 0, 0, 0.4);
float: left;
height: 0;
pointer-events: none;
}
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/tiptap-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { clsx, useControllableState } from '@rozie/runtime-react';
import './TipTap.css';
import './TipTap.global.css';
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).
// 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';
// 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).
interface ToolbarCtx { editor: any; }
interface BubbleMenuCtx { editor: any; }
interface FloatingMenuCtx { editor: any; }
interface NodeViewCtx { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any; }
interface TipTapProps {
/**
* 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…" />
*/
html?: string;
defaultHtml?: string;
onHtmlChange?: (html: string) => void;
/**
* 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.
*/
editable?: boolean;
/**
* 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.
*/
placeholder?: string;
/**
* Whether to place the caret in the document on mount (TipTap's `autofocus` option).
*/
autofocus?: boolean;
/**
* A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
*/
editorClass?: string;
/**
* The accessible name (`aria-label`) applied to the contenteditable element.
*/
ariaLabel?: string;
/**
* ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
*/
editorProps?: Record<string, any>;
/**
* Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Composed **last** so consumer extensions win for the same node or mark.
*/
extensions?: any[];
onUpdate?: (...args: any[]) => void;
onSelectionUpdate?: (...args: any[]) => void;
onFocus?: (...args: any[]) => void;
onBlur?: (...args: any[]) => void;
renderToolbar?: (ctx: ToolbarCtx) => ReactNode;
renderBubbleMenu?: (ctx: BubbleMenuCtx) => ReactNode;
renderFloatingMenu?: (ctx: FloatingMenuCtx) => ReactNode;
renderNodeView?: (ctx: NodeViewCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface TipTapHandle {
getEditor: (...args: any[]) => any;
focusEditor: (...args: any[]) => any;
blurEditor: (...args: any[]) => any;
getHTML: (...args: any[]) => any;
getJSON: (...args: any[]) => any;
getText: (...args: any[]) => any;
setContent: (...args: any[]) => any;
clearContent: (...args: any[]) => any;
toggleBold: (...args: any[]) => any;
toggleItalic: (...args: any[]) => any;
toggleHeading: (...args: any[]) => any;
toggleBulletList: (...args: any[]) => any;
undo: (...args: any[]) => any;
redo: (...args: any[]) => any;
chain: (...args: any[]) => any;
isActive: (...args: any[]) => any;
can: (...args: any[]) => any;
isEmpty: (...args: any[]) => any;
}
const TipTap = forwardRef<TipTapHandle, TipTapProps>(function TipTap(_props: TipTapProps, ref): JSX.Element {
const portalRoots = useRef<Set<Root>>(new Set());
const __defaultEditorProps = useState(() => (() => ({}))())[0];
const __defaultExtensions = useState(() => (() => [])())[0];
const props: Omit<TipTapProps, 'editable' | 'placeholder' | 'autofocus' | 'editorClass' | 'ariaLabel' | 'editorProps' | 'extensions'> & { editable: boolean; placeholder: string; autofocus: boolean; editorClass: string; ariaLabel: string; editorProps: Record<string, any>; extensions: any[] } = {
..._props,
editable: _props.editable ?? true,
placeholder: _props.placeholder ?? '',
autofocus: _props.autofocus ?? false,
editorClass: _props.editorClass ?? '',
ariaLabel: _props.ariaLabel ?? 'Rich text editor',
editorProps: _props.editorProps ?? __defaultEditorProps,
extensions: _props.extensions ?? __defaultExtensions,
};
const _renderToolbarRef = useRef(props.renderToolbar);
_renderToolbarRef.current = props.renderToolbar;
const _renderBubbleMenuRef = useRef(props.renderBubbleMenu);
_renderBubbleMenuRef.current = props.renderBubbleMenu;
const _renderFloatingMenuRef = useRef(props.renderFloatingMenu);
_renderFloatingMenuRef.current = props.renderFloatingMenu;
const _renderNodeViewRef = useRef(props.renderNodeView);
_renderNodeViewRef.current = props.renderNodeView;
const lastHtml = useRef<any>(null);
const bubbleMenuEl = useRef<any>(null);
const floatingMenuEl = useRef<any>(null);
const editor = useRef<any>(null);
const toolbarDispose = useRef<any>(null);
const bubbleMenuDispose = useRef<any>(null);
const floatingMenuDispose = useRef<any>(null);
const [html, setHtml] = useControllableState({
value: props.html,
defaultValue: props.defaultHtml ?? '<p>Start writing…</p>',
onValueChange: props.onHtmlChange,
});
const _editableRef = useRef(props.editable);
_editableRef.current = props.editable;
const _htmlRef = useRef(html);
_htmlRef.current = html;
const [active, setActive] = useState({
bold: false,
italic: false,
h1: false,
h2: false,
bulletList: false
});
const toolbarEl = useRef<HTMLDivElement | null>(null);
const editorEl = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const refreshActive = useCallback(() => {
if (!editor.current) return;
setActive({
bold: editor.current.isActive('bold'),
italic: editor.current.isActive('italic'),
h1: editor.current.isActive('heading', {
level: 1
}),
h2: editor.current.isActive('heading', {
level: 2
}),
bulletList: editor.current.isActive('bulletList')
});
}, []);
function makeNodeView(nv: any, editable: any) {
return (props: any) => {
const {
node,
getPos,
editor: ed
} = props;
// engine-owned outer host the consumer fragment mounts into.
const dom = document.createElement(editable ? 'div' : 'span');
dom.className = editable ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
// EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
// into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
const contentDOM = editable ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
const updateAttributes = (attrs: any) => {
if (typeof getPos !== 'function') return;
const pos = getPos();
if (pos == null) return;
ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...attrs
}));
};
const buildScope = (n: any, selected: any) => ({
node: n,
selected,
updateAttributes,
getPos,
editor: ed,
...(contentDOM ? {
contentDOM
} : {})
});
// Reactive handle — { update, dispose }. The fragment mounts ONCE; every
// engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
const handle = nv(dom, buildScope(node, false));
// contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
// consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
// ProseMirror manages `contentDOM` and renders the node's editable children
// INTO it, so `contentDOM` must live inside the visible hole. The fragment is
// rendered into `dom` by the per-target reactive portal — synchronously on
// React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
// Angular (REQ-23). A query-after-render graft (retried across a microtask +
// a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
// the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
// and the framework never reconciles it away (the hole carries no child binding).
const graftContentDOM = (attempt: any) => {
if (!contentDOM) return;
const hole = dom.querySelector('[data-rozie-hole]');
if (hole) {
if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
return;
}
if (attempt < 5) {
if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
}
};
graftContentDOM(0);
// After a reactive re-render (chrome update), re-graft so a fragment that
// recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
// (REQ-24 — the editable subtree survives every chrome update).
const updateInPlace = (n: any, selected: any) => {
handle.update(buildScope(n, selected));
if (contentDOM) graftContentDOM(0);
};
return {
dom,
...(contentDOM ? {
contentDOM
} : {}),
// attr / content change for THIS node → re-render the fragment in place,
// keep the view (return true). The new node identity is forwarded so the
// fragment reads fresh node.attrs (REQ-26).
update(nextNode: any) {
if (nextNode.type !== node.type) return false;
updateInPlace(nextNode, false);
return true;
},
// NodeSelection enters/leaves the node → toggle `selected` in scope so the
// chip's selected styling is pure engine-driven reactive `update`.
selectNode() {
updateInPlace(node, true);
},
deselectNode() {
updateInPlace(node, false);
},
destroy() {
handle.dispose();
}
};
};
}
const makeNodeViewExtensions = useCallback((nv: any) => {
// (1) NON-EDITABLE inline atom @mention chip (Spike 009 / REQ-26).
const Mention = Node.create({
name: 'rozieMention',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes: () => ({
id: {
default: null
},
label: {
default: ''
}
}),
parseHTML: () => [{
tag: 'span[data-rozie-mention]'
}],
// ATOM nodes are leaf nodes — their renderHTML must NOT include a `0` content
// hole (ProseMirror's DOMSerializer throws "Content hole not allowed in a leaf
// node spec"). The chip's visible content is supplied by the node view; the
// serialized form is just the marker span carrying the attrs.
renderHTML: ({
HTMLAttributes
}: any) => ['span', {
'data-rozie-mention': '',
...HTMLAttributes
}],
addNodeView: () => makeNodeView(nv, false)
});
// (2) EDITABLE block callout with a contentDOM hole (Spike 008 / REQ-23).
const Callout = Node.create({
name: 'rozieCallout',
group: 'block',
content: 'inline*',
defining: true,
addAttributes: () => ({
tone: {
default: 'info'
}
}),
parseHTML: () => [{
tag: 'div[data-rozie-callout]'
}],
renderHTML: ({
HTMLAttributes
}: any) => ['div', {
'data-rozie-callout': '',
...HTMLAttributes
}, 0],
addNodeView: () => makeNodeView(nv, true)
});
return [Mention, Callout];
}, [makeNodeView]);
// ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
// the marquee surface: 14 verbs over the live Editor, uniform across all 6
// targets. Each guards the pre-mount / destroyed `editor = null`.
//
// Collision discipline:
// - The content setter is named `setContent`, NOT `setHtml` — an `html` model
// prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
// $expose verb would collide on the React target (ROZ524). (CodeMirror's
// setValue→replaceValue lesson, html edition.)
// - None of the 14 names collide with LitElement reserved lifecycle methods
// (update/render/firstUpdated/updated/willUpdate/requestUpdate).
// - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
// `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
// class-based targets (Angular) an output field and a method cannot share a
// name (ROZ121). The diagnostic's own guidance: rename the method, keep the
// event's public name. (The expose-verb-vs-event-name collision lesson.)
// - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
// ariaLabel/editorProps/extensions).
function getEditor() {
return editor.current;
}
function focusEditor() {
editor.current?.commands.focus();
}
function blurEditor() {
editor.current?.commands.blur();
}
function getHTML() {
return editor.current ? editor.current.getHTML() : '';
}
function getJSON() {
return editor.current ? editor.current.getJSON() : null;
}
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
function getText() {
return editor.current ? editor.current.getText() : '';
}
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
function setContent(next: any) {
if (!editor.current) return;
const v = next ?? '';
if (v === lastHtml.current) return;
lastHtml.current = v;
editor.current.commands.setContent(v, {
emitUpdate: false
});
setHtml(v);
refreshActive();
}
function clearContent() {
if (!editor.current) return;
editor.current.commands.clearContent();
lastHtml.current = editor.current.getHTML();
setHtml(lastHtml.current);
refreshActive();
}
function toggleBold() {
editor.current?.chain().focus().toggleBold().run();
refreshActive();
}
function toggleItalic() {
editor.current?.chain().focus().toggleItalic().run();
refreshActive();
}
function toggleHeading(level: any) {
editor.current?.chain().focus().toggleHeading({
level: level ?? 1
}).run();
refreshActive();
}
function toggleBulletList() {
editor.current?.chain().focus().toggleBulletList().run();
refreshActive();
}
function undo() {
editor.current?.chain().focus().undo().run();
refreshActive();
}
function redo() {
editor.current?.chain().focus().redo().run();
refreshActive();
}
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
function chain() {
return editor.current ? editor.current.chain().focus() : null;
}
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
// - isActive(name, attrs?): is a mark/node active in the current selection
// (drive toolbar button active styling). False before mount.
// - can(): the command-availability chain (editor.can().chain()…run()) for
// enable/disable of toolbar buttons. null before mount (mirrors chain()).
// - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
// - isActive(name, attrs?): is a mark/node active in the current selection
// (drive toolbar button active styling). False before mount.
// - can(): the command-availability chain (editor.can().chain()…run()) for
// enable/disable of toolbar buttons. null before mount (mirrors chain()).
// - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
function isActive(name: any, attrs: any) {
return editor.current ? editor.current.isActive(name, attrs) : false;
}
function can() {
return editor.current ? editor.current.can() : null;
}
function isEmpty() {
return editor.current ? editor.current.isEmpty : true;
}
useEffect(() => {
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slot = _renderToolbarRef.current ?? props.slots?.['toolbar'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal toolbar { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slot = _renderBubbleMenuRef.current ?? props.slots?.['bubbleMenu'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal bubbleMenu { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slot = _renderFloatingMenuRef.current ?? props.slots?.['floatingMenu'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal floatingMenu { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
const slot = _renderNodeViewRef.current ?? props.slots?.['nodeView'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
// Cascades the @portal nodeView { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
const root = createRoot(container);
const renderScope = (s: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): void => {
flushSync(() => root.render(slot(s)));
};
renderScope(scope);
portalRoots.current.add(root);
return {
update: (s: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): void => renderScope(s),
dispose: (): void => {
root.unmount();
portalRoots.current.delete(root);
},
};
},
};
lastHtml.current = _htmlRef.current;
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
const nodeViewExtensions = (props.renderNodeView ?? props.slots?.["nodeView"]) ? makeNodeViewExtensions(portals.nodeView) : [];
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
const placeholderExtensions = props.placeholder ? [Placeholder.configure({
placeholder: props.placeholder
})] : [];
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
if ((props.renderBubbleMenu ?? props.slots?.["bubbleMenu"])) {
bubbleMenuEl.current = document.createElement('div');
bubbleMenuEl.current.className = 'rozie-tiptap-bubble-menu';
}
if ((props.renderFloatingMenu ?? props.slots?.["floatingMenu"])) {
floatingMenuEl.current = document.createElement('div');
floatingMenuEl.current.className = 'rozie-tiptap-floating-menu';
}
const menuExtensions = [...(bubbleMenuEl.current ? [BubbleMenu.configure({
element: bubbleMenuEl.current
})] : []), ...(floatingMenuEl.current ? [FloatingMenu.configure({
element: floatingMenuEl.current
})] : [])];
editor.current = new Editor({
element: editorEl.current!,
content: _htmlRef.current,
editable: _editableRef.current,
autofocus: props.autofocus,
// StarterKit first; the Placeholder ext next; the reactive node-view nodes
// next; consumer extensions LAST so they win (TipTap applies later-registered
// extensions over earlier ones for the same node/mark).
extensions: [StarterKit, ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...props.extensions],
editorProps: {
attributes: {
'aria-label': props.ariaLabel,
...(props.editorClass ? {
class: props.editorClass
} : {}),
...(props.placeholder ? {
'data-placeholder': props.placeholder,
'aria-placeholder': props.placeholder
} : {})
},
// Consumer editorProps spread LAST — full ProseMirror editorProps control
// (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
...props.editorProps
},
onUpdate: ({
editor
}: any) => {
const next = editor.getHTML();
lastHtml.current = next;
// Round-trip guard — see CodeMirror/Flatpickr for the same shape.
if (next !== _htmlRef.current) setHtml(next);
props.onUpdate && props.onUpdate(next);
},
onSelectionUpdate: () => {
refreshActive();
props.onSelectionUpdate && props.onSelectionUpdate();
},
onFocus: () => props.onFocus && props.onFocus(),
onBlur: () => props.onBlur && props.onBlur()
});
refreshActive();
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
if ((props.renderToolbar ?? props.slots?.["toolbar"]) && toolbarEl.current) {
toolbarDispose.current = portals.toolbar(toolbarEl.current!, {
editor: editor.current
});
}
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
if (bubbleMenuEl.current) {
bubbleMenuDispose.current = portals.bubbleMenu(bubbleMenuEl.current, {
editor: editor.current
});
}
if (floatingMenuEl.current) {
floatingMenuDispose.current = portals.floatingMenu(floatingMenuEl.current, {
editor: editor.current
});
}
return () => {
for (const root of portalRoots.current) root.unmount();
portalRoots.current.clear();
toolbarDispose.current?.();
toolbarDispose.current = null;
bubbleMenuDispose.current?.();
bubbleMenuDispose.current = null;
floatingMenuDispose.current?.();
floatingMenuDispose.current = null;
editor.current?.destroy();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const v = html;
if (!editor.current) return;
if (v === lastHtml.current) return;
lastHtml.current = v;
editor.current.commands.setContent(v, {
emitUpdate: false
});
refreshActive();
}, [html]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
const v = props.editable;
editor.current?.setEditable(v, false);
}, [props.editable]);
const _rozieExposeRef = useRef({ getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent, toggleBold, toggleItalic, toggleHeading, toggleBulletList, undo, redo, chain, isActive, can, isEmpty });
_rozieExposeRef.current = { getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent, toggleBold, toggleItalic, toggleHeading, toggleBulletList, undo, redo, chain, isActive, can, isEmpty };
useImperativeHandle(ref, () => ({ getEditor: (...args: Parameters<typeof getEditor>): ReturnType<typeof getEditor> => _rozieExposeRef.current.getEditor(...args), focusEditor: (...args: Parameters<typeof focusEditor>): ReturnType<typeof focusEditor> => _rozieExposeRef.current.focusEditor(...args), blurEditor: (...args: Parameters<typeof blurEditor>): ReturnType<typeof blurEditor> => _rozieExposeRef.current.blurEditor(...args), getHTML: (...args: Parameters<typeof getHTML>): ReturnType<typeof getHTML> => _rozieExposeRef.current.getHTML(...args), getJSON: (...args: Parameters<typeof getJSON>): ReturnType<typeof getJSON> => _rozieExposeRef.current.getJSON(...args), getText: (...args: Parameters<typeof getText>): ReturnType<typeof getText> => _rozieExposeRef.current.getText(...args), setContent: (...args: Parameters<typeof setContent>): ReturnType<typeof setContent> => _rozieExposeRef.current.setContent(...args), clearContent: (...args: Parameters<typeof clearContent>): ReturnType<typeof clearContent> => _rozieExposeRef.current.clearContent(...args), toggleBold: (...args: Parameters<typeof toggleBold>): ReturnType<typeof toggleBold> => _rozieExposeRef.current.toggleBold(...args), toggleItalic: (...args: Parameters<typeof toggleItalic>): ReturnType<typeof toggleItalic> => _rozieExposeRef.current.toggleItalic(...args), toggleHeading: (...args: Parameters<typeof toggleHeading>): ReturnType<typeof toggleHeading> => _rozieExposeRef.current.toggleHeading(...args), toggleBulletList: (...args: Parameters<typeof toggleBulletList>): ReturnType<typeof toggleBulletList> => _rozieExposeRef.current.toggleBulletList(...args), undo: (...args: Parameters<typeof undo>): ReturnType<typeof undo> => _rozieExposeRef.current.undo(...args), redo: (...args: Parameters<typeof redo>): ReturnType<typeof redo> => _rozieExposeRef.current.redo(...args), chain: (...args: Parameters<typeof chain>): ReturnType<typeof chain> => _rozieExposeRef.current.chain(...args), isActive: (...args: Parameters<typeof isActive>): ReturnType<typeof isActive> => _rozieExposeRef.current.isActive(...args), can: (...args: Parameters<typeof can>): ReturnType<typeof can> => _rozieExposeRef.current.can(...args), isEmpty: (...args: Parameters<typeof isEmpty>): ReturnType<typeof isEmpty> => _rozieExposeRef.current.isEmpty(...args) }), []);
return (
<>
<div className={clsx("rozie-tiptap", { "is-readonly": !props.editable })} data-rozie-s-2aeee876="">
{(props.editable && !(props.renderToolbar ?? props.slots?.['toolbar'])) && <div className={"rozie-tiptap-toolbar"} data-rozie-s-2aeee876="">
<button type="button" className={clsx({ active: active.bold })} aria-label="Bold" onClick={toggleBold} data-rozie-s-2aeee876=""><strong data-rozie-s-2aeee876="">B</strong></button>
<button type="button" className={clsx({ active: active.italic })} aria-label="Italic" onClick={toggleItalic} data-rozie-s-2aeee876=""><em data-rozie-s-2aeee876="">I</em></button>
<span className={"sep"} data-rozie-s-2aeee876="" />
<button type="button" className={clsx({ active: active.h1 })} aria-label="Heading 1" onClick={($event) => { toggleHeading(1); }} data-rozie-s-2aeee876="">H1</button>
<button type="button" className={clsx({ active: active.h2 })} aria-label="Heading 2" onClick={($event) => { toggleHeading(2); }} data-rozie-s-2aeee876="">H2</button>
<span className={"sep"} data-rozie-s-2aeee876="" />
<button type="button" className={clsx({ active: active.bulletList })} aria-label="Bullet list" onClick={toggleBulletList} data-rozie-s-2aeee876="">• List</button>
</div>}{(props.editable && (props.renderToolbar ?? props.slots?.['toolbar'])) && <div className={"rozie-tiptap-toolbar rozie-tiptap-toolbar--slot"} ref={toolbarEl} data-rozie-s-2aeee876="" />}<div ref={editorEl} className={"rozie-tiptap-content"} data-placeholder={props.placeholder} data-rozie-s-2aeee876="" />
</div>
</>
);
});
export default TipTap;vue
<template>
<div :class="['rozie-tiptap', { 'is-readonly': !props.editable }]">
<div v-if="props.editable && !$slots.toolbar" class="rozie-tiptap-toolbar">
<button type="button" :class="{ active: active.bold }" aria-label="Bold" @click="toggleBold"><strong>B</strong></button>
<button type="button" :class="{ active: active.italic }" aria-label="Italic" @click="toggleItalic"><em>I</em></button>
<span class="sep"></span>
<button type="button" :class="{ active: active.h1 }" aria-label="Heading 1" @click="toggleHeading(1)">H1</button>
<button type="button" :class="{ active: active.h2 }" aria-label="Heading 2" @click="toggleHeading(2)">H2</button>
<span class="sep"></span>
<button type="button" :class="{ active: active.bulletList }" aria-label="Bullet list" @click="toggleBulletList">• List</button>
</div><div v-if="props.editable && $slots.toolbar" class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" ref="toolbarElRef"></div><div ref="editorElRef" class="rozie-tiptap-content" :data-placeholder="props.placeholder"></div>
</div>
</template>
<script setup lang="ts">
import { Fragment, h, onBeforeUnmount, onMounted, ref, render, useSlots, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* 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.
*/
editable?: boolean;
/**
* 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.
*/
placeholder?: string;
/**
* Whether to place the caret in the document on mount (TipTap's `autofocus` option).
*/
autofocus?: boolean;
/**
* A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
*/
editorClass?: string;
/**
* The accessible name (`aria-label`) applied to the contenteditable element.
*/
ariaLabel?: string;
/**
* ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
*/
editorProps?: Record<string, any>;
/**
* Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Composed **last** so consumer extensions win for the same node or mark.
*/
extensions?: any[];
}>(),
{ editable: true, placeholder: '', autofocus: false, editorClass: '', ariaLabel: 'Rich text editor', editorProps: () => ({}), extensions: () => [] }
);
/**
* 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…" />
*/
const html = defineModel<string>('html', { default: '<p>Start writing…</p>' });
const emit = defineEmits<{
update: [...args: any[]];
selectionUpdate: [...args: any[]];
focus: [...args: any[]];
blur: [...args: any[]];
}>();
defineSlots<{
toolbar(props: { editor: any }): any;
bubbleMenu(props: { editor: any }): any;
floatingMenu(props: { editor: any }): any;
nodeView(props: { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any }): any;
}>();
const slots = useSlots();
const active = ref({
bold: false,
italic: false,
h1: false,
h2: false,
bulletList: false
});
const toolbarElRef = ref<HTMLElement>();
const editorElRef = ref<HTMLElement>();
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).
// 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';
// 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).
// 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: any = 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).
// 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: any = 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).
// 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: any = 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).
// 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: any = null;
let bubbleMenuDispose: any = null;
let floatingMenuEl: any = null;
let floatingMenuDispose: any = null;
// Recompute the internal toolbar's active-mark booleans from the live editor.
// Recompute the internal toolbar's active-mark booleans from the live editor.
const refreshActive = () => {
if (!editor) return;
active.value = {
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
h1: editor.isActive('heading', {
level: 1
}),
h2: editor.isActive('heading', {
level: 2
}),
bulletList: editor.isActive('bulletList')
};
};
// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator). When the consumer fills the
// `nodeView` slot, two custom ProseMirror nodes render the consumer fragment as
// a custom node *in-engine*, re-rendering it in place on every transaction via
// the reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). Both halves of the primitive are proven and shipped here:
//
// 1. `mention` — a NON-EDITABLE inline ATOM (selectable:true, no contentDOM,
// Spike 009 / REQ-26). selectNode/deselectNode/update(node) → handle.update
// so the chip re-renders in place (engine-driven; no Rozie reactive loop).
//
// 2. `callout` — an EDITABLE BLOCK (content:'inline*', so it HAS a contentDOM,
// Spike 008 / REQ-23). ProseMirror owns the editable hole; the consumer
// fragment renders chrome wrapping a [data-rozie-hole] placeholder and the
// per-target portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator). When the consumer fills the
// `nodeView` slot, two custom ProseMirror nodes render the consumer fragment as
// a custom node *in-engine*, re-rendering it in place on every transaction via
// the reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). Both halves of the primitive are proven and shipped here:
//
// 1. `mention` — a NON-EDITABLE inline ATOM (selectable:true, no contentDOM,
// Spike 009 / REQ-26). selectNode/deselectNode/update(node) → handle.update
// so the chip re-renders in place (engine-driven; no Rozie reactive loop).
//
// 2. `callout` — an EDITABLE BLOCK (content:'inline*', so it HAS a contentDOM,
// Spike 008 / REQ-23). ProseMirror owns the editable hole; the consumer
// fragment renders chrome wrapping a [data-rozie-hole] placeholder and the
// per-target portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
const makeNodeView = (nv: any, editable: any) => (props: any) => {
const {
node,
getPos,
editor: ed
} = props;
// engine-owned outer host the consumer fragment mounts into.
const dom = document.createElement(editable ? 'div' : 'span');
dom.className = editable ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
// EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
// into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
const contentDOM = editable ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
const updateAttributes = (attrs: any) => {
if (typeof getPos !== 'function') return;
const pos = getPos();
if (pos == null) return;
ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...attrs
}));
};
const buildScope = (n: any, selected: any) => ({
node: n,
selected,
updateAttributes,
getPos,
editor: ed,
...(contentDOM ? {
contentDOM
} : {})
});
// Reactive handle — { update, dispose }. The fragment mounts ONCE; every
// engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
const handle = nv(dom, buildScope(node, false));
// contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
// consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
// ProseMirror manages `contentDOM` and renders the node's editable children
// INTO it, so `contentDOM` must live inside the visible hole. The fragment is
// rendered into `dom` by the per-target reactive portal — synchronously on
// React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
// Angular (REQ-23). A query-after-render graft (retried across a microtask +
// a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
// the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
// and the framework never reconciles it away (the hole carries no child binding).
const graftContentDOM = (attempt: any) => {
if (!contentDOM) return;
const hole = dom.querySelector('[data-rozie-hole]');
if (hole) {
if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
return;
}
if (attempt < 5) {
if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
}
};
graftContentDOM(0);
// After a reactive re-render (chrome update), re-graft so a fragment that
// recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
// (REQ-24 — the editable subtree survives every chrome update).
const updateInPlace = (n: any, selected: any) => {
handle.update(buildScope(n, selected));
if (contentDOM) graftContentDOM(0);
};
return {
dom,
...(contentDOM ? {
contentDOM
} : {}),
// attr / content change for THIS node → re-render the fragment in place,
// keep the view (return true). The new node identity is forwarded so the
// fragment reads fresh node.attrs (REQ-26).
update(nextNode: any) {
if (nextNode.type !== node.type) return false;
updateInPlace(nextNode, false);
return true;
},
// NodeSelection enters/leaves the node → toggle `selected` in scope so the
// chip's selected styling is pure engine-driven reactive `update`.
selectNode() {
updateInPlace(node, true);
},
deselectNode() {
updateInPlace(node, false);
},
destroy() {
handle.dispose();
}
};
};
// Build the two custom Nodes bound to the reactive nodeView portal. Takes the
// per-target `$portals.nodeView` (captured here so the reference stays inside
// the mount lifecycle — never top-level, per the bundled-leaf typecheck rule).
// Build the two custom Nodes bound to the reactive nodeView portal. Takes the
// per-target `$portals.nodeView` (captured here so the reference stays inside
// the mount lifecycle — never top-level, per the bundled-leaf typecheck rule).
const makeNodeViewExtensions = (nv: any) => {
// (1) NON-EDITABLE inline atom @mention chip (Spike 009 / REQ-26).
const Mention = Node.create({
name: 'rozieMention',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes: () => ({
id: {
default: null
},
label: {
default: ''
}
}),
parseHTML: () => [{
tag: 'span[data-rozie-mention]'
}],
// ATOM nodes are leaf nodes — their renderHTML must NOT include a `0` content
// hole (ProseMirror's DOMSerializer throws "Content hole not allowed in a leaf
// node spec"). The chip's visible content is supplied by the node view; the
// serialized form is just the marker span carrying the attrs.
renderHTML: ({
HTMLAttributes
}: any) => ['span', {
'data-rozie-mention': '',
...HTMLAttributes
}],
addNodeView: () => makeNodeView(nv, false)
});
// (2) EDITABLE block callout with a contentDOM hole (Spike 008 / REQ-23).
const Callout = Node.create({
name: 'rozieCallout',
group: 'block',
content: 'inline*',
defining: true,
addAttributes: () => ({
tone: {
default: 'info'
}
}),
parseHTML: () => [{
tag: 'div[data-rozie-callout]'
}],
renderHTML: ({
HTMLAttributes
}: any) => ['div', {
'data-rozie-callout': '',
...HTMLAttributes
}, 0],
addNodeView: () => makeNodeView(nv, true)
});
return [Mention, Callout];
};
// ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
// the marquee surface: 14 verbs over the live Editor, uniform across all 6
// targets. Each guards the pre-mount / destroyed `editor = null`.
//
// Collision discipline:
// - The content setter is named `setContent`, NOT `setHtml` — an `html` model
// prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
// $expose verb would collide on the React target (ROZ524). (CodeMirror's
// setValue→replaceValue lesson, html edition.)
// - None of the 14 names collide with LitElement reserved lifecycle methods
// (update/render/firstUpdated/updated/willUpdate/requestUpdate).
// - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
// `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
// class-based targets (Angular) an output field and a method cannot share a
// name (ROZ121). The diagnostic's own guidance: rename the method, keep the
// event's public name. (The expose-verb-vs-event-name collision lesson.)
// - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
// ariaLabel/editorProps/extensions).
function getEditor() {
return editor;
}
function focusEditor() {
editor?.commands.focus();
}
function blurEditor() {
editor?.commands.blur();
}
function getHTML() {
return editor ? editor.getHTML() : '';
}
function getJSON() {
return editor ? editor.getJSON() : null;
}
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
function getText() {
return editor ? editor.getText() : '';
}
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
function setContent(next: any) {
if (!editor) return;
const v = next ?? '';
if (v === lastHtml) return;
lastHtml = v;
editor.commands.setContent(v, {
emitUpdate: false
});
html.value = v;
refreshActive();
}
function clearContent() {
if (!editor) return;
editor.commands.clearContent();
lastHtml = editor.getHTML();
html.value = lastHtml;
refreshActive();
}
function toggleBold() {
editor?.chain().focus().toggleBold().run();
refreshActive();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
refreshActive();
}
function toggleHeading(level: any) {
editor?.chain().focus().toggleHeading({
level: level ?? 1
}).run();
refreshActive();
}
function toggleBulletList() {
editor?.chain().focus().toggleBulletList().run();
refreshActive();
}
function undo() {
editor?.chain().focus().undo().run();
refreshActive();
}
function redo() {
editor?.chain().focus().redo().run();
refreshActive();
}
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
function chain() {
return editor ? editor.chain().focus() : null;
}
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
// - isActive(name, attrs?): is a mark/node active in the current selection
// (drive toolbar button active styling). False before mount.
// - can(): the command-availability chain (editor.can().chain()…run()) for
// enable/disable of toolbar buttons. null before mount (mirrors chain()).
// - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
// - isActive(name, attrs?): is a mark/node active in the current selection
// (drive toolbar button active styling). False before mount.
// - can(): the command-availability chain (editor.can().chain()…run()) for
// enable/disable of toolbar buttons. null before mount (mirrors chain()).
// - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
function isActive(name: any, attrs: any) {
return editor ? editor.isActive(name, attrs) : false;
}
function can() {
return editor ? editor.can() : null;
}
function isEmpty() {
return editor ? editor.isEmpty : true;
}
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalContainers = new Set<HTMLElement>();
const portals = {
toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slotFn = slots.toolbar;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// toolbar { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slotFn = slots.bubbleMenu;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// bubbleMenu { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slotFn = slots.floatingMenu;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// floatingMenu { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
const slotFn = slots.nodeView;
if (!slotFn) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection. Cascades the @portal
// nodeView { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
const renderScope = (s: unknown): void => {
render(h(Fragment, null, slotFn(s)), container);
};
renderScope(scope);
portalContainers.add(container);
return {
update: (s: unknown): void => renderScope(s),
dispose: (): void => {
render(null, container);
portalContainers.delete(container);
},
};
},
};
onBeforeUnmount(() => {
for (const container of portalContainers) render(null, container);
portalContainers.clear();
});
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
lastHtml = html.value;
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
const nodeViewExtensions = slots.nodeView ? makeNodeViewExtensions(portals.nodeView) : [];
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
const placeholderExtensions = props.placeholder ? [Placeholder.configure({
placeholder: props.placeholder
})] : [];
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
if (slots.bubbleMenu) {
bubbleMenuEl = document.createElement('div');
bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
}
if (slots.floatingMenu) {
floatingMenuEl = document.createElement('div');
floatingMenuEl.className = 'rozie-tiptap-floating-menu';
}
const menuExtensions = [...(bubbleMenuEl ? [BubbleMenu.configure({
element: bubbleMenuEl
})] : []), ...(floatingMenuEl ? [FloatingMenu.configure({
element: floatingMenuEl
})] : [])];
editor = new Editor({
element: editorElRef.value!,
content: html.value,
editable: props.editable,
autofocus: props.autofocus,
// StarterKit first; the Placeholder ext next; the reactive node-view nodes
// next; consumer extensions LAST so they win (TipTap applies later-registered
// extensions over earlier ones for the same node/mark).
extensions: [StarterKit, ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...props.extensions],
editorProps: {
attributes: {
'aria-label': props.ariaLabel,
...(props.editorClass ? {
class: props.editorClass
} : {}),
...(props.placeholder ? {
'data-placeholder': props.placeholder,
'aria-placeholder': props.placeholder
} : {})
},
// Consumer editorProps spread LAST — full ProseMirror editorProps control
// (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
...props.editorProps
},
onUpdate: ({
editor
}: any) => {
const next = editor.getHTML();
lastHtml = next;
// Round-trip guard — see CodeMirror/Flatpickr for the same shape.
if (next !== html.value) html.value = next;
emit('update', next);
},
onSelectionUpdate: () => {
refreshActive();
emit('selectionUpdate');
},
onFocus: () => emit('focus'),
onBlur: () => emit('blur')
});
refreshActive();
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
if (slots.toolbar && toolbarElRef.value) {
toolbarDispose = portals.toolbar(toolbarElRef.value!, {
editor
});
}
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
if (bubbleMenuEl) {
bubbleMenuDispose = portals.bubbleMenu(bubbleMenuEl, {
editor
});
}
if (floatingMenuEl) {
floatingMenuDispose = portals.floatingMenu(floatingMenuEl, {
editor
});
}
_cleanup_0 = () => {
toolbarDispose?.();
toolbarDispose = null;
bubbleMenuDispose?.();
bubbleMenuDispose = null;
floatingMenuDispose?.();
floatingMenuDispose = null;
editor?.destroy();
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => html.value, (v: any) => {
if (!editor) return;
if (v === lastHtml) return;
lastHtml = v;
editor.commands.setContent(v, {
emitUpdate: false
});
refreshActive();
});
watch(() => props.editable, (v: any) => editor?.setEditable(v, false));
defineExpose({ getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent, toggleBold, toggleItalic, toggleHeading, toggleBulletList, undo, redo, chain, isActive, can, isEmpty });
</script>
<style scoped>
.rozie-tiptap {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
overflow: hidden;
background: white;
}
.rozie-tiptap.is-readonly {
background: #fafafa;
}
.rozie-tiptap-toolbar {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #f5f5f7;
}
.rozie-tiptap-toolbar button {
padding: 0.25rem 0.5rem;
border: 1px solid transparent;
background: transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
min-width: 1.75rem;
color: rgba(0, 0, 0, 0.65);
}
.rozie-tiptap-toolbar button:hover {
background: rgba(0, 0, 0, 0.06);
}
.rozie-tiptap-toolbar button.active {
background: #1a1a1a;
color: white;
border-color: #1a1a1a;
}
.rozie-tiptap-toolbar .sep {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.25rem;
}
.rozie-tiptap-content {
padding: 0.625rem 0.875rem;
min-height: 6rem;
font: inherit;
outline: none;
}
.rozie-tiptap-content p { margin: 0 0 0.5rem; }
.rozie-tiptap-content p:last-child { margin-bottom: 0; }
.rozie-tiptap-content h1 { font-size: 1.5rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content h2 { font-size: 1.25rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content ul { margin: 0 0 0.5rem; padding-left: 1.5rem; }
</style>
<style>
.rozie-tiptap-content .is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: rgba(0, 0, 0, 0.4);
float: left;
height: 0;
pointer-events: none;
}
</style>svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { mount, unmount } from 'svelte';
import PortalHost from '@rozie/runtime-svelte/PortalHost.svelte';
import PortalHostReactive from '@rozie/runtime-svelte/PortalHostReactive.svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* 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…" />
*/
html?: string;
/**
* 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.
*/
editable?: boolean;
/**
* 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.
*/
placeholder?: string;
/**
* Whether to place the caret in the document on mount (TipTap's `autofocus` option).
*/
autofocus?: boolean;
/**
* A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
*/
editorClass?: string;
/**
* The accessible name (`aria-label`) applied to the contenteditable element.
*/
ariaLabel?: string;
/**
* ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
*/
editorProps?: any;
/**
* Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Composed **last** so consumer extensions win for the same node or mark.
*/
extensions?: any[];
toolbar?: Snippet<[{ editor: any }]>;
bubbleMenu?: Snippet<[{ editor: any }]>;
floatingMenu?: Snippet<[{ editor: any }]>;
nodeView?: Snippet<[{ node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any }]>;
snippets?: Record<string, any>;
onupdate?: (...args: unknown[]) => void;
onselectionupdate?: (...args: unknown[]) => void;
onfocus?: (...args: unknown[]) => void;
onblur?: (...args: unknown[]) => void;
}
let __defaultEditorProps = (() => ({}))();
let __defaultExtensions = (() => [])();
let {
html = $bindable('<p>Start writing…</p>'),
editable = true,
placeholder = '',
autofocus = false,
editorClass = '',
ariaLabel = 'Rich text editor',
editorProps = __defaultEditorProps,
extensions = __defaultExtensions,
toolbar: __toolbarProp,
bubbleMenu: __bubbleMenuProp,
floatingMenu: __floatingMenuProp,
nodeView: __nodeViewProp,
snippets,
onupdate,
onselectionupdate,
onfocus,
onblur
}: Props = $props();
const toolbar = $derived(__toolbarProp ?? snippets?.toolbar);
const bubbleMenu = $derived(__bubbleMenuProp ?? snippets?.bubbleMenu);
const floatingMenu = $derived(__floatingMenuProp ?? snippets?.floatingMenu);
const nodeView = $derived(__nodeViewProp ?? snippets?.nodeView);
let active = $state({
bold: false,
italic: false,
h1: false,
h2: false,
bulletList: false
});
let toolbarEl = $state<HTMLElement | undefined>(undefined);
let editorEl = $state<HTMLElement | undefined>(undefined);
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).
// 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';
// 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).
// 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: any = 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).
// 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: any = 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).
// 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: any = 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).
// 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: any = null;
let bubbleMenuDispose: any = null;
let floatingMenuEl: any = null;
let floatingMenuDispose: any = null;
// Recompute the internal toolbar's active-mark booleans from the live editor.
// Recompute the internal toolbar's active-mark booleans from the live editor.
const refreshActive = () => {
if (!editor) return;
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')
};
};
// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator). When the consumer fills the
// `nodeView` slot, two custom ProseMirror nodes render the consumer fragment as
// a custom node *in-engine*, re-rendering it in place on every transaction via
// the reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). Both halves of the primitive are proven and shipped here:
//
// 1. `mention` — a NON-EDITABLE inline ATOM (selectable:true, no contentDOM,
// Spike 009 / REQ-26). selectNode/deselectNode/update(node) → handle.update
// so the chip re-renders in place (engine-driven; no Rozie reactive loop).
//
// 2. `callout` — an EDITABLE BLOCK (content:'inline*', so it HAS a contentDOM,
// Spike 008 / REQ-23). ProseMirror owns the editable hole; the consumer
// fragment renders chrome wrapping a [data-rozie-hole] placeholder and the
// per-target portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator). When the consumer fills the
// `nodeView` slot, two custom ProseMirror nodes render the consumer fragment as
// a custom node *in-engine*, re-rendering it in place on every transaction via
// the reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). Both halves of the primitive are proven and shipped here:
//
// 1. `mention` — a NON-EDITABLE inline ATOM (selectable:true, no contentDOM,
// Spike 009 / REQ-26). selectNode/deselectNode/update(node) → handle.update
// so the chip re-renders in place (engine-driven; no Rozie reactive loop).
//
// 2. `callout` — an EDITABLE BLOCK (content:'inline*', so it HAS a contentDOM,
// Spike 008 / REQ-23). ProseMirror owns the editable hole; the consumer
// fragment renders chrome wrapping a [data-rozie-hole] placeholder and the
// per-target portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
const makeNodeView = (nv: any, editable: any) => (props: any) => {
const {
node,
getPos,
editor: ed
} = props;
// engine-owned outer host the consumer fragment mounts into.
const dom = document.createElement(editable ? 'div' : 'span');
dom.className = editable ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
// EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
// into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
const contentDOM = editable ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
const updateAttributes = (attrs: any) => {
if (typeof getPos !== 'function') return;
const pos = getPos();
if (pos == null) return;
ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...attrs
}));
};
const buildScope = (n: any, selected: any) => ({
node: n,
selected,
updateAttributes,
getPos,
editor: ed,
...(contentDOM ? {
contentDOM
} : {})
});
// Reactive handle — { update, dispose }. The fragment mounts ONCE; every
// engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
const handle = nv(dom, buildScope(node, false));
// contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
// consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
// ProseMirror manages `contentDOM` and renders the node's editable children
// INTO it, so `contentDOM` must live inside the visible hole. The fragment is
// rendered into `dom` by the per-target reactive portal — synchronously on
// React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
// Angular (REQ-23). A query-after-render graft (retried across a microtask +
// a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
// the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
// and the framework never reconciles it away (the hole carries no child binding).
const graftContentDOM = (attempt: any) => {
if (!contentDOM) return;
const hole = dom.querySelector('[data-rozie-hole]');
if (hole) {
if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
return;
}
if (attempt < 5) {
if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
}
};
graftContentDOM(0);
// After a reactive re-render (chrome update), re-graft so a fragment that
// recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
// (REQ-24 — the editable subtree survives every chrome update).
const updateInPlace = (n: any, selected: any) => {
handle.update(buildScope(n, selected));
if (contentDOM) graftContentDOM(0);
};
return {
dom,
...(contentDOM ? {
contentDOM
} : {}),
// attr / content change for THIS node → re-render the fragment in place,
// keep the view (return true). The new node identity is forwarded so the
// fragment reads fresh node.attrs (REQ-26).
update(nextNode: any) {
if (nextNode.type !== node.type) return false;
updateInPlace(nextNode, false);
return true;
},
// NodeSelection enters/leaves the node → toggle `selected` in scope so the
// chip's selected styling is pure engine-driven reactive `update`.
selectNode() {
updateInPlace(node, true);
},
deselectNode() {
updateInPlace(node, false);
},
destroy() {
handle.dispose();
}
};
};
// Build the two custom Nodes bound to the reactive nodeView portal. Takes the
// per-target `$portals.nodeView` (captured here so the reference stays inside
// the mount lifecycle — never top-level, per the bundled-leaf typecheck rule).
// Build the two custom Nodes bound to the reactive nodeView portal. Takes the
// per-target `$portals.nodeView` (captured here so the reference stays inside
// the mount lifecycle — never top-level, per the bundled-leaf typecheck rule).
const makeNodeViewExtensions = (nv: any) => {
// (1) NON-EDITABLE inline atom @mention chip (Spike 009 / REQ-26).
const Mention = Node.create({
name: 'rozieMention',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes: () => ({
id: {
default: null
},
label: {
default: ''
}
}),
parseHTML: () => [{
tag: 'span[data-rozie-mention]'
}],
// ATOM nodes are leaf nodes — their renderHTML must NOT include a `0` content
// hole (ProseMirror's DOMSerializer throws "Content hole not allowed in a leaf
// node spec"). The chip's visible content is supplied by the node view; the
// serialized form is just the marker span carrying the attrs.
renderHTML: ({
HTMLAttributes
}: any) => ['span', {
'data-rozie-mention': '',
...HTMLAttributes
}],
addNodeView: () => makeNodeView(nv, false)
});
// (2) EDITABLE block callout with a contentDOM hole (Spike 008 / REQ-23).
const Callout = Node.create({
name: 'rozieCallout',
group: 'block',
content: 'inline*',
defining: true,
addAttributes: () => ({
tone: {
default: 'info'
}
}),
parseHTML: () => [{
tag: 'div[data-rozie-callout]'
}],
renderHTML: ({
HTMLAttributes
}: any) => ['div', {
'data-rozie-callout': '',
...HTMLAttributes
}, 0],
addNodeView: () => makeNodeView(nv, true)
});
return [Mention, Callout];
};
// ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
// the marquee surface: 14 verbs over the live Editor, uniform across all 6
// targets. Each guards the pre-mount / destroyed `editor = null`.
//
// Collision discipline:
// - The content setter is named `setContent`, NOT `setHtml` — an `html` model
// prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
// $expose verb would collide on the React target (ROZ524). (CodeMirror's
// setValue→replaceValue lesson, html edition.)
// - None of the 14 names collide with LitElement reserved lifecycle methods
// (update/render/firstUpdated/updated/willUpdate/requestUpdate).
// - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
// `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
// class-based targets (Angular) an output field and a method cannot share a
// name (ROZ121). The diagnostic's own guidance: rename the method, keep the
// event's public name. (The expose-verb-vs-event-name collision lesson.)
// - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
// ariaLabel/editorProps/extensions).
export function getEditor() {
return editor;
}
export function focusEditor() {
editor?.commands.focus();
}
export function blurEditor() {
editor?.commands.blur();
}
export function getHTML() {
return editor ? editor.getHTML() : '';
}
export function getJSON() {
return editor ? editor.getJSON() : null;
}
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
export function getText() {
return editor ? editor.getText() : '';
}
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
export function setContent(next: any) {
if (!editor) return;
const v = next ?? '';
if (v === lastHtml) return;
lastHtml = v;
editor.commands.setContent(v, {
emitUpdate: false
});
html = v;
refreshActive();
}
export function clearContent() {
if (!editor) return;
editor.commands.clearContent();
lastHtml = editor.getHTML();
html = lastHtml;
refreshActive();
}
export function toggleBold() {
editor?.chain().focus().toggleBold().run();
refreshActive();
}
export function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
refreshActive();
}
export function toggleHeading(level: any) {
editor?.chain().focus().toggleHeading({
level: level ?? 1
}).run();
refreshActive();
}
export function toggleBulletList() {
editor?.chain().focus().toggleBulletList().run();
refreshActive();
}
export function undo() {
editor?.chain().focus().undo().run();
refreshActive();
}
export function redo() {
editor?.chain().focus().redo().run();
refreshActive();
}
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
export function chain() {
return editor ? editor.chain().focus() : null;
}
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
// - isActive(name, attrs?): is a mark/node active in the current selection
// (drive toolbar button active styling). False before mount.
// - can(): the command-availability chain (editor.can().chain()…run()) for
// enable/disable of toolbar buttons. null before mount (mirrors chain()).
// - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
// - isActive(name, attrs?): is a mark/node active in the current selection
// (drive toolbar button active styling). False before mount.
// - can(): the command-availability chain (editor.can().chain()…run()) for
// enable/disable of toolbar buttons. null before mount (mirrors chain()).
// - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
export function isActive(name: any, attrs: any) {
return editor ? editor.isActive(name, attrs) : false;
}
export function can() {
return editor ? editor.can() : null;
}
export function isEmpty() {
return editor ? editor.isEmpty : true;
}
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalInstances = new Set<Record<string, unknown>>();
const portals = {
toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
if (!toolbar) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
const inst = mount(PortalHost, {
target: container,
props: { snippet: toolbar, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
if (!bubbleMenu) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
const inst = mount(PortalHost, {
target: container,
props: { snippet: bubbleMenu, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
if (!floatingMenu) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
const inst = mount(PortalHost, {
target: container,
props: { snippet: floatingMenu, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
if (!nodeView) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
const inst = mount(PortalHostReactive, {
target: container,
props: { snippet: nodeView, initialScope: scope },
});
portalInstances.add(inst as Record<string, unknown>);
return {
update: (s: unknown): void => {
(inst as unknown as { update(s: unknown): void }).update(s);
},
dispose: (): void => {
unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.delete(inst as Record<string, unknown>);
},
};
},
};
$effect(() => () => {
for (const inst of portalInstances) unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.clear();
});
onMount(() => {
lastHtml = html;
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
const nodeViewExtensions = nodeView ? makeNodeViewExtensions(portals.nodeView) : [];
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
const placeholderExtensions = placeholder ? [Placeholder.configure({
placeholder: placeholder
})] : [];
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
if (bubbleMenu) {
bubbleMenuEl = document.createElement('div');
bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
}
if (floatingMenu) {
floatingMenuEl = document.createElement('div');
floatingMenuEl.className = 'rozie-tiptap-floating-menu';
}
const menuExtensions = [...(bubbleMenuEl ? [BubbleMenu.configure({
element: bubbleMenuEl
})] : []), ...(floatingMenuEl ? [FloatingMenu.configure({
element: floatingMenuEl
})] : [])];
editor = new Editor({
element: editorEl!,
content: html,
editable: editable,
autofocus: autofocus,
// StarterKit first; the Placeholder ext next; the reactive node-view nodes
// next; consumer extensions LAST so they win (TipTap applies later-registered
// extensions over earlier ones for the same node/mark).
extensions: [StarterKit, ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...extensions],
editorProps: {
attributes: {
'aria-label': ariaLabel,
...(editorClass ? {
class: editorClass
} : {}),
...(placeholder ? {
'data-placeholder': placeholder,
'aria-placeholder': placeholder
} : {})
},
// Consumer editorProps spread LAST — full ProseMirror editorProps control
// (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
...editorProps
},
onUpdate: ({
editor
}: any) => {
const next = editor.getHTML();
lastHtml = next;
// Round-trip guard — see CodeMirror/Flatpickr for the same shape.
if (next !== html) html = next;
onupdate?.(next);
},
onSelectionUpdate: () => {
refreshActive();
onselectionupdate?.();
},
onFocus: () => onfocus?.(),
onBlur: () => onblur?.()
});
refreshActive();
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
if (toolbar && toolbarEl) {
toolbarDispose = portals.toolbar(toolbarEl!, {
editor
});
}
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
if (bubbleMenuEl) {
bubbleMenuDispose = portals.bubbleMenu(bubbleMenuEl, {
editor
});
}
if (floatingMenuEl) {
floatingMenuDispose = portals.floatingMenu(floatingMenuEl, {
editor
});
}
return () => {
toolbarDispose?.();
toolbarDispose = null;
bubbleMenuDispose?.();
bubbleMenuDispose = null;
floatingMenuDispose?.();
floatingMenuDispose = null;
editor?.destroy();
};
});
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => html)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!editor) return;
if (v === lastHtml) return;
lastHtml = v;
editor.commands.setContent(v, {
emitUpdate: false
});
refreshActive();
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => editable)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => editor?.setEditable(v, false))(__watchVal); }); });
</script>
<div class={["rozie-tiptap", { 'is-readonly': !editable }]} data-rozie-s-2aeee876>{#if editable && !toolbar}<div class="rozie-tiptap-toolbar" data-rozie-s-2aeee876><button type="button" class={{ active: active.bold }} aria-label="Bold" onclick={toggleBold} data-rozie-s-2aeee876><strong data-rozie-s-2aeee876>B</strong></button><button type="button" class={{ active: active.italic }} aria-label="Italic" onclick={toggleItalic} data-rozie-s-2aeee876><em data-rozie-s-2aeee876>I</em></button><span class="sep" data-rozie-s-2aeee876></span><button type="button" class={{ active: active.h1 }} aria-label="Heading 1" onclick={($event) => { toggleHeading(1); }} data-rozie-s-2aeee876>H1</button><button type="button" class={{ active: active.h2 }} aria-label="Heading 2" onclick={($event) => { toggleHeading(2); }} data-rozie-s-2aeee876>H2</button><span class="sep" data-rozie-s-2aeee876></span><button type="button" class={{ active: active.bulletList }} aria-label="Bullet list" onclick={toggleBulletList} data-rozie-s-2aeee876>• List</button></div>{/if}{#if editable && toolbar}<div class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" bind:this={toolbarEl} data-rozie-s-2aeee876></div>{/if}<div bind:this={editorEl} class="rozie-tiptap-content" data-placeholder={placeholder} data-rozie-s-2aeee876></div></div>
<style>
:global {
.rozie-tiptap[data-rozie-s-2aeee876] {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
overflow: hidden;
background: white;
}
.rozie-tiptap.is-readonly[data-rozie-s-2aeee876] {
background: #fafafa;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #f5f5f7;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876] {
padding: 0.25rem 0.5rem;
border: 1px solid transparent;
background: transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
min-width: 1.75rem;
color: rgba(0, 0, 0, 0.65);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876]:hover {
background: rgba(0, 0, 0, 0.06);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button.active[data-rozie-s-2aeee876] {
background: #1a1a1a;
color: white;
border-color: #1a1a1a;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] .sep[data-rozie-s-2aeee876] {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.25rem;
}
.rozie-tiptap-content[data-rozie-s-2aeee876] {
padding: 0.625rem 0.875rem;
min-height: 6rem;
font: inherit;
outline: none;
}
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876] { margin: 0 0 0.5rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876]:last-child { margin-bottom: 0; }
.rozie-tiptap-content[data-rozie-s-2aeee876] h1[data-rozie-s-2aeee876] { font-size: 1.5rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] h2[data-rozie-s-2aeee876] { font-size: 1.25rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] ul[data-rozie-s-2aeee876] { margin: 0 0 0.5rem; padding-left: 1.5rem; }
}
:global {
.rozie-tiptap-content .is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: rgba(0, 0, 0, 0.4);
float: left;
height: 0;
pointer-events: none;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, TemplateRef, ViewContainerRef, ViewEncapsulation, contentChild, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
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).
// 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';
// 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).
interface ToolbarCtx {
$implicit: { editor: any };
editor: any;
}
interface BubbleMenuCtx {
$implicit: { editor: any };
editor: any;
}
interface FloatingMenuCtx {
$implicit: { editor: any };
editor: any;
}
interface NodeViewCtx {
$implicit: { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any };
node: any;
selected: any;
updateAttributes: any;
getPos: any;
editor: any;
contentDOM: any;
}
@Component({
selector: 'rozie-tip-tap',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-tiptap" [ngClass]="{ 'is-readonly': !editable() }">
@if (editable() && !(toolbarTpl ?? templates()?.['toolbar'])) {
<div class="rozie-tiptap-toolbar">
<button type="button" [class]="{ active: active().bold }" aria-label="Bold" (click)="toggleBold()"><strong>B</strong></button>
<button type="button" [class]="{ active: active().italic }" aria-label="Italic" (click)="toggleItalic()"><em>I</em></button>
<span class="sep"></span>
<button type="button" [class]="{ active: active().h1 }" aria-label="Heading 1" (click)="toggleHeading(1)">H1</button>
<button type="button" [class]="{ active: active().h2 }" aria-label="Heading 2" (click)="toggleHeading(2)">H2</button>
<span class="sep"></span>
<button type="button" [class]="{ active: active().bulletList }" aria-label="Bullet list" (click)="toggleBulletList()">• List</button>
</div>
}@if (editable() && (toolbarTpl ?? templates()?.['toolbar'])) {
<div class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" #toolbarEl></div>
}<div #editorEl class="rozie-tiptap-content" [attr.data-placeholder]="placeholder()"></div>
</div>
<ng-container #rozie_portalAnchor></ng-container>
`,
styles: [`
.rozie-tiptap {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
overflow: hidden;
background: white;
}
.rozie-tiptap.is-readonly {
background: #fafafa;
}
.rozie-tiptap-toolbar {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #f5f5f7;
}
.rozie-tiptap-toolbar button {
padding: 0.25rem 0.5rem;
border: 1px solid transparent;
background: transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
min-width: 1.75rem;
color: rgba(0, 0, 0, 0.65);
}
.rozie-tiptap-toolbar button:hover {
background: rgba(0, 0, 0, 0.06);
}
.rozie-tiptap-toolbar button.active {
background: #1a1a1a;
color: white;
border-color: #1a1a1a;
}
.rozie-tiptap-toolbar .sep {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.25rem;
}
.rozie-tiptap-content {
padding: 0.625rem 0.875rem;
min-height: 6rem;
font: inherit;
outline: none;
}
.rozie-tiptap-content p { margin: 0 0 0.5rem; }
.rozie-tiptap-content p:last-child { margin-bottom: 0; }
.rozie-tiptap-content h1 { font-size: 1.5rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content h2 { font-size: 1.25rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content ul { margin: 0 0 0.5rem; padding-left: 1.5rem; }
::ng-deep .rozie-tiptap-content .is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: rgba(0, 0, 0, 0.4);
float: left;
height: 0;
pointer-events: none;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TipTap),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class TipTap {
/**
* 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…" />
*/
html = model<string>('<p>Start writing…</p>');
/**
* 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.
*/
editable = input<boolean>(true);
/**
* 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.
*/
placeholder = input<string>('');
/**
* Whether to place the caret in the document on mount (TipTap's `autofocus` option).
*/
autofocus = input<boolean>(false);
/**
* A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
*/
editorClass = input<string>('');
/**
* The accessible name (`aria-label`) applied to the contenteditable element.
*/
ariaLabel = input<string>('Rich text editor');
/**
* ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
*/
editorProps = input<Record<string, any>>((() => ({}))());
/**
* Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Composed **last** so consumer extensions win for the same node or mark.
*/
extensions = input<any[]>((() => [])());
active = signal({
bold: false,
italic: false,
h1: false,
h2: false,
bulletList: false
});
toolbarEl = viewChild<ElementRef<HTMLDivElement>>('toolbarEl');
editorEl = viewChild<ElementRef<HTMLDivElement>>('editorEl');
update = output<unknown>();
selectionUpdate = output<void>();
focus = output<void>();
blur = output<void>();
@ContentChild('toolbar', { read: TemplateRef }) toolbarTpl?: TemplateRef<ToolbarCtx>;
@ContentChild('bubbleMenu', { read: TemplateRef }) bubbleMenuTpl?: TemplateRef<BubbleMenuCtx>;
@ContentChild('floatingMenu', { read: TemplateRef }) floatingMenuTpl?: TemplateRef<FloatingMenuCtx>;
@ContentChild('nodeView', { read: TemplateRef }) nodeViewTpl?: TemplateRef<NodeViewCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private _portalViews = new Set<EmbeddedViewRef<unknown>>();
private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
private _toolbarTpl = contentChild('toolbar', { read: TemplateRef });
private _bubbleMenuTpl = contentChild('bubbleMenu', { read: TemplateRef });
private _floatingMenuTpl = contentChild('floatingMenu', { read: TemplateRef });
private _nodeViewTpl = contentChild('nodeView', { read: TemplateRef });
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
constructor() {
effect(() => { const __watchVal = (() => this.html())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!this.editor) return;
if (v === this.lastHtml) return;
this.lastHtml = v;
this.editor.commands.setContent(v, {
emitUpdate: false
});
this.refreshActive();
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.editable())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => this.editor?.setEditable(v, false))(__watchVal); }); });
}
ngAfterViewInit() {
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const tpl = this._toolbarTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const tpl = this._bubbleMenuTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const tpl = this._floatingMenuTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
const tpl = this._nodeViewTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return {
update: (s: unknown): void => {
Object.assign(view.context as object, s as object);
view.detectChanges();
},
dispose: (): void => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
},
};
},
};
const __placeholder = this.placeholder();
const __editorClass = this.editorClass();
this.lastHtml = this.html();
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
const nodeViewExtensions = (this.nodeViewTpl ?? this.templates()?.['nodeView']) ? this.makeNodeViewExtensions(portals.nodeView) : [];
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
const placeholderExtensions = __placeholder ? [Placeholder.configure({
placeholder: __placeholder
})] : [];
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
if ((this.bubbleMenuTpl ?? this.templates()?.['bubbleMenu'])) {
this.bubbleMenuEl = document.createElement('div');
this.bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
}
if ((this.floatingMenuTpl ?? this.templates()?.['floatingMenu'])) {
this.floatingMenuEl = document.createElement('div');
this.floatingMenuEl.className = 'rozie-tiptap-floating-menu';
}
const menuExtensions = [...(this.bubbleMenuEl ? [BubbleMenu.configure({
element: this.bubbleMenuEl
})] : []), ...(this.floatingMenuEl ? [FloatingMenu.configure({
element: this.floatingMenuEl
})] : [])];
this.editor = new Editor({
element: this.editorEl()!.nativeElement,
content: this.html(),
editable: this.editable(),
autofocus: this.autofocus(),
// StarterKit first; the Placeholder ext next; the reactive node-view nodes
// next; consumer extensions LAST so they win (TipTap applies later-registered
// extensions over earlier ones for the same node/mark).
extensions: [StarterKit, ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...this.extensions()],
editorProps: {
attributes: {
'aria-label': this.ariaLabel(),
...(__editorClass ? {
class: __editorClass
} : {}),
...(__placeholder ? {
'data-placeholder': __placeholder,
'aria-placeholder': __placeholder
} : {})
},
// Consumer editorProps spread LAST — full ProseMirror editorProps control
// (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
...this.editorProps()
},
onUpdate: ({
editor
}: any) => {
const next = editor.getHTML();
this.lastHtml = next;
// Round-trip guard — see CodeMirror/Flatpickr for the same shape.
if (next !== this.html()) this.html.set(next), this.__rozieCvaOnChange(next);
this.update.emit(next);
},
onSelectionUpdate: () => {
this.refreshActive();
this.selectionUpdate.emit();
},
onFocus: () => this.focus.emit(),
onBlur: () => this.blur.emit()
});
this.refreshActive();
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
if ((this.toolbarTpl ?? this.templates()?.['toolbar']) && this.toolbarEl()?.nativeElement) {
this.toolbarDispose = portals.toolbar(this.toolbarEl()!.nativeElement, {
editor: this.editor
});
}
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
if (this.bubbleMenuEl) {
this.bubbleMenuDispose = portals.bubbleMenu(this.bubbleMenuEl, {
editor: this.editor
});
}
if (this.floatingMenuEl) {
this.floatingMenuDispose = portals.floatingMenu(this.floatingMenuEl, {
editor: this.editor
});
}
this.__rozieDestroyRef.onDestroy(() => {
this.toolbarDispose?.();
this.toolbarDispose = null;
this.bubbleMenuDispose?.();
this.bubbleMenuDispose = null;
this.floatingMenuDispose?.();
this.floatingMenuDispose = null;
this.editor?.destroy();
});
this.__rozieDestroyRef.onDestroy(() => {
for (const view of this._portalViews) view.destroy();
this._portalViews.clear();
});
}
editor: any = null;
lastHtml: any = null;
toolbarDispose: any = null;
bubbleMenuEl: any = null;
bubbleMenuDispose: any = null;
floatingMenuEl: any = null;
floatingMenuDispose: any = null;
refreshActive = () => {
if (!this.editor) return;
this.active.set({
bold: this.editor.isActive('bold'),
italic: this.editor.isActive('italic'),
h1: this.editor.isActive('heading', {
level: 1
}),
h2: this.editor.isActive('heading', {
level: 2
}),
bulletList: this.editor.isActive('bulletList')
});
};
makeNodeView = (nv: any, editable: any) => (props: any) => {
const {
node,
getPos,
editor: ed
} = props;
// engine-owned outer host the consumer fragment mounts into.
const dom = document.createElement(editable ? 'div' : 'span');
dom.className = editable ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
// EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
// into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
const contentDOM = editable ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
const updateAttributes = (attrs: any) => {
if (typeof getPos !== 'function') return;
const pos = getPos();
if (pos == null) return;
ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...attrs
}));
};
const buildScope = (n: any, selected: any) => ({
node: n,
selected,
updateAttributes,
getPos,
editor: ed,
...(contentDOM ? {
contentDOM
} : {})
});
// Reactive handle — { update, dispose }. The fragment mounts ONCE; every
// engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
const handle = nv(dom, buildScope(node, false));
// contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
// consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
// ProseMirror manages `contentDOM` and renders the node's editable children
// INTO it, so `contentDOM` must live inside the visible hole. The fragment is
// rendered into `dom` by the per-target reactive portal — synchronously on
// React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
// Angular (REQ-23). A query-after-render graft (retried across a microtask +
// a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
// the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
// and the framework never reconciles it away (the hole carries no child binding).
const graftContentDOM = (attempt: any) => {
if (!contentDOM) return;
const hole = dom.querySelector('[data-rozie-hole]');
if (hole) {
if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
return;
}
if (attempt < 5) {
if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
}
};
graftContentDOM(0);
// After a reactive re-render (chrome update), re-graft so a fragment that
// recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
// (REQ-24 — the editable subtree survives every chrome update).
const updateInPlace = (n: any, selected: any) => {
handle.update(buildScope(n, selected));
if (contentDOM) graftContentDOM(0);
};
return {
dom,
...(contentDOM ? {
contentDOM
} : {}),
// attr / content change for THIS node → re-render the fragment in place,
// keep the view (return true). The new node identity is forwarded so the
// fragment reads fresh node.attrs (REQ-26).
update(nextNode: any) {
if (nextNode.type !== node.type) return false;
updateInPlace(nextNode, false);
return true;
},
// NodeSelection enters/leaves the node → toggle `selected` in scope so the
// chip's selected styling is pure engine-driven reactive `update`.
selectNode() {
updateInPlace(node, true);
},
deselectNode() {
updateInPlace(node, false);
},
destroy() {
handle.dispose();
}
};
};
makeNodeViewExtensions = (nv: any) => {
// (1) NON-EDITABLE inline atom @mention chip (Spike 009 / REQ-26).
const Mention = Node.create({
name: 'rozieMention',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes: () => ({
id: {
default: null
},
label: {
default: ''
}
}),
parseHTML: () => [{
tag: 'span[data-rozie-mention]'
}],
// ATOM nodes are leaf nodes — their renderHTML must NOT include a `0` content
// hole (ProseMirror's DOMSerializer throws "Content hole not allowed in a leaf
// node spec"). The chip's visible content is supplied by the node view; the
// serialized form is just the marker span carrying the attrs.
renderHTML: ({
HTMLAttributes
}: any) => ['span', {
'data-rozie-mention': '',
...HTMLAttributes
}],
addNodeView: () => this.makeNodeView(nv, false)
});
// (2) EDITABLE block callout with a contentDOM hole (Spike 008 / REQ-23).
const Callout = Node.create({
name: 'rozieCallout',
group: 'block',
content: 'inline*',
defining: true,
addAttributes: () => ({
tone: {
default: 'info'
}
}),
parseHTML: () => [{
tag: 'div[data-rozie-callout]'
}],
renderHTML: ({
HTMLAttributes
}: any) => ['div', {
'data-rozie-callout': '',
...HTMLAttributes
}, 0],
addNodeView: () => this.makeNodeView(nv, true)
});
return [Mention, Callout];
};
getEditor = () => {
return this.editor;
};
focusEditor = () => {
this.editor?.commands.focus();
};
blurEditor = () => {
this.editor?.commands.blur();
};
getHTML = () => {
return this.editor ? this.editor.getHTML() : '';
};
getJSON = () => {
return this.editor ? this.editor.getJSON() : null;
};
getText = () => {
return this.editor ? this.editor.getText() : '';
};
setContent = (next: any) => {
if (!this.editor) return;
const v = next ?? '';
if (v === this.lastHtml) return;
this.lastHtml = v;
this.editor.commands.setContent(v, {
emitUpdate: false
});
this.html.set(v), this.__rozieCvaOnChange(v);
this.refreshActive();
};
clearContent = () => {
if (!this.editor) return;
this.editor.commands.clearContent();
this.lastHtml = this.editor.getHTML();
this.html.set(this.lastHtml), this.__rozieCvaOnChange(this.lastHtml);
this.refreshActive();
};
toggleBold = () => {
this.editor?.chain().focus().toggleBold().run();
this.refreshActive();
};
toggleItalic = () => {
this.editor?.chain().focus().toggleItalic().run();
this.refreshActive();
};
toggleHeading = (level: any) => {
this.editor?.chain().focus().toggleHeading({
level: level ?? 1
}).run();
this.refreshActive();
};
toggleBulletList = () => {
this.editor?.chain().focus().toggleBulletList().run();
this.refreshActive();
};
undo = () => {
this.editor?.chain().focus().undo().run();
this.refreshActive();
};
redo = () => {
this.editor?.chain().focus().redo().run();
this.refreshActive();
};
chain = () => {
return this.editor ? this.editor.chain().focus() : null;
};
isActive = (name: any, attrs: any) => {
return this.editor ? this.editor.isActive(name, attrs) : false;
};
can = () => {
return this.editor ? this.editor.can() : null;
};
isEmpty = () => {
return this.editor ? this.editor.isEmpty : true;
};
private __rozieCvaOnChange: (v: string) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: string | null): void {
this.html.set(v ?? '<p>Start writing…</p>');
}
registerOnChange(fn: (v: string) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
static ngTemplateContextGuard(
_dir: TipTap,
_ctx: unknown,
): _ctx is ToolbarCtx | BubbleMenuCtx | FloatingMenuCtx | NodeViewCtx {
return true;
}
}
export default TipTap;tsx
import type { JSX } from 'solid-js';
import { Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle, createControllableSignal, rozieClass } from '@rozie/runtime-solid';
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).
// 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';
// 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).
__rozieInjectStyle('TipTap-2aeee876', `.rozie-tiptap[data-rozie-s-2aeee876] {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
overflow: hidden;
background: white;
}
.rozie-tiptap.is-readonly[data-rozie-s-2aeee876] {
background: #fafafa;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #f5f5f7;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876] {
padding: 0.25rem 0.5rem;
border: 1px solid transparent;
background: transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
min-width: 1.75rem;
color: rgba(0, 0, 0, 0.65);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876]:hover {
background: rgba(0, 0, 0, 0.06);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button.active[data-rozie-s-2aeee876] {
background: #1a1a1a;
color: white;
border-color: #1a1a1a;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] .sep[data-rozie-s-2aeee876] {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.25rem;
}
.rozie-tiptap-content[data-rozie-s-2aeee876] {
padding: 0.625rem 0.875rem;
min-height: 6rem;
font: inherit;
outline: none;
}
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876] { margin: 0 0 0.5rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876]:last-child { margin-bottom: 0; }
.rozie-tiptap-content[data-rozie-s-2aeee876] h1[data-rozie-s-2aeee876] { font-size: 1.5rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] h2[data-rozie-s-2aeee876] { font-size: 1.25rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] ul[data-rozie-s-2aeee876] { margin: 0 0 0.5rem; padding-left: 1.5rem; }
.rozie-tiptap-content .is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: rgba(0, 0, 0, 0.4);
float: left;
height: 0;
pointer-events: none;
}`);
interface ToolbarSlotCtx { editor: any; }
interface BubbleMenuSlotCtx { editor: any; }
interface FloatingMenuSlotCtx { editor: any; }
interface NodeViewSlotCtx { node: any; selected: any; updateAttributes: any; getPos: any; editor: any; contentDOM: any; }
interface TipTapProps {
/**
* 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…" />
*/
html?: string;
defaultHtml?: string;
onHtmlChange?: (html: string) => void;
/**
* 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.
*/
editable?: boolean;
/**
* 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.
*/
placeholder?: string;
/**
* Whether to place the caret in the document on mount (TipTap's `autofocus` option).
*/
autofocus?: boolean;
/**
* A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
*/
editorClass?: string;
/**
* The accessible name (`aria-label`) applied to the contenteditable element.
*/
ariaLabel?: string;
/**
* ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
*/
editorProps?: Record<string, any>;
/**
* Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Composed **last** so consumer extensions win for the same node or mark.
*/
extensions?: any[];
onUpdate?: (...args: unknown[]) => void;
onSelectionUpdate?: (...args: unknown[]) => void;
onFocus?: (...args: unknown[]) => void;
onBlur?: (...args: unknown[]) => void;
toolbarSlot?: (ctx: ToolbarSlotCtx) => JSX.Element;
bubbleMenuSlot?: (ctx: BubbleMenuSlotCtx) => JSX.Element;
floatingMenuSlot?: (ctx: FloatingMenuSlotCtx) => JSX.Element;
nodeViewSlot?: (ctx: () => NodeViewSlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: TipTapHandle) => void;
}
export interface TipTapHandle {
getEditor: (...args: any[]) => any;
focusEditor: (...args: any[]) => any;
blurEditor: (...args: any[]) => any;
getHTML: (...args: any[]) => any;
getJSON: (...args: any[]) => any;
getText: (...args: any[]) => any;
setContent: (...args: any[]) => any;
clearContent: (...args: any[]) => any;
toggleBold: (...args: any[]) => any;
toggleItalic: (...args: any[]) => any;
toggleHeading: (...args: any[]) => any;
toggleBulletList: (...args: any[]) => any;
undo: (...args: any[]) => any;
redo: (...args: any[]) => any;
chain: (...args: any[]) => any;
isActive: (...args: any[]) => any;
can: (...args: any[]) => any;
isEmpty: (...args: any[]) => any;
}
export default function TipTap(_props: TipTapProps): JSX.Element {
const _merged = mergeProps({ editable: true, placeholder: '', autofocus: false, editorClass: '', ariaLabel: 'Rich text editor', editorProps: (() => ({}))(), extensions: (() => [])() }, _props);
const [local, attrs] = splitProps(_merged, ['html', 'editable', 'placeholder', 'autofocus', 'editorClass', 'ariaLabel', 'editorProps', 'extensions', 'ref']);
onMount(() => { local.ref?.({ getEditor, focusEditor, blurEditor, getHTML, getJSON, getText, setContent, clearContent, toggleBold, toggleItalic, toggleHeading, toggleBulletList, undo, redo, chain, isActive, can, isEmpty }); });
const [html, setHtml] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'html', '<p>Start writing…</p>');
const [active, setActive] = createSignal({
bold: false,
italic: false,
h1: false,
h2: false,
bulletList: false
});
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portalDisposers = new Set<() => void>();
const portals = {
toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slot = _props.toolbarSlot ?? _props.slots?.['toolbar'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slot = _props.bubbleMenuSlot ?? _props.slots?.['bubbleMenu'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const slot = _props.floatingMenuSlot ?? _props.slots?.['floatingMenu'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
const slot = _props.nodeViewSlot ?? _props.slots?.['nodeView'];
if (typeof slot !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
const [scopeSig, setScopeSig] = createSignal<unknown>(scope, { equals: false });
const dispose = render(() => slot(scopeSig as unknown as (() => { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown })), container);
portalDisposers.add(dispose);
return {
update: (s: unknown): void => {
setScopeSig(s);
},
dispose: (): void => {
dispose();
portalDisposers.delete(dispose);
},
};
},
};
onCleanup(() => {
for (const dispose of portalDisposers) dispose();
portalDisposers.clear();
});
onMount(() => {
const _cleanup = (() => {
lastHtml = html();
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
const nodeViewExtensions = (_props.nodeViewSlot ?? _props.slots?.["nodeView"]) ? makeNodeViewExtensions(portals.nodeView) : [];
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
const placeholderExtensions = local.placeholder ? [Placeholder.configure({
placeholder: local.placeholder
})] : [];
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
if ((_props.bubbleMenuSlot ?? _props.slots?.["bubbleMenu"])) {
bubbleMenuEl = document.createElement('div');
bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
}
if ((_props.floatingMenuSlot ?? _props.slots?.["floatingMenu"])) {
floatingMenuEl = document.createElement('div');
floatingMenuEl.className = 'rozie-tiptap-floating-menu';
}
const menuExtensions = [...(bubbleMenuEl ? [BubbleMenu.configure({
element: bubbleMenuEl
})] : []), ...(floatingMenuEl ? [FloatingMenu.configure({
element: floatingMenuEl
})] : [])];
editor = new Editor({
element: editorElRef,
content: html(),
editable: local.editable,
autofocus: local.autofocus,
// StarterKit first; the Placeholder ext next; the reactive node-view nodes
// next; consumer extensions LAST so they win (TipTap applies later-registered
// extensions over earlier ones for the same node/mark).
extensions: [StarterKit, ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...local.extensions],
editorProps: {
attributes: {
'aria-label': local.ariaLabel,
...(local.editorClass ? {
class: local.editorClass
} : {}),
...(local.placeholder ? {
'data-placeholder': local.placeholder,
'aria-placeholder': local.placeholder
} : {})
},
// Consumer editorProps spread LAST — full ProseMirror editorProps control
// (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
...local.editorProps
},
onUpdate: ({
editor
}: any) => {
const next = editor.getHTML();
lastHtml = next;
// Round-trip guard — see CodeMirror/Flatpickr for the same shape.
if (next !== html()) setHtml(next);
_props.onUpdate?.(next);
},
onSelectionUpdate: () => {
refreshActive();
_props.onSelectionUpdate?.();
},
onFocus: () => _props.onFocus?.(),
onBlur: () => _props.onBlur?.()
});
refreshActive();
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
if ((_props.toolbarSlot ?? _props.slots?.["toolbar"]) && toolbarElRef) {
toolbarDispose = portals.toolbar(toolbarElRef, {
editor
});
}
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
if (bubbleMenuEl) {
bubbleMenuDispose = portals.bubbleMenu(bubbleMenuEl, {
editor
});
}
if (floatingMenuEl) {
floatingMenuDispose = portals.floatingMenu(floatingMenuEl, {
editor
});
}
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
toolbarDispose?.();
toolbarDispose = null;
bubbleMenuDispose?.();
bubbleMenuDispose = null;
floatingMenuDispose?.();
floatingMenuDispose = null;
editor?.destroy();
});
});
createEffect(on(() => (() => html())(), (v) => untrack(() => ((v: any) => {
if (!editor) return;
if (v === lastHtml) return;
lastHtml = v;
editor.commands.setContent(v, {
emitUpdate: false
});
refreshActive();
})(v)), { defer: true }));
createEffect(on(() => (() => local.editable)(), (v) => untrack(() => ((v: any) => editor?.setEditable(v, false))(v)), { defer: true }));
let toolbarElRef: HTMLElement | null = null;
let editorElRef: HTMLElement | null = null;
// 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: any = 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: any = 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: any = 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: any = null;
let bubbleMenuDispose: any = null;
let floatingMenuEl: any = null;
let floatingMenuDispose: any = null;
// Recompute the internal toolbar's active-mark booleans from the live editor.
function refreshActive() {
if (!editor) return;
setActive({
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
h1: editor.isActive('heading', {
level: 1
}),
h2: editor.isActive('heading', {
level: 2
}),
bulletList: editor.isActive('bulletList')
});
}
// ── Reactive node-view portal slot (Phase 33 — the FIRST shipped `reactive`
// portal slot, the marquee TipTap differentiator). When the consumer fills the
// `nodeView` slot, two custom ProseMirror nodes render the consumer fragment as
// a custom node *in-engine*, re-rendering it in place on every transaction via
// the reactive handle `$portals.nodeView(dom, scope) => { update, dispose }`
// (REQ-22). Both halves of the primitive are proven and shipped here:
//
// 1. `mention` — a NON-EDITABLE inline ATOM (selectable:true, no contentDOM,
// Spike 009 / REQ-26). selectNode/deselectNode/update(node) → handle.update
// so the chip re-renders in place (engine-driven; no Rozie reactive loop).
//
// 2. `callout` — an EDITABLE BLOCK (content:'inline*', so it HAS a contentDOM,
// Spike 008 / REQ-23). ProseMirror owns the editable hole; the consumer
// fragment renders chrome wrapping a [data-rozie-hole] placeholder and the
// per-target portal bridge grafts contentDOM into that hole — native-ref on
// React/Solid/Lit, querySelector-after-render on Vue/Svelte/Angular. The
// .rozie source merely passes `contentDOM` in scope; the graft mechanism is
// PER-TARGET and lives in the emitted portal bridge, not here.
//
// $portals.nodeView is referenced ONLY inside $onMount/the addNodeView closures
// (the $refs-only-in-onMount + bundled-leaf strict-typecheck discipline — the
// same constraint the toolbar slot follows). `makeNodeViewExtensions` is invoked
// from inside $onMount so the `nv` closure (capturing $portals.nodeView) is
// constructed within the mount lifecycle.
function makeNodeView(nv: any, editable: any) {
return (props: any) => {
const {
node,
getPos,
editor: ed
} = props;
// engine-owned outer host the consumer fragment mounts into.
const dom = document.createElement(editable ? 'div' : 'span');
dom.className = editable ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
// EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
// into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
const contentDOM = editable ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
const updateAttributes = (attrs: any) => {
if (typeof getPos !== 'function') return;
const pos = getPos();
if (pos == null) return;
ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...attrs
}));
};
const buildScope = (n: any, selected: any) => ({
node: n,
selected,
updateAttributes,
getPos,
editor: ed,
...(contentDOM ? {
contentDOM
} : {})
});
// Reactive handle — { update, dispose }. The fragment mounts ONCE; every
// engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
const handle = nv(dom, buildScope(node, false));
// contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
// consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
// ProseMirror manages `contentDOM` and renders the node's editable children
// INTO it, so `contentDOM` must live inside the visible hole. The fragment is
// rendered into `dom` by the per-target reactive portal — synchronously on
// React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
// Angular (REQ-23). A query-after-render graft (retried across a microtask +
// a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
// the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
// and the framework never reconciles it away (the hole carries no child binding).
const graftContentDOM = (attempt: any) => {
if (!contentDOM) return;
const hole = dom.querySelector('[data-rozie-hole]');
if (hole) {
if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
return;
}
if (attempt < 5) {
if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
}
};
graftContentDOM(0);
// After a reactive re-render (chrome update), re-graft so a fragment that
// recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
// (REQ-24 — the editable subtree survives every chrome update).
const updateInPlace = (n: any, selected: any) => {
handle.update(buildScope(n, selected));
if (contentDOM) graftContentDOM(0);
};
return {
dom,
...(contentDOM ? {
contentDOM
} : {}),
// attr / content change for THIS node → re-render the fragment in place,
// keep the view (return true). The new node identity is forwarded so the
// fragment reads fresh node.attrs (REQ-26).
update(nextNode: any) {
if (nextNode.type !== node.type) return false;
updateInPlace(nextNode, false);
return true;
},
// NodeSelection enters/leaves the node → toggle `selected` in scope so the
// chip's selected styling is pure engine-driven reactive `update`.
selectNode() {
updateInPlace(node, true);
},
deselectNode() {
updateInPlace(node, false);
},
destroy() {
handle.dispose();
}
};
};
}
// Build the two custom Nodes bound to the reactive nodeView portal. Takes the
// per-target `$portals.nodeView` (captured here so the reference stays inside
// the mount lifecycle — never top-level, per the bundled-leaf typecheck rule).
function makeNodeViewExtensions(nv: any) {
// (1) NON-EDITABLE inline atom @mention chip (Spike 009 / REQ-26).
const Mention = Node.create({
name: 'rozieMention',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes: () => ({
id: {
default: null
},
label: {
default: ''
}
}),
parseHTML: () => [{
tag: 'span[data-rozie-mention]'
}],
// ATOM nodes are leaf nodes — their renderHTML must NOT include a `0` content
// hole (ProseMirror's DOMSerializer throws "Content hole not allowed in a leaf
// node spec"). The chip's visible content is supplied by the node view; the
// serialized form is just the marker span carrying the attrs.
renderHTML: ({
HTMLAttributes
}: any) => ['span', {
'data-rozie-mention': '',
...HTMLAttributes
}],
addNodeView: () => makeNodeView(nv, false)
});
// (2) EDITABLE block callout with a contentDOM hole (Spike 008 / REQ-23).
const Callout = Node.create({
name: 'rozieCallout',
group: 'block',
content: 'inline*',
defining: true,
addAttributes: () => ({
tone: {
default: 'info'
}
}),
parseHTML: () => [{
tag: 'div[data-rozie-callout]'
}],
renderHTML: ({
HTMLAttributes
}: any) => ['div', {
'data-rozie-callout': '',
...HTMLAttributes
}, 0],
addNodeView: () => makeNodeView(nv, true)
});
return [Mention, Callout];
}
// ── Imperative handle (Phase 21 $expose) — TipTap is command-rich, so this is
// the marquee surface: 14 verbs over the live Editor, uniform across all 6
// targets. Each guards the pre-mount / destroyed `editor = null`.
//
// Collision discipline:
// - The content setter is named `setContent`, NOT `setHtml` — an `html` model
// prop makes React auto-generate a `setHtml` state setter, so a `setHtml`
// $expose verb would collide on the React target (ROZ524). (CodeMirror's
// setValue→replaceValue lesson, html edition.)
// - None of the 14 names collide with LitElement reserved lifecycle methods
// (update/render/firstUpdated/updated/willUpdate/requestUpdate).
// - The focus/blur COMMANDS are named `focusEditor`/`blurEditor`, NOT
// `focus`/`blur` — the component emits `focus`/`blur` EVENTS, and on
// class-based targets (Angular) an output field and a method cannot share a
// name (ROZ121). The diagnostic's own guidance: rename the method, keep the
// event's public name. (The expose-verb-vs-event-name collision lesson.)
// - None equals a prop name (html/editable/placeholder/autofocus/editorClass/
// ariaLabel/editorProps/extensions).
function getEditor() {
return editor;
}
function focusEditor() {
editor?.commands.focus();
}
function blurEditor() {
editor?.commands.blur();
}
function getHTML() {
return editor ? editor.getHTML() : '';
}
function getJSON() {
return editor ? editor.getJSON() : null;
}
// Plain-text extraction — word/char counts, search indexing, plaintext export.
// Mirrors getHTML/getJSON (empty string before mount). Was advertised by intent
// alongside getHTML/getJSON but never wired; now first-class.
function getText() {
return editor ? editor.getText() : '';
}
// setContent routes through the SAME suppress-echo bookkeeping as $watch(html):
// update lastHtml first, set with emitUpdate:false (no onUpdate bounce), then
// reflect into the model so a programmatic set keeps the bound state in sync.
function setContent(next: any) {
if (!editor) return;
const v = next ?? '';
if (v === lastHtml) return;
lastHtml = v;
editor.commands.setContent(v, {
emitUpdate: false
});
setHtml(v);
refreshActive();
}
function clearContent() {
if (!editor) return;
editor.commands.clearContent();
lastHtml = editor.getHTML();
setHtml(lastHtml);
refreshActive();
}
function toggleBold() {
editor?.chain().focus().toggleBold().run();
refreshActive();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
refreshActive();
}
function toggleHeading(level: any) {
editor?.chain().focus().toggleHeading({
level: level ?? 1
}).run();
refreshActive();
}
function toggleBulletList() {
editor?.chain().focus().toggleBulletList().run();
refreshActive();
}
function undo() {
editor?.chain().focus().undo().run();
refreshActive();
}
function redo() {
editor?.chain().focus().redo().run();
refreshActive();
}
// Power-user escape hatch — returns a pre-focused command chain (TipTap idiom:
// chain().focus().toggleBold().setColor('#f00').run()). null before mount.
function chain() {
return editor ? editor.chain().focus() : null;
}
// Read-side toolbar primitives. These are precisely what a bring-your-own
// toolbar (the `toolbar`/`bubbleMenu`/`floatingMenu` portal slots) needs and
// the component already computes internally via refreshActive() — exposing them
// removes the per-consumer "drop to getEditor() and re-derive" boilerplate.
// - isActive(name, attrs?): is a mark/node active in the current selection
// (drive toolbar button active styling). False before mount.
// - can(): the command-availability chain (editor.can().chain()…run()) for
// enable/disable of toolbar buttons. null before mount (mirrors chain()).
// - isEmpty(): document-empty (submit-gating / empty-state). true before mount.
function isActive(name: any, attrs: any) {
return editor ? editor.isActive(name, attrs) : false;
}
function can() {
return editor ? editor.can() : null;
}
function isEmpty() {
return editor ? editor.isEmpty : true;
}
return (
<>
<div class={"rozie-tiptap" + " " + rozieClass({ 'is-readonly': !local.editable })} data-rozie-s-2aeee876="">
{<Show when={local.editable && !(_props.toolbarSlot ?? _props.slots?.['toolbar'])}><div class={"rozie-tiptap-toolbar"} data-rozie-s-2aeee876="">
<button type="button" aria-label="Bold" class={rozieClass({ active: active().bold })} onClick={toggleBold} data-rozie-s-2aeee876=""><strong data-rozie-s-2aeee876="">B</strong></button>
<button type="button" aria-label="Italic" class={rozieClass({ active: active().italic })} onClick={toggleItalic} data-rozie-s-2aeee876=""><em data-rozie-s-2aeee876="">I</em></button>
<span class={"sep"} data-rozie-s-2aeee876="" />
<button type="button" aria-label="Heading 1" class={rozieClass({ active: active().h1 })} onClick={($event) => { toggleHeading(1); }} data-rozie-s-2aeee876="">H1</button>
<button type="button" aria-label="Heading 2" class={rozieClass({ active: active().h2 })} onClick={($event) => { toggleHeading(2); }} data-rozie-s-2aeee876="">H2</button>
<span class={"sep"} data-rozie-s-2aeee876="" />
<button type="button" aria-label="Bullet list" class={rozieClass({ active: active().bulletList })} onClick={toggleBulletList} data-rozie-s-2aeee876="">• List</button>
</div></Show>}{<Show when={local.editable && (_props.toolbarSlot ?? _props.slots?.['toolbar'])}><div class={"rozie-tiptap-toolbar rozie-tiptap-toolbar--slot"} ref={(el) => { toolbarElRef = el as HTMLElement; }} data-rozie-s-2aeee876="" /></Show>}<div ref={(el) => { editorElRef = el as HTMLElement; }} class={"rozie-tiptap-content"} data-placeholder={local.placeholder} data-rozie-s-2aeee876="" />
</div>
</>
);
}ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, createLitControllableProperty, injectGlobalStyles } from '@rozie/runtime-lit';
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).
// 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';
// 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).
interface RozieToolbarSlotCtx {
editor: unknown;
}
interface RozieBubbleMenuSlotCtx {
editor: unknown;
}
interface RozieFloatingMenuSlotCtx {
editor: unknown;
}
interface RozieNodeViewSlotCtx {
node: unknown;
selected: unknown;
updateAttributes: unknown;
getPos: unknown;
editor: unknown;
contentDOM: unknown;
}
@customElement('rozie-tip-tap')
export default class TipTap extends SignalWatcher(LitElement) {
static styles = css`
.rozie-tiptap[data-rozie-s-2aeee876] {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
overflow: hidden;
background: white;
}
.rozie-tiptap.is-readonly[data-rozie-s-2aeee876] {
background: #fafafa;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #f5f5f7;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876] {
padding: 0.25rem 0.5rem;
border: 1px solid transparent;
background: transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
min-width: 1.75rem;
color: rgba(0, 0, 0, 0.65);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button[data-rozie-s-2aeee876]:hover {
background: rgba(0, 0, 0, 0.06);
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] button.active[data-rozie-s-2aeee876] {
background: #1a1a1a;
color: white;
border-color: #1a1a1a;
}
.rozie-tiptap-toolbar[data-rozie-s-2aeee876] .sep[data-rozie-s-2aeee876] {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.25rem;
}
.rozie-tiptap-content[data-rozie-s-2aeee876] {
padding: 0.625rem 0.875rem;
min-height: 6rem;
font: inherit;
outline: none;
}
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876] { margin: 0 0 0.5rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] p[data-rozie-s-2aeee876]:last-child { margin-bottom: 0; }
.rozie-tiptap-content[data-rozie-s-2aeee876] h1[data-rozie-s-2aeee876] { font-size: 1.5rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] h2[data-rozie-s-2aeee876] { font-size: 1.25rem; margin: 0.5rem 0 0.375rem; }
.rozie-tiptap-content[data-rozie-s-2aeee876] ul[data-rozie-s-2aeee876] { margin: 0 0 0.5rem; padding-left: 1.5rem; }
.rozie-tiptap-content .is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: rgba(0, 0, 0, 0.4);
float: left;
height: 0;
pointer-events: none;
}
`;
/**
* 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…" />
*/
@property({ type: String, attribute: 'html' }) _html_attr: string = '<p>Start writing…</p>';
private _htmlControllable = createLitControllableProperty<string>({ host: this, eventName: 'html-change', defaultValue: '<p>Start writing…</p>', initialControlledValue: undefined });
/**
* 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.
*/
@property({ type: Boolean, reflect: true }) editable: boolean = true;
/**
* 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.
*/
@property({ type: String, reflect: true }) placeholder: string = '';
/**
* Whether to place the caret in the document on mount (TipTap's `autofocus` option).
*/
@property({ type: Boolean, reflect: true }) autofocus: boolean = false;
/**
* A CSS class applied to the contenteditable element (`editorProps.attributes.class`).
*/
@property({ type: String, reflect: true }) editorClass: string = '';
/**
* The accessible name (`aria-label`) applied to the contenteditable element.
*/
@property({ type: String, reflect: true }) ariaLabel: string = 'Rich text editor';
/**
* ProseMirror `editorProps` passthrough — `handleKeyDown`, `handlePaste`, a custom `attributes`, etc. Spread **last** so consumer `editorProps` win the wrapper's attribute defaults.
*/
@property({ type: Object }) editorProps: any = {};
/**
* Extra TipTap extensions composed onto `StarterKit` — the consumer-extensibility passthrough (Link, Image, Mention, custom nodes/marks, …). Composed **last** so consumer extensions win for the same node or mark.
*/
@property({ type: Array }) extensions: any[] = [];
private _active = signal({
bold: false,
italic: false,
h1: false,
h2: false,
bulletList: false
});
@query('[data-rozie-ref="toolbarEl"]') private _refToolbarEl!: HTMLElement;
@query('[data-rozie-ref="editorEl"]') private _refEditorEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieFirstUpdateDone = false;
private _portalContainers = new Set<HTMLElement>();
@state() private _hasSlotToolbar = false;
@queryAssignedElements({ slot: 'toolbar', flatten: true }) private _slotToolbarElements!: Element[];
@property({ attribute: false }) toolbar?: (scope: { editor: unknown }) => unknown;
@state() private _hasSlotBubbleMenu = false;
@queryAssignedElements({ slot: 'bubbleMenu', flatten: true }) private _slotBubbleMenuElements!: Element[];
@property({ attribute: false }) bubbleMenu?: (scope: { editor: unknown }) => unknown;
@state() private _hasSlotFloatingMenu = false;
@queryAssignedElements({ slot: 'floatingMenu', flatten: true }) private _slotFloatingMenuElements!: Element[];
@property({ attribute: false }) floatingMenu?: (scope: { editor: unknown }) => unknown;
@state() private _hasSlotNodeView = false;
@queryAssignedElements({ slot: 'nodeView', flatten: true }) private _slotNodeViewElements!: Element[];
@property({ attribute: false }) nodeView?: (scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }) => unknown;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot[name="toolbar"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotToolbar = this._slotToolbarElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="bubbleMenu"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotBubbleMenu = this._slotBubbleMenuElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="floatingMenu"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotFloatingMenu = this._slotFloatingMenuElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="nodeView"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotNodeView = this._slotNodeViewElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotToolbar = Array.from(this.children).some((el) => el.getAttribute('slot') === 'toolbar');
this._hasSlotBubbleMenu = Array.from(this.children).some((el) => el.getAttribute('slot') === 'bubbleMenu');
this._hasSlotFloatingMenu = Array.from(this.children).some((el) => el.getAttribute('slot') === 'floatingMenu');
this._hasSlotNodeView = Array.from(this.children).some((el) => el.getAttribute('slot') === 'nodeView');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
adoptDocumentStyles(this);
this._armListeners();
interface ReactivePortalHandle {
update(scope: unknown): void;
dispose(): void;
}
const portals = {
toolbar: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const tpl = this.toolbar;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-toolbar', '2aeee876');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
bubbleMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const tpl = this.bubbleMenu;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-bubbleMenu', '2aeee876');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
floatingMenu: (container: HTMLElement, scope: { editor: unknown }): (() => void) => {
const tpl = this.floatingMenu;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-floatingMenu', '2aeee876');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
nodeView: (container: HTMLElement, scope: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): ReactivePortalHandle => {
const tpl = this.nodeView;
if (typeof tpl !== 'function') return { update() {}, dispose() {} };
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-nodeView', '2aeee876');
const renderScope = (s: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): void => {
render(tpl(s), container);
};
renderScope(scope);
this._portalContainers.add(container);
return {
update: (s: { node: unknown; selected: unknown; updateAttributes: unknown; getPos: unknown; editor: unknown; contentDOM: unknown }): void => renderScope(s),
dispose: (): void => {
render(nothing, container);
this._portalContainers.delete(container);
},
};
},
};
this._disconnectCleanups.push((() => {
this.toolbarDispose?.();
this.toolbarDispose = null;
this.bubbleMenuDispose?.();
this.bubbleMenuDispose = null;
this.floatingMenuDispose?.();
this.floatingMenuDispose = null;
this.editor?.destroy();
}));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.html)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!this.editor) return;
if (v === this.lastHtml) return;
this.lastHtml = v;
this.editor.commands.setContent(v, {
emitUpdate: false
});
this.refreshActive();
})(__watchVal); }); }));
this.lastHtml = this.html;
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
// Register the reactive node-view nodes ONLY when the consumer fills the
// `nodeView` slot — an unfilled slot adds no custom nodes (zero overhead, no
// unused $portals.nodeView reference fired). $portals.nodeView is captured
// here inside the mount body and passed into the node factory, keeping the
// reference scoped to the mount lifecycle (the toolbar-slot discipline).
const nodeViewExtensions = this.nodeView !== undefined ? this.makeNodeViewExtensions(portals.nodeView) : [];
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
// Placeholder ghost-text (G3). Read $props.placeholder ONCE at construction
// (setup-once, like content/editable/autofocus — no reactivity required). The
// Placeholder extension (@tiptap/extensions, version-matched to StarterKit)
// adds class `is-editor-empty` + a `data-placeholder` attribute to the first
// empty node; the `::before` rule in the `:root { }` engine-DOM escape hatch
// (in the style block) paints the ghost text. Empty placeholder = no extension.
const placeholderExtensions = this.placeholder ? [Placeholder.configure({
placeholder: this.placeholder
})] : [];
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
// Selection-anchored menu extensions (G2). Built BEFORE `new Editor` because the
// Floating-UI menu extension needs its host `element` at construction time. Each
// menu's host element is created imperatively (the nodeView discipline — the
// engine owns positioning; the consumer fragment is portalled in AFTER mount).
// An unfilled slot adds NOTHING (zero overhead, no $portals reference fired).
//
// The host elements are created up front (when filled) so they're captured into
// the component-scope `bubbleMenuEl`/`floatingMenuEl` for the post-construction
// portal mount; the extension list is then assembled by conditional SPREAD (NOT
// `const x = []; x.push(…)`), which under the strict-typecheck'd bundled leaves
// infers `any[]` — a bare `const x = []` would infer `never[]` and reject
// `.push(Extension)` (the placeholderExtensions/nodeViewExtensions discipline).
if (this.bubbleMenu !== undefined) {
this.bubbleMenuEl = document.createElement('div');
this.bubbleMenuEl.className = 'rozie-tiptap-bubble-menu';
}
if (this.floatingMenu !== undefined) {
this.floatingMenuEl = document.createElement('div');
this.floatingMenuEl.className = 'rozie-tiptap-floating-menu';
}
const menuExtensions = [...(this.bubbleMenuEl ? [BubbleMenu.configure({
element: this.bubbleMenuEl
})] : []), ...(this.floatingMenuEl ? [FloatingMenu.configure({
element: this.floatingMenuEl
})] : [])];
this.editor = new Editor({
element: this._refEditorEl,
content: this.html,
editable: this.editable,
autofocus: this.autofocus,
// StarterKit first; the Placeholder ext next; the reactive node-view nodes
// next; consumer extensions LAST so they win (TipTap applies later-registered
// extensions over earlier ones for the same node/mark).
extensions: [StarterKit, ...placeholderExtensions, ...nodeViewExtensions, ...menuExtensions, ...this.extensions],
editorProps: {
attributes: {
'aria-label': this.ariaLabel,
...(this.editorClass ? {
class: this.editorClass
} : {}),
...(this.placeholder ? {
'data-placeholder': this.placeholder,
'aria-placeholder': this.placeholder
} : {})
},
// Consumer editorProps spread LAST — full ProseMirror editorProps control
// (handleKeyDown, handlePaste, a custom `attributes`, …) wins.
...this.editorProps
},
onUpdate: ({
editor
}: any) => {
const next = editor.getHTML();
this.lastHtml = next;
// Round-trip guard — see CodeMirror/Flatpickr for the same shape.
if (next !== this.html) this._htmlControllable.write(next);
this.dispatchEvent(new CustomEvent("update", {
detail: next,
bubbles: true,
composed: true
}));
},
onSelectionUpdate: () => {
this.refreshActive();
this.dispatchEvent(new CustomEvent("selectionUpdate", {
detail: undefined,
bubbles: true,
composed: true
}));
},
onFocus: () => this.dispatchEvent(new CustomEvent("focus", {
detail: undefined,
bubbles: true,
composed: true
})),
onBlur: () => this.dispatchEvent(new CustomEvent("blur", {
detail: undefined,
bubbles: true,
composed: true
}))
});
this.refreshActive();
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
// `toolbar` portal slot — when the consumer fills it, mount their toolbar
// fragment into the engine-adjacent host node, handing them the live editor
// (their buttons call editor.chain().focus()…run()). $portals.toolbar is
// referenced ONLY here inside $onMount (the per-target portal helper is scoped
// to the mount lifecycle — a top-level reference would fail the bundled-leaf
// strict typecheck, the FullCalendar/CodeMirror pattern). The host div is
// r-if-gated on $slots.toolbar so $refs.toolbarEl exists exactly when filled.
if (this.toolbar !== undefined && this._refToolbarEl) {
this.toolbarDispose = portals.toolbar(this._refToolbarEl, {
editor: this.editor
});
}
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
// `bubbleMenu` / `floatingMenu` portal slots — mount the consumer's menu
// fragment into the engine-owned (imperatively-created) host element handed to
// the Floating-UI menu extension, with the live editor in scope (their buttons
// call editor.chain().focus()…run()). Like toolbar/nodeView, $portals.bubbleMenu
// / $portals.floatingMenu are referenced ONLY inside $onMount (the bundled-leaf
// strict-typecheck discipline). The element is created above only when the slot
// is filled, so each portal fires exactly when its slot exists.
if (this.bubbleMenuEl) {
this.bubbleMenuDispose = portals.bubbleMenu(this.bubbleMenuEl, {
editor: this.editor
});
}
if (this.floatingMenuEl) {
this.floatingMenuDispose = portals.floatingMenu(this.floatingMenuEl, {
editor: this.editor
});
}
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('editable'))) { const __watchVal = (() => this.editable)(); ((v: any) => this.editor?.setEditable(v, false))(__watchVal); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const container of this._portalContainers) render(nothing, container);
this._portalContainers.clear();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'html') this._htmlControllable.notifyAttributeChange(value as unknown as string);
}
render() {
return html`
<div class="${Object.entries({ "rozie-tiptap": true, 'is-readonly': !this.editable }).filter(([, v]) => v).map(([k]) => k).join(' ')}" data-rozie-s-2aeee876>
${this.editable && !(this.toolbar !== undefined) ? html`<div class="rozie-tiptap-toolbar" data-rozie-s-2aeee876>
<button class="${Object.entries({ active: this._active.value.bold }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Bold" @click=${this.toggleBold} data-rozie-s-2aeee876><strong data-rozie-s-2aeee876>B</strong></button>
<button class="${Object.entries({ active: this._active.value.italic }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Italic" @click=${this.toggleItalic} data-rozie-s-2aeee876><em data-rozie-s-2aeee876>I</em></button>
<span class="sep" data-rozie-s-2aeee876></span>
<button class="${Object.entries({ active: this._active.value.h1 }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Heading 1" @click=${($event: Event) => { this.toggleHeading(1); }} data-rozie-s-2aeee876>H1</button>
<button class="${Object.entries({ active: this._active.value.h2 }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Heading 2" @click=${($event: Event) => { this.toggleHeading(2); }} data-rozie-s-2aeee876>H2</button>
<span class="sep" data-rozie-s-2aeee876></span>
<button class="${Object.entries({ active: this._active.value.bulletList }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" aria-label="Bullet list" @click=${this.toggleBulletList} data-rozie-s-2aeee876>• List</button>
</div>` : nothing}${this.editable && this.toolbar !== undefined ? html`<div class="rozie-tiptap-toolbar rozie-tiptap-toolbar--slot" data-rozie-ref="toolbarEl" data-rozie-s-2aeee876></div>` : nothing}<div class="rozie-tiptap-content" data-placeholder=${this.placeholder} data-rozie-ref="editorEl" data-rozie-s-2aeee876></div>
</div>
<slot name="toolbar"></slot>
<slot name="bubbleMenu"></slot>
<slot name="floatingMenu"></slot>
<slot name="nodeView"></slot>
`;
}
editor: any = null;
lastHtml: any = null;
toolbarDispose: any = null;
bubbleMenuEl: any = null;
bubbleMenuDispose: any = null;
floatingMenuEl: any = null;
floatingMenuDispose: any = null;
refreshActive = () => {
if (!this.editor) return;
this._active.value = {
bold: this.editor.isActive('bold'),
italic: this.editor.isActive('italic'),
h1: this.editor.isActive('heading', {
level: 1
}),
h2: this.editor.isActive('heading', {
level: 2
}),
bulletList: this.editor.isActive('bulletList')
};
};
makeNodeView = (nv: any, editable: any) => (props: any) => {
const {
node,
getPos,
editor: ed
} = props;
// engine-owned outer host the consumer fragment mounts into.
const dom = document.createElement(editable ? 'div' : 'span');
dom.className = editable ? 'rozie-tiptap-nodeview rozie-tiptap-nodeview--block' : 'rozie-tiptap-nodeview rozie-tiptap-nodeview--inline';
// EDITABLE nodes own a ProseMirror-managed contentDOM; the bridge grafts it
// into the consumer fragment's [data-rozie-hole]. ATOM nodes have none.
const contentDOM = editable ? document.createElement(dom.tagName === 'DIV' ? 'div' : 'span') : null;
if (contentDOM) contentDOM.className = 'rozie-tiptap-nodeview-content';
const updateAttributes = (attrs: any) => {
if (typeof getPos !== 'function') return;
const pos = getPos();
if (pos == null) return;
ed.view.dispatch(ed.view.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...attrs
}));
};
const buildScope = (n: any, selected: any) => ({
node: n,
selected,
updateAttributes,
getPos,
editor: ed,
...(contentDOM ? {
contentDOM
} : {})
});
// Reactive handle — { update, dispose }. The fragment mounts ONCE; every
// engine transaction re-invokes handle.update(scope) re-rendering IN PLACE.
const handle = nv(dom, buildScope(node, false));
// contentDOM graft bridge (Spike 008 / REQ-23). For an EDITABLE node the
// consumer fragment renders chrome WRAPPING a `[data-rozie-hole]` placeholder;
// ProseMirror manages `contentDOM` and renders the node's editable children
// INTO it, so `contentDOM` must live inside the visible hole. The fragment is
// rendered into `dom` by the per-target reactive portal — synchronously on
// React/Solid/Lit (native-ref timing) but post-mount/async on Vue/Svelte/
// Angular (REQ-23). A query-after-render graft (retried across a microtask +
// a RAF) covers BOTH timing classes uniformly from the engine side: as soon as
// the hole exists, contentDOM is grafted in. ProseMirror then owns that subtree
// and the framework never reconciles it away (the hole carries no child binding).
const graftContentDOM = (attempt: any) => {
if (!contentDOM) return;
const hole = dom.querySelector('[data-rozie-hole]');
if (hole) {
if (contentDOM.parentNode !== hole) hole.appendChild(contentDOM);
return;
}
if (attempt < 5) {
if (attempt === 0) Promise.resolve().then(() => graftContentDOM(attempt + 1));else requestAnimationFrame(() => graftContentDOM(attempt + 1));
}
};
graftContentDOM(0);
// After a reactive re-render (chrome update), re-graft so a fragment that
// recreated its `[data-rozie-hole]` element does NOT leave contentDOM detached
// (REQ-24 — the editable subtree survives every chrome update).
const updateInPlace = (n: any, selected: any) => {
handle.update(buildScope(n, selected));
if (contentDOM) graftContentDOM(0);
};
return {
dom,
...(contentDOM ? {
contentDOM
} : {}),
// attr / content change for THIS node → re-render the fragment in place,
// keep the view (return true). The new node identity is forwarded so the
// fragment reads fresh node.attrs (REQ-26).
update(nextNode: any) {
if (nextNode.type !== node.type) return false;
updateInPlace(nextNode, false);
return true;
},
// NodeSelection enters/leaves the node → toggle `selected` in scope so the
// chip's selected styling is pure engine-driven reactive `update`.
selectNode() {
updateInPlace(node, true);
},
deselectNode() {
updateInPlace(node, false);
},
destroy() {
handle.dispose();
}
};
};
makeNodeViewExtensions = (nv: any) => {
// (1) NON-EDITABLE inline atom @mention chip (Spike 009 / REQ-26).
const Mention = Node.create({
name: 'rozieMention',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes: () => ({
id: {
default: null
},
label: {
default: ''
}
}),
parseHTML: () => [{
tag: 'span[data-rozie-mention]'
}],
// ATOM nodes are leaf nodes — their renderHTML must NOT include a `0` content
// hole (ProseMirror's DOMSerializer throws "Content hole not allowed in a leaf
// node spec"). The chip's visible content is supplied by the node view; the
// serialized form is just the marker span carrying the attrs.
renderHTML: ({
HTMLAttributes
}: any) => ['span', {
'data-rozie-mention': '',
...HTMLAttributes
}],
addNodeView: () => this.makeNodeView(nv, false)
});
// (2) EDITABLE block callout with a contentDOM hole (Spike 008 / REQ-23).
const Callout = Node.create({
name: 'rozieCallout',
group: 'block',
content: 'inline*',
defining: true,
addAttributes: () => ({
tone: {
default: 'info'
}
}),
parseHTML: () => [{
tag: 'div[data-rozie-callout]'
}],
renderHTML: ({
HTMLAttributes
}: any) => ['div', {
'data-rozie-callout': '',
...HTMLAttributes
}, 0],
addNodeView: () => this.makeNodeView(nv, true)
});
return [Mention, Callout];
};
getEditor() {
return this.editor;
}
focusEditor() {
this.editor?.commands.focus();
}
blurEditor() {
this.editor?.commands.blur();
}
getHTML() {
return this.editor ? this.editor.getHTML() : '';
}
getJSON() {
return this.editor ? this.editor.getJSON() : null;
}
getText() {
return this.editor ? this.editor.getText() : '';
}
setContent(next: any) {
if (!this.editor) return;
const v = next ?? '';
if (v === this.lastHtml) return;
this.lastHtml = v;
this.editor.commands.setContent(v, {
emitUpdate: false
});
this._htmlControllable.write(v);
this.refreshActive();
}
clearContent() {
if (!this.editor) return;
this.editor.commands.clearContent();
this.lastHtml = this.editor.getHTML();
this._htmlControllable.write(this.lastHtml);
this.refreshActive();
}
toggleBold() {
this.editor?.chain().focus().toggleBold().run();
this.refreshActive();
}
toggleItalic() {
this.editor?.chain().focus().toggleItalic().run();
this.refreshActive();
}
toggleHeading(level: any) {
this.editor?.chain().focus().toggleHeading({
level: level ?? 1
}).run();
this.refreshActive();
}
toggleBulletList() {
this.editor?.chain().focus().toggleBulletList().run();
this.refreshActive();
}
undo() {
this.editor?.chain().focus().undo().run();
this.refreshActive();
}
redo() {
this.editor?.chain().focus().redo().run();
this.refreshActive();
}
chain() {
return this.editor ? this.editor.chain().focus() : null;
}
isActive(name: any, attrs: any) {
return this.editor ? this.editor.isActive(name, attrs) : false;
}
can() {
return this.editor ? this.editor.can() : null;
}
isEmpty() {
return this.editor ? this.editor.isEmpty : true;
}
get html(): string { return this._htmlControllable.read(); }
set html(v: string) { this._htmlControllable.notifyPropertyWrite(v); }
}
injectGlobalStyles('rozie-tip-tap-global', `
.rozie-tiptap-content .is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: rgba(0, 0, 0, 0.4);
float: left;
height: 0;
pointer-events: none;
}
`);Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same events, same 14-verb imperative handle, same portal slots, all from the one source above.
See also
- TipTap — showcase & API — install, quick starts for all six frameworks, the events, the imperative handle, and the toolbar / bubble-menu / floating-menu / node-view slots.
- TipTap libraries comparison — how
@rozie-ui/tiptapstacks up against the per-framework wrappers.