feat: extract text editor and allow editing (#150)
This commit was merged in pull request #150.
This commit is contained in:
@@ -1,14 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
|
||||
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||
import { TextEditor } from "@/features/compose/text-editor";
|
||||
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";
|
||||
|
||||
interface TextComposeStepProps {
|
||||
textContent: string;
|
||||
@@ -27,74 +18,6 @@ interface TextComposeStepProps {
|
||||
};
|
||||
}
|
||||
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function getImmersiveTextStyle(length: number) {
|
||||
if (length < 70) return { size: "text-5xl", weight: "font-semibold" };
|
||||
if (length < 130) return { size: "text-3xl", weight: "font-semibold" };
|
||||
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({
|
||||
textContent,
|
||||
onTextChange,
|
||||
@@ -106,190 +29,20 @@ export function TextComposeStep({
|
||||
isDragging,
|
||||
dropZoneProps,
|
||||
}: TextComposeStepProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [forceCardMode, setForceCardMode] = useState(false);
|
||||
|
||||
// Debounce URL detection to avoid fetching on every keystroke
|
||||
const [debouncedText, setDebouncedText] = useState(textContent);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedText(textContent), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [textContent]);
|
||||
const linkPreviews = useAllLinkMetadata(debouncedText);
|
||||
|
||||
const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0;
|
||||
const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewMode) {
|
||||
// Small timeout so the textarea is mounted before focusing
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (textContent.trim()) onAdvance();
|
||||
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setForceCardMode(true);
|
||||
}
|
||||
},
|
||||
[onCancel, onAdvance, textContent],
|
||||
);
|
||||
|
||||
const strip = (
|
||||
<AttachmentStrip
|
||||
attachments={attachments}
|
||||
onRemove={onRemoveAttachment}
|
||||
onAddClick={onAddFiles}
|
||||
linkPreviews={linkPreviews}
|
||||
return (
|
||||
<TextEditor
|
||||
textContent={textContent}
|
||||
onTextChange={onTextChange}
|
||||
onSubmit={onAdvance}
|
||||
onCancel={onCancel}
|
||||
submitHint="next"
|
||||
attachmentProps={{
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
onAddFiles,
|
||||
isDragging,
|
||||
dropZoneProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const keyboardHints = (
|
||||
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
cancel
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
⌘+Enter
|
||||
</kbd>{" "}
|
||||
next
|
||||
</span>
|
||||
{immersive && (
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
⌘+M
|
||||
</kbd>{" "}
|
||||
markdown
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddFiles();
|
||||
}}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
attach
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (immersive) {
|
||||
const style = getImmersiveTextStyle(textContent.length);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textContent}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
className={cn(
|
||||
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
{keyboardHints}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
onKeyDown={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>
|
||||
|
||||
{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 && (
|
||||
<div className="shrink-0 border-t border-white/10 pt-3">
|
||||
{strip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{keyboardHints}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
export interface TextEditorAttachmentProps {
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onAddFiles: () => void;
|
||||
isDragging: boolean;
|
||||
dropZoneProps: {
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
textContent: string;
|
||||
onTextChange: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
/** Label on the ⌘+Enter hint. Defaults to "next". */
|
||||
submitHint?: string;
|
||||
/** When omitted, the editor renders without attachment support (no attach button, no drop zone, no strip). */
|
||||
attachmentProps?: TextEditorAttachmentProps;
|
||||
}
|
||||
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function getImmersiveTextStyle(length: number) {
|
||||
if (length < 70) return { size: "text-5xl", weight: "font-semibold" };
|
||||
if (length < 130) return { size: "text-3xl", weight: "font-semibold" };
|
||||
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,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitHint = "next",
|
||||
attachmentProps,
|
||||
}: TextEditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [forceCardMode, setForceCardMode] = useState(false);
|
||||
|
||||
const [debouncedText, setDebouncedText] = useState(textContent);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedText(textContent), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [textContent]);
|
||||
const linkPreviews = useAllLinkMetadata(debouncedText);
|
||||
|
||||
const attachmentCount = attachmentProps?.attachments.length ?? 0;
|
||||
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
|
||||
const immersive =
|
||||
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (textContent.trim()) onSubmit();
|
||||
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setForceCardMode(true);
|
||||
}
|
||||
},
|
||||
[onCancel, onSubmit, textContent],
|
||||
);
|
||||
|
||||
const strip = attachmentProps && (
|
||||
<AttachmentStrip
|
||||
attachments={attachmentProps.attachments}
|
||||
onRemove={attachmentProps.onRemoveAttachment}
|
||||
onAddClick={attachmentProps.onAddFiles}
|
||||
linkPreviews={linkPreviews}
|
||||
/>
|
||||
);
|
||||
|
||||
const keyboardHints = (
|
||||
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
cancel
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
⌘+Enter
|
||||
</kbd>{" "}
|
||||
{submitHint}
|
||||
</span>
|
||||
{immersive && (
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
⌘+M
|
||||
</kbd>{" "}
|
||||
markdown
|
||||
</span>
|
||||
)}
|
||||
{attachmentProps && (
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
attachmentProps.onAddFiles();
|
||||
}}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
attach
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const dropZoneProps = attachmentProps?.dropZoneProps;
|
||||
const isDragging = attachmentProps?.isDragging ?? false;
|
||||
|
||||
if (immersive) {
|
||||
const style = getImmersiveTextStyle(textContent.length);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textContent}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
className={cn(
|
||||
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
{keyboardHints}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
onKeyDown={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>
|
||||
|
||||
{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">
|
||||
{strip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{keyboardHints}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user