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  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  at caret.
- Resize: corner handle rewrites Obsidian-compatible
 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
- Editor surface is CodeMirror 6 (not
<textarea>), visually still Zed-like (monospace, theme CSS variables, gutter line numbers).
- For each
 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).
- Paste/drop images still upload via
/api/upload and insert  (optional default width later) at caret.
- Corner drag on the widget rewrites Obsidian
|width in the document text (same as today’s withMarkdownImageWidth), rounded/clamped (e.g. 80–1600).
- Clicking the preview focuses/selects the corresponding markdown span (Zed preview click-to-source spirit).
- Double-click handle (or equivalent) clears
|width / resets to default (Obsidian 1.12 pattern)—P1 OK if noted.
- Existing non-image behaviors preserved: wrap, Tab spaces,
-> ligature substitution strategy, scroll-beyond, theme tokens, sync/save.
- 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)



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
- User types/pastes in CM6;
EditorState.doc is the note body string.
- Image decoration plugin scans doc (reuse
extractMarkdownImages or CM6 syntax tree) → block widgets under matches.
- Paste image →
/api/upload → insert \n at selection.
- Drag resize handle → compute width →
withMarkdownImageWidth / transaction replacing the image mark → widget rebuilds from new text.
- Debounced
onChange(doc.toString()) feeds existing save + BroadcastChannel draft sync.
- 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
QA Plan
pnpm install && pnpm dev with MEDIA_UPLOAD_* set.
- Paste an image into a note → confirm CDN URL markdown + inline widget under that line.
- Drag resize → confirm source becomes
 and preview width matches.
- Move the markdown line / type above-below → widget moves with the line.
- Soft wrap on long lines + Korean text +
→ lines.
- Two-tab draft sync: type in A, see body (including image markdown) in B.
- Hard refresh / reopen note: widths persist from markdown only.
Suggested PR Scope
L — prefer 1–2 PRs:
- PR A (if needed): land current image upload/textarea gallery work on
main first (branch task/image-upload if still open).
- PR B (this issue): CM6 migration + inline image widgets + remove gallery; keep upload API.
Do not mix TipTap experiments into the same PR.
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
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
|widthsyntax 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
memo(Zed-inspired UI), live at memo.chasehuh.com.chasehuh-media→ CDNhttps://cdn.chasehuh.com/...→ insertat caret.in the body string.|widthSoT; move display to source line via CM6 (or interim split preview).https://chasehuh-media-upload.cw-huh.workers.dev(repo sibling~/chase/media-worker)MEDIA_UPLOAD_URL,MEDIA_UPLOAD_SECRETtask/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
<textarea class="zed-editor__body">incomponents/memo-app.tsx..zed-bufferwith Zed-likescroll_beyond_last_line: one_pagevia.zed-editor__beyond(app/globals.css).lib/gutter.tsmeasureWrappedRowCounts+ variable-height.zed-gutter__line.lib/preferences.ts)->→ Unicode→(lib/arrows.ts) for Hangul script / HarfBuzzcaltlimitsfont-feature-settings: "calt" 1lib/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)..zed-image-previewsbelow textarea content;components/resizable-image-preview.tsxdrag handle updates|width.Persistence
TEXTin Postgres (lib/db.ts/lib/notes.ts). No schema change required for CM6.Desired Behavior
<textarea>), visually still Zed-like (monospace, theme CSS variables, gutter line numbers).in the document:/api/uploadand insert(optional default width later) at caret.|widthin the document text (same as today’swithMarkdownImageWidth), rounded/clamped (e.g. 80–1600).|width/ resets to default (Obsidian 1.12 pattern)—P1 OK if noted.->ligature substitution strategy, scroll-beyond, theme tokens, sync/save..zed-image-previewsunder 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 CM6transactionFilter/ 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 inmemo-app.tsx— body string remains the sync payload.External docs/source
Example:
width-only;width×height.Proposed API / Schema
No HTTP API change required. Document / body format remains plain text Markdown.
Image embed syntax (SoT in note
body)Upload (existing)
POST /api/uploadmemo_session) viaproxy.tsmultipart/form-datawithfileContent-Type: image/png|jpeg|gif|webp|avif201:{ "url": "https://cdn.chasehuh.com/memo/<uuid>.png", "key": "memo/<uuid>.png" }401unauthenticated,415bad type,503media env missing,500upstream failure.Env (unchanged)
MEDIA_UPLOAD_URLMEDIA_UPLOAD_SECRETImplementation 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.tsusage — 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(orlib/editor/*) — CM6 React wrapper (EditorViewlifecycle).lib/editor/image-widgets.ts— StateField + Decoration building block widgets for image marks.lib/editor/image-widget-view.ts— WidgetType with<img>+ resize handle; callsdispatchto rewrite doc text.lib/editor/paste-images.ts— clipboard/drop handlers calling/api/uploadthen inserting markdown.lib/editor/arrow-input.ts—->normalization.Flow
EditorState.docis the note body string.extractMarkdownImagesor CM6 syntax tree) → block widgets under matches./api/upload→ insert\nat selection.withMarkdownImageWidth/ transaction replacing the image mark → widget rebuilds from new text.onChange(doc.toString())feeds existing save + BroadcastChannel draft sync..zed-editor__beyondbehavior).Tests
extractMarkdownImages/withMarkdownImageWidth(already conceptually covered; add vitest or node asserts if repo gains a runner—Needs verification: currently no test runner inpackage.json).|Nin source; reload note; Hangul→still works; wrap on/off; multi-image notes reorder with text.Edge Cases And Risks
measureWrappedRowCountsis non-trivial—prefer CM6 gutters if possible.<img>needs normal browser UA (already OK).Non-Goals
Acceptance Criteria
<textarea>for the body.images render inline under their source line, not in a document-bottom gallery./api/upload.|widthin the document text; reload preserves size.->→→normalization, gutter line numbers..zed-image-previewsgallery removed (or unused).QA Plan
pnpm install && pnpm devwithMEDIA_UPLOAD_*set.and preview width matches.→lines.Suggested PR Scope
L — prefer 1–2 PRs:
mainfirst (branchtask/image-uploadif still open).Do not mix TipTap experiments into the same PR.