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
This commit is contained in:
Claude
2026-05-28 23:32:57 +00:00
parent 3bfc15945d
commit 47c173f472
8 changed files with 1413 additions and 146 deletions
@@ -0,0 +1,33 @@
/* Blend MDXEditor into the compose glass card.
We lean on MDXEditor's bundled `dark-theme` for typography (heading sizes,
list markers, code styling) and only override the chrome: transparent
background, no border, and padding/colors tuned to the surrounding card. */
.llink-mdxeditor {
--baseBg: transparent;
--basePageBg: transparent;
background: transparent;
height: 100%;
}
.llink-mdxeditor [class*="_editorRoot_"] {
background: transparent;
height: 100%;
}
.llink-mdx-content {
padding: 0 !important;
outline: none;
height: 100%;
color: rgb(255 255 255 / 0.92);
font-size: 0.95rem;
line-height: 1.7;
}
.llink-mdx-content :where(h1, h2, h3, h4, h5, h6) {
color: #fff;
}
.llink-mdx-content a {
color: #60a5fa;
}
@@ -0,0 +1,93 @@
import { useEffect, useRef } from "react";
import {
MDXEditor,
type MDXEditorMethods,
headingsPlugin,
listsPlugin,
quotePlugin,
thematicBreakPlugin,
linkPlugin,
codeBlockPlugin,
codeMirrorPlugin,
markdownShortcutPlugin,
} from "@mdxeditor/editor";
import "@mdxeditor/editor/style.css";
import "./markdown-editor.css";
import { cn } from "@/lib/utils";
// Languages offered for fenced code blocks. Kept short — this is a message
// composer, not a code editor — but enough to cover what people usually paste.
const CODE_BLOCK_LANGUAGES = {
"": "Plain text",
text: "Plain text",
bash: "Shell",
json: "JSON",
js: "JavaScript",
ts: "TypeScript",
tsx: "TSX",
py: "Python",
go: "Go",
rust: "Rust",
css: "CSS",
html: "HTML",
sql: "SQL",
};
interface MarkdownEditorProps {
/** Initial markdown. MDXEditor is the source of truth once mounted; changes
* flow out through `onChange`, so this is only read on mount. */
value: string;
onChange: (markdown: string) => void;
placeholder?: string;
className?: string;
autoFocus?: boolean;
}
/**
* Inline WYSIWYG markdown editor (Obsidian/Notion feel): headings, lists,
* emphasis, links, and code blocks render as you type via MDXEditor's
* markdown shortcuts — no separate preview pane. Reads and writes plain
* markdown, matching how particle content is stored.
*/
export function MarkdownEditor({
value,
onChange,
placeholder,
className,
autoFocus = true,
}: MarkdownEditorProps) {
const ref = useRef<MDXEditorMethods>(null);
useEffect(() => {
if (!autoFocus) return;
// Defer to the next tick so the editor is mounted before we focus it.
const t = setTimeout(() => ref.current?.focus(), 0);
return () => clearTimeout(t);
}, [autoFocus]);
return (
<MDXEditor
ref={ref}
markdown={value}
onChange={onChange}
// Defensive: core handles HTML and degrades unknown syntax to text, so
// this is rare — surface it rather than failing silently.
onError={({ source, error }) =>
console.warn("MarkdownEditor parse issue:", error, source)
}
placeholder={placeholder}
contentEditableClassName="llink-mdx-content"
className={cn("dark-theme llink-mdxeditor", className)}
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
linkPlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
markdownShortcutPlugin(),
]}
/>
);
}
+23 -117
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,48 +188,16 @@ 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>
{previewMode ? (
<MarkdownPreview content={textContent} />
) : (
<textarea
ref={textareaRef}
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<MarkdownEditor
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
onChange={onTextChange}
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"
/>
)}
</div>
{hasEnrichments && strip && (
<div className="shrink-0 border-t border-white/10 pt-3">