Hosted onnoosphere.hyper.mediavia theHypermedia Protocol

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