DOCUMENTATION

Block Editor

Last updated May 2026
Core system
All plans

The blnk block editor is a collaborative, real-time document editor built on Yjs CRDT, Tiptap rich text, and dnd-kit drag and drop. Every document is a flat array of blocks. All editor state — block order, content, metadata, column layout, toggle nesting — lives in a single Block[] array that is synced across collaborators and autosaved continuously.

Block anatomy

Every item in the document is a Block — a plain object with a fixed set of fields. There are no nested trees, no recursive data structures. Relationships like toggle nesting and column membership are expressed through fields on the block itself.

FieldTypeDescription
idstringStable unique identifier. Generated once on creation and never changes. Format: `timestamp-random`.
typeBlockTypeOne of ~40 registered block types (paragraph, heading1–6, toggle, carousel, product_cms, etc.).
contentstringHTML string for text blocks (Tiptap output), plain text for code blocks, empty string for non-text blocks.
metadataRecord<string,any>Block-type-specific configuration. Keys include checked, toggleOpen, listIndex, calloutType, animation, fontSize, fontFamily, textColor, bgColor, etc.
indentnumberIndent level 0–3. Applied as left padding. Controlled by Tab/Shift+Tab.
parentToggleIdstring?ID of the toggle block this block belongs to. Undefined = top-level block.
rowIdstring?Shared ID for all blocks in the same column row. Undefined = standalone block.
colIndexnumber?Which column within the row (0-based). The column ordering within a row.
colWidthsnumber[]?Percentage widths of each column. Stored on the colIndex-0 block only. All other blocks in the row have this undefined.
parentSectionIdstring?ID of the section block this block is inside. Used for section-aware drag scoping.
pending{content}?Set during AI generation. Triggers the InlineDiffView — shows old text struck through and new text below with Accept/Discard buttons.
The flat array structure means all mutations — insertions, deletions, reorders, converts — are pure functions that take Block[] and return Block[]. There is no mutable tree to traverse. This makes the Yjs sync model simple: the entire array is a single Y.Array.

Real-time sync model

The editor uses Yjs as its CRDT layer with Hocuspocus as the WebSocket sync server. The block array is stored as a Y.Array keyed by blocks-{documentId}.

Sync flow

1
React state → Yjs (debounced 16ms)

Every safeTransact call dispatches immediately to React state for instant UI feedback, then schedules a debounced Yjs write 16ms later. The write does a minimal diff — finding the first and last changed block and replacing only that range — rather than replacing the entire array.

2
Yjs → collaborators (Hocuspocus WebSocket)

Hocuspocus broadcasts the Yjs update delta to all connected clients. Each client's Yjs observer fires and syncs its React state. An ignoringEchoRef flag suppresses the local echo — the client that made the change ignores its own Yjs update to avoid a double-render.

3
Yjs → autosave (2s debounce)

scheduleAutoSave fires after 2 seconds of inactivity. It calls getSnapshot() which reads directly from the Y.Array (not React state) to get the authoritative current value.

4
Autosave → server (POST /api/documents/[id]/save)

The server validates blocks against an allowlist, checks for suspicious drops, runs a delete+reinsert transaction, and snapshots a version every 5 minutes.

Offline support

When Hocuspocus is not configured (local mode), changes are written to IndexedDB as a Yjs state delta under the key delta_{documentId}. On next load, the delta is applied before the Yjs observer fires, restoring the last known state. The delta is cleared after a successful save.

Wipe guard

Both the client and server independently protect against accidental document wipes — bugs where the block array unexpectedly collapses to zero or near-zero.

LayerTriggerAction
ClientBlock count drops below 15% of last known good, or below 1 absolutesafeTransact returns false. The update is silently dropped. React state and Yjs are not modified.
ClientSave response is 422setSaveStatus("error"). The debounce timer is not rescheduled — no retry loop.
Server (save)Incoming block count < 10% of current count (when current ≥ 5)Returns HTTP 422 with error message. Database is not modified.
Server (save)Incoming block count < 1 (when current ≥ 3)Returns HTTP 422. Logs "BLOCKED wipe attempt".

Save states

SaveIndicator — all five states
⏱ Saving…✓ Saved✗ Save failed — retrying…⚡ Offline — 3 unsaved changesidle — hidden
idle
No indicator rendered. Initial state and after the saved badge fades.
saving
Spinning clock icon + "Saving…".
saved
Green checkmark + "Saved". Autoclears after a short timeout.
error
Red alert + "Save failed — retrying…". Triggered by non-422 server errors or network failures.
offline
Amber wifi-off icon + pending change count. Triggered when navigator.onLine is false at save time.

Beacon save

When a user closes the tab or navigates away, a final save fires via navigator.sendBeacon to /api/documents/[id]/beacon. The browser guarantees this request completes even after the page unloads. If sendBeacon is unavailable, it falls back to a fetch with { keepalive: true }. The beacon endpoint is identical to the save endpoint except it never creates version snapshots.

Undo / Redo

Undo and redo are powered by Y.UndoManager with a 500ms capture timeout — changes made within 500ms of each other are grouped into a single undo step. The undo stack is cleared on initial load so you cannot undo the document's initial state.

⌘Z
Undo last change group.
⌘⇧Z
Redo.
stopCapturing
Called after operations that should start a new undo group — e.g. after a drag-and-drop completes, so the drop is its own undo step.

Keyboard shortcuts

