Blob: docs/editor-v2-tiptap.md
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_snapshotsdata 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
EditorPaneAPI stable sopage-view.tsxandshared-page-view.tsxdo not need broad changes. - Keep the current collaborative split:
- body content in
YJS_DOCUMENT_STOREasY.XmlFragment - title in
YJS_PAGE_TITLEasY.Text
- body content in
- Keep
Y.Doc,IndexeddbPersistence,y-partyserver/provider, and theonProvidercallback 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-componentsas 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/providerauth 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.tsxsrc/client/components/editor/controllers/formatting-toolbar.tsxsrc/client/components/editor/controllers/link-toolbar.tsxsrc/client/components/editor/controllers/suggestion-menu.tsxsrc/client/components/editor/controllers/placement.tssrc/client/components/page-view.tsxsrc/client/components/shared-page-view.tsxsrc/client/lib/uploads.tssrc/client/hooks/use-sync.tssrc/shared/constants.tssrc/shared/doc-messages.tssrc/client/styles/app.csssrc/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.DocandYJS_DOCUMENT_STOREfragment - verify
@tiptap/extension-collaboration-caretworks withy-partyserver/providerawareness - 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
shareTokenflow
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
EditorPanecomponent name and prop contract unchanged - keep title management in the existing textarea flow
- keep
Y.Docbootstrap, title observer, title seeding, provider setup, and teardown logic - keep
IndexeddbPersistencewithbland:doc:${pageId} - keep
onProviderbehavior unchanged so sync status and presence UI continue to work - configure Tiptap collaboration against the existing
YJS_DOCUMENT_STOREfragment - support
readOnlywitheditable: 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 onProviderstill 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
shouldShowandisChangeOrigin(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";fromsrc/client/styles/app.css - replace
.bn-*styling with.tiptapand related editor styles - wipe D1
doc_snapshotsand 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-reactfits 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.tsxas the public entry point - rewrite
editor-pane.tsxin place unless a thin wrapper around a new Tiptap implementation is clearly cleaner - keep
controllers/placement.tsunchanged - 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.tssrc/client/components/editor/controllers/slash-items.tssrc/client/components/editor/views/code-block-view.tsxsrc/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()oruseTiptapState()instead of prop drilling where that improves clarity
Fallback:
- use
useEditorwith directEditorContentwiring if that keepsEditorPanematerially 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/reactand the existing placement helpers
Fallback:
- if rebuilding a controller turns out to be materially more complex than expected, vendor only the minimum
tiptap-ui-componentspieces 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_TITLEasY.Text - keep body in
YJS_DOCUMENT_STOREasY.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
blocknode withkind: 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/:idstyle path insrc - share-token-aware image resolution remains a render concern, not stored content
intrinsicWidthandintrinsicHeightare 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
assetImagenode 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
UniqueIDa 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
@sourcewiring fromsrc/client/styles/app.css .bn-*selectors and BlockNote-specific theme variables fromsrc/client/styles/custom.css
Add:
.tiptapcontent 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 typechecknpm 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
EditorPaneprop 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-componentsinstead of custom implementation
Recommended Execution Order
- Complete Phase 0 and resolve any provider or awareness mismatch.
- Land the Phase 1 core swap without deleting BlockNote yet.
- Restore Phase 2 controller parity and shared-media behavior.
- Finish Phase 3 code block parity and cleanup.
- Remove BlockNote only after the Tiptap implementation passes validation.
This keeps the migration risk-ordered without redefining the live editor surface as optional.