From 47c173f4721854315dbffef2a6a17daa1a62d1e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 23:32:57 +0000 Subject: [PATCH 1/9] Use inline WYSIWYG markdown editor for long text messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/desktop/package.json | 1 + .../src/features/compose/markdown-editor.css | 33 + .../src/features/compose/markdown-editor.tsx | 93 ++ .../src/features/compose/text-editor.tsx | 140 +- js/desktop/yarn.lock | 1186 ++++++++++++++++- js/mobile/package.json | 9 +- .../features/stream-view/TextParticleView.tsx | 49 +- js/mobile/yarn.lock | 48 + 8 files changed, 1413 insertions(+), 146 deletions(-) create mode 100644 js/desktop/src/features/compose/markdown-editor.css create mode 100644 js/desktop/src/features/compose/markdown-editor.tsx diff --git a/js/desktop/package.json b/js/desktop/package.json index c955972..16b732b 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -58,6 +58,7 @@ "dependencies": { "@livekit/components-react": "^2.9.20", "@livekit/components-styles": "^1.2.0", + "@mdxeditor/editor": "^4.0.1", "@sentry/electron": "^7.11.0", "@sentry/react": "^10.54.0", "@tanstack/react-query": "^5.90.21", diff --git a/js/desktop/src/features/compose/markdown-editor.css b/js/desktop/src/features/compose/markdown-editor.css new file mode 100644 index 0000000..9fd6791 --- /dev/null +++ b/js/desktop/src/features/compose/markdown-editor.css @@ -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; +} diff --git a/js/desktop/src/features/compose/markdown-editor.tsx b/js/desktop/src/features/compose/markdown-editor.tsx new file mode 100644 index 0000000..317a907 --- /dev/null +++ b/js/desktop/src/features/compose/markdown-editor.tsx @@ -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(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 ( + + 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(), + ]} + /> + ); +} diff --git a/js/desktop/src/features/compose/text-editor.tsx b/js/desktop/src/features/compose/text-editor.tsx index 900cae1..38db7ab 100644 --- a/js/desktop/src/features/compose/text-editor.tsx +++ b/js/desktop/src/features/compose/text-editor.tsx @@ -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["components"] = { - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - h4: ({ children }) =>

{children}

, - h5: ({ children }) =>
{children}
, - h6: ({ children }) =>
{children}
, - p: ({ children }) =>

{children}

, - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - a: ({ href, children }) => ( - - {children} - - ), - code: ({ className, children, ...props }) => { - const isBlock = className?.startsWith("language-"); - if (isBlock) { - return ( - - {children} - - ); - } - return ( - - {children} - - ); - }, - pre: ({ children }) => ( -
-      {children}
-    
- ), - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - hr: () =>
    , -}; - -function MarkdownPreview({ content }: { content: string }) { - return ( -
    - - {content} - -
    - ); -} - export function TextEditor({ textContent, onTextChange, @@ -112,7 +49,6 @@ export function TextEditor({ attachmentProps, }: TextEditorProps) { const textareaRef = useRef(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} >
    -
    - - -
    - - {previewMode ? ( - - ) : ( -