Block Content System
Status: Implemented Depends On: Page System, Attachment System
Overview
Section titled “Overview”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.
Overview Diagram
Section titled “Overview Diagram”Architecture
Section titled “Architecture”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)Domain Model
Section titled “Domain Model”BlockContentType
Section titled “BlockContentType”#[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.
ImageBlockMetadata
Section titled “ImageBlockMetadata”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}Validation Rules
Section titled “Validation Rules”| Field | Rule |
|---|---|
caption | Max 500 characters (Unicode char count) |
link_url | Must start with http:// or https://, max 2048 bytes |
scale (Percentage) | Must be 1-100 |
| Block type | Fixed at creation, cannot change |
Database Schema
Section titled “Database Schema”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));| Column | Purpose |
|---|---|
content_type | Discriminant string: "markdown" or "image" |
content_type_metadata | JSON string for variant-specific data (NULL for Markdown) |
Storage Round-Trip
Section titled “Storage Round-Trip”// Serialize to columnslet type_str = block_content_type.as_str(); // "markdown" or "image"let metadata = block_content_type.metadata_json(); // None or Some(json_string)
// Reconstruct from columnslet 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.
Type-Aware FTS5 Indexing
Section titled “Type-Aware FTS5 Indexing”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.
Markdown Export
Section titled “Markdown Export”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  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!("", 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.
Frontend Integration
Section titled “Frontend Integration”ImageBlock Extension
Section titled “ImageBlock Extension”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: "" }, };}Drag-Drop and Paste (ImageBlockDrop)
Section titled “Drag-Drop and Paste (ImageBlockDrop)”The ImageBlockDrop extension registers a ProseMirror plugin that intercepts:
Drag-drop from OS filesystem:
- Filters dropped files to image MIME types (png, jpeg, gif, webp)
- Extracts the Tauri-injected
pathproperty from the File object - Calls
upload_attachmentTauri command - Inserts an
imageBlocknode at the drop position
Paste from clipboard:
- Filters clipboard items to image MIME types
- Reads raw bytes from the clipboard item
- Calls
upload_attachment_bytesTauri command (bytes serialized as JSON array) - Inserts an
imageBlocknode at the cursor position
Only new external drops are handled — internal node moves (moved = true) pass through to TipTap’s default drag
behavior.
InsertBlockMenu
Section titled “InsertBlockMenu”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", },};Key Design Decisions
Section titled “Key Design Decisions”1. Type Fixed at Creation
Section titled “1. Type Fixed at Creation”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.
2. Two-Column Storage
Section titled “2. Two-Column Storage”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.
3. Content Field Dual Purpose
Section titled “3. Content Field Dual Purpose”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.
4. Zero-Copy for Markdown
Section titled “4. Zero-Copy for Markdown”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.
Key Code Paths
Section titled “Key Code Paths”| Scenario | Entry Point |
|---|---|
| Create image block | InsertBlockMenu.insertImageBlock() -> create_block command |
| Drag-drop image | ImageBlockDrop plugin handleDrop() |
| Paste image | ImageBlockDrop plugin handlePaste() |
| FTS5 indexing | BlockContentType::searchable_text() |
| Markdown export | BlockContentType::to_markdown() |
| Storage round-trip | BlockContentType::from_stored() / as_str() + metadata_json() |
| Metadata update | UpdateBlockMetadataUseCase::execute() |
Typed block variants for pages. See also: Attachment System (image blocks reference attachments), Page System.
Was this page helpful?
Thanks for your feedback!