logicspike/docs

Blog Engine

Blog Editor — Architecture & Extension Guide

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 textAlign attr)
  • 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.ts as <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.ts as <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 inserted

Command 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

  1. Add an entry to the array returned by getSuggestionItems() in SlashCommand.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()
    },
}
  1. If it requires a custom node, create it in editor/nodes/ and register it in tiptap.extensions.tsx.
  2. 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: onUpdate fires on every edit → debounced 30 seconds → calls onSave(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; beforeunload fires 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

  1. Install the package (if external):

    cd apps/seller-dashboard && pnpm add @tiptap/extension-my-extension
  2. Import and add to blogEditorExtensions in tiptap.extensions.tsx:

    import MyExtension from '@tiptap/extension-my-extension'
    // ...
    export const blogEditorExtensions = [
        // ...existing...
        MyExtension.configure({ /* options */ }),
    ]
  3. If it's a block node, add a slash command (see "Adding a New Slash Command" above)

  4. 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>`
  5. 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
Blog Engine