Grid Layout — Implementation Plan

Context

Documents are trees of BlockNode { block, children }. Blocks stack vertically — no way to flow items into a multi-column grid. The Query block has a columnCount attribute with CSS grid rendering in DocumentCardGrid, but this only applies to query results (document cards), not arbitrary block content.

We want a Flow Grid layout: items wrap into N columns automatically. Column count is defined by an attribute, and items stack/wrap when there are more items than columns. Think Pinterest / card grid / the Query block's card view — but for any block type.

Why childrenType and not dedicated block types?

Our editor is forked from an old BlockNote. The ProseMirror schema enforces blockNode → block blockChildren? — block nodes can only contain inline* or ''. Adding custom PM nodes outside this hierarchy (like Notion's column_list/column or BlockNote v0.19's @blocknote/xl-multi-column) requires refactoring 15-20+ editor files. The childrenType approach produces the same tree shape but fits within the existing schema.

Data Model

BlockNode { block: { id: "grid-1", type: "Paragraph", text: "", attributes: { childrenType: "Grid", columnCount: 3 } } children: [ BlockNode { block: { id: "item-1", type: "Image", ... } } BlockNode { block: { id: "item-2", type: "Image", ... } } BlockNode { block: { id: "item-3", type: "Image", ... } } BlockNode { block: { id: "item-4", type: "Image", ... } } <- wraps to row 2 ] }

ProseMirror tree:

blockNode (container) block:paragraph ("") <- empty, invisible blockChildren [listType='Grid'] <- CSS grid blockNode -> block:image (...) blockNode -> block:image (...) blockNode -> block:image (...) blockNode -> block:image (...) <- wraps to row 2

Attributes on container block:

    columnCount: number (1-4, default 3) — max columns before wrapping

    gap: number (optional, px, default from layout unit)

CRDT Behavior

Uses existing parent-child tree mechanics — no changes needed:

    Grid items are children in the container's RGA sublist

    Concurrent adds: both items appear, ordered by opID

    Reordering: standard OpMoveBlocks

Implementation Steps

Step 1: Types & Schema

frontend/packages/shared/src/hm-types.ts (line 42-44)

// Before: z.union([ z.literal('Group'), z.literal('Ordered'), z.literal('Unordered'), z.literal('Blockquote'), ]) // After: z.union([ z.literal('Group'), z.literal('Ordered'), z.literal('Unordered'), z.literal('Blockquote'), z.literal('Grid'), ])

frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/defaultBlocks.ts (line 14-17)

// Add 'Grid' to childrenType values childrenType: { default: 'Group', values: ['Group', 'Unordered', 'Ordered', 'Blockquote', 'Grid'], }

Step 2: Block Conversion

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

    In hmBlocksToEditorContent() where childrenType is validated/read: accept 'Grid' as valid value

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

    In editorBlockToHMBlock() where childrenType is written to attributes: pass through 'Grid'

    Ensure columnCount attribute is preserved in conversion

Step 3: Editor — BlockChildren Rendering

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

    listNode() (line 228-238): Add case for 'Grid' — render as <div> with CSS grid classes

    addInputRules(): Add guard so list input rules (- , 1. , > ) don't trigger inside Grid containers

Step 4: Editor — Keyboard Guards

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

Add utility: isInGridContainer(state, pos) -> boolean — checks if current block's parent blockChildren has listType='Grid'.

| Handler | Guard | | ------------------- | -------------------------------------- | | handleTab (~541) | Prevent indent when parent is Grid | | Shift-Tab (~621) | Prevent outdent of grid children |

Note: Enter, Backspace, Delete work normally inside a Grid — items are flat siblings, no cross-boundary issues.

Step 5: Editor — Block Manipulation Commands

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

    sinkListItem() / canNestBlock(): Return false if parent is Grid (prevent indenting grid items)

    liftListItem() / canUnnestBlock(): Return false if block's parent group is Grid

Step 6: Editor — Slash Menu

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

Add slash menu item:

{ name: 'Grid', aliases: ['gallery', 'cards', 'grid'], group: 'Layout', icon: GridIcon, execute: (editor) => { // 1. Replace current block with empty block, set childrenType: 'Grid', columnCount: 3 // 2. Create 3 empty paragraph child blocks // 3. Place cursor in first child } }

Step 7: Editor — Grid Settings UI

Create a toolbar/popover on the Grid container block that allows:

    Column count selector (1, 2, 3, 4) — updates columnCount attribute

    Reuse the pattern from the Query block's column count selector in frontend/apps/desktop/src/editor/query-block.tsx

Step 8: Read-Only Rendering

frontend/packages/ui/src/blocks-content.tsx — BlockNodeList (line 464):

if (childrenType === 'Grid') { return ( <div className={cn('grid gap-4 w-full', gridColumnClass(columnCount))} data-node-type="blockGroup" data-list-type="Grid" > {children} </div> ) }

Helper function:

function gridColumnClass(count: number): string { switch (count) { case 1: return 'grid-cols-1' case 2: return 'grid-cols-1 sm:grid-cols-2' case 3: return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3' case 4: return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4' default: return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3' } }

BlockNodeContent: When inside a Grid, each child renders normally (no list markers). The empty container paragraph is hidden.

Step 9: Responsive Behavior

Tailwind responsive prefixes handle column reduction automatically:

    Mobile: 1 column

    Tablet (sm): 2 columns

    Desktop (md/lg): full columnCount

Testing

Unit Tests — Block Conversion

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

Add to existing describe('childrenType') block (follows pattern of Group/Unordered/Ordered tests):

test('Grid', () => { const hmBlock: HMBlock = { id: 'foo', type: 'Paragraph', text: '', annotations: [], attributes: {childrenType: 'Grid', columnCount: 3}, revision: 'revision123', } const val = hmBlockToEditorBlock(hmBlock) expect(val.props.childrenType).toBe('Grid') }) test('Grid with children preserves structure', () => { const blocks: HMBlockNode[] = [ { block: { id: 'grid-1', type: 'Paragraph', text: '', annotations: [], attributes: {childrenType: 'Grid', columnCount: 2}, }, children: [ { block: { id: 'item-1', type: 'Paragraph', text: 'Item 1', annotations: [], attributes: {}, }, children: [], }, { block: { id: 'item-2', type: 'Paragraph', text: 'Item 2', annotations: [], attributes: {}, }, children: [], }, ], }, ] const result = hmBlocksToEditorContent(blocks) expect(result[0].props.childrenType).toBe('Grid') expect(result[0].children).toHaveLength(2) })

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

Add to existing describe('childrenType') block:

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

Unit Tests — Editor Commands

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

Add test using existing buildDoc/createMockEditor helpers:

describe('Grid container', () => { it('prevents nesting inside Grid', () => { const doc = buildDoc(schema, [ { id: 'grid', text: '', children: { listType: 'Grid', blocks: [ {id: 'item-1', text: 'Item 1'}, {id: 'item-2', text: 'Item 2'}, ], }, }, ]) const state = EditorState.create({doc, schema}) const editor = createMockEditor(state) const pos = findPosInBlock(doc, 'item-2') // Tab should NOT indent item-2 inside a Grid const result = nestBlock(editor, pos) expect(result).toBe(false) }) })

Unit Tests — Type Schema Validation

frontend/packages/shared/src/__tests__/hm-types.test.ts (add to existing or create):

import {HMBlockChildrenTypeSchema} from '../hm-types' describe('HMBlockChildrenType', () => { test('accepts Grid', () => { expect(HMBlockChildrenTypeSchema.parse('Grid')).toBe('Grid') }) test('rejects invalid values', () => { expect(() => HMBlockChildrenTypeSchema.parse('InvalidType')).toThrow() }) })

Pre-Completion Checks

Run these before marking the task as done:

# 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.

Backwards Compatibility

    Old clients: 'Grid' falls through to default <ul class="pl-3"> in BlockNodeList — content visible, stacked vertically as a plain list

    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

Connections0

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

Related documents

Loading connections…