Skip to content

CodeMirror 6 inline Markdown image widgets (Obsidian-style) #7

Description

@chasehuh

Summary

Replace the textarea editor with CodeMirror 6 so Markdown images render inline at the source line (Obsidian Live Preview–style), while keeping Markdown as the single source of truth and preserving Zed-like chrome (gutter, themes, ligatures, scroll-beyond, Tab→spaces, ->).

Yes: for images specifically, CM6 block widgets under ![alt|width](url) is the closest open-source pattern to Obsidian Live Preview. It is not full Obsidian (wikilinks, callouts, graph, vault FS)—only the image spatial/resize contract.

Why This Matters

Paste/upload + Obsidian |width syntax already work, but previews currently render in a detached gallery below the whole document. Research across Zed/Obsidian/Notion/VS Code/Typora shows that pattern is an anti-pattern: best-in-class apps either put pixels on the source line (Obsidian/Typora) or in a preview pane (Zed/VS Code). Detached galleries break the spatial link between markdown and media.

CodeMirror inline widgets fix that while staying Markdown-first (unlike Notion blocks / TipTap as SoT).

Conversation Context

  • Product: password-gated notes app memo (Zed-inspired UI), live at memo.chasehuh.com.
  • Images: paste/drop → Cloudflare Worker → R2 bucket chasehuh-media → CDN https://cdn.chasehuh.com/... → insert ![alt](url) at caret.
  • Resize: corner handle rewrites Obsidian-compatible ![alt|400](url) in the body string.
  • User rejected bottom “media toolbar” chrome (Add image / help text); wants power-user paste/drop only.
  • User then asked for previews again; gallery-below-textarea felt wrong.
  • Research verdict: keep paste + |width SoT; move display to source line via CM6 (or interim split preview).
  • User chose CodeMirror inline and asked for a high-context GitHub mega-issue.
  • Related infra (already deployed, not part of this issue’s code change unless wiring breaks):
    • Worker: https://chasehuh-media-upload.cw-huh.workers.dev (repo sibling ~/chase/media-worker)
    • Env: MEDIA_UPLOAD_URL, MEDIA_UPLOAD_SECRET
  • Open PR branch may exist for image upload (task/image-upload); this issue should land after or on top of that work once merged—Needs verification of PR state at implement time.

Current Behavior

Editor

  • Controlled <textarea class="zed-editor__body"> in components/memo-app.tsx.
  • Scrollport is .zed-buffer with Zed-like scroll_beyond_last_line: one_page via .zed-editor__beyond (app/globals.css).
  • Custom gutter: lib/gutter.ts measureWrappedRowCounts + variable-height .zed-gutter__line.
  • Behaviors already in textarea path:
    • Soft wrap toggle (lib/preferences.ts)
    • Tab → 4 spaces
    • ASCII -> → Unicode (lib/arrows.ts) for Hangul script / HarfBuzz calt limits
    • Font: Lilex as “Zed Mono”, font-feature-settings: "calt" 1
    • Debounced save + BroadcastChannel draft sync (lib/tab-sync.ts)

Images (if image-upload branch / main has landed)

  • POST /api/upload (app/api/upload/route.ts) session-gated; proxies bytes to Worker.
  • lib/media.ts: markdownImage, extractMarkdownImages, withMarkdownImageWidth (Obsidian |width / |WxH).
  • Paste/drop on textarea → upload → insert markdown at caret.
  • Previews: .zed-image-previews below textarea content; components/resizable-image-preview.tsx drag handle updates |width.

Persistence

  • Note body is plain TEXT in Postgres (lib/db.ts / lib/notes.ts). No schema change required for CM6.

