Hosted onnoosphere.hyper.mediavia theHypermedia Protocol

Prerequisites

Grid Layout (Phase 1) must be implemented first. This plan assumes 'Grid' already exists in HMBlockChildrenTypeSchema, the editor guards pattern (isInGridContainer) is established, and BlockNodeList rendering has been extended.

Context

With Grid done, we now want Fixed Columns: explicit column containers where each child IS a column with its own independent content. Unlike Grid (items flow/wrap), Columns give users explicit control — each column is a separate content area with its own block tree.

Think Notion columns or newspaper layouts — not a card grid.

Key Difference from Grid

| Aspect | Grid (Phase 1) | Columns (Phase 2) | | ----------------- | --------------------------------- | --------------------------------------------- | | Nesting depth | 1 level (container -> items) | 2 levels (container -> column wrappers -> content) | | Column count | Attribute (columnCount) | Number of direct children | | Item overflow | Wraps to next row | No overflow, each column is independent | | Content per column| Single block per cell | Multiple blocks per column | | Width control | Equal width, CSS grid | Per-column via columnWidths attribute |

Data Model

BlockNode {
  block: {
    id: "cols-1",
    type: "Paragraph",
    text: "",
    attributes: { childrenType: "Columns", columnWidths: [60, 40] }
  }
  children: [
    BlockNode {
      block: {
        id: "col-1",
        type: "Paragraph",
        text: "",
        attributes: { childrenType: "Group" }
      }
      children: [
        BlockNode { block: { id: "p1", type: "Paragraph", text: "Left content" } }
        BlockNode { block: { id: "img1", type: "Image", ... } }
      ]
    },
    BlockNode {
      block: {
        id: "col-2",
        type: "Paragraph",
        text: "",
        attributes: { childrenType: "Group" }
      }
      children: [
        BlockNode { block: { id: "p2", type: "Paragraph", text: "Right content" } }
      ]
    }
  ]
}

ProseMirror tree:

blockNode (container)
  block:paragraph ("")                    <- empty, invisible
  blockChildren [listType='Columns']      <- CSS flex row
    blockNode (column 1)
      block:paragraph ("")                <- empty, invisible
      blockChildren [listType='Group']    <- column 1 content
        blockNode -> paragraph ("Left text")
        blockNode -> image (...)
    blockNode (column 2)
      block:paragraph ("")
      blockChildren [listType='Group']    <- column 2 content
        blockNode -> paragraph ("Right text")

Attributes on container:

  • columnWidths: number[] (optional, percentages summing to 100, e.g. [60, 40])

  • Column count = number of direct children (not an attribute)

CRDT Behavior

  • Different columns = independent RGA sublists -> no conflicts

  • Same column = standard RGA merge within that column's child list

  • Add/remove columns = OpMoveBlocks on container's child list

  • Move blocks between columns = OpMoveBlocks changing parent -> last-write-wins if concurrent

  • No CRDT changes needed.

Implementation Steps

Step 1: Types & Schema

frontend/packages/shared/src/hm-types.ts

Add 'Columns' to HMBlockChildrenTypeSchema (Grid already present from Phase 1):

z.union([
  z.literal('Group'),
  z.literal('Ordered'),
  z.literal('Unordered'),
  z.literal('Blockquote'),
  z.literal('Grid'),
  z.literal('Columns'),
])

frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/defaultBlocks.ts

childrenType: {
  default: 'Group',
  values: ['Group', 'Unordered', 'Ordered', 'Blockquote', 'Grid', 'Columns'],
}

Step 2: Block Conversion

frontend/packages/shared/src/client/hmblock-to-editorblock.ts

  • Accept 'Columns' as valid childrenType (same pattern as Grid from Phase 1)

frontend/packages/shared/src/client/editorblock-to-hmblock.ts

  • Pass through 'Columns' childrenType

  • Preserve columnWidths attribute in conversion

