If you store image positions as pixel coordinates (x: 200, y: 450), here's what happens:
- User writes a journal entry on mobile (375px wide), places an image next to paragraph 2
- Image is saved at coordinates relative to the 375px canvas
- User opens the same journal on desktop (720px wide)
- Text reflows for the wider canvas — paragraph 2 is now at Y=200 instead of Y=450
- Image stays frozen at the original coordinates (y: 450) — now lands next to paragraph 5
- 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
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.
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.
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 valuemargin: 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".
// 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
}blocks[]— document array (same on all devices)pageWidth— available canvas width (varies per device)lineHeight— typographic line height (constant)
currentY— vertical cursor, starts at page top paddingobstacles[]— list of active image rectangles that text must flow around
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 }
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.
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.
// 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,
};
}┌─────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────┘
| 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.
// 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 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. |
┌──────────────────────────────────────────────────────────┐
│ 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.