Desired Behavior

  1. Editor surface is CodeMirror 6 (not <textarea>), visually still Zed-like (monospace, theme CSS variables, gutter line numbers).
  2. For each ![alt|width?](https://...) in the document:
    • A block widget (or equivalent decoration) renders the image immediately under that markdown line (or replacing the line when caret is elsewhere—Obsidian LP style; pick one and document it).
    • Preferred v1: show markdown line + widget below it when cursor is on/near the line; optionally collapse syntax when cursor is away (P1).
  3. Paste/drop images still upload via /api/upload and insert ![alt](url) (optional default width later) at caret.
  4. Corner drag on the widget rewrites Obsidian |width in the document text (same as today’s withMarkdownImageWidth), rounded/clamped (e.g. 80–1600).
  5. Clicking the preview focuses/selects the corresponding markdown span (Zed preview click-to-source spirit).
  6. Double-click handle (or equivalent) clears |width / resets to default (Obsidian 1.12 pattern)—P1 OK if noted.
  7. Existing non-image behaviors preserved: wrap, Tab spaces, -> ligature substitution strategy, scroll-beyond, theme tokens, sync/save.
  8. Remove detached bottom gallery (.zed-image-previews under whole doc) once inline widgets ship.

Source Of Truth

Internal repo/source

  • components/memo-app.tsx — current editor shell, paste/drop, gallery mount, save/sync.
  • components/resizable-image-preview.tsx — resize gesture to port into CM6 widget.
  • lib/media.ts — markdown image parse/serialize (extractMarkdownImages, withMarkdownImageWidth).
  • lib/gutter.ts — wrap measurement; likely replaced or adapted for CM6 line wrapping / decorations.
  • lib/arrows.ts-> substitution; re-home to CM6 transactionFilter / input handler.
  • app/api/upload/route.ts — keep as upload SoT for the web app.
  • app/globals.css.zed-editor__body, .zed-buffer, .zed-gutter, .zed-image-previews*.
  • lib/tab-sync.ts / save effects in memo-app.tsx — body string remains the sync payload.

External docs/source

Proposed API / Schema

No HTTP API change required. Document / body format remains plain text Markdown.

Image embed syntax (SoT in note body)

![alt](https://cdn.chasehuh.com/memo/<uuid>.png)
![alt|480](https://cdn.chasehuh.com/memo/<uuid>.png)
![alt|480x320](https://cdn.chasehuh.com/memo/<uuid>.png)

Upload (existing)

POST /api/upload

  • Auth: session cookie (memo_session) via proxy.ts
  • Body: raw image bytes or multipart/form-data with file
  • Headers (raw): Content-Type: image/png|jpeg|gif|webp|avif
  • Response 201:
{
  "url": "https://cdn.chasehuh.com/memo/<uuid>.png",
  "key": "memo/<uuid>.png"
}
  • Errors: 401 unauthenticated, 415 bad type, 503 media env missing, 500 upstream failure.

Env (unchanged)

Var Purpose
MEDIA_UPLOAD_URL Worker URL
MEDIA_UPLOAD_SECRET Bearer shared with Worker

Implementation Notes

Likely files to modify

  • components/memo-app.tsx — swap textarea for CM6 host; rewire caret/save/sync/paste.
  • app/globals.css — CM6 theme tokens mapped to --c-*; remove gallery chrome; style image widgets + resize handle.
  • lib/gutter.ts / gutter JSX — either use CM6 gutters or keep custom gutter synced to CM6 scroll/heights (Needs verification which is less broken with wrap).
  • lib/arrows.ts usage — move substitution into CM6 update listener / input handler so Hangul-adjacent arrows still normalize.
  • components/resizable-image-preview.tsx — refactor into CM6 widget view, or delete after port.
  • package.json — add @codemirror/view, @codemirror/state, @codemirror/lang-markdown (or minimal markdown parser), @codemirror/commands, etc.

New files

  • components/codemirror-editor.tsx (or lib/editor/*) — CM6 React wrapper (EditorView lifecycle).
  • lib/editor/image-widgets.ts — StateField + Decoration building block widgets for image marks.
  • lib/editor/image-widget-view.ts — WidgetType with <img> + resize handle; calls dispatch to rewrite doc text.
  • lib/editor/paste-images.ts — clipboard/drop handlers calling /api/upload then inserting markdown.
  • Optional: lib/editor/arrow-input.ts-> normalization.

Flow

  1. User types/pastes in CM6; EditorState.doc is the note body string.
  2. Image decoration plugin scans doc (reuse extractMarkdownImages or CM6 syntax tree) → block widgets under matches.
  3. Paste image → /api/upload → insert ![filename](url)\n at selection.
  4. Drag resize handle → compute width → withMarkdownImageWidth / transaction replacing the image mark → widget rebuilds from new text.
  5. Debounced onChange(doc.toString()) feeds existing save + BroadcastChannel draft sync.
  6. Wrap/scroll-beyond: implement via CM6 viewport + padding bottom (port .zed-editor__beyond behavior).

Tests

  • Unit: extractMarkdownImages / withMarkdownImageWidth (already conceptually covered; add vitest or node asserts if repo gains a runner—Needs verification: currently no test runner in package.json).
  • Manual: paste PNG, see widget under line; resize updates |N in source; reload note; Hangul still works; wrap on/off; multi-image notes reorder with text.

Edge Cases And Risks

  • Soft wrap + custom gutter: CM6 wrap changes line geometry; porting measureWrappedRowCounts is non-trivial—prefer CM6 gutters if possible.
  • IME / Korean input: CM6 composition must not break arrow substitution or image paste.
  • Large images / many widgets: widget DOM cost; virtualize or limit concurrent decoded images.
  • CDN / bot challenges: upload Worker + CDN already work from Next server; browser <img> needs normal browser UA (already OK).
  • Sync races: drafts must still be plain strings; widgets must be pure projections of doc text (no dual SoT for width).
  • Accessibility: resize handle keyboard support (arrow keys) is P1.
  • Bundle size: CM6 + markdown adds weight to a currently tiny Next app.

Non-Goals

  • Full Obsidian feature parity (wikilinks, callouts, graph, vault filesystem).
  • TipTap/ProseMirror block document as primary SoT.
  • Split markdown preview pane as the long-term design (acceptable only as temporary bridge PR if CM6 slips).
  • Changing R2/CDN/Worker architecture (unless broken by auth/env).
  • Collaborative CRDT editing.
  • In-buffer rich crop/filters (Notion-style image toolbar beyond width).

Acceptance Criteria

  • Note editing uses CodeMirror 6; no primary <textarea> for the body.
  • ![alt|width](url) images render inline under their source line, not in a document-bottom gallery.
  • Paste/drop upload still inserts markdown at caret and round-trips through /api/upload.
  • Drag-resize updates |width in the document text; reload preserves size.
  • Zed-like behaviors still work: theme tokens, wrap toggle, Tab→spaces, scroll-beyond last line, -> normalization, gutter line numbers.
  • Save + multi-tab draft sync still operate on the plain body string.
  • Detached .zed-image-previews gallery removed (or unused).
  • README notes CM6 + Obsidian image width syntax briefly.

QA Plan

  1. pnpm install && pnpm dev with MEDIA_UPLOAD_* set.
  2. Paste an image into a note → confirm CDN URL markdown + inline widget under that line.
  3. Drag resize → confirm source becomes ![alt|N](url) and preview width matches.
  4. Move the markdown line / type above-below → widget moves with the line.
  5. Soft wrap on long lines + Korean text + lines.
  6. Two-tab draft sync: type in A, see body (including image markdown) in B.
  7. Hard refresh / reopen note: widths persist from markdown only.

Suggested PR Scope

L — prefer 1–2 PRs:

  1. PR A (if needed): land current image upload/textarea gallery work on main first (branch task/image-upload if still open).
  2. PR B (this issue): CM6 migration + inline image widgets + remove gallery; keep upload API.

Do not mix TipTap experiments into the same PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions