Skip to content
Documentation GitHub
Content

Block Content System

Status: Implemented Depends On: Page System, Attachment System


The Block Content System extends the page model with typed block variants beyond the default rich-text Markdown. Each block has a BlockContentType that determines how it is rendered and indexed for search.

Currently two block types exist:

  • Markdown (default): Rich text content edited via TipTap/Loro CRDT
  • Image: A reference to an attachment with display settings (scale, alignment, caption, link URL)

The type is fixed at block creation time and cannot be changed. This design allows each block type to carry variant-specific metadata while keeping the storage schema stable.


Frontend (React)
├── ImageBlock.ts TipTap Node extension (atom, draggable)
│ Attributes: blockId, attachmentId, scale, alignment, caption, linkUrl, alt
├── ImageBlockView.tsx React NodeView: renders image with controls
├── ImageBlockDrop.ts ProseMirror plugin: intercepts drag-drop and paste
│ Drag-drop: extracts Tauri file path -> upload_attachment -> insert imageBlock
│ Paste: reads clipboard bytes -> upload_attachment_bytes -> insert imageBlock
└── InsertBlockMenu.tsx Block type picker: "Text block" / "Image block"
Calls create_block Tauri command with appropriate BlockContentType
Application
├── CreateBlockUseCase crates/application/src/page/ (accepts BlockContentType)
├── UpdateBlockMetadataUseCase crates/application/src/page/ (updates image metadata)
└── Block entity uses BlockContentType for FTS and markdown export
Domain
├── BlockContentType crates/domain/src/block_content_type.rs
│ Markdown | Image(ImageBlockMetadata)
├── ImageBlockMetadata crates/domain/src/block_content_type.rs
├── ImageScale crates/domain/src/block_content_type.rs
│ FitWidth | OriginalSize | Percentage(u8)
└── ImageAlignment crates/domain/src/block_content_type.rs
Left | Center | Right
Infrastructure
└── blocks table content_type + content_type_metadata columns (V001 baseline)

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "type", content = "data")]
pub enum BlockContentType {
#[default]
Markdown,
Image(ImageBlockMetadata),
}

The enum uses tagged serialization ({"type": "Markdown"} or {"type": "Image", "data": {...}}), making it directly usable across the Tauri IPC boundary via Specta.

pub struct ImageBlockMetadata {
pub attachment_id: Uuid, // references attachments.id
pub scale: ImageScale, // FitWidth | OriginalSize | Percentage(1-100)
pub alignment: ImageAlignment, // Left | Center | Right
pub caption: Option<String>, // <= 500 characters
pub link_url: Option<String>, // http(s):// only, <= 2048 bytes
}
FieldRule
captionMax 500 characters (Unicode char count)
link_urlMust start with http:// or https://, max 2048 bytes
scale (Percentage)Must be 1-100
Block typeFixed at creation, cannot change

The V001 baseline includes two columns on the blocks table:

-- blocks table (relevant columns)
CREATE TABLE blocks (
id TEXT PRIMARY KEY,
page_id TEXT NOT NULL,
slot_id INTEGER NOT NULL,
content TEXT NOT NULL DEFAULT '',
content_loro BLOB,
content_loro_version INTEGER NOT NULL DEFAULT 1,
content_type TEXT NOT NULL DEFAULT 'markdown',
content_type_metadata TEXT,
area TEXT,
FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE,
UNIQUE(page_id, slot_id)
);
ColumnPurpose
content_typeDiscriminant string: "markdown" or "image"
content_type_metadataJSON string for variant-specific data (NULL for Markdown)
// Serialize to columns
let type_str = block_content_type.as_str(); // "markdown" or "image"
let metadata = block_content_type.metadata_json(); // None or Some(json_string)
// Reconstruct from columns
let bct = BlockContentType::from_stored(type_str, metadata.as_deref())?;

The from_stored() constructor returns Err(DomainError::InvalidState) for unknown type strings or malformed JSON, ensuring corrupted data is caught at load time.


The searchable_text() method returns the text contribution for each block type:

impl BlockContentType {
pub fn searchable_text<'c>(&self, content: &'c str) -> Cow<'c, str> {
match self {
Self::Markdown => Cow::Borrowed(content), // zero-copy
Self::Image(meta) => {
// Combines alt text (content field) with caption
let mut text = content.to_string();
if let Some(caption) = &meta.caption {
if !text.is_empty() && !caption.is_empty() {
text.push(' ');
}
text.push_str(caption);
}
Cow::Owned(text)
}
}
}
}

For Markdown blocks, this is a zero-copy borrow. For Image blocks, the alt text (stored in the block’s content field) and caption are concatenated for indexing. This means image captions are searchable through the same FTS5 pipeline as text content.


The to_markdown() method renders each block type for export and import:

impl BlockContentType {
pub fn to_markdown<'c>(&self, content: &'c str) -> Cow<'c, str> {
match self {
Self::Markdown => Cow::Borrowed(content), // zero-copy
Self::Image(meta) => {
// Renders as ![alt](attachments/uuid)
let alt = meta.caption.as_deref()
.filter(|c| !c.is_empty())
.unwrap_or(content);
let escaped_alt = alt.replace('[', r"\[").replace(']', r"\]");
Cow::Owned(format!("![{}](attachments/{})", escaped_alt, meta.attachment_id))
}
}
}
}

Image blocks render as standard Markdown image syntax with the caption (or alt text) as the alt attribute and a relative path to the attachment file. Brackets in alt text are escaped to prevent Markdown injection.


The TipTap ImageBlock node is:

  • Atomic: no inline editing (the entire block is a single unit)
  • Draggable: can be repositioned via drag-and-drop
  • React-rendered: uses ReactNodeViewRenderer(ImageBlockView) for rich display controls

Node attributes map directly to ImageBlockMetadata:

addAttributes() {
return {
blockId: { default: null },
attachmentId: { default: null },
scale: { default: { type: "FitWidth" } },
alignment: { default: "center" },
caption: { default: null },
linkUrl: { default: null },
alt: { default: "" },
};
}

The ImageBlockDrop extension registers a ProseMirror plugin that intercepts:

Drag-drop from OS filesystem:

  1. Filters dropped files to image MIME types (png, jpeg, gif, webp)
  2. Extracts the Tauri-injected path property from the File object
  3. Calls upload_attachment Tauri command
  4. Inserts an imageBlock node at the drop position

Paste from clipboard:

  1. Filters clipboard items to image MIME types
  2. Reads raw bytes from the clipboard item
  3. Calls upload_attachment_bytes Tauri command (bytes serialized as JSON array)
  4. Inserts an imageBlock node at the cursor position

Only new external drops are handled — internal node moves (moved = true) pass through to TipTap’s default drag behavior.

A floating menu component offering “Text block” and “Image block” options. For images, it opens the native file picker via @tauri-apps/plugin-dialog, uploads the selected file, then calls create_block with the appropriate BlockContentType:

const contentType: BlockContentType = {
type: "Image",
data: {
attachment_id: attachment.id,
scale: { type: "FitWidth" },
alignment: "center",
},
};

Block content type cannot change after creation. An Image block cannot become a Markdown block or vice versa. This simplifies the storage model (metadata columns are always consistent with the type discriminant) and avoids lossy conversions.

Rather than a single polymorphic JSON column, the design uses content_type (discriminant) + content_type_metadata (variant data). This allows SQL queries to filter by type efficiently (WHERE content_type = 'image') without parsing JSON.

For Markdown blocks, the content field holds the rich text. For Image blocks, it holds alt text. This preserves the existing FTS5 indexing pipeline — searchable_text() simply adds the caption alongside the alt text.

Both searchable_text() and to_markdown() return Cow<'c, str> — borrowing the input for Markdown blocks (the common case) and only allocating for Image blocks. This avoids unnecessary allocations in the hot path of page rendering and indexing.


ScenarioEntry Point
Create image blockInsertBlockMenu.insertImageBlock() -> create_block command
Drag-drop imageImageBlockDrop plugin handleDrop()
Paste imageImageBlockDrop plugin handlePaste()
FTS5 indexingBlockContentType::searchable_text()
Markdown exportBlockContentType::to_markdown()
Storage round-tripBlockContentType::from_stored() / as_str() + metadata_json()
Metadata updateUpdateBlockMetadataUseCase::execute()

Typed block variants for pages. See also: Attachment System (image blocks reference attachments), Page System.

Was this page helpful?