All block-level keyboard handling lives in makeHandleKeyDown, which returns a stable function reference via useCallback. It fires on every keydown inside any Tiptap editor in the document. The Tiptap BlockEditorKeymap extension (priority 200, higher than Tiptap's built-in priority 100) intercepts Tab, Shift+Tab, and Mod+A before Tiptap can handle them, passing control up to the block-level handler.

Block operations

KeyBehaviour
EnterSplits the block at the cursor. Head (before cursor) stays in the current block; tail (after cursor) becomes a new paragraph after. If the cursor is at the very start or end, or the block is empty, creates a blank paragraph without splitting.
Enter (toggle)On a toggle header, always creates a new sibling toggle of the same variant after the last descendant of the current toggle — never splits the header text.
Enter (list/todo/numbered)Creates a new block of the same type. Numbered list auto-increments the index. Enter on an empty list/todo item converts it to a paragraph.
Backspace (empty)Deletes the block. Focus moves to the previous sibling in the same toggle scope.
Backspace (at start)Merges the current block's content into the previous sibling (if it is not a toggle or divider). Caret is placed at the join point.
Delete / Backspace (multi-select)When 2+ blocks are selected, deletes all selected blocks at once.
TabIf the block is a toggle child and Shift+Tab is pressed, extracts it. Otherwise increases the indent level (0–3). Never captured by Tiptap.
Shift+TabIf the block has a parentToggleId, extracts it from the toggle. Otherwise decreases indent.
/ (empty line)Opens the slash command palette at the current block position.
EscapeCloses the slash command palette.
Space (empty block)Opens the inline AI prompt.

Selection shortcuts

KeyBehaviour
⌘A (first press)Selects just the current block.
⌘A (second press)If inside a toggle, selects all children of that toggle. If at top level, selects all top-level blocks.
⌘A (third press)If toggle children are all selected, walks up one level and selects that toggle's parent scope. At top level with all selected, selects every block in the document.
↑ / ↓ (empty block or block mode)Moves block-mode selection to the previous/next visible block. Only visible blocks (those inside open toggles or at top level) are included in the traversal.
⌘C (multi-select)Copies selected blocks as plain text. Each block on its own line, indented proportionally to its toggle depth.

Markdown shortcuts

Typing a markdown prefix at the start of an empty block and pressing Space or Enter converts the block type. The prefix is cleared from the content:

Type thisConverts to
#Heading 1
##Heading 2
###Heading 3
####Heading 4
#####Heading 5
######Heading 6
-Bulleted List
*Bulleted List
+Bulleted List
1.Numbered List
>Toggle
>#Toggle Heading 1
>##Toggle Heading 2
>###Toggle Heading 3
>>Quote
[]To-do
[ ]To-do
---Divider
```Code Block
$$Equation
[toc]Table of Contents

Slash command palette

Type / on any empty line to open the palette. It opens anchored below the current block, clamped to the viewport so it never overflows. The palette has a search input that uses a scoring function — exact label match scores 100, prefix match 80, substring 60, keyword/description match 30. Results are highlighted with the matching characters marked in amber.

↑ / ↓
Navigate results. The active item scrolls into view.
Enter
Select the highlighted command and insert the block.
Escape
Close the palette without inserting anything.
Type query
Filter across label, description, and keywords. Switching to search mode flattens the grouped browse view into a scored flat list.

In browse mode (empty query), commands are grouped: Basic, Headings, Lists, Media, Advanced. A keyboard hint strip at the bottom shows ↑↓ navigate · ↵ select · Esc close. The palette derives its surface color from the editor background — on dark canvases it lightens the bg by ~22 points per channel.

Drag and drop

Every block has a drag handle — a grip icon visible on hover to the left of the block. Dragging it enters drag mode. The editor computes a DragIntent on every pointer move based on cursor position, total delta, nearby block rects, and toggle open states.

The seven drag intents

computeDragIntent evaluates intents in strict priority order, returning the first one that matches:

PriorityIntentTriggers when…
1columnTotal horizontal delta > 40px AND horizontal movement dominates by 1.5×. Detects merge, add, insert, and extract within column context.
2extractDragged block has a parentToggleId AND cursor has exited the toggle rect: laterally (< 20px from left edge), above (> 10px above top), or below (> 48px below bottom).
3toggle-adopt / toggle-reorderCursor is over a toggle header zone (top portion of toggle rect, inset 10% horizontally). If the toggle is open and cursor is below the header, becomes a toggle-reorder within that toggle's children.
4reorderDefault — nearest block by Y position with edge-zone scaling. Blocks inside the cursor's bounding rect use the top 20% / bottom 20% as before/after zones; blocks outside use midpoint distance.

Column intents

IntentWhat it does
column-mergeDragging a standalone block onto the left or right edge of another standalone block (within edgePx = min(48px, 20% of block width)). Creates a new two-column row.
column-addDragging onto the edge of a block that already has a rowId. Inserts the dragged block as a new column in that row.
column-insertDragging within an existing row, not onto an edge — reorders within the column.
extract (column)Dragging a column block more than 48px outside the row's left or right bounds. Strips rowId/colIndex and reorders as a standalone block.
Column layout — two columns with resize handle
📝Left column content
📝More content
🖼Right column content

Column resize

A 4px drag handle sits between columns. Dragging it adjusts the percentage widths of the two adjacent columns in real time using local component state — no Yjs writes fire during the drag. On mouseup, the final widths are committed to Yjs in a single transaction. Column widths are stored as a number[] of percentages on the colIndex: 0 block only.cleanupRows enforces this invariant after every mutation.

When a drag is active over a column row, an extract hint strip appears below the row — a dashed border with "drop here to exit columns". Dropping onto it extracts the dragged block from the column back to standalone.

Toggle drag behaviors

Drag onto toggle header (closed)
After 400ms of hover, the toggle auto-opens. If you move away before 400ms, the timer cancels and the toggle stays closed. This lets you drag into deeply nested toggles without manually opening them.
Drag onto toggle header (open)
Becomes toggle-reorder — drops the block as the last child of the toggle, or before/after a specific child based on the cursor Y position.
Drag toggle out
Moving a toggle block more than 48px below the toggle's bottom rect, or more than 20px to the left of the thread line, triggers extract. The toggle and all its descendants move atomically — they are never split.
A toggle and all its descendants always move together. applyDragIntentcalls collectDescendantIds before any reorder to gather the full subtree, then moves the entire group. The same applies to delete — deleting a toggle deletes all its children recursively.

Selection

The editor has two selection modes that coexist: text selection(standard browser selection within a single block) and block-mode selection (one or more whole blocks highlighted with a blue halo).

Lasso selection

Click and drag on any empty area of the editor canvas (not on a block's text, not on a drag handle, not inside a ProseMirror editor) to draw a lasso rectangle. After 5px of movement the lasso becomes visible. All blocks whose bounding rect intersects the lasso are selected with a blue halo (data-selection-halo). The lasso DOM element is appended directly to document.body as a fixed-position div — it is not part of the React tree and does not cause re-renders during drag.

Cross-block drag selection

Clicking inside a block's text and dragging across block boundaries enters cross-block selection mode. The blocks between the anchor and the focus are highlighted. During this drag, all ProseMirror editors have their contenteditable temporarily set to false so the browser cannot create a native selection spanning multiple editors. A synthetic native selection is painted using sel.setBaseAndExtent for visual feedback, but the actual selection state is tracked in crossBlockSelectionRef.

Shift+click range selection

Shift+clicking a block extends the selection from the current anchor (the last block you clicked without Shift) to the clicked block. The range includes every block between them in document order, regardless of toggle nesting.

Block mode (non-text blocks)

Clicking a non-text block (image, embed, carousel, database, product blocks, etc.) fires a nonTextBlockFocus event. This puts the editor into block mode for that single block and shows the floating toolbar centered above it — even though there is no text selection. The NON_TEXT_BLOCK_TYPES set determines which blocks trigger this behavior.

Floating toolbar (SelectionToolbar)

The SelectionToolbar is a fixed-position panel that appears above any text selection or block-mode selection. It consists of two rows: a format row and a tool strip.

Context label

The top-left of the toolbar shows a pill indicating what is selected: "1 text" for a single-block text selection, "1 block" for block mode, or "N blocks" for a multi-block selection. Format buttons are hidden in single-block-mode since there is nothing to format.

Format buttons

B / I / U / S
Bold, italic, underline, strikethrough. In cross-block mode, applied to all selected blocks via crossBlockSelectionRef.onFormat.
</>
Inline code. Single-block only.
Link icon
Opens an inline URL input row. Type or paste a URL and press Enter or click Apply. Single-block only.

Colors panel

The Colors dropdown has three tabs: Text, Highlight, and Background.

Text
11 colors (Default through Red) applied as Tiptap color marks on the current selection. In cross-block mode, selectAll() + setColor() is called on each block's editor.
Highlight
9 background highlight colors applied as Tiptap highlight marks. Works the same in cross-block mode.
Background
Sets the block-level background color (metadata.bgColor). Applied via the applyBlockBgColor custom event, which the BlockEditor listens to and writes into block metadata.

Typography panel

Alignment
Left, center, right, justify. Applied via Tiptap setTextAlign. Works in cross-block mode.
Font family
Inter, Serif, Mono, Sans. Applied via applyBlockTypography event → metadata.fontFamily → CSS --block-font-family custom property.
Font size
10 presets (S through 7XL) plus a custom px input. Applied via metadata.fontSize → CSS --block-font-size. The custom property is set on the block wrapper and inherited by ProseMirror.
Line height
Tight, Normal, Relaxed, Loose. Applied via metadata.lineHeight → CSS --block-line-height.

Turn into

A 2-column grid of 13 block types. Selecting one fires a convertSelectedBlocks custom event. The block editor listens and calls convertBlock on each selected block ID. Content is preserved; metadata is reset to the new type's defaults (e.g. switching to todo sets checked: false). Converting a toggle to a non-toggle extracts its children as siblings.

Animation panel

The animation picker is embedded directly in the toolbar. See Animation system below for full details.

AI panel

Six quick-action chips (Improve writing, Make shorter, Expand, Fix grammar, Simplify, Make formal) each dispatch a preset prompt. A custom prompt input below them accepts any text. Both paths fire openInlineAiWithPrompt which the block editor handles by streaming AI output into the block.

AI states

The editor has four distinct AI-driven visual states that blocks can be in:

Streaming shimmer

While AI is generating content for a block, metadata.aiStreaming is set to true and the block content is empty. The block renders an AiStreamingShimmer — a set of animated placeholder bars whose shape matches the block type (single wide bar for headings, three lines for paragraphs, bullet dot + bar for lists, etc.). The shimmer uses a moving gradient animation and a blinking cursor appended via CSS ::after on the ProseMirror element.

AI placeholder block

For complex blocks (database, hero, carousel, spreadsheet, embed, image) that AI generates asynchronously, the editor inserts an ai_placeholderblock with metadata.aiPlaceholderFor set to the target block type. It renders a pulsing skeleton with a type-specific label ("Designing hero section…", "Building database…", etc.) and three bouncing dots.

Inline diff review

When AI proposes a change to existing content, the change is written to block.pending.content rather than block.content. The block renders an InlineDiffView showing the original text struck through in red and the proposed text in green, with Accept and Discard buttons.

Inline diff — AI suggestion with accept/discard
AI suggestion
✗ Discard✓ Accept
The quick brown fox jumps over the lazy dog.
A swift brown fox leapt gracefully over the sleeping dog.

While the AI is still generating (pending content is empty), a DiffSkeleton is shown instead — the original text struck through and three animated shimmer bars below it with a "Writing…" badge.

Accepting writes block.pending.content to block.content and clears block.pending. Discarding clears block.pendingwithout modifying content. Both paths call onAcceptPending / onDiscardPending which trigger an autosave.

Collaboration

Presence indicators

When multiple users are editing the same document, their avatars appear in the editor toolbar as an overlapping strip. Each avatar is a 28px circle with the user's initials (first letter of first name + first letter of last name) in their assigned color. A green online dot appears at the bottom-right.

Presence — three collaborators, one overflow
A
B
C
+2

Up to 4 avatars are shown by default. Overflow is represented by a +N badge. Hovering an avatar shows a tooltip with the user's name. Presence is driven by Hocuspocus's awareness protocol — onAwarenessChange fires whenever any client joins, leaves, or updates its presence field.

Editor access

A document has one owner and any number of editor members (stored in documentMembers). Both the owner and editor members can save via the /api/documents/[id]/save route. Viewer members and unauthenticated visitors get readOnly= — the editor renders but no drag handles, delete buttons, or formatting controls appear, and keyboard handlers are suppressed.

Comments

Comments are per-block. Every block has a comment bubble icon visible on hover to the left of the drag handle. If the block has unresolved comments, the bubble is always visible in orange with a count badge.

Per-block thread

Clicking the comment bubble opens a CommentThread panel floating to the right of the block. The panel shows all comments for that block, grouped by thread. Each comment has:

  • The commenter's name, avatar initial, and relative timestamp (e.g. "3h ago").
  • The comment body with whitespace-pre-wrap and word-break handling.
  • Reply — opens an inline input below the comment.
  • Resolve — marks the comment resolved. Resolved comments appear at 50% opacity with a green "Resolved" badge.
  • Reopen — unresolves a resolved comment.
  • Delete — visible only to the comment author.

New comments are submitted with ⌘↵ or the Send button. The input stops propagation on keydown so editor shortcuts don't fire while typing.

Document comments panel

The document comments panel (opened from the toolbar) shows all comments across the document, grouped by block. Unresolved thread groups appear first. Each group has a Go to button that scrolls the relevant block into view. Resolved threads are collapsed under a disclosure button and shown at 60% opacity.

Clicking the comment bubble on a block with no existing comments opens the panel with that block's input pre-focused and highlighted in blue. After submitting, the highlight clears and the new comment appears in the unresolved section.

The panel subscribes to a commentsUpdated custom event — fired after any resolve, reopen, delete, or new comment — so the unresolved count badge in the toolbar stays in sync without a full panel reload.

Animation system

Every block can have a scroll-triggered or interactive animation set via the Animation panel in the toolbar. Animations are stored in metadata.animation as a BlockAnimation object and rendered on published pages by AnimatedBlock / AnimatedBlockClient.

Animation types

GroupTypes
Fadefade, fade-up, fade-down, fade-left, fade-right
Slideslide-up, slide-down, slide-left, slide-right
Scalescale-up, scale-down, blur-in
Specialflip-up, tilt-3d, static-3d, parallax-z, parallax-z-deep

How animations are applied

AnimatedBlock acts as a router — it inspects the animation type and renders one of four wrappers:

ScrollWrapper
For all Fade, Slide, Scale, blur-in, and flip-up types. Uses Framer Motion useInView to trigger the animation when the block enters the viewport. Easing maps to Framer transitions: spring (stiffness 280/damping 28), bounce (400/14), linear, or ease cubic-bezier.
ParallaxZ
For parallax-z and parallax-z-deep. Uses Framer useScroll + useSpring to shift the block along the Z axis as the user scrolls past. Strength defaults: 60px (parallax-z) / 120px (parallax-z-deep). Fades in/out at scroll edges.
Tilt3D
For tilt-3d. Mouse-driven rAF tilt with configurable maxTilt (default 8°) and hover scale (default 1.03×). Springs back to flat on mouseleave via Framer Motion animate.
Static3D
For static-3d. Applies a fixed CSS transform with rotateX/Y/Z and translateZ. No animation — the block simply sits at the specified angle. Uses isolation: isolate to avoid stacking context issues.

Editor preview

In the editor, the Animation panel shows a live preview box. For scroll/fade/slide animations, a ▶ Replay button fires a one-shot Web Animations API call on the preview element. For static-3d, the preview box updates its CSS transform in real time as you drag the rotation sliders — no replay needed. An AnimationBadge (✦ type) appears in the bottom-right corner of blocks that have an animation set, visible on hover.

Advanced controls

Delay
0–1500ms. Time before the animation starts after the block enters the viewport.
Duration
200–1500ms. Total animation time. Not applicable to tilt-3d or static-3d.
Easing
Ease (cubic-bezier), Spring (stiffness 280), Bounce (stiffness 400), Linear.
Animate once
When true (default), the animation only fires the first time the block enters the viewport. When false, it replays every time.
Parallax strength
Override for parallax-z depth travel in px.
Tilt angle
Max tilt degrees for tilt-3d (2–20°).
Hover scale
Scale factor on hover for tilt-3d (100–110%).
Static 3D
Four sliders: rotateX (±180°), rotateY (±180°), rotateZ (±180°), translateZ (±600px). Reset to flat button.

Image effects (Dither and ASCII)

Carousel and hero blocks support two special image rendering modes beyond standard background-image:

DitherShader
Canvas-based CPU dithering. 4 algorithms: Bayer ordered (4×4 or 8×8 matrix, depending on grid size), Halftone (rotated sine wave), Noise (sin hash), Crosshatch (two diagonal lines). 4 color modes: Original (quantized to 4 levels), Grayscale, Duotone (primary/secondary colors), Custom (2-color palette). Animated mode uses rAF to cycle the noise seed. Uses a ResizeObserver to redraw when the container changes size.
AsciiArt
Canvas-based ASCII renderer. 15 built-in charsets (standard, blocks, binary, dots, minimal, dense, braille, circles, hash, etc.) plus any custom string. 4 animation styles: fade (globalAlpha ramp), typewriter (reveals characters left-to-right), matrix (cascading green katakana then settling to the image), none. Resolution controls column count (20–120+). Uses useInView to start animation only when the block enters the viewport.