Skip to content

Instantly share code, notes, and snippets.

@bluntbrain
Created May 18, 2026 02:09
Show Gist options
  • Select an option

  • Save bluntbrain/1144b3e27d78a95fadb50e01afa216ef to your computer and use it in GitHub Desktop.

Select an option

Save bluntbrain/1144b3e27d78a95fadb50e01afa216ef to your computer and use it in GitHub Desktop.
Talkamore: Responsive Canvas Journal — Document Flow & Float Intent Model (solves cross-device image positioning)

Responsive Canvas Journal: Document Flow & Float Intent Model

The Problem: Fixed Coordinates Break Across Devices

If you store image positions as pixel coordinates (x: 200, y: 450), here's what happens:

  1. User writes a journal entry on mobile (375px wide), places an image next to paragraph 2
  2. Image is saved at coordinates relative to the 375px canvas
  3. User opens the same journal on desktop (720px wide)
  4. Text reflows for the wider canvas — paragraph 2 is now at Y=200 instead of Y=450
  5. Image stays frozen at the original coordinates (y: 450) — now lands next to paragraph 5
  6. Journal looks broken. Image is disconnected from the text it belongs to.
MOBILE (write time)              DESKTOP (view later)
┌──────────────────┐             ┌──────────────────────────┐
│ Para 1: text...  │             │ Para 1: text text text... │
│ Para 2: text...  │  ╔══════╗  │ Para 2: text text text... │
│                  │  ║ IMG  ║  │ Para 3: text text text... │
│ Para 3: text...  │  ║      ║  │                           │ ← image frozen here
│ Para 4: text...  │  ╚══════╝  │         ╔══════╗          │   belongs to Para 2!
│ Para 5: text...  │             │         ║ IMG  ║          │
│                  │             │         ║      ║          │
│                  │             │         ╚══════╝          │
│                  │             │ Para 4: text text text... │
│                  │             │ Para 5: text text text... │
└──────────────────┘             └──────────────────────────┘
         ✓                              ✗ BROKEN

The Solution: Document Flow + Float Intent

Don't store WHERE the image is (coordinates). Store WHERE the image BELONGS (position in the document tree) and HOW it should behave (float intent). Let the layout engine calculate pixel positions fresh on every render.

Document Flow

The document is a linear array of blocks. The order in the array IS the top-to-bottom order in the document.

// This is what gets saved to the database
const document = [
  { type: "paragraph", id: "p1", content: "Today was a good day..." },
  { type: "paragraph", id: "p2", content: "The sun came out and I..." },
  { type: "floatingImage", id: "img1", props: { src: "...", float: "left", maxWidth: 0.4 } },
  { type: "paragraph", id: "p3", content: "I've been thinking about..." },
  { type: "paragraph", id: "p4", content: "Maybe it's time to..." },
];

Key property: img1 ALWAYS sits between p2 and p3. On every device. Forever. No coordinates stored.

Float Intent

An image's float intent describes HOW it should position itself within the text column. It's a REQUEST, not a pixel command.

Intent Behavior Best for
"left" Image hugs left edge, text wraps on right side Photos, illustrations
"right" Image hugs right edge, text wraps on left side Pull quotes, accent images
"center" Image centered, text above and below only Screenshots, diagrams, charts
"full" Image takes full column width, text always above/below Hero images, panoramas

Each intent also has:

  • maxWidth: as a fraction of available width (0.4 = 40%) or fixed pixel value
  • margin: whitespace around the image (text never touches edges)
  • minTextWidth: minimum width for text beside the image (~120px). If there's less room than this, the float degrades to "full".

Pixel Coordinates Are Calculated Fresh Every Render

// NEVER stored. Calculated dynamically by the layout engine.
interface CalculatedPosition {
  x: number;      // derived from float intent + page padding
  y: number;      // derived from position in document flow
  width: number;  // derived from maxWidth × available pageWidth
  height: number; // derived from image aspect ratio × calculated width
}

The Layout Algorithm: Step by Step

Inputs

  • blocks[] — document array (same on all devices)
  • pageWidth — available canvas width (varies per device)
  • lineHeight — typographic line height (constant)

State

  • currentY — vertical cursor, starts at page top padding
  • obstacles[] — list of active image rectangles that text must flow around

Algorithm

function layoutDocument(blocks, pageWidth):
    currentY = PAGE_PADDING_TOP
    obstacles = []
    lines = []

    for each block in blocks:
    
        if block is PARAGRAPH or HEADING:
            text = extractText(block)
            prepared = prepareWithSegments(text, font)
            cursor = { segmentIndex: 0, graphemeIndex: 0 }
            
            while true:
                // ← THE KEY: check available width at THIS y-position
                availableWidth = pageWidth - PAGE_PADDING_X * 2
                xOffset = 0
                
                for each obstacle in obstacles:
                    if currentY is inside obstacle's vertical range:
                        availableWidth -= (obstacle.width + obstacle.margin)
                        if obstacle is left-floated:
                            xOffset = obstacle.x + obstacle.width + obstacle.margin
                
                // If available width is too narrow for readable text
                if availableWidth < MIN_TEXT_WIDTH:
                    // Move past the obstacle entirely
                    currentY = max(all obstacle bottom edges) + margin
                    continue  // retry line at new Y
                
                range = layoutNextLineRange(prepared, cursor, availableWidth)
                if range is null: break  // no more lines
                
                line = materializeLineRange(prepared, range)
                lines.push({ text: line.text, x: PAGE_PADDING_X + xOffset, y: currentY })
                
                cursor = range.end
                currentY += lineHeight
            
            currentY += PARAGRAPH_GAP  // space between blocks
        
        if block is FLOATING_IMAGE:
            // Calculate image dimensions from intent + available width
            imgWidth = min(
                block.props.maxWidth * pageWidth,
                image.naturalWidth * devicePixelRatio
            )
            imgHeight = imgWidth * (image.naturalHeight / image.naturalWidth)
            
            // Check if float is feasible at this width
            effectiveFloat = block.props.float
            remainingWidth = pageWidth - PAGE_PADDING_X * 2 - imgWidth - block.props.margin
            
            if (effectiveFloat === "left" || effectiveFloat === "right"):
                if remainingWidth < MIN_TEXT_WIDTH:
                    effectiveFloat = "full"  // degrade — not enough room for text
            
            // Register obstacle based on effective float
            if effectiveFloat === "left":
                obstacles.push({
                    x: PAGE_PADDING_X,
                    y: currentY,
                    width: imgWidth,
                    height: imgHeight,
                    margin: block.props.margin,
                })
            else if effectiveFloat === "right":
                obstacles.push({
                    x: pageWidth - PAGE_PADDING_X - imgWidth,
                    y: currentY,
                    width: imgWidth,
                    height: imgHeight,
                    margin: block.props.margin,
                })
            // "center" and "full" don't create side obstacles — text goes above/below
            
            currentY += imgHeight + block.props.margin * 2 + PARAGRAPH_GAP
    
    return { lines, totalHeight: currentY + PAGE_PADDING_BOTTOM }

Visual Walkthrough: Same Document, Two Device Widths

Desktop (pageWidth = 680px)

Block 0: Paragraph "Today was a good day. The sun came out..."

Y=40  ████████████████████████████████  full width (no obstacles)
Y=64  ████████████████████████████████
Y=88  ████████████████████████████████
Y=112 ████████████████████████████████

Block 1: Paragraph "I felt like things were finally clicking into place."

Y=136 ████████████████████████████████

--- IMAGE INSERTED HERE (between blocks) ---

Block 2: Image { float: "left", maxWidth: 0.4, margin: 16 }

     → float feasible? 680 - 80(padding) - 272(image) - 16(margin) = 312px for text ✓
     → registered as obstacle: { x: 40, y: 160, width: 272, height: 340 }
     → currentY advances to 160 (image top)
     → after image: currentY = 160 + 340 + 16 + 12 = 528

Block 3: Paragraph "The project that had been stuck for weeks finally moved..."

