feat: use inline markdown editor (#227)

* Use inline WYSIWYG markdown editor for long text messages

Desktop: replace the Write/Preview tab toggle in the compose card with
MDXEditor, an inline WYSIWYG that renders markdown as you type (Obsidian/
Notion feel) and round-trips plain markdown, matching how particle content
is stored. Immersive mode is unchanged — short, plain notes still get the
centered large-type textarea, and ⌘+M still drops into the rich editor.
Shortcut handling moves to the capture phase so ⌘+Enter / Esc win over the
editor's own key handling.

Mobile: render markdown on the display side via react-native-marked's
useMarkdown hook (no FlatList, so it nests cleanly in the existing
ScrollView). Mirrors desktop's immersive-vs-card logic: short plain notes
stay centered; anything with markdown renders formatted instead of showing
raw syntax. Composer stays plain text.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* cleanup desktop app description

* Fix markdown editor rendering and pasted-link duplication

- Restore heading/list typography in the editor: MDXEditor relies on the
  browser's default styles for <h1>/<ul> sizing, which Tailwind's Preflight
  resets. Markdown shortcuts fired but the block looked unchanged. Style the
  editor content explicitly, mirroring the rendered display.
- Keep pasted/typed URLs as plain text (linkPlugin disableAutoLink) so they
  don't become `[url](url)`, which duplicated both the URL and its preview
  card. Also dedupe extractUrls defensively so a repeated URL yields one card.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* Complete GFM support: tables, task lists, strikethrough

Editor: add tablePlugin so markdown tables round-trip and render. Task
lists (`- [ ]`), strikethrough, code (inline + fenced), headings, lists,
quotes, and links already work via listsPlugin/core/markdownShortcut. CSS:
suppress the disc bullet on task-list items (they draw their own checkbox)
and style tables.

Display: render task-list checkboxes (no stray bullet), GFM tables, and
strikethrough so the read view matches what the editor produces.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* Unify markdown on one engine (Milkdown Crepe)

Replace the two-engine setup (MDXEditor for editing + react-markdown for
display) with a single Crepe-based component used for both. Editing is inline
WYSIWYG with full GFM that actually works — task lists, tables, and code
blocks via type-to-create / the slash menu — and read-only mode renders the
exact same way, so write and read can no longer drift (the root cause of the
heading/list/table/checkbox bugs).

- markdown-editor.tsx: Crepe wrapper supporting edit + readOnly, themed to the
  glass card via --crepe-* variable overrides (scoped to .milkdown so they win
  over frame-dark's own definitions).
- text-particle-view.tsx: render the message card with the read-only editor;
  drop the react-markdown component map.
- Remove now-unused deps: @mdxeditor/editor, react-markdown, remark-gfm,
  rehype-highlight, highlight.js.

The compose immersive mode (short, centered plain text) is unchanged.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* Fix Crepe slash-menu transparency and match the app font

The floating menus (slash menu, toolbar, link tooltip) portal to <body>,
outside .milkdown, so the --crepe-* overrides never reached them and their
background fell back to transparent — unreadable over the content. Declare the
palette on those selectors too, keep --crepe-color-surface (near-)opaque so the
menus read clearly, and add a backdrop blur.

Also set --crepe-font-default/title to inherit (and a monospace stack for code)
so the editor uses the application font instead of Crepe's bundled Noto faces.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* Give the slash menu the full card by padding the editor, not the card

Crepe appends the slash menu to .milkdown and (in this version) doesn't expose
a way to portal it to <body>, so it's clipped by the editor's scroll box. That
box sat inside the card's p-5 padding, so the menu cut off at the padding edge.
Move the padding onto the ProseMirror content instead and drop the redundant
inner scroll container, so the menu's clipping bounds become the full card.

https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4

* tweaks

* mobile: render markdown text consistent with desktop

---------

Co-authored-by: Claude <[email protected]>
This commit was merged in pull request #227.
This commit is contained in:
Arjun Patel
2026-05-30 11:10:49 -07:00
committed by GitHub
co-authored by Claude
parent 7b000429e9
commit 2eba834838
10 changed files with 1636 additions and 492 deletions
@@ -0,0 +1,77 @@
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
* properties; we override those to white-on-transparent with a blue accent so
* the editor (and the read-only display, which uses the same engine) sits on
* top of the existing card instead of painting its own opaque background.
*
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
* selectors too, otherwise they fall back to transparent and the menu is
* unreadable over the content behind it. */
.llink-crepe .milkdown,
.milkdown-slash-menu,
.milkdown-toolbar,
.milkdown-link-edit,
.milkdown-link-preview,
.milkdown-block-handle {
--crepe-color-background: transparent;
--crepe-color-on-background: rgb(255 255 255 / 0.92);
/* Surface backs code blocks, tables, and the floating menus — keep it
* (near-)opaque so menus read clearly over content. */
--crepe-color-surface: rgb(24 24 28 / 0.96);
--crepe-color-surface-low: rgb(42 42 50 / 0.96);
--crepe-color-on-surface: #ffffff;
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65);
--crepe-color-outline: rgb(255 255 255 / 0.2);
--crepe-color-primary: #60a5fa;
--crepe-color-secondary: rgb(96 165 250 / 0.25);
--crepe-color-on-secondary: #ffffff;
--crepe-color-inverse: #ffffff;
--crepe-color-on-inverse: #0b0b0b;
--crepe-color-inline-code: #fca5a5;
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
--crepe-color-error: #f87171;
--crepe-color-hover: rgb(255 255 255 / 0.08);
--crepe-color-selected: rgb(96 165 250 / 0.25);
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
--crepe-font-default: inherit;
--crepe-font-title: inherit;
--crepe-font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
}
/* Glassy floating menus over content. */
.milkdown-slash-menu,
.milkdown-toolbar,
.milkdown-link-edit,
.milkdown-link-preview {
backdrop-filter: blur(12px);
}
.llink-crepe .milkdown {
background: transparent;
box-shadow: none;
}
.llink-crepe .milkdown .ProseMirror {
padding: 0;
outline: none;
}
/* Editing context: fill the compose card and scroll internally so a long
* message stays inside the card rather than growing the whole overlay. */
.llink-crepe--fill,
.llink-crepe--fill .milkdown {
height: 100%;
}
.llink-crepe--fill .milkdown {
overflow-y: auto;
}
/* Pad the content (not the card) so the slash menu — which Crepe appends to
* .milkdown — can use the full card width/height before clipping. */
.llink-crepe--fill .milkdown .ProseMirror {
min-height: 100%;
padding: 1.25rem;
}
@@ -0,0 +1,103 @@
import { useEffect, useRef } from "react";
import { Crepe } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame-dark.css";
import "./markdown-editor.css";
import { cn } from "@/lib/utils";
interface MarkdownEditorProps {
/** Initial markdown. The editor owns its content after mount; edits flow out
* through `onChange`, so this is only read when the editor is (re)created. */
value: string;
onChange?: (markdown: string) => void;
placeholder?: string;
/** Render the same engine read-only, for displaying a message. */
readOnly?: boolean;
autoFocus?: boolean;
className?: string;
}
/**
* Single markdown engine used for both composing and displaying messages
* (Milkdown Crepe). Editing is inline/WYSIWYG (Obsidian/Notion feel) with full
* GFM — headings, lists, task lists, tables, code blocks, quotes, links — and
* read-only mode renders the exact same way, so write and read never drift.
*/
export function MarkdownEditor({
value,
onChange,
placeholder,
readOnly = false,
autoFocus = true,
className,
}: MarkdownEditorProps) {
const rootRef = useRef<HTMLDivElement>(null);
// Keep the latest onChange without forcing the editor to be recreated.
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
let cancelled = false;
let created: Crepe | null = null;
const crepe = new Crepe({
root,
defaultValue: value,
features: {
[Crepe.Feature.CodeMirror]: true,
[Crepe.Feature.ListItem]: true,
[Crepe.Feature.Table]: true,
[Crepe.Feature.LinkTooltip]: !readOnly,
[Crepe.Feature.Cursor]: !readOnly,
[Crepe.Feature.BlockEdit]: !readOnly,
[Crepe.Feature.Toolbar]: !readOnly,
[Crepe.Feature.Placeholder]: !readOnly,
[Crepe.Feature.ImageBlock]: false,
[Crepe.Feature.Latex]: false,
[Crepe.Feature.TopBar]: false,
[Crepe.Feature.AI]: false,
},
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: placeholder ?? "" },
},
});
crepe.setReadonly(readOnly);
if (!readOnly) {
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
onChangeRef.current?.(markdown);
});
});
}
crepe.create().then(() => {
if (cancelled) {
crepe.destroy();
return;
}
created = crepe;
if (autoFocus && !readOnly) {
root.querySelector<HTMLElement>(".ProseMirror")?.focus();
}
});
return () => {
cancelled = true;
created?.destroy();
};
// `value` is the initial content only; recreate when the mode flips.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readOnly]);
return (
<div
ref={rootRef}
className={cn("llink-crepe", !readOnly && "llink-crepe--fill", className)}
/>
);
}
+30 -121
View File
@@ -6,10 +6,7 @@ import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
import { AttachmentStrip } from "@/features/compose/attachment-strip";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { Button } from "@/components/ui/button";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
import { MarkdownEditor } from "@/features/compose/markdown-editor";
export interface TextEditorAttachmentProps {
attachments: PendingAttachment[];
@@ -43,66 +40,6 @@ function getImmersiveTextStyle(length: number) {
return { size: "text-2xl", weight: "font-normal" };
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
export function TextEditor({
textContent,
onTextChange,
@@ -112,7 +49,6 @@ export function TextEditor({
attachmentProps,
}: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [previewMode, setPreviewMode] = useState(false);
const [forceCardMode, setForceCardMode] = useState(false);
const [debouncedText, setDebouncedText] = useState(textContent);
@@ -127,33 +63,35 @@ export function TextEditor({
const immersive =
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
// Keep the immersive textarea focused with the caret at the end when we
// (re)enter it. The card-mode editor manages its own focus.
useEffect(() => {
if (!previewMode) {
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
}
}, 0);
return () => clearTimeout(t);
}
}, [previewMode, forceCardMode, immersive]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
if (!immersive) return;
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
}
}, 0);
return () => clearTimeout(t);
}, [immersive]);
// Attached in the capture phase so our shortcuts win before the card-mode
// editor's own key handling (e.g. ⌘+Enter must submit, not insert a break).
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
if (textContent.trim()) onSubmit();
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
setForceCardMode(true);
}
},
@@ -250,51 +188,22 @@ export function TextEditor({
isDragging && "ring-2 ring-inset ring-white/30",
)}
{...dropZoneProps}
onKeyDown={handleKeyDown}
onKeyDownCapture={handleKeyDown}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
<button
type="button"
onClick={() => setPreviewMode(false)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
!previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Write
</button>
<button
type="button"
onClick={() => setPreviewMode(true)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Preview
</button>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl">
{/* No padding here: the editor's own scroll box hosts the slash menu,
so we pad inside the editor (ProseMirror) instead. That keeps the
menu's clipping bounds the full card rather than the inset box. */}
<div className="min-h-0 flex-1">
<MarkdownEditor
value={textContent}
onChange={onTextChange}
placeholder="Hit / to see options"
/>
</div>
{previewMode ? (
<MarkdownPreview content={textContent} />
) : (
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (markdown supported)"
className="min-h-0 flex-1 resize-none border-none bg-transparent font-mono text-sm leading-relaxed text-white placeholder-white/40 outline-none"
/>
)}
{hasEnrichments && strip && (
<div className="shrink-0 border-t border-white/10 pt-3">
<div className="shrink-0 border-t border-white/10 px-5 py-3">
{strip}
</div>
)}
@@ -14,10 +14,7 @@ import { ParticleAttachments } from "@/features/particles/particle-attachments";
import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useAuthStore } from "@/stores/auth-store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
import { MarkdownEditor } from "@/features/compose/markdown-editor";
type TextParticle = Extract<Particle, { type: "text" }>;
@@ -60,66 +57,6 @@ function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content);
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownContent({ content, className }: { content: string; className?: string }) {
return (
<div className={cn("break-words", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return (
<div className="flex flex-wrap gap-3">
@@ -258,15 +195,16 @@ export function TextParticleView({
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div
className={cn(
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded-2xl bg-white/10 p-6 backdrop-blur-md",
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-6 backdrop-blur-md",
"[&::-webkit-scrollbar]:w-2",
"[&::-webkit-scrollbar]:p-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-white/30",
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
)}
>
<MarkdownContent content={content} className="select-text cursor-text pb-3" />
<MarkdownEditor value={content} readOnly className="select-text pb-3" />
{hasLinks && <LinkPreviews entries={linkPreviews} />}
+3 -1
View File
@@ -10,5 +10,7 @@ export interface LinkMetadata {
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
export function extractUrls(text: string): string[] {
return Array.from(text.matchAll(URL_REGEX), (m) => m[0]);
// Dedupe: a URL repeated in the text (e.g. a `[url](url)` markdown link)
// should only yield a single preview card.
return Array.from(new Set(Array.from(text.matchAll(URL_REGEX), (m) => m[0])));
}