Hosted onhoraciohdev.dev.hyper.mediavia theHypermedia Protocol

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.tsxBlockNodeList (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