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
This commit is contained in:
Claude
2026-05-29 19:51:54 +00:00
parent 0e0ca86073
commit c10f4dc41d
5 changed files with 848 additions and 1251 deletions
+1 -5
View File
@@ -58,7 +58,7 @@
"dependencies": {
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@mdxeditor/editor": "^4.0.1",
"@milkdown/crepe": "^7.21.1",
"@sentry/electron": "^7.11.0",
"@sentry/react": "^10.54.0",
"@tanstack/react-query": "^5.90.21",
@@ -66,7 +66,6 @@
"clsx": "^2.1.1",
"electron-squirrel-startup": "^1.0.1",
"firebase": "^12.10.0",
"highlight.js": "^11.11.1",
"livekit-client": "^2.18.0",
"lucide-react": "^0.575.0",
"next-themes": "^0.4.6",
@@ -74,11 +73,8 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"react-use": "^17.6.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"shadcn": "^3.8.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -1,155 +1,49 @@
/* Blend MDXEditor into the compose glass card, and restore prose typography.
*
* MDXEditor renders real <h1>/<ul>/<blockquote> elements and leans on the
* browser's default styles for their look — it ships no heading/list
* typography of its own. Tailwind's Preflight resets those defaults
* (headings -> `font-size: inherit`, lists -> `list-style: none`), which would
* otherwise make a heading or bullet look identical to body text. So we style
* the content elements explicitly here, mirroring the rendered display in
* text-particle-view.tsx so "write" and "read" match. */
/* 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. */
.llink-mdxeditor {
--baseBg: transparent;
--basePageBg: transparent;
/* Override the palette on `.milkdown` itself — frame-dark defines these vars
* there, so a lower-specificity ancestor selector would be ignored. */
.llink-crepe .milkdown {
--crepe-color-background: transparent;
--crepe-color-on-background: rgb(255 255 255 / 0.92);
--crepe-color-surface: rgb(255 255 255 / 0.06);
--crepe-color-surface-low: rgb(255 255 255 / 0.1);
--crepe-color-on-surface: #ffffff;
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.6);
--crepe-color-outline: rgb(255 255 255 / 0.25);
--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);
background: transparent;
height: 100%;
box-shadow: none;
}
.llink-mdxeditor [class*="_editorRoot_"] {
background: transparent;
height: 100%;
}
.llink-mdx-content {
padding: 0 !important;
.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%;
color: rgb(255 255 255 / 0.92);
font-size: 0.95rem;
line-height: 1.7;
}
/* Headings */
.llink-mdx-content h1,
.llink-mdx-content h2,
.llink-mdx-content h3,
.llink-mdx-content h4,
.llink-mdx-content h5,
.llink-mdx-content h6 {
color: #fff;
line-height: 1.25;
}
.llink-mdx-content h1 {
font-size: 1.875rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.llink-mdx-content h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.llink-mdx-content h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.llink-mdx-content h4 {
font-size: 1.125rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.llink-mdx-content h5 {
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.llink-mdx-content h6 {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
.llink-crepe--fill .milkdown {
overflow-y: auto;
}
/* Block spacing */
.llink-mdx-content p {
margin-bottom: 0.75rem;
line-height: 1.625;
}
.llink-mdx-content > :last-child {
margin-bottom: 0;
}
/* Inline emphasis */
.llink-mdx-content strong {
font-weight: 600;
color: #fff;
}
.llink-mdx-content em {
font-style: italic;
}
.llink-mdx-content a {
color: #60a5fa;
text-decoration: underline;
}
/* Lists — Preflight strips the marker and indentation, so restore both */
.llink-mdx-content ul,
.llink-mdx-content ol {
margin-bottom: 0.75rem;
padding-left: 1.25rem;
}
.llink-mdx-content ul {
list-style: disc;
}
.llink-mdx-content ol {
list-style: decimal;
}
.llink-mdx-content li {
margin-bottom: 0.25rem;
line-height: 1.625;
}
/* Task-list items draw their own checkbox — no disc bullet. */
.llink-mdx-content li[class*="_listItem"] {
list-style: none;
}
/* Blockquote */
.llink-mdx-content blockquote {
margin-bottom: 0.75rem;
padding-left: 1rem;
border-left: 2px solid rgb(255 255 255 / 0.3);
font-style: italic;
color: rgb(255 255 255 / 0.7);
}
/* Inline code */
.llink-mdx-content code {
border-radius: 0.25rem;
background: rgb(255 255 255 / 0.1);
padding: 0.1rem 0.375rem;
font-size: 0.85em;
}
/* Horizontal rule */
.llink-mdx-content hr {
margin: 1rem 0;
border: none;
border-top: 1px solid rgb(255 255 255 / 0.1);
}
/* Tables */
.llink-mdx-content table {
margin-bottom: 0.75rem;
border-collapse: collapse;
width: auto;
}
.llink-mdx-content th,
.llink-mdx-content td {
border: 1px solid rgb(255 255 255 / 0.2);
padding: 0.375rem 0.625rem;
text-align: left;
}
.llink-mdx-content th {
background: rgb(255 255 255 / 0.08);
font-weight: 600;
.llink-crepe--fill .milkdown .ProseMirror {
min-height: 100%;
}
@@ -1,102 +1,103 @@
import { useEffect, useRef } from "react";
import {
MDXEditor,
type MDXEditorMethods,
headingsPlugin,
listsPlugin,
quotePlugin,
thematicBreakPlugin,
linkPlugin,
codeBlockPlugin,
codeMirrorPlugin,
tablePlugin,
markdownShortcutPlugin,
} from "@mdxeditor/editor";
import "@mdxeditor/editor/style.css";
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";
// 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. */
/** 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;
onChange?: (markdown: string) => void;
placeholder?: string;
className?: string;
/** Render the same engine read-only, for displaying a message. */
readOnly?: boolean;
autoFocus?: boolean;
className?: string;
}
/**
* 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.
* 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,
className,
readOnly = false,
autoFocus = true,
className,
}: MarkdownEditorProps) {
const ref = useRef<MDXEditorMethods>(null);
const rootRef = useRef<HTMLDivElement>(null);
// Keep the latest onChange without forcing the editor to be recreated.
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
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]);
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 (
<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(),
// disableAutoLink: keep pasted/typed URLs as plain text rather than
// turning them into `[url](url)`. The compose flow already turns bare
// URLs into link-preview cards, so auto-linking would both duplicate
// the URL in the markdown and double up the preview cards.
linkPlugin({ disableAutoLink: true }),
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
tablePlugin(),
// Headings, lists (incl. `- [ ]` task lists), emphasis, strikethrough,
// inline code, quotes, and links all transform as you type via these
// shortcuts; fenced code blocks and tables are inserted via paste/import.
markdownShortcutPlugin(),
]}
<div
ref={rootRef}
className={cn("llink-crepe", !readOnly && "llink-crepe--fill", className)}
/>
);
}
@@ -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,107 +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>
),
del: ({ children }) => <del className="text-white/70 line-through">{children}</del>,
ul: ({ className, children }) => (
<ul
className={cn(
"mb-3 text-white last:mb-0",
className?.includes("contains-task-list") ? "list-none pl-0" : "list-disc pl-5",
)}
>
{children}
</ul>
),
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ className, children }) => (
<li
className={cn(
"mb-1 leading-relaxed",
className?.includes("task-list-item") && "flex list-none items-start gap-2",
)}
>
{children}
</li>
),
input: ({ type, checked }) =>
type === "checkbox" ? (
<input
type="checkbox"
checked={!!checked}
readOnly
className="mt-1.5 size-3.5 shrink-0 accent-blue-500"
/>
) : null,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="mb-3 overflow-x-auto last:mb-0">
<table className="w-auto border-collapse text-sm text-white">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-white/20 bg-white/10 px-2.5 py-1.5 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-white/20 px-2.5 py-1.5">{children}</td>
),
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">
@@ -307,7 +203,7 @@ export function TextParticleView({
"[&::-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} />}
+729 -919
View File
File diff suppressed because it is too large Load Diff