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.tsx — BlockNodeList (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