feat: use tailwindcss-typography as markdown styling

This commit is contained in:
Jiang Feng
2025-04-27 14:19:45 +08:00
parent 741e2f0e62
commit 906df61d81
9 changed files with 140 additions and 220 deletions

View File

@@ -39,7 +39,10 @@ export function Markdown({
}, [animate]); }, [animate]);
return ( return (
<div <div
className={cn(className, "markdown flex flex-col gap-4")} className={cn(
className,
"prose dark:prose-invert prose-p:my-0 prose-img:mt-0 flex flex-col gap-4",
)}
style={style} style={style}
> >
<ReactMarkdown <ReactMarkdown

View File

@@ -316,13 +316,13 @@ function PlanCard({
<Card className={cn("w-full", className)}> <Card className={cn("w-full", className)}>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
<h1 className="text-xl font-medium"> <Markdown animate>
<Markdown animate> {`### ${
{plan.title !== undefined && plan.title !== "" plan.title !== undefined && plan.title !== ""
? plan.title ? plan.title
: "Deep Research"} : "Deep Research"
</Markdown> }`}
</h1> </Markdown>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -1,10 +1,10 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { useRef } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import ReportEditor from "~/components/editor"; import ReportEditor from "~/components/editor";
import { useMessage } from "~/core/store"; import { useMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { LoadingAnimation } from "./loading-animation"; import { LoadingAnimation } from "./loading-animation";
@@ -19,15 +19,45 @@ export function ResearchReportBlock({
messageId: string; messageId: string;
}) { }) {
const message = useMessage(messageId); const message = useMessage(messageId);
const handleMarkdownChange = useCallback(
(markdown: string) => {
if (message) {
message.content = markdown;
useStore.setState({
messages: new Map(useStore.getState().messages).set(
message.id,
message,
),
});
}
},
[message],
);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const isCompleted = message?.isStreaming === false && message?.content !== ""; const isCompleted = message?.isStreaming === false && message?.content !== "";
// TODO: scroll to top when completed, but it's not working
// useEffect(() => {
// if (isCompleted && contentRef.current) {
// setTimeout(() => {
// contentRef
// .current!.closest("[data-radix-scroll-area-viewport]")
// ?.scrollTo({
// top: 0,
// behavior: "smooth",
// });
// }, 500);
// }
// }, [isCompleted]);
return ( return (
<div <div
ref={contentRef} ref={contentRef}
className={cn("relative flex flex-col pb-8", className)} className={cn("relative flex flex-col pt-4 pb-8", className)}
> >
{isCompleted ? ( {isCompleted ? (
<ReportEditor content={message?.content} /> <ReportEditor
content={message?.content}
onMarkdownChange={handleMarkdownChange}
/>
) : ( ) : (
<> <>
<Markdown animate>{message?.content}</Markdown> <Markdown animate>{message?.content}</Markdown>

View File

@@ -99,7 +99,7 @@ export function AISelector({ onOpenChange }: AISelectorProps) {
{hasCompletion && ( {hasCompletion && (
<div className="flex max-h-[400px]"> <div className="flex max-h-[400px]">
<ScrollArea> <ScrollArea>
<div className="prose prose-sm p-2 px-4"> <div className="prose prose-sm dark:prose-invert p-2 px-4">
<Markdown>{completion}</Markdown> <Markdown>{completion}</Markdown>
</div> </div>
</ScrollArea> </ScrollArea>

View File

@@ -38,12 +38,12 @@ const extensions = [...defaultExtensions, slashCommand];
export interface ReportEditorProps { export interface ReportEditorProps {
content: Content; content: Content;
onMarkdownChange?: (markdown: string) => void;
} }
const ReportEditor = ({ content }: ReportEditorProps) => { const ReportEditor = ({ content, onMarkdownChange }: ReportEditorProps) => {
const [initialContent, setInitialContent] = useState<Content>(() => content); const [initialContent, setInitialContent] = useState<Content>(() => content);
const [saveStatus, setSaveStatus] = useState("Saved"); const [saveStatus, setSaveStatus] = useState("Saved");
const [charsCount, setCharsCount] = useState();
const [openNode, setOpenNode] = useState(false); const [openNode, setOpenNode] = useState(false);
const [openColor, setOpenColor] = useState(false); const [openColor, setOpenColor] = useState(false);
@@ -63,17 +63,21 @@ const ReportEditor = ({ content }: ReportEditorProps) => {
const debouncedUpdates = useDebouncedCallback( const debouncedUpdates = useDebouncedCallback(
async (editor: EditorInstance) => { async (editor: EditorInstance) => {
const json = editor.getJSON(); // const json = editor.getJSON();
setCharsCount(editor.storage.characterCount.words()); // // setCharsCount(editor.storage.characterCount.words());
window.localStorage.setItem( // window.localStorage.setItem(
"html-content", // "html-content",
highlightCodeblocks(editor.getHTML()), // highlightCodeblocks(editor.getHTML()),
); // );
window.localStorage.setItem("novel-content", JSON.stringify(json)); // window.localStorage.setItem("novel-content", JSON.stringify(json));
window.localStorage.setItem( // window.localStorage.setItem(
"markdown", // "markdown",
editor.storage.markdown.getMarkdown(), // editor.storage.markdown.getMarkdown(),
); // );
if (onMarkdownChange) {
const markdown = editor.storage.markdown.getMarkdown();
onMarkdownChange(markdown);
}
setSaveStatus("Saved"); setSaveStatus("Saved");
}, },
500, 500,
@@ -89,26 +93,12 @@ const ReportEditor = ({ content }: ReportEditorProps) => {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<div className="absolute top-5 right-5 z-10 mb-5 flex gap-2">
<div className="bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm">
{saveStatus}
</div>
<div
className={
charsCount
? "bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm"
: "hidden"
}
>
{charsCount} Words
</div>
</div>
<EditorRoot> <EditorRoot>
<EditorContent <EditorContent
immediatelyRender={false} immediatelyRender={false}
initialContent={initialContent as JSONContent} initialContent={initialContent as JSONContent}
extensions={extensions} extensions={extensions}
className="border-muted bg-background relative h-full w-full overflow-auto sm:mb-[calc(20vh)] sm:border sm:shadow-lg" className="border-muted relative h-full w-full"
editorProps={{ editorProps={{
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => handleCommandNavigation(event), keydown: (_view, event) => handleCommandNavigation(event),

View File

@@ -1,32 +1,18 @@
import { import {
Brain,
CheckSquare, CheckSquare,
Code, Code,
Heading1, Heading1,
Heading2, Heading2,
Heading3, Heading3,
ImageIcon,
List, List,
ListOrdered, ListOrdered,
MessageSquarePlus,
Text, Text,
TextQuote, TextQuote,
Twitter,
Youtube,
} from "lucide-react"; } from "lucide-react";
import { Command, createSuggestionItems, renderItems } from "novel"; import { Command, createSuggestionItems, renderItems } from "novel";
import { uploadFn } from "./image-upload"; // import { uploadFn } from "./image-upload";
export const suggestionItems = createSuggestionItems([ export const suggestionItems = createSuggestionItems([
{
title: "Send Feedback",
description: "Let us know how we can improve.",
icon: <MessageSquarePlus size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
window.open("/feedback", "_blank");
},
},
{ {
title: "Text", title: "Text",
description: "Just start typing with plain text.", description: "Just start typing with plain text.",
@@ -132,83 +118,83 @@ export const suggestionItems = createSuggestionItems([
command: ({ editor, range }) => command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
}, },
{ // {
title: "Image", // title: "Image",
description: "Upload an image from your computer.", // description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"], // searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />, // icon: <ImageIcon size={18} />,
command: ({ editor, range }) => { // command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); // editor.chain().focus().deleteRange(range).run();
// upload image // // upload image
const input = document.createElement("input"); // const input = document.createElement("input");
input.type = "file"; // input.type = "file";
input.accept = "image/*"; // input.accept = "image/*";
input.onchange = async () => { // input.onchange = async () => {
if (input.files?.length) { // if (input.files?.length) {
const file = input.files[0]; // const file = input.files[0];
if (!file) return; // if (!file) return;
const pos = editor.view.state.selection.from; // const pos = editor.view.state.selection.from;
uploadFn(file, editor.view, pos); // uploadFn(file, editor.view, pos);
} // }
}; // };
input.click(); // input.click();
}, // },
}, // },
{ // {
title: "Youtube", // title: "Youtube",
description: "Embed a Youtube video.", // description: "Embed a Youtube video.",
searchTerms: ["video", "youtube", "embed"], // searchTerms: ["video", "youtube", "embed"],
icon: <Youtube size={18} />, // icon: <Youtube size={18} />,
command: ({ editor, range }) => { // command: ({ editor, range }) => {
const videoLink = prompt("Please enter Youtube Video Link"); // const videoLink = prompt("Please enter Youtube Video Link");
//From https://regexr.com/3dj5t // //From https://regexr.com/3dj5t
const ytRegex = new RegExp( // const ytRegex = new RegExp(
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/, // /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
); // );
if (videoLink && ytRegex.test(videoLink)) { // if (videoLink && ytRegex.test(videoLink)) {
editor // editor
.chain() // .chain()
.focus() // .focus()
.deleteRange(range) // .deleteRange(range)
.setYoutubeVideo({ // .setYoutubeVideo({
src: videoLink, // src: videoLink,
}) // })
.run(); // .run();
} else { // } else {
if (videoLink !== null) { // if (videoLink !== null) {
alert("Please enter a correct Youtube Video Link"); // alert("Please enter a correct Youtube Video Link");
} // }
} // }
}, // },
}, // },
{ // {
title: "Twitter", // title: "Twitter",
description: "Embed a Tweet.", // description: "Embed a Tweet.",
searchTerms: ["twitter", "embed"], // searchTerms: ["twitter", "embed"],
icon: <Twitter size={18} />, // icon: <Twitter size={18} />,
command: ({ editor, range }) => { // command: ({ editor, range }) => {
const tweetLink = prompt("Please enter Twitter Link"); // const tweetLink = prompt("Please enter Twitter Link");
const tweetRegex = new RegExp( // const tweetRegex = new RegExp(
/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/, // /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/,
); // );
if (tweetLink && tweetRegex.test(tweetLink)) { // if (tweetLink && tweetRegex.test(tweetLink)) {
editor // editor
.chain() // .chain()
.focus() // .focus()
.deleteRange(range) // .deleteRange(range)
.setTweet({ // .setTweet({
src: tweetLink, // src: tweetLink,
}) // })
.run(); // .run();
} else { // } else {
if (tweetLink !== null) { // if (tweetLink !== null) {
alert("Please enter a correct Twitter Link"); // alert("Please enter a correct Twitter Link");
} // }
} // }
}, // },
}, // },
]); ]);
export const slashCommand = Command.configure({ export const slashCommand = Command.configure({

View File

@@ -30,7 +30,7 @@ export async function* chatStream(
options: { abortSignal?: AbortSignal } = {}, options: { abortSignal?: AbortSignal } = {},
) { ) {
if (location.search.includes("mock") || location.search.includes("replay=")) { if (location.search.includes("mock") || location.search.includes("replay=")) {
return chatReplayStream(userMessage, params, options); return yield* chatReplayStream(userMessage, params, options);
} }
const stream = fetchStream(resolveServiceURL("chat/stream"), { const stream = fetchStream(resolveServiceURL("chat/stream"), {
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -201,94 +201,6 @@ textarea {
outline: none; outline: none;
} }
.markdown {
line-height: 1.75;
a {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-position: from-font;
text-decoration-color: var(--muted-foreground);
&:hover {
text-decoration: underline;
}
}
h1 {
@apply text-2xl font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h2 {
@apply text-xl font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h3 {
@apply text-lg font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h4 {
@apply text-base font-bold;
margin-bottom: 0.5rem;
}
h5 {
@apply text-sm font-bold;
}
h6 {
@apply text-xs font-bold;
}
ul {
@apply list-disc pl-4;
}
ol {
@apply list-decimal pl-4;
}
img {
display: block;
max-width: 100%;
margin: 0 auto;
}
table {
@apply w-full;
table-layout: fixed;
border-collapse: collapse;
th,
td {
padding: 4px 8px;
border: 1px solid var(--border);
}
th {
@apply bg-muted;
}
}
code:not([class*="language-"]) {
@apply bg-muted;
@apply rounded-md;
@apply px-1;
@apply py-0.5;
@apply border;
}
blockquote {
@apply border-muted-foreground text-muted-foreground border-l-2 pl-2;
}
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;

View File

@@ -1,7 +1,6 @@
@import "./globals.css"; @import "./globals.css";
.ProseMirror { .ProseMirror {
@apply p-12 px-8 sm:px-12;
} }
.ProseMirror .is-editor-empty:first-child::before { .ProseMirror .is-editor-empty:first-child::before {