Step 3: Editor — BlockChildren Rendering

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

  • listNode(): Add case for 'Columns' — render as <div> with display: flex; flex-direction: row; gap styles

  • Each child blockNode inside a Columns container renders as a flex item

  • addInputRules(): Phase 1 Grid guard already exists — extend to also cover Columns

Step 4: Editor — Keyboard Guards

frontend/packages/editor/src/blocknote/core/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts

Rename Phase 1's isInGridContainer() -> isInLayoutContainer() covering both Grid and Columns.

Additional guards needed beyond what Grid already has:

| Handler | Guard | | ------------------------ | -------------------------------------------------------------------- | | handleBackspace (~26) | Prevent merging across column boundaries (col-1 block can't merge with col-2 block) | | handleDelete (~315) | Same — prevent cross-column merging | | handleEnter (~423) | On empty block inside column: stay in column, don't unnest to parent | | handleTab (~541) | Prevent indent of column wrappers — already guarded by Phase 1 | | Shift-Tab (~621) | Prevent outdent of column wrappers — already guarded by Phase 1 |

Step 5: Editor — Block Manipulation Commands

frontend/packages/editor/src/blocknote/core/api/blockManipulation/commands/nestBlock.ts

Phase 1 already prevents nesting in Grid. Extend to:

  • Prevent nesting column wrapper blocks (direct children of Columns container)

  • Content blocks INSIDE a column CAN still be nested normally (they're inside a regular Group)

frontend/packages/editor/src/blocknote/core/api/blockManipulation/commands/mergeBlocks.ts

  • canMerge(): Add guard — blocks in different columns cannot merge

  • getPrevBlockInfo(): Stop traversal at column wrapper boundaries

Step 6: Editor — Slash Menu

frontend/packages/editor/src/slash-menu-items.tsx

{
  name: 'Columns',
  aliases: ['cols', 'side-by-side', 'split'],
  group: 'Layout',
  icon: ColumnsIcon,
  execute: (editor) => {
    // 1. Replace current block with empty block, set childrenType: 'Columns'
    // 2. Create 2 child blocks (column wrappers), each with childrenType: 'Group'
    // 3. Each column wrapper gets 1 empty paragraph child
    // 4. Place cursor in first column's paragraph
  }
}

Step 7: Editor — Column Management UI

Floating toolbar on the Columns container (on hover/selection):

  • "Add Column" button — creates a new column wrapper child

  • "Remove Column" button — removes the last column (min 2 enforced)

  • Column count display (e.g., "2 columns")

  • "Convert to normal blocks" — flattens: removes column wrappers, moves all content blocks to parent level

Column width resize handles:

  • Draggable divider between columns

  • Updates columnWidths attribute on the container block

  • Visual feedback during drag

Step 8: Read-Only Rendering

frontend/packages/ui/src/blocks-content.tsxBlockNodeList (line 464):

if (childrenType === 'Columns') {
  return (
    <div
      className="flex flex-row gap-4 w-full"
      data-node-type="blockGroup"
      data-list-type="Columns"
    >
      {children}
    </div>
  )
}

BlockNodeContent: When parent is Columns:

  • Each child renders as a flex item with flex: 1 (or proportional width from columnWidths)

  • Hide the empty container paragraph block (invisible wrapper)

  • Column content renders normally via recursive BlockNodeList

Step 9: Editor — Drag and Drop

frontend/packages/editor/src/blocknote/core/extensions/SideMenu/SideMenuPlugin.ts

  • blockPositionsFromSelection(): Prevent selection spanning multiple columns

  • onDrop(): Calculate drop target with column awareness — blocks dropped on a column land inside it

  • Future follow-up: side-drop indicators (like BlockNote's multiColumnDropCursor) for dragging blocks into new columns

Step 10: Responsive Behavior

CSS media queries:

  • Below tablet breakpoint: flex-direction: column — columns stack vertically

  • Column widths reset to 100% on mobile

Testing

Unit Tests — Block Conversion

frontend/packages/shared/src/client/__tests__/hmblock-to-editorblock.test.ts

Add to existing describe('childrenType'):

test('Columns', () => {
  const hmBlock: HMBlock = {
    id: 'foo',
    type: 'Paragraph',
    text: '',
    annotations: [],
    attributes: {childrenType: 'Columns'},
    revision: 'revision123',
  }
  const val = hmBlockToEditorBlock(hmBlock)
  expect(val.props.childrenType).toBe('Columns')
})

test('Columns with columnWidths attribute', () => {
  const hmBlock: HMBlock = {
    id: 'foo',
    type: 'Paragraph',
    text: '',
    annotations: [],
    attributes: {childrenType: 'Columns', columnWidths: [60, 40]},
    revision: 'revision123',
  }
  const val = hmBlockToEditorBlock(hmBlock)
  expect(val.props.childrenType).toBe('Columns')
})

test('Columns with nested children structure', () => {
  const blocks: HMBlockNode[] = [
    {
      block: {
        id: 'cols-1',
        type: 'Paragraph',
        text: '',
        annotations: [],
        attributes: {childrenType: 'Columns'},
      },
      children: [
        {
          block: {
            id: 'col-1',
            type: 'Paragraph',
            text: '',
            annotations: [],
            attributes: {childrenType: 'Group'},
          },
          children: [
            {
              block: {
                id: 'p1',
                type: 'Paragraph',
                text: 'Left',
                annotations: [],
                attributes: {},
              },
              children: [],
            },
          ],
        },
        {
          block: {
            id: 'col-2',
            type: 'Paragraph',
            text: '',
            annotations: [],
            attributes: {childrenType: 'Group'},
          },
          children: [
            {
              block: {
                id: 'p2',
                type: 'Paragraph',
                text: 'Right',
                annotations: [],
                attributes: {},
              },
              children: [],
            },
          ],
        },
      ],
    },
  ]
  const result = hmBlocksToEditorContent(blocks)
  expect(result[0].props.childrenType).toBe('Columns')
  expect(result[0].children).toHaveLength(2)
  expect(result[0].children[0].children).toHaveLength(1)
  expect(result[0].children[1].children).toHaveLength(1)
})

frontend/packages/shared/src/client/__tests__/editorblock-to-hmblock.test.ts

Add to existing describe('childrenType'):

test('Columns', () => {
  const editorBlock: EditorBlock = {
    id: 'foo',
    type: 'paragraph',
    children: [],
    props: {childrenType: 'Columns'},
    content: [],
  }
  const val = editorBlockToHMBlock(editorBlock)
  expect(val.attributes.childrenType).toBe('Columns')
})

Unit Tests — Editor Commands

frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/nestBlock.test.ts

describe('Columns container', () => {
  it('prevents nesting column wrapper blocks', () => {
    // Column wrapper (direct child of Columns) cannot be indented
    const doc = buildDoc(schema, [
      {
        id: 'cols-container',
        text: '',
        children: {
          listType: 'Columns',
          blocks: [
            {
              id: 'col-1',
              text: '',
              children: {blocks: [{id: 'p1', text: 'Left'}]},
            },
            {
              id: 'col-2',
              text: '',
              children: {blocks: [{id: 'p2', text: 'Right'}]},
            },
          ],
        },
      },
    ])
    const state = EditorState.create({doc, schema})
    const editor = createMockEditor(state)
    const pos = findPosInBlock(doc, 'col-2')
    const result = nestBlock(editor, pos)
    expect(result).toBe(false)
  })

  it('allows nesting content blocks inside a column', () => {
    // Content inside a column (regular Group) CAN be nested normally
    const doc = buildDoc(schema, [
      {
        id: 'cols-container',
        text: '',
        children: {
          listType: 'Columns',
          blocks: [
            {
              id: 'col-1',
              text: '',
              children: {
                blocks: [
                  {id: 'p1', text: 'First'},
                  {id: 'p2', text: 'Second'},
                ],
              },
            },
          ],
        },
      },
    ])
    const state = EditorState.create({doc, schema})
    const editor = createMockEditor(state)
    const pos = findPosInBlock(doc, 'p2')
    const result = nestBlock(editor, pos)
    expect(result).toBe(true)
  })
})

frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/mergeBlocks.test.ts

describe('Columns container', () => {
  it('prevents merging blocks across column boundaries', () => {
    // Block at end of col-1 + Backspace should NOT merge with start of col-2
    const doc = buildDoc(schema, [
      {
        id: 'cols-container',
        text: '',
        children: {
          listType: 'Columns',
          blocks: [
            {
              id: 'col-1',
              text: '',
              children: {blocks: [{id: 'p1', text: 'Left'}]},
            },
            {
              id: 'col-2',
              text: '',
              children: {blocks: [{id: 'p2', text: 'Right'}]},
            },
          ],
        },
      },
    ])
    const state = EditorState.create({doc, schema})
    const editor = createMockEditor(state)
    // Place cursor at start of p2 and try to merge backwards
    const pos = findPosInBlock(doc, 'p2')
    const result = mergeBlocks(editor, pos)
    expect(result).toBe(false)
  })
})

Unit Tests — Type Schema

frontend/packages/shared/src/__tests__/hm-types.test.ts

test('HMBlockChildrenType accepts Columns', () => {
  expect(HMBlockChildrenTypeSchema.parse('Columns')).toBe('Columns')
})

Pre-Completion Checks

# Format all code
pnpm format:write

# Type check everything
pnpm typecheck

# Run shared package tests (block conversion)
pnpm --filter @shm/shared test

# Run editor package tests (block manipulation)
pnpm --filter @shm/editor test

# Run all tests
pnpm test

All must pass with zero failures.

Files to Modify

| File | Changes | | ---------------------------------------------------------------------------------------------- | ------------------------------------------ | | frontend/packages/shared/src/hm-types.ts | Add 'Columns' to schema | | frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/defaultBlocks.ts | Add 'Columns' to values | | frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockChildren.ts | Flex row rendering | | frontend/packages/editor/src/blocknote/core/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts | Cross-column merge guards, Enter guard | | frontend/packages/editor/src/blocknote/core/api/blockManipulation/commands/nestBlock.ts | Prevent nesting column wrappers | | frontend/packages/editor/src/blocknote/core/api/blockManipulation/commands/mergeBlocks.ts | Prevent cross-column merges | | frontend/packages/editor/src/blocknote/core/extensions/SideMenu/SideMenuPlugin.ts | Column-aware drag-drop | | frontend/packages/editor/src/slash-menu-items.tsx | /columns slash command | | frontend/packages/shared/src/client/hmblock-to-editorblock.ts | Accept 'Columns' | | frontend/packages/shared/src/client/editorblock-to-hmblock.ts | Pass through 'Columns' | | frontend/packages/ui/src/blocks-content.tsx | Flex row rendering in BlockNodeList |

Test Files

| File | Additions | | -------------------------------------------------------------------------------------------------------- | ------------------------------------- | | frontend/packages/shared/src/client/__tests__/hmblock-to-editorblock.test.ts | Columns conversion + nested structure | | frontend/packages/shared/src/client/__tests__/editorblock-to-hmblock.test.ts | Columns reverse conversion | | frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/nestBlock.test.ts | Prevent nesting column wrappers | | frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/mergeBlocks.test.ts | Cross-column merge prevention | | frontend/packages/shared/src/__tests__/hm-types.test.ts | Columns schema validation |

Backwards Compatibility

  • Old clients: 'Columns' falls through to default <ul class="pl-3"> — content visible, stacked vertically

  • Column wrapper blocks render as empty paragraphs with nested content — readable but not side-by-side

  • No proto changes needed (attributes are open Struct)

  • No CRDT changes needed

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

Unsubscribe anytime