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:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { Platform, ScrollView, Text, View, type ViewStyle } from "react-native";
|
||||
import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||
@@ -37,6 +38,105 @@ function getImmersiveStyle(length: number) {
|
||||
return { className: "text-2xl font-normal leading-snug" };
|
||||
}
|
||||
|
||||
// Mirrors desktop's text-particle-view: short plain notes get the immersive
|
||||
// centered treatment; anything with markdown syntax renders formatted instead
|
||||
// of showing raw `**asterisks**`. Covers the full GFM set desktop's Crepe
|
||||
// engine handles — including ~~strikethrough~~ and tables — so short formatted
|
||||
// messages drop to the rendered card rather than showing raw syntax.
|
||||
function hasMarkdownFormatting(content: string): boolean {
|
||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|~~|^>|\|.*\|/m.test(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
|
||||
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
|
||||
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
|
||||
// parsing — read-only, matching desktop's bullet-free checkboxes.
|
||||
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
|
||||
|
||||
function withTaskCheckboxes(markdown: string): string {
|
||||
return markdown.replace(
|
||||
TASK_ITEM_RE,
|
||||
(_match, indent: string, mark: string) =>
|
||||
`${indent}${mark === " " ? "☐" : "☑"} `,
|
||||
);
|
||||
}
|
||||
|
||||
// Mirror the desktop Crepe palette (markdown-editor.css `--crepe-*`) so a
|
||||
// message reads the same on both surfaces: white-on-transparent text, a blue
|
||||
// accent, pink inline code, and a near-opaque dark surface behind code blocks
|
||||
// and tables. Defined at module scope so the references stay stable —
|
||||
// `useMarkdown` re-parses only when these or the content change.
|
||||
//
|
||||
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
|
||||
// uses CodeMirror; react-native-marked only exposes the language tag). They
|
||||
// render as plain monospace on the dark surface, which is acceptable for v1.
|
||||
const TEXT_COLOR = "rgba(255,255,255,0.92)";
|
||||
const ACCENT = "#60a5fa";
|
||||
const SURFACE = "rgba(24,24,28,0.96)";
|
||||
const OUTLINE = "rgba(255,255,255,0.2)";
|
||||
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
|
||||
|
||||
const MARKDOWN_THEME = {
|
||||
colors: {
|
||||
text: TEXT_COLOR,
|
||||
link: ACCENT,
|
||||
code: SURFACE,
|
||||
border: OUTLINE,
|
||||
},
|
||||
};
|
||||
|
||||
const MARKDOWN_STYLES: MarkedStyles = {
|
||||
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
||||
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
||||
strong: { fontWeight: "700" },
|
||||
em: { fontStyle: "italic" },
|
||||
strikethrough: {
|
||||
textDecorationLine: "line-through",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
},
|
||||
// fontStyle "normal" cancels react-native-marked's italic-by-default for
|
||||
// links and inline code (desktop renders neither italic).
|
||||
link: { color: ACCENT, fontStyle: "normal" },
|
||||
// borderBottomWidth 0 removes the library's default heading underline rule,
|
||||
// which desktop's headings don't have.
|
||||
h1: { color: "#ffffff", fontSize: 28, lineHeight: 34, fontWeight: "700", marginTop: 8, marginBottom: 8, borderBottomWidth: 0 },
|
||||
h2: { color: "#ffffff", fontSize: 24, lineHeight: 30, fontWeight: "700", marginTop: 8, marginBottom: 6, borderBottomWidth: 0 },
|
||||
h3: { color: "#ffffff", fontSize: 20, lineHeight: 26, fontWeight: "600", marginTop: 6, marginBottom: 4 },
|
||||
h4: { color: "#ffffff", fontSize: 18, lineHeight: 24, fontWeight: "600", marginTop: 6, marginBottom: 4 },
|
||||
h5: { color: "#ffffff", fontSize: 16, lineHeight: 22, fontWeight: "600", marginTop: 4, marginBottom: 2 },
|
||||
h6: { color: "rgba(255,255,255,0.7)", fontSize: 15, lineHeight: 20, fontWeight: "600", marginTop: 4, marginBottom: 2 },
|
||||
codespan: { color: "#fca5a5", fontFamily: MONO, fontStyle: "normal", backgroundColor: "rgba(255,255,255,0.1)" },
|
||||
code: { backgroundColor: SURFACE, borderColor: OUTLINE, borderWidth: 1, borderRadius: 8, padding: 12, marginVertical: 6 },
|
||||
blockquote: { borderLeftWidth: 3, borderLeftColor: OUTLINE, paddingLeft: 12, marginVertical: 6, opacity: 0.85 },
|
||||
// hr is left to the library default, which already draws a 1px rule in the
|
||||
// themed border color (OUTLINE).
|
||||
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
|
||||
tableRow: { borderColor: OUTLINE },
|
||||
tableCell: { borderColor: OUTLINE, padding: 8 },
|
||||
};
|
||||
|
||||
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
|
||||
// text style, so out of the box code renders italic in the body font. Override
|
||||
// `code` to apply a monospace, non-italic style instead — matching desktop's
|
||||
// code blocks. Instantiated once at module scope to keep the reference stable
|
||||
// for `useMarkdown`'s memoization.
|
||||
const CODE_TEXT_STYLE = {
|
||||
color: TEXT_COLOR,
|
||||
fontFamily: MONO,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
};
|
||||
|
||||
class MarkdownRenderer extends Renderer {
|
||||
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
|
||||
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
|
||||
}
|
||||
}
|
||||
|
||||
const MARKDOWN_RENDERER = new MarkdownRenderer();
|
||||
|
||||
export function TextParticleView({
|
||||
particle,
|
||||
paused,
|
||||
@@ -48,6 +148,11 @@ export function TextParticleView({
|
||||
const durationS = computeReadDuration(content);
|
||||
const elapsedRef = useRef(0);
|
||||
const safe = useStreamSafeArea();
|
||||
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
|
||||
renderer: MARKDOWN_RENDERER,
|
||||
theme: MARKDOWN_THEME,
|
||||
styles: MARKDOWN_STYLES,
|
||||
});
|
||||
|
||||
const editedLabel = editedAt ? (
|
||||
<View className="mt-3 items-center">
|
||||
@@ -79,8 +184,10 @@ export function TextParticleView({
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
||||
|
||||
// Immersive (short, plain): centered, large type — feels like a lock-screen note.
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT) {
|
||||
// Immersive (short, plain): centered, large type — feels like a lock-screen
|
||||
// note. Short messages that contain markdown fall through to the rendered
|
||||
// card so formatting isn't shown as raw syntax.
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasMarkdownFormatting(content)) {
|
||||
const style = getImmersiveStyle(content.length);
|
||||
return (
|
||||
<View
|
||||
@@ -100,10 +207,12 @@ export function TextParticleView({
|
||||
);
|
||||
}
|
||||
|
||||
// Long text: scrollable card so the reader can pace themselves; the
|
||||
// duration timer keeps ticking either way, which is intentional —
|
||||
// long messages should still auto-advance at the 15s cap. Padding is
|
||||
// pulled from the StreamSafeArea so the card never slips under chrome.
|
||||
// Long text or markdown: scrollable card so the reader can pace themselves;
|
||||
// the duration timer keeps ticking either way, which is intentional — long
|
||||
// messages should still auto-advance at the 15s cap. Markdown is rendered
|
||||
// via the useMarkdown hook (not the FlatList-based component) so its blocks
|
||||
// nest cleanly inside this ScrollView. Padding is pulled from the
|
||||
// StreamSafeArea so the card never slips under chrome.
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center px-6"
|
||||
@@ -118,7 +227,7 @@ export function TextParticleView({
|
||||
showsVerticalScrollIndicator
|
||||
indicatorStyle="white"
|
||||
>
|
||||
<Text className="text-white text-lg leading-relaxed">{content}</Text>
|
||||
{markdownNodes}
|
||||
{editedLabel}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user