Skip to content
File

Blob: docs/editor-v2-tiptap.md

Markdown527 lines

Editor V2 Tiptap Plan

Purpose

This document is a single execution plan for replacing the current BlockNote-based editor in bland with Tiptap.

This merged plan assumes:

  • License pressure is the primary reason for the replacement.
  • Data migration is out of scope.
  • D1 doc_snapshots data and local IndexedDB editor state can be wiped.
  • Churn is acceptable for this work.
  • The migration should preserve the live product contract, not just the current visual style.

Decision Summary

  • Keep the public EditorPane API stable so page-view.tsx and shared-page-view.tsx do not need broad changes.
  • Keep the current collaborative split:
    • body content in YJS_DOCUMENT_STORE as Y.XmlFragment
    • title in YJS_PAGE_TITLE as Y.Text
  • Keep Y.Doc, IndexeddbPersistence, y-partyserver/provider, and the onProvider callback contract.
  • Make a Phase 0 compatibility spike mandatory before the broader rewrite.
  • Treat slash menu, formatting toolbar, link toolbar, upload wiring, and shared-media resolution as cutover-scope parity, not optional polish.
  • Keep code blocks in cutover scope, including the existing allowed language set and syntax highlighting. Prefer a Shiki-backed implementation, but allow a fallback if the preferred package proves unstable.
  • Treat drag handle behavior as confirm-first and non-blocking. It can land after cutover if needed.
  • Treat tiptap-ui-components as a fallback accelerator, not the default path.
  • Remove BlockNote-specific dependencies, overrides, and CSS once the Tiptap implementation passes parity checks.

Current Editor Surface To Preserve

The replacement must preserve the behaviors already wired into the live tree:

  • collaborative body content stored in YJS_DOCUMENT_STORE
  • collaborative title stored separately in YJS_PAGE_TITLE
  • IndexedDB persistence keyed by page id
  • y-partyserver/provider auth and share-token connection params
  • provider awareness for presence and sync UI outside the editor
  • read-only shared-page mode
  • upload flow through src/client/lib/uploads.ts
  • share-token-aware media rendering for shared pages
  • provider custom messages used for page metadata refresh
  • code blocks with the current allowed language list

Primary live integration points:

  • src/client/components/editor/editor-pane.tsx
  • src/client/components/editor/controllers/formatting-toolbar.tsx
  • src/client/components/editor/controllers/link-toolbar.tsx
  • src/client/components/editor/controllers/suggestion-menu.tsx
  • src/client/components/editor/controllers/placement.ts
  • src/client/components/page-view.tsx
  • src/client/components/shared-page-view.tsx
  • src/client/lib/uploads.ts
  • src/client/hooks/use-sync.ts
  • src/shared/constants.ts
  • src/shared/doc-messages.ts
  • src/client/styles/app.css
  • src/client/styles/custom.css

Explicit Non-Goals

  • preserving BlockNote document compatibility
  • preserving BlockNote-specific schema or JSON output
  • matching BlockNote DOM structure or CSS class names
  • reworking the worker-side DocSync model
  • changing upload routes, auth flow, or shared types unless the migration requires it

Phase Plan

Phase 0: Compatibility Spike

Goal: verify the collaboration stack and provider contract before broader UI work.

Tasks:

  • install the minimum Tiptap packages needed for a spike
  • mount a temporary Tiptap editor body in place of BlockNote on one page
  • bind Tiptap collaboration to the existing Y.Doc and YJS_DOCUMENT_STORE fragment
  • verify @tiptap/extension-collaboration-caret works with y-partyserver/provider awareness
  • verify the same provider awareness still drives the existing avatar stack
  • verify remote cursor rendering and awareness updates use a compatible user shape
  • verify page switch teardown does not leak providers or awareness state
  • verify read-only shared-page rendering still works with the same shareToken flow

Exit criteria:

  • multi-tab editing syncs through the existing provider
  • remote cursors render correctly
  • existing presence UI still works from the same provider.awareness
  • no duplicate awareness clients appear after page switches
  • shared-page read-only render works without BlockNote

If Phase 0 fails, stop and resolve the provider or extension mismatch before proceeding.

Phase 1: Core Swap

Goal: replace the editor body while preserving the collaboration, title, and provider lifecycle contracts.

Tasks:

  • rewrite the BlockNote-backed body editor as a Tiptap-backed body editor
  • keep the exported EditorPane component name and prop contract unchanged
  • keep title management in the existing textarea flow
  • keep Y.Doc bootstrap, title observer, title seeding, provider setup, and teardown logic
  • keep IndexeddbPersistence with bland:doc:${pageId}
  • keep onProvider behavior unchanged so sync status and presence UI continue to work
  • configure Tiptap collaboration against the existing YJS_DOCUMENT_STORE fragment
  • support readOnly with editable: false
  • add basic content styling so the editor is readable before parity UI lands

Exit criteria:

  • authenticated page editing works with Tiptap
  • title sync still works through YJS_PAGE_TITLE
  • onProvider still drives existing sync and presence UI
  • shared read-only pages still render correctly
  • page remount and teardown behavior remains correct

This phase is not the cutover gate by itself. Do not remove BlockNote yet.

Phase 2: Controller Parity

Goal: restore the editor controls that are part of the current live editor surface.

Tasks:

  • rebuild the slash menu against Tiptap APIs
  • gate slash-menu visibility with shouldShow and isChangeOrigin(transaction) so it only opens for the local editor change in collaborative sessions
  • rebuild the floating formatting toolbar against Tiptap APIs
  • rebuild the link toolbar against Tiptap APIs
  • wire image upload through the existing uploadFile(workspaceId, file, pageId, shareToken) flow
  • support paste and drag/drop image upload
  • preserve share-token-aware media rendering for shared pages
  • keep read-only mode from exposing editing or upload actions
  • preserve the current floating placement behavior by reusing placement.ts

Exit criteria:

  • slash menu works with keyboard and mouse
  • formatting toolbar appears on selection and applies marks correctly
  • link toolbar supports edit, remove, and open behavior
  • image upload works in authenticated pages
  • uploaded media render correctly in shared pages
  • no auth-only media URLs leak into shared-page rendering

Phase 2 restores controller parity, but cutover is not complete until Phase 3 code block parity and cleanup also pass validation.

Phase 3: Extensions And Polish

Goal: finish the remaining editor-specific parity work, remove BlockNote, and clean up the tree.

Tasks:

  • implement code blocks with the current language parity
  • prefer a Shiki-backed path for highlighting
  • allow a maintained fallback if the preferred Shiki package proves incompatible
  • keep the current allowed language list centralized and explicit
  • remove BlockNote dependencies from package.json
  • delete bn-components.tsx
  • remove BlockNote-only controllers, imports, and CSS
  • remove @source "../../../node_modules/@blocknote/shadcn"; from src/client/styles/app.css
  • replace .bn-* styling with .tiptap and related editor styles
  • wipe D1 doc_snapshots and validate fresh-document behavior

Exit criteria:

  • BlockNote packages and imports are gone
  • the editor still passes collaboration, upload, and shared-page checks
  • code blocks remain editable and render with acceptable highlighting
  • typecheck and build pass

Drag Handle Policy

Drag handle behavior is not a cutover gate.

  • If @tiptap/extension-drag-handle-react fits cleanly, it can land during or after Phase 3.
  • If it introduces instability or extra churn, defer it to a follow-up.
  • Shipping without it is acceptable as long as the rest of editor parity is in place.

File Strategy

Prefer the smallest file churn that keeps ownership clear.

Recommended approach:

  • keep src/client/components/editor/editor-pane.tsx as the public entry point
  • rewrite editor-pane.tsx in place unless a thin wrapper around a new Tiptap implementation is clearly cleaner
  • keep controllers/placement.ts unchanged
  • rewrite the current controller files in place unless splitting a helper out materially improves clarity
  • add new files only when they clearly earn their keep

Likely file outcomes:

  • rewrite src/client/components/editor/editor-pane.tsx
  • rewrite src/client/components/editor/controllers/formatting-toolbar.tsx
  • rewrite src/client/components/editor/controllers/link-toolbar.tsx
  • rewrite src/client/components/editor/controllers/suggestion-menu.tsx
  • delete src/client/components/editor/bn-components.tsx
  • update src/client/styles/app.css
  • update src/client/styles/custom.css

Possible new files if needed:

  • src/client/components/editor/extensions/slash-menu.ts
  • src/client/components/editor/controllers/slash-items.ts
  • src/client/components/editor/views/code-block-view.tsx
  • src/client/components/editor/views/image-view.tsx

Do not create a larger tiptap/ subtree by default. Add structure only if the implementation actually needs it.

Dependency Strategy

Use the latest stable Tiptap 3 release and keep every @tiptap/* package on the same version.

Required Core

  • @tiptap/react
  • @tiptap/pm
  • @tiptap/y-tiptap
  • @tiptap/starter-kit
  • @tiptap/extension-collaboration
  • @tiptap/extension-collaboration-caret
  • @tiptap/extension-image
  • @tiptap/extension-placeholder

Required For Parity

  • @tiptap/extension-file-handler
  • @tiptap/suggestion
  • a code block extension path with acceptable highlighting

Likely Needed

  • @tiptap/extension-task-list
  • @tiptap/extension-task-item

Confirm-First

  • table support
  • drag handle support
  • tiptap-ui-components

Remove

  • @blocknote/core
  • @blocknote/react
  • @blocknote/shadcn
  • BlockNote-only transitive UI dependencies that are no longer referenced

Dependency note:

@radix-ui/react-popover is currently only referenced by the BlockNote override file. It is not a required keeper for the Tiptap migration. If the new editor UI does not use it directly, it should leave with BlockNote.

Implementation Notes

Tiptap Configuration Direction

Use a Tiptap configuration that mirrors the existing collaboration contract:

  • StarterKit.configure({ undoRedo: false, link: { openOnClick: false, autolink: true } })
  • Collaboration.configure({ fragment: ydoc.getXmlFragment(YJS_DOCUMENT_STORE) })
  • CollaborationCaret.configure({ provider, user })
  • Image.configure({ inline: false, allowBase64: false })
  • Placeholder.configure(...)

History must stay disabled when Yjs collaboration is enabled.

Link and Underline already come from StarterKit in Tiptap 3. Configure them through StarterKit.configure(...) instead of adding standalone extensions unless the implementation has a specific reason to split them out.

React Integration Direction

Preferred default:

  • use Tiptap's React Composable API for the new editor shell
  • create the editor instance with useEditor
  • render through <Tiptap instance={editor}>
  • let child UI read the editor through useTiptap() or useTiptapState() instead of prop drilling where that improves clarity

Fallback:

  • use useEditor with direct EditorContent wiring if that keeps EditorPane materially simpler in this codebase

The composable API is the preferred React pattern for this migration, but it is not a hard requirement.

Controller Strategy

Default path:

  • rebuild the current controllers against Tiptap APIs
  • keep using @floating-ui/react and the existing placement helpers

Fallback:

  • if rebuilding a controller turns out to be materially more complex than expected, vendor only the minimum tiptap-ui-components pieces needed to finish the migration

Do not default to vendoring a large Tiptap UI template.

Upload And Shared-Media Strategy

Keep the backend flow unchanged.

Client requirements:

  • call the existing uploadFile() helper
  • insert uploaded images into the Tiptap document
  • disable upload entry points in read-only mode
  • preserve share-token-aware media rendering

Preferred rendering strategy:

  • use a small custom Image NodeView if runtime share-token resolution is needed

Alternative:

  • normalize URLs at insertion time only if it proves clearly simpler and token behavior remains acceptable

Code Block Strategy

Code blocks stay in cutover scope.

Requirements:

  • keep the current allowed language list
  • preserve insertion and editing
  • provide acceptable syntax highlighting
  • keep the language selector behavior or an equivalent explicit language choice

Preferred path:

  • Shiki-backed highlighting

Acceptable fallback:

  • a maintained non-Shiki code block extension if it keeps language parity and avoids blocking the migration

Package choice is an implementation detail. Language parity is the actual requirement.

Document Schema Direction

The Tiptap extension set is the effective schema for the editor. For this migration, keep that schema narrow and semantic.

Storage model:

  • keep title in YJS_PAGE_TITLE as Y.Text
  • keep body in YJS_DOCUMENT_STORE as Y.XmlFragment
  • keep Yjs as the only collaborative source of truth for live document state
  • treat JSON and HTML as import/export formats, not the mergeable canonical store

Schema rules:

  • use a small set of typed nodes and marks
  • do not add a generic block node with kind: string
  • do not use data: Record<string, unknown> attr bags
  • do not persist runtime or UI state in the document
  • add new semantics as new nodes or marks, not opaque attrs on unrelated nodes

Body document shape:

doc: block+

paragraph
  attrs: { id?: string }
  content: inline*

heading
  attrs: { id?: string; level: 1 | 2 | 3 }
  content: inline*

blockquote
  attrs: { id?: string }
  content: block+

bulletList
  attrs: { id?: string }
  content: listItem+

orderedList
  attrs: { id?: string; start?: number }
  content: listItem+

listItem
  content: paragraph block*

taskList
  attrs: { id?: string }
  content: taskItem+

taskItem
  attrs: { checked: boolean }
  content: paragraph block*

horizontalRule
  attrs: { id?: string }
  atom: true
  group: block

codeBlock
  attrs: { id?: string; language: AllowedCodeLanguage | null }
  content: text*

image
  attrs: { id?: string; src: string; alt?: string | null; title?: string | null }
  atom: true
  group: block

text

hardBreak
  inline: true
  atom: true

marks:
  bold
  italic
  underline
  strike
  code
  link { href: string }

Schema decisions for this migration:

  • task lists are in cutover scope
  • code blocks persist only raw text plus language
  • links persist only href
  • uploaded images persist a stable /uploads/:id style path in src
  • share-token-aware image resolution remains a render concern, not stored content
  • intrinsicWidth and intrinsicHeight are deferred until the upload flow actually captures them

Image node decision:

  • use the standard Tiptap image node shape for the migration
  • do not introduce a custom assetImage node in the initial cutover
  • revisit a custom asset node only if we need additional asset semantics such as captions, richer metadata, reuse tracking, or server-driven node targeting

Code block rules:

  • keep the language allowlist centralized and explicit
  • enforce the allowlist in editor commands, picker UI, import/paste handling, and tests
  • do not persist highlighted token output, spans, or other derived render state

Stable block IDs:

  • IDs are useful for anchors, comments, analytics, and drag/drop references
  • do not make UniqueID a hard migration requirement
  • add it only when there is a concrete consumer for those IDs
  • if added, scope it to real block nodes and configure it for collaboration-safe transaction filtering

CSS Migration Notes

Remove:

  • BlockNote CSS import from the editor
  • BlockNote @source wiring from src/client/styles/app.css
  • .bn-* selectors and BlockNote-specific theme variables from src/client/styles/custom.css

Add:

  • .tiptap content styling
  • placeholder styling
  • toolbar and menu styling
  • collaboration cursor styling
  • code block styling
  • image and link styling

Keep:

  • existing typography, spacing, and dark theme conventions where they still fit the rest of bland

Validation Plan

Automated

  • npm run typecheck
  • npm run build

Manual

  • edit a page in authenticated mode
  • open the same page in a second tab and confirm collaboration
  • confirm remote cursors render
  • confirm remote presence avatars still show
  • confirm sync status still works
  • confirm title changes still sync
  • reload and confirm IndexedDB restoration works
  • switch between pages and confirm old providers are torn down
  • open a shared page and confirm read-only render
  • upload an image in authenticated mode
  • render that image in shared mode
  • verify slash menu, formatting toolbar, and link toolbar behavior
  • verify code blocks render, remain editable, and preserve language choice

Acceptance Checklist

  • EditorPane prop contract remains unchanged
  • title remains collaborative through YJS_PAGE_TITLE
  • body collaboration works through the existing DocSync transport
  • provider awareness still drives avatar and cursor presence
  • uploads still use existing worker endpoints
  • shared uploaded media still render
  • read-only shared pages still behave correctly
  • code blocks preserve acceptable language and highlighting parity
  • BlockNote packages and imports are removed
  • typecheck passes
  • build passes

Open Decisions

Decide early, but do not let these block Phase 0:

  • whether task lists are required for cutover
  • whether table support is required for cutover
  • which code block package is the preferred implementation
  • whether a custom Image NodeView is needed or simple URL normalization is sufficient
  • whether any controller should use vendored tiptap-ui-components instead of custom implementation

Recommended Execution Order

  1. Complete Phase 0 and resolve any provider or awareness mismatch.
  2. Land the Phase 1 core swap without deleting BlockNote yet.
  3. Restore Phase 2 controller parity and shared-media behavior.
  4. Finish Phase 3 code block parity and cleanup.
  5. Remove BlockNote only after the Tiptap implementation passes validation.

This keeps the migration risk-ordered without redefining the live editor surface as optional.