Skip to content

Instantly share code, notes, and snippets.

@bluntbrain
Last active May 18, 2026 02:18
Show Gist options
  • Select an option

  • Save bluntbrain/62e315711075000133e43f5ee6dbee80 to your computer and use it in GitHub Desktop.

Select an option

Save bluntbrain/62e315711075000133e43f5ee6dbee80 to your computer and use it in GitHub Desktop.
Talkamore: Canvas Journal Editor with Pretext + BlockNote — Frontend Implementation Plan (v3: added document-flow + float-intent responsive model)

Frontend: Canvas-Based Journal Editor with Pretext + BlockNote

Overview

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.

Architecture

┌──────────────────────────────────────────┐
│  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)   │
└──────────────────────────────────────────┘

Why This Architecture

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.


⚠️ Critical Design Rule: Document Flow + Float Intent (NEVER Fixed Coordinates)

The Problem This Solves

If image positions are stored as fixed pixel coordinates (x: 200, y: 450), the journal breaks when viewed on a different screen size:

  1. User writes on mobile (375px wide), places image next to paragraph 2
  2. Image saved at coordinates relative to 375px canvas
  3. User opens same journal on desktop (720px wide)
  4. Text reflows for wider canvas — paragraph 2 moves to Y=200 instead of Y=450
  5. Image stays frozen at original coordinates — now floats next to paragraph 5
  6. Journal looks broken. Image disconnected from its text.

The Solution

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.

Float Intent

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

Responsive Degradation

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;
}

What Gets Saved vs What Gets Calculated

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 vs Mobile (Same Document)

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


Implementation Plan

Phase 1: Pretext Layout Engine (lib/journal-canvas/layout.ts) — Day 1-3

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";
  }
}

Phase 2: Canvas Renderer (lib/journal-canvas/renderer.ts) — Day 2-3

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;
}

Phase 3: JournalCanvasEditor Component — Day 3-5

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>
  );
}

Phase 4: File Upload Hook — Day 4

// 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 };
}

Phase 5: Cursor-to-Block Mapping — Day 5-6

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,
  };
}

Phase 6: Integration with PageCard — Day 6-7

// 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>

Phase 7: Mobile WebView Integration — Day 7

// 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=cover meta tag for notch handling
  • Uses window.postMessage to communicate save state back to native
  • Adjusts toolbar height for safe areas
  • Uses visualViewport API for keyboard avoidance

Implementation Order

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)


Pros and Cons

Pros

  • ✅ 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)

Cons

  • ❌ 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

When to choose this 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

When NOT to choose this approach

  • 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

Files to Create/Edit

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

{
  "dependencies": {
    "@chenglou/pretext": "^0.3.0",
    "@blocknote/core": "^0.47.0"  // already exists
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment