Last Updated: 2026-05-06 Status: Active
Technical reference for the TipTap-based blog editor in apps/seller-dashboard/src/modules/blog/editor/.
NOTE
For UI/UX spec (pages, toolbar layout, keyboard shortcuts reference) see frontend-spec.md.
1. Overview
The editor is built on TipTap v2 (a ProseMirror wrapper). The main files are:
| File | Role |
|---|---|
editor/tiptap.extensions.tsx |
Extension registry — all 22+ extensions, their configuration and customizations |
editor/BlogEditorCore.tsx |
TipTap <Editor> component wrapper — autosave, dirty state, unsaved changes guard |
editor/BlogToolbar.tsx |
Formatting toolbar (Bold, Italic, lists, align, etc.) |
editor/EditorBubbleMenu.tsx |
Inline popup on text selection (formatting marks) |
editor/EditorFloatingMenu.tsx |
Action menu on empty lines |
editor/slash-command/SlashCommand.tsx |
/ command palette extension + item definitions |
editor/nodes/ |
Custom React node views (Image, CodeBlock, Callout, Toggle, Carousel) |
editor/extensions/block-actions.ts |
Keyboard shortcuts for block-level operations |
2. Extension Inventory
All extensions are registered in blogEditorExtensions (exported from tiptap.extensions.tsx).
Core (via StarterKit)
StarterKit is loaded with heading: false and codeBlock: false — these are replaced by custom versions.
| Extension | What it does |
|---|---|
Document |
Root document node |
Paragraph |
<p> blocks |
Text |
Inline text node |
BulletList |
<ul> (with keepMarks: true) |
OrderedList |
<ol> (with keepMarks: true) |
ListItem |
<li> |
Blockquote |
<blockquote> |
HorizontalRule |
<hr> |
HardBreak |
<br> |
Bold |
**text** / Ctrl+B |
Italic |
*text* / Ctrl+I |
Strike |
~~text~~ |
Code |
`code` (inline) |
History |
Undo/redo |
Dropcursor |
Visual cursor for drag and drop |
Gapcursor |
Cursor before/after non-editable nodes |
Replaced / Extended
| Extension | Replaces | Why |
|---|---|---|
CustomHeading |
StarterKit's Heading |
Adds Backspace → paragraph behavior; adds per-level Tailwind classes |
CodeBlockLowlight + CodeBlockNodeView |
StarterKit's CodeBlock |
Syntax highlighting + custom React node view (language selector + copy button) |
Additional Extensions
| Extension | Package | Config notes |
|---|---|---|
Link |
@tiptap/extension-link |
Adds title attr (tooltip on hover); openOnClick: false |
Image + ImageNodeView |
@tiptap/extension-image |
Custom React node view for resize, alignment, captions |
Placeholder |
@tiptap/extension-placeholder |
Context-aware: different hint for heading, codeBlock, paragraph |
Table |
@tiptap/extension-table |
resizable: true, cellMinWidth: 50, lastColumnResizable: true |
TableRow |
@tiptap/extension-table-row |
|
TableHeader |
@tiptap/extension-table-header |
class: "relative" (no inline style — preserves theme) |
TableCell |
@tiptap/extension-table-cell |
class: "relative" |
TaskList |
@tiptap/extension-task-list |
|
TaskItem |
@tiptap/extension-task-item |
nested: true |
Youtube |
@tiptap/extension-youtube |
nocookie: true (always uses youtube-nocookie.com), controls: false |
CharacterCount |
@tiptap/extension-character-count |
Powers the word/char count in EditorStatusBar.tsx |
Typography |
@tiptap/extension-typography |
Smart quotes, em dash auto-conversion, ellipsis, etc. |
TextAlign |
@tiptap/extension-text-align |
Types: ['heading', 'paragraph'] |
Underline |
@tiptap/extension-underline |
Ctrl+U |
Highlight |
@tiptap/extension-highlight |
multicolor: false; yellow bg; Ctrl+Shift+H |
Callout |
editor/nodes/CalloutNode.tsx |
Custom node — info/warning/tip/danger blocks |
Toggle |
editor/nodes/ToggleNode.tsx |
Custom node — collapsible <details> block |
Carousel |
editor/nodes/CarouselNode.tsx |
Custom node — image carousel |
BlockActions |
editor/extensions/block-actions.ts |
Keyboard shortcuts for block-level ops (duplicate, move) |
SlashCommand |
editor/slash-command/SlashCommand.tsx |
/ palette via TipTap Suggestion + Tippy.js |
Why BubbleMenu and FloatingMenu Are NOT in Extensions
// from tiptap.extensions.tsx:
// BubbleMenu and FloatingMenu are NOT included as extensions here
// because we use the <BubbleMenu> and <FloatingMenu> React components
// from @tiptap/react/menus, which register their own plugins.
// Including both creates duplicate plugins that break text selection.They are rendered as React components in BlogEditorCore.tsx, which handles plugin registration internally.
3. Custom Node Views
ImageNodeView (editor/nodes/ImageNodeView.tsx)
Wraps TipTap's Image extension with a React component that adds:
- Size presets: S / M / L / Full
- Alignment: left / center / right (via
textAlignattr) - Caption input
- Hover overlay with edit controls
CodeBlockNodeView (editor/nodes/CodeBlockNodeView.tsx)
Wraps CodeBlockLowlight with:
- Language selector dropdown (JavaScript, TypeScript, CSS, HTML, and 50+ via
lowlight/common) - Copy-to-clipboard button
- Syntax highlighting via
lowlight+hast-util-to-html
CalloutNode (editor/nodes/CalloutNode.tsx)
A custom ProseMirror node with four types: info | warning | tip | danger.
- Renders as a bordered, icon-annotated block using Lucide icons
- Type switcher is in the left gutter (icon buttons)
- Each type maps to a Tailwind color scheme
- Wrapped in
EditorErrorBoundary— a crash in one callout doesn't kill the whole editor - Stored as:
{ type: "callout", attrs: { type: "info" }, content: [...] } - Rendered by
tiptap-renderer.tsas<div class="callout callout-info">
ToggleNode (editor/nodes/ToggleNode.tsx)
A collapsible <details>-style block. Click the summary to expand/collapse.
- Stored as:
{ type: "toggle", content: [{ type: "toggleTitle", ... }, { type: "toggleContent", ... }] } - Rendered by
tiptap-renderer.tsas<details open><summary>...</summary>...</details>
CarouselNode (editor/nodes/CarouselNode.tsx)
An image carousel node.
⚠️ Known renderer gap: tiptap-renderer.ts does not emit the <div data-type="carousel"> wrapper that the SDK's carousel hydration runtime expects. The carousel renders as a flat sequence of <figure> elements on the public site. Editor → public parity is broken for this node type.
4. Slash Command System
The / palette is powered by SlashCommand.tsx (a TipTap Extension using @tiptap/suggestion + Tippy.js for positioning).
Architecture
User types "/"
→ TipTap Suggestion plugin intercepts
→ getSuggestionItems({ query }) called on every keystroke
→ Filtered items passed to renderItems()
→ renderItems() mounts <CommandList> via ReactRenderer + Tippy popup
→ User selects → item.command({ editor, range }) fires
→ Range is deleted, node/mark is insertedCommand Categories and Items
| Category | Commands |
|---|---|
| Basic | Text (paragraph), Quote (blockquote), Divider (hr), Code (codeBlock) |
| Typography | Heading 1–6 |
| Lists | Bullet List, Ordered List, Task List |
| Media | Image (opens MediaDrawer), YouTube (opens YouTubeDialog), Table, Carousel |
| Advanced | Callout Info, Callout Warning, Callout Tip, Callout Danger, Toggle |
| References | Link, Table of Contents |
Each item has: title, description, searchTerms[], icon (Lucide), category, shortcut (optional), command.
Adding a New Slash Command
- Add an entry to the array returned by
getSuggestionItems()inSlashCommand.tsx:
{
title: "My Block",
description: "One-line description shown in the palette.",
searchTerms: ["myblock", "mb", "alias"], // what the user can type to find it
icon: SomeIcon, // Lucide icon
category: "Advanced", // shown as group header
shortcut: "",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setMyBlock().run()
},
}- If it requires a custom node, create it in
editor/nodes/and register it intiptap.extensions.tsx. - If the node needs to be rendered on the public site, add a handler to
apps/blog-service/src/utils/tiptap-renderer.ts.
5. Custom Behaviors
Backspace at Start of Heading → Convert to Paragraph
Standard ProseMirror behavior: Backspace at the start of any block merges it with the previous block. For headings this turns an H1 into whatever was before it — unexpected and destructive.
CustomHeading overrides addKeyboardShortcuts() to detect this case and call setNode("paragraph") instead:
Backspace: ({ editor }) => {
const { $from, empty } = editor.state.selection
if (!empty) return false
if ($from.parentOffset !== 0) return false
if ($from.parent.type.name !== this.name) return false
return editor.commands.setNode("paragraph")
}The parent's keyboard shortcuts are spread first (...this.parent?.()) to preserve Ctrl+Alt+1..6 heading shortcuts.
YouTube Always Uses Privacy-Enhanced Mode
The Youtube extension is configured with nocookie: true:
Youtube.configure({ nocookie: true })This forces all embeds to youtube-nocookie.com/embed/... regardless of what URL the user pastes. Even if YouTubeDialog.parseYouTubeId is bypassed, the host is always the tracking-free domain.
TableHeader and TableCell Use Classes, Not Inline Styles
Inline styles have higher specificity than class-based CSS and would silently override editor.css's theme-aware table styling. Both TableHeader and TableCell use HTMLAttributes: { class: "relative" } with no style attribute.
6. Autosave & Dirty State
Autosave is implemented in BlogEditorCore.tsx:
- Trigger:
onUpdatefires on every edit → debounced 30 seconds → callsonSave(editor.getJSON(), title) - On blur: immediate save (no debounce) — user switching tabs or clicking away triggers a save
- Dirty state: any edit sets an internal dirty flag;
beforeunloadfires if dirty on page close - Save indicator: shows "Saving…" while request is in flight, "Saved" on success, "Unsaved changes" on failure
7. Adding a New Extension
-
Install the package (if external):
cd apps/seller-dashboard && pnpm add @tiptap/extension-my-extension -
Import and add to
blogEditorExtensionsintiptap.extensions.tsx:import MyExtension from '@tiptap/extension-my-extension' // ... export const blogEditorExtensions = [ // ...existing... MyExtension.configure({ /* options */ }), ] -
If it's a block node, add a slash command (see "Adding a New Slash Command" above)
-
If the node appears in public content, add a renderer handler in
apps/blog-service/src/utils/tiptap-renderer.ts:case "myBlock": return `<div class="my-block">${renderChildren(node)}</div>` -
Add tests if the node has non-trivial rendering logic.
8. Renderer Alignment
Every block node the editor can create must have a corresponding case in tiptap-renderer.ts. If a case is missing, that content type is silently dropped on the public site.
| Node | Editor | Renderer | Notes |
|---|---|---|---|
| paragraph | ✅ | ✅ | |
| heading | ✅ | ✅ | H1–H6 |
| bulletList | ✅ | ✅ | |
| orderedList | ✅ | ✅ | |
| listItem | ✅ | ✅ | |
| blockquote | ✅ | ✅ | |
| codeBlock | ✅ | ✅ | class="language-{lang}" |
| horizontalRule | ✅ | ✅ | |
| hardBreak | ✅ | ✅ | |
| image | ✅ | ✅ | <figure>/<figcaption> when caption set |
| table / tableRow / tableHeader / tableCell | ✅ | ✅ | colspan/rowspan supported |
| taskList / taskItem | ✅ | ✅ | <input type="checkbox" disabled> |
| youtube | ✅ | ✅ | Normalized to youtube-nocookie.com/embed/ |
| callout | ✅ | ✅ | <div class="callout callout-{type}"> |
| toggle | ✅ | ✅ | <details open><summary> |
| carousel | ✅ | ❌ | BUG — wrapper not emitted, SDK hydration fails |
| bold / italic / strike / underline / code | ✅ | ✅ | inline marks |
| highlight | ✅ | ✅ | <mark> |
| link | ✅ | ✅ | URL-sanitized (no javascript:, data:) |
| textAlign | ✅ | ✅ | style="text-align: ..." on headings and paragraphs |