Y=160 ████████████ ┌────────────┐  ← collision! narrowed to 352px
Y=184 ████████████ │            │
Y=208 ████████████ │   PHOTO    │
Y=232 ████████████ │            │
Y=256 ████████████ │   272×340  │
Y=280 ████████████ │            │
Y=304 ████████████ │            │
Y=328 ████████████ │            │
Y=352 ████████████ │            │
Y=376 ████████████ └────────────┘
Y=400 ████████████                  ← still in margin zone
......
Y=528 ████████████████████████████  ← obstacle clear! Full width resumes

Block 4: Paragraph "I've been thinking about what happened..."

Y=552 ████████████████████████████  ← full width
Y=576 ████████████████████████████

Text wraps BESIDE the image for ~14 lines, then stretches back to full width once past the image. The image is visually paired with Block 3.

Mobile (pageWidth = 335px)

Block 0: Paragraph "Today was a good day..."

Y=40  █████████████  full width
Y=64  █████████████
Y=88  █████████████
Y=112 █████████████
Y=136 █████████████

Block 1: Paragraph "I felt like things were..."

Y=160 █████████████

--- IMAGE INSERTED HERE ---

Block 2: Image { float: "left", maxWidth: 0.4, margin: 16 }

     → 0.4 × 335 = 134px image width
     → Remaining: 335 - 40(padding) - 134(image) - 16(margin) = 145px for text
     → 145px > MIN_TEXT_WIDTH(120px)? YES — but barely.
     
     Actually, let's be more realistic. With 20px page padding on each side
     and the image at 134px:
     
     Remaining = 335 - 40 - 134 - 16 = 145px → Float IS feasible
     
     But if the image is larger:
     maxWidth: 0.5 → 167px image width
     Remaining = 335 - 40 - 167 - 16 = 112px < 120px → FLOAT DEGRADED to "full"

--- CASE A: Float works (barely) ---

Block 3: Paragraph "The project that had been stuck..."

Y=184 ████ ┌──────────┐  ← narrowed to 145px (tight but readable)
Y=208 ████ │          │
Y=232 ████ │  PHOTO   │
Y=256 ████ │ 134×170  │
Y=280 ████ │          │
Y=304 ████ │          │
Y=328 ████ └──────────┘
Y=352 ████               ← still in margin
Y=376 █████████████████  ← full width! Image clear

--- CASE B: Float degrades to full ---

Y=184 ┌──────────────┐  ← image takes full width
      │              │
      │    PHOTO     │
      │   335×250    │
      │              │
      └──────────────┘
Y=458 ███████████████  ← text resumes below
Y=482 ███████████████

In both cases, the image stays between Block 1 and Block 3 in the document. The semantic relationship is preserved. Only the visual wrapping behavior adapts to the screen.


Responsive Breakpoints for Image Sizing

// lib/journal-canvas/responsive.ts

export interface ResponsiveImageConfig {
  float: "left" | "right" | "center" | "full";
  maxWidth: number;         // fraction of pageWidth (0.2 - 1.0)
  minWidth: number;         // minimum pixel width before degrading
  minTextWidth: number;     // minimum pixels for text beside image (default 120)
}

export function resolveImageLayout(
  config: ResponsiveImageConfig,
  pageWidth: number,
  pagePadding: number,
  imageNaturalWidth: number,
  imageNaturalHeight: number,
): {
  effectiveFloat: "left" | "right" | "center" | "full";
  displayWidth: number;
  displayHeight: number;
  textBeside: boolean;  // true if text can flow alongside
} {
  const availableWidth = pageWidth - pagePadding * 2;
  
  // Calculate display size
  const maxPixelWidth = config.maxWidth * availableWidth;
  const displayWidth = Math.min(maxPixelWidth, imageNaturalWidth);
  const displayHeight = displayWidth * (imageNaturalHeight / imageNaturalWidth);
  
  // Check if float is feasible
  if (config.float === "left" || config.float === "right") {
    const remainingForText = availableWidth - displayWidth - config.margin * 2;
    if (remainingForText >= config.minTextWidth) {
      return {
        effectiveFloat: config.float,
        displayWidth,
        displayHeight,
        textBeside: true,
      };
    }
  }
  
  // Float not feasible — degrade to full-width
  return {
    effectiveFloat: "full",
    displayWidth: availableWidth,
    displayHeight: availableWidth * (imageNaturalHeight / imageNaturalWidth),
    textBeside: false,
  };
}

