feat: support markdown formatting and rendering
Resolves #110 Goes the extra mile and supports markdown for sending code and formatted text, almost document-like. It feels more like GitHub's comments and text areas.
This commit is contained in:
@@ -56,14 +56,18 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"firebase": "^12.10.0",
|
"firebase": "^12.10.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"livekit-client": "^2.18.0",
|
"livekit-client": "^2.18.0",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"react-use": "^17.6.0",
|
"react-use": "^17.6.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^3.8.5",
|
"shadcn": "^3.8.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
|
|||||||
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
||||||
|
|
||||||
interface TextComposeStepProps {
|
interface TextComposeStepProps {
|
||||||
textContent: string;
|
textContent: string;
|
||||||
@@ -31,6 +35,66 @@ function getImmersiveTextStyle(length: number) {
|
|||||||
return { size: "text-2xl", weight: "font-normal" };
|
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 TextComposeStep({
|
export function TextComposeStep({
|
||||||
textContent,
|
textContent,
|
||||||
onTextChange,
|
onTextChange,
|
||||||
@@ -43,6 +107,8 @@ export function TextComposeStep({
|
|||||||
dropZoneProps,
|
dropZoneProps,
|
||||||
}: TextComposeStepProps) {
|
}: TextComposeStepProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [previewMode, setPreviewMode] = useState(false);
|
||||||
|
const [forceCardMode, setForceCardMode] = useState(false);
|
||||||
|
|
||||||
// Debounce URL detection to avoid fetching on every keystroke
|
// Debounce URL detection to avoid fetching on every keystroke
|
||||||
const [debouncedText, setDebouncedText] = useState(textContent);
|
const [debouncedText, setDebouncedText] = useState(textContent);
|
||||||
@@ -52,6 +118,14 @@ export function TextComposeStep({
|
|||||||
}, [textContent]);
|
}, [textContent]);
|
||||||
const linkPreviews = useAllLinkMetadata(debouncedText);
|
const linkPreviews = useAllLinkMetadata(debouncedText);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewMode) {
|
||||||
|
// Small timeout so the textarea is mounted before focusing
|
||||||
|
const t = setTimeout(() => textareaRef.current?.focus(), 0);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [previewMode, forceCardMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -64,13 +138,16 @@ export function TextComposeStep({
|
|||||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (textContent.trim()) onAdvance();
|
if (textContent.trim()) onAdvance();
|
||||||
|
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setForceCardMode(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onCancel, onAdvance, textContent],
|
[onCancel, onAdvance, textContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0;
|
const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0;
|
||||||
const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments;
|
const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
|
||||||
|
|
||||||
const strip = (
|
const strip = (
|
||||||
<AttachmentStrip
|
<AttachmentStrip
|
||||||
@@ -95,6 +172,14 @@ export function TextComposeStep({
|
|||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
next
|
next
|
||||||
</span>
|
</span>
|
||||||
|
{immersive && (
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
⌘+M
|
||||||
|
</kbd>{" "}
|
||||||
|
markdown
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -149,16 +234,49 @@ export function TextComposeStep({
|
|||||||
isDragging && "ring-2 ring-inset ring-white/30",
|
isDragging && "ring-2 ring-inset ring-white/30",
|
||||||
)}
|
)}
|
||||||
{...dropZoneProps}
|
{...dropZoneProps}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div className="mx-8 flex h-[calc(100%-8rem)] w-full flex-col rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
|
<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">
|
||||||
<textarea
|
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
|
||||||
ref={textareaRef}
|
<button
|
||||||
value={textContent}
|
type="button"
|
||||||
onChange={(e) => onTextChange(e.target.value)}
|
onClick={() => setPreviewMode(false)}
|
||||||
onKeyDown={handleKeyDown}
|
className={cn(
|
||||||
placeholder="Type a message..."
|
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||||
className="min-h-0 flex-1 resize-none border-none bg-transparent text-base leading-relaxed text-white placeholder-white/40 outline-none"
|
!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}
|
||||||
|
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 && (
|
{hasEnrichments && (
|
||||||
<div className="shrink-0 border-t border-white/10 pt-3">
|
<div className="shrink-0 border-t border-white/10 pt-3">
|
||||||
{strip}
|
{strip}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
} from "@/components/link-preview-card";
|
} from "@/components/link-preview-card";
|
||||||
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||||
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
import "highlight.js/styles/github-dark.css";
|
||||||
|
|
||||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||||
|
|
||||||
@@ -48,6 +52,70 @@ function getImmersiveTextStyle(length: number) {
|
|||||||
return { size: "text-2xl", weight: "font-normal" };
|
return { size: "text-2xl", weight: "font-normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 overflow-hidden", className)}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeHighlight]}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
@@ -119,8 +187,8 @@ export function TextParticleView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 2: short text, no enrichments — immersive centered display
|
// Mode 2: short plain text, no enrichments — immersive centered display
|
||||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments) {
|
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
|
||||||
const style = getImmersiveTextStyle(content.length);
|
const style = getImmersiveTextStyle(content.length);
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||||
@@ -137,13 +205,11 @@ export function TextParticleView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 3: card layout with enrichments
|
// Mode 3: card layout
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||||
<div className="flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-2xl bg-white/10 p-5 backdrop-blur-md">
|
<div className="flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-2xl bg-white/10 p-5 backdrop-blur-md">
|
||||||
<p className="break-words text-base leading-relaxed text-white select-text cursor-text">
|
<MarkdownContent content={content} className="break-words select-text cursor-text" />
|
||||||
{content}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
||||||
|
|
||||||
|
|||||||
+872
-5
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user