Implement a unified journal editing surface where Canvas renders the journal page (text + images with proper text-wrapping around images) and BlockNote serves as the document engine (schema, input handling, undo/redo). Pretext handles all text measurement and line routing on the Canvas.
No separate preview mode. The Canvas IS the editing surface.
┌──────────────────────────────────────────┐
│ JournalPage (React component) │
│ │
│ ┌──────────────────────────────────────┐│
│ │ <canvas> — THE journal page ││
│ │ ││
│ │ Text text text text text text text ││
│ │ text text text ┌──────────┐ text ││
│ │ text text text │ │ text ││
│ │ text text text │ IMAGE │ text ││ ← Pretext routes each line
│ │ text text text │ │ text ││ at narrowed width beside
│ │ text text text └──────────┘ text ││ images, full width below
│ │ text text text text text text text ││
│ │ text text text text text text text ││
│ │ ││
│ │ [cursor blink via rAF] ││
│ └──────────────────────────────────────┘│
│ │
│ ┌──────────────────────────────────────┐│
│ │ Hidden <textarea> for keyboard input ││ ← positioned at cursor
│ │ (1x1 px, transparent, auto-focus) ││ position via getBoundingClientRect
│ └──────────────────────────────────────┘│
│ │
│ ┌──────────────────────────────────────┐│
│ │ BlockNote (document engine only) ││
│ │ - useCreateBlockNote() schema ││
│ │ - onChange → triggers canvas re-render││
│ │ - BlockNoteView is NOT rendered ││
│ │ - We only use editor API (document, ││
│ │ insertBlocks, updateBlock, etc.) ││
│ └──────────────────────────────────────┘│
│ │
│ ┌──────────────────────────────────────┐│
│ │ Toolbar (custom, floating) ││
│ │ - Bold, italic, heading ││
│ │ - Insert image ││
│ │ - Persona selector ││
│ │ - Connects to BlockNote commands ││
│ └──────────────────────────────────────┘│
└──────────────────────────────────────────┘
│
│ onChange (content JSON)
▼
┌──────────────────────────────────────────┐
│ PUT /api/drafts/:id │
│ (same as today — BlockNote JSON blob) │
└──────────────────────────────────────────┘
Problem today: Images inserted into the journal editor (BlockNote) are full-width block elements. Text cannot flow around them — the space to the left and right of images is wasted, creating a restrictive editing experience. Images also overlap text when placed on the visual journal page.
Why Pretext: Pretext (@chenglou/pretext) by Cheng Lou is a pure-JS text measurement and layout library. It replaces DOM-based text measurement with canvas-ground-truthed glyph widths. Its layoutNextLineRange() API is purpose-built for flowing text around obstacles at varying line widths — exactly what we need.
Why BlockNote as engine: We don't rebuild an editor from scratch. BlockNote handles the hard parts (document model, input processing, undo stack, block schema) and we replace only the rendering layer.
Why Canvas: DOM-based text wrapping around floated images is limited (can't wrap on both sides, can't do non-rectangular obstacles, inconsistent across browsers). Canvas gives pixel-perfect control.
If image positions are stored as fixed pixel coordinates (x: 200, y: 450), the journal breaks when viewed on a different screen size:
- User writes on mobile (375px wide), places image next to paragraph 2
- Image saved at coordinates relative to 375px canvas
- User opens same journal on desktop (720px wide)
- Text reflows for wider canvas — paragraph 2 moves to Y=200 instead of Y=450
- Image stays frozen at original coordinates — now floats next to paragraph 5
- Journal looks broken. Image disconnected from its text.
Don't store WHERE the image is (pixel coordinates). Store WHERE the image BELONGS (position in the document block array) and HOW it should behave (float intent). Let Pretext calculate all pixel positions dynamically on every render.
Images are stored as blocks in the document array between text blocks:
// This is what gets saved to the database — no pixel coordinates anywhere
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", // ← Image as a BLOCK between p2 and p3
id: "img1",
props: {
src: "https://cdn.talkamore.com/...",
float: "left", // INTENT, not coordinate
maxWidth: 0.4, // 40% of available width
margin: 16,
},
},
{ type: "paragraph", id: "p3", content: "I've been thinking about..." },
];img1 ALWAYS sits between p2 and p3 in the document. On every device. Forever.
An image's float intent describes HOW it should position itself. 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 |
"full" |
Image takes full column width, text always above/below | Hero images, panoramas |
If the screen is too narrow for text beside an image, the float degrades gracefully:
const MIN_TEXT_WIDTH = 120; // minimum px for readable text beside an image
function resolveFloat(
float: string,
imageWidth: number,
availableWidth: number,
): string {
if (float === "left" || float === "right") {
const remainingForText = availableWidth - imageWidth;
if (remainingForText < MIN_TEXT_WIDTH) {
return "full"; // Degrade — not enough room for side text
}
}
return float;
}| Thing | Saved in DB? | Changes per device? |
|---|---|---|
| Block order in array | ✅ Yes | ❌ Never |
| Float intent | ✅ Yes | ❌ Never |
| Max width fraction | ✅ Yes | ❌ Never |
| Pixel X of image | ❌ No | ✅ Every render |
| Pixel Y of image | ❌ No | ✅ Every render |
| Image display width | ❌ No | ✅ Every render |
| Line widths | ❌ No | ✅ Every render |
| Line breaks | ❌ No | ✅ Every render |
| Total document height | ❌ No | ✅ Every render |
Nothing visual is stored. Everything visual is computed fresh.
Desktop (680px wide): Mobile (335px wide):
Today was a good day. The sun ┌────────┐ Today was a good day.
came out and I felt like │ │ The sun came out and
things were finally clicking │ PHOTO │ I felt like things were
into place. │272×340 │ finally clicking into
│ │ place.
text wraps beside image → │ │
└────────┘ ┌──────────────┐
The project that had been The proj… │ │
stuck for weeks finally stuck for… │ PHOTO │
moved forward and I could │ 335×250 │ ← floats degraded
breathe again. │ │ to full-width
┌────────┐ └──────────────┘
I've been thinking about what │ │
happened. Maybe it's time to │ PHOTO │ I've been thinking
actually commit to the change.│272×340 │ about what happened.
│ │ Maybe it's time to
text wraps beside image → │ │ actually commit to
└────────┘ the change.
Same document. Same blocks. Same float intents. Different visual arrangement because available width changed. But the images stay next to the paragraphs they belong to.
Full deep-dive: https://gist.github.com/bluntbrain/1144b3e27d78a95fadb50e01afa216ef
The core module. Takes BlockNote document JSON + image positions → outputs drawable line segments with correct widths around image obstacles.
// lib/journal-canvas/layout.ts
import {
prepareWithSegments,
layoutNextLineRange,
materializeLineRange,
measureLineStats,
type LayoutCursor,
} from "@chenglou/pretext";
import type { Block } from "@blocknote/core";
// ─── Types ────────────────────────────────────────────────
export interface ImageObstacle {
x: number; // calculated from float intent + page width
y: number; // derived from position in document flow
width: number; // calculated from maxWidth × available pageWidth
height: number; // calculated from image aspect ratio
margin: number; // whitespace around image
float: "left" | "right" | "center" | "full";
src: string; // CDN URL for rendering
loadedImage?: HTMLImageElement; // pre-loaded for canvas drawImage
}
export interface DrawableLine {
text: string;
x: number; // left offset on canvas
y: number; // top offset on canvas
width: number; // actual rendered width (not the column width)
font: string; // CSS font string for canvas ctx.font
color: string; // text color
blockType: "paragraph" | "heading" | "list" | "quote";
}
export interface LayoutResult {
lines: DrawableLine[];
obstacles: ImageObstacle[]; // for canvas rendering
totalHeight: number;
maxLineWidth: number;
}
// ─── Constants (match journal page design) ────────────────
const PAGE_PADDING = 40; // px from canvas edge
const LINE_HEIGHT = 1.6; // multiplier
const BASE_FONT_SIZE = 17; // px
const HEADING_FONT_SIZE = 28; // px
const PARAGRAPH_GAP = 12; // px between paragraphs
// ─── Font definitions ─────────────────────────────────────
const FONTS = {
paragraph: `400 ${BASE_FONT_SIZE}px 'Crimson Text', Georgia, serif`,
heading: `600 ${HEADING_FONT_SIZE}px 'Inter', system-ui, sans-serif`,
list: `400 ${BASE_FONT_SIZE}px 'Crimson Text', Georgia, serif`,
quote: `400 15px 'Crimson Text', Georgia, serif`,
};
// ─── Core layout function ─────────────────────────────────
export function layoutDocument(
blocks: Block[],
pageWidth: number,
dark: boolean,
imageLoaders: Map<string, HTMLImageElement> = new Map(),
): LayoutResult {
const lines: DrawableLine[] = [];
const obstacles: ImageObstacle[] = [];
let currentY = PAGE_PADDING;
const availableWidth = pageWidth - PAGE_PADDING * 2;
for (const block of blocks) {
// ── Image blocks: register as dynamic obstacle ──
if (block.type === "floatingImage") {
const props = (block as any).props;
if (!props?.src) continue;
// Calculate image dimensions from float intent + current page width
const imgWidth = Math.min(
props.maxWidth * availableWidth,
imageLoaders.get(props.src)?.naturalWidth ?? availableWidth,
);
const naturalImg = imageLoaders.get(props.src);
const imgHeight = naturalImg
? imgWidth * (naturalImg.naturalHeight / naturalImg.naturalWidth)
: imgWidth * 0.75;
// Resolve float — degrade to "full" if not enough room for text
const effectiveFloat = resolveFloat(
props.float ?? "left",
imgWidth,
availableWidth,
);
// Calculate X position from float intent
let obstacleX: number;
if (effectiveFloat === "right") {
obstacleX = pageWidth - PAGE_PADDING - imgWidth;
} else if (effectiveFloat === "left") {
obstacleX = PAGE_PADDING;
} else {
obstacleX = PAGE_PADDING + (availableWidth - imgWidth) / 2;
}
const obstacle: ImageObstacle = {
x: obstacleX,
y: currentY,
width: imgWidth,
height: Math.round(imgHeight),
margin: props.margin ?? 16,
float: effectiveFloat,
src: props.src,
loadedImage: naturalImg,
};
obstacles.push(obstacle);
currentY += Math.round(imgHeight) + (props.margin ?? 16) * 2 + PARAGRAPH_GAP;
continue;
}
// ── Text blocks: layout with obstacle-aware widths ──
const text = extractBlockText(block);
if (!text.trim()) {
currentY += PARAGRAPH_GAP;
continue;
}
const font = getBlockFont(block);
const fontSize = getBlockFontSize(block);
const textColor = dark ? "rgba(237,230,204,0.92)" : "rgba(28,28,28,0.92)";
const blockType = getBlockType(block);
const prepared = prepareWithSegments(text, font);
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
const lineHeight = fontSize * LINE_HEIGHT;
while (true) {
// Per-line obstacle check: does this Y overlap any image?
let lineWidth = availableWidth;
let xOffset = 0;
for (const obs of obstacles) {
const obsTop = obs.y - obs.margin;
const obsBottom = obs.y + obs.height + obs.margin;
if (currentY >= obsTop && currentY <= obsBottom) {
if (obs.float === "left") {
lineWidth = availableWidth - obs.width - obs.margin * 2;
xOffset = obs.x + obs.width + obs.margin;
} else if (obs.float === "right") {
lineWidth = availableWidth - obs.width - obs.margin * 2;
xOffset = 0;
}
}
}
// If remaining width too narrow, skip past obstacles
if (lineWidth < MIN_TEXT_WIDTH && obstacles.length > 0) {
const maxObsBottom = Math.max(
...obstacles.map((o) => o.y + o.height + o.margin),
);
if (currentY < maxObsBottom) {
currentY = maxObsBottom;
continue;
}
}
const range = layoutNextLineRange(prepared, cursor, lineWidth);
if (!range) break;
const line = materializeLineRange(prepared, range);
lines.push({
text: line.text,
x: PAGE_PADDING + xOffset,
y: currentY,
width: lineWidth,
font,
color: textColor,
blockType,
});
cursor = range.end;
currentY += lineHeight;
}
currentY += PARAGRAPH_GAP;
}
return {
lines,
obstacles,
totalHeight: currentY + PAGE_PADDING,
maxLineWidth: availableWidth,
};
}
// ─── Float resolution ─────────────────────────────────────
function resolveFloat(
float: string,
imageWidth: number,
availableWidth: number,
): "left" | "right" | "center" | "full" {
if (float === "left" || float === "right") {
const remainingForText = availableWidth - imageWidth;
if (remainingForText < MIN_TEXT_WIDTH) {
return "full"; // screen too narrow — degrade to full-width
}
}
return float as any;
}
// ─── Block text extraction ─────────────────────────────────
function extractBlockText(block: Block): string {
if (block.content && Array.isArray(block.content)) {
// Walk inline content to extract text
return block.content
.map((node: any) => {
if (node.type === "text") return node.text || "";
if (node.type === "link" && node.content) {
return node.content.map((n: any) => n.text || "").join("");
}
if (node.text) return node.text;
return "";
})
.join("");
}
return "";
}
function getBlockFont(block: Block): string {
switch (block.type) {
case "heading": return FONTS.heading;
case "numberedListItem":
case "bulletListItem": return FONTS.list;
default: return FONTS.paragraph;
}
}
function getBlockFontSize(block: Block): number {
switch (block.type) {
case "heading": return HEADING_FONT_SIZE;
default: return BASE_FONT_SIZE;
}
}
function getBlockType(block: Block): DrawableLine["blockType"] {
switch (block.type) {
case "heading": return "heading";
case "numberedListItem":
case "bulletListItem": return "list";
default: return "paragraph";
}
}Handles the actual Canvas 2D drawing, cursor rendering, and image painting.
// lib/journal-canvas/renderer.ts
import type { DrawableLine, ImageObstacle, LayoutResult } from "./layout";
export interface RenderState {
layout: LayoutResult;
images: ImageObstacle[];
cursorLine: number | null; // line index where cursor is
cursorCharOffset: number | null; // char offset within line
dark: boolean;
}
export function renderCanvas(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
state: RenderState,
) {
const { layout, images, dark } = state;
// ── Clear ──────────────────────────────────
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ── Background ──────────────────────────────
ctx.fillStyle = dark ? "#1a1a1a" : "#faf8f2";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// ── Draw images (BEHIND text) ───────────────
for (const img of images) {
drawImageObstacle(ctx, img);
}
// ── Draw text lines ─────────────────────────
for (const line of layout.lines) {
ctx.font = line.font;
ctx.fillStyle = line.color;
ctx.textBaseline = "top";
ctx.fillText(line.text, line.x, line.y);
}
// ── Draw cursor ─────────────────────────────
if (state.cursorLine !== null && state.cursorLine < layout.lines.length) {
const cursorLine = layout.lines[state.cursorLine];
const cursorX = getCursorX(ctx, cursorLine, state.cursorCharOffset ?? 0);
drawCursor(ctx, cursorX, cursorLine.y, cursorLine.font, dark);
}
}
function drawImageObstacle(ctx: CanvasRenderingContext2D, img: ImageObstacle) {
// Images are pre-loaded as HTMLImageElement by the caller
// This function assumes img has a loadedImage property
// ctx.drawImage(img.loadedImage, img.x, img.y, img.width, img.height);
}
function drawCursor(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
font: string,
dark: boolean,
) {
const fontSize = parseInt(font.match(/(\d+)px/)?.[1] || "17");
ctx.fillStyle = dark ? "rgba(237,230,204,0.8)" : "rgba(28,28,28,0.8)";
ctx.fillRect(x, y + 2, 2, fontSize * 1.2);
}
function getCursorX(
ctx: CanvasRenderingContext2D,
line: DrawableLine,
charOffset: number,
): number {
ctx.font = line.font;
const textBefore = line.text.slice(0, charOffset);
const width = ctx.measureText(textBefore).width;
return line.x + width;
}The main React component that wires everything together.
// components/journal/JournalCanvasEditor.tsx
"use client";
import { useRef, useEffect, useCallback, useState } from "react";
import { useCreateBlockNote } from "@blocknote/react";
import type { Block } from "@blocknote/core";
import { layoutDocument, type ImageObstacle } from "@/lib/journal-canvas/layout";
import { renderCanvas, type RenderState } from "@/lib/journal-canvas/renderer";
import { useMe } from "@/hooks/use-me";
interface Props {
initialContent?: Block[] | PartialBlock[];
onChange?: (blocks: Block[]) => void;
dark?: boolean;
pageWidth?: number;
}
export default function JournalCanvasEditor({
initialContent,
onChange,
dark = false,
pageWidth = 720,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hiddenInputRef = useRef<HTMLTextAreaElement>(null);
const imageLoadersRef = useRef<Map<string, HTMLImageElement>>(new Map());
const cursorRef = useRef({ line: 0, offset: 0 });
const rafRef = useRef<number>(0);
// ── BlockNote as document engine (no BlockNoteView) ──
const editor = useCreateBlockNote({
initialContent: initialContent?.length ? initialContent : undefined,
});
// ── Re-render canvas on content change ──
const doRender = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// High-DPI scaling
const dpr = window.devicePixelRatio || 1;
canvas.width = pageWidth * dpr;
canvas.height = window.innerHeight * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${pageWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
const blocks = editor.document as Block[];
const imageLoaders = imageLoadersRef.current;
const layout = layoutDocument(blocks, pageWidth, dark, imageLoaders);
const state: RenderState = {
layout,
images: layout.obstacles,
cursorLine: cursorRef.current.line,
cursorCharOffset: cursorRef.current.offset,
dark,
};
renderCanvas(ctx, canvas, state);
}, [editor, dark, pageWidth]);
// ── onChange → re-render ──
useEffect(() => {
const unsub = editor.onChange(() => {
doRender();
onChange?.(editor.document as Block[]);
});
return unsub;
}, [editor, doRender, onChange]);
// ── Initial render + resize ──
useEffect(() => {
doRender();
const onResize = () => doRender();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [doRender]);
// ── Cursor blink ──
useEffect(() => {
let visible = true;
const blink = () => {
visible = !visible;
// Toggle cursor visibility via CSS class on canvas overlay
rafRef.current = requestAnimationFrame(blink);
};
rafRef.current = requestAnimationFrame(blink);
return () => cancelAnimationFrame(rafRef.current);
}, []);
// ── Keyboard input → BlockNote ──
const handleInput = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Backspace") {
// Route to BlockNote backspace
editor.deleteBlocks([/* current block id */]);
} else if (e.key === "Enter") {
// Route to BlockNote newline
// Need cursor-to-block mapping
} else {
// Text input: insert at cursor position in the active block
// editor.insertContent([{ type: "text", text: e.key }]);
}
// After any mutation, BlockNote's onChange fires → canvas re-renders
},
[editor],
);
// ── Click on canvas → position cursor ──
const handleCanvasClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const clickY = e.clientY - rect.top;
const clickX = e.clientX - rect.left;
// Map click coordinates to nearest line + char offset
// (reverse of layout — find closest line at clickY, then closest char at clickX)
// This is the trickiest part — requires binary search on layout.lines
// Focus hidden input
hiddenInputRef.current?.focus();
},
[],
);
// ── Image insertion ──
const insertImage = useCallback(
async (file: File) => {
// 1. Upload to /api/journal/upload
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/journal/upload", {
method: "POST",
body: formData,
});
const { url } = await res.json();
// 2. Pre-load image for canvas rendering
const img = new window.Image();
img.src = url;
await new Promise((resolve) => (img.onload = resolve));
imageLoadersRef.current.set(url, img);
// 3. Insert as a floatingImage BLOCK in the BlockNote document
// NO pixel coordinates stored — only float intent + maxWidth
// Pretext will calculate actual position on every render
editor.insertBlocks(
[
{
type: "floatingImage",
props: {
src: url,
float: "left", // INTENT, not coordinate
maxWidth: 0.4, // 40% of available width
margin: 16,
},
},
],
editor.document[0]?.id || "",
"after",
);
doRender();
},
[editor, doRender],
);
return (
<div style={{ position: "relative", width: pageWidth }}>
<canvas
ref={canvasRef}
onClick={handleCanvasClick}
style={{ cursor: "text", borderRadius: 8 }}
/>
<textarea
ref={hiddenInputRef}
onKeyDown={handleInput}
style={{
position: "absolute",
top: 0,
left: 0,
width: 1,
height: 1,
opacity: 0,
pointerEvents: "none",
resize: "none",
}}
autoFocus
/>
</div>
);
}// hooks/use-journal-image-upload.ts
import { useState } from "react";
import { toast } from "sonner";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/avif",
];
export function useJournalImageUpload() {
const [uploading, setUploading] = useState(false);
const upload = async (file: File): Promise<string | null> => {
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error("Unsupported file type. Use JPEG, PNG, WebP, or GIF.");
return null;
}
if (file.size > MAX_FILE_SIZE) {
toast.error("Image too large (max 10MB)");
return null;
}
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/journal/upload", {
method: "POST",
body: formData,
});
if (!res.ok) throw new Error("Upload failed");
const { url } = await res.json();
return url;
} catch {
toast.error("Failed to upload image");
return null;
} finally {
setUploading(false);
}
};
return { upload, uploading };
}This is the hardest part. Canvas clicks need to map back to BlockNote block positions for editing.
// lib/journal-canvas/cursor-mapping.ts
import type { DrawableLine } from "./layout";
export interface CursorPosition {
blockIndex: number;
charOffset: number;
lineIndex: number;
}
/**
* Map a canvas click (x, y) → which block, which line, which char offset.
*
* Strategy: binary search on layout.lines (sorted by y).
* Then measure char widths at that line's font to find char offset.
*/
export function canvasCoordsToCursor(
x: number,
y: number,
lines: DrawableLine[],
ctx: CanvasRenderingContext2D,
): CursorPosition | null {
// Find the closest line by y-coordinate
let bestLine = -1;
let bestDist = Infinity;
for (let i = 0; i < lines.length; i++) {
const dist = Math.abs(lines[i].y - y);
if (dist < bestDist) {
bestDist = dist;
bestLine = i;
}
}
if (bestLine === -1) return null;
const line = lines[bestLine];
ctx.font = line.font;
// Binary search for char offset
let lo = 0;
let hi = line.text.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
const width = ctx.measureText(line.text.slice(0, mid)).width;
if (line.x + width < x) {
lo = mid + 1;
} else {
hi = mid;
}
}
return {
blockIndex: 0, // needs to be resolved from lines array
charOffset: lo,
lineIndex: bestLine,
};
}// app/(journal)/journal/new/PageCard.tsx — changes
// REPLACE this:
// const BlockNoteEditor = dynamic(
// () => import("@/components/journal/BlockNoteEditor"),
// { ssr: false },
// );
// WITH this:
const JournalCanvasEditor = dynamic(
() => import("@/components/journal/JournalCanvasEditor"),
{ ssr: false },
);
// In the render, replace <BlockNoteEditor> with <JournalCanvasEditor>// In the React Native app (separate repo, built by mobile contractor)
import { WebView } from "react-native-webview";
export function JournalEditorScreen() {
return (
<WebView
source={{ uri: "https://app.talkamore.com/journal/new?embed=mobile" }}
// The ?embed=mobile flag tells the web app to:
// - Hide navigation chrome
// - Use mobile-optimized toolbar
// - Handle keyboard avoidance via postMessage
javaScriptEnabled
domStorageEnabled
onMessage={(event) => {
// Handle messages from web: save, close, share
const data = JSON.parse(event.nativeEvent.data);
if (data.type === "save") {
// Journal saved — optional native-side handling
}
}}
/>
);
}The web app detects ?embed=mobile and:
- Adds
viewport-fit=covermeta tag for notch handling - Uses
window.postMessageto communicate save state back to native - Adjusts toolbar height for safe areas
- Uses
visualViewportAPI for keyboard avoidance
| Phase | What | Effort | Dependencies |
|---|---|---|---|
| 1 | Pretext layout engine | 2-3 days | npm i @chenglou/pretext |
| 2 | Canvas renderer | 1-2 days | Phase 1 |
| 3 | JournalCanvasEditor component | 2-3 days | Phase 1, 2 |
| 4 | Image upload hook | 0.5 day | Backend endpoint |
| 5 | Cursor-to-block mapping | 1-2 days | Phase 1, 3 |
| 6 | PageCard integration | 1 day | Phase 3 |
| 7 | Mobile WebView wrapper | 0.5 day | Phase 6 |
Total: ~10-14 days (1 engineer)
- ✅ Perfect text wrapping around images — magazine-quality
- ✅ Text never overlaps images — Pretext handles obstacle routing
- ✅ Single codebase for web + mobile (via WebView)
- ✅ BlockNote handles undo/redo/document model — no reinvention
- ✅ Pretext is pure JS — fast, no DOM reflow during measurement
- ✅ Pixel-perfect rendering across browsers
- ✅ Future: multi-column, pull quotes, drop caps (all Pretext-enabled)
- ❌ No native spellcheck (canvas text can't use browser spellcheck)
- ❌ No native text selection (must implement canvas-based selection)
- ❌ No right-click context menu (must implement custom)
- ❌ Cursor-to-block mapping is nontrivial (5-6 edge cases)
- ❌ Every new editor feature (links, mentions, code blocks) needs Canvas rendering code
- ❌ Debugging harder — can't inspect text in DevTools Elements panel
- ❌ Pretext is a relatively new library (v0.x, limited ecosystem)
- ❌ ~2 weeks of work vs 2 days for the CSS float approach
- Image-text flow is a core product differentiator for the journal
- You're willing to invest in custom rendering for the long term
- The journal's visual quality IS the product
- You need to ship in < 1 week
- You need full accessibility compliance (screen readers)
- You rely heavily on browser text features (spellcheck, find, selection)
- You're unsure if users actually want text wrapping around images
talkamore-frontend/
├── lib/journal-canvas/
│ ├── layout.ts # NEW: Pretext layout engine
│ ├── renderer.ts # NEW: Canvas 2D rendering
│ ├── cursor-mapping.ts # NEW: click-to-block mapping
│ └── index.ts # NEW: barrel export
├── components/journal/
│ ├── JournalCanvasEditor.tsx # NEW: main editor component
│ └── CanvasToolbar.tsx # NEW: floating formatting toolbar
├── hooks/
│ └── use-journal-image-upload.ts # NEW: image upload hook
├── app/(journal)/journal/new/
│ └── PageCard.tsx # EDIT: swap BlockNoteEditor → JournalCanvasEditor
└── package.json # EDIT: add @chenglou/pretext
{
"dependencies": {
"@chenglou/pretext": "^0.3.0",
"@blocknote/core": "^0.47.0" // already exists
}
}