Recommended breakpoint strategy

┌─────────────────────────────────────────────────────────┐
│ Screen Width    │ Image Behavior                        │
├─────────────────┼───────────────────────────────────────┤
│ > 640px         │ Float left/right at 40% width         │
│                 │ Text wraps beside image               │
├─────────────────┼───────────────────────────────────────┤
│ 420px - 640px   │ Float at 35% width                    │
│                 │ Text wraps (narrower)                 │
├─────────────────┼───────────────────────────────────────┤
│ < 420px         │ Image degrades to full width          │
│                 │ Text stacks above and below           │
└─────────────────────────────────────────────────────────┘

What Gets Saved vs What Gets Calculated

Thing Saved in DB? Changes per device? Example
Block order in array ✅ Yes ❌ Never [p1, p2, img1, p3]
Float intent ✅ Yes ❌ Never "left"
Max width fraction ✅ Yes ❌ Never 0.4
Margin ✅ Yes ❌ Never 16
Image URL ✅ Yes ❌ Never https://cdn...
Line widths ❌ No ✅ Every render 352px → 145px
Line breaks ❌ No ✅ Every render "The project" → "The\nproj…"
Pixel X of image ❌ No ✅ Every render 40 → 40
Pixel Y of image ❌ No ✅ Every render 160 → 184
Image display width ❌ No ✅ Every render 272px → 134px
Image display height ❌ No ✅ Every render 340px → 170px
Total document height ❌ No ✅ Every render 1200px → 2800px

Nothing visual is stored. Everything visual is computed.


Database Schema

// Journal.content — BlockNote JSON (jsonb column, unchanged from today)

interface JournalContent {
  title: string;
  mode: "write" | "read";
  pages: Block[][];  // array of pages, each page is an array of blocks
}

type Block = 
  | TextBlock     // paragraph, heading, list item
  | FloatingImageBlock
  | FullWidthImageBlock;

interface FloatingImageBlock {
  type: "floatingImage";
  id: string;
  props: {
    src: string;          // CDN URL
    alt?: string;
    float: "left" | "right" | "center" | "full";  // INTENT only
    maxWidth: number;     // 0.2–1.0, fraction of available width
    margin: number;       // px, whitespace around image
    caption?: string;
  };
  content: [];  // no nested content for image blocks
}

No schema migration needed. This all lives in the existing Journal.content jsonb column. BlockNote natively serializes custom block props to JSON.


Edge Cases Handled

Edge Case How It's Handled
Very narrow screen (< 320px) All floats degrade to "full". Images stack between paragraphs, no side text.
Very wide screen (> 1200px) Images scale up to maxWidth but cap at image.naturalWidth. Text column has plenty of room.
Image taller than text Text wraps beside image until the image ends, then text stretches back to full width.
Image taller than viewport Image is clamped to max(viewport * 0.8, naturalHeight) and scrolls naturally with content.
Multiple images in sequence Each image creates its own obstacle. Text flows between and around them.
Image between two short paragraphs Paragraphs take their natural height. Image obstacle spans the gap between them, wrapping text from the following long paragraph.
User changes float intent on mobile then views on desktop Intent is recalculated on desktop with more generous width. Float that degraded to "full" on mobile may become "left" on desktop.
Image near page bottom Image obstacle is clipped to remaining page height. Text continues below or on next page.
Zooming in/out window.devicePixelRatio is factored into canvas scaling. Layout recalculates on resize event.

Summary

┌──────────────────────────────────────────────────────────┐
│                    THE GOLDEN RULE                        │
│                                                          │
│  Store:      WHAT (blocks) + WHERE IN FLOW (order)       │
│              + HOW TO BEHAVE (float intent)              │
│                                                          │
│  Calculate:  WHERE ON SCREEN (pixels)                    │
│              + HOW WIDE (responsive widths)              │
│              + HOW TALL (layout height)                  │
│                                                          │
│  Never store pixel coordinates.                          │
│  Always recalculate layout on every render.              │
└──────────────────────────────────────────────────────────┘

This is the same principle CSS has used since the 1990s for responsive web design — we're just applying it to Canvas rendering with Pretext as the layout engine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment