>>
Editor Schema Refactoring: Approach ComparisonTwo parallel branches refactor the editor's ProseMirror schema to improve list handling. This document compares both approaches, explains why the unified approach is better, and outlines a plan to consolidate the best parts of each.

    Problem Statement

      The editor on main uses BlockNote's original schema:

        blockGroup — wraps child blocks (renders <div>)

        blockContainer — wraps each block (renders <div> around <div>)

        blockContent — extra wrapper div around actual content elements

      Problems:

        Lists render as <div> instead of semantic <ul>/<ol>/<li>

        Three levels of wrapper divs (blockOuter > blockContainer > blockContent) bloat the DOM

        blockContent is a separate node type that adds complexity to every command

        Toggle between list types requires structural node replacement

      Two branches attempt to fix this independently:

        editor-refactor-plan (Horacio/Claude) — Unified approach

        feat/list-node-type (Yskak) — Dual node type approach

    Approach A: Unified (editor-refactor-plan)

      Core idea: Rename existing nodes, flatten DOM, use addNodeView() for dynamic rendering.

      Schema

        Doc
          └─ blockChildren (was blockGroup)
               └─ blockNode+ (was blockContainer)
                    ├─ block (content: paragraph | heading | code-block | ...)
                    └─ blockChildren? (recursive children)
        

          2 structural node types (renamed from existing)

          blockNode uses addNodeView() to render as <li> when inside a list-type blockChildren, or <div> otherwise

          blockChildren renders as <ul>, <ol>, or <div> based on its listType attribute

          List toggle = setNodeMarkup() on the blockChildren node (attribute change, no structural change)

          DOM flattened from 3 wrappers to 1 (blockContent wrapper removed)

      Changes

        ~400 net lines changed in blocknote core (443 added, 363 removed)

        44 files touched (mostly string replacements: blockGroup -> blockChildren, blockContainer -> blockNode)

        No new commands or helper functions needed

        All existing commands work unchanged (nestBlock, splitBlock, mergeBlocks, etc.)

      Key wins

        transformPastedHTML in code-block extension rewrites custom code renderers (CH.Code, Shiki) into <pre> before DOMParser runs — working and tested

        transformPasted in BlockChildren normalizes pasted fragments — working

        Semantic HTML output via addNodeView() dynamic rendering

        5 passing E2E tests including custom code block paste

    Approach B: Dual Node Types (feat/list-node-type)

      Core idea: Add separate ProseMirror node types for list containers and groups.

      Schema

      Doc
        └─ blockGroup (unchanged, for non-list blocks)
             └─ blockContainer+ (unchanged)
                  ├─ blockContent
                  └─ blockGroup? | listGroup?
      
        └─ listGroup (NEW — renders <ul>/<ol>/<blockquote>)
             └─ listContainer+ (NEW — renders <li>)
                  ├─ blockContent
                  └─ listGroup? | blockGroup?
      

        4 structural node types (2 existing + 2 new)

        listGroup renders <ul>, <ol>, or <blockquote> based on listType attribute

        listContainer renders <li>

        List toggle = replace blockGroup node with listGroup (structural transformation) + replace all blockContainer children with listContainer (recursive)

        DOM structure unchanged (still has blockContent wrapper)

      Changes

        +1,717 / -359 lines (~1,400 net new lines)

        30 files touched

        New files:

          ListGroup.ts (377 lines) — new node type with input rules, paste plugin (commented out)

          ListContainer.ts (59 lines) — new node type

          containerHelpers.ts (105 lines, 12 functions) — abstractions for dual-container branching

        Heavily modified:

          nestBlock.ts (+260 lines) — near-duplicate code paths for list vs block containers

          updateGroup.ts (+205 lines) — structural node replacement for list toggle

          KeyboardShortcutsExtension.ts (+110 lines) — manual position arithmetic for dual containers

          nodeConversions.ts (+114 lines) — list node serialization/deserialization

      Test infrastructure (valuable)

        vitest.config.ts — unit test configuration

        test-helpers-prosemirror.ts (147 lines) — ProseMirror test document builder

        test-helpers.ts (114 lines) — helper utilities

        updateGroup.test.ts (143 lines, 3 actual tests) — unit tests for updateGroup

        5 empty test files (placeholders for future tests)

        JSON fixtures for blockGroup and listGroup test documents

    Detailed Comparison

      1. Schema Complexity

        Winner: Unified. Fewer node types = simpler schema, less surface area for bugs.

      2. Command Complexity

        Winner: Unified. The dual approach requires every command that touches block structure to branch on container type. This creates parallel code paths that must be kept in sync — a maintenance burden that grows with every new command.

      3. List Toggle Mechanism

        Winner: Unified. Changing an attribute is inherently safer and simpler than replacing nodes.

      4. Paste Handling

        Winner: Unified. Paste is one of the hardest editor problems. Having working, tested paste handling is a significant advantage.

      5. DOM Output

        Tie on semantics, Unified wins on DOM cleanliness. Both produce valid semantic HTML. The unified approach also flattens the DOM, reducing wrapper div bloat.

      6. Type Safety

        Mixed. The dual approach has stronger schema-level enforcement but trades it for 40+ TypeScript suppressions. The unified approach relies on runtime behavior in addNodeView() but has clean types.

      7. Current State

        Winner: Unified. Working code > incomplete code.

    Summary Table

    What's Valuable in Each

      From Unified (keep)

        Flattened DOM (fewer wrapper divs)

        Dynamic rendering via addNodeView()

        Working paste handling (transformPastedHTML + transformPasted)

        Attribute-based list toggle (simple, safe, undoable)

        E2E test coverage for paste scenarios

      From Dual (adopt)

        Vitest configuration — unit test setup for the editor package

        ProseMirror test helperstest-helpers-prosemirror.ts builds test documents programmatically, invaluable for unit testing commands

        Test fixtures — JSON document fixtures for reproducible tests

        updateGroup unit tests — can be adapted to test our updateGroup with the unified schema

        Input rules for listsListGroup.ts has well-structured InputRule definitions for 1., -, > patterns (though our branch may already handle these via blockChildren)

    Recommendation

      Use the unified approach as the base and adopt Yskak's test infrastructure.

      Reasons:

        3.5x less code for the same result

        No command branching = no parallel maintenance burden

        Working paste handling (hardest part of editor development)

        Simpler mental model: 2 node types, attribute-based list toggle

        DOM flattening is a strict improvement (less nesting, same semantics)

      The dual approach's only structural advantage — schema-level content enforcement — doesn't justify tripling the codebase complexity, especially when it comes with 40+ @ts-ignore suppressions and disabled paste handling.

    Consolidation Plan

      Phase 1: Finalize Unified Branch

        Complete any remaining edge cases (nested list interactions, keyboard shortcuts)

        Ensure all list types (Ordered, Unordered, Blockquote) toggle correctly

        Verify paste from all common sources (Google Docs, Notion, web pages, code editors)

        Run full E2E suite, fix failures

      Phase 2: Adopt Test Infrastructure

        Copy vitest.config.ts from Yskak's branch

        Copy test-helpers-prosemirror.ts and test-helpers.ts, adapt for unified schema

        Copy JSON fixtures, update node type names (blockContainer -> blockNode, blockGroup -> blockChildren)

        Adapt updateGroup.test.ts tests for attribute-based toggle

      Phase 3: Add Unit Tests for Critical Paths

        Priority order:

          updateGroup — list type toggle (attribute change)

          nestBlock — block indentation / list nesting

          splitBlock — Enter key behavior in lists

          transformPasted (BlockChildren) — paste fragment normalization

          transformPastedHTML (code-block) — custom code renderer rewriting

      Phase 4: Communicate and Align

        Share this document with Yskak

        Acknowledge the valuable test infrastructure contribution

        Align on unified approach as the path forward

        Coordinate merging to avoid conflicts

        Consider pair session to walk through the unified architecture