Block Editor
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.
| Field | Type | Description |
|---|---|---|
id | string | Stable unique identifier. Generated once on creation and never changes. Format: `timestamp-random`. |
type | BlockType | One of ~40 registered block types (paragraph, heading1–6, toggle, carousel, product_cms, etc.). |
content | string | HTML string for text blocks (Tiptap output), plain text for code blocks, empty string for non-text blocks. |
metadata | Record<string,any> | Block-type-specific configuration. Keys include checked, toggleOpen, listIndex, calloutType, animation, fontSize, fontFamily, textColor, bgColor, etc. |
indent | number | Indent level 0–3. Applied as left padding. Controlled by Tab/Shift+Tab. |
parentToggleId | string? | ID of the toggle block this block belongs to. Undefined = top-level block. |
rowId | string? | Shared ID for all blocks in the same column row. Undefined = standalone block. |
colIndex | number? | Which column within the row (0-based). The column ordering within a row. |
colWidths | number[]? | Percentage widths of each column. Stored on the colIndex-0 block only. All other blocks in the row have this undefined. |
parentSectionId | string? | 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. |
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
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.
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.
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.
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.
| Layer | Trigger | Action |
|---|---|---|
| Client | Block count drops below 15% of last known good, or below 1 absolute | safeTransact returns false. The update is silently dropped. React state and Yjs are not modified. |
| Client | Save response is 422 | setSaveStatus("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
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.
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
| Key | Behaviour |
|---|---|
| Enter | Splits 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. |
| Tab | If 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+Tab | If 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. |
| Escape | Closes the slash command palette. |
| Space (empty block) | Opens the inline AI prompt. |
Selection shortcuts
| Key | Behaviour |
|---|---|
| ⌘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 this | Converts 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.
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:
| Priority | Intent | Triggers when… |
|---|---|---|
| 1 | column | Total horizontal delta > 40px AND horizontal movement dominates by 1.5×. Detects merge, add, insert, and extract within column context. |
| 2 | extract | Dragged 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). |
| 3 | toggle-adopt / toggle-reorder | Cursor 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. |
| 4 | reorder | Default — 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
| Intent | What it does |
|---|---|
| column-merge | Dragging 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-add | Dragging onto the edge of a block that already has a rowId. Inserts the dragged block as a new column in that row. |
| column-insert | Dragging 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 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
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
Colors panel
The Colors dropdown has three tabs: Text, Highlight, and Background.
Typography panel
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.
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.
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.
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-wrapand 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
| Group | Types |
|---|---|
| Fade | fade, fade-up, fade-down, fade-left, fade-right |
| Slide | slide-up, slide-down, slide-left, slide-right |
| Scale | scale-up, scale-down, blur-in |
| Special | flip-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:
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
Image effects (Dither and ASCII)
Carousel and hero blocks support two special image rendering modes beyond standard background-image: