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:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
|
||||
@@ -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])));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user