Project Plan
Unified Editor Migration — Macro Plan

Context

Three planning docs (editor-big-plan.md, plan-editor-document-features.md, plan-editor-migration-steps.md) describe the same vision: use the editor to render all document content (read-only and editable), replacing BlocksContent entirely.

The document lifecycle machine (XState v5) is already built and wired into resource pages on branch unified-document-lifecycle-machine. This plan merges all three docs into one.

Key Decisions

    Extensions before swap: Core extensions must reach feature parity before replacing BlocksContent

    Single editor instance: Toggle isEditable rather than swapping components

    renderType + editable: Constructor takes { renderType: 'document' | 'embed' | 'comment', editable: boolean }. renderType is context metadata; plugin loading controlled by editable only (for now)

    Core 4 extensions + Supernumbers: Block Highlighting, Image Gallery, Hover Actions, Range Selection, Supernumbers — Collapsed Blocks deferred

    Editing blocked on renderer swap: In-place editing only after editor is the renderer

    Click-to-edit: Single click on text block or bottom empty area enters edit mode (not a button). Click-drag = text selection (shows range selection bubble), NOT edit trigger

    Draft auto-load: If user has edit access and a draft exists, show draft in edit mode automatically

    Account switching: Save draft + exit edit mode when switching to non-editor account; enter edit mode (or allow it) when switching to editor account

    Supernumbers: Top-right of block, shown in all modes when count > 0. Click emits event from editor, app decides behavior (desktop: open right panel focused on block)

Already Done (this branch)

    ✅ Document machine (document-machine.ts, 465 lines)

    ✅ React hooks/provider (use-document-machine.ts)

    ✅ Debug drawer + inspect tools

    ✅ Wired into resource-page-common.tsx with DocumentMachineProvider

    ✅ Desktop/web pages pass canEdit, existingDraftId

    ✅ Old draft page + draftMachine untouched and functional

Dependency Graph

Phase 1: renderType + editable + readOnly fixes │ ├──→ Phase 2a: Block Highlighting ext ──┐ ├──→ Phase 2b: Image Gallery ext ──┤ ├──→ Phase 2c: Block Hover Actions ext ──┤ (parallel, separate PRs) ├──→ Phase 2d: Range Selection ext ──┤ └──→ Phase 2e: Supernumbers ext ──┘ │ ▼ Phase 3: Renderer Swap (BlocksContent → editor readOnly) │ ▼ Phase 4: In-place Editing (click-to-edit + machine wiring + account switching) │ ▼ Phase 5: Publishing via Machine │ ▼ Phase 6: Cleanup │ ▼ Phase 7 (later): Collapsed Blocks ext

Phase 1: renderType + editable + ReadOnly Fixes

Goal: Introduce renderType + editable to editor constructor, fix blocks that misbehave in readOnly, guard markdown shortcuts.

1a. Editor constructor changes

    Add to BlockNoteEditor.ts options: renderType: 'document' | 'embed' | 'comment' and keep existing editable: boolean

    renderType stored on editor instance for extensions to read (context only, does not affect plugin loading yet)

    editable controls plugin loading as before

    Default: renderType: 'document', editable: true

1b. ReadOnly block fixes


1c. Markdown shortcut guards

    Guard # → heading, - → bullet list, 1. → numbered list triggers behind editor.isEditable

    These are likely TipTap/ProseMirror input rules — find and guard them

    Location: BlockNode.ts handleTextInput (lines 444-460) and/or TipTap extension input rules

    Also check MarkdownExtension.ts (lines 160-217) paste handler

1d. Toolbar suppression when not editable

    editor-view.tsx: conditionally render FormattingToolbarPositioner, SlashMenuPositioner, LinkMenuPositioner, HyperlinkToolbarPositioner only when editable

    SideMenu: only when editable

    Key files:

      frontend/packages/editor/src/blocknote/core/BlockNoteEditor.ts

      frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockNode.ts

      frontend/packages/editor/src/blocknote/core/extensions/Markdown/MarkdownExtension.ts

      frontend/packages/editor/src/editor-view.tsx

      frontend/packages/editor/src/math.tsx

      frontend/packages/editor/src/mentions-plugin.tsx

      frontend/packages/editor/src/media-container.tsx

    Verify: Render editor with editable: false containing math, images, mentions. No interactive controls, no markdown shortcuts fire, no toolbars. pnpm -C frontend typecheck.

Phase 2: Editor Extensions (parallel, separate PRs)

All follow the pattern: *Plugin.ts (ProseMirror plugin) + *Positioner.tsx (React UI). Registered in BlockNoteEditor.ts. Reference patterns: SideMenuPlugin.ts, FormattingToolbarPlugin.ts.

2a. Block Highlighting

    Highlight block from URL #blockId, yellow highlights for citation ranges

    ProseMirror decoration plugin adding CSS classes

    Source: blocks-content.tsx focusBlockId + highlight logic

    New: editor/src/extensions/BlockHighlight/BlockHighlightPlugin.ts

2b. Image Gallery

    Double-click image when !editable → full-screen overlay with keyboard/swipe nav

    Source: blocks-content.tsx ImageGalleryProvider (lines 163-320)

    Reuse: collectImageBlocks, resolveGalleryNavigation, resolveSwipeDirection (already exported)

    New: editor/src/extensions/ImageGallery/ImageGalleryPlugin.ts, react/ImageGallery/ImageGalleryOverlay.tsx

    Modify: image.tsx (double-click handler when !isEditable)

2c. Block Hover Actions

    Hover block → floating card positioned top-right of block: copy block link, start comment

    Mouse tracking + editor.view.posAtCoords() → emit to React positioner

    Source: blocks-content.tsx BlockNodeContent hover card (lines 550-700)

    Version-aware links: In edit mode, block hover uses publishedVersion for existing blocks (blocks that existed before editing started). For new blocks (added during current edit session), copy link/reference uses path + blockRef without version. Editor emits a generic onBlockAction event; the app resolves the correct version.

    New: editor/src/extensions/BlockHoverActions/BlockHoverActionsPlugin.ts, react/BlockHoverActions/BlockHoverActionsPositioner.tsx

    Context needed: resourceId, publishedVersion, onBlockAction callback

2d. Range Selection / Citation Bubble

    Select text (click-drag) when !editable → bubble with "cite" and "comment" actions

    Active only when !isEditable

    In edit mode: click-drag selects text normally (formatting toolbar), does NOT trigger citation bubble

    Source: blocks-content.tsx range selection, useRangeSelection from @shm/shared

    New: editor/src/extensions/RangeSelection/RangeSelectionPlugin.ts, react/RangeSelection/RangeSelectionPositioner.tsx

2e. Supernumbers

    Positioned top-right of block (near/combined with hover actions area)

    Shows citations + comments count as a small badge/number

    Visible in ALL renderTypes and ALL editable states when count > 0, hidden when 0

    Click emits onSupernumberClick({ blockId }) event — editor does NOT decide what happens, the consuming app does (desktop: opens right panel focused on block)

    Data source: blockCitations record passed via editor options/context (same as current blocks-content.tsx line 133)

    New: editor/src/extensions/Supernumbers/SupernumbersPlugin.ts, react/Supernumbers/SupernumbersPositioner.tsx

    Verify each: Render published document with editor editable: false. Feature works. pnpm -C frontend typecheck. Visual comparison with current BlocksContent.

Phase 3: Renderer Swap

Goal: Replace <BlocksContent> with editor (editable: false, renderType: 'document') in resource-page-common.tsx.

What to do

    Add @shm/editor as dependency of @shm/ui (frontend/packages/ui/package.json)

    Create ReadOnlyEditor component in @shm/ui:

      useBlockNote({ editable: false, renderType: 'document', blockSchema: hmBlockSchema })

      Convert blocks via hmBlocksToEditorContent()

      Populate via editor.replaceBlocks() on mount / when blocks change

      Render <BlockNoteView> — no toolbars (suppressed by editable: false)

    In resource-page-common.tsx → ContentViewWithOutline: swap <BlocksContentProvider><BlocksContent> → <ReadOnlyEditor>

    Keep BlocksContent for other consumers (comments, previews) — remove only from document view

    Move setGroupTypes() from desktop/src/models/editor-utils.ts to shared location

    Key files:

      frontend/packages/ui/package.json

      frontend/packages/ui/src/read-only-editor.tsx (NEW)

      frontend/packages/ui/src/resource-page-common.tsx

      @seed-hypermedia/client/hmblock-to-editorblock

    Verify: Open any published document → renders via editor. All block types correct. All 5 extensions work. SSR works on web. pnpm -C frontend typecheck.

Phase 4: In-Place Editing

Goal: Click on text block → editor becomes editable. Wire machine editing states. Handle account switching.

4a. Click-to-edit behavior

    Text blocks only: Single click on paragraph/heading/code-block places cursor → sends edit.start to machine → editable toggles to true, toolbars appear

    Bottom of editor: Click on empty area below last block → same behavior (creates empty paragraph, enters edit mode)

    Non-text blocks (image, video, embed): single click does NOT trigger edit mode

    Click-drag (text selection): does NOT trigger edit mode — shows range selection/citation bubble instead

    Implementation: ProseMirror plugin that intercepts click events on text nodes, checks if !editable && canEdit, then sends edit.start

4b. Draft auto-load

    When navigating to a document where user has edit access:

      Query findByEdit for existing draft

      If draft exists → pass existingDraftId → machine auto-transitions loaded → editing → show draft content in edit mode

      If no draft → show published content in read-only (user clicks to edit)

4c. Machine wiring

    useDocumentEditor hook in desktop app:

      Wraps useBlockNote with machine event wiring

      onEditorContentChange → actorRef.send({type: 'change'})

      Toggle editor.isEditable based on selectIsEditing

      Show/hide toolbars based on editing state

    Create writeDraft actor factory (extract from documents.ts:611-652)

    Provide machine via documentMachine.provide({actors: {writeDraft}}) in desktop-resource.tsx

    Wire navigation guard for unsaved changes

4d. Account switching

    Add capability.changed event to document machine

    useSelectedAccountCapability() already reacts to account changes

    When capability changes:

      Editor → non-editor: Machine receives capability.changed { canEdit: false } → if in editing state: auto-save draft, transition to loaded, set editable: false

      Non-editor → editor: Machine receives capability.changed { canEdit: true } → update canEdit in context, user can now click-to-edit (or auto-enter editing if draft exists)

    Key files:

      frontend/apps/desktop/src/pages/desktop-resource.tsx

      frontend/apps/desktop/src/models/documents.ts (lines 479, 611-652)

      frontend/packages/shared/src/models/document-machine.ts

      frontend/packages/shared/src/models/capabilities.ts

    Verify:

      Click text block → editor becomes editable, toolbars appear, cursor placed

      Click image → nothing (stays read-only)

      Click-drag to select → range selection bubble, NOT edit mode

      Click bottom empty area → enters edit mode

      Type → autosave creates draft

      Navigate to doc with existing draft + edit access → auto-editing with draft content

      Switch account to non-editor while editing → draft saved, exits edit mode

      Switch account to editor → can click-to-edit again

      Old /draft page still works as fallback

Phase 5: Publishing via Machine

Goal: publish.start → publishing.inProgress → cleaningUp → loaded.

What to do

    Extract publish pipeline from publish-draft-button.tsx into publishDocument actor

    Provide via .provide() in desktop-resource.tsx

    Update publish button to read machine state + send publish.start

    context.deps used as baseVersion

    Key files:

      frontend/apps/desktop/src/publish-draft-button.tsx

      frontend/apps/desktop/src/pages/desktop-resource.tsx

    Verify: Full flow: view → click to edit → change → save → publish → back to loaded. Publish error → editing, draft intact. Parent auto-link + push to peers work.

Phase 6: Cleanup

Goal: Remove old code paths.

    Redirect /draft/:id routes to document routes with editing state

    Remove draft-machine.ts, draft.tsx, useDraftEditor

    Remove BlocksContent from document view (keep for comments/previews if still needed)

    Remove the three planning docs, replace with this single macro plan

Verify: No references to old draft machine. All existing tests pass. pnpm -C frontend typecheck.

Phase 7 (Later): Collapsed Blocks Extension

    Collapse/expand block children (headings, nested content)

    Source: blocks-content.tsx collapsedBlocks state (lines 344-355, 967-978)

    ProseMirror plugin managing collapsed state set + decorations

    Not blocking any other phase

PR Strategy

    Phase 1: 1 PR (renderType + editable + readOnly fixes + markdown guards)

    Phase 2: 5 separate PRs/commits (one per extension, independent)

    Phase 3: 1 PR (renderer swap)

    Phase 4: 1 PR (click-to-edit + machine wiring + account switching)

    Phase 5: 1 PR (publishing)

    Phase 6: 1 PR (cleanup)

Supersedes

This plan merges and replaces:

    docs/plans/editor-big-plan.md (Phases 1-4 done, 5-8 absorbed here)

    docs/plans/plan-editor-document-features.md (extensions + EditorMode absorbed here)

    docs/plans/plan-editor-migration-steps.md (steps 1-10 absorbed here)

Those docs can be removed in Phase 6 cleanup.

Key Reference Files


Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime

Connections0

Connections show where other documents or comments link to this document.

Related documents

Loading connections…